diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 38c7ce1..ac3fb75 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> {/* 内容管理 */} } /> diff --git a/apps/web/src/api/health/alerts.ts b/apps/web/src/api/health/alerts.ts index afe6101..bc8feb7 100644 --- a/apps/web/src/api/health/alerts.ts +++ b/apps/web/src/api/health/alerts.ts @@ -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), acknowledge: (id: string, version: number) => diff --git a/apps/web/src/hooks/useAlertSSE.ts b/apps/web/src/hooks/useAlertSSE.ts new file mode 100644 index 0000000..310db39 --- /dev/null +++ b/apps/web/src/hooks/useAlertSSE.ts @@ -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; + 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(null); + const reconnectKeyRef = useRef(0); + const [connected, setConnected] = useState(false); + const [recentAlerts, setRecentAlerts] = useState([]); + + 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 }; +} diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 5f13c29..5162bae 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -92,6 +92,7 @@ const routeTitleFallback: Record = { '/health/article-categories': '分类管理', '/health/article-tags': '标签管理', '/health/alerts': '告警列表', + '/health/alert-dashboard': '告警仪表盘', '/health/alert-rules': '告警规则', }; diff --git a/apps/web/src/pages/health/AlertDashboard.tsx b/apps/web/src/pages/health/AlertDashboard.tsx new file mode 100644 index 0000000..ca6990a --- /dev/null +++ b/apps/web/src/pages/health/AlertDashboard.tsx @@ -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 = { + info: 'default', + warning: 'orange', + critical: 'red', + urgent: 'magenta', +}; + +const SEVERITY_LABEL: Record = { + info: '提示', + warning: '警告', + critical: '严重', + urgent: '紧急', +}; + +const STATUS_COLOR: Record = { + pending: 'orange', + acknowledged: 'blue', + resolved: 'green', + dismissed: 'default', +}; + +const STATUS_LABEL: Record = { + 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([]); + const [selectedAlert, setSelectedAlert] = useState(null); + const [statusFilter, setStatusFilter] = useState(''); + 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 = { + 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 ( + +