Files
hms/apps/web/src/pages/health/ConsultationList.tsx
iven 63ead0c442 refactor(web): 新增 useDictionary hook + 4 个页面下拉选项改用字典 API
- 新增 useDictionary hook 支持字典 API 获取 + fallback 降级
- DoctorList 科室/职称改用 useDictionary (health_department/health_title)
- FollowUpTaskList 随访类型改用 useDictionary (health_follow_up_type)
- ConsultationList 咨询类型改用 useDictionary (health_consultation_type)
- FamilyMembersTab 家庭关系改用 useDictionary (health_relationship)
2026-05-02 11:27:11 +08:00

334 lines
9.6 KiB
TypeScript

import { useState, useCallback } from 'react';
import {
Table,
Select,
Button,
Modal,
Form,
Space,
Popconfirm,
message,
DatePicker,
} from 'antd';
import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import { useNavigate, useSearchParams } 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';
import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { EntityName } from '../../components/EntityName';
import { formatDateTime } from '../../utils/format';
import { usePaginatedData } from '../../hooks/usePaginatedData';
import { useDictionary } from '../../hooks/useDictionary';
const STATUS_OPTIONS = [
{ value: 'waiting', label: '等待中' },
{ value: 'active', label: '进行中' },
{ value: 'closed', label: '已关闭' },
];
const CONSULTATION_TYPE_FALLBACK = [
{ value: 'customer_service', label: '客服咨询' },
{ value: 'medical', label: '医疗咨询' },
{ value: 'health_consultation', label: '健康咨询' },
];
const CONSULTATION_TYPE_MAP: Record<string, string> = {
customer_service: '客服咨询',
medical: '医疗咨询',
health_consultation: '健康咨询',
};
interface ConsultationFilters {
status?: string;
dateRange?: [string, string];
}
export default function ConsultationList() {
const { options: CONSULTATION_TYPE_OPTIONS } = useDictionary('health_consultation_type', CONSULTATION_TYPE_FALLBACK);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const urlPatientId = searchParams.get('patient_id');
// Close session
const [closingId, setClosingId] = useState<string | null>(null);
// Create modal
const [createOpen, setCreateOpen] = useState(false);
const [createLoading, setCreateLoading] = useState(false);
const [createForm] = Form.useForm<CreateSessionReq>();
// --- Paginated data with usePaginatedData ---
const fetchFn = useCallback(
async (page: number, pageSize: number, filters: ConsultationFilters) => {
const params: Record<string, unknown> = { page, page_size: pageSize };
if (filters.status) params.status = filters.status;
if (urlPatientId) params.patient_id = urlPatientId;
if (filters.dateRange) {
params.created_start = filters.dateRange[0];
params.created_end = filters.dateRange[1];
}
return consultationApi.listSessions(params as Parameters<typeof consultationApi.listSessions>[0]);
},
[urlPatientId],
);
const {
data: sessions,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<Session, ConsultationFilters>(fetchFn, {
pageSize: 20,
defaultFilters: {},
});
// --- Handlers ---
const handleTableChange = (pagination: TablePaginationConfig) => {
refresh(pagination.current ?? 1);
};
// Create session
const handleCreate = async () => {
try {
const values = await createForm.validateFields();
setCreateLoading(true);
await consultationApi.createSession(values);
message.success('咨询会话创建成功');
setCreateOpen(false);
createForm.resetFields();
refresh(page);
} 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('会话已关闭');
refresh(page);
} 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 (filters.status) exportParams.status = filters.status;
// --- Columns ---
const columns: ColumnsType<Session> = [
{
title: '患者',
dataIndex: 'patient_name',
key: 'patient_name',
width: 140,
render: (_: unknown, record: Session) => (
<EntityName name={record.patient_name} id={record.patient_id} />
),
},
{
title: '医护',
dataIndex: 'doctor_name',
key: 'doctor_name',
width: 140,
render: (_: unknown, record: Session) => (
<EntityName name={record.doctor_name} id={record.doctor_id} fallbackLabel="未分配" />
),
},
{
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) => 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) => (
<AuthButton code="health.consultation.manage">
<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>
</AuthButton>
),
},
];
return (
<PageContainer
title="咨询管理"
subtitle={`${total}`}
filters={
<>
<Select
allowClear
placeholder="状态筛选"
style={{ width: 140 }}
options={STATUS_OPTIONS}
value={filters.status}
onChange={(value) => setFilters((prev) => ({ ...prev, status: value }))}
/>
<DatePicker.RangePicker
style={{ width: 240 }}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setFilters((prev) => ({
...prev,
dateRange: [dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')],
}));
} else {
setFilters((prev) => ({ ...prev, dateRange: undefined }));
}
}}
/>
</>
}
onResetFilters={() => setFilters({})}
actions={
<Space>
<AuthButton code="health.consultation.manage">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
setCreateOpen(true);
}}
>
</Button>
</AuthButton>
<ExportButton
fetchUrl="/health/consultation-sessions/export"
params={exportParams}
filename="咨询列表.csv"
/>
</Space>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={sessions}
loading={loading}
onChange={handleTableChange}
onRow={(record) => ({
onClick: () => handleRowClick(record),
style: { cursor: 'pointer' },
})}
pagination={{
current: page,
pageSize: 20,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 1010 }}
/>
{/* 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 />
</Form.Item>
<Form.Item name="doctor_id" label="医护">
<DoctorSelect />
</Form.Item>
<Form.Item name="consultation_type" label="咨询类型">
<Select
options={CONSULTATION_TYPE_OPTIONS}
placeholder="选择咨询类型(默认客服咨询)"
allowClear
/>
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}