fix(admin): 补全 React hooks 依赖并修复页面稳定性

- 各页面 useCallback/useMemo 依赖补全(modal/mutation/setter 等),避免闭包过期
- ECharts 使用 optionRef、MainLayout 缓存菜单转换函数、main.tsx 增加挂载点校验
- 学生同步结果改用 recordsCount 展示
This commit is contained in:
2026-08-05 21:14:08 +08:00
parent ab4765cee5
commit 1a1e90c72f
21 changed files with 439 additions and 341 deletions

View File

@@ -271,7 +271,7 @@ export function useAiChatMessageActions({
}, },
}); });
}, },
[activeId, isRequesting, refreshConversations, removeMessage], [activeId, isRequesting, refreshConversations, removeMessage, modal],
); );
const confirmEditMessage = useCallback( const confirmEditMessage = useCallback(

View File

@@ -50,13 +50,15 @@ interface EChartsProps {
const ECharts: React.FC<EChartsProps> = ({ option, style, className, onReady }) => { const ECharts: React.FC<EChartsProps> = ({ option, style, className, onReady }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const optionRef = useRef(option);
optionRef.current = option;
const onReadyRef = useRef(onReady); const onReadyRef = useRef(onReady);
onReadyRef.current = onReady; onReadyRef.current = onReady;
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const chart = echarts.init(containerRef.current); const chart = echarts.init(containerRef.current);
chart.setOption(option); chart.setOption(optionRef.current);
onReadyRef.current?.(chart); onReadyRef.current?.(chart);
const observer = new ResizeObserver(() => chart.resize()); const observer = new ResizeObserver(() => chart.resize());
observer.observe(containerRef.current); observer.observe(containerRef.current);

View File

@@ -62,6 +62,6 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
}, },
}); });
}, },
[studentId, module], [studentId, module, modal],
); );
} }

View File

@@ -169,10 +169,11 @@ const MainLayout: React.FC = () => {
navigate(key); navigate(key);
if (usesDrawer) setDrawerOpen(false); if (usesDrawer) setDrawerOpen(false);
}, },
[navigate, usesDrawer], [navigate, usesDrawer, setDrawerOpen],
); );
const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => { const findSelectedKeys = useCallback(
(items: AppMenuItem[], pathname: string): string[] => {
for (const item of items) { for (const item of items) {
if (item.key === pathname) return [item.key]; if (item.key === pathname) return [item.key];
if (item.children) { if (item.children) {
@@ -181,7 +182,9 @@ const MainLayout: React.FC = () => {
} }
} }
return [pathname]; return [pathname];
}; },
[],
);
const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => { const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => {
for (const item of items) { for (const item of items) {
@@ -201,7 +204,7 @@ const MainLayout: React.FC = () => {
const selectedKeys = useMemo( const selectedKeys = useMemo(
() => findSelectedKeys(menuItems, location.pathname), () => findSelectedKeys(menuItems, location.pathname),
[menuItems, location.pathname], [menuItems, location.pathname, findSelectedKeys],
); );
// 路径变化时同步展开的菜单(不干扰用户手动展开/收起) // 路径变化时同步展开的菜单(不干扰用户手动展开/收起)
useEffect(() => { useEffect(() => {
@@ -210,20 +213,20 @@ const MainLayout: React.FC = () => {
const routeOpenKeys = findOpenKeys(menuItems, location.pathname); const routeOpenKeys = findOpenKeys(menuItems, location.pathname);
setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]); setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]);
} }
}, [location.pathname, menuItems]); }, [location.pathname, menuItems, setOpenKeys]);
const handleOpenChange = useCallback((keys: string[]) => { const handleOpenChange = useCallback((keys: string[]) => {
setOpenKeys(keys); setOpenKeys(keys);
}, []); }, [setOpenKeys]);
const transformToMenuItems = (items: AppMenuItem[]): any[] => { const transformToMenuItems = useCallback((items: AppMenuItem[]): any[] => {
return items.map((item) => ({ return items.map((item) => ({
key: item.key, key: item.key,
icon: item.icon ? iconMap[item.icon] : undefined, icon: item.icon ? iconMap[item.icon] : undefined,
label: item.label, label: item.label,
children: item.children ? transformToMenuItems(item.children) : undefined, children: item.children ? transformToMenuItems(item.children) : undefined,
})); }));
}; }, []);
const menuContent = useMemo( const menuContent = useMemo(
() => ( () => (
<Menu <Menu
@@ -237,7 +240,7 @@ const MainLayout: React.FC = () => {
style={{ border: 'none' }} style={{ border: 'none' }}
/> />
), ),
[selectedKeys, openKeys, menuItems, handleMenuClick], [selectedKeys, openKeys, menuItems, handleMenuClick, handleOpenChange, transformToMenuItems],
); );
return ( return (

View File

@@ -37,7 +37,10 @@ const queryClient = new QueryClient({
}, },
}); });
ReactDOM.createRoot(document.getElementById('root')!).render( const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('未找到 #root 挂载点');
ReactDOM.createRoot(rootElement).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<App /> <App />

View File

@@ -276,7 +276,7 @@ const AiConfigPage: React.FC = () => {
} finally { } finally {
setTesting(false); setTesting(false);
} }
}, [formValues, form, currentProvider]); }, [formValues, form, currentProvider, refreshConfig]);
// ── Clear key ── // ── Clear key ──
@@ -299,7 +299,7 @@ const AiConfigPage: React.FC = () => {
} }
}, },
}); });
}, [config, modal]); }, [config, modal, clearKeyMutation]);
// ── Step navigation ── // ── Step navigation ──

