Kaynağa Gözat

fix(learning): support bulk word assessment and reliable batched saving

彭峰 8 saat önce
ebeveyn
işleme
f71d4336a2

+ 24 - 0
docs/qa/teacher-word-selection-2026-11-02.md

@@ -0,0 +1,24 @@
+# 2026-11-02 老师选词操作优化(未上线)
+
+## 问题与修改
+
+- “单次最多操作 100 个单词”来自 `e_add_words` 后端的请求条数限制。选词页累计标记为认识的词可能超过 100,原先全部一次提交导致报错。客户端现按最多 100 个顺序分批提交,兼容当前线上接口,无需扩大后端写入上限。
+- 每批使用稳定的请求键,成功一批即更新本地进度;保存失败或响应不确定时,保留识词判断并提供“重试保存”,完成原批次核对前暂停修改。全部保存成功后才进入后续训练,避免重试重复增加学习遍数。
+- 未学习单词右侧常驻紧凑的“认识”“不认识”按钮,不依赖展开释义。保留学生只读限制与历史已学遍数规则。
+- 增加“全选本页”和批量判断条。临时勾选与本轮待学选择独立,只作用于当前已加载、展示的未学习词;可以取消个别勾选,也可直接点单词的“不认识”作为例外。
+- 批量认识保存后,未勾选词保留待学;不超过本轮容量时自动进入这些词的训练。全部认识时转到已学一遍栏。原有每轮最多 30 个待学词规则保持不变;超出容量时不会截断勾选或丢弃单词。
+- 切换栏位或加载更多会清除临时批量勾选。保存后待学集合缩短时重新校正加载偏移,保留待学选择,避免漏过后面的词;校正失败仍重试第一页。
+
+## 验证
+
+- 原代码在新增的 7 项回归上失败,初步修改后通过 61 项选词相关集成测试;复查新增边界用例先复现 3 处问题,再修复通过。
+- 最终全量前端测试:移动端 327 项、管理端 348 项,共 675 项通过。
+- 移动端生产构建通过,既有样式体积和 CommonJS 警告仍存在。
+- 覆盖 205 个已认识词按 100/100/5 保存、第二批失败重试、服务端已提交但响应丢失的幂等重试、重试前冻结判断并保留后续动作、分页重排及校正失败、常驻按钮、批量取消例外、全部认识、批量不认识与本轮容量限制、切栏及保存中防重入。
+- 隔离接口浏览器检查:1024px、375px、320px 无横向溢出、长单词不覆盖判断按钮;全选后排除一个词,已勾选词保存为认识,只训练被排除的词;205 个词保存请求为 100/100/5,未重复计数,页面无运行时异常。
+
+浏览器验证使用测试账号和隔离接口,未读写正式业务数据,不代表微信实机验收。可复查的截图、脚本及 `verification.json` 位于本机 `~/.local/state/xiaoshu/qa/2026-11-02-teacher-word-selection/`。
+
+## 发布状态
+
+仅提交、推送本次客户端修改、测试及记录,未部署前端或后端。此前待发布的报表字号和老师学员复习期筛选亦未随本任务上线。

+ 39 - 0
docs/superpowers/plans/2026-11-02-teacher-word-selection.md

@@ -0,0 +1,39 @@
+# 老师选词操作 Implementation Plan
+
+**Goal:** 单词判断按钮常驻,支持全选本页后取消个别词再批量判断,累计识词超过 100 个仍可保存。
+
+**Architecture:** 沿用 Angular 选词页、学习轮次和后端原子写入接口。批量勾选独立于本轮待学选择;保存按 100 个顺序分批,每批沿用稳定 requestKey,成功后立即更新本地进度,失败时留在页面重试。保持本轮待学 30 个的现有规则。
+
+**Tech Stack:** Angular 20、signals、RxJS、Karma/ChromeHeadless。
+
+**Spec:** 用户本次三项截图要求及本文件的交互规则。
+
+## 交互规则
+
+- 未学习单词右侧一直显示紧凑的“认识”“不认识”;中文仍可点击展开,判断不依赖展开。
+- “全选本页”只勾选当前展示栏已加载的未学习词;不包括未加载、隐藏遍数栏或其他课程。勾选可逐个取消,展示批量判断条,支持取消操作。
+- 批量“认识”:勾选词标记认识,未勾选词保留待学;若未勾选词加已有待学不超过 30,自动保存并进入这些待学词的后续训练。全部认识且没有待学词时保存后显示已学一遍栏。
+- 若剩余待学超过本轮 30 个,先保存认识结果并留在页面,由老师选择本轮待学词。批量“不认识”超过本轮容量时保留勾选并提示缩小范围,不截断、不默默丢词。
+- 切换遍数栏、加载更多或重新进入页面清除临时批量勾选,防止误作用于别栏。保存过程中禁止再次判断、重复提交或切换栏位。
+- 无需调整后端 100 个请求上限:仅提高上限仍存在下一道阈值,且放大单次写入负担;客户端自动分批解决累计数超过 100 的问题,同时兼容当前线上接口。
+
+## Task 1: 识词保存支持自动分批及重试
+
+- [x] 在 `projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts` 添加 205 个已认识词保存、第二批失败后重试用例,确认旧实现失败。
+- [x] 修改 `learning-page.component.ts` 的 `persistWordsAsLearned`,顺序提交至多 100 个词;每批稳定 requestKey;已成功批次立即提交识词状态,全部成功后才开始训练。
+- [x] 验证 205 个词分为 100/100/5;失败不导航、不重复计数,重试完成后正确显示进度。
+
+## Task 2: 常驻判断与本页批量判断
+
+- [x] 添加未展开时两个按钮可操作、全选后取消单词、批量认识后进入剩余待学词训练、全认识切至已学栏、批量不认识容量与跨栏清除勾选的回归测试。
+- [x] 修改 `learning-page.component.ts`、`.html` 和 `projects/xiaoshu-mobile/src/styles.scss`,实现上述交互;保留学生只读限制。
+- [x] 运行相关集成测试、全量前端测试和移动端生产构建。
+- [x] 用隔离接口在 375px 和桌面宽度检查按钮常驻、批量条、长单词和无横向溢出。
+- [x] 自查差异,仅提交本任务文件,推送当前分支;不部署。
+
+## 复查补充
+
+- 保存结果尚待确认时锁定识词判断、勾选、切栏及加载,顶部保留“重试保存”;确认完原批次后才恢复操作,保持原 requestKey、请求词集及后续学习动作不变。
+- 已认识词保存后,待学集合会缩短;后续加载先从新的第一页重新取得偏移,保留本地待学选择和缓存,刷新失败仍重试第一页,防止漏词。
+- 批量勾选过程中,单独点击“不认识”视为排除该词,后续批量认识不会覆盖这个决定。
+- 独立复查发现的两处问题已修复并补充回归;最终复查无阻塞问题。

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

