Procházet zdrojové kódy

feat(mobile): download teacher learning reports as PDF

彭峰 před 4 hodinami
rodič
revize
c440942274

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

@@ -0,0 +1,34 @@
+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);
+    const link = document.createElement('a');
+    link.href = url; link.download = filename;
+    document.body.appendChild(link); link.click(); link.remove();
+    setTimeout(() => URL.revokeObjectURL(url), 60_000);
+  }
+
+  async createPdf(element: HTMLElement): Promise<Blob> {
+    await document.fonts?.ready;
+    const { default: html2pdf } = await import('html2pdf.js');
+    let hasBlankTrailingPage = false;
+    const worker = html2pdf().set({
+      margin: [9, 9, 9, 9],
+      image: { type: 'jpeg', quality: 1 },
+      html2canvas: { scale: 1.5, logging: false, useCORS: true, backgroundColor: '#ffffff', windowWidth: 1024, scrollY: 0 },
+      jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
+      pagebreak: { mode: ['css', 'legacy'], avoid: ['.report-summary article', '.word-row', '.review-section', '.pdf-footer', '.comment-card', '.feedback-section', '.evaluation-section'] },
+    }).from(element).toCanvas().get('canvas', canvas => {
+      const pageHeight = Math.floor(canvas.width * (279 / 192));
+      const trailingSlice = canvas.height % pageHeight;
+      hasBlankTrailingPage = trailingSlice > 0 && trailingSlice < 32;
+    }).toPdf();
+    return worker.get('pdf', pdf => {
+      const pageCount = pdf.internal.getNumberOfPages();
+      if (hasBlankTrailingPage && pageCount > 1) pdf.deletePage(pageCount);
+    }).outputPdf('blob');
+  }
+}

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

@@ -0,0 +1,25 @@
+@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>
+    <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>
+    <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>
+    <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>
+        <tr><th scope="row">复习日期</th>@for (item of report.reviewSchedule; track item.day) { <td>{{ item.date.replaceAll('-', '') }}</td> }</tr>
+        <tr><th scope="row">遗忘词数</th>@for (item of report.reviewSchedule; track item.day) { <td class="review-count">&#160;</td> }</tr>
+      </tbody></table>
+      <p class="worksheet-hint">每次复习后填写遗忘的单词数量,未复习的格子留空。@if(report.hasInferredReviewDates){ 未设置的复习日期按学习日期推算;日期缺失时请手写补充。 }</p>
+    </section>
+    @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">
+        @for (field of [['学习内容','contentSummary'],['复习 / 生词本','reviewSummary'],['训练进度','trainingSummary'],['课后作业','homework']]; track field[1]) {
+          <article class="comment-card"><strong>{{ field[0] }}</strong><p>{{ feedback[field[1]] || (field[1] === 'homework' && feedback['noHomework'] ? '无作业' : '未补充') }}</p></article>
+        }
+      </div></section>
+    }
+    <footer class="pdf-footer"><span>数据来源:学习记录 {{ report.generalId }}</span><span>生成时间:{{ report.generatedAt }}</span></footer>
+  </article>
+}

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

