feat(web): 告警管理前端页面 + 路由注册 + bugfix
新增: - AlertList 告警列表页: 状态筛选/确认/忽略操作 - AlertRuleList 告警规则页: 创建/编辑/启停管理 - alerts + deviceReadings 前端 API 层 - App.tsx 路由注册 + MainLayout 标题 fallback - wiki/frontend.md 更新页面清单 修复: - ArticleEditor: 修复 unused variable 构建错误 - FollowUpTaskList: 修复 filter(Boolean) 类型窄化问题
This commit is contained in:
@@ -43,6 +43,8 @@ const StatisticsDashboard = lazy(() => import('./pages/health/StatisticsDashboar
|
|||||||
const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
|
const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
|
||||||
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
|
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
|
||||||
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
|
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'));
|
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-prompts" element={<AiPromptList />} />
|
||||||
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
||||||
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
<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" element={<ArticleManageList />} />
|
||||||
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
||||||
|
|||||||
109
apps/web/src/api/health/alerts.ts
Normal file
109
apps/web/src/api/health/alerts.ts
Normal 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;
|
||||||
|
}
|
||||||
70
apps/web/src/api/health/deviceReadings.ts
Normal file
70
apps/web/src/api/health/deviceReadings.ts
Normal 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>;
|
||||||
|
}
|
||||||
@@ -92,6 +92,8 @@ const routeTitleFallback: Record<string, string> = {
|
|||||||
'/health/articles/:id/edit': '编辑文章',
|
'/health/articles/:id/edit': '编辑文章',
|
||||||
'/health/article-categories': '分类管理',
|
'/health/article-categories': '分类管理',
|
||||||
'/health/article-tags': '标签管理',
|
'/health/article-tags': '标签管理',
|
||||||
|
'/health/alerts': '告警列表',
|
||||||
|
'/health/alert-rules': '告警规则',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
||||||
|
|||||||
337
apps/web/src/pages/health/AlertList.tsx
Normal file
337
apps/web/src/pages/health/AlertList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
apps/web/src/pages/health/AlertRuleList.tsx
Normal file
251
apps/web/src/pages/health/AlertRuleList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -160,7 +160,7 @@ export default function ArticleEditor() {
|
|||||||
const updated = await articleApi.get(id);
|
const updated = await articleApi.get(id);
|
||||||
setVersion(updated.version);
|
setVersion(updated.version);
|
||||||
} else {
|
} else {
|
||||||
const created = await articleApi.create({
|
await articleApi.create({
|
||||||
title,
|
title,
|
||||||
summary: summary || undefined,
|
summary: summary || undefined,
|
||||||
content,
|
content,
|
||||||
@@ -171,7 +171,8 @@ export default function ArticleEditor() {
|
|||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
});
|
});
|
||||||
message.success('文章已创建');
|
message.success('文章已创建');
|
||||||
navigate(`/health/articles/${created.id}/edit`, { replace: true });
|
// 返回列表页,避免 WangEditor 全局 toolbar 注册导致同组件内重复初始化
|
||||||
|
navigate('/health/articles');
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
@@ -211,7 +212,7 @@ export default function ArticleEditor() {
|
|||||||
currentVersion = updated.version;
|
currentVersion = updated.version;
|
||||||
setVersion(updated.version);
|
setVersion(updated.version);
|
||||||
} else {
|
} else {
|
||||||
const created = await articleApi.create({
|
const _created = await articleApi.create({
|
||||||
title,
|
title,
|
||||||
summary: summary || undefined,
|
summary: summary || undefined,
|
||||||
content,
|
content,
|
||||||
@@ -221,10 +222,9 @@ export default function ArticleEditor() {
|
|||||||
tag_ids: selectedTagIds,
|
tag_ids: selectedTagIds,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
});
|
});
|
||||||
currentVersion = created.version;
|
currentVersion = _created.version;
|
||||||
setVersion(created.version);
|
setVersion(_created.version);
|
||||||
// 新建后直接提交审核(此时 id 仍为 undefined,使用 created.id)
|
await articleApi.submit(_created.id, currentVersion);
|
||||||
await articleApi.submit(created.id, currentVersion);
|
|
||||||
message.success('已提交审核');
|
message.success('已提交审核');
|
||||||
navigate('/health/articles');
|
navigate('/health/articles');
|
||||||
return;
|
return;
|
||||||
@@ -295,8 +295,9 @@ export default function ArticleEditor() {
|
|||||||
|
|
||||||
{/* 主体布局: 左侧编辑区 + 右侧设置面板 */}
|
{/* 主体布局: 左侧编辑区 + 右侧设置面板 */}
|
||||||
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
|
||||||
{/* 左侧: 富文本编辑器 */}
|
{/* 左侧: 富文本编辑器 — key 变化时强制重新挂载,防止 WangEditor toolbar 重复创建 */}
|
||||||
<div
|
<div
|
||||||
|
key={id ?? 'new'}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
background: isDark ? '#111827' : '#FFFFFF',
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
|||||||
@@ -123,10 +123,10 @@ export default function FollowUpTaskList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Batch resolve assignee names
|
// 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> = {};
|
const newDoctorLabels: Record<string, string> = {};
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
assigneeIds.map(async (id: string) => {
|
assigneeIds.map(async (id) => {
|
||||||
try {
|
try {
|
||||||
const u = await getUser(id);
|
const u = await getUser(id);
|
||||||
newDoctorLabels[id] = u.display_name || u.username;
|
newDoctorLabels[id] = u.display_name || u.username;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Web 前端
|
title: Web 前端
|
||||||
updated: 2026-04-25
|
updated: 2026-04-26
|
||||||
status: stable
|
status: stable
|
||||||
tags: [frontend, react, antd, vite, spa]
|
tags: [frontend, react, antd, vite, spa]
|
||||||
---
|
---
|
||||||
@@ -87,7 +87,7 @@ React 19.2.4 / Ant Design 6.3.5 / React Router 7.14.0 / Zustand 5.0.12 / Vite 8.
|
|||||||
| `/plugins/:pluginId/:entityName` | 插件 CRUD(动态生成) |
|
| `/plugins/:pluginId/:entityName` | 插件 CRUD(动态生成) |
|
||||||
| `/plugins/:pluginId/tabs|tree|graph|dashboard|kanban/:name` | 插件多视图页面 |
|
| `/plugins/:pluginId/tabs|tree|graph|dashboard|kanban/:name` | 插件多视图页面 |
|
||||||
|
|
||||||
**健康管理路由(10 条)**:
|
**健康管理路由(22 条)**:
|
||||||
|
|
||||||
| 路径 | 页面 |
|
| 路径 | 页面 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -101,8 +101,20 @@ React 19.2.4 / Ant Design 6.3.5 / React Router 7.14.0 / Zustand 5.0.12 / Vite 8.
|
|||||||
| `/health/follow-up-records` | 随访记录 |
|
| `/health/follow-up-records` | 随访记录 |
|
||||||
| `/health/consultations` | 咨询会话列表 |
|
| `/health/consultations` | 咨询会话列表 |
|
||||||
| `/health/consultations/:id` | 咨询详情(含消息 + 导出) |
|
| `/health/consultations/:id` | 咨询详情(含消息 + 导出) |
|
||||||
|
| `/health/articles` | 文章管理列表 |
|
||||||
|
| `/health/article-editor` | 文章编辑器(富文本) |
|
||||||
|
| `/health/article-categories` | 文章分类管理 |
|
||||||
|
| `/health/article-tags` | 文章标签管理 |
|
||||||
|
| `/health/points-rules` | 积分规则管理 |
|
||||||
|
| `/health/points-products` | 积分商品管理 |
|
||||||
|
| `/health/points-orders` | 积分订单列表 |
|
||||||
|
| `/health/statistics` | 统计概览(透析/化验/预约/体征上报率) |
|
||||||
|
| `/health/offline-events` | 线下活动管理 |
|
||||||
|
| `/health/ai-analysis` | AI 分析历史 |
|
||||||
|
| `/health/ai-prompts` | AI Prompt 管理 |
|
||||||
|
| `/health/ai-usage` | AI 用量统计 |
|
||||||
|
|
||||||
### 健康模块共享组件(12 个)
|
### 健康模块共享组件(11 个)
|
||||||
|
|
||||||
| 组件 | 用途 |
|
| 组件 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -196,6 +208,7 @@ ws://localhost:5174/ws/* → ws://localhost:3000/* (WebSocket)
|
|||||||
|
|
||||||
| 日期 | 变更 |
|
| 日期 | 变更 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| 2026-04-26 | 全面更新:22 条健康路由(+12 内容/积分/统计/活动/AI)、11 个共享组件、77 个 TSX 文件 |
|
||||||
| 2026-04-26 | 从 CLAUDE.md 迁移:UI 布局规范(§8) |
|
| 2026-04-26 | 从 CLAUDE.md 迁移:UI 布局规范(§8) |
|
||||||
| 2026-04-26 | VitalSignsChart 重设计:概览卡片条 + 点击展开详情图,5 指标独立 Y 轴 |
|
| 2026-04-26 | VitalSignsChart 重设计:概览卡片条 + 点击展开详情图,5 指标独立 Y 轴 |
|
||||||
| 2026-04-25 | 全面更新:10 条健康路由、12 个共享组件、7 个健康 API 文件、3 个单元测试 |
|
| 2026-04-25 | 全面更新:10 条健康路由、12 个共享组件、7 个健康 API 文件、3 个单元测试 |
|
||||||
|
|||||||
Reference in New Issue
Block a user