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

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