Эх сурвалжийг харах

feat(mobile): support three learning passes for course vocabulary

彭峰 5 өдөр өмнө
parent
commit
4020795517

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

@@ -33,6 +33,9 @@ class LearningModulesApiService {
   studyBatches: any[] = [];
   lastStudyQuery: Record<string, unknown> = {};
   reviewPageResponse: Subject<ApiEnvelope<unknown>> | null = null;
+  courseWords: Record<string, unknown>[] | null = null;
+  courseIncrements = new Map<string, number>();
+  courseBookCounts: number[] | null = null;
   unitRows: Record<string, unknown>[] = [
     { NodeID: 811, NodeName: 'Unit 1', yx_word: 1, total: 20 },
     { NodeID: 812, NodeName: 'Unit 2', yx_word: 0, total: 18 },
@@ -118,19 +121,21 @@ class LearningModulesApiService {
     }
     if (action === 'e_ck_list') {
       this.lastWordParams = params;
-      const allWords = Number(params['nids']) === 813
+      const allWords = (this.courseWords ?? (Number(params['nids']) === 813
         ? Array.from({ length: 65 }, (_, index) => ({ GeneralID: 2001 + index, NodeID: 813, Title: `word-${index + 1}`, sy: `释义 ${index + 1}` }))
         : [
             { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: 'n. 苹果', yp: 'apple.mp3', w_learned: 1 },
             { GeneralID: 1002, NodeID: 812, Title: 'banana', sy: 'n. 香蕉', yp: 'banana.mp3', w_learned: 0 },
-          ];
+          ])).map((word: Record<string, unknown>) => ({...word,w_learned:(Number(word['w_learned'])||0)+(this.courseIncrements.get(String(word['GeneralID']))||0)}));
       const pageIndex = Math.max(1, Number(params['cpage'] ?? 1));
       const pageSize = Math.max(1, Number(params['psize'] ?? 30));
-      const result = allWords.slice((pageIndex - 1) * pageSize, pageIndex * pageSize);
+      const passCounts = [0,1,2,3].map(pass => allWords.filter(word => Math.min(3,Number(word['w_learned']) || 0) === pass).length);
+      const filtered = params['learningPass'] == null ? allWords : allWords.filter(word => Math.min(3,Number(word['w_learned']) || 0) === Number(params['learningPass']));
+      const result = filtered.slice((pageIndex - 1) * pageSize, pageIndex * pageSize);
       return of({
         retcode: 1,
         result: result as T,
-        page: { itemCount: allWords.length, pageCount: Math.ceil(allWords.length / pageSize), pageIndex, pageSize },
+        page: { itemCount: filtered.length, pageCount: Math.ceil(filtered.length / pageSize), pageIndex, pageSize, passCounts, bookPassCounts: this.courseBookCounts ?? passCounts },
       });
     }
     if (action === 'node_get') return this.result<T>([{ NodeID: 3, NodeName: '专项训练' }]);
@@ -163,6 +168,7 @@ class LearningModulesApiService {
     }
     if (action === 'e_add_words') {
       this.lastLearningWordUpdate = params;
+      if (params['save'] === 1) for (const id of String(params['wordsId']).split(',')) this.courseIncrements.set(id,(this.courseIncrements.get(id)||0)+1);
       return this.result<T>({ affected: String(params['wordsId'] ?? '').split(',').filter(Boolean).length });
     }
     if (action === 'content_add_zt') {
@@ -885,6 +891,62 @@ describe('home learning modules', () => {
     expect(play.calls.argsFor(1)[0]).toContain('apple.mp3');
   });
 
+  it('offers four course passes, gates repeats by the whole book and keeps first-pass progress at 100%', async () => {
+    user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1});
+    const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    api.courseWords=[{GeneralID:1001,NodeID:811,Title:'apple',w_learned:1},{GeneralID:1002,NodeID:811,Title:'banana',w_learned:2},{GeneralID:1003,NodeID:811,Title:'pear',w_learned:3}];
+    api.courseBookCounts=[1,1,1,1]; // An unselected unit still has a first-pass word.
+    const harness=await RouterTestingHarness.create('/pages/stu/stu_abc?nids=811&pnid=801&bindingId=701');
+    const learning=()=>harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    const tab=(n:number)=>(harness.routeNativeElement!.querySelectorAll('.course-pass-tabs button')[n] as HTMLButtonElement);
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement!.querySelectorAll('.course-pass-tabs button').length).toBe(4);
+    tab(1).click();harness.fixture.detectChanges();
+    expect(harness.routeNativeElement!.querySelector('.word-select-check')).toBeNull();
+    expect(learning().firstPassProgress()).toBe(75);
+    api.courseBookCounts=[0,1,1,1];tab(0).click();tab(1).click();harness.fixture.detectChanges();
+    expect(learning().firstPassProgress()).toBe(100);
+    const select=harness.routeNativeElement!.querySelector('.word-select-check') as HTMLButtonElement;
+    select.click();harness.fixture.detectChanges();
+    expect(learning().selected().size).toBe(1);
+    tab(2).click();harness.fixture.detectChanges();
+    expect(harness.routeNativeElement!.querySelector('.word-select-check')).toBeNull();
+    api.courseBookCounts=[0,0,2,1];tab(1).click();tab(2).click();harness.fixture.detectChanges();
+    expect(harness.routeNativeElement!.querySelector('.word-select-check')).not.toBeNull();
+    tab(3).click();harness.fixture.detectChanges();
+    expect(harness.routeNativeElement!.textContent).toContain('已完成三遍学习');
+    expect(harness.routeNativeElement!.querySelector('.word-select-check')).toBeNull();
+    expect(learning().firstPassProgress()).toBe(100);
+    // Selecting a learned word must actually enter the regular learning flow.
+    tab(1).click();harness.fixture.detectChanges();
+    (harness.routeNativeElement!.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
+    await harness.fixture.whenStable();harness.fixture.detectChanges();
+    expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con');
+    expect(learning().words().map(word=>word.word)).toEqual(['apple']);
+    const finishPass=async()=>{
+      for(let stage=0;stage<4;stage++){
+        const skip=Array.from(harness.routeNativeElement!.querySelectorAll<HTMLButtonElement>('.sticky-actions button')).find(button=>button.textContent?.includes('跳过这一关'))!;
+        skip.click();await harness.fixture.whenStable();harness.fixture.detectChanges();
+      }
+      expect(TestBed.inject(Router).url).toBe('/pages/his/his');
+    };
+    await finishPass();
+    expect(api.courseIncrements.get('1001')).toBe(1);
+    await harness.navigateByUrl('/pages/stu/stu_abc?nids=811&pnid=801&bindingId=701');harness.fixture.detectChanges();
+    tab(2).click();harness.fixture.detectChanges();
+    expect(learning().words().find(word=>word.word==='apple')?.['w_learned']).toBe(2);
+    (harness.routeNativeElement!.querySelector('[aria-label="加入学习:apple"]') as HTMLButtonElement).click();
+    (harness.routeNativeElement!.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
+    await harness.fixture.whenStable();harness.fixture.detectChanges();
+    await finishPass();
+    expect(api.courseIncrements.get('1001')).toBe(2);
+    await harness.navigateByUrl('/pages/stu/stu_abc?nids=811&pnid=801&bindingId=701');harness.fixture.detectChanges();
+    tab(3).click();harness.fixture.detectChanges();
+    expect(learning().words().find(word=>word.word==='apple')?.['w_learned']).toBe(3);
+    expect(harness.routeNativeElement!.querySelector('.word-select-check')).toBeNull();
+    expect(learning().firstPassProgress()).toBe(100);
+  },30000);
+
   it('shows named schedule students below the trigger and closes after selection or Escape', async () => {
     user.set({userId:300,userName:'coach',honeyName:'张老师',groupId:3});
     const harness=await RouterTestingHarness.create();

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

@@ -106,7 +106,7 @@
       <div>
         @if (isRefineSelectionPage) {
           <strong>{{ isReadOnlySelectionPage ? '词库学习进度' : '本次待学 ' + selected().size + ' / ' + selectionLimit }}</strong>
-          <span>已学完 {{ completedSelectionWords().length }} · 还没学 {{ pendingSelectionWords().length }}</span>
+          <span>整本首遍进度 {{ firstPassProgress() }}% · 未学习 {{ coursePassCount(0) }}</span>
         } @else if (isNewWordSelectionPage) {
           <strong>已选 {{ selected().size }} 个单词</strong>
           <span>未学习 {{ newWordCounts().pending }} 个 · 已学习 {{ newWordCounts().completed }} 个</span>
@@ -146,75 +146,49 @@
       </div>
     } @else if (isRefineSelectionPage) {
       <div class="word-selection-panel">
-        <nav class="word-selection-tabs" role="tablist" aria-label="单词学习状态">
-          <button type="button" role="tab" [class.active]="activeSelectionBucket() === 'pending'" [attr.aria-selected]="activeSelectionBucket() === 'pending'" (click)="activeSelectionBucket.set('pending')">
-            <span>还没学</span><b>{{ pendingSelectionWords().length }}</b>
-          </button>
-          <button type="button" role="tab" [class.active]="activeSelectionBucket() === 'completed'" [attr.aria-selected]="activeSelectionBucket() === 'completed'" (click)="activeSelectionBucket.set('completed')">
-            <span>已学完</span><b>{{ completedSelectionWords().length }}</b>
-          </button>
+        <nav class="word-selection-tabs course-pass-tabs" role="tablist" aria-label="单词学习遍数">
+          @for (label of coursePassTabs; track $index; let pass = $index) {
+            <button type="button" role="tab" [class.active]="coursePass() === pass" [attr.aria-selected]="coursePass() === pass" [attr.aria-controls]="'course-pass-panel'" (click)="selectCoursePass(pass)">
+              <span>{{ label }}</span><b>{{ coursePassCount(pass) }}</b>
+            </button>
+          }
         </nav>
-
-        @if (activeSelectionBucket() === 'pending') {
-          <section class="word-selection-list pending-column" role="tabpanel" aria-label="还没学的单词">
-            <p class="word-column-help">{{ isReadOnlySelectionPage ? '这些单词尚未完成学习,可点击单词查看释义。' : '先回忆中文,核对后选择“认识”或“加入学习”。' }}</p>
-            <div class="word-select-list">
-              @for (word of pendingSelectionWords(); track word.id) {
-                <article class="word-refine-item" [class.selected]="selected().has(word.id)">
-                  <div class="word-refine-main">
-                    @if (isReadOnlySelectionPage) {
-                      <span class="word-pending-mark" aria-hidden="true"><lucide-icon [img]="icons.CircleHelp" [size]="16" /></span>
-                    } @else {
-                      <button type="button" class="word-select-check" [attr.aria-label]="(selected().has(word.id) ? '移出学习:' : '加入学习:') + word.word" [attr.aria-pressed]="selected().has(word.id)" (click)="toggleWord(word)">
-                        @if (selected().has(word.id)) { <lucide-icon [img]="icons.Check" [size]="16" /> }
-                      </button>
-                    }
-                    <button type="button" class="word-reveal" [attr.aria-expanded]="selectionWordRevealed(word)" (click)="revealSelectionWord(word)">
-                      <strong>{{ word.word }}</strong>
-                      <small [class.revealed]="selectionWordRevealed(word)">{{ selectionWordRevealed(word) ? (word.meaning || '暂无释义') : isReadOnlySelectionPage ? '点击查看释义' : '先说出中文意思,再点击核对' }}</small>
+        <section id="course-pass-panel" class="word-selection-list" [class.pending-column]="coursePass() === 0" [class.completed-column]="coursePass() > 0" role="tabpanel" [attr.aria-label]="coursePassTabs[coursePass()]" [attr.aria-busy]="selectionLoading()">
+          <p class="word-column-help">{{ coursePassHint }}</p>
+          <div class="word-select-list">
+            @for (word of visibleCourseWords(); track word.id) {
+              <article class="word-refine-item" [class.selected]="selected().has(word.id)" [class.completed-word]="courseWordPass(word) > 0">
+                <div class="word-refine-main">
+                  @if (canRelearnCourseWord(word)) {
+                    <button type="button" class="word-select-check" [attr.aria-label]="(selected().has(word.id) ? '移出学习:' : '加入学习:') + word.word" [attr.aria-pressed]="selected().has(word.id)" (click)="toggleWord(word)">
+                      @if (selected().has(word.id)) { <lucide-icon [img]="icons.Check" [size]="16" /> }
                     </button>
-                    @if (!isReadOnlySelectionPage) { <span class="audio" aria-hidden="true"><lucide-icon [img]="icons.Volume2" [size]="18" /></span> }
-                  </div>
-                  @if (selectionWordRevealed(word) && !isReadOnlySelectionPage) {
+                  } @else {
+                    <span [class.word-complete-check]="courseWordPass(word) > 0" [class.word-pending-mark]="courseWordPass(word) === 0" aria-hidden="true"><lucide-icon [img]="courseWordPass(word) > 0 ? icons.Check : icons.CircleHelp" [size]="16" /></span>
+                  }
+                  <button type="button" class="word-reveal" [attr.aria-expanded]="selectionWordRevealed(word)" (click)="revealSelectionWord(word)">
+                    <strong>{{ word.word }}</strong>
+                    <small [class.revealed]="selectionWordRevealed(word)">{{ selectionWordRevealed(word) ? (word.meaning || '暂无释义') : courseWordPass(word) > 0 ? selectionCompletionLabel(word) + ' · 点击查看释义' : isReadOnlySelectionPage ? '点击查看释义' : '先说出中文意思,再点击核对' }}</small>
+                  </button>
+                  @if (!isReadOnlySelectionPage) { <span class="audio" aria-hidden="true"><lucide-icon [img]="icons.Volume2" [size]="18" /></span> }
+                </div>
+                @if (!isReadOnlySelectionPage && !wordHistoricallyLearned(word)) {
+                  @if (selectionAssessment()[word.id] === 'known') {
+                    <div class="completed-word-action"><span>本次已标记为认识</span><button type="button" (click)="markWordUnknown(word)">改为未学习</button></div>
+                  } @else if (selectionWordRevealed(word)) {
                     <div class="word-assessment">
                       <span>和你刚才说的一样吗?</span>
-                      <button type="button" class="known-button" [class.active]="selectionAssessment()[word.id] === 'known'" (click)="markWordKnown(word)"><lucide-icon [img]="icons.Check" [size]="15" />认识</button>
+                      <button type="button" class="known-button" (click)="markWordKnown(word)"><lucide-icon [img]="icons.Check" [size]="15" />认识</button>
                       <button type="button" class="unknown-button" [class.active]="selectionAssessment()[word.id] === 'unknown'" (click)="markWordUnknown(word)"><lucide-icon [img]="icons.CircleHelp" [size]="15" />不认识,加入学习</button>
                     </div>
                   }
-                </article>
-              } @empty {
-                <div class="word-column-empty"><lucide-icon [img]="icons.CheckCheck" [size]="24" /><span>{{selectionTotal() > words().length ?'已加载的词已学完,可继续加载更多':'这个词库已经全部学完'}}</span></div>
-              }
-            </div>
-          </section>
-        } @else {
-          <section class="word-selection-list completed-column" role="tabpanel" aria-label="已学完的单词">
-            <p class="word-column-help">包含真正完成学习,以及本次判断为“认识”的词。</p>
-            <div class="word-select-list">
-              @for (word of completedSelectionWords(); track word.id) {
-                <article class="word-refine-item completed-word">
-                  <div class="word-refine-main">
-                    <span class="word-complete-check" aria-hidden="true"><lucide-icon [img]="icons.Check" [size]="16" /></span>
-                    <button type="button" class="word-reveal" [attr.aria-expanded]="selectionWordRevealed(word)" (click)="revealSelectionWord(word)">
-                      <strong>{{ word.word }}</strong>
-                      <small [class.revealed]="selectionWordRevealed(word)">{{ selectionWordRevealed(word) ? (word.meaning || '暂无释义') : selectionCompletionLabel(word) + ' · 点击查看释义' }}</small>
-                    </button>
-                    @if (!isReadOnlySelectionPage) { <span class="audio" aria-hidden="true"><lucide-icon [img]="icons.Volume2" [size]="18" /></span> }
-                  </div>
-                  @if (!wordHistoricallyLearned(word)) {
-                    <div class="completed-word-action">
-                      <span>本次已标记为认识</span>
-                      <button type="button" (click)="markWordUnknown(word)">改为还没学</button>
-                    </div>
-                  }
-                </article>
-              } @empty {
-                <div class="word-column-empty"><lucide-icon [img]="icons.CheckCheck" [size]="24" /><span>还没有学完的词</span></div>
-              }
-            </div>
-          </section>
-        }
+                }
+              </article>
+            } @empty {
+              <div class="word-column-empty"><lucide-icon [img]="icons.BookOpen" [size]="24" /><span>{{ selectionLoading() ? '正在加载单词…' : '本栏暂无单词' }}</span></div>
+            }
+          </div>
+        </section>
       </div>
     } @else {
       <div class="word-select-list">

+ 82 - 7
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -126,6 +126,21 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly selectionRevealed = signal<Set<string>>(new Set());
   readonly selectionAssessment = signal<Record<string, 'known' | 'unknown'>>({});
   readonly activeSelectionBucket = signal<'pending' | 'completed'>('pending');
+  readonly coursePassTabs = ['未学习', '已学一遍', '已学第二遍', '已学第三遍'];
+  readonly coursePass = signal(0);
+  readonly coursePassCounts = signal<number[] | null>(null);
+  readonly bookPassCounts = signal<number[] | null>(null);
+  private readonly courseSelectionCache = signal<Word[]>([]);
+  readonly visibleCourseWords = computed(() => {
+    const candidates = this.coursePass() === 1 ? [...this.words(), ...this.newlyKnownWords()] : this.words();
+    return [...new Map(candidates.map(word => [word.id, word])).values()]
+      .filter(word => this.courseWordPass(word) === this.coursePass());
+  });
+  readonly firstPassProgress = computed(() => {
+    const counts = this.bookPassCounts();
+    const total = counts?.reduce((sum, n) => sum + n, 0) || 0;
+    return total && counts ? Math.round((total - counts[0]) / total * 100) : 0;
+  });
   readonly newWordCounts = signal({ pending: 0, completed: 0 });
   private readonly newWordPageSize = 30;
   private readonly newWordSelectionCache = new Map<string, Word>();
@@ -237,7 +252,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly visibleNewWords = computed(() => this.activeSelectionBucket() === 'completed' ? this.learnedNewWords() : this.unlearnedNewWords());
   readonly newWordPageCount = computed(() => Math.max(1, Math.ceil(this.selectionTotal() / this.newWordPageSize)));
   readonly allVisibleNewWordsSelected = computed(() => this.visibleNewWords().length > 0 && this.visibleNewWords().every((word) => this.selected().has(word.id)));
-  readonly newlyKnownWords = computed(() => this.words().filter((word) => !this.wordHistoricallyLearned(word) && this.selectionAssessment()[word.id] === 'known'));
+  readonly newlyKnownWords = computed(() => (this.isRefineSelectionPage ? this.courseSelectionCache() : this.words()).filter((word) => !this.wordHistoricallyLearned(word) && this.selectionAssessment()[word.id] === 'known'));
   readonly selectionActionLabel = computed(() => {
     const hasLearningWords = this.selected().size > 0;
     const hasKnownWords = this.newlyKnownWords().length > 0;
@@ -602,6 +617,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   toggleWord(word: Word): void {
+    if (this.isRefineSelectionPage && !this.canRelearnCourseWord(word)) return;
     const shouldSelect = !this.selected().has(word.id);
     if (!this.setWordSelected(word, shouldSelect) || !this.isRefineSelectionPage) return;
     if (shouldSelect) this.setSelectionAssessment(word, 'unknown');
@@ -624,8 +640,45 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return this.wordHistoricallyLearned(word) || this.selectionAssessment()[word.id] === 'known';
   }
 
+  courseWordPass(word: Word): number {
+    return Math.min(3, Math.max(this.selectionAssessment()[word.id] === 'known' ? 1 : 0, Math.floor(Number(word['w_learned']) || 0)));
+  }
+
+  coursePassCount(pass: number): number {
+    const counts = this.coursePassCounts();
+    if (!counts) return 0;
+    const known = this.newlyKnownWords().length;
+    return Math.max(0, counts[pass] + (pass === 0 ? -known : pass === 1 ? known : 0));
+  }
+
+  canRelearnCourseWord(word: Word): boolean {
+    const pass = this.courseWordPass(word);
+    const counts = this.bookPassCounts();
+    return !this.isReadOnlySelectionPage && !!counts && pass < 3
+      && (pass === 0 || counts.slice(0, pass).every(count => count === 0));
+  }
+
+  get coursePassHint(): string {
+    const pass = this.coursePass();
+    if (pass === 0) return this.isReadOnlySelectionPage ? '这些单词尚未完成第一遍学习,点击可查看释义。' : '先回忆中文,核对后选择“认识”或“加入学习”。';
+    if (pass === 3) return '这些单词已完成三遍学习,可查看和听读音。';
+    if (!this.bookPassCounts()) return '正在读取整本学习进度,读取完成后可继续学习。';
+    if (this.bookPassCounts()!.slice(0, pass).some(count => count > 0)) return `整本单词完成第${pass === 1 ? '一' : '二'}遍后,可从这里选择单词继续学习。`;
+    return this.isReadOnlySelectionPage ? `这些单词已学${pass}遍,可由老师选词继续学习。` : `选择单词开始第${pass === 1 ? '二' : '三'}遍学习。`;
+  }
+
+  selectCoursePass(pass: number): void {
+    if (pass === this.coursePass() || pass < 0 || pass > 3) return;
+    this.selectionRequest?.unsubscribe();
+    this.coursePass.set(pass);
+    this.selectionPage.set(1);
+    this.words.set([]);
+    this.selectionTotal.set(0);
+    this.loadSelection();
+  }
+
   selectionCompletionLabel(word: Word): string {
-    return this.wordHistoricallyLearned(word) ? '历史已学完' : '本次已认识';
+    return this.wordHistoricallyLearned(word) ? `已学${this.courseWordPass(word)}遍` : '本次已认识';
   }
 
   revealSelectionWord(word: Word): void {
@@ -639,11 +692,13 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   markWordKnown(word: Word): void {
+    if (this.isReadOnlySelectionPage || this.wordHistoricallyLearned(word)) return;
     this.setWordSelected(word, false);
     this.setSelectionAssessment(word, 'known');
   }
 
   markWordUnknown(word: Word): void {
+    if (this.isReadOnlySelectionPage || this.wordHistoricallyLearned(word)) return;
     if (this.setWordSelected(word, true)) this.setSelectionAssessment(word, 'unknown');
   }
 
@@ -670,7 +725,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   startSelected(): void {
-    const available = this.isNewWordSelectionPage ? [...this.newWordSelectionCache.values()] : this.isRefineSelectionPage ? this.pendingSelectionWords() : this.words();
+    if (this.submitting() || this.isReadOnlySelectionPage) return;
+    const available = this.isNewWordSelectionPage ? [...this.newWordSelectionCache.values()] : this.isRefineSelectionPage ? this.courseSelectionCache().filter(word => this.canRelearnCourseWord(word) && this.selectionAssessment()[word.id] !== 'known') : this.words();
     const chosen = available
       .filter((word) => this.selected().has(word.id));
     const newlyKnown = this.newlyKnownWords();
@@ -1308,7 +1364,17 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const failure=(error:ApiError)=>{if(more){this.selectionPage.update(p=>p-1);this.platform.toast(error.message,'error');}else this.error.set(error.message);};
     if (this.meta.path === 'pages/stu/stu_abc') {
       const student = this.session.student() ?? this.session.user();this.selectionLoading.set(true);
-      this.selectionRequest=this.api.get<unknown>('e_ck_list',{checkwords:2,nids:this.query('nids')||this.query('nid'),modelId:52,cpage:this.selectionPage(),psize:100,uid:student?.userId}).pipe(finalize(()=>{this.selectionLoading.set(false);this.loading.set(false);})).subscribe({next:({result,page})=>accept(result,page?.itemCount),error:failure});return;
+      this.selectionRequest=this.api.get<unknown>('e_ck_list',{checkwords:2,nids:this.query('nids')||this.query('nid'),pnid:this.query('pnid'),learningPass:this.coursePass(),modelId:52,cpage:this.selectionPage(),psize:100,uid:student?.userId}).pipe(finalize(()=>{this.selectionLoading.set(false);this.loading.set(false);})).subscribe({next:({result,page})=>{
+        accept(result,page?.itemCount);
+        const merged=new Map(this.courseSelectionCache().map(word=>[word.id,word]));
+        for(const word of this.words()) merged.set(word.id,word);
+        this.courseSelectionCache.set([...merged.values()]);
+        const counts=page?.['passCounts'],bookCounts=page?.['bookPassCounts'];
+        if(Array.isArray(counts)&&counts.length===4&&Array.isArray(bookCounts)&&bookCounts.length===4){
+          this.coursePassCounts.set(counts.map(Number));this.bookPassCounts.set(bookCounts.map(Number));
+        }
+        this.error.set('');
+      },error:failure});return;
     }
 
     if (this.meta.path === 'pages/his/his_words') {
@@ -2115,6 +2181,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.selectionRevealed.set(new Set());
     this.selectionAssessment.set({});
     this.activeSelectionBucket.set('pending');
+    this.coursePass.set(0);
+    this.coursePassCounts.set(null);
+    this.bookPassCounts.set(null);
+    this.courseSelectionCache.set([]);
     this.currentIndex.set(0);
     this.revealed.set(false);
     this.completed.set(false);
@@ -2221,9 +2291,14 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   private commitKnownWords(words: Word[]): void {
-    const ids = new Set(words.map((word) => word.id));
-    this.words.update((current) => current.map((word) => ids.has(word.id) ? { ...word, w_learned: Math.max(1, Number(word['w_learned'] ?? 0)) } : word));
-    this.selectionAssessment.update((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !ids.has(id))) as Record<string, 'known' | 'unknown'>);
+    const ids = new Set(words.map(word => word.id));
+    const update = (current: Word[]) => current.map(word => ids.has(word.id) ? {...word, w_learned: 1} : word);
+    this.words.update(update);
+    this.courseSelectionCache.update(update);
+    const advance = (counts: number[] | null) => counts ? [Math.max(0,counts[0]-ids.size),counts[1]+ids.size,counts[2],counts[3]] : null;
+    this.coursePassCounts.update(advance);
+    this.bookPassCounts.update(advance);
+    this.selectionAssessment.update(current => Object.fromEntries(Object.entries(current).filter(([id]) => !ids.has(id))) as Record<string, 'known' | 'unknown'>);
   }
 
   private findList(value: unknown): SourceItem[] {

+ 4 - 0
projects/xiaoshu-mobile/src/styles.scss

@@ -881,6 +881,10 @@ app-learning-page .word-selection-tabs button + button { border-left: 1px solid
 app-learning-page .word-selection-tabs button.active { color: var(--brand-strong); background: var(--brand-wash); border-bottom-color: var(--brand-strong); }
 app-learning-page .word-selection-tabs b { min-width: 25px; padding: 3px 7px; color: inherit; background: var(--surface-muted); border-radius: 999px; font-size: 11px; }
 app-learning-page .word-selection-tabs button.active b { background: var(--brand-pale); }
+app-learning-page .word-selection-tabs.course-pass-tabs { grid-template-columns: repeat(4,minmax(0,1fr)); }
+app-learning-page .course-pass-tabs button { min-width: 0; min-height: 72px; padding: 8px 4px; flex-direction: column; gap: 5px; font-size: 14px; }
+app-learning-page .course-pass-tabs button span { text-align: center; overflow-wrap: anywhere; }
+
 app-learning-page .word-column-help { min-height: 51px; margin: 0; padding: 10px 14px; color: var(--ink-soft); border-bottom: 1px solid var(--line); font-size: 11px; line-height: 1.45; }
 app-learning-page .word-complete-check { display: grid; width: 32px; height: 32px; color: white; background: var(--brand-strong); border-radius: 50%; place-items: center; }
 app-learning-page .word-pending-mark { display: grid; width: 32px; height: 32px; color: #9a6407; background: var(--accent-pale); border-radius: 50%; place-items: center; }

+ 20 - 4
scripts/deploy-admin-functions.mjs

@@ -2638,10 +2638,26 @@ async function newWordPage(input, current) {
   return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size, pendingCount, completedCount } };
 }
 async function courseWordPage(input, current) {
-  const uid = await authorizeRequestedUser(current, input.uid); const nodes = String(input.nids || input.nid || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0'); if (!nodes.length) fail(400, '缺少词库单元 ID'); const paging = { index: Math.max(1, number(input.cpage || input.page, 1)), size: Math.min(500, Math.max(1, number(input.psize || input.pageSize, 100))) }; const values = [DEFAULT_COMPANY_ID, String(uid), nodes]; const where = 'c."company" = $1 AND c."modelId" = 52 AND CAST(c."nodeId" AS text) = ANY($3::text[])';
-  const countPromise = Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c WHERE ' + where, values);
-  const rowPromise = Psql.query('SELECT c.*, row_to_json(v) AS "__addon", COALESCE(p."learned",0) AS "__learned", COALESCE(p."newWord",0) AS "__newWord" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND v."id" = c."itemId" LEFT JOIN (SELECT CAST(pr."scid" AS text) AS "wordId", MAX(CASE WHEN CAST(pr."xxcs" AS text) ~ \'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(pr."xxcs" AS numeric) ELSE 0 END)::int AS "learned", MAX(CASE WHEN LOWER(CAST(pr."jrscb" AS text)) IN (\'1\',\'true\') THEN 1 ELSE 0 END)::int AS "newWord" FROM "PracticeRecord" pr WHERE pr."company" = $1 AND CAST(pr."yhid" AS text) = $2 GROUP BY CAST(pr."scid" AS text)) p ON p."wordId" = CAST(c."generalId" AS text) WHERE ' + where + ' ORDER BY c."orderId" ASC, c."generalId" ASC LIMIT $4 OFFSET $5', values.concat([paging.size, (paging.index - 1) * paging.size]));
-  const [countRow, rows] = await Promise.all([countPromise, rowPromise]); const result = rows.map((source) => { const learned = number(source.__learned); const newWord = number(source.__newWord); delete source.__learned; delete source.__newWord; const word = normalizeWordRow(source); return { ...word, detail: word, gldy: word.GeneralID, w_learned: learned, ifnew: newWord }; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
+  const uid = await authorizeRequestedUser(current, input.uid);
+  const nodes = String(input.nids || input.nid || '').split(',').map(value => String(number(value))).filter(value => value !== '0');
+  if (!nodes.length) fail(400, '缺少词库单元 ID');
+  const pass = input.learningPass == null || input.learningPass === '' ? null : Number(input.learningPass);
+  if (pass !== null && (!Number.isInteger(pass) || pass < 0 || pass > 3)) fail(400, '学习遍数无效');
+  const paging = {index:Math.max(1,number(input.cpage || input.page,1)),size:Math.min(500,Math.max(1,number(input.psize || input.pageSize,100)))};
+  const values = [DEFAULT_COMPANY_ID,String(uid),nodes,String(number(input.pnid))];
+  const cte = "WITH RECURSIVE book_nodes AS (\n SELECT \"nodeId\" FROM \"Node\" WHERE company=$1 AND \"nodeId\"::text=$4\n UNION SELECT n.\"nodeId\" FROM \"Node\" n JOIN book_nodes parent ON n.\"parentId\"::text=parent.\"nodeId\"::text WHERE n.company=$1\n), progress AS (\n SELECT c.*, LEAST(3,GREATEST(0,COALESCE(p.learned,0)))::int AS pass, COALESCE(p.\"newWord\",0) AS \"newWord\"\n FROM \"CommonModel\" c LEFT JOIN (\n SELECT pr.scid::text AS \"wordId\", MAX(CASE WHEN pr.xxcs::text ~ '^[0-9]+([.][0-9]+)?$' THEN FLOOR(pr.xxcs::numeric) ELSE 0 END) AS learned,\n MAX(CASE WHEN LOWER(pr.jrscb::text) IN ('1','true') THEN 1 ELSE 0 END) AS \"newWord\"\n FROM \"PracticeRecord\" pr WHERE pr.company=$1 AND pr.yhid::text=$2 GROUP BY pr.scid::text\n ) p ON p.\"wordId\"=c.\"generalId\"::text\n WHERE c.company=$1 AND c.\"modelId\"=52 AND (c.\"nodeId\"::text=ANY($3::text[]) OR c.\"nodeId\" IN (SELECT \"nodeId\" FROM book_nodes))\n) ";
+  const [summary,rows] = await Promise.all([
+    Psql.one(cte + "SELECT COUNT(*) FILTER (WHERE \"nodeId\"::text=ANY($3::text[]) AND pass=0)::int AS \"selected0\",COUNT(*) FILTER (WHERE \"nodeId\"::text=ANY($3::text[]) AND pass=1)::int AS \"selected1\",COUNT(*) FILTER (WHERE \"nodeId\"::text=ANY($3::text[]) AND pass=2)::int AS \"selected2\",COUNT(*) FILTER (WHERE \"nodeId\"::text=ANY($3::text[]) AND pass=3)::int AS \"selected3\",COUNT(*) FILTER (WHERE pass=0)::int AS \"book0\",COUNT(*) FILTER (WHERE pass=1)::int AS \"book1\",COUNT(*) FILTER (WHERE pass=2)::int AS \"book2\",COUNT(*) FILTER (WHERE pass=3)::int AS \"book3\" FROM progress",values),
+    Psql.query(cte + "SELECT p.*, row_to_json(v) AS \"__addon\" FROM progress p LEFT JOIN \"VocabularyWord\" v ON v.company=$1 AND v.id=p.\"itemId\" WHERE p.\"nodeId\"::text=ANY($3::text[]) AND ($5::int IS NULL OR p.pass=$5) ORDER BY p.\"orderId\",p.\"generalId\" LIMIT $6 OFFSET $7",values.concat([pass,paging.size,(paging.index-1)*paging.size]))
+  ]);
+  const passCounts = [0,1,2,3].map(n => number(summary['selected'+n]));
+  const bookPassCounts = [0,1,2,3].map(n => number(summary['book'+n]));
+  const total = pass === null ? passCounts.reduce((sum,n)=>sum+n,0) : passCounts[pass];
+  const bookTotal = bookPassCounts.reduce((sum,n)=>sum+n,0);
+  return {rows:rows.map(source=>{
+    const learned=source.pass,newWord=source.newWord;delete source.pass;delete source.newWord;
+    const word=normalizeWordRow(source);return {...word,detail:word,gldy:word.GeneralID,w_learned:learned,ifnew:newWord};
+  }),page:{itemCount:total,pageCount:Math.ceil(total/paging.size),pageIndex:paging.index,pageSize:paging.size,passCounts,bookPassCounts,firstPassProgress:bookTotal?Math.round((bookTotal-bookPassCounts[0])/bookTotal*100):0}};
 }
 async function updateLearningWords(input, current) {
   const uid = await authorizeRequestedUser(current, input.uid); const wordIds = [...new Set(String(input.wordsId || input.wordIds || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0'))]; if (!wordIds.length) fail(400, '请选择单词'); if (wordIds.length > 100) fail(400, '单次最多操作 100 个单词'); const saveLearning = number(input.save) === 1; const hasNewWordFlag = input.ifnew !== undefined && input.ifnew !== ''; const newWordFlag = hasNewWordFlag ? (number(input.ifnew) === 1 ? '1' : '0') : null; if (!saveLearning && !hasNewWordFlag) fail(400, '缺少学习或生词操作类型'); const kcid = number(input.kcid);

+ 64 - 0
scripts/tests/course-word-progress.test.mjs

@@ -0,0 +1,64 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+const gateway=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+async function setup(t){
+ const db=new PGlite();t.after(()=>db.close());
+ await db.exec(`
+ CREATE TABLE "Node" (company text,"nodeId" int,"parentId" int);
+ CREATE TABLE "CommonModel" (company text,"modelId" int,"generalId" int,"itemId" int,"nodeId" int,"orderId" int,title text);
+ CREATE TABLE "VocabularyWord" (company text,id int,sy text);
+ CREATE TABLE "PracticeRecord" (company text,yhid text,scid text,xxcs text,jrscb text);
+ INSERT INTO "Node" VALUES ('7pIbDBJmKx',100,0),('7pIbDBJmKx',110,100),('7pIbDBJmKx',120,100),('other',130,100);
+ INSERT INTO "CommonModel" VALUES
+ ('7pIbDBJmKx',52,1,1,110,1,'one'),('7pIbDBJmKx',52,2,2,110,2,'two'),
+ ('7pIbDBJmKx',52,3,3,110,3,'three'),('7pIbDBJmKx',52,4,4,110,4,'four'),
+ ('7pIbDBJmKx',52,5,5,120,5,'other-unit'),('other',52,6,6,130,6,'foreign');
+ INSERT INTO "VocabularyWord" VALUES ('7pIbDBJmKx',2,'第二个词');
+ INSERT INTO "PracticeRecord" VALUES
+ ('7pIbDBJmKx','42','2','1','0'),('7pIbDBJmKx','42','2','0','0'),
+ ('7pIbDBJmKx','42','3','2.0','0'),('7pIbDBJmKx','42','4','7','0'),
+ ('7pIbDBJmKx','99','1','3','0'),('other','42','1','3','0');
+ `);
+ const context=vm.createContext({Psql:{one:async(s,a)=>(await db.query(s,a)).rows[0],query:async(s,a)=>(await db.query(s,a)).rows}});
+ vm.runInContext(gateway,context);
+ context.authorizeRequestedUser=async(_,uid)=>{if(uid!==42)throw Error('无权访问');return uid;};
+ return {db,read:input=>context.courseWordPage({uid:42,nids:'110',pnid:100,...input},{})};
+}
+test('四栏按最大有效学习次数分类,旧记录超过三遍归入第三遍,不重复计算',async t=>{
+ const {read}=await setup(t);
+ for(let pass=0;pass<4;pass++){
+  const result=await read({learningPass:pass,psize:1});
+  assert.equal(result.rows.length,1);assert.equal(result.rows[0].w_learned,pass);
+  assert.equal(result.rows[0].GeneralID,pass+1);
+  assert.deepEqual(Array.from(result.page.passCounts),[1,1,1,1]);
+  assert.deepEqual(Array.from(result.page.bookPassCounts),[2,1,1,1]);
+  assert.equal(result.page.firstPassProgress,60);
+ }
+ const result=await read({learningPass:1});assert.equal(result.rows[0].detail.sy,'第二个词');
+});
+test('分页后仍保留整本计数,其他单元未学完不会被误判为首遍完成',async t=>{
+ const {read,db}=await setup(t);
+ await db.exec(`UPDATE "PracticeRecord" SET xxcs='1' WHERE company='7pIbDBJmKx' AND yhid='42';INSERT INTO "PracticeRecord" VALUES('7pIbDBJmKx','42','1','1','0');`);
+ const selected=await read({learningPass:1,psize:2,cpage:2});
+ assert.equal(selected.rows.length,2);assert.equal(selected.page.itemCount,4);
+ assert.deepEqual(Array.from(selected.page.bookPassCounts),[1,4,0,0]);assert.equal(selected.page.firstPassProgress,80);
+ const empty=await read({learningPass:1,psize:2,cpage:3});assert.equal(empty.rows.length,0);assert.equal(empty.page.itemCount,4);
+});
+test('首遍全完成后进度始终100%,第二第三遍不重置;兼容旧客户端混合列表',async t=>{
+ const {read,db}=await setup(t);
+ await db.exec(`INSERT INTO "PracticeRecord" VALUES ('7pIbDBJmKx','42','1','1','0'),('7pIbDBJmKx','42','5','1','0');`);
+ assert.equal((await read({})).page.firstPassProgress,100);
+ await db.exec(`UPDATE "PracticeRecord" SET xxcs='2' WHERE company='7pIbDBJmKx' AND yhid='42';`);
+ assert.equal((await read({})).page.firstPassProgress,100);
+ await db.exec(`UPDATE "PracticeRecord" SET xxcs='3' WHERE company='7pIbDBJmKx' AND yhid='42';`);
+ const done=await read({});assert.equal(done.page.firstPassProgress,100);assert.equal(done.rows.length,4);assert.equal(done.page.passCounts[3],4);
+});
+test('拒绝无效遍数和越权访问',async t=>{
+ const {read}=await setup(t);
+ for(const learningPass of [-1,4,1.5,'bad'])await assert.rejects(read({learningPass}),/学习遍数无效/);
+ await assert.rejects(read({uid:99}),/无权访问/);
+});