From af29c062d3216c476e0ef5baa6b00e0b2fe8cc3c Mon Sep 17 00:00:00 2001 From: xiaosi <2652281683@qq.com> Date: Thu, 3 Sep 2026 14:53:29 +0800 Subject: [PATCH] docs: add monitor device polling implementation plan Page-local 5s Monitor load polling and DeviceDetail current-id detail refresh with store apply helpers. --- .../2026-09-03-monitor-device-polling.md | 517 ++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-03-monitor-device-polling.md diff --git a/docs/superpowers/plans/2026-09-03-monitor-device-polling.md b/docs/superpowers/plans/2026-09-03-monitor-device-polling.md new file mode 100644 index 0000000..7825a65 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-monitor-device-polling.md @@ -0,0 +1,517 @@ +# Monitor Device Polling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Monitor and open DeviceDetail pages refresh device status about every 5s so “实时监控” actually stays live. + +**Architecture:** Page-local timers (no store-global poller). `MonitorView` reuses `devices.load()` every 5s with an in-flight gate + visibility pause. `DeviceDetailView` polls only the current route id via `GET /docks|:drones/:id` and merges through new `applyDockDetail` / `applyDroneDetail`. `DevicesView` stays manual-refresh only. + +**Tech Stack:** Vue 3, Pinia `devicesStore`, existing axios `request` + `urls.DOCK` / `urls.DRONE` + +**Spec:** `docs/superpowers/specs/2026-09-03-monitor-device-polling-design.md` + +--- + +### Task 1: Store incremental detail apply helpers + +**Files:** +- Modify: `src/stores/modules/devicesStore.js` + +- [ ] **Step 1: Add dock/drone status helpers used by both `load` and apply** + +Above `export const useDeviceStore`, add (keep existing `DOCK_STATUS` / `alarmCodes` / `environmentValues` / `modeText` / `controlModeText` / `coordinate` / `displayValue` / `gpsText` / `fmtTime`): + +```js +function dockStatusFromDetail(registerStatus, online, rt) { + const pending = registerStatus === 'pending' && !online + if (pending) return { status: '待连接', statusClass: 'pending' } + if (!online) return DOCK_STATUS.offline + if (alarmCodes(rt).length) return { status: '告警', statusClass: 'alarm' } + return DOCK_STATUS.online +} + +function droneStatusFromDetail(online, rt) { + const armed = ['1', 'true', 1, true].includes(rt.armed) + const altitude = coordinate(rt.altitude, -1000, 100000) + const flying = armed || (altitude != null && altitude > 0) + if (!online) return { status: '离线', statusClass: 'offline', altitude, flying } + if (flying) return { status: '飞行中', statusClass: 'mission', altitude, flying } + return { status: '在巢', statusClass: 'online', altitude, flying } +} +``` + +- [ ] **Step 2: Refactor `load()` dock/drone status blocks to call the helpers** + +In the dock `map` callback, replace the local `pending` / `st` computation with: + +```js + const st = dockStatusFromDetail(registerStatus, online, rt) +``` + +In the drone `map` callback, replace `armed` / `altitude` / `flying` / `st` with: + +```js + const st = droneStatusFromDetail(online, rt) + const altitude = st.altitude +``` + +(Keep the rest of each mapped object identical.) + +- [ ] **Step 3: Implement `applyDockDetail` / `applyDroneDetail`** + +Inside the store factory, before `return`, add: + +```js + function applyDockDetail(id, detail) { + const key = String(id) + const dock = docks.value.find((d) => d.id === key) + if (!dock) { + console.warn('[devices] applyDockDetail missing dock', key) + return false + } + const rt = detail?.realtime || {} + const online = Boolean(detail?.online) + const st = dockStatusFromDetail(dock.registerStatus || 'registered', online, rt) + const environment = environmentValues(rt) + const alarms = alarmCodes(rt) + Object.assign(dock, { + status: st.status, + statusClass: st.statusClass, + mode: controlModeText(rt.controlMode), + realtime: rt, + environment, + alarms, + online, + }) + const assetRow = assets[key] + if (assetRow) { + Object.assign(assetRow, { + status: dock.status, + statusClass: dock.statusClass, + realtime: rt, + environment, + alarms, + online, + }) + } + return true + } + + function applyDroneDetail(id, detail) { + const key = String(id) + const drone = drones.value.find((d) => d.id === key) + if (!drone) { + console.warn('[devices] applyDroneDetail missing drone', key) + return false + } + const rt = detail?.realtime || {} + const online = Boolean(detail?.online) + const st = droneStatusFromDetail(online, rt) + const altitude = st.altitude + const battery = rt.batteryPercent ?? rt.batteryPct + const alarms = alarmCodes(rt) + const dock = docks.value.find((d) => d.dockId === drone.dockId) + Object.assign(drone, { + status: st.status, + statusClass: st.statusClass, + battery: battery != null && battery !== '' ? `${battery}%` : drone.battery, + mode: modeText(rt.flightMode), + mission: displayValue(rt.missionName || rt.mission), + sysid: displayValue(rt.currentSysId), + gps: gpsText(rt.gpsQuality), + realtime: rt, + alarms, + online, + _lat: coordinate(rt.latitude, -90, 90), + _lon: coordinate(rt.longitude, -180, 180), + _alt: altitude, + _speed: coordinate(rt.groundSpeed, 0, 1000), + _sats: coordinate(rt.satellites, 0, 1000), + }) + const assetRow = assets[key] + if (assetRow) { + const lon = drone._lon != null ? drone._lon : dock?.longitude + const lat = drone._lat != null ? drone._lat : dock?.latitude + const hasCoordinates = lon != null && lat != null + Object.assign(assetRow, { + status: drone.status, + statusClass: drone.statusClass, + location: drone.statusClass === 'mission' + ? `${dock?.location || '--'} · 飞行中` + : `停放于${dock?.name || '--'}`, + hasCoordinates, + longitude: hasCoordinates ? lon : null, + latitude: hasCoordinates ? lat : null, + inDock: drone.statusClass !== 'mission', + altitude: drone._alt, + speed: drone._speed, + satellites: drone._sats, + realtime: rt, + alarms, + online, + }) + } + return true + } +``` + +Export them on the store return: + +```js + return { + assets, docks, drones, loaded, + asset, record, commandType, sendCommand, load, + applyDockDetail, applyDroneDetail, + } +``` + +- [ ] **Step 4: Static check** + +Run: + +```bash +rg -n "dockStatusFromDetail|droneStatusFromDetail|applyDockDetail|applyDroneDetail" src/stores/modules/devicesStore.js +``` + +Expected: helpers defined; both apply functions present; `return` exports both apply methods. + +- [ ] **Step 5: Commit** + +```bash +git add src/stores/modules/devicesStore.js +git commit -m "feat: apply single dock/drone detail into devices store" +``` + +--- + +### Task 2: MonitorView 5s polling + visibility + +**Files:** +- Modify: `src/views/MonitorView/MonitorView.vue` + +- [ ] **Step 1: Add poll state near other page locals** + +Near `selectedId` / live setup (script top area after stores), add: + +```js +const DEVICE_POLL_MS = 5000 +let devicePollTimer = null +let devicePollInflight = null +let devicePollGen = 0 +let monitorDisposed = false + +function clearDevicePollTimer() { + window.clearInterval(devicePollTimer) + devicePollTimer = null +} + +async function pollDevices({ toastOnSuccess = false } = {}) { + if (monitorDisposed) return + if (devicePollInflight) return + const gen = devicePollGen + devicePollInflight = devices.load() + try { + await devicePollInflight + if (monitorDisposed || gen !== devicePollGen) return + if (selectedId.value && !devices.record(selectedId.value)) clearSelection() + selectedDockIds.forEach((id) => { + if (!devices.docks.some((dock) => dock.id === id)) selectedDockIds.delete(id) + }) + syncMarkers() + if (toastOnSuccess) ui.toast('设备状态已刷新') + } catch (e) { + if (monitorDisposed || gen !== devicePollGen) return + if (toastOnSuccess) ui.toast(e.message || '刷新设备状态失败') + else console.warn('[monitor] device poll failed', e) + } finally { + devicePollInflight = null + } +} + +function startDevicePolling() { + clearDevicePollTimer() + if (monitorDisposed || document.visibilityState === 'hidden') return + devicePollTimer = window.setInterval(() => { + void pollDevices() + }, DEVICE_POLL_MS) +} + +function stopDevicePolling() { + clearDevicePollTimer() +} + +function onDevicePollVisibility() { + if (monitorDisposed) return + if (document.visibilityState === 'hidden') { + stopDevicePolling() + return + } + void pollDevices() + startDevicePolling() +} +``` + +- [ ] **Step 2: Wire lifecycle** + +Update `onMounted` so after the existing initial `devices.load()` try/catch (keep first-load toast on failure), **always** start polling if still mounted: + +```js +onMounted(async () => { + monitorDisposed = false + window.addEventListener('pagehide', onPageHide) + window.addEventListener('keydown', onGlobalKeydown) + document.addEventListener('visibilitychange', onDevicePollVisibility) + try { + await devices.load() + if (route.query.deviceId && devices.record(route.query.deviceId)) selectedId.value = String(route.query.deviceId) + const rec = selectedId.value ? devices.record(selectedId.value) : null + controlExpanded.value = route.query.control === '1' && rec?.kind === 'dock' + } catch (e) { + ui.toast(e.message || '加载设备失败') + } + startDevicePolling() + try { + await setupMap() + } catch (e) { + ui.toast(e.message || '地图初始化失败') + } +}) +``` + +Update `onUnmounted`: + +```js +onUnmounted(() => { + monitorDisposed = true + devicePollGen += 1 + stopDevicePolling() + document.removeEventListener('visibilitychange', onDevicePollVisibility) + window.removeEventListener('pagehide', onPageHide) + window.removeEventListener('keydown', onGlobalKeydown) + closeCommandProgress() + disposeLive() + teardownMap() +}) +``` + +Ensure `onPageHide` (existing) also stops device polling if it only handled live before — either call `stopDevicePolling()` inside `onPageHide`, or rely on unmount; **prefer also stopping in `onPageHide`** so bfcache hides don’t keep firing. + +- [ ] **Step 3: Point `reload()` through the same in-flight gate** + +Replace `reload` body with: + +```js +async function reload() { + await pollDevices({ toastOnSuccess: true }) + if (monitorDisposed) return + if (selectedId.value) flyToSelected() + else resetOverviewCamera() +} +``` + +(`pollDevices` already clears stale selection / selectedDockIds / syncMarkers.) + +- [ ] **Step 4: Static check** + +```bash +rg -n "DEVICE_POLL_MS|pollDevices|startDevicePolling|visibilitychange|toastOnSuccess" src/views/MonitorView/MonitorView.vue +``` + +Expected: 5000 interval, visibility handler, `reload` uses `pollDevices({ toastOnSuccess: true })`. + +- [ ] **Step 5: Commit** + +```bash +git add src/views/MonitorView/MonitorView.vue +git commit -m "feat: poll monitor device status every 5s" +``` + +--- + +### Task 3: DeviceDetailView current-id detail polling + +**Files:** +- Modify: `src/views/DeviceDetailView/DeviceDetailView.vue` + +- [ ] **Step 1: Extend imports** + +Change: + +```js +import { computed, onMounted, ref } from 'vue' +``` + +to: + +```js +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' +``` + +- [ ] **Step 2: Add poll helpers after `record` / `isDrone` computeds** + +```js +const DETAIL_POLL_MS = 5000 +let detailPollTimer = null +let detailPollInflight = null +let detailPollGen = 0 +let detailDisposed = false + +function clearDetailPollTimer() { + window.clearInterval(detailPollTimer) + detailPollTimer = null +} + +async function pollCurrentDetail() { + if (detailDisposed) return + if (detailPollInflight) return + const id = String(route.params.id || '') + const rec = devices.record(id) + if (!id || !rec) return + const gen = detailPollGen + const url = rec.kind === 'drone' ? urls.DRONE(id) : urls.DOCK(id) + detailPollInflight = request.get(url) + try { + const detail = await detailPollInflight + if (detailDisposed || gen !== detailPollGen) return + if (String(route.params.id) !== id) return + if (rec.kind === 'drone') devices.applyDroneDetail(id, detail || {}) + else devices.applyDockDetail(id, detail || {}) + } catch (e) { + if (detailDisposed || gen !== detailPollGen) return + console.warn('[device-detail] poll failed', e) + } finally { + detailPollInflight = null + } +} + +function startDetailPolling() { + clearDetailPollTimer() + if (detailDisposed || document.visibilityState === 'hidden') return + detailPollTimer = window.setInterval(() => { + void pollCurrentDetail() + }, DETAIL_POLL_MS) +} + +function stopDetailPolling() { + clearDetailPollTimer() +} + +function restartDetailPolling() { + detailPollGen += 1 + stopDetailPolling() + void pollCurrentDetail() + startDetailPolling() +} + +function onDetailVisibility() { + if (detailDisposed) return + if (document.visibilityState === 'hidden') { + stopDetailPolling() + return + } + void pollCurrentDetail() + startDetailPolling() +} +``` + +- [ ] **Step 3: Lifecycle + id watch** + +Replace `onMounted` and add unmount/watch: + +```js +onMounted(async () => { + detailDisposed = false + document.addEventListener('visibilitychange', onDetailVisibility) + try { + await devices.load() + if (!isAdmin.value) { + await Promise.all([loadLogs(), loadAlarms()]) + } + } catch (e) { + ui.toast(e.message || '加载设备失败') + } + startDetailPolling() +}) + +onUnmounted(() => { + detailDisposed = true + detailPollGen += 1 + stopDetailPolling() + document.removeEventListener('visibilitychange', onDetailVisibility) +}) + +watch( + () => String(route.params.id || ''), + (next, prev) => { + if (!next || next === prev) return + restartDetailPolling() + } +) +``` + +Keep `handleDockSaved` full `devices.load()` as-is (edit path, not poll path). + +- [ ] **Step 4: Static check** + +```bash +rg -n "DETAIL_POLL_MS|pollCurrentDetail|applyDockDetail|applyDroneDetail|visibilitychange" src/views/DeviceDetailView/DeviceDetailView.vue +``` + +Expected: 5s timer; apply* calls; visibility + id watch; no DevicesView changes. + +- [ ] **Step 5: Commit** + +```bash +git add src/views/DeviceDetailView/DeviceDetailView.vue +git commit -m "feat: poll open device detail every 5s" +``` + +--- + +### Task 4: Build, deploy, smoke + +**Files:** none (verify only) + +- [ ] **Step 1: Build** + +```bash +npm run build +``` + +Expected: Vite build success. + +- [ ] **Step 2: Push + deploy** (same channel as recent frontend deploys) + +```bash +git push origin main +tar -C dist -czf - . | ssh -o BatchMode=yes jg-serv1 'rm -rf /usr/share/nginx/laic-frontend/dist/* && tar -C /usr/share/nginx/laic-frontend/dist -xzf -' +``` + +- [ ] **Step 3: Manual smoke** + +1. Open 实时监控 → DevTools Network:约每 5s 出现 `/v1/docks`、`/v1/drones` 及详情请求;列表/右侧状态会变。 +2. Open 某台设备详情 → 仅见该 id 的 `/v1/docks/:id` 或 `/v1/drones/:id` 约 5s 一次(在首次 load 之后)。 +3. Open 设备管理 → **无**定时详情轮询。 +4. 切到其它浏览器标签再回来:监控/详情应先补一次请求再继续 interval。 +5. 开播/停播/指令浮层仍可用。 + +- [ ] **Step 4: Final commit only if smoke required doc tweak; otherwise done** + +No code commit required if Steps 1–3 pass. + +--- + +## Self-review vs spec + +| Spec requirement | Task | +|------------------|------| +| Monitor 5s `devices.load` | Task 2 | +| in-flight skip + silent fail | Task 2 `pollDevices` | +| visibility pause / resume + immediate refresh | Task 2 | +| unmount / pagehide stop | Task 2 | +| `reload` shares in-flight | Task 2 Step 3 | +| DeviceDetail current-id only | Task 3 | +| apply* merge + assets sync; missing id no-op | Task 1 | +| DevicesView no auto poll | no task touches it | +| live / command progress unchanged | Tasks avoid those modules | +| build/deploy/smoke | Task 4 |