From c622e40a12d23d5c26a102f2283265c3a90cd3ee Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:11:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AD=A6=E7=94=9F=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=E4=B8=8E=E6=8A=A5=E5=91=8A=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/EditableCell/index.tsx | 67 +- .../StudentProfileContent/AttachmentsTab.tsx | 156 +++ .../EditableArchiveCell.tsx | 45 + .../StudentProfileContent/EnrollmentsTab.tsx | 260 ++++ .../StudentProfileContent/ExamScoresTab.tsx | 260 ++++ .../StudentProfileContent/LearningTab.tsx | 215 +++ .../StudentProfileContent/index.tsx | 1207 ++--------------- .../StudentProfileContent/shared.ts | 216 +++ apps/admin/src/pages/StudentProfile/index.tsx | 2 +- .../pages/archive-view.integration.test.ts | 31 +- apps/admin/src/pages/archive-view.ts | 4 + .../src/archive/archive-report.attendance.ts | 165 +++ .../src/archive/archive-report.cover.ts | 89 ++ .../src/archive/archive-report.enrollment.ts | 80 ++ .../server/src/archive/archive-report.exam.ts | 249 ++++ .../src/archive/archive-report.helpers.ts | 20 + .../src/archive/archive-report.learning.ts | 85 ++ .../src/archive/archive-report.service.ts | 849 +----------- .../src/archive/archive-report.styles.ts | 142 ++ apps/server/src/archive/archive.controller.ts | 284 ++-- .../archive/archive.purge.controller.spec.ts | 38 + apps/server/src/archive/archive.purge.spec.ts | 114 ++ apps/server/src/archive/archive.service.ts | 61 +- 23 files changed, 2491 insertions(+), 2148 deletions(-) create mode 100644 apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx create mode 100644 apps/admin/src/components/StudentProfileContent/EditableArchiveCell.tsx create mode 100644 apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx create mode 100644 apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx create mode 100644 apps/admin/src/components/StudentProfileContent/LearningTab.tsx create mode 100644 apps/admin/src/components/StudentProfileContent/shared.ts create mode 100644 apps/server/src/archive/archive-report.attendance.ts create mode 100644 apps/server/src/archive/archive-report.cover.ts create mode 100644 apps/server/src/archive/archive-report.enrollment.ts create mode 100644 apps/server/src/archive/archive-report.exam.ts create mode 100644 apps/server/src/archive/archive-report.helpers.ts create mode 100644 apps/server/src/archive/archive-report.learning.ts create mode 100644 apps/server/src/archive/archive-report.styles.ts create mode 100644 apps/server/src/archive/archive.purge.controller.spec.ts create mode 100644 apps/server/src/archive/archive.purge.spec.ts diff --git a/apps/admin/src/components/EditableCell/index.tsx b/apps/admin/src/components/EditableCell/index.tsx index 58ad4c7..a37a31b 100644 --- a/apps/admin/src/components/EditableCell/index.tsx +++ b/apps/admin/src/components/EditableCell/index.tsx @@ -1,10 +1,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd'; -import type { Dayjs } from 'dayjs'; -import dayjs from 'dayjs'; +import dayjs, { type Dayjs } from 'dayjs'; +import equal from 'fast-deep-equal'; import { usePermission } from '../../hooks/usePermission'; import { message } from '../../ui/app-message'; import './style.css'; +import { getErrorMessage } from '../../utils/error'; export type EditableCellEditor = | 'text' @@ -66,7 +67,7 @@ export function serializeEditableValue(value: unknown, editor: EditableCellEdito } export function editableValuesEqual(left: unknown, right: unknown) { - return JSON.stringify(left ?? null) === JSON.stringify(right ?? null); + return equal(left ?? null, right ?? null); } function isEditorOverlay(target: EventTarget | null) { @@ -112,43 +113,40 @@ const EditableCell = ({ [editor, formatValue, value], ); - useEffect(() => { - if (!editing) { - setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor)); - } - }, [editing, editor, formatValue, value]); - const cancel = useCallback(() => { setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor)); if (activeCell?.id === idRef.current) activeCell = null; setEditing(false); }, [editor, formatValue, value]); - const saveValue = useCallback(async (nextDraft: unknown) => { - if (saving) return false; - const serialized = serializeEditableValue(nextDraft, editor); - if (required && (serialized === '' || serialized === undefined || serialized === null)) { - message.error('该字段不能为空'); - return false; - } - if (editableValuesEqual(serialized, original)) { - if (activeCell?.id === idRef.current) activeCell = null; - setEditing(false); - return true; - } - setSaving(true); - try { - await onSave(parseValue ? parseValue(serialized) : (serialized as Value)); - if (activeCell?.id === idRef.current) activeCell = null; - setEditing(false); - return true; - } catch (error) { - message.error((error as { message?: string })?.message || '保存失败'); - return false; - } finally { - setSaving(false); - } - }, [editor, onSave, original, parseValue, required, saving]); + const saveValue = useCallback( + async (nextDraft: unknown) => { + if (saving) return false; + const serialized = serializeEditableValue(nextDraft, editor); + if (required && (serialized === '' || serialized === undefined || serialized === null)) { + message.error('该字段不能为空'); + return false; + } + if (editableValuesEqual(serialized, original)) { + if (activeCell?.id === idRef.current) activeCell = null; + setEditing(false); + return true; + } + setSaving(true); + try { + await onSave(parseValue ? parseValue(serialized) : (serialized as Value)); + if (activeCell?.id === idRef.current) activeCell = null; + setEditing(false); + return true; + } catch (error) { + message.error(getErrorMessage(error, '保存失败')); + return false; + } finally { + setSaving(false); + } + }, + [editor, onSave, original, parseValue, required, saving], + ); const save = useCallback(() => saveValue(draft), [draft, saveValue]); @@ -199,6 +197,7 @@ const EditableCell = ({ if (!saved) return; } activeCell = { id: idRef.current, save }; + setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor)); setEditing(true); }; diff --git a/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx b/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx new file mode 100644 index 0000000..1a0f132 --- /dev/null +++ b/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react'; +import { App, Button, Popconfirm, Space, Table, Upload } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { EyeOutlined, InboxOutlined, UploadOutlined } from '@ant-design/icons'; +import api from '../../api'; +import { message } from '../../ui/app-message'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { usePermission } from '../../hooks/usePermission'; +import { getErrorMessage } from '../../utils/error'; +import { ATTACHMENT_CATEGORY_OPTIONS, formatFileSize } from './shared'; +import type { AttachmentRecord, TabProps } from './shared'; + +export const AttachmentsTab: React.FC = ({ + data, + studentId, +}) => { + const { modal } = App.useApp(); + const { hasPermission } = usePermission(); + const canPurgeArchive = hasPermission('archive:purge'); + const [uploading, setUploading] = useState(false); + + const deleteAttachmentMutation = useApiMutation( + async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`), + { invalidate: [['archive', studentId]] }, + ); + const purgeAttachmentMutation = useApiMutation( + async (id: number) => api.delete(`/archive/attachments/${id}/permanent`), + { invalidate: [['archive', studentId]] }, + ); + const uploadAttachmentMutation = useApiMutation( + async (formData: FormData) => + api.post(`/archive/${studentId}/attachments`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: [['archive', studentId]] }, + ); + + const handleDelete = async (attachmentId: number) => { + try { + await deleteAttachmentMutation.mutateAsync(attachmentId); + message.success('已归档'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }; + + const handlePurge = (record: AttachmentRecord) => { + modal.confirm({ + title: `永久删除附件「${record.fileName}」?`, + content: '删除后不可恢复,磁盘上的附件文件将被清除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeAttachmentMutation.mutateAsync(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + + 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) => ( + + + {hasPermission('student:edit') && record.status !== 'archived' ? ( + handleDelete(record.id)}> + + + ) : null} + {record.status === 'archived' && canPurgeArchive ? ( + + ) : null} + + ), + }, + ]; + + return ( +
+ {hasPermission('student:edit') ? ( + { + const formData = new FormData(); + formData.append( + 'file', + options.file instanceof File + ? options.file + : new File([options.file as Blob], 'attachment'), + ); + setUploading(true); + try { + await uploadAttachmentMutation.mutateAsync(formData); + message.success('上传成功'); + options.onSuccess?.({}); + } catch (e) { + options.onError?.(e instanceof Error ? e : new Error('上传失败')); + } finally { + setUploading(false); + } + }} + > + + + ) : null} + + columns={columns} + dataSource={data} + rowKey="id" + rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50], + }} + style={{ marginTop: 16 }} + /> +
+ ); +}; diff --git a/apps/admin/src/components/StudentProfileContent/EditableArchiveCell.tsx b/apps/admin/src/components/StudentProfileContent/EditableArchiveCell.tsx new file mode 100644 index 0000000..3935f94 --- /dev/null +++ b/apps/admin/src/components/StudentProfileContent/EditableArchiveCell.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import EditableCell from '../EditableCell'; + +/** + * 学生档案模块统一的可编辑单元格: + * 固定 student:edit 权限,配合各 Tab 的 saveCell 使用。 + */ +export const EditableArchiveCell = ({ + value, + field, + record, + editor, + min, + max, + required, + options, + onSave, + children, +}: { + value: unknown; + field: string; + record: R; + editor?: React.ComponentProps['editor']; + min?: number; + max?: number; + required?: boolean; + options?: Array<{ value: string | number; label: string }>; + onSave: (record: R, field: string, value: unknown) => Promise | void; + children?: React.ReactNode; +}) => ( + { + await onSave(record, field, next); + }} + > + {children ?? String(value ?? '-')} + +); diff --git a/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx b/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx new file mode 100644 index 0000000..a6038ee --- /dev/null +++ b/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx @@ -0,0 +1,260 @@ +import React, { useState } from 'react'; +import { App, Button, DatePicker, Form, Input, Modal, Select, Table, Tag } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { PlusOutlined } from '@ant-design/icons'; +import api from '../../api'; +import { message } from '../../ui/app-message'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { usePermission } from '../../hooks/usePermission'; +import PermissionButton from '../PermissionButton'; +import { EditableArchiveCell } from './EditableArchiveCell'; +import { CLASS_TYPE_OPTIONS, COURSE_CATEGORY_OPTIONS, ENROLLMENT_STATUS_MAP, formatEnrollmentDisplayName, getClassTypeLabel, getCourseCategoryLabel, getEnrollmentStatus } from './shared'; +import type { EnrollmentRecord, TabProps } from './shared'; + +const ENROLLMENT_FIELDS = { + courseCategory: 'courseCategory', + classType: 'classType', + className: 'className', + headTeacher: 'headTeacher', + subjectTeacher: 'subjectTeacher', + startDate: 'startDate', + endDate: 'endDate', + status: 'status', +} as const; + +export const EnrollmentsTab: React.FC = ({ + data, + studentId, +}) => { + const { modal } = App.useApp(); + const { hasPermission } = usePermission(); + const canPurgeArchive = hasPermission('archive:purge'); + const [modalOpen, setModalOpen] = useState(false); + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + + const addEnrollmentMutation = useApiMutation( + async (payload: Record) => + api.post(`/archive/${studentId}/enrollments`, payload), + { invalidate: [['archive', studentId]] }, + ); + const saveEnrollmentCellMutation = useApiMutation( + async ({ id, field, value }: { id: number; field: string; value: unknown }) => + api.put(`/archive/enrollments/${id}`, { [field]: value }), + { invalidate: [['archive', studentId]] }, + ); + const purgeEnrollmentMutation = useApiMutation( + async (id: number) => api.delete(`/archive/enrollments/${id}/permanent`), + { invalidate: [['archive', studentId]] }, + ); + + const handleAdd = async () => { + try { + const values = await form.validateFields(); + setSaving(true); + await addEnrollmentMutation.mutateAsync({ + ...values, + startDate: values.startDate?.format('YYYY-MM-DD'), + endDate: values.endDate?.format('YYYY-MM-DD'), + }); + message.success('报读记录已添加'); + setModalOpen(false); + form.resetFields(); + } catch { + // 错误提示由 useApiMutation 统一处理 + } finally { + setSaving(false); + } + }; + + const saveCell = async (record: EnrollmentRecord, field: string, value: unknown) => { + try { + await saveEnrollmentCellMutation.mutateAsync({ id: record.id, field, value }); + message.success('报读记录已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }; + + const handlePurge = (record: EnrollmentRecord) => { + modal.confirm({ + title: `永久删除报读记录(${formatEnrollmentDisplayName(record)})?`, + content: '删除后不可恢复,被考试成绩引用时将无法删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeEnrollmentMutation.mutateAsync(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + + const columns: ColumnsType = [ + { + title: '课程类别', + dataIndex: 'courseCategory', + render: (v: string, r) => ( + + {getCourseCategoryLabel(v)} + + ), + }, + { + title: '班型', + dataIndex: 'classType', + render: (v: string, r) => ( + + {getClassTypeLabel(v)} + + ), + }, + { + title: '班级名称', + dataIndex: 'className', + render: (v: string, r) => ( + + {v || '-'} + + ), + }, + { + title: '班主任', + dataIndex: 'headTeacher', + render: (v: string, r) => ( + + {v || '-'} + + ), + }, + { + title: '任课教师', + dataIndex: 'subjectTeacher', + render: (v: string, r) => ( + + {v || '-'} + + ), + }, + { + title: '开始日期', + dataIndex: 'startDate', + render: (v: string, r) => ( + + {v || '-'} + + ), + }, + { + title: '结束日期', + dataIndex: 'endDate', + render: (v: string, r) => ( + + {v || '-'} + + ), + }, + { + title: '状态', + dataIndex: 'status', + render: (v: string, r) => { + const status = getEnrollmentStatus(v); + return ( + ({ + value, + label: item.text, + }))} + onSave={saveCell} + > + {status.text} + + ); + }, + }, + { + title: '操作', + render: (_: unknown, r: EnrollmentRecord) => + r.status === 'archived' && canPurgeArchive ? ( + + ) : null, + }, + ]; + + return ( +
+ } + type="primary" + onClick={() => { + form.resetFields(); + setModalOpen(true); + }} + style={{ marginBottom: 16 }} + > + 添加报读记录 + + + columns={columns} + dataSource={data} + rowKey="id" + rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50], + }} + /> + setModalOpen(false)} + confirmLoading={saving} + > +
+ + + + + + + + + + + + + + + + + + +
+
+
+ ); +}; diff --git a/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx b/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx new file mode 100644 index 0000000..ed45f3f --- /dev/null +++ b/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx @@ -0,0 +1,260 @@ +import React, { useState } from 'react'; +import { App, Button, DatePicker, Form, Input, InputNumber, Modal, Select, Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { PlusOutlined } from '@ant-design/icons'; +import api from '../../api'; +import { message } from '../../ui/app-message'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { usePermission } from '../../hooks/usePermission'; +import PermissionButton from '../PermissionButton'; +import { EditableArchiveCell } from './EditableArchiveCell'; +import { EXAM_TYPE_OPTIONS, formatEnrollmentDisplayName, getClassTypeLabel } from './shared'; +import type { EnrollmentRecord, ExamScoreRecord, TabProps } from './shared'; + +const EXAM_SCORE_FIELDS = { + examType: 'examType', + examName: 'examName', + subject: 'subject', + score: 'score', + classAvg: 'classAvg', + rank: 'rank', + examDate: 'examDate', + enrollmentId: 'enrollmentId', +} as const; + +export const ExamScoresTab: React.FC< + TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] } +> = ({ data, studentId, enrollments }) => { + const { modal } = App.useApp(); + const { hasPermission } = usePermission(); + const canPurgeArchive = hasPermission('archive:purge'); + const [modalOpen, setModalOpen] = useState(false); + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + + const addExamScoreMutation = useApiMutation( + async (payload: Record) => + api.post(`/archive/${studentId}/exam-scores`, payload), + { invalidate: [['archive', studentId]] }, + ); + const saveExamScoreCellMutation = useApiMutation( + async ({ id, field, value }: { id: number; field: string; value: unknown }) => + api.put(`/archive/exam-scores/${id}`, { [field]: value }), + { invalidate: [['archive', studentId]] }, + ); + const purgeExamScoreMutation = useApiMutation( + async (id: number) => api.delete(`/archive/exam-scores/${id}/permanent`), + { invalidate: [['archive', studentId]] }, + ); + + const handleAdd = async () => { + try { + const values = await form.validateFields(); + setSaving(true); + await addExamScoreMutation.mutateAsync({ + ...values, + examDate: values.examDate?.format('YYYY-MM-DD'), + }); + message.success('考试成绩已添加'); + setModalOpen(false); + form.resetFields(); + } catch { + // 错误提示由 useApiMutation 统一处理 + } finally { + setSaving(false); + } + }; + + const saveCell = async (record: ExamScoreRecord, field: string, value: unknown) => { + try { + await saveExamScoreCellMutation.mutateAsync({ id: record.id, field, value }); + message.success('考试成绩已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }; + + const handlePurge = (record: ExamScoreRecord) => { + modal.confirm({ + title: `永久删除考试成绩(${record.examName || record.subject || `记录${record.id}`})?`, + content: '删除后不可恢复,成绩记录将被物理删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeExamScoreMutation.mutateAsync(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + + const columns: ColumnsType = [ + { + title: '考试类型', + dataIndex: 'examType', + render: (v: string, r) => ( + + {EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v} + + ), + }, + { + title: '考试名称', + dataIndex: 'examName', + render: (v: string, r) => ( + + {v || '-'} + + ), + }, + { + title: '科目', + dataIndex: 'subject', + render: (v: string, r) => ( + + {v} + + ), + }, + { + title: '成绩', + dataIndex: 'score', + render: (v: number | null, r) => ( + + {v ?? '-'} + + ), + }, + { + title: '班级均分', + dataIndex: 'classAvg', + render: (v: number | undefined, r) => ( + + {v !== undefined ? v : '-'} + + ), + }, + { + title: '排名', + dataIndex: 'rank', + render: (v: number | undefined, r) => ( + + {v !== undefined ? v : '-'} + + ), + }, + { + title: '考试日期', + dataIndex: 'examDate', + render: (v: string, r) => ( + + {v || '-'} + + ), + }, + { + title: '关联报读', + dataIndex: 'enrollmentId', + render: (v: number | undefined, r) => ( + ({ value: item.id, label: formatEnrollmentDisplayName(item), }))} onSave={saveCell}> + {(() => { + if (r.examId) return r.exam?.class?.name || '-'; + if (v === undefined) return '-'; + const enr = enrollments.find((e) => e.id === v); + return enr ? formatEnrollmentDisplayName(enr) : String(v); + })()} + + ), + }, + { + title: '操作', + render: (_: unknown, r: ExamScoreRecord) => + r.status === 'archived' && canPurgeArchive ? ( + + ) : null, + }, + ]; + + return ( +
+ } + type="primary" + onClick={() => { + form.resetFields(); + setModalOpen(true); + }} + style={{ marginBottom: 16 }} + > + 添加考试成绩 + + + columns={columns} + dataSource={data} + rowKey="id" + rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50], + }} + /> + setModalOpen(false)} + confirmLoading={saving} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +}; diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 991f721..549e421 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -1,20 +1,12 @@ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Tabs, Card, Descriptions, Table, Button, - Modal, - Form, - Input, - Select, - DatePicker, - InputNumber, - Upload, Tag, Space, - Popconfirm, Empty, Row, Col, @@ -23,9 +15,6 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { - PlusOutlined, - UploadOutlined, - InboxOutlined, EyeOutlined, CloseOutlined, FileTextOutlined, @@ -36,223 +25,42 @@ import api from '../../api'; import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import { useViewSensitive } from '../../hooks/useViewSensitive'; import { message } from '../../ui/app-message'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas'; import EditableCell from '../EditableCell'; import { usePermission } from '../../hooks/usePermission'; -import PermissionButton from '../PermissionButton'; +import { getErrorMessage } from '../../utils/error'; -// ---- Types ---- +import { ADMISSION_STATUS_MAP, ATTENDANCE_STATUS_MAP, SESSION_LABELS, getOptionLabel } from './shared'; +import type { AttendanceRecordItem, ProfileData, ResultData, StudentInfo, StudentProfileAggregate, StudentProfileContentProps } from './shared'; +import { EnrollmentsTab } from './EnrollmentsTab'; +import { ExamScoresTab } from './ExamScoresTab'; +import { LearningTab } from './LearningTab'; +import { AttachmentsTab } from './AttachmentsTab'; -interface StudentInfo { - id: number; - name: string; - phone: string; - idNumber: string; - studentNo: string; - gender?: string; - ethnicity?: string; - emergencyContact?: string; - emergencyPhone?: string; - organizationId?: number; - organization?: { id?: number; name?: string } | null; - supervisor?: string; - status: string; -} - -interface ProfileData { - targetCollege?: string; - targetMajor?: string; - collegeSchool?: string; - collegeMajor?: string; - subjectDirection?: string; - grade?: string; - profileDate?: string; - notes?: string; -} - -interface EnrollmentRecord { - id: number; - courseCategory: string; - classType: string; - className?: string; - headTeacher?: string; - subjectTeacher?: string; - startDate?: string; - endDate?: string; - status: string; -} - -interface ExamScoreRecord { - id: number; - examId?: number; - exam?: { class?: { name?: string } }; - examType: string; - examName?: string; - subject: string; - score: number | null; - classAvg?: number; - rank?: number; - examDate?: string; - enrollmentId?: number; -} - -interface LearningRecord { - id: number; - recordDate: string; - recordType: string; - content: string; - followUpMethod?: string; - nextStep?: string; -} - -interface ResultData { - cultureFinalScore?: number; - professionalFinalScore?: number; - admissionStatus?: string; - admittedCollege?: string; - admittedMajor?: string; -} - -interface AttachmentRecord { - id: number; - category: string; - fileName: string; - fileSize: number; -} - -interface AttendanceRecordItem { - id: number; - attendanceDate: string; - session: string; - status: string; - source?: string; - remark?: string | null; - punchTime?: string | null; - punchDeviceName?: string | null; - punchDeviceId?: string | null; - schedule?: { subject?: string } | null; - class?: { name?: string } | null; -} - -interface StudentProfileAggregate { - student: StudentInfo; - profile: ProfileData | null; - enrollments: EnrollmentRecord[]; - examScores: ExamScoreRecord[]; - learningRecords: LearningRecord[]; - result: ResultData | null; - attachments: AttachmentRecord[]; - attendances: AttendanceRecordItem[]; -} - -export interface StudentProfileContentProps { - studentId: number; - inDrawer?: boolean; - onClose?: () => void; -} - -// ---- Constants ---- - -const ADMISSION_STATUS_MAP: Record = { - admitted: { text: '已录取', color: 'green' }, - pending: { text: '待录取', color: 'orange' }, - rejected: { text: '未录取', color: 'red' }, - withdrawn: { text: '放弃', color: '#999' }, -}; - -const EXAM_TYPE_OPTIONS = [ - { value: 'monthly', label: '月考' }, - { value: 'midterm', label: '期中' }, - { value: 'final', label: '期末' }, - { value: 'mock', label: '模拟考' }, - { value: 'entrance', label: '入学测试' }, - { value: 'other', label: '其他' }, -]; - -const RECORD_TYPE_OPTIONS = [ - { value: 'study_feedback', label: '学习反馈' }, - { value: 'parent_communication', label: '家长沟通' }, - { value: 'behavior_note', label: '行为记录' }, - { value: 'meeting', label: '会议记录' }, - { value: 'other', label: '其他' }, -]; - -const ENROLLMENT_STATUS_MAP: Record = { - active: { text: '报读中', color: 'green' }, - completed: { text: '已结课', color: 'blue' }, - withdrawn: { text: '已退训', color: 'red' }, -}; - -const COURSE_CATEGORY_OPTIONS = [ - { value: 'culture', label: '文化课' }, - { value: 'professional', label: '专业课' }, - { value: 'comprehensive', label: '综合' }, -]; - -const CLASS_TYPE_OPTIONS = [ - { value: 'one_on_one', label: '一对一' }, - { value: 'small_group', label: '小班' }, - { value: 'large_class', label: '大班' }, - { value: 'online', label: '线上' }, - { value: 'offline', label: '线下' }, -]; - -const getOptionLabel = ( - options: Array<{ value: string; label: string }>, - value?: string | null, -): string => { - if (!value) return '-'; - return options.find((option) => option.value === value)?.label || value; -}; - -const getCourseCategoryLabel = (value?: string | null): string => - getOptionLabel(COURSE_CATEGORY_OPTIONS, value); - -const getClassTypeLabel = (value?: string | null): string => - getOptionLabel(CLASS_TYPE_OPTIONS, value); - -const getEnrollmentStatus = (value?: string | null): { text: string; color: string } => { - if (!value) return { text: '-', color: 'default' }; - return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' }; -}; - -const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string => - enrollment.className || - (enrollment.courseCategory - ? getCourseCategoryLabel(enrollment.courseCategory) - : String(enrollment.id)); - -const ATTACHMENT_CATEGORY_OPTIONS = [ - { value: 'id_card', label: '身份证' }, - { value: 'transcript', label: '成绩单' }, - { value: 'certificate', label: '证书' }, - { value: 'contract', label: '合同' }, - { value: 'photo', label: '照片' }, - { value: 'other', label: '其他' }, -]; - -const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -}; - -// ---- Tab Components ---- - -const ATTENDANCE_STATUS_MAP: Record = { - present: { text: '出勤', color: 'green' }, - late: { text: '迟到', color: 'orange' }, - absent: { text: '缺勤', color: 'red' }, - leave: { text: '请假', color: 'blue' }, - pending: { text: '待确认', color: 'default' }, -}; - -const SESSION_LABELS: Record = { - morning_reading: '早自习', - morning: '上午', - afternoon: '下午', - evening_study: '晚自习', - night_check: '晚寝', -}; +const EditableField: React.FC<{ + value: unknown; + onSave: (value: unknown) => Promise | void; + editor?: React.ComponentProps['editor']; + min?: number; + required?: boolean; + children?: React.ReactNode; +}> = ({ value, onSave, editor, min, required, children }) => ( + { + await onSave(next); + }} + > + {children ?? String(value ?? '-')} + +); const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => { const columns: ColumnsType = [ @@ -307,11 +115,6 @@ const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => ); }; -interface TabProps { - studentId: number; - onRefresh: () => void; -} - const InlineArchiveSummary: React.FC<{ studentId: number; student: StudentInfo; @@ -328,27 +131,51 @@ const InlineArchiveSummary: React.FC<{ profile, result, organizations, - onRefresh, onViewSensitive, canViewSensitive, canChooseOrganization, }) => { + const saveStudentMutation = useApiMutation( + async ({ field, value }: { field: keyof StudentInfo; value: unknown }) => + api.put(`/students/${studentId}`, { [field]: value }), + { invalidate: [['archive', studentId], ['students']] }, + ); + const saveProfileMutation = useApiMutation( + async ({ field, value }: { field: keyof ProfileData; value: unknown }) => + api.put(`/archive/${studentId}/profile`, { [field]: value }), + { invalidate: [['archive', studentId]] }, + ); + const saveResultMutation = useApiMutation( + async ({ field, value }: { field: keyof ResultData; value: unknown }) => + api.put(`/archive/${studentId}/result`, { [field]: value }), + { invalidate: [['archive', studentId]] }, + ); + const saveStudent = async (field: keyof StudentInfo, value: unknown) => { - await api.put(`/students/${studentId}`, { [field]: value }); - message.success('学生资料已保存'); - onRefresh(); + try { + await saveStudentMutation.mutateAsync({ field, value }); + message.success('学生资料已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }; const saveProfile = async (field: keyof ProfileData, value: unknown) => { - await api.put(`/archive/${studentId}/profile`, { [field]: value }); - message.success('档案已保存'); - onRefresh(); + try { + await saveProfileMutation.mutateAsync({ field, value }); + message.success('档案已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }; const saveResult = async (field: keyof ResultData, value: unknown) => { - await api.put(`/archive/${studentId}/result`, { [field]: value }); - message.success('录取信息已保存'); - onRefresh(); + try { + await saveResultMutation.mutateAsync({ field, value }); + message.success('录取信息已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }; const admissionStatus = getOptionLabel( @@ -381,34 +208,25 @@ const InlineArchiveSummary: React.FC<{ )} + - saveStudent('name', next)} - > + saveStudent('name', next)}> {student.name || '-'} - + + - saveStudent('studentNo', next)} - > + saveStudent('studentNo', next)}> {student.studentNo || '-'} - + + - saveStudent('gender', next)} - > + saveStudent('gender', next)}> {student.gender || '-'} - + + + - saveStudent('ethnicity', next)} - > + saveStudent('ethnicity', next)}> {student.ethnicity || '-'} - + + + + {canChooseOrganization ? ( + + + + + + + + + + + + + + = ({ - data, - studentId, - onRefresh, -}) => { - const { hasPermission } = usePermission(); - const [modalOpen, setModalOpen] = useState(false); - const [form] = Form.useForm(); - const [saving, setSaving] = useState(false); - - const handleAdd = async () => { - try { - const values = await form.validateFields(); - setSaving(true); - await api.post(`/archive/${studentId}/enrollments`, { - ...values, - startDate: values.startDate?.format('YYYY-MM-DD'), - endDate: values.endDate?.format('YYYY-MM-DD'), - }); - message.success('报读记录已添加'); - setModalOpen(false); - form.resetFields(); - onRefresh(); - } catch (e: unknown) { - const err = e as { message?: string }; - if (err?.message) message.error(err.message); - } finally { - setSaving(false); - } - }; - - const saveCell = async (record: EnrollmentRecord, field: string, value: unknown) => { - await api.put(`/archive/enrollments/${record.id}`, { [field]: value }); - message.success('报读记录已保存'); - onRefresh(); - }; - - const columns: ColumnsType = [ - { - title: '课程类别', - dataIndex: 'courseCategory', - render: (v: string, r) => ( - saveCell(r, 'courseCategory', next)} - > - {getCourseCategoryLabel(v)} - - ), - }, - { - title: '班型', - dataIndex: 'classType', - render: (v: string, r) => ( - saveCell(r, 'classType', next)} - > - {getClassTypeLabel(v)} - - ), - }, - { - title: '班级名称', - dataIndex: 'className', - render: (v: string, r) => ( - saveCell(r, 'className', next)} - > - {v || '-'} - - ), - }, - { - title: '班主任', - dataIndex: 'headTeacher', - render: (v: string, r) => ( - saveCell(r, 'headTeacher', next)} - > - {v || '-'} - - ), - }, - { - title: '任课教师', - dataIndex: 'subjectTeacher', - render: (v: string, r) => ( - saveCell(r, 'subjectTeacher', next)} - > - {v || '-'} - - ), - }, - { - title: '开始日期', - dataIndex: 'startDate', - render: (v: string, r) => ( - saveCell(r, 'startDate', next)} - > - {v || '-'} - - ), - }, - { - title: '结束日期', - dataIndex: 'endDate', - render: (v: string, r) => ( - saveCell(r, 'endDate', next)} - > - {v || '-'} - - ), - }, - { - title: '状态', - dataIndex: 'status', - render: (v: string, r) => { - const status = getEnrollmentStatus(v); - return ( - ({ - value, - label: item.text, - }))} - permission="student:edit" - onSave={(next) => saveCell(r, 'status', next)} - > - {status.text} - - ); - }, - }, - ]; - - return ( -
- } - type="primary" - onClick={() => { - form.resetFields(); - setModalOpen(true); - }} - style={{ marginBottom: 16 }} - > - 添加报读记录 - - - columns={columns} - dataSource={data} - rowKey="id" - pagination={{ - defaultPageSize: 15, - showSizeChanger: true, - pageSizeOptions: [15, 30, 50], - }} - /> - setModalOpen(false)} - confirmLoading={saving} - > -
- - - - - - - - - - - - - - - - - - -
-
-
- ); -}; - -const ExamScoresTab: React.FC< - TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] } -> = ({ data, studentId, enrollments, onRefresh }) => { - const { hasPermission } = usePermission(); - const [modalOpen, setModalOpen] = useState(false); - const [form] = Form.useForm(); - const [saving, setSaving] = useState(false); - - const handleAdd = async () => { - try { - const values = await form.validateFields(); - setSaving(true); - await api.post(`/archive/${studentId}/exam-scores`, { - ...values, - examDate: values.examDate?.format('YYYY-MM-DD'), - }); - message.success('考试成绩已添加'); - setModalOpen(false); - form.resetFields(); - onRefresh(); - } catch (e: unknown) { - const err = e as { message?: string }; - if (err?.message) message.error(err.message); - } finally { - setSaving(false); - } - }; - - const saveCell = async (record: ExamScoreRecord, field: string, value: unknown) => { - await api.put(`/archive/exam-scores/${record.id}`, { [field]: value }); - message.success('考试成绩已保存'); - onRefresh(); - }; - - const columns: ColumnsType = [ - { - title: '考试类型', - dataIndex: 'examType', - render: (v: string, r) => ( - saveCell(r, 'examType', next)} - > - {EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v} - - ), - }, - { - title: '考试名称', - dataIndex: 'examName', - render: (v: string, r) => ( - saveCell(r, 'examName', next)} - > - {v || '-'} - - ), - }, - { - title: '科目', - dataIndex: 'subject', - render: (v: string, r) => ( - saveCell(r, 'subject', next)} - > - {v} - - ), - }, - { - title: '成绩', - dataIndex: 'score', - render: (v: number | null, r) => ( - saveCell(r, 'score', next)} - > - {v ?? '-'} - - ), - }, - { - title: '班级均分', - dataIndex: 'classAvg', - render: (v: number | undefined, r) => ( - saveCell(r, 'classAvg', next)} - > - {v !== undefined ? v : '-'} - - ), - }, - { - title: '排名', - dataIndex: 'rank', - render: (v: number | undefined, r) => ( - saveCell(r, 'rank', next)} - > - {v !== undefined ? v : '-'} - - ), - }, - { - title: '考试日期', - dataIndex: 'examDate', - render: (v: string, r) => ( - saveCell(r, 'examDate', next)} - > - {v || '-'} - - ), - }, - { - title: '关联报读', - dataIndex: 'enrollmentId', - render: (v: number | undefined, r) => ( - ({ - value: item.id, - label: formatEnrollmentDisplayName(item), - }))} - permission="student:edit" - disabled={!!r.examId} - onSave={(next) => saveCell(r, 'enrollmentId', next)} - > - {(() => { - if (r.examId) return r.exam?.class?.name || '-'; - if (v === undefined) return '-'; - const enr = enrollments.find((e) => e.id === v); - return enr ? formatEnrollmentDisplayName(enr) : String(v); - })()} - - ), - }, - ]; - - return ( -
- } - type="primary" - onClick={() => { - form.resetFields(); - setModalOpen(true); - }} - style={{ marginBottom: 16 }} - > - 添加考试成绩 - - - columns={columns} - dataSource={data} - rowKey="id" - pagination={{ - defaultPageSize: 15, - showSizeChanger: true, - pageSizeOptions: [15, 30, 50], - }} - /> - setModalOpen(false)} - confirmLoading={saving} - > -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- ); -}; - -const AttachmentsTab: React.FC = ({ - data, - studentId, - onRefresh, -}) => { - const { hasPermission } = usePermission(); - const [uploading, setUploading] = useState(false); - - const handleDelete = async (attachmentId: number) => { - try { - await api.delete(`/archive/attachments/${attachmentId}`); - message.success('已归档'); - onRefresh(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '归档失败'); - } - }; - - const columns: ColumnsType = [ - { - 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) => ( - - - {hasPermission('student:edit') ? ( - handleDelete(record.id)}> - - - ) : null} - - ), - }, - ]; - - return ( -
- {hasPermission('student:edit') ? ( - { - 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); - } - }} - > - - - ) : null} - - columns={columns} - dataSource={data} - rowKey="id" - pagination={{ - defaultPageSize: 15, - showSizeChanger: true, - pageSizeOptions: [15, 30, 50], - }} - style={{ marginTop: 16 }} - /> -
- ); -}; - -// ---- Main Component ---- - const StudentProfileContent: React.FC = ({ studentId, inDrawer, @@ -1407,39 +473,44 @@ const StudentProfileContent: React.FC = ({ 'student:edit', ); const canChooseOrganization = hasAnyPermission('student:create', 'student:edit'); - const [aggregateData, setAggregateData] = useState(null); - const [organizations, setOrganizations] = useState>([]); - const [loading, setLoading] = useState(false); - const fetchData = useCallback(async () => { - setLoading(true); - try { - const res = await api.get(`/archive/${studentId}`); - setAggregateData(res); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败'); - } finally { - setLoading(false); - } - }, [studentId]); - - useEffect(() => { - void fetchData(); - }, [fetchData]); - - useEffect(() => { - if (!canLoadOrganizations) { - setOrganizations([]); - return; - } - api - .get('/organizations/options') - .then((res: unknown) => { - setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); - }) - .catch(() => {}); - }, [canLoadOrganizations]); + const { + data: aggregateData, + isLoading, + isFetching, + refetch, + } = useQuery({ + queryKey: ['archive', studentId], + queryFn: async () => { + try { + return validateResponse( + studentProfileAggregateSchema, + await api.get(`/archive/${studentId}`), + ); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败')); + return null; + } + }, + }); + const { data: organizations = [] } = useQuery< + Array<{ id: number; name: string; isHost?: boolean }> + >({ + queryKey: ['organizations', 'options'], + enabled: canLoadOrganizations, + queryFn: async () => { + try { + return validateResponse>( + organizationOptionsSchema, + await api.get('/organizations/options'), + ); + } catch { + return []; + } + }, + }); + const loading = isLoading || isFetching; + const fetchData = useCallback(() => refetch(), [refetch]); const handlePreviewReport = useCallback(async () => { try { @@ -1449,16 +520,13 @@ const StudentProfileContent: React.FC = ({ w.document.write(html); w.document.close(); } - } catch { + } catch (e) { + console.error('加载报告失败', e); message.error('加载报告失败'); } }, [studentId]); - const handleViewSensitive = useViewSensitive( - studentId, - '学生档案', - hasPermission('log:create'), - ); + const handleViewSensitive = useViewSensitive(studentId, '学生档案', hasPermission('log:create')); const tabItems = useMemo(() => { if (!aggregateData) return []; @@ -1521,6 +589,7 @@ const StudentProfileContent: React.FC = ({ return (
+ {inDrawer && ( diff --git a/apps/admin/src/components/StudentProfileContent/shared.ts b/apps/admin/src/components/StudentProfileContent/shared.ts new file mode 100644 index 0000000..378c059 --- /dev/null +++ b/apps/admin/src/components/StudentProfileContent/shared.ts @@ -0,0 +1,216 @@ +export interface StudentInfo { + id: number; + name: string; + phone: string; + idNumber: string; + studentNo: string; + gender?: string; + ethnicity?: string; + emergencyContact?: string; + emergencyPhone?: string; + organizationId?: number; + organization?: { id?: number; name?: string } | null; + supervisor?: string; + status: string; +} + +export interface ProfileData { + targetCollege?: string; + targetMajor?: string; + collegeSchool?: string; + collegeMajor?: string; + subjectDirection?: string; + grade?: string; + profileDate?: string; + notes?: string; +} + +export interface EnrollmentRecord { + id: number; + courseCategory: string; + classType: string; + className?: string; + headTeacher?: string; + subjectTeacher?: string; + startDate?: string; + endDate?: string; + status: string; +} + +export interface ExamScoreRecord { + id: number; + status?: string; + examId?: number; + exam?: { class?: { name?: string } }; + examType: string; + examName?: string; + subject: string; + score: number | null; + classAvg?: number; + rank?: number; + examDate?: string; + enrollmentId?: number; +} + +export interface LearningRecord { + id: number; + status?: string; + recordDate: string; + recordType: string; + content: string; + followUpMethod?: string; + nextStep?: string; +} + +export interface ResultData { + cultureFinalScore?: number; + professionalFinalScore?: number; + admissionStatus?: string; + admittedCollege?: string; + admittedMajor?: string; +} + +export interface AttachmentRecord { + id: number; + status?: string; + category: string; + fileName: string; + fileSize: number; +} + +export interface AttendanceRecordItem { + id: number; + attendanceDate: string; + session: string; + status: string; + source?: string; + remark?: string | null; + punchTime?: string | null; + punchDeviceName?: string | null; + punchDeviceId?: string | null; + schedule?: { subject?: string } | null; + class?: { name?: string } | null; +} + +export interface StudentProfileAggregate { + student: StudentInfo; + profile: ProfileData | null; + enrollments: EnrollmentRecord[]; + examScores: ExamScoreRecord[]; + learningRecords: LearningRecord[]; + result: ResultData | null; + attachments: AttachmentRecord[]; + attendances: AttendanceRecordItem[]; +} + +export interface StudentProfileContentProps { + studentId: number; + inDrawer?: boolean; + onClose?: () => void; +} + +export const ADMISSION_STATUS_MAP: Record = { + admitted: { text: '已录取', color: 'green' }, + pending: { text: '待录取', color: 'orange' }, + rejected: { text: '未录取', color: 'red' }, + withdrawn: { text: '放弃', color: '#999' }, +}; + +export const EXAM_TYPE_OPTIONS = [ + { value: 'monthly', label: '月考' }, + { value: 'midterm', label: '期中' }, + { value: 'final', label: '期末' }, + { value: 'mock', label: '模拟考' }, + { value: 'entrance', label: '入学测试' }, + { value: 'other', label: '其他' }, +]; + +export const RECORD_TYPE_OPTIONS = [ + { value: 'study_feedback', label: '学习反馈' }, + { value: 'parent_communication', label: '家长沟通' }, + { value: 'behavior_note', label: '行为记录' }, + { value: 'meeting', label: '会议记录' }, + { value: 'other', label: '其他' }, +]; + +export const ENROLLMENT_STATUS_MAP: Record = { + active: { text: '报读中', color: 'green' }, + completed: { text: '已结课', color: 'blue' }, + withdrawn: { text: '已退训', color: 'red' }, + archived: { text: '已归档', color: '#999' }, +}; + +export const COURSE_CATEGORY_OPTIONS = [ + { value: 'culture', label: '文化课' }, + { value: 'professional', label: '专业课' }, + { value: 'comprehensive', label: '综合' }, +]; + +export const CLASS_TYPE_OPTIONS = [ + { value: 'one_on_one', label: '一对一' }, + { value: 'small_group', label: '小班' }, + { value: 'large_class', label: '大班' }, + { value: 'online', label: '线上' }, + { value: 'offline', label: '线下' }, +]; + +export const getOptionLabel = ( + options: Array<{ value: string; label: string }>, + value?: string | null, +): string => { + if (!value) return '-'; + return options.find((option) => option.value === value)?.label || value; +}; + +export const getCourseCategoryLabel = (value?: string | null): string => + getOptionLabel(COURSE_CATEGORY_OPTIONS, value); + +export const getClassTypeLabel = (value?: string | null): string => + getOptionLabel(CLASS_TYPE_OPTIONS, value); + +export const getEnrollmentStatus = (value?: string | null): { text: string; color: string } => { + if (!value) return { text: '-', color: 'default' }; + return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' }; +}; + +export const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string => + enrollment.className || + (enrollment.courseCategory + ? getCourseCategoryLabel(enrollment.courseCategory) + : String(enrollment.id)); + +export const ATTACHMENT_CATEGORY_OPTIONS = [ + { value: 'id_card', label: '身份证' }, + { value: 'transcript', label: '成绩单' }, + { value: 'certificate', label: '证书' }, + { value: 'contract', label: '合同' }, + { value: 'photo', label: '照片' }, + { value: 'other', label: '其他' }, +]; + +export 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`; +}; + +export const ATTENDANCE_STATUS_MAP: Record = { + present: { text: '出勤', color: 'green' }, + late: { text: '迟到', color: 'orange' }, + absent: { text: '缺勤', color: 'red' }, + leave: { text: '请假', color: 'blue' }, + pending: { text: '待确认', color: 'default' }, +}; + +export const SESSION_LABELS: Record = { + morning_reading: '早自习', + morning: '上午', + afternoon: '下午', + evening_study: '晚自习', + night_check: '晚寝', +}; + +export interface TabProps { + studentId: number; + onRefresh: () => void; +} diff --git a/apps/admin/src/pages/StudentProfile/index.tsx b/apps/admin/src/pages/StudentProfile/index.tsx index 8d964d6..4bcde4b 100644 --- a/apps/admin/src/pages/StudentProfile/index.tsx +++ b/apps/admin/src/pages/StudentProfile/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router'; import { Card, Button, Space } from 'antd'; import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons'; import StudentProfileContent from '../../components/StudentProfileContent'; diff --git a/apps/admin/src/pages/archive-view.integration.test.ts b/apps/admin/src/pages/archive-view.integration.test.ts index 4d36a29..db613e4 100644 --- a/apps/admin/src/pages/archive-view.integration.test.ts +++ b/apps/admin/src/pages/archive-view.integration.test.ts @@ -35,22 +35,47 @@ describe('归档数据视图', () => { }); it('正常与归档视图的批量动作互斥,且归档视图只读', () => { - expect(archiveViewPolicy('active')).toEqual({ batchAction: 'archive', readonly: false }); - expect(archiveViewPolicy('archived')).toEqual({ batchAction: 'restore', readonly: true }); + expect(archiveViewPolicy('active')).toEqual({ + batchAction: 'archive', + readonly: false, + purgeBatch: false, + }); + expect(archiveViewPolicy('archived')).toEqual({ + batchAction: 'restore', + readonly: true, + purgeBatch: true, + }); }); it('入住三态分别只提供退宿、归档和恢复动作', () => { expect(occupancyViewPolicy('active')).toEqual({ batchAction: 'checkout', readonly: false, + purgeBatch: false, + }); + expect(occupancyViewPolicy('all')).toEqual({ + batchAction: 'archive', + readonly: false, + purgeBatch: false, }); - expect(occupancyViewPolicy('all')).toEqual({ batchAction: 'archive', readonly: false }); expect(occupancyViewPolicy('archived')).toEqual({ batchAction: 'restore', readonly: true, + purgeBatch: true, }); }); + it('批量删除只出现在归档视图,且与批量恢复互斥', () => { + expect(archiveViewPolicy('active').purgeBatch).toBe(false); + expect(archiveViewPolicy('archived').purgeBatch).toBe(true); + expect(occupancyViewPolicy('active').purgeBatch).toBe(false); + expect(occupancyViewPolicy('all').purgeBatch).toBe(false); + expect(occupancyViewPolicy('archived').purgeBatch).toBe(true); + // 归档视图中批量动作固定为恢复,不会同时出现归档;批量删除只在归档视图开启 + expect(archiveViewPolicy('archived').batchAction).toBe('restore'); + expect(occupancyViewPolicy('archived').batchAction).toBe('restore'); + }); + it('只有实际切换视图时才要求清空选择', () => { expect(shouldClearSelectionOnViewChange('active', 'archived')).toBe(true); expect(shouldClearSelectionOnViewChange('archived', 'archived')).toBe(false); diff --git a/apps/admin/src/pages/archive-view.ts b/apps/admin/src/pages/archive-view.ts index 26c0ba0..cc86185 100644 --- a/apps/admin/src/pages/archive-view.ts +++ b/apps/admin/src/pages/archive-view.ts @@ -5,6 +5,8 @@ export type BatchAction = 'archive' | 'restore' | 'checkout'; export interface ViewPolicy { batchAction: BatchAction; readonly: boolean; + /** 批量永久删除只在已归档视图中出现,与批量恢复互斥 */ + purgeBatch: boolean; } export const selectArchiveRecords = ( @@ -20,11 +22,13 @@ export const expenseStatusForView = (view: ArchiveView) => view; export const archiveViewPolicy = (view: ArchiveView): ViewPolicy => ({ batchAction: view === 'archived' ? 'restore' : 'archive', readonly: view === 'archived', + purgeBatch: view === 'archived', }); export const occupancyViewPolicy = (view: OccupancyView): ViewPolicy => ({ batchAction: view === 'active' ? 'checkout' : view === 'all' ? 'archive' : 'restore', readonly: view === 'archived', + purgeBatch: view === 'archived', }); export const shouldClearSelectionOnViewChange = (current: T, next: T) => diff --git a/apps/server/src/archive/archive-report.attendance.ts b/apps/server/src/archive/archive-report.attendance.ts new file mode 100644 index 0000000..18292f2 --- /dev/null +++ b/apps/server/src/archive/archive-report.attendance.ts @@ -0,0 +1,165 @@ +import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers'; + +export function buildAttendance(records: AttendanceRecord[], now: string): string { + const present = records.filter((r) => r.status === 'present').length; + const absent = records.filter((r) => r.status === 'absent').length; + const late = records.filter((r) => r.status === 'late').length; + const leave = records.filter((r) => r.status === 'leave').length; + const total = records.length; + const rate = total > 0 ? ((present / total) * 100).toFixed(1) : '0'; + + const metricHtml = ` +
+
+
${esc(now)} · 系统生成
+
出勤记录
+
+
+
+
+
总考勤次数
+ ${total} +

累计记录

+
+
+
出勤率
+ ${esc(rate)}% +

出勤: ${present} 次

+
+
+
缺勤 / 迟到
+ ${absent} / ${late} +

缺勤 ${absent} · 迟到 ${late}

+
+
+
请假
+ ${leave} +

累计请假次数

+
+
`; + + const chart = renderAttendanceBar(records); + const matrix = renderAttendanceMatrix(records); + + let extraHtml = ''; + if (records.length === 0) { + extraHtml = ''; + } + + return pageFrame(` + ${pageHeader('出勤记录')} + ${metricHtml} + ${extraHtml} + ${chart} + ${matrix} + ${pageFooter()} + `); +} + +export function renderAttendanceBar(records: AttendanceRecord[]): string { + if (records.length === 0) return ''; + + const statuses = ['present', 'absent', 'late', 'leave'] as const; + const counts = statuses.map((s) => records.filter((r) => r.status === s).length); + const labels = ['出勤', '缺勤', '迟到', '请假']; + const colors = ['#18a77d', '#dc2626', '#f59e0b', '#f15b75']; + const maxCount = Math.max(...counts, 1); + + const w = 600; + const h = 150; + const pad = { top: 20, right: 20, bottom: 30, left: 40 }; + const plotW = w - pad.left - pad.right; + const plotH = h - pad.top - pad.bottom; + const barGap = 30; + const barW = (plotW - barGap * (statuses.length - 1)) / statuses.length; + + const scaleH = (v: number): number => (v / maxCount) * plotH; + + let bars = ''; + for (let i = 0; i < statuses.length; i++) { + const x = pad.left + i * (barW + barGap); + const bh = scaleH(counts[i]); + const y = pad.top + plotH - bh; + bars += ``; + bars += `${counts[i]}`; + bars += `${labels[i]}`; + } + + // Y-axis grid + const ySteps = 4; + let yGrid = ''; + for (let i = 0; i <= ySteps; i++) { + const val = Math.round((maxCount * i) / ySteps); + const y = pad.top + plotH - (plotH * i) / ySteps; + yGrid += `${val}`; + if (i < ySteps) { + yGrid += ``; + } + } + + return `
+

出勤统计

+ + + ${yGrid} + ${bars} + +
`; +} + +export function renderAttendanceMatrix(records: AttendanceRecord[]): string { + if (records.length === 0) return ''; + + // Group by date + const dateMap = new Map(); + for (const r of records) { + const existing = dateMap.get(r.attendanceDate) ?? []; + existing.push(r); + dateMap.set(r.attendanceDate, existing); + } + + const dates = [...dateMap.keys()].sort(); + const sessions = ['上午', '下午', '晚自习']; + + let rows = ''; + for (const date of dates.slice(-30)) { + const dayRecords = dateMap.get(date) ?? []; + const cellMap = new Map(); + for (const r of dayRecords) { + cellMap.set(r.session, r.status); + } + + let cells = ''; + for (const session of sessions) { + const status = cellMap.get(session) ?? ''; + cells += `${status ? statusBadge(status) : '-'}`; + } + + rows += `${esc(date)}${cells}`; + } + + return `
+

考勤明细(最近30条)

+ + + + ${sessions.map((s) => ``).join('')} + + ${rows} +
日期${esc(s)}
+
图例: 出勤   缺勤   迟到   请假
+
`; +} + +export function statusBadge(status: string): string { + const map: Record = { + present: { cls: 'present', text: '到' }, + absent: { cls: 'absent', text: '缺' }, + late: { cls: 'late', text: '迟' }, + leave: { cls: 'leave', text: '假' }, + }; + const entry = map[status]; + if (!entry) return `${esc(status)}`; + return `${entry.text}`; +} diff --git a/apps/server/src/archive/archive-report.cover.ts b/apps/server/src/archive/archive-report.cover.ts new file mode 100644 index 0000000..7ed0618 --- /dev/null +++ b/apps/server/src/archive/archive-report.cover.ts @@ -0,0 +1,89 @@ +import { Student } from '../entities/student.entity'; +import { StudentProfile } from '../entities/student-profile.entity'; +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers'; +import { buildEnrollmentSection } from './archive-report.enrollment'; + +export function buildCover( + student: Student, + profile: StudentProfile | null, + enrollments: StudentEnrollment[], + now: string, +): string { + const types = enrollments.map((e) => e.classType).filter(Boolean).join(' / ') || '-'; + + return pageFrame(` + ${pageHeader('封面')} +
学生档案报告
+
生成日期: ${esc(now)}
+
+
+
${esc(student.name)}
+
学号: ${esc(student.studentNo || '-')}
身份证号: ${esc(student.idNumber || '-')}
+
+
+
+
科类方向
+
${esc(profile?.subjectDirection || '-')}
+
+
+
目标院校
+
${esc(profile?.targetCollege || '-')}
+
+
+
目标专业
+
${esc(profile?.targetMajor || '-')}
+
+
+
报读班型
+
${esc(types)}
+
+
+
+
+
01基础信息与报读记录第 2 页
+
02考试成绩总览第 3 页
+
03出勤记录第 4 页
+
04文化课考试成绩第 5 页
+
05学情记录与录取归档第 6 页
+
+
恭学教育
+ ${pageFooter()} + `); +} + +export function buildBasicInfo( + student: Student, + profile: StudentProfile | null, + enrollments: StudentEnrollment[], + now: string, +): string { + const infoCards = ` +
+
+
${esc(now)} · 系统生成
+
基础信息
+
+
+
+

个人信息

+
+
姓名${esc(student.name)}
+
性别${esc(student.gender || '-')}
+
电话${esc(student.phone || '-')}
+
民族${esc(student.ethnicity || '-')}
+
紧急联系人${esc(student.emergencyContact || '-')}
+
紧急电话${esc(student.emergencyPhone || '-')}
+
年级${esc(profile?.grade || '-')}
+
+
`; + + const enrollmentSection = buildEnrollmentSection(enrollments); + + return pageFrame(` + ${pageHeader('基础信息')} + ${infoCards} + ${enrollmentSection} + ${pageFooter()} + `); +} diff --git a/apps/server/src/archive/archive-report.enrollment.ts b/apps/server/src/archive/archive-report.enrollment.ts new file mode 100644 index 0000000..666e47a --- /dev/null +++ b/apps/server/src/archive/archive-report.enrollment.ts @@ -0,0 +1,80 @@ +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { esc } from './archive-report.helpers'; + +export function buildEnrollmentSection(enrollments: StudentEnrollment[]): string { + if (enrollments.length === 0) { + return ``; + } + + const renderEnrollmentTable = (enrs: StudentEnrollment[]): string => { + if (enrs.length === 0) { + return ``; + } + + let rows = ''; + for (const e of enrs) { + rows += ` + ${esc(e.courseCategory || '-')} + ${esc(e.classType || '-')} + ${esc(e.className || '-')} + ${esc(e.headTeacher || '-')} + ${esc(e.subjectTeacher || '-')} + ${esc(e.startDate || '-')} + ${esc(e.endDate || '-')} + `; + } + + return ` + + + + + ${rows} +
课程类别班型班级班主任任课老师开班日期结课日期
`; + }; + + // Multi-enrollment: split culture vs professional + const cultureEnrollments = enrollments.filter( + (e) => e.courseCategory && e.courseCategory.includes('文化'), + ); + const profEnrollments = enrollments.filter( + (e) => e.courseCategory && e.courseCategory.includes('专业'), + ); + const otherEnrollments = enrollments.filter( + (e) => + !e.courseCategory || + (!e.courseCategory.includes('文化') && !e.courseCategory.includes('专业')), + ); + + if (cultureEnrollments.length > 0 || profEnrollments.length > 0) { + let html = + '

报读记录

'; + html += '
'; + + html += '
'; + html += '

文化课报读

'; + html += renderEnrollmentTable(cultureEnrollments); + html += '
'; + + html += '
'; + html += '

专业课报读

'; + html += renderEnrollmentTable(profEnrollments); + html += '
'; + + html += '
'; + + if (otherEnrollments.length > 0) { + html += + '

其他报读

'; + html += renderEnrollmentTable(otherEnrollments); + } + + html += '
'; + return html; + } + + return `
+

报读记录

+ ${renderEnrollmentTable(enrollments)} +
`; +} diff --git a/apps/server/src/archive/archive-report.exam.ts b/apps/server/src/archive/archive-report.exam.ts new file mode 100644 index 0000000..e3df623 --- /dev/null +++ b/apps/server/src/archive/archive-report.exam.ts @@ -0,0 +1,249 @@ +import { ExamScore } from '../entities/exam-score.entity'; +import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers'; + +export function buildExamOverview(exams: ExamScore[], now: string): string { + const cultureExams = exams.filter( + (e) => e.examType && e.examType.includes('文化'), + ); + const entranceExam = exams.find((e) => e.examType === '入学测试'); + const highestExam = [...exams].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0]; + + const entranceScore = entranceExam?.score?.toFixed(1) ?? '-'; + const highestScore = highestExam?.score?.toFixed(1) ?? '-'; + const highestName = highestExam?.examName ?? '-'; + + // Improvement: last exam score minus first exam score + const sortedScores = cultureExams + .map((exam) => exam.score) + .filter((score): score is number => score !== null && score !== undefined); + let improvement = '—'; + if (sortedScores.length >= 2) { + const first = sortedScores[0]; + const last = sortedScores[sortedScores.length - 1]; + improvement = (last - first).toFixed(1); + } + + const avgScore = + cultureExams.length > 0 + ? ( + cultureExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / + cultureExams.length + ).toFixed(1) + : '-'; + + const metricHtml = ` +
+
+
${esc(now)} · 系统生成
+
考试成绩总览
+
+
+
+
+
入学测试成绩
+ ${esc(entranceScore)} +

入学摸底测试

+
+
+
最高分
+ ${esc(highestScore)} +

${esc(highestName)}

+
+
+
进步幅度
+ ${esc(improvement)} +

首考 → 末考变化

+
+
+
平均分
+ ${esc(avgScore)} +

文化课考试均分

+
+
`; + + const scoreTable = renderScoreTable(cultureExams); + const trendChart = renderScoreTrendChart(cultureExams); + + let extraHtml = ''; + if (cultureExams.length === 0) { + extraHtml = ''; + } + + return pageFrame(` + ${pageHeader('考试成绩总览')} + ${metricHtml} + ${extraHtml} + ${scoreTable} + ${trendChart} + ${pageFooter()} + `); +} + +export function renderScoreTable(exams: ExamScore[]): string { + if (exams.length === 0) return ''; + + return `
+

文化课考试成绩

+ + + + + + + ${exams + .map( + (e) => + ` + + + + + + + + `, + ) + .join('')} + +
类型名称科目分数班均排名日期
${esc(e.examType || '-')}${esc(e.examName || '-')}${esc(e.subject || '-')}${e.score != null ? e.score : '-'}${e.classAvg != null ? e.classAvg : '-'}${e.rank != null ? e.rank : '-'}${esc(e.examDate || '-')}
+
`; +} + +export function renderScoreTrendChart(exams: ExamScore[]): string { + const cultureExams = exams.filter((e) => e.score != null); + if (cultureExams.length === 0) return ''; + + const scores = cultureExams.map((e) => Number(e.score)); + const labels = cultureExams.map((e) => { + const d = e.examDate || '-'; + return d.length > 7 ? d.slice(5) : d; + }); + + const w = 600; + const h = 180; + const pad = { top: 20, right: 20, bottom: 30, left: 40 }; + const plotW = w - pad.left - pad.right; + const plotH = h - pad.top - pad.bottom; + + const minScore = Math.min(...scores); + const maxScore = Math.max(...scores); + const scoreRange = maxScore - minScore || 1; + + const scaleY = (s: number): number => + pad.top + plotH - ((s - minScore) / scoreRange) * plotH; + + let points = ''; + let lines = ''; + for (let i = 0; i < scores.length; i++) { + const x = pad.left + (i / Math.max(scores.length - 1, 1)) * plotW; + const y = scaleY(scores[i]); + points += ``; + if (i > 0) { + const px = pad.left + ((i - 1) / Math.max(scores.length - 1, 1)) * plotW; + const py = scaleY(scores[i - 1]); + lines += ``; + } + } + + // Y-axis labels + const ySteps = 4; + let yLabels = ''; + for (let i = 0; i <= ySteps; i++) { + const val = minScore + (scoreRange * i) / ySteps; + const y = scaleY(val); + yLabels += `${val.toFixed(0)}`; + if (i > 0) { + yLabels += ``; + } + } + + // X-axis labels + let xLabels = ''; + const labelStep = Math.max(1, Math.floor(labels.length / 6)); + for (let i = 0; i < labels.length; i += labelStep) { + const x = pad.left + (i / Math.max(scores.length - 1, 1)) * plotW; + xLabels += `${esc(labels[i])}`; + } + + return `
+

成绩趋势

+ + + ${yLabels} + ${xLabels} + ${lines} + ${points} + +
趋势图展示文化课考试成绩的变化轨迹,点数代每次考试的分数
+
`; +} + +export function buildExamDetail(exams: ExamScore[], now: string): string { + const cultureExams = exams.filter( + (e) => e.examType && e.examType.includes('文化'), + ); + + if (cultureExams.length === 0) { + return pageFrame(` + ${pageHeader('文化课考试成绩')} +
+
+
${esc(now)} · 系统生成
+
文化课考试成绩
+
+
+ + ${pageFooter()} + `); + } + + // Group by subject + const subjectMap = new Map(); + for (const e of cultureExams) { + const subject = e.subject || '其他'; + const existing = subjectMap.get(subject) ?? []; + existing.push(e); + subjectMap.set(subject, existing); + } + + let subjectCards = ''; + for (const [subject, subExams] of subjectMap) { + const best = Math.max(...subExams.map((e) => e.score ?? 0)); + const avg = ( + subExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / subExams.length + ).toFixed(1); + + let rows = ''; + for (const e of subExams) { + rows += ` + ${esc(e.examName || '-')} + ${e.score != null ? e.score : '-'} + ${e.classAvg != null ? e.classAvg : '-'} + ${e.rank != null ? e.rank : '-'} + ${esc(e.examDate || '-')} + `; + } + + subjectCards += `
+

${esc(subject)} · 最佳 ${best} · 均分 ${esc(avg)}

+ + + + + ${rows} +
考试名称分数班均排名日期
+
`; + } + + return pageFrame(` + ${pageHeader('文化课考试成绩')} +
+
+
${esc(now)} · 系统生成
+
文化课考试成绩
+
+
+ ${subjectCards} + ${pageFooter()} + `); +} diff --git a/apps/server/src/archive/archive-report.helpers.ts b/apps/server/src/archive/archive-report.helpers.ts new file mode 100644 index 0000000..f2ea33f --- /dev/null +++ b/apps/server/src/archive/archive-report.helpers.ts @@ -0,0 +1,20 @@ +export function esc(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function pageFrame(inner: string): string { + return `
${inner}
`; +} + +export function pageHeader(title: string): string { + return `
恭学教育 · 学生档案${esc(title)}
`; +} + +export function pageFooter(): string { + return ``; +} diff --git a/apps/server/src/archive/archive-report.learning.ts b/apps/server/src/archive/archive-report.learning.ts new file mode 100644 index 0000000..2039e9d --- /dev/null +++ b/apps/server/src/archive/archive-report.learning.ts @@ -0,0 +1,85 @@ +import { LearningRecord } from '../entities/learning-record.entity'; +import { ResultArchive } from '../entities/result-archive.entity'; +import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers'; + +export function buildLearningAndResult( + learnings: LearningRecord[], + result: ResultArchive | null, + now: string, +): string { + let learningHtml = ''; + if (learnings.length === 0) { + learningHtml = ` +
+
+
${esc(now)} · 系统生成
+
学情记录
+
+
+ `; + } else { + const latest = learnings.slice(0, 15); + let rows = ''; + for (const r of latest) { + rows += ` + ${esc(r.recordDate || '-')} + ${esc(r.recordType || '-')} + ${esc((r.content || '-').slice(0, 200))} + ${esc(r.followUpMethod || '-')} + `; + } + + learningHtml = ` +
+
+
${esc(now)} · 系统生成
+
学情记录
+
+
+
+

最近学情记录

+ + + + + + ${rows} +
日期类型内容跟进方式
+
`; + } + + let resultHtml = ''; + if (result) { + resultHtml = ` +
+
+
录取归档
+
+
+
+
+
文化课成绩${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}
+
专业课成绩${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}
+
录取状态${esc(result.admissionStatus || '-')}
+
录取院校${esc(result.admittedCollege || '-')}
+
录取专业${esc(result.admittedMajor || '-')}
+
+
+
录取归档信息为最终结果,如有疑问请联系教务处
`; + } else { + resultHtml = ` +
+
+
录取归档
+
+
+ `; + } + + return pageFrame(` + ${pageHeader('学情记录与录取归档')} + ${learningHtml} + ${resultHtml} + ${pageFooter()} + `); +} diff --git a/apps/server/src/archive/archive-report.service.ts b/apps/server/src/archive/archive-report.service.ts index 312ca0a..6994219 100644 --- a/apps/server/src/archive/archive-report.service.ts +++ b/apps/server/src/archive/archive-report.service.ts @@ -8,6 +8,12 @@ import { LearningRecord } from '../entities/learning-record.entity'; import { ResultArchive } from '../entities/result-archive.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { Student } from '../entities/student.entity'; +import { ARCHIVE_REPORT_CSS } from './archive-report.styles'; +import { esc } from './archive-report.helpers'; +import { buildCover, buildBasicInfo } from './archive-report.cover'; +import { buildExamOverview, buildExamDetail } from './archive-report.exam'; +import { buildAttendance } from './archive-report.attendance'; +import { buildLearningAndResult } from './archive-report.learning'; interface ReportData { student: Student; @@ -45,7 +51,7 @@ export class ArchiveReportService { if (!student) throw new Error('学生不存在'); - const data: ReportData = { + return this.buildHtml({ student, profile, enrollments, @@ -53,166 +59,7 @@ export class ArchiveReportService { learnings, result, attendances, - }; - - return this.buildHtml(data); - } - - private css(): string { - return ` - @page { size: A4; margin: 0; } - * { box-sizing: border-box; } - body { - margin: 0; background: #eef3f8; color: #101828; - font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif; - -webkit-print-color-adjust: exact; print-color-adjust: exact; - } - .page { - position: relative; width: 210mm; height: 297mm; - margin: 0 auto 18px; padding: 14mm 15mm 10mm; - overflow: hidden; background: #fff; page-break-after: always; - } - .frame { - position: absolute; inset: 14mm; border: 1px solid #cfe0f2; pointer-events: none; - } - .header { - position: relative; z-index: 1; display: flex; align-items: center; - height: 39px; padding-bottom: 8px; border-bottom: 1px solid #cfe0f2; - } - .logo { - width: 24px; height: 24px; border-radius: 6px; - display: inline-flex; align-items: center; justify-content: center; - margin-right: 8px; color: #fff; background: #155aa8; - font-weight: 800; font-size: 11px; - } - .brand { font-size: 10px; font-weight: 700; } - .page-kicker { margin-left: auto; font-size: 10px; color: #667085; } - .footer { - position: absolute; left: 15mm; right: 15mm; bottom: 8mm; z-index: 1; - display: flex; justify-content: space-between; - border-top: 1px solid #cfe0f2; padding-top: 5px; - font-size: 10px; color: #667085; - } - h1, h2, h3, p { margin: 0; } - .section-title { font-size: 24px; line-height: 1.24; font-weight: 800; } - .source { font-size: 12px; color: #667085; padding-bottom: 2px; } - .title-row { - display: flex; align-items: flex-end; justify-content: space-between; - margin: 26px 0 17px; - } - .cover-title { margin-top: 60px; font-size: 34px; line-height: 1.22; font-weight: 800; } - .cover-subtitle { margin-top: 22px; font-size: 16px; color: #667085; } - .cover-main { - display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 63px; - } - .cover-name-card { - min-height: 174px; border: 1px solid #cfe0f2; - border-left: 5px solid #155aa8; padding: 22px 24px; - } - .cover-name { - font-size: 44px; line-height: 1.14; font-weight: 800; color: #155aa8; - } - .cover-desc { margin-top: 22px; font-size: 16px; color: #667085; } - .cover-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } - .cover-cell { - min-height: 61px; border: 1px solid #cfe0f2; padding: 11px 14px; - } - .label { font-size: 11px; color: #667085; margin-bottom: 8px; } - .value { font-size: 14px; line-height: 1.5; font-weight: 700; } - .toc { margin-top: 58px; } - .toc-row { - display: grid; grid-template-columns: 48px 1fr 72px; align-items: center; - height: 47px; border-bottom: 1px solid #cfe0f2; - } - .toc-index { color: #155aa8; font-size: 15px; font-weight: 800; } - .toc-name { font-size: 14px; font-weight: 800; } - .toc-page { text-align: right; color: #667085; font-size: 12px; } - .watermark { - position: absolute; right: 36px; bottom: 82px; color: #eaf1fb; - font-size: 56px; font-weight: 900; writing-mode: vertical-rl; - } - .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } - .grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } - .card { border: 1px solid #cfe0f2; padding: 14px; background: #fff; } - .card h3 { font-size: 16px; margin-bottom: 14px; } - .data-table { - width: 100%; border-collapse: collapse; table-layout: fixed; - } - .data-table th, .data-table td { - border: 1px solid #d6e3f2; padding: 8px 9px; font-size: 12px; - line-height: 1.55; vertical-align: top; text-align: left; - } - .data-table th { - background: #eaf3fd; color: #173f6f; font-weight: 800; white-space: nowrap; - } - .data-table td { overflow-wrap: anywhere; word-break: break-word; } - .data-table .nowrap { white-space: nowrap; } - .metric { - min-height: 88px; border: 1px solid #cfe0f2; padding: 13px 14px; - } - .metric .label { margin-bottom: 7px; } - .metric strong { - display: block; color: #155aa8; font-size: 27px; line-height: 1.16; - margin-bottom: 10px; - } - .metric p { - color: #667085; font-size: 12px; line-height: 1.45; - } - .summary-row { - display: grid; grid-template-columns: 92px 1fr; gap: 12px; - padding: 14px 0; border-bottom: 1px solid #d6e3f2; - font-size: 13px; line-height: 1.6; - } - .summary-row:last-child { border-bottom: 0; } - .summary-row strong { color: #155aa8; } - .note { - margin-top: 14px; padding: 12px 16px; border-left: 4px solid #155aa8; - background: #eef5ff; color: #173f6f; font-size: 12px; line-height: 1.7; - } - .banner-note { - margin-top: 12px; padding: 11px 16px; background: #eef5ff; - color: #173f6f; font-size: 12px; line-height: 1.7; - } - .line-chart { width: 100%; height: 180px; display: block; } - .bar-chart { width: 100%; height: 150px; display: block; } - .status { - display: inline-flex; align-items: center; justify-content: center; - width: 18px; height: 18px; border-radius: 5px; margin-right: 6px; - color: #fff; font-size: 11px; font-weight: 800; - } - .present { background: #18a77d; } - .leave { background: #f15b75; } - .late { background: #f59e0b; } - .absent { background: #dc2626; } - .progress-row { - display: grid; grid-template-columns: 72px 1fr 42px; align-items: center; - gap: 8px; margin: 10px 0; font-size: 12px; - } - .progress-track { - height: 11px; border-radius: 999px; background: #dfeaf6; overflow: hidden; - } - .progress-track i { - display: block; height: 100%; border-radius: 999px; - background: linear-gradient(90deg, #155aa8, #2e7df0); - } - .muted { color: #667085; } - @media print { - body { background: #fff; } - .page { margin: 0; box-shadow: none; } - } - `; - } - - private pageFrame(inner: string): string { - return `
${inner}
`; - } - - private pageHeader(title: string): string { - return `
恭学教育 · 学生档案${this.esc(title)}
`; - } - - private pageFooter(): string { - return ``; + }); } private buildHtml(data: ReportData): string { @@ -226,679 +73,15 @@ export class ArchiveReportService { return ` -学生档案报告 - ${this.esc(name)} - +学生档案报告 - ${esc(name)} + -${this.buildCover(student, profile, enrollments, now)} -${this.buildBasicInfo(student, profile, enrollments, now)} -${this.buildExamOverview(exams, now)} -${this.buildAttendance(attendances, now)} -${this.buildExamDetail(exams, now)} -${this.buildLearningAndResult(learnings, result, now)} +${buildCover(student, profile, enrollments, now)} +${buildBasicInfo(student, profile, enrollments, now)} +${buildExamOverview(exams, now)} +${buildAttendance(attendances, now)} +${buildExamDetail(exams, now)} +${buildLearningAndResult(learnings, result, now)} `; } - - private buildCover( - student: Student, - profile: StudentProfile | null, - enrollments: StudentEnrollment[], - now: string, - ): string { - const types = enrollments.map((e) => e.classType).filter(Boolean).join(' / ') || '-'; - - return this.pageFrame(` - ${this.pageHeader('封面')} -
学生档案报告
-
生成日期: ${this.esc(now)}
-
-
-
${this.esc(student.name)}
-
学号: ${this.esc(student.studentNo || '-')}
身份证号: ${this.esc(student.idNumber || '-')}
-
-
-
-
科类方向
-
${this.esc(profile?.subjectDirection || '-')}
-
-
-
目标院校
-
${this.esc(profile?.targetCollege || '-')}
-
-
-
目标专业
-
${this.esc(profile?.targetMajor || '-')}
-
-
-
报读班型
-
${this.esc(types)}
-
-
-
-
-
01基础信息与报读记录第 2 页
-
02考试成绩总览第 3 页
-
03出勤记录第 4 页
-
04文化课考试成绩第 5 页
-
05学情记录与录取归档第 6 页
-
-
恭学教育
- ${this.pageFooter()} - `); - } - - private buildBasicInfo( - student: Student, - profile: StudentProfile | null, - enrollments: StudentEnrollment[], - now: string, - ): string { - const infoCards = ` -
-
-
${this.esc(now)} · 系统生成
-
基础信息
-
-
-
-

个人信息

-
-
姓名${this.esc(student.name)}
-
性别${this.esc(student.gender || '-')}
-
电话${this.esc(student.phone || '-')}
-
民族${this.esc(student.ethnicity || '-')}
-
紧急联系人${this.esc(student.emergencyContact || '-')}
-
紧急电话${this.esc(student.emergencyPhone || '-')}
-
年级${this.esc(profile?.grade || '-')}
-
-
`; - - const enrollmentSection = this.buildEnrollmentSection(enrollments); - - return this.pageFrame(` - ${this.pageHeader('基础信息')} - ${infoCards} - ${enrollmentSection} - ${this.pageFooter()} - `); - } - - private buildEnrollmentSection(enrollments: StudentEnrollment[]): string { - if (enrollments.length === 0) { - return ``; - } - - const renderEnrollmentTable = (enrs: StudentEnrollment[]): string => { - if (enrs.length === 0) { - return ``; - } - - let rows = ''; - for (const e of enrs) { - rows += ` - ${this.esc(e.courseCategory || '-')} - ${this.esc(e.classType || '-')} - ${this.esc(e.className || '-')} - ${this.esc(e.headTeacher || '-')} - ${this.esc(e.subjectTeacher || '-')} - ${this.esc(e.startDate || '-')} - ${this.esc(e.endDate || '-')} - `; - } - - return ` - - - - - ${rows} -
课程类别班型班级班主任任课老师开班日期结课日期
`; - }; - - // Multi-enrollment: split culture vs professional - const cultureEnrollments = enrollments.filter( - (e) => e.courseCategory && e.courseCategory.includes('文化'), - ); - const profEnrollments = enrollments.filter( - (e) => e.courseCategory && e.courseCategory.includes('专业'), - ); - const otherEnrollments = enrollments.filter( - (e) => - !e.courseCategory || - (!e.courseCategory.includes('文化') && !e.courseCategory.includes('专业')), - ); - - if (cultureEnrollments.length > 0 || profEnrollments.length > 0) { - let html = - '

报读记录

'; - html += '
'; - - html += '
'; - html += '

文化课报读

'; - html += renderEnrollmentTable(cultureEnrollments); - html += '
'; - - html += '
'; - html += '

专业课报读

'; - html += renderEnrollmentTable(profEnrollments); - html += '
'; - - html += '
'; - - if (otherEnrollments.length > 0) { - html += - '

其他报读

'; - html += renderEnrollmentTable(otherEnrollments); - } - - html += '
'; - return html; - } - - return `
-

报读记录

- ${renderEnrollmentTable(enrollments)} -
`; - } - - private buildExamOverview(exams: ExamScore[], now: string): string { - const cultureExams = exams.filter( - (e) => e.examType && e.examType.includes('文化'), - ); - const entranceExam = exams.find((e) => e.examType === '入学测试'); - const highestExam = [...exams].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0]; - - const entranceScore = entranceExam?.score?.toFixed(1) ?? '-'; - const highestScore = highestExam?.score?.toFixed(1) ?? '-'; - const highestName = highestExam?.examName ?? '-'; - - // Improvement: last exam score minus first exam score - const sortedScores = cultureExams - .map((exam) => exam.score) - .filter((score): score is number => score !== null && score !== undefined); - let improvement = '—'; - if (sortedScores.length >= 2) { - const first = sortedScores[0]; - const last = sortedScores[sortedScores.length - 1]; - improvement = (last - first).toFixed(1); - } - - const avgScore = - cultureExams.length > 0 - ? ( - cultureExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / - cultureExams.length - ).toFixed(1) - : '-'; - - const metricHtml = ` -
-
-
${this.esc(now)} · 系统生成
-
考试成绩总览
-
-
-
-
-
入学测试成绩
- ${this.esc(entranceScore)} -

入学摸底测试

-
-
-
最高分
- ${this.esc(highestScore)} -

${this.esc(highestName)}

-
-
-
进步幅度
- ${this.esc(improvement)} -

首考 → 末考变化

-
-
-
平均分
- ${this.esc(avgScore)} -

文化课考试均分

-
-
`; - - const scoreTable = this.renderScoreTable(cultureExams); - - const trendChart = this.renderScoreTrendChart(cultureExams); - - let extraHtml = ''; - if (cultureExams.length === 0) { - extraHtml = ''; - } - - return this.pageFrame(` - ${this.pageHeader('考试成绩总览')} - ${metricHtml} - ${extraHtml} - ${scoreTable} - ${trendChart} - ${this.pageFooter()} - `); - } - - private renderScoreTable(exams: ExamScore[]): string { - if (exams.length === 0) return ''; - - return `
-

文化课考试成绩

- - - - - - - ${exams - .map( - (e) => - ` - - - - - - - - `, - ) - .join('')} - -
类型名称科目分数班均排名日期
${this.esc(e.examType || '-')}${this.esc(e.examName || '-')}${this.esc(e.subject || '-')}${e.score != null ? e.score : '-'}${e.classAvg != null ? e.classAvg : '-'}${e.rank != null ? e.rank : '-'}${this.esc(e.examDate || '-')}
-
`; - } - - private renderScoreTrendChart(exams: ExamScore[]): string { - const cultureExams = exams.filter((e) => e.score != null); - if (cultureExams.length === 0) return ''; - - const scores = cultureExams.map((e) => Number(e.score)); - const labels = cultureExams.map((e) => { - const d = e.examDate || '-'; - return d.length > 7 ? d.slice(5) : d; - }); - - const w = 600; - const h = 180; - const pad = { top: 20, right: 20, bottom: 30, left: 40 }; - const plotW = w - pad.left - pad.right; - const plotH = h - pad.top - pad.bottom; - - const minScore = Math.min(...scores); - const maxScore = Math.max(...scores); - const scoreRange = maxScore - minScore || 1; - - const scaleY = (s: number): number => - pad.top + plotH - ((s - minScore) / scoreRange) * plotH; - - let points = ''; - let lines = ''; - for (let i = 0; i < scores.length; i++) { - const x = pad.left + (i / Math.max(scores.length - 1, 1)) * plotW; - const y = scaleY(scores[i]); - points += ``; - if (i > 0) { - const px = pad.left + ((i - 1) / Math.max(scores.length - 1, 1)) * plotW; - const py = scaleY(scores[i - 1]); - lines += ``; - } - } - - // Y-axis labels - const ySteps = 4; - let yLabels = ''; - for (let i = 0; i <= ySteps; i++) { - const val = minScore + (scoreRange * i) / ySteps; - const y = scaleY(val); - yLabels += `${val.toFixed(0)}`; - if (i > 0) { - yLabels += ``; - } - } - - // X-axis labels - let xLabels = ''; - const labelStep = Math.max(1, Math.floor(labels.length / 6)); - for (let i = 0; i < labels.length; i += labelStep) { - const x = pad.left + (i / Math.max(scores.length - 1, 1)) * plotW; - xLabels += `${this.esc(labels[i])}`; - } - - return `
-

成绩趋势

- - - ${yLabels} - ${xLabels} - ${lines} - ${points} - -
趋势图展示文化课考试成绩的变化轨迹,点数代每次考试的分数
-
`; - } - - private buildAttendance(records: AttendanceRecord[], now: string): string { - const present = records.filter((r) => r.status === 'present').length; - const absent = records.filter((r) => r.status === 'absent').length; - const late = records.filter((r) => r.status === 'late').length; - const leave = records.filter((r) => r.status === 'leave').length; - const total = records.length; - const rate = total > 0 ? ((present / total) * 100).toFixed(1) : '0'; - - const metricHtml = ` -
-
-
${this.esc(now)} · 系统生成
-
出勤记录
-
-
-
-
-
总考勤次数
- ${total} -

累计记录

-
-
-
出勤率
- ${this.esc(rate)}% -

出勤: ${present} 次

-
-
-
缺勤 / 迟到
- ${absent} / ${late} -

缺勤 ${absent} · 迟到 ${late}

-
-
-
请假
- ${leave} -

累计请假次数

-
-
`; - - const chart = this.renderAttendanceBar(records); - const matrix = this.renderAttendanceMatrix(records); - - let extraHtml = ''; - if (records.length === 0) { - extraHtml = ''; - } - - return this.pageFrame(` - ${this.pageHeader('出勤记录')} - ${metricHtml} - ${extraHtml} - ${chart} - ${matrix} - ${this.pageFooter()} - `); - } - - private renderAttendanceBar(records: AttendanceRecord[]): string { - if (records.length === 0) return ''; - - const statuses = ['present', 'absent', 'late', 'leave'] as const; - const counts = statuses.map((s) => records.filter((r) => r.status === s).length); - const labels = ['出勤', '缺勤', '迟到', '请假']; - const colors = ['#18a77d', '#dc2626', '#f59e0b', '#f15b75']; - const maxCount = Math.max(...counts, 1); - - const w = 600; - const h = 150; - const pad = { top: 20, right: 20, bottom: 30, left: 40 }; - const plotW = w - pad.left - pad.right; - const plotH = h - pad.top - pad.bottom; - const barGap = 30; - const barW = (plotW - barGap * (statuses.length - 1)) / statuses.length; - - const scaleH = (v: number): number => (v / maxCount) * plotH; - - let bars = ''; - for (let i = 0; i < statuses.length; i++) { - const x = pad.left + i * (barW + barGap); - const bh = scaleH(counts[i]); - const y = pad.top + plotH - bh; - bars += ``; - bars += `${counts[i]}`; - bars += `${labels[i]}`; - } - - // Y-axis grid - const ySteps = 4; - let yGrid = ''; - for (let i = 0; i <= ySteps; i++) { - const val = Math.round((maxCount * i) / ySteps); - const y = pad.top + plotH - (plotH * i) / ySteps; - yGrid += `${val}`; - if (i < ySteps) { - yGrid += ``; - } - } - - return `
-

出勤统计

- - - ${yGrid} - ${bars} - -
`; - } - - private renderAttendanceMatrix(records: AttendanceRecord[]): string { - if (records.length === 0) return ''; - - // Group by date - const dateMap = new Map(); - for (const r of records) { - const existing = dateMap.get(r.attendanceDate) ?? []; - existing.push(r); - dateMap.set(r.attendanceDate, existing); - } - - const dates = [...dateMap.keys()].sort(); - const sessions = ['上午', '下午', '晚自习']; - - let rows = ''; - for (const date of dates.slice(-30)) { - const dayRecords = dateMap.get(date) ?? []; - const cellMap = new Map(); - for (const r of dayRecords) { - cellMap.set(r.session, r.status); - } - - let cells = ''; - for (const session of sessions) { - const status = cellMap.get(session) ?? ''; - cells += `${status ? this.statusBadge(status) : '-'}`; - } - - rows += `${this.esc(date)}${cells}`; - } - - return `
-

考勤明细(最近30条)

- - - - ${sessions.map((s) => ``).join('')} - - ${rows} -
日期${this.esc(s)}
-
图例: 出勤   缺勤   迟到   请假
-
`; - } - - private statusBadge(status: string): string { - const map: Record = { - present: { cls: 'present', text: '到' }, - absent: { cls: 'absent', text: '缺' }, - late: { cls: 'late', text: '迟' }, - leave: { cls: 'leave', text: '假' }, - }; - const entry = map[status]; - if (!entry) return `${this.esc(status)}`; - return `${entry.text}`; - } - - private buildExamDetail(exams: ExamScore[], now: string): string { - const cultureExams = exams.filter( - (e) => e.examType && e.examType.includes('文化'), - ); - - if (cultureExams.length === 0) { - return this.pageFrame(` - ${this.pageHeader('文化课考试成绩')} -
-
-
${this.esc(now)} · 系统生成
-
文化课考试成绩
-
-
- - ${this.pageFooter()} - `); - } - - // Group by subject - const subjectMap = new Map(); - for (const e of cultureExams) { - const subject = e.subject || '其他'; - const existing = subjectMap.get(subject) ?? []; - existing.push(e); - subjectMap.set(subject, existing); - } - - let subjectCards = ''; - for (const [subject, subExams] of subjectMap) { - const best = Math.max(...subExams.map((e) => e.score ?? 0)); - const avg = ( - subExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / subExams.length - ).toFixed(1); - - let rows = ''; - for (const e of subExams) { - rows += ` - ${this.esc(e.examName || '-')} - ${e.score != null ? e.score : '-'} - ${e.classAvg != null ? e.classAvg : '-'} - ${e.rank != null ? e.rank : '-'} - ${this.esc(e.examDate || '-')} - `; - } - - subjectCards += `
-

${this.esc(subject)} · 最佳 ${best} · 均分 ${this.esc(avg)}

- - - - - ${rows} -
考试名称分数班均排名日期
-
`; - } - - return this.pageFrame(` - ${this.pageHeader('文化课考试成绩')} -
-
-
${this.esc(now)} · 系统生成
-
文化课考试成绩
-
-
- ${subjectCards} - ${this.pageFooter()} - `); - } - - private buildLearningAndResult( - learnings: LearningRecord[], - result: ResultArchive | null, - now: string, - ): string { - let learningHtml = ''; - if (learnings.length === 0) { - learningHtml = ` -
-
-
${this.esc(now)} · 系统生成
-
学情记录
-
-
- `; - } else { - const latest = learnings.slice(0, 15); - let rows = ''; - for (const r of latest) { - rows += ` - ${this.esc(r.recordDate || '-')} - ${this.esc(r.recordType || '-')} - ${this.esc((r.content || '-').slice(0, 200))} - ${this.esc(r.followUpMethod || '-')} - `; - } - - learningHtml = ` -
-
-
${this.esc(now)} · 系统生成
-
学情记录
-
-
-
-

最近学情记录

- - - - - - ${rows} -
日期类型内容跟进方式
-
`; - } - - let resultHtml = ''; - if (result) { - resultHtml = ` -
-
-
录取归档
-
-
-
-
-
文化课成绩${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}
-
专业课成绩${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}
-
录取状态${this.esc(result.admissionStatus || '-')}
-
录取院校${this.esc(result.admittedCollege || '-')}
-
录取专业${this.esc(result.admittedMajor || '-')}
-
-
-
录取归档信息为最终结果,如有疑问请联系教务处
`; - } else { - resultHtml = ` -
-
-
录取归档
-
-
- `; - } - - return this.pageFrame(` - ${this.pageHeader('学情记录与录取归档')} - ${learningHtml} - ${resultHtml} - ${this.pageFooter()} - `); - } - - private esc(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } } diff --git a/apps/server/src/archive/archive-report.styles.ts b/apps/server/src/archive/archive-report.styles.ts new file mode 100644 index 0000000..fa21ea8 --- /dev/null +++ b/apps/server/src/archive/archive-report.styles.ts @@ -0,0 +1,142 @@ +export const ARCHIVE_REPORT_CSS = ` + @page { size: A4; margin: 0; } + * { box-sizing: border-box; } + body { + margin: 0; background: #eef3f8; color: #101828; + font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif; + -webkit-print-color-adjust: exact; print-color-adjust: exact; + } + .page { + position: relative; width: 210mm; height: 297mm; + margin: 0 auto 18px; padding: 14mm 15mm 10mm; + overflow: hidden; background: #fff; page-break-after: always; + } + .frame { + position: absolute; inset: 14mm; border: 1px solid #cfe0f2; pointer-events: none; + } + .header { + position: relative; z-index: 1; display: flex; align-items: center; + height: 39px; padding-bottom: 8px; border-bottom: 1px solid #cfe0f2; + } + .logo { + width: 24px; height: 24px; border-radius: 6px; + display: inline-flex; align-items: center; justify-content: center; + margin-right: 8px; color: #fff; background: #155aa8; + font-weight: 800; font-size: 11px; + } + .brand { font-size: 10px; font-weight: 700; } + .page-kicker { margin-left: auto; font-size: 10px; color: #667085; } + .footer { + position: absolute; left: 15mm; right: 15mm; bottom: 8mm; z-index: 1; + display: flex; justify-content: space-between; + border-top: 1px solid #cfe0f2; padding-top: 5px; + font-size: 10px; color: #667085; + } + h1, h2, h3, p { margin: 0; } + .section-title { font-size: 24px; line-height: 1.24; font-weight: 800; } + .source { font-size: 12px; color: #667085; padding-bottom: 2px; } + .title-row { + display: flex; align-items: flex-end; justify-content: space-between; + margin: 26px 0 17px; + } + .cover-title { margin-top: 60px; font-size: 34px; line-height: 1.22; font-weight: 800; } + .cover-subtitle { margin-top: 22px; font-size: 16px; color: #667085; } + .cover-main { + display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 63px; + } + .cover-name-card { + min-height: 174px; border: 1px solid #cfe0f2; + border-left: 5px solid #155aa8; padding: 22px 24px; + } + .cover-name { + font-size: 44px; line-height: 1.14; font-weight: 800; color: #155aa8; + } + .cover-desc { margin-top: 22px; font-size: 16px; color: #667085; } + .cover-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } + .cover-cell { + min-height: 61px; border: 1px solid #cfe0f2; padding: 11px 14px; + } + .label { font-size: 11px; color: #667085; margin-bottom: 8px; } + .value { font-size: 14px; line-height: 1.5; font-weight: 700; } + .toc { margin-top: 58px; } + .toc-row { + display: grid; grid-template-columns: 48px 1fr 72px; align-items: center; + height: 47px; border-bottom: 1px solid #cfe0f2; + } + .toc-index { color: #155aa8; font-size: 15px; font-weight: 800; } + .toc-name { font-size: 14px; font-weight: 800; } + .toc-page { text-align: right; color: #667085; font-size: 12px; } + .watermark { + position: absolute; right: 36px; bottom: 82px; color: #eaf1fb; + font-size: 56px; font-weight: 900; writing-mode: vertical-rl; + } + .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + .grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } + .card { border: 1px solid #cfe0f2; padding: 14px; background: #fff; } + .card h3 { font-size: 16px; margin-bottom: 14px; } + .data-table { + width: 100%; border-collapse: collapse; table-layout: fixed; + } + .data-table th, .data-table td { + border: 1px solid #d6e3f2; padding: 8px 9px; font-size: 12px; + line-height: 1.55; vertical-align: top; text-align: left; + } + .data-table th { + background: #eaf3fd; color: #173f6f; font-weight: 800; white-space: nowrap; + } + .data-table td { overflow-wrap: anywhere; word-break: break-word; } + .data-table .nowrap { white-space: nowrap; } + .metric { + min-height: 88px; border: 1px solid #cfe0f2; padding: 13px 14px; + } + .metric .label { margin-bottom: 7px; } + .metric strong { + display: block; color: #155aa8; font-size: 27px; line-height: 1.16; + margin-bottom: 10px; + } + .metric p { + color: #667085; font-size: 12px; line-height: 1.45; + } + .summary-row { + display: grid; grid-template-columns: 92px 1fr; gap: 12px; + padding: 14px 0; border-bottom: 1px solid #d6e3f2; + font-size: 13px; line-height: 1.6; + } + .summary-row:last-child { border-bottom: 0; } + .summary-row strong { color: #155aa8; } + .note { + margin-top: 14px; padding: 12px 16px; border-left: 4px solid #155aa8; + background: #eef5ff; color: #173f6f; font-size: 12px; line-height: 1.7; + } + .banner-note { + margin-top: 12px; padding: 11px 16px; background: #eef5ff; + color: #173f6f; font-size: 12px; line-height: 1.7; + } + .line-chart { width: 100%; height: 180px; display: block; } + .bar-chart { width: 100%; height: 150px; display: block; } + .status { + display: inline-flex; align-items: center; justify-content: center; + width: 18px; height: 18px; border-radius: 5px; margin-right: 6px; + color: #fff; font-size: 11px; font-weight: 800; + } + .present { background: #18a77d; } + .leave { background: #f15b75; } + .late { background: #f59e0b; } + .absent { background: #dc2626; } + .progress-row { + display: grid; grid-template-columns: 72px 1fr 42px; align-items: center; + gap: 8px; margin: 10px 0; font-size: 12px; + } + .progress-track { + height: 11px; border-radius: 999px; background: #dfeaf6; overflow: hidden; + } + .progress-track i { + display: block; height: 100%; border-radius: 999px; + background: linear-gradient(90deg, #155aa8, #2e7df0); + } + .muted { color: #667085; } + @media print { + body { background: #fff; } + .page { margin: 0; box-shadow: none; } + } + `; diff --git a/apps/server/src/archive/archive.controller.ts b/apps/server/src/archive/archive.controller.ts index fd9aeb4..f9afe1f 100644 --- a/apps/server/src/archive/archive.controller.ts +++ b/apps/server/src/archive/archive.controller.ts @@ -30,7 +30,7 @@ import { } from './dto/archive.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { withAuditLog } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; interface AuthenticatedRequest extends ExpressRequest { @@ -49,19 +49,9 @@ export class ArchiveController { @Get(':studentId') @RequirePermission('student:view') async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.getProfile(studentId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '查看档案', - targetId: studentId, - targetType: 'archive', - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '查看档案', targetId: studentId, targetType: 'archive', + }), () => this.archiveService.getProfile(studentId)); } @Put(':studentId/profile') @@ -71,20 +61,9 @@ export class ArchiveController { @Body() dto: UpsertProfileDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.upsertProfile(studentId, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '更新档案信息', - targetId: studentId, - targetType: 'student_profile', - detail: JSON.stringify(dto), - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '更新档案信息', targetId: studentId, targetType: 'student_profile', detail: JSON.stringify(dto), + }), () => this.archiveService.upsertProfile(studentId, dto)); } @Post(':studentId/enrollments') @@ -94,20 +73,9 @@ export class ArchiveController { @Body() dto: CreateEnrollmentDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.addEnrollment(studentId, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '添加报名记录', - targetId: result.id, - targetType: 'student_enrollment', - detail: `${dto.courseCategory} - ${dto.classType}`, - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (result) => ({ + module: '学生档案', action: '添加报名记录', targetId: result.id, targetType: 'student_enrollment', detail: `${dto.courseCategory} - ${dto.classType}`, + }), () => this.archiveService.addEnrollment(studentId, dto)); } @Put('enrollments/:id') @@ -117,38 +85,25 @@ export class ArchiveController { @Body() dto: UpdateEnrollmentDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.updateEnrollment(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '编辑报名记录', - targetId: id, - targetType: 'student_enrollment', - detail: JSON.stringify(dto), - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '编辑报名记录', targetId: id, targetType: 'student_enrollment', detail: JSON.stringify(dto), + }), () => this.archiveService.updateEnrollment(id, dto)); } @Delete('enrollments/:id') @RequirePermission('student:edit') async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.deleteEnrollment(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '归档报名记录', - targetId: id, - targetType: 'student_enrollment', - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '归档报名记录', targetId: id, targetType: 'student_enrollment', + }), () => this.archiveService.deleteEnrollment(id)); + } + + @Delete('enrollments/:id/permanent') + @RequirePermission('archive:purge') + async purgeEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '永久删除报名记录', targetId: id, targetType: 'student_enrollment', detail: '物理删除,不可恢复', + }), () => this.archiveService.purgeEnrollment(id)); } @Post(':studentId/exam-scores') @@ -158,20 +113,9 @@ export class ArchiveController { @Body() dto: CreateExamScoreDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.addExamScore(studentId, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '添加考试成绩', - targetId: result.id, - targetType: 'exam_score', - detail: `${dto.examType} - ${dto.subject}: ${dto.score}`, - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (result) => ({ + module: '学生档案', action: '添加考试成绩', targetId: result.id, targetType: 'exam_score', detail: `${dto.examType} - ${dto.subject}: ${dto.score}`, + }), () => this.archiveService.addExamScore(studentId, dto)); } @Put('exam-scores/:id') @@ -181,38 +125,25 @@ export class ArchiveController { @Body() dto: UpdateExamScoreDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.updateExamScore(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '编辑考试成绩', - targetId: id, - targetType: 'exam_score', - detail: JSON.stringify(dto), - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '编辑考试成绩', targetId: id, targetType: 'exam_score', detail: JSON.stringify(dto), + }), () => this.archiveService.updateExamScore(id, dto)); } @Delete('exam-scores/:id') @RequirePermission('student:edit') async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.deleteExamScore(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '归档考试成绩', - targetId: id, - targetType: 'exam_score', - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '归档考试成绩', targetId: id, targetType: 'exam_score', + }), () => this.archiveService.deleteExamScore(id)); + } + + @Delete('exam-scores/:id/permanent') + @RequirePermission('archive:purge') + async purgeExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '永久删除考试成绩', targetId: id, targetType: 'exam_score', detail: '物理删除,不可恢复', + }), () => this.archiveService.purgeExamScore(id)); } @Post(':studentId/learning-records') @@ -222,20 +153,9 @@ export class ArchiveController { @Body() dto: CreateLearningRecordDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.addLearningRecord(studentId, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '添加学习记录', - targetId: result.id, - targetType: 'learning_record', - detail: `${dto.recordType}: ${dto.content.substring(0, 50)}`, - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (result) => ({ + module: '学生档案', action: '添加学习记录', targetId: result.id, targetType: 'learning_record', detail: `${dto.recordType}: ${dto.content.substring(0, 50)}`, + }), () => this.archiveService.addLearningRecord(studentId, dto)); } @Put('learning-records/:id') @@ -245,38 +165,25 @@ export class ArchiveController { @Body() dto: UpdateLearningRecordDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.updateLearningRecord(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '编辑学习记录', - targetId: id, - targetType: 'learning_record', - detail: JSON.stringify(dto), - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '编辑学习记录', targetId: id, targetType: 'learning_record', detail: JSON.stringify(dto), + }), () => this.archiveService.updateLearningRecord(id, dto)); } @Delete('learning-records/:id') @RequirePermission('student:edit') async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.deleteLearningRecord(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '归档学习记录', - targetId: id, - targetType: 'learning_record', - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '归档学习记录', targetId: id, targetType: 'learning_record', + }), () => this.archiveService.deleteLearningRecord(id)); + } + + @Delete('learning-records/:id/permanent') + @RequirePermission('archive:purge') + async purgeLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '永久删除学习记录', targetId: id, targetType: 'learning_record', detail: '物理删除,不可恢复', + }), () => this.archiveService.purgeLearningRecord(id)); } @Put(':studentId/result') @@ -286,20 +193,9 @@ export class ArchiveController { @Body() dto: UpsertResultDto, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.upsertResult(studentId, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '更新录取结果', - targetId: studentId, - targetType: 'result_archive', - detail: JSON.stringify(dto), - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '更新录取结果', targetId: studentId, targetType: 'result_archive', detail: JSON.stringify(dto), + }), () => this.archiveService.upsertResult(studentId, dto)); } @Post(':studentId/attachments') @@ -311,20 +207,9 @@ export class ArchiveController { @Body('category') category: string, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.addAttachment(studentId, file, category || 'other'); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '上传附件', - targetId: result.id, - targetType: 'archive_attachment', - detail: `${file.originalname} (${category || 'other'})`, - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (result) => ({ + module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${file.originalname} (${category || 'other'})`, + }), () => this.archiveService.addAttachment(studentId, file, category || 'other')); } @Get(':studentId/attachments/:id') @@ -347,19 +232,17 @@ export class ArchiveController { @Delete('attachments/:id') @RequirePermission('student:edit') async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { - const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.deleteAttachment(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生档案', - action: '归档附件', - targetId: id, - targetType: 'archive_attachment', - ipAddress, - userAgent, - }); - return result; + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '归档附件', targetId: id, targetType: 'archive_attachment', + }), () => this.archiveService.deleteAttachment(id)); + } + + @Delete('attachments/:id/permanent') + @RequirePermission('archive:purge') + async purgeAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + return withAuditLog(this.logService, req, (_result) => ({ + module: '学生档案', action: '永久删除附件', targetId: id, targetType: 'archive_attachment', detail: '物理删除,不可恢复', + }), () => this.archiveService.purgeAttachment(id)); } @Get(':studentId/report-html') @@ -368,18 +251,11 @@ export class ArchiveController { @Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: 'archive', - action: 'generate_report_html', - targetId: studentId, - targetType: 'student', - ipAddress, - userAgent, + return withAuditLog(this.logService, req, () => ({ + module: 'archive', action: 'generate_report_html', targetId: studentId, targetType: 'student', + }), async () => { + const html = await this.reportService.generateReportHtml(studentId); + return { html }; }); - const html = await this.reportService.generateReportHtml(studentId); - return { html }; } } diff --git a/apps/server/src/archive/archive.purge.controller.spec.ts b/apps/server/src/archive/archive.purge.controller.spec.ts new file mode 100644 index 0000000..81c64d7 --- /dev/null +++ b/apps/server/src/archive/archive.purge.controller.spec.ts @@ -0,0 +1,38 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { ArchiveController } from './archive.controller'; + +describe('ArchiveController purge routes', () => { + it('requires archive:purge on permanent delete routes', () => { + expect( + Reflect.getMetadata(PERMISSION_KEY, ArchiveController.prototype.purgeEnrollment), + ).toEqual(['archive:purge']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ArchiveController.prototype.purgeExamScore), + ).toEqual(['archive:purge']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ArchiveController.prototype.purgeLearningRecord), + ).toEqual(['archive:purge']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ArchiveController.prototype.purgeAttachment), + ).toEqual(['archive:purge']); + }); + + it('writes permanent delete audit logs for sub-records', async () => { + const archiveService = { + purgeEnrollment: jest.fn().mockResolvedValue({ message: '已永久删除报名记录(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ArchiveController( + archiveService as never, + { log } as never, + {} as never, + ); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purgeEnrollment(1, req); + expect(archiveService.purgeEnrollment).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '学生档案', action: '永久删除报名记录', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/archive/archive.purge.spec.ts b/apps/server/src/archive/archive.purge.spec.ts new file mode 100644 index 0000000..5f0952e --- /dev/null +++ b/apps/server/src/archive/archive.purge.spec.ts @@ -0,0 +1,114 @@ +import { BadRequestException } from '@nestjs/common'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ArchiveService } from './archive.service'; + +describe('ArchiveService purge sub-records', () => { + const createService = (overrides?: { + enrollment?: Record; + examScore?: Record; + learningRecord?: Record; + attachment?: Record; + scoreCount?: number; + }) => { + const enrollmentRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 1, + status: 'archived', + ...overrides?.enrollment, + }), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const examScoreRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 2, + status: 'archived', + ...overrides?.examScore, + }), + count: jest.fn().mockResolvedValue(overrides?.scoreCount ?? 0), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const learningRecordRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 3, + status: 'archived', + ...overrides?.learningRecord, + }), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const attachmentRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 4, + status: 'archived', + filePath: 'x.pdf', + ...overrides?.attachment, + }), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const service = new ArchiveService( + {} as never, + {} as never, + enrollmentRepo as never, + examScoreRepo as never, + learningRecordRepo as never, + {} as never, + attachmentRepo as never, + {} as never, + {} as never, + ); + return { service, enrollmentRepo, examScoreRepo, learningRecordRepo, attachmentRepo }; + }; + + it('rejects non-archived sub-records', async () => { + const { service, enrollmentRepo } = createService({ enrollment: { status: 'active' } }); + await expect(service.purgeEnrollment(1)).rejects.toThrow( + new BadRequestException('仅已归档报名记录可以永久删除,请先归档'), + ); + expect(enrollmentRepo.delete).not.toHaveBeenCalled(); + }); + + it('rejects enrollments referenced by exam scores', async () => { + const { service, enrollmentRepo } = createService({ scoreCount: 1 }); + await expect(service.purgeEnrollment(1)).rejects.toThrow( + new BadRequestException('该报名记录已被考试成绩引用,无法永久删除'), + ); + expect(enrollmentRepo.delete).not.toHaveBeenCalled(); + }); + + it('deletes archived enrollment, exam score, and learning record', async () => { + const { service, enrollmentRepo, examScoreRepo, learningRecordRepo } = createService(); + await expect(service.purgeEnrollment(1)).resolves.toEqual({ + message: '已永久删除报名记录(不可恢复)', + }); + await expect(service.purgeExamScore(2)).resolves.toEqual({ + message: '已永久删除考试成绩(不可恢复)', + }); + await expect(service.purgeLearningRecord(3)).resolves.toEqual({ + message: '已永久删除学习记录(不可恢复)', + }); + expect(enrollmentRepo.delete).toHaveBeenCalledWith(1); + expect(examScoreRepo.delete).toHaveBeenCalledWith(2); + expect(learningRecordRepo.delete).toHaveBeenCalledWith(3); + }); + + it('deletes the attachment row and removes the disk file', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-purge-')); + process.env.UPLOAD_DIR = tmpDir; + const filePath = 'x.pdf'; + const fullPath = path.join(tmpDir, 'archive', filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, 'data'); + try { + const { service, attachmentRepo } = createService({ attachment: { filePath } }); + await expect(service.purgeAttachment(4)).resolves.toEqual({ + message: '已永久删除附件(不可恢复)', + }); + expect(fs.existsSync(fullPath)).toBe(false); + expect(attachmentRepo.delete).toHaveBeenCalledWith(4); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete process.env.UPLOAD_DIR; + } + }); +}); diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index b6eb6cd..ac210b1 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -74,15 +74,15 @@ export class ArchiveService { attendances, ] = await Promise.all([ this.profileRepo.findOne({ where: { studentId } }), - this.enrollmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }), + this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), this.examScoreRepo.find({ - where: { studentId, status: 'active' }, + where: { studentId }, relations: ['exam', 'exam.class'], order: { examDate: 'DESC' }, }), - this.learningRecordRepo.find({ where: { studentId, status: 'active' }, order: { recordDate: 'DESC' } }), + this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }), this.resultRepo.findOne({ where: { studentId } }), - this.attachmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }), + this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), this.attendanceRepo.find({ where: { studentId }, relations: ['schedule', 'class'], @@ -138,6 +138,20 @@ export class ArchiveService { return { message: '已归档' }; } + async purgeEnrollment(id: number) { + const entity = await this.enrollmentRepo.findOne({ where: { id } }); + if (!entity) throw new NotFoundException('报名记录不存在'); + if (entity.status !== 'archived') { + throw new BadRequestException('仅已归档报名记录可以永久删除,请先归档'); + } + const scoreCount = await this.examScoreRepo.count({ where: { enrollmentId: id } }); + if (scoreCount > 0) { + throw new BadRequestException('该报名记录已被考试成绩引用,无法永久删除'); + } + await this.enrollmentRepo.delete(id); + return { message: '已永久删除报名记录(不可恢复)' }; + } + private async assertEnrollmentBelongsToStudent(studentId: number, enrollmentId?: number) { if (enrollmentId === undefined) return; const enrollment = await this.enrollmentRepo.findOne({ @@ -173,6 +187,16 @@ export class ArchiveService { return { message: '已归档' }; } + async purgeExamScore(id: number) { + const entity = await this.examScoreRepo.findOne({ where: { id } }); + if (!entity) throw new NotFoundException('考试成绩不存在'); + if (entity.status !== 'archived') { + throw new BadRequestException('仅已归档考试成绩可以永久删除,请先归档'); + } + await this.examScoreRepo.delete(id); + return { message: '已永久删除考试成绩(不可恢复)' }; + } + async addLearningRecord(studentId: number, dto: CreateLearningRecordDto) { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); @@ -196,6 +220,16 @@ export class ArchiveService { return { message: '已归档' }; } + async purgeLearningRecord(id: number) { + const entity = await this.learningRecordRepo.findOne({ where: { id } }); + if (!entity) throw new NotFoundException('学习记录不存在'); + if (entity.status !== 'archived') { + throw new BadRequestException('仅已归档学习记录可以永久删除,请先归档'); + } + await this.learningRecordRepo.delete(id); + return { message: '已永久删除学习记录(不可恢复)' }; + } + async upsertResult(studentId: number, dto: UpsertResultDto) { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); @@ -257,4 +291,23 @@ export class ArchiveService { await this.attachmentRepo.update(id, { status: 'archived' }); return { message: '已归档' }; } + + async purgeAttachment(id: number) { + const entity = await this.attachmentRepo.findOne({ where: { id } }); + if (!entity) throw new NotFoundException('附件不存在'); + if (entity.status !== 'archived') { + throw new BadRequestException('仅已归档附件可以永久删除,请先归档'); + } + if (entity.filePath) { + try { + const fullPath = this.resolveAttachmentPath(entity.filePath); + if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath); + } catch (error) { + // 磁盘文件删除失败仅告警,不阻塞数据库删除 + console.warn(`[ArchiveService] 附件文件删除失败: ${entity.filePath}`, error); + } + } + await this.attachmentRepo.delete(id); + return { message: '已永久删除附件(不可恢复)' }; + } }