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

Skip entire spelling stage during vocabulary learning

彭峰 2 недель назад
Родитель
Сommit
9e5b78aeca

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

@@ -283,6 +283,7 @@ describe('home learning modules', () => {
   };
 
   beforeEach(() => {
+    localStorage.removeItem('xiaoshu.learning.300.100.901.skip_spelling_stage');
     user.set({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 });
     session.groupId.set(1);
     session.student.set(null);
@@ -416,6 +417,47 @@ describe('home learning modules', () => {
     expect(router.url).toBe('/pages/home/cart');
   });
 
+  it('skips the entire spelling stage for student wordbook self-study in one click', async () => {
+    const prefix = 'xiaoshu.learning.100.100.practice.';
+    const words = [
+      { GeneralID: 701, Title: 'tend', sy: '照顾' },
+      { GeneralID: 702, Title: 'work on', sy: '从事' },
+      { GeneralID: 703, Title: 'prepare', sy: '准备' },
+    ];
+    localStorage.setItem(prefix + 'learn_mode', JSON.stringify('newword'));
+    localStorage.setItem(prefix + 'choose_newword_learn', JSON.stringify(words.slice(0, 2)));
+    localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify([words.slice(0, 2), words.slice(2)]));
+    localStorage.setItem(prefix + '_page_current_learn', JSON.stringify(1));
+    const harness = await RouterTestingHarness.create();
+    const router = TestBed.inject(Router);
+    await harness.navigateByUrl('/pages/stu/stu5');
+    harness.fixture.detectChanges();
+    const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    expect(learning().words().length).toBe(2);
+    expect(learning().currentIndex()).toBe(0);
+    const skip = Array.from(harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.skippable-spelling-actions button') ?? [])
+      .find((button) => button.textContent?.includes('跳到下一关'))!;
+    skip.click();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(router.url).toContain('/pages/stu/stu_con');
+    expect(learning().words().map((word) => word.word)).toEqual(['prepare']);
+    learning().skipStudyList();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    learning().chooseMatch('left', learning().words()[0]);
+    learning().chooseMatch('right', learning().words()[0]);
+    learning().continueAfterStage();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(router.url).toContain('/pages/stu/stu4');
+    learning().chooseMeaning(learning().words()[0]);
+    learning().continueAfterStage();
+    await harness.fixture.whenStable();
+    expect(router.url).toBe('/pages/member/starshop');
+    expect(localStorage.getItem(prefix + 'learn_mode')).toBeNull();
+  }, 15000);
+
   it('still pronounces pending and completed words when a coach reveals them', async () => {
     user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
     session.groupId.set(3);
@@ -976,11 +1018,10 @@ describe('home learning modules', () => {
     harness.fixture.detectChanges();
     const spellingActions = harness.routeNativeElement?.querySelector('.teacher-spelling-actions') as HTMLElement;
     expect(spellingActions.querySelectorAll('button').length).toBe(3);
-    expect(spellingActions.textContent).toContain('跳过并完成');
+    expect(spellingActions.textContent).toContain('跳到下一关');
+    expect((harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent).words().length).toBe(1);
+    expect(localStorage.getItem('xiaoshu.learning.300.100.701._page_word_orilist')).not.toBeNull();
     (spellingActions.querySelectorAll('button')[1] as HTMLButtonElement).click();
-    harness.fixture.detectChanges();
-    expect(harness.routeNativeElement?.textContent).toContain('拼写完成');
-    (harness.routeNativeElement?.querySelector('.compact-report .primary-button') as HTMLButtonElement).click();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     expect(router.url).toBe('/pages/his/his');
@@ -1191,12 +1232,8 @@ describe('home learning modules', () => {
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     expect(router.url).toContain('/pages/stu/pl_stu5');
-    learning().skipSpellingWord();
-    harness.fixture.detectChanges();
-    expect(harness.routeNativeElement?.querySelector('.compact-report')?.textContent).toContain('本次所选单词已全部完成');
-    expect(harness.routeNativeElement?.querySelector('.compact-report button')?.textContent).toContain('查看学习结果');
+    learning().skipSpellingStage();
     expect(learning().hasNextWordBatch).toBeFalse();
-    learning().continueAfterStage();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     harness.fixture.detectChanges();
@@ -1222,7 +1259,7 @@ describe('home learning modules', () => {
     user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
     session.groupId.set(3);
     session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
-    const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }], [{ GeneralID: 1002, Title: 'banana', sy: '香蕉' }]];
+    const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }, { GeneralID: 1002, Title: 'banana', sy: '香蕉' }], [{ GeneralID: 1003, Title: 'cherry', sy: '樱桃' }]];
     localStorage.setItem('xiaoshu.learning.300.100.901._page_word_orilist', JSON.stringify(pages));
     localStorage.setItem('xiaoshu.learning.300.100.901._page_total_page', '0');
     localStorage.setItem('xiaoshu.learning.300.100.901._page_current_learn', '1');
@@ -1232,24 +1269,56 @@ describe('home learning modules', () => {
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
-    component.skipSpellingWord();
-    harness.fixture.detectChanges();
-    expect(component.stageActionLabel).toBe('学习下一组单词');
-    component.continueAfterStage();
-    component.continueAfterStage();
+    expect(component.words().length).toBe(2);
+    component.skipSpellingStage();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     expect(router.url).toContain('/pages/stu/stu_con');
     const next = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
-    expect(next.words()[0].word).toBe('banana');
+    expect(next.words()[0].word).toBe('cherry');
     expect((TestBed.inject(ApiService) as unknown as LearningModulesApiService).studyUpdates.length).toBe(0);
     (harness.routeNativeElement?.querySelector('.app-header button[aria-label="返回"]') as HTMLButtonElement).click();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     expect(router.url).toBe('/pages/shop/get_order?id=901');
-    for (const key of ['_page_word_orilist', '_page_total_page', '_page_current_learn']) localStorage.removeItem(`xiaoshu.learning.300.100.901.${key}`);
+    for (const key of ['_page_word_orilist', '_page_total_page', '_page_current_learn', 'skip_spelling_stage']) localStorage.removeItem(`xiaoshu.learning.300.100.901.${key}`);
   });
 
+  it('keeps teacher-led spelling skipped through the remaining word groups', async () => {
+    user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
+    session.groupId.set(3);
+    session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
+    const prefix = 'xiaoshu.learning.300.100.901.';
+    const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }], [{ GeneralID: 1002, Title: 'banana', sy: '香蕉' }]];
+    localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify(pages));
+    localStorage.setItem(prefix + '_page_current_learn', '1');
+    const harness = await RouterTestingHarness.create();
+    const router = TestBed.inject(Router);
+    await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&orderId=901');
+    const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    learning().skipSpellingStage();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(router.url).toContain('/pages/stu/stu_con');
+    expect(localStorage.getItem(prefix + 'skip_spelling_stage')).toBe('true');
+    learning().skipStudyList();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    learning().chooseMatch('left', learning().words()[0]);
+    learning().chooseMatch('right', learning().words()[0]);
+    learning().continueAfterStage();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(router.url).toContain('/pages/stu/stu4');
+    learning().chooseMeaning(learning().words()[0]);
+    expect(learning().stageActionLabel).toBe('查看学习结果');
+    learning().continueAfterStage();
+    await harness.fixture.whenStable();
+    expect(router.url).toContain('/pages/stu/pl_end');
+    expect(localStorage.getItem(prefix + 'skip_spelling_stage')).toBe('true');
+    localStorage.removeItem(prefix + 'skip_spelling_stage');
+  }, 15000);
+
   it('resolves the appointment student before reading end results and offers an exit on failure', async () => {
     user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
     session.groupId.set(3);

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

@@ -283,9 +283,9 @@
             }
           </div>
         </article>
-        <div class="sticky-actions" [class.teacher-spelling-actions]="meta.path === 'pages/stu/pl_stu5'">
+        <div class="sticky-actions" [class.teacher-spelling-actions]="meta.path === 'pages/stu/pl_stu5'" [class.skippable-spelling-actions]="canSkipSpellingStage" [style.grid-template-columns]="canSkipSpellingStage ? 'repeat(3,minmax(0,1fr))' : null">
           <button type="button" class="secondary-button" (click)="resetSpelling()"><lucide-icon [img]="icons.RotateCcw" [size]="18" />重拼</button>
-          @if (meta.path === 'pages/stu/pl_stu5') { <button type="button" class="secondary-button" (click)="skipSpellingWord()">{{ currentIndex() === words().length - 1 ? '跳过并完成' : '跳过此词' }}</button> }
+          @if (canSkipSpellingStage) { <button type="button" class="secondary-button" (click)="skipSpellingStage()">跳到下一关</button> }
           <button type="button" class="primary-button" [disabled]="!spellingCorrect()" (click)="nextSpellingWord()">{{ currentIndex() === words().length - 1 ? '完成本关' : '下一个' }}<lucide-icon [img]="icons.ChevronRight" [size]="18" /></button>
         </div>
       }

+ 27 - 15
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -45,6 +45,7 @@ import { isLearningStage, learningReturnUrl } from '../../core/learning-navigati
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
 import { LiveDataRefreshService } from '../../core/live-data-refresh.service';
+import { nextReviewWordOrder } from '../../core/review-word-order';
 
 type SourceItem = Record<string, unknown>;
 
@@ -361,6 +362,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return ['pages/stu/stu5', 'pages/stu/pl_stu5'].includes(this.meta.path);
   }
 
