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

docs(用例管理): 新增元素定位方案对比分析报告

feat(设备模拟): 多类型主题上报支持 + 前端设备列表增强

- 新增元素定位方案对比分析文档(录制器/智能定位/手动编写)
- 设备模拟:central_simulator多类型主题上报、topic_templates扩展
- 设备模拟:前端DeviceList组件增强、新增deviceSim API和类型定义
- 更新HANDOFF_设备模拟交接文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 7c13eeed
# UI自动化测试:元素定位方案对比分析
> **文档版本**: v1.0
> **创建日期**: 2026-08-06
> **作者**: Claude
> **背景**: 平台自动化测试项目 - 复杂交互用例定位问题分析
---
## 一、三种定位方案概览
| 方案 | 原理 | 谁执行 | 适用场景 |
|------|------|--------|----------|
| **录制器** | 捕获用户操作时的真实DOM路径 | 系统自动 | 首次创建用例、简单流程 |
| **智能定位** | 语义推断 + Playwright实际执行 | AI辅助 | 选择器缺失/失效、简单交互 |
| **手动编写** | 人为判断最佳定位策略 | 开发人员 | 复杂交互、精细控制、微前端 |
---
## 二、详细对比分析
### 2.1 准确率对比
| 方案 | 准确率 | 原因分析 |
|------|--------|----------|
| **录制器** | 70%~85% | 捕获的是"那一刻"的DOM路径,页面变化后易失效 |
| **智能定位** | 60%~75% | 语义推断可能误匹配,复杂场景无法处理 |
| **手动编写** | 85%~95% | 人脑可选择最佳策略,适应性强 |
**说明**
- 录制器生成的选择器往往是 `div:nth-child(3)` 这种脆弱路径
- 智能定位对语义清晰的操作(如"点击登录按钮")准确率高,但对"点击第一个会议室的编辑按钮"这类相对定位无法处理
- 手动编写可选择 `data-testid`、语义选择器、XPath等多种策略
---
### 2.2 适用场景对比
| 场景类型 | 录制器 | 智能定位 | 手动编写 |
|----------|--------|----------|----------|
| **登录流程** | ✅ 适用 | ✅ 适用 | ✅ 适用 |
| **菜单导航** | ✅ 适用 | ✅ 适用 | ✅ 适用 |
| **简单表单** | ✅ 适用 | ⚠️ 部分适用 | ✅ 适用 |
| **微前端页面** | ❌ 有限支持 | ⚠️ 需iframe遍历 | ✅ 最灵活 |
| **动态弹窗** | ⚠️ 可能不稳定 | ❌ 无法处理 | ✅ 可处理 |
| **条件渲染元素** | ❌ 无法录制 | ❌ 无法处理 | ✅ 可处理 |
| **相对定位** | ❌ 难以表达 | ❌ 无法处理 | ✅ 可处理 |
| **复杂交互流程** | ⚠️ 部分支持 | ❌ 不适用 | ✅ 最可靠 |
**图例**
- ✅ 适用:方案能很好地处理该场景
- ⚠️ 部分适用:方案有一定局限性
- ❌ 不适用/有限支持:方案无法有效处理
---
### 2.3 微前端支持情况
**问题背景**:本项目的被测系统(统一管理平台)采用 **micro-app 微前端架构**,包含多个子应用。
| 方案 | 微前端内元素定位能力 | 问题 |
|------|---------------------|------|
| **录制器** | ⚠️ 可能无法正确录制 | 录制器可能只捕获主页面操作,微前端内的点击丢失或路径错误 |
| **智能定位** | ⚠️ 需要iframe遍历支持 | 已实现iframe遍历,但动态加载的微前端可能超时 |
| **手动编写** | ✅ 完全支持 | 开发人员知道去哪个iframe/micro-app内查找 |
**实际案例**
```
步骤9: 点击【新建会议】按钮
→ 新建会议按钮在 micro-app 容器内
→ 录制器:可能只录制到容器点击,或路径为 #app > div:nth-child(x)
→ 智能定位:需要遍历 iframe,可能超时或找不到
→ 手动:div.micro-app-body >> button:has-text("新建会议") ✅
```
---
### 2.4 动态元素处理能力
**动态元素类型**
1. **条件渲染**:特定状态才出现的元素(如弹窗、加载提示)
2. **动态ID/Class**:每次加载不同的标识符(如 `.btn-8f3a2`
3. **相对位置**:如"第一个会议室的编辑按钮"、"表格第2行的删除按钮"
| 动态元素类型 | 录制器 | 智能定位 | 手动编写 |
|-------------|--------|----------|----------|
| 条件渲染弹窗 | ❌ 录制时可能不存在 | ❌ 无法预知 | ✅ 可用等待策略 |
| 动态ID/Class | ❌ 路径易失效 | ❌ 无法匹配 | ✅ 可用语义选择器 |
| 相对定位 | ❌ 无法表达 | ❌ 无法处理 | ✅ 可用XPath/组合选择器 |
---
### 2.5 维护成本对比
| 维度 | 录制器 | 智能定位 | 手动编写 |
|------|--------|----------|----------|
| **初次创建成本** | 低(自动录制) | 低(自动生成) | 高(需逐个定位) |
| **选择器更新成本** | 高(需重新录制) | 低(自动重新定位) | 中(需手动修改) |
| **长期维护成本** | 高(频繁失效) | 中(需验证) | 低(稳定策略) |
| **调试难度** | 中(需理解录制逻辑) | 中(需理解AI推断) | 低(人为可控) |
---
### 2.6 技术实现对比
#### 录制器
**原理**
```javascript
// 录制器监听用户操作
document.addEventListener('click', (e) => {
// 获取点击元素的DOM路径
const path = getDOMPath(e.target);
// 生成步骤:{ action: 'click', selector: 'div#app > button.btn' }
});
```
**生成的选择器示例**
```css
/* 脆弱的选择器(易失效) */
#root > div:nth-child(3) > div.content > button:nth-child(2)
/* 稍好的选择器 */
button.submit-btn
/* 无法生成的选择器 */
button:has-text("确定") /* 录制器不知道文本内容 */
.meeting-room-card:has-text("北京") >> .edit-btn /* 无法表达相对定位 */
```
---
#### 智能定位
**原理**
```
用户输入步骤描述 → 关键词提取 → 元素匹配 → 执行验证 → 返回选择器
```
**关键词提取流程**
```python
步骤名称: "点击【新建会议】按钮"
去除动作词(点击)
去除元素类型词(按钮)
提取核心词
关键词: ["新建会议"]
```
**元素匹配策略**
```
优先级1: 关键词直接匹配(ID, name, placeholder, text)
优先级2: 语义推断匹配(根据动作类型推断元素类型)
优先级3: 页面快照回退(提取所有可交互元素)
```
**局限**
```python
# 无法处理相对定位
"点击第一个会议室的编辑按钮" 无法表达"第一个""相对关系"
# 无法处理条件渲染
"点击成功提示的确定按钮" 提示可能还未出现
# 无法处理微前端内动态加载
"点击会议列表第2行" 微前端可能还在加载
```
---
#### 手动编写
**原理**
```python
# 开发人员根据页面实际结构,选择最佳定位策略
# 策略1: 语义选择器(最稳定)
selector = "button:has-text('新建会议')"
# 策略2: data-testid(推荐)
selector = "[data-testid='create-meeting-btn']"
# 策略3: XPath(处理相对定位)
selector = "//div[contains(@class, 'meeting-room')][1]//button[text()='编辑']"
# 策略4: 微前端穿透
selector = ".micro-app-container >> iframe >> button:has-text('提交')"
# 策略5: 组合选择器(处理动态元素)
selector = ".meeting-card:has-text('北京展厅') >> .edit-btn"
```
**优势**
- 可选择最适合当前场景的策略
- 可处理复杂逻辑(如"等待弹窗出现后再点击")
- 可添加回退策略(如先试语义选择器,失败再试XPath)
---
## 三、实际案例分析
### 案例1:会议管理-新建会议用例
**用例信息**
- 用例ID: `case_13650e0406e64779b66e6fa5de35c24a`
- 步骤数: 21步
- 场景: 登录 → 导航 → 填写表单 → 弹窗交互 → 验证
**问题统计**
| 问题类型 | 步骤数 | 占比 |
|---------|--------|------|
| 选择器为None | 5 | 24% |
| 选择器明显错误 | 4 | 19% |
| 选择器过于泛化 | 2 | 10% |
| 选择器正确 | 10 | 48% |
**典型问题步骤**
| 步骤 | 名称 | 当前选择器 | 问题 |
|------|------|-----------|------|
| 9 | 点击新建会议按钮 | `None` | 微前端内元素 |
| 14 | 点击编辑按钮 | `None` | 动态生成的按钮 |
| 18 | 点击查看详情按钮 | `input[placeholder*="搜索"]` | 完全错误的选择器 |
**方案评估**
| 方案 | 能否处理 | 预期效果 |
|------|---------|----------|
| 录制器 | ⚠️ 部分支持 | 可能录制不到微前端内操作,弹窗交互易丢失 |
| 智能定位 | ❌ 不适用 | 无法处理动态弹窗、相对定位、条件渲染 |
| 手动编写 | ✅ 最可靠 | 可逐个定位,处理微前端穿透、动态元素 |
---
### 案例2:简单页面访问用例
**用例信息**
- 用例: 信息发布-页面访问验证
- 步骤数: 6步
- 场景: 登录 → 点击功能中心 → 点击菜单 → 验证页面
**选择器状态**:全部正确
**方案评估**
| 方案 | 能否处理 | 预期效果 |
|------|---------|----------|
| 录制器 | ✅ 完全适用 | 可正确录制,适合此类简单流程 |
| 智能定位 | ✅ 完全适用 | 可自动生成正确选择器 |
| 手动编写 | ✅ 适用 | 但成本高于前两者 |
**结论**:简单流程适合用录制器或智能定位,无需手动编写。
---
## 四、方案选择建议
### 4.1 决策流程图
```
开始定位元素
是否首次创建用例?
├─ 是 → 页面是否为微前端?
│ ├─ 是 → 【手动编写】(推荐)
│ └─ 否 → 【录制器】
└─ 否 → 选择器是否缺失/失效?
├─ 是 → 步骤是否涉及动态元素/弹窗?
│ ├─ 是 → 【手动编写】
│ └─ 否 → 【智能定位】
└─ 否 → 无需操作
```
### 4.2 场景-方案匹配表
| 场景特征 | 推荐方案 | 原因 |
|---------|---------|------|
| 简单登录流程 | 录制器 或 智能定位 | 步骤固定,选择器稳定 |
| 页面访问验证 | 智能定位 | 自动生成足够准确 |
| 微前端页面操作 | 手动编写 | 需穿透iframe/micro-app |
| 表单填写(简单) | 录制器 | 可正确捕获输入操作 |
| 表单填写(复杂动态) | 手动编写 | 动态元素需特殊处理 |
| 弹窗交互 | 手动编写 | 需等待策略 + 精确定位 |
| 表格行操作 | 手动编写 | 需相对定位(XPath) |
| 条件渲染元素 | 手动编写 | 需等待策略 |
### 4.3 选择器编写最佳实践
**优先级排序**
```python
# 1. data-testid(最稳定,需开发配合)
selector = "[data-testid='submit-btn']"
# 2. 语义选择器(推荐)
selector = "button:has-text('确定')"
# 3. aria-label(无障碍)
selector = "[aria-label='关闭弹窗']"
# 4. 唯一ID
selector = "#submit-btn"
# 5. 组合选择器(相对定位)
selector = ".card:has-text('会议室A') >> button.edit"
# 6. XPath(复杂相对定位)
selector = "//tr[td[text()='会议室A']]//button[text()='编辑']"
# 7. CSS nth-child(尽量避免)
selector = ".btn-group > button:nth-child(2)" # 脆弱!
```
**微前端穿透**
```python
# Playwright 选择器穿透 iframe/micro-app
selector = ".micro-app-container >> iframe >> .content >> button"
# 或使用 frame_locator
page.frame_locator('.micro-app-container iframe').locator('button').click()
```
---
## 五、总结
### 5.1 三种方案定位
| 方案 | 定位 | 核心价值 | 核心局限 |
|------|------|---------|----------|
| **录制器** | 快速创建 | 自动化程度高 | 微前端有限支持、选择器脆弱 |
| **智能定位** | AI辅助 | 降低人工成本 | 无法处理复杂场景 |
| **手动编写** | 精确控制 | 最可靠、最灵活 | 人工成本高 |
### 5.2 本项目建议
1. **简单用例**(页面访问、简单导航)
- 使用智能定位或录制器
- 维护成本低
2. **中等复杂用例**(表单填写、Tab切换)
- 智能定位生成初版 + 手动验证修正
- 平衡成本和准确率
3. **高度复杂用例**(新建会议这类多弹窗交互)
- 手动编写选择器
- 或拆分成多个简单用例
- 或评估是否真的需要自动化(可能手工测试更划算)
### 5.3 行业趋势
| 方案 | 代表产品 | 技术水平 |
|------|---------|----------|
| 传统录制 | Selenium IDE, Katalon | 成熟但局限明显 |
| 智能定位 | 本项目智能定位 | 中等(依赖语义推断) |
| AI增强定位 | Testim, Mabl, Applitools | 高(CV + LLM融合) |
| 自愈合 | Healenium, Autokin | 中高(ML预测元素变化) |
**本项目定位**:智能定位处于中等水平,适合简单场景,复杂场景仍需人工介入。
---
## 附录:选择器稳定性评分标准
| 选择器类型 | 稳定性评分 | 示例 |
|-----------|-----------|------|
| data-testid | ⭐⭐⭐⭐⭐ 95% | `[data-testid='submit']` |
| aria-label | ⭐⭐⭐⭐⭐ 90% | `[aria-label='提交']` |
| 语义选择器 | ⭐⭐⭐⭐ 85% | `button:has-text("提交")` |
| 唯一ID | ⭐⭐⭐⭐ 80% | `#submit-btn` |
| 组合选择器 | ⭐⭐⭐ 70% | `.card >> button` |
| XPath相对定位 | ⭐⭐⭐ 65% | `//div[@class='card']//button` |
| CSS nth-child | ⭐⭐ 40% | `div:nth-child(3)` |
| 完整DOM路径 | ⭐ 20% | `#root > div > div:nth-child(3)` |
---
*文档结束*
\ No newline at end of file
# HANDOFF — 设备模拟模块 # HANDOFF — 设备模拟模块
> **生成时间**: 2026-08-05 > **生成时间**: 2026-08-06
> **当前分支**: `platform-auto-test` > **当前分支**: `platform-auto-test`
> **最近提交**: `ae32844b` feat(device-sim): Excel批量导入设备功能 > **最近提交**: `b3b54f03` feat(smart-locate, device-sim): 验证类步骤自动识别 + 设备列表增强
--- ---
## 📊 会话进度记录
### 2026-08-06 会话 B:中控设备多类型主题上报 — 代码实现
**会话目标**:按执行计划实现中控设备 7 种主题类型上报的 4 个阶段
**已完成内容**
#### ✅ 阶段一:后端主题模板 + 模拟器消息体
1. **`topic_templates.py`** — 中控主题模板从 2 种扩展为 7 种
- 新增 `template_subscribe` + `template_publish` 双模板结构(替代旧的 `template` + `direction`
- 每种模板带 `device_type` 标识,用于按类型筛选
- 新增 `resolve_topics_by_device_type()` 函数,根据 `topic_params.device_type` 筛选对应主题
- `resolve_all_topics()` 兼容新旧两种模板结构
2. **`central_simulator.py`** — 新增 7 种消息体构建方法
- `_build_room_online_payload()` — 会议室在线(action: online)
- `_build_device_online_payload()` — 设备在线(action: _updatestatus)
- `_build_audio_payload()` — 音频系统(action: _updateaudio)
- `_build_video_payload()` — 视频系统(action: _updatevideo)
- `_build_control_payload()` — 控制系统(action: _updatecontrol)
- `_build_network_payload()` — 网络系统(action: _updatenetwork)
- `_build_power_payload()` — 电源系统(action: _updatepower)
- `build_status_payload()` 根据 `topic_params.device_type` 动态选择方法
3. **`base_simulator.py`**`_resolve_topics()` 支持按 `device_type` 筛选中控主题
- 中控设备且 `topic_params``device_type` 时调用 `resolve_topics_by_device_type()`
- 其他设备类型保持原有逻辑
#### ✅ 阶段二:后端 Excel 导入 + API 适配
1. **`device_sim_service.py`** — Excel 导入支持主题类型
- 列映射新增 "主题类型" → `central_device_type`(兼容旧版 "设备类型")
- 中控设备自动存入 `topic_params.device_number`(= device_id)
- 中控设备自动存入 `topic_params.device_type`(默认 `device_online`
- 新增 `switch_device_type()` 方法:更新 topic_params + 运行中自动重启
2. **`device_sim.py`** — Excel 模板 + 切换 API
- 中控模板新增"会议室编号"和"主题类型"两列
- 新增 `PATCH /api/device-sim/devices/{device_id}/type` API
- API 参数 `device_type_name` 支持 7 种类型标识
3. **`device_sim.py` (schemas)**`TopicTemplateResponse` 新增 `device_type` 字段
#### ✅ 阶段三:前端设备类型显示 + 切换 UI
1. **`device.ts`** — 新增 `CENTRAL_DEVICE_TYPES` 映射 + `TopicTemplate.deviceType` 字段
2. **`deviceSim.ts`** — 新增 `switchDeviceType()` API 调用
3. **`DeviceList.vue`**
- 中控设备列表新增"主题类型"列(带 el-tag 标签)
- 操作列新增"切换类型"下拉框(7 种选项,当前类型禁用+勾选)
- 切换确认弹窗提示运行中设备将自动重启
- 导入弹窗中控提示更新(会议室编号 + 主题类型说明)
- **表格各列增加 tooltip 说明**
- 设备名称:设备的显示名称,可自定义
- 设备 ID:设备的唯一标识符,用于 MQTT 消息关联
- 主题类型:中控设备上报的 MQTT 主题类型,支持 7 种切换
- 状态:设备的运行状态:运行中/已停止/异常
- 上报次数:设备累计发送 MQTT 消息的次数
- 最后上报:设备最后一次发送 MQTT 消息的时间
#### ✅ 阶段四:联调测试 + 部署验证
- Python 语法检查通过
- 前端 `npm run build` 构建成功
- 已部署到 192.168.5.60(SFTP 上传 + docker restart)
#### ✅ 文案优化
- 用户可见的"设备类型"统一改为"主题类型"
- Excel 表头、前端 UI、API 错误提示均已更新
- 后端列映射兼容旧版"设备类型"列名
**中控 7 种主题类型**
| 类型 | action | 发布主题 | client_udid 来源 |
|------|--------|---------|------------------|
| 会议室在线 | `online` | `/maintain/room/online/{会议室编号}/` | -(udid 字段) |
| 设备在线 | `_updatestatus` | `/maintain/room/master/client/` | 会议室编号 |
| 音频系统 | `_updateaudio` | `/maintain/room/master/client/` | 设备编号 |
| 视频系统 | `_updatevideo` | `/maintain/room/master/client/` | 设备编号 |
| 控制系统 | `_updatecontrol` | `/maintain/room/master/client/` | 设备编号 |
| 网络系统 | `_updatenetwork` | `/maintain/room/master/client/` | 设备编号 |
| 电源系统 | `_updatepower` | `/maintain/room/master/client/` | 设备编号 |
**本次会话修改文件清单**
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/topic_templates.py` | 修改 | 中控模板扩展为 7 种 + 新增 resolve_topics_by_device_type() |
| `backend/app/simulators/central_simulator.py` | 修改 | 新增 7 种消息体构建方法 + 动态选择逻辑 |
| `backend/app/simulators/base_simulator.py` | 修改 | _resolve_topics() 支持按 device_type 筛选 |
| `backend/app/routers/device_sim.py` | 修改 | Excel 模板新增主题类型列 + PATCH 切换类型 API |
| `backend/app/services/device_sim_service.py` | 修改 | Excel 导入支持主题类型 + switch_device_type() |
| `backend/app/schemas/device_sim.py` | 修改 | TopicTemplateResponse 新增 device_type 字段 |
| `frontend/src/types/device.ts` | 修改 | 新增 CENTRAL_DEVICE_TYPES 映射 |
| `frontend/src/api/deviceSim.ts` | 修改 | 新增 switchDeviceType() API |
| `frontend/src/components/device/DeviceList.vue` | 修改 | 主题类型列 + 切换下拉框 + tooltip + 导入提示 |
---
### 2026-08-06 会话 A:中控设备多类型主题上报 — PRD + 计划
**会话目标**:输出 PRD 需求文档和执行计划文档
**已完成内容**
1. ✅ PRD 需求文档 — `Docs/PRD/需求文档/设备模拟/_PRD_需求优化_中控设备多类型主题上报.md`
2. ✅ 执行计划文档 — `Docs/PRD/需求文档/设备模拟/_执行计划_中控设备多类型主题上报.md`
3. ✅ 初步代码实现(已部署到 192.168.5.60 但需按新计划重构)
---
### 2026-08-05 会话:Excel 批量导入 + 设备列表增强
## 一、本次会话完成的工作 ## 一、本次会话完成的工作
### 1. ✅ Excel 批量导入设备功能 ### 1. ✅ Excel 批量导入设备功能
...@@ -101,44 +216,29 @@ ...@@ -101,44 +216,29 @@
--- ---
## 三、修改文件清单 ## 三、后续待办
### 本次会话新增/修改
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/requirements.txt` | 修改 | 添加 openpyxl==3.1.2 |
| `backend/app/schemas/device_sim.py` | 修改 | 新增 DeviceImportRowResult、DeviceImportResponse |
| `backend/app/services/device_sim_service.py` | 修改 | 新增 import_simulators_from_excel() 和辅助函数 |
| `backend/app/routers/device_sim.py` | 修改 | 新增导入和模板下载端点 |
| `frontend/src/types/device.ts` | 修改 | 新增导入相关类型定义 |
| `frontend/src/api/deviceSim.ts` | 修改 | 新增 importDevices、downloadImportTemplate API |
| `frontend/src/components/device/DeviceList.vue` | 修改 | 新增导入弹窗、全部启动/停止按钮、分页增强 |
| `frontend/src/views/device-sim/DoorSim.vue` | 修改 | 移除右侧消息流面板 |
| `frontend/src/views/device-sim/PaperlessSim.vue` | 修改 | 同上 |
| `frontend/src/views/device-sim/CentralSim.vue` | 修改 | 同上 |
| `frontend/src/views/device-sim/ClientSim.vue` | 修改 | 同上 |
---
## 四、后续待办
| 优先级 | 任务 | 说明 | | 优先级 | 任务 | 说明 |
|--------|------|------| |--------|------|------|
| P1 | 中控/集控客户端主题确认 | 确认真实上报/订阅主题 | | P1 | 中控 7 种主题类型联调测试 | 下载中控模板 → 填写 7 种类型 → 导入验证 → 运行中切换类型 → 消息体格式验证 |
| P1 | 中控/集控客户端主题确认 | 确认真实上报/订阅主题与实际系统一致 |
| P2 | 环境配置统计功能 | 统计各环境下的设备数量 | | P2 | 环境配置统计功能 | 统计各环境下的设备数量 |
| P2 | 旧版中控设备兼容性测试 | 已创建的中控设备(无 device_type)默认为"设备在线",需验证 |
| P2 | 执行计划文档状态更新 | 更新 `_执行计划_中控设备多类型主题上报.md` 中 4 个阶段的执行记录 |
--- ---
## 、相关文档索引 ## 、相关文档索引
| 文档 | 路径 | | 文档 | 路径 |
|------|------| |------|------|
| 设备模拟 PRD | `Docs/PRD/需求文档/设备模拟/_PRD_需求文档_设备模拟模块.md` | | 设备模拟 PRD | `Docs/PRD/需求文档/设备模拟/_PRD_需求文档_设备模拟模块.md` |
| 中控多类型 PRD | `Docs/PRD/需求文档/设备模拟/_PRD_需求优化_中控设备多类型主题上报.md` |
| 中控多类型执行计划 | `Docs/PRD/需求文档/设备模拟/_执行计划_中控设备多类型主题上报.md` |
--- ---
## 、部署信息 ## 、部署信息
| 项目 | 值 | | 项目 | 值 |
|------|-----| |------|-----|
...@@ -156,18 +256,41 @@ ...@@ -156,18 +256,41 @@
--- ---
## 七、Excel 导入模板格式 ## 六、Excel 导入模板格式
### 门口屏模板
| 列 | 表头 | 必填 | 说明 | | 列 | 表头 | 必填 | 说明 |
|----|------|------|------| |----|------|------|------|
| A | 环境配置名称 | 是 | 需与已创建的环境配置名称完全一致 | | A | 环境配置名称 | 是 | 需与已创建的环境配置名称完全一致 |
| B | 设备名称 | 是 | | | B | 设备名称 | 是 | |
| C | 设备ID | 是 | 同一环境下唯一 | | C | 设备编号 | 是 | 同一环境下唯一 |
| D | 授权码(app_token) | 否 | 仅门口屏需要,填入 topic_params | | D | 授权码(app_token) | 否 | 仅门口屏需要,填入 topic_params |
| E | 自动重连 | 否 | 默认 true,支持 是/否、true/false | | E | 自动重连 | 否 | 默认 true,支持 是/否、true/false |
| F | 启用定时上报 | 否 | 默认 true | | F | 启用定时上报 | 否 | 默认 true |
| G | 上报间隔(秒) | 否 | 默认 30,整数 >= 1 | | G | 上报间隔(秒) | 否 | 默认 30,整数 >= 1 |
### 中控模板
| 列 | 表头 | 必填 | 说明 |
|----|------|------|------|
| A | 环境配置名称 | 是 | 需与已创建的环境配置名称完全一致 |
| B | 设备名称 | 是 | |
| C | 设备编号 | 是 | 同一环境下唯一,同时作为消息体 client_udid |
| D | 会议室编号 | 是 | 用于 MQTT 主题订阅和消息上报 |
| E | 主题类型 | 是 | 会议室在线/设备在线/音频系统/视频系统/控制系统/网络系统/电源系统 |
| F | 自动重连 | 否 | 默认 true |
| G | 启用定时上报 | 否 | 默认 true |
| H | 上报间隔(秒) | 否 | 默认 30 |
---
## 七、API 新增端点
| 方法 | 路径 | 说明 |
|------|------|------|
| `PATCH` | `/api/device-sim/devices/{device_id}/type` | 切换中控设备主题类型,参数 `device_type_name` |
--- ---
*本文档记录设备模拟模块开发状态,供下次会话快速恢复上下文。* *本文档记录设备模拟模块开发状态,供下次会话快速恢复上下文。*
...@@ -69,12 +69,13 @@ async def get_topic_templates(device_type: str): ...@@ -69,12 +69,13 @@ async def get_topic_templates(device_type: str):
templates=[ templates=[
TopicTemplateResponse( TopicTemplateResponse(
key=t["key"], key=t["key"],
template=t["template"], template=t.get("template", t.get("template_publish", "")),
label=t["label"], label=t["label"],
params=t["params"], params=t["params"],
param_labels=t["param_labels"], param_labels=t["param_labels"],
param_defaults=t["param_defaults"], param_defaults=t["param_defaults"],
direction=t["direction"], direction=t.get("direction", "publish"),
device_type=t.get("device_type"),
) )
for t in templates for t in templates
], ],
...@@ -271,9 +272,12 @@ async def download_import_template( ...@@ -271,9 +272,12 @@ async def download_import_template(
ws.title = "设备导入" ws.title = "设备导入"
# 表头 # 表头
headers = ["环境配置名称", "设备名称", "设备ID"] headers = ["环境配置名称", "设备名称", "设备编号"]
if device_type == "door": if device_type == "door":
headers.append("授权码(app_token)") headers.append("授权码(app_token)")
elif device_type == "central":
headers.append("会议室编号")
headers.append("主题类型")
headers.extend(["自动重连", "启用定时上报", "上报间隔(秒)"]) headers.extend(["自动重连", "启用定时上报", "上报间隔(秒)"])
ws.append(headers) ws.append(headers)
...@@ -281,6 +285,9 @@ async def download_import_template( ...@@ -281,6 +285,9 @@ async def download_import_template(
example = ["测试环境", f"{device_type}_001", f"{device_type}_device_001"] example = ["测试环境", f"{device_type}_001", f"{device_type}_device_001"]
if device_type == "door": if device_type == "door":
example.append("AUTH-0001") example.append("AUTH-0001")
elif device_type == "central":
example.append("A101")
example.append("设备在线")
example.extend(["是", "是", "30"]) example.extend(["是", "是", "30"])
ws.append(example) ws.append(example)
...@@ -316,8 +323,8 @@ async def import_devices( ...@@ -316,8 +323,8 @@ async def import_devices(
""" """
批量导入模拟设备 批量导入模拟设备
上传 Excel 文件批量创建设备。Excel 列:环境配置名称、设备名称、设备ID 上传 Excel 文件批量创建设备。Excel 列:环境配置名称、设备名称、设备编号
授权码(可选)、自动重连(可选)、启用定时上报(可选)、上报间隔(可选)。 授权码(可选,门口屏)、会议室编号(可选,中控)、自动重连(可选)、启用定时上报(可选)、上报间隔(可选)。
device_type 由前端页面决定,不包含在 Excel 中。 device_type 由前端页面决定,不包含在 Excel 中。
""" """
# 校验文件类型 # 校验文件类型
...@@ -433,6 +440,28 @@ async def update_simulator( ...@@ -433,6 +440,28 @@ async def update_simulator(
return SimulatorResponse.model_validate(device) return SimulatorResponse.model_validate(device)
@router.patch("/devices/{device_id}/type")
async def switch_device_type(
device_id: str,
device_type_name: str = Query(..., description="设备类型标识(room_online/device_online/audio/video/control/network/power)"),
service: DeviceSimService = Depends(get_device_sim_service),
):
"""
切换中控设备上报类型
支持在运行时切换中控设备的上报主题类型。
如果设备正在运行,会先停止再重启以应用新配置。
"""
try:
result = await service.switch_device_type(device_id, device_type_name)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"切换主题类型失败: {e}")
raise HTTPException(status_code=500, detail=f"切换主题类型失败: {str(e)}")
@router.delete("/devices/{device_id}") @router.delete("/devices/{device_id}")
async def delete_simulator( async def delete_simulator(
device_id: str, device_id: str,
......
...@@ -255,6 +255,7 @@ class TopicTemplateResponse(BaseModel): ...@@ -255,6 +255,7 @@ class TopicTemplateResponse(BaseModel):
param_labels: Dict[str, str] = Field(default={}, description="参数名 -> 中文标签映射") param_labels: Dict[str, str] = Field(default={}, description="参数名 -> 中文标签映射")
param_defaults: Dict[str, str] = Field(default={}, description="参数名 -> 默认值映射") param_defaults: Dict[str, str] = Field(default={}, description="参数名 -> 默认值映射")
direction: str = Field(..., description="方向:publish(上报) / subscribe(订阅)") direction: str = Field(..., description="方向:publish(上报) / subscribe(订阅)")
device_type: Optional[str] = Field(default=None, description="中控设备类型标识")
model_config = ConfigDict( model_config = ConfigDict(
alias_generator=to_camel, alias_generator=to_camel,
......
...@@ -478,15 +478,31 @@ class DeviceSimService: ...@@ -478,15 +478,31 @@ class DeviceSimService:
COLUMN_MAP = { COLUMN_MAP = {
"环境配置名称": "env_config_name", "环境配置名称": "env_config_name",
"设备名称": "device_name", "设备名称": "device_name",
"设备ID": "device_id",
"设备编号": "device_id",
"设备id": "device_id", "设备id": "device_id",
"授权码": "app_token", "授权码": "app_token",
"授权码(app_token)": "app_token", "授权码(app_token)": "app_token",
"会议室编号": "room_id",
"主题类型": "central_device_type",
"设备类型": "central_device_type", # 兼容旧版
"自动重连": "auto_reconnect", "自动重连": "auto_reconnect",
"启用定时上报": "report_enabled", "启用定时上报": "report_enabled",
"上报间隔(秒)": "report_interval", "上报间隔(秒)": "report_interval",
"上报间隔": "report_interval", "上报间隔": "report_interval",
} }
# 中控设备类型中文 → 标识映射
CENTRAL_DEVICE_TYPES = {
"会议室在线": "room_online",
"设备在线": "device_online",
"音频系统": "audio",
"视频系统": "video",
"控制系统": "control",
"网络系统": "network",
"电源系统": "power",
}
# 解析 Excel # 解析 Excel
wb = load_workbook(BytesIO(file_bytes), read_only=True) wb = load_workbook(BytesIO(file_bytes), read_only=True)
ws = wb.active ws = wb.active
...@@ -576,11 +592,32 @@ class DeviceSimService: ...@@ -576,11 +592,32 @@ class DeviceSimService:
report_enabled = _parse_bool(row_data.get("report_enabled"), True) report_enabled = _parse_bool(row_data.get("report_enabled"), True)
report_interval = _parse_int(row_data.get("report_interval"), 30) report_interval = _parse_int(row_data.get("report_interval"), 30)
# 构建 topic_params(授权码) # 构建 topic_params
topic_params = {} topic_params = {}
# 门口屏:授权码
app_token = row_data.get("app_token") app_token = row_data.get("app_token")
if app_token: if app_token:
topic_params["app_token"] = str(app_token).strip() topic_params["app_token"] = str(app_token).strip()
# 中控:会议室编号(用于主题和消息体)
room_id = row_data.get("room_id")
if room_id:
topic_params["room_id"] = str(room_id).strip()
# 中控:设备编号(用于消息体 client_udid)
# 设备编号 = device_id,存入 topic_params.device_number
if device_type == "central":
topic_params["device_number"] = str(row_data["device_id"]).strip()
# 中控:主题类型
central_device_type = row_data.get("central_device_type")
if central_device_type:
device_type_key = CENTRAL_DEVICE_TYPES.get(str(central_device_type).strip())
if device_type_key:
topic_params["device_type"] = device_type_key
else:
# 无效的主题类型,使用默认值
topic_params["device_type"] = "device_online"
elif device_type == "central":
# 中控设备但没有指定类型,默认为设备在线
topic_params["device_type"] = "device_online"
# 构建创建数据 # 构建创建数据
try: try:
...@@ -886,6 +923,58 @@ class DeviceSimService: ...@@ -886,6 +923,58 @@ class DeviceSimService:
return sim.report(topic, payload) return sim.report(topic, payload)
async def switch_device_type(self, device_id: str, device_type_name: str) -> dict:
"""
切换中控设备上报类型
Args:
device_id: 设备 ID
device_type_name: 设备类型标识(room_online/device_online/audio/video/control/network/power)
Returns:
dict: 切换结果
"""
# 校验主题类型
valid_types = ["room_online", "device_online", "audio", "video", "control", "network", "power"]
if device_type_name not in valid_types:
raise ValueError(f"无效的主题类型: {device_type_name},支持的类型: {', '.join(valid_types)}")
simulator = await self.get_simulator(device_id)
if not simulator:
raise ValueError(f"模拟设备不存在: {device_id}")
if simulator.device_type != "central":
raise ValueError("仅中控设备支持切换类型")
# 检查是否正在运行
was_running = False
with _running_simulators_lock:
sim = _running_simulators.get(device_id)
if sim and sim.is_running():
was_running = True
# 如果正在运行,先停止
if was_running:
await self.stop_simulator(device_id)
# 更新 topic_params
topic_params = dict(simulator.topic_params or {})
topic_params["device_type"] = device_type_name
simulator.topic_params = topic_params
simulator.updated_at = datetime.now()
await self.db.flush()
# 如果之前在运行,重新启动
if was_running:
await self.start_simulator(device_id)
return {
"device_id": device_id,
"device_type": device_type_name,
"was_running": was_running,
"message": f"设备类型已切换为 {device_type_name}" + (",设备已重启" if was_running else "")
}
# ==================== 批量操作 ==================== # ==================== 批量操作 ====================
async def batch_start_simulators(self, device_ids: List[str]) -> dict: async def batch_start_simulators(self, device_ids: List[str]) -> dict:
......
...@@ -95,10 +95,13 @@ class BaseSimulator(ABC): ...@@ -95,10 +95,13 @@ class BaseSimulator(ABC):
如果设备类型有真实主题模板(门口屏/无纸化),则使用真实主题; 如果设备类型有真实主题模板(门口屏/无纸化),则使用真实主题;
否则标记为无真实主题,使用旧格式 fallback。 否则标记为无真实主题,使用旧格式 fallback。
对于中控设备,根据 topic_params.device_type 选择对应的主题模板。
""" """
from app.simulators.topic_templates import ( from app.simulators.topic_templates import (
has_real_topics, has_real_topics,
resolve_all_topics, resolve_all_topics,
resolve_topics_by_device_type,
) )
self._has_real_topics = has_real_topics(self.device_type) self._has_real_topics = has_real_topics(self.device_type)
...@@ -108,6 +111,12 @@ class BaseSimulator(ABC): ...@@ -108,6 +111,12 @@ class BaseSimulator(ABC):
if 'device_id' not in params_with_device_id: if 'device_id' not in params_with_device_id:
params_with_device_id['device_id'] = self.device_id params_with_device_id['device_id'] = self.device_id
# 中控设备:根据 device_type 筛选对应主题
if self.device_type == "central" and "device_type" in self.topic_params:
self._resolved_topics = resolve_topics_by_device_type(
self.device_type, params_with_device_id
)
else:
self._resolved_topics = resolve_all_topics(self.device_type, params_with_device_id) self._resolved_topics = resolve_all_topics(self.device_type, params_with_device_id)
logger.info(f"设备 {self.device_id} 解析主题: {self._resolved_topics}") logger.info(f"设备 {self.device_id} 解析主题: {self._resolved_topics}")
......
...@@ -2,17 +2,18 @@ ...@@ -2,17 +2,18 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
模块名称:central_simulator.py 模块名称:central_simulator.py
模块描述:中控设备模拟器,模拟灯光/窗帘/投影等设备控制 模块描述:中控设备模拟器,支持 7 种主题类型上报
作者:czj 作者:czj
创建日期:2026-07-29 创建日期:2026-07-29
最后修改:2026-07-29 最后修改:2026-08-06
""" """
import json import json
import logging import logging
import random import random
import time import time
import uuid
from typing import Optional from typing import Optional
from app.simulators.base_simulator import BaseSimulator from app.simulators.base_simulator import BaseSimulator
...@@ -21,6 +22,18 @@ from app.services.mqtt_manager import MqttManager ...@@ -21,6 +22,18 @@ from app.services.mqtt_manager import MqttManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 中控设备类型映射
CENTRAL_DEVICE_TYPES = {
"room_online": "会议室在线",
"device_online": "设备在线",
"audio": "音频系统",
"video": "视频系统",
"control": "控制系统",
"network": "网络系统",
"power": "电源系统",
}
class CentralSimulator(BaseSimulator): class CentralSimulator(BaseSimulator):
""" """
中控设备模拟器 中控设备模拟器
...@@ -127,25 +140,198 @@ class CentralSimulator(BaseSimulator): ...@@ -127,25 +140,198 @@ class CentralSimulator(BaseSimulator):
} }
def build_status_payload(self) -> dict: def build_status_payload(self) -> dict:
"""构建设备状态上报消息""" """
sub_status = {} 根据设备类型动态选择消息体构建方法
for dev_id, config in self._sub_devices.items():
sub_status[dev_id] = { 支持的设备类型:
"status": config.get("status", "off"), - room_online: 会议室在线
"brightness": config.get("brightness"), - device_online: 设备在线
"position": config.get("position"), - audio: 音频系统
"temperature": config.get("temperature"), - video: 视频系统
"volume": config.get("volume"), - control: 控制系统
- network: 网络系统
- power: 电源系统
默认为 device_online(设备在线)。
"""
device_type = self.topic_params.get("device_type", "device_online")
builder_method = getattr(self, f"_build_{device_type}_payload", self._build_device_online_payload)
return builder_method()
def _build_room_online_payload(self) -> dict:
"""
构建会议室在线消息体
消息格式:
{
"udid": "uuid",
"action": "online",
"value": 1
}
"""
return {
"udid": str(uuid.uuid4()),
"action": "online",
"value": 1
} }
def _build_device_online_payload(self) -> dict:
"""
构建设备在线消息体
消息格式:
{
"action": "_updatestatus",
"client_udid": "{会议室编号}",
"data": [
{"device_udid": "{设备编号}", "power": 1, "online": 1, "watt": 10000, "run": "在线"},
{"device_udid": "{设备编号}", "power": 1, "online": 1, "watt": 1000, "run": "在线"}
]
}
"""
room_id = self.topic_params.get("room_id", self.device_id)
device_number = self.topic_params.get("device_number", self.device_id)
return { return {
"device_id": self.device_id, "action": "_updatestatus",
"status": "online", "client_udid": room_id,
"current_scene": self._current_scene, "data": [
"scene_name": self._SCENES.get(self._current_scene, {}).get("name", ""), {"device_udid": device_number, "power": 1, "online": 1, "watt": random.randint(5000, 15000), "run": "在线"},
"sub_device_count": len(self._sub_devices), {"device_udid": device_number, "power": 1, "online": 1, "watt": random.randint(500, 1500), "run": "在线"}
"sub_devices": sub_status, ]
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S") }
def _build_audio_payload(self) -> dict:
"""
构建音频系统消息体
消息格式:
{
"action": "_updateaudio",
"client_udid": "{设备编号}",
"data": [...]
}
"""
device_number = self.topic_params.get("device_number", self.device_id)
return {
"action": "_updateaudio",
"client_udid": device_number,
"data": [
{
"address": 1,
"data_all": {"volume": random.randint(20, 50), "mute": 0},
"data_channel": [
{"lineType": 1, "num": 1, "status": 1, "volume": random.randint(40, 80), "mute": 0},
{"lineType": 1, "num": 2, "status": 1, "volume": random.randint(40, 80), "mute": 0}
],
"data_meter": [{"lineType": 1, "num": 1, "dbvalue": random.randint(50, 90)}]
}
]
}
def _build_video_payload(self) -> dict:
"""
构建视频系统消息体
消息格式:
{
"action": "_updatevideo",
"client_udid": "{设备编号}",
"data": [...]
}
"""
device_number = self.topic_params.get("device_number", self.device_id)
return {
"action": "_updatevideo",
"client_udid": device_number,
"data": [
{
"address": 1,
"data_channel": [
{"lineType": 1, "num": 1, "status": random.randint(0, 1)},
{"lineType": 1, "num": 2, "status": random.randint(0, 1)}
]
}
]
}
def _build_control_payload(self) -> dict:
"""
构建控制系统消息体
消息格式:
{
"action": "_updatecontrol",
"client_udid": "{设备编号}",
"data": [...]
}
"""
device_number = self.topic_params.get("device_number", self.device_id)
return {
"action": "_updatecontrol",
"client_udid": device_number,
"data": [
{
"address": 1,
"data_com": [{"lineType": 0, "num": 1, "status": 1, "run": "run success"}],
"data_ir": [{"lineType": 1, "num": 1, "status": 1, "run": "run success"}],
"data_io": [{"lineType": 2, "num": 1, "status": 1, "run": "run success"}],
"data_rel": [{"lineType": 3, "num": 1, "status": 1, "run": "run success"}],
"data_bus": [{"lineType": 4, "num": 1, "status": 1, "run": "run success"}],
"data_net": [{"lineType": 5, "num": 1, "status": 1, "run": "run success"}]
}
]
}
def _build_network_payload(self) -> dict:
"""
构建网络系统消息体
消息格式:
{
"action": "_updatenetwork",
"client_udid": "{设备编号}",
"data": [...]
}
"""
device_number = self.topic_params.get("device_number", self.device_id)
return {
"action": "_updatenetwork",
"client_udid": device_number,
"data": [
{
"address": 1,
"data_channel": [
{"innum": 1, "status": 1, "run": "Good network"},
{"innum": 2, "status": 1, "run": "Good network"}
]
}
]
}
def _build_power_payload(self) -> dict:
"""
构建电源系统消息体
消息格式:
{
"action": "_updatepower",
"client_udid": "{设备编号}",
"data": [...]
}
"""
device_number = self.topic_params.get("device_number", self.device_id)
return {
"action": "_updatepower",
"client_udid": device_number,
"data": [
{
"address": 1,
"data_channel": [
{"innum": 1, "status": 1, "power": 1, "level": round(random.uniform(1.0, 2.0), 1)},
{"innum": 2, "status": 1, "power": 1, "level": round(random.uniform(1.0, 2.0), 1)}
]
}
]
} }
def _on_command(self, topic: str, payload: dict) -> None: def _on_command(self, topic: str, payload: dict) -> None:
......
...@@ -176,7 +176,85 @@ DEVICE_TOPIC_TEMPLATES: Dict[str, List[dict]] = { ...@@ -176,7 +176,85 @@ DEVICE_TOPIC_TEMPLATES: Dict[str, List[dict]] = {
"direction": "subscribe", "direction": "subscribe",
}, },
], ],
"central": [], # 暂无真实主题,保留旧格式 "central": [
# ========== 会议室在线(发布主题不同)==========
{
"key": "room_online",
"template_subscribe": "/maintain/room/master/{room_id}/",
"template_publish": "/maintain/room/online/{room_id}/",
"label": "会议室在线",
"device_type": "room_online",
"params": ["room_id"],
"param_labels": {"room_id": "会议室编号"},
"param_defaults": {"room_id": "A101"},
},
# ========== 设备在线 ==========
{
"key": "device_online",
"template_subscribe": "/maintain/room/master/{room_id}/",
"template_publish": "/maintain/room/master/client/",
"label": "设备在线",
"device_type": "device_online",
"params": ["room_id"],
"param_labels": {"room_id": "会议室编号"},
"param_defaults": {"room_id": "A101"},
},
# ========== 音频系统 ==========
{
"key": "audio",
"template_subscribe": "/maintain/room/master/{room_id}/",
"template_publish": "/maintain/room/master/client/",
"label": "音频系统",
"device_type": "audio",
"params": ["room_id"],
"param_labels": {"room_id": "会议室编号"},
"param_defaults": {"room_id": "A101"},
},
# ========== 视频系统 ==========
{
"key": "video",
"template_subscribe": "/maintain/room/master/{room_id}/",
"template_publish": "/maintain/room/master/client/",
"label": "视频系统",
"device_type": "video",
"params": ["room_id"],
"param_labels": {"room_id": "会议室编号"},
"param_defaults": {"room_id": "A101"},
},
# ========== 控制系统 ==========
{
"key": "control",
"template_subscribe": "/maintain/room/master/{room_id}/",
"template_publish": "/maintain/room/master/client/",
"label": "控制系统",
"device_type": "control",
"params": ["room_id"],
"param_labels": {"room_id": "会议室编号"},
"param_defaults": {"room_id": "A101"},
},
# ========== 网络系统 ==========
{
"key": "network",
"template_subscribe": "/maintain/room/master/{room_id}/",
"template_publish": "/maintain/room/master/client/",
"label": "网络系统",
"device_type": "network",
"params": ["room_id"],
"param_labels": {"room_id": "会议室编号"},
"param_defaults": {"room_id": "A101"},
},
# ========== 电源系统 ==========
{
"key": "power",
"template_subscribe": "/maintain/room/master/{room_id}/",
"template_publish": "/maintain/room/master/client/",
"label": "电源系统",
"device_type": "power",
"params": ["room_id"],
"param_labels": {"room_id": "会议室编号"},
"param_defaults": {"room_id": "A101"},
},
],
"client": [], # 暂无真实主题,保留旧格式 "client": [], # 暂无真实主题,保留旧格式
} }
...@@ -242,12 +320,78 @@ def resolve_all_topics(device_type: str, topic_params: dict) -> Dict[str, dict]: ...@@ -242,12 +320,78 @@ def resolve_all_topics(device_type: str, topic_params: dict) -> Dict[str, dict]:
templates = get_topic_templates(device_type) templates = get_topic_templates(device_type)
resolved = {} resolved = {}
for tmpl in templates: for tmpl in templates:
# 兼容新旧两种模板结构
if "template" in tmpl:
# 旧格式:单个 template + direction
resolved[tmpl["key"]] = { resolved[tmpl["key"]] = {
"topic": resolve_topic(tmpl["template"], topic_params or {}), "topic": resolve_topic(tmpl["template"], topic_params or {}),
"direction": tmpl["direction"], "direction": tmpl["direction"],
"label": tmpl["label"], "label": tmpl["label"],
"template": tmpl["template"], "template": tmpl["template"],
} }
else:
# 新格式:template_subscribe + template_publish(中控设备)
# 订阅主题
if "template_subscribe" in tmpl:
resolved[tmpl["key"] + "_sub"] = {
"topic": resolve_topic(tmpl["template_subscribe"], topic_params or {}),
"direction": "subscribe",
"label": tmpl["label"],
"template": tmpl["template_subscribe"],
"device_type": tmpl.get("device_type"),
}
# 发布主题
if "template_publish" in tmpl:
resolved[tmpl["key"]] = {
"topic": resolve_topic(tmpl["template_publish"], topic_params or {}),
"direction": "publish",
"label": tmpl["label"],
"template": tmpl["template_publish"],
"device_type": tmpl.get("device_type"),
}
return resolved
def resolve_topics_by_device_type(device_type: str, topic_params: dict) -> Dict[str, dict]:
"""
根据中控设备的 device_type 筛选对应主题模板
用于中控设备,根据 topic_params.device_type 选择对应的主题模板。
Args:
device_type: 设备类型(应为 "central")
topic_params: 参数值映射,需包含 device_type 字段
Returns:
Dict[str, dict]: 解析后的主题映射
"""
templates = get_topic_templates(device_type)
central_device_type = topic_params.get("device_type", "device_online")
resolved = {}
for tmpl in templates:
if tmpl.get("device_type") == central_device_type:
# 找到匹配的模板
# 订阅主题
if "template_subscribe" in tmpl:
resolved[tmpl["key"] + "_sub"] = {
"topic": resolve_topic(tmpl["template_subscribe"], topic_params or {}),
"direction": "subscribe",
"label": tmpl["label"],
"template": tmpl["template_subscribe"],
"device_type": tmpl.get("device_type"),
}
# 发布主题
if "template_publish" in tmpl:
resolved[tmpl["key"]] = {
"topic": resolve_topic(tmpl["template_publish"], topic_params or {}),
"direction": "publish",
"label": tmpl["label"],
"template": tmpl["template_publish"],
"device_type": tmpl.get("device_type"),
}
break # 只取匹配的一种
return resolved return resolved
......
...@@ -106,6 +106,18 @@ export function stopSimulator(id: string): Promise<{ message: string; id: string ...@@ -106,6 +106,18 @@ export function stopSimulator(id: string): Promise<{ message: string; id: string
return request.post(`${BASE}/devices/${id}/stop`) return request.post(`${BASE}/devices/${id}/stop`)
} }
/** 切换中控设备类型 */
export function switchDeviceType(id: string, deviceTypeName: string): Promise<{
device_id: string
device_type: string
was_running: boolean
message: string
}> {
return request.patch(`${BASE}/devices/${id}/type`, null, {
params: { device_type_name: deviceTypeName }
})
}
/** 手动触发上报 */ /** 手动触发上报 */
export function manualReport(id: string, data: ManualReportRequest): Promise<{ message: string; success: boolean }> { export function manualReport(id: string, data: ManualReportRequest): Promise<{ message: string; success: boolean }> {
return request.post(`${BASE}/devices/${id}/report`, data) return request.post(`${BASE}/devices/${id}/report`, data)
......
...@@ -55,23 +55,83 @@ ...@@ -55,23 +55,83 @@
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
> >
<el-table-column type="selection" width="50" /> <el-table-column type="selection" width="50" />
<el-table-column prop="deviceName" label="设备名称" min-width="140" /> <el-table-column prop="deviceName" min-width="140">
<el-table-column prop="deviceId" label="设备 ID" min-width="160" /> <template #header>
<el-table-column label="状态" width="100"> <el-tooltip content="设备的显示名称,可自定义" placement="top">
<span>设备名称</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="deviceId" min-width="160">
<template #header>
<el-tooltip content="设备的唯一标识符,用于 MQTT 消息关联" placement="top">
<span>设备 ID</span>
</el-tooltip>
</template>
</el-table-column>
<!-- 中控主题类型显示 -->
<el-table-column v-if="deviceType === 'central'" min-width="110">
<template #header>
<el-tooltip content="中控设备上报的 MQTT 主题类型,支持 7 种切换" placement="top">
<span>主题类型</span>
</el-tooltip>
</template>
<template #default="{ row }">
<el-tag type="primary" size="small">
{{ CENTRAL_DEVICE_TYPES[row.topicParams?.device_type] || '设备在线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column width="100">
<template #header>
<el-tooltip content="设备的运行状态:运行中/已停止/异常" placement="top">
<span>状态</span>
</el-tooltip>
</template>
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.status === 'running' ? 'success' : row.status === 'error' ? 'danger' : 'info'" size="small"> <el-tag :type="row.status === 'running' ? 'success' : row.status === 'error' ? 'danger' : 'info'" size="small">
{{ row.status === 'running' ? '运行中' : row.status === 'error' ? '异常' : '已停止' }} {{ row.status === 'running' ? '运行中' : row.status === 'error' ? '异常' : '已停止' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="totalReports" label="上报次数" width="90" align="center" /> <el-table-column prop="totalReports" width="100" align="center">
<el-table-column label="最后上报" width="170"> <template #header>
<el-tooltip content="设备累计发送 MQTT 消息的次数" placement="top">
<span>上报次数</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column width="170">
<template #header>
<el-tooltip content="设备最后一次发送 MQTT 消息的时间" placement="top">
<span>最后上报</span>
</el-tooltip>
</template>
<template #default="{ row }"> <template #default="{ row }">
{{ row.lastReportedAt ? formatTime(row.lastReportedAt) : '-' }} {{ row.lastReportedAt ? formatTime(row.lastReportedAt) : '-' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="300" fixed="right"> <el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<!-- 中控设备:切换类型下拉框 -->
<el-dropdown v-if="deviceType === 'central'" trigger="click" @command="(cmd: string) => handleSwitchType(row, cmd)" style="margin-right: 8px">
<el-button link type="primary" size="small">
切换类型 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(label, key) in CENTRAL_DEVICE_TYPES"
:key="key"
:command="key"
:disabled="row.topicParams?.device_type === key"
>
{{ label }}
<el-icon v-if="row.topicParams?.device_type === key" style="margin-left: 4px"><Check /></el-icon>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button <el-button
:type="row.status === 'running' ? 'warning' : 'success'" :type="row.status === 'running' ? 'warning' : 'success'"
size="small" size="small"
...@@ -258,6 +318,15 @@ ...@@ -258,6 +318,15 @@
下载模板文件 下载模板文件
</el-link> </el-link>
</template> </template>
<template v-if="deviceType === 'central'" #default>
<div style="margin-top: 8px; font-size: 12px; color: #909399;">
<div><b>会议室编号</b>:用于 MQTT 主题订阅和消息上报</div>
<div><b>主题类型</b>:中控设备上报主题类型,支持 7 种</div>
<div style="margin-top: 4px; padding-left: 12px;">
会议室在线 / 设备在线 / 音频系统 / 视频系统 / 控制系统 / 网络系统 / 电源系统
</div>
</div>
</template>
</el-alert> </el-alert>
<el-upload <el-upload
...@@ -323,8 +392,8 @@ ...@@ -323,8 +392,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Upload } from '@element-plus/icons-vue' import { Plus, Upload, ArrowDown, Check } from '@element-plus/icons-vue'
import { import {
listSimulators, listSimulators,
createSimulator, createSimulator,
...@@ -340,9 +409,10 @@ import { ...@@ -340,9 +409,10 @@ import {
batchDeleteDevices, batchDeleteDevices,
importDevices, importDevices,
downloadImportTemplate, downloadImportTemplate,
switchDeviceType,
} from '@/api/deviceSim' } from '@/api/deviceSim'
import type { Simulator, SimulatorCreate, EnvConfig, DeviceType, TopicTemplate, DeviceImportResponse } from '@/types/device' import type { Simulator, SimulatorCreate, EnvConfig, DeviceType, TopicTemplate, DeviceImportResponse } from '@/types/device'
import { DEVICE_TYPE_LABELS } from '@/types/device' import { DEVICE_TYPE_LABELS, CENTRAL_DEVICE_TYPES } from '@/types/device'
const props = defineProps<{ const props = defineProps<{
deviceType: DeviceType deviceType: DeviceType
...@@ -612,6 +682,37 @@ async function handleDelete(row: Simulator) { ...@@ -612,6 +682,37 @@ async function handleDelete(row: Simulator) {
} }
} }
/** 切换中控设备类型 */
async function handleSwitchType(row: Simulator, typeKey: string) {
const currentType = row.topicParams?.device_type || 'device_online'
if (currentType === typeKey) return
const typeLabel = CENTRAL_DEVICE_TYPES[typeKey]
const isRunning = row.status === 'running'
try {
await ElMessageBox.confirm(
`确定将设备「${row.deviceName}」切换为「${typeLabel}」?${isRunning ? '设备正在运行,切换后将自动重启。' : ''}`,
'切换主题类型',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return // 用户取消
}
operatingId.value = row.id
try {
const res = await switchDeviceType(row.id, typeKey)
ElMessage.success(res.message)
await loadDevices()
emit('device-changed')
} catch (e: any) {
ElMessage.error('切换类型失败: ' + (e.message || ''))
} finally {
operatingId.value = ''
}
}
/** 打开编辑弹窗 */ /** 打开编辑弹窗 */
function openEditDialog(row: Simulator) { function openEditDialog(row: Simulator) {
editForm.value = { editForm.value = {
......
...@@ -24,6 +24,17 @@ export const DEVICE_TYPE_ICONS: Record<DeviceType, string> = { ...@@ -24,6 +24,17 @@ export const DEVICE_TYPE_ICONS: Record<DeviceType, string> = {
client: 'Computer', client: 'Computer',
} }
/** 中控设备类型映射 */
export const CENTRAL_DEVICE_TYPES: Record<string, string> = {
room_online: '会议室在线',
device_online: '设备在线',
audio: '音频系统',
video: '视频系统',
control: '控制系统',
network: '网络系统',
power: '电源系统',
}
/** 环境配置 */ /** 环境配置 */
export interface EnvConfig { export interface EnvConfig {
id: string id: string
...@@ -204,6 +215,7 @@ export interface TopicTemplate { ...@@ -204,6 +215,7 @@ export interface TopicTemplate {
paramLabels: Record<string, string> paramLabels: Record<string, string>
paramDefaults: Record<string, string> paramDefaults: Record<string, string>
direction: 'publish' | 'subscribe' direction: 'publish' | 'subscribe'
deviceType?: string
} }
/** 主题模板列表响应 */ /** 主题模板列表响应 */
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论