@@ -0,0 +1,198 @@
+:host { display:block; width:192mm; }
+.pdf-sheet {
+  box-sizing: border-box;
+  width: 192mm;
+  min-height: 279mm;
+  margin: 0 auto;
+  padding: 10mm 11mm;
+  background: #fff;
+  color: #1e2c27;
+  font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
+}
+.pdf-sheet * {
+  box-sizing: border-box;
+}
+.pdf-title {
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr) auto;
+  align-items: center;
+  gap: 14px;
+  padding-bottom: 22px;
+  border-bottom: 3px solid #1f8066;
+}
+.pdf-title .brand-mark {
+  display: grid;
+  width: 50px;
+  height: 50px;
+  place-items: center;
+  border-radius: 14px;
+  background: #e7f4ef;
+  color: #1f8066;
+}
+.pdf-title p {
+  margin: 0;
+  color: #71817b;
+  font-size: 12px;
+  letter-spacing: 0.18em;
+}
+.pdf-title h1 {
+  margin: 4px 0 0;
+  font-size: 28px;
+}
+.pdf-title > span {
+  align-self: start;
+  color: #83918c;
+  font-size: 11px;
+  white-space: nowrap;
+}
+.report-meta {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  margin: 24px 0 18px;
+  border: 1px solid #dfe8e4;
+  border-radius: 12px;
+  overflow: hidden;
+}
+.report-meta div {
+  min-width: 0;
+  padding: 11px 13px;
+  border-right: 1px solid #e7ecea;
+  border-bottom: 1px solid #e7ecea;
+}
+.report-meta div:nth-child(3n) {
+  border-right: 0;
+}
+.report-meta div:nth-last-child(-n + 3) {
+  border-bottom: 0;
+}
+.report-meta dt {
+  color: #788781;
+  font-size: 10px;
+}
+.report-meta dd {
+  margin: 5px 0 0;
+  overflow-wrap: anywhere;
+  font-size: 13px;
+  font-weight: 750;
+}
+.report-summary {
+  display: grid;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  gap: 9px;
+  margin-bottom: 24px;
+}
+.report-summary article {
+  display: grid;
+  min-width: 0;
+  gap: 5px;
+  padding: 12px;
+  border-radius: 10px;
+  background: #f0f7f4;
+  text-align: center;
+}
+.report-summary strong {
+  color: #176c55;
+  font-size: 21px;
+}
+.report-summary span {
+  color: #6f7f79;
+  font-size: 10px;
+}
+.pdf-section {
+  margin-top: 22px;
+}
+.pdf-section h2 {
+  margin: 0 0 10px;
+  padding-left: 9px;
+  border-left: 4px solid #278467;
+  font-size: 16px;
+}
+.word-table {
+  width: 100%;
+  table-layout: fixed;
+  border-collapse: collapse;
+  font-size: 11px;
+}
+.word-table thead {
+  display: table-header-group;
+}
+.word-table th,
+.word-table td {
+  padding: 9px 8px;
+  border: 1px solid #dfe7e4;
+  overflow-wrap: anywhere;
+  text-align: left;
+  vertical-align: top;
+}
+.word-table th {
+  background: #eef5f2;
+  color: #53665f;
+}
+.word-table th:first-child,
+.word-table td:first-child {
+  width: 8%;
+  text-align: center;
+}
+.word-table th:nth-child(2),
+.word-table td:nth-child(2) {
+  width: 29%;
+}
+.word-table th:last-child,
+.word-table td:last-child {
+  width: 16%;
+}
+.word-table small,
+.word-table em {
+  display: block;
+  margin-top: 3px;
+  color: #788781;
+  font-size: 9px;
+  font-style: normal;
+}
+.word-table [data-result="mastered"] {
+  color: #197052;
+}
+.word-table [data-result="review"] {
+  color: #aa650e;
+}
+.word-table [data-result="unmarked"] {
+  color: #7a8581;
+}
+.worksheet-hint { font-size:10px; color:#6e7d78; line-height:1.7; }
+.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; }
+.review-table th:first-child { width: 60px; }
+.review-table .review-count { height: 32px; }
+.comments {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 9px;
+}
+.comment-card {
+  min-width: 0;
+  padding: 11px;
+  border-radius: 9px;
+  background: #f6f8f7;
+  font-size: 10px;
+}
+.comment-card p {
+  margin: 6px 0 0;
+  overflow-wrap: anywhere;
+  line-height: 1.6;
+}
+.pdf-footer {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 28px;
+  padding-top: 12px;
+  border-top: 1px solid #dfe7e4;
+  color: #84918c;
+  font-size: 9px;
+}
+
+.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; }

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