@@ -1785,7 +1785,7 @@ describe('home learning modules', () => {
     (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
     harness.fixture.detectChanges();
     expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(2);
-    (harness.routeNativeElement?.querySelector('.completed-word-action button') as HTMLButtonElement).click();
+    (harness.routeNativeElement?.querySelector('.completed-column .unknown-button') as HTMLButtonElement).click();
     harness.fixture.detectChanges();
     expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(1);
     (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[0] as HTMLButtonElement).click();
@@ -2232,4 +2232,194 @@ 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) {
+    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:0}));
+    const harness=await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/stu_abc?nids=811&pnid=801');
+    await harness.fixture.whenStable(); harness.fixture.detectChanges();
+    const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    return {api,harness,component};
+  }
+
+  it('keeps compact known and unknown decisions visible before revealing a word', async () => {
+    const {harness,component}=await teacherSelection(2);
+    const row=harness.routeNativeElement!.querySelector('.word-refine-item')!;
+    expect(component.selectionRevealed().size).toBe(0);
+    expect(row.querySelector('.word-refine-main .known-button')?.textContent?.trim()).toBe('认识');
+    expect(row.querySelector('.word-refine-main .unknown-button')?.textContent?.trim()).toBe('不认识');
+    (row.querySelector('.known-button') as HTMLButtonElement).click(); harness.fixture.detectChanges();
+    expect(component.coursePassCount(1)).toBe(1);
+  });
+
+  it('saves 205 accumulated known words in bounded requests without losing words', async () => {
+    const {api,harness,component}=await teacherSelection(205);
+    for(let page=0;page<2;page++){component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges();}
+    const original=api.post.bind(api),sizes:number[]=[];
+    spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>{
+      if(action==='e_add_words'){const size=String(params['wordsId']).split(',').length;sizes.push(size);if(size>100)return throwError(()=>new Error('单次最多操作 100 个单词'));}
+      return original(action,params);
+    }) as typeof api.post);
+    for(const word of component.words())component.markWordKnown(word);
+    component.startSelected();await harness.fixture.whenStable();harness.fixture.detectChanges();
+    expect(sizes).toEqual([100,100,5]);
+    expect(api.courseIncrements.size).toBe(205);
+    expect([...api.courseIncrements.values()].every(n=>n===1)).toBeTrue();
+    expect(component.newlyKnownWords().length).toBe(0);
+    expect(component.coursePassCount(1)).toBe(205);
+  });
+
+  it('retains successful known-word batches when a later request fails and retries only remaining words', async () => {
+    const {api,harness,component}=await teacherSelection(205);
+    for(let page=0;page<2;page++){component.loadMoreSelection();await harness.fixture.whenStable();}
+    const original=api.post.bind(api),requests:Record<string,unknown>[]=[];let failed=false;
+    spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>{
+      if(action==='e_add_words'){
+        requests.push(params);
+        if(String(params['wordsId']).split(',').length>100)return throwError(()=>new Error('单次最多操作 100 个单词'));
+        if(requests.length===2&&!failed){failed=true;return throwError(()=>new Error('第二批网络中断'));}
+      }
+      return original(action,params);
+    }) as typeof api.post);
+    for(const word of component.words())component.markWordKnown(word);
+    component.startSelected();await harness.fixture.whenStable();harness.fixture.detectChanges();
+    expect(component.newlyKnownWords().length).toBe(105);
+    expect(component.coursePassCounts()?.[1]).toBe(100);
+    expect(component.submitting()).toBeFalse();
+    expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
+    component.startSelected();await harness.fixture.whenStable();harness.fixture.detectChanges();
+    expect(requests.map(p=>String(p['wordsId']).split(',').length)).toEqual([100,100,100,5]);
+    expect(requests[2]['requestKey']).toBe(requests[1]['requestKey']);
+    expect(api.courseIncrements.size).toBe(205);
+    expect([...api.courseIncrements.values()].every(n=>n===1)).toBeTrue();
+    expect(component.coursePassCount(1)).toBe(205);
+  });
+
+  it('selects the visible words, allows exceptions, then saves known words and trains only exceptions', async () => {
+    const {api,harness,component}=await teacherSelection(8);
+    const view=()=>harness.routeNativeElement!;
+    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.
+    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();
+    expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con');
+    const learning=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    expect(learning.words().map(w=>w.wordId).sort()).toEqual(['1002','1007']);
+    expect([...api.courseIncrements.keys()].sort()).toEqual(['1001','1003','1004','1005','1006','1008']);
+  });
+
+  it('saves all-known words and opens the learned pass without creating an empty learning round', async () => {
+    const {api,harness,component}=await teacherSelection(8);
+    (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(8);
+    expect(component.coursePass()).toBe(1);
+    expect(component.visibleCourseWords().length).toBe(8);
+    expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
+  });
+
+  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 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);
+    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();
+    expect(harness.routeNativeElement!.querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(0);
+  });
+
+  it('uses select-all only for loaded words and guards decisions while saving', async () => {
+    const {api,harness,component}=await teacherSelection(105);
+    const original=api.post.bind(api),response=new Subject<ApiEnvelope<unknown>>();
+    const post=spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>action==='e_add_words'?response:original(action,params)) as typeof api.post);
+    (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
+    expect(harness.routeNativeElement!.querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(100);
+    (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
+    expect(component.submitting()).toBeTrue();
+    component.startSelected();component.selectCoursePass(1);
+    expect(component.coursePass()).toBe(0);
+    expect(post.calls.allArgs().filter(([action])=>action==='e_add_words').length).toBe(1);
+    response.next({retcode:1,result:{affected:100}});response.complete();await harness.fixture.whenStable();
+    expect(post.calls.allArgs().filter(([action])=>action==='e_add_words').length).toBe(1);
+  });
+
+  it('preserves an ambiguous committed batch key by freezing decisions until retry resolves', async () => {
+    const {api,harness,component}=await teacherSelection(205);
+    for(let page=0;page<2;page++){component.loadMoreSelection();await harness.fixture.whenStable();}
+    const original=api.post.bind(api),completed=new Map<string,Observable<ApiEnvelope<any>>>(),requests:Record<string,unknown>[]=[];let lost=false;
+    spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>{
+      if(action!=='e_add_words')return original(action,params);
+      requests.push(params);const key=String(params['requestKey']);
+      if(completed.has(key))return completed.get(key)!;
+      const saved=original(action,params);completed.set(key,saved);
+      if(!lost){lost=true;return throwError(()=>new Error('服务端已保存但响应丢失'));}
+      return saved;
+    }) as typeof api.post);
+    const first=component.words()[0];
+    (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(harness.routeNativeElement!.querySelector('.selection-retry-note')?.textContent).toContain('尚待确认');
+    expect(harness.routeNativeElement!.querySelector('.selection-header .primary-button')?.textContent).toContain('重试保存');
+    expect(api.courseIncrements.size).toBe(100);
+    component.markWordUnknown(first);component.toggleWord(first);component.selectCoursePass(1);component.loadMoreSelection();
+    expect(component.selectionAssessment()[first.id]).toBe('known');expect(component.selected().size).toBe(0);expect(component.coursePass()).toBe(0);
+    component.startSelected();await harness.fixture.whenStable();harness.fixture.detectChanges();
+    expect(requests[1]['requestKey']).toBe(requests[0]['requestKey']);
+    expect(requests[1]['wordsId']).toBe(requests[0]['wordsId']);
+    expect(api.courseIncrements.size).toBe(205);expect([...api.courseIncrements.values()].every(n=>n===1)).toBeTrue();
+    expect(component.coursePass()).toBe(1);expect(harness.routeNativeElement!.querySelector('.selection-retry-note')).toBeNull();
+  });
+
+  it('rebases pending pages after saving known words without skipping remaining vocabulary or selections', async () => {
+    const {api,harness,component}=await teacherSelection(250);
+    const view=()=>harness.routeNativeElement!;
+    const checks=()=>view().querySelectorAll<HTMLButtonElement>('.word-select-check');
+    checks()[99].click(); // Preserve an existing individual learning choice.
+    (view().querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges();
+    for(let i=60;i<100;i++)checks()[i].click();harness.fixture.detectChanges();
+    (view().querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click();await harness.fixture.whenStable();harness.fixture.detectChanges();
+    expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');expect(api.courseIncrements.size).toBe(60);
+    const get=api.get.bind(api);let failed=false;
+    spyOn(api,'get').and.callFake(((action:string,params:Record<string,unknown>={})=>{
+      if(action==='e_ck_list'&&Number(params['cpage'])===1&&!failed){failed=true;return throwError(()=>new Error('刷新第一页失败'));}
+      return get(action,params);
+    }) as typeof api.get);
+    component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges();
+    expect(component.error()).toBe('');expect(view().querySelector('.word-refine-item')).not.toBeNull();
+    component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges();
+    component.loadMoreSelection();await harness.fixture.whenStable();harness.fixture.detectChanges();
+    expect(component.selectionTotal()).toBe(190);
+    expect(component.visibleCourseWords().map(w=>Number(w.wordId)).sort((a,b)=>a-b)).toEqual(Array.from({length:190},(_,i)=>1061+i));
+    expect(component.selected().has('1100')).toBeTrue();
+  });
+
+  it('treats an individual unknown decision as an exception to bulk known', async () => {
+    const {api,harness}=await teacherSelection(3);
+    (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges();
+    (harness.routeNativeElement!.querySelector('.word-refine-item .unknown-button') as HTMLButtonElement).click();harness.fixture.detectChanges();
+    expect(harness.routeNativeElement!.querySelector('.word-select-check')?.getAttribute('aria-pressed')).toBe('false');
+    (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement).click();await harness.fixture.whenStable();harness.fixture.detectChanges();
+    const learning=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    expect(learning.words().map(w=>w.wordId)).toEqual(['1001']);expect([...api.courseIncrements.keys()].sort()).toEqual(['1002','1003']);
+  });
+
+  it('starts a valid bulk unknown selection without marking those words learned', async () => {
+    const {api,harness}=await teacherSelection(3);
+    (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement).click();harness.fixture.detectChanges();
+    (harness.routeNativeElement!.querySelector('.bulk-assessment .unknown-button') as HTMLButtonElement).click();await harness.fixture.whenStable();harness.fixture.detectChanges();
+    expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con');
+    const learning=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    expect(learning.words().length).toBe(3);expect(api.courseIncrements.size).toBe(0);
+  });
+
 });

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

@@ -119,12 +119,15 @@
       <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) {
-          <button type="button" class="primary-button" [disabled]="submitting()" (click)="startSelected()">
+          <button type="button" class="primary-button" [disabled]="submitting() || selectionLoading()" (click)="startSelected()">
             <lucide-icon [img]="icons.Play" [size]="17" />{{ isRefineSelectionPage ? selectionActionLabel() : '开始学习' }}
           </button>
         }
       </div>
     </header>
