Jelajahi Sumber

perf(learning): streamline course words and reveal answers after audio

彭峰 13 jam lalu
induk
melakukan
0b7d9d5f89

+ 8 - 0
perf/course-word-2026-11-12/BRIEF.md

@@ -0,0 +1,8 @@
+# 正式课程词汇学习优化
+
+- 覆盖老师课程选词、顺序学习、乱序学习,中文挑战保留中文提示和第三次隐藏答案。
+- 选词页能用:真实词条及四栏计数出现,认识/不认识和勾选可操作;点读页能用:真实词条显示、点读立即计数并播放。
+- 释义仅在对应读音完成后显示;第二次点读的答案也在读音结束后出现,第三次继续隐藏,首点音标规则保留。
+- 性能先测真实网关及数据库,再用同条件生产构建、冷上下文、Fast 4G(20ms/4Mbps/3Mbps)交替各10次;以实测瓶颈决定优化,不牺牲学员/帐套/课程隔离、四栏进度、20/50/100选词及课堂批次。
+- 目标为打开到能操作 p75 降低50%;若基线已较快则报告真实收益,避免夸大或添加无证据的缓存。
+- 用户明确要求直接上线;备份原函数及完整静态清单,选择性发布并核对线上代码、资源哈希和交互,保留回滚版本。

+ 31 - 0
perf/course-word-2026-11-12/REPORT.md

@@ -0,0 +1,31 @@
+# 正式课程单词学习优化验收
+
+老师课程选词默认仅显示英文,点击词条或独立读音按钮后,等读音完成再显示中文。正式学习第二次点读也等待读音结束后显示答案,第三次隐藏答案;中文挑战保留中文提示,第三次隐藏英文。播放失败、中断、切页和切换学习栏不会提前显示答案。
+
+## 性能
+
+相同 Chrome、390×844、Fast 4G(20ms、下行4Mbps、上行3Mbps)、gzip,无 CPU 限速,交替10对样本。应用已在课程单元页加载;每次新上下文、词条数据首次请求。计时从单元“下一步”到100个真实词条显示并成功勾选首词。后端为临时受保护只读函数,使用真实课程授权、数据库和相同输入,未写入课堂记录。
+
+| 指标 | 原版 | 修改后 |
+|---|---:|---:|
+| p50 | 513 ms | 471 ms |
+| p75 | 628 ms | 541 ms |
+| p95(本组最大值) | 1569 ms | 626 ms |
+| JSON解压后 | 260365 B | 37987 B |
+| gzip传输 | 12397 B | 5403 B |
+| 列表DOM元素 | 1503 | 1403 |
+
+p75改善13.9%,gzip传输减少56.4%。未达到预设50%耗时改善目标,基线本身已约半秒,不将响应体缩减比例当作打开速度改善比例。本次精简只在新客户端显式 compact:true 时启用,旧客户端响应保持兼容;分页、权限、四栏进度和实时学习结果不变,不引入数据缓存。
+
+## 验证
+
+- 移动端完整套件384项通过,相关服务端16项通过,云函数11项定义校验通过,生产构建通过。
+- 四个学习栏初始仅英文、播放中隐藏、播放完成显示;正式学习三轮点读、中文挑战、独立播放按钮均通过浏览器检查。
+- 320、390、1024宽度无横向溢出,读音按钮实际40×44。
+- 界面验收使用隔离模拟课堂数据与实际构建,性能查询使用真实只读课程数据;不等同于真人试听与设备验收。
+
+## 复现
+
+使用对应前后生产构建及受保护只读代理,代理提供gzip和 X-QA-Read-Ms / X-QA-Decoded-Bytes。设置 BASELINE_URL、CANDIDATE_URL 与可选 CHROME_BIN,运行 bench.mjs。运行 ratchet.mjs 校验本组10对样本p75不超过541ms。原始样本和摘要均在本目录。
+
+生产发布回读将在发布后补充。

+ 10 - 0
perf/course-word-2026-11-12/bench.mjs

