From b6838c1bc1bce7456017f8f4af1fe51475aa6d71 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 4 May 2026 23:47:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20BLE=20=E7=BD=91=E5=85=B3=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20UI=20=E2=80=94=20Phase=202b-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 网关 CRUD 列表页(状态筛选/密钥刷新/API Key 创建展示) + 网关详情页(信息面板/设备绑定管理 Tab) --- apps/web/src/App.tsx | 4 + apps/web/src/api/health/bleGateways.ts | 170 +++++++++++ apps/web/src/layouts/MainLayout.tsx | 2 + .../web/src/pages/health/BleGatewayDetail.tsx | 254 ++++++++++++++++ apps/web/src/pages/health/BleGatewayList.tsx | 286 ++++++++++++++++++ 5 files changed, 716 insertions(+) create mode 100644 apps/web/src/api/health/bleGateways.ts create mode 100644 apps/web/src/pages/health/BleGatewayDetail.tsx create mode 100644 apps/web/src/pages/health/BleGatewayList.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d7574bd..a3579fb 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -58,6 +58,8 @@ const CarePlanDetail = lazy(() => import('./pages/health/CarePlanDetail')); const ShiftList = lazy(() => import('./pages/health/ShiftList')); const ShiftDetail = lazy(() => import('./pages/health/ShiftDetail')); const MedicationRecordList = lazy(() => import('./pages/health/MedicationRecordList')); +const BleGatewayList = lazy(() => import('./pages/health/BleGatewayList')); +const BleGatewayDetail = lazy(() => import('./pages/health/BleGatewayDetail')); // 内容管理 const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList')); @@ -281,6 +283,8 @@ export default function App() { } /> } /> } /> + } /> + } /> {/* 内容管理 */} } /> } /> diff --git a/apps/web/src/api/health/bleGateways.ts b/apps/web/src/api/health/bleGateways.ts new file mode 100644 index 0000000..4c1c8b5 --- /dev/null +++ b/apps/web/src/api/health/bleGateways.ts @@ -0,0 +1,170 @@ +import client from '../client'; +import type { PaginatedResponse } from '../types'; + +// --- Types --- + +export interface BleGateway { + id: string; + tenant_id: string; + gateway_id: string; + name: string; + status: string; + firmware_version?: string; + ip_address?: string; + last_heartbeat_at?: string; + metadata?: Record; + created_at: string; + updated_at: string; + version: number; + api_key?: string; + patient_count?: number; +} + +export interface GatewayBinding { + id: string; + tenant_id: string; + gateway_id: string; + patient_id: string; + peripheral_mac?: string; + device_type?: string; + status: string; + created_at: string; + updated_at: string; + version: number; +} + +export interface CreateBleGatewayReq { + gateway_id: string; + name: string; + firmware_version?: string; + metadata?: Record; +} + +export interface UpdateBleGatewayReq { + name?: string; + status?: string; + firmware_version?: string; + metadata?: Record; +} + +export interface ListBleGatewaysParams { + page?: number; + page_size?: number; + status?: string; +} + +export interface CreateBindingReq { + patient_id: string; + peripheral_mac?: string; + device_type?: string; +} + +export interface BatchBindReq { + bindings: CreateBindingReq[]; +} + +// --- Constants --- + +export const GATEWAY_STATUS_OPTIONS = [ + { label: '在线', value: 'online' }, + { label: '离线', value: 'offline' }, + { label: '未激活', value: 'inactive' }, + { label: '已禁用', value: 'disabled' }, +]; + +export const GATEWAY_STATUS_COLOR: Record = { + online: 'green', + offline: 'red', + inactive: 'default', + disabled: 'error', +}; + +export const GATEWAY_STATUS_LABEL: Record = Object.fromEntries( + GATEWAY_STATUS_OPTIONS.map((o) => [o.value, o.label]), +); + +export const BINDING_STATUS_COLOR: Record = { + active: 'green', + inactive: 'default', + unbound: 'error', +}; + +// --- API --- + +export const bleGatewayApi = { + // --- Gateways --- + + list: async (params?: ListBleGatewaysParams) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>('/health/ble-gateways', { params }); + return data.data; + }, + + get: async (gatewayId: string) => { + const { data } = await client.get<{ + success: boolean; + data: BleGateway; + }>(`/health/ble-gateways/${gatewayId}`); + return data.data; + }, + + create: async (req: CreateBleGatewayReq) => { + const { data } = await client.post<{ + success: boolean; + data: BleGateway; + }>('/health/ble-gateways', req); + return data.data; + }, + + update: async (gatewayId: string, req: UpdateBleGatewayReq & { version: number }) => { + const { data } = await client.put<{ + success: boolean; + data: BleGateway; + }>(`/health/ble-gateways/${gatewayId}`, req); + return data.data; + }, + + delete: async (gatewayId: string, version: number) => { + await client.delete(`/health/ble-gateways/${gatewayId}`, { data: { version } }); + }, + + regenerateKey: async (gatewayId: string) => { + const { data } = await client.post<{ + success: boolean; + data: BleGateway; + }>(`/health/ble-gateways/${gatewayId}/regenerate-key`); + return data.data; + }, + + // --- Bindings --- + + listBindings: async (gatewayId: string, params?: { page?: number; page_size?: number }) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>(`/health/ble-gateways/${gatewayId}/bindings`, { params }); + return data.data; + }, + + bindPatient: async (gatewayId: string, req: CreateBindingReq) => { + const { data } = await client.post<{ + success: boolean; + data: GatewayBinding; + }>(`/health/ble-gateways/${gatewayId}/bindings`, req); + return data.data; + }, + + batchBind: async (gatewayId: string, req: BatchBindReq) => { + const { data } = await client.post<{ + success: boolean; + data: GatewayBinding[]; + }>(`/health/ble-gateways/${gatewayId}/bindings/batch`, req); + return data.data; + }, + + unbindPatient: async (gatewayId: string, bindingId: string, version: number) => { + await client.delete(`/health/ble-gateways/${gatewayId}/bindings/${bindingId}`, { data: { version } }); + }, +}; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index d69ae25..e2fb194 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -116,6 +116,8 @@ const routeTitleFallback: Record = { '/health/shifts': '班次管理', '/health/shifts/:id': '班次详情', '/health/medications': '药物记录', + '/health/ble-gateways': 'BLE 网关管理', + '/health/ble-gateways/:id': '网关详情', }; function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined { diff --git a/apps/web/src/pages/health/BleGatewayDetail.tsx b/apps/web/src/pages/health/BleGatewayDetail.tsx new file mode 100644 index 0000000..7cefcbc --- /dev/null +++ b/apps/web/src/pages/health/BleGatewayDetail.tsx @@ -0,0 +1,254 @@ +import { useState, useCallback, useEffect } from 'react'; +import { + Button, Descriptions, Form, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Tabs, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { useParams, useNavigate } from 'react-router-dom'; + +import { + bleGatewayApi, + type BleGateway, + type GatewayBinding, + type CreateBindingReq, + GATEWAY_STATUS_LABEL, + GATEWAY_STATUS_COLOR, + BINDING_STATUS_COLOR, +} from '../../api/health/bleGateways'; +import { PageContainer } from '../../components/PageContainer'; + +export default function BleGatewayDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [gateway, setGateway] = useState(null); + const [loading, setLoading] = useState(false); + + // Bindings state + const [bindings, setBindings] = useState([]); + const [bindingsTotal, setBindingsTotal] = useState(0); + const [bindingsPage, setBindingsPage] = useState(1); + const [bindingsLoading, setBindingsLoading] = useState(false); + + // Binding modal + const [bindModalOpen, setBindModalOpen] = useState(false); + const [bindSubmitting, setBindSubmitting] = useState(false); + const [bindForm] = Form.useForm(); + + const pageSize = 20; + + const fetchGateway = useCallback(async () => { + if (!id) return; + setLoading(true); + try { + const resp = await bleGatewayApi.get(id); + setGateway(resp); + } catch { + message.error('加载网关详情失败'); + } finally { + setLoading(false); + } + }, [id]); + + const fetchBindings = useCallback(async (p: number) => { + if (!id) return; + setBindingsLoading(true); + try { + const resp = await bleGatewayApi.listBindings(id, { page: p, page_size: pageSize }); + setBindings(resp.data); + setBindingsTotal(resp.total); + setBindingsPage(p); + } catch { + message.error('加载绑定列表失败'); + } finally { + setBindingsLoading(false); + } + }, [id]); + + useEffect(() => { + fetchGateway(); + fetchBindings(1); + }, [fetchGateway, fetchBindings]); + + const handleBindPatient = () => { + bindForm.resetFields(); + setBindModalOpen(true); + }; + + const handleSubmitBind = async () => { + try { + const values = await bindForm.validateFields(); + setBindSubmitting(true); + const req: CreateBindingReq = values; + await bleGatewayApi.bindPatient(id!, req); + message.success('患者绑定成功'); + setBindModalOpen(false); + fetchBindings(bindingsPage); + fetchGateway(); + } catch { + // validation + } finally { + setBindSubmitting(false); + } + }; + + const handleUnbind = async (binding: GatewayBinding) => { + try { + await bleGatewayApi.unbindPatient(id!, binding.id, binding.version); + message.success('已解除绑定'); + fetchBindings(bindingsPage); + fetchGateway(); + } catch { + message.error('解除绑定失败'); + } + }; + + const bindingColumns: ColumnsType = [ + { + title: '患者 ID', + dataIndex: 'patient_id', + width: 200, + ellipsis: true, + }, + { + title: '外设 MAC', + dataIndex: 'peripheral_mac', + width: 160, + render: (v: string) => v ?? '-', + }, + { + title: '设备类型', + dataIndex: 'device_type', + width: 120, + render: (v: string) => v ?? '-', + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (v: string) => ( + {v} + ), + }, + { + title: '绑定时间', + dataIndex: 'created_at', + width: 170, + render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-', + }, + { + title: '操作', + width: 100, + render: (_, record) => ( + handleUnbind(record)}> + + + ), + }, + ]; + + if (!gateway && !loading) { + return ( + navigate('/health/ble-gateways')}> +
网关不存在或加载失败
+
+ ); + } + + return ( + navigate('/health/ble-gateways')} + loading={loading} + > + + {gateway?.gateway_id} + {gateway?.name} + + + {GATEWAY_STATUS_LABEL[gateway?.status ?? ''] ?? gateway?.status} + + + {gateway?.firmware_version ?? '-'} + {gateway?.ip_address ?? '-'} + {gateway?.patient_count ?? 0} + + {gateway?.last_heartbeat_at + ? dayjs(gateway.last_heartbeat_at).format('YYYY-MM-DD HH:mm:ss') + : '-'} + + + {gateway?.created_at + ? dayjs(gateway.created_at).format('YYYY-MM-DD HH:mm:ss') + : '-'} + + + + + + + rowKey="id" + columns={bindingColumns} + dataSource={bindings} + loading={bindingsLoading} + pagination={{ + current: bindingsPage, + pageSize, + total: bindingsTotal, + showTotal: (t) => `共 ${t} 条`, + onChange: (p) => fetchBindings(p), + }} + /> + + ), + }, + ]} + /> + + setBindModalOpen(false)} + confirmLoading={bindSubmitting} + width={480} + > +
+ + + + + + + + + + + + + rowKey="id" + columns={columns} + dataSource={data} + loading={loading} + pagination={{ + current: page, + pageSize, + total, + showTotal: (t) => `共 ${t} 条`, + onChange: (p) => fetchData(p, statusFilter), + }} + /> + + setModalOpen(false)} + confirmLoading={submitting} + width={520} + > + + {!editRecord && ( + + + + )} + + + + {editRecord && ( + + + + + +
+ ); +}