Phase 1 安全热修复: - P0-1: /uploads 文件服务添加 JWT 认证中间件(支持 header + query param) - P0-2: analytics/batch 路由从 public 移到 protected_routes - P0-3: plugin engine SQL 注入修复(format! → 参数化查询) - P0-new: stats_service compute_avg_field 字段白名单 + FLOAT8 类型转换 Phase 2 数据完整性: - P0-4: 组织删除级联检查(添加部门存在性校验) - P0-5: 部门删除级联检查(添加岗位 + 用户存在性校验) - P0-8: workflow on_tenant_deleted 实现 5 实体批量删除 - P0-7: 并行网关 race condition 修复(consumed → completed 原子转换) Phase 3 P1 后端 Bug: - P1-12: plugin host 表名消毒(使用 sanitize_identifier) - P1-10: workflow deprecated 状态转换(published → deprecated) - P1-11: workflow 更新验证条件(nodes/edges 任一变化即验证) - P0-9: 小程序 .gitignore 添加 .env/.env.*/日志 - P1-19: 小程序加密密钥替换为 64 字符强密钥 Phase 4 消息模块: - P1-5: 通知偏好 GET 路由 + handler - P1-4: 消息模板 update/delete CRUD + version - P2-8: mark_all_read SQL 添加 version + 1 - P2-7: markAsRead 改为乐观更新 + 失败回滚 Phase 5 前端修复: - P2-9: 通知面板点击导航到 /messages - P2-1: 随访任务患者名批量 ID 解析(替代 UUID 显示) - P2-5: AppointmentList 分离 patient_id/doctor_id 分别调用 API - P2-17: PluginMarket installed 字段修正(name → id) - P3-3: 路由标题 fallback 改为模式匹配(支持 :id 动态路径) - P2-15: workflow updateDefinition 添加 version 字段 - P3-9: Kanban 版本使用记录实际 version - P2-21: secure-storage 生产环境无密钥时阻止存储 - P3-11: destroyOnHidden → destroyOnClose - P3-13: PendingTasks 深色模式 Tag 颜色适配 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
455 lines
14 KiB
TypeScript
455 lines
14 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
||
import {
|
||
Table,
|
||
Button,
|
||
Space,
|
||
Modal,
|
||
Form,
|
||
Select,
|
||
DatePicker,
|
||
TimePicker,
|
||
Input,
|
||
Dropdown,
|
||
message,
|
||
Card,
|
||
Row,
|
||
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 { patientApi } from '../../api/health/patients';
|
||
import { doctorApi } from '../../api/health/doctors';
|
||
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 [nameCache, setNameCache] = useState<Record<string, string>>({});
|
||
|
||
// ---- 数据获取 ----
|
||
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,
|
||
});
|
||
const items = result.data;
|
||
// 批量解析患者和医生名称(分别调用对应 API)
|
||
const missingPatientIds = new Set<string>();
|
||
const missingDoctorIds = new Set<string>();
|
||
items.forEach((a) => {
|
||
if (a.patient_id && !nameCache[a.patient_id]) missingPatientIds.add(a.patient_id);
|
||
if (a.doctor_id && !nameCache[a.doctor_id]) missingDoctorIds.add(a.doctor_id);
|
||
});
|
||
const newCache: Record<string, string> = {};
|
||
await Promise.allSettled([
|
||
...Array.from(missingPatientIds).map(async (id) => {
|
||
try { const p = await patientApi.get(id); newCache[id] = p.name; } catch { /* skip */ }
|
||
}),
|
||
...Array.from(missingDoctorIds).map(async (id) => {
|
||
try { const d = await doctorApi.get(id); newCache[id] = d.name; } catch { /* skip */ }
|
||
}),
|
||
]);
|
||
if (Object.keys(newCache).length > 0) {
|
||
setNameCache((prev) => ({ ...prev, ...newCache }));
|
||
}
|
||
setData(items);
|
||
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);
|
||
setModalOpen(true);
|
||
};
|
||
|
||
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 ?? nameCache[record.patient_id] ?? record.patient_id.slice(0, 8),
|
||
},
|
||
{
|
||
title: '医护',
|
||
dataIndex: 'doctor_name',
|
||
key: 'doctor_name',
|
||
width: 100,
|
||
render: (_: unknown, record: Appointment) => {
|
||
return record.doctor_name || nameCache[record.doctor_id ?? ''] || 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);
|
||
}}
|
||
onOk={() => form.submit()}
|
||
destroyOnHidden
|
||
width={560}
|
||
>
|
||
<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%' }} />
|
||
</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>
|
||
);
|
||
}
|