import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { Tabs, Card, Descriptions, Table, Button, Modal, Form, Input, Select, DatePicker, InputNumber, Upload, Tag, Space, Popconfirm, Empty, Row, Col, Statistic, Spin, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined, UploadOutlined, InboxOutlined, EyeOutlined, CloseOutlined, FileTextOutlined, ReloadOutlined, ImportOutlined, DownloadOutlined, CheckCircleOutlined, CloseCircleOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import { useViewSensitive } from '../../hooks/useViewSensitive'; import { message } from '../../ui/app-message'; // ---- Types ---- interface StudentInfo { id: number; name: string; phone: string; idNumber: string; studentNo: string; status: string; } interface ProfileData { targetCollege?: string; targetMajor?: string; subjectDirection?: string; grade?: string; profileDate?: string; notes?: string; } interface EnrollmentRecord { id: number; courseCategory: string; classType: string; className?: string; headTeacher?: string; subjectTeacher?: string; startDate?: string; endDate?: string; status: string; } interface ExamScoreRecord { id: number; examType: string; examName?: string; subject: string; score: number; classAvg?: number; rank?: number; examDate?: string; enrollmentId?: number; } interface LearningRecord { id: number; recordDate: string; recordType: string; content: string; followUpMethod?: string; nextStep?: string; } interface ResultData { cultureFinalScore?: number; professionalFinalScore?: number; admissionStatus?: string; admittedCollege?: string; admittedMajor?: string; } interface AttachmentRecord { id: number; category: string; fileName: string; fileSize: number; } interface AttendanceRecordItem { id: number; attendanceDate: string; session: string; status: string; source?: string; remark?: string | null; punchTime?: string | null; punchDeviceName?: string | null; punchDeviceId?: string | null; schedule?: { subject?: string } | null; class?: { name?: string } | null; } interface StudentProfileAggregate { student: StudentInfo; profile: ProfileData | null; enrollments: EnrollmentRecord[]; examScores: ExamScoreRecord[]; learningRecords: LearningRecord[]; result: ResultData | null; attachments: AttachmentRecord[]; attendances: AttendanceRecordItem[]; } export interface StudentProfileContentProps { studentId: number; inDrawer?: boolean; onClose?: () => void; } // ---- Constants ---- const ADMISSION_STATUS_MAP: Record = { admitted: { text: '已录取', color: 'green' }, pending: { text: '待录取', color: 'orange' }, rejected: { text: '未录取', color: 'red' }, withdrawn: { text: '放弃', color: '#999' }, }; const EXAM_TYPE_OPTIONS = [ { value: 'monthly', label: '月考' }, { value: 'midterm', label: '期中' }, { value: 'final', label: '期末' }, { value: 'mock', label: '模拟考' }, { value: 'entrance', label: '入学测试' }, { value: 'other', label: '其他' }, ]; const RECORD_TYPE_OPTIONS = [ { value: 'study_feedback', label: '学习反馈' }, { value: 'parent_communication', label: '家长沟通' }, { value: 'behavior_note', label: '行为记录' }, { value: 'meeting', label: '会议记录' }, { value: 'other', label: '其他' }, ]; const STUDENT_STATUS_MAP: Record = { active: { text: '在读', color: 'green' }, graduated: { text: '已毕业', color: 'blue' }, withdrawn: { text: '已退训', color: 'red' }, archived: { text: '已归档', color: '#999' }, }; const ENROLLMENT_STATUS_MAP: Record = { active: { text: '报读中', color: 'green' }, completed: { text: '已结课', color: 'blue' }, withdrawn: { text: '已退训', color: 'red' }, }; const COURSE_CATEGORY_OPTIONS = [ { value: 'culture', label: '文化课' }, { value: 'professional', label: '专业课' }, { value: 'comprehensive', label: '综合' }, ]; const CLASS_TYPE_OPTIONS = [ { value: 'one_on_one', label: '一对一' }, { value: 'small_group', label: '小班' }, { value: 'large_class', label: '大班' }, { value: 'online', label: '线上' }, { value: 'offline', label: '线下' }, ]; const getOptionLabel = ( options: Array<{ value: string; label: string }>, value?: string | null, ): string => { if (!value) return '-'; return options.find((option) => option.value === value)?.label || value; }; const getCourseCategoryLabel = (value?: string | null): string => getOptionLabel(COURSE_CATEGORY_OPTIONS, value); const getClassTypeLabel = (value?: string | null): string => getOptionLabel(CLASS_TYPE_OPTIONS, value); const getEnrollmentStatus = (value?: string | null): { text: string; color: string } => { if (!value) return { text: '-', color: 'default' }; return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' }; }; const getStudentStatus = (value?: string | null): { text: string; color: string } => { if (!value) return { text: '-', color: 'default' }; return STUDENT_STATUS_MAP[value] || { text: value, color: 'default' }; }; const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string => enrollment.className || (enrollment.courseCategory ? getCourseCategoryLabel(enrollment.courseCategory) : String(enrollment.id)); const ATTACHMENT_CATEGORY_OPTIONS = [ { value: 'id_card', label: '身份证' }, { value: 'transcript', label: '成绩单' }, { value: 'certificate', label: '证书' }, { value: 'contract', label: '合同' }, { value: 'photo', label: '照片' }, { value: 'other', label: '其他' }, ]; const formatFileSize = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; // ---- Tab Components ---- const ATTENDANCE_STATUS_MAP: Record = { present: { text: '出勤', color: 'green' }, late: { text: '迟到', color: 'orange' }, absent: { text: '缺勤', color: 'red' }, leave: { text: '请假', color: 'blue' }, pending: { text: '待确认', color: 'default' }, }; const SESSION_LABELS: Record = { morning_reading: '早自习', morning: '上午', afternoon: '下午', evening_study: '晚自习', night_check: '晚寝', }; const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => { const columns: ColumnsType = [ { title: '日期', dataIndex: 'attendanceDate', width: 120 }, { title: '课程', render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤', }, { title: '时段', dataIndex: 'session', width: 100, render: (value: string) => SESSION_LABELS[value] || value || '-', }, { title: '结果', dataIndex: 'status', width: 90, render: (value: string) => { const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' }; return {meta.text}; }, }, { title: '打卡时间', dataIndex: 'punchTime', width: 170, render: (value?: string | null) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-'), }, { title: '打卡设备', render: (_: unknown, record) => { const name = record.punchDeviceName?.trim(); const id = record.punchDeviceId?.trim(); if (name && id && name !== id) return `${name}(${id})`; return name || id || (record.source === 'manual' ? '老师手动标记' : '-'); }, }, { title: '备注', dataIndex: 'remark', render: (value?: string | null) => value || '-' }, ]; return data.length > 0 ? ( columns={columns} dataSource={data} rowKey="id" scroll={{ x: 900 }} pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }} /> ) : ( ); }; interface TabProps { studentId: number; onRefresh: () => void; } const ProfileTab: React.FC<{ data: ProfileData | null; studentId: number; onRefresh: () => void; }> = ({ data, studentId, onRefresh }) => { const [form] = Form.useForm(); const [saving, setSaving] = useState(false); const handleSave = async () => { try { const values = await form.validateFields(); setSaving(true); await api.put(`/archive/${studentId}/profile`, { ...values, profileDate: values.profileDate?.format('YYYY-MM-DD'), }); message.success('基础档案已保存'); onRefresh(); } catch (e: unknown) { const err = e as { message?: string }; if (err?.message) message.error(err.message); } finally { setSaving(false); } }; return (
); }; const EnrollmentsTab: React.FC = ({ data, studentId, onRefresh, }) => { const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); const handleAdd = async () => { try { const values = await form.validateFields(); setSaving(true); await api.post(`/archive/${studentId}/enrollments`, { ...values, startDate: values.startDate?.format('YYYY-MM-DD'), endDate: values.endDate?.format('YYYY-MM-DD'), }); message.success('报读记录已添加'); setModalOpen(false); form.resetFields(); onRefresh(); } catch (e: unknown) { const err = e as { message?: string }; if (err?.message) message.error(err.message); } finally { setSaving(false); } }; const columns: ColumnsType = [ { title: '课程类别', dataIndex: 'courseCategory', render: getCourseCategoryLabel }, { title: '班型', dataIndex: 'classType', render: getClassTypeLabel }, { title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' }, { title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' }, { title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' }, { title: '开始日期', dataIndex: 'startDate', render: (v: string) => v || '-' }, { title: '结束日期', dataIndex: 'endDate', render: (v: string) => v || '-' }, { title: '状态', dataIndex: 'status', render: (v: string) => { const status = getEnrollmentStatus(v); return {status.text}; }, }, ]; return (
columns={columns} dataSource={data} rowKey="id" pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50], }} /> setModalOpen(false)} confirmLoading={saving} >
); }; const ExamScoresTab: React.FC< TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] } > = ({ data, studentId, enrollments, onRefresh }) => { const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); const handleAdd = async () => { try { const values = await form.validateFields(); setSaving(true); await api.post(`/archive/${studentId}/exam-scores`, { ...values, examDate: values.examDate?.format('YYYY-MM-DD'), }); message.success('考试成绩已添加'); setModalOpen(false); form.resetFields(); onRefresh(); } catch (e: unknown) { const err = e as { message?: string }; if (err?.message) message.error(err.message); } finally { setSaving(false); } }; const columns: ColumnsType = [ { title: '考试类型', dataIndex: 'examType', render: (v: string) => EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v, }, { title: '考试名称', dataIndex: 'examName', render: (v: string) => v || '-' }, { title: '科目', dataIndex: 'subject' }, { title: '成绩', dataIndex: 'score' }, { title: '班级均分', dataIndex: 'classAvg', render: (v: number | undefined) => (v !== undefined ? v : '-'), }, { title: '排名', dataIndex: 'rank', render: (v: number | undefined) => (v !== undefined ? v : '-'), }, { title: '考试日期', dataIndex: 'examDate', render: (v: string) => v || '-' }, { title: '关联报读', dataIndex: 'enrollmentId', render: (v: number | undefined) => { if (v === undefined) return '-'; const enr = enrollments.find((e) => e.id === v); return enr ? formatEnrollmentDisplayName(enr) : String(v); }, }, ]; return (
columns={columns} dataSource={data} rowKey="id" pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50], }} /> setModalOpen(false)} confirmLoading={saving} >
); }; const ResultTab: React.FC = ({ data, studentId, onRefresh, }) => { const [form] = Form.useForm(); const [saving, setSaving] = useState(false); const handleSave = async () => { try { const values = await form.validateFields(); setSaving(true); await api.put(`/archive/${studentId}/result`, values); message.success('录取结果已保存'); onRefresh(); } catch (e: unknown) { const err = e as { message?: string }; if (err?.message) message.error(err.message); } finally { setSaving(false); } }; return (
); }; const AttachmentsTab: React.FC = ({ data, studentId, onRefresh, }) => { const [uploading, setUploading] = useState(false); const handleDelete = async (attachmentId: number) => { try { await api.delete(`/archive/attachments/${attachmentId}`); message.success('已归档'); onRefresh(); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '归档失败'); } }; const columns: ColumnsType = [ { title: '类别', dataIndex: 'category', render: (v: string) => ATTACHMENT_CATEGORY_OPTIONS.find((o) => o.value === v)?.label || v, }, { title: '文件名', dataIndex: 'fileName' }, { title: '大小', dataIndex: 'fileSize', render: formatFileSize }, { title: '操作', render: (_: unknown, record: AttachmentRecord) => ( handleDelete(record.id)}> ), }, ]; return (
{ const formData = new FormData(); formData.append( 'file', options.file instanceof File ? options.file : new File([options.file as Blob], 'attachment'), ); setUploading(true); try { await api.post(`/archive/${studentId}/attachments`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); message.success('上传成功'); options.onSuccess?.({}); onRefresh(); } catch (e: unknown) { const msg = e instanceof Error ? e.message : '上传失败'; message.error(msg); options.onError?.(e instanceof Error ? e : new Error(msg)); } finally { setUploading(false); } }} > columns={columns} dataSource={data} rowKey="id" pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50], }} style={{ marginTop: 16 }} />
); }; // ---- Main Component ---- const StudentProfileContent: React.FC = ({ studentId, inDrawer, onClose, }) => { const [aggregateData, setAggregateData] = useState(null); const [loading, setLoading] = useState(false); // 批量导入状态 const [importModalOpen, setImportModalOpen] = useState(false); const [importing, setImporting] = useState(false); const [importResult, setImportResult] = useState<{ students: { created: number; updated: number }; profiles: number; results: number; enrollments: number; examScores: number; learningRecords: number; errors: Array<{ sheet: string; row: number; phone: string; reason: string }>; } | null>(null); const fetchData = useCallback(async () => { setLoading(true); try { const res = await api.get(`/archive/${studentId}`); setAggregateData(res); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '加载失败'); } finally { setLoading(false); } }, [studentId]); useEffect(() => { void fetchData(); }, [fetchData]); const handleImport = async (file: File) => { setImporting(true); try { const formData = new FormData(); formData.append('file', file); const result = await api.post('/archive/import', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); setImportResult(result); setImportModalOpen(true); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '导入失败'); } finally { setImporting(false); } }; const handleDownloadTemplate = async () => { try { const blob = await api.get('/archive/import/template', { responseType: '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 handlePreviewReport = useCallback(async () => { try { const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`); const w = window.open('', '_blank'); if (w) { w.document.write(html); w.document.close(); } } catch { message.error('加载报告失败'); } }, [studentId]); const handleViewSensitive = useViewSensitive(studentId, '学生档案'); const tabItems = useMemo(() => { if (!aggregateData) return []; const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } = aggregateData; return [ { key: 'profile', label: '扩展档案', children: , }, { key: 'enrollments', label: `报读班型 (${enrollments.length})`, children: , }, { key: 'exams', label: `考试成绩 (${examScores.length})`, children: ( ), }, { key: 'attendance', label: `出勤记录 (${attendances.length})`, children: , }, { key: 'learning', label: `课堂回访 (${learningRecords.length})`, children: ( ), }, { key: 'result', label: '录取归档', children: , }, { key: 'attachments', label: `附件 (${attachments.length})`, children: , }, { key: 'reports', label: '报告版本', children: , }, ]; }, [aggregateData, studentId, fetchData]); if (!aggregateData) { if (loading) { return (
); } return null; } const { student, profile } = aggregateData; return (
{inDrawer && ( handleImport(file as File)} > {!inDrawer && ( )} {[ { title: '入学测试总分' }, { title: '阶段最高分' }, { title: '阶段提升分' }, { title: '出勤率' }, ].map((item) => ( ))} {student.studentNo || '-'} {student.phone ? ( {maskPhone(student.phone)} handleViewSensitive('电话', student.phone)}> ) : ( '-' )} {student.idNumber ? ( {maskIdNumber(student.idNumber)} handleViewSensitive('身份证号', student.idNumber)}> ) : ( '-' )} {(() => { const status = getStudentStatus(student.status); return {status.text}; })()} {profile?.targetCollege && ( {profile.targetCollege} )} {profile?.targetMajor && ( {profile.targetMajor} )} {profile?.grade && {profile.grade}} {profile?.subjectDirection && ( {profile.subjectDirection} )} {/* 批量导入结果弹窗 */} { setImportModalOpen(false); setImportResult(null); fetchData(); }} footer={ } width={560} > {importResult && (
{importResult.errors.length > 0 ? (
失败 {importResult.errors.length} 条
String(i)} size="small" pagination={false} columns={[ { title: 'Sheet', dataIndex: 'sheet', width: 140 }, { title: '行号', dataIndex: 'row', width: 60 }, { title: '手机号', dataIndex: 'phone', width: 120 }, { title: '原因', dataIndex: 'reason' }, ]} /> ) : (
全部导入成功!
)} )} ); }; export default StudentProfileContent;