Răsfoiți Sursa

Show generated reading count on study records

彭峰 1 săptămână în urmă
părinte
comite
04014b9d98

+ 2 - 2
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html

@@ -53,7 +53,7 @@
       @if (visibleItems().length) {
         <div class="record-list">
           @for (item of paginatedItems(); track item['GeneralID'] || $index) {
-            <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs'] || completedReadingId(item)) { <button type="button" class="reading-button" [class.completed]="completedReadingId(item)" [disabled]="readingLoadingId() === recordId(item)" [attr.aria-label]="completedReadingId(item) ? '查看已完成的历史阅读理解' : '生成阅读理解'" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : completedReadingId(item) ? '已完成阅读' : '阅读' }}</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
+            <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs'] || completedReadingId(item)) { <button type="button" class="reading-button" [class.completed]="completedReadingId(item)" [disabled]="readingLoadingId() === recordId(item)" [attr.aria-label]="(completedReadingId(item) ? '查看已完成的历史阅读理解' : '生成阅读理解') + (readingArticleCount(item) ? ',已生成' + readingArticleCount(item) + '篇' : '')" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : completedReadingId(item) ? '已完成阅读' : '阅读' }}@if (readingArticleCount(item)) { <span class="reading-count">{{ readingArticleCount(item) }}</span> }</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
           }
         </div>
         @if (recordPageCount() > 1) {
@@ -93,7 +93,7 @@
         } @else if (meta.path === 'pages/member/ke_fuxi') {
           <article class="income-item"><span [class.done]="item['fxzt']">{{ item['fxzt'] ? '已复习' : '未复习' }}</span><div><strong>{{ title(item) }}</strong><p>{{ incomeName(item) }} {{ item['kywsj'] }}</p>@if(item['rewardStatus']==='historical'){<small>历史待核对,未自动计薪</small>}@else if(item['rewardStatus']==='exception'){<small>计薪异常待核对</small>}</div><b>¥{{ item['money'] || 0 }}</b></article>
         } @else {
-          <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs'] || completedReadingId(item)) { <button type="button" class="reading-button" [class.completed]="completedReadingId(item)" [disabled]="readingLoadingId() === recordId(item)" [attr.aria-label]="completedReadingId(item) ? '查看已完成的历史阅读理解' : '生成阅读理解'" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : completedReadingId(item) ? '已完成阅读' : '阅读' }}</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
+          <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs'] || completedReadingId(item)) { <button type="button" class="reading-button" [class.completed]="completedReadingId(item)" [disabled]="readingLoadingId() === recordId(item)" [attr.aria-label]="(completedReadingId(item) ? '查看已完成的历史阅读理解' : '生成阅读理解') + (readingArticleCount(item) ? ',已生成' + readingArticleCount(item) + '篇' : '')" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : completedReadingId(item) ? '已完成阅读' : '阅读' }}@if (readingArticleCount(item)) { <span class="reading-count">{{ readingArticleCount(item) }}</span> }</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
         }
       }
     </div>

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.scss

@@ -92,6 +92,7 @@
 .learning-item strong, .progress-item strong, .income-item strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
 .learning-item > span, .progress-item > span { color: var(--brand-strong); font-size: 12px; }
 .reading-button { min-width: 48px; min-height: 34px; padding: 0 10px; color: white; background: var(--brand-strong); border: 1px solid transparent; border-radius: 4px; font-size: 12px; font-weight: 700; white-space: nowrap; }
