diff --git a/apps/server/package.json b/apps/server/package.json index d40c28b..91ffe4e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -44,6 +44,7 @@ "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pdfkit": "^0.18.0", + "puppeteer": "^25.3.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.28" diff --git a/apps/server/src/archive/archive-report.service.ts b/apps/server/src/archive/archive-report.service.ts index 43e70d4..a5721fb 100644 --- a/apps/server/src/archive/archive-report.service.ts +++ b/apps/server/src/archive/archive-report.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import * as PDFDocument from 'pdfkit'; +import puppeteer from 'puppeteer'; import { Response } from 'express'; import { StudentProfile } from '../entities/student-profile.entity'; import { StudentEnrollment } from '../entities/student-enrollment.entity'; @@ -11,6 +11,16 @@ import { ResultArchive } from '../entities/result-archive.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { Student } from '../entities/student.entity'; +interface ReportData { + student: Student; + profile: StudentProfile | null; + enrollments: StudentEnrollment[]; + exams: ExamScore[]; + learnings: LearningRecord[]; + result: ResultArchive | null; + attendances: AttendanceRecord[]; +} + @Injectable() export class ArchiveReportService { constructor( @@ -23,241 +33,897 @@ export class ArchiveReportService { @InjectRepository(Student) private studentRepo: Repository, ) {} - 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' } }), - ]); + async generateReport(studentId: number, res: Response): Promise { + 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); + const data: ReportData = { + student, + profile, + enrollments, + exams, + learnings, + result, + attendances, + }; - this.renderCover(doc, student, profile, enrollments); + const html = this.buildHtml(data); - doc.addPage(); - this.renderBasicInfo(doc, student, profile); - this.renderEnrollmentComparison(doc, enrollments); + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); - doc.addPage(); - this.renderExamScores(doc, exams); + try { + const page = await browser.newPage(); + await page.setContent(html, { waitUntil: 'load' }); + const pdf = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + }); - 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(' / ')}`); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename=student_report_${studentId}.pdf`, + ); + res.end(pdf); + } finally { + await browser.close(); } } - 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 css(): string { + return ` + @page { size: A4; margin: 0; } + * { box-sizing: border-box; } + body { + margin: 0; background: #eef3f8; color: #101828; + font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif; + -webkit-print-color-adjust: exact; print-color-adjust: exact; + } + .page { + position: relative; width: 210mm; height: 297mm; + margin: 0 auto 18px; padding: 14mm 15mm 10mm; + overflow: hidden; background: #fff; page-break-after: always; + } + .frame { + position: absolute; inset: 14mm; border: 1px solid #cfe0f2; pointer-events: none; + } + .header { + position: relative; z-index: 1; display: flex; align-items: center; + height: 39px; padding-bottom: 8px; border-bottom: 1px solid #cfe0f2; + } + .logo { + width: 24px; height: 24px; border-radius: 6px; + display: inline-flex; align-items: center; justify-content: center; + margin-right: 8px; color: #fff; background: #155aa8; + font-weight: 800; font-size: 11px; + } + .brand { font-size: 10px; font-weight: 700; } + .page-kicker { margin-left: auto; font-size: 10px; color: #667085; } + .footer { + position: absolute; left: 15mm; right: 15mm; bottom: 8mm; z-index: 1; + display: flex; justify-content: space-between; + border-top: 1px solid #cfe0f2; padding-top: 5px; + font-size: 10px; color: #667085; + } + h1, h2, h3, p { margin: 0; } + .section-title { font-size: 24px; line-height: 1.24; font-weight: 800; } + .source { font-size: 12px; color: #667085; padding-bottom: 2px; } + .title-row { + display: flex; align-items: flex-end; justify-content: space-between; + margin: 26px 0 17px; + } + .cover-title { margin-top: 60px; font-size: 34px; line-height: 1.22; font-weight: 800; } + .cover-subtitle { margin-top: 22px; font-size: 16px; color: #667085; } + .cover-main { + display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 63px; + } + .cover-name-card { + min-height: 174px; border: 1px solid #cfe0f2; + border-left: 5px solid #155aa8; padding: 22px 24px; + } + .cover-name { + font-size: 44px; line-height: 1.14; font-weight: 800; color: #155aa8; + } + .cover-desc { margin-top: 22px; font-size: 16px; color: #667085; } + .cover-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } + .cover-cell { + min-height: 61px; border: 1px solid #cfe0f2; padding: 11px 14px; + } + .label { font-size: 11px; color: #667085; margin-bottom: 8px; } + .value { font-size: 14px; line-height: 1.5; font-weight: 700; } + .toc { margin-top: 58px; } + .toc-row { + display: grid; grid-template-columns: 48px 1fr 72px; align-items: center; + height: 47px; border-bottom: 1px solid #cfe0f2; + } + .toc-index { color: #155aa8; font-size: 15px; font-weight: 800; } + .toc-name { font-size: 14px; font-weight: 800; } + .toc-page { text-align: right; color: #667085; font-size: 12px; } + .watermark { + position: absolute; right: 36px; bottom: 82px; color: #eaf1fb; + font-size: 56px; font-weight: 900; writing-mode: vertical-rl; + } + .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + .grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } + .card { border: 1px solid #cfe0f2; padding: 14px; background: #fff; } + .card h3 { font-size: 16px; margin-bottom: 14px; } + .data-table { + width: 100%; border-collapse: collapse; table-layout: fixed; + } + .data-table th, .data-table td { + border: 1px solid #d6e3f2; padding: 8px 9px; font-size: 12px; + line-height: 1.55; vertical-align: top; text-align: left; + } + .data-table th { + background: #eaf3fd; color: #173f6f; font-weight: 800; white-space: nowrap; + } + .data-table td { overflow-wrap: anywhere; word-break: break-word; } + .data-table .nowrap { white-space: nowrap; } + .metric { + min-height: 88px; border: 1px solid #cfe0f2; padding: 13px 14px; + } + .metric .label { margin-bottom: 7px; } + .metric strong { + display: block; color: #155aa8; font-size: 27px; line-height: 1.16; + margin-bottom: 10px; + } + .metric p { + color: #667085; font-size: 12px; line-height: 1.45; + } + .summary-row { + display: grid; grid-template-columns: 92px 1fr; gap: 12px; + padding: 14px 0; border-bottom: 1px solid #d6e3f2; + font-size: 13px; line-height: 1.6; + } + .summary-row:last-child { border-bottom: 0; } + .summary-row strong { color: #155aa8; } + .note { + margin-top: 14px; padding: 12px 16px; border-left: 4px solid #155aa8; + background: #eef5ff; color: #173f6f; font-size: 12px; line-height: 1.7; + } + .banner-note { + margin-top: 12px; padding: 11px 16px; background: #eef5ff; + color: #173f6f; font-size: 12px; line-height: 1.7; + } + .line-chart { width: 100%; height: 180px; display: block; } + .bar-chart { width: 100%; height: 150px; display: block; } + .status { + display: inline-flex; align-items: center; justify-content: center; + width: 18px; height: 18px; border-radius: 5px; margin-right: 6px; + color: #fff; font-size: 11px; font-weight: 800; + } + .present { background: #18a77d; } + .leave { background: #f15b75; } + .late { background: #f59e0b; } + .absent { background: #dc2626; } + .progress-row { + display: grid; grid-template-columns: 72px 1fr 42px; align-items: center; + gap: 8px; margin: 10px 0; font-size: 12px; + } + .progress-track { + height: 11px; border-radius: 999px; background: #dfeaf6; overflow: hidden; + } + .progress-track i { + display: block; height: 100%; border-radius: 999px; + background: linear-gradient(90deg, #155aa8, #2e7df0); + } + .muted { color: #667085; } + @media print { + body { background: #fff; } + .page { margin: 0; box-shadow: none; } + } + `; } - private renderEnrollmentComparison( - doc, - enrollments: StudentEnrollment[], - ) { - doc.moveDown(1); - doc.fontSize(16).text('报读记录', { underline: true }); - doc.moveDown(0.5); + private pageFrame(inner: string): string { + return `
${inner}
`; + } + private pageHeader(title: string): string { + return `
工学云 · 学生档案${this.esc(title)}
`; + } + + private pageFooter(): string { + return ``; + } + + private buildHtml(data: ReportData): string { + const { student, profile, enrollments, exams, learnings, result, attendances } = data; + const name = student.name; + const now = new Date().toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + return ` + +学生档案报告 - ${this.esc(name)} + + +${this.buildCover(student, profile, enrollments, now)} +${this.buildBasicInfo(student, profile, enrollments, now)} +${this.buildExamOverview(exams, now)} +${this.buildAttendance(attendances, now)} +${this.buildExamDetail(exams, now)} +${this.buildLearningAndResult(learnings, result, now)} +`; + } + + private buildCover( + student: Student, + profile: StudentProfile | null, + enrollments: StudentEnrollment[], + now: string, + ): string { + const types = enrollments.map((e) => e.classType).filter(Boolean).join(' / ') || '-'; + + return this.pageFrame(` + ${this.pageHeader('封面')} +
学生档案报告
+
生成日期: ${this.esc(now)}
+
+
+
${this.esc(student.name)}
+
学号: ${this.esc(student.studentNo || '-')}
身份证号: ${this.esc(student.idNumber || '-')}
+
+
+
+
科类方向
+
${this.esc(profile?.subjectDirection || '-')}
+
+
+
目标院校
+
${this.esc(profile?.targetCollege || '-')}
+
+
+
目标专业
+
${this.esc(profile?.targetMajor || '-')}
+
+
+
报读班型
+
${this.esc(types)}
+
+
+
+
+
01基础信息与报读记录第 2 页
+
02考试成绩总览第 3 页
+
03出勤记录第 4 页
+
04文化课考试成绩第 5 页
+
05学情记录与录取归档第 6 页
+
+
工学云
+ ${this.pageFooter()} + `); + } + + private buildBasicInfo( + student: Student, + profile: StudentProfile | null, + enrollments: StudentEnrollment[], + now: string, + ): string { + const infoCards = ` +
+
+
${this.esc(now)} · 系统生成
+
基础信息
+
+
+
+

个人信息

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

报读记录

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

文化课报读

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

专业课报读

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

其他报读

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

报读记录

+ ${renderEnrollmentTable(enrollments)} +
`; } - private renderExamScores(doc, exams: ExamScore[]) { - doc.fontSize(16).text('考试成绩', { underline: true }); - doc.moveDown(0.5); - if (exams.length === 0) { - doc.fontSize(10).text('暂无考试成绩'); - return; + private buildExamOverview(exams: ExamScore[], now: string): string { + const cultureExams = exams.filter( + (e) => e.examType && e.examType.includes('文化'), + ); + const entranceExam = exams.find((e) => e.examType === '入学测试'); + const highestExam = [...exams].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0]; + + const entranceScore = entranceExam?.score?.toFixed(1) ?? '-'; + const highestScore = highestExam?.score?.toFixed(1) ?? '-'; + const highestName = highestExam?.examName ?? '-'; + + // Improvement: last exam score minus first exam score + const sortedExams = [...cultureExams].filter((e) => e.score != null); + let improvement = '—'; + if (sortedExams.length >= 2) { + const first = sortedExams[0].score!; + const last = sortedExams[sortedExams.length - 1].score!; + improvement = (last - first).toFixed(1); } - 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); + const avgScore = + cultureExams.length > 0 + ? ( + cultureExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / + cultureExams.length + ).toFixed(1) + : '-'; + + const metricHtml = ` +
+
+
${this.esc(now)} · 系统生成
+
考试成绩总览
+
+
+
+
+
入学测试成绩
+ ${this.esc(entranceScore)} +

入学摸底测试

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

${this.esc(highestName)}

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

首考 → 末考变化

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

文化课考试均分

+
+
`; + + const scoreTable = this.renderScoreTable(cultureExams); + + const trendChart = this.renderScoreTrendChart(cultureExams); + + let extraHtml = ''; + if (cultureExams.length === 0) { + extraHtml = ''; + } + + return this.pageFrame(` + ${this.pageHeader('考试成绩总览')} + ${metricHtml} + ${extraHtml} + ${scoreTable} + ${trendChart} + ${this.pageFooter()} + `); } - private renderAttendance(doc, records: AttendanceRecord[]) { - doc.fontSize(16).text('出勤记录', { underline: true }); - doc.moveDown(0.5); - if (records.length === 0) { - doc.fontSize(10).text('暂无出勤记录'); - return; + private renderScoreTable(exams: ExamScore[]): string { + if (exams.length === 0) return ''; + + return `
+

文化课考试成绩

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

成绩趋势

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

累计记录

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

出勤: ${present} 次

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

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

+
+
+
请假
+ ${leave} +

累计请假次数

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

出勤统计

+ + + ${yGrid} + ${bars} + +
`; } - private renderTable( - doc, - rows: string[][], - colWidths: number[], - headers?: string[], - ) { - const startX = doc.x; - const lineHeight = 18; + private renderAttendanceMatrix(records: AttendanceRecord[]): string { + if (records.length === 0) return ''; - 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); + // Group by date + const dateMap = new Map(); + for (const r of records) { + const existing = dateMap.get(r.attendanceDate) ?? []; + existing.push(r); + dateMap.set(r.attendanceDate, existing); } - 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]; + const dates = [...dateMap.keys()].sort(); + const sessions = ['上午', '下午', '晚自习']; + + let rows = ''; + for (const date of dates.slice(-30)) { + const dayRecords = dateMap.get(date) ?? []; + const cellMap = new Map(); + for (const r of dayRecords) { + cellMap.set(r.session, r.status); } - doc.moveDown(maxH / 14); - if (doc.y > doc.page.height - 60) { - doc.addPage(); + + let cells = ''; + for (const session of sessions) { + const status = cellMap.get(session) ?? ''; + cells += `${status ? this.statusBadge(status) : '-'}`; } + + rows += `${this.esc(date)}${cells}`; } + + return `
+

考勤明细(最近30条)

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

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

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

最近学情记录

+ + + + + + ${rows} +
日期类型内容跟进方式
+
`; + } + + let resultHtml = ''; + if (result) { + resultHtml = ` +
+
+
录取归档
+
+
+
+
+
文化课成绩${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}
+
专业课成绩${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}
+
录取状态${this.esc(result.admissionStatus || '-')}
+
录取院校${this.esc(result.admittedCollege || '-')}
+
录取专业${this.esc(result.admittedMajor || '-')}
+
+
+
录取归档信息为最终结果,如有疑问请联系教务处
`; + } else { + resultHtml = ` +
+
+
录取归档
+
+
+ `; + } + + return this.pageFrame(` + ${this.pageHeader('学情记录与录取归档')} + ${learningHtml} + ${resultHtml} + ${this.pageFooter()} + `); + } + + private esc(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } } diff --git a/package-lock.json b/package-lock.json index 9b15792..f02628a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pdfkit": "^0.18.0", + "puppeteer": "^25.3.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.28" @@ -4203,6 +4204,103 @@ "integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==", "license": "MIT" }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.0.6.tgz", + "integrity": "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA==", + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/@rc-component/async-validator": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-6.0.0.tgz", @@ -7663,6 +7761,31 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "16.0.1", + "resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-16.0.1.tgz", + "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.4.0.tgz", @@ -8353,6 +8476,12 @@ "node": ">=8" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1638949", + "resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1638949.tgz", + "integrity": "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==", + "license": "BSD-3-Clause" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/dezalgo/-/dezalgo-1.0.4.tgz", @@ -11883,6 +12012,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/linebreak": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/linebreak/-/linebreak-1.1.0.tgz", @@ -12577,6 +12718,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", @@ -12596,6 +12743,15 @@ "license": "MIT", "optional": true }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmmirror.com/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", @@ -13578,6 +13734,44 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "25.3.0", + "resolved": "https://registry.npmmirror.com/puppeteer/-/puppeteer-25.3.0.tgz", + "integrity": "sha512-O1tx8S315aw8eI99HZ5ZNcVEzJ9+jKF//eO5UvfZ3cXJ6okZ5sX3Y50u7DJaM+ewEK4LqXP068tBhfRaWikj+g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.6", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1638949", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.3.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.3.0", + "resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.3.0.tgz", + "integrity": "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.6", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1638949", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmmirror.com/pure-rand/-/pure-rand-7.0.1.tgz", @@ -15593,6 +15787,12 @@ "node": ">= 0.4" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "license": "MIT" + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz", @@ -16265,6 +16465,12 @@ "defaults": "^1.0.3" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "license": "Apache-2.0" + }, "node_modules/webpack": { "version": "5.108.3", "resolved": "https://registry.npmmirror.com/webpack/-/webpack-5.108.3.tgz", @@ -16625,6 +16831,27 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",