Files
hms/apps/web/src/pages/health/AlertRuleList.tsx
iven 30a578ee00
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
fix(health): 客户试用前全局审计修复 — P0 权限旁路 + API 路径 + 事件注册
P0 阻塞修复:
- 修复 PrivateRoute 权限旁路: p.startsWith('auth.') 匹配不到任何权限码,
  改为基于实际权限码的路由级检查 (user.manage/role.manage/organization.manage)
- 修复 deviceReadings API 路径: /patients/{id}/device-readings/daily 改为
  /vital-signs/daily?patient_id=, 消除 404

P1 重要修复:
- 补全事件注册表: 新增 auth(11) + config(8) + workflow(4) + plugin(2) = 25 条
- article_article_tag 联表新增 tenant_id + deleted_at + 审计列 (迁移 107)
- vital_signs_hourly 新增 deleted_at 支持软删除过滤 (迁移 108)
- 6 个页面添加权限守卫 (AlertDashboard/AlertRuleList/DeviceManage/
  AiAnalysisList/AiUsageDashboard)
- DialysisModule 声明 auth 依赖
2026-05-04 11:02:25 +08:00

224 lines
7.0 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { Button, Form, Input, InputNumber, message, Modal, Result, Select, Space, Switch, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
alertRuleApi,
type AlertRule,
type CreateAlertRuleReq,
type UpdateAlertRuleReq,
} from '../../api/health/alerts';
import { SEVERITY_COLOR, SEVERITY_OPTIONS, DEVICE_TYPE_OPTIONS, CONDITION_TYPE_OPTIONS } from '../../constants/health';
import { usePermission } from '../../hooks/usePermission';
export default function AlertRuleList() {
const { hasPermission } = usePermission('health.alerts.list');
if (!hasPermission) return <Result status="403" title="权限不足" subTitle="您没有查看告警规则的权限" />;
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 alertRuleApi.list({ 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 alertRuleApi.update(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 alertRuleApi.create(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 alertRuleApi.deactivate(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_TYPE_OPTIONS.find((d) => d.value === v)?.label || v,
},
{
title: '条件类型',
dataIndex: 'condition_type',
width: 120,
render: (v: string) => CONDITION_TYPE_OPTIONS.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_TYPE_OPTIONS} disabled={!!editingRule} />
</Form.Item>
<Form.Item name="condition_type" label="条件类型" rules={[{ required: true }]}>
<Select style={{ width: 160 }} options={CONDITION_TYPE_OPTIONS} 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>
);
}