Pārlūkot izejas kodu

feat(mobile): download all three learning report PDF variants

彭峰 11 stundas atpakaļ
vecāks
revīzija
0a239a4011

+ 2 - 5
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report-download.service.ts

@@ -2,13 +2,10 @@ import { Injectable } from '@angular/core';
 
 @Injectable({ providedIn: 'root' })
 export class LessonReportDownloadService {
-  async save(element: HTMLElement, filename: string): Promise<void> {
-    const blob = await this.createPdf(element);
-    const url = URL.createObjectURL(blob);
+  download(file: {url:string;filename:string}): void {
     const link = document.createElement('a');
-    link.href = url; link.download = filename;
+    link.href = file.url; link.download = file.filename;
     document.body.appendChild(link); link.click(); link.remove();
-    setTimeout(() => URL.revokeObjectURL(url), 60_000);
   }
 
   async createPdf(element: HTMLElement): Promise<Blob> {

+ 18 - 2
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report-pdf.component.html

@@ -1,9 +1,23 @@
 @for (report of reports(); track report.generalId) {
-  <article class="pdf-sheet">
-    <section class="pdf-title"><div class="brand-mark"><lucide-icon [img]="icon" [size]="28" /></div><div><p>小树陪练</p><h1>单词学习报表</h1></div><span>NO. {{ report.generalId }}</span></section>
+  <article class="pdf-sheet" [class.worksheet-sheet]="variant()!=='full'">
+    <section class="pdf-title"><div class="brand-mark"><lucide-icon [img]="icon" [size]="28" /></div><div><p>小树陪练</p><h1>{{ title() }}</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.slice(11, 16) }} }</dd></div><div><dt>课型</dt><dd>{{ report.classTypeLabel }}</dd></div><div><dt>预约编号</dt><dd>{{ report.appointmentId || '历史记录' }}</dd></div></dl>
+    @if(variant()==='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>
     <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>
+    } @else {
+      <section class="pdf-section"><h2>{{ variant()==='english' ? '看英文,填写中文释义' : '看中文,填写英文单词' }}</h2>
+        <p class="worksheet-hint">共 {{ report.words.length }} 个单词,请独立填写;完成后对照完整学习报表核对。</p>
+        <table class="word-table worksheet-table"><thead><tr><th>{{ variant()==='english' ? '英文单词' : '中文释义' }}</th><th>{{ variant()==='english' ? '填写中文' : '填写英文' }}</th><th>{{ variant()==='english' ? '英文单词' : '中文释义' }}</th><th>{{ variant()==='english' ? '填写中文' : '填写英文' }}</th></tr></thead><tbody>
+          @for (pair of worksheetRows(report.words); track $index) {
+            <tr class="word-row"><td class="word-prompt">{{ variant()==='english' ? pair[0].word : (pair[0].meaning || '释义缺失') }}</td><td class="answer-space">&#160;</td>
+              @if(pair[1]; as word) { <td class="word-prompt">{{ variant()==='english' ? word.word : (word.meaning || '释义缺失') }}</td><td class="answer-space">&#160;</td> }
+              @else { <td colspan="2" class="worksheet-empty"></td> }
+            </tr>
+          }
+        </tbody></table>
+      </section>
+    }
     <section class="pdf-section review-section"><h2>21天复习计划与记录</h2>
       <p class="review-words">本次学习 {{ report.words.length }} 个单词,词汇见上方列表。</p>
       <table class="review-table"><thead><tr><th scope="row">第几天</th>@for (item of report.reviewSchedule; track item.day) { <th scope="col">{{ item.day }}</th> }</tr></thead><tbody>
@@ -12,6 +26,7 @@
       </tbody></table>
       <p class="worksheet-hint">每次复习后填写遗忘的单词数量,未复习的格子留空。@if(report.hasInferredReviewDates){ 未设置的复习日期按学习日期推算;日期缺失时请手写补充。 }</p>
     </section>
+    @if(variant()==='full') {
     @if ((report.score || report.studentComment || report.coachComment)) { <section class="pdf-section evaluation-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> }
     @if (report.feedback; as feedback) {
       <section class="pdf-section feedback-section"><h2>完课反馈</h2><div class="feedback-grid">
@@ -21,5 +36,6 @@
       </div></section>
     }
     <footer class="pdf-footer"><span>数据来源:学习记录 {{ report.generalId }}</span><span>生成时间:{{ report.generatedAt }}</span></footer>
+    }
   </article>
 }

+ 13 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report-pdf.component.scss

@@ -196,3 +196,16 @@
 .pdf-sheet + .pdf-sheet { break-before:page; }
 .feedback-grid { display:grid; gap:9px; grid-template-columns:1fr 1fr; }
 .comment-card p { white-space:pre-wrap; }
+
+.worksheet-table th:nth-child(odd), .worksheet-table td:nth-child(odd) { width: 24%; }
+.worksheet-table th:nth-child(even), .worksheet-table td:nth-child(even) { width: 26%; }
+.worksheet-table th:first-child, .worksheet-table td:first-child { text-align: left; }
+.worksheet-table td { height: 42px; vertical-align: middle; }
+.worksheet-table .worksheet-empty { background: #fff; }
+.worksheet-sheet { min-height: 0; padding-bottom: 7mm; }
+.worksheet-sheet .pdf-title { padding-bottom: 14px; }
+.worksheet-sheet .report-meta { margin: 15px 0 10px; }
+.worksheet-sheet .report-meta div { padding: 8px 10px; }
+.worksheet-sheet .pdf-section { margin-top: 14px; }
+.worksheet-sheet .worksheet-table th, .worksheet-sheet .worksheet-table td { padding: 7px 6px; }
+.word-prompt { font-weight: 600; }

+ 9 - 2
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report-pdf.component.ts

@@ -1,7 +1,7 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
 import { FileChartColumn, LucideAngularModule } from 'lucide-angular';
-import { LessonPdfReport } from './lesson-report-pdf';
+import { LessonPdfReport, LessonPdfVariant, lessonPdfVariants } from './lesson-report-pdf';
 
 @Component({
   selector: 'app-lesson-report-pdf', standalone: true, imports: [CommonModule, LucideAngularModule],
@@ -10,5 +10,12 @@ import { LessonPdfReport } from './lesson-report-pdf';
 })
 export class LessonReportPdfComponent {
   readonly reports = input.required<LessonPdfReport[]>();
+  readonly variant = input<LessonPdfVariant>('full');
+  readonly title = computed(() => lessonPdfVariants.find(item=>item.key===this.variant())!.title);
   readonly icon = FileChartColumn;
+  worksheetRows(words: LessonPdfReport['words']): Array<[LessonPdfReport['words'][number], LessonPdfReport['words'][number]?]> {
+    const rows: Array<[LessonPdfReport['words'][number], LessonPdfReport['words'][number]?]> = [];
+    for(let index=0;index<words.length;index+=2) rows.push([words[index],words[index+1]]);
+    return rows;
+  }
 }

+ 18 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report-pdf.ts

@@ -47,3 +47,21 @@ export function lessonPdfReport(report: LessonReportData, record: Record<string,
   };
 }
 export type LessonPdfReport = ReturnType<typeof lessonPdfReport>;
+
+export type LessonPdfVariant = 'full' | 'english' | 'chinese';
+export const lessonPdfVariants: Array<{key:LessonPdfVariant; label:string; title:string}> = [
+  {key:'full',label:'完整学习报表',title:'单词学习报表'},
+  {key:'english',label:'英文填中文',title:'英文填中文复习表'},
+  {key:'chinese',label:'中文填英文',title:'中文填英文复习表'},
+];
+export function worksheetReports(reports: LessonPdfReport[]): LessonPdfReport[] {
+  return reports.map(report => {
+    const words = [...report.words];
+    for (let index=words.length-1;index>0;index--) {
+      const next=Math.floor(Math.random()*(index+1));
+      [words[index],words[next]]=[words[next],words[index]];
+    }
+    if(words.length>1 && words.every((word,index)=>word===report.words[index])) words.push(words.shift()!);
+    return {...report,words};
+  });
+}

+ 14 - 3
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.html

@@ -44,15 +44,26 @@
   } @else if (loading()) { <div class="loading-state" role="status">正在整理本次学习报告…</div> }
   <footer>
     @if (session.groupId() === 3) {
-      <button class="download-pdf" type="button" (click)="downloadPdf()" [disabled]="!report() || loading() || exporting()">
-        <lucide-icon [img]="exporting() ? icons.LoaderCircle : icons.Download" [class.spin]="exporting()" [size]="19" />{{ exporting() ? '正在生成 PDF…' : '下载 PDF' }}
+      <fieldset class="pdf-options" [disabled]="exporting()"><legend>选择下载内容</legend>
+        @for (variant of pdfVariants; track variant.key) {
+          <label><input type="checkbox" [checked]="selectedVariants().includes(variant.key)" (change)="toggleVariant(variant.key)" />{{ variant.label }}</label>
+        }
+      </fieldset>
+      <button class="download-pdf" type="button" (click)="downloadPdf()" [disabled]="!report() || loading() || exporting() || !selectedVariants().length">
+        <lucide-icon [img]="exporting() ? icons.LoaderCircle : icons.Download" [class.spin]="exporting()" [size]="19" />{{ exporting() ? exportProgress() : '下载 PDF(' + selectedVariants().length + '份)' }}
       </button>
     }
     <a routerLink="/pages/his/his">查看学习记录</a>
   </footer>
   @if (exportError()) { <p class="export-error" role="alert">{{ exportError() }}</p> }
   @if (exportMessage()) { <p class="export-message" role="status">{{ exportMessage() }}</p> }
+  @if (generatedFiles().length) {
+    <section class="generated-files" aria-label="已生成的 PDF">
+      <p>如浏览器拦截了多文件下载,请允许下载多个文件,或点击下方文件分别保存。</p>
+      @for (file of generatedFiles(); track file.url) { <a [href]="file.url" [download]="file.filename">{{ file.label }} PDF</a> }
+    </section>
+  }
   @if (pdfReports().length) {
-    <div class="pdf-render" aria-hidden="true"><div #pdfContent><app-lesson-report-pdf [reports]="pdfReports()" /></div></div>
+    <div class="pdf-render" aria-hidden="true"><div #pdfContent><app-lesson-report-pdf [reports]="pdfReports()" [variant]="renderVariant()" /></div></div>
   }
 </section>

+ 8 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.scss

@@ -31,3 +31,11 @@ footer a { margin-top:0; }
 .pdf-render { position:fixed; left:-10000px; top:0; width:192mm; pointer-events:none; }
 .spin { animation:report-spin 1s linear infinite; }
 @keyframes report-spin { to { transform:rotate(360deg); } }
+
+.pdf-options { display:flex; flex-wrap:wrap; gap:8px 16px; margin:0; padding:12px 16px; border:1px solid var(--line); border-radius:12px; background:white; }
+.pdf-options legend { padding:0 6px; font-size:13px; color:var(--ink-soft); }
+.pdf-options label { display:flex; align-items:center; gap:8px; min-height:44px; font-size:14px; cursor:pointer; }
+.pdf-options input { width:18px; height:18px; accent-color:var(--brand-strong); }
+.generated-files { margin-top:12px; padding:16px; border:1px solid var(--line); border-radius:12px; background:white; }
+.generated-files p { margin:0 0 8px; font-size:12px; line-height:1.7; color:var(--ink-soft); }
+.generated-files a { display:flex; align-items:center; min-height:44px; color:var(--brand-strong); font-size:14px; }

+ 73 - 19
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.spec.ts

@@ -7,7 +7,7 @@ import { SessionService } from '../../core/session.service';
 import { LessonReportComponent } from './lesson-report.component';
 import { LessonReportPdfComponent } from './lesson-report-pdf.component';
 import { LessonReportDownloadService } from './lesson-report-download.service';
-import { LessonReportData, lessonPdfReport } from './lesson-report-pdf';
+import { LessonReportData, lessonPdfReport, worksheetReports, lessonPdfVariants } from './lesson-report-pdf';
 
 const reportFixture = (): LessonReportData => ({
   order: {GeneralID:901, szyh:20, HoneyName:'测试学员', plxm:'测试老师', yykcmc:'九年级词汇', yysj:'2026-10-22 14:00', dszt:30, dslx:2, teacherPay:30},
@@ -30,8 +30,8 @@ describe('teacher learning report PDF download', () => {
     report = reportFixture(); groupId.set(3);
     api = jasmine.createSpyObj('ApiService',['get']);
     api.get.and.callFake((action: string) => of({retcode:0,result:(action==='app_lesson_report'?report:action==='e_record_detail'?wordsFixture():{batches:[]}) as any}));
-    download = jasmine.createSpyObj('LessonReportDownloadService',['save']);
-    download.save.and.resolveTo();
+    download = jasmine.createSpyObj('LessonReportDownloadService',['createPdf','download']);
+    download.createPdf.and.resolveTo(new Blob(['%PDF-test'],{type:'application/pdf'}));
     TestBed.configureTestingModule({providers:[provideRouter([]),
       {provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({sid:'901'})}}},
       {provide:ApiService,useValue:api},{provide:SessionService,useValue:{groupId}},
@@ -40,23 +40,66 @@ describe('teacher learning report PDF download', () => {
   });
   function screen() { const fixture=TestBed.createComponent(LessonReportComponent);fixture.detectChanges();return fixture; }
   it('offers the teacher a footer download and fetches saved word results before rendering the full report',async()=>{
-    const fixture=screen();
+    const fixture=screen();fixture.componentInstance.selectedVariants.set(['full']);
     expect(fixture.nativeElement.querySelector('footer .download-pdf').textContent).toContain('下载 PDF');
     expect(fixture.nativeElement.querySelector('footer a').textContent).toContain('查看学习记录');
-    download.save.and.callFake(async(element, filename)=>{
-      expect(filename).toBe('小树陪练-单词学习报表-测试学员-2026-10-22-901.pdf');
+    download.createPdf.and.callFake(async(element)=>{
       expect(element.querySelectorAll('.word-row').length).toBe(3);
       expect(element.textContent).toContain('n.灯笼;提灯');
       expect(element.textContent).toContain('已掌握');expect(element.textContent).toContain('待复习');expect(element.textContent).toContain('未标记');
       expect(element.textContent).toContain('21天复习计划');expect(element.textContent).toContain('完成课后复习');
       expect(element.textContent).not.toMatch(/工资|收入|¥/);
+      return new Blob(['%PDF-test']);
     });
     await fixture.componentInstance.downloadPdf();fixture.detectChanges();
     expect(api.get).toHaveBeenCalledWith('e_record_detail',{id:1001,studentId:20});
-    expect(download.save).toHaveBeenCalledTimes(1);
-    expect(fixture.nativeElement.querySelector('[role="status"]').textContent).toContain('PDF 已生成');
+    expect(download.download).toHaveBeenCalledTimes(1);
+    expect(fixture.nativeElement.querySelector('[role="status"]').textContent).toContain('已生成 1 份 PDF');
     expect(fixture.nativeElement.querySelector('.pdf-render')).toBeNull();
   });
+  it('downloads all three formats by default and keeps answers out of the worksheets',async()=>{
+    const fixture=screen();const titles:string[]=[];
+    expect(fixture.nativeElement.querySelectorAll('.pdf-options input:checked').length).toBe(3);
+    download.createPdf.and.callFake(async element=>{
+      titles.push(element.querySelector('h1')!.textContent!);
+      const table=element.querySelector('.worksheet-table');
+      if(titles.length===2) {
+        expect(table?.textContent).toContain('lantern');expect(table?.textContent).not.toContain('灯笼');
+      } else if(titles.length===3) {
+        expect(table?.textContent).toContain('灯笼');expect(table?.textContent).not.toContain('lantern');
+      }
+      if(table) {
+        expect(table.querySelectorAll('.answer-space').length).toBe(3);
+        expect(element.querySelector('.report-summary')).toBeNull();expect(element.querySelector('.feedback-section')).toBeNull();
+      }
+      expect(download.download).not.toHaveBeenCalled();
+      return new Blob(['%PDF-'+titles.length]);
+    });
+    await fixture.componentInstance.downloadPdf();fixture.detectChanges();
+    expect(titles).toEqual(['单词学习报表','英文填中文复习表','中文填英文复习表']);
+    expect(download.download).toHaveBeenCalledTimes(3);
+    expect(new Set(download.download.calls.allArgs().map(args=>args[0].filename)).size).toBe(3);
+    expect(fixture.nativeElement.querySelectorAll('.generated-files a[download]').length).toBe(3);
+    expect(api.get.calls.allArgs().filter(args=>args[0]==='e_record_detail').length).toBe(1);
+    const revoke=spyOn(URL,'revokeObjectURL');fixture.destroy();expect(revoke).toHaveBeenCalledTimes(3);
+  });
+  it('downloads only selected formats and prevents an empty selection',async()=>{
+    const fixture=screen();const choices=fixture.nativeElement.querySelectorAll('.pdf-options input');
+    choices[0].click();choices[2].click();fixture.detectChanges();
+    await fixture.componentInstance.downloadPdf();
+    expect(download.download).toHaveBeenCalledTimes(1);
+    expect(download.download.calls.mostRecent().args[0].filename).toContain('英文填中文复习表');
+    fixture.detectChanges();choices[1].click();fixture.detectChanges();expect(fixture.nativeElement.querySelector('.download-pdf').disabled).toBeTrue();
+    await fixture.componentInstance.downloadPdf();expect(download.download).toHaveBeenCalledTimes(1);
+  });
+  it('does not start partial downloads when a later PDF fails to generate',async()=>{
+    const fixture=screen();let count=0;
+    download.createPdf.and.callFake(async()=>{if(++count===2)throw Error('复习表生成失败');return new Blob(['%PDF-test']);});
+    await fixture.componentInstance.downloadPdf();
+    expect(download.download).not.toHaveBeenCalled();expect(fixture.componentInstance.generatedFiles()).toEqual([]);
+    expect(fixture.componentInstance.exportError()).toContain('复习表生成失败');
+    expect(fixture.componentInstance.exporting()).toBeFalse();
+  });
   it('blocks duplicate clicks and enables retry after a failed record read',async()=>{
     const fixture=screen();const pending=new Subject<any>();
     api.get.and.callFake((action:string)=>action==='e_record_detail'?pending:of({retcode:0,result:report as any}));
@@ -65,21 +108,21 @@ describe('teacher learning report PDF download', () => {
     expect(fixture.nativeElement.querySelector('.download-pdf').disabled).toBeTrue();
     expect(api.get.calls.allArgs().filter(args=>args[0]==='e_record_detail').length).toBe(1);
     pending.error(new Error('单词读取失败'));await first;fixture.detectChanges();
-    expect(download.save).not.toHaveBeenCalled();expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('单词读取失败');
+    expect(download.download).not.toHaveBeenCalled();expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('单词读取失败');
     expect(fixture.nativeElement.querySelector('.download-pdf').disabled).toBeFalse();
     api.get.and.callFake((action:string)=>of({retcode:0,result:(action==='e_record_detail'?wordsFixture():report) as any}));
-    await fixture.componentInstance.downloadPdf();expect(download.save).toHaveBeenCalledTimes(1);
+    await fixture.componentInstance.downloadPdf();expect(download.download).toHaveBeenCalledTimes(3);
   });
   it('does not export incomplete lessons or empty record lists',async()=>{
     const fixture=screen();report.order['dszt']=10;
     await fixture.componentInstance.downloadPdf();expect(fixture.componentInstance.exportError()).toContain('请先完成课程');
     report.order['dszt']=30;report.records=[];
     await fixture.componentInstance.downloadPdf();expect(fixture.componentInstance.exportError()).toContain('暂无学习记录');
-    expect(download.save).not.toHaveBeenCalled();
+    expect(download.download).not.toHaveBeenCalled();
   });
   it('keeps all saved records in one file and reports generation failures',async()=>{
     report.records.push({...report.records[0],generalId:1002});const fixture=screen();
-    download.save.and.callFake(async element=>{
+    download.createPdf.and.callFake(async element=>{
       expect(element.querySelectorAll('.pdf-sheet').length).toBe(2);throw new Error('PDF 生成失败');
     });
     await fixture.componentInstance.downloadPdf();
@@ -89,7 +132,7 @@ describe('teacher learning report PDF download', () => {
   it('keeps the teacher-only download out of the student screen',async()=>{
     groupId.set(1);const fixture=screen();
     expect(fixture.nativeElement.querySelector('.download-pdf')).toBeNull();
-    await fixture.componentInstance.downloadPdf();expect(download.save).not.toHaveBeenCalled();
+    await fixture.componentInstance.downloadPdf();expect(download.download).not.toHaveBeenCalled();
   });
 });
 
@@ -101,17 +144,28 @@ describe('learning report PDF rendering',()=>{
     expect(pdf.reviewSchedule[0].date).toBe('2026-10-24');expect(pdf.reviewSchedule[9].date).toBe('2026-11-12');
     expect(lessonPdfReport(report,{dqrq:'2026-02-30'},wordsFixture()).reviewSchedule[0].date).toBe('');
   });
+  it('shuffles complete worksheets without changing the full report or losing words',()=>{
+    const report=lessonPdfReport(reportFixture(),reportFixture().records[0],wordsFixture());
+    const before=report.words.map(word=>word.word);spyOn(Math,'random').and.returnValue(.9999);
+    const worksheet=worksheetReports([report])[0];
+    expect(worksheet.words.map(word=>word.word)).not.toEqual(before);
+    expect(worksheet.words.map(word=>word.word).sort()).toEqual([...before].sort());
+    expect(report.words.map(word=>word.word)).toEqual(before);
+  });
   it('generates a real multipage A4 PDF with the printable Angular report',async()=>{
     const fixture=TestBed.createComponent(LessonReportPdfComponent);
     const words=Array.from({length:45},(_,index)=>({...wordsFixture()[index%3],GeneralID:index+1}));
     const pdfReport=lessonPdfReport(reportFixture(),reportFixture().records[0],words);
     fixture.componentRef.setInput('reports',[pdfReport]);fixture.detectChanges();
     const service=new LessonReportDownloadService();
-    const blob=await service.createPdf(fixture.nativeElement);
-    expect(blob.type).toBe('application/pdf');expect(blob.size).toBeGreaterThan(20_000);
-    const text=await blob.text();expect(text.startsWith('%PDF-')).toBeTrue();
-    expect((text.match(/\/Type \/Page\b/g)||[]).length).toBeGreaterThan(1);
-    const dimensions=text.match(/\/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]/);
-    expect(Number(dimensions?.[1])).toBeCloseTo(595.28,1);expect(Number(dimensions?.[2])).toBeCloseTo(841.89,1);
+    for(const variant of lessonPdfVariants) {
+      fixture.componentRef.setInput('variant',variant.key);fixture.detectChanges();
+      const blob=await service.createPdf(fixture.nativeElement);
+      expect(blob.type).toBe('application/pdf');expect(blob.size).toBeGreaterThan(20_000);
+      const text=await blob.text();expect(text.startsWith('%PDF-')).toBeTrue();
+      if(variant.key==='full') expect((text.match(/\/Type \/Page\b/g)||[]).length).toBeGreaterThan(1);
+      const dimensions=text.match(/\/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]/);
+      expect(Number(dimensions?.[1])).toBeCloseTo(595.28,1);expect(Number(dimensions?.[2])).toBeCloseTo(841.89,1);
+    }
   },30_000);
 });

+ 42 - 12
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.ts

@@ -1,6 +1,6 @@
 import { lessonStudySources } from './lesson-study-sources';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, inject, OnInit, signal, ViewChild } from '@angular/core';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, inject, OnInit, signal, ViewChild } from '@angular/core';
 import { ActivatedRoute, RouterLink } from '@angular/router';
 import { BookOpen, BrainCircuit, ClipboardList, Download, LoaderCircle, LucideAngularModule, RefreshCw, Star } from 'lucide-angular';
 import { finalize, firstValueFrom } from 'rxjs';
@@ -9,7 +9,7 @@ import { LessonFeedbackComponent } from './lesson-feedback.component';
 import { lessonDateLabel } from './lesson-display';
 
 import { SessionService } from '../../core/session.service';
-import { LessonReportData, LessonPdfReport, lessonPdfReport } from './lesson-report-pdf';
+import { LessonReportData, LessonPdfReport, LessonPdfVariant, lessonPdfVariants, lessonPdfReport, worksheetReports } from './lesson-report-pdf';
 import { LessonReportPdfComponent } from './lesson-report-pdf.component';
 import { LessonReportDownloadService } from './lesson-report-download.service';
 
@@ -26,6 +26,12 @@ export class LessonReportComponent implements OnInit {
   readonly icons = {BookOpen, BrainCircuit, ClipboardList, Download, LoaderCircle, RefreshCw, Star};
   readonly dateLabel = lessonDateLabel;
   readonly exporting = signal(false);
+  readonly pdfVariants = lessonPdfVariants;
+  readonly selectedVariants = signal<LessonPdfVariant[]>(['full','english','chinese']);
+  readonly renderVariant = signal<LessonPdfVariant>('full');
+  readonly exportProgress = signal('');
+  readonly generatedFiles = signal<Array<{url:string;filename:string;label:string}>>([]);
+  private readonly destroyRef = inject(DestroyRef);
   readonly exportError = signal('');
   readonly exportMessage = signal('');
   readonly pdfReports = signal<LessonPdfReport[]>([]);
@@ -36,7 +42,18 @@ export class LessonReportComponent implements OnInit {
   private readonly api = inject(ApiService);
   private readonly route = inject(ActivatedRoute);
   get orderId(): string { return this.route.snapshot.queryParamMap.get('sid') || this.route.snapshot.queryParamMap.get('id') || ''; }
-  ngOnInit(): void { this.load(); }
+  ngOnInit(): void {
+    this.destroyRef.onDestroy(()=>this.clearDownloads());
+    this.load();
+  }
+  toggleVariant(variant: LessonPdfVariant): void {
+    if(this.exporting()) return;
+    this.selectedVariants.update(selected=>selected.includes(variant)?selected.filter(item=>item!==variant):[...selected,variant]);
+  }
+  private clearDownloads(): void {
+    for(const file of this.generatedFiles()) URL.revokeObjectURL(file.url);
+    this.generatedFiles.set([]);
+  }
   load(): void {
     if (this.loading() || this.exporting()) return;
     if (!this.orderId) { this.error.set('缺少预约编号,请从课程详情进入'); return; }
@@ -50,12 +67,14 @@ export class LessonReportComponent implements OnInit {
   }
   async downloadPdf(): Promise<void> {
     if (this.exporting() || this.loading() || !this.report() || this.session.groupId() !== 3) return;
+    const variants = this.pdfVariants.filter(item=>this.selectedVariants().includes(item.key));
+    if(!variants.length) return;
     this.exportError.set(''); this.exportMessage.set('');
     if (/micromessenger/i.test(navigator.userAgent)) {
       this.exportError.set('请点击微信右上角菜单,选择“在浏览器中打开”,再下载 PDF。');
       return;
     }
-    this.exporting.set(true);
+    this.clearDownloads(); this.exporting.set(true); this.exportProgress.set('正在读取学习记录…');
     try {
       const { result: report } = await firstValueFrom(this.api.get<LessonReportData>('app_lesson_report', {id:this.orderId}));
       if (report.order['cancelled'] || ![30, 40].includes(Number(report.order['dszt']))) throw new Error('请先完成课程,再下载学习报表。');
@@ -68,18 +87,29 @@ export class LessonReportComponent implements OnInit {
         if (!Array.isArray(words) || !words.length) throw new Error('学习记录缺少单词明细,暂不能生成报表。');
         reports.push(lessonPdfReport(report, record, words));
       }
-      this.pdfReports.set(reports);
-      this.changeDetector.detectChanges();
-      await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
-      const element = this.pdfContent?.nativeElement;
-      if (!element) throw new Error('报表打印区域尚未准备完成');
       const safeName = `${reports[0].studentName}-${reports[0].studyDate}-${this.orderId}`.replace(/[^a-zA-Z0-9一-龥_-]/g, '_');
-      await this.downloader.save(element, `小树陪练-单词学习报表-${safeName}.pdf`);
-      this.exportMessage.set('PDF 已生成,可在浏览器下载中查看并发送给学生。');
+      const files: Array<{blob:Blob;filename:string;label:string}> = [];
+      for (const [index, variant] of variants.entries()) {
+        if(this.destroyRef.destroyed) return;
+        this.exportProgress.set(`正在生成 ${index+1}/${variants.length}:${variant.label}`);
+        this.renderVariant.set(variant.key);
+        this.pdfReports.set(variant.key==='full' ? reports : worksheetReports(reports));
+        this.changeDetector.detectChanges();
+        await new Promise<void>(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve())));
+        if(this.destroyRef.destroyed) return;
+        const element=this.pdfContent?.nativeElement;
+        if(!element) throw new Error('报表打印区域尚未准备完成');
+        files.push({blob:await this.downloader.createPdf(element),filename:`小树陪练-${variant.title}-${safeName}.pdf`,label:variant.label});
+      }
+      if(this.destroyRef.destroyed) return;
+      const downloads=files.map(file=>({url:URL.createObjectURL(file.blob),filename:file.filename,label:file.label}));
+      this.generatedFiles.set(downloads);
+      for(const file of downloads) this.downloader.download(file);
+      this.exportMessage.set(`已生成 ${downloads.length} 份 PDF,已发起下载。`);
     } catch (error) {
       this.exportError.set(error instanceof Error ? error.message : 'PDF 生成失败,请重试。');
     } finally {
-      this.exporting.set(false); this.pdfReports.set([]);
+      this.exporting.set(false); this.pdfReports.set([]); this.exportProgress.set('');
     }
   }
   count(field: 'learned' | 'ygg'): number {