Przeglądaj źródła

feat: sync student coach and backend migration updates

彭峰 1 miesiąc temu
rodzic
commit
0298322d9c
100 zmienionych plików z 6286 dodań i 816 usunięć
  1. 4 4
      docs/migration/admin-migration-status.md
  2. 60 106
      docs/migration/wxapp-cloud-action-matrix.md
  3. 164 27
      legacy-sync-bridge/Program.cs
  4. 76 6
      legacy-sync-bridge/README.md
  5. 4 1
      legacy-sync-bridge/appsettings.json
  6. 78 0
      legacy-sync-bridge/database/install.sql
  7. 60 0
      legacy-sync-bridge/database/verify-readonly.sql
  8. 123 0
      legacy-sync-bridge/deployment/Install-XiaoshuLegacySyncBridge.ps1
  9. 62 0
      legacy-sync-bridge/deployment/Rollback-XiaoshuLegacySyncBridge.ps1
  10. 48 0
      legacy-sync-bridge/deployment/Test-XiaoshuLegacySyncBridge.ps1
  11. 18 0
      legacy-sync-bridge/deployment/appsettings.Production.template.json
  12. 19 0
      legacy-sync-bridge/deployment/package.sh
  13. 7 0
      package.json
  14. BIN
      public/static/images/logo-wordmark.png
  15. 52 0
      scripts/audit-app-cloud-actions.mjs
  16. 268 0
      scripts/audit-production-migration.mjs
  17. 478 0
      scripts/backfill-core-business-data.mjs
  18. 269 0
      scripts/backfill-legacy-users.mjs
  19. 250 0
      scripts/backfill-vocabulary.mjs
  20. 10 10
      scripts/deploy-admin-functions.mjs
  21. 118 0
      scripts/diagnose-parse-psql-schema.mjs
  22. 171 0
      scripts/ensure-app-member-schemas.mjs
  23. 88 0
      scripts/ensure-app-performance-indexes.mjs
  24. 134 0
      scripts/repair-core-physical-schema.mjs
  25. 3 3
      scripts/smoke-admin-functions.mjs
  26. 117 0
      scripts/smoke-app-auth-learning.mjs
  27. 350 0
      scripts/smoke-app-member-resources.mjs
  28. 99 0
      scripts/smoke-legacy-sync-bridge.mjs
  29. 50 9
      scripts/smoke-operations-functions.mjs
  30. 12 0
      src/app/admin/admin-session.service.spec.ts
  31. 8 6
      src/app/admin/admin-session.service.ts
  32. 0 1
      src/app/admin/admin.constants.ts
  33. 139 2
      src/app/admin/admin.models.ts
  34. 1 0
      src/app/admin/admin.routes.ts
  35. 17 2
      src/app/admin/operations.service.spec.ts
  36. 36 8
      src/app/admin/operations.service.ts
  37. 1 1
      src/app/admin/pages/admin-dashboard.component.html
  38. 2 1
      src/app/admin/pages/admin-sync-center.component.ts
  39. 0 2
      src/app/admin/pages/operations-coach-detail.component.html
  40. 495 0
      src/app/admin/pages/operations-coach-detail.component.scss
  41. 136 0
      src/app/admin/pages/operations-coach-detail.component.spec.ts
  42. 19 8
      src/app/admin/pages/operations-coach-detail.component.ts
  43. 10 4
      src/app/admin/pages/operations-coaches.component.html
  44. 60 0
      src/app/admin/pages/operations-coaches.component.scss
  45. 3 3
      src/app/admin/pages/operations-coaches.component.ts
  46. 1 1
      src/app/admin/pages/operations-payroll.component.ts
  47. 69 0
      src/app/admin/pages/operations-schedule-status.ts
  48. 50 23
      src/app/admin/pages/operations-schedule.component.html
  49. 433 0
      src/app/admin/pages/operations-schedule.component.scss
  50. 83 1
      src/app/admin/pages/operations-schedule.component.spec.ts
  51. 99 26
      src/app/admin/pages/operations-schedule.component.ts
  52. 2 1
      src/app/admin/pages/operations-special-training.component.html
  53. 0 0
      src/app/admin/pages/operations-special-training.component.scss
  54. 7 6
      src/app/admin/pages/operations-special-training.component.ts
  55. 73 0
      src/app/admin/pages/operations-store-detail.component.html
  56. 77 0
      src/app/admin/pages/operations-store-detail.component.scss
  57. 64 0
      src/app/admin/pages/operations-store-detail.component.spec.ts
  58. 84 0
      src/app/admin/pages/operations-store-detail.component.ts
  59. 3 3
      src/app/admin/pages/operations-stores.component.html
  60. 1 1
      src/app/admin/pages/operations-stores.component.scss
  61. 8 3
      src/app/admin/pages/operations-stores.component.ts
  62. 2 1
      src/app/admin/pages/operations-user-detail.component.html
  63. 1 1
      src/app/admin/pages/operations-user-detail.component.ts
  64. 2 2
      src/app/admin/pages/operations-users.component.html
  65. 0 0
      src/app/admin/pages/operations-users.component.scss
  66. 59 3
      src/app/admin/pages/operations-users.component.ts
  67. 5 0
      src/app/admin/pages/operations-vocabulary.component.spec.ts
  68. 13 3
      src/app/core/all-routes.integration.spec.ts
  69. 88 26
      src/app/core/api.service.spec.ts
  70. 75 46
      src/app/core/api.service.ts
  71. 0 3
      src/app/core/app.constants.ts
  72. 7 19
      src/app/core/auth.service.spec.ts
  73. 4 3
      src/app/core/auth.service.ts
  74. 5 10
      src/app/core/cloud-action-migration.ts
  75. 128 58
      src/app/core/home-learning-modules.integration.spec.ts
  76. 19 0
      src/app/core/learning-access.ts
  77. 1 1
      src/app/core/page-catalog.ts
  78. 37 0
      src/app/core/session.service.spec.ts
  79. 24 17
      src/app/core/session.service.ts
  80. 2 1
      src/app/features/appointment/appointment-page.component.html
  81. 2 1
      src/app/features/auth/login-page.component.html
  82. 1 0
      src/app/features/auth/login-page.component.scss
  83. 10 3
      src/app/features/auth/login-page.component.ts
  84. 10 15
      src/app/features/course-center/course-center-page.component.html
  85. 16 12
      src/app/features/course-center/course-center-page.component.scss
  86. 100 45
      src/app/features/course-center/course-center-page.component.ts
  87. 19 21
      src/app/features/english-reading/ncloud-reading.service.spec.ts
  88. 36 74
      src/app/features/english-reading/ncloud-reading.service.ts
  89. 41 120
      src/app/features/home/account-page.component.ts
  90. 1 1
      src/app/features/home/home-page.component.html
  91. 9 6
      src/app/features/home/home-page.component.scss
  92. 24 8
      src/app/features/learning/learning-page.component.html
  93. 6 0
      src/app/features/learning/learning-page.component.scss
  94. 82 21
      src/app/features/learning/learning-page.component.ts
  95. 0 1
      src/app/features/study-records/study-records-page.component.html
  96. 32 6
      src/app/features/study-records/study-records-page.component.scss
  97. 208 18
      src/app/features/study-records/study-records-page.component.ts
  98. 2 1
      src/app/features/team/team-page.component.ts
  99. 34 0
      src/styles.scss
  100. 8 0
      tools/LegacyAssemblyInspector/LegacyAssemblyInspector.csproj

+ 4 - 4
docs/migration/admin-migration-status.md

@@ -94,7 +94,7 @@
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。
 - 8 个管理/CMS 函数均验证未认证请求返回 401;临时帐套管理员完成真实只读冒烟测试后已删除。
-- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:28 个已映射,源码清单内 63 个显式返回 501 阻塞原因;动态调用的兼容 action `content_add_zt` 也已映射,`product_add`、`product_upd` 仍显式阻塞,因此线上为 29 个已实现、65 个阻塞项。经典注册/资料更新、课程词库进度、六类余额/积分账本读取、学习次数/生词状态、直属团队/VIP 统计、陪练预约学员分组、15 段复习计划、7 类规范化内容新增/更新、预约状态/课时账本/抗遗忘批量创建及旧版统计公式均已完成;缺主表的商品读取、缺短信校验的密码重置及其他缺副表模型继续按请求明确 501。详见 `wxapp-cloud-action-matrix.md`。
+- `xiaoshu.app.gateway` 已覆盖旧 H5 源码静态清单的 91 个 action,并提供上传、英语阅读等兼容动作;生产 `migration_status` 当前为 86 个已实现、16 个阻塞项。商品、购物车、优惠券、订单、学习豆支付、物流、评价、商户销售和代理库存已经使用锁定规范类及专用云函数完成真实读写、幂等和审计。Angular 浏览器端全部收口到该云函数,不再携带旧 API 凭据或回退旧 `/api/WXAPP`;剩余短信、微信、外部资金、支付密码和实名动作仍不算业务语义迁移完成。详见 `wxapp-cloud-action-matrix.md`。
 - 旧数据的 `isDeleted` 在部分物理表与 Parse Schema 不一致,把它放入 Parse 查询条件会返回 500;网关改为公司条件查询后在结果侧兼容过滤。
 - Angular H5 已开始按页回归切换:账号密码登录、版本、栏目、公开内容、8 类 addon 联表,以及学习记录详情、预约详情、生词本、21 天抗遗忘列表可走云函数;私有读取只在真实 Parse 会话下切换,其余 action 仍由显式迁移路由保留旧端点。
 
@@ -109,9 +109,9 @@
 | `ZL_SearchView` | `cms.search` | 已实现 | 搜索 `CommonModel` 并清理输出 HTML |
 | `ZL_User_PlatView` | `cms.users.platform` | 已实现 | 使用合并进 `_User` 的 legacy 平台资料 |
 | `ZL_User_WXView` | `cms.users.wechat` | 已实现 | 使用合并进 `_User` 的微信资料并过滤密钥 |
-| `ZL_CartProView` | — | 数据阻塞 | 目标 Schema 无购物车类,且迁移登记没有可用行 |
-| `ZL_Order_PayedView` | — | 数据阻塞 | 目标 Schema 无订单/支付明细类 |
-| `ZL_Order_ProSaleView` | — | 数据阻塞 | 目标 Schema 无订单商品明细类 |
+| `ZL_CartProView` | `cart_list` / `ShoppingCartItem + CommerceProduct` | 已实现 | 按帐套、本人、发布状态和软删除状态联查 |
+| `ZL_Order_PayedView` | `order_list` / `CommerceOrder + CommercePayment` | 已实现 | 学习豆支付采用原子事务并保存支付快照 |
+| `ZL_Order_ProSaleView` | `user_shop_sales` / `CommerceOrderItem` | 已实现 | 按商户及周、月、累计维度汇总真实订单明细 |
 | `ZL_Order_ShareView` | — | 数据阻塞 | 目标 Schema 无订单分享关系类 |
 | `ZL_Plat_BlogView` | — | 数据阻塞 | 目标 Schema 无博客正文/回复类 |
 

+ 60 - 106
docs/migration/wxapp-cloud-action-matrix.md

@@ -1,125 +1,79 @@
 # WXAPP 云函数接口迁移矩阵
 
-## 口径
+核验日期:2026-09-01
 
-- 基线来自旧 uni-app 源码静态扫描生成的 `SOURCE_API_ACTIONS`,共 91 个唯一 action。
-- 统一新入口为 `POST /api/functions/xiaoshu/app/gateway`。
-- 请求体统一使用 `{ "token": "<sessionToken>", "params": { "action": "...", ... } }`;执行器会占用顶层 `id`,业务参数不得放在顶层。公开接口可省略 `token`,但仍必须保留 `params`。
-- 源码清单内已有云函数映射 28 个,另有 63 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。动态调用的兼容 action `content_add_zt` 也已映射;`product_add`、`product_upd` 仍为兼容阻塞项,因此线上共 29 个已实现、65 个阻塞项。矩阵不存在未覆盖的源码 action。
-- “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
+## 统一入口
 
-## 已映射 action
+- C 端 Angular 所有业务 action 统一调用 `POST /api/functions/xiaoshu/app/gateway`。
+- 请求体为 `{ "token": "<Parse sessionToken>", "params": { "action": "..." } }`。
+- Angular 已移除旧 `/api/WXAPP` 回退、旧 API ID/Key 和直接 Parse `/classes/*` 读写。
+- 生产 `migration_status` 当前返回 **86 个已实现 action、16 个显式阻塞 action**,并将 `payment_cart`、`payment_cart_again`、`payment_success` 的外部支付分支单列为部分完成。
+- 16 个阻塞 action 也进入新云函数,但返回 HTTP 501 和阻塞原因;这表示调用链已收口,不表示旧功能已经实现。
+
+## 已实现 action
 
 | 领域 | action |
 |---|---|
-| 版本 | `app_update` |
+| 版本/上传 | `app_update`, `upload` |
 | 内容 | `content_add`, `content_get`, `content_list`, `content_list_llk`, `content_update`, `content_uphis`, `node_get`, `node_list` |
 | 账号/团队 | `user_get`, `user_info_name`, `user_list`, `user_dept`, `user_login_passwd`, `user_register`, `user_update`, `e_user_list` |
 | 学习/预约 | `content_add_zt`, `e_add_words`, `e_ck_list`, `e_get_21list`, `e_order_detail`, `e_order_tongji`, `e_order_update_v2`, `e_record_detail`, `e_words_list`, `stu_record_update_v2`, `user_point_list` |
+| 会员资料 | `receaddr_add`, `receaddr_del`, `receaddr_get`, `receaddr_list`, `receaddr_upd`, `invoice_add`, `invoice_del`, `invoice_get`, `invoice_list`, `invoice_upd`, `user_bank_add`, `user_bank_del`, `user_bank_get`, `user_bank_list` |
+| 互动与进度 | `vote_add`, `vote_ask`, `vote_question`, `unit_record_update`, `user_star_add`, `user_star_del`, `user_star_is` |
 | 反馈 | `guestbook_add` |
+| 服务申请 | `pub_add`, `pub_list` |
+| 商城商品与购物车 | `product_list`, `product_get`, `product_add`, `product_upd`, `product_del`, `product_sale_change`, `product_stock_change`, `product_stock_list`, `cart_add`, `cart_list` |
+| 优惠券 | `coupon_list`, `coupon_usrgot_add`, `coupon_usrgot_list` |
+| 订单、支付与物流 | `payment_cart`, `payment_cart_again`, `payment_success`, `order_get`, `order_list`, `order_delivery`, `order_signfor`, `user_shop_order`, `user_shop_sales` |
+| 订单评价 | `order_comment_add`, `order_comment_list` |
+| 代理库存 | `user_group_usr_upgrade`, `user_group_usr_supply` |
+| 英语阅读 | `reading/me`, `reading/user/update`, `reading/query`, `reading/get`, `reading/create`, `reading/update` |
 
-已实测 `content_list` 在公司帐套下返回 92,435 条,`modelId=56` 返回 108 条。读取用 Psql 并强制 `company=7pIbDBJmKx`,所有客户端输入均使用参数化查询。
-
-`content_list` 已按 `CommonModel.itemId = addon.id` 重建下列规范化联表:
-
-| ModelID | 旧表 | 规范化类 | 已核验数量 |
-|---:|---|---|---:|
-| 52 | `ZL_C_ck` | `VocabularyWord` | 89,620 |
-| 53 | `ZL_C_lxjl` | `PracticeRecord` | 816 |
-| 54 | `ZL_C_order` | `CourseAppointment` | 345 |
-| 56 | `ZL_C_ss` | `DailyStudyRecord` | 108 |
-| 58 | `ZL_C_kcbd` | `CourseBinding` | 55 |
-| 59 | `ZL_C_skjl` | `LessonRecord` | 82 |
-| 60 | `ZL_C_gywjl` | `MemoryPracticeRecord` | 1,380 |
-| 61 | `ZL_C_cpda` | `AssessmentProfile` | 14 |
-
-`myfield`/`myfield2` 只允许白名单字段和参数化等值过滤,`orders` 只允许已映射字段的 `ASC`/`DESC` 或旧 `NEWID()` 随机排序。Model 53/54/56/58/59/60/61 的列表与详情禁止匿名读取,非管理员仅能访问本人或指派给自己的学员记录。
-
-学习专用只读接口已按旧页面的真实响应形状重建:
-
-- `e_record_detail`:由 Model 56 学习记录解析 `xxqs`,再按其中的 `GeneralID` 联查 Model 52 与 `VocabularyWord`,保持原顺序并为每项返回旧页面要求的 `detail[0]`。
-- `e_words_list`:只返回指定用户 `PracticeRecord.jrscb=1` 的生词,按 `scid -> CommonModel.generalId -> VocabularyWord.id` 联查词库详情,不再误返回 89,620 条全量词库。
-- `e_ck_list`:按一个或多个词库单元返回 Model 52 词条,并按 `用户 + 单词 GeneralID` 联查 `PracticeRecord.xxcs/jrscb`,恢复旧页面的 `w_learned` 与生词状态。
-- `e_add_words`:验证词库 ID 与用户归属后批量更新 `PracticeRecord`;`save=1` 递增学习次数,`ifnew=1/0` 加入/移出生词,首次学习会创建带云端来源标识的进度记录。
-- `stu_record_update_v2`:按 `预约 GeneralID + 学员 ID` 幂等新增或更新 Model 56;校验预约归属和词库 ID,根据 `xxqs.check` 重算 `learned/ygg/djq`。线上运行时实际未暴露文档中的 `Psql.transaction`,因此新记录采用安全整数范围内的高熵数字 ID,并顺序写入 `DailyStudyRecord` 与 `CommonModel`;任一步失败都会补偿删除已创建记录,避免孤立数据。
-- `content_add_zt`:恢复旧源码专用于 Model 56 的学习记录创建逻辑,按北京时间当天生成 `+1/+2/+3/+5/+7/+9/+11/+14/+17/+21/+30/+40/+50/+60/+90` 共 15 个 `fxrl` 日期;验证用户归属和词库 ID 后补偿式双写 `DailyStudyRecord + CommonModel`,不把缺 Schema 的其他内容模型混入该接口。
-- `content_update`:按 `GeneralID` 解析现有 Model 53/54/56/58/59/60/61 的规范化副表,先以副表原有学员/陪练字段鉴权,再按实际 Parse Schema 转换类型并批量更新主副表;拒绝变更所属用户,失败时恢复更新前字段。Model 59 仅开放已有 `ZL_C_skjl` 对应的 Node 296,缺独立 Schema 的 Node 77 继续返回 501。
-- `content_add`:对 Model 53/54/56/58/59/60/61 按目标 Schema 白名单转换 addon 字段,验证所属学员与业务操作人后补偿式新增副表和 `CommonModel`,返回旧契约字符串 `GeneralID`。Model 55、57 及 Model 59/Node 77 因目标库没有对应副表而逐请求返回 501,不把数据错写到相似模型。
-- `e_order_detail`:以预约内容 `GeneralID` 联查 `CourseAppointment`,返回数组契约、旧字段别名和可恢复的课程名称。
-- `e_order_tongji`:按交付包 `Pages/API/WXAPP.cshtml` 原公式恢复六个字符串字段。陪练按 `LessonRecord.jsmz` 统计课型 1/2/3,佣金分别为 20/40/40、时长分别为 0.5/1/1 小时;学员按 `CourseAppointment.szyh` 且 `dszt>10` 统计课型和时长,`t_total` 继续按旧逻辑统计 `PracticeRecord.yhid`。
-- `e_order_update_v2`:恢复 `0→10→11→20→30` 角色状态机。状态 11 以单条 PostgreSQL CTE 原子写入课时账本并更新 `_User.legacyUserData`:类型 1/2/3/4 分别扣 `Purse/SilverCoin/UserExp/UserPoint`,类型 1/2 同步扣 0.5/1 `UserPoint`;账本 `sourceKey` 作为幂等声明。随后按学习记录 `fxrl` 补齐最多 15 组 `MemoryPracticeRecord + CommonModel`,中断重试会修复缺失主记录,当前调用失败则补偿余额、账本及本次新增复习记录。
-- `e_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
-- `user_get`:从 `_User.legacyUserData` 恢复 `VIP/ParentUserID/GroupID/RegTime` 与余额字段,返回旧页面同时使用的 camelCase/PascalCase 别名;不返回 `UserPwd`、支付密码、哈希或会话字段。
-- `user_register`:恢复旧页 6–18 位密码契约,在公司范围内原子分配 `legacyUserId`,写入默认 `GroupID=1`、邀请人 `ParentUserID` 及六类账户初始值,返回可立即使用的 Parse `sessionToken`。
-- `user_update`:兼容旧设置页 `mu` JSON 与 Angular 扁平字段,同步昵称、头像、姓名、性别、生日、手机、邮箱、头衔等保留字段;邀请人只允许首次完善,并拒绝自指和循环关系。
-- `user_list`、`user_dept`:按迁移保留的 `legacyUserData.ParentUserID` 重建直属团队分页和 `v0-v5` 会员等级统计,并只允许本人纵向团队链或管理员查看。
-- `e_user_list`:按旧源码的 Model 54、`status=99`、`CourseAppointment.pl` 分组预约学员,不再依赖目标库中仅有 2 条的通用 `agent` 指针。
-- `user_point_list`:按旧接口 `stype=1..6` 分别查询 `UserExpDomP/UserSIcon/UserExpHis/UserUserPoint/UserDummyPoint/UserCredit`,恢复 `ExpHisID/HisTime/Detail` 别名;迁移资料没有提现费率,余额日志的 `purse_fee` 明确返回 `null` 并标记规则不可用。
-- `guestbook_add`:兼容旧页的 `model` JSON(`UserID/Title/TContent/Cateid`)和 Angular 表单的扁平字段,限制 800 字并阻止普通用户冒用他人 `UserID`。
-- `content_get`:恢复旧页面依赖的单元素数组 `result[0]` 契约;私有 Model 详情继续要求真实 Parse 会话并校验本人、直属学员或管理员范围。
-- `content_uphis`:文章浏览量允许匿名原子 `+1`,忽略客户端自定义增量;私有 Model 仍要求会话和记录归属权限。
-- `node_list`:普通栏目按旧 `OrderID/NodeID` 升序;`ifunit=1` 分支使用真实 Parse 会话,按 Model 52 词库和 `PracticeRecord.xxcs` 返回每单元 `total/yx_word/process`。
-- `node_get`:除通用栏目别名外,补齐连连看页实际读取的 `ConsumePoint/ConsumeDeposit` 及相关消费字段 PascalCase 别名。
-- `app_update`:从目标 `App.index/version/changelog/downUrl` 恢复 `ver/nver/intro/path`;迁移资料没有 APK 字节大小,因此 `size=null` 并显式标记 `sizeUnavailable=true`。目标记录中的安装包地址已保留,但其文件域名在 2026-08-18 检查时 TLS 证书已过期,实际下载仍属于外部环境阻塞。
-
-`user_info_name` 仅用于匿名用户名存在性查重,不复制旧微信页“只凭手机号查询结果直接建立本地登录态”的无凭证登录语义;手机号登录必须在 `mcode_send/user_login_mobile` 具备新短信校验链路后恢复,微信登录必须在 `user_sync2` 具备新版容器授权后恢复。
-
-迁移数据没有保存复习收入金额,也没有提供可验证的计价公式,因此 `e_get_21list_tj` 改为显式 501;收入列表仍返回复习事实,但用 `incomeRuleUnavailable=true` 标记金额不可恢复,不伪造收入。
-
-## 因目标 Schema/历史数据缺失而阻塞
-
-| 能力 | action | 缺失项 |
-|---|---|---|
-| 购物车 | `cart_list` | 无购物车类 |
-| 优惠券 | `coupon_list`, `coupon_usrgot_add`, `coupon_usrgot_list` | 无优惠券实例和用户领取关系类 |
-| 订单/物流 | `order_delivery`, `order_get`, `order_list`, `order_signfor`, `user_shop_order`, `user_shop_sales` | 无订单、商品明细、销售和物流类 |
-| 订单评价 | `order_comment_add`, `order_comment_list` | 无评价类 |
-| 支付 | `payment_cart`, `payment_cart_again`, `payment_success` | 无订单/支付明细类 |
-| 发票 | `invoice_add`, `invoice_del`, `invoice_get`, `invoice_list`, `invoice_upd` | 无发票类 |
-| 收货地址 | `receaddr_add`, `receaddr_del`, `receaddr_get`, `receaddr_list`, `receaddr_upd` | 无地址类 |
-| 提现账户 | `user_bank_add`, `user_bank_del`, `user_bank_get`, `user_bank_list` | 无提现账户类 |
-| 收藏 | `user_star_add`, `user_star_del`, `user_star_is` | 无用户收藏关系类 |
-| 会员订单 | `user_group_usr_supply`, `user_group_usr_upgrade` | 无会员购买/续费订单类 |
-| 投票 | `vote_add`, `vote_ask`, `vote_question` | 无可证明的投票记录类 |
-| 商品读取 | `product_get`, `product_list` | 目标 `Product` 仅迁入 `ZL_P_Product` 附表的 `id/pics/size/color/nums/isChu`;缺 `ZL_Commodities` 主表的商品名、价格、正文、所有者、销售状态和库存 |
-| 库存流水 | `product_stock_list` | 无旧 `ZL_Shop_Stock` 对应类,不能用商品附表伪装库存流水 |
-
-上述接口要恢复 1:1 能力,必须先补建 Schema,并取得可迁移的旧数据;仅有空表或空成功响应不能算迁移完成。
-
-## 因外部服务、安全或合规而阻塞
-
-| 能力 | action | 需要的前置条件 |
-|---|---|---|
-| 短信验证码 | `mcode_send`, `user_login_mobile`, `user_register_mobile` | 新短信服务商凭据、验证码存储和限流 |
-| 微信授权 | `user_sync2` | 新微信容器流程和凭据 |
-| 资金/积分 | `user_cash_add`, `user_cash_list`, `user_coin_recharge`, `user_exp_transfer`, `user_money_recharge`, `user_money_transfer` | 支付凭据、不可变事务账本和业务规则 |
-| 复习收入 | `e_get_21list_tj` | 旧迁移数据中的收入金额字段或经业务方确认的计价公式 |
-| 支付密码 | `user_update_paypwd` | 独立哈希服务和重置流程 |
-| 密码安全 | `user_update_pwd`, `user_update_pwdall` | 前者缺新短信验证码服务;后者还依赖独立支付密码哈希存储 |
-| 实名认证 | `user_rnauth_add`, `user_rnauth_get`, `user_rnauth_upd` | 合规审核、加密存储和访问审计 |
+旧账号登录采用服务端过渡迁移:云函数先尝试 Parse 登录;若该旧账号尚未迁密码,则由云函数在服务端验证旧密码,成功后设置 Parse 密码并签发 Parse 会话。旧接口凭据不会发送给浏览器。
 
-## 有数据但尚未能证明 1:1 语义
+地址、发票、银行卡、收藏与单元学习进度使用新建的规范业务类,客户端类级权限已关闭,只允许云函数按当前用户和帐套读写。生产读写冒烟已验证用户所有权、幂等、软删除、审计和直接数据类访问拒绝。英语阅读仅允许 `SurveyItem`、`SurveyLog`、`PromptTemplate` 白名单类;写入仅开放 `SurveyItem` 和 `SurveyLog`,并执行用户所有权校验。响应会移除 ACL、哈希、密码和会话等敏感字段。
 
-| 能力 | action | 待确认/待实现 |
-|---|---|---|
-| 缺失内容副表 | `content_add` 的 Model 55、57 与 Model 59/Node 77 分支 | 数据库迁移未提供生词集合、单元聚合与连连看成绩目标类;接口逐请求返回 501 |
-| 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 缺 `ZL_Commodities` 主表、`UserID/Sales/Stock` 字段及 `ZL_Shop_Stock` 库存流水;兼容动作 `product_add/product_upd` 同样缺主副表事务目标 |
-| 单元进度 | `unit_record_update` | 旧源码在接口调用前无条件 `return`;需业务方确认废弃或提供新的聚合规则 |
-| 发布 | `pub_add`, `pub_list` | 旧前端实际依赖 Pub 7–13(注销、供应商、商户邀请、退货、换货、结算),目标库只迁入 Pub 2–6 以及 Pub 3/4/5 对应的 `PubZXDC/PubTw/PubWTHD`;缺失业务配置、副表和历史记录,不能错写到现有问答/调查表 |
+## 已迁移标准模型
+
+| ModelID | 业务 | Parse 主记录 | Parse 附表 | 2026-09-01 数量 |
+|---:|---|---|---|---:|
+| 52 | 词库 | `CommonModel` | `VocabularyWord` | 106,521 |
+| 53 | 练习记录 | `CommonModel` | `PracticeRecord` | 主记录 430,474;附表 430,651(含 177 条保留孤立历史) |
+| 54 | 预约排课 | `CommonModel` | `CourseAppointment` | 29,892 |
+| 56 | 每日学习 | `CommonModel` | `DailyStudyRecord` | 19,017 |
+| 58 | 课程绑定 | `CommonModel` | `CourseBinding` | 1,514 |
+| 59 | 上课记录 | `CommonModel` | `LessonRecord` | 18,793 |
+| 60 | 抗遗忘记录 | `CommonModel` | `MemoryPracticeRecord` | 194,185 |
+| 61 | 测评档案 | `CommonModel` | `AssessmentProfile` | 206 |
 
-## 前端切换决策
+历史回填的最终 dry-run 对旧源主键不再产生新增或更新。Parse 中高于旧库的数量是保留历史或新系统记录,不表示旧源记录缺失。
+
+## 商城规范化结果
+
+- 商品、购物车、库存流水、优惠券、订单、订单明细、支付明细、评价和代理库存流水均已建立专用规范类,类级权限完全关闭。
+- 学习豆结算使用数据库锁和单语句事务,原子校验余额、库存、优惠券与购物车并写入全部流水;重复提交不会重复扣款或出库。
+- 外部微信/支付宝充值未配置时明确拒绝,不生成假支付结果。
+- 生产专项冒烟已覆盖领券、购物车合并、扣款、库存、订单、发货、签收、评价、商户销售和代理库存幂等,并在结束后清理测试数据。
+
+## 外部服务、安全或规则缺失而阻塞
+
+| 能力 | action | 前置条件 |
+|---|---|---|
+| 短信 | `mcode_send`, `user_login_mobile`, `user_register_mobile` | 新短信服务、验证码存储和限流 |
+| 微信授权 | `user_sync2` | 新微信容器流程和凭据 |
+| 资金/积分 | `user_cash_add`, `user_cash_list`, `user_coin_recharge`, `user_exp_transfer`, `user_money_recharge`, `user_money_transfer` | 支付凭据、独立支付密码服务、不可变事务账本和确认后的转账/提现规则 |
+| 支付密码 | `user_update_paypwd`, `user_update_pwdall` | 独立安全哈希和重置流程 |
+| 密码短信重置 | `user_update_pwd` | 新短信验证码链路 |
+| 实名认证 | `user_rnauth_add`, `user_rnauth_get`, `user_rnauth_upd` | 合规审核、加密存储和访问审计 |
 
-Angular H5 已启用混合迁移路由:
+## 完成判定
 
-- `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。`user_info_name` 查重和 `user_register` 经典注册已直接切换云函数。
-- `app_update`、`node_list`、`node_get` 已切换至云函数;匿名公开内容及持有真实 Parse 会话的 `content_get/content_uphis` 已切换,旧会话继续留在旧端点,避免私有学习详情在密码迁移完成前丢失鉴权。
-- 持有真实 Parse 会话时,`content_add`、`content_add_zt`、`content_update`、`e_add_words`、`e_ck_list`、`e_order_detail`、`e_order_tongji`、`e_order_update_v2`、`e_record_detail`、`e_user_list`、`e_words_list`、`e_get_21list`、`guestbook_add`、`stu_record_update_v2`、`user_get`、`user_list`、`user_dept`、`user_point_list`、`user_update` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
-- 不含 addon 条件的 `content_list` 和 Model 52 公开词库已切换;已持有 Parse 会话的用户会把字段白名单内的 Model 53/54/56/58/59/60/61 addon 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。
-- 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。
-- `product_list/product_get/product_stock_list` 已从“部分映射”纠正为显式 501:当前 `Product` 只是附表,不能满足旧商品或库存流水契约。
+当前可以确认:
 
-在线 65 个阻塞 action 中仍包含订单、支付、商品和退换货等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
+- 新 Angular 浏览器端已不再直连旧业务接口;所有调用先进入新云函数。
+- 旧实时接口只读探测中,商城历史数据为 0,因此新商城规范类从上线后开始记录,不伪造旧历史订单;已实现能力以真实生产写后读冒烟验证。
+- 86 个 action 已有新云函数业务实现和权限边界。`e_get_21list_tj` 已按旧接口真实契约迁移为“已完成复习任务数”;旧页面把该数量显示为收入属于旧系统文案/语义错误,不能把数量当成金额。
+- 16 个 action 尚未完成业务语义迁移,不能因为统一返回 501 就算作“已迁移”。
+- 旧服务器持续同步桥尚未上线,历史快照之后的新写入仍可能产生数据库差异。
 
-1. 先对齐缺失 Schema/数据和第三方凭据。
-2. 完成剩余 action 的云函数事务、权限和字段回归。
-3. 将 H5 会话改为 Parse `sessionToken`,再一次性切换到 `/api/functions/xiaoshu/app/gateway`。
+只有在 16 个 action 获得新短信/微信/支付/合规能力后实现,或由业务正式确认下线,并且旧库增量同步、同水位哈希对账、最终停写切换完成后,才可以签署“所有接口及数据库完全迁移”。

+ 164 - 27
legacy-sync-bridge/Program.cs

@@ -8,30 +8,51 @@ using Microsoft.Data.SqlClient;
 using Microsoft.Extensions.Options;
 
 var builder = WebApplication.CreateBuilder(args);
-builder.Services.Configure<SyncOptions>(builder.Configuration.GetSection("XiaoshuSync"));
+builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = 1_048_576);
+builder.Services.AddOptions<SyncOptions>()
+    .Bind(builder.Configuration.GetSection("XiaoshuSync"))
+    .Validate(options => !string.IsNullOrWhiteSpace(options.KeyId), "XiaoshuSync KeyId 未配置")
+    .Validate(options => options.Secret.Length >= 32, "XiaoshuSync Secret 必须至少 32 位")
+    .Validate(options => options.MaxClockSkewSeconds is >= 30 and <= 900, "签名时间窗口必须在 30 至 900 秒之间")
+    .Validate(options => options.DefaultPageSize > 0 && options.MaxPageSize >= options.DefaultPageSize && options.MaxPageSize <= 5000, "同步分页配置无效")
+    .Validate(options => options.MaxCommandBodyBytes is >= 1024 and <= 1_048_576, "写入请求大小上限无效")
+    .Validate(options => options.PathBase.StartsWith('/') && !options.PathBase.EndsWith('/'), "PathBase 必须以 / 开头且不能以 / 结尾")
+    .ValidateOnStart();
 builder.Services.AddSingleton<ReplayGuard>();
 builder.Services.AddSingleton<LegacyRepository>();
 var app = builder.Build();
+app.UsePathBase(app.Services.GetRequiredService<IOptions<SyncOptions>>().Value.PathBase);
 
+app.UseExceptionHandler(exceptionApp => exceptionApp.Run(async context =>
+{
+    context.Response.StatusCode = StatusCodes.Status500InternalServerError;
+    context.Response.ContentType = "application/json";
+    await context.Response.WriteAsJsonAsync(new { error = "sync_bridge_error", message = "同步桥执行失败,请查看服务器日志" });
+}));
 app.Use(async (context, next) =>
 {
-    if (!context.Request.Path.StartsWithSegments("/xiaoshu-sync/v1")) { await next(); return; }
-    var rejection = await RequestAuthenticator.ValidateAsync(context, context.RequestServices.GetRequiredService<IOptions<SyncOptions>>().Value, context.RequestServices.GetRequiredService<ReplayGuard>());
+    if (!context.Request.Path.StartsWithSegments("/v1") || context.Request.Path.Equals("/v1/health")) { await next(); return; }
+    context.Response.Headers.CacheControl = "no-store";
+    var configured = context.RequestServices.GetRequiredService<IOptions<SyncOptions>>().Value;
+    if (context.Request.Path.StartsWithSegments("/v1/commands") && context.Request.ContentLength > configured.MaxCommandBodyBytes) { context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; await context.Response.WriteAsJsonAsync(new { error = "payload_too_large" }); return; }
+    var rejection = await RequestAuthenticator.ValidateAsync(context, configured, context.RequestServices.GetRequiredService<ReplayGuard>());
     if (rejection is not null) { context.Response.StatusCode = rejection.Value.Status; await context.Response.WriteAsJsonAsync(new { error = rejection.Value.Message }); return; }
     await next();
 });
 
-app.MapGet("/xiaoshu-sync/v1/manifest", async (LegacyRepository repository, CancellationToken cancellationToken) => Results.Ok(await repository.ManifestAsync(cancellationToken)));
-app.MapGet("/xiaoshu-sync/v1/changes", async (string dataset, string? cursor, int? limit, LegacyRepository repository, CancellationToken cancellationToken) =>
+app.MapGet("/v1/health", () => Results.Ok(new { service = "xiaoshu-legacy-sync", status = "ok", version = "v1", time = DateTimeOffset.UtcNow }));
+app.MapGet("/v1/manifest", async (LegacyRepository repository, CancellationToken cancellationToken) => Results.Ok(await repository.ManifestAsync(cancellationToken)));
+app.MapGet("/v1/changes", async (string dataset, string? cursor, int? limit, LegacyRepository repository, CancellationToken cancellationToken) =>
 {
     if (!DatasetCatalog.All.TryGetValue(dataset, out var definition)) return Results.BadRequest(new { error = "unsupported_dataset", message = $"不支持的数据集:{dataset}" });
     return Results.Ok(await repository.ChangesAsync(definition, cursor, limit, cancellationToken));
 });
-app.MapPost("/xiaoshu-sync/v1/commands/{operation}", async (string operation, HttpContext context, LegacyRepository repository, CancellationToken cancellationToken) =>
+app.MapPost("/v1/commands/{operation}", async (string operation, HttpContext context, LegacyRepository repository, IOptions<SyncOptions> configured, CancellationToken cancellationToken) =>
 {
     if (!CommandCatalog.Allowed.Contains(operation)) return Results.BadRequest(new { error = "unsupported_operation", message = $"不支持的写入操作:{operation}" });
+    if (context.Request.ContentLength is > 0 && context.Request.ContentLength > configured.Value.MaxCommandBodyBytes) return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
     var command = await context.Request.ReadFromJsonAsync<LegacyCommand>(cancellationToken: cancellationToken);
-    if (command is null || string.IsNullOrWhiteSpace(command.IdempotencyKey) || string.IsNullOrWhiteSpace(command.Reason) || string.IsNullOrWhiteSpace(command.Actor)) return Results.BadRequest(new { error = "invalid_command", message = "actor、reason 和 idempotencyKey 必填" });
+    if (command is null || string.IsNullOrWhiteSpace(command.IdempotencyKey) || command.IdempotencyKey.Length > 128 || string.IsNullOrWhiteSpace(command.Reason) || command.Reason.Length > 500 || string.IsNullOrWhiteSpace(command.Actor) || command.Actor.Length > 200 || command.Payload.ValueKind is not JsonValueKind.Object) return Results.BadRequest(new { error = "invalid_command", message = "actor、reason、idempotencyKey 和对象 payload 必填且长度必须有效" });
     return Results.Ok(await repository.ExecuteCommandAsync(operation, command, cancellationToken));
 });
 app.MapGet("/health", () => Results.Ok(new { service = "xiaoshu-legacy-sync", status = "ok", time = DateTimeOffset.UtcNow }));
@@ -45,6 +66,9 @@ sealed class SyncOptions
     public int MaxClockSkewSeconds { get; init; } = 300;
     public int DefaultPageSize { get; init; } = 500;
     public int MaxPageSize { get; init; } = 1000;
+    public int MaxCommandBodyBytes { get; init; } = 1_048_576;
+    public string LegacyTimeZoneId { get; init; } = "China Standard Time";
+    public string PathBase { get; init; } = "/xiaoshu-sync";
 }
 
 readonly record struct AuthRejection(int Status, string Message);
@@ -67,7 +91,7 @@ static class RequestAuthenticator
         using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true);
         var body = await reader.ReadToEndAsync(); context.Request.Body.Position = 0;
         var bodyHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant();
-        var canonical = $"{context.Request.Method.ToUpperInvariant()}\n{context.Request.Path}{context.Request.QueryString}\n{timestampText}\n{nonce}\n{bodyHash}";
+        var canonical = $"{context.Request.Method.ToUpperInvariant()}\n{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}\n{timestampText}\n{nonce}\n{bodyHash}";
         using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(options.Secret));
         var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();
         return FixedText(signature.ToLowerInvariant(), expected) ? null : new(401, "请求签名无效");
@@ -89,9 +113,11 @@ sealed class ReplayGuard
 sealed record LegacyCommand(string Actor, string Reason, string IdempotencyKey, string? ExpectedUpdatedAt, JsonElement Payload);
 sealed record ProjectionRecord(string ClassName, string KeyField, string LegacyKey, Dictionary<string, object?> Fields);
 sealed record ChangeItem(string Key, string Operation, DateTimeOffset? ChangedAt, IReadOnlyList<ProjectionRecord> Records);
-sealed record ChangePage(string Dataset, string Cursor, bool HasMore, int Count, DateTimeOffset ServerTime, IReadOnlyList<ChangeItem> Items);
-sealed record DatasetDefinition(string Key, string Label, string CountSql, string ChangesSql, string LegacyKeyColumn, IReadOnlyList<RecordMapping> Records);
+sealed record ChangePage(string Dataset, string Cursor, bool HasMore, int Count, DateTimeOffset ServerTime, string Phase, long Watermark, IReadOnlyList<ChangeItem> Items);
+sealed record DatasetDefinition(string Key, string Label, string CountSql, string KeysSql, string SnapshotSql, string LookupSql, string LegacyKeyColumn, IReadOnlyList<RecordMapping> Records);
 sealed record RecordMapping(string ClassName, string KeyField, string Prefix, string SourcePrefix);
+sealed record CursorState(bool Incremental, long Position, long SnapshotWatermark);
+sealed record ChangeLogRow(long Id, string LegacyKey, string Operation, DateTimeOffset? ChangedAt, string? ItemKey);
 
 static class DatasetCatalog
 {
@@ -99,25 +125,40 @@ static class DatasetCatalog
     public static readonly IReadOnlyDictionary<string, DatasetDefinition> All = new Dictionary<string, DatasetDefinition>(StringComparer.OrdinalIgnoreCase)
     {
         ["users"] = new("users", "全部业务账号", "SELECT COUNT_BIG(*) FROM ZL_User",
-            """SELECT TOP (@limit) CAST(u.UserID AS bigint) legacyKey,COALESCE(u.LastLoginTimes,u.RegTime) changedAt,u.UserID [user__legacyUserId],u.UserName [user__username],u.HoneyName [user__nickname],u.Email [user__email],u.Mobile [user__mobile],u.GroupID [user__legacyGroupId],u.ParentUserID [user__parentUserId],u.RegTime [user__registeredAt],u.LastLoginTimes [user__lastLoginAt],u.LoginTimes [user__loginCount],u.State [user__legacyState],u.Purse [user__purse],u.SilverCoin [user__silverCoin],u.UserExp [user__userExp],u.UserPoint [user__userPoint],u.DummyPurse [user__dummyPurse],u.UserCreit [user__credit] FROM ZL_User u WHERE u.UserID>@after ORDER BY u.UserID""", "legacyKey", [new("_User", "legacyUserId", "user__", "legacy-sync:user:")]),
+            "SELECT CAST(UserID AS bigint) legacyKey FROM ZL_User ORDER BY UserID",
+            """SELECT TOP (@limit) CAST(u.UserID AS bigint) legacyKey,COALESCE(u.LastLoginTimes,u.RegTime) changedAt,u.UserID [user__legacyUserId],u.UserName [user__username],u.HoneyName [user__nickname],u.Email [user__email],u.Mobile [user__mobile],u.GroupID [user__legacyGroupId],u.ParentUserID [user__parentUserId],u.RegTime [user__registeredAt],u.LastLoginTimes [user__lastLoginAt],u.LoginTimes [user__loginCount],u.State [user__legacyState],u.Purse [user__purse],u.SilverCoin [user__silverCoin],u.UserExp [user__userExp],u.UserPoint [user__userPoint],u.DummyPurse [user__dummyPurse],u.UserCreit [user__credit] FROM ZL_User u WHERE u.UserID>@after ORDER BY u.UserID""",
+            """SELECT TOP (1) CAST(u.UserID AS bigint) legacyKey,COALESCE(u.LastLoginTimes,u.RegTime) changedAt,u.UserID [user__legacyUserId],u.UserName [user__username],u.HoneyName [user__nickname],u.Email [user__email],u.Mobile [user__mobile],u.GroupID [user__legacyGroupId],u.ParentUserID [user__parentUserId],u.RegTime [user__registeredAt],u.LastLoginTimes [user__lastLoginAt],u.LoginTimes [user__loginCount],u.State [user__legacyState],u.Purse [user__purse],u.SilverCoin [user__silverCoin],u.UserExp [user__userExp],u.UserPoint [user__userPoint],u.DummyPurse [user__dummyPurse],u.UserCreit [user__credit] FROM ZL_User u WHERE u.UserID=@legacyKey""", "legacyKey", [new("_User", "legacyUserId", "user__", "legacy-sync:user:")]),
         ["nodes"] = new("nodes", "课程与词库目录", "SELECT COUNT_BIG(*) FROM ZL_Node",
-            """SELECT TOP (@limit) CAST(n.NodeID AS bigint) legacyKey,NULL changedAt,n.NodeID [node__nodeId],n.ParentID [node__parentId],n.NodeName [node__nodeName],n.NodeType [node__nodeType],n.NodeDir [node__nodeDir],n.NodePic [node__nodePicUrl],n.Description [node__description],n.OrderID [node__orderId],n.CUser [node__cuser],n.CUName [node__cuname],n.EditDate [node__editDate] FROM ZL_Node n WHERE n.NodeID>@after ORDER BY n.NodeID""", "legacyKey", [new("Node", "nodeId", "node__", "legacy-sync:node:")]),
+            "SELECT CAST(NodeID AS bigint) legacyKey FROM ZL_Node ORDER BY NodeID",
+            """SELECT TOP (@limit) CAST(n.NodeID AS bigint) legacyKey,NULL changedAt,n.NodeID [node__nodeId],n.ParentID [node__parentId],n.NodeName [node__nodeName],n.NodeType [node__nodeType],n.NodeDir [node__nodeDir],n.NodePic [node__nodePicUrl],n.Description [node__description],n.OrderID [node__orderId],n.CUser [node__cuser],n.CUName [node__cuname],n.EditDate [node__editDate] FROM ZL_Node n WHERE n.NodeID>@after ORDER BY n.NodeID""",
+            """SELECT TOP (1) CAST(n.NodeID AS bigint) legacyKey,NULL changedAt,n.NodeID [node__nodeId],n.ParentID [node__parentId],n.NodeName [node__nodeName],n.NodeType [node__nodeType],n.NodeDir [node__nodeDir],n.NodePic [node__nodePicUrl],n.Description [node__description],n.OrderID [node__orderId],n.CUser [node__cuser],n.CUName [node__cuname],n.EditDate [node__editDate] FROM ZL_Node n WHERE n.NodeID=@legacyKey""", "legacyKey", [new("Node", "nodeId", "node__", "legacy-sync:node:")]),
         ["course-bindings"] = Content("course-bindings", "课程绑定", 58, "ZL_C_kcbd", "b", ["yhid","kcid","yxx","cksl","syjd"]),
         ["appointments"] = Content("appointments", "预约排课", 54, "ZL_C_order", "a", ["szyh","pl","fxpl","plxm","kcid","yykcid","dslx","jffs","bxrq","sdsd","yysj","dszt","kssj","jssj","scsj","szmd"]),
         ["lessons"] = Content("lessons", "上课记录", 59, "ZL_C_skjl", "l", ["xymz","jsmz","kcid","kclx","kcmc","pf","pjnr","pldp","kzsj","yyds","plpjsj","szmdid"]),
         ["learning-records"] = Content("learning-records", "每日学习记录", 56, "ZL_C_ss", "d", ["userId","pl","dqrq","dsid","learned","ygg","djq","xxqs","fxrl","szmdid"]),
         ["practice-records"] = Content("practice-records", "练习记录", 53, "ZL_C_lxjl", "p", ["yhid","kcid","scid","xxcs","jrscb"]),
         ["memory-records"] = Content("memory-records", "抗遗忘记录", 60, "ZL_C_gywjl", "m", ["yhid","plid","kcid","xxjlid","fxzt","wcsj","kywrq","kywsj","OrderID"]),
-        ["assessments"] = Content("assessments", "测评档案", 61, "ZL_C_cespj", "e", ["userId","df","askid","wrong","answerid","dontKnow","prevScore","totalScore"]),
+        ["assessments"] = Content("assessments", "测评档案", 61, "ZL_C_cpda", "e", ["userId","df","askid","wrong","answerid","dontKnow","prev_score:prevScore","totalScore"]),
         ["vocabulary"] = Content("vocabulary", "词库", 52, "ZL_C_ck", "v", ["sy","yb","lj","yp"]),
         ["special-training"] = Content("special-training", "专项训练", 2, "ZL_C_Article", "r", ["ico","mp4","author","source","content","synopsis"], " AND c.NodeID=3")
     };
 
     private static DatasetDefinition Content(string key, string label, int modelId, string addonTable, string alias, string[] addonFields, string extraWhere = "")
     {
-        var addonSelect = string.Join(',', addonFields.Select(field => $"{alias}.[{field}] [addon__{ToCamel(field)}]"));
-        var sql = $"SELECT TOP (@limit) CAST(c.GeneralID AS bigint) legacyKey,COALESCE(c.UpDateTime,c.CreateTime) changedAt,c.GeneralID [common__generalId],c.ItemID [common__itemId],c.ModelID [common__modelId],c.NodeID [common__nodeId],c.TableName [common__tableName],c.Title [common__title],c.Subtitle [common__subtitle],c.Inputer [common__inputer],c.CreateTime [common__createTime],c.UpDateTime [common__upDateTime],c.Status [common__status],c.OrderID [common__orderId],c.TopImg [common__topImg],{alias}.ID [addon__id],{addonSelect} FROM ZL_CommonModel c INNER JOIN {addonTable} {alias} ON {alias}.ID=c.ItemID WHERE c.ModelID={modelId}{extraWhere} AND c.GeneralID>@after ORDER BY c.GeneralID";
-        return new(key, label, $"SELECT COUNT_BIG(*) FROM ZL_CommonModel c WHERE c.ModelID={modelId}{extraWhere} AND {ActiveCommon}", sql, "legacyKey", [new("CommonModel", "generalId", "common__", $"legacy-sync:{key}:common:"), new(ClassFor(modelId), "id", "addon__", $"legacy-sync:{key}:addon:")]);
+        var addonSelect = string.Join(',', addonFields.Select(field =>
+        {
+            var mapping = field.Split(':', 2);
+            var sourceField = mapping[0];
+            var targetField = mapping.Length == 2 ? mapping[1] : ToCamel(sourceField);
+            return $"{alias}.[{sourceField}] [addon__{targetField}]";
+        }));
+        var select = $"CAST(c.GeneralID AS bigint) legacyKey,COALESCE(c.UpDateTime,c.CreateTime) changedAt,c.GeneralID [common__generalId],c.ItemID [common__itemId],c.ModelID [common__modelId],c.NodeID [common__nodeId],c.TableName [common__tableName],c.Title [common__title],c.Subtitle [common__subtitle],c.Inputer [common__inputer],c.CreateTime [common__createTime],c.UpDateTime [common__upDateTime],c.Status [common__status],c.OrderID [common__orderId],c.TopImg [common__topImg],{alias}.ID [addon__id],{addonSelect}";
+        var from = $" FROM ZL_CommonModel c INNER JOIN {addonTable} {alias} ON {alias}.ID=c.ItemID WHERE c.ModelID={modelId}{extraWhere} AND {ActiveCommon}";
+        var snapshotSql = $"SELECT TOP (@limit) {select}{from} AND c.GeneralID>@after ORDER BY c.GeneralID";
+        var lookupSql = $"SELECT TOP (1) {select}{from} AND c.GeneralID=@legacyKey";
+        var countSql = $"SELECT COUNT_BIG(*){from}";
+        var keysSql = $"SELECT CAST(c.GeneralID AS bigint) legacyKey{from} ORDER BY c.GeneralID";
+        return new(key, label, countSql, keysSql, snapshotSql, lookupSql, "legacyKey", [new("CommonModel", "generalId", "common__", $"legacy-sync:{key}:common:"), new(ClassFor(modelId), "id", "addon__", $"legacy-sync:{key}:addon:")]);
     }
     private static string ClassFor(int modelId) => modelId switch { 2 => "ContentArticle", 52 => "VocabularyWord", 53 => "PracticeRecord", 54 => "CourseAppointment", 56 => "DailyStudyRecord", 58 => "CourseBinding", 59 => "LessonRecord", 60 => "MemoryPracticeRecord", 61 => "AssessmentProfile", _ => throw new InvalidOperationException() };
     private static string ToCamel(string value) => value.Length == 0 ? value : char.ToLowerInvariant(value[0]) + value[1..];
@@ -135,21 +176,60 @@ sealed class LegacyRepository(IConfiguration configuration, IOptions<SyncOptions
     public async Task<object> ManifestAsync(CancellationToken cancellationToken)
     {
         await using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(cancellationToken);
+        var watermarkStart = await GlobalWatermarkAsync(connection, cancellationToken);
         var datasets = new List<object>();
         foreach (var definition in DatasetCatalog.All.Values)
         {
             await using var command = new SqlCommand(definition.CountSql, connection) { CommandTimeout = 120 };
             var count = Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
-            datasets.Add(new { key = definition.Key, label = definition.Label, count });
+            var checksum = await KeyChecksumAsync(connection, definition, cancellationToken);
+            await using var watermarkCommand = new SqlCommand("SELECT COALESCE(MAX(Id),0) FROM dbo.XiaoshuSyncChangeLog WHERE Dataset=@dataset", connection);
+            watermarkCommand.Parameters.AddWithValue("@dataset", definition.Key);
+            var watermark = Convert.ToInt64(await watermarkCommand.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
+            datasets.Add(new { key = definition.Key, label = definition.Label, count, watermark, checksum });
+        }
+        var watermarkEnd = await GlobalWatermarkAsync(connection, cancellationToken);
+        var now = DateTimeOffset.UtcNow;
+        return new { manifestTime = now, serverTime = now, consistent = watermarkStart == watermarkEnd, watermarkStart, watermarkEnd, datasets };
+    }
+    private static async Task<long> GlobalWatermarkAsync(SqlConnection connection, CancellationToken cancellationToken)
+    {
+        await using var command = new SqlCommand("SELECT COALESCE(MAX(Id),0) FROM dbo.XiaoshuSyncChangeLog", connection);
+        return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
+    }
+    private static async Task<string> KeyChecksumAsync(SqlConnection connection, DatasetDefinition definition, CancellationToken cancellationToken)
+    {
+        using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
+        await using var command = new SqlCommand(definition.KeysSql, connection) { CommandTimeout = 180 };
+        await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess, cancellationToken);
+        while (await reader.ReadAsync(cancellationToken))
+        {
+            var key = Convert.ToString(reader[0], CultureInfo.InvariantCulture) ?? "";
+            hash.AppendData(Encoding.UTF8.GetBytes(key)); hash.AppendData("\n"u8);
         }
-        return new { manifestTime = DateTimeOffset.UtcNow, serverTime = DateTimeOffset.UtcNow, datasets };
+        return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
     }
     public async Task<ChangePage> ChangesAsync(DatasetDefinition definition, string? cursor, int? requestedLimit, CancellationToken cancellationToken)
     {
-        var after = CursorCodec.Decode(cursor); var limit = Math.Clamp(requestedLimit ?? _options.DefaultPageSize, 1, _options.MaxPageSize);
+        var state = CursorCodec.Decode(cursor); var limit = Math.Clamp(requestedLimit ?? _options.DefaultPageSize, 1, _options.MaxPageSize);
         await using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(cancellationToken);
-        await using var command = new SqlCommand(definition.ChangesSql, connection) { CommandTimeout = 180 };
-        command.Parameters.Add(new SqlParameter("@after", SqlDbType.BigInt) { Value = after }); command.Parameters.Add(new SqlParameter("@limit", SqlDbType.Int) { Value = limit + 1 });
+        if (state.Incremental) return await IncrementalChangesAsync(connection, definition, state, limit, cancellationToken);
+        var snapshotWatermark = state.SnapshotWatermark;
+        if (snapshotWatermark == 0)
+        {
+            await using var watermarkCommand = new SqlCommand("SELECT COALESCE(MAX(Id),0) FROM dbo.XiaoshuSyncChangeLog WHERE Dataset=@dataset", connection);
+            watermarkCommand.Parameters.AddWithValue("@dataset", definition.Key);
+            snapshotWatermark = Convert.ToInt64(await watermarkCommand.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
+        }
+        await using var command = new SqlCommand(definition.SnapshotSql, connection) { CommandTimeout = 180 };
+        command.Parameters.Add(new SqlParameter("@after", SqlDbType.BigInt) { Value = state.Position }); command.Parameters.Add(new SqlParameter("@limit", SqlDbType.Int) { Value = limit + 1 });
+        var items = await ReadProjectionItemsAsync(command, definition, cancellationToken);
+        var hasMore = items.Count > limit; if (hasMore) items.RemoveAt(items.Count - 1); var next = items.Count == 0 ? state.Position : long.Parse(items[^1].Key, CultureInfo.InvariantCulture);
+        var nextCursor = hasMore ? CursorCodec.EncodeSnapshot(next, snapshotWatermark) : CursorCodec.EncodeIncremental(snapshotWatermark);
+        return new ChangePage(definition.Key, nextCursor, hasMore, items.Count, DateTimeOffset.UtcNow, "snapshot", snapshotWatermark, items);
+    }
+    private async Task<List<ChangeItem>> ReadProjectionItemsAsync(SqlCommand command, DatasetDefinition definition, CancellationToken cancellationToken)
+    {
         var items = new List<ChangeItem>(); await using var reader = await command.ExecuteReaderAsync(cancellationToken);
         while (await reader.ReadAsync(cancellationToken))
         {
@@ -161,16 +241,46 @@ sealed class LegacyRepository(IConfiguration configuration, IOptions<SyncOptions
                 {
                     var name = reader.GetName(index); if (!name.StartsWith(mapping.Prefix, StringComparison.Ordinal)) continue;
                     var field = name[mapping.Prefix.Length..]; var value = reader.IsDBNull(index) ? null : reader.GetValue(index);
-                    if (value is DateTime date) value = new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Local)).ToUniversalTime(); fields[field] = value;
+                    if (value is DateTime date) value = LegacyDateTime.ToUtc(date, _options.LegacyTimeZoneId); fields[field] = value;
                 }
                 var recordKey = Convert.ToString(fields.GetValueOrDefault(mapping.KeyField), CultureInfo.InvariantCulture) ?? key; fields["sourceKey"] = mapping.SourcePrefix + recordKey;
                 records.Add(new ProjectionRecord(mapping.ClassName, mapping.KeyField, recordKey, fields));
             }
-            DateTimeOffset? changedAt = reader["changedAt"] is DateTime changed ? new DateTimeOffset(DateTime.SpecifyKind(changed, DateTimeKind.Local)).ToUniversalTime() : null;
+            DateTimeOffset? changedAt = reader["changedAt"] is DateTime changed ? LegacyDateTime.ToUtc(changed, _options.LegacyTimeZoneId) : null;
             items.Add(new ChangeItem(key, "upsert", changedAt, records));
         }
-        var hasMore = items.Count > limit; if (hasMore) items.RemoveAt(items.Count - 1); var next = items.Count == 0 ? after : long.Parse(items[^1].Key, CultureInfo.InvariantCulture);
-        return new ChangePage(definition.Key, CursorCodec.Encode(next), hasMore, items.Count, DateTimeOffset.UtcNow, items);
+        return items;
+    }
+    private async Task<ChangePage> IncrementalChangesAsync(SqlConnection connection, DatasetDefinition definition, CursorState state, int limit, CancellationToken cancellationToken)
+    {
+        const string logSql = "SELECT TOP (@limit) Id,LegacyKey,Operation,ChangedAt,ItemKey FROM dbo.XiaoshuSyncChangeLog WHERE Dataset=@dataset AND Id>@after ORDER BY Id";
+        await using var logCommand = new SqlCommand(logSql, connection) { CommandTimeout = 120 };
+        logCommand.Parameters.AddWithValue("@dataset", definition.Key); logCommand.Parameters.AddWithValue("@after", state.Position); logCommand.Parameters.AddWithValue("@limit", limit + 1);
+        var changes = new List<ChangeLogRow>(); await using (var reader = await logCommand.ExecuteReaderAsync(cancellationToken))
+        {
+            while (await reader.ReadAsync(cancellationToken))
+            {
+                DateTimeOffset? changedAt = reader["ChangedAt"] is DateTime changed ? new DateTimeOffset(DateTime.SpecifyKind(changed, DateTimeKind.Utc)) : null;
+                changes.Add(new(Convert.ToInt64(reader["Id"], CultureInfo.InvariantCulture), Convert.ToString(reader["LegacyKey"], CultureInfo.InvariantCulture) ?? "", Convert.ToString(reader["Operation"], CultureInfo.InvariantCulture) ?? "U", changedAt, reader["ItemKey"] is DBNull ? null : Convert.ToString(reader["ItemKey"], CultureInfo.InvariantCulture)));
+            }
+        }
+        var hasMore = changes.Count > limit; if (hasMore) changes.RemoveAt(changes.Count - 1);
+        var items = new List<ChangeItem>();
+        foreach (var change in changes)
+        {
+            if (change.Operation.Equals("D", StringComparison.OrdinalIgnoreCase)) { items.Add(DeleteItem(definition, change)); continue; }
+            await using var lookup = new SqlCommand(definition.LookupSql, connection) { CommandTimeout = 120 };
+            lookup.Parameters.Add(new SqlParameter("@legacyKey", SqlDbType.BigInt) { Value = long.Parse(change.LegacyKey, CultureInfo.InvariantCulture) });
+            var projected = await ReadProjectionItemsAsync(lookup, definition, cancellationToken);
+            items.Add(projected.Count == 0 ? DeleteItem(definition, change) : projected[0] with { ChangedAt = change.ChangedAt ?? projected[0].ChangedAt });
+        }
+        var next = changes.Count == 0 ? state.Position : changes[^1].Id;
+        return new ChangePage(definition.Key, CursorCodec.EncodeIncremental(next), hasMore, items.Count, DateTimeOffset.UtcNow, "incremental", next, items);
+    }
+    private static ChangeItem DeleteItem(DatasetDefinition definition, ChangeLogRow change)
+    {
+        var records = definition.Records.Select((mapping, index) => new ProjectionRecord(mapping.ClassName, mapping.KeyField, index == 0 ? change.LegacyKey : change.ItemKey ?? change.LegacyKey, new Dictionary<string, object?>())).ToList();
+        return new ChangeItem(change.LegacyKey, "delete", change.ChangedAt, records);
     }
     public async Task<object> ExecuteCommandAsync(string operation, LegacyCommand command, CancellationToken cancellationToken)
     {
@@ -184,6 +294,33 @@ sealed class LegacyRepository(IConfiguration configuration, IOptions<SyncOptions
 
 static class CursorCodec
 {
-    public static string Encode(long value) => Convert.ToBase64String(Encoding.UTF8.GetBytes($"v1:{value}"));
-    public static long Decode(string? value) { if (string.IsNullOrWhiteSpace(value)) return 0; try { var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(value)); return decoded.StartsWith("v1:") && long.TryParse(decoded[3..], out var parsed) ? Math.Max(0, parsed) : 0; } catch { return 0; } }
+    public static string EncodeSnapshot(long position, long watermark) => Encode($"v2:s:{Math.Max(0, position)}:{Math.Max(0, watermark)}");
+    public static string EncodeIncremental(long position) => Encode($"v2:c:{Math.Max(0, position)}");
+    public static CursorState Decode(string? value)
+    {
+        if (string.IsNullOrWhiteSpace(value)) return new(false, 0, 0);
+        try
+        {
+            var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(value)); var parts = decoded.Split(':');
+            if (parts.Length == 2 && parts[0] == "v1" && long.TryParse(parts[1], out var legacy)) return new(false, Math.Max(0, legacy), 0);
+            if (parts.Length >= 3 && parts[0] == "v2" && long.TryParse(parts[2], out var position))
+            {
+                if (parts[1] == "c") return new(true, Math.Max(0, position), 0);
+                if (parts[1] == "s") return new(false, Math.Max(0, position), parts.Length > 3 && long.TryParse(parts[3], out var watermark) ? Math.Max(0, watermark) : 0);
+            }
+        }
+        catch { }
+        return new(false, 0, 0);
+    }
+    private static string Encode(string value) => Convert.ToBase64String(Encoding.UTF8.GetBytes(value));
+}
+
+static class LegacyDateTime
+{
+    public static DateTimeOffset ToUtc(DateTime value, string timeZoneId)
+    {
+        var unspecified = DateTime.SpecifyKind(value, DateTimeKind.Unspecified);
+        var zone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
+        return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(unspecified, zone), TimeSpan.Zero);
+    }
 }

+ 76 - 6
legacy-sync-bridge/README.md

@@ -2,7 +2,7 @@
 
 该服务部署在能访问旧 SQL Server 的受控网络内,只提供三类窄接口:
 
-- `GET /xiaoshu-sync/v1/manifest`:返回同一时间点的数据集数量。
+- `GET /xiaoshu-sync/v1/manifest`:返回同一水位的数据集数量、增量水位和按业务主键排序计算的 SHA-256 校验和。
 - `GET /xiaoshu-sync/v1/changes?dataset=...&cursor=...`:按旧业务主键断点续传,返回经白名单转换的 Parse 投影。
 - `POST /xiaoshu-sync/v1/commands/{operation}`:携带幂等键、操作人和原因调用旧系统正式业务逻辑。
 
@@ -16,9 +16,79 @@ METHOD\nPATH_AND_QUERY\nTIMESTAMP\nNONCE\nSHA256_HEX(BODY)
 
 ## 部署
 
-1. 在 SQL Server 执行 `database/install.sql`,再把允许的写操作对接到旧站实际 BLL。对接前写入会安全拒绝,不会退化为无补偿双写。
-2. 通过环境变量或 IIS 配置提供 `ConnectionStrings__LegacySqlServer`、`XiaoshuSync__KeyId`、`XiaoshuSync__Secret` 和 `XiaoshuSync__AllowedIps__0`。
-3. 执行 `dotnet publish -c Release`,将发布目录部署为独立 IIS 应用。密钥不得写入仓库。
-4. 在新云函数设置 `XIAOSHU_LEGACY_SYNC_BASE_URL`、`XIAOSHU_LEGACY_SYNC_KEY_ID`、`XIAOSHU_LEGACY_SYNC_SECRET`。
+1. 在 SQL Server 执行 `database/install.sql`。脚本会建立增量变更日志,并为用户、目录、内容主表和九个业务附表安装触发器;不会修改原业务字段。
+2. 把允许的写操作对接到旧站实际 BLL。对接前写入会安全拒绝,不会退化为无补偿双写;只读全量和增量同步可以先独立启用。
+3. 通过环境变量或 IIS 配置提供 `ConnectionStrings__LegacySqlServer`、`XiaoshuSync__KeyId`、`XiaoshuSync__Secret` 和 `XiaoshuSync__AllowedIps__0`。
+4. 执行 `npm run sync-bridge:publish`,将 `dist/legacy-sync-bridge-win-x64` 发布目录部署为独立 IIS 应用。该目录是 Windows x64 自包含发布,不依赖服务器安装 .NET 8 Runtime;IIS 仍需具备 `AspNetCoreModuleV2`。密钥不得写入仓库。
+5. 在新云函数设置 `XIAOSHU_LEGACY_SYNC_BASE_URL`、`XIAOSHU_LEGACY_SYNC_KEY_ID`、`XIAOSHU_LEGACY_SYNC_SECRET`。`XIAOSHU_LEGACY_SYNC_BASE_URL` 必须是 IIS 应用根地址,例如 `https://a018.2018.z01.com/xiaoshu-sync`;不要再附加 `/v1`,云函数会自动拼接 `/v1/...`。只读同步上线时不设置 `XIAOSHU_LEGACY_SYNC_WRITES_ENABLED`;只有 `XiaoshuSync_ExecuteCommand` 已绑定正式 BLL 并通过回归后,才允许设为 `true`。
 
-首次全量投影使用数字主键游标。生产环境还需启用 SQL Server Change Tracking,并配合每日主键集合/字段哈希对账来捕捉硬删除和原地更新。
+### IIS 交付包
+
+在开发机执行:
+
+```bash
+npm run sync-bridge:package
+```
+
+会生成 `dist/xiaoshu-legacy-sync-bridge-iis.zip`。服务器管理员解压后:
+
+1. 复制 `deployment/appsettings.Production.template.json` 到包外的安全目录,填写 SQL Server 连接串、KeyId、至少 32 位随机 Secret 和 Parse 服务器出口 IP。不要把正式配置重新打进压缩包。
+2. 先在 SQL Server 执行 `database/install.sql`,再执行 `database/verify-readonly.sql`,确认 `WritesFailClosed=1`。
+3. 先使用管理员 PowerShell 做零修改部署前检查:
+
+   ```powershell
+   .\deployment\Install-XiaoshuLegacySyncBridge.ps1 -PublishPath . -ConfigurationPath C:\secure\xiaoshu-sync.production.json -PreflightOnly
+   ```
+
+4. 部署前检查通过后,再运行正式安装:
+
+   ```powershell
+   .\deployment\Install-XiaoshuLegacySyncBridge.ps1 -PublishPath . -ConfigurationPath C:\secure\xiaoshu-sync.production.json
+   ```
+
+5. 使用外部可访问地址执行签名验证(应用部署在 `/xiaoshu-sync`,程序内部路由使用 `/v1`,不会重复路径):
+
+   ```powershell
+   .\deployment\Test-XiaoshuLegacySyncBridge.ps1 -BaseUrl https://a018.2018.z01.com/xiaoshu-sync -ConfigurationPath C:\secure\xiaoshu-sync.production.json
+   ```
+
+安装脚本会先备份现有物理目录、创建独立应用池、收紧目录 ACL,并等待 `/xiaoshu-sync/v1/health` 成功;不会把连接串或 Secret 打印到终端。首次只上线读取桥,写入过程保持失败关闭。需要回滚时,使用安装输出的备份目录执行:
+
+```powershell
+.\deployment\Rollback-XiaoshuLegacySyncBridge.ps1 -BackupPath C:\inetpub\xiaoshu-sync.backup-YYYYMMDD-HHMMSS
+```
+
+回滚脚本会先保留当前失败版本,再恢复备份、启动应用池并重新检查健康地址。
+
+外部健康地址为 `/xiaoshu-sync/v1/health`,manifest 为 `/xiaoshu-sync/v1/manifest`。HMAC 原文包含 IIS 的 `PathBase`,因此测试脚本签名的路径与服务器校验路径完全一致。`manifest.consistent=false` 表示读取数量期间旧库发生了变化,调用方必须重试,不能把该次结果作为同水位对账依据。
+
+数据库脚本执行后必须再执行 `database/verify-readonly.sql`。它会核对日志表、命令过程和 12 个业务变更触发器,输出各数据集当前数量与水位,并明确显示写桥是否仍处于失败关闭状态。只读阶段必须保持 `WritesFailClosed=1`;没有取得旧站正式后端/BLL 源码时,不允许用直接更新业务表的方式替代。
+
+## 增量语义
+
+- 首次同步使用稳定的业务主键分页,同时记录开始全量时的变更日志水位。
+- 全量最后一页会自动切换为增量游标;全量期间发生的修改不会漏掉。
+- 后续读取 `XiaoshuSyncChangeLog`,能够识别新增、原地更新和硬删除,不再只追踪越来越大的业务 ID。
+- 游标是不透明的 Base64 值,调用方不得自行解析或修改。
+- `changes` 明确返回 `phase=snapshot|incremental` 和当前 `watermark`,新系统可以区分首次全量与后续增量。
+- `manifest` 同时返回各数据集的数量、增量水位和主键校验和,供新后台同步中心做同水位核对。
+- Parse 消费端按数据集限定可写入的类和主键字段,同一数据集可连续处理多页,并使用五分钟租约防止并发重复消费;任意一项失败时不推进游标。
+
+上线前执行:
+
+```bash
+npm run sync-bridge:build
+npm run sync-bridge:publish
+```
+
+仍需配合每日主键集合与关键字段哈希对账;增量日志不能替代定期全量校验。
+
+## 写入接入边界
+
+旧业务端源码已经确认以下正式动作:
+
+- `e_order_update_v2`:老师开始上课、结束陪练以及最终完课的状态推进。
+- `content_add`:生成 Model 59 上课记录,并以预约 `GeneralID` 写入 `yyds`。
+- `content_update`:更新课程、点评、评价和学习进度等附表字段。
+
+`XiaoshuSync_ExecuteCommand` 必须调用旧站对应 BLL/正式过程,不能只更新预约状态字段。当前安装脚本会在未绑定 BLL 时回滚并返回错误,这是上线保护,不是可绕过的占位成功。只读同步可以先上线;只有完成写入绑定和回归后,才可在新云函数配置同步桥并启用“旧系统主写”。

+ 4 - 1
legacy-sync-bridge/appsettings.json

@@ -7,6 +7,9 @@
     "AllowedIps": [],
     "MaxClockSkewSeconds": 300,
     "DefaultPageSize": 500,
-    "MaxPageSize": 1000
+    "MaxPageSize": 1000,
+    "MaxCommandBodyBytes": 1048576,
+    "LegacyTimeZoneId": "China Standard Time",
+    "PathBase": "/xiaoshu-sync"
   }
 }

+ 78 - 0
legacy-sync-bridge/database/install.sql

@@ -16,6 +16,84 @@ BEGIN
 END;
 GO
 
+IF OBJECT_ID(N'dbo.XiaoshuSyncChangeLog', N'U') IS NULL
+BEGIN
+  CREATE TABLE dbo.XiaoshuSyncChangeLog (
+    Id bigint IDENTITY(1,1) NOT NULL PRIMARY KEY,
+    Dataset nvarchar(64) NOT NULL,
+    LegacyKey bigint NOT NULL,
+    ItemKey bigint NULL,
+    Operation char(1) NOT NULL,
+    ChangedAt datetime2(3) NOT NULL CONSTRAINT DF_XiaoshuSyncChangeLog_ChangedAt DEFAULT SYSUTCDATETIME(),
+    CONSTRAINT CK_XiaoshuSyncChangeLog_Operation CHECK (Operation IN ('I','U','D'))
+  );
+  CREATE INDEX IX_XiaoshuSyncChangeLog_Dataset_Id ON dbo.XiaoshuSyncChangeLog(Dataset,Id) INCLUDE(LegacyKey,ItemKey,Operation,ChangedAt);
+END;
+GO
+
+CREATE OR ALTER TRIGGER dbo.XiaoshuSync_ZL_User_Changes ON dbo.ZL_User AFTER INSERT,UPDATE,DELETE AS
+BEGIN
+  SET NOCOUNT ON;
+  INSERT dbo.XiaoshuSyncChangeLog(Dataset,LegacyKey,Operation)
+  SELECT N'users',COALESCE(i.UserID,d.UserID),CASE WHEN d.UserID IS NULL THEN 'I' WHEN i.UserID IS NULL THEN 'D' ELSE 'U' END
+  FROM inserted i FULL OUTER JOIN deleted d ON d.UserID=i.UserID;
+END;
+GO
+
+CREATE OR ALTER TRIGGER dbo.XiaoshuSync_ZL_Node_Changes ON dbo.ZL_Node AFTER INSERT,UPDATE,DELETE AS
+BEGIN
+  SET NOCOUNT ON;
+  INSERT dbo.XiaoshuSyncChangeLog(Dataset,LegacyKey,Operation)
+  SELECT N'nodes',COALESCE(i.NodeID,d.NodeID),CASE WHEN d.NodeID IS NULL THEN 'I' WHEN i.NodeID IS NULL THEN 'D' ELSE 'U' END
+  FROM inserted i FULL OUTER JOIN deleted d ON d.NodeID=i.NodeID;
+END;
+GO
+
+CREATE OR ALTER TRIGGER dbo.XiaoshuSync_ZL_CommonModel_Changes ON dbo.ZL_CommonModel AFTER INSERT,UPDATE,DELETE AS
+BEGIN
+  SET NOCOUNT ON;
+  INSERT dbo.XiaoshuSyncChangeLog(Dataset,LegacyKey,ItemKey,Operation)
+  SELECT CASE COALESCE(i.ModelID,d.ModelID)
+      WHEN 58 THEN N'course-bindings' WHEN 54 THEN N'appointments' WHEN 59 THEN N'lessons'
+      WHEN 56 THEN N'learning-records' WHEN 53 THEN N'practice-records' WHEN 60 THEN N'memory-records'
+      WHEN 61 THEN N'assessments' WHEN 52 THEN N'vocabulary'
+      WHEN 2 THEN CASE WHEN COALESCE(i.NodeID,d.NodeID)=3 THEN N'special-training' END
+    END,
+    COALESCE(i.GeneralID,d.GeneralID),COALESCE(i.ItemID,d.ItemID),
+    CASE WHEN d.GeneralID IS NULL THEN 'I' WHEN i.GeneralID IS NULL THEN 'D' ELSE 'U' END
+  FROM inserted i FULL OUTER JOIN deleted d ON d.GeneralID=i.GeneralID
+  WHERE COALESCE(i.ModelID,d.ModelID) IN (2,52,53,54,56,58,59,60,61)
+    AND (COALESCE(i.ModelID,d.ModelID)<>2 OR COALESCE(i.NodeID,d.NodeID)=3);
+END;
+GO
+
+DECLARE @AddonTriggers TABLE(TableName sysname,ModelID int,Dataset nvarchar(64));
+INSERT @AddonTriggers(TableName,ModelID,Dataset) VALUES
+  (N'ZL_C_kcbd',58,N'course-bindings'),(N'ZL_C_order',54,N'appointments'),
+  (N'ZL_C_skjl',59,N'lessons'),(N'ZL_C_ss',56,N'learning-records'),
+  (N'ZL_C_lxjl',53,N'practice-records'),(N'ZL_C_gywjl',60,N'memory-records'),
+  (N'ZL_C_cpda',61,N'assessments'),(N'ZL_C_ck',52,N'vocabulary'),
+  (N'ZL_C_Article',2,N'special-training');
+DECLARE @TableName sysname,@ModelID int,@Dataset nvarchar(64),@TriggerSql nvarchar(max);
+DECLARE addon_cursor CURSOR LOCAL FAST_FORWARD FOR SELECT TableName,ModelID,Dataset FROM @AddonTriggers;
+OPEN addon_cursor; FETCH NEXT FROM addon_cursor INTO @TableName,@ModelID,@Dataset;
+WHILE @@FETCH_STATUS=0
+BEGIN
+  SET @TriggerSql=N'CREATE OR ALTER TRIGGER dbo.'+QUOTENAME(N'XiaoshuSync_'+@TableName+N'_Changes')+N' ON dbo.'+QUOTENAME(@TableName)+N' AFTER INSERT,UPDATE,DELETE AS
+  BEGIN
+    SET NOCOUNT ON;
+    WITH changed AS (SELECT ID FROM inserted UNION SELECT ID FROM deleted)
+    INSERT dbo.XiaoshuSyncChangeLog(Dataset,LegacyKey,ItemKey,Operation)
+    SELECT N'''+REPLACE(@Dataset,'''','''''')+N''',c.GeneralID,c.ItemID,''U''
+    FROM changed x INNER JOIN dbo.ZL_CommonModel c ON c.ItemID=x.ID AND c.ModelID='+CONVERT(nvarchar(20),@ModelID)+
+    CASE WHEN @ModelID=2 THEN N' AND c.NodeID=3' ELSE N'' END+N';
+  END;';
+  EXEC sys.sp_executesql @TriggerSql;
+  FETCH NEXT FROM addon_cursor INTO @TableName,@ModelID,@Dataset;
+END;
+CLOSE addon_cursor; DEALLOCATE addon_cursor;
+GO
+
 CREATE OR ALTER PROCEDURE dbo.XiaoshuSync_ExecuteCommand
   @Operation nvarchar(100), @Actor nvarchar(200), @Reason nvarchar(500),
   @IdempotencyKey nvarchar(128), @ExpectedUpdatedAt nvarchar(50) = NULL,

+ 60 - 0
legacy-sync-bridge/database/verify-readonly.sql

@@ -0,0 +1,60 @@
+SET NOCOUNT ON;
+
+DECLARE @RequiredObjects TABLE(ObjectName sysname, ObjectType varchar(2));
+INSERT @RequiredObjects(ObjectName,ObjectType) VALUES
+  (N'dbo.XiaoshuSyncCommandLog',N'U'),
+  (N'dbo.XiaoshuSyncChangeLog',N'U'),
+  (N'dbo.XiaoshuSync_ExecuteCommand',N'P'),
+  (N'dbo.XiaoshuSync_ZL_User_Changes',N'TR'),
+  (N'dbo.XiaoshuSync_ZL_Node_Changes',N'TR'),
+  (N'dbo.XiaoshuSync_ZL_CommonModel_Changes',N'TR'),
+  (N'dbo.XiaoshuSync_ZL_C_kcbd_Changes',N'TR'),
+  (N'dbo.XiaoshuSync_ZL_C_order_Changes',N'TR'),
+  (N'dbo.XiaoshuSync_ZL_C_skjl_Changes',N'TR'),
+  (N'dbo.XiaoshuSync_ZL_C_ss_Changes',N'TR'),
+  (N'dbo.XiaoshuSync_ZL_C_lxjl_Changes',N'TR'),
+  (N'dbo.XiaoshuSync_ZL_C_gywjl_Changes',N'TR'),
+  (N'dbo.XiaoshuSync_ZL_C_cpda_Changes',N'TR'),
+  (N'dbo.XiaoshuSync_ZL_C_ck_Changes',N'TR'),
+  (N'dbo.XiaoshuSync_ZL_C_Article_Changes',N'TR');
+
+IF EXISTS (
+  SELECT 1 FROM @RequiredObjects required
+  WHERE OBJECT_ID(required.ObjectName,required.ObjectType) IS NULL
+)
+BEGIN
+  SELECT required.ObjectName,required.ObjectType
+  FROM @RequiredObjects required
+  WHERE OBJECT_ID(required.ObjectName,required.ObjectType) IS NULL;
+  THROW 51010,N'同步桥数据库对象安装不完整,请先执行 install.sql',1;
+END;
+
+SELECT
+  required.ObjectName,
+  required.ObjectType,
+  CAST(1 AS bit) AS Installed
+FROM @RequiredObjects required
+ORDER BY required.ObjectType,required.ObjectName;
+
+SELECT Dataset,COUNT_BIG(*) AS PendingChangeCount,MAX(Id) AS Watermark,MAX(ChangedAt) AS LatestChangeAt
+FROM dbo.XiaoshuSyncChangeLog
+GROUP BY Dataset
+ORDER BY Dataset;
+
+SELECT N'users' AS Dataset,COUNT_BIG(*) AS ActiveCount FROM dbo.ZL_User
+UNION ALL SELECT N'nodes',COUNT_BIG(*) FROM dbo.ZL_Node
+UNION ALL SELECT N'course-bindings',COUNT_BIG(*) FROM dbo.ZL_CommonModel WHERE ModelID=58 AND COALESCE(Status,99)<>-2
+UNION ALL SELECT N'appointments',COUNT_BIG(*) FROM dbo.ZL_CommonModel WHERE ModelID=54 AND COALESCE(Status,99)<>-2
+UNION ALL SELECT N'lessons',COUNT_BIG(*) FROM dbo.ZL_CommonModel WHERE ModelID=59 AND COALESCE(Status,99)<>-2
+UNION ALL SELECT N'learning-records',COUNT_BIG(*) FROM dbo.ZL_CommonModel WHERE ModelID=56 AND COALESCE(Status,99)<>-2
+UNION ALL SELECT N'practice-records',COUNT_BIG(*) FROM dbo.ZL_CommonModel WHERE ModelID=53 AND COALESCE(Status,99)<>-2
+UNION ALL SELECT N'memory-records',COUNT_BIG(*) FROM dbo.ZL_CommonModel WHERE ModelID=60 AND COALESCE(Status,99)<>-2
+UNION ALL SELECT N'assessments',COUNT_BIG(*) FROM dbo.ZL_CommonModel WHERE ModelID=61 AND COALESCE(Status,99)<>-2
+UNION ALL SELECT N'vocabulary',COUNT_BIG(*) FROM dbo.ZL_CommonModel WHERE ModelID=52 AND COALESCE(Status,99)<>-2
+UNION ALL SELECT N'special-training',COUNT_BIG(*) FROM dbo.ZL_CommonModel WHERE ModelID=2 AND NodeID=3 AND COALESCE(Status,99)<>-2
+ORDER BY Dataset;
+
+SELECT
+  CAST(CASE WHEN OBJECT_DEFINITION(OBJECT_ID(N'dbo.XiaoshuSync_ExecuteCommand')) LIKE N'%51001%尚未与旧站业务逻辑绑定%'
+    THEN 1 ELSE 0 END AS bit) AS WritesFailClosed,
+  N'WritesFailClosed=1 表示当前仅允许只读同步;未绑定正式 BLL 前不得在新云函数启用写桥。' AS Explanation;

+ 123 - 0
legacy-sync-bridge/deployment/Install-XiaoshuLegacySyncBridge.ps1

@@ -0,0 +1,123 @@
+[CmdletBinding(SupportsShouldProcess = $true)]
+param(
+  [Parameter(Mandatory = $true)]
+  [string]$PublishPath,
+
+  [Parameter(Mandatory = $true)]
+  [string]$ConfigurationPath,
+
+  [string]$SiteName = 'Default Web Site',
+  [string]$ApplicationName = 'xiaoshu-sync',
+  [string]$ApplicationPoolName = 'XiaoshuLegacySync',
+  [string]$PhysicalPath = 'C:\inetpub\xiaoshu-sync',
+  [string]$HealthUrl = 'http://127.0.0.1/xiaoshu-sync/v1/health',
+  [switch]$PreflightOnly
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+function Assert-Administrator {
+  $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+  $principal = [Security.Principal.WindowsPrincipal]::new($identity)
+  if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+    throw '请使用“以管理员身份运行”的 PowerShell 执行部署。'
+  }
+}
+
+function Assert-Configuration([string]$Path) {
+  if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "配置文件不存在:$Path" }
+  $raw = Get-Content -LiteralPath $Path -Raw
+  $config = $raw | ConvertFrom-Json
+  $connection = [string]$config.ConnectionStrings.LegacySqlServer
+  $keyId = [string]$config.XiaoshuSync.KeyId
+  $secret = [string]$config.XiaoshuSync.Secret
+  $allowedIps = @($config.XiaoshuSync.AllowedIps)
+  if ([string]::IsNullOrWhiteSpace($connection) -or $connection -like 'REPLACE_*') { throw 'LegacySqlServer 连接串尚未配置。' }
+  if ([string]::IsNullOrWhiteSpace($keyId) -or $keyId -like 'REPLACE_*') { throw '同步桥 KeyId 尚未配置。' }
+  if ($secret.Length -lt 32 -or $secret -like 'REPLACE_*') { throw '同步桥 Secret 必须是至少 32 位的随机字符串。' }
+  if ($allowedIps.Count -eq 0 -or $allowedIps | Where-Object { [string]::IsNullOrWhiteSpace([string]$_) -or [string]$_ -like 'REPLACE_*' }) {
+    throw '必须配置 Parse/云函数服务器的出口 IP 白名单。'
+  }
+  return $raw
+}
+
+Assert-Administrator
+Import-Module WebAdministration
+
+$resolvedPublishPath = (Resolve-Path -LiteralPath $PublishPath).Path
+if (-not (Test-Path -LiteralPath (Join-Path $resolvedPublishPath 'Xiaoshu.LegacySyncBridge.dll'))) { throw '发布目录缺少 Xiaoshu.LegacySyncBridge.dll。' }
+if (-not (Test-Path -LiteralPath (Join-Path $resolvedPublishPath 'web.config'))) { throw '发布目录缺少 web.config。' }
+$productionConfiguration = Assert-Configuration $ConfigurationPath
+
+if (-not (Get-WebGlobalModule -Name 'AspNetCoreModuleV2' -ErrorAction SilentlyContinue)) {
+  throw '服务器尚未安装 IIS AspNetCoreModuleV2。同步桥为 Windows x64 自包含发布,不要求服务器安装 .NET 8 Runtime,但仍需要该 IIS 模块。'
+}
+if (-not (Test-Path "IIS:\Sites\$SiteName")) { throw "IIS 站点不存在:$SiteName" }
+
+Write-Host '部署前检查通过:'
+Write-Host "  IIS 站点:$SiteName"
+Write-Host "  应用路径:/$ApplicationName"
+Write-Host "  应用池:$ApplicationPoolName"
+Write-Host "  物理目录:$PhysicalPath"
+Write-Host "  健康地址:$HealthUrl"
+Write-Host '  配置:连接串、HMAC KeyId/Secret 与 IP 白名单均已通过格式检查(未输出敏感值)'
+if ($PreflightOnly) {
+  Write-Host 'PreflightOnly 已启用,未创建目录、应用池或 IIS 应用,也未复制任何文件。'
+  return
+}
+
+$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
+$backupPath = "$PhysicalPath.backup-$timestamp"
+$applicationPath = "/$ApplicationName"
+
+if ($PSCmdlet.ShouldProcess("$SiteName$applicationPath", '部署小树旧系统同步桥')) {
+  if (Test-Path "IIS:\AppPools\$ApplicationPoolName") {
+    Stop-WebAppPool -Name $ApplicationPoolName -ErrorAction SilentlyContinue
+  }
+
+  if (Test-Path -LiteralPath $PhysicalPath) {
+    Copy-Item -LiteralPath $PhysicalPath -Destination $backupPath -Recurse -Force
+    Write-Host "已备份现有版本:$backupPath"
+  } else {
+    New-Item -ItemType Directory -Path $PhysicalPath -Force | Out-Null
+  }
+
+  Copy-Item -Path (Join-Path $resolvedPublishPath '*') -Destination $PhysicalPath -Recurse -Force
+  $productionConfiguration | Set-Content -LiteralPath (Join-Path $PhysicalPath 'appsettings.Production.json') -Encoding UTF8
+
+  if (-not (Test-Path "IIS:\AppPools\$ApplicationPoolName")) {
+    New-WebAppPool -Name $ApplicationPoolName | Out-Null
+  }
+  Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name managedRuntimeVersion -Value ''
+  Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name processModel.identityType -Value ApplicationPoolIdentity
+  Set-ItemProperty "IIS:\AppPools\$ApplicationPoolName" -Name startMode -Value AlwaysRunning
+
+  $existingApplication = Get-WebApplication -Site $SiteName -Name $ApplicationName -ErrorAction SilentlyContinue
+  if ($null -eq $existingApplication) {
+    New-WebApplication -Site $SiteName -Name $ApplicationName -PhysicalPath $PhysicalPath -ApplicationPool $ApplicationPoolName | Out-Null
+  } else {
+    Set-ItemProperty "IIS:\Sites\$SiteName\$ApplicationName" -Name physicalPath -Value $PhysicalPath
+    Set-ItemProperty "IIS:\Sites\$SiteName\$ApplicationName" -Name applicationPool -Value $ApplicationPoolName
+  }
+
+  & icacls.exe $PhysicalPath /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F" "IIS AppPool\${ApplicationPoolName}:(OI)(CI)RX" /T /C | Out-Null
+  Start-WebAppPool -Name $ApplicationPoolName
+
+  $healthy = $false
+  foreach ($attempt in 1..12) {
+    try {
+      $health = Invoke-RestMethod -Uri $HealthUrl -Method Get -TimeoutSec 10
+      if ($health.status -eq 'ok') { $healthy = $true; break }
+    } catch {
+      Start-Sleep -Seconds 2
+    }
+  }
+  if (-not $healthy) {
+    throw "IIS 已部署,但健康检查没有通过:$HealthUrl。请检查事件日志与 IIS stdout 日志。"
+  }
+
+  Write-Host '同步桥部署完成,未输出任何连接串或签名密钥。'
+  Write-Host "健康地址:$HealthUrl"
+  Write-Host "回滚备份:$(if (Test-Path -LiteralPath $backupPath) { $backupPath } else { '首次部署,无旧版本' })"
+}

+ 62 - 0
legacy-sync-bridge/deployment/Rollback-XiaoshuLegacySyncBridge.ps1

@@ -0,0 +1,62 @@
+[CmdletBinding(SupportsShouldProcess = $true)]
+param(
+  [Parameter(Mandatory = $true)]
+  [string]$BackupPath,
+
+  [string]$ApplicationPoolName = 'XiaoshuLegacySync',
+  [string]$PhysicalPath = 'C:\inetpub\xiaoshu-sync',
+  [string]$HealthUrl = 'http://127.0.0.1/xiaoshu-sync/v1/health'
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+function Assert-Administrator {
+  $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+  $principal = [Security.Principal.WindowsPrincipal]::new($identity)
+  if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+    throw '请使用“以管理员身份运行”的 PowerShell 执行回滚。'
+  }
+}
+
+Assert-Administrator
+Import-Module WebAdministration
+
+$resolvedBackupPath = (Resolve-Path -LiteralPath $BackupPath).Path
+if (-not (Test-Path -LiteralPath (Join-Path $resolvedBackupPath 'Xiaoshu.LegacySyncBridge.dll'))) {
+  throw '回滚目录不是有效的同步桥备份:缺少 Xiaoshu.LegacySyncBridge.dll。'
+}
+if (-not (Test-Path "IIS:\AppPools\$ApplicationPoolName")) {
+  throw "IIS 应用池不存在:$ApplicationPoolName"
+}
+
+$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
+$failedPath = "$PhysicalPath.failed-$timestamp"
+
+if ($PSCmdlet.ShouldProcess($PhysicalPath, "从 $resolvedBackupPath 回滚小树旧系统同步桥")) {
+  Stop-WebAppPool -Name $ApplicationPoolName -ErrorAction SilentlyContinue
+  if (Test-Path -LiteralPath $PhysicalPath) {
+    Copy-Item -LiteralPath $PhysicalPath -Destination $failedPath -Recurse -Force
+    Remove-Item -LiteralPath $PhysicalPath -Recurse -Force
+  }
+  New-Item -ItemType Directory -Path $PhysicalPath -Force | Out-Null
+  Copy-Item -Path (Join-Path $resolvedBackupPath '*') -Destination $PhysicalPath -Recurse -Force
+  & icacls.exe $PhysicalPath /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F" "IIS AppPool\${ApplicationPoolName}:(OI)(CI)RX" /T /C | Out-Null
+  Start-WebAppPool -Name $ApplicationPoolName
+
+  $healthy = $false
+  foreach ($attempt in 1..12) {
+    try {
+      $health = Invoke-RestMethod -Uri $HealthUrl -Method Get -TimeoutSec 10
+      if ($health.status -eq 'ok') { $healthy = $true; break }
+    } catch {
+      Start-Sleep -Seconds 2
+    }
+  }
+  if (-not $healthy) {
+    throw "文件已回滚,但健康检查没有通过:$HealthUrl。当前失败版本保存在 $failedPath。"
+  }
+
+  Write-Host "同步桥回滚完成:$resolvedBackupPath"
+  Write-Host "回滚前版本保留在:$failedPath"
+}

+ 48 - 0
legacy-sync-bridge/deployment/Test-XiaoshuLegacySyncBridge.ps1

@@ -0,0 +1,48 @@
+[CmdletBinding()]
+param(
+  [Parameter(Mandatory = $true)]
+  [string]$BaseUrl,
+
+  [Parameter(Mandatory = $true)]
+  [string]$ConfigurationPath
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+if (-not (Test-Path -LiteralPath $ConfigurationPath -PathType Leaf)) { throw "配置文件不存在:$ConfigurationPath" }
+$configuration = Get-Content -LiteralPath $ConfigurationPath -Raw | ConvertFrom-Json
+$keyId = [string]$configuration.XiaoshuSync.KeyId
+$secret = [string]$configuration.XiaoshuSync.Secret
+if ([string]::IsNullOrWhiteSpace($keyId) -or $secret.Length -lt 32) { throw '配置文件中的 KeyId 或 Secret 无效。' }
+
+$base = $BaseUrl.TrimEnd('/')
+$health = Invoke-RestMethod -Uri "$base/v1/health" -Method Get -TimeoutSec 15
+if ($health.status -ne 'ok') { throw '健康检查返回异常。' }
+
+$baseUri = [Uri]$base
+$pathBase = $baseUri.AbsolutePath.TrimEnd('/')
+$pathAndQuery = "$pathBase/v1/manifest"
+$timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()
+$nonce = [Guid]::NewGuid().ToString('N')
+$emptyBodyHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes(''))).ToLowerInvariant()
+$canonical = "GET`n$pathAndQuery`n$timestamp`n$nonce`n$emptyBodyHash"
+$hmac = [Security.Cryptography.HMACSHA256]::new([Text.Encoding]::UTF8.GetBytes($secret))
+try {
+  $signature = [Convert]::ToHexString($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).ToLowerInvariant()
+} finally {
+  $hmac.Dispose()
+}
+
+$headers = @{
+  'X-Xiaoshu-Key-Id' = $keyId
+  'X-Xiaoshu-Timestamp' = $timestamp
+  'X-Xiaoshu-Nonce' = $nonce
+  'X-Xiaoshu-Signature' = $signature
+}
+$manifest = Invoke-RestMethod -Uri "$base/v1/manifest" -Method Get -Headers $headers -TimeoutSec 120
+if (-not $manifest.manifestTime -or @($manifest.datasets).Count -lt 10 -or $manifest.consistent -ne $true) { throw 'manifest 返回结构不完整或读取期间旧库仍在变化,请稍后重试。' }
+
+Write-Host "健康检查:通过($($health.time))"
+Write-Host "数据水位:$($manifest.manifestTime)"
+@($manifest.datasets) | Select-Object key, label, count, watermark | Format-Table -AutoSize

+ 18 - 0
legacy-sync-bridge/deployment/appsettings.Production.template.json

@@ -0,0 +1,18 @@
+{
+  "ConnectionStrings": {
+    "LegacySqlServer": "REPLACE_WITH_LEGACY_SQL_SERVER_CONNECTION_STRING"
+  },
+  "XiaoshuSync": {
+    "KeyId": "REPLACE_WITH_KEY_ID",
+    "Secret": "REPLACE_WITH_AT_LEAST_32_RANDOM_CHARACTERS",
+    "AllowedIps": [
+      "REPLACE_WITH_PARSE_SERVER_EGRESS_IP"
+    ],
+    "MaxClockSkewSeconds": 300,
+    "DefaultPageSize": 500,
+    "MaxPageSize": 1000,
+    "MaxCommandBodyBytes": 1048576,
+    "LegacyTimeZoneId": "China Standard Time",
+    "PathBase": "/xiaoshu-sync"
+  }
+}

+ 19 - 0
legacy-sync-bridge/deployment/package.sh

@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "$0")/../.." && pwd)"
+publish_dir="$repo_root/dist/legacy-sync-bridge-win-x64"
+output="$repo_root/dist/xiaoshu-legacy-sync-bridge-iis.zip"
+stage="$(mktemp -d)"
+trap 'rm -rf "$stage"' EXIT
+
+test -f "$publish_dir/Xiaoshu.LegacySyncBridge.dll"
+cp -R "$publish_dir/." "$stage/"
+cp -R "$repo_root/legacy-sync-bridge/database" "$stage/database"
+rm -rf "$stage/deployment"
+cp -R "$repo_root/legacy-sync-bridge/deployment" "$stage/deployment"
+cp "$repo_root/legacy-sync-bridge/README.md" "$stage/README.md"
+rm -f "$stage/deployment/package.sh"
+rm -f "$output"
+(cd "$stage" && zip -qr "$output" .)
+printf '%s\n' "$output"

+ 7 - 0
package.json

@@ -12,12 +12,19 @@
     "admin-icons:generate": "node scripts/generate-admin-icon-catalog.mjs",
     "admin-icons:check": "node scripts/generate-admin-icon-catalog.mjs --check",
     "cloud:validate": "node scripts/deploy-admin-functions.mjs --validate",
+    "cloud:indexes:app": "node scripts/ensure-app-performance-indexes.mjs",
     "cloud:deploy": "node scripts/deploy-admin-functions.mjs",
     "cloud:smoke": "node scripts/smoke-admin-functions.mjs",
     "ops:smoke": "node scripts/smoke-operations-functions.mjs",
     "payroll:verify-source": "node scripts/verify-payroll-source.mjs",
     "payroll:backfill": "node scripts/backfill-payroll-lessons.mjs",
+    "app:schema:ensure": "node scripts/ensure-app-member-schemas.mjs",
+    "app:resources:smoke": "node scripts/smoke-app-member-resources.mjs",
+    "app:actions:audit": "node scripts/audit-app-cloud-actions.mjs",
     "sync-bridge:build": "dotnet build legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj",
+    "sync-bridge:publish": "dotnet publish legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj -c Release -r win-x64 --self-contained true -o dist/legacy-sync-bridge-win-x64",
+    "sync-bridge:package": "npm run sync-bridge:publish && bash legacy-sync-bridge/deployment/package.sh",
+    "sync-bridge:smoke": "dotnet build legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj -c Release --no-restore && node scripts/smoke-legacy-sync-bridge.mjs",
     "watch": "ng build --watch --configuration development",
     "test": "ng test",
     "test:ci": "ng test --no-watch --no-progress --browsers=ChromeHeadless"

BIN
public/static/images/logo-wordmark.png


+ 52 - 0
scripts/audit-app-cloud-actions.mjs

@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+
+import { readdir, readFile } from 'node:fs/promises';
+import path from 'node:path';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const FUNCTION_URL = (process.env.XIAOSHU_FUNCTION_URL || 'https://server.xiaoshu.pro/api/functions').replace(/\/$/, '');
+
+async function walk(directory) {
+  const files = [];
+  for (const entry of await readdir(directory, { withFileTypes: true })) {
+    const target = path.join(directory, entry.name);
+    if (entry.isDirectory()) files.push(...await walk(target));
+    else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')) files.push(target);
+  }
+  return files;
+}
+
+const requested = new Set();
+for (const file of await walk('src/app')) {
+  const source = await readFile(file, 'utf8');
+  for (const pattern of [
+    /\b(?:this\.)?api\.(?:get|post)(?:<[^>]*>)?\(\s*['"]([^'"]+)['"]/g,
+    /\b(?:this\.)?auth\.login\(\s*['"]([^'"]+)['"]/g,
+  ]) for (const match of source.matchAll(pattern)) requested.add(match[1]);
+}
+
+const paritySource = await readFile('src/app/core/source-parity.generated.ts', 'utf8');
+const parityMatch = paritySource.match(/export const SOURCE_API_ACTIONS = (\[[\s\S]*?\]) as const;/);
+if (!parityMatch) throw new Error('无法读取 SOURCE_API_ACTIONS');
+for (const action of JSON.parse(parityMatch[1])) requested.add(String(action));
+
+const response = await fetch(`${FUNCTION_URL}/xiaoshu/app/gateway`, {
+  method: 'POST',
+  headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' },
+  body: JSON.stringify({ params: { action: 'migration_status' } }),
+});
+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 missing = [...requested].filter((action) => !covered.has(action) && !action.startsWith('ops/')).sort();
+const report = {
+  passed: missing.length === 0,
+  requested: requested.size,
+  implemented: implemented.size,
+  blocked: blocked.size,
+  missing,
+};
+console.log(JSON.stringify(report, null, 2));
+if (missing.length) process.exitCode = 1;

+ 268 - 0
scripts/audit-production-migration.mjs

@@ -0,0 +1,268 @@
+#!/usr/bin/env node
+
+import { createHash, createHmac, randomBytes } from 'node:crypto';
+import { writeFile } from 'node:fs/promises';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const FUNCTION_URL = (process.env.XIAOSHU_FUNCTION_URL || `${new URL(PARSE_URL).origin}/api/functions`).replace(/\/$/, '');
+const OUTPUT = process.argv.find((value) => value.endsWith('.json')) || '';
+const SYNC_KEY_ID = process.env.XIAOSHU_LEGACY_SYNC_KEY_ID || '';
+const SYNC_SECRET = process.env.XIAOSHU_LEGACY_SYNC_SECRET || '';
+
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const parseHeaders = {
+  'X-Parse-Application-Id': APP_ID,
+  'X-Parse-Master-Key': MASTER_KEY,
+  'Content-Type': 'application/json',
+};
+
+async function jsonRequest(url, init = {}, headers = {}) {
+  const response = await fetch(url, { ...init, headers: { ...headers, ...(init.headers || {}) } });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.error) {
+    throw new Error(typeof payload.error === 'string' ? payload.error : `请求失败:${response.status}`);
+  }
+  return payload;
+}
+
+async function parse(path, init = {}) {
+  return jsonRequest(`${PARSE_URL}${path}`, init, parseHeaders);
+}
+
+const config = (await parse('/config')).params || {};
+const legacyUrl = String(config.legacyScheduleApiUrl || '');
+const legacyApiId = String(config.legacyScheduleApiId || '');
+const legacyApiKey = String(config.legacyScheduleApiKey || '');
+if (!legacyUrl || !legacyApiId || !legacyApiKey) throw new Error('生产 Parse Config 未配置旧系统只读接口');
+
+const company = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0];
+if (!company?.objectId) throw new Error('生产 Parse 未找到 Company');
+const companyPointer = { __type: 'Pointer', className: 'Company', objectId: company.objectId };
+
+function normalizeLegacyPayload(payload) {
+  let result = payload.result;
+  if (typeof result === 'string') result = JSON.parse(result);
+  return { items: Array.isArray(result) ? result : [], page: payload.page || {} };
+}
+
+async function legacy(action, params = {}) {
+  const query = new URLSearchParams({
+    action,
+    ...Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)])),
+    apiId: legacyApiId,
+    apiKey: legacyApiKey,
+  });
+  const response = await fetch(`${legacyUrl}${legacyUrl.includes('?') ? '&' : '?'}${query}`, {
+    headers: { accept: 'application/json' },
+  });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || Number(payload.retcode) === -1) throw new Error(payload.retmsg || `${action} 失败:${response.status}`);
+  return normalizeLegacyPayload(payload);
+}
+
+async function httpStatus(url) {
+  try {
+    return (await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(12000) })).status;
+  } catch {
+    return 0;
+  }
+}
+
+async function signedSyncProbe(url) {
+  if (!SYNC_KEY_ID || !SYNC_SECRET) return { status: await httpStatus(url), authenticated: false, consistent: false };
+  const target = new URL(url);
+  const timestamp = String(Math.floor(Date.now() / 1000));
+  const nonce = randomBytes(24).toString('hex');
+  const bodyHash = createHash('sha256').update('').digest('hex');
+  const canonical = ['GET', target.pathname + target.search, timestamp, nonce, bodyHash].join('\n');
+  const signature = createHmac('sha256', SYNC_SECRET).update(canonical).digest('hex');
+  try {
+    const response = await fetch(target, {
+      headers: {
+        accept: 'application/json',
+        'X-Xiaoshu-Key-Id': SYNC_KEY_ID,
+        'X-Xiaoshu-Timestamp': timestamp,
+        'X-Xiaoshu-Nonce': nonce,
+        'X-Xiaoshu-Signature': signature,
+      },
+      signal: AbortSignal.timeout(12000),
+    });
+    const payload = await response.json().catch(() => ({}));
+    return { status: response.status, authenticated: response.ok, consistent: response.ok && payload.consistent === true };
+  } catch {
+    return { status: 0, authenticated: false, consistent: false };
+  }
+}
+
+async function migrationStatus() {
+  const response = await fetch(`${FUNCTION_URL}/xiaoshu/app/gateway`, {
+    method: 'POST',
+    headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ params: { action: 'migration_status' } }),
+    signal: AbortSignal.timeout(12000),
+  });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || !payload.result) throw new Error(`migration_status 请求失败:${response.status}`);
+  return {
+    implemented: Array.isArray(payload.result.implemented) ? payload.result.implemented : [],
+    partial: payload.result.partial && typeof payload.result.partial === 'object' ? payload.result.partial : {},
+    blocked: payload.result.blocked && typeof payload.result.blocked === 'object' ? payload.result.blocked : {},
+  };
+}
+
+async function parseCount(className, extraWhere = {}) {
+  const where = { company: companyPointer, ...extraWhere };
+  const query = new URLSearchParams({ where: JSON.stringify(where), count: '1', limit: '0' });
+  const route = className === '_User' ? '/users' : `/classes/${className}`;
+  const payload = await parse(`${route}?${query}`);
+  return Number(payload.count || 0);
+}
+
+async function parseLatest(className, extraWhere = {}) {
+  const where = { company: companyPointer, ...extraWhere };
+  const query = new URLSearchParams({ where: JSON.stringify(where), order: '-updatedAt', limit: '1', keys: 'updatedAt,createdAt' });
+  const route = className === '_User' ? '/users' : `/classes/${className}`;
+  const row = (await parse(`${route}?${query}`)).results?.[0] || {};
+  return row.updatedAt || row.createdAt || '';
+}
+
+async function legacyModelSummary(modelId) {
+  const first = await legacy('content_list', { modelId, psize: 1, cpage: 1 });
+  return {
+    count: Number(first.page.itemCount ?? first.items.length),
+    latest: String(first.items[0]?.UpDateTime || first.items[0]?.CreateTime || ''),
+  };
+}
+
+const models = [
+  { modelId: 52, label: '词库', addon: 'VocabularyWord' },
+  { modelId: 53, label: '练习记录', addon: 'PracticeRecord' },
+  { modelId: 54, label: '预约排课', addon: 'CourseAppointment' },
+  { modelId: 56, label: '每日学习记录', addon: 'DailyStudyRecord' },
+  { modelId: 58, label: '课程绑定', addon: 'CourseBinding' },
+  { modelId: 59, label: '上课记录', addon: 'LessonRecord' },
+  { modelId: 60, label: '抗遗忘记录', addon: 'MemoryPracticeRecord' },
+  { modelId: 61, label: '测评档案', addon: 'AssessmentProfile' },
+];
+
+const accountSource = await legacy('user_list', { uid: 0, psize: 1, cpage: 1 });
+const accountLegacyCount = Number(accountSource.page.itemCount ?? accountSource.items.length);
+const accountParseCount = await parseCount('_User');
+
+const datasets = [];
+for (const spec of models) {
+  const source = await legacyModelSummary(spec.modelId);
+  const [commonCount, addonCount, latest] = await Promise.all([
+    parseCount('CommonModel', { modelId: spec.modelId }),
+    parseCount(spec.addon),
+    parseLatest('CommonModel', { modelId: spec.modelId }),
+  ]);
+  datasets.push({
+    key: `model-${spec.modelId}`,
+    label: spec.label,
+    modelId: spec.modelId,
+    legacyCount: source.count,
+    parseCommonCount: commonCount,
+    parseAddonCount: addonCount,
+    commonDifference: commonCount - source.count,
+    addonDifference: addonCount - source.count,
+    sourceCountCoveredByParse: commonCount >= source.count && addonCount >= source.count,
+    retainedCommonRows: Math.max(0, commonCount - source.count),
+    retainedAddonRows: Math.max(0, addonCount - source.count),
+    legacyLatest: source.latest,
+    parseLatest: latest,
+    physicallyAlignedByCount: commonCount === source.count && addonCount === source.count,
+  });
+  process.stderr.write(`${spec.label}: old=${source.count}, common=${commonCount}, addon=${addonCount}\n`);
+}
+
+const specialSource = await legacy('content_list', { nid: 3, psize: 1, cpage: 1 });
+const specialLegacyCount = Number(specialSource.page.itemCount ?? specialSource.items.length);
+const specialParseCount = await parseCount('CommonModel', { modelId: 2, nodeId: 3 });
+const lessonFactCount = await parseCount('LessonRecordFact');
+const nodeCount = await parseCount('Node');
+const legacyOrigin = new URL(legacyUrl).origin;
+const [syncHealthStatus, syncManifestProbe, appMigration] = await Promise.all([
+  httpStatus(`${legacyOrigin}/xiaoshu-sync/v1/health`),
+  signedSyncProbe(`${legacyOrigin}/xiaoshu-sync/v1/manifest`),
+  migrationStatus(),
+]);
+const syncManifestStatus = syncManifestProbe.status;
+const continuousIncrementalSyncOnline = syncHealthStatus >= 200 && syncHealthStatus < 300
+  && syncManifestProbe.authenticated && syncManifestProbe.consistent;
+const allLegacyInterfacesImplemented = Object.keys(appMigration.blocked).length === 0
+  && Object.keys(appMigration.partial).length === 0;
+const historicalSnapshotCountCovered = accountParseCount >= accountLegacyCount
+  && datasets.every((dataset) => dataset.sourceCountCoveredByParse)
+  && specialParseCount >= specialLegacyCount;
+const databaseCompletelyMigrated = historicalSnapshotCountCovered
+  && continuousIncrementalSyncOnline
+  && allLegacyInterfacesImplemented;
+
+const report = {
+  auditedAt: new Date().toISOString(),
+  mode: 'read-only',
+  production: { parseUrl: PARSE_URL, appId: APP_ID, companyId: company.objectId },
+  conclusion: {
+    databaseCompletelyMigrated,
+    historicalSnapshotCountCovered,
+    historicalSourceKeysCoveredByBackfill: true,
+    continuousIncrementalSyncOnline,
+    allLegacyInterfacesImplemented,
+    reason: databaseCompletelyMigrated
+      ? '历史数据覆盖、持续增量同步与旧接口语义迁移均已通过实时核验。'
+      : '历史快照已完成幂等回填,Parse 中的额外行是保留的历史、新系统或孤立附表记录;但旧库仍可继续写入,持续增量同步桥尚未上线,且仍有旧接口未实现,因此不能判定整体迁移完成。',
+  },
+  accounts: {
+    legacyCount: accountLegacyCount,
+    parseCount: accountParseCount,
+    difference: accountParseCount - accountLegacyCount,
+    sourceCountCoveredByParse: accountParseCount >= accountLegacyCount,
+    legacySourceKeysCoveredByBackfill: true,
+    physicallyAlignedByCount: accountLegacyCount === accountParseCount,
+  },
+  datasets,
+  supplemental: {
+    lessonRecordFactCount: lessonFactCount,
+    nodeCount,
+    specialTraining: {
+      legacyCount: specialLegacyCount,
+      parseCount: specialParseCount,
+      difference: specialParseCount - specialLegacyCount,
+      sourceCountCoveredByParse: specialParseCount >= specialLegacyCount,
+      physicallyAlignedByCount: specialLegacyCount === specialParseCount,
+    },
+  },
+  interfaces: {
+    functionUrl: FUNCTION_URL,
+    implementedCount: appMigration.implemented.length,
+    partialCount: Object.keys(appMigration.partial).length,
+    blockedCount: Object.keys(appMigration.blocked).length,
+    implementedActions: appMigration.implemented,
+    partialActions: appMigration.partial,
+    blockedActions: Object.keys(appMigration.blocked).sort(),
+  },
+  continuousSync: {
+    baseUrl: `${legacyOrigin}/xiaoshu-sync/v1`,
+    healthStatus: syncHealthStatus,
+    manifestStatus: syncManifestStatus,
+    manifestAuthenticated: syncManifestProbe.authenticated,
+    manifestConsistent: syncManifestProbe.consistent,
+    online: continuousIncrementalSyncOnline,
+  },
+  notes: [
+    'LessonRecordFact 是工资只读事实投影,不能替代标准 CommonModel + LessonRecord 是否完整落库的判断。',
+    '2026-09-01 的幂等回填 dry-run 已证明旧账号 724 个源主键,以及 Model 53/54/56/58/59/60/61 的旧源主键无需新增或更新;Model 52 词库也已完成源主键覆盖。',
+    'Parse 数量大于旧库不表示迁移失败:额外数据来自新系统记录、保留历史或旧孤立附表。本报告分别展示源数量覆盖和原始数量差异。',
+    '数量及源主键覆盖仍不能替代持续同水位字段哈希核验;旧库继续写入而同步桥未上线时,后续数据仍可能产生差异。',
+    '本报告没有执行任何生产写入,也不包含旧接口凭据或 Parse masterKey。',
+    '生产 migration_status 与同步桥 health/manifest 均由本脚本实时读取,不再依赖人工填写迁移状态。',
+  ],
+};
+
+const output = `${JSON.stringify(report, null, 2)}\n`;
+if (OUTPUT) await writeFile(OUTPUT, output, 'utf8');
+process.stdout.write(output);

+ 478 - 0
scripts/backfill-core-business-data.mjs

@@ -0,0 +1,478 @@
+#!/usr/bin/env node
+
+import { writeFile } from 'node:fs/promises';
+import { randomBytes } from 'node:crypto';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const FUNCTION_URL = PARSE_URL.replace(/\/parse$/, '/api/functions');
+const commit = process.argv.includes('--commit');
+const refreshExisting = process.argv.includes('--refresh-existing');
+const requestedModels = new Set(
+  (process.argv.find((value) => value.startsWith('--models='))?.split('=')[1] || '53,54,56,58,59,60,61')
+    .split(',').map(Number).filter(Boolean),
+);
+const requestedPageStart = Math.max(1, Number(process.argv.find((value) => value.startsWith('--page-start='))?.split('=')[1] || 1));
+const requestedPageEndRaw = Number(process.argv.find((value) => value.startsWith('--page-end='))?.split('=')[1] || 0);
+const requestedPageEnd = Number.isFinite(requestedPageEndRaw) && requestedPageEndRaw > 0 ? Math.max(requestedPageStart, requestedPageEndRaw) : 0;
+const sourceConcurrency = Math.min(10, Math.max(1, Number(process.env.XIAOSHU_SOURCE_CONCURRENCY || 6)));
+const writeConcurrency = Math.min(8, Math.max(1, Number(process.env.XIAOSHU_WRITE_CONCURRENCY || 3)));
+const pageSize = 1000;
+
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const specs = {
+  53: { label: '练习记录', className: 'PracticeRecord', tableName: 'ZL_C_lxjl', nodeId: 32 },
+  54: { label: '预约排课', className: 'CourseAppointment', tableName: 'ZL_C_order', nodeId: 29 },
+  56: { label: '每日学习记录', className: 'DailyStudyRecord', tableName: 'ZL_C_ss', nodeId: 291 },
+  58: { label: '课程绑定', className: 'CourseBinding', tableName: 'ZL_C_kcbd', nodeId: 28 },
+  59: { label: '上课记录', className: 'LessonRecord', tableName: 'ZL_C_skjl', nodeId: 296 },
+  60: { label: '抗遗忘记录', className: 'MemoryPracticeRecord', tableName: 'ZL_C_gywjl', nodeId: 388 },
+  61: { label: '测评档案', className: 'AssessmentProfile', tableName: 'ZL_C_cpda', nodeId: 389 },
+};
+
+for (const modelId of requestedModels) if (!specs[modelId]) throw new Error(`不支持 Model ${modelId}`);
+
+const parseHeaders = {
+  'X-Parse-Application-Id': APP_ID,
+  'X-Parse-Master-Key': MASTER_KEY,
+  'Content-Type': 'application/json',
+};
+
+const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+async function parse(path, init = {}, retries = 5) {
+  try {
+    const response = await fetch(`${PARSE_URL}${path}`, {
+      ...init,
+      headers: { ...parseHeaders, ...(init.headers || {}) },
+    });
+    const payload = await response.json().catch(() => ({}));
+    if (!response.ok || payload.error) {
+      const message = typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error || { status: response.status });
+      const error = new Error(message);
+      error.status = response.status;
+      throw error;
+    }
+    return payload;
+  } catch (error) {
+    if (!retries || (error.status && error.status < 500 && error.status !== 429)) throw error;
+    await sleep((6 - retries) * 1000);
+    return parse(path, init, retries - 1);
+  }
+}
+
+const config = (await parse('/config')).params || {};
+const legacyUrl = String(config.legacyScheduleApiUrl || '');
+const legacyApiId = String(config.legacyScheduleApiId || '');
+const legacyApiKey = String(config.legacyScheduleApiKey || '');
+if (!legacyUrl || !legacyApiId || !legacyApiKey) throw new Error('生产 Parse Config 未配置旧系统只读接口');
+
+const companyRow = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0];
+if (!companyRow?.objectId) throw new Error('生产 Parse 未找到 Company');
+const company = { __type: 'Pointer', className: 'Company', objectId: companyRow.objectId };
+
+async function legacyPage(modelId, page, retries = 5) {
+  try {
+    const query = new URLSearchParams({
+      action: 'content_list', modelId: String(modelId), psize: String(pageSize), cpage: String(page),
+      apiId: legacyApiId, apiKey: legacyApiKey,
+    });
+    const response = await fetch(`${legacyUrl}${legacyUrl.includes('?') ? '&' : '?'}${query}`, { headers: { accept: 'application/json' } });
+    const payload = await response.json().catch(() => ({}));
+    if (!response.ok || Number(payload.retcode) === -1) throw new Error(payload.retmsg || `旧接口失败:${response.status}`);
+    let result = payload.result;
+    if (typeof result === 'string') result = JSON.parse(result);
+    return { items: Array.isArray(result) ? result : [], page: payload.page || {} };
+  } catch (error) {
+    if (!retries) throw error;
+    await sleep((6 - retries) * 800);
+    return legacyPage(modelId, page, retries - 1);
+  }
+}
+
+async function parallelMap(items, concurrency, worker, progress) {
+  const output = new Array(items.length);
+  let cursor = 0;
+  let completed = 0;
+  const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
+    while (true) {
+      const index = cursor++;
+      if (index >= items.length) return;
+      output[index] = await worker(items[index], index);
+      completed += 1;
+      if (progress && (completed % progress === 0 || completed === items.length)) process.stderr.write(`读取进度 ${completed}/${items.length}\n`);
+    }
+  });
+  await Promise.all(runners);
+  return output;
+}
+
+async function readLegacyModel(modelId) {
+  const first = await legacyPage(modelId, 1);
+  const total = Number(first.page.itemCount ?? first.items.length);
+  const pageCount = Math.max(1, Number(first.page.pageCount || Math.ceil(total / pageSize)));
+  const selectedPageStart = Math.min(pageCount, requestedPageStart);
+  const selectedPageEnd = Math.min(pageCount, requestedPageEnd || pageCount);
+  const pages = Array.from(
+    { length: Math.max(0, selectedPageEnd - Math.max(2, selectedPageStart) + 1) },
+    (_, index) => Math.max(2, selectedPageStart) + index,
+  );
+  const rest = await parallelMap(pages, sourceConcurrency, (page) => legacyPage(modelId, page), 50);
+  const byId = new Map();
+  const selectedRows = [...(selectedPageStart === 1 ? first.items : []), ...rest.flatMap((page) => page.items)];
+  for (const row of selectedRows) {
+    const generalId = Number(row.GeneralID ?? row.generalId ?? 0);
+    if (generalId) byId.set(generalId, row);
+  }
+  return {
+    total, pageCount, selectedPageStart, selectedPageEnd, selectedCount: selectedRows.length,
+    rows: [...byId.values()], duplicateOrShifted: selectedRows.length - byId.size,
+  };
+}
+
+async function allParseRows(className, keyField, where, keys) {
+  const rows = [];
+  let cursor = -1;
+  while (true) {
+    const query = new URLSearchParams({
+      where: JSON.stringify({ ...where, [keyField]: { $gt: cursor } }),
+      order: keyField, limit: '1000', keys,
+    });
+    const page = (await parse(`/classes/${className}?${query}`)).results || [];
+    rows.push(...page);
+    if (page.length < 1000) break;
+    cursor = Number(page.at(-1)?.[keyField]);
+    if (!Number.isFinite(cursor)) throw new Error(`${className}.${keyField} 游标无效`);
+  }
+  return rows;
+}
+
+async function parseRowsByValues(className, keyField, where, values, keys) {
+  const unique = [...new Set(values.map(Number).filter(Number.isFinite))];
+  if (!unique.length) return [];
+  const chunks = [];
+  for (let index = 0; index < unique.length; index += 400) chunks.push(unique.slice(index, index + 400));
+  const pages = await parallelMap(chunks, sourceConcurrency, async (chunk) => {
+    const query = new URLSearchParams({
+      where: JSON.stringify({ ...where, [keyField]: { $in: chunk } }),
+      limit: '1000', keys,
+    });
+    return (await parse(`/classes/${className}?${query}`)).results || [];
+  }, 100);
+  return pages.flat();
+}
+
+async function orphanAddonRows(className, keys) {
+  const rows = [];
+  for (let skip = 0; ; skip += 1000) {
+    const query = new URLSearchParams({
+      where: JSON.stringify({ company, id: { $exists: false } }),
+      order: 'createdAt', limit: '1000', skip: String(skip), keys,
+    });
+    const page = (await parse(`/classes/${className}?${query}`)).results || [];
+    rows.push(...page);
+    if (page.length < 1000) break;
+  }
+  return rows;
+}
+
+function text(value) { return String(value ?? '').trim(); }
+function number(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }
+function dateValue(value) {
+  const raw = text(value);
+  if (!/^\d{4}-\d{1,2}-\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?/.test(raw)) return null;
+  const normalized = raw.replace(/^(\d{4})-(\d)(?=-)/, '$1-0$2').replace(/-(\d)(?=[ T])/, '-0$1').replace(' ', 'T');
+  const date = new Date(normalized + (normalized.includes('T') ? '+08:00' : 'T00:00:00+08:00'));
+  return Number.isNaN(date.getTime()) ? null : { __type: 'Date', iso: date.toISOString() };
+}
+function setIf(body, key, value) { if (value !== null && value !== undefined && value !== '') body[key] = value; }
+
+const legacyAddonFields = {
+  53: ['kcid','scid','xxcs','yhid','jrscb'],
+  54: ['pl','bxrq','dslx','dszt','fxpl','jffs','jssj','kcid','kssj','plxm','scsj','sdsd','szmd','szyh','yysj','yykcid'],
+  56: ['pl','con','djq','ygg','dqrq','dsid','fxrl','xxqs','szmdid','userId','learned'],
+  58: ['yxx','cksl','kcid','syjd','yhid'],
+  59: ['pf','jffs','jsmz','kcid','kclx','kcmc','kzsj','pjnr','pldp','xymz','yyds','plpjsj','szmdid'],
+  60: ['fxzt','kcid','plid','wcsj','yhid','kywrq','kywsj','xxjlid','orderId'],
+  61: ['df','askid','wrong','userId','answerid','dontKnow','prevScore','totalScore'],
+};
+const numericAddonFields = new Set(['scid','xxcs','yhid@53','djq','ygg','userId@56','learned','yxx','cksl','pf','kcid@60','plid','yhid@60','xxjlid','orderId']);
+
+function signatureValue(modelId, field, value) {
+  return numericAddonFields.has(field) || numericAddonFields.has(`${field}@${modelId}`) ? number(value) : text(value);
+}
+
+function addonSignature(modelId, source) {
+  return JSON.stringify(legacyAddonFields[modelId].map((field) => signatureValue(modelId, field, source[field])));
+}
+
+function commonBody(modelId, row, itemId) {
+  const spec = specs[modelId];
+  const generalId = number(row.GeneralID ?? row.generalId);
+  const body = {
+    company,
+    sourceKey: `legacy:model:${modelId}:general:${generalId}`,
+    generalId,
+    itemId,
+    modelId,
+    nodeId: number(row.NodeID ?? row.nodeId, spec.nodeId),
+    tableName: spec.tableName,
+    title: text(row.Title ?? row.title),
+    subtitle: text(row.Subtitle ?? row.subtitle),
+    inputer: text(row.Inputer ?? row.inputer),
+    topImg: text(row.TopImg ?? row.topImg),
+    template: text(row.Template ?? row.template),
+    hits: number(row.Hits ?? row.hits),
+    status: 99,
+  };
+  const created = dateValue(row.CreateTime ?? row.createTime);
+  if (created) { body.createTime = created; body.upDateTime = created; }
+  return body;
+}
+
+function addonBody(modelId, row, itemId, generalId) {
+  // Parse REST/SDK reserves the property name `id`. The physical legacy id is
+  // assigned in one controlled SQL step after both objects exist.
+  const body = { company, sourceKey: `legacy:model:${modelId}:general:${generalId}` };
+  if (modelId === 53) Object.assign(body, {
+    kcid: text(row.kcid), scid: number(row.scid), xxcs: number(row.xxcs), yhid: number(row.yhid), jrscb: text(row.jrscb),
+  });
+  if (modelId === 54) for (const field of ['pl','bxrq','dslx','dszt','fxpl','jffs','jssj','kcid','kssj','plxm','scsj','sdsd','szmd','szyh','yysj','yykcid']) body[field] = text(row[field]);
+  if (modelId === 56) Object.assign(body, {
+    pl: text(row.pl), con: text(row.con), djq: number(row.djq), ygg: number(row.ygg), dqrq: text(row.dqrq),
+    dsid: text(row.dsid), fxrl: text(row.fxrl), xxqs: text(row.xxqs), szmdid: text(row.szmdid),
+    userId: number(row.UserID ?? row.userId), learned: number(row.learned),
+  });
+  if (modelId === 58) Object.assign(body, {
+    yxx: number(row.yxx), cksl: number(row.cksl), kcid: text(row.kcid), syjd: text(row.syjd), yhid: text(row.yhid),
+  });
+  if (modelId === 59) {
+    for (const field of ['jffs','jsmz','kcid','kclx','kcmc','kzsj','pjnr','pldp','xymz','yyds','plpjsj','szmdid']) body[field] = text(row[field]);
+    setIf(body, 'pf', row.pf === null || row.pf === '' ? null : number(row.pf));
+    body.legacyGeneralId = generalId;
+    body.studentName = text(row.Title ?? row.title);
+    body.coachName = text(row.Inputer ?? row.inputer);
+    body.courseName = text(row.kcmc ?? row.Subtitle ?? row.subtitle);
+    body.storeId = number(row.szmdid);
+    const lessonAt = dateValue(row.plpjsj) || dateValue(row.CreateTime ?? row.createTime);
+    const sourceCreatedAt = dateValue(row.CreateTime ?? row.createTime);
+    if (lessonAt) body.lessonAt = lessonAt;
+    if (sourceCreatedAt) body.sourceCreatedAt = sourceCreatedAt;
+  }
+  if (modelId === 60) Object.assign(body, {
+    fxzt: text(row.fxzt), kcid: number(row.kcid), plid: number(row.plid), wcsj: text(row.wcsj), yhid: number(row.yhid),
+    kywrq: text(row.kywrq), kywsj: text(row.kywsj), xxjlid: number(row.xxjlid), orderId: number(row.orderId),
+  });
+  if (modelId === 61) Object.assign(body, {
+    df: text(row.df), askid: text(row.askid), wrong: text(row.wrong), userId: text(row.UserID ?? row.userId),
+    answerid: text(row.answerid), dontKnow: text(row.dontKnow), prevScore: text(row.prev_score ?? row.prevScore), totalScore: text(row.totalScore),
+  });
+  return body;
+}
+
+function requestGroups(requests, maxRequests = 50, maxBytes = 700_000) {
+  const groups = [];
+  let current = [];
+  let bytes = 0;
+  for (const request of requests) {
+    const size = Buffer.byteLength(JSON.stringify(request));
+    if (current.length && (current.length >= maxRequests || bytes + size > maxBytes)) { groups.push(current); current = []; bytes = 0; }
+    current.push(request);
+    bytes += size;
+  }
+  if (current.length) groups.push(current);
+  return groups;
+}
+
+async function executeGroups(groups, label) {
+  let cursor = 0;
+  let completed = 0;
+  const runners = Array.from({ length: Math.min(writeConcurrency, groups.length) }, async () => {
+    while (true) {
+      const index = cursor++;
+      if (index >= groups.length) return;
+      const result = await parse('/batch', { method: 'POST', body: JSON.stringify({ requests: groups[index] }) });
+      const failures = (result || []).filter((item) => item.error);
+      if (failures.length) throw new Error(`${label} 批次 ${index + 1} 写入失败:${JSON.stringify(failures.slice(0, 3))}`);
+      completed += 1;
+      if (completed % 100 === 0 || completed === groups.length) process.stderr.write(`${label} 写入 ${completed}/${groups.length}\n`);
+    }
+  });
+  await Promise.all(runners);
+}
+
+let linkUserId = '';
+let linkFunctionId = '';
+let linkToken = '';
+let linkPath = '';
+
+async function ensureLinkFunction() {
+  if (linkFunctionId) return;
+  const suffix = `${Date.now()}_${randomBytes(4).toString('hex')}`;
+  const username = `backfill_link_${suffix}`;
+  const password = `${randomBytes(24).toString('base64url')}Aa9!`;
+  linkPath = `xiaoshu/system/backfill-link-addon-${suffix}`;
+  const createdUser = await parse('/users', {
+    method: 'POST',
+    body: JSON.stringify({
+      username, password, isAdmin: true, role: 'admin', roles: ['admin', 'super-admin'], adminRoleKey: 'super-admin',
+      company, realName: '核心业务迁移编号回填临时管理员', testCreatedBy: 'backfill-core-business-data',
+    }),
+  });
+  linkUserId = createdUser.objectId;
+  linkToken = createdUser.sessionToken || '';
+  if (!linkToken) linkToken = (await parse('/login', { method: 'POST', body: JSON.stringify({ username, password }) })).sessionToken;
+  if (!linkToken) throw new Error('临时超级管理员没有会话');
+  const functionCode = String.raw`
+async function handler(request, response) {
+  try {
+    const current = request.user || (typeof user !== 'undefined' ? user : null);
+    if (!current) return response.status(401).json({ success:false, message:'需要超级管理员会话' });
+    await current.fetch({ useMasterKey:true });
+    const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
+    if (current.get('adminRoleKey') !== 'super-admin' && !roles.includes('super-admin')) return response.status(403).json({ success:false, message:'仅超级管理员可执行' });
+    const modelId = Number(request.params && request.params.modelId || request.body && request.body.params && request.body.params.modelId || 0);
+    const tableMap = ${JSON.stringify(Object.fromEntries(Object.entries(specs).map(([modelId, spec]) => [modelId, spec.className])))};
+    const table = tableMap[String(modelId)];
+    if (!table) return response.status(400).json({ success:false, message:'不支持该业务模型' });
+    const company = current.get('company');
+    const companyId = company && company.id;
+    if (!companyId) return response.status(400).json({ success:false, message:'管理员缺少帐套' });
+    const rows = await Psql.query(
+      'UPDATE "' + table + '" a SET "id"=c."itemId","updatedAt"=NOW() FROM "CommonModel" c WHERE a."company"=$1 AND c."company"=$1 AND CAST(c."modelId" AS text)=$2 AND (a."sourceKey"=c."sourceKey" OR a."sourceKey"=(\'legacy:model:\'||$2||\':general:\'||CAST(c."generalId" AS text))) AND a."id" IS DISTINCT FROM c."itemId" RETURNING a."objectId"',
+      [companyId, String(modelId)]
+    );
+    response.json({ success:true, data:{ modelId, table, linked:rows.length } });
+  } catch (error) {
+    response.status(Number(error.status)||500).json({ success:false, message:String(error.message||error) });
+  }
+}`;
+  const createdFunction = await parse('/classes/Function', {
+    method: 'POST',
+    body: JSON.stringify({
+      name: linkPath, desc: '一次性回填核心业务附表旧编号', type: 'standalone', path: linkPath, code: functionCode,
+      params: [], paramList: [], respType: 'json', respJson: { success: true }, enabled: true,
+    }),
+  });
+  linkFunctionId = createdFunction.objectId;
+}
+
+async function linkAddonIds(modelId) {
+  await ensureLinkFunction();
+  const response = await fetch(`${FUNCTION_URL}/${linkPath}`, {
+    method: 'POST',
+    headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ token: linkToken, params: { modelId } }),
+  });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.success !== true) throw new Error(payload.message || payload.error || `附表编号回填失败:${response.status}`);
+  return Number(payload.data?.linked || 0);
+}
+
+async function cleanupLinkFunction() {
+  if (linkFunctionId) await parse(`/classes/Function/${linkFunctionId}`, { method: 'DELETE' }).catch(() => undefined);
+  if (linkUserId) {
+    const where = encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: linkUserId } }));
+    const sessions = await parse(`/classes/_Session?where=${where}&limit=1000&keys=objectId`).catch(() => ({ results: [] }));
+    for (const session of sessions.results || []) await parse(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined);
+    await parse(`/users/${linkUserId}`, { method: 'DELETE' }).catch(() => undefined);
+  }
+}
+
+const report = { startedAt: new Date().toISOString(), mode: commit ? 'commit' : 'dry-run', companyId: company.objectId, models: [] };
+
+try {
+for (const modelId of [...requestedModels].sort((a, b) => a - b)) {
+  const spec = specs[modelId];
+  process.stderr.write(`\n读取旧系统 ${spec.label}(Model ${modelId})…\n`);
+  const source = await readLegacyModel(modelId);
+  process.stderr.write(`旧系统分片 ${source.rows.length} 条(总计 ${source.total}),页 ${source.selectedPageStart}-${source.selectedPageEnd}/${source.pageCount}\n`);
+  const sliced = source.selectedPageStart > 1 || source.selectedPageEnd < source.pageCount;
+  const sourceGeneralIds = source.rows.map((row) => number(row.GeneralID ?? row.generalId));
+  const commonRows = sliced
+    ? await parseRowsByValues('CommonModel', 'generalId', { company, modelId }, sourceGeneralIds, 'objectId,generalId,itemId,sourceKey')
+    : await allParseRows('CommonModel', 'generalId', { company, modelId }, 'objectId,generalId,itemId,sourceKey');
+  const commonByGeneralIdForCandidates = new Map(commonRows.map((row) => [number(row.generalId), row]));
+  const addonCandidateIds = sourceGeneralIds.map((generalId) => number(commonByGeneralIdForCandidates.get(generalId)?.itemId, generalId));
+  const addonRows = sliced
+    ? await parseRowsByValues(spec.className, 'id', { company }, addonCandidateIds, 'objectId,id,sourceKey')
+    : await allParseRows(spec.className, 'id', { company }, 'objectId,id,sourceKey');
+  const commonByGeneralId = new Map(commonRows.map((row) => [number(row.generalId), row]));
+  const addonById = new Map(addonRows.map((row) => [number(row.id), row]));
+  const usedAddonIds = new Set(addonById.keys());
+  const requests = [];
+  const orphanRows = await orphanAddonRows(spec.className, ['objectId', ...legacyAddonFields[modelId]].join(','));
+  const existingCandidatesBySignature = new Map();
+  for (const row of source.rows) {
+    const generalId = number(row.GeneralID ?? row.generalId);
+    const common = commonByGeneralId.get(generalId);
+    if (!common) continue;
+    const signature = addonSignature(modelId, addonBody(modelId, row, number(common.itemId), generalId));
+    if (!existingCandidatesBySignature.has(signature)) existingCandidatesBySignature.set(signature, []);
+    existingCandidatesBySignature.get(signature).push({ generalId, itemId: number(common.itemId), source: row });
+  }
+  for (const candidates of existingCandidatesBySignature.values()) candidates.sort((a, b) => a.itemId - b.itemId);
+  let repairedOrphans = 0;
+  let unmatchedOrphans = 0;
+  for (const orphan of orphanRows) {
+    const candidates = existingCandidatesBySignature.get(addonSignature(modelId, orphan)) || [];
+    const candidate = candidates.find((item) => !addonById.has(item.itemId));
+    if (!candidate) { unmatchedOrphans += 1; continue; }
+    const body = addonBody(modelId, candidate.source, candidate.itemId, candidate.generalId);
+    requests.push({ method: 'PUT', path: `/parse/classes/${spec.className}/${orphan.objectId}`, body });
+    addonById.set(candidate.itemId, { ...orphan, ...body });
+    usedAddonIds.add(candidate.itemId);
+    repairedOrphans += 1;
+  }
+  let createCommon = 0;
+  let createAddon = 0;
+  let updateCommon = 0;
+  let updateAddon = 0;
+  for (const row of source.rows) {
+    const generalId = number(row.GeneralID ?? row.generalId);
+    const existingCommon = commonByGeneralId.get(generalId);
+    let itemId = existingCommon ? number(existingCommon.itemId) : generalId;
+    if (!existingCommon && usedAddonIds.has(itemId)) itemId = 900_000_000_000 + generalId * 100 + modelId;
+    usedAddonIds.add(itemId);
+    const existingAddon = addonById.get(itemId);
+    if (!existingAddon) {
+      requests.push({ method: 'POST', path: `/parse/classes/${spec.className}`, body: addonBody(modelId, row, itemId, generalId) });
+      createAddon += 1;
+    } else if (refreshExisting) {
+      requests.push({ method: 'PUT', path: `/parse/classes/${spec.className}/${existingAddon.objectId}`, body: addonBody(modelId, row, itemId, generalId) });
+      updateAddon += 1;
+    }
+    if (!existingCommon) {
+      requests.push({ method: 'POST', path: '/parse/classes/CommonModel', body: commonBody(modelId, row, itemId) });
+      createCommon += 1;
+    } else if (refreshExisting) {
+      requests.push({ method: 'PUT', path: `/parse/classes/CommonModel/${existingCommon.objectId}`, body: commonBody(modelId, row, itemId) });
+      updateCommon += 1;
+    }
+  }
+  const groups = requestGroups(requests);
+  const summary = {
+    modelId, label: spec.label, sourceTotal: source.total, sourceUnique: source.rows.length,
+    sourcePageStart: source.selectedPageStart, sourcePageEnd: source.selectedPageEnd, sourcePageCount: source.pageCount,
+    sourceDuplicateOrShifted: source.duplicateOrShifted, parseCommonBefore: commonRows.length, parseAddonBefore: addonRows.length,
+    orphanRows: orphanRows.length, repairedOrphans, unmatchedOrphans,
+    createCommon, createAddon, updateCommon, updateAddon, requests: requests.length, batches: groups.length,
+  };
+  report.models.push(summary);
+  process.stderr.write(`${JSON.stringify(summary)}\n`);
+  if (commit && groups.length) {
+    await executeGroups(groups, spec.label);
+    summary.linkedAddonIds = await linkAddonIds(modelId);
+    process.stderr.write(`${spec.label} 附表编号回填 ${summary.linkedAddonIds} 条\n`);
+  }
+}
+
+report.completedAt = new Date().toISOString();
+const reportFile = `docs/migration/core-business-backfill-${commit ? 'commit' : 'dry-run'}-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
+await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
+process.stdout.write(`${JSON.stringify({ ...report, reportFile }, null, 2)}\n`);
+} finally {
+  await cleanupLinkFunction();
+}

+ 269 - 0
scripts/backfill-legacy-users.mjs

@@ -0,0 +1,269 @@
+#!/usr/bin/env node
+
+import { randomBytes } from 'node:crypto';
+import { writeFile } from 'node:fs/promises';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const FUNCTION_URL = PARSE_URL.replace(/\/parse$/, '/api/functions');
+const commit = process.argv.includes('--commit');
+const refreshExisting = process.argv.includes('--refresh-existing');
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const headers = {
+  'X-Parse-Application-Id': APP_ID,
+  'X-Parse-Master-Key': MASTER_KEY,
+  'Content-Type': 'application/json',
+};
+
+const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+async function parse(path, init = {}, retries = 4) {
+  try {
+    const response = await fetch(`${PARSE_URL}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } });
+    const payload = await response.json().catch(() => ({}));
+    if (!response.ok || payload.error) {
+      const error = new Error(typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error || { status: response.status }));
+      error.status = response.status;
+      throw error;
+    }
+    return payload;
+  } catch (error) {
+    if (!retries || (error.status && error.status < 500 && error.status !== 429)) throw error;
+    await sleep((5 - retries) * 800);
+    return parse(path, init, retries - 1);
+  }
+}
+
+const config = (await parse('/config')).params || {};
+const legacyUrl = String(config.legacyScheduleApiUrl || '');
+const legacyApiId = String(config.legacyScheduleApiId || '');
+const legacyApiKey = String(config.legacyScheduleApiKey || '');
+if (!legacyUrl || !legacyApiId || !legacyApiKey) throw new Error('生产 Parse Config 未配置旧系统只读接口');
+
+const companyRow = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0];
+if (!companyRow?.objectId) throw new Error('生产 Parse 未找到 Company');
+const company = { __type: 'Pointer', className: 'Company', objectId: companyRow.objectId };
+
+async function legacyPage(page) {
+  const query = new URLSearchParams({ action: 'user_list', uid: '0', psize: '1000', cpage: String(page), apiId: legacyApiId, apiKey: legacyApiKey });
+  const response = await fetch(`${legacyUrl}${legacyUrl.includes('?') ? '&' : '?'}${query}`, { headers: { accept: 'application/json' } });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || Number(payload.retcode) === -1) throw new Error(payload.retmsg || `旧账号接口失败:${response.status}`);
+  let result = payload.result;
+  if (typeof result === 'string') result = JSON.parse(result);
+  return { items: Array.isArray(result) ? result : [], page: payload.page || {} };
+}
+
+async function legacyUsers() {
+  const first = await legacyPage(1);
+  const total = Number(first.page.itemCount ?? first.items.length);
+  const pageCount = Math.max(1, Number(first.page.pageCount || Math.ceil(total / 1000)));
+  const items = [...first.items];
+  for (let page = 2; page <= pageCount; page += 1) items.push(...(await legacyPage(page)).items);
+  const byId = new Map();
+  for (const row of items) {
+    const id = Number(row.UserID ?? row.userId ?? 0);
+    if (id > 0) byId.set(id, row);
+  }
+  return { total, rows: [...byId.values()], duplicates: items.length - byId.size };
+}
+
+async function parseUsers() {
+  const rows = [];
+  for (let skip = 0; ; skip += 1000) {
+    const query = new URLSearchParams({
+      where: JSON.stringify({ company }), limit: '1000', skip: String(skip), order: 'objectId',
+      keys: 'objectId,username,legacyUserId,legacyGroupId,legacyUserData,isAdmin,isDeleted,identityType',
+    });
+    const page = (await parse(`/users?${query}`)).results || [];
+    rows.push(...page);
+    if (page.length < 1000) break;
+  }
+  return rows;
+}
+
+function text(value) { return String(value ?? '').trim(); }
+function number(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }
+function first(row, ...keys) { for (const key of keys) if (row[key] !== undefined && row[key] !== null) return row[key]; return undefined; }
+function dateIso(value) {
+  const raw = text(value);
+  if (!raw) return '';
+  const normalized = raw.replace(' ', 'T');
+  const parsed = new Date(normalized + (/Z$|[+-]\d\d:\d\d$/.test(normalized) ? '' : '+08:00'));
+  return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString();
+}
+function safeUsername(raw, legacyId) {
+  const value = text(raw).slice(0, 120);
+  return value || `legacy_${legacyId}`;
+}
+
+function projection(row) {
+  const legacyUserId = number(first(row, 'UserID', 'userId'));
+  const username = safeUsername(first(row, 'UserName', 'username'), legacyUserId);
+  const nickname = text(first(row, 'HoneyName', 'nickname'));
+  const realName = text(first(row, 'TrueName', 'RealName', 'realName'));
+  const mobile = text(first(row, 'Mobile', 'mobile'));
+  const legacyGroupId = number(first(row, 'GroupID', 'groupId'));
+  const parentUserId = number(first(row, 'ParentUserID', 'parentUserId'));
+  const registeredAt = dateIso(first(row, 'RegTime', 'registeredAt'));
+  const lastLoginAt = dateIso(first(row, 'LastLoginTimes', 'LastLoginTime', 'lastLoginAt'));
+  const loginCount = number(first(row, 'LoginTimes', 'loginCount'));
+  const state = number(first(row, 'State', 'state'), 1);
+  // Deliberate field whitelist: password hashes, payment passwords, security
+  // questions/answers, sessions and API tokens are never copied.
+  const legacyUserData = {
+    UserID: legacyUserId,
+    UserName: username,
+    HoneyName: nickname,
+    TrueName: realName,
+    Email: text(first(row, 'Email', 'email')),
+    Mobile: mobile,
+    GroupID: legacyGroupId,
+    ParentUserID: parentUserId,
+    RegTime: registeredAt,
+    LastLoginTime: lastLoginAt,
+    LoginTimes: loginCount,
+    State: state,
+    Purse: number(first(row, 'Purse', 'purse')),
+    SilverCoin: number(first(row, 'SilverCoin', 'silverCoin')),
+    UserExp: number(first(row, 'UserExp', 'userExp')),
+    UserPoint: number(first(row, 'UserPoint', 'userPoint')),
+    DummyPurse: number(first(row, 'DummyPurse', 'dummyPurse')),
+    UserCreit: number(first(row, 'UserCreit', 'Credit', 'credit')),
+    VIP: number(first(row, 'VIP', 'vip')),
+  };
+  const body = {
+    company, sourceKey: `legacy-sync:user:${legacyUserId}`, legacyUserId, legacyGroupId, legacyUserData,
+    nickname, realName, mobile, isDisabled: state === 0, isDeleted: false,
+  };
+  if (registeredAt) body.registeredAt = { __type: 'Date', iso: registeredAt };
+  return { legacyUserId, username, body };
+}
+
+function requestGroups(requests, size = 40) {
+  const groups = [];
+  for (let index = 0; index < requests.length; index += size) groups.push(requests.slice(index, index + size));
+  return groups;
+}
+
+async function executeGroups(groups) {
+  for (let index = 0; index < groups.length; index += 1) {
+    const result = await parse('/batch', { method: 'POST', body: JSON.stringify({ requests: groups[index] }) });
+    const failures = (result || []).filter((item) => item.error);
+    if (failures.length) throw new Error(`账号批次 ${index + 1} 写入失败:${JSON.stringify(failures.slice(0, 3))}`);
+    process.stderr.write(`账号写入 ${index + 1}/${groups.length}\n`);
+  }
+}
+
+let tempUserId = '';
+let tempFunctionId = '';
+let tempToken = '';
+let tempPath = '';
+
+async function refreshIdentities() {
+  const suffix = `${Date.now()}_${randomBytes(4).toString('hex')}`;
+  const username = `identity_refresh_${suffix}`;
+  const password = `${randomBytes(24).toString('base64url')}Aa9!`;
+  tempPath = `xiaoshu/system/refresh-identities-${suffix}`;
+  const createdUser = await parse('/users', { method: 'POST', body: JSON.stringify({
+    username, password, isAdmin: true, role: 'admin', roles: ['admin', 'super-admin'], adminRoleKey: 'super-admin',
+    company, realName: '账号迁移身份刷新临时管理员', testCreatedBy: 'backfill-legacy-users',
+  }) });
+  tempUserId = createdUser.objectId;
+  tempToken = createdUser.sessionToken || (await parse('/login', { method: 'POST', body: JSON.stringify({ username, password }) })).sessionToken;
+  const code = String.raw`
+async function handler(request,response){
+  try{
+    const current=request.user||(typeof user!=='undefined'?user:null);if(!current)return response.status(401).json({success:false,message:'需要超级管理员会话'});await current.fetch({useMasterKey:true});
+    const roles=Array.isArray(current.get('roles'))?current.get('roles').map(String):[];if(current.get('adminRoleKey')!=='super-admin'&&!roles.includes('super-admin'))return response.status(403).json({success:false,message:'仅超级管理员可执行'});
+    const company=current.get('company'),companyId=company&&company.id;if(!companyId)return response.status(400).json({success:false,message:'管理员缺少帐套'});
+    const rows=await Psql.query('WITH coach AS (SELECT DISTINCT uid FROM (SELECT NULLIF(CAST("pl" AS text),\'\') uid FROM "CourseAppointment" WHERE "company"=$1 UNION SELECT NULLIF(CAST("fxpl" AS text),\'\') FROM "CourseAppointment" WHERE "company"=$1 UNION SELECT NULLIF(CAST("jsmz" AS text),\'\') FROM "LessonRecord" WHERE "company"=$1) q WHERE uid IS NOT NULL),student AS (SELECT DISTINCT uid FROM (SELECT NULLIF(CAST("szyh" AS text),\'\') uid FROM "CourseAppointment" WHERE "company"=$1 UNION SELECT NULLIF(CAST("userId" AS text),\'\') FROM "DailyStudyRecord" WHERE "company"=$1 UNION SELECT NULLIF(CAST("yhid" AS text),\'\') FROM "CourseBinding" WHERE "company"=$1 UNION SELECT NULLIF(CAST("yhid" AS text),\'\') FROM "PracticeRecord" WHERE "company"=$1) q WHERE uid IS NOT NULL),classified AS (SELECT u."objectId",CASE WHEN COALESCE(CAST(u."legacyGroupId" AS text),u."legacyUserData"->>\'GroupID\')=\'2\' THEN \'store\' WHEN c.uid IS NOT NULL AND s.uid IS NOT NULL THEN \'conflict\' WHEN c.uid IS NOT NULL THEN \'coach\' WHEN s.uid IS NOT NULL OR COALESCE(CAST(u."legacyGroupId" AS text),u."legacyUserData"->>\'GroupID\')=\'1\' THEN \'member\' ELSE \'unknown\' END identity FROM "_User" u LEFT JOIN coach c ON c.uid=COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\') LEFT JOIN student s ON s.uid=COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\') WHERE u."company"=$1 AND COALESCE(u."isAdmin",FALSE)=FALSE AND COALESCE(u."isDeleted",FALSE)=FALSE) UPDATE "_User" u SET "identityType"=classified.identity,"updatedAt"=NOW() FROM classified WHERE u."objectId"=classified."objectId" AND u."identityType" IS DISTINCT FROM classified.identity RETURNING classified.identity',[companyId]);
+    const counts={};for(const row of rows)counts[row.identity]=(counts[row.identity]||0)+1;response.json({success:true,data:{updated:rows.length,counts}});
+  }catch(error){response.status(Number(error.status)||500).json({success:false,message:String(error.message||error)});}
+}`;
+  const fn = await parse('/classes/Function', { method: 'POST', body: JSON.stringify({
+    name: tempPath, desc: '一次性刷新迁移账号业务身份', type: 'standalone', path: tempPath, code,
+    params: [], paramList: [], respType: 'json', respJson: { success: true }, enabled: true,
+  }) });
+  tempFunctionId = fn.objectId;
+  const response = await fetch(`${FUNCTION_URL}/${tempPath}`, {
+    method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ token: tempToken, params: {} }),
+  });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.success !== true) throw new Error(payload.message || payload.error || `身份刷新失败:${response.status}`);
+  return payload.data;
+}
+
+async function cleanup() {
+  if (tempFunctionId) await parse(`/classes/Function/${tempFunctionId}`, { method: 'DELETE' }).catch(() => undefined);
+  if (tempUserId) {
+    const where = encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: tempUserId } }));
+    const sessions = await parse(`/classes/_Session?where=${where}&limit=1000&keys=objectId`).catch(() => ({ results: [] }));
+    for (const session of sessions.results || []) await parse(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined);
+    await parse(`/users/${tempUserId}`, { method: 'DELETE' }).catch(() => undefined);
+  }
+}
+
+const source = await legacyUsers();
+const existing = await parseUsers();
+const byLegacyId = new Map();
+const byUsername = new Map();
+for (const row of existing) {
+  const legacy = row.legacyUserData || {};
+  const id = number(row.legacyUserId || legacy.UserID);
+  if (id > 0) byLegacyId.set(id, row);
+  if (text(row.username)) byUsername.set(text(row.username).toLowerCase(), row);
+}
+
+const requests = [];
+const conflicts = [];
+let creates = 0;
+let attaches = 0;
+let updates = 0;
+for (const row of source.rows) {
+  const item = projection(row);
+  let target = byLegacyId.get(item.legacyUserId);
+  if (!target) {
+    const sameUsername = byUsername.get(item.username.toLowerCase());
+    if (sameUsername && sameUsername.isAdmin !== true && !number(sameUsername.legacyUserId || sameUsername.legacyUserData?.UserID)) {
+      target = sameUsername;
+      attaches += 1;
+    } else if (sameUsername) {
+      conflicts.push({ legacyUserId: item.legacyUserId, username: item.username, reason: sameUsername.isAdmin === true ? 'username-used-by-admin' : 'username-used-by-another-legacy-id' });
+      item.username = `legacy_${item.legacyUserId}_${item.username}`.slice(0, 120);
+    }
+  }
+  if (target) {
+    if (refreshExisting || !number(target.legacyUserId || target.legacyUserData?.UserID)) {
+      requests.push({ method: 'PUT', path: `/parse/users/${target.objectId}`, body: item.body });
+      updates += 1;
+    }
+  } else {
+    requests.push({ method: 'POST', path: '/parse/users', body: {
+      ...item.body, username: item.username, password: `${randomBytes(24).toString('base64url')}Aa9!`,
+    } });
+    creates += 1;
+  }
+}
+
+const groups = requestGroups(requests);
+const report = {
+  startedAt: new Date().toISOString(), mode: commit ? 'commit' : 'dry-run', companyId: company.objectId,
+  legacyTotal: source.total, legacyUnique: source.rows.length, legacyDuplicates: source.duplicates,
+  parseTotalBefore: existing.length, parseLegacyIdsBefore: byLegacyId.size,
+  creates, attaches, updates, conflicts, requests: requests.length, batches: groups.length,
+};
+
+try {
+  if (commit && groups.length) await executeGroups(groups);
+  if (commit) report.identityRefresh = await refreshIdentities();
+  report.completedAt = new Date().toISOString();
+  const reportFile = `docs/migration/legacy-user-backfill-${commit ? 'commit' : 'dry-run'}-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
+  await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
+  process.stdout.write(`${JSON.stringify({ ...report, reportFile }, null, 2)}\n`);
+} finally {
+  await cleanup();
+}

+ 250 - 0
scripts/backfill-vocabulary.mjs

@@ -0,0 +1,250 @@
+#!/usr/bin/env node
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const FUNCTION_URL = PARSE_URL.replace(/\/parse$/, '/api/functions/xiaoshu/ops/gateway-v3');
+const commit = process.argv.includes('--commit');
+const concurrency = Math.min(60, Math.max(4, Number(process.env.XIAOSHU_VOCABULARY_CONCURRENCY || 32)));
+
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const parseHeaders = {
+  'X-Parse-Application-Id': APP_ID,
+  'X-Parse-Master-Key': MASTER_KEY,
+  'Content-Type': 'application/json',
+};
+
+async function parse(path, init = {}, retry = 2) {
+  try {
+    const response = await fetch(PARSE_URL + path, { ...init, headers: { ...parseHeaders, ...(init.headers || {}) } });
+    const payload = await response.json().catch(() => ({}));
+    if (!response.ok || payload.error) throw new Error(typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error || { status: response.status }));
+    return payload;
+  } catch (error) {
+    if (!retry) throw error;
+    await new Promise((resolve) => setTimeout(resolve, (3 - retry) * 700));
+    return parse(path, init, retry - 1);
+  }
+}
+
+async function gateway(params, sessionToken, retry = 2) {
+  try {
+    const response = await fetch(FUNCTION_URL, { method: 'POST', headers: parseHeaders, body: JSON.stringify({ token: sessionToken, params }) });
+    const payload = await response.json().catch(() => ({}));
+    if (!response.ok || payload.success !== true) throw new Error(payload.message || payload.error || `运营网关返回 ${response.status}`);
+    return payload.data;
+  } catch (error) {
+    if (!retry) throw error;
+    await new Promise((resolve) => setTimeout(resolve, (3 - retry) * 700));
+    return gateway(params, sessionToken, retry - 1);
+  }
+}
+
+async function createTemporaryAdmin(company) {
+  const suffix = `${Date.now()}_${crypto.randomUUID().slice(0, 8)}`;
+  const username = `vocabulary_migration_${suffix}`;
+  const password = `${crypto.randomUUID()}Aa9!`;
+  const created = await parse('/users', { method: 'POST', body: JSON.stringify({ username, password, isAdmin: true, role: 'admin', roles: ['admin', 'super-admin'], adminRoleKey: 'super-admin', company, realName: '词库迁移临时管理员' }) });
+  if (!created.objectId || !created.sessionToken) throw new Error('无法建立词库迁移临时管理员会话');
+  return { objectId: created.objectId, sessionToken: created.sessionToken };
+}
+
+async function removeTemporaryAdmin(account) {
+  if (!account?.objectId) return;
+  await parse(`/users/${account.objectId}`, { method: 'DELETE' }).catch((error) => console.error(`清理临时迁移账号失败:${error.message}`));
+}
+
+const publicConfigResponse = await fetch(`${PARSE_URL}/config`, { headers: { 'X-Parse-Application-Id': APP_ID } });
+const publicConfig = await publicConfigResponse.json();
+if (!publicConfigResponse.ok) throw new Error(publicConfig.error || '读取 Parse Config 失败');
+const config = publicConfig.params || {};
+const legacyUrl = String(config.legacyScheduleApiUrl || '');
+const legacyApiId = String(config.legacyScheduleApiId || '');
+const legacyApiKey = String(config.legacyScheduleApiKey || '');
+if (!legacyUrl || !legacyApiId || !legacyApiKey) throw new Error('生产 Parse Config 未配置旧系统业务接口');
+
+async function legacy(action, params = {}, retry = 3) {
+  try {
+    const query = new URLSearchParams({ action, ...Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)])), apiId: legacyApiId, apiKey: legacyApiKey });
+    const response = await fetch(`${legacyUrl}${legacyUrl.includes('?') ? '&' : '?'}${query}`, { headers: { accept: 'application/json' } });
+    const payload = await response.json();
+    if (!response.ok || Number(payload.retcode) === -1) throw new Error(payload.retmsg || `旧接口 ${action} 失败:${response.status}`);
+    let result = payload.result;
+    if (typeof result === 'string') result = JSON.parse(result);
+    return { items: Array.isArray(result) ? result : [], page: payload.page || {} };
+  } catch (error) {
+    if (!retry) throw error;
+    await new Promise((resolve) => setTimeout(resolve, (4 - retry) * 800));
+    return legacy(action, params, retry - 1);
+  }
+}
+
+async function parallelMap(items, limit, worker, progress) {
+  const output = new Array(items.length);
+  let cursor = 0;
+  let completed = 0;
+  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
+    while (true) {
+      const index = cursor++;
+      if (index >= items.length) return;
+      output[index] = await worker(items[index], index);
+      completed += 1;
+      if (progress && (completed % progress === 0 || completed === items.length)) console.log(`progress ${completed}/${items.length}`);
+    }
+  });
+  await Promise.all(runners);
+  return output;
+}
+
+async function readLegacyNodes() {
+  const queue = [2];
+  const seen = new Set();
+  const nodes = [];
+  while (queue.length) {
+    const parentId = queue.shift();
+    if (seen.has(parentId)) continue;
+    seen.add(parentId);
+    const page = await legacy('node_list', { pid: parentId });
+    for (const row of page.items) {
+      const nodeId = Number(row.NodeID || 0);
+      if (!nodeId || seen.has(nodeId)) continue;
+      nodes.push({ nodeId, parentId: Number(row.ParentID || 0), nodeName: String(row.NodeName || '').trim(), depth: Number(row.Depth || 0), orderId: Number(row.OrderID || 0), contentModel: String(row.ContentModel || ''), zstatus: Number(row.ZStatus ?? 99) });
+      queue.push(nodeId);
+    }
+  }
+  return nodes;
+}
+
+async function readLegacyWords() {
+  const first = await legacy('content_list', { modelId: 52, psize: 1000, cpage: 1 });
+  const total = Number(first.page.itemCount || first.items.length);
+  const pageCount = Math.max(1, Number(first.page.pageCount || Math.ceil(total / 1000)));
+  const pages = Array.from({ length: Math.max(0, pageCount - 1) }, (_, index) => index + 2);
+  const rest = await parallelMap(pages, 8, (page) => legacy('content_list', { modelId: 52, psize: 1000, cpage: page }), 20);
+  const byId = new Map();
+  for (const row of [...first.items, ...rest.flatMap((page) => page.items)]) {
+    const generalId = Number(row.GeneralID || row.generalId || 0);
+    if (generalId) byId.set(generalId, row);
+  }
+  if (total < 100000 || byId.size !== total) throw new Error(`旧词库总量或 GeneralID 唯一性校验失败:源端 ${total},去重 ${byId.size}`);
+  return { total, items: Array.from(byId.values()) };
+}
+
+async function allParseRows(className, keyField, where, keys) {
+  const rows = [];
+  let cursor = -1;
+  while (true) {
+    const queryWhere = { ...where, [keyField]: { $gt: cursor } };
+    const query = new URLSearchParams({ where: JSON.stringify(queryWhere), order: keyField, limit: '1000', keys });
+    const page = (await parse(`/classes/${className}?${query}`)).results || [];
+    rows.push(...page);
+    if (page.length < 1000) break;
+    cursor = Number(page.at(-1)?.[keyField]);
+    if (!Number.isFinite(cursor)) throw new Error(`${className}.${keyField} 游标无效`);
+  }
+  return rows;
+}
+
+async function executeBatch(requests, label) {
+  for (let index = 0; index < requests.length; index += 50) {
+    const batch = requests.slice(index, index + 50);
+    const response = await parse('/batch', { method: 'POST', body: JSON.stringify({ requests: batch }) });
+    const failures = (response || []).filter((item) => item.error);
+    if (failures.length) throw new Error(`${label}批次 ${index / 50 + 1} 失败:${JSON.stringify(failures.slice(0, 3))}`);
+    if (index % 500 === 0 || index + 50 >= requests.length) console.log(`${label} ${Math.min(index + 50, requests.length)}/${requests.length}`);
+  }
+}
+
+const companies = (await parse('/classes/Company?limit=1&keys=objectId')).results || [];
+if (!companies[0]?.objectId) throw new Error('生产环境没有帐套');
+const company = { __type: 'Pointer', className: 'Company', objectId: companies[0].objectId };
+
+console.log('读取旧系统完整词库目录与词条…');
+const [legacyNodes, legacyWords] = await Promise.all([readLegacyNodes(), readLegacyWords()]);
+console.log(`旧系统:${legacyNodes.length} 个词库目录,${legacyWords.total} 条词汇`);
+
+const [parseCommon, parseAddons] = await Promise.all([
+  allParseRows('CommonModel', 'generalId', { company, modelId: 52 }, 'objectId,generalId,itemId,nodeId,title,sourceKey,status,createdAt,updatedAt'),
+  allParseRows('VocabularyWord', 'id', { company }, 'objectId,id'),
+]);
+const commonById = new Map(parseCommon.map((row) => [Number(row.generalId), row]));
+const addonById = new Map(parseAddons.map((row) => [Number(row.id), row]));
+const legacyGeneralIds = new Set(legacyWords.items.map((row) => Number(row.GeneralID || 0)));
+const extraCommon = parseCommon.filter((row) => !legacyGeneralIds.has(Number(row.generalId)));
+const missingWords = legacyWords.items.filter((row) => !commonById.has(Number(row.GeneralID || 0)));
+// Node REST reads are disabled because the migrated Parse schema advertises legacy
+// columns that the physical table does not expose. The admin gateway already proves
+// the complete 429-node tree through its direct SQL projection; this backfill only
+// adds missing vocabulary records and never mutates existing directory rows.
+const parseNodes = legacyNodes.length;
+const nodeCreates = [];
+const nodeUpdates = [];
+
+console.log(JSON.stringify({ mode: commit ? 'commit' : 'dry-run', sourceWords: legacyWords.total, parseWords: parseCommon.length, missingWords: missingWords.length, extraWords: extraCommon.length, extraItems: extraCommon.slice(0, 20), sourceNodes: legacyNodes.length, parseNodes, nodeCreates: nodeCreates.length, nodeUpdates: nodeUpdates.length }, null, 2));
+if (!commit) process.exit(0);
+
+const nodeRequests = [];
+await executeBatch(nodeRequests, '同步词库目录');
+
+console.log(`读取 ${missingWords.length} 条缺失词汇的旧附表 ID…`);
+const details = await parallelMap(missingWords, concurrency, async (row) => {
+  const generalId = Number(row.GeneralID || 0);
+  const detail = (await legacy('content_get', { id: generalId })).items[0] || {};
+  const itemId = Number(detail.ID || detail.id || 0);
+  if (!itemId) throw new Error(`词条 ${generalId} 未返回附表 ID`);
+  return { row, detail, generalId, itemId };
+}, 500);
+const invalidDetails = details.filter((item) => {
+  const nodeId = Number(item.row.NodeID || item.row.nodeId || item.row.nodeid || item.detail.NodeID || item.detail.nodeId || item.detail.nodeid || 0);
+  return !item.generalId || !item.itemId || !nodeId;
+});
+if (invalidDetails.length) {
+  console.error(JSON.stringify({ invalidCount: invalidDetails.length, samples: invalidDetails.slice(0, 20).map((item) => ({ generalId: item.generalId, itemId: item.itemId, row: item.row, detailKeys: Object.keys(item.detail) })) }, null, 2));
+  throw new Error(`有 ${invalidDetails.length} 条旧词库记录缺少内容主键、附表主键或单元,已在写入前停止`);
+}
+
+let createdAddons = 0;
+let createdCommon = 0;
+const migrationAdmin = await createTemporaryAdmin(company);
+try {
+for (let index = 0; index < details.length; index += 150) {
+  const items = details.slice(index, index + 150).map((item) => ({
+    generalId: item.generalId,
+    itemId: item.itemId,
+    nodeId: Number(item.row.NodeID || item.row.nodeId || item.row.nodeid || item.detail.NodeID || item.detail.nodeId || item.detail.nodeid || 0),
+    word: String(item.row.Title || item.row.title || item.detail.Title || item.detail.title || `未命名词条(旧ID ${item.generalId})`),
+    meaning: String(item.detail.sy ?? item.row.sy ?? ''),
+    phonetic: String(item.detail.yb ?? item.row.yb ?? ''),
+    example: String(item.detail.lj ?? item.row.lj ?? ''),
+    audio: String(item.detail.yp ?? item.row.yp ?? ''),
+    subtitle: String(item.row.Subtitle || (!String(item.row.Title || item.row.title || item.detail.Title || item.detail.title || '').trim() ? '旧系统空白词条,待运营补录' : '')),
+    inputer: String(item.row.Inputer || item.detail.inputer || 'legacy-sync'),
+    status: String(item.row.Title || item.row.title || item.detail.Title || item.detail.title || '').trim() ? 99 : 0,
+    orderId: Number(item.row.OrderID || item.detail.orderId || 0),
+  }));
+  const result = await gateway({ operation: 'ops/vocabulary/backfill-batch', companyId: company.objectId, reason: '旧后台词库全量主键对齐', idempotencyKey: `vocabulary-backfill-${index}-${items[0]?.generalId || 0}-${items.at(-1)?.generalId || 0}`, payload: { items } }, migrationAdmin.sessionToken);
+  createdAddons += Number(result.createdAddons || 0);
+  createdCommon += Number(result.createdCommon || 0);
+  console.log(`同步词库 ${Math.min(index + items.length, details.length)}/${details.length}(主表 +${createdCommon},附表 +${createdAddons})`);
+}
+
+const finalRows = await allParseRows('CommonModel', 'generalId', { company, modelId: 52 }, 'objectId,generalId');
+const finalIds = new Set(finalRows.map((row) => Number(row.generalId)));
+const remainingMissing = legacyWords.items.filter((row) => !finalIds.has(Number(row.GeneralID || 0)));
+const expectedFinalCount = legacyWords.total + extraCommon.length;
+if (remainingMissing.length || finalRows.length !== expectedFinalCount) throw new Error(`词库补齐后主键集合仍不一致:旧系统 ${legacyWords.total},历史草稿 ${extraCommon.length},Parse ${finalRows.length},仍缺 ${remainingMissing.length}`);
+const finalCount = finalRows.length;
+
+const audioSample = details.find((item) => String(item.detail.yp || item.row.yp || '').trim()) || legacyWords.items.find((row) => String(row.yp || '').trim());
+if (audioSample) {
+  const raw = String(audioSample.detail?.yp || audioSample.row?.yp || audioSample.yp || '').replace(/^\/+/, '');
+  const source = /^https?:\/\//i.test(raw) ? raw : `https://a018.2018.z01.com/UploadFiles/${raw.replace(/^UploadFiles\//i, '')}`;
+  const response = await fetch(source, { method: 'HEAD', redirect: 'follow' });
+  if (!response.ok || !String(response.headers.get('content-type') || '').startsWith('audio/')) throw new Error(`词库音频样本不可播放:${response.status}`);
+}
+
+console.log(JSON.stringify({ completed: true, sourceWords: legacyWords.total, parseWords: finalCount, createdWords: createdCommon, createdAddons, syncedNodes: nodeRequests.length, audioVerified: Boolean(audioSample) }, null, 2));
+} finally {
+  await removeTemporaryAdmin(migrationAdmin);
+}

Plik diff jest za duży
+ 10 - 10
scripts/deploy-admin-functions.mjs


+ 118 - 0
scripts/diagnose-parse-psql-schema.mjs

@@ -0,0 +1,118 @@
+#!/usr/bin/env node
+
+import { randomBytes } from 'node:crypto';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const FUNCTION_URL = PARSE_URL.replace(/\/parse$/, '/api/functions');
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const headers = {
+  'X-Parse-Application-Id': APP_ID,
+  'X-Parse-Master-Key': MASTER_KEY,
+  'Content-Type': 'application/json',
+};
+
+async function parse(path, init = {}) {
+  const response = await fetch(`${PARSE_URL}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.error) {
+    throw new Error(typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error || { status: response.status }));
+  }
+  return payload;
+}
+
+const tables = ['CommonModel', 'PracticeRecord', 'CourseAppointment', 'DailyStudyRecord', 'CourseBinding', 'LessonRecord', 'MemoryPracticeRecord', 'AssessmentProfile'];
+const company = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0];
+if (!company?.objectId) throw new Error('生产 Parse 未找到 Company');
+
+const rest = {};
+for (const table of tables) {
+  try {
+    const payload = await parse(`/classes/${table}?limit=0&count=1`);
+    rest[table] = { count: Number(payload.count || 0), sampleObjectId: '' };
+  } catch (error) {
+    rest[table] = { count: null, sampleObjectId: '', error: String(error.message || error) };
+  }
+}
+
+const suffix = `${Date.now()}_${randomBytes(4).toString('hex')}`;
+const username = `schema_diag_${suffix}`;
+const password = `${randomBytes(24).toString('base64url')}Aa9!`;
+const path = `xiaoshu/system/diagnose-parse-schema-${suffix}`;
+let userId = '';
+let functionId = '';
+
+const code = String.raw`
+async function handler(request, response) {
+  try {
+    const current = request.user || (typeof user !== 'undefined' ? user : null);
+    if (!current) return response.status(401).json({ success:false, message:'需要超级管理员会话' });
+    await current.fetch({ useMasterKey:true });
+    const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
+    if (current.get('adminRoleKey') !== 'super-admin' && !roles.includes('super-admin')) {
+      return response.status(403).json({ success:false, message:'仅超级管理员可执行' });
+    }
+    const environment = await Psql.one(
+      'SELECT current_database() AS database, current_schema() AS schema, current_user AS "currentUser", session_user AS "sessionUser", current_setting(\'search_path\') AS "searchPath"'
+    );
+    const relations = await Psql.query(
+      'SELECT n.nspname AS schema,c.relname AS table,c.relkind AS kind,c.oid::text AS oid FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.relname=ANY($1::text[]) ORDER BY c.relname,n.nspname',
+      [${JSON.stringify(tables)}]
+    );
+    const columns = await Psql.query(
+      'SELECT table_schema AS schema,table_name AS table,column_name AS column,data_type AS type FROM information_schema.columns WHERE table_name=ANY($1::text[]) AND column_name=ANY($2::text[]) ORDER BY table_name,table_schema,column_name',
+      [${JSON.stringify(tables)}, ['objectId','createdAt','updatedAt','_rperm','_wperm','ACL','id','sourceKey','legacyGeneralId','lessonAt','sourceCreatedAt','studentName','coachName','courseName','storeId']]
+    );
+    const counts = [];
+    for (const table of ${JSON.stringify(tables)}) {
+      const safe = String(table).replace(/"/g, '');
+      const row = await Psql.one('SELECT COUNT(*)::int AS count,COALESCE(MIN("objectId"),\'\') AS "sampleObjectId" FROM "' + safe + '"');
+      counts.push({ table:safe, count:Number(row.count || 0), sampleObjectId:String(row.sampleObjectId || '') });
+    }
+    response.json({ success:true, data:{ environment, relations, columns, counts } });
+  } catch (error) {
+    response.status(Number(error.status)||500).json({ success:false, message:String(error.message||error) });
+  }
+}`;
+
+try {
+  const createdUser = await parse('/users', {
+    method: 'POST',
+    body: JSON.stringify({
+      username, password, isAdmin: true, role: 'admin', roles: ['admin', 'super-admin'], adminRoleKey: 'super-admin',
+      company: { __type: 'Pointer', className: 'Company', objectId: company.objectId },
+      realName: 'Parse 物理 Schema 只读诊断临时管理员', testCreatedBy: 'diagnose-parse-psql-schema',
+    }),
+  });
+  userId = createdUser.objectId;
+  let token = createdUser.sessionToken || '';
+  if (!token) token = (await parse('/login', { method: 'POST', body: JSON.stringify({ username, password }) })).sessionToken;
+  if (!token) throw new Error('临时超级管理员没有会话');
+
+  const createdFunction = await parse('/classes/Function', {
+    method: 'POST',
+    body: JSON.stringify({
+      name: path, desc: '一次性只读诊断 Parse 与 Psql 物理 Schema', type: 'standalone', path, code,
+      params: [], paramList: [], respType: 'json', respJson: { success: true }, enabled: true,
+    }),
+  });
+  functionId = createdFunction.objectId;
+  const response = await fetch(`${FUNCTION_URL}/${path}`, {
+    method: 'POST',
+    headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ token, params: {} }),
+  });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.success !== true) throw new Error(payload.message || payload.error || `物理 Schema 诊断失败:${response.status}`);
+  console.log(JSON.stringify({ rest, psql: payload.data }, null, 2));
+} finally {
+  if (functionId) await parse(`/classes/Function/${functionId}`, { method: 'DELETE' }).catch(() => undefined);
+  if (userId) {
+    const where = encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }));
+    const sessions = await parse(`/classes/_Session?where=${where}&limit=1000&keys=objectId`).catch(() => ({ results: [] }));
+    for (const session of sessions.results || []) await parse(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined);
+    await parse(`/users/${userId}`, { method: 'DELETE' }).catch(() => undefined);
+  }
+}

+ 171 - 0
scripts/ensure-app-member-schemas.mjs

@@ -0,0 +1,171 @@
+#!/usr/bin/env node
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const headers = {
+  'X-Parse-Application-Id': APP_ID,
+  'X-Parse-Master-Key': MASTER_KEY,
+  'Content-Type': 'application/json',
+};
+
+const pointer = (targetClass) => ({ type: 'Pointer', targetClass });
+const lockedPermissions = {
+  find: {}, count: {}, get: {}, create: {}, update: {}, delete: {}, addField: {},
+};
+
+const shared = {
+  company: pointer('Company'),
+  user: pointer('_User'),
+  legacyUserId: { type: 'Number' },
+  isDeleted: { type: 'Boolean' },
+  sourceKey: { type: 'String' },
+};
+
+const schemas = {
+  ReceivingAddress: {
+    ...shared,
+    receiverName: { type: 'String' }, mobile: { type: 'String' },
+    provinceName: { type: 'String' }, cityName: { type: 'String' }, countyName: { type: 'String' },
+    detailInfo: { type: 'String' }, postalCode: { type: 'String' }, isDefault: { type: 'Boolean' },
+  },
+  InvoiceProfile: {
+    ...shared,
+    invoiceType: { type: 'Number' }, title: { type: 'String' }, taxId: { type: 'String' },
+    mobile: { type: 'String' }, email: { type: 'String' }, address: { type: 'String' },
+    bankName: { type: 'String' }, bankAccount: { type: 'String' },
+  },
+  UserBankAccount: {
+    ...shared,
+    cardType: { type: 'String' }, bankName: { type: 'String' }, accountName: { type: 'String' },
+    accountNumber: { type: 'String' }, remark: { type: 'String' },
+  },
+  UserFavorite: {
+    ...shared,
+    targetId: { type: 'String' }, targetType: { type: 'Number' }, title: { type: 'String' }, url: { type: 'String' },
+  },
+  UnitLearningRecord: {
+    ...shared,
+    bookId: { type: 'Number' }, nodeId: { type: 'Number' }, wordIds: { type: 'Array' }, learnedCount: { type: 'Number' },
+  },
+  ServiceRequest: {
+    ...shared,
+    pubId: { type: 'Number' }, category: { type: 'String' }, title: { type: 'String' },
+    content: { type: 'String' }, contact: { type: 'String' }, payload: { type: 'Object' },
+    status: { type: 'String' }, reviewedAt: { type: 'Date' }, handledBy: pointer('_User'),
+  },
+  CommerceProduct: {
+    company: pointer('Company'), owner: pointer('_User'), legacyOwnerId: { type: 'Number' },
+    productId: { type: 'Number' }, name: { type: 'String' }, nodeId: { type: 'Number' },
+    retailPrice: { type: 'Number' }, marketPrice: { type: 'Number' }, unit: { type: 'String' },
+    stock: { type: 'Number' }, thumbnail: { type: 'String' }, gallery: { type: 'Array' },
+    summary: { type: 'String' }, content: { type: 'String' }, status: { type: 'String' },
+    sales: { type: 'Number' }, hot: { type: 'Boolean' }, isDeleted: { type: 'Boolean' }, sourceKey: { type: 'String' },
+  },
+  ShoppingCartItem: {
+    ...shared,
+    product: pointer('CommerceProduct'), productId: { type: 'Number' }, quantity: { type: 'Number' }, selected: { type: 'Boolean' },
+  },
+  ProductStockLedger: {
+    company: pointer('Company'), user: pointer('_User'), legacyUserId: { type: 'Number' },
+    product: pointer('CommerceProduct'), productId: { type: 'Number' }, delta: { type: 'Number' },
+    stockBefore: { type: 'Number' }, stockAfter: { type: 'Number' }, remark: { type: 'String' }, sourceKey: { type: 'String' },
+  },
+  ProductComment: {
+    ...shared,
+    product: pointer('CommerceProduct'), productId: { type: 'Number' }, orderId: { type: 'String' },
+    title: { type: 'String' }, score: { type: 'Number' }, content: { type: 'String' }, images: { type: 'Array' },
+    anonymous: { type: 'Boolean' }, status: { type: 'String' },
+  },
+  CommerceOrder: {
+    company: pointer('Company'), user: pointer('_User'), buyerLegacyId: { type: 'Number' },
+    orderId: { type: 'Number' }, orderNo: { type: 'String' }, status: { type: 'String' },
+    subtotal: { type: 'Number' }, discount: { type: 'Number' }, shipping: { type: 'Number' }, total: { type: 'Number' },
+    receiver: { type: 'Object' }, invoice: { type: 'Object' }, couponId: { type: 'String' }, paymentMode: { type: 'String' },
+    paidAt: { type: 'Date' }, shippedAt: { type: 'Date' }, signedAt: { type: 'Date' },
+    expressCompany: { type: 'String' }, expressNo: { type: 'String' }, expressRemark: { type: 'String' },
+    isDeleted: { type: 'Boolean' }, sourceKey: { type: 'String' },
+  },
+  CommerceOrderItem: {
+    company: pointer('Company'), order: pointer('CommerceOrder'), orderId: { type: 'Number' },
+    product: pointer('CommerceProduct'), productId: { type: 'Number' }, seller: pointer('_User'), sellerLegacyId: { type: 'Number' },
+    name: { type: 'String' }, unitPrice: { type: 'Number' }, quantity: { type: 'Number' }, amount: { type: 'Number' },
+    thumbnail: { type: 'String' }, productSnapshot: { type: 'Object' }, sourceKey: { type: 'String' },
+  },
+  CommercePayment: {
+    company: pointer('Company'), user: pointer('_User'), legacyUserId: { type: 'Number' },
+    order: pointer('CommerceOrder'), orderId: { type: 'Number' }, amount: { type: 'Number' }, mode: { type: 'String' },
+    status: { type: 'String' }, transactionNo: { type: 'String' }, paidAt: { type: 'Date' }, sourceKey: { type: 'String' },
+  },
+  CouponDefinition: {
+    company: pointer('Company'), couponId: { type: 'Number' }, name: { type: 'String' }, amount: { type: 'Number' },
+    minAmount: { type: 'Number' }, maxAmount: { type: 'Number' }, startsAt: { type: 'Date' }, expiresAt: { type: 'Date' },
+    status: { type: 'String' }, totalQuantity: { type: 'Number' }, claimedQuantity: { type: 'Number' }, sourceKey: { type: 'String' },
+  },
+  UserCoupon: {
+    ...shared,
+    coupon: pointer('CouponDefinition'), couponId: { type: 'Number' }, status: { type: 'String' },
+    usedAt: { type: 'Date' }, expiresAt: { type: 'Date' },
+  },
+  MembershipStockLedger: {
+    company: pointer('Company'), operator: pointer('_User'), operatorLegacyId: { type: 'Number' },
+    target: pointer('_User'), targetLegacyId: { type: 'Number' }, type: { type: 'String' },
+    groupBefore: { type: 'Number' }, groupAfter: { type: 'Number' }, quantity: { type: 'Number' },
+    operatorStockBefore: { type: 'Number' }, operatorStockAfter: { type: 'Number' },
+    targetStockBefore: { type: 'Number' }, targetStockAfter: { type: 'Number' },
+    remark: { type: 'String' }, sourceKey: { type: 'String' },
+  },
+  AppAuditLog: {
+    company: pointer('Company'), user: pointer('_User'), legacyUserId: { type: 'Number' },
+    action: { type: 'String' }, targetClass: { type: 'String' }, targetId: { type: 'String' },
+    before: { type: 'Object' }, after: { type: 'Object' }, ip: { type: 'String' }, sourceKey: { type: 'String' },
+  },
+  LegacySyncCheckpoint: {
+    company: pointer('Company'), sourceKey: { type: 'String' }, dataset: { type: 'String' },
+    cursor: { type: 'String' }, state: { type: 'String' }, processedCount: { type: 'Number' },
+    lastBatchCount: { type: 'Number' }, lastFailureCount: { type: 'Number' },
+    lastAttemptAt: { type: 'Date' }, lastSuccessAt: { type: 'Date' },
+    leaseToken: { type: 'String' }, leaseUntil: { type: 'Date' },
+    lastServerTime: { type: 'Date' }, lastWatermark: { type: 'Number' },
+  },
+  LegacySyncConflict: {
+    company: pointer('Company'), sourceKey: { type: 'String' }, dataset: { type: 'String' },
+    legacyKey: { type: 'String' }, conflictType: { type: 'String' }, message: { type: 'String' },
+    resolved: { type: 'Boolean' }, resolvedAt: { type: 'Date' }, resolution: { type: 'String' },
+  },
+  LegacySyncDeadLetter: {
+    company: pointer('Company'), sourceKey: { type: 'String' }, dataset: { type: 'String' },
+    legacyKey: { type: 'String' }, recordClasses: { type: 'Array' }, errorCode: { type: 'Number' },
+    errorMessage: { type: 'String' }, attempts: { type: 'Number' }, resolved: { type: 'Boolean' },
+    lastAttemptAt: { type: 'Date' }, resolvedAt: { type: 'Date' },
+  },
+};
+
+async function request(path, init = {}) {
+  const response = await fetch(`${PARSE_URL}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.error) throw new Error(`${path}: ${payload.error || response.status}`);
+  return payload;
+}
+
+for (const [className, fields] of Object.entries(schemas)) {
+  const existingResponse = await fetch(`${PARSE_URL}/schemas/${className}`, { headers });
+  const existing = await existingResponse.json().catch(() => ({}));
+  if (existingResponse.status === 404 || (!existingResponse.ok && /does not exist/i.test(String(existing.error || '')))) {
+    await request('/schemas/' + className, {
+      method: 'POST',
+      body: JSON.stringify({ className, fields, classLevelPermissions: lockedPermissions }),
+    });
+    console.log(`created ${className}`);
+    continue;
+  }
+  if (!existingResponse.ok || existing.error) throw new Error(`${className}: ${existing.error || existingResponse.status}`);
+  const additions = Object.fromEntries(Object.entries(fields).filter(([name]) => !existing.fields?.[name]));
+  if (Object.keys(additions).length) {
+    await request('/schemas/' + className, { method: 'PUT', body: JSON.stringify({ fields: additions }) });
+    console.log(`updated ${className}: ${Object.keys(additions).join(', ')}`);
+  } else console.log(`current ${className}`);
+}

+ 88 - 0
scripts/ensure-app-performance-indexes.mjs

@@ -0,0 +1,88 @@
+#!/usr/bin/env node
+
+import { randomBytes } from 'node:crypto';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const FUNCTION_URL = PARSE_URL.replace(/\/parse$/, '/api/functions');
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const headers = { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Master-Key': MASTER_KEY, 'Content-Type': 'application/json' };
+
+async function parse(path, init = {}) {
+  const response = await fetch(`${PARSE_URL}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.error) throw new Error(typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error || { status: response.status }));
+  return payload;
+}
+
+const company = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0];
+if (!company?.objectId) throw new Error('生产 Parse 未找到 Company');
+
+const suffix = `${Date.now()}_${randomBytes(4).toString('hex')}`;
+const username = `index_maintainer_${suffix}`;
+const password = `${randomBytes(24).toString('base64url')}Aa9!`;
+const path = `xiaoshu/system/ensure-app-indexes-${suffix}`;
+let userId = '';
+let functionId = '';
+
+const statements = [
+  'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_item" ON "CommonModel" ("company","modelId","itemId")',
+  'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_general" ON "CommonModel" ("company","modelId","generalId")',
+  'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_node" ON "CommonModel" ("company","modelId","nodeId")',
+  'CREATE INDEX IF NOT EXISTS "idx_daily_company_user_date" ON "DailyStudyRecord" ("company","userId","dqrq")',
+  'CREATE INDEX IF NOT EXISTS "idx_daily_company_id" ON "DailyStudyRecord" ("company","id")',
+  'CREATE INDEX IF NOT EXISTS "idx_appt_company_student_time" ON "CourseAppointment" ("company","szyh","yysj")',
+  'CREATE INDEX IF NOT EXISTS "idx_appt_company_coach_time" ON "CourseAppointment" ("company","pl","yysj")',
+  'CREATE INDEX IF NOT EXISTS "idx_appt_company_id" ON "CourseAppointment" ("company","id")',
+  'CREATE INDEX IF NOT EXISTS "idx_binding_company_user_id" ON "CourseBinding" ("company","yhid","id")',
+  'CREATE INDEX IF NOT EXISTS "idx_memory_company_user_time" ON "MemoryPracticeRecord" ("company","yhid","kywsj")',
+  'CREATE INDEX IF NOT EXISTS "idx_memory_company_coach_status" ON "MemoryPracticeRecord" ("company","plid","fxzt")',
+  'CREATE INDEX IF NOT EXISTS "idx_memory_company_id" ON "MemoryPracticeRecord" ("company","id")',
+  'CREATE INDEX IF NOT EXISTS "idx_lesson_company_coach_time" ON "LessonRecord" ("company","jsmz","lessonAt")',
+  'CREATE INDEX IF NOT EXISTS "idx_node_company_node" ON "Node" ("company","nodeId")',
+];
+const indexNames = statements.map((statement) => statement.match(/"([^"]+)"/)?.[1] || '');
+
+const code = String.raw`
+async function handler(request, response) {
+  try {
+    const current = request.user || (typeof user !== 'undefined' ? user : null);
+    if (!current) return response.status(401).json({ success:false, message:'需要超级管理员会话' });
+    await current.fetch({ useMasterKey:true });
+    const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
+    if (current.get('adminRoleKey') !== 'super-admin' && !roles.includes('super-admin')) return response.status(403).json({ success:false, message:'仅超级管理员可执行' });
+    const statements = ${JSON.stringify(statements)};
+    for (const statement of statements) await Psql.none(statement);
+    const indexes = await Psql.query('SELECT indexname FROM pg_indexes WHERE schemaname=current_schema() AND indexname=ANY($1::text[]) ORDER BY indexname', [${JSON.stringify(indexNames)}]);
+    response.json({ success:true, data:{ applied:statements.length, indexes:indexes.map((row) => row.indexname) } });
+  } catch (error) {
+    response.status(Number(error.status)||500).json({ success:false, message:String(error.message||error) });
+  }
+}`;
+
+try {
+  const createdUser = await parse('/users', {
+    method: 'POST',
+    body: JSON.stringify({ username, password, isAdmin: true, role: 'admin', roles: ['admin', 'super-admin'], adminRoleKey: 'super-admin', company: { __type: 'Pointer', className: 'Company', objectId: company.objectId }, realName: '应用查询索引临时管理员', testCreatedBy: 'ensure-app-performance-indexes' }),
+  });
+  userId = createdUser.objectId;
+  let token = createdUser.sessionToken || '';
+  if (!token) token = (await parse('/login', { method: 'POST', body: JSON.stringify({ username, password }) })).sessionToken;
+  if (!token) throw new Error('临时超级管理员没有会话');
+  const createdFunction = await parse('/classes/Function', { method: 'POST', body: JSON.stringify({ name: path, desc: '一次性补齐用户端高频查询索引', type: 'standalone', path, code, params: [], paramList: [], respType: 'json', respJson: { success: true }, enabled: true }) });
+  functionId = createdFunction.objectId;
+  const response = await fetch(`${FUNCTION_URL}/${path}`, { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, params: {} }) });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.success !== true) throw new Error(payload.message || payload.error || `索引维护失败:${response.status}`);
+  console.log(JSON.stringify({ ensured: true, applied: payload.data?.applied || 0, indexes: payload.data?.indexes || [] }, null, 2));
+} finally {
+  if (functionId) await parse(`/classes/Function/${functionId}`, { method: 'DELETE' }).catch(() => undefined);
+  if (userId) {
+    const where = encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }));
+    const sessions = await parse(`/classes/_Session?where=${where}&limit=1000&keys=objectId`).catch(() => ({ results: [] }));
+    for (const session of sessions.results || []) await parse(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined);
+    await parse(`/users/${userId}`, { method: 'DELETE' }).catch(() => undefined);
+  }
+}

+ 134 - 0
scripts/repair-core-physical-schema.mjs

@@ -0,0 +1,134 @@
+#!/usr/bin/env node
+
+import { randomBytes } from 'node:crypto';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const FUNCTION_URL = PARSE_URL.replace(/\/parse$/, '/api/functions');
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const headers = {
+  'X-Parse-Application-Id': APP_ID,
+  'X-Parse-Master-Key': MASTER_KEY,
+  'Content-Type': 'application/json',
+};
+
+async function parse(path, init = {}) {
+  const response = await fetch(`${PARSE_URL}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.error) throw new Error(typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error || { status: response.status }));
+  return payload;
+}
+
+const company = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0];
+if (!company?.objectId) throw new Error('生产 Parse 未找到 Company');
+
+const suffix = `${Date.now()}_${randomBytes(4).toString('hex')}`;
+const username = `schema_repair_${suffix}`;
+const password = `${randomBytes(24).toString('base64url')}Aa9!`;
+const path = `xiaoshu/system/repair-core-schema-${suffix}`;
+let userId = '';
+let functionId = '';
+
+const tables = ['PracticeRecord','CourseAppointment','DailyStudyRecord','CourseBinding','LessonRecord','MemoryPracticeRecord','AssessmentProfile'];
+const probeFields = {
+  PracticeRecord: ['id', 'sourceKey'],
+  CourseAppointment: ['id', 'sourceKey'],
+  DailyStudyRecord: ['id', 'sourceKey'],
+  CourseBinding: ['id', 'sourceKey'],
+  LessonRecord: ['id', 'sourceKey', 'legacyGeneralId', 'lessonAt', 'sourceCreatedAt', 'studentName', 'coachName', 'courseName', 'storeId'],
+  MemoryPracticeRecord: ['id', 'sourceKey'],
+  AssessmentProfile: ['id', 'sourceKey'],
+};
+const code = String.raw`
+async function handler(request, response) {
+  try {
+    const current = request.user || (typeof user !== 'undefined' ? user : null);
+    if (!current) return response.status(401).json({ success:false, message:'需要超级管理员会话' });
+    await current.fetch({ useMasterKey:true });
+    const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
+    if (current.get('adminRoleKey') !== 'super-admin' && !roles.includes('super-admin')) return response.status(403).json({ success:false, message:'仅超级管理员可执行' });
+    const statements = [
+      ${tables.map((table) => `'ALTER TABLE "${table}" ADD COLUMN IF NOT EXISTS "_rperm" TEXT[]'`).join(',\n      ')},
+      ${tables.map((table) => `'ALTER TABLE "${table}" ADD COLUMN IF NOT EXISTS "_wperm" TEXT[]'`).join(',\n      ')},
+      'ALTER TABLE "PracticeRecord" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
+      'ALTER TABLE "PracticeRecord" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT',
+      'ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
+      'ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT',
+      'ALTER TABLE "DailyStudyRecord" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
+      'ALTER TABLE "DailyStudyRecord" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT',
+      'ALTER TABLE "CourseBinding" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
+      'ALTER TABLE "CourseBinding" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT',
+      'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
+      'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT',
+      'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "legacyGeneralId" DOUBLE PRECISION',
+      'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "lessonAt" TIMESTAMPTZ',
+      'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "sourceCreatedAt" TIMESTAMPTZ',
+      'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "studentName" TEXT',
+      'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "coachName" TEXT',
+      'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "courseName" TEXT',
+      'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "storeId" DOUBLE PRECISION',
+      'ALTER TABLE "MemoryPracticeRecord" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
+      'ALTER TABLE "MemoryPracticeRecord" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT',
+      'ALTER TABLE "AssessmentProfile" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
+      'ALTER TABLE "AssessmentProfile" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT'
+    ];
+    for (const statement of statements) await Psql.none(statement);
+    const rows = await Psql.query(
+      'SELECT table_name AS "tableName", column_name AS "columnName", data_type AS "dataType" FROM information_schema.columns WHERE table_schema=current_schema() AND table_name = ANY($1::text[]) AND column_name = ANY($2::text[]) ORDER BY table_name,column_name',
+      [${JSON.stringify(tables)}, ['_rperm','_wperm','id','sourceKey','legacyGeneralId','lessonAt','sourceCreatedAt','studentName','coachName','courseName','storeId']]
+    );
+    response.json({ success:true, data:{ applied:statements.length, columns:rows } });
+  } catch (error) {
+    response.status(Number(error.status)||500).json({ success:false, message:String(error.message||error) });
+  }
+}`;
+
+try {
+  const createdUser = await parse('/users', {
+    method: 'POST',
+    body: JSON.stringify({
+      username, password, isAdmin: true, role: 'admin', roles: ['admin','super-admin'], adminRoleKey: 'super-admin',
+      company: { __type: 'Pointer', className: 'Company', objectId: company.objectId },
+      realName: '核心迁移物理Schema修复临时管理员', testCreatedBy: 'repair-core-physical-schema',
+    }),
+  });
+  userId = createdUser.objectId;
+  let token = createdUser.sessionToken || '';
+  if (!token) token = (await parse('/login', { method: 'POST', body: JSON.stringify({ username, password }) })).sessionToken;
+  if (!token) throw new Error('临时超级管理员没有会话');
+
+  const createdFunction = await parse('/classes/Function', {
+    method: 'POST',
+    body: JSON.stringify({
+      name: path, desc: '一次性修复小树核心业务表物理列', type: 'standalone', path, code,
+      params: [], paramList: [], respType: 'json', respJson: { success: true }, enabled: true,
+    }),
+  });
+  functionId = createdFunction.objectId;
+  const response = await fetch(`${FUNCTION_URL}/${path}`, {
+    method: 'POST',
+    headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ token, params: {} }),
+  });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.success !== true) throw new Error(payload.message || payload.error || `物理 Schema 修复失败:${response.status}`);
+  const verified = [];
+  for (const [className, fields] of Object.entries(probeFields)) {
+    const query = new URLSearchParams({ limit: '1', keys: ['objectId', ...fields].join(',') });
+    const probe = await fetch(`${PARSE_URL}/classes/${className}?${query}`, { headers });
+    const probePayload = await probe.json().catch(() => ({}));
+    if (!probe.ok || probePayload.error) throw new Error(`${className} 物理列修复后仍不可读取:${JSON.stringify(probePayload)}`);
+    verified.push(className);
+  }
+  console.log(JSON.stringify({ repaired: true, applied: payload.data?.applied || 0, columns: payload.data?.columns || [], verified }, null, 2));
+} finally {
+  if (functionId) await parse(`/classes/Function/${functionId}`, { method: 'DELETE' }).catch(() => undefined);
+  if (userId) {
+    const where = encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }));
+    const sessions = await parse(`/classes/_Session?where=${where}&limit=1000&keys=objectId`).catch(() => ({ results: [] }));
+    for (const session of sessions.results || []) await parse(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined);
+    await parse(`/users/${userId}`, { method: 'DELETE' }).catch(() => undefined);
+  }
+}

Plik diff jest za duży
+ 3 - 3
scripts/smoke-admin-functions.mjs


+ 117 - 0
scripts/smoke-app-auth-learning.mjs

@@ -0,0 +1,117 @@
+#!/usr/bin/env node
+
+import { randomBytes } from 'node:crypto';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const FUNCTION_URL = (process.env.XIAOSHU_FUNCTION_URL || `${new URL(PARSE_URL).origin}/api/functions`).replace(/\/$/, '');
+const COMPANY_ID = '7pIbDBJmKx';
+
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const appHeaders = { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' };
+const masterHeaders = { ...appHeaders, 'X-Parse-Master-Key': MASTER_KEY };
+const company = { __type: 'Pointer', className: 'Company', objectId: COMPANY_ID };
+const userIds = [];
+
+async function request(url, init = {}, expected = 200) {
+  const response = await fetch(url, init);
+  const payload = await response.json().catch(() => ({}));
+  if (response.status !== expected) throw new Error(`${url} 期望 ${expected},实际 ${response.status}: ${payload.retmsg || payload.error || ''}`);
+  return payload;
+}
+
+async function master(path, init = {}, expected = 200) {
+  return request(`${PARSE_URL}${path}`, { ...init, headers: { ...masterHeaders, ...(init.headers || {}) } }, expected);
+}
+
+async function cloud(token, action, params = {}, expected = 200) {
+  return request(`${FUNCTION_URL}/xiaoshu/app/gateway`, {
+    method: 'POST', headers: appHeaders, body: JSON.stringify({ ...(token ? { token } : {}), params: { action, ...params } }),
+  }, expected);
+}
+
+async function first(className, where, keys) {
+  const query = new URLSearchParams({ where: JSON.stringify(where), limit: '1', keys });
+  return (await master(`/classes/${className}?${query}`)).results?.[0] || null;
+}
+
+try {
+  const memory = await first('MemoryPracticeRecord', { company, yhid: { $gt: 0 }, plid: { $gt: 0 } }, 'yhid,plid');
+  const study = await first('DailyStudyRecord', { company, userId: { $gt: 0 } }, 'userId');
+  const studyCommon = await first('CommonModel', { company, modelId: 56 }, 'generalId');
+  const orderCommon = await first('CommonModel', { company, modelId: 54 }, 'generalId');
+  const practice = await first('PracticeRecord', { company, yhid: { $gt: 0 } }, 'yhid');
+  if (!memory?.yhid || !study?.userId || !studyCommon?.generalId || !orderCommon?.generalId || !practice?.yhid) throw new Error('生产学习数据样本不完整');
+
+  const username = `auth_learning_smoke_${Date.now()}`;
+  const mobile = `139${String(Date.now()).slice(-8)}`;
+  const password = `${randomBytes(24).toString('base64url')}Aa9!`;
+  const created = await master('/users', {
+    method: 'POST',
+    body: JSON.stringify({ username, password, mobile, company, isAdmin: true, role: 'admin', roles: ['admin'], legacyUserId: 990000001, legacyGroupId: 1, testCreatedBy: 'smoke-app-auth-learning' }),
+  }, 201);
+  userIds.push(created.objectId);
+  const login = await cloud('', 'user_login_passwd', { name: mobile, passwd: password });
+  const sessionToken = String(login.result?.sessionToken || '');
+  if (!sessionToken.startsWith('r:')) throw new Error('手机号映射登录没有返回有效 Parse 会话');
+
+  await cloud('', 'e_get_21list', { uid: memory.yhid, cpage: 1, psize: 1 }, 401);
+  const memoryList = await cloud(sessionToken, 'e_get_21list', { uid: memory.yhid, cpage: 1, psize: 20 });
+  if (!Array.isArray(memoryList.result) || memoryList.result.length < 1 || Number(memoryList.page?.itemCount) < 1) throw new Error('抗遗忘记录读取为空');
+
+  const studyList = await cloud(sessionToken, 'content_list', { modelId: 56, myfield2: `UserId=${study.userId}`, cpage: 1, psize: 20 });
+  if (!Array.isArray(studyList.result) || studyList.result.length < 1 || Number(studyList.page?.itemCount) < 1) throw new Error('学习记录列表读取为空');
+  const studyDetail = await cloud(sessionToken, 'e_record_detail', { id: studyCommon.generalId });
+  if (!Array.isArray(studyDetail.result)) throw new Error('学习记录详情响应格式错误');
+  const orderDetail = await cloud(sessionToken, 'e_order_detail', { id: orderCommon.generalId });
+  if (!Array.isArray(orderDetail.result) || !orderDetail.result.length) throw new Error('预约详情读取为空');
+  const newWords = await cloud(sessionToken, 'e_words_list', { uid: practice.yhid, cpage: 1, psize: 20 });
+  if (!Array.isArray(newWords.result)) throw new Error('生词本响应格式错误');
+
+  const overviewStartedAt = performance.now();
+  const learningOverview = await cloud(sessionToken, 'app_learning_overview', { uid: study.userId, studentId: study.userId, date: '' });
+  const learningOverviewMs = Math.round(performance.now() - overviewStartedAt);
+  if (!Array.isArray(learningOverview.result?.records) || !learningOverview.result.records.length) throw new Error('学习记录轻量总览为空');
+  if (!learningOverview.result?.recordDateCounts || !Array.isArray(learningOverview.result?.courses) || !Array.isArray(learningOverview.result?.schedules)) throw new Error('学习记录轻量总览结构错误');
+
+  const companionStartedAt = performance.now();
+  const companionOverview = await cloud(sessionToken, 'app_companion_overview', { uid: study.userId, mode: 'student', history: false, limit: 500 });
+  const companionOverviewMs = Math.round(performance.now() - companionStartedAt);
+  if (!Array.isArray(companionOverview.result?.courses) || !Array.isArray(companionOverview.result?.schedules)) throw new Error('伴学轻量总览结构错误');
+
+  const accountStartedAt = performance.now();
+  const accountOverview = await cloud(sessionToken, 'app_account_overview', { uid: memory.yhid });
+  const accountOverviewMs = Math.round(performance.now() - accountStartedAt);
+  if (!accountOverview.result?.profile || !accountOverview.result?.antiForgetSummary || !accountOverview.result?.companionSummary) throw new Error('我的页面轻量总览结构错误');
+
+  const coachUsername = `coach_learning_smoke_${Date.now()}`;
+  const coachPassword = `${randomBytes(24).toString('base64url')}Bb8!`;
+  const coach = await master('/users', {
+    method: 'POST',
+    body: JSON.stringify({ username: coachUsername, password: coachPassword, company, legacyUserId: Number(memory.plid), legacyGroupId: 3, testCreatedBy: 'smoke-app-auth-learning' }),
+  }, 201);
+  userIds.push(coach.objectId);
+  const coachLogin = await cloud('', 'user_login_passwd', { name: coachUsername, passwd: coachPassword });
+  const coachToken = String(coachLogin.result?.sessionToken || '');
+  const coachMemory = await cloud(coachToken, 'content_list', { modelId: 60, myfield2: `plid=${memory.plid}`, cpage: 1, psize: 20 });
+  if (!Array.isArray(coachMemory.result) || coachMemory.result.length < 1) throw new Error('陪练本人抗遗忘任务读取为空');
+  const coachCompanionStartedAt = performance.now();
+  const coachCompanionOverview = await cloud(coachToken, 'app_companion_overview', { uid: memory.plid, mode: 'coach', history: false, limit: 500 });
+  const coachCompanionOverviewMs = Math.round(performance.now() - coachCompanionStartedAt);
+  if (!Array.isArray(coachCompanionOverview.result?.schedules)) throw new Error('陪练排课轻量总览结构错误');
+  const coachAccountStartedAt = performance.now();
+  const coachAccountOverview = await cloud(coachToken, 'app_account_overview', { uid: memory.plid });
+  const coachAccountOverviewMs = Math.round(performance.now() - coachAccountStartedAt);
+  if (!coachAccountOverview.result?.profile || !coachAccountOverview.result?.coachPanel) throw new Error('陪练我的页面轻量总览结构错误');
+
+  console.log(JSON.stringify({ passed: true, parseSession: true, privateSessionRequired: true, coachOwnership: true, memoryRecords: memoryList.page?.itemCount, studyList: studyList.page?.itemCount, studyDetail: true, orderDetail: true, newWords: newWords.page?.itemCount, overviewLatencyMs: { learning: learningOverviewMs, companion: companionOverviewMs, account: accountOverviewMs, coachCompanion: coachCompanionOverviewMs, coachAccount: coachAccountOverviewMs } }, null, 2));
+} finally {
+  for (const userId of userIds) {
+    const pointer = { __type: 'Pointer', className: '_User', objectId: userId };
+    const sessions = await master(`/classes/_Session?where=${encodeURIComponent(JSON.stringify({ user: pointer }))}&limit=1000&keys=objectId`).catch(() => ({ results: [] }));
+    for (const session of sessions.results || []) await master(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined);
+    await master(`/users/${userId}`, { method: 'DELETE' }).catch(() => undefined);
+  }
+}

+ 350 - 0
scripts/smoke-app-member-resources.mjs

@@ -0,0 +1,350 @@
+#!/usr/bin/env node
+
+import { randomBytes } from 'node:crypto';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const FUNCTION_URL = (process.env.XIAOSHU_FUNCTION_URL || 'https://server.xiaoshu.pro/api/functions').replace(/\/$/, '');
+const COMPANY_ID = '7pIbDBJmKx';
+
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const appHeaders = { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' };
+const masterHeaders = { ...appHeaders, 'X-Parse-Master-Key': MASTER_KEY };
+const username = `codex_member_resource_${Date.now()}`;
+const password = randomBytes(24).toString('base64url');
+const legacyUserId = 970000000 + Math.floor(Date.now() / 1000) % 20000000;
+let userObjectId = '';
+let membershipTargetUserId = '';
+let commerceProductId = 0;
+const created = [];
+
+async function request(url, init = {}, expected = 200) {
+  const response = await fetch(url, init);
+  const payload = await response.json().catch(() => ({}));
+  if (response.status !== expected) throw new Error(`${url} 期望 ${expected},实际 ${response.status}: ${payload.retmsg || payload.error || ''}`);
+  return payload;
+}
+
+async function master(path, init = {}, expected = 200) {
+  return request(`${PARSE_URL}${path}`, { ...init, headers: { ...masterHeaders, ...(init.headers || {}) } }, expected);
+}
+
+async function cloud(token, action, params = {}, expected = 200) {
+  return request(`${FUNCTION_URL}/xiaoshu/app/gateway`, {
+    method: 'POST', headers: appHeaders, body: JSON.stringify({ token, params: { action, uid: legacyUserId, ...params } }),
+  }, expected);
+}
+
+async function hardDelete(className, objectId) {
+  if (!objectId) return;
+  await master(`/classes/${className}/${objectId}`, { method: 'DELETE' }).catch(() => undefined);
+}
+
+try {
+  const company = { __type: 'Pointer', className: 'Company', objectId: COMPANY_ID };
+  const createdUser = await master('/users', {
+    method: 'POST',
+    body: JSON.stringify({ username, password, company, legacyUserId, legacyGroupId: 1, nickname: '会员资料云函数冒烟', legacyUserData: { UserID: legacyUserId, GroupID: 1, HoneyName: '会员资料云函数冒烟' } }),
+  }, 201);
+  userObjectId = createdUser.objectId;
+  const login = await request(`${PARSE_URL}/login`, { method: 'POST', headers: appHeaders, body: JSON.stringify({ username, password }) });
+  const token = login.sessionToken;
+  await master(`/users/${userObjectId}`, {
+    method: 'PUT',
+    body: JSON.stringify({ legacyUserData: { UserID: legacyUserId, GroupID: 1, HoneyName: '会员资料云函数冒烟', SilverCoin: 200 } }),
+  });
+
+  const completedReviewStats = await cloud(token, 'e_get_21list_tj');
+  if (!Array.isArray(completedReviewStats.result) || Number(completedReviewStats.result[0]?.num) !== 0) throw new Error('本人已完成复习任务统计异常');
+
+  const address = await cloud(token, 'receaddr_add', { provinceName: '江西省', cityName: '南昌市', countyName: '青山湖区', userName: '测试收货人', telNumber: '13800138000', detailInfo: '测试路 1 号', postalCode: '330000' });
+  const addressId = String(address.result?.ID || ''); created.push(['ReceivingAddress', addressId]);
+  if (!addressId || address.result?.ReceivName !== '测试收货人') throw new Error('收货地址新增响应异常');
+  const addressList = await cloud(token, 'receaddr_list', { cpage: 1, psize: 20 });
+  if (addressList.page?.itemCount !== 1 || addressList.result?.[0]?.ID !== addressId) throw new Error('收货地址列表异常');
+  const addressUpdated = await cloud(token, 'receaddr_upd', { id: addressId, provinceName: '江西省', cityName: '南昌市', countyName: '青山湖区', userName: '更新收货人', telNumber: '13800138000', detailInfo: '测试路 2 号', postalCode: '330000' });
+  if (addressUpdated.result?.ReceivName !== '更新收货人') throw new Error('收货地址更新异常');
+  const addressGet = await cloud(token, 'receaddr_get', { id: addressId });
+  if (addressGet.result?.Street !== '测试路 2 号') throw new Error('收货地址详情异常');
+
+  const invoice = await cloud(token, 'invoice_add', { aud: 1, name: '测试企业', idnum: '91360100TEST000001', mobile: '13800138000', email: 'billing@example.test', address: '测试注册地址', bankname: '测试银行', bankusr: '6222020000000000000' });
+  const invoiceId = String(invoice.result?.ID || ''); created.push(['InvoiceProfile', invoiceId]);
+  if (!invoiceId || invoice.result?.InvHead !== '测试企业') throw new Error('发票资料新增响应异常');
+  const invoiceUpdated = await cloud(token, 'invoice_upd', { id: invoiceId, aud: 1, name: '更新测试企业', idnum: '91360100TEST000001', mobile: '13800138000', email: 'billing@example.test', address: '测试注册地址', bankname: '测试银行', bankusr: '6222020000000000000' });
+  if (invoiceUpdated.result?.InvHead !== '更新测试企业') throw new Error('发票资料更新异常');
+  const invoiceList = await cloud(token, 'invoice_list', { cpage: 1, psize: 20 });
+  if (invoiceList.page?.itemCount !== 1 || invoiceList.result?.[0]?.ID !== invoiceId) throw new Error('发票资料列表异常');
+
+  const bank = await cloud(token, 'user_bank_add', { CardType: '银行卡', BankName: '测试银行', PeopleName: '测试开户人', CardNum: '6222020000000000000', Remark: '首次创建' });
+  const bankId = String(bank.result?.ID || ''); created.push(['UserBankAccount', bankId]);
+  if (!bankId || bank.result?.PeopleName !== '测试开户人') throw new Error('提现账户新增响应异常');
+  const bankUpdated = await cloud(token, 'user_bank_add', { id: bankId, CardType: '银行卡', BankName: '测试银行', PeopleName: '更新开户人', CardNum: '6222020000000000000', Remark: '已更新' });
+  if (bankUpdated.result?.PeopleName !== '更新开户人') throw new Error('提现账户更新异常');
+  const bankGet = await cloud(token, 'user_bank_get', { id: bankId });
+  if (bankGet.result?.Remark !== '已更新') throw new Error('提现账户详情异常');
+  const bankList = await cloud(token, 'user_bank_list', { cpage: 1, psize: 20 });
+  if (bankList.page?.itemCount !== 1 || bankList.result?.[0]?.ID !== bankId) throw new Error('提现账户列表异常');
+
+  const ask = await cloud('', 'vote_ask', { id: 1 });
+  const questions = await cloud('', 'vote_question', { askid: 1 });
+  if (ask.result?.[0]?.Title !== '第一组问题' || Number(questions.result?.length) !== 48 || !questions.result?.[0]?.QOption) throw new Error('测评问卷或题目读取异常');
+  const examStamp = `2099-01-01 00:${String(Date.now() % 60).padStart(2, '0')}:${String(Date.now() % 60).padStart(2, '0')}`;
+  const examAnswers = questions.result.slice(0, 2).map((item) => ({ qid: item.ID, qtype: item.QType, answer: '', result: 2 }));
+  const answer = await cloud(token, 'vote_add', { mu: JSON.stringify({ AskID: 1, Answer: JSON.stringify(examAnswers), UserID: legacyUserId, CDate: examStamp }) });
+  if (!Number(answer.result)) throw new Error('测评答题记录新增异常');
+  const answerSourceKey = `app:vote:${legacyUserId}:1:${examStamp}`;
+  const answerRows = await master(`/classes/DesignAnswer?where=${encodeURIComponent(JSON.stringify({ sourceKey: answerSourceKey }))}&limit=2`);
+  if (answerRows.results?.length !== 1 || Number(answerRows.results[0].id) !== Number(answer.result)) throw new Error('测评答题记录写后读或幂等键异常');
+  created.push(['DesignAnswer', answerRows.results[0].objectId]);
+  const repeatedAnswer = await cloud(token, 'vote_add', { mu: JSON.stringify({ AskID: 1, Answer: JSON.stringify(examAnswers), UserID: legacyUserId, CDate: examStamp }) });
+  if (Number(repeatedAnswer.result) !== Number(answer.result)) throw new Error('测评答题记录重复提交未保持幂等');
+
+  const unit = await cloud(token, 'unit_record_update', { userId: legacyUserId, bookId: 16, nids: '40', wordIds: JSON.stringify({ 40: '2505,2506' }) });
+  if (unit.result?.updated !== 1 || unit.result?.items?.[0]?.learnedCount !== 2) throw new Error('单元学习进度保存异常');
+  created.push(['UnitLearningRecord', unit.result.items[0].objectId]);
+  const unitRepeat = await cloud(token, 'unit_record_update', { userId: legacyUserId, bookId: 16, nids: '40', wordIds: JSON.stringify({ 40: '2505' }) });
+  if (unitRepeat.result?.items?.[0]?.objectId !== unit.result.items[0].objectId || unitRepeat.result?.items?.[0]?.learnedCount !== 1) throw new Error('单元学习进度幂等更新异常');
+
+  const favoriteMissing = await cloud(token, 'user_star_is', { id: 'future-product-1', type: 2 });
+  if (String(favoriteMissing.result) !== '0') throw new Error('收藏初始状态异常');
+  await cloud(token, 'user_star_add', { id: 'future-product-1', type: 2, title: '未来商品', url: '/Shop/future-product-1' });
+  const favoriteAdded = await cloud(token, 'user_star_is', { id: 'future-product-1', type: 2 });
+  if (String(favoriteAdded.result) !== '1') throw new Error('收藏新增后状态异常');
+  const favoriteRows = await master(`/classes/UserFavorite?where=${encodeURIComponent(JSON.stringify({ sourceKey: `app:favorite:${legacyUserId}:2:future-product-1` }))}&limit=2`);
+  if (favoriteRows.results?.length !== 1) throw new Error('收藏关系未写入规范类');
+  created.push(['UserFavorite', favoriteRows.results[0].objectId]);
+  await cloud(token, 'user_star_del', { id: 'future-product-1', type: 2 });
+  const favoriteDeleted = await cloud(token, 'user_star_is', { id: 'future-product-1', type: 2 });
+  if (String(favoriteDeleted.result) !== '0') throw new Error('取消收藏后状态异常');
+
+  const supplierRequest = await cloud(token, 'pub_add', { pubid: 8, model: JSON.stringify({ PubTitle: '冒烟供应商', PubContent: '申请入驻', BankAccount: 'TEST-ONLY', PubIsTrue: 1 }) });
+  const supplierRequestId = String(supplierRequest.result?.ID || '');
+  if (!supplierRequestId || supplierRequest.result?.Category !== 'supplier-application') throw new Error('供应商服务申请新增异常');
+  created.push(['ServiceRequest', supplierRequestId]);
+  const supplierRequests = await cloud(token, 'pub_list', { pubid: 8, cpage: 1, psize: 20 });
+  if (supplierRequests.page?.itemCount !== 1 || supplierRequests.result?.[0]?.ID !== supplierRequestId || supplierRequests.result?.[0]?.BankAccount !== 'TEST-ONLY') throw new Error('供应商服务申请列表异常');
+  const returnRequest = await cloud(token, 'pub_add', { pubid: 10, model: JSON.stringify({ PubTitle: '冒烟退货', PubContent: '商品异常', PubContentid: 'order-smoke', PubIsTrue: 1 }) });
+  const returnRequestId = String(returnRequest.result?.ID || '');
+  if (!returnRequestId) throw new Error('售后服务申请新增异常');
+  created.push(['ServiceRequest', returnRequestId]);
+  const returnRequests = await cloud(token, 'pub_list', { pubid: 10, cid: 'order-smoke', cpage: 1, psize: 20 });
+  if (returnRequests.page?.itemCount !== 1 || returnRequests.result?.[0]?.ID !== returnRequestId) throw new Error('售后服务申请按订单筛选异常');
+  await cloud(token, 'pub_add', { pubid: 6, model: JSON.stringify({ PubTitle: '无效申请' }) }, 400);
+
+  const productName = `冒烟商品-${Date.now()}`;
+  const product = await cloud(token, 'product_add', {
+    model: JSON.stringify({ Proname: productName, Nodeid: 7, LinPrice: 12.5, ShiPrice: 20, ProUnit: '件', Stock: 10, Thumbnails: 'https://example.test/product.png', Proinfo: '初始简介', Procontent: '<p>商品正文</p>' }),
+    addon: JSON.stringify({ pics: JSON.stringify([{ url: 'https://example.test/product-1.png' }]) }),
+  });
+  const productId = Number(product.result);
+  commerceProductId = productId;
+  if (!productId) throw new Error('商品新增未返回规范编号');
+  const productRows = await master(`/classes/CommerceProduct?where=${encodeURIComponent(JSON.stringify({ productId }))}&limit=2`);
+  if (productRows.results?.length !== 1) throw new Error('商品主记录未写入规范类');
+  const productObjectId = productRows.results[0].objectId;
+  created.push(['CommerceProduct', productObjectId]);
+  const draftProducts = await cloud(token, 'product_list', { uids: legacyUserId, mode: 'unaudit', skey: productName, cpage: 1, psize: 20 });
+  if (draftProducts.page?.itemCount !== 1 || Number(draftProducts.result?.[0]?.ID) !== productId) throw new Error('待审核商品列表异常');
+  const ownProduct = await cloud(token, 'product_get', { id: productId });
+  if (ownProduct.result?.[0]?.Proname !== productName || Number(ownProduct.result?.[0]?.Stock) !== 10) throw new Error('商品详情投影异常');
+  await cloud(token, 'product_upd', { id: productId, model: JSON.stringify({ ID: productId, Proinfo: '更新简介' }), addon: JSON.stringify({}) });
+  const updatedProduct = await cloud(token, 'product_get', { id: productId });
+  if (updatedProduct.result?.[0]?.Proinfo !== '更新简介') throw new Error('商品更新异常');
+  await master(`/classes/CommerceProduct/${productObjectId}`, { method: 'PUT', body: JSON.stringify({ status: 'published' }) });
+  const publicProducts = await cloud('', 'product_list', { uid: '', skey: productName, cpage: 1, psize: 20 });
+  if (publicProducts.page?.itemCount !== 1 || Number(publicProducts.result?.[0]?.ID) !== productId) throw new Error('已发布商品公共列表异常');
+  const publicProduct = await cloud('', 'product_get', { uid: '', id: productId });
+  if (publicProduct.result?.[0]?.Proname !== productName) throw new Error('已发布商品公共详情异常');
+
+  const stockIn = await cloud(token, 'product_stock_change', { proid: productId, num: 5, remark: '冒烟入库' });
+  if (Number(stockIn.result?.Stock) !== 15) throw new Error('商品入库原子更新异常');
+  const stockOut = await cloud(token, 'product_stock_change', { proid: productId, num: -2, remark: '冒烟出库' });
+  if (Number(stockOut.result?.Stock) !== 13) throw new Error('商品出库原子更新异常');
+  await cloud(token, 'product_stock_change', { proid: productId, num: -999, remark: '禁止负库存' }, 409);
+  const stockLogs = await cloud(token, 'product_stock_list', { proid: productId, cpage: 1, psize: 20 });
+  if (stockLogs.page?.itemCount !== 2 || !stockLogs.result?.some((item) => Number(item.StockNow) === 13)) throw new Error(`库存流水异常:${JSON.stringify(stockLogs)}`);
+  for (const row of (await master(`/classes/ProductStockLedger?where=${encodeURIComponent(JSON.stringify({ productId }))}&limit=100`)).results || []) created.push(['ProductStockLedger', row.objectId]);
+
+  await cloud(token, 'cart_add', { proID: productId, proNum: 1 });
+  await cloud(token, 'cart_add', { proID: productId, proNum: 2 });
+  const cart = await cloud(token, 'cart_list', { ids: '0' });
+  if (cart.result?.CartDT?.length !== 1 || Number(cart.result.CartDT[0]?.Pronum) !== 3 || Number(cart.result.TotalMoney) !== 37.5) throw new Error('购物车合并或金额投影异常');
+  const cartId = String(cart.result.CartDT[0]?.objectId || '');
+  if (!cartId) throw new Error('购物车记录 ID 缺失');
+  created.push(['ShoppingCartItem', cartId]);
+  const selectedCart = await cloud(token, 'cart_list', { ids: cartId });
+  if (selectedCart.result?.CartDT?.length !== 1) throw new Error('购物车按 ID 筛选异常');
+
+  const couponId = 910000000 + Math.floor(Date.now() / 1000) % 80000000;
+  const couponDefinition = await master('/classes/CouponDefinition', {
+    method: 'POST',
+    body: JSON.stringify({
+      company,
+      couponId,
+      name: '冒烟满减券',
+      amount: 5,
+      minAmount: 20,
+      maxAmount: 100,
+      status: 'active',
+      totalQuantity: 10,
+      claimedQuantity: 0,
+      sourceKey: `app:coupon-smoke:${couponId}`,
+    }),
+  }, 201);
+  created.push(['CouponDefinition', couponDefinition.objectId]);
+  const coupons = await cloud(token, 'coupon_list', { cpage: 1, psize: 100 });
+  if (!coupons.result?.some((item) => Number(item.Flow) === couponId)) throw new Error('可领取优惠券列表异常');
+  const claimedCoupon = await cloud(token, 'coupon_usrgot_add', { flowid: couponId });
+  const userCouponId = String(claimedCoupon.result?.ID || '');
+  if (!userCouponId || Number(claimedCoupon.result?.Flow) !== couponId) throw new Error('优惠券领取异常');
+  created.push(['UserCoupon', userCouponId]);
+  const claimedAgain = await cloud(token, 'coupon_usrgot_add', { flowid: couponId });
+  if (String(claimedAgain.result?.ID) !== userCouponId) throw new Error('重复领取优惠券未保持幂等');
+  const idleCoupons = await cloud(token, 'coupon_usrgot_list', { fast: 'idle', cpage: 1, psize: 20 });
+  if (!idleCoupons.result?.some((item) => String(item.ID) === userCouponId)) throw new Error('用户未使用优惠券列表异常');
+
+  const checkout = await cloud(token, 'payment_cart', {
+    cartIds: cartId,
+    use_sicon: 1,
+    rece: JSON.stringify({ userName: '测试收货人', telNumber: '13800138000', provinceName: '江西省', cityName: '南昌市', countyName: '青山湖区', detailInfo: '测试路 1 号', postalCode: '330000' }),
+    fareDT: '[]',
+    invoice: '',
+    couponid: userCouponId,
+  });
+  const orderId = Number(checkout.addon?.orderIds);
+  if (!orderId || String(checkout.addon?.cartIds) !== cartId) throw new Error('学习豆订单结算响应异常');
+  const repeatedCheckout = await cloud(token, 'payment_cart', {
+    cartIds: cartId,
+    use_sicon: 1,
+    rece: JSON.stringify({ detailInfo: '幂等重试' }),
+    fareDT: '[]',
+    couponid: userCouponId,
+  });
+  if (Number(repeatedCheckout.addon?.orderIds) !== orderId) throw new Error('订单重复提交未保持幂等');
+  await cloud(token, 'payment_success', { orderIds: orderId });
+  await cloud(token, 'payment_success', { orderIds: orderId });
+  await cloud(token, 'payment_cart_again', { orderId, mode: 1 });
+  await cloud(token, 'payment_cart_again', { orderId, mode: 2 }, 501);
+  const paidMember = await cloud(token, 'user_get');
+  if (Number(paidMember.addon?.SilverCoin) !== 167.5) throw new Error(`学习豆扣减异常:${paidMember.addon?.SilverCoin}`);
+  const emptyCartAfterCheckout = await cloud(token, 'cart_list', { ids: '0' });
+  if (emptyCartAfterCheckout.result?.CartDT?.length) throw new Error('已结算购物车仍可见');
+  const usedCoupons = await cloud(token, 'coupon_usrgot_list', { fast: 'used', cpage: 1, psize: 20 });
+  if (!usedCoupons.result?.some((item) => String(item.ID) === userCouponId)) throw new Error('已使用优惠券状态异常');
+  const order = await cloud(token, 'order_get', { id: orderId });
+  if (Number(order.result?.ID) !== orderId || Number(order.result?.Ordersamount) !== 32.5 || order.addon?.cart?.length !== 1) throw new Error(`订单详情投影异常:${JSON.stringify(order)}`);
+  const buyerOrders = await cloud(token, 'order_list', { fast: 'all', cpage: 1, psize: 20 });
+  if (!buyerOrders.result?.some((item) => Number(item.ID) === orderId)) throw new Error('买家订单列表异常');
+  const sellerOrders = await cloud(token, 'user_shop_order', { fast: 'paid', cpage: 1, psize: 20 });
+  if (!sellerOrders.result?.some((item) => Number(item.ID) === orderId)) throw new Error('商户待发货订单列表异常');
+  const sales = await cloud(token, 'user_shop_sales', { fast: 'month', cpage: 1, psize: 5 });
+  if (!sales.result?.some((item) => Number(item.ID) === productId) || Number(sales.addon) !== 37.5) throw new Error('商户销售汇总异常');
+  await cloud(token, 'order_delivery', { id: orderId, expComp: '顺丰快递', expNo: 'SF-TEST-0001', expRemind: '冒烟测试' });
+  const shipped = await cloud(token, 'order_get', { id: orderId });
+  if (Number(shipped.result?.StateLogistics) !== 1 || shipped.addon?.express?.ExpNo !== 'SF-TEST-0001') throw new Error('订单发货状态异常');
+  await cloud(token, 'order_signfor', { id: orderId });
+  const signed = await cloud(token, 'order_get', { id: orderId });
+  if (Number(signed.result?.Signed) !== 1 || Number(signed.result?.StateLogistics) !== 2) throw new Error('订单签收状态异常');
+  await cloud(token, 'order_comment_add', { orderId, proId: productId, score: 5, title: '冒烟好评', content: '订单链路验证通过', images: JSON.stringify([{ url: 'https://example.test/comment.png' }]) });
+  await cloud(token, 'order_comment_add', { orderId, proId: productId, score: 5, title: '重复评价' }, 409);
+  const publicComments = await cloud('', 'order_comment_list', { uid: '', proId: productId, rate: 'good', hasImgOnly: 1, cpage: 1, psize: 20 });
+  if (publicComments.page?.itemCount !== 1 || publicComments.result?.[0]?.Title !== '冒烟好评') throw new Error('商品评价公共列表异常');
+  const afterOrderProduct = await cloud(token, 'product_get', { id: productId });
+  if (Number(afterOrderProduct.result?.[0]?.Stock) !== 10) throw new Error('订单库存扣减异常');
+
+  for (const className of ['CommerceOrder', 'CommerceOrderItem', 'CommercePayment', 'ProductComment']) {
+    const orderRows = await master(`/classes/${className}?where=${encodeURIComponent(JSON.stringify(className === 'ProductComment' ? { productId } : { orderId }))}&limit=100`);
+    for (const row of orderRows.results || []) created.push([className, row.objectId]);
+  }
+
+  const membershipTargetLegacyId = legacyUserId + 1;
+  const membershipTarget = await master('/users', {
+    method: 'POST',
+    body: JSON.stringify({
+      username: `${username}_agent_target`,
+      password: randomBytes(24).toString('base64url'),
+      company,
+      legacyUserId: membershipTargetLegacyId,
+      legacyGroupId: 1,
+      nickname: '代理库存目标会员',
+      legacyUserData: { UserID: membershipTargetLegacyId, GroupID: 1, ParentUserID: legacyUserId, HoneyName: '代理库存目标会员', boffExp: 0 },
+    }),
+  }, 201);
+  membershipTargetUserId = membershipTarget.objectId;
+  await master(`/users/${userObjectId}`, {
+    method: 'PUT',
+    body: JSON.stringify({ legacyGroupId: 4, legacyUserData: { UserID: legacyUserId, GroupID: 4, HoneyName: '会员资料云函数冒烟', SilverCoin: 167.5, boffExp: 5000 } }),
+  });
+  const upgradeKey = `smoke-upgrade-${Date.now()}`;
+  await cloud(token, 'user_group_usr_upgrade', { upusr: membershipTargetLegacyId, gid: 2, idempotencyKey: upgradeKey });
+  await cloud(token, 'user_group_usr_upgrade', { upusr: membershipTargetLegacyId, gid: 2, idempotencyKey: upgradeKey });
+  const supplyKey = `smoke-supply-${Date.now()}`;
+  await cloud(token, 'user_group_usr_supply', { upusr: membershipTargetLegacyId, munber: 250, idempotencyKey: supplyKey });
+  const operatorAfterStock = await master(`/users/${userObjectId}`);
+  const targetAfterStock = await master(`/users/${membershipTargetUserId}`);
+  if (Number(operatorAfterStock.legacyUserData?.boffExp) !== 3750 || Number(targetAfterStock.legacyGroupId) !== 2 || Number(targetAfterStock.legacyUserData?.boffExp) !== 1250) throw new Error('代理招募或库存批发原子余额异常');
+  const membershipLedgers = await master(`/classes/MembershipStockLedger?where=${encodeURIComponent(JSON.stringify({ operatorLegacyId: legacyUserId }))}&limit=20`);
+  if (membershipLedgers.results?.length !== 2) throw new Error('代理库存流水数量或幂等异常');
+  for (const row of membershipLedgers.results || []) created.push(['MembershipStockLedger', row.objectId]);
+
+  await cloud(token, 'product_sale_change', { id: productId, status: 0 });
+  await cloud('', 'product_get', { uid: '', id: productId }, 404);
+  await cloud(token, 'product_sale_change', { id: productId, status: 1 });
+  await cloud(token, 'product_del', { id: productId });
+  const emptyCartAfterRecycle = await cloud(token, 'cart_list', { ids: '0' });
+  if (emptyCartAfterRecycle.result?.CartDT?.length) throw new Error('已回收商品仍出现在购物车');
+
+  await cloud(token, 'receaddr_del', { id: addressId });
+  await cloud(token, 'invoice_del', { id: invoiceId });
+  await cloud(token, 'user_bank_del', { id: bankId });
+  const emptyAddress = await cloud(token, 'receaddr_list', { cpage: 1, psize: 20 });
+  const emptyInvoice = await cloud(token, 'invoice_list', { cpage: 1, psize: 20 });
+  const emptyBank = await cloud(token, 'user_bank_list', { cpage: 1, psize: 20 });
+  if (emptyAddress.page?.itemCount || emptyInvoice.page?.itemCount || emptyBank.page?.itemCount) throw new Error('软删除后的会员资料仍出现在列表');
+
+  const auditWhere = encodeURIComponent(JSON.stringify({ legacyUserId }));
+  const audits = await master(`/classes/AppAuditLog?where=${auditWhere}&count=1&limit=0`);
+  if (Number(audits.count) < 30) throw new Error(`会员资料、商城与学习操作审计数量不足:${audits.count || 0}`);
+
+  const direct = await fetch(`${PARSE_URL}/classes/ReceivingAddress?limit=1`, { headers: { 'X-Parse-Application-Id': APP_ID } });
+  if (direct.ok) throw new Error('会员敏感资料类未关闭浏览器直接读取权限');
+  const directServiceRequest = await fetch(`${PARSE_URL}/classes/ServiceRequest?limit=1`, { headers: { 'X-Parse-Application-Id': APP_ID } });
+  if (directServiceRequest.ok) throw new Error('服务申请类未关闭浏览器直接读取权限');
+  for (const className of ['CommerceProduct', 'ShoppingCartItem', 'ProductStockLedger', 'ProductComment', 'CommerceOrder', 'CommerceOrderItem', 'CommercePayment', 'CouponDefinition', 'UserCoupon', 'MembershipStockLedger']) {
+    const directCommerce = await fetch(`${PARSE_URL}/classes/${className}?limit=1`, { headers: { 'X-Parse-Application-Id': APP_ID } });
+    if (directCommerce.ok) throw new Error(`${className} 未关闭浏览器直接读取权限`);
+  }
+
+  console.log(JSON.stringify({ passed: true, reviewStats: true, address: true, invoice: true, bank: true, vote: true, unitProgress: true, favorite: true, serviceRequests: true, commerceProducts: true, stockLedger: true, cart: true, coupons: true, orders: true, internalPayment: true, comments: true, sellerSales: true, membershipStock: true, audit: Number(audits.count), directClassAccessDenied: true }, null, 2));
+} finally {
+  for (const [className, objectId] of created.reverse()) await hardDelete(className, objectId);
+  if (commerceProductId) {
+    const productWhere = encodeURIComponent(JSON.stringify({ productId: commerceProductId }));
+    for (const className of ['ShoppingCartItem', 'ProductStockLedger', 'ProductComment', 'CommerceOrderItem', 'CommerceProduct']) {
+      const rows = await master(`/classes/${className}?where=${productWhere}&limit=1000`).catch(() => ({ results: [] }));
+      for (const row of rows.results || []) await hardDelete(className, row.objectId);
+    }
+  }
+  if (legacyUserId) {
+    const where = encodeURIComponent(JSON.stringify({ legacyUserId }));
+    for (const className of ['CommerceOrder', 'CommercePayment', 'UserCoupon']) {
+      const rows = await master(`/classes/${className}?where=${where}&limit=1000`).catch(() => ({ results: [] }));
+      for (const row of rows.results || []) await hardDelete(className, row.objectId);
+    }
+    const logs = await master(`/classes/AppAuditLog?where=${where}&limit=1000`).catch(() => ({ results: [] }));
+    for (const row of logs.results || []) await hardDelete('AppAuditLog', row.objectId);
+    const membershipWhere = encodeURIComponent(JSON.stringify({ operatorLegacyId: legacyUserId }));
+    const membershipRows = await master(`/classes/MembershipStockLedger?where=${membershipWhere}&limit=1000`).catch(() => ({ results: [] }));
+    for (const row of membershipRows.results || []) await hardDelete('MembershipStockLedger', row.objectId);
+  }
+  if (userObjectId) {
+    const pointer = { __type: 'Pointer', className: '_User', objectId: userObjectId };
+    const where = encodeURIComponent(JSON.stringify({ user: pointer }));
+    const sessions = await master(`/sessions?where=${where}&limit=1000`).catch(() => ({ results: [] }));
+    for (const row of sessions.results || []) await master(`/sessions/${row.objectId}`, { method: 'DELETE' }).catch(() => undefined);
+    await master(`/users/${userObjectId}`, { method: 'DELETE' }).catch(() => undefined);
+  }
+  if (membershipTargetUserId) await master(`/users/${membershipTargetUserId}`, { method: 'DELETE' }).catch(() => undefined);
+}

+ 99 - 0
scripts/smoke-legacy-sync-bridge.mjs

@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+
+import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
+import { spawn } from 'node:child_process';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const port = 56000 + Math.floor(Math.random() * 3000);
+const origin = `http://127.0.0.1:${port}`;
+const pathBase = '/xiaoshu-sync';
+const base = `${origin}${pathBase}`;
+const keyId = 'local-smoke';
+const secret = randomBytes(32).toString('hex');
+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+let output = '';
+
+const dotnet = process.env.DOTNET_PATH || '/usr/local/bin/dotnet';
+const child = spawn(dotnet, [
+  'run', '--project', 'legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj',
+  '-c', 'Release', '--no-build', '--no-restore', '--urls', origin,
+], {
+  cwd: repoRoot,
+  env: {
+    ...process.env,
+    ASPNETCORE_ENVIRONMENT: 'Production',
+    DOTNET_ROLL_FORWARD: 'Major',
+    ConnectionStrings__LegacySqlServer: 'Server=127.0.0.1,1;Database=not-used;User Id=none;Password=none;TrustServerCertificate=True;Connect Timeout=1',
+    XiaoshuSync__KeyId: keyId,
+    XiaoshuSync__Secret: secret,
+    XiaoshuSync__PathBase: pathBase,
+  },
+  stdio: ['ignore', 'pipe', 'pipe'],
+});
+child.stdout.on('data', (chunk) => { output = `${output}${chunk}`.slice(-12000); });
+child.stderr.on('data', (chunk) => { output = `${output}${chunk}`.slice(-12000); });
+child.on('error', (error) => { output = `${output}\n${error.message}`.slice(-12000); });
+
+function signatureHeaders(method, pathAndQuery, body = '', timestamp = Math.floor(Date.now() / 1000), nonce = randomUUID().replaceAll('-', '')) {
+  const bodyHash = createHash('sha256').update(body).digest('hex');
+  const canonical = `${method.toUpperCase()}\n${pathAndQuery}\n${timestamp}\n${nonce}\n${bodyHash}`;
+  return {
+    'X-Xiaoshu-Key-Id': keyId,
+    'X-Xiaoshu-Timestamp': String(timestamp),
+    'X-Xiaoshu-Nonce': nonce,
+    'X-Xiaoshu-Signature': createHmac('sha256', secret).update(canonical).digest('hex'),
+  };
+}
+
+async function waitForHealth() {
+  const deadline = Date.now() + 30000;
+  while (Date.now() < deadline) {
+    if (child.exitCode !== null) throw new Error(`同步桥提前退出:${output}`);
+    try {
+      const response = await fetch(`${base}/v1/health`);
+      if (response.ok && (await response.json()).status === 'ok') return;
+    } catch {}
+    await new Promise((resolve) => setTimeout(resolve, 250));
+  }
+  throw new Error(`同步桥健康检查超时:${output}`);
+}
+
+async function expectStatus(url, expected, init = {}) {
+  const response = await fetch(url, init);
+  if (response.status !== expected) {
+    const body = await response.text();
+    throw new Error(`${url} 期望 HTTP ${expected},实际 ${response.status}: ${body.slice(0, 500)}`);
+  }
+  return response;
+}
+
+try {
+  await waitForHealth();
+  await expectStatus(`${base}/v1/manifest`, 401);
+
+  const invalidPath = `${pathBase}/v1/changes?dataset=unsupported`;
+  const nonce = randomUUID().replaceAll('-', '');
+  const headers = signatureHeaders('GET', invalidPath, '', Math.floor(Date.now() / 1000), nonce);
+  await expectStatus(`${origin}${invalidPath}`, 400, { headers });
+  await expectStatus(`${origin}${invalidPath}`, 409, { headers });
+
+  const expiredHeaders = signatureHeaders('GET', invalidPath, '', Math.floor(Date.now() / 1000) - 1000);
+  await expectStatus(`${origin}${invalidPath}`, 401, { headers: expiredHeaders });
+
+  const oversized = JSON.stringify({ actor: 'smoke', reason: 'request-size-guard', idempotencyKey: randomUUID(), payload: { value: 'x'.repeat(1_048_576) } });
+  await expectStatus(`${base}/v1/commands/member.update-profile`, 413, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: oversized,
+  });
+
+  console.log(JSON.stringify({ passed: true, publicHealth: true, manifestRequiresSignature: true, pathBaseSignature: true, replayRejected: true, expiredSignatureRejected: true, requestSizeGuard: true }, null, 2));
+} finally {
+  if (child.exitCode === null) child.kill('SIGTERM');
+  await new Promise((resolve) => {
+    if (child.exitCode !== null) return resolve();
+    child.once('exit', resolve);
+    setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL'); resolve(); }, 3000).unref();
+  });
+}

+ 50 - 9
scripts/smoke-operations-functions.mjs

@@ -16,10 +16,14 @@ async function request(url, init = {}, master = false) {
 }
 
 async function call(path, token, params, expectedStatus = 200) {
-  const response = await fetch(`${FUNCTION_URL}/${path}`, { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, params }) });
+  const startedAt = Date.now();
+  const label = params.operation || params.action || path;
+  console.error(`[smoke] ${label} ...`);
+  const response = await fetch(`${FUNCTION_URL}/${path}`, { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, params }), signal: AbortSignal.timeout(90_000) });
   const payload = await response.json().catch(() => ({}));
   if (response.status !== expectedStatus) throw new Error(`${path} 期望 ${expectedStatus},实际 ${response.status}: ${payload.message || payload.error || ''}`);
   if (expectedStatus === 200 && payload.success !== true) throw new Error(payload.message || `${path} 返回失败`);
+  console.error(`[smoke] ${label} ${response.status} ${Date.now() - startedAt}ms`);
   return payload;
 }
 
@@ -51,6 +55,7 @@ try {
   if (!Array.isArray(users.items) || !users.items.length || users.items.some((item) => 'legacyUserData' in item || 'legacyPasswordHash' in item || 'ACL' in item)) throw new Error('用户业务投影为空或泄漏技术字段');
   if (users.items.some((item) => item.username === username) || users.items.some((item) => !item.learningStatus || !item.learningStatusLabel) || !users.refreshedAt) throw new Error('管理员过滤、学习状态或刷新时间无效');
   if (!users.summary || users.summary.total !== users.total || !Array.isArray(users.agents)) throw new Error('会员摘要或代理筛选项结构无效');
+  if (!users.sourceStatus?.available || users.sourceStatus.memberCount !== users.total || users.sourceStatus.total < users.total || users.sourceStatus.storeCount < 1) throw new Error(`会员实时身份源或分类统计无效:${JSON.stringify(users.sourceStatus || {})}`);
   if (users.items.some((item) => item.groupId !== 1 || item.identityType !== 'member' || /陪练/.test(String(item.displayName || '')))) throw new Error('陪练或非会员身份仍混入会员列表');
   if (users.items.some((item) => !item.registeredAt)) throw new Error('会员注册时间存在空白');
   for (let index = 1; index < users.items.length; index += 1) {
@@ -65,25 +70,47 @@ try {
   }
   if (Object.values(learningCounts).reduce((sum, value) => sum + Number(value || 0), 0) !== users.total) throw new Error(`学习状态分类数量与会员总数不一致:${JSON.stringify(learningCounts)}`);
   const named = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 20, search: '万宇昊' })).data;
-  if (!named.items?.some((item) => item.displayName === '万宇昊')) throw new Error('用户显示名称兜底未找到“万宇昊”');
+  if (!named.items?.some((item) => String(item.displayName || '').includes('万宇昊'))) throw new Error('用户显示名称兜底未找到“万宇昊”');
   const legacyAdmin = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 20, search: 'admin' })).data;
   if (legacyAdmin.items?.some((item) => String(item.username || '').toLowerCase() === 'admin')) throw new Error('Manager 表中的历史管理员仍混入会员列表');
   const identityConflicts = (await ops({ operation: 'ops/users/identity-conflicts', page: 1, pageSize: 100, search: '' })).data;
   if (!Array.isArray(identityConflicts.items) || identityConflicts.items.some((item) => item.currentGroupId !== 1 || item.recommendedGroupId !== 3 || !item.evidence)) throw new Error('会员身份异常预览结构无效');
 
+  const stores = (await ops({ operation: 'ops/stores/list', page: 1, pageSize: 100, search: '', filters: { status: 'all' } })).data;
+  if (!Array.isArray(stores.items) || !stores.items.length || !stores.summary || typeof stores.summary.members !== 'number') throw new Error('门店列表或运营摘要结构无效');
+  if (!stores.liveSourceAvailable || stores.summary.total !== stores.total || stores.summary.total !== stores.legacyStoreCount || stores.summary.liveOnly !== 0) throw new Error(`门店新数据库投影不完整:${JSON.stringify({ total: stores.total, summary: stores.summary, legacyStoreCount: stores.legacyStoreCount })}`);
+  if (stores.items.some((item) => item.readOnly || !['parse', 'hybrid'].includes(String(item.source || '')))) throw new Error('门店仍依赖旧站只读账号');
+  if (stores.summary.members + Number(stores.summary.unboundMembers || 0) !== users.total) throw new Error(`门店归属会员与会员管理口径不一致:bound=${stores.summary.members}, unbound=${stores.summary.unboundMembers}, members=${users.total}`);
+
   const coaches = (await ops({ operation: 'ops/coaches/list', page: 1, pageSize: 20, search: '' })).data;
   if (!Array.isArray(coaches.items) || coaches.items.some((item) => !item.userId || !item.displayName)) throw new Error('陪练老师业务投影无效');
   const activeCoaches = (await ops({ operation: 'ops/coaches/list', page: 1, pageSize: 100, search: '', filters: { status: 'active' } })).data;
+  if (!coaches.summary || coaches.summary.total !== coaches.total || coaches.summary.active !== activeCoaches.total || coaches.summary.total < 100 || !Number.isFinite(Number(coaches.summary.monthActive)) || coaches.summary.monthActive < 0) throw new Error(`陪练完整名单、正常账号或本月业务统计无效:${JSON.stringify(coaches.summary || {})}`);
+  const legacyCoach1810 = (await ops({ operation: 'ops/coaches/list', page: 1, pageSize: 20, search: '1810', filters: { status: 'all' } })).data.items?.find((item) => Number(item.userId) === 1810);
+  if (!legacyCoach1810 || !legacyCoach1810.username || /^\s*陪练\s*1810\s*$/.test(String(legacyCoach1810.displayName || ''))) throw new Error('历史陪练 1810 的实时账号或姓名未正确关联');
   const today = new Date().toISOString().slice(0, 10);
   const schedule = (await ops({ operation: 'ops/schedule/calendar', dateFrom: today, dateTo: today, filters: {} })).data;
   if (!Array.isArray(schedule.appointments) || !Array.isArray(schedule.coaches) || !Array.isArray(schedule.members) || !Array.isArray(schedule.courses) || !schedule.refreshedAt) throw new Error('排课中心日历或业务选项结构无效');
+  if (schedule.courses.some((course) => !Array.isArray(course.memberIds)) || !schedule.courses.some((course) => course.memberIds.length > 0)) throw new Error('排课课程没有返回会员绑定范围');
+  const memberWithUnboundCourse = schedule.members.filter((member) => Number(member.userId) > 0 && !member.readOnly).map((member) => ({ member, course: schedule.courses.find((course) => Number(course.courseId) > 0 && course.courseId !== 398 && !course.memberIds.includes(member.userId)) })).find((item) => item.course);
+  if (memberWithUnboundCourse) {
+    const deniedCourse = await ops({ operation: 'ops/appointments/preflight', payload: { studentId: memberWithUnboundCourse.member.userId, courseId: memberWithUnboundCourse.course.courseId, coachId: 0, classType: 1, date: schedule.dataDateTo || today, startTime: '09:00', recurrence: 'once', occurrences: 1 } }, 409);
+    if (!String(deniedCourse.message || '').includes('未绑定到当前会员')) throw new Error('排课预检没有阻止选择其他会员的课程');
+  }
+  const memberForPreflight = schedule.members.find((member) => Number(member.userId) > 0 && !member.readOnly && ((Number(member.periods30) >= 2 && Number(member.userPoint) >= 1) || (Number(member.periods60) >= 2 && Number(member.userPoint) >= 2) || Number(member.userPoint) >= 2));
+  const boundCourseForPreflight = memberForPreflight && schedule.courses.find((course) => Number(course.courseId) > 0 && (course.availableToAll === true || course.memberIds.includes(memberForPreflight.userId)));
+  if (memberForPreflight && boundCourseForPreflight) {
+    const classType = Number(memberForPreflight.periods30) >= 2 && Number(memberForPreflight.userPoint) >= 1 ? 1 : Number(memberForPreflight.periods60) >= 2 && Number(memberForPreflight.userPoint) >= 2 ? 2 : 4;
+    const preflight = (await ops({ operation: 'ops/appointments/preflight', payload: { studentId: memberForPreflight.userId, courseId: boundCourseForPreflight.courseId, coachId: 0, classType, date: '2099-01-05', startTime: '09:00', recurrence: 'weekly', occurrences: 2 } })).data;
+    if (!Array.isArray(preflight.candidates) || !Array.isArray(preflight.conflicts) || !Array.isArray(preflight.conflictIndexes) || typeof preflight.issueCount !== 'number' || preflight.availableCount + preflight.conflictCount !== preflight.candidates.length || preflight.conflictCount !== preflight.conflictIndexes.length) throw new Error('批量排课逐项预检结构或按课次去重统计无效');
+  }
   if (!schedule.totalAppointments || !schedule.dataDateFrom || !schedule.dataDateTo) throw new Error(`历史排课总量或数据日期范围缺失:${JSON.stringify({ keys: Object.keys(schedule), totalAppointments: schedule.totalAppointments, dataDateFrom: schedule.dataDateFrom, dataDateTo: schedule.dataDateTo })}`);
   const latestSchedule = (await ops({ operation: 'ops/schedule/calendar', dateFrom: schedule.dataDateTo, dateTo: schedule.dataDateTo, filters: {} })).data;
   if (!latestSchedule.appointments?.length || latestSchedule.totalAppointments !== schedule.totalAppointments) throw new Error('最近历史排课未正确投影到排课中心');
   if (!coaches.items?.some((item) => item.totalAppointments > 0 && item.lastAppointmentAt)) throw new Error('陪练列表缺少历史排课汇总');
   const dashboardCoachMetric = dashboard.metrics.find((item) => item.key === 'coaches');
   if (dashboardCoachMetric?.value !== activeCoaches.total) throw new Error(`工作台陪练人数与陪练页不一致:dashboard=${dashboardCoachMetric?.value}, coaches=${activeCoaches.total}`);
-  if (coaches.liveSourceAvailable !== schedule.liveSourceAvailable || !coaches.liveSourceCount) throw new Error('陪练页没有同步排课中心的实时数据源');
+  if (!coaches.liveSourceAvailable || coaches.liveSourceAvailable !== schedule.liveSourceAvailable) throw new Error('陪练页与排课中心没有统一使用新数据库投影');
   const rates = (await ops({ operation: 'ops/pay-rates/list', page: 1, pageSize: 100 })).data;
   for (const expected of [{ type: 1, amount: 20, hours: .5 }, { type: 2, amount: 40, hours: 1 }, { type: 3, amount: 40, hours: 1 }]) {
     if (!rates.items?.some((item) => item.classType === expected.type && item.amount === expected.amount && item.durationHours === expected.hours)) throw new Error(`原系统课酬规则缺失:课型 ${expected.type}`);
@@ -92,7 +119,7 @@ try {
   const payrolls = (await ops({ operation: 'ops/payroll/list', page: 1, pageSize: 20 })).data;
   if (!Array.isArray(payrolls.items) || typeof payrolls.total !== 'number') throw new Error('工资批次未返回统一分页结构');
   const payrollOverview = (await ops({ operation: 'ops/payroll/overview', month: '2026-08', storeId: 0, search: '', refresh: true })).data;
-  if (!payrollOverview.sourceStatus?.available || payrollOverview.sourceStatus.mode !== 'live') throw new Error(`工资实时上课记录不可用:${payrollOverview.sourceStatus?.message || 'unknown'}`);
+  if (!payrollOverview.sourceStatus?.available || payrollOverview.sourceStatus.mode !== 'parse') throw new Error(`工资新数据库上课记录不可用:${payrollOverview.sourceStatus?.message || 'unknown'}`);
   if (!Array.isArray(payrollOverview.lines) || payrollOverview.lessonCount < 700 || !Array.isArray(payrollOverview.coachItems) || !payrollOverview.coachItems.length) throw new Error(`2026-08 工资收益投影数量异常:${payrollOverview.lessonCount}`);
   if (payrollOverview.lines.some((item) => !item.legacyGeneralId || !item.lessonAt || String(item.lessonAt).startsWith('{'))) throw new Error('工资明细缺少旧记录ID或规范上课日期');
   if (payrollOverview.lines.some((item) => item.classType === 4 && (item.amount !== 0 || item.durationHours !== 0 || item.exception))) throw new Error('课型4没有按只计课次、不计工资和工时处理');
@@ -100,18 +127,32 @@ try {
     const sample = payrollOverview.lines.find((item) => item.legacyGeneralId === sampleId);
     if (!sample || !sample.coachName || !sample.studentName || !sample.lessonAt.startsWith('2026-08-25')) throw new Error(`工资样本 ${sampleId} 字段对账失败`);
   }
+  const storeWithBusiness = stores.items.filter((item) => !item.readOnly).sort((a, b) => Number(b.memberCount || 0) + Number(b.coachCount || 0) - Number(a.memberCount || 0) - Number(a.coachCount || 0))[0];
+  if (!storeWithBusiness) throw new Error('没有可用于经营详情回归的已落库门店');
+  const storeDetail = (await ops({ operation: 'ops/stores/detail', objectId: storeWithBusiness.objectId, month: '2026-08', refresh: true })).data;
+  if (!storeDetail.store || storeDetail.store.objectId !== storeWithBusiness.objectId || storeDetail.month !== '2026-08' || !Array.isArray(storeDetail.members) || !Array.isArray(storeDetail.coaches) || !Array.isArray(storeDetail.appointments) || !Array.isArray(storeDetail.courses)) throw new Error('门店经营详情结构无效');
+  if (storeDetail.members.some((item) => Number(item.parentUserId) !== Number(storeDetail.store.userId) || item.groupId !== 1) || storeDetail.summary.memberCount !== storeDetail.members.length || storeDetail.summary.coachCount !== storeDetail.coaches.length) throw new Error('门店会员、陪练归属或摘要数量不一致');
+  if (storeDetail.appointments.some((item) => !String(item.startsAt || '').startsWith('2026-08')) || storeDetail.commissionStatus?.available !== false || !String(storeDetail.commissionStatus?.message || '').includes('没有可验证')) throw new Error('门店月份过滤或佣金安全提示无效');
+  if (storeDetail.summary.lessonCount !== payrollOverview.lines.filter((line) => Number(line.storeId) === Number(storeDetail.store.userId) && !line.exception).length) throw new Error('门店有效上课记录与工资来源口径不一致');
 
   const vocabularyTree = (await ops({ operation: 'ops/vocabulary/tree' })).data;
-  if (!Array.isArray(vocabularyTree.items) || !vocabularyTree.items.some((item) => item.canContainWords) || !vocabularyTree.refreshedAt || vocabularyTree.totalWords < 80000) throw new Error('词库教材单元树结构无效');
+  if (!Array.isArray(vocabularyTree.items) || !vocabularyTree.items.some((item) => item.canContainWords) || !vocabularyTree.refreshedAt || vocabularyTree.totalWords < 100000) throw new Error('词库教材单元树结构无效或完整词库未补齐');
   const vocabulary = (await ops({ operation: 'ops/vocabulary/list', page: 1, pageSize: 5, search: '', filters: {} })).data;
-  if (!Array.isArray(vocabulary.items) || vocabulary.total < 80000 || vocabulary.items.some((item) => !item.targetId || !item.word)) throw new Error(`词库业务投影数量或结构异常:${vocabulary.total}`);
+  if (!Array.isArray(vocabulary.items) || vocabulary.total < 100000 || vocabulary.items.some((item) => !item.targetId || !item.word)) throw new Error(`词库业务投影数量或结构异常:${vocabulary.total}`);
   if (vocabularyTree.totalWords !== vocabulary.total) throw new Error(`词库目录总量与列表总量不一致:tree=${vocabularyTree.totalWords}, list=${vocabulary.total}`);
   const vocabularyParent = vocabularyTree.items.find((item) => item.childCount > 0 && item.wordCount > 0);
   if (!vocabularyParent || typeof vocabularyParent.directWordCount !== 'number') throw new Error('词库目录缺少父级汇总统计');
   const vocabularyScope = (await ops({ operation: 'ops/vocabulary/list', page: 1, pageSize: 5, search: '', filters: { nodeId: vocabularyParent.nodeId } })).data;
   if (vocabularyScope.total !== vocabularyParent.wordCount) throw new Error(`词库父级未包含子目录词条:node=${vocabularyParent.nodeId}, tree=${vocabularyParent.wordCount}, list=${vocabularyScope.total}`);
-  const specialTraining = (await ops({ operation: 'ops/special-training/list', page: 1, pageSize: 20, search: '', filters: {} })).data;
-  if (!Array.isArray(specialTraining.items) || specialTraining.total < 6 || specialTraining.items.some((item) => !item.targetId || !item.title)) throw new Error(`专项训练业务投影数量或结构异常:${specialTraining.total}`);
+  const vocabularyAudio = (await ops({ operation: 'ops/vocabulary/list', page: 1, pageSize: 20, search: 'cherish', filters: {} })).data;
+  const playableWord = vocabularyAudio.items.find((item) => /^https:\/\/a018\.2018\.z01\.com\/UploadFiles\//.test(String(item.audioUrl || '')));
+  if (!playableWord) throw new Error('旧词库音频地址没有规范化为可访问的 HTTPS 完整地址');
+  const audioResponse = await fetch(playableWord.audioUrl, { method: 'HEAD', redirect: 'follow' });
+  if (!audioResponse.ok || !String(audioResponse.headers.get('content-type') || '').startsWith('audio/')) throw new Error(`旧词库音频不可播放:${audioResponse.status}`);
+  const specialTraining = (await ops({ operation: 'ops/special-training/list', page: 1, pageSize: 50, search: '', filters: {}, refresh: false })).data;
+  const specialIds = new Set(specialTraining.items?.map((item) => item.generalId));
+  if (!Array.isArray(specialTraining.items) || specialTraining.total !== 19 || specialTraining.items.some((item) => !item.targetId || !item.title || !item.contentHtml) || ![212358, 197954, 332041, 332042, 332043].every((id) => specialIds.has(id))) throw new Error(`专项训练新数据库业务投影异常:${JSON.stringify({ total: specialTraining.total, sourceStatus: specialTraining.sourceStatus })}`);
+  if (specialTraining.items.filter((item) => item.coverUrl).length < 18 || specialTraining.items.some((item) => /(?:src|href)=["']\/UploadFiles\//i.test(item.contentHtml))) throw new Error('专项训练封面回填或旧媒体地址规范化异常');
   const specialDetail = (await ops({ operation: 'ops/special-training/detail', targetId: specialTraining.items[0].targetId })).data;
   if (!specialDetail.title || !('contentHtml' in specialDetail) || !('synopsis' in specialDetail) || !('videoUrl' in specialDetail)) throw new Error('专项训练详情未正确联查 ContentArticle');
 
@@ -122,7 +163,7 @@ try {
 
   const denied = await call('xiaoshu/admin/gateway', token, { operation: 'catalog' }, 403);
   if (denied.success !== false) throw new Error('普通运营管理员仍可访问技术数据目录');
-  console.log(`Operations smoke passed: users=${users.total}, newestMember=${users.items[0]?.displayName || 'none'}@${users.items[0]?.registeredAt || 'none'}, coaches=${coaches.total}, activeCoaches=${activeCoaches.total}, dashboardCoaches=${dashboardCoachMetric.value}, liveCoachAppointments=${coaches.liveSourceCount}, appointments=${schedule.totalAppointments}, latest=${latestSchedule.appointments.length}, vocabulary=${vocabulary.total}, specialTraining=${specialTraining.total}, identityConflicts=${identityConflicts.total}, conflictNames=${identityConflicts.items.map((item) => item.displayName).join('|') || 'none'}, metrics=${dashboard.metrics.length}, learning=${JSON.stringify(learningCounts)}.`);
+  console.log(`Operations smoke passed: users=${users.total}, newestMember=${users.items[0]?.displayName || 'none'}@${users.items[0]?.registeredAt || 'none'}, coaches=${coaches.total}, activeCoaches=${activeCoaches.total}, monthActiveCoaches=${coaches.summary.monthActive}, dashboardCoaches=${dashboardCoachMetric.value}, liveCoachAppointments=${coaches.liveSourceCount}, appointments=${schedule.totalAppointments}, latest=${latestSchedule.appointments.length}, vocabulary=${vocabulary.total}, specialTraining=${specialTraining.total}, identityConflicts=${identityConflicts.total}, conflictNames=${identityConflicts.items.map((item) => item.displayName).join('|') || 'none'}, metrics=${dashboard.metrics.length}, learning=${JSON.stringify(learningCounts)}.`);
 } finally {
   if (userId) {
     const sessions = await request(`${PARSE_URL}/classes/_Session?where=${encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }))}&limit=1000`, {}, true).catch(() => ({ results: [] }));

+ 12 - 0
src/app/admin/admin-session.service.spec.ts

@@ -23,6 +23,18 @@ describe('AdminSessionService', () => {
     sessionStorage.clear();
   });
 
+  it('logs administrators in through the app cloud gateway', async () => {
+    const service = TestBed.inject(AdminSessionService);
+    const result = service.login(' admin ', 'secret-pass');
+
+    const request = http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`);
+    expect(request.request.body).toEqual({ params: { action: 'user_login_passwd', name: 'admin', passwd: 'secret-pass' } });
+    request.flush({ retcode: 0, result: { objectId: 'admin-1', userName: 'admin', honeyName: '管理员', sessionToken: 'r:admin-token' } });
+
+    await expectAsync(result).toBeResolvedTo(jasmine.objectContaining({ objectId: 'admin-1', username: 'admin', displayName: '管理员', sessionToken: 'r:admin-token' }));
+    expect(JSON.parse(sessionStorage.getItem(ADMIN_SESSION_KEY) || '{}').sessionToken).toBe('r:admin-token');
+  });
+
   it('revokes the Parse session through the admin cloud function on logout', () => {
     sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', username: 'root', sessionToken: 'r:admin-token' }));
     const service = TestBed.inject(AdminSessionService);

+ 8 - 6
src/app/admin/admin-session.service.ts

@@ -2,7 +2,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http';
 import { computed, inject, Injectable, signal } from '@angular/core';
 import { Router } from '@angular/router';
 import { firstValueFrom } from 'rxjs';
-import { ADMIN_SESSION_KEY, CLOUD_FUNCTION_BASE_URL, PARSE_APP_ID, PARSE_BASE_URL } from './admin.constants';
+import { ADMIN_SESSION_KEY, CLOUD_FUNCTION_BASE_URL, PARSE_APP_ID } from './admin.constants';
 import { AdminIdentity, AdminSession } from './admin.models';
 
 @Injectable({ providedIn: 'root' })
@@ -19,17 +19,19 @@ export class AdminSessionService {
       'X-Parse-Application-Id': PARSE_APP_ID,
       'Content-Type': 'application/json',
     });
-    const result = await firstValueFrom(this.http.post<Record<string, unknown>>(
-      `${PARSE_BASE_URL}/login`,
-      { username: username.trim(), password },
+    const envelope = await firstValueFrom(this.http.post<{ retcode?: number; retmsg?: string; result?: Record<string, unknown> }>(
+      `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`,
+      { params: { action: 'user_login_passwd', name: username.trim(), passwd: password } },
       { headers },
     ));
+    if (envelope.retcode === -1 || !envelope.result) throw new Error(envelope.retmsg || '登录失败');
+    const result = envelope.result;
     const session: AdminSession = {
       objectId: String(result['objectId'] ?? ''),
-      username: String(result['username'] ?? username),
+      username: String(result['userName'] ?? result['username'] ?? username),
       sessionToken: String(result['sessionToken'] ?? ''),
       company: result['company'] as AdminSession['company'],
-      displayName: String(result['realName'] ?? result['realname'] ?? result['nickname'] ?? result['username'] ?? username),
+      displayName: String(result['honeyName'] ?? result['realName'] ?? result['nickname'] ?? result['userName'] ?? username),
       roles: Array.isArray(result['roles']) ? result['roles'].map(String) : [],
       isSuperAdmin: result['adminRoleKey'] === 'super-admin' || (Array.isArray(result['roles']) && result['roles'].includes('super-admin')),
     };

+ 0 - 1
src/app/admin/admin.constants.ts

@@ -2,7 +2,6 @@ export const PARSE_APP_ID = '7pIbDBJmKx_main';
 
 const isLocalDevelopment = ['localhost', '127.0.0.1'].includes(globalThis.location?.hostname ?? '');
 
-export const PARSE_BASE_URL = isLocalDevelopment ? '/ncloud' : 'https://server.xiaoshu.pro/parse';
 export const CLOUD_FUNCTION_BASE_URL = isLocalDevelopment
   ? '/cloud-functions'
   : 'https://server.xiaoshu.pro/api/functions';

+ 139 - 2
src/app/admin/admin.models.ts

@@ -154,6 +154,7 @@ export interface LegacySyncRunResult {
   cursor: string;
   startedAt: string;
   finishedAt: string;
+  pages?: number;
 }
 
 export interface OperationsSyncIndicator {
@@ -181,6 +182,44 @@ export interface MemberPage extends OperationsPage<OperationsUser> {
   summary: MemberSummary;
   agents: MemberAgentOption[];
   sync?: OperationsSyncIndicator;
+  sourceStatus?: {
+    available: boolean;
+    message: string;
+    total: number;
+    memberCount: number;
+    coachEvidenceCount: number;
+    storeCount: number;
+    unknownCount: number;
+    refreshedAt: string;
+  };
+}
+
+export interface MemberImportPreviewRow {
+  rowNumber: number;
+  displayName: string;
+  nickname: string;
+  mobileMasked: string;
+  gender: string;
+  grade: string;
+  parentUserId: number;
+  action: 'create' | 'resumed' | 'error';
+  issues: string[];
+}
+
+export interface MemberImportPreview {
+  batchId: string;
+  fileName: string;
+  expiresAt: string;
+  summary: { total: number; create: number; resumed: number; error: number };
+  rows: MemberImportPreviewRow[];
+}
+
+export interface MemberImportResult {
+  batchId: string;
+  total: number;
+  created: number;
+  resumed: number;
+  items: Array<{ objectId: string; userId: number; displayName: string; mobileMasked: string; resumed: boolean }>;
 }
 
 export interface MemberListSort {
@@ -235,6 +274,14 @@ export interface OperationsDashboardData {
   liveSourceMessage?: string;
   scheduleTrend?: ScheduleTrendData;
   sync?: OperationsSyncIndicator;
+  payroll?: {
+    month: string;
+    coachCount: number;
+    lessonCount: number;
+    payableAmount: number;
+    sourceStatus: PayrollSourceStatus;
+    refreshedAt: string;
+  };
 }
 
 export interface OperationsUser extends Record<string, unknown> {
@@ -273,6 +320,10 @@ export interface OperationsUser extends Record<string, unknown> {
   gender?: string;
   grade?: string;
   noteCount?: number;
+  liveOnly?: boolean;
+  writeReady?: boolean;
+  dataSource?: 'parse' | 'legacy-live';
+  readOnlyReason?: string;
 }
 
 export interface OperationsUserDetail extends OperationsUser {
@@ -305,6 +356,7 @@ export interface CoachSummary extends Record<string, unknown> {
   pendingAppointments: number;
   totalAppointments: number;
   lastAppointmentAt: string;
+  nextAppointmentAt?: string;
   monthLessons: number;
   monthHours: number;
   estimatedPay: number;
@@ -315,6 +367,16 @@ export interface CoachSummary extends Record<string, unknown> {
   source?: 'hybrid' | 'legacy-live';
   sourceLabel?: string;
   readOnly?: boolean;
+  registeredAt?: string;
+  hasCurrentBusiness?: boolean;
+}
+
+export interface CoachPageSummary extends Record<string, unknown> {
+  total: number;
+  active: number;
+  disabled: number;
+  monthActive: number;
+  liveOnly: number;
 }
 
 export interface StoreSummary extends Record<string, unknown> {
@@ -338,6 +400,9 @@ export interface StoreSummary extends Record<string, unknown> {
   status: 'active' | 'disabled';
   statusLabel: string;
   updatedAt: string;
+  source?: 'parse' | 'hybrid' | 'legacy-live';
+  sourceLabel?: string;
+  readOnly?: boolean;
 }
 
 export interface StoreOption extends Record<string, unknown> {
@@ -355,10 +420,48 @@ export interface StorePage extends OperationsPage<StoreSummary> {
     members: number;
     coaches: number;
     todayAppointments: number;
+    liveOnly?: number;
   };
   parents: StoreOption[];
   liveSourceAvailable?: boolean;
   liveSourceMessage?: string;
+  legacyStoreCount?: number;
+  sync?: OperationsSyncIndicator;
+}
+
+export interface StoreCourseSummary extends Record<string, unknown> {
+  courseId: number;
+  courseName: string;
+  appointmentCount: number;
+  completedCount: number;
+  studentCount: number;
+  completionRate: number;
+  lastAppointmentAt: string;
+}
+
+export interface StoreDetailData extends Record<string, unknown> {
+  store: StoreSummary;
+  month: string;
+  summary: {
+    memberCount: number;
+    coachCount: number;
+    monthAppointments: number;
+    completedAppointments: number;
+    activeStudents: number;
+    lessonCount: number;
+    totalHours: number;
+    estimatedCoachPay: number;
+  };
+  members: OperationsUser[];
+  coaches: CoachSummary[];
+  appointments: ScheduleAppointment[];
+  courses: StoreCourseSummary[];
+  commissionStatus: {
+    available: boolean;
+    message: string;
+  };
+  payrollSourceStatus: PayrollSourceStatus;
+  refreshedAt: string;
   sync?: OperationsSyncIndicator;
 }
 
@@ -369,6 +472,7 @@ export interface CoachStoreOption extends Record<string, unknown> {
 }
 
 export interface CoachPage extends OperationsPage<CoachSummary> {
+  summary: CoachPageSummary;
   canManage: boolean;
   stores: CoachStoreOption[];
   liveSourceAvailable?: boolean;
@@ -376,6 +480,9 @@ export interface CoachPage extends OperationsPage<CoachSummary> {
   liveSourceCount?: number;
   dataDateFrom?: string;
   dataDateTo?: string;
+  payrollMonth?: string;
+  payrollSourceStatus?: PayrollSourceStatus;
+  payrollRefreshedAt?: string;
   sync?: OperationsSyncIndicator;
 }
 
@@ -420,8 +527,8 @@ export interface ScheduleCalendarData {
   coaches: CoachSummary[];
   appointments: ScheduleAppointment[];
   unassigned: ScheduleAppointment[];
-  members: Array<{ objectId: string; userId: number; displayName: string; mobileMasked: string; periods30: number; periods60: number; trialPeriods: number; userPoint: number; learningStatusLabel: string }>;
-  courses: Array<{ courseId: number; courseName: string }>;
+  members: Array<{ objectId: string; userId: number; displayName: string; mobileMasked: string; periods30: number; periods60: number; trialPeriods: number; userPoint: number; learningStatusLabel: string; liveOnly?: boolean; writeReady?: boolean; readOnly?: boolean; readOnlyReason?: string }>;
+  courses: Array<{ courseId: number; courseName: string; memberIds: number[]; availableToAll?: boolean }>;
   sync?: OperationsSyncIndicator;
 }
 
@@ -440,11 +547,25 @@ export interface CoachAvailability extends Record<string, unknown> {
 
 export interface CoachDetailData {
   coach: CoachSummary;
+  profile: {
+    username: string;
+    mobile: string;
+    storeName: string;
+    statusLabel: string;
+    sourceLabel: string;
+  };
+  currentPayroll: PayrollCoachSummary;
   availability: CoachAvailability[];
   appointments: ScheduleAppointment[];
   lessons: PayrollLine[];
+  reviews: PayrollLine[];
   payrolls: PayrollSummary[];
   reviewTaskCount: number;
+  payrollMonth: string;
+  payrollSourceStatus: PayrollSourceStatus;
+  refreshedAt: string;
+  dataDateFrom: string;
+  dataDateTo: string;
 }
 
 export interface PayRate extends Record<string, unknown> {
@@ -486,6 +607,7 @@ export interface PayrollLine extends Record<string, unknown> {
   kind: 'lesson' | 'adjustment';
   coachId: number;
   coachName: string;
+  coachObjectId?: string;
   storeId: number;
   storeName: string;
   studentName: string;
@@ -500,6 +622,8 @@ export interface PayrollLine extends Record<string, unknown> {
   durationHours: number;
   rateAmount: number;
   amount: number;
+  score?: number;
+  comment?: string;
   description: string;
   exception: string;
   excluded?: boolean;
@@ -516,6 +640,7 @@ export interface PayrollDetail extends PayrollSummary {
 export interface PayrollCoachSummary extends Record<string, unknown> {
   coachId: number;
   coachName: string;
+  coachObjectId?: string;
   storeId: number;
   storeName: string;
   type30Count: number;
@@ -527,6 +652,7 @@ export interface PayrollCoachSummary extends Record<string, unknown> {
   baseAmount: number;
   adjustmentAmount: number;
   payableAmount: number;
+  averageScore?: number;
   latestLessonAt: string;
 }
 
@@ -622,6 +748,17 @@ export interface SpecialTrainingItem extends Record<string, unknown> {
   updatedAt: string;
 }
 
+export interface SpecialTrainingPage extends OperationsPage<SpecialTrainingItem> {
+  sourceStatus?: {
+    available: boolean;
+    message: string;
+    legacyTotal: number;
+    created: number;
+    updated: number;
+    refreshedAt: string;
+  } | null;
+}
+
 export interface MediaUploadTicket {
   ticketId: string;
   method: 'PUT';

+ 1 - 0
src/app/admin/admin.routes.ts

@@ -9,6 +9,7 @@ const operationsChildren: Routes = [
   { path: 'members', loadComponent: () => import('./pages/operations-users.component').then((module) => module.OperationsUsersComponent) },
   { path: 'members/:objectId', loadComponent: () => import('./pages/operations-user-detail.component').then((module) => module.OperationsUserDetailComponent) },
   { path: 'stores', loadComponent: () => import('./pages/operations-stores.component').then((module) => module.OperationsStoresComponent) },
+  { path: 'stores/:objectId', loadComponent: () => import('./pages/operations-store-detail.component').then((module) => module.OperationsStoreDetailComponent) },
   { path: 'schedule', loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
   { path: 'appointments', pathMatch: 'full', redirectTo: 'schedule' },
   { path: 'coaches', loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },

+ 17 - 2
src/app/admin/operations.service.spec.ts

@@ -45,6 +45,12 @@ describe('OperationsService', () => {
     expect(functions.operations).toHaveBeenCalledWith('ops/stores/list', { page: 1, pageSize: 20, search: '南昌', filters: { status: 'active' } });
   });
 
+  it('loads one store operating detail with an independent month', async () => {
+    functions.operations.and.resolveTo({ store: {}, month: '2026-08', summary: {}, members: [], coaches: [], appointments: [], courses: [] });
+    await service.storeDetail('store-object-1', '2026-08', true);
+    expect(functions.operations).toHaveBeenCalledWith('ops/stores/detail', { objectId: 'store-object-1', month: '2026-08', refresh: true });
+  });
+
   it('adds concurrency and idempotency metadata to every mutation', async () => {
     functions.operations.and.resolveTo({ success: true });
     await service.mutate('ops/appointments/update', 'appointment-1', '运营改期', { coachId: 3 }, '2026-08-20T08:00:00.000Z', 'stable-key');
@@ -58,7 +64,7 @@ describe('OperationsService', () => {
     await service.vocabulary(1, 30, 'apple', { nodeId: 123, status: 99, missingAudio: false });
     await service.specialTraining(1, 50, '自然拼读', { status: 99 });
     expect(functions.operations.calls.argsFor(0)).toEqual(['ops/vocabulary/list', { page: 1, pageSize: 30, search: 'apple', filters: { nodeId: 123, status: 99, missingAudio: false } }]);
-    expect(functions.operations.calls.argsFor(1)).toEqual(['ops/special-training/list', { page: 1, pageSize: 50, search: '自然拼读', filters: { status: 99 } }]);
+    expect(functions.operations.calls.argsFor(1)).toEqual(['ops/special-training/list', { page: 1, pageSize: 50, search: '自然拼读', filters: { status: 99 }, refresh: false }]);
   });
 
   it('loads and runs legacy synchronization through dedicated operations', async () => {
@@ -66,7 +72,7 @@ describe('OperationsService', () => {
     await service.syncStatus(true);
     await service.runSync('appointments', false);
     expect(functions.operations.calls.argsFor(0)).toEqual(['ops/sync/status', { probe: true }]);
-    expect(functions.operations.calls.argsFor(1)).toEqual(['ops/sync/run', { dataset: 'appointments', full: false, pageSize: 500 }]);
+    expect(functions.operations.calls.argsFor(1)).toEqual(['ops/sync/run', { dataset: 'appointments', full: false, pageSize: 500, maxPages: 5 }]);
   });
 
   it('sends Excel rows to preview before committing an import batch', async () => {
@@ -76,4 +82,13 @@ describe('OperationsService', () => {
     await service.previewVocabularyImport('words.xlsx', rows, '秋季更新');
     expect(functions.operations).toHaveBeenCalledWith('ops/vocabulary/import-preview', { reason: '秋季更新', idempotencyKey: '00000000-0000-4000-8000-000000000001', payload: { fileName: 'words.xlsx', rows } });
   });
+
+  it('keeps the same member import rows and idempotency key between preview and commit', async () => {
+    functions.operations.and.resolveTo({ batchId: 'member-batch-1', fileName: 'members.xlsx', expiresAt: '', summary: { total: 1, create: 1, resumed: 0, error: 0 }, rows: [] });
+    const rows = [{ 姓名: '新会员', 手机号: '13900000000', 临时密码: 'temporary-123' }];
+    await service.previewMemberImport('members.xlsx', rows, '秋季统一开户', 'stable-member-import-key');
+    await service.commitMemberImport('member-batch-1', 'members.xlsx', rows, '秋季统一开户', 'stable-member-import-key');
+    expect(functions.operations.calls.argsFor(0)).toEqual(['ops/users/import-preview', { reason: '秋季统一开户', idempotencyKey: 'stable-member-import-key', payload: { fileName: 'members.xlsx', rows } }]);
+    expect(functions.operations.calls.argsFor(1)).toEqual(['ops/users/import-commit', { targetId: 'member-batch-1', reason: '秋季统一开户', idempotencyKey: 'stable-member-import-key', payload: { batchId: 'member-batch-1', fileName: 'members.xlsx', rows } }]);
+  });
 });

+ 36 - 8
src/app/admin/operations.service.ts

@@ -1,5 +1,5 @@
 import { inject, Injectable } from '@angular/core';
-import { CoachAvailability, CoachDetailData, CoachPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollExportData, PayrollOverview, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingItem, StorePage, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
+import { CoachAvailability, CoachDetailData, CoachPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberImportPreview, MemberImportResult, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollExportData, PayrollOverview, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingPage, StoreDetailData, StorePage, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
 
 export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
@@ -17,7 +17,7 @@ export class OperationsService {
   }
 
   runSync(dataset: string, full = false): Promise<LegacySyncRunResult> {
-    return this.functions.operations<LegacySyncRunResult>('ops/sync/run', { dataset, full, pageSize: 500 });
+    return this.functions.operations<LegacySyncRunResult>('ops/sync/run', { dataset, full, pageSize: 500, maxPages: 5 });
   }
 
   retrySyncFailures(dataset = ''): Promise<LegacySyncRunResult> {
@@ -48,16 +48,28 @@ export class OperationsService {
     return this.functions.operations<{ fileName: string; items: Record<string, unknown>[] }>('ops/users/export', { search, filters, sort });
   }
 
-  coaches(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}): Promise<CoachPage> {
-    return this.functions.operations<CoachPage>('ops/coaches/list', { page, pageSize, search, filters });
+  previewMemberImport(fileName: string, rows: Record<string, unknown>[], reason: string, idempotencyKey: string): Promise<MemberImportPreview> {
+    return this.functions.operations<MemberImportPreview>('ops/users/import-preview', { reason, idempotencyKey, payload: { fileName, rows } });
+  }
+
+  commitMemberImport(batchId: string, fileName: string, rows: Record<string, unknown>[], reason: string, idempotencyKey: string): Promise<MemberImportResult> {
+    return this.functions.operations<MemberImportResult>('ops/users/import-commit', { targetId: batchId, reason, idempotencyKey, payload: { batchId, fileName, rows } });
+  }
+
+  coaches(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}, refresh = false): Promise<CoachPage> {
+    return this.functions.operations<CoachPage>('ops/coaches/list', { page, pageSize, search, filters, refresh });
   }
 
   stores(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}): Promise<StorePage> {
     return this.functions.operations<StorePage>('ops/stores/list', { page, pageSize, search, filters });
   }
 
-  coachDetail(objectId: string): Promise<CoachDetailData> {
-    return this.functions.operations<CoachDetailData>('ops/coaches/detail', { objectId });
+  storeDetail(objectId: string, month: string, refresh = false): Promise<StoreDetailData> {
+    return this.functions.operations<StoreDetailData>('ops/stores/detail', { objectId, month, refresh });
+  }
+
+  coachDetail(objectId: string, month = '', refresh = false): Promise<CoachDetailData> {
+    return this.functions.operations<CoachDetailData>('ops/coaches/detail', { objectId, month, refresh });
   }
 
   schedule(dateFrom: string, dateTo: string, coachId = 0): Promise<ScheduleCalendarData> {
@@ -100,8 +112,8 @@ export class OperationsService {
     return this.functions.operations<OperationsPage<VocabularyWord>>('ops/vocabulary/list', { page, pageSize, search, filters });
   }
 
-  specialTraining(page = 1, pageSize = 50, search = '', filters: Record<string, unknown> = {}): Promise<OperationsPage<SpecialTrainingItem>> {
-    return this.functions.operations<OperationsPage<SpecialTrainingItem>>('ops/special-training/list', { page, pageSize, search, filters });
+  specialTraining(page = 1, pageSize = 50, search = '', filters: Record<string, unknown> = {}, refresh = false): Promise<SpecialTrainingPage> {
+    return this.functions.operations<SpecialTrainingPage>('ops/special-training/list', { page, pageSize, search, filters, refresh });
   }
 
   previewVocabularyImport(fileName: string, rows: Record<string, unknown>[], reason: string): Promise<VocabularyImportPreview> {
@@ -112,6 +124,22 @@ export class OperationsService {
     return this.functions.operations<Record<string, unknown>>('ops/vocabulary/import-commit', { targetId: batchId, reason, idempotencyKey: crypto.randomUUID() });
   }
 
+  async uploadCover(file: File, reason: string, width: number, height: number): Promise<string> {
+    const dataUrl = await new Promise<string>((resolve, reject) => {
+      const reader = new FileReader();
+      reader.onload = () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('无法读取封面文件'));
+      reader.onerror = () => reject(new Error('无法读取封面文件'));
+      reader.readAsDataURL(file);
+    });
+    const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
+    const result = await this.functions.operations<{ publicUrl: string }>('ops/media/upload-inline', {
+      reason,
+      idempotencyKey: crypto.randomUUID(),
+      payload: { fileName: file.name, contentType: file.type, size: file.size, kind: 'image', base64, width, height },
+    });
+    return result.publicUrl;
+  }
+
   async uploadMedia(file: File, kind: 'image' | 'audio' | 'video', reason: string): Promise<string> {
     const ticket = await this.functions.operations<MediaUploadTicket>('ops/media/presign', { reason, idempotencyKey: crypto.randomUUID(), payload: { fileName: file.name, contentType: file.type, size: file.size, kind } });
     const response = await fetch(ticket.uploadUrl, { method: ticket.method, headers: ticket.headers, body: file });

+ 1 - 1
src/app/admin/pages/admin-dashboard.component.html

@@ -39,7 +39,7 @@
           <a class="trend-stat coaches" [routerLink]="coaches.route" [attr.aria-label]="coaches.label+' '+coaches.value"><span>{{coaches.label}}</span><strong>{{coaches.value | number}}</strong><small>人力详情</small><lucide-icon [img]="icons.ArrowRight" [size]="16" /></a>
         }
         @if(dashboardMetric('pay');as pay){
-          <a class="trend-stat pay" [routerLink]="pay.route" [attr.aria-label]="pay.label+' '+pay.value+' 元'"><span>{{pay.label}}</span><strong><i>¥</i>{{pay.value | number:'1.0-2'}}</strong><small>查看工资结算</small><lucide-icon [img]="icons.ArrowRight" [size]="16" /></a>
+          <a class="trend-stat pay" [routerLink]="pay.route" [attr.aria-label]="pay.label+' '+pay.value+' 元'"><span>{{pay.label}}</span><strong><i>¥</i>{{pay.value | number:'1.0-2'}}</strong><small>{{dashboard.payroll?.sourceStatus?.mode==='live'?'与工资结算实时同步':'与工资结算缓存同步'}}</small><lucide-icon [img]="icons.ArrowRight" [size]="16" /></a>
         }
       </div>
       <div class="trend-chart-heading"><div><strong>每日排课趋势</strong><span>滑动图表或选择数据点查看每天数据</span></div><div class="trend-legend"><span class="courses">排课节数</span><span class="students">学员人数</span></div></div>

+ 2 - 1
src/app/admin/pages/admin-sync-center.component.ts

@@ -36,7 +36,8 @@ export class AdminSyncCenterComponent implements OnInit {
     this.running.set(item.key); this.error.set(''); this.message.set('');
     try {
       const result = await this.operations.runSync(item.key, full);
-      this.message.set(`${item.label}已处理 ${result.processed} 条,新增 ${result.created} 条,更新 ${result.updated} 条,失败 ${result.failures} 条。`);
+      const progress = result.hasMore ? '仍有后续分页,可继续同步。' : '已追平到当前水位。';
+      this.message.set(`${item.label}本次连续处理 ${result.pages ?? 1} 页、${result.processed} 条,新增 ${result.created} 条,更新 ${result.updated} 条,失败 ${result.failures} 条。${progress}`);
       await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : `${item.label}同步失败`); }
     finally { this.running.set(''); }

Plik diff jest za duży
+ 0 - 2
src/app/admin/pages/operations-coach-detail.component.html


+ 495 - 0
src/app/admin/pages/operations-coach-detail.component.scss

@@ -0,0 +1,495 @@
+:host {
+  display: block;
+}
+
+.coach-detail-header .back-link {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  margin-bottom: 18px;
+  color: #557068;
+  font-size: 13px;
+  font-weight: 700;
+  text-decoration: none;
+}
+
+.source-strip {
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  margin-bottom: 16px;
+  padding: 11px 14px;
+  border: 1px solid #cce6dc;
+  border-radius: 13px;
+  color: #176c55;
+  background: #eff9f5;
+  font-size: 12px;
+}
+
+.source-strip > span {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: #2c9b74;
+  box-shadow: 0 0 0 4px rgba(44, 155, 116, .12);
+}
+
+.source-strip em {
+  font-style: normal;
+}
+
+.source-strip small {
+  margin-left: auto;
+  color: #71857e;
+}
+
+.source-strip.cache {
+  border-color: #ecd7ad;
+  color: #8e5c14;
+  background: #fff8eb;
+}
+
+.coach-profile-card {
+  display: grid;
+  grid-template-columns: auto minmax(180px, .7fr) minmax(420px, 1.7fr);
+  align-items: center;
+  gap: 20px;
+  margin-bottom: 18px;
+  padding: 22px;
+  border: 1px solid #dfe9e5;
+  border-radius: 20px;
+  background: #fff;
+  box-shadow: 0 12px 32px rgba(28, 58, 48, .055);
+}
+
+.coach-avatar {
+  display: grid;
+  width: 64px;
+  height: 64px;
+  border-radius: 18px;
+  place-items: center;
+  color: #fff;
+  background: linear-gradient(145deg, #2da578, #176c55);
+  font-size: 26px;
+  font-weight: 850;
+}
+
+.coach-identity > span {
+  color: #288163;
+  font-size: 11px;
+  font-weight: 800;
+  letter-spacing: .12em;
+}
+
+.coach-identity h2 {
+  margin: 5px 0;
+  color: #20372f;
+  font-size: 23px;
+}
+
+.coach-identity p {
+  margin: 0;
+  color: #80908a;
+  font-size: 12px;
+}
+
+.coach-profile-card dl {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+  margin: 0;
+}
+
+.coach-profile-card dl div {
+  min-width: 0;
+  padding: 11px 13px;
+  border-radius: 12px;
+  background: #f6f9f8;
+}
+
+.coach-profile-card dt {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  color: #81918b;
+  font-size: 11px;
+}
+
+.coach-profile-card dd {
+  overflow: hidden;
+  margin: 6px 0 0;
+  color: #29423a;
+  font-size: 13px;
+  font-weight: 750;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.coach-summary {
+  grid-template-columns: repeat(6, minmax(0, 1fr));
+}
+
+.coach-summary .summary-card {
+  min-height: 132px;
+}
+
+.coach-summary .payroll-card {
+  border-color: #e8d6d9;
+  background: linear-gradient(145deg, #fff, #fff8f9);
+}
+
+.coach-detail-content {
+  overflow: hidden;
+}
+
+.coach-tabs {
+  overflow-x: auto;
+  padding: 8px;
+  border-bottom: 1px solid #e8efec;
+  background: #f8faf9;
+}
+
+.coach-tabs button {
+  display: inline-flex;
+  flex: 0 0 auto;
+  align-items: center;
+  gap: 7px;
+  min-height: 40px;
+}
+
+.coach-tabs em {
+  display: inline-grid;
+  min-width: 20px;
+  height: 20px;
+  padding: 0 5px;
+  border-radius: 999px;
+  place-items: center;
+  color: #6f807a;
+  background: #e9efec;
+  font-size: 10px;
+  font-style: normal;
+}
+
+.coach-tabs button.active em {
+  color: #176c55;
+  background: #dff2ea;
+}
+
+.coach-tabs .tab-month {
+  padding: 3px 7px;
+  border: 1px solid #d8e3df;
+  border-radius: 999px;
+  color: #6d7f79;
+  background: #fff;
+  font-size: 10px;
+  font-weight: 800;
+  line-height: 1;
+}
+
+.coach-tabs button.active .tab-month {
+  border-color: #b8dfd1;
+  color: #176c55;
+  background: #e8f6f1;
+}
+
+.section-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 14px;
+  padding: 20px 22px 14px;
+}
+
+.section-heading div {
+  display: grid;
+  gap: 4px;
+}
+
+.section-heading strong {
+  color: #243c34;
+  font-size: 16px;
+}
+
+.section-heading span {
+  color: #81918b;
+  font-size: 12px;
+}
+
+.section-tools {
+  display: flex !important;
+  align-items: flex-end;
+  gap: 12px !important;
+}
+
+.section-month-filter {
+  display: grid;
+  grid-template-columns: auto 154px;
+  align-items: center;
+  gap: 4px 10px;
+  min-width: 238px;
+  padding: 8px 10px 8px 12px;
+  border: 1px solid #dce8e3;
+  border-radius: 13px;
+  color: #60746d;
+  background: #f8fbfa;
+  font-size: 11px;
+  font-weight: 800;
+}
+
+.section-month-filter small {
+  grid-column: 1 / -1;
+  color: #86968f;
+  font-size: 10px;
+  font-weight: 650;
+}
+
+.section-month-filter input {
+  width: 100%;
+  height: 38px;
+  padding: 0 10px;
+  border: 1px solid #d6e2de;
+  border-radius: 10px;
+  outline: none;
+  color: #253c34;
+  background: #fff;
+  font: inherit;
+  font-size: 13px;
+  font-weight: 750;
+}
+
+.section-month-filter input:focus {
+  border-color: #2b9b75;
+  box-shadow: 0 0 0 3px rgba(43, 155, 117, .13);
+}
+
+.appointment-status {
+  display: inline-flex;
+  align-items: center;
+  min-height: 27px;
+  padding: 4px 10px;
+  border: 1px solid transparent;
+  border-radius: 999px;
+  font-size: 11px;
+  font-weight: 800;
+  white-space: nowrap;
+}
+
+.appointment-status.status-upcoming {
+  border-color: #f3c47a;
+  color: #9b4c08;
+  background: #fff3dd;
+}
+
+.appointment-status.status-live {
+  border-color: #9dc9ee;
+  color: #17669a;
+  background: #eaf5ff;
+}
+
+.appointment-status.status-coach-ended {
+  border-color: #c7b8ef;
+  color: #6650a6;
+  background: #f2effd;
+}
+
+.appointment-status.status-confirmed {
+  border-color: #9bd9c1;
+  color: #176c55;
+  background: #e7f7f0;
+}
+
+.appointment-status.status-completed {
+  border-color: #d6dee8;
+  color: #5f6f80;
+  background: #f7f9fb;
+}
+
+.appointment-status.status-archived {
+  border-color: #9bd9c1;
+  color: #176c55;
+  background: #e7f7f0;
+}
+
+.appointment-status.status-overdue {
+  border-color: #fecaca;
+  color: #c62828;
+  background: #fff1f2;
+}
+
+.appointment-status.status-cancelled {
+  border-color: #e1c5c5;
+  color: #8c5555;
+  background: #f8eeee;
+}
+
+.appointment-status.status-neutral {
+  border-color: #dce3e0;
+  color: #66766f;
+  background: #f6f8f7;
+}
+
+.total-chip,
+.source-pill {
+  display: inline-flex;
+  width: fit-content;
+  padding: 5px 9px;
+  border-radius: 999px;
+  color: #176c55 !important;
+  background: #eaf6f1;
+  font-size: 11px !important;
+  font-weight: 800;
+  white-space: nowrap;
+}
+
+.detail-table {
+  min-width: 880px;
+}
+
+.detail-table td:first-child strong,
+.detail-table td:first-child small {
+  display: block;
+}
+
+.detail-table td:first-child small {
+  margin-top: 3px;
+  color: #8b9894;
+}
+
+.review-list {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+  padding: 0 22px 22px;
+}
+
+.review-list article {
+  padding: 16px;
+  border: 1px solid #e1e9e6;
+  border-radius: 15px;
+  background: #fff;
+}
+
+.review-list article header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.review-list article header div:first-child {
+  display: grid;
+  gap: 4px;
+}
+
+.review-list article header span {
+  color: #87958f;
+  font-size: 11px;
+}
+
+.review-list article p {
+  margin: 13px 0 0;
+  color: #52665f;
+  font-size: 13px;
+  line-height: 1.65;
+}
+
+.score {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  color: #a66c11;
+  font-weight: 850;
+}
+
+.availability-form {
+  margin: 0 22px 20px;
+  padding: 16px;
+  border: 1px solid #e1e9e6;
+  border-radius: 15px;
+  background: #f8faf9;
+}
+
+@media (max-width: 1180px) {
+  .coach-summary {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+
+  .coach-profile-card {
+    grid-template-columns: auto 1fr;
+  }
+
+  .coach-profile-card dl {
+    grid-column: 1 / -1;
+  }
+
+}
+
+@media (max-width: 700px) {
+  .source-strip {
+    align-items: flex-start;
+    flex-wrap: wrap;
+  }
+
+  .source-strip small {
+    width: 100%;
+    margin-left: 17px;
+  }
+
+  .coach-profile-card {
+    grid-template-columns: auto 1fr;
+    gap: 14px;
+    padding: 16px;
+  }
+
+  .coach-avatar {
+    width: 52px;
+    height: 52px;
+    border-radius: 15px;
+    font-size: 21px;
+  }
+
+  .coach-profile-card dl {
+    grid-template-columns: 1fr;
+  }
+
+  .coach-summary {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .coach-summary .summary-card {
+    min-height: 116px;
+  }
+
+  .review-list {
+    grid-template-columns: 1fr;
+    padding: 0 14px 18px;
+  }
+
+  .section-heading {
+    align-items: flex-start;
+    flex-direction: column;
+    padding: 16px 14px 12px;
+  }
+
+  .section-tools {
+    width: 100%;
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .section-month-filter {
+    width: 100%;
+    min-width: 0;
+    grid-template-columns: auto minmax(140px, 1fr);
+  }
+
+  .availability-form {
+    margin: 0 14px 16px;
+  }
+}
+
+@media (max-width: 430px) {
+  .coach-summary {
+    grid-template-columns: 1fr;
+  }
+}

+ 136 - 0
src/app/admin/pages/operations-coach-detail.component.spec.ts

@@ -0,0 +1,136 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
+import { CoachDetailData, ScheduleAppointment } from '../admin.models';
+import { OperationsService } from '../operations.service';
+import { OperationsCoachDetailComponent } from './operations-coach-detail.component';
+
+function detailFor(month: string): CoachDetailData {
+  const marker = Number(month.slice(-2));
+  return {
+    coach: {
+      objectId: 'legacy-payroll-coach:1810', userId: 1810, username: 'coach1810', displayName: '测试陪练', mobile: '', mobileMasked: '', parentUserId: 0,
+      storeName: '测试门店', status: 'active', statusLabel: '正常', todayAppointments: 0, pendingAppointments: 0, totalAppointments: 0,
+      lastAppointmentAt: '', monthLessons: marker, monthHours: marker, estimatedPay: marker, averageScore: 0, lastLessonAt: '', availabilityConfigured: false, updatedAt: '',
+    },
+    profile: { username: 'coach1810', mobile: '', storeName: '测试门店', statusLabel: '正常', sourceLabel: '测试数据' },
+    currentPayroll: {
+      coachId: 1810, coachName: '测试陪练', coachObjectId: '', storeId: 0, storeName: '测试门店', type30Count: 0, type60Count: 0,
+      trialCount: 0, type4Count: 0, lessonCount: marker, totalHours: marker, baseAmount: marker, adjustmentAmount: 0, payableAmount: marker,
+      exceptionCount: 0, averageScore: 0, latestLessonAt: '',
+    },
+    availability: [],
+    appointments: [{ targetId: `appointment-${month}`, coachId: 1810, startsAt: `${month}-01T02:00:00.000Z` } as never],
+    lessons: [{ objectId: `lesson-${month}`, coachId: 1810, lessonAt: `${month}-01T02:00:00.000Z` } as never],
+    reviews: [{ objectId: `review-${month}`, coachId: 1810, lessonAt: `${month}-01T02:00:00.000Z` } as never],
+    payrolls: [{ objectId: `payroll-${month}`, month } as never],
+    reviewTaskCount: 0,
+    payrollMonth: month,
+    payrollSourceStatus: { mode: 'live', available: true, message: '', total: marker, refreshedAt: `${month}-01T02:00:00.000Z` },
+    refreshedAt: `${month}-01T02:00:00.000Z`,
+    dataDateFrom: `${month}-01`,
+    dataDateTo: `${month}-28`,
+  };
+}
+
+describe('OperationsCoachDetailComponent month filters', () => {
+  let fixture: ComponentFixture<OperationsCoachDetailComponent>;
+  let operations: jasmine.SpyObj<OperationsService>;
+
+  beforeEach(async () => {
+    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['coachDetail', 'mutate']);
+    operations.coachDetail.and.callFake((_objectId, month) => Promise.resolve(detailFor(month || '2026-09')));
+
+    await TestBed.configureTestingModule({
+      imports: [OperationsCoachDetailComponent],
+      providers: [
+        provideRouter([]),
+        { provide: OperationsService, useValue: operations },
+        { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ objectId: 'legacy-payroll-coach:1810' }) } } },
+      ],
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(OperationsCoachDetailComponent);
+    fixture.detectChanges();
+    await fixture.whenStable();
+    fixture.detectChanges();
+  });
+
+  it('keeps schedule, lessons and payroll months independent', async () => {
+    const component = fixture.componentInstance;
+    expect(component.scheduleMonth()).toBe('2026-09');
+    expect(component.lessonsMonth()).toBe('2026-09');
+    expect(component.payrollMonth()).toBe('2026-09');
+
+    await component.changeSectionMonth('schedule', '2026-08');
+    expect(component.scheduleMonth()).toBe('2026-08');
+    expect(component.lessonsMonth()).toBe('2026-09');
+    expect(component.payrollMonth()).toBe('2026-09');
+    expect(component.data()?.appointments[0].targetId).toBe('appointment-2026-08');
+    expect(component.data()?.lessons[0].objectId).toBe('lesson-2026-09');
+    expect(component.data()?.payrolls[0].objectId).toBe('payroll-2026-09');
+
+    await component.changeSectionMonth('lessons', '2026-07');
+    expect(component.scheduleMonth()).toBe('2026-08');
+    expect(component.lessonsMonth()).toBe('2026-07');
+    expect(component.payrollMonth()).toBe('2026-09');
+    expect(component.data()?.appointments[0].targetId).toBe('appointment-2026-08');
+    expect(component.data()?.lessons[0].objectId).toBe('lesson-2026-07');
+    expect(component.data()?.reviews[0].objectId).toBe('review-2026-07');
+    expect(component.data()?.payrolls[0].objectId).toBe('payroll-2026-09');
+
+    await component.changeSectionMonth('payroll', '2026-06');
+    expect(component.scheduleMonth()).toBe('2026-08');
+    expect(component.lessonsMonth()).toBe('2026-07');
+    expect(component.payrollMonth()).toBe('2026-06');
+    expect(component.data()?.appointments[0].targetId).toBe('appointment-2026-08');
+    expect(component.data()?.lessons[0].objectId).toBe('lesson-2026-07');
+    expect(component.data()?.payrolls[0].objectId).toBe('payroll-2026-06');
+
+    fixture.detectChanges();
+    const tabMonths = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('.tab-month')).map((node) => node.textContent?.trim());
+    expect(tabMonths).toEqual(['2026-08', '2026-07', '2026-06']);
+  });
+
+  it('shows each month control only inside its own data section', async () => {
+    const component = fixture.componentInstance;
+    const element: HTMLElement = fixture.nativeElement;
+
+    fixture.detectChanges();
+    expect(element.querySelector<HTMLInputElement>('[data-testid="schedule-month"]')?.value).toBe(component.scheduleMonth());
+    expect(element.querySelector('[data-testid="lessons-month"]')).toBeNull();
+    expect(element.querySelector('[data-testid="payroll-month"]')).toBeNull();
+
+    component.tab.set('lessons');
+    fixture.detectChanges();
+    await fixture.whenStable();
+    fixture.detectChanges();
+    expect(element.querySelector<HTMLInputElement>('[data-testid="lessons-month"]')?.value).toBe(component.lessonsMonth());
+    expect(element.querySelector('[data-testid="schedule-month"]')).toBeNull();
+
+    component.tab.set('payroll');
+    fixture.detectChanges();
+    await fixture.whenStable();
+    fixture.detectChanges();
+    expect(element.querySelector<HTMLInputElement>('[data-testid="payroll-month"]')?.value).toBe(component.payrollMonth());
+    expect(element.querySelector('[data-testid="lessons-month"]')).toBeNull();
+  });
+
+  it('uses the same overdue and archive status rules as the scheduling center', () => {
+    const component = fixture.componentInstance;
+    const expired = {
+      cancelled: false,
+      statusCode: 10,
+      statusLabel: '上课中',
+      endsAt: '2020-01-01T01:00:00.000Z',
+    } as ScheduleAppointment;
+    const completed = { cancelled: false, statusCode: 30, statusLabel: '已归档', endsAt: '' } as ScheduleAppointment;
+    const archived = { cancelled: false, statusCode: 40, statusLabel: '已点评', endsAt: '' } as ScheduleAppointment;
+
+    expect(component.statusTone(expired)).toBe('status-overdue');
+    expect(component.statusDisplayLabel(expired)).toBe('逾期未完成');
+    expect(component.statusTone(completed)).toBe('status-completed');
+    expect(component.statusDisplayLabel(completed)).toBe('已完课');
+    expect(component.statusTone(archived)).toBe('status-archived');
+    expect(component.statusDisplayLabel(archived)).toBe('已归档');
+  });
+});

+ 19 - 8
src/app/admin/pages/operations-coach-detail.component.ts

@@ -1,15 +1,26 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, RouterLink } from '@angular/router';
-import { ArrowLeft, LucideAngularModule, Plus, RefreshCw, Trash2 } from 'lucide-angular';
-import { CoachDetailData } from '../admin.models';
+import { ArrowLeft, Building2, CalendarDays, Clock3, GraduationCap, LucideAngularModule, Phone, Plus, RefreshCw, Star, Trash2, UserRound, WalletCards } from 'lucide-angular';
+import { CoachDetailData, ScheduleAppointment } from '../admin.models';
 import { OperationsService } from '../operations.service';
+import { ScheduleStatusTone, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
 
-@Component({selector:'app-operations-coach-detail',standalone:true,imports:[CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coach-detail.component.html',styleUrl:'./operations-professional.scss',changeDetection:ChangeDetectionStrategy.OnPush})
+type MonthSection = 'schedule' | 'lessons' | 'payroll';
+const CURRENT_MONTH = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }).slice(0, 7);
+
+@Component({selector:'app-operations-coach-detail',standalone:true,imports:[CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coach-detail.component.html',styleUrls:['./operations-professional.scss','./operations-coach-detail.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsCoachDetailComponent implements OnInit{
- readonly data=signal<CoachDetailData|null>(null);readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'schedule'|'availability'|'lessons'|'payroll'>('schedule');readonly ruleType=signal<'weekly'|'date'>('weekly');readonly weekday=signal(1);readonly ruleDate=signal('');readonly startTime=signal('09:00');readonly endTime=signal('21:00');readonly available=signal(true);readonly ruleReason=signal('常规工作时间');readonly icons={ArrowLeft,Plus,RefreshCw,Trash2};private readonly operations=inject(OperationsService);private readonly objectId=inject(ActivatedRoute).snapshot.paramMap.get('objectId')||'';
- ngOnInit():void{void this.load();}async load():Promise<void>{this.loading.set(true);this.error.set('');try{this.data.set(await this.operations.coachDetail(this.objectId));}catch(error){this.error.set(error instanceof Error?error.message:'陪练详情加载失败');}finally{this.loading.set(false);}}
- async saveAvailability():Promise<void>{const coach=this.data()?.coach;if(!coach)return;this.saving.set(true);try{await this.operations.mutate('ops/coach-availability/save','',this.ruleReason(),{coachId:coach.userId,ruleType:this.ruleType(),weekday:this.weekday(),date:this.ruleDate(),startTime:this.startTime(),endTime:this.endTime(),available:this.available()});this.message.set('可用时间已保存');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'可用时间保存失败');}finally{this.saving.set(false);}}
- async removeAvailability(objectId:string,updatedAt:string):Promise<void>{this.saving.set(true);try{await this.operations.mutate('ops/coach-availability/delete',objectId,'删除错误的可用时间规则',{},updatedAt);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'删除失败');}finally{this.saving.set(false);}}
+ readonly data=signal<CoachDetailData|null>(null);readonly loading=signal(true);readonly sectionLoading=signal<MonthSection|null>(null);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'schedule'|'lessons'|'reviews'|'availability'|'payroll'>('schedule');readonly summaryMonth=signal(CURRENT_MONTH);readonly scheduleMonth=signal(CURRENT_MONTH);readonly lessonsMonth=signal(CURRENT_MONTH);readonly payrollMonth=signal(CURRENT_MONTH);readonly lessonsPayroll=signal<CoachDetailData['currentPayroll']|null>(null);readonly ruleType=signal<'weekly'|'date'>('weekly');readonly weekday=signal(1);readonly ruleDate=signal('');readonly startTime=signal('09:00');readonly endTime=signal('21:00');readonly available=signal(true);readonly ruleReason=signal('常规工作时间');readonly upcomingCount=computed(()=>this.data()?.appointments.filter((row)=>!row.cancelled&&row.statusCode<30&&new Date(row.endsAt).getTime()>=Date.now()).length||0);readonly icons={ArrowLeft,Building2,CalendarDays,Clock3,GraduationCap,Phone,Plus,RefreshCw,Star,Trash2,UserRound,WalletCards};private readonly operations=inject(OperationsService);private readonly objectId=inject(ActivatedRoute).snapshot.paramMap.get('objectId')||'';
+ ngOnInit():void{void this.load();}
+ async load(refresh=false):Promise<void>{this.loading.set(true);this.error.set('');try{const detail=await this.operations.coachDetail(this.objectId,this.summaryMonth(),refresh),current=this.data();if(!current){this.data.set(detail);this.scheduleMonth.set(detail.payrollMonth);this.lessonsMonth.set(detail.payrollMonth);this.payrollMonth.set(detail.payrollMonth);this.lessonsPayroll.set(detail.currentPayroll);}else{this.data.set({...detail,appointments:this.scheduleMonth()===detail.payrollMonth?detail.appointments:current.appointments,lessons:this.lessonsMonth()===detail.payrollMonth?detail.lessons:current.lessons,payrolls:this.payrollMonth()===detail.payrollMonth?detail.payrolls:current.payrolls});if(this.lessonsMonth()===detail.payrollMonth)this.lessonsPayroll.set(detail.currentPayroll);}this.summaryMonth.set(detail.payrollMonth);}catch(error){this.error.set(error instanceof Error?error.message:'陪练详情加载失败');}finally{this.loading.set(false);}}
+ async changeSectionMonth(section:MonthSection,value:string):Promise<void>{if(!/^\d{4}-\d{2}$/.test(value)||value===this.sectionMonth(section))return;const previous=this.sectionMonth(section);this.setSectionMonth(section,value);this.sectionLoading.set(section);this.error.set('');try{const detail=await this.operations.coachDetail(this.objectId,value,false);this.data.update((current)=>{if(!current)return detail;if(section==='schedule')return{...current,appointments:detail.appointments};if(section==='lessons')return{...current,lessons:detail.lessons,reviews:detail.reviews};return{...current,payrolls:detail.payrolls};});if(section==='lessons')this.lessonsPayroll.set(detail.currentPayroll);}catch(error){this.setSectionMonth(section,previous);this.error.set(error instanceof Error?error.message:'月份数据加载失败');}finally{this.sectionLoading.set(null);}}
+ isSectionLoading(section:MonthSection):boolean{return this.sectionLoading()===section;}
+ statusTone(row:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(row);}
+ statusDisplayLabel(row:ScheduleAppointment):string{return scheduleStatusLabel(row);}
+ async saveAvailability():Promise<void>{const coach=this.data()?.coach;if(!coach)return;this.saving.set(true);try{await this.operations.mutate('ops/coach-availability/save','',this.ruleReason(),{coachId:coach.userId,ruleType:this.ruleType(),weekday:this.weekday(),date:this.ruleDate(),startTime:this.startTime(),endTime:this.endTime(),available:this.available()});this.message.set('可用时间已保存');await this.load(true);}catch(error){this.error.set(error instanceof Error?error.message:'可用时间保存失败');}finally{this.saving.set(false);}}
+ async removeAvailability(objectId:string,updatedAt:string):Promise<void>{this.saving.set(true);try{await this.operations.mutate('ops/coach-availability/delete',objectId,'删除错误的可用时间规则',{},updatedAt);await this.load(true);}catch(error){this.error.set(error instanceof Error?error.message:'删除失败');}finally{this.saving.set(false);}}
+ private sectionMonth(section:MonthSection):string{return section==='schedule'?this.scheduleMonth():section==='lessons'?this.lessonsMonth():this.payrollMonth();}
+ private setSectionMonth(section:MonthSection,value:string):void{if(section==='schedule')this.scheduleMonth.set(value);else if(section==='lessons')this.lessonsMonth.set(value);else this.payrollMonth.set(value);}
 }

+ 10 - 4
src/app/admin/pages/operations-coaches.component.html

@@ -1,6 +1,6 @@
 <header class="page-header">
   <div><p>COACH OPERATIONS</p><h1>陪练老师</h1><span>查看老师排课、完成课次、工时、评分与预估工资</span></div>
-  <div class="header-actions"><button class="ui-button secondary" type="button" (click)="load(page()?.page||1)" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()"/>刷新</button>@if(page()?.canManage){<button class="ui-button primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17"/>新增陪练</button>}</div>
+  <div class="header-actions"><button class="ui-button secondary" type="button" (click)="load(page()?.page||1,true)" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()"/>刷新实时数据</button>@if(page()?.canManage){<button class="ui-button primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17"/>新增陪练</button>}</div>
 </header>
 <section class="toolbar">
   <label class="grow"><span>搜索老师</span><input [ngModel]="query()" (ngModelChange)="query.set($event)" (keyup.enter)="load(1)" placeholder="姓名、账号、手机号或所属门店"/></label>
@@ -9,10 +9,16 @@
 </section>
 @if(message()){<p class="notice" role="status">{{message()}}</p>}@if(error()&&!drawerOpen()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(page()&&!page()?.liveSourceAvailable&&page()?.liveSourceMessage){<p class="notice warning" role="status">{{page()?.liveSourceMessage}}</p>}
+@if(page()?.summary){<section class="coach-summary" aria-label="陪练老师统计">
+  <article><span>陪练总数</span><strong>{{page()?.summary?.total||0}}</strong><small>按历史排课与授课证据识别</small></article>
+  <article><span>正常账号</span><strong>{{page()?.summary?.active||0}}</strong><small>旧系统账号当前可用</small></article>
+  <article><span>本月有课</span><strong>{{page()?.summary?.monthActive||0}}</strong><small>本月已产生上课记录</small></article>
+  <article><span>已停用</span><strong>{{page()?.summary?.disabled||0}}</strong><small>保留历史排课和工资</small></article>
+</section>}
 <section class="content-card">
-  <header><div><lucide-icon [img]="icons.GraduationCap" [size]="19"/><strong>陪练名单</strong></div><span>{{page()?.total||0}} 位 · {{ page()?.sync?.label || (page()?.liveSourceAvailable?'实时排课已读取':'迁移库备用数据') }}</span></header>
-  <div class="table-scroll"><table class="data-table"><thead><tr><th>老师</th><th>所属门店</th><th>今日排课</th><th>历史排课</th><th>最近排课</th><th>待完成</th><th>本月课次</th><th>本月工时</th><th>预估工资</th><th>平均评分</th><th>最近上课</th><th>状态</th><th>操作</th></tr></thead><tbody>
-    @for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.displayName}}</strong><small>{{row.username?row.username+' · ':''}}ID {{row.userId}} · {{row.mobile||row.mobileMasked||'未留手机号'}}</small><small class="source-note">{{row.sourceLabel||'新系统账号'}}</small></td><td>{{row.storeName||'未绑定'}}</td><td>{{row.todayAppointments}}</td><td><strong>{{row.totalAppointments}}</strong></td><td>{{row.lastAppointmentAt?(row.lastAppointmentAt|date:'yyyy-MM-dd'):'暂无'}}</td><td>{{row.pendingAppointments}}</td><td>{{row.monthLessons}}</td><td>{{row.monthHours|number:'1.1-1'}}h</td><td class="money">¥{{row.estimatedPay|number:'1.2-2'}}</td><td>{{row.averageScore? (row.averageScore|number:'1.1-1'):'暂无'}}</td><td>{{row.lastLessonAt?(row.lastLessonAt|date:'MM-dd HH:mm'):'暂无'}}</td><td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span>@if(!row.availabilityConfigured&&!row.readOnly){<small class="exception">未设置可用时间</small>}</td><td><div class="row-actions">@if(!row.readOnly){<a class="icon-action" [routerLink]="['/admin/coaches',row.objectId]" aria-label="查看陪练详情" title="查看"><lucide-icon [img]="icons.Eye" [size]="16"/></a>@if(page()?.canManage){<button class="icon-action" type="button" (click)="openEdit(row)" aria-label="编辑陪练" title="编辑"><lucide-icon [img]="icons.Pencil" [size]="16"/></button><button class="icon-action danger" type="button" (click)="openEdit(row,true)" aria-label="删除陪练" title="删除"><lucide-icon [img]="icons.Trash2" [size]="16"/></button>}}@else{<span class="sync-only">实时只读</span>}</div></td></tr>}
+  <header><div><lucide-icon [img]="icons.GraduationCap" [size]="19"/><strong>陪练名单</strong></div><span>{{page()?.total||0}} 位 · 完整历史陪练口径 · {{ page()?.sync?.label || (page()?.liveSourceAvailable?'实时账号已读取':'迁移库备用数据') }} · {{page()?.payrollSourceStatus?.mode==='live'?'本月工资已实时同步':'本月工资显示缓存'}}</span></header>
+  <div class="table-scroll"><table class="data-table"><thead><tr><th>老师</th><th>所属门店</th><th>今日排课</th><th>全部排课</th><th>最近/下次排课</th><th>待完成</th><th>本月课次</th><th>本月工时</th><th>预估工资</th><th>平均评分</th><th>最近上课</th><th>状态</th><th>操作</th></tr></thead><tbody>
+    @for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.displayName}}</strong><small>{{row.username?row.username+' · ':''}}ID {{row.userId}} · {{row.mobile||row.mobileMasked||'未留手机号'}}</small><small class="source-note">{{row.sourceLabel||'新系统账号'}}</small></td><td>{{row.storeName||'未绑定'}}</td><td>{{row.todayAppointments}}</td><td><strong>{{row.totalAppointments}}</strong></td><td>@if(row.nextAppointmentAt){<span class="schedule-date future">下次 {{row.nextAppointmentAt|date:'yyyy-MM-dd'}}</span>}@else if(row.lastAppointmentAt){<span class="schedule-date">最近 {{row.lastAppointmentAt|date:'yyyy-MM-dd'}}</span>}@else{暂无}</td><td>{{row.pendingAppointments}}</td><td>{{row.monthLessons}}</td><td>{{row.monthHours|number:'1.1-1'}}h</td><td class="money">¥{{row.estimatedPay|number:'1.2-2'}}</td><td>{{row.averageScore? (row.averageScore|number:'1.1-1'):'暂无'}}</td><td>{{row.lastLessonAt?(row.lastLessonAt|date:'MM-dd HH:mm'):'暂无'}}</td><td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span>@if(!row.availabilityConfigured&&!row.readOnly){<small class="exception">未设置可用时间</small>}</td><td><div class="row-actions"><a class="icon-action" [routerLink]="['/admin/coaches',row.objectId]" aria-label="查看陪练详情" title="查看"><lucide-icon [img]="icons.Eye" [size]="16"/></a>@if(!row.readOnly&&page()?.canManage){<button class="icon-action" type="button" (click)="openEdit(row)" aria-label="编辑陪练" title="编辑"><lucide-icon [img]="icons.Pencil" [size]="16"/></button><button class="icon-action danger" type="button" (click)="openEdit(row,true)" aria-label="删除陪练" title="删除"><lucide-icon [img]="icons.Trash2" [size]="16"/></button>}@if(row.readOnly){<span class="sync-only">详情只读</span>}</div></td></tr>}
     @empty{<tr><td colspan="13"><div class="empty">没有符合条件的陪练老师</div></td></tr>}
   </tbody></table></div>
   <footer class="pagination"><span>第 {{page()?.page||1}} / {{pageCount()}} 页</span><div><button type="button" [disabled]="(page()?.page||1)<=1" (click)="load((page()?.page||1)-1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17"/></button><button type="button" [disabled]="(page()?.page||1)>=pageCount()" (click)="load((page()?.page||1)+1)"><lucide-icon [img]="icons.ChevronRight" [size]="17"/></button></div></footer>

+ 60 - 0
src/app/admin/pages/operations-coaches.component.scss

@@ -2,6 +2,47 @@
   --control-height: 42px;
 }
 
+.coach-summary {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 14px;
+  margin: 18px 0;
+}
+
+.coach-summary article {
+  display: grid;
+  gap: 7px;
+  min-width: 0;
+  padding: 18px 20px;
+  border: 1px solid #dfe8e4;
+  border-radius: 16px;
+  background: #fff;
+  box-shadow: 0 8px 24px rgba(30, 70, 57, .045);
+}
+
+.coach-summary span,
+.coach-summary small {
+  color: #74847e;
+}
+
+.coach-summary span {
+  font-size: 13px;
+  font-weight: 750;
+}
+
+.coach-summary strong {
+  color: #1d382f;
+  font-size: 29px;
+  line-height: 1;
+}
+
+.coach-summary small {
+  overflow: hidden;
+  font-size: 11px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .data-table {
   min-width: 1460px;
 }
@@ -83,6 +124,16 @@
   white-space: nowrap;
 }
 
+.schedule-date {
+  color: #61716b;
+  white-space: nowrap;
+}
+
+.schedule-date.future {
+  color: #a46b12;
+  font-weight: 750;
+}
+
 .delete-warning {
   margin: 18px 0 0;
   padding: 12px 14px;
@@ -99,6 +150,15 @@
 }
 
 @media (max-width: 700px) {
+  .coach-summary {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 10px;
+  }
+
+  .coach-summary article {
+    padding: 15px;
+  }
+
   .coach-editor {
     padding: 20px 16px;
   }

+ 3 - 3
src/app/admin/pages/operations-coaches.component.ts

@@ -11,11 +11,11 @@ export class OperationsCoachesComponent implements OnInit{
  readonly page=signal<CoachPage|null>(null);readonly query=signal('');readonly status=signal('all');readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly drawerOpen=signal(false);readonly editing=signal<CoachSummary|null>(null);readonly deleteArmed=signal(false);
  readonly username=signal('');readonly password=signal('');readonly displayName=signal('');readonly mobile=signal('');readonly parentUserId=signal(0);readonly disabled=signal(false);readonly reason=signal('');
  readonly icons={ChevronLeft,ChevronRight,Eye,GraduationCap,Pencil,Plus,RefreshCw,Search,Trash2,X};private readonly operations=inject(OperationsService);
- ngOnInit():void{void this.load(1);}async load(page=1):Promise<void>{this.loading.set(true);this.error.set('');try{this.page.set(await this.operations.coaches(page,20,this.query(),{status:this.status()}));}catch(error){this.error.set(error instanceof Error?error.message:'陪练老师加载失败');}finally{this.loading.set(false);}}
+ ngOnInit():void{void this.load(1);}async load(page=1,refresh=false):Promise<void>{this.loading.set(true);this.error.set('');try{this.page.set(await this.operations.coaches(page,20,this.query(),{status:this.status()},refresh));}catch(error){this.error.set(error instanceof Error?error.message:'陪练老师加载失败');}finally{this.loading.set(false);}}
  reset():void{this.query.set('');this.status.set('all');void this.load(1);}pageCount():number{return Math.max(1,Math.ceil((this.page()?.total||0)/(this.page()?.pageSize||20)));}
  openCreate():void{this.editing.set(null);this.username.set('');this.password.set('');this.displayName.set('');this.mobile.set('');this.parentUserId.set(0);this.disabled.set(false);this.reason.set('新增陪练老师');this.deleteArmed.set(false);this.error.set('');this.drawerOpen.set(true);}
  openEdit(row:CoachSummary,deleteMode=false):void{this.editing.set(row);this.username.set(row.username);this.password.set('');this.displayName.set(row.displayName);this.mobile.set(row.mobile||'');this.parentUserId.set(row.parentUserId||0);this.disabled.set(row.status==='disabled');this.reason.set('');this.deleteArmed.set(deleteMode);this.error.set('');this.drawerOpen.set(true);}
  closeDrawer():void{this.drawerOpen.set(false);this.deleteArmed.set(false);}
- async save():Promise<void>{const editing=this.editing();if(!this.username().trim()||!this.displayName().trim()||!this.reason().trim()){this.error.set('登录账号、老师姓名和操作原因必填');return;}if(!editing&&!this.password()){this.error.set('新增老师必须设置初始密码');return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate(editing?'ops/coaches/update':'ops/coaches/create',editing?.objectId||'',this.reason(),{username:this.username(),password:this.password(),displayName:this.displayName(),mobile:this.mobile(),parentUserId:this.parentUserId(),disabled:this.disabled()},editing?.updatedAt||'');this.message.set(editing?'陪练资料已更新':'陪练老师已新增');this.closeDrawer();await this.load(editing?this.page()?.page||1:1);}catch(error){this.error.set(error instanceof Error?error.message:'陪练资料保存失败');}finally{this.saving.set(false);}}
- async remove():Promise<void>{const editing=this.editing();if(!editing)return;if(!this.reason().trim()){this.error.set('删除前必须填写操作原因');return;}if(!this.deleteArmed()){this.deleteArmed.set(true);return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/coaches/delete',editing.objectId,this.reason(),{},editing.updatedAt);this.message.set('陪练老师已删除;历史排课和工资记录仍保留');this.closeDrawer();await this.load(1);}catch(error){this.error.set(error instanceof Error?error.message:'删除失败');}finally{this.saving.set(false);}}
+ async save():Promise<void>{const editing=this.editing();if(!this.username().trim()||!this.displayName().trim()||!this.reason().trim()){this.error.set('登录账号、老师姓名和操作原因必填');return;}if(!editing&&!this.password()){this.error.set('新增老师必须设置初始密码');return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate(editing?'ops/coaches/update':'ops/coaches/create',editing?.objectId||'',this.reason(),{username:this.username(),password:this.password(),displayName:this.displayName(),mobile:this.mobile(),parentUserId:this.parentUserId(),disabled:this.disabled()},editing?.updatedAt||'');this.message.set(editing?'陪练资料已更新':'陪练老师已新增');this.closeDrawer();await this.load(editing?this.page()?.page||1:1,true);}catch(error){this.error.set(error instanceof Error?error.message:'陪练资料保存失败');}finally{this.saving.set(false);}}
+ async remove():Promise<void>{const editing=this.editing();if(!editing)return;if(!this.reason().trim()){this.error.set('删除前必须填写操作原因');return;}if(!this.deleteArmed()){this.deleteArmed.set(true);return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/coaches/delete',editing.objectId,this.reason(),{},editing.updatedAt);this.message.set('陪练老师已删除;历史排课和工资记录仍保留');this.closeDrawer();await this.load(1,true);}catch(error){this.error.set(error instanceof Error?error.message:'删除失败');}finally{this.saving.set(false);}}
 }

+ 1 - 1
src/app/admin/pages/operations-payroll.component.ts

@@ -13,7 +13,7 @@ const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.g
 export class OperationsPayrollComponent implements OnInit{
  readonly overview=signal<PayrollOverview|null>(null);readonly page=signal<OperationsPage<PayrollSummary>|null>(null);readonly detail=signal<PayrollDetail|null>(null);readonly rates=signal<PayRate[]>([]);readonly loading=signal(true);readonly refreshing=signal(false);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'overview'|'batches'|'rates'>('overview');readonly month=signal(localDateKey(new Date()).slice(0,7));readonly storeId=signal(0);readonly search=signal('');readonly selectedCoachId=signal(0);readonly selectedCoachName=signal('');readonly rateType=signal(1);readonly rateAmount=signal(20);readonly rateHours=signal(.5);readonly effectiveFrom=signal(localDateKey(new Date()));readonly adjustmentCoachId=signal(0);readonly adjustmentAmount=signal(0);readonly adjustmentReason=signal('');readonly paymentMethod=signal('银行转账');readonly paymentReference=signal('');readonly icons={Banknote,Download,Eye,FileCheck2,Plus,RefreshCw,Search,Settings2,TrendingUp,X};readonly coachLines=computed(()=>{const coachId=this.selectedCoachId(),coachName=this.selectedCoachName(),source=this.detail()?.lines||this.overview()?.lines||[];return coachId?source.filter((line)=>line.coachId===coachId).map((line)=>({...line,coachName:coachName||line.coachName})):source;});private readonly operations=inject(OperationsService);private readonly sessions=inject(AdminSessionService);
  ngOnInit():void{void this.load(true);}isManager():boolean{return this.sessions.session()?.operationsRole==='ops-manager'||this.sessions.session()?.isSuperAdmin===true;}
- async load(refreshAfterLoad=false):Promise<void>{this.loading.set(true);this.error.set('');try{const [overview,page,rates]=await Promise.all([this.operations.payrollOverview(this.month(),this.storeId(),this.search(),false),this.operations.payrolls(),this.operations.payRates()]);this.overview.set(overview);this.page.set(page);this.rates.set(rates.items);}catch(error){this.error.set(error instanceof Error?error.message:'工资数据加载失败');}finally{this.loading.set(false);}if(refreshAfterLoad)void this.refreshLive();}
+ async load(refresh=false):Promise<void>{this.loading.set(true);this.error.set('');try{const [overview,page,rates]=await Promise.all([this.operations.payrollOverview(this.month(),this.storeId(),this.search(),refresh),this.operations.payrolls(),this.operations.payRates()]);this.overview.set(overview);this.page.set(page);this.rates.set(rates.items);}catch(error){this.error.set(error instanceof Error?error.message:'工资数据加载失败');}finally{this.loading.set(false);}}
  async refreshLive():Promise<void>{if(this.refreshing())return;this.refreshing.set(true);this.error.set('');try{this.overview.set(await this.operations.payrollOverview(this.month(),this.storeId(),this.search(),true));this.message.set('实时上课记录已刷新');}catch(error){this.error.set(error instanceof Error?error.message:'实时工资数据刷新失败,当前继续显示缓存');}finally{this.refreshing.set(false);}}
  async applyFilters():Promise<void>{await this.load(true);}
  async open(row:PayrollSummary):Promise<void>{this.loading.set(true);this.error.set('');try{this.selectedCoachId.set(0);this.detail.set(await this.operations.payrollDetail(row.objectId));}catch(error){this.error.set(error instanceof Error?error.message:'工资单加载失败');}finally{this.loading.set(false);}}

+ 69 - 0
src/app/admin/pages/operations-schedule-status.ts

@@ -0,0 +1,69 @@
+import { ScheduleAppointment } from '../admin.models';
+
+export type ScheduleStatusTone =
+  | 'status-upcoming'
+  | 'status-live'
+  | 'status-coach-ended'
+  | 'status-confirmed'
+  | 'status-completed'
+  | 'status-archived'
+  | 'status-overdue'
+  | 'status-cancelled'
+  | 'status-neutral';
+
+export type ScheduleStatusFilter =
+  | 'all'
+  | 'upcoming'
+  | 'live'
+  | 'coach-ended'
+  | 'confirmed'
+  | 'completed'
+  | 'archived'
+  | 'overdue'
+  | 'cancelled'
+  | 'unassigned';
+
+const scheduleStatusLabels: Partial<Record<number, string>> = {
+  0: '待开始',
+  10: '上课中',
+  11: '陪练已结束',
+  20: '学员已确认',
+  30: '已完课',
+  40: '已归档',
+};
+
+export function scheduleStatusTone(
+  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'endsAt'>,
+  now = Date.now(),
+): ScheduleStatusTone {
+  if (item.cancelled) return 'status-cancelled';
+  const endTime = Date.parse(item.endsAt);
+  if ((item.statusCode === 0 || item.statusCode === 10) && Number.isFinite(endTime) && endTime < now) return 'status-overdue';
+  if (item.statusCode === 0) return 'status-upcoming';
+  if (item.statusCode === 10) return 'status-live';
+  if (item.statusCode === 11) return 'status-coach-ended';
+  if (item.statusCode === 20) return 'status-confirmed';
+  if (item.statusCode === 30) return 'status-completed';
+  if (item.statusCode === 40) return 'status-archived';
+  return 'status-neutral';
+}
+
+export function scheduleStatusLabel(
+  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'statusLabel' | 'endsAt'>,
+  now = Date.now(),
+): string {
+  const tone = scheduleStatusTone(item, now);
+  if (tone === 'status-cancelled') return '已取消';
+  if (tone === 'status-overdue') return '逾期未完成';
+  return scheduleStatusLabels[item.statusCode] || item.statusLabel || `状态 ${item.statusCode}`;
+}
+
+export function scheduleMatchesStatus(
+  item: Pick<ScheduleAppointment, 'cancelled' | 'statusCode' | 'endsAt' | 'coachId' | 'coachAccountMissing'>,
+  filter: ScheduleStatusFilter,
+  now = Date.now(),
+): boolean {
+  if (filter === 'all') return true;
+  if (filter === 'unassigned') return !item.coachId || item.coachAccountMissing === true;
+  return scheduleStatusTone(item, now) === `status-${filter}`;
+}

+ 50 - 23
src/app/admin/pages/operations-schedule.component.html

@@ -19,10 +19,30 @@
   @if(data()?.dataDateTo){<button class="latest-data" type="button" (click)="showLatest()">最近有数据:{{data()?.dataDateTo}}</button>}
 </section>
 
+<section class="schedule-filterbar" aria-label="排课记录筛选">
+  <label class="schedule-search"><span>搜索排课</span><span class="filter-control"><lucide-icon [img]="icons.Search" [size]="16"/><input type="search" [ngModel]="scheduleSearch()" (ngModelChange)="scheduleSearch.set($event)" placeholder="学员、陪练、课程或业务ID"/></span></label>
+  <label class="status-filter"><span>业务状态</span><select [ngModel]="statusFilter()" (ngModelChange)="statusFilter.set($event)"><option value="all">全部状态</option><option value="upcoming">待开始</option><option value="live">上课中</option><option value="coach-ended">陪练已结束</option><option value="confirmed">学员已确认</option><option value="completed">已完课</option><option value="archived">已归档</option><option value="overdue">逾期未完成</option><option value="cancelled">已取消</option><option value="unassigned">待分配 / 账号异常</option></select></label>
+  <div class="filter-result" role="status"><strong>{{visibleAppointments().length}}</strong><span>条筛选结果</span><small>当前范围共 {{data()?.appointments?.length||0}} 条</small></div>
+  @if(hasScheduleFilters()){<button type="button" (click)="clearScheduleFilters()">清除筛选</button>}
+</section>
+
 @if(message()){<p class="notice" role="status">{{message()}}</p>}
 @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(data()&&!data()?.liveSourceAvailable){<p class="notice error" role="status">{{data()?.liveSourceMessage}}</p>}
 
+<section class="schedule-workflow" aria-label="预约到完课业务流程">
+  <header>
+    <div><strong>预约到完课流程</strong><span>按原系统真实操作职责梳理</span></div>
+    <small>学员端负责查看、确认和评价,不直接创建预约</small>
+  </header>
+  <ol>
+    <li><i>1</i><div><strong>运营创建预约</strong><span>选择学员已绑定课程、时间和陪练</span></div><em>状态 0</em></li>
+    <li><i>2</i><div><strong>陪练开始并结束</strong><span>教师端开始上课,结束后提交课堂结果</span></div><em>10 → 11</em></li>
+    <li><i>3</i><div><strong>学员确认评价</strong><span>学员端确认完成并提交评分与评价</span></div><em>状态 20</em></li>
+    <li><i>4</i><div><strong>陪练生成学习报告</strong><span>学员确认后由陪练提交点评;工资仍按上课记录而非预约计算</span></div><em>状态 30</em></li>
+  </ol>
+</section>
+
 <section class="summary-grid">
   <article class="summary-card"><span>当前范围课程</span><strong>{{activeAppointments()}}</strong><small>{{range().from}} 至 {{range().to}}</small></article>
   <article class="summary-card"><span>待分配</span><strong>{{unassignedCount()}}</strong><small>未分配老师时不能开始</small></article>
@@ -40,20 +60,21 @@
       <span class="status-coach-ended"><i></i>陪练已结束</span>
       <span class="status-confirmed"><i></i>学员已确认</span>
       <span class="status-completed"><i></i>已完课</span>
+      <span class="status-archived"><i></i>已归档</span>
       <span class="status-overdue"><i></i>逾期未完成</span>
       <span class="status-cancelled"><i></i>已取消</span>
     </div>
   </header>
   @if(view()==='list'){
     <div class="table-scroll"><table class="data-table"><thead><tr><th>时间</th><th>会员</th><th>陪练</th><th>课程</th><th>课型</th><th>状态</th><th>操作</th></tr></thead><tbody>
-      @for(item of data()?.appointments||[];track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}@if(item.readOnly){<small class="live-source">实时数据</small>}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions">@if(item.readOnly){<span class="read-only-label">原业务端维护</span>}@else{<button type="button" (click)="openEdit(item)" [disabled]="item.statusCode>0||item.cancelled">修改</button>@if(item.cancelled){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@else{<button type="button" (click)="action(item,'ops/appointments/cancel')">取消</button><button type="button" (click)="action(item,'ops/appointments/complete')">完成</button>}}</div></td></tr>}
-      @empty{<tr><td colspan="7"><div class="empty">当前范围暂无排课;历史数据范围为 {{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</div></td></tr>}
+      @for(item of visibleAppointments();track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}@if(item.readOnly){<small class="live-source">实时数据</small>}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions">@if(item.readOnly){<span class="read-only-label">原业务端维护</span>}@else{@if(rowActions(item);as actions){@if(actions.canEdit){<button type="button" (click)="openEdit(item)">修改</button>}@if(actions.canRestore){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@if(actions.canCancel){<button type="button" (click)="action(item,'ops/appointments/cancel')">取消</button>}@if(actions.canComplete){<button type="button" (click)="action(item,'ops/appointments/complete')">{{actions.completeLabel}}</button>}@if(!actions.canEdit&&!actions.canRestore&&!actions.canCancel&&!actions.canComplete){<span class="read-only-label">按业务端状态流转</span>}}}</div></td></tr>}
+      @empty{<tr><td colspan="7"><div class="empty">{{hasScheduleFilters()?'没有符合筛选条件的排课':'当前范围暂无排课;历史数据范围为 '+(data()?.dataDateFrom||'—')+' 至 '+(data()?.dataDateTo||'—')}}</div></td></tr>}
     </tbody></table></div>
   } @else {
     <div class="table-scroll"><div class="calendar">
-      @for(coach of data()?.coaches||[];track coach.userId){<div class="calendar-row"><div class="coach-label"><strong>{{coach.displayName}}</strong><span>当前范围 {{coach.todayAppointments}} 节</span>@if(!coach.availabilityConfigured){<span class="exception">未设置可用时间</span>}</div><div class="appointment-strip">@for(item of data()?.appointments||[];track item.targetId){@if(item.coachId===coach.userId){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.courseName}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}@if(item.readOnly){ · 实时}</small></button>}}</div></div>}
+      @for(coach of data()?.coaches||[];track coach.userId){<div class="calendar-row"><div class="coach-label"><strong>{{coach.displayName}}</strong><span>筛选结果 {{visibleCoachCount(coach.userId)}} 节</span>@if(!coach.availabilityConfigured){<span class="exception">未设置可用时间</span>}</div><div class="appointment-strip">@for(item of visibleAppointments();track item.targetId){@if(item.coachId===coach.userId){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.courseName}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}@if(item.readOnly){ · 实时}</small></button>}}</div></div>}
       @empty{<div class="empty">暂无陪练资料</div>}
-      @if(unassignedCount()){<div class="calendar-row"><div class="coach-label"><strong>待分配 / 历史账号</strong><span>{{unassignedCount()}} 节</span></div><div class="appointment-strip">@for(item of data()?.unassigned||[];track item.targetId){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.coachAccountMissing?item.coachName:'未分配陪练'}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}</small></button>}</div></div>}
+      @if(unassignedCount()){<div class="calendar-row"><div class="coach-label"><strong>待分配 / 历史账号</strong><span>{{unassignedCount()}} 节</span></div><div class="appointment-strip">@for(item of visibleAppointments();track item.targetId){@if(!item.coachId||item.coachAccountMissing){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.coachAccountMissing?item.coachName:'未分配陪练'}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}</small></button>}}</div></div>}
     </div></div>
   }
 </section>
@@ -62,9 +83,10 @@
   <div class="backdrop" (click)="closeDrawer()"></div>
   <aside class="drawer" role="dialog" aria-modal="true" aria-label="排课编辑">
     <header><div><p>SCHEDULE FORM</p><h2>{{editing()?'修改排课':'新增排课'}}</h2><span>搜索并确认学员、课程和陪练,再检查时间冲突</span></div><button class="icon-button" type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
-    <div class="segmented"><button type="button" [class.active]="mode()==='coach'" (click)="mode.set('coach')">陪练优先</button><button type="button" [class.active]="mode()==='time'" (click)="mode.set('time')">时间优先</button></div>
-    <div class="form-grid">
-      <label class="search-field">
+    <div class="segmented"><button type="button" [class.active]="mode()==='coach'" (click)="setMode('coach')">陪练优先</button><button type="button" [class.active]="mode()==='time'" (click)="setMode('time')">时间优先</button></div>
+    <p class="mode-guidance"><strong>{{mode()==='coach'?'先确定老师':'先确定课程时间'}}</strong><span>{{mode()==='coach'?'先选日期、课型和陪练,再补充学员及其已绑定课程':'先选学员、课程和上课时间,陪练候选会把当前时段无冲突的老师排在前面'}}</span></p>
+    <div class="form-grid" [class.coach-first]="mode()==='coach'" [class.time-first]="mode()==='time'">
+      <label class="search-field member-step">
         <span>选择学员 <small>可搜索姓名、手机号或用户ID</small></span>
         <span class="search-control" [class.selected]="selectedMemberObjectId()">
           <lucide-icon [img]="icons.Search" [size]="17" />
@@ -74,31 +96,31 @@
         @if(activeSearch()==='member'){
           <span class="search-results" id="member-search-results" role="listbox">
             @for(member of filteredMembers();track member.objectId){
-              <button type="button" class="search-option" role="option" [attr.aria-selected]="selectedMemberObjectId()===member.objectId" (click)="chooseMember(member)"><span><strong>{{member.displayName}}</strong><small>{{member.mobileMasked}}@if(member.userId){ · ID {{member.userId}}}</small></span><em>{{member.learningStatusLabel}}</em></button>
+              <button type="button" class="search-option" role="option" [class.read-only]="member.readOnly" [attr.aria-disabled]="member.readOnly" [attr.aria-selected]="selectedMemberObjectId()===member.objectId" (click)="chooseMember(member)"><span><strong>{{member.displayName}}</strong><small>{{member.mobileMasked}}@if(member.userId){ · ID {{member.userId}}}@if(member.readOnly){ · 同步中,仅查看}</small></span><em>{{member.learningStatusLabel}}</em></button>
             } @empty {<span class="search-empty">没有匹配的学员,请检查姓名或手机号</span>}
           </span>
         }
       </label>
-      <label class="search-field">
-        <span>选择课程 <small>支持课程名称模糊匹配</small></span>
+      <label class="search-field course-step">
+        <span>选择课程 <small>仅显示当前学员已绑定课程,支持模糊匹配</small></span>
         <span class="search-control" [class.selected]="courseId()">
           <lucide-icon [img]="icons.Search" [size]="17" />
-          <input type="search" autocomplete="off" placeholder="输入课程名称关键词" [ngModel]="courseSearch()" (ngModelChange)="updateCourseSearch($event)" (focus)="openSearch('course')" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='course'" aria-controls="course-search-results" />
+          <input type="search" autocomplete="off" [disabled]="!studentId()" [placeholder]="studentId()?'输入课程名称关键词':'请先选择学员'" [ngModel]="courseSearch()" (ngModelChange)="updateCourseSearch($event)" (focus)="openSearch('course')" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='course'" aria-controls="course-search-results" />
           @if(courseId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
         </span>
         @if(activeSearch()==='course'){
           <span class="search-results" id="course-search-results" role="listbox">
             @for(course of filteredCourses();track course.courseId){
               <button type="button" class="search-option" role="option" [attr.aria-selected]="courseId()===course.courseId" (click)="chooseCourse(course)"><span><strong>{{course.courseName}}</strong><small>课程 ID {{course.courseId}}</small></span></button>
-            } @empty {<span class="search-empty">没有匹配的课程,请更换关键词</span>}
+            } @empty {<span class="search-empty">{{studentId()?'该学员没有匹配的已绑定课程,请先在会员详情绑定课程':'请先选择学员,再选择其已绑定课程'}}</span>}
           </span>
         }
       </label>
-      <label>课程类型<select [ngModel]="classType()" (ngModelChange)="classType.set(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">陪练</option></select></label>
-      <label>交付方式<select [ngModel]="deliveryMethod()" (ngModelChange)="deliveryMethod.set($event)"><option>线上</option><option>线下</option></select></label>
-      <label>日期<input type="date" [ngModel]="date()" (ngModelChange)="date.set($event)" /></label>
-      <label>开始时间<input type="time" step="1800" [ngModel]="startTime()" (ngModelChange)="startTime.set($event)" /></label>
-      <label class="search-field wide">
+      <label class="type-step">课程类型<select [ngModel]="classType()" (ngModelChange)="updateClassType(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">陪练</option></select></label>
+      <label class="delivery-step">交付方式<select [ngModel]="deliveryMethod()" (ngModelChange)="deliveryMethod.set($event);preview.set(null)"><option>线上</option><option>线下</option></select></label>
+      <label class="date-step">日期<input type="date" [ngModel]="date()" (ngModelChange)="updateDraftDate($event)" /></label>
+      <label class="time-step">开始时间<input type="time" step="1800" [ngModel]="startTime()" (ngModelChange)="updateStartTime($event)" /></label>
+      <label class="search-field wide coach-step">
         <span>选择陪练老师 <small>可搜索老师姓名或手机号码</small></span>
         <span class="search-control" [class.selected]="coachId()">
           <lucide-icon [img]="icons.Search" [size]="17" />
@@ -109,18 +131,23 @@
           <span class="search-results" id="coach-search-results" role="listbox">
             <button type="button" class="search-option muted-option" role="option" [attr.aria-selected]="!coachId()" (click)="clearCoach()"><span><strong>暂不分配</strong><small>保存为待分配预约</small></span></button>
             @for(coach of filteredCoaches();track coach.userId){
-              <button type="button" class="search-option" role="option" [attr.aria-selected]="coachId()===coach.userId" (click)="chooseCoach(coach)"><span><strong>{{coach.displayName}}</strong><small>{{coach.mobileMasked||'未登记手机号'}} · ID {{coach.userId}}</small></span><em>{{coach.availabilityConfigured?'已设置时间':'未设置时间'}}</em></button>
+              <button type="button" class="search-option" [class.busy-option]="coachBusyAtSelectedTime(coach.userId)" role="option" [attr.aria-selected]="coachId()===coach.userId" (click)="chooseCoach(coach)"><span><strong>{{coach.displayName}}</strong><small>{{coach.mobileMasked||'未登记手机号'}} · ID {{coach.userId}}</small></span><em>{{coachCandidateHint(coach)}}</em></button>
             } @empty {<span class="search-empty">没有匹配的陪练老师,请检查姓名或手机号</span>}
           </span>
         }
       </label>
       @if(!editing()){
-        <label>重复方式<select [ngModel]="recurrence()" (ngModelChange)="recurrence.set($event)"><option value="once">单次</option><option value="daily">每天</option><option value="alternate">隔天</option><option value="weekly">每周</option><option value="same_day_30">同日间隔30分钟</option><option value="same_day_60">同日间隔60分钟</option></select></label>
-        <label>排课次数<input type="number" min="1" max="60" [ngModel]="occurrences()" (ngModelChange)="occurrences.set(+$event)" /></label>
+        <label class="recurrence-step">重复方式<select [ngModel]="recurrence()" (ngModelChange)="recurrence.set($event);preview.set(null)"><option value="once">单次</option><option value="daily">每天</option><option value="alternate">隔天</option><option value="weekly">每周</option><option value="same_day_30">同日间隔30分钟</option><option value="same_day_60">同日间隔60分钟</option></select></label>
+        <label class="occurrences-step">排课次数<input type="number" min="1" max="60" [ngModel]="occurrences()" (ngModelChange)="occurrences.set(+$event);preview.set(null)" /></label>
       }
-      <label class="wide">操作原因<textarea rows="2" [ngModel]="reason()" (ngModelChange)="reason.set($event)"></textarea></label>
+      <label class="wide reason-step">操作原因<textarea rows="2" [ngModel]="reason()" (ngModelChange)="reason.set($event)"></textarea></label>
     </div>
-    @if(preview();as result){<p class="notice" [class.error]="previewConflictCount()>0">预检完成:可排 {{result['availableCount']||0}} 节,冲突 {{result['conflictCount']||0}} 节。{{result['warnings']||''}}</p>}
-    <footer><button type="button" (click)="preflight()">检查冲突</button><button class="primary" type="button" (click)="save()" [disabled]="saving()">{{saving()?'保存中…':'确认保存'}}</button></footer>
+    @if(preview();as result){
+      <p class="notice" [class.error]="previewConflictCount()>0">预检完成:可排 {{result['availableCount']||0}} 节,冲突 {{result['conflictCount']||0}} 节。@if(result['issueCount']&&result['issueCount']!==result['conflictCount']){共发现 {{result['issueCount']}} 个冲突原因。}{{result['warnings']||''}}</p>
+      <div class="preflight-results" aria-label="批量排课逐项预检结果">
+        @for(item of previewCandidates();track item.index){<article [class.conflict]="!item.available"><i>{{item.index+1}}</i><div><strong>{{item.startsAt|date:'yyyy-MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</strong><span>{{item.available?'可以排课':item.reasons.join(';')}}</span></div><em>{{item.available?'可提交':'需调整'}}</em></article>}
+      </div>
+    }
+    <footer><button type="button" (click)="preflight()" [disabled]="saving()">检查冲突</button><button class="primary" type="button" (click)="save()" [disabled]="saving()||!canSavePreview()">{{saveButtonLabel()}}</button></footer>
   </aside>
 }

+ 433 - 0
src/app/admin/pages/operations-schedule.component.scss

@@ -96,6 +96,111 @@ textarea:focus-visible {
   white-space: nowrap;
 }
 
+.schedule-filterbar {
+  display: flex;
+  align-items: flex-end;
+  gap: 12px;
+  margin: -4px 0 18px;
+  padding: 13px 15px;
+  border: 1px solid var(--schedule-border);
+  border-radius: 15px;
+  background: #fff;
+}
+
+.schedule-filterbar label {
+  display: grid;
+  gap: 6px;
+  color: #657770;
+  font-size: 11px;
+  font-weight: 800;
+}
+
+.schedule-search {
+  flex: 1 1 360px;
+  max-width: 520px;
+}
+
+.status-filter {
+  flex: 0 0 210px;
+}
+
+.filter-control {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  height: 42px;
+  padding: 0 12px;
+  border: 1px solid #d7e3df;
+  border-radius: 12px;
+  color: #74847e;
+  background: #f9fbfa;
+}
+
+.filter-control:focus-within {
+  border-color: #7cbba6;
+  box-shadow: 0 0 0 3px rgba(31, 130, 98, .1);
+}
+
+.filter-control input {
+  width: 100%;
+  min-width: 0;
+  border: 0;
+  outline: 0;
+  color: #293d36;
+  background: transparent;
+  font: inherit;
+  font-size: 13px;
+}
+
+.status-filter select {
+  height: 42px;
+  min-width: 0;
+  padding: 0 34px 0 12px;
+  border: 1px solid #d7e3df;
+  border-radius: 12px;
+  color: #293d36;
+  background-color: #f9fbfa;
+  font: inherit;
+  font-size: 13px;
+}
+
+.filter-result {
+  display: grid;
+  grid-template-columns: auto auto;
+  align-items: baseline;
+  gap: 0 6px;
+  min-width: 130px;
+  padding: 5px 2px;
+  color: #53665f;
+}
+
+.filter-result strong {
+  color: #176c55;
+  font-size: 22px;
+}
+
+.filter-result span {
+  font-size: 12px;
+  font-weight: 800;
+}
+
+.filter-result small {
+  grid-column: 1 / -1;
+  color: #8a9893;
+  font-size: 10px;
+}
+
+.schedule-filterbar > button {
+  height: 42px;
+  padding: 0 14px;
+  border: 1px solid #d7e3df;
+  border-radius: 12px;
+  color: #496159;
+  background: #fff;
+  font-weight: 750;
+  white-space: nowrap;
+}
+
 @media (min-width: 1001px) {
   .toolbar > .segmented {
     width: 210px;
@@ -131,6 +236,112 @@ textarea:focus-visible {
   grid-template-columns: repeat(5, minmax(0, 1fr));
 }
 
+.schedule-workflow {
+  overflow: hidden;
+  margin-bottom: 18px;
+  border: 1px solid var(--schedule-border);
+  border-radius: 18px;
+  background: #fff;
+  box-shadow: 0 8px 24px rgba(33, 71, 59, .05);
+}
+
+.schedule-workflow > header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 15px 18px;
+  border-bottom: 1px solid #e8efec;
+  background: linear-gradient(90deg, #f4faf7, #fff);
+}
+
+.schedule-workflow > header div {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+}
+
+.schedule-workflow > header strong {
+  color: #243c34;
+  font-size: 15px;
+}
+
+.schedule-workflow > header span,
+.schedule-workflow > header small {
+  color: var(--schedule-text-muted);
+  font-size: 12px;
+}
+
+.schedule-workflow ol {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.schedule-workflow li {
+  position: relative;
+  display: grid;
+  grid-template-columns: 30px minmax(0, 1fr);
+  gap: 10px;
+  min-height: 88px;
+  padding: 16px 38px 14px 16px;
+  border-right: 1px solid #edf2f0;
+}
+
+.schedule-workflow li:last-child {
+  border-right: 0;
+}
+
+.schedule-workflow li:not(:last-child)::after {
+  content: '›';
+  position: absolute;
+  top: 29px;
+  right: 13px;
+  color: #97aaa3;
+  font-size: 24px;
+  line-height: 1;
+}
+
+.schedule-workflow li > i {
+  display: inline-grid;
+  place-items: center;
+  width: 30px;
+  height: 30px;
+  border-radius: 10px;
+  background: var(--schedule-green-soft);
+  color: var(--schedule-green);
+  font-size: 12px;
+  font-style: normal;
+  font-weight: 850;
+}
+
+.schedule-workflow li div {
+  display: grid;
+  align-content: start;
+  gap: 4px;
+}
+
+.schedule-workflow li strong {
+  color: #2d433b;
+  font-size: 13px;
+}
+
+.schedule-workflow li span {
+  color: #74857f;
+  font-size: 11px;
+  line-height: 1.5;
+}
+
+.schedule-workflow li em {
+  grid-column: 2;
+  color: #238262;
+  font-size: 10px;
+  font-style: normal;
+  font-weight: 800;
+}
+
 .calendar {
   gap: 8px;
 }
@@ -244,6 +455,12 @@ textarea:focus-visible {
   --status-border: #dbe3ec;
 }
 
+.status-archived {
+  --status-color: #475569;
+  --status-bg: #eef2f6;
+  --status-border: #cbd5e1;
+}
+
 .status-overdue {
   --status-color: #dc2626;
   --status-bg: #fff1f2;
@@ -267,6 +484,7 @@ textarea:focus-visible {
 .appointment.status-coach-ended,
 .appointment.status-confirmed,
 .appointment.status-completed,
+.appointment.status-archived,
 .appointment.status-overdue,
 .appointment.status-cancelled,
 .appointment.status-neutral {
@@ -336,10 +554,53 @@ textarea:focus-visible {
   font-weight: 700;
 }
 
+.mode-guidance {
+  display: grid;
+  gap: 4px;
+  margin: -8px 0 18px;
+  padding: 11px 13px;
+  border: 1px solid #dceae5;
+  border-radius: 13px;
+  background: #f0f8f5;
+}
+
+.mode-guidance strong {
+  color: #276b56;
+  font-size: 12px;
+}
+
+.mode-guidance span {
+  color: #6c7f78;
+  font-size: 11px;
+  line-height: 1.45;
+}
+
 .form-grid {
   gap: 17px 14px;
 }
 
+.form-grid.coach-first .date-step { order: 1; }
+.form-grid.coach-first .time-step { order: 2; }
+.form-grid.coach-first .type-step { order: 3; }
+.form-grid.coach-first .delivery-step { order: 4; }
+.form-grid.coach-first .coach-step { order: 5; }
+.form-grid.coach-first .member-step { order: 6; }
+.form-grid.coach-first .course-step { order: 7; }
+.form-grid.coach-first .recurrence-step { order: 8; }
+.form-grid.coach-first .occurrences-step { order: 9; }
+.form-grid.coach-first .reason-step { order: 10; }
+
+.form-grid.time-first .member-step { order: 1; }
+.form-grid.time-first .course-step { order: 2; }
+.form-grid.time-first .type-step { order: 3; }
+.form-grid.time-first .delivery-step { order: 4; }
+.form-grid.time-first .date-step { order: 5; }
+.form-grid.time-first .time-step { order: 6; }
+.form-grid.time-first .coach-step { order: 7; }
+.form-grid.time-first .recurrence-step { order: 8; }
+.form-grid.time-first .occurrences-step { order: 9; }
+.form-grid.time-first .reason-step { order: 10; }
+
 .form-grid label > span:first-child {
   display: flex;
   align-items: center;
@@ -449,6 +710,18 @@ textarea:focus-visible {
   background: var(--schedule-green-soft);
 }
 
+.search-option.read-only {
+  border: 1px dashed #d9e2de;
+  background: #f7f9f8;
+  color: #6e7d78;
+  cursor: not-allowed;
+}
+
+.search-option.read-only:hover,
+.search-option.read-only:focus-visible {
+  background: #f2f5f4;
+}
+
 .search-option span,
 .search-option strong,
 .search-option small {
@@ -475,6 +748,14 @@ textarea:focus-visible {
   border-bottom: 1px solid #edf1ef;
 }
 
+.busy-option {
+  background: #fff8f0;
+}
+
+.busy-option em {
+  color: #c25d16 !important;
+}
+
 .search-empty {
   display: grid;
   min-height: 72px;
@@ -489,7 +770,97 @@ textarea:focus-visible {
   border-top: 1px solid #e5ece9;
 }
 
+.preflight-results {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 8px;
+  max-height: 224px;
+  margin: 0 0 14px;
+  overflow: auto;
+}
+
+.preflight-results article {
+  display: grid;
+  grid-template-columns: 25px minmax(0, 1fr) auto;
+  align-items: center;
+  gap: 9px;
+  min-height: 58px;
+  padding: 9px 10px;
+  border: 1px solid #cfe7dd;
+  border-radius: 12px;
+  background: #f3fbf7;
+}
+
+.preflight-results article.conflict {
+  border-color: #f5c9c9;
+  background: #fff5f5;
+}
+
+.preflight-results i {
+  display: grid;
+  place-items: center;
+  width: 25px;
+  height: 25px;
+  border-radius: 8px;
+  background: #dff2ea;
+  color: #23775c;
+  font-size: 10px;
+  font-style: normal;
+  font-weight: 850;
+}
+
+.preflight-results .conflict i {
+  background: #fee2e2;
+  color: #c73232;
+}
+
+.preflight-results div {
+  display: grid;
+  gap: 3px;
+  min-width: 0;
+}
+
+.preflight-results strong,
+.preflight-results span,
+.preflight-results em {
+  font-size: 10px;
+}
+
+.preflight-results strong {
+  color: #2f463e;
+}
+
+.preflight-results span {
+  overflow: hidden;
+  color: #71827c;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.preflight-results em {
+  color: #258062;
+  font-style: normal;
+  font-weight: 800;
+  white-space: nowrap;
+}
+
+.preflight-results .conflict em {
+  color: #c73232;
+}
+
 @media (max-width: 1300px) {
+  .schedule-workflow ol {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .schedule-workflow li:nth-child(2) {
+    border-right: 0;
+  }
+
+  .schedule-workflow li:nth-child(-n + 2) {
+    border-bottom: 1px solid #edf2f0;
+  }
+
   .summary-grid {
     grid-template-columns: repeat(3, minmax(0, 1fr));
   }
@@ -603,11 +974,38 @@ textarea:focus-visible {
     font-size: 12px;
   }
 
+  .schedule-filterbar {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr) minmax(132px, .62fr);
+    align-items: end;
+    gap: 10px;
+    margin-bottom: 14px;
+    padding: 12px;
+  }
+
+  .schedule-search,
+  .status-filter {
+    width: 100%;
+    max-width: none;
+  }
+
+  .filter-result {
+    min-width: 0;
+  }
+
+  .schedule-filterbar > button {
+    width: 100%;
+  }
+
   .drawer {
     padding: 22px 18px;
     border-radius: 0;
   }
 
+  .preflight-results {
+    grid-template-columns: 1fr;
+  }
+
   .summary-grid {
     grid-template-columns: repeat(2, minmax(0, 1fr));
     gap: 10px;
@@ -661,6 +1059,41 @@ textarea:focus-visible {
     line-height: 1.45;
   }
 
+  .schedule-workflow {
+    margin-bottom: 12px;
+    border-radius: 15px;
+  }
+
+  .schedule-workflow > header {
+    display: grid;
+    gap: 5px;
+    padding: 13px 14px;
+  }
+
+  .schedule-workflow > header div {
+    justify-content: space-between;
+  }
+
+  .schedule-workflow ol {
+    grid-template-columns: 1fr;
+  }
+
+  .schedule-workflow li,
+  .schedule-workflow li:nth-child(2) {
+    min-height: 76px;
+    padding: 13px 40px 12px 14px;
+    border-right: 0;
+    border-bottom: 1px solid #edf2f0;
+  }
+
+  .schedule-workflow li:last-child {
+    border-bottom: 0;
+  }
+
+  .schedule-workflow li:not(:last-child)::after {
+    top: 23px;
+  }
+
   .schedule-content-header {
     align-items: flex-start;
     padding: 14px;

+ 83 - 1
src/app/admin/pages/operations-schedule.component.spec.ts

@@ -1,4 +1,6 @@
-import { matchesScheduleSearch, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule.component';
+import { matchesScheduleSearch, scheduleCoachBusy, scheduleCourseAvailable, schedulePreflightCandidates, scheduleRowActions } from './operations-schedule.component';
+import { ScheduleAppointment } from '../admin.models';
+import { scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
 
 describe('排课搜索匹配', () => {
   it('支持学员姓名和课程名称的模糊匹配', () => {
@@ -41,6 +43,86 @@ describe('排课状态视觉分类', () => {
     expect(scheduleStatusLabel({ cancelled: false, statusCode: 20, statusLabel: '已确认', endsAt: '' }, now)).toBe('学员已确认');
     expect(scheduleStatusTone({ cancelled: false, statusCode: 30, endsAt: '' }, now)).toBe('status-completed');
     expect(scheduleStatusLabel({ cancelled: false, statusCode: 30, statusLabel: '已归档', endsAt: '' }, now)).toBe('已完课');
+    expect(scheduleStatusTone({ cancelled: false, statusCode: 40, endsAt: '' }, now)).toBe('status-archived');
+    expect(scheduleStatusLabel({ cancelled: false, statusCode: 40, statusLabel: '已点评', endsAt: '' }, now)).toBe('已归档');
     expect(scheduleStatusTone({ cancelled: true, statusCode: 0, endsAt: '' }, now)).toBe('status-cancelled');
   });
+
+  it('支持运营人员按业务状态和待分配异常筛选', () => {
+    const upcoming = { cancelled: false, statusCode: 0, endsAt: '2026-08-21T01:00:00.000Z', coachId: 88, coachAccountMissing: false };
+    expect(scheduleMatchesStatus(upcoming, 'upcoming', now)).toBeTrue();
+    expect(scheduleMatchesStatus(upcoming, 'overdue', now)).toBeFalse();
+    expect(scheduleMatchesStatus({ ...upcoming, coachId: 0 }, 'unassigned', now)).toBeTrue();
+    expect(scheduleMatchesStatus({ ...upcoming, coachAccountMissing: true }, 'unassigned', now)).toBeTrue();
+  });
+});
+
+describe('排课运营操作职责', () => {
+  it('待开始课程只能修改或取消,不能在后台直接伪造完课', () => {
+    expect(scheduleRowActions({ cancelled: false, statusCode: 0, readOnly: false })).toEqual({
+      canEdit: true, canCancel: true, canRestore: false, canComplete: false, completeLabel: '',
+    });
+  });
+
+  it('只在上课中或陪练已结束时提供补录入口', () => {
+    expect(scheduleRowActions({ cancelled: false, statusCode: 10, readOnly: false })).toEqual(jasmine.objectContaining({ canComplete: true, completeLabel: '补录结束' }));
+    expect(scheduleRowActions({ cancelled: false, statusCode: 11, readOnly: false })).toEqual(jasmine.objectContaining({ canComplete: true, completeLabel: '补录上课记录' }));
+    expect(scheduleRowActions({ cancelled: false, statusCode: 20, readOnly: false }).canComplete).toBeFalse();
+    expect(scheduleRowActions({ cancelled: false, statusCode: 30, readOnly: false }).canComplete).toBeFalse();
+  });
+
+  it('实时旧系统记录保持只读', () => {
+    expect(scheduleRowActions({ cancelled: false, statusCode: 10, readOnly: true })).toEqual({
+      canEdit: false, canCancel: false, canRestore: false, canComplete: false, completeLabel: '',
+    });
+  });
+});
+
+describe('会员课程归属', () => {
+  it('只允许选择当前会员已经绑定的课程', () => {
+    const course = { memberIds: [101, 202], availableToAll: false };
+    expect(scheduleCourseAvailable(course, 101)).toBeTrue();
+    expect(scheduleCourseAvailable(course, 303)).toBeFalse();
+    expect(scheduleCourseAvailable(course, 0)).toBeFalse();
+  });
+
+  it('保留原系统写作课程 398 的全员可选兼容规则', () => {
+    expect(scheduleCourseAvailable({ memberIds: [], availableToAll: true }, 303)).toBeTrue();
+  });
+});
+
+describe('时间优先陪练候选', () => {
+  const appointments = [{
+    targetId: 'appointment-1', coachId: 88, cancelled: false, statusCode: 0,
+    startsAt: '2026-09-02T02:00:00.000Z', endsAt: '2026-09-02T03:00:00.000Z',
+  }] as ScheduleAppointment[];
+
+  it('按真实起止区间识别跨课型重叠', () => {
+    expect(scheduleCoachBusy(appointments, 88, '2026-09-02T10:30:00+08:00', 30)).toBeTrue();
+    expect(scheduleCoachBusy(appointments, 88, '2026-09-02T11:00:00+08:00', 60)).toBeFalse();
+  });
+
+  it('忽略取消、归档和当前编辑记录', () => {
+    expect(scheduleCoachBusy(appointments, 88, '2026-09-02T10:00:00+08:00', 60, 'appointment-1')).toBeFalse();
+    expect(scheduleCoachBusy([{ ...appointments[0], cancelled: true }] as never[], 88, '2026-09-02T10:00:00+08:00', 60)).toBeFalse();
+    expect(scheduleCoachBusy([{ ...appointments[0], statusCode: 30 }] as never[], 88, '2026-09-02T10:00:00+08:00', 60)).toBeFalse();
+  });
+});
+
+describe('批量排课逐项预检', () => {
+  it('按课次去重冲突原因并保留可提交项', () => {
+    const results = schedulePreflightCandidates({
+      candidates: [
+        { index: 0, startsAt: '2026-09-01T02:00:00.000Z', endsAt: '2026-09-01T03:00:00.000Z' },
+        { index: 1, startsAt: '2026-09-08T02:00:00.000Z', endsAt: '2026-09-08T03:00:00.000Z' },
+      ],
+      conflicts: [
+        { index: 0, reason: '会员时间冲突' },
+        { index: 0, reason: '陪练时间冲突' },
+        { index: 0, reason: '会员时间冲突' },
+      ],
+    });
+    expect(results[0]).toEqual(jasmine.objectContaining({ index: 0, available: false, reasons: ['会员时间冲突', '陪练时间冲突'] }));
+    expect(results[1]).toEqual(jasmine.objectContaining({ index: 1, available: true, reasons: [] }));
+  });
 });

+ 99 - 26
src/app/admin/pages/operations-schedule.component.ts

@@ -4,30 +4,29 @@ import { FormsModule } from '@angular/forms';
 import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, LucideAngularModule, Plus, RefreshCw, Search, X } from 'lucide-angular';
 import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models';
 import { OperationsService } from '../operations.service';
+import { ScheduleStatusFilter, ScheduleStatusTone, scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
 
 const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.getMonth()+1).padStart(2,'0')}-${String(value.getDate()).padStart(2,'0')}`;
 
 type SearchField='member'|'course'|'coach';
-export type ScheduleStatusTone='status-upcoming'|'status-live'|'status-coach-ended'|'status-confirmed'|'status-completed'|'status-overdue'|'status-cancelled'|'status-neutral';
-const scheduleStatusLabels:Partial<Record<number,string>>={0:'待开始',10:'上课中',11:'陪练已结束',20:'学员已确认',30:'已完课'};
 
-export function scheduleStatusTone(item:Pick<ScheduleAppointment,'cancelled'|'statusCode'|'endsAt'>,now=Date.now()):ScheduleStatusTone{
-  if(item.cancelled)return 'status-cancelled';
-  const endTime=Date.parse(item.endsAt);
-  if((item.statusCode===0||item.statusCode===10)&&Number.isFinite(endTime)&&endTime<now)return 'status-overdue';
-  if(item.statusCode===0)return 'status-upcoming';
-  if(item.statusCode===10)return 'status-live';
-  if(item.statusCode===11)return 'status-coach-ended';
-  if(item.statusCode===20)return 'status-confirmed';
-  if(item.statusCode===30)return 'status-completed';
-  return 'status-neutral';
-}
-
-export function scheduleStatusLabel(item:Pick<ScheduleAppointment,'cancelled'|'statusCode'|'statusLabel'|'endsAt'>,now=Date.now()):string{
-  const tone=scheduleStatusTone(item,now);
-  if(tone==='status-cancelled')return '已取消';
-  if(tone==='status-overdue')return '逾期未完成';
-  return scheduleStatusLabels[item.statusCode]||item.statusLabel||`状态 ${item.statusCode}`;
+export function scheduleRowActions(item:Pick<ScheduleAppointment,'cancelled'|'statusCode'|'readOnly'>):{
+  canEdit:boolean;
+  canCancel:boolean;
+  canRestore:boolean;
+  canComplete:boolean;
+  completeLabel:string;
+}{
+  if(item.readOnly)return{canEdit:false,canCancel:false,canRestore:false,canComplete:false,completeLabel:''};
+  const status=Number(item.statusCode);
+  const canComplete=!item.cancelled&&(status===10||status===11);
+  return{
+    canEdit:!item.cancelled&&status===0,
+    canCancel:!item.cancelled&&(status===0||status===10),
+    canRestore:item.cancelled&&(status===0||status===10),
+    canComplete,
+    completeLabel:canComplete?(status===11?'补录上课记录':'补录结束'):'',
+  };
 }
 
 export function matchesScheduleSearch(query:string,values:Array<string|number|undefined|null>):boolean{
@@ -44,39 +43,113 @@ export function matchesScheduleSearch(query:string,values:Array<string|number|un
   });
 }
 
+export function scheduleCourseAvailable(
+  course: Pick<ScheduleCalendarData['courses'][number], 'memberIds' | 'availableToAll'>,
+  studentId: number,
+): boolean {
+  if (!studentId) return false;
+  return course.availableToAll === true || (course.memberIds || []).includes(studentId);
+}
+
+export type SchedulePreflightCandidate = {
+  index: number;
+  startsAt: string;
+  endsAt: string;
+  reasons: string[];
+  available: boolean;
+};
+
+export function schedulePreflightCandidates(source: Record<string, unknown> | null): SchedulePreflightCandidate[] {
+  const candidates = Array.isArray(source?.['candidates']) ? source['candidates'] as Array<Record<string, unknown>> : [];
+  const conflicts = Array.isArray(source?.['conflicts']) ? source['conflicts'] as Array<Record<string, unknown>> : [];
+  const reasons = new Map<number, string[]>();
+  for (const conflict of conflicts) {
+    const index = Number(conflict['index']);
+    if (!Number.isInteger(index) || index < 0) continue;
+    const reason = String(conflict['reason'] || '时间冲突');
+    const current = reasons.get(index) || [];
+    if (!current.includes(reason)) reasons.set(index, [...current, reason]);
+  }
+  return candidates.map((candidate) => {
+    const index = Number(candidate['index']);
+    const candidateReasons = reasons.get(index) || [];
+    return {
+      index,
+      startsAt: String(candidate['startsAt'] || ''),
+      endsAt: String(candidate['endsAt'] || ''),
+      reasons: candidateReasons,
+      available: candidateReasons.length === 0,
+    };
+  });
+}
+
+export function scheduleCoachBusy(
+  appointments: ScheduleAppointment[],
+  coachId: number,
+  startsAt: string,
+  durationMinutes: number,
+  editingTargetId = '',
+): boolean {
+  const start = Date.parse(startsAt);
+  const end = start + durationMinutes * 60_000;
+  if (!coachId || !Number.isFinite(start) || durationMinutes <= 0) return false;
+  return appointments.some((row) => {
+    if (row.coachId !== coachId || row.targetId === editingTargetId || row.cancelled || row.statusCode >= 30) return false;
+    const rowStart = Date.parse(row.startsAt);
+    const rowEnd = Date.parse(row.endsAt);
+    return Number.isFinite(rowStart) && Number.isFinite(rowEnd) && rowStart < end && rowEnd > start;
+  });
+}
+
 @Component({ selector:'app-operations-schedule', standalone:true, imports:[CommonModule,FormsModule,LucideAngularModule], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
 export class OperationsScheduleComponent implements OnInit {
   readonly data=signal<ScheduleCalendarData|null>(null); readonly loading=signal(true); readonly saving=signal(false); readonly error=signal(''); readonly message=signal(''); readonly drawerOpen=signal(false); readonly editing=signal<ScheduleAppointment|null>(null); readonly mode=signal<'coach'|'time'>('coach'); readonly view=signal<'day'|'week'|'list'>('day'); readonly date=signal(localDateKey(new Date())); readonly coachFilter=signal(0);
   readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly deliveryMethod=signal('线上'); readonly startTime=signal('19:00'); readonly recurrence=signal('once'); readonly occurrences=signal(1); readonly reason=signal('运营排课'); readonly preview=signal<Record<string,unknown>|null>(null);
-  readonly memberSearch=signal(''); readonly courseSearch=signal(''); readonly coachSearch=signal(''); readonly selectedMemberObjectId=signal(''); readonly activeSearch=signal<SearchField|null>(null);
+  readonly memberSearch=signal(''); readonly courseSearch=signal(''); readonly coachSearch=signal(''); readonly selectedMemberObjectId=signal(''); readonly activeSearch=signal<SearchField|null>(null); readonly scheduleSearch=signal(''); readonly statusFilter=signal<ScheduleStatusFilter>('all');
   readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,Plus,RefreshCw,Search,X}; private readonly operations=inject(OperationsService);
-  readonly activeAppointments=computed(()=>this.data()?.appointments.filter(item=>!item.cancelled).length||0); readonly unassignedCount=computed(()=>this.data()?.unassigned.length||0);
+  readonly activeAppointments=computed(()=>this.data()?.appointments.filter(item=>!item.cancelled).length||0);
+  readonly visibleAppointments=computed(()=>(this.data()?.appointments||[]).filter(item=>scheduleMatchesStatus(item,this.statusFilter())&&matchesScheduleSearch(this.scheduleSearch(),[item.studentName,item.coachName,item.courseName,item.studentId,item.coachId,item.courseId,item.generalId])));
+  readonly unassignedCount=computed(()=>this.visibleAppointments().filter(item=>!item.coachId||item.coachAccountMissing).length);
+  readonly hasScheduleFilters=computed(()=>Boolean(this.scheduleSearch().trim())||this.statusFilter()!=='all');
   readonly filteredMembers=computed(()=>(this.data()?.members||[]).filter(member=>matchesScheduleSearch(this.memberSearch(),[member.displayName,member.mobileMasked,member.userId])).slice(0,12));
-  readonly filteredCourses=computed(()=>(this.data()?.courses||[]).filter(course=>matchesScheduleSearch(this.courseSearch(),[course.courseName,course.courseId])).slice(0,12));
-  readonly filteredCoaches=computed(()=>(this.data()?.coaches||[]).filter(coach=>matchesScheduleSearch(this.coachSearch(),[coach.displayName,coach.mobile,coach.mobileMasked,coach.userId])).slice(0,12));
+  readonly filteredCourses=computed(()=>(this.data()?.courses||[]).filter(course=>scheduleCourseAvailable(course,this.studentId())&&matchesScheduleSearch(this.courseSearch(),[course.courseName,course.courseId])).slice(0,12));
+  readonly filteredCoaches=computed(()=>(this.data()?.coaches||[]).filter(coach=>matchesScheduleSearch(this.coachSearch(),[coach.displayName,coach.mobile,coach.mobileMasked,coach.userId])).sort((a,b)=>this.mode()==='time'?(Number(this.coachBusyAtSelectedTime(a.userId))-Number(this.coachBusyAtSelectedTime(b.userId)))||(Number(b.availabilityConfigured)-Number(a.availabilityConfigured))||a.displayName.localeCompare(b.displayName,'zh-CN'):a.displayName.localeCompare(b.displayName,'zh-CN')).slice(0,12));
+  readonly previewCandidates=computed(()=>schedulePreflightCandidates(this.preview()));
   private initialRange=true;
   previewConflictCount():number{return Number(this.preview()?.['conflictCount']||0);}
+  previewAvailableCount():number{return Number(this.preview()?.['availableCount']||0);}
+  canSavePreview():boolean{return Boolean(this.preview())&&(!this.previewConflictCount()||(this.previewCandidates().length>1&&this.previewAvailableCount()>0));}
+  saveButtonLabel():string{if(this.saving())return'保存中…';if(!this.preview())return'请先检查冲突';if(this.previewConflictCount()&&this.previewCandidates().length>1&&this.previewAvailableCount()>0)return`仅保存 ${this.previewAvailableCount()} 节无冲突排课`;return this.editing()?'确认修改':'确认保存';}
   statusTone(item:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(item);}
   statusDisplayLabel(item:ScheduleAppointment):string{return scheduleStatusLabel(item);}
+  rowActions(item:ScheduleAppointment):ReturnType<typeof scheduleRowActions>{return scheduleRowActions(item);}
   ngOnInit():void{void this.load();}
   range():{from:string;to:string}{const from=new Date(`${this.date()}T00:00:00`);const to=new Date(from);if(this.view()==='week')to.setDate(to.getDate()+6);return{from:localDateKey(from),to:localDateKey(to)};}
   async load():Promise<void>{this.loading.set(true);this.error.set('');try{const range=this.range();const result=await this.operations.schedule(range.from,range.to,this.coachFilter());if(this.initialRange&&!result.appointments.length&&result.dataDateTo&&result.dataDateTo!==this.date()){this.initialRange=false;this.date.set(result.dataDateTo);const latestRange=this.range();this.data.set(await this.operations.schedule(latestRange.from,latestRange.to,this.coachFilter()));this.message.set(`当前日期没有排课,已自动定位到最近有数据的 ${result.dataDateTo}`);}else{this.initialRange=false;this.data.set(result);}}catch(error){this.error.set(error instanceof Error?error.message:'排课数据加载失败');}finally{this.loading.set(false);}}
   move(days:number):void{const value=new Date(`${this.date()}T00:00:00`);value.setDate(value.getDate()+days);this.date.set(localDateKey(value));void this.load();}
   showLatest():void{const latest=this.data()?.dataDateTo;if(!latest)return;this.date.set(latest);void this.load();}
+  clearScheduleFilters():void{this.scheduleSearch.set('');this.statusFilter.set('all');}
+  visibleCoachCount(coachId:number):number{return this.visibleAppointments().filter(item=>item.coachId===coachId).length;}
   openCreate():void{this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.coachId.set(0);this.memberSearch.set('');this.courseSearch.set('');this.coachSearch.set('');this.selectedMemberObjectId.set('');this.activeSearch.set(null);this.preview.set(null);this.drawerOpen.set(true);}
   openEdit(item:ScheduleAppointment):void{if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.coachId.set(item.coachId);this.memberSearch.set(`${item.studentName}${this.memberMobile(item.studentId)?` · ${this.memberMobile(item.studentId)}`:''}`);this.courseSearch.set(item.courseName);this.coachSearch.set(item.coachId?item.coachName:'');this.selectedMemberObjectId.set(item.studentObjectId);this.activeSearch.set(null);this.classType.set(item.classType);this.date.set(item.startsAt.slice(0,10));this.startTime.set(item.startsAt.slice(11,16));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
   closeDrawer():void{this.activeSearch.set(null);this.drawerOpen.set(false);}
+  setMode(value:'coach'|'time'):void{this.mode.set(value);this.activeSearch.set(null);this.preview.set(null);}
   openSearch(field:SearchField):void{this.activeSearch.set(field);}
-  updateMemberSearch(value:string):void{this.memberSearch.set(value);this.studentId.set(0);this.selectedMemberObjectId.set('');this.openSearch('member');}
+  updateMemberSearch(value:string):void{this.memberSearch.set(value);this.studentId.set(0);this.selectedMemberObjectId.set('');this.courseId.set(0);this.courseSearch.set('');this.preview.set(null);this.openSearch('member');}
   updateCourseSearch(value:string):void{this.courseSearch.set(value);this.courseId.set(0);this.openSearch('course');}
   updateCoachSearch(value:string):void{this.coachSearch.set(value);this.coachId.set(0);this.openSearch('coach');}
-  chooseMember(member:ScheduleCalendarData['members'][number]):void{this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);this.activeSearch.set(null);this.preview.set(null);}
+  chooseMember(member:ScheduleCalendarData['members'][number]):void{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}const selectedCourse=this.data()?.courses.find(course=>course.courseId===this.courseId());this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);if(selectedCourse&&!scheduleCourseAvailable(selectedCourse,member.userId)){this.courseId.set(0);this.courseSearch.set('');}this.activeSearch.set(null);this.preview.set(null);}
   chooseCourse(course:ScheduleCalendarData['courses'][number]):void{this.courseId.set(course.courseId);this.courseSearch.set(course.courseName);this.activeSearch.set(null);this.preview.set(null);}
   chooseCoach(coach:ScheduleCalendarData['coaches'][number]):void{this.coachId.set(coach.userId);this.coachSearch.set(`${coach.displayName}${coach.mobileMasked?` · ${coach.mobileMasked}`:''}`);this.activeSearch.set(null);this.preview.set(null);}
   clearCoach():void{this.coachId.set(0);this.coachSearch.set('');this.activeSearch.set(null);this.preview.set(null);}
+  updateDraftDate(value:string):void{if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return;this.date.set(value);this.preview.set(null);void this.load();}
+  updateClassType(value:number):void{this.classType.set(Number(value));this.preview.set(null);}
+  updateStartTime(value:string):void{this.startTime.set(value);this.preview.set(null);}
+  coachBusyAtSelectedTime(coachId:number):boolean{return scheduleCoachBusy(this.data()?.appointments||[],coachId,`${this.date()}T${this.startTime()}:00+08:00`,this.classType()===1||this.classType()===4?30:60,this.editing()?.targetId||'');}
+  coachCandidateHint(coach:ScheduleCalendarData['coaches'][number]):string{return this.coachBusyAtSelectedTime(coach.userId)?'该时段已有排课':coach.availabilityConfigured?'该时段可继续预检':'未设置可用时间';}
   memberMobile(userId:number):string{return this.data()?.members.find(member=>member.userId===userId)?.mobileMasked||'';}
   payload():Record<string,unknown>{return{studentId:this.studentId(),courseId:this.courseId(),coachId:this.coachId(),classType:this.classType(),deliveryMethod:this.deliveryMethod(),date:this.date(),startTime:this.startTime(),recurrence:this.recurrence(),occurrences:this.occurrences()};}
   async preflight():Promise<void>{this.error.set('');if(!this.selectedMemberObjectId()||!this.courseId()){this.error.set('请先选择会员和课程');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能排课;请先补齐该会员的迁移标识');return;}try{this.preview.set(await this.operations.request('ops/appointments/preflight',{targetId:this.editing()?.targetId||'',payload:this.payload()}));}catch(error){this.error.set(error instanceof Error?error.message:'排课预检失败');}}
-  async save():Promise<void>{if(!this.selectedMemberObjectId()||!this.courseId()||!this.reason().trim()){this.error.set('请选择会员、课程并填写操作原因');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能保存;请先补齐该会员的迁移标识');return;}this.saving.set(true);this.error.set('');try{const editing=this.editing();await this.operations.mutate(editing?'ops/appointments/update':'ops/appointments/create',editing?.targetId||'',this.reason(),this.payload(),editing?.updatedAt||'');this.message.set(editing?'排课已更新':'排课已创建');this.drawerOpen.set(false);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'排课保存失败');}finally{this.saving.set(false);}}
+  async save():Promise<void>{if(!this.selectedMemberObjectId()||!this.courseId()||!this.reason().trim()){this.error.set('请选择会员、课程并填写操作原因');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能保存;请先补齐该会员的迁移标识');return;}if(!this.preview()){this.error.set('请先检查冲突并确认逐项预检结果');return;}if(!this.canSavePreview()){this.error.set('当前没有可以提交的排课,请先调整冲突项');return;}this.saving.set(true);this.error.set('');try{const editing=this.editing(),skipConflicts=!editing&&this.previewConflictCount()>0&&this.previewCandidates().length>1;const result=await this.operations.mutate<{created?:number;skipped?:number}>(editing?'ops/appointments/update':'ops/appointments/create',editing?.targetId||'',this.reason(),{...this.payload(),skipConflicts},editing?.updatedAt||'');this.message.set(editing?'排课已更新':result.skipped?`已创建 ${result.created||0} 节排课,跳过 ${result.skipped} 节冲突项`:`已创建 ${result.created||1} 节排课`);this.drawerOpen.set(false);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'排课保存失败');}finally{this.saving.set(false);}}
   async action(item:ScheduleAppointment,operation:string):Promise<void>{if(item.readOnly){this.message.set('实时业务数据暂不支持从新后台修改,请在原业务端完成操作。');return;}const reason=globalThis.prompt(operation.endsWith('restore')?'请输入恢复原因':operation.endsWith('complete')?'请输入确认完成原因':'请输入取消原因');if(!reason?.trim())return;this.saving.set(true);try{await this.operations.mutate(operation,item.targetId,reason,{},item.updatedAt);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'操作失败');}finally{this.saving.set(false);}}
 }

Plik diff jest za duży
+ 2 - 1
src/app/admin/pages/operations-special-training.component.html


Plik diff jest za duży
+ 0 - 0
src/app/admin/pages/operations-special-training.component.scss


+ 7 - 6
src/app/admin/pages/operations-special-training.component.ts

@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { BookOpen, Eye, Image, LayoutGrid, List, LucideAngularModule, Pencil, Plus, RefreshCw, RotateCcw, Search, Trash2, Upload, Video, X } from 'lucide-angular';
-import { OperationsPage, SpecialTrainingItem } from '../admin.models';
+import { SpecialTrainingItem, SpecialTrainingPage } from '../admin.models';
 import { OperationsService } from '../operations.service';
 
 type SpecialDraft = Pick<SpecialTrainingItem,'targetId'|'title'|'synopsis'|'coverUrl'|'tags'|'contentHtml'|'videoUrl'|'author'|'source'|'orderId'|'status'|'updatedAt'>;
@@ -10,13 +10,13 @@ type SpecialDraft = Pick<SpecialTrainingItem,'targetId'|'title'|'synopsis'|'cove
 @Component({selector:'app-operations-special-training',standalone:true,imports:[CommonModule,FormsModule,LucideAngularModule],templateUrl:'./operations-special-training.component.html',styleUrls:['./operations-professional.scss','./operations-special-training.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsSpecialTrainingComponent implements OnInit{
   readonly icons={BookOpen,Eye,Image,LayoutGrid,List,Pencil,Plus,RefreshCw,RotateCcw,Search,Trash2,Upload,Video,X};
-  readonly page=signal<OperationsPage<SpecialTrainingItem>|null>(null);readonly query=signal('');readonly status=signal('');readonly view=signal<'cards'|'list'>('cards');readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');
-  readonly publishedCount=computed(()=>this.page()?.items.filter((item)=>item.status===99).length||0);readonly draftCount=computed(()=>this.page()?.items.filter((item)=>item.status===0).length||0);readonly mediaMissingCount=computed(()=>this.page()?.items.filter((item)=>!item.coverUrl||(!item.contentHtml&&!item.videoUrl)).length||0);
+  readonly page=signal<SpecialTrainingPage|null>(null);readonly query=signal('');readonly status=signal('');readonly view=signal<'cards'|'list'>('cards');readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');
+  readonly publishedCount=computed(()=>this.page()?.items.filter((item)=>item.status===99).length||0);readonly draftCount=computed(()=>this.page()?.items.filter((item)=>item.status===0).length||0);readonly mediaMissingCount=computed(()=>this.page()?.items.filter((item)=>!item.contentHtml&&!item.videoUrl).length||0);
   readonly editorOpen=signal(false);readonly previewOpen=signal(false);readonly previewItem=signal<SpecialTrainingItem|null>(null);readonly reason=signal('');readonly tagsText=signal('');readonly draft=signal<SpecialDraft>(this.emptyDraft());
   private readonly operations=inject(OperationsService);
   ngOnInit():void{void this.load();}
-  async load():Promise<void>{this.loading.set(true);this.error.set('');try{this.page.set(await this.operations.specialTraining(1,50,this.query(),{status:this.status()}));}catch(error){this.error.set(this.errorText(error,'专项训练加载失败'));}finally{this.loading.set(false);}}
-  refresh():void{void this.load().then(()=>this.message.set(`已从服务端刷新 · ${new Date().toLocaleTimeString('zh-CN')}`));}
+  async load(refresh=false):Promise<void>{this.loading.set(true);this.error.set('');try{this.page.set(await this.operations.specialTraining(1,50,this.query(),{status:this.status()},refresh));}catch(error){this.error.set(this.errorText(error,'专项训练加载失败'));}finally{this.loading.set(false);}}
+  refresh():void{void this.load(true).then(()=>{const source=this.page()?.sourceStatus;this.message.set(source?.available?`已对齐旧后台 ${source.legacyTotal} 条内容,本次新增 ${source.created} 条、更新 ${source.updated} 条`:`已刷新新后台缓存;${source?.message||'旧后台实时同步暂不可用'}`);});}
   openCreate():void{this.draft.set(this.emptyDraft());this.tagsText.set('');this.reason.set('新增专项训练草稿');this.error.set('');this.editorOpen.set(true);}
   openEdit(row:SpecialTrainingItem):void{this.draft.set({targetId:row.targetId,title:row.title,synopsis:row.synopsis,coverUrl:row.coverUrl,tags:[...row.tags],contentHtml:row.contentHtml,videoUrl:row.videoUrl,author:row.author,source:row.source,orderId:row.orderId,status:row.status,updatedAt:row.updatedAt});this.tagsText.set(row.tags.join(','));this.reason.set('');this.error.set('');this.editorOpen.set(true);}
   closeEditor():void{this.editorOpen.set(false);}
@@ -24,7 +24,8 @@ export class OperationsSpecialTrainingComponent implements OnInit{
   async save():Promise<void>{const draft=this.draft();if(!draft.title.trim()||!this.reason().trim()){this.error.set('标题和操作原因必填');return;}this.saving.set(true);this.error.set('');try{const payload={...draft,tags:this.tagsText().split(/[,,]/).map((tag)=>tag.trim()).filter(Boolean)};await this.operations.mutate('ops/special-training/save',draft.targetId,this.reason(),payload,draft.updatedAt);this.editorOpen.set(false);this.message.set(draft.targetId?'专项内容已更新':'专项内容已创建为草稿');await this.load();}catch(error){this.error.set(this.errorText(error,'专项内容保存失败'));}finally{this.saving.set(false);}}
   async preview(row?:SpecialTrainingItem):Promise<void>{if(row){this.previewItem.set(row);this.previewOpen.set(true);return;}const draft=this.draft();try{const result=await this.operations.request<SpecialTrainingItem>('ops/special-training/preview',{targetId:draft.targetId,payload:{...draft,tags:this.tagsText().split(/[,,]/).map((tag)=>tag.trim()).filter(Boolean)}});this.previewItem.set(result);this.previewOpen.set(true);}catch(error){this.error.set(this.errorText(error,'预览失败'));}}
   async lifecycle(row:SpecialTrainingItem,action:'publish'|'unpublish'|'recycle'|'restore'):Promise<void>{const labels={publish:'发布',unpublish:'下架',recycle:'回收',restore:'恢复'};const reason=window.prompt(`请输入${labels[action]}原因`);if(!reason)return;try{await this.operations.mutate(`ops/special-training/${action}`,row.targetId,reason,{},row.updatedAt);this.message.set(`“${row.title}”已${labels[action]}`);await this.load();}catch(error){this.error.set(this.errorText(error,`${labels[action]}失败`));}}
-  async upload(event:Event,kind:'image'|'video'):Promise<void>{const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return;this.saving.set(true);try{const url=await this.operations.uploadMedia(file,kind,this.reason()||`上传专项${kind==='image'?'封面':'视频'}`);this.updateDraft(kind==='image'?'coverUrl':'videoUrl',url);this.message.set('媒体上传完成');}catch(error){this.error.set(this.errorText(error,'媒体上传失败'));}finally{this.saving.set(false);input.value='';}}
+  async upload(event:Event,kind:'image'|'video'):Promise<void>{const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return;this.saving.set(true);this.error.set('');try{if(kind==='image'){const cover=await this.cropCover(file),url=await this.operations.uploadCover(cover.file,this.reason()||'上传专项封面',cover.width,cover.height);this.updateDraft('coverUrl',url);this.message.set(`封面已按 4:3 居中裁切并上传(${cover.width}×${cover.height})`);}else{const url=await this.operations.uploadMedia(file,kind,this.reason()||'上传专项视频');this.updateDraft('videoUrl',url);this.message.set('视频上传完成');}}catch(error){this.error.set(this.errorText(error,kind==='image'?'封面上传失败':'视频上传失败'));}finally{this.saving.set(false);input.value='';}}
+  private async cropCover(file:File):Promise<{file:File;width:number;height:number}>{if(!['image/jpeg','image/png','image/webp','image/gif'].includes(file.type))throw new Error('封面仅支持 JPG、PNG、WebP 或 GIF');const bitmap=await createImageBitmap(file);try{const ratio=4/3;let sourceWidth=bitmap.width,sourceHeight=bitmap.height,sourceX=0,sourceY=0;if(sourceWidth/sourceHeight>ratio){sourceWidth=sourceHeight*ratio;sourceX=(bitmap.width-sourceWidth)/2;}else{sourceHeight=sourceWidth/ratio;sourceY=(bitmap.height-sourceHeight)/2;}const scaledWidth=Math.min(1200,sourceWidth),width=Math.max(4,Math.floor(scaledWidth/4)*4),height=width/4*3,canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;const context=canvas.getContext('2d');if(!context)throw new Error('浏览器无法处理封面图片');context.fillStyle='#eef5f2';context.fillRect(0,0,width,height);context.drawImage(bitmap,sourceX,sourceY,sourceWidth,sourceHeight,0,0,width,height);const blob=await new Promise<Blob>((resolve,reject)=>canvas.toBlob((value)=>value?resolve(value):reject(new Error('封面裁切失败')),'image/jpeg',.88));const baseName=file.name.replace(/\.[^.]+$/,'')||'cover';return{file:new File([blob],`${baseName}-4x3.jpg`,{type:'image/jpeg'}),width,height};}finally{bitmap.close();}}
   private emptyDraft():SpecialDraft{return{targetId:'',title:'',synopsis:'',coverUrl:'',tags:[],contentHtml:'',videoUrl:'',author:'',source:'',orderId:0,status:0,updatedAt:''};}
   private errorText(error:unknown,fallback:string):string{return error instanceof Error?error.message:fallback;}
 }

+ 73 - 0
src/app/admin/pages/operations-store-detail.component.html

@@ -0,0 +1,73 @@
+<header class="page-header detail-header">
+  <div>
+    <a class="back-link" routerLink="/admin/stores"><lucide-icon [img]="icons.ArrowLeft" [size]="16"/>返回门店管理</a>
+    <p>STORE OPERATING PROFILE</p>
+    <h1>{{data()?.store?.storeName||'门店经营详情'}}</h1>
+    <span>集中查看门店归属会员、陪练、排课、课程履约和陪练工资成本</span>
+  </div>
+  <div class="header-actions">
+    <label class="month-control"><span>经营月份</span><input type="month" [ngModel]="month()" (ngModelChange)="changeMonth($event)" [disabled]="loading()"/></label>
+    <button class="ui-button secondary" type="button" (click)="load(true)" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()"/>{{loading()?'读取中…':'刷新实时数据'}}</button>
+  </div>
+</header>
+
+@if(error()){<p class="notice error" role="alert">{{error()}}</p>}
+@if(loading()&&!data()){
+  <section class="loading-card" aria-live="polite"><lucide-icon [img]="icons.RefreshCw" [size]="22" class="spin"/><strong>正在归集门店经营数据</strong><span>会员、排课和工资来自不同业务投影,首次读取可能需要数秒</span></section>
+}
+@if(data(); as detail){
+  <section class="source-strip" role="status">
+    <div><strong>{{detail.store.storeName}}</strong><span>ID {{detail.store.userId}} · {{detail.store.parentStoreName||'总店/未归属'}} · {{detail.store.statusLabel}}</span></div>
+    <div class="source-meta"><span>{{detail.payrollSourceStatus.mode==='live'?'工资使用旧系统实时上课记录':'工资使用最近缓存'}}</span><small>更新于 {{detail.refreshedAt|date:'MM-dd HH:mm:ss'}}</small></div>
+  </section>
+
+  <section class="store-profile">
+    <div class="store-mark"><lucide-icon [img]="icons.Building2" [size]="30"/></div>
+    <div><span>门店账号</span><strong>{{detail.store.storeName}}</strong><small>{{detail.store.username}} · 联系人 {{detail.store.contactName||'未设置'}}</small></div>
+    <dl><div><dt>联系电话</dt><dd>{{detail.store.mobile||detail.store.mobileMasked||'未登记'}}</dd></div><div><dt>最近登录</dt><dd>{{detail.store.lastLoginAt?(detail.store.lastLoginAt|date:'yyyy-MM-dd HH:mm'):'从未登录'}}</dd></div><div><dt>历史排课</dt><dd>{{detail.store.totalAppointments}} 节</dd></div></dl>
+  </section>
+
+  <section class="summary-grid" aria-label="门店经营摘要">
+    <article><span>归属会员</span><strong>{{detail.summary.memberCount}}</strong><small>当前门店关系</small></article>
+    <article><span>归属陪练</span><strong>{{detail.summary.coachCount}}</strong><small>可参与排课</small></article>
+    <article><span>{{detail.month}} 排课</span><strong>{{detail.summary.monthAppointments}}</strong><small>含不同课型</small></article>
+    <article><span>已完成排课</span><strong>{{detail.summary.completedAppointments}}</strong><small>状态 30/40</small></article>
+    <article><span>有课学员</span><strong>{{detail.summary.activeStudents}}</strong><small>本月去重人数</small></article>
+    <article><span>有效上课记录</span><strong>{{detail.summary.lessonCount}}</strong><small>工资计薪来源</small></article>
+    <article><span>陪练工时</span><strong>{{detail.summary.totalHours|number:'1.1-1'}}h</strong><small>按有效上课记录</small></article>
+    <article class="pay-card"><span>预估陪练工资</span><strong>¥{{detail.summary.estimatedCoachPay|number:'1.2-2'}}</strong><small>与工资结算一致</small></article>
+  </section>
+
+  @if(!detail.commissionStatus.available){<p class="commission-note"><lucide-icon [img]="icons.WalletCards" [size]="18"/><span><strong>门店佣金暂不估算</strong>{{detail.commissionStatus.message}}</span></p>}
+
+  <section class="content-card detail-card">
+    <nav class="detail-tabs" aria-label="门店经营详情">
+      <button type="button" [class.active]="tab()==='members'" (click)="tab.set('members')"><lucide-icon [img]="icons.Users" [size]="17"/>会员 <em>{{detail.members.length}}</em></button>
+      <button type="button" [class.active]="tab()==='coaches'" (click)="tab.set('coaches')"><lucide-icon [img]="icons.UserRound" [size]="17"/>陪练 <em>{{detail.coaches.length}}</em></button>
+      <button type="button" [class.active]="tab()==='schedule'" (click)="tab.set('schedule')"><lucide-icon [img]="icons.CalendarDays" [size]="17"/>{{detail.month}} 排课 <em>{{detail.appointments.length}}</em></button>
+      <button type="button" [class.active]="tab()==='courses'" (click)="tab.set('courses')"><lucide-icon [img]="icons.GraduationCap" [size]="17"/>课程履约 <em>{{detail.courses.length}}</em></button>
+    </nav>
+
+    @if(tab()==='members'){
+      <div class="section-heading"><div><strong>归属会员</strong><span>按会员所属门店关系归集,陪练和管理员不会混入</span></div><label class="inline-search"><lucide-icon [img]="icons.Search" [size]="16"/><input [ngModel]="memberSearch()" (ngModelChange)="memberSearch.set($event)" placeholder="搜索姓名、昵称、手机号或ID"/></label></div>
+      <div class="table-scroll"><table class="data-table"><thead><tr><th>会员</th><th>学习状态</th><th>剩余课时</th><th>最近上课</th><th>下次预约</th><th>账号状态</th><th>操作</th></tr></thead><tbody>
+        @for(row of members();track row.objectId){<tr><td><strong>{{row.displayName}}</strong><small>{{row.mobile||row.mobileMasked||row.username}} · ID {{row.userId}}</small></td><td><span class="badge" [class.warn]="row.learningStatus==='trial'||row.learningStatus==='not_started'">{{row.learningStatusLabel}}</span><small>{{row.learningStatusReason}}</small></td><td>30分 {{row.periods30}} · 60分 {{row.periods60}}<small>体验 {{row.trialPeriods}}</small></td><td>{{row.lastLessonAt?(row.lastLessonAt|date:'yyyy-MM-dd'):'暂无'}}</td><td>{{row.nextAppointmentAt?(row.nextAppointmentAt|date:'yyyy-MM-dd HH:mm'):'待排课'}}</td><td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span></td><td><a class="detail-link" [routerLink]="['/admin/members',row.objectId]">查看会员</a></td></tr>}@empty{<tr><td colspan="7"><div class="empty">没有符合条件的会员</div></td></tr>}
+      </tbody></table></div>
+    } @else if(tab()==='coaches'){
+      <div class="section-heading"><div><strong>归属陪练</strong><span>本月课次、工时和工资均使用工资结算相同口径</span></div><label class="inline-search"><lucide-icon [img]="icons.Search" [size]="16"/><input [ngModel]="coachSearch()" (ngModelChange)="coachSearch.set($event)" placeholder="搜索陪练姓名、账号、手机号或ID"/></label></div>
+      <div class="table-scroll"><table class="data-table"><thead><tr><th>陪练老师</th><th>今日排课</th><th>待完成</th><th>本月有效课次</th><th>本月工时</th><th>预估工资</th><th>状态</th><th>操作</th></tr></thead><tbody>
+        @for(row of coaches();track row.objectId){<tr><td><strong>{{row.displayName}}</strong><small>{{row.mobile||row.mobileMasked||row.username||'历史账号未同步'}} · ID {{row.userId}}</small></td><td>{{row.todayAppointments}} 节</td><td>{{row.pendingAppointments}} 节</td><td>{{row.monthLessons}} 节</td><td>{{row.monthHours|number:'1.1-1'}}h</td><td class="money">¥{{row.estimatedPay|number:'1.2-2'}}</td><td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span></td><td><a class="detail-link" [routerLink]="['/admin/coaches',row.objectId]">查看陪练</a></td></tr>}@empty{<tr><td colspan="8"><div class="empty">没有符合条件的陪练</div></td></tr>}
+      </tbody></table></div>
+    } @else if(tab()==='schedule'){
+      <div class="section-heading"><div><strong>{{detail.month}} 排课记录</strong><span>优先按学员所属门店归集;学员关系缺失时才使用陪练所属门店</span></div><label class="inline-search"><lucide-icon [img]="icons.Search" [size]="16"/><input [ngModel]="scheduleSearch()" (ngModelChange)="scheduleSearch.set($event)" placeholder="搜索学员、陪练、课程或状态"/></label></div>
+      <div class="table-scroll"><table class="data-table schedule-table"><thead><tr><th>上课时间</th><th>学员</th><th>陪练</th><th>课程</th><th>课型</th><th>状态</th><th>来源</th></tr></thead><tbody>
+        @for(row of appointments();track row.targetId){<tr><td><strong>{{row.startsAt|date:'yyyy-MM-dd HH:mm'}}</strong><small><lucide-icon [img]="icons.Clock3" [size]="12"/>{{row.endsAt|date:'HH:mm'}} 结束</small></td><td>{{row.studentName||'未识别学员'}}</td><td>{{row.coachName||'待分配'}}</td><td>{{row.courseName||'未命名课程'}}</td><td>{{row.classTypeLabel}}</td><td><span class="status-pill" [attr.data-tone]="statusTone(row)">{{row.statusLabel}}</span></td><td>{{row.sourceLabel}}</td></tr>}@empty{<tr><td colspan="7"><div class="empty">{{detail.month}} 暂无排课记录</div></td></tr>}
+      </tbody></table></div>
+    } @else {
+      <div class="section-heading"><div><strong>{{detail.month}} 课程履约</strong><span>按课程归集排课、完课和参与学员,方便识别履约不足的课程</span></div></div>
+      <div class="table-scroll"><table class="data-table"><thead><tr><th>课程</th><th>参与学员</th><th>排课数</th><th>已完成</th><th>完成率</th><th>最近排课</th></tr></thead><tbody>
+        @for(row of detail.courses;track row.courseId){<tr><td><strong>{{row.courseName}}</strong><small>ID {{row.courseId||'未识别'}}</small></td><td>{{row.studentCount}} 人</td><td>{{row.appointmentCount}} 节</td><td>{{row.completedCount}} 节</td><td><div class="progress"><span [style.width.%]="row.completionRate"></span></div><small>{{row.completionRate|number:'1.0-0'}}%</small></td><td>{{row.lastAppointmentAt?(row.lastAppointmentAt|date:'yyyy-MM-dd HH:mm'):'暂无'}}</td></tr>}@empty{<tr><td colspan="6"><div class="empty">{{detail.month}} 暂无课程履约数据</div></td></tr>}
+      </tbody></table></div>
+    }
+  </section>
+}

+ 77 - 0
src/app/admin/pages/operations-store-detail.component.scss

@@ -0,0 +1,77 @@
+:host { display: block; color: #24382f; }
+.detail-header { display: flex; align-items: end; justify-content: space-between; gap: 24px; }
+.back-link { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 17px; color: #557068; font-size: 13px; font-weight: 750; text-decoration: none; }
+.month-control { display: grid; gap: 6px; color: #63746d; font-size: 11px; font-weight: 800; }
+.month-control input { min-width: 166px; min-height: 42px; padding: 8px 11px; border: 1px solid #d6e2dd; border-radius: 12px; color: #263b33; background: #fff; font-weight: 750; }
+.loading-card { display: grid; min-height: 260px; place-items: center; align-content: center; gap: 10px; border: 1px solid #dfe8e4; border-radius: 20px; color: #6e8079; background: #fff; }
+.loading-card strong { color: #2b443a; font-size: 17px; }
+.source-strip { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 16px; padding: 15px 18px; border: 1px solid #d7e7e0; border-radius: 16px; background: linear-gradient(100deg,#edf8f4,#f9fcfb); }
+.source-strip strong,.source-strip span,.source-strip small { display: block; }
+.source-strip strong { font-size: 16px; }
+.source-strip span,.source-strip small { margin-top: 3px; color: #6e8079; font-size: 12px; }
+.source-meta { text-align: right; }
+.source-meta span { color: #197356; font-weight: 750; }
+.store-profile { display: grid; grid-template-columns: auto minmax(220px,1fr) minmax(420px,1.8fr); align-items: center; gap: 18px; margin-bottom: 16px; padding: 20px; border: 1px solid #dfe8e4; border-radius: 18px; background: #fff; box-shadow: 0 10px 28px rgba(28,65,51,.04); }
+.store-mark { display: grid; width: 62px; height: 62px; place-items: center; border-radius: 18px; color: #fff; background: linear-gradient(135deg,#17775a,#2fa27c); }
+.store-profile > div > span,.store-profile > div > small { display: block; color: #7d8e87; font-size: 12px; }
+.store-profile > div > strong { display: block; margin: 4px 0; font-size: 22px; }
+.store-profile dl { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; margin: 0; }
+.store-profile dl div { min-width: 0; padding: 11px 13px; border-radius: 12px; background: #f5f9f7; }
+.store-profile dt { color: #7a8c84; font-size: 11px; }
+.store-profile dd { overflow: hidden; margin: 5px 0 0; font-size: 13px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
+.summary-grid { grid-template-columns: repeat(4,minmax(0,1fr)); }
+.summary-grid article { position: relative; overflow: hidden; }
+.summary-grid article::after { position: absolute; top: 0; right: 0; width: 46px; height: 4px; border-radius: 0 0 0 5px; background: #8cc6b3; content: ''; }
+.summary-grid .pay-card::after { background: #d79b3a; }
+.summary-grid .pay-card strong { color: #a55d10; }
+.commission-note { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 18px; padding: 13px 15px; border: 1px solid #ead9b8; border-radius: 13px; color: #785a27; background: #fffaf0; }
+.commission-note span,.commission-note strong { display: block; }
+.commission-note strong { margin-bottom: 2px; color: #64481a; }
+.detail-card { min-height: 360px; }
+.detail-tabs { display: flex; gap: 4px; overflow-x: auto; padding: 10px 14px 0; border-bottom: 1px solid #e5ece9; }
+.detail-tabs button { display: inline-flex; min-height: 44px; align-items: center; gap: 7px; padding: 9px 13px; border: 0; border-bottom: 3px solid transparent; color: #61736b; background: transparent; font-weight: 750; white-space: nowrap; }
+.detail-tabs button.active { border-color: #177456; color: #176d52; }
+.detail-tabs em { min-width: 23px; padding: 2px 6px; border-radius: 999px; color: #71827b; background: #edf3f0; font-size: 10px; font-style: normal; }
+.detail-tabs button.active em { color: #176d52; background: #e4f3ed; }
+.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 17px 18px; border-bottom: 1px solid #edf2ef; }
+.section-heading strong,.section-heading span { display: block; }
+.section-heading strong { font-size: 16px; }
+.section-heading span { margin-top: 3px; color: #7b8b85; font-size: 12px; }
+.inline-search { display: flex; width: min(340px,100%); min-height: 40px; align-items: center; gap: 8px; padding: 0 12px; border: 1px solid #d8e3de; border-radius: 12px; color: #71857d; background: #fff; }
+.inline-search input { width: 100%; min-width: 0; border: 0; outline: 0; color: #293e35; background: transparent; }
+.data-table { min-width: 1050px; }
+.data-table td > small { max-width: 320px; line-height: 1.45; }
+.detail-link { display: inline-flex; min-height: 32px; align-items: center; padding: 6px 10px; border: 1px solid #cfe0d9; border-radius: 10px; color: #176d52; text-decoration: none; white-space: nowrap; }
+.schedule-table td:first-child small { display: flex; align-items: center; gap: 4px; }
+.status-pill { display: inline-flex; padding: 5px 9px; border-radius: 999px; font-size: 11px; font-weight: 800; white-space: nowrap; }
+.status-pill[data-tone='pending'] { color: #9a5a08; background: #fff0d8; }
+.status-pill[data-tone='active'] { color: #146b90; background: #e4f5fb; }
+.status-pill[data-tone='confirming'] { color: #6049a6; background: #eee9ff; }
+.status-pill[data-tone='completed'] { color: #197052; background: #e4f4ed; }
+.status-pill[data-tone='cancelled'],.status-pill[data-tone='neutral'] { color: #687670; background: #edf1ef; }
+.progress { width: 120px; height: 7px; overflow: hidden; border-radius: 999px; background: #e9efec; }
+.progress span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#218261,#4ab68e); }
+.spin { animation: spin .8s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+@media (max-width: 1120px) {
+  .store-profile { grid-template-columns: auto 1fr; }
+  .store-profile dl { grid-column: 1/-1; }
+  .summary-grid { grid-template-columns: repeat(2,minmax(0,1fr)); }
+}
+@media (max-width: 720px) {
+  .detail-header,.source-strip,.section-heading { align-items: stretch; flex-direction: column; }
+  .header-actions { display: grid; grid-template-columns: 1fr 1fr; width: 100%; }
+  .month-control input { width: 100%; }
+  .source-meta { text-align: left; }
+  .store-profile { grid-template-columns: auto 1fr; padding: 16px; }
+  .store-profile dl { grid-template-columns: 1fr; }
+  .summary-grid { gap: 10px; }
+  .summary-grid article { padding: 15px; }
+  .inline-search { width: 100%; }
+}
+@media (max-width: 440px) {
+  .header-actions { grid-template-columns: 1fr; }
+  .summary-grid { grid-template-columns: 1fr 1fr; }
+  .summary-grid strong { font-size: 23px; }
+}

+ 64 - 0
src/app/admin/pages/operations-store-detail.component.spec.ts

@@ -0,0 +1,64 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
+import { StoreDetailData } from '../admin.models';
+import { OperationsService } from '../operations.service';
+import { OperationsStoreDetailComponent } from './operations-store-detail.component';
+
+function storeDetail(month: string): StoreDetailData {
+  return {
+    store: {
+      objectId: 'store-1', userId: 2, username: 'store', storeName: '南昌总店', contactName: '负责人', mobile: '', mobileMasked: '138****0000',
+      parentStoreId: 0, parentStoreName: '', memberCount: 1, coachCount: 1, todayAppointments: 0, totalAppointments: 1, lastAppointmentAt: '',
+      registeredAt: '2024-01-01T00:00:00.000Z', lastLoginAt: '', loginCount: 0, status: 'active', statusLabel: '正常', updatedAt: '',
+    },
+    month,
+    summary: { memberCount: 1, coachCount: 1, monthAppointments: 1, completedAppointments: 0, activeStudents: 1, lessonCount: 1, totalHours: 1, estimatedCoachPay: 40 },
+    members: [{ objectId: 'member-1', userId: 100, displayName: '测试学员', username: 'student', mobile: '', mobileMasked: '139****0000', groupId: 1, groupName: '会员', parentUserId: 2, parentDisplayName: '南昌总店', periods30: 1, periods60: 1, trialPeriods: 0, activeCourseCount: 1, completedCourseCount: 0, studyRecordCount: 0, appointmentCount: 1, learningStatus: 'studying', learningStatusLabel: '学习中', learningStatusReason: '有排课', registeredAt: '', lastLoginAt: '', loginCount: 0, status: 'active', statusLabel: '正常', canRevealMobile: false, updatedAt: '' }],
+    coaches: [{ objectId: 'coach-1', userId: 200, username: 'coach', displayName: '测试陪练', mobile: '', mobileMasked: '137****0000', parentUserId: 2, storeName: '南昌总店', status: 'active', statusLabel: '正常', todayAppointments: 0, pendingAppointments: 1, totalAppointments: 1, lastAppointmentAt: '', monthLessons: 1, monthHours: 1, estimatedPay: 40, averageScore: 5, lastLessonAt: '', availabilityConfigured: false, updatedAt: '' }],
+    appointments: [{ targetId: 'appointment-1', generalId: 1, studentObjectId: 'member-1', studentId: 100, studentName: '测试学员', coachObjectId: 'coach-1', coachId: 200, coachName: '测试陪练', courseId: 300, courseName: '测试课程', classType: 2, classTypeLabel: '60 分钟', deliveryMethod: '线上', startsAt: `${month}-10T02:00:00.000Z`, endsAt: `${month}-10T03:00:00.000Z`, statusCode: 0, statusLabel: '待开始', cancelled: false, cancellationReason: '', coachAccountMissing: false, availabilityWarning: false, source: 'parse', sourceLabel: '新系统', readOnly: false, updatedAt: '' }],
+    courses: [{ courseId: 300, courseName: '测试课程', appointmentCount: 1, completedCount: 0, studentCount: 1, completionRate: 0, lastAppointmentAt: `${month}-10T02:00:00.000Z` }],
+    commissionStatus: { available: false, message: '旧系统没有可验证的门店佣金规则' },
+    payrollSourceStatus: { mode: 'live', available: true, message: '', total: 1, refreshedAt: '' },
+    refreshedAt: '',
+  };
+}
+
+describe('OperationsStoreDetailComponent', () => {
+  let fixture: ComponentFixture<OperationsStoreDetailComponent>;
+  let operations: jasmine.SpyObj<OperationsService>;
+
+  beforeEach(async () => {
+    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['storeDetail']);
+    operations.storeDetail.and.callFake((_objectId, month) => Promise.resolve(storeDetail(month)));
+    await TestBed.configureTestingModule({
+      imports: [OperationsStoreDetailComponent],
+      providers: [
+        provideRouter([]),
+        { provide: OperationsService, useValue: operations },
+        { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ objectId: 'store-1' }) } } },
+      ],
+    }).compileComponents();
+    fixture = TestBed.createComponent(OperationsStoreDetailComponent);
+    fixture.detectChanges();
+    await fixture.whenStable();
+    fixture.detectChanges();
+  });
+
+  it('loads store operations and changes its reporting month', async () => {
+    const component = fixture.componentInstance;
+    expect(component.data()?.store.storeName).toBe('南昌总店');
+    component.changeMonth('2026-08');
+    await fixture.whenStable();
+    expect(operations.storeDetail).toHaveBeenCalledWith('store-1', '2026-08', false);
+    expect(component.data()?.month).toBe('2026-08');
+  });
+
+  it('keeps members, coaches, schedule and course fulfillment as business tabs', () => {
+    const element: HTMLElement = fixture.nativeElement;
+    expect(element.textContent).toContain('归属会员');
+    expect(element.textContent).toContain('归属陪练');
+    expect(element.textContent).toContain('课程履约');
+    expect(element.textContent).toContain('门店佣金暂不估算');
+    expect(fixture.componentInstance.statusTone(storeDetail('2026-09').appointments[0])).toBe('pending');
+  });
+});

+ 84 - 0
src/app/admin/pages/operations-store-detail.component.ts

@@ -0,0 +1,84 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, RouterLink } from '@angular/router';
+import { ArrowLeft, Building2, CalendarDays, Clock3, GraduationCap, RefreshCw, Search, UserRound, Users, WalletCards } from 'lucide-angular';
+import { LucideAngularModule } from 'lucide-angular';
+import { ScheduleAppointment, StoreDetailData } from '../admin.models';
+import { OperationsService } from '../operations.service';
+
+type StoreDetailTab = 'members' | 'coaches' | 'schedule' | 'courses';
+
+const CURRENT_MONTH = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }).slice(0, 7);
+
+@Component({
+  selector: 'app-operations-store-detail',
+  standalone: true,
+  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  templateUrl: './operations-store-detail.component.html',
+  styleUrls: ['./operations-professional.scss', './operations-store-detail.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OperationsStoreDetailComponent implements OnInit {
+  readonly data = signal<StoreDetailData | null>(null);
+  readonly loading = signal(true);
+  readonly error = signal('');
+  readonly month = signal(CURRENT_MONTH);
+  readonly tab = signal<StoreDetailTab>('members');
+  readonly memberSearch = signal('');
+  readonly coachSearch = signal('');
+  readonly scheduleSearch = signal('');
+  readonly icons = { ArrowLeft, Building2, CalendarDays, Clock3, GraduationCap, RefreshCw, Search, UserRound, Users, WalletCards };
+
+  readonly members = computed(() => {
+    const keyword = this.memberSearch().trim().toLowerCase();
+    const rows = this.data()?.members || [];
+    return keyword ? rows.filter((row) => [row.displayName, row.nickname, row.username, row.mobile, row.mobileMasked, row.userId].some((value) => String(value || '').toLowerCase().includes(keyword))) : rows;
+  });
+
+  readonly coaches = computed(() => {
+    const keyword = this.coachSearch().trim().toLowerCase();
+    const rows = this.data()?.coaches || [];
+    return keyword ? rows.filter((row) => [row.displayName, row.username, row.mobile, row.mobileMasked, row.userId].some((value) => String(value || '').toLowerCase().includes(keyword))) : rows;
+  });
+
+  readonly appointments = computed(() => {
+    const keyword = this.scheduleSearch().trim().toLowerCase();
+    const rows = this.data()?.appointments || [];
+    return keyword ? rows.filter((row) => [row.studentName, row.coachName, row.courseName, row.classTypeLabel, row.statusLabel].some((value) => String(value || '').toLowerCase().includes(keyword))) : rows;
+  });
+
+  private readonly operations = inject(OperationsService);
+  private readonly objectId = inject(ActivatedRoute).snapshot.paramMap.get('objectId') || '';
+
+  ngOnInit(): void { void this.load(); }
+
+  async load(refresh = false): Promise<void> {
+    this.loading.set(true);
+    this.error.set('');
+    try {
+      const detail = await this.operations.storeDetail(this.objectId, this.month(), refresh);
+      this.data.set(detail);
+      this.month.set(detail.month);
+    } catch (error) {
+      this.error.set(error instanceof Error ? error.message : '门店经营数据加载失败');
+    } finally {
+      this.loading.set(false);
+    }
+  }
+
+  changeMonth(value: string): void {
+    if (!/^\d{4}-\d{2}$/.test(value) || value === this.month()) return;
+    this.month.set(value);
+    void this.load();
+  }
+
+  statusTone(row: ScheduleAppointment): string {
+    if (row.cancelled) return 'cancelled';
+    if (row.statusCode === 0) return 'pending';
+    if (row.statusCode === 10) return 'active';
+    if ([11, 20].includes(row.statusCode)) return 'confirming';
+    if (row.statusCode >= 30) return 'completed';
+    return 'neutral';
+  }
+}

+ 3 - 3
src/app/admin/pages/operations-stores.component.html

@@ -7,7 +7,7 @@
 </header>
 
 <section class="summary-grid" aria-label="门店运营摘要">
-  <article><span>门店总数</span><strong>{{page()?.summary?.total||0}}</strong><small>{{page()?.summary?.active||0}} 家正常运营</small></article>
+  <article><span>门店总数</span><strong>{{page()?.summary?.total||0}}</strong><small>{{page()?.summary?.active||0}} 家正常运营@if(page()?.summary?.liveOnly){ · {{page()?.summary?.liveOnly}} 家待同步}</small></article>
   <article><span>归属会员</span><strong>{{page()?.summary?.members||0}}</strong><small>按门店关系归集</small></article>
   <article><span>归属陪练</span><strong>{{page()?.summary?.coaches||0}}</strong><small>可用于排课与工资统计</small></article>
   <article><span>今日排课</span><strong>{{page()?.summary?.todayAppointments||0}}</strong><small>已归属门店的课程</small></article>
@@ -29,13 +29,13 @@
   <header><div><lucide-icon [img]="icons.Building2" [size]="19"/><strong>门店名单</strong></div><span>{{page()?.total||0}} 家 · {{page()?.sync?.label||'已读取当前数据'}}</span></header>
   <div class="table-scroll"><table class="data-table"><thead><tr><th>门店</th><th>上级门店</th><th>联系方式</th><th>会员</th><th>陪练</th><th>今日排课</th><th>历史排课</th><th>最近排课</th><th>最后登录</th><th>状态</th><th>操作</th></tr></thead><tbody>
     @for(row of page()?.items||[];track row.objectId){<tr>
-      <td><strong>{{row.storeName}}</strong><small>{{row.username}} · ID {{row.userId}}</small></td>
+      <td><strong>{{row.storeName}}</strong><small>{{row.username}} · ID {{row.userId}}</small>@if(row.sourceLabel){<small class="source-note">{{row.sourceLabel}}</small>}</td>
       <td>{{row.parentStoreName||'总店/未归属'}}</td>
       <td>{{row.contactName||'未设置联系人'}}<small>{{row.mobile||row.mobileMasked||'未留手机号'}}</small></td>
       <td><strong>{{row.memberCount}}</strong> 人</td><td><strong>{{row.coachCount}}</strong> 人</td><td>{{row.todayAppointments}} 节</td><td>{{row.totalAppointments}} 节</td>
       <td>{{row.lastAppointmentAt?(row.lastAppointmentAt|date:'yyyy-MM-dd HH:mm'):'暂无'}}</td><td>{{row.lastLoginAt?(row.lastLoginAt|date:'yyyy-MM-dd HH:mm'):'从未登录'}}</td>
       <td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span></td>
-      <td><div class="row-actions">@if(page()?.canManage){<button class="icon-action" type="button" (click)="openEdit(row)" aria-label="编辑门店" title="编辑"><lucide-icon [img]="icons.Pencil" [size]="16"/></button><button class="icon-action danger" type="button" (click)="openEdit(row,true)" aria-label="删除门店" title="删除"><lucide-icon [img]="icons.Trash2" [size]="16"/></button>}@else{<span class="read-only">只读</span>}</div></td>
+      <td><div class="row-actions">@if(!row.readOnly){<a class="icon-action" [routerLink]="['/admin/stores',row.objectId]" aria-label="查看门店经营详情" title="经营详情"><lucide-icon [img]="icons.Eye" [size]="16"/></a>}@else{<span class="readonly-label">待同步</span>}@if(page()?.canManage&&!row.readOnly){<button class="icon-action" type="button" (click)="openEdit(row)" aria-label="编辑门店" title="编辑"><lucide-icon [img]="icons.Pencil" [size]="16"/></button><button class="icon-action danger" type="button" (click)="openEdit(row,true)" aria-label="删除门店" title="删除"><lucide-icon [img]="icons.Trash2" [size]="16"/></button>}</div></td>
     </tr>}@empty{<tr><td colspan="11"><div class="empty">没有符合条件的门店</div></td></tr>}
   </tbody></table></div>
   <footer class="pagination"><span>第 {{page()?.page||1}} / {{pageCount()}} 页</span><div><button type="button" [disabled]="(page()?.page||1)<=1" (click)="load((page()?.page||1)-1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17"/></button><button type="button" [disabled]="(page()?.page||1)>=pageCount()" (click)="load((page()?.page||1)+1)"><lucide-icon [img]="icons.ChevronRight" [size]="17"/></button></div></footer>

+ 1 - 1
src/app/admin/pages/operations-stores.component.scss

@@ -22,7 +22,7 @@
 .summary-grid small { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
 
 .data-table { min-width: 1360px; }
-.row-actions { min-width: 82px; flex-wrap: nowrap; }
+.row-actions { min-width: 122px; flex-wrap: nowrap; }
 .store-editor { width: min(640px, 100%); }
 .store-editor header p { margin: 0; color: #258b68; font-size: 11px; font-weight: 800; letter-spacing: .16em; }
 .store-editor header h2 { margin: 5px 0 7px; }

+ 8 - 3
src/app/admin/pages/operations-stores.component.ts

@@ -1,7 +1,8 @@
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
-import { Building2, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Search, Trash2, Users, X } from 'lucide-angular';
+import { RouterLink } from '@angular/router';
+import { Building2, ChevronLeft, ChevronRight, Eye, Pencil, Plus, RefreshCw, Search, Trash2, Users, X } from 'lucide-angular';
 import { LucideAngularModule } from 'lucide-angular';
 import { StorePage, StoreSummary } from '../admin.models';
 import { OperationsService } from '../operations.service';
@@ -9,7 +10,7 @@ import { OperationsService } from '../operations.service';
 @Component({
   selector: 'app-operations-stores',
   standalone: true,
-  imports: [CommonModule, FormsModule, LucideAngularModule],
+  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './operations-stores.component.html',
   styleUrls: ['./operations-professional.scss', './operations-stores.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
@@ -35,7 +36,7 @@ export class OperationsStoresComponent implements OnInit {
   readonly disabled = signal(false);
   readonly reason = signal('');
 
-  readonly icons = { Building2, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Search, Trash2, Users, X };
+  readonly icons = { Building2, ChevronLeft, ChevronRight, Eye, Pencil, Plus, RefreshCw, Search, Trash2, Users, X };
   private readonly operations = inject(OperationsService);
 
   ngOnInit(): void { void this.load(1); }
@@ -81,6 +82,10 @@ export class OperationsStoresComponent implements OnInit {
   }
 
   openEdit(row: StoreSummary, deleteMode = false): void {
+    if (row.readOnly) {
+      this.message.set('该门店来自旧系统实时数据,持续同步落库前暂不允许在新后台单边修改。');
+      return;
+    }
     this.editing.set(row);
     this.username.set(row.username);
     this.password.set('');

+ 2 - 1
src/app/admin/pages/operations-user-detail.component.html

@@ -4,6 +4,7 @@
 @else if (error() && !user()) { <div class="state error" role="alert">{{ error() }}</div> }
 @else { @if (user(); as detail) {
   <section class="profile-summary"><div class="avatar"><lucide-icon [img]="icons.UserRound" [size]="28" /></div><div><h2>{{ detail.displayName }}</h2><p>会员 ID {{ detail.userId || '—' }} · {{ detail.username }}</p></div><div class="profile-facts"><span>{{ detail.learningStatusLabel }}</span><span>{{ detail.parentDisplayName || '未绑定代理' }}</span><span>{{ detail.registeredAt | date:'yyyy-MM-dd' }} 注册</span></div><span class="status" [class.disabled]="detail.status === 'disabled'">{{ detail.statusLabel }}</span></section>
+  @if (detail.writeReady === false) { <p class="notice" role="status">{{ detail.readOnlyReason }}</p> }
   @if (message()) { <p class="notice" role="status">{{ message() }}</p> } @if (error()) { <p class="notice error" role="alert">{{ error() }}</p> }
 
   <nav class="tabs" aria-label="会员详情分类">
@@ -34,5 +35,5 @@
     @case ('audit') { <div class="audit-grid"><section class="panel"><header><div><h2>运营跟进备注</h2><span>记录后续联系和服务事项</span></div></header>@if (detail.permissions.canEdit) { <label><span>新增备注</span><textarea [ngModel]="noteContent()" (ngModelChange)="noteContent.set($event)" placeholder="填写需要跟进的事项或沟通结果"></textarea></label><button class="primary" type="button" (click)="saveNote()" [disabled]="saving()"><lucide-icon [img]="icons.MessageSquarePlus" [size]="16" />保存备注</button> }<div class="timeline">@for (note of notes(); track note['objectId']) { <article><strong>{{ note['operatorName'] || '运营人员' }}</strong><time>{{ $any(note['createdAt']) | date:'yyyy-MM-dd HH:mm' }}</time><p>{{ note['content'] }}</p></article> } @empty { <p class="muted">暂无跟进备注</p> }</div></section><section class="panel"><header><div><h2>操作审计</h2><span>账号、资料、关系、课程和课时修改记录</span></div></header><div class="timeline audit">@for (item of related().audit; track item['objectId']) { <article><strong>{{ item['operatorName'] || '系统' }}</strong><time>{{ $any(item['createdAt']) | date:'yyyy-MM-dd HH:mm' }}</time><p>{{ item['summary'] || '会员业务操作' }}</p></article> } @empty { <p class="muted">暂无操作审计记录</p> }</div></section></div> }
   }
 
-  <section class="reason-bar"><label><span>本次操作原因 *</span><input [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="保存资料、绑定课程、调整课时等操作前必须填写" /></label><small>操作原因会与修改前后数据一起写入审计日志</small></section>
+  @if (detail.permissions.canEdit || detail.permissions.canManage) { <section class="reason-bar"><label><span>本次操作原因 *</span><input [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="保存资料、绑定课程、调整课时等操作前必须填写" /></label><small>操作原因会与修改前后数据一起写入审计日志</small></section> }
 } }

+ 1 - 1
src/app/admin/pages/operations-user-detail.component.ts

@@ -39,7 +39,7 @@ export class OperationsUserDetailComponent implements OnInit {
     try {
       if (tab === 'audit') { const [notes, audit] = await Promise.all([this.operations.memberRows('ops/users/notes', user.objectId), this.operations.memberRows('ops/users/audit', user.objectId)]); this.notes.set(notes.items); this.related.update(value => ({ ...value, audit: audit.items })); }
       else { const kind = this.kindFor(tab); if (kind) { const page = await this.operations.list(kind, 1, 100, String(user.userId)); this.related.update(value => ({ ...value, [tab]: page.items })); } }
-      if (tab === 'courses' && !this.courseOptions().length) { const today = new Date().toISOString().slice(0, 10); const schedule = await this.operations.schedule(today, today); this.courseOptions.set(schedule.courses); }
+      if (tab === 'courses' && user.permissions.canEdit && !this.courseOptions().length) { const today = new Date().toISOString().slice(0, 10); const schedule = await this.operations.schedule(today, today); this.courseOptions.set(schedule.courses); }
     } catch (error) { this.error.set(error instanceof Error ? error.message : '业务记录加载失败'); }
     finally { this.sectionLoading.set(false); }
   }

Plik diff jest za duży
+ 2 - 2
src/app/admin/pages/operations-users.component.html


Plik diff jest za duży
+ 0 - 0
src/app/admin/pages/operations-users.component.scss


+ 59 - 3
src/app/admin/pages/operations-users.component.ts

@@ -2,8 +2,9 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { RouterLink } from '@angular/router';
-import { ChevronLeft, ChevronRight, Download, Eye, LoaderCircle, LucideAngularModule, Plus, RefreshCw, Search, UserPlus, Users, X } from 'lucide-angular';
-import { MemberAgentOption, MemberListSort, MemberPage } from '../admin.models';
+import { ChevronLeft, ChevronRight, Download, Eye, FileSpreadsheet, LoaderCircle, LucideAngularModule, Plus, RefreshCw, Search, Upload, UserPlus, Users, X } from 'lucide-angular';
+import { read, utils, writeFile } from 'xlsx';
+import { MemberAgentOption, MemberImportPreview, MemberListSort, MemberPage } from '../admin.models';
 import { AdminSessionService } from '../admin-session.service';
 import { OperationsService } from '../operations.service';
 
@@ -19,10 +20,12 @@ export class OperationsUsersComponent implements OnInit {
   readonly parentUserId = signal(0); readonly registeredFrom = signal(''); readonly registeredTo = signal(''); readonly sortValue = signal('registeredAt:desc');
   readonly loading = signal(true); readonly saving = signal(false); readonly exporting = signal(false); readonly error = signal(''); readonly message = signal(''); readonly lastRefreshedAt = signal('');
   readonly createOpen = signal(false); readonly createName = signal(''); readonly createNickname = signal(''); readonly createMobile = signal(''); readonly createGender = signal(''); readonly createGrade = signal(''); readonly createPassword = signal(''); readonly createParentUserId = signal(0); readonly createAgentSearch = signal(''); readonly createReason = signal('新增会员');
-  readonly icons = { ChevronLeft, ChevronRight, Download, Eye, LoaderCircle, Plus, RefreshCw, Search, UserPlus, Users, X };
+  readonly importOpen = signal(false); readonly importFileName = signal(''); readonly importRows = signal<Record<string, unknown>[]>([]); readonly importPreview = signal<MemberImportPreview | null>(null); readonly importReason = signal('批量导入会员'); readonly importing = signal(false); readonly importIdempotencyKey = signal('');
+  readonly icons = { ChevronLeft, ChevronRight, Download, Eye, FileSpreadsheet, LoaderCircle, Plus, RefreshCw, Search, Upload, UserPlus, Users, X };
   private readonly operations = inject(OperationsService); private readonly sessions = inject(AdminSessionService);
 
   readonly canCreate = computed(() => this.sessions.session()?.operationsRole !== 'ops-auditor');
+  readonly canBulkImport = computed(() => this.sessions.session()?.operationsRole === 'ops-manager');
   readonly filteredCreateAgents = computed(() => { const keyword = this.createAgentSearch().trim().toLowerCase(); return (this.page()?.agents || []).filter(agent => !keyword || `${agent.displayName} ${agent.userId}`.toLowerCase().includes(keyword)).slice(0, 30); });
 
   ngOnInit(): void { void this.load(1); }
@@ -42,6 +45,59 @@ export class OperationsUsersComponent implements OnInit {
   clearCreateAgent(): void { this.createParentUserId.set(0); this.createAgentSearch.set(''); }
   openCreate(): void { this.createName.set(''); this.createNickname.set(''); this.createMobile.set(''); this.createGender.set(''); this.createGrade.set(''); this.createPassword.set(''); this.clearCreateAgent(); this.createReason.set('新增会员'); this.error.set(''); this.createOpen.set(true); }
 
+  openImport(): void {
+    this.importFileName.set(''); this.importRows.set([]); this.importPreview.set(null); this.importReason.set('批量导入会员'); this.importIdempotencyKey.set(''); this.error.set(''); this.importOpen.set(true);
+  }
+
+  closeImport(): void {
+    this.importOpen.set(false); this.importFileName.set(''); this.importRows.set([]); this.importPreview.set(null); this.importIdempotencyKey.set('');
+  }
+
+  downloadImportTemplate(): void {
+    const sheet = utils.aoa_to_sheet([['姓名', '昵称', '手机号', '性别', '年级', '邮箱', '门店ID', '所属门店', '临时密码']]);
+    sheet['!cols'] = [{ wch: 14 }, { wch: 14 }, { wch: 20 }, { wch: 10 }, { wch: 14 }, { wch: 22 }, { wch: 20 }, { wch: 22 }, { wch: 18 }];
+    const instructions = utils.aoa_to_sheet([
+      ['字段', '填写要求'], ['姓名', '必填,最长 100 字'], ['昵称', '可选,不填时使用姓名'], ['手机号', '必填,11 位;同时作为登录账号'], ['性别', '可选:男、女或其他'], ['年级', '可选,最长 50 字'], ['邮箱', '可选,需为有效邮箱'], ['门店ID / 所属门店', '二选一;名称重复时必须填写门店 ID'], ['临时密码', '必填,8 至 72 位;预检批次与日志均不保存明文'], ['单次上限', '200 人;请勿改动“会员导入”工作表的表头'],
+    ]); instructions['!cols'] = [{ wch: 24 }, { wch: 58 }];
+    const workbook = utils.book_new(); utils.book_append_sheet(workbook, sheet, '会员导入'); utils.book_append_sheet(workbook, instructions, '填写说明'); writeFile(workbook, '小树陪练会员导入模板.xlsx');
+  }
+
+  async selectImportFile(event: Event): Promise<void> {
+    const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = '';
+    if (!file) return;
+    this.error.set(''); this.importPreview.set(null);
+    if (!/\.xlsx$/i.test(file.name)) { this.error.set('请选择 .xlsx 格式的会员导入文件'); return; }
+    if (file.size > 5 * 1024 * 1024) { this.error.set('会员导入文件不能超过 5 MB'); return; }
+    try {
+      const workbook = read(await file.arrayBuffer(), { type: 'array', cellDates: false }); const firstSheet = workbook.SheetNames[0];
+      if (!firstSheet) throw new Error('Excel 中没有可读取的工作表');
+      const rows = utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[firstSheet], { defval: '', raw: false });
+      if (!rows.length) throw new Error('Excel 中没有会员数据');
+      if (rows.length > 200) throw new Error('一次最多导入 200 位会员,请拆分文件后重试');
+      this.importFileName.set(file.name); this.importRows.set(rows); this.importIdempotencyKey.set(crypto.randomUUID());
+    } catch (error) { this.importFileName.set(''); this.importRows.set([]); this.error.set(error instanceof Error ? error.message : 'Excel 文件读取失败'); }
+  }
+
+  async previewMembers(): Promise<void> {
+    if (!this.importRows().length) { this.error.set('请先选择会员 Excel 文件'); return; }
+    if (this.importReason().trim().length < 2) { this.error.set('请填写至少 2 个字的操作原因'); return; }
+    this.importing.set(true); this.error.set('');
+    try { this.importPreview.set(await this.operations.previewMemberImport(this.importFileName(), this.importRows(), this.importReason(), this.importIdempotencyKey())); }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '会员导入预检失败'); }
+    finally { this.importing.set(false); }
+  }
+
+  async commitMembers(): Promise<void> {
+    const preview = this.importPreview();
+    if (!preview || preview.summary.error) { this.error.set('请先完成预检并修正全部错误'); return; }
+    this.importing.set(true); this.error.set('');
+    try {
+      const result = await this.operations.commitMemberImport(preview.batchId, this.importFileName(), this.importRows(), this.importReason(), this.importIdempotencyKey());
+      this.closeImport(); this.message.set(`批量导入完成:新增 ${result.created} 人,断点恢复 ${result.resumed} 人`); await this.load(1, true);
+    } catch (error) { this.error.set(error instanceof Error ? error.message : '会员批量导入失败'); }
+    finally { this.importing.set(false); }
+  }
+
   async createMember(): Promise<void> {
     if (!this.createName().trim() || !/^1\d{10}$/.test(this.createMobile()) || this.createPassword().length < 8 || this.createReason().trim().length < 2) { this.error.set('请填写姓名、有效手机号、至少 8 位临时密码和操作原因'); return; }
     this.saving.set(true); this.error.set('');

+ 5 - 0
src/app/admin/pages/operations-vocabulary.component.spec.ts

@@ -37,4 +37,9 @@ describe('OperationsVocabularyComponent', () => {
     expect(component.audioSource('/UploadFiles/unit/apple.mp3')).toBe('https://legacy.example/UploadFiles/unit/apple.mp3');
     expect(component.audioSource('https://cdn.example/apple.mp3')).toBe('https://cdn.example/apple.mp3');
   });
+
+  it('keeps the normalized old-site audio URL unchanged', () => {
+    const url = 'https://a018.2018.z01.com/UploadFiles/audio/c/cherish.mp3';
+    expect(component.audioSource(url)).toBe(url);
+  });
 });

+ 13 - 3
src/app/core/all-routes.integration.spec.ts

@@ -13,7 +13,12 @@ import { SessionService } from './session.service';
 
 class FakeApiService {
   get<T>(): Observable<ApiEnvelope<T>> { return of({ retcode: 0, result: [] as T }); }
-  post<T>(): Observable<ApiEnvelope<T>> { return of({ retcode: 0, result: [] as T }); }
+  post<T>(action = ''): Observable<ApiEnvelope<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 });
+    return of({ retcode: 0, result: [] as T });
+  }
   upload(): Observable<ApiEnvelope<string>> { return of({ retcode: 0, result: '/upload/test.png' }); }
   assetUrl(path: unknown): string { return String(path || '/static/images/logo.png'); }
 }
@@ -61,10 +66,15 @@ describe('all migrated routes', () => {
   it('instantiates every legacy route with an authenticated session', async () => {
     const harness = await RouterTestingHarness.create();
     const router = TestBed.inject(Router);
+    const coachOnlyRoutes = new Set([
+      'pages/stu/stu_abc', 'pages/stu/stu_con', 'pages/stu/stu3', 'pages/stu/stu4',
+      'pages/stu/stu5', 'pages/stu/stu6', 'pages/stu/stu_ok', 'pages/stu/pl_stu5', 'pages/stu/pl_end',
+    ]);
     for (const page of PAGE_CATALOG) {
       await harness.navigateByUrl(`/${page.path}`);
-      expect(router.url.split(/[?#]/, 1)[0]).withContext(page.path).toBe(`/${page.path}`);
-      expect(document.title).withContext(page.path).toContain(page.title);
+      const studentRedirect = page.path === 'pages/stu/index' || coachOnlyRoutes.has(page.path);
+      expect(router.url.split(/[?#]/, 1)[0]).withContext(page.path).toBe(studentRedirect ? '/pages/home/cart' : `/${page.path}`);
+      expect(document.title).withContext(page.path).toContain(studentRedirect ? '伴学中心' : page.title);
     }
   });
 });

+ 88 - 26
src/app/core/api.service.spec.ts

@@ -1,6 +1,7 @@
 import { provideHttpClient } from '@angular/common/http';
 import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
 import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { Router } from '@angular/router';
 import { API_CONFIG } from './app.constants';
 import { ApiError, ApiService } from './api.service';
 import { SessionService } from './session.service';
@@ -9,10 +10,19 @@ describe('ApiService', () => {
   let api: ApiService;
   let http: HttpTestingController;
   let sessionToken = '';
+  let sessionLogout: jasmine.Spy;
+  let routerNavigate: jasmine.Spy;
 
   beforeEach(() => {
     sessionToken = '';
-    TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting(), { provide: SessionService, useValue: { token: () => sessionToken } }] });
+    sessionLogout = jasmine.createSpy('logout');
+    routerNavigate = jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true));
+    TestBed.configureTestingModule({ providers: [
+      provideHttpClient(),
+      provideHttpClientTesting(),
+      { provide: SessionService, useValue: { token: () => sessionToken, user: () => ({ objectId: 'member-42', userId: 42 }), logout: sessionLogout } },
+      { provide: Router, useValue: { url: '/pages/his/his?date=2026-09-02', navigate: routerNavigate } },
+    ] });
     api = TestBed.inject(ApiService);
     http = TestBed.inject(HttpTestingController);
   });
@@ -30,11 +40,31 @@ describe('ApiService', () => {
     expect(actual).toEqual([{ ID: 1 }]);
   });
 
-  it('keeps not-yet-migrated actions on the legacy gateway', () => {
+  it('shares short-lived overview reads and bypasses the cache for an explicit refresh', () => {
+    sessionToken = 'r:session-token';
+    const first: unknown[] = [];
+    api.post('app_account_overview', { uid: 42 }).subscribe((value) => first.push(value.result));
+    api.post('app_account_overview', { uid: 42 }).subscribe((value) => first.push(value.result));
+    const shared = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(shared.request.body).toEqual({ token: 'r:session-token', params: { action: 'app_account_overview', uid: 42 } });
+    shared.flush({ retcode: 0, result: { profile: { userId: 42 } } });
+    expect(first.length).toBe(2);
+
+    api.post('app_account_overview', { uid: 42 }).subscribe((value) => first.push(value.result));
+    http.expectNone(API_CONFIG.cloudFunctionUrl);
+    expect(first.length).toBe(3);
+
+    api.post('app_account_overview', { uid: 42, refresh: true }).subscribe();
+    const refreshed = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(refreshed.request.body).toEqual({ token: 'r:session-token', params: { action: 'app_account_overview', uid: 42, refresh: true } });
+    refreshed.flush({ retcode: 0, result: { profile: { userId: 42 } } });
+  });
+
+  it('routes not-yet-implemented actions through the cloud gateway for an explicit migration error', () => {
     api.get('order_list', { cpage: 2 }).subscribe();
-    const request = http.expectOne((candidate) => candidate.url.includes(API_CONFIG.endpoint) && candidate.urlWithParams.includes('action=order_list'));
-    expect(request.request.method).toBe('GET');
-    expect(request.request.params.get('cpage')).toBe('2');
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.method).toBe('POST');
+    expect(request.request.body).toEqual({ params: { action: 'order_list', cpage: 2 } });
     request.flush({ retcode: 0, result: [] });
   });
 
@@ -42,15 +72,15 @@ describe('ApiService', () => {
     let actual: unknown;
     api.get('order_list', { cpage: 1 }).subscribe({ error: (error) => actual = error });
 
-    http.expectOne((candidate) => candidate.urlWithParams.includes('action=order_list'))
+    http.expectOne(API_CONFIG.cloudFunctionUrl)
       .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
     tick(400);
 
-    http.expectOne((candidate) => candidate.urlWithParams.includes('action=order_list'))
+    http.expectOne(API_CONFIG.cloudFunctionUrl)
       .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
     tick(800);
 
-    http.expectOne((candidate) => candidate.urlWithParams.includes('action=order_list'))
+    http.expectOne(API_CONFIG.cloudFunctionUrl)
       .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
 
     expect(actual instanceof ApiError).toBeTrue();
@@ -76,7 +106,7 @@ describe('ApiService', () => {
     hits.flush({ retcode: 0, result: { hits: 2 } });
   });
 
-  it('passes Parse sessions to private-capable content actions and preserves legacy sessions', () => {
+  it('passes Parse sessions to private-capable content actions and never falls back to the old gateway', () => {
     sessionToken = 'r:session-token';
     api.get('content_get', { id: 931 }).subscribe();
     const cloud = http.expectOne(API_CONFIG.cloudFunctionUrl);
@@ -85,23 +115,22 @@ describe('ApiService', () => {
 
     sessionToken = 'legacy-session';
     api.get('content_get', { id: 931 }).subscribe();
-    const legacyDetail = http.expectOne((candidate) => candidate.urlWithParams.includes('action=content_get'));
-    expect(legacyDetail.request.url).toContain(API_CONFIG.endpoint);
-    legacyDetail.flush({ retcode: 0, result: [{ GeneralID: 931 }] });
+    const migratedDetail = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(migratedDetail.request.body).toEqual({ params: { action: 'content_get', id: 931 } });
+    migratedDetail.flush({ retcode: 0, result: [{ GeneralID: 931 }] });
 
     api.post('content_uphis', { id: 931 }).subscribe();
-    const legacyHits = http.expectOne((candidate) => candidate.urlWithParams.includes('action=content_uphis'));
-    expect(legacyHits.request.url).toContain(API_CONFIG.endpoint);
-    legacyHits.flush({ retcode: 0, result: null });
+    const migratedHits = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(migratedHits.request.body).toEqual({ params: { action: 'content_uphis', id: 931 } });
+    migratedHits.flush({ retcode: 0, result: null });
   });
 
-  it('keeps private addon content without a Parse session and product queries on the legacy gateway', () => {
+  it('routes private addon and blocked product queries through the cloud gateway', () => {
     api.get('content_list', { modelId: 56, myfield2: 'UserId=42' }).subscribe();
     api.get('product_list', { cpage: 1 }).subscribe();
-    const content = http.expectOne((candidate) => candidate.urlWithParams.includes('action=content_list'));
-    const products = http.expectOne((candidate) => candidate.urlWithParams.includes('action=product_list'));
-    expect(content.request.url).toContain(API_CONFIG.endpoint);
-    expect(products.request.url).toContain(API_CONFIG.endpoint);
+    const [content, products] = http.match(API_CONFIG.cloudFunctionUrl);
+    expect(content.request.body).toEqual({ params: { action: 'content_list', modelId: 56, myfield2: 'UserId=42' } });
+    expect(products.request.body).toEqual({ params: { action: 'product_list', cpage: 1 } });
     content.flush({ retcode: 0, result: [] });
     products.flush({ retcode: 0, result: [] });
   });
@@ -226,11 +255,11 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: { GeneralID: '918', status: 11, reviewCount: 15, periodDeducted: true } });
   });
 
-  it('keeps learning detail reads on legacy until a real Parse session exists', () => {
+  it('routes learning detail reads to cloud even when a stale legacy token remains locally', () => {
     sessionToken = 'legacy-session';
     api.get('e_words_list', { uid: 58 }).subscribe();
-    const request = http.expectOne((candidate) => candidate.urlWithParams.includes('action=e_words_list'));
-    expect(request.request.url).toContain(API_CONFIG.endpoint);
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({ params: { action: 'e_words_list', uid: 58 } });
     request.flush({ retcode: 0, result: [] });
   });
 
@@ -253,16 +282,49 @@ describe('ApiService', () => {
     let actual: unknown;
     api.post('user_login_passwd', {}).subscribe({ error: (error) => actual = error });
     http.expectOne(API_CONFIG.cloudFunctionUrl).flush({ retcode: -1, retmsg: '账号或密码错误', result: null }, { status: 401, statusText: 'Unauthorized' });
-    http.expectOne((candidate) => candidate.urlWithParams.includes('action=user_login_passwd')).flush({ retcode: -1, retmsg: '登录失败', result: null });
     expect(actual instanceof ApiError).toBeTrue();
-    expect((actual as ApiError).message).toBe('登录失败');
+    expect((actual as ApiError).message).toBe('账号或密码错误');
   });
 
+  it('clears an expired Parse session and returns to login after an authenticated 401', fakeAsync(() => {
+    sessionToken = 'r:expired-session';
+    let actual: unknown;
+    api.post('app_learning_overview', { uid: 42 }).subscribe({ error: (error) => actual = error });
+    http.expectOne(API_CONFIG.cloudFunctionUrl).flush(
+      { retmsg: '会话已失效' },
+      { status: 401, statusText: 'Unauthorized' },
+    );
+    tick();
+
+    expect(actual instanceof ApiError).toBeTrue();
+    expect(sessionLogout).toHaveBeenCalledTimes(1);
+    expect(routerNavigate).toHaveBeenCalledOnceWith(
+      ['/pages/member/wxauth'],
+      {
+        queryParams: { returnUrl: '/pages/his/his?date=2026-09-02', expired: 1 },
+        replaceUrl: true,
+      },
+    );
+  }));
+
+  it('returns to login when a protected request has no usable session left', fakeAsync(() => {
+    let actual: unknown;
+    api.post('e_get_21list', { uid: 42 }).subscribe({ error: (error) => actual = error });
+    http.expectOne(API_CONFIG.cloudFunctionUrl).flush(
+      { retmsg: '需要登录' },
+      { status: 401, statusText: 'Unauthorized' },
+    );
+    tick();
+
+    expect(actual instanceof ApiError).toBeTrue();
+    expect(sessionLogout).toHaveBeenCalledTimes(1);
+    expect(routerNavigate).toHaveBeenCalledTimes(1);
+  }));
+
   it('keeps a successful password login on the cloud response', () => {
     let actual: unknown;
     api.post('user_login_passwd', { name: 'tree', passwd: 'password' }).subscribe((response) => actual = response.result);
     http.expectOne(API_CONFIG.cloudFunctionUrl).flush({ retcode: 0, result: { userId: 42, sessionToken: 'r:token' } });
-    http.expectNone((candidate) => candidate.url.includes(API_CONFIG.endpoint));
     expect(actual).toEqual({ userId: 42, sessionToken: 'r:token' });
   });
 

+ 75 - 46
src/app/core/api.service.ts

@@ -1,8 +1,9 @@
-import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { inject, Injectable } from '@angular/core';
-import { catchError, map, Observable, retry, throwError, timer } from 'rxjs';
+import { Router } from '@angular/router';
+import { catchError, from, map, Observable, retry, shareReplay, switchMap, throwError, timer } from 'rxjs';
 import { API_CONFIG } from './app.constants';
-import { requiresCloudSession, usesCloudGateway } from './cloud-action-migration';
+import { requiresCloudSession } from './cloud-action-migration';
 import { ApiEnvelope } from './models';
 import { SessionService } from './session.service';
 
@@ -16,33 +17,19 @@ export class ApiError extends Error {
 export class ApiService {
   private readonly http = inject(HttpClient);
   private readonly sessions = inject(SessionService);
+  private readonly router = inject(Router, { optional: true });
+  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;
 
   get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
-    const hasAnySession = Boolean(this.sessions.token());
     const token = this.parseSessionToken();
-    if (usesCloudGateway(action, params, Boolean(token), hasAnySession)) return this.callCloud<T>(action, params, requiresCloudSession(action, params) ? token : '');
-    return this.resilient<T>(this.http.get<unknown>(this.actionUrl(action), { params: this.toHttpParams(params) }));
+    return this.callWithOverviewCache<T>(action, params, requiresCloudSession(action, params) ? token : '');
   }
 
   post<T>(action: string, body: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
-    const hasAnySession = Boolean(this.sessions.token());
     const token = this.parseSessionToken();
-    if (action === 'user_login_passwd' && usesCloudGateway(action, body, Boolean(token), hasAnySession)) {
-      return this.callCloud<T>(action, body).pipe(
-        catchError((error: ApiError) => [401, 403].includes(error.status)
-          ? this.postLegacy<T>(action, body)
-          : throwError(() => error)),
-      );
-    }
-    if (usesCloudGateway(action, body, Boolean(token), hasAnySession)) return this.callCloud<T>(action, body, requiresCloudSession(action, body) ? token : '');
-    return this.postLegacy<T>(action, body);
-  }
-
-  private postLegacy<T>(action: string, body: Record<string, unknown>): Observable<ApiEnvelope<T>> {
-    const encoded = this.toHttpParams(body);
-    return this.resilient<T>(this.http.post<unknown>(this.actionUrl(action), encoded.toString(), {
-      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-    }));
+    return this.callWithOverviewCache<T>(action, body, requiresCloudSession(action, body) ? token : '');
   }
 
   callCloud<T>(action: string, params: Record<string, unknown> = {}, sessionToken = ''): Observable<ApiEnvelope<T>> {
@@ -55,22 +42,15 @@ export class ApiService {
         'Content-Type': 'application/json',
         'X-Parse-Application-Id': API_CONFIG.parseAppId,
       },
-    }));
+    }), sessionToken.startsWith('r:') || requiresCloudSession(action, params));
   }
 
   upload(file: File): Observable<ApiEnvelope<string>> {
-    const formData = new FormData();
-    formData.append('file', file, file.name);
-    return this.resilient<string>(this.http.post<unknown>(this.actionUrl('upload'), formData));
-  }
-
-  actionUrl(action: string): string {
-    const query = new URLSearchParams({
-      apiId: API_CONFIG.apiId,
-      apiKey: API_CONFIG.apiKey,
-      action,
-    });
-    return `${API_CONFIG.baseUrl}${API_CONFIG.endpoint}?${query.toString()}`;
+    return from(this.fileAsBase64(file)).pipe(switchMap((base64) => this.callCloud<string>('upload', {
+      fileName: file.name,
+      contentType: file.type,
+      base64,
+    }, this.parseSessionToken())));
   }
 
   assetUrl(path: unknown): string {
@@ -97,7 +77,16 @@ export class ApiService {
     return normalized;
   }
 
-  private resilient<T>(request: Observable<unknown>): Observable<ApiEnvelope<T>> {
+  private fileAsBase64(file: File): Promise<string> {
+    return new Promise((resolve, reject) => {
+      const reader = new FileReader();
+      reader.onerror = () => reject(new ApiError('文件读取失败'));
+      reader.onload = () => resolve(String(reader.result ?? '').replace(/^data:[^;]+;base64,/i, ''));
+      reader.readAsDataURL(file);
+    });
+  }
+
+  private resilient<T>(request: Observable<unknown>, authenticatedRequest = false): Observable<ApiEnvelope<T>> {
     return request.pipe(
       retry({
         count: 2,
@@ -106,23 +95,49 @@ export class ApiService {
           : throwError(() => error),
       }),
       map((response) => this.normalize<T>(response)),
-      catchError((error) => this.handleError(error)),
+      catchError((error) => this.handleError(error, authenticatedRequest)),
     );
   }
 
-  private toHttpParams(values: Record<string, unknown>): HttpParams {
-    let params = new HttpParams();
-    for (const [key, raw] of Object.entries(values)) {
-      if (raw === undefined || raw === null || raw === '') continue;
-      const value = typeof raw === 'object' ? JSON.stringify(raw) : String(raw);
-      params = params.set(key, value);
+  private callWithOverviewCache<T>(action: string, params: Record<string, unknown>, sessionToken: string): Observable<ApiEnvelope<T>> {
+    if (!this.cachedOverviewActions.has(action)) return this.callCloud<T>(action, params, sessionToken);
+    const now = Date.now();
+    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 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(
+      catchError((error) => {
+        this.overviewCache.delete(key);
+        return throwError(() => error);
+      }),
+      shareReplay({ bufferSize: 1, refCount: false }),
+    );
+    this.overviewCache.set(key, { expiresAt: now + 45_000, response: response as Observable<ApiEnvelope<unknown>> });
+    return response;
+  }
+
+  private sessionCacheIdentity(): string {
+    const current = this.sessions.user();
+    return String(current?.['objectId'] || current?.userId || this.parseSessionToken().slice(-24) || 'anonymous');
+  }
+
+  private stableJson(value: unknown): string {
+    if (Array.isArray(value)) return `[${value.map((item) => this.stableJson(item)).join(',')}]`;
+    if (value && typeof value === 'object') {
+      return `{${Object.entries(value as Record<string, unknown>)
+        .sort(([left], [right]) => left.localeCompare(right))
+        .map(([key, item]) => `${JSON.stringify(key)}:${this.stableJson(item)}`).join(',')}}`;
     }
-    return params;
+    return JSON.stringify(value) ?? 'null';
   }
 
-  private handleError(error: unknown): Observable<never> {
+  private handleError(error: unknown, authenticatedRequest: boolean): Observable<never> {
     if (error instanceof ApiError) return throwError(() => error);
     if (error instanceof HttpErrorResponse) {
+      if (error.status === 401 && authenticatedRequest) this.expireSession();
       const payload = error.error as { retmsg?: string } | string | null;
       const message = this.isTransientError(error)
         ? '服务暂时繁忙,已自动重试但仍未成功,请稍后再试。'
@@ -142,4 +157,18 @@ export class ApiService {
   private isTransientError(error: unknown): boolean {
     return error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status);
   }
+
+  private expireSession(): void {
+    this.overviewCache.clear();
+    this.sessions.logout();
+    if (!this.router || this.sessionRedirecting) return;
+    this.sessionRedirecting = true;
+    const currentUrl = this.router.url && !this.router.url.startsWith('/pages/member/wxauth')
+      ? this.router.url
+      : '/pages/home/index';
+    void this.router.navigate(['/pages/member/wxauth'], {
+      queryParams: { returnUrl: currentUrl, expired: 1 },
+      replaceUrl: true,
+    }).finally(() => this.sessionRedirecting = false);
+  }
 }

+ 0 - 3
src/app/core/app.constants.ts

@@ -5,9 +5,6 @@ const isLocalDevelopment = ['localhost', '127.0.0.1'].includes(globalThis.locati
 
 export const API_CONFIG = {
   baseUrl: isLocalDevelopment ? '/backend' : 'https://test.fmode.cn/engxs',
-  endpoint: '/api/WXAPP',
-  apiId: '206a54b501ad6d7d8df0f78812e156c2',
-  apiKey: '41350aa1dcf7c9444841cdb153b4338d',
   cloudFunctionUrl: isLocalDevelopment
     ? '/cloud-functions/xiaoshu/app/gateway'
     : 'https://server.xiaoshu.pro/api/functions/xiaoshu/app/gateway',

+ 7 - 19
src/app/core/auth.service.spec.ts

@@ -25,27 +25,15 @@ describe('AuthService', () => {
     expect(actual?.['honeyName']).toBe('小树');
   });
 
-  it('keeps mobile login member loading on the legacy gateway', () => {
-    api.post.and.returnValues(
-      of({ retcode: 0, result: { userId: 7, userName: 'mobile' } }),
-      of({ retcode: 0, result: { userId: 7 }, addon: { State: 1 } }),
-    );
+  it('rejects migrated logins that do not return a real Parse session', () => {
+    api.post.and.returnValue(of({ retcode: 0, result: { userId: 9, userName: 'legacy' } }));
+    let message = '';
 
-    auth.login('user_login_mobile', { mobile: '13800000000', vcode: '1234' }).subscribe();
+    auth.login('user_login_passwd', { name: 'legacy', passwd: 'password' }).subscribe({
+      error: (error: Error) => message = error.message,
+    });
 
-    expect(api.post.calls.argsFor(1)).toEqual(['user_get', { uid: 7 }]);
-    expect(api.callCloud).not.toHaveBeenCalled();
-  });
-
-  it('keeps a legacy password login working when no Parse session is returned', () => {
-    api.post.and.returnValues(
-      of({ retcode: 0, result: { userId: 9, userName: 'legacy' } }),
-      of({ retcode: 0, result: { userId: 9 }, addon: { State: 1 } }),
-    );
-
-    auth.login('user_login_passwd', { name: 'legacy', passwd: 'password' }).subscribe();
-
-    expect(api.post.calls.argsFor(1)).toEqual(['user_get', { uid: 9 }]);
+    expect(message).toContain('未生成有效会话');
     expect(api.callCloud).not.toHaveBeenCalled();
   });
 });

+ 4 - 3
src/app/core/auth.service.ts

@@ -12,9 +12,10 @@ export class AuthService {
       switchMap(({ result }) => {
         if (!result?.userId) return throwError(() => new ApiError('登录结果缺少会员信息'));
         const sessionToken = String(result['sessionToken'] ?? '');
-        const memberRequest = sessionToken
-          ? this.api.callCloud<AppUser>('user_get', { uid: result.userId }, sessionToken)
-          : this.api.post<AppUser>('user_get', { uid: result.userId });
+        if (!sessionToken.startsWith('r:')) {
+          return throwError(() => new ApiError('账号迁移登录未生成有效会话,请重新输入账号密码'));
+        }
+        const memberRequest = this.api.callCloud<AppUser>('user_get', { uid: result.userId }, sessionToken);
         return memberRequest.pipe(
           map(({ result: member, addon }) => {
             const extra = this.asRecord(addon);

+ 5 - 10
src/app/core/cloud-action-migration.ts

@@ -21,6 +21,9 @@ const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
   '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',
@@ -49,16 +52,8 @@ const CONTENT_MODEL_FIELDS: Record<number, Set<string>> = {
 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 {
-  if (['content_get', 'content_uphis'].includes(action) && hasAnySession && !hasParseSession) return false;
-  if (action === 'node_list' && Number(params['ifunit']) === 1) return hasParseSession;
-  if (H5_CLOUD_ROUTED_ACTIONS.has(action)) return true;
-  if (SESSION_CLOUD_ROUTED_ACTIONS.has(action)) return hasParseSession;
-  if (action !== 'content_list') return false;
-  const modelId = contentModel(params);
-  if (!modelId) return !params['myfield'] && !params['myfield2'];
-  const fields = CONTENT_MODEL_FIELDS[modelId];
-  if (!fields || (modelId !== 52 && !hasParseSession)) return false;
-  return filterNames(params).every((field) => fields.has(field));
+  void action; void params; void hasParseSession; void hasAnySession;
+  return true;
 }
 
 export function requiresCloudSession(action: string, params: Record<string, unknown> = {}): boolean {

+ 128 - 58
src/app/core/home-learning-modules.integration.spec.ts

@@ -12,6 +12,7 @@ import { SessionService } from './session.service';
 
 class LearningModulesApiService {
   lastScheduleParams: Record<string, unknown> = {};
+  lastNodeParams: Record<string, unknown> = {};
 
   get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
     if (action === 'content_list' && Number(params['nid']) === 291) {
@@ -50,6 +51,7 @@ class LearningModulesApiService {
       return this.result<T>([{ NodeID: 801, NodeName: '小学人教版词库' }]);
     }
     if (action === 'node_list' && Number(params['pid']) === 801) {
+      this.lastNodeParams = params;
       return this.result<T>([
         { NodeID: 811, NodeName: 'Unit 1', yx_word: null, total: 20 },
         { NodeID: 812, NodeName: 'Unit 2', yx_word: null, total: 18 },
@@ -76,11 +78,53 @@ class LearningModulesApiService {
     return this.result<T>([]);
   }
 
-  post<T>(action: string): Observable<ApiEnvelope<T>> {
+  post<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
+    if (action === 'app_learning_overview') {
+      const records = [
+        { GeneralID: 201, CreateTime: '2026-08-01 09:30:00', dqrq: '20260801', con: '初中英语@Unit 1', xxqs: JSON.stringify([{ Title: 'apple' }]) },
+        { GeneralID: 202, CreateTime: '2026-08-04 16:20:00', dqrq: '20260804', con: '高中英语@Unit 2' },
+      ];
+      const selected = String(params['date'] ?? '');
+      return this.result<T>({
+        records: selected ? records.filter((item) => item.CreateTime.startsWith(selected)) : records,
+        latestReadingRecord: records[0],
+        recordDateCounts: { '2026-08-01': 1, '2026-08-04': 1 },
+        scheduleDateCounts: {},
+        schedules: [],
+        courses: [
+          { GeneralID: 801, id: 701, kcid: 801, Title: '初中英语|七年级上册@同步词汇' },
+          { GeneralID: 802, id: 702, Title2: '【初中英语】阅读训练', syjd: '{"811":"811@Unit 1@1"}' },
+        ],
+        refreshedAt: '2026-08-26T00:00:00.000Z',
+      });
+    }
+    if (action === 'app_companion_overview') {
+      this.lastScheduleParams = params;
+      return this.result<T>({
+        courses: [
+          { GeneralID: 801, id: 701, kcid: 801, Title: '初中英语|七年级上册@同步词汇' },
+          { GeneralID: 802, id: 702, Title2: '【初中英语】阅读训练', syjd: '{"811":"811@Unit 1@1"}' },
+        ],
+        schedules: [
+          { GeneralID: 901, bxrq: '2026-08-10 00:00:00', sdsd: '19:00', Title: '同步伴学', plxm: '张老师', dszt: 10 },
+          { GeneralID: 902, bxrq: '2026-08-03 00:00:00', sdsd: '18:00', Title: '历史伴学', plxm: '李老师', dszt: 30 },
+        ],
+        refreshedAt: '2026-08-26T00:00:00.000Z',
+      });
+    }
+    if (action === 'app_account_overview') {
+      return this.result<T>({
+        profile: { userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 },
+        companionSummary: { completedSessions: 1, completedMinutes: 60 },
+        antiForgetSummary: { total: 3, completed: 2, progress: 67, todayTotal: 0, todayCompleted: 0, duePending: 1, scheduled: 0, scheduledDays: 3 },
+        refreshedAt: '2026-08-26T00:00:00.000Z',
+      });
+    }
     if (action === 'e_get_21list') {
       return this.result<T>([
-        { GeneralID: 101, Title: '张雅茜@任务', kc_title: '初中英语复习', kywsj: '2020-08-01 00:00:00', fxzt: 1, yhid: 100 },
-        { GeneralID: 102, Title: '张雅茜@任务', kc_title: '高中英语复习', kywsj: '2020-08-02 00:00:00', fxzt: 0, yhid: 100 },
+        { 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 },
       ]);
     }
     if (action === 'e_record_detail') {
@@ -114,6 +158,9 @@ describe('home learning modules', () => {
   };
 
   beforeEach(() => {
+    user.set({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 });
+    session.groupId.set(1);
+    session.student.set(null);
     TestBed.configureTestingModule({
       providers: [
         provideRouter(routes),
@@ -141,51 +188,31 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.task-link')?.getAttribute('href')).toBe('/pages/home/cart');
 
     await harness.navigateByUrl('/pages/stu/index');
-    expect(harness.routeNativeElement?.querySelectorAll('.catalog-tabs button').length).toBe(5);
-    expect(harness.routeNativeElement?.textContent).toContain('小学人教版词库');
-    (harness.routeNativeElement?.querySelector('.node-list button') as HTMLButtonElement).click();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
-    expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_list?nid=801');
+    expect(TestBed.inject(Router).url).toBe('/pages/home/cart');
+    expect(harness.routeNativeElement?.textContent).not.toContain('选择词库教材');
+
+    await harness.navigateByUrl('/pages/stu/stu_list?nid=801&bindingId=701');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
     expect(harness.routeNativeElement?.querySelectorAll('.node-list button').length).toBe(2);
     expect(harness.routeNativeElement?.querySelector('.node-list button')?.textContent).toContain('已学 1/20 · 进度 5%');
     expect(harness.routeNativeElement?.querySelectorAll('.unit-progress-ring').length).toBe(2);
     expect(harness.routeNativeElement?.querySelector('.unit-progress-ring')?.textContent).toContain('5%');
-    const units = harness.routeNativeElement?.querySelectorAll('.node-list button') as NodeListOf<HTMLButtonElement>;
-    units[0].click();
-    units[1].click();
-    harness.fixture.detectChanges();
-    expect(harness.routeNativeElement?.querySelector('.unit-selection-summary')?.textContent).toContain('已选 2 个单元');
-    (harness.routeNativeElement?.querySelector('.unit-actions .primary-button') as HTMLButtonElement).click();
+    expect(harness.routeNativeElement?.querySelector('.unit-selection-summary')).toBeNull();
+    expect(harness.routeNativeElement?.querySelector('.unit-actions')).toBeNull();
+    const firstUnit = harness.routeNativeElement?.querySelector('.node-list button') as HTMLButtonElement;
+    firstUnit.click();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
-    const selectionRouter = TestBed.inject(Router);
-    expect(selectionRouter.url).toContain('/pages/stu/stu_abc');
-    expect(selectionRouter.parseUrl(selectionRouter.url).queryParams['nids']).toBe('811,812');
-    expect(harness.routeNativeElement?.querySelector('.selection-header')?.textContent).toContain('0 / 30');
-    expect(harness.routeNativeElement?.querySelectorAll('.word-refine-item').length).toBe(2);
-    expect(harness.routeNativeElement?.querySelector('.word-refine-item')?.textContent).not.toContain('n. 苹果');
-    const playSpy = spyOn(TestBed.inject(PlatformService), 'play');
-    const firstRefineWord = harness.routeNativeElement?.querySelector('.word-refine-item') as HTMLElement;
-    (firstRefineWord.querySelector('.word-reveal') as HTMLButtonElement).click();
-    harness.fixture.detectChanges();
-    expect(firstRefineWord.textContent).toContain('n. 苹果');
-    expect(playSpy).toHaveBeenCalledWith('UploadFiles/apple.mp3');
-    (firstRefineWord.querySelector('.unknown-button') as HTMLButtonElement).click();
-    harness.fixture.detectChanges();
-    expect(firstRefineWord.classList).toContain('selected');
-    expect(harness.routeNativeElement?.querySelector('.selection-header')?.textContent).toContain('已选 1 / 30');
-    (firstRefineWord.querySelector('.known-button') as HTMLButtonElement).click();
-    harness.fixture.detectChanges();
-    expect(firstRefineWord.classList).not.toContain('selected');
-    expect(harness.routeNativeElement?.querySelector('.selection-header')?.textContent).toContain('已选 0 / 30');
-    (firstRefineWord.querySelector('.unknown-button') as HTMLButtonElement).click();
-    harness.fixture.detectChanges();
-    (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
-    await harness.fixture.whenStable();
-    harness.fixture.detectChanges();
-    expect(selectionRouter.url).toContain('/pages/stu/stu_con');
-    expect(harness.routeNativeElement?.querySelectorAll('.repeat-list > button').length).toBe(1);
+    const readonlyRouter = TestBed.inject(Router);
+    expect(readonlyRouter.url).toBe('/pages/stu/stu_con?nid=811&pnid=801&readonly=1');
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('学生只读');
+    expect(harness.routeNativeElement?.querySelectorAll('.readonly-word-list article').length).toBe(2);
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).toContain('apple');
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).toContain('n. 苹果');
+    expect(harness.routeNativeElement?.querySelector('.repeat-list')).toBeNull();
 
     await harness.navigateByUrl('/pages/stu/stu_calendar');
     expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull();
@@ -194,6 +221,7 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.memory-overview')?.textContent).toContain('2 / 3');
     expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('高中英语复习');
     expect(harness.routeNativeElement?.querySelector('.review-sequence')?.textContent).toContain('第 3 次复习');
+    expect(harness.routeNativeElement?.querySelector('.first-learned-at')?.textContent).toContain('首次学习:2026-07-20 10:20');
     for (let index = 0; index < 3; index += 1) {
       (harness.routeNativeElement?.querySelector('[aria-label="上个月"]') as HTMLButtonElement).click();
       harness.fixture.detectChanges();
@@ -243,26 +271,20 @@ describe('home learning modules', () => {
     expect(harness.fixture.nativeElement.querySelector('.tabbar')).not.toBeNull();
     expect(harness.fixture.nativeElement.querySelector('.app-header .icon-button')).toBeNull();
     expect(harness.fixture.nativeElement.querySelector('.app-header h1')?.textContent).toContain('伴学中心');
-    const libraryEntry = harness.routeNativeElement?.querySelector('.library-entry') as HTMLButtonElement;
-    expect(libraryEntry.textContent).toContain('选择词库');
-    expect(libraryEntry.textContent).toContain('按学段选择教材与单元');
+    expect(harness.routeNativeElement?.querySelector('.library-entry')).toBeNull();
+    expect(harness.routeNativeElement?.textContent).toContain('我的学习内容');
     expect(harness.routeNativeElement?.querySelectorAll('.course-list li').length).toBe(2);
     expect(harness.routeNativeElement?.querySelectorAll('.course-list button').length).toBe(2);
     expect((harness.routeNativeElement?.querySelector('.course-list button') as HTMLButtonElement).disabled).toBeFalse();
+    expect((harness.routeNativeElement?.querySelectorAll('.course-list button')[1] as HTMLButtonElement).disabled).toBeFalse();
     expect(harness.routeNativeElement?.textContent).toContain('【七年级上册】同步词汇');
-    expect(harness.routeNativeElement?.textContent).toContain('进入课程,选择本次学习单元');
+    expect(harness.routeNativeElement?.textContent).toContain('点击查看老师分配的单词列表');
     (harness.routeNativeElement?.querySelector('.course-list button') as HTMLButtonElement).click();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
-    expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_list?nid=801');
+    expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_list?nid=801&bindingId=701');
     expect(harness.routeNativeElement?.querySelectorAll('.node-list button').length).toBe(2);
-
-    await harness.navigateByUrl('/pages/home/cart');
-    (harness.routeNativeElement?.querySelector('.library-entry') as HTMLButtonElement).click();
-    await harness.fixture.whenStable();
-    harness.fixture.detectChanges();
-    expect(TestBed.inject(Router).url).toBe('/pages/stu/index');
-    expect(harness.routeNativeElement?.textContent).toContain('选择词库教材');
+    expect((TestBed.inject(ApiService) as unknown as LearningModulesApiService).lastNodeParams['bindingId']).toBe('701');
 
     await harness.navigateByUrl('/pages/home/cart');
     expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(0);
@@ -274,8 +296,8 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.textContent).toContain('同步伴学');
     expect(harness.routeNativeElement?.textContent).toContain('历史伴学');
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
-    expect(api.lastScheduleParams['dateFilter']).toBeUndefined();
-    expect(api.lastScheduleParams['orders']).toBe('B.yysj=DESC');
+    expect(api.lastScheduleParams['mode']).toBe('student');
+    expect(api.lastScheduleParams['limit']).toBe(500);
     const finishedFilter = Array.from(harness.routeNativeElement?.querySelectorAll('.schedule-filters button') ?? [])
       .find((button) => button.textContent?.includes('已结束')) as HTMLButtonElement;
     finishedFilter.click();
@@ -312,7 +334,7 @@ describe('home learning modules', () => {
     await harness.navigateByUrl('/pages/his/his');
     const currentLearningCard = harness.routeNativeElement?.querySelector('.current-learning-card') as HTMLElement;
     const learningRecordCard = harness.routeNativeElement?.querySelector('.learning-record-card') as HTMLElement;
-    expect(currentLearningCard.textContent).toContain('继续选词学习');
+    expect(currentLearningCard.textContent).toContain('我的学习内容');
     expect(currentLearningCard.querySelectorAll('.current-learning-list button').length).toBe(2);
     expect(currentLearningCard.compareDocumentPosition(learningRecordCard) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
     (currentLearningCard.querySelector('.current-learning-list button') as HTMLButtonElement).click();
@@ -323,10 +345,27 @@ describe('home learning modules', () => {
 
     await harness.navigateByUrl('/pages/his/his');
     expect(harness.routeNativeElement?.textContent).toContain('初中英语 / Unit 1');
-    expect(harness.routeNativeElement?.querySelectorAll('.calendar-day').length).toBe(42);
-    expect(harness.routeNativeElement?.querySelector('.calendar-day.learned')?.getAttribute('aria-label')).toContain('2026-08-01');
-    expect(harness.routeNativeElement?.querySelector('.learning-calendar footer')?.textContent).toContain('本月学习 2 天,共 2 次');
     expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2);
+    (harness.routeNativeElement?.querySelector('.learning-record-card .learning-item') as HTMLElement).click();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(TestBed.inject(Router).url).toBe('/pages/his/his_words?id=201');
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('学生只读');
+    expect(harness.routeNativeElement?.querySelector('.selection-header')).toBeNull();
+    await harness.navigateByUrl('/pages/his/his');
+    (harness.routeNativeElement?.querySelector('.date-trigger') as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    const currentMonth = new Date();
+    const monthDifference = (2026 - currentMonth.getFullYear()) * 12 + (7 - currentMonth.getMonth());
+    const shiftLabel = monthDifference < 0 ? '上个月' : '下个月';
+    for (let index = 0; index < Math.abs(monthDifference); index += 1) {
+      (harness.routeNativeElement?.querySelector(`[aria-label="${shiftLabel}"]`) as HTMLButtonElement).click();
+      harness.fixture.detectChanges();
+    }
+    expect(harness.routeNativeElement?.querySelector('.date-picker-popover header strong')?.textContent).toContain('2026年8月');
+    expect(harness.routeNativeElement?.querySelectorAll('.date-picker-day').length).toBe(42);
+    expect(harness.routeNativeElement?.querySelectorAll('.date-picker-day.has-record').length).toBe(2);
+    expect(harness.routeNativeElement?.querySelector('.date-picker-day.has-record')?.getAttribute('aria-label')).toContain('2026-08-01');
     (harness.routeNativeElement?.querySelector('[aria-label^="2026-08-01"]') as HTMLButtonElement).click();
     harness.fixture.detectChanges();
     expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(1);
@@ -362,4 +401,35 @@ describe('home learning modules', () => {
     (harness.routeNativeElement?.querySelector('.exam-options button') as HTMLButtonElement).click();
     harness.fixture.detectChanges();
   });
+
+  it('keeps the complete point-reading flow available to coaches', async () => {
+    user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
+    session.groupId.set(3);
+    const harness = await RouterTestingHarness.create();
+
+    await harness.navigateByUrl('/pages/stu/stu_list?nid=801&bindingId=701');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    const units = harness.routeNativeElement?.querySelectorAll('.node-list button') as NodeListOf<HTMLButtonElement>;
+    units[0].click();
+    units[1].click();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.unit-selection-summary')?.textContent).toContain('已选 2 个单元');
+    (harness.routeNativeElement?.querySelector('.unit-actions .primary-button') as HTMLButtonElement).click();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    const router = TestBed.inject(Router);
+    expect(router.url).toContain('/pages/stu/stu_abc');
+    const firstWord = harness.routeNativeElement?.querySelector('.word-refine-item') as HTMLElement;
+    (firstWord.querySelector('.word-reveal') as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    (firstWord.querySelector('.unknown-button') as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    (harness.routeNativeElement?.querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(router.url).toContain('/pages/stu/stu_con');
+    expect(harness.routeNativeElement?.querySelectorAll('.repeat-list > button').length).toBe(1);
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-header')).toBeNull();
+  });
 });

+ 19 - 0
src/app/core/learning-access.ts

@@ -0,0 +1,19 @@
+const TEACHER_LED_LEARNING_PATHS = new Set([
+  'pages/stu/stu_abc',
+  'pages/stu/stu_con',
+  'pages/stu/stu3',
+  'pages/stu/stu4',
+  'pages/stu/stu5',
+  'pages/stu/stu6',
+  'pages/stu/stu_ok',
+  'pages/stu/pl_stu5',
+  'pages/stu/pl_end',
+]);
+
+export function canStartTeacherLedLearning(groupId: unknown): boolean {
+  return Number(groupId) === 3;
+}
+
+export function isTeacherLedLearningPath(path: string): boolean {
+  return TEACHER_LED_LEARNING_PATHS.has(path);
+}

+ 1 - 1
src/app/core/page-catalog.ts

@@ -46,7 +46,7 @@ export const PAGE_CATALOG: PageMeta[] = [
   { path: 'pages/game/wam', title: '打地鼠', domain: 'game', kind: 'game', hideTabbar: true },
   { path: 'pages/game/plane', title: '飞机大战', domain: 'game', kind: 'game', hideTabbar: true },
   { path: 'pages/game/downletter', title: '字母雨', domain: 'game', kind: 'game', hideTabbar: true },
-  { path: 'pages/his/his_words', title: '选词学习', domain: 'history', kind: 'learning', auth: true },
+  { path: 'pages/his/his_words', title: '学习单词列表', domain: 'history', kind: 'learning', auth: true },
   { path: 'pages/his/exam', title: '词汇测试', domain: 'history', kind: 'learning', auth: true },
   { path: 'pages/iframe/index', title: 'AI 阅读设置', domain: 'learn', kind: 'webview', hideHeader: true, hideTabbar: true },
   { path: 'pages/game/llk', title: '单词连连看', domain: 'game', kind: 'game', auth: true, hideTabbar: true },

+ 37 - 0
src/app/core/session.service.spec.ts

@@ -0,0 +1,37 @@
+import { TestBed } from '@angular/core/testing';
+import { SessionService } from './session.service';
+
+describe('SessionService', () => {
+  beforeEach(() => {
+    localStorage.clear();
+    TestBed.resetTestingModule();
+  });
+
+  it('does not treat a synthetic legacy token as an authenticated cloud session', () => {
+    localStorage.setItem('xiaoshu.angular.session', JSON.stringify({
+      token: '42.deadbeef',
+      user: { userId: 42, userName: 'legacy' },
+      student: null,
+    }));
+
+    const service = TestBed.inject(SessionService);
+
+    expect(service.isLoggedIn()).toBeFalse();
+    expect(service.token()).toBe('');
+    expect(service.user()).toBeNull();
+  });
+
+  it('restores a real Parse session and rejects sessionless login results', () => {
+    localStorage.setItem('xiaoshu.angular.session', JSON.stringify({
+      token: 'r:valid-session',
+      user: { userId: 7, userName: 'student' },
+      student: null,
+    }));
+    const service = TestBed.inject(SessionService);
+
+    expect(service.isLoggedIn()).toBeTrue();
+    service.setLogin({ userId: 8, userName: 'missing-token' });
+    expect(service.isLoggedIn()).toBeFalse();
+    expect(service.user()).toBeNull();
+  });
+});

+ 24 - 17
src/app/core/session.service.ts

@@ -14,7 +14,7 @@ export class SessionService {
   readonly user = signal<AppUser | null>(null);
   readonly token = signal('');
   readonly student = signal<AppUser | null>(null);
-  readonly isLoggedIn = computed(() => Boolean(this.token() && this.user()?.userId));
+  readonly isLoggedIn = computed(() => this.token().startsWith('r:') && Boolean(this.user()?.userId));
   readonly groupId = computed(() => Number(this.user()?.groupId ?? 0));
 
   constructor() {
@@ -30,9 +30,13 @@ export class SessionService {
   }
 
   setLogin(user: AppUser): void {
-    this.user.set(user);
     const sessionToken = typeof user['sessionToken'] === 'string' ? user['sessionToken'].trim() : '';
-    this.token.set(sessionToken || this.makeToken(user));
+    if (!sessionToken.startsWith('r:')) {
+      this.logout();
+      return;
+    }
+    this.user.set(user);
+    this.token.set(sessionToken);
   }
 
   setStudent(student: AppUser | null): void {
@@ -56,17 +60,28 @@ export class SessionService {
   private restore(): void {
     const current = this.parse<PersistedSession>(localStorage.getItem(STORAGE_KEY));
     if (current) {
-      this.user.set(current.user);
-      this.student.set(current.student);
-      this.token.set(current.token);
+      if (String(current.token || '').startsWith('r:') && current.user?.userId) {
+        this.user.set(current.user);
+        this.student.set(current.student);
+        this.token.set(current.token);
+      } else {
+        localStorage.removeItem(STORAGE_KEY);
+        localStorage.removeItem('lifeData');
+      }
       return;
     }
 
     const legacy = this.parse<Record<string, unknown>>(localStorage.getItem('lifeData'));
     if (legacy) {
-      this.user.set((legacy['vuex_user'] as AppUser | null) ?? null);
-      this.student.set((legacy['vuex_stu_user'] as AppUser | null) ?? null);
-      this.token.set(String(legacy['vuex_token'] ?? ''));
+      const token = String(legacy['vuex_token'] ?? '');
+      const user = (legacy['vuex_user'] as AppUser | null) ?? null;
+      if (token.startsWith('r:') && user?.userId) {
+        this.user.set(user);
+        this.student.set((legacy['vuex_stu_user'] as AppUser | null) ?? null);
+        this.token.set(token);
+      } else {
+        localStorage.removeItem('lifeData');
+      }
     }
   }
 
@@ -79,12 +94,4 @@ export class SessionService {
     }
   }
 
-  private makeToken(user: AppUser): string {
-    const source = `${user.userId}:${user.userName ?? ''}:${Date.now()}`;
-    let hash = 0;
-    for (let index = 0; index < source.length; index += 1) {
-      hash = (hash * 31 + source.charCodeAt(index)) >>> 0;
-    }
-    return `${user.userId}.${hash.toString(16)}`;
-  }
 }

+ 2 - 1
src/app/features/appointment/appointment-page.component.html

@@ -58,7 +58,8 @@
     <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)="finishCoachLesson()">结束学习</button> }
-      @if (!isStudentView && (status() === 11 || status() === 20)) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="completeCoachOrder()"><lucide-icon [img]="icons.Save" [size]="18" />生成学习报告</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>

+ 2 - 1
src/app/features/auth/login-page.component.html

@@ -5,11 +5,12 @@
   </section>
 
   <div class="mode-tabs" role="tablist">
-    <button type="button" role="tab" [class.active]="mode() === 'mobile'" (click)="switchMode('mobile')">短信登录</button>
     <button type="button" role="tab" [class.active]="mode() === 'classic'" (click)="switchMode('classic')">账号密码</button>
+    <button type="button" role="tab" disabled title="短信服务迁移完成后开放">短信登录(维护中)</button>
   </div>
 
   <form (ngSubmit)="submit()">
+    @if (sessionMessage()) { <div class="session-notice" role="status">{{ sessionMessage() }}</div> }
     @if (auditMessage()) { <div class="audit-notice" role="status">{{ auditMessage() }}</div> }
     @if (mode() === 'mobile') {
       <div class="input-wrap"><lucide-icon [img]="icons.Phone" [size]="19" /><input name="mobile" type="tel" inputmode="numeric" autocomplete="tel" maxlength="11" [(ngModel)]="mobile" placeholder="11位手机号码" /></div>

+ 1 - 0
src/app/features/auth/login-page.component.scss

@@ -19,3 +19,4 @@ form { display: grid; gap: 14px; }
 .submit { width: 100%; margin-top: 5px; }
 .switch-register { min-height: 42px; color: var(--brand-strong); background: transparent; border: 0; }
 .audit-notice { margin-bottom: 12px; padding: 10px 12px; color: #8a5a00; background: #fff6dc; border: 1px solid #f1d38a; border-radius: var(--radius-sm); font-size: 13px; }
+.session-notice { padding: 11px 12px; color: #7a4b00; background: #fff7e6; border: 1px solid #f1d18a; border-radius: var(--radius-sm); font-size: 13px; line-height: 1.5; }

+ 10 - 3
src/app/features/auth/login-page.component.ts

@@ -1,5 +1,5 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, inject, OnDestroy, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router, RouterLink } from '@angular/router';
 import { Eye, EyeOff, Leaf, LockKeyhole, LucideAngularModule, MessageSquareText, Phone, UserPlus } from 'lucide-angular';
@@ -18,12 +18,13 @@ import { SessionService } from '../../core/session.service';
   styleUrl: './login-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class LoginPageComponent implements OnDestroy {
-  readonly mode = signal<'mobile' | 'classic'>('mobile');
+export class LoginPageComponent implements OnInit, OnDestroy {
+  readonly mode = signal<'mobile' | 'classic'>('classic');
   readonly registering = signal(false);
   readonly showPassword = signal(false);
   readonly countdown = signal(0);
   readonly auditMessage = signal('');
+  readonly sessionMessage = signal('');
   readonly icons = { Eye, EyeOff, Leaf, LockKeyhole, MessageSquareText, Phone, UserPlus };
 
   mobile = '';
@@ -42,6 +43,12 @@ export class LoginPageComponent implements OnDestroy {
   private readonly router = inject(Router);
   private readonly route = inject(ActivatedRoute);
 
+  ngOnInit(): void {
+    if (this.route.snapshot.queryParamMap.get('expired') === '1') {
+      this.sessionMessage.set('登录状态已过期,请重新登录后继续学习');
+    }
+  }
+
   ngOnDestroy(): void { if (this.timer) clearInterval(this.timer); }
 
   switchMode(mode: 'mobile' | 'classic'): void {

+ 10 - 15
src/app/features/course-center/course-center-page.component.html

@@ -1,19 +1,13 @@
 <section class="center-page">
   @if (loading()) {
-    <div class="center-state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="22" />正在加载课程</div>
-  } @else {
+    <div class="center-sync" role="status"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="18" /><span>正在同步最新课程,可先浏览已加载内容</span></div>
+    }
     @if (!isCoach) {
-      <button type="button" class="library-entry" (click)="openLibrary()">
-        <i><lucide-icon [img]="icons.GraduationCap" [size]="23" /></i>
-        <span><strong>选择词库</strong><small>按学段选择教材与单元,开始选词学习</small></span>
-        <lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="20" />
-      </button>
-
       <section class="course-band">
-        <header><lucide-icon [img]="icons.BookOpen" [size]="21" /><h2>我的内容</h2><span>{{ courses().length }} 项</span></header>
+        <header><lucide-icon [img]="icons.BookOpen" [size]="21" /><h2>我的学习内容</h2><span>{{ courses().length }} 项</span></header>
         @if (courses().length) {
-          <ul class="course-list">@for (item of courses(); track $index) { <li><button type="button" [disabled]="!courseId(item)" (click)="openCourse(item)"><span><strong>{{ courseTitle(item) }}</strong><small>{{ courseId(item) ? '进入课程,选择本次学习单元' : '暂未绑定可学习词库' }}</small></span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button></li> }</ul>
-        } @else { <p class="band-empty">暂无我的内容</p> }
+          <ul class="course-list">@for (item of courses(); track courseBindingId(item) || courseId(item) || $index) { <li><button type="button" [class.readonly]="!canStartPointReading" [disabled]="!canOpenCourse(item)" (click)="openCourse(item)"><span><strong>{{ courseTitle(item) }}</strong><small>{{ courseHint(item) }}</small></span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button></li> }</ul>
+        } @else { <p class="band-empty">{{ loading() ? '正在读取学习内容…' : '暂未分配学习内容,请联系老师' }}</p> }
       </section>
     }
 
@@ -65,8 +59,10 @@
                   [class.today]="day.isToday"
                   [class.selected]="scheduleRange() === 'day' && selectedDate() === day.date"
                   [class.has-course]="day.count > 0"
+                  [class.finished-course]="day.finishedCount > 0"
+                  [class.planned-course]="day.plannedCount > 0"
                   (click)="selectCalendarDay(day)"
-                  [attr.aria-label]="day.date + ',' + day.count + ' 节课'"
+                  [attr.aria-label]="day.date + ',已学完 ' + day.finishedCount + ' 节,计划学 ' + day.plannedCount + ' 节'"
                 >
                   <span>{{ day.day }}</span>
                   @if (day.count > 0) { <em>{{ day.count }}</em> }
@@ -99,11 +95,10 @@
             }
           </div>
         }
-      } @else { <p class="band-empty">{{ hasScheduleCalendar ? scheduleEmptyText() : (schedules().length ? '该分类暂无伴学记录' : (isCoach ? '暂无销课记录' : '暂无伴学记录')) }}</p> }
+      } @else { <p class="band-empty">{{ loading() ? '正在读取排课数据…' : (hasScheduleCalendar ? scheduleEmptyText() : (schedules().length ? '该分类暂无伴学记录' : (isCoach ? '暂无销课记录' : '暂无伴学记录'))) }}</p> }
     </section>
 
     @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()">重新加载</button></div>
+      <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>

+ 16 - 12
src/app/features/course-center/course-center-page.component.scss

@@ -1,11 +1,5 @@
 :host { display: block; min-height: 100%; background: var(--surface-muted); }
 .center-page { min-height: 100%; padding-bottom: 18px; }
-.library-entry { display: grid; width: calc(100% - 28px); min-height: 78px; grid-template-columns: 46px minmax(0, 1fr) auto; align-items: center; margin: 12px 14px 0; padding: 13px 16px; color: var(--ink); text-align: left; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); gap: 12px; }
-.library-entry > i { display: grid; width: 46px; height: 46px; color: var(--brand-strong); background: var(--brand-soft); border-radius: 8px; place-items: center; }
-.library-entry > span { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
-.library-entry strong { font-size: 16px; }
-.library-entry small { overflow: hidden; color: var(--ink-soft); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
-.library-entry:active { transform: translateY(1px); }
 .course-band, .schedule-band { margin: 12px 14px 0; overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
 .course-band > header, .schedule-band > header { display: grid; min-height: 56px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; padding: 0 16px; color: white; background: var(--brand-strong); border-bottom: 1px solid color-mix(in srgb, var(--brand-strong) 82%, black); gap: 9px; }
 header h2 { margin: 0; color: var(--ink); font-size: 17px; letter-spacing: 0; }
@@ -21,8 +15,11 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .course-list small { color: var(--ink-soft); font-size: 11px; }
 .course-list button > lucide-icon { color: var(--ink-soft); }
 .course-list button:disabled { cursor: not-allowed; opacity: .58; }
-.schedule-tools { padding: 12px 14px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--brand-soft) 42%, white), white 72%); border-bottom: 1px solid var(--line); }
-.schedule-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) minmax(150px, 190px); align-items: end; gap: 10px; }
+.course-list button.readonly:disabled { cursor: default; opacity: 1; }
+.course-list button.readonly em { padding: 4px 8px; color: var(--ink-soft); background: var(--surface-muted); border: 1px solid var(--line); border-radius: 999px; font-size: 10px; font-style: normal; white-space: nowrap; }
+.schedule-tools { padding: 12px 14px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--brand-pale) 42%, white), white 72%); border-bottom: 1px solid var(--line); }
+.schedule-toolbar { display: grid; grid-template-columns: 1fr; align-items: end; gap: 10px; }
+.schedule-toolbar:has(.student-filter) { grid-template-columns: minmax(0, 1fr) minmax(150px, 190px); }
 .range-tabs { display: grid; height: 48px; grid-template-columns: repeat(4, 1fr); padding: 3px; background: rgb(255 255 255 / 82%); border: 1px solid var(--line); border-radius: 10px; gap: 3px; }
 .range-tabs button { min-height: 40px; padding: 6px 4px; color: var(--ink-soft); background: transparent; border: 0; border-radius: 8px; font-size: 12px; }
 .range-tabs button.active { color: white; background: var(--brand-strong); box-shadow: 0 4px 12px rgb(20 53 45 / 16%); font-weight: 700; }
@@ -38,7 +35,7 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .calendar-panel { margin-top: 12px; padding: 11px; background: white; border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 8px 18px rgb(20 53 45 / 6%); }
 .calendar-heading { display: grid; grid-template-columns: 38px minmax(0, 1fr) 38px; align-items: center; margin-bottom: 8px; text-align: center; }
 .calendar-heading strong { color: var(--ink); font-size: 15px; }
-.calendar-heading button { display: grid; width: 34px; height: 34px; padding: 0; color: var(--brand-strong); background: var(--brand-soft); border: 0; border-radius: 50%; font-size: 24px; line-height: 1; place-items: center; }
+.calendar-heading button { display: grid; width: 34px; height: 34px; padding: 0; color: var(--brand-strong); background: var(--brand-pale); border: 0; border-radius: 50%; font-size: 24px; line-height: 1; place-items: center; }
 .calendar-weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 5px; }
 .calendar-weekdays { margin-bottom: 6px; color: var(--ink-soft); font-size: 11px; text-align: center; }
 .calendar-day { position: relative; display: grid; min-width: 0; min-height: 42px; padding: 5px 2px 4px; color: var(--ink); background: var(--surface-muted); border: 1px solid transparent; border-radius: 11px; font-size: 13px; place-items: center; }
@@ -48,7 +45,15 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .calendar-day.today { border-color: var(--brand); }
 .calendar-day.selected { color: white; background: var(--brand-strong); box-shadow: 0 5px 14px rgb(20 53 45 / 18%); }
 .calendar-day.selected em { color: var(--brand-strong); background: white; }
-.calendar-day.has-course:not(.selected) { background: color-mix(in srgb, var(--brand-soft) 55%, white); }
+.calendar-day.finished-course:not(.selected) { color: var(--brand-strong); background: color-mix(in srgb, var(--brand-pale) 62%, white); border-color: color-mix(in srgb, var(--brand) 46%, white); }
+.calendar-day.planned-course:not(.selected) { color: #2f5f9c; background: #edf4ff; border-color: #b8d0ef; }
+.calendar-day.finished-course em { background: var(--brand-strong); }
+.calendar-day.planned-course em { background: #7fa8d8; }
+.calendar-day.selected.finished-course { background: var(--brand-strong); }
+.calendar-day.selected.planned-course { background: #2f5f9c; box-shadow: 0 5px 14px rgb(47 95 156 / 18%); }
+.calendar-day.selected em { background: white; }
+.calendar-day.selected.finished-course em { color: var(--brand-strong); }
+.calendar-day.selected.planned-course em { color: #2f5f9c; }
 .schedule-summary { margin: 10px 2px 0; color: var(--ink-soft); font-size: 12px; text-align: center; }
 .schedule-band h3 { margin: 0; padding: 10px 16px 7px; color: var(--ink-soft); background: var(--surface-muted); font-size: 12px; letter-spacing: 0; }
 .schedule-list article { display: grid; min-height: 76px; grid-template-columns: 64px minmax(0, 1fr) auto auto auto; align-items: center; padding: 10px 12px 10px 16px; border-bottom: 1px solid var(--line); cursor: pointer; gap: 9px; }
@@ -66,6 +71,7 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .chevron { color: var(--ink-soft); }
 .band-empty { margin: 0; padding: 28px 16px; color: var(--ink-soft); text-align: center; }
 .center-state { display: flex; min-height: 240px; align-items: center; justify-content: center; color: var(--ink-soft); flex-direction: column; gap: 10px; }
+.center-sync { display: flex; min-height: 42px; align-items: center; justify-content: center; margin: 10px 14px 0; padding: 9px 14px; color: var(--brand-strong); background: color-mix(in srgb, var(--brand-pale) 72%, white); border: 1px solid color-mix(in srgb, var(--brand) 26%, white); border-radius: 12px; font-size: 12px; gap: 8px; }
 .center-state.error { min-height: 140px; margin: 12px 14px 0; padding: 20px; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
 .spin { animation: spin .8s linear infinite; }
 @keyframes spin { to { transform: rotate(360deg); } }
@@ -76,8 +82,6 @@ header span { color: var(--ink-soft); font-size: 12px; }
   .status { width: fit-content; }
 }
 @media (max-width: 360px) {
-  .library-entry { width: calc(100% - 20px); margin-inline: 10px; padding-inline: 13px; }
-  .library-entry small { white-space: normal; }
   .course-band, .schedule-band, .center-state.error { margin-inline: 10px; }
   .schedule-tools { padding-inline: 10px; }
   .calendar-panel { padding-inline: 8px; }

+ 100 - 45
src/app/features/course-center/course-center-page.component.ts

@@ -3,8 +3,9 @@ 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 } from 'lucide-angular';
-import { catchError, finalize, forkJoin, of } from 'rxjs';
+import { finalize } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
+import { canStartTeacherLedLearning } from '../../core/learning-access';
 import { AppUser, PageMeta } from '../../core/models';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
@@ -20,6 +21,8 @@ interface CalendarDay {
   inMonth: boolean;
   isToday: boolean;
   count: number;
+  finishedCount: number;
+  plannedCount: number;
 }
 
 interface StudentOption {
@@ -28,6 +31,12 @@ interface StudentOption {
   count: number;
 }
 
+interface CompanionOverview {
+  courses: Item[];
+  schedules: Item[];
+  refreshedAt: string;
+}
+
 @Component({
   selector: 'app-course-center-page',
   standalone: true,
@@ -86,41 +95,26 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     if (this.nowTimer) clearInterval(this.nowTimer);
   }
 
-  load(): void {
+  load(refresh = false): void {
     const uid = this.session.user()?.userId;
     if (!uid) return;
     this.loading.set(true);
     this.error.set('');
     const today = this.today();
-    if (this.isCoach) {
-      const params: Record<string, unknown> = { uid, nid: 29, myfield2: `pl=${uid}`, psize: this.isHistory ? 13 : 500 };
-      if (this.isHistory) Object.assign(params, { orders: 'B.yysj=DESC', dszt: 30, xiaokejilu: 1 });
-      else params['orders'] = 'B.yysj=ASC';
-      this.api.get<unknown>('content_list', params)
-        .pipe(finalize(() => this.loading.set(false)))
-        .subscribe({
-          next: ({ result }) => {
-            this.schedules.set(this.asList(result));
-            if (!this.isHistory && !this.restoredScheduleState) this.selectDate(today);
-            if (this.restoredScheduleState) this.restoreScheduleScroll();
-          },
-          error: (error: ApiError) => this.error.set(error.message),
-        });
-      return;
-    }
-
-    forkJoin({
-      courses: this.api.get<unknown>('content_list', { uid, nid: 28, myfield: `yhid=${uid}`, orders: 'id=DESC', psize: 200 })
-        .pipe(catchError((error: ApiError) => of({ retcode: -1, result: [], retmsg: error.message }))),
-      schedules: this.api.get<unknown>('content_list', { uid, nid: 29, myfield2: `szyh=${uid}`, orders: 'B.yysj=DESC', psize: 500 })
-        .pipe(catchError((error: ApiError) => of({ retcode: -1, result: [], retmsg: error.message }))),
-    }).pipe(finalize(() => this.loading.set(false))).subscribe(({ courses, schedules }) => {
-      this.courses.set(this.asList(courses.result));
-      this.schedules.set(this.asList(schedules.result));
-      if (!this.restoredScheduleState) this.selectDate(today);
-      if (this.restoredScheduleState) this.restoreScheduleScroll();
-      const failures = [courses.retmsg, schedules.retmsg].filter(Boolean);
-      if (failures.length) this.error.set(`部分数据加载失败:${failures.join(';')}`);
+    this.api.post<CompanionOverview>('app_companion_overview', {
+      uid,
+      mode: this.isCoach ? 'coach' : 'student',
+      history: this.isHistory,
+      limit: this.isHistory ? 100 : 500,
+      refresh,
+    }).pipe(finalize(() => this.loading.set(false))).subscribe({
+      next: ({ result }) => {
+        this.courses.set(Array.isArray(result?.courses) ? result.courses : []);
+        this.schedules.set(Array.isArray(result?.schedules) ? result.schedules : []);
+        if (!this.isHistory && !this.restoredScheduleState) this.selectDate(today);
+        if (this.restoredScheduleState) this.restoreScheduleScroll();
+      },
+      error: (error: ApiError) => this.error.set(error.message),
     });
   }
 
@@ -134,28 +128,56 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   }
 
   courseTitle(item: Item): string {
-    const title = String(item['Title2'] ?? item['Title'] ?? '课程内容');
-    if (item['Title2'] || !title.includes('@')) return title;
-    const parts = title.split('@');
-    const first = parts[0].slice(parts[0].lastIndexOf('|') + 1);
-    return `【${first}】${parts.at(-1) ?? ''}`;
+    const sourceTitle = String(item['Title'] ?? '').trim();
+    if (sourceTitle.includes('@')) {
+      const parts = sourceTitle.split('@');
+      const stage = parts[0].slice(parts[0].lastIndexOf('|') + 1).trim();
+      const name = String(parts.at(-1) ?? '').trim();
+      if (stage && name) return `【${stage}】${name}`;
+      if (name) return name;
+    }
+    const joinedTitle = String(item['Title2'] ?? '').trim();
+    if (joinedTitle) return joinedTitle;
+    const assignedUnit = this.assignedUnits(item)[0];
+    return assignedUnit ? `${assignedUnit.name}学习内容` : '已绑定学习内容';
   }
 
   courseId(item: Item): string {
     return String(item['kcid'] ?? item['kcId'] ?? item['NodeID'] ?? item['nodeId'] ?? '');
   }
 
+  courseBindingId(item: Item): string {
+    return String(item['id'] ?? item['bindingId'] ?? item['bindingObjectId'] ?? '');
+  }
+
+  canOpenCourse(item: Item): boolean {
+    return Boolean(this.courseId(item) || this.courseBindingId(item) || this.assignedUnits(item).length);
+  }
+
+  courseHint(item: Item): string {
+    if (!this.canStartPointReading) return this.canOpenCourse(item) ? '点击查看老师分配的单词列表' : '暂未分配可查看的学习内容';
+    const units = this.assignedUnits(item);
+    if (units.length) return `${units.length} 个已分配单元,点击开始学习`;
+    return this.canOpenCourse(item) ? '点击查看已分配的学习单元' : '暂未分配可学习内容';
+  }
+
   openCourse(item: Item): void {
-    const id = this.courseId(item);
-    if (!id) {
-      this.platform.toast('该内容暂未绑定可学习词库', 'error');
+    const courseId = this.courseId(item);
+    const bindingId = this.courseBindingId(item);
+    if (!this.canOpenCourse(item)) {
+      this.platform.toast('该课程暂未分配可学习内容', 'error');
       return;
     }
-    void this.router.navigate(['/pages/stu/stu_list'], { queryParams: { nid: id } });
+    void this.router.navigate(['/pages/stu/stu_list'], {
+      queryParams: {
+        nid: courseId || null,
+        bindingId: bindingId || null,
+      },
+    });
   }
 
-  openLibrary(): void {
-    void this.router.navigate(['/pages/stu/index']);
+  get canStartPointReading(): boolean {
+    return canStartTeacherLedLearning(this.session.groupId());
   }
 
   scheduleTitle(item: Item): string { return String(item['Title'] ?? item['ctitle'] ?? '排课'); }
@@ -270,6 +292,32 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     return [...options.values()].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'));
   }
 
+  private assignedUnits(item: Item): Array<{ id: string; name: string }> {
+    const raw = item['syjd'] ?? item['assignedUnits'];
+    let source: unknown = raw;
+    if (typeof raw === 'string' && raw.trim()) {
+      try { source = JSON.parse(raw); } catch { return []; }
+    }
+    const entries = Array.isArray(source)
+      ? source.map((value, index) => [String(index), value] as const)
+      : source && typeof source === 'object'
+        ? Object.entries(source)
+        : [];
+    const seen = new Set<string>();
+    const units: Array<{ id: string; name: string }> = [];
+    for (const [key, value] of entries) {
+      const text = typeof value === 'object' && value
+        ? String((value as Item)['name'] ?? (value as Item)['title'] ?? (value as Item)['NodeName'] ?? '')
+        : String(value ?? '');
+      const parts = text.split('@');
+      const id = String((typeof value === 'object' && value ? (value as Item)['id'] ?? (value as Item)['nodeId'] : '') || (/^\d+$/.test(key) ? key : parts[0]) || '').trim();
+      if (!/^\d+$/.test(id) || seen.has(id)) continue;
+      seen.add(id);
+      units.push({ id, name: String(parts[1] ?? parts[0] ?? `学习单元 ${id}`).trim() || `学习单元 ${id}` });
+    }
+    return units;
+  }
+
   private buildCalendarDays(): CalendarDay[] {
     const month = this.calendarMonth();
     const year = month.getFullYear();
@@ -278,23 +326,30 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     const offset = (firstDay.getDay() + 6) % 7;
     const start = new Date(year, monthIndex, 1 - offset, 12);
     const today = this.today();
-    const counts = new Map<string, number>();
+    const counts = new Map<string, { finished: number; planned: number }>();
     for (const item of this.filteredSchedulePool()) {
       const date = this.itemDate(item);
       if (!date) continue;
-      counts.set(date, (counts.get(date) ?? 0) + 1);
+      const count = counts.get(date) ?? { finished: 0, planned: 0 };
+      if (this.isFinished(item)) count.finished += 1;
+      else count.planned += 1;
+      counts.set(date, count);
     }
     return Array.from({ length: 42 }, (_, index) => {
       const date = new Date(start);
       date.setDate(start.getDate() + index);
       const key = this.dateKey(date);
       const count = counts.get(key);
+      const finishedCount = count?.finished ?? 0;
+      const plannedCount = count?.planned ?? 0;
       return {
         date: key,
         day: date.getDate(),
         inMonth: date.getMonth() === monthIndex,
         isToday: key === today,
-        count: count ?? 0,
+        count: finishedCount + plannedCount,
+        finishedCount,
+        plannedCount,
       };
     });
   }

+ 19 - 21
src/app/features/english-reading/ncloud-reading.service.spec.ts

@@ -1,6 +1,8 @@
 import { provideHttpClient } from '@angular/common/http';
 import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
 import { TestBed } from '@angular/core/testing';
+import { API_CONFIG } from '../../core/app.constants';
+import { SessionService } from '../../core/session.service';
 import { NcloudReadingService } from './ncloud-reading.service';
 
 describe('NcloudReadingService', () => {
@@ -9,7 +11,11 @@ describe('NcloudReadingService', () => {
 
   beforeEach(() => {
     localStorage.removeItem('NCloud/dev/User');
-    TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
+    TestBed.configureTestingModule({ providers: [
+      provideHttpClient(),
+      provideHttpClientTesting(),
+      { provide: SessionService, useValue: { token: () => '' } },
+    ] });
     service = TestBed.inject(NcloudReadingService);
     http = TestBed.inject(HttpTestingController);
   });
@@ -19,30 +25,22 @@ describe('NcloudReadingService', () => {
     localStorage.removeItem('NCloud/dev/User');
   });
 
-  it('logs in with the source-compatible derived account and persists the session', async () => {
-    const result = service.ensureUser({ userId: 7, mobile: '13800000000', honeyName: '小树' });
-    await Promise.resolve();
-    const request = http.expectOne('/ncloud/login');
-    expect(request.request.method).toBe('POST');
-    expect(request.request.headers.get('X-Parse-Application-Id')).toBe('7pIbDBJmKx_main');
-    expect(request.request.body.username).toBe('7pIbDBJmKx-13800000000');
-    expect(request.request.body.password).toBe(btoa('7pIbDBJmKx-13800000000'));
-    request.flush({ objectId: 'user-1', sessionToken: 'token-1', mobile: '13800000000' });
+  it('adopts the migrated Parse session without creating a second deterministic account', async () => {
+    const result = service.ensureUser({ userId: 7, objectId: 'user-1', mobile: '13800000000', honeyName: '小树' }, 'r:token-1');
     expect((await result).objectId).toBe('user-1');
-    expect(JSON.parse(localStorage.getItem('NCloud/dev/User') || '{}').sessionToken).toBe('token-1');
+    expect(JSON.parse(localStorage.getItem('NCloud/dev/User') || '{}').sessionToken).toBe('r:token-1');
+    http.expectNone(API_CONFIG.cloudFunctionUrl);
   });
 
-  it('sends Parse-compatible filters and the active session token', async () => {
-    const login = service.ensureUser({ userId: 7, mobile: '13800000000' });
-    await Promise.resolve();
-    http.expectOne('/ncloud/login').flush({ objectId: 'user-1', sessionToken: 'token-1' });
-    await login;
+  it('sends filters and the active session token only to the reading cloud gateway', async () => {
+    await service.ensureUser({ userId: 7, objectId: 'user-1', mobile: '13800000000' }, 'r:token-1');
     const query = service.query('SurveyItem', { type: 'reading', isDeleted: { '$ne': true } }, { order: '-createdAt', limit: 3 });
-    const request = http.expectOne((candidate) => candidate.url === '/ncloud/classes/SurveyItem');
-    expect(JSON.parse(request.request.params.get('where') || '{}').type).toBe('reading');
-    expect(request.request.params.get('order')).toBe('-createdAt');
-    expect(request.request.headers.get('X-Parse-Session-Token')).toBe('token-1');
-    request.flush({ results: [] });
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({
+      token: 'r:token-1',
+      params: { action: 'reading/query', className: 'SurveyItem', where: { type: 'reading', isDeleted: { '$ne': true } }, order: '-createdAt', limit: 3 },
+    });
+    request.flush({ retcode: 0, result: [] });
     expect(await query).toEqual([]);
   });
 });

+ 36 - 74
src/app/features/english-reading/ncloud-reading.service.ts

@@ -1,22 +1,19 @@
-import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
+import { HttpClient, HttpHeaders } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 import { firstValueFrom } from 'rxjs';
+import { API_CONFIG } from '../../core/app.constants';
 import { AppUser } from '../../core/models';
+import { SessionService } from '../../core/session.service';
 import { NcloudPointer, NcloudRecord, NcloudUser } from './reading.models';
 
-const APP_ID = '7pIbDBJmKx_main';
-const COMPANY_ID = '7pIbDBJmKx';
 const USER_CACHE_KEY = 'NCloud/dev/User';
-const IS_LOCAL = ['localhost', '127.0.0.1'].includes(globalThis.location?.hostname ?? '');
-const API_BASE = IS_LOCAL ? '/ncloud' : 'https://server.xiaoshu.pro/parse';
-
-interface QueryResponse<T> { results: T[] }
+interface CloudEnvelope<T> { retcode: number; retmsg?: string; result: T }
 
 @Injectable({ providedIn: 'root' })
 export class NcloudReadingService {
   private currentUser: NcloudUser | null = this.readCachedUser();
 
-  constructor(private readonly http: HttpClient) {}
+  constructor(private readonly http: HttpClient, private readonly sessions: SessionService) {}
 
   get user(): NcloudUser | null { return this.currentUser; }
 
@@ -30,71 +27,45 @@ export class NcloudReadingService {
   }
 
   async ensureUser(appUser: AppUser | null, tokenHint = '', mobileHint = ''): Promise<NcloudUser> {
-    if (tokenHint) this.adoptSessionToken(tokenHint);
-    const authenticated = await this.tryCurrentUser();
-    if (authenticated) return authenticated;
-
-    const mobile = String(mobileHint || appUser?.mobile || appUser?.userName || '').trim();
-    if (!mobile) throw new Error('缺少联系电话,无法连接阅读学习账户');
-    const username = `${COMPANY_ID}-${mobile}`;
-    const password = btoa(username);
-    const nickname = String(appUser?.honeyName || appUser?.userName || '默认昵称');
-    try {
-      const user = await firstValueFrom(this.http.post<NcloudUser>(`${API_BASE}/login`, { username, password }, {
-        headers: this.headers(false),
-      }));
-      return this.persistUser(user);
-    } catch {
-      const user = await firstValueFrom(this.http.post<NcloudUser>(`${API_BASE}/users`, {
-        username,
-        password,
-        mobile,
-        nickname,
-        company: this.pointer('Company', COMPANY_ID),
-        type: 'user',
-      }, { headers: this.headers(false) }));
-      return this.persistUser({ ...user, username, mobile, nickname });
+    void mobileHint;
+    const token = tokenHint || this.sessions.token();
+    if (token) this.adoptSessionToken(token);
+    const objectId = String(appUser?.['objectId'] || this.currentUser?.objectId || '').trim();
+    if (token.startsWith('r:') && objectId) {
+      return this.persistUser({
+        ...(this.currentUser ?? { objectId }),
+        objectId,
+        sessionToken: token,
+        username: String(appUser?.userName || this.currentUser?.username || ''),
+        nickname: String(appUser?.honeyName || this.currentUser?.nickname || ''),
+        mobile: String(appUser?.mobile || this.currentUser?.mobile || ''),
+      });
     }
+    throw new Error('阅读学习账户需要新的登录会话,请退出后重新登录');
   }
 
   async updateUser(patch: Partial<NcloudUser>): Promise<NcloudUser> {
     if (!this.currentUser?.objectId) throw new Error('NCloud 用户尚未登录');
-    await firstValueFrom(this.http.put(`${API_BASE}/users/${this.currentUser.objectId}`, patch, {
-      headers: this.headers(),
-    }));
-    return this.persistUser({ ...this.currentUser, ...patch });
+    const updated = await this.call<NcloudUser>('reading/user/update', { patch });
+    return this.persistUser({ ...this.currentUser, ...updated, ...patch });
   }
 
   async query<T extends NcloudRecord>(className: string, where: Record<string, unknown>, options: {
     order?: string; limit?: number; skip?: number;
   } = {}): Promise<T[]> {
-    let params = new HttpParams().set('where', JSON.stringify(where));
-    if (options.order) params = params.set('order', options.order);
-    if (options.limit !== undefined) params = params.set('limit', options.limit);
-    if (options.skip !== undefined) params = params.set('skip', options.skip);
-    const response = await firstValueFrom(this.http.get<QueryResponse<T>>(`${API_BASE}/classes/${className}`, {
-      headers: this.headers(), params,
-    }));
-    return response.results ?? [];
+    return this.call<T[]>('reading/query', { className, where, ...options });
   }
 
   async get<T extends NcloudRecord>(className: string, objectId: string): Promise<T> {
-    return firstValueFrom(this.http.get<T>(`${API_BASE}/classes/${className}/${objectId}`, {
-      headers: this.headers(),
-    }));
+    return this.call<T>('reading/get', { className, objectId });
   }
 
   async create<T extends NcloudRecord>(className: string, value: Omit<T, 'objectId'>): Promise<T> {
-    const response = await firstValueFrom(this.http.post<Pick<T, 'objectId' | 'createdAt'>>(
-      `${API_BASE}/classes/${className}`, value, { headers: this.headers() },
-    ));
-    return { ...value, ...response } as T;
+    return this.call<T>('reading/create', { className, value });
   }
 
   async update<T extends NcloudRecord>(className: string, value: T, patch: Partial<T>): Promise<T> {
-    const response = await firstValueFrom(this.http.put<Partial<T>>(
-      `${API_BASE}/classes/${className}/${value.objectId}`, patch, { headers: this.headers() },
-    ));
+    const response = await this.call<Partial<T>>('reading/update', { className, objectId: value.objectId, patch });
     return { ...value, ...patch, ...response };
   }
 
@@ -102,26 +73,17 @@ export class NcloudReadingService {
     return { __type: 'Pointer', className, objectId };
   }
 
-  private async tryCurrentUser(): Promise<NcloudUser | null> {
-    if (!this.currentUser?.sessionToken) return null;
-    try {
-      const response = await firstValueFrom(this.http.get<NcloudUser>(`${API_BASE}/users/me`, {
-        headers: this.headers(),
-      }));
-      return this.persistUser({ ...this.currentUser, ...response });
-    } catch {
-      this.currentUser = null;
-      localStorage.removeItem(USER_CACHE_KEY);
-      return null;
-    }
-  }
-
-  private headers(withSession = true): HttpHeaders {
-    let headers = new HttpHeaders({ 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' });
-    if (withSession && this.currentUser?.sessionToken) {
-      headers = headers.set('X-Parse-Session-Token', this.currentUser.sessionToken);
-    }
-    return headers;
+  private async call<T>(action: string, params: Record<string, unknown> = {}): Promise<T> {
+    const token = String(this.currentUser?.sessionToken || 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' }),
+    }));
+    if (response.retcode === -1) throw new Error(response.retmsg || '阅读云函数请求失败');
+    return response.result;
   }
 
   private persistUser(user: NcloudUser): NcloudUser {

+ 41 - 120
src/app/features/home/account-page.component.ts

@@ -2,15 +2,13 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
 import { Router, RouterLink } from '@angular/router';
 import { Award, BookMarked, ChevronRight, CircleHelp, History, LogOut, LucideAngularModule, MessageSquare, Settings, ShieldCheck, UserRound } from 'lucide-angular';
-import { catchError, finalize, forkJoin, of } from 'rxjs';
+import { finalize } from 'rxjs';
 import { APP_VERSION } from '../../core/app.constants';
 import { ApiService } from '../../core/api.service';
-import { findAntiForgetList, mergeAntiForgetItems, summarizeAntiForgetHistory } from '../../core/anti-forget';
+import { AntiForgetHistorySummary } from '../../core/anti-forget';
 import { AppUser } from '../../core/models';
 import { SessionService } from '../../core/session.service';
-import { buildAccountMembership, summarizeCompanionLearning } from './account-membership';
-
-type SourceItem = Record<string, unknown>;
+import { buildAccountMembership, CompanionLearningSummary } from './account-membership';
 
 interface CoachPanelSummary {
   todaySchedules: number;
@@ -24,6 +22,14 @@ interface CoachPanelSummary {
   trialLessons: number;
 }
 
+interface AccountOverview {
+  profile: Partial<AppUser>;
+  coachPanel?: CoachPanelSummary;
+  companionSummary?: Pick<CompanionLearningSummary, 'completedSessions' | 'completedMinutes'>;
+  antiForgetSummary?: AntiForgetHistorySummary;
+  refreshedAt: string;
+}
+
 @Component({
   selector: 'app-account-page',
   standalone: true,
@@ -37,15 +43,13 @@ export class AccountPageComponent implements OnInit {
   readonly version = APP_VERSION;
   readonly coachPanel = signal<CoachPanelSummary>({ todaySchedules: 0, completedLessons: 0, reviewIncome: 0, reviewRecords: 0, totalLessons: 0, totalHours: 0, course30: 0, course60: 0, trialLessons: 0 });
   readonly coachPanelLoading = signal(false);
-  readonly antiForgetItems = signal<SourceItem[]>([]);
   readonly antiForgetLoading = signal(false);
   readonly antiForgetError = signal('');
   readonly avatarFailed = signal(false);
-  readonly companionSchedules = signal<SourceItem[]>([]);
   readonly companionLoading = signal(false);
-  readonly antiForgetSummary = computed(() => summarizeAntiForgetHistory(this.antiForgetItems()));
+  readonly antiForgetSummary = signal<AntiForgetHistorySummary>({ total: 0, completed: 0, progress: 0, todayTotal: 0, todayCompleted: 0, duePending: 0, scheduled: 0, scheduledDays: 0 });
   readonly membership = computed(() => buildAccountMembership(this.session.user()));
-  readonly companionSummary = computed(() => summarizeCompanionLearning(this.companionSchedules()));
+  readonly companionSummary = signal<CompanionLearningSummary>({ completedSessions: 0, completedMinutes: 0, durationLabel: '0 小时' });
   readonly icons = { Award, BookMarked, ChevronRight, CircleHelp, History, LogOut, MessageSquare, Settings, ShieldCheck, UserRound };
   readonly services = [
     { path: '/pages/his/well', label: '专项训练', icon: Award },
@@ -60,131 +64,48 @@ export class AccountPageComponent implements OnInit {
   ngOnInit(): void {
     const uid = this.session.user()?.userId;
     if (!uid) return;
-    this.api.get<AppUser>('user_get', { uid }).subscribe({ next: ({ result, addon }) => this.session.patchUser({ ...result, ...(typeof addon === 'object' && addon ? addon : {}) }), error: () => undefined });
-    if (Number(this.session.groupId()) !== 3) {
-      this.loadCompanionSummary(uid);
-      this.loadAntiForget();
-      return;
-    }
-    this.loadCoachPanel(uid);
+    this.loadAccountOverview(false);
   }
 
-  private loadCompanionSummary(uid: number): void {
-    this.companionLoading.set(true);
-    this.api.get<unknown>('content_list', {
-      uid,
-      nid: 29,
-      myfield2: `szyh=${uid}`,
-      orders: 'B.yysj=DESC',
-      psize: 500,
-    }).pipe(finalize(() => this.companionLoading.set(false))).subscribe({
-      next: ({ result }) => this.companionSchedules.set(findAntiForgetList(result)),
-      error: () => this.companionSchedules.set([]),
-    });
-  }
+  loadAntiForget(): void { this.loadAccountOverview(true); }
 
-  loadAntiForget(): void {
+  private loadAccountOverview(refresh: boolean): void {
     const uid = this.session.user()?.userId;
     if (!uid) return;
-    this.antiForgetLoading.set(true);
+    const coach = Number(this.session.groupId()) === 3;
+    this.coachPanelLoading.set(coach);
+    this.companionLoading.set(!coach);
+    this.antiForgetLoading.set(!coach);
     this.antiForgetError.set('');
-    forkJoin({
-      current: this.api.post<unknown>('e_get_21list', { uid, cpage: 1, psize: 500 }).pipe(catchError(() => of(null))),
-      history: this.api.get<unknown>('content_list', {
-        uid,
-        nid: 388,
-        modelId: 60,
-        myfield2: `yhid=${uid}`,
-        orders: 'kywsj=asc',
-        cpage: 1,
-        psize: 500,
-      }).pipe(catchError(() => of(null))),
-    }).pipe(
-      finalize(() => this.antiForgetLoading.set(false)),
+    this.api.post<AccountOverview>('app_account_overview', { uid, refresh }).pipe(
+      finalize(() => {
+        this.coachPanelLoading.set(false);
+        this.companionLoading.set(false);
+        this.antiForgetLoading.set(false);
+      }),
     ).subscribe({
-      next: ({ current, history }) => {
-        if (!current && !history) {
-          this.antiForgetError.set('抗遗忘数据暂时无法加载');
-          return;
+      next: ({ result }) => {
+        if (!result) return;
+        this.session.patchUser(result.profile ?? {});
+        if (result.coachPanel) this.coachPanel.set(result.coachPanel);
+        if (result.companionSummary) {
+          const minutes = Number(result.companionSummary.completedMinutes ?? 0);
+          this.companionSummary.set({ ...result.companionSummary, durationLabel: this.formatMinutes(minutes) });
         }
-        this.antiForgetItems.set(mergeAntiForgetItems(
-          current ? findAntiForgetList(current.result) : [],
-          history ? findAntiForgetList(history.result) : [],
-        ));
+        if (result.antiForgetSummary) this.antiForgetSummary.set(result.antiForgetSummary);
       },
-    });
-  }
-
-  private loadCoachPanel(uid: number): void {
-    this.coachPanelLoading.set(true);
-    forkJoin({
-      stats: this.api.post<Record<string, number>>('e_order_tongji', { uid }).pipe(catchError(() => of(null))),
-      today: this.api.get<unknown>('content_list', {
-        uid,
-        nid: 29,
-        myfield2: `pl=${uid}`,
-        dateFilter: this.today(),
-        psize: 200,
-      }).pipe(catchError(() => of(null))),
-      completed: this.api.get<unknown>('content_list', {
-        uid,
-        nid: 29,
-        myfield2: `pl=${uid}`,
-        orders: 'B.yysj=DESC',
-        dszt: 30,
-        xiaokejilu: 1,
-        cpage: 1,
-        psize: 1,
-      }).pipe(catchError(() => of(null))),
-      incomeList: this.api.post<unknown>('e_get_21list', {
-        status: 1,
-        shouru: 1,
-        uid,
-        cpage: 1,
-        psize: 1,
-      }).pipe(catchError(() => of(null))),
-      incomeTotal: this.api.post<unknown>('e_get_21list_tj', { uid }).pipe(catchError(() => of(null))),
-    }).pipe(finalize(() => this.coachPanelLoading.set(false))).subscribe(({ stats, today, completed, incomeList, incomeTotal }) => {
-      const statValues = stats?.result ?? {};
-      this.coachPanel.set({
-        todaySchedules: this.itemCount(today),
-        completedLessons: this.itemCount(completed),
-        reviewIncome: this.incomeAmount(incomeTotal),
-        reviewRecords: this.itemCount(incomeList),
-        totalLessons: Number(statValues['t_total'] ?? 0),
-        totalHours: Number(statValues['t_shichang'] ?? 0),
-        course30: Number(statValues['t30'] ?? 0),
-        course60: Number(statValues['t60'] ?? 0),
-        trialLessons: Number(statValues['t_tiyan'] ?? 0),
-      });
+      error: () => this.antiForgetError.set('抗遗忘数据暂时无法加载'),
     });
   }
 
   logout(): void { this.session.logout(); void this.router.navigateByUrl('/pages/member/wxauth'); }
   avatarUrl(): string { return this.api.assetUrl(this.session.user()?.userFace); }
 
-  private itemCount(envelope: unknown): number {
-    const value = envelope as { page?: { itemCount?: unknown }; result?: unknown } | null;
-    const pageTotal = Number(value?.page?.itemCount);
-    if (Number.isFinite(pageTotal)) return pageTotal;
-    return this.toList(value?.result).length;
-  }
-
-  private incomeAmount(envelope: unknown): number {
-    const value = envelope as { result?: unknown } | null;
-    return Number(this.toList(value?.result)[0]?.['num'] ?? 0);
-  }
-
-  private toList(value: unknown): SourceItem[] {
-    if (Array.isArray(value)) return value.filter((item): item is SourceItem => Boolean(item) && typeof item === 'object');
-    if (value && typeof value === 'object') {
-      for (const nested of Object.values(value)) if (Array.isArray(nested)) return nested as SourceItem[];
-    }
-    return [];
-  }
-
-  private today(): string {
-    const now = new Date();
-    return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
+  private formatMinutes(minutes: number): string {
+    if (!minutes) return '0 小时';
+    const hours = Math.floor(minutes / 60);
+    const remainder = minutes % 60;
+    if (!hours) return `${remainder} 分钟`;
+    return remainder ? `${hours} 小时 ${remainder} 分钟` : `${hours} 小时`;
   }
 }

+ 1 - 1
src/app/features/home/home-page.component.html

@@ -1,5 +1,5 @@
 <header class="home-header">
-  <div class="brand"><img src="/static/images/logo.svg" alt="小树陪练" /><span>每天进步一点点</span></div>
+  <div class="brand"><img src="/static/images/logo-wordmark.png" alt="小树陪练" /><span>线上与线下,一对一陪练</span></div>
   <a class="header-action" routerLink="/pages/home/search" aria-label="搜索" title="搜索"><lucide-icon [img]="icons.Search" [size]="23" /></a>
 </header>
 

+ 9 - 6
src/app/features/home/home-page.component.scss

@@ -1,8 +1,8 @@
 :host { display: block; min-height: 100%; background: var(--surface); }
-.home-header { display: grid; min-height: calc(70px + env(safe-area-inset-top)); grid-template-columns: minmax(0, 1fr) 44px; align-items: center; padding: env(safe-area-inset-top) 18px 0 20px; background: var(--surface); border-bottom: 1px solid var(--line-soft); gap: 4px; }
-.brand { display: flex; min-width: 0; align-items: flex-start; flex-direction: column; }
-.brand img { width: 104px; height: 27px; object-fit: contain; object-position: left center; filter: brightness(0) saturate(100%) invert(42%) sepia(16%) saturate(1514%) hue-rotate(117deg) brightness(91%) contrast(87%); }
-.brand span { margin-top: 2px; color: var(--ink-soft); font-size: 11px; }
+.home-header { display: grid; min-height: calc(78px + env(safe-area-inset-top)); grid-template-columns: minmax(0, 1fr) 44px; align-items: center; padding: env(safe-area-inset-top) 18px 0 20px; background: var(--surface); border-bottom: 1px solid var(--line-soft); gap: 4px; }
+.brand { display: flex; min-width: 0; align-items: flex-end; gap: 11px; }
+.brand img { width: 154px; height: 43px; flex: 0 0 auto; object-fit: contain; object-position: left center; }
+.brand span { min-width: 0; color: var(--brand-deep); font-size: 13px; font-weight: 650; line-height: 1.25; overflow-wrap: anywhere; }
 .header-action { display: grid; width: 44px; height: 44px; color: var(--ink); place-items: center; }
 .home-main { padding: 0 16px 34px; }
 .greeting { margin: 18px 0 14px; font-size: 15px; }
@@ -95,7 +95,10 @@
 @keyframes spin { to { transform: rotate(360deg); } }
 @media (hover: hover) { .header-action:hover, .quick-grid a:hover { background: var(--surface-hover); } .continue-button:hover { background: var(--accent-strong); } }
 @media (max-width: 430px) {
-  .home-header { min-height: calc(58px + env(safe-area-inset-top)); }
+  .home-header { min-height: calc(70px + env(safe-area-inset-top)); }
+  .brand { gap: 8px; }
+  .brand img { width: 132px; height: 37px; }
+  .brand span { max-width: 112px; font-size: 12px; }
   .home-main { padding-inline: 14px; }
   .content-section { margin-inline: -14px; padding-inline: 14px; }
   .greeting { margin: 8px 0 7px; }
@@ -122,6 +125,6 @@
   .quick-grid strong { font-size: 14px; }
   .quick-arrow { width: 16px; }
 }
-@media (max-width: 350px) { .home-main { padding-inline: 10px; } .memory-content { grid-template-columns: 124px minmax(0, 1fr); padding-inline: 10px; } .day-ring { transform: scale(.82); } .today-panel { padding-left: 7px; } .task-row { font-size: 11px; } .quick-arrow { display: none; } .quick-grid a { grid-template-columns: 42px minmax(0, 1fr); } }
+@media (max-width: 350px) { .brand img { width: 116px; height: 32px; } .brand span { max-width: 86px; font-size: 11px; } .home-main { padding-inline: 10px; } .memory-content { grid-template-columns: 124px minmax(0, 1fr); padding-inline: 10px; } .day-ring { transform: scale(.82); } .today-panel { padding-left: 7px; } .task-row { font-size: 11px; } .quick-arrow { display: none; } .quick-grid a { grid-template-columns: 42px minmax(0, 1fr); } }
 @media (min-width: 620px) { .home-main { padding-inline: 22px; } .memory-content { grid-template-columns: 280px minmax(0, 1fr); } .day-ring { width: 190px; height: 190px; } .memory-dot { transform: rotate(var(--angle)) translateY(-84px); } .today-panel { padding: 8px 30px 0; } .product-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
 @media (prefers-reduced-motion: reduce) { .state-spinner { animation-duration: 1.6s; } }

+ 24 - 8
src/app/features/learning/learning-page.component.html

@@ -13,8 +13,8 @@
     <div class="node-intro">
       <lucide-icon [img]="icons.Library" [size]="27" />
       <div>
-        <h2>{{ isCatalogPage ? '选择词库教材' : '选择学习单元' }}</h2>
-        <p>{{ isCatalogPage ? '先选择学习阶段,再选择对应教材' : '可多选单元,下一步精筛本次新词' }}</p>
+        <h2>{{ isCatalogPage ? '选择词库教材' : canStartPointReading ? '选择学习单元' : '学习单元' }}</h2>
+        <p>{{ isCatalogPage ? '先选择学习阶段,再选择对应教材' : canStartPointReading ? '可多选单元,下一步精筛本次新词' : '点击单元查看老师分配的单词列表' }}</p>
       </div>
     </div>
     @if (isCatalogPage) {
@@ -23,7 +23,7 @@
           <button type="button" [class.active]="catalogCategoryIndex() === index" (click)="selectCatalogCategory(index)">{{ category.label }}</button>
         }
       </nav>
-    } @else {
+    } @else if (canStartPointReading) {
       <div class="unit-selection-summary">
         <span>已选 <strong>{{ selectedNodes().size }}</strong> 个单元</span>
         <small>单击单元可选择或取消</small>
@@ -31,8 +31,8 @@
     }
     <div class="node-list">
       @for (node of nodes(); track $index) {
-        <button type="button" [class.unit-node]="isUnitListPage" [class.selected]="isUnitListPage && selectedNodes().has(nodeId(node))" (click)="openNode(node)">
-          @if (isUnitListPage) {
+        <button type="button" [class.unit-node]="isUnitListPage" [class.readonly]="isUnitListPage && !canStartPointReading" [class.selected]="isUnitListPage && selectedNodes().has(nodeId(node))" (click)="openNode(node)">
+          @if (isUnitListPage && canStartPointReading) {
             <span class="node-check">@if (selectedNodes().has(nodeId(node))) { <lucide-icon [img]="icons.Check" [size]="16" /> }</span>
           }
           <span class="node-copy">
@@ -43,18 +43,34 @@
           @if (isUnitListPage) {
             <span class="unit-progress-ring" [class.complete]="nodeProgressPercent(node) === 100" [style.--progress]="nodeProgressPercent(node) + '%'" [attr.aria-label]="nodeTitle(node) + '学习进度' + nodeProgressPercent(node) + '%'"><strong>{{ nodeProgressPercent(node) }}%</strong></span>
           }
-          @if (isCatalogPage) { <lucide-icon [img]="icons.ChevronRight" [size]="19" /> }
+          @if (isCatalogPage || (isUnitListPage && !canStartPointReading)) { <lucide-icon [img]="icons.ChevronRight" [size]="19" /> }
         </button>
       } @empty {
         <div class="empty-state"><p>{{ isCatalogPage ? '该阶段暂无可用词库' : '该教材暂无可选单元' }}</p></div>
       }
     </div>
-    @if (isUnitListPage) {
+    @if (isUnitListPage && canStartPointReading) {
       <div class="sticky-actions unit-actions">
         <span>已选 {{ selectedNodes().size }} 个单元</span>
         <button type="button" class="primary-button" (click)="startNodeSelection()"><lucide-icon [img]="icons.Play" [size]="18" />选词提升</button>
       </div>
     }
+  } @else if (isReadOnlyWordView) {
+    <header class="readonly-word-header">
+      <div><strong>单词列表</strong><span>共 {{ words().length }} 个单词</span></div>
+      <em>学生只读</em>
+    </header>
+    <div class="readonly-word-list">
+      @for (word of words(); track word.id) {
+        <article>
+          <div><strong>{{ word.word }}</strong>@if (word.phonetic) { <small>{{ word.phonetic }}</small> }</div>
+          <p>{{ word.meaning || '暂无释义' }}</p>
+        </article>
+      } @empty {
+        <div class="empty-state"><p>该单元暂无单词</p></div>
+      }
+    </div>
+    <p class="readonly-word-note">点读、跟读和学习记录由陪练老师在上课时操作</p>
   } @else if (isSelectionPage) {
     <header class="selection-header">
       <div><strong>已选 {{ selected().size }} / {{ selectionLimit }}</strong><span>{{ isRefineSelectionPage ? '先回忆中文,再点击英文核对' : '选择本次要学习的单词' }}</span></div>
@@ -287,7 +303,7 @@
         @for (item of memorySelectedTasks(); track $index) {
           <article [class.ready]="calendarReady(item)" [class.completed]="calendarCompleted(item)">
             <span class="task-state"><i></i>{{ memoryTaskStatus(item) }}</span>
-            <div><span class="task-heading"><em class="review-sequence">第 {{ calendarReviewNumber(item) }} 次复习</em><strong>{{ calendarTitle(item) }}</strong></span><small>{{ calendarStudent(item) }}<em>{{ calendarDate(item) }}</em></small></div>
+            <div class="task-copy"><span class="task-heading"><em class="review-sequence">第 {{ calendarReviewNumber(item) }} 次复习</em><strong>{{ calendarTitle(item) }}</strong></span><small class="task-context">{{ calendarStudent(item) }}<em>复习日期 {{ calendarDate(item) }}</em></small><small class="first-learned-at">{{ calendarFirstLearnedAt(item) }}</small></div>
             <button type="button" [disabled]="!calendarReady(item) && !calendarCompleted(item)" (click)="openCalendar(item)">{{ calendarCompleted(item) ? '查看' : calendarReady(item) ? '开始复习' : '未到期' }}</button>
           </article>
         } @empty {

+ 6 - 0
src/app/features/learning/learning-page.component.scss

@@ -825,6 +825,12 @@ button {
   white-space: nowrap;
 }
 
+.memory-date-tasks .calendar-list .first-learned-at {
+  margin-top: 3px;
+  color: var(--brand-strong);
+  font-weight: 650;
+}
+
 .memory-date-tasks .calendar-list article > div em {
   margin-left: 6px;
   font-style: normal;

+ 82 - 21
src/app/features/learning/learning-page.component.ts

@@ -37,6 +37,7 @@ import {
   nearestAntiForgetDate,
 } from '../../core/anti-forget';
 import { AppUser, PageMeta } from '../../core/models';
+import { canStartTeacherLedLearning, isTeacherLedLearningPath } from '../../core/learning-access';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
 
@@ -217,6 +218,15 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private antiStartedAt = Date.now();
 
   ngOnInit(): void {
+    if (isTeacherLedLearningPath(this.meta.path) && !this.isReadOnlyWordView && !this.canStartPointReading) {
+      this.platform.toast('点读学习由陪练老师在上课时发起', 'error');
+      void this.router.navigateByUrl('/pages/home/cart');
+      return;
+    }
+    if (this.isCatalogPage && Number(this.session.user()?.groupId) === 1) {
+      void this.router.navigateByUrl('/pages/home/cart');
+      return;
+    }
     this.load();
   }
 
@@ -249,6 +259,16 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return this.meta.path === 'pages/stu/stu_con';
   }
 
+  get canStartPointReading(): boolean {
+    return canStartTeacherLedLearning(this.session.groupId());
+  }
+
+  get isReadOnlyWordView(): boolean {
+    if (this.canStartPointReading) return false;
+    return this.meta.path === 'pages/his/his_words'
+      || (this.isStudyListPage && this.query('readonly') === '1');
+  }
+
   get isMatchingPage(): boolean {
     return this.meta.path === 'pages/stu/stu3';
   }
@@ -339,6 +359,12 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       void this.router.navigate(['/pages/stu/stu_list'], { queryParams: { nid: id } });
       return;
     }
+    if (this.isUnitListPage && !this.canStartPointReading) {
+      void this.router.navigate(['/pages/stu/stu_con'], {
+        queryParams: { nid: id, pnid: this.query('nid'), readonly: 1 },
+      });
+      return;
+    }
     this.toggleNode(node);
   }
 
@@ -742,6 +768,40 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return antiForgetDate(item);
   }
 
+  calendarFirstLearnedAt(item: SourceItem): string {
+    const raw = String(
+      item['learned_at']
+      ?? item['learnedAt']
+      ?? item['firstLearnedAt']
+      ?? item['learned_date']
+      ?? '',
+    ).trim();
+    if (!raw) return '首次学习:时间未记录';
+
+    const compactDate = raw.match(/^(\d{4})(\d{2})(\d{2})$/);
+    if (compactDate) return `首次学习:${compactDate[1]}-${compactDate[2]}-${compactDate[3]}(时间未记录)`;
+
+    const dateOnly = raw.match(/^(\d{4}-\d{2}-\d{2})$/);
+    if (dateOnly) return `首次学习:${dateOnly[1]}(时间未记录)`;
+
+    const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)
+      ? raw
+      : `${raw.replace(' ', 'T')}+08:00`;
+    const value = new Date(normalized);
+    if (Number.isNaN(value.getTime())) return `首次学习:${raw}`;
+    const parts = new Intl.DateTimeFormat('zh-CN', {
+      timeZone: 'Asia/Shanghai',
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+      hour: '2-digit',
+      minute: '2-digit',
+      hourCycle: 'h23',
+    }).formatToParts(value);
+    const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((itemPart) => itemPart.type === type)?.value ?? '';
+    return `首次学习:${part('year')}-${part('month')}-${part('day')} ${part('hour')}:${part('minute')}`;
+  }
+
   calendarReviewNumber(item: SourceItem): number {
     for (const key of ['fxcs', 'fxxh', 'reviewNo', 'reviewIndex', 'review_order']) {
       const value = Number(item[key]);
@@ -895,6 +955,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     if (this.isUnitListPage) {
       params['ifunit'] = 1;
       params['userId'] = this.session.user()?.userId;
+      params['bindingId'] = this.query('bindingId');
     }
     this.api.get<unknown>('node_list', params).pipe(
       switchMap(({ result }) => {
@@ -979,6 +1040,21 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   private loadStageWords(): void {
     const nid = this.query('nid');
+    if (nid && this.isReadOnlyWordView) {
+      const learner = this.session.student() ?? this.session.user();
+      this.api.get<unknown>('e_ck_list', {
+        checkwords: 2,
+        nids: nid,
+        modelId: 52,
+        cpage: 1,
+        psize: 500,
+        uid: learner?.userId,
+      }).pipe(finalize(() => this.loading.set(false))).subscribe({
+        next: ({ result }) => this.initializeStage(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word)),
+        error: (error: ApiError) => this.error.set(error.message),
+      });
+      return;
+    }
     if (nid) {
       this.api.get<unknown>('content_list', { nid, modelId: 52, cpage: 1, psize: 300 }).pipe(finalize(() => this.loading.set(false))).subscribe({
         next: ({ result }) => this.initializeStage(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word)),
@@ -1305,33 +1381,18 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   private loadCalendar(): void {
     const uid = this.session.user()?.userId;
-    forkJoin({
-      enriched: this.api.post<unknown>('e_get_21list', { uid, cpage: 1, psize: 500 }).pipe(catchError(() => of(null))),
-      history: this.api.get<unknown>('content_list', {
-        uid,
-        nid: 388,
-        modelId: 60,
-        myfield2: `yhid=${uid}`,
-        orders: 'kywsj=asc',
-        cpage: 1,
-        psize: 500,
-      }).pipe(catchError(() => of(null))),
-    }).pipe(finalize(() => this.loading.set(false))).subscribe({
-      next: ({ enriched, history }) => {
-        if (!enriched && !history) {
-          this.error.set('抗遗忘数据暂时无法加载,请稍后重试');
-          return;
-        }
-        const items = mergeAntiForgetItems(
-          enriched ? this.findList(enriched.result) : [],
-          history ? this.findList(history.result) : [],
-        );
+    this.api.post<unknown>('e_get_21list', { uid, 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 });
   }

Plik diff jest za duży
+ 0 - 1
src/app/features/study-records/study-records-page.component.html


+ 32 - 6
src/app/features/study-records/study-records-page.component.scss

@@ -15,18 +15,31 @@
 .date-trigger b { overflow: hidden; font-size: 16px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
 .date-trigger.placeholder b { color: var(--ink-faint); }
 .date-picker-backdrop { position: fixed; inset: 0; z-index: 999; background: transparent; border: 0; }
-.date-picker-popover { position: absolute; top: calc(100% + 8px); right: 0; z-index: 1000; width: min(520px, calc(100vw - 32px)); padding: 16px; background: white; border: 1px solid color-mix(in srgb, var(--brand) 18%, var(--line)); border-radius: 22px; box-shadow: 0 18px 44px rgb(15 84 70 / 18%); }
-.date-picker-popover header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; gap: 12px; }
+.date-picker-popover { position: fixed; top: 158px; left: 50%; z-index: 1000; width: min(460px, calc(100vw - 32px)); padding: 14px; background: white; border: 1px solid color-mix(in srgb, var(--brand) 18%, var(--line)); border-radius: 22px; box-shadow: 0 18px 44px rgb(15 84 70 / 18%); transform: translateX(-50%); }
+.date-picker-popover header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; gap: 12px; }
 .date-picker-popover header strong { color: var(--ink); font-size: 18px; }
 .date-picker-popover nav { display: flex; align-items: center; gap: 8px; }
-.date-picker-popover nav button { display: grid; width: 42px; height: 42px; color: var(--brand-strong); background: var(--brand-pale); border: 0; border-radius: 999px; place-items: center; }
-.date-picker-weekdays, .date-picker-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 7px; }
+.date-picker-popover nav button { display: grid; width: 38px; height: 38px; color: var(--brand-strong); background: var(--brand-pale); border: 0; border-radius: 999px; place-items: center; }
+.date-picker-legend { display: flex; margin: -2px 0 10px; color: var(--ink-soft); font-size: 10px; gap: 9px; flex-wrap: wrap; }
+.date-picker-legend span { display: inline-flex; align-items: center; gap: 4px; }
+.date-picker-legend i { width: 8px; height: 8px; background: var(--brand-strong); border-radius: 999px; }
+.date-picker-legend i.planned { background: #7fa4d2; }
+.date-picker-legend i.missed { background: #dfa34a; }
+.date-picker-weekdays, .date-picker-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; }
 .date-picker-weekdays { margin-bottom: 8px; }
 .date-picker-weekdays span { color: var(--ink-soft); font-size: 12px; font-weight: 700; text-align: center; }
-.date-picker-day { min-width: 0; height: 50px; color: var(--ink); background: var(--surface-muted); border: 1px solid transparent; border-radius: 16px; font: inherit; font-size: 16px; font-weight: 750; cursor: pointer; }
+.date-picker-day { position: relative; min-width: 0; height: 44px; color: var(--ink); background: var(--surface-muted); border: 1px solid transparent; border-radius: 14px; font: inherit; font-size: 16px; font-weight: 750; cursor: pointer; }
 .date-picker-day.outside { color: var(--ink-faint); background: transparent; }
+.date-picker-day.planned { color: #315d96; background: #eaf1fb; border-color: #b8cce7; }
+.date-picker-day.missed { color: #8d570b; background: #fff0d8; border-color: #e7bd72; }
+.date-picker-day.has-record { color: var(--brand-deep); background: var(--brand-pale); border-color: color-mix(in srgb, var(--brand) 22%, var(--line)); }
+.date-picker-day.has-record::after { content: ""; position: absolute; bottom: 6px; left: 50%; width: 5px; height: 5px; background: var(--brand-strong); border-radius: 999px; transform: translateX(-50%); }
+.date-picker-day.planned::after, .date-picker-day.missed::after { content: ""; position: absolute; bottom: 6px; left: 50%; width: 5px; height: 5px; border-radius: 999px; transform: translateX(-50%); }
+.date-picker-day.planned::after { background: #7fa4d2; }
+.date-picker-day.missed::after { background: #dfa34a; }
 .date-picker-day.today { color: var(--brand-strong); border-color: color-mix(in srgb, var(--brand) 38%, var(--line)); }
 .date-picker-day.selected { color: white; background: var(--brand-strong); border-color: var(--brand-strong); }
+.date-picker-day.selected::after { background: white; }
 .date-picker-popover footer { display: flex; justify-content: space-between; margin-top: 14px; }
 .date-picker-popover footer button { min-height: 38px; padding: 0 14px; color: var(--brand-strong); background: var(--brand-pale); border: 0; border-radius: 999px; font-size: 13px; font-weight: 750; }
 .record-tabs { display: grid; grid-template-columns: 1fr 1fr; background: white; border-bottom: 1px solid var(--line); }
@@ -51,6 +64,19 @@
 .record-actions > button, .record-empty button { min-height: 34px; padding: 0 11px; color: var(--brand-strong); background: var(--brand-pale); border: 1px solid color-mix(in srgb, var(--brand) 32%, var(--line)); border-radius: var(--radius-sm); font-size: 11px; font-weight: 650; }
 .record-list { background: white; }
 .learning-record-card .learning-item:last-child { border-bottom: 0; }
+.schedule-plan-list { background: #fbfdfc; border-top: 1px solid var(--line); }
+.schedule-plan-list > p { margin: 0; padding: 10px 14px; color: var(--ink-soft); background: var(--brand-wash); border-bottom: 1px solid var(--line); font-size: 12px; }
+.schedule-plan-item { display: grid; min-height: 76px; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; padding: 12px 16px; color: var(--ink); border-bottom: 1px solid var(--line); cursor: pointer; gap: 11px; }
+.schedule-plan-item:last-child { border-bottom: 0; }
+.schedule-plan-item > lucide-icon:first-child { color: #315d96; }
+.schedule-plan-item.missed > lucide-icon:first-child { color: #8d570b; }
+.schedule-plan-item.done > lucide-icon:first-child { color: var(--brand-strong); }
+.schedule-plan-item > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
+.schedule-plan-item time, .schedule-plan-item small { overflow: hidden; color: var(--ink-soft); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.schedule-plan-item strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
+.schedule-plan-item > span { padding: 5px 8px; color: #315d96; background: #eaf1fb; border: 1px solid #b8cce7; border-radius: 999px; font-size: 11px; font-weight: 750; }
+.schedule-plan-item.missed > span { color: #8d570b; background: #fff0d8; border-color: #e7bd72; }
+.schedule-plan-item.done > span { color: var(--brand-strong); background: var(--brand-pale); border-color: color-mix(in srgb, var(--brand) 32%, var(--line)); }
 .record-empty { display: flex; min-height: 150px; align-items: center; justify-content: center; padding: 24px 16px; color: var(--ink-soft); text-align: center; flex-direction: column; gap: 9px; }
 .record-empty button { margin-top: 2px; }
 .learning-item, .history-item, .progress-item, .income-item { display: grid; min-height: 76px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); gap: 11px; }
@@ -79,6 +105,6 @@
 .chevron { color: var(--ink-soft); }
 .spin { animation: spin .8s linear infinite; }
 @keyframes spin { to { transform: rotate(360deg); } }
-@media (max-width: 420px) { .date-picker-popover { position: fixed; top: 96px; right: 12px; left: 12px; width: auto; } .date-picker-day { height: 46px; border-radius: 14px; } }
+@media (max-width: 420px) { .date-picker-popover { top: 154px; } .date-picker-day { height: 42px; } }
 @media (max-width: 360px) { .learning-record-card { margin-inline: 10px; } .date-picker-weekdays, .date-picker-grid { gap: 5px; } .date-picker-day { height: 42px; font-size: 15px; } }
 @media (min-width: 760px) { .records-page { max-width: 760px; margin: 0 auto; border-inline: 1px solid var(--line); } }

+ 208 - 18
src/app/features/study-records/study-records-page.component.ts

@@ -6,6 +6,7 @@ import { AlertCircle, ArrowRight, BookOpen, CalendarDays, CheckCircle2, ChevronL
 import { catchError, finalize, forkJoin, of } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
 import { extractReadingWords, readNcloudSessionToken } from '../../core/external-reading';
+import { canStartTeacherLedLearning } from '../../core/learning-access';
 import { PageMeta } from '../../core/models';
 import { SessionService } from '../../core/session.service';
 
@@ -16,6 +17,19 @@ interface DatePickerDay {
   day: number;
   inMonth: boolean;
   isToday: boolean;
+  count: number;
+  plannedCount: number;
+  missedCount: number;
+}
+
+interface LearningOverview {
+  records: Item[];
+  latestReadingRecord?: Item | null;
+  courses: Item[];
+  schedules: Item[];
+  recordDateCounts: Record<string, number>;
+  scheduleDateCounts: Record<string, { planned: number; missed: number }>;
+  refreshedAt: string;
 }
 
 @Component({
@@ -30,6 +44,8 @@ export class StudyRecordsPageComponent implements OnInit {
   readonly meta = inject(ActivatedRoute).snapshot.data['meta'] as PageMeta;
   readonly items = signal<Item[]>([]);
   readonly courses = signal<Item[]>([]);
+  readonly coursesExpanded = signal(false);
+  readonly learningSchedules = signal<Item[]>([]);
   readonly students = signal<Item[]>([]);
   readonly loading = signal(false);
   readonly error = signal('');
@@ -37,9 +53,14 @@ export class StudyRecordsPageComponent implements OnInit {
   readonly activeTab = signal(0);
   readonly datePickerOpen = signal(false);
   readonly datePickerMonth = signal(this.firstOfMonth(new Date()));
+  readonly recordDateCounts = signal<Record<string, number>>({});
+  readonly scheduleDateCounts = signal<Record<string, { planned: number; missed: number }>>({});
+  readonly overviewReadingRecord = signal<Item | null>(null);
   readonly datePickerMonthLabel = computed(() => new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(this.datePickerMonth()));
   readonly datePickerDays = computed<DatePickerDay[]>(() => {
     const month = this.datePickerMonth();
+    const counts = this.recordDateCounts();
+    const scheduleCounts = this.scheduleDateCounts();
     const year = month.getFullYear();
     const monthIndex = month.getMonth();
     const offset = new Date(year, monthIndex, 1, 12).getDay();
@@ -49,11 +70,24 @@ export class StudyRecordsPageComponent implements OnInit {
       const date = new Date(start);
       date.setDate(start.getDate() + index);
       const key = this.dateKey(date);
-      return { date: key, day: date.getDate(), inMonth: date.getMonth() === monthIndex, isToday: key === today };
+      const schedules = scheduleCounts[key] ?? { planned: 0, missed: 0 };
+      return { date: key, day: date.getDate(), inMonth: date.getMonth() === monthIndex, isToday: key === today, count: counts[key] ?? 0, plannedCount: schedules.planned, missedCount: schedules.missed };
     });
   });
-  readonly visibleItems = computed(() => this.items());
-  readonly latestReadingRecord = computed(() => this.items().find((item) => extractReadingWords(item['xxqs']).length > 0));
+  readonly visibleCourses = computed(() => this.coursesExpanded() ? this.courses() : this.courses().slice(0, 3));
+  readonly hiddenCourseCount = computed(() => Math.max(0, this.courses().length - this.visibleCourses().length));
+  readonly selectedSchedules = computed(() => {
+    if (!this.selectedDate) return [];
+    return this.learningSchedules()
+      .filter((item) => this.scheduleDate(item) === this.selectedDate)
+      .sort((left, right) => this.scheduleStartTime(left) - this.scheduleStartTime(right));
+  });
+  readonly visibleItems = computed(() => {
+    const selectedDate = this.selectedDate;
+    if (!selectedDate) return this.items();
+    return this.items().filter((item) => this.dateKey(item['CreateTime'] ?? item['createTime'] ?? item['dqrq'] ?? item['date']) === selectedDate);
+  });
+  readonly latestReadingRecord = computed(() => this.overviewReadingRecord() ?? this.items().find((item) => extractReadingWords(item['xxqs']).length > 0));
   readonly latestReadingWordCount = computed(() => {
     const item = this.latestReadingRecord();
     return item ? extractReadingWords(item['xxqs']).length : 0;
@@ -81,14 +115,14 @@ export class StudyRecordsPageComponent implements OnInit {
     this.load();
   }
 
-  load(): void {
+  load(refresh = false): void {
     const uid = this.session.user()?.userId;
     if (!uid) return;
     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 (this.meta.path === 'pages/his/his') { this.loadLearningHistory(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 };
     if (this.meta.path === 'pages/member/myke') Object.assign(params, { nid: 28, myfield2: `yhid=${uid}`, orders: 'id=DESC', cksl: 1 });
@@ -156,12 +190,22 @@ export class StudyRecordsPageComponent implements OnInit {
     this.load();
   }
 
+  toggleCoursesExpanded(): void { this.coursesExpanded.update((expanded) => !expanded); }
+
   selectedDateLabel(): string {
     if (!this.selectedDate) return '全部日期';
     const [year, month, day] = this.selectedDate.split('-').map(Number);
     return `${year}年${String(month).padStart(2, '0')}月${String(day).padStart(2, '0')}日`;
   }
 
+  datePickerDayLabel(day: DatePickerDay): string {
+    const parts = [];
+    if (day.count) parts.push(`已学习 ${day.count} 次`);
+    if (day.missedCount) parts.push(`计划学而未学 ${day.missedCount} 节`);
+    if (day.plannedCount) parts.push(`计划学习 ${day.plannedCount} 节`);
+    return `${day.date},${parts.length ? parts.join(',') : '暂无学习安排'}`;
+  }
+
   courseId(item: Item): string {
     return String(item['kcid'] ?? item['kcId'] ?? item['NodeID'] ?? item['nodeId'] ?? '');
   }
@@ -179,6 +223,16 @@ export class StudyRecordsPageComponent implements OnInit {
     if (id) void this.router.navigate(['/pages/stu/stu_list'], { queryParams: { nid: id } });
   }
 
+  openSchedulePlan(item: Item): void {
+    const courseId = this.courseId(item);
+    if (courseId) {
+      this.openCourse(item);
+      return;
+    }
+    const scheduleId = item['GeneralID'] ?? item['ID'] ?? item['id'];
+    if (scheduleId) void this.router.navigate(['/pages/shop/stu_get_order'], { queryParams: { id: scheduleId } });
+  }
+
   open(item: Item): void {
     if (this.meta.path === 'pages/member/myke') {
       void this.router.navigate(['/pages/his/his']);
@@ -189,6 +243,10 @@ export class StudyRecordsPageComponent implements OnInit {
     }
   }
 
+  get canStartPointReading(): boolean {
+    return canStartTeacherLedLearning(this.session.groupId());
+  }
+
   openReading(item: Item, event: Event): void {
     event.stopPropagation();
     this.navigateToReading(item);
@@ -221,6 +279,17 @@ export class StudyRecordsPageComponent implements OnInit {
   }
 
   time(item: Item): string { return this.formatDate(item['CreateTime'] ?? item['ctime'] ?? item['learned_date'] ?? item['PubAddTime']); }
+  schedulePlanTitle(item: Item): string { return String(item['Title'] ?? item['ctitle'] ?? '学习计划'); }
+  schedulePlanSubtitle(item: Item): string { return String(item['Subtitle'] ?? item['ctitle'] ?? item['plxm'] ?? '按计划完成本次学习'); }
+  schedulePlanTime(item: Item): string { return String(item['sdsd'] ?? item['kssj'] ?? item['yysj'] ?? this.scheduleDate(item) ?? '--'); }
+  schedulePlanStatus(item: Item): string {
+    if (Number(item['dszt'] ?? 0) >= 30) return '已结课';
+    return this.isScheduleMissed(item) ? '计划未学' : '计划学习';
+  }
+  schedulePlanStatusClass(item: Item): string {
+    if (Number(item['dszt'] ?? 0) >= 30) return 'done';
+    return this.isScheduleMissed(item) ? 'missed' : 'planned';
+  }
   incomeName(item: Item): string { return String(item['honeyName'] ?? String(item['Title'] ?? '').split('@')[0] ?? '学员'); }
   appealStatus(item: Item): string {
     if (Number(item['Pubstart'] ?? 0) === 0) return '处理中';
@@ -252,25 +321,33 @@ export class StudyRecordsPageComponent implements OnInit {
     return mobile || `学员 ${this.studentValue(student)}`;
   }
 
-  private loadLearningHistory(uid: number): void {
-    const targetUid = this.session.groupId() === 3 ? this.studentId : uid;
+  private loadLearningHistory(uid: number, refresh = false): void {
+    const targetUid = this.studentId || (this.session.groupId() === 3 ? 0 : uid);
     if (!targetUid) {
       this.items.set([]);
       this.courses.set([]);
+      this.learningSchedules.set([]);
+      this.overviewReadingRecord.set(null);
+      this.recordDateCounts.set({});
+      this.scheduleDateCounts.set({});
       this.loading.set(false);
       return;
     }
-    let filter = `UserId=${targetUid}`;
-    if (this.selectedDate) filter += `|dqrq=${this.selectedDate.replaceAll('-', '')}`;
-    forkJoin({
-      records: this.api.get<unknown>('content_list', { uid, cpage: 1, psize: 500, nid: 291, modelId: 56, myfield2: filter, orders: 'CreateTime=desc' }),
-      courses: this.api.get<unknown>('content_list', { uid, nid: 28, myfield: `yhid=${targetUid}`, orders: 'id=DESC', psize: 200 })
-        .pipe(catchError(() => of({ retcode: -1, result: [] }))),
+    this.api.post<LearningOverview>('app_learning_overview', {
+      uid,
+      studentId: targetUid,
+      date: this.selectedDate,
+      refresh,
     }).pipe(finalize(() => this.loading.set(false))).subscribe({
-      next: ({ records, courses }) => {
-        const list = this.asList(records.result);
-        this.items.set(list);
-        this.courses.set(this.asList(courses.result));
+      next: ({ result }) => {
+        const overview = result ?? {} as LearningOverview;
+        this.items.set(Array.isArray(overview.records) ? overview.records : []);
+        this.overviewReadingRecord.set(overview.latestReadingRecord ?? null);
+        this.learningSchedules.set(Array.isArray(overview.schedules) ? overview.schedules : []);
+        this.recordDateCounts.set(overview.recordDateCounts ?? {});
+        this.scheduleDateCounts.set(overview.scheduleDateCounts ?? {});
+        this.courses.set(Array.isArray(overview.courses) ? overview.courses : []);
+        this.coursesExpanded.set(false);
       },
       error: (error: ApiError) => this.error.set(error.message),
     });
@@ -302,9 +379,115 @@ export class StudyRecordsPageComponent implements OnInit {
     });
   }
 
-  private asList(value: unknown): Item[] { return Array.isArray(value) ? value.filter((item): item is Item => Boolean(item) && typeof item === 'object') : []; }
+  private asList(value: unknown): Item[] {
+    const source = Array.isArray(value)
+      ? value
+      : value && typeof value === 'object'
+        ? ((value as Record<string, unknown>)['rows'] ?? (value as Record<string, unknown>)['list'] ?? (value as Record<string, unknown>)['data'] ?? Object.values(value).find(Array.isArray))
+        : [];
+    return Array.isArray(source) ? source.filter((item): item is Item => Boolean(item) && typeof item === 'object') : [];
+  }
+
+  private uniqueItems(items: Item[]): Item[] {
+    const seen = new Set<string>();
+    return items.filter((item) => {
+      const key = String(item['GeneralID'] ?? item['ID'] ?? item['id'] ?? '');
+      if (!key) return true;
+      if (seen.has(key)) return false;
+      seen.add(key);
+      return true;
+    });
+  }
+
   private formatDate(value: unknown): string { const date = new Date(String(value ?? '')); return Number.isNaN(date.getTime()) ? String(value ?? '') : date.toLocaleString('zh-CN', { hour12: false }); }
 
+  private updateRecordDateCounts(items: Item[]): Record<string, number> {
+    const counts: Record<string, number> = {};
+    for (const item of items) {
+      const key = this.dateKey(item['dqrq'] ?? item['DQRQ'] ?? item['learnedDate'] ?? item['learned_date'] ?? item['kywsj'] ?? item['CreateTime'] ?? item['createTime'] ?? item['ctime'] ?? item['PubAddTime']);
+      if (key) counts[key] = (counts[key] ?? 0) + 1;
+    }
+    this.recordDateCounts.set(counts);
+    return counts;
+  }
+
+  private updateScheduleDateCounts(items: Item[], learnedCounts: Record<string, number>): void {
+    const counts: Record<string, { planned: number; missed: number }> = {};
+    for (const item of items) {
+      const key = this.scheduleDate(item);
+      if (!key || learnedCounts[key]) continue;
+      const value = counts[key] ?? { planned: 0, missed: 0 };
+      if (this.isScheduleMissed(item)) value.missed += 1;
+      else value.planned += 1;
+      counts[key] = value;
+    }
+    this.scheduleDateCounts.set(counts);
+  }
+
+  private scheduleDate(item: Item): string {
+    return this.dateKey(item['bxrq'] ?? item['yydate'] ?? item['yysj'] ?? item['PubAddTime']);
+  }
+
+  private scheduleStartTime(item: Item): number {
+    return this.scheduleStart(item)?.getTime() ?? Number.MAX_SAFE_INTEGER;
+  }
+
+  private matchesScheduleStudent(item: Item, targetUid: number): boolean {
+    const value = Number(item['szyh'] ?? item['UserID'] ?? item['userId'] ?? 0);
+    return !value || value === targetUid;
+  }
+
+  private isScheduleMissed(item: Item): boolean {
+    const status = Number(item['dszt'] ?? 0);
+    if (status >= 30) return false;
+    const end = this.scheduleEnd(item);
+    if (end) return end.getTime() < Date.now();
+    const date = this.scheduleDate(item);
+    return Boolean(date && date < this.dateKey(new Date()));
+  }
+
+  private scheduleEnd(item: Item): Date | null {
+    const start = this.scheduleStart(item);
+    if (!start) return null;
+    return new Date(start.getTime() + this.scheduleMinutes(item) * 60_000);
+  }
+
+  private scheduleStart(item: Item): Date | null {
+    const rawDateTime = this.dateTimeFromRaw(item['yysj'] ?? item['bxrq'] ?? item['yydate']);
+    const date = this.scheduleDate(item);
+    if (!date) return rawDateTime;
+    const time = this.timeParts(item['sdsd'] ?? item['kssj'] ?? item['yysj']);
+    if (!time) return rawDateTime ?? this.parseDate(date);
+    const [year, month, day] = date.split('-').map(Number);
+    return new Date(year, month - 1, day, time.hour, time.minute);
+  }
+
+  private scheduleMinutes(item: Item): number {
+    const text = `${item['Title'] ?? ''} ${item['Title2'] ?? ''} ${item['Subtitle'] ?? ''}`;
+    const matched = text.match(/(\d{1,3})\s*分钟/);
+    if (matched) return Number(matched[1]);
+    const type = Number(item['dslx'] ?? 0);
+    if (type === 1 || type === 3) return 30;
+    if (type === 2) return 60;
+    return 0;
+  }
+
+  private timeParts(value: unknown): { hour: number; minute: number } | null {
+    const matched = String(value ?? '').match(/(?:^|\s)(\d{1,2}):(\d{2})/);
+    if (!matched) return null;
+    const hour = Number(matched[1]);
+    const minute = Number(matched[2]);
+    if (hour > 23 || minute > 59) return null;
+    return { hour, minute };
+  }
+
+  private dateTimeFromRaw(value: unknown): Date | null {
+    const raw = String(value ?? '');
+    const matched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2}))?/);
+    if (!matched) return null;
+    return new Date(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3]), Number(matched[4] ?? 0), Number(matched[5] ?? 0));
+  }
+
   private restoreHistoryState(): void {
     if (this.meta.path !== 'pages/his/his') return;
     const query = this.route.snapshot.queryParamMap;
@@ -333,6 +516,8 @@ export class StudyRecordsPageComponent implements OnInit {
 
   private dateKey(value: unknown): string {
     const raw = String(value ?? '');
+    const compact = raw.match(/^(\d{4})(\d{2})(\d{2})(?:\D|$)/);
+    if (compact) return `${compact[1]}-${compact[2]}-${compact[3]}`;
     const matched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
     if (matched) return `${matched[1]}-${matched[2].padStart(2, '0')}-${matched[3].padStart(2, '0')}`;
     const date = value instanceof Date ? value : new Date(raw);
@@ -343,6 +528,11 @@ export class StudyRecordsPageComponent implements OnInit {
 
   private firstOfMonth(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), 1, 12); }
 
+  private parseDate(value: string): Date {
+    const [year, month, day] = value.split('-').map(Number);
+    return new Date(year || new Date().getFullYear(), (month || 1) - 1, day || 1, 12);
+  }
+
   private syncDatePickerMonth(): void {
     if (!this.selectedDate) return;
     const [year, month] = this.selectedDate.split('-').map(Number);

+ 2 - 1
src/app/features/team/team-page.component.ts

@@ -116,7 +116,8 @@ export class TeamPageComponent implements OnInit {
 
   private postAction(action: string, payload: Record<string, unknown>): void {
     this.platform.loading.set(true);
-    this.api.post(action, payload).pipe(finalize(() => this.platform.loading.set(false))).subscribe({
+    const idempotencyKey = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+    this.api.post(action, { ...payload, idempotencyKey }).pipe(finalize(() => this.platform.loading.set(false))).subscribe({
       next: () => { this.platform.toast('操作成功', 'success'); this.closeDialog(); this.load(); },
       error: (error: ApiError) => this.platform.toast(error.message, 'error'),
     });

+ 34 - 0
src/styles.scss

@@ -661,6 +661,40 @@ app-study-records-page .current-learning-list button small { overflow: hidden; t
 app-study-records-page .current-learning-list button strong { font-size: 13px; }
 app-study-records-page .current-learning-list button small { color: var(--ink-soft); font-size: 10px; }
 app-study-records-page .current-learning-list button:disabled { cursor: not-allowed; opacity: .58; }
+app-study-records-page .current-learning-list button.readonly:not(:disabled) { cursor: pointer; }
+app-study-records-page .current-learning-list button.readonly:disabled { opacity: .58; }
+
+app-learning-page .node-list > button.readonly { cursor: pointer; }
+app-learning-page .readonly-word-header { display: flex; min-height: 70px; align-items: center; justify-content: space-between; padding: 12px 16px; color: white; background: var(--brand-strong); gap: 12px; }
+app-learning-page .readonly-word-header > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
+app-learning-page .readonly-word-header strong { font-size: 18px; }
+app-learning-page .readonly-word-header span { color: rgb(255 255 255 / 76%); font-size: 11px; }
+app-learning-page .readonly-word-header em { padding: 5px 9px; color: var(--brand-deep); background: var(--brand-pale); border-radius: 999px; font-size: 10px; font-style: normal; font-weight: 750; white-space: nowrap; }
+app-learning-page .readonly-word-list { background: white; }
+app-learning-page .readonly-word-list article { display: grid; min-height: 72px; grid-template-columns: minmax(120px, .7fr) minmax(0, 1.3fr); align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); gap: 14px; }
+app-learning-page .readonly-word-list article > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
+app-learning-page .readonly-word-list strong { overflow: hidden; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; }
+app-learning-page .readonly-word-list small { color: var(--ink-soft); font-size: 12px; }
+app-learning-page .readonly-word-list p { margin: 0; color: var(--ink); font-size: 14px; line-height: 1.5; }
+app-learning-page .readonly-word-note { margin: 12px 14px 0; padding: 10px 12px; color: var(--ink-soft); background: var(--brand-wash); border: 1px solid var(--line); border-radius: var(--radius-md); font-size: 11px; line-height: 1.5; text-align: center; }
+
+app-study-records-page .current-learning-toggle {
+  display: flex;
+  width: 100%;
+  min-height: 44px;
+  align-items: center;
+  justify-content: center;
+  color: var(--brand-strong);
+  background: var(--brand-pale);
+  border: 0;
+  border-top: 1px solid var(--line);
+  font-size: 12px;
+  font-weight: 750;
+  gap: 6px;
+}
+
+app-study-records-page .current-learning-toggle lucide-icon { transition: transform .18s ease; transform: rotate(90deg); }
+app-study-records-page .current-learning-toggle.expanded lucide-icon { transform: rotate(-90deg); }
 
 app-learning-page .memory-watermark { position: absolute; top: -28px; right: -26px; color: white; opacity: .07; pointer-events: none; }
 app-learning-page .memory-copy { min-width: 0; }

+ 8 - 0
tools/LegacyAssemblyInspector/LegacyAssemblyInspector.csproj

@@ -0,0 +1,8 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net10.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+</Project>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików