Просмотр исходного кода

fix(teaching): show ended writing reviews and streamline word selection

彭峰 3 часов назад
Родитель
Сommit
8c0cf6a728

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

@@ -53,6 +53,13 @@ import { nextReviewWordOrder } from '../../core/review-word-order';
 
 type SourceItem = Record<string, unknown>;
 
+interface LessonBatchResult {
+  batchId: string;
+  batchVersion: number;
+  learned?: number;
+  resumed?: boolean;
+}
+
 interface Word extends SourceItem {
   id: string;
   wordId: string;
@@ -1877,19 +1884,21 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     }, error: (e: ApiError) => this.platform.toast(e.message, 'error')});
   }
 
-  private saveLessonBatch(mode: 'draft' | 'confirm' | 'mastery', words: Word[]): Observable<unknown> {
+  private saveLessonBatch(mode: 'draft' | 'open-draft' | 'confirm' | 'mastery', words: Word[]): Observable<LessonBatchResult> {
     const batchId = String(this.storageGet('batchId') || crypto.randomUUID());
     this.storageSet('batchId', batchId);
     const version = Number(this.storageGet('batchVersion') || 0);
-    const xxqs = words.map(w => ({GeneralID: w.wordId, Title: w.word, check: mode !== 'draft' ? this.reviewValue(w) : 0}));
-    return this.api.post<any>('stu_record_update_v2', {
+    const xxqs = words.map(w => ({GeneralID: w.wordId, Title: w.word, check: mode === 'confirm' || mode === 'mastery' ? this.reviewValue(w) : 0}));
+    return this.api.post<LessonBatchResult>('stu_record_update_v2', {
       orderId: this.query('orderId') || this.query('id'), uid: this.session.student()?.userId,
       batchId, saveMode: mode, expectedVersion: version,
       requestKey: this.draft().command('batch:' + batchId + ':' + version + ':' + mode + ':' + JSON.stringify(xxqs)),
       addon: JSON.stringify({bindingId: this.query('bindingId'), xxqs}),
-    }).pipe(tap(({result}) => {
+    }).pipe(map(({result}) => {
+      this.storageSet('batchId', result.batchId || batchId);
       this.storageSet('batchVersion', result.batchVersion);
-      this.lessonLearnedCount.set(Number(result.learned || 0));
+      if (result.learned !== undefined) this.lessonLearnedCount.set(Number(result.learned));
+      return result;
     }));
   }
 
@@ -2232,18 +2241,20 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   private beginSelectedFlow(chosen: Word[], mode: '' | 'newword' | 'history_word'): void {
     if (this.query('orderId') && !mode) {
+      this.clearLearningStorage();
       this.submitting.set(true);
-      this.api.get<any>('app_lesson_study', {orderId: this.query('orderId')}).pipe(finalize(() => this.submitting.set(false))).subscribe({
-        next: ({result}) => {
-          const pending = result.batches.find((b: any) => b.status === 'draft' && (!this.query('bindingId') || b.bindingId === this.query('bindingId')));
-          if (pending) {
-            this.storageSet('batchId', pending.batchId); this.storageSet('batchVersion', pending.version);
-            this.platform.toast('还有待确认批次,请先确认掌握状态'); this.navigateStage('/pages/stu/pl_end'); return;
+      this.saveLessonBatch('open-draft', chosen).pipe(finalize(() => this.submitting.set(false))).subscribe({
+        next: result => {
+          if (result.resumed) {
+            this.platform.toast('还有待确认批次,请先确认掌握状态');
+            this.navigateStage('/pages/stu/pl_end');
+            return;
           }
-          this.clearLearningStorage(); this.submitting.set(true);
-          this.saveLessonBatch('draft', chosen).pipe(finalize(() => this.submitting.set(false))).subscribe({next: () => this.beginSelectedStages(chosen, mode, true),error: (e: ApiError) => this.platform.toast(e.message, 'error')});
-        }, error: (e: ApiError) => this.platform.toast(e.message, 'error'),
-      }); return;
+          this.beginSelectedStages(chosen, mode, true);
+        },
+        error: (e: ApiError) => this.platform.toast(e.message, 'error'),
+      });
+      return;
     }
     this.beginSelectedStages(chosen, mode);
   }

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

@@ -25,17 +25,19 @@ function reviewStudentNameSql() {
   return `COALESCE(NULLIF(BTRIM(u."realName"),''),NULLIF(BTRIM(u."nickname"),''),NULLIF(BTRIM(u."legacyUserData"->>'HoneyName'),''),CASE WHEN POSITION('@' IN c."title")>0 THEN NULLIF(BTRIM(SPLIT_PART(c."title",'@',1)),'') END,NULLIF(BTRIM(u."username"),''),'学员 '||m."yhid"::text)`;
 }
 function notesReviewSourceSql() {
-  // Some classified legacy bookings retain dszt=0; an exact legacy lesson proves completion without changing billing state.
+  // A legacy lesson can be finished while its booking still awaits confirmation.
   return `EXISTS (SELECT 1 FROM "CommonModel" ac JOIN "CourseAppointment" a ON a.company=ac.company AND a.id=ac."itemId"
     WHERE ac.company=d.company AND ac."modelId"=54 AND ac."generalId"::text=d.dsid::text AND COALESCE(ac.status,99)<>-2
     AND a.szyh::text=d."userId"::text
     AND ((a."courseCategoryKey" IN ('primary_writing','middle_writing','high_writing') AND a.dszt IN ('11','20','30','40'))
       OR ((NULLIF(BTRIM(a."courseCategoryKey"),'') IS NULL OR a."courseCategoryKey" IN ('primary_writing','middle_writing','high_writing'))
         AND a.kcid::text='398' AND a."sourceKey"='legacy:model:54:general:'||ac."generalId"::text AND ac."sourceKey"=a."sourceKey"
-        AND (a.dszt IN ('30','40') OR (a.dszt='0' AND EXISTS (
+        AND (a.dszt IN ('30','40') OR (a.dszt IN ('0','11','20') AND EXISTS (
           SELECT 1 FROM "LessonRecord" l JOIN "CommonModel" lc ON lc.company=l.company AND lc."modelId"=59 AND lc."itemId"=l.id AND lc.status=99
           WHERE l.company=a.company AND l.yyds::text=ac."generalId"::text AND l.xymz::text=d."userId"::text AND l.kcid::text='398'
-          AND l."sourceKey"='legacy:model:59:general:'||lc."generalId"::text AND lc."sourceKey"=l."sourceKey"))))))`;
+          AND l."sourceKey"='legacy:model:59:general:'||lc."generalId"::text AND lc."sourceKey"=l."sourceKey"
+          AND (a.dszt='0' OR (NULLIF(BTRIM(to_jsonb(a)->>'kssj'),'') IS NOT NULL
+            AND NULLIF(BTRIM(to_jsonb(a)->>'jssj'),'') IS NOT NULL AND to_jsonb(l)->>'jsmz'=to_jsonb(a)->>'pl'))))))))`;
 }
 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();

+ 12 - 1
scripts/sql/lesson-study-batches.sql

@@ -54,7 +54,7 @@ END $$;
 CREATE OR REPLACE FUNCTION xs_lesson_study_save(co text,gid text,actor text,actor_uid numeric,request_key text,p jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
 DECLARE a "CourseAppointment"; b "LessonStudyBatch"; prior "LessonStudyCommand"; result jsonb; bid text=p->>'batchId'; mode text=p->>'mode'; w jsonb=p->'words'; fingerprint jsonb; valid_count integer;
 BEGIN
- IF length(COALESCE(request_key,'')) NOT BETWEEN 10 AND 160 OR length(COALESCE(bid,'')) NOT BETWEEN 1 AND 160 OR mode IS NULL OR mode NOT IN ('draft','confirm','discard','legacy','mastery') OR jsonb_typeof(w) IS DISTINCT FROM 'array' OR jsonb_array_length(w)>500 THEN RAISE EXCEPTION '学习批次参数无效'; END IF;
+ IF length(COALESCE(request_key,'')) NOT BETWEEN 10 AND 160 OR length(COALESCE(bid,'')) NOT BETWEEN 1 AND 160 OR mode IS NULL OR mode NOT IN ('draft','open-draft','confirm','discard','legacy','mastery') OR jsonb_typeof(w) IS DISTINCT FROM 'array' OR jsonb_array_length(w)>500 THEN RAISE EXCEPTION '学习批次参数无效'; END IF;
  PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  SELECT a0.* INTO a FROM "CourseAppointment" a0 JOIN "CommonModel" c ON c.company=a0.company AND c."modelId"=54 AND c."itemId"=a0.id AND c.status=99 WHERE c.company=co AND c."generalId"::text=gid FOR UPDATE OF a0;
  IF a."objectId" IS NULL OR a.pl::text<>actor_uid::text OR NOT EXISTS(SELECT 1 FROM "_User" WHERE "objectId"=actor AND company=co AND "legacyUserId"=actor_uid AND "legacyGroupId"=3 AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '仅预约老师可以保存课堂学习'; END IF;
@@ -66,6 +66,17 @@ BEGIN
  WITH RECURSIVE nodes AS (SELECT n."nodeId" FROM "Node" n JOIN "CourseBinding" cb ON cb.company=n.company AND cb.kcid=n."nodeId"::text WHERE cb.company=co AND cb."objectId"=p->>'bindingId' AND COALESCE(n.zstatus,99)=99 UNION SELECT n."nodeId" FROM "Node" n JOIN nodes parent ON n."parentId"=parent."nodeId" WHERE n.company=co AND COALESCE(n.zstatus,99)=99)
  SELECT count(DISTINCT c."generalId") INTO valid_count FROM "CommonModel" c JOIN nodes n ON n."nodeId"=c."nodeId" WHERE c.company=co AND c."modelId"=52 AND c.status=99 AND c."generalId"::text IN (SELECT value->>'GeneralID' FROM jsonb_array_elements(w));
  IF valid_count<>(SELECT count(DISTINCT value->>'GeneralID') FROM jsonb_array_elements(w)) OR EXISTS(SELECT 1 FROM jsonb_array_elements(w) v WHERE NULLIF(v->>'GeneralID','') IS NULL) THEN RAISE EXCEPTION '学习单词与所选课程不匹配'; END IF;
+ IF mode='open-draft' THEN
+  -- The appointment lock covers both the pending-round check and a new draft.
+  SELECT * INTO b FROM "LessonStudyBatch" WHERE company=co AND "orderId"=gid
+   AND "bindingId"=p->>'bindingId' AND status='draft' ORDER BY "updatedAt","batchId" LIMIT 1;
+  IF FOUND THEN
+   result=jsonb_build_object('resumed',true,'batchId',b."batchId",'batchVersion',b.version);
+   INSERT INTO "LessonStudyCommand" VALUES(co,actor,request_key,fingerprint,result,now());
+   RETURN result;
+  END IF;
+  mode='draft';
+ END IF;
  SELECT * INTO b FROM "LessonStudyBatch" WHERE company=co AND "orderId"=gid AND "batchId"=bid;
  IF b."batchId" IS NOT NULL AND (b."bindingId" IS DISTINCT FROM p->>'bindingId' OR (mode<>'legacy' AND b.version IS DISTINCT FROM (p->>'expectedVersion')::int)) THEN RAISE EXCEPTION '学习批次已更新,请刷新后重试'; END IF;
  IF b."batchId" IS NULL AND COALESCE((p->>'expectedVersion')::int,0)<>0 THEN RAISE EXCEPTION '学习批次版本无效'; END IF;

+ 22 - 2
scripts/tests/legacy-writing-review.test.mjs

@@ -10,9 +10,12 @@ async function fixture(run){
  const db=new PGlite();try{
   await db.exec(readFileSync(new URL('./fixtures/teacher-review-schema.sql',import.meta.url),'utf8'));
   await db.exec(`ALTER TABLE "DailyStudyRecord" ADD COLUMN pl text,ADD COLUMN "canonicalStudyId" text;
