forked from wangziqi/gongxue-base
refactor(admin): extract StudentProfileContent component and reuse in drawer
This commit is contained in:
906
apps/admin/src/components/StudentProfileContent/index.tsx
Normal file
906
apps/admin/src/components/StudentProfileContent/index.tsx
Normal file
@@ -0,0 +1,906 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
DatePicker,
|
||||||
|
InputNumber,
|
||||||
|
Upload,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
Popconfirm,
|
||||||
|
Empty,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
FilePdfOutlined,
|
||||||
|
CameraOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import api from '../../api';
|
||||||
|
|
||||||
|
// ---- 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;
|
||||||
|
campusLocation?: 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 StudentProfileAggregate {
|
||||||
|
student: StudentInfo;
|
||||||
|
profile: ProfileData | null;
|
||||||
|
enrollments: EnrollmentRecord[];
|
||||||
|
examScores: ExamScoreRecord[];
|
||||||
|
learningRecords: LearningRecord[];
|
||||||
|
result: ResultData | null;
|
||||||
|
attachments: AttachmentRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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 ----
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
targetCollege: data?.targetCollege ?? undefined,
|
||||||
|
targetMajor: data?.targetMajor ?? undefined,
|
||||||
|
subjectDirection: data?.subjectDirection ?? undefined,
|
||||||
|
grade: data?.grade ?? undefined,
|
||||||
|
campusLocation: data?.campusLocation ?? undefined,
|
||||||
|
profileDate: data?.profileDate ? dayjs(data.profileDate) : undefined,
|
||||||
|
notes: data?.notes ?? undefined,
|
||||||
|
}}
|
||||||
|
style={{ maxWidth: 600 }}
|
||||||
|
>
|
||||||
|
<Form.Item name="targetCollege" label="目标院校">
|
||||||
|
<Input placeholder="请输入目标院校" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="targetMajor" label="目标专业">
|
||||||
|
<Input placeholder="请输入目标专业" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="subjectDirection" label="选科方向">
|
||||||
|
<Input placeholder="如:物化生、史地政" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="grade" label="年级">
|
||||||
|
<Input placeholder="如:高三" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="campusLocation" label="校区">
|
||||||
|
<Input placeholder="请输入校区" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="profileDate" label="建档日期">
|
||||||
|
<DatePicker style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="notes" label="备注">
|
||||||
|
<Input.TextArea rows={3} placeholder="其他备注信息" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" onClick={handleSave} loading={saving}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
||||||
|
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<EnrollmentRecord> = [
|
||||||
|
{ title: '课程类别', dataIndex: 'courseCategory', render: (v: string) => v || '-' },
|
||||||
|
{ title: '班型', dataIndex: 'classType', render: (v: string) => v || '-' },
|
||||||
|
{ 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 colorMap: Record<string, string> = {
|
||||||
|
active: 'green',
|
||||||
|
completed: 'blue',
|
||||||
|
withdrawn: 'red',
|
||||||
|
};
|
||||||
|
return <Tag color={colorMap[v] || 'default'}>{v || '-'}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
添加报读记录
|
||||||
|
</Button>
|
||||||
|
<Table<EnrollmentRecord>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={{ pageSize: 15 }}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="添加报读记录"
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleAdd}
|
||||||
|
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 [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<ExamScoreRecord> = [
|
||||||
|
{
|
||||||
|
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 ? `${enr.className || enr.courseCategory || v}` : String(v);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
添加考试成绩
|
||||||
|
</Button>
|
||||||
|
<Table<ExamScoreRecord>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={{ pageSize: 15 }}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="添加考试成绩"
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleAdd}
|
||||||
|
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: `${e.className || e.courseCategory || e.id} (${e.classType})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({ 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}/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 columns: ColumnsType<LearningRecord> = [
|
||||||
|
{ title: '记录日期', dataIndex: 'recordDate' },
|
||||||
|
{
|
||||||
|
title: '记录类型',
|
||||||
|
dataIndex: 'recordType',
|
||||||
|
render: (v: string) => RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v,
|
||||||
|
},
|
||||||
|
{ title: '内容', dataIndex: 'content', ellipsis: true },
|
||||||
|
{ title: '跟进方式', dataIndex: 'followUpMethod', render: (v: string) => v || '-' },
|
||||||
|
{ title: '下一步计划', dataIndex: 'nextStep', render: (v: string) => v || '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
添加学情记录
|
||||||
|
</Button>
|
||||||
|
<Table<LearningRecord>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={{ pageSize: 15 }}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="添加学情记录"
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleAdd}
|
||||||
|
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 ResultTab: React.FC<TabProps & { data: ResultData | null }> = ({ 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 (
|
||||||
|
<div>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
cultureFinalScore: data?.cultureFinalScore ?? undefined,
|
||||||
|
professionalFinalScore: data?.professionalFinalScore ?? undefined,
|
||||||
|
admissionStatus: data?.admissionStatus ?? undefined,
|
||||||
|
admittedCollege: data?.admittedCollege ?? undefined,
|
||||||
|
admittedMajor: data?.admittedMajor ?? undefined,
|
||||||
|
}}
|
||||||
|
style={{ maxWidth: 500 }}
|
||||||
|
>
|
||||||
|
<Form.Item name="cultureFinalScore" label="文化课最终分">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="professionalFinalScore" label="专业课最终分">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="admissionStatus" label="录取状态">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="请选择录取状态"
|
||||||
|
options={Object.entries(ADMISSION_STATUS_MAP).map(([k, v]) => ({
|
||||||
|
value: k,
|
||||||
|
label: v.text,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="admittedCollege" label="录取院校">
|
||||||
|
<Input placeholder="请输入录取院校" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="admittedMajor" label="录取专业">
|
||||||
|
<Input placeholder="请输入录取专业" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" onClick={handleSave} loading={saving}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ data, studentId, onRefresh }) => {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const handleDelete = async (attachmentId: number) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/archive/${studentId}/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={() => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
window.open(`/api/archive/${studentId}/attachments/${record.id}?token=${token}`, '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="确定删除该附件?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
<Table<AttachmentRecord>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={{ pageSize: 15 }}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Main Component ----
|
||||||
|
|
||||||
|
const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||||
|
studentId,
|
||||||
|
inDrawer,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(null);
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const handleDownloadReport = useCallback(() => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
window.open(`/api/archive/${studentId}/report?token=${token}`, '_blank');
|
||||||
|
}, [studentId]);
|
||||||
|
|
||||||
|
const handlePreviewReport = useCallback(() => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
window.open(`/api/archive/${studentId}/report?token=${token}`, '_blank');
|
||||||
|
}, [studentId]);
|
||||||
|
|
||||||
|
const handleDownloadPdf = useCallback(async () => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/archive/${studentId}/report`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('下载失败');
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `学员档案_${studentId}.pdf`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string };
|
||||||
|
message.error(err?.message || 'PDF下载失败');
|
||||||
|
}
|
||||||
|
}, [studentId]);
|
||||||
|
|
||||||
|
if (!aggregateData) return null;
|
||||||
|
|
||||||
|
const { student, profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{inDrawer && (
|
||||||
|
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
|
||||||
|
<Space>
|
||||||
|
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 500 }}>
|
||||||
|
学员档案 - {student.name} ({student.studentNo})
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button icon={<FileTextOutlined />} onClick={handlePreviewReport}>
|
||||||
|
报告预览
|
||||||
|
</Button>
|
||||||
|
<Button icon={<FilePdfOutlined />} onClick={handleDownloadPdf}>
|
||||||
|
PDF下载
|
||||||
|
</Button>
|
||||||
|
<Button icon={<CameraOutlined />} onClick={handleDownloadReport}>
|
||||||
|
生成报告快照
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}>
|
||||||
|
<Descriptions.Item label="学号">{student.studentNo || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="电话">{student.phone || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="身份证号">{student.idNumber || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag>{student.status || '-'}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{profile?.targetCollege && (
|
||||||
|
<Descriptions.Item label="目标院校">{profile.targetCollege}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{profile?.targetMajor && (
|
||||||
|
<Descriptions.Item label="目标专业">{profile.targetMajor}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{profile?.grade && (
|
||||||
|
<Descriptions.Item label="年级">{profile.grade}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{profile?.subjectDirection && (
|
||||||
|
<Descriptions.Item label="选科方向">{profile.subjectDirection}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{profile?.campusLocation && (
|
||||||
|
<Descriptions.Item label="校区">{profile.campusLocation}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="profile"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
label: '扩展档案',
|
||||||
|
children: <ProfileTab data={profile} studentId={studentId} onRefresh={fetchData} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: '出勤记录',
|
||||||
|
children: <Empty description="暂无出勤记录" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'learning',
|
||||||
|
label: `课堂回访 (${learningRecords.length})`,
|
||||||
|
children: (
|
||||||
|
<LearningTab data={learningRecords} studentId={studentId} onRefresh={fetchData} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'result',
|
||||||
|
label: '录取归档',
|
||||||
|
children: <ResultTab data={result} studentId={studentId} onRefresh={fetchData} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'attachments',
|
||||||
|
label: `附件 (${attachments.length})`,
|
||||||
|
children: (
|
||||||
|
<AttachmentsTab data={attachments} studentId={studentId} onRefresh={fetchData} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reports',
|
||||||
|
label: '报告版本',
|
||||||
|
children: <Empty description="暂无报告版本" />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentProfileContent;
|
||||||
@@ -1,742 +1,21 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import { Card, Button, Space } from 'antd';
|
||||||
Tabs,
|
import { ArrowLeftOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
Card,
|
import StudentProfileContent from '../../components/StudentProfileContent';
|
||||||
Descriptions,
|
|
||||||
Table,
|
|
||||||
Button,
|
|
||||||
Modal,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
DatePicker,
|
|
||||||
InputNumber,
|
|
||||||
Upload,
|
|
||||||
Tag,
|
|
||||||
Space,
|
|
||||||
message,
|
|
||||||
Popconfirm,
|
|
||||||
} from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import {
|
|
||||||
PlusOutlined,
|
|
||||||
UploadOutlined,
|
|
||||||
DownloadOutlined,
|
|
||||||
ArrowLeftOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
EyeOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import api from '../../api';
|
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
|
||||||
// ---- 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;
|
|
||||||
campusLocation?: 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 StudentProfileAggregate {
|
|
||||||
student: StudentInfo;
|
|
||||||
profile: ProfileData | null;
|
|
||||||
enrollments: EnrollmentRecord[];
|
|
||||||
examScores: ExamScoreRecord[];
|
|
||||||
learningRecords: LearningRecord[];
|
|
||||||
result: ResultData | null;
|
|
||||||
attachments: AttachmentRecord[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 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 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 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 ----
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
layout="vertical"
|
|
||||||
initialValues={{
|
|
||||||
targetCollege: data?.targetCollege ?? undefined,
|
|
||||||
targetMajor: data?.targetMajor ?? undefined,
|
|
||||||
subjectDirection: data?.subjectDirection ?? undefined,
|
|
||||||
grade: data?.grade ?? undefined,
|
|
||||||
campusLocation: data?.campusLocation ?? undefined,
|
|
||||||
profileDate: data?.profileDate ? dayjs(data.profileDate) : undefined,
|
|
||||||
notes: data?.notes ?? undefined,
|
|
||||||
}}
|
|
||||||
style={{ maxWidth: 600 }}
|
|
||||||
>
|
|
||||||
<Form.Item name="targetCollege" label="目标院校">
|
|
||||||
<Input placeholder="请输入目标院校" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="targetMajor" label="目标专业">
|
|
||||||
<Input placeholder="请输入目标专业" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="subjectDirection" label="选科方向">
|
|
||||||
<Input placeholder="如:物化生、史地政" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="grade" label="年级">
|
|
||||||
<Input placeholder="如:高三" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="campusLocation" label="校区">
|
|
||||||
<Input placeholder="请输入校区" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="profileDate" label="建档日期">
|
|
||||||
<DatePicker style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="notes" label="备注">
|
|
||||||
<Input.TextArea rows={3} placeholder="其他备注信息" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item>
|
|
||||||
<Button type="primary" onClick={handleSave} loading={saving}>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
|
||||||
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<EnrollmentRecord> = [
|
|
||||||
{ title: '课程类别', dataIndex: 'courseCategory', render: (v: string) => v || '-' },
|
|
||||||
{ title: '班型', dataIndex: 'classType', render: (v: string) => v || '-' },
|
|
||||||
{ 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 colorMap: Record<string, string> = {
|
|
||||||
active: 'green',
|
|
||||||
completed: 'blue',
|
|
||||||
withdrawn: 'red',
|
|
||||||
};
|
|
||||||
return <Tag color={colorMap[v] || 'default'}>{v || '-'}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
type="primary"
|
|
||||||
onClick={() => {
|
|
||||||
form.resetFields();
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
>
|
|
||||||
添加报读记录
|
|
||||||
</Button>
|
|
||||||
<Table<EnrollmentRecord>
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data}
|
|
||||||
rowKey="id"
|
|
||||||
pagination={{ pageSize: 15 }}
|
|
||||||
/>
|
|
||||||
<Modal
|
|
||||||
title="添加报读记录"
|
|
||||||
open={modalOpen}
|
|
||||||
onOk={handleAdd}
|
|
||||||
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 [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<ExamScoreRecord> = [
|
|
||||||
{
|
|
||||||
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 ? `${enr.className || enr.courseCategory || v}` : String(v);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
type="primary"
|
|
||||||
onClick={() => {
|
|
||||||
form.resetFields();
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
>
|
|
||||||
添加考试成绩
|
|
||||||
</Button>
|
|
||||||
<Table<ExamScoreRecord>
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data}
|
|
||||||
rowKey="id"
|
|
||||||
pagination={{ pageSize: 15 }}
|
|
||||||
/>
|
|
||||||
<Modal
|
|
||||||
title="添加考试成绩"
|
|
||||||
open={modalOpen}
|
|
||||||
onOk={handleAdd}
|
|
||||||
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: `${e.className || e.courseCategory || e.id} (${e.classType})`,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({ 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}/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 columns: ColumnsType<LearningRecord> = [
|
|
||||||
{ title: '记录日期', dataIndex: 'recordDate' },
|
|
||||||
{
|
|
||||||
title: '记录类型',
|
|
||||||
dataIndex: 'recordType',
|
|
||||||
render: (v: string) => RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v,
|
|
||||||
},
|
|
||||||
{ title: '内容', dataIndex: 'content', ellipsis: true },
|
|
||||||
{ title: '跟进方式', dataIndex: 'followUpMethod', render: (v: string) => v || '-' },
|
|
||||||
{ title: '下一步计划', dataIndex: 'nextStep', render: (v: string) => v || '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
type="primary"
|
|
||||||
onClick={() => {
|
|
||||||
form.resetFields();
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
>
|
|
||||||
添加学情记录
|
|
||||||
</Button>
|
|
||||||
<Table<LearningRecord>
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data}
|
|
||||||
rowKey="id"
|
|
||||||
pagination={{ pageSize: 15 }}
|
|
||||||
/>
|
|
||||||
<Modal
|
|
||||||
title="添加学情记录"
|
|
||||||
open={modalOpen}
|
|
||||||
onOk={handleAdd}
|
|
||||||
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 ResultTab: React.FC<TabProps & { data: ResultData | null }> = ({ 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 (
|
|
||||||
<div>
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
layout="vertical"
|
|
||||||
initialValues={{
|
|
||||||
cultureFinalScore: data?.cultureFinalScore ?? undefined,
|
|
||||||
professionalFinalScore: data?.professionalFinalScore ?? undefined,
|
|
||||||
admissionStatus: data?.admissionStatus ?? undefined,
|
|
||||||
admittedCollege: data?.admittedCollege ?? undefined,
|
|
||||||
admittedMajor: data?.admittedMajor ?? undefined,
|
|
||||||
}}
|
|
||||||
style={{ maxWidth: 500 }}
|
|
||||||
>
|
|
||||||
<Form.Item name="cultureFinalScore" label="文化课最终分">
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="professionalFinalScore" label="专业课最终分">
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="admissionStatus" label="录取状态">
|
|
||||||
<Select
|
|
||||||
allowClear
|
|
||||||
placeholder="请选择录取状态"
|
|
||||||
options={Object.entries(ADMISSION_STATUS_MAP).map(([k, v]) => ({
|
|
||||||
value: k,
|
|
||||||
label: v.text,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="admittedCollege" label="录取院校">
|
|
||||||
<Input placeholder="请输入录取院校" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="admittedMajor" label="录取专业">
|
|
||||||
<Input placeholder="请输入录取专业" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item>
|
|
||||||
<Button type="primary" onClick={handleSave} loading={saving}>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ data, studentId, onRefresh }) => {
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
|
|
||||||
const handleDelete = async (attachmentId: number) => {
|
|
||||||
try {
|
|
||||||
await api.delete(`/archive/${studentId}/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={() => {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
window.open(`/api/archive/${studentId}/attachments/${record.id}?token=${token}`, '_blank');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
查看
|
|
||||||
</Button>
|
|
||||||
<Popconfirm title="确定删除该附件?" onConfirm={() => handleDelete(record.id)}>
|
|
||||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
<Table<AttachmentRecord>
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data}
|
|
||||||
rowKey="id"
|
|
||||||
pagination={{ pageSize: 15 }}
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Main Page ----
|
|
||||||
|
|
||||||
const StudentProfilePage: React.FC = () => {
|
const StudentProfilePage: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
if (!id) return null;
|
||||||
if (!id) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await api.get<StudentProfileAggregate>(`/archive/${id}`);
|
|
||||||
setAggregateData(res);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const err = e as { message?: string };
|
|
||||||
message.error(err?.message || '加载失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void fetchData();
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
if (!aggregateData) return null;
|
|
||||||
|
|
||||||
const studentId = Number(id);
|
const studentId = Number(id);
|
||||||
const { student, profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData;
|
|
||||||
|
|
||||||
const handleDownloadReport = () => {
|
const handleDownloadReport = () => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
window.open(`/api/archive/${id}/report?token=${token}`, '_blank');
|
window.open(`/api/archive/${studentId}/report?token=${token}`, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -744,12 +23,9 @@ const StudentProfilePage: React.FC = () => {
|
|||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/students')} />
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/students')} />
|
||||||
<span>
|
<span>学生档案</span>
|
||||||
{student.name} — 学生档案
|
|
||||||
</span>
|
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
loading={loading}
|
|
||||||
extra={
|
extra={
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="student:view"
|
permission="student:view"
|
||||||
@@ -761,78 +37,7 @@ const StudentProfilePage: React.FC = () => {
|
|||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}>
|
<StudentProfileContent studentId={studentId} />
|
||||||
<Descriptions.Item label="学号">{student.studentNo || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="电话">{student.phone || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="身份证号">{student.idNumber || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="状态">
|
|
||||||
<Tag>{student.status || '-'}</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
{profile?.targetCollege && (
|
|
||||||
<Descriptions.Item label="目标院校">{profile.targetCollege}</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
{profile?.targetMajor && (
|
|
||||||
<Descriptions.Item label="目标专业">{profile.targetMajor}</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
{profile?.grade && (
|
|
||||||
<Descriptions.Item label="年级">{profile.grade}</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
{profile?.subjectDirection && (
|
|
||||||
<Descriptions.Item label="选科方向">{profile.subjectDirection}</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
{profile?.campusLocation && (
|
|
||||||
<Descriptions.Item label="校区">{profile.campusLocation}</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
</Descriptions>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
defaultActiveKey="profile"
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'profile',
|
|
||||||
label: '基础档案',
|
|
||||||
children: <ProfileTab data={profile} studentId={studentId} onRefresh={fetchData} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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: 'learning',
|
|
||||||
label: `学情记录 (${learningRecords.length})`,
|
|
||||||
children: (
|
|
||||||
<LearningTab data={learningRecords} studentId={studentId} onRefresh={fetchData} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'result',
|
|
||||||
label: '录取结果',
|
|
||||||
children: <ResultTab data={result} studentId={studentId} onRefresh={fetchData} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'attachments',
|
|
||||||
label: `附件 (${attachments.length})`,
|
|
||||||
children: (
|
|
||||||
<AttachmentsTab data={attachments} studentId={studentId} onRefresh={fetchData} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
Col,
|
Col,
|
||||||
Card,
|
Card,
|
||||||
Drawer,
|
Drawer,
|
||||||
Tabs,
|
|
||||||
Descriptions,
|
Descriptions,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +30,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import StudentProfileContent from '../../components/StudentProfileContent';
|
||||||
|
|
||||||
const maskPhone = (phone: string) => {
|
const maskPhone = (phone: string) => {
|
||||||
if (!phone || phone.length < 7) return phone || '-';
|
if (!phone || phone.length < 7) return phone || '-';
|
||||||
@@ -83,18 +83,12 @@ const StudentsPage: React.FC = () => {
|
|||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||||
const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({});
|
const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({});
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [drawerData, setDrawerData] = useState<any>(null);
|
const [drawerStudentId, setDrawerStudentId] = useState<number | undefined>(undefined);
|
||||||
const [drawerLoading, setDrawerLoading] = useState(false);
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
const openDrawer = async (studentId: number) => {
|
const openDrawer = (studentId: number) => {
|
||||||
|
setDrawerStudentId(studentId);
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
setDrawerLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await api.get(`/archive/${studentId}`);
|
|
||||||
setDrawerData(res);
|
|
||||||
} catch { setDrawerData(null); }
|
|
||||||
setDrawerLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewSensitive = (studentId: number, field: string, value: string) => {
|
const handleViewSensitive = (studentId: number, field: string, value: string) => {
|
||||||
@@ -583,98 +577,18 @@ const StudentsPage: React.FC = () => {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title={drawerData?.student?.name ? `${drawerData.student.name} — 学生档案` : '学生档案'}
|
title={null}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => { setDrawerOpen(false); setDrawerData(null); }}
|
onClose={() => { setDrawerOpen(false); }}
|
||||||
width={720}
|
width={720}
|
||||||
loading={drawerLoading}
|
|
||||||
>
|
>
|
||||||
{drawerData && (<div>
|
{drawerStudentId && (
|
||||||
<Tabs items={[
|
<StudentProfileContent
|
||||||
{
|
studentId={drawerStudentId}
|
||||||
key: 'info', label: '基础信息',
|
inDrawer
|
||||||
children: (
|
onClose={() => { setDrawerOpen(false); }}
|
||||||
<Descriptions column={2} size="small" bordered>
|
/>
|
||||||
<Descriptions.Item label="姓名">{drawerData.student?.name}</Descriptions.Item>
|
)}
|
||||||
<Descriptions.Item label="学号">{drawerData.student?.studentNo || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="电话">{drawerData.student?.phone || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="身份证">{drawerData.student?.idNumber || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="状态"><Tag>{drawerData.student?.status}</Tag></Descriptions.Item>
|
|
||||||
<Descriptions.Item label="目标院校">{drawerData.profile?.targetCollege || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="目标专业">{drawerData.profile?.targetMajor || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="科类方向">{drawerData.profile?.subjectDirection || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="年级">{drawerData.profile?.grade || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="校区">{drawerData.profile?.campusLocation || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="建档日期">{drawerData.profile?.profileDate || '-'}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'enrollments', label: `报读记录 (${drawerData.enrollments?.length || 0})`,
|
|
||||||
children: drawerData.enrollments?.length ? (
|
|
||||||
<Table rowKey="id" size="small" pagination={false} dataSource={drawerData.enrollments} columns={[
|
|
||||||
{ title: '课程类别', dataIndex: 'courseCategory' },
|
|
||||||
{ title: '班型', dataIndex: 'classType' },
|
|
||||||
{ title: '班级', dataIndex: 'className' },
|
|
||||||
{ title: '班主任', dataIndex: 'headTeacher' },
|
|
||||||
{ title: '开课', dataIndex: 'startDate' },
|
|
||||||
{ title: '结课', dataIndex: 'endDate' },
|
|
||||||
]} />
|
|
||||||
) : <div style={{ color: '#999' }}>暂无报读记录</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'exams', label: `考试成绩 (${drawerData.examScores?.length || 0})`,
|
|
||||||
children: drawerData.examScores?.length ? (
|
|
||||||
<Table rowKey="id" size="small" pagination={false} dataSource={drawerData.examScores} columns={[
|
|
||||||
{ title: '类型', dataIndex: 'examType' },
|
|
||||||
{ title: '科目', dataIndex: 'subject' },
|
|
||||||
{ title: '分数', dataIndex: 'score' },
|
|
||||||
{ title: '班均', dataIndex: 'classAvg', render: (v: number) => v ?? '-' },
|
|
||||||
{ title: '排名', dataIndex: 'rank', render: (v: number) => v ?? '-' },
|
|
||||||
{ title: '日期', dataIndex: 'examDate' },
|
|
||||||
]} />
|
|
||||||
) : <div style={{ color: '#999' }}>暂无考试成绩</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'result', label: '录取结果',
|
|
||||||
children: drawerData.result ? (
|
|
||||||
<Descriptions column={2} size="small" bordered>
|
|
||||||
<Descriptions.Item label="文化课成绩">{drawerData.result.cultureFinalScore ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="专业课成绩">{drawerData.result.professionalFinalScore ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="录取状态">{drawerData.result.admissionStatus || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="录取院校">{drawerData.result.admittedCollege || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="录取专业" span={2}>{drawerData.result.admittedMajor || '-'}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
) : <div style={{ color: '#999' }}>暂无录取结果</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'learning', label: `学情记录 (${drawerData.learningRecords?.length || 0})`,
|
|
||||||
children: drawerData.learningRecords?.length ? (
|
|
||||||
<Table rowKey="id" size="small" pagination={false} dataSource={drawerData.learningRecords} columns={[
|
|
||||||
{ title: '日期', dataIndex: 'recordDate', width: 100 },
|
|
||||||
{ title: '类型', dataIndex: 'recordType', width: 80 },
|
|
||||||
{ title: '内容', dataIndex: 'content', ellipsis: true },
|
|
||||||
{ title: '跟进方式', dataIndex: 'followUpMethod', width: 100 },
|
|
||||||
{ title: '下一步', dataIndex: 'nextStep', width: 100 },
|
|
||||||
]} />
|
|
||||||
) : <div style={{ color: '#999' }}>暂无学情记录</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'attachments', label: `附件 (${drawerData.attachments?.length || 0})`,
|
|
||||||
children: drawerData.attachments?.length ? (
|
|
||||||
<Table rowKey="id" size="small" pagination={false} dataSource={drawerData.attachments} columns={[
|
|
||||||
{ title: '分类', dataIndex: 'category', width: 80 },
|
|
||||||
{ title: '文件名', dataIndex: 'fileName', ellipsis: true },
|
|
||||||
{ title: '大小', dataIndex: 'fileSize', width: 80, render: (v: number) => v ? `${(v / 1024).toFixed(1)} KB` : '-' },
|
|
||||||
]} />
|
|
||||||
) : <div style={{ color: '#999' }}>暂无附件</div>,
|
|
||||||
},
|
|
||||||
]} />
|
|
||||||
<Button type="primary" icon={<DownloadOutlined />} style={{ marginTop: 16 }} onClick={() => {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
window.open(`/api/archive/${drawerData?.student?.id}/report?token=${token}`, '_blank');
|
|
||||||
}}>生成档案报表</Button>
|
|
||||||
</div>)}
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user