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

feat(device-sim): 无纸化授权码 + 环境配置弹窗简化 + 批量删除性能优化

- 无纸化导入模板增加授权码列(ANP-NRY-0022 格式)
- 环境配置弹窗精简为 7 个核心字段(移除主题前缀/默认参数/授权码配置)
- 批量删除重写为批量 SQL 操作,100 条设备秒级完成
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 6380fb05
# HANDOFF — 设备模拟模块 # HANDOFF — 设备模拟模块
> **生成时间**: 2026-08-13 > **生成时间**: 2026-08-19
> **当前分支**: `platform-auto-test` > **当前分支**: `platform-auto-test`
> **最近提交**: `26cc89c5` fix(executor): 登录检测多信号优化与设备模拟按类型批量启停 > **最近提交**: `1a3d52ad` fix(executor): 修复执行中无法取消(MySQL 1205 行锁)并加固 SUT URL 校验
> **未提交改动**: 设备模拟相关 6 个前端文件 + 文档(见下方"会话 C"修改文件清单 > **未提交改动**: 设备模拟相关改动 + 其他模块(见 git status
--- ---
## 📊 会话进度记录 ## 📊 会话进度记录
### 2026-08-19 会话 D:无纸化授权码 + 环境配置弹窗简化 + 批量删除性能修复(已全部完成并部署)
**会话目标**:三项设备模拟模块优化 — ①无纸化导入模板加授权码列 ②环境配置弹窗简化 ③100 条设备批量删除卡"处理中"修复
**状态**:✅ 全部完成 — 文档 + 代码实现 + 构建 + 部署到 192.168.5.60 + API 验证通过
---
#### ① 无纸化导入模板增加授权码列
**背景**:真实无纸化设备在 `rebootResponseTopic` 上报消息体中携带 `authCode`(如 `"authCode": "ANP-NRY-0022"`),但导入模板无此列,导致无法填写授权码。
**已修改**`backend/app/routers/device_sim.py`):
- 模板列逻辑由 `if device_type == "door"` 改为 `if device_type in ("door", "paperless")`,两种设备都输出"授权码(app_token)"列
- 无纸化示例值:`ANP-NRY-0022`(匹配真实设备 authCode 格式)
- 门口屏示例值由 `AUTH-0001` 改为更真实的 `AND-518-0001`
**模拟器兼容性**(无需改动,已确认 `paperless_simulator.py` 兼容):
- `build_reboot_response_payload()``topic_params.app_token` 读取授权码
- `auth_code = app_token.replace("AND-", "ANP-", 1)` 自动转换格式(door 的 AND- 前缀 → paperless 的 ANP- 前缀)
---
#### ② 环境配置弹窗简化
**背景**:新增/编辑环境配置弹窗字段过多,用户只关心核心连接信息。
**已修改**`frontend/src/views/device-sim/EnvConfig.vue`):
- **移除**约 90 行模板代码:主题前缀配置(door/paperless/central/client)、默认主题参数(company_id / conference_name / conference_id / room_ids)、门口屏授权码配置(prefix/start/end)、无纸化授权码配置(prefix/start/end)
- **保留**字段:配置名称、Broker 地址、端口、用户名、密码、客户端 ID 前缀、TLS 加密
- 移除 `DEVICE_TYPE_LABELS` 导入、`DEVICE_TYPES` 常量、`addRoomId()`/`removeRoomId()` 函数、`.room-ids-inline` CSS
- `defaultForm()``defaultTopicParams` 简化为 `{}`(后端仍兼容旧数据)
---
#### ③ 批量删除 100 条设备卡"处理中"修复
**问题现象**:无纸化模拟页面勾选约 100 条设备批量删除,前端长时间显示"处理中",未删除成功。
**根因**:旧版 `batch_delete_simulators()` 用 for 循环逐条调用 `delete_simulator()`,每台设备都执行:MQTT 停止发布 → 查询设备 → 查询日志 → 删除日志 → 删除设备 → flush,100 台设备产生上百次数据库往返 + MQTT 同步操作。
**已重写**`backend/app/services/device_sim_service.py`,原 1167-1196 行):
- **去重**`list(dict.fromkeys(device_ids))` 防重复 ID
- **单次查询**:一次 `select(DeviceSimulator.id).where(id.in_(ids))` 确认存在,不存在归入 `failed_ids`
- **批量停止**:遍历 `_running_simulators` 逐个取实例 stop,外层 try/except 捕获异常不中断(MQTT 失败只记 warning)
- **批量 SQL 删除**`delete(ReportLog).where(device_id.in_(ids))` + `delete(DeviceSimulator).where(id.in_(ids))`**单次 flush()**
- **关键点**`ReportLog.device_id` 存的是 `DeviceSimulator.id`(主键 `sim_xxx`),不是 MQTT `device_id``door_001`),删除条件按主键关联
- 返回 `success_count / failed_count / failed_ids / message`
**前端检查**`frontend/src/api/deviceSim.ts` 第 188 行,无需改动):
- 批量删除 URL 已使用正斜杠 `/devices/batch`(非反斜杠),body 用 `{ deviceIds }` 匹配后端 DELETE schema
---
#### 部署与验证(192.168.5.60)
**部署脚本**:复用 `deploy_paperless_token.py`(SFTP 上传 2 个后端文件 + 85 个前端 dist 文件 → `docker compose restart app`
| 验证项 | 结果 |
|--------|------|
| 健康检查 `http://192.168.5.60/health` | ✅ 200 healthy |
| 无纸化模板接口 `?device_type=paperless` | ✅ 正常返回 Excel(含授权码列) |
| 批量删除 API(不存在的 ID) | ✅ `successCount:0, failedCount:1` 校验逻辑正确 |
| **批量删除 100 条真实设备** | ✅ `successCount:100, failedCount:0` 秒级完成 |
| 数据库校验 | ✅ 设备总数 1087 → 987(确认减少 100 条) |
**注意**:部署脚本名称 `deploy_paperless_token.py` 是历史遗留命名,实际现在部署的是本会话三项改动,下次可改名或新建通用部署脚本。
---
**修改文件清单(本次会话)**
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/routers/device_sim.py` | 修改 | 无纸化模板加授权码列 + 示例值优化 |
| `backend/app/services/device_sim_service.py` | 修改 | 重写 `batch_delete_simulators()` 为批量 SQL 操作 |
| `frontend/src/views/device-sim/EnvConfig.vue` | 修改 | 环境配置弹窗简化(删除主题前缀/默认参数/授权码配置区块) |
| `deploy_paperless_token.py` | 已有 | 部署脚本(复用上传到 5.60) |
**待办**
- 本次改动(含此前会话 B/C 未提交的设备模拟改动)尚未提交 git,下次会话可通过 `/GitCommit` 提交
- 建议浏览器硬刷新(Ctrl+F5)验证:无纸化导入模板含授权码列;环境配置弹窗仅 7 个字段;批量勾选 100 条设备删除秒级完成
---
### 2026-08-13 会话 C:环境筛选与统计卡片联动修复(已全部完成并部署) ### 2026-08-13 会话 C:环境筛选与统计卡片联动修复(已全部完成并部署)
**会话目标**:修复设备模拟页面筛选 5.202 环境时统计卡片仍显示 540 的问题 **会话目标**:修复设备模拟页面筛选 5.202 环境时统计卡片仍显示 540 的问题
...@@ -466,11 +551,13 @@ ...@@ -466,11 +551,13 @@
| A | 环境配置名称 | 是 | 需与已创建的环境配置名称完全一致 | | A | 环境配置名称 | 是 | 需与已创建的环境配置名称完全一致 |
| B | 设备名称 | 是 | | | B | 设备名称 | 是 | |
| C | 设备编号 | 是 | 同一环境下唯一 | | C | 设备编号 | 是 | 同一环境下唯一 |
| D | 授权码(app_token) | 否 | 仅门口屏需要,填入 topic_params | | D | 授权码(app_token) | 否 | 门口屏、无纸化都需要,填入 topic_params(无纸化 authCode 来源) |
| E | 自动重连 | 否 | 默认 true,支持 是/否、true/false | | E | 自动重连 | 否 | 默认 true,支持 是/否、true/false |
| F | 启用定时上报 | 否 | 默认 true | | F | 启用定时上报 | 否 | 默认 true |
| G | 上报间隔(秒) | 否 | 默认 30,整数 >= 1 | | G | 上报间隔(秒) | 否 | 默认 30,整数 >= 1 |
> **2026-08-19 更新**:授权码列已由"仅门口屏"扩展为"门口屏 + 无纸化"(无纸化示例 `ANP-NRY-0022`,门口屏示例 `AND-518-0001`)。
### 中控模板 ### 中控模板
| 列 | 表头 | 必填 | 说明 | | 列 | 表头 | 必填 | 说明 |
......
...@@ -295,7 +295,7 @@ async def download_import_template( ...@@ -295,7 +295,7 @@ async def download_import_template(
# 表头 # 表头
headers = ["环境配置名称", "设备名称", "设备编号"] headers = ["环境配置名称", "设备名称", "设备编号"]
if device_type == "door": if device_type in ("door", "paperless"):
headers.append("授权码(app_token)") headers.append("授权码(app_token)")
elif device_type == "central": elif device_type == "central":
headers.append("会议室编号") headers.append("会议室编号")
...@@ -306,7 +306,9 @@ async def download_import_template( ...@@ -306,7 +306,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("AND-518-0001")
elif device_type == "paperless":
example.append("ANP-NRY-0022")
elif device_type == "central": elif device_type == "central":
example.append("A101") example.append("A101")
example.append("设备在线") example.append("设备在线")
......
...@@ -659,7 +659,7 @@ class DeviceSimService: ...@@ -659,7 +659,7 @@ class DeviceSimService:
# 构建 topic_params # 构建 topic_params
topic_params = {} topic_params = {}
# 门口屏:授权码 # 门口屏、无纸化:授权码(无纸化 rebootResponseTopic 的 authCode 来源)
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()
...@@ -1166,33 +1166,56 @@ class DeviceSimService: ...@@ -1166,33 +1166,56 @@ class DeviceSimService:
async def batch_delete_simulators(self, device_ids: List[str]) -> dict: async def batch_delete_simulators(self, device_ids: List[str]) -> dict:
""" """
批量删除模拟设备 批量删除模拟设备
Args: 先停止内存中的运行实例,再使用批量 SQL 删除上报记录和设备记录,
device_ids: 设备 ID 列表 避免逐台执行 MQTT 操作和数据库 flush 导致大批量删除长时间阻塞。
Returns:
dict: {"success_count": int, "failed_count": int, "failed_ids": list, "message": str}
""" """
success_count = 0 if not device_ids:
failed_ids = [] return {
"success_count": 0,
"failed_count": 0,
"failed_ids": [],
"message": "没有选择要删除的设备",
}
for device_id in device_ids: # 去重,避免同一设备重复处理
unique_ids = list(dict.fromkeys(device_ids))
# 先确认数据库中实际存在的设备,避免把不存在的 ID 计为成功
result = await self.db.execute(
select(DeviceSimulator.id).where(DeviceSimulator.id.in_(unique_ids))
)
existing_ids = [row[0] for row in result.all()]
existing_id_set = set(existing_ids)
failed_ids = [device_id for device_id in unique_ids if device_id not in existing_id_set]
# 停止运行中的实例。MQTT 停止异常不能阻断数据库删除。
for device_id in existing_ids:
with _running_simulators_lock:
sim = _running_simulators.pop(device_id, None)
if sim:
try: try:
result = await self.delete_simulator(device_id) sim.stop()
if result:
success_count += 1
else:
failed_ids.append(device_id)
except Exception as e: except Exception as e:
logger.warning(f"批量删除设备失败: {device_id}, error={e}") logger.warning(f"批量删除停止设备失败,继续删除数据库记录: {device_id}, error={e}")
failed_ids.append(device_id)
if existing_ids:
# ReportLog.device_id 保存的是模拟器主键,和 DeviceSimulator.id 一致。
await self.db.execute(
delete(ReportLog).where(ReportLog.device_id.in_(existing_ids))
)
await self.db.execute(
delete(DeviceSimulator).where(DeviceSimulator.id.in_(existing_ids))
)
await self.db.flush()
success_count = len(existing_ids)
return { return {
"success_count": success_count, "success_count": success_count,
"failed_count": len(failed_ids), "failed_count": len(failed_ids),
"failed_ids": failed_ids, "failed_ids": failed_ids,
"message": f"批量删除完成: 成功 {success_count}, 失败 {len(failed_ids)}" "message": f"批量删除完成: 成功 {success_count}, 失败 {len(failed_ids)}",
} }
# ==================== 上报记录 ==================== # ==================== 上报记录 ====================
......
...@@ -89,92 +89,6 @@ ...@@ -89,92 +89,6 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<!-- 主题前缀配置(多主题) -->
<el-divider content-position="left">主题前缀配置</el-divider>
<el-row :gutter="16">
<el-col :span="12" v-for="dt in DEVICE_TYPES" :key="dt">
<el-form-item :label="DEVICE_TYPE_LABELS[dt]">
<el-input v-model="form.topics![dt]" :placeholder="`device/${dt}`" size="small" />
</el-form-item>
</el-col>
</el-row>
<el-alert
type="info"
:closable="false"
show-icon
title="各设备类型使用独立的 MQTT 主题前缀,留空默认按 device/{设备类型} 生成"
style="margin-bottom: 8px"
/>
<!-- 默认主题参数配置 -->
<el-divider content-position="left">默认主题参数</el-divider>
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="公司编号">
<el-input v-model="form.defaultTopicParams!.company_id" placeholder="如:001" size="small" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="会议室名称">
<el-input v-model="form.defaultTopicParams!.conference_name" placeholder="如:测试会议室1" size="small" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="会议室ID">
<el-input v-model="form.defaultTopicParams!.conference_id" placeholder="如:16" size="small" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="会议室编号">
<div class="room-ids-inline">
<template v-for="(_room, index) in form.defaultTopicParams!.room_ids" :key="index">
<el-input v-model="form.defaultTopicParams!.room_ids![index]" :placeholder="`C${index + 1}`" size="small" style="width: 100px" />
<el-button v-if="(form.defaultTopicParams!.room_ids?.length ?? 0) > 1" type="danger" link size="small" @click="removeRoomId(index)" :icon="Delete" />
</template>
<el-button size="small" @click="addRoomId" :icon="Plus" circle />
</div>
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">门口屏授权码配置</el-divider>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="授权码前缀">
<el-input v-model="form.defaultTopicParams!.app_token_prefix" placeholder="如:AND-518" size="small" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="起始编号">
<el-input-number v-model="form.defaultTopicParams!.app_token_start" :min="1" :max="9999" style="width: 100%" size="small" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="结束编号">
<el-input-number v-model="form.defaultTopicParams!.app_token_end" :min="1" :max="9999" style="width: 100%" size="small" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">无纸化授权码配置</el-divider>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="授权码前缀">
<el-input v-model="form.defaultTopicParams!.paperless_token_prefix" placeholder="如:ANP-518" size="small" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="起始编号">
<el-input-number v-model="form.defaultTopicParams!.paperless_token_start" :min="1" :max="9999" style="width: 100%" size="small" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="结束编号">
<el-input-number v-model="form.defaultTopicParams!.paperless_token_end" :min="1" :max="9999" style="width: 100%" size="small" />
</el-form-item>
</el-col>
</el-row>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
...@@ -187,7 +101,7 @@ ...@@ -187,7 +101,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus, Delete } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import { import {
listEnvConfigs, listEnvConfigs,
createEnvConfig, createEnvConfig,
...@@ -196,10 +110,6 @@ import { ...@@ -196,10 +110,6 @@ import {
testEnvConnection, testEnvConnection,
} from '@/api/deviceSim' } from '@/api/deviceSim'
import type { EnvConfig, EnvConfigCreate } from '@/types/device' import type { EnvConfig, EnvConfigCreate } from '@/types/device'
import { DEVICE_TYPE_LABELS } from '@/types/device'
/** 支持配置主题前缀的设备类型(按固定顺序展示) */
const DEVICE_TYPES = ['door', 'paperless', 'central', 'client'] as const
/** 由主题前缀自动生成默认多主题映射 */ /** 由主题前缀自动生成默认多主题映射 */
function defaultTopics(): Record<string, string> { function defaultTopics(): Record<string, string> {
...@@ -228,18 +138,7 @@ const defaultForm = (): EnvConfigCreate => ({ ...@@ -228,18 +138,7 @@ const defaultForm = (): EnvConfigCreate => ({
useTls: false, useTls: false,
clientIdPrefix: 'sim', clientIdPrefix: 'sim',
topics: defaultTopics(), topics: defaultTopics(),
defaultTopicParams: { defaultTopicParams: {},
company_id: '',
room_ids: [''],
app_token_prefix: '',
app_token_start: 1,
app_token_end: 10,
paperless_token_prefix: '',
paperless_token_start: 1,
paperless_token_end: 10,
conference_id: '',
conference_name: '',
},
}) })
const form = reactive<EnvConfigCreate>(defaultForm()) const form = reactive<EnvConfigCreate>(defaultForm())
...@@ -284,46 +183,11 @@ function openEditDialog(row: EnvConfig) { ...@@ -284,46 +183,11 @@ function openEditDialog(row: EnvConfig) {
useTls: row.useTls, useTls: row.useTls,
clientIdPrefix: row.clientIdPrefix, clientIdPrefix: row.clientIdPrefix,
topics: row.topics ? { ...row.topics } : defaultTopics(), topics: row.topics ? { ...row.topics } : defaultTopics(),
defaultTopicParams: row.defaultTopicParams ? { defaultTopicParams: row.defaultTopicParams ? { ...row.defaultTopicParams } : {},
company_id: row.defaultTopicParams.company_id || '',
room_ids: row.defaultTopicParams.room_ids?.length ? [...row.defaultTopicParams.room_ids] : [''],
app_token_prefix: row.defaultTopicParams.app_token_prefix || '',
app_token_start: row.defaultTopicParams.app_token_start ?? 1,
app_token_end: row.defaultTopicParams.app_token_end ?? 10,
paperless_token_prefix: row.defaultTopicParams.paperless_token_prefix || '',
paperless_token_start: row.defaultTopicParams.paperless_token_start ?? 1,
paperless_token_end: row.defaultTopicParams.paperless_token_end ?? 10,
conference_id: row.defaultTopicParams.conference_id || '',
conference_name: row.defaultTopicParams.conference_name || '',
} : {
company_id: '',
room_ids: [''],
app_token_prefix: '',
app_token_start: 1,
app_token_end: 10,
paperless_token_prefix: '',
paperless_token_start: 1,
paperless_token_end: 10,
conference_id: '',
conference_name: '',
},
}) })
dialogVisible.value = true dialogVisible.value = true
} }
/** 添加会议室编号 */
function addRoomId() {
if (!form.defaultTopicParams!.room_ids) {
form.defaultTopicParams!.room_ids = ['']
}
form.defaultTopicParams!.room_ids.push('')
}
/** 删除会议室编号 */
function removeRoomId(index: number) {
form.defaultTopicParams!.room_ids?.splice(index, 1)
}
/** 保存配置 */ /** 保存配置 */
async function handleSave() { async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false) const valid = await formRef.value?.validate().catch(() => false)
...@@ -412,11 +276,4 @@ onMounted(() => { ...@@ -412,11 +276,4 @@ onMounted(() => {
color: #303133; color: #303133;
margin: 0; margin: 0;
} }
.room-ids-inline {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
</style> </style>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论