@@ -0,0 +1,95 @@
< template >
< section class = "devices-page" >
< el-alert v-if = "lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" / >
< section class = "device-metrics" >
< span > < small > 设备总数 < / small > < strong > { { topology . assets . length } } < / strong > < / span >
< span > < small > 在线 < / small > < strong > { { onlineCount } } < / strong > < / span >
< span > < small > 开放告警 < / small > < strong > { { topology . openAlerts . length } } < / strong > < / span >
< span > < small > 维护中 < / small > < strong > { { openMaintenance . length } } < / strong > < / span >
< / section >
< section class = "panel" >
< header class = "panel-toolbar device-toolbar" >
< div > < p class = "section-kicker" > M08 - D · 设备运营 < / p > < h3 > 设备资产 、 拓扑与控制 < / h3 > < / div >
< div class = "toolbar-actions" > < el-select v-model = "storeId" filterable placeholder="选择授权门店" @change="changeStore"><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select><el-button :icon="RefreshCw" :loading="loading" @click="loadTopology">刷新</el-button><el-button type="primary" :icon="Plus" :disabled="!storeId" @click="openAsset" > 设备入库 < / el -button > < / div >
< / header >
< el-tabs v-model = "activeTab" class="device-tabs" >
< el -tab -pane label = "设备资产" name = "assets" >
< el-table v-loading = "loading" :data="topology.assets" class="data-table" row-key="id" >
< el -table -column label = "设备" min -width = " 200 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { typeLabel ( row . deviceType ) } } · { { row . model } } < / strong > < span > { { row . deviceId } } · 资产 # { { row . id } } < / span > < / div > < / template > < / el-table-column >
< el-table-column label = "门店 / 房间" min -width = " 170 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { storeName ( row . storeId ) } } < / strong > < span > { { roomName ( row . roomId ) } } < / span > < / div > < / template > < / el-table-column >
< el-table-column label = "状态" width = "130" > < template # default = "{ row }" > < div class = "stack" > < el-tag :type = "statusType(row.status)" > { { row . status } } < / el-tag > < span > { { row . maintenanceStatus } } < / span > < / div > < / template > < / el-table-column >
< el-table-column label = "固件 / 信号" min -width = " 140 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { row . firmwareVersion || '-' } } < / strong > < span > { { row . signalStrength == null ? '-' : ` ${ row . signalStrength } dBm ` } } < / span > < / div > < / template > < / el-table-column >
< el-table-column label = "能力" min -width = " 190 " > < template # default = "{ row }" > < div class = "tag-list" > < el-tag v-for = "capability in row.capabilities" :key="capability" effect="plain" > {{ capability }} < / el -tag > < / div > < / template > < / el-table-column >
< el-table-column label = "最近心跳" width = "150" > < template # default = "{ row }" > { { shortDate ( row . lastHeartbeatAt || row . lastSeenAt ) } } < / template > < / el-table-column >
< el-table-column label = "操作" width = "270" fixed = "right" > < template # default = "{ row }" > < div class = "row-actions" > < el-button size = "small" :icon = "Wrench" @click ="openMaintenanceDialog(row)" > 维护 < / el -button > < el-button v-if = "row.deviceType !== 'SUB_LOCK'" size="small" :icon="Cable" @click="openChannel(row)">通道</el-button><el-button v-if="row.roomId" size="small" :icon="Power" @click="openControl(row)" > 控制 < / el -button > < / div > < / template > < / el-table-column >
< / el-table >
< / el-tab-pane >
< el-tab-pane label = "通道与拓扑" name = "topology" >
< div class = "tab-action-row" > < span > 同一房间控制目标保持唯一 ; Sub ‑ 1 G 门锁必须绑定控制箱父设备 。 < / span > < el-button :icon = "Link2" @click ="openLink" > 绑定门锁 < / el -button > < / div >
< section class = "device-topology-grid" > < article > < h4 > 控制通道 < / h4 > < el-table :data = "topology.channels" class = "data-table" > < el-table-column label = "设备" min -width = " 120 " > < template # default = "{ row }" > { { assetName ( row . assetId ) } } < / template > < / el-table-column > < el-table-column label = "房间" min -width = " 120 " > < template # default = "{ row }" > { { roomName ( row . roomId ) } } < / template > < / el-table-column > < el-table-column prop = "channelCode" label = "通道" width = "90" / > < el-table-column prop = "purpose" label = "用途" min -width = " 140 " / > < / el-table > < / article > < article > < h4 > Sub ‑ 1 G 父子链路 < / h4 > < el-table :data = "topology.links" class = "data-table" > < el-table-column label = "父设备" min -width = " 120 " > < template # default = "{ row }" > { { assetName ( row . parentAssetId ) } } < / template > < / el-table-column > < el-table-column label = "子设备" min -width = " 120 " > < template # default = "{ row }" > { { assetName ( row . childAssetId ) } } < / template > < / el-table-column > < el-table-column prop = "subId" label = "Sub ID" min -width = " 110 " / > < el-table-column prop = "subtype" label = "型号" width = "90" / > < el-table-column prop = "status" label = "状态" width = "90" / > < / el-table > < / article > < / section >
< / el-tab-pane >
< el-tab-pane : label = "`开放告警 (${topology.openAlerts.length})`" name = "alerts" > < el-table :data = "topology.openAlerts" class = "data-table" > < el-table-column label = "设备 / 房间" min -width = " 180 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { assetName ( row . assetId ) } } < / strong > < span > { { roomName ( row . roomId ) } } < / span > < / div > < / template > < / el-table-column > < el-table-column prop = "alertType" label = "告警类型" min -width = " 140 " / > < el-table-column label = "等级" width = "100" > < template # default = "{ row }" > < el-tag :type = "severityType(row.severity)" > { { row . severity } } < / el-tag > < / template > < / el-table-column > < el-table-column prop = "summary" label = "摘要" min -width = " 240 " / > < el-table-column label = "最后出现" width = "150" > < template # default = "{ row }" > { { shortDate ( row . lastSeenAt ) } } < / template > < / el-table-column > < / el-table > < / el-tab-pane >
< el-tab-pane label = "维护记录" name = "maintenance" > < el-table :data = "topology.maintenance" class = "data-table" > < el-table-column label = "设备 / 房间" min -width = " 180 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { assetName ( row . assetId ) } } < / strong > < span > { { roomName ( row . roomId ) } } < / span > < / div > < / template > < / el-table-column > < el-table-column prop = "recordType" label = "类型" width = "120" / > < el-table-column label = "状态" width = "110" > < template # default = "{ row }" > < el-tag : type = "row.status === 'OPEN' ? 'warning' : 'success'" > { { row . status } } < / el-tag > < / template > < / el-table-column > < el-table-column prop = "description" label = "说明" min -width = " 260 " / > < el-table-column label = "创建 / 解决" min -width = " 190 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { shortDate ( row . createdAt ) } } < / strong > < span > { { shortDate ( row . resolvedAt ) } } < / span > < / div > < / template > < / el-table-column > < / el-table > < / el-tab-pane >
< / el-tabs >
< / section >
< el-dialog v-model = "assetDialog" title="设备入库" width="min(700px, 94vw)"><el-form label-position="top" class="asset-form"><el-form-item label="设备类型"><el-select v-model="assetForm.deviceType"><el-option label="4G 控制箱" value="CONTROL_BOX" /><el-option label="Sub‑ 1G 门锁" value="SUB_LOCK" /><el-option label="4G 智慧插座" value="SMART_SOCKET" /></el-select></el-form-item><el-form-item label="房间"><el-select v-model="assetForm.roomId" clearable><el-option label="门店公共设备" value="" /><el-option v-for="room in rooms" :key="room.id" :label="`${room.roomNo} · ${room.name}`" :value="room.id" /></el-select></el-form-item><el-form-item label="DeviceID"><el-input v-model="assetForm.deviceId" /></el-form-item><el-form-item label="型号"><el-input v-model="assetForm.model" /></el-form-item><el-form-item label="IMEI"><el-input v-model="assetForm.imei" /></el-form-item><el-form-item label="ICCID"><el-input v-model="assetForm.iccid" /></el-form-item><el-form-item label="固件版本"><el-input v-model="assetForm.firmwareVersion" /></el-form-item><el-form-item label="能力(逗号分隔)"><el-input v-model="assetForm.capabilitiesText" placeholder="POWER,LOCK,TTS" /></el-form-item></el-form><template #footer><el-button @click="assetDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveAsset" > 入库 < / el -button > < / template > < / el-dialog >
< el-dialog v-model = "channelDialog" title="绑定设备通道" width="min(580px, 94vw)"><el-form label-position="top"><el-form-item label="房间"><el-select v-model="channelForm.roomId"><el-option v-for="room in rooms" :key="room.id" :label="`${room.roomNo} · ${room.name}`" :value="room.id" /></el-select></el-form-item><el-form-item label="通道"><el-select v-model="channelForm.channelCode"><el-option v-for="code in channelCodes" :key="code" :label="code" :value="code" /></el-select></el-form-item><el-form-item label="用途"><el-select v-model="channelForm.purpose"><el-option v-for="purpose in purposes" :key="purpose" :label="purpose" :value="purpose" /></el-select></el-form-item></el-form><template #footer><el-button @click="channelDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveChannel" > 保存 < / el -button > < / template > < / el-dialog >
< el-dialog v-model = "linkDialog" title="绑定 Sub‑ 1G 门锁" width="min(620px, 94vw)"><el-form label-position="top"><el-form-item label="控制箱"><el-select v-model="linkForm.parentAssetId"><el-option v-for="item in controlBoxes" :key="item.id" :label="assetName(item.id)" :value="item.id" /></el-select></el-form-item><el-form-item label="门锁"><el-select v-model="linkForm.childAssetId"><el-option v-for="item in subLocks" :key="item.id" :label="assetName(item.id)" :value="item.id" /></el-select></el-form-item><el-form-item label="房间"><el-select v-model="linkForm.roomId"><el-option v-for="room in rooms" :key="room.id" :label="`${room.roomNo} · ${room.name}`" :value="room.id" /></el-select></el-form-item><el-form-item label="Sub ID"><el-input v-model="linkForm.subId" /></el-form-item><el-form-item label="型号"><el-select v-model="linkForm.subtype"><el-option label="701C" value="701C" /><el-option label="701G" value="701G" /></el-select></el-form-item></el-form><template #footer><el-button @click="linkDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveLink" > 保存 < / el -button > < / template > < / el-dialog >
< el-dialog v-model = "maintenanceDialog" title="设备维护记录" width="min(600px, 94vw)"><el-form label-position="top"><el-form-item label="类型"><el-select v-model="maintenanceForm.recordType"><el-option label="巡检" value="INSPECTION" /><el-option label="维修" value="REPAIR" /><el-option label="更换" value="REPLACEMENT" /></el-select></el-form-item><el-form-item label="状态"><el-radio-group v-model="maintenanceForm.status"><el-radio value="OPEN">处理中</el-radio><el-radio value="RESOLVED">已解决</el-radio></el-radio-group></el-form-item><el-form-item label="说明"><el-input v-model="maintenanceForm.description" type="textarea" :rows="4" /></el-form-item></el-form><template #footer><el-button @click="maintenanceDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMaintenance" > 保存 < / el -button > < / template > < / el-dialog >
< el-dialog v-model = "controlDialog" title="设备控制" width="min(620px, 94vw)"><el-alert title="命令发布不等于设备已 ACK;请刷新状态或结合现场确认。" type="warning" show-icon :closable="false" /><div class="device-control-grid"><el-button :icon="DoorOpen" @click="runControl('door', { order: 'open', holdopen: 0, delayTime: 4 })">临时开门</el-button><el-button :icon="Power" @click="runControl('power', { slotall: 'on' })">全屋通电</el-button><el-button :icon="PowerOff" @click="runControl('power', { slotall: 'off' })">全屋断电</el-button><el-button :icon="PlugZap" @click="runControl('socket/switch', { on: true, slotNum: 1 })">插座通电</el-button><el-button :icon="Unplug" @click="runControl('socket/switch', { on: false, slotNum: 1 })" > 插座断电 < / el -button > < / div > < / el-dialog >
< / section >
< / template >
< script setup lang = "ts" >
import { computed , reactive , ref , watch } from 'vue' ;
import { ElMessage , ElMessageBox } from 'element-plus' ;
import { Cable , DoorOpen , Link2 , PlugZap , Plus , Power , PowerOff , RefreshCw , Unplug , Wrench } from '@lucide/vue' ;
import { ApiError , addDeviceMaintenance , bindDeviceChannel , bindSubDevice , controlDevice , createDeviceAsset , getDeviceTopology , listManagedRooms , listManagedStores , type ApiSession } from '../api' ;
import { shortDate } from '../format' ;
import type { DeviceAsset , DeviceTopology , DeviceType , ManagedRoom , ManagedStore } from '../types' ;
const props = defineProps < { session : ApiSession } > ( ) ;
const stores = ref < ManagedStore [ ] > ( [ ] ) ; const rooms = ref < ManagedRoom [ ] > ( [ ] ) ; const storeId = ref ( '' ) ;
const topology = reactive < DeviceTopology > ( { assets : [ ] , channels : [ ] , links : [ ] , openAlerts : [ ] , maintenance : [ ] } ) ;
const activeTab = ref ( 'assets' ) ; const loading = ref ( false ) ; const saving = ref ( false ) ; const lastError = ref ( '' ) ;
const assetDialog = ref ( false ) ; const channelDialog = ref ( false ) ; const linkDialog = ref ( false ) ; const maintenanceDialog = ref ( false ) ; const controlDialog = ref ( false ) ;
const assetForm = reactive ( { deviceType : 'CONTROL_BOX' as DeviceType , roomId : '' , deviceId : '' , model : '' , imei : '' , iccid : '' , firmwareVersion : '' , capabilitiesText : '' } ) ;
const channelForm = reactive ( { assetId : '' , roomId : '' , channelCode : 'SLOT1' , purpose : 'ROOM_POWER' } ) ;
const linkForm = reactive ( { parentAssetId : '' , childAssetId : '' , roomId : '' , subId : '' , subtype : '701C' } ) ;
const maintenanceForm = reactive ( { assetId : '' , roomId : '' , recordType : 'INSPECTION' as 'INSPECTION' | 'REPAIR' | 'REPLACEMENT' , status : 'OPEN' as 'OPEN' | 'RESOLVED' , description : '' } ) ;
const controlAsset = ref < DeviceAsset | null > ( null ) ;
const channelCodes = [ 'SLOT1' , 'SLOT2' , 'SLOT3' , 'MAIN' , 'LOCK' , 'LED' , 'TTS' ] ;
const purposes = [ 'ROOM_POWER' , 'AIR_CONDITIONER' , 'LIGHTING' , 'DOOR_MAGNET' , 'STORE_DOOR' , 'ROOM_DOOR' , 'TTS' , 'LED' ] ;
const onlineCount = computed ( ( ) => topology . assets . filter ( ( item ) => item . status === 'ONLINE' ) . length ) ;
const openMaintenance = computed ( ( ) => topology . maintenance . filter ( ( item ) => item . status === 'OPEN' ) ) ;
const controlBoxes = computed ( ( ) => topology . assets . filter ( ( item ) => item . deviceType === 'CONTROL_BOX' ) ) ;
const subLocks = computed ( ( ) => topology . assets . filter ( ( item ) => item . deviceType === 'SUB_LOCK' ) ) ;
async function capture < T > ( work : ( ) => Promise < T > ) { lastError . value = '' ; try { return await work ( ) ; } catch ( error ) { lastError . value = error instanceof ApiError ? ` ${ error . code } ${ error . traceId ? ` · ${ error . traceId } ` : '' } ` : error instanceof Error ? error . message : '操作失败' ; throw error ; } }
async function loadWorkspace ( ) { if ( ! props . session . token ) return ; loading . value = true ; try { stores . value = await capture ( ( ) => listManagedStores ( props . session ) ) ; storeId . value || = stores . value [ 0 ] ? . id || '' ; await changeStore ( ) ; } catch { /* displayed */ } finally { loading . value = false ; } }
async function changeStore ( ) { if ( ! storeId . value ) return ; loading . value = true ; try { [ rooms . value ] = await Promise . all ( [ capture ( ( ) => listManagedRooms ( props . session , storeId . value ) ) , loadTopology ( false ) ] ) ; } catch { /* displayed */ } finally { loading . value = false ; } }
async function loadTopology ( show = true ) { if ( ! storeId . value ) return ; if ( show ) loading . value = true ; try { Object . assign ( topology , await capture ( ( ) => getDeviceTopology ( props . session , storeId . value ) ) ) ; } catch { /* displayed */ } finally { if ( show ) loading . value = false ; } }
function openAsset ( ) { Object . assign ( assetForm , { deviceType : 'CONTROL_BOX' , roomId : '' , deviceId : '' , model : '' , imei : '' , iccid : '' , firmwareVersion : '' , capabilitiesText : '' } ) ; assetDialog . value = true ; }
async function saveAsset ( ) { if ( ! assetForm . deviceId || ! assetForm . model ) return ElMessage . warning ( '请填写 DeviceID 和型号' ) ; saving . value = true ; try { await capture ( ( ) => createDeviceAsset ( props . session , { storeId : storeId . value , roomId : assetForm . roomId || null , deviceId : assetForm . deviceId . trim ( ) , model : assetForm . model . trim ( ) , imei : assetForm . imei . trim ( ) , iccid : assetForm . iccid . trim ( ) || null , deviceType : assetForm . deviceType , firmwareVersion : assetForm . firmwareVersion . trim ( ) , signalStrength : null , capabilities : assetForm . capabilitiesText . split ( /[, ,\s]+/ ) . map ( ( item ) => item . trim ( ) . toUpperCase ( ) ) . filter ( Boolean ) } ) ) ; assetDialog . value = false ; ElMessage . success ( '设备已入库' ) ; await loadTopology ( ) ; } catch { /* displayed */ } finally { saving . value = false ; } }
function openChannel ( asset : DeviceAsset ) { Object . assign ( channelForm , { assetId : asset . id , roomId : asset . roomId || rooms . value [ 0 ] ? . id || '' , channelCode : 'SLOT1' , purpose : 'ROOM_POWER' } ) ; channelDialog . value = true ; }
async function saveChannel ( ) { if ( ! channelForm . roomId ) return ElMessage . warning ( '请选择房间' ) ; saving . value = true ; try { await capture ( ( ) => bindDeviceChannel ( props . session , { ... channelForm , storeId : storeId . value } ) ) ; channelDialog . value = false ; ElMessage . success ( '控制通道已绑定' ) ; await loadTopology ( ) ; } catch { /* displayed */ } finally { saving . value = false ; } }
function openLink ( ) { Object . assign ( linkForm , { parentAssetId : controlBoxes . value [ 0 ] ? . id || '' , childAssetId : subLocks . value [ 0 ] ? . id || '' , roomId : rooms . value [ 0 ] ? . id || '' , subId : '' , subtype : '701C' } ) ; linkDialog . value = true ; }
async function saveLink ( ) { if ( ! linkForm . parentAssetId || ! linkForm . childAssetId || ! linkForm . roomId || ! linkForm . subId ) return ElMessage . warning ( '请完整填写父子设备、房间和 Sub ID' ) ; saving . value = true ; try { await capture ( ( ) => bindSubDevice ( props . session , { ... linkForm , storeId : storeId . value } ) ) ; linkDialog . value = false ; ElMessage . success ( '门锁拓扑已绑定' ) ; await loadTopology ( ) ; } catch { /* displayed */ } finally { saving . value = false ; } }
function openMaintenanceDialog ( asset : DeviceAsset ) { Object . assign ( maintenanceForm , { assetId : asset . id , roomId : asset . roomId || '' , recordType : 'INSPECTION' , status : 'OPEN' , description : '' } ) ; maintenanceDialog . value = true ; }
async function saveMaintenance ( ) { saving . value = true ; try { await capture ( ( ) => addDeviceMaintenance ( props . session , maintenanceForm . assetId , { storeId : storeId . value , roomId : maintenanceForm . roomId || null , recordType : maintenanceForm . recordType , status : maintenanceForm . status , description : maintenanceForm . description . trim ( ) } ) ) ; maintenanceDialog . value = false ; ElMessage . success ( '维护记录已保存' ) ; await loadTopology ( ) ; } catch { /* displayed */ } finally { saving . value = false ; } }
function openControl ( asset : DeviceAsset ) { controlAsset . value = asset ; controlDialog . value = true ; }
async function runControl ( action : 'door' | 'power' | 'socket/switch' , command : Record < string , unknown > ) { const asset = controlAsset . value ; if ( ! asset ? . roomId ) return ; try { await ElMessageBox . confirm ( ` 确认对 ${ roomName ( asset . roomId ) } 下发设备命令? ` , '危险操作确认' , { type : 'warning' } ) ; const result = await capture ( ( ) => controlDevice ( props . session , action , { storeId : storeId . value , roomId : asset . roomId , orderId : null , ... command } ) ) ; ElMessage . success ( ` 命令已受理 ${ result . commandId ? ` · # ${ result . commandId } ` : '' } ,等待设备 ACK ` ) ; controlDialog . value = false ; } catch ( error ) { if ( error !== 'cancel' && error !== 'close' ) { /* displayed */ } } }
function storeName ( id : string ) { return stores . value . find ( ( item ) => item . id === id ) ? . name || ` 门店 # ${ id } ` ; }
function roomName ( id : string | null ) { if ( ! id ) return '门店公共设备' ; const room = rooms . value . find ( ( item ) => item . id === id ) ; return room ? ` ${ room . roomNo } · ${ room . name } ` : ` 房间 # ${ id } ` ; }
function assetName ( id : string ) { const asset = topology . assets . find ( ( item ) => item . id === id ) ; return asset ? ` ${ typeLabel ( asset . deviceType ) } · ${ asset . deviceId } ` : ` 设备 # ${ id } ` ; }
function typeLabel ( type : DeviceType ) { return ( { CONTROL _BOX : '控制箱' , SUB _LOCK : 'Sub‑ 1G 门锁' , SMART _SOCKET : '智慧插座' } ) [ type ] ; }
function statusType ( status : string ) { return status === 'ONLINE' ? 'success' : status === 'FAULT' ? 'danger' : 'info' ; }
function severityType ( value : string ) { return [ 'CRITICAL' , 'HIGH' ] . includes ( value ) ? 'danger' : value === 'MEDIUM' ? 'warning' : 'info' ; }
watch ( ( ) => props . session . token , ( token ) => { if ( token ) void loadWorkspace ( ) ; } , { immediate : true } ) ;
< / script >