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

feat(smart-locate, device-sim): 验证类步骤自动识别 + 设备列表增强

验证类步骤自动识别:
- 新增 is_verification_step() 函数,识别包含"查看/检查/验证"等关键词的步骤
- 支持两种验证方式:提示语验证(el-message)和列表搜索验证
- 修复 page 变量获取时机问题

设备模拟模块优化:
- 新增全部启动/全部停止按钮(当前页设备)
- 分页器支持每页条数选择(10/20/50/100)
- 移除右侧消息流面板,设备列表全宽显示
- 修复 Excel 模板下载中文文件名编码问题

其他修复:
- 登录页面加载策略改为 domcontentloaded,避免 SPA 超时
- 更新文档:执行计划、交接文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 ae32844b
...@@ -333,10 +333,15 @@ function extractInteractiveElements() { ...@@ -333,10 +333,15 @@ function extractInteractiveElements() {
### 8.4 Phase 4 执行记录 ### 8.4 Phase 4 执行记录
**开始时间**: 待执行 **开始时间**: 2026-08-03
**结束时间**: **结束时间**: 2026-08-03
**执行人**: **执行人**: Claude Code
**完成情况**: ⏳ 待部署验证 **完成情况**: ✅ 已完成
- 代码已提交并推送到远程仓库(commit `46a566f2`
- 后端 element_locator.py 已上传到服务器
- 前端构建产物已上传(Cases-D5GbBfN-.js、Cases-CVUl85Qc.css)
- Docker 容器已重启
- API 验证通过(新字段 elements_extracted/retries/page_load_time 已返回)
--- ---
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
> **创建日期**: 2026-07-29 > **创建日期**: 2026-07-29
> **作者**: czj > **作者**: czj
> **优先级**: P0 > **优先级**: P0
> **版本**: v5.60
> **状态**: 待评审 > **状态**: 待评审
--- ---
......
此差异已折叠。
此差异已折叠。
...@@ -264,9 +264,9 @@ class PlaywrightExecutor: ...@@ -264,9 +264,9 @@ class PlaywrightExecutor:
try: try:
logger.info("开始自动登录...") logger.info("开始自动登录...")
# 访问登录页 # 访问登录页 - 使用 domcontentloaded 避免 SPA 页面 networkidle 超时
self._page.goto(self.LOGIN_CONFIG["base_url"], wait_until="networkidle") self._page.goto(self.LOGIN_CONFIG["base_url"], wait_until="domcontentloaded", timeout=30000)
self._page.wait_for_timeout(2000) self._page.wait_for_timeout(3000) # 等待页面渲染
# 等待登录表单加载 # 等待登录表单加载
try: try:
......
...@@ -295,13 +295,15 @@ async def download_import_template( ...@@ -295,13 +295,15 @@ async def download_import_template(
wb.save(buffer) wb.save(buffer)
buffer.seek(0) buffer.seek(0)
from urllib.parse import quote
device_type_label = {"door": "门口屏", "paperless": "无纸化", "central": "中控", "client": "集控客户端"} device_type_label = {"door": "门口屏", "paperless": "无纸化", "central": "中控", "client": "集控客户端"}
filename = f"{device_type_label.get(device_type, device_type)}_设备导入模板.xlsx" filename = f"{device_type_label.get(device_type, device_type)}_设备导入模板.xlsx"
encoded_filename = quote(filename)
return StreamingResponse( return StreamingResponse(
buffer, buffer,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"}, headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
) )
......
...@@ -198,14 +198,44 @@ def detect_action_type(description: str) -> str: ...@@ -198,14 +198,44 @@ def detect_action_type(description: str) -> str:
if any(kw in desc_lower for kw in ['等待', '延时']): if any(kw in desc_lower for kw in ['等待', '延时']):
return 'wait' return 'wait'
# 验证 # 验证(优先级提高)
if any(kw in desc_lower for kw in ['断言', '验证', '检查', '确认']): if any(kw in desc_lower for kw in ['断言', '验证', '检查', '确认', '查看', '是否', '是否成功']):
return 'assert' return 'assert'
# 默认为点击 # 默认为点击
return 'click' return 'click'
def is_verification_step(description: str) -> bool:
"""
判断是否为验证类步骤(需要检查操作结果提示)
验证类步骤特征:
- 包含"查看"、"检查"、"验证"等关键词
- 包含"是否成功"、"是否正确"等疑问句式
- 包含"新增成功"、"添加成功"等期望结果
Args:
description (str): 步骤描述
Returns:
bool: 是否为验证类步骤
"""
verification_keywords = [
'查看', '检查', '验证', '确认', '判断',
'是否成功', '是否正确', '是否新增',
'新增成功', '添加成功', '保存成功', '删除成功',
'操作成功', '提交成功', '创建成功',
'列表是否', '数据是否'
]
for kw in verification_keywords:
if kw in description:
return True
return False
# ==================== 元素匹配 ==================== # ==================== 元素匹配 ====================
def match_element_by_keywords( def match_element_by_keywords(
......
...@@ -20,6 +20,7 @@ from app.services.keyword_matcher import ( ...@@ -20,6 +20,7 @@ from app.services.keyword_matcher import (
extract_keywords, extract_keywords,
extract_value_from_description, extract_value_from_description,
detect_action_type, detect_action_type,
is_verification_step,
match_element_by_keywords, match_element_by_keywords,
find_element_by_semantic find_element_by_semantic
) )
...@@ -294,6 +295,9 @@ class SmartLocateService: ...@@ -294,6 +295,9 @@ class SmartLocateService:
} }
try: try:
# ⚠️ 关键修复:先获取 page 对象,验证步骤需要使用
page = self.executor._page
# navigate 类型:需要设置 url 参数 # navigate 类型:需要设置 url 参数
if action == 'navigate': if action == 'navigate':
# 检查是否已有 url 参数 # 检查是否已有 url 参数
...@@ -336,12 +340,124 @@ class SmartLocateService: ...@@ -336,12 +340,124 @@ class SmartLocateService:
return result return result
# ⚠️ 新增:验证类步骤特殊处理
# 验证类步骤(如"查看是否新增成功")支持两种验证方式:
# 1. 提示语验证:等待成功提示出现
# 2. 列表搜索验证:在列表中搜索判断是否新增
if is_verification_step(name):
logger.info(f"步骤 {order} 为验证类步骤,尝试查找操作结果")
# ===== 方式1:提示语验证 =====
message_selectors = [
'.el-message--success .el-message__content', # 成功提示
'.el-message__content', # 通用提示内容
'//p[@class="el-message__content"]', # XPath 方式
'.el-message', # 整个提示框
]
for sel in message_selectors:
try:
# 等待提示出现(操作后通常会有短暂延迟)
page.wait_for_selector(sel, timeout=5000)
element = page.locator(sel).first
if element:
# 提取提示文本用于验证
message_text = element.inner_text().strip()
logger.info(f"找到操作结果提示: '{message_text}'")
result['success'] = True
result['action'] = 'wait' # 改为 wait 类型
result['selectors'] = {
'primary': sel,
'candidates': [
{'type': 'css', 'value': sel, 'confidence': 0.95, 'priority': 1}
]
}
result['params']['selector'] = sel
result['params']['timeout'] = 10000
result['params']['expected_text'] = message_text # 记录提示文本
result['params']['verify_method'] = 'message' # 验证方式
result['message'] = f'验证类步骤(提示语),找到提示: {message_text}'
logger.info(f"步骤 {order} 定位成功(提示验证): {sel}")
# 截图
try:
screenshot_path = os.path.join(
self.screenshot_dir,
f"smart_locate_{order}_{datetime.now().strftime('%H%M%S')}.png"
)
page.screenshot(path=screenshot_path)
with open(screenshot_path, 'rb') as f:
result['screenshot'] = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.debug(f"截图失败(非致命): {e}")
return result
except Exception as e:
logger.debug(f"提示语选择器 {sel} 未找到: {e}")
continue
logger.info(f"未找到提示语,尝试列表搜索验证...")
# ===== 方式2:列表搜索验证 =====
# 查找页面上的搜索输入框
search_input_selectors = [
'input[placeholder*="搜索"]',
'input[placeholder*="查询"]',
'input[placeholder*="查找"]',
'input[placeholder*="关键字"]',
'.search-input',
'.el-input__inner',
]
for sel in search_input_selectors:
try:
element = page.locator(sel).first
if element and element.is_visible():
logger.info(f"找到搜索输入框: {sel}")
result['success'] = True
result['action'] = 'fill' # 改为 fill 类型(输入搜索词)
result['selectors'] = {
'primary': sel,
'candidates': [
{'type': 'css', 'value': sel, 'confidence': 0.85, 'priority': 1}
]
}
result['params']['selector'] = sel
# 从步骤名称中提取搜索关键词(如"查看列表是否正确新增数据")
# 用户需要手动指定搜索词,这里只提供选择器
result['params']['verify_method'] = 'search' # 验证方式
result['message'] = f'验证类步骤(列表搜索),找到搜索框: {sel}'
logger.info(f"步骤 {order} 定位成功(列表搜索): {sel}")
# 截图
try:
screenshot_path = os.path.join(
self.screenshot_dir,
f"smart_locate_{order}_{datetime.now().strftime('%H%M%S')}.png"
)
page.screenshot(path=screenshot_path)
with open(screenshot_path, 'rb') as f:
result['screenshot'] = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.debug(f"截图失败(非致命): {e}")
return result
except Exception as e:
logger.debug(f"搜索框选择器 {sel} 未找到: {e}")
continue
# 如果两种方式都没有找到
logger.warning(f"步骤 {order} 为验证类但未找到提示元素或搜索框")
result['success'] = False
result['message'] = f'验证类步骤,但未找到操作结果提示元素或列表搜索框'
return result
# 提取关键词 # 提取关键词
keywords = extract_keywords(name) keywords = extract_keywords(name)
logger.debug(f"提取关键词: {keywords}") logger.debug(f"提取关键词: {keywords}")
page = self.executor._page
# ⚠️ 新增:检查是否需要在抽屉内查找 # ⚠️ 新增:检查是否需要在抽屉内查找
drawer_keywords = ['资产管理', '资产信息', '资产故障', '资产设备', drawer_keywords = ['资产管理', '资产信息', '资产故障', '资产设备',
'会议预约', '会议运维', '会议转录', '信息发布', '会议预约', '会议运维', '会议转录', '信息发布',
......
...@@ -32,6 +32,12 @@ ...@@ -32,6 +32,12 @@
</el-popconfirm> </el-popconfirm>
</template> </template>
<!-- 全部启动/停止 -->
<template v-else>
<el-button type="success" size="small" :loading="batchOperating" @click="handleStartAll">全部启动</el-button>
<el-button type="warning" size="small" :loading="batchOperating" @click="handleStopAll">全部停止</el-button>
</template>
<el-button type="primary" @click="openCreateDialog"> <el-button type="primary" @click="openCreateDialog">
<el-icon><Plus /></el-icon>新增设备 <el-icon><Plus /></el-icon>新增设备
</el-button> </el-button>
...@@ -101,13 +107,15 @@ ...@@ -101,13 +107,15 @@
</el-table> </el-table>
<!-- 分页 --> <!-- 分页 -->
<div class="pagination-wrapper" v-if="total > limit"> <div class="pagination-wrapper" v-if="total > 0">
<el-pagination <el-pagination
v-model:current-page="currentPage" v-model:current-page="currentPage"
:page-size="limit" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total" :total="total"
layout="prev, pager, next, total" layout="total, sizes, prev, pager, next, jumper"
@change="loadDevices" @change="loadDevices"
@size-change="loadDevices"
/> />
</div> </div>
...@@ -351,7 +359,7 @@ const loading = ref(false) ...@@ -351,7 +359,7 @@ const loading = ref(false)
const devices = ref<Simulator[]>([]) const devices = ref<Simulator[]>([])
const total = ref(0) const total = ref(0)
const currentPage = ref(1) const currentPage = ref(1)
const limit = 20 const pageSize = ref(20)
const operatingId = ref('') const operatingId = ref('')
// 批量操作 // 批量操作
...@@ -444,8 +452,8 @@ async function loadDevices() { ...@@ -444,8 +452,8 @@ async function loadDevices() {
loading.value = true loading.value = true
try { try {
const params: any = { const params: any = {
skip: (currentPage.value - 1) * limit, skip: (currentPage.value - 1) * pageSize.value,
limit, limit: pageSize.value,
deviceType: props.deviceType, deviceType: props.deviceType,
} }
if (filterEnvConfigId.value) params.envConfigId = filterEnvConfigId.value if (filterEnvConfigId.value) params.envConfigId = filterEnvConfigId.value
...@@ -724,6 +732,46 @@ async function handleBatchDelete() { ...@@ -724,6 +732,46 @@ async function handleBatchDelete() {
} }
} }
/** 全部启动 */
async function handleStartAll() {
batchOperating.value = true
try {
const allIds = devices.value.map(d => d.id)
if (allIds.length === 0) {
ElMessage.warning('没有可启动的设备')
return
}
const res = await batchStartDevices(allIds)
ElMessage.success(res.message)
await loadDevices()
emit('device-changed')
} catch (e: any) {
ElMessage.error('全部启动失败: ' + (e.message || ''))
} finally {
batchOperating.value = false
}
}
/** 全部停止 */
async function handleStopAll() {
batchOperating.value = true
try {
const allIds = devices.value.map(d => d.id)
if (allIds.length === 0) {
ElMessage.warning('没有可停止的设备')
return
}
const res = await batchStopDevices(allIds)
ElMessage.success(res.message)
await loadDevices()
emit('device-changed')
} catch (e: any) {
ElMessage.error('全部停止失败: ' + (e.message || ''))
} finally {
batchOperating.value = false
}
}
/** 打开导入弹窗 */ /** 打开导入弹窗 */
function openImportDialog() { function openImportDialog() {
importFile.value = null importFile.value = null
......
<!-- <!--
组件名称:CentralSim.vue 组件名称:CentralSim.vue
组件描述:中控设备模拟页面 - 设备列表 + 实时消息流 组件描述:中控设备模拟页面
@author czj @author czj
@date 2026-07-29 @date 2026-07-29
...@@ -17,24 +17,15 @@ ...@@ -17,24 +17,15 @@
</div> </div>
</div> </div>
<!-- 主体:左侧设备列表 + 右侧消息流 --> <!-- 设备列表 -->
<div class="main-content">
<div class="left-panel">
<DeviceList device-type="central" @device-changed="onDeviceChanged" /> <DeviceList device-type="central" @device-changed="onDeviceChanged" />
</div> </div>
<div class="right-panel">
<div class="panel-title">实时消息流</div>
<MessageStream device-type="central" />
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import DeviceList from '@/components/device/DeviceList.vue' import DeviceList from '@/components/device/DeviceList.vue'
import MessageStream from '@/components/device/MessageStream.vue'
import { listEnvConfigs } from '@/api/deviceSim' import { listEnvConfigs } from '@/api/deviceSim'
import type { EnvConfig } from '@/types/device' import type { EnvConfig } from '@/types/device'
...@@ -59,9 +50,4 @@ onMounted(async () => { ...@@ -59,9 +50,4 @@ onMounted(async () => {
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 20px; font-weight: 600; color: #303133; margin: 0; } .page-header h2 { font-size: 20px; font-weight: 600; color: #303133; margin: 0; }
.header-actions { display: flex; align-items: center; gap: 12px; } .header-actions { display: flex; align-items: center; gap: 12px; }
.main-content { display: flex; gap: 16px; flex: 1; min-height: 0; }
.left-panel { flex: 3; min-width: 0; }
.right-panel { flex: 2; display: flex; flex-direction: column; min-width: 0; }
.panel-title { font-size: 14px; font-weight: 600; color: #303133; margin-bottom: 8px; }
.right-panel :deep(.message-stream) { flex: 1; min-height: 400px; }
</style> </style>
\ No newline at end of file
<!-- <!--
组件名称:ClientSim.vue 组件名称:ClientSim.vue
组件描述:集控客户端模拟页面 - 设备列表 + 实时消息流 组件描述:集控客户端模拟页面
@author czj @author czj
@date 2026-07-29 @date 2026-07-29
...@@ -17,24 +17,15 @@ ...@@ -17,24 +17,15 @@
</div> </div>
</div> </div>
<!-- 主体:左侧设备列表 + 右侧消息流 --> <!-- 设备列表 -->
<div class="main-content">
<div class="left-panel">
<DeviceList device-type="client" @device-changed="onDeviceChanged" /> <DeviceList device-type="client" @device-changed="onDeviceChanged" />
</div> </div>
<div class="right-panel">
<div class="panel-title">实时消息流</div>
<MessageStream device-type="client" />
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import DeviceList from '@/components/device/DeviceList.vue' import DeviceList from '@/components/device/DeviceList.vue'
import MessageStream from '@/components/device/MessageStream.vue'
import { listEnvConfigs } from '@/api/deviceSim' import { listEnvConfigs } from '@/api/deviceSim'
import type { EnvConfig } from '@/types/device' import type { EnvConfig } from '@/types/device'
...@@ -59,9 +50,4 @@ onMounted(async () => { ...@@ -59,9 +50,4 @@ onMounted(async () => {
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 20px; font-weight: 600; color: #303133; margin: 0; } .page-header h2 { font-size: 20px; font-weight: 600; color: #303133; margin: 0; }
.header-actions { display: flex; align-items: center; gap: 12px; } .header-actions { display: flex; align-items: center; gap: 12px; }
.main-content { display: flex; gap: 16px; flex: 1; min-height: 0; }
.left-panel { flex: 3; min-width: 0; }
.right-panel { flex: 2; display: flex; flex-direction: column; min-width: 0; }
.panel-title { font-size: 14px; font-weight: 600; color: #303133; margin-bottom: 8px; }
.right-panel :deep(.message-stream) { flex: 1; min-height: 400px; }
</style> </style>
\ No newline at end of file
<!-- <!--
组件名称:DoorSim.vue 组件名称:DoorSim.vue
组件描述:门口屏模拟页面 - 设备列表 + 实时消息流 组件描述:门口屏模拟页面
@author czj @author czj
@date 2026-07-29 @date 2026-07-29
...@@ -40,24 +40,15 @@ ...@@ -40,24 +40,15 @@
</div> </div>
</div> </div>
<!-- 主体:左侧设备列表 + 右侧消息流 --> <!-- 设备列表 -->
<div class="main-content">
<div class="left-panel">
<DeviceList device-type="door" :env-config-id="currentEnvId" @device-changed="onDeviceChanged" /> <DeviceList device-type="door" :env-config-id="currentEnvId" @device-changed="onDeviceChanged" />
</div> </div>
<div class="right-panel">
<div class="panel-title">实时消息流</div>
<MessageStream device-type="door" />
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import DeviceList from '@/components/device/DeviceList.vue' import DeviceList from '@/components/device/DeviceList.vue'
import MessageStream from '@/components/device/MessageStream.vue'
import { listEnvConfigs } from '@/api/deviceSim' import { listEnvConfigs } from '@/api/deviceSim'
import type { EnvConfig } from '@/types/device' import type { EnvConfig } from '@/types/device'
...@@ -156,35 +147,4 @@ onMounted(async () => { ...@@ -156,35 +147,4 @@ onMounted(async () => {
.stat-card.stopped .stat-value { .stat-card.stopped .stat-value {
color: #909399; color: #909399;
} }
.main-content {
display: flex;
gap: 16px;
flex: 1;
min-height: 0;
}
.left-panel {
flex: 3;
min-width: 0;
}
.right-panel {
flex: 2;
display: flex;
flex-direction: column;
min-width: 0;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.right-panel :deep(.message-stream) {
flex: 1;
min-height: 400px;
}
</style> </style>
<!-- <!--
组件名称:PaperlessSim.vue 组件名称:PaperlessSim.vue
组件描述:无纸化模拟页面 - 设备列表 + 实时消息流 组件描述:无纸化模拟页面
@author czj @author czj
@date 2026-07-29 @date 2026-07-29
...@@ -17,24 +17,15 @@ ...@@ -17,24 +17,15 @@
</div> </div>
</div> </div>
<!-- 主体:左侧设备列表 + 右侧消息流 --> <!-- 设备列表 -->
<div class="main-content">
<div class="left-panel">
<DeviceList device-type="paperless" @device-changed="onDeviceChanged" /> <DeviceList device-type="paperless" @device-changed="onDeviceChanged" />
</div> </div>
<div class="right-panel">
<div class="panel-title">实时消息流</div>
<MessageStream device-type="paperless" />
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import DeviceList from '@/components/device/DeviceList.vue' import DeviceList from '@/components/device/DeviceList.vue'
import MessageStream from '@/components/device/MessageStream.vue'
import { listEnvConfigs } from '@/api/deviceSim' import { listEnvConfigs } from '@/api/deviceSim'
import type { EnvConfig } from '@/types/device' import type { EnvConfig } from '@/types/device'
...@@ -59,9 +50,4 @@ onMounted(async () => { ...@@ -59,9 +50,4 @@ onMounted(async () => {
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 20px; font-weight: 600; color: #303133; margin: 0; } .page-header h2 { font-size: 20px; font-weight: 600; color: #303133; margin: 0; }
.header-actions { display: flex; align-items: center; gap: 12px; } .header-actions { display: flex; align-items: center; gap: 12px; }
.main-content { display: flex; gap: 16px; flex: 1; min-height: 0; }
.left-panel { flex: 3; min-width: 0; }
.right-panel { flex: 2; display: flex; flex-direction: column; min-width: 0; }
.panel-title { font-size: 14px; font-weight: 600; color: #303133; margin-bottom: 8px; }
.right-panel :deep(.message-stream) { flex: 1; min-height: 400px; }
</style> </style>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论