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

fix(mobile): connect teaching data and make lesson writes retry-safe

彭峰 1 месяц назад
Родитель
Сommit
0c4aca44f1
50 измененных файлов с 890 добавлено и 741 удалено
  1. 75 0
      docs/features/student-teacher-teaching-release-2026-09-10.md
  2. 2 1
      projects/xiaoshu-mobile/src/app/app.routes.ts
  3. 2 1
      projects/xiaoshu-mobile/src/app/core/all-routes.integration.spec.ts
  4. 15 6
      projects/xiaoshu-mobile/src/app/core/api.service.spec.ts
  5. 23 9
      projects/xiaoshu-mobile/src/app/core/api.service.ts
  6. 5 49
      projects/xiaoshu-mobile/src/app/core/cloud-action-migration.ts
  7. 9 0
      projects/xiaoshu-mobile/src/app/core/feature-policy.ts
  8. 41 18
      projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts
  9. 6 0
      projects/xiaoshu-mobile/src/app/core/learning-draft.spec.ts
  10. 9 0
      projects/xiaoshu-mobile/src/app/core/learning-draft.ts
  11. 3 3
      projects/xiaoshu-mobile/src/app/core/page-catalog.ts
  12. 8 0
      projects/xiaoshu-mobile/src/app/core/review-page.ts
  13. 10 0
      projects/xiaoshu-mobile/src/app/core/session.service.ts
  14. 4 4
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html
  15. 27 128
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts
  16. 11 0
      projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.ts
  17. 1 1
      projects/xiaoshu-mobile/src/app/features/auth/login-page.component.ts
  18. 4 4
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html
  19. 33 28
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts
  20. 11 0
      projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.spec.ts
  21. 6 4
      projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.ts
  22. 4 7
      projects/xiaoshu-mobile/src/app/features/english-reading/fmode-ai.service.ts
  23. 1 1
      projects/xiaoshu-mobile/src/app/features/english-reading/reading-cloud.service.spec.ts
  24. 8 8
      projects/xiaoshu-mobile/src/app/features/english-reading/reading-cloud.service.ts
  25. 11 5
      projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.ts
  26. 10 3
      projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.ts
  27. 11 3
      projects/xiaoshu-mobile/src/app/features/english-reading/reading-setup.component.ts
  28. 2 0
      projects/xiaoshu-mobile/src/app/features/english-reading/reading.models.ts
  29. 1 1
      projects/xiaoshu-mobile/src/app/features/english-reading/reading.utils.spec.ts
  30. 3 1
      projects/xiaoshu-mobile/src/app/features/english-reading/reading.utils.ts
  31. 6 50
      projects/xiaoshu-mobile/src/app/features/finance/finance-page.component.html
  32. 1 36
      projects/xiaoshu-mobile/src/app/features/finance/finance-page.component.scss
  33. 23 234
      projects/xiaoshu-mobile/src/app/features/finance/finance-page.component.ts
  34. 2 2
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.html
  35. 6 12
      projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts
  36. 3 2
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html
  37. 53 77
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts
  38. 5 0
      projects/xiaoshu-mobile/src/app/features/resource/unavailable-page.component.ts
  39. 5 5
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html
  40. 33 26
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts
  41. 2 0
      projects/xiaoshu-mobile/src/styles.scss
  42. 4 1
      scripts/audit-app-cloud-actions.mjs
  43. 20 0
      scripts/audit-mobile-teaching-readonly.mjs
  44. 55 0
      scripts/cloud/mobile-teaching.js
  45. 17 11
      scripts/deploy-admin-functions.mjs
  46. 44 0
      scripts/migrate-mobile-teaching.mjs
  47. 101 0
      scripts/smoke-mobile-teaching-flow.mjs
  48. 39 0
      scripts/smoke-mobile-teaching-writes.mjs
  49. 88 0
      scripts/sql/mobile-teaching.sql
  50. 27 0
      scripts/tests/mobile-teaching.test.mjs

+ 75 - 0
docs/features/student-teacher-teaching-release-2026-09-10.md

@@ -0,0 +1,75 @@
+# 学生端、老师端教学接通与优化交付
+
+日期:2026-09-10(北京时间)。本次源代码范围为 xiaoshu-mobile、移动端云网关、教学事务迁移与验收脚本。后台前端仅执行回归检查。
+
+## 已实现
+
+| 模块 | 最终行为 |
+| --- | --- |
+| 会话与提交 | 第一方教学、反馈、测评、单元和收入接口统一携带 Parse 会话;401 清理会话并回登录页。读请求允许有限重试,普通写请求不自动重试。重要教学写入携带稳定请求键,失败重试复用键。 |
+| 预约状态 | 新库 CommonModel 取消标记传递到列表、日历和详情;默认排除取消预约,历史明确标注,前后端均阻止开课。未重新改写历史预约。 |
+| 完课闭环 | 老师结束并提交反馈后,学生可查看、评价,老师再确认。评价先保存再更新状态;状态确认由数据库锁定预约、校验操作者和前置状态,重复请求不倒退状态。 |
+| 课时账户 | 显示 30 分钟课时、60 分钟课时、体验课时、陪练时长的余额、预留、可用数量及对应分页流水。工资独立显示;混用课时的充值、提现、转赠、商城入口停用。 |
+| 列表与统计 | 抗遗忘、收入、预约、销课、学习历史和学员列表接服务端分页;日历和总量由后台聚合。主要学员、日期切换请求取消或序号保护,写入成功清理概览缓存。加载更多失败不跳过该页。 |
+| 学习上下文 | 保留学生、预约、bindingId 和返回入口;单元记录归属学生。学习草稿按账号、学生、预约/开课隔离,账号变更清理草稿;学习写入键随当前草稿保留。 |
+| 可靠保存 | 单词进度、学习记录双表、测评、单元、阅读创建使用数据库教学命令事务;失败回滚,重复请求返回已有结果。关键保存失败显示错误,提交中防连点。 |
+| 阅读 | 复用文章要求完整规范化词表及级别、类型、主题匹配;重新生成跳过复用。老师带读写入所选学生,服务端固定学生和操作者,禁止跨学生改归属。历史文章按 ID 打开,PDF 按需加载。 |
+| 新库报表 | 学习报表读取预约、学习记录、完课反馈和抗遗忘汇总,替换旧报表链接。 |
+| 辅助入口 | 短信、支付、提现、实名认证、排行榜、旧游戏等未接通能力明确停用或提示;动态页面配置中的接口纳入能力检查,避免把错误伪装成空列表。 |
+| UI 与拆分 | 课时账户、报表、停用说明复用卡片、页面留白和错误状态;提取学习草稿模块。大型学习组件尚有后续拆分空间,未宣称已消除全部样式预算警告。 |
+
+## 数据库与部署
+
+- 新增 `MobileTeachingCommand` 和 `xs_mobile_teaching_write`,主键为帐套、学习对象作用域、带操作者前缀的请求键,序列化同一学习对象的教学写入。
+- 新增 `xs_mobile_confirm_appointment`,承接 20/30 两个完课确认状态;验证学生/老师身份、前置状态、取消标记,不修改课时或工资快照。
+- 阅读记录增加 `operatorId`、`learnerLegacyId`、`sourceStudyId` 字段。
+- 已在目标服务器执行迁移,并更新 `xiaoshu/app/gateway`,函数 ID `D3F5Zj8saa`;能力版本 `2026-09-mobile-teaching-v1`。原接口路径保留。
+- 课时扣减沿用现有课时业务事务,完课反馈在后续幂等事务中保存;两者并非一个跨表总事务。反馈失败可重试,已成功扣减不会重复扣费。
+- 没有批量开放待确认课程、补发历史抗遗忘工资、调整已锁定工资或重做历史预约清理。
+
+## 本次验证
+
+| 检查 | 结果与边界 |
+| --- | --- |
+| 移动端自动测试 | 103 项通过,含路由集成、会话、草稿隔离、阅读完整词表匹配和重新生成。 |
+| 后台自动测试 | 192 项通过。 |
+| 教学事务、开课课时、抗遗忘测试 | 38 项通过,含并发幂等、写入回滚、确认状态权限和防倒退。 |
+| 云函数与能力检查 | 11 个定义通过;扫描 114 个请求动作,未发现漏适配动作(实现集合与明确停用集合不互斥)。 |
+| 构建 | 移动端、后台构建成功;仍有现存样式体积与 PDF 依赖 CommonJS 优化警告。 |
+| 线上隔离教学写入 | 学习并发保存、学习记录双表、单元、测评、阅读、越权拒绝、四类账户读取通过。 |
+| 线上隔离完课流程 | 老师带读→学生查看文章→老师完课→学生查看反馈与新报表→学生评价→老师最终确认通过;取消预约拒绝开课,重复完课只扣一次课时。 |
+| 线上课时事务 | 重试、充值/完课并发、争抢最后一节课、退款/预留并发、四类余额与流水、事务审计通过。 |
+| 线上抗遗忘计薪 | 首次完成仅 1 元,重复完成、改写到另一月份不增薪且不改首次月份。使用数据库回滚验收,没有留下测试工资。 |
+| 测试清理 | 隔离用户、业务记录、临时函数清理;此前测试命令凭据按本次隔离账号精确清理,未清理真实用户记录。 |
+
+真实账号仅做只读对账:抽查老师抗遗忘任务 573 条(29 页),学生 220 条(11 页),完整遍历后数量及唯一数量均与服务端总量一致。默认预约列表不再包含已取消预约。抽查学生四类余额为 0、9、0、9,预留为 0,与后台一致。待确认开课 1,298 条保持原状。
+
+核对材料保存在忽略目录 `tmp/mobile-teaching-verification/`:`read-audit.json` 为只读证据,`pending-enrollments.json` 为待确认开课清单(记录、学生、课程 ID、分类与状态)。这些业务数据不提交 Git;证据不足的课程继续待确认,不自动开放。
+
+## 待发布与待验收
+
+1. **前端线上发布**:本机无公网前端站点部署凭据,本次只生成构建产物并按此前要求尝试推送。后端部署成功不表示公网学生端/老师端已更新。
+2. **Git 推送**:远端 `git.fmode.cn` 返回 HTTP 502。本地提交保留,远端恢复后运行 `git push origin main`;不能把本次本地提交称为已推送。
+3. **真实设备体验**:真实手机音频播放、浏览器返回体验和弱网交互尚待人工设备验收;自动化与接口验收不能替代这些体验。
+4. **实际 AI 生成**:未消耗真实模型执行生成/重新生成;本次验证了匹配逻辑、学生归属与阅读存取,模型输出质量、费用和耗时待验收。
+5. **辅助功能**:短信登录、外部支付/提现/实名认证、排行榜和旧游戏依然未完整接通,已明确停用。它们不是本次宣称上线的功能。
+6. **体积优化**:后续按单词学习、练习、复习日历继续拆分学习组件并抽取共享样式;当前功能修复没有消除所有既有构建警告。
+
+## 可复跑命令
+
+需要线上权限的脚本从环境读取 `XIAOSHU_MASTER_KEY`,不得写入仓库。迁移只改结构和函数,不执行历史补发。隔离写入脚本会临时创建测试账号并在 finally 清理,不应把它当纯只读脚本运行。
+
+```sh
+npm run test:mobile:ci
+npm run test:admin:ci
+node --test scripts/tests/mobile-teaching.test.mjs scripts/tests/member-enrollment.test.mjs scripts/tests/member-enrollment-gateway.test.mjs scripts/tests/review-rewards.test.mjs
+npm run cloud:validate
+npm run app:actions:audit
+npm run build:mobile
+npm run build:admin
+node scripts/migrate-mobile-teaching.mjs --apply
+node scripts/deploy-admin-functions.mjs --only=xiaoshu/app/gateway
+node scripts/audit-mobile-teaching-readonly.mjs
+node scripts/smoke-mobile-teaching-writes.mjs
+node scripts/smoke-mobile-teaching-flow.mjs
+```

+ 2 - 1
projects/xiaoshu-mobile/src/app/app.routes.ts

@@ -1,4 +1,5 @@
 import { Route, Routes } from '@angular/router';
+import { unavailableReason } from './core/feature-policy';
 import { authGuard } from './core/auth.guard';
 import { PageKind } from './core/models';
 import { PAGE_CATALOG } from './core/page-catalog';
@@ -33,7 +34,7 @@ const components: Record<PageKind, ComponentLoader> = {
 
 const migratedRoutes: Routes = PAGE_CATALOG.map((meta): Route => ({
   path: meta.path,
-  loadComponent: components[meta.kind],
+  loadComponent: unavailableReason(meta) ? () => import('./features/resource/unavailable-page.component').then(m=>m.UnavailablePageComponent) : meta.path === 'pages/shop/baobiao' ? () => import('./features/appointment/lesson-report.component').then(m=>m.LessonReportComponent) : components[meta.kind],
   canActivate: meta.auth ? [authGuard] : undefined,
   data: { meta },
 }));

+ 2 - 1
projects/xiaoshu-mobile/src/app/core/all-routes.integration.spec.ts

@@ -12,8 +12,9 @@ import { PAGE_CATALOG } from './page-catalog';
 import { SessionService } from './session.service';
 
 class FakeApiService {
-  get<T>(): Observable<ApiEnvelope<T>> { return of({ retcode: 0, result: [] as T }); }
+  get<T>(action=''): Observable<ApiEnvelope<T>> { if(action==='app_credit_overview')return of({retcode:0,result:{accounts:[]} as T}); return of({ retcode: 0, result: [] as T }); }
   post<T>(action = ''): Observable<ApiEnvelope<T>> {
+    if(action==='app_review_page')return of({retcode:0,result:{items:[],page:{itemCount:0,pageCount:1},summary:{total:0,completed:0,due:0,today:0,income:0},dates:[]} as T});
     if (action === 'app_learning_overview') return of({ retcode: 0, result: { records: [], courses: [], schedules: [], recordDateCounts: {}, scheduleDateCounts: {} } as T });
     if (action === 'app_companion_overview') return of({ retcode: 0, result: { courses: [], schedules: [] } as T });
     if (action === 'app_account_overview') return of({ retcode: 0, result: { profile: {}, companionSummary: { completedSessions: 0, completedMinutes: 0 }, antiForgetSummary: { total: 0, completed: 0, progress: 0, todayTotal: 0, todayCompleted: 0, duePending: 0, scheduled: 0, scheduledDays: 0 } } as T });

+ 15 - 6
projects/xiaoshu-mobile/src/app/core/api.service.spec.ts

@@ -84,7 +84,7 @@ describe('ApiService', () => {
       .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
 
     expect(actual instanceof ApiError).toBeTrue();
-    expect((actual as ApiError).message).toBe('服务暂时繁忙,已自动重试但仍未成功,请稍后再试。');
+    expect((actual as ApiError).message).toBe('请求暂未完成,请检查网络后重试;已填写内容会保留。');
   }));
 
   it('nests reserved id parameters inside params', () => {
@@ -203,7 +203,7 @@ describe('ApiService', () => {
     sessionToken = 'r:session-token';
     api.post('e_add_words', { uid: 58, wordsId: '2505,2503', kcid: 16, ifnew: 1 }).subscribe();
     const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
-    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'e_add_words', uid: 58, wordsId: '2505,2503', kcid: 16, ifnew: 1 } });
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { requestKey: jasmine.any(String), action: 'e_add_words', uid: 58, wordsId: '2505,2503', kcid: 16, ifnew: 1 } });
     request.flush({ retcode: 0, result: { affected: 2, newWordState: 1 } });
   });
 
@@ -211,7 +211,7 @@ describe('ApiService', () => {
     sessionToken = 'r:session-token';
     api.post('stu_record_update_v2', { orderId: 918, uid: 23, addon: '{"xxqs":[]}' }).subscribe();
     const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
-    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'stu_record_update_v2', orderId: 918, uid: 23, addon: '{"xxqs":[]}' } });
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { requestKey: jasmine.any(String), action: 'stu_record_update_v2', orderId: 918, uid: 23, addon: '{"xxqs":[]}' } });
     request.flush({ retcode: 0, result: { GeneralID: 123456, created: true } });
   });
 
@@ -219,7 +219,7 @@ describe('ApiService', () => {
     sessionToken = 'r:session-token';
     api.post('content_add_zt', { content: '{"ModelID":56}', addon: '{"UserID":23,"xxqs":"[]"}' }).subscribe();
     const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
-    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'content_add_zt', content: '{"ModelID":56}', addon: '{"UserID":23,"xxqs":"[]"}' } });
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { requestKey: jasmine.any(String), action: 'content_add_zt', content: '{"ModelID":56}', addon: '{"UserID":23,"xxqs":"[]"}' } });
     request.flush({ retcode: 0, result: '123456' });
   });
 
@@ -227,7 +227,7 @@ describe('ApiService', () => {
     sessionToken = 'r:session-token';
     api.post('content_update', { content: '{"GeneralID":123456}', addon: '{"learned":2}' }).subscribe();
     const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
-    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'content_update', content: '{"GeneralID":123456}', addon: '{"learned":2}' } });
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { requestKey: jasmine.any(String), action: 'content_update', content: '{"GeneralID":123456}', addon: '{"learned":2}' } });
     request.flush({ retcode: 0, result: '123456' });
   });
 
@@ -235,7 +235,7 @@ describe('ApiService', () => {
     sessionToken = 'r:session-token';
     api.post('content_add', { content: '{"ModelID":61,"nodeId":389}', addon: '{"UserID":23,"df":8}' }).subscribe();
     const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
-    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'content_add', content: '{"ModelID":61,"nodeId":389}', addon: '{"UserID":23,"df":8}' } });
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { requestKey: jasmine.any(String), action: 'content_add', content: '{"ModelID":61,"nodeId":389}', addon: '{"UserID":23,"df":8}' } });
     request.flush({ retcode: 0, result: '123456' });
   });
 
@@ -340,3 +340,12 @@ describe('ApiService', () => {
     register.flush({ retcode: 0, result: { userId: 42, sessionToken: 'r:token' } });
   });
 });
+
+describe('teaching request reliability',()=>{
+ let api:ApiService,http:HttpTestingController;
+ beforeEach(()=>{TestBed.configureTestingModule({providers:[provideHttpClient(),provideHttpClientTesting(),{provide:SessionService,useValue:{token:()=> 'r:teacher',user:()=>({userId:88}),logout:()=>{}}}]});api=TestBed.inject(ApiService);http=TestBed.inject(HttpTestingController);});
+ afterEach(()=>http.verify());
+ for(const action of ['e_order_complete_feedback','vote_add','unit_record_update','e_get_21list_tj'])it(action+' carries the current session',()=>{api.post(action,{uid:1761}).subscribe();const req=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(req.request.body.token).toBe('r:teacher');req.flush({retcode:0,result:{}});});
+ it('does not automatically retry a write and retains its request key for manual retry',fakeAsync(()=>{const body={uid:1761,wordsId:'7',save:1};api.post('e_add_words',body).subscribe({error:()=>{}});const first=http.expectOne(API_CONFIG.cloudFunctionUrl),key=first.request.body.params.requestKey;expect(key).toBeTruthy();first.flush('',{status:503,statusText:'Unavailable'});tick(2000);http.expectNone(API_CONFIG.cloudFunctionUrl);api.post('e_add_words',body).subscribe();const retry=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(retry.request.body.params.requestKey).toBe(key);retry.flush({retcode:0,result:{}});}));
+ it('invalidates account and learning summaries after a successful teaching write',()=>{api.post('app_learning_overview',{uid:1761}).subscribe();http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{records:[]}});api.post('e_order_complete_feedback',{orderId:7}).subscribe();http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{}});api.post('app_learning_overview',{uid:1761}).subscribe();http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{records:[{id:1}]}});});
+});

+ 23 - 9
projects/xiaoshu-mobile/src/app/core/api.service.ts

@@ -1,7 +1,7 @@
 import { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { inject, Injectable } from '@angular/core';
 import { Router } from '@angular/router';
-import { catchError, from, map, Observable, retry, shareReplay, switchMap, throwError, timer } from 'rxjs';
+import { catchError, from, map, Observable, retry, shareReplay, switchMap, tap, throwError, timer } from 'rxjs';
 import { API_CONFIG } from './app.constants';
 import { requiresCloudSession } from './cloud-action-migration';
 import { ApiEnvelope } from './models';
@@ -21,18 +21,25 @@ export class ApiService {
   private readonly overviewCache = new Map<string, { expiresAt: number; response: Observable<ApiEnvelope<unknown>> }>();
   private readonly cachedOverviewActions = new Set(['app_learning_overview', 'app_companion_overview', 'app_account_overview']);
   private sessionRedirecting = false;
+  private readonly pendingWrites=new Map<string,string>();
 
   get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
     const token = this.parseSessionToken();
-    return this.callWithOverviewCache<T>(action, params, requiresCloudSession(action, params) ? token : '');
+    return this.callWithOverviewCache<T>(action, params, token);
   }
 
   post<T>(action: string, body: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
     const token = this.parseSessionToken();
-    return this.callWithOverviewCache<T>(action, body, requiresCloudSession(action, body) ? token : '');
+    return this.callWithOverviewCache<T>(action, body, token);
   }
 
-  callCloud<T>(action: string, params: Record<string, unknown> = {}, sessionToken = ''): Observable<ApiEnvelope<T>> {
+  callCloud<T>(action: string, params: Record<string, unknown> = {}, sessionToken = this.parseSessionToken()): Observable<ApiEnvelope<T>> {
+    const teachingWrite=['e_add_words','content_add_zt','content_update','content_add','stu_record_update_v2','unit_record_update','vote_add','reading/create'].includes(action);
+    const requestIdentity=this.parseSessionToken()+'|'+action+'|'+this.stableJson(params);
+    if(teachingWrite && !params['requestKey']) {
+      const key=this.pendingWrites.get(requestIdentity)||crypto.randomUUID();
+      this.pendingWrites.set(requestIdentity,key);params={...params,requestKey:key};
+    }
     const body = {
       ...(sessionToken ? { token: sessionToken } : {}),
       params: { action, ...params },
@@ -42,7 +49,7 @@ export class ApiService {
         'Content-Type': 'application/json',
         'X-Parse-Application-Id': API_CONFIG.parseAppId,
       },
-    }), sessionToken.startsWith('r:') || requiresCloudSession(action, params));
+    }), sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action)).pipe(tap(() => { if (!this.isReadAction(action)) this.invalidateOverviews(); this.pendingWrites.delete(requestIdentity); }));
   }
 
   upload(file: File): Observable<ApiEnvelope<string>> {
@@ -60,6 +67,13 @@ export class ApiService {
     return `${API_CONFIG.baseUrl}${value.startsWith('/') ? value : `/${value}`}`;
   }
 
+  invalidateOverviews(): void { this.overviewCache.clear(); }
+
+  private isReadAction(action: string): boolean {
+    return /(?:_list|_get|_detail|_overview|_tongji|_tj|_is)$/.test(action)
+      || ['reading/me','reading/query','reading/get','migration_status', 'app_update', 'node_list', 'node_get', 'content_get', 'vote_ask', 'vote_question', 'e_get_21list', 'app_review_page', 'app_lesson_report'].includes(action);
+  }
+
   private normalize<T>(input: unknown): ApiEnvelope<T> {
     const envelope = (input ?? {}) as ApiEnvelope<unknown>;
     let result = envelope.result;
@@ -86,10 +100,10 @@ export class ApiService {
     });
   }
 
