feat(web): IoT + FHIR V1 Plan 5 — Web 前端实施
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- API 层: deviceReadings 日聚合查询 + OAuth 合作方 CRUD 接口
- 常量: 设备连接状态/连接类型/实时监控指标常量
- Hook: useVitalSSE — 复用全局 SSE 连接的 vital_update 事件
- 页面: RealtimeMonitor 实时体征监控台 (SSE + 告警排序)
- 页面: OAuthClientList FHIR 合作方管理 (CRUD + Secret 重置)
- 增强: DeviceManage 设备状态/固件/连接类型列 + 状态筛选
- 路由: 新增 3 个懒加载路由
- 测试: RealtimeMonitor + OAuthClientList 单元测试
This commit is contained in:
iven
2026-05-04 02:40:57 +08:00
parent 24562dd54b
commit 70aacf47a0
11 changed files with 668 additions and 3 deletions

View File

@@ -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>

View 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();
});
});
});

View 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>
);
}

View 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();
});
});
});

View 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>
);
}