feat: 学生报告改为流式排版并隐藏空数据区块
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers';
|
||||
import { esc, sectionFrame, sectionHeader } from './archive-report.helpers';
|
||||
|
||||
export function buildAttendance(records: AttendanceRecord[], now: string): string {
|
||||
if (records.length === 0) return '';
|
||||
|
||||
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;
|
||||
@@ -42,18 +44,11 @@ export function buildAttendance(records: AttendanceRecord[], now: string): strin
|
||||
const chart = renderAttendanceBar(records);
|
||||
const matrix = renderAttendanceMatrix(records);
|
||||
|
||||
let extraHtml = '';
|
||||
if (records.length === 0) {
|
||||
extraHtml = '<div class="banner-note">暂无出勤记录</div>';
|
||||
}
|
||||
|
||||
return pageFrame(`
|
||||
${pageHeader('出勤记录')}
|
||||
return sectionFrame(`
|
||||
${sectionHeader('出勤记录')}
|
||||
${metricHtml}
|
||||
${extraHtml}
|
||||
${chart}
|
||||
${matrix}
|
||||
${pageFooter()}
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentProfile } from '../entities/student-profile.entity';
|
||||
import { StudentEnrollment } from '../entities/student-enrollment.entity';
|
||||
import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers';
|
||||
import { esc, sectionFrame, sectionHeader, coverFooter } from './archive-report.helpers';
|
||||
import { buildEnrollmentSection } from './archive-report.enrollment';
|
||||
|
||||
export function buildCover(
|
||||
@@ -9,11 +9,22 @@ export function buildCover(
|
||||
profile: StudentProfile | null,
|
||||
enrollments: StudentEnrollment[],
|
||||
now: string,
|
||||
tocNames: string[],
|
||||
): string {
|
||||
const types = enrollments.map((e) => e.classType).filter(Boolean).join(' / ') || '-';
|
||||
|
||||
return pageFrame(`
|
||||
${pageHeader('封面')}
|
||||
const tocHtml =
|
||||
tocNames.length > 0
|
||||
? tocNames
|
||||
.map(
|
||||
(name, i) => `
|
||||
<div class="toc-row"><span class="toc-index">${String(i + 1).padStart(2, '0')}</span><span class="toc-name">${esc(name)}</span></div>`,
|
||||
)
|
||||
.join('')
|
||||
: '<div class="toc-row"><span class="toc-name muted">暂无章节</span></div>';
|
||||
|
||||
return sectionFrame(`
|
||||
${sectionHeader('封面')}
|
||||
<div class="cover-title">学生档案报告</div>
|
||||
<div class="cover-subtitle">生成日期: ${esc(now)}</div>
|
||||
<div class="cover-main">
|
||||
@@ -40,16 +51,10 @@ export function buildCover(
|
||||
</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="toc">${tocHtml}</div>
|
||||
<div class="watermark">恭学教育</div>
|
||||
${pageFooter()}
|
||||
`);
|
||||
${coverFooter()}
|
||||
`, true);
|
||||
}
|
||||
|
||||
export function buildBasicInfo(
|
||||
@@ -80,10 +85,9 @@ export function buildBasicInfo(
|
||||
|
||||
const enrollmentSection = buildEnrollmentSection(enrollments);
|
||||
|
||||
return pageFrame(`
|
||||
${pageHeader('基础信息')}
|
||||
return sectionFrame(`
|
||||
${sectionHeader('基础信息')}
|
||||
${infoCards}
|
||||
${enrollmentSection}
|
||||
${pageFooter()}
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,10 @@ import { StudentEnrollment } from '../entities/student-enrollment.entity';
|
||||
import { esc } from './archive-report.helpers';
|
||||
|
||||
export function buildEnrollmentSection(enrollments: StudentEnrollment[]): string {
|
||||
if (enrollments.length === 0) {
|
||||
return `<div class="banner-note">暂无报读记录</div>`;
|
||||
}
|
||||
if (enrollments.length === 0) return '';
|
||||
|
||||
const renderEnrollmentTable = (enrs: StudentEnrollment[]): string => {
|
||||
if (enrs.length === 0) {
|
||||
return `<div class="banner-note">暂无数据</div>`;
|
||||
}
|
||||
if (enrs.length === 0) return '';
|
||||
|
||||
let rows = '';
|
||||
for (const e of enrs) {
|
||||
@@ -46,35 +42,21 @@ export function buildEnrollmentSection(enrollments: StudentEnrollment[]): string
|
||||
(!e.courseCategory.includes('文化') && !e.courseCategory.includes('专业')),
|
||||
);
|
||||
|
||||
if (cultureEnrollments.length > 0 || profEnrollments.length > 0) {
|
||||
let html =
|
||||
'<div class="card" style="margin-bottom:14px;"><h3>报读记录</h3>';
|
||||
const cultureTable = renderEnrollmentTable(cultureEnrollments);
|
||||
const profTable = renderEnrollmentTable(profEnrollments);
|
||||
const otherTable = renderEnrollmentTable(otherEnrollments);
|
||||
|
||||
let html = '<div class="card" style="margin-bottom:14px;"><h3>报读记录</h3>';
|
||||
if (cultureTable && profTable) {
|
||||
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><h3 style="font-size:13px;margin-bottom:8px;">文化课报读</h3>${cultureTable}</div>`;
|
||||
html += `<div><h3 style="font-size:13px;margin-bottom:8px;">专业课报读</h3>${profTable}</div>`;
|
||||
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;
|
||||
} else {
|
||||
if (cultureTable) html += `<h3 style="font-size:13px;margin:0 0 8px;">文化课报读</h3>${cultureTable}`;
|
||||
if (profTable) html += `<h3 style="font-size:13px;margin:0 0 8px;">专业课报读</h3>${profTable}`;
|
||||
}
|
||||
|
||||
return `<div class="card" style="margin-bottom:14px;">
|
||||
<h3>报读记录</h3>
|
||||
${renderEnrollmentTable(enrollments)}
|
||||
</div>`;
|
||||
if (otherTable) html += `<h3 style="font-size:13px;margin:10px 0 8px;">其他报读</h3>${otherTable}`;
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ExamScore } from '../entities/exam-score.entity';
|
||||
import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers';
|
||||
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('文化'),
|
||||
);
|
||||
@@ -64,18 +66,11 @@ export function buildExamOverview(exams: ExamScore[], now: string): string {
|
||||
const scoreTable = renderScoreTable(cultureExams);
|
||||
const trendChart = renderScoreTrendChart(cultureExams);
|
||||
|
||||
let extraHtml = '';
|
||||
if (cultureExams.length === 0) {
|
||||
extraHtml = '<div class="banner-note">暂无文化课考试成绩</div>';
|
||||
}
|
||||
|
||||
return pageFrame(`
|
||||
${pageHeader('考试成绩总览')}
|
||||
return sectionFrame(`
|
||||
${sectionHeader('考试成绩总览')}
|
||||
${metricHtml}
|
||||
${extraHtml}
|
||||
${scoreTable}
|
||||
${trendChart}
|
||||
${pageFooter()}
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -183,19 +178,7 @@ export function buildExamDetail(exams: ExamScore[], now: string): string {
|
||||
(e) => e.examType && e.examType.includes('文化'),
|
||||
);
|
||||
|
||||
if (cultureExams.length === 0) {
|
||||
return pageFrame(`
|
||||
${pageHeader('文化课考试成绩')}
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<div class="source">${esc(now)} · 系统生成</div>
|
||||
<div class="section-title">文化课考试成绩</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="banner-note">暂无文化课考试成绩</div>
|
||||
${pageFooter()}
|
||||
`);
|
||||
}
|
||||
if (cultureExams.length === 0) return '';
|
||||
|
||||
// Group by subject
|
||||
const subjectMap = new Map<string, ExamScore[]>();
|
||||
@@ -235,8 +218,8 @@ export function buildExamDetail(exams: ExamScore[], now: string): string {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return pageFrame(`
|
||||
${pageHeader('文化课考试成绩')}
|
||||
return sectionFrame(`
|
||||
${sectionHeader('文化课考试成绩')}
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<div class="source">${esc(now)} · 系统生成</div>
|
||||
@@ -244,6 +227,5 @@ export function buildExamDetail(exams: ExamScore[], now: string): string {
|
||||
</div>
|
||||
</div>
|
||||
${subjectCards}
|
||||
${pageFooter()}
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,14 @@ export function esc(value: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function pageFrame(inner: string): string {
|
||||
return `<div class="page"><div class="frame"></div>${inner}</div>`;
|
||||
export function sectionFrame(inner: string, cover = false): string {
|
||||
return `<div class="section${cover ? ' section-cover' : ''}">${cover ? '<div class="frame"></div>' : ''}${inner}</div>`;
|
||||
}
|
||||
|
||||
export function pageHeader(title: string): string {
|
||||
export function sectionHeader(title: string): string {
|
||||
return `<div class="header"><span class="logo">G</span><span class="brand">恭学教育 · 学生档案</span><span class="page-kicker">${esc(title)}</span></div>`;
|
||||
}
|
||||
|
||||
export function pageFooter(): string {
|
||||
export function coverFooter(): string {
|
||||
return `<div class="footer"><span>恭学教育 · 学生档案报告</span><span>机密 · 仅限内部使用</span></div>`;
|
||||
}
|
||||
|
||||
@@ -1,85 +1,61 @@
|
||||
import { LearningRecord } from '../entities/learning-record.entity';
|
||||
import { ResultArchive } from '../entities/result-archive.entity';
|
||||
import { esc, pageFrame, pageFooter, pageHeader } from './archive-report.helpers';
|
||||
import { esc, sectionFrame, sectionHeader } from './archive-report.helpers';
|
||||
|
||||
export function buildLearningAndResult(
|
||||
learnings: LearningRecord[],
|
||||
result: ResultArchive | null,
|
||||
now: string,
|
||||
): string {
|
||||
let learningHtml = '';
|
||||
if (learnings.length === 0) {
|
||||
learningHtml = `
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<div class="source">${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">${esc(r.recordDate || '-')}</td>
|
||||
<td>${esc(r.recordType || '-')}</td>
|
||||
<td>${esc((r.content || '-').slice(0, 200))}</td>
|
||||
<td>${esc(r.followUpMethod || '-')}</td>
|
||||
</tr>`;
|
||||
}
|
||||
export function buildLearning(learnings: LearningRecord[], now: string): string {
|
||||
if (learnings.length === 0) return '';
|
||||
|
||||
learningHtml = `
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<div class="source">${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>`;
|
||||
const latest = learnings.slice(0, 15);
|
||||
let rows = '';
|
||||
for (const r of latest) {
|
||||
rows += `<tr>
|
||||
<td class="nowrap">${esc(r.recordDate || '-')}</td>
|
||||
<td>${esc(r.recordType || '-')}</td>
|
||||
<td>${esc((r.content || '-').slice(0, 200))}</td>
|
||||
<td>${esc(r.followUpMethod || '-')}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
let resultHtml = '';
|
||||
if (result) {
|
||||
resultHtml = `
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<div class="section-title">录取归档</div>
|
||||
</div>
|
||||
return sectionFrame(`
|
||||
${sectionHeader('学情记录')}
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<div class="source">${esc(now)} · 系统生成</div>
|
||||
<div class="section-title">学情记录</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>${esc(result.admissionStatus || '-')}</strong></span></div>
|
||||
<div class="summary-row"><span>录取院校</span><span><strong>${esc(result.admittedCollege || '-')}</strong></span></div>
|
||||
<div class="summary-row"><span>录取专业</span><span><strong>${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 pageFrame(`
|
||||
${pageHeader('学情记录与录取归档')}
|
||||
${learningHtml}
|
||||
${resultHtml}
|
||||
${pageFooter()}
|
||||
</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>
|
||||
`);
|
||||
}
|
||||
|
||||
export function buildResult(result: ResultArchive | null, now: string): string {
|
||||
if (!result) return '';
|
||||
|
||||
return sectionFrame(`
|
||||
${sectionHeader('录取归档')}
|
||||
<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>${esc(result.admissionStatus || '-')}</strong></span></div>
|
||||
<div class="summary-row"><span>录取院校</span><span><strong>${esc(result.admittedCollege || '-')}</strong></span></div>
|
||||
<div class="summary-row"><span>录取专业</span><span><strong>${esc(result.admittedMajor || '-')}</strong></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note">录取归档信息为最终结果,如有疑问请联系教务处</div>
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
import { ArchiveReportService } from './archive-report.service';
|
||||
|
||||
interface MockData {
|
||||
student?: Record<string, unknown>;
|
||||
profile?: Record<string, unknown> | null;
|
||||
enrollments?: Array<Record<string, unknown>>;
|
||||
exams?: Array<Record<string, unknown>>;
|
||||
learnings?: Array<Record<string, unknown>>;
|
||||
result?: Record<string, unknown> | null;
|
||||
attendances?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function makeService(data: MockData = {}): ArchiveReportService {
|
||||
return new ArchiveReportService(
|
||||
{ findOne: jest.fn().mockResolvedValue(data.profile ?? null) } as never,
|
||||
{ find: jest.fn().mockResolvedValue(data.enrollments ?? []) } as never,
|
||||
{ find: jest.fn().mockResolvedValue(data.exams ?? []) } as never,
|
||||
{ find: jest.fn().mockResolvedValue(data.learnings ?? []) } as never,
|
||||
{ findOne: jest.fn().mockResolvedValue(data.result ?? null) } as never,
|
||||
{ find: jest.fn().mockResolvedValue(data.attendances ?? []) } as never,
|
||||
{
|
||||
findOne: jest.fn().mockResolvedValue(
|
||||
data.student ?? { id: 1, name: '测试学生', studentNo: 'S001' },
|
||||
),
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ArchiveReportService retired profile fields', () => {
|
||||
it('does not render the retired campus field in a student report', async () => {
|
||||
const service = new ArchiveReportService(
|
||||
{ findOne: jest.fn().mockResolvedValue({ campusLocation: '旧校区', grade: '高三' }) } as never,
|
||||
{ find: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ find: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ find: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findOne: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ find: jest.fn().mockResolvedValue([]) } as never,
|
||||
{
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
name: '测试学生',
|
||||
gender: '男',
|
||||
phone: '',
|
||||
ethnicity: '',
|
||||
emergencyContact: '',
|
||||
emergencyPhone: '',
|
||||
}),
|
||||
} as never,
|
||||
);
|
||||
const service = makeService({
|
||||
profile: { campusLocation: '旧校区', grade: '高三' },
|
||||
student: {
|
||||
id: 1,
|
||||
name: '测试学生',
|
||||
gender: '男',
|
||||
phone: '',
|
||||
ethnicity: '',
|
||||
emergencyContact: '',
|
||||
emergencyPhone: '',
|
||||
},
|
||||
});
|
||||
|
||||
const html = await service.generateReportHtml(1);
|
||||
|
||||
@@ -29,3 +48,73 @@ describe('ArchiveReportService retired profile fields', () => {
|
||||
expect(html).toContain('高三');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArchiveReportService empty sections', () => {
|
||||
it('hides empty sections and removes fixed TOC page numbers', async () => {
|
||||
const service = makeService({ profile: { grade: '高三' } });
|
||||
const html = await service.generateReportHtml(1);
|
||||
|
||||
expect(html).toContain('学生档案报告');
|
||||
expect(html).toContain('基础信息');
|
||||
expect(html).not.toContain('考试成绩总览');
|
||||
expect(html).not.toContain('出勤记录');
|
||||
expect(html).not.toContain('文化课考试成绩');
|
||||
expect(html).not.toContain('学情记录');
|
||||
expect(html).not.toContain('录取归档');
|
||||
expect(html).not.toContain('第 2 页');
|
||||
expect(html).not.toContain('暂无');
|
||||
});
|
||||
|
||||
it('renders sections with data and lists only those sections in the TOC', async () => {
|
||||
const service = makeService({
|
||||
profile: { grade: '高三' },
|
||||
enrollments: [{ courseCategory: '文化课', classType: '全日制' }],
|
||||
exams: [
|
||||
{
|
||||
examType: '文化课月考',
|
||||
examName: '一月月考',
|
||||
subject: '数学',
|
||||
score: 88,
|
||||
examDate: '2026-01-10',
|
||||
},
|
||||
],
|
||||
attendances: [
|
||||
{ attendanceDate: '2026-01-12', session: '上午', status: 'present' },
|
||||
],
|
||||
learnings: [
|
||||
{
|
||||
recordDate: '2026-01-13',
|
||||
recordType: '回访',
|
||||
content: '状态良好',
|
||||
followUpMethod: '电话',
|
||||
},
|
||||
],
|
||||
result: {
|
||||
cultureFinalScore: 90,
|
||||
professionalFinalScore: 85,
|
||||
admissionStatus: '录取',
|
||||
admittedCollege: '示例大学',
|
||||
admittedMajor: '计算机',
|
||||
},
|
||||
});
|
||||
|
||||
const html = await service.generateReportHtml(1);
|
||||
|
||||
expect(html).toContain('考试成绩总览');
|
||||
expect(html).toContain('出勤记录');
|
||||
expect(html).toContain('文化课考试成绩');
|
||||
expect(html).toContain('学情记录');
|
||||
expect(html).toContain('录取归档');
|
||||
expect(html).toContain('<h3>报读记录</h3>');
|
||||
expect(html).not.toContain('第 1 页');
|
||||
expect(html).not.toContain('第 2 页');
|
||||
});
|
||||
|
||||
it('omits the enrollment card when there are no enrollments', async () => {
|
||||
const service = makeService({ profile: { grade: '高三' } });
|
||||
const html = await service.generateReportHtml(1);
|
||||
|
||||
expect(html).not.toContain('<h3>报读记录</h3>');
|
||||
expect(html).not.toContain('暂无报读记录');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { esc } from './archive-report.helpers';
|
||||
import { buildCover, buildBasicInfo } from './archive-report.cover';
|
||||
import { buildExamOverview, buildExamDetail } from './archive-report.exam';
|
||||
import { buildAttendance } from './archive-report.attendance';
|
||||
import { buildLearningAndResult } from './archive-report.learning';
|
||||
import { buildLearning, buildResult } from './archive-report.learning';
|
||||
|
||||
interface ReportData {
|
||||
student: Student;
|
||||
@@ -71,17 +71,33 @@ export class ArchiveReportService {
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const sections = [
|
||||
{
|
||||
name: enrollments.length > 0 ? '基础信息与报读记录' : '基础信息',
|
||||
html: buildBasicInfo(student, profile, enrollments, now),
|
||||
},
|
||||
{ name: '考试成绩总览', html: buildExamOverview(exams, now) },
|
||||
{ name: '出勤记录', html: buildAttendance(attendances, now) },
|
||||
{ name: '文化课考试成绩', html: buildExamDetail(exams, now) },
|
||||
{ name: '学情记录', html: buildLearning(learnings, now) },
|
||||
{ name: '录取归档', html: buildResult(result, now) },
|
||||
].filter((section) => section.html.length > 0);
|
||||
|
||||
const cover = buildCover(
|
||||
student,
|
||||
profile,
|
||||
enrollments,
|
||||
now,
|
||||
sections.map((section) => section.name),
|
||||
);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="UTF-8"><title>学生档案报告 - ${esc(name)}</title>
|
||||
<style>${ARCHIVE_REPORT_CSS}</style></head>
|
||||
<body>
|
||||
${buildCover(student, profile, enrollments, now)}
|
||||
${buildBasicInfo(student, profile, enrollments, now)}
|
||||
${buildExamOverview(exams, now)}
|
||||
${buildAttendance(attendances, now)}
|
||||
${buildExamDetail(exams, now)}
|
||||
${buildLearningAndResult(learnings, result, now)}
|
||||
${cover}
|
||||
${sections.map((section) => section.html).join('\n')}
|
||||
</body></html>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@ export const ARCHIVE_REPORT_CSS = `
|
||||
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;
|
||||
.section {
|
||||
position: relative; width: 210mm; max-width: 100%;
|
||||
margin: 0 auto 18px; padding: 14mm 15mm 10mm;
|
||||
overflow: hidden; background: #fff; page-break-after: always;
|
||||
background: #fff;
|
||||
}
|
||||
.section-cover {
|
||||
height: 297mm; overflow: hidden;
|
||||
page-break-after: always; break-after: page;
|
||||
}
|
||||
.frame {
|
||||
position: absolute; inset: 14mm; border: 1px solid #cfe0f2; pointer-events: none;
|
||||
@@ -17,6 +21,7 @@ export const ARCHIVE_REPORT_CSS = `
|
||||
.header {
|
||||
position: relative; z-index: 1; display: flex; align-items: center;
|
||||
height: 39px; padding-bottom: 8px; border-bottom: 1px solid #cfe0f2;
|
||||
page-break-after: avoid; break-after: avoid;
|
||||
}
|
||||
.logo {
|
||||
width: 24px; height: 24px; border-radius: 6px;
|
||||
@@ -33,11 +38,13 @@ export const ARCHIVE_REPORT_CSS = `
|
||||
font-size: 10px; color: #667085;
|
||||
}
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1, h2, h3 { page-break-after: avoid; break-after: avoid; }
|
||||
.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;
|
||||
page-break-after: avoid; break-after: avoid;
|
||||
}
|
||||
.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; }
|
||||
@@ -60,7 +67,7 @@ export const ARCHIVE_REPORT_CSS = `
|
||||
.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;
|
||||
display: grid; grid-template-columns: 48px 1fr; align-items: center;
|
||||
height: 47px; border-bottom: 1px solid #cfe0f2;
|
||||
}
|
||||
.toc-index { color: #155aa8; font-size: 15px; font-weight: 800; }
|
||||
@@ -70,9 +77,18 @@ export const ARCHIVE_REPORT_CSS = `
|
||||
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; }
|
||||
.grid-2 {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
|
||||
page-break-inside: avoid; break-inside: avoid;
|
||||
}
|
||||
.grid-4 {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
|
||||
page-break-inside: avoid; break-inside: avoid;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #cfe0f2; padding: 14px; background: #fff;
|
||||
page-break-inside: avoid; break-inside: avoid;
|
||||
}
|
||||
.card h3 { font-size: 16px; margin-bottom: 14px; }
|
||||
.data-table {
|
||||
width: 100%; border-collapse: collapse; table-layout: fixed;
|
||||
@@ -86,8 +102,10 @@ export const ARCHIVE_REPORT_CSS = `
|
||||
}
|
||||
.data-table td { overflow-wrap: anywhere; word-break: break-word; }
|
||||
.data-table .nowrap { white-space: nowrap; }
|
||||
.data-table tr { page-break-inside: avoid; break-inside: avoid; }
|
||||
.metric {
|
||||
min-height: 88px; border: 1px solid #cfe0f2; padding: 13px 14px;
|
||||
page-break-inside: avoid; break-inside: avoid;
|
||||
}
|
||||
.metric .label { margin-bottom: 7px; }
|
||||
.metric strong {
|
||||
@@ -107,13 +125,16 @@ export const ARCHIVE_REPORT_CSS = `
|
||||
.note {
|
||||
margin-top: 14px; padding: 12px 16px; border-left: 4px solid #155aa8;
|
||||
background: #eef5ff; color: #173f6f; font-size: 12px; line-height: 1.7;
|
||||
page-break-inside: avoid; break-inside: avoid;
|
||||
}
|
||||
.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;
|
||||
page-break-inside: avoid; break-inside: avoid;
|
||||
}
|
||||
.bar-chart {
|
||||
width: 100%; height: 150px; display: block;
|
||||
page-break-inside: avoid; break-inside: avoid;
|
||||
}
|
||||
.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;
|
||||
@@ -137,6 +158,6 @@ export const ARCHIVE_REPORT_CSS = `
|
||||
.muted { color: #667085; }
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.page { margin: 0; box-shadow: none; }
|
||||
.section { margin: 0; box-shadow: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user