import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, App, Button, Card, Col, Descriptions, Drawer, Empty, Form, Input, Modal, Popconfirm, Row, Select, Space, Table, Tag, Upload, } from 'antd'; import type { UploadProps } from 'antd'; import { CloudUploadOutlined, DownloadOutlined, ExportOutlined, EyeOutlined, InboxOutlined, PlusOutlined, SwapOutlined, UndoOutlined, UploadOutlined, } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import StudentProfileContent from '../../components/StudentProfileContent'; import EditableCell from '../../components/EditableCell'; import JinshujuMatchModal from '../../components/JinshujuMatchModal'; import { maskIdNumber, maskPhone } from '../../utils/sensitive'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; const statusMap: Record = { active: { text: '在读', color: 'green' }, graduated: { text: '已毕业', color: 'blue' }, withdrawn: { text: '已退训', color: 'red' }, archived: { text: '已归档', color: '#999' }, }; interface EnrollmentInfo { classId: number; className: string; classType: string; startDate: string; endDate: string; joinDate: string; leaveDate: string; status: string; attendanceStats: { total: number; present: number; absent: number; late: number; leave: number; rate: number; }; } interface StudentCreateImportResult { message?: string; imported?: number; skipped?: number; } interface StudentUpdateImportResult { message?: string; matched?: number; skipped?: number; } 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 canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger'); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [organizations, setOrganizations] = useState([]); 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 [filterClassId, setFilterClassId] = useState(undefined); const [filterTeacherId, setFilterTeacherId] = useState(undefined); const [classOptions, setClassOptions] = useState([]); const [teacherOptions, setTeacherOptions] = useState([]); const [showArchived, setShowArchived] = useState(false); const [archivedCount, setArchivedCount] = useState(0); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchLoading, setBatchLoading] = useState(false); const [enrollmentData, setEnrollmentData] = useState>({}); const [drawerOpen, setDrawerOpen] = useState(false); const [drawerStudentId, setDrawerStudentId] = useState(undefined); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); const openDrawer = (studentId: number) => { setDrawerStudentId(studentId); setDrawerOpen(true); }; const [jinshujuOpen, setJinshujuOpen] = useState(false); // Sensitive info modal — command-style; destroy when log:create is lost or comp unmounts. // Close the student form modal when the user loses the required permission. useEffect(() => { if (!canSaveStudent && modalOpen) { setModalOpen(false); setEditing(null); form.resetFields(); } }, [canSaveStudent, modalOpen, form]); // Close sensitive modal when log:create is lost (imperative ref already set above). 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 = (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 { message.error('审计日志记录失败,请稍后重试'); } }, afterClose: () => { sensitiveModalRef.current = null; }, }); }; const handleBatchDelete = async () => { setBatchLoading(true); try { const res: any = await api.post('/students/batch-delete', { ids: selectedRowKeys }); message.success(res?.message || `已批量归档 ${selectedRowKeys.length} 人`); setSelectedRowKeys([]); fetchData(); } catch (e: any) { message.error(e?.message || '批量归档失败'); } finally { setBatchLoading(false); } }; const fetchData = useCallback(async () => { setLoading(true); try { const params: Record = { name: searchName || undefined, includeArchived: 'true', }; if (filterStatus) params.status = filterStatus; if (filterOrganizationId) params.organizationId = filterOrganizationId; if (filterClassId) params.classId = filterClassId; if (filterTeacherId) params.teacherId = filterTeacherId; const res = (await api.get('/students', { params })) as Array>; const list = res as Array>; const archived = list.filter((r) => r.status === 'archived'); setArchivedCount(archived.length); setData(showArchived ? list : list.filter((r) => r.status !== 'archived')); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '加载失败,请稍后重试'); } setLoading(false); }, [ searchName, showArchived, filterStatus, filterOrganizationId, filterClassId, filterTeacherId, ]); useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { if (!canLoadOrganizations) { setOrganizations([]); setFilterOrganizationId(undefined); return; } if (canViewOrganizations) { api .get('/organizations', { params: { includeArchived: 'false' } }) .then((res: unknown) => { setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); }) .catch(() => {}); } else { api .get('/organizations/options') .then((res: unknown) => { setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); }) .catch(() => {}); } api .get('/students/filter-lookups') .then((res) => { setClassOptions(res.classes || []); setTeacherOptions(res.teachers || []); }) .catch(() => {}); }, [canLoadOrganizations]); const handleSave = async () => { const values = await form.validateFields(); setSaving(true); try { if (editing) { await api.put(`/students/${editing.id}`, values); message.success('更新成功'); } else { await api.post('/students', values); message.success('创建成功'); } setModalOpen(false); form.resetFields(); setEditing(null); fetchData(); } catch (e: any) { message.error(e?.message || '操作失败'); } finally { setSaving(false); } }; const saveCell = useCallback( async (record: any, field: string, value: unknown) => { await api.put(`/students/${record.id}`, { [field]: value }); message.success('已保存'); await fetchData(); }, [fetchData], ); const handleArchive = async (id: number) => { try { await api.delete(`/students/${id}`); message.success('已归档'); fetchData(); } catch (e: any) { message.error(e?.message || '归档失败'); } }; const handleRestore = async (id: number) => { try { await api.put(`/students/${id}/restore`); message.success('已恢复'); fetchData(); } catch (e: any) { message.error(e?.message || '恢复失败'); } }; const handleDownloadTemplate = () => { const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; const token = localStorage.getItem('token'); fetch(`${baseURL}/students/template`, { headers: { Authorization: `Bearer ${token}` } }) .then((res) => res.blob()) .then((blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '学生导入模板.xlsx'; a.click(); URL.revokeObjectURL(url); }) .catch(() => message.error('下载失败')); }; const showCreateImportResult = (result: StudentCreateImportResult) => { const imported = result.imported ?? 0; const skipped = result.skipped ?? 0; modal.success({ title: '导入完成', okText: '知道了', content: (
{imported} 人 {skipped} 人
跳过原因:
  • 姓名为空
  • 已存在同名学生
