diff --git a/admin/src/App.vue b/admin/src/App.vue index 0df615f..66d1563 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -46,7 +46,12 @@ 订单运营 - @@ -117,6 +122,11 @@ :session="session" /> + + ('overview'); +const activeModule = ref<'overview' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview'); const activeTab = ref('tasks'); const lastError = ref(''); const lastMessage = ref(''); @@ -366,6 +377,7 @@ const activeModuleMeta = computed(() => ({ overview: { stage: 'M08-D', title: '平台运营总览' }, stores: { stage: 'M08-D', title: '门店与房间管理' }, orders: { stage: 'M08-D', title: '订单筛选与运营处置' }, + devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' }, payments: { stage: 'M08-D', title: '支付、退款与分账' }, thirdParty: { stage: 'M08-D', title: '团购与第三方平台运营' }, people: { stage: 'M08-D', title: '会员与员工用户管理' }, diff --git a/admin/src/api.ts b/admin/src/api.ts index 3f3ea6c..7de5320 100644 --- a/admin/src/api.ts +++ b/admin/src/api.ts @@ -5,6 +5,8 @@ import type { CleaningTask, CleaningTaskEvent, CleaningTaskMember, + DeviceTopology, + DeviceType, BusinessStatistics, ManagedRoom, MemberCard, @@ -381,6 +383,42 @@ export function getMember(session: ApiSession, memberId: string) { return request(session, `/members/${encodeURIComponent(memberId)}`); } +export function getDeviceTopology(session: ApiSession, storeId: string) { + return request(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) { + return request<{ commandId?: string; status?: string }>(session, `/device-control/${action}`, { method: 'POST', body: JSON.stringify(input) }); +} + export function createStaffUser( session: ApiSession, input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] } diff --git a/admin/src/components/DevicesPanel.vue b/admin/src/components/DevicesPanel.vue new file mode 100644 index 0000000..6ce1503 --- /dev/null +++ b/admin/src/components/DevicesPanel.vue @@ -0,0 +1,95 @@ + + + diff --git a/admin/src/styles.css b/admin/src/styles.css index 6d33b10..d2d3ca4 100644 --- a/admin/src/styles.css +++ b/admin/src/styles.css @@ -1597,6 +1597,67 @@ textarea { 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 { border-radius: 8px; } @@ -1704,6 +1765,8 @@ textarea { .third-party-metrics, .people-metrics, .member-detail-grid, + .device-metrics, + .device-topology-grid, .payment-tools-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -1816,6 +1879,9 @@ textarea { .third-party-metrics, .people-metrics, .member-detail-grid, + .device-metrics, + .device-topology-grid, + .device-control-grid, .payment-tools-grid { grid-template-columns: 1fr; } diff --git a/admin/src/types.ts b/admin/src/types.ts index 81c1c85..a513c6d 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -364,6 +364,79 @@ export interface MemberDetail extends MemberCard { 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; + 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 { id: string; taskNo: string; diff --git a/scripts/check-admin-m08-d.mjs b/scripts/check-admin-m08-d.mjs index c98212c..b104245 100644 --- a/scripts/check-admin-m08-d.mjs +++ b/scripts/check-admin-m08-d.mjs @@ -15,12 +15,14 @@ for (const pattern of [ "activeModule = 'payments'", "activeModule = 'thirdParty'", "activeModule = 'people'", + "activeModule = 'devices'", '平台运营总览', 'StoresRoomsPanel', 'OrdersPanel', 'PaymentsPanel', 'ThirdPartyPanel', 'MembersStaffPanel', + 'DevicesPanel', '运营总览', 'savedToken', 'loadCleaningWorkspace' @@ -159,6 +161,20 @@ for (const pattern of [ const userRoutes = read('backend/src/routes/user-management.ts'); 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'); assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/); @@ -194,6 +210,8 @@ for (const pattern of [ '.settings-preview', '.people-metrics', '.member-detail-grid', + '.device-metrics', + '.device-topology-grid', '@media (max-width: 980px)', '@media (max-width: 560px)' ]) {