feat(web): 实时告警仪表盘页面 + SSE Hook + 告警详情面板
- 新增 AlertDashboard 页面:实时告警列表 + 统计摘要 + 详情面板 - 新增 useAlertSSE Hook:封装 SSE 连接、自动重连、事件分发 - 新增 AlertDetailPanel 组件:告警详情展示 + 确认/忽略/恢复操作 - alertApi.list 添加 doctor_id 参数支持 - 注册 /health/alert-dashboard 路由 + 面包屑映射
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
129
apps/web/src/hooks/useAlertSSE.ts
Normal file
129
apps/web/src/hooks/useAlertSSE.ts
Normal 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 };
|
||||
}
|
||||
@@ -92,6 +92,7 @@ const routeTitleFallback: Record<string, string> = {
|
||||
'/health/article-categories': '分类管理',
|
||||
'/health/article-tags': '标签管理',
|
||||
'/health/alerts': '告警列表',
|
||||
'/health/alert-dashboard': '告警仪表盘',
|
||||
'/health/alert-rules': '告警规则',
|
||||
};
|
||||
|
||||
|
||||
318
apps/web/src/pages/health/AlertDashboard.tsx
Normal file
318
apps/web/src/pages/health/AlertDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
apps/web/src/pages/health/components/AlertDetailPanel.tsx
Normal file
171
apps/web/src/pages/health/components/AlertDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user