Files
gongxue-base/apps/admin/src/pages/StudentProfile/index.tsx

841 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Tabs,
Card,
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';
// ---- 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 { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(null);
const [loading, setLoading] = useState(false);
const fetchData = useCallback(async () => {
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 { student, profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData;
const handleDownloadReport = () => {
const token = localStorage.getItem('token');
window.open(`/api/archive/${id}/report?token=${token}`, '_blank');
};
return (
<Card
title={
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/students')} />
<span>
{student.name}
</span>
</Space>
}
loading={loading}
extra={
<PermissionButton
permission="student:view"
type="primary"
icon={<DownloadOutlined />}
onClick={handleDownloadReport}
>
</PermissionButton>
}
>
<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: '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>
);
};
export default StudentProfilePage;