Files
hms/apps/web/src/pages/health/AppointmentList.tsx
iven 83fe89cbcd
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
fix: 全系统审计问题修复 — 安全/数据完整性/功能缺陷/UX (Phase 1-5)
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>
2026-04-26 19:16:23 +08:00

455 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,
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>
);
}