612 lines
20 KiB
TypeScript
612 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),
|
|
{ invalidate: invalidateStudents },
|
|
);
|
|
const importMatchMutation = useApiMutation(
|
|
async (formData: FormData) =>
|
|
api.post('/students/import-match', formData),
|
|
{ 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;
|