feat(web): 实时告警仪表盘页面 + SSE Hook + 告警详情面板
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

- 新增 AlertDashboard 页面:实时告警列表 + 统计摘要 + 详情面板
- 新增 useAlertSSE Hook:封装 SSE 连接、自动重连、事件分发
- 新增 AlertDetailPanel 组件:告警详情展示 + 确认/忽略/恢复操作
- alertApi.list 添加 doctor_id 参数支持
- 注册 /health/alert-dashboard 路由 + 面包屑映射
This commit is contained in:
iven
2026-04-28 19:59:51 +08:00
parent cf844a561f
commit 27c32e5561
6 changed files with 622 additions and 1 deletions

View File

@@ -45,6 +45,7 @@ const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
const AlertList = lazy(() => import('./pages/health/AlertList'));
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
// 内容管理
@@ -249,6 +250,7 @@ export default function App() {
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
<Route path="/health/alerts" element={<AlertList />} />
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
<Route path="/health/alert-rules" element={<AlertRuleList />} />
{/* 内容管理 */}
<Route path="/health/articles" element={<ArticleManageList />} />

View File

@@ -59,7 +59,7 @@ export interface UpdateAlertRuleReq {
// --- API ---
export const alertApi = {
list: (params?: { patient_id?: string; status?: string; page?: number; page_size?: number }) =>
list: (params?: { patient_id?: string; doctor_id?: string; status?: string; page?: number; page_size?: number }) =>
client.get('/health/alerts', { params }).then((r) => r.data.data as PaginatedResponse<Alert>),
acknowledge: (id: string, version: number) =>

View File

@@ -0,0 +1,129 @@
import { useEffect, useRef, useCallback, useState } from 'react';
/** SSE 事件数据结构 — alert.triggered 事件 */
export interface AlertSSEEvent {
alert_id: string;
patient_id: string;
rule_name: string;
severity: string;
detail?: Record<string, unknown>;
schema_version?: string;
occurred_at?: string;
}
/** SSE 事件数据结构 — device.readings.synced 事件 */
export interface VitalUpdateSSEEvent {
patient_id: string;
count: number;
device_model?: string;
date_range?: {
from?: string;
to?: string;
};
schema_version?: string;
occurred_at?: string;
}
interface UseAlertSSEOptions {
/** 是否启用 SSE 连接(默认 true */
enabled?: boolean;
/** 收到 alert 事件的回调 */
onAlert?: (data: AlertSSEEvent) => void;
/** 收到 vital_update 事件的回调 */
onVitalUpdate?: (data: VitalUpdateSSEEvent) => void;
}
interface UseAlertSSEReturn {
/** 连接状态 */
connected: boolean;
/** 最近收到的 alert 事件列表(最多保留 100 条) */
recentAlerts: AlertSSEEvent[];
/** 手动重连 */
reconnect: () => void;
}
const MAX_RECENT_ALERTS = 100;
/**
* SSE 实时告警订阅 Hook。
*
* 封装 EventSource 连接管理,支持:
* - 自动重连EventSource 内置)
* - 事件分发alert / vital_update
* - 连接状态追踪
* - 最近告警缓存
*/
export function useAlertSSE(options: UseAlertSSEOptions = {}): UseAlertSSEReturn {
const { enabled = true, onAlert, onVitalUpdate } = options;
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectKeyRef = useRef(0);
const [connected, setConnected] = useState(false);
const [recentAlerts, setRecentAlerts] = useState<AlertSSEEvent[]>([]);
const connect = useCallback(() => {
// 关闭旧连接
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setConnected(false);
if (!enabled) return;
const token = localStorage.getItem('access_token');
if (!token) return;
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1';
const url = `${baseUrl}/messages/stream?token=${encodeURIComponent(token)}`;
const es = new EventSource(url);
eventSourceRef.current = es;
es.onopen = () => {
setConnected(true);
};
es.addEventListener('alert', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data) as AlertSSEEvent;
onAlert?.(data);
setRecentAlerts((prev) => {
const next = [data, ...prev];
return next.length > MAX_RECENT_ALERTS ? next.slice(0, MAX_RECENT_ALERTS) : next;
});
} catch {
// 忽略解析失败的事件
}
});
es.addEventListener('vital_update', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data) as VitalUpdateSSEEvent;
onVitalUpdate?.(data);
} catch {
// 忽略解析失败的事件
}
});
es.onerror = () => {
setConnected(false);
// EventSource 会自动重连,无需手动处理
};
}, [enabled, onAlert, onVitalUpdate]);
useEffect(() => {
connect();
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setConnected(false);
};
}, [connect, reconnectKeyRef.current]);
const reconnect = useCallback(() => {
reconnectKeyRef.current += 1;
}, []);
return { connected, recentAlerts, reconnect };
}

View File

@@ -92,6 +92,7 @@ const routeTitleFallback: Record<string, string> = {
'/health/article-categories': '分类管理',
'/health/article-tags': '标签管理',
'/health/alerts': '告警列表',
'/health/alert-dashboard': '告警仪表盘',
'/health/alert-rules': '告警规则',
};

View File

@@ -0,0 +1,318 @@
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>
);
}

