You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1364 lines
43 KiB
1364 lines
43 KiB
<template>
|
|
<section class="monitor-layout" :class="{ focused: !!selectedId, 'control-expanded': controlExpanded }">
|
|
<MonitorDeviceList
|
|
:dock-count="devices.docks.length"
|
|
:drone-count="devices.drones.length"
|
|
v-model:search="search"
|
|
v-model:filter="filter"
|
|
:filter-counts="filterCounts"
|
|
:docks="filteredDocks"
|
|
:selected-dock-ids="selectedDockIds"
|
|
:selected-id="selectedId"
|
|
:is-dock-active="isDockCardActive"
|
|
:altitude-of="droneAltitude"
|
|
@clear-selection="clearSelection"
|
|
@select-all="selectAllDocks"
|
|
@clear-dock-selection="clearDockSelection"
|
|
@select="select"
|
|
@toggle-select="setDockSelection"
|
|
/>
|
|
|
|
<div class="map-panel">
|
|
<MonitorMapChrome
|
|
:map-mode="mapMode"
|
|
:scale-text="mapScaleText"
|
|
@set-map-mode="setMapMode"
|
|
@fit-all="fitAll"
|
|
>
|
|
<CommandProgressFloat
|
|
:open="commandProgress.open"
|
|
:title="commandProgress.title"
|
|
:device-name="commandProgress.deviceName"
|
|
:step="commandProgress.step"
|
|
:status="commandProgress.status"
|
|
:result="commandProgress.result"
|
|
:error="commandProgress.error"
|
|
:terminal-label="commandProgressTerminalLabel"
|
|
@close="closeCommandProgress"
|
|
/>
|
|
</MonitorMapChrome>
|
|
</div>
|
|
|
|
<MonitorDetailPanel
|
|
ref="detailPanelRef"
|
|
:selected="!!selectedAsset"
|
|
:header="detailHeader"
|
|
:relation="detailRelation"
|
|
:live-view="detailLiveView"
|
|
:dock-status="detailDockStatus"
|
|
:flight-status="detailFlightStatus"
|
|
:mission="detailMission"
|
|
:environment="detailEnvironment"
|
|
:actions="detailActions"
|
|
v-model:control-expanded="controlExpanded"
|
|
@close="clearSelection"
|
|
@open-device-detail="goDetail"
|
|
@select-parent="select"
|
|
@toggle-live="toggleLive"
|
|
@fullscreen-live="fullscreenLive"
|
|
@live-error="ui.toast('视频播放失败')"
|
|
@open-dock-status="openDockStatus"
|
|
@open-mission-detail="goMissionDetail"
|
|
@command="onDetailCommand"
|
|
/>
|
|
</section>
|
|
|
|
|
|
<DockStatusDialog
|
|
v-model:visible="dockStatusOpen"
|
|
:title="dockStatusTitle"
|
|
:summary="dockStatusSummary"
|
|
:charge-active="chargeTone"
|
|
:updated-text="dockStatusUpdatedText"
|
|
:board="dockStatusBoard"
|
|
@close="closeDockStatus"
|
|
/>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useDeviceStore } from '@/stores/modules/devicesStore'
|
|
import { useUiStore } from '@/stores/modules/uiStore'
|
|
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 MonitorDetailPanel from '@/components/MonitorDetailPanel.vue'
|
|
import MonitorDeviceList from '@/components/MonitorDeviceList.vue'
|
|
import DockStatusDialog from '@/components/DockStatusDialog.vue'
|
|
import CommandProgressFloat from '@/components/CommandProgressFloat.vue'
|
|
import MonitorMapChrome from '@/components/MonitorMapChrome.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'
|
|
const DEVICE_HALO = 'monitor-devices-halo'
|
|
const DEVICE_LABEL = 'monitor-devices-label'
|
|
const BINDING_SOURCE = 'monitor-binding'
|
|
const BINDING_LINE = 'monitor-binding-line'
|
|
|
|
const PIN_KINDS = ['dock', 'drone']
|
|
const PIN_STATUSES = ['online', 'mission', 'alarm', 'offline', 'pending']
|
|
|
|
function pinImageId(kind, status) {
|
|
const s = PIN_STATUSES.includes(status) ? status : 'offline'
|
|
const k = PIN_KINDS.includes(kind) ? kind : 'dock'
|
|
return `${k}-pin-${s}`
|
|
}
|
|
|
|
const devices = useDeviceStore()
|
|
const ui = useUiStore()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const userStore = useUserStore()
|
|
const isAdmin = computed(() => userStore.isAdmin())
|
|
|
|
const selectedId = ref(null)
|
|
const live = reactive({ session: null, dockId: '', playUrl: '', heartbeatTimer: null })
|
|
const detailPanelRef = ref(null)
|
|
|
|
const filter = ref('all')
|
|
const search = ref('')
|
|
const controlExpanded = ref(false)
|
|
const dockStatusOpen = ref(false)
|
|
const selectedDockIds = reactive(new Set())
|
|
|
|
let mapInstance = null
|
|
let layersReady = false
|
|
let pendingLoadHandler = null
|
|
let pendingStyleHandler = null
|
|
const mapMode = ref('satellite')
|
|
const mapScaleText = ref('2 km')
|
|
let suppressMapClick = false
|
|
|
|
const dockExtraCommands = [
|
|
{ title: '一键起飞', desc: '无人机将从机巢起飞并进入待命状态。', icon: '#i-plane', hint: '下发指令' },
|
|
{ title: '一键降落', desc: '无人机将返回机巢并自动降落。', icon: '#i-home', hint: '下发指令' },
|
|
{ title: '起飞准备', desc: '将执行开舱并解除归中。', icon: '#i-plane', hint: '下发指令' },
|
|
{ title: '降落准备', desc: '将开舱并等待无人机返航。', icon: '#i-home', hint: '下发指令' },
|
|
{ title: '打开舱门', desc: '将打开机巢舱门。', icon: '#i-door', hint: '下发指令' },
|
|
{ title: '关闭舱门', desc: '将关闭机巢舱门。', icon: '#i-door', hint: '下发指令' },
|
|
{ title: '开始充电', desc: '将连接充电回路。', icon: '#i-zap', hint: '下发指令' },
|
|
{ title: '无人机开机', desc: '将开启舱内无人机电源。', icon: '#i-zap', hint: '下发指令' },
|
|
{ title: '无人机关机', desc: '将关闭舱内无人机电源。', icon: '#i-zap', hint: '下发指令' },
|
|
{ title: '整机复位', desc: '机巢将恢复待命状态。', icon: '#i-settings', hint: '下发指令' },
|
|
{ title: '远程重启', desc: '设备将短暂离线后重新连接。', icon: '#i-settings', hint: '下发指令' },
|
|
{ title: '清除设备告警', desc: '将清除机巢当前未处理告警。', icon: '#i-check', hint: '下发指令' }
|
|
]
|
|
|
|
const droneExtraCommands = [
|
|
{ title: '起飞', desc: '无人机将立即起飞进入待命状态。', icon: '#i-plane', hint: '离巢待命' },
|
|
{ title: '降落', desc: '无人机将就地降落。', icon: '#i-home', hint: '就地降落' },
|
|
{ title: '一键返航', desc: '无人机将返回绑定机巢并自动降落。', icon: '#i-home', hint: '自动回巢' },
|
|
{ title: '悬停', desc: '无人机将在当前位置悬停。', icon: '#i-stop', hint: '保持位置' },
|
|
{ title: '开始任务', desc: '向绑定机巢下发开始任务指令。', icon: '#i-play', hint: '下发指令' },
|
|
{ title: '暂停任务', desc: '向绑定机巢下发暂停指令。', icon: '#i-stop', hint: '下发指令' },
|
|
{ title: '继续任务', desc: '向绑定机巢下发继续指令。', icon: '#i-play', hint: '下发指令' },
|
|
{ title: '取消任务', desc: '向绑定机巢下发取消指令。', icon: '#i-x', hint: '下发指令' },
|
|
{ title: '紧急停止', desc: '立即中止飞行,无人机原地悬停待命。', icon: '#i-stop', hint: '原地悬停', danger: true }
|
|
]
|
|
|
|
const mapMarkers = computed(() =>
|
|
Object.values(devices.assets).filter((a) => a.hasCoordinates && !a.inDock)
|
|
)
|
|
|
|
|
|
function devicesFeatureCollection() {
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: mapMarkers.value
|
|
.map((asset) => {
|
|
const lon = Number(asset.longitude)
|
|
const lat = Number(asset.latitude)
|
|
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null
|
|
const selected = selectedId.value === asset.id
|
|
const status = asset.statusClass || 'offline'
|
|
return {
|
|
type: 'Feature',
|
|
// asset.id 多为字符串,省略 numeric Feature.id,靠 setData 全量刷新
|
|
properties: {
|
|
assetId: String(asset.id),
|
|
name: asset.name || '',
|
|
kind: asset.type,
|
|
status,
|
|
selected: selected ? 1 : 0,
|
|
icon: pinImageId(asset.type, status),
|
|
},
|
|
geometry: { type: 'Point', coordinates: [lon, lat] },
|
|
}
|
|
})
|
|
.filter(Boolean),
|
|
}
|
|
}
|
|
|
|
function bindingFeatureCollection() {
|
|
const ends = bindingEndpoints()
|
|
if (!ends) {
|
|
return { type: 'FeatureCollection', features: [] }
|
|
}
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: [
|
|
{
|
|
type: 'Feature',
|
|
properties: { label: '绑定设备' },
|
|
geometry: { type: 'LineString', coordinates: ends },
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
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 = []
|
|
for (const kind of PIN_KINDS) {
|
|
for (const status of PIN_STATUSES) {
|
|
const id = pinImageId(kind, status)
|
|
if (map.hasImage(id)) continue
|
|
const url = `/assets/icons/${id}.svg`
|
|
jobs.push(
|
|
loadPinImageData(url)
|
|
.then((image) => {
|
|
if (!map.hasImage(id)) {
|
|
// Mapbox loadImage 对 SVG 常因 createImageBitmap 失败而静默跳过;
|
|
// 先栅格成 ImageData 再 addImage,兼容稳定。
|
|
map.addImage(id, image, { pixelRatio: 2 })
|
|
}
|
|
})
|
|
.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) 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)) {
|
|
mapInstance.addSource(DEVICE_SOURCE, { type: 'geojson', data: devicesFeatureCollection() })
|
|
}
|
|
if (!mapInstance.getSource(BINDING_SOURCE)) {
|
|
mapInstance.addSource(BINDING_SOURCE, { type: 'geojson', data: bindingFeatureCollection() })
|
|
}
|
|
|
|
if (!mapInstance.getLayer(BINDING_LINE)) {
|
|
mapInstance.addLayer({
|
|
id: BINDING_LINE,
|
|
type: 'line',
|
|
source: BINDING_SOURCE,
|
|
paint: {
|
|
'line-color': 'rgba(117,191,246,0.95)',
|
|
'line-width': 2,
|
|
'line-dasharray': [2, 1.5],
|
|
'line-opacity': 0.95,
|
|
},
|
|
})
|
|
}
|
|
|
|
if (!mapInstance.getLayer(DEVICE_HALO)) {
|
|
mapInstance.addLayer({
|
|
id: DEVICE_HALO,
|
|
type: 'circle',
|
|
source: DEVICE_SOURCE,
|
|
filter: ['==', ['get', 'selected'], 1],
|
|
paint: {
|
|
'circle-radius': 22,
|
|
'circle-color': 'rgba(45,130,218,0.22)',
|
|
'circle-stroke-width': 0,
|
|
},
|
|
})
|
|
}
|
|
|
|
if (!mapInstance.getLayer(DEVICE_SYMBOL)) {
|
|
mapInstance.addLayer({
|
|
id: DEVICE_SYMBOL,
|
|
type: 'symbol',
|
|
source: DEVICE_SOURCE,
|
|
layout: {
|
|
'icon-image': ['get', 'icon'],
|
|
'icon-size': [
|
|
'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,
|
|
'icon-ignore-placement': true,
|
|
},
|
|
})
|
|
}
|
|
|
|
if (!mapInstance.getLayer(DEVICE_LABEL)) {
|
|
mapInstance.addLayer({
|
|
id: DEVICE_LABEL,
|
|
type: 'symbol',
|
|
source: DEVICE_SOURCE,
|
|
minzoom: 4,
|
|
layout: {
|
|
'text-field': ['get', 'name'],
|
|
'text-size': 11,
|
|
'text-offset': [0, 0.6],
|
|
'text-anchor': 'top',
|
|
'text-allow-overlap': false,
|
|
},
|
|
paint: {
|
|
'text-color': '#fff',
|
|
'text-halo-color': 'rgba(24,35,45,.86)',
|
|
'text-halo-width': 1.2,
|
|
},
|
|
})
|
|
}
|
|
|
|
layersReady = true
|
|
}
|
|
|
|
function clearMonitorLayers() {
|
|
if (!mapInstance) return
|
|
for (const id of [DEVICE_LABEL, DEVICE_SYMBOL, DEVICE_HALO, BINDING_LINE]) {
|
|
if (mapInstance.getLayer(id)) mapInstance.removeLayer(id)
|
|
}
|
|
for (const id of [DEVICE_SOURCE, BINDING_SOURCE]) {
|
|
if (mapInstance.getSource(id)) mapInstance.removeSource(id)
|
|
}
|
|
layersReady = false
|
|
}
|
|
|
|
function syncMarkers() {
|
|
if (!mapInstance || !layersReady) return
|
|
const src = mapInstance.getSource(DEVICE_SOURCE)
|
|
if (src) src.setData(devicesFeatureCollection())
|
|
syncBindingLine()
|
|
}
|
|
|
|
function updateMapScale() {
|
|
if (!mapInstance) return
|
|
const zoom = mapInstance.getZoom()
|
|
// rough WebMercator meters-per-pixel at equator * 100px bar
|
|
const meters = (156543.03392 / (2 ** zoom)) * 100
|
|
if (meters >= 1000) mapScaleText.value = `${Math.round(meters / 1000)} km`
|
|
else if (meters >= 100) mapScaleText.value = `${Math.round(meters / 10) * 10} m`
|
|
else mapScaleText.value = `${Math.max(1, Math.round(meters))} m`
|
|
}
|
|
|
|
function bindingEndpoints() {
|
|
if (!selectedId.value) return null
|
|
const asset = devices.asset(selectedId.value)
|
|
if (!asset) return null
|
|
let dockAsset = null
|
|
let droneAsset = null
|
|
if (asset.type === 'dock') {
|
|
dockAsset = asset
|
|
const dockRec = devices.record(asset.id)
|
|
const flying = devices.drones.find((d) => d.dockId === dockRec?.dockId && d.statusClass === 'mission')
|
|
droneAsset = flying ? devices.asset(flying.id) : null
|
|
} else {
|
|
droneAsset = asset.inDock ? null : asset
|
|
dockAsset = asset.parent ? devices.asset(asset.parent) : null
|
|
}
|
|
if (!dockAsset?.hasCoordinates || !droneAsset?.hasCoordinates || droneAsset.inDock) return null
|
|
const a = [Number(dockAsset.longitude), Number(dockAsset.latitude)]
|
|
const b = [Number(droneAsset.longitude), Number(droneAsset.latitude)]
|
|
if (!a.every(Number.isFinite) || !b.every(Number.isFinite)) return null
|
|
return [a, b]
|
|
}
|
|
|
|
function syncBindingLine() {
|
|
if (!mapInstance || !layersReady) return
|
|
const src = mapInstance.getSource(BINDING_SOURCE)
|
|
if (src) src.setData(bindingFeatureCollection())
|
|
}
|
|
|
|
function cancelPendingMapHandlers() {
|
|
if (!mapInstance) {
|
|
pendingLoadHandler = null
|
|
pendingStyleHandler = null
|
|
return
|
|
}
|
|
if (pendingLoadHandler) {
|
|
mapInstance.off('load', pendingLoadHandler)
|
|
mapInstance.off('style.load', pendingLoadHandler)
|
|
pendingLoadHandler = null
|
|
}
|
|
if (pendingStyleHandler) {
|
|
mapInstance.off('style.load', pendingStyleHandler)
|
|
pendingStyleHandler = null
|
|
}
|
|
}
|
|
|
|
async function rebuildAfterStyle() {
|
|
pendingStyleHandler = null
|
|
if (!mapInstance) return
|
|
try {
|
|
mapInstance.setProjection('globe')
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
applyChineseLabels(mapInstance)
|
|
layersReady = false
|
|
await ensureMonitorLayers()
|
|
syncMarkers()
|
|
updateMapScale()
|
|
}
|
|
|
|
function setMapMode(mode) {
|
|
if (!mapInstance || mapMode.value === mode) {
|
|
mapMode.value = mode
|
|
return
|
|
}
|
|
if (!mapConfig.token) {
|
|
ui.toast('缺少 Mapbox token,无法切换底图')
|
|
return
|
|
}
|
|
mapMode.value = mode
|
|
layersReady = false
|
|
if (pendingStyleHandler) {
|
|
mapInstance.off('style.load', pendingStyleHandler)
|
|
pendingStyleHandler = null
|
|
}
|
|
mapInstance.setStyle(MAP_STYLES[mode] || MAP_STYLES.satellite)
|
|
pendingStyleHandler = rebuildAfterStyle
|
|
mapInstance.once('style.load', pendingStyleHandler)
|
|
}
|
|
function resetOverviewCamera() {
|
|
if (!mapInstance) return
|
|
mapInstance.jumpTo({
|
|
center: MAP_OVERVIEW.center,
|
|
zoom: MAP_OVERVIEW.zoom,
|
|
pitch: 0,
|
|
bearing: 0,
|
|
})
|
|
updateMapScale()
|
|
}
|
|
|
|
function fitAll() {
|
|
if (selectedId.value) {
|
|
clearSelection()
|
|
return
|
|
}
|
|
resetOverviewCamera()
|
|
}
|
|
|
|
function flyToSelected() {
|
|
if (!mapInstance || !selectedId.value) return
|
|
const asset = devices.asset(selectedId.value)
|
|
if (!asset?.hasCoordinates) return
|
|
const lon = Number(asset.longitude)
|
|
const lat = Number(asset.latitude)
|
|
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return
|
|
const nextZoom = Math.max(Number(mapInstance.getZoom()) || 0, MAP_VIEW.detailZoom)
|
|
mapInstance.jumpTo({
|
|
center: [lon, lat],
|
|
zoom: nextZoom,
|
|
pitch: 0,
|
|
bearing: 0,
|
|
})
|
|
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 waitForMap()
|
|
if (!map) {
|
|
ui.toast('地图未初始化(缺少 Mapbox token)')
|
|
return
|
|
}
|
|
mapInstance = map
|
|
if (typeof window !== 'undefined') window.__LAIC_MAP__ = map
|
|
mapHelper.toggleMapMode({ is2D: true })
|
|
updateMapScale()
|
|
|
|
let readyStarted = false
|
|
const onReady = async () => {
|
|
if (readyStarted) return
|
|
readyStarted = true
|
|
pendingLoadHandler = null
|
|
if (!mapInstance) return
|
|
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()
|
|
if (map.isStyleLoaded()) {
|
|
await onReady()
|
|
} else {
|
|
pendingLoadHandler = onReady
|
|
map.once('load', pendingLoadHandler)
|
|
map.once('style.load', pendingLoadHandler)
|
|
// 双事件都可能已触发:短轮询兜底,避免首屏卡在默认比例尺
|
|
const startedAt = Date.now()
|
|
const poll = () => {
|
|
if (readyStarted || mapInstance !== map) return
|
|
if (map.isStyleLoaded()) {
|
|
onReady()
|
|
return
|
|
}
|
|
if (Date.now() - startedAt < 5000) setTimeout(poll, 50)
|
|
}
|
|
setTimeout(poll, 0)
|
|
}
|
|
|
|
map.on('click', onMapClick)
|
|
map.on('mouseenter', DEVICE_SYMBOL, onSymbolEnter)
|
|
map.on('mouseleave', DEVICE_SYMBOL, onSymbolLeave)
|
|
map.on('zoom', updateMapScale)
|
|
map.on('move', updateMapScale)
|
|
}
|
|
|
|
|
|
|
|
function onMapClick(e) {
|
|
if (suppressMapClick) return
|
|
if (!layersReady) return
|
|
const layers = [DEVICE_SYMBOL, DEVICE_LABEL].filter((id) => mapInstance.getLayer(id))
|
|
if (!layers.length) return
|
|
const feats = mapInstance.queryRenderedFeatures(e.point, {
|
|
layers,
|
|
})
|
|
const id = feats[0]?.properties?.assetId
|
|
if (id) {
|
|
suppressMapClick = true
|
|
select(String(id))
|
|
setTimeout(() => { suppressMapClick = false }, 0)
|
|
return
|
|
}
|
|
clearSelection()
|
|
}
|
|
|
|
function onSymbolEnter() {
|
|
if (!mapInstance) return
|
|
mapInstance.getCanvas().style.cursor = 'pointer'
|
|
}
|
|
|
|
function onSymbolLeave() {
|
|
if (!mapInstance) return
|
|
mapInstance.getCanvas().style.cursor = ''
|
|
}
|
|
|
|
|
|
function teardownMap() {
|
|
cancelPendingMapHandlers()
|
|
if (mapInstance) {
|
|
mapInstance.off('click', onMapClick)
|
|
mapInstance.off('mouseenter', DEVICE_SYMBOL, onSymbolEnter)
|
|
mapInstance.off('mouseleave', DEVICE_SYMBOL, onSymbolLeave)
|
|
mapInstance.off('zoom', updateMapScale)
|
|
mapInstance.off('move', updateMapScale)
|
|
clearMonitorLayers()
|
|
}
|
|
mapInstance = null
|
|
if (typeof window !== 'undefined' && window.__LAIC_MAP__) delete window.__LAIC_MAP__
|
|
layersReady = false
|
|
commonRefs.clearPendingList('map')
|
|
}
|
|
|
|
onMounted(async () => {
|
|
window.addEventListener('keydown', onGlobalKeydown)
|
|
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 || '加载设备失败')
|
|
}
|
|
try {
|
|
await setupMap()
|
|
} catch (e) {
|
|
ui.toast(e.message || '地图初始化失败')
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('keydown', onGlobalKeydown)
|
|
closeCommandProgress()
|
|
closeLive()
|
|
teardownMap()
|
|
})
|
|
|
|
watch(mapMarkers, () => {
|
|
syncMarkers()
|
|
}, { deep: true })
|
|
|
|
watch(selectedId, () => {
|
|
syncMarkers()
|
|
if (selectedId.value) flyToSelected()
|
|
else resetOverviewCamera()
|
|
})
|
|
|
|
const selectedAsset = computed(() => (selectedId.value ? devices.asset(selectedId.value) : null))
|
|
const isDrone = computed(() => selectedAsset.value?.type === 'drone')
|
|
const record = computed(() => (selectedId.value ? devices.record(selectedId.value) : null))
|
|
const isFlying = computed(() => isDrone.value && record.value?.statusClass === 'mission')
|
|
const selectedDrone = computed(() => (isDrone.value ? record.value : devices.drones.find((d) => d.dockId === record.value?.dockId) || null))
|
|
const selectedDock = computed(() => (isDrone.value ? devices.docks.find((d) => d.dockId === record.value?.dockId) || null : record.value))
|
|
const selectedDockEnvironment = computed(() => selectedDock.value?.environment || null)
|
|
const hasEnvironment = computed(() => Object.values(selectedDockEnvironment.value || {}).some((value) => value != null && value !== ''))
|
|
const droneMissionState = computed(() => {
|
|
if (!isDrone.value || !record.value) return ''
|
|
if (record.value.statusClass === 'mission') return record.value.status || '任务中'
|
|
if (record.value.statusClass === 'offline') return '设备离线'
|
|
return record.value.status || '停放待命'
|
|
})
|
|
|
|
const livePhaseText = computed(() => {
|
|
if (!live.session) return '待机'
|
|
const phase = live.session.phase
|
|
if (phase === 'streaming') return live.playUrl.startsWith('fake://') ? '模拟直播' : '直播中'
|
|
if (phase === 'starting') return '启动中'
|
|
if (phase === 'reconnecting') return '重连中'
|
|
if (phase === 'stopping') return '停止中'
|
|
if (phase === 'failed') return '启动失败'
|
|
return '已停止'
|
|
})
|
|
const canFullscreenLive = computed(() => !!live.playUrl && !live.playUrl.startsWith('fake://'))
|
|
|
|
const detailHeader = computed(() => {
|
|
const asset = selectedAsset.value
|
|
if (!asset) return null
|
|
return {
|
|
kindLabel: isDrone.value ? '无人机设备' : '机巢设备',
|
|
name: asset.name,
|
|
location: asset.location,
|
|
status: asset.status,
|
|
statusClass: asset.statusClass,
|
|
isDrone: isDrone.value,
|
|
}
|
|
})
|
|
|
|
const detailRelation = computed(() => {
|
|
if (!isDrone.value || !selectedAsset.value) return null
|
|
return {
|
|
parentId: selectedAsset.value.parent || null,
|
|
parentName: selectedDroneParentName.value,
|
|
}
|
|
})
|
|
|
|
const detailLiveView = computed(() => ({
|
|
playUrl: live.playUrl,
|
|
phase: live.session?.phase,
|
|
active: !!live.session,
|
|
phaseText: livePhaseText.value,
|
|
canFullscreen: canFullscreenLive.value,
|
|
cameraLabel: isDrone.value ? '无人机图传' : '机巢摄像头',
|
|
}))
|
|
|
|
const detailDockStatus = computed(() => {
|
|
if (isDrone.value) return null
|
|
return {
|
|
droneBattery: selectedDrone.value?.battery || '--',
|
|
chargeText: chargeText(rt('chargingState')),
|
|
chargeActive: chargeTone.value,
|
|
doorSummary: doorSummary.value,
|
|
controlMode: dockControlMode.value,
|
|
}
|
|
})
|
|
|
|
const detailFlightStatus = computed(() => {
|
|
if (!isDrone.value || !selectedAsset.value) return null
|
|
return {
|
|
missionState: droneMissionState.value,
|
|
battery: record.value?.battery || '--',
|
|
altitudeText: displayWithUnit(selectedAsset.value.altitude, 'm'),
|
|
speedText: displayWithUnit(selectedAsset.value.speed, 'm/s', 1),
|
|
satellitesText: displayWithUnit(selectedAsset.value.satellites, '颗'),
|
|
}
|
|
})
|
|
|
|
const detailMission = computed(() => {
|
|
if (!isDrone.value) return null
|
|
const detail = missionDetail.value
|
|
return {
|
|
hasMission: detail.hasMission,
|
|
name: detail.name,
|
|
route: detail.route,
|
|
progressText: detail.progressText,
|
|
progressWidth: detail.progressWidth,
|
|
waypointText: detail.waypointText,
|
|
elapsedText: detail.elapsedText,
|
|
distanceText: detail.distanceText,
|
|
canOpenDetail: !!detail.taskId,
|
|
}
|
|
})
|
|
|
|
const detailEnvironment = computed(() => ({
|
|
updatedText: selectedDock.value?.updated || record.value?.updated || '刚刚',
|
|
hasData: hasEnvironment.value,
|
|
outsideTemperatureText: displayWithUnit(selectedDockEnvironment.value?.outsideTemperature, '℃'),
|
|
windSpeedText: displayWithUnit(selectedDockEnvironment.value?.windSpeed, 'm/s'),
|
|
rainfallText: rainfallText(selectedDockEnvironment.value),
|
|
}))
|
|
|
|
const detailActions = computed(() => ({
|
|
visible: !isAdmin.value,
|
|
isDrone: isDrone.value,
|
|
isFlying: isFlying.value,
|
|
statusText: buttonStatusText,
|
|
activeTitle: commandProgress.status ? commandProgress.buttonTitle : '',
|
|
dockExtra: dockExtraCommands,
|
|
droneExtra: droneExtraCommands,
|
|
}))
|
|
|
|
function onDetailCommand({ title, desc }) {
|
|
runCommand(title, desc)
|
|
}
|
|
|
|
function toggleLive() {
|
|
if (live.session) closeLive()
|
|
else openLive()
|
|
}
|
|
|
|
function fullscreenLive() {
|
|
if (!canFullscreenLive.value) {
|
|
ui.toast('当前无可全屏播放的视频流')
|
|
return
|
|
}
|
|
const ok = detailPanelRef.value?.livePlayerRef?.requestFullscreen?.()
|
|
if (!ok) ui.toast('当前浏览器不支持全屏')
|
|
}
|
|
|
|
async function openLive() {
|
|
const dockId = selectedDock.value?.dockId
|
|
if (!dockId) return
|
|
try {
|
|
const result = await joinLive(dockId)
|
|
live.session = result.session
|
|
live.dockId = dockId
|
|
scheduleLiveHeartbeat(result.leaseExpiresAt)
|
|
if (result.session.phase === 'streaming') await refreshPlayURL()
|
|
} catch (e) {
|
|
ui.toast(e.message || '打开直播失败')
|
|
}
|
|
}
|
|
|
|
async function refreshPlayURL() {
|
|
if (!live.dockId) return
|
|
const play = await getLivePlayURL(live.dockId)
|
|
live.playUrl = play.playUrl || ''
|
|
}
|
|
|
|
function scheduleLiveHeartbeat(expiresAt) {
|
|
window.clearTimeout(live.heartbeatTimer)
|
|
const delay = Math.max(5000, (Number(expiresAt) * 1000 - Date.now()) / 2)
|
|
live.heartbeatTimer = window.setTimeout(async () => {
|
|
try {
|
|
const result = await heartbeatLive(live.dockId, live.session.id)
|
|
live.session = result.session
|
|
scheduleLiveHeartbeat(result.leaseExpiresAt)
|
|
if (result.session.phase === 'streaming' && !live.playUrl) await refreshPlayURL()
|
|
} catch (e) {
|
|
ui.toast(e.message || '直播观看已结束')
|
|
await closeLive(false)
|
|
}
|
|
}, delay)
|
|
}
|
|
|
|
async function closeLive(sendRequest = true) {
|
|
window.clearTimeout(live.heartbeatTimer)
|
|
const session = live.session
|
|
const dockId = live.dockId
|
|
live.session = null
|
|
live.dockId = ''
|
|
live.playUrl = ''
|
|
if (sendRequest && session && dockId) {
|
|
try {
|
|
await leaveLive(dockId, session.id)
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
|
|
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) {
|
|
if (value === true || value === 'true' || value === '1' || value === 1) return '是'
|
|
if (value === false || value === 'false' || value === '0' || value === 0) return '否'
|
|
return '--'
|
|
}
|
|
|
|
function rainfallText(env) {
|
|
if (!env) return '--'
|
|
const amount = env.rainfall ?? env.rainAmount ?? env.rainFall ?? env.precipitation
|
|
if (amount != null && amount !== '') {
|
|
const num = Number(amount)
|
|
if (Number.isFinite(num)) return `${num} mm`
|
|
return `${amount} mm`
|
|
}
|
|
if (env.rain != null && env.rain !== '') {
|
|
if (env.rain === true || env.rain === 'true' || env.rain === 1 || env.rain === '1') return '有降雨'
|
|
if (env.rain === false || env.rain === 'false' || env.rain === 0 || env.rain === '0') return '0 mm'
|
|
return String(env.rain)
|
|
}
|
|
return '--'
|
|
}
|
|
|
|
|
|
function displayValue(value) {
|
|
return value == null || value === '' ? '--' : value
|
|
}
|
|
|
|
function rt(key, ...aliases) {
|
|
const realtime = record.value?.realtime || {}
|
|
for (const name of [key, ...aliases]) {
|
|
if (realtime[name] != null && realtime[name] !== '') return realtime[name]
|
|
}
|
|
return null
|
|
}
|
|
|
|
function rtObj(key) {
|
|
const raw = record.value?.realtime?.[key]
|
|
if (!raw) return {}
|
|
if (typeof raw === 'object') return raw
|
|
try { return JSON.parse(raw) || {} } catch { return {} }
|
|
}
|
|
|
|
function doorValue(side) {
|
|
return rtObj('door')[side]
|
|
}
|
|
|
|
function isClosed(value) {
|
|
return value === 'closed' || value === 'close'
|
|
}
|
|
|
|
function centeringValue(key) {
|
|
return rtObj('centering')[key]
|
|
}
|
|
|
|
function isTight(value) {
|
|
return value === 'tight'
|
|
}
|
|
|
|
function isTrue(value) {
|
|
return value === true || value === 'true' || value === '1' || value === 1
|
|
}
|
|
|
|
function chargeText(value) {
|
|
const key = String(value || '').toLowerCase()
|
|
if (key === 'charging') return '充电中'
|
|
if (key === 'idle') return '空闲'
|
|
if (key === 'full' || key === 'charged') return '已充满'
|
|
return displayValue(value)
|
|
}
|
|
|
|
const chargeTone = computed(() => String(rt('chargingState') || '').toLowerCase() === 'charging')
|
|
|
|
const doorSummary = computed(() => {
|
|
const left = doorValue('left')
|
|
const right = doorValue('right')
|
|
if (isClosed(left) && isClosed(right)) return '已关闭'
|
|
if (left === 'open' || right === 'open') return '已打开'
|
|
return displayValue(left || right)
|
|
})
|
|
|
|
const dockControlMode = computed(() => record.value?.mode || displayValue(rt('controlMode')))
|
|
|
|
function formatDuration(seconds) {
|
|
const total = Math.max(0, Math.floor(Number(seconds) || 0))
|
|
if (!Number.isFinite(total) || total <= 0) return '--'
|
|
const mm = String(Math.floor(total / 60)).padStart(2, '0')
|
|
const ss = String(total % 60).padStart(2, '0')
|
|
return `${mm}:${ss}`
|
|
}
|
|
|
|
function formatDistance(meters) {
|
|
const value = Number(meters)
|
|
if (!Number.isFinite(value) || value < 0) return '--'
|
|
if (value >= 1000) return `${(value / 1000).toFixed(1)} km`
|
|
return `${Math.round(value)} m`
|
|
}
|
|
|
|
const missionDetail = computed(() => {
|
|
const rec = record.value
|
|
const nameRaw = rec?.mission || rt('missionName', 'mission')
|
|
const name = nameRaw && nameRaw !== '--' && nameRaw !== '无' ? String(nameRaw) : ''
|
|
const route = displayValue(rt('routeName', 'routeCode', 'route'))
|
|
const progressRaw = rt('missionProgress', 'progressPercent', 'progress')
|
|
const progressNum = Number(progressRaw)
|
|
const hasProgress = Number.isFinite(progressNum)
|
|
const currentWp = rt('currentWaypoint', 'waypointIndex', 'waypoint')
|
|
const totalWp = rt('waypointCount', 'totalWaypoints', 'waypoints')
|
|
const elapsed = rt('missionElapsed', 'elapsed', 'flightDuration', 'elapsedSeconds')
|
|
const distance = rt('distanceToHome', 'distanceFromDock', 'homeDistance', 'distance')
|
|
const taskId = rt('taskId', 'missionId') || rec?.taskId || null
|
|
const active = !!name || rec?.statusClass === 'mission'
|
|
return {
|
|
hasMission: active,
|
|
name: name || (rec?.statusClass === 'mission' ? (rec.status || '执行中任务') : '暂无任务'),
|
|
route: route === '--' ? '航线 --' : `航线 ${route}`,
|
|
progressText: hasProgress ? `${Math.max(0, Math.min(100, Math.round(progressNum)))}%` : '--',
|
|
progressWidth: hasProgress ? `${Math.max(0, Math.min(100, progressNum))}%` : '0%',
|
|
waypointText: (currentWp != null || totalWp != null)
|
|
? `航点 ${displayValue(currentWp)} / ${displayValue(totalWp)}`
|
|
: '航点 -- / --',
|
|
elapsedText: `已飞行 ${formatDuration(elapsed)}`,
|
|
distanceText: `距离机巢 ${formatDistance(distance)}`,
|
|
taskId,
|
|
}
|
|
})
|
|
|
|
function goMissionDetail() {
|
|
const id = missionDetail.value.taskId
|
|
if (!id) {
|
|
ui.toast('暂无任务详情')
|
|
return
|
|
}
|
|
router.push({ name: 'tasks', query: { taskId: String(id) } })
|
|
}
|
|
|
|
|
|
|
|
const isAutoMode = computed(() => {
|
|
const mode = String(rt('controlMode') || record.value?.mode || '').toLowerCase()
|
|
return mode === 'auto' || mode === 'automatic' || mode.includes('自动')
|
|
})
|
|
|
|
const dockStatusTitle = computed(() => {
|
|
if (!isDrone.value) return selectedAsset.value?.name || '机巢'
|
|
return selectedDock.value?.name || selectedDroneParentName.value || '机巢'
|
|
})
|
|
|
|
const dockStatusSummary = computed(() => {
|
|
const dock = selectedDock.value
|
|
const drone = selectedDrone.value
|
|
const online = !!dock?.online
|
|
const alarms = Array.isArray(dock?.alarms) ? dock.alarms : []
|
|
const alarmCount = alarms.length
|
|
const left = doorValue('left')
|
|
const right = doorValue('right')
|
|
let doorHint = '门状态未知'
|
|
if (isClosed(left) && isClosed(right)) doorHint = '左门、右门反馈正常'
|
|
else if (left === 'open' || right === 'open') doorHint = '存在开启舱门'
|
|
const droneLabel = drone
|
|
? (drone.statusClass === 'mission' ? (drone.status || '任务中') : (drone.status || '在巢待命'))
|
|
: '未绑定'
|
|
const droneHint = drone
|
|
? `${drone.name || drone.code || '--'}${drone.battery && drone.battery !== '--' ? ` · 电量 ${drone.battery}` : ''}`
|
|
: '暂无绑定无人机'
|
|
return {
|
|
online,
|
|
connection: online ? '在线' : (dock?.status || '离线'),
|
|
connectionHint: online ? `心跳正常 · ${dock?.updated || '刚刚'}` : (dock?.updated ? `最后在线 ${dock.updated}` : '设备离线'),
|
|
drone: droneLabel,
|
|
droneHint,
|
|
door: doorSummary.value,
|
|
doorHint,
|
|
charge: chargeText(rt('chargingState')),
|
|
chargeHint: isTrue(rt('dronePresent')) ? '电池在位' : '在位状态未知',
|
|
mode: dockControlMode.value,
|
|
modeHint: `手自动切换:${dockControlMode.value}`,
|
|
alarmCount,
|
|
alarm: alarmCount ? `${alarmCount} 条` : '无告警',
|
|
alarmHint: alarmCount ? alarms.slice(0, 2).join('、') : '急停未触发 · 报警总信号正常'
|
|
}
|
|
})
|
|
|
|
const dockStatusUpdatedText = computed(() => selectedDock.value?.updated || record.value?.updated || '刚刚')
|
|
|
|
const dockStatusBoard = computed(() => {
|
|
const left = doorValue('left')
|
|
const right = doorValue('right')
|
|
const lr = centeringValue('leftRight')
|
|
const fb = centeringValue('frontBack')
|
|
return {
|
|
leftClosed: isClosed(left),
|
|
leftOpen: left === 'open',
|
|
rightClosed: isClosed(right),
|
|
rightOpen: right === 'open',
|
|
lrTight: isTight(lr),
|
|
lrLoose: lr === 'loose',
|
|
fbTight: isTight(fb),
|
|
fbLoose: fb === 'loose',
|
|
emergencyStop: isTrue(rt('emergencyStop')),
|
|
autoMode: isAutoMode.value,
|
|
noAlarm: !dockStatusSummary.value.alarmCount,
|
|
resetDone: isTrue(rt('resetDone', 'resetComplete')),
|
|
charging: chargeTone.value,
|
|
chargeIdle: String(rt('chargingState') || '').toLowerCase() === 'idle',
|
|
dronePresent: isTrue(rt('dronePresent')),
|
|
powerOnline: isTrue(rt('powerOnline', 'powerOn')),
|
|
}
|
|
})
|
|
|
|
function openDockStatus() {
|
|
if (!selectedId.value) return
|
|
dockStatusOpen.value = true
|
|
}
|
|
|
|
function closeDockStatus() {
|
|
dockStatusOpen.value = false
|
|
}
|
|
|
|
function onGlobalKeydown(event) {
|
|
if (event.key === 'Escape' && commandProgress.open) {
|
|
closeCommandProgress()
|
|
return
|
|
}
|
|
if (event.key === 'Escape' && dockStatusOpen.value) closeDockStatus()
|
|
}
|
|
|
|
|
|
|
|
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()
|
|
if (selectedId.value) flyToSelected()
|
|
else resetOverviewCamera()
|
|
ui.toast('设备状态已刷新')
|
|
} catch (e) {
|
|
ui.toast(e.message || '刷新设备状态失败')
|
|
}
|
|
}
|
|
|
|
const groupedDocks = computed(() =>
|
|
devices.docks.map((dock) => ({
|
|
...dock,
|
|
children: devices.drones.filter((d) => d.dockId === dock.dockId)
|
|
}))
|
|
)
|
|
|
|
const filteredDocks = computed(() => {
|
|
const q = search.value.trim().toLowerCase()
|
|
return groupedDocks.value.filter((dock) => {
|
|
const matchesFilter =
|
|
filter.value === 'all' ||
|
|
(filter.value === 'online' && dock.statusClass === 'online') ||
|
|
(filter.value === 'alarm' && dock.statusClass === 'alarm')
|
|
if (!matchesFilter) return false
|
|
if (!q) return true
|
|
return (
|
|
dock.name.toLowerCase().includes(q) ||
|
|
dock.code.toLowerCase().includes(q) ||
|
|
dock.location.toLowerCase().includes(q) ||
|
|
dock.children.some((c) => c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q))
|
|
)
|
|
})
|
|
})
|
|
|
|
const filterCounts = computed(() => ({
|
|
all: devices.docks.length,
|
|
online: devices.docks.filter((d) => d.statusClass === 'online').length,
|
|
alarm: devices.docks.filter((d) => d.statusClass === 'alarm').length
|
|
}))
|
|
|
|
const selectedDroneParentName = computed(() => {
|
|
if (!isDrone.value) return ''
|
|
return selectedAsset.value?.parentName || devices.asset(selectedAsset.value?.parent)?.name || '未绑定'
|
|
})
|
|
|
|
function select(id) {
|
|
if (!id) return
|
|
const prev = selectedId.value ? devices.record(selectedId.value) : null
|
|
selectedId.value = String(id)
|
|
const rec = devices.record(id)
|
|
if (!rec || rec.kind !== prev?.kind) controlExpanded.value = false
|
|
}
|
|
|
|
function clearDockSelection() {
|
|
selectedDockIds.clear()
|
|
}
|
|
|
|
function setDockSelection(id, checked) {
|
|
if (checked) selectedDockIds.add(id)
|
|
else selectedDockIds.delete(id)
|
|
}
|
|
|
|
function selectAllDocks() {
|
|
filteredDocks.value.forEach((dock) => selectedDockIds.add(dock.id))
|
|
}
|
|
|
|
function clearSelection() {
|
|
selectedId.value = null
|
|
controlExpanded.value = false
|
|
}
|
|
|
|
function isDockCardActive(dock) {
|
|
return selectedId.value === dock.id || dock.children.some((c) => c.id === selectedId.value)
|
|
}
|
|
|
|
function droneAltitude(id) {
|
|
return devices.asset(id)?.altitude
|
|
}
|
|
|
|
function goDetail() {
|
|
router.push({ name: 'device-detail', params: { id: selectedId.value } })
|
|
}
|
|
|
|
function zoomIn() {
|
|
if (!mapInstance) return
|
|
mapInstance.zoomTo(Math.min(mapInstance.getMaxZoom(), mapInstance.getZoom() + 1), { duration: 200 })
|
|
}
|
|
function zoomOut() {
|
|
if (!mapInstance) return
|
|
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
|
|
let commandPollInFlight = false
|
|
let commandProgressEpoch = 0
|
|
|
|
function buttonStatusText(title) {
|
|
if (commandProgress.buttonTitle !== title || !commandProgress.status) return '状态:待执行'
|
|
const meta = COMMAND_STATUS[commandProgress.status]
|
|
return meta ? `状态:${meta.name}` : '状态:待执行'
|
|
}
|
|
|
|
const commandProgressTerminalLabel = computed(() => {
|
|
if (commandProgress.status === 'acked') return '已确认'
|
|
return COMMAND_STATUS[commandProgress.status]?.name || '执行结果'
|
|
})
|
|
|
|
function clearCommandTimers() {
|
|
if (commandPollTimer) {
|
|
clearInterval(commandPollTimer)
|
|
commandPollTimer = null
|
|
}
|
|
if (commandAutoCloseTimer) {
|
|
clearTimeout(commandAutoCloseTimer)
|
|
commandAutoCloseTimer = null
|
|
}
|
|
}
|
|
|
|
function closeCommandProgress() {
|
|
clearCommandTimers()
|
|
commandProgressEpoch += 1
|
|
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 || commandPollInFlight) return
|
|
const epoch = commandProgressEpoch
|
|
commandPollInFlight = true
|
|
try {
|
|
const cmd = await request.get(urls.COMMAND(commandProgress.commandId))
|
|
if (epoch !== commandProgressEpoch) return
|
|
commandPollFails = 0
|
|
const meta = applyCommandStatus(cmd)
|
|
if (meta.terminal) {
|
|
clearInterval(commandPollTimer)
|
|
commandPollTimer = null
|
|
scheduleAutoClose()
|
|
}
|
|
} catch (e) {
|
|
if (epoch !== commandProgressEpoch) return
|
|
commandPollFails += 1
|
|
const status = e?.status ?? e?.response?.status
|
|
if (status === 403 || status === 404 || commandPollFails >= 3) {
|
|
clearInterval(commandPollTimer)
|
|
commandPollTimer = null
|
|
commandProgress.error =
|
|
status === 403
|
|
? '无权限查看进度,请稍后在操作记录确认'
|
|
: status === 404
|
|
? '未找到指令记录'
|
|
: (e.message || '进度查询失败')
|
|
scheduleAutoClose()
|
|
}
|
|
} finally {
|
|
commandPollInFlight = false
|
|
}
|
|
}
|
|
|
|
function startCommandProgress(cmd, title, deviceName) {
|
|
clearCommandTimers()
|
|
commandPollInFlight = false
|
|
commandProgressEpoch += 1
|
|
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
|
|
commandProgressEpoch += 1
|
|
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
|
|
if (!rec.online) {
|
|
ui.toast('设备当前离线,无法下发指令')
|
|
return
|
|
}
|
|
if (rec.kind === 'drone' && rec.bindingState !== 'bound') {
|
|
ui.toast('无人机未绑定机巢,无法下发指令')
|
|
return
|
|
}
|
|
const ok = await ui.confirm(title, desc)
|
|
if (!ok) return
|
|
try {
|
|
const cmd = await devices.sendCommand(rec, title)
|
|
ui.toast(`${title}指令已下发`)
|
|
startCommandProgress(cmd, title, rec.name)
|
|
await reload()
|
|
} catch (e) {
|
|
ui.toast(e.message || '指令下发失败')
|
|
}
|
|
}
|
|
</script>
|
|
|