diff --git a/apps/web/src/pages/health/DialysisManageList.tsx b/apps/web/src/pages/health/DialysisManageList.tsx new file mode 100644 index 0000000..589c5bd --- /dev/null +++ b/apps/web/src/pages/health/DialysisManageList.tsx @@ -0,0 +1,380 @@ +import { useState, useCallback, useMemo } from 'react'; +import { Table, Tag, Button, Modal, Form, InputNumber, DatePicker, Select, message, Popconfirm, Space, Input } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined, AuditOutlined } from '@ant-design/icons'; +import { dayjs } from '../../utils/dayjs'; +import type { Dayjs } from 'dayjs'; +import { dialysisApi } from '../../api/health/dialysis'; +import type { DialysisRecord } from '../../api/health/dialysis'; +import { patientApi } from '../../api/health/patients'; +import { usePaginatedData } from '../../hooks/usePaginatedData'; +import { AuthButton } from '../../components/AuthButton'; +import { handleApiError } from '../../api/client'; + +const DIALYSIS_TYPE_MAP: Record = { + HD: { color: 'blue', label: 'HD' }, + HDF: { color: 'green', label: 'HDF' }, + HF: { color: 'purple', label: 'HF' }, +}; + +const STATUS_MAP: Record = { + pending: { color: 'orange', label: '待审核' }, + reviewed: { color: 'green', label: '已审核' }, +}; + +interface PatientOption { + id: string; + name: string; +} + +export default function DialysisManageList() { + const [selectedPatientId, setSelectedPatientId] = useState(null); + const [patientOptions, setPatientOptions] = useState([]); + const [patientSearch, setPatientSearch] = useState(''); + const [modalOpen, setModalOpen] = useState(false); + const [editingRecord, setEditingRecord] = useState(null); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + const [reviewOpen, setReviewOpen] = useState(false); + const [reviewRecord, setReviewRecord] = useState(null); + const [reviewSubmitting, setReviewSubmitting] = useState(false); + + const searchPatients = async (keyword: string) => { + if (!keyword.trim()) { + setPatientOptions([]); + return; + } + try { + const res = await patientApi.list({ search: keyword, page: 1, page_size: 20 }); + setPatientOptions( + (res.data || []).map((p) => ({ id: p.id, name: p.name })), + ); + } catch { + // ignore + } + }; + + const fetcher = useCallback( + async (page: number, pageSize: number) => { + if (!selectedPatientId) return { data: [], total: 0, page: 1 }; + return dialysisApi.listRecords(selectedPatientId, { page, page_size: pageSize }); + }, + [selectedPatientId], + ); + + const { data, total, page, loading, refresh } = usePaginatedData(fetcher, 10); + + const openCreateModal = () => { + if (!selectedPatientId) { + message.warning('请先选择患者'); + return; + } + setEditingRecord(null); + form.resetFields(); + form.setFieldsValue({ dialysis_type: 'HD' }); + setModalOpen(true); + }; + + const openEditModal = (record: DialysisRecord) => { + setEditingRecord(record); + form.setFieldsValue({ + dialysis_date: dayjs(record.dialysis_date), + start_time: record.start_time, + end_time: record.end_time, + dialysis_type: record.dialysis_type, + pre_weight: record.pre_weight, + post_weight: record.post_weight, + dry_weight: record.dry_weight, + pre_bp_systolic: record.pre_bp_systolic, + pre_bp_diastolic: record.pre_bp_diastolic, + post_bp_systolic: record.post_bp_systolic, + post_bp_diastolic: record.post_bp_diastolic, + pre_heart_rate: record.pre_heart_rate, + post_heart_rate: record.post_heart_rate, + ultrafiltration_volume: record.ultrafiltration_volume, + dialysis_duration: record.dialysis_duration, + blood_flow_rate: record.blood_flow_rate, + complication_notes: record.complication_notes, + }); + setModalOpen(true); + }; + + const handleSubmit = async (values: Record) => { + setSubmitting(true); + try { + const payload = { + ...values, + patient_id: selectedPatientId!, + dialysis_date: (values.dialysis_date as Dayjs).format('YYYY-MM-DD'), + }; + if (editingRecord) { + await dialysisApi.updateRecord(editingRecord.id, { + ...payload, + version: editingRecord.version, + }); + message.success('透析记录更新成功'); + } else { + await dialysisApi.createRecord(payload as Parameters[0]); + message.success('透析记录添加成功'); + } + setModalOpen(false); + setEditingRecord(null); + form.resetFields(); + refresh(); + } catch (err) { + handleApiError(err, editingRecord ? '更新失败' : '添加失败'); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (record: DialysisRecord) => { + try { + await dialysisApi.deleteRecord(record.id, record.version); + message.success('删除成功'); + refresh(); + } catch (err) { + handleApiError(err, '删除失败'); + } + }; + + const handleReview = async () => { + if (!reviewRecord) return; + setReviewSubmitting(true); + try { + await dialysisApi.reviewRecord(reviewRecord.id, { version: reviewRecord.version }); + message.success('审核完成'); + setReviewOpen(false); + setReviewRecord(null); + refresh(); + } catch (err) { + handleApiError(err, '审核失败'); + } finally { + setReviewSubmitting(false); + } + }; + + const columns = useMemo( + () => [ + { title: '透析日期', dataIndex: 'dialysis_date', key: 'dialysis_date', width: 110 }, + { + title: '类型', + dataIndex: 'dialysis_type', + key: 'dialysis_type', + width: 70, + render: (v: string) => { + const m = DIALYSIS_TYPE_MAP[v]; + return m ? {m.label} : {v}; + }, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 80, + render: (v: string) => { + const m = STATUS_MAP[v]; + return m ? {m.label} : {v}; + }, + }, + { + title: '透前体重', + dataIndex: 'pre_weight', + key: 'pre_weight', + width: 90, + render: (v: number | null) => (v != null ? `${v} kg` : '-'), + }, + { + title: '透后体重', + dataIndex: 'post_weight', + key: 'post_weight', + width: 90, + render: (v: number | null) => (v != null ? `${v} kg` : '-'), + }, + { + title: '超滤量', + dataIndex: 'ultrafiltration_volume', + key: 'ultrafiltration_volume', + width: 80, + render: (v: number | null) => (v != null ? `${v} ml` : '-'), + }, + { + title: '时长(分)', + dataIndex: 'dialysis_duration', + key: 'dialysis_duration', + width: 80, + render: (v: number | null) => v ?? '-', + }, + { + title: '操作', + key: 'actions', + width: 200, + render: (_: unknown, record: DialysisRecord) => ( + + + {record.status === 'pending' && ( + + )} + + handleDelete(record)} okText="确认" cancelText="取消"> + + + + + ), + }, + ], + [openEditModal, handleDelete], + ); + + return ( +
+
+ + + + + +
+
+ + + + + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+ + + + + + + { + setReviewOpen(false); + setReviewRecord(null); + }} + onOk={handleReview} + confirmLoading={reviewSubmitting} + width={400} + > + {reviewRecord && ( +
+

透析日期:{reviewRecord.dialysis_date}

+

透析方式:{reviewRecord.dialysis_type}

+

时长:{reviewRecord.dialysis_duration ?? '-'} 分钟

+

确认审核通过该透析记录?

+
+ )} +
+
+ ); +} diff --git a/apps/web/src/pages/health/components/DailyMonitoringTab.tsx b/apps/web/src/pages/health/components/DailyMonitoringTab.tsx new file mode 100644 index 0000000..b71a0fd --- /dev/null +++ b/apps/web/src/pages/health/components/DailyMonitoringTab.tsx @@ -0,0 +1,248 @@ +import { useCallback, useState, useMemo } from 'react'; +import { Table, Button, Modal, Form, Input, InputNumber, DatePicker, message, Popconfirm, Space } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { dayjs } from '../../../utils/dayjs'; +import type { Dayjs } from 'dayjs'; +import { healthDataApi } from '../../../api/health/healthData'; +import type { DailyMonitoring } from '../../../api/health/healthData'; +import { usePaginatedData } from '../../../hooks/usePaginatedData'; +import { AuthButton } from '../../../components/AuthButton'; +import { handleApiError } from '../../../api/client'; + +interface Props { + patientId: string; +} + +export function DailyMonitoringTab({ patientId }: Props) { + const [modalOpen, setModalOpen] = useState(false); + const [editingRecord, setEditingRecord] = useState(null); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + + const fetcher = useCallback( + async (page: number, pageSize: number) => { + return healthDataApi.listDailyMonitoring(patientId, { page, page_size: pageSize }); + }, + [patientId], + ); + + const { data, total, page, loading, refresh } = usePaginatedData(fetcher, 10); + + const openCreateModal = () => { + setEditingRecord(null); + form.resetFields(); + setModalOpen(true); + }; + + const openEditModal = (record: DailyMonitoring) => { + setEditingRecord(record); + form.setFieldsValue({ + record_date: dayjs(record.record_date), + morning_bp_systolic: record.morning_bp_systolic, + morning_bp_diastolic: record.morning_bp_diastolic, + evening_bp_systolic: record.evening_bp_systolic, + evening_bp_diastolic: record.evening_bp_diastolic, + weight: record.weight, + blood_sugar: record.blood_sugar, + fluid_intake: record.fluid_intake, + urine_output: record.urine_output, + notes: record.notes, + }); + setModalOpen(true); + }; + + const handleSubmit = async (values: { + record_date: Dayjs; + morning_bp_systolic?: number; + morning_bp_diastolic?: number; + evening_bp_systolic?: number; + evening_bp_diastolic?: number; + weight?: number; + blood_sugar?: number; + fluid_intake?: number; + urine_output?: number; + notes?: string; + }) => { + setSubmitting(true); + try { + const payload = { + ...values, + record_date: values.record_date.format('YYYY-MM-DD'), + }; + if (editingRecord) { + await healthDataApi.updateDailyMonitoring(editingRecord.id, { + ...payload, + version: editingRecord.version, + }); + message.success('日常监测记录更新成功'); + } else { + await healthDataApi.createDailyMonitoring({ ...payload, patient_id: patientId }); + message.success('日常监测记录添加成功'); + } + setModalOpen(false); + setEditingRecord(null); + form.resetFields(); + refresh(); + } catch (err) { + handleApiError(err, editingRecord ? '更新失败' : '添加失败'); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (record: DailyMonitoring) => { + try { + await healthDataApi.deleteDailyMonitoring(record.id, record.version); + message.success('删除成功'); + refresh(); + } catch (err) { + handleApiError(err, '删除失败'); + } + }; + + const columns = useMemo( + () => [ + { title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 110 }, + { + title: '晨起血压', + key: 'morning_bp', + width: 120, + render: (_: unknown, r: DailyMonitoring) => + r.morning_bp_systolic != null ? `${r.morning_bp_systolic}/${r.morning_bp_diastolic}` : '-', + }, + { + title: '晚间血压', + key: 'evening_bp', + width: 120, + render: (_: unknown, r: DailyMonitoring) => + r.evening_bp_systolic != null ? `${r.evening_bp_systolic}/${r.evening_bp_diastolic}` : '-', + }, + { + title: '体重(kg)', + dataIndex: 'weight', + key: 'weight', + width: 90, + render: (v: number | null) => v ?? '-', + }, + { + title: '血糖(mmol/L)', + dataIndex: 'blood_sugar', + key: 'blood_sugar', + width: 110, + render: (v: number | null) => v ?? '-', + }, + { + title: '入量(ml)', + dataIndex: 'fluid_intake', + key: 'fluid_intake', + width: 90, + render: (v: number | null) => v ?? '-', + }, + { + title: '出量(ml)', + dataIndex: 'urine_output', + key: 'urine_output', + width: 90, + render: (v: number | null) => v ?? '-', + }, + { + title: '操作', + key: 'actions', + width: 120, + render: (_: unknown, record: DailyMonitoring) => ( + + + + handleDelete(record)} okText="确认" cancelText="取消"> + + + + + ), + }, + ], + [openEditModal, handleDelete], + ); + + return ( +
+
+ +
+ refresh(p), + showTotal: (t) => `共 ${t} 条`, + style: { margin: 0 }, + }} + /> + { + setModalOpen(false); + setEditingRecord(null); + }} + onOk={() => form.submit()} + confirmLoading={submitting} + destroyOnClose + width={560} + > +
+ + + +
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+ + + + +
+ + ); +} diff --git a/apps/web/src/pages/health/components/DeviceReadingsTab.tsx b/apps/web/src/pages/health/components/DeviceReadingsTab.tsx index 48192c5..a1c689b 100644 --- a/apps/web/src/pages/health/components/DeviceReadingsTab.tsx +++ b/apps/web/src/pages/health/components/DeviceReadingsTab.tsx @@ -45,11 +45,6 @@ interface Props { /* ---------- 原始数据 Tab ---------- */ -interface RawFilters { - deviceType: string | undefined; - hours: number; -} - function RawDataTab({ patientId }: Props) { const [deviceType, setDeviceType] = useState(undefined); const [hours, setHours] = useState(24); @@ -162,11 +157,6 @@ function RawDataTab({ patientId }: Props) { /* ---------- 小时聚合 Tab ---------- */ -interface HourlyFilters { - deviceType: string | undefined; - days: number; -} - function HourlyAggTab({ patientId }: Props) { const [deviceType, setDeviceType] = useState(undefined); const [days, setDays] = useState(7); diff --git a/crates/erp-core/src/test_helpers.rs b/crates/erp-core/src/test_helpers.rs index 69c6d82..914e262 100644 --- a/crates/erp-core/src/test_helpers.rs +++ b/crates/erp-core/src/test_helpers.rs @@ -3,7 +3,7 @@ //! 每个测试在独立事务中执行,测试结束自动回滚,无数据残留。 //! 多个测试共享同一个数据库连接池,无连接竞争。 -use sea_orm::{ConnectOptions, DatabaseConnection, DatabaseTransaction}; +use sea_orm::{ConnectOptions, Database, DatabaseConnection, DatabaseTransaction, TransactionTrait}; use std::sync::OnceLock; use tokio::sync::OnceCell; @@ -25,7 +25,7 @@ async fn db_pool() -> &'static DatabaseConnection { let opt = ConnectOptions::new(db_url()) .max_connections(5) .to_owned(); - DatabaseConnection::connect(opt) + Database::connect(opt) .await .expect("测试数据库连接失败") }) diff --git a/crates/erp-server/tests/integration/health_alert_tests.rs b/crates/erp-server/tests/integration/health_alert_tests.rs index 852d40c..aefcc56 100644 --- a/crates/erp-server/tests/integration/health_alert_tests.rs +++ b/crates/erp-server/tests/integration/health_alert_tests.rs @@ -249,7 +249,7 @@ async fn test_alert_list_filter_and_tenant_isolation() { // 按患者 A 过滤 let (alerts_a, total_a) = alert_service::list_alerts( - app.health_state(), app.tenant_id(), Some(patient_a), None, 1, 20, + app.health_state(), app.tenant_id(), Some(patient_a), None, None, 1, 20, ) .await .unwrap(); @@ -259,7 +259,7 @@ async fn test_alert_list_filter_and_tenant_isolation() { // 租户隔离 let other_tenant = uuid::Uuid::new_v4(); let (_alerts_other, total_other) = alert_service::list_alerts( - app.health_state(), other_tenant, Some(patient_a), None, 1, 20, + app.health_state(), other_tenant, Some(patient_a), None, None, 1, 20, ) .await .unwrap(); diff --git a/crates/erp-server/tests/integration/health_article_tests.rs b/crates/erp-server/tests/integration/health_article_tests.rs index f372bd3..f3ffbbe 100644 --- a/crates/erp-server/tests/integration/health_article_tests.rs +++ b/crates/erp-server/tests/integration/health_article_tests.rs @@ -236,7 +236,7 @@ async fn test_article_soft_delete() { .expect("删除应成功"); let result = article_service::get_article( - app.health_state(), app.tenant_id(), article.id, + app.health_state(), app.tenant_id(), article.id, true, ) .await; assert!(result.is_err(), "软删除后查询应失败"); @@ -252,7 +252,7 @@ async fn test_article_tenant_isolation() { let other_tenant = uuid::Uuid::new_v4(); let result = article_service::get_article( - app.health_state(), other_tenant, article.id, + app.health_state(), other_tenant, article.id, true, ) .await; assert!(result.is_err(), "不同租户不应看到此文章"); diff --git a/crates/erp-server/tests/integration/health_data_tests.rs b/crates/erp-server/tests/integration/health_data_tests.rs index 8c8e26c..3fc6743 100644 --- a/crates/erp-server/tests/integration/health_data_tests.rs +++ b/crates/erp-server/tests/integration/health_data_tests.rs @@ -3,7 +3,6 @@ //! 验证体征 CRUD、化验报告 CRUD + 审阅、租户隔离、乐观锁。 use erp_health::dto::health_data_dto::*; -use erp_dialysis::dto::dialysis_dto::ReviewLabReportReq; use erp_health::service::health_data_service; use super::test_fixture::TestApp; diff --git a/crates/erp-server/tests/integration/test_fixture.rs b/crates/erp-server/tests/integration/test_fixture.rs index 545bbe6..b61f867 100644 --- a/crates/erp-server/tests/integration/test_fixture.rs +++ b/crates/erp-server/tests/integration/test_fixture.rs @@ -14,6 +14,7 @@ use super::test_db::TestDb; pub struct TestApp { test_db: TestDb, health_state: HealthState, + dialysis_state: DialysisState, tenant_id: uuid::Uuid, operator_id: uuid::Uuid, } @@ -26,9 +27,15 @@ impl TestApp { event_bus: EventBus::new(100), crypto: PiiCrypto::dev_default(), }; + let dialysis_state = DialysisState { + db: test_db.db().clone(), + event_bus: health_state.event_bus.clone(), + crypto: health_state.crypto.clone(), + }; Self { test_db, health_state, + dialysis_state, tenant_id: uuid::Uuid::new_v4(), operator_id: uuid::Uuid::new_v4(), } @@ -42,12 +49,8 @@ impl TestApp { &self.health_state } - pub fn dialysis_state(&self) -> DialysisState { - DialysisState { - db: self.test_db.db().clone(), - event_bus: self.health_state.event_bus.clone(), - crypto: self.health_state.crypto.clone(), - } + pub fn dialysis_state(&self) -> &DialysisState { + &self.dialysis_state } pub fn tenant_id(&self) -> uuid::Uuid {