From cac61637ced3af1ece91d352e3ebc72d14eff574 Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 29 Apr 2026 06:28:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20Web=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E8=AE=BE=E5=A4=87=E6=95=B0=E6=8D=AE=E9=9B=86=E6=88=90?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=20=E2=80=94=20Phase=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增告警三页面(仪表盘/列表/规则)+ 设备管理菜单种子数据 - 新增设备管理后端 API(GET /devices + DELETE /devices/{id}) - 新增设备数据查看组件 DeviceReadingsTab(原始数据 + 小时聚合) - 新增设备管理页面 DeviceManage(列表/筛选/解绑) - 患者详情页新增设备数据 Tab --- apps/web/src/App.tsx | 2 + apps/web/src/api/health/devices.ts | 32 ++ apps/web/src/layouts/MainLayout.tsx | 1 + apps/web/src/pages/health/DeviceManage.tsx | 164 +++++++++ apps/web/src/pages/health/PatientDetail.tsx | 2 + .../health/components/DeviceReadingsTab.tsx | 312 ++++++++++++++++++ crates/erp-health/src/error.rs | 4 + .../erp-health/src/handler/device_handler.rs | 85 +++++ crates/erp-health/src/handler/mod.rs | 1 + crates/erp-health/src/module.rs | 23 +- .../erp-health/src/service/device_service.rs | 72 ++++ crates/erp-health/src/service/mod.rs | 1 + crates/erp-server/migration/src/lib.rs | 2 + ...20260429_000095_seed_alert_device_menus.rs | 84 +++++ 14 files changed, 784 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/api/health/devices.ts create mode 100644 apps/web/src/pages/health/DeviceManage.tsx create mode 100644 apps/web/src/pages/health/components/DeviceReadingsTab.tsx create mode 100644 crates/erp-health/src/handler/device_handler.rs create mode 100644 crates/erp-health/src/service/device_service.rs create mode 100644 crates/erp-server/migration/src/m20260429_000095_seed_alert_device_menus.rs diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ac3fb75..8f0c383 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -47,6 +47,7 @@ const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard')); const AlertList = lazy(() => import('./pages/health/AlertList')); const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard')); const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList')); +const DeviceManage = lazy(() => import('./pages/health/DeviceManage')); // 内容管理 const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList')); @@ -252,6 +253,7 @@ export default function App() { } /> } /> } /> + } /> {/* 内容管理 */} } /> } /> diff --git a/apps/web/src/api/health/devices.ts b/apps/web/src/api/health/devices.ts new file mode 100644 index 0000000..c6347b4 --- /dev/null +++ b/apps/web/src/api/health/devices.ts @@ -0,0 +1,32 @@ +import client from '../client'; +import type { PaginatedResponse } from '../types'; + +// --- Types --- +export interface DeviceItem { + id: string; + patient_id: string; + device_id: string; + device_model: string; + device_type: string; + bound_at: string; + last_sync_at: string; + version: number; +} + +// --- API --- +export const deviceApi = { + listDevices: (params?: { + patient_id?: string; + device_type?: string; + page?: number; + page_size?: number; + }) => + client + .get('/health/devices', { params }) + .then((r) => r.data.data as PaginatedResponse), + + unbindDevice: (id: string, version: number) => + client + .delete(`/health/devices/${id}`, { data: { version } }) + .then((r) => r.data.data as DeviceItem), +}; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 5162bae..ab41509 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -94,6 +94,7 @@ const routeTitleFallback: Record = { '/health/alerts': '告警列表', '/health/alert-dashboard': '告警仪表盘', '/health/alert-rules': '告警规则', + '/health/devices': '设备管理', }; function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined { diff --git a/apps/web/src/pages/health/DeviceManage.tsx b/apps/web/src/pages/health/DeviceManage.tsx new file mode 100644 index 0000000..42982f7 --- /dev/null +++ b/apps/web/src/pages/health/DeviceManage.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Button, Input, message, Popconfirm, Select, Space, Table, Tag } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; + +import { deviceApi, type DeviceItem } from '../../api/health/devices'; + +const DEVICE_TYPE_OPTIONS = [ + { label: '血压', value: 'blood_pressure' }, + { label: '血糖', value: 'blood_glucose' }, + { label: '心率', value: 'heart_rate' }, + { label: '血氧', value: 'blood_oxygen' }, + { label: '步数', value: 'steps' }, + { label: '体温', value: 'temperature' }, +]; + +const DEVICE_TYPE_COLOR: Record = { + blood_pressure: 'red', + blood_glucose: 'purple', + heart_rate: 'volcano', + blood_oxygen: 'blue', + steps: 'green', + temperature: 'orange', +}; + +function formatTime(val?: string | null): string { + if (!val) return '-'; + return dayjs(val).format('YYYY-MM-DD HH:mm'); +} + +export default function DeviceManage() { + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + + const [filterPatientId, setFilterPatientId] = useState(''); + const [filterDeviceType, setFilterDeviceType] = useState(undefined); + + const fetchDevices = useCallback(async () => { + setLoading(true); + try { + const res = await deviceApi.listDevices({ + page, + page_size: 20, + ...(filterPatientId ? { patient_id: filterPatientId } : {}), + ...(filterDeviceType ? { device_type: filterDeviceType } : {}), + }); + setData(res.data); + setTotal(res.total); + } catch { + message.error('加载设备列表失败'); + } finally { + setLoading(false); + } + }, [page, filterPatientId, filterDeviceType]); + + useEffect(() => { + fetchDevices(); + }, [fetchDevices]); + + const handleUnbind = async (record: DeviceItem) => { + try { + await deviceApi.unbindDevice(record.id, record.version); + message.success('设备已解绑'); + fetchDevices(); + } catch { + message.error('解绑失败'); + } + }; + + const columns: ColumnsType = [ + { + title: '设备 ID', + dataIndex: 'device_id', + width: 120, + render: (v: string) => v.slice(0, 8), + }, + { + title: '设备型号', + dataIndex: 'device_model', + width: 160, + }, + { + title: '设备类型', + dataIndex: 'device_type', + width: 100, + render: (v: string) => { + const label = DEVICE_TYPE_OPTIONS.find((d) => d.value === v)?.label || v; + return {label}; + }, + }, + { + title: '绑定时间', + dataIndex: 'bound_at', + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: '最后同步', + dataIndex: 'last_sync_at', + width: 170, + render: (v: string) => formatTime(v), + }, + { + title: '操作', + width: 80, + render: (_, record) => ( + handleUnbind(record)} + okText="确定" + cancelText="取消" + > + + + ), + }, + ]; + + return ( +
+

设备管理

+ + + setFilterPatientId(e.target.value)} + style={{ width: 200 }} + allowClear + /> + { + setDeviceType(v); + refresh(1); + }} + /> + { + setDeviceType(v); + refresh(1); + }} + /> +