- 创建 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→排班→预约创建+状态流转→随访生命周期→咨询会话+消息→患者详情+健康数据
341 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|