@@ -0,0 +1,10 @@
+import{chromium}from'playwright';import fs from'node:fs';import assert from'node:assert/strict';
+const root=new URL('.',import.meta.url).pathname,fixture=JSON.parse(fs.readFileSync(root+'fixture.json')),browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_BIN}),samples=[];
+try{for(let pair=0;pair<10;pair++)for(const version of ['A','B']){
+ const ctx=await browser.newContext({viewport:{width:390,height:844}}),page=await ctx.newPage(),api=[];await ctx.addInitScript(()=>{localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:qa',user:{userId:300,groupId:3},student:{userId:1713,groupId:1}}));HTMLMediaElement.prototype.load=()=>{};HTMLMediaElement.prototype.play=()=>Promise.resolve();});
+ const cdp=await ctx.newCDPSession(page);await cdp.send('Network.enable');await cdp.send('Network.emulateNetworkConditions',{offline:false,latency:20,downloadThroughput:4e6/8,uploadThroughput:3e6/8,connectionType:'cellular4g'});
+ page.on('response',r=>{if(r.request().postDataJSON()?.params?.action==='e_ck_list')api.push({serverMs:Number(r.headers()['x-qa-read-ms']),wireBytes:Number(r.headers()['content-length']),decodedBytes:Number(r.headers()['x-qa-decoded-bytes'])});});
+ await page.goto((version==='A'?process.env.BASELINE_URL:process.env.CANDIDATE_URL)+'/pages/stu/stu_list?nid='+fixture.pnid+'&orderId=qa-order',{waitUntil:'domcontentloaded'});await page.locator('.node-list > button').first().click();await page.waitForFunction(()=>!document.querySelector('.unit-actions .primary-button')?.disabled);
+ const start=performance.now();await page.locator('.unit-actions .primary-button').click();await page.waitForFunction(()=>document.querySelectorAll('.word-refine-item').length===100);await page.locator('.word-select-check').first().click();await page.waitForFunction(()=>document.querySelector('.word-select-check')?.getAttribute('aria-pressed')==='true');
+ const ms=Math.round(performance.now()-start);assert.equal(api.length,1);samples.push({pair,version,ms,...api[0],domNodes:await page.locator('.word-select-list *').count()});fs.writeFileSync(root+'paired-samples.json',JSON.stringify(samples,null,2));console.log(JSON.stringify(samples.at(-1)));await ctx.close();
+}const stat=rows=>{const s=rows.map(x=>x.ms).sort((a,b)=>a-b);return{n:s.length,p50:s[4],p75:s[7],p95:s[9]};},before=stat(samples.filter(x=>x.version==='A')),after=stat(samples.filter(x=>x.version==='B')),summary={before,after,improvement:1-after.p75/before.p75,scope:'unit choice click to 100 selectable course words; gzip; Fast4G; warm app, cold data'};assert.ok(after.p75<before.p75,'Must improve actual readiness');fs.writeFileSync(root+'paired-summary.json',JSON.stringify(summary,null,2));console.log(JSON.stringify(summary));}finally{await browser.close();}

+ 1 - 0
perf/course-word-2026-11-12/fixture.json

@@ -0,0 +1 @@
+{"pnid": "171"}

TEMPAT SAMPAH
perf/course-word-2026-11-12/local-course-default.png


TEMPAT SAMPAH
perf/course-word-2026-11-12/local-study-3-third.png


+ 182 - 0
perf/course-word-2026-11-12/paired-samples.json

@@ -0,0 +1,182 @@
+[
+  {
+    "pair": 0,
+    "version": "A",
+    "ms": 1569,
+    "serverMs": 517,
+    "wireBytes": 12397,
+    "decodedBytes": 260365,
+    "domNodes": 1503
+  },
+  {
+    "pair": 0,
+    "version": "B",
+    "ms": 451,
+    "serverMs": 109,
+    "wireBytes": 5403,
+    "decodedBytes": 37987,
+    "domNodes": 1403
+  },
+  {
+    "pair": 1,
+    "version": "A",
+    "ms": 513,
+    "serverMs": 115,
+    "wireBytes": 12397,
+    "decodedBytes": 260365,
+    "domNodes": 1503
+  },
+  {
+    "pair": 1,
+    "version": "B",
+    "ms": 396,
+    "serverMs": 104,
+    "wireBytes": 5403,
+    "decodedBytes": 37987,
+    "domNodes": 1403
+  },
+  {
+    "pair": 2,
+    "version": "A",
+    "ms": 512,
+    "serverMs": 118,
+    "wireBytes": 12397,
+    "decodedBytes": 260365,
+    "domNodes": 1503
+  },
+  {
+    "pair": 2,
+    "version": "B",
+    "ms": 380,
+    "serverMs": 99,
+    "wireBytes": 5403,
+    "decodedBytes": 37987,
+    "domNodes": 1403
+  },
+  {
+    "pair": 3,
+    "version": "A",
+    "ms": 523,
+    "serverMs": 113,
+    "wireBytes": 12397,
+    "decodedBytes": 260365,
+    "domNodes": 1503
+  },
+  {
+    "pair": 3,
+    "version": "B",
+    "ms": 541,
+    "serverMs": 183,
+    "wireBytes": 5403,
+    "decodedBytes": 37987,
+    "domNodes": 1403
+  },
+  {
+    "pair": 4,
+    "version": "A",
+    "ms": 478,
+    "serverMs": 112,
+    "wireBytes": 12397,
+    "decodedBytes": 260365,
+    "domNodes": 1503
+  },
+  {
+    "pair": 4,
+    "version": "B",
+    "ms": 471,
+    "serverMs": 149,
+    "wireBytes": 5403,
+    "decodedBytes": 37987,
+    "domNodes": 1403
+  },
+  {
+    "pair": 5,
+    "version": "A",
+    "ms": 628,
+    "serverMs": 141,
+    "wireBytes": 12397,
+    "decodedBytes": 260365,
+    "domNodes": 1503
+  },
+  {
+    "pair": 5,
+    "version": "B",
+    "ms": 496,
+    "serverMs": 139,
+    "wireBytes": 5403,
+    "decodedBytes": 37987,
+    "domNodes": 1403
+  },
+  {
+    "pair": 6,
+    "version": "A",
+    "ms": 462,
+    "serverMs": 103,
+    "wireBytes": 12397,
+    "decodedBytes": 260365,
+    "domNodes": 1503
+  },
+  {
+    "pair": 6,
+    "version": "B",
+    "ms": 615,
+    "serverMs": 195,
+    "wireBytes": 5403,
+    "decodedBytes": 37987,
+    "domNodes": 1403
+  },
+  {
+    "pair": 7,
+    "version": "A",
+    "ms": 553,
+    "serverMs": 119,
+    "wireBytes": 12397,
+    "decodedBytes": 260365,
+    "domNodes": 1503
+  },
+  {
+    "pair": 7,
+    "version": "B",
+    "ms": 497,
+    "serverMs": 128,
+    "wireBytes": 5403,
+    "decodedBytes": 37987,
+    "domNodes": 1403
+  },
+  {
+    "pair": 8,
+    "version": "A",
+    "ms": 661,
+    "serverMs": 164,
+    "wireBytes": 12397,
+    "decodedBytes": 260365,
+    "domNodes": 1503
+  },
+  {
+    "pair": 8,
+    "version": "B",
+    "ms": 626,
+    "serverMs": 120,
+    "wireBytes": 5403,
+    "decodedBytes": 37987,
+    "domNodes": 1403
+  },
+  {
+    "pair": 9,
+    "version": "A",
+    "ms": 445,
+    "serverMs": 105,
+    "wireBytes": 12397,
+    "decodedBytes": 260365,
+    "domNodes": 1503
+  },
+  {
+    "pair": 9,
+    "version": "B",
+    "ms": 430,
+    "serverMs": 104,
+    "wireBytes": 5403,
+    "decodedBytes": 37987,
+    "domNodes": 1403
+  }
+]

+ 16 - 0
perf/course-word-2026-11-12/paired-summary.json

@@ -0,0 +1,16 @@
+{
+  "before": {
+    "n": 10,
+    "p50": 513,
+    "p75": 628,
+    "p95": 1569
+  },
+  "after": {
+    "n": 10,
+    "p50": 471,
+    "p75": 541,
+    "p95": 626
+  },
+  "improvement": 0.13853503184713378,
+  "scope": "unit choice click to 100 selectable course words; gzip; Fast4G; warm app, cold data"
+}

+ 5 - 0
perf/course-word-2026-11-12/ratchet.json

@@ -0,0 +1,5 @@
+{
+  "samplesPerVariant": 10,
+  "maximumP75Ms": 541,
+  "minimumImprovement": 0
+}

+ 1 - 0
perf/course-word-2026-11-12/ratchet.mjs

@@ -0,0 +1 @@
+import fs from 'node:fs';import assert from 'node:assert/strict';const r=new URL('.',import.meta.url),s=JSON.parse(fs.readFileSync(new URL('paired-summary.json',r))),c=JSON.parse(fs.readFileSync(new URL('ratchet.json',r)));assert.equal(s.before.n,c.samplesPerVariant);assert.equal(s.after.n,c.samplesPerVariant);assert.ok(s.after.p75<=c.maximumP75Ms);assert.ok(s.improvement>=c.minimumImprovement);console.log('Course readiness ratchet passed');

+ 28 - 0
perf/course-word-2026-11-12/ui-local.json

@@ -0,0 +1,28 @@
+{
+  "passes": [
+    0,
+    1,
+    2,
+    3
+  ],
+  "defaultEnglishOnly": true,
+  "showAfterAudioEnded": true,
+  "speakerIndependent": true,
+  "speakerHeight": 44,
+  "studyRounds": [
+    1,
+    2,
+    3
+  ],
+  "firstTapPhonetic": true,
+  "secondTapWaitsForAudio": true,
+  "thirdTapHidden": true,
+  "chineseChallengePreserved": true,
+  "widths": [
+    320,
+    390,
+    1024
+  ],
+  "enlargedTypographyNoOverflow": true,
+  "mockedData": true
+}

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

@@ -1293,6 +1293,24 @@ describe('home learning modules', () => {
     expect(localStorage.getItem(prefix + 'learn_mode')).toBeNull();
   }, 15000);
 
+  for(const pass of [0,1,2,3]) {
+    it(`waits for course word audio before revealing meanings in pass ${pass}`,async()=>{
+      const {harness,component}=await teacherSelection(2,pass,'901');
+      component.words.update(words=>words.map(word=>({...word,audio:'course.mp3'})));
+      const platform=TestBed.inject(PlatformService),play=spyOn(platform,'play').and.resolveTo('played');spyOn(platform,'toast');
+      harness.fixture.detectChanges();const view=()=>harness.routeNativeElement!;
+      const meanings=()=>view().querySelectorAll('.word-reveal small.revealed').length;
+      expect(meanings()).toBe(0);
+      (view().querySelector('.word-reveal') as HTMLButtonElement).click();await harness.fixture.whenStable();harness.fixture.detectChanges();
+      expect(meanings()).toBe(0);const old=play.calls.mostRecent().args[2]!;expect(old).toBeDefined();if(!old)return;
+      old.onEnded();harness.fixture.detectChanges();expect(meanings()).toBe(1);
+      component.load();await harness.fixture.whenStable();if(pass>0){component.selectCoursePass(pass);await harness.fixture.whenStable();}component.words.update(words=>words.map(word=>({...word,audio:'course.mp3'})));harness.fixture.detectChanges();expect(meanings()).toBe(0);
+      (view().querySelector('.word-reveal') as HTMLButtonElement).click();await harness.fixture.whenStable();const late=play.calls.mostRecent().args[2]!;
+      (view().querySelectorAll('.word-reveal')[1] as HTMLButtonElement).click();await harness.fixture.whenStable();late.onEnded();harness.fixture.detectChanges();expect(meanings()).toBe(0);
+      play.calls.mostRecent().args[2]!.onError?.();harness.fixture.detectChanges();expect(meanings()).toBe(0);
+    });
+  }
+
   it('still pronounces pending and completed words when a coach reveals them', async () => {
     user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
     session.groupId.set(3);
@@ -2814,8 +2832,8 @@ describe('home learning modules', () => {
       expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con');
       const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
       expect(component.words().length).toBe(2);
-      component.words.update(words => words.map(word => ({...word,phonetic:word.phonetic || '/wɜːd/'})));
-      spyOn(component,'play').and.resolveTo();
+      component.words.update(words => words.map(word => ({...word,phonetic:word.phonetic || '/wɜːd/',audio:'course.mp3'})));
+      const play=spyOn(platform,'play').and.resolveTo('played');
       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')!;
@@ -2823,8 +2841,10 @@ describe('home learning modules', () => {
         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()?.textContent).toBe(step===3 ? first.word : first.meaning);
+        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()).toBeNull();expect(row.querySelectorAll('.repeat-dots .done').length).toBe(3);
         if(step!==3)expect(row.querySelector('.study-phonetic')?.textContent).toBe(first.phonetic);
         row.click();harness.fixture.detectChanges();expect(answer()).toBeNull();expect(component.repetitions()[first.id]).toBe(3);

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

@@ -216,7 +216,8 @@
                   }
                   <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 || '暂无释义') : courseWordPass(word) > 0 ? selectionCompletionLabel(word) + ' · 点击查看释义' : isReadOnlySelectionPage ? '点击查看释义' : '先说出中文意思,再点击核对' }}</small>
+                    @if (selectionWordRevealed(word)) { <small class="revealed">{{ word.meaning || '暂无释义' }}</small> }
+                    @else if (isReadOnlySelectionPage) { <small>{{ courseWordPass(word) > 0 ? selectionCompletionLabel(word) + ' · 点击查看释义' : '点击查看释义' }}</small> }
                   </button>
                   @if (!isReadOnlySelectionPage && coursePass() < 3) {
                     <div class="word-assessment" role="group" [attr.aria-label]="word.word + '的识词判断'">
@@ -224,7 +225,7 @@
                       <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> }
+                  @if (!isReadOnlySelectionPage) { <button type="button" class="audio quiet-button" [attr.aria-label]="'播放单词读音:' + word.word" (click)="play(word)"><lucide-icon [img]="icons.Volume2" [size]="18" /></button> }
                 </div>
               </article>
             } @empty {
@@ -275,7 +276,7 @@
           <span>
             <strong [class.study-headword]="studyStep() !== 3">{{ studyStep() === 3 ? word.meaning : word.word }}</strong>
             @if (studyStep() !== 3 && repeatCount >= 1 && word.phonetic) { <small class="study-phonetic">{{ word.phonetic }}</small> }
-            @if (repeatCount === 2) { <small>{{ studyStep() === 3 ? word.word : word.meaning }}</small> }
+            @if (repeatCount === 2 && selectionWordRevealed(word)) { <small>{{ studyStep() === 3 ? word.word : word.meaning }}</small> }
           </span>
           <span class="repeat-dots" aria-label="跟读次数">
             @for (dot of [1, 2, 3]; track dot) { <i [class.done]="repeatCount >= dot"></i> }

+ 17 - 10
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -766,6 +766,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   selectCoursePass(pass: number): void {
     if (this.selectionLocked() || pass === this.coursePass() || pass < 0 || pass > 3) return;
+    this.audioRequest++;this.platform.stopAudio();this.audioPreparing.set(false);
+    this.selectionRevealed.set(new Set());
     this.bulkSelection.set(null);
     this.courseSelectionNeedsRebase = false;
     this.selectionRequest?.unsubscribe();
@@ -781,12 +783,14 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   revealSelectionWord(word: Word): void {
-    this.selectionRevealed.update((current) => {
-      const next = new Set(current);
-      if (next.has(word.id)) next.delete(word.id);
-      else next.add(word.id);
-      return next;
-    });
+    if (this.isReadOnlySelectionPage) {
+      this.selectionRevealed.update(current => {
+        const next = new Set(current);
+        if (next.has(word.id)) next.delete(word.id); else next.add(word.id);
+        return next;
+      });
+      return;
+    }
     this.play(word);
   }
 
@@ -964,12 +968,12 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.audioPreparing.set(false);
     if (this.isHistoryWordView) this.historyAudioWordId.set(word.id);
     const playAudio = async (url: string, silentFailure = false) => {
-      const status = await (this.isHistoryWordView || this.isNewWordSelectionPage
+      const status = await (this.isHistoryWordView || this.isSelectionPage || this.isStudyListPage
         ? this.platform.play(url, silentFailure, {
           onEnded: () => {
             if (!active()) return;
-            if (this.isNewWordSelectionPage) this.selectionRevealed.update(current => new Set(current).add(word.id));
-            else this.historyWordRevealed.update(current => new Set(current).add(word.id));
+            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));
             this.historyAudioWordId.set('');
           },
           onError: () => {
@@ -1029,6 +1033,7 @@ 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 }));
@@ -1039,6 +1044,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   shuffleStudyWords(): void {
+    this.audioRequest++;this.platform.stopAudio();this.audioPreparing.set(false);
+    this.selectionRevealed.set(new Set());
     this.words.set(shuffledStudy(this.words()));
     this.repetitions.set({});
   }
@@ -1600,7 +1607,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     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})=>{
+      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,compact:true,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]));

