From bdd6e21e293b6ecfc4dfdb077a5d2adce125f950 Mon Sep 17 00:00:00 2001 From: xiaosi <2652281683@qq.com> Date: Fri, 28 Aug 2026 14:56:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=91=E6=8E=A7=E6=8C=87=E4=BB=A4?= =?UTF-8?q?=E4=B8=8B=E5=8F=91=E5=90=8E=E8=BD=AE=E8=AF=A2=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/MonitorView/MonitorView.vue | 266 ++++++++++++++++++++++++-- 1 file changed, 247 insertions(+), 19 deletions(-) diff --git a/src/views/MonitorView/MonitorView.vue b/src/views/MonitorView/MonitorView.vue index 4f8dd64..37b4b09 100644 --- a/src/views/MonitorView/MonitorView.vue +++ b/src/views/MonitorView/MonitorView.vue @@ -368,8 +368,12 @@ import { useUserStore } from '@/stores/modules/userStore' import commonRefs from '@/utils/commonRefs' import mapHelper from '@/core/mapHelper' import mapConfig, { MAP_STYLES, MAP_OVERVIEW, MAP_VIEW } from '@/config/map' +import { applyChineseLabels } from '@/utils/mapLabels' import LivePlayer from '@/components/LivePlayer.vue' 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_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) { if (!map) return const jobs = [] @@ -498,22 +522,41 @@ async function ensurePinImages(map) { if (map.hasImage(id)) continue const url = `/assets/icons/${id}.svg` 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 }) } - resolve() }) - }), + .catch((err) => { + console.warn('[Monitor] pin image failed:', id, err) + }), ) } } 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() { - 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) if (!mapInstance.getSource(DEVICE_SOURCE)) { @@ -559,10 +602,10 @@ async function ensureMonitorLayers() { layout: { 'icon-image': ['get', 'icon'], '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-allow-overlap': true, @@ -676,6 +719,7 @@ async function rebuildAfterStyle() { } catch (_) { /* ignore */ } + applyChineseLabels(mapInstance) layersReady = false await ensureMonitorLayers() syncMarkers() @@ -737,13 +781,42 @@ function flyToSelected() { 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() { - const map = await commonRefs.getRef('map') + const map = await waitForMap() if (!map) { ui.toast('地图未初始化(缺少 Mapbox token)') return } mapInstance = map + if (typeof window !== 'undefined') window.__LAIC_MAP__ = map mapHelper.toggleMapMode({ is2D: true }) updateMapScale() @@ -753,11 +826,25 @@ async function setupMap() { readyStarted = true pendingLoadHandler = null if (!mapInstance) return - await ensureMonitorLayers() - syncMarkers() - if (selectedId.value) flyToSelected() - else resetOverviewCamera() - updateMapScale() + try { + 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() + if (selectedId.value) flyToSelected() + else resetOverviewCamera() + updateMapScale() + } catch (err) { + readyStarted = false + console.error('[Monitor] ensure layers failed:', err) + ui.toast(err?.message || '机巢图层初始化失败') + } } cancelPendingMapHandlers() @@ -829,6 +916,7 @@ function teardownMap() { clearMonitorLayers() } mapInstance = null + if (typeof window !== 'undefined' && window.__LAIC_MAP__) delete window.__LAIC_MAP__ layersReady = false commonRefs.clearPendingList('map') } @@ -852,6 +940,7 @@ onMounted(async () => { onUnmounted(() => { window.removeEventListener('keydown', onGlobalKeydown) + closeCommandProgress() closeLive() 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) { @@ -1154,6 +1248,10 @@ function closeDockStatus() { } function onGlobalKeydown(event) { + if (event.key === 'Escape' && commandProgress.open) { + closeCommandProgress() + return + } if (event.key === 'Escape' && dockStatusOpen.value) closeDockStatus() } @@ -1270,6 +1368,135 @@ function zoomOut() { 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) { const rec = record.value if (!rec) return @@ -1284,8 +1511,9 @@ async function runCommand(title, desc) { const ok = await ui.confirm(title, desc) if (!ok) return try { - await devices.sendCommand(rec, title) + const cmd = await devices.sendCommand(rec, title) ui.toast(`${title}指令已下发`) + startCommandProgress(cmd, title, rec.name) await reload() } catch (e) { ui.toast(e.message || '指令下发失败')