+   ALTER TABLE "CourseAppointment" ADD COLUMN pl text,ADD COLUMN kssj text,ADD COLUMN jssj text;
+   ALTER TABLE "LessonRecord" ADD COLUMN jsmz text;
    INSERT INTO "CommonModel"(company,"modelId","generalId","itemId",status,"sourceKey") VALUES('co',56,100,101,99,'legacy:model:56:general:100'),('co',54,200,201,99,'legacy:model:54:general:200');
    INSERT INTO "DailyStudyRecord"(company,id,learned,xxqs,dsid,"userId",pl) VALUES('co',101,0,'','200',20,'30');
-   INSERT INTO "CourseAppointment" VALUES('co',201,'20','30',NULL,'398','legacy:model:54:general:200');
+   INSERT INTO "CourseAppointment"(company,id,szyh,dszt,"courseCategoryKey",kcid,"sourceKey",pl)
+   VALUES('co',201,'20','30',NULL,'398','legacy:model:54:general:200','30');
    INSERT INTO "Node" VALUES('co',398,'阅读写作');`);
   const query=async(s,a)=>(await db.query(s,a)).rows;
   const ctx=vm.createContext({Date,Promise,Parse:{},Psql:{query,one:async(s,a)=>(await query(s,a))[0],oneOrNone:async(s,a)=>(await query(s,a))[0]||null}});
@@ -52,7 +55,7 @@ test('普通空词表、错误学生、未完课、回收和无可信来源的39
 test('旧预约状态滞后但已有同一学生同一课次的真实旧完课凭据时恢复笔记复习',()=>fixture(async({db,ctx,coach})=>{
  await db.exec(`UPDATE "CourseAppointment" SET dszt='0',"courseCategoryKey"='high_writing';
   INSERT INTO "CommonModel"(company,"modelId","generalId","itemId",status,"sourceKey") VALUES('co',59,300,301,99,'legacy:model:59:general:300');
