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_doctor: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
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() {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
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 (
|
||||
<Card>
|
||||
<Typography.Title level={4}>咨询详情</Typography.Title>
|
||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
||||
</Card>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
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() {
|
||||
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 (
|
||||
<Card>
|
||||
<Typography.Title level={4}>咨询管理</Typography.Title>
|
||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
||||
</Card>
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 160 }}
|
||||
options={STATUS_OPTIONS}
|
||||
value={query.status}
|
||||
onChange={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() {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
// ---- 状态 ----
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
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 (
|
||||
<Card>
|
||||
<Typography.Title level={4}>随访记录</Typography.Title>
|
||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
||||
</Card>
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
}}
|
||||
>
|
||||
<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() {
|
||||
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 (
|
||||
<Card>
|
||||
<Typography.Title level={4}>随访管理</Typography.Title>
|
||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
||||
</Card>
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 160 }}
|
||||
options={STATUS_OPTIONS}
|
||||
value={query.status}
|
||||
onChange={(value) => handleFilterChange('status', value)}
|
||||
/>
|
||||
<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() {
|
||||
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 (
|
||||
<Card>
|
||||
<Typography.Title level={4}>患者详情</Typography.Title>
|
||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
||||
</Card>
|
||||
<div>
|
||||
{/* 顶部导航 */}
|
||||
<div
|
||||
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() {
|
||||
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 (
|
||||
<Card>
|
||||
<Typography.Title level={4}>患者管理</Typography.Title>
|
||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
||||
</Card>
|
||||
<div>
|
||||
{/* 页面标题和工具栏 */}
|
||||
<div className="erp-page-header">
|
||||
<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() {
|
||||
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 (
|
||||
<Card>
|
||||
<Typography.Title level={4}>标签管理</Typography.Title>
|
||||
<Typography.Text type="secondary">开发中</Typography.Text>
|
||||
</Card>
|
||||
<div>
|
||||
{/* 说明卡片 */}
|
||||
<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';
|
||||
|
||||
interface Props {
|
||||
|
||||
Reference in New Issue
Block a user