Files
hms/apps/web/src/pages/health/AppointmentList.tsx
iven c6856370c6 perf(web): AppointmentList 移除 nameCache N+1 请求
后端已内联 patient_name/doctor_name,前端移除逐条查询
patientApi/doctorApi 的 nameCache 逻辑,列表加载降为 O(1) 请求。
2026-04-27 09:41:47 +08:00

472 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Select,
DatePicker,
TimePicker,
Input,
Dropdown,
message,
Card,
Row,
Alert,
Col,
} from 'antd';
import {
PlusOutlined,
DownOutlined,
} from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import { appointmentApi, type Appointment, type CreateAppointmentReq } from '../../api/health/appointments';
import { StatusTag } from './components/StatusTag';
import { PatientSelect } from './components/PatientSelect';
import { DoctorSelect } from './components/DoctorSelect';
import { AuthButton } from '../../components/AuthButton';
/** 预约类型选项 */
const APPOINTMENT_TYPE_OPTIONS = [
{ value: 'outpatient', label: '门诊' },
{ value: 'recheck', label: '复诊' },
{ value: 'health_checkup', label: '体检' },
{ value: 'consultation', label: '咨询' },
{ value: 'dialysis', label: '透析' },
];
const APPOINTMENT_TYPE_MAP: Record<string, string> = {
outpatient: '门诊',
recheck: '复诊',
health_checkup: '体检',
consultation: '咨询',
dialysis: '透析',
};
/** 状态筛选选项 */
const STATUS_OPTIONS = [
{ value: 'pending', label: '待确认' },
{ value: 'confirmed', label: '已确认' },
{ value: 'completed', label: '已完成' },
{ value: 'cancelled', label: '已取消' },
{ value: 'no_show', label: '未到诊' },
];
/** 状态流转规则 */
const STATUS_TRANSITIONS: Record<string, { value: string; label: string }[]> = {
pending: [
{ value: 'confirmed', label: '确认' },
{ value: 'cancelled', label: '取消' },
],
confirmed: [
{ value: 'completed', label: '完成' },
{ value: 'no_show', label: '未到诊' },
{ value: 'cancelled', label: '取消' },
],
completed: [],
cancelled: [],
no_show: [
{ value: 'confirmed', label: '重新确认' },
],
};
export default function AppointmentList() {
const [data, setData] = useState<Appointment[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
const [dateFilter, setDateFilter] = useState<Dayjs | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
// 患者选择状态(受控组件,不挂在 Form.Item 上)
const [selectedPatientId, setSelectedPatientId] = useState<string | undefined>(undefined);
const [selectedDoctorId, setSelectedDoctorId] = useState<string | undefined>(undefined);
// 排班校验
const [scheduleHint, setScheduleHint] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
// ---- 数据获取 ----
const fetchData = useCallback(async (p = page, ps = pageSize) => {
setLoading(true);
try {
const result = await appointmentApi.list({
page: p,
page_size: ps,
status: statusFilter || undefined,
date: dateFilter ? dateFilter.format('YYYY-MM-DD') : undefined,
});
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载预约列表失败');
} finally {
setLoading(false);
}
}, [page, pageSize, statusFilter, dateFilter]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ---- 状态变更 ----
const DESTRUCTIVE_STATUSES = new Set(['cancelled', 'no_show']);
const handleStatusChange = (record: Appointment, newStatus: string) => {
const transition = STATUS_TRANSITIONS[record.status]?.find((t) => t.value === newStatus);
if (!transition) return;
if (DESTRUCTIVE_STATUSES.has(newStatus)) {
let cancelReason = '';
Modal.confirm({
title: `确认${transition.label}`,
content: newStatus === 'cancelled' ? (
<Input.TextArea
rows={3}
placeholder="请输入取消原因"
onChange={(e) => { cancelReason = e.target.value; }}
/>
) : (
<span>"{transition.label}"</span>
),
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
await appointmentApi.updateStatus(record.id, {
status: newStatus,
version: record.version,
...(newStatus === 'cancelled' && { cancel_reason: cancelReason }),
});
message.success('状态更新成功');
fetchData(page, pageSize);
} catch {
message.error('状态更新失败');
}
},
});
} else {
Modal.confirm({
title: `确认${transition.label}`,
content: `确定将此预约状态变更为"${transition.label}"`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
await appointmentApi.updateStatus(record.id, {
status: newStatus,
version: record.version,
});
message.success('状态更新成功');
fetchData(page, pageSize);
} catch {
message.error('状态更新失败');
}
},
});
}
};
// ---- 新建预约 ----
const openCreate = () => {
form.resetFields();
setSelectedPatientId(undefined);
setSelectedDoctorId(undefined);
setScheduleHint(null);
setSelectedDate(null);
setModalOpen(true);
};
// 排班校验:医生 + 日期选定后查询排班
useEffect(() => {
if (!selectedDoctorId || !selectedDate || !modalOpen) {
setScheduleHint(null);
return;
}
let cancelled = false;
appointmentApi.listSchedules({ doctor_id: selectedDoctorId, date: selectedDate, page: 1, page_size: 50 })
.then((result) => {
if (cancelled) return;
const schedules = result.data;
if (schedules.length === 0) {
setScheduleHint(`该医生在 ${selectedDate} 暂无排班,请确认是否需要先创建排班`);
} else {
const slots = schedules
.filter((s) => s.status === 'active' && s.current_appointments < s.max_appointments)
.map((s) => `${s.start_time}-${s.end_time}(${s.current_appointments}/${s.max_appointments})`)
.join('、');
setScheduleHint(slots ? `可约时段:${slots}` : `该医生在 ${selectedDate} 排班已满或已停用`);
}
})
.catch(() => { if (!cancelled) setScheduleHint(null); });
return () => { cancelled = true; };
}, [selectedDoctorId, selectedDate, modalOpen]);
const handleSubmit = async (values: {
appointment_date: Dayjs;
start_time: Dayjs;
end_time: Dayjs;
appointment_type?: string;
notes?: string;
}) => {
if (!selectedPatientId) {
message.warning('请选择患者');
return;
}
if (!selectedDoctorId) {
message.warning('请选择医护');
return;
}
try {
const req: CreateAppointmentReq = {
patient_id: selectedPatientId,
doctor_id: selectedDoctorId || undefined,
appointment_date: values.appointment_date.format('YYYY-MM-DD'),
start_time: values.start_time.format('HH:mm'),
end_time: values.end_time.format('HH:mm'),
appointment_type: values.appointment_type || 'outpatient',
notes: values.notes || undefined,
};
await appointmentApi.create(req);
message.success('预约创建成功');
setModalOpen(false);
form.resetFields();
setSelectedPatientId(undefined);
setSelectedDoctorId(undefined);
fetchData(page, pageSize);
} catch {
message.error('创建预约失败');
}
};
// ---- 列定义 ----
const columns = [
{
title: '患者',
dataIndex: 'patient_name',
key: 'patient_name',
width: 100,
render: (_: unknown, record: Appointment) =>
record.patient_name ?? record.patient_id.slice(0, 8),
},
{
title: '医护',
dataIndex: 'doctor_name',
key: 'doctor_name',
width: 100,
render: (_: unknown, record: Appointment) =>
record.doctor_name ?? record.doctor_id?.slice(0, 8) ?? '-',
},
{
title: '预约类型',
dataIndex: 'appointment_type',
key: 'appointment_type',
width: 90,
render: (val: string) => APPOINTMENT_TYPE_MAP[val] || val,
},
{
title: '预约日期',
dataIndex: 'appointment_date',
key: 'appointment_date',
width: 120,
render: (val: string) => val || '-',
},
{
title: '时段',
key: 'time_range',
width: 120,
render: (_: unknown, record: Appointment) =>
record.start_time && record.end_time
? `${record.start_time} - ${record.end_time}`
: '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (val: string) => <StatusTag status={val} />,
},
{
title: '备注',
dataIndex: 'notes',
key: 'notes',
width: 180,
ellipsis: true,
render: (val: string) => val || '-',
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as const,
render: (_: unknown, record: Appointment) => {
const transitions = STATUS_TRANSITIONS[record.status] || [];
if (transitions.length === 0) {
return <span style={{ color: '#999' }}></span>;
}
return (
<AuthButton code="health.appointment.manage">
<Dropdown
menu={{
items: transitions.map((t) => ({
key: t.value,
label: t.label,
onClick: () => handleStatusChange(record, t.value),
})),
}}
>
<Button type="link" size="small">
<DownOutlined />
</Button>
</Dropdown>
</AuthButton>
);
},
},
];
return (
<Card>
{/* 筛选栏 */}
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
<Col flex="auto">
<Space>
<Select
placeholder="筛选状态"
value={statusFilter}
onChange={(val) => {
setStatusFilter(val);
setPage(1);
}}
options={STATUS_OPTIONS}
allowClear
style={{ width: 140 }}
/>
<DatePicker
placeholder="筛选日期"
value={dateFilter}
onChange={(val) => {
setDateFilter(val);
setPage(1);
}}
allowClear
/>
</Space>
</Col>
<Col>
<AuthButton code="health.appointment.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
</Col>
</Row>
{/* 数据表格 */}
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
scroll={{ x: 1000 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
{/* 新建预约弹窗 */}
<Modal
title="新建预约"
open={modalOpen}
onCancel={() => {
setModalOpen(false);
form.resetFields();
setSelectedPatientId(undefined);
setSelectedDoctorId(undefined);
setScheduleHint(null);
}}
onOk={() => form.submit()}
destroyOnHidden
width={560}
>
{scheduleHint && (
<Alert
message={scheduleHint}
type={scheduleHint.includes('暂无排班') ? 'warning' : 'info'}
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item label="患者" required>
<PatientSelect
value={selectedPatientId}
onChange={(val) => setSelectedPatientId(val)}
placeholder="搜索选择患者"
/>
</Form.Item>
<Form.Item label="医护" required>
<DoctorSelect
value={selectedDoctorId}
onChange={(val) => setSelectedDoctorId(val)}
placeholder="搜索选择医护"
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="appointment_date"
label="预约日期"
rules={[{ required: true, message: '请选择预约日期' }]}
>
<DatePicker style={{ width: '100%' }} onChange={(d) => setSelectedDate(d ? d.format('YYYY-MM-DD') : null)} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="appointment_type" label="预约类型" initialValue="outpatient">
<Select options={APPOINTMENT_TYPE_OPTIONS} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="start_time"
label="开始时间"
rules={[{ required: true, message: '请选择开始时间' }]}
>
<TimePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="end_time"
label="结束时间"
rules={[{ required: true, message: '请选择结束时间' }]}
>
<TimePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={3} placeholder="预约备注信息" />
</Form.Item>
</Form>
</Modal>
</Card>
);
}