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, } 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'; import EditableCell from '../EditableCell'; // ---- Types ---- interface StudentInfo { id: number; name: string; phone: string; idNumber: string; studentNo: string; gender?: string; ethnicity?: string; emergencyContact?: string; emergencyPhone?: string; organizationId?: number; organization?: { id?: number; name?: string } | null; supervisor?: 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 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 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 InlineArchiveSummary: React.FC<{ studentId: number; student: StudentInfo; profile: ProfileData | null; result: ResultData | null; organizations: Array<{ id: number; name: string }>; onRefresh: () => void; onViewSensitive: (fieldLabel: string, value: string) => void; }> = ({ studentId, student, profile, result, organizations, onRefresh, onViewSensitive }) => { const saveStudent = async (field: keyof StudentInfo, value: unknown) => { await api.put(`/students/${studentId}`, { [field]: value }); message.success('学生资料已保存'); onRefresh(); }; const saveProfile = async (field: keyof ProfileData, value: unknown) => { await api.put(`/archive/${studentId}/profile`, { [field]: value }); message.success('档案已保存'); onRefresh(); }; const saveResult = async (field: keyof ResultData, value: unknown) => { await api.put(`/archive/${studentId}/result`, { [field]: value }); message.success('录取信息已保存'); onRefresh(); }; const admissionStatus = getOptionLabel( Object.entries(ADMISSION_STATUS_MAP).map(([value, meta]) => ({ value, label: meta.text, })), result?.admissionStatus, ); return ( saveStudent('phone', next)} > {student.phone ? ( {maskPhone(student.phone)} onViewSensitive('电话', student.phone)}> ) : ( '-' )} saveStudent('name', next)} > {student.name || '-'} saveStudent('studentNo', next)} > {student.studentNo || '-'} saveStudent('gender', next)} > {student.gender || '-'} saveStudent('idNumber', next)} > {student.idNumber ? ( {maskIdNumber(student.idNumber)} onViewSensitive('身份证号', student.idNumber)}> ) : ( '-' )} saveStudent('ethnicity', next)} > {student.ethnicity || '-'} saveStudent('emergencyContact', next)} > {student.emergencyContact || '-'} saveStudent('emergencyPhone', next)} > {student.emergencyPhone ? ( {maskPhone(student.emergencyPhone)} onViewSensitive('紧急联系人电话', student.emergencyPhone || '')}> ) : ( '-' )} ({ value: item.id, label: item.name }))} permission="student:edit" onSave={(next) => saveStudent('organizationId', next)} > {student.organization?.name ? {student.organization.name} : '-'} saveStudent('supervisor', next)} > {student.supervisor || '-'} saveProfile('targetCollege', next)} > {profile?.targetCollege || '-'} saveProfile('targetMajor', next)} > {profile?.targetMajor || '-'} saveProfile('subjectDirection', next)} > {profile?.subjectDirection || '-'} saveProfile('grade', next)} > {profile?.grade || '-'} saveProfile('profileDate', next)} > {profile?.profileDate || '-'} saveProfile('notes', next)} > {profile?.notes || '-'} saveResult('cultureFinalScore', next)} > {result?.cultureFinalScore ?? '-'} saveResult('professionalFinalScore', next)} > {result?.professionalFinalScore ?? '-'} ({ value, label: meta.text, }))} permission="student:edit" onSave={(next) => saveResult('admissionStatus', next)} > {admissionStatus} saveResult('admittedCollege', next)} > {result?.admittedCollege || '-'} saveResult('admittedMajor', next)} > {result?.admittedMajor || '-'} ); }; 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 saveCell = async (record: EnrollmentRecord, field: string, value: unknown) => { await api.put(`/archive/enrollments/${record.id}`, { [field]: value }); message.success('报读记录已保存'); onRefresh(); }; const columns: ColumnsType = [ { title: '课程类别', dataIndex: 'courseCategory', render: (v: string, r) => ( saveCell(r, 'courseCategory', next)} > {getCourseCategoryLabel(v)} ), }, { title: '班型', dataIndex: 'classType', render: (v: string, r) => ( saveCell(r, 'classType', next)} > {getClassTypeLabel(v)} ), }, { title: '班级名称', dataIndex: 'className', render: (v: string, r) => ( saveCell(r, 'className', next)} > {v || '-'} ), }, { title: '班主任', dataIndex: 'headTeacher', render: (v: string, r) => ( saveCell(r, 'headTeacher', next)} > {v || '-'} ), }, { title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string, r) => ( saveCell(r, 'subjectTeacher', next)} > {v || '-'} ), }, { title: '开始日期', dataIndex: 'startDate', render: (v: string, r) => ( saveCell(r, 'startDate', next)} > {v || '-'} ), }, { title: '结束日期', dataIndex: 'endDate', render: (v: string, r) => ( saveCell(r, 'endDate', next)} > {v || '-'} ), }, { title: '状态', dataIndex: 'status', render: (v: string, r) => { const status = getEnrollmentStatus(v); return ( ({ value, label: item.text, }))} permission="student:edit" onSave={(next) => saveCell(r, 'status', next)} > {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 saveCell = async (record: ExamScoreRecord, field: string, value: unknown) => { await api.put(`/archive/exam-scores/${record.id}`, { [field]: value }); message.success('考试成绩已保存'); onRefresh(); }; const columns: ColumnsType = [ { title: '考试类型', dataIndex: 'examType', render: (v: string, r) => ( saveCell(r, 'examType', next)} > {EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v} ), }, { title: '考试名称', dataIndex: 'examName', render: (v: string, r) => ( saveCell(r, 'examName', next)} > {v || '-'} ), }, { title: '科目', dataIndex: 'subject', render: (v: string, r) => ( saveCell(r, 'subject', next)} > {v} ), }, { title: '成绩', dataIndex: 'score', render: (v: number, r) => ( saveCell(r, 'score', next)} > {v} ), }, { title: '班级均分', dataIndex: 'classAvg', render: (v: number | undefined, r) => ( saveCell(r, 'classAvg', next)} > {v !== undefined ? v : '-'} ), }, { title: '排名', dataIndex: 'rank', render: (v: number | undefined, r) => ( saveCell(r, 'rank', next)} > {v !== undefined ? v : '-'} ), }, { title: '考试日期', dataIndex: 'examDate', render: (v: string, r) => ( saveCell(r, 'examDate', next)} > {v || '-'} ), }, { title: '关联报读', dataIndex: 'enrollmentId', render: (v: number | undefined, r) => ( ({ value: item.id, label: formatEnrollmentDisplayName(item), }))} permission="student:edit" onSave={(next) => saveCell(r, 'enrollmentId', next)} > {(() => { 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 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 [organizations, setOrganizations] = useState>([]); const [loading, setLoading] = useState(false); 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]); useEffect(() => { api .get('/organizations', { params: { includeArchived: 'false' } }) .then((res: unknown) => { setOrganizations(res as Array<{ id: number; name: string }>); }) .catch(() => {}); }, []); 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 { enrollments, examScores, learningRecords, attachments, attendances } = aggregateData; return [ { 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: 'attachments', label: `附件 (${attachments.length})`, children: , }, { key: 'reports', label: '报告版本', children: , }, ]; }, [aggregateData, studentId, fetchData]); if (!aggregateData) { if (loading) { return (
); } return null; } const { student, profile, result } = aggregateData; return (
{inDrawer && ( )} {[ { title: '入学测试总分' }, { title: '阶段最高分' }, { title: '阶段提升分' }, { title: '出勤率' }, ].map((item) => ( ))}
); }; export default StudentProfileContent;