-  INSERT INTO "LessonRecord" VALUES('co',301,'200','20','398','legacy:model:59:general:300');`);
+  INSERT INTO "LessonRecord"(company,id,yyds,xymz,kcid,"sourceKey",jsmz) VALUES('co',301,'200','20','398','legacy:model:59:general:300','30');`);
  const page=()=>ctx.appReviewPage({scope:'today',includeIncome:false},coach);
  assert.equal((await page()).page.itemCount,2);
  assert.equal((await ctx.recordDetailData({id:100},coach)).addon[0].reviewContentMode,'notes');
@@ -69,3 +72,20 @@ test('旧预约状态滞后但已有同一学生同一课次的真实旧完课
   assert.equal((await ctx.recordDetailData({id:100},coach)).addon[0].reviewContentMode,undefined,patch);
  }
 }));
+
+test('旧系统已结束、待课后确认的阅读写作任务可显示并打开笔记',()=>fixture(async({db,ctx,coach})=>{
+ await db.exec(`UPDATE "CourseAppointment" SET dszt='11',kssj='2026-10-13 10:43',jssj='2026-10-13 11:40';
+  INSERT INTO "CommonModel"(company,"modelId","generalId","itemId",status,"sourceKey") VALUES('co',59,300,301,99,'legacy:model:59:general:300');
+  INSERT INTO "LessonRecord"(company,id,yyds,xymz,kcid,"sourceKey",jsmz) VALUES('co',301,'200','20','398','legacy:model:59:general:300','30');`);
+ const page=()=>ctx.appReviewPage({scope:'today',includeIncome:false},coach);
+ const detail=()=>ctx.recordDetailData({id:100},coach);
+ for(const status of ['11','20']){
+  await db.query('UPDATE "CourseAppointment" SET dszt=$1',[status]);
+  assert.equal((await page()).page.itemCount,2);
+  assert.equal((await detail()).addon[0].reviewContentMode,'notes');
+ }
+ await db.exec(`UPDATE "LessonRecord" SET jsmz='31'`);
+ assert.equal((await page()).page.itemCount,0);
+ await db.exec(`UPDATE "LessonRecord" SET jsmz='30';UPDATE "CourseAppointment" SET jssj=''`);
+ assert.equal((await page()).page.itemCount,0);
+}));

