Преглед на файлове

fix: correct three-tap reading hints and add held swipe reveal

彭峰 преди 1 час
родител
ревизия
fd93e27c62

+ 32 - 0
docs/qa/three-tap-reading-2026-11-14.md

@@ -0,0 +1,32 @@
+# 三遍点读显示与手势修正 — 2026-11-14
+
+## 交付范围
+
+- 英文轮次第 1 次显示音标、第 2 次立即显示中文、第 3 次隐藏中文并保留音标;中文轮次第 1 次仅读音、第 2 次显示英文、第 3 次隐藏英文。
+- 提示直接由点击次数决定,不等待音频播放结束;音频失败、中断、迟到结束回调不影响点读提示。
+- 手动乱序、整轮结束、初始化/离开重载均清空临时提示。完成词额外点击只重播,次数仍封顶 3。
+- 主词、音标、释义从左至右排列;英中同字号,长词/长释义在各自区域换行。
+- 长按至少 450ms 后向左滑至少 40px 显示中文,松手保持;不计次数,不推进轮次。该词下一次普通点击收起临时提示,即使原顺序规则阻止其学习进度。中文轮次不会因手势额外显示英文。
+- 短划、右划、竖划、取消、第二指加入不触发中文;抑制手势附带的点击,保留随后普通点击与键盘点击。
+
+## 保持原样的规则
+
+一组最多 6 词,选择 10 词仍拆成 6+4。每组顺序英文、乱序英文、中文挑战三轮,每轮每词 3 次;三轮结束后依次配对、听音识意、组合拼写,再继续下一组或进入原完成流程。顺序限制、跳过规则、记录保存和历史学习遍数均未改动。
+
+## 验证记录
+
+- 定向组件/路由/手势回归:112 项通过。覆盖老师课程带学、老师生词本、学生生词本自学的三轮提示、重置与配对→听音→拼写路由。
+- 首次旧实现回归复现第二次点击未立即显示答案和未识别手势。测试夹具原误按选中10词期待首组10词,核对原始6词分组后修正夹具,未改产品分组。
+- 独立审查发现两项边缘缺陷:被顺序限制阻止的点击未清除临时提示、第二指加入未取消手势。两项均先新增/修改回归,观察失败,再修复。
+- 最终全移动端回归:396 项通过,进程退出码 0。
+- 相关服务端回归:26 项通过(批次、释义、词库发布、生词本、课程累计遍数)。
+- 最终移动端生产构建通过;现有组件样式体积和 PDF 依赖 CommonJS 警告仍存在。
+- 浏览器最终三个入口全流程通过,包含长词、长释义与纵向滚动,进程退出码 0。脚本 `scripts/check-three-tap-reading-browser.mjs` 使用本地产物、隔离 API 与音频夹具,屏蔽所有外部请求。
+
+## 证据与边界
+
+浏览器在 Chrome 中执行真实鼠标长按拖动与 CDP 触摸事件;检查 320/375/768px 的实际字号、右侧布局、溢出以及截图。每个入口选择10词,保留6+4分组,各完成90次正常点读、两组配对/听音/拼写;最后停在拼写完成页,不声称已做正式数据保存验收。
+
+没有操作正式账号或写入正式学习记录,未做实体手机/微信实机验收,未发布生产环境。Git 提交推送与部署分别记录。
+
+证据保存在 `/Users/mac/.local/state/xiaoshu/qa/2026-11-14-three-tap-reading/`,含最终日志、浏览器结果 JSON 与截图。

+ 35 - 0
docs/superpowers/plans/2026-11-14-three-tap-reading.md

@@ -0,0 +1,35 @@
+# Three-tap Reading Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement task-by-task. Steps use checkbox syntax.
+
+**Goal:** 修正提示、重置、同排同字号和长按左滑,不改变学习次数或环节。
+**Architecture:** 原共用 LearningPageComponent 保留 repetitions/studyStep;模板直接根据点击数显示提示。独立 PressSwipeLeftDirective 识别手势并拦截其合成点击;临时中文使用独立集合。
+**Tech Stack:** Angular 20, TypeScript, SCSS, Jasmine/Karma, Playwright.
+**Spec:** docs/superpowers/specs/2026-11-14-three-tap-reading-design.md
+
+## Global Constraints
+- 三轮、每词每轮三次不变;配对/听音/拼写/分组/保存及跳过行为不变。
+- 老师带学、老师生词本、学生自学使用同一显示规则;单词与中文同字号。
+- 保留工作区无关文件;按 AGENTS.md 仅提交本次文件并推送当前跟踪分支,不部署。
+
+## Review Focus
+- 快速点击、音频失败与迟到回调不可改变提示。
+- 轮次/手动乱序后不残留手势提示。
+- 长按左滑不计次数;短划、右划、竖向滚动、取消、多指不误触。
+- 320/375/768px 长词长释义不遮挡、提示在右边、字号一致。
+- 第三轮结束后环节及入口身份不变。
+
+### Task 1: 点读显示与手势
+**Files:** learning-page.component.ts/html/scss, styles.scss; shared/press-swipe-left.directive.ts; core/home-learning-modules.integration.spec.ts; shared/press-swipe-left.directive.spec.ts.
+**Interfaces:** directive selector [appPressSwipeLeft], output pressSwipeLeft; component revealStudyMeaning(word), studyMeaningRevealed set.
+- [x] 修改三入口路由回归:第二次立即显示、第三次隐藏、跨三轮重置、三轮后进入配对。
+- [x] 新增手势 DOM 测试:450ms 长按 + 左移40px;非目标手势不触发;抑制合成点击但允许随后点击/键盘。
+- [x] 运行测试确认旧实现失败,再实现最小修改。
+- [x] 运行移动端回归和移动端生产构建。
+
+### Task 2: 浏览器验收与交付
+**Files:** scripts/check-three-tap-reading-browser.mjs; docs/qa/three-tap-reading-2026-11-14.md.
+**Interfaces:** 使用生产构建与本地隔离 API,三个入口完整三轮、配对/听音/拼写流程。
+- [x] 检查三个入口及320/375/768px布局、鼠标和触摸手势,保存本地证据。
+- [x] 独立复查差异,完成相应回归,记录明确验证边界。
+- [ ] 仅提交本次修改并推送当前跟踪分支。

