فهرست منبع

feat(learning): hide third-tap answers and add bulk word quantities

彭峰 18 ساعت پیش
والد
کامیت
6b399910a2

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

@@ -2402,12 +2402,12 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成');expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).not.toMatch(/收入|费用|计薪|¥/);expect(component.canDeactivate()).toBeTrue();
   });
 
-  async function teacherSelection(count: number, pass = 0) {
+  async function teacherSelection(count: number, pass = 0, orderId = '') {
     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=Array.from({length:count},(_,i)=>({GeneralID:1001+i,NodeID:811,Title:'word-'+(i+1),sy:'释义 '+(i+1),w_learned:pass}));
     const harness=await RouterTestingHarness.create();
-    await harness.navigateByUrl('/pages/stu/stu_abc?nids=811&pnid=801');
+    await harness.navigateByUrl('/pages/stu/stu_abc?nids=811&pnid=801' + (orderId ? '&orderId=' + orderId : ''));
     await harness.fixture.whenStable(); harness.fixture.detectChanges();
     const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
     if(pass>0){component.selectCoursePass(pass);await harness.fixture.whenStable();harness.fixture.detectChanges();}
@@ -2473,7 +2473,7 @@ describe('home learning modules', () => {
     const select=view().querySelector('.bulk-select-toggle') as HTMLButtonElement;
     expect(select).not.toBeNull();select?.click();harness.fixture.detectChanges();
     expect(view().querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(8);
-    expect(component.selected().size).toBe(0); // Temporary bulk checks are independent of the 30-word study round.
+    expect(component.selected().size).toBe(0); // Temporary bulk checks do not change the actual learning selection.
     const checks=view().querySelectorAll<HTMLButtonElement>('.word-select-check');checks[1].click();checks[6].click();harness.fixture.detectChanges();
     (view().querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click();
     await harness.fixture.whenStable();harness.fixture.detectChanges();
@@ -2495,13 +2495,15 @@ describe('home learning modules', () => {
   });
 
   it('keeps bulk unknown checks intact when they exceed a round and clears them across pass switches', async () => {
-    const {api,harness,component}=await teacherSelection(31);
+    const {api,harness,component}=await teacherSelection(101);
+    component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges();
+    component.toggleWord(component.words()[100]);
     const toast=spyOn(TestBed.inject(PlatformService),'toast');
     (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
     (harness.routeNativeElement!.querySelector('.bulk-assessment .unknown-button') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
-    expect(toast).toHaveBeenCalledWith(jasmine.stringMatching(/30/),'error');
-    expect(harness.routeNativeElement!.querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(31);
-    expect(component.selected().size).toBe(0);expect(api.courseIncrements.size).toBe(0);
+    expect(toast).toHaveBeenCalledWith(jasmine.stringMatching(/100/),'error');
+    expect(harness.routeNativeElement!.querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(100);
+    expect(component.selected().size).toBe(1);expect(api.courseIncrements.size).toBe(0);
     component.selectCoursePass(1);await harness.fixture.whenStable();harness.fixture.detectChanges();
     expect(harness.routeNativeElement!.querySelector('.bulk-assessment')).toBeNull();
     component.selectCoursePass(0);await harness.fixture.whenStable();harness.fixture.detectChanges();
@@ -2537,6 +2539,8 @@ describe('home learning modules', () => {
     }) as typeof api.post);
     const first=component.words()[0];
     (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges();
+    for (const word of component.bulkCandidates().slice(100)) component.toggleWord(word);
+    harness.fixture.detectChanges();
     (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click();
     await harness.fixture.whenStable();harness.fixture.detectChanges();
     expect(harness.routeNativeElement!.querySelector('.selection-retry-note')?.textContent).toContain('尚待确认');
@@ -2613,6 +2617,142 @@ describe('home learning modules', () => {
     });
   }
 
+  it('marks only the requested 20 course words known without learning the other 80', async () => {
+    const {api,harness,component} = await teacherSelection(100);
+    spyOn(TestBed.inject(PlatformService), 'toast');
+    const picker = harness.routeNativeElement!.querySelector<HTMLSelectElement>('.bulk-select-size')!;
+    picker.value='20';picker.dispatchEvent(new Event('change'));harness.fixture.detectChanges();
+    (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges();
+    (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click();
+    await harness.fixture.whenStable();harness.fixture.detectChanges();
+    expect(api.courseIncrements.size).toBe(20);expect(component.selected().size).toBe(0);
+    expect(component.coursePassCounts()).toEqual([80,20,0,0]);
+    expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
+  });
+
+  it('preserves individual wordbook choices when a quantity selection request fails', async () => {
+    spyOn(TestBed.inject(PlatformService), 'toast');
+    const api=TestBed.inject(ApiService), original=api.get.bind(api), pending=new Subject<ApiEnvelope<unknown>>();
+    let reads=0;
+    spyOn(api,'get').and.callFake(((action:string,params:Record<string,unknown>={})=>{
+      if(action!=='e_words_list')return original(action,params);
+      if(reads++)return pending;
+      return of({retcode:0,result:[{GeneralID:601,detail:[{GeneralID:701,Title:'apple',sy:'苹果'}]}],page:{itemCount:1,pendingCount:1,completedCount:0}});
+    }) as typeof api.get);
+    const harness=await RouterTestingHarness.create('/pages/member/starshop');
+    await harness.fixture.whenStable();harness.fixture.detectChanges();
+    const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    (harness.routeNativeElement!.querySelector('.word-select-list > button') as HTMLButtonElement).click();harness.fixture.detectChanges();
+    (harness.routeNativeElement!.querySelector('.selection-header .quiet-button') as HTMLButtonElement).click();harness.fixture.detectChanges();
+    expect(component.selectionLoading()).toBeTrue();
+    pending.error(new Error('temporary failure'));harness.fixture.detectChanges();
+    expect([...component.selected()]).toEqual(['701']);expect(component.selectionLoading()).toBeFalse();
+  });
+
+  for (const entry of ['course-0', 'course-1', 'course-2', 'coach-wordbook', 'student-wordbook']) for (const startCount of [20,50,100]) {
+    it(`selects only the requested quantity and starts ${startCount} words for ${entry}`, async () => {
+      spyOn(TestBed.inject(PlatformService), 'toast');
+      let harness: RouterTestingHarness;
+      if (entry.startsWith('course-')) {
+        harness = (await teacherSelection(100, Number(entry.slice(-1)), entry === 'course-0' ? '901' : '')).harness;
+      } else {
+        if (entry === 'coach-wordbook') {
+          user.set({userId:300,groupId:3});session.groupId.set(3);
+        }
+        const api = TestBed.inject(ApiService), original = api.get.bind(api);
+        spyOn(api, 'get').and.callFake(((action: string, params: Record<string, unknown> = {}) => {
+          if (action === 'e_user_list') return of({retcode:0,result:[{userId:100,honeyName:'学员甲'}]});
+          if (action === 'e_words_list') {
+            const all = Array.from({length:100}, (_,i) => ({GeneralID:601+i,detail:[{GeneralID:1001+i,Title:'word-'+i,sy:'释义 '+i}]}));
+            const size = Number(params['psize']), page = Number(params['cpage']);
+            return of({retcode:0,result:all.slice((page-1)*size,page*size),page:{itemCount:100,pendingCount:100,completedCount:0,pageCount:Math.ceil(100/size)}});
+          }
+          return original(action,params);
+        }) as typeof api.get);
+        harness = await RouterTestingHarness.create('/pages/member/starshop');
+        await harness.fixture.whenStable();harness.fixture.detectChanges();
+      }
+      const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+      const picker = harness.routeNativeElement!.querySelector<HTMLSelectElement>('.bulk-select-size');
+      expect(picker).withContext('every editable selection entry has a quantity picker').not.toBeNull();
+      if (!picker) return;
+      expect(Array.from(picker.options).map(option => option.value)).toEqual(['20','50','100']);
+      expect(picker.value).toBe('100');
+      const clickAll = () => {
+        (harness.routeNativeElement!.querySelector('.bulk-select-toggle, .quiet-button') as HTMLButtonElement).click();
+        harness.fixture.detectChanges();
+      };
+      for (const count of [20,50,100]) {
+        picker.value = String(count);picker.dispatchEvent(new Event('change'));harness.fixture.detectChanges();
+        clickAll();
+        expect((component.bulkSelection() ?? component.selected()).size).toBe(count);
+        if (count < 100) {clickAll();expect((component.bulkSelection() ?? component.selected()).size).toBe(0);}
+      }
+      if (startCount !== 100) {
+        clickAll();picker.value = String(startCount);picker.dispatchEvent(new Event('change'));harness.fixture.detectChanges();clickAll();
+      }
+      const button = entry.startsWith('course-') ? '.bulk-assessment .unknown-button' : '.selection-header .primary-button';
+      (harness.routeNativeElement!.querySelector(button) as HTMLButtonElement).click();
+      await harness.fixture.whenStable();harness.fixture.detectChanges();
+      expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con');
+      const pages = JSON.parse(localStorage.getItem(entry === 'coach-wordbook'
+        ? 'xiaoshu.learning.300.100.wordbook._page_word_orilist'
+        : entry === 'student-wordbook' ? 'xiaoshu.learning.100.100.practice._page_word_orilist'
+        : entry === 'course-0' ? 'xiaoshu.learning.300.100.901.unbound._page_word_orilist'
+        : 'xiaoshu.learning.300.100.practice._page_word_orilist') || '[]');
+      expect(pages.flat().length).toBe(startCount);
+    });
+  }
+
+  for (const entry of ['course', 'coach-wordbook', 'student-wordbook'] as const) {
+    it(`hides the answer on the third point-reading tap in every round for ${entry}`, async () => {
+      let harness: RouterTestingHarness;
+      const platform = TestBed.inject(PlatformService);
+      spyOn(platform, 'toast');
+      if (entry === 'course') {
+        const selection = await teacherSelection(2,0,'901');
+        harness = selection.harness;
+        selection.component.selected.set(new Set(selection.component.words().map(word => word.id)));
+        selection.component.startSelected();
+      } else {
+        if (entry === 'coach-wordbook') {
+          user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1});
+        }
+        const api = TestBed.inject(ApiService), original = api.get.bind(api);
+        spyOn(api, 'get').and.callFake(((action: string, params: Record<string, unknown> = {}) => {
+          if (action === 'e_user_list') return of({retcode:0,result:[{userId:100,honeyName:'学员甲'}]});
+          if (action === 'e_words_list') return of({retcode:0,result:[{GeneralID:601,detail:[{GeneralID:701,Title:'apple',sy:'苹果',yb:'/ˈæpəl/'}]},{GeneralID:602,detail:[{GeneralID:702,Title:'banana',sy:'香蕉',yb:'/bəˈnɑːnə/'}]}]});
+          return original(action,params);
+        }) as typeof api.get);
+        harness = await RouterTestingHarness.create('/pages/member/starshop' + (entry === 'coach-wordbook' ? '?wordbookStudentId=100' : ''));
+        await harness.fixture.whenStable();harness.fixture.detectChanges();
+        for (const button of Array.from(harness.routeNativeElement!.querySelectorAll<HTMLButtonElement>('.word-select-list > button'))) button.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');
+      const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+      expect(component.words().length).toBe(2);
+      component.words.update(words => words.map(word => ({...word,phonetic:word.phonetic || '/wɜːd/'})));
+      spyOn(component,'play').and.resolveTo();
+      for (const step of [1,2,3] as const) {
+        expect(component.studyStep()).toBe(step);harness.fixture.detectChanges();
+        const first = component.words()[0], row = harness.routeNativeElement!.querySelector<HTMLButtonElement>('.repeat-list > button')!;
+        const answer = () => row.querySelector('small:not(.study-phonetic)');
+        expect(row.querySelector('strong')?.textContent).toBe(step===3 ? first.meaning : first.word);
+        expect(answer()).toBeNull();
+        row.click();harness.fixture.detectChanges();expect(answer()).toBeNull();
+        if(step!==3)expect(row.querySelector('.study-phonetic')?.textContent).toBe(first.phonetic);
+        row.click();harness.fixture.detectChanges();expect(answer()?.textContent).toBe(step===3 ? first.word : first.meaning);
+        row.click();harness.fixture.detectChanges();expect(answer()).toBeNull();expect(row.querySelectorAll('.repeat-dots .done').length).toBe(3);
+        if(step!==3)expect(row.querySelector('.study-phonetic')?.textContent).toBe(first.phonetic);
+        row.click();harness.fixture.detectChanges();expect(answer()).toBeNull();expect(component.repetitions()[first.id]).toBe(3);
+        if(step<3){for(const [index,word] of component.words().entries())if(index>0)for(let tap=0;tap<3;tap++)component.repeatWord(word,index);}
+      }
+    });
+  }
+
   it('uses different save identities for consecutive learned passes and leaves the third pass read-only', async () => {
     const {api,harness,component}=await teacherSelection(2,1);
     spyOn(TestBed.inject(PlatformService),'toast'); // Two consecutive saves test state, not toast dismissal timers.

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

@@ -141,7 +141,14 @@
         }
       </div>
       <div class="header-actions">
-        @if (!isRefineSelectionPage) { <button type="button" class="quiet-button" [disabled]="isNewWordSelectionPage && !visibleNewWords().length" (click)="toggleAllWords()">{{ isNewWordSelectionPage ? (allVisibleNewWordsSelected() ? '取消本栏' : '全选本栏') : (selected().size ? '取消全选' : '全选') }}</button> }
+        @if (!isReadOnlySelectionPage && (!isRefineSelectionPage || coursePass() < 3)) {
+          <label class="bulk-size-label">全选数量
+            <select class="bulk-select-size" [value]="bulkSelectCount()" [disabled]="selectionLocked() || selectionLoading()" (change)="changeBulkSelectCount(+$any($event.target).value)">
+              <option value="20">20 个</option><option value="50">50 个</option><option value="100">100 个</option>
+            </select>
+          </label>
+        }
+        @if (!isRefineSelectionPage) { <button type="button" class="quiet-button" [disabled]="selectionLocked() || selectionLoading() || (isNewWordSelectionPage && !visibleNewWords().length)" (click)="toggleAllWords()">{{ isNewWordSelectionPage ? (allVisibleNewWordsSelected() ? '取消全选' : '全选 ' + bulkSelectCount() + ' 个') : (selected().size ? '取消全选' : '全选 ' + bulkSelectCount() + ' 个') }}</button> }
         @if (!isReadOnlySelectionPage) {
           <button type="button" class="primary-button" [disabled]="submitting() || selectionLoading() || (isCoachWordbookSelection && !selected().size)" (click)="startSelected()">
             <lucide-icon [img]="icons.Play" [size]="17" />{{ isRefineSelectionPage ? selectionActionLabel() : isCoachWordbookSelection ? '开始带练' : '开始学习' }}
@@ -185,7 +192,7 @@
           <p class="word-column-help">{{ coursePassHint }}</p>
           @if (!isReadOnlySelectionPage && coursePass() < 3) {
             <div class="course-bulk-toolbar">
-              <button type="button" class="bulk-select-toggle" [disabled]="selectionLocked() || selectionLoading() || !bulkCandidates().length" [attr.aria-pressed]="bulkSelection() !== null" (click)="toggleBulkSelection()">{{ bulkSelection() !== null ? '取消全选' : '全选本页' }}</button>
+              <button type="button" class="bulk-select-toggle" [disabled]="selectionLocked() || selectionLoading() || !bulkCandidates().length" [attr.aria-pressed]="bulkSelection() !== null" (click)="toggleBulkSelection()">{{ bulkSelection() !== null ? '取消全选' : '全选 ' + bulkSelectCount() + ' 个' }}</button>
               @if (bulkSelection(); as checked) {
                 <span class="bulk-count" role="status">已勾选 <b>{{ checked.size }}</b> 个</span>
                 <div class="bulk-assessment" role="group" aria-label="批量判断单词">
@@ -268,7 +275,7 @@
           <span>
             <strong [class.study-headword]="studyStep() !== 3">{{ studyStep() === 3 ? word.meaning : word.word }}</strong>
             @if (studyStep() !== 3 && repeatCount >= 1 && word.phonetic) { <small class="study-phonetic">{{ word.phonetic }}</small> }
-            @if (repeatCount >= 2) { <small>{{ studyStep() === 3 ? word.word : word.meaning }}</small> }
+            @if (repeatCount === 2) { <small>{{ studyStep() === 3 ? word.word : word.meaning }}</small> }
           </span>
           <span class="repeat-dots" aria-label="跟读次数">
             @for (dot of [1, 2, 3]; track dot) { <i [class.done]="repeatCount >= dot"></i> }

+ 47 - 16
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -142,6 +142,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly selectionRevealed = signal<Set<string>>(new Set());
   readonly selectionAssessment = signal<Record<string, 'known' | 'unknown'>>({});
   readonly bulkSelection = signal<Set<string> | null>(null);
+  readonly bulkSelectCount = signal(100);
+  private readonly newWordBulkSelection = signal<{bucket: string; count: number; ids: string[]} | null>(null);
   readonly knownSaveRetry = signal(false);
   private knownSaveContinuation: { continueLearning: boolean; openKnownPass: boolean } | null = null;
   private courseSelectionNeedsRebase = false;
@@ -280,7 +282,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly unlearnedNewWords = computed(() => this.words().filter((word) => !this.newWordLearned(word)));
   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 allVisibleNewWordsSelected = computed(() => {
+    const bulk = this.newWordBulkSelection();
+    return Boolean(bulk && bulk.bucket === this.activeSelectionBucket() && bulk.count === this.bulkSelectCount()
+      && bulk.ids.length && bulk.ids.every(id => this.selected().has(id)));
+  });
   readonly newlyKnownWords = computed(() => (this.isRefineSelectionPage ? this.courseSelectionCache() : this.words()).filter((word) => this.selectionAssessment()[word.id] === 'known'));
   readonly selectionActionLabel = computed(() => {
     if (this.knownSaveRetry()) return '重试保存';
@@ -558,8 +564,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   get selectionLimit(): number {
-    if (this.meta.path === 'pages/stu/stu_abc') return 30;
-    if (this.isNewWordSelectionPage) return 100;
+    if (this.isRefineSelectionPage || this.isNewWordSelectionPage) return 100;
     return this.words().length;
   }
 
@@ -806,7 +811,13 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   toggleBulkSelection(): void {
     if (this.selectionLocked() || this.selectionLoading() || this.isReadOnlySelectionPage || this.coursePass() >= 3) return;
-    this.bulkSelection.set(this.bulkSelection() === null ? new Set(this.bulkCandidates().map(word => word.id)) : null);
+    this.bulkSelection.set(this.bulkSelection() === null ? new Set(this.bulkCandidates().slice(0, this.bulkSelectCount()).map(word => word.id)) : null);
+  }
+
+  changeBulkSelectCount(count: number): void {
+    if (this.selectionLocked() || this.selectionLoading() || ![20, 50, 100].includes(count)) return;
+    this.bulkSelectCount.set(count);
+    if (this.bulkSelection() !== null) this.bulkSelection.set(null);
   }
 
   applyBulkAssessment(assessment: 'known' | 'unknown'): void {
@@ -830,31 +841,50 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const remaining = candidates.filter(word => !checked.has(word.id));
     this.bulkSelection.set(null);
     for (const word of chosen) this.markWordKnown(word);
-    const canContinue = new Set([...this.selected(), ...remaining.map(word => word.id)]).size <= this.selectionLimit;
+    const canContinue = remaining.length === 0 || (candidates.length <= this.bulkSelectCount()
+      && new Set([...this.selected(), ...remaining.map(word => word.id)]).size <= Math.min(30, this.bulkSelectCount()));
     if (canContinue) for (const word of remaining) this.markWordUnknown(word);
     this.startSelected(canContinue, canContinue && this.selected().size === 0);
   }
 
   toggleAllWords(): void {
+    if (this.selectionLocked() || this.selectionLoading() || this.isReadOnlySelectionPage) return;
     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 if (next.size < this.selectionLimit) next.add(word.id);
-      }
-      if (!clearVisible && next.size === this.selectionLimit && visible.some((word) => !next.has(word.id))) {
-        this.platform.toast(`本次最多选择 ${this.selectionLimit} 个单词`, 'error');
+      const previous = this.newWordBulkSelection(), bucket = this.activeSelectionBucket(), count = this.bulkSelectCount();
+      if (this.allVisibleNewWordsSelected() && previous) {
+        this.selected.update(current => new Set([...current].filter(id => !previous.ids.includes(id))));
+        this.newWordBulkSelection.set(null);
+        return;
       }
-      this.selected.set(next);
+      const uid = this.isCoachWordbookSelection ? this.wordbookStudentId() : this.session.user()?.userId;
+      if (!uid) return;
+      const source = this.legacyNewWords
+        ? of(this.legacyNewWords.filter(word => this.newWordLearned(word) === (bucket === 'completed')).slice(0, count))
+        : this.api.get<unknown>('e_words_list', {uid, cpage: 1, psize: count, learningStatus: bucket})
+          .pipe(map(({result}) => this.findList(result).map(item => this.toNewWord(item)).filter(word => word.word).slice(0, count)));
+      this.selectionLoading.set(true);
+      this.selectionRequest = source.pipe(finalize(() => this.selectionLoading.set(false))).subscribe({
+        next: words => {
+          const next = new Set(this.selected());
+          if (previous?.bucket === bucket) for (const id of previous.ids) next.delete(id);
+          const ids: string[] = [];
+          for (const word of words) {
+            if (!next.has(word.id) && next.size >= this.selectionLimit) continue;
+            next.add(word.id);ids.push(word.id);this.newWordSelectionCache.set(word.id, word);
+          }
+          this.selected.set(next);
+          this.newWordBulkSelection.set({bucket, count, ids});
+          if (ids.length < words.length) this.platform.toast(`本次最多选择 ${this.selectionLimit} 个单词`, 'error');
+        },
+        error: () => this.platform.toast('选词失败,请重试', 'error'),
+      });
       return;
     }
     if (this.selected().size) {
       this.selected.set(new Set());
       return;
     }
-    this.selected.set(new Set(this.words().slice(0, this.selectionLimit).map((word) => word.id)));
+    this.selected.set(new Set(this.words().slice(0, Math.min(this.bulkSelectCount(), this.selectionLimit)).map((word) => word.id)));
   }
 
   startSelected(continueLearning = true, openKnownPass = false): void {
@@ -2412,6 +2442,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.calendarError.set('');
     this.selected.set(new Set());
     this.newWordSelectionCache.clear();
+    this.newWordBulkSelection.set(null);
     this.newWordCounts.set({ pending: 0, completed: 0 });
     this.legacyNewWords = null;
     this.selectionRevealed.set(new Set());

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

@@ -442,6 +442,8 @@ app-learning-page .memory-student-filter label { display: flex; min-width: 0; fl
 app-learning-page .memory-student-filter span { color: var(--ink-soft); font-size: 11px; }
 app-learning-page .memory-student-filter strong { color: var(--ink); font-size: 18px; }
 app-learning-page .memory-student-filter select { min-height: 42px; padding-block: 7px; }
+app-learning-page .bulk-size-label { font-size: 12px; white-space: nowrap; }
+app-learning-page .bulk-select-size { width: auto; }
 app-learning-page .teacher-spelling-actions { grid-template-columns: repeat(3, minmax(0, 1fr)); }
 
 @media (max-width: 520px) {