+.reading-button .reading-count { display: inline-flex; min-width: 16px; height: 16px; align-items: center; justify-content: center; margin-left: 5px; padding: 0 3px; color: var(--brand-strong); background: white; border-radius: 999px; font-size: 10px; line-height: 1; }
 .reading-button.completed { min-width: 86px; color: #315d96; background: #eaf1fb; border-color: #b8cce7; border-radius: 999px; }
 .reading-button.completed:hover { background: #dce8f7; border-color: #91add1; }
 .reading-button:disabled { cursor: wait; opacity: .68; }

+ 5 - 0
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -402,6 +402,11 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
     return String(item['readingArticleId'] ?? '').trim();
   }
 
+  readingArticleCount(item: Item): number {
+    const count = Number(item['readingArticleCount']);
+    return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
+  }
+
   private openReadingSetup(item: Item, wordList: string[]): void {
     const params = {
       wordList: wordList.join(','),

+ 11 - 5
scripts/deploy-admin-functions.mjs

@@ -2687,11 +2687,17 @@ function appOverviewCourseRow(source) {
 function appOverviewStudyRow(row) {
   if(!row)return null;return{objectId:String(row.objectId||''),GeneralID:number(row.generalId||row.id),ID:number(row.generalId||row.id),Title:String(row.title||row.con||'学习记录'),CreateTime:firstValue(row.createTime,row.createdAt),pl:row.pl,con:row.con,djq:row.djq,ygg:row.ygg,dqrq:row.dqrq,dsid:row.dsid,fxrl:row.fxrl,xxqs:row.xxqs,szmdid:row.szmdid,UserID:row.userId,userId:row.userId,learned:row.learned};
 }
-async function appOverviewCompletedReadingMap(records,targetId) {
+async function appOverviewReadingMap(records,targetId) {
   const studyIds=[...new Set(records.map((row)=>String(row&&row.GeneralID||'').trim()).filter(Boolean))];if(!studyIds.length)return new Map();
   const learner=await legacyUserObject(targetId);if(!learner)return new Map();const ownerIds=[learner.id,'_User$'+learner.id];
-  const rows=await Psql.query('SELECT DISTINCT ON (a."createOptions"#>>\'{params,sourceStudyId}\') a."createOptions"#>>\'{params,sourceStudyId}\' AS "sourceStudyId",a."objectId" AS "readingArticleId",a."title" AS "readingTitle",completed."completedAt" AS "readingCompletedAt" FROM "SurveyItem" a JOIN LATERAL (SELECT COALESCE(l."createdAt",l."updatedAt") AS "completedAt" FROM "SurveyLog" l WHERE CAST(l."surveyItem" AS text)=ANY(ARRAY[CAST(a."objectId" AS text),\'SurveyItem$\'||CAST(a."objectId" AS text)]) AND CAST(l."user" AS text)=ANY($1::text[]) ORDER BY COALESCE(l."createdAt",l."updatedAt") DESC,l."objectId" DESC LIMIT 1) completed ON TRUE WHERE CAST(a."user" AS text)=ANY($1::text[]) AND a."type"=\'reading\' AND COALESCE(a."isDeleted",FALSE)=FALSE AND a."createOptions"#>>\'{params,sourceStudyId}\'=ANY($2::text[]) ORDER BY a."createOptions"#>>\'{params,sourceStudyId}\',completed."completedAt" DESC,COALESCE(a."createdAt",a."updatedAt") DESC',[ownerIds,studyIds]);
-  const map=new Map();for(const row of rows)map.set(String(row.sourceStudyId),{readingArticleId:String(row.readingArticleId||''),readingTitle:String(row.readingTitle||''),readingCompletedAt:row.readingCompletedAt});return map;
+  const [counts,completedRows]=await Promise.all([
+    Psql.query('SELECT a."createOptions"#>>\'{params,sourceStudyId}\' AS "sourceStudyId",COUNT(*)::int AS "readingArticleCount" FROM "SurveyItem" a WHERE CAST(a."user" AS text)=ANY($1::text[]) AND a."type"=\'reading\' AND COALESCE(a."isDeleted",FALSE)=FALSE AND a."createOptions"#>>\'{params,sourceStudyId}\'=ANY($2::text[]) GROUP BY a."createOptions"#>>\'{params,sourceStudyId}\'',[ownerIds,studyIds]),
+    Psql.query('SELECT DISTINCT ON (a."createOptions"#>>\'{params,sourceStudyId}\') a."createOptions"#>>\'{params,sourceStudyId}\' AS "sourceStudyId",a."objectId" AS "readingArticleId",a."title" AS "readingTitle",completed."completedAt" AS "readingCompletedAt" FROM "SurveyItem" a JOIN LATERAL (SELECT COALESCE(l."createdAt",l."updatedAt") AS "completedAt" FROM "SurveyLog" l WHERE CAST(l."surveyItem" AS text)=ANY(ARRAY[CAST(a."objectId" AS text),\'SurveyItem$\'||CAST(a."objectId" AS text)]) AND CAST(l."user" AS text)=ANY($1::text[]) ORDER BY COALESCE(l."createdAt",l."updatedAt") DESC,l."objectId" DESC LIMIT 1) completed ON TRUE WHERE CAST(a."user" AS text)=ANY($1::text[]) AND a."type"=\'reading\' AND COALESCE(a."isDeleted",FALSE)=FALSE AND a."createOptions"#>>\'{params,sourceStudyId}\'=ANY($2::text[]) ORDER BY a."createOptions"#>>\'{params,sourceStudyId}\',completed."completedAt" DESC,COALESCE(a."createdAt",a."updatedAt") DESC',[ownerIds,studyIds]),
+  ]);
+  const map=new Map();
+  for(const row of counts)map.set(String(row.sourceStudyId),{readingArticleCount:Number(row.readingArticleCount)||0});
+  for(const row of completedRows)map.set(String(row.sourceStudyId),{...map.get(String(row.sourceStudyId)),readingArticleId:String(row.readingArticleId||''),readingTitle:String(row.readingTitle||''),readingCompletedAt:row.readingCompletedAt});
+  return map;
 }
 async function appLearningTarget(current,requestedId) {
   const ownId=ownLegacyId(current),targetId=number(requestedId||ownId);if(!targetId)fail(400,'缺少学员 ID');if(isAdminUser(current)||targetId===ownId)return targetId;
@@ -2762,11 +2768,11 @@ async function appLearningOverview(input,current) {
     Psql.query('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",b.*,b."objectId" AS "bindingObjectId",n."nodeName" AS "courseTitle" FROM "CourseBinding" b LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=$1 AND n."nodeId"=CASE WHEN COALESCE(b."kcid",\'\')~\'^\\d+(?:\\.0+)?$\' THEN b."kcid"::numeric ELSE -1 END WHERE b."company"=$1 AND b."yhid"=$2 AND b."enrollmentStatus"=\'active\' AND c."status"=99 AND COALESCE(n."zstatus",99)=99 ORDER BY b."id" DESC',[DEFAULT_COMPANY_ID,String(targetId)]),
     Psql.oneOrNone('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",d."id",d."pl",d."con",d."djq",d."ygg",d."dqrq",d."dsid",d."fxrl",d."xxqs",d."szmdid",d."userId",d."learned" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1 AND d."userId"=$2 AND COALESCE(NULLIF(d."xxqs",\'\'),\'[]\')<>\'[]\' ORDER BY COALESCE(c."createTime",c."createdAt",d."updatedAt",d."createdAt") DESC LIMIT 1',[DEFAULT_COMPANY_ID,targetId])
   ]);
-  const normalizedRecords=records.map(appOverviewStudyRow),normalizedLatestReading=appOverviewStudyRow(latestReading),completedReadingMap=await appOverviewCompletedReadingMap(normalizedLatestReading?[...normalizedRecords,normalizedLatestReading]:normalizedRecords,targetId),attachCompletedReading=(row)=>{if(!row)return row;const reading=completedReadingMap.get(String(row.GeneralID));return reading?{...row,...reading}:row;};
+  const normalizedRecords=records.map(appOverviewStudyRow),normalizedLatestReading=appOverviewStudyRow(latestReading),readingMap=await appOverviewReadingMap(normalizedLatestReading?[...normalizedRecords,normalizedLatestReading]:normalizedRecords,targetId),attachReading=(row)=>{if(!row)return row;const reading=readingMap.get(String(row.GeneralID));return reading?{...row,...reading}:row;};
   const recordDateCounts={},scheduleDateCounts={},learnedDates=new Set();for(const row of dateRows){const date=appOverviewDateKey(row.date);if(date){recordDateCounts[date]=number(row.count);learnedDates.add(date);}}
   const today=appOverviewToday();for(const row of scheduleGroups){const date=appOverviewDateKey(row.bxrq||row.yysj);if(!date||learnedDates.has(date))continue;const count=number(row.count),status=number(row.dszt),value=scheduleDateCounts[date]||{planned:0,missed:0};if(status<30&&date<today)value.missed+=count;else value.planned+=count;scheduleDateCounts[date]=value;}
   const total=await Psql.one('SELECT COUNT(*)::int total FROM "DailyStudyRecord" d WHERE '+recordWhere.join(' AND '),recordValues);
-  const live=await appLiveVersionSnapshot(targetId,['learning','courses','appointments','reading'],current,legacyLearningSync);return {page:mobilePageMeta(total.total,paging),...mobileSource(),records:normalizedRecords.map(attachCompletedReading),latestReadingRecord:attachCompletedReading(normalizedLatestReading),recordDateCounts,scheduleDateCounts,schedules:schedules.map(appOverviewScheduleRow),courses:courses.map(appOverviewCourseRow),legacyLearningSync,...live};
+  const live=await appLiveVersionSnapshot(targetId,['learning','courses','appointments','reading'],current,legacyLearningSync);return {page:mobilePageMeta(total.total,paging),...mobileSource(),records:normalizedRecords.map(attachReading),latestReadingRecord:attachReading(normalizedLatestReading),recordDateCounts,scheduleDateCounts,schedules:schedules.map(appOverviewScheduleRow),courses:courses.map(appOverviewCourseRow),legacyLearningSync,...live};
 }
 async function appStudentCompanionOverview(input,current,uid,p) {
   const history=input.history===true,legacyAppointmentSync=await syncLegacyAppointments('szyh',uid,input.refresh===true);

+ 13 - 6
scripts/tests/mobile-teaching.test.mjs

@@ -27,13 +27,20 @@ test('完课确认:学生与老师依次确认,重复确认幂等,越权
  await db.exec('UPDATE "CommonModel" SET status=-2');await assert.rejects(confirm(30,20),/已取消/);
  }finally{await db.close();}
 });
-test('学习记录:仅关联已经提交答题记录的历史阅读',async()=>{
+test('学习记录:统计已生成文章,未作答文章也计入,已完成记录保持关联',async()=>{
  const db=new PGlite();try{
   await db.exec('CREATE TABLE "SurveyItem"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,"user" text,"type" text,"title" text,"createOptions" jsonb,"isDeleted" boolean);CREATE TABLE "SurveyLog"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,"user" text,"surveyItem" text);');
-  await db.query('INSERT INTO "SurveyItem" VALUES($1,now(),now(),$2,$3,$4,$5::jsonb,false),($6,now(),now(),$2,$3,$7,$8::jsonb,false)', ['article201','_User$learner','reading','已完成文章',JSON.stringify({params:{sourceStudyId:'201'}}),'article202','未完成文章',JSON.stringify({params:{sourceStudyId:'202'}})]);
-  await db.query('INSERT INTO "SurveyLog" VALUES($1,now(),now(),$2,$3)', ['log201','_User$learner','SurveyItem$article201']);
-  const source=gatewaySource.match(/async function appOverviewCompletedReadingMap[\s\S]*?\n}\nasync function appLearningTarget/)?.[0].replace(/\nasync function appLearningTarget[\s\S]*$/,'');assert.ok(source);
-  const context={Psql:{query:async(statement,args)=>(await db.query(statement,args)).rows},legacyUserObject:async()=>({id:'learner'})};vm.runInNewContext(source+';this.readingMap=appOverviewCompletedReadingMap',context);
-  const map=await context.readingMap([{GeneralID:201},{GeneralID:202}],100);assert.equal(map.get('201').readingArticleId,'article201');assert.equal(map.get('201').readingTitle,'已完成文章');assert.equal(map.has('202'),false);
+  for(const [id,studyId,owner,deleted] of [
+   ['article201a','201','_User$learner',false],['article201b','201','learner',false],
+   ['article202a','202','_User$learner',false],['article202b','202','_User$learner',false],['article202c','202','_User$learner',false],
+   ['otherOwner','202','_User$other',false],['deletedArticle','202','_User$learner',true],
+  ]) await db.query('INSERT INTO "SurveyItem" VALUES($1,now(),now(),$2,$3,$4,$5::jsonb,$6)',[id,owner,'reading',id,JSON.stringify({params:{sourceStudyId:studyId}}),deleted]);
+  await db.query('INSERT INTO "SurveyLog" VALUES($1,now(),now(),$2,$3)', ['log201','_User$learner','SurveyItem$article201a']);
+  const source=gatewaySource.match(/async function appOverviewReadingMap[\s\S]*?\n}\nasync function appLearningTarget/)?.[0].replace(/\nasync function appLearningTarget[\s\S]*$/,'');assert.ok(source);
+  const context={Psql:{query:async(statement,args)=>(await db.query(statement,args)).rows},legacyUserObject:async()=>({id:'learner'})};vm.runInNewContext(source+';this.readingMap=appOverviewReadingMap',context);
+  const map=await context.readingMap([{GeneralID:201},{GeneralID:202},{GeneralID:203}],100);
+  assert.equal(map.get('201').readingArticleCount,2);assert.equal(map.get('201').readingArticleId,'article201a');
+  assert.equal(map.get('202').readingArticleCount,3);assert.equal(map.get('202').readingArticleId,undefined);
+  assert.equal(map.has('203'),false);
  }finally{await db.close();}
 });