fix(admin): 补全 React hooks 依赖并修复页面稳定性
- 各页面 useCallback/useMemo 依赖补全(modal/mutation/setter 等),避免闭包过期 - ECharts 使用 optionRef、MainLayout 缓存菜单转换函数、main.tsx 增加挂载点校验 - 学生同步结果改用 recordsCount 展示
This commit is contained in:
@@ -271,7 +271,7 @@ export function useAiChatMessageActions({
|
||||
},
|
||||
});
|
||||
},
|
||||
[activeId, isRequesting, refreshConversations, removeMessage],
|
||||
[activeId, isRequesting, refreshConversations, removeMessage, modal],
|
||||
);
|
||||
|
||||
const confirmEditMessage = useCallback(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -62,6 +62,6 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
|
||||
},
|
||||
});
|
||||
},
|
||||
[studentId, module],
|
||||
[studentId, module, modal],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
() => [
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -198,7 +198,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
[setUnavailableDates],
|
||||
);
|
||||
|
||||
const handleClassroomChange = (classroomId: number) => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}>
|
||||
返回今天
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user