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(
|
const confirmEditMessage = useCallback(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -62,6 +62,6 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[studentId, module],
|
[studentId, module, modal],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,10 +169,11 @@ const MainLayout: React.FC = () => {
|
|||||||
navigate(key);
|
navigate(key);
|
||||||
if (usesDrawer) setDrawerOpen(false);
|
if (usesDrawer) setDrawerOpen(false);
|
||||||
},
|
},
|
||||||
[navigate, usesDrawer],
|
[navigate, usesDrawer, setDrawerOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => {
|
const findSelectedKeys = useCallback(
|
||||||
|
(items: AppMenuItem[], pathname: string): string[] => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.key === pathname) return [item.key];
|
if (item.key === pathname) return [item.key];
|
||||||
if (item.children) {
|
if (item.children) {
|
||||||
@@ -181,7 +182,9 @@ const MainLayout: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [pathname];
|
return [pathname];
|
||||||
};
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => {
|
const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -201,7 +204,7 @@ const MainLayout: React.FC = () => {
|
|||||||
|
|
||||||
const selectedKeys = useMemo(
|
const selectedKeys = useMemo(
|
||||||
() => findSelectedKeys(menuItems, location.pathname),
|
() => findSelectedKeys(menuItems, location.pathname),
|
||||||
[menuItems, location.pathname],
|
[menuItems, location.pathname, findSelectedKeys],
|
||||||
);
|
);
|
||||||
// 路径变化时同步展开的菜单(不干扰用户手动展开/收起)
|
// 路径变化时同步展开的菜单(不干扰用户手动展开/收起)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -210,20 +213,20 @@ const MainLayout: React.FC = () => {
|
|||||||
const routeOpenKeys = findOpenKeys(menuItems, location.pathname);
|
const routeOpenKeys = findOpenKeys(menuItems, location.pathname);
|
||||||
setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]);
|
setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]);
|
||||||
}
|
}
|
||||||
}, [location.pathname, menuItems]);
|
}, [location.pathname, menuItems, setOpenKeys]);
|
||||||
|
|
||||||
const handleOpenChange = useCallback((keys: string[]) => {
|
const handleOpenChange = useCallback((keys: string[]) => {
|
||||||
setOpenKeys(keys);
|
setOpenKeys(keys);
|
||||||
}, []);
|
}, [setOpenKeys]);
|
||||||
|
|
||||||
const transformToMenuItems = (items: AppMenuItem[]): any[] => {
|
const transformToMenuItems = useCallback((items: AppMenuItem[]): any[] => {
|
||||||
return items.map((item) => ({
|
return items.map((item) => ({
|
||||||
key: item.key,
|
key: item.key,
|
||||||
icon: item.icon ? iconMap[item.icon] : undefined,
|
icon: item.icon ? iconMap[item.icon] : undefined,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
children: item.children ? transformToMenuItems(item.children) : undefined,
|
children: item.children ? transformToMenuItems(item.children) : undefined,
|
||||||
}));
|
}));
|
||||||
};
|
}, []);
|
||||||
const menuContent = useMemo(
|
const menuContent = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<Menu
|
<Menu
|
||||||
@@ -237,7 +240,7 @@ const MainLayout: React.FC = () => {
|
|||||||
style={{ border: 'none' }}
|
style={{ border: 'none' }}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[selectedKeys, openKeys, menuItems, handleMenuClick],
|
[selectedKeys, openKeys, menuItems, handleMenuClick, handleOpenChange, transformToMenuItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 ──
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
App,
|
App,
|
||||||
Table,
|
Table,
|
||||||
@@ -139,7 +139,7 @@ const BillsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showDetail = async (id: number) => {
|
const showDetail = useCallback(async (id: number) => {
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/bills/${id}`);
|
const res = await api.get(`/bills/${id}`);
|
||||||
@@ -149,9 +149,10 @@ const BillsPage: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleCancel = async (id: number) => {
|
const handleCancel = useCallback(
|
||||||
|
(id: number) => {
|
||||||
let reason = '';
|
let reason = '';
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: '取消账单并退回已扣余额',
|
title: '取消账单并退回已扣余额',
|
||||||
@@ -175,18 +176,24 @@ const BillsPage: React.FC = () => {
|
|||||||
message.success('账单已取消,已扣余额已冲正退回');
|
message.success('账单已取消,已扣余额已冲正退回');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[modal, cancelMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handleArchive = async (id: number) => {
|
const handleArchive = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await archiveMutation.mutateAsync(id);
|
await archiveMutation.mutateAsync(id);
|
||||||
message.success('账单已归档');
|
message.success('账单已归档');
|
||||||
} catch {
|
} catch {
|
||||||
// 错误提示由 useApiMutation 统一处理
|
// 错误提示由 useApiMutation 统一处理
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[archiveMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePurge = (id: number, studentName: string, period: string) => {
|
const handlePurge = useCallback(
|
||||||
|
(id: number, studentName: string, period: string) => {
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: `永久删除账单(${studentName} ${period})?`,
|
title: `永久删除账单(${studentName} ${period})?`,
|
||||||
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
|
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
|
||||||
@@ -202,7 +209,9 @@ const BillsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[modal, purgeMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const batchArchive = async () => {
|
const batchArchive = async () => {
|
||||||
if (selectedRows.length === 0) return message.warning('请先选择账单');
|
if (selectedRows.length === 0) return message.warning('请先选择账单');
|
||||||
@@ -226,7 +235,7 @@ const BillsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportPdf = async (billId: number) => {
|
const handleExportPdf = useCallback(async (billId: number) => {
|
||||||
const printWindow = window.open('', '_blank');
|
const printWindow = window.open('', '_blank');
|
||||||
if (!printWindow) {
|
if (!printWindow) {
|
||||||
message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试');
|
message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试');
|
||||||
@@ -245,7 +254,7 @@ const BillsPage: React.FC = () => {
|
|||||||
printWindow.close();
|
printWindow.close();
|
||||||
message.error(error?.message || '账单加载失败');
|
message.error(error?.message || '账单加载失败');
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
@@ -89,33 +89,6 @@ const ClassesPage: React.FC = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
|
||||||
const handleArchive = async (id: number, archive: boolean) => {
|
|
||||||
try {
|
|
||||||
await archiveMutation.mutateAsync({ id, archive });
|
|
||||||
message.success(archive ? '已归档' : '已恢复');
|
|
||||||
} catch {
|
|
||||||
// 错误提示由 useApiMutation 统一处理
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePurge = (record: ClassItem) => {
|
|
||||||
modal.confirm({
|
|
||||||
title: `永久删除班级「${record.name}」?`,
|
|
||||||
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
|
|
||||||
okText: '永久删除',
|
|
||||||
okButtonProps: { danger: true },
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
await purgeMutation.mutateAsync(record.id);
|
|
||||||
message.success('已永久删除(不可恢复)');
|
|
||||||
} catch {
|
|
||||||
// 错误提示由 useApiMutation 统一处理
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data = [],
|
data = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -160,6 +133,39 @@ const ClassesPage: React.FC = () => {
|
|||||||
{ invalidate: [['classes']] },
|
{ invalidate: [['classes']] },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleArchive = useCallback(
|
||||||
|
async (id: number, archive: boolean) => {
|
||||||
|
try {
|
||||||
|
await archiveMutation.mutateAsync({ id, archive });
|
||||||
|
message.success(archive ? '已归档' : '已恢复');
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 useApiMutation 统一处理
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[archiveMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePurge = useCallback(
|
||||||
|
(record: ClassItem) => {
|
||||||
|
modal.confirm({
|
||||||
|
title: `永久删除班级「${record.name}」?`,
|
||||||
|
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
|
||||||
|
okText: '永久删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await purgeMutation.mutateAsync(record.id);
|
||||||
|
message.success('已永久删除(不可恢复)');
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 useApiMutation 统一处理
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[modal, purgeMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!searchText) return data;
|
if (!searchText) return data;
|
||||||
const q = searchText.toLowerCase();
|
const q = searchText.toLowerCase();
|
||||||
@@ -174,7 +180,8 @@ const ClassesPage: React.FC = () => {
|
|||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (record: ClassItem) => {
|
const handleEdit = useCallback(
|
||||||
|
(record: ClassItem) => {
|
||||||
setEditing(record);
|
setEditing(record);
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
...record,
|
...record,
|
||||||
@@ -183,7 +190,9 @@ const ClassesPage: React.FC = () => {
|
|||||||
endDate: record.endDate ? dayjs(record.endDate) : undefined,
|
endDate: record.endDate ? dayjs(record.endDate) : undefined,
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -369,7 +378,7 @@ const ClassesPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[saveCell, canPurgeClass, handlePurge],
|
[saveCell, canPurgeClass, handlePurge, navigate, handleEdit, handleArchive],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[setUnavailableDates],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClassroomChange = (classroomId: number) => {
|
const handleClassroomChange = (classroomId: number) => {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
@@ -147,37 +147,48 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveCell = async (record: any, field: string, value: unknown) => {
|
const saveCell = useCallback(
|
||||||
|
async (record: any, field: string, value: unknown) => {
|
||||||
try {
|
try {
|
||||||
await saveCellMutation.mutateAsync({ record, field, value });
|
await saveCellMutation.mutateAsync({ record, field, value });
|
||||||
message.success('已保存');
|
message.success('已保存');
|
||||||
} catch {
|
} catch {
|
||||||
// 错误提示由 useApiMutation 统一处理
|
// 错误提示由 useApiMutation 统一处理
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[saveCellMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handleArchive = async (id: number) => {
|
const handleArchive = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await archiveMutation.mutateAsync(id);
|
await archiveMutation.mutateAsync(id);
|
||||||
message.success('已归档');
|
message.success('已归档');
|
||||||
} catch {
|
} catch {
|
||||||
// 错误提示由 useApiMutation 统一处理
|
// 错误提示由 useApiMutation 统一处理
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[archiveMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handleRestore = async (id: number) => {
|
const handleRestore = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await restoreMutation.mutateAsync(id);
|
await restoreMutation.mutateAsync(id);
|
||||||
message.success('已恢复');
|
message.success('已恢复');
|
||||||
} catch {
|
} catch {
|
||||||
// 错误提示由 useApiMutation 统一处理
|
// 错误提示由 useApiMutation 统一处理
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[restoreMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePurge = (id: number, name: string) => {
|
const handlePurge = useCallback(
|
||||||
|
(id: number, name: string) => {
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: `永久删除教室「${name}」?`,
|
title: `永久删除教室「${name}」?`,
|
||||||
content: '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?',
|
content:
|
||||||
|
'删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?',
|
||||||
okText: '永久删除',
|
okText: '永久删除',
|
||||||
okButtonProps: { danger: true },
|
okButtonProps: { danger: true },
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
@@ -190,7 +201,9 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[modal, purgeMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDownloadTemplate = () => {
|
const handleDownloadTemplate = () => {
|
||||||
const baseURL = import.meta.env.PROD
|
const baseURL = import.meta.env.PROD
|
||||||
@@ -397,7 +410,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[handlePurge, hasPermission],
|
[handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -369,7 +369,8 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePurge = (id: number, studentName: string) => {
|
const handlePurge = useCallback(
|
||||||
|
(id: number, studentName: string) => {
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: `永久删除入住记录(${studentName})?`,
|
title: `永久删除入住记录(${studentName})?`,
|
||||||
content: '删除后不可恢复,该入住记录将被物理删除。确定继续?',
|
content: '删除后不可恢复,该入住记录将被物理删除。确定继续?',
|
||||||
@@ -385,7 +386,9 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[modal, purgeMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handleBatchPurge = async () => {
|
const handleBatchPurge = async () => {
|
||||||
if (batchLoading) return;
|
if (batchLoading) return;
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -92,12 +92,15 @@ const RolesPage: React.FC = () => {
|
|||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (record: RoleItem) => {
|
const handleEdit = useCallback(
|
||||||
|
(record: RoleItem) => {
|
||||||
setEditing(record);
|
setEditing(record);
|
||||||
form.setFieldsValue({ name: record.name, description: record.description });
|
form.setFieldsValue({ name: record.name, description: record.description });
|
||||||
setSelectedPermIds(record.permissions.map((p) => p.id));
|
setSelectedPermIds(record.permissions.map((p) => p.id));
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -117,14 +120,17 @@ const RolesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisable = async (id: number) => {
|
const handleDisable = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await disableMutation.mutateAsync(id);
|
await disableMutation.mutateAsync(id);
|
||||||
message.success('角色已停用');
|
message.success('角色已停用');
|
||||||
} catch {
|
} catch {
|
||||||
// 错误提示由 useApiMutation 统一处理
|
// 错误提示由 useApiMutation 统一处理
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[disableMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const groupNames: Record<string, string> = {
|
const groupNames: Record<string, string> = {
|
||||||
dashboard: '数据面板',
|
dashboard: '数据面板',
|
||||||
@@ -264,7 +270,7 @@ const RolesPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[permissionOptions, saveCell],
|
[permissionOptions, saveCell, handleEdit, handleDisable],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGroupCheckAll = (group: string, checked: boolean) => {
|
const handleGroupCheckAll = (group: string, checked: boolean) => {
|
||||||
|
|||||||
@@ -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)}>
|
||||||
返回今天
|
返回今天
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -93,10 +93,10 @@ const StudentsPage: React.FC = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [jinshujuOpen, setJinshujuOpen] = useState(false);
|
const [jinshujuOpen, setJinshujuOpen] = useState(false);
|
||||||
|
|
||||||
const openDrawer = (studentId: number) => {
|
const openDrawer = useCallback((studentId: number) => {
|
||||||
setDrawerStudentId(studentId);
|
setDrawerStudentId(studentId);
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const logCreateRef = React.useRef(hasPermission('log:create'));
|
const logCreateRef = React.useRef(hasPermission('log:create'));
|
||||||
const sensitiveModalRef = React.useRef<ReturnType<typeof modal.confirm> | null>(null);
|
const sensitiveModalRef = React.useRef<ReturnType<typeof modal.confirm> | null>(null);
|
||||||
@@ -112,7 +112,8 @@ const StudentsPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleViewSensitive = (studentId: number, field: string, value: string) => {
|
const handleViewSensitive = useCallback(
|
||||||
|
(studentId: number, field: string, value: string) => {
|
||||||
if (!logCreateRef.current) return;
|
if (!logCreateRef.current) return;
|
||||||
sensitiveModalRef.current = modal.confirm({
|
sensitiveModalRef.current = modal.confirm({
|
||||||
title: '查看敏感信息',
|
title: '查看敏感信息',
|
||||||
@@ -143,7 +144,9 @@ const StudentsPage: React.FC = () => {
|
|||||||
sensitiveModalRef.current = null;
|
sensitiveModalRef.current = null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[modal],
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data = [],
|
data = [],
|
||||||
@@ -329,25 +332,32 @@ const StudentsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchive = async (id: number) => {
|
const handleArchive = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await archiveMutation.mutateAsync(id);
|
await archiveMutation.mutateAsync(id);
|
||||||
message.success('已归档');
|
message.success('已归档');
|
||||||
} catch {
|
} catch {
|
||||||
// 错误提示由 useApiMutation 统一处理
|
// 错误提示由 useApiMutation 统一处理
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[archiveMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handleRestore = async (id: number) => {
|
const handleRestore = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await restoreMutation.mutateAsync(id);
|
await restoreMutation.mutateAsync(id);
|
||||||
message.success('已恢复');
|
message.success('已恢复');
|
||||||
} catch {
|
} catch {
|
||||||
// 错误提示由 useApiMutation 统一处理
|
// 错误提示由 useApiMutation 统一处理
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[restoreMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePurge = (id: number, name: string) => {
|
const handlePurge = useCallback(
|
||||||
|
(id: number, name: string) => {
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: `永久删除学生「${name}」?`,
|
title: `永久删除学生「${name}」?`,
|
||||||
content: '删除后不可恢复,该学生及其关联数据将无法找回。确定继续?',
|
content: '删除后不可恢复,该学生及其关联数据将无法找回。确定继续?',
|
||||||
@@ -363,7 +373,9 @@ const StudentsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[modal, purgeMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handleBatchDelete = async () => {
|
const handleBatchDelete = async () => {
|
||||||
if (batchLoading) return;
|
if (batchLoading) return;
|
||||||
@@ -450,7 +462,9 @@ const StudentsPage: React.FC = () => {
|
|||||||
if (log?.status === 'partial') {
|
if (log?.status === 'partial') {
|
||||||
message.warning(log.errorMessage || '钉钉同步完成,但有数据需要人工处理');
|
message.warning(log.errorMessage || '钉钉同步完成,但有数据需要人工处理');
|
||||||
} else {
|
} else {
|
||||||
message.success(log?.errorMessage || `钉钉同步完成,共处理 ${res.synced} 条`);
|
message.success(
|
||||||
|
log?.errorMessage || `钉钉同步完成,共处理 ${log?.recordsCount ?? res.synced} 条`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({ queryKey: ['students'] });
|
void queryClient.invalidateQueries({ queryKey: ['students'] });
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -506,6 +520,10 @@ const StudentsPage: React.FC = () => {
|
|||||||
saveCell,
|
saveCell,
|
||||||
handleViewSensitive,
|
handleViewSensitive,
|
||||||
form,
|
form,
|
||||||
|
openDrawer,
|
||||||
|
handleArchive,
|
||||||
|
handleRestore,
|
||||||
|
handlePurge,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ const UsersPage: React.FC = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
|
||||||
const handleOpenProfile = async (record: any) => {
|
const handleOpenProfile = useCallback(
|
||||||
|
async (record: any) => {
|
||||||
setProfileUser(record);
|
setProfileUser(record);
|
||||||
try {
|
try {
|
||||||
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
|
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
|
||||||
@@ -53,7 +54,9 @@ const UsersPage: React.FC = () => {
|
|||||||
profileForm.setFieldsValue({});
|
profileForm.setFieldsValue({});
|
||||||
}
|
}
|
||||||
setProfileModalOpen(true);
|
setProfileModalOpen(true);
|
||||||
};
|
},
|
||||||
|
[profileForm],
|
||||||
|
);
|
||||||
|
|
||||||
const handleProfileSubmit = async () => {
|
const handleProfileSubmit = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -133,7 +136,8 @@ const UsersPage: React.FC = () => {
|
|||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (record: any) => {
|
const handleEdit = useCallback(
|
||||||
|
(record: any) => {
|
||||||
setEditing(record);
|
setEditing(record);
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
username: record.username,
|
username: record.username,
|
||||||
@@ -141,7 +145,9 @@ const UsersPage: React.FC = () => {
|
|||||||
roleIds: record.roles?.map((r: any) => r.id) || [],
|
roleIds: record.roles?.map((r: any) => r.id) || [],
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -161,16 +167,20 @@ const UsersPage: React.FC = () => {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleArchive = async (id: number, archive: boolean) => {
|
const handleArchive = useCallback(
|
||||||
|
async (id: number, archive: boolean) => {
|
||||||
try {
|
try {
|
||||||
await archiveMutation.mutateAsync({ id, archive });
|
await archiveMutation.mutateAsync({ id, archive });
|
||||||
message.success(archive ? '已归档' : '已恢复');
|
message.success(archive ? '已归档' : '已恢复');
|
||||||
} catch {
|
} catch {
|
||||||
// 错误提示由 useApiMutation 统一处理
|
// 错误提示由 useApiMutation 统一处理
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[archiveMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePurge = (record: any) => {
|
const handlePurge = useCallback(
|
||||||
|
(record: any) => {
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: `永久删除账号「${record.name || record.username}」?`,
|
title: `永久删除账号「${record.name || record.username}」?`,
|
||||||
content:
|
content:
|
||||||
@@ -187,13 +197,18 @@ const UsersPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[modal, purgeMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handleResetPwd = (record: any) => {
|
const handleResetPwd = useCallback(
|
||||||
|
(record: any) => {
|
||||||
setResetTarget(record);
|
setResetTarget(record);
|
||||||
pwdForm.resetFields();
|
pwdForm.resetFields();
|
||||||
setPwdModalOpen(true);
|
setPwdModalOpen(true);
|
||||||
};
|
},
|
||||||
|
[pwdForm],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePwdSubmit = async () => {
|
const handlePwdSubmit = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -353,7 +368,16 @@ const UsersPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[roles, saveCell, canPurgeUser, handlePurge],
|
[
|
||||||
|
roles,
|
||||||
|
saveCell,
|
||||||
|
canPurgeUser,
|
||||||
|
handlePurge,
|
||||||
|
handleOpenProfile,
|
||||||
|
handleEdit,
|
||||||
|
handleArchive,
|
||||||
|
handleResetPwd,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ const WalletsPage: React.FC = () => {
|
|||||||
setSelectedRowKeys([]);
|
setSelectedRowKeys([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openChange = (row: WalletRow) => {
|
const openChange = useCallback(
|
||||||
|
(row: WalletRow) => {
|
||||||
setSelected(row);
|
setSelected(row);
|
||||||
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
|
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
|
||||||
};
|
},
|
||||||
|
[form, setSelected],
|
||||||
|
);
|
||||||
|
|
||||||
const openBatchChange = () => {
|
const openBatchChange = () => {
|
||||||
batchForm.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
|
batchForm.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
|
||||||
@@ -189,7 +192,8 @@ const WalletsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showTransactions = async (row: WalletRow) => {
|
const showTransactions = useCallback(
|
||||||
|
async (row: WalletRow) => {
|
||||||
setSelected(row);
|
setSelected(row);
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
try {
|
try {
|
||||||
@@ -202,7 +206,9 @@ const WalletsPage: React.FC = () => {
|
|||||||
console.error('加载余额流水失败', error);
|
console.error('加载余额流水失败', error);
|
||||||
message.error('加载流水失败');
|
message.error('加载流水失败');
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[setSelected, setDrawerOpen, setTransactions],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -257,7 +263,7 @@ const WalletsPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[openChange, showTransactions],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user