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