提交 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 生成,遵循项目执行计划文档规范。*
# HANDOFF — 会话交接文档
> **生成时间**: 2026-07-14(已更新)
> **生成时间**: 2026-07-16
> **当前分支**: `platform-auto-test`(跟踪 `origin/platform-auto-test`)
> **最近提交**: 菜单分类架构与步骤编辑功能(feat(menu))
> **状态**: ✅ 菜单分类任务**已完成并提交**
> **最近提交**: `50f65be1` feat(menu): 菜单分类架构与步骤编辑功能
> **状态**: ⚠️ P0 退出登录修复完成,但被测系统登录流程异常导致当前无法执行用例
---
......@@ -21,94 +21,161 @@
| 前端 | Vue 3 + Element Plus + Vite 5 + TypeScript + ECharts |
| 被测系统 | https://192.168.5.44 (admin@xty / Ubains@13579 / 验证码 csba) |
### 被测系统关键特性(本次踩坑核心)
被测系统是 **SPA + hash 路由**,主页是"常用功能"卡片式布局(不是传统侧边栏菜单):
- 登录页 URL: `https://192.168.5.44/#/platform?platform=%2Fplatform%2F%23%2Flogin`
- 主页 URL: `https://192.168.5.44/#/platform?platform=%2Fplatform%2F%23%2FHome`
- 业务子页面 URL: `https://192.168.5.44/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FmeetingAll`
- 菜单元素: `<div class="block"><p>菜单名</p></div>`,主页有 29 个 `.block`
- 已登录时访问 `#/login` 会被重定向回主页(无法通过 hash 导航登出)
### 目录结构(关键路径)
```
platform-auto-test/
├── backend/app/
│ ├── main.py # FastAPI 入口(Windows ProactorEventLoop)
│ ├── config.py / database.py # 配置 + SQLite 引擎(_ensure_columns 自动补齐新列)
│ ├── models/ # ORM: module(含module_type)/test_case(case_type)/execution(case_type)/case_result
│ ├── schemas/ # Pydantic 校验
│ ├── routers/ # 10 个路由模块(均支持按类型筛选)
│ ├── services/ # 业务逻辑
│ ├── executors/playwright_executor.py # 执行引擎(同步API,12动作+8断言)
│ ├── websocket/manager.py # WS 推送
│ └── utils/selector_mapper.py
│ ├── executors/playwright_executor.py # 执行引擎(本次改动重点)
│ ├── services/execution_service.py # 执行调度(本次改动重点)
│ └── ...
├── frontend/src/
│ ├── App.vue / router/index.ts # 布局(父子菜单)+ 路由(/:type? 可选参数)
│ ├── views/ # 7 页面(均读 route.params.type 筛选)
│ ├── components/CaseStepEditor.vue # UI自动化步骤编辑器(新)
│ ├── api/ components/ utils/
├── Docs/PRD/自动化测试平台/ # 所有 PRD/计划/进度文档
└── .claude/skills/ # CreateCMD/GitCommit/Handoff/prd-plan/prd-code
│ ├── views/Cases.vue # 用例管理(执行跳转带类型)
│ ├── views/Execution.vue # 执行中心(轮询+进度条绿色)
│ ├── components/StepDetailPanel.vue # 步骤详情(截图默认展开)
│ └── ...
├── Docs/PRD/ # 文档(已按模块归类)
│ ├── 自动化测试平台/{总览,执行中心,用例管理,模块管理,用例录制,报告中心,系统配置}/
│ └── 问题处理/执行中心/ # 问题处理文档
└── HANDOFF.md # 本文档
```
---
## 二、本次任务(✅ 已完成并提交)
## 二、本次任务(🔄 进行中)
### 任务:用例执行链路优化 + 多个 bug 修复
**目标**: 让会议管理等业务模块的用例批量执行时,首用例登录、中间用例复用登录态、末用例退出登录,全链路通过。
### 自测结果(最新一轮)
会议管理 6 用例执行:**5 passed / 1 failed(通过率 83.33%)**
| 用例 | 结果 | 说明 |
|------|------|------|
| 个人日程页面访问验证 | ✅ passed | 首用例,含登录步骤,第一个执行 |
| 会议列表页面访问验证 | ✅ passed | 复用登录态,点击菜单成功 |
| 会议室列表页面访问验证 | ✅ passed | 同上 |
| 会议模板页面访问验证 | ✅ passed | 同上 |
| 新建会议页面访问验证 | ✅ passed | 同上 |
| 预定数据页面访问验证 | ❌ failed | 末用例,**"退出登录"步骤超时** |
---
## 三、已完成事项(本次会话)
### 3.1 前端功能(已完成并验证)
| # | 功能 | 文件 | 状态 |
|---|------|------|------|
| 1 | 用例执行后跳转执行中心对应子菜单 | `Cases.vue` | ✅ `router.push(\`/execution/${currentType.value}\`)` |
| 2 | 步骤详情截图默认展开 | `StepDetailPanel.vue` | ✅ viewingScreenshot 改为 Set,默认全展开 |
| 3 | 执行中心顶部状态轮询修复(不再卡"运行中") | `Execution.vue` | ✅ startPolling/stopPolling 5秒轮询兜底 |
| 4 | 执行完成后进度条绿色 + "已完成"绿色 | `Execution.vue` | ✅ progressStatus 统一 success,条纹动画仅 running |
### 3.2 用例数据改造(已完成)
| # | 改造 | 说明 |
|---|------|------|
| 1 | 20 个非登录用例去除重复登录步骤 | 10步→3步(业务+返回主页) |
| 2 | 4 个业务模块首用例保留登录 | 模块初始化登录(auto_login=False) |
| 3 | 4 个业务模块末用例加退出登录 | 末用例退出登录(navigate 到 #/login) |
| 4 | 用例 order 字段排序 | 首用例 order=0,确保第一个执行 |
| 5 | 菜单选择器适配实际 DOM | `text=会议列表``.block:has-text("会议列表")` |
### 3.3 执行器代码改造(已完成)
| # | 改造 | 文件 | 说明 |
|---|------|------|------|
| 1 | auto_login 读取用例自身配置 | playwright_executor.py | `case_config.get("auto_login", self.auto_login)` |
| 2 | 已登录时导航到主页 | playwright_executor.py | `_navigate_to_home()` 直接 goto 主页 URL |
| 3 | 退出登录自动检测 | playwright_executor.py | `_detect_logout()` 点击后检测回登录页 |
| 4 | 登录态自动同步 | playwright_executor.py | `_detect_login_state()` 检测主页 .block 设 _is_logged_in=True |
| 5 | navigate 用 domcontentloaded | playwright_executor.py | 避免 networkidle 在 SPA 超时 |
| 6 | hash 路由特殊处理 | playwright_executor.py | `_is_same_origin_hash_change` + JS 设 location.hash |
| 7 | 退出登录走 reset_login_state | playwright_executor.py | URL 含 login 时清 cookies 强制登出 |
| 8 | 用例执行顺序按模块+order排序 | execution_service.py | `run_execution` JOIN TestCase ORDER BY |
### 3.4 文档(已完成)
- PRD 文档按模块归类到 `Docs/PRD/自动化测试平台/{总览,执行中心,用例管理,...}/`
- 新增问题处理文档目录 `Docs/PRD/问题处理/执行中心/`
- 本次会话新增文档:
- `_PRD_需求文档_用例执行跳转执行中心对应子菜单.md`
- `_PRD_需求文档_步骤详情截图默认展开.md`
- `_问题处理_执行中心顶部状态卡在运行中.md`
- `_问题处理_非登录用例重复执行登录.md`
- `_问题处理_执行用例结果显示待执行.md`
### 任务:菜单分类架构与步骤编辑(Task #5)
---
**状态**: ✅ 全部完成,前后端已验证通过并提交。
## 四、卡点和问题(⚠️ 当前未解决)
#### 完成内容
### 4.1 被测系统登录流程异常(当前核心卡点)
**分类字段(后端)**
- `Module.module_type`(standard/custom,默认 standard)
- `TestCase.case_type`(ui/api/security/deploy/performance/functional,默认 ui)
- `Execution.case_type`(默认 ui,创建执行时取首个用例的 case_type 自动填充)
- `database.py``_ensure_columns()``ALTER TABLE ADD COLUMN ... DEFAULT` 自动补齐旧库——**无需迁移脚本**,现有 5 模块/25 用例/5 执行已自动归类为 standard/ui/ui
**现象**:登录后协议弹窗"确定"按钮点击无效,登录流程无法完成,所有用例执行失败。
**筛选(后端)**
- `routers/modules.py` list 按 module_type 筛选
- `routers/cases.py` list 按 case_type 筛选(+ `_case_to_response` 辅助函数透传 case_type)
- `routers/executions.py` list 按 case_type 筛选
- `routers/reports.py` list 按 case_type 筛选(报告中心无"部署测试"子菜单)
- service 层(module/case/execution)list 方法签名均加 type 参数
**已验证的排查**
- Playwright `click()` → 无效
- `force=True` 强制点击 → 无效
- JS `element.click()` 通过 evaluate → 无效
- 键盘 `Enter` → 无效
- 弹窗"确定"按钮确实可见且可获取,但点击后页面无响应
- 之前(同一会话早期)执行会议管理 6/6 全通过,登录正常
- 多次执行后被测系统状态可能发生变化
**菜单与路由(前端)**
- `router/index.ts`:modules/cases/execution/reports 改为 `/:type?` 可选参数
- `App.vue`:模块/用例/执行/报告 4 个父菜单改 `el-sub-menu`,子菜单项按类型指向对应路由;`pageTitle` 随类型变化;`currentRoute` 处理无 type 时的菜单高亮回退
- 4 个页面(Modules/Cases/Execution/Reports)读 `route.params.type` 筛选,`watch` 类型变化重新加载
**下一步排查方向**
1. 手动在浏览器中确认被测系统登录是否正常
2. 如果手动登录正常,可能是 Playwright 和被测系统之间的兼容性问题
3. 检查被测系统是否有防自动化检测机制
4. 尝试换一个全新浏览器 profile
**步骤编辑器(前端)**
- 新建 `components/CaseStepEditor.vue`:UI 自动化步骤编辑器
- 12 种动作(navigate/click/fill/select/hover/wait/scroll/press/check/uncheck/assert/screenshot)
- 8 种断言(visible/hidden/text/contains/value/count/url/title)
- 按动作类型动态显示参数表单(参数存 step.params,snake_case 键,与执行器读取一致)
- 增删步骤 / 上下移 / 自动重排 order
- Cases.vue 已用 `v-model="editForm.steps"` 集成,仅 ui 类型显示
### 4.2 退出登录步骤超时(已修复)
**平台改名**:"自动化测试平台" → "测试管理平台"(App.vue logo/标题、router beforeEach、index.html)
**根因**:8001 端口有 8 个僵尸 python 进程,加载的是旧代码,新代码的 `reset_login_state` 修复没生效。
**构建修复**
- `CreateCaseRequest` 类型 `moduleId``module_id`、补 `status?`(对齐后端 snake_case)
- 清理 Cases/Modules/Recorder/request.ts 未使用的导入与变量(noUnusedLocals)
- 补装 `terser`(vite 配置用 terser 压缩但缺失,package.json + package-lock.json)
**修复**
1. 杀掉所有僵尸进程
2. 移除 `logout_debug.log` 调试代码
3. 增加 `params.logout` 标志位
4. 在 8001 端口干净启动后端
### 验证结果
**验证结果**:修复后首次执行会议管理 6/6 全通过(exec_639b0301d1a841fab37a439784342797)
- ✅ 后端 `app` 导入正常(64 路由)
- ✅ 前端 `npm run build` 通过(vue-tsc 类型检查全绿 + Vite 打包成功)
- ✅ DB 分类列存在,数据分布正确(modules: standard=5/custom=0;cases: ui=25/api=0;executions: ui=5/api=0)
- ✅ service 层类型筛选查询端到端正确
### 4.3 其他已知问题
| 问题 | 状态 |
|------|------|
| 80% 通过率的登录模块用例(错误密码验证失败) | 已有,.el-message 选择器问题,本次未处理 |
| 非登录用例"验证XX页面加载"用 `body` 选择器太粗糙 | 待优化为具体元素断言 |
---
## 三、待办(按优先级)
## 五、下一步计划(按优先级)
| 优先级 | 待办 | 说明 |
|--------|------|------|
| **P1** | main.py 跨平台兼容 | 为 Linux 部署铺路(见第六节),加 `sys.platform` 条件分支 |
| **P0** | 排查被测系统登录异常 | 手动确认登录是否正常,或换浏览器 profile 重试 |
| **P0** | 提交本次所有改动 | 退出登录修复+前端跳转+文档+新增用例均未提交 |
| **P1** | 优化底部菜单点击(滚动支持) | RSE 等菜单在页面底部,需滚动后点击 |
| **P1** | Settings.vue 功能实现 | Phase 5 剩余 P0 |
| **P1** | Pinia 状态管理 + 单元测试 | Phase 5 剩余 P0 |
| **P2** | 接口/安全/部署/性能执行器 | 本次只搭分类框架,非 ui 类型的执行器后续迭代;当前新建非 ui 用例可执行时会提示 |
| **P2** | 2 个登录用例细化 | 错误密码 .el-message 选择器、空账号时序问题 |
| **P2** | main.py 跨平台兼容 | Linux 部署铺路 |
---
## 、踩坑记录(绝对不要重复踩)
## 、踩坑记录(绝对不要重复踩)
| # | 现象 | 根因 | 正确做法 |
|---|------|------|---------|
......@@ -119,36 +186,25 @@ platform-auto-test/
| 5 | bash start 启动 CMD 失败 | MSYS 损坏 && 引号 | MSYS_NO_PATHCONV=1 cmd.exe /c start |
| 6 | 前端页面空白 | useRoute() 在 computed 内 | 移到 setup 顶层 |
| 7 | NOT NULL constraint error_message | nullable=False 但成功为None | nullable=True |
| 8 | curl 发中文 JSON 失败 | Windows bash 编码 | 用 Python httpx |
| 9 | 登录用例全失败 | execution_service 强制 auto_login=True + URL 判断登录成功有误 | 已修复:移除强制覆盖 + 检查登录表单判断 |
| 10 | 用例间登录态污染 | 共享执行器,上用例登录进主页影响下用例 | reset_login_state() + config.reset_login |
| 11 | 删除执行记录误删用例 | — | 删除 Execution 级联删 CaseResult,TestCase 独立不受影响 |
| 12 | SQLite 已有表加列无效 | create_all 不改已存在表 | _ensure_columns() 用 ALTER TABLE ADD COLUMN ... DEFAULT |
| 13 | **vite build 失败 terser not found** | vite 配置用 terser 压缩但 terser 是可选依赖未装 | `npm i -D terser`(已补装) |
| 14 | **noUnusedLocals 类型错误** | CreateCaseRequest 用 moduleId(驼峰) 但代码发 module_id | 请求类型对齐后端 snake_case;清理未用导入 |
| 8 | curl 发中文 JSON 失败 | Windows bash 编码 | 用 Python httpx/sqlite3 直查 |
| 9 | **用例执行顺序乱** | `run_execution` 查 case_results 无 ORDER BY | JOIN TestCase ORDER BY module_id, order, created_at |
| 10 | **navigate 超时 30s** | networkidle 在 SPA 永不空闲 | 用 domcontentloaded + timeout=15000 + 异常捕获 |
| 11 | **已登录访问 #/login 被重定向** | SPA hash 路由已登录态拦截 | 退出登录用 reset_login_state 清 cookies,不依赖 hash 导航 |
| 12 | **auto_login 用例重复登录** | 首用例手动登录后 _is_logged_in 未同步 | _detect_login_state 检测主页 .block 设标记 |
| 13 | **菜单 text= 选择器失败** | 被测系统菜单是 .block>p 卡片,非传统菜单 | 用 `.block:has-text("菜单名")` |
| 14 | **后端代码改了不生效** | 8001 端口被僵尸进程占用,新进程绑定失败 | 换端口或彻底释放端口;勿用 --reload(启动失败留僵尸) |
| 15 | **_is_logged_in 判断不准** | 子页面无 .block,_detect_login_state 不设 True(但不设 False) | 仅在主页设 True,不主动设 False |
---
## 五、Linux 部署考量(远期)
用户后续要部署到 Linux,当前代码有 **Windows 强绑定**
| 问题 | 当前(Windows) | Linux 方案 |
|------|--------------|-----------|
| 事件循环 | WindowsProactorEventLoopPolicy | 默认 asyncio,需 `sys.platform` 条件判断 |
| Playwright | 同步API+线程池(Windows子进程限制) | Linux 可改回异步API,性能更好 |
| 路径 | 部分硬编码 | 用 pathlib.Path |
**建议**:main.py 加平台条件分支。CLAUDE.md 写的"不要改回 async_playwright"是 **Windows 下的约束**,Linux 反而异步更好。
---
## 六、启动指南
## 七、启动指南
```bash
# 后端(端口 8001,工作目录 backend/)
cd backend && pip install -r requirements.txt && playwright install chromium
uvicorn app.main:app --reload --port 8001
uvicorn app.main:app --port 8001
# ⚠️ 建议不用 --reload(启动失败会留僵尸进程占用端口)
# ⚠️ 若 8001 被占,换 --port 8002,前端 vite.config.ts 代理同步改
# 前端(端口 3000,代理 8001)
cd frontend && npm install && npm run dev
......@@ -158,29 +214,74 @@ cd frontend && npm install && npm run dev
- API 文档: http://localhost:8001/docs
- 被测系统: https://192.168.5.44 (admin@xty / Ubains@13579 / csba)
### 自测流程(会议管理模块)
1. 访问 http://localhost:3000/cases/ui
2. 筛选"会议管理"模块,全选 6 个用例
3. 点击"执行选中",自动跳转 /execution/ui
4. 等待执行完成(约 2-3 分钟)
5. 展开执行记录查看结果
---
## 七、文档索引
## 八、未提交变更说明
本次会话所有改动**均未提交**。工作区改动:
**代码改动**:
- `backend/app/executors/playwright_executor.py`(退出登录逻辑优化,移除调试代码)
- `frontend/src/views/Cases.vue`(执行跳转改为 query 参数传递 execution_id)
- `frontend/src/views/Execution.vue`(新增 useRouter,onMounted 检测 query.run 自动触发执行,run 改为异步不等待)
- `backend/data/test_platform.db`(新增 4 个数据分析模块用例,共 29 个用例)
**文档改动**:
- `Docs/PRD/问题处理/执行中心/_问题处理_退出登录步骤超时与后端僵尸进程.md`(新增)
- `Docs/PRD/问题处理/执行中心/_执行计划_修复退出登录步骤超时与后端僵尸进程.md`(新增)
- `HANDOFF.md`(本文档,更新状态)
**建议**: 被测系统登录问题解决后,用 /GitCommit 一次性提交。
---
## 九、文档索引
| 文档 | 路径 |
|------|------|
| **菜单分类 PRD** | `Docs/PRD/自动化测试平台/_PRD_需求文档_菜单分类与步骤编辑.md` |
| **菜单分类执行计划** | `Docs/PRD/自动化测试平台/_执行计划_菜单分类与步骤编辑.md` |
| CLAUDE.md | `CLAUDE.md`(工作指导,含踩坑+约束) |
| 主 PRD | `Docs/PRD/自动化测试平台/_PRD_平台自动化测试可视化系统需求文档.md` |
| 当前进度 | `Docs/PRD/自动化测试平台/当前进度记录.md` |
| 本次问题处理文档 | `Docs/PRD/问题处理/执行中心/` |
| 用例管理 PRD | `Docs/PRD/自动化测试平台/用例管理/` |
| 执行中心 PRD | `Docs/PRD/自动化测试平台/执行中心/` |
| 总览 PRD | `Docs/PRD/自动化测试平台/总览/` |
| HANDOFF.md | `HANDOFF.md`(本文档) |
---
## 八、未提交变更说明
## 十、关键代码位置速查
### 退出登录 bug 排查核心位置
**菜单分类任务已提交**(feat(menu))。工作区仍有的改动均为**不应提交**的临时文件:
- 根目录大量临时脚本(`auto_explore*.py` / `check_*.py` / `explore_*.py` / `debug_menu*` / `test_*.py` / `*.png` / `created_cases.json` 等)——探索被测系统时的临时工具
- `backend/data/*.log` / `*.json` / `reports/` / `screenshots/` / `*.html` / `__pycache__/`
- `frontend/dist/` / `frontend/node_modules/`
- `backend/app/executors/playwright_executor.py`:
- `execute_step` 的 navigate 分支(约 707 行):URL 含 login 走 reset_login_state
- `reset_login_state`(约 290 行):清 cookies + goto 登录页
- `_detect_login_state`(约 376 行):检测主页 .block 设 _is_logged_in
- `_navigate_to_home`(约 327 行):已登录时导航回主页
- `_do_navigate`(约 821 行):hash 路由特殊处理 + domcontentloaded
- 智能跳过逻辑(约 695 行):`"退出" not in step_result.name` 时不跳过
**下次会话**:根目录临时脚本如需清理可批量删除,不影响功能。
- `backend/app/services/execution_service.py`:
- `run_execution` 查询(约 224 行):JOIN TestCase ORDER BY module_id, order
### 验证后端是否加载新代码
```bash
cd backend && python -c "
import inspect
from app.executors.playwright_executor import PlaywrightExecutor
src = inspect.getsource(PlaywrightExecutor.execute_step)
print('reset分支在代码中:', '清除 cookies 强制登出' in src)
"
```
---
*本文档由 /Handoff skill 生成并更新,供下一次会话快速恢复上下文。*
*本文档由 /Handoff skill 生成,供下一次会话快速恢复上下文。*
......@@ -308,7 +308,10 @@ class PlaywrightExecutor:
except Exception:
pass
# 重新访问登录页(用 domcontentloaded 避免 networkidle 超时)
self._page.goto(self.LOGIN_CONFIG["base_url"], wait_until="domcontentloaded")
try:
self._page.goto(self.LOGIN_CONFIG["base_url"], wait_until="domcontentloaded", timeout=15000)
except PlaywrightTimeoutError:
logger.debug("重置登录态 goto 超时(忽略)")
self._page.wait_for_timeout(2000)
# 显式等待登录表单加载,避免页面未就绪导致后续步骤超时
try:
......@@ -324,6 +327,75 @@ class PlaywrightExecutor:
except Exception as e:
logger.error(f"重置登录态失败: {e}")
def _navigate_to_home(self) -> None:
"""
导航到主页
在已登录状态下,确保下一个用例从主页开始操作。
上一个用例执行完可能停留在子页面,直接点击菜单可能失败。
"""
try:
current_url = self._page.url
# 如果已经在主页(常用功能页),无需导航
# 主页 URL 格式: https://xxx/#/platform?platform=...Home
if "Home" in current_url and "#" in current_url:
logger.debug("已在主页,无需导航")
return
logger.info(f"导航到主页... (当前URL: {current_url})")
# 直接导航到主页 URL(最可靠的方式)
self._page.goto(
self.LOGIN_CONFIG["base_url"] + "/#/platform?platform=%2Fplatform%2F%23%2FHome",
wait_until="domcontentloaded"
)
self._page.wait_for_timeout(2000)
# 等待常用功能卡片加载
try:
self._page.wait_for_selector(".block", timeout=10000)
logger.debug("主页常用功能已加载")
except Exception:
logger.warning("等待主页 .block 元素超时")
logger.info("已导航到主页")
except Exception as e:
logger.warning(f"导航到主页失败(不影响后续执行): {e}")
def _detect_logout(self) -> None:
"""
检测是否已退出登录
点击操作后检查页面是否回到了登录页(URL 含 login 或出现登录表单)。
如果检测到退出登录,重置 _is_logged_in 标记,
确保下一个用例的登录步骤不会被智能跳过。
"""
try:
current_url = self._page.url
login_form = self._page.query_selector('input[placeholder*="手机号"], input[type="password"]')
if "login" in current_url.lower() or login_form:
self._is_logged_in = False
logger.info("检测到退出登录,重置登录态标记")
except Exception:
pass
def _detect_login_state(self) -> None:
"""
检测是否已登录
用于手动登录步骤(auto_login=False)执行后的状态同步。
判断依据:URL 不含 login,且页面出现常用功能卡片(.block)或主页特征。
如果检测到已登录,设置 _is_logged_in=True,
确保下一个用例(auto_login=True)复用登录态而非重新登录。
"""
try:
current_url = self._page.url
# 不在登录页 + 页面有 .block(常用功能卡片)= 已登录进入主页
if "login" not in current_url.lower():
has_block = self._page.query_selector(".block")
if has_block:
self._is_logged_in = True
logger.info("检测到已登录进入主页,设置登录态标记")
except Exception:
pass
# ==================== 选择器与等待辅助 ====================
def _resolve_selectors(self, params: Dict[str, Any]) -> List[str]:
......@@ -509,12 +581,18 @@ class PlaywrightExecutor:
self.reset_login_state()
# 如果启用了自动登录且尚未登录,先执行登录
if self.auto_login and not self._is_logged_in:
# 优先使用用例自身的 auto_login 配置,其次使用执行器全局配置
need_auto_login = case_config.get("auto_login", self.auto_login)
if need_auto_login and not self._is_logged_in:
login_success = self.do_login()
if not login_success:
logger.warning("自动登录失败,可能影响需要登录态的测试步骤")
elif need_auto_login and self._is_logged_in:
# 已登录但需要确保从主页开始(上一个用例可能停留在子页面)
self._navigate_to_home()
logger.debug(f"跳过登录: auto_login={need_auto_login}, is_logged_in={self._is_logged_in}")
else:
logger.debug(f"跳过登录: auto_login={self.auto_login}, is_logged_in={self._is_logged_in}")
logger.debug(f"跳过登录: auto_login={need_auto_login}, is_logged_in={self._is_logged_in}")
result = CaseExecutionResult(case_id=case_id, case_name=case_name)
start_time = datetime.now()
......@@ -615,7 +693,10 @@ class PlaywrightExecutor:
logger.debug(f"⏳ 执行步骤 {step_result.order}: {step_result.name} (action={action}, attempt={attempt}/{max_attempts})")
# 智能处理:如果已登录且步骤是导航到登录页或首页,跳过
if action == "navigate" and self._is_logged_in:
# 但退出登录步骤不跳过(通过 params.logout 或步骤名含"退出"判断)
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("/"))):
......@@ -626,11 +707,37 @@ class PlaywrightExecutor:
# 根据动作类型分发执行
if action == "navigate":
url = params.get("url", "")
# 退出登录:导航到登录页时,清除 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
self._do_navigate(params)
step_result.log = f"✓ 导航到: {params.get('url', '')}"
step_result.log = f"✓ 导航到: {url}"
# 导航到登录页后重置登录态标记
if self._is_logged_in and "login" in url.lower():
self._is_logged_in = False
logger.info("导航到登录页,重置登录态标记")
# 检测是否已登录:导航后若进入主页,同步登录态标记
if not self._is_logged_in:
self._detect_login_state()
elif action == "click":
self._do_click(params, force=force)
step_result.log = f"✓ 点击元素: {params.get('selector', '') or '多选择器'}"
# 检测是否退出登录:点击后页面回到登录页
if self._is_logged_in:
self._detect_logout()
# 检测是否已登录:点击后若进入主页,同步登录态标记
if not self._is_logged_in:
self._detect_login_state()
elif action == "fill":
self._do_fill(params, force=force)
step_result.log = f"✓ 填充输入框: {params.get('selector', '') or '多选择器'} = {params.get('value', '')}"
......@@ -670,6 +777,8 @@ class PlaywrightExecutor:
break
except Exception as e:
import sys as _sys
print(f"===DEBUG step异常: name={step_result.name} action={action} err={type(e).__name__}: {e}===", file=_sys.stderr, flush=True)
last_error = e
step_result.status = "failed"
step_result.error = str(e)
......@@ -733,15 +842,47 @@ class PlaywrightExecutor:
页面导航
Args:
params (dict): {"url": str}
params (dict): {"url": str, "wait_until": str (可选)}
"""
url = params.get("url", "")
if not url:
raise ValueError("导航URL不能为空")
self._page.goto(url, wait_until="networkidle")
current_url = self._page.url
# 如果只是 hash 变化(同源),用 JS 修改 location 更可靠,
# 避免 goto 等待 navigation 事件在 SPA hash 路由上超时
if self._is_same_origin_hash_change(current_url, url):
try:
# 提取 hash 部分设置
hash_part = url.split("#", 1)[1] if "#" in url else ""
if hash_part:
self._page.evaluate(f'window.location.hash = "#{hash_part}"')
self._page.wait_for_timeout(1500)
logger.debug(f"hash 导航完成: #{hash_part}")
self._wait_for_loading_overlay()
return
except Exception as e:
logger.debug(f"hash 导航失败,回退到 goto: {e}")
# 默认用 domcontentloaded 避免 SPA 持续网络活动导致 networkidle 超时
wait_until = params.get("wait_until", "domcontentloaded")
try:
self._page.goto(url, wait_until=wait_until, timeout=15000)
except PlaywrightTimeoutError:
# hash 路由或 SPA 导航可能不触发 goto 完成事件,忽略超时
logger.debug(f"导航 goto 超时(忽略): {url}")
# 等待加载遮罩消失
self._wait_for_loading_overlay()
def _is_same_origin_hash_change(self, current: str, target: str) -> bool:
"""判断目标 URL 是否与当前同源且仅 hash 不同"""
try:
# 提取 scheme+host 部分
def base(u):
return u.split("#")[0].split("?")[0].rstrip("/")
return base(current) == base(target) and "#" in target
except Exception:
return False
def _do_click(self, params: dict, force: bool = False) -> None:
"""
点击元素(多选择器回退 + 强制点击)
......
......@@ -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,10 +222,16 @@ class ExecutionService:
})
# 获取所有待执行的用例结果
results_query = select(CaseResult).where(
# 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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论