feat(web): 危急值阈值 + 诊断记录 Web UI — Phase 2b-2/2b-3
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

危急值阈值:CRUD 列表页(指标/方向/阈值/级别/科室/年龄范围)
诊断记录:患者范围 CRUD 列表页(ICD编码/类型/状态/确诊日期)
This commit is contained in:
iven
2026-05-04 23:59:22 +08:00
parent b6838c1bc1
commit 0774dd75ad
6 changed files with 731 additions and 0 deletions

View File

@@ -60,6 +60,8 @@ const ShiftDetail = lazy(() => import('./pages/health/ShiftDetail'));
const MedicationRecordList = lazy(() => import('./pages/health/MedicationRecordList'));
const BleGatewayList = lazy(() => import('./pages/health/BleGatewayList'));
const BleGatewayDetail = lazy(() => import('./pages/health/BleGatewayDetail'));
const CriticalValueThresholdList = lazy(() => import('./pages/health/CriticalValueThresholdList'));
const DiagnosisList = lazy(() => import('./pages/health/DiagnosisList'));
// 内容管理
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
@@ -285,6 +287,8 @@ export default function App() {
<Route path="/health/medications" element={<MedicationRecordList />} />
<Route path="/health/ble-gateways" element={<BleGatewayList />} />
<Route path="/health/ble-gateways/:id" element={<BleGatewayDetail />} />
<Route path="/health/critical-value-thresholds" element={<CriticalValueThresholdList />} />
<Route path="/health/diagnoses" element={<DiagnosisList />} />
{/* 内容管理 */}
<Route path="/health/articles" element={<ArticleManageList />} />
<Route path="/health/articles/new" element={<ArticleEditor />} />

View File

@@ -0,0 +1,110 @@
import client from '../client';
// --- Types ---
export interface CriticalValueThreshold {
id: string;
tenant_id: string;
indicator: string;
direction: string;
threshold_value: number;
level: string;
department?: string;
age_min?: number;
age_max?: number;
is_active: boolean;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateThresholdReq {
indicator: string;
direction: string;
threshold_value: number;
level?: string;
department?: string;
age_min?: number;
age_max?: number;
}
export interface UpdateThresholdReq {
threshold_value: number;
level?: string;
department?: string;
age_min?: number;
age_max?: number;
version: number;
}
// --- Constants ---
export const INDICATOR_OPTIONS = [
{ label: '收缩压', value: 'systolic_bp' },
{ label: '舒张压', value: 'diastolic_bp' },
{ label: '心率', value: 'heart_rate' },
{ label: '血糖', value: 'blood_sugar' },
{ label: '空腹血糖', value: 'blood_sugar_fasting' },
{ label: '餐后血糖', value: 'blood_sugar_postprandial' },
{ label: '血氧', value: 'blood_oxygen' },
{ label: '体温', value: 'temperature' },
];
export const DIRECTION_OPTIONS = [
{ label: '偏高', value: 'high' },
{ label: '偏低', value: 'low' },
];
export const LEVEL_OPTIONS = [
{ label: '危急', value: 'critical' },
{ label: '警告', value: 'warning' },
];
export const LEVEL_COLOR: Record<string, string> = {
critical: 'red',
warning: 'orange',
};
export const INDICATOR_LABEL: Record<string, string> = Object.fromEntries(
INDICATOR_OPTIONS.map((o) => [o.value, o.label]),
);
export const DIRECTION_LABEL: Record<string, string> = Object.fromEntries(
DIRECTION_OPTIONS.map((o) => [o.value, o.label]),
);
export const LEVEL_LABEL: Record<string, string> = Object.fromEntries(
LEVEL_OPTIONS.map((o) => [o.value, o.label]),
);
// --- API ---
export const criticalValueThresholdApi = {
list: async () => {
const { data } = await client.get<{
success: boolean;
data: CriticalValueThreshold[];
}>('/health/critical-value-thresholds');
return data.data;
},
create: async (req: CreateThresholdReq) => {
const { data } = await client.post<{
success: boolean;
data: CriticalValueThreshold;
}>('/health/critical-value-thresholds', req);
return data.data;
},
update: async (id: string, req: UpdateThresholdReq) => {
const { data } = await client.put<{
success: boolean;
data: CriticalValueThreshold;
}>(`/health/critical-value-thresholds/${id}`, req);
return data.data;
},
delete: async (id: string) => {
await client.delete(`/health/critical-value-thresholds/${id}`);
},
};

View File

@@ -0,0 +1,108 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Diagnosis {
id: string;
patient_id: string;
health_record_id?: string;
icd_code: string;
diagnosis_name: string;
diagnosis_type: string;
diagnosed_date: string;
status: string;
diagnosed_by?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateDiagnosisReq {
icd_code: string;
diagnosis_name: string;
diagnosis_type?: string;
diagnosed_date: string;
status?: string;
health_record_id?: string;
diagnosed_by?: string;
notes?: string;
}
export interface UpdateDiagnosisReq {
icd_code?: string;
diagnosis_name?: string;
diagnosis_type?: string;
diagnosed_date?: string;
status?: string;
health_record_id?: string;
diagnosed_by?: string;
notes?: string;
}
// --- Constants ---
export const DIAGNOSIS_TYPE_OPTIONS = [
{ label: '主要诊断', value: 'primary' },
{ label: '次要诊断', value: 'secondary' },
{ label: '合并症', value: 'comorbid' },
];
export const DIAGNOSIS_STATUS_OPTIONS = [
{ label: '活跃', value: 'active' },
{ label: '已缓解', value: 'resolved' },
{ label: '慢性', value: 'chronic' },
];
export const DIAGNOSIS_TYPE_COLOR: Record<string, string> = {
primary: 'red',
secondary: 'blue',
comorbid: 'orange',
};
export const DIAGNOSIS_STATUS_COLOR: Record<string, string> = {
active: 'green',
resolved: 'default',
chronic: 'orange',
};
export const DIAGNOSIS_TYPE_LABEL: Record<string, string> = Object.fromEntries(
DIAGNOSIS_TYPE_OPTIONS.map((o) => [o.value, o.label]),
);
export const DIAGNOSIS_STATUS_LABEL: Record<string, string> = Object.fromEntries(
DIAGNOSIS_STATUS_OPTIONS.map((o) => [o.value, o.label]),
);
// --- API ---
export const diagnosisApi = {
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Diagnosis>;
}>(`/health/patients/${patientId}/diagnoses`, { params });
return data.data;
},
create: async (patientId: string, req: CreateDiagnosisReq) => {
const { data } = await client.post<{
success: boolean;
data: Diagnosis;
}>(`/health/patients/${patientId}/diagnoses`, req);
return data.data;
},
update: async (diagnosisId: string, req: UpdateDiagnosisReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: Diagnosis;
}>(`/health/diagnoses/${diagnosisId}`, req);
return data.data;
},
delete: async (diagnosisId: string, version: number) => {
await client.delete(`/health/diagnoses/${diagnosisId}`, { data: { version } });
},
};