View File

@@ -0,0 +1,171 @@
import { Descriptions, Tag, Typography, Space, Button, Popconfirm, Tooltip } from 'antd';
import {
CheckOutlined,
StopOutlined,
SafetyCertificateOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import type { Alert } from '../../../api/health/alerts';
const SEVERITY_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
info: { color: 'default', label: '提示', icon: <ExclamationCircleOutlined /> },
warning: { color: 'orange', label: '警告', icon: <ExclamationCircleOutlined /> },
critical: { color: 'red', label: '严重', icon: <ExclamationCircleOutlined /> },
urgent: { color: 'magenta', label: '紧急', icon: <ExclamationCircleOutlined /> },
};
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
pending: { color: 'orange', label: '待处理' },
acknowledged: { color: 'blue', label: '已确认' },
resolved: { color: 'green', label: '已恢复' },
dismissed: { color: 'default', label: '已忽略' },
};
interface AlertDetailPanelProps {
alert: Alert;
onAcknowledge?: (id: string, version: number) => Promise<void>;
onDismiss?: (id: string, version: number) => Promise<void>;
onResolve?: (id: string, version: number) => Promise<void>;
loading?: boolean;
}
/**
* 告警详情面板 — 展示告警完整信息及操作按钮。
*/
export function AlertDetailPanel({
alert,
onAcknowledge,
onDismiss,
onResolve,
loading = false,
}: AlertDetailPanelProps) {
const severity = SEVERITY_CONFIG[alert.severity] ?? SEVERITY_CONFIG.info;
const status = STATUS_CONFIG[alert.status] ?? STATUS_CONFIG.pending;
const isPending = alert.status === 'pending';
const isAcknowledged = alert.status === 'acknowledged';
return (
<div style={{ padding: 16 }}>
{/* 顶部摘要 */}
<div style={{ marginBottom: 16 }}>
<Space align="center" size="middle">
<Tag color={severity.color} icon={severity.icon} style={{ fontSize: 14, padding: '4px 12px' }}>
{severity.label}
</Tag>
<Tag color={status.color}>{status.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<ClockCircleOutlined style={{ marginRight: 4 }} />
{new Date(alert.created_at).toLocaleString('zh-CN')}
</Typography.Text>
</Space>
</div>
{/* 详情 */}
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="告警 ID" span={2}>
<Typography.Text copyable style={{ fontSize: 12 }}>{alert.id}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="患者 ID">
<Typography.Text copyable style={{ fontSize: 12 }}>{alert.patient_id}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="规则 ID">
<Typography.Text copyable style={{ fontSize: 12 }}>{alert.rule_id}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="严重程度">
<Tag color={severity.color}>{severity.label}</Tag>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={status.color}>{status.label}</Tag>
</Descriptions.Item>
{alert.acknowledged_by && (
<Descriptions.Item label="处理人" span={2}>
<Typography.Text style={{ fontSize: 12 }}>{alert.acknowledged_by}</Typography.Text>
</Descriptions.Item>
)}
{alert.acknowledged_at && (
<Descriptions.Item label="确认时间">
{new Date(alert.acknowledged_at).toLocaleString('zh-CN')}
</Descriptions.Item>
)}
{alert.resolved_at && (
<Descriptions.Item label="恢复时间">
{new Date(alert.resolved_at).toLocaleString('zh-CN')}
</Descriptions.Item>
)}
</Descriptions>
{/* 告警详情 JSON */}
{alert.detail && (
<div style={{ marginTop: 12 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<pre style={{
fontSize: 12,
background: 'var(--ant-color-bg-layout)',
padding: 8,
borderRadius: 6,
maxHeight: 200,
overflow: 'auto',
margin: '4px 0 0',
}}>
{JSON.stringify(alert.detail, null, 2)}
</pre>
</div>
)}
{/* 操作按钮 */}
<div style={{ marginTop: 16, borderTop: '1px solid var(--ant-color-border)', paddingTop: 12 }}>
<Space>
{isPending && onAcknowledge && (
<Tooltip title="确认已知晓此告警">
<Popconfirm
title="确认此告警?"
description="确认后将标记为已确认状态"
onConfirm={() => onAcknowledge(alert.id, alert.version)}
>
<Button
type="primary"
icon={<CheckOutlined />}
loading={loading}
>
</Button>
</Popconfirm>
</Tooltip>
)}
{isPending && onDismiss && (
<Tooltip title="忽略此告警">
<Popconfirm
title="忽略此告警?"
description="告警将被标记为已忽略"
onConfirm={() => onDismiss(alert.id, alert.version)}
>
<Button icon={<StopOutlined />} loading={loading}>
</Button>
</Popconfirm>
</Tooltip>
)}
{(isPending || isAcknowledged) && onResolve && (
<Tooltip title="标记告警已恢复">
<Popconfirm
title="标记为已恢复?"
description="告警将标记为已恢复状态"
onConfirm={() => onResolve(alert.id, alert.version)}
>
<Button
type="primary"
ghost
icon={<SafetyCertificateOutlined />}
loading={loading}
>
</Button>
</Popconfirm>
</Tooltip>
)}
</Space>
</div>
</div>
);
}