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

fix(device-sim): 修复设备模拟模块多个问题

1. 数据库表未创建
   - 在 models/__init__.py 中导入 EnvConfig, DeviceSimulator, ReportLog

2. 嵌套路由无法渲染
   - 创建 device-sim/index.vue 和 system/index.vue 父级容器
   - 更新路由配置,为父路由添加 component 属性

3. 列表不显示数据
   - 修复 request.ts 响应拦截器,返回 response.data

4. MQTT连接测试失败
   - 修复 test_env_connection 状态更新逻辑
   - 测试成功后更新数据库状态

5. Schema 字段名冲突
   - 将 metadata 改为 extra_attrs,避免与 SQLAlchemy MetaData 冲突

6. 前端优化
   - 环境配置页面简化,去掉主题配置
   - MQTT账号密码设置默认值

7. 文档更新
   - 新增 HANDOFF_设备模拟.md 交接文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 5848a57b
......@@ -157,7 +157,7 @@ POST /api/ai/locate
| 项目 | 值 |
|------|-----|
| 服务器 IP | 192.168.5.60 |
| SSH 用户 | ubains |
| SSH 用户/密码 | ubains / Ubains@123 |
| 部署目录 | `/data/third_party/plat-auto-test/` |
| 前端地址 | http://192.168.5.60 |
| API 文档 | http://192.168.5.60/docs |
......
# HANDOFF — 设备模拟模块开发进度
> **生成时间**: 2026-08-03
> **当前分支**: `platform-auto-test`
> **状态**: 🟡 基础功能完成,主题配置待改造
---
## 一、本次会话完成的工作
### 1. ✅ 数据库表未创建问题修复
**问题**`backend/app/models/__init__.py` 中没有导入设备模拟模型,导致 SQLAlchemy 的 `Base.metadata` 没有注册这些表
**修复**
```python
# backend/app/models/__init__.py
from app.models.device_sim import EnvConfig, DeviceSimulator, ReportLog
```
**结果**:服务器重启后自动创建 3 张表
- `device_env_configs`(环境配置)
- `device_simulators`(模拟设备)
- `device_report_logs`(上报记录)
---
### 2. ✅ 嵌套路由无法渲染问题修复
**问题**`/device-sim``/system` 父路由缺少组件,没有 `<router-view />` 渲染子路由
**修复**
- 创建 `frontend/src/views/device-sim/index.vue`(父级容器)
- 创建 `frontend/src/views/system/index.vue`(父级容器)
- 更新 `frontend/src/router/index.ts`,为父路由添加 `component` 属性
---
### 3. ✅ 列表不显示数据问题修复
**问题**`frontend/src/utils/request.ts` 响应拦截器返回 `response` 而不是 `response.data`
**修复**
```typescript
// 之前
return response
// 修复后
return response.data
```
---
### 4. ✅ MQTT 连接测试失败修复
**问题**:容器中未安装 `paho-mqtt`
**修复**
```bash
docker exec plat-auto-test-app pip install paho-mqtt==2.1.0
```
**注意**:需要将 `paho-mqtt==2.1.0` 添加到 `requirements.txt` 并重建镜像才能持久化
---
### 5. ✅ 测试连接后状态不更新问题修复
**问题**
1. `test_env_connection` 调用 `get_env_config`,后者用内存状态覆盖了数据库状态
2. `list_env_configs` 每次都用内存状态覆盖数据库状态
**修复**
- `test_env_connection` 直接查询数据库,不走 `get_env_config`
- 测试成功后更新数据库状态为 `connected`,并记录 `last_connected_at`
- `list_env_configs` 只在内存有活跃连接时覆盖,否则保留数据库状态
---
### 6. ✅ 前端环境配置页面优化
**优化内容**
- MQTT 账号密码设置默认值:`mqtt@cmdb` / `mqtt@webpassw0RD`
- 去掉"主题配置"列和折叠面板,简化界面
- 列表显示:配置名称、Broker 地址、端口、状态、最后连接、操作
---
### 7. ✅ Schema 字段名冲突修复
**问题**`SimulatorResponse``metadata` 字段与 SQLAlchemy 的 `MetaData` 对象冲突
**修复**
- Schema 字段名改为 `extra_attrs`,通过 `alias_generator=to_camel` 映射为前端 `extraAttrs`
- 前端 TypeScript 类型同步更新
---
## 二、服务器部署状态
| 项目 | 状态 |
|------|------|
| 数据库表 | ✅ 3 张表已创建 |
| 环境配置数据 | ✅ 1 条配置(192.168.5.44,状态:已连接) |
| paho-mqtt | ✅ 已安装(需重建镜像持久化) |
| 前端代码 | ✅ 已部署(41 个文件) |
| 后端代码 | ✅ 已部署 |
**服务器信息**
- IP: 192.168.5.60
- SSH: ubains / Ubains@123
- 部署目录: `/data/third_party/plat-auto-test/`
- 前端: http://192.168.5.60
- API: http://192.168.5.60/docs
---
## 三、待提交的代码修改
### 后端文件
| 文件 | 修改说明 |
|------|----------|
| `backend/app/models/__init__.py` | 导入设备模拟模型 |
| `backend/app/schemas/device_sim.py` | 1. TestConnectionResponse 添加 latencyMs 别名<br>2. SimulatorBase/SimulatorUpdate 字段名 metadata → extra_attrs |
| `backend/app/services/device_sim_service.py` | 1. test_env_connection 直接查数据库<br>2. 测试成功后更新状态<br>3. list_env_configs 保留数据库状态 |
### 前端文件
| 文件 | 修改说明 |
|------|----------|
| `frontend/src/utils/request.ts` | 响应拦截器返回 response.data |
| `frontend/src/router/index.ts` | 为 /device-sim 和 /system 添加父路由组件 |
| `frontend/src/views/device-sim/index.vue` | 新建父级容器 |
| `frontend/src/views/system/index.vue` | 新建父级容器 |
| `frontend/src/views/device-sim/EnvConfig.vue` | 简化界面,去掉主题配置,添加默认账号密码 |
| `frontend/src/types/device.ts` | metadata → extraAttrs |
---
## 四、待处理任务
### P0 - 必须处理
| 任务 | 说明 |
|------|------|
| **paho-mqtt 持久化** | 当前是手动安装,需在 `requirements.txt` 中添加并重建镜像 |
| **Git 提交** | 上述修改都未提交,建议尽快提交 |
### P1 - 主题配置改造
**背景**:当前模拟器使用 `{prefix}/{device_type}/{device_id}/{action}` 格式拼接主题,但真实系统使用固定主题路径,部分包含动态参数。
**真实主题列表**
**门口屏(7-8 个)**
```
/meeting/conference/{会议室编号}/
/material/client/{设备ID}/
/iot/v1/conference/service/request/{公司编号}/{会议室编号}/
/doorscreen/v5/{设备ID}/face/extractFeature/imageBase64/list/up
/iot/v1/device/event/request/millimeterhaveRadar/{会议室编号}/
/meeting/message/{会议室编号}/
rebootResponseTopic
/iot/v1/device/service/request/{公司编号}/{设备ID}/
```
**无纸化(6 个)**
```
/meeting/message/{会议室编号}/
/iot/v1/meeting/service/request/meetingControl/{公司编号}/{mid}/
/androidPanel/
/iot/v1/meeting/service/request/{会议室编号}/
/message/paperLessService/callService
```
**动态参数**
- `{会议室编号}` - 用户填写
- `{设备ID}` - 用户填写
- `{公司编号}` - 用户填写
- `{mid}` - 会议ID,用户填写
**改造方案**(待确认):
1. 数据模型:在 `device_simulators` 表中增加 `topic_params` JSON 字段存储参数
2. 模拟器:读取设备类型对应的固定主题模板,替换参数后使用
3. 前端:创建/编辑设备时展示该设备类型的主题列表,填写动态参数
---
## 五、环境配置说明
### MQTT 默认账号密码
创建环境配置时,账号密码已设置默认值:
- 用户名: `mqtt@cmdb`
- 密码: `mqtt@webpassw0RD`
### 当前环境配置
| 配置名称 | Broker 地址 | 端口 | 状态 |
|----------|------------|------|------|
| 192.168.5.44 | 192.168.5.44 | 1883 | 已连接 |
---
## 六、快速运维命令
### 更新后端代码
```bash
# 单个文件
scp backend/app/services/device_sim_service.py ubains@192.168.5.60:/data/third_party/plat-auto-test/backend/app/services/
ssh ubains@192.168.5.60 'cd /data/third_party/plat-auto-test/deploy && docker compose restart app'
# 全部后端
scp -r backend/app ubains@192.168.5.60:/data/third_party/plat-auto-test/backend/
ssh ubains@192.168.5.60 'cd /data/third_party/plat-auto-test/deploy && docker compose restart app'
```
### 更新前端代码
```bash
cd frontend && npm run build
scp -r dist/* ubains@192.168.5.60:/data/third_party/plat-auto-test/frontend/dist/
```
### 查看日志
```bash
ssh ubains@192.168.5.60 'cd /data/third_party/plat-auto-test/deploy && docker compose logs -f app --tail=100'
```
---
## 七、相关文档索引
| 文档 | 路径 |
|------|------|
| 设备模拟 PRD | `Docs/PRD/需求文档/设备模拟/_PRD_需求文档_设备模拟模块.md` |
| 设备模拟执行计划 | `Docs/PRD/需求文档/设备模拟/_执行计划_设备模拟模块.md` |
| 多主题配置优化 PRD | `Docs/PRD/需求文档/设备模拟/_PRD_需求优化_环境配置多主题与设备接收记录.md` |
| 项目总交接 | `HANDOFF.md` |
---
*本文档由 Claude Code 生成,记录设备模拟模块开发进度,供下次会话快速恢复上下文。*
\ No newline at end of file
......@@ -98,7 +98,7 @@ class SimulatorBase(BaseModel):
device_name: str = Field(..., min_length=1, max_length=200, description="模拟设备名称")
device_id: str = Field(..., min_length=1, max_length=200, description="模拟设备 ID")
auto_reconnect: bool = Field(default=True, description="是否自动重连")
metadata: Optional[dict] = Field(default=None, description="设备自定义属性")
extra_attrs: Optional[dict] = Field(default=None, description="设备自定义属性")
model_config = ConfigDict(
alias_generator=to_camel,
......@@ -116,7 +116,7 @@ class SimulatorUpdate(BaseModel):
device_name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="模拟设备名称")
auto_reconnect: Optional[bool] = Field(default=None, description="是否自动重连")
metadata: Optional[dict] = Field(default=None, description="设备自定义属性")
extra_attrs: Optional[dict] = Field(default=None, description="设备自定义属性")
model_config = ConfigDict(
alias_generator=to_camel,
......@@ -192,4 +192,8 @@ class TestConnectionResponse(BaseModel):
success: bool = Field(..., description="是否成功")
message: str = Field(default="", description="结果信息")
latency_ms: Optional[int] = Field(default=None, description="连接延迟(毫秒)")
\ No newline at end of file
latency_ms: Optional[int] = Field(default=None, alias="latencyMs", description="连接延迟(毫秒)")
model_config = ConfigDict(
populate_by_name=True,
)
\ No newline at end of file
......@@ -103,9 +103,11 @@ class DeviceSimService:
result = await self.db.execute(query)
items = list(result.scalars().all())
# 更新实时状态
# 更新实时状态:仅当内存中有活跃连接时覆盖数据库状态
for item in items:
item.status = mqtt_manager.get_connection_status(item.id)
mem_status = mqtt_manager.get_connection_status(item.id)
if mem_status == "connected":
item.status = "connected"
return items, total
......@@ -243,7 +245,10 @@ class DeviceSimService:
Returns:
TestConnectionResponse: 测试结果
"""
config = await self.get_env_config(config_id)
# 直接查询数据库,不用 get_env_config(避免内存状态覆盖)
query = select(EnvConfig).where(EnvConfig.id == config_id)
result = await self.db.execute(query)
config = result.scalar_one_or_none()
if not config:
return TestConnectionResponse(success=False, message="环境配置不存在")
......@@ -259,6 +264,12 @@ class DeviceSimService:
use_tls=config.use_tls,
)
# 更新数据库状态
config.status = "connected" if success else "error"
if success:
config.last_connected_at = datetime.now()
await self.db.commit()
return TestConnectionResponse(
success=success,
message=message,
......
......@@ -26,7 +26,7 @@ export const caseApi = {
limit?: number
}): Promise<CaseListResponse> {
const response = await request.get('/api/cases', { params })
return response.data
return response as any
},
/**
......@@ -34,7 +34,7 @@ export const caseApi = {
*/
async get(id: string): Promise<TestCase> {
const response = await request.get(`/api/cases/${id}`)
return response.data
return response as any
},
/**
......@@ -42,7 +42,7 @@ export const caseApi = {
*/
async create(data: CreateCaseRequest): Promise<TestCase> {
const response = await request.post('/api/cases', data)
return response.data
return response as any
},
/**
......@@ -50,7 +50,7 @@ export const caseApi = {
*/
async update(id: string, data: Partial<CreateCaseRequest>): Promise<TestCase> {
const response = await request.put(`/api/cases/${id}`, data)
return response.data
return response as any
},
/**
......@@ -65,7 +65,7 @@ export const caseApi = {
*/
async copy(id: string): Promise<TestCase> {
const response = await request.post(`/api/cases/${id}/copy`)
return response.data
return response as any
},
/**
......@@ -73,7 +73,7 @@ export const caseApi = {
*/
async import(data: any[]): Promise<{ success_count: number; failed_count: number; errors: string[] }> {
const response = await request.post('/api/cases/import', data)
return response.data
return response as any
},
/**
......@@ -81,6 +81,6 @@ export const caseApi = {
*/
async export(caseIds?: string[]): Promise<any[]> {
const response = await request.post('/api/cases/export', caseIds)
return response.data
return response as any
},
}
\ No newline at end of file
......@@ -3,6 +3,7 @@
*
* 提供本地元素定位功能,通过 Playwright 访问被测页面,
* 提取可交互元素,匹配步骤描述,返回候选定位器列表。
* 支持单个步骤定位和批量定位(一键获取用例所有步骤的定位)。
*
* @module api/elementLocate
* @author czj
......@@ -53,32 +54,91 @@ export interface LocateResponse {
screenshot?: string
}
/**
* 批量元素定位请求参数
*/
export interface BatchLocateRequest {
/** 用例 ID */
case_id: string
/** 目标页面 URL */
page_url?: string
/** 是否自动登录(默认 true) */
auto_login?: boolean
}
/**
* 单个步骤的定位结果
*/
export interface StepLocateResult {
/** 步骤顺序 */
order: number
/** 步骤描述 */
description: string
/** 是否成功 */
success: boolean
/** 定位类型 */
locator_type: string
/** 定位值 */
locator_value: string
/** 置信度 */
confidence: number
/** 消息 */
message: string
}
/**
* 批量元素定位响应
*/
export interface BatchLocateResponse {
/** 是否成功 */
success: boolean
/** 用例 ID */
case_id: string
/** 总步骤数 */
total_steps: number
/** 成功定位步骤数 */
located_steps: number
/** 每个步骤的定位结果 */
results: StepLocateResult[]
/** 消息 */
message: string
}
/**
* 元素定位 API
*/
export const elementLocateApi = {
/**
* 执行元素定位
* 执行单个步骤元素定位
*/
async locate(data: LocateRequest): Promise<LocateResponse> {
const response = await request.post('/api/element/locate', data, {
timeout: 60000,
})
return response as any
},
/**
* 批量元素定位(一键获取用例所有步骤的定位)
*
* @param data - 定位请求参数
* @returns 定位结果
* @param data - 批量定位请求参数
* @returns 每个步骤的定位结果
*
* @example
* const result = await elementLocateApi.locate({
* step_description: '输入用户名',
* const result = await elementLocateApi.locateBatch({
* case_id: 'case_xxx',
* page_url: 'https://192.168.5.44/',
* auto_login: true,
* })
*
* if (result.success && result.candidates.length > 0) {
* const best = result.candidates[0]
* console.log(`定位成功: ${best.locator_value} (${best.confidence})`)
* if (result.success) {
* console.log(`成功定位 ${result.located_steps}/${result.total_steps} 个步骤`)
* }
*/
async locate(data: LocateRequest): Promise<LocateResponse> {
const response = await request.post('/api/element/locate', data, {
timeout: 60000, // 60秒超时(Playwright 启动较慢)
async locateBatch(data: BatchLocateRequest): Promise<BatchLocateResponse> {
const response = await request.post('/api/element/locate-batch', data, {
timeout: 120000, // 批量定位可能需要较长时间
})
return response.data
return response as any
},
}
\ No newline at end of file
......@@ -22,7 +22,7 @@ export const executionApi = {
case_type?: string
}): Promise<{ total: number; items: any[] }> {
const response = await request.get('/api/executions', { params })
return response.data
return response as any
},
/**
......@@ -30,7 +30,7 @@ export const executionApi = {
*/
async get(id: string): Promise<any> {
const response = await request.get(`/api/executions/${id}`)
return response.data
return response as any
},
/**
......@@ -43,7 +43,7 @@ export const executionApi = {
config?: Record<string, any>
}): Promise<any> {
const response = await request.post('/api/executions', data)
return response.data
return response as any
},
/**
......@@ -51,7 +51,7 @@ export const executionApi = {
*/
async run(id: string, config?: Record<string, any>): Promise<any> {
const response = await request.post(`/api/executions/${id}/run`, config)
return response.data
return response as any
},
/**
......@@ -59,7 +59,7 @@ export const executionApi = {
*/
async cancel(id: string): Promise<any> {
const response = await request.post(`/api/executions/${id}/cancel`)
return response.data
return response as any
},
/**
......@@ -67,7 +67,7 @@ export const executionApi = {
*/
async getResults(id: string): Promise<{ total: number; items: any[] }> {
const response = await request.get(`/api/executions/${id}/results`)
return response.data
return response as any
},
/**
......@@ -77,7 +77,7 @@ export const executionApi = {
const response = await request.get(
`/api/executions/${executionId}/results/${caseId}`
)
return response.data
return response as any
},
/**
......@@ -98,6 +98,6 @@ export const executionApi = {
errors: string[]
}> {
const response = await request.post('/api/executions/batch-delete', { ids })
return response.data
return response as any
},
}
\ No newline at end of file
......@@ -24,7 +24,7 @@ export const moduleApi = {
const response = await request.get('/api/modules', {
params: { skip, limit, module_type, case_type }
})
return response.data
return response as any
},
/**
......@@ -32,7 +32,7 @@ export const moduleApi = {
*/
async get(id: string): Promise<Module> {
const response = await request.get(`/api/modules/${id}`)
return response.data
return response as any
},
/**
......@@ -40,7 +40,7 @@ export const moduleApi = {
*/
async create(data: CreateModuleRequest): Promise<Module> {
const response = await request.post('/api/modules', data)
return response.data
return response as any
},
/**
......@@ -48,7 +48,7 @@ export const moduleApi = {
*/
async update(id: string, data: UpdateModuleRequest): Promise<Module> {
const response = await request.put(`/api/modules/${id}`, data)
return response.data
return response as any
},
/**
......@@ -63,7 +63,7 @@ export const moduleApi = {
*/
async getStats(id: string): Promise<any> {
const response = await request.get(`/api/modules/${id}/stats`)
return response.data
return response as any
}
}
......
......@@ -20,7 +20,7 @@ export const recorderApi = {
message: string
}> {
const response = await request.post('/api/recorder/start', data)
return response.data
return response as any
},
/**
......@@ -34,7 +34,7 @@ export const recorderApi = {
const response = await request.get('/api/recorder/steps', {
params: { session_id: sessionId }
})
return response.data
return response as any
},
/**
......@@ -45,7 +45,7 @@ export const recorderApi = {
session_id: sessionId,
step: step,
})
return response.data
return response as any
},
/**
......@@ -60,7 +60,7 @@ export const recorderApi = {
session_id: sessionId,
...data,
})
return response.data
return response as any
},
/**
......@@ -70,7 +70,7 @@ export const recorderApi = {
const response = await request.post('/api/recorder/stop', null, {
params: { session_id: sessionId }
})
return response.data
return response as any
},
/**
......@@ -85,6 +85,6 @@ export const recorderApi = {
tags?: string[]
}): Promise<any> {
const response = await request.post('/api/recorder/save', data)
return response.data
return response as any
},
}
\ No newline at end of file
......@@ -17,7 +17,7 @@ export const reportApi = {
*/
async list(params?: { skip?: number; limit?: number; case_type?: string }): Promise<{ total: number; items: any[] }> {
const response = await request.get('/api/reports/list', { params })
return response.data
return response as any
},
/**
......@@ -32,7 +32,7 @@ export const reportApi = {
*/
async export(executionId: string): Promise<{ file_path: string; message: string }> {
const response = await request.post(`/api/reports/export/${executionId}`)
return response.data
return response as any
},
/**
......
......@@ -19,7 +19,7 @@ export const statsApi = {
const response = await request.get('/api/stats/overview', {
params: { days }
})
return response.data
return response as any
},
/**
......@@ -29,7 +29,7 @@ export const statsApi = {
const response = await request.get('/api/stats/trend', {
params: { days }
})
return response.data
return response as any
},
/**
......@@ -37,7 +37,7 @@ export const statsApi = {
*/
async getModules(): Promise<any> {
const response = await request.get('/api/stats/modules')
return response.data
return response as any
},
/**
......@@ -47,7 +47,7 @@ export const statsApi = {
const response = await request.get(`/api/stats/module/${moduleId}`, {
params: { days }
})
return response.data
return response as any
},
/**
......@@ -55,7 +55,7 @@ export const statsApi = {
*/
async getPriority(): Promise<any> {
const response = await request.get('/api/stats/priority')
return response.data
return response as any
},
/**
......@@ -63,6 +63,6 @@ export const statsApi = {
*/
async getStatus(): Promise<any> {
const response = await request.get('/api/stats/status')
return response.data
return response as any
},
}
\ No newline at end of file
......@@ -35,6 +35,6 @@ export const systemApi = {
*/
async getInfo(): Promise<SystemInfo> {
const response = await request.get('/api/system/info')
return response.data
return response as any
},
}
......@@ -51,6 +51,7 @@ const routes: RouteRecordRaw[] = [
{
path: '/device-sim',
name: 'DeviceSim',
component: () => import('@/views/device-sim/index.vue'),
redirect: '/device-sim/settings',
meta: { title: '设备模拟' },
children: [
......@@ -90,6 +91,7 @@ const routes: RouteRecordRaw[] = [
{
path: '/system',
name: 'System',
component: () => import('@/views/system/index.vue'),
redirect: '/system/settings',
meta: { title: '系统管理' },
children: [
......
......@@ -77,7 +77,7 @@ export interface Simulator {
deviceId: string
status: string
autoReconnect: boolean
metadata: Record<string, any> | null
extraAttrs: Record<string, any> | null
lastReportedAt: string | null
totalReports: number
createdAt: string
......@@ -91,7 +91,7 @@ export interface SimulatorCreate {
deviceName: string
deviceId: string
autoReconnect?: boolean
metadata?: Record<string, any> | null
extraAttrs?: Record<string, any> | null
}
/** 更新模拟设备请求 */
......
......@@ -38,7 +38,7 @@ request.interceptors.request.use(
*/
request.interceptors.response.use(
(response: AxiosResponse) => {
return response
return response.data
},
(error) => {
// 统一错误处理
......
<!--
组件名称:EnvConfig.vue
组件描述:环境配置管理页面 - MQTT Broker 连接配置 CRUD,支持多主题配置
组件描述:环境配置管理页面 - MQTT Broker 连接配置 CRUD
@author czj
@date 2026-07-29
......@@ -28,22 +28,6 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="主题配置" min-width="220">
<template #default="{ row }">
<div class="topic-tags">
<el-tag
v-for="(prefix, type) in normalizeTopics(row)"
:key="type"
size="small"
type="info"
class="topic-tag"
>
{{ deviceTypeLabel(type as string) }}: {{ prefix }}
</el-tag>
<span v-if="!hasTopics(row)" class="topic-empty">{{ row.topicPrefix || 'device' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="最后连接" width="170">
<template #default="{ row }">
{{ row.lastConnectedAt ? formatTime(row.lastConnectedAt) : '-' }}
......@@ -64,24 +48,15 @@
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑环境配置' : '新增环境配置'" width="640px">
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑环境配置' : '新增环境配置'" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" label-position="top">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="配置名称" prop="name">
<el-input v-model="form.name" placeholder="如:测试环境" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="客户端 ID 前缀">
<el-input v-model="form.clientIdPrefix" placeholder="如:sim" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="配置名称" prop="name">
<el-input v-model="form.name" placeholder="如:测试环境" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="14">
<el-form-item label="Broker 地址" prop="brokerHost">
<el-input v-model="form.brokerHost" placeholder="如:192.168.1.100" />
<el-input v-model="form.brokerHost" placeholder="如:192.168.5.44" />
</el-form-item>
</el-col>
<el-col :span="10">
......@@ -102,38 +77,18 @@
</el-form-item>
</el-col>
</el-row>
<!-- 高级选项:主题配置(折叠显示) -->
<el-collapse v-model="advancedCollapse" class="advanced-collapse">
<el-collapse-item title="高级选项(主题配置)" name="topics">
<el-form-item label="主题基础前缀" prop="topicPrefix">
<el-input v-model="form.topicPrefix" placeholder="如:device" @input="onBasePrefixChange" />
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="客户端 ID 前缀">
<el-input v-model="form.clientIdPrefix" placeholder="如:sim" />
</el-form-item>
<div class="topics-editor">
<div class="topic-item" v-for="dt in deviceTypes" :key="dt">
<span class="topic-label">{{ deviceTypeLabel(dt) }}</span>
<el-input
v-model="topicValues[dt]"
:placeholder="`${form.topicPrefix || 'device'}/${dt}`"
size="default"
class="topic-input"
>
<template #append>/{{ dt }}</template>
</el-input>
</div>
<el-tag type="info" size="small" effect="plain" class="topic-tip">
修改基础前缀可批量重置各设备主题;也可单独自定义每个设备类型的主题路径
</el-tag>
</div>
<el-row :gutter="20" style="margin-top: 12px">
<el-col :span="12">
<el-form-item label="TLS 加密">
<el-switch v-model="form.useTls" />
</el-form-item>
</el-col>
</el-row>
</el-collapse-item>
</el-collapse>
</el-col>
<el-col :span="12">
<el-form-item label="TLS 加密">
<el-switch v-model="form.useTls" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
......@@ -156,20 +111,6 @@ import {
} from '@/api/deviceSim'
import type { EnvConfig, EnvConfigCreate } from '@/types/device'
// 支持的设备类型
const deviceTypes = ['door', 'paperless', 'central', 'client'] as const
const deviceTypeLabels: Record<string, string> = {
door: '门口屏',
paperless: '无纸化',
central: '中控设备',
client: '集控客户端',
}
/** 设备类型中文名 */
function deviceTypeLabel(type: string): string {
return deviceTypeLabels[type] || type
}
const loading = ref(false)
const configs = ref<EnvConfig[]>([])
const dialogVisible = ref(false)
......@@ -177,17 +118,6 @@ const isEdit = ref(false)
const saving = ref(false)
const editId = ref('')
const formRef = ref()
const advancedCollapse = ref<string[]>([]) // 高级选项折叠面板状态(默认收起)
/** 根据基础前缀生成默认多主题映射 */
function buildDefaultTopics(prefix?: string | null): Record<string, string> {
const p = prefix || 'device'
const result: Record<string, string> = {}
for (const dt of deviceTypes) {
result[dt] = `${p}/${dt}`
}
return result
}
const defaultForm = (): EnvConfigCreate => ({
name: '',
......@@ -195,42 +125,16 @@ const defaultForm = (): EnvConfigCreate => ({
brokerPort: 1883,
username: 'mqtt@cmdb',
password: 'mqtt@webpassw0RD',
topicPrefix: 'device',
topics: buildDefaultTopics('device'),
useTls: false,
clientIdPrefix: 'sim',
})
const form = reactive<EnvConfigCreate>(defaultForm())
// 主题编辑对象(保证非空,用于模板双向绑定)
const topicValues = reactive<Record<string, string>>(buildDefaultTopics('device'))
const rules = {
name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
brokerHost: [{ required: true, message: '请输入 Broker 地址', trigger: 'blur' }],
brokerPort: [{ required: true, message: '请输入端口号', trigger: 'blur' }],
topicPrefix: [{ required: true, message: '请输入主题基础前缀', trigger: 'blur' }],
}
/** 基础前缀变更时,批量重置各设备主题(仅当用户未自定义时) */
function onBasePrefixChange() {
const fresh = buildDefaultTopics(form.topicPrefix)
Object.keys(topicValues).forEach((k) => delete topicValues[k])
Object.assign(topicValues, fresh)
}
/** 规范化主题配置:兼容旧数据(topics 为空时从 topicPrefix 生成) */
function normalizeTopics(row: EnvConfig): Record<string, string> {
if (row.topics && Object.keys(row.topics).length > 0) {
return row.topics
}
return buildDefaultTopics(row.topicPrefix || 'device')
}
/** 是否已有多主题配置 */
function hasTopics(row: EnvConfig): boolean {
return !!(row.topics && Object.keys(row.topics).length > 0)
}
/** 加载配置列表 */
......@@ -251,8 +155,6 @@ function openCreateDialog() {
isEdit.value = false
editId.value = ''
Object.assign(form, defaultForm())
Object.keys(topicValues).forEach((k) => delete topicValues[k])
Object.assign(topicValues, buildDefaultTopics('device'))
dialogVisible.value = true
}
......@@ -260,20 +162,15 @@ function openCreateDialog() {
function openEditDialog(row: EnvConfig) {
isEdit.value = true
editId.value = row.id
const topics = normalizeTopics(row)
Object.assign(form, {
name: row.name,
brokerHost: row.brokerHost,
brokerPort: row.brokerPort,
username: row.username,
password: '',
topicPrefix: row.topicPrefix,
topics,
useTls: row.useTls,
clientIdPrefix: row.clientIdPrefix,
})
Object.keys(topicValues).forEach((k) => delete topicValues[k])
Object.assign(topicValues, topics)
dialogVisible.value = true
}
......@@ -282,9 +179,6 @@ async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
// 将主题编辑对象回写到 form
form.topics = { ...topicValues }
saving.value = true
try {
if (isEdit.value) {
......@@ -368,74 +262,4 @@ onMounted(() => {
color: #303133;
margin: 0;
}
.topic-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.topic-tag {
margin: 0;
}
.topic-empty {
color: #909399;
font-size: 13px;
}
.advanced-collapse {
margin-top: 16px;
border: none;
}
.advanced-collapse :deep(.el-collapse-item__header) {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 0 16px;
font-size: 14px;
color: #606266;
}
.advanced-collapse :deep(.el-collapse-item__wrap) {
border: none;
}
.advanced-collapse :deep(.el-collapse-item__content) {
padding-top: 16px;
}
.topics-editor {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
background: #fafafa;
border-radius: 6px;
border: 1px solid #ebeef5;
margin-top: 8px;
}
.topic-item {
display: flex;
align-items: center;
gap: 10px;
}
.topic-label {
width: 80px;
flex-shrink: 0;
font-size: 13px;
color: #606266;
}
.topic-input {
flex: 1;
}
.topic-tip {
margin-top: 4px;
align-self: flex-start;
}
</style>
</style>
\ No newline at end of file
<!--
组件名称:DeviceSim.vue
组件描述:设备模拟模块父级容器,渲染子路由
@author czj
@date 2026-08-03
-->
<template>
<router-view />
</template>
<script setup lang="ts">
// 设备模拟模块容器组件
// 仅作为子路由出口,无需额外逻辑
</script>
\ No newline at end of file
<!--
组件名称:System.vue
组件描述:系统管理模块父级容器,渲染子路由
@author czj
@date 2026-08-03
-->
<template>
<router-view />
</template>
<script setup lang="ts">
// 系统管理容器组件
// 仅作为子路由出口,无需额外逻辑
</script>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论