perf(web): ConsultationList/FollowUpTaskList 移除 N+1 nameCache

后端已内联 patient_name/doctor_name,前端移除逐条查询。
Session/FollowUpTask 接口添加 name 可选字段。
FollowUpTaskList 保留 assignee 的 getUser 查询(users 表未内联)。
This commit is contained in:
iven
2026-04-27 09:47:37 +08:00
parent c6856370c6
commit f934ca0eaf
4 changed files with 36 additions and 105 deletions

View File

@@ -6,6 +6,8 @@ export interface Session {
id: string; id: string;
patient_id: string; patient_id: string;
doctor_id?: string; doctor_id?: string;
patient_name?: string;
doctor_name?: string;
consultation_type: string; consultation_type: string;
status: string; status: string;
last_message_at?: string; last_message_at?: string;

View File

@@ -6,6 +6,7 @@ export interface FollowUpTask {
id: string; id: string;
patient_id: string; patient_id: string;
assigned_to?: string; assigned_to?: string;
patient_name?: string;
follow_up_type: string; follow_up_type: string;
planned_date: string; planned_date: string;
status: string; status: string;

View File

@@ -13,8 +13,6 @@ import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { consultationApi, type Session, type CreateSessionReq } from '../../api/health/consultations'; import { consultationApi, type Session, type CreateSessionReq } from '../../api/health/consultations';
import { patientApi } from '../../api/health/patients';
import { doctorApi } from '../../api/health/doctors';
import { StatusTag } from './components/StatusTag'; import { StatusTag } from './components/StatusTag';
import { PatientSelect } from './components/PatientSelect'; import { PatientSelect } from './components/PatientSelect';
import { DoctorSelect } from './components/DoctorSelect'; import { DoctorSelect } from './components/DoctorSelect';
@@ -69,10 +67,6 @@ export default function ConsultationList() {
// Close session // Close session
const [closingId, setClosingId] = useState<string | null>(null); const [closingId, setClosingId] = useState<string | null>(null);
// Label caches
const [patientLabels, setPatientLabels] = useState<Record<string, string>>({});
const [doctorLabels, setDoctorLabels] = useState<Record<string, string>>({});
const isDark = useThemeMode(); const isDark = useThemeMode();
// --- Data fetching --- // --- Data fetching ---
@@ -82,48 +76,12 @@ export default function ConsultationList() {
const result = await consultationApi.listSessions(params); const result = await consultationApi.listSessions(params);
setSessions(result.data); setSessions(result.data);
setTotal(result.total); setTotal(result.total);
// 批量解析患者名称
const patientIds = [...new Set(result.data.map((s) => s.patient_id))];
const missingPatientIds = patientIds.filter((id) => !patientLabels[id]);
if (missingPatientIds.length > 0) {
const newLabels: Record<string, string> = {};
await Promise.all(
missingPatientIds.map(async (id) => {
try {
const detail = await patientApi.get(id);
newLabels[id] = detail.name;
} catch {
newLabels[id] = id.slice(0, 8);
}
}),
);
setPatientLabels((prev) => ({ ...prev, ...newLabels }));
}
// 批量解析医生名称
const doctorIds = [...new Set(result.data.map((s) => s.doctor_id).filter(Boolean))] as string[];
const missingDoctorIds = doctorIds.filter((id) => !doctorLabels[id]);
if (missingDoctorIds.length > 0) {
const newLabels: Record<string, string> = {};
await Promise.all(
missingDoctorIds.map(async (id) => {
try {
const detail = await doctorApi.get(id);
newLabels[id] = detail.name;
} catch {
newLabels[id] = id.slice(0, 8);
}
}),
);
setDoctorLabels((prev) => ({ ...prev, ...newLabels }));
}
} catch { } catch {
message.error('加载咨询列表失败'); message.error('加载咨询列表失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [patientLabels, doctorLabels]); }, []);
useEffect(() => { useEffect(() => {
fetchSessions(query); fetchSessions(query);
@@ -142,14 +100,6 @@ export default function ConsultationList() {
})); }));
}; };
const handlePatientLabel = (id: string, label: string) => {
setPatientLabels((prev) => ({ ...prev, [id]: label }));
};
const handleDoctorLabel = (id: string, label: string) => {
setDoctorLabels((prev) => ({ ...prev, [id]: label }));
};
// Create session // Create session
const handleCreate = async () => { const handleCreate = async () => {
try { try {
@@ -195,18 +145,19 @@ export default function ConsultationList() {
const columns: ColumnsType<Session> = [ const columns: ColumnsType<Session> = [
{ {
title: '患者', title: '患者',
dataIndex: 'patient_id', dataIndex: 'patient_name',
key: 'patient_id', key: 'patient_name',
width: 140, width: 140,
render: (id: string) => patientLabels[id] || id.slice(0, 8), render: (_: unknown, record: Session) =>
record.patient_name ?? record.patient_id.slice(0, 8),
}, },
{ {
title: '医护', title: '医护',
dataIndex: 'doctor_id', dataIndex: 'doctor_name',
key: 'doctor_id', key: 'doctor_name',
width: 140, width: 140,
render: (id: string | undefined) => render: (_: unknown, record: Session) =>
id ? doctorLabels[id] || id.slice(0, 8) : '-', record.doctor_name ?? record.doctor_id?.slice(0, 8) ?? '-',
}, },
{ {
title: '咨询类型', title: '咨询类型',
@@ -383,14 +334,10 @@ export default function ConsultationList() {
label="患者" label="患者"
rules={[{ required: true, message: '请选择患者' }]} rules={[{ required: true, message: '请选择患者' }]}
> >
<PatientSelect <PatientSelect />
onChange={(_val, label) => handlePatientLabel(_val, label)}
/>
</Form.Item> </Form.Item>
<Form.Item name="doctor_id" label="医护"> <Form.Item name="doctor_id" label="医护">
<DoctorSelect <DoctorSelect />
onChange={(_val, label) => handleDoctorLabel(_val, label)}
/>
</Form.Item> </Form.Item>
<Form.Item name="consultation_type" label="咨询类型"> <Form.Item name="consultation_type" label="咨询类型">
<Select <Select

View File

@@ -15,7 +15,6 @@ import { PlusOutlined, EditOutlined, SwapOutlined, DeleteOutlined } from '@ant-d
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type UpdateFollowUpTaskReq } from '../../api/health/followUp'; import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type UpdateFollowUpTaskReq } from '../../api/health/followUp';
import { patientApi } from '../../api/health/patients';
import { getUser } from '../../api/users'; import { getUser } from '../../api/users';
import { StatusTag } from './components/StatusTag'; import { StatusTag } from './components/StatusTag';
import { PatientSelect } from './components/PatientSelect'; import { PatientSelect } from './components/PatientSelect';
@@ -93,8 +92,7 @@ export default function FollowUpTaskList() {
const [assignForm] = Form.useForm<AssignFormValues>(); const [assignForm] = Form.useForm<AssignFormValues>();
const [assignTask, setAssignTask] = useState<FollowUpTask | null>(null); const [assignTask, setAssignTask] = useState<FollowUpTask | null>(null);
// Patient/doctor label cache for display // Doctor label cache (for assignee display from users table)
const [patientLabels, setPatientLabels] = useState<Record<string, string>>({});
const [doctorLabels, setDoctorLabels] = useState<Record<string, string>>({}); const [doctorLabels, setDoctorLabels] = useState<Record<string, string>>({});
const isDark = useThemeMode(); const isDark = useThemeMode();
@@ -107,41 +105,29 @@ export default function FollowUpTaskList() {
setTasks(result.data); setTasks(result.data);
setTotal(result.total); setTotal(result.total);
// Batch resolve patient names // Batch resolve assignee names (from users table, not inlined by backend)
const patientIds = [...new Set(result.data.map((t: FollowUpTask) => t.patient_id).filter(Boolean))];
const newLabels: Record<string, string> = {};
await Promise.allSettled(
patientIds.map(async (id: string) => {
try {
const p = await patientApi.get(id);
newLabels[id] = p.name;
} catch { /* skip */ }
}),
);
if (Object.keys(newLabels).length > 0) {
setPatientLabels((prev) => ({ ...prev, ...newLabels }));
}
// Batch resolve assignee names
const assigneeIds = [...new Set(result.data.map((t: FollowUpTask) => t.assigned_to).filter((x): x is string => !!x))]; const assigneeIds = [...new Set(result.data.map((t: FollowUpTask) => t.assigned_to).filter((x): x is string => !!x))];
const newDoctorLabels: Record<string, string> = {}; const missingIds = assigneeIds.filter((id) => !doctorLabels[id]);
await Promise.allSettled( if (missingIds.length > 0) {
assigneeIds.map(async (id) => { const newDoctorLabels: Record<string, string> = {};
try { await Promise.allSettled(
const u = await getUser(id); missingIds.map(async (id) => {
newDoctorLabels[id] = u.display_name || u.username; try {
} catch { /* skip */ } const u = await getUser(id);
}), newDoctorLabels[id] = u.display_name || u.username;
); } catch { /* skip */ }
if (Object.keys(newDoctorLabels).length > 0) { }),
setDoctorLabels((prev) => ({ ...prev, ...newDoctorLabels })); );
if (Object.keys(newDoctorLabels).length > 0) {
setDoctorLabels((prev) => ({ ...prev, ...newDoctorLabels }));
}
} }
} catch { } catch {
message.error('加载随访任务失败'); message.error('加载随访任务失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [doctorLabels]);
useEffect(() => { useEffect(() => {
fetchTasks(query); fetchTasks(query);
@@ -260,10 +246,6 @@ export default function FollowUpTaskList() {
}; };
// Store labels from selects // Store labels from selects
const handlePatientLabel = (id: string, label: string) => {
setPatientLabels((prev) => ({ ...prev, [id]: label }));
};
const handleDoctorLabel = (id: string, label: string) => { const handleDoctorLabel = (id: string, label: string) => {
setDoctorLabels((prev) => ({ ...prev, [id]: label })); setDoctorLabels((prev) => ({ ...prev, [id]: label }));
}; };
@@ -272,10 +254,11 @@ export default function FollowUpTaskList() {
const columns: ColumnsType<FollowUpTask> = [ const columns: ColumnsType<FollowUpTask> = [
{ {
title: '患者', title: '患者',
dataIndex: 'patient_id', dataIndex: 'patient_name',
key: 'patient_id', key: 'patient_name',
width: 140, width: 140,
render: (id: string) => patientLabels[id] || id.slice(0, 8), render: (_: unknown, record: FollowUpTask) =>
record.patient_name ?? record.patient_id.slice(0, 8),
}, },
{ {
title: '随访类型', title: '随访类型',
@@ -445,9 +428,7 @@ export default function FollowUpTaskList() {
label="患者" label="患者"
rules={[{ required: true, message: '请选择患者' }]} rules={[{ required: true, message: '请选择患者' }]}
> >
<PatientSelect <PatientSelect />
onChange={(_val, label) => handlePatientLabel(_val, label)}
/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="follow_up_type" name="follow_up_type"