Files
gongxue-base/apps/server/src/archive/archive-report.service.ts

903 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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 generateReportHtml(studentId: number): Promise<string> {
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 `<div class="page"><div class="frame"></div>${inner}</div>`;
}
private pageHeader(title: string): string {
return `<div class="header"><span class="logo">G</span><span class="brand">恭学教育 · 学生档案</span><span class="page-kicker">${this.esc(title)}</span></div>`;
}
private pageFooter(): string {
return `<div class="footer"><span>恭学教育 · 学生档案报告</span><span>机密 · 仅限内部使用</span></div>`;
}
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 `<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>学生档案报告 - ${this.esc(name)}</title>
<style>${this.css()}</style></head>
<body>
${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)}
</body></html>`;
}
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('封面')}
<div class="cover-title">学生档案报告</div>
<div class="cover-subtitle">生成日期: ${this.esc(now)}</div>
<div class="cover-main">
<div class="cover-name-card">
<div class="cover-name">${this.esc(student.name)}</div>
<div class="cover-desc">学号: ${this.esc(student.studentNo || '-')}<br>身份证号: ${this.esc(student.idNumber || '-')}</div>
</div>
<div class="cover-info">
<div class="cover-cell">
<div class="label">科类方向</div>
<div class="value">${this.esc(profile?.subjectDirection || '-')}</div>
</div>
<div class="cover-cell">
<div class="label">目标院校</div>
<div class="value">${this.esc(profile?.targetCollege || '-')}</div>
</div>
<div class="cover-cell">
<div class="label">目标专业</div>
<div class="value">${this.esc(profile?.targetMajor || '-')}</div>
</div>
<div class="cover-cell">
<div class="label">报读班型</div>
<div class="value">${this.esc(types)}</div>
</div>
</div>
</div>
<div class="toc">
<div class="toc-row"><span class="toc-index">01</span><span class="toc-name">基础信息与报读记录</span><span class="toc-page">第 2 页</span></div>
<div class="toc-row"><span class="toc-index">02</span><span class="toc-name">考试成绩总览</span><span class="toc-page">第 3 页</span></div>
<div class="toc-row"><span class="toc-index">03</span><span class="toc-name">出勤记录</span><span class="toc-page">第 4 页</span></div>
<div class="toc-row"><span class="toc-index">04</span><span class="toc-name">文化课考试成绩</span><span class="toc-page">第 5 页</span></div>
<div class="toc-row"><span class="toc-index">05</span><span class="toc-name">学情记录与录取归档</span><span class="toc-page">第 6 页</span></div>
</div>
<div class="watermark">恭学教育</div>
${this.pageFooter()}
`);
}
private buildBasicInfo(
student: Student,
profile: StudentProfile | null,
enrollments: StudentEnrollment[],
now: string,
): string {
const infoCards = `
<div class="title-row">
<div>
<div class="source">${this.esc(now)} · 系统生成</div>
<div class="section-title">基础信息</div>
</div>
</div>
<div class="card" style="margin-bottom:14px;">
<h3>个人信息</h3>
<div class="grid-2">
<div class="summary-row"><span>姓名</span><span><strong>${this.esc(student.name)}</strong></span></div>
<div class="summary-row"><span>性别</span><span>${this.esc(student.gender || '-')}</span></div>
<div class="summary-row"><span>电话</span><span>${this.esc(student.phone || '-')}</span></div>
<div class="summary-row"><span>民族</span><span>${this.esc(student.ethnicity || '-')}</span></div>
<div class="summary-row"><span>紧急联系人</span><span>${this.esc(student.emergencyContact || '-')}</span></div>
<div class="summary-row"><span>紧急电话</span><span>${this.esc(student.emergencyPhone || '-')}</span></div>
<div class="summary-row"><span>年级</span><span>${this.esc(profile?.grade || '-')}</span></div>
</div>
</div>`;
const enrollmentSection = this.buildEnrollmentSection(enrollments);
return this.pageFrame(`
${this.pageHeader('基础信息')}
${infoCards}
${enrollmentSection}
${this.pageFooter()}
`);
}
private buildEnrollmentSection(enrollments: StudentEnrollment[]): string {
if (enrollments.length === 0) {
return `<div class="banner-note">暂无报读记录</div>`;
}
const renderEnrollmentTable = (enrs: StudentEnrollment[]): string => {
if (enrs.length === 0) {
return `<div class="banner-note">暂无数据</div>`;
}
let rows = '';
for (const e of enrs) {
rows += `<tr>
<td>${this.esc(e.courseCategory || '-')}</td>
<td>${this.esc(e.classType || '-')}</td>
<td>${this.esc(e.className || '-')}</td>
<td>${this.esc(e.headTeacher || '-')}</td>
<td>${this.esc(e.subjectTeacher || '-')}</td>
<td class="nowrap">${this.esc(e.startDate || '-')}</td>
<td class="nowrap">${this.esc(e.endDate || '-')}</td>
</tr>`;
}
return `<table class="data-table">
<thead><tr>
<th>课程类别</th><th>班型</th><th>班级</th>
<th>班主任</th><th>任课老师</th><th>开班日期</th><th>结课日期</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
};
// 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 =
'<div class="card" style="margin-bottom:14px;"><h3>报读记录</h3>';
html += '<div class="grid-2" style="gap:14px;">';
html += '<div>';
html += '<h3 style="font-size:13px;margin-bottom:8px;">文化课报读</h3>';
html += renderEnrollmentTable(cultureEnrollments);
html += '</div>';
html += '<div>';
html += '<h3 style="font-size:13px;margin-bottom:8px;">专业课报读</h3>';
html += renderEnrollmentTable(profEnrollments);
html += '</div>';
html += '</div>';
if (otherEnrollments.length > 0) {
html +=
'<h3 style="font-size:13px;margin:10px 0 8px;">其他报读</h3>';
html += renderEnrollmentTable(otherEnrollments);
}
html += '</div>';
return html;
}
return `<div class="card" style="margin-bottom:14px;">
<h3>报读记录</h3>
${renderEnrollmentTable(enrollments)}
</div>`;
}
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 = `
<div class="title-row">
<div>
<div class="source">${this.esc(now)} · 系统生成</div>
<div class="section-title">考试成绩总览</div>
</div>
</div>
<div class="grid-4" style="margin-bottom:14px;">
<div class="metric">
<div class="label">入学测试成绩</div>
<strong>${this.esc(entranceScore)}</strong>
<p>入学摸底测试</p>
</div>
<div class="metric">
<div class="label">最高分</div>
<strong>${this.esc(highestScore)}</strong>
<p>${this.esc(highestName)}</p>
</div>
<div class="metric">
<div class="label">进步幅度</div>
<strong>${this.esc(improvement)}</strong>
<p>首考 → 末考变化</p>
</div>
<div class="metric">
<div class="label">平均分</div>
<strong>${this.esc(avgScore)}</strong>
<p>文化课考试均分</p>
</div>
</div>`;
const scoreTable = this.renderScoreTable(cultureExams);
const trendChart = this.renderScoreTrendChart(cultureExams);
let extraHtml = '';
if (cultureExams.length === 0) {
extraHtml = '<div class="banner-note">暂无文化课考试成绩</div>';
}
return this.pageFrame(`
${this.pageHeader('考试成绩总览')}
${metricHtml}
${extraHtml}
${scoreTable}
${trendChart}
${this.pageFooter()}
`);
}
private renderScoreTable(exams: ExamScore[]): string {
if (exams.length === 0) return '';
return `<div class="card" style="margin-bottom:14px;">
<h3>文化课考试成绩</h3>
<table class="data-table">
<thead><tr>
<th>类型</th><th>名称</th><th>科目</th>
<th>分数</th><th>班均</th><th>排名</th><th>日期</th>
</tr></thead>
<tbody>
${exams
.map(
(e) =>
`<tr>
<td>${this.esc(e.examType || '-')}</td>
<td>${this.esc(e.examName || '-')}</td>
<td>${this.esc(e.subject || '-')}</td>
<td>${e.score != null ? e.score : '-'}</td>
<td>${e.classAvg != null ? e.classAvg : '-'}</td>
<td>${e.rank != null ? e.rank : '-'}</td>
<td class="nowrap">${this.esc(e.examDate || '-')}</td>
</tr>`,
)
.join('')}
</tbody>
</table>
</div>`;
}
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 += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="4" fill="#155aa8" stroke="#fff" stroke-width="2"/>`;
if (i > 0) {
const px = pad.left + ((i - 1) / Math.max(scores.length - 1, 1)) * plotW;
const py = scaleY(scores[i - 1]);
lines += `<line x1="${px.toFixed(1)}" y1="${py.toFixed(1)}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="#155aa8" stroke-width="2" stroke-linecap="round"/>`;
}
}
// 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 += `<text x="${pad.left - 6}" y="${y.toFixed(1) + 4}" text-anchor="end" fill="#667085" font-size="10">${val.toFixed(0)}</text>`;
if (i > 0) {
yLabels += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="#eaf3fd" stroke-width="1"/>`;
}
}
// 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 += `<text x="${x.toFixed(1)}" y="${h - 6}" text-anchor="middle" fill="#667085" font-size="10">${this.esc(labels[i])}</text>`;
}
return `<div class="card" style="margin-bottom:14px;">
<h3>成绩趋势</h3>
<svg class="line-chart" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="${w}" height="${h}" fill="#fff"/>
${yLabels}
${xLabels}
${lines}
${points}
</svg>
<div class="note">趋势图展示文化课考试成绩的变化轨迹,点数代每次考试的分数</div>
</div>`;
}
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 = `
<div class="title-row">
<div>
<div class="source">${this.esc(now)} · 系统生成</div>
<div class="section-title">出勤记录</div>
</div>
</div>
<div class="grid-4" style="margin-bottom:14px;">
<div class="metric">
<div class="label">总考勤次数</div>
<strong>${total}</strong>
<p>累计记录</p>
</div>
<div class="metric">
<div class="label">出勤率</div>
<strong>${this.esc(rate)}%</strong>
<p>出勤: ${present} 次</p>
</div>
<div class="metric">
<div class="label">缺勤 / 迟到</div>
<strong>${absent} / ${late}</strong>
<p>缺勤 ${absent} · 迟到 ${late}</p>
</div>
<div class="metric">
<div class="label">请假</div>
<strong>${leave}</strong>
<p>累计请假次数</p>
</div>
</div>`;
const chart = this.renderAttendanceBar(records);
const matrix = this.renderAttendanceMatrix(records);
let extraHtml = '';
if (records.length === 0) {
extraHtml = '<div class="banner-note">暂无出勤记录</div>';
}
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 += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${bh.toFixed(1)}" fill="${colors[i]}" rx="4"/>`;
bars += `<text x="${(x + barW / 2).toFixed(1)}" y="${(y - 6).toFixed(1)}" text-anchor="middle" fill="#101828" font-size="12" font-weight="700">${counts[i]}</text>`;
bars += `<text x="${(x + barW / 2).toFixed(1)}" y="${(h - 8).toFixed(1)}" text-anchor="middle" fill="#667085" font-size="11">${labels[i]}</text>`;
}
// 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 += `<text x="${pad.left - 6}" y="${y + 4}" text-anchor="end" fill="#667085" font-size="10">${val}</text>`;
if (i < ySteps) {
yGrid += `<line x1="${pad.left}" y1="${y}" x2="${w - pad.right}" y2="${y}" stroke="#eaf3fd" stroke-width="1"/>`;
}
}
return `<div class="card" style="margin-bottom:14px;">
<h3>出勤统计</h3>
<svg class="bar-chart" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="${w}" height="${h}" fill="#fff"/>
${yGrid}
${bars}
</svg>
</div>`;
}
private renderAttendanceMatrix(records: AttendanceRecord[]): string {
if (records.length === 0) return '';
// Group by date
const dateMap = new Map<string, AttendanceRecord[]>();
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<string, string>();
for (const r of dayRecords) {
cellMap.set(r.session, r.status);
}
let cells = '';
for (const session of sessions) {
const status = cellMap.get(session) ?? '';
cells += `<td>${status ? this.statusBadge(status) : '-'}</td>`;
}
rows += `<tr><td class="nowrap">${this.esc(date)}</td>${cells}</tr>`;
}
return `<div class="card" style="margin-bottom:14px;">
<h3>考勤明细最近30条</h3>
<table class="data-table">
<thead><tr>
<th>日期</th>
${sessions.map((s) => `<th>${this.esc(s)}</th>`).join('')}
</tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="note">图例: <span class="status present">到</span> 出勤 &nbsp; <span class="status absent">缺</span> 缺勤 &nbsp; <span class="status late">迟</span> 迟到 &nbsp; <span class="status leave">假</span> 请假</div>
</div>`;
}
private statusBadge(status: string): string {
const map: Record<string, { cls: string; text: string }> = {
present: { cls: 'present', text: '到' },
absent: { cls: 'absent', text: '缺' },
late: { cls: 'late', text: '迟' },
leave: { cls: 'leave', text: '假' },
};
const entry = map[status];
if (!entry) return `<span class="muted">${this.esc(status)}</span>`;
return `<span class="status ${entry.cls}">${entry.text}</span>`;
}
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('文化课考试成绩')}
<div class="title-row">
<div>
<div class="source">${this.esc(now)} · 系统生成</div>
<div class="section-title">文化课考试成绩</div>
</div>
</div>
<div class="banner-note">暂无文化课考试成绩</div>
${this.pageFooter()}
`);
}
// Group by subject
const subjectMap = new Map<string, ExamScore[]>();
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 += `<tr>
<td>${this.esc(e.examName || '-')}</td>
<td>${e.score != null ? e.score : '-'}</td>
<td>${e.classAvg != null ? e.classAvg : '-'}</td>
<td>${e.rank != null ? e.rank : '-'}</td>
<td class="nowrap">${this.esc(e.examDate || '-')}</td>
</tr>`;
}
subjectCards += `<div class="card" style="margin-bottom:14px;">
<h3>${this.esc(subject)} · 最佳 ${best} · 均分 ${this.esc(avg)}</h3>
<table class="data-table">
<thead><tr>
<th>考试名称</th><th>分数</th><th>班均</th><th>排名</th><th>日期</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
return this.pageFrame(`
${this.pageHeader('文化课考试成绩')}
<div class="title-row">
<div>
<div class="source">${this.esc(now)} · 系统生成</div>
<div class="section-title">文化课考试成绩</div>
</div>
</div>
${subjectCards}
${this.pageFooter()}
`);
}
private buildLearningAndResult(
learnings: LearningRecord[],
result: ResultArchive | null,
now: string,
): string {
let learningHtml = '';
if (learnings.length === 0) {
learningHtml = `
<div class="title-row">
<div>
<div class="source">${this.esc(now)} · 系统生成</div>
<div class="section-title">学情记录</div>
</div>
</div>
<div class="banner-note">暂无学情记录</div>`;
} else {
const latest = learnings.slice(0, 15);
let rows = '';
for (const r of latest) {
rows += `<tr>
<td class="nowrap">${this.esc(r.recordDate || '-')}</td>
<td>${this.esc(r.recordType || '-')}</td>
<td>${this.esc((r.content || '-').slice(0, 200))}</td>
<td>${this.esc(r.followUpMethod || '-')}</td>
</tr>`;
}
learningHtml = `
<div class="title-row">
<div>
<div class="source">${this.esc(now)} · 系统生成</div>
<div class="section-title">学情记录</div>
</div>
</div>
<div class="card" style="margin-bottom:14px;">
<h3>最近学情记录</h3>
<table class="data-table">
<thead><tr>
<th style="width:90px;">日期</th><th style="width:60px;">类型</th>
<th>内容</th><th style="width:70px;">跟进方式</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
let resultHtml = '';
if (result) {
resultHtml = `
<div class="title-row">
<div>
<div class="section-title">录取归档</div>
</div>
</div>
<div class="card">
<div class="grid-2">
<div class="summary-row"><span>文化课成绩</span><span><strong>${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}</strong></span></div>
<div class="summary-row"><span>专业课成绩</span><span><strong>${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}</strong></span></div>
<div class="summary-row"><span>录取状态</span><span><strong>${this.esc(result.admissionStatus || '-')}</strong></span></div>
<div class="summary-row"><span>录取院校</span><span><strong>${this.esc(result.admittedCollege || '-')}</strong></span></div>
<div class="summary-row"><span>录取专业</span><span><strong>${this.esc(result.admittedMajor || '-')}</strong></span></div>
</div>
</div>
<div class="note">录取归档信息为最终结果,如有疑问请联系教务处</div>`;
} else {
resultHtml = `
<div class="title-row">
<div>
<div class="section-title">录取归档</div>
</div>
</div>
<div class="banner-note">暂无录取归档信息</div>`;
}
return this.pageFrame(`
${this.pageHeader('学情记录与录取归档')}
${learningHtml}
${resultHtml}
${this.pageFooter()}
`);
}
private esc(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
}