Files
hms/apps/web/src/pages/health/AlertDashboard.tsx
iven 27c32e5561
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
feat(web): 实时告警仪表盘页面 + SSE Hook + 告警详情面板
- 新增 AlertDashboard 页面:实时告警列表 + 统计摘要 + 详情面板
- 新增 useAlertSSE Hook:封装 SSE 连接、自动重连、事件分发
- 新增 AlertDetailPanel 组件:告警详情展示 + 确认/忽略/恢复操作
- alertApi.list 添加 doctor_id 参数支持
- 注册 /health/alert-dashboard 路由 + 面包屑映射
2026-04-28 19:59:51 +08:00

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