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(null); const canSaveStudent = editing ? canEditStudent : canCreateStudent; const [searchName, setSearchName] = useState(''); const [filterStatus, setFilterStatus] = useState(undefined); const [filterOrganizationId, setFilterOrganizationId] = useState(undefined); const effectiveFilterOrganizationId = canLoadOrganizations ? filterOrganizationId : undefined; const [filterClassId, setFilterClassId] = useState(undefined); const [filterTeacherId, setFilterTeacherId] = useState(undefined); const [showArchived, setShowArchived] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); 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(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 | 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({ queryKey: [ 'students', searchName, showArchived, filterStatus, effectiveFilterOrganizationId, filterClassId, filterTeacherId, ], queryFn: async () => { try { const params: Record = { 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>; return selectArchiveRecords( validateResponse>>(studentsSchema, res), showArchived ? 'archived' : 'active', ); } catch (e: unknown) { message.error(getErrorMessage(e, '加载失败,请稍后重试')); return []; } }, }); const loading = isLoading || isFetching; const queryClient = useQueryClient(); const invalidateStudents: Array = [['students']]; const saveMutation = useApiMutation( async (values: Record) => 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>( organizationsSchema, await api.get('/organizations', { params: { includeArchived: 'false' }, }), ); } return validateResponse>( organizationOptionsSchema, await api.get('/organizations/options'), ); } catch { return []; } }, }); const { data: lookups = { classes: [], teachers: [] } } = useQuery({ queryKey: ['students', 'filter-lookups'], enabled: canLoadOrganizations, queryFn: async () => { try { return validateResponse( studentFilterLookupsSchema, await api.get('/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('/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 (
{ 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} /> setPageInfo({ current, pageSize })} selectedRowKeys={selectedRowKeys} onSelect={setSelectedRowKeys} onClearSelection={() => setSelectedRowKeys([])} /> { setModalOpen(false); setEditing(null); }} /> {canSyncJinshuju ? ( setJinshujuOpen(false)} onApplied={() => { setJinshujuOpen(false); void queryClient.invalidateQueries({ queryKey: ['students'] }); }} /> ) : null} setDrawerOpen(false)} />
); }; export default StudentsPage;