Forráskód Böngészése

feat(admin): add shuffled vocabulary worksheets and printable review log

彭峰 3 napja
szülő
commit
b70be12efe

+ 31 - 6
projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.html

@@ -51,16 +51,41 @@
 
 @if (detailLoading() && !detail()) { <div class="detail-loading"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在整理学习报表…</div> }
 @if (detail(); as report) {
-  <div class="backdrop" (click)="close()"></div><aside class="drawer report-drawer" role="dialog" aria-modal="true" aria-label="单词学习报表预览"><header><div><p>REPORT PREVIEW</p><h2>{{ report.studentName }} · {{ report.studyDate }}</h2><span>学习记录 {{ report.generalId }}</span></div><button class="icon-action" type="button" aria-label="关闭" (click)="close()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
+  <div class="backdrop" (click)="close()"></div><aside class="drawer report-drawer" role="dialog" aria-modal="true" aria-label="单词学习报表预览"><header><div><p>REPORT PREVIEW</p><h2>{{ report.studentName }} · {{ report.studyDate }}</h2><span>学习记录 {{ report.generalId }}</span></div><button class="icon-action" type="button" aria-label="关闭" [disabled]="!!exportingId()" (click)="close()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
   @if (report.countMismatch) { <p class="notice error">历史汇总数量与单词明细不一致,报表已按实际单词明细重新计算。</p> }
+  <div class="report-variants" role="group" aria-label="报表类型">
+    <button type="button" [class.selected]="reportVariant()==='full'" [attr.aria-pressed]="reportVariant()==='full'" [disabled]="!!exportingId()" (click)="selectVariant('full')">完整学习报表</button>
+    <button type="button" [class.selected]="reportVariant()==='english'" [attr.aria-pressed]="reportVariant()==='english'" [disabled]="!!exportingId()" (click)="selectVariant('english')">英文填中文</button>
+    <button type="button" [class.selected]="reportVariant()==='chinese'" [attr.aria-pressed]="reportVariant()==='chinese'" [disabled]="!!exportingId()" (click)="selectVariant('chinese')">中文填英文</button>
+  </div>
+  @if(reportVariant()!=='full'){<p class="worksheet-hint">每次打开复习版都会打乱顺序,下载 PDF 与当前预览一致。</p>}
+  @if (error()) { <p class="notice error" role="alert">{{ error() }}</p> }
   <div class="preview-scroll"><article #pdfContent class="pdf-sheet">
-    <section class="pdf-title"><div class="brand-mark"><lucide-icon [img]="icons.FileChartColumn" [size]="28" /></div><div><p>小树陪练</p><h1>单词学习报表</h1></div><span>NO. {{ report.generalId }}</span></section>
+    <section class="pdf-title"><div class="brand-mark"><lucide-icon [img]="icons.FileChartColumn" [size]="28" /></div><div><p>小树陪练</p><h1>{{ reportTitle() }}</h1></div><span>NO. {{ report.generalId }}</span></section>
     <dl class="report-meta"><div><dt>学员</dt><dd>{{ report.studentName }}</dd></div><div><dt>陪练老师</dt><dd>{{ report.coachName }}</dd></div><div><dt>课程</dt><dd>{{ report.courseName }}</dd></div><div><dt>学习日期</dt><dd>{{ report.studyDate }} @if(report.appointmentAt){ {{ report.appointmentAt | date:'HH:mm' }} }</dd></div><div><dt>课型</dt><dd>{{ report.classTypeLabel }}</dd></div><div><dt>预约编号</dt><dd>{{ report.appointmentId || '历史记录' }}</dd></div></dl>
-    <section class="report-summary"><article><strong>{{ report.learnedCount }}</strong><span>学习单词</span></article><article><strong>{{ report.masteredCount }}</strong><span>已掌握</span></article><article><strong>{{ report.reviewDueCount }}</strong><span>待复习</span></article><article><strong>{{ report.unmarkedCount }}</strong><span>未标记</span></article><article><strong>{{ report.masteryRate | number:'1.0-1' }}%</strong><span>掌握率</span></article></section>
+    @if(reportVariant()==='full'){<section class="report-summary"><article><strong>{{ report.learnedCount }}</strong><span>学习单词</span></article><article><strong>{{ report.masteredCount }}</strong><span>已掌握</span></article><article><strong>{{ report.reviewDueCount }}</strong><span>待复习</span></article><article><strong>{{ report.unmarkedCount }}</strong><span>未标记</span></article><article><strong>{{ report.masteryRate | number:'1.0-1' }}%</strong><span>掌握率</span></article></section>}
+    @if (reportVariant()==='full') {
     <section class="pdf-section"><h2>单词学习明细</h2><table class="word-table"><thead><tr><th>#</th><th>单词 / 音标</th><th>中文释义</th><th>学习结果</th></tr></thead><tbody>@for (word of report.words; track word.index) { <tr class="word-row"><td>{{ word.index }}</td><td><strong>{{ word.word }}</strong><small>{{ word.phonetic || '—' }}</small>@if(word.example){<em>{{ word.example }}</em>}</td><td>{{ word.meaning || '暂无释义' }}</td><td><span [attr.data-result]="word.result">{{ word.resultLabel }}</span></td></tr> } @empty { <tr><td colspan="4">没有可展示的单词明细</td></tr> }</tbody></table></section>
-    @if (report.reviewSchedule.length) { <section class="pdf-section review-section"><h2>21天复习计划</h2><div class="review-grid">@for (item of report.reviewSchedule; track item.day) { <div class="review-item"><strong>第 {{ item.day }} 天</strong><span>{{ item.date }}</span></div> }</div></section> }
-    @if (report.score || report.studentComment || report.coachComment) { <section class="pdf-section"><h2>课程评价</h2><div class="comments"><article class="comment-card"><strong>学员评分</strong><p>{{ report.score ? report.score + ' 分' : '未评分' }}</p></article><article class="comment-card"><strong>学员评价</strong><p>{{ report.studentComment || '暂无评价' }}</p></article><article class="comment-card"><strong>陪练点评</strong><p>{{ report.coachComment || '暂无点评' }}</p></article></div></section> }
+    } @else {
+      <section class="pdf-section"><h2>{{ reportVariant()==='english' ? '看英文,填写中文释义' : '看中文,填写英文单词' }}</h2>
+        <p class="worksheet-hint">共 {{ report.words.length }} 个单词,请独立填写;完成后对照完整学习报表核对。</p>
+        <table class="word-table worksheet-table"><thead><tr><th>#</th><th>{{ reportVariant()==='english' ? '英文单词' : '中文释义' }}</th><th>{{ reportVariant()==='english' ? '填写中文' : '填写英文' }}</th></tr></thead><tbody>
+          @for (word of displayWords(); track word.index) { <tr class="word-row"><td>{{ $index + 1 }}</td><td class="word-prompt">{{ reportVariant()==='english' ? word.word : (word.meaning || '释义缺失') }}</td><td class="answer-space">&#160;</td></tr> }
+          @empty { <tr><td colspan="3">没有可展示的单词明细</td></tr> }
+        </tbody></table>
+      </section>
+    }
+    <section class="pdf-section review-section"><h2>21天复习计划与记录</h2>
+      <p class="review-words"><strong>本次学习词汇({{ report.words.length }} 个):</strong>{{ learnedWords() }}</p>
+      <table class="review-table"><thead><tr><th scope="row">第几天</th>@for (item of reviewSchedule(); track item.day) { <th scope="col">{{ item.day }}</th> }</tr></thead><tbody>
+        <tr><th scope="row">复习日期</th>@for (item of reviewSchedule(); track item.day) { <td>{{ item.date.replaceAll('-', '') }}</td> }</tr>
+        <tr><th scope="row">记住词数</th>@for (item of reviewSchedule(); track item.day) { <td class="review-count">&#160;</td> }</tr>
+        <tr><th scope="row">遗忘词数</th>@for (item of reviewSchedule(); track item.day) { <td class="review-count">&#160;</td> }</tr>
+      </tbody></table>
+      <p class="worksheet-hint">每次复习后填写记住、遗忘的单词数量,未复习的格子留空。@if(hasInferredReviewDates()){ 未设置的复习日期按学习日期推算;日期缺失时请手写补充。 }</p>
+    </section>
+    @if (reportVariant()==='full' && (report.score || report.studentComment || report.coachComment)) { <section class="pdf-section"><h2>课程评价</h2><div class="comments"><article class="comment-card"><strong>学员评分</strong><p>{{ report.score ? report.score + ' 分' : '未评分' }}</p></article><article class="comment-card"><strong>学员评价</strong><p>{{ report.studentComment || '暂无评价' }}</p></article><article class="comment-card"><strong>陪练点评</strong><p>{{ report.coachComment || '暂无点评' }}</p></article></div></section> }
     <footer class="pdf-footer"><span>数据来源:学习记录 {{ report.generalId }}</span><span>生成时间:{{ report.generatedAt | date:'yyyy-MM-dd HH:mm:ss' }}</span></footer>
   </article></div>
-  <footer class="drawer-actions"><span class="report-status" [attr.data-status]="report.status">{{ report.statusLabel }}</span><button class="ui-button primary" type="button" [disabled]="!report.canExport || !!exportingId()" (click)="download(report)"><lucide-icon [img]="exportingId() ? icons.LoaderCircle : icons.Download" [size]="17" [class.spin]="!!exportingId()" />{{ exportingId() ? '正在生成' : '下载 PDF' }}</button></footer></aside>
+  <footer class="drawer-actions"><span class="report-status" [attr.data-status]="report.status">{{ report.statusLabel }}</span><button class="ui-button primary" type="button" [disabled]="!report.canExport || !!exportingId()" (click)="download(report, reportVariant())"><lucide-icon [img]="exportingId() ? icons.LoaderCircle : icons.Download" [size]="17" [class.spin]="!!exportingId()" />{{ exportingId() ? '正在生成' : '下载 PDF' }}</button></footer></aside>
 }

+ 16 - 18
projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.scss

@@ -449,23 +449,21 @@
 .word-table [data-result="unmarked"] {
   color: #7a8581;
 }
-.review-grid {
-  display: grid;
-  grid-template-columns: repeat(5, minmax(0, 1fr));
-  gap: 8px;
-}
-.review-item {
-  display: grid;
-  gap: 4px;
-  padding: 9px;
-  border: 1px solid #dfe8e4;
-  border-radius: 8px;
-  text-align: center;
-  font-size: 10px;
-}
-.review-item span {
-  color: #6e7d78;
-}
+.report-variants { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }
+.report-variants button { padding: 10px 16px; border: 1px solid #dfe7e4; border-radius: 8px; background: white; color: #53665f; cursor: pointer; }
+.report-variants button.selected { background: #176c55; border-color: #176c55; color: white; }
+.report-variants button:disabled { cursor: wait; opacity: .65; }
+.worksheet-hint { font-size: 10px; color: #6e7d78; line-height: 1.7; }
+.worksheet-table th:nth-child(2), .worksheet-table td:nth-child(2) { width: 42%; }
+.worksheet-table th:last-child, .worksheet-table td:last-child { width: 50%; }
+.worksheet-table td { height: 48px; vertical-align: middle; }
+.word-prompt { font-weight: 600; }
+.review-words { font-size: 10px; line-height: 1.8; overflow-wrap: anywhere; }
+.review-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 9px; break-inside: avoid; }
+.review-table th, .review-table td { border: 1px solid #cbd7d1; padding: 9px 1px; text-align: center; }
+.review-table th { background: #eef5f2; font-weight: 600; }
+.review-table th:first-child { width: 60px; }
+.review-table .review-count { height: 32px; }
 .comments {
   display: grid;
   grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -573,7 +571,7 @@
 }
 @media print {
   .word-row,
-  .review-item,
+  .review-table,
   .comment-card {
     break-inside: avoid;
   }

+ 74 - 1
projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.spec.ts

@@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
 import { LearningReportDetail, LearningReportPage } from '../admin.models';
 import { OperationsService } from '../operations.service';
-import { OperationsLearningReportsComponent } from './operations-learning-reports.component';
+import { OperationsLearningReportsComponent, printableReviewSchedule, shuffleReportWords } from './operations-learning-reports.component';
 
 const sourceStatus = { state: 'synced' as const, label: '学习记录已同步', message: '', lastSuccessAt: '2026-09-04T12:00:00.000Z', lagSeconds: 0 };
 const report = {
@@ -124,4 +124,77 @@ describe('OperationsLearningReportsComponent', () => {
     expect(component.searching()).toBeFalse();
     expect(queryButton?.textContent).toContain('查找');
   });
+  it('prints only the selected language without answers in examples, comments or the review word list', async () => {
+    const component = fixture.componentInstance;
+    await component.open('study-common-1');
+    const original = component.detail()!;
+    component.detail.set({ ...original, words: [{ ...original.words[0], phonetic: '/apple-hint/', example: 'apple 苹果' }], coachComment: 'apple 苹果' });
+    for (const variant of ['english', 'chinese'] as const) {
+      component.selectVariant(variant); fixture.detectChanges();
+      const sheet = (fixture.nativeElement as HTMLElement).querySelector('.pdf-sheet')!;
+      expect(sheet.textContent).toContain(variant === 'english' ? 'apple' : '苹果');
+      expect(sheet.textContent).not.toContain(variant === 'english' ? '苹果' : 'apple');
+      expect(sheet.querySelectorAll('.answer-space').length).toBe(1);
+      expect(sheet.querySelector('.comments')).toBeNull();
+      expect(sheet.querySelectorAll('.review-count').length).toBe(20);
+      expect(Array.from(sheet.querySelectorAll('.review-count')).every((cell) => !cell.textContent?.trim())).toBeTrue();
+    }
+    component.selectVariant('full'); fixture.detectChanges();
+    const sheet = (fixture.nativeElement as HTMLElement).querySelector('.pdf-sheet')!;
+    expect(sheet.textContent).toContain('apple');
+    expect(sheet.textContent).toContain('苹果');
+    expect(sheet.querySelector('.comments')).not.toBeNull();
+  });
+
+  it('creates ten review dates across months and years and preserves stored dates', async () => {
+    await fixture.componentInstance.open('study-common-1');
+    const detail = fixture.componentInstance.detail()!;
+    const schedule = printableReviewSchedule({ ...detail, studyDate: '2026-09-21', reviewSchedule: [] });
+    expect(schedule.map((item) => item.day)).toEqual([1, 2, 3, 5, 7, 9, 11, 14, 17, 21]);
+    expect(schedule.map((item) => item.date)).toEqual(['2026-09-22', '2026-09-23', '2026-09-24', '2026-09-26', '2026-09-28', '2026-09-30', '2026-10-02', '2026-10-05', '2026-10-08', '2026-10-12']);
+    expect(printableReviewSchedule({ ...detail, studyDate: '2026-12-31', reviewSchedule: [] })[0].date).toBe('2027-01-01');
+    expect(printableReviewSchedule({ ...detail, studyDate: '2028-02-28', reviewSchedule: [] })[0].date).toBe('2028-02-29');
+    expect(printableReviewSchedule({ ...detail, studyDate: '2026-09-21' })[0]).toEqual({ day: 1, date: '2026-09-05', inferred: false });
+    expect(printableReviewSchedule({ ...detail, studyDate: '', reviewSchedule: [] }).every((item) => item.date === '')).toBeTrue();
+    expect(printableReviewSchedule({ ...detail, studyDate: '2026-02-30', reviewSchedule: [] }).every((item) => item.date === '')).toBeTrue();
+  });
+
+  it('keeps the report stable while exporting and resets the variant on a new preview', async () => {
+    const component = fixture.componentInstance;
+    await component.open('study-common-1');
+    component.selectVariant('english');
+    component.exportingId.set('study-common-1');
+    component.selectVariant('chinese'); component.close();
+    await component.open('another-report');
+    expect(component.reportVariant()).toBe('english');
+    expect(component.detail()?.targetId).toBe('study-common-1');
+    expect(operations.learningReportDetail).toHaveBeenCalledTimes(1);
+    component.exportingId.set('');
+    await component.open('study-common-1');
+    expect(component.reportVariant()).toBe('full');
+  });
+
+  it('reshuffles either worksheet on each entry without changing the original report', async () => {
+    const component = fixture.componentInstance;
+    await component.open('study-common-1');
+    const original = component.detail()!;
+    const words = ['apple', 'banana', 'orange', 'grape'].map((word, index) => ({ ...original.words[0], word, index: index + 1, generalId: index + 1 }));
+    component.detail.set({ ...original, words });
+    spyOn(Math, 'random').and.returnValue(0.999);
+    component.selectVariant('english');
+    const first = component.displayWords().map((word) => word.word);
+    expect(first).not.toEqual(words.map((word) => word.word));
+    component.selectVariant('chinese');
+    const second = component.displayWords().map((word) => word.word);
+    expect(second).not.toEqual(first);
+    component.selectVariant('chinese');
+    expect(component.displayWords().map((word) => word.word)).not.toEqual(second);
+    expect([...component.displayWords()].sort((a, b) => a.index - b.index)).toEqual(words);
+    component.selectVariant('full');
+    expect(component.displayWords()).toEqual(words);
+    expect(words.map((word) => word.word)).toEqual(['apple', 'banana', 'orange', 'grape']);
+    expect(shuffleReportWords([])).toEqual([]);
+    expect(shuffleReportWords([words[0]])).toEqual([words[0]]);
+  });
+
 });

+ 59 - 8
projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.ts

@@ -1,11 +1,11 @@
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, RouterLink } from '@angular/router';
 import html2pdf from 'html2pdf.js';
 import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, LucideAngularModule, RefreshCw, RotateCcw, Search, X } from 'lucide-angular';
-import { LearningReportDetail, LearningReportListItem, LearningReportPage } from '../admin.models';
+import { LearningReportDetail, LearningReportListItem, LearningReportPage, LearningReportWord } from '../admin.models';
 import { OperationsService } from '../operations.service';
 import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
 
@@ -14,6 +14,32 @@ const localMonth = (): string => {
   return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
 };
 
+type ReportVariant = 'full' | 'english' | 'chinese';
+const reviewDays = [1, 2, 3, 5, 7, 9, 11, 14, 17, 21];
+export function shuffleReportWords(words: LearningReportWord[], previous: LearningReportWord[] = words): LearningReportWord[] {
+  const shuffled = [...words];
+  for (let index = shuffled.length - 1; index > 0; index--) {
+    const next = Math.floor(Math.random() * (index + 1));
+    [shuffled[index], shuffled[next]] = [shuffled[next], shuffled[index]];
+  }
+  // Avoid showing the exact same sequence on consecutive visits.
+  if (shuffled.length > 1 && shuffled.every((word, index) => word.index === previous[index]?.index && word.generalId === previous[index]?.generalId)) {
+    shuffled.push(shuffled.shift()!);
+  }
+  return shuffled;
+}
+
+const reportTitles: Record<ReportVariant, string> = { full: '单词学习报表', english: '英文填中文复习表', chinese: '中文填英文复习表' };
+
+export function printableReviewSchedule(report: LearningReportDetail): Array<{ day: number; date: string; inferred: boolean }> {
+  const base = /^\d{4}-\d{2}-\d{2}$/.test(report.studyDate) ? new Date(`${report.studyDate}T00:00:00Z`) : null;
+  const validBase = base && !Number.isNaN(base.getTime()) && base.toISOString().slice(0, 10) === report.studyDate;
+  return reviewDays.map((day) => {
+    const stored = report.reviewSchedule?.find((item) => item.day === day)?.date;
+    return { day, date: stored || (validBase ? new Date(base.getTime() + day * 86400000).toISOString().slice(0, 10) : ''), inferred: !stored };
+  });
+}
+
 type ReportDatePicker = 'month' | 'from' | 'to';
 interface CalendarDay { day: number; date: string; }
 
@@ -30,6 +56,13 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
 
   readonly data = signal<LearningReportPage | null>(null);
   readonly detail = signal<LearningReportDetail | null>(null);
+  readonly reportVariant = signal<ReportVariant>('full');
+  private readonly worksheetWords = signal<LearningReportWord[]>([]);
+  readonly displayWords = computed(() => this.reportVariant() === 'full' ? (this.detail()?.words || []) : this.worksheetWords());
+  readonly reportTitle = computed(() => reportTitles[this.reportVariant()]);
+  readonly reviewSchedule = computed(() => this.detail() ? printableReviewSchedule(this.detail()!) : []);
+  readonly hasInferredReviewDates = computed(() => this.reviewSchedule().some((item) => item.inferred));
+  readonly learnedWords = computed(() => this.displayWords().map((word) => this.reportVariant() === 'chinese' ? (word.meaning || '释义缺失') : word.word).join(';') || '暂无单词明细');
   readonly loading = signal(true);
   readonly searching = signal(false);
   readonly refreshing = signal(false);
@@ -172,19 +205,37 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
   }
 
   async open(targetId: string): Promise<void> {
+    if (this.exportingId() || this.detailLoading()) return;
+    this.reportVariant.set('full');
     this.detailLoading.set(true); this.error.set('');
     try { this.detail.set(await this.operations.learningReportDetail(targetId)); }
     catch (error) { this.error.set(error instanceof Error ? error.message : '报表详情加载失败'); }
     finally { this.detailLoading.set(false); }
   }
 
-  close(): void { this.detail.set(null); }
+  close(): void { if (!this.exportingId()) this.detail.set(null); }
+
+  selectVariant(variant: ReportVariant): void {
+    if (this.exportingId() || this.detailLoading()) return;
+    if (variant !== 'full') {
+      const words = this.detail()?.words || [];
+      this.worksheetWords.set(shuffleReportWords(words, this.worksheetWords().length ? this.worksheetWords() : words));
+    }
+    this.reportVariant.set(variant);
+  }
 
-  async download(row: LearningReportListItem | LearningReportDetail): Promise<void> {
-    if (!row.canExport || this.exportingId()) return;
+  async download(row: LearningReportListItem | LearningReportDetail, variant: ReportVariant = 'full'): Promise<void> {
+    if (!row.canExport || this.exportingId() || this.detailLoading()) return;
     this.exportingId.set(row.targetId); this.error.set('');
     try {
       const report = await this.operations.learningReportExport(row.targetId);
+      if (variant !== 'full') {
+        const previous = this.worksheetWords();
+        const fresh = new Map(report.words.map((word) => [`${word.generalId}:${word.index}`, word]));
+        const sameWords = previous.length === report.words.length && previous.every((word) => fresh.has(`${word.generalId}:${word.index}`));
+        this.worksheetWords.set(sameWords ? previous.map((word) => fresh.get(`${word.generalId}:${word.index}`)!) : shuffleReportWords(report.words));
+      }
+      this.reportVariant.set(variant);
       this.detail.set(report);
       await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
       await document.fonts?.ready;
@@ -193,11 +244,11 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
       const safeName = `${report.studentName}-${report.studyDate}-${report.generalId}`.replace(/[^a-zA-Z0-9一-龥_-]/g, '_');
       let hasBlankTrailingPage = false;
       const worker = html2pdf().set({
-        margin: [9, 9, 9, 9], filename: `小树陪练-单词学习报表-${safeName}.pdf`,
+        margin: [9, 9, 9, 9], filename: `小树陪练-${reportTitles[variant]}-${safeName}.pdf`,
         image: { type: 'jpeg', quality: 1 },
         html2canvas: { scale: 1.5, logging: false, useCORS: true, backgroundColor: '#ffffff' },
         jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
-        pagebreak: { mode: ['css', 'legacy'], avoid: ['.report-summary article', '.word-row', '.review-item', '.comment-card'] },
+        pagebreak: { mode: ['css', 'legacy'], avoid: ['.report-summary article', '.word-row', '.review-table', '.worksheet-hint', '.pdf-footer', '.comment-card'] },
       }).from(element).toCanvas().get('canvas', (canvas) => {
         const pageHeight = Math.floor(canvas.width * (279 / 192));
         const trailingSlice = canvas.height % pageHeight;
@@ -207,7 +258,7 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
         const pageCount = pdf.internal.getNumberOfPages();
         if (hasBlankTrailingPage && pageCount > 1) pdf.deletePage(pageCount);
       }).save();
-      this.message.set('学习报表 PDF 已生成');
+      this.message.set(`${reportTitles[variant]} PDF 已生成`);
     } catch (error) {
       this.error.set(error instanceof Error ? error.message : 'PDF 生成失败');
     } finally { this.exportingId.set(''); }