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

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