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 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 />} />
|
||||
|
||||
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/article-categories': '分类管理',
|
||||
'/health/article-tags': '标签管理',
|
||||
'/health/alerts': '告警列表',
|
||||
'/health/alert-rules': '告警规则',
|
||||
};
|
||||
|
||||
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);
|
||||
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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user