@@ -0,0 +1,14 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { FileChartColumn, LucideAngularModule } from 'lucide-angular';
+import { LessonPdfReport } from './lesson-report-pdf';
+
+@Component({
+  selector: 'app-lesson-report-pdf', standalone: true, imports: [CommonModule, LucideAngularModule],
+  templateUrl: './lesson-report-pdf.component.html', styleUrl: './lesson-report-pdf.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class LessonReportPdfComponent {
+  readonly reports = input.required<LessonPdfReport[]>();
+  readonly icon = FileChartColumn;
+}

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

@@ -0,0 +1,49 @@
+import { lessonDateLabel } from './lesson-display';
+
+export interface LessonReportData {
+  order: Record<string, unknown>;
+  feedback: Record<string, unknown> | null;
+  records: Array<Record<string, unknown>>;
+  antiForget: { total: number; completed: number };
+  refreshedAt: string;
+}
+
+/** Use the saved check result, as in the admin report, rather than today's mastery state. */
+export function lessonPdfReport(report: LessonReportData, record: Record<string, unknown>, rawWords: Array<Record<string, unknown>>) {
+  const order = report.order;
+  const words = rawWords.map((word, index) => {
+    const detail = (Array.isArray(word['detail']) ? word['detail'][0] : word['detail']) || word;
+    const check = Number(word['check'] ?? word['Check']);
+    return {
+      index: index + 1, word: String(word['Title'] ?? word['title'] ?? detail['Title'] ?? ''),
+      phonetic: String(detail['yb'] || ''), meaning: String(detail['sy'] || ''), example: String(detail['lj'] || ''),
+      result: check === 1 ? 'mastered' : check === 2 ? 'review' : 'unmarked',
+      resultLabel: check === 1 ? '已掌握' : check === 2 ? '待复习' : '未标记',
+    };
+  });
+  const studyDate = lessonDateLabel(record['dqrq'] || order['yysj'] || order['bxrq']).slice(0, 10);
+  const base = /^\d{4}-\d{2}-\d{2}$/.test(studyDate) ? new Date(`${studyDate}T00:00:00Z`) : null;
+  const validBase = base && Number.isFinite(base.getTime()) && base.toISOString().slice(0, 10) === studyDate;
+  const dates = String(record['fxrl'] || '').split(',').map(lessonDateLabel).filter(Boolean);
+  const mastered = words.filter(word => word.result === 'mastered').length;
+  const review = words.filter(word => word.result === 'review').length;
+  const studentReview = order['studentReview'] as Record<string, unknown> | undefined;
+  return {
+    generalId: String(record['generalId'] || record['GeneralID'] || ''),
+    studentName: String(order['HoneyName'] || '学员'), coachName: String(order['plxm'] || order['plName'] || '陪练老师'),
+    courseName: String(order['kcmc'] || order['yykcmc'] || '课程学习报告'),
+    studyDate, appointmentAt: lessonDateLabel(order['yysj']), appointmentId: String(order['GeneralID'] || ''),
+    classTypeLabel: [order['courseCategoryName'], `${Number(order['durationMinutes']) || ([2, 3].includes(Number(order['dslx'])) ? 60 : 30)} 分钟`].filter(Boolean).join(' · '),
+    learnedCount: words.length, masteredCount: mastered, reviewDueCount: review,
+    unmarkedCount: words.length - mastered - review, masteryRate: words.length ? mastered / words.length * 100 : 0,
+    hasInferredReviewDates: dates.length < 10,
+    words, reviewSchedule: [1, 2, 3, 5, 7, 9, 11, 14, 17, 21].map((day, index) => ({
+      day, date: dates[index] || (validBase ? new Date(base.getTime() + day * 86400000).toISOString().slice(0, 10) : ''), inferred: !dates[index],
+    })),
+    score: Number(studentReview?.['score']) || 0, studentComment: String(studentReview?.['comment'] || ''),
+    coachComment: String(report.feedback?.['coachComment'] || ''),
+    feedback: report.feedback?.['feedbackStatus'] === 'pending' ? null : report.feedback,
+    generatedAt: lessonDateLabel(new Date().toISOString()),
+  };
+}
+export type LessonPdfReport = ReturnType<typeof lessonPdfReport>;

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

@@ -1,8 +1,8 @@
 <section class="lesson-report" [attr.aria-busy]="loading()">
-  @if (error()) { <div class="load-error" role="alert"><p>{{ error() }}</p><button type="button" (click)="load()" [disabled]="loading()">重新加载</button></div> }
+  @if (error()) { <div class="load-error" role="alert"><p>{{ error() }}</p><button type="button" (click)="load()" [disabled]="loading() || exporting()">重新加载</button></div> }
   @if (report(); as r) {
     <header class="report-hero">
-      <div class="hero-top"><span>本次课堂报告</span><button type="button" aria-label="刷新学习报告" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="18" /></button></div>
+      <div class="hero-top"><span>本次课堂报告</span><button type="button" aria-label="刷新学习报告" (click)="load()" [disabled]="loading() || exporting()"><lucide-icon [img]="icons.RefreshCw" [size]="18" /></button></div>
       <h1>{{ r.order['kcmc'] || r.order['yykcmc'] || '课程学习报告' }}</h1>
       <p>{{ r.order['HoneyName'] || '学员' }}<span aria-hidden="true"> · </span>{{ dateLabel(r.order['yysj']) }}</p>
       @if (r.order['cancelled']) { <span class="status-badge">此预约已取消</span> }
@@ -42,5 +42,17 @@
     </section>
     <p class="report-updated">数据更新于 {{ dateLabel(r.refreshedAt) }}</p>
   } @else if (loading()) { <div class="loading-state" role="status">正在整理本次学习报告…</div> }
-  <footer><a routerLink="/pages/his/his">查看学习记录</a></footer>
+  <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' }}
+      </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 (pdfReports().length) {
+    <div class="pdf-render" aria-hidden="true"><div #pdfContent><app-lesson-report-pdf [reports]="pdfReports()" /></div></div>
+  }
 </section>

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

@@ -21,3 +21,13 @@ h2 lucide-icon { color:var(--brand-strong); }.edit-link { display:inline-flex; m
 footer a { display:flex; align-items:center; justify-content:center; min-height:48px; margin-top:16px; border:1px solid var(--line); border-radius:12px; background:white; color:var(--brand-strong); text-decoration:none; font-weight:600; font-size:14px; }
 .load-error,.loading-state { padding:24px; line-height:1.7; }.load-error { color:#a63428; }.load-error button { min-height:44px; }button:disabled { opacity:.6; }a:focus-visible,button:focus-visible { outline:2px solid var(--brand-strong); outline-offset:3px; }button:active,a:active { opacity:.8; }
 @media(max-width:420px) { .report-card,.report-hero { padding:16px; } h1 { font-size:20px; } }
+
+footer { display:grid; gap:12px; margin-top:16px; }
+footer a { margin-top:0; }
+.download-pdf { display:flex; justify-content:center; align-items:center; gap:8px; width:100%; min-height:48px; border:0; border-radius:12px; background:var(--brand-strong); color:white; font:inherit; font-size:14px; font-weight:600; cursor:pointer; }
+.download-pdf:disabled { cursor:default; }
+.export-error,.export-message { margin:12px 0 0; font-size:13px; line-height:1.7; }
+.export-error { color:#a63428; }.export-message { color:var(--brand-strong); }
+.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); } }

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

@@ -0,0 +1,117 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
+import { of, Subject } from 'rxjs';
+import { ApiService } from '../../core/api.service';
+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';
+
+const reportFixture = (): LessonReportData => ({
+  order: {GeneralID:901, szyh:20, HoneyName:'测试学员', plxm:'测试老师', yykcmc:'九年级词汇', yysj:'2026-10-22 14:00', dszt:30, dslx:2, teacherPay:30},
+  feedback: {feedbackStatus:'submitted',contentSummary:'完成本节单词学习',reviewSummary:'复习生词',trainingSummary:'第3次课',homework:'完成课后复习',coachComment:'继续加油'},
+  records: [{generalId:1001,dqrq:'2026-10-22',learned:3,ygg:1,djq:1}],
+  antiForget: {total:10,completed:0}, refreshedAt:'2026-10-22T14:00:00Z',
+});
+const wordsFixture = () => [
+  {GeneralID:1,Title:'lantern',check:1,detail:[{yb:'ˈlæntən',sy:'n.灯笼;提灯'}]},
+  {GeneralID:2,Title:'eve',check:2,detail:[{yb:'iːv',sy:'n.前夜;前夕'}]},
+  {GeneralID:3,Title:'tomb',check:0,detail:[{yb:'tuːm',sy:'n.坟墓'}]},
+];
+
+describe('teacher learning report PDF download', () => {
+  let report: LessonReportData;
+  const groupId = signal(3);
+  let api: jasmine.SpyObj<ApiService>;
+  let download: jasmine.SpyObj<LessonReportDownloadService>;
+  beforeEach(() => {
+    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();
+    TestBed.configureTestingModule({providers:[provideRouter([]),
+      {provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({sid:'901'})}}},
+      {provide:ApiService,useValue:api},{provide:SessionService,useValue:{groupId}},
+      {provide:LessonReportDownloadService,useValue: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();
+    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');
+      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(/工资|收入|¥/);
+    });
+    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(fixture.nativeElement.querySelector('.pdf-render')).toBeNull();
+  });
+  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}));
+    const first=fixture.componentInstance.downloadPdf();await Promise.resolve();
+    await fixture.componentInstance.downloadPdf();fixture.detectChanges();
+    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(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);
+  });
+  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();
+  });
+  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=>{
+      expect(element.querySelectorAll('.pdf-sheet').length).toBe(2);throw new Error('PDF 生成失败');
+    });
+    await fixture.componentInstance.downloadPdf();
+    expect(fixture.componentInstance.exportError()).toBe('PDF 生成失败');
+    expect(fixture.componentInstance.exporting()).toBeFalse();expect(fixture.componentInstance.pdfReports()).toEqual([]);
+  });
+  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();
+  });
+});
+
+describe('learning report PDF rendering',()=>{
+  it('uses stored mastery checks and valid review dates without inferring an invalid date',()=>{
+    const report=reportFixture();const record={...report.records[0],fxrl:'2026-10-24,2026-10-25'};
+    const pdf=lessonPdfReport(report,record,wordsFixture());
+    expect([pdf.masteredCount,pdf.reviewDueCount,pdf.unmarkedCount]).toEqual([1,1,1]);
+    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('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);
+  },30_000);
+});

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

@@ -1,47 +1,87 @@
 import { lessonStudySources } from './lesson-study-sources';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, inject, OnInit, signal, ViewChild } from '@angular/core';
 import { ActivatedRoute, RouterLink } from '@angular/router';
