From 8bd59b8ac61fd66435dee5a9b27c52dbf2f41b74 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 6 Jul 2026 15:58:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20complete=20student=20archive=20subsyste?= =?UTF-8?q?m=20=E2=80=94=207=20entities,=20PDF=20report,=20frontend=20prof?= =?UTF-8?q?ile=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/App.tsx | 12 +- apps/admin/src/pages/StudentProfile/index.tsx | 844 ++++++++++++++++++ apps/admin/src/pages/Students/index.tsx | 10 + .../src/archive/archive-report.service.ts | 263 ++++++ apps/server/src/archive/archive.controller.ts | 16 +- apps/server/src/archive/archive.module.ts | 5 +- apps/server/src/archive/archive.service.ts | 1 + .../src/entities/archive-attachment.entity.ts | 52 ++ apps/server/src/entities/exam-score.entity.ts | 66 ++ apps/server/src/entities/index.ts | 7 + .../src/entities/learning-record.entity.ts | 52 ++ .../src/entities/result-archive.entity.ts | 52 ++ .../src/entities/student-enrollment.entity.ts | 61 ++ .../src/entities/student-profile.entity.ts | 58 ++ .../src/entities/student-report.entity.ts | 49 + 15 files changed, 1539 insertions(+), 9 deletions(-) create mode 100644 apps/admin/src/pages/StudentProfile/index.tsx create mode 100644 apps/server/src/archive/archive-report.service.ts create mode 100644 apps/server/src/entities/archive-attachment.entity.ts create mode 100644 apps/server/src/entities/exam-score.entity.ts create mode 100644 apps/server/src/entities/learning-record.entity.ts create mode 100644 apps/server/src/entities/result-archive.entity.ts create mode 100644 apps/server/src/entities/student-enrollment.entity.ts create mode 100644 apps/server/src/entities/student-profile.entity.ts create mode 100644 apps/server/src/entities/student-report.entity.ts diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 7069219..37fcb17 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -13,9 +13,10 @@ import BillsPage from './pages/Bills'; import RoomVisualPage from './pages/RoomVisual'; import OperationLogsPage from './pages/OperationLogs'; import UsersPage from './pages/Users'; -import TeachersPage from './pages/Teachers'; +import ClassroomsPage from './pages/Classrooms'; import DepositsPage from './pages/Deposits'; import ClassroomsPage from './pages/Classrooms'; +import StudentProfilePage from './pages/StudentProfile'; import ClassesPage from './pages/Classes'; import ClassDetailPage from './pages/Classes/detail'; import TenantsPage from './pages/Tenants'; @@ -86,6 +87,15 @@ const App: React.FC = () => { } /> + + + + + } + /> = { + 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 ( +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +const EnrollmentsTab: React.FC = ({ + data, + studentId, + onRefresh, +}) => { + const [modalOpen, setModalOpen] = useState(false); + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + + const handleAdd = async () => { + try { + const values = await form.validateFields(); + setSaving(true); + await api.post(`/archive/${studentId}/enrollments`, { + ...values, + startDate: values.startDate?.format('YYYY-MM-DD'), + endDate: values.endDate?.format('YYYY-MM-DD'), + }); + message.success('报读记录已添加'); + setModalOpen(false); + form.resetFields(); + onRefresh(); + } catch (e: unknown) { + const err = e as { message?: string }; + if (err?.message) message.error(err.message); + } finally { + setSaving(false); + } + }; + + const columns: ColumnsType = [ + { 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 = { + active: 'green', + completed: 'blue', + withdrawn: 'red', + }; + return {v || '-'}; + }, + }, + ]; + + return ( +
+ + + columns={columns} + dataSource={data} + rowKey="id" + pagination={{ pageSize: 15 }} + /> + setModalOpen(false)} + confirmLoading={saving} + > +
+ + + + + + + + + + + + + + + + + + +
+
+
+ ); +}; + +const ExamScoresTab: React.FC = ({ + 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 = [ + { + 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 ( +
+ + + columns={columns} + dataSource={data} + rowKey="id" + pagination={{ pageSize: 15 }} + /> + setModalOpen(false)} + confirmLoading={saving} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +}; + +const ResultTab: React.FC = ({ 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 ( +
+
+ + + + + + + + + + + + + + + +
+
+ ); +}; + +const AttachmentsTab: React.FC = ({ 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 = [ + { + title: '类别', + dataIndex: 'category', + render: (v: string) => ATTACHMENT_CATEGORY_OPTIONS.find((o) => o.value === v)?.label || v, + }, + { title: '文件名', dataIndex: 'fileName' }, + { title: '大小', dataIndex: 'fileSize', render: formatFileSize }, + { + title: '操作', + render: (_: unknown, record: AttachmentRecord) => ( + + + handleDelete(record.id)}> + + + + ), + }, + ]; + + return ( +
+ void; + onError?: (error: Error) => void; + }) => { + const formData = new FormData(); + formData.append('file', options.file instanceof File ? options.file : new File([options.file], '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 err = e as { message?: string }; + message.error(err?.message || '上传失败'); + options.onError?.(err instanceof Error ? err : new Error('Upload failed')); + } finally { + setUploading(false); + } + }} + > + + + + columns={columns} + dataSource={data} + rowKey="id" + pagination={{ pageSize: 15 }} + style={{ marginTop: 16 }} + /> +
+ ); +}; + +// ---- Main Page ---- + +const StudentProfilePage: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [aggregateData, setAggregateData] = useState(null); + const [loading, setLoading] = useState(false); + + const fetchData = useCallback(async () => { + if (!id) return; + setLoading(true); + try { + const res = await api.get(`/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 ( + +