diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a3579fb..b52547e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> {/* 内容管理 */} } /> } /> diff --git a/apps/web/src/api/health/criticalValueThresholds.ts b/apps/web/src/api/health/criticalValueThresholds.ts new file mode 100644 index 0000000..55f75a4 --- /dev/null +++ b/apps/web/src/api/health/criticalValueThresholds.ts @@ -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 = { + critical: 'red', + warning: 'orange', +}; + +export const INDICATOR_LABEL: Record = Object.fromEntries( + INDICATOR_OPTIONS.map((o) => [o.value, o.label]), +); + +export const DIRECTION_LABEL: Record = Object.fromEntries( + DIRECTION_OPTIONS.map((o) => [o.value, o.label]), +); + +export const LEVEL_LABEL: Record = 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}`); + }, +}; diff --git a/apps/web/src/api/health/diagnoses.ts b/apps/web/src/api/health/diagnoses.ts new file mode 100644 index 0000000..3fe68e7 --- /dev/null +++ b/apps/web/src/api/health/diagnoses.ts @@ -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 = { + primary: 'red', + secondary: 'blue', + comorbid: 'orange', +}; + +export const DIAGNOSIS_STATUS_COLOR: Record = { + active: 'green', + resolved: 'default', + chronic: 'orange', +}; + +export const DIAGNOSIS_TYPE_LABEL: Record = Object.fromEntries( + DIAGNOSIS_TYPE_OPTIONS.map((o) => [o.value, o.label]), +); + +export const DIAGNOSIS_STATUS_LABEL: Record = 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; + }>(`/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 } }); + }, +}; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index e2fb194..ab5b7f2 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -118,6 +118,8 @@ const routeTitleFallback: Record = { '/health/medications': '药物记录', '/health/ble-gateways': 'BLE 网关管理', '/health/ble-gateways/:id': '网关详情', + '/health/critical-value-thresholds': '危急值阈值', + '/health/diagnoses': '诊断记录', }; function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined { diff --git a/apps/web/src/pages/health/CriticalValueThresholdList.tsx b/apps/web/src/pages/health/CriticalValueThresholdList.tsx new file mode 100644 index 0000000..0c07add --- /dev/null +++ b/apps/web/src/pages/health/CriticalValueThresholdList.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + + const [modalOpen, setModalOpen] = useState(false); + const [editRecord, setEditRecord] = useState(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 = [ + { + 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) => ( + {LEVEL_LABEL[v] ?? v} + ), + }, + { + 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) => {v ? '启用' : '停用'}, + }, + { + title: '操作', + width: 140, + render: (_, record) => ( + + + handleDelete(record)}> + + + + ), + }, + ]; + + if (!hasPermission) { + return ; + } + + return ( + 添加阈值} + > + + + + + {loaded && ( + + rowKey="id" + columns={columns} + dataSource={data} + loading={loading} + pagination={false} + /> + )} + + setModalOpen(false)} + confirmLoading={submitting} + width={520} + > +
+ {!editRecord && ( + <> + + + + + )} + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/apps/web/src/pages/health/DiagnosisList.tsx b/apps/web/src/pages/health/DiagnosisList.tsx new file mode 100644 index 0000000..0d56f74 --- /dev/null +++ b/apps/web/src/pages/health/DiagnosisList.tsx @@ -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([]); + 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(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 = 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) => ( + + {DIAGNOSIS_TYPE_LABEL[v] ?? v} + + ), + }, + { + title: '确诊日期', + dataIndex: 'diagnosed_date', + width: 110, + render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-', + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (v: string) => ( + + {DIAGNOSIS_STATUS_LABEL[v] ?? v} + + ), + }, + { + title: '备注', + dataIndex: 'notes', + width: 160, + ellipsis: true, + render: (v: string) => v ?? '-', + }, + { + title: '操作', + width: 140, + render: (_, record) => ( + + + handleDelete(record)}> + + + + ), + }, + ], [patientId, page]); + + if (!hasPermission) { + return ; + } + + return ( + 添加诊断 : undefined} + > + + setSearchInput(e.target.value)} + onSearch={handleSearch} + style={{ width: 360 }} + enterButton="查询" + /> + {patientId && ( + 当前患者: {patientId} + )} + + + {!patientId ? ( + + ) : ( + + rowKey="id" + columns={columns} + dataSource={data} + loading={loading} + pagination={{ + current: page, + pageSize, + total, + showTotal: (t) => `共 ${t} 条`, + onChange: (p) => fetchData(patientId, p), + }} + /> + )} + + setModalOpen(false)} + confirmLoading={submitting} + width={600} + > +
+ {!editRecord && ( + + + + )} + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +}