Browse Source
Page-local 5s Monitor load polling and DeviceDetail current-id detail refresh with store apply helpers.main
1 changed files with 517 additions and 0 deletions
@ -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 | |
||||
Loading…
Reference in new issue