当前后端只返回统计汇总,暂时无法列出具体哪几行被跳过。
), }); }; const showUpdateImportResult = (result: StudentUpdateImportResult) => { const matched = result.matched ?? 0; const skipped = result.skipped ?? 0; modal.success({ title: '更新完成', okText: '知道了', content: (
{matched} 人 {skipped} 人
匹配规则:
手机号优先,身份证号其次
当前后端只返回统计汇总,暂时无法列出具体哪几行未匹配。
), }); }; const handleCreateStudentsImport: UploadProps['customRequest'] = async ({ file, onSuccess, onError, }) => { const formData = new FormData(); formData.append('file', file as File); try { const res = (await api.post('/students/import', formData, { headers: { 'Content-Type': 'multipart/form-data' }, })) as StudentCreateImportResult; showCreateImportResult(res); onSuccess?.(res); fetchData(); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '导入失败'); onError?.(e instanceof Error ? e : new Error(err?.message || '导入失败')); } }; const handleUpdateExistingStudentsImport: UploadProps['customRequest'] = async ({ file, onSuccess, onError, }) => { const formData = new FormData(); formData.append('file', file as File); try { const res = (await api.post('/students/import-match', formData, { headers: { 'Content-Type': 'multipart/form-data' }, })) as StudentUpdateImportResult; showUpdateImportResult(res); onSuccess?.(res); fetchData(); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '更新已有学生资料失败'); onError?.(e instanceof Error ? e : new Error(err?.message || '更新已有学生资料失败')); } }; const handleExport = () => { const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; const token = localStorage.getItem('token'); const params = new URLSearchParams(); if (searchName) params.set('name', searchName); if (filterStatus) params.set('status', filterStatus); if (filterOrganizationId) params.set('organizationId', String(filterOrganizationId)); 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()}` : ''; fetch(`${baseURL}/students/export${query}`, { headers: { Authorization: `Bearer ${token}` } }) .then((res) => res.blob()) .then((blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '学生名单.xlsx'; a.click(); URL.revokeObjectURL(url); }) .catch(() => message.error('导出失败')); }; const columns = useMemo( () => [ { title: 'ID', dataIndex: 'id', width: 70 }, { title: '姓名', dataIndex: 'name', width: 120, render: (v: string, record: any) => ( saveCell(record, 'name', next)} > {v} ), }, { title: '电话', dataIndex: 'phone', width: 140, render: (v: string, record: any) => { if (!v) return '-'; return ( {maskPhone(v)} {hasPermission('log:create') ? ( ) : null} ); }, }, { title: '学号', dataIndex: 'studentNo', width: 120, render: (v: string, record: any) => ( saveCell(record, 'studentNo', next)} > {v || '-'} ), }, { title: '身份证', dataIndex: 'idNumber', width: 180, render: (v: string, record: any) => { if (!v) return '-'; return ( {maskIdNumber(v)} {hasPermission('log:create') ? ( ) : null} ); }, }, { title: '民族', dataIndex: 'ethnicity', width: 90, render: (v: string, record: any) => ( saveCell(record, 'ethnicity', next)} > {v || '-'} ), }, { title: '紧急联系人', dataIndex: 'emergencyContact', width: 100, render: (v: string, record: any) => ( saveCell(record, 'emergencyContact', next)} > {v || '-'} ), }, { title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 150, render: (v: string, record: any) => { if (!v) return '-'; return ( {maskPhone(v)} {hasPermission('log:create') ? ( ) : null} ); }, }, { title: '所属机构', dataIndex: 'organization', width: 100, render: (organization: { name?: string } | null, record: any) => canChooseOrganization ? ( ({ value: item.id, label: item.name }))} permission="student:edit" disabled={record.status === 'archived'} required onSave={(next) => saveCell(record, 'organizationId', next)} > {organization?.name ? ( {organization.name} ) : ( '-' )} ) : organization?.name ? ( {organization.name} ) : ( '-' ), }, { title: '负责人', dataIndex: 'supervisor', width: 100, render: (v: string, record: any) => ( saveCell(record, 'supervisor', next)} > {v || '-'} ), }, { title: '状态', dataIndex: 'status', width: 80, render: (s: string, record: any) => ( saveCell(record, 'status', next)} > {statusMap[s]?.text || s} ), }, { title: '操作', width: 180, render: (_: any, record: any) => ( {record.status === 'archived' ? ( canEditStudent ? ( handleRestore(record.id)} okText="恢复" cancelText="取消" > ) : null ) : ( <> openDrawer(record.id)} > 档案 { setEditing(record); form.setFieldsValue(record); setModalOpen(true); }} > 编辑 {canDeleteStudent ? ( handleArchive(record.id)} okText="归档" cancelText="取消" > ) : null} )} ), }, ], [ handleViewSensitive, openDrawer, showArchived, organizations, saveCell, hasPermission, canChooseOrganization, ], ); return (
{canViewOrganizations ? ( ) : null} { setFilterTeacherId(v); }} options={teacherOptions.map((item) => ({ value: item.id, label: item.name === item.username ? item.name : `${item.name}(${item.username})`, }))} /> {canDeleteStudent ? ( ) : null} } onClick={() => { setEditing(null); form.resetFields(); const host = organizations.find((organization) => organization.isHost); if (host) form.setFieldValue('organizationId', host.id); setModalOpen(true); }} > 添加学生 {hasPermission('student:import') ? ( <> ) : null} {canSyncJinshuju ? ( ) : null} } onClick={handleDownloadTemplate} > 下载模板 } onClick={handleExport} > 导出名单
更新已有学生资料:先按手机号、再按身份证号匹配;Excel 中填写的非空字段会覆盖原资料,未匹配的学生不会新增。请确认姓名、手机号、身份证号、所属机构和联系人等内容无误。 } /> }} scroll={{ x: 1410 }} pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50, 100], showTotal: (total) => `共 ${total} 人`, }} rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')} rowSelection={{ selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys as number[]), getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }), }} expandable={{ rowExpandable: () => true, expandedRowRender: (record) => { const enrollments = enrollmentData[record.id]; if (!enrollments) return null; if (enrollments.length < 2) { return (
当前仅 {enrollments.length} 个班型,无可对比数据
); } return ( {enrollments.map((enr, idx) => ( {enr.className || '-'} {enr.startDate || enr.joinDate || '-'} {enr.endDate || enr.leaveDate || '-'} {enr.status || '-'} ))} ); }, onExpand: async (expanded, record) => { if (expanded && !enrollmentData[record.id]) { try { const res = await api.get<{ enrollments: EnrollmentInfo[] }>( `/students/${record.id}/compare-classes`, ); setEnrollmentData((prev) => ({ ...prev, [record.id]: res.enrollments })); } catch { setEnrollmentData((prev) => ({ ...prev, [record.id]: [] })); } } }, }} /> { setModalOpen(false); setEditing(null); }} okText="保存" confirmLoading={saving} >
{canChooseOrganization ? ( {editing && (