- 新增 AlertDashboard 页面:实时告警列表 + 统计摘要 + 详情面板 - 新增 useAlertSSE Hook:封装 SSE 连接、自动重连、事件分发 - 新增 AlertDetailPanel 组件:告警详情展示 + 确认/忽略/恢复操作 - alertApi.list 添加 doctor_id 参数支持 - 注册 /health/alert-dashboard 路由 + 面包屑映射
319 lines
9.9 KiB
TypeScript
319 lines
9.9 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import {
|
|
Row,
|
|
Col,
|
|
Card,
|
|
Statistic,
|
|
Tag,
|
|
List,
|
|
Select,
|
|
Badge,
|
|
Typography,
|
|
Spin,
|
|
Space,
|
|
Flex,
|
|
} from 'antd';
|
|
import {
|
|
AlertOutlined,
|
|
CheckCircleOutlined,
|
|
ExclamationCircleOutlined,
|
|
WarningOutlined,
|
|
WifiOutlined,
|
|
} from '@ant-design/icons';
|
|
import { alertApi, type Alert } from '../../api/health/alerts';
|
|
import { useAlertSSE, type AlertSSEEvent } from '../../hooks/useAlertSSE';
|
|
import { AlertDetailPanel } from './components/AlertDetailPanel';
|
|
import { PageContainer } from '../../components/PageContainer';
|
|
import { EntityName } from '../../components/EntityName';
|
|
|
|
const SEVERITY_COLOR: Record<string, string> = {
|
|
info: 'default',
|
|
warning: 'orange',
|
|
critical: 'red',
|
|
urgent: 'magenta',
|
|
};
|
|
|
|
const SEVERITY_LABEL: Record<string, string> = {
|
|
info: '提示',
|
|
warning: '警告',
|
|
critical: '严重',
|
|
urgent: '紧急',
|
|
};
|
|
|
|
const STATUS_COLOR: Record<string, string> = {
|
|
pending: 'orange',
|
|
acknowledged: 'blue',
|
|
resolved: 'green',
|
|
dismissed: 'default',
|
|
};
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
pending: '待处理',
|
|
acknowledged: '已确认',
|
|
resolved: '已恢复',
|
|
dismissed: '已忽略',
|
|
};
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: '', label: '全部状态' },
|
|
{ value: 'pending', label: '待处理' },
|
|
{ value: 'acknowledged', label: '已确认' },
|
|
{ value: 'resolved', label: '已恢复' },
|
|
{ value: 'dismissed', label: '已忽略' },
|
|
];
|
|
|
|
/**
|
|
* 实时告警仪表盘 — 医生端。
|
|
*
|
|
* 功能:
|
|
* - SSE 实时接收新告警推送
|
|
* - 按状态/严重程度筛选
|
|
* - 告警列表 + 详情面板
|
|
* - 统计摘要(待处理/已确认/危急值)
|
|
* - 确认/忽略/恢复操作
|
|
*/
|
|
export default function AlertDashboard() {
|
|
const [alerts, setAlerts] = useState<Alert[]>([]);
|
|
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [actionLoading, setActionLoading] = useState(false);
|
|
const [total, setTotal] = useState(0);
|
|
|
|
// 加载告警列表
|
|
const fetchAlerts = useCallback(async (status?: string) => {
|
|
try {
|
|
setLoading(true);
|
|
const params: Record<string, string | number> = {
|
|
page: 1,
|
|
page_size: 50,
|
|
};
|
|
if (status) {
|
|
params.status = status;
|
|
}
|
|
const result = await alertApi.list(params);
|
|
setAlerts(result.data);
|
|
setTotal(result.total);
|
|
} catch {
|
|
// 静默降级
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// SSE 实时推送
|
|
const handleNewAlert = useCallback((event: AlertSSEEvent) => {
|
|
// 将 SSE 事件转换为 Alert 对象并插入列表头部
|
|
const newAlert: Alert = {
|
|
id: event.alert_id,
|
|
patient_id: event.patient_id,
|
|
rule_id: '',
|
|
severity: event.severity,
|
|
title: event.rule_name ?? '新告警',
|
|
detail: event.detail,
|
|
status: 'pending',
|
|
created_at: event.occurred_at ?? new Date().toISOString(),
|
|
version: 1,
|
|
};
|
|
setAlerts((prev) => [newAlert, ...prev]);
|
|
setTotal((prev) => prev + 1);
|
|
}, []);
|
|
|
|
const { connected } = useAlertSSE({
|
|
enabled: true,
|
|
onAlert: handleNewAlert,
|
|
});
|
|
|
|
// 初始加载
|
|
useEffect(() => {
|
|
fetchAlerts(statusFilter || undefined);
|
|
}, [fetchAlerts, statusFilter]);
|
|
|
|
// 操作回调
|
|
const handleAcknowledge = useCallback(async (id: string, version: number) => {
|
|
setActionLoading(true);
|
|
try {
|
|
const updated = await alertApi.acknowledge(id, version);
|
|
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
|
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
|
|
} catch {
|
|
// 错误由 API client 统一处理
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleDismiss = useCallback(async (id: string, version: number) => {
|
|
setActionLoading(true);
|
|
try {
|
|
const updated = await alertApi.dismiss(id, version);
|
|
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
|
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
|
|
} catch {
|
|
// 错误由 API client 统一处理
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleResolve = useCallback(async (id: string, version: number) => {
|
|
setActionLoading(true);
|
|
try {
|
|
const updated = await alertApi.resolve(id, version);
|
|
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
|
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
|
|
} catch {
|
|
// 错误由 API client 统一处理
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// 统计
|
|
const pendingCount = alerts.filter((a) => a.status === 'pending').length;
|
|
const acknowledgedCount = alerts.filter((a) => a.status === 'acknowledged').length;
|
|
const criticalCount = alerts.filter((a) => a.severity === 'critical' || a.severity === 'urgent').length;
|
|
|
|
return (
|
|
<PageContainer
|
|
title="告警仪表盘"
|
|
subtitle={`共 ${total} 条告警`}
|
|
filters={
|
|
<Space>
|
|
<Select
|
|
value={statusFilter}
|
|
onChange={setStatusFilter}
|
|
options={STATUS_OPTIONS}
|
|
style={{ width: 120 }}
|
|
placeholder="按状态筛选"
|
|
/>
|
|
<Badge status={connected ? 'success' : 'error'} text={
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
<WifiOutlined style={{ marginRight: 4 }} />
|
|
{connected ? '实时连接' : '连接断开'}
|
|
</Typography.Text>
|
|
} />
|
|
</Space>
|
|
}
|
|
>
|
|
<div style={{ padding: 16 }}>
|
|
{/* 统计卡片 */}
|
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
|
<Col xs={8}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="待处理"
|
|
value={pendingCount}
|
|
prefix={<ExclamationCircleOutlined />}
|
|
valueStyle={{ color: pendingCount > 0 ? '#fa8c16' : undefined }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={8}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="已确认"
|
|
value={acknowledgedCount}
|
|
prefix={<CheckCircleOutlined />}
|
|
valueStyle={{ color: '#1890ff' }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={8}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="危急值"
|
|
value={criticalCount}
|
|
prefix={<WarningOutlined />}
|
|
valueStyle={{ color: criticalCount > 0 ? '#ff4d4f' : undefined }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* 告警列表 + 详情 */}
|
|
<Row gutter={[16, 16]}>
|
|
<Col xs={24} md={14}>
|
|
<Card
|
|
title={
|
|
<Space>
|
|
<AlertOutlined />
|
|
<span>告警列表</span>
|
|
<Badge count={pendingCount} />
|
|
</Space>
|
|
}
|
|
size="small"
|
|
style={{ maxHeight: 600, overflow: 'auto' }}
|
|
>
|
|
<Spin spinning={loading}>
|
|
<List
|
|
size="small"
|
|
dataSource={alerts}
|
|
locale={{ emptyText: '暂无告警' }}
|
|
renderItem={(alert) => (
|
|
<List.Item
|
|
onClick={() => setSelectedAlert(alert)}
|
|
style={{
|
|
cursor: 'pointer',
|
|
background: selectedAlert?.id === alert.id ? 'var(--ant-color-primary-bg)' : undefined,
|
|
padding: '8px 12px',
|
|
borderRadius: 6,
|
|
transition: 'background 0.2s',
|
|
}}
|
|
>
|
|
<List.Item.Meta
|
|
avatar={
|
|
<Tag
|
|
color={SEVERITY_COLOR[alert.severity]}
|
|
style={{ margin: 0, minWidth: 48, textAlign: 'center' }}
|
|
>
|
|
{SEVERITY_LABEL[alert.severity] ?? alert.severity}
|
|
</Tag>
|
|
}
|
|
title={
|
|
<Flex justify="space-between" align="center">
|
|
<span>{alert.title}</span>
|
|
<Tag color={STATUS_COLOR[alert.status]} style={{ fontSize: 11 }}>
|
|
{STATUS_LABEL[alert.status] ?? alert.status}
|
|
</Tag>
|
|
</Flex>
|
|
}
|
|
description={
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
患者: <EntityName id={alert.patient_id} />
|
|
{' · '}
|
|
{new Date(alert.created_at).toLocaleString('zh-CN')}
|
|
</Typography.Text>
|
|
}
|
|
/>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
</Spin>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} md={10}>
|
|
<Card title="告警详情" size="small">
|
|
{selectedAlert ? (
|
|
<AlertDetailPanel
|
|
alert={selectedAlert}
|
|
onAcknowledge={handleAcknowledge}
|
|
onDismiss={handleDismiss}
|
|
onResolve={handleResolve}
|
|
loading={actionLoading}
|
|
/>
|
|
) : (
|
|
<div style={{ padding: 40, textAlign: 'center' }}>
|
|
<Typography.Text type="secondary">
|
|
点击左侧告警查看详情
|
|
</Typography.Text>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
</div>
|
|
</PageContainer>
|
|
);
|
|
}
|