Files
hms/apps/web/src/pages/health/ConsultationList.tsx
iven 22b8ac7ac6
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix: 修复多角色找茬测试 V2 发现的 11 个问题
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
2026-05-08 12:42:41 +08:00

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