Files
hms/apps/web/src/pages/health/PatientDetail.tsx
iven 355e8da272
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): 全链路流通性验证修复
- 创建 stub migration 解决缺失文件报错
- PatientList/PatientDetail: DatePicker dayjs 对象序列化为 YYYY-MM-DD
- AppointmentList: 预约类型与后端验证对齐(outpatient/recheck/health_checkup/consultation/dialysis)
- AppointmentList: 医生字段改为必填(后端 CAS 排班要求), destroyOnClose→destroyOnHidden
- Home.tsx: 补充审计日志 action 翻译(created/login_failed 等)

全链路验证通过: 医生CRUD→排班→预约创建+状态流转→随访生命周期→咨询会话+消息→患者详情+健康数据
2026-04-25 11:31:54 +08:00

341 lines
12 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card,
Descriptions,
Tabs,
Button,
Space,
Modal,
Form,
Input,
Select,
DatePicker,
message,
Spin,
} from 'antd';
import { ArrowLeftOutlined, EditOutlined } from '@ant-design/icons';
import { patientApi } from '../../api/health/patients';
import type {
PatientDetail as PatientDetailType,
UpdatePatientReq,
} from '../../api/health/patients';
import { StatusTag } from './components/StatusTag';
import { VitalSignsTab } from './components/VitalSignsTab';
import { LabReportsTab } from './components/LabReportsTab';
import { HealthRecordsTab } from './components/HealthRecordsTab';
import { FollowUpTab } from './components/FollowUpTab';
import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS } from '../../constants/health';
import { useThemeMode } from '../../hooks/useThemeMode';
const GENDER_LABEL: Record<string, string> = {
male: '男',
female: '女',
other: '其他',
};
export default function PatientDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [patient, setPatient] = useState<PatientDetailType | null>(null);
const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [form] = Form.useForm();
const isDark = useThemeMode();
// --- 加载患者基本信息 ---
const fetchPatient = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const data = await patientApi.get(id);
setPatient(data);
} catch {
message.error('加载患者信息失败');
}
setLoading(false);
}, [id]);
useEffect(() => {
fetchPatient();
}, [fetchPatient]);
// --- 编辑患者 ---
const handleEdit = async (values: {
name?: string;
gender?: string;
birth_date?: unknown;
blood_type?: string;
id_number?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
notes?: string;
}) => {
if (!patient) return;
const formatted = {
...values,
birth_date: values.birth_date && typeof values.birth_date === 'object' && 'format' in (values.birth_date as object)
? (values.birth_date as { format: (f: string) => string }).format('YYYY-MM-DD')
: (values.birth_date as string | undefined),
};
try {
const req: UpdatePatientReq & { version: number } = {
...formatted,
version: patient.version,
};
await patientApi.update(patient.id, req);
message.success('患者信息更新成功');
setEditModalOpen(false);
fetchPatient();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '更新失败';
message.error(errorMsg);
}
};
const openEditModal = () => {
if (!patient) return;
form.setFieldsValue({
name: patient.name,
gender: patient.gender,
birth_date: patient.birth_date,
blood_type: patient.blood_type,
id_number: patient.id_number,
allergy_history: patient.allergy_history,
medical_history_summary: patient.medical_history_summary,
emergency_contact_name: patient.emergency_contact_name,
emergency_contact_phone: patient.emergency_contact_phone,
notes: patient.notes,
});
setEditModalOpen(true);
};
// --- 加载状态 ---
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (!patient) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<p></p>
<Button onClick={() => navigate('/health/patients')}></Button>
</div>
);
}
// --- 主题卡片样式 ---
const cardStyle = {
borderRadius: 12,
background: isDark ? '#111827' : '#FFFFFF',
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
};
return (
<div>
{/* 顶部导航 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/health/patients')}
type="text"
>
</Button>
</div>
{/* 患者基本信息卡片 */}
<Card style={{ ...cardStyle, marginBottom: 16 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 16,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div
style={{
width: 48,
height: 48,
borderRadius: 12,
background: 'linear-gradient(135deg, #0ea5e9, #38bdf8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 20,
fontWeight: 600,
}}
>
{(patient.name?.[0] || 'P').toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 18 }}>{patient.name}</div>
<Space size={8} style={{ marginTop: 4 }}>
<StatusTag status={patient.status} />
<StatusTag status={patient.verification_status} />
</Space>
</div>
</div>
<Button icon={<EditOutlined />} onClick={openEditModal}>
</Button>
</div>
<Descriptions column={3} size="small">
<Descriptions.Item label="性别">
{GENDER_LABEL[patient.gender || ''] || patient.gender || '-'}
</Descriptions.Item>
<Descriptions.Item label="出生日期">
{patient.birth_date || '-'}
</Descriptions.Item>
<Descriptions.Item label="血型">
{patient.blood_type || '-'}
</Descriptions.Item>
<Descriptions.Item label="身份证号">
{patient.id_number || '-'}
</Descriptions.Item>
<Descriptions.Item label="来源">
{patient.source || '-'}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{patient.created_at ? new Date(patient.created_at).toLocaleString('zh-CN') : '-'}
</Descriptions.Item>
</Descriptions>
</Card>
{/* 标签页 */}
<Card style={cardStyle}>
<Tabs
defaultActiveKey="info"
items={[
{
key: 'info',
label: '基本信息',
children: (
<Descriptions column={2} bordered size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="姓名">{patient.name}</Descriptions.Item>
<Descriptions.Item label="性别">
{GENDER_LABEL[patient.gender || ''] || patient.gender || '-'}
</Descriptions.Item>
<Descriptions.Item label="出生日期">
{patient.birth_date || '-'}
</Descriptions.Item>
<Descriptions.Item label="血型">
{patient.blood_type || '-'}
</Descriptions.Item>
<Descriptions.Item label="身份证号">
{patient.id_number || '-'}
</Descriptions.Item>
<Descriptions.Item label="状态">
<StatusTag status={patient.status} />
</Descriptions.Item>
<Descriptions.Item label="认证状态">
<StatusTag status={patient.verification_status} />
</Descriptions.Item>
<Descriptions.Item label="来源">
{patient.source || '-'}
</Descriptions.Item>
<Descriptions.Item label="过敏史" span={2}>
{patient.allergy_history || '-'}
</Descriptions.Item>
<Descriptions.Item label="病史摘要" span={2}>
{patient.medical_history_summary || '-'}
</Descriptions.Item>
<Descriptions.Item label="紧急联系人">
{patient.emergency_contact_name || '-'}
</Descriptions.Item>
<Descriptions.Item label="紧急联系电话">
{patient.emergency_contact_phone || '-'}
</Descriptions.Item>
<Descriptions.Item label="备注" span={2}>
{patient.notes || '-'}
</Descriptions.Item>
</Descriptions>
),
},
{
key: 'health',
label: '健康数据',
children: id ? (
<Tabs
defaultActiveKey="vital"
items={[
{ key: 'vital', label: '体征数据', children: <VitalSignsTab patientId={id} /> },
{ key: 'lab', label: '化验报告', children: <LabReportsTab patientId={id} /> },
{ key: 'records', label: '健康档案', children: <HealthRecordsTab patientId={id} /> },
]}
/>
) : null,
},
{
key: 'followup',
label: '随访记录',
children: id ? <FollowUpTab patientId={id} /> : null,
},
]}
/>
</Card>
{/* 编辑弹窗 */}
<Modal
title="编辑患者信息"
open={editModalOpen}
onCancel={() => setEditModalOpen(false)}
onOk={() => form.submit()}
width={600}
>
<Form form={form} onFinish={handleEdit} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: '请输入患者姓名' }]}
>
<Input placeholder="请输入姓名" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="gender" label="性别" style={{ flex: 1 }}>
<Select options={GENDER_OPTIONS} placeholder="请选择" allowClear />
</Form.Item>
<Form.Item name="blood_type" label="血型" style={{ flex: 1 }}>
<Select options={BLOOD_TYPE_OPTIONS} placeholder="请选择" allowClear />
</Form.Item>
</div>
<Form.Item name="birth_date" label="出生日期">
<DatePicker style={{ width: '100%' }} placeholder="请选择" />
</Form.Item>
<Form.Item name="id_number" label="身份证号">
<Input placeholder="请输入身份证号" />
</Form.Item>
<Form.Item name="allergy_history" label="过敏史">
<Input.TextArea rows={2} placeholder="请输入过敏史" />
</Form.Item>
<Form.Item name="medical_history_summary" label="病史摘要">
<Input.TextArea rows={2} placeholder="请输入病史摘要" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="emergency_contact_name" label="紧急联系人" style={{ flex: 1 }}>
<Input placeholder="联系人姓名" />
</Form.Item>
<Form.Item name="emergency_contact_phone" label="紧急联系电话" style={{ flex: 1 }}>
<Input placeholder="联系电话" />
</Form.Item>
</div>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder="请输入备注" />
</Form.Item>
</Form>
</Modal>
</div>
);
}