Files
hms/apps/web/src/pages/health/PatientList.tsx
iven 679d83d3b6
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
refactor(web): 迁移 3 个健康页面错误处理到 useApiRequest — 消除内联 catch/message.error
- PatientList: handleCreateOrEdit/handleDelete/openEditModal 使用 execute
- AppointmentList: handleStatusChange(2处)/handleSubmit 使用 execute
- FollowUpTaskList: handleCreate/handleRecordSubmit/handleAssign/handleDelete 使用 execute
- 移除不再需要的 message 导入(PatientList/FollowUpTaskList)
2026-04-28 19:24:07 +08:00

487 lines
14 KiB
TypeScript

import { useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Table,
Button,
Space,
Form,
Input,
Select,
Popconfirm,
DatePicker,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { patientApi } from '../../api/health/patients';
import type {
PatientListItem,
PatientDetail,
CreatePatientReq,
UpdatePatientReq,
} from '../../api/health/patients';
import { StatusTag } from './components/StatusTag';
import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS, STATUS_OPTIONS } from '../../constants/health';
import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { DrawerForm } from '../../components/DrawerForm';
import { usePaginatedData } from '../../hooks/usePaginatedData';
import { useApiRequest } from '../../hooks/useApiRequest';
import { calcAge, formatDateTime } from '../../utils/format';
import { dayjs } from '../../utils/dayjs';
/** 筛选器结构 */
interface PatientFilters {
search: string;
status: string;
gender: string;
dateRange: [string, string] | null;
}
const DEFAULT_FILTERS: PatientFilters = {
search: '',
status: '',
gender: '',
dateRange: null,
};
export default function PatientList() {
const navigate = useNavigate();
const [modalOpen, setModalOpen] = useState(false);
const [editingPatient, setEditingPatient] = useState<PatientDetail | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
// ---- API 请求 Hook ----
const { execute } = useApiRequest();
// ---- 分页数据 Hook ----
const {
data: patients,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<PatientListItem, PatientFilters>(
async (p, pageSize, f) => {
const result = await patientApi.list({
page: p,
page_size: pageSize,
search: f.search || undefined,
status: f.status || undefined,
});
return result;
},
{ pageSize: 20, defaultFilters: { ...DEFAULT_FILTERS } },
);
// ---- 筛选回调 ----
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleSearchChange = useCallback(
(value: string) => {
setFilters((prev) => ({ ...prev, search: value }));
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
refresh(1);
}, 300);
},
[setFilters, refresh],
);
const handleFilterChange = useCallback(
(key: keyof PatientFilters, value: string | [string, string] | null) => {
setFilters((prev) => ({ ...prev, [key]: value }));
refresh(1);
},
[setFilters, refresh],
);
const handleResetFilters = useCallback(() => {
setFilters({ ...DEFAULT_FILTERS });
refresh(1);
}, [setFilters, refresh]);
// ---- CRUD 操作 ----
const handleCreateOrEdit = async (values: Record<string, unknown>) => {
const birthDate = values.birth_date;
const formattedBirthDate =
birthDate && typeof birthDate === 'object' && 'format' in (birthDate as object)
? (birthDate as { format: (f: string) => string }).format('YYYY-MM-DD')
: (birthDate as string | undefined);
const payload = {
name: values.name as string,
gender: values.gender as string | undefined,
birth_date: formattedBirthDate,
blood_type: values.blood_type as string | undefined,
id_number: values.id_number as string | undefined,
allergy_history: values.allergy_history as string | undefined,
medical_history_summary: values.medical_history_summary as string | undefined,
emergency_contact_name: values.emergency_contact_name as string | undefined,
emergency_contact_phone: values.emergency_contact_phone as string | undefined,
source: values.source as string | undefined,
notes: values.notes as string | undefined,
};
const successMsg = editingPatient ? '患者信息更新成功' : '患者创建成功';
const result = await execute(
async () => {
if (editingPatient) {
const req: UpdatePatientReq & { version: number } = {
...payload,
version: editingPatient.version,
};
return patientApi.update(editingPatient.id, req);
}
const req: CreatePatientReq = payload;
return patientApi.create(req);
},
successMsg,
'操作失败',
);
if (result !== null) {
closeModal();
refresh();
}
};
const handleDelete = async (id: string) => {
const patient = patients.find((p) => p.id === id);
const version = patient?.version ?? 0;
const result = await execute(
() => patientApi.delete(id, version),
'患者已删除',
);
if (result !== null) refresh();
};
const openCreateModal = () => {
setEditingPatient(null);
setModalOpen(true);
};
const openEditModal = async (record: PatientListItem) => {
const detail = await execute(
() => patientApi.get(record.id),
undefined,
'获取患者详情失败',
);
if (detail) {
setEditingPatient(detail);
setModalOpen(true);
}
};
const closeModal = () => {
setModalOpen(false);
setEditingPatient(null);
};
// ---- 列定义 ----
const columns = [
{
title: '姓名',
dataIndex: 'name',
key: 'name',
render: (name: string, record: PatientListItem) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'linear-gradient(135deg, #0ea5e9, #38bdf8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 13,
fontWeight: 600,
}}
>
{(name?.[0] || 'P').toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 500, fontSize: 14 }}>{name}</div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>
{record.source && <span>: {record.source}</span>}
</div>
</div>
</div>
),
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
width: 80,
render: (v?: string) => {
if (!v) return '-';
const map: Record<string, string> = {
male: '男',
female: '女',
other: '其他',
};
return map[v] || v;
},
},
{
title: '年龄',
dataIndex: 'birth_date',
key: 'birth_date',
width: 100,
render: (v?: string) => calcAge(v),
},
{
title: '血型',
dataIndex: 'blood_type',
key: 'blood_type',
width: 80,
render: (v?: string) => v || '-',
},
{
title: '状态',
key: 'status',
width: 140,
render: (_: unknown, record: PatientListItem) => (
<Space size={4}>
<StatusTag status={record.status} />
<StatusTag status={record.verification_status} />
</Space>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 150,
render: (v: string) => formatDateTime(v),
},
{
title: '操作',
key: 'actions',
width: 140,
render: (_: unknown, record: PatientListItem) => (
<AuthButton code="health.patient.manage">
<Space size={4}>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
openEditModal(record);
}}
/>
<Popconfirm
title="确定删除此患者?"
onConfirm={(e) => {
e?.stopPropagation();
handleDelete(record.id);
}}
>
<Button
size="small"
type="text"
icon={<DeleteOutlined />}
danger
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
</Space>
</AuthButton>
),
},
];
return (
<PageContainer
title="患者管理"
subtitle="管理患者档案、基本信息和认证状态"
filters={
<>
<Input
placeholder="搜索患者姓名..."
value={filters.search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
style={{ width: 200 }}
/>
<Select
placeholder="状态"
value={filters.status || undefined}
onChange={(v) => handleFilterChange('status', v ?? '')}
options={STATUS_OPTIONS}
allowClear
style={{ width: 130 }}
/>
<Select
placeholder="性别"
value={filters.gender || undefined}
onChange={(v) => handleFilterChange('gender', v ?? '')}
options={GENDER_OPTIONS}
allowClear
style={{ width: 120 }}
/>
<DatePicker.RangePicker
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
handleFilterChange('dateRange', [
dates[0].format('YYYY-MM-DD'),
dates[1].format('YYYY-MM-DD'),
]);
} else {
handleFilterChange('dateRange', null);
}
}}
placeholder={['开始日期', '结束日期']}
/>
</>
}
onResetFilters={handleResetFilters}
actions={
<AuthButton code="health.patient.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</AuthButton>
}
selectedCount={selectedRowKeys.length}
onClearSelection={() => setSelectedRowKeys([])}
batchActions={
<span style={{ fontSize: 13, color: '#94a3b8' }}>
{selectedRowKeys.length}
</span>
}
loading={loading}
>
<Table
columns={columns}
dataSource={patients}
rowKey="id"
loading={loading}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
onRow={(record) => ({
onClick: () => navigate(`/health/patients/${record.id}`),
style: { cursor: 'pointer' },
})}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => refresh(p),
showTotal: (t) => `${t} 条记录`,
style: { padding: '12px 16px', margin: 0 },
}}
/>
{/* 新建/编辑患者抽屉 */}
<DrawerForm
title={editingPatient ? '编辑患者' : '新建患者'}
open={modalOpen}
onClose={closeModal}
onSubmit={handleCreateOrEdit}
initialValues={
editingPatient
? {
name: editingPatient.name,
gender: editingPatient.gender,
birth_date: editingPatient.birth_date ? dayjs(editingPatient.birth_date) : undefined,
blood_type: editingPatient.blood_type,
id_number: editingPatient.id_number,
allergy_history: editingPatient.allergy_history,
medical_history_summary: editingPatient.medical_history_summary,
emergency_contact_name: editingPatient.emergency_contact_name,
emergency_contact_phone: editingPatient.emergency_contact_phone,
source: editingPatient.source,
notes: editingPatient.notes,
}
: undefined
}
width={640}
columns={2}
sections={[
{
title: '基本信息',
fields: (
<>
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: '请输入患者姓名' }]}
>
<Input placeholder="请输入姓名" />
</Form.Item>
<Form.Item name="gender" label="性别">
<Select options={GENDER_OPTIONS} placeholder="请选择性别" allowClear />
</Form.Item>
<Form.Item name="birth_date" label="出生日期">
<DatePicker style={{ width: '100%' }} placeholder="请选择出生日期" />
</Form.Item>
<Form.Item name="blood_type" label="血型">
<Select
options={BLOOD_TYPE_OPTIONS}
placeholder="请选择血型"
allowClear
/>
</Form.Item>
</>
),
},
{
title: '联系方式',
fields: (
<>
<Form.Item name="id_number" label="身份证号">
<Input placeholder="请输入身份证号" />
</Form.Item>
<Form.Item name="source" label="来源">
<Input placeholder="请输入患者来源" />
</Form.Item>
</>
),
},
{
title: '医疗信息',
fields: (
<>
<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>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder="请输入备注" />
</Form.Item>
</>
),
},
{
title: '紧急联系人',
fields: (
<>
<Form.Item name="emergency_contact_name" label="联系人姓名">
<Input placeholder="请输入紧急联系人姓名" />
</Form.Item>
<Form.Item name="emergency_contact_phone" label="联系电话">
<Input placeholder="请输入紧急联系人电话" />
</Form.Item>
</>
),
},
]}
/>
</PageContainer>
);
}