+    @if (isRefineSelectionPage && knownSaveRetry()) {
+      <p class="selection-retry-note" role="alert">识词结果尚待确认,请先重试保存,再继续选词。</p>
+    }
     @if (isNewWordSelectionPage) {
       <div class="word-selection-panel">
         <nav class="word-selection-tabs" role="tablist" aria-label="生词学习状态">
@@ -149,20 +152,33 @@
       <div class="word-selection-panel">
         <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)">
+            <button type="button" role="tab" [class.active]="coursePass() === pass" [disabled]="selectionLocked()" [attr.aria-selected]="coursePass() === pass" [attr.aria-controls]="'course-pass-panel'" (click)="selectCoursePass(pass)">
               <span>{{ label }}</span><b>{{ coursePassCount(pass) }}</b>
             </button>
           }
         </nav>
         <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>
+          @if (!isReadOnlySelectionPage && coursePass() === 0) {
+            <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>
+              @if (bulkSelection(); as checked) {
+                <div class="bulk-assessment" role="group" aria-label="批量判断单词">
+                  <span role="status">已勾选 {{ checked.size }} 个</span>
+                  <button type="button" class="known-button" [disabled]="selectionLocked() || !checked.size" (click)="applyBulkAssessment('known')">认识</button>
+                  <button type="button" class="unknown-button" [disabled]="selectionLocked() || !checked.size" (click)="applyBulkAssessment('unknown')">不认识</button>
+                </div>
+                <small>勾选认识的词,取消不认识的词,再点“认识”;未勾选的保留待学。</small>
+              }
+            </div>
+          }
           <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">