View File

@@ -118,6 +118,8 @@ const routeTitleFallback: Record<string, string> = {
'/health/medications': '药物记录',
'/health/ble-gateways': 'BLE 网关管理',
'/health/ble-gateways/:id': '网关详情',
'/health/critical-value-thresholds': '危急值阈值',
'/health/diagnoses': '诊断记录',
};
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {

View File

@@ -0,0 +1,236 @@
import { useState, useCallback } from 'react';
import {
Button, Form, Input, InputNumber, message, Modal, Popconfirm, Result, Select, Space, Switch, Table, Tag,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
criticalValueThresholdApi,
type CriticalValueThreshold,
type CreateThresholdReq,
type UpdateThresholdReq,
INDICATOR_OPTIONS,
DIRECTION_OPTIONS,
LEVEL_OPTIONS,
LEVEL_COLOR,
INDICATOR_LABEL,
DIRECTION_LABEL,
LEVEL_LABEL,
} from '../../api/health/criticalValueThresholds';
import { PageContainer } from '../../components/PageContainer';
import { usePermission } from '../../hooks/usePermission';
export default function CriticalValueThresholdList() {
const { hasPermission } = usePermission('health.critical-value-thresholds.list');
const [data, setData] = useState<CriticalValueThreshold[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editRecord, setEditRecord] = useState<CriticalValueThreshold | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
const fetchData = useCallback(async () => {
setLoading(true);
try {
const list = await criticalValueThresholdApi.list();
setData(list);
setLoaded(true);
} catch {
message.error('加载危急值阈值失败');
} finally {
setLoading(false);
}
}, []);
const handleCreate = () => {
setEditRecord(null);
form.resetFields();
form.setFieldsValue({ level: 'critical' });
setModalOpen(true);
};
const handleEdit = (record: CriticalValueThreshold) => {
setEditRecord(record);
form.setFieldsValue({
indicator: record.indicator,
direction: record.direction,
threshold_value: record.threshold_value,
level: record.level,
department: record.department,
age_min: record.age_min,
age_max: record.age_max,
});
setModalOpen(true);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
if (editRecord) {
const req: UpdateThresholdReq = {
threshold_value: values.threshold_value,
level: values.level,
department: values.department,
age_min: values.age_min,
age_max: values.age_max,
version: editRecord.version,
};
await criticalValueThresholdApi.update(editRecord.id, req);
message.success('阈值已更新');
} else {
const req: CreateThresholdReq = values;
await criticalValueThresholdApi.create(req);
message.success('阈值已创建');
}
setModalOpen(false);
fetchData();
} catch {
// validation
} finally {
setSubmitting(false);
}
};
const handleDelete = async (record: CriticalValueThreshold) => {
try {
await criticalValueThresholdApi.delete(record.id);
message.success('阈值已删除');
fetchData();
} catch {
message.error('删除失败');
}
};
const columns: ColumnsType<CriticalValueThreshold> = [
{
title: '指标',
dataIndex: 'indicator',
width: 120,
render: (v: string) => INDICATOR_LABEL[v] ?? v,
},
{
title: '方向',
dataIndex: 'direction',
width: 80,
render: (v: string) => DIRECTION_LABEL[v] ?? v,
},
{
title: '阈值',
dataIndex: 'threshold_value',
width: 100,
render: (v: number) => v,
},
{
title: '级别',
dataIndex: 'level',
width: 80,
render: (v: string) => (
<Tag color={LEVEL_COLOR[v] ?? 'default'}>{LEVEL_LABEL[v] ?? v}</Tag>
),
},
{
title: '科室',
dataIndex: 'department',
width: 100,
render: (v: string) => v ?? '通用',
},
{
title: '年龄范围',
width: 120,
render: (_, record) => {
if (record.age_min == null && record.age_max == null) return '不限';
return `${record.age_min ?? 0} - ${record.age_max ?? '∞'}`;
},
},
{
title: '状态',
dataIndex: 'is_active',
width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作',
width: 140,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => handleEdit(record)}></Button>
<Popconfirm title="确定删除此阈值规则?" onConfirm={() => handleDelete(record)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
];
if (!hasPermission) {
return <Result status="403" title="权限不足" subTitle="您没有查看危急值阈值的权限" />;
}
return (
<PageContainer
title="危急值阈值"
actions={<Button type="primary" onClick={handleCreate}></Button>}
>
<Space style={{ marginBottom: 16 }}>
<Button type="primary" onClick={fetchData} loading={loading}>
{loaded ? '刷新' : '加载阈值'}
</Button>
</Space>
{loaded && (
<Table<CriticalValueThreshold>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={false}
/>
)}
<Modal
title={editRecord ? '编辑阈值' : '添加阈值'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
confirmLoading={submitting}
width={520}
>
<Form form={form} layout="vertical">
{!editRecord && (
<>
<Form.Item name="indicator" label="指标" rules={[{ required: true, message: '请选择指标' }]}>
<Select options={INDICATOR_OPTIONS} placeholder="选择指标" />
</Form.Item>
<Form.Item name="direction" label="方向" rules={[{ required: true, message: '请选择方向' }]}>
<Select options={DIRECTION_OPTIONS} placeholder="偏高/偏低" />
</Form.Item>
</>
)}
<Form.Item name="threshold_value" label="阈值" rules={[{ required: true, message: '请输入阈值' }]}>
<InputNumber style={{ width: '100%' }} step={0.1} />
</Form.Item>
<Form.Item name="level" label="级别">
<Select options={LEVEL_OPTIONS} />
</Form.Item>
<Form.Item name="department" label="限定科室">
<Input placeholder="留空表示通用规则" />
</Form.Item>
<Space style={{ width: '100%' }} size="middle">
<Form.Item name="age_min" label="最小年龄">
<InputNumber min={0} max={150} placeholder="不限" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="age_max" label="最大年龄">
<InputNumber min={0} max={150} placeholder="不限" style={{ width: 200 }} />
</Form.Item>
</Space>
</Form>
</Modal>
</PageContainer>
);
}

View File

@@ -0,0 +1,271 @@
import { useState, useCallback, useMemo } from 'react';
import {
Button, DatePicker, Form, Input, message, Modal, Popconfirm,
Result, Select, Space, Table, Tag,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import {
diagnosisApi,
type Diagnosis,
type CreateDiagnosisReq,
type UpdateDiagnosisReq,
DIAGNOSIS_TYPE_OPTIONS,
DIAGNOSIS_STATUS_OPTIONS,
DIAGNOSIS_TYPE_COLOR,
DIAGNOSIS_STATUS_COLOR,
DIAGNOSIS_TYPE_LABEL,
DIAGNOSIS_STATUS_LABEL,
} from '../../api/health/diagnoses';
import { PageContainer } from '../../components/PageContainer';
import { usePermission } from '../../hooks/usePermission';
export default function DiagnosisList() {
const { hasPermission } = usePermission('health.health-data.list');
const [data, setData] = useState<Diagnosis[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [patientId, setPatientId] = useState('');
const [searchInput, setSearchInput] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editRecord, setEditRecord] = useState<Diagnosis | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
const pageSize = 20;
const fetchData = useCallback(async (pid: string, p: number) => {
if (!pid) return;
setLoading(true);
try {
const resp = await diagnosisApi.list(pid, { page: p, page_size: pageSize });
setData(resp.data);
setTotal(resp.total);
setPage(p);
} catch {
message.error('加载诊断记录失败');
} finally {
setLoading(false);
}
}, []);
const handleSearch = () => {
const pid = searchInput.trim();
if (!pid) {
message.warning('请输入患者 ID');
return;
}
setPatientId(pid);
fetchData(pid, 1);
};
const handleCreate = () => {
setEditRecord(null);
form.resetFields();
form.setFieldsValue({ patient_id: patientId, diagnosis_type: 'primary', status: 'active' });
setModalOpen(true);
};
const handleEdit = (record: Diagnosis) => {
setEditRecord(record);
form.setFieldsValue({
icd_code: record.icd_code,
diagnosis_name: record.diagnosis_name,
diagnosis_type: record.diagnosis_type,
diagnosed_date: record.diagnosed_date ? dayjs(record.diagnosed_date) : undefined,
status: record.status,
diagnosed_by: record.diagnosed_by,
notes: record.notes,
});
setModalOpen(true);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const req = {
...values,
diagnosed_date: values.diagnosed_date?.format('YYYY-MM-DD'),
};
setSubmitting(true);
if (editRecord) {
await diagnosisApi.update(editRecord.id, {
...req,
version: editRecord.version,
} as UpdateDiagnosisReq & { version: number });
message.success('诊断记录已更新');
} else {
await diagnosisApi.create(req.patient_id, req as CreateDiagnosisReq);
message.success('诊断记录已创建');
}
setModalOpen(false);
fetchData(patientId, page);
} catch {
// validation
} finally {
setSubmitting(false);
}
};
const handleDelete = async (record: Diagnosis) => {
try {
await diagnosisApi.delete(record.id, record.version);
message.success('诊断记录已删除');
fetchData(patientId, page);
} catch {
message.error('删除失败');
}
};
const columns: ColumnsType<Diagnosis> = useMemo(() => [
{
title: 'ICD 编码',
dataIndex: 'icd_code',
width: 110,
},
{
title: '诊断名称',
dataIndex: 'diagnosis_name',
width: 200,
ellipsis: true,
},
{
title: '类型',
dataIndex: 'diagnosis_type',
width: 100,
render: (v: string) => (
<Tag color={DIAGNOSIS_TYPE_COLOR[v] ?? 'default'}>
{DIAGNOSIS_TYPE_LABEL[v] ?? v}
</Tag>
),
},
{
title: '确诊日期',
dataIndex: 'diagnosed_date',
width: 110,
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-',
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (v: string) => (
<Tag color={DIAGNOSIS_STATUS_COLOR[v] ?? 'default'}>
{DIAGNOSIS_STATUS_LABEL[v] ?? v}
</Tag>
),
},
{
title: '备注',
dataIndex: 'notes',
width: 160,
ellipsis: true,
render: (v: string) => v ?? '-',
},
{
title: '操作',
width: 140,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => handleEdit(record)}></Button>
<Popconfirm title="确定删除此诊断记录?" onConfirm={() => handleDelete(record)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
], [patientId, page]);
if (!hasPermission) {
return <Result status="403" title="权限不足" subTitle="您没有查看诊断记录的权限" />;
}
return (
<PageContainer
title="诊断记录"
actions={patientId ? <Button type="primary" onClick={handleCreate}></Button> : undefined}
>
<Space style={{ marginBottom: 16 }} wrap>
<Input.Search
placeholder="输入患者 ID 搜索"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onSearch={handleSearch}
style={{ width: 360 }}
enterButton="查询"
/>
{patientId && (
<Tag color="blue">: {patientId}</Tag>
)}
</Space>
{!patientId ? (
<Result title="请输入患者 ID 查询诊断记录" />
) : (
<Table<Diagnosis>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showTotal: (t) => `${t}`,
onChange: (p) => fetchData(patientId, p),
}}
/>
)}
<Modal
title={editRecord ? '编辑诊断' : '添加诊断'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
confirmLoading={submitting}
width={600}
>
<Form form={form} layout="vertical">
{!editRecord && (
<Form.Item name="patient_id" label="患者 ID" rules={[{ required: true }]}>
<Input disabled />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="middle">
<Form.Item name="icd_code" label="ICD 编码" rules={[{ required: true, message: '请输入 ICD 编码' }]} style={{ width: 200 }}>
<Input placeholder="如N18.9" />
</Form.Item>
<Form.Item name="diagnosis_name" label="诊断名称" rules={[{ required: true, message: '请输入诊断名称' }]} style={{ width: 320 }}>
<Input placeholder="如:慢性肾脏病" />
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size="middle">
<Form.Item name="diagnosis_type" label="类型" style={{ width: 200 }}>
<Select options={DIAGNOSIS_TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="diagnosed_date" label="确诊日期" rules={[{ required: true, message: '请选择确诊日期' }]} style={{ width: 260 }}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size="middle">
<Form.Item name="status" label="状态" style={{ width: 200 }}>
<Select options={DIAGNOSIS_STATUS_OPTIONS} />
</Form.Item>
<Form.Item name="diagnosed_by" label="确诊医生 ID" style={{ width: 320 }}>
<Input placeholder="医生 UUID可选" />
</Form.Item>
</Space>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}