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

fix: restore teacher learning record links

彭峰 1 месяц назад
Родитель
Сommit
ffeae13877

+ 10 - 0
projects/xiaoshu-mobile/src/app/core/external-reading.spec.ts

@@ -8,6 +8,16 @@ describe('external reading migration', () => {
     expect(extractReadingWords('not-json')).toEqual([]);
   });
 
+  it('supports migrated word shapes and double-encoded history data', () => {
+    expect(extractReadingWords([
+      { title: 'apple' },
+      { word: 'book' },
+      { detail: [{ Word: 'cat' }] },
+      'dog',
+    ])).toEqual(['apple', 'book', 'cat', 'dog']);
+    expect(extractReadingWords('"[{\\"title\\":\\"pear\\"}]"')).toEqual(['pear']);
+  });
+
   it('accepts packed legacy params in the internal reading route', () => {
     const value = JSON.stringify({ wordList: ['one'], token: 'token' });
     expect(normalizeReadingParams({ params: value }).wordList).toEqual(['one']);

+ 11 - 3
projects/xiaoshu-mobile/src/app/core/external-reading.ts

@@ -1,13 +1,21 @@
 export function extractReadingWords(value: unknown): string[] {
   let parsed = value;
-  if (typeof value === 'string') {
-    try { parsed = JSON.parse(value) as unknown; }
+  for (let depth = 0; depth < 2 && typeof parsed === 'string'; depth += 1) {
+    try { parsed = JSON.parse(parsed) as unknown; }
     catch { return []; }
   }
   const entries = Array.isArray(parsed)
     ? parsed
     : parsed && typeof parsed === 'object' ? Object.values(parsed) : [];
   return entries
-    .map((entry) => entry && typeof entry === 'object' ? String((entry as Record<string, unknown>)['Title'] ?? '').trim() : '')
+    .map((entry) => {
+      if (typeof entry === 'string') return entry.trim();
+      if (!entry || typeof entry !== 'object') return '';
+      const item = entry as Record<string, unknown>;
+      const detailValue = Array.isArray(item['detail']) ? item['detail'][0] : item['detail'];
+      const detail = detailValue && typeof detailValue === 'object' ? detailValue as Record<string, unknown> : {};
+      return String(item['Title'] ?? item['title'] ?? item['word'] ?? item['Word']
+        ?? detail['Title'] ?? detail['title'] ?? detail['word'] ?? detail['Word'] ?? '').trim();
+    })
     .filter(Boolean);
 }

+ 12 - 0
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -519,6 +519,18 @@ describe('home learning modules', () => {
     expect((harness.routeNativeElement?.querySelector('.student-filter select') as HTMLSelectElement).selectedOptions[0]?.textContent).toContain('张雅茜');
     expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2);
     expect(session.student()?.userId).toBe(100);
+    (harness.routeNativeElement?.querySelector('.learning-record-card .learning-item') as HTMLElement).click();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(TestBed.inject(Router).url).toBe('/pages/his/his_words?id=201');
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('历史记录');
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-card')?.textContent).toContain('apple');
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-card')?.textContent).toContain('共 1 个单词');
+    expect(harness.routeNativeElement?.querySelector('.selection-header')).toBeNull();
+
+    await harness.navigateByUrl('/pages/his/his');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
     const teacherCourse = harness.routeNativeElement?.querySelector('.current-learning-list button') as HTMLButtonElement;
     expect(teacherCourse.disabled).toBeFalse();
     expect(teacherCourse.classList.contains('readonly')).toBeFalse();

+ 3 - 3
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -59,7 +59,7 @@
     <section class="readonly-word-card" aria-labelledby="readonly-word-title">
       <header class="readonly-word-header">
         <div><strong id="readonly-word-title">单词列表</strong><span>第 {{ wordPreviewRangeStart() }}–{{ wordPreviewRangeEnd() }} 条 · 共 {{ wordPreviewTotal() }} 个单词</span></div>
-        <em>学生只读</em>
+        <em>{{ isHistoryWordView && canStartPointReading ? '历史记录' : '学生只读' }}</em>
       </header>
       <div class="readonly-word-list" [class.is-updating]="wordPreviewLoading()" [attr.aria-busy]="wordPreviewLoading()" aria-live="polite">
         @for (word of words(); track word.id) {
@@ -68,7 +68,7 @@
             <p>{{ word.meaning || '暂无释义' }}</p>
           </article>
         } @empty {
-          <div class="empty-state"><p>该单元暂无单词</p></div>
+          <div class="empty-state"><p>{{ isHistoryWordView ? '该次学习记录暂无可显示的单词' : '该单元暂无单词' }}</p></div>
         }
       </div>
       @if (wordPreviewPageCount() > 1) {
@@ -83,7 +83,7 @@
           <span>{{ wordPreviewPage() }} / {{ wordPreviewPageCount() }} 页</span>
         </nav>
       }
-      <p class="readonly-word-note">学生可查看老师分配的单词,点读、跟读和学习记录由陪练老师在上课时操作</p>
+      <p class="readonly-word-note">{{ isHistoryWordView ? '这里展示本次学习记录中的单词,不会改动原学习结果' : '学生可查看老师分配的单词,点读、跟读和学习记录由陪练老师在上课时操作' }}</p>
     </section>
   } @else if (isSelectionPage) {
     <header class="selection-header">

+ 10 - 5
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -266,6 +266,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return ['pages/stu/stu_abc', 'pages/his/his_words', 'pages/member/starshop'].includes(this.meta.path);
   }
 
+  get isHistoryWordView(): boolean {
+    return this.meta.path === 'pages/his/his_words';
+  }
+
   get isRefineSelectionPage(): boolean {
     return this.meta.path === 'pages/stu/stu_abc';
   }
@@ -279,9 +283,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   get isReadOnlyWordView(): boolean {
-    if (this.canStartPointReading) return false;
-    return this.meta.path === 'pages/his/his_words'
-      || (this.isStudyListPage && this.query('readonly') === '1');
+    if (this.isHistoryWordView) return true;
+    return !this.canStartPointReading && this.isStudyListPage && this.query('readonly') === '1';
   }
 
   get isMatchingPage(): boolean {
@@ -1021,9 +1024,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     }
 
     if (this.meta.path === 'pages/his/his_words') {
-      this.api.get<unknown>('e_record_detail', { id: this.query('id') }).pipe(finalize(() => this.loading.set(false))).subscribe({
+      this.api.post<unknown>('e_record_detail', { id: this.query('id') }).pipe(finalize(() => this.loading.set(false))).subscribe({
         next: ({ result, addon }) => {
-          this.words.set(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word));
+          const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word);
+          this.words.set(words);
+          this.wordPreviewTotal.set(words.length);
           const sourceAddon = this.findList(addon)[0];
           if (sourceAddon) this.orderInfo.set(sourceAddon);
         },

+ 2 - 2
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html

@@ -53,7 +53,7 @@
       @if (visibleItems().length) {
         <div class="record-list">
           @for (item of visibleItems(); track item['GeneralID'] || $index) {
-            <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs']) { <button type="button" class="reading-button" (click)="openReading(item, $event)">阅读</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
+            <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs']) { <button type="button" class="reading-button" [disabled]="readingLoadingId() === recordId(item)" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : '阅读' }}</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
           }
         </div>
       }
@@ -86,7 +86,7 @@
         } @else if (meta.path === 'pages/member/ke_fuxi') {
           <article class="income-item"><span [class.done]="item['fxzt']">{{ item['fxzt'] ? '已复习' : '未复习' }}</span><div><strong>{{ title(item) }}</strong><p>{{ incomeName(item) }} {{ item['kywsj'] }}</p></div><b>¥{{ item['money'] || 0 }}</b></article>
         } @else {
-          <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs']) { <button type="button" class="reading-button" (click)="openReading(item, $event)">阅读</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
+          <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs']) { <button type="button" class="reading-button" [disabled]="readingLoadingId() === recordId(item)" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : '阅读' }}</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
         }
       }
     </div>

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.scss

@@ -87,6 +87,7 @@
 .learning-item strong, .progress-item strong, .income-item strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
 .learning-item > span, .progress-item > span { color: var(--brand-strong); font-size: 12px; }
 .reading-button { min-width: 48px; min-height: 34px; padding: 0 10px; color: white; background: var(--brand-strong); border: 0; border-radius: 4px; font-size: 12px; }
+.reading-button:disabled { cursor: wait; opacity: .68; }
 .history-item { grid-template-columns: auto minmax(0, 1fr) auto; color: var(--ink-soft); }
 .history-item.complete { color: var(--brand-strong); }
 .history-item > span { font-size: 12px; }

+ 36 - 3
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -59,6 +59,7 @@ export class StudyRecordsPageComponent implements OnInit {
   readonly recordDateCounts = signal<Record<string, number>>({});
   readonly scheduleDateCounts = signal<Record<string, { planned: number; missed: number }>>({});
   readonly overviewReadingRecord = signal<Item | null>(null);
+  readonly readingLoadingId = signal('');
   readonly datePickerMonthLabel = computed(() => new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(this.datePickerMonth()));
   readonly datePickerDays = computed<DatePickerDay[]>(() => {
     const month = this.datePickerMonth();
@@ -277,7 +278,12 @@ export class StudyRecordsPageComponent implements OnInit {
       return;
     }
     if (['pages/his/his', 'pages/member/myorder'].includes(this.meta.path)) {
-      void this.router.navigate(['/pages/his/his_words'], { queryParams: { id: item['GeneralID'] } });
+      const id = this.recordId(item);
+      if (!id) {
+        this.platform.toast('该学习记录缺少编号,暂时无法打开', 'error');
+        return;
+      }
+      void this.router.navigate(['/pages/his/his_words'], { queryParams: { id } });
     }
   }
 
@@ -319,10 +325,37 @@ export class StudyRecordsPageComponent implements OnInit {
 
   private navigateToReading(item: Item): void {
     const wordList = extractReadingWords(item['xxqs']);
-    if (!wordList.length) return;
+    if (wordList.length) {
+      this.openReadingSetup(item, wordList);
+      return;
+    }
+    const id = this.recordId(item);
+    if (!id) {
+      this.platform.toast('该学习记录暂无可用单词', 'error');
+      return;
+    }
+    this.readingLoadingId.set(id);
+    this.api.post<unknown>('e_record_detail', { id }).pipe(finalize(() => this.readingLoadingId.set(''))).subscribe({
+      next: ({ result }) => {
+        const hydratedWords = extractReadingWords(result);
+        if (!hydratedWords.length) {
+          this.platform.toast('该学习记录暂无可用单词', 'error');
+          return;
+        }
+        this.openReadingSetup(item, hydratedWords);
+      },
+      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
+    });
+  }
+
+  recordId(item: Item): string {
+    return String(item['GeneralID'] ?? item['generalId'] ?? item['ID'] ?? item['id'] ?? '').trim();
+  }
+
+  private openReadingSetup(item: Item, wordList: string[]): void {
     const params = {
       wordList: wordList.join(','),
-      GeneralID: item['GeneralID'],
+      GeneralID: this.recordId(item),
       returnUrl: this.router.url,
       t: Date.now(),
     };