+ 15 - 0
scripts/tests/lesson-study-batches.test.mjs

@@ -82,6 +82,21 @@ test('draft does not count, blocks completion, and can be confirmed without losi
  await assert.rejects(save(a,'pending',words(10,5,0),'confirm',1),/掌握情况/);
  assert.equal((await save(a,'pending',words(10,5),'confirm',1)).learned,14);
 });
+test('opening a selection creates one draft and safely returns the pending round on a concurrent open',async()=>{
+ const a=await appointment();
+ await save(a,'earlier',words(1,2));
+ const openKey='open-draft-first';
+ const first=await save(a,'first-open',words(3,3,0),'open-draft',0,openKey);
+ assert.equal(first.batchId,'first-open');
+ assert.equal(first.learned,2);
+ const resumed=await save(a,'second-open',words(6,2,0),'open-draft');
+ assert.equal(resumed.resumed,true);
+ assert.equal(resumed.batchId,'first-open');
+ assert.equal(resumed.batchVersion,first.batchVersion);
+ assert.deepEqual(await save(a,'first-open',words(3,3,0),'open-draft',0,openKey),first);
+ assert.equal((await one('SELECT count(*)::int n FROM "LessonStudyBatch" WHERE "orderId"=$1 AND status=\'draft\'',[a.gid])).n,1);
+ assert.equal((await save(a,resumed.batchId,words(3,3),'confirm',resumed.batchVersion)).learned,5);
+});
 test('duplicate words deduplicate and stale versions cannot overwrite mastery; retry is idempotent',async()=>{
  const a=await appointment();const key='retry-batch-key';const first=await save(a,'first',words(1,9),'confirm',0,key);assert.deepEqual(await save(a,'first',words(1,9),'confirm',0,key),first);
  await save(a,'second',words(5,5,2));assert.equal((await studies(a))[0].learned,'9');