Browse Source

feat: 监控指令下发后轮询命令状态

main
xiaosi 3 weeks ago
parent
commit
bdd6e21e29
  1. 254
      src/views/MonitorView/MonitorView.vue

254
src/views/MonitorView/MonitorView.vue

@ -368,8 +368,12 @@ import { useUserStore } from '@/stores/modules/userStore'
import commonRefs from '@/utils/commonRefs' import commonRefs from '@/utils/commonRefs'
import mapHelper from '@/core/mapHelper' import mapHelper from '@/core/mapHelper'
import mapConfig, { MAP_STYLES, MAP_OVERVIEW, MAP_VIEW } from '@/config/map' import mapConfig, { MAP_STYLES, MAP_OVERVIEW, MAP_VIEW } from '@/config/map'
import { applyChineseLabels } from '@/utils/mapLabels'
import LivePlayer from '@/components/LivePlayer.vue' import LivePlayer from '@/components/LivePlayer.vue'
import { getLivePlayURL, heartbeatLive, joinLive, leaveLive } from '@/api/live' import { getLivePlayURL, heartbeatLive, joinLive, leaveLive } from '@/api/live'
import request from '@/utils/http'
import * as urls from '@/config/urls'
const DEVICE_SOURCE = 'monitor-devices' const DEVICE_SOURCE = 'monitor-devices'
const DEVICE_SYMBOL = 'monitor-devices-symbol' const DEVICE_SYMBOL = 'monitor-devices-symbol'
@ -489,6 +493,26 @@ function bindingFeatureCollection() {
} }
} }
async function loadPinImageData(url) {
const img = await new Promise((resolve, reject) => {
const el = new Image()
el.onload = () => resolve(el)
el.onerror = () => reject(new Error(`图标加载失败: ${url}`))
el.src = url
})
// viewBox SVG naturalWidth/Height 0
const width = img.naturalWidth || img.width || 48
const height = img.naturalHeight || img.height || 64
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('无法创建 canvas 上下文')
ctx.clearRect(0, 0, width, height)
ctx.drawImage(img, 0, 0, width, height)
return ctx.getImageData(0, 0, width, height)
}
async function ensurePinImages(map) { async function ensurePinImages(map) {
if (!map) return if (!map) return
const jobs = [] const jobs = []
@ -498,13 +522,16 @@ async function ensurePinImages(map) {
if (map.hasImage(id)) continue if (map.hasImage(id)) continue
const url = `/assets/icons/${id}.svg` const url = `/assets/icons/${id}.svg`
jobs.push( jobs.push(
new Promise((resolve) => {
map.loadImage(url, (err, image) => {
if (!err && image && !map.hasImage(id)) {
loadPinImageData(url)
.then((image) => {
if (!map.hasImage(id)) {
// Mapbox loadImage SVG createImageBitmap
// ImageData addImage
map.addImage(id, image, { pixelRatio: 2 }) map.addImage(id, image, { pixelRatio: 2 })
} }
resolve()
}) })
.catch((err) => {
console.warn('[Monitor] pin image failed:', id, err)
}), }),
) )
} }
@ -512,8 +539,24 @@ async function ensurePinImages(map) {
await Promise.all(jobs) await Promise.all(jobs)
} }
async function waitStyleLoaded(map, timeoutMs = 8000) {
if (!map) return false
if (map.isStyleLoaded()) return true
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (map.isStyleLoaded()) return true
await new Promise((resolve) => setTimeout(resolve, 50))
}
return !!map.isStyleLoaded()
}
async function ensureMonitorLayers() { async function ensureMonitorLayers() {
if (!mapInstance || !mapInstance.isStyleLoaded()) return
if (!mapInstance) return
const styleReady = await waitStyleLoaded(mapInstance)
if (!styleReady) {
console.warn('[Monitor] map style not ready, skip layer ensure')
return
}
await ensurePinImages(mapInstance) await ensurePinImages(mapInstance)
if (!mapInstance.getSource(DEVICE_SOURCE)) { if (!mapInstance.getSource(DEVICE_SOURCE)) {
@ -559,10 +602,10 @@ async function ensureMonitorLayers() {
layout: { layout: {
'icon-image': ['get', 'icon'], 'icon-image': ['get', 'icon'],
'icon-size': [ 'icon-size': [
'case',
['==', ['get', 'selected'], 1],
1.25,
1,
'interpolate', ['linear'], ['zoom'],
1.5, ['case', ['==', ['get', 'selected'], 1], 0.95, 0.8],
4, ['case', ['==', ['get', 'selected'], 1], 1.15, 0.95],
10, ['case', ['==', ['get', 'selected'], 1], 1.25, 1],
], ],
'icon-anchor': 'bottom', 'icon-anchor': 'bottom',
'icon-allow-overlap': true, 'icon-allow-overlap': true,
@ -676,6 +719,7 @@ async function rebuildAfterStyle() {
} catch (_) { } catch (_) {
/* ignore */ /* ignore */
} }
applyChineseLabels(mapInstance)
layersReady = false layersReady = false
await ensureMonitorLayers() await ensureMonitorLayers()
syncMarkers() syncMarkers()
@ -737,13 +781,42 @@ function flyToSelected() {
updateMapScale() updateMapScale()
} }
async function waitForMap(timeoutMs = 8000) {
const readMap = () => {
const el = typeof document !== 'undefined' ? document.getElementById('map') : null
if (el?.__laicMap) return el.__laicMap
const fromHelper = mapHelper.getMap()
if (fromHelper) return fromHelper
if (typeof window !== 'undefined' && window.__LAIC_MAPBOX_MAP__) return window.__LAIC_MAPBOX_MAP__
return null
}
const immediate = readMap()
if (immediate) return immediate
const fromRefs = await Promise.race([
commonRefs.getRef('map'),
new Promise((resolve) => setTimeout(() => resolve(null), Math.min(1500, timeoutMs))),
])
if (fromRefs) return fromRefs
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const mapped = readMap()
if (mapped) return mapped
await new Promise((resolve) => setTimeout(resolve, 50))
}
return null
}
async function setupMap() { async function setupMap() {
const map = await commonRefs.getRef('map')
const map = await waitForMap()
if (!map) { if (!map) {
ui.toast('地图未初始化(缺少 Mapbox token)') ui.toast('地图未初始化(缺少 Mapbox token)')
return return
} }
mapInstance = map mapInstance = map
if (typeof window !== 'undefined') window.__LAIC_MAP__ = map
mapHelper.toggleMapMode({ is2D: true }) mapHelper.toggleMapMode({ is2D: true })
updateMapScale() updateMapScale()
@ -753,11 +826,25 @@ async function setupMap() {
readyStarted = true readyStarted = true
pendingLoadHandler = null pendingLoadHandler = null
if (!mapInstance) return if (!mapInstance) return
try {
await ensureMonitorLayers() await ensureMonitorLayers()
if (!layersReady) {
// style/HMR readyStarted
for (let i = 0; i < 20 && !layersReady; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (!mapInstance) return
await ensureMonitorLayers()
}
}
syncMarkers() syncMarkers()
if (selectedId.value) flyToSelected() if (selectedId.value) flyToSelected()
else resetOverviewCamera() else resetOverviewCamera()
updateMapScale() updateMapScale()
} catch (err) {
readyStarted = false
console.error('[Monitor] ensure layers failed:', err)
ui.toast(err?.message || '机巢图层初始化失败')
}
} }
cancelPendingMapHandlers() cancelPendingMapHandlers()
@ -829,6 +916,7 @@ function teardownMap() {
clearMonitorLayers() clearMonitorLayers()
} }
mapInstance = null mapInstance = null
if (typeof window !== 'undefined' && window.__LAIC_MAP__) delete window.__LAIC_MAP__
layersReady = false layersReady = false
commonRefs.clearPendingList('map') commonRefs.clearPendingList('map')
} }
@ -852,6 +940,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', onGlobalKeydown) window.removeEventListener('keydown', onGlobalKeydown)
closeCommandProgress()
closeLive() closeLive()
teardownMap() teardownMap()
}) })
@ -958,8 +1047,13 @@ async function closeLive(sendRequest = true) {
} }
function displayWithUnit(value, unit) {
return value == null || value === '' ? '--' : `${value}${unit}`
function displayWithUnit(value, unit, digits) {
if (value == null || value === '') return '--'
const num = Number(value)
if (Number.isFinite(num) && digits != null) {
return `${num.toFixed(digits)}${unit}`
}
return `${value}${unit}`
} }
function boolText(value) { function boolText(value) {
@ -1154,6 +1248,10 @@ function closeDockStatus() {
} }
function onGlobalKeydown(event) { function onGlobalKeydown(event) {
if (event.key === 'Escape' && commandProgress.open) {
closeCommandProgress()
return
}
if (event.key === 'Escape' && dockStatusOpen.value) closeDockStatus() if (event.key === 'Escape' && dockStatusOpen.value) closeDockStatus()
} }
@ -1270,6 +1368,135 @@ function zoomOut() {
mapInstance.zoomTo(Math.max(mapInstance.getMinZoom(), mapInstance.getZoom() - 1), { duration: 200 }) mapInstance.zoomTo(Math.max(mapInstance.getMinZoom(), mapInstance.getZoom() - 1), { duration: 200 })
} }
const COMMAND_STATUS = {
sent: { name: '已下发', step: 2, result: '等待应答', terminal: false },
acked: { name: '已确认', step: 3, result: '执行成功', terminal: true, ok: true },
timeout: { name: '已超时', step: 3, result: '已超时', terminal: true, ok: false },
terminal: { name: '执行失败', step: 3, result: '执行失败', terminal: true, ok: false },
}
const commandProgress = reactive({
open: false,
title: '',
deviceName: '',
commandId: '',
status: '', // sent/acked/timeout/terminal
result: '',
step: 1, // 1 2 3
error: '',
buttonTitle: '',
})
let commandPollTimer = null
let commandAutoCloseTimer = null
let commandPollFails = 0
function buttonStatusText(title) {
if (commandProgress.buttonTitle !== title || !commandProgress.status) return '状态:待执行'
const meta = COMMAND_STATUS[commandProgress.status]
return meta ? `状态:${meta.name}` : '状态:待执行'
}
function clearCommandTimers() {
if (commandPollTimer) {
clearInterval(commandPollTimer)
commandPollTimer = null
}
if (commandAutoCloseTimer) {
clearTimeout(commandAutoCloseTimer)
commandAutoCloseTimer = null
}
}
function closeCommandProgress() {
clearCommandTimers()
commandProgress.open = false
}
function applyCommandStatus(cmd) {
const status = cmd?.status || 'sent'
const meta = COMMAND_STATUS[status] || COMMAND_STATUS.sent
commandProgress.status = status
commandProgress.step = meta.step
commandProgress.result = cmd?.ackResultCode || meta.result
commandProgress.error = ''
return meta
}
function scheduleAutoClose() {
if (commandAutoCloseTimer) clearTimeout(commandAutoCloseTimer)
commandAutoCloseTimer = setTimeout(() => {
closeCommandProgress()
}, 5000)
}
async function pollCommandOnce() {
if (!commandProgress.commandId) return
try {
const cmd = await request.get(urls.COMMAND(commandProgress.commandId))
commandPollFails = 0
const meta = applyCommandStatus(cmd)
if (meta.terminal) {
clearInterval(commandPollTimer)
commandPollTimer = null
scheduleAutoClose()
}
} catch (e) {
commandPollFails += 1
const status = e?.response?.status
if (status === 403 || status === 404 || commandPollFails >= 3) {
clearInterval(commandPollTimer)
commandPollTimer = null
commandProgress.error =
status === 403
? '无权限查看进度,请稍后在操作记录确认'
: status === 404
? '未找到指令记录'
: (e.message || '进度查询失败')
scheduleAutoClose()
}
}
}
function startCommandProgress(cmd, title, deviceName) {
clearCommandTimers()
commandPollFails = 0
commandProgress.open = true
commandProgress.title = title
commandProgress.deviceName = deviceName || '--'
commandProgress.commandId = String(cmd?.id || '')
commandProgress.buttonTitle = title
commandProgress.step = 1
commandProgress.error = ''
applyCommandStatus(cmd || { status: 'sent' })
if (!commandProgress.commandId) {
commandProgress.error = '未返回指令 ID,无法跟踪进度'
scheduleAutoClose()
return
}
const ttl = Number(cmd?.ttlMs) > 0 ? Number(cmd.ttlMs) : 30000
const deadline = Date.now() + Math.max(ttl, 30000) + 5000
commandPollTimer = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(commandPollTimer)
commandPollTimer = null
if (!COMMAND_STATUS[commandProgress.status]?.terminal) {
commandProgress.error = '等待超时,请稍后在操作记录查看'
commandProgress.step = 3
}
scheduleAutoClose()
return
}
await pollCommandOnce()
}, 1500)
// 1.5s
pollCommandOnce()
}
async function runCommand(title, desc) { async function runCommand(title, desc) {
const rec = record.value const rec = record.value
if (!rec) return if (!rec) return
@ -1284,8 +1511,9 @@ async function runCommand(title, desc) {
const ok = await ui.confirm(title, desc) const ok = await ui.confirm(title, desc)
if (!ok) return if (!ok) return
try { try {
await devices.sendCommand(rec, title)
const cmd = await devices.sendCommand(rec, title)
ui.toast(`${title}指令已下发`) ui.toast(`${title}指令已下发`)
startCommandProgress(cmd, title, rec.name)
await reload() await reload()
} catch (e) { } catch (e) {
ui.toast(e.message || '指令下发失败') ui.toast(e.message || '指令下发失败')

Loading…
Cancel
Save