View File

@@ -115,10 +115,11 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
enabled: !!classId && !!attendanceDate, enabled: !!classId && !!attendanceDate,
queryFn: async () => { queryFn: async () => {
try { try {
if (!attendanceDate) return [];
return validateResponse<HistoryScheduleOption[]>( return validateResponse<HistoryScheduleOption[]>(
attendanceScheduleOptionsSchema, attendanceScheduleOptionsSchema,
await api.get<HistoryScheduleOption[]>('/attendance-records/schedules', { await api.get<HistoryScheduleOption[]>('/attendance-records/schedules', {
params: { classId, date: attendanceDate!.format('YYYY-MM-DD') }, params: { classId, date: attendanceDate.format('YYYY-MM-DD') },
}), }),
); );
} catch (error: unknown) { } catch (error: unknown) {
@@ -214,7 +215,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
if (effectiveScheduleId) params.scheduleId = effectiveScheduleId; if (effectiveScheduleId) params.scheduleId = effectiveScheduleId;
return params; return params;
}, },
[page, pageSize, classId, attendanceDate, status, session, scheduleId], [page, pageSize, classId, attendanceDate, status, session, effectiveScheduleId],
); );
const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery<DingTalkSyncStatus | null>({ const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery<DingTalkSyncStatus | null>({
@@ -334,7 +335,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
} finally { } finally {
setRefreshingDingTalk(false); setRefreshingDingTalk(false);
} }
}, [attendanceDate, classId, loadRecords, loadSyncStatus, session]); }, [attendanceDate, classId, loadRecords, loadSyncStatus, session, refreshDingTalkMutation]);
const resetFilters = () => { const resetFilters = () => {
setClassId(undefined); setClassId(undefined);

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { import {
App, App,
Table, Table,
@@ -139,7 +139,7 @@ const BillsPage: React.FC = () => {
} }
}; };
const showDetail = async (id: number) => { const showDetail = useCallback(async (id: number) => {
setDetailLoading(true); setDetailLoading(true);
try { try {
const res = await api.get(`/bills/${id}`); const res = await api.get(`/bills/${id}`);
@@ -149,9 +149,10 @@ const BillsPage: React.FC = () => {
} finally { } finally {
setDetailLoading(false); setDetailLoading(false);
} }
}; }, []);
const handleCancel = async (id: number) => { const handleCancel = useCallback(
(id: number) => {
let reason = ''; let reason = '';
modal.confirm({ modal.confirm({
title: '取消账单并退回已扣余额', title: '取消账单并退回已扣余额',
@@ -175,18 +176,24 @@ const BillsPage: React.FC = () => {
message.success('账单已取消,已扣余额已冲正退回'); message.success('账单已取消,已扣余额已冲正退回');
}, },
}); });
}; },
[modal, cancelMutation],
);
const handleArchive = async (id: number) => { const handleArchive = useCallback(
async (id: number) => {
try { try {
await archiveMutation.mutateAsync(id); await archiveMutation.mutateAsync(id);
message.success('账单已归档'); message.success('账单已归档');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
}; },
[archiveMutation],
);
const handlePurge = (id: number, studentName: string, period: string) => { const handlePurge = useCallback(
(id: number, studentName: string, period: string) => {
modal.confirm({ modal.confirm({
title: `永久删除账单(${studentName} ${period}`, title: `永久删除账单(${studentName} ${period}`,
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?', content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
@@ -202,7 +209,9 @@ const BillsPage: React.FC = () => {
} }
}, },
}); });
}; },
[modal, purgeMutation],
);
const batchArchive = async () => { const batchArchive = async () => {
if (selectedRows.length === 0) return message.warning('请先选择账单'); if (selectedRows.length === 0) return message.warning('请先选择账单');
@@ -226,7 +235,7 @@ const BillsPage: React.FC = () => {
); );
}; };
const handleExportPdf = async (billId: number) => { const handleExportPdf = useCallback(async (billId: number) => {
const printWindow = window.open('', '_blank'); const printWindow = window.open('', '_blank');
if (!printWindow) { if (!printWindow) {
message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试'); message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试');
@@ -245,7 +254,7 @@ const BillsPage: React.FC = () => {
printWindow.close(); printWindow.close();
message.error(error?.message || '账单加载失败'); message.error(error?.message || '账单加载失败');
} }
}; }, []);
const columns = useMemo( const columns = useMemo(
() => [ () => [

View File

@@ -89,33 +89,6 @@ const ClassesPage: React.FC = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const handleArchive = async (id: number, archive: boolean) => {
try {
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (record: ClassItem) => {
modal.confirm({
title: `永久删除班级「${record.name}」?`,
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const { const {
data = [], data = [],
isLoading, isLoading,
@@ -160,6 +133,39 @@ const ClassesPage: React.FC = () => {
{ invalidate: [['classes']] }, { invalidate: [['classes']] },
); );
const handleArchive = useCallback(
async (id: number, archive: boolean) => {
try {
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[archiveMutation],
);
const handlePurge = useCallback(
(record: ClassItem) => {
modal.confirm({
title: `永久删除班级「${record.name}」?`,
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!searchText) return data; if (!searchText) return data;
const q = searchText.toLowerCase(); const q = searchText.toLowerCase();
@@ -174,7 +180,8 @@ const ClassesPage: React.FC = () => {
setModalOpen(true); setModalOpen(true);
}; };
const handleEdit = (record: ClassItem) => { const handleEdit = useCallback(
(record: ClassItem) => {
setEditing(record); setEditing(record);
form.setFieldsValue({ form.setFieldsValue({
...record, ...record,
@@ -183,7 +190,9 @@ const ClassesPage: React.FC = () => {
endDate: record.endDate ? dayjs(record.endDate) : undefined, endDate: record.endDate ? dayjs(record.endDate) : undefined,
}); });
setModalOpen(true); setModalOpen(true);
}; },
[form],
);
const handleSubmit = async () => { const handleSubmit = async () => {
setSaving(true); setSaving(true);
@@ -369,7 +378,7 @@ const ClassesPage: React.FC = () => {
), ),
}, },
], ],
[saveCell, canPurgeClass, handlePurge], [saveCell, canPurgeClass, handlePurge, navigate, handleEdit, handleArchive],
); );
return ( return (

View File

@@ -198,7 +198,7 @@ const ClassroomRentalsPage: React.FC = () => {
} }
} }
}, },
[], [setUnavailableDates],
); );
const handleClassroomChange = (classroomId: number) => { const handleClassroomChange = (classroomId: number) => {

View File

@@ -65,7 +65,7 @@ const ClassroomSchedulePage: React.FC = () => {
for (const c of data.classrooms) { for (const c of data.classrooms) {
const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}` : ''}`; const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}` : ''}`;
if (!map.has(key)) map.set(key, []); if (!map.has(key)) map.set(key, []);
map.get(key)!.push(c); map.get(key)?.push(c);
} }
return Array.from(map.entries()).map(([name, classrooms]) => ({ name, classrooms })); return Array.from(map.entries()).map(([name, classrooms]) => ({ name, classrooms }));
}, [data]); }, [data]);

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation'; import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate'; import { validateResponse } from '../../utils/validate';
@@ -147,37 +147,48 @@ const ClassroomsPage: React.FC = () => {
} }
}; };
const saveCell = async (record: any, field: string, value: unknown) => { const saveCell = useCallback(
async (record: any, field: string, value: unknown) => {
try { try {
await saveCellMutation.mutateAsync({ record, field, value }); await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存'); message.success('已保存');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
}; },
[saveCellMutation],
);
const handleArchive = async (id: number) => { const handleArchive = useCallback(
async (id: number) => {
try { try {
await archiveMutation.mutateAsync(id); await archiveMutation.mutateAsync(id);
message.success('已归档'); message.success('已归档');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
}; },
[archiveMutation],
);
const handleRestore = async (id: number) => { const handleRestore = useCallback(
async (id: number) => {
try { try {
await restoreMutation.mutateAsync(id); await restoreMutation.mutateAsync(id);
message.success('已恢复'); message.success('已恢复');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
}; },
[restoreMutation],
);
const handlePurge = (id: number, name: string) => { const handlePurge = useCallback(
(id: number, name: string) => {
modal.confirm({ modal.confirm({
title: `永久删除教室「${name}」?`, title: `永久删除教室「${name}」?`,
content: '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?', content:
'删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?',
okText: '永久删除', okText: '永久删除',
okButtonProps: { danger: true }, okButtonProps: { danger: true },
cancelText: '取消', cancelText: '取消',
@@ -190,7 +201,9 @@ const ClassroomsPage: React.FC = () => {
} }
}, },
}); });
}; },
[modal, purgeMutation],
);
const handleDownloadTemplate = () => { const handleDownloadTemplate = () => {
const baseURL = import.meta.env.PROD const baseURL = import.meta.env.PROD
@@ -397,7 +410,7 @@ const ClassroomsPage: React.FC = () => {
), ),
}, },
], ],
[handlePurge, hasPermission], [handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form],
); );
return ( return (

View File

@@ -186,7 +186,8 @@ const DashboardPage: React.FC = () => {
aria-label="选择日期范围" aria-label="选择日期范围"
value={[dayjs(period[0]), dayjs(period[1])]} value={[dayjs(period[0]), dayjs(period[1])]}
onChange={(dates) => { onChange={(dates) => {
if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]); if (dates?.[0] && dates?.[1])
setPeriod([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
}} }}
/> />
</div> </div>

View File

@@ -369,7 +369,8 @@ const OccupanciesPage: React.FC = () => {
} }
}; };
const handlePurge = (id: number, studentName: string) => { const handlePurge = useCallback(
(id: number, studentName: string) => {
modal.confirm({ modal.confirm({
title: `永久删除入住记录(${studentName}`, title: `永久删除入住记录(${studentName}`,
content: '删除后不可恢复,该入住记录将被物理删除。确定继续?', content: '删除后不可恢复,该入住记录将被物理删除。确定继续?',
@@ -385,7 +386,9 @@ const OccupanciesPage: React.FC = () => {
} }
}, },
}); });
}; },
[modal, purgeMutation],
);
const handleBatchPurge = async () => { const handleBatchPurge = async () => {
if (batchLoading) return; if (batchLoading) return;

View File

@@ -153,8 +153,8 @@ const OperationLogsPage: React.FC = () => {
placeholder={['开始日期', '结束日期']} placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD" format="YYYY-MM-DD"
onChange={(dates) => { onChange={(dates) => {
if (dates) if (dates?.[0] && dates?.[1])
setDateRange([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]); setDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
else setDateRange(null); else setDateRange(null);
setPage(1); setPage(1);
}} }}

View File

@@ -92,12 +92,15 @@ const RolesPage: React.FC = () => {
setModalOpen(true); setModalOpen(true);
}; };
const handleEdit = (record: RoleItem) => { const handleEdit = useCallback(
(record: RoleItem) => {
setEditing(record); setEditing(record);
form.setFieldsValue({ name: record.name, description: record.description }); form.setFieldsValue({ name: record.name, description: record.description });
setSelectedPermIds(record.permissions.map((p) => p.id)); setSelectedPermIds(record.permissions.map((p) => p.id));
setModalOpen(true); setModalOpen(true);
}; },
[form],
);
const handleSubmit = async () => { const handleSubmit = async () => {
setSaving(true); setSaving(true);
@@ -117,14 +120,17 @@ const RolesPage: React.FC = () => {
} }
}; };
const handleDisable = async (id: number) => { const handleDisable = useCallback(
async (id: number) => {
try { try {
await disableMutation.mutateAsync(id); await disableMutation.mutateAsync(id);
message.success('角色已停用'); message.success('角色已停用');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
}; },
[disableMutation],
);
const groupNames: Record<string, string> = { const groupNames: Record<string, string> = {
dashboard: '数据面板', dashboard: '数据面板',
@@ -264,7 +270,7 @@ const RolesPage: React.FC = () => {
), ),
}, },
], ],
[permissionOptions, saveCell], [permissionOptions, saveCell, handleEdit, handleDisable],
); );
const handleGroupCheckAll = (group: string, checked: boolean) => { const handleGroupCheckAll = (group: string, checked: boolean) => {

View File

@@ -102,7 +102,7 @@ const RoomVisualPage: React.FC = () => {
queryKey: ['rooms', 'visual', isHistorical, asOf], queryKey: ['rooms', 'visual', isHistorical, asOf],
queryFn: async () => { queryFn: async () => {
try { try {
const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined; const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined;
return await api.get('/rooms/visual', { params }); return await api.get('/rooms/visual', { params });
} catch (e: unknown) { } catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试')); message.error(getErrorMessage(e, '加载失败,请稍后重试'));
@@ -229,7 +229,7 @@ const RoomVisualPage: React.FC = () => {
showIcon showIcon
icon={<HistoryOutlined />} icon={<HistoryOutlined />}
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
title={`正在查看 ${asOf!.format('YYYY年M月D日')} 的历史入住情况(含当日已归档房间),非实时数据`} title={`正在查看 ${asOf ? asOf.format('YYYY年M月D日') : ''} 的历史入住情况(含当日已归档房间),非实时数据`}
action={ action={
<Button size="small" type="link" onClick={() => setAsOf(null)}> <Button size="small" type="link" onClick={() => setAsOf(null)}>

View File

@@ -9,12 +9,10 @@ import api from '../../api';
const StudentProfilePage: React.FC = () => { const StudentProfilePage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
if (!id) return null;
const studentId = Number(id); const studentId = Number(id);
const handlePreviewReport = useCallback(async () => { const handlePreviewReport = useCallback(async () => {
if (Number.isNaN(studentId)) return;
try { try {
const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`); const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`);
const w = window.open('', '_blank'); const w = window.open('', '_blank');
@@ -27,6 +25,8 @@ const StudentProfilePage: React.FC = () => {
} }
}, [studentId]); }, [studentId]);
if (!id) return null;
return ( return (
<Card <Card
title={ title={

View File

@@ -93,10 +93,10 @@ const StudentsPage: React.FC = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [jinshujuOpen, setJinshujuOpen] = useState(false); const [jinshujuOpen, setJinshujuOpen] = useState(false);
const openDrawer = (studentId: number) => { const openDrawer = useCallback((studentId: number) => {
setDrawerStudentId(studentId); setDrawerStudentId(studentId);
setDrawerOpen(true); setDrawerOpen(true);
}; }, []);
const logCreateRef = React.useRef(hasPermission('log:create')); const logCreateRef = React.useRef(hasPermission('log:create'));
const sensitiveModalRef = React.useRef<ReturnType<typeof modal.confirm> | null>(null); const sensitiveModalRef = React.useRef<ReturnType<typeof modal.confirm> | null>(null);
@@ -112,7 +112,8 @@ const StudentsPage: React.FC = () => {
}; };
}, []); }, []);
const handleViewSensitive = (studentId: number, field: string, value: string) => { const handleViewSensitive = useCallback(
(studentId: number, field: string, value: string) => {
if (!logCreateRef.current) return; if (!logCreateRef.current) return;
sensitiveModalRef.current = modal.confirm({ sensitiveModalRef.current = modal.confirm({
title: '查看敏感信息', title: '查看敏感信息',
@@ -143,7 +144,9 @@ const StudentsPage: React.FC = () => {
sensitiveModalRef.current = null; sensitiveModalRef.current = null;
}, },
}); });
}; },
[modal],
);
const { const {
data = [], data = [],
@@ -329,25 +332,32 @@ const StudentsPage: React.FC = () => {
} }
}; };
const handleArchive = async (id: number) => { const handleArchive = useCallback(
async (id: number) => {
try { try {
await archiveMutation.mutateAsync(id); await archiveMutation.mutateAsync(id);
message.success('已归档'); message.success('已归档');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
}; },
[archiveMutation],
);
const handleRestore = async (id: number) => { const handleRestore = useCallback(
async (id: number) => {
try { try {
await restoreMutation.mutateAsync(id); await restoreMutation.mutateAsync(id);
message.success('已恢复'); message.success('已恢复');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
}; },
[restoreMutation],
);
const handlePurge = (id: number, name: string) => { const handlePurge = useCallback(
(id: number, name: string) => {
modal.confirm({ modal.confirm({
title: `永久删除学生「${name}」?`, title: `永久删除学生「${name}」?`,
content: '删除后不可恢复,该学生及其关联数据将无法找回。确定继续?', content: '删除后不可恢复,该学生及其关联数据将无法找回。确定继续?',
@@ -363,7 +373,9 @@ const StudentsPage: React.FC = () => {
} }
}, },
}); });
}; },
[modal, purgeMutation],
);
const handleBatchDelete = async () => { const handleBatchDelete = async () => {
if (batchLoading) return; if (batchLoading) return;
@@ -450,7 +462,9 @@ const StudentsPage: React.FC = () => {
if (log?.status === 'partial') { if (log?.status === 'partial') {
message.warning(log.errorMessage || '钉钉同步完成,但有数据需要人工处理'); message.warning(log.errorMessage || '钉钉同步完成,但有数据需要人工处理');
} else { } else {
message.success(log?.errorMessage || `钉钉同步完成,共处理 ${res.synced}`); message.success(
log?.errorMessage || `钉钉同步完成,共处理 ${log?.recordsCount ?? res.synced}`,
);
} }
void queryClient.invalidateQueries({ queryKey: ['students'] }); void queryClient.invalidateQueries({ queryKey: ['students'] });
} catch (e: unknown) { } catch (e: unknown) {
@@ -506,6 +520,10 @@ const StudentsPage: React.FC = () => {
saveCell, saveCell,
handleViewSensitive, handleViewSensitive,
form, form,
openDrawer,
handleArchive,
handleRestore,
handlePurge,
], ],
); );

View File

@@ -44,7 +44,8 @@ const UsersPage: React.FC = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const handleOpenProfile = async (record: any) => { const handleOpenProfile = useCallback(
async (record: any) => {
setProfileUser(record); setProfileUser(record);
try { try {
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`); const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
@@ -53,7 +54,9 @@ const UsersPage: React.FC = () => {
profileForm.setFieldsValue({}); profileForm.setFieldsValue({});
} }
setProfileModalOpen(true); setProfileModalOpen(true);
}; },
[profileForm],
);
const handleProfileSubmit = async () => { const handleProfileSubmit = async () => {
setSaving(true); setSaving(true);
@@ -133,7 +136,8 @@ const UsersPage: React.FC = () => {
setModalOpen(true); setModalOpen(true);
}; };
const handleEdit = (record: any) => { const handleEdit = useCallback(
(record: any) => {
setEditing(record); setEditing(record);
form.setFieldsValue({ form.setFieldsValue({
username: record.username, username: record.username,
@@ -141,7 +145,9 @@ const UsersPage: React.FC = () => {
roleIds: record.roles?.map((r: any) => r.id) || [], roleIds: record.roles?.map((r: any) => r.id) || [],
}); });
setModalOpen(true); setModalOpen(true);
}; },
[form],
);
const handleSubmit = async () => { const handleSubmit = async () => {
setSaving(true); setSaving(true);
@@ -161,16 +167,20 @@ const UsersPage: React.FC = () => {
setSaving(false); setSaving(false);
} }
}; };
const handleArchive = async (id: number, archive: boolean) => { const handleArchive = useCallback(
async (id: number, archive: boolean) => {
try { try {
await archiveMutation.mutateAsync({ id, archive }); await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复'); message.success(archive ? '已归档' : '已恢复');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
}; },
[archiveMutation],
);
const handlePurge = (record: any) => { const handlePurge = useCallback(
(record: any) => {
modal.confirm({ modal.confirm({
title: `永久删除账号「${record.name || record.username}」?`, title: `永久删除账号「${record.name || record.username}」?`,
content: content:
@@ -187,13 +197,18 @@ const UsersPage: React.FC = () => {
} }
}, },
}); });
}; },
[modal, purgeMutation],
);
const handleResetPwd = (record: any) => { const handleResetPwd = useCallback(
(record: any) => {
setResetTarget(record); setResetTarget(record);
pwdForm.resetFields(); pwdForm.resetFields();
setPwdModalOpen(true); setPwdModalOpen(true);
}; },
[pwdForm],
);
const handlePwdSubmit = async () => { const handlePwdSubmit = async () => {
setSaving(true); setSaving(true);
@@ -353,7 +368,16 @@ const UsersPage: React.FC = () => {
), ),
}, },
], ],
[roles, saveCell, canPurgeUser, handlePurge], [
roles,
saveCell,
canPurgeUser,
handlePurge,
handleOpenProfile,
handleEdit,
handleArchive,
handleResetPwd,
],
); );
return ( return (

View File

@@ -123,10 +123,13 @@ const WalletsPage: React.FC = () => {
setSelectedRowKeys([]); setSelectedRowKeys([]);
}; };
const openChange = (row: WalletRow) => { const openChange = useCallback(
(row: WalletRow) => {
setSelected(row); setSelected(row);
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' }); form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
}; },
[form, setSelected],
);
const openBatchChange = () => { const openBatchChange = () => {
batchForm.setFieldsValue({ type: 'recharge', amount: undefined, description: '' }); batchForm.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
@@ -189,7 +192,8 @@ const WalletsPage: React.FC = () => {
} }
}; };
const showTransactions = async (row: WalletRow) => { const showTransactions = useCallback(
async (row: WalletRow) => {
setSelected(row); setSelected(row);
setDrawerOpen(true); setDrawerOpen(true);
try { try {
@@ -202,7 +206,9 @@ const WalletsPage: React.FC = () => {
console.error('加载余额流水失败', error); console.error('加载余额流水失败', error);
message.error('加载流水失败'); message.error('加载流水失败');
} }
}; },
[setSelected, setDrawerOpen, setTransactions],
);
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -257,7 +263,7 @@ const WalletsPage: React.FC = () => {
), ),
}, },
], ],
[], [openChange, showTransactions],
); );
return ( return (