fix(web): 告警详情面板用户体验改进
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled

- 用 Alert 横幅醒目展示严重程度、标题和行动指引
- 患者信息卡片显示姓名而非原始 UUID
- 将 JSON 详情解析为中文标签(告警描述/监测值/阈值等)
- 技术信息(原始 ID)移入折叠面板
This commit is contained in:
iven
2026-05-07 07:38:04 +08:00
parent 1613e3cfe9
commit a9821ab832

View File

@@ -1,18 +1,21 @@
import { Descriptions, Tag, Typography, Space, Button, Popconfirm, Tooltip } from 'antd'; import { Descriptions, Tag, Typography, Space, Button, Popconfirm, Tooltip, Collapse, Alert as AntAlert } from 'antd';
import { import {
CheckOutlined, CheckOutlined,
StopOutlined, StopOutlined,
SafetyCertificateOutlined, SafetyCertificateOutlined,
ClockCircleOutlined, ClockCircleOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
UserOutlined,
CodeOutlined,
MedicineBoxOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { Alert } from '../../../api/health/alerts'; import type { Alert } from '../../../api/health/alerts';
const SEVERITY_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode }> = { const SEVERITY_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode; bannerType: 'info' | 'warning' | 'error' }> = {
info: { color: 'default', label: '提示', icon: <ExclamationCircleOutlined /> }, info: { color: 'default', label: '提示', icon: <ExclamationCircleOutlined />, bannerType: 'info' },
warning: { color: 'orange', label: '警告', icon: <ExclamationCircleOutlined /> }, warning: { color: 'orange', label: '警告', icon: <ExclamationCircleOutlined />, bannerType: 'warning' },
critical: { color: 'red', label: '严重', icon: <ExclamationCircleOutlined /> }, critical: { color: 'red', label: '严重', icon: <ExclamationCircleOutlined />, bannerType: 'error' },
urgent: { color: 'magenta', label: '紧急', icon: <ExclamationCircleOutlined /> }, urgent: { color: 'magenta', label: '紧急', icon: <ExclamationCircleOutlined />, bannerType: 'error' },
}; };
const STATUS_CONFIG: Record<string, { color: string; label: string }> = { const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
@@ -23,17 +26,56 @@ const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
dismissed: { color: 'default', label: '已忽略' }, dismissed: { color: 'default', label: '已忽略' },
}; };
const SEVERITY_GUIDANCE: Record<string, string> = {
info: '请关注患者状态变化,必要时进行随访。',
warning: '建议尽快确认并安排相关检查或随访。',
critical: '需要立即关注!请确认告警并安排紧急处理。',
urgent: '紧急状况!请立即确认并采取干预措施。',
};
const DETAIL_LABEL_MAP: Record<string, string> = {
message: '告警描述',
value: '监测值',
threshold: '阈值',
unit: '单位',
metric: '指标',
metric_name: '指标名称',
indicator_type: '体征类型',
recorded_at: '记录时间',
patient_name: '患者',
blood_pressure_systolic: '收缩压',
blood_pressure_diastolic: '舒张压',
heart_rate: '心率',
blood_glucose: '血糖',
temperature: '体温',
spo2: '血氧饱和度',
};
function formatDetailValue(key: string, value: unknown): string {
if (value === null || value === undefined) return '-';
if (typeof value === 'string') {
if (key.endsWith('_at') || key === 'recorded_at') {
try { return new Date(value).toLocaleString('zh-CN'); } catch { return value; }
}
return value;
}
if (typeof value === 'number') return String(value);
if (typeof value === 'boolean') return value ? '是' : '否';
return JSON.stringify(value);
}
function getDetailLabel(key: string): string {
return DETAIL_LABEL_MAP[key] ?? key;
}
interface AlertDetailPanelProps { interface AlertDetailPanelProps {
alert: Alert; alert: Alert;
onAcknowledge?: (id: string, version: number) => Promise<void>; onAcknowledge?: (id: string, version: number) => Promise<void>;
onDismiss?: (id: string, version: number) => Promise<void>; onDismiss?: (id: string, version: number, reason?: string) => Promise<void>;
onResolve?: (id: string, version: number) => Promise<void>; onResolve?: (id: string, version: number) => Promise<void>;
loading?: boolean; loading?: boolean;
} }
/**
* 告警详情面板 — 展示告警完整信息及操作按钮。
*/
export function AlertDetailPanel({ export function AlertDetailPanel({
alert, alert,
onAcknowledge, onAcknowledge,
@@ -46,124 +88,174 @@ export function AlertDetailPanel({
const isPending = alert.status === 'pending' || alert.status === 'active'; const isPending = alert.status === 'pending' || alert.status === 'active';
const isAcknowledged = alert.status === 'acknowledged'; const isAcknowledged = alert.status === 'acknowledged';
const detailEntries = alert.detail
? Object.entries(alert.detail).filter(([, v]) => v !== null && v !== undefined)
: [];
const detailMessage = alert.detail?.message as string | undefined;
return ( return (
<div style={{ padding: 16 }}> <div style={{ padding: 16 }}>
{/* 顶部摘要 */} {/* 严重程度提示 */}
<div style={{ marginBottom: 16 }}> <AntAlert
<Space align="center" size="middle"> type={severity.bannerType}
<Tag color={severity.color} icon={severity.icon} style={{ fontSize: 14, padding: '4px 12px' }}> showIcon
{severity.label} icon={severity.icon}
</Tag> banner={false}
<Tag color={status.color}>{status.label}</Tag> style={{ marginBottom: 12 }}
message={
<Space>
<span style={{ fontWeight: 600 }}>{alert.title || severity.label}</span>
<Tag color={status.color}>{status.label}</Tag>
</Space>
}
description={SEVERITY_GUIDANCE[alert.severity] ?? '请关注此告警并及时处理。'}
/>
{/* 患者信息 */}
<div style={{
background: 'var(--ant-color-bg-container)',
border: '1px solid var(--ant-color-border)',
borderRadius: 8,
padding: '12px 16px',
marginBottom: 12,
}}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Space>
<MedicineBoxOutlined style={{ color: 'var(--ant-color-primary)' }} />
<Typography.Text strong style={{ fontSize: 15 }}>
{alert.patient_name || '未知患者'}
</Typography.Text>
<Tooltip title={`患者 ID: ${alert.patient_id}`}>
<Typography.Text type="secondary" style={{ fontSize: 12, cursor: 'help' }}>
(ID: {alert.patient_id.slice(0, 8)}...)
</Typography.Text>
</Tooltip>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
<ClockCircleOutlined style={{ marginRight: 4 }} /> <ClockCircleOutlined style={{ marginRight: 4 }} />
{new Date(alert.created_at).toLocaleString('zh-CN')} {new Date(alert.created_at).toLocaleString('zh-CN')}
</Typography.Text> </Typography.Text>
</Space> </Space>
</div> </div>
{/* 详情 */} {/* 告警详情 */}
<Descriptions column={2} size="small" bordered> {detailEntries.length > 0 && (
<Descriptions.Item label="告警 ID" span={2}> <div style={{ marginBottom: 12 }}>
<Typography.Text copyable style={{ fontSize: 12 }}>{alert.id}</Typography.Text> <Typography.Text type="secondary" style={{ fontSize: 13, marginBottom: 8, display: 'block' }}>
</Descriptions.Item> <UserOutlined style={{ marginRight: 4 }} />
<Descriptions.Item label="患者 ID">
<Typography.Text copyable style={{ fontSize: 12 }}>{alert.patient_id}</Typography.Text> </Typography.Text>
</Descriptions.Item> <div style={{
<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)', background: 'var(--ant-color-bg-layout)',
padding: 8,
borderRadius: 6, borderRadius: 6,
maxHeight: 200, padding: '8px 12px',
overflow: 'auto',
margin: '4px 0 0',
}}> }}>
{JSON.stringify(alert.detail, null, 2)} {detailMessage && (
</pre> <Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: detailEntries.length > 1 ? 8 : 0 }}>
{detailMessage}
</Typography.Text>
)}
<Descriptions
column={2}
size="small"
items={detailEntries
.filter(([k]) => k !== 'message')
.map(([key, value]) => ({
key,
label: getDetailLabel(key),
children: <Typography.Text style={{ fontSize: 13 }}>{formatDetailValue(key, value)}</Typography.Text>,
}))
}
/>
</div>
</div> </div>
)} )}
{/* 处理记录 */}
{(alert.acknowledged_by || alert.resolved_at) && (
<Descriptions column={2} size="small" bordered style={{ marginBottom: 12 }}>
{alert.acknowledged_by && (
<Descriptions.Item label="处理人">
<Typography.Text style={{ fontSize: 13 }}>{alert.acknowledged_by}</Typography.Text>
</Descriptions.Item>
)}
{alert.acknowledged_at && (
<Descriptions.Item label="确认时间">
<Typography.Text style={{ fontSize: 13 }}>
{new Date(alert.acknowledged_at).toLocaleString('zh-CN')}
</Typography.Text>
</Descriptions.Item>
)}
{alert.resolved_at && (
<Descriptions.Item label="恢复时间" span={2}>
<Typography.Text style={{ fontSize: 13 }}>
{new Date(alert.resolved_at).toLocaleString('zh-CN')}
</Typography.Text>
</Descriptions.Item>
)}
</Descriptions>
)}
{/* 技术信息(折叠) */}
<Collapse
ghost
size="small"
items={[{
key: 'tech',
label: <Typography.Text type="secondary" style={{ fontSize: 12 }}><CodeOutlined /> </Typography.Text>,
children: (
<Descriptions column={1} size="small">
<Descriptions.Item label="告警 ID">
<Typography.Text copyable style={{ fontSize: 12 }}>{alert.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="患者 ID">
<Typography.Text copyable style={{ fontSize: 12 }}>{alert.patient_id}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="版本">{alert.version}</Descriptions.Item>
</Descriptions>
),
}]}
/>
{/* 操作按钮 */} {/* 操作按钮 */}
<div style={{ marginTop: 16, borderTop: '1px solid var(--ant-color-border)', paddingTop: 12 }}> <div style={{ marginTop: 8, borderTop: '1px solid var(--ant-color-border)', paddingTop: 12 }}>
<Space> <Space>
{isPending && onAcknowledge && ( {isPending && onAcknowledge && (
<Tooltip title="确认已知晓此告警"> <Popconfirm
<Popconfirm title="确认此告警?"
title="确认此告警?" description="确认后将标记为已确认状态"
description="确认后将标记为已确认状态" onConfirm={() => onAcknowledge(alert.id, alert.version)}
onConfirm={() => onAcknowledge(alert.id, alert.version)} >
> <Button type="primary" icon={<CheckOutlined />} loading={loading}>
<Button
type="primary" </Button>
icon={<CheckOutlined />} </Popconfirm>
loading={loading}
>
</Button>
</Popconfirm>
</Tooltip>
)} )}
{isPending && onDismiss && ( {isPending && onDismiss && (
<Tooltip title="忽略此告警"> <Popconfirm
<Popconfirm title="忽略此告警?"
title="忽略此告警?" description="告警将被标记为已忽略"
description="告警将被标记为已忽略" onConfirm={() => onDismiss(alert.id, alert.version)}
onConfirm={() => onDismiss(alert.id, alert.version)} >
> <Button icon={<StopOutlined />} loading={loading}>
<Button icon={<StopOutlined />} loading={loading}>
</Button>
</Button> </Popconfirm>
</Popconfirm>
</Tooltip>
)} )}
{(isPending || isAcknowledged) && onResolve && ( {(isPending || isAcknowledged) && onResolve && (
<Tooltip title="标记告警已恢复"> <Popconfirm
<Popconfirm title="标记为已恢复?"
title="标记为已恢复" description="告警将标记为已恢复状态"
description="告警将标记为已恢复状态" onConfirm={() => onResolve(alert.id, alert.version)}
onConfirm={() => onResolve(alert.id, alert.version)} >
> <Button type="primary" ghost icon={<SafetyCertificateOutlined />} loading={loading}>
<Button
type="primary" </Button>
ghost </Popconfirm>
icon={<SafetyCertificateOutlined />}
loading={loading}
>
</Button>
</Popconfirm>
</Tooltip>
)} )}
</Space> </Space>
</div> </div>