import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; 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'; interface ReportData { student: Student; profile: StudentProfile | null; enrollments: StudentEnrollment[]; exams: ExamScore[]; learnings: LearningRecord[]; result: ResultArchive | null; attendances: AttendanceRecord[]; } @Injectable() export class ArchiveReportService { constructor( @InjectRepository(StudentProfile) private profileRepo: Repository, @InjectRepository(StudentEnrollment) private enrollmentRepo: Repository, @InjectRepository(ExamScore) private examRepo: Repository, @InjectRepository(LearningRecord) private learningRepo: Repository, @InjectRepository(ResultArchive) private resultRepo: Repository, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, ) {} async generateReportHtml(studentId: number): 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 data: ReportData = { student, profile, enrollments, exams, learnings, result, attendances, }; return this.buildHtml(data); } private css(): string { return ` @page { size: A4; margin: 0; } * { box-sizing: border-box; } body { margin: 0; background: #eef3f8; color: #101828; font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif; -webkit-print-color-adjust: exact; print-color-adjust: exact; } .page { position: relative; width: 210mm; height: 297mm; margin: 0 auto 18px; padding: 14mm 15mm 10mm; overflow: hidden; background: #fff; page-break-after: always; } .frame { position: absolute; inset: 14mm; border: 1px solid #cfe0f2; pointer-events: none; } .header { position: relative; z-index: 1; display: flex; align-items: center; height: 39px; padding-bottom: 8px; border-bottom: 1px solid #cfe0f2; } .logo { width: 24px; height: 24px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; color: #fff; background: #155aa8; font-weight: 800; font-size: 11px; } .brand { font-size: 10px; font-weight: 700; } .page-kicker { margin-left: auto; font-size: 10px; color: #667085; } .footer { position: absolute; left: 15mm; right: 15mm; bottom: 8mm; z-index: 1; display: flex; justify-content: space-between; border-top: 1px solid #cfe0f2; padding-top: 5px; font-size: 10px; color: #667085; } h1, h2, h3, p { margin: 0; } .section-title { font-size: 24px; line-height: 1.24; font-weight: 800; } .source { font-size: 12px; color: #667085; padding-bottom: 2px; } .title-row { display: flex; align-items: flex-end; justify-content: space-between; margin: 26px 0 17px; } .cover-title { margin-top: 60px; font-size: 34px; line-height: 1.22; font-weight: 800; } .cover-subtitle { margin-top: 22px; font-size: 16px; color: #667085; } .cover-main { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 63px; } .cover-name-card { min-height: 174px; border: 1px solid #cfe0f2; border-left: 5px solid #155aa8; padding: 22px 24px; } .cover-name { font-size: 44px; line-height: 1.14; font-weight: 800; color: #155aa8; } .cover-desc { margin-top: 22px; font-size: 16px; color: #667085; } .cover-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .cover-cell { min-height: 61px; border: 1px solid #cfe0f2; padding: 11px 14px; } .label { font-size: 11px; color: #667085; margin-bottom: 8px; } .value { font-size: 14px; line-height: 1.5; font-weight: 700; } .toc { margin-top: 58px; } .toc-row { display: grid; grid-template-columns: 48px 1fr 72px; align-items: center; height: 47px; border-bottom: 1px solid #cfe0f2; } .toc-index { color: #155aa8; font-size: 15px; font-weight: 800; } .toc-name { font-size: 14px; font-weight: 800; } .toc-page { text-align: right; color: #667085; font-size: 12px; } .watermark { position: absolute; right: 36px; bottom: 82px; color: #eaf1fb; font-size: 56px; font-weight: 900; writing-mode: vertical-rl; } .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } .card { border: 1px solid #cfe0f2; padding: 14px; background: #fff; } .card h3 { font-size: 16px; margin-bottom: 14px; } .data-table { width: 100%; border-collapse: collapse; table-layout: fixed; } .data-table th, .data-table td { border: 1px solid #d6e3f2; padding: 8px 9px; font-size: 12px; line-height: 1.55; vertical-align: top; text-align: left; } .data-table th { background: #eaf3fd; color: #173f6f; font-weight: 800; white-space: nowrap; } .data-table td { overflow-wrap: anywhere; word-break: break-word; } .data-table .nowrap { white-space: nowrap; } .metric { min-height: 88px; border: 1px solid #cfe0f2; padding: 13px 14px; } .metric .label { margin-bottom: 7px; } .metric strong { display: block; color: #155aa8; font-size: 27px; line-height: 1.16; margin-bottom: 10px; } .metric p { color: #667085; font-size: 12px; line-height: 1.45; } .summary-row { display: grid; grid-template-columns: 92px 1fr; gap: 12px; padding: 14px 0; border-bottom: 1px solid #d6e3f2; font-size: 13px; line-height: 1.6; } .summary-row:last-child { border-bottom: 0; } .summary-row strong { color: #155aa8; } .note { margin-top: 14px; padding: 12px 16px; border-left: 4px solid #155aa8; background: #eef5ff; color: #173f6f; font-size: 12px; line-height: 1.7; } .banner-note { margin-top: 12px; padding: 11px 16px; background: #eef5ff; color: #173f6f; font-size: 12px; line-height: 1.7; } .line-chart { width: 100%; height: 180px; display: block; } .bar-chart { width: 100%; height: 150px; display: block; } .status { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 5px; margin-right: 6px; color: #fff; font-size: 11px; font-weight: 800; } .present { background: #18a77d; } .leave { background: #f15b75; } .late { background: #f59e0b; } .absent { background: #dc2626; } .progress-row { display: grid; grid-template-columns: 72px 1fr 42px; align-items: center; gap: 8px; margin: 10px 0; font-size: 12px; } .progress-track { height: 11px; border-radius: 999px; background: #dfeaf6; overflow: hidden; } .progress-track i { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, #155aa8, #2e7df0); } .muted { color: #667085; } @media print { body { background: #fff; } .page { margin: 0; box-shadow: none; } } `; } private pageFrame(inner: string): string { return `
${inner}
`; } private pageHeader(title: string): string { return `
恭学教育 · 学生档案${this.esc(title)}
`; } private pageFooter(): string { return ``; } private buildHtml(data: ReportData): string { 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?.grade || '-')}
`; const enrollmentSection = this.buildEnrollmentSection(enrollments); return this.pageFrame(` ${this.pageHeader('基础信息')} ${infoCards} ${enrollmentSection} ${this.pageFooter()} `); } private buildEnrollmentSection(enrollments: StudentEnrollment[]): string { if (enrollments.length === 0) { return ``; } const renderEnrollmentTable = (enrs: StudentEnrollment[]): string => { if (enrs.length === 0) { return ``; } let rows = ''; for (const e of enrs) { rows += ` ${this.esc(e.courseCategory || '-')} ${this.esc(e.classType || '-')} ${this.esc(e.className || '-')} ${this.esc(e.headTeacher || '-')} ${this.esc(e.subjectTeacher || '-')} ${this.esc(e.startDate || '-')} ${this.esc(e.endDate || '-')} `; } return `${rows}
课程类别班型班级 班主任任课老师开班日期结课日期
`; }; // Multi-enrollment: split culture vs professional const cultureEnrollments = enrollments.filter( (e) => e.courseCategory && e.courseCategory.includes('文化'), ); const profEnrollments = enrollments.filter( (e) => e.courseCategory && e.courseCategory.includes('专业'), ); const otherEnrollments = enrollments.filter( (e) => !e.courseCategory || (!e.courseCategory.includes('文化') && !e.courseCategory.includes('专业')), ); if (cultureEnrollments.length > 0 || profEnrollments.length > 0) { let html = '

报读记录

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

文化课报读

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

专业课报读

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

其他报读

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

报读记录

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

入学摸底测试

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

${this.esc(highestName)}

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

首考 → 末考变化

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

文化课考试均分

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

文化课考试成绩

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

成绩趋势

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

累计记录

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

出勤: ${present} 次

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

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

请假
${leave}

累计请假次数

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

出勤统计

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

考勤明细(最近30条)

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

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

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

最近学情记录

${rows}
日期类型 内容跟进方式
`; } let resultHtml = ''; if (result) { resultHtml = `
录取归档
文化课成绩${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}
专业课成绩${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}
录取状态${this.esc(result.admissionStatus || '-')}
录取院校${this.esc(result.admittedCollege || '-')}
录取专业${this.esc(result.admittedMajor || '-')}
录取归档信息为最终结果,如有疑问请联系教务处
`; } else { resultHtml = `
录取归档
`; } return this.pageFrame(` ${this.pageHeader('学情记录与录取归档')} ${learningHtml} ${resultHtml} ${this.pageFooter()} `); } private esc(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } }