فهرست منبع

fix: unify coach and learning report word lesson labels

彭峰 5 ساعت پیش
والد
کامیت
8cd39f290e

+ 2 - 2
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -1,5 +1,5 @@
 import { LessonFeedbackComponent } from './lesson-feedback.component';
-import { lessonDateLabel } from './lesson-display';
+import { lessonDateLabel, wordLessonTypeLabel } from './lesson-display';
 import { DropdownDirective } from '../../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, OnInit, OnDestroy, signal, viewChild } from '@angular/core';
@@ -200,7 +200,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
       delivery: ['jffs'],
       coach: ['plxm', 'plName'],
     };
-    if (label === 'type') return String(this.order()['courseCategoryName'] || ({1:'30分钟课',2:'60分钟课',3:'体验训练',4:'课型4'} as Record<string,string>)[String(this.order()['dslx'])] || this.order()['dslx_t'] || '课程');
+    if (label === 'type') return wordLessonTypeLabel(this.order(), this.feedback()?.['durationMinutes']) || String(this.order()['courseCategoryName'] || ({1:'30分钟课',2:'60分钟课',3:'体验训练',4:'课型4'} as Record<string,string>)[String(this.order()['dslx'])] || this.order()['dslx_t'] || '课程');
     for (const key of aliases[label] ?? []) if (this.order()[key] !== undefined) return String(this.order()[key]);
     return '';
   }

+ 14 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-display.ts

