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(

View File

@@ -50,13 +50,15 @@ interface EChartsProps {
const ECharts: React.FC<EChartsProps> = ({ option, style, className, onReady }) => {
const containerRef = useRef<HTMLDivElement>(null);
const optionRef = useRef(option);
optionRef.current = option;
const onReadyRef = useRef(onReady);
onReadyRef.current = onReady;
useEffect(() => {
if (!containerRef.current) return;
const chart = echarts.init(containerRef.current);
chart.setOption(option);
chart.setOption(optionRef.current);
onReadyRef.current?.(chart);
const observer = new ResizeObserver(() => chart.resize());
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);
if (usesDrawer) setDrawerOpen(false);
},
[navigate, usesDrawer],
[navigate, usesDrawer, setDrawerOpen],
);
const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => {
for (const item of items) {
if (item.key === pathname) return [item.key];
if (item.children) {
const found = findSelectedKeys(item.children, pathname);
if (found.length > 0) return found;
const findSelectedKeys = useCallback(
(items: AppMenuItem[], pathname: string): string[] => {
for (const item of items) {
if (item.key === pathname) return [item.key];
if (item.children) {
const found = findSelectedKeys(item.children, pathname);
if (found.length > 0) return found;
}
}
}
return [pathname];
};
return [pathname];
},
[],
);
const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => {
for (const item of items) {
@@ -201,7 +204,7 @@ const MainLayout: React.FC = () => {
const selectedKeys = useMemo(
() => findSelectedKeys(menuItems, location.pathname),
[menuItems, location.pathname],
[menuItems, location.pathname, findSelectedKeys],
);
// 路径变化时同步展开的菜单(不干扰用户手动展开/收起)
useEffect(() => {
@@ -210,20 +213,20 @@ const MainLayout: React.FC = () => {
const routeOpenKeys = findOpenKeys(menuItems, location.pathname);
setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]);
}
}, [location.pathname, menuItems]);
}, [location.pathname, menuItems, setOpenKeys]);
const handleOpenChange = useCallback((keys: string[]) => {
setOpenKeys(keys);
}, []);
}, [setOpenKeys]);
const transformToMenuItems = (items: AppMenuItem[]): any[] => {
const transformToMenuItems = useCallback((items: AppMenuItem[]): any[] => {
return items.map((item) => ({
key: item.key,
icon: item.icon ? iconMap[item.icon] : undefined,
label: item.label,
children: item.children ? transformToMenuItems(item.children) : undefined,
}));
};
}, []);
const menuContent = useMemo(
() => (
<Menu
@@ -237,7 +240,7 @@ const MainLayout: React.FC = () => {
style={{ border: 'none' }}
/>
),
[selectedKeys, openKeys, menuItems, handleMenuClick],
[selectedKeys, openKeys, menuItems, handleMenuClick, handleOpenChange, transformToMenuItems],
);
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>
<QueryClientProvider client={queryClient}>
<App />

View File

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

View File

@@ -115,10 +115,11 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
enabled: !!classId && !!attendanceDate,
queryFn: async () => {
try {
if (!attendanceDate) return [];
return validateResponse<HistoryScheduleOption[]>(
attendanceScheduleOptionsSchema,
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) {
@@ -214,7 +215,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
if (effectiveScheduleId) params.scheduleId = effectiveScheduleId;
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>({
@@ -334,7 +335,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
} finally {
setRefreshingDingTalk(false);
}
}, [attendanceDate, classId, loadRecords, loadSyncStatus, session]);
}, [attendanceDate, classId, loadRecords, loadSyncStatus, session, refreshDingTalkMutation]);
const resetFilters = () => {
setClassId(undefined);

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import {
App,
Table,
@@ -139,7 +139,7 @@ const BillsPage: React.FC = () => {
}
};
const showDetail = async (id: number) => {
const showDetail = useCallback(async (id: number) => {
setDetailLoading(true);
try {
const res = await api.get(`/bills/${id}`);
@@ -149,60 +149,69 @@ const BillsPage: React.FC = () => {
} finally {
setDetailLoading(false);
}
};
}, []);
const handleCancel = async (id: number) => {
let reason = '';
modal.confirm({
title: '取消账单并退回已扣余额',
content: (
<Input.TextArea
placeholder="请输入取消原因"
maxLength={300}
onChange={(event) => {
reason = event.target.value;
}}
/>
),
okText: '确认取消',
cancelText: '返回',
onOk: async () => {
if (!reason.trim()) {
message.error('请输入取消原因');
throw new Error('reason required');
}
await cancelMutation.mutateAsync({ id, reason: reason.trim() });
message.success('账单已取消,已扣余额已冲正退回');
},
});
};
const handleCancel = useCallback(
(id: number) => {
let reason = '';
modal.confirm({
title: '取消账单并退回已扣余额',
content: (
<Input.TextArea
placeholder="请输入取消原因"
maxLength={300}
onChange={(event) => {
reason = event.target.value;
}}
/>
),
okText: '确认取消',
cancelText: '返回',
onOk: async () => {
if (!reason.trim()) {
message.error('请输入取消原因');
throw new Error('reason required');
}
await cancelMutation.mutateAsync({ id, reason: reason.trim() });
message.success('账单已取消,已扣余额已冲正退回');
},
});
},
[modal, cancelMutation],
);
const handleArchive = async (id: number) => {
try {
await archiveMutation.mutateAsync(id);
message.success('账单已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleArchive = useCallback(
async (id: number) => {
try {
await archiveMutation.mutateAsync(id);
message.success('账单已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[archiveMutation],
);
const handlePurge = (id: number, studentName: string, period: string) => {
modal.confirm({
title: `永久删除账单(${studentName} ${period}`,
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handlePurge = useCallback(
(id: number, studentName: string, period: string) => {
modal.confirm({
title: `永久删除账单(${studentName} ${period}`,
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const batchArchive = async () => {
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');
if (!printWindow) {
message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试');
@@ -245,7 +254,7 @@ const BillsPage: React.FC = () => {
printWindow.close();
message.error(error?.message || '账单加载失败');
}
};
}, []);
const columns = useMemo(
() => [

View File

@@ -89,33 +89,6 @@ const ClassesPage: React.FC = () => {
const [saving, setSaving] = 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 {
data = [],
isLoading,
@@ -160,6 +133,39 @@ const ClassesPage: React.FC = () => {
{ 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(() => {
if (!searchText) return data;
const q = searchText.toLowerCase();
@@ -174,16 +180,19 @@ const ClassesPage: React.FC = () => {
setModalOpen(true);
};
const handleEdit = (record: ClassItem) => {
setEditing(record);
form.setFieldsValue({
...record,
notes: record.notes ?? undefined,
startDate: record.startDate ? dayjs(record.startDate) : undefined,
endDate: record.endDate ? dayjs(record.endDate) : undefined,
});
setModalOpen(true);
};
const handleEdit = useCallback(
(record: ClassItem) => {
setEditing(record);
form.setFieldsValue({
...record,
notes: record.notes ?? undefined,
startDate: record.startDate ? dayjs(record.startDate) : undefined,
endDate: record.endDate ? dayjs(record.endDate) : undefined,
});
setModalOpen(true);
},
[form],
);
const handleSubmit = async () => {
setSaving(true);
@@ -369,7 +378,7 @@ const ClassesPage: React.FC = () => {
),
},
],
[saveCell, canPurgeClass, handlePurge],
[saveCell, canPurgeClass, handlePurge, navigate, handleEdit, handleArchive],
);
return (

View File

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

View File

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

View File

@@ -186,7 +186,8 @@ const DashboardPage: React.FC = () => {
aria-label="选择日期范围"
value={[dayjs(period[0]), dayjs(period[1])]}
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>

View File

@@ -369,23 +369,26 @@ const OccupanciesPage: React.FC = () => {
}
};
const handlePurge = (id: number, studentName: string) => {
modal.confirm({
title: `永久删除入住记录(${studentName}`,
content: '删除后不可恢复,该入住记录将被物理删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handlePurge = useCallback(
(id: number, studentName: string) => {
modal.confirm({
title: `永久删除入住记录(${studentName}`,
content: '删除后不可恢复,该入住记录将被物理删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const handleBatchPurge = async () => {
if (batchLoading) return;

View File

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

View File

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

View File

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

View File

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

View File

@@ -93,10 +93,10 @@ const StudentsPage: React.FC = () => {
const [saving, setSaving] = useState(false);
const [jinshujuOpen, setJinshujuOpen] = useState(false);
const openDrawer = (studentId: number) => {
const openDrawer = useCallback((studentId: number) => {
setDrawerStudentId(studentId);
setDrawerOpen(true);
};
}, []);
const logCreateRef = React.useRef(hasPermission('log:create'));
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) => {
if (!logCreateRef.current) return;
sensitiveModalRef.current = modal.confirm({
title: '查看敏感信息',
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
okText: '确认查看',
cancelText: '取消',
onOk: async () => {
if (!logCreateRef.current) return;
try {
await api.post('/operation-logs/audit', {
module: '学生管理',
action: '查看敏感信息',
targetId: studentId,
targetType: 'student',
detail: `查看${field}`,
});
modal.info({
title: field,
content: value,
okText: '关闭',
});
} catch (e) {
console.error('审计日志记录失败', e);
message.error('审计日志记录失败,请稍后重试');
}
},
afterClose: () => {
sensitiveModalRef.current = null;
},
});
};
const handleViewSensitive = useCallback(
(studentId: number, field: string, value: string) => {
if (!logCreateRef.current) return;
sensitiveModalRef.current = modal.confirm({
title: '查看敏感信息',
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
okText: '确认查看',
cancelText: '取消',
onOk: async () => {
if (!logCreateRef.current) return;
try {
await api.post('/operation-logs/audit', {
module: '学生管理',
action: '查看敏感信息',
targetId: studentId,
targetType: 'student',
detail: `查看${field}`,
});
modal.info({
title: field,
content: value,
okText: '关闭',
});
} catch (e) {
console.error('审计日志记录失败', e);
message.error('审计日志记录失败,请稍后重试');
}
},
afterClose: () => {
sensitiveModalRef.current = null;
},
});
},
[modal],
);
const {
data = [],
@@ -329,41 +332,50 @@ const StudentsPage: React.FC = () => {
}
};
const handleArchive = async (id: number) => {
try {
await archiveMutation.mutateAsync(id);
message.success('已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleArchive = useCallback(
async (id: number) => {
try {
await archiveMutation.mutateAsync(id);
message.success('已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[archiveMutation],
);
const handleRestore = async (id: number) => {
try {
await restoreMutation.mutateAsync(id);
message.success('已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleRestore = useCallback(
async (id: number) => {
try {
await restoreMutation.mutateAsync(id);
message.success('已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[restoreMutation],
);
const handlePurge = (id: number, name: string) => {
modal.confirm({
title: `永久删除学生「${name}」?`,
content: '删除后不可恢复,该学生及其关联数据将无法找回。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handlePurge = useCallback(
(id: number, name: string) => {
modal.confirm({
title: `永久删除学生「${name}」?`,
content: '删除后不可恢复,该学生及其关联数据将无法找回。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const handleBatchDelete = async () => {
if (batchLoading) return;
@@ -450,7 +462,9 @@ const StudentsPage: React.FC = () => {
if (log?.status === 'partial') {
message.warning(log.errorMessage || '钉钉同步完成,但有数据需要人工处理');
} else {
message.success(log?.errorMessage || `钉钉同步完成,共处理 ${res.synced}`);
message.success(
log?.errorMessage || `钉钉同步完成,共处理 ${log?.recordsCount ?? res.synced}`,
);
}
void queryClient.invalidateQueries({ queryKey: ['students'] });
} catch (e: unknown) {
@@ -506,6 +520,10 @@ const StudentsPage: React.FC = () => {
saveCell,
handleViewSensitive,
form,
openDrawer,
handleArchive,
handleRestore,
handlePurge,
],
);

View File

@@ -44,16 +44,19 @@ const UsersPage: React.FC = () => {
const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const handleOpenProfile = async (record: any) => {
setProfileUser(record);
try {
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
profileForm.setFieldsValue(userProfileResponseToFormValues(res));
} catch {
profileForm.setFieldsValue({});
}
setProfileModalOpen(true);
};
const handleOpenProfile = useCallback(
async (record: any) => {
setProfileUser(record);
try {
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
profileForm.setFieldsValue(userProfileResponseToFormValues(res));
} catch {
profileForm.setFieldsValue({});
}
setProfileModalOpen(true);
},
[profileForm],
);
const handleProfileSubmit = async () => {
setSaving(true);
@@ -133,15 +136,18 @@ const UsersPage: React.FC = () => {
setModalOpen(true);
};
const handleEdit = (record: any) => {
setEditing(record);
form.setFieldsValue({
username: record.username,
name: record.name,
roleIds: record.roles?.map((r: any) => r.id) || [],
});
setModalOpen(true);
};
const handleEdit = useCallback(
(record: any) => {
setEditing(record);
form.setFieldsValue({
username: record.username,
name: record.name,
roleIds: record.roles?.map((r: any) => r.id) || [],
});
setModalOpen(true);
},
[form],
);
const handleSubmit = async () => {
setSaving(true);
@@ -161,39 +167,48 @@ const UsersPage: React.FC = () => {
setSaving(false);
}
};
const handleArchive = async (id: number, archive: boolean) => {
try {
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleArchive = useCallback(
async (id: number, archive: boolean) => {
try {
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[archiveMutation],
);
const handlePurge = (record: any) => {
modal.confirm({
title: `永久删除账号「${record.name || record.username}」?`,
content:
'删除后不可恢复,关联学生、任教、排课或考勤操作时将无法删除;角色绑定、通知和 AI 会话将被清除,操作日志保留。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handlePurge = useCallback(
(record: any) => {
modal.confirm({
title: `永久删除账号「${record.name || record.username}」?`,
content:
'删除后不可恢复,关联学生、任教、排课或考勤操作时将无法删除;角色绑定、通知和 AI 会话将被清除,操作日志保留。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const handleResetPwd = (record: any) => {
setResetTarget(record);
pwdForm.resetFields();
setPwdModalOpen(true);
};
const handleResetPwd = useCallback(
(record: any) => {
setResetTarget(record);
pwdForm.resetFields();
setPwdModalOpen(true);
},
[pwdForm],
);
const handlePwdSubmit = async () => {
setSaving(true);
@@ -353,7 +368,16 @@ const UsersPage: React.FC = () => {
),
},
],
[roles, saveCell, canPurgeUser, handlePurge],
[
roles,
saveCell,
canPurgeUser,
handlePurge,
handleOpenProfile,
handleEdit,
handleArchive,
handleResetPwd,
],
);
return (

View File

@@ -123,10 +123,13 @@ const WalletsPage: React.FC = () => {
setSelectedRowKeys([]);
};
const openChange = (row: WalletRow) => {
setSelected(row);
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
};
const openChange = useCallback(
(row: WalletRow) => {
setSelected(row);
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
},
[form, setSelected],
);
const openBatchChange = () => {
batchForm.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
@@ -189,20 +192,23 @@ const WalletsPage: React.FC = () => {
}
};
const showTransactions = async (row: WalletRow) => {
setSelected(row);
setDrawerOpen(true);
try {
setTransactions(
await api.get<WalletTransaction[]>('/wallets/transactions', {
params: { studentId: row.studentId },
}),
);
} catch (error: unknown) {
console.error('加载余额流水失败', error);
message.error('加载流水失败');
}
};
const showTransactions = useCallback(
async (row: WalletRow) => {
setSelected(row);
setDrawerOpen(true);
try {
setTransactions(
await api.get<WalletTransaction[]>('/wallets/transactions', {
params: { studentId: row.studentId },
}),
);
} catch (error: unknown) {
console.error('加载余额流水失败', error);
message.error('加载流水失败');
}
},
[setSelected, setDrawerOpen, setTransactions],
);
const columns = useMemo(
() => [
@@ -257,7 +263,7 @@ const WalletsPage: React.FC = () => {
),
},
],
[],
[openChange, showTransactions],
);
return (