-import { BookOpen, BrainCircuit, ClipboardList, LucideAngularModule, RefreshCw, Star } from 'lucide-angular';
-import { finalize } from 'rxjs';
+import { BookOpen, BrainCircuit, ClipboardList, Download, LoaderCircle, LucideAngularModule, RefreshCw, Star } from 'lucide-angular';
+import { finalize, firstValueFrom } from 'rxjs';
 import { ApiService } from '../../core/api.service';
 import { LessonFeedbackComponent } from './lesson-feedback.component';
 import { lessonDateLabel } from './lesson-display';
 
-interface Report {
-  order: Record<string, unknown>;
-  feedback: Record<string, unknown> | null;
-  records: Array<Record<string, unknown>>;
-  antiForget: {total: number; completed: number};
-  refreshedAt: string;
-}
+import { SessionService } from '../../core/session.service';
+import { LessonReportData, LessonPdfReport, lessonPdfReport } from './lesson-report-pdf';
+import { LessonReportPdfComponent } from './lesson-report-pdf.component';
+import { LessonReportDownloadService } from './lesson-report-download.service';
+
 @Component({
-  standalone: true, imports: [CommonModule, RouterLink, LucideAngularModule, LessonFeedbackComponent],
+  standalone: true, imports: [CommonModule, RouterLink, LucideAngularModule, LessonFeedbackComponent, LessonReportPdfComponent],
   templateUrl: './lesson-report.component.html', styleUrl: './lesson-report.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class LessonReportComponent implements OnInit {
-  readonly report = signal<Report | null>(null);
+  readonly report = signal<LessonReportData | null>(null);
   readonly sources = signal<Array<{label: string; count: number}>>([]);
   readonly loading = signal(false);
   readonly error = signal('');
-  readonly icons = {BookOpen, BrainCircuit, ClipboardList, RefreshCw, Star};
+  readonly icons = {BookOpen, BrainCircuit, ClipboardList, Download, LoaderCircle, RefreshCw, Star};
   readonly dateLabel = lessonDateLabel;
+  readonly exporting = signal(false);
+  readonly exportError = signal('');
+  readonly exportMessage = signal('');
+  readonly pdfReports = signal<LessonPdfReport[]>([]);
+  readonly session = inject(SessionService);
+  @ViewChild('pdfContent') pdfContent?: ElementRef<HTMLElement>;
+  private readonly changeDetector = inject(ChangeDetectorRef);
+  private readonly downloader = inject(LessonReportDownloadService);
   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(); }
   load(): void {
-    if (this.loading()) return;
+    if (this.loading() || this.exporting()) return;
     if (!this.orderId) { this.error.set('缺少预约编号,请从课程详情进入'); return; }
     this.loading.set(true); this.error.set('');
-    this.api.get<Report>('app_lesson_report', {id:this.orderId}).pipe(finalize(() => this.loading.set(false))).subscribe({
+    this.api.get<LessonReportData>('app_lesson_report', {id:this.orderId}).pipe(finalize(() => this.loading.set(false))).subscribe({
       next: r => {
         this.report.set(r.result); this.sources.set([]);
         this.api.get<any>('app_lesson_study', {orderId:this.orderId}).subscribe({next: ({result}) => this.sources.set(lessonStudySources(result)), error: () => this.sources.set([])});
       }, error: e => this.error.set(e.message),
     });
   }
+  async downloadPdf(): Promise<void> {
+    if (this.exporting() || this.loading() || !this.report() || this.session.groupId() !== 3) return;
+    this.exportError.set(''); this.exportMessage.set('');
+    if (/micromessenger/i.test(navigator.userAgent)) {
+      this.exportError.set('请点击微信右上角菜单,选择“在浏览器中打开”,再下载 PDF。');
+      return;
+    }
+    this.exporting.set(true);
+    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('请先完成课程,再下载学习报表。');
+      if (!report.records.length) throw new Error('本次课程暂无学习记录可导出。');
+      const reports: LessonPdfReport[] = [];
+      for (const record of report.records) {
+        const id = record['generalId'] || record['GeneralID'];
+        if (!id) throw new Error('学习记录编号缺失,请刷新后重试。');
+        const { result: words } = await firstValueFrom(this.api.get<Array<Record<string, unknown>>>('e_record_detail', {id, studentId:report.order['szyh']}));
+        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 已生成,可在浏览器下载中查看并发送给学生。');
+    } catch (error) {
+      this.exportError.set(error instanceof Error ? error.message : 'PDF 生成失败,请重试。');
+    } finally {
+      this.exporting.set(false); this.pdfReports.set([]);
+    }
+  }
   count(field: 'learned' | 'ygg'): number {
     return (this.report()?.records || []).reduce((sum, row) => sum + (Number(row[field]) || 0), 0);
   }

+ 1 - 0
projects/xiaoshu-mobile/src/types/html2pdf.d.ts

@@ -11,6 +11,7 @@ declare module 'html2pdf.js' {
       deletePage(pageNumber: number): void;
     }) => void): Html2PdfWorker;
     save(): Promise<void>;
+    outputPdf(type: 'blob'): Promise<Blob>;
   }
   export default function html2pdf(): Html2PdfWorker;
 }

