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

feat(handoff): P1 遗留待办处理 — 验证字段统一、断言补全、定位器优化

- 智能定位同步设置 verify 顶层字段(dialog/element_visible/text_visible),打通执行器消费链
- 执行器 _do_assert 新增 body_not_contains 断言分支
- 执行器 _do_fill 增加 scroll_into_view_if_needed() 滚动兜底
- 前端 CaseStepEditor 独立 VERIFY_TYPE_OPTIONS(8 种 verify 类型)
- 前端 IntentStepEditor 新增 verify 折叠编辑区(round-trip 保留)
- 前端 stepParser 新增 verifyMethodToVerify() 兼容转换函数
- page_url_mapping.json 注册 19 个二级菜单(match_rules 识别)
- 新增 PRD 需求文档与计划执行文档
- HANDOFF 更新:标记 3 个 P0 映射问题已修复
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 7cb9b7c1
# PRD — P1 遗留待办:验证步骤前端适配与定位器优化
> **文档版本**: v1.0
> **创建日期**: 2026-08-21
> **作者**: Claude Code(czj 确认)
> **状态**: 待确认
> **关联文档**: `HANDOFF_UI自动化.md`(P1 待办项)
---
## 一、背景与问题
### 1.1 背景
`HANDOFF_UI自动化.md` 遗留两个 P1 待办项,经代码调研确认当前状态:
| P1 待办 | 当前状态 | 问题本质 |
|---------|---------|---------|
| 前端适配验证步骤的 `verify_method` 参数 | 后端生成字段但前端无 UI、执行器不消费 | 字段设计割裂(`verify_method` vs `verify` 顶层字段) |
| 增加更多菜单的定位器优化(滚动、备选选择器) | `page_url_mapping.json` 仅注册 6 个页面,19 个二级菜单无条目 | `_do_fill` 无滚动兜底、二级菜单无页面注册 |
### 1.2 问题详情
#### 1.2.1 `verify_method` 字段设计割裂
代码调研结论(详见探索报告):
| 项 | 现状 | 问题 |
|----|------|------|
| `smart_locate_service.py`(L597/L648/L694) | 智能定位生成 `params.verify_method``message`/`search`/`text_visible` 3 个值) | 写入的是 `params` 顶层,**未设置 `verify` 顶层字段** |
| `playwright_executor.py` `_verify_step_state`(L2748) | 仅消费 `step.get("verify")` 顶层字段,结构 `{"type": "...", "selector": "...", "text": "...", "must": bool}` | **完全不消费 `verify_method`** |
| `_build_verify_assert`(L2799) | verify 类型:`text_visible`/`element_visible`/`text_hidden`/`element_exists`/`url_contains`/`dialog`/`value`/`count` | 类型与前端 `ASSERT_TYPE_OPTIONS`(10 种 assert 类型)**不一致** |
| 前端 `CaseStepEditor.vue`(L479) | `ASSERT_TYPE_OPTIONS` 10 种:visible/hidden/text/contains/value/count/url_contains/title/element_exists/element_count_min | 缺少 `body_not_contains`(后端 `api_test_executor.py` L231 已支持但 `playwright_executor.py` `_do_assert` **不支持**) |
| 前端 `IntentStepEditor.vue` | **完全没有** verify/semantic 编辑区 | 意图模式下验证步骤无法编辑 |
**根因链**`verify_method` 是旧方案遗留字段,只标记"验证方式"但未形成可持久化/可消费的 `verify` 步骤配置 → 前端无处展示、执行器不执行、智能定位结果"看着有验证、实际不验证"。
#### 1.2.2 二级菜单定位器缺失
| 项 | 现状 | 影响 |
|----|------|------|
| `page_url_mapping.json` | 仅 6 个页面(create_meeting/login/home/meeting_detail/meeting_list/statistics) | 功能中心 19 个二级菜单(壁纸推送/脚本命令/应用管理/屏幕管理/软件卸载/设备网络/门口屏发布/播放器发布/资产信息/资产设备/运维设备/运维巡检/工单列表/我的工单/告警工单/信息窗管理/消息通知/会务统筹/会务工单)**均无页面注册** |
| `_do_fill` | 无显式 `scroll_into_view_if_needed()` | `_do_click` 已有(L2019/L2068/L2161),fill 依赖 click 间接触发,独立输入场景缺少滚动兜底 |
| 选择器解析 `_resolve_selectors`(L677) | 已支持 `params.selectors`(数组)+ `params.selector`(单个)双模式 + `page_key/element_key` 查 SelectorMapper | 多选择器回退机制完善,但存量步骤多只用单个选择器 |
---
## 二、目标与非目标
### 2.1 目标
1. **统一验证字段模型**:智能定位同时输出 `verify` 顶层字段(执行器真实消费),`verify_method` 作为兼容标记保留
2. **前端补全验证类型**`ASSERT_TYPE_OPTIONS` 新增 `body_not_contains`;verify 编辑区使用独立的 `VERIFY_TYPE_OPTIONS`(与后端 `_build_verify_assert` 对齐)
3. **意图步骤编辑器支持验证**`IntentStepEditor.vue` 新增 verify 折叠编辑区(round-trip 保留语义字段)
4. **执行器补全 `body_not_contains` 断言**`_do_assert` 支持页面响应体/文本不包含校验
5. **二级菜单页面注册**:为 19 个二级菜单补充 `page_url_mapping.json` 条目(url_patterns + fingerprint),提升 scope 识别与直达导航能力
6. **定位滚动优化**`_do_fill` 增加 `scroll_into_view_if_needed()` 兜底
### 2.2 非目标
1. 不替换现有执行器/语义解析链(老步骤走原路径,完全向后兼容)
2. 不做智能定位 API 与用例执行路径的统一(P2,另行评估)
3. 不完善 `menu_mapping.py` 全量二级菜单 URL(P2)
4. 不做 Claude 调用性能优化(P2)
---
## 三、方案设计
### 3.1 验证字段统一(P1-1 核心)
```
旧方案:params.verify_method = "message" (前端无 UI、执行器不消费)
新方案:verify = {"type": "dialog"} (执行器 _verify_step_state 消费)
verify = {"type": "text_visible", "text": target_text}
```
`smart_locate_service.py` 三处 `verify_method` 赋值处同步设置 `verify` 顶层字段:
| verify_method | verify 顶层字段 | 说明 |
|---|---|---|
| `message`(L597) | `{"type": "dialog"}` | .el-message--success 提示出现 |
| `search`(L648) | `{"type": "element_visible"}` | 搜索框可见 |
| `text_visible`(L694) | `{"type": "text_visible", "text": target_text}` | 指定文本可见 |
**理由**`verify` 顶层字段是 `step.get("verify")` 读取字段(`_verify_step_state` L2768),`params.verify_method` 无任何消费方。前后端数据链路打通后,智能定位产出的步骤保存到数据库,前端编辑回显时 `stepParser.ts` round-trip 保留 `step.verify`(已支持,L210-211/L262-263)。
### 3.2 执行器新增 `body_not_contains` 断言(P1-1)
`_do_assert` 新增分支(与 `api_test_executor.py` L231 语义对齐):
```
assert_type == "body_not_contains":
1. 有 selector → 取该元素 innerText(多选择器逐个尝试)
2. 无 selector → 取 page.content() 全文文本
3. expected 为空 → 视为失败(防假通过,与 api 版一致)
4. expected 不在文本中 → 通过
```
### 3.3 前端 CaseStepEditor 补全(P1-1)
1. `ASSERT_TYPE_OPTIONS` 新增:`body_not_contains`(响应体/页面不包含)
2. 新增独立 `VERIFY_TYPE_OPTIONS`(与后端 `_build_verify_assert` 对齐):
| value | label | 需要 selector | 需要 text |
|-------|-------|:---:|:---:|
| `text_visible` | 文本可见 | ✓ | ✓ |
| `element_visible` | 元素可见 | ✓ | ✗ |
| `text_hidden` | 文本隐藏 | ✓ | ✓ |
| `element_exists` | 元素存在 | ✓ | ✗ |
| `url_contains` | URL 包含 | ✗ | ✓ |
| `dialog` | 弹窗提示 | ✗ | ✓ |
| `value` | 输入值相等 | ✓ | ✓ |
| `count` | 元素数量 | ✓ | ✓ |
3. verify 编辑区改用 `VERIFY_TYPE_OPTIONS`(不再复用 `ASSERT_TYPE_OPTIONS`),`verifyNeedsSelector`/`verifyNeedsText` 同步调整
### 3.4 前端 IntentStepEditor 新增 verify 编辑区(P1-1)
在预期结果区域下方新增折叠高级区(样式参考 CaseStepEditor `.step-advanced`):
- verify 类型选择(VERIFY_TYPE_OPTIONS)
- selector / text / timeout / must 字段
- **不改动** `stepParser.ts` round-trip(已验证保留 `step.verify`),仅新增 UI
### 3.5 二级菜单页面注册(P1-2)
`page_url_mapping.json` 新增 19 个二级菜单条目:
- url 字段已知的用直达 URL(参考 `menu_mapping.py` `MENU_BLOCK_ROUTES``/#/meetingV3?meetingV3=%2F...%2F{Page}` 模式)
- url_patterns 用页面标识(如 `WallpaperPush``ScriptCommand` 等,需按实际页面实测)
- fingerprint 用页面标题或唯一文本(待 `/sut-explore` 真机探测确认后填充,本阶段先注册结构 + 可识别模式)
- ⚠️ **注意**:url_patterns/fingerprint 仅用于 scope 识别,**不影响存量用例**;新增条目后 `page_url_service` 单例不热加载需重启 uvicorn
### 3.6 定位滚动优化(P1-2)
`_do_fill` 在 fill 前增加 `scroll_into_view_if_needed()`(与 `_do_click` L2068 一致),失败不阻塞(try/except 包裹)。
---
## 四、验收标准
| # | 验收项 | 验证方式 |
|---|--------|---------|
| 1 | 智能定位验证类步骤返回 `verify` 顶层字段 | 手动触发 `POST /api/element/smart-locate`,检查返回步骤含 verify |
| 2 | `_do_assert` 支持 `body_not_contains` | 后端单测 + 含该断言的 api_call 步骤执行 |
| 3 | 前端 assert 动作下拉含 `body_not_contains` | `npm run build` + 页面检查 |
| 4 | verify 编辑区用独立类型(text_visible/dialog 等) | 页面检查 CaseStepEditor 高级区 |
| 5 | IntentStepEditor 可编辑 verify | 页面检查 + 保存回显不丢 |
| 6 | 存量用例零回归 | 后端 `pytest` 全量 + 前端 `npm run build` |
| 7 | `_do_fill` 滚动兜底 | 代码 review + 含 fill 的用例执行**
## 五、风险评估
| 风险 | 等级 | 缓解 |
|------|------|------|
| `page_url_mapping.json` 二级菜单 URL 需真机探测确认 | 中 | 本阶段先注册结构 + 可识别模式,具体 URL 走 `/sut-explore` 实测后补 |
| verify 类型与 assert 类型拆分后存量 verify 数据兼容 | 低 | 前端仅 UI 展示层拆分,存取仍为 `verify` 顶层字段 dict,round-trip 不丢 |
| `body_not_contains` 页面全文误判(弹层文本干扰) | 低 | 无 selector 时取 `page.content()`,与 api 版语义一致;优先推荐带 selector 使用 |
\ No newline at end of file
此差异已折叠。
...@@ -90,6 +90,177 @@ ...@@ -90,6 +90,177 @@
"step_name_contains": ["预定数据", "数据统计"], "step_name_contains": ["预定数据", "数据统计"],
"action_is": ["click"] "action_is": ["click"]
} }
},
{
"id": "wallpaper_push",
"name": "壁纸推送",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "壁纸推送"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "script_command",
"name": "脚本命令",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "脚本命令"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "app_management",
"name": "应用管理",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "应用管理"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "screen_management",
"name": "屏幕管理",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "屏幕管理"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "software_uninstall",
"name": "软件卸载",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "软件卸载"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "device_network",
"name": "设备网络",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "设备网络"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "door_publish",
"name": "门口屏发布",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "门口屏发布"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "player_publish",
"name": "播放器发布",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "播放器发布"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "asset_info",
"name": "资产信息",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "资产信息"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "asset_device",
"name": "资产设备",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "资产设备"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "ops_device",
"name": "运维设备",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "运维设备"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "ops_inspection",
"name": "运维巡检",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "运维巡检"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "work_order_list",
"name": "工单列表",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "工单列表"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "my_work_order",
"name": "我的工单",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "我的工单"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "alarm_work_order",
"name": "告警工单",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "告警工单"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "info_window",
"name": "信息窗管理",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "信息窗管理"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "message_notification",
"name": "消息通知",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "消息通知"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "conference_coordination",
"name": "会务统筹",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "会务统筹"},
{"type": "action_is", "value": "click"}
]
},
{
"id": "conference_work_order",
"name": "会务工单",
"url": "",
"match_rules": [
{"type": "step_name_contains", "value": "会务工单"},
{"type": "action_is", "value": "click"}
]
} }
] ]
} }
...@@ -595,6 +595,7 @@ class SmartLocateService: ...@@ -595,6 +595,7 @@ class SmartLocateService:
result['params']['timeout'] = 10000 result['params']['timeout'] = 10000
result['params']['expected_text'] = message_text # 记录提示文本 result['params']['expected_text'] = message_text # 记录提示文本
result['params']['verify_method'] = 'message' # 验证方式 result['params']['verify_method'] = 'message' # 验证方式
result['verify'] = {'type': 'dialog'} # P1: 同步设置 verify 顶层字段(执行器 _verify_step_state 消费)
result['message'] = f'验证类步骤(提示语),找到提示: {message_text}' result['message'] = f'验证类步骤(提示语),找到提示: {message_text}'
logger.info(f"步骤 {order} 定位成功(提示验证): {sel}") logger.info(f"步骤 {order} 定位成功(提示验证): {sel}")
...@@ -646,6 +647,7 @@ class SmartLocateService: ...@@ -646,6 +647,7 @@ class SmartLocateService:
# 从步骤名称中提取搜索关键词(如"查看列表是否正确新增数据") # 从步骤名称中提取搜索关键词(如"查看列表是否正确新增数据")
# 用户需要手动指定搜索词,这里只提供选择器 # 用户需要手动指定搜索词,这里只提供选择器
result['params']['verify_method'] = 'search' # 验证方式 result['params']['verify_method'] = 'search' # 验证方式
result['verify'] = {'type': 'element_visible', 'selector': sel} # P1: 同步设置 verify 顶层字段(搜索框可见)
result['message'] = f'验证类步骤(列表搜索),找到搜索框: {sel}' result['message'] = f'验证类步骤(列表搜索),找到搜索框: {sel}'
logger.info(f"步骤 {order} 定位成功(列表搜索): {sel}") logger.info(f"步骤 {order} 定位成功(列表搜索): {sel}")
...@@ -693,6 +695,7 @@ class SmartLocateService: ...@@ -693,6 +695,7 @@ class SmartLocateService:
result['params']['timeout'] = 20000 result['params']['timeout'] = 20000
result['params']['verify_method'] = 'text_visible' result['params']['verify_method'] = 'text_visible'
result['params']['expected_text'] = target_text # 记录目标文本 result['params']['expected_text'] = target_text # 记录目标文本
result['verify'] = {'type': 'text_visible', 'text': target_text} # P1: 同步设置 verify 顶层字段(目标文本可见)
result['message'] = f'验证类步骤(目标文本可见),找到文本: {target_text}' result['message'] = f'验证类步骤(目标文本可见),找到文本: {target_text}'
logger.info(f"步骤 {order} 定位成功(目标文本验证): {text_sel}") logger.info(f"步骤 {order} 定位成功(目标文本验证): {text_sel}")
......
...@@ -344,7 +344,7 @@ ...@@ -344,7 +344,7 @@
<div class="adv-row"> <div class="adv-row">
<el-select v-model="getVerify(step).type" size="small" style="width: 160px" placeholder="验证类型" clearable> <el-select v-model="getVerify(step).type" size="small" style="width: 160px" placeholder="验证类型" clearable>
<el-option <el-option
v-for="t in ASSERT_TYPE_OPTIONS" v-for="t in VERIFY_TYPE_OPTIONS"
:key="t.value" :key="t.value"
:label="t.label" :label="t.label"
:value="t.value" :value="t.value"
...@@ -475,7 +475,7 @@ const SEMANTIC_TYPE_OPTIONS = [ ...@@ -475,7 +475,7 @@ const SEMANTIC_TYPE_OPTIONS = [
{ label: '弹窗 (dialog)', value: 'dialog' }, { label: '弹窗 (dialog)', value: 'dialog' },
] ]
/** 断言类型选项(10 种,与后端 _do_assert 对齐;url 已统一为 url_contains) */ /** 断言类型选项(11 种,与后端 _do_assert 对齐;url 已统一为 url_contains) */
const ASSERT_TYPE_OPTIONS = [ const ASSERT_TYPE_OPTIONS = [
{ label: '元素可见 (visible)', value: 'visible' }, { label: '元素可见 (visible)', value: 'visible' },
{ label: '元素隐藏 (hidden)', value: 'hidden' }, { label: '元素隐藏 (hidden)', value: 'hidden' },
...@@ -487,6 +487,19 @@ const ASSERT_TYPE_OPTIONS = [ ...@@ -487,6 +487,19 @@ const ASSERT_TYPE_OPTIONS = [
{ label: '标题包含 (title)', value: 'title' }, { label: '标题包含 (title)', value: 'title' },
{ label: '元素存在 (element_exists)', value: 'element_exists' }, { label: '元素存在 (element_exists)', value: 'element_exists' },
{ label: '数量>=最小 (element_count_min)', value: 'element_count_min' }, { label: '数量>=最小 (element_count_min)', value: 'element_count_min' },
{ label: '页面不包含 (body_not_contains)', value: 'body_not_contains' },
]
/** verify 类型选项(与后端 _build_verify_assert 对齐,独立于 assert 类型) */
const VERIFY_TYPE_OPTIONS = [
{ label: '文本可见 (text_visible)', value: 'text_visible' },
{ label: '元素可见 (element_visible)', value: 'element_visible' },
{ label: '文本隐藏 (text_hidden)', value: 'text_hidden' },
{ label: '元素存在 (element_exists)', value: 'element_exists' },
{ label: 'URL 包含 (url_contains)', value: 'url_contains' },
{ label: '弹窗提示 (dialog)', value: 'dialog' },
{ label: '输入值相等 (value)', value: 'value' },
{ label: '元素数量 (count)', value: 'count' },
] ]
// ==================== 辅助方法 ==================== // ==================== 辅助方法 ====================
...@@ -549,13 +562,13 @@ const needsSelector = (type: string): boolean => ...@@ -549,13 +562,13 @@ const needsSelector = (type: string): boolean =>
const needsExpected = (type: string): boolean => const needsExpected = (type: string): boolean =>
['text', 'contains', 'value', 'count', 'url_contains', 'title', 'element_count_min'].includes(type || '') ['text', 'contains', 'value', 'count', 'url_contains', 'title', 'element_count_min'].includes(type || '')
/** 验证是否需要选择器 */ /** 验证是否需要选择器(verify 类型口径) */
const verifyNeedsSelector = (type: string): boolean => const verifyNeedsSelector = (type: string): boolean =>
needsSelector(type) ['text_visible', 'element_visible', 'text_hidden', 'element_exists', 'value', 'count'].includes(type || '')
/** 验证是否需要期望文本 */ /** 验证是否需要期望文本(verify 类型口径) */
const verifyNeedsText = (type: string): boolean => const verifyNeedsText = (type: string): boolean =>
needsExpected(type) ['text_visible', 'text_hidden', 'url_contains', 'dialog', 'value', 'count'].includes(type || '')
/** /**
* 动作变更时重置无关参数,避免残留干扰 * 动作变更时重置无关参数,避免残留干扰
......
...@@ -145,6 +145,68 @@ ...@@ -145,6 +145,68 @@
clearable clearable
/> />
</div> </div>
<!-- 状态验证折叠区(P1:意图模式支持 verify 编辑,防假通过) -->
<div class="step-row verify-toggle-row">
<el-button
size="small"
link
type="primary"
@click="toggleAdvanced(index)"
>
<el-icon class="toggle-icon" :class="{ expanded: !!advancedOpen[index] }"><ArrowRight /></el-icon>
{{ hasVerify(step) ? `状态验证(${verifyTypeLabel(step)})` : '状态验证(可选,防假通过)' }}
</el-button>
</div>
<div v-if="advancedOpen[index]" class="verify-area">
<div class="step-row">
<span class="field-label">验证类型</span>
<el-select
v-model="getVerify(step).type"
size="small"
style="width: 200px"
placeholder="选择验证类型"
clearable
>
<el-option
v-for="t in VERIFY_TYPE_OPTIONS"
:key="t.value"
:label="t.label"
:value="t.value"
/>
</el-select>
<el-input
v-if="verifyNeedsSelector(getVerify(step).type)"
v-model="getVerify(step).selector"
placeholder="验证选择器(CSS/XPath)"
size="small"
style="flex: 1"
/>
<el-input
v-if="verifyNeedsText(getVerify(step).type)"
v-model="getVerify(step).text"
placeholder="期望文本/URL片段"
size="small"
style="width: 240px"
/>
</div>
<div class="step-row">
<span class="field-label">验证参数</span>
<span class="wait-label">超时</span>
<el-input-number
v-model="getVerify(step).timeout"
:min="0"
:step="1000"
size="small"
controls-position="right"
style="width: 130px"
/>
<span class="wait-label">ms</span>
<el-checkbox v-model="getVerify(step).must">
失败即失败(must)
</el-checkbox>
</div>
</div>
</div> </div>
<!-- 添加步骤按钮 --> <!-- 添加步骤按钮 -->
...@@ -168,13 +230,33 @@ ...@@ -168,13 +230,33 @@
* 意图步骤编辑器,通过 v-model 双向绑定 IntentStep 数组。 * 意图步骤编辑器,通过 v-model 双向绑定 IntentStep 数组。
* 每行包含:步骤描述 + 等待时间 + 预期结果 + 定位信息。 * 每行包含:步骤描述 + 等待时间 + 预期结果 + 定位信息。
*/ */
import { ref, computed } from 'vue' import { ref, computed, reactive } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Top, Bottom, Delete, Plus, Search } from '@element-plus/icons-vue' import { Top, Bottom, Delete, Plus, Search, ArrowRight } from '@element-plus/icons-vue'
import type { IntentStep } from '@/utils/stepParser' import type { IntentStep } from '@/utils/stepParser'
import { elementLocateApi } from '@/api/elementLocate' import { elementLocateApi } from '@/api/elementLocate'
import { usePageUrl } from '@/utils/usePageUrl' import { usePageUrl } from '@/utils/usePageUrl'
/** 状态验证类型选项(与后端 _build_verify_assert 对齐;CaseStepEditor 同款) */
const VERIFY_TYPE_OPTIONS = [
{ label: '文本可见 (text_visible)', value: 'text_visible' },
{ label: '元素可见 (element_visible)', value: 'element_visible' },
{ label: '文本隐藏 (text_hidden)', value: 'text_hidden' },
{ label: '元素存在 (element_exists)', value: 'element_exists' },
{ label: 'URL 包含 (url_contains)', value: 'url_contains' },
{ label: '弹窗提示 (dialog)', value: 'dialog' },
{ label: '输入值相等 (value)', value: 'value' },
{ label: '元素数量 (count)', value: 'count' },
]
/** 验证是否需要选择器(verify 类型口径) */
const verifyNeedsSelector = (type: string): boolean =>
['text_visible', 'element_visible', 'text_hidden', 'element_exists', 'value', 'count'].includes(type || '')
/** 验证是否需要期望文本(verify 类型口径) */
const verifyNeedsText = (type: string): boolean =>
['text_visible', 'text_hidden', 'url_contains', 'dialog', 'value', 'count'].includes(type || '')
const props = defineProps<{ const props = defineProps<{
/** v-model 绑定的意图步骤数组 */ /** v-model 绑定的意图步骤数组 */
modelValue: IntentStep[] modelValue: IntentStep[]
...@@ -184,9 +266,12 @@ const emit = defineEmits<{ ...@@ -184,9 +266,12 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: IntentStep[]): void (e: 'update:modelValue', value: IntentStep[]): void
}>() }>()
/** steps 计算属性 */ /** 步骤计算属性 */
const steps = computed(() => props.modelValue) const steps = computed(() => props.modelValue)
/** 每行的折叠区展开状态(key 为步骤索引) */
const advancedOpen = reactive<Record<number, boolean>>({})
/** 当前正在获取定位的步骤索引 */ /** 当前正在获取定位的步骤索引 */
const locatingIndex = ref(-1) const locatingIndex = ref(-1)
...@@ -238,6 +323,32 @@ const emitChange = () => { ...@@ -238,6 +323,32 @@ const emitChange = () => {
emit('update:modelValue', steps.value) emit('update:modelValue', steps.value)
} }
// ==================== 状态验证编辑 ====================
/** 读取(必要时初始化)步骤的 verify 对象(v-model 直绑,round-trip 保留) */
const getVerify = (step: IntentStep): Record<string, any> => {
if (!step.verify || typeof step.verify !== 'object') {
// 直接赋值以触发响应式更新
step.verify = { type: '', selector: '', text: '', timeout: 0, must: false }
}
return step.verify
}
/** 步骤是否已有有效 verify 类型 */
const hasVerify = (step: IntentStep): boolean => !!step.verify && !!step.verify.type
/** verify 类型中文标签(用于折叠按钮文案) */
const verifyTypeLabel = (step: IntentStep): string => {
const t = getVerify(step).type
const found = VERIFY_TYPE_OPTIONS.find((o) => o.value === t)
return found ? found.label : (t || '未设置')
}
/** 展开/收起折叠区 */
const toggleAdvanced = (index: number) => {
advancedOpen[index] = !advancedOpen[index]
}
// ==================== 元素定位 ==================== // ==================== 元素定位 ====================
/** /**
...@@ -378,6 +489,26 @@ const handleLocate = async (index: number) => { ...@@ -378,6 +489,26 @@ const handleLocate = async (index: number) => {
padding-top: 8px; padding-top: 8px;
border-top: 1px dashed #e8eaed; border-top: 1px dashed #e8eaed;
} }
.verify-toggle-row {
padding-top: 6px;
margin-top: 4px;
.toggle-icon {
transition: transform 0.2s;
&.expanded {
transform: rotate(90deg);
}
}
}
.verify-area {
padding: 10px 10px 6px 10px;
margin-top: 4px;
background: #f0f5ff;
border: 1px solid #d9e6ff;
border-radius: 4px;
}
} }
.add-step-btn { .add-step-btn {
......
...@@ -214,9 +214,41 @@ export function toStepDefinition(step: IntentStep & { ...@@ -214,9 +214,41 @@ export function toStepDefinition(step: IntentStep & {
result.run_on = step.run_on result.run_on = step.run_on
} }
// P1:verify_method 兼容转换(智能定位旧产出 → verify 顶层字段)
if (!result.verify && params.verify_method) {
const v = verifyMethodToVerify(params.verify_method, params)
if (v) result.verify = v
}
return result return result
} }
/**
* 将智能定位旧字段 verify_method 转换为 verify 顶层字段
*
* P1 兼容转换:只读模式下已支持 verify_method → verify 映射,
* 保存时同样转换,避免「仅编辑不保存丢失」。
*
* @param vm - verify_method 值(message / search / text_visible)
* @param params - 动作参数(可能含 selector / expected_text / expected)
* @returns verify 对象或 null
*/
export function verifyMethodToVerify(
vm: string,
params: Record<string, any>
): Record<string, any> | null {
if (!vm) return null
if (vm === 'message') return { type: 'dialog' }
if (vm === 'search') return { type: 'element_visible', selector: params?.selector || '' }
if (vm === 'text_visible') {
return {
type: 'text_visible',
text: params?.expected_text || params?.expected || '',
}
}
return null
}
/** /**
* 将后端 StepDefinition 转换为意图步骤格式 * 将后端 StepDefinition 转换为意图步骤格式
* *
...@@ -266,5 +298,11 @@ export function toIntentStep(step: any): IntentStep { ...@@ -266,5 +298,11 @@ export function toIntentStep(step: any): IntentStep {
intentStep.run_on = step.run_on intentStep.run_on = step.run_on
} }
// P1:verify_method 兼容转换(智能定位旧产出,回显时可编辑)
if (!intentStep.verify && step.params?.verify_method) {
const v = verifyMethodToVerify(step.params.verify_method, step.params)
if (v) intentStep.verify = v
}
return intentStep return intentStep
} }
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论