forked from wangziqi/gongxue-base
232 lines
7.8 KiB
TypeScript
232 lines
7.8 KiB
TypeScript
import { ExamScore } from '../entities/exam-score.entity';
|
|
import { esc, sectionFrame, sectionHeader } from './archive-report.helpers';
|
|
|
|
export function buildExamOverview(exams: ExamScore[], now: string): string {
|
|
if (exams.length === 0) return '';
|
|
|
|
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 sortedScores = cultureExams
|
|
.map((exam) => exam.score)
|
|
.filter((score): score is number => score !== null && score !== undefined);
|
|
let improvement = '—';
|
|
if (sortedScores.length >= 2) {
|
|
const first = sortedScores[0];
|
|
const last = sortedScores[sortedScores.length - 1];
|
|
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">${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>${esc(entranceScore)}</strong>
|
|
<p>入学摸底测试</p>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="label">最高分</div>
|
|
<strong>${esc(highestScore)}</strong>
|
|
<p>${esc(highestName)}</p>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="label">进步幅度</div>
|
|
<strong>${esc(improvement)}</strong>
|
|
<p>首考 → 末考变化</p>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="label">平均分</div>
|
|
<strong>${esc(avgScore)}</strong>
|
|
<p>文化课考试均分</p>
|
|
</div>
|
|
</div>`;
|
|
|
|
const scoreTable = renderScoreTable(cultureExams);
|
|
const trendChart = renderScoreTrendChart(cultureExams);
|
|
|
|
return sectionFrame(`
|
|
${sectionHeader('考试成绩总览')}
|
|
${metricHtml}
|
|
${scoreTable}
|
|
${trendChart}
|
|
`);
|
|
}
|
|
|
|
export function 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>${esc(e.examType || '-')}</td>
|
|
<td>${esc(e.examName || '-')}</td>
|
|
<td>${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">${esc(e.examDate || '-')}</td>
|
|
</tr>`,
|
|
)
|
|
.join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
export function renderScoreTrendChart(exams: ExamScore[]): string {
|
|
const cultureExams = exams.filter((e) => e.score != null);
|
|
if (cultureExams.length === 0) return '';
|
|
|
|
const scores = cultureExams.map((e) => Number(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 + 4).toFixed(1)}" 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">${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>`;
|
|
}
|
|
|
|
export function buildExamDetail(exams: ExamScore[], now: string): string {
|
|
const cultureExams = exams.filter(
|
|
(e) => e.examType && e.examType.includes('文化'),
|
|
);
|
|
|
|
if (cultureExams.length === 0) return '';
|
|
|
|
// 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>${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">${esc(e.examDate || '-')}</td>
|
|
</tr>`;
|
|
}
|
|
|
|
subjectCards += `<div class="card" style="margin-bottom:14px;">
|
|
<h3>${esc(subject)} · 最佳 ${best} · 均分 ${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 sectionFrame(`
|
|
${sectionHeader('文化课考试成绩')}
|
|
<div class="title-row">
|
|
<div>
|
|
<div class="source">${esc(now)} · 系统生成</div>
|
|
<div class="section-title">文化课考试成绩</div>
|
|
</div>
|
|
</div>
|
|
${subjectCards}
|
|
`);
|
|
}
|