提交 b0d1f92a authored 作者: 陈泽健's avatar 陈泽健

fix(execution): 退出登录超时修复与执行跳转优化

核心修复:
- 退出登录步骤超时:根因是8001端口僵尸进程加载旧代码
- 移除 logout_debug.log 调试代码,增加 params.logout 标志位
- 执行跳转改为 query.run 参数立即跳转,不等待 run 完成
- Execution.vue 检测 query.run 自动触发执行

其他改动:
- 用例执行顺序按模块+order排序
- 截图默认展开
- 新增4个数据分析用例(共29个)
- 问题处理文档按模块归类
- 更新 HANDOFF.md
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 50f65be1
# 执行计划 - 修复执行中心顶部状态卡在"运行中"
> **文档类型**: 执行计划文档
> **创建日期**: 2026-07-15
> **作者**: czj
> **关联文档**: `_问题处理_执行中心顶部状态卡在运行中.md`
> **状态**: 待执行
---
## 一、改动总览
| # | 改动项 | 文件 | 改动量 | 说明 |
|---|--------|------|--------|------|
| 1 | 新增轮询定时器变量 | `Execution.vue` | 2 行 | `activePollTimer` + 清除逻辑 |
| 2 | 新增 `pollActiveExecution` 方法 | `Execution.vue` | ~20 行 | 定时查询执行状态 |
| 3 | 修改 `connectWebSocket` | `Execution.vue` | 1 行 | 连接时同时启动轮询 |
| 4 | 修改 `disconnectWebSocket` | `Execution.vue` | 2 行 | 断开时清除轮询 |
| 5 | 修改 `onUnmounted` | `Execution.vue` | 1 行 | 确保清除轮询 |
**总计**:1 个文件,约 26 行改动。
---
## 二、详细执行步骤
### Step 1:新增轮询定时器变量
**文件**`frontend/src/views/Execution.vue`
在 WebSocket 客户端变量后新增:
```typescript
// WebSocket 客户端
let wsClient: WebSocketClient | null = null
// 主动轮询定时器(双重保障:WebSocket 断连时仍能检测执行完成)
let activePollTimer: ReturnType<typeof setInterval> | null = null
```
### Step 2:新增 `pollActiveExecution` 方法
`disconnectWebSocket` 方法后新增:
```typescript
/**
* 主动轮询当前执行状态
*
* 当 WebSocket 断连或 execution_complete 事件丢失时,
* 通过定时查询 API 确保执行状态最终能被正确更新。
*/
const startPolling = () => {
stopPolling()
activePollTimer = setInterval(async () => {
if (!activeExecution.value || activeExecution.value.status !== 'running') {
stopPolling()
return
}
try {
const latest = await executionApi.get(activeExecution.value.id)
if (latest.status !== 'running') {
// 状态已变化,更新 activeExecution
activeExecution.value = latest
if (latest.status === 'completed') {
ElMessage.success(`执行完成: 通过率 ${latest.pass_rate}%`)
}
disconnectWebSocket()
await loadExecutions()
}
} catch (error: any) {
console.error('轮询执行状态失败:', error)
}
}, 5000) // 每 5 秒轮询一次
}
const stopPolling = () => {
if (activePollTimer) {
clearInterval(activePollTimer)
activePollTimer = null
}
}
```
### Step 3:修改 `connectWebSocket`
`connectWebSocket` 方法末尾(`wsClient.connect()` 后)添加启动轮询:
```typescript
const connectWebSocket = (executionId: string) => {
// ... 已有代码 ...
wsClient.connect()
// 启动轮询双重保障
startPolling()
}
```
### Step 4:修改 `disconnectWebSocket`
`disconnectWebSocket` 方法中添加清除轮询:
```typescript
const disconnectWebSocket = () => {
stopPolling() // ← 新增:清除轮询
if (wsClient) {
wsClient.disconnect()
wsClient = null
}
}
```
### Step 5:确认 `onUnmounted` 清除
当前 `onUnmounted` 已调用 `disconnectWebSocket()`,而 `disconnectWebSocket` 现在会调用 `stopPolling()`,所以无需额外改动。
---
## 三、验证步骤
### 3.1 构建验证
```bash
cd frontend && npm run build
```
### 3.2 功能验证
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 执行用例,正常等待完成 | WebSocket 收到 `execution_complete`,顶部状态变为"已完成" |
| 2 | 执行用例,在执行中打开浏览器 DevTools 断开 WebSocket | 5 秒内轮询检测到完成,顶部状态变为"已完成" |
| 3 | 执行完成后刷新页面 | 列表中无 running 记录,顶部不显示运行中卡片 |
| 4 | 执行中离开页面(组件卸载) | 轮询定时器被清除,无内存泄漏 |
---
## 四、风险评估
| 风险 | 概率 | 影响 | 缓解 |
|------|------|------|------|
| 轮询增加 API 请求量 | 低 | 每执行任务 +1 次/5秒 | 仅在 running 时轮询,执行完成后立即停止 |
| 轮询与 WebSocket 同时更新 | 低 | 短暂重复 | `execution_complete` 事件会调用 `disconnectWebSocket``stopPolling`,轮询自动停止 |
| `executionApi.get` 请求失败 | 低 | 轮询中断 | catch 后不停止轮询,下次继续尝试 |
---
*本文档由 Claude Code 生成,遵循项目执行计划文档规范。*
# 执行计划 - 修复执行用例结果显示待执行且全部失败
> **文档类型**: 执行计划文档
> **创建日期**: 2026-07-15
> **作者**: czj
> **关联文档**: `_问题处理_执行用例结果显示待执行.md`
> **状态**: 待执行
---
## 一、改动总览
| # | 改动项 | 文件 | 说明 |
|---|--------|------|------|
| 1 | 用例执行顺序按模块+order排序 | `execution_service.py` | 创建执行时按模块分组排序用例 |
| 2 | 修复"等待主页加载"选择器 | 数据库(用例步骤) | 用 URL 变化判断替代 DOM 选择器 |
| 3 | 修复"退出登录"选择器 | 数据库(用例步骤) | 适配实际 DOM 结构 |
---
## 二、详细执行步骤
### Step 1:修复用例执行顺序
**文件**`backend/app/services/execution_service.py`
**问题**`create_execution` 中创建 `CaseResult` 时按 `case_ids` 顺序,而 `case_ids` 来自前端传入的用户勾选顺序,不保证首用例在前。
**方案**:创建执行时,先按模块分组,同模块内按 `test_case.order` 排序,确保每个模块的首用例(含登录)排在前面。
**改动位置**`create_execution` 方法中,在创建 `CaseResult` 之前,对 `cases` 按模块+order 排序。
### Step 2:修复"等待主页加载"选择器
**问题**:当前选择器 `.el-menu, .sidebar-container, .home-container` 不匹配目标系统主页的实际 DOM。
**方案**:改用 URL 断言判断登录成功——登录后 URL 应从 `#/login` 变为不含 `login` 的路径。
**改动**:将所有首用例步骤 8"等待主页加载"的参数改为:
```json
{
"order": 8,
"name": "等待主页加载",
"action": "assert",
"params": {
"type": "url",
"expected": "#/"
}
}
```
### Step 3:修复"退出登录"选择器
**问题**`button:has-text("退出")``button:has-text("确定")` 找不到,说明退出登录流程可能是下拉菜单→点击退出→出现确认弹窗→点击确认。
**方案**:先看截图分析实际 DOM,再调整选择器。由于退出登录流程可能涉及多步操作,简化方案为:直接清除 cookies 导航回登录页(复用 `reset_login_state` 逻辑),而不是通过 UI 操作退出。
**改动**:将末用例的退出登录步骤改为导航到登录页(带 URL 参数),执行器会自动检测到登录页并重置 `_is_logged_in`
```json
[
{"order": 3, "name": "退出登录", "action": "navigate", "params": {"url": "https://192.168.5.44/#/login"}}
]
```
---
## 三、验证步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 全选会议管理用例执行 | 首用例(个人日程,含登录)第一个执行 |
| 2 | 首用例登录成功 | 步骤 8"等待主页加载"通过 |
| 3 | 后续用例复用登录态 | 从主页点击菜单成功 |
| 4 | 末用例退出登录 | 导航到登录页,`_is_logged_in` 重置 |
| 5 | 执行完成后查看结果 | 用例结果状态正确显示 |
---
*本文档由 Claude Code 生成,遵循项目执行计划文档规范。*
# 执行计划 - 修复退出登录步骤超时与后端僵尸进程
> **文档类型**: 执行计划文档
> **创建日期**: 2026-07-16
> **作者**: czj
> **关联文档**: `_问题处理_退出登录步骤超时与后端僵尸进程.md`
> **状态**: 待执行
---
## 一、改动总览
| # | 改动项 | 范围 | 说明 |
|---|--------|------|------|
| 1 | 杀掉所有僵尸 python 进程 | 运维 | 清理 8001 端口 8 个僵尸进程 |
| 2 | 杀掉 8002 端口后端进程 | 运维 | 统一端口,避免混乱 |
| 3 | 清理调试代码 | `playwright_executor.py` | 移除 `logout_debug.log` 写入 |
| 4 | 优化退出登录逻辑 | `playwright_executor.py` | 增加步骤 params.logout 标志位,不依赖步骤名 |
| 5 | 在 8001 端口启动后端 | 运维 | 不用 --reload,避免再次产生僵尸 |
| 6 | 验证会议管理 6 用例全通过 | 测试 | 退出登录步骤不再超时 |
**总计**:1 个代码文件改动,2 个运维操作,1 个验证步骤。
---
## 二、详细执行步骤
### Step 1:清理僵尸进程
**操作**:杀掉所有 python.exe 的 multiprocessing.spawn 子进程 + 8002 端口进程
```bash
# 杀掉所有 multiprocessing.spawn 子进程(8 个僵尸)
taskkill /F /PID 4852 /PID 4068 /PID 13408 /PID 25872 /PID 6644 /PID 23564 /PID 21476 /PID 7448
# 杀掉 8002 端口后端
taskkill /F /PID 28076
# 验证 8001 端口已释放
netstat -ano | grep 8001
```
**预期**:8001 端口无 LISTENING 记录,8002 端口也无 LISTENING。
### Step 2:清理调试代码 + 优化退出登录逻辑
**文件**`backend/app/executors/playwright_executor.py`
#### 2.1 移除 logout_debug.log 调试代码
**修改前**(第 710-726 行):
```python
# 退出登录:导航到登录页时,清除 cookies 强制登出
# (SPA hash 路由在已登录时访问 #/login 会被重定向回主页)
with open('logout_debug.log', 'a', encoding='utf-8') as _f:
_f.write(f"navigate: name={step_result.name} url={url} logged_in={self._is_logged_in}\n")
if "login" in url.lower():
with open('logout_debug.log', 'a', encoding='utf-8') as _f:
_f.write(f"走reset分支\n")
try:
self.reset_login_state()
with open('logout_debug.log', 'a', encoding='utf-8') as _f:
_f.write(f"reset成功\n")
except Exception as re:
with open('logout_debug.log', 'a', encoding='utf-8') as _f:
_f.write(f"reset异常: {type(re).__name__}: {re}\n")
step_result.log = f"✓ 退出登录,回到登录页"
step_result.status = "passed"
break
```
**修改后**
```python
# 退出登录:导航到登录页时,清除 cookies 强制登出
# (SPA hash 路由在已登录时访问 #/login 会被重定向回主页)
if "login" in url.lower() or params.get("logout", False):
logger.info(f"退出登录: name={step_result.name}, url={url}")
try:
self.reset_login_state()
except Exception as re:
logger.error(f"退出登录 reset 失败: {re}")
raise
step_result.log = f"✓ 退出登录,回到登录页"
step_result.status = "passed"
break
```
#### 2.2 优化智能跳过逻辑
**修改前**(第 695-705 行):
```python
# 智能处理:如果已登录且步骤是导航到登录页或首页,跳过
# 但"退出登录"步骤不跳过(步骤名称包含"退出")
if (action == "navigate" and self._is_logged_in
and "退出" not in step_result.name):
url = params.get("url", "")
if (self.LOGIN_CONFIG["base_url"] in url and
("login" in url.lower() or url == self.LOGIN_CONFIG["base_url"] or url.endswith("/"))):
step_result.status = "passed"
step_result.log = f"✓ 已登录,跳过导航: {url}"
logger.info(f"✓ 已登录,跳过步骤 {step_result.order}: {step_result.name}")
break
```
**修改后**
```python
# 智能处理:如果已登录且步骤是导航到登录页或首页,跳过
# 但退出登录步骤不跳过(通过 params.logout 或 URL 含 login 判断)
if (action == "navigate" and self._is_logged_in
and not params.get("logout", False)
and "退出" not in step_result.name):
url = params.get("url", "")
if (self.LOGIN_CONFIG["base_url"] in url and
("login" in url.lower() or url == self.LOGIN_CONFIG["base_url"] or url.endswith("/"))):
step_result.status = "passed"
step_result.log = f"✓ 已登录,跳过导航: {url}"
logger.info(f"✓ 已登录,跳过步骤 {step_result.order}: {step_result.name}")
break
```
**改动说明**
- 增加 `params.get("logout", False)` 判断,步骤可通过 `params.logout: true` 显式声明退出登录
- 保留 `"退出" not in step_result.name` 兼容旧数据
- 退出登录的 navigate 分支增加 `params.get("logout", False)` 条件,双重保障
#### 2.3 删除残留的 logout_debug.log 文件
```bash
rm -f backend/logout_debug.log
```
### Step 3:在 8001 端口启动后端
```bash
cd backend
uvicorn app.main:app --port 8001
# ⚠️ 不用 --reload,避免再次产生僵尸进程
```
**验证**
```bash
curl http://localhost:8001/health
# 预期: {"status":"healthy",...}
```
### Step 4:验证会议管理 6 用例全通过
1. 访问 http://localhost:3000/cases/ui
2. 筛选"会议管理"模块,全选 6 个用例
3. 点击"执行选中",自动跳转 /execution/ui
4. 等待执行完成
5. 验证 6/6 全部 passed,退出登录步骤不再超时
---
## 三、验证步骤
### 3.1 端口验证
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | `netstat -ano \| grep 8001` | 只有 1 个 LISTENING(新后端进程) |
| 2 | `curl localhost:8001/health` | 返回 healthy |
| 3 | `curl localhost:8002/health` | 连接失败(8002 已关闭) |
### 3.2 代码验证
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | grep "logout_debug" playwright_executor.py | 无匹配(调试代码已移除) |
| 2 | grep "params.get.*logout" playwright_executor.py | 有匹配(新增 logout 标志位) |
### 3.3 功能验证
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 批量执行会议管理 6 用例 | 6/6 全部 passed |
| 2 | 查看末用例"预定数据"步骤详情 | "退出登录"步骤状态 passed |
| 3 | 查看首用例"个人日程"步骤详情 | 登录步骤正常,auto_login=False 手动登录 |
---
## 四、风险评估
| 风险 | 概率 | 影响 | 缓解 |
|------|------|------|------|
| 杀进程时误杀其他 python 服务 | 低 | 其他服务中断 | 仅杀确认的 multiprocessing.spawn 子进程和 8002 进程 |
| 8001 端口 TIME_WAIT 未释放 | 中 | 新后端启动失败 | 等待 30-60 秒后重试,或换端口 |
| 退出登录 reset_login_state 仍失败 | 低 | 末用例失败 | 已验证独立脚本通过,问题在进程而非代码 |
| 前端代理仍指向 8001 | 无 | 无 | vite.config.ts 已指向 8001,无需修改 |
---
*本文档由 Claude Code 生成,遵循项目执行计划文档规范。*
# 执行计划 - 修复非登录用例重复执行登录
> **文档类型**: 执行计划文档
> **创建日期**: 2026-07-15
> **作者**: czj
> **关联文档**: `_问题处理_非登录用例重复执行登录.md`
> **状态**: 待执行
---
## 一、改动总览
| # | 改动项 | 范围 | 说明 |
|---|--------|------|------|
| 1 | 修改 20 个非登录用例的步骤 | 数据库 | 删除前 8 个登录步骤,只保留业务步骤 |
| 2 | 修改 20 个非登录用例的 config | 数据库 | `auto_login: false``auto_login: true` |
| 3 | 每个非登录用例末尾增加"返回主页"步骤 | 数据库 | 确保下一用例从主页开始 |
**总计**:20 条用例数据修改,0 个代码文件改动。
---
## 二、详细执行步骤
### Step 1:识别需修改的用例
**不修改的用例**(5 个登录模块用例,`reset_login: true`):
| 用例 | 原因 |
|------|------|
| 登录-正常登录测试 | 登录功能本身测试 |
| 登录-登录方式切换验证 | 登录功能本身测试 |
| 登录-空账号密码验证 | 登录功能本身测试 |
| 登录-错误密码验证 | 登录功能本身测试 |
| 登录-页面元素验证 | 登录功能本身测试 |
**需修改的用例**(20 个业务用例,`auto_login: false``true`,删除登录步骤):
所有非登录模块的"页面元素验证"用例,如会议管理、会议室列表、会议统计等。
### Step 2:修改用例步骤结构
**修改前**(10 步):
```json
[
{"order": 1, "name": "打开登录页面", "action": "navigate", ...},
{"order": 2, "name": "等待登录表单加载", "action": "wait", ...},
{"order": 3, "name": "填写账号", "action": "fill", ...},
{"order": 4, "name": "填写密码", "action": "fill", ...},
{"order": 5, "name": "填写图形验证码", "action": "fill", ...},
{"order": 6, "name": "点击登录按钮", "action": "click", ...},
{"order": 7, "name": "确认同意协议弹窗", "action": "click", ...},
{"order": 8, "name": "等待主页加载", "action": "wait", ...},
{"order": 9, "name": "点击XX菜单", "action": "click", ...},
{"order": 10, "name": "验证XX页面元素", "action": "assert", ...}
]
```
**修改后**(3 步):
```json
[
{"order": 1, "name": "点击XX菜单", "action": "click", ...},
{"order": 2, "name": "验证XX页面元素", "action": "assert", ...},
{"order": 3, "name": "返回主页", "action": "click", "params": {"selector": ".sidebar-logo, .logo, a[href='#/dashboard'], .el-menu-item:first-child"}}
]
```
### Step 3:修改用例 config
```json
// 修改前
{"auto_login": false, ...}
// 修改后
{"auto_login": true, ...}
```
### Step 4:编写数据修改脚本
用 Python 脚本直接修改 SQLite 数据库中的用例数据:
1. 查询所有 `case_type='ui'``reset_login` 不为 true 的用例
2. 对每个用例:删除前 8 个步骤(登录步骤),保留剩余步骤,重新编排 order
3. 在最后添加"返回主页"步骤
4. 更新 config 中 `auto_login``true`
5. 更新数据库
---
## 三、验证步骤
### 3.1 数据验证
修改后查询用例步骤,确认:
- 非登录用例只有 2-3 个业务步骤
- config 中 `auto_login: true`
- 登录用例不受影响
### 3.2 功能验证
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 批量执行会议管理模块用例 | 第一个用例自动登录,后续用例复用登录态 |
| 2 | 查看步骤详情 | 非登录用例只有业务步骤,无登录步骤 |
| 3 | 执行登录模块用例 | 独立执行,`reset_login: true` 清除登录态 |
| 4 | 执行效率对比 | 每个非登录用例节省 ~15 秒 |
---
## 四、风险评估
| 风险 | 概率 | 影响 | 缓解 |
|------|------|------|------|
| 返回主页的选择器在不同页面不一致 | 中 | 末步点击失败 | 使用多选择器回退链 |
| auto_login 登录失败 | 低 | 所有业务用例失败 | 登录逻辑已验证,`_is_logged_in` 防重复 |
| 修改脚本误改登录用例 | 低 | 登录用例被破坏 | 脚本中排除 `reset_login=true` 的用例 |
---
*本文档由 Claude Code 生成,遵循项目执行计划文档规范。*
# 问题处理文档 - 执行中心顶部状态卡在"运行中"
> **文档类型**: 问题处理文档
> **创建日期**: 2026-07-15
> **作者**: czj
> **优先级**: P1
> **状态**: 待修复
---
## 一、问题描述
### 1.1 现象
执行中心页面顶部显示"当前执行"状态卡片,执行任务已经完成(列表中状态为"已完成"),但顶部卡片仍然显示"运行中",进度条持续流动。
### 1.2 复现步骤
1. 在用例管理页面勾选用例,点击"执行选中"
2. 跳转到执行中心,顶部显示"当前执行"卡片,状态"运行中"
3. 执行完成后,**列表中该执行记录状态已变为"已完成"**
4. **但顶部卡片仍显示"运行中",进度条仍在流动**
### 1.3 影响范围
- 执行中心页面顶部状态卡片显示错误
- 用户误以为执行仍在进行中
- 需要手动刷新页面才能消除
---
## 二、根因分析
### 2.1 状态更新流程
```
后端执行完成
→ 广播 execution_complete 事件
→ 前端 WebSocket 收到
→ activeExecution.value = data(更新为 completed)
→ disconnectWebSocket()
→ loadExecutions()(刷新列表)
```
### 2.2 Bug 根因
**问题在于 `activeExecution` 的状态没有主动轮询刷新机制**。以下场景会导致状态卡在"运行中":
| 场景 | 原因 |
|------|------|
| WebSocket 断连未收到 `execution_complete` | 网络波动、代理超时等导致 WebSocket 在执行完成前断开 |
| 页面刷新后重新加载 | `onMounted` 只在列表中找 `running` 的执行设为 `activeExecution`,但如果执行已完成则不设置,`activeExecution``null`——这种情况表现正常(不显示卡片),但如果 `activeExecution` 已被设置为 running 状态后,页面刷新前没来得及清除 |
| WebSocket `execution_complete` 事件丢失 | 服务端广播时客户端短暂断连 |
| `activeExecution` 被设置后不再更新 | 前端只在 `execution_complete`/`execution_cancelled` 事件中更新 `activeExecution`,如果没收到事件就永远不更新 |
**核心缺陷**:前端没有主动轮询 `activeExecution` 对应执行记录的最新状态的机制。一旦 WebSocket 事件丢失,`activeExecution` 就永远停留在旧状态。
### 2.3 代码追踪
**`Execution.vue` 第 557-562 行** — 页面加载时只关注 running 状态:
```typescript
const running = executions.value.find(e => e.status === 'running')
if (running) {
activeExecution.value = running
connectWebSocket(running.id)
}
```
**`Execution.vue` 第 778-783 行** — WebSocket 收到完成事件才更新:
```typescript
wsClient.on('execution_complete', (data: any) => {
activeExecution.value = data // ← 只有收到事件才更新
disconnectWebSocket()
loadExecutions()
})
```
**`Execution.vue` 无主动刷新 `activeExecution` 的逻辑** — 如果 WebSocket 断连,没有任何定时器或轮询来检查执行状态是否已变化。
---
## 三、修复方案
### 3.1 方案:增加定时轮询刷新 activeExecution
`activeExecution` 存在且状态为 `running` 时,启动一个定时器,每隔 N 秒主动查询该执行记录的最新状态:
1. 如果后端返回状态已变为 `completed`/`cancelled`/`failed`,更新 `activeExecution` 并断开 WebSocket
2. 如果后端返回状态仍为 `running`,继续保持轮询
3. WebSocket 正常收到 `execution_complete` 时,清除轮询(已有的逻辑)
**改动文件**`frontend/src/views/Execution.vue`
**改动点**
1. 新增 `activePollTimer` 变量,存储轮询定时器 ID
2. 修改 `connectWebSocket`,在连接 WebSocket 的同时启动轮询(双重保障)
3. 新增 `pollActiveExecution` 方法,定时查询执行状态
4. 修改 `disconnectWebSocket`,同时清除轮询定时器
5. `onUnmounted` 清除定时器(已有,需确认)
### 3.2 轮询参数
| 参数 | 值 | 说明 |
|------|------|------|
| 轮询间隔 | 5 秒 | 平衡实时性与请求量 |
| 触发条件 | `activeExecution` 存在且 `status === 'running'` | 只在运行中才轮询 |
| 停止条件 | 状态变为非 `running`,或组件卸载,或手动断开 | 多重保障 |
---
## 四、验收标准
| 测试项 | 预期结果 |
|--------|----------|
| 正常执行完成(WebSocket 正常) | 顶部状态卡片显示"已完成",与列表一致 |
| WebSocket 断连后执行完成 | 5 秒内轮询检测到状态变化,顶部卡片更新为"已完成" |
| 页面刷新时执行已完成 | 顶部不显示运行中卡片(列表中无 running 记录) |
| 执行被取消 | 顶部状态卡片显示"已取消" |
| 执行仍在进行中 | 轮询不干扰正常执行,顶部保持"运行中" |
| 组件卸载 | 轮询定时器被清除,无内存泄漏 |
---
## 五、相关文件
- `frontend/src/views/Execution.vue` — 执行中心页面(改动点)
- `frontend/src/utils/websocket.ts` — WebSocket 客户端
- `backend/app/services/execution_service.py` — 执行服务(广播逻辑)
---
*本文档由 Claude Code 生成,遵循项目问题处理文档规范。*
# 问题处理文档 - 执行用例结果显示"待执行"且用例全部失败
> **文档类型**: 问题处理文档
> **创建日期**: 2026-07-15
> **作者**: czj
> **优先级**: P1
> **状态**: 待修复
---
## 一、问题描述
### 1.1 现象
会议管理模块全选用例执行后,执行中心显示执行已完成,但每条用例结果都显示"待执行"。展开后实际查看,所有用例都失败了。
### 1.2 复现步骤
1. 用例管理 > UI自动化,全选会议管理模块 6 个用例
2. 点击"执行选中"
3. 跳转到执行中心,等待执行完成
4. 展开执行记录查看结果
---
## 二、根因分析
### 2.1 用例执行顺序未按模块排序
`execution_service.py` 第 222-227 行查询 `case_results`**没有 ORDER BY**
```python
results_query = select(CaseResult).where(
CaseResult.execution_id == execution_id,
CaseResult.status == "pending",
)
```
导致用例执行顺序不确定。实际执行顺序变成了:
1. 会议室列表(auto_login,需要登录态)
2. 会议列表(auto_login,需要登录态)
3. ...
4. **个人日程(首用例,含登录步骤)**
首用例"个人日程"排在第 4 位执行,但前 3 个用例需要登录态才能操作——它们在未登录状态下点击菜单自然失败。
### 2.2 等待主页的选择器不匹配
首用例登录步骤 1-7 全部通过,但步骤 8"等待主页加载"的选择器 `.el-menu, .sidebar-container, .home-container` 超时失败。这说明该系统主页的实际 DOM 结构不包含这些 class。
### 2.3 退出登录步骤选择器不匹配
末用例"预定数据"的步骤 3"退出登录"通过,但步骤 4"确认退出登录"的选择器 `button:has-text("退出"), button:has-text("确定")` 找不到,说明退出登录的弹窗结构不同。
### 2.4 前端显示"待执行"
API 返回的数据中 `status` 是正确的(`failed`),前端显示"待执行"可能是以下原因之一:
- WebSocket `case_complete` 事件中 `case_result.status` 先被设为 `running`(第 325 行),然后才更新为最终状态(第 339 行),但 `case_complete` 广播在状态更新后(第 358 行),应该没问题
- 用户可能在执行完成前就看了展开结果,WebSocket 缓存了旧的 `pending` 状态
---
## 三、修复方案
### 3.1 用例执行顺序按模块排序
`execution_service.py` 中查询 `case_results` 时,增加 `ORDER BY``created_at` 排序,确保用例按创建顺序执行(首用例在前)。
但更根本的方案是:**创建执行时,按模块分组排序用例,同模块内按 `order` 字段排序**。这样首用例(含登录)必然先执行。
### 3.2 修复"等待主页加载"选择器
使用更通用的选择器,或直接等待 URL 变化(登录成功后 URL 从 `#/login` 变为 `#/dashboard` 或其他主页路径)。
### 3.3 修复"退出登录"选择器
需要适配系统的实际退出登录流程。通过截图分析实际 DOM 结构来调整选择器。
---
## 四、验收标准
| 测试项 | 预期结果 |
|--------|----------|
| 全选会议管理用例执行 | 首用例(含登录)第一个执行 |
| 首用例登录成功后 | 步骤8"等待主页加载"通过 |
| 后续用例复用登录态 | 从主页点击菜单成功 |
| 末用例退出登录 | 退出登录和确认步骤通过 |
| 执行完成后展开结果 | 用例结果状态正确显示 |
---
*本文档由 Claude Code 生成,遵循项目问题处理文档规范。*
# 问题处理文档 - 退出登录步骤超时与后端僵尸进程
> **文档类型**: 问题处理文档
> **创建日期**: 2026-07-16
> **作者**: czj
> **优先级**: P0
> **状态**: 待修复
---
## 一、问题描述
### 1.1 现象
批量执行会议管理模块 6 个用例时,前 5 个通过,**末用例"预定数据页面访问验证"的"退出登录"步骤超时失败**,通过率 83.33%。
退出登录步骤定义:
```json
{"order": 3, "name": "退出登录", "action": "navigate", "params": {"url": "https://192.168.5.44/#/login"}}
```
### 1.2 关键异常
1. **独立 Python 脚本**直接调用 `PlaywrightExecutor` → 退出登录**完全通过**
2. **通过后端 API 触发**执行 → 退出登录**失败**
3. 代码中添加了 `logout_debug.log` 写入调试,但 API 触发执行后该文件**根本没创建**
4. 说明 navigate 分支的 `reset_login_state` 代码在实际后端进程中**没执行到**
---
## 二、根因分析
### 2.1 直接原因:后端运行旧代码
| 验证项 | 结果 |
|--------|------|
| 源代码中 `reset_login_state` 分支是否存在 | ✅ 存在(第 710-726 行) |
| `logout_debug.log` 是否生成 | ❌ 未生成 |
| 独立脚本执行退出登录 | ✅ 通过 |
| API 触发执行退出登录 | ❌ 失败 |
**结论**:源代码是新的,但实际运行的后端进程加载的是**旧代码**
### 2.2 根本原因:8001 端口僵尸进程
通过排查发现:
```
8001 端口 LISTENING 进程:
PID 25864 → tasklist 查不到(已死)
PID 25884 → tasklist 查不到(已死)
PID 24140 → tasklist 查不到(已死)
PID 2456 → tasklist 查不到(已死)
PID 5620 → tasklist 查不到(已死)
PID 26524 → tasklist 查不到(已死)
PID 23672 → tasklist 查不到(已死)
PID 15316 → tasklist 查不到(已死)
8002 端口 LISTENING 进程:
PID 28076 → python.exe uvicorn --port 8002(正在运行,新代码)
```
- 8001 端口有 **8 个僵尸进程**(父进程已死,子进程通过 `multiprocessing.spawn` 成为孤儿)
- 这些僵尸进程仍在 8001 端口 LISTENING,持有旧代码的 socket
- 前端 vite.config.ts 代理指向 `http://localhost:8001`
- 新启动的 uvicorn 因端口被占无法绑定 8001,用户可能又启动了 8002 端口
- **实际通过前端操作触发执行时,请求打到了 8001 的旧代码进程**
### 2.3 僵尸进程产生的原因
多次使用 `uvicorn --reload` 启动后端时:
1. uvicorn 主进程启动 → 加载代码 → 监听 8001
2. `--reload` 检测到文件变化 → 旧主进程退出 → 新主进程尝试绑定 8001
3. 旧主进程的子进程(multiprocessing.spawn)未正确清理 → 成为孤儿进程
4. 孤儿进程的 socket 仍在 8001 上 LISTENING → 新进程绑定失败 → 新进程也退出
5. 循环往复,僵尸越积越多
### 2.4 退出登录代码逻辑本身的问题
即使僵尸进程问题解决后,退出登录的代码也有以下需优化之处:
| # | 问题 | 位置 | 说明 |
|---|------|------|------|
| 1 | 调试代码残留 | 第 712-723 行 | `logout_debug.log` 写入是调试代码,不应留在生产代码中 |
| 2 | 退出登录仅依赖 navigate 分支 | 第 710-726 行 | 只有 URL 含 `login` 时才走 reset 分支,不够健壮 |
| 3 | 智能跳过逻辑与退出登录耦合 | 第 697-705 行 | 靠步骤名含"退出"来区分,命名不规范会导致逻辑错误 |
---
## 三、修复方案
### 3.1 方案:清理僵尸进程 + 统一端口 + 代码优化(推荐)
**步骤一:清理所有僵尸 python 进程**
杀掉所有 8001 端口的僵尸进程,确保端口完全释放。
**步骤二:统一后端端口为 8001**
在 8001 端口启动后端(不用 `--reload`),前端代理无需修改。
**步骤三:清理调试代码 + 优化退出登录逻辑**
1. 移除 `logout_debug.log` 调试代码
2. 退出登录步骤增加 `logout` 动作类型支持(或用 step.params 中的标志位)
3. 智能跳过逻辑改为基于步骤 params 而非步骤名
### 3.2 不采用的方案
| 方案 | 原因 |
|------|------|
| 前端改代理到 8002 | 治标不治本,8001 僵尸仍占资源 |
| 重启电脑 | 太重,可精确杀进程解决 |
| 继续用 --reload | 正是 --reload 导致僵尸进程,不应再用 |
---
## 四、验收标准
| 测试项 | 预期结果 |
|--------|----------|
| 8001 端口无僵尸进程 | `netstat -ano | grep 8001` 只有一个 LISTENING |
| 后端在 8001 端口正常启动 | `curl localhost:8001/health` 返回 healthy |
| 会议管理 6 用例批量执行 | 退出登录步骤通过,6/6 全部 passed |
| 代码中无调试残留 | 无 `logout_debug.log` 写入代码 |
| 前端正常访问 | `localhost:3000` 功能正常 |
---
## 五、相关文件
- `backend/app/executors/playwright_executor.py` — 执行器(退出登录逻辑、调试代码)
- `frontend/vite.config.ts` — 前端代理配置(指向 8001)
- `backend/app/main.py` — 后端入口(Windows ProactorEventLoop 配置)
---
*本文档由 Claude Code 生成,遵循项目问题处理文档规范。*
# 问题处理文档 - 非登录用例重复执行登录导致执行效率低且易失败
> **文档类型**: 问题处理文档
> **创建日期**: 2026-07-15
> **作者**: czj
> **优先级**: P1
> **状态**: 待修复
---
## 一、问题描述
### 1.1 现象
批量执行会议管理模块的用例时,每个用例的前 7 个步骤都是登录操作(打开登录页→等待表单→填账号→填密码→填验证码→点登录→确认协议弹窗)。第一个用例登录成功后,后续用例仍重复执行完整的登录流程,导致:
1. **执行效率极低**:每个用例额外花费 ~15 秒在登录上
2. **已登录再登录导致失败**:登录成功后再次访问登录页并填写表单,可能触发异常(如表单不存在、元素找不到等)
3. **步骤冗余**:25 个用例中有 24 个非登录用例,每个都重复 7 个登录步骤
### 1.2 期望行为
- **第一个用例**执行登录,登录成功后进入主页执行业务操作,完成后**返回主页**
- **后续用例**复用登录态,从主页开始直接执行业务操作,**跳过登录步骤**
- **登录模块用例**仍需独立执行(`reset_login: true`),验证登录功能本身
---
## 二、根因分析
### 2.1 当前用例步骤结构
所有非登录用例的 10 个步骤中,前 7 个是登录步骤:
```
步骤1: 打开登录页
步骤2: 等待登录表单加载
步骤3: 填写账号
步骤4: 填写密码
步骤5: 填写图形验证码
步骤6: 点击登录按钮
步骤7: 确认同意协议弹窗
步骤8: 等待主页加载
步骤9: 点击业务菜单(如"会议管理")
步骤10: 验证业务页面元素
```
### 2.2 执行器已有智能跳过逻辑但不完善
`playwright_executor.py` 第 618-625 行有智能跳过逻辑:
```python
if action == "navigate" and self._is_logged_in:
url = params.get("url", "")
if (self.LOGIN_CONFIG["base_url"] in url and
("login" in url.lower() or url == self.LOGIN_CONFIG["base_url"] or url.endswith("/"))):
step_result.status = "passed"
step_result.log = f"✓ 已登录,跳过导航: {url}"
break
```
**但这个逻辑只跳过 navigate 到登录页这一步**,不跳过后续的"填写账号"、"填写密码"等步骤。这些步骤在已登录的页面上执行时,因为找不到登录表单元素而失败。
### 2.3 核心问题
**非登录用例不应该包含登录步骤,而应该由执行器自动处理登录**。当前设计把登录步骤硬编码在每个用例中,导致:
1. 每个用例都重复登录
2. 已登录时执行登录步骤会失败
3. 无法利用执行器共享的登录态
### 2.4 正确的架构
| 用例类型 | 登录方式 | 步骤内容 |
|----------|----------|----------|
| 登录模块用例 | `reset_login: true` + 手动步骤 | 完整登录流程(验证登录功能本身) |
| 非登录用例(业务用例) | `auto_login: true` + 执行器自动登录 | 只包含业务操作步骤(从主页开始) |
---
## 三、修复方案
### 3.1 方案一:修改用例数据 + 启用 auto_login(推荐)
**思路**:修改非登录用例,删除前 8 个登录步骤,只保留业务步骤,同时将 `auto_login` 改为 `true`
**执行器已有完整支持**
- `auto_login=True` 时,执行用例前自动调用 `do_login()`(第 512-515 行)
- `_is_logged_in` 标记防止重复登录(第 218-219 行)
- 多用例共享同一执行器,登录态自然保持(`run_all_cases_sync` 第 239 行)
- `reset_login: true` 用于登录模块用例,重置登录态后再执行
**改造后用例步骤结构**
```
# 登录模块用例(不变)
步骤1: 打开登录页
步骤2: 等待登录表单加载
...
步骤8: 等待登录成功跳转
# 非登录用例(改造后)
步骤1: 点击业务菜单(如"会议管理") ← 直接从主页开始
步骤2: 验证业务页面元素
```
**优势**
- 执行器已有的 `auto_login` + `_is_logged_in` 机制完全支持
- 无需改执行器代码
- 用例步骤更简洁
- 第一个用例自动登录,后续用例复用登录态
### 3.2 方案二:执行器智能跳过登录步骤区域
**思路**:执行器检测到已登录时,自动跳过用例开头的一组登录步骤。
**问题**:无法准确判断哪些步骤属于"登录区域",可能误跳业务步骤。不采用。
### 3.3 选择方案一
---
## 四、具体改动
### 4.1 数据库用例步骤修改
对 20 个非登录用例(排除 5 个登录模块用例):
1. 删除前 8 个登录相关步骤(打开登录页→等待主页加载)
2. 只保留业务步骤(点击菜单 + 验证元素),重新编排 order
3. config 中 `auto_login` 改为 `true`
### 4.2 用例步骤最后加"返回主页"
每个非登录用例最后一步增加"点击首页/主页 logo"步骤,确保下一个用例从主页开始操作,避免停留在子页面影响下一个用例的菜单点击。
---
## 五、验收标准
| 测试项 | 预期结果 |
|--------|----------|
| 批量执行会议管理模块用例 | 第一个用例自动登录,后续用例复用登录态,不再重复登录 |
| 每个用例步骤数 | 非登录用例只有业务步骤(~3 步),无登录步骤 |
| 执行效率 | 每个非登录用例省去 ~15 秒登录时间 |
| 登录模块用例 | 仍独立执行,`reset_login: true` 清除登录态 |
| 用例间登录态保持 | 第一个用例登录后,后续用例共享登录态 |
| 用例执行完成后返回主页 | 最后一步点击主页,下一个用例从主页开始 |
---
## 六、相关文件
- `backend/app/executors/playwright_executor.py` — 执行器(`auto_login` + `do_login` + `_is_logged_in`
- `backend/app/services/execution_service.py` — 执行服务(`run_all_cases_sync` 共享执行器)
- `backend/app/models/test_case.py` — 用例模型
---
*本文档由 Claude Code 生成,遵循项目问题处理文档规范。*
# PRD 需求文档 - 步骤详情截图默认展开
> **文档类型**: PRD 需求文档
> **创建日期**: 2026-07-15
> **作者**: czj
> **优先级**: P2
> **状态**: 待开发
---
## 一、需求背景
### 1.1 问题描述
执行中心查看步骤详情时,每个步骤的截图需要手动点击"截图"按钮才能展开显示。用户查看步骤详情的主要目的就是看截图和错误信息,每次都要额外点击一次才能看到截图,操作不够流畅。
**当前交互流程**
1. 点击"查看步骤" → 弹出步骤详情弹窗
2. 看到步骤列表,截图按钮可见
3. **再点击"截图"按钮** → 截图才展开显示
4. 点击"截图"按钮 → 截图收起
### 1.2 目标
步骤详情弹窗打开时,**有截图的步骤默认展开截图预览**,省去手动点击展开的操作。截图按钮保留,用于收起/展开切换。
**期望交互流程**
1. 点击"查看步骤" → 弹出步骤详情弹窗
2. 有截图的步骤**自动展示截图预览**,无需额外点击
3. 点击"截图"按钮 → 收起截图;再点 → 重新展开
---
## 二、功能需求
### 2.1 截图默认展开
**改动位置**`frontend/src/components/StepDetailPanel.vue`
**当前逻辑**(第 161 行):
```typescript
const viewingScreenshot = ref<string | null>(null)
```
`viewingScreenshot` 初始为 `null`,所有截图默认不显示。点击"截图"按钮时切换为对应截图路径才显示。
**改为**:初始化时,将所有有截图的步骤的截图路径收集起来,使截图默认展开。
### 2.2 截图按钮行为
截图按钮保留,功能从"展开截图"变为"收起/展开切换":
- 截图已展开时点击 → 收起
- 截图已收起时点击 → 展开
当前 `viewScreenshot` 方法已支持切换逻辑(第 227-233 行),无需修改。
### 2.3 日志和错误详情
日志和错误详情**保持当前行为**(默认收起,手动点击展开),因为:
- 截图是步骤执行结果最直观的展示,用户最常查看
- 日志和错误信息文本较长,默认展开会导致页面过长
- 错误详情只在失败步骤出现,按需查看更合理
---
## 三、影响分析
| 层级 | 文件 | 改动 | 影响 |
|------|------|------|------|
| 前端 | `StepDetailPanel.vue` | `viewingScreenshot` 初始值计算 | 截图默认展开 |
| 后端 | — | 无 | — |
| 数据库 | — | 无 | — |
### 边界场景
| 场景 | 处理方式 |
|------|----------|
| 步骤无截图 | 不显示截图区域,与当前行为一致 |
| 步骤有截图 | 默认展开截图预览 |
| 多个步骤都有截图 | 所有截图都默认展开 |
| 截图加载失败 | 显示"截图加载失败"占位,与当前行为一致 |
| 点击截图按钮收起后再展开 | 正常切换,与当前行为一致 |
---
## 四、验收标准
| 测试项 | 预期结果 |
|--------|----------|
| 打开步骤详情弹窗,有截图的步骤 | 截图预览默认展开显示 |
| 打开步骤详情弹窗,无截图的步骤 | 不显示截图区域,无变化 |
| 点击"截图"按钮(截图已展开) | 截图收起 |
| 再次点击"截图"按钮(截图已收起) | 截图重新展开 |
| 日志按钮行为 | 默认收起,点击展开,无变化 |
| 错误详情按钮行为 | 默认收起,点击展开,无变化 |
| 多步骤都有截图 | 所有截图默认展开 |
---
## 五、相关文档
- `frontend/src/components/StepDetailPanel.vue` — 步骤详情组件(改动点)
- `frontend/src/views/Execution.vue` — 执行中心页面(调用方)
---
*本文档由 Claude Code 生成,遵循项目 PRD 文档规范。*
# 执行计划 - 步骤详情截图默认展开
> **文档类型**: 执行计划文档
> **创建日期**: 2026-07-15
> **作者**: czj
> **关联 PRD**: `_PRD_需求文档_步骤详情截图默认展开.md`
> **状态**: 待执行
---
## 一、改动总览
| # | 改动项 | 文件 | 改动量 | 说明 |
|---|--------|------|--------|------|
| 1 | viewingScreenshot 初始值改为计算属性 | `StepDetailPanel.vue` | ~10 行 | 从 `ref(null)` 改为基于 steps 的 computed 初始值 |
**总计**:1 个文件,约 10 行改动。
---
## 二、详细执行步骤
### Step 1:修改 StepDetailPanel.vue 截图默认展开逻辑
**文件**`frontend/src/components/StepDetailPanel.vue`
#### 改动 1:将 `viewingScreenshot` 从简单 ref 改为带初始值的 ref
**当前代码**(第 161 行):
```typescript
const viewingScreenshot = ref<string | null>(null)
```
**改为**
```typescript
/** 截图默认展开:取最后一个有截图的步骤的截图路径作为初始值 */
const viewingScreenshot = ref<string | null>(
(() => {
// 从后往前找第一个有截图的步骤,默认展开该截图
for (let i = props.steps.length - 1; i >= 0; i--) {
if (props.steps[i]?.screenshot) {
return props.steps[i].screenshot
}
}
return null
})()
)
```
**说明**
- 使用 IIFE 在初始化时遍历 steps,找到有截图的步骤
- 从后往前找,优先展示最后一个步骤的截图(通常是最新的执行状态)
- 如果没有任何步骤有截图,初始值为 `null`,与当前行为一致
#### 改动 2:截图显示条件调整
**当前代码**(第 97 行模板):
```html
<div class="step-screenshot" v-if="viewingScreenshot === step.screenshot && step.screenshot">
```
这个条件是"当前查看的截图 === 该步骤的截图",即一次只显示一个截图。
**改为支持多截图同时展开**
`viewingScreenshot` 从"单值"改为"集合",支持多个截图同时默认展开。
**具体改动**
1.`viewingScreenshot``ref<string | null>` 改为 `ref<Set<string>>`,初始值包含所有有截图的步骤的截图路径
2. `viewScreenshot` 方法改为对 Set 的 add/delete 操作
3. 模板中 `v-if` 条件改为 `viewingScreenshot.has(step.screenshot)`
**改动后的代码**
```typescript
/** 截图默认展开:收集所有有截图的步骤,默认全部展开 */
const viewingScreenshot = ref<Set<string>>(
new Set(props.steps.filter(s => s.screenshot).map(s => s.screenshot!))
)
const viewScreenshot = (screenshot: string) => {
if (viewingScreenshot.value.has(screenshot)) {
viewingScreenshot.value.delete(screenshot)
} else {
viewingScreenshot.value.add(screenshot)
}
// 触发响应式更新(Set 的 add/delete 不触发 ref 响应)
viewingScreenshot.value = new Set(viewingScreenshot.value)
}
```
模板改动:
```html
<!-- 改动前 -->
<div class="step-screenshot" v-if="viewingScreenshot === step.screenshot && step.screenshot">
<!-- 改动后 -->
<div class="step-screenshot" v-if="viewingScreenshot.has(step.screenshot) && step.screenshot">
```
---
## 三、验证步骤
### 3.1 构建验证
```bash
cd frontend && npm run build
```
### 3.2 功能验证
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 执行一个有截图的用例,完成后点击"查看步骤" | 步骤详情弹窗中,有截图的步骤默认展开截图预览 |
| 2 | 点击"截图"按钮(截图已展开) | 截图收起 |
| 3 | 再次点击"截图"按钮 | 截图重新展开 |
| 4 | 多个步骤都有截图 | 所有截图默认展开 |
| 5 | 步骤无截图 | 不显示截图区域 |
---
## 四、风险评估
| 风险 | 概率 | 影响 | 缓解 |
|------|------|------|------|
| Set 的 add/delete 不触发 Vue 响应式 | 中 | 截图按钮点击后无变化 | 通过重新赋值 `new Set()` 触发响应式 |
| steps 为空时初始化报错 | 低 | 组件渲染失败 | `props.steps.filter(...)` 对空数组安全 |
| 截图数量多导致页面过长 | 低 | 用户体验稍差 | 截图已限制 `max-height: 300px`,影响可控 |
---
*本文档由 Claude Code 生成,遵循项目执行计划文档规范。*
# PRD 需求文档 - 用例执行后跳转执行中心对应子菜单
> **文档类型**: PRD 需求文档
> **创建日期**: 2026-07-15
> **作者**: czj
> **优先级**: P1
> **状态**: 待开发
---
## 一、需求背景
### 1.1 问题描述
当前用例管理页面(Cases.vue)勾选用例点击"执行选中"后,跳转到执行中心的路由是 `/execution`**未携带类型参数**。而执行中心已按菜单分类架构拆分为子菜单(UI自动化、接口自动化等),路由格式为 `/execution/:type?`
**现状问题**
1. 从"用例管理 > UI自动化"执行用例后,跳转到 `/execution`,由于路由无 type 参数,`currentType` 默认为 `ui`,看似正常——但这是因为默认值恰好是 `ui`
2. 从"用例管理 > 接口自动化"执行用例后,同样跳到 `/execution`,默认显示 ui 类型的执行记录,**无法看到刚执行的 api 类型记录**
3. 从"用例管理 > 安全自动化测试"等其他类型执行后同理,**跳转后看不到对应的执行结果**
4. 侧边栏菜单高亮也会不正确——无 type 参数时菜单项无法正确匹配高亮
### 1.2 目标
用例管理页面执行用例后,**自动跳转到执行中心对应的类型子菜单**,确保用户能立即看到刚创建的执行记录,操作更流畅。
**期望行为**
- 在"用例管理 > UI自动化"执行 → 跳转到 `/execution/ui`
- 在"用例管理 > 接口自动化"执行 → 跳转到 `/execution/api`
- 在"用例管理 > 安全自动化测试"执行 → 跳转到 `/execution/security`
- 其他类型同理
---
## 二、功能需求
### 2.1 核心改动:执行后跳转携带类型参数
**改动位置**`frontend/src/views/Cases.vue``runSelectedCases` 方法
**当前代码**(第 677-692 行):
```typescript
const runSelectedCases = async () => {
if (selectedCases.value.length === 0) return
try {
const caseIds = selectedCases.value.map(c => c.id)
const execution = await executionApi.create({
case_ids: caseIds,
name: `手动执行-${new Date().toLocaleDateString()}`,
})
await executionApi.run(execution.id)
ElMessage.success('执行任务已启动')
router.push('/execution') // ← 问题:未带类型参数
} catch (error: any) {
ElMessage.error('启动执行失败: ' + error.message)
}
}
```
**改为**
```typescript
router.push(`/execution/${currentType.value}`) // ← 携带当前用例类型
```
`currentType` 已在 Cases.vue 第 423 行定义为 `computed(() => (route.params.type as string) || 'ui')`,直接复用即可。
### 2.2 跳转后的执行中心行为
跳转到 `/execution/ui` 后,Execution.vue 的 `currentType` 计算属性会自动读取 `route.params.type``ui``loadExecutions` 会按 `case_type: 'ui'` 筛选执行记录。**无需额外修改**
### 2.3 菜单高亮
跳转到 `/execution/ui` 后,App.vue 中 `el-menu``default-active` 会匹配到 `/execution/ui` 这个 `el-menu-item`,侧边栏"执行中心 > UI自动化"子菜单会正确高亮。**无需额外修改**
---
## 三、影响分析
### 3.1 影响范围
| 层级 | 文件 | 改动 | 影响 |
|------|------|------|------|
| 前端 | `Cases.vue` | 1 行路由跳转路径 | 仅跳转目标变化 |
| 后端 | — | 无 | — |
| 数据库 | — | 无 | — |
| 其他前端页面 | — | 无 | — |
### 3.2 边界场景
| 场景 | 处理方式 |
|------|----------|
| 选中多个不同类型的用例执行 | `currentType` 取当前页面的类型(即路由参数),创建执行时后端自动取首个用例的 case_type,跳转目标与当前页面类型一致 |
| 直接访问 `/cases`(无 type 参数) | `currentType` 默认 `ui`,跳转到 `/execution/ui`,符合预期 |
| 选中 0 个用例点击执行 | 按钮已 `disabled`,不会触发 |
---
## 四、验收标准
| 测试项 | 预期结果 |
|--------|----------|
| 在"用例管理 > UI自动化"勾选用例点击执行 | 跳转到 `/execution/ui`,侧边栏"执行中心 > UI自动化"高亮,列表显示 ui 类型执行记录 |
| 在"用例管理 > 接口自动化"勾选用例点击执行 | 跳转到 `/execution/api`,侧边栏"执行中心 > 接口自动化"高亮,列表显示 api 类型执行记录 |
| 在"用例管理 > 安全自动化测试"勾选用例点击执行 | 跳转到 `/execution/security`,列表显示 security 类型执行记录 |
| 直接访问 `/cases`(无 type)执行用例 | 跳转到 `/execution/ui`,默认行为正确 |
| 执行后跳转的执行中心列表能立即看到刚创建的执行记录 | ✅ 列表自动加载 |
---
## 五、相关文档
- `frontend/src/views/Cases.vue` — 用例管理页面(改动点)
- `frontend/src/views/Execution.vue` — 执行中心页面(已有类型筛选逻辑)
- `frontend/src/App.vue` — 侧边栏菜单(已有子菜单结构)
- `frontend/src/router/index.ts` — 路由定义(已有 `/:type?` 参数)
---
*本文档由 Claude Code 生成,遵循项目 PRD 文档规范。*
# 执行计划 - 用例执行后跳转执行中心对应子菜单
> **文档类型**: 执行计划文档
> **创建日期**: 2026-07-15
> **作者**: czj
> **关联 PRD**: `_PRD_需求文档_用例执行跳转执行中心对应子菜单.md`
> **状态**: 待执行
---
## 一、改动总览
| # | 改动项 | 文件 | 改动量 | 说明 |
|---|--------|------|--------|------|
| 1 | 执行后跳转携带类型参数 | `frontend/src/views/Cases.vue` | 1 行 | `router.push('/execution')``router.push(\`/execution/${currentType.value}\`)` |
**总计**:1 个文件,1 行核心改动。
---
## 二、详细执行步骤
### Step 1:修改 Cases.vue 跳转路径
**文件**`frontend/src/views/Cases.vue`
**位置**:第 688 行,`runSelectedCases` 方法内
**改动**
```typescript
// 改动前
router.push('/execution')
// 改动后
router.push(`/execution/${currentType.value}`)
```
**说明**
- `currentType` 已在第 423 行定义为 `computed(() => (route.params.type as string) || 'ui')`
- 当用户在"用例管理 > UI自动化"页面时,`currentType.value` = `'ui'`,跳转到 `/execution/ui`
- 当用户在"用例管理 > 接口自动化"页面时,`currentType.value` = `'api'`,跳转到 `/execution/api`
- 直接访问 `/cases`(无 type 参数)时,`currentType.value` = `'ui'`,跳转到 `/execution/ui`
**无需额外改动**
- Execution.vue — 已有 `currentType = computed(() => (route.params.type as string) || 'ui')``watch(currentType)` 自动加载
- App.vue — 已有子菜单结构,`/execution/ui` 等路径可正确匹配高亮
- router/index.ts — 已有 `/execution/:type?` 路由定义
- 后端 — 无需改动,`case_type` 由 service 层自动推断
---
## 三、验证步骤
### 3.1 构建验证
```bash
cd frontend && npm run build
```
确认 vue-tsc 类型检查通过 + Vite 打包成功。
### 3.2 功能验证
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 启动前后端服务 | 服务正常运行 |
| 2 | 访问"用例管理 > UI自动化",勾选用例,点击"执行选中" | 跳转到 `/execution/ui`,侧边栏"执行中心 > UI自动化"高亮 |
| 3 | 访问"用例管理 > 接口自动化"(如有用例),执行 | 跳转到 `/execution/api`,侧边栏"执行中心 > 接口自动化"高亮 |
| 4 | 直接访问 `/cases`(无 type),执行用例 | 跳转到 `/execution/ui`,默认行为正确 |
| 5 | 跳转后执行中心列表 | 能看到刚创建的执行记录 |
---
## 四、风险评估
| 风险 | 概率 | 影响 | 缓解 |
|------|------|------|------|
| `currentType` 值与后端推断的 case_type 不一致 | 低 | 跳转后列表可能为空 | 后端取首个用例的 case_type,前端取当前页面类型,正常使用场景下一致 |
| 选中不同类型用例执行 | 低 | 执行记录归到首个用例类型下 | 属于已有问题,不在本次范围 |
---
*本文档由 Claude Code 生成,遵循项目执行计划文档规范。*
此差异已折叠。
......@@ -150,7 +150,10 @@ class ExecutionService:
name = f"测试执行-{date_str}"
# 先查询用例,取首个用例的 case_type 作为执行记录的类型(用于按类型筛选)
cases_query = select(TestCase).where(TestCase.id.in_(case_ids))
# 按模块分组 + order 排序,确保同模块内首用例(含登录)先执行
cases_query = select(TestCase).where(TestCase.id.in_(case_ids)).order_by(
TestCase.module_id, TestCase.order, TestCase.created_at
)
cases_result = await self.db.execute(cases_query)
cases = list(cases_result.scalars().all())
first_case_type = cases[0].case_type if cases else "ui"
......@@ -219,9 +222,15 @@ class ExecutionService:
})
# 获取所有待执行的用例结果
results_query = select(CaseResult).where(
CaseResult.execution_id == execution_id,
CaseResult.status == "pending",
# JOIN TestCase 按模块分组 + order 排序,确保同模块内首用例(含登录)先执行
results_query = (
select(CaseResult)
.join(TestCase, CaseResult.case_id == TestCase.id)
.where(
CaseResult.execution_id == execution_id,
CaseResult.status == "pending",
)
.order_by(TestCase.module_id, TestCase.order, TestCase.created_at)
)
results_data = await self.db.execute(results_query)
case_results = list(results_data.scalars().all())
......
......@@ -94,7 +94,7 @@
</div>
<!-- 截图预览 -->
<div class="step-screenshot" v-if="viewingScreenshot === step.screenshot && step.screenshot">
<div class="step-screenshot" v-if="step.screenshot && viewingScreenshot.has(step.screenshot)">
<el-image
:src="getScreenshotUrl(step.screenshot)"
fit="contain"
......@@ -157,8 +157,10 @@ const props = defineProps<{
const expandedLogs = ref<Record<number, boolean>>({})
// 展开的错误
const expandedErrors = ref<Record<number, boolean>>({})
// 当前查看的截图
const viewingScreenshot = ref<string | null>(null)
// 当前查看的截图(默认展开所有有截图的步骤)
const viewingScreenshot = ref<Set<string>>(
new Set(props.steps.filter(s => s.screenshot).map(s => s.screenshot!))
)
// 统计概览
const summaryStats = computed(() => {
......@@ -225,11 +227,13 @@ const toggleError = (order: number) => {
}
const viewScreenshot = (screenshot: string) => {
if (viewingScreenshot.value === screenshot) {
viewingScreenshot.value = null
if (viewingScreenshot.value.has(screenshot)) {
viewingScreenshot.value.delete(screenshot)
} else {
viewingScreenshot.value = screenshot
viewingScreenshot.value.add(screenshot)
}
// 触发响应式更新(Set 的 add/delete 不触发 ref 响应)
viewingScreenshot.value = new Set(viewingScreenshot.value)
}
const getScreenshotUrl = (screenshot: string) => {
......
......@@ -683,9 +683,13 @@ const runSelectedCases = async () => {
case_ids: caseIds,
name: `手动执行-${new Date().toLocaleDateString()}`,
})
await executionApi.run(execution.id)
ElMessage.success('执行任务已启动')
router.push('/execution')
// 创建成功后立即跳转到执行中心,不等待 run 完成
// run 由执行中心页面自动触发(通过 query 参数传递 execution_id)
ElMessage.success('执行任务已创建,正在跳转...')
router.push({
path: `/execution/${currentType.value}`,
query: { run: execution.id },
})
} catch (error: any) {
ElMessage.error('启动执行失败: ' + error.message)
}
......
......@@ -53,11 +53,11 @@
:percentage="progressPercent"
:stroke-width="20"
:status="progressStatus"
striped
striped-flow
:striped="activeExecution.status === 'running'"
:striped-flow="activeExecution.status === 'running'"
:duration="8"
>
<span class="progress-text">
<span class="progress-text" :class="{ 'progress-completed': activeExecution.status !== 'running' }">
{{ activeExecution.status === 'running' ? '执行中...' : '已完成' }}
</span>
</el-progress>
......@@ -320,7 +320,7 @@
*/
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VideoPlay, Refresh, Delete } from '@element-plus/icons-vue'
import { executionApi } from '@/api/executions'
......@@ -329,6 +329,7 @@ import { WebSocketClient } from '@/utils/websocket'
import StepDetailPanel from '@/components/StepDetailPanel.vue'
const route = useRoute()
const router = useRouter()
// ==================== 响应式数据 ====================
......@@ -371,6 +372,8 @@ const currentExecutionId = ref('')
// WebSocket 客户端
let wsClient: WebSocketClient | null = null
// 主动轮询定时器(双重保障:WebSocket 断连时仍能检测执行完成)
let activePollTimer: ReturnType<typeof setInterval> | null = null
// ==================== 计算属性 ====================
......@@ -385,9 +388,8 @@ const progressPercent = computed(() => {
const progressStatus = computed(() => {
if (!activeExecution.value) return ''
if (activeExecution.value.status === 'running') return ''
if (activeExecution.value.pass_rate >= 90) return 'success'
if (activeExecution.value.pass_rate >= 70) return 'warning'
return 'exception'
// 执行完成后进度条统一显示绿色
return 'success'
})
const passRateClass = computed(() => {
......@@ -641,20 +643,25 @@ const createAndRunExecution = async () => {
config: { headless: runConfig.value.headless },
})
// 启动执行
await executionApi.run(execution.id, { headless: runConfig.value.headless })
ElMessage.success('执行任务已启动')
// 设置当前执行并连接 WebSocket
activeExecution.value = execution
connectWebSocket(execution.id)
runDialogVisible.value = false
selectedCases.value = []
runConfig.value.name = ''
// 刷新列表
await loadExecutions()
// 异步触发执行(不等待完成,通过轮询/WebSocket 更新状态)
executionApi.run(execution.id, { headless: runConfig.value.headless }).then(() => {
ElMessage.success('执行完成')
loadExecutions()
}).catch((error: any) => {
console.warn('执行任务返回异常:', error.message)
loadExecutions()
})
// 设置当前执行并连接 WebSocket
activeExecution.value = execution
connectWebSocket(execution.id)
ElMessage.info('执行任务已启动,请等待...')
startPolling()
await loadExecutions()
} catch (error: any) {
ElMessage.error('启动执行失败: ' + error.message)
} finally {
......@@ -664,16 +671,26 @@ const createAndRunExecution = async () => {
const startExecution = async (id: string) => {
try {
await executionApi.run(id)
ElMessage.success('执行任务已启动')
// 先刷新列表获取最新数据,设置当前执行
await loadExecutions()
// 设置当前执行
const execution = executions.value.find(e => e.id === id)
if (execution) {
activeExecution.value = execution
connectWebSocket(id)
}
// 异步触发执行(不等待完成,通过轮询/WebSocket 更新状态)
executionApi.run(id).then(() => {
ElMessage.success('执行完成')
loadExecutions()
}).catch((error: any) => {
// 执行过程中出错不弹窗(可能是超时等),轮询会更新最终状态
console.warn('执行任务返回异常:', error.message)
loadExecutions()
})
ElMessage.info('执行任务已启动,请等待...')
startPolling()
} catch (error: any) {
ElMessage.error('启动执行失败: ' + error.message)
}
......@@ -789,20 +806,70 @@ const connectWebSocket = (executionId: string) => {
})
wsClient.connect()
// 启动轮询双重保障
startPolling()
}
const disconnectWebSocket = () => {
stopPolling()
if (wsClient) {
wsClient.disconnect()
wsClient = null
}
}
/**
* 主动轮询当前执行状态
*
* 当 WebSocket 断连或 execution_complete 事件丢失时,
* 通过定时查询 API 确保执行状态最终能被正确更新。
*/
const startPolling = () => {
stopPolling()
activePollTimer = setInterval(async () => {
if (!activeExecution.value || activeExecution.value.status !== 'running') {
stopPolling()
return
}
try {
const latest = await executionApi.get(activeExecution.value.id)
if (latest.status !== 'running') {
// 状态已变化,更新 activeExecution
activeExecution.value = latest
if (latest.status === 'completed') {
ElMessage.success(`执行完成: 通过率 ${latest.pass_rate}%`)
}
disconnectWebSocket()
await loadExecutions()
}
} catch (error: any) {
console.error('轮询执行状态失败:', error)
}
}, 5000)
}
const stopPolling = () => {
if (activePollTimer) {
clearInterval(activePollTimer)
activePollTimer = null
}
}
// ==================== 生命周期 ====================
onMounted(() => {
loadExecutions()
onMounted(async () => {
await loadExecutions()
loadAllCases()
// 检测从用例管理页跳转过来的自动执行请求
const runId = route.query.run as string
if (runId) {
// 清除 query 参数,避免刷新页面重复执行
router.replace({ path: route.path, query: {} })
// 自动触发执行
startExecution(runId)
}
})
// 路由类型变化时重新加载(切换执行类型子菜单)
......@@ -857,6 +924,11 @@ onUnmounted(() => {
.progress-text {
font-size: 14px;
&.progress-completed {
color: #67c23a;
font-weight: 600;
}
}
}
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论