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

fix: cache complete daily teacher review list and simplify navigation

彭峰 1 неделя назад
Родитель
Сommit
8cc93fa6ec

+ 6 - 4
docs/features/teacher-earnings-2026-10-15.md

@@ -7,9 +7,9 @@
 - 老师可直接结束课程,反馈后补。事务仍校验身份、课程状态、学习批次、课时凭据,保存完课工资快照、扣课时并生成适用复习计划;后补反馈不重做结课。独立 feedbackStatus 区分待补和已提交。
 - 销课记录和结课结果显示实际课酬,合法零元与工资待核对分开。无统一固定单价、无测试账号工资特例。
 - 复习首次完成按数据库生成的北京时间完成时间计薪。当天 1 元、逾期 0 元、未来拒绝,重试返回原凭据;状态与收入同事务。行锁后取实际服务器时间,避免请求排队跨午夜仍使用请求开始时间。旧 content_update 入口同样受规则约束。
-- 首页服务端先筛选今天再分页,4 条预览、今日总数/完成/待完成、单独逾期入口;未来计划仍在完整日历。没有任务、全部完成与加载失败分别呈现。
+- 首页仅显示全部今日任务,按每页 100 条完整加载后显示;发送 scope 与明确日期兼容旧接口,并在客户端过滤非今日记录,计数与列表一致。每条“查看计划”进入该任务详情;底部只保留“查看全部学员复习计划”,进入完整日历。没有任务、全部完成与加载失败分别呈现。
 - 首页、我的、销课和复习工资使用后台同一计算规则与净收入视图。老师收入是累计已记账收入,与工资实际发放分开;不再按课次数乘 20/40 元估算。缺失工资依据计入待核对数量。
-- 老师浏览日历、销课、工资、课程详情等页面保留现有底部导航。练习中未提交离开有提示,提交中禁止离开;完成后有继续今日、返回原列表及首页,原列表日期、学生、页码与滚动位置可恢复。缓存隔离账号、日期、筛选和业务数据版本,跨页/标签写入失效;首页跨午夜与重新可见刷新。
+- 老师浏览日历、销课、工资、课程详情等页面保留现有底部导航。复习页去除内容区重复的“返回复习任务”,沿用左上角返回;练习中未提交离开有提示,提交中禁止离开;完成后有继续今日、返回原列表及首页,原列表日期、学生、页码与滚动位置可恢复。缓存隔离账号、日期、筛选和业务数据版本,跨页/标签写入失效;首页今日列表单独按登录账号、会话与北京时间自然日缓存到 sessionStorage,返回首页、切回前台和同日重载页面均复用,跨日/切换账号/新登录重新加载;手动刷新保留。服务器确认复习成功后更新缓存任务及计数,不重拉列表。
 - 历史错计提供只读核对工具、后台筛选和逐条纠正入口。原凭据不可改写;未结算来源净额冲销,草稿行移除,已审核/已发放原单保留,后续草稿记唯一关联调整。理由、原金额、调整金额和工资单可导出。没有运行历史纠正。
 
 ## 验证证据
@@ -17,12 +17,12 @@
 | 检查 | 结果及限制 |
 | --- | --- |
 | Node / 数据库回归 | 211 项通过;真实 PostgreSQL DDL、触发器、约束与事务在 PGlite 执行 |
-| 移动端 ChromeHeadless | 220 项通过,含未反馈结课及服务器确认前防重复提交 |
+| 移动端 ChromeHeadless | 228 项通过,含未反馈结课、防重复提交、完整今日列表和按登录/日期缓存 |
 | 管理端 ChromeHeadless | 271 项通过,含纠正权限、理由和承接草稿版本检查 |
 | 双端生产构建 | 通过;存在原有 SCSS budget 与 CommonJS 警告 |
 | cloud:validate | 11 个函数校验通过 |
 | 本地动作覆盖检查 | 122 个使用动作检查,missing 为空 |
-| 浏览器页面 | 本地生产构建+隔离接口数据:当天/逾期完成、金额、首页刷新、错误态、导航通过;360px/390px 截图已人工查看,姓名和工资不互相挤压 |
+| 浏览器页面 | 本地生产构建+隔离接口数据:当天/逾期完成、金额、首页缓存复用、手动刷新错误态、导航通过;360px/390px 截图已人工查看,姓名和工资不互相挤压 |
 | 组合迁移 | 一笔事务安装,连续执行两次通过,原完课和反馈记录保留 |
 
 复现:`npm run test:server`、`npm run test:mobile:ci`、`npm run test:admin:ci`、`npm run build`、`npm run cloud:validate`、`npm run app:actions:audit:local`。浏览器验收:`npm run teacher-earnings:browser`,截图/结果写入忽略目录 `tmp/teacher-earnings-qa/`,不会连接线上账号。