+  get canSkipSpellingStage(): boolean {
+    return this.meta.path === 'pages/stu/pl_stu5'
+      || (this.meta.path === 'pages/stu/stu5' && this.isNewWordSelfStudyStage);
+  }
+
   get hasNextWordBatch(): boolean {
     const currentPage = Number(this.storageGet('_page_current_learn') ?? 1);
     const pages = this.parseArray(this.storageGet('_page_word_orilist'));
@@ -370,11 +376,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   get stageActionLabel(): string {
     if (this.isMatchingPage) return '进入听音识意';
-    if (this.isListeningPage) return '进入组合拼写';
+    if (this.isListeningPage && this.storageGet('skip_spelling_stage') !== true) return '进入组合拼写';
     if (this.hasNextWordBatch) return '学习下一组单词';
     const mode = String(this.storageGet('learn_mode') ?? '');
     if (mode === 'newword' || mode === 'history_word') return '完成学习并返回';
-    return this.meta.path === 'pages/stu/pl_stu5' && !this.query('orderId') ? '保存并结束学习' : '查看学习结果';
+    return this.canStartPointReading && !this.query('orderId') ? '保存并结束学习' : '查看学习结果';
   }
 
   get canReturnToLearningEntry(): boolean {
@@ -772,15 +778,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.play();
   }
 
-  skipSpellingWord(): void {
-    if (this.meta.path !== 'pages/stu/pl_stu5') return;
-    if (this.currentIndex() >= this.words().length - 1) {
-      this.completed.set(true);
-      return;
-    }
-    this.currentIndex.update((value) => value + 1);
-    this.prepareSpelling();
-    setTimeout(() => this.play(), 120);
+  skipSpellingStage(): void {
+    if (!this.canSkipSpellingStage || this.completed() || this.submitting()) return;
+    this.storageSet('skip_spelling_stage', true);
+    this.completed.set(true);
+    this.continueAfterStage();
   }
 
   continueAfterStage(): void {
@@ -790,8 +792,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       this.navigateStage('/pages/stu/stu4');
     } else if (this.isListeningPage) {
       this.completed.set(false);
-      const path = Number(this.session.user()?.groupId) === 3 ? '/pages/stu/pl_stu5' : '/pages/stu/stu5';
-      this.navigateStage(path);
+      if (this.storageGet('skip_spelling_stage') === true) this.finishSpellingFlow();
+      else this.navigateStage(this.canStartPointReading ? '/pages/stu/pl_stu5' : '/pages/stu/stu5');
     } else if (this.isSpellingPage) {
       this.finishSpellingFlow();
     }
@@ -1301,7 +1303,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       this.completed.set(false);
       this.clearLearningStorage();
       void this.router.navigateByUrl('/pages/his/his', { replaceUrl: true });
-    } else if (this.meta.path === 'pages/stu/pl_stu5') {
+    } else if (this.meta.path === 'pages/stu/pl_stu5' || (this.isListeningPage && this.canStartPointReading && this.storageGet('skip_spelling_stage') === true)) {
       if (this.query('orderId')) {
         this.createPracticeLearningRecord();
       } else {
@@ -1554,7 +1556,17 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private loadRecord(): void {
     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);
+        const key = `xiaoshu.review.order.${this.session.student()?.userId ?? this.session.user()?.userId}.${this.query('id')}`;
+        let previous: string[][] = [];
+        try {
+          const stored: unknown = JSON.parse(localStorage.getItem(key) ?? '[]');
+          if (Array.isArray(stored)) previous = stored.filter((order): order is string[] =>
+            Array.isArray(order) && order.every((id) => typeof id === 'string'));
+        } catch { /* A damaged or unavailable local cache should not block review. */ }
+        const ordered = nextReviewWordOrder(words, previous, (word) => word.id);
+        this.words.set(ordered.words);
+        try { localStorage.setItem(key, JSON.stringify(ordered.history)); } catch { /* Review still works without local storage. */ }
         this.orderInfo.set(this.findList(addon)[0] ?? {});
       },
       error: (error: ApiError) => this.error.set(error.message),