-  private resilient<T>(request: Observable<unknown>, authenticatedRequest = false): Observable<ApiEnvelope<T>> {
+  private resilient<T>(request: Observable<unknown>, authenticatedRequest = false, retryRead = true): Observable<ApiEnvelope<T>> {
     return request.pipe(
       retry({
-        count: 2,
+        count: retryRead ? 2 : 0,
         delay: (error, retryCount) => this.isTransientError(error)
           ? timer(retryCount * 400)
           : throwError(() => error),
@@ -105,7 +119,7 @@ export class ApiService {
     for (const [key, entry] of this.overviewCache) if (entry.expiresAt <= now) this.overviewCache.delete(key);
     const cacheParams = { ...params };
     delete cacheParams['refresh'];
-    const key = `${this.sessionCacheIdentity()}|${action}|${this.stableJson(cacheParams)}`;
+    const key = `${this.sessionCacheIdentity()}|${this.parseSessionToken()}|${action}|${this.stableJson(cacheParams)}`;
     const cached = this.overviewCache.get(key);
     if (params['refresh'] !== true && cached?.expiresAt && cached.expiresAt > now) return cached.response as Observable<ApiEnvelope<T>>;
     const response = this.callCloud<T>(action, params, sessionToken).pipe(
@@ -140,7 +154,7 @@ export class ApiService {
       if (error.status === 401 && authenticatedRequest) this.expireSession();
       const payload = error.error as { retmsg?: string } | string | null;
       const message = this.isTransientError(error)
-        ? '服务暂时繁忙,已自动重试但仍未成功,请稍后再试。'
+        ? '请求暂未完成,请检查网络后重试;已填写内容会保留。'
         : typeof payload === 'object' && payload?.retmsg
           ? payload.retmsg
           : error.message || '网络请求失败';

+ 5 - 49
projects/xiaoshu-mobile/src/app/core/cloud-action-migration.ts

@@ -1,7 +1,5 @@
 /**
- * Actions that are safe to route from the current Angular H5 application to
- * the new Parse cloud-function gateway. Keep this list narrower than the
- * server-side mapping until each page's response shape has passed regression.
+ * Public legacy entry points. Private actions require the current Parse session.
  */
 export const H5_CLOUD_ROUTED_ACTIONS = new Set([
   'app_update',
@@ -14,41 +12,6 @@ export const H5_CLOUD_ROUTED_ACTIONS = new Set([
   'user_register',
 ]);
 
-const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
-  'content_add',
-  'content_add_zt',
-  'content_update',
-  'e_add_words',
-  'e_ck_list',
-  'e_get_21list',
-  'app_account_overview',
-  'app_companion_overview',
-  'app_learning_overview',
-  'e_order_detail',
-  'e_order_tongji',
-  'e_order_update_v2',
-  'e_record_detail',
-  'e_user_list',
-  'e_words_list',
-  'guestbook_add',
-  'stu_record_update_v2',
-  'user_dept',
-  'user_get',
-  'user_list',
-  'user_point_list',
-  'user_update',
-]);
-
-const CONTENT_MODEL_FIELDS: Record<number, Set<string>> = {
-  52: new Set(['id', 'lj', 'sy', 'yb', 'yp']),
-  53: new Set(['id', 'kcid', 'scid', 'xxcs', 'yhid', 'jrscb']),
-  54: new Set(['id', 'pl', 'bxrq', 'dslx', 'dszt', 'fxpl', 'jffs', 'jssj', 'kcid', 'kssj', 'plxm', 'scsj', 'sdsd', 'szmd', 'szyh', 'yysj', 'yykcid']),
-  56: new Set(['id', 'pl', 'con', 'djq', 'ygg', 'dqrq', 'dsid', 'fxrl', 'xxqs', 'szmdid', 'userid', 'learned']),
-  58: new Set(['id', 'yxx', 'cksl', 'kcid', 'syjd', 'yhid']),
-  59: new Set(['id', 'pf', 'jffs', 'jsmz', 'kcid', 'kclx', 'kcmc', 'kzsj', 'pjnr', 'pldp', 'xymz', 'yyds', 'plpjsj', 'szmdid']),
-  60: new Set(['id', 'fxzt', 'kcid', 'plid', 'wcsj', 'yhid', 'kywrq', 'kywsj', 'xxjlid', 'orderid']),
-  61: new Set(['id', 'df', 'askid', 'wrong', 'userid', 'answerid', 'dontknow', 'prevscore', 'totalscore']),
-};
 const NODE_MODELS: Record<number, number> = { 28: 58, 29: 54, 32: 53, 291: 56, 296: 59, 327: 52, 388: 60, 389: 61 };
 
 export function usesCloudGateway(action: string, params: Record<string, unknown> = {}, hasParseSession = false, hasAnySession = hasParseSession): boolean {
@@ -57,10 +20,10 @@ export function usesCloudGateway(action: string, params: Record<string, unknown>
 }
 
 export function requiresCloudSession(action: string, params: Record<string, unknown> = {}): boolean {
-  return SESSION_CLOUD_ROUTED_ACTIONS.has(action)
-    || ['content_get', 'content_uphis'].includes(action)
-    || (action === 'node_list' && Number(params['ifunit']) === 1)
-    || (action === 'content_list' && contentModel(params) !== 52 && Boolean(contentModel(params)));
+  return !new Set(['migration_status', 'app_update', 'user_login_passwd', 'user_register', 'user_info_name',
+    'mcode_send', 'user_login_mobile', 'user_register_mobile', 'vote_ask', 'vote_question', 'product_list', 'product_get', 'node_get']).has(action)
+    && !(action === 'node_list' && Number(params['ifunit']) !== 1)
+    && !(action === 'content_list' && contentModel(params) === 52 && !params['myfield'] && !params['myfield2']);
 }
 
 function contentModel(params: Record<string, unknown>): number {
@@ -69,10 +32,3 @@ function contentModel(params: Record<string, unknown>): number {
   const nodes = String(params['nodeid'] ?? params['nid'] ?? params['nodes'] ?? '').split(',').map(Number).filter(Boolean);
   return nodes.length === 1 ? NODE_MODELS[nodes[0]] ?? 0 : 0;
 }
-
-function filterNames(params: Record<string, unknown>): string[] {
-  return [params['myfield'], params['myfield2']]
-    .filter(Boolean)
-    .flatMap((value) => String(value).split('|'))
-    .map((part) => part.split('=', 1)[0].replace(/^B\./i, '').toLowerCase());
-}

+ 9 - 0
projects/xiaoshu-mobile/src/app/core/feature-policy.ts

@@ -0,0 +1,9 @@
+import { PageMeta } from './models';
+export function unavailableReason(meta: PageMeta): string {
+  if(['product','productList','checkout','order','merchant','team','support','supportFeedback'].includes(meta.kind)) return '此辅助功能暂未开放。请通过意见反馈联系管理员,现有教学功能可以正常使用。';
+  if(['pages/member/ranklist','pages/game/llk','pages/member/myorder_appeal'].includes(meta.path)) return '此功能正在适配新系统,暂未开放。';
+  if(meta.path==='pages/stu/stu7') return '请从首页或抗遗忘日历进入已安排的复习任务。';
+  if(meta.kind==='form' && meta.saveAction!=='user_update' && meta.saveAction!=='guestbook_add') return '此功能暂未开放,请联系管理员处理。';
+  if(meta.kind==='resource' && /invoice|receaddr|purse_out|rnauth|coupon|myshop|order|star$/.test(meta.path)) return '此辅助功能暂未开放。';
+  return '';
+}

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

@@ -166,7 +166,8 @@ class LearningModulesApiService {
           ];
       const selected = String(params['date'] ?? '');
       return this.result<T>({
-        records: selected ? records.filter((item) => item.CreateTime.startsWith(selected)) : records,
+        records: (selected ? records.filter((item) => item.CreateTime.startsWith(selected)) : records).slice((Number(params['page']||1)-1)*20,Number(params['page']||1)*20),
+        page:{itemCount:selected?records.filter(item=>item.CreateTime.startsWith(selected)).length:records.length,pageCount:Math.ceil(records.length/20)},
         latestReadingRecord: records[0],
         recordDateCounts: { '2026-08-01': 1, '2026-08-04': 1 },
         scheduleDateCounts: {},
@@ -199,6 +200,7 @@ class LearningModulesApiService {
           { GeneralID: 801, id: 701, kcid: 801, Title: '初中英语|七年级上册@同步词汇' },
           { GeneralID: 802, id: 702, Title2: '【初中英语】阅读训练', syjd: '{"811":"811@Unit 1@1"}' },
         ],
+        dates:(params['history']?historySchedules:schedules).map(item=>({date:item.bxrq.slice(0,10),finished:item.dszt>=30?1:0,planned:item.dszt>=30?0:1})),
         schedules: params['history']
           ? historySchedules
           : rangeStart && rangeEnd
@@ -222,6 +224,17 @@ class LearningModulesApiService {
         refreshedAt: '2026-08-26T00:00:00.000Z',
       });
     }
+    if (action === 'app_review_page') {
+      this.lastMemoryParams=params;
+      const items=[
+        { 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 },
+        ...(Number(params['shouru']) === 1
+          ? [{ GeneralID: 103, Title: '李同学@任务', kc_title: '小学英语复习', kywsj: '2026-08-02 10:00', learned_at: '2026-07-21T02:20:00.000Z', fxzt: 0, yhid: 101, xxjlid: 901 }]
+          : []),
+      ];return this.result<T>({items,page:{itemCount:items.length,pageCount:1},summary:{total:items.length,completed:items.filter(i=>i.fxzt===1).length,due:items.filter(i=>i.fxzt!==1).length,today:0,income:0},dates:items.map(i=>({date:i.kywsj.slice(0,10),total:1,completed:i.fxzt===1?1:0}))});
+    }
     if (action === 'e_get_21list') {
       this.lastMemoryParams = params;
       return this.result<T>([
@@ -350,6 +363,11 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.selection-header')?.textContent).toContain('词库学习进度');
 
     await harness.navigateByUrl('/pages/stu/stu_calendar');
+    const calendarComponent=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    calendarComponent.memoryMonth.set(new Date(2026,7,1));
+    calendarComponent.memorySelectedDate.set('2026-08-02');
+    (calendarComponent as any).loadCalendar();
+    harness.fixture.detectChanges();
     expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull();
     expect(harness.routeNativeElement?.querySelector('.memory-student-filter')).toBeNull();
     expect(harness.routeNativeElement?.querySelectorAll('.memory-date').length).toBe(42);
@@ -395,9 +413,9 @@ describe('home learning modules', () => {
     (harness.routeNativeElement?.querySelector('.word-select-list > button') as HTMLButtonElement).click();
     (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
     await harness.fixture.whenStable();
-    const selectedNewWords = JSON.parse(sessionStorage.getItem('xiaoshu.selectedWords') ?? '[]') as Array<{ word?: string }>;
+    const selectedNewWords = JSON.parse(sessionStorage.getItem('xiaoshu.learning.100.100.practice.selectedWords') ?? '[]') as Array<{ word?: string }>;
     expect(selectedNewWords[0]?.word).toBe('tend');
-    expect(sessionStorage.getItem('xiaoshu.selectedWords')).not.toContain('@张雅茜');
+    expect(sessionStorage.getItem('xiaoshu.learning.100.100.practice.selectedWords')).not.toContain('@张雅茜');
 
     await harness.navigateByUrl('/pages/his/well?nid=3');
     expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull();
@@ -444,7 +462,7 @@ describe('home learning modules', () => {
     harness.fixture.detectChanges();
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     expect(api.lastScheduleParams['mode']).toBe('student');
-    expect(api.lastScheduleParams['limit']).toBe(500);
+    expect(api.lastScheduleParams['pageSize']).toBe(20);
     const finishedFilter = Array.from(harness.routeNativeElement?.querySelectorAll('.schedule-filters button') ?? [])
       .find((button) => button.textContent?.includes('已结束')) as HTMLButtonElement;
     finishedFilter.click();
@@ -564,7 +582,7 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.home-header .header-action')?.getAttribute('href')).toBe('/pages/home/ucenter');
     expect(harness.routeNativeElement?.querySelector('.content-section')).toBeNull();
     const memoryApi = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
-    expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, shouru: 1 }));
+    expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, pageSize:20 }));
     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('带学生复习');
@@ -575,14 +593,19 @@ describe('home learning modules', () => {
     expect(session.student()?.userId).toBe(100);
 
     await harness.navigateByUrl('/pages/stu/stu_calendar');
+    const calendarComponent=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    calendarComponent.memoryMonth.set(new Date(2026,7,1));
+    calendarComponent.memorySelectedDate.set('2026-08-02');
+    (calendarComponent as any).loadCalendar();
+    harness.fixture.detectChanges();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
-    expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, shouru: 1, psize: 500 }));
+    expect(memoryApi.lastMemoryParams).toEqual(jasmine.objectContaining({ uid: 300, pageSize:20 }));
     expect(harness.routeNativeElement?.querySelector('.memory-copy')?.textContent).toContain('关联学员抗遗忘任务');
     expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('张雅茜');
     expect(harness.routeNativeElement?.querySelector('.memory-date-tasks button')?.textContent).toContain('带学生复习');
     const memoryStudentFilter = harness.routeNativeElement?.querySelector('[aria-label="筛选抗遗忘学员"] select') as HTMLSelectElement;
-    expect(memoryStudentFilter.options.length).toBe(3);
+    expect(memoryStudentFilter.options.length).toBe(2);
     memoryStudentFilter.value = '100';
     memoryStudentFilter.dispatchEvent(new Event('change'));
     harness.fixture.detectChanges();
@@ -594,9 +617,9 @@ describe('home learning modules', () => {
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     const monthFilter = harness.routeNativeElement?.querySelector('[aria-label="按月份筛选销课记录"]') as HTMLSelectElement;
-    expect(monthFilter.value).toBe('2026-07');
+    expect(monthFilter.value).toBe('all');
     expect(monthFilter.options.length).toBe(4);
-    expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(2);
+    expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(5);
     monthFilter.value = '2026-03';
     monthFilter.dispatchEvent(new Event('change'));
     const studentSearch = harness.routeNativeElement?.querySelector('[aria-label="按学生姓名筛选销课记录"]') as HTMLInputElement;
@@ -689,7 +712,7 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelectorAll('.repeat-list > button').length).toBe(1);
     expect(harness.routeNativeElement?.querySelector('.readonly-word-header')).toBeNull();
 
-    await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801');
+    await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&bindingId=701');
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     const spellingActions = harness.routeNativeElement?.querySelector('.teacher-spelling-actions') as HTMLElement;
@@ -881,11 +904,11 @@ describe('home learning modules', () => {
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     api.appointmentStatus = 10;
     const word = { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果' };
-    localStorage.setItem('xiaoshu.learning._page_word_orilist', JSON.stringify([[word]]));
-    localStorage.setItem('xiaoshu.learning._page_current_learn', '1');
+    localStorage.setItem('xiaoshu.learning.300.100.901._page_word_orilist', JSON.stringify([[word]]));
+    localStorage.setItem('xiaoshu.learning.300.100.901._page_current_learn', '1');
     // Stale page-count metadata must not create a nonexistent next group.
-    localStorage.setItem('xiaoshu.learning._page_total_page', '2');
-    localStorage.removeItem('xiaoshu.learning.learn_mode');
+    localStorage.setItem('xiaoshu.learning.300.100.901._page_total_page', '2');
+    localStorage.removeItem('xiaoshu.learning.300.100.901.learn_mode');
     const harness = await RouterTestingHarness.create();
     const router = TestBed.inject(Router);
     const navigate = spyOn(router, 'navigate').and.callThrough();
@@ -941,9 +964,9 @@ describe('home learning modules', () => {
     session.groupId.set(3);
     session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
     const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }], [{ GeneralID: 1002, Title: 'banana', sy: '香蕉' }]];
-    localStorage.setItem('xiaoshu.learning._page_word_orilist', JSON.stringify(pages));
-    localStorage.setItem('xiaoshu.learning._page_total_page', '0');
-    localStorage.setItem('xiaoshu.learning._page_current_learn', '1');
+    localStorage.setItem('xiaoshu.learning.300.100.901._page_word_orilist', JSON.stringify(pages));
+    localStorage.setItem('xiaoshu.learning.300.100.901._page_total_page', '0');
+    localStorage.setItem('xiaoshu.learning.300.100.901._page_current_learn', '1');
     const harness = await RouterTestingHarness.create();
     const router = TestBed.inject(Router);
     await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&orderId=901');
@@ -965,7 +988,7 @@ describe('home learning modules', () => {
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     expect(router.url).toBe('/pages/shop/get_order?id=901');
-    for (const key of ['_page_word_orilist', '_page_total_page', '_page_current_learn']) localStorage.removeItem(`xiaoshu.learning.${key}`);
+    for (const key of ['_page_word_orilist', '_page_total_page', '_page_current_learn']) localStorage.removeItem(`xiaoshu.learning.300.100.901.${key}`);
   });
 
   it('resolves the appointment student before reading end results and offers an exit on failure', async () => {

+ 6 - 0
projects/xiaoshu-mobile/src/app/core/learning-draft.spec.ts

@@ -0,0 +1,6 @@
+import {LearningDraft} from './learning-draft';
+describe('learning draft isolation',()=>{
+ afterEach(()=>{for(const storage of[localStorage,sessionStorage])for(const key of Object.keys(storage))if(key.startsWith('test.learning.'))storage.removeItem(key);});
+ it('isolates teachers, students and appointments',()=>{const first=new LearningDraft('test.learning.1.2.3');first.set('words',['apple']);for(const scope of['2.2.3','1.4.3','1.2.5'])expect(new LearningDraft('test.learning.'+scope).get('words')).toBeNull();expect(first.get('words')).toEqual(['apple']);});
+ it('retains write identifiers across navigation and clears only this attempt',()=>{const first=new LearningDraft('test.learning.1.2.3'),second=new LearningDraft('test.learning.1.4.3');const key=first.command('word:7');expect(new LearningDraft(first.prefix).command('word:7')).toBe(key);second.set('words',['river']);first.clear();expect(first.get('commands')).toBeNull();expect(second.get('words')).toEqual(['river']);});
+});

+ 9 - 0
projects/xiaoshu-mobile/src/app/core/learning-draft.ts

@@ -0,0 +1,9 @@
+/** A learning attempt is scoped to the signed-in actor, learner and entry context. */
+export class LearningDraft {
+  constructor(readonly prefix:string) {}
+  key(name:string):string{return `${this.prefix}.${name}`;}
+  get(name:string):unknown{const raw=localStorage.getItem(this.key(name));if(raw===null)return null;try{return JSON.parse(raw);}catch{return raw;}}
+  set(name:string,value:unknown):void{localStorage.setItem(this.key(name),JSON.stringify(value));}
+  command(name:string):string{const keys=(this.get('commands')||{}) as Record<string,string>;if(!keys[name]){keys[name]=crypto.randomUUID();this.set('commands',keys);}return keys[name];}
+  clear():void{for(const storage of [localStorage,sessionStorage])for(const key of Object.keys(storage))if(key.startsWith(this.prefix+'.'))storage.removeItem(key);}
+}

+ 3 - 3
projects/xiaoshu-mobile/src/app/core/page-catalog.ts

@@ -71,8 +71,8 @@ export const PAGE_CATALOG: PageMeta[] = [
   { path: 'pages/shop/stu_get_order', title: '预约详情', domain: 'shop', kind: 'appointment', auth: true },
   { path: 'pages/shop/baobiao', title: '学习报表', domain: 'shop', kind: 'webview', auth: true },
 
-  { path: 'pages/member/point', title: '我的学习令', domain: 'member', kind: 'finance', auth: true },
-  { path: 'pages/member/purse', title: '我的钱包', domain: 'member', kind: 'finance', auth: true },
+  { path: 'pages/member/point', title: '陪练时长', domain: 'member', kind: 'finance', auth: true },
+  { path: 'pages/member/purse', title: '课时账户', domain: 'member', kind: 'finance', auth: true },
   { path: 'pages/member/purse_outacc', title: '提现账户管理', domain: 'member', kind: 'resource', auth: true, api: { action: 'user_bank_list' }, itemRoute: '/pages/member/purse_outacc_add', deleteAction: 'user_bank_del' },
   { path: 'pages/member/purse_outacc_add', title: '提现账户详情', domain: 'member', kind: 'form', auth: true, api: { action: 'user_bank_get' }, saveAction: 'user_bank_add', fields: [{ key: 'CardType', label: '账户类型', type: 'select', required: true, options: ['银行卡', '支付宝', '微信', '其它'].map((value) => ({ label: value, value })) }, text('BankName', '银行名称', true), text('PeopleName', '开户人', true), text('CardNum', '开户账号', true), text('Remark', '备注', false, 'textarea')] },
   { path: 'pages/member/purse_out_log', title: '提现记录', domain: 'member', kind: 'resource', auth: true, api: { action: 'user_cash_list' } },
@@ -106,7 +106,7 @@ export const PAGE_CATALOG: PageMeta[] = [
   { path: 'pages/member/passwd', title: '修改登录密码', domain: 'member', kind: 'form', auth: true, saveAction: 'user_update_pwd', fields: [text('passwd', '新密码', true, 'password'), text('passwd2', '确认密码', true, 'password'), text('vcode', '验证码', true)] },
   { path: 'pages/member/passwd2', title: '修改支付密码', domain: 'member', kind: 'form', auth: true, saveAction: 'user_update_paypwd', fields: [text('paypwd', '新支付密码', true, 'password'), text('paypwd2', '确认支付密码', true, 'password'), text('vcode', '验证码', true)] },
   { path: 'pages/member/rnauth', title: '实名认证', domain: 'member', kind: 'form', auth: true, api: { action: 'user_rnauth_get' }, saveAction: 'user_rnauth_add', fields: [text('realName', '真实姓名', true), text('idCard', '身份证件号', true), text('front', '身份证国徽面', true, 'file'), text('back', '身份证人像面', true, 'file'), text('handheld', '本人手持身份证照片', true, 'file'), text('mobile', '联系手机', false, 'tel'), text('address', '联系地址')] },
-  { path: 'pages/member/coin', title: '我的学习豆', domain: 'member', kind: 'finance', auth: true },
+  { path: 'pages/member/coin', title: '60 分钟课时', domain: 'member', kind: 'finance', auth: true },
   { path: 'pages/member/ghis', title: '抗遗忘历史', domain: 'member', kind: 'studyRecords', auth: true },
   { path: 'pages/member/myke', title: '我的练习进度', domain: 'member', kind: 'studyRecords', auth: true },
   { path: 'pages/member/ke_xiao', title: '销课记录', domain: 'member', kind: 'courseCenter', auth: true },

+ 8 - 0
projects/xiaoshu-mobile/src/app/core/review-page.ts

@@ -0,0 +1,8 @@
+import { buildAntiForgetCalendar, buildAntiForgetView } from './anti-forget';
+export interface ReviewPage { items:Record<string,unknown>[];page:{itemCount:number;pageCount:number};summary:{total:number;completed:number;due:number;today:number;income:number}; dates:Array<{date:string;total:number;completed:number}>;students?:Array<{id:string;name:string;taskCount:number}> }
+export function reviewCalendar(dates:ReviewPage['dates'],month:Date,selected:string){
+  const view=buildAntiForgetCalendar([],month,selected);const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date());
+  view.days=view.days.map(day=>{const row=dates.find(d=>d.date===day.date);const total=Number(row?.total)||0,completed=Number(row?.completed)||0,pending=total-completed;return{...day,total,completed,pending,state:!total?'none' as const:!pending?'completed' as const:day.date<=today?'due' as const:'scheduled' as const};});
+  view.scheduledDays=view.days.filter(d=>d.inCurrentMonth&&d.total>0).length;return view;
+}
+export function reviewOverview(data:ReviewPage|null){const view=buildAntiForgetView([]);if(!data)return view;const s=data.summary;view.summary={...view.summary,total:s.total,completed:s.completed,pending:s.due,todayTotal:s.today,todayCompleted:Number(data.dates.find(d=>d.date===new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date()))?.completed)||0,progress:s.total?Math.round(s.completed/s.total*100):0};view.days=view.days.map(day=>{const d=data.dates.find(d=>d.date===day.date);return{...day,total:d?.total||0,completed:d?.completed||0};});return view;}

+ 10 - 0
projects/xiaoshu-mobile/src/app/core/session.service.ts

@@ -35,6 +35,8 @@ export class SessionService {
       this.logout();
       return;
     }
+    if (this.user()?.userId !== user.userId) this.clearLearningDrafts();
+    this.student.set(null);
     this.user.set(user);
     this.token.set(sessionToken);
   }
@@ -50,7 +52,15 @@ export class SessionService {
     }
   }
 
+  private clearLearningDrafts(): void {
+    for (const key of Object.keys(localStorage)) if (key.startsWith('xiaoshu.learning.')) localStorage.removeItem(key);
+    for (const key of Object.keys(sessionStorage)) if (key.startsWith('xiaoshu.learning.')) sessionStorage.removeItem(key);
+    sessionStorage.removeItem('xiaoshu.selectedWords');
+    localStorage.removeItem('xiaoshu.reading.user');
+  }
+
   logout(): void {
+    this.clearLearningDrafts();
     this.user.set(null);
     this.student.set(null);
     this.token.set('');

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

@@ -19,7 +19,7 @@
       @if (status() > 20) { <div><dt>陪练老师</dt><dd>{{ field('coach') || '-' }}</dd></div><div><dt>学习成果</dt><dd>{{ order()['dsxueshi'] || 0 }} 学时,累计识词 {{ order()['learned'] || 0 }}</dd></div> }
     </dl>
 
-    @if (!isStudentView && status() <= 10 && !isWriting) {
+    @if (!order()['cancelled'] && !isStudentView && status() <= 10 && !isWriting) {
       <section class="course-section">
         <label><span>本次课程</span><strong>{{order()['courseCategoryName'] || ''}} · {{field('course')}}</strong><small>如需改课,请由管理员调整排课</small></label>
         <div class="unit-heading"><strong>选择学习单元</strong><span>可多选,长按标记功能在 Web 中改为右侧按钮</span></div>
@@ -100,13 +100,13 @@
       </section>
     }
 
-    <div class="appointment-actions">
+    @if (!order()['cancelled']) {<div class="appointment-actions">
       @if (!isStudentView && status() === 0) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="startOrContinue()"><lucide-icon [img]="icons.Play" [size]="18" />开始课程</button> }
       @if (!isStudentView && status() === 10) { <button type="button" class="secondary-button" [disabled]="submitting()" (click)="startOrContinue()">继续课程<lucide-icon [img]="icons.ChevronRight" [size]="18" /></button><button type="button" class="primary-button" [disabled]="submitting()" (click)="openCompletionFeedback()">结束课程</button> }
       @if (!isStudentView && status() === 11) { <span class="workflow-hint">等待学员确认并评价</span> }
       @if (!isStudentView && status() === 20) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="completeCoachOrder()"><lucide-icon [img]="icons.Save" [size]="18" />生成学习报告</button> }
       @if (isStudentView && status() === 11) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishStudentLesson()"><lucide-icon [img]="icons.GraduationCap" [size]="18" />确认学习结束</button> }
       @if (isStudentView && status() === 20) { <button type="button" class="secondary-button" disabled>已确认结束</button> }
-    </div>
+    </div>}
   }
-</section>
+@if(feedback();as f){<section class="lesson-feedback card"><h3>完课反馈</h3><p>学习内容:{{f['contentSummary']}}</p><p>训练进度:{{f['trainingSummary']}}</p><p>作业:{{f['homework']||'无作业'}}</p></section>}</section>

+ 27 - 128
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, injec
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { BookCheck, Check, ChevronRight, CircleHelp, Clock3, Flag, GraduationCap, LucideAngularModule, Play, Save, Star, X } from 'lucide-angular';
-import { finalize } from 'rxjs';
+import { switchMap, finalize } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
 import { PageMeta } from '../../core/models';
 import { PlatformService } from '../../core/platform.service';
@@ -38,6 +38,7 @@ export class AppointmentPageComponent implements OnInit {
   readonly icons = { BookCheck, Check, ChevronRight, CircleHelp, Clock3, Flag, GraduationCap, Play, Save, Star, X };
   readonly status = computed(() => Number(this.order()['dszt'] ?? this.order()['status'] ?? 0));
 
+  readonly feedback=computed(()=>this.order()['feedback'] as Record<string,unknown>|null);
   selectedCourse = '';
   coachComment = '';
   contentSummary = '';
@@ -60,7 +61,7 @@ export class AppointmentPageComponent implements OnInit {
   constructor() {
     effect(() => {
       const dialog = this.completionDialog()?.nativeElement;
-      if (dialog && this.route.snapshot.queryParamMap.get('feedback') === '1') dialog.showModal();
+      if (dialog && !this.isStudentView && !this.order()['cancelled'] && this.status()===10 && this.route.snapshot.queryParamMap.get('feedback') === '1') dialog.showModal();
     });
   }
 
@@ -115,6 +116,7 @@ export class AppointmentPageComponent implements OnInit {
   }
 
   statusText(): string {
+    if(this.order()['cancelled'])return '已取消';
     return String(this.order()['status_txt'] ?? ({ 0: '待开始', 10: '学习中', 11: '等待学员确认', 20: '学员已确认', 30: '已完成' } as Record<number, string>)[this.status()] ?? `状态 ${this.status()}`);
   }
 
@@ -184,6 +186,7 @@ export class AppointmentPageComponent implements OnInit {
   }
 
   startOrContinue(): void {
+    if(this.order()['cancelled']===true){this.platform.toast('此预约已取消,不能开始课程','error');return;}
     if (!this.isWriting && !this.selectedUnits().size) {
       this.platform.toast('请选择单元', 'error');
       return;
@@ -195,12 +198,13 @@ export class AppointmentPageComponent implements OnInit {
       }
       void this.router.navigate(['/pages/stu/stu_abc'], {
         replaceUrl: true,
-        queryParams: { nids: [...this.selectedUnits()].join(','), pnid: this.selectedCourse, orderId: this.orderId },
+        queryParams: { nids: [...this.selectedUnits()].join(','), pnid: this.selectedCourse, orderId: this.orderId, bindingId:this.order()['bindingId']||undefined },
       });
     });
   }
 
   openCompletionFeedback(): void {
+    if(this.order()['cancelled']===true)return;
     if (this.isStudentView || this.status() !== 10 || this.submitting()) return;
     this.feedbackError.set('');
     this.completionDialog()?.nativeElement.showModal();
@@ -239,132 +243,27 @@ export class AppointmentPageComponent implements OnInit {
   }
 
   completeCoachOrder(): void {
-    if (!this.coachComment.trim()) {
-      this.platform.toast('请填写陪练点评', 'error');
-      return;
-    }
-    this.updateOrderStatus(30, () => this.saveCoachComment());
-  }
-
-  async finishStudentLesson(): Promise<void> {
-    if (!(await this.platform.confirm('即将确认本次学习结束,确认继续?'))) return;
-    if (this.studentScore < 1 || this.studentScore > 5) {
-      this.platform.toast('评分应为 1 到 5 分', 'error');
-      return;
-    }
-    this.updateOrderStatus(20, () => this.saveStudentComment());
-  }
-
-  private finishCoachStatus(): void {
-    this.updateOrderStatus(11, () => this.savePracticeSession());
-  }
-
-  private updateOrderStatus(status: number, next: () => void): void {
+    if(!this.coachComment.trim()){this.platform.toast('请填写陪练点评','error');return;}
+    this.saveLessonComment({pldp:this.coachComment,plpjsj:this.dateTime()},30);
+  }
+  async finishStudentLesson():Promise<void>{
+    if(this.submitting())return;
+    if(!(await this.platform.confirm('即将确认本次学习结束,确认继续?')))return;
+    if(this.studentScore<1||this.studentScore>5){this.platform.toast('评分应为 1 到 5 分','error');return;}
+    this.saveLessonComment({pf:this.studentScore,pjnr:this.studentComment},20);
+  }
+  private saveLessonComment(patch:SourceItem,status:number):void{
+    if(this.submitting())return;const id=this.practiceRecord()['GeneralID'];
+    if(!id){this.platform.toast('未找到本次完课记录,请刷新后重试','error');return;}
     this.submitting.set(true);
-    this.api.post('e_order_update_v2', {
-      status,
-      uid: this.session.user()?.userId,
-      content: JSON.stringify({ GeneralID: this.orderId, UpDateTime: this.dateTime() }),
-    }).pipe(finalize(() => this.submitting.set(false))).subscribe({
-      next: () => next(),
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
-    });
+    this.api.post('content_update',{content:JSON.stringify({GeneralID:id}),addon:JSON.stringify(patch)}).pipe(
+      switchMap(()=>this.api.post('e_order_update_v2',{status,content:JSON.stringify({GeneralID:this.orderId})})),
+      finalize(()=>this.submitting.set(false)),
+    ).subscribe({next:()=>this.load(),error:(error:ApiError)=>this.platform.toast(error.message,'error')});
   }
-
-  private saveCoachComment(): void {
-    const record = this.practiceRecord();
-    if (!record['GeneralID']) {
-      this.platform.toast('未找到陪练记录', 'error');
-      return;
-    }
-    const now = this.dateTime();
-    this.api.post('content_update', {
-      content: JSON.stringify({ GeneralID: record['GeneralID'], UpDateTime: now }),
-      addon: JSON.stringify({ pldp: this.coachComment, plpjsj: now, kclx: this.order()['dslx_t'] }),
-    }).subscribe({
-      next: () => this.load(),
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
-    });
-  }
-
-  private saveStudentComment(): void {
-    const record = this.practiceRecord();
-    if (!record['GeneralID']) {
-      this.platform.toast('未找到陪练记录', 'error');
-      return;
-    }
-    this.api.post('content_update', {
-      content: JSON.stringify({ GeneralID: record['GeneralID'], UpDateTime: this.dateTime() }),
-      addon: JSON.stringify({ pf: this.studentScore, pjnr: this.studentComment }),
-    }).subscribe({
-      next: () => this.load(),
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
-    });
-  }
-
-  private savePracticeSession(): void {
-    const student = this.session.student();
-    const studentId = this.appointmentStudentId();
-    const user = this.session.user();
-    const existing = this.practiceRecord();
-    const content: SourceItem = {
-      ModelID: 59,
-      nodeId: 296,
-      inputer: user?.honeyName,
-      status: 99,
-      Hits: 0,
-      title: student?.honeyName ?? this.field('student'),
-    };
-    let action = 'content_add';
-    if (existing['GeneralID']) {
-      content['GeneralID'] = existing['GeneralID'];
-      content['UpDateTime'] = this.dateOnly();
-      action = 'content_update';
-    }
-    const addon = {
-      jsmz: user?.userId,
-      kcid: this.selectedCourse,
-      xymz: studentId,
-      kclx: this.order()['dslx_t'],
-      yyds: this.orderId,
-      jffs: this.order()['jffs'],
-      kcmc: this.order()['kcmc'],
-      szmdid: this.order()['szmd'],
-      kzsj: existing['kzsj'] ?? '[]',
-    };
-    this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) }).subscribe({
-      next: () => this.load(),
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
-    });
-  }
-
-  private saveWritingLearningRecord(next: () => void): void {
-    const user = this.session.user();
-    const student = Number(user?.groupId) === 3 ? this.session.student() : null;
-    const owner = student ?? user;
-    const existing = this.learningRecord();
-    const content: SourceItem = { ModelID: 56, nodeId: 291, inputer: user?.honeyName, status: 99, Hits: 0, title: owner?.honeyName };
-    let action = 'content_add_zt';
-    if (existing['GeneralID']) {
-      content['GeneralID'] = existing['GeneralID'];
-      action = 'stu_record_update';
-    }
-    const addon = {
-      con: `完成了《${String(this.order()['kcmc'] ?? '')}》的学习@${this.dateTime()}`,
-      szmdid: this.order()['szmd'],
-      dqrq: this.dateOnly().replaceAll('-', ''),
-      dsid: this.orderId,
-      pl: student ? user?.userId : 0,
-      UserID: owner?.userId,
-      learned: 0,
-      ygg: 0,
-      djq: 0,
-      xxqs: '',
-    };
-    this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) }).subscribe({
-      next: () => next(),
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
-    });
+  private updateOrderStatus(status:number,next:()=>void):void{
+    if(this.submitting())return;this.submitting.set(true);
+    this.api.post('e_order_update_v2',{status,uid:this.session.user()?.userId,content:JSON.stringify({GeneralID:this.orderId})}).pipe(finalize(()=>this.submitting.set(false))).subscribe({next:()=>next(),error:(error:ApiError)=>this.platform.toast(error.message,'error')});
   }
 
   private loadPracticeRecord(): void {
@@ -373,7 +272,7 @@ export class AppointmentPageComponent implements OnInit {
     this.api.get<unknown>('content_list', { ModelID: 59, psize: 2, myfield2: targetField }).subscribe({
       next: ({ result }) => {
         const records=this.findList(result),bindingId=String(this.order()['bindingId']||'');
-        const record = (bindingId?records.find(row=>String(row['bindingId']||row['id'])===bindingId):records.length===1?records[0]:undefined) ?? {};
+        const record = records.find(row=>String(row['yyds']||'')===this.orderId)|| (records.length===1?records[0]:undefined) || {};
         this.practiceRecord.set(record);
         this.coachComment = String(record['pldp'] ?? this.coachComment);
         this.studentScore = Number(record['pf'] ?? this.studentScore);

+ 11 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.ts

@@ -0,0 +1,11 @@
+import { CommonModule } from '@angular/common';
+import { Component, inject, OnInit, signal } from '@angular/core';
+import { ActivatedRoute, RouterLink } from '@angular/router';
+import { finalize } from 'rxjs';
+import { ApiService } from '../../core/api.service';
+interface Report {order:Record<string,unknown>;feedback:Record<string,unknown>|null;records:Array<Record<string,unknown>>;antiForget:{total:number;completed:number};refreshedAt:string}
+@Component({standalone:true,imports:[CommonModule,RouterLink],template:`<section class="lesson-report"><header><h2>学习报表</h2><p>课程学习、完课反馈与抗遗忘进度</p></header>@if(error()){<p class="error" role="alert">{{error()}}</p><button (click)="load()">重新加载</button>}@if(report();as r){<article><h3>{{r.order['kcmc']}}</h3><p>{{r.order['HoneyName']}} · {{r.order['yysj']}}</p>@if(r.order['cancelled']){<p>此预约已取消</p>}</article><article><h3>完课反馈</h3>@if(r.feedback;as f){<p><b>学习内容:</b>{{f['contentSummary']}}</p><p><b>复习情况:</b>{{f['reviewSummary']||'未补充'}}</p><p><b>训练进度:</b>{{f['trainingSummary']}}</p><p><b>作业:</b>{{f['homework']||'无作业'}}</p>}@else{<p>尚未提交完课反馈</p>}</article><article><h3>学习记录</h3>@for(item of r.records;track item['generalId']){<p>{{item['dqrq']}} · 已学 {{item['learned']}} 个单词 · 已掌握 {{item['ygg']}}</p>}@empty{<p>暂无本次课程的单词学习记录</p>}</article><article><h3>抗遗忘计划</h3><p>已完成 {{r.antiForget.completed}} / {{r.antiForget.total}} 项</p></article><small>更新时间 {{r.refreshedAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</small>}@else if(loading()){<p>正在加载…</p>}<footer><a routerLink="/pages/his/his">查看学习记录</a></footer></section>`,styles:[`.lesson-report{max-width:1000px;margin:24px auto;padding:0 20px;line-height:1.8}.lesson-report article{padding:24px;margin:16px 0;border:1px solid var(--line);border-radius:18px;background:white}.error{color:#af3328}`]})
+export class LessonReportComponent implements OnInit{
+ readonly report=signal<Report|null>(null);readonly loading=signal(false);readonly error=signal('');private readonly api=inject(ApiService);private readonly route=inject(ActivatedRoute);
+ ngOnInit():void{this.load();}load():void{const id=this.route.snapshot.queryParamMap.get('sid')||this.route.snapshot.queryParamMap.get('id');if(!id){this.error.set('缺少预约编号,请从课程详情进入');return;}this.loading.set(true);this.error.set('');this.api.get<Report>('app_lesson_report',{id}).pipe(finalize(()=>this.loading.set(false))).subscribe({next:r=>this.report.set(r.result),error:e=>this.error.set(e.message)});}
+}

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/auth/login-page.component.ts

@@ -137,7 +137,7 @@ export class LoginPageComponent implements OnInit, OnDestroy {
       next: ({ result }) => {
         this.session.setLogin(result);
         this.platform.toast('注册成功', 'success');
-        void this.router.navigateByUrl('/pages/member/wxauth_after');
+        void this.router.navigateByUrl('/pages/home/index');
       },
       error: (error: ApiError) => this.platform.toast(`注册失败:${error.message}`, 'error'),
     });

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

@@ -35,7 +35,7 @@
             </label>
           </div>
           <footer>
-            <span>当前显示 <strong>{{ visibleSchedules().length }}</strong> 节,共 {{ schedules().length }} 节</span>
+            <span>当前显示 <strong>{{ visibleSchedules().length }}</strong> 节,共 {{ listTotal() }} 节</span>
             @if (historyFilterActive()) { <button type="button" (click)="clearHistoryFilters()"><lucide-icon [img]="icons.X" [size]="15" />清除筛选</button> }
           </footer>
         </section>
@@ -53,7 +53,7 @@
               <label class="student-filter">
                 <span>学员</span>
                 <select [ngModel]="selectedStudent()" (ngModelChange)="setStudentFilter($event)">
-                  <option value="all">全部学员({{ schedules().length }})</option>
+                  <option value="all">全部学员({{ listTotal() }})</option>
                   @for (student of studentOptions(); track student.id) {
                     <option [value]="student.id">{{ student.name }}({{ student.count }})</option>
                   }
@@ -102,7 +102,7 @@
         </div>
       } @else if (!isCoach && schedules().length) {
         <nav class="schedule-filters" aria-label="伴学记录筛选">
-          <button type="button" [class.active]="scheduleFilter() === 'all'" (click)="setScheduleFilter('all')">全部 <span>{{ schedules().length }}</span></button>
+          <button type="button" [class.active]="scheduleFilter() === 'all'" (click)="setScheduleFilter('all')">全部 <span>{{ listTotal() }}</span></button>
           <button type="button" [class.active]="scheduleFilter() === 'pending'" (click)="setScheduleFilter('pending')">计划未完成 <span>{{ pendingCount() }}</span></button>
           <button type="button" [class.active]="scheduleFilter() === 'finished'" (click)="setScheduleFilter('finished')">已结束 <span>{{ finishedCount() }}</span></button>
         </nav>
@@ -128,4 +128,4 @@
     @if (error()) {
       <div class="center-state error"><lucide-icon [img]="icons.AlertCircle" [size]="25" /><span>{{ error() }}</span><button type="button" class="secondary-button" (click)="load(true)">重新加载</button></div>
     }
-</section>
+<nav class="mobile-pagination"><button (click)="turnPage(-1)" [disabled]="loading()||listPage()<=1">上一页</button><span>共 {{listTotal()}} 条 · {{listPage()}} / {{listPages()}}</span><button (click)="turnPage(1)" [disabled]="loading()||listPage()>=listPages()">下一页</button></nav></section>

+ 33 - 28
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, LucideAngularModule, Search, X } from 'lucide-angular';
-import { finalize } from 'rxjs';
+import { finalize, Subscription } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
 import { canStartTeacherLedLearning } from '../../core/learning-access';
 import { AppUser, PageMeta } from '../../core/models';
@@ -38,6 +38,7 @@ interface MonthOption {
 }
 
 interface CompanionOverview {
+  page?: {itemCount:number;pageCount:number}; dates?: Array<{date:string;finished:number;planned:number}>; studentOptions?:StudentOption[];
   courses: Item[];
   schedules: Item[];
   refreshedAt: string;
@@ -58,6 +59,8 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   readonly isHistory = this.meta.path === 'pages/member/ke_xiao';
   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 loadRequest?:Subscription; private loadId=0;
   readonly courses = signal<Item[]>([]);
   readonly schedules = signal<Item[]>([]);
   readonly scheduleFilter = signal<ScheduleFilter>('all');
@@ -70,14 +73,14 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   readonly now = signal(Date.now());
   readonly loading = signal(false);
   readonly error = signal('');
-  readonly studentOptions = computed(() => this.buildStudentOptions());
+  readonly studentOptions = computed(() => this.serverStudents());
   readonly historyMonthOptions = computed(() => this.buildHistoryMonthOptions());
   readonly historyFilterActive = computed(() => this.historyMonth() !== 'all' || Boolean(this.historyStudentQuery().trim()));
   readonly scopedSchedules = computed(() => this.schedules().filter((item) => this.matchesStudent(item)));
   readonly filteredSchedulePool = computed(() => this.scopedSchedules().filter((item) => this.matchesStatus(item)));
   readonly visibleSchedules = computed(() => this.filteredSchedulePool().filter((item) => this.matchesRange(item) && this.matchesHistoryFilters(item)));
-  readonly pendingCount = computed(() => this.scopedSchedules().filter((item) => this.isOverdueUnfinished(item)).length);
-  readonly finishedCount = computed(() => this.scopedSchedules().filter((item) => this.isFinished(item)).length);
+  readonly pendingCount = computed(() => this.serverDates().filter(d=>d.date<new Date().toLocaleDateString('en-CA',{timeZone:'Asia/Shanghai'})).reduce((n,d)=>n+d.planned,0));
+  readonly finishedCount = computed(() => this.serverDates().reduce((n,d)=>n+d.finished,0));
   readonly calendarDays = computed(() => this.buildCalendarDays());
   readonly calendarLabel = computed(() => new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(this.calendarMonth()));
   readonly scheduleRangeLabel = computed(() => {
@@ -106,35 +109,38 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   }
 
   ngOnDestroy(): void {
+    this.loadId++;this.loadRequest?.unsubscribe();
     if (this.nowTimer) clearInterval(this.nowTimer);
   }
 
-  load(refresh = false): void {
+  turnPage(delta:number):void{this.listPage.update(p=>Math.max(1,Math.min(this.listPages(),p+delta)));this.load(false,true);}
+
+  load(refresh = false, keepPage=false): void {
+    this.loadRequest?.unsubscribe(); const requestId=++this.loadId; if(!keepPage)this.listPage.set(1);
     const uid = this.session.user()?.userId;
     if (!uid) return;
     this.loading.set(true);
     this.error.set('');
     const today = this.today();
     const range = this.isHistory || this.scheduleRange() === 'all' ? null : this.calendarWindow();
-    this.api.post<CompanionOverview>('app_companion_overview', {
+    this.loadRequest=this.api.post<CompanionOverview>('app_companion_overview', {
       uid,
       mode: this.isCoach ? 'coach' : 'student',
       history: this.isHistory,
-      limit: this.isHistory ? 100 : range ? 200 : 500,
+      page:this.listPage(),pageSize:20,studentId:this.selectedStudent()==='all'?undefined:this.selectedStudent(),filter:this.scheduleFilter(),month:this.historyMonth()==='all'?undefined:this.historyMonth(),search:this.historyStudentQuery(),
+      dateStart:this.isHistory||this.scheduleRange()==='all'?undefined:this.scheduleRange()==='week'?this.weekRange(this.selectedDate())[0]:this.selectedDate(),
+      dateEnd:this.isHistory||this.scheduleRange()==='all'?undefined:this.scheduleRange()==='week'?this.weekRange(this.selectedDate())[1]:this.selectedDate(),
       rangeStart: range?.start,
       rangeEnd: range?.end,
       refresh,
     }).pipe(finalize(() => this.loading.set(false))).subscribe({
       next: ({ result }) => {
+        if(requestId!==this.loadId)return;this.listPages.set(result.page?.pageCount||1);this.listTotal.set(result.page?.itemCount||0);this.serverDates.set(result.dates||[]);this.serverStudents.set(result.studentOptions||[]);
         this.courses.set(Array.isArray(result?.courses) ? result.courses : []);
         this.schedules.set(Array.isArray(result?.schedules) ? result.schedules : []);
-        if (this.isHistory && !this.historyFilterInitialized) {
-          this.historyMonth.set(this.historyMonthOptions()[0]?.value ?? 'all');
-          this.historyFilterInitialized = true;
-        }
         this.loadedRange = range;
         this.loadedAll = !range;
-        if (!this.initialScheduleLoaded && !this.isHistory && !this.restoredScheduleState) this.selectDate(today);
+        if (!this.initialScheduleLoaded && !this.isHistory && !this.restoredScheduleState) this.selectedDate.set(today);
         this.initialScheduleLoaded = true;
         if (this.restoredScheduleState) this.restoreScheduleScroll();
       },
@@ -211,31 +217,35 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   scheduleId(item: Item): unknown { return item['GeneralID'] ?? item['ID'] ?? item['id']; }
 
   statusText(item: Item): string {
+    if(item['cancelled']===true)return '已取消';
     if (this.isOverdueUnfinished(item)) return '计划未完成';
     return ({ 0: '未开始', 10: '已开始', 11: '已上课', 20: '已评价', 30: '已结课' } as Record<number, string>)[this.rawStatus(item)] ?? '--';
   }
 
   statusClass(item: Item): string { return this.isOverdueUnfinished(item) ? 'status-11' : `status-${this.rawStatus(item)}`; }
 
-  isFinished(item: Item): boolean { return this.rawStatus(item) >= 30; }
+  isFinished(item: Item): boolean { return item['cancelled']!==true && this.rawStatus(item) >= 30; }
 
   setScheduleFilter(filter: ScheduleFilter): void {
     this.scheduleFilter.set(filter);
+    this.load();
     this.syncScheduleState();
   }
 
   setStudentFilter(value: string): void {
     this.selectedStudent.set(value || 'all');
+    this.load();
     this.syncScheduleState();
   }
 
-  setHistoryMonth(value: string): void { this.historyMonth.set(value || 'all'); }
+  setHistoryMonth(value: string): void { this.historyMonth.set(value || 'all'); this.load(); }
 
-  setHistoryStudentQuery(value: string): void { this.historyStudentQuery.set(value); }
+  setHistoryStudentQuery(value: string): void { this.historyStudentQuery.set(value); this.load(); }
 
   clearHistoryFilters(): void {
     this.historyMonth.set('all');
     this.historyStudentQuery.set('');
+    this.load();
   }
 
   shiftCalendarMonth(amount: number): void {
@@ -253,6 +263,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
 
   showWeek(): void {
     this.scheduleRange.set('week');
+    this.load();
     this.ensureSelectedDate();
     this.syncScheduleState();
   }
@@ -260,7 +271,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   showAllSchedules(): void {
     this.scheduleRange.set('all');
     this.syncScheduleState();
-    if (!this.loadedAll) this.load();
+    this.load();
   }
 
   scheduleEmptyText(): string {
@@ -272,6 +283,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   }
 
   open(item: Item, event?: Event): void {
+    if(item['cancelled']===true){this.platform.toast('该预约已取消','info');return;}
     this.saveScheduleScroll(item, event);
     if (!this.isCoach) {
       this.storeAppointmentSnapshot(item);
@@ -331,10 +343,10 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   private buildHistoryMonthOptions(): MonthOption[] {
     if (!this.isHistory) return [];
     const counts = new Map<string, number>();
-    for (const item of this.schedules()) {
-      const month = this.itemDate(item).slice(0, 7);
+    for (const item of this.serverDates()) {
+      const month = item.date.slice(0, 7);
       if (!/^\d{4}-\d{2}$/.test(month)) continue;
-      counts.set(month, (counts.get(month) ?? 0) + 1);
+      counts.set(month, (counts.get(month) ?? 0) + item.finished + item.planned);
     }
     return [...counts.entries()]
       .sort(([left], [right]) => right.localeCompare(left))
@@ -379,14 +391,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     const start = new Date(year, monthIndex, 1 - offset, 12);
     const today = this.today();
     const counts = new Map<string, { finished: number; planned: number }>();
-    for (const item of this.filteredSchedulePool()) {
-      const date = this.itemDate(item);
-      if (!date) continue;
-      const count = counts.get(date) ?? { finished: 0, planned: 0 };
-      if (this.isFinished(item)) count.finished += 1;
-      else count.planned += 1;
-      counts.set(date, count);
-    }
+    for(const row of this.serverDates()) counts.set(row.date,{finished:row.finished,planned:row.planned});
     return Array.from({ length: 42 }, (_, index) => {
       const date = new Date(start);
       date.setDate(start.getDate() + index);
@@ -513,7 +518,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     const value = this.parseDate(date);
     this.calendarMonth.set(new Date(value.getFullYear(), value.getMonth(), 1, 12));
     this.syncScheduleState();
-    if (!this.loadedRange || date < this.loadedRange.start || date > this.loadedRange.end) this.load();
+    this.load();
   }
 
   private calendarWindow(): { start: string; end: string } {

+ 11 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.spec.ts

@@ -0,0 +1,11 @@
+import {EnglishReadingService} from './english-reading.service';
+import {ReadingCloudService} from './reading-cloud.service';
+import {FmodeAiService} from './fmode-ai.service';
+import {ReadingArticle,ReadingRouteParams}from './reading.models';
+describe('reading article reuse',()=>{
+ const params={level:'初一',type:'记叙文',theme:'校园',wordList:['apple','river'],renew:false} as ReadingRouteParams;
+ function setup(){const cloud={userPointer:{__type:'Pointer',className:'_User',objectId:'student'},query:jasmine.createSpy('query').and.resolveTo([]),create:jasmine.createSpy('create').and.callFake(async(_name:string,data:object)=>({...data,objectId:'new'}))};const s=new EnglishReadingService(cloud as unknown as ReadingCloudService,{} as FmodeAiService);spyOn(s as any,'formatPrompt').and.resolveTo('prompt');spyOn(s as any,'collectStream').and.resolveTo('A new story\napple river');return{s,cloud};}
+ it('queries the complete normalized word set and reading configuration',async()=>{const{s,cloud}=setup();cloud.query.and.resolveTo([{objectId:'existing',content:'apple river'}]);const article=await s.createArticle(params,()=>{});expect(article.objectId).toBe('existing');const where=cloud.query.calls.first().args[1];expect(where['createOptions.params.wordListKey']).toBe('apple|river');expect(where['createOptions.params.level']).toBe('初一');expect(where['createOptions.params.type']).toBe('记叙文');expect(where['createOptions.params.theme']).toBe('校园');expect(where['createOptions.params.wordList']).toBeUndefined();expect(cloud.create).not.toHaveBeenCalled();});
+ it('does not reuse an article that only overlaps part of the requested vocabulary',async()=>{const{s,cloud}=setup();cloud.query.and.callFake(async(_n:string,w:Record<string,unknown>)=>w['createOptions.params.wordList']?[{objectId:'old-apple-tree'}]:[]);const article=await s.createArticle(params,()=>{});expect(article.objectId).toBe('new');expect(cloud.create).toHaveBeenCalled();});
+ it('regenerates on explicit request without querying a reusable article',async()=>{const{s,cloud}=setup();const article=await s.createArticle({...params,renew:true},()=>{});expect(article.objectId).toBe('new');expect(cloud.query).not.toHaveBeenCalled();});
+});

+ 6 - 4
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.ts

@@ -33,7 +33,7 @@ export class EnglishReadingService {
       user: this.cloud.userPointer,
       isDeleted: { '$ne': true },
     };
-    if (wordList.length) where['createOptions.params.wordList'] = { '$in': wordList };
+    if (wordList.length) where['createOptions.params.wordListKey'] = [...new Set(wordList.map(w=>w.trim().toLowerCase()))].sort().join('|');
     return this.cloud.query<ReadingArticle>('SurveyItem', where, { order: '-createdAt', limit });
   }
 
@@ -44,7 +44,7 @@ export class EnglishReadingService {
         'createOptions.params.level': params.level,
         'createOptions.params.type': params.type,
         'createOptions.params.theme': params.theme,
-        'createOptions.params.wordList': { '$in': params.wordList },
+        'createOptions.params.wordListKey': [...new Set(params.wordList.map(w=>w.trim().toLowerCase()))].sort().join('|'),
         user: this.cloud.userPointer,
         isDeleted: { '$ne': true },
       }, { limit: 1 });
@@ -54,6 +54,8 @@ export class EnglishReadingService {
 
   async createArticle(params: ReadingRouteParams, onProgress: (progress: ReadingProgress) => void): Promise<ReadingArticle> {
     const createParams = {
+      sourceStudyId:params.sourceStudyId||'',
+      wordListKey: [...new Set(params.wordList.map(w=>w.trim().toLowerCase()))].sort().join('|'),
       wordList: params.wordList,
       count: readingWordLimit(params.level, params.wordList.length),
       stage: readingStage(params.level),
@@ -67,7 +69,7 @@ export class EnglishReadingService {
         'createOptions.params.level': params.level,
         'createOptions.params.type': params.type,
         'createOptions.params.theme': params.theme,
-        'createOptions.params.wordList': { '$in': params.wordList },
+        'createOptions.params.wordListKey': [...new Set(params.wordList.map(w=>w.trim().toLowerCase()))].sort().join('|'),
         user: this.cloud.userPointer,
         isDeleted: { '$ne': true },
       }, { order: '-createdAt', limit: 1 });
@@ -179,7 +181,7 @@ export class EnglishReadingService {
       user: this.cloud.userPointer,
       isDeleted: { '$ne': true },
     };
-    if (wordList.length) where['createOptions.params.wordList'] = { '$in': wordList };
+    if (wordList.length) where['createOptions.params.wordListKey'] = [...new Set(wordList.map(w=>w.trim().toLowerCase()))].sort().join('|');
     const articles = await this.cloud.query<ReadingArticle>('SurveyItem', where, { order: '-createdAt', skip, limit });
     if (!articles.length) return [];
     const pointers = articles.map((article) => this.cloud.pointer('SurveyItem', article.objectId));

+ 4 - 7
projects/xiaoshu-mobile/src/app/features/english-reading/fmode-ai.service.ts

@@ -1,7 +1,8 @@
 import { HttpClient, HttpHeaders } from '@angular/common/http';
-import { Injectable } from '@angular/core';
+import { inject, Injectable } from '@angular/core';
 import { firstValueFrom, Observable } from 'rxjs';
 import { API_CONFIG } from '../../core/app.constants';
+import { ApiService } from '../../core/api.service';
 import { SessionService } from '../../core/session.service';
 
 export interface AiStreamMessage {
@@ -21,6 +22,7 @@ interface AiResult {
 
 @Injectable({ providedIn: 'root' })
 export class FmodeAiService {
+  private readonly api=inject(ApiService);
   constructor(private readonly http: HttpClient, private readonly sessions: SessionService) {}
 
   stream(prompt: string): Observable<AiStreamMessage> {
@@ -59,12 +61,7 @@ export class FmodeAiService {
   private request(prompt: string, jsonMode: boolean): Observable<CloudEnvelope<AiResult>> {
     const token = String(this.sessions.token() || '').trim();
     if (!token.startsWith('r:')) throw new Error('阅读学习需要登录,请退出后重新登录');
-    return this.http.post<CloudEnvelope<AiResult>>(API_CONFIG.cloudFunctionUrl, {
-      token,
-      params: { action: 'reading/ai', prompt, jsonMode },
-    }, {
-      headers: new HttpHeaders({ 'X-Parse-Application-Id': API_CONFIG.parseAppId, 'Content-Type': 'application/json' }),
-    });
+    return this.api.callCloud<AiResult>('reading/ai',{prompt,jsonMode},token);
   }
 
   private unwrap(response: CloudEnvelope<AiResult>): string {

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading-cloud.service.spec.ts

@@ -18,7 +18,7 @@ describe('ReadingCloudService', () => {
     TestBed.configureTestingModule({ providers: [
       provideHttpClient(),
       provideHttpClientTesting(),
-      { provide: SessionService, useValue: { token: () => token } },
+      { provide: SessionService, useValue: { token: () => token, user: () => ({userId:7}) } },
     ] });
     service = TestBed.inject(ReadingCloudService);
     http = TestBed.inject(HttpTestingController);

+ 8 - 8
projects/xiaoshu-mobile/src/app/features/english-reading/reading-cloud.service.ts

@@ -1,8 +1,9 @@
 import { HttpClient, HttpHeaders } from '@angular/common/http';
-import { Injectable } from '@angular/core';
+import { inject, Injectable } from '@angular/core';
 import { firstValueFrom } from 'rxjs';
 import { API_CONFIG } from '../../core/app.constants';
 import { AppUser } from '../../core/models';
+import { ApiService } from '../../core/api.service';
 import { SessionService } from '../../core/session.service';
 import { ReadingPointer, ReadingRecord, ReadingUser } from './reading.models';
 
@@ -12,6 +13,8 @@ interface CloudEnvelope<T> { retcode: number; retmsg?: string; result: T }
 
 @Injectable({ providedIn: 'root' })
 export class ReadingCloudService {
+  private readonly api=inject(ApiService);
+  private learnerId=0;
   private currentUser: ReadingUser | null;
 
   constructor(private readonly http: HttpClient, private readonly sessions: SessionService) {
@@ -29,7 +32,9 @@ export class ReadingCloudService {
   async ensureUser(appUser: AppUser | null, mobileHint = ''): Promise<ReadingUser> {
     const token = String(this.sessions.token() || '').trim();
     if (!token.startsWith('r:')) throw new Error('阅读学习需要登录,请退出后重新登录');
-    if (this.currentUser?.sessionToken === token && this.currentUser.objectId) return this.currentUser;
+    const learnerId=Number(appUser?.userId||this.sessions.user()?.userId);
+    if (this.learnerId===learnerId && this.currentUser?.sessionToken === token && this.currentUser.objectId) return this.currentUser;
+    this.learnerId=learnerId;
     const profile = await this.call<ReadingUser>('reading/me');
     return this.persistUser({
       ...profile,
@@ -72,12 +77,7 @@ export class ReadingCloudService {
   private async call<T>(action: string, params: Record<string, unknown> = {}): Promise<T> {
     const token = String(this.sessions.token() || '').trim();
     if (!token.startsWith('r:')) throw new Error('阅读学习账户需要新的登录会话,请退出后重新登录');
-    const response = await firstValueFrom(this.http.post<CloudEnvelope<T>>(API_CONFIG.cloudFunctionUrl, {
-      token,
-      params: { action, ...params },
-    }, {
-      headers: new HttpHeaders({ 'X-Parse-Application-Id': API_CONFIG.parseAppId, 'Content-Type': 'application/json' }),
-    }));
+    const response = await firstValueFrom(this.api.callCloud<T>(action,{...params,...(this.learnerId&&this.learnerId!==Number(this.sessions.user()?.userId)?{studentId:this.learnerId}:{})},token));
     if (response.retcode === -1) throw new Error(response.retmsg || '阅读云函数请求失败');
     return response.result;
   }

+ 11 - 5
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.ts

@@ -20,6 +20,7 @@ interface ReadingDialog {
 @Component({
   selector: 'app-reading-exercise',
   standalone: true,
+  providers: [ReadingCloudService, EnglishReadingService],
   imports: [CommonModule, LucideAngularModule],
   templateUrl: './reading-exercise.component.html',
   styleUrl: './reading-exercise.component.scss',
@@ -73,6 +74,11 @@ export class ReadingExerciseComponent implements OnInit {
     return article ? highlightWords(article.content, article.createOptions.params.wordList) : '';
   }
 
+  private readingLearner() {
+    const id=Number(this.route.snapshot.queryParamMap.get('studentId'));
+    return this.session.groupId()===3 && id ? {userId:id,groupId:1,honeyName:this.session.student()?.userId===id?this.session.student()?.honeyName:''} : this.session.user();
+  }
+
   ngOnInit(): void {
     const raw = Object.fromEntries(this.route.snapshot.queryParamMap.keys.map((key) => {
       const values = this.route.snapshot.queryParamMap.getAll(key);
@@ -114,7 +120,7 @@ export class ReadingExerciseComponent implements OnInit {
 
   async print(): Promise<void> {
     const id = this.article()?.objectId;
-    if (id) await this.router.navigate(['/words/reading/pdf', id]);
+    if (id) await this.router.navigate(['/words/reading/pdf', id],{queryParams:{studentId:this.params.studentId}});
   }
 
   async createNewArticle(): Promise<void> {
@@ -159,10 +165,10 @@ export class ReadingExerciseComponent implements OnInit {
     const values = entry.article.createOptions.params;
     this.closeHistory();
     await this.router.navigate(['/words/reading'], {
-      queryParams: { ...values, wordList: values.wordList.join(','), t: Date.now() },
+      queryParams: { ...values, studentId:this.readingLearner()?.userId, sid:entry.article.objectId, wordList: values.wordList.join(','), t: Date.now() },
       replaceUrl: true,
     });
-    this.params = { ...this.params, ...values, sid: undefined };
+    this.params = { ...this.params, ...values, sid: entry.article.objectId, renew:false };
     await this.load();
   }
 
@@ -186,10 +192,10 @@ export class ReadingExerciseComponent implements OnInit {
     this.submitted.set(false);
     this.loadingTip.set(this.tips[Math.floor(Math.random() * this.tips.length)]);
     try {
-      await this.cloud.ensureUser(this.session.student() ?? this.session.user(), this.params.phone);
+      await this.cloud.ensureUser(this.readingLearner(), this.params.phone);
       await this.refreshHistoryCount();
       let article = await this.withTimeout(
-        this.reading.createArticle({ ...this.params, renew: false }, (value) => this.updateArticleProgress(value)),
+        this.params.sid&&!this.params.renew?this.cloud.get<ReadingArticle>('SurveyItem',this.params.sid):this.reading.createArticle(this.params, (value) => this.updateArticleProgress(value)),
         120000, '短文生成',
       );
       this.article.set(article);

+ 10 - 3
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.ts

@@ -1,7 +1,6 @@
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, ElementRef, inject, OnInit, signal, ViewChild } from '@angular/core';
 import { ActivatedRoute } from '@angular/router';
-import html2pdf from 'html2pdf.js';
 import { ArrowLeft, Download, LoaderCircle, LucideAngularModule } from 'lucide-angular';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
@@ -13,6 +12,7 @@ import { highlightWords } from './reading.utils';
 @Component({
   selector: 'app-reading-pdf',
   standalone: true,
+  providers: [ReadingCloudService, EnglishReadingService],
   imports: [CommonModule, LucideAngularModule],
   templateUrl: './reading-pdf.component.html',
   styleUrl: './reading-pdf.component.scss',
@@ -29,7 +29,8 @@ export class ReadingPdfComponent implements OnInit {
   readonly wechatHint = signal(false);
   readonly error = signal('');
 
-  private readonly sid = inject(ActivatedRoute).snapshot.paramMap.get('sid') ?? '';
+  private readonly route = inject(ActivatedRoute);
+  private readonly sid = this.route.snapshot.paramMap.get('sid') ?? '';
   private readonly cloud = inject(ReadingCloudService);
   private readonly reading = inject(EnglishReadingService);
   private readonly session = inject(SessionService);
@@ -40,10 +41,15 @@ export class ReadingPdfComponent implements OnInit {
     return article ? highlightWords(article.content, article.createOptions.params.wordList) : '';
   }
 
+  private readingLearner() {
+    const id=Number(this.route.snapshot.queryParamMap.get('studentId'));
+    return this.session.groupId()===3 && id ? {userId:id,groupId:1,honeyName:this.session.student()?.userId===id?this.session.student()?.honeyName:''} : this.session.user();
+  }
+
   async ngOnInit(): Promise<void> {
     if (!this.sid) { this.error.set('未提供阅读文章编号'); this.loading.set(false); return; }
     try {
-      await this.cloud.ensureUser(this.session.student() ?? this.session.user());
+      await this.cloud.ensureUser(this.readingLearner());
       const article = await this.cloud.get<ReadingArticle>('SurveyItem', this.sid);
       const questions = await this.reading.getQuestions(article);
       this.article.set(article);
@@ -69,6 +75,7 @@ export class ReadingPdfComponent implements OnInit {
       await document.fonts?.ready;
       const filename = `${article.title.replace(/[^a-zA-Z0-9一-龥]/g, '_')}_quiz.pdf`;
       let hasBlankTrailingPage = false;
+      const { default: html2pdf } = await import('html2pdf.js');
       const worker = html2pdf().set({
         margin: [10, 10, 10, 10], filename,
         image: { type: 'jpeg', quality: 1 },

+ 11 - 3
projects/xiaoshu-mobile/src/app/features/english-reading/reading-setup.component.ts

@@ -13,6 +13,7 @@ import { normalizeReadingParams, READING_LEVELS, READING_THEMES, READING_TYPES,
 @Component({
   selector: 'app-reading-setup',
   standalone: true,
+  providers: [ReadingCloudService, EnglishReadingService],
   imports: [CommonModule, FormsModule, LucideAngularModule],
   templateUrl: './reading-setup.component.html',
   styleUrl: './reading-setup.component.scss',
@@ -50,13 +51,18 @@ export class ReadingSetupComponent implements OnInit {
 
   get vocabularyCount(): number { return splitWords(this.vocabulary).length; }
 
+  private readingLearner() {
+    const id=Number(this.route.snapshot.queryParamMap.get('studentId'));
+    return this.session.groupId()===3 && id ? {userId:id,groupId:1,honeyName:this.session.student()?.userId===id?this.session.student()?.honeyName:''} : this.session.user();
+  }
+
   ngOnInit(): void {
     const raw = Object.fromEntries(this.route.snapshot.queryParamMap.keys.map((key) => {
       const values = this.route.snapshot.queryParamMap.getAll(key);
       return [key, values.length > 1 ? values : values[0]];
     }));
     const params = normalizeReadingParams(raw);
-    const appUser = this.session.student() ?? this.session.user();
+    const appUser = this.readingLearner();
     const ncloudUser = this.cloud.user;
     this.nickname = ncloudUser?.nickname || params.name || String(appUser?.honeyName || appUser?.userName || '');
     this.mobile = ncloudUser?.mobile || params.phone || String(appUser?.mobile || appUser?.userName || '');
@@ -107,6 +113,8 @@ export class ReadingSetupComponent implements OnInit {
       this.type = selectedType;
       this.theme = selectedTheme;
       const params: ReadingRouteParams = {
+        studentId:this.readingLearner()?.userId,
+        sourceStudyId:this.route.snapshot.queryParamMap.get('GeneralID')||'',
         name: this.nickname, phone: this.mobile, level: this.level, wordList: words,
         type: selectedType, theme: selectedTheme, t: Date.now(),
       };
@@ -156,12 +164,12 @@ export class ReadingSetupComponent implements OnInit {
     const params = entry.article.createOptions.params;
     this.closeHistory();
     await this.router.navigate(['/words/reading'], {
-      queryParams: { ...params, wordList: params.wordList.join(','), t: Date.now() },
+      queryParams: { ...params, studentId:this.readingLearner()?.userId, sid:entry.article.objectId, wordList: params.wordList.join(','), t: Date.now() },
     });
   }
 
   private async ensureUser(): Promise<void> {
-    const appUser = this.session.student() ?? this.session.user();
+    const appUser = this.readingLearner();
     await this.cloud.ensureUser(appUser, this.mobile);
   }
 

+ 2 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading.models.ts

@@ -69,6 +69,8 @@ export interface ReadingLog extends ReadingRecord {
 }
 
 export interface ReadingRouteParams {
+  studentId?: number;
+  sourceStudyId?: string;
   name: string;
   phone: string;
   level: string;

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading.utils.spec.ts

@@ -12,7 +12,7 @@ describe('reading utilities', () => {
   });
 
   it('maps grade aliases and keeps the original article length rules', () => {
-    expect(readingStage('九年级')).toBe('小学');
+    expect(readingStage('九年级')).toBe('初中');
     expect(readingStage('高二')).toBe('高中');
     expect(readingStage('六年级')).toBe('小学');
     expect(readingWordLimit('初三', 40)).toBe(410);

+ 3 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading.utils.ts

@@ -23,6 +23,8 @@ export function normalizeReadingParams(value: Record<string, unknown>): ReadingR
   const params = { ...nested, ...value };
   delete params['params'];
   return {
+    studentId:Number(params['studentId'])||undefined,
+    sourceStudyId:String(params['GeneralID']||params['sourceStudyId']||''),
     name: String(params['name'] ?? params['nickname'] ?? ''),
     phone: String(params['phone'] ?? params['mobile'] ?? ''),
     level: String(params['level'] ?? '九年级'),
@@ -37,7 +39,7 @@ export function normalizeReadingParams(value: Record<string, unknown>): ReadingR
 }
 
 export function readingStage(level: string): '小学' | '初中' | '高中' {
-  if (level.includes('初')) return '初中';
+  if (/初|七年级|八年级|九年级|[789]年级/.test(level)) return '初中';
   if (level.includes('高')) return '高中';
   return '小学';
 }

+ 6 - 50
projects/xiaoshu-mobile/src/app/features/finance/finance-page.component.html

@@ -1,52 +1,8 @@
 <section class="finance-page">
-  <header class="balance-band">
-    <span>当前可用{{ unitName }}</span>
-    <strong>{{ balance() | number:'1.2-2' }}</strong>
-    <div class="balance-actions">
-      @if (!isPoint) { <button type="button" (click)="open('recharge')"><lucide-icon [img]="icons.ArrowDownToLine" [size]="18" />充值</button> }
-      @if (!isCoin) { <button type="button" (click)="open('transfer')"><lucide-icon [img]="icons.Send" [size]="18" />转赠</button> }
-      @if (!isPoint && !isCoin) { <button type="button" (click)="open('cash')"><lucide-icon [img]="icons.ArrowUpRight" [size]="18" />提现</button> }
-    </div>
-  </header>
-
-  <div class="log-heading"><strong><lucide-icon [img]="icons.History" [size]="18" />账户明细</strong><button type="button" title="刷新" aria-label="刷新" (click)="load()"><lucide-icon [img]="icons.RefreshCw" [size]="18" /></button></div>
-  <div class="finance-logs">
-    @for (item of logs(); track item['ExpHisID'] ?? item['ID'] ?? $index) {
-      <article>
-        <div><strong>{{ logTitle(item) }}</strong><span>{{ logTime(item) }}</span></div>
-        <em [class.income]="logAmount(item) > 0">{{ logAmount(item) > 0 ? '+' : '' }}{{ logAmount(item) | number:'1.2-2' }}</em>
-      </article>
-    } @empty {
-      <div class="empty-state"><p>{{ loading() ? '正在加载明细...' : '暂无账户明细' }}</p></div>
-    }
-  </div>
-
-  @if (mode()) {
-    <div class="modal-backdrop" (click)="close()">
-      <form class="finance-modal" (click)="$event.stopPropagation()" (ngSubmit)="mode() === 'recharge' ? recharge() : mode() === 'transfer' ? transfer() : cashOut()">
-        <header><strong>{{ modalTitle }}</strong><button type="button" title="关闭" aria-label="关闭" (click)="close()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
-
-        @if (mode() === 'recharge') {
-          @if (isCoin) {
-            <label><span>学习豆套餐</span><div class="coin-package"><strong>399 学习豆</strong><input type="number" min="1" step="1" name="coinCount" [(ngModel)]="coinCount" /></div></label>
-          } @else {
-            <label><span>充值金额</span><input type="number" min="0.01" step="0.01" name="rechargeAmount" [(ngModel)]="rechargeAmount" placeholder="请输入充值金额" /></label>
-          }
-          <fieldset><legend>支付方式</legend><label class="radio-row"><input type="radio" name="provider" value="wxpay" [(ngModel)]="provider" /><span>微信支付</span></label><label class="radio-row"><input type="radio" name="provider" value="alipay" [(ngModel)]="provider" /><span>支付宝</span></label></fieldset>
-        } @else if (mode() === 'transfer') {
-          <label><span>会员 ID 或用户名</span><input name="targetUser" [(ngModel)]="targetUser" placeholder="优先填写会员 ID" /></label>
-          <label><span>赠送数量</span><input type="number" min="0.01" step="0.01" name="transferAmount" [(ngModel)]="transferAmount" /></label>
-          <label><span>备注</span><textarea name="remark" [(ngModel)]="remark"></textarea></label>
-          @if (!isPoint) { <label><span>二级支付密码</span><input type="password" name="payPassword" [(ngModel)]="payPassword" autocomplete="current-password" /></label> }
-        } @else {
-          <label><span>提现金额</span><input type="number" min="0.01" step="0.01" name="cashAmount" [(ngModel)]="cashAmount" /></label>
-          <label><span>提现账户</span><select name="bankId" [(ngModel)]="bankId"><option value="">请选择账户</option>@for (bank of banks(); track bank['ID'] ?? $index) { <option [value]="bank['ID']">{{ bankTitle(bank) }}</option> }</select></label>
-          <a routerLink="/pages/member/purse_outacc" class="account-link"><lucide-icon [img]="icons.CreditCard" [size]="17" />管理提现账户</a>
-          <label><span>二级支付密码</span><input type="password" name="cashPassword" [(ngModel)]="payPassword" autocomplete="current-password" /></label>
-        }
-
-        <button type="submit" class="primary-button submit-button" [disabled]="submitting()"><lucide-icon [img]="icons.WalletCards" [size]="18" />{{ submitting() ? '处理中...' : '确认提交' }}</button>
-      </form>
-    </div>
-  }
+  <header><h2>课时账户</h2><p>课时用于学习,预留课时在预约结束或取消后更新。</p></header>
+  <div class="credit-grid">@for(account of accounts();track account.account){<button type="button" class="credit-card" [class.selected]="selected()===account.account" (click)="choose(account.account)"><strong>{{account.label}}</strong><b>{{account.available | number:'1.0-2'}} <small>{{account.unit}}可用</small></b><span>余额 {{account.balance | number:'1.0-2'}} · 已预留 {{account.reserved | number:'1.0-2'}}</span></button>}</div>
+  @if(error()){<div class="credit-error" role="alert">{{error()}} <button type="button" (click)="load()">重新加载</button></div>}
+  <div class="log-heading"><strong>课时明细</strong><button type="button" (click)="load()" [disabled]="loading()">刷新</button></div>
+  <div class="finance-logs">@for(item of logs();track item['ExpHisID'] ?? $index){<article><div><strong>{{item['Detail'] || item['Remark'] || '课时变动'}}</strong><span>{{date(item['HisTime'])}}</span></div><em>{{amount(item['Score']) | number:'1.0-2'}}</em></article>}@empty{<p>{{loading()?'正在加载…':error()?'':'暂无课时明细'}}</p>}</div>
+  <nav class="mobile-pagination"><button (click)="turn(-1)" [disabled]="loading()||page()<=1">上一页</button><span>{{page()}} / {{pages()}}</span><button (click)="turn(1)" [disabled]="loading()||page()>=pages()">下一页</button></nav>
 </section>

+ 1 - 36
projects/xiaoshu-mobile/src/app/features/finance/finance-page.component.scss

@@ -1,36 +1 @@
-.finance-page { min-height: 100%; }
-.balance-band { padding: 25px 18px 18px; color: white; background: var(--brand-strong); }
-.balance-band > span { font-size: 12px; opacity: .82; }
-.balance-band > strong { display: block; margin: 7px 0 18px; font-size: 34px; letter-spacing: 0; }
-.balance-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
-.balance-actions button { display: flex; min-height: 40px; align-items: center; justify-content: center; color: #174b40; background: white; border: 0; border-radius: var(--radius-md); gap: 5px; }
-.balance-actions button:only-child { grid-column: 1 / -1; }
-.log-heading { display: flex; min-height: 52px; align-items: center; justify-content: space-between; padding: 0 15px; background: white; border-bottom: 1px solid var(--line); }
-.log-heading strong { display: flex; align-items: center; gap: 7px; }
-.log-heading button { display: grid; width: 38px; height: 38px; padding: 0; color: var(--brand-strong); background: transparent; border: 0; place-items: center; }
-.finance-logs { background: white; }
-.finance-logs article { display: flex; min-height: 68px; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--line); gap: 15px; }
-.finance-logs article > div { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
-.finance-logs article strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
-.finance-logs article span { color: var(--ink-soft); font-size: 11px; }
-.finance-logs em { color: var(--danger); font-size: 16px; font-style: normal; font-weight: 700; white-space: nowrap; }
-.finance-logs em.income { color: var(--brand-strong); }
-.modal-backdrop { position: fixed; z-index: 80; inset: 0; display: flex; align-items: flex-end; justify-content: center; padding: 16px; background: rgb(15 28 25 / 48%); }
-.finance-modal { width: min(100%, 520px); max-height: min(720px, calc(100dvh - 32px)); padding: 18px; overflow: auto; background: white; border-radius: 8px; }
-.finance-modal > header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 17px; }
-.finance-modal > header strong { font-size: 18px; }
-.finance-modal > header button { display: grid; width: 36px; height: 36px; padding: 0; color: var(--ink-soft); background: transparent; border: 0; place-items: center; }
-.finance-modal > label { display: grid; margin-bottom: 14px; gap: 7px; }
-.finance-modal > label > span, fieldset legend { color: var(--ink-soft); font-size: 12px; }
-.finance-modal input, .finance-modal textarea, .finance-modal select { width: 100%; min-height: 45px; padding: 9px 11px; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: var(--radius-sm); }
-.finance-modal textarea { min-height: 80px; resize: vertical; }
-.finance-modal fieldset { margin: 0 0 15px; padding: 0; border: 0; }
-.finance-modal fieldset legend { margin-bottom: 7px; }
-.radio-row { display: flex; min-height: 44px; align-items: center; padding: 0 10px; border: 1px solid var(--line); }
-.radio-row + .radio-row { border-top: 0; }
-.radio-row input { width: 18px; min-height: 18px; margin-right: 9px; }
-.coin-package { display: grid; grid-template-columns: minmax(0, 1fr) 92px; align-items: center; padding: 10px 12px; color: #6a4a11; background: #fff7e7; border: 1px solid #f0d28f; border-radius: var(--radius-md); gap: 10px; }
-.coin-package input { text-align: center; }
-.account-link { display: flex; width: fit-content; align-items: center; margin: -5px 0 15px; color: var(--brand-strong); font-size: 13px; gap: 5px; }
-.submit-button { display: flex; width: 100%; min-height: 49px; align-items: center; justify-content: center; margin-top: 8px; gap: 6px; }
-@media (min-width: 720px) { .modal-backdrop { align-items: center; } }
+.finance-page{max-width:1100px;margin:24px auto;padding:24px;border:1px solid var(--border-color,#dce6e2);border-radius:20px;background:white}header p,.credit-card span,.finance-logs span{color:#71827c}.credit-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin:24px 0}.credit-card{display:flex;flex-direction:column;gap:12px;text-align:left;border:1px solid #dce6e2;border-radius:16px;padding:20px;background:#fff;color:#203d34;cursor:pointer}.credit-card.selected{border-color:#18745d;background:#edf8f3}.credit-card b{font-size:24px}.credit-card small{font-size:12px}.credit-error{padding:16px;background:#fff0ed;color:#a73c30;border-radius:12px}.log-heading,.finance-logs article{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px 0}.finance-logs article{border-bottom:1px solid #e7eeeb}.finance-logs article div{display:flex;flex-direction:column;gap:8px}.finance-logs em{font-style:normal;color:#18745d}button{font:inherit}.log-heading button,.credit-error button{padding:8px 16px;border:1px solid #dce6e2;border-radius:10px;background:white}@media(max-width:700px){.finance-page{margin:16px;padding:16px}.credit-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.credit-card{padding:14px}}

+ 23 - 234
projects/xiaoshu-mobile/src/app/features/finance/finance-page.component.ts

@@ -1,238 +1,27 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
-import { FormsModule } from '@angular/forms';
-import { ActivatedRoute, RouterLink } from '@angular/router';
-import { ArrowDownToLine, ArrowUpRight, CreditCard, History, LucideAngularModule, RefreshCw, Send, WalletCards, X } from 'lucide-angular';
-import { finalize } from 'rxjs';
+import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { finalize, Subscription } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
-import { PageMeta } from '../../core/models';
-import { PaymentProvider, PaymentService } from '../../core/payment.service';
-import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
-
-type FinanceMode = 'recharge' | 'transfer' | 'cash' | null;
-type SourceItem = Record<string, unknown>;
-
-@Component({
-  selector: 'app-finance-page',
-  standalone: true,
-  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
-  templateUrl: './finance-page.component.html',
-  styleUrl: './finance-page.component.scss',
-  changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class FinancePageComponent implements OnInit {
-  readonly meta = inject(ActivatedRoute).snapshot.data['meta'] as PageMeta;
-  readonly session = inject(SessionService);
-  readonly logs = signal<SourceItem[]>([]);
-  readonly banks = signal<SourceItem[]>([]);
-  readonly addon = signal<SourceItem>({});
-  readonly loading = signal(false);
-  readonly submitting = signal(false);
-  readonly mode = signal<FinanceMode>(null);
-  readonly icons = { ArrowDownToLine, ArrowUpRight, CreditCard, History, RefreshCw, Send, WalletCards, X };
-  readonly balance = computed(() => {
-    const user = this.session.user();
-    if (this.isCoin) return Number(this.addon()['silverCoin'] ?? this.addon()['SilverCoin'] ?? user?.silverCoin ?? 0);
-    if (this.isPoint) return Number(user?.point ?? 0);
-    return Number(user?.purse ?? 0);
-  });
-
-  rechargeAmount = 0;
-  coinCount = 1;
-  provider: PaymentProvider = 'wxpay';
-  targetUser = '';
-  transferAmount = 0;
-  remark = '';
-  payPassword = '';
-  cashAmount = 0;
-  bankId = '';
-
-  private readonly api = inject(ApiService);
-  private readonly route = inject(ActivatedRoute);
-  private readonly payment = inject(PaymentService);
-  private readonly platform = inject(PlatformService);
-
-  ngOnInit(): void {
-    if (this.isCoin && this.route.snapshot.queryParamMap.get('charge') === '1') this.mode.set('recharge');
-    this.load();
-  }
-
-  get isCoin(): boolean { return this.meta.path === 'pages/member/coin'; }
-  get isPoint(): boolean { return this.meta.path === 'pages/member/point'; }
-  get stype(): number { return this.isPoint ? 3 : this.isCoin ? 2 : 1; }
-  get unitName(): string { return this.isPoint ? '学习令' : this.isCoin ? '学习豆' : '余额'; }
-  get modalTitle(): string { return this.mode() === 'recharge' ? `充值${this.unitName}` : this.mode() === 'transfer' ? `转赠${this.unitName}` : '申请提现'; }
-
-  load(): void {
-    this.loading.set(true);
-    const uid = this.session.user()?.userId;
-    this.api.get<unknown>('user_point_list', { uid, stype: this.stype, cpage: 1, psize: 100 }).pipe(finalize(() => this.loading.set(false))).subscribe({
-      next: ({ result }) => this.logs.set(this.findList(result)),
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
-    });
-    this.api.get<unknown>('user_get', { uid }).subscribe({
-      next: ({ result, addon }) => {
-        const user = (Array.isArray(result) ? result[0] : result) as SourceItem;
-        if (user && typeof user === 'object') this.session.patchUser(user);
-        const sourceAddon = this.asRecord(Array.isArray(addon) ? addon[0] : addon);
-        this.addon.set(sourceAddon);
-        this.session.patchUser({ silverCoin: Number(sourceAddon['silverCoin'] ?? sourceAddon['SilverCoin'] ?? this.session.user()?.silverCoin ?? 0) });
-      },
-      error: () => undefined,
-    });
-    if (!this.isPoint && !this.isCoin) this.loadBanks();
-  }
-
-  open(mode: Exclude<FinanceMode, null>): void {
-    this.mode.set(mode);
-    this.payPassword = '';
-    if (mode === 'cash') this.loadBanks();
-  }
-
-  close(): void {
-    this.mode.set(null);
-  }
-
-  recharge(): void {
-    const money = this.isCoin ? 399 * Math.max(1, Number(this.coinCount)) : Number(this.rechargeAmount);
-    if (!Number.isFinite(money) || money <= 0) {
-      this.platform.toast('请输入大于 0 的充值金额', 'error');
-      return;
-    }
-    const action = this.isCoin ? 'user_coin_recharge' : 'user_money_recharge';
-    this.submitting.set(true);
-    this.api.post<unknown>(action, {
-      appId: 3,
-      uid: this.session.user()?.userId,
-      money,
-      use_alipay: this.provider === 'alipay' ? 1 : 0,
-    }).subscribe({
-      next: async ({ result }) => {
-        try {
-          const status = await this.payment.pay(this.provider, result);
-          if (status === 'paid') {
-            this.platform.toast('支付成功', 'success');
-            this.close();
-            this.load();
-          }
-        } catch (error) {
-          this.platform.toast(error instanceof Error ? error.message : '支付失败', 'error');
-        } finally {
-          this.submitting.set(false);
-        }
-      },
-      error: (error: ApiError) => {
-        this.submitting.set(false);
-        this.platform.toast(`充值单生成失败:${error.message}`, 'error');
-      },
-    });
-  }
-
-  transfer(): void {
-    const target = this.targetUser.trim();
-    const money = Number(this.transferAmount);
-    if (!target) {
-      this.platform.toast('请填写会员 ID 或用户名', 'error');
-      return;
-    }
-    if (!Number.isFinite(money) || money <= 0) {
-      this.platform.toast('赠送数量必须大于 0', 'error');
-      return;
-    }
-    if (money > this.balance()) {
-      this.platform.toast(`${this.unitName}不足`, 'error');
-      return;
-    }
-    if (!this.isPoint && !this.payPassword) {
-      this.platform.toast('请输入二级支付密码', 'error');
-      return;
-    }
-    const action = this.isPoint ? 'user_exp_transfer' : 'user_money_transfer';
-    this.submitting.set(true);
-    this.api.post(action, {
-      uid: this.session.user()?.userId,
-      touser: target,
-      money,
-      remark: this.remark,
-      passwd: this.isPoint ? undefined : this.payPassword,
-    }).pipe(finalize(() => this.submitting.set(false))).subscribe({
-      next: () => this.actionComplete('转赠成功'),
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
-    });
-  }
-
-  cashOut(): void {
-    const money = Number(this.cashAmount);
-    if (!Number.isFinite(money) || money <= 0) {
-      this.platform.toast('请输入大于 0 的提现金额', 'error');
-      return;
-    }
-    if (money > this.balance()) {
-      this.platform.toast('余额不足', 'error');
-      return;
-    }
-    if (!this.bankId) {
-      this.platform.toast('请选择提现账户', 'error');
-      return;
-    }
-    if (!this.payPassword) {
-      this.platform.toast('请输入二级支付密码', 'error');
-      return;
-    }
-    this.submitting.set(true);
-    this.api.post('user_cash_add', {
-      uid: this.session.user()?.userId,
-      money,
-      bankid: this.bankId,
-      passwd: this.payPassword,
-    }).pipe(finalize(() => this.submitting.set(false))).subscribe({
-      next: () => this.actionComplete('提现申请已提交'),
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
-    });
-  }
-
-  logAmount(item: SourceItem): number {
-    return Number(item['score'] ?? item['Exp'] ?? item['Money'] ?? item['money'] ?? item['HisExp'] ?? 0);
-  }
-
-  logTitle(item: SourceItem): string {
-    return String(item['HisInfo'] ?? item['Remark'] ?? item['remark'] ?? item['Title'] ?? '账户变动');
-  }
-
-  logTime(item: SourceItem): string {
-    return String(item['HisTime'] ?? item['CreateTime'] ?? item['AddTime'] ?? '').replace('T', ' ').slice(0, 16);
-  }
-
-  bankTitle(bank: SourceItem): string {
-    return `${String(bank['CardType'] ?? '')} ${String(bank['BankName'] ?? '')} ${String(bank['CardNum'] ?? '')}`.trim();
-  }
-
-  private loadBanks(): void {
-    this.api.get<unknown>('user_bank_list', { uid: this.session.user()?.userId, cpage: 1, psize: 100 }).subscribe({
-      next: ({ result }) => this.banks.set(this.findList(result)),
-      error: () => undefined,
-    });
-  }
-
-  private actionComplete(message: string): void {
-    this.platform.toast(message, 'success');
-    this.targetUser = '';
-    this.transferAmount = 0;
-    this.cashAmount = 0;
-    this.remark = '';
-    this.payPassword = '';
-    this.close();
-    this.load();
-  }
-
-  private findList(value: unknown): SourceItem[] {
-    if (Array.isArray(value)) return value as SourceItem[];
-    if (value && typeof value === 'object') for (const child of Object.values(value)) if (Array.isArray(child)) return child as SourceItem[];
-    return [];
-  }
-
-  private asRecord(value: unknown): SourceItem {
-    return value && typeof value === 'object' && !Array.isArray(value) ? value as SourceItem : {};
-  }
+interface CreditAccount { account: string; label: string; historyType: number; balance: number; reserved: number; available: number; unit: string }
+@Component({selector:'app-finance-page',standalone:true,imports:[CommonModule],templateUrl:'./finance-page.component.html',styleUrl:'./finance-page.component.scss',changeDetection:ChangeDetectionStrategy.OnPush})
+export class FinancePageComponent implements OnInit, OnDestroy {
+  readonly accounts=signal<CreditAccount[]>([]); readonly logs=signal<Record<string,unknown>[]>([]);
+  readonly selected=signal('Purse'); readonly page=signal(1); readonly pages=signal(1); readonly loading=signal(false); readonly error=signal('');
+  private readonly api=inject(ApiService); private readonly session=inject(SessionService); private readonly route=inject(ActivatedRoute);
+  private request?:Subscription; private requestId=0;
+  ngOnInit():void { const route=this.route.snapshot;this.selected.set(route.queryParamMap.get('account')|| (route.routeConfig?.path?.endsWith('/coin')?'SilverCoin':route.routeConfig?.path?.endsWith('/point')?'UserPoint':'Purse'));this.load(); }
+  ngOnDestroy():void{this.requestId++;this.request?.unsubscribe();}
+  choose(account:string):void{this.selected.set(account);this.page.set(1);this.load();}
+  turn(delta:number):void{this.page.update(p=>Math.max(1,Math.min(this.pages(),p+delta)));this.load();}
+  load():void{
+    this.request?.unsubscribe();const id=++this.requestId;this.loading.set(true);this.error.set('');
+    this.request=this.api.get<{accounts:CreditAccount[]}>('app_credit_overview',{uid:this.session.user()?.userId}).subscribe({next:({result})=>{
+      if(id!==this.requestId)return;this.accounts.set(result.accounts);const current=result.accounts.find(a=>a.account===this.selected());if(!current){this.error.set('课时账户不存在');this.loading.set(false);return;}
+      this.request=this.api.get<unknown>('user_point_list',{uid:this.session.user()?.userId,stype:current.historyType,cpage:this.page(),psize:20}).pipe(finalize(()=>{if(id===this.requestId)this.loading.set(false);})).subscribe({next:r=>{if(id!==this.requestId)return;this.logs.set(Array.isArray(r.result)?r.result:[]);this.pages.set(Number(r.page?.pageCount)||1);},error:(e:ApiError)=>this.error.set(e.message)});
+    },error:(e:ApiError)=>{this.error.set(e.message);this.loading.set(false);}});
+  }
+  amount(value:unknown):number{return Number(value)||0;}
+  date(value:unknown):string{return value?new Date(String(value)).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}):'—';}
 }

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

@@ -36,9 +36,9 @@
     }
   </section>
   @if (session.groupId() === 3) {
-    <section class="wallet-strip coach-wallet-strip"><a routerLink="/pages/member/purse"><strong>¥{{ session.user()?.purse || 0 }}</strong><span>钱包余额</span></a><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ coachPanel().reviewIncome | number:'1.0-2' }}</strong><span>抗遗忘工资</span></a><a routerLink="/pages/member/feedback"><strong>申请</strong><span>休息时间</span></a></section>
+    <section class="wallet-strip coach-wallet-strip"><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ coachPanel().reviewIncome | number:'1.0-2' }}</strong><span>抗遗忘工资</span></a><a routerLink="/pages/member/feedback"><strong>申请</strong><span>休息时间</span></a></section>
   } @else {
-    <section class="wallet-strip"><a routerLink="/pages/member/point"><strong>{{ session.user()?.point || 0 }}</strong><span>学习令</span></a><a routerLink="/pages/member/coin"><strong>{{ session.user()?.silverCoin || 0 }}</strong><span>学习豆</span></a><a routerLink="/pages/member/purse"><strong>{{ session.user()?.purse || 0 }}</strong><span>钱包余额</span></a></section>
+    <section class="wallet-strip"><a routerLink="/pages/member/purse"><strong>查看</strong><span>课时余额与预留</span></a><a routerLink="/pages/member/point"><strong>{{ session.user()?.['UserPoint'] || 0 }}</strong><span>可查陪练时长</span></a></section>
   }
   @if (session.groupId() === 3) {
     <section class="role-panel"><header><h2>陪练专用</h2><a routerLink="/pages/member/feedback">休息时间申请</a></header><div class="order-strip coach-strip"><a routerLink="/pages/home/shop"><strong>{{ coachPanel().todaySchedules }}</strong><span>今日排课</span></a><a routerLink="/pages/member/ke_xiao"><strong>{{ coachPanel().completedLessons }}</strong><span>销课记录</span></a><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ coachPanel().reviewIncome | number:'1.0-2' }}</strong><span>抗遗忘工资</span></a></div></section>

+ 6 - 12
projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts

@@ -1,3 +1,4 @@
+import { ReviewPage, reviewOverview } from '../../core/review-page';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
 import { Router, RouterLink } from '@angular/router';
@@ -29,7 +30,8 @@ export class HomePageComponent implements OnInit {
   readonly antiForgetItems = signal<Item[]>([]);
   readonly antiForgetLoading = signal(false);
   readonly antiForgetError = signal('');
-  readonly antiForgetView = computed(() => buildAntiForgetView(this.antiForgetItems()));
+  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) => {
     const stateOrder = (item: Item) => antiForgetCompleted(item) ? 2 : this.coachReviewReady(item) ? 0 : 1;
@@ -38,15 +40,7 @@ export class HomePageComponent implements OnInit {
     const dateDifference = antiForgetDate(left).localeCompare(antiForgetDate(right));
     return stateOrder(left) === 1 ? dateDifference : -dateDifference;
   }));
-  readonly coachReviewSummary = computed(() => {
-    const today = this.today();
-    const items = this.antiForgetItems().filter((item) => antiForgetDate(item));
-    return {
-      today: items.filter((item) => antiForgetDate(item) === today).length,
-      due: items.filter((item) => !antiForgetCompleted(item) && antiForgetDate(item) <= today).length,
-      completed: items.filter(antiForgetCompleted).length,
-    };
-  });
+  readonly coachReviewSummary=computed(()=>this.reviewData()?.summary||{today:0,due:0,completed:0});
   readonly icons = { AlertCircle, BarChart3, BookOpen, CalendarDays, Check, ChevronRight, Clock3, Eye, EyeOff, GraduationCap, Headphones, Info, RefreshCw, Search, Sparkles, UserRound, Zap };
 
   private readonly api = inject(ApiService);
@@ -153,10 +147,10 @@ export class HomePageComponent implements OnInit {
   private loadAntiForget(uid: string | number): void {
     this.antiForgetLoading.set(true);
     this.antiForgetError.set('');
-    this.api.post<unknown>('e_get_21list', { uid, shouru: this.isCoach() ? 1 : undefined, cpage: 1, psize: this.isCoach() ? 500 : 100 }).pipe(
+    this.api.post<ReviewPage>('app_review_page', { uid, page:1,pageSize:20,status:'pending' }).pipe(
       finalize(() => this.antiForgetLoading.set(false)),
     ).subscribe({
-      next: ({ result }) => this.antiForgetItems.set(findAntiForgetList(result)),
+      next: ({ result }) => {this.reviewData.set(result);this.antiForgetItems.set(result.items);},
       error: () => this.antiForgetError.set('抗遗忘计划暂时无法加载'),
     });
   }

+ 3 - 2
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -146,7 +146,7 @@
                   }
                 </article>
               } @empty {
-                <div class="word-column-empty"><lucide-icon [img]="icons.CheckCheck" [size]="24" /><span>这个词库已经全部学完</span></div>
+                <div class="word-column-empty"><lucide-icon [img]="icons.CheckCheck" [size]="24" /><span>{{selectionTotal() > words().length ?'已加载的词已学完,可继续加载更多':'这个词库已经全部学完'}}</span></div>
               }
             </div>
           </section>
@@ -196,6 +196,7 @@
         <button type="button" class="danger-button" (click)="deleteSelectedNewWords()"><lucide-icon [img]="icons.Trash2" [size]="18" />删除所选</button>
       </div>
     }
+    @if(selectionTotal()>0){<div class="mobile-pagination"><span>已加载 {{words().length}} / {{selectionTotal()}} 个单词</span>@if(words().length<selectionTotal()){<button (click)="loadMoreSelection()" [disabled]="selectionLoading()">{{selectionLoading()?'加载中…':'加载更多单词'}}</button>}</div>}
   } @else if (isStudyListPage) {
     <div class="stage-banner">
       <div>
@@ -485,7 +486,7 @@
   } @else {
     <div class="empty-state"><div><lucide-icon [img]="icons.BookOpen" [size]="34" /><p>暂无学习内容</p></div></div>
   }
-</section>
+@if(isCalendarPage){<nav class="mobile-pagination"><button (click)="turnReviewPage(-1)" [disabled]="loading()||reviewListPage()<=1">上一页</button><span>共 {{reviewPageData()?.page?.itemCount||0}} 项 · 第 {{reviewListPage()}} 页</span><button (click)="turnReviewPage(1)" [disabled]="loading()||reviewListPage()>=(reviewPageData()?.page?.pageCount||1)">下一页</button></nav>}</section>
 
 <ng-template #stageComplete let-title="title" let-text="text">
   <div class="report compact-report">

+ 53 - 77
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -1,3 +1,5 @@
+import { LearningDraft } from '../../core/learning-draft';
+import { ReviewPage, reviewCalendar } from '../../core/review-page';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
@@ -23,7 +25,7 @@ import {
   Volume2,
   X,
 } from 'lucide-angular';
-import { catchError, finalize, forkJoin, Observable, of, switchMap, throwError } from 'rxjs';
+import { catchError, finalize, forkJoin, Observable, of, Subscription, switchMap, throwError } from 'rxjs';
 import { GAME_AUDIO } from '../../core/app.constants';
 import { ApiError, ApiService } from '../../core/api.service';
 import {
@@ -124,6 +126,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly unknown = signal<Word[]>([]);
   readonly loading = signal(false);
   readonly submitting = signal(false);
+  readonly selectionTotal=signal(0);readonly selectionPage=signal(1);readonly selectionLoading=signal(false);private selectionRequest?:Subscription;
   readonly error = signal('');
 
   readonly studyStep = signal<1 | 2 | 3>(1);
@@ -216,6 +219,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     : 0);
   readonly wordPreviewRangeEnd = computed(() => Math.min(this.wordPreviewPage() * this.wordPreviewPageSize, this.wordPreviewTotal()));
   readonly memoryStudents = computed(() => {
+    if(this.reviewPageData()?.students)return this.reviewPageData()!.students!.map(s=>({id:String(s.id),name:s.name,taskCount:s.taskCount}));
     const students = new Map<string, MemoryStudentOption>();
     for (const item of this.calendarItems()) {
       const id = String(item['yhid'] ?? '').trim();
@@ -227,17 +231,14 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return [...students.values()].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'));
   });
   readonly memorySelectedStudentName = computed(() => this.memoryStudents().find((student) => student.id === this.memoryStudentId())?.name || '全部学员');
+  readonly reviewPageData=signal<ReviewPage|null>(null); readonly reviewListPage=signal(1);
   readonly memoryFilteredItems = computed(() => {
     const studentId = this.memoryStudentId();
     return studentId ? this.calendarItems().filter((item) => String(item['yhid'] ?? '') === studentId) : this.calendarItems();
   });
   readonly memoryView = computed(() => buildAntiForgetView(this.memoryFilteredItems()));
   readonly memorySummary = computed(() => this.memoryView().summary);
-  readonly memoryCalendar = computed(() => buildAntiForgetCalendar(
-    this.memoryFilteredItems(),
-    this.memoryMonth(),
-    this.memorySelectedDate(),
-  ));
+  readonly memoryCalendar=computed(()=>reviewCalendar(this.reviewPageData()?.dates||[],this.memoryMonth(),this.memorySelectedDate()));
   readonly memorySelectedTasks = computed(() => antiForgetTasksOnDate(this.memoryFilteredItems(), this.memorySelectedDate()));
   readonly memorySelectedLabel = computed(() => {
     const value = this.memorySelectedDate();
@@ -245,11 +246,9 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const [year, month, day] = value.split('-').map(Number);
     return new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }).format(new Date(year, month - 1, day, 12));
   });
-  readonly memoryCompletedCount = computed(() => this.memoryFilteredItems().filter(antiForgetCompleted).length);
-  readonly memoryDueCount = computed(() => this.memoryFilteredItems().filter((item) => !antiForgetCompleted(item) && antiForgetDate(item) <= this.dateOnly()).length);
-  readonly memoryProgress = computed(() => this.memoryFilteredItems().length
-    ? Math.round((this.memoryCompletedCount() / this.memoryFilteredItems().length) * 100)
-    : 0);
+  readonly memoryCompletedCount = computed(() => this.reviewPageData()?.summary.completed||0);
+  readonly memoryDueCount = computed(() => this.reviewPageData()?.summary.due||0);
+  readonly memoryProgress=computed(()=>{const s=this.reviewPageData()?.summary;return s?.total?Math.round(s.completed/s.total*100):0;});
   readonly progress = computed(() => {
     const total = this.isMatchingPage ? this.words().length : this.words().length || this.questions().length;
     if (!total) return 0;
@@ -280,6 +279,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   ngOnDestroy(): void {
+    this.calendarRequestId++;this.selectionRequest?.unsubscribe();
     this.stopExamTimer();
     this.platform.stopAudio();
   }
@@ -393,6 +393,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   load(): void {
+    this.selectionRequest?.unsubscribe();this.selectionPage.set(1);this.selectionTotal.set(0);
     this.resetPageState();
     this.loading.set(true);
     this.error.set('');
@@ -485,6 +486,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       queryParams: {
         nids: [...this.selectedNodes()].join(','),
         pnid: this.query('nid'),
+        bindingId: this.query('bindingId'),
         orderId: this.query('orderId'),
       },
     });
@@ -798,7 +800,6 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       this.submitPracticeEnd();
       return;
     }
-    this.updateUnitRecord();
     this.saveLearningRecord();
   }
 
@@ -866,6 +867,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   endRecord(): void {
+    if(this.submitting())return;
     const now = this.dateTime();
     this.submitting.set(true);
     this.api.post('content_update', {
@@ -954,6 +956,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   selectMemoryDay(day: AntiForgetCalendarDay): void {
     this.memorySelectedDate.set(day.date);
+    this.reviewListPage.set(1);this.loadCalendar();
     if (!day.inCurrentMonth) {
       const [year, month] = day.date.split('-').map(Number);
       this.memoryMonth.set(new Date(year, month - 1, 1, 12));
@@ -965,8 +968,9 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const next = new Date(current.getFullYear(), current.getMonth() + amount, 1, 12);
     this.memoryMonth.set(next);
     const monthKey = `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, '0')}`;
-    const firstTask = this.calendarItems().map(antiForgetDate).filter((date) => date.startsWith(monthKey)).sort()[0];
+    const firstTask = (this.reviewPageData()?.dates||[]).map(row=>row.date).filter((date) => date.startsWith(monthKey)).sort()[0];
     this.memorySelectedDate.set(firstTask || `${monthKey}-01`);
+    this.reviewListPage.set(1);this.loadCalendar();
   }
 
   showCurrentMemoryMonth(): void {
@@ -974,14 +978,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const [year, month] = today.split('-').map(Number);
     this.memoryMonth.set(new Date(year, month - 1, 1, 12));
     this.memorySelectedDate.set(today);
+    this.reviewListPage.set(1);this.loadCalendar();
   }
 
   selectMemoryStudent(event: Event): void {
-    this.memoryStudentId.set((event.target as HTMLSelectElement).value);
-    const selectedDate = nearestAntiForgetDate(this.memoryFilteredItems());
-    const [year, month] = selectedDate.split('-').map(Number);
-    this.memorySelectedDate.set(selectedDate);
-    this.memoryMonth.set(new Date(year, month - 1, 1, 12));
+    this.memoryStudentId.set((event.target as HTMLSelectElement).value);this.reviewListPage.set(1);this.loadCalendar();
   }
 
   memoryTaskStatus(item: SourceItem): string {
@@ -1039,6 +1040,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   submitExam(): void {
+    if(this.submitting())return;
     this.stopExamTimer();
     const ask = this.askInfo();
     const user = this.session.user();
@@ -1113,21 +1115,13 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     });
   }
 
-  private loadSelection(): void {
+  loadMoreSelection():void{if(!this.selectionLoading()&&this.words().length<this.selectionTotal()){this.selectionPage.update(p=>p+1);this.loadSelection(true);}}
+  private loadSelection(more=false): void {
+    const accept=(result:unknown,total:unknown)=>{const incoming=this.findList(result).map(item=>this.meta.path==='pages/member/starshop'?this.toNewWord(item):this.toWord(item)).filter(w=>w.word);this.words.set(more?[...this.words(),...incoming]:incoming);this.selectionTotal.set(Number(total)||this.words().length);};
+    const failure=(error:ApiError)=>{if(more){this.selectionPage.update(p=>p-1);this.platform.toast(error.message,'error');}else this.error.set(error.message);};
     if (this.meta.path === 'pages/stu/stu_abc') {
-      const student = this.session.student() ?? this.session.user();
-      this.api.get<unknown>('e_ck_list', {
-        checkwords: 2,
-        nids: this.query('nids') || this.query('nid'),
-        modelId: 52,
-        cpage: 1,
-        psize: 300,
-        uid: student?.userId,
-      }).pipe(finalize(() => this.loading.set(false))).subscribe({
-        next: ({ result }) => this.words.set(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word)),
-        error: (error: ApiError) => this.error.set(error.message),
-      });
-      return;
+      const student = this.session.student() ?? this.session.user();this.selectionLoading.set(true);
+      this.selectionRequest=this.api.get<unknown>('e_ck_list',{checkwords:2,nids:this.query('nids')||this.query('nid'),modelId:52,cpage:this.selectionPage(),psize:100,uid:student?.userId}).pipe(finalize(()=>{this.selectionLoading.set(false);this.loading.set(false);})).subscribe({next:({result,page})=>accept(result,page?.itemCount),error:failure});return;
     }
 
     if (this.meta.path === 'pages/his/his_words') {
@@ -1145,10 +1139,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     }
 
     const owner = Number(this.session.user()?.groupId) === 3 ? this.session.student() : this.session.user();
-    this.api.get<unknown>('e_words_list', { uid: owner?.userId, cpage: 1, psize: 300 }).pipe(finalize(() => this.loading.set(false))).subscribe({
-      next: ({ result }) => this.words.set(this.findList(result).map((item) => this.toNewWord(item)).filter((word) => word.word)),
-      error: (error: ApiError) => this.error.set(error.message),
-    });
+    this.selectionLoading.set(true);
+    this.selectionRequest=this.api.get<unknown>('e_words_list',{uid:owner?.userId,cpage:this.selectionPage(),psize:100}).pipe(finalize(()=>{this.selectionLoading.set(false);this.loading.set(false);})).subscribe({next:({result,page})=>accept(result,page?.itemCount),error:failure});
   }
 
   private loadStageWords(): void {
@@ -1301,32 +1293,31 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       this.error.set('请先选择本次学习的单词');
       return;
     }
-    this.updateUnitRecord();
     const user = this.session.user();
     this.api.get<unknown>('content_list', {
       modelId: 56,
       psize: 2,
-      myfield2: `UserId=${user?.userId}|dqrq=${this.compactDate()}`,
+      myfield2: `UserId=${(this.canStartPointReading ? this.session.student() : user)?.userId}|dqrq=${this.compactDate()}`,
     }).subscribe({
       next: ({ result }) => this.learnedRecord.set(this.findList(result)[0] ?? {}),
-      error: () => undefined,
+      error: (error:ApiError) => this.error.set(error.message),
     });
   }
 
-  private updateUnitRecord(): void {
+  private updateUnitRecord(): Observable<unknown> {
     const nids = this.query('nids');
     const bookId = this.query('pnid');
-    if (!nids || !bookId) return;
+    if (!nids || !bookId) return of(null);
     const wordIds: Record<string, string> = {};
     for (const nodeId of nids.split(',')) {
       wordIds[nodeId] = this.words().filter((word) => word.nodeId === nodeId).map((word) => word.wordId).join(',');
     }
-    this.api.post('unit_record_update', {
+    return this.api.post('unit_record_update', {
       nids,
-      userId: this.session.user()?.userId,
+      userId: (this.canStartPointReading ? this.session.student() : this.session.user())?.userId,
       bookId,
       wordIds: JSON.stringify(wordIds),
-    }).subscribe({ error: () => undefined });
+    });
   }
 
   private saveLearningRecord(): void {
@@ -1371,6 +1362,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     if (!existing['xxqs']) addon['fxrl'] = '';
     this.submitting.set(true);
     this.persistWordsAsLearned(this.words(), owner).pipe(
+      switchMap(()=>this.updateUnitRecord()),
       switchMap(() => this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) })),
       finalize(() => this.submitting.set(false)),
     ).subscribe({
@@ -1542,24 +1534,12 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     });
   }
 
+  turnReviewPage(delta:number):void{this.reviewListPage.update(p=>Math.max(1,Math.min(this.reviewPageData()?.page.pageCount||1,p+delta)));this.loadCalendar();}
   private loadCalendar(): void {
-    const uid = this.session.user()?.userId;
-    const coachMode = Number(this.session.groupId()) === 3;
-    this.api.post<unknown>('e_get_21list', { uid, shouru: coachMode ? 1 : undefined, cpage: 1, psize: 500 })
-      .pipe(finalize(() => this.loading.set(false)))
-      .subscribe({
-      next: ({ result }) => {
-        const items = mergeAntiForgetItems(this.findList(result), []);
-        this.calendarItems.set(items);
-        const selectedDate = nearestAntiForgetDate(items);
-        const [year, month] = selectedDate.split('-').map(Number);
-        this.memorySelectedDate.set(selectedDate);
-        this.memoryMonth.set(new Date(year, month - 1, 1, 12));
-      },
-      error: () => this.error.set('抗遗忘数据暂时无法加载,请稍后重试'),
-    });
-    this.api.post('e_get_21list_tj', { uid }).subscribe({ error: () => undefined });
+    const requestId=++this.calendarRequestId;this.loading.set(true);
+    this.api.post<ReviewPage>('app_review_page',{uid:this.session.user()?.userId,studentId:this.memoryStudentId()||undefined,date:this.memorySelectedDate()||this.dateOnly(),page:this.reviewListPage(),pageSize:20}).pipe(finalize(()=>{if(requestId===this.calendarRequestId)this.loading.set(false);})).subscribe({next:({result})=>{if(requestId!==this.calendarRequestId)return;this.reviewPageData.set(result);this.calendarItems.set(result.items);if(!this.memorySelectedDate())this.memorySelectedDate.set(this.dateOnly());},error:(e:ApiError)=>{if(requestId===this.calendarRequestId)this.error.set(e.message);}});
   }
+  private calendarRequestId=0;
 
   private loadAntiForget(): void {
     const user = this.session.user();
@@ -1744,13 +1724,14 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.storageSet('_page_current_learn', 1);
     this.storageSet('_page_total_page', pages.length);
     this.storageSet('_page_word_orilist', pages);
-    sessionStorage.setItem('xiaoshu.selectedWords', JSON.stringify(pages[0] ?? chosen));
+    sessionStorage.setItem(this.learningStorageKey('selectedWords'), JSON.stringify(pages[0] ?? chosen));
     void this.router.navigate(['/pages/stu/stu_con'], {
       replaceUrl: true,
       state: { learningReturnUrl: this.router.url },
       queryParams: {
         nids: this.query('nids') || this.query('nid'),
         pnid: this.query('pnid'),
+        bindingId: this.query('bindingId'),
         orderId: this.query('orderId'),
       },
     });
@@ -1764,6 +1745,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
         nid: this.query('nid'),
         nids: this.query('nids'),
         pnid: this.query('pnid'),
+        bindingId: this.query('bindingId'),
         orderId: this.query('orderId') || this.query('id'),
       },
     });
@@ -1778,7 +1760,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
         const mode = String(this.storageGet('learn_mode') ?? '');
         const key = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn';
         this.storageSet(key, page);
-        sessionStorage.setItem('xiaoshu.selectedWords', JSON.stringify(page));
+        sessionStorage.setItem(this.learningStorageKey('selectedWords'), JSON.stringify(page));
         return page.map((item) => this.toWord(item as SourceItem));
       }
       return [];
@@ -1787,7 +1769,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const key = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn';
     const stored = this.parseArray(this.storageGet(key));
     if (stored.length) return stored.map((item) => this.toWord(item));
-    return this.parseArray(sessionStorage.getItem('xiaoshu.selectedWords')).map((item) => this.toWord(item));
+    return this.parseArray(sessionStorage.getItem(this.learningStorageKey('selectedWords'))).map((item) => this.toWord(item));
   }
 
   private readAllLearningWords(): Word[] {
@@ -1798,12 +1780,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return this.readLearningWords();
   }
 
-  private clearLearningStorage(): void {
-    for (const key of ['learn_mode', 'choose_word_learn', 'choose_newword_learn', 'choose_hisword_learn', '_page_word_orilist', '_page_total_page', '_page_current_learn']) {
-      localStorage.removeItem(`xiaoshu.learning.${key}`);
-    }
-    sessionStorage.removeItem('xiaoshu.selectedWords');
-  }
+  private clearLearningStorage(): void { this.draft().clear(); }
 
   private resetPageState(): void {
     this.stopExamTimer();
@@ -1917,6 +1894,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       wordsId: words.map((word) => word.wordId).join(','),
       kcid: this.query('pnid') || this.query('kcid') || this.orderInfo()['kcid'],
       save: 1,
+      requestKey:this.draft().command('learn:'+words.map(w=>w.wordId).sort().join(',')),
     });
   }
 
@@ -1959,15 +1937,13 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return copy;
   }
 
-  private storageGet(key: string): unknown {
-    const raw = localStorage.getItem(`xiaoshu.learning.${key}`);
-    if (raw === null) return null;
-    try { return JSON.parse(raw) as unknown; } catch { return raw; }
-  }
-
-  private storageSet(key: string, value: unknown): void {
-    localStorage.setItem(`xiaoshu.learning.${key}`, JSON.stringify(value));
+  private draft():LearningDraft {
+    const learner=this.canStartPointReading?this.session.student():this.session.user();
+    return new LearningDraft(`xiaoshu.learning.${this.session.user()?.userId}.${learner?.userId}.${this.query('orderId')||this.query('bindingId')||'practice'}`);
   }
+  private learningStorageKey(key:string):string{return this.draft().key(key);}
+  private storageGet(key:string):unknown{return this.draft().get(key);}
+  private storageSet(key:string,value:unknown):void{this.draft().set(key,value);}
 
   private query(name: string): string {
     return this.route.snapshot.queryParamMap.get(name) ?? '';

+ 5 - 0
projects/xiaoshu-mobile/src/app/features/resource/unavailable-page.component.ts

@@ -0,0 +1,5 @@
+import { Component, inject } from '@angular/core';
+import { ActivatedRoute, RouterLink } from '@angular/router';
+import { unavailableReason } from '../../core/feature-policy';
+@Component({standalone:true,imports:[RouterLink],template:'<section class="feature-unavailable"><h2>暂未开放</h2><p>{{reason}}</p><a routerLink="/pages/home/index">返回首页</a> <a routerLink="/pages/member/feedback">意见反馈</a></section>',styles:['.feature-unavailable{margin:24px;padding:28px;border:1px solid var(--line);border-radius:20px;background:white;line-height:1.8}']})
+export class UnavailablePageComponent {readonly reason=unavailableReason(inject(ActivatedRoute).snapshot.data['meta']);}

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

@@ -37,7 +37,7 @@
     }
   }
   @if (meta.path === 'pages/member/myorder_appeal') { <div class="record-tabs"><button type="button" [class.active]="activeTab() === 0" (click)="selectTab(0)">我的退货</button><button type="button" [class.active]="activeTab() === 1" (click)="selectTab(1)">我的换货</button></div> }
-  @if (meta.path === 'pages/member/ke_fuxi') { <div class="income-summary"><span>复习总收入</span><strong>¥{{ totalIncome() | number:'1.2-2' }}</strong><small>本统计仅作参考</small></div> }
+  @if (meta.path === 'pages/member/ke_fuxi') { <div class="income-summary"><span>抗遗忘工资合计</span><strong>¥{{ totalIncome() | number:'1.2-2' }}</strong><small>按首次完成的计薪凭据统计</small></div> }
   @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) {
@@ -48,7 +48,7 @@
     <section class="learning-record-card" aria-labelledby="learning-record-title">
       <header>
         <div><span id="learning-record-title">学习记录</span><strong>{{ selectedCalendarLabel() }}</strong></div>
-        <div class="record-actions"><span>{{ visibleItems().length }} 条@if (selectedSchedules().length) { · {{ selectedSchedules().length }} 个计划 }</span>@if (selectedDate) { <button type="button" (click)="showAllRecords()">显示全部</button> }</div>
+        <div class="record-actions"><span>{{ serverTotal() }} 条@if (selectedSchedules().length) { · {{ selectedSchedules().length }} 个计划 }</span>@if (selectedDate) { <button type="button" (click)="showAllRecords()">显示全部</button> }</div>
       </header>
       @if (visibleItems().length) {
         <div class="record-list">
@@ -87,9 +87,9 @@
         @if (meta.path === 'pages/member/myorder_appeal') {
           <article class="appeal-item"><header><strong>订单号:{{ item['OrderNo'] || '-' }}</strong><span>{{ appealStatus(item) }}</span></header><p>{{ item['PubContent'] }}</p>@if (appealImages(item).length) { <div class="appeal-images">@for (image of appealImages(item); track image) { <a [href]="image" target="_blank" rel="noopener"><img [src]="image" alt="售后凭证" /></a> }</div> }<footer><time>{{ time(item) }}</time>@if (item['PubRemind']) { <span>处理情况:{{ item['PubRemind'] }}</span> }</footer></article>
         } @else if (meta.path === 'pages/member/ghis') {
-          <article class="history-item" [class.complete]="item['num'] && item['num'] === item['yxx']"><lucide-icon [img]="item['num'] && item['num'] === item['yxx'] ? icons.CheckCircle2 : icons.Clock3" [size]="22" /><div><strong>{{ time(item) }}</strong><p>应复习 {{ item['num'] || 0 }} 个,已复习 {{ item['yxx'] || 0 }} 个,时长 {{ formatDuration(item['times']) }}</p></div><span>{{ item['num'] && item['num'] === item['yxx'] ? '已完成' : '未完成' }}</span></article>
+          <article class="history-item" [class.complete]="item['fxzt']==1"><lucide-icon [img]="item['fxzt']==1?icons.CheckCircle2:icons.Clock3" [size]="22"/><div><strong>{{title(item)}}</strong><p>计划日期:{{item['kywrq']||item['kywsj']}}</p></div><span>{{item['fxzt']==1?'已完成':'待复习'}}</span></article>
         } @else if (meta.path === 'pages/member/myke') {
-          <article class="progress-item" (click)="open(item)"><lucide-icon [img]="icons.RotateCcw" [size]="21" /><div><strong>{{ title(item) }}</strong><p>完成度:{{ item['yxx'] || 0 }}/{{ item['cksl2'] || item['cksl'] || 0 }}</p></div><span>复习</span></article>
+          <article class="progress-item" (click)="open(item)"><lucide-icon [img]="icons.RotateCcw" [size]="21" /><div><strong>{{ title(item) }}</strong><p>点击查看分册最新学习进度</p></div><span>复习</span></article>
         } @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 {
@@ -98,4 +98,4 @@
       }
     </div>
   } @else { <div class="records-state">暂无记录</div> }
-</section>
+@if(studentsHaveMore()){<button (click)="loadStudents(true)" [disabled]="studentsLoading()">加载更多学员</button>}@if(meta.path!=='pages/his/his' && serverTotal()>0){<nav class="mobile-pagination"><button (click)="goToRecordPage(recordPage()-1)" [disabled]="loading()||recordPage()<=1">上一页</button><span>共 {{serverTotal()}} 条 · 第 {{recordPage()}} 页</span><button (click)="goToRecordPage(recordPage()+1)" [disabled]="loading()||recordPage()>=recordPageCount()">下一页</button></nav>}</section>

+ 33 - 26
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -1,9 +1,10 @@
+import { ReviewPage } from '../../core/review-page';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, OnInit, OnDestroy, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router, RouterLink } from '@angular/router';
 import { AlertCircle, ArrowRight, BookOpen, CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Clock3, LoaderCircle, LucideAngularModule, RotateCcw, Sparkles } from 'lucide-angular';
-import { catchError, finalize, forkJoin, of } from 'rxjs';
+import { catchError, finalize, forkJoin, of, Subscription } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
 import { extractReadingWords } from '../../core/external-reading';
 import { canStartTeacherLedLearning } from '../../core/learning-access';
@@ -24,6 +25,7 @@ interface DatePickerDay {
 }
 
 interface LearningOverview {
+  page?:{itemCount:number;pageCount:number};
   records: Item[];
   latestReadingRecord?: Item | null;
   courses: Item[];
@@ -41,7 +43,8 @@ interface LearningOverview {
   styleUrl: './study-records-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class StudyRecordsPageComponent implements OnInit {
+export class StudyRecordsPageComponent implements OnInit, OnDestroy {
+  ngOnDestroy():void{this.historyRequestId++;this.historyRequest?.unsubscribe();}
   readonly meta = inject(ActivatedRoute).snapshot.data['meta'] as PageMeta;
   readonly items = signal<Item[]>([]);
   readonly courses = signal<Item[]>([]);
@@ -60,6 +63,8 @@ export class StudyRecordsPageComponent implements OnInit {
   readonly scheduleDateCounts = signal<Record<string, { planned: number; missed: number }>>({});
   readonly overviewReadingRecord = signal<Item | null>(null);
   readonly readingLoadingId = signal('');
+  readonly serverTotal=signal(0);private historyRequest?:Subscription;private historyRequestId=0;
+  readonly studentListPage=signal(1);readonly studentsHaveMore=signal(false);
   readonly recordPage = signal(1);
   readonly recordPageSize = 20;
   readonly datePickerMonthLabel = computed(() => new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(this.datePickerMonth()));
@@ -93,11 +98,8 @@ export class StudyRecordsPageComponent implements OnInit {
     if (!selectedDate) return this.items();
     return this.items().filter((item) => this.dateKey(item['CreateTime'] ?? item['createTime'] ?? item['dqrq'] ?? item['date']) === selectedDate);
   });
-  readonly recordPageCount = computed(() => Math.max(1, Math.ceil(this.visibleItems().length / this.recordPageSize)));
-  readonly paginatedItems = computed(() => {
-    const start = (this.recordPage() - 1) * this.recordPageSize;
-    return this.visibleItems().slice(start, start + this.recordPageSize);
-  });
+  readonly recordPageCount = computed(() => Math.max(1, Math.ceil(this.serverTotal() / this.recordPageSize)));
+  readonly paginatedItems = computed(() => this.visibleItems());
   readonly latestReadingRecord = computed(() => this.overviewReadingRecord() ?? this.items().find((item) => extractReadingWords(item['xxqs']).length > 0));
   readonly latestReadingWordCount = computed(() => {
     const item = this.latestReadingRecord();
@@ -137,10 +139,10 @@ export class StudyRecordsPageComponent implements OnInit {
     this.loading.set(true);
     this.error.set('');
     if (this.meta.path === 'pages/member/myorder_appeal') { this.loadAppeals(uid); return; }
-    if (this.meta.path === 'pages/member/ke_fuxi') { this.loadIncome(uid); return; }
+    if (['pages/member/ke_fuxi','pages/member/ghis'].includes(this.meta.path)) { this.loadIncome(uid); return; }
     if (this.meta.path === 'pages/his/his') { this.loadLearningHistory(uid, refresh); return; }
 
-    const params: Record<string, unknown> = { uid, cpage: 1, psize: this.meta.path === 'pages/member/myorder' ? 30 : 200 };
+    const params: Record<string, unknown> = { uid, cpage: this.recordPage(), psize: this.recordPageSize };
     if (this.meta.path === 'pages/member/myke') Object.assign(params, { nid: 28, myfield2: `yhid=${uid}`, orders: 'id=DESC', cksl: 1 });
     else if (this.meta.path === 'pages/member/ghis') Object.assign(params, { nid: 254, modelId: 56, myfield2: `UserId=${uid}`, orders: 'CreateTime=desc', psize: 20 });
     else {
@@ -149,8 +151,10 @@ export class StudyRecordsPageComponent implements OnInit {
       if (this.selectedDate) filter += `|dqrq=${this.selectedDate.replaceAll('-', '')}`;
       Object.assign(params, { nid: 291, modelId: 56, myfield2: filter });
     }
-    this.api.get<unknown>('content_list', params).pipe(finalize(() => this.loading.set(false))).subscribe({
-      next: ({ result }) => {
+    this.historyRequest?.unsubscribe();
+    this.historyRequest=this.api.get<unknown>('content_list', params).pipe(finalize(() => this.loading.set(false))).subscribe({
+      next: ({ result, page }) => {
+        this.serverTotal.set(page?.itemCount||0);
         const list = this.asList(result);
         this.items.set(list);
       },
@@ -216,6 +220,7 @@ export class StudyRecordsPageComponent implements OnInit {
 
   goToRecordPage(page: number): void {
     this.recordPage.set(Math.min(this.recordPageCount(), Math.max(1, page)));
+    this.load();
   }
 
   selectedDateLabel(): string {
@@ -372,6 +377,7 @@ export class StudyRecordsPageComponent implements OnInit {
     const params = {
       wordList: wordList.join(','),
       GeneralID: this.recordId(item),
+      studentId:this.studentId||this.session.user()?.userId,
       returnUrl: this.router.url,
       t: Date.now(),
     };
@@ -430,6 +436,7 @@ export class StudyRecordsPageComponent implements OnInit {
   }
 
   private loadLearningHistory(uid: number, refresh = false): void {
+    this.historyRequest?.unsubscribe();const requestId=++this.historyRequestId;
     const targetUid = this.studentId || (this.session.groupId() === 3 ? 0 : uid);
     if (!targetUid) {
       this.items.set([]);
@@ -442,19 +449,22 @@ export class StudyRecordsPageComponent implements OnInit {
       return;
     }
     const calendarRange = this.datePickerRange();
-    this.api.post<LearningOverview>('app_learning_overview', {
+    this.historyRequest=this.api.post<LearningOverview>('app_learning_overview', {
       uid,
       studentId: targetUid,
+      page:this.recordPage(),pageSize:this.recordPageSize,
       date: this.selectedDate,
       calendarStart: calendarRange.start,
       calendarEnd: calendarRange.end,
       refresh,
     }).pipe(finalize(() => this.loading.set(false))).subscribe({
       next: ({ result }) => {
+        if(requestId!==this.historyRequestId)return;
         const overview = result ?? {} as LearningOverview;
+        this.serverTotal.set(overview.page?.itemCount||overview.records?.length||0);
         const records = Array.isArray(overview.records) ? overview.records : [];
         this.items.set(records);
-        this.recordPage.update((page) => Math.min(page, Math.max(1, Math.ceil(records.length / this.recordPageSize))));
+        this.recordPage.update((page) => Math.min(page, Math.max(1, Math.ceil(this.serverTotal() / this.recordPageSize))));
         this.overviewReadingRecord.set(overview.latestReadingRecord ?? null);
         this.learningSchedules.set(Array.isArray(overview.schedules) ? overview.schedules : []);
         this.recordDateCounts.set(overview.recordDateCounts ?? {});
@@ -476,15 +486,19 @@ export class StudyRecordsPageComponent implements OnInit {
     return { start: this.dateKey(start), end: this.dateKey(end) };
   }
 
-  private loadStudents(): void {
+  loadStudents(more=false): void {
+    if(this.studentsLoading())return;
+    const requestedPage=more?this.studentListPage()+1:1;
     this.studentsLoading.set(true);
     this.studentsError.set('');
-    this.api.post<unknown>('e_user_list', { uid: this.session.user()?.userId, cpage: 1, psize: 200 }).pipe(
+    this.api.post<unknown>('e_user_list', { uid: this.session.user()?.userId, cpage:requestedPage, psize:50 }).pipe(
       finalize(() => this.studentsLoading.set(false)),
     ).subscribe({
       next: ({ result }) => {
+        this.studentListPage.set(requestedPage);
         const students = this.asList(result);
-        this.students.set(students);
+        this.studentsHaveMore.set(students.length===50);
+        this.students.set(more?[...this.students(),...students]:students);
         if (this.studentId) {
           this.syncSelectedStudentContext();
           return;
@@ -531,15 +545,8 @@ export class StudyRecordsPageComponent implements OnInit {
   }
 
   private loadIncome(uid: number): void {
-    forkJoin({
-      list: this.api.post<unknown>('e_get_21list', { status: 1, shouru: 1, uid, cpage: 1, psize: 20 }),
-      total: this.session.groupId() === 3
-        ? this.api.post<unknown>('e_get_21list_tj', { uid })
-        : of({ retcode: 1, result: [] }),
-    }).pipe(finalize(() => this.loading.set(false))).subscribe({ next: ({ list, total }) => {
-      this.items.set(this.asList(list.result));
-      this.totalIncome.set(Number(this.asList(total.result)[0]?.['num'] ?? 0));
-    }, error: (error: ApiError) => this.error.set(error.message || '抗遗忘工资加载失败') });
+    this.historyRequest?.unsubscribe();const requestId=++this.historyRequestId;
+    this.historyRequest=this.api.post<ReviewPage>('app_review_page',{uid,page:this.recordPage(),pageSize:20,status:this.meta.path==='pages/member/ke_fuxi'?'completed':undefined}).pipe(finalize(()=>{if(requestId===this.historyRequestId)this.loading.set(false);})).subscribe({next:({result})=>{if(requestId!==this.historyRequestId)return;this.items.set(result.items);this.serverTotal.set(result.page.itemCount);this.totalIncome.set(result.summary.income);},error:(e:ApiError)=>this.error.set(e.message)});
   }
 
   private asList(value: unknown): Item[] {

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

@@ -851,3 +851,5 @@ app-learning-page .word-column-empty { display: flex; min-height: 150px; padding
 @media (max-width: 360px) {
   :root { --page-gutter: 10px; }
 }
+
+.mobile-pagination{display:flex;align-items:center;justify-content:center;gap:16px;padding:20px;flex-wrap:wrap}.mobile-pagination button{border:1px solid #dce6e2;border-radius:10px;background:#fff;color:#18745d;padding:10px 16px;font:inherit;cursor:pointer}.mobile-pagination button:disabled{opacity:.45;cursor:default}

+ 4 - 1
scripts/audit-app-cloud-actions.mjs

@@ -21,6 +21,9 @@ for (const file of await walk('projects/xiaoshu-mobile/src/app')) {
   const source = await readFile(file, 'utf8');
   for (const pattern of [
     /\b(?:this\.)?api\.(?:get|post)(?:<[^>]*>)?\(\s*['"]([^'"]+)['"]/g,
+    /\b(?:saveAction|deleteAction|listAction|loadAction):\s*['"]([^'"]+)['"]/g,
+    /['"](ranklist_[1-5])['"]/g,
+    /['"](reading\/(?:me|query|get|create|update|ai|user\/update))['"]/g,
     /\b(?:this\.)?auth\.login\(\s*['"]([^'"]+)['"]/g,
   ]) for (const match of source.matchAll(pattern)) requested.add(match[1]);
 }
@@ -39,7 +42,7 @@ const payload = await response.json().catch(() => ({}));
 if (!response.ok || !payload.result) throw new Error(`migration_status 请求失败:${response.status}`);
 const implemented = new Set(Array.isArray(payload.result.implemented) ? payload.result.implemented.map(String) : []);
 const blocked = new Set(Object.keys(payload.result.blocked || {}));
-const covered = new Set([...implemented, ...blocked]);
+const covered = new Set([...implemented, ...blocked, ...(payload.result.disabledActions || [])]);
 const missing = [...requested].filter((action) => !covered.has(action) && !action.startsWith('ops/')).sort();
 const report = {
   passed: missing.length === 0,

+ 20 - 0
scripts/audit-mobile-teaching-readonly.mjs

@@ -0,0 +1,20 @@
+import {writeFileSync,mkdirSync}from'node:fs';
+const base='https://server.xiaoshu.pro',h={'Content-Type':'application/json','X-Parse-Application-Id':'7pIbDBJmKx_main','X-Parse-Master-Key':process.env.XIAOSHU_MASTER_KEY};
+async function parse(path){const r=await fetch(base+'/parse'+path,{headers:h});const d=await r.json();if(!r.ok)throw Error(d.error||r.status);return d;}
+const sessions=(await parse('/classes/_Session?include=user&limit=1000&order=-updatedAt')).results;
+const report={date:new Date().toISOString(),reads:[]};
+for(const uid of [Number(process.env.XIAOSHU_AUDIT_TEACHER_ID||88),Number(process.env.XIAOSHU_AUDIT_STUDENT_ID||1761)]){
+ const s=sessions.find(s=>s.user?.legacyUserId===uid&&new Date(s.expiresAt.iso)>new Date());if(!s){report.reads.push({uid,error:'no session'});continue;}
+ for(const params of [{action:'app_credit_overview',uid},{action:'app_review_page',uid,page:1,pageSize:20},{action:'app_review_page',uid,page:2,pageSize:20},{action:'app_companion_overview',uid,page:1,pageSize:20},{action:'app_learning_overview',uid,page:1,pageSize:20},{action:'reading/query',className:'SurveyItem',where:{type:'reading'},limit:1}]){
+ const r=await fetch(base+'/api/functions/xiaoshu/app/gateway',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:s.sessionToken,params}),signal:AbortSignal.timeout(55000)});const d=await r.json(),v=d.result;
+ const entry={uid,action:params.action,requestPage:params.page,status:r.status,error:d.retmsg,page:v?.page,summary:v?.summary,source:v?.source,items:(v?.items||v?.records||v?.schedules||[]).length,cancelled:v?.schedules?.filter(x=>x.cancelled).length,accounts:v?.accounts};report.reads.push(entry);console.log(JSON.stringify(entry));
+ }
+ const first=report.reads.find(r=>r.uid===uid&&r.action==='app_review_page');if(first?.page?.pageCount){const collected=[];for(let page=1;page<=first.page.pageCount;page+=4){const pages=Array.from({length:Math.min(4,first.page.pageCount-page+1)},(_,i)=>page+i);const responses=await Promise.all(pages.map(async p=>{const r=await fetch(base+'/api/functions/xiaoshu/app/gateway',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:s.sessionToken,params:{action:'app_review_page',uid,page:p,pageSize:20}}),signal:AbortSignal.timeout(55000)});const d=await r.json();if(!r.ok)throw Error(d.retmsg||r.status);return d.result.items;}));for(const rows of responses)collected.push(...rows);}const unique=new Set(collected.map(r=>String(r.GeneralID)));const result={uid,action:'review_all_pages',items:collected.length,unique:unique.size,total:first.page.itemCount,complete:unique.size===first.page.itemCount&&unique.size===collected.length};report.reads.push(result);console.log(JSON.stringify(result));}
+}
+const pending=[];for(let skip=0;;skip+=500){const rows=(await parse('/classes/CourseBinding?limit=500&skip='+skip+'&where='+encodeURIComponent(JSON.stringify({enrollmentStatus:{$ne:'active'}}))+'&keys=objectId,id,yhid,kcid,enrollmentStatus,categoryKey,pendingReason')).results;if(!rows.length)break;pending.push(...rows);}
+mkdirSync('tmp/mobile-teaching-verification',{recursive:true});writeFileSync('tmp/mobile-teaching-verification/pending-enrollments.json',JSON.stringify({generatedAt:report.date,count:pending.length,items:pending},null,2));report.pendingEnrollmentCount=pending.length;writeFileSync('tmp/mobile-teaching-verification/read-audit.json',JSON.stringify(report,null,2));
+
+report.businessCounts={};for(const [label,table,where] of [["activeEnrollments","CourseBinding",{enrollmentStatus:"active"}],["pendingEnrollments","CourseBinding",{enrollmentStatus:"pending"}]]){const value=await parse("/classes/"+table+"?count=1&limit=0&where="+encodeURIComponent(JSON.stringify(where)));report.businessCounts[label]=value.count;}console.log(JSON.stringify(report.businessCounts));writeFileSync("tmp/mobile-teaching-verification/read-audit.json",JSON.stringify(report,null,2));
+
+const admin=sessions.find(s=>s.user?.adminRoleKey==='super-admin'&&new Date(s.expiresAt.iso)>new Date());
+if(admin){const r=await fetch(base+'/api/functions/xiaoshu/ops/gateway-v3',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:admin.sessionToken,params:{operation:'ops/payroll/review-rewards',companyId:'7pIbDBJmKx',page:1,pageSize:1}})});const d=await r.json();if(!r.ok||!d.success)throw Error(d.message||'工资对账失败');const v=d.data.summary;report.businessCounts={...report.businessCounts,earnedRewards:v.reviewCount,historicalRewards:v.historicalCount,exceptionRewards:v.exceptionCount};console.log(JSON.stringify(report.businessCounts));writeFileSync('tmp/mobile-teaching-verification/read-audit.json',JSON.stringify(report,null,2));}else{throw Error('缺少工资只读核对会话');}

+ 55 - 0
scripts/cloud/mobile-teaching.js

@@ -0,0 +1,55 @@
+// Shared teaching reads. Business state lives in the new database, not browser caches.
+function mobilePage(input) { return { index: Math.max(1, Math.floor(number(input.page || input.cpage, 1))), size: Math.min(100, Math.max(1, Math.floor(number(input.pageSize || input.psize, 20)))) }; }
+function mobilePageMeta(total, p) { return { itemCount:Number(total),pageCount:Math.ceil(Number(total)/p.size),pageIndex:p.index,pageSize:p.size }; }
+function mobileSource() { return {source:'database',refreshedAt:new Date().toISOString(),complete:true}; }
+const MOBILE_DISABLED_ACTIONS = new Set(['cart_add','payment_cart','payment_cart_again','payment_success','user_group_usr_supply','user_group_usr_upgrade']);
+async function appCreditOverview(input,current) {
+  const uid=await authorizeRequestedUser(current,input.studentId||input.uid),owner=uid===ownLegacyId(current)?current:await legacyUserObject(uid);
+  if(!owner)fail(404,'学员不存在');
+  const data=legacyData(owner),reserved=await Psql.query('SELECT cost->>\'account\' AS account,SUM((cost->>\'amount\')::numeric) AS amount FROM "CourseCreditReservation" r CROSS JOIN LATERAL jsonb_array_elements(r."creditCosts") cost WHERE r."company"=$1 AND r."studentId"=$2 AND r."state"=\'reserved\' GROUP BY cost->>\'account\'',[DEFAULT_COMPANY_ID,uid]);
+  const labels={Purse:'30 分钟课时',SilverCoin:'60 分钟课时',UserExp:'体验课时',UserPoint:'陪练时长'};
+  return {studentId:uid,accounts:Object.entries(labels).map(([account,label],i)=>{const balance=number(data[account]),held=number(reserved.find(r=>r.account===account)?.amount);return{account,label,historyType:i+1,balance,reserved:held,available:balance-held,unit:account==='UserPoint'?'小时':'次'};}),...mobileSource()};
+}
+async function appLessonReport(input,current) {
+  const order=await orderDetailRow(input.id||input.orderId,current);if(!order)fail(404,'预约不存在');
+  const id=String(order.GeneralID||input.id||input.orderId);
+  const [feedback,records,reviews]=await Promise.all([
+    Psql.oneOrNone('SELECT * FROM "LessonCompletionReport" WHERE "company"=$1 AND "appointmentGeneralId"::text=$2 ORDER BY "submittedAt" DESC LIMIT 1',[DEFAULT_COMPANY_ID,id]),
+    Psql.query('SELECT c."generalId",d."con",d."dqrq",d."learned",d."ygg",d."djq" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=d."company" AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1 AND d."dsid"::text=$2 AND d."userId"=$3 ORDER BY d."dqrq" DESC',[DEFAULT_COMPANY_ID,id,number(order.szyh)]),
+    Psql.one('SELECT COUNT(*)::int total,COUNT(*) FILTER(WHERE "fxzt"=\'1\')::int completed FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "orderId"::text=$2 AND "yhid"=$3',[DEFAULT_COMPANY_ID,id,number(order.szyh)])
+  ]);
+  return {order,feedback:feedback?safe(feedback):null,records,antiForget:reviews,...mobileSource()};
+}
+async function appReviewPage(input,current) {
+  const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input);
+  const args=[DEFAULT_COMPANY_ID,String(uid)],clauses=['m."company"=$1','m."'+(coach?'plid':'yhid')+'"::text=$2'];
+  const add=(sql,value)=>{args.push(value);clauses.push(sql.replace('?', '$'+args.length));};
+  const students=coach?await Psql.query('SELECT m."yhid"::text AS id,COALESCE(MAX(u."realName"),MAX(u."nickname"),MAX(u."username"),m."yhid"::text) AS name,COUNT(*)::int AS "taskCount" FROM "MemoryPracticeRecord" m LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+clauses.join(' AND ')+' GROUP BY m."yhid"',args):[];
+  if(coach&&number(input.studentId))add('m."yhid"::text=?',String(input.studentId));
+  const baseWhere=clauses.join(' AND '),baseArgs=[...args];
+  const dateSql='LEFT(COALESCE(NULLIF(m."kywrq",\'\'),m."kywsj"),10)';
+  const summary=await Psql.one('SELECT COUNT(*)::int total,COUNT(*) FILTER(WHERE m."fxzt"=\'1\')::int completed,COUNT(*) FILTER(WHERE COALESCE(m."fxzt",\'0\')<>\'1\' AND '+dateSql+'<=$'+(args.length+1)+')::int due,COUNT(*) FILTER(WHERE '+dateSql+'=$'+(args.length+1)+')::int today FROM "MemoryPracticeRecord" m WHERE '+baseWhere,[...args,appOverviewToday()]);
+  const dates=await Psql.query('SELECT '+dateSql+' AS date,COUNT(*)::int total,COUNT(*) FILTER(WHERE m."fxzt"=\'1\')::int completed FROM "MemoryPracticeRecord" m WHERE '+baseWhere+' GROUP BY '+dateSql,baseArgs);
+  if(input.date)add(dateSql+'=?',String(input.date));
+  if(input.month)add('LEFT('+dateSql+',7)=?',String(input.month));
+  if(input.status==='completed')clauses.push('m."fxzt"=\'1\'');
+  if(input.status==='pending')clauses.push('COALESCE(m."fxzt",\'0\')<>\'1\'');
+  const where=clauses.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int total FROM "MemoryPracticeRecord" m WHERE '+where,args);
+  const rows=await Psql.query('SELECT c.*,row_to_json(m) AS "__addon",n."nodeName" AS "kc_title",d."dqrq" AS "learned_date",d."createdAt" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",COALESCE(u."realName",u."nickname",u."username") AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN "CommonModel" c ON c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN "CommonModel" dc ON dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewReward" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+' ORDER BY m."kywsj" DESC,m."id" DESC LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
+  return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,income:coach?await reviewIncomeTotal(DEFAULT_COMPANY_ID,uid):0},dates,students,...mobileSource()};
+}
+
+function mobileCommandKey(input) {const key=String(input.requestKey||'');return /^[A-Za-z0-9:_-]{10,160}$/.test(key)?key:null;}
+function mobileObjectData(row) {
+  const json=row.toJSON(),data={...json,objectId:row.id||Math.random().toString(36).slice(2,12),createdAt:json.createdAt||new Date().toISOString()};
+  delete data.ACL;delete data.className;
+  for(const [key,value] of Object.entries(data))if(value&&typeof value==='object'&&value.__type==='Pointer')data[key]=value.className+'$'+value.objectId;else if(value&&typeof value==='object'&&value.__type==='Date')data[key]=value.iso;
+  if(['CommonModel','DailyStudyRecord','PracticeRecord'].includes(row.className))data.company=DEFAULT_COMPANY_ID;
+  return data;
+}
+async function mobileAtomicWrite(input,current,operations,scope='') {
+  const key=mobileCommandKey(input)||('legacy:'+randomObjectId());
+  const semantic={...input};delete semantic.requestKey;delete semantic.token;
+  const row=await Psql.one('SELECT xs_mobile_teaching_write($1,$2,$3,md5($4),$5::jsonb) AS result',[DEFAULT_COMPANY_ID,scope||String(ownLegacyId(current)),String(current.id)+':'+key,JSON.stringify(semantic),JSON.stringify(operations)]);
+  return row.result;
+}

Разница между файлами не показана из-за своего большого размера
+ 17 - 11
scripts/deploy-admin-functions.mjs


+ 44 - 0
scripts/migrate-mobile-teaching.mjs

@@ -0,0 +1,44 @@
+#!/usr/bin/env node
+import { readFileSync } from 'node:fs';
+import { randomBytes } from 'node:crypto';
+import { request as httpsRequest } from 'node:https';
+const sql=readFileSync(new URL('./sql/mobile-teaching.sql',import.meta.url),'utf8');
+if(!process.argv.includes('--apply')) {
+  console.log('只读检查:教学写入迁移脚本已就绪。使用 --apply 执行;不修改历史教学或工资数据。');
+  process.exit(0);
+}
+const master=process.env.XIAOSHU_MASTER_KEY;
+if(!master)throw new Error('缺少 XIAOSHU_MASTER_KEY');
+const parseUrl=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,'');
+const appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
+async function request(path,options={}) {
+  const res=await fetch(parseUrl+path,{...options,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':master}});
+  const data=await res.json();if(!res.ok||data.error)throw new Error(data.error||data.message||data.retmsg||`HTTP ${res.status}`);return data;
+}
+function invokeMigration(url,token) {
+  return new Promise((resolve,reject)=>{const body=JSON.stringify({token,params:{}}),req=httpsRequest(url,{method:'POST',headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}},(res)=>{let raw='';res.setEncoding('utf8');res.on('data',(chunk)=>raw+=chunk);res.on('end',()=>{try{resolve({ok:Number(res.statusCode)>=200&&Number(res.statusCode)<300,data:JSON.parse(raw)})}catch(error){reject(error)}});});req.on('error',reject);req.write(body);req.end();});
+}
+for(const name of ['SurveyItem','SurveyLog']) {
+ const schema=await request('/schemas/'+name),fields=Object.fromEntries(Object.entries({operatorId:{type:'String'},learnerLegacyId:{type:'Number'},sourceStudyId:{type:'String'}}).filter(([key])=>!schema.fields[key]));
+ if(Object.keys(fields).length)await request('/schemas/'+name,{method:'PUT',body:JSON.stringify({fields})});
+}
+const company=(await request('/classes/Company?limit=1&keys=objectId')).results?.[0];
+if(!company)throw new Error('未找到帐套');
+const suffix=Date.now()+'_'+randomBytes(5).toString('hex'),path='xiaoshu/system/mobile-teaching-'+suffix;
+let userId='',functionId='';
+try {
+  const sessions=await request('/classes/_Session?include=user&limit=200&order=-updatedAt');
+  const selected=sessions.results.find(s=>s.sessionToken&&s.user?.isAdmin&&!s.user?.isDisabled&&new Date(s.expiresAt.iso)>new Date());
+  if(!selected)throw new Error('没有可用管理员会话');
+  const account={objectId:selected.user.objectId,sessionToken:selected.sessionToken};const authorizedId=account.objectId;
+  const code=`async function handler(request,response){try{const current=request.user;if(!current)return response.status(401).json({success:false});await current.fetch({useMasterKey:true});if(current.id!==${JSON.stringify(authorizedId)})return response.status(403).json({success:false});await Psql.none(${JSON.stringify(sql)});const counts=await Psql.query('SELECT COUNT(*)::int AS commands FROM "MobileTeachingCommand"');return response.json({success:true,counts});}catch(error){return response.status(500).json({success:false,message:String(error.message||error)});}}`;
+  const fn=await request('/classes/Function',{method:'POST',body:JSON.stringify({name:path,path,code,type:'standalone',desc:'教学写入一次性数据库迁移',enabled:true,params:[],paramList:[],respType:'json'})});functionId=fn.objectId;
+  const res=await invokeMigration(parseUrl.replace(/\/parse$/,'/api/functions/')+path,account.sessionToken);
+  if(!res.ok||!res.data.success)throw new Error(res.data.message||'迁移失败');console.log(JSON.stringify(res.data,null,2));
+} finally {
+  // Cleanup failures are reported, including IDs, so no temporary privileged surface is silently left behind.
+  const failures=[];
+  if(functionId)try{await request('/classes/Function/'+functionId,{method:'DELETE'});}catch{failures.push('临时函数 '+functionId);}
+  if(userId)try{await request('/users/'+userId,{method:'DELETE'});}catch{failures.push('临时管理员 '+userId);}
+  if(failures.length)throw new Error('需要清理:'+failures.join('、'));
+}

+ 101 - 0
scripts/smoke-mobile-teaching-flow.mjs

@@ -0,0 +1,101 @@
+#!/usr/bin/env node
+// Creates disposable users in the target company, verifies real transactions, then removes all fixture business rows.
+import {randomBytes,randomUUID} from 'node:crypto';
+import {writeFileSync,mkdirSync} from 'node:fs';
+import {resolve,join} from 'node:path';
+const co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx',base=(process.env.XIAOSHU_SERVER_URL||'https://server.xiaoshu.pro').replace(/\/$/,''),appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',suffix=Date.now(),state={users:[],co,admin:null,helper:null};
+if(!process.env.XIAOSHU_MASTER_KEY)throw Error('缺少 XIAOSHU_MASTER_KEY');
+const fixtureDirectory=resolve('tmp/mobile-teaching-flow-'+suffix);mkdirSync(fixtureDirectory,{recursive:true,mode:0o700});const statePath=join(fixtureDirectory,'fixture.json');
+const password=randomBytes(24).toString('base64url'),company={__type:'Pointer',className:'Company',objectId:co};
+function persist(){writeFileSync(statePath,JSON.stringify(state),{mode:0o600});}
+async function parse(path,method='GET',data){const r=await fetch(base+'/parse'+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':process.env.XIAOSHU_MASTER_KEY},...(data?{body:JSON.stringify(data)}:{})});const d=await r.json();if(!r.ok)throw Error('Parse '+path.split('?')[0]+': '+(d.error||r.status));return d;}
+async function call(path,token,params,allowFailure=false){const r=await fetch(base+'/api/functions/'+path,{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(60000)});const d=await r.json();if(!allowFailure&&(!r.ok||d.success===false||d.retcode<0))throw Error((params.operation||params.action||path)+': '+(d.message||d.retmsg||d.error||r.status));return {status:r.status,data:d.data??d.result,raw:d};}
+const ops=async(operation,params={},allow=false)=>call('xiaoshu/ops/gateway-v3',state.admin.sessionToken,{operation,...params},allow);
+const mutate=(operation,targetId,payload,key=randomUUID(),allow=false)=>ops(operation,{targetId,payload,reason:'发布隔离账户验收',idempotencyKey:key},allow);
+const expect=(ok,label)=>{if(!ok)throw Error('Assertion: '+label);console.log('PASS '+label);};
+async function user(kind,index,extra={}){const legacyUserId=999000000+(suffix%100000)*10+index;const value=await parse('/users','POST',{username:'enrollment_release_'+suffix+'_'+kind+'_'+index,password,company,identityType:kind,legacyGroupId:kind==='store'?2:kind==='coach'?3:1,legacyUserId,realName:'发布验收'+kind,legacyUserData:{UserID:legacyUserId,GroupID:kind==='store'?2:kind==='coach'?3:1,ParentUserID:0,Purse:0,SilverCoin:0,UserExp:0,UserPoint:0},...extra});state.users.push({...value,legacyUserId,kind});persist();return state.users.at(-1);}
+async function installHelper(){const ids=state.users.map(u=>u.legacyUserId),idObjects=state.users.map(u=>u.objectId),path='xiaoshu/system/enrollment-check-'+suffix;
+const code=helperCode(state);
+const fn=await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});state.helper={...fn,path};persist();}
+try{
+ const existingSessions=(await parse('/classes/_Session?include=user&limit=1000&order=-updatedAt')).results;
+ const authorizer=existingSessions.find(s=>s.user?.adminRoleKey==='super-admin'&&new Date(s.expiresAt.iso)>new Date());if(!authorizer)throw Error('缺少管理员会话');state.admin={objectId:authorizer.user.objectId,sessionToken:authorizer.sessionToken};
+ const store=await user('store',2),coach=await user('coach',3),empty=await user('member',4);
+ const created=(await mutate('ops/users/create','',{displayName:'发布验收学员',nickname:'发布验收学员',username:'release_member_'+suffix,mobile:'199'+String(suffix).slice(-8),parentUserId:store.legacyUserId,password})).data;
+ const student={objectId:created.objectId,legacyUserId:Number(created.userId),kind:'created-member'};state.users.push(student);persist();
+ await installHelper();expect(!!created.objectId&&student.legacyUserId>0,'创建学员并关联门店');
+ const zero=(await ops('ops/course-bindings/options',{studentId:empty.legacyUserId,categoryKey:'word'})).data;expect(!zero.eligible,'零余额开课资格被拒绝');
+ for(const account of ['Purse','SilverCoin','UserPoint','UserExp']){const fundKey=randomUUID(),transferKey=randomUUID(),fund={account,amount:10,payMethod:'运营记账'},transfer={account,amount:5,payMethod:'运营记账',direction:'add'};for(let retry=0;retry<2;retry++){await mutate('ops/stores/credit-post',store.objectId,fund,fundKey);await mutate('ops/users/adjust-balance',student.objectId,transfer,transferKey);}}
+ const storeAccounts=(await ops('ops/stores/credit-accounts',{objectId:store.objectId})).data;expect(storeAccounts.accounts.every(a=>a.balance===5)&&storeAccounts.transactions.length===8,'总部入账与双边划拨重试不重复记账');
+ const accounts=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(accounts.accounts.every(a=>a.balance===5),'四类课时门店划拨后学员余额一致');
+ const initial=(await ops('ops/course-bindings/options',{studentId:student.legacyUserId,categoryKey:'word'})).data;expect(initial.categories.length===6,'六个新分类来自线上教学标准');
+ for(const categoryKey of ['primary_writing','middle_writing','high_writing']){const o=(await ops('ops/course-bindings/options',{studentId:student.legacyUserId,categoryKey})).data;expect(o.items.every(i=>i.stageKey===categoryKey.split('_')[0]),categoryKey+' 学阶限制');}
+ const course=initial.items.find(i=>i.eligible&&!i.alreadyOpened);expect(!!course,'完整目录存在可开内容');
+ const key=randomUUID(),payload={studentId:student.legacyUserId,categoryKey:'word',courseIds:[course.courseId]};const word=(await mutate('ops/course-bindings/save','',payload,key)).data;const retry=(await mutate('ops/course-bindings/save','',payload,key)).data;expect(word.bindingIds[0]===retry.bindingIds[0],'重复开课请求幂等');
+ const self=(await mutate('ops/course-bindings/save','',{...payload,categoryKey:'word_self_study'})).data;expect(self.bindingIds[0]!==word.bindingIds[0],'同词表跨分类分别开课');
+ const zeroSave=await mutate('ops/course-bindings/save','',{...payload,studentId:empty.legacyUserId},randomUUID(),true);expect(zeroSave.status===409,'零余额实际开课请求被拒绝');
+ const duplicate=await mutate('ops/course-bindings/save','',payload,randomUUID(),true);expect(duplicate.status===409,'同分类重复开课被拒绝');
+ const studentToken=(await parse('/login','POST',{username:'release_member_'+suffix,password})).sessionToken;
+ const learning=(await call('xiaoshu/app/gateway',studentToken,{action:'app_learning_overview'})).data;expect(learning.courses.length===2&&new Set(learning.courses.map(c=>c.categoryKey)).size===2,'用户端分别展示两个开课分类');
+ const units=await Promise.all([word,self].map(b=>call('xiaoshu/app/gateway',studentToken,{action:'node_list',ifunit:1,pid:course.courseId,bindingId:b.bindingIds[0]})));expect(JSON.stringify(units[0].data)===JSON.stringify(units[1].data),'同词表跨分类沿用共享词汇进度');
+ const date=new Date(Date.now()+10*86400000).toISOString().slice(0,10),draft={studentId:student.legacyUserId,coachId:coach.legacyUserId,courseId:course.courseId,durationMinutes:60,deliveryMode:'online',date,startTime:'10:00',recurrence:'once',occurrences:1};
+ const ambiguous=await ops('ops/appointments/preflight',{payload:draft},true);expect(ambiguous.status===409,'旧排课请求遇分类歧义明确拒绝');
+ const appointment=(await mutate('ops/appointments/create','',{...draft,bindingId:word.bindingIds[0]})).data.targetIds[0];
+ const appData=await parse('/classes/CommonModel/'+appointment);const generalId=appData.generalId;
+ const teacherView=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_companion_overview',mode:'coach',rangeStart:date,rangeEnd:date})).data;expect(teacherView.schedules.some(a=>a.bindingId===word.bindingIds[0]&&a.courseCategoryKey==='word'),'教师端预约保留开课分类');
+ const assisted=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'reading/create',studentId:student.legacyUserId,requestKey:randomUUID(),className:'SurveyItem',value:{type:'reading',title:'隔离老师带读',content:'A test article.',createOptions:{tpl:'test',params:{wordList:['test']}}}})).data;
+ expect(assisted.learnerLegacyId===student.legacyUserId&&assisted.operatorId===coach.objectId,'老师带读归属所选学生并记录老师');
+ const studentArticle=(await call('xiaoshu/app/gateway',studentToken,{action:'reading/get',className:'SurveyItem',objectId:assisted.objectId})).data;expect(studentArticle.objectId===assisted.objectId,'学生读取老师带读文章');
+ await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:generalId})});const feedback={action:'e_order_complete_feedback',orderId:generalId,content:JSON.stringify({GeneralID:generalId}),feedback:JSON.stringify({contentSummary:'隔离教学验收',reviewStatus:'完成',reviewSummary:'复习已完成',trainingStatus:'正常进行',trainingSummary:'已完成本单元',homework:'无作业',noHomework:true,notes:'隔离账号验收'})};
+ await call('xiaoshu/app/gateway',coach.sessionToken,feedback);await call('xiaoshu/app/gateway',coach.sessionToken,feedback);
+ const detail=(await call('xiaoshu/app/gateway',studentToken,{action:'e_order_detail',id:generalId})).data;
+ expect(detail[0]?.feedback?.contentSummary==='隔离教学验收','老师提交完课后学生读取反馈');
+ const lessonReport=(await call('xiaoshu/app/gateway',studentToken,{action:'app_lesson_report',id:generalId})).data;expect(lessonReport.feedback?.contentSummary==='隔离教学验收','新库学习报表读取同一反馈');
+ const feedbackForeign=await call('xiaoshu/app/gateway',empty.sessionToken,feedback,true);
+ expect(feedbackForeign.status===403,'已有完课反馈的重试仍校验学员权限');
+ const lessons=(await call('xiaoshu/app/gateway',studentToken,{action:'content_list',modelId:59,myfield2:'yyds='+generalId+'|xymz='+student.legacyUserId,psize:2})).data;const lessonGeneralId=lessons[0].GeneralID;
+ await call('xiaoshu/app/gateway',studentToken,{action:'content_update',requestKey:randomUUID(),content:JSON.stringify({GeneralID:lessonGeneralId}),addon:JSON.stringify({pf:5,pjnr:'隔离学生评价'})});
+ await call('xiaoshu/app/gateway',studentToken,{action:'e_order_update_v2',status:20,content:JSON.stringify({GeneralID:generalId})});
+ await call('xiaoshu/app/gateway',coach.sessionToken,{action:'content_update',requestKey:randomUUID(),content:JSON.stringify({GeneralID:lessonGeneralId}),addon:JSON.stringify({pldp:'隔离老师点评',plpjsj:'2026-09-10 10:00:00'})});
+ await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:30,content:JSON.stringify({GeneralID:generalId})});expect(true,'学生评价、老师点评与最终完成均可保存');
+ await mutate('ops/appointments/complete',appointment,{});
+ const after=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(after.accounts.find(a=>a.account==='SilverCoin').balance===4&&after.accounts.find(a=>a.account==='UserPoint').balance===4,'教师完课与管理员重试只扣一次');
+ const a2=(await mutate('ops/appointments/create','',{...draft,bindingId:self.bindingIds[0],startTime:'12:00'})).data.targetIds[0];await mutate('ops/appointments/cancel',a2,{});await mutate('ops/appointments/restore',a2,{});await mutate('ops/appointments/cancel',a2,{});const cancelledCommon=await parse('/classes/CommonModel/'+a2);const cancelledStart=await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:cancelledCommon.generalId})},true);expect(cancelledStart.status===409,'取消预约禁止开课');
+ const a3=(await mutate('ops/appointments/create','',{...draft,bindingId:word.bindingIds[0],startTime:'13:00'})).data.targetIds[0],a3Common=await parse('/classes/CommonModel/'+a3);await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:a3Common.generalId})});
+ const rechargeRace=await Promise.all([mutate('ops/users/adjust-balance',student.objectId,{account:'SilverCoin',amount:1,payMethod:'运营记账',direction:'add'},randomUUID(),true),mutate('ops/appointments/complete',a3,{},randomUUID(),true)]);expect(rechargeRace.every(r=>r.status===200),'充值与完课并发都成功');
+ const concurrentBalance=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(concurrentBalance.accounts.find(a=>a.account==='SilverCoin').balance===4,'充值与完课并发余额无覆盖');
+ const transfer=accounts.transactions.find(t=>t.kind==='transfer'&&t.account==='Purse');await mutate('ops/users/adjust-balance',student.objectId,{account:'Purse',amount:4,payMethod:'运营记账',direction:'deduct',sourceTransactionId:transfer.objectId});
+ const drafts=['14:00','15:00'].map(startTime=>({...draft,bindingId:word.bindingIds[0],durationMinutes:30,startTime}));const race=await Promise.all(drafts.map(p=>mutate('ops/appointments/create','',p,randomUUID(),true)));expect(race.filter(r=>r.status===200).length===1&&race.filter(r=>r.status===409).length===1,'服务器多连接争抢最后一节课仅一单成功');
+ const winner=race.find(r=>r.status===200).data.targetIds[0];await mutate('ops/appointments/cancel',winner,{});
+ const race2=await Promise.all([mutate('ops/appointments/create','',drafts[0],randomUUID(),true),mutate('ops/users/adjust-balance',student.objectId,{account:'Purse',amount:1,payMethod:'运营记账',direction:'deduct',sourceTransactionId:transfer.objectId},randomUUID(),true)]);expect(race2.filter(r=>r.status===200).length===1,'退款和预留并发互斥');
+ const reconciliation=(await call(state.helper.path,state.admin.sessionToken,{mode:'reconcile'})).data;expect(reconciliation.every(r=>Number(r.balance)===Number(r.ledger)),'门店学员四类余额与流水逐项对账');
+ const audit=(await call(state.helper.path,state.admin.sessionToken,{mode:'audit'})).data;expect(audit[0].missing===0,'全部课时业务单均有事务审计');
+ const snapshot=(await call(state.helper.path,state.admin.sessionToken,{mode:'counts'})).data;console.log('ENROLLMENT_COUNTS',snapshot);console.log('ONLINE_SMOKE_PASSED');
+}catch(e){console.error('ONLINE_SMOKE_FAILED',e.message);process.exitCode=1;}
+finally{let clean=true;if(state.helper)try{await call(state.helper.path,state.admin.sessionToken,{mode:'cleanup'});}catch(e){console.error('CLEANUP_FAILED',e.message);clean=false;}if(clean){if(state.helper)await parse('/classes/Function/'+state.helper.objectId,'DELETE');for(const u of [...state.users].reverse())await parse('/users/'+u.objectId,'DELETE');console.log('FIXTURES_CLEANED');}else{console.error('隔离资源待清理,受限状态文件:'+statePath);process.exitCode=1;}}
+
+function helperCode(state){
+const ids=state.users.map(u=>u.legacyUserId),objects=state.users.map(u=>u.objectId),texts=ids.map(String),co=state.co,admin=state.admin.objectId;
+const statements=[
+['DELETE FROM "SurveyLog" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],
+['DELETE FROM "SurveyItem" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],
+['DELETE FROM "MobileTeachingCommand" WHERE "company"=$1 AND "actorId"=ANY($2::text[])',[co,texts]],
+['DELETE FROM "LessonCompletionReport" WHERE "company"=$1 AND "appointmentGeneralId" IN (SELECT c."generalId" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"=c."itemId" WHERE c."company"=$1 AND c."modelId"=54 AND a."szyh"::text=ANY($2::text[]))',[co,texts]],
+['DELETE FROM "CourseCreditReservation" WHERE "company"=$1 AND "studentId"=ANY($2::numeric[])',[co,ids]],
+['DELETE FROM "CommonModel" c USING "LessonRecord" l WHERE c."company"=$1 AND l."company"=$1 AND c."modelId"=59 AND c."itemId"=l."id" AND l."xymz"::text=ANY($2::text[])',[co,texts]],
+['DELETE FROM "LessonRecord" WHERE "company"=$1 AND "xymz"::text=ANY($2::text[])',[co,texts]],
+['DELETE FROM "CommonModel" c USING "CourseAppointment" a WHERE c."company"=$1 AND a."company"=$1 AND c."modelId"=54 AND c."itemId"=a."id" AND a."szyh"::text=ANY($2::text[])',[co,texts]],
+['DELETE FROM "CourseAppointment" WHERE "company"=$1 AND "szyh"::text=ANY($2::text[])',[co,texts]],
+['DELETE FROM "CommonModel" c USING "CourseBinding" b WHERE c."company"=$1 AND b."company"=$1 AND c."modelId"=58 AND c."itemId"=b."id" AND b."yhid"::text=ANY($2::text[])',[co,texts]],
+['DELETE FROM "CourseBinding" WHERE "company"=$1 AND "yhid"::text=ANY($2::text[])',[co,texts]],
+...['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'].map(t=>['DELETE FROM "'+t+'" WHERE "company"=$1 AND "userId"=ANY($2::numeric[])',[co,ids]]),
+['DELETE FROM "CreditTransaction" WHERE "company"=$1 AND ("storeId"=ANY($2::numeric[]) OR "studentId"=ANY($2::numeric[]))',[co,ids]],
+['DELETE FROM "EnrollmentCommand" WHERE "company"=$1 AND "request"->>\'uid\'=ANY($2::text[])',[co,texts]]];
+const ledgerTables=['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'],accounts=['Purse','SilverCoin','UserExp','UserPoint'];
+const ledger=ledgerTables.map((table,i)=>`SELECT "userId",'${accounts[i]}' AS account,"score" FROM "${table}" WHERE "company"=$1 AND "userId"=ANY($2::numeric[])`).join(' UNION ALL ');
+const queries={counts:['SELECT "enrollmentStatus",count(*)::int AS count FROM "CourseBinding" WHERE "company"=$1 GROUP BY "enrollmentStatus"',[co]],
+reconcile:[`SELECT u."legacyUserId",ac.account,COALESCE((u."legacyUserData"->>ac.account)::numeric,0) balance,COALESCE(sum(l."score"),0) ledger FROM "_User" u CROSS JOIN unnest(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) ac(account) LEFT JOIN (${ledger}) l ON l."userId"=u."legacyUserId" AND l.account=ac.account WHERE u."objectId"=ANY($3::text[]) GROUP BY u."objectId",u."legacyUserId",ac.account`,[co,ids,objects]],
+audit:[`SELECT count(*)::int AS missing FROM "CreditTransaction" t WHERE t."company"=$1 AND t."operatorId"=$2 AND NOT EXISTS(SELECT 1 FROM "SysLog" s WHERE s."company"=t."company" AND s."sourceKey"='credit:'||t."objectId")`,[co,admin]]};
+const code=`async function handler(request,response){try{if(!request.user||request.user.id!==${JSON.stringify(admin)})return response.status(403).json({success:false});const mode=(request.body.params||{}).mode,queries=${JSON.stringify(queries)};if(queries[mode])return response.json({success:true,data:await Psql.query(...queries[mode])});if(mode!=='cleanup')return response.status(400).json({success:false});for(const [sql,args] of ${JSON.stringify(statements)})await Psql.none(sql,args);return response.json({success:true,data:{cleaned:true}});}catch(e){return response.status(500).json({success:false,message:String(e.message),detail:e.detail||'',where:e.where||''});}}`;
+new Function(code);return code;
+}

+ 39 - 0
scripts/smoke-mobile-teaching-writes.mjs

@@ -0,0 +1,39 @@
+import{randomBytes,randomUUID}from'node:crypto';import{writeFileSync,mkdirSync}from'node:fs';
+const co='7pIbDBJmKx',base='https://server.xiaoshu.pro',app='7pIbDBJmKx_main',h={'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':process.env.XIAOSHU_MASTER_KEY},suffix=Date.now(),uid=990000000+suffix%1000000,users=[],functions=[],results=[];let helper;
+async function parse(path,method='GET',data){const r=await fetch(base+'/parse'+path,{method,headers:h,...(data?{body:JSON.stringify(data)}:{})});const d=await r.json();if(!r.ok)throw Error(path.split('?')[0]+': '+(d.error||d.message||d.retmsg||r.status));return d;}
+async function call(token,params,path='xiaoshu/app/gateway'){const r=await fetch(base+'/api/functions/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(55000)});const d=await r.json();return{status:r.status,...d};}
+function check(ok,label){results.push({label,passed:!!ok});console.log((ok?'PASS ':'FAIL ')+label);if(!ok)throw Error(label);}
+try{
+ const sessions=(await parse('/classes/_Session?include=user&limit=200&order=-updatedAt')).results;const admin=sessions.find(s=>s.user?.isAdmin&&new Date(s.expiresAt.iso)>new Date());if(!admin)throw Error('no admin');
+ for(const [kind,group,id]of [['student',1,uid],['teacher',3,uid+1]]){const u=await parse('/users','POST',{username:'teachcheck_'+suffix+'_'+kind,password:randomBytes(24).toString('base64url'),company:{__type:'Pointer',className:'Company',objectId:co},legacyGroupId:group,legacyUserId:id,identityType:kind==='student'?'member':'coach',realName:'教学隔离验收',legacyUserData:{UserID:id,GroupID:group,ParentUserID:0,Purse:0,SilverCoin:0,UserExp:0,UserPoint:0}});users.push({...u,uid:id});writeFileSync('/tmp/mobile-fixture-ids.json',JSON.stringify(users.map(({objectId,uid})=>({objectId,uid}))));}
+ const student=users[0],teacher=users[1];check(!!student.sessionToken&&!!teacher.sessionToken,'隔离学生及老师会话创建');
+ const words=(await parse('/classes/CommonModel?where='+encodeURIComponent(JSON.stringify({modelId:52,status:99}))+'&limit=1&keys=generalId,nodeId')).results;const word=words[0];
+ const input={action:'e_add_words',uid:student.uid,wordsId:String(word.generalId),kcid:word.nodeId,save:1,requestKey:randomUUID()};const repeated=await Promise.all([call(student.sessionToken,input),call(student.sessionToken,input),call(student.sessionToken,input)]);console.log(repeated.map(r=>({status:r.status,msg:r.retmsg})));check(repeated.every(r=>r.status===200),'单词学习并发提交返回成功');
+ const practices=(await parse('/classes/PracticeRecord?where='+encodeURIComponent(JSON.stringify({yhid:student.uid}))+'&limit=100')).results;check(practices.length===1&&Number(practices[0].xxcs)===1,'并发及重复请求只增加一次学习次数');
+ const study={action:'content_add_zt',requestKey:randomUUID(),content:JSON.stringify({ModelID:56,nodeId:291,title:'隔离验收'}),addon:JSON.stringify({UserID:student.uid,createReviewPlan:false,dqrq:'20260910',xxqs:JSON.stringify([{GeneralID:word.generalId,Title:'test',check:1}])})};const first=await call(student.sessionToken,study);console.log({studyStatus:first.status,msg:first.retmsg});const again=await call(student.sessionToken,study);check(first.status===200&&first.result===again.result,'学习记录双表原子保存及网络重试幂等');
+ const unit=await call(student.sessionToken,{action:'unit_record_update',requestKey:randomUUID(),userId:student.uid,bookId:word.nodeId,nids:String(word.nodeId),wordIds:JSON.stringify({[word.nodeId]:String(word.generalId)})});check(unit.status===200,'单元进度归属学生并保存');
+ const assessment={action:'content_add',requestKey:randomUUID(),content:JSON.stringify({ModelID:61,nodeId:389,title:'隔离测评'}),addon:JSON.stringify({UserID:student.uid,df:1})};const exam=await call(student.sessionToken,assessment),examAgain=await call(student.sessionToken,assessment);check(exam.status===200&&exam.result===examAgain.result,'测评保存和重复提交幂等');
+ // Verify the deployed reward trigger inside a subtransaction, then roll back the entire fixture.
+ const rewardPath='xiaoshu/system/teaching-reward-check-'+suffix,taskId='reward-fixture-'+suffix;
+ const rewardSql=`DO $check$ DECLARE n int; amount numeric; BEGIN BEGIN
+ INSERT INTO "MemoryPracticeRecord"("objectId","createdAt","updatedAt","company","sourceKey","id","yhid","plid","xxjlid","kcid","kywrq","kywsj","fxzt") VALUES ('${taskId}',now(),now(),'${co}','${taskId}',${uid+5},${student.uid},${teacher.uid},${Number(first.result)},${word.nodeId},'2026-09-10','2026-09-10 10:00','0');
+ PERFORM xiaoshu_complete_review('${co}','${taskId}');PERFORM xiaoshu_complete_review('${co}','${taskId}');
+ UPDATE "MemoryPracticeRecord" SET "fxzt"='1',"wcsj"='2026-10-01 00:00:00' WHERE "objectId"='${taskId}';
+ SELECT count(*),sum(r."amount") INTO n,amount FROM "ReviewReward" r WHERE "company"='${co}' AND "taskId"='${taskId}' AND "status"='earned' AND "incomeMonth"=to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM');
+ RAISE EXCEPTION USING ERRCODE='ZX001',MESSAGE='rollback teaching fixture';EXCEPTION WHEN SQLSTATE 'ZX001' THEN NULL;END;
+ IF n<>1 OR amount<>1 THEN RAISE EXCEPTION 'reward verification failed'; END IF;
+ IF EXISTS(SELECT 1 FROM "ReviewReward" WHERE "taskId"='${taskId}') THEN RAISE EXCEPTION 'fixture rollback failed';END IF; END $check$;`;
+ const rewardFn=await parse('/classes/Function','POST',{name:rewardPath,path:rewardPath,type:'standalone',enabled:true,params:[],paramList:[],respType:'json',code:`async function handler(request,response){if(request.user?.id!==${JSON.stringify(admin.user.objectId)})return response.status(403).json({});await Psql.none(${JSON.stringify(rewardSql)});return response.json({success:true});}`});
+ try{const checked=await call(admin.sessionToken,{},rewardPath);console.log({rewardStatus:checked.status,error:checked.retmsg||checked.message});check(checked.success,'线上抗遗忘首次完成仅计薪一次、跨月重试不改月份(事务回滚验收)');}finally{await parse('/classes/Function/'+rewardFn.objectId,'DELETE');}
+ const foreign=await call(teacher.sessionToken,{...input,requestKey:randomUUID()});check(foreign.status===403,'未关联老师不能修改学生学习记录');
+ const article={action:'reading/create',className:'SurveyItem',requestKey:randomUUID(),value:{type:'reading',title:'隔离阅读验收',content:'test content',createOptions:{tpl:'test',params:{wordList:['test']}}}};const a=await call(student.sessionToken,article),ar=await call(student.sessionToken,article);console.log({readingStatus:a.status,msg:a.retmsg});check(a.status===200&&a.result?.objectId===ar.result?.objectId,'阅读保存及重试幂等');
+ check(a.result?.operatorId===student.objectId&&a.result?.learnerLegacyId===student.uid,'阅读归属和操作者由服务端固定');
+ const bad=await call(teacher.sessionToken,{action:'reading/get',className:'SurveyItem',objectId:a.result.objectId,studentId:student.uid});check(bad.status===403,'阅读跨学员权限拒绝');
+ const credit=await call(student.sessionToken,{action:'app_credit_overview',uid:student.uid});check(credit.status===200&&credit.result.accounts.length===4&&credit.result.accounts.every(a=>a.balance===0),'四类课时读取独立账户');
+}finally{
+ // Only remove rows owned by these disposable fixture IDs. Their credentials are never logged.
+ const sessions=(await parse('/classes/_Session?include=user&limit=200&order=-updatedAt')).results;const admin=sessions.find(s=>s.user?.isAdmin&&new Date(s.expiresAt.iso)>new Date());
+ if(users.length&&admin){const ids=users.map(u=>u.uid),objects=users.map(u=>u.objectId),path='xiaoshu/system/teaching-clean-'+suffix;const code=`async function handler(request,response){if(request.user?.id!==${JSON.stringify(admin.user.objectId)})return response.status(403).json({});await Psql.none('DELETE FROM "CommonModel" WHERE "company"=$1 AND "modelId"=56 AND "itemId" IN (SELECT "id" FROM "DailyStudyRecord" WHERE "company"=$1 AND "userId"=ANY($2::numeric[]));DELETE FROM "CommonModel" WHERE "company"=$1 AND "modelId"=61 AND "itemId" IN (SELECT "id" FROM "AssessmentProfile" WHERE "company"=$1 AND "userId"::text=ANY($3::text[]));DELETE FROM "AssessmentProfile" WHERE "company"=$1 AND "userId"::text=ANY($3::text[]);DELETE FROM "UnitLearningRecord" WHERE "legacyUserId"=ANY($2::numeric[]);DELETE FROM "DailyStudyRecord" WHERE "company"=$1 AND "userId"=ANY($2::numeric[]);DELETE FROM "PracticeRecord" WHERE "company"=$1 AND "yhid"=ANY($2::numeric[]);DELETE FROM "MobileTeachingCommand" WHERE "company"=$1 AND "actorId"=ANY($3::text[]);DELETE FROM "SurveyLog" WHERE "operatorId"=ANY($4::text[]);DELETE FROM "SurveyItem" WHERE "operatorId"=ANY($4::text[])',[${JSON.stringify(co)},${JSON.stringify(ids)},${JSON.stringify(ids.map(String))},${JSON.stringify(objects)}]);return response.json({success:true});}`;const fn=await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});try{const cleaned=await call(admin.sessionToken,{},path);console.log({cleanupStatus:cleaned.status,error:cleaned.retmsg||cleaned.message});check(cleaned.success,'隔离业务数据清理');}finally{await parse('/classes/Function/'+fn.objectId,'DELETE');}}
+ for(const u of users)await parse('/users/'+u.objectId,'DELETE');
+ mkdirSync('tmp/mobile-teaching-verification',{recursive:true});writeFileSync('tmp/mobile-teaching-verification/write-audit.json',JSON.stringify({date:new Date().toISOString(),results},null,2));
+}

+ 88 - 0
scripts/sql/mobile-teaching.sql

@@ -0,0 +1,88 @@
+-- Atomic, retry-safe teaching writes. No historical teaching or payroll data is rewritten.
+CREATE TABLE IF NOT EXISTS "MobileTeachingCommand" (
+  "company" text NOT NULL, "actorId" text NOT NULL, "requestKey" text NOT NULL,
+  "fingerprint" text NOT NULL, "result" jsonb NOT NULL,
+  "createdAt" timestamptz NOT NULL DEFAULT now(),
+  PRIMARY KEY ("company","actorId","requestKey")
+);
+CREATE OR REPLACE FUNCTION xs_mobile_teaching_write(p_company text,p_actor text,p_key text,p_fingerprint text,p_operations jsonb)
+RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE op jsonb; data jsonb; criteria jsonb; previous jsonb; saved jsonb; output jsonb := '[]';
+  tab text; field text; columns_sql text; values_sql text; where_sql text; typ text; counter text;
+  existing jsonb; prior "MobileTeachingCommand"%ROWTYPE;
+BEGIN
+  IF length(p_key)<10 OR length(p_key)>160 OR jsonb_array_length(p_operations)>105 THEN RAISE EXCEPTION 'Invalid teaching command'; END IF;
+  -- Serialize the learner's commands as well as duplicate request keys; nested writes share one transaction.
+  PERFORM pg_advisory_xact_lock(hashtextextended(p_company||':'||p_actor,0));
+  SELECT * INTO prior FROM "MobileTeachingCommand" WHERE "company"=p_company AND "actorId"=p_actor AND "requestKey"=p_key;
+  IF FOUND THEN
+    IF prior."fingerprint"<>p_fingerprint THEN RAISE EXCEPTION 'Request key reused with different content'; END IF;
+    RETURN prior."result";
+  END IF;
+  FOR op IN SELECT value FROM jsonb_array_elements(p_operations) LOOP
+    tab := op->>'table';
+    IF tab NOT IN ('PracticeRecord','DailyStudyRecord','CommonModel','UnitLearningRecord','AssessmentProfile','SurveyItem','SurveyLog','DesignAnswer','LessonCompletionReport','LessonRecord') THEN RAISE EXCEPTION 'Teaching table not allowed'; END IF;
+    data := op->'data'; criteria := op->'match';
+    IF criteria IS NULL OR criteria='{}' THEN RAISE EXCEPTION 'Missing business key'; END IF;
+    IF op ? 'reference' THEN
+      FOR field,counter IN SELECT key,value#>>'{}' FROM jsonb_each(op->'reference') LOOP
+        data := jsonb_set(data,ARRAY[field],previous->counter);
+        IF criteria ? field THEN criteria:=jsonb_set(criteria,ARRAY[field],previous->counter); END IF;
+      END LOOP;
+    END IF;
+    where_sql := '';
+    FOR field IN SELECT jsonb_object_keys(criteria) LOOP
+      SELECT format_type(a.atttypid,a.atttypmod) INTO typ FROM pg_attribute a WHERE a.attrelid=to_regclass(format('%I',tab)) AND a.attname=field AND NOT a.attisdropped;
+      IF typ IS NULL THEN RAISE EXCEPTION 'Unknown match field %',field; END IF;
+      where_sql:=where_sql||CASE WHEN where_sql='' THEN '' ELSE ' AND ' END||format('%I = ($1->>%L)::%s',field,field,typ);
+    END LOOP;
+    EXECUTE format('SELECT to_jsonb(t) FROM %I t WHERE %s ORDER BY "createdAt", "objectId" LIMIT 1 FOR UPDATE',tab,where_sql) INTO existing USING criteria;
+    IF existing IS NOT NULL THEN
+      data := data || jsonb_build_object('objectId',existing->'objectId','createdAt',existing->'createdAt');
+      -- Stable legacy IDs must survive retries and concurrent upserts.
+      FOREACH field IN ARRAY ARRAY['id','generalId','itemId'] LOOP
+        IF existing ? field THEN data:=jsonb_set(data,ARRAY[field],existing->field); END IF;
+      END LOOP;
+    END IF;
+    FOR field IN SELECT jsonb_array_elements_text(COALESCE(op->'increment','[]')) LOOP
+      data:=jsonb_set(data,ARRAY[field],to_jsonb(COALESCE((existing->>field)::numeric,0)+COALESCE((data->>field)::numeric,0)));
+    END LOOP;
+    FOR field IN SELECT jsonb_array_elements_text(COALESCE(op->'union','[]')) LOOP
+      data:=jsonb_set(data,ARRAY[field],(SELECT COALESCE(jsonb_agg(DISTINCT value),'[]') FROM jsonb_array_elements(COALESCE(existing->field,'[]')||COALESCE(data->field,'[]'))));
+    END LOOP;
+    IF tab='UnitLearningRecord' THEN data:=jsonb_set(data,'{learnedCount}',to_jsonb(jsonb_array_length(data->'wordIds'))); END IF;
+    data:=data||jsonb_build_object('updatedAt',now());
+    columns_sql:=''; values_sql:='';
+    FOR field IN SELECT jsonb_object_keys(data) LOOP
+      IF NOT EXISTS(SELECT 1 FROM pg_attribute WHERE attrelid=to_regclass(format('%I',tab)) AND attname=field AND NOT attisdropped) THEN RAISE EXCEPTION 'Unknown teaching field %.%',tab,field; END IF;
+      columns_sql:=columns_sql||CASE WHEN columns_sql='' THEN '' ELSE ',' END||format('%I',field);
+      values_sql:=values_sql||CASE WHEN values_sql='' THEN '' ELSE ',' END||format('r.%I',field);
+    END LOOP;
+    IF existing IS NULL THEN
+      EXECUTE format('INSERT INTO %I (%s) SELECT %s FROM jsonb_populate_record(NULL::%I,$1) r RETURNING to_jsonb(%I.*)',tab,columns_sql,values_sql,tab,tab) INTO saved USING data;
+    ELSE
+      EXECUTE format('UPDATE %I t SET (%s)=(SELECT %s FROM jsonb_populate_record(NULL::%I,$1) r) WHERE t."objectId"=$2 RETURNING to_jsonb(t)',tab,columns_sql,values_sql,tab) INTO saved USING data,existing->>'objectId';
+    END IF;
+    previous:=saved; output:=output||jsonb_build_array(saved);
+  END LOOP;
+  INSERT INTO "MobileTeachingCommand" VALUES(p_company,p_actor,p_key,p_fingerprint,output,now());
+  RETURN output;
+END $$;
+REVOKE ALL ON FUNCTION xs_mobile_teaching_write(text,text,text,text,jsonb) FROM PUBLIC;
+
+-- Post-lesson acknowledgement never changes credits or pay snapshots.
+CREATE OR REPLACE FUNCTION xs_mobile_confirm_appointment(co text,general_id text,target_status integer,actor_id numeric,administrator boolean)
+RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE a record; old_status integer;
+BEGIN
+ IF target_status NOT IN (20,30) THEN RAISE EXCEPTION '不支持的确认状态';END IF;
+ SELECT ca.*,c."status" AS common_status INTO a FROM "CourseAppointment" ca JOIN "CommonModel" c ON c."company"=ca."company" AND c."modelId"=54 AND c."itemId"=ca."id" WHERE c."company"=co AND c."generalId"::text=general_id FOR UPDATE OF ca;
+ IF NOT FOUND OR a.common_status=-2 THEN RAISE EXCEPTION '预约不存在或已取消'; END IF;
+ IF NOT administrator AND actor_id::text IS DISTINCT FROM (CASE WHEN target_status=20 THEN a."szyh"::text ELSE a."pl"::text END) THEN RAISE EXCEPTION '无权确认该预约';END IF;
+ old_status:=a."dszt"::integer;
+ IF old_status>=target_status THEN RETURN jsonb_build_object('GeneralID',general_id,'status',old_status,'unchanged',true);END IF;
+ IF (target_status=20 AND old_status<>11) OR (target_status=30 AND old_status<>20) THEN RAISE EXCEPTION '预约状态已变更,请刷新';END IF;
+ UPDATE "CourseAppointment" SET "dszt"=target_status::text,"updatedAt"=now() WHERE "objectId"=a."objectId";
+ RETURN jsonb_build_object('GeneralID',general_id,'previousStatus',old_status,'status',target_status,'unchanged',false);
+END $$;
+REVOKE ALL ON FUNCTION xs_mobile_confirm_appointment(text,text,integer,numeric,boolean) FROM PUBLIC;

+ 27 - 0
scripts/tests/mobile-teaching.test.mjs

@@ -0,0 +1,27 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import{readFileSync}from'node:fs';import{PGlite}from'@electric-sql/pglite';
+const sql=readFileSync(new URL('../sql/mobile-teaching.sql',import.meta.url),'utf8');
+test('教学事务:重复及并发请求只增一次,关联记录同事务,失败整体回滚',async()=>{
+ const db=new PGlite();try{
+ await db.exec(`CREATE TABLE "PracticeRecord"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,"yhid" int,"scid" int,"xxcs" int);CREATE TABLE "DailyStudyRecord"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,id bigint,"sourceKey" text);CREATE TABLE "CommonModel"("objectId" text primary key,"createdAt" timestamptz,"updatedAt" timestamptz,company text,"itemId" bigint,"generalId" bigint,"sourceKey" text);`);await db.exec(sql);
+ const run=(key,ops,fingerprint='same')=>db.query('select xs_mobile_teaching_write($1,$2,$3,$4,$5::jsonb) result',['co','learner',key,fingerprint,JSON.stringify(ops)]);
+ const op={table:'PracticeRecord',match:{company:'co',yhid:1,scid:2},data:{objectId:'word',createdAt:new Date(),company:'co',yhid:1,scid:2,xxcs:1},increment:['xxcs']};
+ await Promise.all(Array.from({length:8},()=>run('request-0001',[op])));assert.equal((await db.query('SELECT xxcs FROM "PracticeRecord"')).rows[0].xxcs,1);
+ await run('request-0002',[op]);assert.equal((await db.query('SELECT xxcs FROM "PracticeRecord"')).rows[0].xxcs,2);
+ await assert.rejects(run('request-0001',[op],'changed'),/different content/);
+ await assert.rejects(run('request-0003',[op,{table:'Wrong',match:{id:1},data:{}}]),/not allowed/);assert.equal((await db.query('SELECT xxcs FROM "PracticeRecord"')).rows[0].xxcs,2);
+ const pair=[{table:'DailyStudyRecord',match:{company:'co',sourceKey:'study'},data:{objectId:'d',createdAt:new Date(),company:'co',id:77,sourceKey:'study'}},{table:'CommonModel',match:{company:'co',sourceKey:'study'},data:{objectId:'c',createdAt:new Date(),company:'co',itemId:999,generalId:88,sourceKey:'study'},reference:{itemId:'id'}}];
+ await run('request-0004',pair);await run('request-0005',pair);assert.equal((await db.query('SELECT "itemId" FROM "CommonModel"')).rows[0].itemId,77);assert.equal((await db.query('SELECT COUNT(*) n FROM "DailyStudyRecord"')).rows[0].n,1);
+ }finally{await db.close();}
+});
+test('完课确认:学生与老师依次确认,重复确认幂等,越权和取消预约拒绝',async()=>{
+ const db=new PGlite();try{
+ await db.exec(`CREATE TABLE "CourseAppointment"("objectId" text,company text,id bigint,szyh bigint,pl bigint,dszt text,"updatedAt" timestamptz);CREATE TABLE "CommonModel"(company text,"modelId" int,"itemId" bigint,"generalId" bigint,status int);INSERT INTO "CourseAppointment" VALUES('a','co',1,10,20,'11',now());INSERT INTO "CommonModel" VALUES('co',54,1,100,0);`);await db.exec(sql);
+ const confirm=(status,actor)=>db.query('select xs_mobile_confirm_appointment($1,$2,$3,$4,false) result',['co','100',status,actor]);
+ await assert.rejects(confirm(20,99),/无权/);await assert.rejects(confirm(30,20),/状态已变更/);
+ assert.equal((await confirm(20,10)).rows[0].result.status,20);
+ assert.equal((await confirm(20,10)).rows[0].result.unchanged,true);
+ assert.equal((await confirm(30,20)).rows[0].result.status,30);
+ assert.equal((await confirm(20,10)).rows[0].result.status,30);
+ await db.exec('UPDATE "CommonModel" SET status=-2');await assert.rejects(confirm(30,20),/已取消/);
+ }finally{await db.close();}
+});

Некоторые файлы не были показаны из-за большого количества измененных файлов