feat(web): 健康管理模块 10 页面完整实现
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled

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:
iven
2026-04-25 00:57:48 +08:00
parent 02c96d9b45
commit 0c21f13e72
12 changed files with 3976 additions and 45 deletions

View File

@@ -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 {

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,4 +1,4 @@
import { Avatar, Typography, Space } from 'antd';
import { Avatar, Typography } from 'antd';
import { UserOutlined } from '@ant-design/icons';
interface Props {