浏览代码

fix(learning): reveal word meanings in lesson assessment

彭峰 1 天之前
父节点
当前提交
fecce2047d

+ 17 - 0
docs/qa/lesson-end-word-meaning-2026-11-04.md

@@ -0,0 +1,17 @@
+# 学习结束检测显示中文释义(2026-11-04)
+
+老师在学习结束页面点击单词时,同时播放读音并显示该词的中文释义;再次点击播放读音并收起释义。各词独立展开,不更改“已掌握 / 待加强”的判断。长释义可换行;词库及原批次都没有释义时显示“暂无中文释义”。
+
+待确认的学习批次原本只保存单词编号、英文和掌握状态。`appLessonStudy` 在完成预约访问授权后,按单词编号批量读取词库中文释义,覆盖草稿和 `user-confirmed-recovery` 补录掌握情况批次;词库缺失时保留批次已有释义字段,保存格式和历史数据不变。
+
+## 验证
+
+- 相关学习批次、阅读数据及释义接口共 16 项后端测试通过,包括实际 PGlite SQL 关联、历史恢复批次、词序与掌握状态、原有 `jssy` 兼容。
+- 移动端学习模块 70 项集成测试通过,包含点击发音、逐词展开/收起及不修改掌握状态。最终无障碍标签和换行样式另由生产构建和浏览器验证。
+- 移动端生产构建及 11 个云函数定义校验通过。
+- 375px 本地页面使用隔离 API 验证展开、缺失释义提示、无页面横向溢出及长释义的实际 computed `white-space: normal` 与多行显示。截图和脚本在 `/Users/mac/.local/state/xiaoshu/qa/2026-11-04-end-meaning/`。音频使用隔离资源,发音调用在组件测试中核对;未做线上真实设备音频验收。
+- 独立审查发现的历史补录批次缺释义、以及 Angular 组件样式优先级导致不换行的问题均已修复并验证,复审未发现剩余阻塞。
+
+## 交付边界
+
+本次只提交推送代码,未发布。上线需要选择性更新应用网关 `appLessonStudy` 和手机端静态资源,并保留其他线上函数。上一项已推送的旧阅读写作预约兼容修复另有数据库函数与预约学段确认步骤,若一并上线须按其独立验收记录处理。

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

@@ -2033,6 +2033,19 @@ describe('home learning modules', () => {
     expect(api.completionRequests.length).toBe(0);
   });
 
+  it('reveals and speaks each assessment word without changing mastery', async () => {
+    user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1});
+    const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10;
+    api.studyBatches=[{batchId:'meaning-round',version:1,status:'draft',bindingId:'binding-801',words:[{GeneralID:1001,Title:'crop',sy:'庄稼',check:1},{GeneralID:1002,Title:'gather',sy:'聚集',check:2}]}];
+    const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/stu/pl_end?orderId=901&bindingId=binding-801&batchId=meaning-round');await harness.fixture.whenStable();harness.fixture.detectChanges();
+    const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    const play=spyOn(component,'play').and.resolveTo();const cards=harness.routeNativeElement!.querySelectorAll('.review-list article');
+    expect(cards[0].textContent).not.toContain('庄稼');(cards[0].querySelector('.word-audio') as HTMLButtonElement).click();harness.fixture.detectChanges();
+    expect(play).toHaveBeenCalledWith(component.words()[0]);expect(cards[0].textContent).toContain('庄稼');expect(cards[1].textContent).not.toContain('聚集');
+    expect(component.reviewValue(component.words()[0])).toBe(1);expect(component.reviewValue(component.words()[1])).toBe(2);
+    (cards[0].querySelector('.word-audio') as HTMLButtonElement).click();harness.fixture.detectChanges();expect(cards[0].textContent).not.toContain('庄稼');expect(play).toHaveBeenCalledTimes(2);
+  });
+
   it('keeps round choices on save failure and retries with the same command key', async () => {
     user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1});
     const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10;

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