+ 5 - 5
projects/xiaoshu-mobile/src/styles.scss

@@ -867,14 +867,14 @@ 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) auto 26px; gap: 8px; }
+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 40px; 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 { display: flex; min-width: 0; min-height: 52px; padding: 5px 0; align-items: flex-start; justify-content: center; color: var(--ink); text-align: left; background: transparent; border: 0; flex-direction: column; gap: 5px; }
 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-refine-main .audio { display: grid; width: 40px; height: 44px; min-width: 40px; min-height: 44px; color: var(--brand-strong); place-items: center; }
 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; }
@@ -892,8 +892,8 @@ app-learning-page .bulk-assessment button:focus-visible { outline: 2px solid var
 @media (hover: hover) { app-learning-page .bulk-assessment button:not(:disabled):hover { filter: brightness(1.06); } }
 @media (max-width: 350px) { app-learning-page .course-bulk-toolbar { grid-template-columns: auto minmax(0,1fr); } app-learning-page .bulk-count { justify-self: end; } app-learning-page .bulk-assessment { grid-column: 1 / -1; } }
 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:has(.word-assessment) .audio { width: 40px; }
+app-learning-page .word-refine-main:not(:has(.word-assessment)) { grid-template-columns: 32px minmax(0, 1fr) 40px; }
 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; } }