+ 11 - 0
docs/superpowers/specs/2026-11-14-three-tap-reading-design.md

@@ -0,0 +1,11 @@
+# 三遍点读显示修正(用户已确认)
+
+保留一组单词的三个点读轮次:顺序英文、乱序英文、乱序中文,每轮每词三次;之后配对、听音识意、组合拼写、分组/完成流转、跳过规则、累计遍数均不变。
+
+英文模式点击 1 显示音标、2 显示中文、3 隐藏中文且保留音标;中文模式点击 1 播放读音、2 显示英文、3 隐藏英文。显示立即跟随点击,不依赖音频成功或结束。完成词再次点击只重播,不增加进度。整轮完成与手动乱序均重置提示;仅整轮完成推进原有轮次。
+
+主词、音标、释义从左至右排在同一行;长释义只在右侧区域换行。英文和中文同字号。长按左滑显示中文,松手保留,不计次数、不推进环节;该词下次普通点击恢复正常提示。中文轮次不会因手势额外显示英文。
+
+方案选择:使用已有共用点读页面统一修正,避免不同入口出现不同学习规则;音频结束显义方案无法满足点击即显示,故不采用。新增小型独立手势指令处理长按、方向、滚动及点击抑制。
+
+验证老师课程带学、老师生词本、学生生词本自学的真实组件路由;本地浏览器使用隔离接口,不写入正式学习记录。正式部署和手机实机结果单独说明。

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

@@ -2824,7 +2824,7 @@ describe('home learning modules', () => {
   }
 
   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 () => {
+    it(`shows hints immediately, resets all three rounds and preserves the practice flow for ${entry}`, async () => {
       let harness: RouterTestingHarness;
       const platform = TestBed.inject(PlatformService);
       spyOn(platform, 'toast');
@@ -2840,7 +2840,7 @@ describe('home learning modules', () => {
         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ə/'}]}]});
+          if (action === 'e_words_list') return of({retcode:0,result:Array.from({length:2},(_,i)=>({GeneralID:601+i,detail:[{GeneralID:701+i,Title:'word-'+i,sy:'释义'+i,yb:'/wɜːd/'}]}))});
           return original(action,params);
         }) as typeof api.get);
         harness = await RouterTestingHarness.create('/pages/member/starshop' + (entry === 'coach-wordbook' ? '?wordbookStudentId=100' : ''));
@@ -2858,22 +2858,62 @@ describe('home learning modules', () => {
       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)');
+        const answer = () => row.querySelector('.study-answer, 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();
         play.calls.mostRecent().args[2]!.onEnded();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()).toBeNull();
-        play.calls.mostRecent().args[2]!.onEnded();harness.fixture.detectChanges();expect(answer()?.textContent).toBe(step===3 ? first.word : first.meaning);
+        row.click();harness.fixture.detectChanges();expect(answer()?.textContent).toBe(step===3 ? first.word : first.meaning);
+        const lateEnded=play.calls.mostRecent().args[2]!.onEnded;
+        lateEnded();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);
+        lateEnded();harness.fixture.detectChanges();expect(answer()).toBeNull();
         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);}
+        for(const [index,word] of component.words().entries())if(index>0)for(let tap=0;tap<3;tap++)component.repeatWord(word,index);
+        if(step<3){expect(component.repetitions()).toEqual({});harness.fixture.detectChanges();expect(harness.routeNativeElement!.querySelector('.study-answer')).toBeNull();}
       }
+      await harness.fixture.whenStable();harness.fixture.detectChanges();
+      expect(TestBed.inject(Router).url).toContain('/pages/stu/stu3');
+      const stage=()=>harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+      for(const word of [...stage().matchLeft()]){stage().chooseMatch('left',word);stage().chooseMatch('right',word);}
+      stage().continueAfterStage();await harness.fixture.whenStable();harness.fixture.detectChanges();
+      expect(TestBed.inject(Router).url).toContain('/pages/stu/stu4');
+      for(let i=0;i<2;i++)stage().chooseMeaning(stage().current()!);
+      stage().continueAfterStage();await harness.fixture.whenStable();harness.fixture.detectChanges();
+      expect(TestBed.inject(Router).url).toContain(entry==='student-wordbook'?'/pages/stu/stu5':'/pages/stu/pl_stu5');
     });
   }
 
