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.
 
 
 
 

1177 lines
46 KiB

<template>
<section class="monitor-layout" :class="{ focused: !!selectedId, 'control-expanded': controlExpanded }">
<aside class="device-panel">
<div class="panel-heading">
<div><h1>设备列表</h1><span>{{ devices.docks.length }} · {{ devices.drones.length }} </span></div>
<button class="icon-button" title="收起面板" @click="clearSelection"><svg><use href="#i-x" /></svg></button>
</div>
<label class="device-search">
<svg><use href="#i-search" /></svg>
<input v-model="search" type="text" placeholder="搜索机巢或无人机" />
</label>
<div class="filter-row">
<button class="filter" :class="{ active: filter === 'all' }" @click="filter = 'all'">全部 <b>{{ filterCounts.all }}</b></button>
<button class="filter" :class="{ active: filter === 'online' }" @click="filter = 'online'">在线 <b>{{ filterCounts.online }}</b></button>
<button class="filter" :class="{ active: filter === 'alarm' }" @click="filter = 'alarm'">告警 <b>{{ filterCounts.alarm }}</b></button>
</div>
<div v-if="filteredDocks.length" class="multi-select-bar" :class="{ 'has-selection': selectedDockIds.size }">
<span>已选择 {{ selectedDockIds.size }} 个机巢</span>
<button type="button" @click="selectAllDocks">全选</button>
<button type="button" @click="clearDockSelection">取消选择</button>
</div>
<div class="device-list">
<template v-for="dock in filteredDocks" :key="dock.id">
<article class="device-card" :class="{ active: isDockCardActive(dock), 'multi-selected': selectedDockIds.has(dock.id) }">
<label class="device-select-checkbox" :title="`选择${dock.name}`" @click.stop>
<input type="checkbox" :checked="selectedDockIds.has(dock.id)" @change="toggleDockSelection(dock.id)" />
<span></span>
</label>
<button class="device-card-main" @click="select(dock.id)">
<span class="dock-glyph" :class="{ alarm: dock.statusClass === 'alarm', offline: dock.statusClass === 'offline' }">
<svg><use href="#i-home" /></svg>
</span>
<span class="device-copy">
<strong>{{ dock.name }}</strong>
<small>{{ dock.code }}</small>
</span>
<i class="status" :class="dock.statusClass">{{ dock.status }}</i>
</button>
<button
v-for="drone in dock.children"
:key="drone.id"
class="bound-drone"
:class="{ active: selectedId === drone.id, flying: drone.statusClass === 'mission', offline: drone.statusClass === 'offline' }"
@click="select(drone.id)"
>
<span class="tree-line"></span>
<svg><use href="#i-plane" /></svg>
<span><strong>{{ drone.name }}</strong><small>{{ droneSummary(drone) }}</small></span>
<i>{{ droneTag(drone) }}</i>
</button>
<div v-if="dock.statusClass === 'alarm'" class="device-alert">
<svg><use href="#i-alert" /></svg>检测到设备告警<span>{{ dock.updated }}</span>
</div>
<div v-else-if="dock.statusClass === 'offline'" class="device-alert muted">
最后在线 {{ dock.updated }}<span>网络中断</span>
</div>
</article>
</template>
<div v-if="!filteredDocks.length" class="table-empty" style="height:120px;display:grid;place-items:center;color:var(--muted);font-size:10px">暂无匹配设备</div>
</div>
</aside>
<div class="map-panel">
<div class="map-toolbar">
<button class="map-type" type="button" :class="{ active: mapMode === 'satellite' }" @click="setMapMode('satellite')">卫星</button>
<button class="map-type" type="button" :class="{ active: mapMode === 'street' }" @click="setMapMode('street')">地图</button>
<button class="icon-button" title="查看全部设备" @click="fitAll"><svg><use href="#i-maximize" /></svg></button>
</div>
<div class="map-legend">
<span><i class="green"></i>在线机巢</span>
<span><i class="blue"></i>飞行中</span>
<span><i class="red"></i>告警</span>
<span><i class="gray"></i>离线</span>
</div>
<div class="map-scale">{{ mapScaleText }}</div>
</div>
<aside class="detail-panel" :class="{ 'drone-selected': isDrone }">
<template v-if="selectedAsset">
<header class="detail-header">
<div>
<span class="eyebrow">{{ isDrone ? '无人机设备' : '机巢设备' }}</span>
<h2>{{ selectedAsset.name }}</h2>
<p><svg><use href="#i-map-pin" /></svg>{{ selectedAsset.location }}</p>
</div>
<div class="detail-head-actions">
<i class="status" :class="selectedAsset.statusClass">{{ selectedAsset.status }}</i>
<button class="icon-button" title="查看设备详情" @click="goDetail"><svg><use href="#i-maximize" /></svg></button>
<button class="icon-button" title="关闭详情" @click="clearSelection"><svg><use href="#i-x" /></svg></button>
</div>
</header>
<!-- Drone only: bound dock -->
<div class="asset-relation" v-if="isDrone">
<span><svg><use href="#i-home" /></svg>所属机巢</span>
<button v-if="selectedAsset.parent" @click="select(selectedAsset.parent)">{{ selectedDroneParentName }}<svg><use href="#i-chevron" /></svg></button>
<button v-else disabled>{{ selectedDroneParentName }}</button>
</div>
<!-- Dock only: live video -->
<section class="video-section">
<div class="section-title">
<h3>实时画面</h3>
<div>
<span class="live-dot" :class="{ active: !!live.session }" />
<b>{{ livePhaseText }}</b>
<button class="icon-button" type="button" title="全屏播放" :disabled="!canFullscreenLive" @click="fullscreenLive">
<svg><use href="#i-maximize" /></svg>
</button>
</div>
</div>
<LivePlayer
ref="livePlayerRef"
:play-url="live.playUrl"
:phase="live.session?.phase"
:active="!!live.session"
:camera-label="isDrone ? '无人机图传' : '机巢摄像头'"
@toggle="toggleLive"
@error="ui.toast('视频播放失败')"
/>
</section>
<!-- Dock only: status metrics -->
<section v-if="!isDrone" class="status-section">
<div class="section-title">
<h3>运行状态</h3>
<button class="text-button" type="button" @click="openDockStatus">查看详情</button>
</div>
<div class="metric-grid">
<div><svg><use href="#i-battery" /></svg><span>无人机电量</span><strong>{{ selectedDrone?.battery || '--' }}</strong></div>
<div><svg><use href="#i-zap" /></svg><span>充电状态</span><strong :class="{ success: chargeTone }">{{ chargeText(rt('chargingState')) }}</strong></div>
<div><svg><use href="#i-door" /></svg><span>舱门状态</span><strong>{{ doorSummary }}</strong></div>
<div><svg><use href="#i-satellite" /></svg><span>控制模式</span><strong>{{ dockControlMode }}</strong></div>
</div>
</section>
<!-- Drone only: flight status -->
<section v-if="isDrone" class="status-section">
<div class="section-title">
<h3>飞行状态</h3>
<span class="mission-state">{{ droneMissionState }}</span>
</div>
<div class="metric-grid drone-metrics">
<div><svg><use href="#i-battery" /></svg><span>飞行电量</span><strong>{{ record?.battery || '--' }}</strong></div>
<div><svg><use href="#i-plane" /></svg><span>相对高度</span><strong>{{ displayWithUnit(selectedAsset.altitude, 'm') }}</strong></div>
<div><svg><use href="#i-route" /></svg><span>地速</span><strong>{{ displayWithUnit(selectedAsset.speed, 'm/s', 1) }}</strong></div>
<div><svg><use href="#i-satellite" /></svg><span>卫星数量</span><strong>{{ displayWithUnit(selectedAsset.satellites, '颗') }}</strong></div>
</div>
</section>
<section v-if="isDrone" class="mission-progress">
<div class="section-title">
<h3>当前任务</h3>
<button class="text-button" type="button" :disabled="!missionDetail.taskId" @click="goMissionDetail">任务详情</button>
</div>
<template v-if="missionDetail.hasMission">
<div class="mission-name">
<span>
<strong>{{ missionDetail.name }}</strong>
<small>{{ missionDetail.route }}</small>
</span>
<b>{{ missionDetail.progressText }}</b>
</div>
<div class="progress-track"><i :style="{ width: missionDetail.progressWidth }"></i></div>
<div class="progress-meta">
<span>{{ missionDetail.waypointText }}</span>
<span>{{ missionDetail.elapsedText }}</span>
<span>{{ missionDetail.distanceText }}</span>
</div>
</template>
<div v-else class="table-empty">暂无任务</div>
</section>
<!-- Drone only: mission -->
<section class="environment-section">
<div class="section-title">
<h3>机巢环境</h3>
<span>{{ selectedDock?.updated || record?.updated || '刚刚' }}</span>
</div>
<div v-if="hasEnvironment" class="environment-row">
<div><svg><use href="#i-sun" /></svg><span>舱外温度</span><strong>{{ displayWithUnit(selectedDockEnvironment?.outsideTemperature, '℃') }}</strong></div>
<div><svg><use href="#i-wind" /></svg><span>风速</span><strong>{{ displayWithUnit(selectedDockEnvironment?.windSpeed, 'm/s') }}</strong></div>
<div><svg><use href="#i-cloud-rain" /></svg><span>雨量</span><strong>{{ rainfallText(selectedDockEnvironment) }}</strong></div>
</div>
<div v-else class="table-empty">暂无环境数据</div>
</section>
<!-- Quick actions -->
<section v-if="!isAdmin" class="quick-actions" :class="{ 'drone-quick-actions': isDrone, 'all-controls': controlExpanded }">
<template v-if="!isDrone">
<div class="section-title">
<h3>{{ controlExpanded ? '控制' : '快捷控制' }}</h3>
<button class="control-mode-toggle" :class="{ expanded: controlExpanded }" type="button" @click="controlExpanded = !controlExpanded">
{{ controlExpanded ? '简单控制' : '更多控制' }}<svg><use href="#i-chevron" /></svg>
</button>
</div>
<div class="workflow-actions">
<button class="workflow-action takeoff" @click="runCommand('一键起飞', '无人机将从机巢起飞并进入待命状态。')"><svg><use href="#i-plane" /></svg><span><strong>一键起飞</strong><small>状态:待执行</small></span></button>
<button class="workflow-action landing" @click="runCommand('一键降落', '无人机将返回机巢并自动降落。')"><svg><use href="#i-home" /></svg><span><strong>一键降落</strong><small>状态:待执行</small></span></button>
</div>
<div class="stage-actions">
<button @click="runCommand('起飞准备', '将执行开舱并解除归中。')"><b>01</b><span>起飞准备<small>状态:待执行</small></span></button>
<button @click="runCommand('起飞准备完成', '确认起飞准备已完成。')"><b>02</b><span>起飞准备完成<small>状态:待执行</small></span></button>
<button @click="runCommand('降落准备', '将开舱并等待无人机返航。')"><b>03</b><span>降落准备<small>状态:待执行</small></span></button>
<button @click="runCommand('降落准备完成', '确认降落准备已完成。')"><b>04</b><span>降落准备完成<small>状态:待执行</small></span></button>
</div>
<button class="reset-action" @click="runCommand('整机复位', '机巢将恢复待命状态。')"><svg><use href="#i-settings" /></svg><span><strong>整机复位</strong><small>状态:待执行</small></span></button>
<div v-show="controlExpanded" class="dock-control-extra">
<div class="dock-control-extra-grid">
<button v-for="cmd in dockExtraCommands" :key="cmd.title" @click="runCommand(cmd.title, cmd.desc)">
<svg><use :href="cmd.icon" /></svg>
<span><strong>{{ cmd.title }}</strong><small>{{ cmd.hint }}</small></span>
</button>
</div>
</div>
</template>
<template v-else>
<div class="section-title">
<h3>{{ controlExpanded ? '控制' : '快捷控制' }}</h3>
<button class="control-mode-toggle" :class="{ expanded: controlExpanded }" type="button" @click="controlExpanded = !controlExpanded">
{{ controlExpanded ? '简单控制' : '更多控制' }}<svg><use href="#i-chevron" /></svg>
</button>
</div>
<button
v-if="isFlying"
class="primary-action return-action"
@click="runCommand('一键返航', '无人机将返回绑定机巢并自动降落。')"
>
<svg><use href="#i-home" /></svg>
<span><strong>一键返航</strong><small>自动回巢 · 降落</small></span>
</button>
<button
v-else
class="primary-action"
@click="runCommand('起飞', '无人机将立即起飞进入待命状态。')"
>
<svg><use href="#i-plane" /></svg>
<span><strong>起飞</strong><small>离巢后进入待命</small></span>
</button>
<div class="secondary-actions">
<button @click="runCommand('暂停任务', '向绑定机巢下发暂停指令。')"><svg><use href="#i-stop" /></svg>暂停任务</button>
<button @click="runCommand('继续任务', '向绑定机巢下发继续指令。')"><svg><use href="#i-play" /></svg>继续任务</button>
<button @click="runCommand('取消任务', '向绑定机巢下发取消指令。')"><svg><use href="#i-x" /></svg>取消任务</button>
</div>
<button class="reset-action" @click="runCommand('紧急停止', '立即中止飞行,无人机原地悬停待命。')"><svg><use href="#i-stop" /></svg><span><strong>紧急停止</strong><small>中止任务 · 原地悬停</small></span></button>
<div v-show="controlExpanded" class="drone-control-all">
<button v-for="cmd in droneExtraCommands" :key="cmd.title" :class="{ danger: cmd.danger }" @click="runCommand(cmd.title, cmd.desc)">
<svg><use :href="cmd.icon" /></svg>
<span><strong>{{ cmd.title }}</strong><small>{{ cmd.hint }}</small></span>
</button>
</div>
</template>
</section>
</template>
<div v-else class="table-empty" style="height:100%;display:grid;place-items:center;color:var(--muted);font-size:11px">请在左侧选择设备</div>
</aside>
</section>
<div class="modal-backdrop" :class="{ open: dockStatusOpen }" :aria-hidden="!dockStatusOpen" @click.self="closeDockStatus">
<div class="modal dock-status-dialog" role="dialog" aria-modal="true" aria-labelledby="dockStatusTitle">
<header class="dock-status-header">
<div>
<span>机巢运行状态</span>
<h3 id="dockStatusTitle">{{ dockStatusTitle }}</h3>
</div>
<button class="icon-button" type="button" title="关闭详情" @click="closeDockStatus">
<svg><use href="#i-x" /></svg>
</button>
</header>
<div class="dock-status-body">
<div class="dock-status-grid">
<div>
<span>设备连接</span>
<strong :class="{ 'green-text': dockStatusSummary.online }">{{ dockStatusSummary.connection }}</strong>
<small>{{ dockStatusSummary.connectionHint }}</small>
</div>
<div>
<span>无人机状态</span>
<strong>{{ dockStatusSummary.drone }}</strong>
<small>{{ dockStatusSummary.droneHint }}</small>
</div>
<div>
<span>舱门状态</span>
<strong>{{ dockStatusSummary.door }}</strong>
<small>{{ dockStatusSummary.doorHint }}</small>
</div>
<div>
<span>充电模块</span>
<strong :class="{ 'green-text': chargeTone }">{{ dockStatusSummary.charge }}</strong>
<small>{{ dockStatusSummary.chargeHint }}</small>
</div>
<div>
<span>控制模式</span>
<strong>{{ dockStatusSummary.mode }}</strong>
<small>{{ dockStatusSummary.modeHint }}</small>
</div>
<div>
<span>当前告警</span>
<strong :class="{ 'green-text': !dockStatusSummary.alarmCount }">{{ dockStatusSummary.alarm }}</strong>
<small>{{ dockStatusSummary.alarmHint }}</small>
</div>
</div>
<section class="dock-state-section">
<div class="dock-state-heading">
<h4>状态</h4>
<span>PLC 实时反馈 · {{ selectedDock?.updated || record?.updated || '刚刚' }}</span>
</div>
<div class="dock-state-board">
<article>
<h5>舱门状态</h5>
<ul>
<li :class="{ active: isClosed(doorValue('left')) }"><i></i>左顶门关</li>
<li :class="{ active: doorValue('left') === 'open' }"><i></i>左顶门开</li>
<li :class="{ active: isClosed(doorValue('right')) }"><i></i>右顶门关</li>
<li :class="{ active: doorValue('right') === 'open' }"><i></i>右顶门开</li>
</ul>
</article>
<article>
<h5>杆件状态</h5>
<ul>
<li :class="{ active: isTight(centeringValue('leftRight')) }"><i></i>左右归中闭合</li>
<li :class="{ active: centeringValue('leftRight') === 'loose' }"><i></i>左右归中打开</li>
<li :class="{ active: isTight(centeringValue('frontBack')) }"><i></i>前后归中闭合</li>
<li :class="{ active: centeringValue('frontBack') === 'loose' }"><i></i>前后归中打开</li>
</ul>
</article>
<article>
<h5>系统状态</h5>
<ul>
<li :class="{ active: isTrue(rt('emergencyStop')) }"><i :class="{ warning: isTrue(rt('emergencyStop')) }"></i>急停</li>
<li :class="{ active: isAutoMode }"><i></i>自动模式</li>
<li :class="{ active: !dockStatusSummary.alarmCount }"><i></i>无报警</li>
<li :class="{ active: isTrue(rt('resetDone', 'resetComplete')) }"><i></i>复位完成</li>
</ul>
</article>
<article>
<h5>充电与在位</h5>
<ul>
<li :class="{ active: chargeTone }"><i></i>充电中</li>
<li :class="{ active: String(rt('chargingState') || '').toLowerCase() === 'idle' }"><i></i>充电空闲</li>
<li :class="{ active: isTrue(rt('dronePresent')) }"><i></i>无人机在位</li>
<li :class="{ active: isTrue(rt('powerOnline', 'powerOn')) }"><i></i>电源在线</li>
</ul>
</article>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import mapboxgl from 'mapbox-gl'
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 } from '@/config/map'
import LivePlayer from '@/components/LivePlayer.vue'
import { getLivePlayURL, heartbeatLive, joinLive, leaveLive } from '@/api/live'
const DEVICE_SOURCE = 'monitor-devices'
const DEVICE_CIRCLE = 'monitor-devices-circle'
const DEVICE_LABEL = 'monitor-devices-label'
const BINDING_SOURCE = 'monitor-binding'
const BINDING_LINE = 'monitor-binding-line'
const STATUS_COLORS = {
online: '#21a06a',
mission: '#2d82da',
alarm: '#cc3d43',
offline: '#84909b',
pending: '#c48a2a',
}
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 livePlayerRef = 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
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 statusColor(statusClass) {
return STATUS_COLORS[statusClass] || STATUS_COLORS.offline
}
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
return {
type: 'Feature',
// asset.id 多为字符串,省略 numeric Feature.id,靠 setData 全量刷新
properties: {
assetId: String(asset.id),
name: asset.name || '',
kind: asset.type,
selected,
color: statusColor(asset.statusClass),
radius: asset.type === 'drone' ? (selected ? 10 : 8) : (selected ? 12 : 10),
},
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 },
},
],
}
}
function ensureMonitorLayers() {
if (!mapInstance || !mapInstance.isStyleLoaded()) return
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_CIRCLE)) {
mapInstance.addLayer({
id: DEVICE_CIRCLE,
type: 'circle',
source: DEVICE_SOURCE,
paint: {
'circle-radius': ['get', 'radius'],
'circle-color': ['get', 'color'],
'circle-stroke-color': '#fff',
'circle-stroke-width': [
'case',
['to-boolean', ['get', 'selected']],
3,
2,
],
},
})
}
if (!mapInstance.getLayer(DEVICE_LABEL)) {
mapInstance.addLayer({
id: DEVICE_LABEL,
type: 'symbol',
source: DEVICE_SOURCE,
layout: {
'text-field': ['get', 'name'],
'text-size': 11,
'text-offset': [0, 1.4],
'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_CIRCLE, 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 rebuildAfterStyle() {
layersReady = false
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
mapInstance.setStyle(MAP_STYLES[mode] || MAP_STYLES.satellite)
mapInstance.once('style.load', rebuildAfterStyle)
}
function fitAll() {
if (selectedId.value) {
clearSelection()
return
}
fitAllMarkers()
}
function fitAllMarkers() {
if (!mapInstance) return
const coords = mapMarkers.value
.map((a) => [Number(a.longitude), Number(a.latitude)])
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat))
if (!coords.length) {
mapInstance.jumpTo({ center: mapConfig.center, zoom: mapConfig.zoom })
return
}
if (coords.length === 1) {
mapInstance.easeTo({ center: coords[0], zoom: 16, duration: 450 })
return
}
const bounds = coords.reduce(
(b, c) => b.extend(c),
new mapboxgl.LngLatBounds(coords[0], coords[0]),
)
mapInstance.fitBounds(bounds, { padding: 60, duration: 450 })
}
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
mapInstance.easeTo({
center: [lon, lat],
zoom: Math.max(mapInstance.getZoom(), 16),
duration: 450,
})
}
function onMapClick(e) {
if (suppressMapClick) return
const feats = mapInstance.queryRenderedFeatures(e.point, {
layers: [DEVICE_CIRCLE, DEVICE_LABEL].filter((id) => mapInstance.getLayer(id)),
})
const id = feats[0]?.properties?.assetId
if (id) {
suppressMapClick = true
select(String(id))
setTimeout(() => { suppressMapClick = false }, 0)
return
}
clearSelection()
}
async function setupMap() {
const map = await commonRefs.getRef('map')
if (!map) {
ui.toast('地图未初始化(缺少 Mapbox token)')
return
}
mapInstance = map
mapHelper.toggleMapMode({ is2D: true })
const onReady = () => {
ensureMonitorLayers()
syncMarkers()
if (selectedId.value) flyToSelected()
else fitAllMarkers()
updateMapScale()
}
if (map.isStyleLoaded()) onReady()
else map.once('load', onReady)
map.on('click', onMapClick)
map.on('zoom', updateMapScale)
map.on('move', updateMapScale)
}
function teardownMap() {
if (mapInstance) {
mapInstance.off('click', onMapClick)
mapInstance.off('zoom', updateMapScale)
mapInstance.off('move', updateMapScale)
clearMonitorLayers()
}
mapInstance = null
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'
await setupMap()
} catch (e) {
ui.toast(e.message || '加载设备失败')
}
})
onUnmounted(() => {
window.removeEventListener('keydown', onGlobalKeydown)
closeLive()
teardownMap()
})
watch(mapMarkers, () => {
syncMarkers()
}, { deep: true })
watch(selectedId, () => {
syncMarkers()
if (selectedId.value) flyToSelected()
else fitAllMarkers()
})
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://'))
function toggleLive() {
if (live.session) closeLive()
else openLive()
}
function fullscreenLive() {
if (!canFullscreenLive.value) {
ui.toast('当前无可全屏播放的视频流')
return
}
const ok = livePlayerRef.value?.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) {
return value == null || value === '' ? '--' : `${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('、') : '急停未触发 · 报警总信号正常'
}
})
function openDockStatus() {
if (!selectedId.value) return
dockStatusOpen.value = true
}
function closeDockStatus() {
dockStatusOpen.value = false
}
function onGlobalKeydown(event) {
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 fitAllMarkers()
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 toggleDockSelection(id) {
if (selectedDockIds.has(id)) selectedDockIds.delete(id)
else selectedDockIds.add(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 droneSummary(drone) {
if (drone.statusClass === 'mission') {
const alt = devices.asset(drone.id)?.altitude
if (alt != null) return `飞行中 · ${alt} m`
return '飞行中'
}
if (drone.statusClass === 'offline') return '设备离线'
return drone.battery && drone.battery !== '--' ? `已入巢 · 充电 ${drone.battery}` : '已入巢'
}
function droneTag(drone) {
if (drone.statusClass === 'mission') return '飞行中'
if (drone.statusClass === 'offline') return '离线'
return '在巢'
}
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 })
}
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 {
await devices.sendCommand(rec, title)
ui.toast(`${title}指令已下发`)
await reload()
} catch (e) {
ui.toast(e.message || '指令下发失败')
}
}
</script>