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

feat(device-sim, element-locate): 设备模拟定时上报配置 + 元素定位优化

设备模拟模块:
- 新增定时上报配置功能(1秒-24小时灵活配置)
- 修复消息体格式符合 Mqtt_Send.py 真实设备格式
- 修复消息体被覆盖的关键 Bug
- 添加 topic_params 和 report_config 字段
- 优化启动设备异常处理

元素定位模块:
- 优化元素定位 API 接口
- 增强前端 API 调用逻辑

文档:
- 新增自然语言用例智能定位功能 PRD 和执行计划
- 新增设备模拟修复计划文档
- 新增设备模拟主题配置交接文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 4e31fa9b
# PRD — 自然语言用例智能定位功能
> **模块**: UI 自动化测试 — 智能元素定位
> **优先级**: P0
> **作者**: czj / Claude Code
> **创建日期**: 2026-08-05
> **最后修改**: 2026-08-05
---
## 一、需求背景
### 1.1 用户场景
用户在前端页面录入用例时,只需写自然语言步骤描述:
```
1. 访问登录页面
2. 输入用户名 admin@xty
3. 输入密码 Ubains@13579
4. 点击登录按钮
5. 点击功能中心
6. 点击信息发布
7. 点击新增按钮
```
系统自动访问被测系统,为每个步骤找到精确的定位元素,填充到用例中,使用例可直接执行。
### 1.2 当前问题
现有"获取定位"功能(`/api/element/locate-batch`)的工作方式:
```
自然语言步骤 → 提取页面所有元素 → Claude CLI 推断匹配 → 返回选择器
```
**问题**
| # | 问题 | 示例 |
|---|------|------|
| 1 | AI 推断选择器不准确 | 返回 `.el-tabs` 但页面实际没有这个元素 |
| 2 | 只返回一个选择器,无备选 | 主选择器失效后用例直接失败 |
| 3 | 没有真实执行验证 | 选择器看起来对但点击不到 |
| 4 | 登录流程处理不完善 | 缺少勾选协议步骤导致无法登录 |
### 1.3 已验证的方案
在 2026-08-05 的测试中,通过 Playwright 脚本自动访问被测系统,自动执行操作并提取选择器,生成"信息发布-导航验证"用例(12 步),**执行 12/12 全部通过**
验证结论:**Playwright 实际操作 + 提取选择器的方式是可靠的,不需要 AI 推断**
---
## 二、需求目标
### 2.1 核心目标
用户录入自然语言步骤后,系统自动访问被测系统,**实际执行操作**并提取精确选择器,使生成的用例可直接执行通过。
### 2.2 与现有"获取定位"的区别
| 对比项 | 现有获取定位 | 智能定位(本需求) |
|--------|------------|-------------------|
| 方式 | 提取所有元素 → AI 匹配 | 实际执行操作 → 提取选择器 |
| 准确性 | AI 推断,不稳定 | 实际验证,高准确 |
| Claude CLI | 必须依赖 | 不需要 |
| 登录处理 | 简单登录 | 完整登录(含协议勾选) |
| 选择器数量 | 每步 1 个 | 每步多个候选 |
| 验证方式 | 无 | 实际执行验证 |
### 2.3 成功指标
| 指标 | 目标值 |
|------|--------|
| 定位选择器准确率 | ≥ 95% |
| 用例首次执行通过率 | ≥ 90% |
| 是否需要 Claude CLI | 不需要 |
| 单步骤定位延迟 | ≤ 3s |
---
## 三、功能需求
### FR-01: 自然语言步骤解析
将用户输入的自然语言步骤解析为可执行的操作:
| 自然语言 | 解析结果 |
|----------|----------|
| 访问登录页面 | `navigate``url: https://192.168.5.44` |
| 输入用户名 admin@xty | `fill``selector: ?, value: admin@xty` |
| 输入密码 Ubains@13579 | `fill``selector: ?, value: Ubains@13579` |
| 点击登录按钮 | `click``selector: ?` |
| 等待页面加载 | `wait``selector: ?, timeout: 10000` |
| 点击功能中心 | `click``selector: ?` |
| 点击信息发布 | `click``selector: ?` |
**关键点**:解析后 `selector` 为空,需要通过实际访问被测系统来填充。
**解析策略**
- 关键词匹配动作类型:`输入`→fill,`点击`→click,`等待`→wait,`访问`→navigate,`选择`→select
- 提取操作值:`输入用户名 admin@xty` → value=`admin@xty`
- 提取目标元素描述:`点击登录按钮` → 目标=`登录按钮`
### FR-02: 自动访问被测系统并执行
系统使用 Playwright 自动访问被测系统,按步骤顺序执行操作,在执行过程中提取选择器。
**完整流程**
```
1. 启动 Playwright 浏览器
2. 执行登录模板(自动登录)
3. 按步骤顺序:
a. 根据自然语言描述,在当前页面查找匹配的元素
b. 实际执行操作(点击/填写/等待)
c. 提取精确选择器 + 候选选择器
d. 截图
e. 继续下一步
4. 关闭浏览器
5. 返回所有步骤的选择器
```
### FR-03: 元素查找策略
在每个步骤中,如何从自然语言描述找到页面上的目标元素:
**三级策略**(按优先级):
#### 第一级:关键词直接匹配(最快,无需 AI)
```python
def find_element_by_keyword(page, description):
"""通过关键词在页面元素中直接匹配"""
# 提取关键词
keywords = extract_keywords(description) # "登录按钮" → ["登录"]
# 遍历页面所有可交互元素
elements = page.locator('input, button, a, select, textarea, [role="button"]').all()
for el in elements:
# 匹配元素的文本内容
text = el.inner_text().strip()
if any(kw in text for kw in keywords):
return el, extract_selectors(el)
# 匹配 placeholder
placeholder = el.get_attribute('placeholder') or ''
if any(kw in placeholder for kw in keywords):
return el, extract_selectors(el)
# 匹配 aria-label
aria = el.get_attribute('aria-label') or ''
if any(kw in aria for kw in keywords):
return el, extract_selectors(el)
return None, None
```
**匹配规则**
| 自然语言描述 | 关键词提取 | 匹配目标 | 匹配方式 |
|-------------|-----------|---------|---------|
| 输入用户名 | ["用户名", "手机号"] | placeholder | 包含匹配 |
| 输入密码 | ["密码"] | type=password | 精确匹配 |
| 输入验证码 | ["验证码", "图形"] | placeholder | 包含匹配 |
| 点击登录按钮 | ["登录"] | button 文本 | 包含匹配 |
| 点击功能中心 | ["功能中心"] | 文本/id | 包含匹配 |
| 点击信息发布 | ["信息发布"] | 文本 | 包含匹配 |
| 点击新增按钮 | ["新增", "添加"] | button 文本 | 包含匹配 |
#### 第二级:语义推断匹配
当关键词无法直接匹配时,根据操作类型推断目标元素:
| 操作 | 推断规则 |
|------|---------|
| `fill` + 有 value | 找输入框(input/textarea),优先匹配 placeholder 包含步骤描述关键词的 |
| `click` + 包含"按钮" | 找 button,优先匹配文本包含关键词的 |
| `click` + 包含"菜单"/"导航" | 找菜单项/链接 |
| `wait` + 包含"页面" | 找容器元素(.container, .el-main, [class*="nav"]) |
| `click` + 包含"抽屉"/"弹窗" | 找 .el-drawer, .el-dialog |
#### 第三级:页面快照 + 元素列表回退
如果前两级都没匹配到,提取页面所有可交互元素列表,按操作类型过滤后选择最可能的元素。
### FR-04: 多候选选择器
每个步骤提取多种选择器,执行时按优先级回退:
```json
{
"order": 3,
"name": "输入用户名",
"action": "fill",
"params": {
"selector": "input[placeholder*=\"手机号\"]",
"value": "admin@xty"
},
"selectors": {
"primary": "input[placeholder*=\"手机号\"]",
"candidates": [
{"type": "css", "value": "input[placeholder*=\"手机号\"]", "confidence": 0.95},
{"type": "css", "value": "#login-username", "confidence": 0.90},
{"type": "xpath", "value": "//input[@placeholder[contains(.,'手机号')]]", "confidence": 0.80},
{"type": "text", "value": "手机号/用户名/邮箱", "confidence": 0.70}
]
},
"element_info": {
"tag": "INPUT",
"type": "text",
"placeholder": "手机号/用户名/邮箱",
"text": "",
"id": "",
"className": "el-input__inner"
}
}
```
### FR-05: 登录流程自动化
系统预置已验证的登录模板,自动处理登录(含协议勾选),用户无需手动配置登录步骤。
**预置登录模板(8 步,已验证通过)**
| 步骤 | 名称 | 操作 | 选择器 | 值 |
|------|------|------|--------|-----|
| 1 | 访问登录页面 | navigate | - | https://192.168.5.44 |
| 2 | 等待登录表单加载 | wait | `input[placeholder*="手机号"]` | timeout:10000 |
| 3 | 输入用户名 | fill | `input[placeholder*="手机号"]` | admin@xty |
| 4 | 输入密码 | fill | `input[type="password"]` | Ubains@13579 |
| 5 | 输入验证码 | fill | `input[placeholder*="图"]` | csba |
| 6 | 勾选协议复选框 | click | `.el-checkbox` | - |
| 7 | 点击登录按钮 | click | `button:has-text("登录")` | - |
| 8 | 等待登录跳转完成 | wait | `[class*='nav'], .container, .el-main` | timeout:15000 |
**功能中心导航模板(3 步,已验证通过)**
| 步骤 | 名称 | 操作 | 选择器 |
|------|------|------|--------|
| 9 | 点击功能中心图标 | click | `//*[@id="Home"]/div[1]/div[1]` |
| 10 | 等待功能抽屉打开 | wait | `.el-drawer` |
| 11 | 点击目标菜单 | click | `.el-drawer >> text="${菜单名}"` |
### FR-06: 定位结果展示与编辑
定位完成后,前端展示每个步骤的定位结果:
- ✅ 定位成功:显示主选择器 + 置信度 + 截图
- ⚠️ 定位不确定:显示候选选择器列表,用户可选择
- ❌ 定位失败:标记失败,用户可手动填写选择器
用户可编辑选择器后重新验证。
---
## 四、API 设计
### 4.1 智能定位 API
#### POST /api/element/smart-locate
**请求体**
```json
{
"steps": [
{"order": 1, "name": "访问登录页面", "action": "navigate", "params": {"url": "https://192.168.5.44"}},
{"order": 2, "name": "输入用户名", "action": "fill", "params": {"value": "admin@xty"}},
{"order": 3, "name": "输入密码", "action": "fill", "params": {"value": "Ubains@13579"}},
{"order": 4, "name": "点击登录按钮", "action": "click"},
{"order": 5, "name": "点击功能中心", "action": "click"},
{"order": 6, "name": "点击信息发布", "action": "click"},
{"order": 7, "name": "点击新增按钮", "action": "click"}
],
"auto_login": true,
"navigate_menu": "信息发布",
"page_url": "https://192.168.5.44"
}
```
**响应**
```json
{
"success": true,
"total_steps": 7,
"located_steps": 7,
"results": [
{
"order": 1,
"name": "访问登录页面",
"success": true,
"action": "navigate",
"params": {"url": "https://192.168.5.44"},
"selectors": {
"primary": null,
"candidates": []
},
"screenshot": "base64..."
},
{
"order": 2,
"name": "输入用户名",
"success": true,
"action": "fill",
"params": {
"selector": "input[placeholder*=\"手机号\"]",
"value": "admin@xty"
},
"selectors": {
"primary": "input[placeholder*=\"手机号\"]",
"candidates": [
{"type": "css", "value": "input[placeholder*=\"手机号\"]", "confidence": 0.95},
{"type": "xpath", "value": "//input[contains(@placeholder,'手机号')]", "confidence": 0.80}
]
},
"element_info": {
"tag": "INPUT",
"placeholder": "手机号/用户名/邮箱",
"type": "text"
},
"screenshot": "base64..."
}
],
"message": "成功定位 7/7 个步骤"
}
```
### 4.2 登录模板 API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/login-templates` | 获取所有登录模板 |
| POST | `/api/login-templates` | 创建登录模板 |
| PUT | `/api/login-templates/{id}` | 更新登录模板 |
| DELETE | `/api/login-templates/{id}` | 删除登录模板 |
---
## 五、核心算法
### 5.1 整体流程
```python
def smart_locate(steps, auto_login=True, navigate_menu=""):
"""智能定位主流程"""
# 1. 启动浏览器
executor = PlaywrightExecutor()
executor.start()
# 2. 自动登录(使用预置模板)
if auto_login:
executor.do_login() # 执行登录模板8步
# 3. 导航到目标菜单
if navigate_menu:
navigate_to_menu(executor, navigate_menu) # 功能中心3步
# 4. 逐步骤定位
results = []
for step in steps:
result = locate_step(executor, step)
results.append(result)
# 如果定位成功,实际执行操作(确保下一步在正确的页面状态)
if result["success"]:
execute_step(executor, result)
# 5. 关闭浏览器
executor.stop()
return results
```
### 5.2 单步骤定位
```python
def locate_step(executor, step):
"""为单个步骤定位元素"""
action = step["action"]
name = step["name"]
# navigate 类型不需要定位
if action == "navigate":
return {"success": True, "selectors": None}
# 提取关键词
keywords = extract_keywords(name)
# 第一级:关键词直接匹配
element, selectors = find_element_by_keyword(executor.page, keywords, action)
if element:
return {
"success": True,
"selectors": selectors,
"element_info": extract_element_info(element)
}
# 第二级:语义推断
element, selectors = find_element_by_semantic(executor.page, keywords, action)
if element:
return {
"success": True,
"selectors": selectors,
"element_info": extract_element_info(element)
}
# 第三级:回退
return {"success": False, "message": f"未找到匹配元素: {name}"}
```
### 5.3 关键词提取
```python
# 动作词表
ACTION_WORDS = ["点击", "输入", "选择", "等待", "访问", "勾选", "填写", "按下", "切换"]
# 元素类型词表
ELEMENT_WORDS = ["按钮", "输入框", "下拉", "菜单", "链接", "复选框", "表格", "标签", "图标"]
def extract_keywords(description):
"""从自然语言描述中提取关键词"""
# 去除动作词
text = description
for word in ACTION_WORDS + ELEMENT_WORDS:
text = text.replace(word, "")
# 分词(按空格、标点分割)
words = [w.strip() for w in re.split(r'[\s,,、]', text) if w.strip()]
return words
```
---
## 六、与现有"获取定位"的关系
### 6.1 替代关系
本功能**替代**现有的 `/api/element/locate-batch` 接口,不再需要 Claude CLI。
### 6.2 复用部分
| 组件 | 处理方式 |
|------|---------|
| `PlaywrightExecutor` | 复用,扩展 `do_login()``navigate_to_menu()` |
| `extract_interactive_elements()` | 复用,用于元素列表提取 |
| 前端配置弹窗 | 改造,去掉 Claude CLI 相关配置 |
| WebSocket 推送 | 复用,实时推送定位进度 |
### 6.3 不再需要
| 组件 | 原因 |
|------|------|
| `call_claude_batch_via_ssh()` | 不再依赖 Claude CLI |
| SSH 连接逻辑 | 不再需要连接宿主机 |
| AI 置信度判断 | 用实际执行验证代替 |
---
## 七、非功能需求
| # | 需求 | 说明 |
|---|------|------|
| 1 | 定位过程互斥 | 同一时间只能有一个定位任务运行 |
| 2 | 超时保护 | 单次定位最长 5 分钟 |
| 3 | 浏览器资源清理 | 定位完成后自动关闭浏览器 |
| 4 | 兼容现有用例格式 | 生成的步骤格式与现有用例完全兼容 |
---
## 八、约束与风险
| # | 风险 | 缓解措施 |
|---|------|----------|
| 1 | 关键词提取不准 | 多级匹配策略 + 语义推断 + 回退 |
| 2 | 微前端 shadowRoot 内元素 | 遍历 shadowRoot + micro-app 容器 |
| 3 | iframe 内元素 | 注入脚本到 iframe contentDocument |
| 4 | Windows 同步 API 约束 | 使用 sync_playwright + 线程池 |
| 5 | 页面动态加载 | 每步操作后等待 + 截图验证 |
---
*本文档由 Claude Code 于 2026-08-05 创建。*
# 执行计划 — 自然语言用例智能定位功能
> **关联 PRD**: `_PRD_自然语言用例智能定位功能.md`
> **优先级**: P0
> **作者**: czj / Claude Code
> **创建日期**: 2026-08-05
> **预计工期**: 5 天
---
## 总体规划
| Phase | 内容 | 工期 | 依赖 |
|-------|------|------|------|
| Phase 1 | 登录流程模板化 | 1 天 | 无 |
| Phase 2 | 智能定位后端核心 | 2 天 | Phase 1 |
| Phase 3 | 前端集成与测试 | 1 天 | Phase 2 |
| Phase 4 | 优化与文档更新 | 1 天 | Phase 3 |
---
## Phase 1: 登录流程模板化(1 天)
### 目标
将已验证的登录步骤固化为模板,智能定位时自动使用,用户无需手动配置登录。
### 任务清单
- [ ] 创建 `login_templates` 表(SQLite)
- [ ] 创建 `backend/app/models/login_template.py`
- [ ] 创建 `backend/app/schemas/login_template.py`
- [ ] 创建 `backend/app/routers/login_template.py`
- [ ] 创建 `backend/app/services/login_template_service.py`
- [ ] 插入默认登录模板(已验证的 8 步登录 + 3 步功能中心导航)
- [ ]`main.py` 注册路由
### 预置模板内容
**登录模板(8 步)**
| 步骤 | 名称 | 操作 | 选择器 | 值 |
|------|------|------|--------|-----|
| 1 | 访问登录页面 | navigate | - | https://192.168.5.44 |
| 2 | 等待登录表单加载 | wait | `input[placeholder*="手机号"]` | timeout:10000 |
| 3 | 输入用户名 | fill | `input[placeholder*="手机号"]` | admin@xty |
| 4 | 输入密码 | fill | `input[type="password"]` | Ubains@13579 |
| 5 | 输入验证码 | fill | `input[placeholder*="图"]` | csba |
| 6 | 勾选协议复选框 | click | `.el-checkbox` | - |
| 7 | 点击登录按钮 | click | `button:has-text("登录")` | - |
| 8 | 等待登录跳转完成 | wait | `[class*='nav'], .container, .el-main` | timeout:15000 |
**功能中心导航模板(3 步)**
| 步骤 | 名称 | 操作 | 选择器 |
|------|------|------|--------|
| 9 | 点击功能中心图标 | click | `//*[@id="Home"]/div[1]/div[1]` |
| 10 | 等待功能抽屉打开 | wait | `.el-drawer` |
| 11 | 点击目标菜单 | click | `.el-drawer >> text="${菜单名}"` |
### 验证标准
- [ ] API 可正常 CRUD 登录模板
- [ ] 默认模板存在且包含 11 个步骤
---
## Phase 2: 智能定位后端核心(2 天)
### 目标
实现 `/api/element/smart-locate` 接口,自动访问被测系统、执行操作、提取选择器。
### 任务清单
#### 2.1 关键词提取与匹配模块(0.5 天)
- [ ] 创建 `backend/app/services/keyword_matcher.py`
```python
# 关键词提取
def extract_keywords(description: str) -> list[str]
# 关键词匹配元素
def match_element_by_keywords(page, keywords: list[str], action: str) -> tuple[Element, list[dict]]
```
#### 2.2 选择器提取模块(0.5 天)
- [ ] 创建 `backend/app/services/selector_extractor.py`
```python
# 提取多候选选择器
def extract_selectors(element) -> dict
# 按优先级排序
# 1. data-testid 2. ID 3. placeholder 4. aria-label 5. 文本 6. 组合选择器 7. XPath
```
#### 2.3 智能定位服务(1 天)
- [ ] 创建 `backend/app/services/smart_locate_service.py`
```python
class SmartLocateService:
def locate_steps(self, steps: list[dict], auto_login: bool, navigate_menu: str) -> list[dict]:
"""
主流程:
1. 启动 Playwright
2. 执行登录模板
3. 导航到目标菜单
4. 逐步骤定位 + 执行
5. 返回结果
"""
def locate_single_step(self, step: dict) -> dict:
"""
单步骤定位:
1. 提取关键词
2. 关键词匹配
3. 语义推断
4. 回退策略
"""
```
#### 2.4 智能定位路由(0.5 天)
- [ ] 创建 `backend/app/routers/smart_locate.py`
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/element/smart-locate` | 智能定位主接口 |
| POST | `/api/element/verify-step` | 单步骤验证(调试用) |
- [ ]`main.py` 注册路由
### 验证标准
- [ ] `/api/element/smart-locate` 可自动登录并定位元素
- [ ] 每个步骤返回多种候选选择器
- [ ] 定位的选择器执行通过率 ≥ 90%
---
## Phase 3: 前端集成与测试(1 天)
### 目标
改造前端"获取定位"功能,调用智能定位接口。
### 任务清单
- [ ] 修改 `frontend/src/api/elementLocate.ts`,增加 `smartLocate` 接口
- [ ] 修改 `frontend/src/views/Cases.vue`,"获取定位"按钮改为调用智能定位接口
- [ ] 保留现有配置弹窗(目标菜单、自动登录等)
- [ ] 实时显示定位进度(WebSocket)
- [ ] 定位结果展示:成功/不确定/失败,可编辑选择器
### 验证标准
- [ ] 点击"获取定位"后可正常调用智能定位接口
- [ ] 定位结果自动填充到步骤表格
- [ ] 可直接保存并执行用例
---
## Phase 4: 优化与文档更新(1 天)
### 任务清单
- [ ] 端到端测试:创建"会议管理"模块用例并验证执行
- [ ] 更新 `HANDOFF_UI自动化.md`
- [ ] 删除旧的 `prd-code` skill 相关文档(`_PRD_用例录制器功能.md`
- [ ] 清理临时脚本
### 验证标准
- [ ] 新建用例定位准确率 ≥ 95%
- [ ] 用例首次执行通过率 ≥ 90%
---
## 文件清单
### 新增文件
| 文件 | Phase | 说明 |
|------|-------|------|
| `backend/app/models/login_template.py` | 1 | 登录模板 ORM |
| `backend/app/schemas/login_template.py` | 1 | 登录模板 Schema |
| `backend/app/routers/login_template.py` | 1 | 登录模板路由 |
| `backend/app/services/login_template_service.py` | 1 | 登录模板服务 |
| `backend/app/services/keyword_matcher.py` | 2 | 关键词匹配模块 |
| `backend/app/services/selector_extractor.py` | 2 | 选择器提取模块 |
| `backend/app/services/smart_locate_service.py` | 2 | 智能定位服务 |
| `backend/app/routers/smart_locate.py` | 2 | 智能定位路由 |
### 修改文件
| 文件 | Phase | 说明 |
|------|-------|------|
| `backend/app/main.py` | 1,2 | 注册新路由 |
| `backend/app/database.py` | 1 | 新增 login_templates 表 |
| `backend/app/executors/playwright_executor.py` | 2 | 增加 `do_login_template()` 方法 |
| `frontend/src/api/elementLocate.ts` | 3 | 增加 smartLocate 接口 |
| `frontend/src/views/Cases.vue` | 3 | 改造获取定位功能 |
---
## 与旧方案对比
| 对比项 | 旧方案(AI 推断) | 新方案(智能定位) |
|--------|-----------------|------------------|
| 核心接口 | `/api/element/locate-batch` | `/api/element/smart-locate` |
| Claude CLI | 必须依赖 | 不需要 |
| 选择器来源 | AI 静态推断 | 实际执行验证 |
| 准确率 | 不稳定 | 高(已验证 12/12 通过) |
| 延迟 | 10-30s | 预计 5-15s |
| 成本 | Claude API 调用 | 无额外成本 |
---
*本文档由 Claude Code 于 2026-08-05 创建。*
# 执行计划 — 修复 metadata 字段错误
> **文档类型**: 执行计划文档
> **创建日期**: 2026-08-03
> **作者**: Claude Code
> **优先级**: P0(阻塞)
> **版本**: v5.60
> **状态**: ✅ 全部完成
---
## 一、执行概述
### 1.1 目标
修复创建模拟设备时的多个关联错误,恢复设备创建功能。
### 1.2 范围
- 修改 `backend/app/services/device_sim_service.py` 字段名
- 修改 `backend/app/schemas/device_sim.py` 添加验证器和缺失字段
- 数据库迁移添加 `topic_params`
- 更新 PRD 文档版本至 v5.60
### 1.3 实际工时
30 分钟
---
## 二、执行步骤
### Step 1: 修复 service 层字段名(✅ 已完成)
**文件**: `backend/app/services/device_sim_service.py:389`
```python
# 修改前
extra_attrs=data.metadata or {},
# 修改后
extra_attrs=data.extra_attrs or {},
```
---
### Step 2: 添加 field_validator(✅ 已完成)
**文件**: `backend/app/schemas/device_sim.py`
1. 添加导入:`field_validator`
2.`SimulatorResponse` 中添加 `ignore_sqlalchemy_metadata` 验证器
3. 添加 `metadata` 字段声明(`exclude=True`
---
### Step 3: 恢复缺失字段(✅ 已完成)
**文件**: `backend/app/schemas/device_sim.py`
1. `SimulatorBase` 添加 `topic_params` 字段
2. `SimulatorUpdate` 添加 `topic_params` 字段
3. 添加 `TopicTemplateResponse``TopicTemplateListResponse`
---
### Step 4: 数据库迁移(✅ 已完成)
```sql
ALTER TABLE device_simulators ADD COLUMN topic_params TEXT
```
---
### Step 5: 重启服务并验证(✅ 已完成)
- 后端服务已重启
- 创建门口屏设备:201 ✓
- 创建无纸化设备:201 ✓
- 创建中控设备:201 ✓
- 主题模板 API:200 ✓
---
### Step 6: 更新版本标记(✅ 已完成)
PRD 文档已更新为 v5.60。
---
## 三、修改文件清单
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/services/device_sim_service.py` | 修改 | 第 389 行:字段名修正 |
| `backend/app/schemas/device_sim.py` | 修改 | 添加验证器、缺失字段、主题模板 Schema |
| `backend/data/test_platform.db` | 修改 | 添加 `topic_params` 列 |
| `Docs/PRD/需求文档/设备模拟/_PRD_需求文档_设备模拟模块.md` | 修改 | 更新版本至 v5.60 |
| `Docs/PRD/需求文档/设备模拟/_问题处理_创建模拟设备失败_metadata字段错误.md` | 新建 | 问题处理文档 |
| `Docs/PRD/需求文档/设备模拟/_执行计划_修复metadata字段错误_v5.60.md` | 新建 | 本文档 |
---
## 四、验收标准
- [x] service 层字段名修正
- [x] Schema 添加 field_validator
- [x] Schema 恢复缺失字段
- [x] 数据库迁移完成
- [x] 后端服务重启
- [x] 创建设备 API 返回 201
- [x] 不同设备类型均可创建
- [x] 主题参数配置功能正常
- [x] 无其他副作用
---
## 五、相关文档
- 问题处理文档: `_问题处理_创建模拟设备失败_metadata字段错误.md`
- PRD 文档: `_PRD_需求文档_设备模拟模块.md`
- 交接文档: `HANDOFF_设备模拟_主题配置改造.md`
---
*本文档为修复 metadata 字段错误的执行计划,版本 v5.60,全部完成。*
# HANDOFF — 设备模拟主题配置改造
> **生成时间**: 2026-08-03
> **当前分支**: `platform-auto-test`
> **状态**: ✅ 主题配置改造完成
---
## 一、本次会话完成的工作
### 1. ✅ 主题模板系统实现
**背景**:模拟器原来使用 `{prefix}/{device_type}/{device_id}/{action}` 格式拼接主题,但真实系统(192.168.5.44)使用固定主题路径,包含动态参数。
**改造内容**
#### 1.1 新增主题模板常量文件
文件:`backend/app/simulators/topic_templates.py`
定义门口屏和无纸化的真实主题模板:
```python
DEVICE_TOPIC_TEMPLATES = {
"door": [
{"key": "conference", "template": "/meeting/conference/{room_id}/", ...},
{"key": "material_client", "template": "/material/client/{device_id}/", ...},
{"key": "device_service_request", "template": "/iot/v1/device/service/request/{company_id}/{device_id}/", "direction": "subscribe"},
# ... 共 8 个模板
],
"paperless": [
{"key": "meeting_message", "template": "/meeting/message/{room_id}/", ...},
{"key": "meeting_control", "template": "/iot/v1/meeting/service/request/meetingControl/{company_id}/{mid}/", ...},
# ... 共 5 个模板
],
"central": [], # 保留旧格式
"client": [], # 保留旧格式
}
```
每个模板包含:
- `key`: 模板唯一标识
- `template`: 主题路径模板(含 `{param}` 占位符)
- `label`: 中文标签
- `params`: 需要填写的动态参数名列表
- `param_labels`: 参数名 → 中文标签映射
- `param_defaults`: 参数名 → 默认值映射
- `direction`: `publish`(上报)或 `subscribe`(订阅)
#### 1.2 工具函数
```python
# 解析主题模板,用参数值替换占位符
resolve_topic(template, params) str
# 解析设备类型的所有主题模板
resolve_all_topics(device_type, topic_params) -> Dict[str, dict]
# 获取设备类型的默认参数值
get_default_params(device_type) -> dict
```
### 2. ✅ 数据模型变更
#### 2.1 `DeviceSimulator` 新增 `topic_params` 字段
文件:`backend/app/models/device_sim.py`
```python
topic_params: Mapped[Optional[dict]] = mapped_column(
JSON, nullable=True,
comment="主题动态参数,如 {'room_id': 'A101', 'company_id': '001'}"
)
```
#### 2.2 Schema 变更
文件:`backend/app/schemas/device_sim.py`
- `SimulatorBase` / `SimulatorCreate` / `SimulatorUpdate` 新增 `topic_params` 字段
- 新增 `TopicTemplateResponse``TopicTemplateListResponse` Schema
### 3. ✅ 新增 API 端点
文件:`backend/app/routers/device_sim.py`
```
GET /api/device-sim/topic-templates/{device_type}
→ 返回该设备类型的主题模板列表(含参数定义和默认值)
```
响应示例:
```json
{
"deviceType": "door",
"templates": [
{
"key": "conference",
"template": "/meeting/conference/{room_id}/",
"label": "会议信息",
"params": ["room_id"],
"paramLabels": {"room_id": "会议室编号"},
"paramDefaults": {"room_id": "A101"},
"direction": "publish"
}
],
"hasRealTopics": true
}
```
### 4. ✅ 模拟器改造
#### 4.1 `base_simulator.py` 改造
新增属性:
- `topic_params`: 主题动态参数
- `_resolved_topics`: 解析后的主题映射
- `_has_real_topics`: 是否有真实主题模板
新增方法:
- `_resolve_topics()`: 解析主题模板
- `get_resolved_topic(key)`: 获取解析后的主题
- `get_publish_topics()`: 获取所有上报主题
- `get_subscribe_topics()`: 获取所有订阅主题
改造方法:
- `start()`: 使用真实主题发布注册消息,自动订阅 `direction=subscribe` 的主题
- `stop()`: 使用真实主题发送离线消息,取消订阅
- `_auto_report_loop()`: 轮流使用各 publish 主题上报
#### 4.2 各子模拟器改造
- `door_simulator.py`: 构造函数新增 `topic_params``_auto_close_door`/`_simulate_reboot` 使用真实主题
- `paperless_simulator.py`: 构造函数新增 `topic_params``_simulate_file_sync` 使用真实主题
- `central_simulator.py` / `client_simulator.py`: 仅新增参数,保留旧格式
### 5. ✅ 前端改造
#### 5.1 类型定义更新
文件:`frontend/src/types/device.ts`
- `Simulator` / `SimulatorCreate` / `SimulatorUpdate` 新增 `topicParams` 字段
- 新增 `TopicTemplate``TopicTemplateListResponse` 类型
#### 5.2 API 封装
文件:`frontend/src/api/deviceSim.ts`
新增:`getTopicTemplates(deviceType: string): Promise<TopicTemplateListResponse>`
#### 5.3 DeviceList.vue 改造
文件:`frontend/src/components/device/DeviceList.vue`
- 新增设备弹窗增加"主题参数配置"区域
- 选择设备类型后自动加载主题模板
- 展示该设备类型的主题列表,每个动态参数显示输入框
- **预填默认值**(从 `paramDefaults` 获取)
- 保存时将参数值存入 `topicParams`
---
## 二、修改文件清单
### 后端
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/topic_templates.py` | **新建** | 主题模板常量定义 + 解析工具函数 |
| `backend/app/models/device_sim.py` | 修改 | `DeviceSimulator` 新增 `topic_params` 字段 |
| `backend/app/schemas/device_sim.py` | 修改 | 新增 `topic_params` 字段和 `TopicTemplate` Schema |
| `backend/app/routers/device_sim.py` | 修改 | 新增 `GET /topic-templates/{device_type}` 端点 |
| `backend/app/services/device_sim_service.py` | 修改 | `create_simulator`/`start_simulator` 适配 `topic_params` |
| `backend/app/simulators/__init__.py` | 修改 | `create_simulator()` 新增 `topic_params` 参数 |
| `backend/app/simulators/base_simulator.py` | 修改 | 新增主题解析逻辑,改造 `start/stop/_auto_report_loop` |
| `backend/app/simulators/door_simulator.py` | 修改 | 构造函数 + `_auto_close_door` + `_simulate_reboot` |
| `backend/app/simulators/paperless_simulator.py` | 修改 | 构造函数 + `_simulate_file_sync` |
| `backend/app/simulators/central_simulator.py` | 修改 | 构造函数新增参数(保留旧格式) |
| `backend/app/simulators/client_simulator.py` | 修改 | 构造函数新增参数(保留旧格式) |
### 前端
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `frontend/src/types/device.ts` | 修改 | 新增 `topicParams` 字段和 `TopicTemplate` 类型 |
| `frontend/src/api/deviceSim.ts` | 修改 | 新增 `getTopicTemplates()` API |
| `frontend/src/components/device/DeviceList.vue` | 修改 | 新增设备弹窗增加主题参数配置区域 |
---
## 三、核心机制说明
### 3.1 主题解析流程
```
用户创建设备 → 前端获取主题模板 → 用户填写动态参数 → 保存到 topic_params
模拟器启动 → _resolve_topics() → 用 topic_params 替换模板中的 {param}
resolved_topics = {
"conference": "/meeting/conference/A101/",
"device_service_request": "/iot/v1/device/service/request/001/DS001/",
...
}
```
### 3.2 自动订阅机制
模拟器 `start()` 时:
1. 获取 `direction=subscribe` 的主题列表
2. 调用 `mqtt.subscribe()` 订阅这些主题
3. 平台下发指令时,`_on_command()` 收到并处理
### 3.3 轮流上报机制
`_auto_report_loop()` 中:
- 如果有真实主题模板,轮流使用各 `publish` 主题上报
- 每次上报使用不同主题,确保所有主题都有数据
### 3.4 向后兼容
- `topic_params``nullable`,旧设备无此字段时模拟器使用旧格式
- 中控/集控客户端暂无真实主题模板,继续使用 `{prefix}/{type}/{id}/{action}` 格式
---
## 四、真实主题列表
### 门口屏(8 个)
| Key | 模板 | 方向 | 参数 |
|-----|------|------|------|
| conference | `/meeting/conference/{room_id}/` | publish | room_id |
| material_client | `/material/client/{device_id}/` | publish | device_id |
| conference_service_request | `/iot/v1/conference/service/request/{company_id}/{room_id}/` | publish | company_id, room_id |
| face_extract | `/doorscreen/v5/{device_id}/face/extractFeature/imageBase64/list/up` | publish | device_id |
| radar_event | `/iot/v1/device/event/request/millimeterhaveRadar/{room_id}/` | publish | room_id |
| meeting_message | `/meeting/message/{room_id}/` | publish | room_id |
| reboot_response | `rebootResponseTopic` | publish | 无 |
| device_service_request | `/iot/v1/device/service/request/{company_id}/{device_id}/` | subscribe | company_id, device_id |
### 无纸化(5 个)
| Key | 模板 | 方向 | 参数 |
|-----|------|------|------|
| meeting_message | `/meeting/message/{room_id}/` | publish | room_id |
| meeting_control | `/iot/v1/meeting/service/request/meetingControl/{company_id}/{mid}/` | publish | company_id, mid |
| android_panel | `/androidPanel/` | publish | 无 |
| meeting_service_request | `/iot/v1/meeting/service/request/{room_id}/` | subscribe | room_id |
| paperless_service | `/message/paperLessService/callService` | publish | 无 |
---
## 五、默认参数值
| 参数 | 默认值 | 说明 |
|------|--------|------|
| room_id | A101 | 会议室编号 |
| device_id | DS001 | 设备ID |
| company_id | 001 | 公司编号 |
| mid | M001 | 会议ID |
---
## 六、待提交的代码修改
所有改动都未提交,建议使用 `/GitCommit` skill 提交:
```bash
git add -A
git commit -m "feat(device-sim): 主题配置改造 - 支持真实系统主题模板
- 新增 topic_templates.py 定义门口屏/无纸化真实主题模板
- DeviceSimulator 模型新增 topic_params JSON 字段
- 新增 GET /api/device-sim/topic-templates/{device_type} API
- 模拟器支持真实主题:模板 + 参数 → 实际主题
- 前端新增设备弹窗增加主题参数配置区域
- 自动订阅响应主题,轮流使用各 publish 主题上报
- 向后兼容:旧设备无 topic_params 时使用旧格式
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
## 七、验证步骤
### 7.1 后端验证
```bash
cd backend
uvicorn app.main:app --reload --port 8001
```
访问 http://localhost:8001/docs 确认新 API:
- `GET /api/device-sim/topic-templates/{device_type}`
### 7.2 前端验证
```bash
cd frontend
npm run dev
```
访问 http://localhost:3000/device-sim/door:
1. 点击"新增设备"
2. 确认显示"主题参数配置"区域
3. 确认动态参数输入框预填了默认值
### 7.3 功能验证
1. 创建门口屏设备,填写参数(如会议室编号=A202)
2. 启动模拟设备
3. 检查上报记录,确认主题为 `/meeting/conference/A202/` 格式
---
## 八、后续优化建议
### P1 - 后续迭代
| 任务 | 说明 |
|------|------|
| 中控真实主题 | 需要提供中控设备的真实主题列表 |
| 集控客户端真实主题 | 需要提供集控客户端的真实主题列表 |
| 主题参数校验 | 前端校验参数格式(如会议室编号不能为空) |
| 批量创建 | 支持批量创建同类型设备,自动递增参数值 |
### P2 - 体验优化
| 任务 | 说明 |
|------|------|
| 主题预览 | 前端实时预览解析后的主题路径 |
| 参数模板 | 保存常用的参数组合为模板,快速复用 |
| 设备克隆 | 克隆设备时复制 topic_params |
---
## 九、相关文档索引
| 文档 | 路径 |
|------|------|
| 设备模拟 PRD | `Docs/PRD/需求文档/设备模拟/_PRD_需求文档_设备模拟模块.md` |
| 设备模拟执行计划 | `Docs/PRD/需求文档/设备模拟/_执行计划_设备模拟模块.md` |
| 多主题配置优化 PRD | `Docs/PRD/需求文档/设备模拟/_PRD_需求优化_环境配置多主题与设备接收记录.md` |
| 上次交接文档 | `HANDOFF_设备模拟.md` |
| 项目总交接 | `HANDOFF.md` |
---
*本文档由 Claude Code 生成,记录设备模拟主题配置改造进度,供下次会话快速恢复上下文。*
\ No newline at end of file
......@@ -52,6 +52,7 @@ class EnvConfig(Base):
topics: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="多主题前缀映射,如 {'door': 'device/door', 'paperless': 'device/paperless'}")
use_tls: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否启用 TLS")
client_id_prefix: Mapped[str] = mapped_column(String(100), default="sim", comment="客户端 ID 前缀")
default_topic_params: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="默认主题参数,含 company_id/room_ids/app_token_prefix/app_token_start/app_token_end/conference_id/conference_name")
status: Mapped[str] = mapped_column(String(20), default="disconnected", comment="连接状态")
last_connected_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="最后连接时间")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间")
......@@ -90,6 +91,8 @@ class DeviceSimulator(Base):
status: Mapped[str] = mapped_column(String(20), default="stopped", comment="运行状态")
auto_reconnect: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否自动重连")
extra_attrs: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="设备自定义属性")
topic_params: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="主题动态参数,如 {'room_id': 'A101', 'company_id': '001'}")
report_config: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="上报配置,含 interval(秒)/enabled(开关)")
last_reported_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="最后上报时间")
total_reports: Mapped[int] = mapped_column(Integer, default=0, comment="累计上报次数")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间")
......
......@@ -64,6 +64,8 @@ class BatchLocateRequest(BaseModel):
wait_for_selector: str = "" # 等待特定元素出现(CSS选择器)
retry_on_empty: bool = True # 元素为空时是否重试
max_retries: int = 2 # 最大重试次数
# === 新增参数(导航功能)===
navigate_menu: str = "" # 目标菜单名称(如"会议管理"、"信息管理")
class StepLocateResult(BaseModel):
......@@ -252,8 +254,17 @@ def extract_keywords(text: str) -> list:
return [kw for kw in keywords if kw not in seen and not seen.add(kw)]
def build_locator_candidates(step_description: str, elements: list, page_title: str = "", page_url: str = "") -> list:
"""关键词算法匹配元素,返回候选定位器列表(按置信度降序)"""
def build_locator_candidates(step_description: str, elements: list, page_title: str = "", page_url: str = "", skip_claude: bool = False) -> list:
"""
关键词算法匹配元素,返回候选定位器列表(按置信度降序)
Args:
step_description: 步骤描述
elements: 页面元素列表
page_title: 页面标题
page_url: 页面 URL
skip_claude: 是否跳过 Claude CLI 调用(批量定位时已在外面批量调用)
"""
candidates = []
desc_lower = step_description.lower()
is_input_action = any(kw in desc_lower for kw in ['输入', '填写', '键入', '写入', '填入'])
......@@ -296,8 +307,11 @@ def build_locator_candidates(step_description: str, elements: list, page_title:
candidates.sort(key=lambda x: x['confidence'], reverse=True)
# 始终调用宿主机 Claude CLI 进行精确匹配
# 容器内已登录并提取了元素,直接传给 Claude CLI 做匹配
# 如果 skip_claude=True,跳过 Claude CLI 调用(批量定位时已在外面批量调用)
if skip_claude:
return candidates[:5]
# 单个定位时调用 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'):
......@@ -330,10 +344,121 @@ def build_locator_candidates(step_description: str, elements: list, page_title:
return candidates[:5]
def call_claude_batch_via_ssh(step_descriptions: list, page_url: str, elements: list, page_title: str = "") -> dict:
"""
批量调用 Claude CLI 一次性匹配多个步骤(优化性能,减少 SSH 调用次数)
Args:
step_descriptions: 步骤描述列表 [{"order": 1, "description": "输入用户名"}, ...]
page_url: 页面 URL
elements: 页面元素列表
page_title: 页面标题
Returns:
dict: {order: {"locator_type": ..., "locator_value": ..., "confidence": ..., "reason": ...}}
"""
HOST = "192.168.5.60"
HOST_USER = "ubains"
SSH_PASSWORD = "Ubains@123"
if not elements:
logger.warning("没有元素列表可传给 Claude CLI")
return {}
if not step_descriptions:
return {}
# 构造批量匹配的 prompt
element_summary = json.dumps(elements[:80], ensure_ascii=False, indent=2) # 增加到 80 个元素
steps_json = json.dumps(step_descriptions, ensure_ascii=False, indent=2)
prompt = f"""你是一个UI自动化测试的定位助手。根据多个步骤描述和页面元素列表,批量匹配所有步骤对应的元素。
页面标题:{page_title}
页面URL:{page_url}
步骤列表(JSON格式):
{steps_json}
可交互元素列表(JSON格式):
{element_summary}
请为每个步骤找到最匹配的元素,返回JSON格式结果:
{{
"results": [
{{
"order": 步骤序号,
"locator_type": "css 或 xpath 或 text 或 id",
"locator_value": "定位值",
"confidence": 0.0-1.0(小数表示置信度),
"reason": "简要说明为什么匹配这个元素"
}},
...
]
}}
注意:
1. 必须为所有步骤返回结果,即使置信度为 0
2. 如果找不到匹配元素,locator_type 和 locator_value 设为空字符串,confidence 设为 0
3. 优先使用 CSS 选择器,语义化定位器优先(如 [placeholder*='xxx'])"""
try:
import paramiko
import base64
# 将 prompt 编码为 base64 避免特殊字符问题
prompt_b64 = base64.b64encode(prompt.encode('utf-8')).decode('ascii')
# 通过 SSH 调用 Claude CLI(增加超时时间到 120 秒)
cmd = f'echo {prompt_b64} | base64 -d | claude -p --print 2>/dev/null'
logger.info(f"通过 SSH 调用宿主机 Claude CLI 进行批量元素匹配({len(step_descriptions)} 个步骤)...")
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=120)
output = stdout.read().decode('utf-8', errors='replace')
err = stderr.read().decode('utf-8', errors='replace')
ssh.close()
if output:
# 提取 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])
results = parsed.get('results', [])
# 转换为以 order 为 key 的字典
result_map = {}
for r in results:
order = r.get('order', 0)
result_map[order] = {
'locator_type': r.get('locator_type', ''),
'locator_value': r.get('locator_value', ''),
'confidence': r.get('confidence', 0),
'reason': r.get('reason', 'Claude Code 批量匹配')
}
logger.info(f"Claude CLI 批量匹配完成: {len(result_map)}/{len(step_descriptions)} 个步骤")
return result_map
else:
logger.warning(f"Claude CLI 输出中未找到 JSON: {output[:300]}")
else:
logger.warning(f"Claude CLI 无输出, stderr: {err[:200]}")
return {}
except Exception as e:
logger.warning(f"Claude CLI 批量调用异常: {e}")
return {}
def call_claude_code_via_ssh(step_description: str, page_url: str, expected: str = "",
elements: list = None, page_title: str = "") -> dict:
"""
通过 SSH 调用宿主机上的 Claude CLI 进行元素匹配
通过 SSH 调用宿主机上的 Claude CLI 进行元素匹配(单个步骤)
容器内已通过 Playwright 登录并提取了页面元素,
这里只把元素列表传给宿主机的 Claude CLI 做精确匹配,
......@@ -423,12 +548,259 @@ def call_claude_code_via_ssh(step_description: str, page_url: str, expected: str
return {"locator_type": "", "locator_value": "", "confidence": 0, "reason": f"Claude调用异常: {str(e)}"}
def _navigate_to_menu(page, menu_name: str) -> bool:
"""
在已登录的页面上导航到指定菜单
被测系统导航方式:
1. 首页菜单卡片(.block 元素,包含分类和子菜单)
2. 直接通过 URL 路由导航(#hash 路由)
3. 功能中心抽屉(.home_nav_left 打开,再点击菜单项)
4. 通用 text 选择器
Args:
page: Playwright Page 对象
menu_name: 目标菜单名称(如"会议管理"、"信息管理")
Returns:
bool: 是否成功导航
"""
if not menu_name:
return True
logger.info(f"尝试导航到菜单: {menu_name}")
# 首页菜单卡片 URL 映射(基于实际探索,2026-08-04)
# 探索结果:22/24 成功,2 个超时(RSE相关)
MENU_ROUTES = {
# 会议管理相关
'新建会议': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FCreateMeeting',
'会议模板': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FMeetingtemplate',
'个人日程': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FtodayMeeting',
'会议室列表': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FMeetingRoomList',
'会议列表': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FmeetingAll',
# 数据统计
'预定数据': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FStatisticsModule',
'使用数据': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FUsedataModule',
'管理看板': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FManagePanel',
'故障数据': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FErrorModule',
# 运维管理
'运维数据': '/#/monitor?monitor=%2Fmonitor%2F%23%2Flogin',
'运维统计': '/#/backend/monitor?monitor=%2Fmonitor%2F%23%2Flogin',
'巡检报表': '/#/backend/monitor?monitor=%2Fmonitor%2F%23%2Flogin',
# 统计报表
'会议统计': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FMeetingStatistics',
'通知统计': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FMeetingStatistics',
'会议服务统计': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FMeetingStatistics',
'会议室统计': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FMeetingStatistics',
# 第三方集成
'Welink会议统计': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FOnlineMeetingStatistics%3FonlineType%25M22',
'历史记录-WeLink': '/#/backend/meetingV2?meetingV2=%2FmeetingV2%2F%23%2Fmeeting%2Frecord%3FonlineType%25M2Welink',
'SMC3.0会议统计': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FOnlineMeetingStatistics%3FonlineType%25M21',
'历史记录-SMC3.0': '/#/backend/meetingV2?meetingV2=%2FmeetingV2%2F%23%2Fmeeting%2Frecord%3FonlineType%25M2SMC3',
# 概览
'会议概览': '/#/platform?platform=%2Fplatform%2F%23%2FHome',
'会议室概览': '/#/platform?platform=%2Fplatform%2F%23%2FHome',
# 一级菜单别名映射(兼容旧代码)
'会议管理': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FmeetingAll',
'信息管理': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FMeetingStatistics',
}
# 方式0: 通过 URL 路由直接导航(最可靠)
if menu_name in MENU_ROUTES:
try:
base_url = 'https://192.168.5.44'
target_url = base_url + MENU_ROUTES[menu_name]
logger.info(f"通过 URL 路由导航: {target_url}")
page.goto(target_url, wait_until="domcontentloaded", timeout=15000)
page.wait_for_timeout(5000) # 等待微前端子应用加载
# 验证页面是否加载成功
current_url = page.url
logger.info(f"导航后 URL: {current_url}")
return True
except Exception as e:
logger.warning(f"URL 路由导航失败: {e}")
# 方式1: 点击首页菜单卡片(.block 元素)
try:
blocks = page.query_selector_all('.block')
logger.info(f"找到 {len(blocks)} 个菜单卡片")
for block in blocks:
p_elem = block.query_selector('p')
if p_elem:
text = (p_elem.inner_text() or '').strip()
if menu_name in text or text in menu_name:
block.click()
logger.info(f"通过首页菜单卡片导航成功: {text}")
page.wait_for_timeout(3000)
return True
except Exception as e:
logger.warning(f"首页菜单卡片导航失败: {e}")
# 方式2: 点击左侧导航菜单(el-menu)
try:
menu_items = page.query_selector_all('.el-menu-item')
for item in menu_items:
text = (item.inner_text() or '').strip()
if menu_name in text or text in menu_name:
item.click()
logger.info(f"通过左侧菜单导航成功: {text}")
page.wait_for_timeout(3000)
return True
except Exception as e:
logger.warning(f"左侧菜单导航失败: {e}")
# 方式3: 点击子菜单标题展开
try:
sub_menus = page.query_selector_all('.el-sub-menu__title')
for sub in sub_menus:
text = (sub.inner_text() or '').strip()
if menu_name in text or text in menu_name:
sub.click()
page.wait_for_timeout(1000)
menu_items = page.query_selector_all('.el-menu-item')
for item in menu_items:
item_text = (item.inner_text() or '').strip()
if menu_name in item_text or item_text in menu_name:
item.click()
logger.info(f"通过子菜单导航成功: {item_text}")
page.wait_for_timeout(3000)
return True
except Exception as e:
logger.warning(f"子菜单导航失败: {e}")
# 方式4: 功能中心抽屉
try:
nav_btn = page.query_selector('.home_nav_left')
if nav_btn and nav_btn.is_visible():
nav_btn.click()
page.wait_for_timeout(2000)
drawer_items = page.query_selector_all('.el-drawer .menu-item, .el-drawer .block, .el-drawer p')
for item in drawer_items:
text = (item.inner_text() or '').strip()
if menu_name in text or text in menu_name:
item.click()
logger.info(f"通过功能中心导航成功: {text}")
page.wait_for_timeout(3000)
return True
except Exception as e:
logger.warning(f"功能中心导航失败: {e}")
# 方式5: 使用 Playwright 的 text 选择器点击
try:
page.click(f'text="{menu_name}"', timeout=5000)
logger.info(f"通过 text 选择器导航成功: {menu_name}")
page.wait_for_timeout(3000)
return True
except Exception as e:
logger.warning(f"text 选择器导航失败: {e}")
logger.warning(f"所有导航方式均失败,无法导航到: {menu_name}")
return False
def _infer_menu_from_steps(steps: list) -> str:
"""
从用例步骤描述中推断目标菜单名称
根据步骤描述中的关键词匹配菜单名称。
如果多个步骤指向不同菜单,返回第一个非导航步骤的菜单。
Args:
steps: 用例步骤列表
Returns:
str: 推断的菜单名称,空字符串表示无法推断
"""
# 菜单关键词映射(基于实际首页菜单,2026-08-04)
MENU_KEYWORDS = {
# 会议管理
'新建会议': ['新建会议', '创建会议', '快速预约', '预约会议'],
'会议模板': ['会议模板', '模板', '快速创建'],
'个人日程': ['个人日程', '我的会议', '今日会议'],
'会议室列表': ['会议室', '会议室列表', '房间列表'],
'会议列表': ['会议列表', '所有会议', '会议记录'],
# 数据统计
'预定数据': ['预定', '预定数据', '预订统计'],
'使用数据': ['使用数据', '使用统计', '使用情况'],
'管理看板': ['管理看板', '看板', '数据看板'],
'故障数据': ['故障', '故障数据', '故障统计'],
# 运维管理
'运维数据': ['运维数据', '运维情况'],
'运维统计': ['运维统计', '运维报表'],
'巡检报表': ['巡检', '巡检报表', '巡检记录'],
# 统计报表
'会议统计': ['会议统计', '会议报表'],
'通知统计': ['通知统计', '通知报表'],
'会议服务统计': ['会议服务', '服务统计'],
'会议室统计': ['会议室统计', '会议室报表'],
# 第三方集成
'Welink会议统计': ['Welink', 'WeLink', 'welink'],
'历史记录-WeLink': ['WeLink历史', 'WeLink记录'],
'SMC3.0会议统计': ['SMC', 'SMC3.0', 'smc'],
'历史记录-SMC3.0': ['SMC历史', 'SMC记录'],
'RSE会议统计': ['RSE', 'rse'],
'历史记录-RSE': ['RSE历史', 'RSE记录'],
# 概览
'会议概览': ['会议概览', '概览'],
'会议室概览': ['会议室概览'],
# 一级菜单别名(兼容旧代码)
'会议管理': ['会议', '预约', '会议室'],
'信息管理': ['信息', '通知', '消息', '公告'],
}
# 收集所有步骤描述中的关键词
all_keywords = []
for step in steps:
desc = step.get('name', '') or step.get('description', '')
if not desc.strip():
continue
# 跳过导航和等待步骤
action = step.get('action', '')
if action in ['navigate', 'wait']:
continue
all_keywords.append(desc)
# 匹配菜单
menu_scores = {}
for menu_name, keywords in MENU_KEYWORDS.items():
score = 0
for desc in all_keywords:
for kw in keywords:
if kw in desc:
score += 1
break # 每个步骤只计一次
if score > 0:
menu_scores[menu_name] = score
if not menu_scores:
return ""
# 返回得分最高的菜单
best_menu = max(menu_scores, key=menu_scores.get)
logger.info(f"根据步骤推断目标菜单: {best_menu} (得分: {menu_scores[best_menu]})")
return best_menu
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:
max_retries: int = 2,
navigate_menu: str = "") -> dict:
"""
使用已有的 PlaywrightExecutor 执行定位准备工作
......@@ -447,6 +819,13 @@ def _do_locate_with_executor(executor, page_url: str, auto_login: bool,
return {'success': False, 'elements': [], 'page_title': '', 'current_url': '',
'message': '自动登录失败', 'elements_extracted': 0, 'retries': 0, 'page_load_time': 0}
# 导航到目标菜单(如果指定)
if navigate_menu and executor._is_logged_in:
logger.info(f"导航到目标菜单: {navigate_menu}")
nav_success = _navigate_to_menu(executor._page, navigate_menu)
if not nav_success:
logger.warning(f"导航到 {navigate_menu} 失败,将使用当前页面的元素")
# 访问目标页面
logger.info(f"访问页面: {page_url}")
current_url = executor._page.url
......@@ -615,6 +994,13 @@ async def locate_batch(request: BatchLocateRequest):
logger.info(f"用例有 {len(steps)} 个步骤")
# 智能推断目标菜单(如果未指定)
navigate_menu = request.navigate_menu
if not navigate_menu and request.auto_login:
navigate_menu = _infer_menu_from_steps(steps)
if navigate_menu:
logger.info(f"智能推断目标菜单: {navigate_menu}")
# 在线程中执行 Playwright
result_container = {
'success': False, 'case_id': request.case_id,
......@@ -642,7 +1028,8 @@ async def locate_batch(request: BatchLocateRequest):
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
max_retries=request.max_retries,
navigate_menu=navigate_menu
)
if not prep['success']:
result_container['message'] = prep['message']
......@@ -655,19 +1042,22 @@ async def locate_batch(request: BatchLocateRequest):
result_container['page_load_time'] = prep['page_load_time']
located_count = 0
# 为每个步骤匹配定位器
for step in updated_steps:
# === 性能优化:批量调用 Claude CLI(一次性匹配所有步骤)===
# 1. 收集需要定位的步骤
steps_to_locate = []
step_index_map = {} # order -> 索引
for idx, step in enumerate(updated_steps):
step_desc = step.get('name', '') or step.get('description', '')
step_order = step.get('order', 0)
action = step.get('action', '')
# 跳过空描述和导航步骤
if not step_desc.strip():
result_container['results'].append(StepLocateResult(
order=step_order, description=step_desc, success=False, message="步骤描述为空"
))
continue
# 跳过 navigate 类型的步骤(不需要定位器)
action = step.get('action', '')
if action == 'navigate':
result_container['results'].append(StepLocateResult(
order=step_order, description=step_desc, success=True,
......@@ -676,34 +1066,71 @@ async def locate_batch(request: BatchLocateRequest):
located_count += 1
continue
# 匹配定位器
candidates = build_locator_candidates(step_desc, elements, page_title, request.page_url)
if candidates:
best = candidates[0]
# 更新步骤的定位信息(使用驼峰命名匹配前端 IntentStepEditor)
step['locatorType'] = best['locator_type']
step['locatorValue'] = best['locator_value']
# 兼容蛇形命名(如果其他地方需要)
step['locator_type'] = best['locator_type']
step['locator_value'] = best['locator_value']
# 同步到 params.selector
if best['locator_value'] and 'params' in step:
step['params']['selector'] = best['locator_value']
elif best['locator_value']:
step['params'] = {'selector': best['locator_value']}
steps_to_locate.append({'order': step_order, 'description': step_desc})
step_index_map[step_order] = idx
result_container['results'].append(StepLocateResult(
order=step_order, description=step_desc, success=True,
locator_type=best['locator_type'], locator_value=best['locator_value'],
confidence=best['confidence'], message=f"置信度 {best['confidence']:.0%}"
))
located_count += 1
# 2. 批量调用 Claude CLI 一次性匹配(核心优化)
logger.info(f"批量调用 Claude CLI 匹配 {len(steps_to_locate)} 个步骤...")
claude_results = {}
if steps_to_locate:
try:
claude_results = call_claude_batch_via_ssh(
step_descriptions=steps_to_locate,
page_url=request.page_url,
elements=elements,
page_title=page_title
)
except Exception as e:
logger.warning(f"Claude 批量匹配失败,将使用关键词算法: {e}")
# 3. 为每个步骤匹配定位器
for step_info in steps_to_locate:
step_order = step_info['order']
step_desc = step_info['description']
step_idx = step_index_map[step_order]
step = updated_steps[step_idx]
# 优先使用 Claude 批量匹配结果
claude_result = claude_results.get(step_order, {})
if claude_result and claude_result.get('locator_value'):
# 使用 Claude 结果
locator_type = claude_result['locator_type']
locator_value = claude_result['locator_value']
confidence = claude_result.get('confidence', 0.8)
message = f"Claude 批量匹配 (置信度 {confidence:.0%})"
else:
result_container['results'].append(StepLocateResult(
order=step_order, description=step_desc, success=False,
message="未找到匹配元素"
))
# 回退到关键词算法(skip_claude=True 避免重复调用)
candidates = build_locator_candidates(step_desc, elements, page_title, request.page_url, skip_claude=True)
if candidates:
best = candidates[0]
locator_type = best['locator_type']
locator_value = best['locator_value']
confidence = best['confidence']
message = f"关键词匹配 (置信度 {confidence:.0%})"
else:
# 无匹配
result_container['results'].append(StepLocateResult(
order=step_order, description=step_desc, success=False,
message="未找到匹配元素"
))
continue
# 更新步骤的定位信息
step['locatorType'] = locator_type
step['locatorValue'] = locator_value
step['locator_type'] = locator_type
step['locator_value'] = locator_value
if locator_value and 'params' in step:
step['params']['selector'] = locator_value
elif locator_value:
step['params'] = {'selector': locator_value}
result_container['results'].append(StepLocateResult(
order=step_order, description=step_desc, success=True,
locator_type=locator_type, locator_value=locator_value,
confidence=confidence, message=message
))
located_count += 1
result_container['located_steps'] = located_count
result_container['success'] = True
......@@ -721,7 +1148,7 @@ async def locate_batch(request: BatchLocateRequest):
worker_thread = threading.Thread(target=_batch_worker, daemon=True)
worker_thread.start()
worker_thread.join(timeout=300) # 5 minutes for batch locate (each step needs SSH + Claude CLI)
worker_thread.join(timeout=600) # 10 minutes for batch locate (login + menu navigation + element extraction + each step needs SSH + Claude CLI)
if worker_thread.is_alive():
return BatchLocateResponse(success=False, case_id=request.case_id, total_steps=len(steps), located_steps=0, results=[], message="批量定位超时")
......
......@@ -34,6 +34,7 @@ class EnvConfigBase(BaseModel):
topics: Optional[Dict[str, str]] = Field(default=None, description="多主题前缀映射,key为设备类型,value为主题前缀")
use_tls: bool = Field(default=False, description="是否启用 TLS")
client_id_prefix: str = Field(default="sim", max_length=100, description="客户端 ID 前缀")
default_topic_params: Optional[dict] = Field(default=None, description="默认主题参数,如 {'company_id': '001', 'room_ids': ['A101', 'A202']}")
model_config = ConfigDict(
alias_generator=to_camel,
......@@ -58,6 +59,7 @@ class EnvConfigUpdate(BaseModel):
topics: Optional[Dict[str, str]] = Field(default=None, description="多主题前缀映射")
use_tls: Optional[bool] = Field(default=None, description="是否启用 TLS")
client_id_prefix: Optional[str] = Field(default=None, max_length=100, description="客户端 ID 前缀")
default_topic_params: Optional[dict] = Field(default=None, description="默认主题参数")
model_config = ConfigDict(
alias_generator=to_camel,
......@@ -100,6 +102,21 @@ class SimulatorBase(BaseModel):
auto_reconnect: bool = Field(default=True, description="是否自动重连")
extra_attrs: Optional[dict] = Field(default=None, description="设备自定义属性")
topic_params: Optional[Dict[str, str]] = Field(default=None, description="主题动态参数")
report_config: Optional[dict] = Field(default=None, description="上报配置,含 interval(秒)/enabled(开关)")
@field_validator('report_config')
@classmethod
def validate_report_config(cls, v):
"""验证上报配置字段"""
if v is None:
return None
if 'interval' in v:
if not isinstance(v['interval'], int) or v['interval'] < 1:
raise ValueError('interval must be a positive integer (>= 1)')
if 'enabled' in v:
if not isinstance(v['enabled'], bool):
raise ValueError('enabled must be a boolean')
return v
model_config = ConfigDict(
alias_generator=to_camel,
......@@ -119,6 +136,21 @@ class SimulatorUpdate(BaseModel):
auto_reconnect: Optional[bool] = Field(default=None, description="是否自动重连")
extra_attrs: Optional[dict] = Field(default=None, description="设备自定义属性")
topic_params: Optional[Dict[str, str]] = Field(default=None, description="主题动态参数")
report_config: Optional[dict] = Field(default=None, description="上报配置,含 interval(秒)/enabled(开关)")
@field_validator('report_config')
@classmethod
def validate_report_config(cls, v):
"""验证上报配置字段"""
if v is None:
return None
if 'interval' in v:
if not isinstance(v['interval'], int) or v['interval'] < 1:
raise ValueError('interval must be a positive integer (>= 1)')
if 'enabled' in v:
if not isinstance(v['enabled'], bool):
raise ValueError('enabled must be a boolean')
return v
model_config = ConfigDict(
alias_generator=to_camel,
......
......@@ -388,6 +388,7 @@ class DeviceSimService:
auto_reconnect=data.auto_reconnect,
extra_attrs=data.extra_attrs or {},
topic_params=data.topic_params or {},
report_config=data.report_config,
status="stopped",
)
......@@ -507,6 +508,7 @@ class DeviceSimService:
device_id=simulator.device_id,
env_config_id=config.id,
mqtt_manager=mqtt_manager,
report_config=simulator.report_config,
topics=topics,
topic_params=simulator.topic_params,
)
......@@ -529,7 +531,11 @@ class DeviceSimService:
_running_simulators[device_id] = sim
simulator.status = "running"
simulator.updated_at = datetime.now()
await self.db.flush()
try:
await self.db.flush()
except Exception as e:
logger.warning(f"更新设备状态失败(模拟器已启动): {device_id}, error={e}")
# 不抛出异常,模拟器已在运行
return success
......
......@@ -37,8 +37,9 @@ DEFAULT_INTERVAL_MAP = {
def create_simulator(device_type: str, device_id: str,
env_config_id: str, mqtt_manager: MqttManager,
report_interval: Optional[int] = None,
topics: Optional[dict] = None) -> Optional[BaseSimulator]:
report_config: Optional[dict] = None,
topics: Optional[dict] = None,
topic_params: Optional[dict] = None) -> Optional[BaseSimulator]:
"""
模拟器工厂方法
......@@ -49,8 +50,9 @@ def create_simulator(device_type: str, device_id: str,
device_id: 设备 ID
env_config_id: 环境配置 ID
mqtt_manager: MQTT 管理器实例
report_interval: 上报间隔(秒),None 使用默认值
report_config: 上报配置,如 {'interval': 30, 'enabled': True}
topics: 多主题前缀映射,key 为设备类型,value 为主题前缀
topic_params: 主题动态参数,如 {'room_id': 'A101', 'company_id': '001'}
Returns:
BaseSimulator: 模拟器实例,如果设备类型不支持则返回 None
......@@ -59,15 +61,18 @@ def create_simulator(device_type: str, device_id: str,
if not simulator_class:
return None
if report_interval is None:
report_interval = DEFAULT_INTERVAL_MAP.get(device_type, 30)
# 如果未提供 report_config,使用设备类型默认值
if report_config is None:
default_interval = DEFAULT_INTERVAL_MAP.get(device_type, 30)
report_config = {"interval": default_interval, "enabled": True}
return simulator_class(
device_id=device_id,
env_config_id=env_config_id,
mqtt_manager=mqtt_manager,
report_interval=report_interval,
topics=topics or {}
report_config=report_config,
topics=topics or {},
topic_params=topic_params,
)
......
......@@ -15,7 +15,7 @@ import threading
import time
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Optional
from typing import Optional, List
from app.services.mqtt_manager import MqttManager
......@@ -36,14 +36,15 @@ class BaseSimulator(ABC):
mqtt (MqttManager): MQTT 连接管理器
_running (bool): 是否正在运行
_thread (threading.Thread): 定时上报线程
_report_interval (int): 上报间隔(秒)
_report_config (dict): 上报配置,含 interval(秒)/enabled(开关)
_on_report_callback (callable): 上报回调(用于记录日志)
"""
def __init__(self, device_id: str, env_config_id: str,
device_type: str, mqtt_manager: MqttManager,
report_interval: int = 30,
topics: Optional[dict] = None):
report_config: Optional[dict] = None,
topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
"""
初始化模拟设备
......@@ -52,8 +53,9 @@ class BaseSimulator(ABC):
env_config_id: 环境配置 ID
device_type: 设备类型
mqtt_manager: MQTT 管理器实例
report_interval: 上报间隔(秒)
report_config: 上报配置,如 {'interval': 30, 'enabled': True}
topics: 多主题前缀映射,key 为设备类型,value 为主题前缀
topic_params: 主题动态参数,如 {'room_id': 'A101', 'company_id': '001'}
"""
self.device_id = device_id
self.env_config_id = env_config_id
......@@ -61,10 +63,18 @@ class BaseSimulator(ABC):
self.mqtt = mqtt_manager
self._running = False
self._thread: Optional[threading.Thread] = None
self._report_interval = report_interval
self._report_config = report_config or {}
self._report_interval = self._report_config.get('interval', 30)
self._report_enabled = self._report_config.get('enabled', True)
self._on_report_callback = None
self._lock = threading.Lock()
self.topics = topics or {}
self.topic_params = topic_params or {}
# 解析真实主题模板
self._resolved_topics: dict = {}
self._has_real_topics: bool = False
self._resolve_topics()
def get_topic_prefix(self) -> str:
"""
......@@ -79,6 +89,72 @@ class BaseSimulator(ABC):
return self.topics[self.device_type]
return f"device/{self.device_type}"
def _resolve_topics(self) -> None:
"""
解析主题模板,用 topic_params 替换占位符生成实际主题
如果设备类型有真实主题模板(门口屏/无纸化),则使用真实主题;
否则标记为无真实主题,使用旧格式 fallback。
"""
from app.simulators.topic_templates import (
has_real_topics,
resolve_all_topics,
)
self._has_real_topics = has_real_topics(self.device_type)
if self._has_real_topics:
# 确保topic_params中包含device_id(用于主题模板中的{device_id}占位符)
params_with_device_id = dict(self.topic_params)
if 'device_id' not in params_with_device_id:
params_with_device_id['device_id'] = self.device_id
self._resolved_topics = resolve_all_topics(self.device_type, params_with_device_id)
logger.info(f"设备 {self.device_id} 解析主题: {self._resolved_topics}")
def get_resolved_topic(self, template_key: str) -> Optional[str]:
"""
获取解析后的主题
Args:
template_key: 模板 key,如 "conference", "meeting_message"
Returns:
str: 解析后的实际主题,如不存在返回 None
"""
if template_key in self._resolved_topics:
return self._resolved_topics[template_key]["topic"]
return None
def get_publish_topics(self) -> List[dict]:
"""
获取所有上报主题
Returns:
List[dict]: 上报主题列表,每项包含 key, topic, label
"""
if not self._has_real_topics:
return []
return [
{"key": k, "topic": v["topic"], "label": v["label"]}
for k, v in self._resolved_topics.items()
if v["direction"] == "publish"
]
def get_subscribe_topics(self) -> List[dict]:
"""
获取所有订阅主题
Returns:
List[dict]: 订阅主题列表,每项包含 key, topic, label
"""
if not self._has_real_topics:
return []
return [
{"key": k, "topic": v["topic"], "label": v["label"]}
for k, v in self._resolved_topics.items()
if v["direction"] == "subscribe"
]
@abstractmethod
def build_register_payload(self) -> dict:
"""
......@@ -99,6 +175,126 @@ class BaseSimulator(ABC):
"""
pass
def build_reboot_response_payload(self) -> dict:
"""
构建 rebootResponseTopic 消息体
包含设备所有固定/模拟字段,符合 Mqtt_Send.py 的消息格式。
Returns:
dict: reboot 响应消息内容
"""
import random
import json
from datetime import datetime
# 从 topic_params 获取动态参数
company_id = self.topic_params.get("company_id", "001")
room_id = self.topic_params.get("room_id", "A101")
app_token = self.topic_params.get("app_token", "AND-518-0001")
conference_id = self.topic_params.get("conference_id", "")
conference_name = self.topic_params.get("conference_name", "")
# 内存和存储
total_mem = 1957
free_mem = random.randint(600, 900)
internal_total = 11075297280
internal_free = random.randint(9700000000, 11000000000)
# 系统时间
sys_date_obj = datetime.now()
sys_date = sys_date_obj.strftime("%a %b %d %H:%M:%S GMT+08:00 %Y")
sys_date_str = sys_date_obj.strftime("%Y-%m-%d %H:%M:%S")
# 运行时长(模拟)
seed = hash(self.device_id) % 86400
hours = seed // 3600
minutes = (seed % 3600) // 60
seconds = seed % 60
sys_elapsed_realtime = f"{hours}:{minutes:02d}:{seconds:02d}"
# 构建 buildInfo
build_info = {
"appToken": app_token,
"companyNumber": company_id,
"cnum": room_id,
"conferenceName": conference_name,
"conferenceId": int(conference_id) if conference_id else 0,
"defaultQrCodeUrl": None,
"aliasName": conference_name,
"serverBaseUrl": "https://192.168.9.86/exapi/",
"localBindTime": sys_date_str,
"generalField": json.dumps({
"floorPlanPath": "",
"conferencePhone": "",
"chooseTimeType": 1,
"meetingTopicSwitch": "1",
"meetingContentSwitch": "1",
"meetingReverseTypeSwitch": "1",
"seatArrangeSwitch": "0",
"meetingVoteSwitch": "1",
"jumpToPaperless": 2,
"meetingPublishScreenSwitch": "1",
"isLeaderJoin": False,
"approvalList": []
})
}
# 构建 result 对象
result_data = {
"result": {
"buildInfo": build_info,
"faceVersion": "4.2.12021020201.1",
"wgtVersion": "0.2.134",
"deviceModel": "Pad10-Pro",
"abiList": ["arm64-v8a", "armeabi-v7a", "armeabi"],
"androidId": self.device_id,
"appName": "UBAINS",
"appPackageName": "com.ubains.local.gviewer",
"appVersion": 351,
"appVersionName": "1.3.351",
"battery": round(random.uniform(0.3, 1.0), 1),
"bluetoothEnabled": False,
"camerasCount": 1,
"charging": True,
"density": 1,
"freeAndTotalMemory": f"{free_mem}M/{total_mem}M",
"internalAvailableSize": internal_free,
"internalTotalSize": internal_total,
"ipAddress": ".".join(str(random.randint(1, 255)) for _ in range(4)),
"macAddress": f"{random.randint(0,255):02X}:{random.randint(0,255):02X}:{random.randint(0,255):02X}:{random.randint(0,255):02X}:{random.randint(0,255):02X}:{random.randint(0,255):02X}",
"networkType": "NETWORK_WIFI",
"processCpuUsage": f"{random.randint(10, 50)}.{random.randint(0, 99):02d}%",
"resolution": "1920x1200",
"romName": "yuxian",
"rooted": True,
"sdkVersionCode": 30,
"sdkVersionName": "11",
"sysDate": sys_date,
"sysDateStr": sys_date_str,
"sysElapsedRealtime": sys_elapsed_realtime,
"sysLanguage": "en",
"sysSupportedSensors": ["Accelerometer sensor"],
"buildConfig": {
"FLAVOR_APP_TYPE": "rksystem",
"BUILD_TYPE": "release",
"FLAVOR_FEATURE": "doorscreen",
"DEBUG": False,
"FLAVOR": "rksystemDoorscreen",
},
"faceBuildInfo": "arcfacepro4x",
"authCode": app_token,
"conferenceName": conference_name
}
}
# 外层消息结构
return {
"method": "/system/readSystemInfo",
"clientId": self.device_id,
"result": json.dumps(result_data, ensure_ascii=False)
}
@abstractmethod
def _on_command(self, topic: str, payload: dict) -> None:
"""
......@@ -155,6 +351,9 @@ class BaseSimulator(ABC):
2. 订阅平台下发指令主题
3. 开启定时上报线程
如果设备类型有真实主题模板(门口屏/无纸化),使用真实主题;
否则使用旧格式 {prefix}/{device_type}/{device_id}/{action}。
Returns:
bool: 是否启动成功
"""
......@@ -165,20 +364,63 @@ class BaseSimulator(ABC):
try:
# 1. 发送注册消息
register_topic = f"{self.get_topic_prefix()}/{self.device_id}/register"
register_payload = self.build_register_payload()
if self._has_real_topics:
# 使用真实主题:注册消息发送到第一个 publish 主题
publish_topics = self.get_publish_topics()
if publish_topics:
register_topic_info = publish_topics[0]
register_topic = register_topic_info["topic"]
topic_key = register_topic_info.get("key", "")
# 根据主题 key 构建不同的注册消息体
if topic_key == "reboot_response":
register_payload = self.build_reboot_response_payload()
elif topic_key == "android_broadcast":
# 安卓广播主题:心跳上报
register_payload = {
"type": "heartbeat",
"clientId": self.device_id,
"appId": "com.ubains.local.gviewer",
"deviceId": self.device_id,
}
else:
register_payload = self.build_register_payload()
else:
register_topic = self.get_topic_prefix()
register_payload = self.build_register_payload()
else:
# 旧格式
register_topic = f"{self.get_topic_prefix()}/{self.device_id}/register"
register_payload = self.build_register_payload()
reg_success = self.mqtt.publish(self.env_config_id, register_topic, register_payload)
self._notify_report(register_topic, register_payload,
status="success" if reg_success else "failed")
# 2. 订阅平台下发指令主题
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
sub_success = self.mqtt.subscribe(self.env_config_id, command_topic, self._on_command)
if not sub_success:
logger.warning(f"订阅指令主题失败: {command_topic}")
if self._has_real_topics:
# 使用真实主题:订阅所有 subscribe 方向的主题
for sub_info in self.get_subscribe_topics():
sub_success = self.mqtt.subscribe(self.env_config_id, sub_info["topic"], self._on_command)
if not sub_success:
logger.warning(f"订阅指令主题失败: {sub_info['topic']}")
else:
logger.info(f"订阅指令主题成功: {sub_info['topic']} ({sub_info['label']})")
else:
# 旧格式
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
sub_success = self.mqtt.subscribe(self.env_config_id, command_topic, self._on_command)
if not sub_success:
logger.warning(f"订阅指令主题失败: {command_topic}")
# 3. 发送初始状态
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
if self._has_real_topics:
# 使用真实主题:状态消息发送到第一个 publish 主题
if publish_topics:
status_topic = publish_topics[0]["topic"]
else:
status_topic = self.get_topic_prefix()
else:
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
status_payload = self.build_status_payload()
status_payload["status"] = "online"
self.mqtt.publish(self.env_config_id, status_topic, status_payload)
......@@ -207,6 +449,7 @@ class BaseSimulator(ABC):
流程:
1. 发送离线状态
2. 停止定时上报线程
3. 取消订阅指令主题
Returns:
bool: 是否停止成功
......@@ -219,14 +462,23 @@ class BaseSimulator(ABC):
self._running = False
# 发送离线状态
offline_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
if self._has_real_topics:
publish_topics = self.get_publish_topics()
offline_topic = publish_topics[0]["topic"] if publish_topics else self.get_topic_prefix()
else:
offline_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
offline_payload = {"status": "offline", "device_id": self.device_id}
self.mqtt.publish(self.env_config_id, offline_topic, offline_payload)
self._notify_report(offline_topic, offline_payload, status="success")
# 取消订阅指令主题
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
self.mqtt.unsubscribe(self.env_config_id, command_topic)
if self._has_real_topics:
for sub_info in self.get_subscribe_topics():
self.mqtt.unsubscribe(self.env_config_id, sub_info["topic"])
else:
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
self.mqtt.unsubscribe(self.env_config_id, command_topic)
logger.info(f"模拟设备停止成功: {self.device_id}")
return True
......@@ -269,13 +521,51 @@ class BaseSimulator(ABC):
定时上报循环
在独立线程中运行,定时上报设备状态。
如果有真实主题模板,轮流使用各 publish 主题上报;
否则使用旧格式。
"""
# 检查是否启用定时上报
if not self._report_enabled:
logger.info(f"定时上报已禁用: {self.device_id}")
return
logger.info(f"定时上报线程启动: {self.device_id}, 间隔={self._report_interval}s")
# 轮流使用各 publish 主题的索引
publish_index = 0
while self._running:
try:
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
status_payload = self.build_status_payload()
status_payload = None
if self._has_real_topics:
# 使用真实主题:轮流使用各 publish 主题上报
publish_topics = self.get_publish_topics()
if publish_topics:
topic_info = publish_topics[publish_index % len(publish_topics)]
status_topic = topic_info["topic"]
topic_key = topic_info.get("key", "")
publish_index += 1
# 根据主题 key 构建不同的消息体
if topic_key == "reboot_response":
status_payload = self.build_reboot_response_payload()
elif topic_key == "android_broadcast":
# 安卓广播主题:心跳上报
status_payload = {
"type": "heartbeat",
"clientId": self.device_id,
"appId": "com.ubains.local.gviewer",
"deviceId": self.device_id,
}
else:
status_payload = self.build_status_payload()
else:
status_topic = self.get_topic_prefix()
status_payload = self.build_status_payload()
else:
# 旧格式
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
status_payload = self.build_status_payload()
success = self.mqtt.publish(self.env_config_id, status_topic, status_payload)
self._notify_report(status_topic, status_payload,
status="success" if success else "failed")
......
......@@ -97,10 +97,11 @@ class CentralSimulator(BaseSimulator):
}
def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 30,
topics: Optional[dict] = None):
mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
super().__init__(device_id, env_config_id, "central", mqtt_manager,
report_interval, topics)
report_config, topics, topic_params)
self._sub_devices = {}
self._current_scene = "power_off"
self._init_sub_devices()
......
......@@ -42,8 +42,9 @@ class ClientSimulator(BaseSimulator):
_VERSIONS = ["V1.2.0", "V1.3.0", "V1.4.0", "V2.0.0", "V2.1.0"]
def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 5,
topics: Optional[dict] = None):
mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
"""
初始化集控客户端模拟器
......@@ -51,11 +52,12 @@ class ClientSimulator(BaseSimulator):
device_id: 设备 ID
env_config_id: 环境配置 ID
mqtt_manager: MQTT 管理器
report_interval: 上报间隔(秒),集控客户端心跳频率更高
report_config: 上报配置,如 {'interval': 5, 'enabled': True}
topics: 多主题前缀映射
topic_params: 主题动态参数
"""
super().__init__(device_id, env_config_id, "client", mqtt_manager,
report_interval, topics)
report_config, topics, topic_params)
self._version = random.choice(self._VERSIONS)
self._heartbeat_count = 0
self._log_level = "info"
......
......@@ -38,8 +38,9 @@ class DoorSimulator(BaseSimulator):
"""
def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 30,
topics: Optional[dict] = None):
mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
"""
初始化门口屏模拟器
......@@ -47,11 +48,12 @@ class DoorSimulator(BaseSimulator):
device_id: 设备 ID
env_config_id: 环境配置 ID
mqtt_manager: MQTT 管理器
report_interval: 上报间隔(秒)
report_config: 上报配置,如 {'interval': 30, 'enabled': True}
topics: 多主题前缀映射
topic_params: 主题动态参数
"""
super().__init__(device_id, env_config_id, "door", mqtt_manager,
report_interval, topics)
report_config, topics, topic_params)
self._door_status = "closed"
self._battery_level = random.randint(60, 100)
self._call_active = False
......@@ -156,7 +158,11 @@ class DoorSimulator(BaseSimulator):
def _auto_close_door(self) -> None:
"""模拟自动关门"""
self._door_status = "closed"
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
if self._has_real_topics:
publish_topics = self.get_publish_topics()
status_topic = publish_topics[0]["topic"] if publish_topics else self.get_topic_prefix()
else:
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
self.mqtt.publish(self.env_config_id, status_topic, {
"device_id": self.device_id,
"status": "online",
......@@ -168,7 +174,11 @@ class DoorSimulator(BaseSimulator):
"""模拟设备重启"""
import threading as t
# 发送离线
offline_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
if self._has_real_topics:
publish_topics = self.get_publish_topics()
offline_topic = publish_topics[0]["topic"] if publish_topics else self.get_topic_prefix()
else:
offline_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
self.mqtt.publish(self.env_config_id, offline_topic, {
"device_id": self.device_id, "status": "offline", "reason": "reboot"
})
......@@ -176,7 +186,11 @@ class DoorSimulator(BaseSimulator):
def _recover():
time.sleep(5)
if self._running:
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
if self._has_real_topics:
publish_topics = self.get_publish_topics()
status_topic = publish_topics[0]["topic"] if publish_topics else self.get_topic_prefix()
else:
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
self.mqtt.publish(self.env_config_id, status_topic, self.build_status_payload())
t.Thread(target=_recover, daemon=True).start()
......
......@@ -52,10 +52,11 @@ class PaperlessSimulator(BaseSimulator):
]
def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 30,
topics: Optional[dict] = None):
mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
super().__init__(device_id, env_config_id, "paperless", mqtt_manager,
report_interval, topics)
report_config, topics, topic_params)
self._meeting_status = "idle"
self._signed_in = False
self._file_sync_progress = 0
......@@ -187,8 +188,15 @@ class PaperlessSimulator(BaseSimulator):
time.sleep(1)
self._file_sync_progress = i * 10
# 上报同步进度
sync_topic = f"{self.get_topic_prefix()}/{self.device_id}/file_sync"
self.mqtt.publish(self.env_config_id, sync_topic, {
if self._has_real_topics:
# 使用真实主题,找 meeting_message 或第一个 publish 主题
topic = self.get_resolved_topic("meeting_message")
if not topic:
publish_topics = self.get_publish_topics()
topic = publish_topics[0]["topic"] if publish_topics else self.get_topic_prefix()
else:
topic = f"{self.get_topic_prefix()}/{self.device_id}/file_sync"
self.mqtt.publish(self.env_config_id, topic, {
"device_id": self.device_id,
"progress": self._file_sync_progress,
"file_name": f"meeting_materials_{time.strftime('%Y%m%d')}.pdf",
......
......@@ -74,6 +74,8 @@ export interface BatchLocateRequest {
retry_on_empty?: boolean
/** 最大重试次数(默认 2) */
max_retries?: number
/** 目标菜单名称(如"会议管理"、"信息管理",支持智能推断) */
navigate_menu?: string
}
/**
......
......@@ -51,6 +51,14 @@
>
{{ row.status === 'running' ? '停止' : '启动' }}
</el-button>
<el-button
link
type="primary"
size="small"
@click="openEditDialog(row)"
>
编辑
</el-button>
<el-button
link
type="primary"
......@@ -81,7 +89,7 @@
</div>
<!-- 新增设备弹窗 -->
<el-dialog v-model="createDialogVisible" title="新增模拟设备" width="500px">
<el-dialog v-model="createDialogVisible" title="新增模拟设备" width="600px">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px" label-position="top">
<el-form-item label="关联环境" prop="envConfigId">
<el-select v-model="createForm.envConfigId" placeholder="选择环境配置" style="width: 100%">
......@@ -94,9 +102,77 @@
<el-form-item label="设备 ID" prop="deviceId">
<el-input v-model="createForm.deviceId" :placeholder="`如:${deviceType}_001`" />
</el-form-item>
<!-- 门口屏授权码选择 -->
<el-form-item v-if="hasRealTopics && deviceType === 'door' && appTokenOptions.length > 0" label="门口屏授权码">
<el-select v-model="createForm.topicParams!.app_token" placeholder="选择授权码" style="width: 100%" size="small">
<el-option v-for="token in appTokenOptions" :key="token" :label="token" :value="token" />
</el-select>
</el-form-item>
<el-form-item label="自动重连">
<el-switch v-model="createForm.autoReconnect" />
</el-form-item>
<!-- 上报配置区域 -->
<el-divider content-position="left">上报配置</el-divider>
<el-form-item label="启用定时上报">
<el-switch v-model="createForm.reportConfig!.enabled" @change="onReportEnabledChange" />
</el-form-item>
<el-form-item label="上报间隔(秒)" v-if="createForm.reportConfig?.enabled">
<el-input-number
v-model="createForm.reportConfig!.interval"
:min="1"
:max="86400"
:step="1"
placeholder="请输入上报间隔"
style="width: 200px"
/>
<span class="form-hint" style="margin-left: 10px; color: #909399; font-size: 12px;">
范围: 1秒 ~ 24小时(86400秒)
</span>
</el-form-item>
<!-- 主题参数配置(仅门口屏/无纸化显示)- 已隐藏 -->
<!-- <el-divider v-if="hasRealTopics" content-position="left">主题参数配置</el-divider>
<div v-if="hasRealTopics && topicTemplates.length > 0" class="topic-params-section">
<div v-for="tmpl in topicTemplates" :key="tmpl.key" class="topic-template-item">
<div class="topic-template-header">
<el-tag :type="tmpl.direction === 'publish' ? 'primary' : 'success'" size="small" class="direction-tag">
{{ tmpl.direction === 'publish' ? '上报' : '订阅' }}
</el-tag>
<span class="topic-label">{{ tmpl.label }}</span>
<span class="topic-template-path">{{ tmpl.template }}</span>
</div>
<div v-if="tmpl.params.length > 0" class="topic-param-inputs">
<el-row :gutter="12">
<el-col v-for="paramKey in tmpl.params" :key="paramKey" :span="12">
<el-form-item :label="tmpl.paramLabels[paramKey] || paramKey" class="compact-form-item">
<el-input
v-if="paramKey === 'company_id'"
v-model="(createForm.topicParams as any)[paramKey]"
size="small"
disabled
/>
<el-select
v-else-if="paramKey === 'room_id' && defaultTopicParams?.room_ids?.length"
v-model="(createForm.topicParams as any)[paramKey]"
size="small"
style="width: 100%"
placeholder="选择会议室"
>
<el-option v-for="room in defaultTopicParams.room_ids" :key="room" :label="room" :value="room" />
</el-select>
<el-input
v-else
v-model="(createForm.topicParams as any)[paramKey]"
:placeholder="tmpl.paramDefaults[paramKey] || `请输入${tmpl.paramLabels[paramKey] || paramKey}`"
size="small"
/>
</el-form-item>
</el-col>
</el-row>
</div>
</div>
</div> -->
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
......@@ -124,6 +200,22 @@
<el-button type="primary" :loading="reporting" @click="handleManualReport">发送</el-button>
</template>
</el-dialog>
<!-- 编辑设备弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑设备" width="500px">
<el-form ref="editFormRef" :model="editForm" label-width="100px" label-position="top">
<el-form-item label="设备名称">
<el-input v-model="editForm.deviceName" />
</el-form-item>
<el-form-item label="自动重连">
<el-switch v-model="editForm.autoReconnect" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editing" @click="handleEdit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
......@@ -134,13 +226,15 @@ import { Plus } from '@element-plus/icons-vue'
import {
listSimulators,
createSimulator,
updateSimulator,
deleteSimulator,
startSimulator,
stopSimulator,
manualReport,
listEnvConfigs,
getTopicTemplates,
} from '@/api/deviceSim'
import type { Simulator, SimulatorCreate, EnvConfig, DeviceType } from '@/types/device'
import type { Simulator, SimulatorCreate, EnvConfig, DeviceType, TopicTemplate } from '@/types/device'
import { DEVICE_TYPE_LABELS } from '@/types/device'
const props = defineProps<{
......@@ -176,6 +270,11 @@ const createForm = ref<SimulatorCreate>({
deviceName: '',
deviceId: '',
autoReconnect: true,
topicParams: {},
reportConfig: {
interval: 30,
enabled: true,
},
})
const createRules = {
envConfigId: [{ required: true, message: '请选择环境配置', trigger: 'change' }],
......@@ -183,12 +282,52 @@ const createRules = {
deviceId: [{ required: true, message: '请输入设备 ID', trigger: 'blur' }],
}
// 主题模板
const topicTemplates = ref<TopicTemplate[]>([])
const hasRealTopics = ref(false)
const loadingTemplates = ref(false)
// 环境配置的默认主题参数
const defaultTopicParams = ref<{
company_id?: string
room_ids?: string[]
app_token_prefix?: string
app_token_start?: number
app_token_end?: number
conference_id?: string
conference_name?: string
} | null>(null)
// 授权码选项列表
const appTokenOptions = computed(() => {
if (!defaultTopicParams.value?.app_token_prefix) return []
const prefix = defaultTopicParams.value.app_token_prefix
const start = defaultTopicParams.value.app_token_start ?? 1
const end = defaultTopicParams.value.app_token_end ?? 10
const options: string[] = []
for (let i = start; i <= end; i++) {
options.push(`${prefix}-${String(i).padStart(4, '0')}`)
}
return options
})
// 手动上报弹窗
const reportDialogVisible = ref(false)
const reporting = ref(false)
const reportFormRef = ref()
const reportForm = ref({ topic: '', payloadStr: '{}', deviceId: '' })
// 编辑设备弹窗
const editDialogVisible = ref(false)
const editing = ref(false)
const editFormRef = ref()
const editForm = ref({
id: '',
deviceName: '',
autoReconnect: true,
topicParams: {} as Record<string, string>,
})
/** 加载设备列表 */
async function loadDevices() {
loading.value = true
......@@ -219,16 +358,88 @@ async function loadEnvConfigs() {
} catch (_) { /* ignore */ }
}
/** 加载主题模板 */
async function loadTopicTemplates() {
if (!props.deviceType) return
loadingTemplates.value = true
try {
const res = await getTopicTemplates(props.deviceType)
topicTemplates.value = res.templates
hasRealTopics.value = res.hasRealTopics
} catch (_) {
topicTemplates.value = []
hasRealTopics.value = false
} finally {
loadingTemplates.value = false
}
}
/** 打开新增弹窗 */
function openCreateDialog() {
async function openCreateDialog() {
createForm.value = {
envConfigId: filterEnvConfigId.value || '',
deviceType: props.deviceType,
deviceName: '',
deviceId: '',
autoReconnect: true,
topicParams: {},
reportConfig: {
interval: 30,
enabled: true,
},
}
defaultTopicParams.value = null
createDialogVisible.value = true
// 加载主题模板
await loadTopicTemplates()
// 如果有预选环境,加载其默认参数
if (createForm.value.envConfigId) {
await loadDefaultTopicParams(createForm.value.envConfigId)
}
}
/** 加载环境配置的默认主题参数 */
async function loadDefaultTopicParams(envConfigId: string) {
const env = envConfigs.value.find(e => e.id === envConfigId)
if (env?.defaultTopicParams) {
defaultTopicParams.value = env.defaultTopicParams
// 预填默认参数值
if (hasRealTopics.value) {
const defaults: Record<string, string> = {}
// 使用环境配置的 company_id
if (env.defaultTopicParams.company_id) {
defaults.company_id = env.defaultTopicParams.company_id
}
// 使用环境配置的第一个 room_id
if (env.defaultTopicParams.room_ids?.length) {
defaults.room_id = env.defaultTopicParams.room_ids[0]
}
// 其他参数使用模板默认值
for (const tmpl of topicTemplates.value) {
for (const paramKey of tmpl.params) {
if (!defaults[paramKey] && tmpl.paramDefaults[paramKey]) {
defaults[paramKey] = tmpl.paramDefaults[paramKey]
}
}
}
createForm.value.topicParams = defaults
}
}
}
/** 上报启用状态变化 */
function onReportEnabledChange(enabled: boolean) {
if (enabled && !createForm.value.reportConfig?.interval) {
// 启用时确保 interval 有默认值
createForm.value.reportConfig = {
...createForm.value.reportConfig,
interval: 30,
enabled: true,
}
}
}
/** 创建设备 */
......@@ -282,6 +493,36 @@ async function handleDelete(row: Simulator) {
}
}
/** 打开编辑弹窗 */
function openEditDialog(row: Simulator) {
editForm.value = {
id: row.id,
deviceName: row.deviceName,
autoReconnect: row.autoReconnect,
topicParams: {},
}
editDialogVisible.value = true
}
/** 保存编辑 */
async function handleEdit() {
editing.value = true
try {
await updateSimulator(editForm.value.id, {
deviceName: editForm.value.deviceName,
autoReconnect: editForm.value.autoReconnect,
})
ElMessage.success('设备已更新')
editDialogVisible.value = false
await loadDevices()
emit('device-changed')
} catch (e: any) {
ElMessage.error('更新失败: ' + (e.message || ''))
} finally {
editing.value = false
}
}
/** 打开手动上报弹窗 */
function openManualReport(row: Simulator) {
reportForm.value = { topic: 'status', payloadStr: '{}', deviceId: row.id }
......@@ -342,4 +583,61 @@ onMounted(() => {
justify-content: flex-end;
margin-top: 16px;
}
.topic-params-section {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 12px;
margin-bottom: 12px;
}
.topic-template-item {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.topic-template-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.topic-template-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.direction-tag {
flex-shrink: 0;
}
.topic-label {
font-weight: 500;
color: #303133;
}
.topic-template-path {
font-family: 'Courier New', monospace;
font-size: 12px;
color: #909399;
margin-left: auto;
}
.topic-param-inputs {
padding-left: 8px;
}
.compact-form-item {
margin-bottom: 8px;
}
.compact-form-item :deep(.el-form-item__label) {
font-size: 13px;
padding-bottom: 2px;
}
</style>
\ No newline at end of file
......@@ -36,6 +36,15 @@ export interface EnvConfig {
topics: Record<string, string> | null
useTls: boolean
clientIdPrefix: string
defaultTopicParams: {
company_id?: string
room_ids?: string[]
app_token_prefix?: string
app_token_start?: number
app_token_end?: number
conference_id?: string
conference_name?: string
} | null
status: string
lastConnectedAt: string | null
createdAt: string
......@@ -53,6 +62,15 @@ export interface EnvConfigCreate {
topics?: Record<string, string> | null
useTls?: boolean
clientIdPrefix?: string
defaultTopicParams?: {
company_id?: string
room_ids?: string[]
app_token_prefix?: string
app_token_start?: number
app_token_end?: number
conference_id?: string
conference_name?: string
} | null
}
/** 更新环境配置请求 */
......@@ -66,6 +84,15 @@ export interface EnvConfigUpdate {
topics?: Record<string, string> | null
useTls?: boolean
clientIdPrefix?: string
defaultTopicParams?: {
company_id?: string
room_ids?: string[]
app_token_prefix?: string
app_token_start?: number
app_token_end?: number
conference_id?: string
conference_name?: string
} | null
}
/** 模拟设备 */
......@@ -79,6 +106,10 @@ export interface Simulator {
autoReconnect: boolean
extraAttrs: Record<string, any> | null
topicParams: Record<string, string> | null
reportConfig?: {
interval: number // 上报间隔(秒)
enabled?: boolean // 是否启用定时上报
}
lastReportedAt: string | null
totalReports: number
createdAt: string
......@@ -94,6 +125,10 @@ export interface SimulatorCreate {
autoReconnect?: boolean
extraAttrs?: Record<string, any> | null
topicParams?: Record<string, string> | null
reportConfig?: {
interval: number
enabled?: boolean
}
}
/** 更新模拟设备请求 */
......@@ -102,6 +137,10 @@ export interface SimulatorUpdate {
autoReconnect?: boolean
extraAttrs?: Record<string, any> | null
topicParams?: Record<string, string> | null
reportConfig?: {
interval: number
enabled?: boolean
}
}
/** 上报记录 */
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论