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,19 +169,22 @@ 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(
for (const item of items) { (items: AppMenuItem[], pathname: string): string[] => {
if (item.key === pathname) return [item.key]; for (const item of items) {
if (item.children) { if (item.key === pathname) return [item.key];
const found = findSelectedKeys(item.children, pathname); if (item.children) {
if (found.length > 0) return found; const found = findSelectedKeys(item.children, pathname);
if (found.length > 0) return found;
}
} }
} 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,60 +149,69 @@ const BillsPage: React.FC = () => {
} finally { } finally {
setDetailLoading(false); setDetailLoading(false);
} }
}; }, []);
const handleCancel = async (id: number) => { const handleCancel = useCallback(
let reason = ''; (id: number) => {
modal.confirm({ let reason = '';
title: '取消账单并退回已扣余额', modal.confirm({
content: ( title: '取消账单并退回已扣余额',
<Input.TextArea content: (
placeholder="请输入取消原因" <Input.TextArea
maxLength={300} placeholder="请输入取消原因"
onChange={(event) => { maxLength={300}
reason = event.target.value; onChange={(event) => {
}} reason = event.target.value;
/> }}
), />
okText: '确认取消', ),
cancelText: '返回', okText: '确认取消',
onOk: async () => { cancelText: '返回',
if (!reason.trim()) { onOk: async () => {
message.error('请输入取消原因'); if (!reason.trim()) {
throw new Error('reason required'); message.error('请输入取消原因');
} throw new Error('reason required');
await cancelMutation.mutateAsync({ id, reason: reason.trim() }); }
message.success('账单已取消,已扣余额已冲正退回'); await cancelMutation.mutateAsync({ id, reason: reason.trim() });
}, message.success('账单已取消,已扣余额已冲正退回');
}); },
}; });
},
[modal, cancelMutation],
);
const handleArchive = async (id: number) => { const handleArchive = useCallback(
try { async (id: number) => {
await archiveMutation.mutateAsync(id); try {
message.success('账单已归档'); await archiveMutation.mutateAsync(id);
} catch { message.success('账单已归档');
// 错误提示由 useApiMutation 统一处理 } catch {
} // 错误提示由 useApiMutation 统一处理
}; }
},
[archiveMutation],
);
const handlePurge = (id: number, studentName: string, period: string) => { const handlePurge = useCallback(
modal.confirm({ (id: number, studentName: string, period: string) => {
title: `永久删除账单(${studentName} ${period}`, modal.confirm({
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?', title: `永久删除账单(${studentName} ${period}`,
okText: '永久删除', content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
okButtonProps: { danger: true }, okText: '永久删除',
cancelText: '取消', okButtonProps: { danger: true },
onOk: async () => { cancelText: '取消',
try { onOk: async () => {
await purgeMutation.mutateAsync(id); try {
message.success('已永久删除(不可恢复)'); await purgeMutation.mutateAsync(id);
} catch { message.success('已永久删除(不可恢复)');
// 错误提示由 useApiMutation 统一处理 } catch {
} // 错误提示由 useApiMutation 统一处理
}, }
}); },
}; });
},
[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,16 +180,19 @@ const ClassesPage: React.FC = () => {
setModalOpen(true); setModalOpen(true);
}; };
const handleEdit = (record: ClassItem) => { const handleEdit = useCallback(
setEditing(record); (record: ClassItem) => {
form.setFieldsValue({ setEditing(record);
...record, form.setFieldsValue({
notes: record.notes ?? undefined, ...record,
startDate: record.startDate ? dayjs(record.startDate) : undefined, notes: record.notes ?? undefined,
endDate: record.endDate ? dayjs(record.endDate) : undefined, startDate: record.startDate ? dayjs(record.startDate) : 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,50 +147,63 @@ const ClassroomsPage: React.FC = () => {
} }
}; };
const saveCell = async (record: any, field: string, value: unknown) => { const saveCell = useCallback(
try { async (record: any, field: string, value: unknown) => {
await saveCellMutation.mutateAsync({ record, field, value }); try {
message.success('已保存'); await saveCellMutation.mutateAsync({ record, field, value });
} catch { message.success('已保存');
// 错误提示由 useApiMutation 统一处理 } catch {
} // 错误提示由 useApiMutation 统一处理
}; }
},
[saveCellMutation],
);
const handleArchive = async (id: number) => { const handleArchive = useCallback(
try { async (id: number) => {
await archiveMutation.mutateAsync(id); try {
message.success('已归档'); await archiveMutation.mutateAsync(id);
} catch { message.success('已归档');
// 错误提示由 useApiMutation 统一处理 } catch {
} // 错误提示由 useApiMutation 统一处理
}; }
},
[archiveMutation],
);
const handleRestore = async (id: number) => { const handleRestore = useCallback(
try { async (id: number) => {
await restoreMutation.mutateAsync(id); try {
message.success('已恢复'); await restoreMutation.mutateAsync(id);
} catch { message.success('已恢复');
// 错误提示由 useApiMutation 统一处理 } catch {
} // 错误提示由 useApiMutation 统一处理
}; }
},
[restoreMutation],
);
const handlePurge = (id: number, name: string) => { const handlePurge = useCallback(
modal.confirm({ (id: number, name: string) => {
title: `永久删除教室「${name}」?`, modal.confirm({
content: '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?', title: `永久删除教室「${name}」?`,
okText: '永久删除', content:
okButtonProps: { danger: true }, '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?',
cancelText: '取消', okText: '永久删除',
onOk: async () => { okButtonProps: { danger: true },
try { cancelText: '取消',
await purgeMutation.mutateAsync(id); onOk: async () => {
message.success('已永久删除(不可恢复)'); try {
} catch { await purgeMutation.mutateAsync(id);
// 错误提示由 useApiMutation 统一处理 message.success('已永久删除(不可恢复)');
} } catch {
}, // 错误提示由 useApiMutation 统一处理
}); }
}; },
});
},
[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,23 +369,26 @@ const OccupanciesPage: React.FC = () => {
} }
}; };
const handlePurge = (id: number, studentName: string) => { const handlePurge = useCallback(
modal.confirm({ (id: number, studentName: string) => {
title: `永久删除入住记录(${studentName}`, modal.confirm({
content: '删除后不可恢复,该入住记录将被物理删除。确定继续?', title: `永久删除入住记录(${studentName}`,
okText: '永久删除', content: '删除后不可恢复,该入住记录将被物理删除。确定继续?',
okButtonProps: { danger: true }, okText: '永久删除',
cancelText: '取消', okButtonProps: { danger: true },
onOk: async () => { cancelText: '取消',
try { onOk: async () => {
await purgeMutation.mutateAsync(id); try {
message.success('已永久删除(不可恢复)'); await purgeMutation.mutateAsync(id);
} catch { message.success('已永久删除(不可恢复)');
// 错误提示由 useApiMutation 统一处理 } catch {
} // 错误提示由 useApiMutation 统一处理
}, }
}); },
}; });
},
[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(
setEditing(record); (record: RoleItem) => {
form.setFieldsValue({ name: record.name, description: record.description }); setEditing(record);
setSelectedPermIds(record.permissions.map((p) => p.id)); form.setFieldsValue({ name: record.name, description: record.description });
setModalOpen(true); setSelectedPermIds(record.permissions.map((p) => p.id));
}; 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(
try { async (id: number) => {
await disableMutation.mutateAsync(id); try {
message.success('角色已停用'); await disableMutation.mutateAsync(id);
} catch { message.success('角色已停用');
// 错误提示由 useApiMutation 统一处理 } catch {
} // 错误提示由 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,38 +112,41 @@ const StudentsPage: React.FC = () => {
}; };
}, []); }, []);
const handleViewSensitive = (studentId: number, field: string, value: string) => { const handleViewSensitive = useCallback(
if (!logCreateRef.current) return; (studentId: number, field: string, value: string) => {
sensitiveModalRef.current = modal.confirm({ if (!logCreateRef.current) return;
title: '查看敏感信息', sensitiveModalRef.current = modal.confirm({
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`, title: '查看敏感信息',
okText: '确认查看', content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
cancelText: '取消', okText: '确认查看',
onOk: async () => { cancelText: '取消',
if (!logCreateRef.current) return; onOk: async () => {
try { if (!logCreateRef.current) return;
await api.post('/operation-logs/audit', { try {
module: '学生管理', await api.post('/operation-logs/audit', {
action: '查看敏感信息', module: '学生管理',
targetId: studentId, action: '查看敏感信息',
targetType: 'student', targetId: studentId,
detail: `查看${field}`, targetType: 'student',
}); detail: `查看${field}`,
modal.info({ });
title: field, modal.info({
content: value, title: field,
okText: '关闭', content: value,
}); okText: '关闭',
} catch (e) { });
console.error('审计日志记录失败', e); } catch (e) {
message.error('审计日志记录失败,请稍后重试'); console.error('审计日志记录失败', e);
} message.error('审计日志记录失败,请稍后重试');
}, }
afterClose: () => { },
sensitiveModalRef.current = null; afterClose: () => {
}, sensitiveModalRef.current = null;
}); },
}; });
},
[modal],
);
const { const {
data = [], data = [],
@@ -329,41 +332,50 @@ const StudentsPage: React.FC = () => {
} }
}; };
const handleArchive = async (id: number) => { const handleArchive = useCallback(
try { async (id: number) => {
await archiveMutation.mutateAsync(id); try {
message.success('已归档'); await archiveMutation.mutateAsync(id);
} catch { message.success('已归档');
// 错误提示由 useApiMutation 统一处理 } catch {
} // 错误提示由 useApiMutation 统一处理
}; }
},
[archiveMutation],
);
const handleRestore = async (id: number) => { const handleRestore = useCallback(
try { async (id: number) => {
await restoreMutation.mutateAsync(id); try {
message.success('已恢复'); await restoreMutation.mutateAsync(id);
} catch { message.success('已恢复');
// 错误提示由 useApiMutation 统一处理 } catch {
} // 错误提示由 useApiMutation 统一处理
}; }
},
[restoreMutation],
);
const handlePurge = (id: number, name: string) => { const handlePurge = useCallback(
modal.confirm({ (id: number, name: string) => {
title: `永久删除学生「${name}」?`, modal.confirm({
content: '删除后不可恢复,该学生及其关联数据将无法找回。确定继续?', title: `永久删除学生「${name}」?`,
okText: '永久删除', content: '删除后不可恢复,该学生及其关联数据将无法找回。确定继续?',
okButtonProps: { danger: true }, okText: '永久删除',
cancelText: '取消', okButtonProps: { danger: true },
onOk: async () => { cancelText: '取消',
try { onOk: async () => {
await purgeMutation.mutateAsync(id); try {
message.success('已永久删除(不可恢复)'); await purgeMutation.mutateAsync(id);
} catch { message.success('已永久删除(不可恢复)');
// 错误提示由 useApiMutation 统一处理 } catch {
} // 错误提示由 useApiMutation 统一处理
}, }
}); },
}; });
},
[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,16 +44,19 @@ 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(
setProfileUser(record); async (record: any) => {
try { setProfileUser(record);
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`); try {
profileForm.setFieldsValue(userProfileResponseToFormValues(res)); const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
} catch { profileForm.setFieldsValue(userProfileResponseToFormValues(res));
profileForm.setFieldsValue({}); } catch {
} profileForm.setFieldsValue({});
setProfileModalOpen(true); }
}; setProfileModalOpen(true);
},
[profileForm],
);
const handleProfileSubmit = async () => { const handleProfileSubmit = async () => {
setSaving(true); setSaving(true);
@@ -133,15 +136,18 @@ const UsersPage: React.FC = () => {
setModalOpen(true); setModalOpen(true);
}; };
const handleEdit = (record: any) => { const handleEdit = useCallback(
setEditing(record); (record: any) => {
form.setFieldsValue({ setEditing(record);
username: record.username, form.setFieldsValue({
name: record.name, username: record.username,
roleIds: record.roles?.map((r: any) => r.id) || [], name: record.name,
}); roleIds: record.roles?.map((r: any) => r.id) || [],
setModalOpen(true); });
}; setModalOpen(true);
},
[form],
);
const handleSubmit = async () => { const handleSubmit = async () => {
setSaving(true); setSaving(true);
@@ -161,39 +167,48 @@ const UsersPage: React.FC = () => {
setSaving(false); setSaving(false);
} }
}; };
const handleArchive = async (id: number, archive: boolean) => { const handleArchive = useCallback(
try { async (id: number, archive: boolean) => {
await archiveMutation.mutateAsync({ id, archive }); try {
message.success(archive ? '已归档' : '已恢复'); await archiveMutation.mutateAsync({ id, archive });
} catch { message.success(archive ? '已归档' : '已恢复');
// 错误提示由 useApiMutation 统一处理 } catch {
} // 错误提示由 useApiMutation 统一处理
}; }
},
[archiveMutation],
);
const handlePurge = (record: any) => { const handlePurge = useCallback(
modal.confirm({ (record: any) => {
title: `永久删除账号「${record.name || record.username}」?`, modal.confirm({
content: title: `永久删除账号「${record.name || record.username}」?`,
'删除后不可恢复,关联学生、任教、排课或考勤操作时将无法删除;角色绑定、通知和 AI 会话将被清除,操作日志保留。确定继续?', content:
okText: '永久删除', '删除后不可恢复,关联学生、任教、排课或考勤操作时将无法删除;角色绑定、通知和 AI 会话将被清除,操作日志保留。确定继续?',
okButtonProps: { danger: true }, okText: '永久删除',
cancelText: '取消', okButtonProps: { danger: true },
onOk: async () => { cancelText: '取消',
try { onOk: async () => {
await purgeMutation.mutateAsync(record.id); try {
message.success('已永久删除(不可恢复)'); await purgeMutation.mutateAsync(record.id);
} catch { message.success('已永久删除(不可恢复)');
// 错误提示由 useApiMutation 统一处理 } catch {
} // 错误提示由 useApiMutation 统一处理
}, }
}); },
}; });
},
[modal, purgeMutation],
);
const handleResetPwd = (record: any) => { const handleResetPwd = useCallback(
setResetTarget(record); (record: any) => {
pwdForm.resetFields(); setResetTarget(record);
setPwdModalOpen(true); pwdForm.resetFields();
}; 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(
setSelected(row); (row: WalletRow) => {
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' }); setSelected(row);
}; 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,20 +192,23 @@ const WalletsPage: React.FC = () => {
} }
}; };
const showTransactions = async (row: WalletRow) => { const showTransactions = useCallback(
setSelected(row); async (row: WalletRow) => {
setDrawerOpen(true); setSelected(row);
try { setDrawerOpen(true);
setTransactions( try {
await api.get<WalletTransaction[]>('/wallets/transactions', { setTransactions(
params: { studentId: row.studentId }, await api.get<WalletTransaction[]>('/wallets/transactions', {
}), params: { studentId: row.studentId },
); }),
} catch (error: unknown) { );
console.error('加载余额流水失败', error); } catch (error: unknown) {
message.error('加载流水失败'); console.error('加载余额流水失败', error);
} message.error('加载流水失败');
}; }
},
[setSelected, setDrawerOpen, setTransactions],
);
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -257,7 +263,7 @@ const WalletsPage: React.FC = () => {
), ),
}, },
], ],
[], [openChange, showTransactions],
); );
return ( return (