From 0c21f13e722b811533fbaab6b3f7a38ca0ddbb3f Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 00:57:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=81=A5=E5=BA=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=A8=A1=E5=9D=97=2010=20=E9=A1=B5=E9=9D=A2=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/api/health/consultations.ts | 2 + apps/web/src/pages/health/AppointmentList.tsx | 373 ++++++++- .../src/pages/health/ConsultationDetail.tsx | 372 ++++++++- .../web/src/pages/health/ConsultationList.tsx | 365 ++++++++- apps/web/src/pages/health/DoctorList.tsx | 354 ++++++++- apps/web/src/pages/health/DoctorSchedule.tsx | 414 +++++++++- .../src/pages/health/FollowUpRecordList.tsx | 224 +++++- .../web/src/pages/health/FollowUpTaskList.tsx | 508 +++++++++++- apps/web/src/pages/health/PatientDetail.tsx | 737 +++++++++++++++++- apps/web/src/pages/health/PatientList.tsx | 400 +++++++++- .../web/src/pages/health/PatientTagManage.tsx | 270 ++++++- .../pages/health/components/ChatBubble.tsx | 2 +- 12 files changed, 3976 insertions(+), 45 deletions(-) diff --git a/apps/web/src/api/health/consultations.ts b/apps/web/src/api/health/consultations.ts index 8ebc2dc..f9f33a3 100644 --- a/apps/web/src/api/health/consultations.ts +++ b/apps/web/src/api/health/consultations.ts @@ -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 { diff --git a/apps/web/src/pages/health/AppointmentList.tsx b/apps/web/src/pages/health/AppointmentList.tsx index 7cb46b7..b5e7539 100644 --- a/apps/web/src/pages/health/AppointmentList.tsx +++ b/apps/web/src/pages/health/AppointmentList.tsx @@ -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 = { + 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 = { + 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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [loading, setLoading] = useState(false); + const [statusFilter, setStatusFilter] = useState(undefined); + const [dateFilter, setDateFilter] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [form] = Form.useForm(); + + // 患者选择状态(受控组件,不挂在 Form.Item 上) + const [selectedPatientId, setSelectedPatientId] = useState(undefined); + const [selectedDoctorId, setSelectedDoctorId] = useState(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).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).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) => , + }, + { + 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 无可用操作; + } + return ( + ({ + key: t.value, + label: t.label, + onClick: () => handleStatusChange(record, t.value), + })), + }} + > + + + ); + }, + }, + ]; + return ( - 预约排班 - 开发中 + {/* 筛选栏 */} + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/apps/web/src/pages/health/ConsultationDetail.tsx b/apps/web/src/pages/health/ConsultationDetail.tsx index 8715878..556a9d1 100644 --- a/apps/web/src/pages/health/ConsultationDetail.tsx +++ b/apps/web/src/pages/health/ConsultationDetail.tsx @@ -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 = { + 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(null); + const [sessionLoading, setSessionLoading] = useState(true); + + // Messages + const [messages, setMessages] = useState([]); + 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(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 ( +
+ + {msg.content} + +
+ ); + } + + const isImage = msg.content_type === 'image'; + + return ( +
+
+ {isImage ? ( + + ) : ( +
+ + {msg.content} + +
+ )} + + {formatTime(msg.created_at)} + +
+
+ ); + }; + + // --- Full render --- + if (sessionLoading && messages.length === 0) { + return ( +
+ +
+ ); + } + + const isClosed = session?.status === 'closed'; + return ( - - 咨询详情 - 开发中 - +
+ {/* Top bar */} +
+ + 咨询会话 + + {session && ( + <> + + 患者: {session.patient_id.slice(0, 8)} + + + 医护: {session.doctor_id ? session.doctor_id.slice(0, 8) : '-'} + + + + )} + {!session && ( + + ID: {sessionId.slice(0, 8)} + + )} + {session && !isClosed && ( + + + + )} +
+ + {/* Chat area */} +
+ {hasMore && ( +
+ +
+ )} + + {msgLoading && messages.length === 0 && ( +
+ +
+ )} + + {messages.map(renderMessage)} + +
+
+ + {/* Input area */} +
+ 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} + /> + +
+
); } diff --git a/apps/web/src/pages/health/ConsultationList.tsx b/apps/web/src/pages/health/ConsultationList.tsx index a2e52b4..d4b2ee0 100644 --- a/apps/web/src/pages/health/ConsultationList.tsx +++ b/apps/web/src/pages/health/ConsultationList.tsx @@ -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 = { + 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([]); + 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(); + + // Close session + const [closingId, setClosingId] = useState(null); + + // Label caches + const [patientLabels, setPatientLabels] = useState>({}); + const [doctorLabels, setDoctorLabels] = useState>({}); + + 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 = {}; + if (query.status) exportParams.status = query.status; + + // --- Columns --- + const columns: ColumnsType = [ + { + 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) => , + }, + { + title: '未读(患者/医护)', + key: 'unread', + width: 140, + render: (_: unknown, record: Session) => ( + + {record.unread_count_patient} / {record.unread_count_doctor} + + ), + }, + { + title: '最后消息时间', + dataIndex: 'last_message_at', + key: 'last_message_at', + width: 160, + render: (v: string | undefined) => ( + + {formatDateTime(v)} + + ), + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + width: 160, + render: (v: string) => ( + + {formatDateTime(v)} + + ), + }, + { + title: '操作', + key: 'actions', + width: 120, + render: (_: unknown, record: Session) => ( + + {record.status !== 'closed' && ( + handleClose(record)} + okText="确认" + cancelText="取消" + > + + + )} + + ), + }, + ]; + return ( - - 咨询管理 - 开发中 - +
+ {/* Toolbar */} +
+ + + + +
); } diff --git a/apps/web/src/pages/health/DoctorList.tsx b/apps/web/src/pages/health/DoctorList.tsx index 0b59087..90af7e0 100644 --- a/apps/web/src/pages/health/DoctorList.tsx +++ b/apps/web/src/pages/health/DoctorList.tsx @@ -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 = { + online: { status: 'success', text: '在线' }, + offline: { status: 'default', text: '离线' }, + busy: { status: 'processing', text: '忙碌' }, +}; export default function DoctorList() { + const [data, setData] = useState([]); + 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(undefined); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(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 | 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 ; + }, + }, + { + 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) => ( + + + handleDelete(record.id)} + okText="确定" + cancelText="取消" + > + + + + ), + }, + ]; + return ( - 医护管理 - 开发中 + {/* 筛选栏 */} + + + + } + value={searchText} + onChange={(e) => handleSearchChange(e.target.value)} + allowClear + style={{ width: 220 }} + /> + + + + + + + + + + + + + + + + + + + + ); } diff --git a/apps/web/src/pages/health/DoctorSchedule.tsx b/apps/web/src/pages/health/DoctorSchedule.tsx index a8e72bb..8cc0379 100644 --- a/apps/web/src/pages/health/DoctorSchedule.tsx +++ b/apps/web/src/pages/health/DoctorSchedule.tsx @@ -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 = { + am: '上午', + pm: '下午', +}; + +/** 排班状态选项 */ +const SCHEDULE_STATUS_OPTIONS = [ + { value: 'active', label: '启用' }, + { value: 'inactive', label: '停用' }, + { value: 'cancelled', label: '已取消' }, +]; export default function DoctorSchedule() { + // ---- 状态 ---- + const [selectedDoctorId, setSelectedDoctorId] = useState(undefined); + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [loading, setLoading] = useState(false); + + // 视图模式 + const [viewMode, setViewMode] = useState('列表'); + + // 日历数据 + const [calendarData, setCalendarData] = useState([]); + const [calendarLoading, setCalendarLoading] = useState(false); + + // 弹窗 + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(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 ( + + {record.current_appointments}/{record.max_appointments} + + ); + }, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 90, + render: (val: string) => , + }, + { + title: '操作', + key: 'action', + width: 120, + render: (_: unknown, record: Schedule) => ( + + + + ), + }, + ]; + + // ---- 将日历数据转换为 CalendarView 所需格式 ---- + const calendarScheduleMap: Record = {}; + 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 ( - 排班管理 - 开发中 + {/* 顶部操作栏 */} + + + + 选择医护: + + {selectedDoctorId && ( + setViewMode(val as string)} + options={['列表', '日历']} + /> + )} + + + + + {selectedDoctorId && ( + + )} + + + + {/* 内容区 */} + {!selectedDoctorId ? ( + + ) : viewMode === '列表' ? ( + `共 ${t} 条`, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + ) : ( + +
+ +
+
+ )} + + {/* 新建 / 编辑排班弹窗 */} + { + setModalOpen(false); + form.resetFields(); + }} + onOk={() => form.submit()} + destroyOnClose + width={520} + > +
+ +
+ + + + + + + + + + )} + + + ); } diff --git a/apps/web/src/pages/health/FollowUpRecordList.tsx b/apps/web/src/pages/health/FollowUpRecordList.tsx index c3cae64..4a32763 100644 --- a/apps/web/src/pages/health/FollowUpRecordList.tsx +++ b/apps/web/src/pages/health/FollowUpRecordList.tsx @@ -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 = { + 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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [query, setQuery] = useState({ page: 1, page_size: 20 }); + const [selectedPatient, setSelectedPatient] = useState(); + + 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 = {}; + 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 = [ + { + title: '任务ID', + dataIndex: 'task_id', + key: 'task_id', + width: 140, + render: (id: string) => ( + + {id.slice(0, 8)} + + ), + }, + { + title: '执行人', + dataIndex: 'executed_by', + key: 'executed_by', + width: 140, + render: (id: string | undefined) => + id ? ( + + {id.slice(0, 8)} + + ) : ( + '-' + ), + }, + { + 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 ( - - 随访记录 - 开发中 - +
+ {/* Toolbar */} +
+ + handlePatientChange(val)} + placeholder="筛选患者" + /> + + + 共 {total} 条 + +
+ + {/* Table */} +
+
`共 ${t} 条`, + }} + scroll={{ x: 1030 }} + /> + + ); } diff --git a/apps/web/src/pages/health/FollowUpTaskList.tsx b/apps/web/src/pages/health/FollowUpTaskList.tsx index 8ce4ff2..c523ac5 100644 --- a/apps/web/src/pages/health/FollowUpTaskList.tsx +++ b/apps/web/src/pages/health/FollowUpTaskList.tsx @@ -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 = { + 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([]); + 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(); + + // Fill record modal + const [recordOpen, setRecordOpen] = useState(false); + const [recordLoading, setRecordLoading] = useState(false); + const [recordForm] = Form.useForm(); + const [activeTask, setActiveTask] = useState(null); + + // Assign modal + const [assignOpen, setAssignOpen] = useState(false); + const [assignLoading, setAssignLoading] = useState(false); + const [assignForm] = Form.useForm(); + const [assignTask, setAssignTask] = useState(null); + + // Patient/doctor label cache for display + const [patientLabels, setPatientLabels] = useState>({}); + const [doctorLabels, setDoctorLabels] = useState>({}); + + 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 = [ + { + 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) => , + }, + { + 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) => ( + + {formatDateTime(v)} + + ), + }, + { + title: '操作', + key: 'actions', + width: 220, + render: (_: unknown, record: FollowUpTask) => ( + + + + handleDelete(record)} + okText="确认" + cancelText="取消" + > + + + + ), + }, + ]; + return ( - - 随访管理 - 开发中 - +
+ {/* Toolbar */} +
+
`共 ${t} 条`, + }} + scroll={{ x: 980 }} + /> + + + {/* Create Task Modal */} + setCreateOpen(false)} + confirmLoading={createLoading} + okText="创建" + cancelText="取消" + destroyOnClose + > +
+ + handlePatientLabel(_val, label)} + /> + + +
setVitalSignsPage(p), + showTotal: (t) => `共 ${t} 条`, + style: { margin: 0 }, + }} + /> + + ), + }, + { + key: 'lab', + label: '化验报告', + children: ( +
setLabReportsPage(p), + showTotal: (t) => `共 ${t} 条`, + style: { margin: 0 }, + }} + /> + ), + }, + { + key: 'records', + label: '健康档案', + children: ( +
setHealthRecordsPage(p), + showTotal: (t) => `共 ${t} 条`, + style: { margin: 0 }, + }} + /> + ), + }, + ]} + /> + ), + }, + { + key: 'followup', + label: '随访记录', + children: ( +
setFollowUpPage(p), + showTotal: (t) => `共 ${t} 条`, + style: { margin: 0 }, + }} + /> + ), + }, + ]} + /> + + + {/* 编辑弹窗 */} + setEditModalOpen(false)} + onOk={() => form.submit()} + width={600} + > + + + + +
+ + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + + +
+ ); } diff --git a/apps/web/src/pages/health/PatientList.tsx b/apps/web/src/pages/health/PatientList.tsx index bf02c2c..d131529 100644 --- a/apps/web/src/pages/health/PatientList.tsx +++ b/apps/web/src/pages/health/PatientList.tsx @@ -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([]); + 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(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 | 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) => ( +
+
+ {(name?.[0] || 'P').toUpperCase()} +
+
+
{name}
+ {record.source && ( +
+ 来源: {record.source} +
+ )} +
+
+ ), + }, + { + title: '性别', + dataIndex: 'gender', + key: 'gender', + width: 80, + render: (v?: string) => { + if (!v) return '-'; + const map: Record = { 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) => , + }, + { + title: '认证状态', + dataIndex: 'verification_status', + key: 'verification_status', + width: 100, + render: (v: string) => , + }, + { + 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) => ( + +
({ + 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 }, + }} + /> + + + {/* 新建/编辑患者弹窗 */} + form.submit()} + width={520} + > +
+ + + + + + + + + + + + + + + + +
+ ); } diff --git a/apps/web/src/pages/health/PatientTagManage.tsx b/apps/web/src/pages/health/PatientTagManage.tsx index 608e96a..b7b43f0 100644 --- a/apps/web/src/pages/health/PatientTagManage.tsx +++ b/apps/web/src/pages/health/PatientTagManage.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [tagModalOpen, setTagModalOpen] = useState(false); + const [selectedPatient, setSelectedPatient] = useState(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) => ( +
+
+ {(name?.[0] || 'P').toUpperCase()} +
+ {name} +
+ ), + }, + { + title: '性别', + dataIndex: 'gender', + key: 'gender', + width: 80, + render: (v?: string) => { + if (!v) return '-'; + const map: Record = { 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 暂无标签; + } + return ( + + {tagIds.map((t) => ( + + {t} + + ))} + + ); + }, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status: string) => { + const colorMap: Record = { + active: 'green', + inactive: 'default', + deceased: 'default', + }; + const labelMap: Record = { + active: '活跃', + inactive: '停用', + deceased: '已故', + }; + return {labelMap[status] || status}; + }, + }, + { + title: '操作', + key: 'actions', + width: 120, + render: (_: unknown, record: PatientListItem) => ( + + ), + }, + ]; + return ( - - 标签管理 - 开发中 - +
+ {/* 说明卡片 */} + +
+ +
+ + 标签管理说明 + + + 标签通过患者管理页面进行关联。您可以在下方列表中为每位患者管理标签,输入标签 + ID(逗号分隔)进行批量设置。 + +
+
+
+ + {/* 页面标题 */} +
+
+

标签管理

+
为患者分配和管理分类标签
+
+
+ + {/* 表格容器 */} +
+
{ + setPage(p); + fetchPatients(p); + }, + showTotal: (t) => `共 ${t} 条记录`, + style: { padding: '12px 16px', margin: 0 }, + }} + /> + + + {/* 标签管理弹窗 */} + { + setTagModalOpen(false); + setTagInput(''); + }} + onOk={handleSaveTags} + confirmLoading={saving} + okText="保存" + width={440} + > +
+ + 请输入标签 ID,多个标签用英文逗号分隔。 + + setTagInput(e.target.value)} + onPressEnter={handleSaveTags} + /> +
+
+ ); } diff --git a/apps/web/src/pages/health/components/ChatBubble.tsx b/apps/web/src/pages/health/components/ChatBubble.tsx index fde1913..8078a8e 100644 --- a/apps/web/src/pages/health/components/ChatBubble.tsx +++ b/apps/web/src/pages/health/components/ChatBubble.tsx @@ -1,4 +1,4 @@ -import { Avatar, Typography, Space } from 'antd'; +import { Avatar, Typography } from 'antd'; import { UserOutlined } from '@ant-design/icons'; interface Props {