|
|
@ -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,22 +522,41 @@ 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) |
|
|
|
|
|
}), |
|
|
) |
|
|
) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
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 |
|
|
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() |
|
|
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 || '指令下发失败') |
|
|
|