From 5f83080ab82b5ecf4a17eef7d5abf8f2f43a64f4 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 27 Apr 2026 07:38:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=91=8A=E8=AD=A6=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=89=8D=E7=AB=AF=E9=A1=B5=E9=9D=A2=20+=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E6=B3=A8=E5=86=8C=20+=20bugfix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - AlertList 告警列表页: 状态筛选/确认/忽略操作 - AlertRuleList 告警规则页: 创建/编辑/启停管理 - alerts + deviceReadings 前端 API 层 - App.tsx 路由注册 + MainLayout 标题 fallback - wiki/frontend.md 更新页面清单 修复: - ArticleEditor: 修复 unused variable 构建错误 - FollowUpTaskList: 修复 filter(Boolean) 类型窄化问题 --- apps/web/src/App.tsx | 4 + apps/web/src/api/health/alerts.ts | 109 ++++++ apps/web/src/api/health/deviceReadings.ts | 70 ++++ apps/web/src/layouts/MainLayout.tsx | 2 + apps/web/src/pages/health/AlertList.tsx | 337 ++++++++++++++++++ apps/web/src/pages/health/AlertRuleList.tsx | 251 +++++++++++++ apps/web/src/pages/health/ArticleEditor.tsx | 17 +- .../web/src/pages/health/FollowUpTaskList.tsx | 4 +- wiki/frontend.md | 19 +- 9 files changed, 800 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/api/health/alerts.ts create mode 100644 apps/web/src/api/health/deviceReadings.ts create mode 100644 apps/web/src/pages/health/AlertList.tsx create mode 100644 apps/web/src/pages/health/AlertRuleList.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ca2f3e6..5c19eb4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> {/* 内容管理 */} } /> } /> diff --git a/apps/web/src/api/health/alerts.ts b/apps/web/src/api/health/alerts.ts new file mode 100644 index 0000000..4347367 --- /dev/null +++ b/apps/web/src/api/health/alerts.ts @@ -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; + 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; + severity: string; + is_active: boolean; + apply_tags?: Record; + 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; + severity?: string; + apply_tags?: Record; + notify_roles?: unknown[]; + cooldown_minutes?: number; +} + +export interface UpdateAlertRuleReq { + name?: string; + description?: string; + condition_params?: Record; + severity?: string; + apply_tags?: Record; + 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; +} + +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; +} + +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; +} diff --git a/apps/web/src/api/health/deviceReadings.ts b/apps/web/src/api/health/deviceReadings.ts new file mode 100644 index 0000000..ceb3e43 --- /dev/null +++ b/apps/web/src/api/health/deviceReadings.ts @@ -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; + 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; + 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; +} + +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; +} diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 9be0f4e..f9e612b 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -92,6 +92,8 @@ const routeTitleFallback: Record = { '/health/articles/:id/edit': '编辑文章', '/health/article-categories': '分类管理', '/health/article-tags': '标签管理', + '/health/alerts': '告警列表', + '/health/alert-rules': '告警规则', }; function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined { diff --git a/apps/web/src/pages/health/AlertList.tsx b/apps/web/src/pages/health/AlertList.tsx new file mode 100644 index 0000000..1d56555 --- /dev/null +++ b/apps/web/src/pages/health/AlertList.tsx @@ -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 = { + 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: '已忽略', +}; + +// --- 辅助函数 --- + +/** 截取 ID 前 8 位用于展示 */ +function shortId(id: string): string { + return id.length > 8 ? id.slice(0, 8) : id; +} + +/** 从 detail 中提取规则名称 */ +function extractRuleName(detail: Record | 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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [actionLoading, setActionLoading] = useState(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 = [ + { + title: '患者ID', + dataIndex: 'patient_id', + key: 'patient_id', + width: 110, + render: (id: string) => ( + + {shortId(id)} + + ), + }, + { + 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) => ( + + {SEVERITY_LABEL[val] || val} + + ), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (val: string) => ( + + {STATUS_LABEL[val] || val} + + ), + }, + { + title: '触发时间', + dataIndex: 'created_at', + key: 'created_at', + width: 140, + render: (val: string) => ( + + {relativeTimeStr(val)} + + ), + }, + { + title: '操作', + key: 'actions', + width: 160, + render: (_: unknown, record: Alert) => ( + + + {record.status === 'pending' && ( + handleAcknowledge(record)} + okText="确认" + cancelText="取消" + > + + + )} + {(record.status === 'pending' || record.status === 'acknowledged') && ( + handleDismiss(record)} + okText="确认" + cancelText="取消" + > + + + )} + + + ), + }, + ]; + + return ( +
+ {/* 筛选栏 */} +
+ + + + + + + + + + + + + + + +