Files
gongxue-base/apps/admin/src/pages/Students/index.tsx
wangziqi 1a1e90c72f fix(admin): 补全 React hooks 依赖并修复页面稳定性
- 各页面 useCallback/useMemo 依赖补全(modal/mutation/setter 等),避免闭包过期
- ECharts 使用 optionRef、MainLayout 缓存菜单转换函数、main.tsx 增加挂载点校验
- 学生同步结果改用 recordsCount 展示
2026-08-05 21:14:08 +08:00

616 lines
20 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
App,
Form,
} from 'antd';
import api from '../../api';
import { usePermission } from '../../hooks/usePermission';
import { useUserStore } from '../../store/user/userStore';
import { selectArchiveRecords } from '../archive-view';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import {
organizationOptionsSchema,
organizationsSchema,
studentFilterLookupsSchema,
studentsSchema,
} from '../../api/schemas';
import { getErrorMessage } from '../../utils/error';
import { message } from '../../ui/app-message';
import { buildStudentColumns } from './StudentColumns';
import { StudentsToolbar } from './StudentsToolbar';
import {
JinshujuModal,
StudentDrawer,
StudentEditModal,
showCreateImportResult,
showUpdateImportResult,
} from './StudentModals';
import { StudentsTable } from './StudentsTable';
interface StudentCreateImportResult {
message?: string;
imported?: number;
skipped?: number;
}
interface StudentUpdateImportResult {
message?: string;
matched?: number;
skipped?: number;
}
interface DingTalkSyncLog {
status: string;
recordsCount: number;
errorMessage?: string | null;
}
interface DingTalkSyncResult {
synced: number;
logs: DingTalkSyncLog[];
}
interface StudentFilterLookups {
classes: Array<{ id: number; name: string; code?: string }>;
teachers: Array<{ id: number; name: string; username: string }>;
}
const StudentsPage: React.FC = () => {
const { modal } = App.useApp();
const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission();
const canViewOrganizations = hasPermission('organization:view');
const canLoadOrganizations = hasAnyPermission(
'organization:view',
'student:create',
'student:edit',
);
const canChooseOrganization = hasAnyPermission('student:create', 'student:edit');
const canCreateStudent = hasPermission('student:create');
const canEditStudent = hasPermission('student:edit');
const canDeleteStudent = hasPermission('student:delete');
const canPurgeStudent = hasPermission('student:purge');
const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger');
const canSyncDingTalk = hasAllPermissions('sync:read', 'sync:trigger');
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const canSaveStudent = editing ? canEditStudent : canCreateStudent;
const [searchName, setSearchName] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterOrganizationId, setFilterOrganizationId] = useState<number | undefined>(undefined);
const effectiveFilterOrganizationId = canLoadOrganizations ? filterOrganizationId : undefined;
const [filterClassId, setFilterClassId] = useState<number | undefined>(undefined);
const [filterTeacherId, setFilterTeacherId] = useState<number | undefined>(undefined);
const [showArchived, setShowArchived] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
const [dingSyncLoading, setDingSyncLoading] = useState(false);
const [pageInfo, setPageInfo] = useState({ current: 1, pageSize: 15 });
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerStudentId, setDrawerStudentId] = useState<number | undefined>(undefined);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [jinshujuOpen, setJinshujuOpen] = useState(false);
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);
logCreateRef.current = hasPermission('log:create');
useEffect(() => {
if (!logCreateRef.current && sensitiveModalRef.current) {
sensitiveModalRef.current.destroy();
sensitiveModalRef.current = null;
}
return () => {
sensitiveModalRef.current?.destroy();
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 = [],
isLoading,
isFetching,
} = useQuery<any[]>({
queryKey: [
'students',
searchName,
showArchived,
filterStatus,
effectiveFilterOrganizationId,
filterClassId,
filterTeacherId,
],
queryFn: async () => {
try {
const params: Record<string, unknown> = {
name: searchName || undefined,
includeArchived: showArchived ? 'true' : undefined,
};
if (showArchived) params.status = 'archived';
else if (filterStatus) params.status = filterStatus;
if (effectiveFilterOrganizationId) params.organizationId = effectiveFilterOrganizationId;
if (filterClassId) params.classId = filterClassId;
if (filterTeacherId) params.teacherId = filterTeacherId;
const res = (await api.get('/students', { params })) as Array<Record<string, unknown>>;
return selectArchiveRecords(
validateResponse<Array<Record<string, unknown>>>(studentsSchema, res),
showArchived ? 'archived' : 'active',
);
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
return [];
}
},
});
const loading = isLoading || isFetching;
const queryClient = useQueryClient();
const invalidateStudents: Array<readonly unknown[]> = [['students']];
const saveMutation = useApiMutation(
async (values: Record<string, unknown>) =>
editing ? api.put(`/students/${editing.id}`, values) : api.post('/students', values),
{ invalidate: invalidateStudents },
);
const saveCellMutation = useApiMutation(
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
api.put(`/students/${record.id}`, { [field]: value }),
{ invalidate: invalidateStudents },
);
const archiveMutation = useApiMutation(
async (id: number) => api.delete(`/students/${id}`),
{ invalidate: invalidateStudents },
);
const restoreMutation = useApiMutation(
async (id: number) => api.put(`/students/${id}/restore`),
{ invalidate: invalidateStudents },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/students/${id}/permanent`),
{ invalidate: invalidateStudents },
);
const batchDeleteMutation = useApiMutation(
async (ids: number[]) => api.post('/students/batch-delete', { ids }),
{ invalidate: invalidateStudents },
);
const batchRestoreMutation = useApiMutation(
async (ids: number[]) =>
api.put<{ message?: string; restored: number; skipped: number }>(
'/students/batch-restore',
{ ids },
),
{ invalidate: invalidateStudents },
);
const batchPurgeMutation = useApiMutation(
async (ids: number[]) => api.post('/students/batch-permanent-delete', { ids }),
{ invalidate: invalidateStudents },
);
const importMutation = useApiMutation(
async (formData: FormData) =>
api.post('/students/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
{ invalidate: invalidateStudents },
);
const importMatchMutation = useApiMutation(
async (formData: FormData) =>
api.post('/students/import-match', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
{ invalidate: invalidateStudents },
);
const { data: organizations = [] } = useQuery<
Array<{ id: number; name: string; isHost?: boolean }>
>({
queryKey: ['students', 'organizations', canViewOrganizations],
enabled: canLoadOrganizations,
queryFn: async () => {
try {
if (canViewOrganizations) {
return validateResponse<Array<{ id: number; name: string; isHost?: boolean }>>(
organizationsSchema,
await api.get('/organizations', {
params: { includeArchived: 'false' },
}),
);
}
return validateResponse<Array<{ id: number; name: string; isHost?: boolean }>>(
organizationOptionsSchema,
await api.get('/organizations/options'),
);
} catch {
return [];
}
},
});
const { data: lookups = { classes: [], teachers: [] } } = useQuery<StudentFilterLookups>({
queryKey: ['students', 'filter-lookups'],
enabled: canLoadOrganizations,
queryFn: async () => {
try {
return validateResponse<StudentFilterLookups>(
studentFilterLookupsSchema,
await api.get<StudentFilterLookups>('/students/filter-lookups'),
);
} catch {
return { classes: [], teachers: [] };
}
},
});
const classOptions = lookups.classes || [];
const teacherOptions = lookups.teachers || [];
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
await saveMutation.mutateAsync(values);
message.success(editing ? '更新成功' : '创建成功');
setModalOpen(false);
form.resetFields();
setEditing(null);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = useCallback(
async (record: any, field: string, value: unknown) => {
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[saveCellMutation],
);
const downloadApiFile = async (path: string, filename: string, errorMessage = '下载失败') => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = useUserStore.getState().token;
try {
const res = await fetch(`${baseURL}${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (error: unknown) {
console.error(errorMessage, error);
message.error(errorMessage);
}
};
const handleArchive = useCallback(
async (id: number) => {
try {
await archiveMutation.mutateAsync(id);
message.success('已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[archiveMutation],
);
const handleRestore = useCallback(
async (id: number) => {
try {
await restoreMutation.mutateAsync(id);
message.success('已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[restoreMutation],
);
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;
setBatchLoading(true);
try {
const res: any = await batchDeleteMutation.mutateAsync(selectedRowKeys);
message.success(res?.message || `已批量归档 ${selectedRowKeys.length}`);
setSelectedRowKeys([]);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
};
const handleBatchRestore = async () => {
if (batchLoading) return;
setBatchLoading(true);
try {
const res = await batchRestoreMutation.mutateAsync(selectedRowKeys);
message.success(
`已批量恢复 ${res.restored}${res.skipped ? `,跳过 ${res.skipped}` : ''}`,
);
setSelectedRowKeys([]);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
};
const handleBatchPurge = async () => {
if (batchLoading) return;
setBatchLoading(true);
try {
const res: any = await batchPurgeMutation.mutateAsync(selectedRowKeys);
message.success(res?.message || `已永久删除 ${selectedRowKeys.length}`);
setSelectedRowKeys([]);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
};
const handleDownloadTemplate = () => {
void downloadApiFile('/students/template', '学生导入模板.xlsx');
};
const handleCreateStudentsImport = async ({ file, onSuccess, onError }: any) => {
const formData = new FormData();
formData.append('file', file as File);
try {
const res = (await importMutation.mutateAsync(formData)) as StudentCreateImportResult;
showCreateImportResult(modal, res);
onSuccess?.(res);
} catch (e) {
onError?.(e instanceof Error ? e : new Error(getErrorMessage(e, '导入失败')));
}
};
const handleUpdateExistingStudentsImport = async ({ file, onSuccess, onError }: any) => {
const formData = new FormData();
formData.append('file', file as File);
try {
const res = (await importMatchMutation.mutateAsync(formData)) as StudentUpdateImportResult;
showUpdateImportResult(modal, res);
onSuccess?.(res);
} catch (e) {
onError?.(
e instanceof Error ? e : new Error(getErrorMessage(e, '更新已有学生资料失败')),
);
}
};
const handleDingTalkSync = async () => {
setDingSyncLoading(true);
try {
const res = await api.post<DingTalkSyncResult>('/sync/trigger', null, {
params: { platform: 'dingtalk_students', createMissing: false, updateProfile: false },
timeout: 120000,
});
const log = res.logs?.[0];
if (log?.status === 'partial') {
message.warning(log.errorMessage || '钉钉同步完成,但有数据需要人工处理');
} else {
message.success(
log?.errorMessage || `钉钉同步完成,共处理 ${log?.recordsCount ?? res.synced}`,
);
}
void queryClient.invalidateQueries({ queryKey: ['students'] });
} catch (e: unknown) {
message.error(getErrorMessage(e, '钉钉同步失败'));
} finally {
setDingSyncLoading(false);
}
};
const handleExport = () => {
const params = new URLSearchParams();
if (searchName) params.set('name', searchName);
if (filterStatus) params.set('status', filterStatus);
if (effectiveFilterOrganizationId)
params.set('organizationId', String(effectiveFilterOrganizationId));
if (showArchived) params.set('includeArchived', 'true');
if (filterClassId) params.set('classId', String(filterClassId));
if (filterTeacherId) params.set('teacherId', String(filterTeacherId));
const query = params.toString() ? `?${params.toString()}` : '';
void downloadApiFile(`/students/export${query}`, '学生名单.xlsx', '导出失败');
};
const columns = useMemo(
() =>
buildStudentColumns({
pageInfo,
organizations,
canChooseOrganization,
canEditStudent,
canDeleteStudent,
canPurgeStudent,
canViewSensitive: hasPermission('log:create'),
onSaveCell: saveCell,
onViewSensitive: handleViewSensitive,
onOpenDrawer: openDrawer,
onEdit: (record) => {
setEditing(record);
form.setFieldsValue(record);
setModalOpen(true);
},
onRestore: handleRestore,
onPurge: handlePurge,
onArchive: handleArchive,
}),
[
pageInfo,
organizations,
canChooseOrganization,
canEditStudent,
canDeleteStudent,
canPurgeStudent,
hasPermission,
saveCell,
handleViewSensitive,
form,
openDrawer,
handleArchive,
handleRestore,
handlePurge,
],
);
return (
<div>
<StudentsToolbar
onSearchName={setSearchName}
filterStatus={filterStatus}
onFilterStatus={setFilterStatus}
effectiveFilterOrganizationId={effectiveFilterOrganizationId}
onFilterOrganization={setFilterOrganizationId}
canViewOrganizations={canViewOrganizations}
organizations={organizations}
filterClassId={filterClassId}
onFilterClass={setFilterClassId}
classOptions={classOptions}
filterTeacherId={filterTeacherId}
onFilterTeacher={setFilterTeacherId}
teacherOptions={teacherOptions}
showArchived={showArchived}
onToggleArchived={() => {
setShowArchived(!showArchived);
setFilterStatus(undefined);
setSelectedRowKeys([]);
}}
selectedRowKeys={selectedRowKeys}
batchLoading={batchLoading}
canEditStudent={canEditStudent}
canPurgeStudent={canPurgeStudent}
canDeleteStudent={canDeleteStudent}
canSyncJinshuju={canSyncJinshuju}
canSyncDingTalk={canSyncDingTalk}
dingSyncLoading={dingSyncLoading}
onBatchRestore={handleBatchRestore}
onBatchPurge={handleBatchPurge}
onBatchDelete={handleBatchDelete}
onAddStudent={() => {
setEditing(null);
form.resetFields();
const host = organizations.find((organization) => organization.isHost);
if (host) form.setFieldValue('organizationId', host.id);
setModalOpen(true);
}}
onOpenJinshuju={() => setJinshujuOpen(true)}
onDingTalkSync={handleDingTalkSync}
onCreateImport={handleCreateStudentsImport}
onUpdateImport={handleUpdateExistingStudentsImport}
onDownloadTemplate={handleDownloadTemplate}
onExport={handleExport}
/>
<StudentsTable
columns={columns}
data={data}
loading={loading}
pageInfo={pageInfo}
onPageChange={(current, pageSize) => setPageInfo({ current, pageSize })}
selectedRowKeys={selectedRowKeys}
onSelect={setSelectedRowKeys}
onClearSelection={() => setSelectedRowKeys([])}
/>
<StudentEditModal
open={modalOpen && canSaveStudent}
editing={!!editing}
saving={saving}
form={form}
canChooseOrganization={canChooseOrganization}
organizations={organizations}
onOk={canSaveStudent ? handleSave : undefined}
onCancel={() => {
setModalOpen(false);
setEditing(null);
}}
/>
{canSyncJinshuju ? (
<JinshujuModal
open={jinshujuOpen && canSyncJinshuju}
onClose={() => setJinshujuOpen(false)}
onApplied={() => {
setJinshujuOpen(false);
void queryClient.invalidateQueries({ queryKey: ['students'] });
}}
/>
) : null}
<StudentDrawer open={drawerOpen} studentId={drawerStudentId ?? null} onClose={() => setDrawerOpen(false)} />
</div>
);
};
export default StudentsPage;