feat(M08-D): 补设备运营后台
This commit is contained in:
+14
-2
@@ -46,7 +46,12 @@
|
|||||||
<ClipboardList :size="18" />
|
<ClipboardList :size="18" />
|
||||||
<span>订单运营</span>
|
<span>订单运营</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-item" type="button" disabled>
|
<button
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: activeModule === 'devices' }"
|
||||||
|
type="button"
|
||||||
|
@click="activeModule = 'devices'"
|
||||||
|
>
|
||||||
<RadioTower :size="18" />
|
<RadioTower :size="18" />
|
||||||
<span>设备</span>
|
<span>设备</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -117,6 +122,11 @@
|
|||||||
:session="session"
|
:session="session"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DevicesPanel
|
||||||
|
v-else-if="activeModule === 'devices'"
|
||||||
|
:session="session"
|
||||||
|
/>
|
||||||
|
|
||||||
<PaymentsPanel
|
<PaymentsPanel
|
||||||
v-else-if="activeModule === 'payments'"
|
v-else-if="activeModule === 'payments'"
|
||||||
:session="session"
|
:session="session"
|
||||||
@@ -317,6 +327,7 @@ import OrdersPanel from './components/OrdersPanel.vue';
|
|||||||
import PaymentsPanel from './components/PaymentsPanel.vue';
|
import PaymentsPanel from './components/PaymentsPanel.vue';
|
||||||
import ThirdPartyPanel from './components/ThirdPartyPanel.vue';
|
import ThirdPartyPanel from './components/ThirdPartyPanel.vue';
|
||||||
import MembersStaffPanel from './components/MembersStaffPanel.vue';
|
import MembersStaffPanel from './components/MembersStaffPanel.vue';
|
||||||
|
import DevicesPanel from './components/DevicesPanel.vue';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
assignCleaningTask,
|
assignCleaningTask,
|
||||||
@@ -353,7 +364,7 @@ import { money } from './format';
|
|||||||
|
|
||||||
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
||||||
const tokenDraft = ref(savedToken.value);
|
const tokenDraft = ref(savedToken.value);
|
||||||
const activeModule = ref<'overview' | 'stores' | 'orders' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
|
const activeModule = ref<'overview' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
|
||||||
const activeTab = ref('tasks');
|
const activeTab = ref('tasks');
|
||||||
const lastError = ref('');
|
const lastError = ref('');
|
||||||
const lastMessage = ref('');
|
const lastMessage = ref('');
|
||||||
@@ -366,6 +377,7 @@ const activeModuleMeta = computed(() => ({
|
|||||||
overview: { stage: 'M08-D', title: '平台运营总览' },
|
overview: { stage: 'M08-D', title: '平台运营总览' },
|
||||||
stores: { stage: 'M08-D', title: '门店与房间管理' },
|
stores: { stage: 'M08-D', title: '门店与房间管理' },
|
||||||
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
|
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
|
||||||
|
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
|
||||||
payments: { stage: 'M08-D', title: '支付、退款与分账' },
|
payments: { stage: 'M08-D', title: '支付、退款与分账' },
|
||||||
thirdParty: { stage: 'M08-D', title: '团购与第三方平台运营' },
|
thirdParty: { stage: 'M08-D', title: '团购与第三方平台运营' },
|
||||||
people: { stage: 'M08-D', title: '会员与员工用户管理' },
|
people: { stage: 'M08-D', title: '会员与员工用户管理' },
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
CleaningTask,
|
CleaningTask,
|
||||||
CleaningTaskEvent,
|
CleaningTaskEvent,
|
||||||
CleaningTaskMember,
|
CleaningTaskMember,
|
||||||
|
DeviceTopology,
|
||||||
|
DeviceType,
|
||||||
BusinessStatistics,
|
BusinessStatistics,
|
||||||
ManagedRoom,
|
ManagedRoom,
|
||||||
MemberCard,
|
MemberCard,
|
||||||
@@ -381,6 +383,42 @@ export function getMember(session: ApiSession, memberId: string) {
|
|||||||
return request<MemberDetail>(session, `/members/${encodeURIComponent(memberId)}`);
|
return request<MemberDetail>(session, `/members/${encodeURIComponent(memberId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDeviceTopology(session: ApiSession, storeId: string) {
|
||||||
|
return request<DeviceTopology>(session, `/device-topology?${new URLSearchParams({ storeId })}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeviceAsset(session: ApiSession, input: {
|
||||||
|
storeId: string; roomId: string | null; deviceId: string; imei: string; iccid: string | null;
|
||||||
|
deviceType: DeviceType; model: string; firmwareVersion: string;
|
||||||
|
signalStrength: number | null; capabilities: string[];
|
||||||
|
}) {
|
||||||
|
return request<{ assetId: string }>(session, '/devices', { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindDeviceChannel(session: ApiSession, input: {
|
||||||
|
assetId: string; storeId: string; roomId: string; channelCode: string; purpose: string;
|
||||||
|
}) {
|
||||||
|
return request<{ assetId: string; targetKey: string }>(session, '/device-channels', { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindSubDevice(session: ApiSession, input: {
|
||||||
|
parentAssetId: string; childAssetId: string; storeId: string; roomId: string;
|
||||||
|
subId: string; subtype: string;
|
||||||
|
}) {
|
||||||
|
return request<{ childAssetId: string; parentAssetId: string }>(session, '/device-links', { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDeviceMaintenance(session: ApiSession, assetId: string, input: {
|
||||||
|
storeId: string; roomId: string | null; recordType: 'INSPECTION' | 'REPAIR' | 'REPLACEMENT';
|
||||||
|
status: 'OPEN' | 'RESOLVED'; description: string;
|
||||||
|
}) {
|
||||||
|
return request<{ maintenanceId: string }>(session, `/devices/${encodeURIComponent(assetId)}/maintenance`, { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function controlDevice(session: ApiSession, action: 'door' | 'power' | 'socket/switch', input: Record<string, unknown>) {
|
||||||
|
return request<{ commandId?: string; status?: string }>(session, `/device-control/${action}`, { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
export function createStaffUser(
|
export function createStaffUser(
|
||||||
session: ApiSession,
|
session: ApiSession,
|
||||||
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
||||||
|
|||||||
@@ -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‑1G 门锁必须绑定控制箱父设备。</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‑1G 父子链路</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>
|
||||||
@@ -1597,6 +1597,67 @@ textarea {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.devices-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-metrics span {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d8e0ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-metrics small {
|
||||||
|
color: #69788c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-metrics strong {
|
||||||
|
color: #172033;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-toolbar h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tabs {
|
||||||
|
padding: 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-topology-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-topology-grid article {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e1e7ef;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-topology-grid h4 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-control-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.el-button {
|
.el-button {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@@ -1704,6 +1765,8 @@ textarea {
|
|||||||
.third-party-metrics,
|
.third-party-metrics,
|
||||||
.people-metrics,
|
.people-metrics,
|
||||||
.member-detail-grid,
|
.member-detail-grid,
|
||||||
|
.device-metrics,
|
||||||
|
.device-topology-grid,
|
||||||
.payment-tools-grid {
|
.payment-tools-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -1816,6 +1879,9 @@ textarea {
|
|||||||
.third-party-metrics,
|
.third-party-metrics,
|
||||||
.people-metrics,
|
.people-metrics,
|
||||||
.member-detail-grid,
|
.member-detail-grid,
|
||||||
|
.device-metrics,
|
||||||
|
.device-topology-grid,
|
||||||
|
.device-control-grid,
|
||||||
.payment-tools-grid {
|
.payment-tools-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,6 +364,79 @@ export interface MemberDetail extends MemberCard {
|
|||||||
recentLedger: MemberLedgerEntry[];
|
recentLedger: MemberLedgerEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeviceType = 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
|
||||||
|
|
||||||
|
export interface DeviceAsset {
|
||||||
|
id: string;
|
||||||
|
storeId: string;
|
||||||
|
roomId: string | null;
|
||||||
|
deviceId: string;
|
||||||
|
imei: string;
|
||||||
|
iccid: string | null;
|
||||||
|
deviceType: DeviceType;
|
||||||
|
model: string;
|
||||||
|
firmwareVersion: string;
|
||||||
|
status: 'ONLINE' | 'OFFLINE' | 'FAULT';
|
||||||
|
signalStrength: number | null;
|
||||||
|
capabilities: string[];
|
||||||
|
stateSnapshot: Record<string, unknown>;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
lastHeartbeatAt: string | null;
|
||||||
|
maintenanceStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceChannel {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
roomId: string;
|
||||||
|
channelCode: string;
|
||||||
|
purpose: string;
|
||||||
|
targetKey: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLink {
|
||||||
|
id: string;
|
||||||
|
parentAssetId: string;
|
||||||
|
childAssetId: string;
|
||||||
|
roomId: string;
|
||||||
|
linkType: string;
|
||||||
|
subId: string;
|
||||||
|
subtype: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceAlert {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
roomId: string | null;
|
||||||
|
alertType: string;
|
||||||
|
severity: string;
|
||||||
|
status: string;
|
||||||
|
summary: string;
|
||||||
|
firstSeenAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceMaintenance {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
roomId: string | null;
|
||||||
|
recordType: 'INSPECTION' | 'REPAIR' | 'REPLACEMENT';
|
||||||
|
status: 'OPEN' | 'RESOLVED';
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
resolvedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceTopology {
|
||||||
|
assets: DeviceAsset[];
|
||||||
|
channels: DeviceChannel[];
|
||||||
|
links: DeviceLink[];
|
||||||
|
openAlerts: DeviceAlert[];
|
||||||
|
maintenance: DeviceMaintenance[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CleaningTask {
|
export interface CleaningTask {
|
||||||
id: string;
|
id: string;
|
||||||
taskNo: string;
|
taskNo: string;
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ for (const pattern of [
|
|||||||
"activeModule = 'payments'",
|
"activeModule = 'payments'",
|
||||||
"activeModule = 'thirdParty'",
|
"activeModule = 'thirdParty'",
|
||||||
"activeModule = 'people'",
|
"activeModule = 'people'",
|
||||||
|
"activeModule = 'devices'",
|
||||||
'平台运营总览',
|
'平台运营总览',
|
||||||
'StoresRoomsPanel',
|
'StoresRoomsPanel',
|
||||||
'OrdersPanel',
|
'OrdersPanel',
|
||||||
'PaymentsPanel',
|
'PaymentsPanel',
|
||||||
'ThirdPartyPanel',
|
'ThirdPartyPanel',
|
||||||
'MembersStaffPanel',
|
'MembersStaffPanel',
|
||||||
|
'DevicesPanel',
|
||||||
'运营总览',
|
'运营总览',
|
||||||
'savedToken',
|
'savedToken',
|
||||||
'loadCleaningWorkspace'
|
'loadCleaningWorkspace'
|
||||||
@@ -159,6 +161,20 @@ for (const pattern of [
|
|||||||
const userRoutes = read('backend/src/routes/user-management.ts');
|
const userRoutes = read('backend/src/routes/user-management.ts');
|
||||||
assert.match(userRoutes, /userType: z\.enum\(\['STAFF'\]\)\.optional\(\)/);
|
assert.match(userRoutes, /userType: z\.enum\(\['STAFF'\]\)\.optional\(\)/);
|
||||||
|
|
||||||
|
const devices = read('admin/src/components/DevicesPanel.vue');
|
||||||
|
for (const pattern of [
|
||||||
|
'getDeviceTopology',
|
||||||
|
'createDeviceAsset',
|
||||||
|
'bindDeviceChannel',
|
||||||
|
'bindSubDevice',
|
||||||
|
'addDeviceMaintenance',
|
||||||
|
'controlDevice',
|
||||||
|
'openAlerts',
|
||||||
|
'Sub‑1G'
|
||||||
|
]) {
|
||||||
|
assert.match(devices, new RegExp(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
const routes = read('backend/src/routes/business-statistics.ts');
|
const routes = read('backend/src/routes/business-statistics.ts');
|
||||||
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
|
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
|
||||||
|
|
||||||
@@ -194,6 +210,8 @@ for (const pattern of [
|
|||||||
'.settings-preview',
|
'.settings-preview',
|
||||||
'.people-metrics',
|
'.people-metrics',
|
||||||
'.member-detail-grid',
|
'.member-detail-grid',
|
||||||
|
'.device-metrics',
|
||||||
|
'.device-topology-grid',
|
||||||
'@media (max-width: 980px)',
|
'@media (max-width: 980px)',
|
||||||
'@media (max-width: 560px)'
|
'@media (max-width: 560px)'
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user