Ver código fonte

fix(mobile): restore one-click downloads of separate lesson PDFs

彭峰 2 horas atrás
pai
commit
bbea13c9ab

+ 0 - 1
package-lock.json

@@ -16,7 +16,6 @@
         "@angular/platform-browser": "20.3.33",
         "@angular/platform-browser-dynamic": "20.3.33",
         "@angular/router": "20.3.33",
-        "fflate": "0.8.3",
         "html2pdf.js": "0.14.0",
         "jspdf": "4.2.1",
         "lucide-angular": "1.0.0",

+ 0 - 1
package.json

@@ -79,7 +79,6 @@
     "@angular/platform-browser": "20.3.33",
     "@angular/platform-browser-dynamic": "20.3.33",
     "@angular/router": "20.3.33",
-    "fflate": "0.8.3",
     "html2pdf.js": "0.14.0",
     "jspdf": "4.2.1",
     "lucide-angular": "1.0.0",

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

@@ -8,12 +8,6 @@ export class LessonReportDownloadService {
     document.body.appendChild(link); link.click(); link.remove();
   }
 
-  async createZip(files: readonly { filename: string; blob: Blob }[]): Promise<Blob> {
-    const { zipSync } = await import('fflate');
-    const entries = await Promise.all(files.map(async file => [file.filename, new Uint8Array(await file.blob.arrayBuffer())] as const));
-    return new Blob([zipSync(Object.fromEntries(entries), { level: 0 })], { type: 'application/zip' });
-  }
-
   async createPdf(element: HTMLElement): Promise<Blob> {
     await document.fonts?.ready;
     const { default: html2pdf } = await import('html2pdf.js');

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

@@ -50,7 +50,7 @@
         }
       </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() : selectedVariants().length > 1 ? '下载报表压缩包(' + selectedVariants().length + '份)' : '下载 PDF' }}
+        <lucide-icon [img]="exporting() ? icons.LoaderCircle : icons.Download" [class.spin]="exporting()" [size]="19" />{{ exporting() ? exportProgress() : selectedVariants().length > 1 ? '下载 ' + selectedVariants().length + ' 份 PDF' : '下载 PDF' }}
       </button>
     }
     <a routerLink="/pages/his/his">查看学习记录</a>
@@ -59,7 +59,7 @@
   @if (exportMessage()) { <p class="export-message" role="status">{{ exportMessage() }}</p> }
   @if (generatedFiles().length) {
     <section class="generated-files" aria-label="已生成的学习报表">
-      <p>如果下载未自动开始,请点击下方链接保存。</p>
+      <p>如有文件未自动下载,请点击对应链接保存。</p>
       @for (file of generatedFiles(); track file.url) { <a [href]="file.url" [download]="file.filename">{{ file.label }}</a> }
     </section>
   }

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

@@ -30,9 +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',['createPdf','createZip','download']);
+    download = jasmine.createSpyObj('LessonReportDownloadService',['createPdf','download']);
     download.createPdf.and.resolveTo(new Blob(['%PDF-test'],{type:'application/pdf'}));
