import React, { useEffect, useState } from 'react'; import { Table, Button, Modal, Form, Input, Select, Space, message, Tag, Popconfirm, Upload, App, Row, Col, Card, Descriptions, } from 'antd'; import { PlusOutlined, UploadOutlined, DownloadOutlined, UndoOutlined, InboxOutlined, ExportOutlined, DeleteOutlined, EyeOutlined, } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; const maskPhone = (phone: string) => { if (!phone || phone.length < 7) return phone || '-'; return phone.slice(0, 3) + '****' + phone.slice(-4); }; const maskIdNumber = (id: string) => { if (!id || id.length < 8) return id || '-'; return id.slice(0, 3) + '***********' + id.slice(-4); }; 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; }; } const StudentsPage: React.FC = () => { const { modal } = App.useApp(); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [tenants, setTenants] = useState([]); const [editing, setEditing] = useState(null); const [searchName, setSearchName] = useState(''); const [filterStatus, setFilterStatus] = useState(undefined); const [filterTenantId, setFilterTenantId] = useState(undefined); const [showArchived, setShowArchived] = useState(false); const [archivedCount, setArchivedCount] = useState(0); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [enrollmentData, setEnrollmentData] = useState>({}); const [form] = Form.useForm(); const handleViewSensitive = (studentId: number, field: string, value: string) => { modal.confirm({ title: '查看敏感信息', content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`, okText: '确认查看', cancelText: '取消', onOk: async () => { api.post('/operation-logs/audit', { module: '学生管理', action: '查看敏感信息', targetId: studentId, targetType: 'student', detail: `查看${field}`, }).catch(() => {}); modal.info({ title: field, content: value, okText: '关闭', }); }, }); }; const handleBatchDelete = async () => { 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 || '批量归档失败'); } }; const fetchData = async () => { setLoading(true); try { const params: Record = { name: searchName || undefined, includeArchived: 'true' }; if (filterStatus) params.status = filterStatus; if (filterTenantId) params.tenantId = filterTenantId; 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) { console.error(e); } setLoading(false); }; useEffect(() => { fetchData(); }, [searchName, showArchived, filterStatus, filterTenantId]); useEffect(() => { api.get('/tenants', { params: { includeArchived: 'false' } }).then((res: unknown) => { setTenants(res as Array<{ id: number; name: string }>); }).catch(() => {}); }, []); const handleSave = async () => { const values = await form.validateFields(); 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 || '操作失败'); } }; 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 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 = showArchived ? '?includeArchived=true' : ''; fetch(`${baseURL}/students/export${params}`, { 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 = [ { title: 'ID', dataIndex: 'id', width: 60 }, { title: '姓名', dataIndex: 'name', ellipsis: true }, { title: '性别', dataIndex: 'gender', width: 60 }, { title: '电话', dataIndex: 'phone', width: 140, ellipsis: true, render: (v: string, record: any) => { if (!v) return '-'; return ( {maskPhone(v)} handleViewSensitive(record.id, '电话', v)} title="点击查看完整号码"> ); }, }, { title: '学号', dataIndex: 'studentNumber', width: 120, ellipsis: true, render: (v: string) => v || '-', }, { title: '身份证', dataIndex: 'idNumber', width: 180, ellipsis: true, render: (v: string, record: any) => { if (!v) return '-'; return ( {maskIdNumber(v)} handleViewSensitive(record.id, '身份证号', v)} title="点击查看完整号码"> ); }, }, { title: '民族', dataIndex: 'ethnicity', width: 80 }, { title: '紧急联系人', dataIndex: 'emergencyContact', ellipsis: true }, { title: '紧急联系人电话', dataIndex: 'emergencyPhone', ellipsis: true }, { title: '所属机构', dataIndex: 'tenant', render: (tenant: { name?: string } | null) => tenant?.name ? {tenant.name} : '-', }, { title: '负责人', dataIndex: 'supervisor', ellipsis: true }, { title: '状态', dataIndex: 'status', render: (s: string) => {statusMap[s]?.text || s}, }, { title: '操作', width: 180, render: (_: any, record: any) => ( {record.status === 'archived' ? ( handleRestore(record.id)} okText="恢复" cancelText="取消" > } type="link"> 恢复 ) : ( <> { setEditing(record); form.setFieldsValue(record); setModalOpen(true); }} > 编辑 handleArchive(record.id)} okText="归档" cancelText="取消" > }> 归档 )} ), }, ]; return (
} disabled={selectedRowKeys.length === 0} > 批量归档 } onClick={() => { setEditing(null); form.resetFields(); setModalOpen(true); }} > 添加学生 { const formData = new FormData(); formData.append('file', file); try { const res: any = await api.post('/students/import', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); message.success(res.message); onSuccess?.(res); fetchData(); } catch (e: any) { message.error(e?.message || '导入失败'); onError?.(e); } }} > } onClick={handleDownloadTemplate} > 下载模板 } onClick={handleExport} > 导出名单
`共 ${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="保存" >
{editing && (