+ 1 - 1
scripts/cloud/mobile-teaching.js

@@ -15,7 +15,7 @@ async function appLessonReport(input,current) {
   const id=String(order.GeneralID||input.id||input.orderId);
   const [feedback,records,reviews]=await Promise.all([
     Psql.oneOrNone('SELECT * FROM "LessonCompletionReport" WHERE "company"=$1 AND "appointmentGeneralId"::text=$2 ORDER BY "submittedAt" DESC LIMIT 1',[DEFAULT_COMPANY_ID,id]),
-    Psql.query('SELECT c."generalId",d."con",d."dqrq",d."learned",d."ygg",d."djq" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=d."company" AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1 AND d."dsid"::text=$2 AND d."userId"=$3 AND COALESCE(to_jsonb(d)->>\'recordScope\',\'\')<>\'lesson-alias\' ORDER BY d."dqrq" DESC',[DEFAULT_COMPANY_ID,id,number(order.szyh)]),
+    Psql.query('SELECT c."generalId",d."con",d."dqrq",d."learned",d."ygg",d."djq",d."fxrl" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=d."company" AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1 AND d."dsid"::text=$2 AND d."userId"=$3 AND COALESCE(to_jsonb(d)->>\'recordScope\',\'\')<>\'lesson-alias\' ORDER BY d."dqrq" DESC',[DEFAULT_COMPANY_ID,id,number(order.szyh)]),
     Psql.one('SELECT COUNT(*)::int total,COUNT(*) FILTER(WHERE "fxzt"=\'1\')::int completed FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "orderId"::text=$2 AND "yhid"=$3',[DEFAULT_COMPANY_ID,id,number(order.szyh)])
   ]);
   return {order:await lessonOrderCapabilities(order,current),feedback:feedback?safe(feedback):null,records,antiForget:reviews,...mobileSource()};

+ 19 - 0
scripts/tests/mobile-teaching.test.mjs

@@ -44,3 +44,22 @@ test('学习记录:统计已生成文章,未作答文章也计入,已完
   assert.equal(map.has('203'),false);
  }finally{await db.close();}
 });
