feat: complete student archive subsystem — 7 entities, PDF report, frontend profile page

This commit is contained in:
2026-07-06 15:58:36 +08:00
parent b6990dd11b
commit 8bd59b8ac6
15 changed files with 1539 additions and 9 deletions

View File

@@ -0,0 +1,263 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as PDFDocument from 'pdfkit';
import { Response } from 'express';
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 { AttendanceRecord } from '../entities/attendance-record.entity';
import { Student } from '../entities/student.entity';
@Injectable()
export class ArchiveReportService {
constructor(
@InjectRepository(StudentProfile) private profileRepo: Repository<StudentProfile>,
@InjectRepository(StudentEnrollment) private enrollmentRepo: Repository<StudentEnrollment>,
@InjectRepository(ExamScore) private examRepo: Repository<ExamScore>,
@InjectRepository(LearningRecord) private learningRepo: Repository<LearningRecord>,
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
) {}
async generateReport(studentId: number, res: Response) {
const [student, profile, enrollments, exams, learnings, result, attendances] = await Promise.all([
this.studentRepo.findOne({ where: { id: studentId } }),
this.profileRepo.findOne({ where: { studentId } }),
this.enrollmentRepo.find({ where: { studentId }, order: { startDate: 'ASC' } }),
this.examRepo.find({ where: { studentId }, order: { examDate: 'ASC' } }),
this.learningRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
this.resultRepo.findOne({ where: { studentId } }),
this.attendanceRepo.find({ where: { studentId }, order: { attendanceDate: 'ASC' } }),
]);
if (!student) throw new Error('学生不存在');
const doc = new PDFDocument({ size: 'A4', margin: 40 });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename=student_report_${studentId}.pdf`);
doc.pipe(res);
this.renderCover(doc, student, profile, enrollments);
doc.addPage();
this.renderBasicInfo(doc, student, profile);
this.renderEnrollmentComparison(doc, enrollments);
doc.addPage();
this.renderExamScores(doc, exams);
doc.addPage();
this.renderAttendance(doc, attendances);
doc.addPage();
this.renderLearningRecords(doc, learnings);
if (result) this.renderResult(doc, result);
doc.end();
}
private renderCover(
doc,
student: Student,
profile: StudentProfile | null,
enrollments: StudentEnrollment[],
) {
doc.fontSize(24).text('学生档案报告', { align: 'center' });
doc.moveDown(2);
doc.fontSize(16).text(student.name, { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).text(`学号: ${student.studentNo || '-'}`, { align: 'center' });
doc.moveDown(0.3);
doc.fontSize(10).text(`身份证号: ${student.idNumber || '-'}`, { align: 'center' });
doc.moveDown(1);
if (profile) {
doc.fontSize(12).text(`科类方向: ${profile.subjectDirection || '-'}`);
doc.text(`目标院校: ${profile.targetCollege || '-'}`);
doc.text(`目标专业: ${profile.targetMajor || '-'}`);
doc.text(`建档日期: ${profile.profileDate || '-'}`);
}
doc.moveDown(1);
const types = enrollments.map((e) => e.classType).filter(Boolean);
if (types.length > 0) {
doc.fontSize(12).text(`报读班型: ${types.join(' / ')}`);
}
}
private renderBasicInfo(
doc,
student: Student,
profile: StudentProfile | null,
) {
doc.fontSize(16).text('基础信息', { underline: true });
doc.moveDown(0.5);
const rows = [
['姓名', student.name, '性别', student.gender || '-'],
['电话', student.phone || '-', '民族', student.ethnicity || '-'],
['紧急联系人', student.emergencyContact || '-', '紧急电话', student.emergencyPhone || '-'],
['校区', profile?.campusLocation || '-', '年级', profile?.grade || '-'],
];
this.renderTable(doc, rows, [100, 150, 100, 150]);
}
private renderEnrollmentComparison(
doc,
enrollments: StudentEnrollment[],
) {
doc.moveDown(1);
doc.fontSize(16).text('报读记录', { underline: true });
doc.moveDown(0.5);
if (enrollments.length === 0) {
doc.fontSize(10).text('暂无报读记录');
return;
}
if (enrollments.length >= 2) {
doc.fontSize(12).text('多班型对比', { underline: true });
doc.moveDown(0.3);
const headers = ['项目', ...enrollments.map((_, i) => `班型${i + 1}`)];
const rows = [
['课程类别', ...enrollments.map((e) => e.courseCategory || '-')],
['班型', ...enrollments.map((e) => e.classType || '-')],
['班级', ...enrollments.map((e) => e.className || '-')],
['班主任', ...enrollments.map((e) => e.headTeacher || '-')],
['任课老师', ...enrollments.map((e) => e.subjectTeacher || '-')],
['开班', ...enrollments.map((e) => e.startDate || '-')],
['结课', ...enrollments.map((e) => e.endDate || '-')],
];
const colWidths = [
80,
...enrollments.map(() => (doc.page.width - 120) / enrollments.length),
];
this.renderTable(doc, rows, colWidths, headers);
} else {
const e = enrollments[0];
const rows = [
['课程类别', e.courseCategory || '-'],
['班型', e.classType || '-'],
['班级', e.className || '-'],
['班主任', e.headTeacher || '-'],
['任课老师', e.subjectTeacher || '-'],
['开班日期', e.startDate || '-'],
['结课日期', e.endDate || '-'],
];
this.renderTable(doc, rows, [120, 200]);
}
}
private renderExamScores(doc, exams: ExamScore[]) {
doc.fontSize(16).text('考试成绩', { underline: true });
doc.moveDown(0.5);
if (exams.length === 0) {
doc.fontSize(10).text('暂无考试成绩');
return;
}
const headers = ['类型', '名称', '科目', '分数', '班均', '排名', '日期'];
const rows = exams.map((e) => [
e.examType,
e.examName || '-',
e.subject,
String(e.score ?? '-'),
e.classAvg != null ? String(e.classAvg) : '-',
e.rank != null ? String(e.rank) : '-',
e.examDate || '-',
]);
this.renderTable(doc, rows, [60, 80, 80, 50, 50, 50, 80], headers);
}
private renderAttendance(doc, records: AttendanceRecord[]) {
doc.fontSize(16).text('出勤记录', { underline: true });
doc.moveDown(0.5);
if (records.length === 0) {
doc.fontSize(10).text('暂无出勤记录');
return;
}
const present = records.filter((r) => r.status === 'present').length;
const absent = records.filter((r) => r.status === 'absent').length;
const late = records.filter((r) => r.status === 'late').length;
const leave = records.filter((r) => r.status === 'leave').length;
const total = records.length;
doc.fontSize(10).text(
`总计: ${total} 次 | 出勤: ${present} | 缺勤: ${absent} | 迟到: ${late} | 请假: ${leave}`,
);
doc.text(`出勤率: ${total > 0 ? ((present / total) * 100).toFixed(1) : 0}%`);
}
private renderLearningRecords(doc, records: LearningRecord[]) {
doc.fontSize(16).text('学情记录', { underline: true });
doc.moveDown(0.5);
if (records.length === 0) {
doc.fontSize(10).text('暂无学情记录');
return;
}
for (const r of records.slice(0, 20)) {
doc.fontSize(10).text(`${r.recordDate || '-'} [${r.recordType}]`);
doc.fontSize(9).text(` ${(r.content || '').slice(0, 200)}`);
if (r.followUpMethod) doc.text(` 跟进: ${r.followUpMethod}`);
doc.moveDown(0.2);
}
}
private renderResult(doc, result: ResultArchive) {
doc.moveDown(1);
doc.fontSize(16).text('录取归档', { underline: true });
doc.moveDown(0.5);
const rows = [
['文化课成绩', result.cultureFinalScore != null ? String(result.cultureFinalScore) : '-'],
[
'专业课成绩',
result.professionalFinalScore != null ? String(result.professionalFinalScore) : '-',
],
['录取状态', result.admissionStatus || '-'],
['录取院校', result.admittedCollege || '-'],
['录取专业', result.admittedMajor || '-'],
];
this.renderTable(doc, rows, [120, 200]);
}
private renderTable(
doc,
rows: string[][],
colWidths: number[],
headers?: string[],
) {
const startX = doc.x;
const lineHeight = 18;
if (headers) {
doc.font('Helvetica-Bold').fontSize(9);
let x = startX;
for (let i = 0; i < headers.length; i++) {
doc.text(headers[i], x, doc.y, { width: colWidths[i], lineBreak: false });
x += colWidths[i];
}
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(8);
for (const row of rows) {
let x = startX;
const maxH = Math.max(
...row.map((cell, i) => doc.heightOfString(cell || '', { width: colWidths[i] })),
);
for (let i = 0; i < row.length && i < colWidths.length; i++) {
doc.text(row[i] || '-', x, doc.y, { width: colWidths[i], lineBreak: false });
x += colWidths[i];
}
doc.moveDown(maxH / 14);
if (doc.y > doc.page.height - 60) {
doc.addPage();
}
}
}
}

View File

@@ -8,11 +8,13 @@ import {
Param,
UseGuards,
Request,
Res,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Request as ExpressRequest } from 'express';
import type { Request as ExpressRequest, Response } from 'express';
import { ArchiveReportService } from './archive-report.service';
import { ArchiveService } from './archive.service';
import {
UpsertProfileDto,
@@ -36,6 +38,7 @@ export class ArchiveController {
constructor(
private readonly archiveService: ArchiveService,
private readonly logService: OperationLogsService,
private readonly reportService: ArchiveReportService,
) {}
@Get(':studentId')
@@ -339,11 +342,10 @@ export class ArchiveController {
@Get(':studentId/report')
@RequirePermission('student:view')
async generateReport(@Param('studentId') studentId: string) {
return {
studentId: +studentId,
message: '学生档案报告功能开发中',
generatedAt: new Date().toISOString(),
};
async generateReport(
@Param('studentId') studentId: string,
@Res() res: Response,
) {
return this.reportService.generateReport(+studentId, res);
}
}

View File

@@ -7,9 +7,11 @@ import { ExamScore } from '../entities/exam-score.entity';
import { LearningRecord } from '../entities/learning-record.entity';
import { ResultArchive } from '../entities/result-archive.entity';
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { CommonModule } from '../common/common.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { ArchiveService } from './archive.service';
import { ArchiveReportService } from './archive-report.service';
import { ArchiveController } from './archive.controller';
@Module({
@@ -22,12 +24,13 @@ import { ArchiveController } from './archive.controller';
LearningRecord,
ResultArchive,
ArchiveAttachment,
AttendanceRecord,
]),
CommonModule,
NotificationsModule,
],
controllers: [ArchiveController],
providers: [ArchiveService],
providers: [ArchiveService, ArchiveReportService],
exports: [ArchiveService],
})
export class ArchiveModule {}

View File

@@ -207,3 +207,4 @@ export class ArchiveService {
return { message: '已删除' };
}
}

View File

@@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('archive_attachments')
export class ArchiveAttachment {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'student_id', type: 'integer' })
studentId: number;
@ManyToOne(() => Student, { eager: true })
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ length: 50, nullable: true })
category: string;
@Column({ name: 'file_name', length: 255, nullable: true })
fileName: string;
@Column({ name: 'file_path', length: 500, nullable: true })
filePath: string;
@Column({ name: 'file_size', type: 'integer', nullable: true })
fileSize: number;
@Column({ name: 'mime_type', length: 100, nullable: true })
mimeType: string;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,66 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { StudentEnrollment } from './student-enrollment.entity';
import { Department } from './department.entity';
@Entity('exam_scores')
export class ExamScore {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'student_id', type: 'integer' })
studentId: number;
@ManyToOne(() => Student, { eager: true })
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ name: 'enrollment_id', type: 'integer', nullable: true })
enrollmentId: number;
@ManyToOne(() => StudentEnrollment, { nullable: true })
@JoinColumn({ name: 'enrollment_id' })
enrollment: StudentEnrollment;
@Column({ name: 'exam_type', length: 50, nullable: true })
examType: string;
@Column({ name: 'exam_name', length: 100, nullable: true })
examName: string;
@Column({ length: 50, nullable: true })
subject: string;
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
score: number;
@Column({ name: 'class_avg', type: 'decimal', precision: 5, scale: 2, nullable: true })
classAvg: number;
@Column({ type: 'integer', nullable: true })
rank: number;
@Column({ name: 'exam_date', type: 'date', nullable: true })
examDate: string;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -26,3 +26,10 @@ export { ExpenseType } from './expense-type.entity';
export { Notification, NotificationType } from './notification.entity';
export { Department, DepartmentType } from './department.entity';
export { UserDepartment } from './user-department.entity';
export { StudentProfile } from './student-profile.entity';
export { StudentEnrollment } from './student-enrollment.entity';
export { ExamScore } from './exam-score.entity';
export { LearningRecord } from './learning-record.entity';
export { ResultArchive } from './result-archive.entity';
export { ArchiveAttachment } from './archive-attachment.entity';
export { StudentReport } from './student-report.entity';

View File

@@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('learning_records')
export class LearningRecord {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'student_id', type: 'integer' })
studentId: number;
@ManyToOne(() => Student, { eager: true })
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ name: 'record_date', type: 'date', nullable: true })
recordDate: string;
@Column({ name: 'record_type', length: 50, nullable: true })
recordType: string;
@Column({ type: 'text', nullable: true })
content: string;
@Column({ name: 'follow_up_method', length: 50, nullable: true })
followUpMethod: string;
@Column({ name: 'next_step', type: 'text', nullable: true })
nextStep: string;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('result_archives')
export class ResultArchive {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'student_id', type: 'integer', unique: true })
studentId: number;
@ManyToOne(() => Student, { eager: true })
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ name: 'culture_final_score', type: 'decimal', precision: 5, scale: 2, nullable: true })
cultureFinalScore: number;
@Column({ name: 'professional_final_score', type: 'decimal', precision: 5, scale: 2, nullable: true })
professionalFinalScore: number;
@Column({ name: 'admission_status', length: 50, nullable: true })
admissionStatus: string;
@Column({ name: 'admitted_college', length: 100, nullable: true })
admittedCollege: string;
@Column({ name: 'admitted_major', length: 100, nullable: true })
admittedMajor: string;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,61 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('student_enrollments')
export class StudentEnrollment {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'student_id', type: 'integer' })
studentId: number;
@ManyToOne(() => Student, { eager: true })
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ name: 'course_category', length: 50, nullable: true })
courseCategory: string;
@Column({ name: 'class_type', length: 50, nullable: true })
classType: string;
@Column({ name: 'class_name', length: 100, nullable: true })
className: string;
@Column({ name: 'head_teacher', length: 50, nullable: true })
headTeacher: string;
@Column({ name: 'subject_teacher', length: 50, nullable: true })
subjectTeacher: string;
@Column({ name: 'start_date', type: 'date', nullable: true })
startDate: string;
@Column({ name: 'end_date', type: 'date', nullable: true })
endDate: string;
@Column({ length: 20, default: 'active' })
status: string;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,58 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('student_profiles')
export class StudentProfile {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'student_id', type: 'integer', unique: true })
studentId: number;
@ManyToOne(() => Student, { eager: true })
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ name: 'target_college', length: 100, nullable: true })
targetCollege: string;
@Column({ name: 'target_major', length: 100, nullable: true })
targetMajor: string;
@Column({ name: 'subject_direction', length: 50, nullable: true })
subjectDirection: string;
@Column({ length: 20, nullable: true })
grade: string;
@Column({ name: 'campus_location', length: 100, nullable: true })
campusLocation: string;
@Column({ name: 'profile_date', type: 'date', nullable: true })
profileDate: string;
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,49 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('student_reports')
export class StudentReport {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'student_id', type: 'integer' })
studentId: number;
@ManyToOne(() => Student, { eager: true })
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ name: 'snapshot_data', type: 'simple-json', nullable: true })
snapshotData: Record<string, unknown>;
@Column({ name: 'html_content', type: 'text', nullable: true })
htmlContent: string;
@Column({ name: 'pdf_path', length: 500, nullable: true })
pdfPath: string;
@Column({ name: 'generated_at', type: 'datetime', nullable: true })
generatedAt: Date;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}