feat(web): 健康管理模块 10 页面完整实现
Task 12 - 患者管理: - PatientList: 搜索+状态筛选+CRUD+行点击跳转详情 - PatientTagManage: 患者标签管理+批量打标 - PatientDetail: 3Tab详情页(基本信息/健康数据/随访记录)+编辑 Task 13 - 医护预约: - DoctorList: 科室筛选+CRUD+在线状态Badge - AppointmentList: 状态筛选+日期筛选+创建预约+状态流转 - DoctorSchedule: 医生选择+列表/日历视图+排班CRUD Task 14 - 随访咨询: - FollowUpTaskList: 任务CRUD+填写记录+分配医护 - FollowUpRecordList: 只读台账+日期范围筛选+导出 - ConsultationList: 会话列表+创建+关闭+行点击跳转 - ConsultationDetail: 聊天界面+消息分页+发送+图片预览 修正: consultations.ts Session类型补充 updated_at/version
This commit is contained in:
@@ -12,6 +12,8 @@ export interface Session {
|
|||||||
unread_count_patient: number;
|
unread_count_patient: number;
|
||||||
unread_count_doctor: number;
|
unread_count_doctor: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateSessionReq {
|
export interface CreateSessionReq {
|
||||||
|
|||||||
@@ -1,10 +1,377 @@
|
|||||||
import { Card, Typography } from 'antd';
|
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 { StatusTag } from './components/StatusTag';
|
||||||
|
import { PatientSelect } from './components/PatientSelect';
|
||||||
|
import { DoctorSelect } from './components/DoctorSelect';
|
||||||
|
|
||||||
|
/** 预约类型选项 */
|
||||||
|
const APPOINTMENT_TYPE_OPTIONS = [
|
||||||
|
{ value: 'outpatient', label: '门诊' },
|
||||||
|
{ value: 'physical_checkup', label: '体检' },
|
||||||
|
{ value: 'follow_up', label: '随访' },
|
||||||
|
{ value: 'consultation', label: '咨询' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const APPOINTMENT_TYPE_MAP: Record<string, string> = {
|
||||||
|
outpatient: '门诊',
|
||||||
|
physical_checkup: '体检',
|
||||||
|
follow_up: '随访',
|
||||||
|
consultation: '咨询',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 状态筛选选项 */
|
||||||
|
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() {
|
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 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 handleStatusChange = async (record: Appointment, newStatus: string) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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 as unknown as Record<string, unknown>).patient_name as string || record.patient_id.slice(0, 8),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '医护',
|
||||||
|
dataIndex: 'doctor_name',
|
||||||
|
key: 'doctor_name',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, record: Appointment) => {
|
||||||
|
const name = (record as unknown as Record<string, unknown>).doctor_name as string | undefined;
|
||||||
|
return 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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<Typography.Title level={4}>预约排班</Typography.Title>
|
{/* 筛选栏 */}
|
||||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
<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>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||||
|
新建预约
|
||||||
|
</Button>
|
||||||
|
</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()}
|
||||||
|
destroyOnClose
|
||||||
|
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="医护">
|
||||||
|
<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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,372 @@
|
|||||||
import { Card, Typography } from 'antd';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Button, Input, Spin, Popconfirm, message, theme, Typography } from 'antd';
|
||||||
|
import { SendOutlined, CloseCircleOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { consultationApi, type Session, type Message } from '../../api/health/consultations';
|
||||||
|
import { StatusTag } from './components/StatusTag';
|
||||||
|
import { ImagePreview } from './components/ImagePreview';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse image URLs from message content (JSON array or single URL string). */
|
||||||
|
function parseImageUrls(content: string): string[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
if (Array.isArray(parsed)) return parsed.map(String);
|
||||||
|
return [String(parsed)];
|
||||||
|
} catch {
|
||||||
|
return [content];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_ALIGN: Record<string, 'flex-start' | 'flex-end' | 'center'> = {
|
||||||
|
patient: 'flex-start',
|
||||||
|
doctor: 'flex-end',
|
||||||
|
system: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
export default function ConsultationDetail() {
|
export default function ConsultationDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const sessionId = id ?? '';
|
||||||
|
|
||||||
|
// Session info
|
||||||
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
|
const [sessionLoading, setSessionLoading] = useState(true);
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [msgPage, setMsgPage] = useState(1);
|
||||||
|
const [msgLoading, setMsgLoading] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [inputText, setInputText] = useState('');
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
|
||||||
|
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const shouldScrollRef = useRef(true);
|
||||||
|
|
||||||
|
const { token: themeToken } = theme.useToken();
|
||||||
|
const isDark =
|
||||||
|
themeToken.colorBgContainer === '#111827' ||
|
||||||
|
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
|
||||||
|
|
||||||
|
// --- Fetch session info ---
|
||||||
|
const fetchSession = useCallback(async () => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
setSessionLoading(true);
|
||||||
|
try {
|
||||||
|
// Use the list endpoint to find our session
|
||||||
|
const result = await consultationApi.listSessions({ page: 1, page_size: 1 });
|
||||||
|
const found = result.data.find((s) => s.id === sessionId);
|
||||||
|
if (found) setSession(found);
|
||||||
|
} catch {
|
||||||
|
// Session info is supplementary; don't block chat
|
||||||
|
}
|
||||||
|
setSessionLoading(false);
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// --- Fetch messages ---
|
||||||
|
const fetchMessages = useCallback(
|
||||||
|
async (page: number, append: boolean) => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
setMsgLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await consultationApi.listMessages(sessionId, {
|
||||||
|
page,
|
||||||
|
page_size: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
const newMsgs = result.data;
|
||||||
|
const totalPages = Math.ceil(result.total / PAGE_SIZE);
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
setMessages((prev) => [...newMsgs, ...prev]);
|
||||||
|
} else {
|
||||||
|
setMessages(newMsgs);
|
||||||
|
}
|
||||||
|
setHasMore(page < totalPages);
|
||||||
|
} catch {
|
||||||
|
message.error('加载消息失败');
|
||||||
|
}
|
||||||
|
setMsgLoading(false);
|
||||||
|
},
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSession();
|
||||||
|
fetchMessages(1, false);
|
||||||
|
}, [fetchSession, fetchMessages]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldScrollRef.current && chatEndRef.current) {
|
||||||
|
chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
|
// --- Send message ---
|
||||||
|
const handleSend = async () => {
|
||||||
|
const text = inputText.trim();
|
||||||
|
if (!text || !sessionId) return;
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
// Optimistically append to UI
|
||||||
|
const optimisticMsg: Message = {
|
||||||
|
id: `temp_${Date.now()}`,
|
||||||
|
session_id: sessionId,
|
||||||
|
sender_id: '',
|
||||||
|
sender_role: 'doctor',
|
||||||
|
content_type: 'text',
|
||||||
|
content: text,
|
||||||
|
is_read: false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, optimisticMsg]);
|
||||||
|
setInputText('');
|
||||||
|
shouldScrollRef.current = true;
|
||||||
|
|
||||||
|
await consultationApi.createMessage({
|
||||||
|
session_id: sessionId,
|
||||||
|
sender_id: '',
|
||||||
|
sender_role: 'doctor',
|
||||||
|
content_type: 'text',
|
||||||
|
content: text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh to replace optimistic message with server version
|
||||||
|
await fetchMessages(msgPage, false);
|
||||||
|
} catch {
|
||||||
|
message.error('发送失败');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Load more (older messages) ---
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
const nextPage = msgPage + 1;
|
||||||
|
setMsgPage(nextPage);
|
||||||
|
shouldScrollRef.current = false;
|
||||||
|
fetchMessages(nextPage, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Close session ---
|
||||||
|
const handleClose = async () => {
|
||||||
|
if (!session) return;
|
||||||
|
try {
|
||||||
|
const updated = await consultationApi.closeSession(session.id, {
|
||||||
|
version: session.version,
|
||||||
|
});
|
||||||
|
setSession(updated);
|
||||||
|
message.success('会话已关闭');
|
||||||
|
} catch {
|
||||||
|
message.error('关闭会话失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Render a single message bubble ---
|
||||||
|
const renderMessage = (msg: Message) => {
|
||||||
|
const align = ROLE_ALIGN[msg.sender_role] ?? 'flex-start';
|
||||||
|
|
||||||
|
// System messages: centered plain text
|
||||||
|
if (msg.sender_role === 'system') {
|
||||||
|
return (
|
||||||
|
<div key={msg.id} style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{msg.content}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImage = msg.content_type === 'image';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: align,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: '70%' }}>
|
||||||
|
{isImage ? (
|
||||||
|
<ImagePreview urls={parseImageUrls(msg.content)} width={200} />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: msg.sender_role === 'doctor' ? '#1890ff' : '#f0f0f0',
|
||||||
|
color: msg.sender_role === 'doctor' ? '#fff' : '#000',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph style={{ margin: 0, color: 'inherit' }}>
|
||||||
|
{msg.content}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{formatTime(msg.created_at)}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Full render ---
|
||||||
|
if (sessionLoading && messages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isClosed = session?.status === 'closed';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div
|
||||||
<Typography.Title level={4}>咨询详情</Typography.Title>
|
style={{
|
||||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
display: 'flex',
|
||||||
</Card>
|
flexDirection: 'column',
|
||||||
|
height: 'calc(100vh - 120px)',
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderBottom: `1px solid ${isDark ? '#1e293b' : '#f1f5f9'}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||||
|
咨询会话
|
||||||
|
</Typography.Text>
|
||||||
|
{session && (
|
||||||
|
<>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
患者: {session.patient_id.slice(0, 8)}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
医护: {session.doctor_id ? session.doctor_id.slice(0, 8) : '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
<StatusTag status={session.status} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!session && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
ID: {sessionId.slice(0, 8)}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
{session && !isClosed && (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认关闭该咨询会话?"
|
||||||
|
onConfirm={handleClose}
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<CloseCircleOutlined />}
|
||||||
|
style={{ marginLeft: 'auto' }}
|
||||||
|
>
|
||||||
|
关闭会话
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '16px 20px',
|
||||||
|
background: isDark ? '#0f172a' : '#f8fafc',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasMore && (
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ArrowUpOutlined />}
|
||||||
|
loading={msgLoading}
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msgLoading && messages.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map(renderMessage)}
|
||||||
|
|
||||||
|
<div ref={chatEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 8,
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderTop: `1px solid ${isDark ? '#1e293b' : '#f1f5f9'}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
value={inputText}
|
||||||
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
|
placeholder={isClosed ? '会话已关闭' : '输入消息...'}
|
||||||
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
|
style={{ flex: 1, borderRadius: 8 }}
|
||||||
|
onPressEnter={(e) => {
|
||||||
|
if (!e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isClosed}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={handleSend}
|
||||||
|
loading={sending}
|
||||||
|
disabled={!inputText.trim() || isClosed}
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,365 @@
|
|||||||
import { Card, Typography } from 'antd';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Select,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Space,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
theme,
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||||
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { consultationApi, type Session, type CreateSessionReq } from '../../api/health/consultations';
|
||||||
|
import { StatusTag } from './components/StatusTag';
|
||||||
|
import { PatientSelect } from './components/PatientSelect';
|
||||||
|
import { DoctorSelect } from './components/DoctorSelect';
|
||||||
|
import { ExportButton } from './components/ExportButton';
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'waiting', label: '等待中' },
|
||||||
|
{ value: 'active', label: '进行中' },
|
||||||
|
{ value: 'closed', label: '已关闭' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONSULTATION_TYPE_OPTIONS = [
|
||||||
|
{ value: 'customer_service', label: '客服咨询' },
|
||||||
|
{ value: 'medical', label: '医疗咨询' },
|
||||||
|
{ value: 'health_consultation', label: '健康咨询' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONSULTATION_TYPE_MAP: Record<string, string> = {
|
||||||
|
customer_service: '客服咨询',
|
||||||
|
medical: '医疗咨询',
|
||||||
|
health_consultation: '健康咨询',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDateTime(value: string | undefined): string {
|
||||||
|
if (!value) return '-';
|
||||||
|
return new Date(value).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function ConsultationList() {
|
export default function ConsultationList() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
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 modal
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
|
const [createForm] = Form.useForm<CreateSessionReq>();
|
||||||
|
|
||||||
|
// Close session
|
||||||
|
const [closingId, setClosingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Label caches
|
||||||
|
const [patientLabels, setPatientLabels] = useState<Record<string, string>>({});
|
||||||
|
const [doctorLabels, setDoctorLabels] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const { token: themeToken } = theme.useToken();
|
||||||
|
const isDark =
|
||||||
|
themeToken.colorBgContainer === '#111827' ||
|
||||||
|
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
|
||||||
|
|
||||||
|
// --- Data fetching ---
|
||||||
|
const fetchSessions = useCallback(async (params: { page: number; page_size: number; status?: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await consultationApi.listSessions(params);
|
||||||
|
setSessions(result.data);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载咨询列表失败');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSessions(query);
|
||||||
|
}, [query, fetchSessions]);
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
const handleFilterChange = (value: string | undefined) => {
|
||||||
|
setQuery((prev) => ({ ...prev, status: value || undefined, page: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTableChange = (pagination: TablePaginationConfig) => {
|
||||||
|
setQuery((prev) => ({
|
||||||
|
...prev,
|
||||||
|
page: pagination.current ?? 1,
|
||||||
|
page_size: pagination.pageSize ?? 20,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePatientLabel = (id: string, label: string) => {
|
||||||
|
setPatientLabels((prev) => ({ ...prev, [id]: label }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoctorLabel = (id: string, label: string) => {
|
||||||
|
setDoctorLabels((prev) => ({ ...prev, [id]: label }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
const values = await createForm.validateFields();
|
||||||
|
setCreateLoading(true);
|
||||||
|
await consultationApi.createSession(values);
|
||||||
|
message.success('咨询会话创建成功');
|
||||||
|
setCreateOpen(false);
|
||||||
|
createForm.resetFields();
|
||||||
|
fetchSessions(query);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err && typeof err === 'object' && 'errorFields' in err) return;
|
||||||
|
message.error('创建咨询会话失败');
|
||||||
|
} finally {
|
||||||
|
setCreateLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close session
|
||||||
|
const handleClose = async (session: Session) => {
|
||||||
|
setClosingId(session.id);
|
||||||
|
try {
|
||||||
|
await consultationApi.closeSession(session.id, { version: session.version });
|
||||||
|
message.success('会话已关闭');
|
||||||
|
fetchSessions(query);
|
||||||
|
} catch {
|
||||||
|
message.error('关闭会话失败');
|
||||||
|
} finally {
|
||||||
|
setClosingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Row click -> navigate to detail
|
||||||
|
const handleRowClick = (record: Session) => {
|
||||||
|
navigate(`/health/consultations/${record.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export params
|
||||||
|
const exportParams: Record<string, string> = {};
|
||||||
|
if (query.status) exportParams.status = query.status;
|
||||||
|
|
||||||
|
// --- Columns ---
|
||||||
|
const columns: ColumnsType<Session> = [
|
||||||
|
{
|
||||||
|
title: '患者',
|
||||||
|
dataIndex: 'patient_id',
|
||||||
|
key: 'patient_id',
|
||||||
|
width: 140,
|
||||||
|
render: (id: string) => patientLabels[id] || id.slice(0, 8),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '医护',
|
||||||
|
dataIndex: 'doctor_id',
|
||||||
|
key: 'doctor_id',
|
||||||
|
width: 140,
|
||||||
|
render: (id: string | undefined) =>
|
||||||
|
id ? doctorLabels[id] || id.slice(0, 8) : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '咨询类型',
|
||||||
|
dataIndex: 'consultation_type',
|
||||||
|
key: 'consultation_type',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => CONSULTATION_TYPE_MAP[v] || v,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (status: string) => <StatusTag status={status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '未读(患者/医护)',
|
||||||
|
key: 'unread',
|
||||||
|
width: 140,
|
||||||
|
render: (_: unknown, record: Session) => (
|
||||||
|
<span>
|
||||||
|
{record.unread_count_patient} / {record.unread_count_doctor}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后消息时间',
|
||||||
|
dataIndex: 'last_message_at',
|
||||||
|
key: 'last_message_at',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string | undefined) => (
|
||||||
|
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
|
||||||
|
{formatDateTime(v)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 120,
|
||||||
|
render: (_: unknown, record: Session) => (
|
||||||
|
<Space size={4}>
|
||||||
|
{record.status !== 'closed' && (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认关闭该咨询会话?"
|
||||||
|
onConfirm={() => handleClose(record)}
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<CloseCircleOutlined />}
|
||||||
|
loading={closingId === record.id}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div>
|
||||||
<Typography.Title level={4}>咨询管理</Typography.Title>
|
{/* Toolbar */}
|
||||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
<div
|
||||||
</Card>
|
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={handleFilterChange}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
createForm.resetFields();
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建会话
|
||||||
|
</Button>
|
||||||
|
<ExportButton
|
||||||
|
fetchUrl="/health/consultation-sessions/export"
|
||||||
|
params={exportParams}
|
||||||
|
filename="咨询列表.csv"
|
||||||
|
/>
|
||||||
|
<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={sessions}
|
||||||
|
loading={loading}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => handleRowClick(record),
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})}
|
||||||
|
pagination={{
|
||||||
|
current: query.page,
|
||||||
|
pageSize: query.page_size,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1010 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Session 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="doctor_id" label="医护">
|
||||||
|
<DoctorSelect
|
||||||
|
onChange={(_val, label) => handleDoctorLabel(_val, label)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="consultation_type" label="咨询类型">
|
||||||
|
<Select
|
||||||
|
options={CONSULTATION_TYPE_OPTIONS}
|
||||||
|
placeholder="选择咨询类型(默认客服咨询)"
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,358 @@
|
|||||||
import { Card, Typography } from 'antd';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Badge,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
Card,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { doctorApi, type Doctor, type CreateDoctorReq, type UpdateDoctorReq } from '../../api/health/doctors';
|
||||||
|
|
||||||
|
/** 科室选项 — 可后续改为从字典接口获取 */
|
||||||
|
const DEPARTMENT_OPTIONS = [
|
||||||
|
{ value: '全科', label: '全科' },
|
||||||
|
{ value: '内科', label: '内科' },
|
||||||
|
{ value: '外科', label: '外科' },
|
||||||
|
{ value: '儿科', label: '儿科' },
|
||||||
|
{ value: '妇产科', label: '妇产科' },
|
||||||
|
{ value: '骨科', label: '骨科' },
|
||||||
|
{ value: '眼科', label: '眼科' },
|
||||||
|
{ value: '口腔科', label: '口腔科' },
|
||||||
|
{ value: '皮肤科', label: '皮肤科' },
|
||||||
|
{ value: '中医科', label: '中医科' },
|
||||||
|
{ value: '体检中心', label: '体检中心' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TITLE_OPTIONS = [
|
||||||
|
{ value: '住院医师', label: '住院医师' },
|
||||||
|
{ value: '主治医师', label: '主治医师' },
|
||||||
|
{ value: '副主任医师', label: '副主任医师' },
|
||||||
|
{ value: '主任医师', label: '主任医师' },
|
||||||
|
{ value: '护士', label: '护士' },
|
||||||
|
{ value: '护师', label: '护师' },
|
||||||
|
{ value: '主管护师', label: '主管护师' },
|
||||||
|
{ value: '副主任护师', label: '副主任护师' },
|
||||||
|
{ value: '主任护师', label: '主任护师' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ONLINE_STATUS_MAP: Record<string, { status: 'success' | 'default' | 'processing'; text: string }> = {
|
||||||
|
online: { status: 'success', text: '在线' },
|
||||||
|
offline: { status: 'default', text: '离线' },
|
||||||
|
busy: { status: 'processing', text: '忙碌' },
|
||||||
|
};
|
||||||
|
|
||||||
export default function DoctorList() {
|
export default function DoctorList() {
|
||||||
|
const [data, setData] = useState<Doctor[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [deptFilter, setDeptFilter] = useState<string | undefined>(undefined);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Doctor | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
// ---- 数据获取 ----
|
||||||
|
const fetchData = useCallback(async (p = page, ps = pageSize) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await doctorApi.list({
|
||||||
|
page: p,
|
||||||
|
page_size: ps,
|
||||||
|
search: searchText || undefined,
|
||||||
|
department: deptFilter || undefined,
|
||||||
|
});
|
||||||
|
setData(result.data);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载医护列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, searchText, deptFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// ---- 搜索防抖 ----
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const handleSearchChange = useCallback((val: string) => {
|
||||||
|
setSearchText(val);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => setPage(1), 300);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- 新建 / 编辑 ----
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (record: Doctor) => {
|
||||||
|
setEditing(record);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: record.name,
|
||||||
|
department: record.department,
|
||||||
|
title: record.title,
|
||||||
|
specialty: record.specialty,
|
||||||
|
license_number: record.license_number,
|
||||||
|
bio: record.bio,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values: {
|
||||||
|
name: string;
|
||||||
|
department?: string;
|
||||||
|
title?: string;
|
||||||
|
specialty?: string;
|
||||||
|
license_number?: string;
|
||||||
|
bio?: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
const req: UpdateDoctorReq & { version: number } = {
|
||||||
|
name: values.name,
|
||||||
|
department: values.department,
|
||||||
|
title: values.title,
|
||||||
|
specialty: values.specialty,
|
||||||
|
license_number: values.license_number,
|
||||||
|
bio: values.bio,
|
||||||
|
version: editing.version,
|
||||||
|
};
|
||||||
|
await doctorApi.update(editing.id, req);
|
||||||
|
message.success('更新成功');
|
||||||
|
} else {
|
||||||
|
const req: CreateDoctorReq = {
|
||||||
|
name: values.name,
|
||||||
|
department: values.department,
|
||||||
|
title: values.title,
|
||||||
|
specialty: values.specialty,
|
||||||
|
license_number: values.license_number,
|
||||||
|
bio: values.bio,
|
||||||
|
};
|
||||||
|
await doctorApi.create(req);
|
||||||
|
message.success('创建成功');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
fetchData(page, pageSize);
|
||||||
|
} catch {
|
||||||
|
message.error(editing ? '更新失败' : '创建失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 删除 ----
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await doctorApi.delete(id);
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchData(page, pageSize);
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 列定义 ----
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '姓名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
width: 120,
|
||||||
|
fixed: 'left' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '科室',
|
||||||
|
dataIndex: 'department',
|
||||||
|
key: 'department',
|
||||||
|
width: 120,
|
||||||
|
render: (val: string) => val || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '职称',
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
width: 120,
|
||||||
|
render: (val: string) => val || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '专长',
|
||||||
|
dataIndex: 'specialty',
|
||||||
|
key: 'specialty',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (val: string) => val || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '执业编号',
|
||||||
|
dataIndex: 'license_number',
|
||||||
|
key: 'license_number',
|
||||||
|
width: 150,
|
||||||
|
render: (val: string) => val || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '在线状态',
|
||||||
|
dataIndex: 'online_status',
|
||||||
|
key: 'online_status',
|
||||||
|
width: 100,
|
||||||
|
render: (val: string) => {
|
||||||
|
const cfg = ONLINE_STATUS_MAP[val] || { status: 'default' as const, text: val };
|
||||||
|
return <Badge status={cfg.status} text={cfg.text} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 140,
|
||||||
|
fixed: 'right' as const,
|
||||||
|
render: (_: unknown, record: Doctor) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => openEdit(record)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除该医护?"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<Typography.Title level={4}>医护管理</Typography.Title>
|
{/* 筛选栏 */}
|
||||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
|
||||||
|
<Col flex="auto">
|
||||||
|
<Space>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索姓名"
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 220 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="筛选科室"
|
||||||
|
value={deptFilter}
|
||||||
|
onChange={(val) => {
|
||||||
|
setDeptFilter(val);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={DEPARTMENT_OPTIONS}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 160 }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||||
|
新建医护
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 数据表格 */}
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 新建 / 编辑弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑医护' : '新建医护'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
destroyOnClose
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="姓名"
|
||||||
|
rules={[{ required: true, message: '请输入姓名' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入医护姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="department" label="科室">
|
||||||
|
<Select placeholder="选择科室" options={DEPARTMENT_OPTIONS} allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="title" label="职称">
|
||||||
|
<Select placeholder="选择职称" options={TITLE_OPTIONS} allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="specialty" label="专长">
|
||||||
|
<Input.TextArea rows={2} placeholder="如:心血管疾病、糖尿病管理" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="license_number" label="执业编号">
|
||||||
|
<Input placeholder="请输入执业编号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bio" label="简介">
|
||||||
|
<Input.TextArea rows={3} placeholder="个人简介" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,418 @@
|
|||||||
import { Card, Typography } from 'antd';
|
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 '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';
|
||||||
|
|
||||||
|
/** 时段选项 */
|
||||||
|
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() {
|
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 fetchCalendar = useCallback(async () => {
|
||||||
|
if (!selectedDoctorId) {
|
||||||
|
setCalendarData([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCalendarLoading(true);
|
||||||
|
try {
|
||||||
|
const now = dayjs();
|
||||||
|
const result = await appointmentApi.calendar({
|
||||||
|
start_date: now.startOf('month').format('YYYY-MM-DD'),
|
||||||
|
end_date: now.endOf('month').format('YYYY-MM-DD'),
|
||||||
|
doctor_id: selectedDoctorId,
|
||||||
|
});
|
||||||
|
setCalendarData(result);
|
||||||
|
} catch {
|
||||||
|
message.error('加载日历数据失败');
|
||||||
|
} finally {
|
||||||
|
setCalendarLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedDoctorId]);
|
||||||
|
|
||||||
|
// 切换医护或视图模式时加载数据
|
||||||
|
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) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => openEdit(record)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- 将日历数据转换为 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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<Typography.Title level={4}>排班管理</Typography.Title>
|
{/* 顶部操作栏 */}
|
||||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
<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 && (
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||||
|
新建排班
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</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} />
|
||||||
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,224 @@
|
|||||||
import { Card, Typography } from 'antd';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Table, DatePicker, message, theme } from 'antd';
|
||||||
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { followUpApi, type FollowUpRecord } from '../../api/health/followUp';
|
||||||
|
import { PatientSelect } from './components/PatientSelect';
|
||||||
|
import { ExportButton } from './components/ExportButton';
|
||||||
|
|
||||||
|
const RESULT_MAP: Record<string, string> = {
|
||||||
|
normal: '正常',
|
||||||
|
abnormal: '异常',
|
||||||
|
unreachable: '无法联系',
|
||||||
|
refused: '拒绝随访',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QueryParams {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
task_id?: string;
|
||||||
|
patient_id?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function FollowUpRecordList() {
|
export default function FollowUpRecordList() {
|
||||||
|
const [records, setRecords] = useState<FollowUpRecord[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [query, setQuery] = useState<QueryParams>({ page: 1, page_size: 20 });
|
||||||
|
const [selectedPatient, setSelectedPatient] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const { token: themeToken } = theme.useToken();
|
||||||
|
const isDark =
|
||||||
|
themeToken.colorBgContainer === '#111827' ||
|
||||||
|
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
|
||||||
|
|
||||||
|
// --- Data fetching ---
|
||||||
|
const fetchRecords = useCallback(async (params: QueryParams) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await followUpApi.listRecords(params);
|
||||||
|
setRecords(result.data);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载随访记录失败');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRecords(query);
|
||||||
|
}, [query, fetchRecords]);
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
const handleTableChange = (pagination: TablePaginationConfig) => {
|
||||||
|
setQuery((prev) => ({
|
||||||
|
...prev,
|
||||||
|
page: pagination.current ?? 1,
|
||||||
|
page_size: pagination.pageSize ?? 20,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePatientChange = (value: string | undefined) => {
|
||||||
|
setSelectedPatient(value);
|
||||||
|
setQuery((prev) => ({
|
||||||
|
...prev,
|
||||||
|
patient_id: value || undefined,
|
||||||
|
page: 1,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateRangeChange = (
|
||||||
|
_dates: [dayjs.Dayjs | null, dayjs.Dayjs | null] | null,
|
||||||
|
dateStrings: [string, string],
|
||||||
|
) => {
|
||||||
|
setQuery((prev) => ({
|
||||||
|
...prev,
|
||||||
|
start_date: dateStrings[0] || undefined,
|
||||||
|
end_date: dateStrings[1] || undefined,
|
||||||
|
page: 1,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build export params
|
||||||
|
const exportParams: Record<string, string> = {};
|
||||||
|
if (query.patient_id) exportParams.patient_id = query.patient_id;
|
||||||
|
if (query.start_date) exportParams.start_date = query.start_date;
|
||||||
|
if (query.end_date) exportParams.end_date = query.end_date;
|
||||||
|
|
||||||
|
// --- Columns ---
|
||||||
|
const columns: ColumnsType<FollowUpRecord> = [
|
||||||
|
{
|
||||||
|
title: '任务ID',
|
||||||
|
dataIndex: 'task_id',
|
||||||
|
key: 'task_id',
|
||||||
|
width: 140,
|
||||||
|
render: (id: string) => (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
{id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '执行人',
|
||||||
|
dataIndex: 'executed_by',
|
||||||
|
key: 'executed_by',
|
||||||
|
width: 140,
|
||||||
|
render: (id: string | undefined) =>
|
||||||
|
id ? (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
{id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '执行日期',
|
||||||
|
dataIndex: 'executed_date',
|
||||||
|
key: 'executed_date',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结果',
|
||||||
|
dataIndex: 'result',
|
||||||
|
key: 'result',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string | undefined) =>
|
||||||
|
v ? RESULT_MAP[v] || v : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '患者状况',
|
||||||
|
dataIndex: 'patient_condition',
|
||||||
|
key: 'patient_condition',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string | undefined) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '医嘱',
|
||||||
|
dataIndex: 'medical_advice',
|
||||||
|
key: 'medical_advice',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string | undefined) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '下次随访日期',
|
||||||
|
dataIndex: 'next_follow_up_date',
|
||||||
|
key: 'next_follow_up_date',
|
||||||
|
width: 130,
|
||||||
|
render: (v: string | undefined) => v || '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div>
|
||||||
<Typography.Title level={4}>随访记录</Typography.Title>
|
{/* Toolbar */}
|
||||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
<div
|
||||||
</Card>
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 12,
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
onChange={handleDateRangeChange}
|
||||||
|
placeholder={['开始日期', '结束日期']}
|
||||||
|
style={{ width: 260 }}
|
||||||
|
/>
|
||||||
|
<PatientSelect
|
||||||
|
value={selectedPatient}
|
||||||
|
onChange={(val) => handlePatientChange(val)}
|
||||||
|
placeholder="筛选患者"
|
||||||
|
/>
|
||||||
|
<ExportButton
|
||||||
|
fetchUrl="/health/follow-up-records/export"
|
||||||
|
params={exportParams}
|
||||||
|
filename={`随访记录_${dayjs().format('YYYYMMDD')}.csv`}
|
||||||
|
/>
|
||||||
|
<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={records}
|
||||||
|
loading={loading}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
pagination={{
|
||||||
|
current: query.page,
|
||||||
|
pageSize: query.page_size,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1030 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,508 @@
|
|||||||
import { Card, Typography } from 'antd';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Select,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
DatePicker,
|
||||||
|
Space,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
theme,
|
||||||
|
} 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 { StatusTag } from './components/StatusTag';
|
||||||
|
import { PatientSelect } from './components/PatientSelect';
|
||||||
|
import { DoctorSelect } from './components/DoctorSelect';
|
||||||
|
|
||||||
|
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() {
|
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 { token: themeToken } = theme.useToken();
|
||||||
|
const isDark =
|
||||||
|
themeToken.colorBgContainer === '#111827' ||
|
||||||
|
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
|
} catch {
|
||||||
|
message.error('加载随访任务失败');
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
await followUpApi.createTask({
|
||||||
|
...values,
|
||||||
|
planned_date: values.planned_date,
|
||||||
|
});
|
||||||
|
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) => (
|
||||||
|
<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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div>
|
||||||
<Typography.Title level={4}>随访管理</Typography.Title>
|
{/* Toolbar */}
|
||||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
<div
|
||||||
</Card>
|
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)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
createForm.resetFields();
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建任务
|
||||||
|
</Button>
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,737 @@
|
|||||||
import { Card, Typography } from 'antd';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Tabs,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
DatePicker,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
Spin,
|
||||||
|
theme,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { patientApi } from '../../api/health/patients';
|
||||||
|
import type {
|
||||||
|
PatientDetail as PatientDetailType,
|
||||||
|
UpdatePatientReq,
|
||||||
|
} from '../../api/health/patients';
|
||||||
|
import { healthDataApi } from '../../api/health/healthData';
|
||||||
|
import type {
|
||||||
|
VitalSigns,
|
||||||
|
LabReport,
|
||||||
|
HealthRecord,
|
||||||
|
} from '../../api/health/healthData';
|
||||||
|
import { followUpApi } from '../../api/health/followUp';
|
||||||
|
import type { FollowUpRecord } from '../../api/health/followUp';
|
||||||
|
import { StatusTag } from './components/StatusTag';
|
||||||
|
import { VitalSignsChart } from './components/VitalSignsChart';
|
||||||
|
|
||||||
|
const GENDER_OPTIONS = [
|
||||||
|
{ value: 'male', label: '男' },
|
||||||
|
{ value: 'female', label: '女' },
|
||||||
|
{ value: 'other', label: '其他' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOOD_TYPE_OPTIONS = [
|
||||||
|
{ value: 'A', label: 'A 型' },
|
||||||
|
{ value: 'B', label: 'B 型' },
|
||||||
|
{ value: 'AB', label: 'AB 型' },
|
||||||
|
{ value: 'O', label: 'O 型' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GENDER_LABEL: Record<string, string> = {
|
||||||
|
male: '男',
|
||||||
|
female: '女',
|
||||||
|
other: '其他',
|
||||||
|
};
|
||||||
|
|
||||||
export default function PatientDetail() {
|
export default function PatientDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [patient, setPatient] = useState<PatientDetailType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const isDark =
|
||||||
|
token.colorBgContainer === '#111827' ||
|
||||||
|
token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||||
|
|
||||||
|
// 健康数据子 tab 的状态
|
||||||
|
const [vitalSigns, setVitalSigns] = useState<VitalSigns[]>([]);
|
||||||
|
const [vitalSignsTotal, setVitalSignsTotal] = useState(0);
|
||||||
|
const [vitalSignsPage, setVitalSignsPage] = useState(1);
|
||||||
|
const [vitalSignsLoading, setVitalSignsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [labReports, setLabReports] = useState<LabReport[]>([]);
|
||||||
|
const [labReportsTotal, setLabReportsTotal] = useState(0);
|
||||||
|
const [labReportsPage, setLabReportsPage] = useState(1);
|
||||||
|
const [labReportsLoading, setLabReportsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [healthRecords, setHealthRecords] = useState<HealthRecord[]>([]);
|
||||||
|
const [healthRecordsTotal, setHealthRecordsTotal] = useState(0);
|
||||||
|
const [healthRecordsPage, setHealthRecordsPage] = useState(1);
|
||||||
|
const [healthRecordsLoading, setHealthRecordsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 随访记录状态
|
||||||
|
const [followUpRecords, setFollowUpRecords] = useState<FollowUpRecord[]>([]);
|
||||||
|
const [followUpTotal, setFollowUpTotal] = useState(0);
|
||||||
|
const [followUpPage, setFollowUpPage] = useState(1);
|
||||||
|
const [followUpLoading, setFollowUpLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchPatient = useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await patientApi.get(id);
|
||||||
|
setPatient(data);
|
||||||
|
} catch {
|
||||||
|
message.error('加载患者信息失败');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchVitalSigns = useCallback(
|
||||||
|
async (p = vitalSignsPage) => {
|
||||||
|
if (!id) return;
|
||||||
|
setVitalSignsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await healthDataApi.listVitalSigns(id, {
|
||||||
|
page: p,
|
||||||
|
page_size: 10,
|
||||||
|
});
|
||||||
|
setVitalSigns(result.data);
|
||||||
|
setVitalSignsTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载体征数据失败');
|
||||||
|
}
|
||||||
|
setVitalSignsLoading(false);
|
||||||
|
},
|
||||||
|
[id, vitalSignsPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchLabReports = useCallback(
|
||||||
|
async (p = labReportsPage) => {
|
||||||
|
if (!id) return;
|
||||||
|
setLabReportsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await healthDataApi.listLabReports(id, {
|
||||||
|
page: p,
|
||||||
|
page_size: 10,
|
||||||
|
});
|
||||||
|
setLabReports(result.data);
|
||||||
|
setLabReportsTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载化验报告失败');
|
||||||
|
}
|
||||||
|
setLabReportsLoading(false);
|
||||||
|
},
|
||||||
|
[id, labReportsPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchHealthRecords = useCallback(
|
||||||
|
async (p = healthRecordsPage) => {
|
||||||
|
if (!id) return;
|
||||||
|
setHealthRecordsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await healthDataApi.listHealthRecords(id, {
|
||||||
|
page: p,
|
||||||
|
page_size: 10,
|
||||||
|
});
|
||||||
|
setHealthRecords(result.data);
|
||||||
|
setHealthRecordsTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载健康档案失败');
|
||||||
|
}
|
||||||
|
setHealthRecordsLoading(false);
|
||||||
|
},
|
||||||
|
[id, healthRecordsPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchFollowUpRecords = useCallback(
|
||||||
|
async (p = followUpPage) => {
|
||||||
|
if (!id) return;
|
||||||
|
setFollowUpLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await followUpApi.listRecords({
|
||||||
|
patient_id: id,
|
||||||
|
page: p,
|
||||||
|
page_size: 10,
|
||||||
|
});
|
||||||
|
setFollowUpRecords(result.data);
|
||||||
|
setFollowUpTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载随访记录失败');
|
||||||
|
}
|
||||||
|
setFollowUpLoading(false);
|
||||||
|
},
|
||||||
|
[id, followUpPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPatient();
|
||||||
|
}, [fetchPatient]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVitalSigns();
|
||||||
|
}, [fetchVitalSigns]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLabReports();
|
||||||
|
}, [fetchLabReports]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHealthRecords();
|
||||||
|
}, [fetchHealthRecords]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFollowUpRecords();
|
||||||
|
}, [fetchFollowUpRecords]);
|
||||||
|
|
||||||
|
const handleEdit = async (values: {
|
||||||
|
name?: string;
|
||||||
|
gender?: string;
|
||||||
|
birth_date?: string;
|
||||||
|
blood_type?: string;
|
||||||
|
id_number?: string;
|
||||||
|
allergy_history?: string;
|
||||||
|
medical_history_summary?: string;
|
||||||
|
emergency_contact_name?: string;
|
||||||
|
emergency_contact_phone?: string;
|
||||||
|
notes?: string;
|
||||||
|
}) => {
|
||||||
|
if (!patient) return;
|
||||||
|
try {
|
||||||
|
const req: UpdatePatientReq & { version: number } = {
|
||||||
|
...values,
|
||||||
|
version: patient.version,
|
||||||
|
};
|
||||||
|
await patientApi.update(patient.id, req);
|
||||||
|
message.success('患者信息更新成功');
|
||||||
|
setEditModalOpen(false);
|
||||||
|
fetchPatient();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMsg =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message || '更新失败';
|
||||||
|
message.error(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = () => {
|
||||||
|
if (!patient) return;
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: patient.name,
|
||||||
|
gender: patient.gender,
|
||||||
|
birth_date: patient.birth_date,
|
||||||
|
blood_type: patient.blood_type,
|
||||||
|
id_number: patient.id_number,
|
||||||
|
allergy_history: patient.allergy_history,
|
||||||
|
medical_history_summary: patient.medical_history_summary,
|
||||||
|
emergency_contact_name: patient.emergency_contact_name,
|
||||||
|
emergency_contact_phone: patient.emergency_contact_phone,
|
||||||
|
notes: patient.notes,
|
||||||
|
});
|
||||||
|
setEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 体征数据列定义
|
||||||
|
const vitalSignsColumns = [
|
||||||
|
{
|
||||||
|
title: '记录日期',
|
||||||
|
dataIndex: 'record_date',
|
||||||
|
key: 'record_date',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '收缩压(晨)',
|
||||||
|
dataIndex: 'systolic_bp_morning',
|
||||||
|
key: 'systolic_bp_morning',
|
||||||
|
width: 110,
|
||||||
|
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '舒张压(晨)',
|
||||||
|
dataIndex: 'diastolic_bp_morning',
|
||||||
|
key: 'diastolic_bp_morning',
|
||||||
|
width: 110,
|
||||||
|
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '心率',
|
||||||
|
dataIndex: 'heart_rate',
|
||||||
|
key: 'heart_rate',
|
||||||
|
width: 80,
|
||||||
|
render: (v?: number) => (v != null ? `${v} bpm` : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '体重',
|
||||||
|
dataIndex: 'weight',
|
||||||
|
key: 'weight',
|
||||||
|
width: 80,
|
||||||
|
render: (v?: number) => (v != null ? `${v} kg` : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '血糖',
|
||||||
|
dataIndex: 'blood_sugar',
|
||||||
|
key: 'blood_sugar',
|
||||||
|
width: 80,
|
||||||
|
render: (v?: number) => (v != null ? `${v} mmol/L` : '-'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 化验报告列定义
|
||||||
|
const labReportColumns = [
|
||||||
|
{
|
||||||
|
title: '报告日期',
|
||||||
|
dataIndex: 'report_date',
|
||||||
|
key: 'report_date',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '报告类型',
|
||||||
|
dataIndex: 'report_type',
|
||||||
|
key: 'report_type',
|
||||||
|
width: 120,
|
||||||
|
render: (v: string) => <Tag>{v}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '医生解读',
|
||||||
|
dataIndex: 'doctor_interpretation',
|
||||||
|
key: 'doctor_interpretation',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 170,
|
||||||
|
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 健康档案列定义
|
||||||
|
const healthRecordColumns = [
|
||||||
|
{
|
||||||
|
title: '记录类型',
|
||||||
|
dataIndex: 'record_type',
|
||||||
|
key: 'record_type',
|
||||||
|
width: 120,
|
||||||
|
render: (v: string) => <Tag>{v}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '记录日期',
|
||||||
|
dataIndex: 'record_date',
|
||||||
|
key: 'record_date',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '内容',
|
||||||
|
dataIndex: 'content',
|
||||||
|
key: 'content',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 170,
|
||||||
|
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 随访记录列定义
|
||||||
|
const followUpColumns = [
|
||||||
|
{
|
||||||
|
title: '执行日期',
|
||||||
|
dataIndex: 'executed_date',
|
||||||
|
key: 'executed_date',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '随访结果',
|
||||||
|
dataIndex: 'result',
|
||||||
|
key: 'result',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '患者状况',
|
||||||
|
dataIndex: 'patient_condition',
|
||||||
|
key: 'patient_condition',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '医嘱',
|
||||||
|
dataIndex: 'medical_advice',
|
||||||
|
key: 'medical_advice',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '下次随访日期',
|
||||||
|
dataIndex: 'next_follow_up_date',
|
||||||
|
key: 'next_follow_up_date',
|
||||||
|
width: 130,
|
||||||
|
render: (v?: string) => v || '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!patient) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||||
|
<p>未找到患者信息</p>
|
||||||
|
<Button onClick={() => navigate('/health/patients')}>返回列表</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div>
|
||||||
<Typography.Title level={4}>患者详情</Typography.Title>
|
{/* 顶部导航 */}
|
||||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
<div
|
||||||
</Card>
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate('/health/patients')}
|
||||||
|
type="text"
|
||||||
|
>
|
||||||
|
返回列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 患者基本信息卡片 */}
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'linear-gradient(135deg, #0ea5e9, #38bdf8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(patient.name?.[0] || 'P').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 18 }}>{patient.name}</div>
|
||||||
|
<Space size={8} style={{ marginTop: 4 }}>
|
||||||
|
<StatusTag status={patient.status} />
|
||||||
|
<StatusTag status={patient.verification_status} />
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button icon={<EditOutlined />} onClick={openEditModal}>
|
||||||
|
编辑信息
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Descriptions column={3} size="small">
|
||||||
|
<Descriptions.Item label="性别">
|
||||||
|
{GENDER_LABEL[patient.gender || ''] || patient.gender || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="出生日期">
|
||||||
|
{patient.birth_date || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="血型">
|
||||||
|
{patient.blood_type || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="身份证号">
|
||||||
|
{patient.id_number || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">
|
||||||
|
{patient.source || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间">
|
||||||
|
{patient.created_at
|
||||||
|
? new Date(patient.created_at).toLocaleString('zh-CN')
|
||||||
|
: '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 标签页 */}
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="info"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'info',
|
||||||
|
label: '基本信息',
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<Descriptions
|
||||||
|
column={2}
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="姓名">
|
||||||
|
{patient.name}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="性别">
|
||||||
|
{GENDER_LABEL[patient.gender || ''] ||
|
||||||
|
patient.gender ||
|
||||||
|
'-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="出生日期">
|
||||||
|
{patient.birth_date || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="血型">
|
||||||
|
{patient.blood_type || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="身份证号">
|
||||||
|
{patient.id_number || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<StatusTag status={patient.status} />
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="认证状态">
|
||||||
|
<StatusTag status={patient.verification_status} />
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">
|
||||||
|
{patient.source || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="过敏史" span={2}>
|
||||||
|
{patient.allergy_history || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="病史摘要" span={2}>
|
||||||
|
{patient.medical_history_summary || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="紧急联系人">
|
||||||
|
{patient.emergency_contact_name || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="紧急联系电话">
|
||||||
|
{patient.emergency_contact_phone || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注" span={2}>
|
||||||
|
{patient.notes || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'health',
|
||||||
|
label: '健康数据',
|
||||||
|
children: (
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="vital"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'vital',
|
||||||
|
label: '体征数据',
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
{id && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<VitalSignsChart patientId={id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Table
|
||||||
|
columns={vitalSignsColumns}
|
||||||
|
dataSource={vitalSigns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={vitalSignsLoading}
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
current: vitalSignsPage,
|
||||||
|
total: vitalSignsTotal,
|
||||||
|
pageSize: 10,
|
||||||
|
onChange: (p) => setVitalSignsPage(p),
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
style: { margin: 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lab',
|
||||||
|
label: '化验报告',
|
||||||
|
children: (
|
||||||
|
<Table
|
||||||
|
columns={labReportColumns}
|
||||||
|
dataSource={labReports}
|
||||||
|
rowKey="id"
|
||||||
|
loading={labReportsLoading}
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
current: labReportsPage,
|
||||||
|
total: labReportsTotal,
|
||||||
|
pageSize: 10,
|
||||||
|
onChange: (p) => setLabReportsPage(p),
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
style: { margin: 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'records',
|
||||||
|
label: '健康档案',
|
||||||
|
children: (
|
||||||
|
<Table
|
||||||
|
columns={healthRecordColumns}
|
||||||
|
dataSource={healthRecords}
|
||||||
|
rowKey="id"
|
||||||
|
loading={healthRecordsLoading}
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
current: healthRecordsPage,
|
||||||
|
total: healthRecordsTotal,
|
||||||
|
pageSize: 10,
|
||||||
|
onChange: (p) => setHealthRecordsPage(p),
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
style: { margin: 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'followup',
|
||||||
|
label: '随访记录',
|
||||||
|
children: (
|
||||||
|
<Table
|
||||||
|
columns={followUpColumns}
|
||||||
|
dataSource={followUpRecords}
|
||||||
|
rowKey="id"
|
||||||
|
loading={followUpLoading}
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
current: followUpPage,
|
||||||
|
total: followUpTotal,
|
||||||
|
pageSize: 10,
|
||||||
|
onChange: (p) => setFollowUpPage(p),
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
style: { margin: 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 编辑弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="编辑患者信息"
|
||||||
|
open={editModalOpen}
|
||||||
|
onCancel={() => setEditModalOpen(false)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onFinish={handleEdit}
|
||||||
|
layout="vertical"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="姓名"
|
||||||
|
rules={[{ required: true, message: '请输入患者姓名' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="gender" label="性别" style={{ flex: 1 }}>
|
||||||
|
<Select options={GENDER_OPTIONS} placeholder="请选择" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="blood_type" label="血型" style={{ flex: 1 }}>
|
||||||
|
<Select
|
||||||
|
options={BLOOD_TYPE_OPTIONS}
|
||||||
|
placeholder="请选择"
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Form.Item name="birth_date" label="出生日期">
|
||||||
|
<DatePicker style={{ width: '100%' }} placeholder="请选择" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="id_number" label="身份证号">
|
||||||
|
<Input placeholder="请输入身份证号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="allergy_history" label="过敏史">
|
||||||
|
<Input.TextArea rows={2} placeholder="请输入过敏史" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="medical_history_summary" label="病史摘要">
|
||||||
|
<Input.TextArea rows={2} placeholder="请输入病史摘要" />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="emergency_contact_name"
|
||||||
|
label="紧急联系人"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<Input placeholder="联系人姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="emergency_contact_phone"
|
||||||
|
label="紧急联系电话"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<Input placeholder="联系电话" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Form.Item name="notes" label="备注">
|
||||||
|
<Input.TextArea rows={2} placeholder="请输入备注" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,400 @@
|
|||||||
import { Card, Typography } from 'antd';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Popconfirm,
|
||||||
|
DatePicker,
|
||||||
|
message,
|
||||||
|
theme,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { patientApi } from '../../api/health/patients';
|
||||||
|
import type {
|
||||||
|
PatientListItem,
|
||||||
|
CreatePatientReq,
|
||||||
|
UpdatePatientReq,
|
||||||
|
} from '../../api/health/patients';
|
||||||
|
import { StatusTag } from './components/StatusTag';
|
||||||
|
|
||||||
|
const GENDER_OPTIONS = [
|
||||||
|
{ value: 'male', label: '男' },
|
||||||
|
{ value: 'female', label: '女' },
|
||||||
|
{ value: 'other', label: '其他' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOOD_TYPE_OPTIONS = [
|
||||||
|
{ value: 'A', label: 'A 型' },
|
||||||
|
{ value: 'B', label: 'B 型' },
|
||||||
|
{ value: 'AB', label: 'AB 型' },
|
||||||
|
{ value: 'O', label: 'O 型' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: 'active', label: '活跃' },
|
||||||
|
{ value: 'inactive', label: '停用' },
|
||||||
|
{ value: 'deceased', label: '已故' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function PatientList() {
|
export default function PatientList() {
|
||||||
|
const [patients, setPatients] = useState<PatientListItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingPatient, setEditingPatient] = useState<PatientListItem | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const isDark =
|
||||||
|
token.colorBgContainer === '#111827' ||
|
||||||
|
token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const fetchPatients = useCallback(
|
||||||
|
async (p = page) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await patientApi.list({
|
||||||
|
page: p,
|
||||||
|
page_size: 20,
|
||||||
|
search: searchText || undefined,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
});
|
||||||
|
setPatients(result.data);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载患者列表失败');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
[page, searchText, statusFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const debouncedSearch = useCallback(() => {
|
||||||
|
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||||
|
debounceTimer.current = setTimeout(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, 300);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPatients();
|
||||||
|
}, [fetchPatients]);
|
||||||
|
|
||||||
|
const handleCreateOrEdit = async (values: {
|
||||||
|
name: string;
|
||||||
|
gender?: string;
|
||||||
|
birth_date?: string;
|
||||||
|
blood_type?: string;
|
||||||
|
id_number?: string;
|
||||||
|
allergy_history?: string;
|
||||||
|
notes?: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
if (editingPatient) {
|
||||||
|
const req: UpdatePatientReq & { version: number } = {
|
||||||
|
...values,
|
||||||
|
version: (editingPatient as PatientListItem & { version?: number }).version ?? 0,
|
||||||
|
};
|
||||||
|
await patientApi.update(editingPatient.id, req);
|
||||||
|
message.success('患者信息更新成功');
|
||||||
|
} else {
|
||||||
|
const req: CreatePatientReq = values;
|
||||||
|
await patientApi.create(req);
|
||||||
|
message.success('患者创建成功');
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
fetchPatients();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMsg =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message || '操作失败';
|
||||||
|
message.error(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const patient = patients.find((p) => p.id === id);
|
||||||
|
const version = (patient as PatientListItem & { version?: number })?.version ?? 0;
|
||||||
|
await patientApi.delete(id, version);
|
||||||
|
message.success('患者已删除');
|
||||||
|
fetchPatients();
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setEditingPatient(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (record: PatientListItem) => {
|
||||||
|
setEditingPatient(record);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: record.name,
|
||||||
|
gender: record.gender,
|
||||||
|
birth_date: record.birth_date,
|
||||||
|
blood_type: record.blood_type,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditingPatient(null);
|
||||||
|
form.resetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '姓名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (name: string, record: PatientListItem) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'linear-gradient(135deg, #0ea5e9, #38bdf8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(name?.[0] || 'P').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 14 }}>{name}</div>
|
||||||
|
{record.source && (
|
||||||
|
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8' }}>
|
||||||
|
来源: {record.source}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '性别',
|
||||||
|
dataIndex: 'gender',
|
||||||
|
key: 'gender',
|
||||||
|
width: 80,
|
||||||
|
render: (v?: string) => {
|
||||||
|
if (!v) return '-';
|
||||||
|
const map: Record<string, string> = { male: '男', female: '女', other: '其他' };
|
||||||
|
return map[v] || v;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '出生日期',
|
||||||
|
dataIndex: 'birth_date',
|
||||||
|
key: 'birth_date',
|
||||||
|
width: 120,
|
||||||
|
render: (v?: string) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '血型',
|
||||||
|
dataIndex: 'blood_type',
|
||||||
|
key: 'blood_type',
|
||||||
|
width: 80,
|
||||||
|
render: (v?: string) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (status: string) => <StatusTag status={status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '认证状态',
|
||||||
|
dataIndex: 'verification_status',
|
||||||
|
key: 'verification_status',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => <StatusTag status={v} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '来源',
|
||||||
|
dataIndex: 'source',
|
||||||
|
key: 'source',
|
||||||
|
width: 100,
|
||||||
|
render: (v?: string) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 170,
|
||||||
|
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 140,
|
||||||
|
render: (_: unknown, record: PatientListItem) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openEditModal(record);
|
||||||
|
}}
|
||||||
|
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||||
|
/>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此患者?"
|
||||||
|
onConfirm={(e) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
handleDelete(record.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
danger
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div>
|
||||||
<Typography.Title level={4}>患者管理</Typography.Title>
|
{/* 页面标题和工具栏 */}
|
||||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
<div className="erp-page-header">
|
||||||
</Card>
|
<div>
|
||||||
|
<h4>患者管理</h4>
|
||||||
|
<div className="erp-page-subtitle">
|
||||||
|
管理患者档案、基本信息和认证状态
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Space size={8}>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索患者姓名..."
|
||||||
|
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchText(e.target.value);
|
||||||
|
debouncedSearch();
|
||||||
|
}}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 200, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStatusFilter(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
style={{ width: 130, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||||
|
新建患者
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格容器 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={patients}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => navigate(`/health/patients/${record.id}`),
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
total,
|
||||||
|
pageSize: 20,
|
||||||
|
onChange: (p) => {
|
||||||
|
setPage(p);
|
||||||
|
fetchPatients(p);
|
||||||
|
},
|
||||||
|
showTotal: (t) => `共 ${t} 条记录`,
|
||||||
|
style: { padding: '12px 16px', margin: 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 新建/编辑患者弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={editingPatient ? '编辑患者' : '新建患者'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={closeModal}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onFinish={handleCreateOrEdit}
|
||||||
|
layout="vertical"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="姓名"
|
||||||
|
rules={[{ required: true, message: '请输入患者姓名' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="gender" label="性别">
|
||||||
|
<Select options={GENDER_OPTIONS} placeholder="请选择性别" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="birth_date" label="出生日期">
|
||||||
|
<DatePicker style={{ width: '100%' }} placeholder="请选择出生日期" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="blood_type" label="血型">
|
||||||
|
<Select options={BLOOD_TYPE_OPTIONS} placeholder="请选择血型" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="id_number" label="身份证号">
|
||||||
|
<Input placeholder="请输入身份证号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="allergy_history" label="过敏史">
|
||||||
|
<Input.TextArea rows={2} placeholder="请输入过敏史" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="notes" label="备注">
|
||||||
|
<Input.TextArea rows={2} placeholder="请输入备注" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,270 @@
|
|||||||
import { Card, Typography } from 'antd';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Modal,
|
||||||
|
Input,
|
||||||
|
Tag,
|
||||||
|
Card,
|
||||||
|
message,
|
||||||
|
theme,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { TagsOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||||
|
import { patientApi } from '../../api/health/patients';
|
||||||
|
import type { PatientListItem } from '../../api/health/patients';
|
||||||
|
|
||||||
export default function PatientTagManage() {
|
export default function PatientTagManage() {
|
||||||
|
const [patients, setPatients] = useState<PatientListItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tagModalOpen, setTagModalOpen] = useState(false);
|
||||||
|
const [selectedPatient, setSelectedPatient] = useState<PatientListItem | null>(null);
|
||||||
|
const [tagInput, setTagInput] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const isDark =
|
||||||
|
token.colorBgContainer === '#111827' ||
|
||||||
|
token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||||
|
|
||||||
|
const fetchPatients = useCallback(
|
||||||
|
async (p = page) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await patientApi.list({ page: p, page_size: 20 });
|
||||||
|
setPatients(result.data);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载患者列表失败');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
[page],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPatients();
|
||||||
|
}, [fetchPatients]);
|
||||||
|
|
||||||
|
const openTagModal = (record: PatientListItem) => {
|
||||||
|
setSelectedPatient(record);
|
||||||
|
setTagInput('');
|
||||||
|
setTagModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTags = async () => {
|
||||||
|
if (!selectedPatient) return;
|
||||||
|
const tagIds = tagInput
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await patientApi.manageTags(selectedPatient.id, tagIds);
|
||||||
|
message.success('标签更新成功');
|
||||||
|
setTagModalOpen(false);
|
||||||
|
setTagInput('');
|
||||||
|
fetchPatients();
|
||||||
|
} catch {
|
||||||
|
message.error('标签更新失败');
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '患者姓名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (name: string) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'linear-gradient(135deg, #0ea5e9, #38bdf8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(name?.[0] || 'P').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontWeight: 500 }}>{name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '性别',
|
||||||
|
dataIndex: 'gender',
|
||||||
|
key: 'gender',
|
||||||
|
width: 80,
|
||||||
|
render: (v?: string) => {
|
||||||
|
if (!v) return '-';
|
||||||
|
const map: Record<string, string> = { male: '男', female: '女', other: '其他' };
|
||||||
|
return map[v] || v;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标签',
|
||||||
|
dataIndex: 'tags',
|
||||||
|
key: 'tags',
|
||||||
|
render: (_: unknown, record: PatientListItem) => {
|
||||||
|
const tagIds = (record as PatientListItem & { tag_ids?: string[] }).tag_ids;
|
||||||
|
if (!tagIds || tagIds.length === 0) {
|
||||||
|
return <span style={{ color: isDark ? '#475569' : '#CBD5E1' }}>暂无标签</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{tagIds.map((t) => (
|
||||||
|
<Tag
|
||||||
|
key={t}
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#0f172a' : '#f0f9ff',
|
||||||
|
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
|
||||||
|
color: isDark ? '#7dd3fc' : '#0369a1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (status: string) => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
active: 'green',
|
||||||
|
inactive: 'default',
|
||||||
|
deceased: 'default',
|
||||||
|
};
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
active: '活跃',
|
||||||
|
inactive: '停用',
|
||||||
|
deceased: '已故',
|
||||||
|
};
|
||||||
|
return <Tag color={colorMap[status] || 'default'}>{labelMap[status] || status}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, record: PatientListItem) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<TagsOutlined />}
|
||||||
|
onClick={() => openTagModal(record)}
|
||||||
|
>
|
||||||
|
管理标签
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div>
|
||||||
<Typography.Title level={4}>标签管理</Typography.Title>
|
{/* 说明卡片 */}
|
||||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
<Card
|
||||||
</Card>
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||||
|
<AppstoreOutlined
|
||||||
|
style={{ fontSize: 20, color: '#0ea5e9', marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong style={{ fontSize: 14 }}>
|
||||||
|
标签管理说明
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Paragraph
|
||||||
|
type="secondary"
|
||||||
|
style={{ margin: '4px 0 0', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
标签通过患者管理页面进行关联。您可以在下方列表中为每位患者管理标签,输入标签
|
||||||
|
ID(逗号分隔)进行批量设置。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="erp-page-header">
|
||||||
|
<div>
|
||||||
|
<h4>标签管理</h4>
|
||||||
|
<div className="erp-page-subtitle">为患者分配和管理分类标签</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格容器 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={patients}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
total,
|
||||||
|
pageSize: 20,
|
||||||
|
onChange: (p) => {
|
||||||
|
setPage(p);
|
||||||
|
fetchPatients(p);
|
||||||
|
},
|
||||||
|
showTotal: (t) => `共 ${t} 条记录`,
|
||||||
|
style: { padding: '12px 16px', margin: 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签管理弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={`管理标签 - ${selectedPatient?.name || ''}`}
|
||||||
|
open={tagModalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setTagModalOpen(false);
|
||||||
|
setTagInput('');
|
||||||
|
}}
|
||||||
|
onOk={handleSaveTags}
|
||||||
|
confirmLoading={saving}
|
||||||
|
okText="保存"
|
||||||
|
width={440}
|
||||||
|
>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
请输入标签 ID,多个标签用英文逗号分隔。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Input
|
||||||
|
placeholder="例如: tag-001, tag-002, tag-003"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onPressEnter={handleSaveTags}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Avatar, Typography, Space } from 'antd';
|
import { Avatar, Typography } from 'antd';
|
||||||
import { UserOutlined } from '@ant-design/icons';
|
import { UserOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
Reference in New Issue
Block a user