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.
829 lines
30 KiB
829 lines
30 KiB
<template>
|
|
<section class="device-page">
|
|
<header class="device-page-header">
|
|
<div>
|
|
<h1>设备管理</h1>
|
|
<span>{{ updatedLabel }}</span>
|
|
</div>
|
|
<div class="page-actions">
|
|
<t-button shape="square" variant="outline" title="刷新" @click="reload"><svg><use href="#i-refresh" /></svg></t-button>
|
|
<t-button v-if="!isAdmin" theme="primary" @click="openAddDock"><svg><use href="#i-plus" /></svg>添加机巢</t-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">
|
|
<t-tabs v-model="tab" class="device-asset-tabs">
|
|
<t-tab-panel value="docks" :label="docksTabLabel" :destroy-on-hide="false" />
|
|
<t-tab-panel value="drones" :label="dronesTabLabel" :destroy-on-hide="false" />
|
|
</t-tabs>
|
|
|
|
<div class="asset-toolbar">
|
|
<div class="asset-search">
|
|
<t-input
|
|
v-model="search"
|
|
clearable
|
|
placeholder="搜索设备名称、编号或 SN"
|
|
@keyup.enter="onSearchCommit"
|
|
@clear="onSearchCommit"
|
|
>
|
|
<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" />
|
|
<t-button variant="outline" type="button" @click="resetFilters">重置</t-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="asset-table-wrap">
|
|
<t-table
|
|
row-key="id"
|
|
:data="tableData"
|
|
:columns="tableColumns"
|
|
:empty="tableEmpty"
|
|
hover
|
|
@row-click="handleRowClick"
|
|
>
|
|
<template #name="{ row }">
|
|
<div class="asset-name-cell">
|
|
<span class="table-asset-icon" :class="{ drone: tab === 'drones' }">
|
|
<svg><use :href="tab === 'drones' ? '#i-plane' : '#i-home'" /></svg>
|
|
</span>
|
|
<span><strong>{{ row.name }}</strong><small>{{ row.code }}</small></span>
|
|
</div>
|
|
</template>
|
|
<template #status="{ row }">
|
|
<span class="table-status" :class="row.statusClass"><i></i>{{ row.status }}</span>
|
|
</template>
|
|
<template #binding="{ row }">
|
|
<span
|
|
class="binding-cell"
|
|
:class="{ unbound: tab === 'docks' && row.bindingState === 'unbound' }"
|
|
>
|
|
<svg><use href="#i-link" /></svg>{{ row.binding }}
|
|
</span>
|
|
</template>
|
|
<template #actions="{ row }">
|
|
<div class="row-actions" @click.stop>
|
|
<template v-if="tab === 'docks'">
|
|
<t-button v-if="!isAdmin" variant="text" theme="primary" @click="openFirmwareUpgrade(row.id)">固件升级</t-button>
|
|
<t-button v-if="!isAdmin" variant="text" theme="primary" @click="goControl(row.id)">远程控制</t-button>
|
|
<t-button variant="text" theme="primary" @click="goDetail(row.id)">详情</t-button>
|
|
<t-button v-if="!isAdmin" variant="text" theme="danger" @click="deleteDock(row)">删除</t-button>
|
|
</template>
|
|
<template v-else>
|
|
<t-button v-if="!isAdmin" variant="text" theme="primary" @click="ui.toast('请选择航线后上传')">航线上传</t-button>
|
|
<t-button v-if="!isAdmin" variant="text" theme="primary" @click="goControl(row.id)">远程控制</t-button>
|
|
<t-button variant="text" theme="primary" @click="goDetail(row.id)">详情</t-button>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</t-table>
|
|
</div>
|
|
|
|
<div class="table-footer">
|
|
<t-pagination
|
|
v-model:current="pageNum"
|
|
:total="total"
|
|
:page-size="pageSize"
|
|
@current-change="loadTable"
|
|
/>
|
|
</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>
|
|
<t-button shape="square" variant="outline" @click="drawerOpen = false"><svg><use href="#i-x" /></svg></t-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><t-button v-if="!isAdmin" variant="text" theme="primary" @click="ui.toast('管理绑定')">管理绑定</t-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>
|
|
<t-button v-if="!isAdmin && drawerRecord.kind === 'dock'" variant="outline" theme="danger" @click="deleteDock(drawerRecord)">删除机巢</t-button>
|
|
<t-button v-if="!isAdmin && drawerRecord.kind === 'dock'" variant="outline" @click="openEditDock(drawerRecord)">编辑信息</t-button>
|
|
<t-button theme="primary" @click="goDetail(drawerRecord.id)">查看完整详情</t-button>
|
|
</footer>
|
|
</template>
|
|
</aside>
|
|
|
|
<t-dialog
|
|
v-model:visible="addDockVisible"
|
|
header="添加机巢"
|
|
width="520px"
|
|
destroy-on-close
|
|
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="32" 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>
|
|
<t-button variant="outline" type="button" @click="addDockVisible = false">取消</t-button>
|
|
<t-button theme="primary" type="button" @click="submitAddDock">确认添加</t-button>
|
|
</template>
|
|
</t-dialog>
|
|
|
|
<DockEditModal :visible="editDockVisible" :dock="editDockRecord" @close="editDockVisible = false" @saved="handleDockSaved" />
|
|
|
|
<t-dialog
|
|
v-model:visible="firmwareVisible"
|
|
header="固件升级"
|
|
width="500px"
|
|
destroy-on-close
|
|
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>
|
|
<t-button variant="outline" type="button" @click="firmwareVisible = false">取消</t-button>
|
|
<t-button theme="primary" type="button" :disabled="!upgradeFirmwareId || upgrading" @click="doUpgrade">{{ upgrading ? '下发中…' : '开始升级' }}</t-button>
|
|
</template>
|
|
</t-dialog>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, reactive, ref, watch } 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')
|
|
const pageNum = ref(1)
|
|
const pageSize = 20
|
|
const total = ref(0)
|
|
const tableRecords = ref([])
|
|
const docksTotal = ref(0)
|
|
const dronesTotal = ref(0)
|
|
const search = ref('')
|
|
const searchKeyword = ref('')
|
|
const statusFilter = ref('')
|
|
const bindingFilter = ref('')
|
|
let searchTimer = null
|
|
|
|
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('')
|
|
|
|
function findLocalRecord(id) {
|
|
const key = String(id)
|
|
return tableRecords.value.find((d) => d.id === key) || devices.record(key)
|
|
}
|
|
|
|
const firmwareRecord = computed(() => (drawerId.value ? findLocalRecord(drawerId.value) : null))
|
|
const upgradeFirmwareOptions = computed(() =>
|
|
firmwareList.value.map((f) => ({ label: firmwareOptionLabel(f), value: f.id }))
|
|
)
|
|
const drawerRecord = computed(() => (drawerId.value ? findLocalRecord(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 fmtTime(t) {
|
|
if (!t) return '--'
|
|
const d = new Date(t)
|
|
if (Number.isNaN(d.getTime())) return '--'
|
|
return d.toLocaleString('zh-CN', { hour12: false })
|
|
}
|
|
|
|
function parseJson(value, fallback = null) {
|
|
if (!value) return fallback
|
|
if (typeof value === 'object') return value
|
|
try { return JSON.parse(value) } catch { return fallback }
|
|
}
|
|
|
|
function alarmCodes(realtime) {
|
|
const codes = parseJson(realtime?.alarmCodes, [])
|
|
return Array.isArray(codes) ? codes : []
|
|
}
|
|
|
|
function modeText(m) {
|
|
const key = String(m || '').toUpperCase()
|
|
switch (key) {
|
|
case 'STANDBY': return '待命'
|
|
case 'AUTO': return 'AUTO'
|
|
case 'GUIDED': return 'GUIDED'
|
|
case 'LOITER': return '悬停'
|
|
case 'RTL': return '返航'
|
|
default: return m || '--'
|
|
}
|
|
}
|
|
|
|
function controlModeText(m) {
|
|
const key = String(m || '').toLowerCase()
|
|
if (key === 'auto' || key === 'automatic') return '自动'
|
|
if (key === 'manual') return '手动'
|
|
return m || '--'
|
|
}
|
|
|
|
function displayValue(value) {
|
|
return value == null || value === '' ? '--' : String(value)
|
|
}
|
|
|
|
function coordinate(value, min, max) {
|
|
const number = Number(value)
|
|
return Number.isFinite(number) && number >= min && number <= max ? number : null
|
|
}
|
|
|
|
function gpsText(q) {
|
|
const key = String(q || '').toUpperCase().replace(/-/g, '_')
|
|
switch (key) {
|
|
case 'RTK_FIX':
|
|
case 'RTK_FIXED': return 'RTK 固定解'
|
|
case 'RTK_FLOAT': return 'RTK 浮点解'
|
|
case '3D_FIX': return '3D 定位'
|
|
case '2D_FIX': return '2D 定位'
|
|
default: return q ? String(q) : '无定位'
|
|
}
|
|
}
|
|
|
|
function mapDockRow(d, detail = {}) {
|
|
const id = String(d.id)
|
|
const rt = detail.realtime || {}
|
|
const online = Boolean(detail.online)
|
|
const registerStatus = d.registerStatus || 'registered'
|
|
const pending = registerStatus === 'pending' && !online
|
|
const st = pending
|
|
? { status: '待连接', statusClass: 'pending' }
|
|
: online
|
|
? alarmCodes(rt).length ? { status: '告警', statusClass: 'alarm' } : { status: '在线', statusClass: 'online' }
|
|
: { status: '离线', statusClass: 'offline' }
|
|
const cachedDrone = devices.drones.find((dr) => dr.dockId === d.dockId)
|
|
return {
|
|
id,
|
|
kind: 'dock',
|
|
dockId: d.dockId,
|
|
name: d.name || d.dockId,
|
|
code: d.code || d.sn || d.dockId,
|
|
sn: d.sn || d.dockId || '--',
|
|
iccid: d.iccid || '--',
|
|
longitude: coordinate(d.longitude, -180, 180),
|
|
latitude: coordinate(d.latitude, -90, 90),
|
|
location: d.location || '--',
|
|
status: st.status,
|
|
statusClass: st.statusClass,
|
|
binding: cachedDrone?.name || '--',
|
|
bindingState: cachedDrone ? 'bound' : 'unbound',
|
|
mode: controlModeText(rt.controlMode),
|
|
version: d.softwareVer ? `v${d.softwareVer}` : '--',
|
|
updated: fmtTime(d.updatedAt),
|
|
protocol: d.protocolVer ? `MQTT v${d.protocolVer}` : '--',
|
|
registerStatus,
|
|
realtime: rt,
|
|
online
|
|
}
|
|
}
|
|
|
|
function mapDroneRow(d, detail = {}) {
|
|
const id = String(d.id)
|
|
const rt = detail.realtime || {}
|
|
const armed = ['1', 'true', 1, true].includes(rt.armed)
|
|
const altitude = coordinate(rt.altitude, -1000, 100000)
|
|
const flying = armed || (altitude != null && altitude > 0)
|
|
const online = Boolean(detail.online)
|
|
const st = !online
|
|
? { status: '离线', statusClass: 'offline' }
|
|
: flying
|
|
? { status: '飞行中', statusClass: 'mission' }
|
|
: { status: '在巢', statusClass: 'online' }
|
|
const dock = devices.docks.find((dk) => dk.dockId === d.dockId)
|
|
const battery = rt.batteryPercent ?? rt.batteryPct ?? d.battery
|
|
return {
|
|
id,
|
|
kind: 'drone',
|
|
droneSn: d.droneSn,
|
|
dockId: d.dockId,
|
|
name: d.name || d.droneSn,
|
|
code: d.code || d.droneSn,
|
|
status: st.status,
|
|
statusClass: st.statusClass,
|
|
binding: dock?.name || '--',
|
|
bindingState: dock ? 'bound' : 'unbound',
|
|
battery: battery != null && battery !== '' ? `${battery}%` : '--',
|
|
mode: modeText(rt.flightMode),
|
|
mission: displayValue(rt.missionName || rt.mission),
|
|
updated: fmtTime(d.updatedAt),
|
|
sysid: displayValue(rt.currentSysId),
|
|
gps: gpsText(rt.gpsQuality),
|
|
sn: d.droneSn || d.code || '--',
|
|
model: d.model || '--',
|
|
firmwareVer: d.firmwareVer || '--',
|
|
realtime: rt,
|
|
online
|
|
}
|
|
}
|
|
|
|
async function enrichRecords(records, kind) {
|
|
if (!records.length) return []
|
|
const details = await Promise.all(
|
|
records.map((row) =>
|
|
request
|
|
.get(kind === 'docks' ? urls.DOCK(row.id) : urls.DRONE(row.id))
|
|
.then((r) => r || {})
|
|
.catch(() => ({}))
|
|
)
|
|
)
|
|
return records.map((row, i) => (kind === 'docks' ? mapDockRow(row, details[i]) : mapDroneRow(row, details[i])))
|
|
}
|
|
|
|
async function loadTable() {
|
|
const kind = tab.value
|
|
const params = { pageNum: pageNum.value, pageSize }
|
|
const keyword = searchKeyword.value.trim()
|
|
if (keyword) params.keyword = keyword
|
|
try {
|
|
const page = await request.get(kind === 'docks' ? urls.DOCKS : urls.DRONES, { params })
|
|
const records = page?.records || []
|
|
total.value = Number(page?.total) || 0
|
|
if (kind === 'docks') docksTotal.value = total.value
|
|
else dronesTotal.value = total.value
|
|
tableRecords.value = await enrichRecords(records, kind)
|
|
} catch (e) {
|
|
tableRecords.value = []
|
|
total.value = 0
|
|
ui.toast(e.message || '加载设备列表失败')
|
|
}
|
|
}
|
|
|
|
function matchesClientFilters(d) {
|
|
if (statusFilter.value && d.statusClass !== statusFilter.value) return false
|
|
if (bindingFilter.value && d.bindingState !== bindingFilter.value) return false
|
|
return true
|
|
}
|
|
|
|
const docksTabLabel = computed(() => `机巢设备 ${docksTotal.value || devices.docks.length}`)
|
|
const dronesTabLabel = computed(() => `无人机 ${dronesTotal.value || devices.drones.length}`)
|
|
|
|
const dockColumns = [
|
|
{ colKey: 'name', title: '机巢设备', width: '18%' },
|
|
{ colKey: 'status', title: '状态' },
|
|
{ colKey: 'binding', title: '绑定无人机' },
|
|
{ colKey: 'mode', title: '控制模式' },
|
|
{ colKey: 'location', title: '设备位置' },
|
|
{ colKey: 'version', title: '软件版本' },
|
|
{ colKey: 'updated', title: '最近上报' },
|
|
{ colKey: 'actions', title: '操作', width: '20%' }
|
|
]
|
|
|
|
const droneColumns = [
|
|
{ colKey: 'name', title: '无人机', width: '18%' },
|
|
{ colKey: 'status', title: '状态' },
|
|
{ colKey: 'binding', title: '所属机巢' },
|
|
{ colKey: 'battery', title: '电量' },
|
|
{ colKey: 'mode', title: '飞行模式' },
|
|
{ colKey: 'mission', title: '当前任务' },
|
|
{ colKey: 'updated', title: '最近上报' },
|
|
{ colKey: 'actions', title: '操作', width: '22%' }
|
|
]
|
|
|
|
const tableColumns = computed(() => (tab.value === 'docks' ? dockColumns : droneColumns))
|
|
const tableData = computed(() => tableRecords.value.filter(matchesClientFilters))
|
|
const tableEmpty = computed(() =>
|
|
statusFilter.value || bindingFilter.value
|
|
? '当前页无符合条件的设备(状态/绑定仅筛选当前页)'
|
|
: '暂无符合条件的设备'
|
|
)
|
|
|
|
function onSearchCommit() {
|
|
if (searchTimer) {
|
|
clearTimeout(searchTimer)
|
|
searchTimer = null
|
|
}
|
|
const next = search.value.trim()
|
|
if (searchKeyword.value === next && pageNum.value === 1) {
|
|
loadTable()
|
|
return
|
|
}
|
|
searchKeyword.value = next
|
|
pageNum.value = 1
|
|
loadTable()
|
|
}
|
|
|
|
watch(search, (value) => {
|
|
if (searchTimer) clearTimeout(searchTimer)
|
|
searchTimer = setTimeout(() => {
|
|
searchTimer = null
|
|
const next = String(value || '').trim()
|
|
if (searchKeyword.value === next) return
|
|
searchKeyword.value = next
|
|
pageNum.value = 1
|
|
loadTable()
|
|
}, 300)
|
|
})
|
|
|
|
watch(tab, () => {
|
|
if (searchTimer) {
|
|
clearTimeout(searchTimer)
|
|
searchTimer = null
|
|
}
|
|
search.value = ''
|
|
searchKeyword.value = ''
|
|
statusFilter.value = ''
|
|
bindingFilter.value = ''
|
|
drawerOpen.value = false
|
|
pageNum.value = 1
|
|
loadTable()
|
|
})
|
|
|
|
function handleRowClick({ row }) {
|
|
openDrawer(row.id)
|
|
}
|
|
|
|
function resetFilters(reload = true) {
|
|
if (searchTimer) {
|
|
clearTimeout(searchTimer)
|
|
searchTimer = null
|
|
}
|
|
search.value = ''
|
|
searchKeyword.value = ''
|
|
statusFilter.value = ''
|
|
bindingFilter.value = ''
|
|
if (reload) {
|
|
pageNum.value = 1
|
|
loadTable()
|
|
}
|
|
}
|
|
|
|
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 Promise.all([loadTable(), 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 loadTable()
|
|
await devices.load()
|
|
} catch (e) {
|
|
ui.toast(e.message || '删除机巢失败')
|
|
}
|
|
}
|
|
|
|
async function handleDockSaved() {
|
|
editDockVisible.value = false
|
|
try {
|
|
await loadTable()
|
|
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)) {
|
|
addDockError.value = '机巢 SN 已存在。'
|
|
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 loadTable()
|
|
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
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
await Promise.all([devices.load(), loadTable()])
|
|
loadedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
|
} catch (e) {
|
|
ui.toast(e.message || '加载设备失败')
|
|
}
|
|
if (!isAdmin.value) loadFirmwares()
|
|
})
|
|
</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;
|
|
}
|
|
.page-actions :deep(.t-button svg) {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
.row-actions :deep(.t-button) {
|
|
height: 28px;
|
|
}
|
|
.drawer-section-title :deep(.t-button) {
|
|
font-size: 11px;
|
|
}
|
|
.device-asset-tabs :deep(.t-tabs__nav-container) {
|
|
padding: 0 18px;
|
|
}
|
|
.device-asset-tabs :deep(.t-tabs__nav-wrap) {
|
|
margin-left: 0;
|
|
}
|
|
.asset-table-wrap :deep(.t-table) {
|
|
background: transparent;
|
|
}
|
|
.asset-table-wrap :deep(.t-table__body tr) {
|
|
cursor: pointer;
|
|
}
|
|
</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>
|
|
|