- 修复 verbatimModuleSyntax 要求的 import type 声明 - 修复未使用导入(Badge/EditOutlined/Space/Input/Switch 等) - 修复 mock.calls 类型注解([string,unknown] → any[]) - 修复 vitest 全局超时和 poolTimeout 配置 - 修复 PageContainer 缺少 onBack prop、MenuInfo children 可选 - 修复 CopilotAlert Badge status info→processing、useCopilotRisk 二次解包 - 修复 articles/doctors 测试 delete 调用缺少 version 参数 - 添加排班管理/预约管理面包屑标题 fallback
154 lines
6.1 KiB
TypeScript
154 lines
6.1 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import { Card, Row, Col, Statistic, Tag, List, Select, Badge, Typography, Space, Empty, Result } from 'antd';
|
|
import { AlertOutlined } from '@ant-design/icons';
|
|
import { useVitalSSE } from '../../hooks/useVitalSSE';
|
|
import { usePermission } from '../../hooks/usePermission';
|
|
import { alertApi, type Alert } from '../../api/health/alerts';
|
|
import { PageContainer } from '../../components/PageContainer';
|
|
import { 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 { hasPermission } = usePermission('health.alerts.list');
|
|
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);
|
|
|
|
if (!hasPermission) {
|
|
return <Result status="403" title="权限不足" subTitle="您没有访问实时体征监控的权限" />;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|