import React, { useCallback, useMemo } from 'react'; import { Tabs, Card, Descriptions, Table, Button, Tag, Space, Empty, Row, Col, Statistic, Spin, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { 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 { useQuery } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { queryKeys } from '../../api/queryKeys'; import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas'; import EditableCell from '../EditableCell'; import { usePermission } from '../../hooks/usePermission'; import { QueryErrorState } from '../QueryState'; import { ADMISSION_STATUS_MAP, ATTENDANCE_STATUS_MAP, SESSION_LABELS, getOptionLabel } from './shared'; import type { AttendanceRecordItem, ProfileData, ResultData, StudentInfo, StudentProfileAggregate, StudentProfileContentProps } from './shared'; import { EnrollmentsTab } from './EnrollmentsTab'; import { ExamScoresTab } from './ExamScoresTab'; import { LearningTab } from './LearningTab'; import { AttachmentsTab } from './AttachmentsTab'; const EditableField: React.FC<{ value: unknown; onSave: (value: unknown) => Promise | void; editor?: React.ComponentProps['editor']; min?: number; required?: boolean; children?: React.ReactNode; }> = ({ value, onSave, editor, min, required, children }) => ( { await onSave(next); }} > {children ?? String(value ?? '-')} ); 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] }} /> ) : ( ); }; 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; canViewSensitive: boolean; canChooseOrganization: boolean; }> = ({ studentId, student, profile, result, organizations, onViewSensitive, canViewSensitive, canChooseOrganization, }) => { const saveStudentMutation = useApiMutation( async ({ field, value }: { field: keyof StudentInfo; value: unknown }) => api.put(`/students/${studentId}`, { [field]: value }), { invalidate: [['archive', studentId], ['students']] }, ); const saveProfileMutation = useApiMutation( async ({ field, value }: { field: keyof ProfileData; value: unknown }) => api.put(`/archive/${studentId}/profile`, { [field]: value }), { invalidate: [['archive', studentId]] }, ); const saveResultMutation = useApiMutation( async ({ field, value }: { field: keyof ResultData; value: unknown }) => api.put(`/archive/${studentId}/result`, { [field]: value }), { invalidate: [['archive', studentId]] }, ); const saveStudent = async (field: keyof StudentInfo, value: unknown) => { try { await saveStudentMutation.mutateAsync({ field, value }); message.success('学生资料已保存'); } catch { // 错误提示由 useApiMutation 统一处理 } }; const saveProfile = async (field: keyof ProfileData, value: unknown) => { try { await saveProfileMutation.mutateAsync({ field, value }); message.success('档案已保存'); } catch { // 错误提示由 useApiMutation 统一处理 } }; const saveResult = async (field: keyof ResultData, value: unknown) => { try { await saveResultMutation.mutateAsync({ field, value }); message.success('录取信息已保存'); } catch { // 错误提示由 useApiMutation 统一处理 } }; 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)} {canViewSensitive ? ( onViewSensitive('电话', student.phone)}> ) : null} ) : ( '-' )} saveStudent('name', next)}> {student.name || '-'} saveStudent('studentNo', next)}> {student.studentNo || '-'} saveStudent('gender', next)}> {student.gender || '-'} saveStudent('idNumber', next)} > {student.idNumber ? ( {maskIdNumber(student.idNumber)} {canViewSensitive ? ( onViewSensitive('身份证号', student.idNumber)}> ) : null} ) : ( '-' )} saveStudent('ethnicity', next)}> {student.ethnicity || '-'} saveStudent('emergencyContact', next)} > {student.emergencyContact || '-'} saveStudent('emergencyPhone', next)} > {student.emergencyPhone ? ( {maskPhone(student.emergencyPhone)} {canViewSensitive ? ( onViewSensitive('紧急联系人电话', student.emergencyPhone || '')}> ) : null} ) : ( '-' )} {canChooseOrganization ? ( ({ value: item.id, label: item.name }))} permission="student:edit" onSave={(next) => saveStudent('organizationId', next)} > {student.organization?.name ? ( {student.organization.name} ) : ( '-' )} ) : student.organization?.name ? ( {student.organization.name} ) : ( '-' )} saveStudent('supervisor', next)} > {student.supervisor || '-'} saveProfile('targetCollege', next)} > {profile?.targetCollege || '-'} saveProfile('targetMajor', next)} > {profile?.targetMajor || '-'} saveProfile('collegeSchool', next)} > {profile?.collegeSchool || '-'} saveProfile('collegeMajor', next)} > {profile?.collegeMajor || '-'} 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 StudentProfileContent: React.FC = ({ studentId, inDrawer, onClose, }) => { const { hasPermission, hasAnyPermission } = usePermission(); const canLoadOrganizations = hasAnyPermission( 'organization:view', 'student:create', 'student:edit', ); const canChooseOrganization = hasAnyPermission('student:create', 'student:edit'); const { data: aggregateData, isLoading, isFetching, isError, refetch, } = useQuery({ queryKey: queryKeys.archive.detail(studentId), queryFn: async () => { return validateResponse( studentProfileAggregateSchema, await api.get(`/archive/${studentId}`), ); }, }); const { data: organizations = [] } = useQuery< Array<{ id: number; name: string; isHost?: boolean }> >({ queryKey: queryKeys.organizations.options(), enabled: canLoadOrganizations, queryFn: async () => { try { return validateResponse>( organizationOptionsSchema, await api.get('/organizations/options'), ); } catch { return []; } }, }); const loading = isLoading || isFetching; const fetchData = useCallback(() => refetch(), [refetch]); 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 (e) { console.error('加载报告失败', e); message.error('加载报告失败'); } }, [studentId]); const handleViewSensitive = useViewSensitive(studentId, '学生档案', hasPermission('log:create')); 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 (
); } if (isError) { return ( void refetch()} /> ); } return null; } const { student, profile, result } = aggregateData; return (
{inDrawer && ( )} {[ { title: '入学测试总分' }, { title: '阶段最高分' }, { title: '阶段提升分' }, { title: '出勤率' }, ].map((item) => ( ))}
); }; export default StudentProfileContent;