+  it('reveals Chinese with a held left swipe without consuming a tap and resets it on shuffle', async () => {
+    const {harness,component:selection}=await teacherSelection(2,0,'901');
+    selection.selected.set(new Set(selection.words().map(word=>word.id)));selection.startSelected();
+    await harness.fixture.whenStable();harness.fixture.detectChanges();
+    const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    spyOn(TestBed.inject(PlatformService),'play').and.resolveTo('played');
+    component.words.update(words=>words.map(word=>({...word,audio:'course.mp3'})));
+    const row=()=>harness.routeNativeElement!.querySelector<HTMLButtonElement>('.repeat-list > button')!;
+    const swipe=()=>{
+      const target=row();
+      for(const [type,x,time] of [['pointerdown',150,0],['pointermove',100,500],['pointerup',100,510]] as const){
+        const event=new PointerEvent(type,{bubbles:true,button:0,pointerId:1,isPrimary:true,clientX:x,clientY:30});
+        Object.defineProperty(event,'timeStamp',{value:time});target.dispatchEvent(event);
+      }
+      target.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true,detail:1}));harness.fixture.detectChanges();
+    };
+    swipe();expect(row().querySelector('.study-answer')?.textContent).toBe(component.words()[0].meaning);
+    expect(component.repetitions()).toEqual({});expect(component.studyStep()).toBe(1);
+    row().click();harness.fixture.detectChanges();expect(row().querySelector('.study-answer')).toBeNull();
+    expect(component.repetitions()[component.words()[0].id]).toBe(1);
+    swipe();component.shuffleStudyWords();harness.fixture.detectChanges();
+    expect(row().querySelector('.study-answer')).toBeNull();expect(component.repetitions()).toEqual({});expect(component.studyStep()).toBe(1);
+    component.studyStep.set(3);harness.fixture.detectChanges();swipe();
+    expect(row().querySelector('.study-answer')).toBeNull();expect(component.repetitions()).toEqual({});
+  });
+
+
+
   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.

+ 9 - 0
projects/xiaoshu-mobile/src/app/core/word-audio.service.spec.ts

@@ -56,6 +56,15 @@ describe('学习页面自动读音', () => {
     component = TestBed.runInInjectionContext(() => new LearningPageComponent());
     component.words.set([word()]);
   });
