feat(web): IoT + FHIR V1 Plan 5 — Web 前端实施
- API 层: deviceReadings 日聚合查询 + OAuth 合作方 CRUD 接口 - 常量: 设备连接状态/连接类型/实时监控指标常量 - Hook: useVitalSSE — 复用全局 SSE 连接的 vital_update 事件 - 页面: RealtimeMonitor 实时体征监控台 (SSE + 告警排序) - 页面: OAuthClientList FHIR 合作方管理 (CRUD + Secret 重置) - 增强: DeviceManage 设备状态/固件/连接类型列 + 状态筛选 - 路由: 新增 3 个懒加载路由 - 测试: RealtimeMonitor + OAuthClientList 单元测试
This commit is contained in:
@@ -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() {
|
||||
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
|
||||
<Route path="/health/alert-rules" element={<AlertRuleList />} />
|
||||
<Route path="/health/devices" element={<DeviceManage />} />
|
||||
<Route path="/health/realtime-monitor" element={<RealtimeMonitor />} />
|
||||
<Route path="/health/oauth-clients" element={<OAuthClientList />} />
|
||||
<Route path="/health/dialysis" element={<DialysisManageList />} />
|
||||
<Route path="/health/action-inbox" element={<ActionInbox />} />
|
||||
<Route path="/health/follow-up-templates" element={<FollowUpTemplateList />} />
|
||||
|
||||
@@ -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<HourlyReading>);
|
||||
},
|
||||
|
||||
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<DailyReading>);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
bound_at: string;
|
||||
last_sync_at: string;
|
||||
version: number;
|
||||
|
||||
73
apps/web/src/api/health/oauthClients.ts
Normal file
73
apps/web/src/api/health/oauthClients.ts
Normal file
@@ -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),
|
||||
};
|
||||
@@ -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<string, string> = {
|
||||
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<string, { color: string; label: string }> = {
|
||||
// 预约状态
|
||||
|
||||
67
apps/web/src/hooks/useVitalSSE.ts
Normal file
67
apps/web/src/hooks/useVitalSSE.ts
Normal file
@@ -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<string, PatientVital>;
|
||||
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<Map<string, PatientVital>>(new Map());
|
||||
const [lastUpdate, setLastUpdate] = useState<VitalUpdateEvent | null>(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 };
|
||||
}
|
||||
@@ -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<string | undefined>(undefined);
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(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 <Tag color={DEVICE_TYPE_COLOR[v] || 'default'}>{label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (v: string) => {
|
||||
const config: Record<string, { color: 'success' | 'error' | 'default' | 'processing'; label: string }> = {
|
||||
online: { color: 'success', label: '在线' },
|
||||
offline: { color: 'default', label: '离线' },
|
||||
paired: { color: 'processing', label: '已配对' },
|
||||
error: { color: 'error', label: '异常' },
|
||||
};
|
||||
const c = config[v];
|
||||
return c ? <Badge status={c.color} text={c.label} /> : <span>{v || '-'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '连接方式',
|
||||
dataIndex: 'connection_type',
|
||||
width: 90,
|
||||
render: (v: string) => {
|
||||
const map: Record<string, string> = { ble: '蓝牙', gateway: '网关', manual: '手动' };
|
||||
return <Tag>{map[v] ?? v ?? '-'}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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
|
||||
/>
|
||||
<Select
|
||||
placeholder="设备状态"
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
options={DEVICE_STATUS_OPTIONS}
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" onClick={() => setPage(1)}>
|
||||
查询
|
||||
</Button>
|
||||
|
||||
36
apps/web/src/pages/health/OAuthClientList.test.tsx
Normal file
36
apps/web/src/pages/health/OAuthClientList.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import OAuthClientList from './OAuthClientList';
|
||||
|
||||
vi.mock('../../api/health/oauthClients', () => ({
|
||||
oauthClientApi: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
regenerateSecret: vi.fn(),
|
||||
},
|
||||
FHIR_SCOPE_OPTIONS: [
|
||||
{ value: 'Patient.read', label: 'Patient.read' },
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('../../components/PageContainer', () => ({
|
||||
PageContainer: ({ children, title, actions }: { children: React.ReactNode; title: string; actions?: React.ReactNode }) => (
|
||||
<div><h1>{title}</h1>{actions}{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('OAuthClientList', () => {
|
||||
it('renders page title', async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<OAuthClientList />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('FHIR API 合作方管理')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
207
apps/web/src/pages/health/OAuthClientList.tsx
Normal file
207
apps/web/src/pages/health/OAuthClientList.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Button, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
oauthClientApi,
|
||||
type OAuthClient,
|
||||
type CreateOAuthClientReq,
|
||||
type UpdateOAuthClientReq,
|
||||
FHIR_SCOPE_OPTIONS,
|
||||
} from '../../api/health/oauthClients';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
|
||||
export default function OAuthClientList() {
|
||||
const [data, setData] = useState<OAuthClient[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editRecord, setEditRecord] = useState<OAuthClient | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await oauthClientApi.list();
|
||||
setData(list);
|
||||
} catch {
|
||||
message.error('加载合作方列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditRecord(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: OAuthClient) => {
|
||||
setEditRecord(record);
|
||||
form.setFieldsValue({
|
||||
client_name: record.client_name,
|
||||
scopes: record.scopes,
|
||||
rate_limit_per_minute: record.rate_limit_per_minute,
|
||||
token_lifetime_seconds: record.token_lifetime_seconds,
|
||||
is_active: record.is_active,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editRecord) {
|
||||
const req: UpdateOAuthClientReq = {
|
||||
...values,
|
||||
version: editRecord.version,
|
||||
};
|
||||
await oauthClientApi.update(editRecord.id, req);
|
||||
message.success('合作方已更新');
|
||||
} else {
|
||||
const req: CreateOAuthClientReq = values;
|
||||
const result = await oauthClientApi.create(req);
|
||||
Modal.success({
|
||||
title: '合作方已创建',
|
||||
content: (
|
||||
<div>
|
||||
<p><strong>Client ID:</strong> {result.client_id}</p>
|
||||
<p><strong>Client Secret:</strong> {result.client_secret}</p>
|
||||
<Typography.Text type="warning">请妥善保存 Secret,关闭后不可再次查看。</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch {
|
||||
// 表单校验失败
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await oauthClientApi.delete(id);
|
||||
message.success('合作方已删除');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = async (record: OAuthClient) => {
|
||||
try {
|
||||
const result = await oauthClientApi.regenerateSecret(record.id);
|
||||
Modal.info({
|
||||
title: 'Secret 已重新生成',
|
||||
content: (
|
||||
<div>
|
||||
<p><strong>新 Client Secret:</strong></p>
|
||||
<Typography.Text copyable code>{result.client_secret}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
message.error('重新生成 Secret 失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<OAuthClient> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'client_name',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: 'Client ID',
|
||||
dataIndex: 'client_id',
|
||||
width: 200,
|
||||
render: (v: string) => <Typography.Text copyable code>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: 'Scope',
|
||||
dataIndex: 'scopes',
|
||||
width: 250,
|
||||
render: (scopes: string[]) =>
|
||||
scopes.map((s) => <Tag key={s} color="blue">{s}</Tag>),
|
||||
},
|
||||
{
|
||||
title: '限流 (次/分)',
|
||||
dataIndex: 'rate_limit_per_minute',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button size="small" onClick={() => handleRegenerate(record)}>重置 Secret</Button>
|
||||
<Popconfirm title="确定删除此合作方?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="FHIR API 合作方管理"
|
||||
actions={<Button type="primary" onClick={handleCreate}>创建合作方</Button>}
|
||||
>
|
||||
<Table<OAuthClient>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editRecord ? '编辑合作方' : '创建合作方'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="client_name" label="合作方名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="如:XX医院HIS系统" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopes" label="FHIR Scope" rules={[{ required: true, message: '请选择至少一个 scope' }]}>
|
||||
<Select mode="multiple" options={FHIR_SCOPE_OPTIONS} placeholder="选择允许的 FHIR 资源" />
|
||||
</Form.Item>
|
||||
<Form.Item name="rate_limit_per_minute" label="限流 (次/分钟)" initialValue={60}>
|
||||
<InputNumber min={1} max={1000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="token_lifetime_seconds" label="Token 有效期 (秒)" initialValue={3600}>
|
||||
<InputNumber min={60} max={86400} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
{editRecord && (
|
||||
<Form.Item name="is_active" label="启用状态" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/pages/health/RealtimeMonitor.test.tsx
Normal file
37
apps/web/src/pages/health/RealtimeMonitor.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import RealtimeMonitor from './RealtimeMonitor';
|
||||
|
||||
vi.mock('../../api/health/alerts', () => ({
|
||||
alertApi: {
|
||||
list: vi.fn().mockResolvedValue({ data: [], total: 0 }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useVitalSSE', () => ({
|
||||
useVitalSSE: vi.fn().mockReturnValue({
|
||||
connected: true,
|
||||
patientVitals: new Map(),
|
||||
lastUpdate: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/PageContainer', () => ({
|
||||
PageContainer: ({ children, title, actions }: { children: React.ReactNode; title: string; actions?: React.ReactNode }) => (
|
||||
<div><h1>{title}</h1>{actions}{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('RealtimeMonitor', () => {
|
||||
it('renders page title', async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<RealtimeMonitor />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('实时体征监控')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
147
apps/web/src/pages/health/RealtimeMonitor.tsx
Normal file
147
apps/web/src/pages/health/RealtimeMonitor.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Card, Row, Col, Statistic, Tag, List, Select, Badge, Typography, Space, Empty } from 'antd';
|
||||
import { AlertOutlined } from '@ant-design/icons';
|
||||
import { useVitalSSE } from '../../hooks/useVitalSSE';
|
||||
import { alertApi, type Alert } from '../../api/health/alerts';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { SEVERITY_COLOR, SEVERITY_LABEL, VITAL_CARD_METRICS } from '../../constants/health';
|
||||
|
||||
interface PatientAlertSummary {
|
||||
patient_id: string;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时体征监控台 — 医生 Web 端。
|
||||
*
|
||||
* SSE 实时接收体征更新,按告警严重度排序患者列表。
|
||||
*/
|
||||
export default function RealtimeMonitor() {
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(null);
|
||||
const [alertSummary, setAlertSummary] = useState<PatientAlertSummary[]>([]);
|
||||
|
||||
const { connected, patientVitals } = useVitalSSE({ enabled: true });
|
||||
|
||||
const fetchAlerts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await alertApi.list({ page: 1, page_size: 100, status: 'pending' });
|
||||
setAlerts(result.data);
|
||||
|
||||
const summaryMap = new Map<string, PatientAlertSummary>();
|
||||
for (const alert of result.data) {
|
||||
const existing = summaryMap.get(alert.patient_id) ?? {
|
||||
patient_id: alert.patient_id,
|
||||
critical: 0, high: 0, medium: 0, low: 0,
|
||||
};
|
||||
const sev = alert.severity;
|
||||
if (sev === 'critical') existing.critical++;
|
||||
else if (sev === 'high') existing.high++;
|
||||
else if (sev === 'medium') existing.medium++;
|
||||
else existing.low++;
|
||||
summaryMap.set(alert.patient_id, existing);
|
||||
}
|
||||
setAlertSummary(Array.from(summaryMap.values()));
|
||||
} catch {
|
||||
// 静默降级
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlerts();
|
||||
}, [fetchAlerts]);
|
||||
|
||||
const sortedPatients = [...alertSummary].sort((a, b) => {
|
||||
const score = (s: PatientAlertSummary) => s.critical * 4 + s.high * 3 + s.medium * 2 + s.low;
|
||||
return score(b) - score(a);
|
||||
});
|
||||
|
||||
const totalCritical = alertSummary.reduce((s, a) => s + a.critical, 0);
|
||||
const totalHigh = alertSummary.reduce((s, a) => s + a.high, 0);
|
||||
const totalMedium = alertSummary.reduce((s, a) => s + a.medium, 0);
|
||||
const totalLow = alertSummary.reduce((s, a) => s + a.low, 0);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="实时体征监控"
|
||||
actions={
|
||||
<Space>
|
||||
<Badge status={connected ? 'success' : 'error'} text={connected ? '已连接' : '断开'} />
|
||||
<Select
|
||||
placeholder="筛选患者"
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
onChange={(v) => setSelectedPatientId(v ?? null)}
|
||||
options={sortedPatients.map((p) => ({
|
||||
value: p.patient_id,
|
||||
label: p.patient_id,
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}><Card><Statistic title="危急" value={totalCritical} valueStyle={{ color: '#cf1322' }} prefix={<AlertOutlined />} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="高危" value={totalHigh} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="中等" value={totalMedium} valueStyle={{ color: '#fa8c16' }} prefix={<AlertOutlined />} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="低危" value={totalLow} valueStyle={{ color: '#8c8c8c' }} prefix={<AlertOutlined />} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
<Card title={`患者列表(${sortedPatients.length} 人有活跃告警)`} loading={loading}>
|
||||
{sortedPatients.length === 0 ? (
|
||||
<Empty description="暂无活跃告警" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={selectedPatientId ? sortedPatients.filter((p) => 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 (
|
||||
<List.Item
|
||||
style={{ cursor: 'pointer', padding: '12px 16px' }}
|
||||
onClick={() => setSelectedPatientId(
|
||||
selectedPatientId === item.patient_id ? null : item.patient_id,
|
||||
)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
<Typography.Text>{item.patient_id}</Typography.Text>
|
||||
{item.critical > 0 && <Tag color="red">{item.critical} 危急</Tag>}
|
||||
{item.high > 0 && <Tag color="volcano">{item.high} 高危</Tag>}
|
||||
{item.medium > 0 && <Tag color="orange">{item.medium} 中等</Tag>}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space wrap>
|
||||
{latestVitals.map((v) => {
|
||||
const metric = VITAL_CARD_METRICS.find((m) => m.key === v.device_type);
|
||||
if (!metric) return null;
|
||||
return (
|
||||
<Tag key={v.device_type} color="blue">
|
||||
{metric.label}: {v.latest_value ?? '-'} {metric.unit}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{latestVitals.length === 0 && <Typography.Text type="secondary">暂无实时数据</Typography.Text>}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user