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.
479 lines
24 KiB
479 lines
24 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-world" :style="mapStyle" @click="handleMapClick">
|
|
<div class="map-surface"></div>
|
|
<svg v-if="bindingLine" class="binding-line" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
<line :x1="bindingLine.x1" :y1="bindingLine.y1" :x2="bindingLine.x2" :y2="bindingLine.y2" />
|
|
<text :x="(bindingLine.x1 + bindingLine.x2) / 2" :y="(bindingLine.y1 + bindingLine.y2) / 2 - 2">绑定机巢</text>
|
|
</svg>
|
|
|
|
<button
|
|
v-for="a in mapMarkers"
|
|
:key="a.id"
|
|
class="map-marker"
|
|
:class="[a.statusClass, { selected: selectedId === a.id }]"
|
|
:style="{ left: a.x + '%', top: a.y + '%' }"
|
|
@click.stop="select(a.id)"
|
|
>
|
|
<span><svg><use :href="a.type === 'drone' ? '#i-plane' : '#i-home'" /></svg></span>
|
|
<b>{{ a.name }}</b>
|
|
<small v-if="a.type === 'drone' && a.altitude != null">{{ a.altitude }} m</small>
|
|
<em v-if="a.type === 'drone' && a.statusClass === 'mission'"><svg><use href="#i-zap" /></svg></em>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="map-toolbar">
|
|
<span class="map-type active">静态底图</span>
|
|
<button class="icon-button" title="放大" @click="zoomIn"><svg><use href="#i-plus" /></svg></button>
|
|
<button class="icon-button" title="缩小" @click="zoomOut"><svg><use href="#i-minus" /></svg></button>
|
|
<button class="icon-button" title="刷新" @click="reload"><svg><use href="#i-refresh" /></svg></button>
|
|
<button class="map-view-all" type="button" @click="clearSelection">查看全部设备</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>
|
|
|
|
<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-link" /></svg>绑定机巢:{{ selectedDroneParentName }}</span>
|
|
<button v-if="selectedAsset.parent" @click="select(selectedAsset.parent)">切换<svg><use href="#i-chevron" /></svg></button>
|
|
</div>
|
|
|
|
<!-- Dock only: live video -->
|
|
<section v-if="!isDrone" class="video-section">
|
|
<div class="section-title">
|
|
<h3>实时画面</h3>
|
|
<span class="video-unavailable">暂无视频流</span>
|
|
</div>
|
|
<div class="video-frame video-empty">
|
|
<span>暂无实时视频数据</span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Dock only: status metrics -->
|
|
<section v-if="!isDrone" class="status-section">
|
|
<div class="section-title"><h3>机巢状态</h3><span>更新于 {{ record?.updated || '刚刚' }}</span></div>
|
|
<div class="metric-grid">
|
|
<div><svg><use href="#i-battery" /></svg><span>无人机电量</span><strong>{{ selectedDrone?.battery || '--' }}</strong></div>
|
|
<div><svg><use href="#i-wind" /></svg><span>舱内温度</span><strong>{{ displayWithUnit(selectedAsset.environment?.insideTemperature, '℃') }}</strong></div>
|
|
<div><svg><use href="#i-cloud-rain" /></svg><span>舱内湿度</span><strong>{{ displayWithUnit(selectedAsset.environment?.insideHumidity, '%') }}</strong></div>
|
|
<div><svg><use href="#i-satellite" /></svg><span>PLC连接</span><strong>{{ boolText(selectedAsset.realtime?.plcConnected) }}</strong></div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Drone only: flight status -->
|
|
<section v-if="isDrone" class="status-section">
|
|
<div class="section-title"><h3>飞行状态</h3><span>更新于 {{ record?.updated || '刚刚' }}</span></div>
|
|
<div class="metric-grid">
|
|
<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-wind" /></svg><span>飞行速度</span><strong>{{ displayWithUnit(selectedAsset.speed, 'm/s') }}</strong></div>
|
|
<div><svg><use href="#i-satellite" /></svg><span>卫星数量</span><strong>{{ displayWithUnit(selectedAsset.satellites, '颗') }}</strong></div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Drone only: mission -->
|
|
<section class="environment-section">
|
|
<div class="section-title"><h3>环境信息</h3></div>
|
|
<div v-if="hasEnvironment" class="environment-row">
|
|
<div><svg><use href="#i-wind" /></svg><span>风速</span><strong>{{ displayWithUnit(selectedDockEnvironment?.windSpeed, 'm/s') }}</strong></div>
|
|
<div><svg><use href="#i-sun" /></svg><span>室外温度</span><strong>{{ displayWithUnit(selectedDockEnvironment?.outsideTemperature, '℃') }}</strong></div>
|
|
<div><svg><use href="#i-cloud-rain" /></svg><span>降雨</span><strong>{{ boolText(selectedDockEnvironment?.rain) }}</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>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { useDeviceStore } from '../store/devices'
|
|
import { useUiStore } from '../store/ui'
|
|
import { useRouter } from 'vue-router'
|
|
import { useUserStore } from '../store/user'
|
|
|
|
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 filter = ref('all')
|
|
const search = ref('')
|
|
const controlExpanded = ref(false)
|
|
const selectedDockIds = reactive(new Set())
|
|
const mapZoom = ref(1)
|
|
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 }
|
|
]
|
|
|
|
onMounted(async () => {
|
|
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 || '加载设备失败')
|
|
}
|
|
})
|
|
|
|
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 !== ''))
|
|
|
|
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 '--'
|
|
}
|
|
|
|
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)
|
|
})
|
|
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 mapMarkers = computed(() => Object.values(devices.assets).filter((a) => a.hasCoordinates && !a.inDock))
|
|
|
|
const selectedDroneParentName = computed(() => {
|
|
if (!isDrone.value) return ''
|
|
return selectedAsset.value?.parentName || devices.asset(selectedAsset.value?.parent)?.name || '未绑定'
|
|
})
|
|
|
|
const bindingLine = computed(() => {
|
|
if (!isDrone.value || !selectedAsset.value?.hasCoordinates || !selectedAsset.value?.parent) return null
|
|
const dock = devices.asset(selectedAsset.value.parent)
|
|
if (!dock?.hasCoordinates) return null
|
|
return { x1: dock.x, y1: dock.y, x2: selectedAsset.value.x, y2: selectedAsset.value.y }
|
|
})
|
|
|
|
const mapStyle = computed(() => {
|
|
const a = selectedAsset.value
|
|
if (!a || a.x == null) return { transform: `scale(${mapZoom.value})` }
|
|
return {
|
|
transform: `scale(${mapZoom.value * 1.34})`,
|
|
transformOrigin: `${a.x}% ${a.y}%`
|
|
}
|
|
})
|
|
|
|
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
|
|
mapZoom.value = 1
|
|
}
|
|
|
|
function handleMapClick(event) {
|
|
if (event.target === event.currentTarget || event.target.classList.contains('map-surface')) clearSelection()
|
|
}
|
|
|
|
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 num(v) {
|
|
if (v == null) return '--'
|
|
return String(v).replace('%', '')
|
|
}
|
|
|
|
function goDetail() {
|
|
router.push({ name: 'device-detail', params: { id: selectedId.value } })
|
|
}
|
|
|
|
function zoomIn() {
|
|
mapZoom.value = Math.min(2, Math.round((mapZoom.value + 0.15) * 100) / 100)
|
|
}
|
|
function zoomOut() {
|
|
mapZoom.value = Math.max(0.7, Math.round((mapZoom.value - 0.15) * 100) / 100)
|
|
}
|
|
|
|
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>
|
|
|