+  it('顺序限制仍阻止后续单词计次,但普通点击会清除该词的临时中文提示', () => {
+    component.meta.path = 'pages/stu/stu_con';
+    const second=word('102','second.mp3');component.words.set([word('101','first.mp3'),second]);
+    component.revealStudyMeaning(second);expect(component.studyMeaningRevealed().has(second.id)).toBeTrue();
+    component.repeatWord(second,1);
+    expect(component.studyMeaningRevealed().has(second.id)).toBeFalse();
+    expect(component.repetitions()).toEqual({});expect(component.studyStep()).toBe(1);
+    expect(platform.play).not.toHaveBeenCalled();
+  });
   it('已有正常音频直接播放,空白外层字段仍使用内层已有音频', async () => {
     const mapped = (component as any).toWord({ GeneralID: 101, yp: ' ', detail: { yp: 'apple.mp3' } });
     expect(mapped.audio).toBe('apple.mp3');

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

@@ -272,11 +272,13 @@
     <div class="repeat-list">
       @for (word of words(); track word.id; let index = $index) {
         @let repeatCount = repetitions()[word.id] || 0;
-        <button type="button" (click)="repeatWord(word, index)">
-          <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 && selectionWordRevealed(word)) { <small>{{ studyStep() === 3 ? word.word : word.meaning }}</small> }
+        <button type="button" appPressSwipeLeft (pressSwipeLeft)="revealStudyMeaning(word)" (click)="repeatWord(word, index)">
+          <span class="study-copy">
+            <strong class="study-headword">{{ studyStep() === 3 ? word.meaning : word.word }}</strong>
+            <span class="study-hints">
+              @if (studyStep() !== 3 && repeatCount >= 1 && word.phonetic) { <span class="study-phonetic">{{ word.phonetic }}</span> }
+              @if (repeatCount === 2 || (studyStep() !== 3 && studyMeaningRevealed().has(word.id))) { <span class="study-answer">{{ studyStep() === 3 ? word.word : word.meaning }}</span> }
+            </span>
           </span>
           <span class="repeat-dots" aria-label="跟读次数">
             @for (dot of [1, 2, 3]; track dot) { <i [class.done]="repeatCount >= dot"></i> }

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

@@ -305,8 +305,7 @@ button {
   gap: 12px;
 }
 
-.node-list button .node-copy,
-.repeat-list button > span:first-child {
+.node-list button .node-copy {
   display: flex;
   min-width: 0;
   flex-direction: column;

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

@@ -3,6 +3,7 @@ import { WordAudioService } from '../../core/word-audio.service';
 import { lessonDateLabel } from '../appointment/lesson-display';
 import { TeacherTodayReviewService } from '../../core/teacher-today-review.service';
 import { lessonStudySources } from '../appointment/lesson-study-sources';
+import { PressSwipeLeftDirective } from '../../shared/press-swipe-left.directive';
 import { DropdownDirective } from '../../shared/dropdown.directive';
 import { LearningDraft } from '../../core/learning-draft';
 import { ReviewPage, reviewCalendar } from '../../core/review-page';
@@ -119,7 +120,7 @@ const WORD_CATALOG_CATEGORIES = [
 @Component({
   selector: 'app-learning-page',
   standalone: true,
-  imports: [DropdownDirective, CommonModule, LucideAngularModule, RouterLink],
+  imports: [PressSwipeLeftDirective, DropdownDirective, CommonModule, LucideAngularModule, RouterLink],
   templateUrl: './learning-page.component.html',
   styleUrl: './learning-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
@@ -193,6 +194,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   readonly studyStep = signal<1 | 2 | 3>(1);
   readonly repetitions = signal<Record<string, number>>({});
+  readonly studyMeaningRevealed = signal<Set<string>>(new Set());
   readonly matchLeft = signal<Word[]>([]);
   readonly matchRight = signal<Word[]>([]);
   readonly leftChoice = signal('');
@@ -973,7 +975,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
           onEnded: () => {
             if (!active()) return;
             if (this.isHistoryWordView) this.historyWordRevealed.update(current => new Set(current).add(word.id));
-            else if (!this.isStudyListPage || this.repetitions()[word.id] === 2) this.selectionRevealed.update(current => new Set(current).add(word.id));
+            else if (!this.isStudyListPage) this.selectionRevealed.update(current => new Set(current).add(word.id));
             this.historyAudioWordId.set('');
           },
           onError: () => {
@@ -1024,7 +1026,13 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.play(word);
   }
 
+  revealStudyMeaning(word: Word): void {
+    if (this.studyStep() === 3) return;
+    this.studyMeaningRevealed.update(current => new Set(current).add(word.id));
+  }
+
   repeatWord(word: Word, index: number): void {
+    this.studyMeaningRevealed.update(current => new Set([...current].filter(id => id !== word.id)));
     const count = this.repetitions()[word.id] ?? 0;
     if (this.studyStep() === 1 && index > 0) {
       const previous = this.words()[index - 1];
@@ -1033,7 +1041,6 @@ export class LearningPageComponent implements OnInit, OnDestroy {
         return;
       }
     }
-    this.selectionRevealed.update(current => new Set([...current].filter(id => id !== word.id)));
     this.play(word);
     if (count < 3) {
       this.repetitions.update((value) => ({ ...value, [word.id]: count + 1 }));
@@ -1048,6 +1055,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.selectionRevealed.set(new Set());
     this.words.set(shuffledStudy(this.words()));
     this.repetitions.set({});
+    this.studyMeaningRevealed.set(new Set());
   }
 
   skipStudyList(): void {
@@ -1736,6 +1744,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     if (this.isStudyListPage) {
       this.studyStep.set(1);
       this.repetitions.set({});
+      this.studyMeaningRevealed.set(new Set());
     } else if (this.isMatchingPage) {
       this.matchLeft.set(this.shuffle(words));
       this.matchRight.set(this.shuffle(words));
@@ -1754,9 +1763,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private finishStudyRound(): void {
     if (this.studyStep() < 3) {
       const next = (this.studyStep() + 1) as 2 | 3;
+      this.selectionRevealed.set(new Set());
       this.studyStep.set(next);
       this.words.set(shuffledStudy(this.words()));
       this.repetitions.set({});
+      this.studyMeaningRevealed.set(new Set());
       this.platform.toast(next === 2 ? '进入乱序学习' : '进入中文挑战', 'success');
       return;
     }
@@ -2473,6 +2484,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.unknown.set([]);
     this.studyStep.set(1);
     this.repetitions.set({});
+    this.studyMeaningRevealed.set(new Set());
     this.matchLeft.set([]);
     this.matchRight.set([]);
     this.leftChoice.set('');

+ 40 - 0
projects/xiaoshu-mobile/src/app/shared/press-swipe-left.directive.spec.ts

@@ -0,0 +1,40 @@
+import { Component } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { PressSwipeLeftDirective } from './press-swipe-left.directive';
+
+@Component({standalone:true, imports:[PressSwipeLeftDirective], template:'<button appPressSwipeLeft (pressSwipeLeft)="reveals=reveals+1" (click)="clicks=clicks+1">word</button>'})
+class Host { reveals=0; clicks=0; }
+
+describe('长按左滑',()=>{
+  function setup(){
+    const fixture=TestBed.createComponent(Host);fixture.detectChanges();
+    const button:HTMLButtonElement=fixture.nativeElement.querySelector('button');
+    const pointer=(type:string,x:number,y:number,time:number,id=1,primary=true)=>{
+      const event=new PointerEvent(type,{bubbles:true,cancelable:true,clientX:x,clientY:y,pointerId:id,isPrimary:primary,button:0});
+      Object.defineProperty(event,'timeStamp',{value:time});button.dispatchEvent(event);
+    };
+    const click=()=>button.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true,detail:1}));
+    return {fixture,button,host:fixture.componentInstance,pointer,click};
+  }
+  it('450ms后左移40px仅显示一次,拦截合成点击,下一次正常点击仍可用',()=>{
+    const {host,pointer,click}=setup();
+    pointer('pointerdown',150,30,0);pointer('pointermove',110,30,450);pointer('pointermove',90,30,470);pointer('pointerup',90,30,500);click();
+    expect(host.reveals).toBe(1);expect(host.clicks).toBe(0);
+    pointer('pointerdown',150,30,600);pointer('pointerup',150,30,650);click();expect(host.clicks).toBe(1);
+  });
+  for(const [name,x,y,time] of [['短划',100,30,100],['右划',210,30,500],['竖向滚动',145,80,500],['距离不足',135,30,500]] as const){
+    it(name+'不显示中文且不误算点击',()=>{
+      const {host,pointer,click}=setup();pointer('pointerdown',150,30,0);pointer('pointermove',x,y,time);pointer('pointerup',x,y,time+10);click();
+      expect(host.reveals).toBe(0);expect(host.clicks).toBe(0);
+    });
+  }
+  it('取消与非主指针不触发;键盘点击继续工作',()=>{
+    const {host,button,pointer,click}=setup();
+    pointer('pointerdown',150,30,0);pointer('pointercancel',150,30,500);pointer('pointermove',100,30,510);click();expect(host.reveals).toBe(0);expect(host.clicks).toBe(0);
+    pointer('pointerdown',150,30,600,2,false);pointer('pointermove',100,30,1100,2,false);expect(host.reveals).toBe(0);
+    button.click();expect(host.clicks).toBe(1);
+  });
+  it('另一根手指加入会取消待识别手势',()=>{
+    const {host,pointer}=setup();pointer('pointerdown',150,30,0);pointer('pointerdown',200,30,100,2,false);pointer('pointermove',110,30,450);expect(host.reveals).toBe(0);
+  });
+});

+ 76 - 0
projects/xiaoshu-mobile/src/app/shared/press-swipe-left.directive.ts

@@ -0,0 +1,76 @@
+import { DestroyRef, Directive, ElementRef, EventEmitter, HostListener, inject, Output } from '@angular/core';
+
+/** A deliberate hold then left swipe; vertical scrolling remains native (touch-action: pan-y). */
+@Directive({ selector: '[appPressSwipeLeft]', standalone: true })
+export class PressSwipeLeftDirective {
+  @Output() readonly pressSwipeLeft = new EventEmitter<void>();
+  private readonly element = inject(ElementRef<HTMLElement>).nativeElement;
+  private gesture?: { id: number; x: number; y: number; started: number; revealed: boolean };
+  private suppressClick = false;
+
+  constructor() {
+    // Capture before Angular's normal click handler so a swipe never counts as a reading tap.
+    const onClick = (event: MouseEvent) => {
+      if (this.suppressClick && event.detail !== 0) {
+        event.preventDefault();
+        event.stopImmediatePropagation();
+      }
+      this.suppressClick = false;
+    };
+    this.element.addEventListener('click', onClick, true);
+    inject(DestroyRef).onDestroy(() => this.element.removeEventListener('click', onClick, true));
+  }
+
+  @HostListener('pointerdown', ['$event'])
+  onPointerDown(event: PointerEvent): void {
+    if (!event.isPrimary || event.button !== 0) return;
+    this.suppressClick = false;
+    this.gesture = { id: event.pointerId, x: event.clientX, y: event.clientY, started: event.timeStamp, revealed: false };
+    // Synthetic events in tests have no active native pointer to capture.
+    if (event.isTrusted) this.element.setPointerCapture(event.pointerId);
+  }
+
+  @HostListener('document:pointerdown', ['$event'])
+  cancelForAdditionalPointer(event: PointerEvent): void {
+    if (event.isPrimary || !this.gesture) return;
+    this.gesture = undefined;
+    this.suppressClick = true;
+  }
+
+  @HostListener('pointermove', ['$event'])
+  onPointerMove(event: PointerEvent): void {
+    const gesture = this.gesture;
+    if (!gesture || gesture.id !== event.pointerId || gesture.revealed) return;
+    const dx = event.clientX - gesture.x, dy = event.clientY - gesture.y;
+    const held = event.timeStamp - gesture.started >= 450;
+    if (Math.abs(dy) > 18 || dx > 18 || (!held && Math.hypot(dx, dy) > 10)) {
+      this.suppressClick = true;
+      this.gesture = undefined;
+      return;
+    }
+    if (held && dx <= -40) {
+      this.suppressClick = true;
+      gesture.revealed = true;
+      this.pressSwipeLeft.emit();
+    }
+  }
+
+  @HostListener('pointerup', ['$event'])
+  onPointerUp(event: PointerEvent): void {
+    this.onPointerMove(event);
+    if (this.gesture?.id !== event.pointerId) return;
+    this.suppressClick ||= event.timeStamp - this.gesture.started >= 450;
+    this.gesture = undefined;
+  }
+
+  @HostListener('pointercancel', ['$event'])
+  @HostListener('lostpointercapture', ['$event'])
+  onPointerCancel(event: PointerEvent): void {
+    if (this.gesture?.id !== event.pointerId) return;
+    this.gesture = undefined;
+    this.suppressClick = true;
+  }
+
+  @HostListener('contextmenu', ['$event'])
+  onContextMenu(event: Event): void { event.preventDefault(); }
+}

+ 18 - 3
projects/xiaoshu-mobile/src/styles.scss

@@ -476,10 +476,25 @@ app-learning-page .node-list .unit-progress-ring strong { z-index: 1; font-size:
 app-learning-page .unit-actions { grid-template-columns: minmax(0, 1fr) minmax(150px, 1.4fr); align-items: center; }
 app-learning-page .unit-actions > span { color: var(--ink-soft); font-size: 13px; text-align: center; }
 
-app-learning-page .repeat-list .study-headword { font-size: 1.5em; line-height: 1.25; }
+app-learning-page .repeat-list > button {
+  touch-action: pan-y;
+  user-select: none;
+  -webkit-user-select: none;
+  -webkit-touch-callout: none;
+}
+app-learning-page .repeat-list .study-copy {
+  display: flex;
+  flex: 1;
+  min-width: 0;
+  align-items: baseline;
+  gap: 12px;
+}
 app-learning-page .repeat-list .study-headword,
-app-learning-page .repeat-list button small { overflow-wrap: anywhere; }
-app-learning-page .repeat-list .study-phonetic { font-size: 1em; }
+app-learning-page .repeat-list .study-answer { font-size: 1.5em; line-height: 1.4; overflow-wrap: anywhere; }
+app-learning-page .repeat-list .study-headword { flex: 0 1 auto; max-width: 45%; }
+app-learning-page .repeat-list .study-hints { flex: 1; min-width: 0; color: var(--ink-soft); overflow-wrap: anywhere; }
+app-learning-page .repeat-list .study-phonetic { font-size: 1em; line-height: 1.4; }
+app-learning-page .repeat-list .study-phonetic + .study-answer { margin-left: 10px; }
 app-learning-page .repeat-list .repeat-dots { flex: 0 0 auto; }
 
 app-resource-page .resource-page > :is(.description, .order-context, .action-row, .tabs, .state-line, .error-state, .resource-list, .detail-list, .empty-state),

+ 126 - 0
scripts/check-three-tap-reading-browser.mjs

@@ -0,0 +1,126 @@
+// Local production build + isolated API fixtures. Never sends requests to production.
+import { chromium } from 'playwright';
+import { createServer } from 'node:http';
+import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
+import { resolve, extname } from 'node:path';
+import assert from 'node:assert/strict';
+const root=resolve('dist/xiaoshu-mobile/browser');
+const out=resolve(process.env.THREE_TAP_QA_DIR || '/tmp/xiaoshu-three-tap-browser');mkdirSync(out,{recursive:true});
+const server=createServer((req,res)=>{
+ let file=resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://local').pathname));
+ if(!file.startsWith(root+'/')||!existsSync(file)||!extname(file))file=root+'/index.html';
+ res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml'})[extname(file)]||'application/octet-stream');res.end(readFileSync(file));
+});
+await new Promise(done=>server.listen(0,'127.0.0.1',done));const origin='http://127.0.0.1:'+server.address().port;
+const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
+const words=['apple','banana','orange','pear','grape','peach','melon','lemon','mango','plum'].map((Title,i)=>({GeneralID:701+i,NodeID:811,Title,sy:['苹果','香蕉','橙子','梨','葡萄','桃子','甜瓜','柠檬','芒果','李子'][i],yb:'/wɜːd/',yp:origin+'/fixtures/'+(701+i)+'.wav',w_learned:0}));
+const wav=Buffer.alloc(44+3200);wav.write('RIFF');wav.writeUInt32LE(wav.length-8,4);wav.write('WAVEfmt ',8);wav.writeUInt32LE(16,16);wav.writeUInt16LE(1,20);wav.writeUInt16LE(1,22);wav.writeUInt32LE(16000,24);wav.writeUInt32LE(32000,28);wav.writeUInt16LE(2,32);wav.writeUInt16LE(16,34);wav.write('data',36);wav.writeUInt32LE(3200,40);
+words[0]={...words[0],Title:'characteristically',sy:'典型地;具有特征地;用于验证较长中文释义在右侧区域换行'};
+const report=[];
+async function until(check,message){for(let i=0;i<100;i++){if(await check())return;await new Promise(r=>setTimeout(r,50));}throw Error(message);}
+try{
+ for(const entry of ['course','coach-wordbook','student-wordbook']){
+  const errors=[],calls=[];const teacher=entry!=='student-wordbook';
+  const context=await browser.newContext({viewport:{width:375,height:812},hasTouch:true,reducedMotion:'reduce'});
+  await context.addInitScript(({teacher})=>{
+   localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:local-three-tap-fixture',user:{userId:teacher?300:100,groupId:teacher?3:1,honeyName:'本地测试'},student:teacher?{userId:100,groupId:1,honeyName:'测试学员'}:null}));
+   HTMLMediaElement.prototype.play=function(){window.__lastAudio=this.src;return Promise.resolve();};
+   HTMLMediaElement.prototype.load=function(){};
+  },{teacher});
+  await context.route('**/*',async route=>{
+   const url=new URL(route.request().url());if(url.origin!==origin)return route.abort();
+   if(url.pathname.startsWith('/fixtures/'))return route.fulfill({body:wav,contentType:'audio/wav'});
+   if(!url.pathname.startsWith('/cloud-functions/'))return route.continue();
+   const {params:p}=route.request().postDataJSON();calls.push(p);let result=[],page;
+   if(p.action==='app_session_validate')result={valid:true};
+   else if(p.action==='app_live_versions')result={version:'1',scopeVersions:{},syncStatus:{state:'current'}};
+   else if(p.action==='e_user_list')result=[{userId:100,honeyName:'测试学员'}];
+   else if(p.action==='e_ck_list'){result=words;page={itemCount:10,pageCount:1,passCounts:[10,0,0,0],bookPassCounts:[10,0,0,0]};}
+   else if(p.action==='e_words_list'){result=words.map((w,i)=>({GeneralID:601+i,xxcs:1,detail:[w]}));page={itemCount:10,pageCount:1,pendingCount:10,completedCount:0};}
+   else if(p.action==='stu_record_update_v2')result={batchId:p.batchId,batchVersion:1,learned:0};
+   else if(p.action==='content_list'&&p.orders==='NEWID()')result=Array.from({length:8},(_,i)=>({GeneralID:9000+i,Title:'external'+i,sy:'外部释义'+i}));
+   else if(p.action==='app_word_audio_ensure')result={state:'ready',url:origin+'/fixtures/'+p.wordId+'.wav'};
+   return route.fulfill({json:{retcode:0,result,...(page?{page}:{})}});
+  });
+  const page=await context.newPage();
+  const settle=()=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+  page.on('pageerror',e=>errors.push(e.message));
+  await page.goto(origin+(entry==='course'?'/pages/stu/stu_abc?nids=811&pnid=801&orderId=901':'/pages/member/starshop'+(teacher?'?wordbookStudentId=100':'')));
+  const select=entry==='course'?'.word-select-check':'.word-select-list > button';
+  await page.locator(select).first().waitFor();assert.equal(await page.locator(select).count(),10);
+  for(const button of await page.locator(select).all())await button.click();
+  await page.locator('.selection-header .primary-button').click();await page.locator('.repeat-list > button').first().waitFor();
+  assert.equal(await page.locator('.repeat-list > button').count(),6,'existing six-word grouping');
+  const row=()=>page.locator('.repeat-list > button').first();
+  const firstWord=await row().locator('strong').innerText();const firstMeaning=words.find(w=>w.Title===firstWord).sy;
+  // Actual mouse hold/left drag; generated click must not consume a reading tap.
+  let box=await row().boundingBox();await page.mouse.move(box.x+box.width-30,box.y+25);await page.mouse.down();await new Promise(r=>setTimeout(r,480));await page.mouse.move(box.x+box.width-90,box.y+25,{steps:4});await page.mouse.up();
+  assert.equal(await row().locator('.study-answer').innerText(),firstMeaning);assert.equal(await row().locator('.repeat-dots .done').count(),0);
+  await row().click();await settle();assert.equal(await row().locator('.study-answer').count(),0);assert.equal(await row().locator('.repeat-dots .done').count(),1);
+  // Real touch gesture via CDP, including native scroll cancellation on vertical movement.
+  const cdp=await context.newCDPSession(page);box=await row().boundingBox();
+  const touch=async(type,x,y)=>cdp.send('Input.dispatchTouchEvent',{type,touchPoints:type==='touchEnd'?[]:[{x,y}]});
+  await touch('touchStart',box.x+box.width-30,box.y+25);await new Promise(r=>setTimeout(r,480));await touch('touchMove',box.x+box.width-90,box.y+25);await touch('touchEnd');await settle();
+  assert.equal(await row().locator('.study-answer').innerText(),firstMeaning);assert.equal(await row().locator('.repeat-dots .done').count(),1);
+  await page.getByRole('button',{name:'重新乱序',exact:true}).click();await settle();assert.equal(await page.locator('.study-answer').count(),0);assert.equal(await page.locator('.repeat-dots .done').count(),0);
+  await page.setViewportSize({width:375,height:420});await page.evaluate(()=>window.scrollTo(0,0));await settle();
+  box=await row().boundingBox();
+  const scrollPosition=()=>page.evaluate(()=>window.scrollY+Array.from(document.querySelectorAll('*')).reduce((n,e)=>n+e.scrollTop,0));
+  const beforeScroll=await scrollPosition();
+  await touch('touchStart',box.x+box.width/2,box.y+35);
+  for(let offset=15;offset<=100;offset+=15){await touch('touchMove',box.x+box.width/2,box.y+35-offset);await new Promise(r=>setTimeout(r,20));}
+  await touch('touchEnd');await settle();
+  assert.ok(await scrollPosition()>beforeScroll,'vertical touch still scrolls');
+  assert.equal(await page.locator('.study-answer').count(),0);assert.equal(await page.locator('.repeat-dots .done').count(),0);
+  await page.setViewportSize({width:375,height:812});await page.evaluate(()=>window.scrollTo(0,0));await settle();
+  let readingTaps=0;
+  for(const groupSize of [6,4]){
+   for(const step of [1,2,3]){
+    assert.match(await page.locator('.stage-banner').innerText(),new RegExp('第 '+step+' / 3 轮'));
+    assert.equal(await page.locator('.study-answer').count(),0);assert.equal(await page.locator('.study-phonetic').count(),0);
+    const sequence=await page.locator('.study-headword').allTextContents();
+    for(let index=0;index<groupSize;index++){
+     const target=page.locator('.repeat-list > button').nth(index);const word=words.find(w=>(step===3?w.sy:w.Title)===sequence[index]);assert.ok(word);
+     await target.click();await settle();readingTaps++;assert.equal(await target.locator('.study-answer').count(),0);
+     assert.equal(await target.locator('.study-phonetic').count(),step===3?0:1);
+     await target.click();await settle();readingTaps++;assert.equal(await target.locator('.study-answer').innerText(),step===3?word.Title:word.sy);
+     if((index===0||word.GeneralID===701)&&groupSize===6){
+      for(const width of [320,375,768]){
+       await page.setViewportSize({width,height:900});
+       const geometry=await target.evaluate(el=>{const head=el.querySelector('.study-headword'),answer=el.querySelector('.study-answer'),hints=el.querySelector('.study-hints');const a=head.getBoundingClientRect(),b=hints.getBoundingClientRect();return {headSize:getComputedStyle(head).fontSize,answerSize:getComputedStyle(answer).fontSize,right:b.x>=a.right,overflow:document.documentElement.scrollWidth>innerWidth};});
+       assert.equal(geometry.headSize,geometry.answerSize);assert.ok(geometry.right);assert.equal(geometry.overflow,false);
+       await page.screenshot({path:out+'/'+entry+'-round-'+step+'-'+width+(word.GeneralID===701?'-long':'')+'.png'});
+      }
+      await page.setViewportSize({width:375,height:812});
+     }
+     await target.click();await settle();readingTaps++;
+     if(index<groupSize-1){assert.equal(await target.locator('.study-answer').count(),0);assert.equal(await target.locator('.repeat-dots .done').count(),3);}
+    }
+   }
+   await page.waitForURL('**/pages/stu/stu3**');
+   for(let i=0;i<groupSize;i++){
+    const title=await page.locator('.match-grid > div').first().locator('button').first().innerText();const word=words.find(w=>w.Title===title);
+    await page.locator('.match-grid > div').first().getByRole('button',{name:title,exact:true}).click();
+    await page.locator('.match-grid > div').nth(1).getByRole('button',{name:word.sy,exact:true}).click();await settle();
+   }
+   await page.getByRole('button',{name:'进入听音识意'}).click();await page.waitForURL('**/pages/stu/stu4**');
+   for(let i=0;i<groupSize;i++){
+    await until(async()=>/\/fixtures\/7\d+\.wav$/.test(await page.evaluate(()=>window.__lastAudio||'')),'listening audio');
+    const src=await page.evaluate(()=>window.__lastAudio),id=Number(src.match(/(\d+)\.wav$/)[1]),word=words.find(w=>w.GeneralID===id);assert.ok(word);
+    await page.evaluate(()=>window.__lastAudio='');
+    await page.locator('.option-list').getByRole('button',{name:word.sy,exact:true}).click();await settle();
+   }
+   await page.getByRole('button',{name:'进入组合拼写'}).click();await page.waitForURL(teacher?'**/pages/stu/pl_stu5**':'**/pages/stu/stu5**');
+   for(let i=0;i<groupSize;i++){
+    const meaning=await page.locator('.spelling-card p').innerText(),word=words.find(w=>w.sy===meaning);assert.ok(word);
+    for(const letter of word.Title){await page.locator('.letter-grid button:enabled').filter({hasText:new RegExp('^'+letter+'$')}).first().click();await settle();}
+    await page.locator('.sticky-actions .primary-button').click();await settle();
+   }
+   if(groupSize===6){await page.getByRole('button',{name:'学习下一组单词'}).click();await page.locator('.repeat-list > button').first().waitFor();assert.equal(await page.locator('.repeat-list > button').count(),4);}
+  }
+  assert.equal(readingTaps,90);assert.deepEqual(errors,[]);
+  report.push({entry,passed:true,readingTaps,groups:[6,4],roundsPerGroup:3,stages:['point reading','matching','listening','spelling'],mouseAndTouchSwipe:true,verticalScroll:true,longLabels:true,widths:[320,375,768],productionRequests:0});
+  await context.close();
+ }
+ writeFileSync(out+'/results.json',JSON.stringify({environment:'local production build with isolated API and audio fixtures',report},null,2));console.log(JSON.stringify(report,null,2));
+}finally{await browser.close();await new Promise(done=>server.close(done));}