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

feat(records): number learning and training history in reverse

彭峰 пре 17 часа
родитељ
комит
fc1633afb1

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/lesson-records.component.html

@@ -33,7 +33,7 @@
       <tbody>
         @for (row of d.items; track row.lessonId; let index = $index) {
           <tr>
-            <td class="number-cell">{{ (d.page - 1) * d.pageSize + index + 1 }}</td>
+            <td class="number-cell">{{ d.total - (d.page - 1) * d.pageSize - index }}</td>
             <td [attr.title]="row.studentName"><strong>{{ row.studentName }}</strong></td>
             <td class="id-cell">{{ row.studentId }}</td>
             <td [attr.title]="row.storeName">{{ row.storeName }}</td>

+ 4 - 4
projects/xiaoshu-admin/src/app/pages/lesson-records.component.spec.ts

@@ -7,7 +7,7 @@ describe('训练记录与复习转交',()=>{
  let ops:jasmine.SpyObj<OperationsService>;
  beforeEach(()=>{ops=jasmine.createSpyObj<OperationsService>('OperationsService',['request']);TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:ops},{provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({})}}}]});});
  const page=(items:unknown[],total=2)=>({items,total,cumulativeTotal:51,page:1,pageSize:100,version:'snapshot1'});
- it('训练记录逐列显示,翻页按返回的页码连续编号',async()=>{
+ it('训练记录按总数倒序编号,翻页后从剩余次数继续',async()=>{
   const row={lessonId:'lesson-a',studentName:'灵灵',studentId:1582,storeName:'南昌总店',courseName:'人教版2024年9月录',
    lessonAt:'2026-11-10T08:33:00Z',coachName:'明慧陪练',classTypeLabel:'单词课',feedbackSummary:'本节学习反馈',
    reviewCoachLabel:'明慧陪练',pendingReviews:10,duplicateCount:2};
@@ -17,16 +17,16 @@ describe('训练记录与复习转交',()=>{
   await fixture.whenStable();
   fixture.detectChanges();
   const cells=()=>Array.from(fixture.nativeElement.querySelectorAll('.lesson-table tbody tr:first-child td') as NodeListOf<HTMLTableCellElement>,cell=>cell.textContent!.trim());
-  expect(cells().slice(0,8)).toEqual(['1','灵灵','1582','南昌总店','人教版2024年9月录','2026-11-10 16:33','明慧陪练','单词课']);
+  expect(cells().slice(0,8)).toEqual(['23','灵灵','1582','南昌总店','人教版2024年9月录','2026-11-10 16:33','明慧陪练','单词课']);
   expect(cells().slice(9)).toEqual(['明慧陪练','10 次','查看反馈']);
   expect(fixture.nativeElement.querySelector('.source-warning').title).toBe('同课次有重复来源,已合并计数');
   ops.request.and.resolveTo({...page([{...row,lessonId:'lesson-b'}],23),page:2,pageSize:20});
   fixture.componentInstance.turn(1);
   fixture.detectChanges();
-  expect(cells()[0]).toBe('1');
+  expect(cells()[0]).toBe('23');
   await fixture.whenStable();
   fixture.detectChanges();
-  expect(cells()[0]).toBe('21');
+  expect(cells()[0]).toBe('3');
  });
  it('菜单返回保留筛选和已读数据,30 秒后后台更新失败仍可查看',async()=>{
   let now=100_000;spyOn(Date,'now').and.callFake(()=>now);

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

@@ -329,6 +329,56 @@ describe('home learning modules', () => {
     });
   });
 
+  for (const role of [1,3]) {
+    it(`numbers learning records in reverse across pages and date filters for role ${role}`, async () => {
+      user.set({userId:role===3?300:100,groupId:role});session.groupId.set(role);
+      const api=TestBed.inject(ApiService), original=api.post.bind(api);
+      spyOn(TestBed.inject(LiveDataRefreshService),'watch').and.returnValue(()=>{});
+      spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>{
+        if(action!=='app_learning_overview')return original(action,params);
+        const total=params['date']?2:26,offset=(Number(params['page'])-1)*20;
+        return of({retcode:0,result:{records:Array.from({length:Math.min(20,total-offset)},(_,i)=>({GeneralID:1000+offset+i,CreateTime:params['date']||'2026-11-12 12:00',con:'课程 '+(total-offset-i)})),courses:[],schedules:[],recordDateCounts:{},scheduleDateCounts:{},page:{itemCount:total,pageCount:Math.ceil(total/20)}}});
+      }) as typeof api.post);
+      const harness=await RouterTestingHarness.create('/pages/his/his');await harness.fixture.whenStable();harness.fixture.detectChanges();
+      const component=harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent;
+      const numbers=()=>Array.from(harness.routeNativeElement!.querySelectorAll('.learning-item .record-number'),node=>node.textContent!.trim());
+      expect(numbers()).toEqual(Array.from({length:20},(_,i)=>String(26-i)));
+      component.goToRecordPage(2);await harness.fixture.whenStable();harness.fixture.detectChanges();
+      expect(numbers()).toEqual(['6','5','4','3','2','1']);
+      component.selectDate('2026-11-12');await harness.fixture.whenStable();harness.fixture.detectChanges();
+      expect(numbers()).toEqual(['2','1']);
+    });
+  }
+
+  for (const role of [1,3]) {
+    it(`numbers training records across date groups and pages with newest first for role ${role}`, async () => {
+      user.set({userId:role===3?300:100,groupId:role});session.groupId.set(role);
+      const api=TestBed.inject(ApiService), original=api.post.bind(api);
+      spyOn(TestBed.inject(LiveDataRefreshService),'watch').and.returnValue(()=>{});
+      const calls:Record<string,unknown>[]=[];
+      const delayed = new Subject<ApiEnvelope<unknown>>();let retryPage = false;
+      spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>{
+        if(action!=='app_companion_overview')return original(action,params);
+        calls.push(params);if(Number(params['page'])===2 && !retryPage)return delayed;
+        const total=26,offset=(Number(params['page'])-1)*20;
+        return of({retcode:0,result:{schedules:Array.from({length:Math.min(20,total-offset)},(_,i)=>({GeneralID:1000+offset+i,bxrq:'2026-11-'+String(26-offset-i).padStart(2,'0'),sdsd:'19:00',Title:'训练 '+(26-offset-i),dszt:30})),courses:[],studentOptions:[],dates:[],earningsSummary:{amount:0,pendingCount:0},page:{itemCount:26,pageCount:2}}});
+      }) as typeof api.post);
+      const harness=await RouterTestingHarness.create(role===3?'/pages/member/ke_xiao':'/pages/home/cart?csStatus=finished&csRange=all');
+      await harness.fixture.whenStable();harness.fixture.detectChanges();
+      const component=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
+      const numbers=()=>Array.from(harness.routeNativeElement!.querySelectorAll('.schedule-list .record-number'),node=>node.textContent!.trim());
+      expect(calls[0]['history']).toBe(role===3);
+      expect(numbers()).toEqual(Array.from({length:20},(_,i)=>String(26-i)));
+      expect(harness.routeNativeElement!.querySelector('.schedule-copy strong')?.textContent).toContain('训练 26');
+      component.turnPage(1);harness.fixture.detectChanges();
+      expect(numbers()).toEqual(Array.from({length:20},(_,i)=>String(26-i)));
+      delayed.error(new Error('翻页失败'));await harness.fixture.whenStable();harness.fixture.detectChanges();
+      expect(numbers()).toEqual(Array.from({length:20},(_,i)=>String(26-i)));
+      retryPage=true;component.load(true,true);await harness.fixture.whenStable();harness.fixture.detectChanges();
+      expect(numbers()).toEqual(['6','5','4','3','2','1']);
+    });
+  }
+
   it('filters teacher history students before restoring a stale selection and uses server pagination', async () => {
     user.set({userId:300, groupId:3}); session.groupId.set(3);
     const watch = spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {});

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

@@ -125,7 +125,7 @@
           <div class="schedule-list">
             @for (item of group.items; track $index) {
               <article [class.has-lesson-pay]="isHistory && isCoach && !item['cancelled']" [attr.data-schedule-id]="scheduleId(item)" (click)="open(item, $event)">
-                <time>{{ scheduleTime(item) }}</time>
+                @if (historyOrder()) { <div class="record-time"><span class="record-number" [attr.aria-label]="'序号 ' + recordNumber(item)">{{ recordNumber(item) }}</span><time>{{ scheduleTime(item) }}</time></div> } @else { <time>{{ scheduleTime(item) }}</time> }
                 <div class="schedule-copy"><strong>{{ scheduleTitle(item) }}</strong>@if (scheduleSubtitle(item)) { <span>{{ scheduleSubtitle(item) }}</span> }</div>
                 @if (isHistory && isCoach && !item['cancelled']) { <div class="lesson-pay"><strong>{{ item['payStatus'] === 'earned' ? '陪练费用 ¥' + ($any(item['teacherPay']) | number:'1.2-2') : '费用待核对' }}</strong><small [class.feedback-pending]="item['feedbackStatus'] !== 'submitted'">{{item['feedbackStatus']==='submitted'?'反馈已提交':'反馈待补'}}</small></div> }
                 <span class="status" [class]="'status ' + statusClass(item)">{{ statusText(item) }}</span>

+ 8 - 0
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -69,6 +69,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   readonly hasScheduleCalendar = !this.isHistory;
   readonly weekLabels = ['一', '二', '三', '四', '五', '六', '日'];
   readonly listPage=signal(1); readonly listPages=signal(1); readonly listTotal=signal(0); readonly serverDates=signal<Array<{date:string;finished:number;planned:number}>>([]); readonly serverStudents=signal<StudentOption[]>([]);
+  private readonly loadedListPage = signal(1);
   private loadRequest?:Subscription; private loadId=0;
   readonly courses = signal<Item[]>([]);
   readonly coursesExpanded = signal(false);
@@ -76,6 +77,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   toggleCourses(): void { this.coursesExpanded.update(expanded => !expanded); }
   readonly schedules = signal<Item[]>([]);
   readonly scheduleFilter = signal<ScheduleFilter>('all');
+  readonly historyOrder = computed(() => this.isHistory || this.scheduleFilter() === 'finished');
   readonly scheduleRange = signal<ScheduleRange>('day');
   readonly now = signal(Date.now());
   readonly selectedDate = signal(this.today());
@@ -205,6 +207,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
         if(requestId!==this.loadId)return;this.listPages.set(result.page?.pageCount||1);this.listTotal.set(result.page?.itemCount ?? result.schedules?.length ?? 0);this.serverDates.set(result.dates||[]);this.serverStudents.set(result.studentOptions||[]);
         this.earningsSummary.set(result.earningsSummary ?? null);
         this.courses.set(Array.isArray(result?.courses) ? result.courses : []);
+        this.loadedListPage.set(params.page);
         this.schedules.set(Array.isArray(result?.schedules) ? result.schedules : []);
         this.refreshedAt.set(result.refreshedAt || new Date().toISOString());
         if (!this.isCoach) this.hasFreshLegacyAppointments = result.legacyAppointmentSync?.state === 'current' && result.legacyAppointmentSync.complete !== false;
@@ -264,6 +267,10 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     return Array.from(grouped, ([date, items]) => ({ date, items }));
   }
 
+  recordNumber(item: Item): number {
+    return this.listTotal() - (this.loadedListPage() - 1) * 20 - this.sortedVisibleSchedules().indexOf(item);
+  }
+
   courseCategory(item: Item): string { return String(item['categoryName'] || ({word:'单词课',trial:'体验训练',primary_writing:'小学语法写作课',middle_writing:'中学语法写作课',high_writing:'高中语法写作课',word_self_study:'单词自学课'} as Record<string,string>)[String(item['categoryKey'])] || ''); }
   courseTitle(item: Item): string {
     const sourceTitle = String(item['Title'] ?? '').trim();
@@ -567,6 +574,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   private sortedVisibleSchedules(): Item[] {
     const list = [...this.visibleSchedules()];
     if (this.isHistory) return list;
+    if (this.historyOrder()) return list.sort((left, right) => this.lessonStartTime(right) - this.lessonStartTime(left));
     return list.sort((left, right) => this.lessonStartTime(left) - this.lessonStartTime(right));
   }
 

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

@@ -68,7 +68,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) ? '查看已完成的历史阅读理解' : '生成阅读理解') + (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>
+            <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><span class="record-number" [attr.aria-label]="'序号 ' + recordNumber($index)">{{ recordNumber($index) }}</span><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) {
@@ -108,7 +108,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['kywrq'] || item['kywsj'] }} · 完成 {{ item['completedAt'] ? ($any(item['completedAt']) | date:'yyyy-MM-dd HH:mm':'+0800') : '待核对' }}</p>@if(item['correctionReason']){<small>费用已核对@if(item['originalAmount'] != null){ · 原金额 ¥{{ $any(item['originalAmount']) | number:'1.2-2' }}}@if(item['correctionAmount'] != null){ · 调整 ¥{{ $any(item['correctionAmount']) | number:'1.2-2' }}}</small><details class="fee-correction"><summary>查看核对说明</summary><p>{{item['correctionReason']}}</p></details>}@else if(item['rewardStatus']==='ineligible'){<small>{{ item['ineligibleReason']==='self_study' ? '学生自主复习,不计费' : item['ineligibleReason']==='duplicate_review' ? '重复复习,不重复计费' : item['ineligibleReason']==='overdue' ? '逾期补做,不计费' : '本次复习不计费' }}</small>}@else if(item['rewardStatus']==='historical'){<small>历史待核对,未自动计费</small>}@else if(item['rewardStatus']==='exception'){<small>费用异常待核对</small>}</div><b [class.zero-fee]="!item['money']">¥{{ ($any(item['money']) || 0) | number:'1.2-2' }}</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) ? '查看已完成的历史阅读理解' : '生成阅读理解') + (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>
+          <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><span class="record-number" [attr.aria-label]="'序号 ' + recordNumber($index)">{{ recordNumber($index) }}</span><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.ts

@@ -119,6 +119,7 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
   });
   readonly recordPageCount = computed(() => Math.max(1, Math.ceil(this.serverTotal() / this.recordPageSize)));
   readonly paginatedItems = computed(() => this.visibleItems());