+              <article class="word-refine-item" [class.selected]="courseWordChecked(word)" [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 type="button" class="word-select-check" [disabled]="selectionLocked()" [attr.aria-label]="(bulkSelection() !== null ? (courseWordChecked(word) ? '取消勾选:' : '勾选:') : (selected().has(word.id) ? '移出学习:' : '加入学习:')) + word.word" [attr.aria-pressed]="courseWordChecked(word)" (click)="toggleWord(word)">
+                      @if (courseWordChecked(word)) { <lucide-icon [img]="icons.Check" [size]="16" /> }
                     </button>
                   } @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>
@@ -171,19 +187,14 @@
                     <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" (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>
+                  @if (!isReadOnlySelectionPage && !wordHistoricallyLearned(word)) {
+                    <div class="word-assessment" role="group" [attr.aria-label]="word.word + '的识词判断'">
+                      <button type="button" class="known-button" [disabled]="selectionLocked()" [class.active]="selectionAssessment()[word.id] === 'known'" [attr.aria-pressed]="selectionAssessment()[word.id] === 'known'" (click)="markWordKnown(word)">认识</button>
+                      <button type="button" class="unknown-button" [disabled]="selectionLocked()" [class.active]="selectionAssessment()[word.id] === 'unknown'" [attr.aria-pressed]="selectionAssessment()[word.id] === 'unknown'" (click)="markWordUnknown(word)">不认识</button>
                     </div>
                   }