+ 7 - 1
scripts/deploy-admin-functions.mjs

@@ -2705,7 +2705,13 @@ async function courseWordPage(input, current) {
   const bookTotal = bookPassCounts.reduce((sum,n)=>sum+n,0);
   return {rows:rows.map(source=>{
     const learned=source.pass,newWord=source.newWord;delete source.pass;delete source.newWord;
-    const word=normalizeWordRow(source);return {...word,detail:word,gldy:word.GeneralID,w_learned:learned,ifnew:newWord};
+    const word=normalizeWordRow(source);
+    if(input.compact===true){
+      const keys=['GeneralID','NodeID','Title','Title2','word','Word','sy','jssy','Translate','Meaning','yb','Phonetic','phonetic','yp','dy','lj','nodeId'];
+      const detail=Object.fromEntries(keys.filter(key=>word[key]!==undefined).map(key=>[key,word[key]]));
+      return {...detail,detail,gldy:word.GeneralID,w_learned:learned,ifnew:newWord};
+    }
+    return {...word,detail:word,gldy:word.GeneralID,w_learned:learned,ifnew:newWord};
   }),page:{itemCount:total,pageCount:Math.ceil(total/paging.size),pageIndex:paging.index,pageSize:paging.size,passCounts,bookPassCounts,firstPassProgress:bookTotal?Math.round((bookTotal-bookPassCounts[0])/bookTotal*100):0}};
 }
 async function updateLearningWords(input, current) {

+ 12 - 0
scripts/tests/course-word-progress.test.mjs

@@ -62,3 +62,15 @@ test('拒绝无效遍数和越权访问',async t=>{
  for(const learningPass of [-1,4,1.5,'bad'])await assert.rejects(read({learningPass}),/学习遍数无效/);
  await assert.rejects(read({uid:99}),/无权访问/);
 });
+
+test('精简选词响应保留单词、音标、释义、音频与全部进度,旧客户端仍得到完整响应',async t=>{
+ const {read,db}=await setup(t);
+ await db.exec(`ALTER TABLE "CommonModel" ADD COLUMN body text;UPDATE "CommonModel" SET body=repeat('metadata',400);ALTER TABLE "VocabularyWord" ADD COLUMN yb text,ADD COLUMN yp text,ADD COLUMN lj text;UPDATE "VocabularyWord" SET yb='/tuː/',yp='two.mp3',lj='two words' WHERE id=2;`);
+ const full=await read({}),compact=await read({compact:true});
+ assert.equal(full.rows[1].body.length,3200);assert.equal(compact.rows[1].body,undefined);
+ assert.deepEqual(Array.from(compact.rows,row=>row.GeneralID),[1,2,3,4]);
+ for(const key of ['GeneralID','NodeID','Title','sy','yb','yp','lj','gldy','w_learned','ifnew'])for(let i=0;i<full.rows.length;i++)assert.deepEqual(compact.rows[i][key],full.rows[i][key],key);
+ assert.deepEqual(JSON.parse(JSON.stringify(compact.page)),JSON.parse(JSON.stringify(full.page)));
+ assert.ok(JSON.stringify(compact).length<JSON.stringify(full).length/2);
+ assert.equal((await read({compact:'true'})).rows[1].body.length,3200);
+});