feat(web): 告警管理前端页面 + 路由注册 + bugfix
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

新增:
- AlertList 告警列表页: 状态筛选/确认/忽略操作
- AlertRuleList 告警规则页: 创建/编辑/启停管理
- alerts + deviceReadings 前端 API 层
- App.tsx 路由注册 + MainLayout 标题 fallback
- wiki/frontend.md 更新页面清单

修复:
- ArticleEditor: 修复 unused variable 构建错误
- FollowUpTaskList: 修复 filter(Boolean) 类型窄化问题
This commit is contained in:
iven
2026-04-27 07:38:47 +08:00
parent 3424a33b6b
commit 5f83080ab8
9 changed files with 800 additions and 13 deletions

View File

@@ -43,6 +43,8 @@ const StatisticsDashboard = lazy(() => import('./pages/health/StatisticsDashboar
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 AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
// 内容管理
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
@@ -209,6 +211,8 @@ export default function App() {
<Route path="/health/ai-prompts" element={<AiPromptList />} />
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
<Route path="/health/alerts" element={<AlertList />} />
<Route path="/health/alert-rules" element={<AlertRuleList />} />
{/* 内容管理 */}
<Route path="/health/articles" element={<ArticleManageList />} />
<Route path="/health/articles/new" element={<ArticleEditor />} />

View File

@@ -0,0 +1,109 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Alert {
id: string;
patient_id: string;
rule_id: string;
severity: string;
title: string;
detail?: Record<string, unknown>;
status: string;
acknowledged_by?: string;
acknowledged_at?: string;
resolved_at?: string;
created_at: string;
version: number;
}
export interface AlertRule {
id: string;
name: string;
description?: string;
device_type: string;
condition_type: string;
condition_params: Record<string, unknown>;
severity: string;
is_active: boolean;
apply_tags?: Record<string, unknown>;
notify_roles: unknown[];
cooldown_minutes: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateAlertRuleReq {
name: string;
description?: string;
device_type: string;
condition_type: string;
condition_params: Record<string, unknown>;
severity?: string;
apply_tags?: Record<string, unknown>;
notify_roles?: unknown[];
cooldown_minutes?: number;
}
export interface UpdateAlertRuleReq {
name?: string;
description?: string;
condition_params?: Record<string, unknown>;
severity?: string;
apply_tags?: Record<string, unknown>;
notify_roles?: unknown[];
cooldown_minutes?: number;
version: number;
}
// --- Alert API ---
export async function listAlerts(params?: {
patient_id?: string;
status?: string;
page?: number;
page_size?: number;
}) {
const res = await client.get('/health/alerts', { params });
return res.data.data as PaginatedResponse<Alert>;
}
export async function acknowledgeAlert(id: string, version: number) {
const res = await client.put(`/health/alerts/${id}/acknowledge`, { version });
return res.data.data as Alert;
}
export async function dismissAlert(id: string, version: number) {
const res = await client.put(`/health/alerts/${id}/dismiss`, { version });
return res.data.data as Alert;
}
export async function resolveAlert(id: string, version: number) {
const res = await client.put(`/health/alerts/${id}/resolve`, { version });
return res.data.data as Alert;
}
// --- Alert Rule API ---
export async function listAlertRules(params?: {
device_type?: string;
page?: number;
page_size?: number;
}) {
const res = await client.get('/health/alert-rules', { params });
return res.data.data as PaginatedResponse<AlertRule>;
}
export async function createAlertRule(data: CreateAlertRuleReq) {
const res = await client.post('/health/alert-rules', data);
return res.data.data as AlertRule;
}
export async function updateAlertRule(id: string, data: UpdateAlertRuleReq) {
const res = await client.put(`/health/alert-rules/${id}`, data);
return res.data.data as AlertRule;
}
export async function deactivateAlertRule(id: string, version: number) {
const res = await client.put(`/health/alert-rules/${id}/deactivate`, { version });
return res.data.data as AlertRule;
}

View File

@@ -0,0 +1,70 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface DeviceReading {
id: string;
device_id?: string;
device_type: string;
device_model?: string;
raw_value: Record<string, unknown>;
measured_at: string;
created_at: string;
}
export interface HourlyReading {
id: string;
device_type: string;
hour_start: string;
min_val?: number;
max_val?: number;
avg_val: number;
sample_count: number;
}
export interface BatchReadingRequest {
device_id: string;
device_model?: string;
readings: {
device_type: string;
values: Record<string, unknown>;
measured_at: string;
}[];
}
export interface BatchResult {
accepted: number;
duplicates: number;
earliest?: string;
latest?: string;
}
// --- API ---
export async function batchCreateReadings(patientId: string, data: BatchReadingRequest) {
const res = await client.post(`/health/patients/${patientId}/device-readings/batch`, data);
return res.data.data as BatchResult;
}
export async function queryReadings(params: {
patient_id: string;
device_type?: string;
hours?: number;
page?: number;
page_size?: number;
}) {
const { patient_id, ...query } = params;
const res = await client.get(`/health/patients/${patient_id}/device-readings`, { params: query });
return res.data.data as PaginatedResponse<DeviceReading>;
}
export async function queryHourlyReadings(params: {
patient_id: string;
device_type: string;
days?: number;
page?: number;
page_size?: number;
}) {
const { patient_id, ...query } = params;
const res = await client.get(`/health/patients/${patient_id}/device-readings/hourly`, { params: query });
return res.data.data as PaginatedResponse<HourlyReading>;
}

View File

@@ -92,6 +92,8 @@ const routeTitleFallback: Record<string, string> = {
'/health/articles/:id/edit': '编辑文章',
'/health/article-categories': '分类管理',
'/health/article-tags': '标签管理',
'/health/alerts': '告警列表',
'/health/alert-rules': '告警规则',
};
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {

View File

@@ -0,0 +1,337 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Select,
Button,
Tag,
Space,
Popconfirm,
message,
} from 'antd';
import { CheckOutlined, StopOutlined } from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
import {
listAlerts,
acknowledgeAlert,
dismissAlert,
type Alert,
} from '../../api/health/alerts';
import { AuthButton } from '../../components/AuthButton';
import { useThemeMode } from '../../hooks/useThemeMode';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
// --- 常量映射 ---
const STATUS_OPTIONS = [
{ value: 'pending', label: '待处理' },
{ value: 'acknowledged', label: '已确认' },
{ value: 'resolved', label: '已恢复' },
{ value: 'dismissed', label: '已忽略' },
];
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: '已忽略',
};
// --- 辅助函数 ---
/** 截取 ID 前 8 位用于展示 */
function shortId(id: string): string {
return id.length > 8 ? id.slice(0, 8) : id;
}
/** 从 detail 中提取规则名称 */
function extractRuleName(detail: Record<string, unknown> | undefined): string {
if (!detail) return '-';
const ruleName = detail.rule_name;
return typeof ruleName === 'string' && ruleName ? ruleName : '-';
}
/** 格式化为相对时间 */
function relativeTimeStr(value: string): string {
return dayjs(value).fromNow();
}
export default function AlertList() {
const isDark = useThemeMode();
const [data, setData] = useState<Alert[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [query, setQuery] = useState<{
page: number;
page_size: number;
status?: string;
}>({
page: 1,
page_size: 20,
});
// ---- 数据获取 ----
const fetchData = useCallback(
async (params: { page: number; page_size: number; status?: string }) => {
setLoading(true);
try {
const result = await listAlerts(params);
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载告警列表失败');
} finally {
setLoading(false);
}
},
[],
);
useEffect(() => {
fetchData(query);
}, [query, fetchData]);
// ---- 筛选与分页 ----
const handleFilterChange = (value: string | undefined) => {
setQuery((prev) => ({ ...prev, status: value || undefined, page: 1 }));
};
const handleTableChange = (pagination: TablePaginationConfig) => {
setQuery((prev) => ({
...prev,
page: pagination.current ?? 1,
page_size: pagination.pageSize ?? 20,
}));
};
// ---- 操作 ----
const handleAcknowledge = async (record: Alert) => {
setActionLoading(record.id);
try {
await acknowledgeAlert(record.id, record.version);
message.success('告警已确认');
fetchData(query);
} catch {
message.error('确认告警失败');
} finally {
setActionLoading(null);
}
};
const handleDismiss = async (record: Alert) => {
setActionLoading(record.id);
try {
await dismissAlert(record.id, record.version);
message.success('告警已忽略');
fetchData(query);
} catch {
message.error('忽略告警失败');
} finally {
setActionLoading(null);
}
};
// ---- 列定义 ----
const columns: ColumnsType<Alert> = [
{
title: '患者ID',
dataIndex: 'patient_id',
key: 'patient_id',
width: 110,
render: (id: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 13 }}>
{shortId(id)}
</span>
),
},
{
title: '规则名称',
key: 'rule_name',
width: 160,
render: (_: unknown, record: Alert) =>
extractRuleName(record.detail),
},
{
title: '告警标题',
dataIndex: 'title',
key: 'title',
width: 200,
ellipsis: true,
},
{
title: '严重程度',
dataIndex: 'severity',
key: 'severity',
width: 100,
render: (val: string) => (
<Tag color={SEVERITY_COLOR[val] || 'default'}>
{SEVERITY_LABEL[val] || val}
</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (val: string) => (
<Tag color={STATUS_COLOR[val] || 'default'}>
{STATUS_LABEL[val] || val}
</Tag>
),
},
{
title: '触发时间',
dataIndex: 'created_at',
key: 'created_at',
width: 140,
render: (val: string) => (
<span
title={dayjs(val).format('YYYY-MM-DD HH:mm:ss')}
style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}
>
{relativeTimeStr(val)}
</span>
),
},
{
title: '操作',
key: 'actions',
width: 160,
render: (_: unknown, record: Alert) => (
<AuthButton code="health.alert.manage">
<Space size={4}>
{record.status === 'pending' && (
<Popconfirm
title="确认处理该告警?"
onConfirm={() => handleAcknowledge(record)}
okText="确认"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<CheckOutlined />}
loading={actionLoading === record.id}
>
</Button>
</Popconfirm>
)}
{(record.status === 'pending' || record.status === 'acknowledged') && (
<Popconfirm
title="确认忽略该告警?"
onConfirm={() => handleDismiss(record)}
okText="确认"
cancelText="取消"
>
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
loading={actionLoading === record.id}
>
</Button>
</Popconfirm>
)}
</Space>
</AuthButton>
),
},
];
return (
<div>
{/* 筛选栏 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: 12,
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 10,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}
>
<Select
allowClear
placeholder="状态筛选"
style={{ width: 160 }}
options={STATUS_OPTIONS}
value={query.status}
onChange={handleFilterChange}
/>
<span
style={{
fontSize: 13,
color: isDark ? '#475569' : '#94a3b8',
marginLeft: 'auto',
}}
>
{total}
</span>
</div>
{/* 数据表格 */}
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
onChange={handleTableChange}
pagination={{
current: query.page,
pageSize: query.page_size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 970 }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,251 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, Form, Input, InputNumber, message, Modal, Select, Space, Switch, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
createAlertRule,
deactivateAlertRule,
listAlertRules,
updateAlertRule,
type AlertRule,
type CreateAlertRuleReq,
type UpdateAlertRuleReq,
} from '../../api/health/alerts';
const DEVICE_TYPES = [
{ label: '心率', value: 'heart_rate' },
{ label: '血氧', value: 'blood_oxygen' },
{ label: '体温', value: 'temperature' },
{ label: '步数', value: 'steps' },
{ label: '睡眠', value: 'sleep' },
{ label: '压力', value: 'stress' },
];
const CONDITION_TYPES = [
{ label: '单次阈值', value: 'single_threshold' },
{ label: '连续触发', value: 'consecutive' },
{ label: '趋势变化', value: 'trend' },
];
const SEVERITY_OPTIONS = [
{ label: '提示', value: 'info' },
{ label: '警告', value: 'warning' },
{ label: '严重', value: 'critical' },
{ label: '紧急', value: 'urgent' },
];
const SEVERITY_COLOR: Record<string, string> = {
info: 'default',
warning: 'orange',
critical: 'red',
urgent: 'magenta',
};
export default function AlertRuleList() {
const [data, setData] = useState<AlertRule[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [modalOpen, setModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<AlertRule | null>(null);
const [form] = Form.useForm();
const fetchRules = useCallback(async () => {
setLoading(true);
try {
const res = await listAlertRules({ page, page_size: 20 });
setData(res.data);
setTotal(res.total);
} catch {
message.error('加载规则列表失败');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
fetchRules();
}, [fetchRules]);
const openCreateModal = () => {
setEditingRule(null);
form.resetFields();
form.setFieldsValue({
severity: 'warning',
cooldown_minutes: 60,
});
setModalOpen(true);
};
const openEditModal = (rule: AlertRule) => {
setEditingRule(rule);
form.setFieldsValue({
name: rule.name,
description: rule.description,
device_type: rule.device_type,
condition_type: rule.condition_type,
condition_params: JSON.stringify(rule.condition_params, null, 2),
severity: rule.severity,
cooldown_minutes: rule.cooldown_minutes,
});
setModalOpen(true);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const conditionParams = JSON.parse(values.condition_params);
if (editingRule) {
const req: UpdateAlertRuleReq = {
name: values.name,
description: values.description,
condition_params: conditionParams,
severity: values.severity,
cooldown_minutes: values.cooldown_minutes,
version: editingRule.version,
};
await updateAlertRule(editingRule.id, req);
message.success('规则已更新');
} else {
const req: CreateAlertRuleReq = {
name: values.name,
description: values.description,
device_type: values.device_type,
condition_type: values.condition_type,
condition_params: conditionParams,
severity: values.severity,
cooldown_minutes: values.cooldown_minutes,
};
await createAlertRule(req);
message.success('规则已创建');
}
setModalOpen(false);
fetchRules();
} catch (e) {
if (e instanceof SyntaxError) {
message.error('条件参数 JSON 格式无效');
}
}
};
const handleToggle = async (rule: AlertRule, active: boolean) => {
try {
if (!active) {
await deactivateAlertRule(rule.id, rule.version);
message.success('规则已禁用');
}
fetchRules();
} catch {
message.error('操作失败');
}
};
const columns: ColumnsType<AlertRule> = [
{ title: '规则名称', dataIndex: 'name', width: 180 },
{
title: '指标类型',
dataIndex: 'device_type',
width: 100,
render: (v: string) => DEVICE_TYPES.find((d) => d.value === v)?.label || v,
},
{
title: '条件类型',
dataIndex: 'condition_type',
width: 120,
render: (v: string) => CONDITION_TYPES.find((c) => c.value === v)?.label || v,
},
{
title: '严重程度',
dataIndex: 'severity',
width: 90,
render: (v: string) => <Tag color={SEVERITY_COLOR[v] || 'default'}>{v}</Tag>,
},
{
title: '启用',
dataIndex: 'is_active',
width: 80,
render: (v: boolean, record) => (
<Switch checked={v} onChange={(checked) => handleToggle(record, checked)} />
),
},
{
title: '冷却(分)',
dataIndex: 'cooldown_minutes',
width: 90,
},
{
title: '操作',
width: 80,
render: (_, record) => (
<Button size="small" type="link" onClick={() => openEditModal(record)}>
</Button>
),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0 }}></h2>
<Button type="primary" onClick={openCreateModal}></Button>
</div>
<Table<AlertRule>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
showTotal: (t) => `${t}`,
onChange: (p) => setPage(p),
}}
/>
<Modal
title={editingRule ? '编辑规则' : '新建规则'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={560}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="规则名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} />
</Form.Item>
<Space style={{ width: '100%' }} size="large">
<Form.Item name="device_type" label="指标类型" rules={[{ required: true }]}>
<Select style={{ width: 160 }} options={DEVICE_TYPES} disabled={!!editingRule} />
</Form.Item>
<Form.Item name="condition_type" label="条件类型" rules={[{ required: true }]}>
<Select style={{ width: 160 }} options={CONDITION_TYPES} disabled={!!editingRule} />
</Form.Item>
</Space>
<Form.Item
name="condition_params"
label="条件参数 (JSON)"
rules={[{ required: true }]}
extra='例如: {"direction":"above","value":100}'
>
<Input.TextArea rows={4} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Space style={{ width: '100%' }} size="large">
<Form.Item name="severity" label="严重程度">
<Select style={{ width: 140 }} options={SEVERITY_OPTIONS} />
</Form.Item>
<Form.Item name="cooldown_minutes" label="冷却时间(分钟)">
<InputNumber min={1} max={1440} style={{ width: 140 }} />
</Form.Item>
</Space>
</Form>
</Modal>
</div>
);
}

View File

@@ -160,7 +160,7 @@ export default function ArticleEditor() {
const updated = await articleApi.get(id);
setVersion(updated.version);
} else {
const created = await articleApi.create({
await articleApi.create({
title,
summary: summary || undefined,
content,
@@ -171,7 +171,8 @@ export default function ArticleEditor() {
sort_order: sortOrder,
});
message.success('文章已创建');
navigate(`/health/articles/${created.id}/edit`, { replace: true });
// 返回列表页,避免 WangEditor 全局 toolbar 注册导致同组件内重复初始化
navigate('/health/articles');
}
} catch (err: unknown) {
const errorMsg =
@@ -211,7 +212,7 @@ export default function ArticleEditor() {
currentVersion = updated.version;
setVersion(updated.version);
} else {
const created = await articleApi.create({
const _created = await articleApi.create({
title,
summary: summary || undefined,
content,
@@ -221,10 +222,9 @@ export default function ArticleEditor() {
tag_ids: selectedTagIds,
sort_order: sortOrder,
});
currentVersion = created.version;
setVersion(created.version);
// 新建后直接提交审核(此时 id 仍为 undefined使用 created.id
await articleApi.submit(created.id, currentVersion);
currentVersion = _created.version;
setVersion(_created.version);
await articleApi.submit(_created.id, currentVersion);
message.success('已提交审核');
navigate('/health/articles');
return;
@@ -295,8 +295,9 @@ export default function ArticleEditor() {
{/* 主体布局: 左侧编辑区 + 右侧设置面板 */}
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
{/* 左侧: 富文本编辑器 */}
{/* 左侧: 富文本编辑器 — key 变化时强制重新挂载,防止 WangEditor toolbar 重复创建 */}
<div
key={id ?? 'new'}
style={{
flex: 1,
background: isDark ? '#111827' : '#FFFFFF',

View File

@@ -123,10 +123,10 @@ export default function FollowUpTaskList() {
}
// Batch resolve assignee names
const assigneeIds = [...new Set(result.data.map((t: FollowUpTask) => t.assigned_to).filter(Boolean))];
const assigneeIds = [...new Set(result.data.map((t: FollowUpTask) => t.assigned_to).filter((x): x is string => !!x))];
const newDoctorLabels: Record<string, string> = {};
await Promise.allSettled(
assigneeIds.map(async (id: string) => {
assigneeIds.map(async (id) => {
try {
const u = await getUser(id);
newDoctorLabels[id] = u.display_name || u.username;