Compare commits

...

6 Commits

Author SHA1 Message Date
xiaosi 1c6cf6a2cc feat: poll open device detail every 5s 2 weeks ago
xiaosi cc92ad5ef9 feat: poll monitor device status every 5s 2 weeks ago
xiaosi 1b67e09b0c feat: apply single dock/drone detail into devices store 2 weeks ago
xiaosi af29c062d3 docs: add monitor device polling implementation plan 2 weeks ago
xiaosi d198402f36 docs: tighten monitor polling spec ambiguities 2 weeks ago
xiaosi c04c9119fa docs: add monitor device polling design 2 weeks ago
  1. 517
      docs/superpowers/plans/2026-09-03-monitor-device-polling.md
  2. 112
      docs/superpowers/specs/2026-09-03-monitor-device-polling-design.md
  3. 129
      src/stores/modules/devicesStore.js
  4. 83
      src/views/DeviceDetailView/DeviceDetailView.vue
  5. 77
      src/views/MonitorView/MonitorView.vue

517
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 |

112
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()`(失败仍 toast 一次,与现状一致);无论首次成败,只要仍挂在本页就启动 `setInterval(() => void pollDevices(), 5000)`
2. `pollDevices`
- 若上一轮 `load` 仍 in-flight → **跳过本轮**(防请求堆积)。
- 否则 `await devices.load()`
- 单次失败:静默(`console.warn` 即可),**不 toast**,下一轮继续。
- `401` 仍走现有 `http` 拦截器(登出 / 跳转)。
3. `onUnmounted` / `pagehide`:`clearInterval`;用页面级 `disposed`/`generation` 忽略卸载后才返回的轮询结果(避免卸载后无意义的续跑逻辑;store 已被 `load()` 写入可保留,不必回滚)。
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 + `console.warn`**(不触发全量 `load`,避免详情页单独制造全表风暴)。
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` |
| `src/views/MonitorView/MonitorView.vue` | 5s 轮询 + visibility + 与 `reload` 共用 in-flight |
| `src/views/DeviceDetailView/DeviceDetailView.vue` | 当前 id 详情轮询 + id watch + visibility |
完成后:实现计划 → 编码 → 构建部署 → 人工看监控与详情是否按 5s 变。

129
src/stores/modules/devicesStore.js