-                }
+                  @if (!isReadOnlySelectionPage) { <span class="audio" aria-hidden="true"><lucide-icon [img]="icons.Volume2" [size]="18" /></span> }
+                </div>
               </article>
             } @empty {
               <div class="word-column-empty"><lucide-icon [img]="icons.BookOpen" [size]="24" /><span>{{ selectionLoading() ? '正在加载单词…' : '本栏暂无单词' }}</span></div>
@@ -216,7 +227,7 @@
         <button type="button" (click)="goToNewWordPage(selectionPage() + 1)" [disabled]="selectionLoading() || selectionPage() >= newWordPageCount()">下一页</button>
       </div>
     } @else if (!isNewWordSelectionPage && selectionTotal() > 0) {
-      <div class="mobile-pagination"><span>已加载 {{words().length}} / {{selectionTotal()}} 个单词</span>@if(words().length<selectionTotal()){<button (click)="loadMoreSelection()" [disabled]="selectionLoading()">{{selectionLoading()?'加载中…':'加载更多单词'}}</button>}</div>
+      <div class="mobile-pagination"><span>已加载 {{words().length}} / {{selectionTotal()}} 个单词</span>@if(words().length<selectionTotal()){<button (click)="loadMoreSelection()" [disabled]="selectionLocked() || selectionLoading()">{{selectionLoading()?'加载中…':'加载更多单词'}}</button>}</div>
     }
   } @else if (isStudyListPage) {
     <div class="stage-banner">

+ 106 - 20
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -30,7 +30,7 @@ import {
   Volume2,
   X,
 } from 'lucide-angular';
-import { catchError, finalize, forkJoin, map, Observable, of, Subscription, switchMap, tap, throwError } from 'rxjs';
+import { catchError, concatMap, finalize, forkJoin, from, map, Observable, of, Subscription, switchMap, tap, throwError, toArray } from 'rxjs';
 import { listeningChoices, shuffledStudy } from '../../core/learning-random';
 import { ApiError, ApiService } from '../../core/api.service';
 import {
@@ -128,6 +128,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly selected = signal<Set<string>>(new Set());
   readonly selectionRevealed = signal<Set<string>>(new Set());
   readonly selectionAssessment = signal<Record<string, 'known' | 'unknown'>>({});
+  readonly bulkSelection = signal<Set<string> | null>(null);
+  readonly knownSaveRetry = signal(false);
+  private knownSaveContinuation: { continueLearning: boolean; openKnownPass: boolean } | null = null;
+  private courseSelectionNeedsRebase = false;
+  readonly bulkCandidates = computed(() => this.visibleCourseWords().filter(word => !this.wordHistoricallyLearned(word)));
   readonly activeSelectionBucket = signal<'pending' | 'completed'>('pending');
   readonly coursePassTabs = ['未学习', '已学一遍', '已学第二遍', '已学第三遍'];
   readonly coursePass = signal(0);
@@ -159,6 +164,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly calendarLoading = signal(false);
   readonly calendarError = signal('');
   readonly submitting = signal(false);
+  readonly selectionLocked = computed(() => this.submitting() || this.knownSaveRetry());
   readonly selectionTotal=signal(0);readonly selectionPage=signal(1);readonly selectionLoading=signal(false);private selectionRequest?:Subscription;
   readonly error = signal('');
   readonly recordWordless = signal(false);
@@ -257,6 +263,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly allVisibleNewWordsSelected = computed(() => this.visibleNewWords().length > 0 && this.visibleNewWords().every((word) => this.selected().has(word.id)));
   readonly newlyKnownWords = computed(() => (this.isRefineSelectionPage ? this.courseSelectionCache() : this.words()).filter((word) => !this.wordHistoricallyLearned(word) && this.selectionAssessment()[word.id] === 'known'));
   readonly selectionActionLabel = computed(() => {
+    if (this.knownSaveRetry()) return '重试保存';
     const hasLearningWords = this.selected().size > 0;
     const hasKnownWords = this.newlyKnownWords().length > 0;
     if (hasLearningWords && hasKnownWords) return '保存并开始学习';
@@ -628,6 +635,14 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   toggleWord(word: Word): void {
+    if (this.selectionLocked()) return;
+    const bulk = this.bulkSelection();
+    if (this.isRefineSelectionPage && bulk !== null) {
+      const next = new Set(bulk);
+      if (next.has(word.id)) next.delete(word.id); else next.add(word.id);
+      this.bulkSelection.set(next);
+      return;
+    }
     if (this.isRefineSelectionPage && !this.canRelearnCourseWord(word)) return;
     const shouldSelect = !this.selected().has(word.id);
     if (!this.setWordSelected(word, shouldSelect) || !this.isRefineSelectionPage) return;
@@ -668,13 +683,15 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   get coursePassHint(): string {
     const pass = this.coursePass();
-    if (pass === 0) return this.isReadOnlySelectionPage ? '这些单词尚未完成第一遍学习,点击可查看释义。' : '先回忆中文,核对后选择“认识”或“加入学习”。';
+    if (pass === 0) return this.isReadOnlySelectionPage ? '这些单词尚未完成第一遍学习,点击可查看释义。' : '可直接选择“认识”或“不认识”,也可全选本页后批量判断。';
     if (pass === 3) return '这些单词已完成三遍学习,可查看和听读音。';
     return this.isReadOnlySelectionPage ? `这些单词已学${pass}遍,可由老师选词继续学习。` : `选择单词开始第${pass === 1 ? '二' : '三'}遍学习。`;
   }
 
   selectCoursePass(pass: number): void {
-    if (pass === this.coursePass() || pass < 0 || pass > 3) return;
+    if (this.selectionLocked() || pass === this.coursePass() || pass < 0 || pass > 3) return;
+    this.bulkSelection.set(null);
+    this.courseSelectionNeedsRebase = false;
     this.selectionRequest?.unsubscribe();
     this.coursePass.set(pass);
     this.selectionPage.set(1);
@@ -698,14 +715,53 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   markWordKnown(word: Word): void {
-    if (this.isReadOnlySelectionPage || this.wordHistoricallyLearned(word)) return;
+    if (this.selectionLocked() || this.isReadOnlySelectionPage || this.wordHistoricallyLearned(word)) return;
     this.setWordSelected(word, false);
     this.setSelectionAssessment(word, 'known');
+    this.bulkSelection.update(current => current === null ? null : new Set([...current].filter(id => id !== word.id)));
   }
 
   markWordUnknown(word: Word): void {
-    if (this.isReadOnlySelectionPage || this.wordHistoricallyLearned(word)) return;
-    if (this.setWordSelected(word, true)) this.setSelectionAssessment(word, 'unknown');
+    if (this.selectionLocked() || this.isReadOnlySelectionPage || this.wordHistoricallyLearned(word)) return;
+    if (this.setWordSelected(word, true)) {
+      this.setSelectionAssessment(word, 'unknown');
+      this.bulkSelection.update(current => current === null ? null : new Set([...current].filter(id => id !== word.id)));
+    }
+  }
+
+  courseWordChecked(word: Word): boolean {
+    return (this.bulkSelection() ?? this.selected()).has(word.id);
+  }
+
+  toggleBulkSelection(): void {
+    if (this.selectionLocked() || this.selectionLoading() || this.isReadOnlySelectionPage || this.coursePass() !== 0) return;
+    this.bulkSelection.set(this.bulkSelection() === null ? new Set(this.bulkCandidates().map(word => word.id)) : null);
+  }
+
+  applyBulkAssessment(assessment: 'known' | 'unknown'): void {
+    if (this.selectionLocked() || this.isReadOnlySelectionPage || this.coursePass() !== 0) return;
+    const checked = this.bulkSelection();
+    if (!checked?.size) return;
+    const candidates = this.bulkCandidates();
+    const chosen = candidates.filter(word => checked.has(word.id));
+    if (!chosen.length) return;
+    if (assessment === 'unknown') {
+      const combined = new Set([...this.selected(), ...chosen.map(word => word.id)]);
+      if (combined.size > this.selectionLimit) {
+        this.platform.toast(`本轮最多学习 ${this.selectionLimit} 个单词,请减少勾选后重试`, 'error');
+        return;
+      }
+      this.bulkSelection.set(null);
+      for (const word of chosen) this.markWordUnknown(word);
+      this.startSelected();
+      return;
+    }
+    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;
+    if (canContinue) for (const word of remaining) this.markWordUnknown(word);
+    this.startSelected(canContinue, canContinue && this.selected().size === 0);
   }
 
   toggleAllWords(): void {
@@ -730,8 +786,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.selected.set(new Set(this.words().slice(0, this.selectionLimit).map((word) => word.id)));
   }
 
-  startSelected(): void {
-    if (this.submitting() || this.isReadOnlySelectionPage) return;
+  startSelected(continueLearning = true, openKnownPass = false): void {
+    if (this.submitting() || this.selectionLoading() || this.isReadOnlySelectionPage) return;
+    if (this.knownSaveRetry() && this.knownSaveContinuation) {
+      ({ continueLearning, openKnownPass } = this.knownSaveContinuation);
+    }
     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));
@@ -748,16 +807,24 @@ export class LearningPageComponent implements OnInit, OnDestroy {
         return;
       }
       this.submitting.set(true);
-      this.persistWordsAsLearned(newlyKnown, student).pipe(finalize(() => this.submitting.set(false))).subscribe({
+      this.knownSaveContinuation = { continueLearning, openKnownPass };
+      this.persistWordsAsLearned(newlyKnown, student, batch => this.commitKnownWords(batch)).subscribe({
         next: () => {
-          if (newlyKnown.length) this.commitKnownWords(newlyKnown);
-          if (chosen.length) {
+          this.knownSaveRetry.set(false);
+          this.knownSaveContinuation = null;
+          this.submitting.set(false);
+          if (continueLearning && chosen.length) {
             this.beginSelectedFlow(chosen, '');
             return;
           }
+          if (openKnownPass) this.selectCoursePass(1);
           this.platform.toast('识词结果已保存', 'success');
         },
-        error: (error: ApiError) => this.platform.toast(error.message, 'error'),
+        error: (error: ApiError) => {
+          this.knownSaveRetry.set(true);
+          this.submitting.set(false);
+          this.platform.toast(`${error.message};已保存的批次会保留,请点击“重试保存”完成核对`, 'error');
+        },
       });
       return;
     }
@@ -1348,7 +1415,18 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     });
   }
 
-  loadMoreSelection():void{if(!this.selectionLoading()&&this.words().length<this.selectionTotal()){this.selectionPage.update(p=>p+1);this.loadSelection(true);}}
+  loadMoreSelection(): void {
+    if (this.selectionLocked() || this.selectionLoading() || this.words().length >= this.selectionTotal()) return;
+    this.bulkSelection.set(null);
+    if (this.isRefineSelectionPage && this.courseSelectionNeedsRebase) {
+      const previousPage = this.selectionPage();
+      this.selectionPage.set(1);
+      this.loadSelection(false, previousPage);
+      return;
+    }
+    this.selectionPage.update(page => page + 1);
+    this.loadSelection(true);
+  }
   selectNewWordBucket(bucket: 'pending' | 'completed'): void {
     if (this.activeSelectionBucket() === bucket) return;
     this.selectionRequest?.unsubscribe();
@@ -1410,13 +1488,14 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     });
   }
 
-  private loadSelection(more=false): void {
+  private loadSelection(more=false, rebaseFromPage?: number): void {
     const accept=(result:unknown,total:unknown)=>{const incoming=this.findList(result).map(item=>this.meta.path==='pages/member/starshop'?this.toNewWord(item):this.toWord(item)).filter(w=>w.word);this.words.set(more?[...this.words(),...incoming]:incoming);this.selectionTotal.set(Number(total)||this.words().length);};
-    const failure=(error:ApiError)=>{if(more){this.selectionPage.update(p=>p-1);this.platform.toast(error.message,'error');}else this.error.set(error.message);};
+    const failure=(error:ApiError)=>{if(rebaseFromPage!==undefined){this.selectionPage.set(rebaseFromPage);this.platform.toast(error.message,'error');}else 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'),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);
+        if (rebaseFromPage !== undefined) this.courseSelectionNeedsRebase = false;
         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()]);
@@ -2236,6 +2315,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.legacyNewWords = null;
     this.selectionRevealed.set(new Set());
     this.selectionAssessment.set({});
+    this.bulkSelection.set(null);
+    this.knownSaveRetry.set(false);
+    this.knownSaveContinuation = null;
+    this.courseSelectionNeedsRebase = false;
     this.activeSelectionBucket.set('pending');
     this.coursePass.set(0);
     this.coursePassCounts.set(null);
@@ -2341,18 +2424,21 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.selectionAssessment.update((current) => Object.fromEntries(Object.entries(current).filter(([id]) => id !== word.id)) as Record<string, 'known' | 'unknown'>);
   }
 
-  private persistWordsAsLearned(words: Word[], owner: AppUser | null | undefined): Observable<unknown> {
+  private persistWordsAsLearned(words: Word[], owner: AppUser | null | undefined, onBatchSaved?: (batch: Word[]) => void): Observable<unknown> {
     if (!words.length) return of(null);
-    return this.api.post('e_add_words', {
+    const batches: Word[][] = [];
+    for (let index = 0; index < words.length; index += 100) batches.push(words.slice(index, index + 100));
+    return from(batches).pipe(concatMap(batch => this.api.post('e_add_words', {
       uid: owner?.userId,
-      wordsId: words.map((word) => word.wordId).join(','),
+      wordsId: batch.map((word) => word.wordId).join(','),
       kcid: (this.meta.path === 'pages/stu/pl_end' ? this.orderInfo()['learningCourseId'] : '') || this.query('pnid') || this.query('kcid') || this.orderInfo()['kcid'],
       save: 1,
-      requestKey:this.draft().command('learn:'+words.map(w=>w.wordId).sort().join(',')),
-    });
+      requestKey:this.draft().command('learn:'+batch.map(w=>w.wordId).sort().join(',')),
+    }).pipe(tap(() => onBatchSaved?.(batch)))), toArray());
   }
 
   private commitKnownWords(words: Word[]): void {
+    if (this.isRefineSelectionPage && this.coursePass() === 0) this.courseSelectionNeedsRebase = true;
     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);

+ 15 - 10
projects/xiaoshu-mobile/src/styles.scss

@@ -859,21 +859,29 @@ app-study-records-page .ai-reading-flow span { overflow: hidden; max-width: 100%
 app-learning-page .word-refine-item { background: white; border-bottom: 1px solid var(--line); }
 app-learning-page .word-refine-item.selected { background: var(--brand-pale); }
 app-learning-page .word-refine-item:last-child { border-bottom: 0; }
-app-learning-page .word-refine-main { display: grid; min-height: 72px; align-items: center; padding: 8px 14px; grid-template-columns: 32px minmax(0, 1fr) 34px; gap: 10px; }
+app-learning-page .word-refine-main { display: grid; min-height: 72px; align-items: center; padding: 8px 14px; grid-template-columns: 32px minmax(0, 1fr) auto 26px; gap: 8px; }
 app-learning-page .word-select-check { display: grid; width: 32px; height: 32px; padding: 0; color: white; background: white; border: 1px solid var(--line); border-radius: var(--radius-sm); place-items: center; }
 app-learning-page .word-select-check[aria-pressed='true'] { background: var(--brand-strong); border-color: var(--brand-strong); }
 app-learning-page .word-reveal { display: flex; min-width: 0; min-height: 52px; padding: 5px 0; align-items: flex-start; color: var(--ink); text-align: left; background: transparent; border: 0; flex-direction: column; gap: 5px; }
-app-learning-page .word-reveal strong { font-size: 18px; }
+app-learning-page .word-reveal strong { max-width: 100%; font-size: 18px; overflow-wrap: anywhere; }
 app-learning-page .word-reveal small { overflow: hidden; max-width: 100%; color: var(--ink-faint); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
 app-learning-page .word-reveal small.revealed { color: var(--ink-soft); font-size: 13px; }
 app-learning-page .word-refine-main .audio { display: grid; width: 34px; height: 34px; color: var(--brand-strong); place-items: center; }
-app-learning-page .word-assessment { display: grid; padding: 0 14px 12px 56px; grid-template-columns: minmax(0, .85fr) minmax(0, 1.45fr); gap: 7px; }
-app-learning-page .word-assessment > span { grid-column: 1 / -1; color: var(--ink-soft); font-size: 10px; }
-app-learning-page .word-assessment button { display: inline-flex; min-width: 0; min-height: 38px; padding: 0 9px; align-items: center; justify-content: center; background: white; border: 1px solid var(--line); border-radius: var(--radius-sm); font-size: 11px; font-weight: 650; gap: 5px; }
-app-learning-page .word-assessment .known-button { color: var(--brand-strong); }
-app-learning-page .word-assessment .unknown-button { color: #9a6407; }
+app-learning-page .word-assessment { display: flex; align-items: center; gap: 4px; }
+app-learning-page .word-assessment button, app-learning-page .bulk-assessment button, app-learning-page .bulk-select-toggle { display: inline-flex; min-width: 0; min-height: 32px; padding: 0 8px; align-items: center; justify-content: center; color: var(--brand-strong); background: white; border: 1px solid var(--line); border-radius: var(--radius-sm); font-size: 12px; font-weight: 650; white-space: nowrap; }
+app-learning-page .word-assessment .unknown-button, app-learning-page .bulk-assessment .unknown-button { color: #9a6407; }
 app-learning-page .word-assessment .known-button.active { color: white; background: var(--brand-strong); border-color: var(--brand-strong); }
 app-learning-page .word-assessment .unknown-button.active { color: #4d3100; background: var(--accent-pale); border-color: var(--accent); }
+app-learning-page .course-bulk-toolbar { display: flex; position: sticky; top: 0; z-index: 2; padding: 10px 14px; align-items: center; flex-wrap: wrap; gap: 8px 12px; background: var(--brand-wash); border-bottom: 1px solid var(--line); }
+app-learning-page .bulk-assessment { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
+app-learning-page .bulk-assessment span { color: var(--ink-soft); font-size: 12px; }
+app-learning-page .course-bulk-toolbar small { flex-basis: 100%; color: var(--ink-soft); font-size: 11px; line-height: 1.6; }
+app-learning-page .selection-retry-note { margin: 10px 14px; padding: 12px; color: #704500; background: var(--accent-pale); border-radius: var(--radius-sm); font-size: 13px; line-height: 1.6; }
+app-learning-page .word-refine-main:has(.word-assessment) .audio { width: 26px; }
+app-learning-page .word-refine-main:not(:has(.word-assessment)) { grid-template-columns: 32px minmax(0, 1fr) 26px; }
+app-learning-page .word-refine-main button:disabled, app-learning-page .course-bulk-toolbar button:disabled { opacity: .55; cursor: default; }
+@media (pointer: coarse) { app-learning-page .word-assessment button, app-learning-page .bulk-assessment button, app-learning-page .bulk-select-toggle { min-height: 44px; } }
+@media (max-width: 380px) { app-learning-page .word-refine-main { padding-inline: 10px; gap: 6px; } app-learning-page .word-assessment button { padding-inline: 6px; font-size: 11px; } }
 app-learning-page .word-selection-panel { padding: 14px; background: var(--surface-muted); }
 app-learning-page .word-selection-list { overflow: hidden; background: white; border: solid var(--line); border-width: 0 1px 1px; border-radius: 0 0 var(--radius-lg) var(--radius-lg); box-shadow: var(--shadow-sm); }
 app-learning-page .word-selection-tabs { display: grid; overflow: hidden; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: var(--shadow-sm); grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -890,14 +898,11 @@ app-learning-page .word-column-help { min-height: 51px; margin: 0; padding: 10px
 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; }
 app-learning-page .completed-word { background: color-mix(in srgb, var(--brand-pale) 58%, white); }
-app-learning-page .completed-word-action { display: flex; padding: 0 14px 12px 56px; align-items: center; justify-content: space-between; color: var(--ink-soft); font-size: 10px; gap: 8px; }
-app-learning-page .completed-word-action button { min-height: 30px; padding: 0 9px; color: var(--brand-strong); background: white; border: 1px solid var(--line); border-radius: var(--radius-sm); font-size: 10px; white-space: nowrap; }
 app-learning-page .word-column-empty { display: flex; min-height: 150px; padding: 24px; align-items: center; justify-content: center; color: var(--ink-faint); flex-direction: column; gap: 8px; }
 
 @media (hover: hover) { app-study-records-page .ai-reading-cta:not(:disabled):hover { filter: brightness(1.04); transform: translateY(-1px); } }
 @media (prefers-reduced-motion: reduce) { app-study-records-page .ai-reading-cta { transition: none; } }
 @media (max-width: 360px) { app-study-records-page .ai-reading-feature { margin-inline: 10px; } app-study-records-page .ai-reading-copy { width: 58%; padding-left: 14px; } app-study-records-page .ai-reading-copy h2 { font-size: 21px; } app-study-records-page .ai-reading-copy p { width: 160px; } app-study-records-page .ai-reading-cta { padding-inline: 9px; } }
-@media (max-width: 360px) { app-learning-page .word-assessment, app-learning-page .completed-word-action { padding-left: 14px; } }
 @media (min-width: 500px) { app-study-records-page .ai-reading-copy { width: 44%; max-width: 334px; } app-study-records-page .ai-reading-copy p { width: 230px; } }
 
 @media (max-width: 360px) {