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

feat(recorder): 真录制器与定位质量提升 Phase 1-5(引擎/路由/E2E/前端)

- 真录制引擎 recorder_engine.py:会话线程 + CAPTURE_JS 捕获 + expose_binding 上报 + WS 实时推送;
  修复主循环事件饥饿(page.is_closed() 不泵事件队列 → page.evaluate("1") 真实 CDP 往返)
- recorder.py /save 三重 P0 修复(孤儿 except 语法错误 + 用例创建漏出 else 分支 +
  case_service NameError),重写为 CaseService.create + config 强制 auto_login 注入
- element_locator /pages 页面注册表查询(直达 URL 下拉数据源)
- 前端:Recorder.vue 真录制器页面(暂停=丢弃新捕获,保存走本地列表)、
  websocket.ts modulePrefix 支持、stepParser/IntentStepEditor 语义化步骤、elementLocate API
- 新增 test_recorder_e2e.py 10 测试(本地 mock 页 + 独立 SQLite 全离线隔离);
  后端 408/408 全绿,前端 npm run build 零报错
- config.py:RECORDER_ENABLED / RECORDER_SESSION_TTL / RECORDER_CANDIDATE_LIMIT
  (同文件截图保留天数配置属其他窗口,本次未含)
- 文档:真录制器 PRD + 计划执行 + 语义化用例创建指南;HANDOFF_UI自动化 会话 51
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 123b88ba
# 执行计划 — 真录制器与定位质量提升
> **文档版本**: v1.0
> **创建日期**: 2026-09-07
> **作者**: Claude Code
> **关联PRD**: `_PRD_真录制器与定位质量提升.md`
> **状态**: 待确认
> **所属分支**: `platform-auto-test`
---
## 一、执行概览
### 1.1 背景与目标
当前 UI 自动化「录入 → 定位 → 执行」链路中:
1. 录制器为**纯内存 dict 的空壳**,无法启动真实浏览器捕获操作;
2. 录制生成的用例未注入 `auto_login: true`,新建用例无登录步骤执行直接失败;
3. 意图编辑模式(`Cases.vue`)在无 semantic 时强切,丢失 `params.url` / `wait.timeout` / `selectors` 链;
4. 智能定位只看系统首页,无法针对具体目标页面提取元素。
本执行计划按 PRD 要求,聚焦**阶段 1(真录制器 MVP + 配套修复)**进行全栈落地,实现「点开始 → 开有头浏览器 → 自动登录 → 真实捕获 → 实时推送步骤 → 保存即跑通」,并修复参数丢失与定位目标页面支持。
### 1.2 预估工时与阶段划分
| 阶段 | 重点目标 | 涉及文件 |
|------|----------|----------|
| **Phase 1: 后端真录制器引擎(P0)** | `RecorderEngine` 同步 Playwright 独立线程生命周期、自动登录、注入捕获脚本、指纹生成、WS 广播 | `backend/app/services/recorder_engine.py`<br>`backend/app/config.py` |
| **Phase 2: 录制器 API 与 WS 改造(P0)** | 改造 `recorder.py` 支持引擎交互、新增 `/status`、注册 WS 端点、保存强制 `auto_login: true` | `backend/app/routers/recorder.py`<br>`backend/app/main.py` |
| **Phase 3: 前端 Recorder.vue 改造(P0)** | 连接录制器专属 WebSocket、实时步骤列表与属性编辑、保存用例后直接跳转执行 | `frontend/src/views/Recorder.vue`<br>`frontend/src/api/recorder.ts` |
| **Phase 4: 配套修复与稳定性加固(P1)** | `stepParser.ts` 补齐 params 字段透传;`element_locator.py` 支持 `page_url` 参数 | `frontend/src/utils/stepParser.ts`<br>`backend/app/routers/element_locator.py` |
| **Phase 5: 验证与验收(P0)** | 运行离线本地 HTML 驱动 E2E 验证脚本 + 前端 `npm run build` 打包校验 | `backend/tests/test_recorder_e2e.py` |
---
## 二、Phase 1: 后端真录制器引擎(RecorderEngine)
### 2.1 任务清单
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 1.1 | 新增配置项 | `backend/app/config.py` | `RECORDER_SESSION_TTL`(300s)、`RECORDER_CANDIDATE_LIMIT`(4) |
| 1.2 | 创建 RecorderEngine 核心服务 | `backend/app/services/recorder_engine.py` | 封装多线程 Playwright 实例、自动登录、DOM 注入 |
| 1.3 | 编写 DOM 捕获与指纹提取 JS 脚本 | `backend/app/services/recorder_engine.py` | 穿透 micro-app,捕获 click/fill/press/navigate,生成候选选择器链与 semantic |
| 1.4 | 异步与同步跨线程通信 | `backend/app/services/recorder_engine.py` | `expose_binding` 回调通过 `asyncio.run_coroutine_threadsafe` 推送至 WS manager |
### 2.2 核心实现细节
- **线程与事件循环**:由于 Windows 平台限制,Playwright 同步 API 运行在专有工作线程 `threading.Thread` 中,并在入口执行 `asyncio.set_event_loop(None)`,避免与 FastAPI 主 ProactorEventLoop 冲突。
- **浏览器与自动登录**:复用与 `PlaywrightExecutor` 一致的 Chrome 伪装参数与绕过 webdriver 检测脚本,默认以 `headless=False` 启动并自动填充 `admin@xty / Ubains@13579 / csba`
- **选择器打分与候选排序**
1. `data-id` / `data-testid` / 语义 role 属性(得分 100);
2. 短文本锚定 `button:has-text("...")` / `a:has-text("...")`(得分 85);
3. `placeholder` / `aria-label` / `name`(得分 70);
4. 稳定 class 组合(过滤 `.is-active` / `.el-input__inner` 等通用类,得分 50);
5. 结构路径与 `nth` 回退(得分 30)。
- **微前端穿透**:监听挂载在 `document` capture 阶段,使用 `composedPath()` 获取深层真实目标。
---
## 三、Phase 2: 录制器 API 与 WS 改造
### 3.1 任务清单
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 2.1 | 改造 `/start` 端点 | `backend/app/routers/recorder.py` | 触发 `recorder_engine.start_session`,返回 `session_id` |
| 2.2 | 新增 `/status` 端点 | `backend/app/routers/recorder.py` | 获取录制器当前运行状态、步骤列表 |
| 2.3 | 改造 `/stop` 端点 | `backend/app/routers/recorder.py` | 停止监听,停止状态推至客户端 |
| 2.4 | 改造 `/save` 端点 | `backend/app/routers/recorder.py` | 持久化用例,**强制注入 `auto_login: true`** 并销毁录制器会话 |
| 2.5 | 增加录制器专属 WebSocket 路由 | `backend/app/routers/recorder.py`<br>`backend/app/main.py` | `@router.websocket("/ws/{session_id}")`,加入 `manager` 管理 |
---
## 四、Phase 3: 前端 Recorder.vue 改造
### 4.1 任务清单
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 3.1 | API 层补充方法 | `frontend/src/api/recorder.ts` | 增加 `getStatus`,调整类型定义 |
| 3.2 | 接入 WebSocket 监听 | `frontend/src/views/Recorder.vue` | 监听 `step_captured``session_ready``session_closed` |
| 3.3 | 步骤列表实时渲染与编辑 | `frontend/src/views/Recorder.vue` | 实时滚动最新步骤,支持查看/微调候选选择器、修改预期值 |
| 3.4 | 提示交互升级 | `frontend/src/views/Recorder.vue` | 录制中显示浮动提示条引导用户在浏览器中操作,操作后即时高亮反馈 |
---
## 五、Phase 4: 配套修复与稳定性加固
### 5.1 任务清单
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 4.1 | 修复 `stepParser.ts` 参数丢失 | `frontend/src/utils/stepParser.ts` | `toIntentStep` 保留 `url``timeout``selectors``params` 全字段 |
| 4.2 | `element_locator.py` 目标页面支持 | `backend/app/routers/element_locator.py` | `/locate` 接收可选 `page_url`,优先跳转目标 URL 后再提取 DOM |
---
## 六、Phase 5: 验证与验收
### 6.1 验证用例设计
1. **录制链路测试**`backend/tests/test_recorder_e2e.py`):
- 启动模拟本地 HTML 静态服务(包含输入框、按钮、下拉框);
- 后端启动 RecorderSession 指向本地地址;
- 通过模拟合成事件或脚本触发操作,断言 WS 接收到的步骤是否包含正确的 action、selectors 候选和 params。
2. **保存用例检查**
- 检查生成的用例 `config` 字典中必须包含 `auto_login: True`
3. **前端编译打包**
- 执行 `cd frontend && npm run build`,确保 TypeScript 类型与 Vite 构建零报错。
---
*本文档由 Claude Code 自动生成,待下一步执行代码编写。*
......@@ -2,12 +2,56 @@
> **生成时间**: 2026-09-07
> **当前分支**: `platform-auto-test`
> **最近提交**: `45a247b8` docs(handoff): 会话51 交接——截图瘦身已部署5.44 + PRD/计划文档归档
> **状态**: 🟢 **会话51:定时任务截图瘦身(failure_only + JPEG q60 + 14天过期清理)已实现并部署 5.44(重启后通过用例 0 截图验证 ✓);存量 843 张 PNG/267MB 待清理;5.202/5.60 未部署;5.44 全量套件 ~100% 失败(登录落首页问题)仍在排查中**(历史会话见下
> **最近提交**: `1e28bafe` feat(recorder): 真录制器与定位质量提升 Phase 1-5(引擎/路由/E2E/前端)
> **状态**: 🟢 **会话52 完成(2026-09-07):真录制器 Phase 5 —— recorder.py /save 三重 P0 修复、recorder_engine.py 主循环事件饥饿修复(page.is_closed() 不泵事件队列)、新增 test_recorder_e2e.py(10 测试,后端 408/408 全绿)、Recorder.vue TS 修复(togglePause 前端暂停语义),前端 build 零报错,已提交推送。上一会话51:定时任务截图瘦身已部署 5.44,其遗留 P0=5.44 全量套件登录落首页失败排查中**(历史会话见下章节
---
## 📊 当前状态(会话 51,2026-09-07 · 定时任务截图瘦身)
## 📊 当前状态(会话 52,2026-09-07 · 真录制器 Phase 5:E2E 测试 + 两个 P0 修复)
**背景**:继续 `_PRD/需求文档/用例管理/_PRD_真录制器与定位质量提升_计划执行.md` Phase 5(Phase 1-4 引擎/路由/捕获脚本/前端已在工作区实现,未验证)。
### ✅ P0 修复 1:`routers/recorder.py` /save 端点三重缺陷
- **语法错误**:第 281 行孤儿 `except`(无匹配 `try`)→ **后端整个无法启动**(py_compile 确认 SyntaxError)
- **逻辑错误**:用例创建只在 `else` 分支(前端传权威步骤时反而永不落库)
- **NameError**:调用了不存在的 `case_service.create_case`
- **重写后**:统一流程 —— 前端 `steps` 优先(重编号 order),否则取会话步骤归一化透传(selectors/page_key/element_key/force/wait_after);`CaseService(db).create(TestCaseCreate(...))`**config 强制 `auto_login: true`**(录制用例无登录步骤,不注入则执行时在登录页空转失败);保存成功后 `destroy_session`
### ✅ P0 修复 2:`services/recorder_engine.py` 主循环事件饥饿(生产级 bug)
- **现象**:E2E 首轮录制 0 步骤送达;登录期上报能到、录制期上报滞留至进程退出
- **根因**:主循环用 `page.is_closed()` 探活 —— 它只读本地标记,**不做 CDP 往返、不泵同步 greenlet 事件队列**`expose_binding("__rec_report")` 回调永不派发
- **修复**:主循环每轮 `page.evaluate("1")` 真实往返(泵送事件),异常才判 closed
- **排查路径**:3 个递进探针(隔离捕获探针 → 引擎直连探针 → 引擎线程内打桩)证实后删除
### ✅ 新增:`backend/tests/test_recorder_e2e.py`(Phase 5 交付物,10 测试)
- **完全离线隔离**:本地 ThreadingHTTPServer mock 页(含 `_do_login` 约定的登录表单)+ 独立 SQLite(NullPool 防跨事件循环取连接)+ uvicorn 后台线程 `lifespan="off"` + `websockets.sync.client`**不触碰 192.168.5.44 与开发库**
- **覆盖**:完整链路(/start → WS 快照/status → session_ready → 6 步捕获断言 action/params/selectors → step_verified 全 verified → 断言追加 → /stop → /save → DB 校验 auto_login+selectors 持久化 → /status 404);保存 API 边界(前端权威步骤重编号 / 会话步骤 / 空步骤 400 / 未知会话 404 / RECORDER_ENABLED=false 500);断言 API;步骤构建器动作映射与 capture 关闭忽略
- **mock 关键点**:登录成功判定查 DOM 中 `input[type="password"]` 存在性 → 必须 `.remove()` 不能 CSS 隐藏;合成 KeyboardEvent 需显式 `{key:'Enter'}`
### ✅ 前端:Recorder.vue 两个 TS 编译错误(阻塞 `npm run build`)
- `togglePause` 模板引用但未定义 → 实现**前端暂停语义**`isPaused` 翻转 + WS `step_captured` 处理器暂停期间丢弃新步骤(保存走本地列表 `buildOverrideSteps()`,故暂停步骤真实不入用例)
- `stepCounter` 声明赋值但从未读取(TS6133)→ 删除变量及 4 处赋值
### ✅ 验证结果
| 验证 | 结果 |
|------|------|
| 后端全量 `pytest tests/` | **408 passed, 52.11s**(含录制器 E2E 10/10,37.5s) |
| 前端 `npm run build`(vue-tsc + Vite) | **零报错**(31.0s) |
| 前端 `npm run lint` | 0 errors(15 warnings 均为其他文件遗留) |
### 🕳️ 踩坑沉淀(勿重复踩)
1. **`page.is_closed()` 不是探活泵**:本地标记检查,不泵 Playwright 同步事件队列;轮询循环必须有真实 Playwright 调用(如 `evaluate("1")`)才能派发 `expose_binding` 回调
2. **Playwright 同步对象线程绑定**:跨线程触碰 `session._page``greenlet.error: cannot switch to a different thread`;测试打桩必须 patch 引擎线程内执行的方法(`_handle_report`/`_process_verify_queue`
3. **测试 `get_db` override 必须 commit**`CaseService.create` 只 flush,与生产 get_db 行为不一致会导致「保存成功但库里没有」
4. **假会话要 `main_loop = None`**:否则 `_emit` 向永不运行的 loop `run_coroutine_threadsafe` 产生 RuntimeWarning
### ⚠️ 待办
- ~~P1 提交本会话改动~~ ✅ 已完成(`1e28bafe`,16 文件,config.py 部分暂存仅含录制器配置,其他窗口 WIP 未混入)
- P2 真实 192.168.5.44 环境手动验收录制链路(浏览器有头 + 自动登录 + 保存执行)
---
## 📊 历史状态(会话 51,2026-09-07 · 定时任务截图瘦身)
**背景**:5.44 定时全量执行每次产生 800+ 张 PNG 步骤截图(267MB/次,累计数 GB),磁盘有撑满风险。用户选定方案:「压缩质量且只截取关键失败步骤」。
......@@ -92,7 +136,7 @@
### ✅ 视讯设备(终端维护)navigate 直达升级
- **真实直达 URL**`https://192.168.5.44/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FTerminalMaintenance`(meetingV3 微应用,2s 表格即出现)
- **DOM 特征**:列头=终端名称腾讯/终端型号/通话状态/所属MCU/终端号码/终端IP/终端SN(断言 `:text("终端名称")` 前缀匹配);区域树点击后节点带 `.el-tree-node.is-current`**当前终端无数据(行=0)**`element_count_min` 用 expected=0 兼容空态
- **3 条用例**:页面访问验证 / 表格数据验证 / 树形筛选验证(`generate_terminal_maintenance_cases.py` 幂等,先删同名再插)
- **3 条用例**:页面访问验证 / 日历数据验证 / 日历导航验证(`generate_terminal_maintenance_cases.py` 幂等,先删同名再插)
### ✅ 验证结果
| 执行 | 范围 | 结果 |
......@@ -146,7 +190,7 @@
---
## 📊 当前状态(会话 48,2026-09-01 · 定时任务看门狗修复)
## 📊 历史状态(会话 48,2026-09-01 · 定时任务看门狗修复)
**定时任务看门狗失败执行无报告输出(P0)**:5.44 连续 4 次被看门狗判失败、报告目录无文件。根因 = `run_ok=False` 时跳过报告生成。
......@@ -171,4 +215,4 @@
---
*本文档由 Claude Code 于 2026-09-02 更新(会话 50:会议巡检 3 条 drawer+click 弱用例升级为 meetingV3 Meetinginspection navigate 直达(登录页重挂载 + 12s 日历渲染,页面无 .el-table/.el-pagination,改用 .el-calendar 日历与周期/会前巡检按钮断言),执行 3/3 通过,已同步三台 5.44/5.202→413、5.60→415(无重启)并提交推送 `1924d77f` + 收尾 `d2660651`;其余弱入口运维区域/告警列表/转录列表/会议审批 直达 URL 跳各自登录页,保持 drawer+click 弱模式(memory 固化);探测脚本 7 个沉淀 + 生成脚本幂等。历史:会话 49 /sut-explore 全量盘点 + 视讯设备 navigate 直达升级 3 用例 11/11 通过已推送 `35a58b2e`;会话 48 看门狗修复 + 缺口补齐 8/8 通过已推送 `720bf764`)。*
*本文档由 Claude Code 于 2026-09-07 更新(会话 52:真录制器 Phase 5 完成 —— recorder.py /save 三重 P0 修复(孤儿 except 语法错误 + 用例创建漏出 else 分支 + case_service NameError,重写为 CaseService.create + auto_login 注入)、recorder_engine.py 主循环事件饥饿修复(page.is_closed() 不泵事件队列 → page.evaluate("1") 真实 CDP 往返泵送 expose_binding 回调)、新增 test_recorder_e2e.py 10 测试(本地 mock 页 + 独立 SQLite 全离线隔离)、Recorder.vue 两个 TS 编译错误修复(togglePause 前端暂停语义 + 删 stepCounter)。验证:后端 408/408 全绿 + 前端 npm run build 零报错,提交 `1e28bafe` 已推送。历史:会话 51 截图瘦身部署 5.44(`228c732a`+`45a247b8`,遗留 5.44 登录落首页 P0);会话 50 会议巡检 navigate 直达 3/3 通过 `1924d77f`+`d2660651`;会话 49 视讯设备直达升级 11/11 `35a58b2e`;会话 48 看门狗修复 + 缺口补齐 8/8 `720bf764`)。*
......@@ -117,6 +117,11 @@ class Settings:
RESOURCE_ALERT_CPU: float = float(os.getenv("RESOURCE_ALERT_CPU", "85.0"))
RESOURCE_ALERT_MEM: float = float(os.getenv("RESOURCE_ALERT_MEM", "85.0"))
# 用例录制器配置(真录制器 MVP)
RECORDER_ENABLED: bool = os.getenv("RECORDER_ENABLED", "true").lower() == "true"
RECORDER_SESSION_TTL: int = int(os.getenv("RECORDER_SESSION_TTL", "300")) # 会话空闲超时(秒),默认 5 分钟
RECORDER_CANDIDATE_LIMIT: int = int(os.getenv("RECORDER_CANDIDATE_LIMIT", "4")) # 候选选择器上限
# 目标机资源监控配置(SSH 方式采集被测系统资源)
TARGET_MONITOR_ENABLED: bool = os.getenv("TARGET_MONITOR_ENABLED", "false").lower() == "true"
# 注意:目标机 SSH 采样为同步阻塞且跨主机(默认关闭)。如开启需量大,建议采样间隔调大(TARGET_MONITOR_INTERVAL)
......
......@@ -898,6 +898,41 @@ def _do_locate_with_executor(executor, page_url: str, auto_login: bool,
}
# ==================== 页面注册表查询 ====================
@router.get("/pages")
async def list_registered_pages():
"""
列出页面注册表中带直达 URL 的页面
供前端「获取定位」的目标页面下拉选择(数据源 page_url_mapping.json)。
无直达 URL 的注册项(登录页/主页)不返回;URL 的 base 部分按当前
系统配置 TARGET_URL 动态替换。
Returns:
dict: {success, total, pages: [{id, name, url}]}
"""
from app.services.page_url_service import get_page_url_service
svc = get_page_url_service()
pages = []
for pid in svc.get_page_ids():
page = svc.get_scope_page(pid) or {}
url = page.get("url") or ""
if not url:
continue
try:
url = svc._replace_base_url(url)
except Exception:
pass
pages.append({
"id": pid,
"name": page.get("name") or pid,
"url": url,
})
return {"success": True, "total": len(pages), "pages": pages}
# ==================== 单个步骤定位 API ====================
@router.post("/locate", response_model=LocateResponse)
......
此差异已折叠。
此差异已折叠。
此差异已折叠。
......@@ -206,10 +206,30 @@ export interface SmartLocateResponse {
parameters?: Record<string, string>
}
/**
* 页面注册表项(page_url_mapping.json 带直达 URL 的页面)
*/
export interface RegisteredPage {
/** 页面 ID(scope 标识) */
id: string
/** 页面名称 */
name: string
/** 直达 URL(base 已按系统配置 TARGET_URL 替换) */
url: string
}
/**
* 元素定位 API
*/
export const elementLocateApi = {
/**
* 列出页面注册表中带直达 URL 的页面(「获取定位」目标页面下拉数据源)
*/
async listPages(): Promise<{ success: boolean; total: number; pages: RegisteredPage[] }> {
const response = await request.get('/api/element/pages')
return response as any
},
/**
* 执行单个步骤元素定位
*/
......
......@@ -23,6 +23,25 @@ export const recorderApi = {
return response as any
},
/**
* 获取录制会话状态
*/
async getStatus(sessionId: string): Promise<{
session_id: string
status: string
is_recording: boolean
capture_enabled: boolean
error?: string | null
steps: any[]
total_steps: number
started_at: string
}> {
const response = await request.get('/api/recorder/status', {
params: { session_id: sessionId }
})
return response as any
},
/**
* 获取已录制步骤
*/
......@@ -67,8 +86,8 @@ export const recorderApi = {
* 停止录制
*/
async stop(sessionId: string): Promise<any> {
const response = await request.post('/api/recorder/stop', null, {
params: { session_id: sessionId }
const response = await request.post('/api/recorder/stop', {
session_id: sessionId
})
return response as any
},
......@@ -83,6 +102,7 @@ export const recorderApi = {
description?: string
priority?: string
tags?: string[]
steps?: any[]
}): Promise<any> {
const response = await request.post('/api/recorder/save', data)
return response as any
......
......@@ -19,6 +19,28 @@
<template>
<div class="intent-step-editor">
<!-- 目标页面选择(获取定位用;数据源 page_url_mapping.json 注册表,支持手动输入 URL) -->
<div class="step-row target-page-row">
<span class="field-label">目标页面</span>
<el-select
v-model="targetPageUrl"
size="small"
filterable
allow-create
clearable
default-first-option
placeholder="选择注册页面或直接输入 URL"
style="flex: 1"
>
<el-option
v-for="p in pageOptions"
:key="p.id"
:label="`${p.name}(${p.url.length > 60 ? p.url.slice(0, 57) + '...' : p.url})`"
:value="p.url"
/>
</el-select>
</div>
<!-- 步骤为空时的占位 -->
<el-empty
v-if="!steps.length"
......@@ -275,9 +297,29 @@ const advancedOpen = reactive<Record<number, boolean>>({})
/** 当前正在获取定位的步骤索引 */
const locatingIndex = ref(-1)
/** 目标网页 URL */
/** 目标网页 URL(系统配置兜底值) */
const { pageUrl } = usePageUrl()
/** 目标页面下拉选项(page_url_mapping.json 注册表中带直达 URL 的页面) */
const pageOptions = ref<{ id: string; name: string; url: string }[]>([])
/** 用户选择/输入的目标页面 URL(空时回退系统配置) */
const targetPageUrl = ref('')
/** 实际生效的目标页面 URL:用户选择优先,系统配置兜底 */
const effectivePageUrl = computed(() => targetPageUrl.value || pageUrl.value)
/** 加载页面注册表选项(失败静默:下拉为空但仍可手动输入 URL) */
const loadPageOptions = async () => {
try {
const res = await elementLocateApi.listPages()
pageOptions.value = res.pages || []
} catch {
/* 静默失败 */
}
}
loadPageOptions()
// ==================== 步骤增删移动 ====================
/** 添加步骤(自动编号) */
......@@ -372,8 +414,8 @@ const handleLocate = async (index: number) => {
return
}
if (!pageUrl.value) {
ElMessage.warning('请先设置目标网页 URL(在系统配置中)')
if (!effectivePageUrl.value) {
ElMessage.warning('请先选择目标页面或输入 URL(也可在系统配置中设置默认地址)')
return
}
......@@ -382,10 +424,10 @@ const handleLocate = async (index: number) => {
try {
ElMessage.info('正在启动浏览器访问页面...')
// 调用本地元素定位 API
// 调用本地元素定位 API(page_url 支持注册页面直达 URL,不再只看首页)
const result = await elementLocateApi.locate({
step_description: step.description,
page_url: pageUrl.value,
page_url: effectivePageUrl.value,
expected: step.expected,
auto_login: true, // 默认自动登录
})
......@@ -416,6 +458,16 @@ const handleLocate = async (index: number) => {
.intent-step-editor {
width: 100%;
.target-page-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 8px 10px;
background: #f5f7fa;
border-radius: 4px;
}
.step-item {
padding: 14px;
margin-bottom: 12px;
......
......@@ -23,6 +23,8 @@ export interface IntentStep {
semantic?: Record<string, any>
verify?: Record<string, any>
run_on?: string
/** 原始后端步骤(round-trip 数据保全:action/params/selectors 整包保留,编辑不丢数据) */
_original?: Record<string, any>
}
/** 意图步骤编辑器 emit 事件 */
......@@ -158,11 +160,11 @@ export function inferSemantic(description: string): Record<string, any> | null {
/**
* 将意图步骤转换为 StepDefinition 格式
*
* 根据步骤描述自动推断 action 和 params,
* 并将 locator_type / locator_value 同步到 params.selector。
*
* 阶段 6 增强:步骤无显式定位器时附带 semantic 语义目标;
* 保留步骤自带 semantic / verify / run_on(round-trip 安全)。
* 数据保全(真录制器 PRD 配套修复):
* - 步骤带 _original(编辑回显来源)时,action/params/selectors 以原值为基底,
* 仅应用用户在意图编辑器中实际可改的字段(名称/预期/实际/等待/定位值/语义字段),
* 避免 navigate.url、wait.timeout、api_call params、selectors 回退链被重新推断抹除
* - 无 _original(新建步骤)时保持原推断逻辑不变
*
* @param step - 意图步骤数据
* @returns 标准 StepDefinition 格式
......@@ -180,12 +182,24 @@ export function toStepDefinition(step: IntentStep & {
verify?: Record<string, any>
run_on?: string
}): Record<string, any> {
const action = inferAction(step.description)
const params = inferParams(step.description, step.locatorValue)
const original = step._original as Record<string, any> | undefined
const originalParams: Record<string, any> = (original?.params as Record<string, any>) || {}
// action:有原始步骤保留原动作(推断易错且不可见),否则按描述推断
const action = (original?.action as string) || inferAction(step.description)
// 如果有 locator_value,自动同步到 params.selector
if (step.locatorValue && !params.selector) {
// params:以原 params 为基底整包保留,再做增量修改
const params: Record<string, any> = { ...originalParams }
if (!original) {
Object.assign(params, inferParams(step.description, step.locatorValue))
}
if (step.locatorValue) {
// 用户显式填写/修改定位值 → 同步到 params.selector
params.selector = step.locatorValue
} else if (original) {
// 用户清空定位值 → 移除原 selector(显式删除语义)
delete params.selector
}
const result: Record<string, any> = {
......@@ -196,22 +210,38 @@ export function toStepDefinition(step: IntentStep & {
expected: step.expected || '',
actual: step.actual || '',
waitAfter: step.waitAfter || 0,
wait_after: step.waitAfter || 0,
locator_type: step.locatorType || '',
locator_value: step.locatorValue || '',
}
// 阶段 6:无显式定位器时推断语义目标;否则保留步骤自带字段
// selectors 多选器回退链:原样保留(编辑不丢)
const originalSelectors = original?.selectors || originalParams.selectors
if (Array.isArray(originalSelectors) && originalSelectors.length) {
result.selectors = originalSelectors
if (!Array.isArray(params.selectors)) {
params.selectors = originalSelectors
}
}
// 语义字段:用户编辑值优先,其次原值,最后(无 original 时)按描述推断
if (step.semantic && typeof step.semantic === 'object' && step.semantic.text) {
result.semantic = step.semantic
} else if (!step.locatorValue) {
} else if (original?.semantic && typeof original.semantic === 'object' && original.semantic.text) {
result.semantic = original.semantic
} else if (!step.locatorValue && !original) {
const semantic = inferSemantic(step.description)
if (semantic) result.semantic = semantic
}
if (step.verify && typeof step.verify === 'object' && step.verify.type) {
result.verify = step.verify
} else if (original?.verify && typeof original.verify === 'object' && original.verify.type) {
result.verify = original.verify
}
if (step.run_on) {
result.run_on = step.run_on
} else if (original?.run_on) {
result.run_on = original.run_on
}
// P1:verify_method 兼容转换(智能定位旧产出 → verify 顶层字段)
......@@ -268,7 +298,7 @@ export function toIntentStep(step: any): IntentStep {
description: step.name || '',
expected: step.expected || '',
actual: step.actual || '',
waitAfter: step.waitAfter || 0,
waitAfter: step.wait_after ?? step.waitAfter ?? 0,
locatorType: step.locator_type || '',
locatorValue: step.locator_value || '',
}
......@@ -304,5 +334,17 @@ export function toIntentStep(step: any): IntentStep {
if (v) intentStep.verify = v
}
// 数据保全(真录制器 PRD 配套修复):保留原始后端步骤,保存时以原值为基底
// 避免 toIntentStep → toStepDefinition round-trip 重新推断抹除 params/selectors
intentStep._original = {
action: step.action,
params: step.params,
selectors: step.selectors,
semantic: step.semantic,
verify: step.verify,
run_on: step.run_on,
wait_after: step.wait_after,
}
return intentStep
}
\ No newline at end of file
......@@ -23,9 +23,12 @@ export class WebSocketClient {
/** WebSocket 连接实例 */
private ws: WebSocket | null = null
/** 执行记录 ID */
/** 会话/执行记录 ID */
private executionId: string = ''
/** API 模块前缀(executions/recorder 等,决定 WS 端点路径) */
private modulePrefix: string = 'executions'
/** 是否已连接 */
private connected: boolean = false
......@@ -41,8 +44,9 @@ export class WebSocketClient {
/** 事件处理器映射 */
private handlers: Map<string, MessageHandler[]> = new Map()
constructor(executionId: string) {
constructor(executionId: string, modulePrefix: string = 'executions') {
this.executionId = executionId
this.modulePrefix = modulePrefix
}
/**
......@@ -50,7 +54,7 @@ export class WebSocketClient {
*/
connect(): void {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${protocol}//${window.location.host}/api/executions/ws/${this.executionId}`
const url = `${protocol}//${window.location.host}/api/${this.modulePrefix}/ws/${this.executionId}`
try {
this.ws = new WebSocket(url)
......
此差异已折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论