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

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