- [P0] 登录失败无反馈: client.ts 响应拦截器跳过 /auth/login 的 401 处理,让错误传播到 Login 组件 - [P0] 统计仪表盘 400: 前端用独立 try/catch 替代 Promise.all 提高容错性;后端 stats_service 白名单补充 ultrafiltration_volume/dialysis_duration - [P1] 随访负责人显示 UUID: 批量解析 assigned_to 用户名 - [P2] 消息中心时间未格式化: 添加 formatDateTime 函数 - [P2] 首页显示 login_failed: 过滤审计日志中的 login_failed 动作
548 lines
16 KiB
TypeScript
548 lines
16 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
Table,
|
|
Select,
|
|
Button,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
DatePicker,
|
|
Space,
|
|
Popconfirm,
|
|
message,
|
|
} from 'antd';
|
|
import { PlusOutlined, EditOutlined, SwapOutlined, DeleteOutlined } from '@ant-design/icons';
|
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
|
import dayjs from 'dayjs';
|
|
import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type UpdateFollowUpTaskReq } from '../../api/health/followUp';
|
|
import { patientApi } from '../../api/health/patients';
|
|
import { getUser } from '../../api/users';
|
|
import { StatusTag } from './components/StatusTag';
|
|
import { PatientSelect } from './components/PatientSelect';
|
|
import { DoctorSelect } from './components/DoctorSelect';
|
|
import { useThemeMode } from '../../hooks/useThemeMode';
|
|
import { AuthButton } from '../../components/AuthButton';
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: 'pending', label: '待处理' },
|
|
{ value: 'in_progress', label: '进行中' },
|
|
{ value: 'completed', label: '已完成' },
|
|
{ value: 'overdue', label: '逾期' },
|
|
{ value: 'cancelled', label: '已取消' },
|
|
];
|
|
|
|
const FOLLOW_UP_TYPE_OPTIONS = [
|
|
{ value: 'phone', label: '电话' },
|
|
{ value: 'outpatient', label: '门诊' },
|
|
{ value: 'home_visit', label: '家访' },
|
|
{ value: 'wechat', label: '微信' },
|
|
];
|
|
|
|
const FOLLOW_UP_TYPE_MAP: Record<string, string> = {
|
|
phone: '电话',
|
|
outpatient: '门诊',
|
|
home_visit: '家访',
|
|
wechat: '微信',
|
|
};
|
|
|
|
function formatDateTime(value: string): string {
|
|
return new Date(value).toLocaleString('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
interface RecordFormValues {
|
|
executed_date: dayjs.Dayjs;
|
|
result: string;
|
|
patient_condition: string;
|
|
medical_advice: string;
|
|
next_follow_up_date?: dayjs.Dayjs;
|
|
}
|
|
|
|
interface AssignFormValues {
|
|
assigned_to: string;
|
|
}
|
|
|
|
export default function FollowUpTaskList() {
|
|
const [tasks, setTasks] = useState<FollowUpTask[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [query, setQuery] = useState<{ page: number; page_size: number; status?: string }>({
|
|
page: 1,
|
|
page_size: 20,
|
|
});
|
|
|
|
// Create task modal
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [createLoading, setCreateLoading] = useState(false);
|
|
const [createForm] = Form.useForm<CreateFollowUpTaskReq>();
|
|
|
|
// Fill record modal
|
|
const [recordOpen, setRecordOpen] = useState(false);
|
|
const [recordLoading, setRecordLoading] = useState(false);
|
|
const [recordForm] = Form.useForm<RecordFormValues>();
|
|
const [activeTask, setActiveTask] = useState<FollowUpTask | null>(null);
|
|
|
|
// Assign modal
|
|
const [assignOpen, setAssignOpen] = useState(false);
|
|
const [assignLoading, setAssignLoading] = useState(false);
|
|
const [assignForm] = Form.useForm<AssignFormValues>();
|
|
const [assignTask, setAssignTask] = useState<FollowUpTask | null>(null);
|
|
|
|
// Patient/doctor label cache for display
|
|
const [patientLabels, setPatientLabels] = useState<Record<string, string>>({});
|
|
const [doctorLabels, setDoctorLabels] = useState<Record<string, string>>({});
|
|
|
|
const isDark = useThemeMode();
|
|
|
|
// --- Data fetching ---
|
|
const fetchTasks = useCallback(async (params: { page: number; page_size: number; status?: string }) => {
|
|
setLoading(true);
|
|
try {
|
|
const result = await followUpApi.listTasks(params);
|
|
setTasks(result.data);
|
|
setTotal(result.total);
|
|
|
|
// Batch resolve patient names
|
|
const patientIds = [...new Set(result.data.map((t: FollowUpTask) => t.patient_id).filter(Boolean))];
|
|
const newLabels: Record<string, string> = {};
|
|
await Promise.allSettled(
|
|
patientIds.map(async (id: string) => {
|
|
try {
|
|
const p = await patientApi.get(id);
|
|
newLabels[id] = p.name;
|
|
} catch { /* skip */ }
|
|
}),
|
|
);
|
|
if (Object.keys(newLabels).length > 0) {
|
|
setPatientLabels((prev) => ({ ...prev, ...newLabels }));
|
|
}
|
|
|
|
// Batch resolve assignee names
|
|
const assigneeIds = [...new Set(result.data.map((t: FollowUpTask) => t.assigned_to).filter(Boolean))];
|
|
const newDoctorLabels: Record<string, string> = {};
|
|
await Promise.allSettled(
|
|
assigneeIds.map(async (id: string) => {
|
|
try {
|
|
const u = await getUser(id);
|
|
newDoctorLabels[id] = u.display_name || u.username;
|
|
} catch { /* skip */ }
|
|
}),
|
|
);
|
|
if (Object.keys(newDoctorLabels).length > 0) {
|
|
setDoctorLabels((prev) => ({ ...prev, ...newDoctorLabels }));
|
|
}
|
|
} catch {
|
|
message.error('加载随访任务失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchTasks(query);
|
|
}, [query, fetchTasks]);
|
|
|
|
// --- Handlers ---
|
|
const handleFilterChange = (field: 'status', value: string | undefined) => {
|
|
setQuery((prev) => ({ ...prev, [field]: value || undefined, page: 1 }));
|
|
};
|
|
|
|
const handleTableChange = (pagination: TablePaginationConfig) => {
|
|
setQuery((prev) => ({
|
|
...prev,
|
|
page: pagination.current ?? 1,
|
|
page_size: pagination.pageSize ?? 20,
|
|
}));
|
|
};
|
|
|
|
// Create task
|
|
const handleCreate = async () => {
|
|
try {
|
|
const values = await createForm.validateFields();
|
|
setCreateLoading(true);
|
|
const plannedDate = values.planned_date;
|
|
await followUpApi.createTask({
|
|
patient_id: values.patient_id,
|
|
follow_up_type: values.follow_up_type,
|
|
planned_date: dayjs.isDayjs(plannedDate) ? plannedDate.format('YYYY-MM-DD') : plannedDate,
|
|
assigned_to: values.assigned_to,
|
|
content_template: values.content_template,
|
|
});
|
|
message.success('随访任务创建成功');
|
|
setCreateOpen(false);
|
|
createForm.resetFields();
|
|
fetchTasks(query);
|
|
} catch (err: unknown) {
|
|
if (err && typeof err === 'object' && 'errorFields' in err) return; // form validation
|
|
message.error('创建随访任务失败');
|
|
} finally {
|
|
setCreateLoading(false);
|
|
}
|
|
};
|
|
|
|
// Fill record
|
|
const openRecordModal = (task: FollowUpTask) => {
|
|
setActiveTask(task);
|
|
recordForm.resetFields();
|
|
setRecordOpen(true);
|
|
};
|
|
|
|
const handleRecordSubmit = async () => {
|
|
if (!activeTask) return;
|
|
try {
|
|
const values = await recordForm.validateFields();
|
|
setRecordLoading(true);
|
|
await followUpApi.createRecord(activeTask.id, {
|
|
executed_date: values.executed_date.format('YYYY-MM-DD'),
|
|
result: values.result,
|
|
patient_condition: values.patient_condition,
|
|
medical_advice: values.medical_advice,
|
|
next_follow_up_date: values.next_follow_up_date?.format('YYYY-MM-DD'),
|
|
});
|
|
message.success('随访记录填写成功');
|
|
setRecordOpen(false);
|
|
setActiveTask(null);
|
|
fetchTasks(query);
|
|
} catch (err: unknown) {
|
|
if (err && typeof err === 'object' && 'errorFields' in err) return;
|
|
message.error('填写随访记录失败');
|
|
} finally {
|
|
setRecordLoading(false);
|
|
}
|
|
};
|
|
|
|
// Assign
|
|
const openAssignModal = (task: FollowUpTask) => {
|
|
setAssignTask(task);
|
|
assignForm.resetFields();
|
|
if (task.assigned_to) {
|
|
assignForm.setFieldsValue({ assigned_to: task.assigned_to });
|
|
}
|
|
setAssignOpen(true);
|
|
};
|
|
|
|
const handleAssign = async () => {
|
|
if (!assignTask) return;
|
|
try {
|
|
const values = await assignForm.validateFields();
|
|
setAssignLoading(true);
|
|
const req: UpdateFollowUpTaskReq & { version: number } = {
|
|
assigned_to: values.assigned_to,
|
|
version: assignTask.version,
|
|
};
|
|
await followUpApi.updateTask(assignTask.id, req);
|
|
message.success('分配成功');
|
|
setAssignOpen(false);
|
|
setAssignTask(null);
|
|
fetchTasks(query);
|
|
} catch (err: unknown) {
|
|
if (err && typeof err === 'object' && 'errorFields' in err) return;
|
|
message.error('分配失败');
|
|
} finally {
|
|
setAssignLoading(false);
|
|
}
|
|
};
|
|
|
|
// Delete
|
|
const handleDelete = async (record: FollowUpTask) => {
|
|
try {
|
|
await followUpApi.deleteTask(record.id, record.version);
|
|
message.success('删除成功');
|
|
fetchTasks(query);
|
|
} catch {
|
|
message.error('删除失败');
|
|
}
|
|
};
|
|
|
|
// Store labels from selects
|
|
const handlePatientLabel = (id: string, label: string) => {
|
|
setPatientLabels((prev) => ({ ...prev, [id]: label }));
|
|
};
|
|
|
|
const handleDoctorLabel = (id: string, label: string) => {
|
|
setDoctorLabels((prev) => ({ ...prev, [id]: label }));
|
|
};
|
|
|
|
// --- Columns ---
|
|
const columns: ColumnsType<FollowUpTask> = [
|
|
{
|
|
title: '患者',
|
|
dataIndex: 'patient_id',
|
|
key: 'patient_id',
|
|
width: 140,
|
|
render: (id: string) => patientLabels[id] || id.slice(0, 8),
|
|
},
|
|
{
|
|
title: '随访类型',
|
|
dataIndex: 'follow_up_type',
|
|
key: 'follow_up_type',
|
|
width: 100,
|
|
render: (v: string) => FOLLOW_UP_TYPE_MAP[v] || v,
|
|
},
|
|
{
|
|
title: '计划日期',
|
|
dataIndex: 'planned_date',
|
|
key: 'planned_date',
|
|
width: 120,
|
|
render: (v: string) => v,
|
|
},
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
key: 'status',
|
|
width: 100,
|
|
render: (status: string) => <StatusTag status={status} />,
|
|
},
|
|
{
|
|
title: '负责人',
|
|
dataIndex: 'assigned_to',
|
|
key: 'assigned_to',
|
|
width: 140,
|
|
render: (id: string | undefined) =>
|
|
id ? doctorLabels[id] || id.slice(0, 8) : '-',
|
|
},
|
|
{
|
|
title: '创建时间',
|
|
dataIndex: 'created_at',
|
|
key: 'created_at',
|
|
width: 160,
|
|
render: (v: string) => (
|
|
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
|
|
{formatDateTime(v)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
title: '操作',
|
|
key: 'actions',
|
|
width: 220,
|
|
render: (_: unknown, record: FollowUpTask) => (
|
|
<AuthButton code="health.follow-up.manage">
|
|
<Space size={4}>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<EditOutlined />}
|
|
onClick={() => openRecordModal(record)}
|
|
>
|
|
填写记录
|
|
</Button>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<SwapOutlined />}
|
|
onClick={() => openAssignModal(record)}
|
|
>
|
|
分配
|
|
</Button>
|
|
<Popconfirm
|
|
title="确认删除该随访任务?"
|
|
onConfirm={() => handleDelete(record)}
|
|
okText="确认"
|
|
cancelText="取消"
|
|
>
|
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
|
删除
|
|
</Button>
|
|
</Popconfirm>
|
|
</Space>
|
|
</AuthButton>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
{/* Toolbar */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
marginBottom: 16,
|
|
padding: 12,
|
|
background: isDark ? '#111827' : '#FFFFFF',
|
|
borderRadius: 10,
|
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
|
}}
|
|
>
|
|
<Select
|
|
allowClear
|
|
placeholder="状态筛选"
|
|
style={{ width: 160 }}
|
|
options={STATUS_OPTIONS}
|
|
value={query.status}
|
|
onChange={(value) => handleFilterChange('status', value)}
|
|
/>
|
|
<AuthButton code="health.follow-up.manage">
|
|
<Button
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={() => {
|
|
createForm.resetFields();
|
|
setCreateOpen(true);
|
|
}}
|
|
>
|
|
新建任务
|
|
</Button>
|
|
</AuthButton>
|
|
<span
|
|
style={{
|
|
fontSize: 13,
|
|
color: isDark ? '#475569' : '#94a3b8',
|
|
marginLeft: 'auto',
|
|
}}
|
|
>
|
|
共 {total} 条
|
|
</span>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div
|
|
style={{
|
|
background: isDark ? '#111827' : '#FFFFFF',
|
|
borderRadius: 12,
|
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Table
|
|
rowKey="id"
|
|
columns={columns}
|
|
dataSource={tasks}
|
|
loading={loading}
|
|
onChange={handleTableChange}
|
|
pagination={{
|
|
current: query.page,
|
|
pageSize: query.page_size,
|
|
total,
|
|
showSizeChanger: true,
|
|
showTotal: (t) => `共 ${t} 条`,
|
|
}}
|
|
scroll={{ x: 980 }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Create Task Modal */}
|
|
<Modal
|
|
title="新建随访任务"
|
|
open={createOpen}
|
|
onOk={handleCreate}
|
|
onCancel={() => setCreateOpen(false)}
|
|
confirmLoading={createLoading}
|
|
okText="创建"
|
|
cancelText="取消"
|
|
destroyOnClose
|
|
>
|
|
<Form form={createForm} layout="vertical" autoComplete="off">
|
|
<Form.Item
|
|
name="patient_id"
|
|
label="患者"
|
|
rules={[{ required: true, message: '请选择患者' }]}
|
|
>
|
|
<PatientSelect
|
|
onChange={(_val, label) => handlePatientLabel(_val, label)}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="follow_up_type"
|
|
label="随访类型"
|
|
rules={[{ required: true, message: '请选择随访类型' }]}
|
|
>
|
|
<Select options={FOLLOW_UP_TYPE_OPTIONS} placeholder="选择随访类型" />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="planned_date"
|
|
label="计划日期"
|
|
rules={[{ required: true, message: '请选择计划日期' }]}
|
|
>
|
|
<DatePicker style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="assigned_to" label="负责人">
|
|
<DoctorSelect
|
|
onChange={(_val, label) => handleDoctorLabel(_val, label)}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="content_template" label="内容模板">
|
|
<Input.TextArea rows={3} placeholder="随访内容模板(可选)" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Fill Record Modal */}
|
|
<Modal
|
|
title={`填写随访记录 — 任务 ${activeTask?.id?.slice(0, 8) ?? ''}`}
|
|
open={recordOpen}
|
|
onOk={handleRecordSubmit}
|
|
onCancel={() => {
|
|
setRecordOpen(false);
|
|
setActiveTask(null);
|
|
}}
|
|
confirmLoading={recordLoading}
|
|
okText="提交"
|
|
cancelText="取消"
|
|
destroyOnClose
|
|
width={560}
|
|
>
|
|
<Form form={recordForm} layout="vertical" autoComplete="off">
|
|
<Form.Item
|
|
name="executed_date"
|
|
label="执行日期"
|
|
rules={[{ required: true, message: '请选择执行日期' }]}
|
|
>
|
|
<DatePicker style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="result"
|
|
label="随访结果"
|
|
rules={[{ required: true, message: '请填写随访结果' }]}
|
|
>
|
|
<Input.TextArea rows={3} placeholder="描述随访结果" />
|
|
</Form.Item>
|
|
<Form.Item name="patient_condition" label="患者状况">
|
|
<Input.TextArea rows={3} placeholder="描述患者当前状况" />
|
|
</Form.Item>
|
|
<Form.Item name="medical_advice" label="医嘱">
|
|
<Input.TextArea rows={3} placeholder="医嘱内容" />
|
|
</Form.Item>
|
|
<Form.Item name="next_follow_up_date" label="下次随访日期">
|
|
<DatePicker style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Assign Modal */}
|
|
<Modal
|
|
title="分配负责人"
|
|
open={assignOpen}
|
|
onOk={handleAssign}
|
|
onCancel={() => {
|
|
setAssignOpen(false);
|
|
setAssignTask(null);
|
|
}}
|
|
confirmLoading={assignLoading}
|
|
okText="确认"
|
|
cancelText="取消"
|
|
destroyOnClose
|
|
>
|
|
<Form form={assignForm} layout="vertical" autoComplete="off">
|
|
<Form.Item
|
|
name="assigned_to"
|
|
label="负责人"
|
|
rules={[{ required: true, message: '请选择负责人' }]}
|
|
>
|
|
<DoctorSelect
|
|
onChange={(_val, label) => handleDoctorLabel(_val, label)}
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|