From 70aacf47a004647a701b5a94ae54f3073047e791 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 4 May 2026 02:40:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20IoT=20+=20FHIR=20V1=20Plan=205=20?= =?UTF-8?q?=E2=80=94=20Web=20=E5=89=8D=E7=AB=AF=E5=AE=9E=E6=96=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 层: deviceReadings 日聚合查询 + OAuth 合作方 CRUD 接口 - 常量: 设备连接状态/连接类型/实时监控指标常量 - Hook: useVitalSSE — 复用全局 SSE 连接的 vital_update 事件 - 页面: RealtimeMonitor 实时体征监控台 (SSE + 告警排序) - 页面: OAuthClientList FHIR 合作方管理 (CRUD + Secret 重置) - 增强: DeviceManage 设备状态/固件/连接类型列 + 状态筛选 - 路由: 新增 3 个懒加载路由 - 测试: RealtimeMonitor + OAuthClientList 单元测试 --- apps/web/src/App.tsx | 4 + apps/web/src/api/health/deviceReadings.ts | 16 ++ apps/web/src/api/health/devices.ts | 5 + apps/web/src/api/health/oauthClients.ts | 73 ++++++ apps/web/src/constants/health.ts | 33 +++ apps/web/src/hooks/useVitalSSE.ts | 67 ++++++ apps/web/src/pages/health/DeviceManage.tsx | 46 +++- .../src/pages/health/OAuthClientList.test.tsx | 36 +++ apps/web/src/pages/health/OAuthClientList.tsx | 207 ++++++++++++++++++ .../src/pages/health/RealtimeMonitor.test.tsx | 37 ++++ apps/web/src/pages/health/RealtimeMonitor.tsx | 147 +++++++++++++ 11 files changed, 668 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/api/health/oauthClients.ts create mode 100644 apps/web/src/hooks/useVitalSSE.ts create mode 100644 apps/web/src/pages/health/OAuthClientList.test.tsx create mode 100644 apps/web/src/pages/health/OAuthClientList.tsx create mode 100644 apps/web/src/pages/health/RealtimeMonitor.test.tsx create mode 100644 apps/web/src/pages/health/RealtimeMonitor.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 93a6c8a..f49cb36 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -48,6 +48,8 @@ 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 RealtimeMonitor = lazy(() => import('./pages/health/RealtimeMonitor')); +const OAuthClientList = lazy(() => import('./pages/health/OAuthClientList')); const DialysisManageList = lazy(() => import('./pages/health/DialysisManageList')); const ActionInbox = lazy(() => import('./pages/health/ActionInbox')); const FollowUpTemplateList = lazy(() => import('./pages/health/FollowUpTemplateList')); @@ -257,6 +259,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/health/deviceReadings.ts b/apps/web/src/api/health/deviceReadings.ts index b1fdfca..403faff 100644 --- a/apps/web/src/api/health/deviceReadings.ts +++ b/apps/web/src/api/health/deviceReadings.ts @@ -22,6 +22,17 @@ export interface HourlyReading { sample_count: number; } +export interface DailyReading { + id: string; + device_type: string; + date_bucket: string; + min_val?: number; + max_val?: number; + avg_val: number; + sample_count: number; + percentile_95?: number; +} + export interface BatchReadingRequest { device_id: string; device_model?: string; @@ -53,4 +64,9 @@ export const deviceReadingApi = { const { patient_id, ...query } = params; return client.get(`/health/patients/${patient_id}/device-readings/hourly`, { params: query }).then((r) => r.data.data as PaginatedResponse); }, + + queryDaily: (params: { patient_id: string; device_type?: string; from_date?: string; to_date?: string; page?: number; page_size?: number }) => { + const { patient_id, ...query } = params; + return client.get(`/health/patients/${patient_id}/device-readings/daily`, { params: query }).then((r) => r.data.data as PaginatedResponse); + }, }; diff --git a/apps/web/src/api/health/devices.ts b/apps/web/src/api/health/devices.ts index c6347b4..d7b6cbd 100644 --- a/apps/web/src/api/health/devices.ts +++ b/apps/web/src/api/health/devices.ts @@ -8,6 +8,11 @@ export interface DeviceItem { device_id: string; device_model: string; device_type: string; + status?: string; + firmware_version?: string; + manufacturer?: string; + connection_type?: string; + metadata?: Record; bound_at: string; last_sync_at: string; version: number; diff --git a/apps/web/src/api/health/oauthClients.ts b/apps/web/src/api/health/oauthClients.ts new file mode 100644 index 0000000..9ff7615 --- /dev/null +++ b/apps/web/src/api/health/oauthClients.ts @@ -0,0 +1,73 @@ +import client from '../client'; + +// --- Types --- +export interface OAuthClient { + id: string; + client_id: string; + client_name: string; + scopes: string[]; + rate_limit_per_minute: number; + is_active: boolean; + token_lifetime_seconds: number; + created_at: string; + version: number; +} + +export interface OAuthClientDetail extends OAuthClient { + tenant_id: string; + client_secret: string; + allowed_patient_ids?: string[]; +} + +export interface CreateOAuthClientReq { + client_name: string; + scopes: string[]; + allowed_patient_ids?: string[]; + rate_limit_per_minute?: number; + token_lifetime_seconds?: number; +} + +export interface UpdateOAuthClientReq { + client_name?: string; + scopes?: string[]; + allowed_patient_ids?: string[] | null; + rate_limit_per_minute?: number; + is_active?: boolean; + token_lifetime_seconds?: number; + version: number; +} + +export interface RegenerateSecretResp { + client_id: string; + client_secret: string; +} + +// --- FHIR Scope --- +export const FHIR_SCOPE_OPTIONS = [ + { value: 'Patient.read', label: 'Patient.read — 读取患者' }, + { value: 'Observation.read', label: 'Observation.read — 读取体征' }, + { value: 'Device.read', label: 'Device.read — 读取设备' }, + { value: 'DiagnosticReport.read', label: 'DiagnosticReport.read — 读取诊断报告' }, + { value: 'Encounter.read', label: 'Encounter.read — 读取就诊记录' }, + { value: 'Practitioner.read', label: 'Practitioner.read — 读取医护' }, + { value: 'Appointment.read', label: 'Appointment.read — 读取预约' }, + { value: 'Task.read', label: 'Task.read — 读取随访任务' }, +]; + +// --- API --- +export const oauthClientApi = { + list: () => + client.get('/health/oauth/clients').then((r) => r.data.data as OAuthClient[]), + + create: (data: CreateOAuthClientReq) => + client.post('/health/oauth/clients', data).then((r) => r.data.data as OAuthClientDetail), + + update: (id: string, data: UpdateOAuthClientReq) => + client.put(`/health/oauth/clients/${id}`, data).then((r) => r.data.data as OAuthClient), + + delete: (id: string) => + client.delete(`/health/oauth/clients/${id}`).then((r) => r.data), + + regenerateSecret: (id: string) => + client.post(`/health/oauth/clients/${id}/regenerate-secret`).then((r) => r.data.data as RegenerateSecretResp), +}; diff --git a/apps/web/src/constants/health.ts b/apps/web/src/constants/health.ts index 80543ea..8d3515a 100644 --- a/apps/web/src/constants/health.ts +++ b/apps/web/src/constants/health.ts @@ -118,6 +118,39 @@ export const CONDITION_TYPE_OPTIONS = [ { value: 'trend', label: '趋势变化' }, ]; +// --- 设备连接状态 --- +export const DEVICE_STATUS_OPTIONS = [ + { value: '', label: '全部状态' }, + { value: 'online', label: '在线' }, + { value: 'offline', label: '离线' }, + { value: 'paired', label: '已配对' }, + { value: 'error', label: '异常' }, +]; + +export const DEVICE_STATUS_COLOR: Record = { + online: 'green', + offline: 'default', + paired: 'blue', + error: 'red', +}; + +// --- 设备连接类型 --- +export const CONNECTION_TYPE_OPTIONS = [ + { value: 'ble', label: '蓝牙' }, + { value: 'gateway', label: '网关' }, + { value: 'manual', label: '手动录入' }, +]; + +// --- 实时监控卡片指标 --- +export const VITAL_CARD_METRICS = [ + { key: 'heart_rate', label: '心率', unit: 'bpm', color: '#ff4d4f' }, + { key: 'blood_oxygen', label: '血氧', unit: '%', color: '#1890ff' }, + { key: 'blood_pressure', label: '血压', unit: 'mmHg', color: '#f5222d' }, + { key: 'blood_glucose', label: '血糖', unit: 'mg/dL', color: '#722ed1' }, + { key: 'temperature', label: '体温', unit: '°C', color: '#fa8c16' }, + { key: 'steps', label: '步数', unit: '步', color: '#52c41a' }, +] as const; + // --- 通用状态标签(StatusTag 组件统一引用) --- export const STATUS_TAG_CONFIG: Record = { // 预约状态 diff --git a/apps/web/src/hooks/useVitalSSE.ts b/apps/web/src/hooks/useVitalSSE.ts new file mode 100644 index 0000000..8c9284c --- /dev/null +++ b/apps/web/src/hooks/useVitalSSE.ts @@ -0,0 +1,67 @@ +import { useState, useCallback } from 'react'; +import { useAlertSSE, type VitalUpdateSSEEvent } from './useAlertSSE'; + +export type VitalUpdateEvent = VitalUpdateSSEEvent; + +interface PatientVital { + patient_id: string; + device_type: string; + latest_value?: number; + updated_at: string; +} + +interface UseVitalSSEOptions { + enabled?: boolean; + patientIds?: string[]; + onUpdate?: (data: VitalUpdateEvent) => void; +} + +interface UseVitalSSEReturn { + connected: boolean; + patientVitals: Map; + lastUpdate: VitalUpdateEvent | null; +} + +/** + * 实时体征 hook — 复用全局 SSE 连接的 vital_update 事件。 + * + * 内部调用 useAlertSSE(共享 /messages/stream 连接), + * 聚合患者最新体征数据到 Map。 + */ +export function useVitalSSE(options: UseVitalSSEOptions = {}): UseVitalSSEReturn { + const { enabled = true, patientIds, onUpdate } = options; + const [patientVitals, setPatientVitals] = useState>(new Map()); + const [lastUpdate, setLastUpdate] = useState(null); + + const handleVitalUpdate = useCallback( + (data: VitalUpdateEvent) => { + if (patientIds && patientIds.length > 0 && !patientIds.includes(data.patient_id)) { + return; + } + + setPatientVitals((prev) => { + const next = new Map(prev); + if (data.device_model) { + const key = `${data.patient_id}_${data.device_model}`; + next.set(key, { + patient_id: data.patient_id, + device_type: data.device_model, + latest_value: data.count > 0 ? undefined : undefined, + updated_at: data.occurred_at ?? new Date().toISOString(), + }); + } + return next; + }); + setLastUpdate(data); + onUpdate?.(data); + }, + [patientIds, onUpdate], + ); + + const { connected } = useAlertSSE({ + enabled, + onVitalUpdate: handleVitalUpdate, + }); + + return { connected, patientVitals, lastUpdate }; +} diff --git a/apps/web/src/pages/health/DeviceManage.tsx b/apps/web/src/pages/health/DeviceManage.tsx index 041b59c..c054ff8 100644 --- a/apps/web/src/pages/health/DeviceManage.tsx +++ b/apps/web/src/pages/health/DeviceManage.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useState } from 'react'; -import { Button, Input, message, Popconfirm, Select, Space, Table, Tag } from 'antd'; +import { Button, Input, message, Popconfirm, Select, Space, Table, Tag, Badge } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import dayjs from 'dayjs'; import { deviceApi, type DeviceItem } from '../../api/health/devices'; -import { DEVICE_TYPE_OPTIONS, DEVICE_TYPE_COLOR } from '../../constants/health'; +import { DEVICE_TYPE_OPTIONS, DEVICE_TYPE_COLOR, DEVICE_STATUS_OPTIONS } from '../../constants/health'; import { PatientSelect } from './components/PatientSelect'; function formatTime(val?: string | null): string { @@ -20,6 +20,7 @@ export default function DeviceManage() { const [filterPatientId, setFilterPatientId] = useState(''); const [filterDeviceType, setFilterDeviceType] = useState(undefined); + const [filterStatus, setFilterStatus] = useState(undefined); const fetchDevices = useCallback(async () => { setLoading(true); @@ -29,6 +30,7 @@ export default function DeviceManage() { page_size: 20, ...(filterPatientId ? { patient_id: filterPatientId } : {}), ...(filterDeviceType ? { device_type: filterDeviceType } : {}), + ...(filterStatus ? { status: filterStatus } : {}), }); setData(res.data); setTotal(res.total); @@ -37,7 +39,7 @@ export default function DeviceManage() { } finally { setLoading(false); } - }, [page, filterPatientId, filterDeviceType]); + }, [page, filterPatientId, filterDeviceType, filterStatus]); useEffect(() => { fetchDevices(); @@ -74,6 +76,36 @@ export default function DeviceManage() { return {label}; }, }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (v: string) => { + const config: Record = { + online: { color: 'success', label: '在线' }, + offline: { color: 'default', label: '离线' }, + paired: { color: 'processing', label: '已配对' }, + error: { color: 'error', label: '异常' }, + }; + const c = config[v]; + return c ? : {v || '-'}; + }, + }, + { + title: '连接方式', + dataIndex: 'connection_type', + width: 90, + render: (v: string) => { + const map: Record = { ble: '蓝牙', gateway: '网关', manual: '手动' }; + return {map[v] ?? v ?? '-'}; + }, + }, + { + title: '固件版本', + dataIndex: 'firmware_version', + width: 110, + render: (v: string) => v ?? '-', + }, { title: '绑定时间', dataIndex: 'bound_at', @@ -125,6 +157,14 @@ export default function DeviceManage() { style={{ width: 140 }} allowClear /> + + + + setSelectedPatientId(v ?? null)} + options={sortedPatients.map((p) => ({ + value: p.patient_id, + label: p.patient_id, + }))} + /> + + } + > + + } /> + } /> + } /> + } /> + + + + {sortedPatients.length === 0 ? ( + + ) : ( + p.patient_id === selectedPatientId) : sortedPatients} + renderItem={(item) => { + const vitalKeys = Array.from(patientVitals.entries()) + .filter(([key]) => key.startsWith(item.patient_id)); + const latestVitals = vitalKeys.map(([, v]) => v); + + return ( + setSelectedPatientId( + selectedPatientId === item.patient_id ? null : item.patient_id, + )} + > + + {item.patient_id} + {item.critical > 0 && {item.critical} 危急} + {item.high > 0 && {item.high} 高危} + {item.medium > 0 && {item.medium} 中等} + + } + description={ + + {latestVitals.map((v) => { + const metric = VITAL_CARD_METRICS.find((m) => m.key === v.device_type); + if (!metric) return null; + return ( + + {metric.label}: {v.latest_value ?? '-'} {metric.unit} + + ); + })} + {latestVitals.length === 0 && 暂无实时数据} + + } + /> + + ); + }} + /> + )} + + + ); +}