Преглед изворни кода

fix(teaching): refresh coach reviews and bundle report downloads

彭峰 пре 1 час
родитељ
комит
acdb5a6662

+ 1 - 0
package-lock.json

@@ -16,6 +16,7 @@
         "@angular/platform-browser": "20.3.33",
         "@angular/platform-browser-dynamic": "20.3.33",
         "@angular/router": "20.3.33",
+        "fflate": "0.8.3",
         "html2pdf.js": "0.14.0",
         "jspdf": "4.2.1",
         "lucide-angular": "1.0.0",

+ 1 - 0
package.json

@@ -79,6 +79,7 @@
     "@angular/platform-browser": "20.3.33",
     "@angular/platform-browser-dynamic": "20.3.33",
     "@angular/router": "20.3.33",
+    "fflate": "0.8.3",
     "html2pdf.js": "0.14.0",
     "jspdf": "4.2.1",
     "lucide-angular": "1.0.0",

+ 1 - 1
projects/xiaoshu-mobile/src/app/core/review-page.ts

@@ -1,5 +1,5 @@
 import { buildAntiForgetCalendar, buildAntiForgetView } from './anti-forget';
-export interface ReviewPage { serverToday?:string;items:Record<string,unknown>[];page:{itemCount:number;pageCount:number};summary:{total:number;completed:number;due:number;today:number;income:number;incomeScope?:string;todayTotal?:number;todayCompleted?:number;todayPending?:number;overduePending?:number}; dates:Array<{date:string;total:number;completed:number}>;students?:Array<{id:string;name:string;taskCount:number}> }
+export interface ReviewPage { serverToday?:string;items:Record<string,unknown>[];page:{itemCount:number;pageCount:number};summary:{total:number;completed:number;due:number;today:number;income:number;incomeScope?:string;todayTotal?:number;todayCompleted?:number;todayPending?:number;overduePending?:number}; dates:Array<{date:string;total:number;completed:number}>;students?:Array<{id:string;name:string;taskCount:number}>;legacyReviewSync?: { state: 'current' | 'conflict' | 'unavailable' } }
 export function reviewCalendar(dates:ReviewPage['dates'],month:Date,selected:string){
   const view=buildAntiForgetCalendar([],month,selected);const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date());
   view.days=view.days.map(day=>{const row=dates.find(d=>d.date===day.date);const total=Number(row?.total)||0,completed=Number(row?.completed)||0,pending=total-completed;return{...day,total,completed,pending,state:!total?'none' as const:!pending?'completed' as const:day.date<=today?'due' as const:'scheduled' as const};});

+ 1 - 0
projects/xiaoshu-mobile/src/app/core/teacher-today-review.service.spec.ts