+
+test('课堂报表带回保存的复习日期,并保持预约和学员的数据范围',async()=>{
+ const db=new PGlite();try{
+  await db.exec(`CREATE TABLE "DailyStudyRecord"(company text,id int,"userId" int,dsid text,con text,dqrq text,learned int,ygg int,djq int,fxrl text,"recordScope" text);
+   CREATE TABLE "CommonModel"(company text,"modelId" int,"itemId" int,"generalId" int);
+   INSERT INTO "DailyStudyRecord" VALUES('co',1,20,'901','学习','2026-10-22',3,1,1,'20261024,20261025','lesson'),('co',2,99,'901','其他学员','2026-10-22',3,1,1,'20261026','lesson'),('other',3,20,'901','其他租户','2026-10-22',3,1,1,'20261027','lesson'),('co',4,20,'901','重复别名','2026-10-22',3,1,1,'20261028','lesson-alias');
+   INSERT INTO "CommonModel" VALUES('co',56,1,1001),('co',56,2,1002),('other',56,3,1003),('co',56,4,1004);`);
+  const source=readFileSync(new URL('../cloud/mobile-teaching.js',import.meta.url),'utf8');
+  const start=source.indexOf('async function appLessonReport('),end=source.indexOf('// Legacy profiles',start);
+  let allowed=true,reads=0;
+  const context={DEFAULT_COMPANY_ID:'co',number:Number,safe:x=>x,mobileSource:()=>({}),lessonOrderCapabilities:async x=>x,
+   orderDetailRow:async()=>{if(!allowed)throw Error('无权访问预约');return{GeneralID:901,szyh:20};},
+   Psql:{query:async(statement,args)=>{reads++;return(await db.query(statement,args)).rows;},oneOrNone:async()=>null,one:async()=>({total:0,completed:0})}};
+  vm.runInNewContext(source.slice(start,end)+';this.report=appLessonReport',context);
+  const result=await context.report({id:'901'},{});
+  assert.equal(result.records.length,1);assert.equal(result.records[0].generalId,1001);assert.equal(result.records[0].fxrl,'20261024,20261025');
+  allowed=false;await assert.rejects(context.report({id:'901'},{}),/无权访问/);assert.equal(reads,1);
+ }finally{await db.close();}
+});