Procházet zdrojové kódy

feat(mobile): split word selection by learning status

彭峰 před 1 měsícem
rodič
revize
491e396128

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

@@ -14,6 +14,7 @@ class LearningModulesApiService {
   lastScheduleParams: Record<string, unknown> = {};
   lastNodeParams: Record<string, unknown> = {};
   lastWordParams: Record<string, unknown> = {};
+  lastLearningWordUpdate: Record<string, unknown> = {};
 
   get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
     if (action === 'content_list' && Number(params['nid']) === 291) {
@@ -91,6 +92,10 @@ class LearningModulesApiService {
   }
 
   post<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
+    if (action === 'e_add_words') {
+      this.lastLearningWordUpdate = params;
+      return this.result<T>({ affected: String(params['wordsId'] ?? '').split(',').filter(Boolean).length });
+    }
     if (action === 'e_order_tongji') {
       return this.result<T>({ t30: 4, t60: 6, t_tiyan: 1, t_total: 11, t_shichang: 9, t_yongji: 320 });
     }
@@ -560,11 +565,31 @@ describe('home learning modules', () => {
     harness.fixture.detectChanges();
     const router = TestBed.inject(Router);
     expect(router.url).toContain('/pages/stu/stu_abc');
-    const firstWord = harness.routeNativeElement?.querySelector('.word-refine-item') as HTMLElement;
+    expect(harness.routeNativeElement?.querySelector('.pending-column')?.textContent).toContain('banana');
+    expect(harness.routeNativeElement?.querySelector('.completed-column')).toBeNull();
+    expect(harness.routeNativeElement?.querySelectorAll('.pending-column .word-refine-item').length).toBe(1);
+    (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.completed-column')?.textContent).toContain('apple');
+    expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull();
+    (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[0] as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    const firstWord = harness.routeNativeElement?.querySelector('.pending-column .word-refine-item') as HTMLElement;
     (firstWord.querySelector('.word-reveal') as HTMLButtonElement).click();
     harness.fixture.detectChanges();
-    (firstWord.querySelector('.unknown-button') as HTMLButtonElement).click();
+    (firstWord.querySelector('.known-button') as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelectorAll('.pending-column .word-refine-item').length).toBe(0);
+    expect(harness.routeNativeElement?.querySelector('.selection-header .primary-button')?.textContent).toContain('保存识词结果');
+    (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.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();
     harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelectorAll('.pending-column .word-refine-item').length).toBe(1);
     (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
@@ -572,4 +597,32 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelectorAll('.repeat-list > button').length).toBe(1);
     expect(harness.routeNativeElement?.querySelector('.readonly-word-header')).toBeNull();
   });
+
+  it('counts words marked as known as completed and persists them for the student', async () => {
+    user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
+    session.groupId.set(3);
+    session.student.set({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 });
+    const harness = await RouterTestingHarness.create();
+
+    await harness.navigateByUrl('/pages/stu/stu_abc?nids=811,812&pnid=801');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    const pendingWord = harness.routeNativeElement?.querySelector('.pending-column .word-refine-item') as HTMLElement;
+    (pendingWord.querySelector('.word-reveal') as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    (pendingWord.querySelector('.known-button') as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    expect(api.lastLearningWordUpdate).toEqual(jasmine.objectContaining({ uid: 100, wordsId: '1002', kcid: '801', save: 1 }));
+    expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
+    expect(harness.routeNativeElement?.querySelector('.completed-column')).toBeNull();
+    (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);
+    expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull();
+  });
 });

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

@@ -86,41 +86,93 @@
       <p class="readonly-word-note">{{ isHistoryWordView ? '这里展示本次学习记录中的单词,不会改动原学习结果' : '学生可查看老师分配的单词,点读、跟读和学习记录由陪练老师在上课时操作' }}</p>
     </section>
   } @else if (isSelectionPage) {
-    <header class="selection-header">
-      <div><strong>已选 {{ selected().size }} / {{ selectionLimit }}</strong><span>{{ isRefineSelectionPage ? '先回忆中文,再点击英文核对' : '选择本次要学习的单词' }}</span></div>
+    <header class="selection-header" [class.refine-selection-header]="isRefineSelectionPage">
+      <div>
+        @if (isRefineSelectionPage) {
+          <strong>本次待学 {{ selected().size }} / {{ selectionLimit }}</strong>
+          <span>已学完 {{ completedSelectionWords().length }} · 还没学 {{ pendingSelectionWords().length }}</span>
+        } @else {
+          <strong>已选 {{ selected().size }} / {{ selectionLimit }}</strong>
+          <span>选择本次要学习的单词</span>
+        }
+      </div>
       <div class="header-actions">
         @if (!isRefineSelectionPage) { <button type="button" class="quiet-button" (click)="toggleAllWords()">{{ selected().size ? '取消全选' : '全选' }}</button> }
         <button type="button" class="primary-button" [disabled]="submitting()" (click)="startSelected()">
-          <lucide-icon [img]="icons.Play" [size]="17" />开始学习
+          <lucide-icon [img]="icons.Play" [size]="17" />{{ isRefineSelectionPage ? selectionActionLabel() : '开始学习' }}
         </button>
       </div>
     </header>
-    <div class="word-select-list">
-      @if (isRefineSelectionPage) {
-        @for (word of words(); track word.id) {
-          <article class="word-refine-item" [class.selected]="selected().has(word.id)">
-            <div class="word-refine-main">
-              <button type="button" class="word-select-check" [attr.aria-label]="(selected().has(word.id) ? '移出学习:' : '加入学习:') + word.word" [attr.aria-pressed]="selected().has(word.id)" (click)="toggleWord(word)">
-                @if (selected().has(word.id)) { <lucide-icon [img]="icons.Check" [size]="16" /> }
-              </button>
-              <button type="button" class="word-reveal" [attr.aria-expanded]="selectionWordRevealed(word)" (click)="revealSelectionWord(word)">
-                <strong>{{ word.word }}</strong>
-                <small [class.revealed]="selectionWordRevealed(word)">{{ selectionWordRevealed(word) ? (word.meaning || '暂无释义') : '先说出中文意思,再点击核对' }}</small>
-              </button>
-              <span class="audio" aria-hidden="true"><lucide-icon [img]="icons.Volume2" [size]="18" /></span>
+    @if (isRefineSelectionPage) {
+      <div class="word-selection-panel">
+        <nav class="word-selection-tabs" role="tablist" aria-label="单词学习状态">
+          <button type="button" role="tab" [class.active]="activeSelectionBucket() === 'pending'" [attr.aria-selected]="activeSelectionBucket() === 'pending'" (click)="activeSelectionBucket.set('pending')">
+            <span>还没学</span><b>{{ pendingSelectionWords().length }}</b>
+          </button>
+          <button type="button" role="tab" [class.active]="activeSelectionBucket() === 'completed'" [attr.aria-selected]="activeSelectionBucket() === 'completed'" (click)="activeSelectionBucket.set('completed')">
+            <span>已学完</span><b>{{ completedSelectionWords().length }}</b>
+          </button>
+        </nav>
+
+        @if (activeSelectionBucket() === 'pending') {
+          <section class="word-selection-list pending-column" role="tabpanel" aria-label="还没学的单词">
+            <p class="word-column-help">先回忆中文,核对后选择“认识”或“加入学习”。</p>
+            <div class="word-select-list">
+              @for (word of pendingSelectionWords(); track word.id) {
+                <article class="word-refine-item" [class.selected]="selected().has(word.id)">
+                  <div class="word-refine-main">
+                    <button type="button" class="word-select-check" [attr.aria-label]="(selected().has(word.id) ? '移出学习:' : '加入学习:') + word.word" [attr.aria-pressed]="selected().has(word.id)" (click)="toggleWord(word)">
+                      @if (selected().has(word.id)) { <lucide-icon [img]="icons.Check" [size]="16" /> }
+                    </button>
+                    <button type="button" class="word-reveal" [attr.aria-expanded]="selectionWordRevealed(word)" (click)="revealSelectionWord(word)">
+                      <strong>{{ word.word }}</strong>
+                      <small [class.revealed]="selectionWordRevealed(word)">{{ selectionWordRevealed(word) ? (word.meaning || '暂无释义') : '先说出中文意思,再点击核对' }}</small>
+                    </button>
+                    <span class="audio" aria-hidden="true"><lucide-icon [img]="icons.Volume2" [size]="18" /></span>
+                  </div>
+                  @if (selectionWordRevealed(word)) {
+                    <div class="word-assessment">
+                      <span>和你刚才说的一样吗?</span>
+                      <button type="button" class="known-button" [class.active]="selectionAssessment()[word.id] === 'known'" (click)="markWordKnown(word)"><lucide-icon [img]="icons.Check" [size]="15" />认识</button>
+                      <button type="button" class="unknown-button" [class.active]="selectionAssessment()[word.id] === 'unknown'" (click)="markWordUnknown(word)"><lucide-icon [img]="icons.CircleHelp" [size]="15" />不认识,加入学习</button>
+                    </div>
+                  }
+                </article>
+              } @empty {
+                <div class="word-column-empty"><lucide-icon [img]="icons.CheckCheck" [size]="24" /><span>这个词库已经全部学完</span></div>
+              }
             </div>
-            @if (selectionWordRevealed(word)) {
-              <div class="word-assessment">
-                <span>和你刚才说的一样吗?</span>
-                <button type="button" class="known-button" [class.active]="selectionAssessment()[word.id] === 'known'" (click)="markWordKnown(word)"><lucide-icon [img]="icons.Check" [size]="15" />认识</button>
-                <button type="button" class="unknown-button" [class.active]="selectionAssessment()[word.id] === 'unknown'" (click)="markWordUnknown(word)"><lucide-icon [img]="icons.CircleHelp" [size]="15" />不认识,加入学习</button>
-              </div>
-            }
-          </article>
-        } @empty {
-          <div class="empty-state"><p>暂无可选词汇</p></div>
+          </section>
+        } @else {
+          <section class="word-selection-list completed-column" role="tabpanel" aria-label="已学完的单词">
+            <p class="word-column-help">包含真正完成学习,以及本次判断为“认识”的词。</p>
+            <div class="word-select-list">
+              @for (word of completedSelectionWords(); track word.id) {
+                <article class="word-refine-item completed-word">
+                  <div class="word-refine-main">
+                    <span class="word-complete-check" aria-hidden="true"><lucide-icon [img]="icons.Check" [size]="16" /></span>
+                    <button type="button" class="word-reveal" [attr.aria-expanded]="selectionWordRevealed(word)" (click)="revealSelectionWord(word)">
+                      <strong>{{ word.word }}</strong>
+                      <small [class.revealed]="selectionWordRevealed(word)">{{ selectionWordRevealed(word) ? (word.meaning || '暂无释义') : selectionCompletionLabel(word) + ' · 点击查看释义' }}</small>
+                    </button>
+                    <span class="audio" aria-hidden="true"><lucide-icon [img]="icons.Volume2" [size]="18" /></span>
+                  </div>
+                  @if (!wordHistoricallyLearned(word)) {
+                    <div class="completed-word-action">
+                      <span>本次已标记为认识</span>
+                      <button type="button" (click)="markWordUnknown(word)">改为还没学</button>
+                    </div>
+                  }
+                </article>
+              } @empty {
+                <div class="word-column-empty"><lucide-icon [img]="icons.CheckCheck" [size]="24" /><span>还没有学完的词</span></div>
+              }
+            </div>
+          </section>
         }
-      } @else {
+      </div>
+    } @else {
+      <div class="word-select-list">
         @for (word of words(); track word.id) {
           <button type="button" [class.selected]="selected().has(word.id)" (click)="toggleWord(word)">
             <span class="check">@if (selected().has(word.id)) { <lucide-icon [img]="icons.Check" [size]="16" /> }</span>
@@ -130,8 +182,8 @@
         } @empty {
           <div class="empty-state"><p>暂无可选词汇</p></div>
         }
-      }
-    </div>
+      </div>
+    }
     @if (meta.path === 'pages/member/starshop') {
       <div class="sticky-actions single">
         <button type="button" class="danger-button" (click)="deleteSelectedNewWords()"><lucide-icon [img]="icons.Trash2" [size]="18" />删除所选</button>

+ 5 - 0
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.scss

@@ -1358,6 +1358,11 @@ button {
     min-height: 30px;
   }
 
+  .refine-selection-header .header-actions .primary-button {
+    max-width: 154px;
+    white-space: normal;
+  }
+
   .calendar-list > button {
     grid-template-columns: 82px minmax(0, 1fr);
   }

+ 73 - 19
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -23,7 +23,7 @@ import {
   Volume2,
   X,
 } from 'lucide-angular';
-import { catchError, finalize, forkJoin, of } from 'rxjs';
+import { catchError, finalize, forkJoin, Observable, of, switchMap } from 'rxjs';
 import { GAME_AUDIO } from '../../core/app.constants';
 import { ApiError, ApiService } from '../../core/api.service';
 import {
@@ -109,6 +109,7 @@ 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 activeSelectionBucket = signal<'pending' | 'completed'>('pending');
   readonly currentIndex = signal(0);
   readonly revealed = signal(false);
   readonly completed = signal(false);
@@ -185,6 +186,16 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   };
 
   readonly current = computed(() => this.words()[this.currentIndex()] ?? null);
+  readonly completedSelectionWords = computed(() => this.words().filter((word) => this.selectionWordCompleted(word)));
+  readonly pendingSelectionWords = computed(() => this.words().filter((word) => !this.selectionWordCompleted(word)));
+  readonly newlyKnownWords = computed(() => this.words().filter((word) => !this.wordHistoricallyLearned(word) && this.selectionAssessment()[word.id] === 'known'));
+  readonly selectionActionLabel = computed(() => {
+    const hasLearningWords = this.selected().size > 0;
+    const hasKnownWords = this.newlyKnownWords().length > 0;
+    if (hasLearningWords && hasKnownWords) return '保存并开始学习';
+    if (hasKnownWords) return '保存识词结果';
+    return '开始学习';
+  });
   readonly currentQuestion = computed(() => this.questions()[this.currentIndex()] ?? null);
   readonly wordPreviewPageCount = computed(() => Math.max(1, Math.ceil(this.wordPreviewTotal() / this.wordPreviewPageSize)));
   readonly wordPreviewPages = computed(() => {
@@ -452,13 +463,26 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   toggleWord(word: Word): void {
     const shouldSelect = !this.selected().has(word.id);
     if (!this.setWordSelected(word, shouldSelect) || !this.isRefineSelectionPage) return;
-    this.setSelectionAssessment(word, shouldSelect ? 'unknown' : 'known');
+    if (shouldSelect) this.setSelectionAssessment(word, 'unknown');
+    else this.clearSelectionAssessment(word);
   }
 
   selectionWordRevealed(word: Word): boolean {
     return this.selectionRevealed().has(word.id);
   }
 
+  wordHistoricallyLearned(word: Word): boolean {
+    return Number(word['w_learned'] ?? 0) > 0;
+  }
+
+  selectionWordCompleted(word: Word): boolean {
+    return this.wordHistoricallyLearned(word) || this.selectionAssessment()[word.id] === 'known';
+  }
+
+  selectionCompletionLabel(word: Word): string {
+    return this.wordHistoricallyLearned(word) ? '历史已学完' : '本次已认识';
+  }
+
   revealSelectionWord(word: Word): void {
     this.selectionRevealed.update((current) => {
       const next = new Set(current);
@@ -487,9 +511,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   startSelected(): void {
-    const chosen = this.words().filter((word) => this.selected().has(word.id));
-    if (!chosen.length) {
-      this.platform.toast('请至少选择一个单词', 'error');
+    const chosen = this.pendingSelectionWords().filter((word) => this.selected().has(word.id));
+    const newlyKnown = this.newlyKnownWords();
+    if (!chosen.length && !newlyKnown.length) {
+      this.platform.toast('请先判断单词是否认识,或选择要学习的单词', 'error');
       return;
     }
 
@@ -500,13 +525,15 @@ export class LearningPageComponent implements OnInit, OnDestroy {
         return;
       }
       this.submitting.set(true);
-      this.api.post('e_add_words', {
-        uid: student.userId,
-        wordsId: chosen.map((word) => word.wordId).join(','),
-        kcid: this.query('pnid'),
-        save: 1,
-      }).pipe(finalize(() => this.submitting.set(false))).subscribe({
-        next: () => this.beginSelectedFlow(chosen, ''),
+      this.persistWordsAsLearned(newlyKnown, student).pipe(finalize(() => this.submitting.set(false))).subscribe({
+        next: () => {
+          if (newlyKnown.length) this.commitKnownWords(newlyKnown);
+          if (chosen.length) {
+            this.beginSelectedFlow(chosen, '');
+            return;
+          }
+          this.platform.toast('识词结果已保存', 'success');
+        },
         error: (error: ApiError) => this.platform.toast(error.message, 'error'),
       });
       return;
@@ -1252,7 +1279,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     };
     if (!existing['xxqs']) addon['fxrl'] = '';
     this.submitting.set(true);
-    this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) }).pipe(finalize(() => this.submitting.set(false))).subscribe({
+    this.persistWordsAsLearned(this.words(), owner).pipe(
+      switchMap(() => this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) })),
+      finalize(() => this.submitting.set(false)),
+    ).subscribe({
       next: () => {
         this.platform.toast('提交成功', 'success');
         void this.router.navigate(['/pages/member/myorder'], { queryParams: { pnid: this.query('pnid') } });
@@ -1386,12 +1416,15 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       xxqs: JSON.stringify(xxqs),
     };
     this.submitting.set(true);
-    this.api.post('stu_record_update_v2', {
-      orderId: this.query('orderId'),
-      uid: owner?.userId,
-      inputer: user?.honeyName,
-      addon: JSON.stringify(addon),
-    }).pipe(finalize(() => this.submitting.set(false))).subscribe({
+    this.persistWordsAsLearned(allWords, owner).pipe(
+      switchMap(() => this.api.post('stu_record_update_v2', {
+        orderId: this.query('orderId'),
+        uid: owner?.userId,
+        inputer: user?.honeyName,
+        addon: JSON.stringify(addon),
+      })),
+      finalize(() => this.submitting.set(false)),
+    ).subscribe({
       next: () => this.navigateStage('/pages/stu/pl_end'),
       error: (error: ApiError) => this.platform.toast(error.message, 'error'),
     });
@@ -1676,6 +1709,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.selected.set(new Set());
     this.selectionRevealed.set(new Set());
     this.selectionAssessment.set({});
+    this.activeSelectionBucket.set('pending');
     this.currentIndex.set(0);
     this.revealed.set(false);
     this.completed.set(false);
@@ -1764,6 +1798,26 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.selectionAssessment.update((current) => ({ ...current, [word.id]: assessment }));
   }
 
+  private clearSelectionAssessment(word: Word): void {
+    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> {
+    if (!words.length) return of(null);
+    return this.api.post('e_add_words', {
+      uid: owner?.userId,
+      wordsId: words.map((word) => word.wordId).join(','),
+      kcid: this.query('pnid') || this.query('kcid') || this.orderInfo()['kcid'],
+      save: 1,
+    });
+  }
+
+  private commitKnownWords(words: Word[]): void {
+    const ids = new Set(words.map((word) => word.id));
+    this.words.update((current) => current.map((word) => ids.has(word.id) ? { ...word, w_learned: Math.max(1, Number(word['w_learned'] ?? 0)) } : word));
+    this.selectionAssessment.update((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !ids.has(id))) as Record<string, 'known' | 'unknown'>);
+  }
+
   private findList(value: unknown): SourceItem[] {
     if (Array.isArray(value)) return value as SourceItem[];
     if (value && typeof value === 'object') {

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

@@ -801,12 +801,26 @@ app-learning-page .word-assessment button { display: inline-flex; min-width: 0;
 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 .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-soft); border-color: var(--accent); }
+app-learning-page .word-assessment .unknown-button.active { color: #4d3100; background: var(--accent-pale); border-color: var(--accent); }
+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)); }
+app-learning-page .word-selection-tabs button { display: flex; min-height: 58px; padding: 0 14px; align-items: center; justify-content: center; color: var(--ink-soft); background: white; border: 0; border-bottom: 3px solid transparent; font-size: 15px; font-weight: 700; gap: 8px; }
+app-learning-page .word-selection-tabs button + button { border-left: 1px solid var(--line); }
+app-learning-page .word-selection-tabs button.active { color: var(--brand-strong); background: var(--brand-wash); border-bottom-color: var(--brand-strong); }
+app-learning-page .word-selection-tabs b { min-width: 25px; padding: 3px 7px; color: inherit; background: var(--surface-muted); border-radius: 999px; font-size: 11px; }
+app-learning-page .word-selection-tabs button.active b { background: var(--brand-pale); }
+app-learning-page .word-column-help { min-height: 51px; margin: 0; padding: 10px 14px; color: var(--ink-soft); border-bottom: 1px solid var(--line); font-size: 11px; line-height: 1.45; }
+app-learning-page .word-complete-check { display: grid; width: 32px; height: 32px; color: white; background: var(--brand-strong); border-radius: 50%; place-items: center; }
+app-learning-page .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 { padding-left: 14px; } }
+@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) {