@ -125,6 +125,24 @@ function project(lon, lat, bounds) {
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 } return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }
} }
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 }
}
export const useDeviceStore = defineStore('devices', () => { export const useDeviceStore = defineStore('devices', () => {
const assets = reactive({}) const assets = reactive({})
const docks = ref([]) const docks = ref([])
@ -187,12 +205,7 @@ export const useDeviceStore = defineStore('devices', () => {
const rt = detail.realtime || {} const rt = detail.realtime || {}
const online = Boolean(detail.online) const online = Boolean(detail.online)
const registerStatus = d.registerStatus || 'registered' const registerStatus = d.registerStatus || 'registered'
const pending = registerStatus === 'pending' && !online
const st = pending
? { status: '待连接', statusClass: 'pending' }
: online
? alarmCodes(rt).length ? { status: '告警', statusClass: 'alarm' } : DOCK_STATUS.online
: DOCK_STATUS.offline
const st = dockStatusFromDetail(registerStatus, online, rt)
const name = d.name || d.dockId const name = d.name || d.dockId
const environment = environmentValues(rt) const environment = environmentValues(rt)
return { return {
@ -226,15 +239,9 @@ export const useDeviceStore = defineStore('devices', () => {
const id = String(d.id) const id = String(d.id)
const detail = droneDetails[i] || {} const detail = droneDetails[i] || {}
const rt = detail.realtime || {} const rt = detail.realtime || {}
const armed = ['1', 'true', 1, true].includes(rt.armed)
const altitude = coordinate(rt.altitude, -1000, 100000)
const flying = armed || (altitude != null && altitude > 0)
const online = Boolean(detail.online) const online = Boolean(detail.online)
const st = !online
? { status: '离线', statusClass: 'offline' }
: flying
? { status: '飞行中', statusClass: 'mission' }
: { status: '在巢', statusClass: 'online' }
const st = droneStatusFromDetail(online, rt)
const altitude = st.altitude
const dock = newDocks.find((dk) => dk.dockId === d.dockId) const dock = newDocks.find((dk) => dk.dockId === d.dockId)
const battery = rt.batteryPercent ?? rt.batteryPct ?? d.battery const battery = rt.batteryPercent ?? rt.batteryPct ?? d.battery
return { return {
@ -315,5 +322,97 @@ export const useDeviceStore = defineStore('devices', () => {
return { docks: newDocks, drones: newDrones } return { docks: newDocks, drones: newDrones }
} }
return { assets, docks, drones, loaded, asset, record, commandType, sendCommand, load }
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
}
return { assets, docks, drones, loaded, asset, record, commandType, sendCommand, load, applyDockDetail, applyDroneDetail }
}) })

83
src/views/DeviceDetailView/DeviceDetailView.vue

@ -295,7 +295,7 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useDeviceStore } from '@/stores/modules/devicesStore' import { useDeviceStore } from '@/stores/modules/devicesStore'
import { useUiStore } from '@/stores/modules/uiStore' import { useUiStore } from '@/stores/modules/uiStore'
@ -345,6 +345,69 @@ const logColumns = [
const record = computed(() => devices.record(route.params.id)) const record = computed(() => devices.record(route.params.id))
const asset = computed(() => devices.asset(route.params.id)) const asset = computed(() => devices.asset(route.params.id))
const isDrone = computed(() => record.value?.kind === 'drone') const isDrone = computed(() => record.value?.kind === 'drone')
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()
}
const boundDrone = computed(() => (record.value?.kind === 'dock' ? devices.drones.find((d) => d.dockId === record.value.dockId) : null)) const boundDrone = computed(() => (record.value?.kind === 'dock' ? devices.drones.find((d) => d.dockId === record.value.dockId) : null))
const boundDock = computed(() => (record.value?.kind === 'drone' ? devices.docks.find((d) => d.dockId === record.value.dockId) : null)) const boundDock = computed(() => (record.value?.kind === 'drone' ? devices.docks.find((d) => d.dockId === record.value.dockId) : null))
const latestLog = computed(() => logs.value[0] || null) const latestLog = computed(() => logs.value[0] || null)
@ -486,6 +549,8 @@ function chargeText(value) {
} }
onMounted(async () => { onMounted(async () => {
detailDisposed = false
document.addEventListener('visibilitychange', onDetailVisibility)
try { try {
await devices.load() await devices.load()
if (!isAdmin.value) { if (!isAdmin.value) {
@ -494,8 +559,24 @@ onMounted(async () => {
} catch (e) { } catch (e) {
ui.toast(e.message || '加载设备失败') 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()
}
)
const COMMAND_NAMES = { const COMMAND_NAMES = {
'dock.open': '打开舱门', 'dock.open': '打开舱门',

77
src/views/MonitorView/MonitorView.vue

@ -137,6 +137,7 @@ const {
}) })
function onPageHide() { function onPageHide() {
stopDevicePolling()
disposeLive({ keepalive: true }) disposeLive({ keepalive: true })
} }
@ -146,6 +147,62 @@ const controlExpanded = ref(false)
const dockStatusOpen = ref(false) const dockStatusOpen = ref(false)
const selectedDockIds = reactive(new Set()) const selectedDockIds = reactive(new Set())
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()
}
let mapInstance = null let mapInstance = null
let layersReady = false let layersReady = false
let pendingLoadHandler = null let pendingLoadHandler = null
@ -660,8 +717,10 @@ function teardownMap() {
} }
onMounted(async () => { onMounted(async () => {
monitorDisposed = false
window.addEventListener('pagehide', onPageHide) window.addEventListener('pagehide', onPageHide)
window.addEventListener('keydown', onGlobalKeydown) window.addEventListener('keydown', onGlobalKeydown)
document.addEventListener('visibilitychange', onDevicePollVisibility)
try { try {
await devices.load() await devices.load()
if (route.query.deviceId && devices.record(route.query.deviceId)) selectedId.value = String(route.query.deviceId) if (route.query.deviceId && devices.record(route.query.deviceId)) selectedId.value = String(route.query.deviceId)
@ -670,6 +729,7 @@ onMounted(async () => {
} catch (e) { } catch (e) {
ui.toast(e.message || '加载设备失败') ui.toast(e.message || '加载设备失败')
} }
startDevicePolling()
try { try {
await setupMap() await setupMap()
} catch (e) { } catch (e) {
@ -678,6 +738,10 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
monitorDisposed = true
devicePollGen += 1
stopDevicePolling()
document.removeEventListener('visibilitychange', onDevicePollVisibility)
window.removeEventListener('pagehide', onPageHide) window.removeEventListener('pagehide', onPageHide)
window.removeEventListener('keydown', onGlobalKeydown) window.removeEventListener('keydown', onGlobalKeydown)
closeCommandProgress() closeCommandProgress()
@ -1042,19 +1106,10 @@ function onGlobalKeydown(event) {
async function reload() { async function reload() {
try {
await devices.load()
if (selectedId.value && !devices.record(selectedId.value)) clearSelection()
selectedDockIds.forEach((id) => {
if (!devices.docks.some((dock) => dock.id === id)) selectedDockIds.delete(id)
})
syncMarkers()
await pollDevices({ toastOnSuccess: true })
if (monitorDisposed) return
if (selectedId.value) flyToSelected() if (selectedId.value) flyToSelected()
else resetOverviewCamera() else resetOverviewCamera()
ui.toast('设备状态已刷新')
} catch (e) {
ui.toast(e.message || '刷新设备状态失败')
}
} }
const groupedDocks = computed(() => const groupedDocks = computed(() =>

Loading…
Cancel
Save