-    download.createZip.and.callFake(files => new LessonReportDownloadService().createZip(files));
     TestBed.configureTestingModule({providers:[provideRouter([]),
       {provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({sid:'901'})}}},
       {provide:ApiService,useValue:api},{provide:SessionService,useValue:{groupId}},
@@ -58,9 +57,10 @@ describe('teacher learning report PDF download', () => {
     expect(fixture.nativeElement.querySelector('[role="status"]').textContent).toContain('已生成 1 份 PDF');
     expect(fixture.nativeElement.querySelector('.pdf-render')).toBeNull();
   });
-  it('downloads all three formats as one archive and keeps answers out of the worksheets',async()=>{
+  it('downloads all three formats as separate PDFs 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);
+    expect(fixture.nativeElement.querySelector('.download-pdf').textContent).toContain('下载 3 份 PDF');
     download.createPdf.and.callFake(async element=>{
       titles.push(element.querySelector('h1')!.textContent!);
       const table=element.querySelector('.worksheet-table');
@@ -74,21 +74,21 @@ describe('teacher learning report PDF download', () => {
         expect(element.querySelector('.report-summary')).toBeNull();expect(element.querySelector('.feedback-section')).toBeNull();
       }
       expect(download.download).not.toHaveBeenCalled();
-      return new Blob(['%PDF-'+titles.length]);
+      return new Blob(['%PDF-'+titles.length],{type:'application/pdf'});
     });
     await fixture.componentInstance.downloadPdf();fixture.detectChanges();
     expect(titles).toEqual(['单词学习报表','英文填中文复习表','中文填英文复习表']);
-    expect(download.createZip).toHaveBeenCalledTimes(1);
-    const { unzipSync } = await import('fflate');
-    const archive = await download.createZip.calls.mostRecent().returnValue;
-    const entries = unzipSync(new Uint8Array(await archive.arrayBuffer()));
-    expect(Object.keys(entries)).toEqual(titles.map(title => `小树陪练-${title}-测试学员-2026-10-22-901.pdf`));
-    expect(Object.values(entries).map(bytes => new TextDecoder().decode(bytes))).toEqual(['%PDF-1', '%PDF-2', '%PDF-3']);
-    expect(download.download).toHaveBeenCalledTimes(1);
-    expect(download.download.calls.mostRecent().args[0].filename).toMatch(/\.zip$/);
-    expect(fixture.nativeElement.querySelectorAll('.generated-files a[download]').length).toBe(1);
+    expect(download.download).toHaveBeenCalledTimes(3);
+    const files = download.download.calls.allArgs().map(([file]) => file);
+    expect(files.map(file => file.filename)).toEqual(titles.map(title => `小树陪练-${title}-测试学员-2026-10-22-901.pdf`));
+    const links = Array.from(fixture.nativeElement.querySelectorAll('.generated-files a[download]') as NodeListOf<HTMLAnchorElement>);
+    expect(links.map(link => link.download)).toEqual(files.map(file => file.filename));
+    expect(links.map(link => link.href)).toEqual(files.map(file => file.url));
+    const blobs = await Promise.all(files.map(file => fetch(file.url).then(response => response.blob())));
+    expect(blobs.map(blob => blob.type)).toEqual(Array(3).fill('application/pdf'));
+    expect(await Promise.all(blobs.map(blob => blob.text()))).toEqual(['%PDF-1', '%PDF-2', '%PDF-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(1);
+    const revoke=spyOn(URL,'revokeObjectURL').and.callThrough();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');
@@ -118,7 +118,7 @@ describe('teacher learning report PDF download', () => {
     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.download).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;

+ 8 - 13
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.ts

@@ -102,20 +102,15 @@ export class LessonReportComponent implements OnInit {
         files.push({blob:await this.downloader.createPdf(element),filename:`小树陪练-${variant.title}-${safeName}.pdf`,label:`保存${variant.label} PDF`});
       }
       if(this.destroyRef.destroyed) return;
-      let file = files[0];
-      if (files.length > 1) {
-        this.exportProgress.set('正在打包报表');
-        file = {
-          blob: await this.downloader.createZip(files),
-          filename: `小树陪练-学习报表-${safeName}.zip`,
-          label: '保存报表压缩包(ZIP)',
-        };
+      const downloads = files.map(({blob, filename, label}) => ({url: URL.createObjectURL(blob), filename, label}));
+      this.generatedFiles.set(downloads);
+      for (const [index, file] of downloads.entries()) {
+        // WebKit can coalesce consecutive clicks before starting the first download.
+        if (index > 0) await new Promise<void>(resolve => setTimeout(resolve, 100));
+        if (this.destroyRef.destroyed) return;
+        this.downloader.download(file);
       }
-      if (this.destroyRef.destroyed) return;
-      const download = { url: URL.createObjectURL(file.blob), filename: file.filename, label: file.label };
-      this.generatedFiles.set([download]);
-      this.downloader.download(download);
-      this.exportMessage.set(files.length > 1 ? `已将 ${files.length} 份 PDF 打包为 1 个 ZIP。` : '已生成 1 份 PDF。');
+      this.exportMessage.set(`已生成 ${downloads.length} 份 PDF。`);
     } catch (error) {
       this.exportError.set(error instanceof Error ? error.message : 'PDF 生成失败,请重试。');
     } finally {