@@ -329,7 +329,7 @@
     <div class="review-list">
       @for (word of words(); track word.id) {
         <article>
-          <button type="button" class="word-audio" (click)="play(word)"><span><strong>{{ word.word }}</strong>@if (meta.path !== 'pages/stu/pl_end') { <small>{{ word.meaning }}</small> }</span><lucide-icon [img]="icons.Volume2" [size]="18" /></button>
+          <button type="button" class="word-audio" [class.assessment-word]="meta.path === 'pages/stu/pl_end'" [attr.aria-label]="meta.path === 'pages/stu/pl_end' ? word.word + (recordRevealed().has(word.id) ? ',播放读音并隐藏中文释义' : ',播放读音并显示中文释义') : null" (click)="meta.path === 'pages/stu/pl_end' ? toggleRecordAnswer(word) : play(word)" [attr.aria-expanded]="meta.path === 'pages/stu/pl_end' ? recordRevealed().has(word.id) : null"><span><strong>{{ word.word }}</strong>@if (meta.path !== 'pages/stu/pl_end' || recordRevealed().has(word.id)) { <small>{{ word.meaning || (meta.path === 'pages/stu/pl_end' ? '暂无中文释义' : '') }}</small> }</span><lucide-icon [img]="icons.Volume2" [size]="18" /></button>
           <div class="review-buttons">
             <button type="button" [class.active]="reviewValue(word) === 1" (click)="markReview(word, 1)"><lucide-icon [img]="icons.Check" [size]="18" />已掌握</button>
             <button type="button" [class.active]="reviewValue(word) === 2" (click)="markReview(word, 2)"><lucide-icon [img]="icons.X" [size]="18" />待加强</button>

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

@@ -927,3 +927,6 @@ body app-study-records-page .fee-month-select > button.xs-dropdown-trigger {
   border-radius: 10px;
 }
 body app-study-records-page .fee-month-select > .fee-select-chevron { display: none; }
+
+/* Keep the full meaning readable in the end-of-lesson assessment. */
+app-learning-page .review-list .word-audio.assessment-word small { white-space: normal; overflow-wrap: anywhere; }

+ 5 - 1
scripts/cloud/lesson-study.js

@@ -20,7 +20,11 @@ async function appLessonStudy(input,current) {
  const record=await Psql.oneOrNone('SELECT d.*,c."generalId" AS "GeneralID" FROM "DailyStudyRecord" d JOIN "CommonModel" c ON c.company=d.company AND c."modelId"=56 AND c."itemId"=d.id WHERE d.company=$1 AND d.dsid::text=$2 AND d."recordScope"=\'lesson\'',[DEFAULT_COMPANY_ID,gid]);
  const nodes=await Psql.query('SELECT "nodeId","nodeName" FROM "Node" WHERE company=$1 AND "nodeId"::text=ANY($2::text[])',[DEFAULT_COMPANY_ID,[...new Set(batches.flatMap(b=>parseArray(b.words).map(w=>String(w.nodeId||''))))]]);
  const names=new Map(nodes.map(n=>[String(n.nodeId),n.nodeName]));
- return {orderId:gid,record:record?{...record,words:parseArray(record.xxqs)}:null,batches:batches.map(b=>({...b,words:parseArray(b.words).map(w=>({...w,nodeName:names.get(String(w.nodeId))||''}))})),pending:batches.filter(b=>b.status==='draft').length};
+ const needsMeaning=b=>b.status==='draft'||(b.status==='confirmed'&&b.provenance==='user-confirmed-recovery');
+ const wordIds=[...new Set(batches.filter(needsMeaning).flatMap(b=>parseArray(b.words).map(w=>String(w.GeneralID||w.generalId||''))).filter(Boolean))];
+ const vocabulary=wordIds.length?await Psql.query('SELECT c."generalId",v.sy FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v.company=c.company AND v.id=c."itemId" WHERE c.company=$1 AND c."modelId"=52 AND c."generalId"::text=ANY($2::text[])',[DEFAULT_COMPANY_ID,wordIds]):[];
+ const meanings=new Map(vocabulary.map(w=>[String(w.generalId),w.sy||'']));
+ return {orderId:gid,record:record?{...record,words:parseArray(record.xxqs)}:null,batches:batches.map(b=>({...b,words:parseArray(b.words).map(w=>({...w,...(needsMeaning(b)&&meanings.get(String(w.GeneralID||w.generalId))?{sy:meanings.get(String(w.GeneralID||w.generalId))}:{}),nodeName:names.get(String(w.nodeId))||''}))})),pending:batches.filter(b=>b.status==='draft').length};
 }
 async function appReadingSource(input,current) {
  const detail=await recordDetailData({id:input.sourceStudyId,studentId:input.studentId},current);

+ 22 - 0
scripts/tests/lesson-study-meaning.test.mjs

@@ -0,0 +1,22 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import vm from 'node:vm';
+const source=readFileSync(new URL('../cloud/lesson-study.js',import.meta.url),'utf8');
+test('待确认批次从词库批量补中文释义,保留词序与掌握状态',async()=>{
+ const calls=[];const context={DEFAULT_COMPANY_ID:'co',assertLessonTenant:()=>{},orderDetailRow:async()=>({szyh:20}),parseArray:v=>Array.isArray(v)?v:JSON.parse(v||'[]'),fail:(_s,m)=>{throw Error(m);},Psql:{oneOrNone:async()=>null,query:async(sql,args)=>{calls.push({sql,args});if(sql.includes('LessonStudyBatch'))return[{batchId:'b',status:'draft',words:[{GeneralID:12,Title:'crop',check:1},{GeneralID:13,Title:'gather',check:2}]},{batchId:'recovered',status:'confirmed',provenance:'user-confirmed-recovery',words:[{GeneralID:14,Title:'church',check:0},{GeneralID:15,Title:'other',jssy:'旧词义',check:0}]}];if(sql.includes('VocabularyWord'))return[{generalId:13,sy:'聚集'},{generalId:12,sy:'庄稼'},{generalId:14,sy:'教堂'}];return[];}}};
+ vm.createContext(context);vm.runInContext(source,context);const result=await context.appLessonStudy({orderId:'1'},{});
+ assert.deepEqual(Array.from(result.batches[0].words,w=>[w.Title,w.sy,w.check]),[['crop','庄稼',1],['gather','聚集',2]]);
+ assert.equal(result.batches[1].words[0].sy,'教堂');assert.equal(result.batches[1].words[1].jssy,'旧词义');assert.equal(result.batches[1].words[1].sy,undefined);
+ assert.equal(calls.filter(c=>c.sql.includes('VocabularyWord')).length,1);
+});
+test('真实 SQL 可按单词编号关联词库并恢复含掌握状态的释义',async()=>{
+ const {PGlite}=await import('@electric-sql/pglite');const db=new PGlite();try{
+  await db.exec(`CREATE TABLE "LessonStudyBatch"(company text,"orderId" text,"batchId" text,status text,provenance text,"bindingId" text,words jsonb,"updatedAt" timestamptz);
+  CREATE TABLE "CourseBinding"(company text,"objectId" text,kcid text);CREATE TABLE "Node"(company text,"nodeId" numeric,"nodeName" text);
+  CREATE TABLE "DailyStudyRecord"(company text,id numeric,dsid text,"recordScope" text,xxqs text);
+  CREATE TABLE "CommonModel"(company text,"modelId" numeric,"itemId" numeric,"generalId" numeric);
+  CREATE TABLE "VocabularyWord"(company text,id numeric,sy text);
+  INSERT INTO "LessonStudyBatch" VALUES('co','1','recovery','confirmed','user-confirmed-recovery',null,'[{"GeneralID":12,"Title":"crop","check":2}]',now());
+  INSERT INTO "CommonModel" VALUES('co',52,7,12);INSERT INTO "VocabularyWord" VALUES('co',7,'庄稼');`);
+  const query=async(s,p=[])=>(await db.query(s,p)).rows,context={DEFAULT_COMPANY_ID:'co',assertLessonTenant:()=>{},orderDetailRow:async()=>({szyh:20}),parseArray:v=>Array.isArray(v)?v:JSON.parse(v||'[]'),Psql:{query,oneOrNone:async(s,p)=>(await query(s,p))[0]||null}};
+  vm.createContext(context);vm.runInContext(source,context);const result=await context.appLessonStudy({orderId:'1'},{});assert.equal(result.batches[0].words[0].sy,'庄稼');assert.equal(result.batches[0].words[0].check,2);
+ }finally{await db.close();}
+});