1577 lines
45 KiB
TypeScript
1577 lines
45 KiB
TypeScript
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';
|
||
import { usePermission } from '../../hooks/usePermission';
|
||
import PermissionButton from '../PermissionButton';
|
||
|
||
// ---- 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;
|
||
collegeSchool?: string;
|
||
collegeMajor?: 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;
|
||
examId?: number;
|
||
exam?: { class?: { name?: string } };
|
||
examType: string;
|
||
examName?: string;
|
||
subject: string;
|
||
score: number | null;
|
||
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<string, { text: string; color: string }> = {
|
||
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<string, { text: string; color: string }> = {
|
||
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<string, { text: string; color: string }> = {
|
||
present: { text: '出勤', color: 'green' },
|
||
late: { text: '迟到', color: 'orange' },
|
||
absent: { text: '缺勤', color: 'red' },
|
||
leave: { text: '请假', color: 'blue' },
|
||
pending: { text: '待确认', color: 'default' },
|
||
};
|
||
|
||
const SESSION_LABELS: Record<string, string> = {
|
||
morning_reading: '早自习',
|
||
morning: '上午',
|
||
afternoon: '下午',
|
||
evening_study: '晚自习',
|
||
night_check: '晚寝',
|
||
};
|
||
|
||
const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => {
|
||
const columns: ColumnsType<AttendanceRecordItem> = [
|
||
{ 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 <Tag color={meta.color}>{meta.text}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
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 ? (
|
||
<Table<AttendanceRecordItem>
|
||
columns={columns}
|
||
dataSource={data}
|
||
rowKey="id"
|
||
scroll={{ x: 900 }}
|
||
pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }}
|
||
/>
|
||
) : (
|
||
<Empty description="暂无出勤记录" />
|
||
);
|
||
};
|
||
|
||
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;
|
||
canViewSensitive: boolean;
|
||
canChooseOrganization: boolean;
|
||
}> = ({
|
||
studentId,
|
||
student,
|
||
profile,
|
||
result,
|
||
organizations,
|
||
onRefresh,
|
||
onViewSensitive,
|
||
canViewSensitive,
|
||
canChooseOrganization,
|
||
}) => {
|
||
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 (
|
||
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}>
|
||
<Descriptions.Item label="手机号">
|
||
<EditableCell
|
||
value={student.phone}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('phone', next)}
|
||
>
|
||
{student.phone ? (
|
||
<span>
|
||
<span style={{ marginRight: 8 }}>{maskPhone(student.phone)}</span>
|
||
{canViewSensitive ? (
|
||
<a onClick={() => onViewSensitive('电话', student.phone)}>
|
||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||
</a>
|
||
) : null}
|
||
</span>
|
||
) : (
|
||
'-'
|
||
)}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="姓名">
|
||
<EditableCell
|
||
value={student.name}
|
||
permission="student:edit"
|
||
required
|
||
onSave={(next) => saveStudent('name', next)}
|
||
>
|
||
{student.name || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="学号">
|
||
<EditableCell
|
||
value={student.studentNo}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('studentNo', next)}
|
||
>
|
||
{student.studentNo || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="性别">
|
||
<EditableCell
|
||
value={student.gender}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('gender', next)}
|
||
>
|
||
{student.gender || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="身份证号">
|
||
<EditableCell
|
||
value={student.idNumber}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('idNumber', next)}
|
||
>
|
||
{student.idNumber ? (
|
||
<span>
|
||
<span style={{ marginRight: 8 }}>{maskIdNumber(student.idNumber)}</span>
|
||
{canViewSensitive ? (
|
||
<a onClick={() => onViewSensitive('身份证号', student.idNumber)}>
|
||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||
</a>
|
||
) : null}
|
||
</span>
|
||
) : (
|
||
'-'
|
||
)}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="民族">
|
||
<EditableCell
|
||
value={student.ethnicity}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('ethnicity', next)}
|
||
>
|
||
{student.ethnicity || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="紧急联系人">
|
||
<EditableCell
|
||
value={student.emergencyContact}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('emergencyContact', next)}
|
||
>
|
||
{student.emergencyContact || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="紧急联系人电话">
|
||
<EditableCell
|
||
value={student.emergencyPhone}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('emergencyPhone', next)}
|
||
>
|
||
{student.emergencyPhone ? (
|
||
<span>
|
||
<span style={{ marginRight: 8 }}>{maskPhone(student.emergencyPhone)}</span>
|
||
{canViewSensitive ? (
|
||
<a onClick={() => onViewSensitive('紧急联系人电话', student.emergencyPhone || '')}>
|
||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||
</a>
|
||
) : null}
|
||
</span>
|
||
) : (
|
||
'-'
|
||
)}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="所属机构">
|
||
{canChooseOrganization ? (
|
||
<EditableCell
|
||
value={student.organizationId}
|
||
editor="select"
|
||
options={organizations.map((item) => ({ value: item.id, label: item.name }))}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('organizationId', next)}
|
||
>
|
||
{student.organization?.name ? (
|
||
<Tag color="purple">{student.organization.name}</Tag>
|
||
) : (
|
||
'-'
|
||
)}
|
||
</EditableCell>
|
||
) : student.organization?.name ? (
|
||
<Tag color="purple">{student.organization.name}</Tag>
|
||
) : (
|
||
'-'
|
||
)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="负责人">
|
||
<EditableCell
|
||
value={student.supervisor}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('supervisor', next)}
|
||
>
|
||
{student.supervisor || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="目标院校">
|
||
<EditableCell
|
||
value={profile?.targetCollege}
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('targetCollege', next)}
|
||
>
|
||
{profile?.targetCollege || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="目标专业">
|
||
<EditableCell
|
||
value={profile?.targetMajor}
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('targetMajor', next)}
|
||
>
|
||
{profile?.targetMajor || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="大专院校">
|
||
<EditableCell
|
||
value={profile?.collegeSchool}
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('collegeSchool', next)}
|
||
>
|
||
{profile?.collegeSchool || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="大专专业">
|
||
<EditableCell
|
||
value={profile?.collegeMajor}
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('collegeMajor', next)}
|
||
>
|
||
{profile?.collegeMajor || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="选科方向">
|
||
<EditableCell
|
||
value={profile?.subjectDirection}
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('subjectDirection', next)}
|
||
>
|
||
{profile?.subjectDirection || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="年级">
|
||
<EditableCell
|
||
value={profile?.grade}
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('grade', next)}
|
||
>
|
||
{profile?.grade || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="建档日期">
|
||
<EditableCell
|
||
value={profile?.profileDate}
|
||
editor="date"
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('profileDate', next)}
|
||
>
|
||
{profile?.profileDate || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="档案备注">
|
||
<EditableCell
|
||
value={profile?.notes}
|
||
editor="textarea"
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('notes', next)}
|
||
>
|
||
{profile?.notes || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="文化课最终分">
|
||
<EditableCell
|
||
value={result?.cultureFinalScore}
|
||
editor="number"
|
||
min={0}
|
||
permission="student:edit"
|
||
onSave={(next) => saveResult('cultureFinalScore', next)}
|
||
>
|
||
{result?.cultureFinalScore ?? '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="专业课最终分">
|
||
<EditableCell
|
||
value={result?.professionalFinalScore}
|
||
editor="number"
|
||
min={0}
|
||
permission="student:edit"
|
||
onSave={(next) => saveResult('professionalFinalScore', next)}
|
||
>
|
||
{result?.professionalFinalScore ?? '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="录取状态">
|
||
<EditableCell
|
||
value={result?.admissionStatus}
|
||
editor="select"
|
||
options={Object.entries(ADMISSION_STATUS_MAP).map(([value, meta]) => ({
|
||
value,
|
||
label: meta.text,
|
||
}))}
|
||
permission="student:edit"
|
||
onSave={(next) => saveResult('admissionStatus', next)}
|
||
>
|
||
{admissionStatus}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="录取院校">
|
||
<EditableCell
|
||
value={result?.admittedCollege}
|
||
permission="student:edit"
|
||
onSave={(next) => saveResult('admittedCollege', next)}
|
||
>
|
||
{result?.admittedCollege || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="录取专业">
|
||
<EditableCell
|
||
value={result?.admittedMajor}
|
||
permission="student:edit"
|
||
onSave={(next) => saveResult('admittedMajor', next)}
|
||
>
|
||
{result?.admittedMajor || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
);
|
||
};
|
||
|
||
const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
||
data,
|
||
studentId,
|
||
onRefresh,
|
||
}) => {
|
||
const { hasPermission } = usePermission();
|
||
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<EnrollmentRecord> = [
|
||
{
|
||
title: '课程类别',
|
||
dataIndex: 'courseCategory',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="select"
|
||
options={COURSE_CATEGORY_OPTIONS}
|
||
permission="student:edit"
|
||
required
|
||
onSave={(next) => saveCell(r, 'courseCategory', next)}
|
||
>
|
||
{getCourseCategoryLabel(v)}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '班型',
|
||
dataIndex: 'classType',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="select"
|
||
options={CLASS_TYPE_OPTIONS}
|
||
permission="student:edit"
|
||
required
|
||
onSave={(next) => saveCell(r, 'classType', next)}
|
||
>
|
||
{getClassTypeLabel(v)}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '班级名称',
|
||
dataIndex: 'className',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
permission="student:edit"
|
||
onSave={(next) => saveCell(r, 'className', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '班主任',
|
||
dataIndex: 'headTeacher',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
permission="student:edit"
|
||
onSave={(next) => saveCell(r, 'headTeacher', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '任课教师',
|
||
dataIndex: 'subjectTeacher',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
permission="student:edit"
|
||
onSave={(next) => saveCell(r, 'subjectTeacher', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '开始日期',
|
||
dataIndex: 'startDate',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="date"
|
||
permission="student:edit"
|
||
onSave={(next) => saveCell(r, 'startDate', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '结束日期',
|
||
dataIndex: 'endDate',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="date"
|
||
permission="student:edit"
|
||
onSave={(next) => saveCell(r, 'endDate', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
render: (v: string, r) => {
|
||
const status = getEnrollmentStatus(v);
|
||
return (
|
||
<EditableCell
|
||
value={v}
|
||
editor="select"
|
||
options={Object.entries(ENROLLMENT_STATUS_MAP).map(([value, item]) => ({
|
||
value,
|
||
label: item.text,
|
||
}))}
|
||
permission="student:edit"
|
||
onSave={(next) => saveCell(r, 'status', next)}
|
||
>
|
||
<Tag color={status.color}>{status.text}</Tag>
|
||
</EditableCell>
|
||
);
|
||
},
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<PermissionButton
|
||
permission="student:edit"
|
||
icon={<PlusOutlined />}
|
||
type="primary"
|
||
onClick={() => {
|
||
form.resetFields();
|
||
setModalOpen(true);
|
||
}}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
添加报读记录
|
||
</PermissionButton>
|
||
<Table<EnrollmentRecord>
|
||
columns={columns}
|
||
dataSource={data}
|
||
rowKey="id"
|
||
pagination={{
|
||
defaultPageSize: 15,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [15, 30, 50],
|
||
}}
|
||
/>
|
||
<Modal
|
||
title="添加报读记录"
|
||
open={modalOpen && hasPermission('student:edit')}
|
||
onOk={hasPermission('student:edit') ? handleAdd : undefined}
|
||
onCancel={() => setModalOpen(false)}
|
||
confirmLoading={saving}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item
|
||
name="courseCategory"
|
||
label="课程类别"
|
||
rules={[{ required: true, message: '请选择课程类别' }]}
|
||
>
|
||
<Select options={COURSE_CATEGORY_OPTIONS} placeholder="请选择" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="classType"
|
||
label="班型"
|
||
rules={[{ required: true, message: '请选择班型' }]}
|
||
>
|
||
<Select options={CLASS_TYPE_OPTIONS} placeholder="请选择" />
|
||
</Form.Item>
|
||
<Form.Item name="className" label="班级名称">
|
||
<Input placeholder="如:2024届冲刺班" />
|
||
</Form.Item>
|
||
<Form.Item name="headTeacher" label="班主任">
|
||
<Input placeholder="班主任姓名" />
|
||
</Form.Item>
|
||
<Form.Item name="subjectTeacher" label="任课教师">
|
||
<Input placeholder="任课教师姓名" />
|
||
</Form.Item>
|
||
<Form.Item name="startDate" label="开始日期">
|
||
<DatePicker style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="endDate" label="结束日期">
|
||
<DatePicker style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ExamScoresTab: React.FC<
|
||
TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] }
|
||
> = ({ data, studentId, enrollments, onRefresh }) => {
|
||
const { hasPermission } = usePermission();
|
||
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<ExamScoreRecord> = [
|
||
{
|
||
title: '考试类型',
|
||
dataIndex: 'examType',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="select"
|
||
options={EXAM_TYPE_OPTIONS}
|
||
permission="student:edit"
|
||
disabled={!!r.examId}
|
||
required
|
||
onSave={(next) => saveCell(r, 'examType', next)}
|
||
>
|
||
{EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '考试名称',
|
||
dataIndex: 'examName',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
permission="student:edit"
|
||
disabled={!!r.examId}
|
||
onSave={(next) => saveCell(r, 'examName', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '科目',
|
||
dataIndex: 'subject',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
required
|
||
permission="student:edit"
|
||
disabled={!!r.examId}
|
||
onSave={(next) => saveCell(r, 'subject', next)}
|
||
>
|
||
{v}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '成绩',
|
||
dataIndex: 'score',
|
||
render: (v: number | null, r) => (
|
||
<EditableCell
|
||
value={v ?? undefined}
|
||
editor="number"
|
||
min={0}
|
||
permission="student:edit"
|
||
disabled={!!r.examId}
|
||
onSave={(next) => saveCell(r, 'score', next)}
|
||
>
|
||
{v ?? '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '班级均分',
|
||
dataIndex: 'classAvg',
|
||
render: (v: number | undefined, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="number"
|
||
min={0}
|
||
permission="student:edit"
|
||
disabled={!!r.examId}
|
||
onSave={(next) => saveCell(r, 'classAvg', next)}
|
||
>
|
||
{v !== undefined ? v : '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '排名',
|
||
dataIndex: 'rank',
|
||
render: (v: number | undefined, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="number"
|
||
min={1}
|
||
permission="student:edit"
|
||
disabled={!!r.examId}
|
||
onSave={(next) => saveCell(r, 'rank', next)}
|
||
>
|
||
{v !== undefined ? v : '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '考试日期',
|
||
dataIndex: 'examDate',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="date"
|
||
permission="student:edit"
|
||
disabled={!!r.examId}
|
||
onSave={(next) => saveCell(r, 'examDate', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '关联报读',
|
||
dataIndex: 'enrollmentId',
|
||
render: (v: number | undefined, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="select"
|
||
options={enrollments.map((item) => ({
|
||
value: item.id,
|
||
label: formatEnrollmentDisplayName(item),
|
||
}))}
|
||
permission="student:edit"
|
||
disabled={!!r.examId}
|
||
onSave={(next) => saveCell(r, 'enrollmentId', next)}
|
||
>
|
||
{(() => {
|
||
if (r.examId) return r.exam?.class?.name || '-';
|
||
if (v === undefined) return '-';
|
||
const enr = enrollments.find((e) => e.id === v);
|
||
return enr ? formatEnrollmentDisplayName(enr) : String(v);
|
||
})()}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<PermissionButton
|
||
permission="student:edit"
|
||
icon={<PlusOutlined />}
|
||
type="primary"
|
||
onClick={() => {
|
||
form.resetFields();
|
||
setModalOpen(true);
|
||
}}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
添加考试成绩
|
||
</PermissionButton>
|
||
<Table<ExamScoreRecord>
|
||
columns={columns}
|
||
dataSource={data}
|
||
rowKey="id"
|
||
pagination={{
|
||
defaultPageSize: 15,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [15, 30, 50],
|
||
}}
|
||
/>
|
||
<Modal
|
||
title="添加考试成绩"
|
||
open={modalOpen && hasPermission('student:edit')}
|
||
onOk={hasPermission('student:edit') ? handleAdd : undefined}
|
||
onCancel={() => setModalOpen(false)}
|
||
confirmLoading={saving}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item
|
||
name="examType"
|
||
label="考试类型"
|
||
rules={[{ required: true, message: '请选择考试类型' }]}
|
||
>
|
||
<Select options={EXAM_TYPE_OPTIONS} placeholder="请选择" />
|
||
</Form.Item>
|
||
<Form.Item name="examName" label="考试名称">
|
||
<Input placeholder="如:2024第一次月考" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="subject"
|
||
label="科目"
|
||
rules={[{ required: true, message: '请输入科目' }]}
|
||
>
|
||
<Input placeholder="如:数学" />
|
||
</Form.Item>
|
||
<Form.Item name="score" label="成绩" rules={[{ required: true, message: '请输入成绩' }]}>
|
||
<InputNumber min={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="classAvg" label="班级均分">
|
||
<InputNumber min={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="rank" label="排名">
|
||
<InputNumber min={1} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="examDate" label="考试日期">
|
||
<DatePicker style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="enrollmentId" label="关联报读">
|
||
<Select
|
||
allowClear
|
||
placeholder="选择关联的报读记录"
|
||
options={enrollments.map((e) => ({
|
||
value: e.id,
|
||
label: `${formatEnrollmentDisplayName(e)}(${getClassTypeLabel(e.classType)})`,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
|
||
data,
|
||
studentId,
|
||
onRefresh,
|
||
}) => {
|
||
const { hasPermission } = usePermission();
|
||
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}/learning-records`, {
|
||
...values,
|
||
recordDate: values.recordDate?.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: LearningRecord, field: string, value: unknown) => {
|
||
await api.put(`/archive/learning-records/${record.id}`, { [field]: value });
|
||
message.success('学情记录已保存');
|
||
onRefresh();
|
||
};
|
||
|
||
const columns: ColumnsType<LearningRecord> = [
|
||
{
|
||
title: '记录日期',
|
||
dataIndex: 'recordDate',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="date"
|
||
permission="student:edit"
|
||
required
|
||
onSave={(next) => saveCell(r, 'recordDate', next)}
|
||
>
|
||
{v}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '记录类型',
|
||
dataIndex: 'recordType',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="select"
|
||
options={RECORD_TYPE_OPTIONS}
|
||
permission="student:edit"
|
||
required
|
||
onSave={(next) => saveCell(r, 'recordType', next)}
|
||
>
|
||
{RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '内容',
|
||
dataIndex: 'content',
|
||
ellipsis: true,
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="textarea"
|
||
permission="student:edit"
|
||
required
|
||
onSave={(next) => saveCell(r, 'content', next)}
|
||
>
|
||
{v}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '跟进方式',
|
||
dataIndex: 'followUpMethod',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
permission="student:edit"
|
||
onSave={(next) => saveCell(r, 'followUpMethod', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '下一步计划',
|
||
dataIndex: 'nextStep',
|
||
render: (v: string, r) => (
|
||
<EditableCell
|
||
value={v}
|
||
editor="textarea"
|
||
permission="student:edit"
|
||
onSave={(next) => saveCell(r, 'nextStep', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<PermissionButton
|
||
permission="student:edit"
|
||
icon={<PlusOutlined />}
|
||
type="primary"
|
||
onClick={() => {
|
||
form.resetFields();
|
||
setModalOpen(true);
|
||
}}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
添加学情记录
|
||
</PermissionButton>
|
||
<Table<LearningRecord>
|
||
columns={columns}
|
||
dataSource={data}
|
||
rowKey="id"
|
||
pagination={{
|
||
defaultPageSize: 15,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [15, 30, 50],
|
||
}}
|
||
/>
|
||
<Modal
|
||
title="添加学情记录"
|
||
open={modalOpen && hasPermission('student:edit')}
|
||
onOk={hasPermission('student:edit') ? handleAdd : undefined}
|
||
onCancel={() => setModalOpen(false)}
|
||
confirmLoading={saving}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item
|
||
name="recordDate"
|
||
label="记录日期"
|
||
rules={[{ required: true, message: '请选择日期' }]}
|
||
>
|
||
<DatePicker style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="recordType"
|
||
label="记录类型"
|
||
rules={[{ required: true, message: '请选择记录类型' }]}
|
||
>
|
||
<Select options={RECORD_TYPE_OPTIONS} placeholder="请选择" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="content"
|
||
label="内容"
|
||
rules={[{ required: true, message: '请输入内容' }]}
|
||
>
|
||
<Input.TextArea rows={4} placeholder="请记录学情内容" />
|
||
</Form.Item>
|
||
<Form.Item name="followUpMethod" label="跟进方式">
|
||
<Input placeholder="如:电话、微信、面谈" />
|
||
</Form.Item>
|
||
<Form.Item name="nextStep" label="下一步计划">
|
||
<Input placeholder="后续跟进计划" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
|
||
data,
|
||
studentId,
|
||
onRefresh,
|
||
}) => {
|
||
const { hasPermission } = usePermission();
|
||
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<AttachmentRecord> = [
|
||
{
|
||
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) => (
|
||
<Space>
|
||
<Button
|
||
size="small"
|
||
icon={<EyeOutlined />}
|
||
onClick={async () => {
|
||
try {
|
||
const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {
|
||
responseType: 'blob',
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
window.open(url, '_blank');
|
||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string };
|
||
message.error(err?.message || '查看失败');
|
||
}
|
||
}}
|
||
>
|
||
查看
|
||
</Button>
|
||
{hasPermission('student:edit') ? (
|
||
<Popconfirm title="确定归档该附件?" onConfirm={() => handleDelete(record.id)}>
|
||
<Button size="small" danger icon={<InboxOutlined />}>
|
||
归档
|
||
</Button>
|
||
</Popconfirm>
|
||
) : null}
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
{hasPermission('student:edit') ? (
|
||
<Upload
|
||
showUploadList={false}
|
||
customRequest={async (options) => {
|
||
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);
|
||
}
|
||
}}
|
||
>
|
||
<Button icon={<UploadOutlined />} loading={uploading}>
|
||
上传附件
|
||
</Button>
|
||
</Upload>
|
||
) : null}
|
||
<Table<AttachmentRecord>
|
||
columns={columns}
|
||
dataSource={data}
|
||
rowKey="id"
|
||
pagination={{
|
||
defaultPageSize: 15,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [15, 30, 50],
|
||
}}
|
||
style={{ marginTop: 16 }}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ---- Main Component ----
|
||
|
||
const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||
studentId,
|
||
inDrawer,
|
||
onClose,
|
||
}) => {
|
||
const { hasPermission, hasAnyPermission } = usePermission();
|
||
const canLoadOrganizations = hasAnyPermission(
|
||
'organization:view',
|
||
'student:create',
|
||
'student:edit',
|
||
);
|
||
const canChooseOrganization = hasAnyPermission('student:create', 'student:edit');
|
||
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(null);
|
||
const [organizations, setOrganizations] = useState<Array<{ id: number; name: string }>>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const fetchData = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await api.get<StudentProfileAggregate>(`/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(() => {
|
||
if (!canLoadOrganizations) {
|
||
setOrganizations([]);
|
||
return;
|
||
}
|
||
api
|
||
.get('/organizations/options')
|
||
.then((res: unknown) => {
|
||
setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>);
|
||
})
|
||
.catch(() => {});
|
||
}, [canLoadOrganizations]);
|
||
|
||
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,
|
||
'学生档案',
|
||
hasPermission('log:create'),
|
||
);
|
||
|
||
const tabItems = useMemo(() => {
|
||
if (!aggregateData) return [];
|
||
const { enrollments, examScores, learningRecords, attachments, attendances } = aggregateData;
|
||
return [
|
||
{
|
||
key: 'enrollments',
|
||
label: `报读班型 (${enrollments.length})`,
|
||
children: <EnrollmentsTab data={enrollments} studentId={studentId} onRefresh={fetchData} />,
|
||
},
|
||
{
|
||
key: 'exams',
|
||
label: `考试成绩 (${examScores.length})`,
|
||
children: (
|
||
<ExamScoresTab
|
||
data={examScores}
|
||
studentId={studentId}
|
||
enrollments={enrollments}
|
||
onRefresh={fetchData}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: 'attendance',
|
||
label: `出勤记录 (${attendances.length})`,
|
||
children: <AttendanceTab data={attendances} />,
|
||
},
|
||
{
|
||
key: 'learning',
|
||
label: `课堂回访 (${learningRecords.length})`,
|
||
children: (
|
||
<LearningTab data={learningRecords} studentId={studentId} onRefresh={fetchData} />
|
||
),
|
||
},
|
||
{
|
||
key: 'attachments',
|
||
label: `附件 (${attachments.length})`,
|
||
children: <AttachmentsTab data={attachments} studentId={studentId} onRefresh={fetchData} />,
|
||
},
|
||
{
|
||
key: 'reports',
|
||
label: '报告版本',
|
||
children: <Empty description="暂无报告版本" />,
|
||
},
|
||
];
|
||
}, [aggregateData, studentId, fetchData]);
|
||
|
||
if (!aggregateData) {
|
||
if (loading) {
|
||
return (
|
||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||
<Spin size="large" />
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const { student, profile, result } = aggregateData;
|
||
|
||
return (
|
||
<div>
|
||
{inDrawer && (
|
||
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
|
||
<Space>
|
||
<Button type="text" icon={<CloseOutlined />} onClick={onClose} aria-label="关闭档案" />
|
||
<span style={{ fontSize: 16, fontWeight: 500 }}>
|
||
学员档案 - {student.name}
|
||
{student.studentNo ? ` (${student.studentNo})` : ''}
|
||
</span>
|
||
</Space>
|
||
<Space>
|
||
<Button icon={<FileTextOutlined />} onClick={handlePreviewReport}>
|
||
预览报告
|
||
</Button>
|
||
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
|
||
刷新
|
||
</Button>
|
||
</Space>
|
||
</Row>
|
||
)}
|
||
|
||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||
{[
|
||
{ title: '入学测试总分' },
|
||
{ title: '阶段最高分' },
|
||
{ title: '阶段提升分' },
|
||
{ title: '出勤率' },
|
||
].map((item) => (
|
||
<Col span={6} key={item.title}>
|
||
<Card size="small">
|
||
<Statistic title={item.title} value="-" />
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
|
||
<InlineArchiveSummary
|
||
studentId={studentId}
|
||
student={student}
|
||
profile={profile}
|
||
result={result}
|
||
organizations={organizations}
|
||
onRefresh={fetchData}
|
||
onViewSensitive={handleViewSensitive}
|
||
canViewSensitive={hasPermission('log:create')}
|
||
canChooseOrganization={canChooseOrganization}
|
||
/>
|
||
|
||
<Tabs defaultActiveKey="enrollments" items={tabItems} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default StudentProfileContent;
|