@@ -54,6 +54,7 @@ describe('TeacherTodayReviewService', () => {
     expect(result.summary.todayPending).toBe(1);
     await firstValueFrom(service.load(true));
     expect(api.post.calls.count()).toBe(2);
+    expect(api.post.calls.mostRecent().args[1]).toEqual(jasmine.objectContaining({ refresh: true }));
   });
 
   it('restores the same login daily cache after page reload', async () => {

+ 1 - 0
projects/xiaoshu-mobile/src/app/core/teacher-today-review.service.ts

@@ -30,6 +30,7 @@ export class TeacherTodayReviewService {
     const fetchPage = (page: number, selectedDate: string) => this.api.post<ReviewPage>('app_review_page', {
       uid, page, pageSize: 100,
       scope: 'today', date: selectedDate, includeMetadata: false, includeIncome: false,
+      ...(force && page === 1 ? { refresh: true } : {}),
     }).pipe(take(1), map(({ result }) => ({ result, page })));
     const request = fetchPage(1, date).pipe(
       expand(({ result, page }) => key === this.ensureIdentity() && page < result.page.pageCount ? fetchPage(page + 1, result.serverToday || date) : EMPTY),

+ 6 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report-download.service.ts

@@ -8,6 +8,12 @@ export class LessonReportDownloadService {
     document.body.appendChild(link); link.click(); link.remove();
   }
 
+  async createZip(files: readonly { filename: string; blob: Blob }[]): Promise<Blob> {
+    const { zipSync } = await import('fflate');
+    const entries = await Promise.all(files.map(async file => [file.filename, new Uint8Array(await file.blob.arrayBuffer())] as const));
+    return new Blob([zipSync(Object.fromEntries(entries), { level: 0 })], { type: 'application/zip' });
+  }
+
   async createPdf(element: HTMLElement): Promise<Blob> {
     await document.fonts?.ready;
     const { default: html2pdf } = await import('html2pdf.js');

+ 4 - 4
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.html

@@ -50,7 +50,7 @@
         }
       </fieldset>
       <button class="download-pdf" type="button" (click)="downloadPdf()" [disabled]="!report() || loading() || exporting() || !selectedVariants().length">
-        <lucide-icon [img]="exporting() ? icons.LoaderCircle : icons.Download" [class.spin]="exporting()" [size]="19" />{{ exporting() ? exportProgress() : '下载 PDF(' + selectedVariants().length + '份)' }}
+        <lucide-icon [img]="exporting() ? icons.LoaderCircle : icons.Download" [class.spin]="exporting()" [size]="19" />{{ exporting() ? exportProgress() : selectedVariants().length > 1 ? '下载报表压缩包(' + selectedVariants().length + '份)' : '下载 PDF' }}
       </button>
     }
     <a routerLink="/pages/his/his">查看学习记录</a>
@@ -58,9 +58,9 @@
   @if (exportError()) { <p class="export-error" role="alert">{{ exportError() }}</p> }
   @if (exportMessage()) { <p class="export-message" role="status">{{ exportMessage() }}</p> }
   @if (generatedFiles().length) {
-    <section class="generated-files" aria-label="已生成的 PDF">
-      <p>如浏览器拦截了多文件下载,请允许下载多个文件,或点击下方文件分别保存。</p>
-      @for (file of generatedFiles(); track file.url) { <a [href]="file.url" [download]="file.filename">{{ file.label }} PDF</a> }
+    <section class="generated-files" aria-label="已生成的学习报表">
+      <p>如果下载未自动开始,请点击下方链接保存。</p>
+      @for (file of generatedFiles(); track file.url) { <a [href]="file.url" [download]="file.filename">{{ file.label }}</a> }
     </section>
   }
   @if (pdfReports().length) {

+ 15 - 8
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.spec.ts

@@ -30,8 +30,9 @@ describe('teacher learning report PDF download', () => {
     report = reportFixture(); groupId.set(3);
     api = jasmine.createSpyObj('ApiService',['get']);
     api.get.and.callFake((action: string) => of({retcode:0,result:(action==='app_lesson_report'?report:action==='e_record_detail'?wordsFixture():{batches:[]}) as any}));
-    download = jasmine.createSpyObj('LessonReportDownloadService',['createPdf','download']);
+    download = jasmine.createSpyObj('LessonReportDownloadService',['createPdf','createZip','download']);
     download.createPdf.and.resolveTo(new Blob(['%PDF-test'],{type:'application/pdf'}));
+    download.createZip.and.callFake(files => new LessonReportDownloadService().createZip(files));
     TestBed.configureTestingModule({providers:[provideRouter([]),
       {provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({sid:'901'})}}},
       {provide:ApiService,useValue:api},{provide:SessionService,useValue:{groupId}},
@@ -40,7 +41,7 @@ describe('teacher learning report PDF download', () => {
   });
   function screen() { const fixture=TestBed.createComponent(LessonReportComponent);fixture.detectChanges();return fixture; }
   it('offers the teacher a footer download and fetches saved word results before rendering the full report',async()=>{
-    const fixture=screen();fixture.componentInstance.selectedVariants.set(['full']);
+    const fixture=screen();fixture.componentInstance.selectedVariants.set(['full']);fixture.detectChanges();
     expect(fixture.nativeElement.querySelector('footer .download-pdf').textContent).toContain('下载 PDF');
     expect(fixture.nativeElement.querySelector('footer a').textContent).toContain('查看学习记录');
     download.createPdf.and.callFake(async(element)=>{
@@ -57,7 +58,7 @@ describe('teacher learning report PDF download', () => {
     expect(fixture.nativeElement.querySelector('[role="status"]').textContent).toContain('已生成 1 份 PDF');
     expect(fixture.nativeElement.querySelector('.pdf-render')).toBeNull();
   });
-  it('downloads all three formats by default and keeps answers out of the worksheets',async()=>{
+  it('downloads all three formats as one archive and keeps answers out of the worksheets',async()=>{
     const fixture=screen();const titles:string[]=[];
     expect(fixture.nativeElement.querySelectorAll('.pdf-options input:checked').length).toBe(3);
     download.createPdf.and.callFake(async element=>{
@@ -77,11 +78,17 @@ describe('teacher learning report PDF download', () => {
     });
     await fixture.componentInstance.downloadPdf();fixture.detectChanges();
     expect(titles).toEqual(['单词学习报表','英文填中文复习表','中文填英文复习表']);
-    expect(download.download).toHaveBeenCalledTimes(3);
-    expect(new Set(download.download.calls.allArgs().map(args=>args[0].filename)).size).toBe(3);
-    expect(fixture.nativeElement.querySelectorAll('.generated-files a[download]').length).toBe(3);
+    expect(download.createZip).toHaveBeenCalledTimes(1);
+    const { unzipSync } = await import('fflate');
+    const archive = await download.createZip.calls.mostRecent().returnValue;
+    const entries = unzipSync(new Uint8Array(await archive.arrayBuffer()));
+    expect(Object.keys(entries)).toEqual(titles.map(title => `小树陪练-${title}-测试学员-2026-10-22-901.pdf`));
+    expect(Object.values(entries).map(bytes => new TextDecoder().decode(bytes))).toEqual(['%PDF-1', '%PDF-2', '%PDF-3']);
+    expect(download.download).toHaveBeenCalledTimes(1);
+    expect(download.download.calls.mostRecent().args[0].filename).toMatch(/\.zip$/);
+    expect(fixture.nativeElement.querySelectorAll('.generated-files a[download]').length).toBe(1);
     expect(api.get.calls.allArgs().filter(args=>args[0]==='e_record_detail').length).toBe(1);
-    const revoke=spyOn(URL,'revokeObjectURL');fixture.destroy();expect(revoke).toHaveBeenCalledTimes(3);
+    const revoke=spyOn(URL,'revokeObjectURL');fixture.destroy();expect(revoke).toHaveBeenCalledTimes(1);
   });
   it('downloads only selected formats and prevents an empty selection',async()=>{
     const fixture=screen();const choices=fixture.nativeElement.querySelectorAll('.pdf-options input');
@@ -111,7 +118,7 @@ describe('teacher learning report PDF download', () => {
     expect(download.download).not.toHaveBeenCalled();expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('单词读取失败');
     expect(fixture.nativeElement.querySelector('.download-pdf').disabled).toBeFalse();
     api.get.and.callFake((action:string)=>of({retcode:0,result:(action==='e_record_detail'?wordsFixture():report) as any}));
-    await fixture.componentInstance.downloadPdf();expect(download.download).toHaveBeenCalledTimes(3);
+    await fixture.componentInstance.downloadPdf();expect(download.download).toHaveBeenCalledTimes(1);
   });
   it('does not export incomplete lessons or empty record lists',async()=>{
     const fixture=screen();report.order['dszt']=10;

+ 15 - 5
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.ts

@@ -99,13 +99,23 @@ export class LessonReportComponent implements OnInit {
         if(this.destroyRef.destroyed) return;
         const element=this.pdfContent?.nativeElement;
         if(!element) throw new Error('报表打印区域尚未准备完成');
-        files.push({blob:await this.downloader.createPdf(element),filename:`小树陪练-${variant.title}-${safeName}.pdf`,label:variant.label});
+        files.push({blob:await this.downloader.createPdf(element),filename:`小树陪练-${variant.title}-${safeName}.pdf`,label:`保存${variant.label} PDF`});
       }
       if(this.destroyRef.destroyed) return;
-      const downloads=files.map(file=>({url:URL.createObjectURL(file.blob),filename:file.filename,label:file.label}));
-      this.generatedFiles.set(downloads);
-      for(const file of downloads) this.downloader.download(file);
-      this.exportMessage.set(`已生成 ${downloads.length} 份 PDF,已发起下载。`);
+      let file = files[0];
+      if (files.length > 1) {
+        this.exportProgress.set('正在打包报表');
+        file = {
+          blob: await this.downloader.createZip(files),
+          filename: `小树陪练-学习报表-${safeName}.zip`,
+          label: '保存报表压缩包(ZIP)',
+        };
+      }
+      if (this.destroyRef.destroyed) return;
+      const download = { url: URL.createObjectURL(file.blob), filename: file.filename, label: file.label };
+      this.generatedFiles.set([download]);
+      this.downloader.download(download);
+      this.exportMessage.set(files.length > 1 ? `已将 ${files.length} 份 PDF 打包为 1 个 ZIP。` : '已生成 1 份 PDF。');
     } catch (error) {
       this.exportError.set(error instanceof Error ? error.message : 'PDF 生成失败,请重试。');
     } finally {

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

@@ -33,6 +33,9 @@
       } @else if (antiForgetError()) {
         <button type="button" class="coach-memory-state error" (click)="retryAntiForget()"><lucide-icon [img]="icons.AlertCircle" [size]="21" /><span>{{ antiForgetError() }},点击重试</span></button>
       } @else {
+        @if (reviewData()?.legacyReviewSync?.state === 'unavailable') {
+          <p class="today-complete" role="status">旧系统暂未同步,当前显示已同步的任务。</p>
+        }
         <div class="coach-memory-summary" aria-label="抗遗忘任务统计">
           <span><strong>{{ coachReviewSummary().todayTotal ?? coachReviewSummary().today }}</strong><small>今日任务</small></span>
           <span><strong>{{ coachReviewSummary().todayPending ?? 0 }}</strong><small>今日待完成</small></span>
@@ -51,7 +54,7 @@
           }
         </div>
       }
-      @if (!antiForgetLoading() && !antiForgetError()) {
+      @if (!antiForgetLoading() && !antiForgetError() && reviewData()?.legacyReviewSync?.state !== 'unavailable') {
         @if ((coachReviewSummary().todayTotal ?? 0) > 0 && coachReviewSummary().todayPending === 0) { <p class="today-complete" role="status">今天的复习任务已全部完成</p> }
       }
       <a class="coach-memory-footer" routerLink="/pages/stu/stu_calendar"><span>查看全部学员复习计划</span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></a>

+ 7 - 3
scripts/cloud/mobile-teaching.js

@@ -39,7 +39,11 @@ function notesReviewSourceSql() {
 }
 async function appReviewPage(input,current) {
   const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input),includeMetadata=input.includeMetadata!==false,today=appOverviewToday();
-  const reviewTarget=coach&&number(input.studentId)?await appReviewTarget(current,input.studentId):coach?0:uid,legacyReviewSync=reviewTarget?await syncLegacyReviews(reviewTarget,current,input.refresh===true):undefined;
+  const reviewTarget=coach&&number(input.studentId)?await appReviewTarget(current,input.studentId):coach?0:uid;
+  const refreshCoachToday=coach&&!reviewTarget&&input.scope==='today'&&p.index===1&&input.refresh===true&&uid===ownLegacyId(current);
+  if(refreshCoachToday)assertLessonTenant(current);
+  const legacyReviewSync=reviewTarget?await syncLegacyReviews(reviewTarget,current,input.refresh===true):
+    refreshCoachToday?await syncLegacyCoachTodayReviews(uid,current,today):undefined;
   const args=[DEFAULT_COMPANY_ID,String(uid)],clauses=['m."company"=$1','m."'+(coach?'plid':'yhid')+'"::text=$2'];
   // Empty writing records are deliberate notes reviews; other empty sources remain hidden.
   const notesSource=notesReviewSourceSql();
@@ -83,10 +87,10 @@ function mobileObjectData(row) {
   if(['CommonModel','DailyStudyRecord','PracticeRecord'].includes(row.className))data.company=DEFAULT_COMPANY_ID;
   return data;
 }
-async function mobileAtomicWrite(input,current,operations,scope='') {
+async function mobileAtomicWrite(input,current,operations,scope='',db=Psql) {
   const key=mobileCommandKey(input)||('legacy:'+randomObjectId());
   const semantic={...input};delete semantic.requestKey;delete semantic.token;
-  const row=await Psql.one('SELECT xs_mobile_teaching_write($1,$2,$3,md5($4),$5::jsonb) AS result',[DEFAULT_COMPANY_ID,scope||String(ownLegacyId(current)),String(current.id)+':'+key,JSON.stringify(semantic),JSON.stringify(operations)]);
+  const row=await db.one('SELECT xs_mobile_teaching_write($1,$2,$3,md5($4),$5::jsonb) AS result',[DEFAULT_COMPANY_ID,scope||String(ownLegacyId(current)),String(current.id)+':'+key,JSON.stringify(semantic),JSON.stringify(operations)]);
   return row.result;
 }
 

+ 72 - 21
scripts/deploy-admin-functions.mjs

@@ -2827,30 +2827,81 @@ async function syncLegacyLearning(targetId,current,force=false){const key=String
 const LEGACY_REVIEW_PULLS=new Map();
 function legacyReviewRecord(source,targetId){const generalId=number(source&&firstValue(source.GeneralID,source.generalId)),studentId=number(source&&firstValue(source.yhid,source.UserID,source.userId));if(!generalId||studentId!==number(targetId))return null;const createdAt=legacyLearningIso(firstValue(source.CreateTime,source.createTime,source.UpDateTime,source.updatedAt))||new Date().toISOString();return{generalId,itemId:generalId,sourceKey:'legacy:model:60:general:'+generalId,createdAt,common:{generalId,itemId:generalId,modelId:60,nodeId:number(firstValue(source.NodeID,source.nodeId),388)||388,tableName:'ZL_C_gywjl',title:String(firstValue(source.Title,source.title,'抗遗忘复习')||'抗遗忘复习').slice(0,200),subtitle:String(firstValue(source.Subtitle,source.subtitle,'')||'').slice(0,500),inputer:String(firstValue(source.Inputer,source.inputer,'')||'').slice(0,100),hits:number(firstValue(source.Hits,source.hits)),status:number(firstValue(source.Status,source.status),99),createTime:createdAt},addon:{fxzt:String(firstValue(source.fxzt,'0')||'0'),kcid:number(source.kcid),plid:number(source.plid),wcsj:String(source.wcsj||''),yhid:studentId,kywrq:String(source.kywrq||'').slice(0,20),kywsj:String(source.kywsj||'').slice(0,40),xxjlid:number(source.xxjlid),orderId:number(source.orderId)}};}
 function legacyReviewSignature(row){if(!row)return'';return JSON.stringify({title:String(row.title||''),subtitle:String(row.subtitle||''),inputer:String(row.inputer||''),hits:number(row.hits),status:number(row.status,99),fxzt:String(row.fxzt||'0'),kcid:number(row.kcid),plid:number(row.plid),wcsj:String(row.wcsj||''),yhid:number(row.yhid),kywrq:String(row.kywrq||''),kywsj:String(row.kywsj||''),xxjlid:number(row.xxjlid),orderId:number(row.orderId)});}
-async function legacyReviewPage(config,targetId,page,pageSize){const params=new URLSearchParams({action:'content_list',modelId:'60',nid:'388',myfield2:'yhid='+targetId,orders:'CreateTime=desc',cpage:String(page),psize:String(pageSize),apiId:config.apiId,apiKey:config.apiKey}),url=config.url+(config.url.includes('?')?'&':'?')+params.toString();let response,raw,payload;try{response=await fetch(url,{headers:{accept:'application/json'},signal:typeof AbortSignal!=='undefined'&&AbortSignal.timeout?AbortSignal.timeout(12000):undefined});raw=await response.text();payload=JSON.parse(raw);}catch(error){throw new Error('旧系统抗遗忘记录读取失败');}if(!response.ok||number(payload&&payload.retcode)===-1)throw new Error(String(payload&&payload.retmsg||'旧系统抗遗忘记录读取失败'));let result=payload&&payload.result;if(typeof result==='string')try{result=JSON.parse(result);}catch(_){result=[];}const rows=Array.isArray(result)?result:Array.isArray(result&&result.items)?result.items:[],meta=payload&&payload.page||result&&result.page||{};return{rows,pageCount:Math.max(1,number(meta.pageCount,1)),total:number(meta.itemCount,rows.length)};}
+async function legacyReviewPage(config,filter,page,pageSize){const params=new URLSearchParams({action:'content_list',modelId:'60',nid:'388',myfield2:filter,orders:'CreateTime=desc',cpage:String(page),psize:String(pageSize),apiId:config.apiId,apiKey:config.apiKey}),url=config.url+(config.url.includes('?')?'&':'?')+params.toString();let response,raw,payload;try{response=await fetch(url,{headers:{accept:'application/json'},signal:typeof AbortSignal!=='undefined'&&AbortSignal.timeout?AbortSignal.timeout(12000):undefined});raw=await response.text();payload=JSON.parse(raw);}catch(error){throw new Error('旧系统抗遗忘记录读取失败');}if(!response.ok||number(payload&&payload.retcode)===-1)throw new Error(String(payload&&payload.retmsg||'旧系统抗遗忘记录读取失败'));let result=payload&&payload.result;if(typeof result==='string')try{result=JSON.parse(result);}catch(_){result=[];}const rows=Array.isArray(result)?result:Array.isArray(result&&result.items)?result.items:[],meta=payload&&payload.page||result&&result.page||{};return{rows,pageCount:Math.max(1,number(meta.pageCount,1)),total:number(meta.itemCount,rows.length)};}
 async function performLegacyReviewSync(targetId,current,force=false){
  const config=await appLegacyConfig();if(!config.url||!config.apiId||!config.apiKey)throw new Error('旧系统抗遗忘同步尚未配置');
- const pageSize=500,first=await legacyReviewPage(config,targetId,1,pageSize),targetCount=await Psql.one('SELECT COUNT(*)::int AS total FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "yhid"=$2',[DEFAULT_COMPANY_ID,targetId]),needsFull=force||number(targetCount.total)<first.total,pageCount=needsFull?Math.min(40,first.pageCount):1,pages=[first];
- if(pageCount>1)pages.push(...await Promise.all(Array.from({length:pageCount-1},(_,index)=>legacyReviewPage(config,targetId,index+2,pageSize))));
+ const pageSize=500,first=await legacyReviewPage(config,'yhid='+targetId,1,pageSize),targetCount=await Psql.one('SELECT COUNT(*)::int AS total FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "yhid"=$2',[DEFAULT_COMPANY_ID,targetId]),needsFull=force||number(targetCount.total)<first.total,pageCount=needsFull?Math.min(40,first.pageCount):1,pages=[first];
+ if(pageCount>1)pages.push(...await Promise.all(Array.from({length:pageCount-1},(_,index)=>legacyReviewPage(config,'yhid='+targetId,index+2,pageSize))));
  const byId=new Map();for(const source of pages.flatMap(item=>item.rows)){const row=legacyReviewRecord(source,targetId);if(row)byId.set(row.generalId,row);}
- const records=[...byId.values()],ids=records.map(row=>String(row.generalId));
- const existing=ids.length?await Psql.query('SELECT c."generalId",c."itemId",to_jsonb(m) AS "taskSnapshot" FROM "CommonModel" c LEFT JOIN "MemoryPracticeRecord" m ON m.company=c.company AND m.id=c."itemId" WHERE c.company=$1 AND c."modelId"=60 AND c."generalId"::text=ANY($2::text[])',[DEFAULT_COMPANY_ID,ids]):[];
- const known=new Map(existing.map(r=>[number(r.generalId),r])),usedIds=new Set((ids.length?await Psql.query('SELECT CAST("id" AS text) AS id FROM "MemoryPracticeRecord" WHERE company=$1 AND id::text=ANY($2::text[])',[DEFAULT_COMPANY_ID,ids]):[]).map(r=>String(r.id)));
- let written=0,conflicts=0;
- for(const row of records){const saved=known.get(row.generalId);if(!saved?.taskSnapshot)continue;
-  const fields=Object.keys(row.addon).filter(f=>String(row.addon[f]??'')!==String(saved.taskSnapshot[f]??''));if(!fields.length)continue;
-  const values=Object.fromEntries(fields.map(f=>[f,row.addon[f]]));
-  const result=await Psql.tx(async t=>{const applied=(await t.one('SELECT xs_legacy_review_patch($1,$2,$3::jsonb,$4::jsonb) result',[DEFAULT_COMPANY_ID,saved.taskSnapshot.objectId,JSON.stringify(values),JSON.stringify(saved.taskSnapshot)])).result;
-   if(!applied.applied)await t.none('INSERT INTO "ReviewAssignmentConflict" ("objectId",company,"taskId",incoming,preserved) VALUES(md5($1||$2||$3),$1,$2,$3::jsonb,$4::jsonb) ON CONFLICT DO NOTHING',[DEFAULT_COMPANY_ID,saved.taskSnapshot.objectId,JSON.stringify(row.addon),JSON.stringify(saved.taskSnapshot)]);return applied;});
-  if(result.applied)written++;else conflicts++;
- }
- const missing=records.filter(r=>!known.get(r.generalId)?.taskSnapshot);
- for(let index=0;index<missing.length;index+=50){const operations=[];for(const row of missing.slice(index,index+50)){
-  const saved=known.get(row.generalId),preferred=saved?number(saved.itemId):row.generalId,itemId=!saved&&usedIds.has(String(preferred))?900000000000+row.generalId*100+60:preferred;usedIds.add(String(itemId));
-  const common={objectId:randomObjectId(),createdAt:row.createdAt,company:DEFAULT_COMPANY_ID,sourceKey:row.sourceKey,...row.common,itemId},addon={objectId:randomObjectId(),createdAt:row.createdAt,company:DEFAULT_COMPANY_ID,sourceKey:row.sourceKey,id:itemId,...row.addon};
-  operations.push({table:'MemoryPracticeRecord',data:addon,match:{company:DEFAULT_COMPANY_ID,id:itemId}},{table:'CommonModel',data:common,match:{company:DEFAULT_COMPANY_ID,modelId:60,generalId:row.generalId},reference:{itemId:'id'}});
- }if(operations.length){await mobileAtomicWrite({requestKey:'legacy-review:'+targetId+':'+Date.now()+':'+index},current,operations,String(targetId));written+=operations.length/2;}}
- return{state:conflicts?'conflict':'current',conflicts,lastSuccessAt:new Date().toISOString(),lagSeconds:0,pending:0,pulled:records.length,written,sourceTotal:first.total,complete:pageCount===first.pageCount};
+ const records=[...byId.values()],{written,conflicts}=await applyLegacyReviewRecords(records,targetId,current);
+ return {state:conflicts?'conflict':'current',conflicts,lastSuccessAt:new Date().toISOString(),lagSeconds:0,pending:0,pulled:records.length,written,sourceTotal:first.total,complete:pageCount===first.pageCount};
+}
+async function applyLegacyReviewRecords(records,targetId,current,coachRefresh=false){
+ if(!records.length)return {written:0,conflicts:0};
+ return Psql.tx(async t=>{
+  // Match the direct importer lock order and serialize each learner's upserts.
+  await t.none("SET LOCAL lock_timeout='3s'");
+  if(coachRefresh)await t.one("SELECT pg_advisory_xact_lock(hashtext('xs-credit:'||$1))",[DEFAULT_COMPANY_ID]);
+  await t.one('SELECT pg_advisory_xact_lock(hashtextextended($1,0))',[DEFAULT_COMPANY_ID+':'+targetId]);
+  const ids=records.map(row=>String(row.generalId));
+  const existing=await t.query('SELECT c."generalId",c."itemId",c.status,c."sourceKey",c."teachingManaged",to_jsonb(m) AS "taskSnapshot" FROM "CommonModel" c LEFT JOIN "MemoryPracticeRecord" m ON m.company=c.company AND m.id=c."itemId" WHERE c.company=$1 AND c."modelId"=60 AND c."generalId"::text=ANY($2::text[])',[DEFAULT_COMPANY_ID,ids]);
+  const alternateId=row=>900000000000+row.generalId*100+60;
+  const known=new Map(existing.map(r=>[number(r.generalId),r])),usedIds=new Set((await t.query('SELECT CAST("id" AS text) AS id FROM "MemoryPracticeRecord" WHERE company=$1 AND id::text=ANY($2::text[])',[DEFAULT_COMPANY_ID,[...ids,...records.map(row=>String(alternateId(row)))]])).map(r=>String(r.id)));
+  let written=0,conflicts=0;
+  for(const row of records){const saved=known.get(row.generalId);if(!saved?.taskSnapshot)continue;
+   const fields=Object.keys(row.addon).filter(f=>String(row.addon[f]??'')!==String(saved.taskSnapshot[f]??''));if(!fields.length)continue;
+   const values=Object.fromEntries(fields.map(f=>[f,row.addon[f]]));
+   const result=(await t.one('SELECT xs_legacy_review_patch($1,$2,$3::jsonb,$4::jsonb) result',[DEFAULT_COMPANY_ID,saved.taskSnapshot.objectId,JSON.stringify(values),JSON.stringify(saved.taskSnapshot)])).result;
+   if(!result.applied)await t.none('INSERT INTO "ReviewAssignmentConflict" ("objectId",company,"taskId",incoming,preserved) VALUES(md5($1||$2||$3),$1,$2,$3::jsonb,$4::jsonb) ON CONFLICT DO NOTHING',[DEFAULT_COMPANY_ID,saved.taskSnapshot.objectId,JSON.stringify(row.addon),JSON.stringify(saved.taskSnapshot)]);
+   if(result.applied)written++;else conflicts++;
+  }
+  const missing=records.filter(row=>{
+   const saved=known.get(row.generalId);
+   if(saved?.taskSnapshot)return false;
+   if(coachRefresh&&saved&&(saved.teachingManaged||number(saved.status)===-2||saved.sourceKey!==row.sourceKey)){conflicts++;return false;}
+   return true;
+  });
+  for(let index=0;index<missing.length;index+=50){const operations=[];for(const row of missing.slice(index,index+50)){
+   const saved=known.get(row.generalId),preferred=saved?number(saved.itemId):row.generalId,itemId=!saved&&usedIds.has(String(preferred))?alternateId(row):preferred;
+   if(usedIds.has(String(itemId))){conflicts++;continue;}
+   usedIds.add(String(itemId));
+   const common={objectId:randomObjectId(),createdAt:row.createdAt,company:DEFAULT_COMPANY_ID,sourceKey:row.sourceKey,...row.common,itemId},addon={objectId:randomObjectId(),createdAt:row.createdAt,company:DEFAULT_COMPANY_ID,sourceKey:row.sourceKey,id:itemId,...row.addon};
+   operations.push({table:'MemoryPracticeRecord',data:addon,match:{company:DEFAULT_COMPANY_ID,id:itemId}},{table:'CommonModel',data:common,match:{company:DEFAULT_COMPANY_ID,modelId:60,generalId:row.generalId},reference:{itemId:'id'}});
+  }if(operations.length){await mobileAtomicWrite({requestKey:'legacy-review:'+targetId+':'+Date.now()+':'+index},current,operations,String(targetId),t);written+=operations.length/2;}}
+  return {written,conflicts};
+ });
+}
+const LEGACY_COACH_TODAY_REVIEW_PULLS = new Map();
+async function syncLegacyCoachTodayReviews(coachId,current,today) {
+ const key=coachId+':'+today;
+ if(LEGACY_COACH_TODAY_REVIEW_PULLS.has(key))return LEGACY_COACH_TODAY_REVIEW_PULLS.get(key);
+ const promise=(async()=>{
+  const config=await appLegacyConfig();
+  if(!config.url||!config.apiId||!config.apiKey)throw new Error('旧系统抗遗忘同步尚未配置');
+  const filter='plid='+coachId+'|kywrq='+today,pageSize=500;
+  const first=await legacyReviewPage(config,filter,1,pageSize);
+  if(first.pageCount>4)throw new Error('旧系统当天复习记录超出刷新范围');
+  const pages=[first];
+  for(let page=2;page<=first.pageCount;page++)pages.push(await legacyReviewPage(config,filter,page,pageSize));
+  const source=pages.flatMap(page=>page.rows);
+  if(source.length!==first.total||pages.some(page=>page.total!==first.total||page.pageCount!==first.pageCount))throw new Error('旧系统当天复习分页不完整');
+  const byLearner=new Map();
+  for(const item of source){
+   const learner=number(firstValue(item.yhid,item.UserID,item.userId)),row=legacyReviewRecord(item,learner);
+   if(!row||!learner||row.common.status!==99||row.addon.plid!==coachId||(row.addon.kywrq||row.addon.kywsj).slice(0,10)!==today)continue;
+   if(!byLearner.has(learner))byLearner.set(learner,new Map());
+   byLearner.get(learner).set(row.generalId,row);
+  }
+  let written=0,conflicts=0,pulled=0;
+  for(const [learner,rows] of byLearner){
+   const result=await applyLegacyReviewRecords([...rows.values()],learner,current,true);
+   written+=result.written;conflicts+=result.conflicts;pulled+=rows.size;
+  }
+  return {state:conflicts?'conflict':'current',conflicts,written,pulled,complete:true,lastSuccessAt:new Date().toISOString(),lagSeconds:0,pending:0};
+ })().catch(error=>({state:'unavailable',complete:false,message:String(error&&error.message||'旧系统抗遗忘记录同步失败')}))
+  .finally(()=>LEGACY_COACH_TODAY_REVIEW_PULLS.delete(key));
+ LEGACY_COACH_TODAY_REVIEW_PULLS.set(key,promise);
+ return promise;
 }
 async function syncLegacyReviews(targetId,current,force=false){const key=String(targetId),now=Date.now(),cached=LEGACY_REVIEW_PULLS.get(key);if(!force&&cached&&now-cached.startedAt<10000)return cached.promise;const promise=performLegacyReviewSync(targetId,current,force).catch((error)=>({state:'unavailable',lastSuccessAt:'',lagSeconds:null,pending:0,pulled:0,written:0,message:String(error&&error.message||'旧系统抗遗忘记录同步失败')}));LEGACY_REVIEW_PULLS.set(key,{startedAt:now,promise});return promise;}
 const LEGACY_APPOINTMENT_PULLS=new Map(),LEGACY_APPOINTMENT_VERSIONS=new Map();

+ 26 - 4
scripts/tests/legacy-review-pull.test.mjs

@@ -8,7 +8,7 @@ const source = gatewaySource.match(/const LEGACY_REVIEW_PULLS=new Map\(\);[\s\S]
   .replace(/\nconst APP_LIVE_SCOPES=[\s\S]*$/, '');
 assert.ok(source, '未找到旧系统抗遗忘记录拉取实现');
 
-function contextFor(rows, initial = []) {
+function contextFor(rows, initial = [], filter = 'yhid=42') {
   let sequence = 0;
   let stored = initial;
   const conflicts = [];
@@ -30,12 +30,17 @@ function contextFor(rows, initial = []) {
     randomObjectId() { sequence += 1; return `object${String(sequence).padStart(14, '0')}`; },
     async appLegacyConfig() { return { url: 'https://legacy.example/api', apiId: 'id', apiKey: 'key' }; },
     async fetch(url) {
-      assert.match(String(url), /myfield2=yhid%3D42/);
+      assert.equal(new URL(url).searchParams.get('myfield2'), filter);
       return { ok: true, async text() { return JSON.stringify({ retcode: 1, result: JSON.stringify(rows), page: { itemCount: rows.length, pageCount: 1 } }); } };
     },
     Psql: {
-      async none(sql, args) { if(sql.includes('ReviewAssignmentConflict')) {conflicts.push(args);return;}throw Error(sql);},
+      async none(sql, args) {
+        if (sql.startsWith('SET LOCAL lock_timeout')) return;
+        if (sql.includes('ReviewAssignmentConflict')) { conflicts.push(args); return; }
+        throw Error(sql);
+      },
       async one(sql, args) {
+        if (sql.includes('pg_advisory_xact_lock')) return {};
         if(sql.includes('xs_legacy_review_patch')) {const item=stored.find(r=>(r.taskObjectId||r.objectId)===args[1]);if(Number(item?.assignmentVersion)>0)return {result:{applied:false,reason:'protected'}};Object.assign(item,JSON.parse(args[2]));return {result:{applied:true}};}
         if (sql.includes('COUNT(*)') && sql.includes('MemoryPracticeRecord')) return { total: stored.length };
         throw new Error(`unexpected one sql: ${sql}`);
@@ -58,10 +63,27 @@ function contextFor(rows, initial = []) {
     },
   };
   context.Psql.tx=async callback=>callback(context.Psql);
-  vm.runInNewContext(`${source};this.normalize=legacyReviewRecord;this.sync=performLegacyReviewSync`, context);
+  vm.runInNewContext(`${source};this.normalize=legacyReviewRecord;this.sync=performLegacyReviewSync;this.syncToday=(...args)=>syncLegacyCoachTodayReviews(...args)`, context);
   return { context, writes, conflicts };
 }
 
+test('教练刷新仅补齐本人当天的抗遗忘任务', async () => {
+  const day = '2026-11-08';
+  const base = { yhid: 42, plid: 155, kywrq: day, CreateTime: day + ' 15:03:00', fxzt: '1' };
+  const { context, writes } = contextFor([
+    { ...base, GeneralID: 889405 },
+    { ...base, GeneralID: 889406, yhid: 43 },
+    { ...base, GeneralID: 889407, plid: 156 },
+    { ...base, GeneralID: 889408, kywrq: '2026-11-09' },
+  ], [], 'plid=155|kywrq=' + day);
+  const coach = { id: 'coach' };
+  const result = await context.syncToday(155, coach, day);
+  assert.equal(result.state, 'current');
+  assert.equal(result.written, 2);
+  assert.deepEqual(writes.map(batch => batch[1].match.generalId), [889405, 889406]);
+  assert.ok(writes.every(batch => batch[0].data.fxzt === '1'));
+});
+
 test('旧系统抗遗忘记录只接收当前学员并保留计划与完成状态', () => {
   const { context } = contextFor([]);
   assert.equal(context.normalize({ GeneralID: 1, yhid: 7 }, 42), null);

+ 2 - 2
scripts/tests/legacy-review-sync-policy.test.mjs

@@ -4,6 +4,7 @@ import {readFileSync} from 'node:fs';
 import vm from 'node:vm';
 import {PGlite} from '@electric-sql/pglite';
 const db=new PGlite(),q=async(s,p=[])=>(await db.query(s,p)).rows,one=async(s,p=[])=>(await q(s,p))[0];
+const Psql={query:q,one,none:async(s,p)=>{await q(s,p);},tx:async fn=>{await db.exec('BEGIN');try{const result=await fn(Psql);await db.exec('COMMIT');return result;}catch(e){await db.exec('ROLLBACK');throw e;}}};
 const sql=n=>readFileSync(new URL('../sql/'+n+'.sql',import.meta.url),'utf8');
 let sequence=200;
 const snapshot=async id=>(await one('SELECT to_jsonb(m) value FROM "MemoryPracticeRecord" m WHERE "objectId"=$1',[id])).value;
@@ -84,7 +85,6 @@ test('陈旧快照、未确认的本地修改、来源身份变化和无权限
 test('页面刷新和夜间同步共用保护及来源基线,页面同步后仍可跟进下一次旧切换',async()=>{
  const id=await task('page-follow'),current=await snapshot('page-follow');
  const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8').match(/const LEGACY_REVIEW_PULLS=new Map\(\);[\s\S]*?\nconst APP_LIVE_SCOPES=/)[0].replace(/\nconst APP_LIVE_SCOPES=[\s\S]*$/,'');
- const Psql={query:q,one,none:async(s,p)=>{await q(s,p);},tx:async fn=>{await db.exec('BEGIN');try{const result=await fn(Psql);await db.exec('COMMIT');return result;}catch(e){await db.exec('ROLLBACK');throw e;}}};
  const context={Psql,Map,Date,JSON,String,Number,Math,Promise,URLSearchParams,AbortSignal,DEFAULT_COMPANY_ID:'c',number:(v,f=0)=>Number.isFinite(Number(v))?Number(v):f,firstValue:(...v)=>v.find(x=>x!==undefined&&x!==null&&x!==''),legacyLearningIso:()=>new Date('2026-09-01').toISOString(),appLegacyConfig:async()=>({url:'https://old.invalid',apiId:'id',apiKey:'key'}),fetch:async()=>({ok:true,text:async()=>JSON.stringify({retcode:1,result:[{...current,GeneralID:id,plid:11}],page:{itemCount:1,pageCount:1}})}),mobileAtomicWrite:async()=>{throw Error('Existing tasks must use the shared source updater');}};
  vm.createContext(context);vm.runInContext(source+';this.sync=performLegacyReviewSync;',context);
  const result=await context.sync(20,{id:'student'},true);assert.equal(result.written,1);assert.equal(result.conflicts,0);assert.equal((await snapshot('page-follow')).plid,11);
@@ -122,7 +122,7 @@ test('先到队列的当天完成副本不被逾期原件永久排除,最终
 test('页面修复已存在主表的缺失附表时,同一事务建立来源状态和费用队列',async()=>{
  const id=await task('page-orphan'),old=await snapshot('page-orphan');await q('DELETE FROM "MemoryPracticeRecord" WHERE "objectId"=\'page-orphan\'');await q('DELETE FROM "LegacyReviewSyncState" WHERE "taskId"=\'page-orphan\'');
  const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8').match(/const LEGACY_REVIEW_PULLS=new Map\(\);[\s\S]*?\nconst APP_LIVE_SCOPES=/)[0].replace(/\nconst APP_LIVE_SCOPES=[\s\S]*$/,'');let serial=0;
- const context={Psql:{query:q,one},Map,Date,JSON,String,Number,Math,Promise,URLSearchParams,AbortSignal,DEFAULT_COMPANY_ID:'c',number:(v,f=0)=>Number.isFinite(Number(v))?Number(v):f,firstValue:(...v)=>v.find(x=>x!==undefined&&x!==null&&x!==''),randomObjectId:()=>('orphan-object-'+(++serial)),legacyLearningIso:()=>new Date('2026-09-01').toISOString(),appLegacyConfig:async()=>({url:'https://old.invalid',apiId:'id',apiKey:'key'}),fetch:async()=>({ok:true,text:async()=>JSON.stringify({retcode:1,result:[{...old,GeneralID:id,plid:11,fxzt:'1',wcsj:'2026-09-05 10:00'}],page:{itemCount:1,pageCount:1}})}),mobileAtomicWrite:async(input,current,operations)=>(await one("SELECT xs_mobile_teaching_write('c','20','orphan-page-repair','fixed-fingerprint',$1::jsonb) result",[JSON.stringify(operations)])).result};
+ const context={Psql,Map,Date,JSON,String,Number,Math,Promise,URLSearchParams,AbortSignal,DEFAULT_COMPANY_ID:'c',number:(v,f=0)=>Number.isFinite(Number(v))?Number(v):f,firstValue:(...v)=>v.find(x=>x!==undefined&&x!==null&&x!==''),randomObjectId:()=>('orphan-object-'+(++serial)),legacyLearningIso:()=>new Date('2026-09-01').toISOString(),appLegacyConfig:async()=>({url:'https://old.invalid',apiId:'id',apiKey:'key'}),fetch:async()=>({ok:true,text:async()=>JSON.stringify({retcode:1,result:[{...old,GeneralID:id,plid:11,fxzt:'1',wcsj:'2026-09-05 10:00'}],page:{itemCount:1,pageCount:1}})}),mobileAtomicWrite:async(input,current,operations)=>(await one("SELECT xs_mobile_teaching_write('c','20','orphan-page-repair','fixed-fingerprint',$1::jsonb) result",[JSON.stringify(operations)])).result};
  vm.createContext(context);vm.runInContext(source+';this.sync=performLegacyReviewSync;',context);const result=await context.sync(20,{id:'student'},true);assert.equal(result.written,1);
  const tracked=await one('SELECT * FROM "LegacyReviewSyncState" WHERE "taskId"=(SELECT "objectId" FROM "MemoryPracticeRecord" WHERE id=$1)',[id]);assert.equal(tracked.pendingIncome,true);const completed=await drain();assert.equal(completed.earned,1);assert.equal(completed.amount,1);
 });

+ 29 - 0
scripts/tests/review-student-names.test.mjs

@@ -4,6 +4,35 @@ import {readFileSync} from 'node:fs';
 import vm from 'node:vm';
 import {PGlite} from '@electric-sql/pglite';
 const source=readFileSync(new URL('../cloud/mobile-teaching.js',import.meta.url),'utf8');
+test('仅本人主动刷新今日第一页时核对旧系统,失败仍返回已同步的任务', async () => {
+ const db = new PGlite();
+ try {
+  await db.exec(readFileSync(new URL('./fixtures/teacher-review-schema.sql',import.meta.url),'utf8'));
+  const query = async (sql, args) => (await db.query(sql, args)).rows;
+  const pulls = [];
+  const coach = { get: () => 3 };
+  const ctx = vm.createContext({
+   DEFAULT_COMPANY_ID: 'co', number: (v,f=0) => Number(v)||f, authorizeRequestedUser: async (_current,uid) => uid||30,
+   ownLegacyId: () => 30, assertLessonTenant: current => assert.equal(current,coach),
+   appOverviewToday: () => '2026-10-14', legacyAliases: row => row, appLiveVersionSnapshot: async () => ({}),
+   syncLegacyCoachTodayReviews: async (...args) => { pulls.push(args); return {state:'unavailable'}; },
+   Psql: {query,one:async (sql,args)=>(await query(sql,args))[0]},
+  });
+  vm.runInContext(source,ctx);
+  const read = overrides => ctx.appReviewPage({scope:'today',includeIncome:false,includeMetadata:false,...overrides},coach);
+  await read({});
+  await read({refresh:true,page:2});
+  await read({refresh:true,scope:'overdue'});
+  await read({refresh:true,uid:31});
+  assert.equal(pulls.length,0);
+  const refreshed = await read({refresh:true,date:'2000-01-01'});
+  assert.deepEqual(pulls,[[30,coach,'2026-10-14']]);
+  assert.equal(refreshed.legacyReviewSync.state,'unavailable');
+  assert.equal(refreshed.items.length,2);
+ } finally {
+  await db.close();
+ }
+});
 test('历史复习首次学习时间来自原始学习记录,不显示九月一日迁移时间',async()=>{
  const db=new PGlite();try{
   await db.exec(readFileSync(new URL('./fixtures/teacher-review-schema.sql',import.meta.url),'utf8'));