P0 (CRITICAL): - C1: 统计 API 全部改为 safe_aggregate 容错,防止单个子查询崩溃导致 500 - C2: Token 刷新增加用户身份验证,防止并发场景下身份切换 - C3: 患者端线下活动接口添加患者档案验证,防止 Doctor/HM 越权访问 P1 (HIGH): - H1: 操作记录用 EntityName 组件解析用户名,不再显示截断 UUID - H4: 告警标题添加中英文映射 (translateAlertTitle) - H5: 告警面板补全 message import + 修复 hooks 顺序 - H8: 咨询消息发送按钮添加 AuthButton 权限控制 - H9: routeConfig 日常监测权限码改为 health.daily-monitoring.* P2 (MEDIUM): - M4: 咨询类型映射补全 online/phone/doctor/follow_up 中文标签 DTO: LabReportStatisticsResp, AppointmentStatisticsResp, VitalSignsReportRateResp 添加 Default derive
362 lines
10 KiB
TypeScript
362 lines
10 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: "健康咨询" },
|
|
{ value: "online", label: "在线咨询" },
|
|
{ value: "phone", label: "电话咨询" },
|
|
{ value: "doctor", label: "医生咨询" },
|
|
{ value: "follow_up", label: "随访咨询" },
|
|
];
|
|
|
|
const CONSULTATION_TYPE_MAP: Record<string, string> = {
|
|
customer_service: "客服咨询",
|
|
medical: "医疗咨询",
|
|
health_consultation: "健康咨询",
|
|
online: "在线咨询",
|
|
phone: "电话咨询",
|
|
doctor: "医生咨询",
|
|
follow_up: "随访咨询",
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|