Procházet zdrojové kódy

fix(mobile): filter teacher history students by active schedules

彭峰 před 3 dny
rodič
revize
1eee92a009

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

@@ -329,6 +329,94 @@ describe('home learning modules', () => {
     });
   });
 
+  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(() => {});
+    const api = TestBed.inject(ApiService), original = api.post.bind(api);
+    const students = new Subject<ApiEnvelope<unknown>>();
+    const post = spyOn(api, 'post').and.callFake(((action: string, params: Record<string, unknown> = {}) => {
+      if (action === 'e_user_list') return students;
+      return original(action, params);
+    }) as typeof api.post);
+    const harness = await RouterTestingHarness.create('/pages/his/his?uid=999');
+    expect(post.calls.allArgs().find(([action]) => action === 'e_user_list')?.[1]).toEqual(jasmine.objectContaining({scope:'current-scheduled',selectedStudentId:999}));
+    expect(post.calls.allArgs().some(([action]) => action === 'app_learning_overview')).toBeFalse();
+    expect(watch).not.toHaveBeenCalled();
+    students.next({retcode:0,result:[{szyh:100,honeyname:'在学学员'}],page:{itemCount:1,pageCount:1}});
+    students.complete();
+    await harness.fixture.whenStable(); harness.fixture.detectChanges();
+    const component = harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent;
+    expect(component.studentId).toBe(100);
+    expect(watch).toHaveBeenCalledTimes(1);
+    expect(session.student()?.userId).toBe(100);
+    expect(TestBed.inject(Router).url).toContain('uid=100');
+    expect(component.studentsHaveMore()).toBeFalse();
+    expect(post.calls.allArgs().filter(([action]) => action === 'app_learning_overview').map(([,params]) => params?.['studentId'])).toEqual([100]);
+    expect(harness.routeNativeElement?.querySelector('.student-filter select')?.textContent).toContain('在学学员');
+  });
+
+  it('clears teacher history and the old student context when no eligible scheduled students remain', async () => {
+    user.set({userId:300, groupId:3}); session.groupId.set(3);
+    session.student.set({userId:999,groupId:1,honeyName:'旧学员'});
+    spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {});
+    const api = TestBed.inject(ApiService), original = api.post.bind(api);
+    const post = spyOn(api, 'post').and.callFake(((action: string, params: Record<string, unknown> = {}) =>
+      action === 'e_user_list' ? of({retcode:0,result:[],page:{itemCount:0,pageCount:0}}) : original(action,params)) as typeof api.post);
+    const harness = await RouterTestingHarness.create('/pages/his/his?uid=999');
+    await harness.fixture.whenStable(); harness.fixture.detectChanges();
+    expect(session.student()).toBeNull();
+    expect(TestBed.inject(Router).url).toBe('/pages/his/his');
+    expect(harness.routeNativeElement?.textContent).toContain('暂无在排课的正常学员');
+    expect(harness.routeNativeElement?.querySelector('.learning-record-card')).toBeNull();
+    expect(post.calls.allArgs().some(([action]) => action === 'app_learning_overview')).toBeFalse();
+  });
+
+  it('keeps an eligible restored student and a stable student order while loading more pages', async () => {
+    user.set({userId:300, groupId:3}); session.groupId.set(3);
+    spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {});
+    const api = TestBed.inject(ApiService), original = api.post.bind(api), requests: Record<string,unknown>[] = [];
+    spyOn(api, 'post').and.callFake(((action: string, params: Record<string, unknown> = {}) => {
+      if (action !== 'e_user_list') return original(action,params);
+      requests.push(params);
+      return of({retcode:0,result:params['cpage']===1?[{szyh:777,honeyname:'原学员'},{szyh:100,honeyname:'另一学员'}]:[{szyh:101,honeyname:'下一页学员'}],page:{itemCount:3,pageCount:2}});
+    }) as typeof api.post);
+    const harness = await RouterTestingHarness.create('/pages/his/his?uid=777');
+    await harness.fixture.whenStable(); harness.fixture.detectChanges();
+    const component = harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent;
+    expect(component.studentId).toBe(777);
+    expect(component.studentsHaveMore()).toBeTrue();
+    component.studentId=100; component.onFilterChange();
+    component.loadStudents(true);
+    await harness.fixture.whenStable(); harness.fixture.detectChanges();
+    expect(requests.map(params => params['selectedStudentId'])).toEqual([777,777]);
+    expect(component.students().map(row => component.studentValue(row))).toEqual([777,100,101]);
+    expect(component.studentId).toBe(100);
+    expect(component.studentsHaveMore()).toBeFalse();
+  });
+
+  it('shows a retry state instead of loading a stale student when the filtered student request fails', async () => {
+    user.set({userId:300, groupId:3}); session.groupId.set(3);
+    session.student.set({userId:999,groupId:1,honeyName:'旧学员'});
+    const watch = spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {});
+    const api = TestBed.inject(ApiService), original = api.post.bind(api);
+    let fail = true;
+    const post = spyOn(api, 'post').and.callFake(((action: string, params: Record<string, unknown> = {}) => {
+      if (action !== 'e_user_list') return original(action,params);
+      return fail ? throwError(() => new Error('学员列表暂时无法加载')) : of({retcode:0,result:[{szyh:100,honeyname:'在学学员'}],page:{itemCount:1,pageCount:1}});
+    }) as typeof api.post);
+    const harness = await RouterTestingHarness.create('/pages/his/his?uid=999');
+    await harness.fixture.whenStable(); harness.fixture.detectChanges();
+    expect(session.student()).toBeNull();
+    expect(harness.routeNativeElement?.textContent).toContain('学员列表加载失败');
+    expect(post.calls.allArgs().some(([action]) => action === 'app_learning_overview')).toBeFalse();
+    expect(watch).not.toHaveBeenCalled();
+    fail = false;
+    (harness.routeNativeElement?.querySelector('.teacher-student-state button') as HTMLButtonElement).click();
+    await harness.fixture.whenStable(); harness.fixture.detectChanges();
+    expect(session.student()?.userId).toBe(100);
+    expect(harness.routeNativeElement?.querySelector('.teacher-student-state')).toBeNull();
+  });
+
   it('reconciles monthly review fees from every matching page when an old gateway always returns 29', async () => {
     user.set({userId:300, groupId:3, honeyName:'钱老师'}); session.groupId.set(3);
     const api = TestBed.inject(ApiService), original = api.post.bind(api), requests: Record<string,unknown>[] = [];

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

@@ -56,7 +56,7 @@
   @if (meta.path === 'pages/member/myorder' && items().length) { <section class="mastery"><h2>当前已掌握</h2><div><span>{{ items()[0]['learned'] || 0 }}<small>学习单词</small></span><span>{{ items()[0]['ygg'] || 0 }}<small>抗遗忘单词</small></span><span>{{ items()[0]['djq'] || 0 }}<small>待抗遗忘</small></span></div><footer><a routerLink="/pages/stu/index">继续学习</a><a routerLink="/pages/stu/stu_calendar">科学抗遗忘</a></footer></section> }
 
   @if (meta.path === 'pages/his/his' && session.groupId() === 3 && !studentId) {
-    <section class="teacher-student-state" [class.error]="studentsError()"><lucide-icon [img]="studentsLoading() ? icons.LoaderCircle : studentsError() ? icons.AlertCircle : icons.BookOpen" [size]="28" [class.spin]="studentsLoading()" /><strong>{{ studentsLoading() ? '正在加载学员' : studentsError() ? '学员列表加载失败' : '暂未找到关联学员' }}</strong><span>{{ studentsLoading() ? '加载完成后将自动显示第一位学员的记录' : studentsError() || '关联学员后,学习记录会自动显示在这里' }}</span>@if (studentsError()) { <button type="button" (click)="retryStudents()">重新加载</button> }</section>
+    <section class="teacher-student-state" [class.error]="studentsError()"><lucide-icon [img]="studentsLoading() ? icons.LoaderCircle : studentsError() ? icons.AlertCircle : icons.BookOpen" [size]="28" [class.spin]="studentsLoading()" /><strong>{{ studentsLoading() ? '正在加载学员' : studentsError() ? '学员列表加载失败' : '暂无在排课的正常学员' }}</strong><span>{{ studentsLoading() ? '加载完成后将自动显示第一位学员的记录' : studentsError() || '这里只显示账号正常,且今天或以后仍有未完成排课的学员' }}</span>@if (studentsError()) { <button type="button" (click)="retryStudents()">重新加载</button> }</section>
   } @else if (loading()) { <div class="records-state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="24" />正在加载记录</div> }
   @else if (error()) { <div class="records-state"><lucide-icon [img]="icons.AlertCircle" [size]="26" />{{ error() }}<button type="button" class="secondary-button" (click)="load(true)">重新加载</button></div> }
   @else if (meta.path === 'pages/his/his') {

+ 25 - 17
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -49,7 +49,7 @@ interface LearningOverview {
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class StudyRecordsPageComponent implements OnInit, OnDestroy {
-  ngOnDestroy():void{this.stopLiveRefresh?.();this.historyRequestId++;this.historyRequest?.unsubscribe();}
+  ngOnDestroy():void{this.stopLiveRefresh?.();this.historyRequestId++;this.historyRequest?.unsubscribe();this.studentsRequest?.unsubscribe();}
   readonly meta = inject(ActivatedRoute).snapshot.data['meta'] as PageMeta;
   readonly items = signal<Item[]>([]);
   readonly courses = signal<Item[]>([]);
@@ -143,15 +143,16 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
   readonly session = inject(SessionService);
   private syncingHistoryQuery = false;
   private stopLiveRefresh?: () => void;
+  private studentsRequest?: Subscription;
+  private studentListSelectionId = 0;
 
   ngOnInit(): void {
     this.restoreHistoryState();
-    if (this.meta.path === 'pages/his/his') this.startLiveRefresh();
     if (this.meta.path === 'pages/his/his' && this.session.groupId() === 3) {
       this.loadStudents();
-      if (this.studentId) this.load();
       return;
     }
+    if (this.meta.path === 'pages/his/his') this.startLiveRefresh();
     this.load();
   }
 
@@ -491,6 +492,7 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
     this.historyRequest?.unsubscribe();const requestId=++this.historyRequestId;
     const targetUid = this.studentId || (this.session.groupId() === 3 ? 0 : uid);
     if (!targetUid) {
+      this.serverTotal.set(0);
       this.items.set([]);
       this.courses.set([]);
       this.learningSchedules.set([]);
@@ -570,32 +572,38 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
   loadStudents(more=false): void {
     if(this.studentsLoading())return;
     const requestedPage=more?this.studentListPage()+1:1;
+    if (!more) this.studentListSelectionId = this.studentId;
     this.studentsLoading.set(true);
     this.studentsError.set('');
-    this.api.post<unknown>('e_user_list', { uid: this.session.user()?.userId, cpage:requestedPage, psize:50 }).pipe(
+    this.studentsRequest = this.api.post<unknown>('e_user_list', { uid: this.session.user()?.userId, cpage:requestedPage, psize:50, scope:'current-scheduled', selectedStudentId:this.studentListSelectionId }).pipe(
       finalize(() => this.studentsLoading.set(false)),
     ).subscribe({
-      next: ({ result }) => {
+      next: ({ result, page }) => {
         this.studentListPage.set(requestedPage);
         const students = this.asList(result);
-        this.studentsHaveMore.set(students.length===50);
+        this.studentsHaveMore.set(page?.pageCount != null ? requestedPage < page.pageCount : students.length===50);
         this.students.set(more?[...this.students(),...students]:students);
-        if (this.studentId) {
-          this.syncSelectedStudentContext();
-          return;
-        }
-        if (!students.length) {
-          this.session.setStudent(null);
-          return;
-        }
+        if (more) return;
         const firstStudentId = students.map((student) => this.studentValue(student)).find((value) => value > 0) ?? 0;
-        if (!firstStudentId) return;
-        this.studentId = firstStudentId;
+        if (!students.some(student => this.studentValue(student) === this.studentId)) this.studentId = firstStudentId;
         this.syncSelectedStudentContext();
         this.syncHistoryState();
+        if (this.studentId && !this.stopLiveRefresh) this.startLiveRefresh();
+        if (!this.studentId) { this.stopLiveRefresh?.(); this.stopLiveRefresh = undefined; }
         this.load();
       },
-      error: (error: ApiError) => this.studentsError.set(error.message || '学员列表暂时无法加载'),
+      error: (error: ApiError) => {
+        this.studentsError.set(error.message || '学员列表暂时无法加载');
+        if (!more) {
+          this.students.set([]);
+          this.studentsHaveMore.set(false);
+          this.studentId = 0;
+          this.syncSelectedStudentContext();
+          this.syncHistoryState();
+          this.stopLiveRefresh?.(); this.stopLiveRefresh = undefined;
+          this.load();
+        }
+      },
     });
   }
 

+ 20 - 1
scripts/deploy-admin-functions.mjs

@@ -2559,7 +2559,26 @@ async function teamStats(input,current) {
   const parentId = await authorizeTeamUser(current,input.uid); const rows = await Psql.query('SELECT COALESCE("legacyUserData"->>\'VIP\',\'0\') AS vip,COUNT(*)::int AS total FROM "_User" WHERE "company"=$1 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) AND COALESCE("legacyUserData"->>\'ParentUserID\',\'0\')=$2 GROUP BY COALESCE("legacyUserData"->>\'VIP\',\'0\')', [DEFAULT_COMPANY_ID,String(parentId)]); const result = { childs:0,v0:0,v1:0,v2:0,v3:0,v4:0,v5:0 }; for (const row of rows) { const total = number(row.total); result.childs += total; const key = 'v' + Math.max(0,Math.min(5,number(row.vip))); result[key] += total; } return result;
 }
 async function coachStudentPage(input,current) {
-  const coachId = ownLegacyId(current); if (!coachId) fail(400,'陪练账号缺少旧系统用户 ID'); const paging = pageInput(input); const baseValues = [DEFAULT_COMPANY_ID,String(coachId)]; const base = 'FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=$1 AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE c."company"=$1 AND CAST(c."modelId" AS text)=\'54\' AND CAST(c."status" AS text)=\'99\' AND CAST(a."pl" AS text)=$2 AND COALESCE(CAST(a."szyh" AS text),\'\')<>\'\''; const countRow = await Psql.one('SELECT COUNT(DISTINCT CAST(a."szyh" AS text))::int AS total ' + base,baseValues); const rows = await Psql.query('WITH students AS (SELECT CAST(a."szyh" AS text) AS uid,MAX(CAST(a."id" AS text)) AS appointment_id ' + base + ' GROUP BY CAST(a."szyh" AS text) ORDER BY MAX(c."updatedAt") DESC LIMIT $3 OFFSET $4) SELECT ' + userSelect('u') + ',students.uid AS "__studentId",students.appointment_id AS "__appointmentId" FROM students LEFT JOIN "_User" u ON u."company"=$1 AND CAST(u."legacyUserId" AS text)=students.uid',baseValues.concat([paging.size,(paging.index - 1) * paging.size])); const normalized = rows.map((row) => { const value = legacyUserRow(row); const studentId = number(row.__studentId || value.UserID); return { ...value, userId:studentId, UserID:studentId, szyh:studentId, ID:number(row.__appointmentId), honeyname:value.HoneyName, honeyName:value.HoneyName }; }); const total = number(countRow.total); return { rows:normalized,page:{ itemCount:total,pageCount:Math.ceil(total / paging.size),pageIndex:paging.index,pageSize:paging.size } };
+  const coachId = ownLegacyId(current);
+  if (!coachId) fail(400,'陪练账号缺少旧系统用户 ID');
+  const paging = pageInput(input), scheduledOnly = input.scope === 'current-scheduled';
+  const values = [DEFAULT_COMPANY_ID,String(coachId)];
+  let base = "FROM \"CommonModel\" c JOIN \"CourseAppointment\" a ON a.\"company\"=$1 AND CAST(a.\"id\" AS text)=CAST(c.\"itemId\" AS text)";
+  if (scheduledOnly) base += " JOIN \"_User\" eligible ON eligible.\"company\"=$1 AND CAST(eligible.\"legacyUserId\" AS text)=CAST(a.\"szyh\" AS text)";
+  base += " WHERE c.\"company\"=$1 AND CAST(c.\"modelId\" AS text)='54' AND CAST(c.\"status\" AS text)='99' AND CAST(a.\"pl\" AS text)=$2 AND COALESCE(CAST(a.\"szyh\" AS text),'')<>''";
+  if (scheduledOnly) {
+    values.push(appOverviewToday().replace(/-/g,''));
+    base += " AND eligible.\"isDisabled\" IS NOT TRUE AND eligible.\"isDeleted\" IS NOT TRUE\n      AND COALESCE(eligible.\"legacyGroupId\"::text,eligible.\"legacyUserData\"->>'GroupID','0')='1'\n      AND COALESCE(to_jsonb(a)->>'isDeleted','false') NOT IN ('true','1')\n      AND COALESCE(to_jsonb(c)->>'isDeleted','false') NOT IN ('true','1')\n      AND COALESCE(NULLIF(a.\"dszt\"::text,''),'0') IN ('0','10','20')\n      AND LEFT(REGEXP_REPLACE(COALESCE(NULLIF(a.\"bxrq\",''),a.\"yysj\",''),'[^0-9]','','g'),8) >= $3";
+  }
+  const countRow = await Psql.one('SELECT COUNT(DISTINCT CAST(a."szyh" AS text))::int AS total ' + base,values);
+  // Keep an eligible restored selection on the first page, even with many students.
+  const preferred = scheduledOnly ? String(number(input.selectedStudentId)) : '';
+  const rowValues = values.concat([preferred,paging.size,(paging.index - 1) * paging.size]);
+  const preferredParam = '$' + (values.length + 1), limitParam = '$' + (values.length + 2), offsetParam = '$' + (values.length + 3);
+  const rows = await Psql.query('WITH students AS (SELECT CAST(a."szyh" AS text) AS uid,MAX(CAST(a."id" AS text)) AS appointment_id,MAX(c."updatedAt") AS updated_at,(CAST(a."szyh" AS text)=' + preferredParam + ') AS preferred ' + base + ' GROUP BY CAST(a."szyh" AS text) ORDER BY preferred DESC,updated_at DESC,uid LIMIT ' + limitParam + ' OFFSET ' + offsetParam + ') SELECT ' + userSelect('u') + ',students.uid AS "__studentId",students.appointment_id AS "__appointmentId" FROM students LEFT JOIN "_User" u ON u."company"=$1 AND CAST(u."legacyUserId" AS text)=students.uid ORDER BY students.preferred DESC,students.updated_at DESC,students.uid',rowValues);
+  const normalized = rows.map((row) => { const value = legacyUserRow(row); const studentId = number(row.__studentId || value.UserID); return { ...value, userId:studentId, UserID:studentId, szyh:studentId, ID:number(row.__appointmentId), honeyname:value.HoneyName, honeyName:value.HoneyName }; });
+  const total = number(countRow.total);
+  return { rows:normalized,page:{ itemCount:total,pageCount:Math.ceil(total / paging.size),pageIndex:paging.index,pageSize:paging.size } };
 }
 const POINT_HISTORY_CLASSES = { 1:'UserExpDomP',2:'UserSIcon',3:'UserExpHis',4:'UserUserPoint',5:'UserDummyPoint',6:'UserCredit' };
 function legacyPointRow(source) { const row = safe(source); row.ExpHisID = firstValue(row.expHisId,row.objectId); row.UserID = number(row.userId); row.HisTime = firstValue(row.hisTime,row.createdAt); row.Score = number(row.score); row.Detail = String(firstValue(row.detail,row.remark,'') || ''); row.Remark = String(row.remark || ''); row.score_before = number(row.scoreBefore); return row; }

+ 101 - 0
scripts/tests/coach-student-page.test.mjs

@@ -0,0 +1,101 @@
+import { after, before, beforeEach, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+import { PGlite } from '@electric-sql/pglite';
+
+const db = new PGlite();
+const source = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
+const gateway = vm.createContext({});
+vm.runInContext(source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace(/const DEFAULT_COMPANY_ID = '[^']+';/, "const DEFAULT_COMPANY_ID = 'co';"), gateway);
+gateway.Psql = {
+  one: async (sql, args) => (await db.query(sql, args)).rows[0],
+  query: async (sql, args) => (await db.query(sql, args)).rows,
+};
+gateway.appOverviewToday = () => '2026-10-30';
+const coach = { get: key => key === 'legacyUserId' ? 300 : undefined };
+const page = (input = {}) => gateway.coachStudentPage({ scope: 'current-scheduled', ...input }, coach);
+const ids = result => Array.from(result.rows, row => row.UserID);
+
+before(async () => {
+  await db.exec(`
+    CREATE TABLE "_User"("objectId" text,"company" text,"legacyUserId" int,"legacyGroupId" int,"legacyUserData" jsonb,
+      "isDisabled" boolean,"isDeleted" boolean,"username" text,"email" text,"mobile" text,"phone" text,"avatar" text,
+      "nickname" text,"nickName" text,"realName" text,"createdAt" timestamptz);
+    CREATE TABLE "CourseAppointment"("company" text,"id" int,"szyh" text,"pl" text,"dszt" text,"bxrq" text,"yysj" text,"isDeleted" boolean);
+    CREATE TABLE "CommonModel"("company" text,"modelId" int,"itemId" int,"status" int,"updatedAt" timestamptz,"isDeleted" boolean);
+  `);
+});
+beforeEach(() => db.exec('TRUNCATE "_User","CourseAppointment","CommonModel"'));
+after(() => db.close());
+
+async function user(id, { disabled = false, deleted = false, group = 1, company = 'co' } = {}) {
+  await db.query('INSERT INTO "_User"("objectId","company","legacyUserId","legacyGroupId","isDisabled","isDeleted","username","legacyUserData") VALUES($1,$2,$3,$4,$5,$6,$7,$8)',
+    ['u' + id, company, id, group, disabled, deleted, 'student-' + id, { GroupID: 1 }]);
+}
+async function appointment(id, student, { date = '2026-10-31', yysj = '', status = '0', commonStatus = 99, teacher = '300', company = 'co', deleted = false, commonDeleted = false } = {}) {
+  await db.query('INSERT INTO "CourseAppointment" VALUES($1,$2,$3,$4,$5,$6,$7,$8)', [company, id, String(student), teacher, status, date, yysj, deleted]);
+  await db.query('INSERT INTO "CommonModel" VALUES($1,54,$2,$3,$4,$5)', [company, id, commonStatus, '2026-10-30T00:00:00Z', commonDeleted]);
+}
+
+test('仅列出正常学员账号且在当前老师名下仍有今天或未来排课,先筛选再分页', async () => {
+  for (let id = 1; id <= 13; id++) await user(id, { disabled: id === 2, deleted: id === 3, group: id === 4 ? 3 : 1 });
+  await appointment(1, 1);
+  await appointment(2, 2);
+  await appointment(3, 3);
+  await appointment(4, 4);
+  await appointment(5, 5, { date: '2026-10-29' });
+  await appointment(6, 6, { status: '30' });
+  await appointment(7, 7, { commonStatus: -2 });
+  await appointment(8, 8, { teacher: '301' });
+  await appointment(9, 9, { company: 'other' });
+  await appointment(10, 10, { date: '2026-10-30', status: '10' });
+  await appointment(11, 11, { deleted: true });
+  await appointment(12, 12, { commonDeleted: true });
+  await appointment(13, 13, { status: '-1' });
+  await appointment(14, 999); // No matching account.
+  await appointment(15, 1); // Multiple lessons must not duplicate a student.
+  const first = await page({ psize: 1 });
+  const second = await page({ psize: 1, cpage: 2 });
+  assert.equal(first.page.itemCount, 2);
+  assert.equal(first.page.pageCount, 2);
+  assert.deepEqual([...ids(first), ...ids(second)].sort((a, b) => a - b), [1, 10]);
+});
+
+test('日期兼容紧凑日期、斜线日期及预约时间回退,空日期不进入列表', async () => {
+  for (let id = 1; id <= 5; id++) await user(id);
+  await appointment(1, 1, { date: '20261030' });
+  await appointment(2, 2, { date: '', yysj: '2026-10-30 09:00:00' });
+  await appointment(3, 3, { date: '2026/10/31' });
+  await appointment(4, 4, { date: '', yysj: '' });
+  await appointment(5, 5, { date: '20261029' });
+  assert.deepEqual(ids(await page()).sort((a, b) => a - b), [1, 2, 3]);
+});
+
+test('恢复有效学员时将其放在首屏,停用或无排课的旧选择不能绕过筛选', async () => {
+  for (let id = 1; id <= 4; id++) { await user(id, { disabled: id === 4 }); await appointment(id, id); }
+  await user(5);
+  const selected = await page({ selectedStudentId: 3, psize: 2 });
+  assert.equal(ids(selected)[0], 3);
+  const next = await page({ selectedStudentId: 3, psize: 2, cpage: 2 });
+  assert.equal(new Set([...ids(selected), ...ids(next)]).size, 3);
+  for (const selectedStudentId of [4, 5]) {
+    const result = await page({ selectedStudentId });
+    assert.equal(result.page.itemCount, 3);
+    assert.ok(!ids(result).includes(selectedStudentId));
+  }
+});
+
+test('筛选固定使用登录老师和当前帐套,请求参数不能指定其他老师', async () => {
+  await user(1); await user(2); await user(3, { company: 'other' });
+  await appointment(1, 1); await appointment(2, 2, { teacher: '301' }); await appointment(3, 3);
+  assert.deepEqual(ids(await page({ uid: 301, coachId: 301, company: 'other' })), [1]);
+});
+
+test('未请求新筛选的我的学员接口仍可列出历史学员', async () => {
+  await user(1); await user(2, { disabled: true });
+  await appointment(1, 1, { date: '2020-01-01', status: '30' });
+  await appointment(2, 2);
+  assert.deepEqual(ids(await page()), []);
+  assert.deepEqual(ids(await page({ scope: '' })).sort((a, b) => a - b), [1, 2]);
+});