Files
hms/apps/web/src/pages/health/DoctorSchedule.tsx
iven 4cfbdec5fc
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
refactor(web): 统一 dayjs 导入为集中初始化 — 11 个文件
所有 health 页面从 import dayjs from 'dayjs' 迁移到
import { dayjs } from '.../utils/dayjs',确保 relativeTime
和 zh-cn locale 全局生效。
2026-04-28 01:47:13 +08:00

432 lines
12 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Select,
DatePicker,
TimePicker,
InputNumber,
Segmented,
Spin,
message,
Card,
Row,
Col,
Empty,
} from 'antd';
import { PlusOutlined, EditOutlined } from '@ant-design/icons';
import { dayjs } from '../../utils/dayjs';
import type { Dayjs } from 'dayjs';
import {
appointmentApi,
type Schedule,
type CreateScheduleReq,
type UpdateScheduleReq,
type CalendarDay,
} from '../../api/health/appointments';
import { DoctorSelect } from './components/DoctorSelect';
import { CalendarView, type ScheduleItem } from './components/CalendarView';
import { StatusTag } from './components/StatusTag';
import { AuthButton } from '../../components/AuthButton';
/** 时段选项 */
const PERIOD_OPTIONS = [
{ value: 'am', label: '上午' },
{ value: 'pm', label: '下午' },
];
const PERIOD_LABEL: Record<string, string> = {
am: '上午',
pm: '下午',
};
/** 排班状态选项 */
const SCHEDULE_STATUS_OPTIONS = [
{ value: 'active', label: '启用' },
{ value: 'inactive', label: '停用' },
{ value: 'cancelled', label: '已取消' },
];
export default function DoctorSchedule() {
// ---- 状态 ----
const [selectedDoctorId, setSelectedDoctorId] = useState<string | undefined>(undefined);
const [data, setData] = useState<Schedule[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [loading, setLoading] = useState(false);
// 视图模式
const [viewMode, setViewMode] = useState<string>('列表');
// 日历数据
const [calendarData, setCalendarData] = useState<CalendarDay[]>([]);
const [calendarLoading, setCalendarLoading] = useState(false);
// 弹窗
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Schedule | null>(null);
const [form] = Form.useForm();
// ---- 列表数据获取 ----
const fetchSchedules = useCallback(async (p = page, ps = pageSize) => {
if (!selectedDoctorId) {
setData([]);
setTotal(0);
return;
}
setLoading(true);
try {
const result = await appointmentApi.listSchedules({
page: p,
page_size: ps,
doctor_id: selectedDoctorId,
});
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载排班列表失败');
} finally {
setLoading(false);
}
}, [page, pageSize, selectedDoctorId]);
// ---- 日历数据获取 ----
const [calendarMonth, setCalendarMonth] = useState<Dayjs>(dayjs());
const fetchCalendar = useCallback(async (month?: Dayjs) => {
if (!selectedDoctorId) {
setCalendarData([]);
return;
}
const target = month ?? calendarMonth;
setCalendarLoading(true);
try {
const result = await appointmentApi.calendar({
start_date: target.startOf('month').format('YYYY-MM-DD'),
end_date: target.endOf('month').format('YYYY-MM-DD'),
doctor_id: selectedDoctorId,
});
setCalendarData(result);
} catch {
message.error('加载日历数据失败');
} finally {
setCalendarLoading(false);
}
}, [selectedDoctorId, calendarMonth]);
// 切换医护或视图模式时加载数据
useEffect(() => {
if (viewMode === '列表') {
fetchSchedules();
} else {
fetchCalendar();
}
}, [fetchSchedules, fetchCalendar, viewMode]);
// 切换医护时重置页码
const handleDoctorChange = (val: string) => {
setSelectedDoctorId(val || undefined);
setPage(1);
};
// ---- 新建 / 编辑排班 ----
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ period_type: 'am', max_appointments: 10 });
setModalOpen(true);
};
const openEdit = (record: Schedule) => {
setEditing(record);
form.setFieldsValue({
schedule_date: dayjs(record.schedule_date),
period_type: record.period_type,
start_time: dayjs(record.start_time, 'HH:mm'),
end_time: dayjs(record.end_time, 'HH:mm'),
max_appointments: record.max_appointments,
status: record.status,
});
setModalOpen(true);
};
const handleSubmit = async (values: {
schedule_date: Dayjs;
period_type: string;
start_time: Dayjs;
end_time: Dayjs;
max_appointments: number;
status?: string;
}) => {
if (!selectedDoctorId) {
message.warning('请先选择医护');
return;
}
try {
if (editing) {
const req: UpdateScheduleReq & { version: number } = {
start_time: values.start_time.format('HH:mm'),
end_time: values.end_time.format('HH:mm'),
max_appointments: values.max_appointments,
status: values.status,
version: editing.version,
};
await appointmentApi.updateSchedule(editing.id, req);
message.success('排班更新成功');
} else {
const req: CreateScheduleReq = {
doctor_id: selectedDoctorId,
schedule_date: values.schedule_date.format('YYYY-MM-DD'),
period_type: values.period_type,
start_time: values.start_time.format('HH:mm'),
end_time: values.end_time.format('HH:mm'),
max_appointments: values.max_appointments,
};
await appointmentApi.createSchedule(req);
message.success('排班创建成功');
}
setModalOpen(false);
form.resetFields();
if (viewMode === '列表') {
fetchSchedules(page, pageSize);
} else {
fetchCalendar();
}
} catch {
message.error(editing ? '更新排班失败' : '创建排班失败');
}
};
// ---- 列定义 ----
const columns = [
{
title: '日期',
dataIndex: 'schedule_date',
key: 'schedule_date',
width: 120,
render: (val: string) => val || '-',
},
{
title: '时段',
dataIndex: 'period_type',
key: 'period_type',
width: 80,
render: (val: string) => PERIOD_LABEL[val] || val,
},
{
title: '开始时间',
dataIndex: 'start_time',
key: 'start_time',
width: 100,
render: (val: string) => val || '-',
},
{
title: '结束时间',
dataIndex: 'end_time',
key: 'end_time',
width: 100,
render: (val: string) => val || '-',
},
{
title: '已约/上限',
key: 'appointment_ratio',
width: 110,
render: (_: unknown, record: Schedule) => {
const ratio = record.max_appointments > 0
? record.current_appointments / record.max_appointments
: 0;
const color = ratio >= 1 ? '#ff4d4f' : ratio >= 0.8 ? '#faad14' : '#52c41a';
return (
<span style={{ color }}>
{record.current_appointments}/{record.max_appointments}
</span>
);
},
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (val: string) => <StatusTag status={val} />,
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: unknown, record: Schedule) => (
<AuthButton code="health.doctor.manage">
<Space size="small">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
>
</Button>
</Space>
</AuthButton>
),
},
];
// ---- 将日历数据转换为 CalendarView 所需格式 ----
const calendarScheduleMap: Record<string, ScheduleItem[]> = {};
for (const day of calendarData) {
calendarScheduleMap[day.date] = day.schedules.map((s) => ({
id: s.id,
start_time: s.start_time,
end_time: s.end_time,
current_appointments: s.current_appointments,
max_appointments: s.max_appointments,
status: s.status,
}));
}
return (
<Card>
{/* 顶部操作栏 */}
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
<Col>
<Space>
<span style={{ fontWeight: 500 }}>:</span>
<DoctorSelect
value={selectedDoctorId}
onChange={handleDoctorChange}
placeholder="搜索选择医护"
/>
{selectedDoctorId && (
<Segmented
value={viewMode}
onChange={(val) => setViewMode(val as string)}
options={['列表', '日历']}
/>
)}
</Space>
</Col>
<Col flex="auto" />
<Col>
{selectedDoctorId && (
<AuthButton code="health.doctor.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
)}
</Col>
</Row>
{/* 内容区 */}
{!selectedDoctorId ? (
<Empty description="请先选择医护以查看排班" />
) : viewMode === '列表' ? (
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
scroll={{ x: 750 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
) : (
<Spin spinning={calendarLoading}>
<div style={{ marginTop: 16 }}>
<CalendarView
schedules={calendarScheduleMap}
onPanelChange={(date) => {
setCalendarMonth(date);
fetchCalendar(date);
}}
/>
</div>
</Spin>
)}
{/* 新建 / 编辑排班弹窗 */}
<Modal
title={editing ? '编辑排班' : '新建排班'}
open={modalOpen}
onCancel={() => {
setModalOpen(false);
form.resetFields();
}}
onOk={() => form.submit()}
destroyOnClose
width={520}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="schedule_date"
label="排班日期"
rules={[{ required: true, message: '请选择日期' }]}
>
<DatePicker style={{ width: '100%' }} disabled={!!editing} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="period_type" label="时段" rules={[{ required: true }]}>
<Select options={PERIOD_OPTIONS} disabled={!!editing} />
</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>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="max_appointments"
label="最大预约数"
rules={[{ required: true, message: '请输入最大预约数' }]}
>
<InputNumber min={1} max={200} style={{ width: '100%' }} />
</Form.Item>
</Col>
{editing && (
<Col span={12}>
<Form.Item name="status" label="状态">
<Select options={SCHEDULE_STATUS_OPTIONS} />
</Form.Item>
</Col>
)}
</Row>
</Form>
</Modal>
</Card>
);
}