diff --git a/docs/superpowers/specs/2026-09-03-monitor-device-polling-design.md b/docs/superpowers/specs/2026-09-03-monitor-device-polling-design.md new file mode 100644 index 0000000..a1e2d8a --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-monitor-device-polling-design.md @@ -0,0 +1,112 @@ +# 实时监控 / 设备详情状态轮询 + +日期:2026-09-03 +状态:已确认(待写实现计划) +范围:仅前端 `laic-frontend` + +## 1. 背景 + +「实时监控」页当前只在 `onMounted`(以及指令成功后的 `reload`)调用一次 `devices.load()`。左侧设备列表、右侧详情面板、地图 marker 都读同一份 Pinia `devicesStore`;store 不刷新,UI 就不更新。 + +直播会话(`useMonitorLive`)与指令进度(`useCommandProgress`)已有各自定时器,但**设备状态本身没有轮询**,与「实时监控」语义不符。 + +现有 `devices.load()` 契约: + +1. `GET /v1/docks` + `GET /v1/drones`(近全量 pageSize=100) +2. 再对每条 `GET /v1/docks/:id` / `GET /v1/drones/:id` 取 `online` + `realtime` +3. 映射 status / environment / alarms / 地图坐标后写入 `docks` / `drones` / `assets` + +设备详情页 `DeviceDetailView` 同样只在挂载时 `devices.load()` 一次,无持续刷新。 + +## 2. 目标 + +1. **实时监控页**停在页面上时,设备列表 / 选中详情 / 地图状态约每 **5 秒**自动更新。 +2. **设备详情页**只刷新**当前路由打开的那一台**设备详情,约每 **5 秒**一次。 +3. 离开页面或标签页隐藏时停止轮询;回到前台立即补刷一次再恢复。 +4. 不改变直播 heartbeat / phase / play-url,也不改变指令进度轮询。 + +## 3. 非目标 + +- **设备管理列表页(`DevicesView`)不自动刷新**(仍手动刷新按钮)。 +- 不新增后端批量快照 / WebSocket / SSE。 +- 不在本轮优化掉 Monitor 的 N+1 详情请求(已知成本,后续可换批量 API)。 +- 不改 `DevicesView` 分页本地 `tableRecords` 数据源。 + +## 4. 方案(页面各自定时器) + +采用页面级 `setInterval`,不引入 store 全局轮询器。 + +### 4.1 `MonitorView` + +1. `onMounted`:保留现有首次 `devices.load()`;成功后启动 `setInterval(() => void pollDevices(), 5000)`。 +2. `pollDevices`: + - 若上一轮 `load` 仍 in-flight → **跳过本轮**(防请求堆积)。 + - 否则 `await devices.load()`。 + - 单次失败:静默(`console.warn` 即可),**不 toast**,下一轮继续。 + - `401` 仍走现有 `http` 拦截器(登出 / 跳转)。 +3. `onUnmounted` / `pagehide`:`clearInterval`,并丢掉 in-flight 回写意图(沿用现有 generation/disposed 习惯,至少保证 timer 清掉)。 +4. `document.visibilitychange`: + - `hidden` → 暂停(clearInterval) + - `visible` → 立即 `pollDevices()` 一次,再重新 `setInterval` +5. 指令路径现有 `reload()` 继续直接 `devices.load()`;与轮询共享同一 in-flight 闸门更佳(同一 `loading`/`inflight` 标志),避免指令 reload 与定时器重叠打双份。 + +### 4.2 `DeviceDetailView` + +1. 保留挂载时一次 `devices.load()`(以及非 admin 的 logs/alarms)。 +2. 另启 **当前 `route.params.id`** 的详情轮询,间隔 5000ms: + - dock → `GET /v1/docks/:id` + - drone → `GET /v1/drones/:id` +3. 响应经 store 新方法合并进**对应一条**记录(见 4.3),驱动本页与若仍挂着的 Monitor 共享状态。 +4. `watch(() => route.params.id)`:停旧定时器,按新 id 立即拉一次并重启 interval。 +5. 生命周期 / visibility / in-flight 跳过 / 失败静默:与 Monitor 同规则。 +6. 离开页:清除定时器。 + +### 4.3 `devicesStore` 增量回写 + +新增(命名可微调,语义固定): + +- `applyDockDetail(id, detail)` +- `applyDroneDetail(id, detail)` + +行为: + +1. 找到 `docks` / `drones` 中对应 `id`;找不到则 **no-op**(或可选触发一次全量 `load` 作为 fallback;实现计划里二选一,默认 **no-op + console**,避免详情页单独制造全表风暴)。 +2. 用与 `load()` 相同的映射规则更新该条:`online`、`realtime`、`status` / `statusClass`、`environment`、`alarms`、`mode`、无人机 `battery` / `_lat` `_lon` `_alt` 等派生字段。 +3. 同步更新 `assets[id]` 上用于地图的 status / 坐标 / realtime 相关字段,避免 Monitor 与详情不一致。 +4. **不**重拉列表,**不**重建无关条目。 + +`load()` 本身可抽一小段「detail → record 字段」纯函数供全量与增量共用,避免两套映射分叉;若改动面过大,允许增量路径先复制必要映射,但字段语义必须与 `load()` 一致。 + +## 5. 错误与并发 + +| 场景 | 行为 | +|------|------| +| 轮询 HTTP 业务/网络失败 | 静默,保留旧数据,下轮再试 | +| 401 | 现有拦截器登出 | +| 上轮未完成 | 跳过本轮 | +| 页面 hidden | 暂停 timer | +| 页面 visible | 立即补一次 + 重启 timer | +| 详情 id 切换 | 取消旧轮询,新 id 立即请求 | + +## 6. 验收 + +1. 停留在实时监控:约 5s 内列表状态 / 右侧详情(电量、舱门、环境、在线等)/ 地图 marker 会随后端变化更新。 +2. 打开设备详情 A:网络面板仅见对 A 的详情轮询;切换到 B 后改为 B。 +3. 设备管理列表页打开时**无**定时 `/docks/:id` `/drones/:id` 轮询(仅用户点击刷新或进入时的请求)。 +4. 从监控/详情路由离开,或切到其它浏览器标签:轮询停止;切回后先补刷再按 5s 继续。 +5. 开播、停播、指令进度浮层行为与改前一致。 + +## 7. 风险 + +- Monitor 每 5s 全量 `load()` 仍是列表 + N 次详情;设备数量上来后 QPS 偏高。本轮按产品选择接受;后续应用批量状态接口替换 `pollDevices` 内部实现,页面契约可不变。 +- 详情增量映射若与 `load()` 分叉,会出现「详情页字段」与「监控全量刷新后字段」不一致;优先抽共享映射。 + +## 8. 实现落点(预告) + +| 文件 | 变更 | +|------|------| +| `src/stores/modules/devicesStore.js` | 抽映射;新增 `applyDockDetail` / `applyDroneDetail`;可选 `inflight` 或由页面自管 | +| `src/views/MonitorView/MonitorView.vue` | 5s 轮询 + visibility + 与 `reload` 共用 in-flight | +| `src/views/DeviceDetailView/DeviceDetailView.vue` | 当前 id 详情轮询 + id watch + visibility | + +完成后:实现计划 → 编码 → 构建部署 → 人工看监控与详情是否按 5s 变。