+  recordNumber(index: number): number { return this.serverTotal() - (this.recordPage() - 1) * this.recordPageSize - index; }
   readonly latestReadingRecord = computed(() => this.overviewReadingRecord() ?? this.items().find((item) => extractReadingWords(item['xxqs']).length > 0));
   readonly latestReadingWordCount = computed(() => {
     const item = this.latestReadingRecord();

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

@@ -938,3 +938,6 @@ body app-study-records-page .fee-month-select > .fee-select-chevron { display: n
 
 /* 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; }
+
+app-study-records-page .record-number, app-course-center-page .record-number { display: inline-flex; min-width: 28px; min-height: 28px; align-items: center; justify-content: center; padding: 2px 5px; color: var(--brand-strong); background: var(--brand-pale); border-radius: 7px; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
+app-course-center-page .record-time { display: flex; align-items: flex-start; flex-direction: column; gap: 5px; }

+ 2 - 2
scripts/deploy-admin-functions.mjs

@@ -2996,7 +2996,7 @@ async function appStudentCompanionOverview(input,current,uid,p) {
   const stored=await Psql.query('SELECT c."generalId",c."title",c."createTime",c."status" AS "commonStatus",a.*,n."nodeName" AS "courseTitle" FROM "CourseAppointment" a LEFT JOIN "CommonModel" c ON c."company"=a."company" AND c."modelId"=54 AND c."itemId"=a."id" LEFT JOIN "Node" n ON n."company"=a."company" AND n."nodeId"::text=a."kcid" WHERE a."company"=$1 AND a."szyh"=$2 ORDER BY a."yysj" ASC,a."id" DESC LIMIT 5000',[DEFAULT_COMPANY_ID,String(uid)]),liveRows=legacyAppointmentSync.state==='current'?legacyAppointmentSync.items:[],merged=mergeCompanionRows(stored.map(appOverviewScheduleRow),liveRows),search=String(input.search||'').trim().toLocaleLowerCase('zh-CN'),month=/^\d{4}-\d{2}$/.test(String(input.month||''))?String(input.month):'',today=appOverviewToday();
   let base=merged.filter((row)=>history?(row.cancelled===true||number(row.dszt)>=30):row.cancelled!==true);if(search)base=base.filter((row)=>(String(row.Title||'')+' '+String(row.Title2||'')+' '+String(row.plxm||'')).toLocaleLowerCase('zh-CN').includes(search));if(month)base=base.filter((row)=>companionDateKey(row).startsWith(month+'-'));
   const dateMap=new Map();for(const row of base){const date=companionDateKey(row);if(!date)continue;const value=dateMap.get(date)||{date,finished:0,planned:0};if(number(row.dszt)>=30&&row.cancelled!==true)value.finished++;else if(row.cancelled!==true)value.planned++;dateMap.set(date,value);}const dates=[...dateMap.values()].sort((left,right)=>left.date.localeCompare(right.date));
-  let filtered=base;if(input.dateStart)filtered=filtered.filter((row)=>companionDateKey(row)>=String(input.dateStart));if(input.dateEnd)filtered=filtered.filter((row)=>companionDateKey(row)<=String(input.dateEnd));if(input.filter==='finished')filtered=filtered.filter((row)=>number(row.dszt)>=30&&row.cancelled!==true);if(input.filter==='pending')filtered=filtered.filter((row)=>number(row.dszt)<30&&row.cancelled!==true&&companionDateKey(row)<today);filtered.sort((left,right)=>{const order=(companionDateKey(left)+' '+String(left.yysj||left.sdsd||'')).localeCompare(companionDateKey(right)+' '+String(right.yysj||right.sdsd||''));return history?-order:order;});
+  let filtered=base;if(input.dateStart)filtered=filtered.filter((row)=>companionDateKey(row)>=String(input.dateStart));if(input.dateEnd)filtered=filtered.filter((row)=>companionDateKey(row)<=String(input.dateEnd));if(input.filter==='finished')filtered=filtered.filter((row)=>number(row.dszt)>=30&&row.cancelled!==true);if(input.filter==='pending')filtered=filtered.filter((row)=>number(row.dszt)<30&&row.cancelled!==true&&companionDateKey(row)<today);filtered.sort((left,right)=>{const order=(companionDateKey(left)+' '+String(left.yysj||left.sdsd||'')).localeCompare(companionDateKey(right)+' '+String(right.yysj||right.sdsd||''));return history||input.filter==='finished'?-order:order;});
   const total=filtered.length,offset=(p.index-1)*p.size,schedules=filtered.slice(offset,offset+p.size),courses=await Psql.query('SELECT c."generalId",c."title",b.*,b."objectId" AS "bindingObjectId",n."nodeName" AS "courseTitle" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=b."company" AND n."nodeId"::text=b."kcid" 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(uid)]),scopes=['courses','appointments'],live=withLegacyAppointmentVersion(await appLiveVersionSnapshot(uid,scopes,current),scopes,legacyAppointmentSync);
   return{schedules,courses:courses.map(appOverviewCourseRow),page:mobilePageMeta(total,p),dates,studentOptions:[],...mobileSource(),...live};
 }
@@ -3020,7 +3020,7 @@ async function appCompanionOverview(input,current) {
   if(input.filter==='finished')clauses.push('a."dszt"::numeric>=30 AND COALESCE(c."status",99)<>-2');
   if(input.filter==='pending'){clauses.push('a."dszt"::numeric<30 AND COALESCE(c."status",99)<>-2');add(date+'<?',appOverviewToday());}
   const where=clauses.join(' AND '),readCount=()=>Psql.one('SELECT COUNT(*)::int total'+join+' WHERE '+where,args);
-  const readRows=()=>Psql.query('SELECT c."generalId",c."title",c."createTime",c."status" AS "commonStatus",a.*,n."nodeName" AS "courseTitle"'+join+' LEFT JOIN "Node" n ON n."company"=a."company" AND n."nodeId"::text=a."kcid" WHERE '+where+' ORDER BY a."yysj" '+(history?'DESC':'ASC')+',a."id" DESC LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
+  const readRows=()=>Psql.query('SELECT c."generalId",c."title",c."createTime",c."status" AS "commonStatus",a.*,n."nodeName" AS "courseTitle"'+join+' LEFT JOIN "Node" n ON n."company"=a."company" AND n."nodeId"::text=a."kcid" WHERE '+where+' ORDER BY a."yysj" '+(history||input.filter==='finished'?'DESC':'ASC')+',a."id" DESC LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
   const readCourses=()=>coach?[]:Psql.query('SELECT c."generalId",c."title",b.*,b."objectId" AS "bindingObjectId",n."nodeName" AS "courseTitle" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=b."company" AND n."nodeId"::text=b."kcid" 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(uid)]);
   const readAllRows=()=>coach&&history?Psql.query('SELECT c."generalId",c."status" AS "commonStatus",a.dszt'+join+' WHERE '+where,args):[];
   // Each request reads fresh data; only independent reads overlap, with bounded database pressure.

+ 22 - 0
scripts/tests/mobile-read-performance.test.mjs

@@ -55,3 +55,25 @@ test('profileOnly and companion cannot bypass requested-user authorization',asyn
  await assert.rejects(h.ctx.appAccountOverview({profileOnly:true,uid:123},h.current),/denied/);
  await assert.rejects(h.ctx.appCompanionOverview({source:'database',uid:123},h.current),/denied/);assert.equal(h.calls.length,0);
 });
+
+for (const coach of [true,false]) test(`finished records descend before pagination and preserve calendar planned counts (coach=${coach})`,async()=>{
+ const h=harness(currentSource,{coach});
+ const result=await h.ctx.appCompanionOverview({source:'database',filter:'finished'},h.current);
+ const rows=h.calls.find(call=>call.sql.startsWith('SELECT c."generalId"')&&call.sql.includes(' LIMIT '));
+ assert.match(rows.sql,/ORDER BY a\."yysj" DESC/);
+ const dates=h.calls.find(call=>call.sql.includes('AS date'));
+ assert.doesNotMatch(dates.sql.split(' WHERE ')[1],/dszt/);
+ assert.equal(result.dates[0].planned,1);
+});
+
+test('student merged training history descends before pagination without losing pending calendar counts',async()=>{
+ const fn=source.slice(source.indexOf('async function appStudentCompanionOverview('),source.indexOf('async function appCompanionOverview('));
+ const rows=[{GeneralID:1,bxrq:'2026-11-01',sdsd:'19:00',dszt:30},{GeneralID:2,bxrq:'2026-11-02',sdsd:'19:00',dszt:30},{GeneralID:3,bxrq:'2026-11-03',sdsd:'19:00',dszt:30},{GeneralID:4,bxrq:'2026-11-04',sdsd:'19:00',dszt:0}];
+ const ctx=vm.createContext({DEFAULT_COMPANY_ID:'co',syncLegacyAppointments:async()=>({state:'unavailable',items:[]}),Psql:{query:async sql=>sql.includes('FROM "CourseAppointment"')?rows:[]},mergeCompanionRows:x=>x,appOverviewScheduleRow:x=>x,appOverviewCourseRow:x=>x,companionDateKey:r=>r.bxrq,appOverviewToday:()=> '2026-11-12',number:Number,mobilePageMeta:(n)=>({itemCount:n}),mobileSource:()=>({}),appLiveVersionSnapshot:async()=>({}),withLegacyAppointmentVersion:x=>x});
+ vm.runInContext(fn,ctx);
+ const first=await ctx.appStudentCompanionOverview({filter:'finished'},{},20,{index:1,size:2});
+ assert.deepEqual(plain(first.schedules.map(r=>r.GeneralID)),[3,2]);
+ const last=await ctx.appStudentCompanionOverview({filter:'finished'},{},20,{index:2,size:2});
+ assert.deepEqual(plain(last.schedules.map(r=>r.GeneralID)),[1]);
+ assert.equal(first.dates.reduce((n,d)=>n+d.planned,0),1);
+});