From 90cc0c10b2c32bc6062065b33d74e2d8ec50f032 Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 21 Jul 2026 15:11:52 +0800 Subject: [PATCH 1/5] feat: expand student archive import template --- .../src/components/EditableCell/index.tsx | 2 +- .../StudentProfileContent/index.tsx | 491 +++++++++++------- apps/server/package.json | 1 + .../scripts/generate-student-import-xlsx.ts | 104 ++++ apps/server/src/archive/archive.service.ts | 5 +- .../src/students/student-import.spec.ts | 75 +++ apps/server/src/students/student-import.ts | 319 ++++++++++++ .../src/students/students.agent-api.spec.ts | 5 + .../src/students/students.controller.ts | 178 ++----- .../src/students/students.lifecycle.spec.ts | 34 ++ apps/server/src/students/students.module.ts | 10 + .../src/students/students.scope.spec.ts | 10 + apps/server/src/students/students.service.ts | 254 +++++++-- 13 files changed, 1103 insertions(+), 385 deletions(-) create mode 100644 apps/server/scripts/generate-student-import-xlsx.ts create mode 100644 apps/server/src/students/student-import.spec.ts create mode 100644 apps/server/src/students/student-import.ts diff --git a/apps/admin/src/components/EditableCell/index.tsx b/apps/admin/src/components/EditableCell/index.tsx index c69a132..a60b195 100644 --- a/apps/admin/src/components/EditableCell/index.tsx +++ b/apps/admin/src/components/EditableCell/index.tsx @@ -24,7 +24,7 @@ export interface EditableCellOption { export interface EditableCellProps { value: Value; - children: React.ReactNode; + children?: React.ReactNode; editor?: EditableCellEditor; options?: EditableCellOption[]; permission?: string; diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index b723ac6..77b5971 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -46,6 +46,13 @@ interface StudentInfo { 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; } @@ -163,13 +170,6 @@ const RECORD_TYPE_OPTIONS = [ { value: 'other', label: '其他' }, ]; -const STUDENT_STATUS_MAP: Record = { - active: { text: '在读', color: 'green' }, - graduated: { text: '已毕业', color: 'blue' }, - withdrawn: { text: '已退训', color: 'red' }, - archived: { text: '已归档', color: '#999' }, -}; - const ENROLLMENT_STATUS_MAP: Record = { active: { text: '报读中', color: 'green' }, completed: { text: '已结课', color: 'blue' }, @@ -209,11 +209,6 @@ const getEnrollmentStatus = (value?: string | null): { text: string; color: stri return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' }; }; -const getStudentStatus = (value?: string | null): { text: string; color: string } => { - if (!value) return { text: '-', color: 'default' }; - return STUDENT_STATUS_MAP[value] || { text: value, color: 'default' }; -}; - const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string => enrollment.className || (enrollment.courseCategory @@ -311,72 +306,274 @@ interface TabProps { onRefresh: () => void; } -const ProfileTab: React.FC<{ - data: ProfileData | null; +const InlineArchiveSummary: React.FC<{ studentId: number; + student: StudentInfo; + profile: ProfileData | null; + result: ResultData | null; + organizations: Array<{ id: number; name: string }>; onRefresh: () => void; -}> = ({ data, studentId, onRefresh }) => { - const [form] = Form.useForm(); - const [saving, setSaving] = useState(false); - - const handleSave = async () => { - try { - const values = await form.validateFields(); - setSaving(true); - await api.put(`/archive/${studentId}/profile`, { - ...values, - profileDate: values.profileDate?.format('YYYY-MM-DD'), - }); - message.success('基础档案已保存'); - onRefresh(); - } catch (e: unknown) { - const err = e as { message?: string }; - if (err?.message) message.error(err.message); - } finally { - setSaving(false); - } + onViewSensitive: (fieldLabel: string, value: string) => void; +}> = ({ studentId, student, profile, result, organizations, onRefresh, onViewSensitive }) => { + const saveStudent = async (field: keyof StudentInfo, value: unknown) => { + await api.put(`/students/${studentId}`, { [field]: value }); + message.success('学生资料已保存'); + onRefresh(); }; + const saveProfile = async (field: keyof ProfileData, value: unknown) => { + await api.put(`/archive/${studentId}/profile`, { [field]: value }); + message.success('档案已保存'); + onRefresh(); + }; + + const saveResult = async (field: keyof ResultData, value: unknown) => { + await api.put(`/archive/${studentId}/result`, { [field]: value }); + message.success('录取信息已保存'); + onRefresh(); + }; + + const admissionStatus = getOptionLabel( + Object.entries(ADMISSION_STATUS_MAP).map(([value, meta]) => ({ + value, + label: meta.text, + })), + result?.admissionStatus, + ); + return ( -
-
- - - - - - - - - - - - - - - - - - - - - -
-
+ + + saveStudent('phone', next)} + > + {student.phone ? ( + + {maskPhone(student.phone)} + onViewSensitive('电话', student.phone)}> + + + + ) : ( + '-' + )} + + + + saveStudent('name', next)} + > + {student.name || '-'} + + + + saveStudent('studentNo', next)} + > + {student.studentNo || '-'} + + + + saveStudent('gender', next)} + > + {student.gender || '-'} + + + + saveStudent('idNumber', next)} + > + {student.idNumber ? ( + + {maskIdNumber(student.idNumber)} + onViewSensitive('身份证号', student.idNumber)}> + + + + ) : ( + '-' + )} + + + + saveStudent('ethnicity', next)} + > + {student.ethnicity || '-'} + + + + saveStudent('emergencyContact', next)} + > + {student.emergencyContact || '-'} + + + + saveStudent('emergencyPhone', next)} + > + {student.emergencyPhone ? ( + + {maskPhone(student.emergencyPhone)} + onViewSensitive('紧急联系人电话', student.emergencyPhone || '')}> + + + + ) : ( + '-' + )} + + + + ({ value: item.id, label: item.name }))} + permission="student:edit" + onSave={(next) => saveStudent('organizationId', next)} + > + {student.organization?.name ? {student.organization.name} : '-'} + + + + saveStudent('supervisor', next)} + > + {student.supervisor || '-'} + + + + saveProfile('targetCollege', next)} + > + {profile?.targetCollege || '-'} + + + + saveProfile('targetMajor', next)} + > + {profile?.targetMajor || '-'} + + + + saveProfile('subjectDirection', next)} + > + {profile?.subjectDirection || '-'} + + + + saveProfile('grade', next)} + > + {profile?.grade || '-'} + + + + saveProfile('profileDate', next)} + > + {profile?.profileDate || '-'} + + + + saveProfile('notes', next)} + > + {profile?.notes || '-'} + + + + saveResult('cultureFinalScore', next)} + > + {result?.cultureFinalScore ?? '-'} + + + + saveResult('professionalFinalScore', next)} + > + {result?.professionalFinalScore ?? '-'} + + + + ({ + value, + label: meta.text, + }))} + permission="student:edit" + onSave={(next) => saveResult('admissionStatus', next)} + > + {admissionStatus} + + + + saveResult('admittedCollege', next)} + > + {result?.admittedCollege || '-'} + + + + saveResult('admittedMajor', next)} + > + {result?.admittedMajor || '-'} + + + ); }; @@ -1018,75 +1215,6 @@ const LearningTab: React.FC = ({ ); }; -const ResultTab: React.FC = ({ - data, - studentId, - onRefresh, -}) => { - const [form] = Form.useForm(); - const [saving, setSaving] = useState(false); - - const handleSave = async () => { - try { - const values = await form.validateFields(); - setSaving(true); - await api.put(`/archive/${studentId}/result`, values); - message.success('录取结果已保存'); - onRefresh(); - } catch (e: unknown) { - const err = e as { message?: string }; - if (err?.message) message.error(err.message); - } finally { - setSaving(false); - } - }; - - return ( -
-
- - - - - - - - - - - - - - - -
-
- ); -}; - const AttachmentsTab: React.FC = ({ data, studentId, @@ -1202,6 +1330,7 @@ const StudentProfileContent: React.FC = ({ onClose, }) => { const [aggregateData, setAggregateData] = useState(null); + const [organizations, setOrganizations] = useState>([]); const [loading, setLoading] = useState(false); const fetchData = useCallback(async () => { @@ -1221,6 +1350,15 @@ const StudentProfileContent: React.FC = ({ void fetchData(); }, [fetchData]); + useEffect(() => { + api + .get('/organizations', { params: { includeArchived: 'false' } }) + .then((res: unknown) => { + setOrganizations(res as Array<{ id: number; name: string }>); + }) + .catch(() => {}); + }, []); + const handlePreviewReport = useCallback(async () => { try { const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`); @@ -1238,14 +1376,8 @@ const StudentProfileContent: React.FC = ({ const tabItems = useMemo(() => { if (!aggregateData) return []; - const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } = - aggregateData; + const { enrollments, examScores, learningRecords, attachments, attendances } = aggregateData; return [ - { - key: 'profile', - label: '扩展档案', - children: , - }, { key: 'enrollments', label: `报读班型 (${enrollments.length})`, @@ -1275,11 +1407,6 @@ const StudentProfileContent: React.FC = ({ ), }, - { - key: 'result', - label: '录取归档', - children: , - }, { key: 'attachments', label: `附件 (${attachments.length})`, @@ -1304,7 +1431,7 @@ const StudentProfileContent: React.FC = ({ return null; } - const { student, profile } = aggregateData; + const { student, profile, result } = aggregateData; return (
@@ -1342,51 +1469,17 @@ const StudentProfileContent: React.FC = ({ ))} - - {student.studentNo || '-'} - - {student.phone ? ( - - {maskPhone(student.phone)} - handleViewSensitive('电话', student.phone)}> - - - - ) : ( - '-' - )} - - - {student.idNumber ? ( - - {maskIdNumber(student.idNumber)} - handleViewSensitive('身份证号', student.idNumber)}> - - - - ) : ( - '-' - )} - - - {(() => { - const status = getStudentStatus(student.status); - return {status.text}; - })()} - - {profile?.targetCollege && ( - {profile.targetCollege} - )} - {profile?.targetMajor && ( - {profile.targetMajor} - )} - {profile?.grade && {profile.grade}} - {profile?.subjectDirection && ( - {profile.subjectDirection} - )} - + - +
); }; diff --git a/apps/server/package.json b/apps/server/package.json index d0550e9..f35d955 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -13,6 +13,7 @@ "start": "nest start", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", + "generate:student-import": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/generate-student-import-xlsx.ts", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "typecheck": "tsc -p tsconfig.build.json --noEmit", "test": "jest", diff --git a/apps/server/scripts/generate-student-import-xlsx.ts b/apps/server/scripts/generate-student-import-xlsx.ts new file mode 100644 index 0000000..df7e6d7 --- /dev/null +++ b/apps/server/scripts/generate-student-import-xlsx.ts @@ -0,0 +1,104 @@ +/// + +import * as ExcelJS from 'exceljs'; +import { + STUDENT_IMPORT_COLUMNS, + createStudentImportTemplateWorkbook, +} from '../src/students/student-import'; + +const count = Number(process.argv[2] || 1000); +const output = process.argv[3] || `student-import-${count}.xlsx`; + +function getWorksheetOrThrow(workbook: ExcelJS.Workbook, name: string) { + const sheet = workbook.getWorksheet(name); + if (!sheet) throw new Error(`Worksheet not found: ${name}`); + return sheet; +} + +async function main() { + if (!Number.isInteger(count) || count <= 0) { + throw new Error('Count must be a positive integer'); + } + + const workbook = createStudentImportTemplateWorkbook(); + const students = getWorksheetOrThrow(workbook, '学生基础+档案+录取'); + const enrollments = getWorksheetOrThrow(workbook, '报读班型'); + const examScores = getWorksheetOrThrow(workbook, '考试成绩'); + const learningRecords = getWorksheetOrThrow(workbook, '课堂回访'); + + for (let i = 1; i <= count; i++) { + const serial = String(i).padStart(4, '0'); + const phone = `138${String(i).padStart(8, '0')}`; + students.addRow({ + phone, + name: `导入学生${serial}`, + studentNo: `GX${serial}`, + gender: i % 2 === 0 ? '女' : '男', + idNumber: `110101200601${String((i % 28) + 1).padStart(2, '0')}${String(i % 1000).padStart(3, '0')}X`, + ethnicity: '汉族', + emergencyContact: `联系人${serial}`, + emergencyPhone: `139${String(i).padStart(8, '0')}`, + organization: '', + supervisor: '', + targetCollege: `目标院校${(i % 20) + 1}`, + targetMajor: `目标专业${(i % 10) + 1}`, + subjectDirection: ['物化生', '物化地', '史政地'][i % 3], + grade: '高三', + profileDate: '2024-09-01', + notes: '', + cultureFinalScore: 500 + (i % 151), + professionalFinalScore: 300 + (i % 101), + admissionStatus: 'pending', + admittedCollege: '', + admittedMajor: '', + }); + + enrollments.addRow({ + phone, + name: `导入学生${serial}`, + courseCategory: i % 2 === 0 ? 'culture' : 'professional', + classType: i % 3 === 0 ? 'small_group' : 'one_on_one', + className: `导入测试班${(i % 10) + 1}`, + headTeacher: '', + subjectTeacher: '', + startDate: '2024-09-01', + endDate: '2025-06-01', + status: 'active', + }); + + examScores.addRow({ + phone, + name: `导入学生${serial}`, + examType: 'monthly', + examName: '导入测试月考', + subject: '语文', + score: 80 + (i % 41), + classAvg: 90, + rank: (i % 50) + 1, + examDate: '2024-10-15', + enrollmentName: `导入测试班${(i % 10) + 1}`, + }); + + learningRecords.addRow({ + phone, + name: `导入学生${serial}`, + recordDate: '2024-10-20', + recordType: 'study_feedback', + content: `导入测试回访${serial}`, + followUpMethod: 'phone', + nextStep: '', + }); + } + + for (const sheet of workbook.worksheets) { + sheet.views = [{ state: 'frozen', ySplit: 1 }]; + } + + await workbook.xlsx.writeFile(output); + console.log(`Generated ${count} students with ${STUDENT_IMPORT_COLUMNS.length} main columns: ${output}`); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index e867620..f1140c2 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -58,7 +58,10 @@ export class ArchiveService { } async getProfile(studentId: number) { - const student = await this.studentRepo.findOne({ where: { id: studentId } }); + const student = await this.studentRepo.findOne({ + where: { id: studentId }, + relations: ['organization'], + }); if (!student) throw new NotFoundException('学生不存在'); const [ diff --git a/apps/server/src/students/student-import.spec.ts b/apps/server/src/students/student-import.spec.ts new file mode 100644 index 0000000..0de3fef --- /dev/null +++ b/apps/server/src/students/student-import.spec.ts @@ -0,0 +1,75 @@ +import * as ExcelJS from 'exceljs'; +import { + createStudentImportTemplateWorkbook, + parseStudentImportWorkbook, +} from './student-import'; + +describe('student import workbook', () => { + it('creates a template with one main example row and header-only detail sheets', () => { + const workbook = createStudentImportTemplateWorkbook(); + const students = workbook.getWorksheet('学生基础+档案+录取'); + + expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual([ + '学生基础+档案+录取', + '报读班型', + '考试成绩', + '课堂回访', + ]); + expect(students?.rowCount).toBe(2); + expect(students?.getRow(2).getCell(1).value).toBe('13800138000'); + expect(students?.getRow(2).getCell(19).value).toBe('pending'); + expect(students?.getRow(2).getCell(20).value || '').toBe(''); + expect(students?.getRow(2).getCell(21).value || '').toBe(''); + expect(workbook.getWorksheet('报读班型')?.rowCount).toBe(1); + expect(workbook.getWorksheet('考试成绩')?.rowCount).toBe(1); + expect(workbook.getWorksheet('课堂回访')?.rowCount).toBe(1); + }); + + it('maps rows by headers across archive sheets', () => { + const workbook = new ExcelJS.Workbook(); + const students = workbook.addWorksheet('学生基础+档案+录取'); + students.addRow([ + '手机号*', + '姓名*', + '目标院校', + '建档日期', + '文化课最终分', + '录取状态', + ]); + students.addRow(['13800138000', '张三', '北京大学', '2024/9/1', 620, 'pending']); + + const enrollments = workbook.addWorksheet('报读班型'); + enrollments.addRow(['手机号*', '课程类别*', '班型*', '班级名称']); + enrollments.addRow(['13800138000', 'culture', 'one_on_one', '冲刺班']); + + const examScores = workbook.addWorksheet('考试成绩'); + examScores.addRow(['手机号*', '考试类型*', '科目*', '成绩*', '考试日期']); + examScores.addRow(['13800138000', 'monthly', '语文', '108.5', '2024-10-15']); + + const learningRecords = workbook.addWorksheet('课堂回访'); + learningRecords.addRow(['手机号*', '记录日期*', '记录类型*', '内容*']); + learningRecords.addRow(['13800138000', '2024-10-20', 'study_feedback', '状态稳定']); + + const parsed = parseStudentImportWorkbook(workbook); + + expect(parsed.students[0]).toEqual( + expect.objectContaining({ + phone: '13800138000', + name: '张三', + targetCollege: '北京大学', + profileDate: '2024-09-01', + cultureFinalScore: 620, + admissionStatus: 'pending', + }), + ); + expect(parsed.enrollments[0]).toEqual( + expect.objectContaining({ courseCategory: 'culture', classType: 'one_on_one' }), + ); + expect(parsed.examScores[0]).toEqual( + expect.objectContaining({ score: 108.5, examDate: '2024-10-15' }), + ); + expect(parsed.learningRecords[0]).toEqual( + expect.objectContaining({ recordType: 'study_feedback', content: '状态稳定' }), + ); + }); +}); diff --git a/apps/server/src/students/student-import.ts b/apps/server/src/students/student-import.ts new file mode 100644 index 0000000..44dbc0f --- /dev/null +++ b/apps/server/src/students/student-import.ts @@ -0,0 +1,319 @@ +import * as ExcelJS from 'exceljs'; + +export interface StudentImportRow { + name: string; + studentNo?: string; + phone?: string; + idNumber?: string; + gender?: string; + ethnicity?: string; + emergencyContact?: string; + emergencyPhone?: string; + organization?: string; + supervisor?: string; + organizationId?: number; + targetCollege?: string; + targetMajor?: string; + subjectDirection?: string; + grade?: string; + profileDate?: string; + notes?: string; + cultureFinalScore?: number; + professionalFinalScore?: number; + admissionStatus?: string; + admittedCollege?: string; + admittedMajor?: string; +} + +export interface StudentEnrollmentImportRow { + phone: string; + name?: string; + courseCategory: string; + classType: string; + className?: string; + headTeacher?: string; + subjectTeacher?: string; + startDate?: string; + endDate?: string; + status?: string; +} + +export interface ExamScoreImportRow { + phone: string; + name?: string; + examType: string; + examName?: string; + subject: string; + score?: number; + classAvg?: number; + rank?: number; + examDate?: string; + enrollmentName?: string; +} + +export interface LearningRecordImportRow { + phone: string; + name?: string; + recordDate: string; + recordType: string; + content: string; + followUpMethod?: string; + nextStep?: string; +} + +export interface StudentWorkbookImport { + students: StudentImportRow[]; + enrollments: StudentEnrollmentImportRow[]; + examScores: ExamScoreImportRow[]; + learningRecords: LearningRecordImportRow[]; +} + +type ColumnDef = { + header: string; + key: keyof T; + width: number; + aliases?: string[]; + kind?: 'text' | 'date' | 'number' | 'integer'; +}; + +export const STUDENT_IMPORT_COLUMNS: ColumnDef[] = [ + { header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] }, + { header: '姓名*', key: 'name', width: 15, aliases: ['姓名'] }, + { header: '学号', key: 'studentNo', width: 15 }, + { header: '性别', key: 'gender', width: 8 }, + { header: '身份证号', key: 'idNumber', width: 22, aliases: ['身份证', '学号/身份证'] }, + { header: '民族', key: 'ethnicity', width: 10 }, + { header: '紧急联系人', key: 'emergencyContact', width: 15 }, + { header: '紧急联系人电话', key: 'emergencyPhone', width: 18 }, + { header: '所属机构', key: 'organization', width: 18, aliases: ['所属机构名称'] }, + { header: '负责人', key: 'supervisor', width: 15, aliases: ['负责人/班主任'] }, + { header: '目标院校', key: 'targetCollege', width: 18 }, + { header: '目标专业', key: 'targetMajor', width: 22 }, + { header: '选科方向', key: 'subjectDirection', width: 14 }, + { header: '年级', key: 'grade', width: 10 }, + { header: '建档日期', key: 'profileDate', width: 14, kind: 'date' }, + { header: '档案备注', key: 'notes', width: 28 }, + { header: '文化课最终分', key: 'cultureFinalScore', width: 14, kind: 'number' }, + { header: '专业课最终分', key: 'professionalFinalScore', width: 14, kind: 'number' }, + { header: '录取状态', key: 'admissionStatus', width: 14 }, + { header: '录取院校', key: 'admittedCollege', width: 18 }, + { header: '录取专业', key: 'admittedMajor', width: 22 }, +]; + +export const STUDENT_EXPORT_COLUMNS = [ + ...STUDENT_IMPORT_COLUMNS, +]; + +const ENROLLMENT_IMPORT_COLUMNS: ColumnDef[] = [ + { header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] }, + { header: '姓名', key: 'name', width: 15 }, + { header: '课程类别*', key: 'courseCategory', width: 14, aliases: ['课程类别'] }, + { header: '班型*', key: 'classType', width: 14, aliases: ['班型'] }, + { header: '班级名称', key: 'className', width: 20 }, + { header: '班主任', key: 'headTeacher', width: 15 }, + { header: '任课教师', key: 'subjectTeacher', width: 15 }, + { header: '开始日期', key: 'startDate', width: 14, kind: 'date' }, + { header: '结束日期', key: 'endDate', width: 14, kind: 'date' }, + { header: '状态', key: 'status', width: 12 }, +]; + +const EXAM_SCORE_IMPORT_COLUMNS: ColumnDef[] = [ + { header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] }, + { header: '姓名', key: 'name', width: 15 }, + { header: '考试类型*', key: 'examType', width: 14, aliases: ['考试类型'] }, + { header: '考试名称', key: 'examName', width: 20 }, + { header: '科目*', key: 'subject', width: 14, aliases: ['科目'] }, + { header: '成绩*', key: 'score', width: 12, aliases: ['成绩'], kind: 'number' }, + { header: '班级均分', key: 'classAvg', width: 12, kind: 'number' }, + { header: '排名', key: 'rank', width: 10, kind: 'integer' }, + { header: '考试日期', key: 'examDate', width: 14, kind: 'date' }, + { header: '关联报读(班级名)', key: 'enrollmentName', width: 22, aliases: ['关联报读(班级名)'] }, +]; + +const LEARNING_RECORD_IMPORT_COLUMNS: ColumnDef[] = [ + { header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] }, + { header: '姓名', key: 'name', width: 15 }, + { header: '记录日期*', key: 'recordDate', width: 14, aliases: ['记录日期'], kind: 'date' }, + { header: '记录类型*', key: 'recordType', width: 14, aliases: ['记录类型'] }, + { header: '内容*', key: 'content', width: 36, aliases: ['内容'] }, + { header: '跟进方式', key: 'followUpMethod', width: 14 }, + { header: '下一步计划', key: 'nextStep', width: 28 }, +]; + +function normalizeHeader(header: string): string { + return header.trim().replace(/\*+$/u, '').trim(); +} + +function getCellPrimitiveValue(cell: ExcelJS.Cell): unknown { + const value = cell.value; + if (value === null || value === undefined) return undefined; + if (value instanceof Date) return value; + if (typeof value === 'object') { + if ('result' in value) return value.result; + if ('text' in value) return value.text; + if ('richText' in value && Array.isArray(value.richText)) { + return value.richText.map((part) => part.text).join(''); + } + } + return value; +} + +function cellToText(cell: ExcelJS.Cell): string { + const value = getCellPrimitiveValue(cell); + if (value === null || value === undefined) return ''; + if (value instanceof Date) return formatDate(value); + return String(value).trim(); +} + +function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function excelSerialToDate(serial: number): string | undefined { + if (!Number.isFinite(serial) || serial < 1) return undefined; + const epoch = Date.UTC(1899, 11, 30); + return formatDate(new Date(epoch + serial * 24 * 60 * 60 * 1000)); +} + +function parseDateText(cell: ExcelJS.Cell): string | undefined { + const value = getCellPrimitiveValue(cell); + if (value instanceof Date) return formatDate(value); + if (typeof value === 'number') return excelSerialToDate(value); + const text = value === null || value === undefined ? '' : String(value).trim(); + if (!text) return undefined; + const normalized = text.replace(/[/.]/g, '-'); + const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/u); + if (!match) return text; + return `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`; +} + +function parseNumber(cell: ExcelJS.Cell): number | undefined { + const text = cellToText(cell).replace(/,/g, ''); + if (!text) return undefined; + const value = Number(text); + return Number.isFinite(value) ? value : undefined; +} + +function buildHeaderMap(ws: ExcelJS.Worksheet, columns: ColumnDef[]) { + const columnByHeader = new Map>(); + for (const column of columns) { + columnByHeader.set(normalizeHeader(column.header), column); + for (const alias of column.aliases || []) { + columnByHeader.set(normalizeHeader(alias), column); + } + } + + const headerIndex = new Map>(); + ws.getRow(1).eachCell((cell, colNumber) => { + const column = columnByHeader.get(normalizeHeader(cellToText(cell))); + if (column) headerIndex.set(colNumber, column); + }); + return headerIndex; +} + +function parseWorksheetRows( + ws: ExcelJS.Worksheet | undefined, + columns: ColumnDef[], +): T[] { + if (!ws) return []; + const headerIndex = buildHeaderMap(ws, columns); + const rows: T[] = []; + + ws.eachRow((row, idx) => { + if (idx === 1) return; + const parsed: Partial> = {}; + headerIndex.forEach((column, colNumber) => { + const cell = row.getCell(colNumber); + const value = + column.kind === 'date' + ? parseDateText(cell) + : column.kind === 'number' || column.kind === 'integer' + ? parseNumber(cell) + : cellToText(cell); + if (value !== undefined && value !== '') { + parsed[column.key] = column.kind === 'integer' && typeof value === 'number' + ? Math.trunc(value) + : value; + } + }); + + if (Object.keys(parsed).length > 0) rows.push(parsed as T); + }); + + return rows; +} + +function findWorksheet(workbook: ExcelJS.Workbook, names: string[], fallbackIndex?: number) { + for (const name of names) { + const sheet = workbook.getWorksheet(name); + if (sheet) return sheet; + } + return fallbackIndex === undefined ? undefined : workbook.worksheets[fallbackIndex]; +} + +export function parseStudentImportWorkbook(workbook: ExcelJS.Workbook): StudentWorkbookImport { + return { + students: parseWorksheetRows( + findWorksheet(workbook, ['学生基础+档案+录取', '学生导入模板', '学生名单'], 0), + STUDENT_IMPORT_COLUMNS, + ).map((row) => ({ ...row, name: row.name || '', phone: row.phone || '' })), + enrollments: parseWorksheetRows( + findWorksheet(workbook, ['报读班型']), + ENROLLMENT_IMPORT_COLUMNS, + ), + examScores: parseWorksheetRows( + findWorksheet(workbook, ['考试成绩']), + EXAM_SCORE_IMPORT_COLUMNS, + ), + learningRecords: parseWorksheetRows( + findWorksheet(workbook, ['课堂回访']), + LEARNING_RECORD_IMPORT_COLUMNS, + ), + }; +} + +function applyHeaderStyle(ws: ExcelJS.Worksheet) { + ws.getRow(1).font = { bold: true }; + ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; +} + +function setupWorksheet(workbook: ExcelJS.Workbook, name: string, columns: ColumnDef[]) { + const ws = workbook.addWorksheet(name); + ws.columns = columns.map(({ header, key, width }) => ({ header, key: String(key), width })); + applyHeaderStyle(ws); + return ws; +} + +export function createStudentImportTemplateWorkbook(): ExcelJS.Workbook { + const workbook = new ExcelJS.Workbook(); + setupWorksheet(workbook, '学生基础+档案+录取', STUDENT_IMPORT_COLUMNS).addRow({ + phone: '13800138000', + name: '张三', + studentNo: '2024001', + gender: '男', + idNumber: '11010120060101001X', + ethnicity: '汉族', + emergencyContact: '张父', + emergencyPhone: '13900139000', + organization: '北京校区', + supervisor: '李老师', + targetCollege: '北京大学', + targetMajor: '计算机科学与技术', + subjectDirection: '物化生', + grade: '高三', + profileDate: '2024-09-01', + notes: '学习态度积极,目标明确', + cultureFinalScore: 620, + professionalFinalScore: 580, + admissionStatus: 'pending', + admittedCollege: '', + admittedMajor: '', + }); + setupWorksheet(workbook, '报读班型', ENROLLMENT_IMPORT_COLUMNS); + setupWorksheet(workbook, '考试成绩', EXAM_SCORE_IMPORT_COLUMNS); + setupWorksheet(workbook, '课堂回访', LEARNING_RECORD_IMPORT_COLUMNS); + return workbook; +} diff --git a/apps/server/src/students/students.agent-api.spec.ts b/apps/server/src/students/students.agent-api.spec.ts index b83f023..03c779d 100644 --- a/apps/server/src/students/students.agent-api.spec.ts +++ b/apps/server/src/students/students.agent-api.spec.ts @@ -62,6 +62,11 @@ function makeService( {} as never, // attendanceRepo {} as never, // classTeacherRepo {} as never, // organizationRepo + {} as never, // profileRepo + {} as never, // enrollmentRepo + {} as never, // examScoreRepo + {} as never, // learningRecordRepo + {} as never, // resultRepo ); return { service, studentQb, classStudentQb }; diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index e45cb38..a3ed56f 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -30,127 +30,16 @@ import { RequirePermission } from '../auth/decorators/permission.decorator'; import { AuthorizationService, CaslAction, SubjectName } from '../authorization'; import type { AuthenticatedUser } from '../authorization'; import * as ExcelJS from 'exceljs'; +import { + createStudentImportTemplateWorkbook, + parseStudentImportWorkbook, + STUDENT_EXPORT_COLUMNS, +} from './student-import'; interface AuthenticatedRequest { user: AuthenticatedUser; } - -interface StudentImportRow { - name: string; - studentNo?: string; - phone?: string; - idNumber?: string; - gender?: string; - ethnicity?: string; - emergencyContact?: string; - emergencyPhone?: string; - organization?: string; - supervisor?: string; - organizationId?: number; -} - -const STUDENT_IMPORT_COLUMNS = [ - { header: '姓名', key: 'name', width: 15 }, - { header: '学号', key: 'studentNo', width: 15 }, - { header: '性别', key: 'gender', width: 8 }, - { header: '电话', key: 'phone', width: 18 }, - { header: '身份证号', key: 'idNumber', width: 22 }, - { header: '民族', key: 'ethnicity', width: 10 }, - { header: '紧急联系人', key: 'emergencyContact', width: 15 }, - { header: '紧急联系人电话', key: 'emergencyPhone', width: 18 }, - { header: '所属机构名称', key: 'organization', width: 18 }, - { header: '负责人/班主任', key: 'supervisor', width: 15 }, -]; - -const STUDENT_EXPORT_COLUMNS = [ - ...STUDENT_IMPORT_COLUMNS.map((column) => ({ - ...column, - header: column.key === 'organization' ? '所属机构' : column.header, - })), - { header: '状态', key: 'status', width: 10 }, -]; - -const STUDENT_IMPORT_HEADER_MAP: Record = { - 姓名: 'name', - 学号: 'studentNo', - 电话: 'phone', - 手机号: 'phone', - '学号/身份证': 'idNumber', - 身份证: 'idNumber', - 身份证号: 'idNumber', - 性别: 'gender', - 民族: 'ethnicity', - 紧急联系人: 'emergencyContact', - 紧急联系人电话: 'emergencyPhone', - 所属机构: 'organization', - 所属机构名称: 'organization', - 负责人: 'supervisor', - '负责人/班主任': 'supervisor', -}; - -function getExcelCellText(cell: ExcelJS.Cell): string { - const value = cell.value; - if (value === null || value === undefined) return ''; - if (typeof value === 'object') { - if ('text' in value) return String(value.text || ''); - if ('richText' in value && Array.isArray(value.richText)) { - return value.richText.map((part) => part.text).join(''); - } - if ('result' in value) return String(value.result || ''); - } - return String(value); -} - -function parseStudentImportRows(ws: ExcelJS.Worksheet): StudentImportRow[] { - const headerIndex = new Map(); - ws.getRow(1).eachCell((cell, colNumber) => { - const header = getExcelCellText(cell).trim(); - const field = STUDENT_IMPORT_HEADER_MAP[header]; - if (field) headerIndex.set(colNumber, field); - }); - - const rows: StudentImportRow[] = []; - ws.eachRow((row, idx) => { - if (idx === 1) return; - - const parsed: Partial = {}; - if (headerIndex.size > 0) { - headerIndex.forEach((field, colNumber) => { - const value = getExcelCellText(row.getCell(colNumber)).trim(); - if (value) { - Object.assign(parsed, { [field]: value }); - } - }); - } else { - parsed.name = getExcelCellText(row.getCell(1)).trim(); - parsed.studentNo = getExcelCellText(row.getCell(2)).trim() || undefined; - parsed.gender = getExcelCellText(row.getCell(3)).trim() || undefined; - parsed.phone = getExcelCellText(row.getCell(4)).trim() || undefined; - parsed.idNumber = getExcelCellText(row.getCell(5)).trim() || undefined; - parsed.ethnicity = getExcelCellText(row.getCell(6)).trim() || undefined; - parsed.emergencyContact = getExcelCellText(row.getCell(7)).trim() || undefined; - parsed.emergencyPhone = getExcelCellText(row.getCell(8)).trim() || undefined; - parsed.organization = getExcelCellText(row.getCell(9)).trim() || undefined; - parsed.supervisor = getExcelCellText(row.getCell(10)).trim() || undefined; - } - - rows.push({ - name: parsed.name || '', - studentNo: parsed.studentNo, - phone: parsed.phone, - idNumber: parsed.idNumber, - gender: parsed.gender, - ethnicity: parsed.ethnicity, - emergencyContact: parsed.emergencyContact, - emergencyPhone: parsed.emergencyPhone, - organization: parsed.organization, - supervisor: parsed.supervisor, - }); - }); - return rows; -} - @UseGuards(JwtAuthGuard) @Controller('students') export class StudentsController { @@ -218,25 +107,34 @@ export class StudentsController { ws.columns = STUDENT_EXPORT_COLUMNS; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; - const statusMap: Record = { - active: '在读', - graduated: '已毕业', - withdrawn: '已退训', - archived: '已归档', - }; + const { profiles, results } = await this.service.getArchiveExportMaps( + students.map((student) => student.id), + ); for (const s of students) { + const profile = profiles.get(s.id); + const result = results.get(s.id); ws.addRow({ + phone: s.phone || '', name: s.name, studentNo: s.studentNo || '', gender: s.gender || '', - phone: s.phone || '', idNumber: s.idNumber || '', ethnicity: s.ethnicity || '', emergencyContact: s.emergencyContact || '', emergencyPhone: s.emergencyPhone || '', organization: s.organization?.name || '', supervisor: s.supervisor || '', - status: statusMap[s.status] || s.status, + targetCollege: profile?.targetCollege || '', + targetMajor: profile?.targetMajor || '', + subjectDirection: profile?.subjectDirection || '', + grade: profile?.grade || '', + profileDate: profile?.profileDate || '', + notes: profile?.notes || '', + cultureFinalScore: result?.cultureFinalScore ?? '', + professionalFinalScore: result?.professionalFinalScore ?? '', + admissionStatus: result?.admissionStatus || '', + admittedCollege: result?.admittedCollege || '', + admittedMajor: result?.admittedMajor || '', }); } const { ipAddress, userAgent } = extractRequestInfo(req); @@ -261,23 +159,7 @@ export class StudentsController { @Get('template') @RequirePermission('student:view') async downloadTemplate(@Res() res: Response) { - const workbook = new ExcelJS.Workbook(); - const ws = workbook.addWorksheet('学生导入模板'); - ws.columns = STUDENT_IMPORT_COLUMNS; - ws.getRow(1).font = { bold: true }; - ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; - ws.addRow({ - name: '张三', - studentNo: '2024001', - gender: '男', - phone: '13800138000', - idNumber: '11010120060101001X', - ethnicity: '汉族', - emergencyContact: '张父', - emergencyPhone: '13900000000', - organization: 'XX教育公司', - supervisor: '', - }); + const workbook = createStudentImportTemplateWorkbook(); res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', @@ -391,10 +273,9 @@ export class StudentsController { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as any); - const ws = workbook.worksheets[0]; - const rows = parseStudentImportRows(ws); + const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs - for (const row of rows) { + for (const row of importData.students) { if (row.organization) { const organization = await this.organizationRepo.findOne({ where: { name: row.organization }, @@ -404,7 +285,7 @@ export class StudentsController { } } } - const result = await this.service.batchImport(rows); + const result = await this.service.batchImport(importData); await this.logService.log({ userId: req.user?.id, username: req.user?.username, @@ -424,10 +305,9 @@ export class StudentsController { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); - const ws = workbook.worksheets[0]; - const rows = parseStudentImportRows(ws); + const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs - for (const row of rows) { + for (const row of importData.students) { if (row.organization) { const organization = await this.organizationRepo.findOne({ where: { name: row.organization }, @@ -435,7 +315,7 @@ export class StudentsController { if (organization) row.organizationId = organization.id; } } - const result = await this.service.matchImport(rows); + const result = await this.service.matchImport(importData); await this.logService.log({ userId: req.user?.id, username: req.user?.username, diff --git a/apps/server/src/students/students.lifecycle.spec.ts b/apps/server/src/students/students.lifecycle.spec.ts index 7006344..ecd8d0f 100644 --- a/apps/server/src/students/students.lifecycle.spec.ts +++ b/apps/server/src/students/students.lifecycle.spec.ts @@ -2,6 +2,8 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { StudentsService } from './students.service'; function createService(repo: Record, organizationRepo = {}) { + const profileRepo = { find: jest.fn().mockResolvedValue([]) }; + const resultRepo = { find: jest.fn().mockResolvedValue([]) }; return new StudentsService( repo as never, {} as never, @@ -9,6 +11,11 @@ function createService(repo: Record, organizationRepo = {}) { {} as never, {} as never, organizationRepo as never, + profileRepo as never, + {} as never, + {} as never, + {} as never, + resultRepo as never, ); } @@ -40,4 +47,31 @@ describe('StudentsService — archive lifecycle boundaries', () => { const repo = { findOne: jest.fn().mockResolvedValue(null) }; await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException); }); + + it('builds export archive maps from profile and result rows', async () => { + const profileRepo = { + find: jest.fn().mockResolvedValue([{ studentId: 1, targetCollege: '北京大学' }]), + }; + const resultRepo = { + find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]), + }; + const service = new StudentsService( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + profileRepo as never, + {} as never, + {} as never, + {} as never, + resultRepo as never, + ); + + const maps = await service.getArchiveExportMaps([1]); + + expect(maps.profiles.get(1)?.targetCollege).toBe('北京大学'); + expect(maps.results.get(1)?.admissionStatus).toBe('pending'); + }); }); diff --git a/apps/server/src/students/students.module.ts b/apps/server/src/students/students.module.ts index 759fdcf..a1576b0 100644 --- a/apps/server/src/students/students.module.ts +++ b/apps/server/src/students/students.module.ts @@ -6,6 +6,11 @@ import { Organization } from '../entities/organization.entity'; import { ClassStudent } from '../entities/class-student.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; +import { StudentProfile } from '../entities/student-profile.entity'; +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { ExamScore } from '../entities/exam-score.entity'; +import { LearningRecord } from '../entities/learning-record.entity'; +import { ResultArchive } from '../entities/result-archive.entity'; import { StudentsService } from './students.service'; import { StudentAccessScopeFactory } from './student-access-scope.factory'; import { StudentsController } from './students.controller'; @@ -19,6 +24,11 @@ import { StudentsController } from './students.controller'; AttendanceRecord, Organization, ClassTeacher, + StudentProfile, + StudentEnrollment, + ExamScore, + LearningRecord, + ResultArchive, ]), ], controllers: [StudentsController], diff --git a/apps/server/src/students/students.scope.spec.ts b/apps/server/src/students/students.scope.spec.ts index 44c3751..6e4faf6 100644 --- a/apps/server/src/students/students.scope.spec.ts +++ b/apps/server/src/students/students.scope.spec.ts @@ -13,6 +13,11 @@ describe('StudentsService — teacher class scope', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, ); await service.findAll({}, [3, 5]); @@ -37,6 +42,11 @@ describe('StudentsService — teacher class scope', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, ); await expect(service.findAll({}, [])).resolves.toEqual([]); diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index f6c1211..a701f9d 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -7,7 +7,19 @@ import { ClassStudent } from '../entities/class-student.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { Organization } from '../entities/organization.entity'; +import { StudentProfile } from '../entities/student-profile.entity'; +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { ExamScore } from '../entities/exam-score.entity'; +import { LearningRecord } from '../entities/learning-record.entity'; +import { ResultArchive } from '../entities/result-archive.entity'; import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto'; +import type { + ExamScoreImportRow, + LearningRecordImportRow, + StudentEnrollmentImportRow, + StudentImportRow, + StudentWorkbookImport, +} from './student-import'; import type { StudentAccessScope } from './student-access-scope'; @Injectable() @@ -19,6 +31,11 @@ export class StudentsService { @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(Organization) private organizationRepo: Repository, + @InjectRepository(StudentProfile) private profileRepo: Repository, + @InjectRepository(StudentEnrollment) private enrollmentRepo: Repository, + @InjectRepository(ExamScore) private examScoreRepo: Repository, + @InjectRepository(LearningRecord) private learningRecordRepo: Repository, + @InjectRepository(ResultArchive) private resultRepo: Repository, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { @@ -35,6 +52,23 @@ export class StudentsService { }); } + async getArchiveExportMaps(studentIds: number[]) { + if (studentIds.length === 0) { + return { + profiles: new Map(), + results: new Map(), + }; + } + const [profiles, results] = await Promise.all([ + this.profileRepo.find({ where: { studentId: In(studentIds) } }), + this.resultRepo.find({ where: { studentId: In(studentIds) } }), + ]); + return { + profiles: new Map(profiles.map((profile) => [profile.studentId, profile])), + results: new Map(results.map((result) => [result.studentId, result])), + }; + } + async findAll( query?: { name?: string; @@ -185,24 +219,12 @@ export class StudentsService { return { message: '已恢复' }; } - async batchImport( - rows: { - name: string; - studentNo?: string; - phone?: string; - idNumber?: string; - gender?: string; - ethnicity?: string; - emergencyContact?: string; - emergencyPhone?: string; - organization?: string; - supervisor?: string; - organizationId?: number; - }[], - ) { + async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) { + const data = this.normalizeImportData(importData); let imported = 0; let skipped = 0; - for (const row of rows) { + let archiveImported = 0; + for (const row of data.students) { if (!row.name || !row.name.trim()) { skipped++; continue; @@ -212,7 +234,7 @@ export class StudentsService { skipped++; continue; } - await this.repo.save( + const student = await this.repo.save( this.repo.create({ name: row.name.trim(), studentNo: row.studentNo?.trim() || undefined, @@ -226,33 +248,23 @@ export class StudentsService { organizationId: row.organizationId || (await this.getHostOrganizationId()), }), ); + archiveImported += await this.importArchiveData(student.id, row, data); imported++; } return { - message: `成功导入 ${imported} 名学生,跳过 ${skipped} 条(重复或空行)`, + message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`, imported, + archiveImported, skipped, }; } - async matchImport( - rows: { - name: string; - studentNo?: string; - phone?: string; - idNumber?: string; - gender?: string; - ethnicity?: string; - emergencyContact?: string; - emergencyPhone?: string; - organization?: string; - supervisor?: string; - organizationId?: number; - }[], - ) { + async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) { + const data = this.normalizeImportData(importData); let matched = 0; let skipped = 0; - for (const row of rows) { + let archiveImported = 0; + for (const row of data.students) { // Match by phone first, then idNumber let student = row.phone?.trim() ? await this.repo.findOne({ where: { phone: row.phone.trim() } }) @@ -291,15 +303,187 @@ export class StudentsService { if (row.supervisor) updates.supervisor = row.supervisor; if (row.organizationId) updates.organizationId = row.organizationId; await this.repo.update(student.id, updates); + archiveImported += await this.importArchiveData(student.id, row, data); matched++; } return { - message: `更新已有学生资料 ${matched} 人,跳过 ${skipped} 条(无匹配)`, + message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`, matched, + archiveImported, skipped, }; } + private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport { + if (Array.isArray(importData)) { + return { students: importData, enrollments: [], examScores: [], learningRecords: [] }; + } + return importData; + } + + private normalizePhone(phone?: string) { + return phone?.trim() || ''; + } + + private sameValue(left?: string | number | null, right?: string | number | null) { + return String(left ?? '').trim() === String(right ?? '').trim(); + } + + private hasProfileData(row: StudentImportRow) { + return [ + row.targetCollege, + row.targetMajor, + row.subjectDirection, + row.grade, + row.profileDate, + row.notes, + ].some((value) => value !== undefined && String(value).trim() !== ''); + } + + private hasResultData(row: StudentImportRow) { + return [ + row.cultureFinalScore, + row.professionalFinalScore, + row.admissionStatus, + row.admittedCollege, + row.admittedMajor, + ].some((value) => value !== undefined && String(value).trim() !== ''); + } + + private async importArchiveData( + studentId: number, + row: StudentImportRow, + data: StudentWorkbookImport, + ) { + const phone = this.normalizePhone(row.phone); + let imported = 0; + if (this.hasProfileData(row)) { + await this.upsertProfileFromImport(studentId, row); + imported++; + } + if (this.hasResultData(row)) { + await this.upsertResultFromImport(studentId, row); + imported++; + } + if (!phone) return imported; + + const enrollmentByClassName = new Map(); + for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) { + const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); + if (!enrollment) continue; + if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); + imported++; + } + for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) { + if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { + imported++; + } + } + for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) { + if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { + imported++; + } + } + return imported; + } + + private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { + const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId }); + if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); + if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); + if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim(); + if (row.grade?.trim()) entity.grade = row.grade.trim(); + if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim(); + if (row.notes?.trim()) entity.notes = row.notes.trim(); + await this.profileRepo.save(entity); + } + + private async upsertResultFromImport(studentId: number, row: StudentImportRow) { + const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId }); + if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; + if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore; + if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); + if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); + if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); + await this.resultRepo.save(entity); + } + + private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) { + if (!row.courseCategory?.trim() || !row.classType?.trim()) { + return null; + } + const existing = await this.enrollmentRepo.find({ where: { studentId } }); + const entity = + existing.find( + (item) => + this.sameValue(item.courseCategory, row.courseCategory) && + this.sameValue(item.classType, row.classType) && + this.sameValue(item.className, row.className) && + this.sameValue(item.startDate, row.startDate), + ) || this.enrollmentRepo.create({ studentId }); + entity.courseCategory = row.courseCategory.trim(); + entity.classType = row.classType.trim(); + if (row.className?.trim()) entity.className = row.className.trim(); + if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim(); + if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim(); + if (row.startDate?.trim()) entity.startDate = row.startDate.trim(); + if (row.endDate?.trim()) entity.endDate = row.endDate.trim(); + if (row.status?.trim()) entity.status = row.status.trim(); + else if (!entity.status) entity.status = 'active'; + return this.enrollmentRepo.save(entity); + } + + private async upsertExamScoreFromImport( + studentId: number, + row: ExamScoreImportRow, + enrollmentByClassName: Map, + ) { + if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false; + const existing = await this.examScoreRepo.find({ where: { studentId } }); + const entity = + existing.find( + (item) => + this.sameValue(item.examType, row.examType) && + this.sameValue(item.examName, row.examName) && + this.sameValue(item.subject, row.subject) && + this.sameValue(item.examDate, row.examDate), + ) || this.examScoreRepo.create({ studentId }); + entity.examType = row.examType.trim(); + entity.subject = row.subject.trim(); + entity.score = row.score; + if (row.examName?.trim()) entity.examName = row.examName.trim(); + if (row.classAvg !== undefined) entity.classAvg = row.classAvg; + if (row.rank !== undefined) entity.rank = row.rank; + if (row.examDate?.trim()) entity.examDate = row.examDate.trim(); + if (row.enrollmentName?.trim()) { + const enrollment = enrollmentByClassName.get(row.enrollmentName.trim()); + if (enrollment) entity.enrollmentId = enrollment.id; + } + if (!entity.status) entity.status = 'active'; + await this.examScoreRepo.save(entity); + return true; + } + + private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) { + if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false; + const existing = await this.learningRecordRepo.find({ where: { studentId } }); + const entity = + existing.find( + (item) => + this.sameValue(item.recordDate, row.recordDate) && + this.sameValue(item.recordType, row.recordType) && + this.sameValue(item.content, row.content), + ) || this.learningRecordRepo.create({ studentId }); + entity.recordDate = row.recordDate.trim(); + entity.recordType = row.recordType.trim(); + entity.content = row.content.trim(); + if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim(); + if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim(); + if (!entity.status) entity.status = 'active'; + await this.learningRecordRepo.save(entity); + return true; + } + private async assertActiveOrganization(id: number) { const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } }); if (!organization) throw new BadRequestException('所属机构不存在或已归档'); -- 2.49.1 From 37ef6f9dd7ff7c92b2b1e330209cee750b781bb7 Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 21 Jul 2026 15:35:35 +0800 Subject: [PATCH 2/5] feat: add college archive fields --- .../StudentProfileContent/index.tsx | 20 +++++++++++++ .../scripts/generate-student-import-xlsx.ts | 2 ++ apps/server/src/archive/dto/archive.dto.ts | 2 ++ .../database/database-migrations.service.ts | 20 +++++++++++++ .../src/entities/student-profile.entity.ts | 6 ++++ .../migrations/1784520727860-InitialSchema.ts | 2 +- .../src/students/student-import.spec.ts | 30 ++++++++++++++++--- apps/server/src/students/student-import.ts | 6 ++++ .../src/students/students.controller.ts | 2 ++ .../src/students/students.lifecycle.spec.ts | 7 ++++- apps/server/src/students/students.service.ts | 4 +++ 11 files changed, 95 insertions(+), 6 deletions(-) diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 77b5971..15d0b55 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -59,6 +59,8 @@ interface StudentInfo { interface ProfileData { targetCollege?: string; targetMajor?: string; + collegeSchool?: string; + collegeMajor?: string; subjectDirection?: string; grade?: string; profileDate?: string; @@ -481,6 +483,24 @@ const InlineArchiveSummary: React.FC<{ {profile?.targetMajor || '-'} + + saveProfile('collegeSchool', next)} + > + {profile?.collegeSchool || '-'} + + + + saveProfile('collegeMajor', next)} + > + {profile?.collegeMajor || '-'} + + { await this.ensureAiConfigTable(); await this.ensureSyncStateLeaseColumns(); + await this.ensureStudentProfileCollegeColumns(); await this.ensureCourseAttendanceSchema(); await this.ensureAttendanceDevicesSchema(); await this.ensureStudentWalletSchema(); @@ -42,6 +43,25 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { } } + private async ensureStudentProfileCollegeColumns(): Promise { + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + try { + const table = await runner.getTable('student_profiles'); + if (!table) return; + const columns = new Set(table.columns.map((column) => column.name)); + const additions: Array<[string, string]> = [ + ['college_school', 'VARCHAR(100)'], + ['college_major', 'VARCHAR(100)'], + ]; + for (const [name, definition] of additions) { + if (!columns.has(name)) await runner.query(`ALTER TABLE student_profiles ADD COLUMN ${name} ${definition}`); + } + } finally { + await runner.release(); + } + } + private async ensureAttendanceDevicesSchema(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); diff --git a/apps/server/src/entities/student-profile.entity.ts b/apps/server/src/entities/student-profile.entity.ts index bac56a9..7b687b4 100644 --- a/apps/server/src/entities/student-profile.entity.ts +++ b/apps/server/src/entities/student-profile.entity.ts @@ -27,6 +27,12 @@ export class StudentProfile { @Column({ name: 'target_major', length: 100, nullable: true }) targetMajor: string; + @Column({ name: 'college_school', length: 100, nullable: true }) + collegeSchool: string; + + @Column({ name: 'college_major', length: 100, nullable: true }) + collegeMajor: string; + @Column({ name: 'subject_direction', length: 50, nullable: true }) subjectDirection: string; diff --git a/apps/server/src/migrations/1784520727860-InitialSchema.ts b/apps/server/src/migrations/1784520727860-InitialSchema.ts index eea4fb2..36cc656 100644 --- a/apps/server/src/migrations/1784520727860-InitialSchema.ts +++ b/apps/server/src/migrations/1784520727860-InitialSchema.ts @@ -21,7 +21,7 @@ export class InitialSchema1784520727860 implements MigrationInterface { await queryRunner.query(`CREATE TABLE \`bills\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`period_start\` date NOT NULL, \`period_end\` date NOT NULL, \`shared_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`personal_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`total_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`source\` varchar(30) NOT NULL DEFAULT 'batch', \`paid_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`outstanding_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`status\` varchar(20) NOT NULL DEFAULT 'unpaid', \`cancelled_at\` datetime NULL, \`cancel_reason\` varchar(300) NULL, \`generated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`students\` (\`id\` int NOT NULL AUTO_INCREMENT, \`name\` varchar(50) NOT NULL, \`student_no\` varchar(30) NULL, \`phone\` varchar(20) NULL, \`id_number\` varchar(30) NULL, \`gender\` varchar(10) NULL, \`ethnicity\` varchar(20) NULL, \`emergency_contact\` varchar(50) NULL, \`emergency_phone\` varchar(20) NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`supervisor\` varchar(50) NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`user_id\` int NULL, \`organization_id\` int NULL, UNIQUE INDEX \`IDX_fb3eff90b11bddf7285f9b4e28\` (\`user_id\`), UNIQUE INDEX \`REL_fb3eff90b11bddf7285f9b4e28\` (\`user_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`student_wallets\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`balance\` decimal(12,2) NOT NULL DEFAULT '0.00', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_07a434ad1a960d506386754d59\` (\`student_id\`), UNIQUE INDEX \`REL_07a434ad1a960d506386754d59\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); - await queryRunner.query(`CREATE TABLE \`student_profiles\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`target_college\` varchar(100) NULL, \`target_major\` varchar(100) NULL, \`subject_direction\` varchar(50) NULL, \`grade\` varchar(20) NULL, \`profile_date\` date NULL, \`notes\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_4cedc08d3dc1f2c2da8a12f7a8\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`student_profiles\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`target_college\` varchar(100) NULL, \`target_major\` varchar(100) NULL, \`college_school\` varchar(100) NULL, \`college_major\` varchar(100) NULL, \`subject_direction\` varchar(50) NULL, \`grade\` varchar(20) NULL, \`profile_date\` date NULL, \`notes\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_4cedc08d3dc1f2c2da8a12f7a8\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`student_enrollments\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`course_category\` varchar(50) NULL, \`class_type\` varchar(50) NULL, \`class_name\` varchar(100) NULL, \`head_teacher\` varchar(50) NULL, \`subject_teacher\` varchar(50) NULL, \`start_date\` date NULL, \`end_date\` date NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`student_ding_mapping\` (\`id\` int NOT NULL AUTO_INCREMENT, \`ding_user_id\` varchar(100) NOT NULL, \`student_id\` int NOT NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_0d1ec47e2f901d37e3b6e56331\` (\`ding_user_id\`), UNIQUE INDEX \`IDX_f9ba15ff04de8ffbd8679ae9db\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`result_archives\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`culture_final_score\` decimal(5,2) NULL, \`professional_final_score\` decimal(5,2) NULL, \`admission_status\` varchar(50) NULL, \`admitted_college\` varchar(100) NULL, \`admitted_major\` varchar(100) NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_377bba8eb6a027eecd9737d4ed\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); diff --git a/apps/server/src/students/student-import.spec.ts b/apps/server/src/students/student-import.spec.ts index 0de3fef..3b19471 100644 --- a/apps/server/src/students/student-import.spec.ts +++ b/apps/server/src/students/student-import.spec.ts @@ -16,10 +16,19 @@ describe('student import workbook', () => { '课堂回访', ]); expect(students?.rowCount).toBe(2); + const headerRow = students?.getRow(1); + const columnByHeader = new Map(); + headerRow?.eachCell((cell, colNumber) => columnByHeader.set(String(cell.value), colNumber)); expect(students?.getRow(2).getCell(1).value).toBe('13800138000'); - expect(students?.getRow(2).getCell(19).value).toBe('pending'); - expect(students?.getRow(2).getCell(20).value || '').toBe(''); - expect(students?.getRow(2).getCell(21).value || '').toBe(''); + expect(students?.getRow(2).getCell(columnByHeader.get('大专院校') || 0).value).toBe( + '北京职业技术学院', + ); + expect(students?.getRow(2).getCell(columnByHeader.get('大专专业') || 0).value).toBe( + '软件技术', + ); + expect(students?.getRow(2).getCell(columnByHeader.get('录取状态') || 0).value).toBe('pending'); + expect(students?.getRow(2).getCell(columnByHeader.get('录取院校') || 0).value || '').toBe(''); + expect(students?.getRow(2).getCell(columnByHeader.get('录取专业') || 0).value || '').toBe(''); expect(workbook.getWorksheet('报读班型')?.rowCount).toBe(1); expect(workbook.getWorksheet('考试成绩')?.rowCount).toBe(1); expect(workbook.getWorksheet('课堂回访')?.rowCount).toBe(1); @@ -32,11 +41,22 @@ describe('student import workbook', () => { '手机号*', '姓名*', '目标院校', + '大专院校', + '大专专业', '建档日期', '文化课最终分', '录取状态', ]); - students.addRow(['13800138000', '张三', '北京大学', '2024/9/1', 620, 'pending']); + students.addRow([ + '13800138000', + '张三', + '北京大学', + '北京职业技术学院', + '软件技术', + '2024/9/1', + 620, + 'pending', + ]); const enrollments = workbook.addWorksheet('报读班型'); enrollments.addRow(['手机号*', '课程类别*', '班型*', '班级名称']); @@ -57,6 +77,8 @@ describe('student import workbook', () => { phone: '13800138000', name: '张三', targetCollege: '北京大学', + collegeSchool: '北京职业技术学院', + collegeMajor: '软件技术', profileDate: '2024-09-01', cultureFinalScore: 620, admissionStatus: 'pending', diff --git a/apps/server/src/students/student-import.ts b/apps/server/src/students/student-import.ts index 44dbc0f..0962771 100644 --- a/apps/server/src/students/student-import.ts +++ b/apps/server/src/students/student-import.ts @@ -14,6 +14,8 @@ export interface StudentImportRow { organizationId?: number; targetCollege?: string; targetMajor?: string; + collegeSchool?: string; + collegeMajor?: string; subjectDirection?: string; grade?: string; profileDate?: string; @@ -89,6 +91,8 @@ export const STUDENT_IMPORT_COLUMNS: ColumnDef[] = [ { header: '负责人', key: 'supervisor', width: 15, aliases: ['负责人/班主任'] }, { header: '目标院校', key: 'targetCollege', width: 18 }, { header: '目标专业', key: 'targetMajor', width: 22 }, + { header: '大专院校', key: 'collegeSchool', width: 18 }, + { header: '大专专业', key: 'collegeMajor', width: 22 }, { header: '选科方向', key: 'subjectDirection', width: 14 }, { header: '年级', key: 'grade', width: 10 }, { header: '建档日期', key: 'profileDate', width: 14, kind: 'date' }, @@ -302,6 +306,8 @@ export function createStudentImportTemplateWorkbook(): ExcelJS.Workbook { supervisor: '李老师', targetCollege: '北京大学', targetMajor: '计算机科学与技术', + collegeSchool: '北京职业技术学院', + collegeMajor: '软件技术', subjectDirection: '物化生', grade: '高三', profileDate: '2024-09-01', diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index a3ed56f..96cdd61 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -126,6 +126,8 @@ export class StudentsController { supervisor: s.supervisor || '', targetCollege: profile?.targetCollege || '', targetMajor: profile?.targetMajor || '', + collegeSchool: profile?.collegeSchool || '', + collegeMajor: profile?.collegeMajor || '', subjectDirection: profile?.subjectDirection || '', grade: profile?.grade || '', profileDate: profile?.profileDate || '', diff --git a/apps/server/src/students/students.lifecycle.spec.ts b/apps/server/src/students/students.lifecycle.spec.ts index ecd8d0f..fbe7b11 100644 --- a/apps/server/src/students/students.lifecycle.spec.ts +++ b/apps/server/src/students/students.lifecycle.spec.ts @@ -50,7 +50,11 @@ describe('StudentsService — archive lifecycle boundaries', () => { it('builds export archive maps from profile and result rows', async () => { const profileRepo = { - find: jest.fn().mockResolvedValue([{ studentId: 1, targetCollege: '北京大学' }]), + find: jest.fn().mockResolvedValue([{ + studentId: 1, + targetCollege: '北京大学', + collegeSchool: '北京职业技术学院', + }]), }; const resultRepo = { find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]), @@ -72,6 +76,7 @@ describe('StudentsService — archive lifecycle boundaries', () => { const maps = await service.getArchiveExportMaps([1]); expect(maps.profiles.get(1)?.targetCollege).toBe('北京大学'); + expect(maps.profiles.get(1)?.collegeSchool).toBe('北京职业技术学院'); expect(maps.results.get(1)?.admissionStatus).toBe('pending'); }); }); diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index a701f9d..26a2279 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -333,6 +333,8 @@ export class StudentsService { return [ row.targetCollege, row.targetMajor, + row.collegeSchool, + row.collegeMajor, row.subjectDirection, row.grade, row.profileDate, @@ -391,6 +393,8 @@ export class StudentsService { const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId }); if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); + if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); + if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim(); if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim(); if (row.grade?.trim()) entity.grade = row.grade.trim(); if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim(); -- 2.49.1 From cbc04fea4fb6d05cf88055df84a2140ca068a226 Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 21 Jul 2026 16:04:17 +0800 Subject: [PATCH 3/5] feat: add exam score management --- apps/admin/src/App.tsx | 18 ++ .../src/auth/menu-policy.integration.test.ts | 2 + apps/admin/src/auth/menu-policy.ts | 1 + .../permission-navigation.integration.test.ts | 1 + apps/admin/src/auth/permission-navigation.ts | 5 + .../StudentProfileContent/index.tsx | 20 +- apps/admin/src/layouts/MainLayout.tsx | 2 + apps/admin/src/pages/Exams/ExamFormModal.tsx | 59 ++++++ apps/admin/src/pages/Exams/detail.tsx | 94 +++++++++ apps/admin/src/pages/Exams/index.tsx | 129 +++++++++++++ apps/admin/src/pages/Exams/style.css | 77 ++++++++ apps/admin/src/pages/Exams/types.ts | 37 ++++ apps/server/src/app.module.ts | 7 +- .../src/archive/archive-report.service.ts | 12 +- .../src/archive/archive.boundaries.spec.ts | 16 ++ apps/server/src/archive/archive.service.ts | 8 +- apps/server/src/entities/exam-score.entity.ts | 14 +- apps/server/src/entities/exam.entity.ts | 49 +++++ apps/server/src/entities/index.ts | 1 + apps/server/src/exams/dto/exam.dto.ts | 29 +++ apps/server/src/exams/exams.controller.ts | 104 ++++++++++ apps/server/src/exams/exams.module.ts | 16 ++ apps/server/src/exams/exams.service.spec.ts | 127 ++++++++++++ apps/server/src/exams/exams.service.ts | 182 ++++++++++++++++++ apps/server/src/migration-runner.ts | 3 +- .../1784600000000-AddExamManagement.ts | 62 ++++++ apps/server/src/rbac/rbac.service.ts | 2 + 27 files changed, 1061 insertions(+), 16 deletions(-) create mode 100644 apps/admin/src/pages/Exams/ExamFormModal.tsx create mode 100644 apps/admin/src/pages/Exams/detail.tsx create mode 100644 apps/admin/src/pages/Exams/index.tsx create mode 100644 apps/admin/src/pages/Exams/style.css create mode 100644 apps/admin/src/pages/Exams/types.ts create mode 100644 apps/server/src/entities/exam.entity.ts create mode 100644 apps/server/src/exams/dto/exam.dto.ts create mode 100644 apps/server/src/exams/exams.controller.ts create mode 100644 apps/server/src/exams/exams.module.ts create mode 100644 apps/server/src/exams/exams.service.spec.ts create mode 100644 apps/server/src/exams/exams.service.ts create mode 100644 apps/server/src/migrations/1784600000000-AddExamManagement.ts diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 4163de4..28881d2 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -24,6 +24,8 @@ const TeachersPage = lazy(() => import('./pages/Teachers')); const StudentProfilePage = lazy(() => import('./pages/StudentProfile')); const ClassesPage = lazy(() => import('./pages/Classes')); const ClassDetailPage = lazy(() => import('./pages/Classes/detail')); +const ExamsPage = lazy(() => import('./pages/Exams')); +const ExamDetailPage = lazy(() => import('./pages/Exams/detail')); const OrganizationsPage = lazy(() => import('./pages/Organizations')); const ClassroomRentalsPage = lazy(() => import('./pages/ClassroomRentals')); const ClassroomSchedulePage = lazy(() => import('./pages/ClassroomSchedule')); @@ -174,6 +176,22 @@ const App: React.FC = () => { } /> + + + + } + /> + + + + } + /> { expect(menu.map((item) => item.label)).toEqual(['数据面板', '教务管理', '通知中心']); expect(paths.filter((path) => path === '/schedules')).toHaveLength(1); expect(paths.filter((path) => path === '/attendance')).toHaveLength(1); + expect(paths).toContain('/exams'); expect(paths).not.toContain('/teacher-workspace'); }); diff --git a/apps/admin/src/auth/menu-policy.ts b/apps/admin/src/auth/menu-policy.ts index aa990b7..ceff6c5 100644 --- a/apps/admin/src/auth/menu-policy.ts +++ b/apps/admin/src/auth/menu-policy.ts @@ -61,6 +61,7 @@ const SECTIONS: MenuSection[] = [ children: [ { key: '/students', label: '学生管理', icon: 'students', permission: 'student:view' }, { key: '/classes', label: '班级管理', icon: 'classes', permission: 'class:view' }, + { key: '/exams', label: '考试管理', icon: 'exam', permission: 'exam:view' }, { key: '/teachers', label: '教师管理', icon: 'teachers', permission: 'teacher:view' }, { key: '/schedules', label: '排课管理', icon: 'calendar', permission: 'schedule:view' }, { key: '/attendance', label: '历史考勤', icon: 'attendance', permission: 'attendance:view' }, diff --git a/apps/admin/src/auth/permission-navigation.integration.test.ts b/apps/admin/src/auth/permission-navigation.integration.test.ts index 16882b8..2f77014 100644 --- a/apps/admin/src/auth/permission-navigation.integration.test.ts +++ b/apps/admin/src/auth/permission-navigation.integration.test.ts @@ -30,6 +30,7 @@ describe('permission navigation', () => { it('keeps route permission lookup aligned for nested detail routes', () => { expect(getRequiredPermission('/classes/12')).toBe('class:view'); expect(getRequiredPermission('/students/8/profile')).toBe('student:view'); + expect(getRequiredPermission('/exams/8')).toBe('exam:view'); expect(canAccessPath('/ai-config', ['ai:config:read'])).toBe(true); expect(canAccessPath('/ai-config', ['integration:read'])).toBe(false); }); diff --git a/apps/admin/src/auth/permission-navigation.ts b/apps/admin/src/auth/permission-navigation.ts index 6b09f63..5fe9b41 100644 --- a/apps/admin/src/auth/permission-navigation.ts +++ b/apps/admin/src/auth/permission-navigation.ts @@ -24,6 +24,11 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [ permission: 'class:view', matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p), }, + { + path: '/exams', + permission: 'exam:view', + matches: (p) => p === '/exams' || /^\/exams\/\d+$/.test(p), + }, { path: '/attendance', permission: 'attendance:view' }, { path: '/schedules', permission: 'schedule:view' }, { path: '/classroom-schedule', permission: 'rental:view' }, diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 15d0b55..e86223b 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -81,10 +81,12 @@ interface EnrollmentRecord { interface ExamScoreRecord { id: number; + examId?: number; + exam?: { class?: { name?: string } }; examType: string; examName?: string; subject: string; - score: number; + score: number | null; classAvg?: number; rank?: number; examDate?: string; @@ -865,6 +867,7 @@ const ExamScoresTab: React.FC< editor="select" options={EXAM_TYPE_OPTIONS} permission="student:edit" + disabled={!!r.examId} required onSave={(next) => saveCell(r, 'examType', next)} > @@ -879,6 +882,7 @@ const ExamScoresTab: React.FC< saveCell(r, 'examName', next)} > {v || '-'} @@ -893,6 +897,7 @@ const ExamScoresTab: React.FC< value={v} required permission="student:edit" + disabled={!!r.examId} onSave={(next) => saveCell(r, 'subject', next)} > {v} @@ -902,16 +907,16 @@ const ExamScoresTab: React.FC< { title: '成绩', dataIndex: 'score', - render: (v: number, r) => ( + render: (v: number | null, r) => ( saveCell(r, 'score', next)} > - {v} + {v ?? '-'} ), }, @@ -924,6 +929,7 @@ const ExamScoresTab: React.FC< editor="number" min={0} permission="student:edit" + disabled={!!r.examId} onSave={(next) => saveCell(r, 'classAvg', next)} > {v !== undefined ? v : '-'} @@ -939,6 +945,7 @@ const ExamScoresTab: React.FC< editor="number" min={1} permission="student:edit" + disabled={!!r.examId} onSave={(next) => saveCell(r, 'rank', next)} > {v !== undefined ? v : '-'} @@ -953,6 +960,7 @@ const ExamScoresTab: React.FC< value={v} editor="date" permission="student:edit" + disabled={!!r.examId} onSave={(next) => saveCell(r, 'examDate', next)} > {v || '-'} @@ -971,9 +979,11 @@ const ExamScoresTab: React.FC< 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); diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 54c5e33..495e6cb 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -25,6 +25,7 @@ import { CheckCircleOutlined, LaptopOutlined, BellOutlined, + TrophyOutlined, ApiOutlined, RobotOutlined, } from '@ant-design/icons'; @@ -46,6 +47,7 @@ const iconMap: Record = { students: , classes: , teachers: , + exam: , home: , overview: , occupancy: , diff --git a/apps/admin/src/pages/Exams/ExamFormModal.tsx b/apps/admin/src/pages/Exams/ExamFormModal.tsx new file mode 100644 index 0000000..6f60b64 --- /dev/null +++ b/apps/admin/src/pages/Exams/ExamFormModal.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { DatePicker, Form, Input, Modal, Select } from 'antd'; +import type { FormInstance } from 'antd'; +import type { ClassOption, ExamFormValues } from './types'; +import { EXAM_TYPE_OPTIONS } from './types'; + +interface Props { + open: boolean; + editing: boolean; + saving: boolean; + form: FormInstance; + classes: ClassOption[]; + onCancel: () => void; + onSubmit: () => void; +} + +const ExamFormModal: React.FC = ({ + open, + editing, + saving, + form, + classes, + onCancel, + onSubmit, +}) => ( + +
+ + + + + + + + + + + setKeyword(event.target.value)} prefix={} placeholder="搜索考试名称" allowClear /> + + + + + + + + {data.length === 0 && !loading ? ( +
+ ) : ( + + {data.map((exam) => { + const percent = exam.totalStudents === 0 ? 0 : Math.round((exam.enteredScores / exam.totalStudents) * 100); + return ( + + {exam.examType}{exam.examName}} + extra={成绩录入} + actions={[ + navigate(`/exams/${exam.id}`)}>查看成绩, + ]} + > +
科目{exam.subject}
+
班级{exam.className}
+
日期{exam.examDate}
+
成绩录入{exam.enteredScores}/{exam.totalStudents}
+
+ + ); + })} +
+ )} + + setModalOpen(false)} onSubmit={() => void submit()} /> + + ); +}; + +export default ExamsPage; diff --git a/apps/admin/src/pages/Exams/style.css b/apps/admin/src/pages/Exams/style.css new file mode 100644 index 0000000..3428cc3 --- /dev/null +++ b/apps/admin/src/pages/Exams/style.css @@ -0,0 +1,77 @@ +.exam-page, +.exam-detail-page { + display: flex; + flex-direction: column; + gap: 16px; +} + +.exam-toolbar, +.exam-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.exam-detail-header h2 { + margin: 0; + font-size: 20px; + letter-spacing: 0; +} + +.exam-card { + height: 100%; + border-radius: 8px; +} + +.exam-card .ant-card-head-title { + min-width: 0; +} + +.exam-card .ant-card-head-title > .ant-space { + max-width: 100%; +} + +.exam-card .ant-card-head-title span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.exam-meta, +.exam-progress > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 10px; +} + +.exam-meta span, +.exam-progress span { + color: rgba(0, 0, 0, 0.55); +} + +.exam-progress { + margin-top: 16px; +} + +.exam-empty, +.exam-detail-loading { + min-height: 360px; + display: grid; + place-items: center; +} + +.exam-summary { + border-radius: 8px; +} + +@media (max-width: 575px) { + .exam-toolbar > .ant-space, + .exam-toolbar .ant-input-affix-wrapper, + .exam-toolbar .ant-select { + width: 100% !important; + } +} diff --git a/apps/admin/src/pages/Exams/types.ts b/apps/admin/src/pages/Exams/types.ts new file mode 100644 index 0000000..f2e94b2 --- /dev/null +++ b/apps/admin/src/pages/Exams/types.ts @@ -0,0 +1,37 @@ +import type dayjs from 'dayjs'; + +export interface ExamItem { + id: number; + examType: string; + examName: string; + subject: string; + examDate: string; + classId: number; + className: string; + status: 'active' | 'archived'; + totalStudents: number; + enteredScores: number; +} + +export interface ExamFormValues { + examType: string; + examName: string; + subject: string; + examDate: dayjs.Dayjs; + classId: number; +} + +export interface ClassOption { + id: number; + name: string; + isArchived: boolean; +} + +export const EXAM_TYPE_OPTIONS = [ + { value: '月考', label: '月考' }, + { value: '周测', label: '周测' }, + { value: '期中考试', label: '期中考试' }, + { value: '期末考试', label: '期末考试' }, + { value: '模拟考试', label: '模拟考试' }, + { value: '入学测试', label: '入学测试' }, +]; diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index a09f1b6..2c3efe9 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -39,6 +39,7 @@ import { StudentProfile, StudentEnrollment, ExamScore, + Exam, LearningRecord, ExpenseType, ResultArchive, @@ -51,7 +52,8 @@ import { } from './entities'; import { AuthModule } from './auth/auth.module'; import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema'; -const allMigrations = [InitialSchema1784520727860]; +import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement'; +const allMigrations = [InitialSchema1784520727860, AddExamManagement1784600000000]; import { AuthorizationModule } from './authorization'; import { RbacModule } from './rbac/rbac.module'; import { StudentsModule } from './students/students.module'; @@ -81,6 +83,7 @@ import { AgentToolsModule } from './agent-tools'; import { AiConfigModule } from './ai-config/ai-config.module'; import { WalletsModule } from './wallets/wallets.module'; import { FinancialOperationsModule } from './financial-operations/financial-operations.module'; +import { ExamsModule } from './exams/exams.module'; import { IntegrationConfig, @@ -137,6 +140,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; StudentProfile, StudentEnrollment, ExamScore, + Exam, LearningRecord, ExpenseType, ArchiveAttachment, @@ -178,6 +182,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; AuthModule, RbacModule, StudentsModule, + ExamsModule, RoomsModule, OccupanciesModule, ExpensesModule, diff --git a/apps/server/src/archive/archive-report.service.ts b/apps/server/src/archive/archive-report.service.ts index e493754..312ca0a 100644 --- a/apps/server/src/archive/archive-report.service.ts +++ b/apps/server/src/archive/archive-report.service.ts @@ -412,11 +412,13 @@ ${this.buildLearningAndResult(learnings, result, now)} const highestName = highestExam?.examName ?? '-'; // Improvement: last exam score minus first exam score - const sortedExams = [...cultureExams].filter((e) => e.score != null); + const sortedScores = cultureExams + .map((exam) => exam.score) + .filter((score): score is number => score !== null && score !== undefined); let improvement = '—'; - if (sortedExams.length >= 2) { - const first = sortedExams[0].score; - const last = sortedExams[sortedExams.length - 1].score; + if (sortedScores.length >= 2) { + const first = sortedScores[0]; + const last = sortedScores[sortedScores.length - 1]; improvement = (last - first).toFixed(1); } @@ -511,7 +513,7 @@ ${this.buildLearningAndResult(learnings, result, now)} const cultureExams = exams.filter((e) => e.score != null); if (cultureExams.length === 0) return ''; - const scores = cultureExams.map((e) => e.score); + 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; diff --git a/apps/server/src/archive/archive.boundaries.spec.ts b/apps/server/src/archive/archive.boundaries.spec.ts index 13ed5e7..e4b3fd3 100644 --- a/apps/server/src/archive/archive.boundaries.spec.ts +++ b/apps/server/src/archive/archive.boundaries.spec.ts @@ -67,6 +67,22 @@ describe('ArchiveService — resource and relationship boundaries', () => { expect(exam.save).not.toHaveBeenCalled(); }); + it('keeps exam-management scores read-only in the student archive', async () => { + const exam = { + findOne: jest.fn().mockResolvedValue({ id: 3, studentId: 7, examId: 8 }), + save: jest.fn(), + update: jest.fn(), + }; + const service = createService({ exam }); + + await expect(service.updateExamScore(3, { score: 95 })).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect(service.deleteExamScore(3)).rejects.toBeInstanceOf(BadRequestException); + expect(exam.save).not.toHaveBeenCalled(); + expect(exam.update).not.toHaveBeenCalled(); + }); + it('rejects a missing attachment upload before writing to disk', async () => { const service = createService({ student: { findOne: jest.fn() } }); await expect(service.addAttachment(7, undefined as never, 'other')).rejects.toBeInstanceOf( diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index f1140c2..b6eb6cd 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -75,7 +75,11 @@ export class ArchiveService { ] = await Promise.all([ this.profileRepo.findOne({ where: { studentId } }), this.enrollmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }), - this.examScoreRepo.find({ where: { studentId, status: 'active' }, order: { examDate: 'DESC' } }), + this.examScoreRepo.find({ + where: { studentId, status: 'active' }, + relations: ['exam', 'exam.class'], + order: { examDate: 'DESC' }, + }), this.learningRecordRepo.find({ where: { studentId, status: 'active' }, order: { recordDate: 'DESC' } }), this.resultRepo.findOne({ where: { studentId } }), this.attachmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }), @@ -154,6 +158,7 @@ export class ArchiveService { async updateExamScore(id: number, dto: UpdateExamScoreDto) { const entity = await this.examScoreRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('考试成绩不存在'); + if (entity.examId) throw new BadRequestException('考试管理同步成绩请在考试管理中修改'); await this.assertEnrollmentBelongsToStudent(entity.studentId, dto.enrollmentId); Object.assign(entity, dto); return this.examScoreRepo.save(entity); @@ -162,6 +167,7 @@ export class ArchiveService { async deleteExamScore(id: number) { const entity = await this.examScoreRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('考试成绩不存在'); + if (entity.examId) throw new BadRequestException('考试管理同步成绩不能在学生档案中归档'); if (entity.status === 'archived') throw new BadRequestException('考试成绩已归档'); await this.examScoreRepo.update(id, { status: 'archived' }); return { message: '已归档' }; diff --git a/apps/server/src/entities/exam-score.entity.ts b/apps/server/src/entities/exam-score.entity.ts index 4a88ecf..4a6e3e9 100644 --- a/apps/server/src/entities/exam-score.entity.ts +++ b/apps/server/src/entities/exam-score.entity.ts @@ -9,12 +9,20 @@ import { } from 'typeorm'; import { Student } from './student.entity'; import { StudentEnrollment } from './student-enrollment.entity'; +import { Exam } from './exam.entity'; @Entity('exam_scores') export class ExamScore { @PrimaryGeneratedColumn() id: number; + @Column({ name: 'exam_id', type: 'integer', nullable: true }) + examId: number | null; + + @ManyToOne(() => Exam, (exam) => exam.scores, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'exam_id' }) + exam: Exam | null; + @Column({ name: 'student_id', type: 'integer' }) studentId: number; @@ -39,13 +47,13 @@ export class ExamScore { subject: string; @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) - score: number; + score: number | null; @Column({ name: 'class_avg', type: 'decimal', precision: 5, scale: 2, nullable: true }) - classAvg: number; + classAvg: number | null; @Column({ type: 'integer', nullable: true }) - rank: number; + rank: number | null; @Column({ name: 'exam_date', type: 'date', nullable: true }) examDate: string; diff --git a/apps/server/src/entities/exam.entity.ts b/apps/server/src/entities/exam.entity.ts new file mode 100644 index 0000000..47de60d --- /dev/null +++ b/apps/server/src/entities/exam.entity.ts @@ -0,0 +1,49 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Class } from './class.entity'; +import { ExamScore } from './exam-score.entity'; + +@Entity('exams') +export class Exam { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'exam_type', length: 50 }) + examType: string; + + @Column({ name: 'exam_name', length: 100 }) + examName: string; + + @Column({ length: 50 }) + subject: string; + + @Column({ name: 'exam_date', type: 'date' }) + examDate: string; + + @Column({ name: 'class_id', type: 'integer' }) + classId: number; + + @ManyToOne(() => Class) + @JoinColumn({ name: 'class_id' }) + class: Class; + + @OneToMany(() => ExamScore, (score) => score.exam) + scores: ExamScore[]; + + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: 'active' | 'archived'; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index 8b5cba5..9e85eae 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -32,6 +32,7 @@ export { Notification, NotificationType } from './notification.entity'; export { StudentProfile } from './student-profile.entity'; export { StudentEnrollment } from './student-enrollment.entity'; export { ExamScore } from './exam-score.entity'; +export { Exam } from './exam.entity'; export { LearningRecord } from './learning-record.entity'; export { ResultArchive } from './result-archive.entity'; export { ArchiveAttachment } from './archive-attachment.entity'; diff --git a/apps/server/src/exams/dto/exam.dto.ts b/apps/server/src/exams/dto/exam.dto.ts new file mode 100644 index 0000000..5747a9b --- /dev/null +++ b/apps/server/src/exams/dto/exam.dto.ts @@ -0,0 +1,29 @@ +import { Type } from 'class-transformer'; +import { + IsDateString, + IsInt, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export class CreateExamDto { + @IsString() @IsNotEmpty() examType: string; + @IsString() @IsNotEmpty() examName: string; + @IsString() @IsNotEmpty() subject: string; + @IsDateString() examDate: string; + @IsInt() @Min(1) classId: number; +} + +export class QueryExamDto { + @IsOptional() @IsString() keyword?: string; + @IsOptional() @IsString() examType?: string; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) classId?: number; +} + +export class UpdateExamScoreValueDto { + @IsOptional() @IsNumber() @Min(0) @Max(999.99) score?: number | null; +} diff --git a/apps/server/src/exams/exams.controller.ts b/apps/server/src/exams/exams.controller.ts new file mode 100644 index 0000000..aae912f --- /dev/null +++ b/apps/server/src/exams/exams.controller.ts @@ -0,0 +1,104 @@ +import { + Body, + Controller, + Get, + Param, + ParseIntPipe, + Post, + Put, + Query, + Request, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequirePermission } from '../auth/decorators/permission.decorator'; +import { extractRequestInfo } from '../common/request-utils'; +import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import type { AuthenticatedUser } from '../authorization'; +import { CreateExamDto, QueryExamDto, UpdateExamScoreValueDto } from './dto/exam.dto'; +import { ExamsService } from './exams.service'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + ip?: string; + headers?: Record; +} + +@UseGuards(JwtAuthGuard) +@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) +@Controller('exams') +export class ExamsController { + constructor( + private readonly service: ExamsService, + private readonly logService: OperationLogsService, + ) {} + + private canManageAll(req: AuthenticatedRequest) { + return req.user.isSuperAdmin || req.user.permissions.includes('exam:edit'); + } + + @Get() + @RequirePermission('exam:view') + async findAll(@Query() query: QueryExamDto, @Request() req: AuthenticatedRequest) { + const classIds = await this.service.getAccessibleClassIds(req.user.id, this.canManageAll(req)); + return this.service.findAll(query, classIds); + } + + @Get(':id') + @RequirePermission('exam:view') + findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + return this.service.findOne(id, req.user.id, this.canManageAll(req)); + } + + @Post() + @RequirePermission('exam:view') + async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) { + const result = await this.service.create(dto, req.user.id, this.canManageAll(req)); + const { ipAddress, userAgent } = extractRequestInfo(req); + await this.logService.log({ + userId: req.user.id, + username: req.user.username, + module: '考试管理', + action: '创建考试', + targetId: result.id, + targetType: 'exam', + detail: `${dto.examName} - ${dto.subject}`, + ipAddress, + userAgent, + }); + return result; + } + + @Put(':examId/scores/:scoreId') + @RequirePermission('exam:view') + async updateScore( + @Param('examId', ParseIntPipe) examId: number, + @Param('scoreId', ParseIntPipe) scoreId: number, + @Body() dto: UpdateExamScoreValueDto, + @Request() req: AuthenticatedRequest, + ) { + const result = await this.service.updateScore( + examId, + scoreId, + dto.score, + req.user.id, + this.canManageAll(req), + ); + const { ipAddress, userAgent } = extractRequestInfo(req); + await this.logService.log({ + userId: req.user.id, + username: req.user.username, + module: '考试管理', + action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩', + targetId: scoreId, + targetType: 'exam_score', + detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩:${dto.score}`, + ipAddress, + userAgent, + }); + return result; + } + +} diff --git a/apps/server/src/exams/exams.module.ts b/apps/server/src/exams/exams.module.ts new file mode 100644 index 0000000..b48ba04 --- /dev/null +++ b/apps/server/src/exams/exams.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Class, ClassStudent, ClassTeacher, Exam, ExamScore, Student } from '../entities'; +import { OperationLogsModule } from '../operation-logs/operation-logs.module'; +import { ExamsController } from './exams.controller'; +import { ExamsService } from './exams.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Exam, ExamScore, Class, ClassStudent, ClassTeacher, Student]), + OperationLogsModule, + ], + controllers: [ExamsController], + providers: [ExamsService], +}) +export class ExamsModule {} diff --git a/apps/server/src/exams/exams.service.spec.ts b/apps/server/src/exams/exams.service.spec.ts new file mode 100644 index 0000000..2547127 --- /dev/null +++ b/apps/server/src/exams/exams.service.spec.ts @@ -0,0 +1,127 @@ +import { BadRequestException } from '@nestjs/common'; +import { ExamScore } from '../entities'; +import { ExamsService } from './exams.service'; + +function createService(transaction: (run: (manager: any) => Promise) => Promise) { + return new ExamsService( + {} as never, + {} as never, + {} as never, + {} as never, + { findOne: jest.fn().mockResolvedValue({ id: 1 }) } as never, + { transaction } as never, + ); +} + +describe('ExamsService', () => { + it('creates score rows from the active class roster snapshot', async () => { + const members = [ + { studentId: 11, status: 'active' }, + { studentId: 12, status: 'active' }, + ]; + const manager = { + findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }), + find: jest.fn().mockResolvedValue(members), + create: jest.fn((_entity, value) => value), + save: jest.fn(async (entity, value) => + entity.name === 'Exam' ? { ...value, id: 9 } : value, + ), + }; + const service = createService(async (run) => run(manager)); + + await service.create( + { + examType: '月考', + examName: '七月月考', + subject: '数学', + examDate: '2026-07-21', + classId: 3, + }, + 1, + true, + ); + + expect(manager.find).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ where: { classId: 3, status: 'active' } }), + ); + expect(manager.save).toHaveBeenCalledWith( + ExamScore, + expect.arrayContaining([ + expect.objectContaining({ examId: 9, studentId: 11, score: null }), + expect.objectContaining({ examId: 9, studentId: 12, score: null }), + ]), + ); + }); + + it('rejects creating an exam for an empty class', async () => { + const manager = { + findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }), + find: jest.fn().mockResolvedValue([]), + }; + const service = createService(async (run) => run(manager)); + + await expect( + service.create( + { + examType: '月考', + examName: '七月月考', + subject: '数学', + examDate: '2026-07-21', + classId: 3, + }, + 1, + true, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('counts zero, ignores empty scores, and uses competition ranking', async () => { + const rows = [ + { id: 1, score: 90, classAvg: null, rank: null }, + { id: 2, score: 90, classAvg: null, rank: null }, + { id: 3, score: 60, classAvg: null, rank: null }, + { id: 4, score: 0, classAvg: null, rank: null }, + { id: 5, score: null, classAvg: null, rank: null }, + ]; + const manager = { + findOne: jest + .fn() + .mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' }) + .mockResolvedValueOnce(rows[0]) + .mockResolvedValueOnce(rows[0]), + find: jest.fn().mockResolvedValue(rows), + save: jest.fn(async (_entity, value) => value), + }; + const service = createService(async (run) => run(manager)); + + await service.updateScore(8, 1, 90, 1, true); + + expect(rows.map((row) => row.rank)).toEqual([1, 1, 3, 4, null]); + expect(rows.map((row) => row.classAvg)).toEqual([60, 60, 60, 60, 60]); + }); + + it('clears a score and recalculates the remaining rows', async () => { + const rows = [ + { id: 1, score: 90, classAvg: 80, rank: 1 }, + { id: 2, score: 70, classAvg: 80, rank: 2 }, + ]; + const manager = { + findOne: jest + .fn() + .mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' }) + .mockResolvedValueOnce(rows[0]) + .mockResolvedValueOnce(rows[0]), + find: jest.fn().mockResolvedValue(rows), + save: jest.fn(async (_entity, value) => value), + }; + const service = createService(async (run) => run(manager)); + + await service.updateScore(8, 1, null, 1, true); + + expect(rows).toEqual([ + expect.objectContaining({ score: null, classAvg: 70, rank: null }), + expect.objectContaining({ score: 70, classAvg: 70, rank: 1 }), + ]); + }); +}); diff --git a/apps/server/src/exams/exams.service.ts b/apps/server/src/exams/exams.service.ts new file mode 100644 index 0000000..aaa3aef --- /dev/null +++ b/apps/server/src/exams/exams.service.ts @@ -0,0 +1,182 @@ +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, EntityManager, In, Like, Repository } from 'typeorm'; +import { Class, ClassStudent, ClassTeacher, Exam, ExamScore } from '../entities'; +import { CreateExamDto, QueryExamDto } from './dto/exam.dto'; + +@Injectable() +export class ExamsService { + constructor( + @InjectRepository(Exam) private examRepo: Repository, + @InjectRepository(ExamScore) private scoreRepo: Repository, + @InjectRepository(Class) private classRepo: Repository, + @InjectRepository(ClassStudent) private classStudentRepo: Repository, + @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, + private dataSource: DataSource, + ) {} + + async getAccessibleClassIds(userId: number, canManageAll: boolean) { + if (canManageAll) return undefined; + const rows = await this.classTeacherRepo.find({ where: { userId } }); + return [...new Set(rows.map((row) => row.classId))]; + } + + async assertClassAccess(userId: number, classId: number, canManageAll: boolean) { + if (canManageAll) return; + const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } }); + if (!assignment) throw new ForbiddenException('只能访问自己被分配的班级'); + } + + async findAll(query: QueryExamDto, accessibleClassIds?: number[]) { + const where: Record = { + status: 'active', + }; + if (query.keyword) where.examName = Like(`%${query.keyword}%`); + if (query.examType) where.examType = query.examType; + if (query.classId) where.classId = query.classId; + if (accessibleClassIds) { + if (accessibleClassIds.length === 0) return []; + where.classId = query.classId + ? accessibleClassIds.includes(query.classId) + ? query.classId + : -1 + : In(accessibleClassIds); + } + + const exams = await this.examRepo.find({ + where, + relations: ['class'], + order: { examDate: 'DESC', createdAt: 'DESC' }, + }); + if (exams.length === 0) return []; + const scoreRows = await this.scoreRepo.find({ + where: { examId: In(exams.map((exam) => exam.id)), status: 'active' }, + }); + const progress = new Map(); + for (const row of scoreRows) { + const item = progress.get(row.examId!) ?? { total: 0, entered: 0 }; + item.total++; + if (row.score !== null && row.score !== undefined) item.entered++; + progress.set(row.examId!, item); + } + return exams.map((exam) => ({ + ...exam, + className: exam.class?.name, + totalStudents: progress.get(exam.id)?.total ?? 0, + enteredScores: progress.get(exam.id)?.entered ?? 0, + })); + } + + async findOne(id: number, userId: number, canManageAll: boolean) { + const exam = await this.examRepo.findOne({ where: { id }, relations: ['class'] }); + if (!exam) throw new NotFoundException('考试不存在'); + await this.assertClassAccess(userId, exam.classId, canManageAll); + const scores = await this.scoreRepo.find({ + where: { examId: id, status: 'active' }, + relations: ['student'], + order: { id: 'ASC' }, + }); + return { + ...exam, + className: exam.class?.name, + totalStudents: scores.length, + enteredScores: scores.filter((row) => row.score !== null && row.score !== undefined).length, + scores: scores.map((row) => ({ + id: row.id, + studentId: row.studentId, + phone: row.student?.phone, + name: row.student?.name, + score: row.score === null ? null : Number(row.score), + classAvg: row.classAvg === null ? null : Number(row.classAvg), + rank: row.rank, + })), + }; + } + + async create(dto: CreateExamDto, userId: number, canManageAll: boolean) { + await this.assertClassAccess(userId, dto.classId, canManageAll); + return this.dataSource.transaction(async (manager) => { + const cls = await manager.findOne(Class, { where: { id: dto.classId } }); + if (!cls) throw new NotFoundException('班级不存在'); + if (cls.isArchived) throw new BadRequestException('归档班级不能创建考试'); + const members = await manager.find(ClassStudent, { + where: { classId: dto.classId, status: 'active' }, + order: { createdAt: 'ASC' }, + }); + if (members.length === 0) throw new BadRequestException('班级暂无在读学员,不能创建考试'); + const exam = await manager.save(Exam, manager.create(Exam, { ...dto, status: 'active' })); + await this.createScoreRows(manager, exam, members); + return exam; + }); + } + + async updateScore( + examId: number, + scoreId: number, + score: number | null | undefined, + userId: number, + canManageAll: boolean, + ) { + return this.dataSource.transaction(async (manager) => { + const exam = await manager.findOne(Exam, { where: { id: examId } }); + if (!exam) throw new NotFoundException('考试不存在'); + await this.assertClassAccess(userId, exam.classId, canManageAll); + if (exam.status === 'archived') throw new BadRequestException('已归档考试不能录入成绩'); + const row = await manager.findOne(ExamScore, { where: { id: scoreId, examId } }); + if (!row) throw new NotFoundException('成绩记录不存在'); + row.score = score === undefined ? null : score; + await manager.save(ExamScore, row); + await this.recalculate(manager, examId); + return manager.findOne(ExamScore, { where: { id: scoreId } }); + }); + } + + private async createScoreRows(manager: EntityManager, exam: Exam, members: ClassStudent[]) { + const rows = members.map((member) => + manager.create(ExamScore, { + examId: exam.id, + studentId: member.studentId, + examType: exam.examType, + examName: exam.examName, + subject: exam.subject, + score: null, + classAvg: null, + rank: null, + examDate: exam.examDate, + status: 'active', + }), + ); + await manager.save(ExamScore, rows); + } + + private async recalculate(manager: EntityManager, examId: number) { + const rows = await manager.find(ExamScore, { where: { examId, status: 'active' } }); + const entered = rows + .filter((row) => row.score !== null && row.score !== undefined) + .sort((a, b) => Number(b.score) - Number(a.score)); + const average = + entered.length === 0 + ? null + : Math.round((entered.reduce((sum, row) => sum + Number(row.score), 0) / entered.length) * 100) / + 100; + let previousScore: number | null = null; + let previousRank = 0; + for (let index = 0; index < entered.length; index++) { + const row = entered[index]; + const value = Number(row.score); + const rank = previousScore !== null && value === previousScore ? previousRank : index + 1; + row.classAvg = average; + row.rank = rank; + previousScore = value; + previousRank = rank; + } + const enteredIds = new Set(entered.map((row) => row.id)); + for (const row of rows) { + if (!enteredIds.has(row.id)) { + row.classAvg = average; + row.rank = null; + } + } + await manager.save(ExamScore, rows); + } +} diff --git a/apps/server/src/migration-runner.ts b/apps/server/src/migration-runner.ts index e0ff8d7..e8e6bb3 100644 --- a/apps/server/src/migration-runner.ts +++ b/apps/server/src/migration-runner.ts @@ -1,5 +1,6 @@ import { DataSource } from 'typeorm'; import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema'; +import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement'; import { config } from 'dotenv'; config(); @@ -18,7 +19,7 @@ export async function runMigrationsOnStartup(): Promise { password: process.env.DB_PASSWORD || '', database: process.env.DB_DATABASE || 'dorm_billing', charset: 'utf8mb4', - migrations: [InitialSchema1784520727860], + migrations: [InitialSchema1784520727860, AddExamManagement1784600000000], }); await ds.initialize(); diff --git a/apps/server/src/migrations/1784600000000-AddExamManagement.ts b/apps/server/src/migrations/1784600000000-AddExamManagement.ts new file mode 100644 index 0000000..b4cf48d --- /dev/null +++ b/apps/server/src/migrations/1784600000000-AddExamManagement.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn, TableForeignKey, TableIndex } from 'typeorm'; + +export class AddExamManagement1784600000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasTable('exams'))) { + await queryRunner.createTable( + new Table({ + name: 'exams', + columns: [ + { name: 'id', type: 'integer', isPrimary: true, isGenerated: true, generationStrategy: 'increment' }, + { name: 'exam_type', type: 'varchar', length: '50' }, + { name: 'exam_name', type: 'varchar', length: '100' }, + { name: 'subject', type: 'varchar', length: '50' }, + { name: 'exam_date', type: 'date' }, + { name: 'class_id', type: 'integer' }, + { name: 'status', type: 'varchar', length: '20', default: "'active'" }, + { name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + ], + }), + ); + await queryRunner.createForeignKey( + 'exams', + new TableForeignKey({ + columnNames: ['class_id'], + referencedTableName: 'classes', + referencedColumnNames: ['id'], + }), + ); + await queryRunner.createIndex('exams', new TableIndex({ columnNames: ['class_id', 'status'] })); + } + + if (!(await queryRunner.hasColumn('exam_scores', 'exam_id'))) { + await queryRunner.addColumn( + 'exam_scores', + new TableColumn({ name: 'exam_id', type: 'integer', isNullable: true }), + ); + await queryRunner.createForeignKey( + 'exam_scores', + new TableForeignKey({ + columnNames: ['exam_id'], + referencedTableName: 'exams', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createIndex('exam_scores', new TableIndex({ columnNames: ['exam_id'] })); + } + } + + async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasColumn('exam_scores', 'exam_id')) { + const table = await queryRunner.getTable('exam_scores'); + const foreignKey = table?.foreignKeys.find((key) => key.columnNames.includes('exam_id')); + if (foreignKey) await queryRunner.dropForeignKey('exam_scores', foreignKey); + const index = table?.indices.find((item) => item.columnNames.includes('exam_id')); + if (index) await queryRunner.dropIndex('exam_scores', index); + await queryRunner.dropColumn('exam_scores', 'exam_id'); + } + if (await queryRunner.hasTable('exams')) await queryRunner.dropTable('exams'); + } +} diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index a76dd6a..a814e61 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -26,6 +26,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = { code: 'student:delete', name: '归档学生', group: 'student' }, { code: 'student:import', name: '导入学生', group: 'student' }, { code: 'student:export', name: '导出学生', group: 'student' }, + { code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' }, { code: 'room:view', name: '查看宿舍', group: 'room' }, { code: 'room:create', name: '新增宿舍', group: 'room' }, { code: 'room:edit', name: '编辑宿舍', group: 'room' }, @@ -203,6 +204,7 @@ export const PRESET_ROLES: Array<{ isSystem: true, permissionGroups: [ 'student', + 'exam', 'class', 'schedule', 'attendance', -- 2.49.1 From 04082820b0ffda59123a7b28c49440edfb62807b Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 21 Jul 2026 17:31:44 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20CI=20=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=EF=BC=8C=E5=90=88=E5=B9=B6=20lint=E3=80=81?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=A3=80=E6=9F=A5=E5=92=8C=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 67 ++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ab73130..97d2033 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -7,34 +7,53 @@ on: push: branches: [main] +# 同一分支有新提交时取消旧任务,避免旧任务持续占用 Gitea runner。 +concurrency: + group: ci-${{ gitea.ref }} + cancel-in-progress: true + jobs: - lint: + verify: runs-on: ubuntu-latest + timeout-minutes: 30 + env: + CI: 'true' + TURBO_UI: stream steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' - - run: npm ci - - run: npm run lint + - name: 检出代码 + uses: actions/checkout@v4 + timeout-minutes: 3 - typecheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: 安装 Node.js + uses: actions/setup-node@v4 + timeout-minutes: 3 with: node-version: '22' - - run: npm ci - - run: npm run typecheck + cache: npm - test: - runs-on: ubuntu-latest - needs: [lint, typecheck] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' - - run: npm ci - - run: npm run test + - name: 安装依赖 + run: npm ci --no-audit --no-fund + timeout-minutes: 10 + + - name: 代码检查 + # 后端原 lint 脚本带 --fix;CI 中追加 --no-fix,避免检查时修改工作区。 + run: | + npm run lint -w @gongxue/admin + npm run lint -w @gongxue/server -- --no-fix + timeout-minutes: 5 + + - name: 类型检查 + run: npm run typecheck + timeout-minutes: 8 + + - name: 后端测试 + run: npm run test -w @gongxue/server -- --runInBand --forceExit + timeout-minutes: 10 + + - name: 安装 Chromium + run: npx playwright install --with-deps chromium + timeout-minutes: 10 + + - name: 前端测试 + run: npm run test -w @gongxue/admin + timeout-minutes: 10 -- 2.49.1 From 4fdf190d694503889da496b4e2d69895a28e5f2a Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 21 Jul 2026 17:35:16 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20CI=20=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=EF=BC=8C=E7=AE=80=E5=8C=96=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=E5=B9=B6=E6=9B=B4=E6=96=B0=E7=8E=AF=E5=A2=83=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 65 +++++++++++++++-------------------------- 1 file changed, 24 insertions(+), 41 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 97d2033..43537d8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,59 +1,42 @@ -# PR 自动检查:代码风格 + 类型检查 + 测试 -name: CI 检查 +name: CI + +env: + NPM_CONFIG_REGISTRY: https://registry.npmmirror.com + NPM_CONFIG_AUDIT: "false" + NPM_CONFIG_FUND: "false" + NPM_CONFIG_PREFER_OFFLINE: "true" + NPM_CONFIG_REPLACE_REGISTRY_HOST: always + CI: "true" + TURBO_UI: stream on: pull_request: branches: [main] - push: - branches: [main] - -# 同一分支有新提交时取消旧任务,避免旧任务持续占用 Gitea runner。 -concurrency: - group: ci-${{ gitea.ref }} - cancel-in-progress: true + workflow_dispatch: jobs: - verify: + check: runs-on: ubuntu-latest + container: node:20.20.2-bookworm timeout-minutes: 30 - env: - CI: 'true' - TURBO_UI: stream + steps: - - name: 检出代码 - uses: actions/checkout@v4 - timeout-minutes: 3 + - name: Checkout + uses: https://gitee.com/mirrors_actions/checkout@v4 - - name: 安装 Node.js - uses: actions/setup-node@v4 - timeout-minutes: 3 - with: - node-version: '22' - cache: npm + - name: Install dependencies + run: npm ci - - name: 安装依赖 - run: npm ci --no-audit --no-fund - timeout-minutes: 10 - - - name: 代码检查 - # 后端原 lint 脚本带 --fix;CI 中追加 --no-fix,避免检查时修改工作区。 + - name: Lint run: | npm run lint -w @gongxue/admin npm run lint -w @gongxue/server -- --no-fix - timeout-minutes: 5 - - name: 类型检查 + - name: Type check run: npm run typecheck - timeout-minutes: 8 - - name: 后端测试 + - name: Build frontend + run: npm run build -w @gongxue/admin + + - name: Run backend tests run: npm run test -w @gongxue/server -- --runInBand --forceExit - timeout-minutes: 10 - - - name: 安装 Chromium - run: npx playwright install --with-deps chromium - timeout-minutes: 10 - - - name: 前端测试 - run: npm run test -w @gongxue/admin - timeout-minutes: 10 -- 2.49.1