903 lines
34 KiB
TypeScript
903 lines
34 KiB
TypeScript
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> 出勤 <span class="status absent">缺</span> 缺勤 <span class="status late">迟</span> 迟到 <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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
}
|