Просмотр исходного кода

feat(admin): print vocabulary worksheets in two columns without numbers

彭峰 2 недель назад
Родитель
Сommit
058c2dd9fb

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

@@ -60,7 +60,7 @@
   </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">
+  <div class="preview-scroll"><article #pdfContent class="pdf-sheet" [class.worksheet-sheet]="reportVariant()!=='full'">
     <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>
     @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>}
@@ -69,9 +69,9 @@
     } @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> }
+        <table class="word-table worksheet-table"><thead><tr><th>{{ reportVariant()==='english' ? '英文单词' : '中文释义' }}</th><th>{{ reportVariant()==='english' ? '填写中文' : '填写英文' }}</th><th>{{ reportVariant()==='english' ? '英文单词' : '中文释义' }}</th><th>{{ reportVariant()==='english' ? '填写中文' : '填写英文' }}</th></tr></thead><tbody>
+          @for (pair of worksheetRows(); track $index) { <tr class="word-row"><td class="word-prompt">{{ reportVariant()==='english' ? pair[0].word : (pair[0].meaning || '释义缺失') }}</td><td class="answer-space">&#160;</td>@if (pair[1]; as word) { <td class="word-prompt">{{ reportVariant()==='english' ? word.word : (word.meaning || '释义缺失') }}</td><td class="answer-space">&#160;</td> } @else { <td colspan="2" class="worksheet-empty"></td> }</tr> }
+          @empty { <tr><td colspan="4">没有可展示的单词明细</td></tr> }
         </tbody></table>
       </section>
     }
@@ -84,7 +84,7 @@
       <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>
+    @if(reportVariant()==='full'){<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, reportVariant())"><lucide-icon [img]="exportingId() ? icons.LoaderCircle : icons.Download" [size]="17" [class.spin]="!!exportingId()" />{{ exportingId() ? '正在生成' : '下载 PDF' }}</button></footer></aside>
 }

+ 12 - 3
projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.scss

@@ -454,11 +454,20 @@
 .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; }
+.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; }
 .review-words { font-size: 10px; line-height: 1.8; overflow-wrap: anywhere; }
+.review-section { break-inside: avoid; }
 .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; }

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

@@ -135,6 +135,9 @@ describe('OperationsLearningReportsComponent', () => {
       expect(sheet.textContent).toContain(variant === 'english' ? 'apple' : '苹果');
       expect(sheet.textContent).not.toContain(variant === 'english' ? '苹果' : 'apple');
       expect(sheet.querySelectorAll('.answer-space').length).toBe(1);
+      expect(Array.from(sheet.querySelectorAll('.worksheet-table thead th')).map((cell) => cell.textContent?.trim())).toEqual(variant === 'english' ? ['英文单词', '填写中文', '英文单词', '填写中文'] : ['中文释义', '填写英文', '中文释义', '填写英文']);
+      expect(sheet.querySelector('.worksheet-table thead')?.textContent).not.toContain('#');
+      expect(sheet.querySelector('.worksheet-table tbody tr')?.children.length).toBe(3);
       expect(sheet.querySelector('.comments')).toBeNull();
       expect(sheet.querySelectorAll('.review-count').length).toBe(10);
       expect(Array.from(sheet.querySelectorAll('.review-count')).every((cell) => !cell.textContent?.trim())).toBeTrue();
@@ -188,6 +191,11 @@ describe('OperationsLearningReportsComponent', () => {
     component.selectVariant('english');
     const first = component.displayWords().map((word) => word.word);
     expect(first).not.toEqual(words.map((word) => word.word));
+    fixture.detectChanges();
+    const pairs = (fixture.nativeElement as HTMLElement).querySelectorAll('.worksheet-table tbody tr');
+    expect(pairs.length).toBe(2);
+    expect(Array.from(pairs).map((pair) => pair.querySelectorAll('.word-prompt').length)).toEqual([2, 2]);
+    expect(Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('.worksheet-table .word-prompt')).map((prompt) => prompt.textContent?.trim())).toEqual(first);
     component.selectVariant('chinese');
     const second = component.displayWords().map((word) => word.word);
     expect(second).not.toEqual(first);

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

@@ -59,6 +59,12 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
   readonly reportVariant = signal<ReportVariant>('full');
   private readonly worksheetWords = signal<LearningReportWord[]>([]);
   readonly displayWords = computed(() => this.reportVariant() === 'full' ? (this.detail()?.words || []) : this.worksheetWords());
+  readonly worksheetRows = computed(() => {
+    const words = this.displayWords();
+    const rows: Array<[LearningReportWord, LearningReportWord?]> = [];
+    for (let index = 0; index < words.length; index += 2) rows.push([words[index], words[index + 1]]);
+    return rows;
+  });
   readonly reportTitle = computed(() => reportTitles[this.reportVariant()]);
   readonly reviewSchedule = computed(() => this.detail() ? printableReviewSchedule(this.detail()!) : []);
   readonly hasInferredReviewDates = computed(() => this.reviewSchedule().some((item) => item.inferred));
@@ -247,7 +253,7 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
         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-table', '.worksheet-hint', '.pdf-footer', '.comment-card'] },
+        pagebreak: { mode: ['css', 'legacy'], avoid: ['.report-summary article', '.word-row', '.review-section', '.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;