Răsfoiți Sursa

Split wordbook by learning status and save self-study progress

彭峰 1 săptămână în urmă
părinte
comite
b65bb2e2ea

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

@@ -452,6 +452,63 @@ describe('home learning modules', () => {
     expect(router.url).toBe('/pages/home/cart');
   });
 
+  it('separates unlearned and learned wordbook words while keeping selections across tabs', async () => {
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const get = api.get.bind(api);
+    spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list'
+      ? of({ retcode: 1, result: [
+        { GeneralID: 601, Title: 'tend@张雅茜', xxcs: 0, detail: [{ GeneralID: 701, Title: 'tend', sy: '照顾' }] },
+        { GeneralID: 602, Title: 'prepare@张雅茜', xxcs: 2, detail: [{ GeneralID: 702, Title: 'prepare', sy: '准备' }] },
+      ], page: { itemCount: 2, pageCount: 1, pageIndex: 1, pageSize: 100 } } as ApiEnvelope<any>)
+      : get(action, params));
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/member/starshop');
+    harness.fixture.detectChanges();
+    const tabs = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.word-selection-tabs button') ?? [];
+    expect(tabs[0].textContent).toContain('未学习');
+    expect(tabs[0].textContent).toContain('1');
+    expect(harness.routeNativeElement?.querySelector('.pending-column')?.textContent).toContain('tend');
+    (harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button') as HTMLButtonElement).click();
+    tabs[1].click();
+    harness.fixture.detectChanges();
+    expect(tabs[1].textContent).toContain('已学习');
+    expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('prepare');
+    (harness.routeNativeElement?.querySelector('.completed-column .word-select-list > button') as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.selection-header strong')?.textContent).toContain('已选 2 个单词');
+    tabs[0].click();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button.selected')).not.toBeNull();
+  });
+
+  it('saves completed wordbook study before showing the word in the learned tab', async () => {
+    const prefix = 'xiaoshu.learning.100.100.practice.';
+    const word = { GeneralID: 701, Title: 'tend', sy: '照顾' };
+    localStorage.setItem(prefix + 'learn_mode', JSON.stringify('newword'));
+    localStorage.setItem(prefix + 'choose_newword_learn', JSON.stringify([word]));
+    localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify([[word]]));
+    localStorage.setItem(prefix + '_page_current_learn', JSON.stringify(1));
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const get = api.get.bind(api);
+    spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list'
+      ? of({ retcode: 1, result: [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: api.lastLearningWordUpdate['save'] ? 1 : 0, detail: [word] }], page: { itemCount: 1, pageCount: 1, pageIndex: 1, pageSize: 100 } } as ApiEnvelope<any>)
+      : get(action, params));
+    const harness = await RouterTestingHarness.create();
+    const router = TestBed.inject(Router);
+    await harness.navigateByUrl('/pages/stu/stu5');
+    const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    learning.skipSpellingStage();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(api.lastLearningWordUpdate).toEqual(jasmine.objectContaining({ uid: 100, wordsId: '701', save: 1 }));
+    expect(router.url).toBe('/pages/member/starshop');
+    const tabs = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.word-selection-tabs button') ?? [];
+    expect(tabs[0].textContent).toContain('0');
+    tabs[1].click();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('tend');
+  }, 15000);
+
   it('skips the entire spelling stage for student wordbook self-study in one click', async () => {
     const prefix = 'xiaoshu.learning.100.100.practice.';
     const words = [

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

@@ -106,13 +106,16 @@
         @if (isRefineSelectionPage) {
           <strong>{{ isReadOnlySelectionPage ? '词库学习进度' : '本次待学 ' + selected().size + ' / ' + selectionLimit }}</strong>
           <span>已学完 {{ completedSelectionWords().length }} · 还没学 {{ pendingSelectionWords().length }}</span>
+        } @else if (isNewWordSelectionPage) {
+          <strong>已选 {{ selected().size }} 个单词</strong>
+          <span>已加载 {{ words().length }} 个 · 未学习 {{ unlearnedNewWords().length }} · 已学习 {{ learnedNewWords().length }}</span>
         } @else {
           <strong>已选 {{ selected().size }} / {{ selectionLimit }}</strong>
           <span>选择本次要学习的单词</span>
         }
       </div>
       <div class="header-actions">
-        @if (!isRefineSelectionPage) { <button type="button" class="quiet-button" (click)="toggleAllWords()">{{ selected().size ? '取消全选' : '全选' }}</button> }
+        @if (!isRefineSelectionPage) { <button type="button" class="quiet-button" [disabled]="isNewWordSelectionPage && !visibleNewWords().length" (click)="toggleAllWords()">{{ isNewWordSelectionPage ? (allVisibleNewWordsSelected() ? '取消本栏' : '全选本栏') : (selected().size ? '取消全选' : '全选') }}</button> }
         @if (!isReadOnlySelectionPage) {
           <button type="button" class="primary-button" [disabled]="submitting()" (click)="startSelected()">
             <lucide-icon [img]="icons.Play" [size]="17" />{{ isRefineSelectionPage ? selectionActionLabel() : '开始学习' }}
@@ -120,7 +123,27 @@
         }
       </div>
     </header>
-    @if (isRefineSelectionPage) {
+    @if (isNewWordSelectionPage) {
+      <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>{{ unlearnedNewWords().length }}</b></button>
+          <button type="button" role="tab" [class.active]="activeSelectionBucket() === 'completed'" [attr.aria-selected]="activeSelectionBucket() === 'completed'" (click)="activeSelectionBucket.set('completed')"><span>已学习</span><b>{{ learnedNewWords().length }}</b></button>
+        </nav>
+        <section class="word-selection-list" [class.pending-column]="activeSelectionBucket() === 'pending'" [class.completed-column]="activeSelectionBucket() === 'completed'" role="tabpanel" [attr.aria-label]="activeSelectionBucket() === 'pending' ? '未学习的生词' : '已学习的生词'">
+          <div class="word-select-list">
+            @for (word of visibleNewWords(); track word.id) {
+              <button type="button" [class.selected]="selected().has(word.id)" (click)="toggleWord(word)">
+                <span class="check">@if (selected().has(word.id)) { <lucide-icon [img]="icons.Check" [size]="16" /> }</span>
+                <span><strong>{{ word.word }}</strong><small>{{ word.meaning || '暂无释义' }}</small></span>
+                <span class="audio" (click)="$event.stopPropagation(); play(word)"><lucide-icon [img]="icons.Volume2" [size]="18" /></span>
+              </button>
+            } @empty {
+              <div class="word-column-empty"><lucide-icon [img]="icons.CheckCheck" [size]="24" /><span>{{ activeSelectionBucket() === 'pending' ? '已加载的生词都学过了' : '还没有学过的生词' }}</span></div>
+            }
+          </div>
+        </section>
+      </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')">
@@ -256,7 +279,7 @@
     }
   } @else if (isListeningPage) {
     @if (completed()) {
-      <ng-container *ngTemplateOutlet="stageComplete; context: { title: '听音识意完成', text: '下一关是组合拼写' }" />
+      <ng-container *ngTemplateOutlet="stageComplete; context: { title: '听音识意完成', text: spellingStageSkipped ? '本组已完成,可以继续学习' : '下一关是组合拼写' }" />
     } @else {
       @if (current(); as word) {
         <div class="study-progress"><div><span [style.width.%]="progress()"></span></div><strong>{{ currentIndex() + 1 }} / {{ words().length }}</strong></div>

+ 53 - 8
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -205,6 +205,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly current = computed(() => this.words()[this.currentIndex()] ?? null);
   readonly completedSelectionWords = computed(() => this.words().filter((word) => this.selectionWordCompleted(word)));
   readonly pendingSelectionWords = computed(() => this.words().filter((word) => !this.selectionWordCompleted(word)));
+  readonly learnedNewWords = computed(() => this.words().filter((word) => this.newWordLearned(word)));
+  readonly unlearnedNewWords = computed(() => this.words().filter((word) => !this.newWordLearned(word)));
+  readonly visibleNewWords = computed(() => this.activeSelectionBucket() === 'completed' ? this.learnedNewWords() : this.unlearnedNewWords());
+  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 selectionActionLabel = computed(() => {
     const hasLearningWords = this.selected().size > 0;
@@ -319,6 +323,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return ['pages/stu/stu_abc', 'pages/his/his_words', 'pages/member/starshop'].includes(this.meta.path);
   }
 
+  get isNewWordSelectionPage(): boolean {
+    return this.meta.path === 'pages/member/starshop';
+  }
+
   get isHistoryWordView(): boolean {
     return this.meta.path === 'pages/his/his_words';
   }
@@ -364,6 +372,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return ['pages/stu/stu5', 'pages/stu/pl_stu5'].includes(this.meta.path);
   }
 
+  get spellingStageSkipped(): boolean {
+    return this.storageGet('skip_spelling_stage') === true;
+  }
+
   get canSkipSpellingStage(): boolean {
     return this.meta.path === 'pages/stu/pl_stu5'
       || (this.meta.path === 'pages/stu/stu5' && this.isNewWordSelfStudyStage);
@@ -378,7 +390,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   get stageActionLabel(): string {
     if (this.isMatchingPage) return '进入听音识意';
-    if (this.isListeningPage && this.storageGet('skip_spelling_stage') !== true) return '进入组合拼写';
+    if (this.isListeningPage && !this.spellingStageSkipped) return '进入组合拼写';
     if (this.hasNextWordBatch) return '学习下一组单词';
     const mode = String(this.storageGet('learn_mode') ?? '');
     if (mode === 'newword' || mode === 'history_word') return '完成学习并返回';
@@ -570,6 +582,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return Number(word['w_learned'] ?? 0) > 0;
   }
 
+  newWordLearned(word: Word): boolean {
+    return Number(word['xxcs'] ?? 0) > 0 || this.wordHistoricallyLearned(word);
+  }
+
   selectionWordCompleted(word: Word): boolean {
     return this.wordHistoricallyLearned(word) || this.selectionAssessment()[word.id] === 'known';
   }
@@ -598,6 +614,17 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   toggleAllWords(): void {
+    if (this.isNewWordSelectionPage) {
+      const visible = this.visibleNewWords();
+      const clearVisible = this.allVisibleNewWordsSelected();
+      const next = new Set(this.selected());
+      for (const word of visible) {
+        if (clearVisible) next.delete(word.id);
+        else next.add(word.id);
+      }
+      this.selected.set(next);
+      return;
+    }
     if (this.selected().size) {
       this.selected.set(new Set());
       return;
@@ -801,7 +828,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       this.navigateStage('/pages/stu/stu4');
     } else if (this.isListeningPage) {
       this.completed.set(false);
-      if (this.storageGet('skip_spelling_stage') === true) this.finishSpellingFlow();
+      if (this.spellingStageSkipped) this.finishSpellingFlow();
       else this.navigateStage(this.canStartPointReading ? '/pages/stu/pl_stu5' : '/pages/stu/stu5');
     } else if (this.isSpellingPage) {
       this.finishSpellingFlow();
@@ -1298,18 +1325,36 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   private finishSpellingFlow(): void {
     const currentPage = Number(this.storageGet('_page_current_learn') ?? 1);
+    const mode = String(this.storageGet('learn_mode') ?? '');
+    if (mode === 'newword') {
+      const learner = this.canStartPointReading ? this.session.student() : this.session.user();
+      this.submitting.set(true);
+      this.persistWordsAsLearned(this.words(), learner).pipe(finalize(() => this.submitting.set(false))).subscribe({
+        next: () => {
+          if (this.hasNextWordBatch) {
+            this.completed.set(false);
+            this.storageSet('_page_current_learn', currentPage + 1);
+            this.navigateStage('/pages/stu/stu_con');
+            return;
+          }
+          this.completed.set(false);
+          this.clearLearningStorage();
+          void this.router.navigateByUrl('/pages/member/starshop', { replaceUrl: true });
+        },
+        error: (error: ApiError) => {
+          this.completed.set(true);
+          this.platform.toast(error.message || '学习结果保存失败,请重试', 'error');
+        },
+      });
+      return;
+    }
     if (this.hasNextWordBatch) {
       this.completed.set(false);
       this.storageSet('_page_current_learn', currentPage + 1);
       this.navigateStage('/pages/stu/stu_con');
       return;
     }
-    const mode = String(this.storageGet('learn_mode') ?? '');
-    if (mode === 'newword') {
-      this.completed.set(false);
-      this.clearLearningStorage();
-      void this.router.navigateByUrl('/pages/member/starshop', { replaceUrl: true });
-    } else if (mode === 'history_word') {
+    if (mode === 'history_word') {
       this.completed.set(false);
       this.clearLearningStorage();
       void this.router.navigateByUrl('/pages/his/his', { replaceUrl: true });