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.
 
 
 
 

572 lines
22 KiB

<template>
<section class="device-page">
<header class="device-page-header">
<div>
<h1>设备管理</h1>
<span>{{ updatedLabel }}</span>
</div>
<div class="page-actions">
<button class="icon-command" title="刷新" @click="reload"><svg><use href="#i-refresh" /></svg></button>
<button v-if="!isAdmin" class="primary-command" @click="openAddDock"><svg><use href="#i-plus" /></svg>添加机巢</button>
</div>
</header>
<div class="asset-overview">
<div><span>机巢总数</span><strong>{{ overview.docks }}</strong><small>台</small></div>
<div><span>在线设备</span><strong class="green-text">{{ overview.online }}</strong><small>台</small></div>
<div><span>执行任务</span><strong class="blue-text">{{ overview.mission }}</strong><small>台</small></div>
<div><span>当前告警</span><strong class="red-text">{{ overview.alarm }}</strong><small>台</small></div>
<div><span>绑定无人机</span><strong>{{ overview.boundDrones }}</strong><small>架</small></div>
</div>
<div class="asset-workspace">
<div class="asset-tabs">
<button class="asset-tab" :class="{ active: tab === 'docks' }" @click="setTab('docks')">机巢设备<b>{{ devices.docks.length }}</b></button>
<button class="asset-tab" :class="{ active: tab === 'drones' }" @click="setTab('drones')">无人机<b>{{ devices.drones.length }}</b></button>
</div>
<div class="asset-toolbar">
<div class="asset-search">
<t-input v-model="search" clearable placeholder="搜索设备名称、编号或 SN">
<template #prefix-icon><t-icon name="search" /></template>
</t-input>
</div>
<div class="toolbar-group">
<t-select v-model="statusFilter" :options="statusOptions" placeholder="全部状态" style="width: 140px" />
<t-select v-model="bindingFilter" :options="bindingOptions" placeholder="全部绑定状态" style="width: 160px" />
<button class="plain-command" type="button" @click="resetFilters">重置</button>
</div>
</div>
<div class="asset-table-wrap">
<table class="asset-table">
<thead>
<tr v-if="tab === 'docks'">
<th style="width:18%">机巢设备</th>
<th>状态</th>
<th>绑定无人机</th>
<th>控制模式</th>
<th>设备位置</th>
<th>软件版本</th>
<th>最近上报</th>
<th style="width:20%">操作</th>
</tr>
<tr v-else>
<th style="width:18%">无人机</th>
<th>状态</th>
<th>所属机巢</th>
<th>电量</th>
<th>飞行模式</th>
<th>当前任务</th>
<th>最近上报</th>
<th style="width:22%">操作</th>
</tr>
</thead>
<tbody v-if="tab === 'docks'">
<tr v-for="d in filteredDocks" :key="d.id" @click="openDrawer(d.id)">
<td>
<div class="asset-name-cell">
<span class="table-asset-icon"><svg><use href="#i-home" /></svg></span>
<span><strong>{{ d.name }}</strong><small>{{ d.code }}</small></span>
</div>
</td>
<td><span class="table-status" :class="d.statusClass"><i></i>{{ d.status }}</span></td>
<td><span class="binding-cell" :class="{ unbound: d.bindingState === 'unbound' }"><svg><use href="#i-link" /></svg>{{ d.binding }}</span></td>
<td>{{ d.mode }}</td>
<td>{{ d.location }}</td>
<td>{{ d.version }}</td>
<td>{{ d.updated }}</td>
<td>
<div class="row-actions">
<button v-if="!isAdmin" @click.stop="openFirmwareUpgrade(d.id)">固件升级</button>
<button v-if="!isAdmin" @click.stop="goControl(d.id)">远程控制</button>
<button @click.stop="goDetail(d.id)">详情</button>
<button v-if="!isAdmin" @click.stop="deleteDock(d)">删除</button>
</div>
</td>
</tr>
<tr v-if="!filteredDocks.length"><td class="table-empty" colspan="8">暂无符合条件的设备</td></tr>
</tbody>
<tbody v-else>
<tr v-for="d in filteredDrones" :key="d.id" @click="openDrawer(d.id)">
<td>
<div class="asset-name-cell">
<span class="table-asset-icon drone"><svg><use href="#i-plane" /></svg></span>
<span><strong>{{ d.name }}</strong><small>{{ d.code }}</small></span>
</div>
</td>
<td><span class="table-status" :class="d.statusClass"><i></i>{{ d.status }}</span></td>
<td><span class="binding-cell"><svg><use href="#i-link" /></svg>{{ d.binding }}</span></td>
<td>{{ d.battery }}</td>
<td>{{ d.mode }}</td>
<td>{{ d.mission }}</td>
<td>{{ d.updated }}</td>
<td>
<div class="row-actions">
<button v-if="!isAdmin" @click.stop="ui.toast('请选择航线后上传')">航线上传</button>
<button v-if="!isAdmin" @click.stop="goControl(d.id)">远程控制</button>
<button @click.stop="goDetail(d.id)">详情</button>
</div>
</td>
</tr>
<tr v-if="!filteredDrones.length"><td class="table-empty" colspan="8">暂无符合条件的设备</td></tr>
</tbody>
</table>
</div>
<div class="table-footer">
<span>共 {{ tab === 'docks' ? filteredDocks.length : filteredDrones.length }} 条</span>
<div>
<button disabled><svg><use href="#i-chevron" /></svg></button>
<b>1</b>
<button disabled><svg><use href="#i-chevron" /></svg></button>
</div>
</div>
</div>
<!-- Drawer -->
<div class="drawer-scrim" :class="{ open: drawerOpen }" @click="drawerOpen = false"></div>
<aside class="device-drawer" :class="{ open: drawerOpen }">
<template v-if="drawerRecord">
<header>
<div class="drawer-identity">
<span :class="{ drone: drawerRecord.kind === 'drone' }"><svg><use :href="drawerRecord.kind === 'drone' ? '#i-plane' : '#i-home'" /></svg></span>
<div>
<small>{{ drawerRecord.kind === 'drone' ? '无人机设备' : '机巢设备' }}</small>
<h2>{{ drawerRecord.name }}</h2>
<p>{{ drawerRecord.code }}</p>
</div>
</div>
<button class="icon-button" @click="drawerOpen = false"><svg><use href="#i-x" /></svg></button>
</header>
<div class="drawer-status">
<em class="status" :class="drawerRecord.statusClass">{{ drawerRecord.status }}</em>
<span>{{ drawerStatusText }}</span>
</div>
<section class="drawer-section">
<h3>基本信息</h3>
<dl class="property-list">
<template v-if="drawerRecord.kind === 'dock'">
<div><dt>机巢 SN</dt><dd>{{ drawerRecord.sn }}</dd></div>
<div><dt>通信卡 ICCID</dt><dd>{{ drawerRecord.iccid }}</dd></div>
<div><dt>设备位置</dt><dd>{{ drawerRecord.location }}</dd></div>
<div><dt>经度</dt><dd>{{ displayCoord(drawerRecord.longitude) }}</dd></div>
<div><dt>纬度</dt><dd>{{ displayCoord(drawerRecord.latitude) }}</dd></div>
<div><dt>控制模式</dt><dd>{{ drawerRecord.mode }}</dd></div>
<div><dt>软件版本</dt><dd>{{ drawerRecord.version }}</dd></div>
</template>
<template v-else>
<div><dt>飞控 SN</dt><dd>{{ drawerRecord.sn }}</dd></div>
<div><dt>当前 SysID</dt><dd>{{ drawerRecord.sysid }}</dd></div>
<div><dt>飞行模式</dt><dd>{{ drawerRecord.mode }}</dd></div>
<div><dt>剩余电量</dt><dd>{{ drawerRecord.battery }}</dd></div>
<div><dt>定位状态</dt><dd>{{ drawerRecord.gps }}</dd></div>
<div><dt>最近上报</dt><dd>{{ drawerRecord.updated }}</dd></div>
</template>
</dl>
</section>
<section class="drawer-section">
<div class="drawer-section-title"><h3>绑定关系</h3><button v-if="!isAdmin" @click="ui.toast('管理绑定')">管理绑定</button></div>
<button class="binding-device" @click="goBinding">
<span><svg><use :href="drawerRecord.kind === 'drone' ? '#i-home' : '#i-plane'" /></svg></span>
<div><strong>{{ drawerRecord.binding }}</strong><small>{{ drawerBindingText }}</small></div>
<svg><use href="#i-chevron" /></svg>
</button>
</section>
<footer>
<button v-if="!isAdmin && drawerRecord.kind === 'dock'" class="plain-command danger-command" @click="deleteDock(drawerRecord)">删除机巢</button>
<button v-if="!isAdmin && drawerRecord.kind === 'dock'" class="plain-command" @click="openEditDock(drawerRecord)">编辑信息</button>
<button class="primary-command" @click="goDetail(drawerRecord.id)">查看完整详情</button>
</footer>
</template>
</aside>
<t-dialog
v-model:visible="addDockVisible"
header="添加机巢"
width="520px"
dialog-class-name="add-dock-dialog"
placement="center"
>
<form novalidate @submit.prevent="submitAddDock">
<label><span>机巢名称<b>*</b></span><t-input v-model="dockForm.name" :maxlength="30" placeholder="例如:青山湖 03 号机巢" /></label>
<label><span>机巢 SN<b>*</b></span><t-input v-model="dockForm.sn" :maxlength="40" placeholder="请输入机身铭牌 SN" /></label>
<label><span>通信卡 ICCID<b>*</b></span><t-input v-model="dockForm.iccid" :maxlength="22" placeholder="请输入通信卡 ICCID" /></label>
<div class="form-row">
<label><span>经度<b>*</b></span><t-input-number v-model="dockForm.longitude" theme="normal" :min="-180" :max="180" :step="0.000001" placeholder="例如:120.219375" style="width: 100%" /></label>
<label><span>纬度<b>*</b></span><t-input-number v-model="dockForm.latitude" theme="normal" :min="-90" :max="90" :step="0.000001" placeholder="例如:30.259244" style="width: 100%" /></label>
</div>
<label><span>设备位置<em>选填</em></span><t-input v-model="dockForm.location" :maxlength="50" placeholder="例如:青山湖科技城 C 区" /></label>
<div class="form-error">{{ addDockError }}</div>
</form>
<template #footer>
<button class="plain-command" type="button" @click="addDockVisible = false">取消</button>
<button class="primary-command" type="button" @click="submitAddDock">确认添加</button>
</template>
</t-dialog>
<DockEditModal :visible="editDockVisible" :dock="editDockRecord" @close="editDockVisible = false" @saved="handleDockSaved" />
<t-dialog
v-model:visible="firmwareVisible"
header="固件升级"
width="500px"
dialog-class-name="firmware-dialog"
placement="center"
>
<div class="firmware-content" v-if="firmwareRecord">
<div class="firmware-device">
<span class="table-asset-icon"><svg><use href="#i-home" /></svg></span>
<div>
<strong>{{ firmwareRecord.name }}</strong>
<small>当前版本 {{ firmwareRecord.version }}</small>
</div>
<i class="status" :class="firmwareRecord.statusClass">{{ firmwareRecord.status }}</i>
</div>
<label>
<span>目标固件版本</span>
<t-select
v-model="upgradeFirmwareId"
:options="upgradeFirmwareOptions"
:placeholder="firmwareList.length ? '选择目标版本' : '暂无已发布固件'"
:disabled="!firmwareList.length"
clearable
style="width: 100%"
/>
</label>
<div class="firmware-checks">
<span>升级前请确认设备状态</span>
</div>
<p class="firmware-note">升级期间机巢将暂时停止远程控制和视频服务,请确认当前没有飞行任务。升级完成后设备会自动重启并恢复连接。</p>
<div class="form-error">{{ firmwareError }}</div>
</div>
<template #footer>
<button class="plain-command" type="button" @click="firmwareVisible = false">取消</button>
<button class="primary-command" type="button" :disabled="!upgradeFirmwareId || upgrading" @click="doUpgrade">{{ upgrading ? '下发中' : '开始升级' }}</button>
</template>
</t-dialog>
</section>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useDeviceStore } from '@/stores/modules/devicesStore'
import { useUiStore } from '@/stores/modules/uiStore'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/modules/userStore'
import request from '@/utils/http'
import DockEditModal from '@/components/DockEditModal.vue'
import * as urls from '@/config/urls'
const devices = useDeviceStore()
const ui = useUiStore()
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.isAdmin())
const tab = ref('docks')
onMounted(async () => {
try {
await devices.load()
loadedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} catch (e) {
ui.toast(e.message || '加载设备失败')
}
if (!isAdmin.value) loadFirmwares()
})
const search = ref('')
const statusFilter = ref('')
const bindingFilter = ref('')
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '在线', value: 'online' },
{ label: '任务中', value: 'mission' },
{ label: '告警', value: 'alarm' },
{ label: '离线', value: 'offline' },
{ label: '待连接', value: 'pending' }
]
const bindingOptions = [
{ label: '全部绑定状态', value: '' },
{ label: '已绑定', value: 'bound' },
{ label: '未绑定', value: 'unbound' }
]
const loadedAt = ref('')
const drawerOpen = ref(false)
const drawerId = ref(null)
const addDockVisible = ref(false)
const editDockVisible = ref(false)
const editDockRecord = ref(null)
const addDockError = ref('')
const dockForm = reactive({ name: '', sn: '', iccid: '', longitude: undefined, latitude: undefined, location: '' })
const firmwareList = ref([])
const upgradeFirmwareId = ref(null)
const upgrading = ref(false)
const firmwareVisible = ref(false)
const firmwareError = ref('')
const firmwareRecord = computed(() => (drawerId.value ? devices.record(drawerId.value) : null))
const upgradeFirmwareOptions = computed(() =>
firmwareList.value.map((f) => ({ label: firmwareOptionLabel(f), value: f.id }))
)
const drawerRecord = computed(() => (drawerId.value ? devices.record(drawerId.value) : null))
const overview = computed(() => {
const all = [...devices.docks, ...devices.drones]
return {
docks: devices.docks.length,
online: all.filter((d) => d.statusClass === 'online' || d.statusClass === 'mission').length,
mission: all.filter((d) => d.statusClass === 'mission').length,
alarm: all.filter((d) => d.statusClass === 'alarm').length,
boundDrones: devices.drones.filter((d) => d.bindingState === 'bound').length
}
})
const updatedLabel = computed(() => (loadedAt.value ? `数据更新于 ${loadedAt.value}` : '管理机巢与无人机设备的接入、绑定与运行状态'))
const drawerStatusText = computed(() => {
const rec = drawerRecord.value
if (!rec) return ''
if (rec.statusClass === 'pending') return '机巢已添加,等待设备连接'
if (rec.statusClass === 'offline') return `设备最后上报于 ${rec.updated}`
return `状态数据更新于 ${rec.updated}`
})
const drawerBindingText = computed(() => {
const rec = drawerRecord.value
if (!rec) return ''
if (rec.kind === 'dock' && rec.bindingState === 'bound') {
const drone = devices.drones.find((d) => d.dockId === rec.dockId)
return drone?.battery && drone.battery !== '--' ? `已入巢 · 电量 ${drone.battery}` : '绑定关系正常'
}
return rec.bindingState === 'bound' ? '绑定关系正常' : '当前未绑定设备'
})
function displayCoord(value) {
return value == null || value === '' ? '--' : value
}
function matchesFilters(d) {
const q = search.value.trim().toLowerCase()
if (statusFilter.value && d.statusClass !== statusFilter.value) return false
if (bindingFilter.value && d.bindingState !== bindingFilter.value) return false
if (!q) return true
const haystack = [d.name, d.code, d.sn, d.location, d.binding].filter(Boolean).join(' ').toLowerCase()
return haystack.includes(q)
}
const filteredDocks = computed(() => devices.docks.filter(matchesFilters))
const filteredDrones = computed(() => devices.drones.filter(matchesFilters))
function setTab(next) {
tab.value = next
resetFilters()
drawerOpen.value = false
}
function resetFilters() {
search.value = ''
statusFilter.value = ''
bindingFilter.value = ''
}
function openDrawer(id) {
drawerId.value = id
drawerOpen.value = true
}
function goControl(id) {
router.push({ name: 'monitor', query: { deviceId: id, control: '1' } })
}
async function reload() {
try {
await devices.load()
loadedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
ui.toast('设备列表已刷新')
} catch (e) {
ui.toast(e.message || '刷新设备列表失败')
}
}
function goDetail(id) {
drawerOpen.value = false
router.push({ name: 'device-detail', params: { id } })
}
async function openFirmwareUpgrade(id) {
drawerId.value = id
firmwareError.value = ''
upgradeFirmwareId.value = null
firmwareVisible.value = true
await loadFirmwares()
if (!firmwareList.value.length && !firmwareError.value) {
firmwareError.value = '暂无已发布固件,请先在固件管理中发布版本。'
}
}
function goBinding() {
ui.toast('请选择要绑定的设备')
}
function openEditDock(dock) {
editDockRecord.value = dock
editDockVisible.value = true
}
async function deleteDock(dock) {
const ok = await ui.confirm(`确认硬删除机巢「${dock.name}」?`, '删除后机巢及其关联设备数据将永久移除,且无法恢复。')
if (!ok) return
try {
await request.delete(urls.DOCK(dock.id))
drawerOpen.value = false
ui.toast('机巢已永久删除')
await devices.load()
} catch (e) {
ui.toast(e.message || '删除机巢失败')
}
}
async function handleDockSaved() {
editDockVisible.value = false
try {
await devices.load()
ui.toast('机巢信息已更新')
} catch (e) {
ui.toast(e.message || '机巢已保存,但刷新设备数据失败')
}
}
function openAddDock() {
Object.assign(dockForm, { name: '', sn: '', iccid: '', longitude: undefined, latitude: undefined, location: '' })
addDockError.value = ''
addDockVisible.value = true
}
async function submitAddDock() {
addDockError.value = ''
const name = dockForm.name.trim()
const sn = dockForm.sn.trim().toUpperCase()
const iccid = dockForm.iccid.trim().replace(/\s+/g, '')
const longitude = Number(dockForm.longitude)
const latitude = Number(dockForm.latitude)
if (!name || !sn || !iccid || dockForm.longitude === '' || dockForm.longitude == null || dockForm.latitude === '' || dockForm.latitude == null) {
addDockError.value = '请填写机巢名称、机巢 SN、通信卡 ICCID、经度和纬度。'
return
}
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
addDockError.value = '经度应在 -180~180 之间。'
return
}
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
addDockError.value = '纬度应在 -90~90 之间。'
return
}
if (devices.docks.some((dock) => dock.sn.toUpperCase() === sn || dock.iccid.replace(/--/g, '') === iccid)) {
addDockError.value = '机巢 SN 或通信卡 ICCID 已存在。'
return
}
try {
await request.post(urls.DOCKS, {
dockId: sn,
name,
code: sn,
sn,
iccid,
location: dockForm.location.trim(),
longitude,
latitude
})
addDockVisible.value = false
ui.toast('机巢已成功接入')
await devices.load()
} catch (e) {
addDockError.value = e.message || '接入失败'
}
}
async function loadFirmwares() {
try {
const list = await request.get(urls.FIRMWARES_RELEASED)
const records = Array.isArray(list) ? list : list?.records || []
firmwareList.value = records.map((f) => ({
id: String(f.id),
version: f.version,
description: f.description || '',
mandatory: Number(f.mandatory) === 1
}))
} catch (e) {
firmwareList.value = []
firmwareError.value = e.message || '加载固件版本失败'
}
}
function firmwareOptionLabel(f) {
const tag = f.mandatory ? '强制升级' : '推荐稳定版'
return f.description ? `${f.version} · ${tag} · ${f.description}` : `${f.version} · ${tag}`
}
async function doUpgrade() {
const rec = firmwareRecord.value
firmwareError.value = ''
if (!rec || !upgradeFirmwareId.value) {
firmwareError.value = '请选择目标固件版本。'
return
}
if (rec.statusClass === 'offline') {
firmwareError.value = '设备离线,无法下发升级。'
return
}
const ok = await ui.confirm('确认升级固件?', `将把机巢「${rec.name}」升级到所选版本,升级期间设备可能短暂离线。`)
if (!ok) return
upgrading.value = true
try {
await request.post(urls.DOCK_FIRMWARE_UPGRADE(rec.id), { firmwareId: Number(upgradeFirmwareId.value) })
firmwareVisible.value = false
upgradeFirmwareId.value = null
ui.toast('升级指令已下发')
} catch (e) {
firmwareError.value = e.message || '升级下发失败'
} finally {
upgrading.value = false
}
}
</script>
<style scoped>
.asset-search :deep(.t-input) {
width: 100%;
}
.asset-search {
border: 0;
padding: 0;
height: auto;
}
.toolbar-group :deep(.t-select) {
flex-shrink: 0;
}
</style>
<style>
.add-dock-dialog .t-input,
.add-dock-dialog .t-input-number,
.firmware-dialog .t-select {
width: 100%;
}
.add-dock-dialog .t-dialog__footer,
.firmware-dialog .t-dialog__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.firmware-dialog .firmware-content {
padding: 0;
}
</style>