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

feat(element-locate): 元素定位功能优化 - API增强 + 配置面板

Phase 1: 后端 API 增强
- 新增 BatchLocateRequest 参数: page_load_timeout, extra_wait_time, wait_for_selector, retry_on_empty, max_retries
- 新增 BatchLocateResponse 字段: elements_extracted, retries, page_load_time
- 增强 _do_locate_with_executor 支持配置参数和重试机制

Phase 2: 微前端元素提取增强
- 优化 extract_interactive_elements 函数,增强元素去重
- 扩展 Element UI/Vuetify/Ant Design 组件支持
- 提取更多元素属性(data-*, title, alt, role 等)

Phase 3: 前端配置面板
- 新增获取定位配置弹窗(支持配置等待时间、重试等参数)
- 新增预览页面按钮
- 优化定位结果显示(元素数量、耗时、重试次数)
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 885b901c
......@@ -300,31 +300,43 @@ function extractInteractiveElements() {
### 8.1 Phase 1 执行记录
**开始时间**:
**结束时间**:
**执行人**:
**完成情况**:
**开始时间**: 2026-08-03
**结束时间**: 2026-08-03
**执行人**: Claude Code
**完成情况**: ✅ 已完成
- 更新 BatchLocateRequest/BatchLocateResponse schema
- 增强 _do_locate_with_executor 支持配置参数和重试机制
- 记录元素数量、重试次数、页面加载耗时
### 8.2 Phase 2 执行记录
**开始时间**:
**结束时间**:
**执行人**:
**完成情况**:
**开始时间**: 2026-08-03
**结束时间**: 2026-08-03
**执行人**: Claude Code
**完成情况**: ✅ 已完成
- 优化 extract_interactive_elements 函数
- 增强元素去重逻辑(多重属性组合 key)
- 扩展 Element UI/Vuetify/Ant Design 组件支持
- 提取更多元素属性(data-*, title, alt, role 等)
### 8.3 Phase 3 执行记录
**开始时间**:
**结束时间**:
**执行人**:
**完成情况**:
**开始时间**: 2026-08-03
**结束时间**: 2026-08-03
**执行人**: Claude Code
**完成情况**: ✅ 已完成
- 新增获取定位配置弹窗
- 支持配置页面加载超时、额外等待时间、等待特定元素
- 新增预览页面按钮
- 优化定位结果显示(元素数量、耗时、重试次数)
- 前端构建验证通过
### 8.4 Phase 4 执行记录
**开始时间**:
**结束时间**:
**执行人**:
**完成情况**:
**开始时间**: 待执行
**结束时间**:
**执行人**:
**完成情况**: ⏳ 待部署验证
---
......
......@@ -2,10 +2,16 @@
> **生成时间**: 2026-08-03
> **当前分支**: `platform-auto-test`
> **最近提交**: `82c6f778` fix(element-locate): 修复批量定位数据库更新问题,延长超时时间
> **状态**: ✅ 元素定位功能完成 + 始终使用 Claude CLI + 前端步骤表格显示优化
> **最近提交**: `待提交` feat(element-locate): 元素定位功能优化 - API增强 + 配置面板
> **状态**: ✅ 元素定位功能优化完成(Phase 1-3)
>
> **本次会话完成**:
> - ✅ Phase 1: 后端 API 增强 — 新增 5 个配置参数 + 3 个响应字段 + 重试机制
> - ✅ Phase 2: 微前端元素提取增强 — 扩展 Element UI/Vuetify/Ant Design 组件支持
> - ✅ Phase 3: 前端配置面板 — 获取定位配置弹窗 + 预览页面功能
> - ✅ 前端构建验证通过
>
> **上次会话完成**:
> - ✅ 修复步骤表格不显示定位信息问题(新增"定位类型"和"定位值"列)
> - ✅ 去掉置信度判断,始终调用 Claude CLI 进行元素匹配
> - ✅ 修复批量定位数据库更新失败问题
......@@ -13,8 +19,6 @@
> - ✅ 优化微前端元素提取(支持 shadow DOM + Element UI 组件)
> - ✅ 优化 Claude CLI 调用(直接传递元素列表,不再让宿主机重新访问页面)
> - ✅ 创建测试用例验证闭环(登录成功 8/8,会议列表 2/7)
> - ✅ 生成优化需求文档和计划执行文档
> - ✅ 代码提交并推送到远程仓库
---
......@@ -197,7 +201,12 @@
{
"case_id": "case_xxx",
"page_url": "https://192.168.5.44/",
"auto_login": true
"auto_login": true,
"page_load_timeout": 10000, // 可选:页面加载超时(毫秒)
"extra_wait_time": 5000, // 可选:额外等待时间(毫秒)
"wait_for_selector": "", // 可选:等待特定元素(CSS选择器)
"retry_on_empty": true, // 可选:元素为空时重试
"max_retries": 2 // 可选:最大重试次数
}
```
......@@ -219,7 +228,10 @@
"message": "置信度 95%"
}
],
"message": "成功定位 5/6 个步骤"
"message": "成功定位 5/6 个步骤",
"elements_extracted": 42, // 新增:提取到的元素总数
"retries": 0, // 新增:实际重试次数
"page_load_time": 12.5 // 新增:页面加载耗时(秒)
}
```
......@@ -240,7 +252,7 @@
| 优先级 | 任务 | 说明 |
|--------|------|------|
| 🔴 **P0** | 元素定位功能优化 | 按已生成的 PRD 和计划执行文档实施优化 |
| 🔴 **P0** | 部署到服务器 | 前端已构建,需上传到 192.168.5.60 并验证 |
| 🟡 **P1** | 设备模拟模块联调 | MQTT 环境配置与联调待验证 |
| 🟢 **P2** | 清理临时脚本 | 根目录大量探索脚本和部署脚本待清理 |
| 🟢 **P2** | Phase 5 剩余 | Pinia 状态管理、单元测试 |
......
......@@ -58,6 +58,12 @@ class BatchLocateRequest(BaseModel):
case_id: str
page_url: str = "https://192.168.5.44"
auto_login: bool = True
# === 新增参数(Phase 1: API 增强)===
page_load_timeout: int = 10000 # 页面加载超时时间(毫秒)
extra_wait_time: int = 5000 # 页面加载后额外等待时间(毫秒)
wait_for_selector: str = "" # 等待特定元素出现(CSS选择器)
retry_on_empty: bool = True # 元素为空时是否重试
max_retries: int = 2 # 最大重试次数
class StepLocateResult(BaseModel):
......@@ -79,48 +85,141 @@ class BatchLocateResponse(BaseModel):
located_steps: int
results: List[StepLocateResult]
message: str
# === 新增字段(Phase 1: API 增强)===
elements_extracted: int = 0 # 提取到的元素总数
retries: int = 0 # 实际重试次数
page_load_time: float = 0.0 # 页面加载耗时(秒)
# ==================== 元素提取函数 ====================
def extract_interactive_elements(page) -> list:
"""提取页面中所有可交互元素的信息,支持 micro-app 微前端容器"""
"""
提取页面中所有可交互元素的信息,支持 micro-app 微前端容器
Phase 2 增强:
- 更精确的元素去重(使用多重属性组合)
- 扩展 Element UI 组件支持
- 支持 Vuetify、Ant Design 等组件库
- 提取更多元素属性(data-*, title, alt 等)
"""
return page.evaluate('''() => {
const elements = [];
document.querySelectorAll('input,button,a,select,textarea,label,[role="button"]').forEach(el => {
const seen = new Set();
function getElementKey(el) {
// 使用多重属性组合作为去重 key
const parts = [
el.tagName,
el.id || '',
el.name || '',
el.placeholder || '',
(el.innerText || '').substring(0, 30).trim(),
el.getAttribute('aria-label') || '',
el.className && typeof el.className === 'string' ? el.className.substring(0, 50) : ''
];
return parts.join('|');
}
function addElement(el, extra = {}) {
const key = getElementKey(el);
if (seen.has(key)) return;
seen.add(key);
// 提取 data-* 属性
const dataAttrs = {};
for (const attr of el.attributes || []) {
if (attr.name.startsWith('data-')) {
dataAttrs[attr.name] = attr.value;
}
}
elements.push({
tag: el.tagName, type: el.type || '', placeholder: el.placeholder || '',
text: (el.innerText || '').substring(0, 50), id: el.id || '', name: el.name || '',
className: el.className || '', ariaLabel: el.getAttribute('aria-label') || '',
dataTestid: el.getAttribute('data-testid') || '', href: el.href || '', value: el.value || '',
tag: el.tagName,
type: el.type || '',
placeholder: el.placeholder || '',
text: (el.innerText || '').substring(0, 80).trim(),
id: el.id || '',
name: el.name || '',
className: (el.className && typeof el.className === 'string') ? el.className.substring(0, 100) : '',
ariaLabel: el.getAttribute('aria-label') || '',
dataTestid: el.getAttribute('data-testid') || el.getAttribute('data-test-id') || '',
title: el.title || el.getAttribute('title') || '',
alt: el.alt || '',
href: el.href || '',
value: el.value || '',
role: el.getAttribute('role') || '',
dataAttrs: dataAttrs,
...extra
});
});
}
// 1. 主文档元素 - 扩展选择器
document.querySelectorAll('input,button,a,select,textarea,label,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="tab"],[role="menuitem"],[class*="btn"],[class*="button"],[class*="click"]').forEach(el => addElement(el));
// 2. micro-app 微前端容器 - 多种方式尝试提取
document.querySelectorAll('micro-app').forEach(microApp => {
// 方式1: shadowRoot
const shadowBody = microApp.querySelector('micro-app-body');
if (shadowBody && shadowBody.shadowRoot) {
shadowBody.shadowRoot.querySelectorAll('input,button,a,select,textarea').forEach(el => {
elements.push({
tag: el.tagName, type: el.type || '', placeholder: el.placeholder || '',
text: (el.innerText || '').substring(0, 50), id: el.id || '', name: el.name || '',
inMicroApp: true,
});
});
shadowBody.shadowRoot.querySelectorAll('input,button,a,select,textarea,[role="button"],[role="link"],[class*="el-"]').forEach(el => addElement(el, {inMicroApp: true}));
}
// 方式2: 直接在 micro-app 内查找(非 shadow DOM 模式)
microApp.querySelectorAll('input,button,a,select,textarea,[role="button"],[role="link"],[class*="el-"]').forEach(el => addElement(el, {inMicroApp: true}));
// 方式3: 查找 micro-app-body 的子元素(无 shadow)
if (shadowBody && !shadowBody.shadowRoot) {
shadowBody.querySelectorAll('input,button,a,select,textarea,[role="button"],[role="link"],[class*="el-"]').forEach(el => addElement(el, {inMicroApp: true}));
}
});
// 3. iframe 内容
document.querySelectorAll('iframe').forEach(iframe => {
try {
const iframeDoc = iframe.contentDocument;
if (iframeDoc) {
iframeDoc.querySelectorAll('input,button,a,select,textarea').forEach(el => {
elements.push({
tag: el.tagName, type: el.type || '', placeholder: el.placeholder || '',
text: (el.innerText || '').substring(0, 50), id: el.id || '', name: el.name || '',
inIframe: true,
});
});
iframeDoc.querySelectorAll('input,button,a,select,textarea,[role="button"]').forEach(el => addElement(el, {inIframe: true}));
}
} catch (e) {}
});
// 4. Element UI 组件 - 扩展选择器
const elSelectors = [
'.el-input__inner', '.el-input', '.el-select', '.el-button',
'.el-radio', '.el-checkbox', '.el-switch', '.el-slider',
'.el-date-editor', '.el-cascader', '.el-transfer',
'.el-table .el-button', '.el-form-item input',
'.el-dialog input', '.el-dialog button',
'.el-dropdown', '.el-dropdown-menu__item'
];
elSelectors.forEach(sel => {
document.querySelectorAll(sel).forEach(el => {
const input = el.matches('input, button, a, select, textarea') ? el : el.querySelector('input, button, a, select, textarea');
if (input) {
addElement(input, {isComponent: true, componentClass: el.className ? el.className.substring(0, 50) : '', framework: 'ElementUI'});
} else {
addElement(el, {isComponent: true, componentClass: el.className ? el.className.substring(0, 50) : '', framework: 'ElementUI'});
}
});
});
// 5. Vuetify 组件
const vuetifySelectors = [
'.v-btn', '.v-input', '.v-select', '.v-text-field input',
'.v-checkbox', '.v-radio', '.v-switch', '.v-slider'
];
vuetifySelectors.forEach(sel => {
document.querySelectorAll(sel).forEach(el => addElement(el, {isComponent: true, framework: 'Vuetify'}));
});
// 6. Ant Design 组件
const antdSelectors = [
'.ant-btn', '.ant-input', '.ant-select', '.ant-checkbox',
'.ant-radio', '.ant-switch', '.ant-slider'
];
antdSelectors.forEach(sel => {
document.querySelectorAll(sel).forEach(el => addElement(el, {isComponent: true, framework: 'AntDesign'}));
});
return elements;
}''')
......@@ -197,10 +296,10 @@ def build_locator_candidates(step_description: str, elements: list, page_title:
candidates.sort(key=lambda x: x['confidence'], reverse=True)
# 始终调用宿主机上的 ai_locate_worker.py,让 Claude CLI 进行精确匹配
# 不再依赖置信度判断,确保每次都使用 Claude Code 能力
logger.info("调用宿主机 ai_locate_worker.py 进行 Claude CLI 精确匹配...")
claude_result = call_claude_code_via_ssh(step_description, page_url)
# 始终调用宿主机 Claude CLI 进行精确匹配
# 容器内已登录并提取了元素,直接传给 Claude CLI 做匹配
logger.info("调用宿主机 Claude CLI 进行精确匹配...")
claude_result = call_claude_code_via_ssh(step_description, page_url, elements=elements, page_title=page_title)
if claude_result and claude_result.get('locator_type') and claude_result.get('locator_value'):
confidence = claude_result.get('confidence', 0.8)
if confidence > 0.3: # 降低阈值,只要 Claude 返回有效结果就采纳
......@@ -231,84 +330,122 @@ def build_locator_candidates(step_description: str, elements: list, page_title:
return candidates[:5]
def call_claude_code_via_ssh(step_description: str, page_url: str, expected: str = "") -> dict:
def call_claude_code_via_ssh(step_description: str, page_url: str, expected: str = "",
elements: list = None, page_title: str = "") -> dict:
"""
通过 SSH 调用宿主机上的 ai_locate_worker.py
宿主机直接使用 Playwright 访问页面获取真实元素 + Claude CLI 分析
通过 SSH 调用宿主机上的 Claude CLI 进行元素匹配
利用已有的 ai_locate_worker.py 脚本(宿主机上已有),
它已经在宿主机上安装了 Playwright + Claude Code CLI。
容器内已通过 Playwright 登录并提取了页面元素,
这里只把元素列表传给宿主机的 Claude CLI 做精确匹配,
不再让宿主机重新访问页面(避免登录状态问题)。
"""
HOST = "192.168.5.60"
HOST_USER = "ubains"
SSH_PASSWORD = "Ubains@123"
DEPLOY_DIR = "/data/third_party/plat-auto-test"
# 在宿主机上执行 ai_locate_worker.py 脚本
# 这个脚本会:
# 1. Playwright 打开目标页面,提取真实 DOM 元素
# 2. 关键词算法 + Claude Code 精确匹配
# 3. 返回定位结果
cmd = (
f'cd {DEPLOY_DIR}/backend && '
f'python3 scripts/ai_locate_worker.py '
f'--step-desc "{step_description}" '
f'--page-url "{page_url}" '
f'--expected "{expected}"'
)
if not elements:
logger.warning("没有元素列表可传给 Claude CLI")
return {"locator_type": "", "locator_value": "", "confidence": 0, "reason": "无元素数据"}
# 构造 Claude CLI 的 prompt
element_summary = json.dumps(elements[:50], ensure_ascii=False, indent=2)
prompt = f"""你是一个UI自动化测试的定位助手。根据步骤描述和页面元素列表,找到最匹配的元素。
步骤描述:{step_description}
页面标题:{page_title}
页面URL:{page_url}
可交互元素列表(JSON格式):
{element_summary}
请分析这些元素,找到与步骤描述最匹配的元素,返回JSON格式结果:
{{
"locator_type": "css 或 xpath 或 text 或 id",
"locator_value": "定位值",
"confidence": 0.0-1.0(小数表示置信度),
"reason": "简要说明为什么匹配这个元素"
}}
如果找不到匹配的元素,返回:
{{"locator_type": "", "locator_value": "", "confidence": 0, "reason": "未找到匹配元素"}}"""
try:
logger.info(f"通过 SSH 调用宿主机 {HOST} 上的 ai_locate_worker.py...")
import paramiko
import base64
# 将 prompt 编码为 base64 避免特殊字符问题
prompt_b64 = base64.b64encode(prompt.encode('utf-8')).decode('ascii')
# 通过 SSH 调用 Claude CLI
cmd = f'echo {prompt_b64} | base64 -d | claude -p --print 2>/dev/null'
logger.info(f"通过 SSH 调用宿主机 Claude CLI 进行元素匹配...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(HOST, username=HOST_USER, password=SSH_PASSWORD, timeout=10)
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=90)
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=60)
output = stdout.read().decode('utf-8', errors='replace')
err = stderr.read().decode('utf-8', errors='replace')
ssh.close()
if output:
# 脚本输出中包含 JSON 结果
# 提取 JSON 结果
json_start = output.find('{')
json_end = output.rfind('}') + 1
if json_start >= 0 and json_end > json_start:
parsed = json.loads(output[json_start:json_end])
logger.info(f"ai_locate_worker.py 返回结果: success={parsed.get('success')}, candidates={len(parsed.get('candidates', []))}")
# 从结果中提取第一个候选(最优匹配)
candidates = parsed.get('candidates', [])
if candidates:
best = candidates[0]
locator_type = parsed.get('locator_type', '')
locator_value = parsed.get('locator_value', '')
confidence = parsed.get('confidence', 0)
reason = parsed.get('reason', 'Claude Code 分析匹配')
if locator_type and locator_value:
logger.info(f"Claude CLI 匹配: {locator_value} (置信度: {confidence})")
return {
'locator_type': best.get('locator_type', ''),
'locator_value': best.get('locator_value', ''),
'confidence': best.get('confidence', 0.8),
'reason': best.get('reasons', ['Claude Code 分析匹配'])[0] if best.get('reasons') else 'Claude Code 分析匹配'
'locator_type': locator_type,
'locator_value': locator_value,
'confidence': confidence,
'reason': reason
}
else:
logger.info(f"Claude CLI 未找到匹配: {reason}")
return {"locator_type": "", "locator_value": "", "confidence": 0, "reason": reason}
else:
logger.warning(f"脚本输出中未找到 JSON: {output[:300]}")
logger.warning(f"Claude CLI 输出中未找到 JSON: {output[:300]}")
else:
logger.warning(f"SSH 命令无输出, stderr: {err[:200]}")
logger.warning(f"Claude CLI 无输出, stderr: {err[:200]}")
return {"locator_type": "", "locator_value": "", "confidence": 0, "reason": "Claude调用失败"}
except Exception as e:
logger.warning(f"Claude Code 调用异常: {e}")
return {"locator_type": "", "locator_value": "", "confidence": 0, "reason": f"Claude调用失败: {str(e)}"}
logger.warning(f"Claude CLI 调用异常: {e}")
return {"locator_type": "", "locator_value": "", "confidence": 0, "reason": f"Claude调用异常: {str(e)}"}
def _do_locate_with_executor(executor, page_url: str, auto_login: bool) -> dict:
"""使用已有的 PlaywrightExecutor 执行定位准备工作
返回 { success, elements, page_title, current_url, message }
def _do_locate_with_executor(executor, page_url: str, auto_login: bool,
page_load_timeout: int = 10000,
extra_wait_time: int = 5000,
wait_for_selector: str = "",
retry_on_empty: bool = True,
max_retries: int = 2) -> dict:
"""
使用已有的 PlaywrightExecutor 执行定位准备工作
返回 { success, elements, page_title, current_url, message, elements_extracted, retries, page_load_time }
"""
import time
start_time = time.time()
retries = 0
elements_extracted = 0
# 自动登录
if auto_login and not executor._is_logged_in:
logger.info("执行自动登录...")
login_success = executor.do_login()
if not login_success:
return {'success': False, 'elements': [], 'page_title': '', 'current_url': '', 'message': '自动登录失败'}
return {'success': False, 'elements': [], 'page_title': '', 'current_url': '',
'message': '自动登录失败', 'elements_extracted': 0, 'retries': 0, 'page_load_time': 0}
# 访问目标页面
logger.info(f"访问页面: {page_url}")
......@@ -320,23 +457,66 @@ def _do_locate_with_executor(executor, page_url: str, auto_login: bool) -> dict:
logger.info(f"已在登录后页面,跳过导航。当前 URL: {current_url}")
else:
try:
executor._page.goto(page_url, wait_until="domcontentloaded", timeout=30000)
# 使用可配置的超时时间
executor._page.goto(page_url, wait_until="domcontentloaded", timeout=page_load_timeout)
except Exception as e:
logger.warning(f"页面加载超时(networkidle): {e}")
logger.warning(f"页面加载超时: {e}")
try:
executor._page.goto(page_url, wait_until="domcontentloaded", timeout=15000)
except Exception as e2:
logger.warning(f"页面加载超时(domcontentloaded),继续: {e2}")
executor._page.wait_for_timeout(3000)
# 等待页面稳定(使用可配置的额外等待时间)
executor._page.wait_for_timeout(extra_wait_time)
# 如果指定了等待特定元素,先等待该元素出现
if wait_for_selector:
logger.info(f"等待特定元素出现: {wait_for_selector}")
try:
executor._page.wait_for_selector(wait_for_selector, timeout=extra_wait_time)
logger.info(f"元素已出现: {wait_for_selector}")
except Exception as e:
logger.warning(f"等待元素超时,继续执行: {e}")
# 微前端页面需要额外等待子应用加载
try:
executor._page.wait_for_selector('micro-app', timeout=5000)
logger.info("检测到 micro-app 容器,等待子应用加载...")
executor._page.wait_for_timeout(3000)
except Exception:
pass
# 提取元素
# 提取元素(支持重试机制)
elements = extract_interactive_elements(executor._page)
elements_extracted = len(elements)
if not elements and retry_on_empty:
for retry_count in range(max_retries):
retries += 1
logger.info(f"元素提取为空,第 {retries} 次重试(共 {max_retries} 次)...")
executor._page.wait_for_timeout(5000)
elements = extract_interactive_elements(executor._page)
elements_extracted = len(elements)
if elements:
logger.info(f"重试成功,提取到 {len(elements)} 个元素")
break
page_title = executor._page.title()
current_url = executor._page.url
logger.info(f"提取到 {len(elements)} 个元素,页面标题: {page_title}")
return {'success': True, 'elements': elements, 'page_title': page_title, 'current_url': current_url, 'message': 'ok'}
page_load_time = time.time() - start_time
logger.info(f"提取到 {len(elements)} 个元素,页面标题: {page_title},耗时: {page_load_time:.1f}s,重试: {retries} 次")
return {
'success': True,
'elements': elements,
'page_title': page_title,
'current_url': current_url,
'message': 'ok',
'elements_extracted': elements_extracted,
'retries': retries,
'page_load_time': page_load_time
}
# ==================== 单个步骤定位 API ====================
......@@ -439,7 +619,8 @@ async def locate_batch(request: BatchLocateRequest):
result_container = {
'success': False, 'case_id': request.case_id,
'total_steps': len(steps), 'located_steps': 0,
'results': [], 'message': ''
'results': [], 'message': '',
'elements_extracted': 0, 'retries': 0, 'page_load_time': 0.0
}
updated_steps = [dict(s) if not isinstance(s, dict) else s for s in steps]
......@@ -454,14 +635,24 @@ async def locate_batch(request: BatchLocateRequest):
executor = PlaywrightExecutor(config)
executor.start()
# 访问页面并提取元素(只做一次)
prep = _do_locate_with_executor(executor, request.page_url, request.auto_login)
# 访问页面并提取元素(只做一次,传递增强参数)
prep = _do_locate_with_executor(
executor, request.page_url, request.auto_login,
page_load_timeout=request.page_load_timeout,
extra_wait_time=request.extra_wait_time,
wait_for_selector=request.wait_for_selector,
retry_on_empty=request.retry_on_empty,
max_retries=request.max_retries
)
if not prep['success']:
result_container['message'] = prep['message']
return
elements = prep['elements']
page_title = prep['page_title']
result_container['elements_extracted'] = prep['elements_extracted']
result_container['retries'] = prep['retries']
result_container['page_load_time'] = prep['page_load_time']
located_count = 0
# 为每个步骤匹配定位器
......
......@@ -64,6 +64,16 @@ export interface BatchLocateRequest {
page_url?: string
/** 是否自动登录(默认 true) */
auto_login?: boolean
/** 页面加载超时时间(毫秒,默认 10000) */
page_load_timeout?: number
/** 页面加载后额外等待时间(毫秒,默认 5000) */
extra_wait_time?: number
/** 等待特定元素出现(CSS选择器) */
wait_for_selector?: string
/** 元素为空时是否重试(默认 true) */
retry_on_empty?: boolean
/** 最大重试次数(默认 2) */
max_retries?: number
}
/**
......@@ -102,6 +112,12 @@ export interface BatchLocateResponse {
results: StepLocateResult[]
/** 消息 */
message: string
/** 提取到的元素总数 */
elements_extracted?: number
/** 实际重试次数 */
retries?: number
/** 页面加载耗时(秒) */
page_load_time?: number
}
/**
......
......@@ -188,7 +188,7 @@
size="small"
type="success"
link
@click="batchLocate(row)"
@click="showLocateConfig(row)"
:loading="locatingCaseId === row.id"
:disabled="row.caseType !== 'ui'"
>
......@@ -217,6 +217,67 @@
</div>
</el-card>
<!-- 获取定位配置弹窗 -->
<el-dialog
v-model="locateConfigVisible"
title="批量获取定位"
width="500px"
destroy-on-close
>
<el-form label-width="120px">
<el-form-item label="用例名称">
<el-input :value="locateConfigCaseName" disabled />
</el-form-item>
<el-divider content-position="left">高级配置</el-divider>
<el-form-item label="页面加载超时">
<el-input-number
v-model="locateConfig.page_load_timeout"
:min="5000"
:max="60000"
:step="5000"
style="width: 150px"
/>
<span style="margin-left: 10px; color: #999">毫秒</span>
</el-form-item>
<el-form-item label="额外等待时间">
<el-input-number
v-model="locateConfig.extra_wait_time"
:min="0"
:max="30000"
:step="1000"
style="width: 150px"
/>
<span style="margin-left: 10px; color: #999">毫秒</span>
</el-form-item>
<el-form-item label="等待特定元素">
<el-input
v-model="locateConfig.wait_for_selector"
placeholder="CSS 选择器(可选)"
style="width: 300px"
/>
</el-form-item>
<el-form-item label="自动重试">
<el-switch v-model="locateConfig.retry_on_empty" />
<span style="margin-left: 10px; color: #999">元素为空时自动重试</span>
</el-form-item>
<el-form-item v-if="locateConfig.retry_on_empty" label="最大重试次数">
<el-input-number
v-model="locateConfig.max_retries"
:min="1"
:max="5"
style="width: 100px"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="previewPage">预览页面</el-button>
<el-button @click="locateConfigVisible = false">取消</el-button>
<el-button type="primary" @click="batchLocate" :loading="locatingCaseId !== ''">
开始获取
</el-button>
</template>
</el-dialog>
<!-- 创建/编辑用例弹窗 -->
<el-dialog
v-model="editDialogVisible"
......@@ -884,6 +945,34 @@ const deleteCase = async (row: any) => {
/** 当前正在获取定位的用例 ID */
const locatingCaseId = ref('')
/** 配置面板相关状态 */
const locateConfigVisible = ref(false)
const locateConfigCaseId = ref('')
const locateConfigCaseName = ref('')
const locateConfig = ref({
page_load_timeout: 10000,
extra_wait_time: 5000,
wait_for_selector: '',
retry_on_empty: true,
max_retries: 2,
})
/**
* 显示获取定位配置面板
*/
const showLocateConfig = (row: any) => {
locateConfigCaseId.value = row.id
locateConfigCaseName.value = row.name
locateConfigVisible.value = true
}
/**
* 预览页面(在新窗口打开已登录的目标页面)
*/
const previewPage = () => {
window.open('https://192.168.5.44/', '_blank')
}
/**
* 一键获取用例所有步骤的元素定位
*
......@@ -893,29 +982,35 @@ const locatingCaseId = ref('')
* 3. 为每个步骤匹配定位器并更新到数据库
* 4. 前端显示结果
*/
const batchLocate = async (row: any) => {
try {
await ElMessageBox.confirm(
`将为用例「${row.name}」的所有步骤自动获取元素定位,是否继续?`,
'批量获取定位',
{ type: 'info', confirmButtonText: '开始获取', cancelButtonText: '取消' }
)
} catch {
return
}
const batchLocate = async () => {
locateConfigVisible.value = false
locatingCaseId.value = locateConfigCaseId.value
locatingCaseId.value = row.id
try {
ElMessage.info('正在启动浏览器获取定位,请稍候...')
const result = await elementLocateApi.locateBatch({
case_id: row.id,
case_id: locateConfigCaseId.value,
page_url: 'https://192.168.5.44/',
auto_login: true,
page_load_timeout: locateConfig.value.page_load_timeout,
extra_wait_time: locateConfig.value.extra_wait_time,
wait_for_selector: locateConfig.value.wait_for_selector || undefined,
retry_on_empty: locateConfig.value.retry_on_empty,
max_retries: locateConfig.value.max_retries,
})
if (result.success) {
const msg = `定位完成:成功 ${result.located_steps}/${result.total_steps} 个步骤`
let msg = `定位完成:成功 ${result.located_steps}/${result.total_steps} 个步骤`
if (result.elements_extracted) {
msg += `,提取 ${result.elements_extracted} 个元素`
}
if (result.page_load_time) {
msg += `,耗时 ${result.page_load_time.toFixed(1)}s`
}
if (result.retries) {
msg += `,重试 ${result.retries} 次`
}
ElMessage.success(msg)
// 刷新用例列表
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论