@@ -9,3 +9,17 @@ export function lessonDateLabel(value: unknown): string {
   if (!Number.isFinite(date.getTime())) return raw.slice(0, 16);
   return new Intl.DateTimeFormat('sv-SE', {timeZone:'Asia/Shanghai', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', hour12:false}).format(date);
 }
+
+/** Prefer the saved lesson duration, then the appointment and legacy class type. */
+export function wordLessonTypeLabel(order: Record<string, unknown>, savedMinutes?: unknown): string {
+  const categoryKey = String(order['courseCategoryKey'] || '').trim();
+  const categoryName = String(order['courseCategoryName'] || '').trim();
+  const type = String(order['dslx'] ?? order['kclx'] ?? '').trim();
+  const isWord = categoryKey === 'word' || categoryName === '单词课'
+    || (!categoryKey && !categoryName && String(order['kcid']) !== '398' && ['1', '2'].includes(type));
+  if (!isWord) return '';
+  const legacyMinutes = ({ '1': 30, '2': 60 } as Record<string, number>)[type];
+  const minutes = [savedMinutes, order['durationMinutes'], legacyMinutes].map(Number)
+    .find(value => Number.isFinite(value) && value > 0);
+  return minutes ? `单词课 ${minutes} 分钟` : '';
+}

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

@@ -1,4 +1,4 @@
-import { lessonDateLabel } from './lesson-display';
+import { lessonDateLabel, wordLessonTypeLabel } from './lesson-display';
 
 export interface LessonReportData {
   order: Record<string, unknown>;
@@ -33,7 +33,7 @@ export function lessonPdfReport(report: LessonReportData, record: Record<string,
     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(' · '),
+    classTypeLabel: wordLessonTypeLabel(order, report.feedback?.['durationMinutes']) || [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,

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

@@ -5,6 +5,7 @@
       <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 (wordTypeLabel(r.order, r.feedback?.['durationMinutes']); as label) { <p>{{ label }}</p> }
       @if (r.order['cancelled']) { <span class="status-badge">此预约已取消</span> }
       @else { <span class="status-badge">{{ r.feedback && r.feedback['feedbackStatus'] !== 'pending' ? '老师反馈已提交' : '老师反馈待补' }}</span> }
     </header>

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

@@ -144,6 +144,17 @@ describe('teacher learning report PDF download', () => {
 });
 
 describe('learning report PDF rendering',()=>{
+  it('labels 30 and 60 minute word lessons using saved duration before the legacy type',()=>{
+    for(const minutes of [30,60]){
+      const source=reportFixture();
+      source.order={...source.order,courseCategoryKey:'word',courseCategoryName:'单词课',durationMinutes:60};
+      source.feedback={...source.feedback,durationMinutes:minutes};
+      expect(lessonPdfReport(source,source.records[0],wordsFixture()).classTypeLabel).toBe('单词课 '+minutes+' 分钟');
+    }
+    const legacy=reportFixture();
+    expect(lessonPdfReport(legacy,legacy.records[0],wordsFixture()).classTypeLabel).toBe('单词课 60 分钟');
+  });
+
   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());

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

@@ -6,7 +6,7 @@ import { BookOpen, BrainCircuit, ClipboardList, Download, LoaderCircle, LucideAn
 import { finalize, firstValueFrom } from 'rxjs';
 import { ApiService } from '../../core/api.service';
 import { LessonFeedbackComponent } from './lesson-feedback.component';
-import { lessonDateLabel } from './lesson-display';
+import { lessonDateLabel, wordLessonTypeLabel } from './lesson-display';
 
 import { SessionService } from '../../core/session.service';
 import { LessonReportData, LessonPdfReport, LessonPdfVariant, lessonPdfVariants, lessonPdfReport, worksheetReports } from './lesson-report-pdf';
@@ -25,6 +25,7 @@ export class LessonReportComponent implements OnInit {
   readonly error = signal('');
   readonly icons = {BookOpen, BrainCircuit, ClipboardList, Download, LoaderCircle, RefreshCw, Star};
   readonly dateLabel = lessonDateLabel;
+  readonly wordTypeLabel = wordLessonTypeLabel;
   readonly exporting = signal(false);
   readonly pdfVariants = lessonPdfVariants;
   readonly selectedVariants = signal<LessonPdfVariant[]>(['full','english','chinese']);

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 25 - 4
scripts/deploy-admin-functions.mjs


+ 51 - 0
scripts/tests/learning-report-query.test.mjs

@@ -105,3 +105,54 @@ test('写作报表的词汇掌握率不适用,普通单词报表仍计算掌
   assert.equal(word.masteryRate, 100);
   assert.equal(word.canExport, true);
 });
+
+test('单词学习报表统一课型格式,优先显示保存时长并兼容旧课型', () => {
+  for(const minutes of [30,60]){
+    const report=gateway.learningReportProjection({classType:'2',courseId:203,durationMinutes:minutes,courseCategoryKey:'word',courseCategoryName:'单词课'});
+    assert.equal(report.classTypeLabel,'单词课 '+minutes+' 分钟');
+  }
+  assert.equal(gateway.learningReportProjection({legacyLesson:{classType:'1',courseId:'203'}}).classTypeLabel,'单词课 30 分钟');
+  assert.equal(gateway.learningReportProjection({legacyLesson:{classType:'2',courseId:'161'}}).classTypeLabel,'单词课 60 分钟');
+  assert.equal(gateway.learningReportProjection({classType:'1',courseId:203}).classTypeLabel,'单词课 30 分钟');
+  assert.equal(gateway.learningReportProjection({classType:'2',courseId:203}).classTypeLabel,'单词课 60 分钟');
+  assert.equal(gateway.learningReportProjection({classType:'2',courseId:398,isWriting:true}).classTypeLabel,'60 分钟');
+});
+
+test('批量读取已保存时长和旧课型时同时匹配帐套、预约与学员', async () => {
+  await db.exec(`
+    CREATE TABLE "LessonCompletionReport"(company text,"appointmentGeneralId" numeric,"studentId" numeric,"durationMinutes" numeric,"submittedAt" timestamptz,"objectId" text);
+    CREATE TABLE "LessonRecord"(company text,yyds text,xymz text,kclx text,kcid text,"updatedAt" timestamptz,"objectId" text);
+    INSERT INTO "LessonCompletionReport" VALUES
+      ('co',201,59,30,'2026-11-01','saved'),
+      ('other',201,59,60,'2026-11-02','other-company'),
+      ('co',201,60,60,'2026-11-02','other-student');
+    INSERT INTO "LessonRecord" VALUES
+      ('co','202','59','1','203','2026-11-01','legacy'),
+      ('other','202','59','2','203','2026-11-02','other-company'),
+      ('co','202','60','2','203','2026-11-02','other-student');
+  `);
+  gateway.Psql = { query: async (sql, values) => (await db.query(sql, values)).rows };
+  const enriched = await gateway.learningReportClassTypeRows([
+    { recordCompany: 'co', dsid: '201', userId: 59, appointmentObjectId: 'a', classType: '2', courseId: 203, durationMinutes: 60 },
+    { recordCompany: 'co', dsid: '202', userId: 59 },
+  ]);
+  assert.equal(gateway.learningReportProjection(enriched[0]).classTypeLabel, '单词课 30 分钟');
+  assert.equal(gateway.learningReportProjection(enriched[1]).classTypeLabel, '单词课 30 分钟');
+});
+
+test('报表先定位课次时仍兼容报表对象、通用编号和原学习记录编号', async () => {
+  await db.exec(`
+    INSERT INTO "CommonModel" VALUES ('study-target','co',56,21,3021,NULL,NULL),('foreign-target','other',56,21,4021,NULL,NULL);
+    INSERT INTO "DailyStudyRecord" ("objectId",company,id) VALUES ('foreign-target-record','other',21);
+  `);
+  let lookup = '';
+  gateway.Psql = { query: async sql => { lookup = sql; return []; } };
+  await assert.rejects(gateway.learningReportDetail({ isSuperAdmin: true }, { targetId: 'study-target' }), /学习报表不存在/);
+  const end = lookup.indexOf(' SELECT c."objectId" AS "targetId"');
+  assert.ok(end > 0);
+  const targetSql = lookup.slice(0, end) + ' SELECT * FROM target_records';
+  for (const target of ['study-target', '3021', 'writing-report']) {
+    assert.deepEqual((await db.query(targetSql, ['co', target])).rows.map(row => row.objectId), ['writing-report']);
+  }
+  assert.deepEqual((await db.query(targetSql, ['co', 'foreign-target'])).rows, []);
+});

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است