@@ -31,6 +31,8 @@ PGlite 单连接的并发调用会排队,不能证明生产 PostgreSQL 多连
 
 ## 性能实测及边界
 
+以下为最初 4 条预览版本的查询阶段测量,不代表后来“全部今日任务+当天缓存”版本的首载性能;后者首载请求量随当天任务数变化。
+
 固定 10,000 条任务,40 条今天任务,原始与修改后 appReviewPage 使用同一 PGlite 数据库和现有索引。先各预热 4 次,再按 AB/BA 交替各测 20 次,不注入网络延迟。原始首页请求 pending/20 条,修改后请求 today/4 条并省略日历和学员元数据。
 
 | 测量 | 原版 | 修改后 |

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

@@ -248,7 +248,7 @@ class LearningModulesApiService {
     if (action === 'app_review_page') {
       this.lastMemoryParams=params;
       if (this.reviewPageResponse) return this.reviewPageResponse as Observable<ApiEnvelope<T>>;
-      const items=[
+      const items=params['scope']==='today' ? [{ GeneralID: 102, Title: '张雅茜@任务', kc_title: '高中英语复习', kywsj: String(params['date'])+' 09:00', fxzt: 0, yhid: 100, xxjlid: 900 }] : [
         { GeneralID: 100, Title: '张雅茜@历史任务', kc_title: '历史复习', kywsj: '2026-05-15 09:00', learned_at: '2026-05-01T01:15:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 },
         { GeneralID: 101, Title: '张雅茜@任务', kc_title: '初中英语复习', kywsj: '2026-08-01 09:00', learned_at: '2026-07-20T02:20:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 },
         { GeneralID: 102, Title: '张雅茜@任务', kc_title: '高中英语复习', kywsj: '2026-08-02 09:00', learned_at: '2026-07-20T02:20:00.000Z', fxzt: 0, yhid: 100, xxjlid: 900 },
@@ -300,6 +300,7 @@ describe('home learning modules', () => {
   };
 
   beforeEach(() => {
+    sessionStorage.removeItem('xiaoshu.learning.teacherTodayReview');
     spyOn(window, 'confirm').and.returnValue(true);
     // Route tests do not depend on remote audio availability or its error-toast timer.
     spyOn(HTMLMediaElement.prototype, 'play').and.returnValue(Promise.resolve());
@@ -317,6 +318,36 @@ describe('home learning modules', () => {
     });
   });
 
+  it('keeps all today tasks cached across navigation and visibility', async () => {
+    user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
+    session.groupId.set(3);
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const originalPost = api.post.bind(api);
+    const post = spyOn(api, 'post').and.callFake((action, params) => {
+      if (action !== 'app_review_page') return originalPost(action, params);
+      return of({ retcode: 1, result: {
+        items: Array.from({length: 7}, (_, i) => ({ GeneralID: 100+i, yhid: 100, Title: '今日学员', kywsj: params?.['date'], fxzt: i === 6 ? 1 : 0 })),
+        page: { itemCount: 7, pageCount: 1 }, summary: { total: 7, completed: 1, due: 6, today: 7, income: 0 }, dates: [],
+      } }) as never;
+    });
+    const count = () => post.calls.allArgs().filter(([name]) => name === 'app_review_page').length;
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/home/index');
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelectorAll('.coach-memory-list article').length).toBe(7);
+    expect(harness.routeNativeElement?.querySelectorAll('.coach-memory-footer').length).toBe(1);
+    expect(harness.routeNativeElement?.textContent).not.toContain('查看今日全部任务');
+    document.dispatchEvent(new Event('visibilitychange'));
+    await harness.navigateByUrl('/pages/member/starshop');
+    await harness.navigateByUrl('/pages/home/index');
+    harness.fixture.detectChanges();
+    expect(count()).toBe(1);
+    expect(harness.routeNativeElement?.querySelector('.coach-memory-state')).toBeNull();
+    (harness.routeNativeElement?.querySelector('.coach-memory-refresh') as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    expect(count()).toBe(2);
+  });
+
   it('loads the review calendar once per login and refreshes only on an explicit request', async () => {
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     const originalPost = api.post.bind(api);
@@ -1205,8 +1236,8 @@ describe('home learning modules', () => {
     await harness.navigateByUrl('/pages/home/index');
     response.next({ retcode: 1, result: {
       items: [
-        { GeneralID: 102, yhid: 100, honeyName: '', Title: '旧姓名@复习', kywsj: '2025-07-19', fxzt: 0, xxjlid: 900 },
-        { GeneralID: 103, yhid: '101', honeyName: ' ', HoneyName: '', kywsj: '2025-07-15', fxzt: 0, xxjlid: 901 },
+        { GeneralID: 102, yhid: 100, honeyName: '', Title: '旧姓名@复习', kywsj: String(api.lastMemoryParams['date']), fxzt: 0, xxjlid: 900 },
+        { GeneralID: 103, yhid: '101', honeyName: ' ', HoneyName: '', kywsj: String(api.lastMemoryParams['date']), fxzt: 0, xxjlid: 901 },
       ],
       students: [{ id: '101', name: '李同学', taskCount: 1 }, { id: '100', name: '张雅茜', taskCount: 1 }],
       page: { itemCount: 2, pageCount: 1 },
@@ -1243,11 +1274,11 @@ describe('home learning modules', () => {
     expect(coachAccountTab?.textContent).toContain('我的');
     expect(harness.routeNativeElement?.querySelector('.content-section')).toBeNull();
     const memoryApi = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
-    expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, pageSize:4, scope:'today', includeMetadata:false, includeIncome:false }));
+    expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, pageSize:100, scope:'today', includeMetadata:false, includeIncome:false }));
     expect(harness.routeNativeElement?.querySelector('.coach-memory-board')?.textContent).toContain('抗遗忘带练');
     expect(harness.routeNativeElement?.querySelector('.coach-memory-board')?.textContent).toContain('张雅茜');
-    expect(harness.routeNativeElement?.querySelector('.coach-memory-list article.ready > button')?.textContent).toContain('带学生复习');
-    expect(harness.routeNativeElement?.querySelector('.coach-memory-footer')?.getAttribute('href')).toContain('/pages/stu/stu_calendar?date=');
+    expect(harness.routeNativeElement?.querySelector('.coach-memory-list article.ready > button')?.textContent).toContain('查看计划');
+    expect(harness.routeNativeElement?.querySelector('.coach-memory-footer')?.getAttribute('href')).toBe('/pages/stu/stu_calendar');
     (harness.routeNativeElement?.querySelector('.coach-memory-list article.ready > button') as HTMLButtonElement).click();
     await harness.fixture.whenStable();
     expect(TestBed.inject(Router).url).toContain('/pages/stu/record?id=900&gid=102');

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

@@ -0,0 +1,119 @@
+import { TestBed } from '@angular/core/testing';
+import { signal } from '@angular/core';
+import { firstValueFrom, of, Subject, throwError } from 'rxjs';
+import { ApiService } from './api.service';
+import { SessionService } from './session.service';
+import { ReviewPage } from './review-page';
+import { beijingToday, TeacherTodayReviewService } from './teacher-today-review.service';
+
+const storageKey = 'xiaoshu.learning.teacherTodayReview';
+
+describe('TeacherTodayReviewService', () => {
+  let service: TeacherTodayReviewService;
+  let api: jasmine.SpyObj<ApiService>;
+  const user = signal({ userId: 300, groupId: 3 });
+  const token = signal('r:teacher-today-test');
+  const page = (items: ReviewPage['items'], pageCount = 1): ReviewPage => ({
+    items, page: { itemCount: items.length, pageCount }, dates: [],
+    summary: { total: 500, completed: 0, due: 0, today: 2, income: 0 },
+  });
+  const task = (id: number, date = beijingToday(), fxzt = 0) => ({ GeneralID: id, kywsj: date + ' 09:00', fxzt });
+
+  beforeEach(() => {
+    sessionStorage.removeItem(storageKey);
+    user.set({ userId: 300, groupId: 3 });
+    token.set('r:teacher-today-test');
+    api = jasmine.createSpyObj<ApiService>('ApiService', ['post']);
+    TestBed.configureTestingModule({ providers: [
+      { provide: ApiService, useValue: api },
+      { provide: SessionService, useValue: { user, token } },
+    ] });
+    service = TestBed.inject(TeacherTodayReviewService);
+  });
+  afterEach(() => sessionStorage.removeItem(storageKey));
+
+  it('loads every page, filters non-today rows and counts the displayed tasks on older gateways', async () => {
+    api.post.and.callFake((_action, params) => of({ result: page(Number(params?.['page']) === 1
+      ? [...Array.from({ length: 100 }, (_, i) => task(i)), task(999, '2099-01-01')]
+      : [task(100, beijingToday(), 1)], 2) }) as never);
+    const result = await firstValueFrom(service.load());
+    expect(api.post.calls.count()).toBe(2);
+    expect(api.post.calls.first().args[1]).toEqual(jasmine.objectContaining({ date: beijingToday(), pageSize: 100, scope: 'today' }));
+    expect(result.items.length).toBe(101);
+    expect(result.summary).toEqual(jasmine.objectContaining({ todayTotal: 101, todayPending: 100, todayCompleted: 1 }));
+  });
+
+  it('reuses the daily list and updates confirmed completion without another request', async () => {
+    api.post.and.returnValue(of({ result: page([task(1), task(2)]) }) as never);
+    await firstValueFrom(service.load());
+    service.markCompleted('1');
+    service.markCompleted('1');
+    const result = await firstValueFrom(service.load());
+    expect(api.post.calls.count()).toBe(1);
+    expect(result.summary.todayCompleted).toBe(1);
+    expect(result.summary.todayPending).toBe(1);
+    await firstValueFrom(service.load(true));
+    expect(api.post.calls.count()).toBe(2);
+  });
+
+  it('restores the same login daily cache after page reload', async () => {
+    api.post.and.returnValue(of({ result: page([task(1)]) }) as never);
+    await firstValueFrom(service.load());
+    const restored = TestBed.runInInjectionContext(() => new TeacherTodayReviewService());
+    expect((await firstValueFrom(restored.load())).items.length).toBe(1);
+    expect(api.post.calls.count()).toBe(1);
+  });
+
+  it('reloads when the Beijing day, account or login changes', async () => {
+    jasmine.clock().install();
+    try {
+      jasmine.clock().mockDate(new Date('2026-10-15T15:59:59Z'));
+      api.post.and.callFake(() => of({ result: page([task(1)]) }) as never);
+      await firstValueFrom(service.load());
+      jasmine.clock().mockDate(new Date('2026-10-15T16:00:00Z'));
+      await firstValueFrom(service.load());
+      expect(api.post.calls.mostRecent().args[1]?.['date']).toBe('2026-10-16');
+      user.set({ userId: 301, groupId: 3 });
+      await firstValueFrom(service.load());
+      token.set('r:another-login');
+      await firstValueFrom(service.load());
+      expect(api.post.calls.count()).toBe(4);
+    } finally { jasmine.clock().uninstall(); }
+  });
+
+  it('does not cache incomplete multi-page results and retries the full list', async () => {
+    let fail = true;
+    api.post.and.callFake((_action, params) => Number(params?.['page']) === 2 && fail
+      ? throwError(() => new Error('offline')) : of({ result: page([task(Number(params?.['page']))], 2) }) as never);
+    await expectAsync(firstValueFrom(service.load())).toBeRejectedWithError('offline');
+    expect(sessionStorage.getItem(storageKey)).toBeNull();
+    fail = false;
+    expect((await firstValueFrom(service.load())).items.length).toBe(2);
+    expect(api.post.calls.count()).toBe(4);
+  });
+
+  it('shares an in-flight load and preserves completion confirmed while it was loading', async () => {
+    const response = new Subject<unknown>();
+    api.post.and.returnValue(response as never);
+    const first = firstValueFrom(service.load());
+    const second = firstValueFrom(service.load());
+    service.markCompleted('1');
+    response.next({ result: page([task(1)]) });
+    expect((await first).summary.todayCompleted).toBe(1);
+    expect((await second).summary.todayCompleted).toBe(1);
+    expect(api.post.calls.count()).toBe(1);
+  });
+
+  it('does not let an old account response overwrite the current account cache', async () => {
+    const oldResponse = new Subject<unknown>();
+    api.post.and.returnValue(oldResponse as never);
+    const oldLoad = firstValueFrom(service.load());
+    user.set({ userId: 301, groupId: 3 });
+    api.post.and.returnValue(of({ result: page([task(2)]) }) as never);
+    await firstValueFrom(service.load());
+    oldResponse.next({ result: page([task(1)], 2) });
+    await oldLoad;
+    expect((await firstValueFrom(service.load())).items[0]['GeneralID']).toBe(2);
+    expect(api.post.calls.count()).toBe(2);
+  });
+});

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

@@ -0,0 +1,89 @@
+import { inject, Injectable } from '@angular/core';
+import { EMPTY, expand, finalize, map, Observable, of, reduce, shareReplay, take, tap } from 'rxjs';
+import { antiForgetCompleted, antiForgetDate } from './anti-forget';
+import { ApiService } from './api.service';
+import { ReviewPage } from './review-page';
+import { SessionService } from './session.service';
+
+const STORAGE_KEY = 'xiaoshu.learning.teacherTodayReview';
+
+export function beijingToday(): string {
+  return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
+}
+
+/** One complete daily list per login. Navigation and generic data changes do not refetch it. */
+@Injectable({ providedIn: 'root' })
+export class TeacherTodayReviewService {
+  private readonly api = inject(ApiService);
+  private readonly session = inject(SessionService);
+  private identity = '';
+  private cached?: ReviewPage;
+  private pending?: Observable<ReviewPage>;
+  private readonly completedIds = new Set<string>();
+
+  load(force = false): Observable<ReviewPage> {
+    const key = this.ensureIdentity();
+    if (this.pending) return this.pending;
+    if (this.cached && !force) return of(this.cached);
+    const date = beijingToday();
+    const uid = this.session.user()?.userId;
+    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,
+    }).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),
+      reduce((all, { result }) => all ? { ...all, items: [...all.items, ...result.items] } : result, null as ReviewPage | null),
+      map((result) => {
+        if (!result) throw new Error('今日复习数据为空');
+        const serverToday = result.serverToday || date;
+        const items = result.items.filter(item => antiForgetDate(item) === serverToday);
+        return this.withSummary({ ...result, serverToday, items });
+      }),
+      tap(result => { if (this.ensureIdentity() === key) { this.cached = result; this.persist(); } }),
+      finalize(() => { if (this.pending === request) this.pending = undefined; }),
+      shareReplay({ bufferSize: 1, refCount: false }),
+    );
+    this.pending = request;
+    return request;
+  }
+
+  /** Called only after the server confirms completion; never infer success from a click. */
+  markCompleted(id: string): void {
+    this.ensureIdentity();
+    this.completedIds.add(String(id));
+    if (this.cached) {
+      this.cached = this.withSummary(this.cached);
+      this.persist();
+    }
+  }
+
+  private withSummary(data: ReviewPage): ReviewPage {
+    const items = data.items.map(item => this.completedIds.has(String(item['GeneralID'])) ? { ...item, fxzt: 1 } : item);
+    const completed = items.filter(antiForgetCompleted).length;
+    return { ...data, items, page: { itemCount: items.length, pageCount: 1 }, summary: {
+      ...data.summary, today: items.length, todayTotal: items.length, todayCompleted: completed, todayPending: items.length - completed,
+    } };
+  }
+
+  private ensureIdentity(): string {
+    const key = `${this.session.user()?.userId}|${this.session.token()}|${beijingToday()}`;
+    if (key !== this.identity) {
+      this.identity = key;
+      this.cached = undefined;
+      this.pending = undefined;
+      this.completedIds.clear();
+      try {
+        const saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null');
+        if (saved?.identity === key && Array.isArray(saved.data?.items) && Array.isArray(saved.data?.dates) && saved.data?.summary && saved.data?.serverToday) this.cached = saved.data;
+        else sessionStorage.removeItem(STORAGE_KEY);
+      } catch { /* Storage is optional; the in-memory daily cache still works. */ }
+    }
+    return key;
+  }
+
+  private persist(): void {
+    try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ identity: this.identity, data: this.cached })); }
+    catch { /* Quota or privacy mode must not turn a successful load into an error. */ }
+  }
+}

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

@@ -49,12 +49,12 @@
           <span><strong>{{ coachReviewSummary().todayCompleted ?? 0 }}</strong><small>今日已完成</small></span>
         </div>
         <div class="coach-memory-list">
-          @for (item of coachReviewItems().slice(0, 4); track $index) {
+          @for (item of coachReviewItems(); track $index) {
             <article [class.ready]="coachReviewReady(item)" [class.completed]="coachReviewStatus(item) === '已完成'">
               <div class="coach-student-avatar"><lucide-icon [img]="icons.UserRound" [size]="18" /></div>
               <div class="coach-memory-copy"><strong>{{ coachReviewStudent(item) }}</strong><span>{{ coachReviewTitle(item) }} · {{ coachReviewDate(item) }}</span></div>
               <em>{{ coachReviewStatus(item) }}</em>
-              <button type="button" (click)="openCoachReview(item)">{{ coachReviewAction(item) }}</button>
+              <button type="button" (click)="openCoachReview(item)">查看计划</button>
             </article>
           } @empty {
             <div class="coach-memory-empty"><lucide-icon [img]="icons.Check" [size]="24" /><strong>今天没有复习任务</strong><span>可查看全部复习计划了解后续安排</span></div>
@@ -63,8 +63,6 @@
       }
       @if (!antiForgetLoading() && !antiForgetError()) {
         @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" [queryParams]="{date:today()}">查看今日全部任务</a>
-        @if (coachReviewSummary().overduePending) { <a class="coach-memory-footer" routerLink="/pages/stu/stu_calendar" [queryParams]="{scope:'overdue'}">逾期补做 {{coachReviewSummary().overduePending}} 条 · 不计薪</a> }
       }
       <a class="coach-memory-footer" routerLink="/pages/stu/stu_calendar"><span>查看全部学员复习计划</span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></a>
     </section>

+ 27 - 18
projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts

@@ -3,7 +3,8 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, NgZone, OnDestroy, OnInit, signal } from '@angular/core';
 import { Router, RouterLink } from '@angular/router';
 import { AlertCircle, BarChart3, BookMarked, BookOpen, CalendarDays, Check, ChevronRight, Clock3, Eye, EyeOff, GraduationCap, Info, LucideAngularModule, RefreshCw, Search, UserRound, Zap } from 'lucide-angular';
-import { finalize } from 'rxjs';
+import { finalize, map, Subscription } from 'rxjs';
+import { beijingToday, TeacherTodayReviewService } from '../../core/teacher-today-review.service';
 import { antiForgetCompleted, antiForgetDate, buildAntiForgetView, findAntiForgetList } from '../../core/anti-forget';
 import { ApiService } from '../../core/api.service';
 import { PlatformService } from '../../core/platform.service';
@@ -37,7 +38,7 @@ export class HomePageComponent implements OnInit, OnDestroy {
   readonly reviewData=signal<ReviewPage|null>(null);
   readonly antiForgetView = computed(() => reviewOverview(this.reviewData()));
   readonly isCoach = computed(() => Number(this.session.groupId()) === 3);
-  readonly coachReviewItems = computed(() => [...this.antiForgetItems()].sort((left, right) => {
+  readonly coachReviewItems = computed(() => this.antiForgetItems().filter(item => antiForgetDate(item) === this.today()).sort((left, right) => {
     const stateOrder = (item: Item) => antiForgetCompleted(item) ? 2 : this.coachReviewReady(item) ? 0 : 1;
     const stateDifference = stateOrder(left) - stateOrder(right);
     if (stateDifference) return stateDifference;
@@ -52,12 +53,20 @@ export class HomePageComponent implements OnInit, OnDestroy {
   private readonly platform = inject(PlatformService);
   private readonly liveData = inject(LiveDataRefreshService);
   private readonly zone=inject(NgZone);
+  private readonly todayReviews = inject(TeacherTodayReviewService);
+  private reviewRequest?: Subscription;
   private midnightTimer?: ReturnType<typeof setTimeout>;
-  private readonly resumeToday=()=>{if(document.visibilityState==='visible')this.retryAntiForget();};
+  private readonly resumeToday=()=>{
+    if(document.visibilityState==='visible') {
+      const uid=this.session.user()?.userId;
+      if(uid && (!this.isCoach() || this.reviewLoadedDate !== beijingToday())) this.loadAntiForget(uid);
+    }
+  };
+  private reviewLoadedDate = '';
   private scheduleTodayRefresh():void{
     if(this.midnightTimer)clearTimeout(this.midnightTimer);
     const now=Date.now(),next=(Math.floor((now+8*3600000)/86400000)+1)*86400000-8*3600000;
-    this.midnightTimer=this.zone.runOutsideAngular(()=>setTimeout(()=>this.zone.run(()=>{this.retryAntiForget();this.retryCoachStats();this.scheduleTodayRefresh();}),next-now+100));
+    this.midnightTimer=this.zone.runOutsideAngular(()=>setTimeout(()=>this.zone.run(()=>{const uid=this.session.user()?.userId;if(uid)this.loadAntiForget(uid);this.retryCoachStats();this.scheduleTodayRefresh();}),next-now+100));
   }
   private stopLiveRefresh?: () => void;
 
@@ -77,7 +86,7 @@ export class HomePageComponent implements OnInit, OnDestroy {
           if (!currentId) return;
           this.loadStats(currentId, true);
           if(!this.isCoach())this.loadBalances(currentId);
-          this.loadAntiForget(currentId, true);
+          if (!this.isCoach()) this.loadAntiForget(currentId, true);
         },
       });
     }
@@ -85,6 +94,7 @@ export class HomePageComponent implements OnInit, OnDestroy {
 
   ngOnDestroy(): void {
     this.stopLiveRefresh?.();
+    this.reviewRequest?.unsubscribe();
     if(this.midnightTimer)clearTimeout(this.midnightTimer);
     document.removeEventListener('visibilitychange',this.resumeToday);
   }
@@ -96,7 +106,7 @@ export class HomePageComponent implements OnInit, OnDestroy {
 
   retryAntiForget(): void {
     const uid = this.session.user()?.userId;
-    if (uid) this.loadAntiForget(uid);
+    if (uid) this.loadAntiForget(uid, false, true);
   }
 
   coachReviewStudent(item: Item): string {
@@ -128,16 +138,7 @@ export class HomePageComponent implements OnInit, OnDestroy {
     return this.coachReviewReady(item) ? '待复习' : '未到期';
   }
 
-  coachReviewAction(item: Item): string {
-    if (antiForgetCompleted(item)) return '查看';
-    return this.coachReviewReady(item) ? '带学生复习' : '查看计划';
-  }
-
   openCoachReview(item: Item): void {
-    if (!this.coachReviewReady(item) && !antiForgetCompleted(item)) {
-      void this.router.navigateByUrl('/pages/stu/stu_calendar');
-      return;
-    }
     const studentId = Number(item['yhid'] ?? 0);
     if (!studentId) {
       this.platform.toast('复习任务缺少关联学员', 'error');
@@ -168,15 +169,23 @@ export class HomePageComponent implements OnInit, OnDestroy {
     });
   }
 
-  private loadAntiForget(uid: string | number, background = false): void {
+  private loadAntiForget(uid: string | number, background = false, force = false): void {
+    this.reviewRequest?.unsubscribe();
     if (!background) {
       this.antiForgetLoading.set(true);
       this.antiForgetError.set('');
     }
-    this.api.post<ReviewPage>('app_review_page', { uid, page:1,pageSize:this.isCoach()?4:20,...(this.isCoach()?{scope:'today',includeMetadata:false,includeIncome:false}:{status:'pending'}) }).pipe(
+    const request = this.isCoach() ? this.todayReviews.load(force) :
+      this.api.post<ReviewPage>('app_review_page', { uid, page: 1, pageSize: 20, status: 'pending' }).pipe(map(({ result }) => result));
+    this.reviewRequest = request.pipe(
       finalize(() => { if (!background) this.antiForgetLoading.set(false); }),
     ).subscribe({
-      next: ({ result }) => {this.reviewData.set(result);this.antiForgetItems.set(result.items);this.antiForgetError.set('');},
+      next: (result) => {
+        this.reviewData.set(result);
+        this.antiForgetItems.set(result.items);
+        this.reviewLoadedDate = beijingToday();
+        this.antiForgetError.set('');
+      },
       error: () => { this.antiForgetError.set('抗遗忘计划暂时无法加载'); },
     });
   }

+ 0 - 1
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -460,7 +460,6 @@
         <a class="secondary-button" routerLink="/pages/home/index">返回首页</a>
       </section>
     } @else {
-    <button class="secondary-button" type="button" (click)="returnToReview()">返回复习任务</button>
     @if(canStartPointReading && overdueReview){<p class="record-tip" role="status">这是逾期任务,可以补做,但不计复习工资。</p>}
 
     <section class="record-overview">

+ 3 - 1
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -1,3 +1,4 @@
+import { TeacherTodayReviewService } from '../../core/teacher-today-review.service';
 import { lessonStudySources } from '../appointment/lesson-study-sources';
 import { DropdownDirective } from '../../shared/dropdown.directive';
 import { LearningDraft } from '../../core/learning-draft';
@@ -284,6 +285,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private readonly router = inject(Router);
   private readonly api = inject(ApiService);
   private readonly session = inject(SessionService);
+  private readonly todayReviews = inject(TeacherTodayReviewService);
   private readonly platform = inject(PlatformService);
   private readonly wordPreviewPageSize = 30;
   private examTimer: ReturnType<typeof setInterval> | null = null;
@@ -958,7 +960,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     if(this.submitting()||this.reviewCompletion())return;
     this.submitting.set(true);
     this.api.post<{amount:number;rewardStatus:string;unchanged?:boolean}>('app_review_complete',{id:this.query('gid')}).pipe(finalize(()=>this.submitting.set(false))).subscribe({
-      next:({result})=>{this.reviewCache().clear();this.reviewCompletion.set(result);},
+      next:({result})=>{this.reviewCache().clear();if(Number(this.session.groupId())===3)this.todayReviews.markCompleted(this.query('gid'));this.reviewCompletion.set(result);},
       error:(e:ApiError)=>this.platform.toast(e.message,'error'),
     });
   }

+ 8 - 5
scripts/check-teacher-earnings-browser.mjs

@@ -36,14 +36,17 @@ try{
  const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());
  await page.goto(origin+'/pages/home/index');await page.locator('.coach-memory-list article').waitFor();
  assert.match(await page.locator('.income-breakdown').innerText(),/\*\*\*/);await page.getByRole('button',{name:'显示累计收入'}).click();await page.waitForFunction(()=>document.querySelector('.income-breakdown')?.textContent.includes('¥122.00'));assert.match(await page.locator('.income-breakdown').innerText(),/¥120\.00[\s\S]*¥2\.00[\s\S]*¥122\.00/);
- assert.ok(calls.some(p=>p.action==='app_review_page'&&p.scope==='today'&&p.pageSize===4&&p.includeMetadata===false));checks.push('首页当天范围、三项累计收入同显同隐');
+ assert.ok(calls.some(p=>p.action==='app_review_page'&&p.scope==='today'&&p.pageSize===100&&p.date===day&&p.includeMetadata===false));checks.push('首页当天范围、三项累计收入同显同隐');
  await page.screenshot({path:out+'/home-390.png',fullPage:true});
- await page.getByRole('button',{name:'带学生复习',exact:true}).click();await page.locator('.record-list').waitFor();await page.getByRole('button',{name:'结束本次复习',exact:true}).click();await page.locator('.review-complete').waitFor();assert.match(await page.locator('.review-complete').innerText(),/本次收入 ¥1\.00/);await page.screenshot({path:out+'/review-complete-390.png',fullPage:true});
- await page.getByRole('link',{name:'返回首页',exact:true}).click();await page.getByText('今天的复习任务已全部完成').waitFor();checks.push('完成结果显示服务器金额、返回首页更新今日计数');
- await page.getByText('逾期补做 1 条 · 不计薪').click();await page.locator('.memory-date-tasks').waitFor();assert.ok(await page.locator('.tabbar').isVisible());await page.getByRole('button',{name:'带学生复习',exact:false}).first().click();await page.getByText('这是逾期任务,可以补做,但不计复习工资。').waitFor();await page.getByRole('button',{name:'结束本次复习',exact:true}).click();await page.locator('.review-complete').waitFor();assert.match(await page.locator('.review-complete').innerText(),/本次收入 ¥0\.00/);checks.push('逾期入口、补做前说明、完成零元显示');
+ await page.getByRole('button',{name:'查看计划',exact:true}).click();await page.locator('.record-list').waitFor();assert.equal(await page.getByRole('button',{name:'返回复习任务',exact:true}).count(),0);await page.screenshot({path:out+'/review-390.png',fullPage:true});await page.getByRole('button',{name:'返回',exact:true}).click();await page.locator('.coach-memory-list article').waitFor();assert.equal(calls.filter(p=>p.action==='app_review_page'&&p.scope==='today').length,1);await page.getByRole('button',{name:'查看计划',exact:true}).click();await page.locator('.record-list').waitFor();checks.push('复习页仅保留顶部返回,返回首页不重新加载任务');await page.getByRole('button',{name:'结束本次复习',exact:true}).click();await page.locator('.review-complete').waitFor();assert.match(await page.locator('.review-complete').innerText(),/本次收入 ¥1\.00/);await page.screenshot({path:out+'/review-complete-390.png',fullPage:true});
+ await page.getByRole('link',{name:'返回首页',exact:true}).click();await page.getByText('今天的复习任务已全部完成').waitFor();checks.push('完成结果显示服务器金额、返回首页更新缓存计数且不重新请求');
+ assert.equal(calls.filter(p=>p.action==='app_review_page'&&p.scope==='today').length,1);
+ assert.equal(await page.getByText('查看今日全部任务').count(),0);
+ await page.getByRole('link',{name:'查看全部学员复习计划'}).click();await page.locator('.memory-date-tasks').waitFor();assert.match(page.url(),/\/pages\/stu\/stu_calendar$/);
+ await page.goto(origin+'/pages/stu/stu_calendar?scope=overdue');await page.locator('.memory-date-tasks').waitFor();assert.ok(await page.locator('.tabbar').isVisible());await page.getByRole('button',{name:'带学生复习',exact:false}).first().click();await page.getByText('这是逾期任务,可以补做,但不计复习工资。').waitFor();await page.getByRole('button',{name:'结束本次复习',exact:true}).click();await page.locator('.review-complete').waitFor();assert.match(await page.locator('.review-complete').innerText(),/本次收入 ¥0\.00/);checks.push('逾期入口、补做前说明、完成零元显示');
  await page.goto(origin+'/pages/member/ke_xiao');await page.locator('.lesson-pay').first().waitFor();assert.match(await page.locator('body').innerText(),/带课工资 ¥45\.00/);assert.match(await page.locator('body').innerText(),/带课工资 ¥0\.00/);assert.match(await page.locator('body').innerText(),/工资待核对/);assert.ok(await page.locator('.tabbar').isVisible());
  for(const width of [390,360]){await page.setViewportSize({width,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));assert.ok(await page.locator('.schedule-copy').first().evaluate(e=>e.getBoundingClientRect().width>=80));await page.screenshot({path:out+'/history-'+width+'.png',fullPage:true});}
  checks.push('销课45元、合法零元、待核对分开显示;360/390宽度无横向溢出且固定导航可见');
- failReview=true;await page.goto(origin+'/pages/home/index');await page.getByText('抗遗忘计划暂时无法加载,点击重试').waitFor();assert.equal(await page.getByText('今天没有复习任务').count(),0);checks.push('接口失败不会显示没有任务');
+ failReview=true;await page.goto(origin+'/pages/home/index');await page.getByText('今天的复习任务已全部完成').waitFor();await page.getByRole('button',{name:'刷新抗遗忘任务',exact:true}).click();await page.getByText('抗遗忘计划暂时无法加载,点击重试').waitFor();assert.equal(await page.getByText('今天没有复习任务').count(),0);checks.push('接口失败不会显示没有任务');
  assert.deepEqual(errors,[]);writeFileSync(out+'/browser-result.json',JSON.stringify({passed:true,environment:'local build + isolated fixture API, not live backend or WeChat',checks,requests:calls.length,errors},null,2));console.log(JSON.stringify({passed:true,checks},null,2));
 }finally{await browser.close();await new Promise(r=>server.close(r));}