Przeglądaj źródła

fix: close three-end launch audit with guarded local verification

彭峰 5 godzin temu
rodzic
commit
bf7c8b0f01
96 zmienionych plików z 3677 dodań i 653 usunięć
  1. 4 2
      angular.json
  2. 63 0
      docs/qa/launch-fixes-2026-10-13/README.md
  3. 28 0
      docs/qa/launch-fixes-2026-10-13/dependency-summary.json
  4. BIN
      docs/qa/launch-fixes-2026-10-13/export-fixture.pdf
  5. 212 0
      docs/qa/launch-fixes-2026-10-13/local-page-probes.json
  6. 18 0
      docs/qa/launch-fixes-2026-10-13/migration-plan.json
  7. BIN
      docs/qa/launch-fixes-2026-10-13/payroll-cutoff.png
  8. 10 0
      docs/qa/launch-fixes-2026-10-13/probe-local-pages.cjs
  9. BIN
      docs/qa/launch-fixes-2026-10-13/report-previews/appointment-375.png
  10. BIN
      docs/qa/launch-fixes-2026-10-13/report-previews/appointment-812.png
  11. BIN
      docs/qa/launch-fixes-2026-10-13/report-previews/edit-375.png
  12. BIN
      docs/qa/launch-fixes-2026-10-13/report-previews/edit-812.png
  13. BIN
      docs/qa/launch-fixes-2026-10-13/report-previews/report-375.png
  14. BIN
      docs/qa/launch-fixes-2026-10-13/report-previews/report-812.png
  15. 35 0
      docs/qa/launch-fixes-2026-10-13/report-ui-check.cjs
  16. 14 0
      docs/qa/launch-fixes-2026-10-13/security-ui-check.cjs
  17. 27 0
      docs/qa/launch-fixes-2026-10-13/security-ui-results.json
  18. BIN
      docs/qa/launch-fixes-2026-10-13/student-home-unavailable.png
  19. BIN
      docs/qa/launch-fixes-2026-10-13/teacher-home-unavailable.png
  20. 58 0
      docs/qa/launch-fixes-2026-10-13/verification-summary.json
  21. 1913 0
      docs/qa/launch-fixes-2026-10-13/verification.log
  22. 3 0
      karma.conf.cjs
  23. 469 413
      package-lock.json
  24. 29 20
      package.json
  25. 27 0
      projects/shared/diagnostics.ts
  26. 3 1
      projects/xiaoshu-admin/src/app/admin-session.service.ts
  27. 1 1
      projects/xiaoshu-admin/src/app/admin.constants.ts
  28. 1 0
      projects/xiaoshu-admin/src/app/admin.guard.ts
  29. 2 0
      projects/xiaoshu-admin/src/app/admin.models.ts
  30. 2 0
      projects/xiaoshu-admin/src/app/admin.routes.ts
  31. 4 2
      projects/xiaoshu-admin/src/app/app.config.ts
  32. 6 1
      projects/xiaoshu-admin/src/app/cloud-functions.service.spec.ts
  33. 4 4
      projects/xiaoshu-admin/src/app/cloud-functions.service.ts
  34. 1 1
      projects/xiaoshu-admin/src/app/pages/admin-login.component.html
  35. 1 1
      projects/xiaoshu-admin/src/app/pages/admin-login.component.ts
  36. 1 1
      projects/xiaoshu-admin/src/app/pages/admin-resource.component.html
  37. 2 0
      projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.spec.ts
  38. 2 2
      projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.ts
  39. 1 1
      projects/xiaoshu-admin/src/app/pages/operations-coaches.component.html
  40. 0 0
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html
  41. 3 1
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts
  42. 7 7
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html
  43. 1 1
      projects/xiaoshu-admin/src/app/pages/operations-special-training.component.html
  44. 2 1
      projects/xiaoshu-admin/src/app/pages/operations-special-training.component.ts
  45. 1 1
      projects/xiaoshu-admin/src/app/pages/operations-stores.component.html
  46. 2 6
      projects/xiaoshu-admin/src/app/pages/operations-users.component.ts
  47. 0 0
      projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.html
  48. 4 2
      projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.ts
  49. 25 0
      projects/xiaoshu-admin/src/app/pages/payroll-cutoff.component.ts
  50. 12 0
      projects/xiaoshu-admin/src/app/pages/privacy-requests.component.ts
  51. 9 0
      projects/xiaoshu-admin/src/app/shared/accessible-dialog.directive.ts
  52. 8 0
      projects/xiaoshu-admin/src/app/shared/bounded-workbook.spec.ts
  53. 48 0
      projects/xiaoshu-admin/src/app/shared/bounded-workbook.ts
  54. 5 3
      projects/xiaoshu-mobile/src/app/app.config.ts
  55. 3 2
      projects/xiaoshu-mobile/src/app/app.routes.ts
  56. 20 0
      projects/xiaoshu-mobile/src/app/core/api.service.spec.ts
  57. 22 8
      projects/xiaoshu-mobile/src/app/core/api.service.ts
  58. 6 0
      projects/xiaoshu-mobile/src/app/core/auth.guard.ts
  59. 24 0
      projects/xiaoshu-mobile/src/app/core/diagnostics.spec.ts
  60. 1 0
      projects/xiaoshu-mobile/src/app/core/feature-policy.ts
  61. 72 69
      projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts
  62. 16 0
      projects/xiaoshu-mobile/src/app/core/session.service.spec.ts
  63. 19 2
      projects/xiaoshu-mobile/src/app/core/session.service.ts
  64. 12 0
      projects/xiaoshu-mobile/src/app/features/auth/deletion-page.component.ts
  65. 7 7
      projects/xiaoshu-mobile/src/app/features/auth/login-page.component.html
  66. 3 0
      projects/xiaoshu-mobile/src/app/features/auth/login-page.component.scss
  67. 7 0
      projects/xiaoshu-mobile/src/app/features/auth/login-page.component.spec.ts
  68. 5 2
      projects/xiaoshu-mobile/src/app/features/auth/login-page.component.ts
  69. 2 0
      projects/xiaoshu-mobile/src/app/features/auth/password-page.component.spec.ts
  70. 14 0
      projects/xiaoshu-mobile/src/app/features/auth/password-page.component.ts
  71. 12 12
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html
  72. 12 11
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.html
  73. 1 1
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts
  74. 7 5
      projects/xiaoshu-mobile/src/app/features/home/home-page.component.html
  75. 14 2
      projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts
  76. 4 0
      projects/xiaoshu-mobile/src/app/features/webview/webview-page.component.spec.ts
  77. 15 7
      projects/xiaoshu-mobile/src/app/features/webview/webview-page.component.ts
  78. 15 7
      scripts/audit-app-cloud-actions.mjs
  79. 5 0
      scripts/cloud/account-deletion-ops.js
  80. 5 0
      scripts/cloud/account-deletion.js
  81. 32 0
      scripts/cloud/account-security.js
  82. 1 0
      scripts/cloud/legacy-direct-supplement.js
  83. 13 22
      scripts/cloud/payroll-integrity.js
  84. 14 7
      scripts/deploy-admin-functions.mjs
  85. 1 1
      scripts/install-direct-sync-launchagent.mjs
  86. 23 0
      scripts/lib/sync-alerts.mjs
  87. 18 0
      scripts/migrate-launch-fixes.mjs
  88. 3 1
      scripts/run-nightly-direct-sync.mjs
  89. 25 0
      scripts/sql/account-deletion.sql
  90. 37 0
      scripts/sql/account-security.sql
  91. 75 0
      scripts/sql/payroll-cutoff.sql
  92. 3 2
      scripts/sql/payroll-integrity.sql
  93. 15 0
      scripts/tests/account-security.test.mjs
  94. 3 1
      scripts/tests/direct-nightly-sync.test.mjs
  95. 14 0
      scripts/tests/launch-fixes.test.mjs
  96. 9 12
      scripts/tests/payroll-integrity.test.mjs

+ 4 - 2
angular.json

@@ -35,7 +35,7 @@
             "production": {
             "production": {
               "budgets": [
               "budgets": [
                 { "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
                 { "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
-                { "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "20kB" }
+                { "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "22kB" }
               ],
               ],
               "outputHashing": "all"
               "outputHashing": "all"
             },
             },
@@ -61,6 +61,7 @@
         "test": {
         "test": {
           "builder": "@angular-devkit/build-angular:karma",
           "builder": "@angular-devkit/build-angular:karma",
           "options": {
           "options": {
+            "karmaConfig": "karma.conf.cjs",
             "polyfills": ["zone.js", "zone.js/testing"],
             "polyfills": ["zone.js", "zone.js/testing"],
             "tsConfig": "projects/xiaoshu-mobile/tsconfig.spec.json",
             "tsConfig": "projects/xiaoshu-mobile/tsconfig.spec.json",
             "inlineStyleLanguage": "scss",
             "inlineStyleLanguage": "scss",
@@ -99,7 +100,7 @@
             "production": {
             "production": {
               "budgets": [
               "budgets": [
                 { "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
                 { "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
-                { "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "20kB" }
+                { "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "22kB" }
               ],
               ],
               "outputHashing": "all"
               "outputHashing": "all"
             },
             },
@@ -125,6 +126,7 @@
         "test": {
         "test": {
           "builder": "@angular-devkit/build-angular:karma",
           "builder": "@angular-devkit/build-angular:karma",
           "options": {
           "options": {
+            "karmaConfig": "karma.conf.cjs",
             "polyfills": ["zone.js", "zone.js/testing"],
             "polyfills": ["zone.js", "zone.js/testing"],
             "tsConfig": "projects/xiaoshu-admin/tsconfig.spec.json",
             "tsConfig": "projects/xiaoshu-admin/tsconfig.spec.json",
             "inlineStyleLanguage": "scss",
             "inlineStyleLanguage": "scss",

+ 63 - 0
docs/qa/launch-fixes-2026-10-13/README.md

@@ -0,0 +1,63 @@
+# 三端审查问题修复与本地验收(2026-10-13)
+
+对应 `docs/qa/launch-audit-2026-10-13/README.md`,修复基线 `6d4e042`。学生和老师共用移动应用,分别验证身份路径;后台单独构建和测试。本次没有发布前端、云函数、SQL,没有修改真实会员、课时、工资或同步数据,也没有更新正在运行的 Mac 定时任务。
+
+## 优先级与处理结果
+
+| 项目 | 本地修复 | 验证与边界 |
+|---|---|---|
+| A01 P1 旧密码覆盖新密码 | 显式一次性迁移资格;密码版本比较;数据库触发器关闭旧回退、原子撤销旧会话;新系统网络错误不启动密码迁移 | VM 与 PGlite 测试覆盖首次迁移、拒绝重置账号/未知历史、身份不匹配、并发密码版本和会话撤销。历史不明的既有账号默认关闭旧密码回退,首次登录失败时须由管理员重置;没有批量假定其“从未改密” |
+| A02 P1 迟到 401 清会话 | 对比请求 token,复核当前会话;断网/503/验证超时保留会话 | 迟到旧 token、有效当前 token、复核失败及后台会话测试通过 |
+| A03 P1 密码规则 | 新建/注册/改密/重置统一 8–72 位;登录兼容已有短密码、最大 72 位 | 8/18/19/72 位客户端用例及浏览器 72 位请求;超界拒绝。后台通用重置入口同步限制上界 |
+| A04 P1 临时密码 | 三端返回并执行强制改密;提供当前密码验证、新密码确认、失败保留、成功重新登录 | 前端与服务端门禁;浏览器模拟首次保存 503、重试成功;改密会撤销该账号其他会话 |
+| A05 P1 工资截止核验 | 改用旧系统直接接口的成功批次/业务编号/校验和;按月份显示差异并逐条记录“保留新系统”依据,经理确认截止来源 | 缺投影/无凭据仍阻断;只有姓名/录入人差异可不影响计薪;当前月可草稿,整月同步后才可审核;新同步或源记录变化使确认失效。SQL 锁定财务来源及冲突表,提交时再次核验。不调用同步桥,不伪造 ready/checkpoint |
+| A06 P1 依赖 | Angular 20.3.33/CLI 20.3.39、TS 5.9、SheetJS 官方 0.20.3、html2pdf 0.14、jsPDF 4.2 | 生产依赖扫描 0 条;完整树仍有 12 个开发工具条目,均由同一 braces 公告传播,详见下文;Excel/PDF/两端构建已回归 |
+| B01 P2 失败显示假零 | 首页及用户中心无成功查询时显示“—”;余额刷新,缓存结果标明上次时间和失败 | 三端故障页面与状态测试;不把空字符串当真实零 |
+| B02 P2 错误与空状态混淆 | 学生/教师课程列表及后台排课将加载、失败、真空、保留旧结果分开 | 503 浏览器矩阵无“错误同时宣称无排课”的误导;首次未成功不显示零统计 |
+| B03 P2 隐私注销 | 学员申请/查询,管理员分页处理,版本冲突与帐套隔离;实际会员注销后才能登记完成;协议入口统一文章 120242/120243 | SQL 重复申请、跨帐套、版本和完成前置条件通过;申请不等于删除完成。协议内容及保留期限仍由运营发布者确认 |
+| B04 P2 请求悬挂 | 三端请求统一 20 秒超时;会话复核 10 秒;写入超时提示结果待核对,无自动重放 | 悬挂请求测试释放等待;教学保存沿用原幂等键;后台操作失败后应先刷新核对 |
+| B05 P2 Excel 解析 | 5 MB 压缩体、20 MB 实际展开、2000 ZIP 条目、101 列;会员 200 行,词库 2000 行;只接收 xlsx | 解压前目录核验及流式实测展开量;伪造展开大小、超行/超体积、正常压缩文件测试通过;浏览器大文件拒绝/正常预览通过 |
+| B06 P2 外链 | HTTPS 精确主机名单、拒绝凭据/端口及异常编码;iframe 仅必要脚本,去掉同源/弹窗/下载能力 | URL 边界测试通过。允许域名为 test.fmode.cn、a018.2018.z01.com;旧外部页依赖额外权限的功能需发布者复核,不能任意扩大名单 |
+| B07 P2 会话同步 | 监听跨标签身份/退出;身份切换清教学草稿并重新加载;正常退出撤销当前服务端 token | 单元测试覆盖跨标签切换与撤销请求。主动改密撤销全部会话;普通退出只撤销当前会话 |
+| B08 P2 测试过期 | 更新课程绑定接口和缓存键;固定月份时钟;修复 HEAD 中三条测试错误嵌套;提供 verify:local 完整门禁 | 在工作区和独立暂存树副本分别检查,避免靠未提交修改才通过 |
+| B09 P2 同步告警 | 最终失败通知、24 小时去重、恢复通知、保留差异显著增长提醒;只含固定文字和汇总数 | 注入通知器测试通过。安装器包含新模块,但本次没有重新安装,当前活动任务仍是旧运行器;Mac 通知权限与自然 23 点触发待发布时验收 |
+| B10 P3 诊断 | 两端安全本地错误缓冲(50 条)、版本/请求编号、异常恢复提示、诊断下载;网关失败日志关联同一编号 | 不记录请求正文、token、学员资料或异常原文;测试脱敏、容量和提示去重;浏览器同时断言 pageerror 和 Angular 运行错误缓冲 |
+| B11 P3 可访问性 | 登录持久 label、触控目标;后台主要图标命名;工资/专项/词库弹层焦点、Esc 与回焦 | 浏览器验证聚焦/Tab 圈定/Esc/回焦。属于关键路径抽验,不是全站无障碍认证 |
+
+## 本地验证结果
+
+最终独立暂存树验证及命令退出状态见 `verification-summary.json`。
+
+- `npm run verify:local`:移动端 **209**、后台 **261**、服务端 **169** 项通过,0 失败;两端生产构建通过,11 个云函数定义语法验证通过。
+- 离线 action 清单:requested 118、implemented 104、blocked 21、missing 0。三类有交集,不能相加当覆盖率;21 个已明确关闭的历史功能没有因此变成已实现。
+- 故障矩阵:375/1366 宽,学生首页/课表/个人中心/改密、老师首页/课表、后台工作台/排课/工资;18 个页面状态检查,以及两次未登录跳转检查。无横向溢出、无浏览器或 Angular 运行异常。
+- 学习报告、预约详情、报告编辑:375/812 宽及横屏,取消、跳转修改、保存一次通过,表单无溢出。
+- 72 位登录与强制改密重试、工资差异/月份确认、弹层键盘操作、Excel 超限/合法导入预览通过。
+- 真实 html2pdf 库生成 **3 页**隔离测试 PDF(`export-fixture.pdf`),不含客户数据。
+- 浏览器所有外部网络被阻断,接口是隔离模拟数据。PGlite 是本地数据库,不是生产 PostgreSQL;没有通过模拟请求给真实学员结课、扣费或发工资。
+
+升级后仍有原项目样式预算和 CommonJS 警告。Angular 20 的输出使个别既有组件 CSS 略超过 20 kB,单组件错误阈值调整为 22 kB、8 kB 警告保留;这不是体积或速度优化结论。真实设备、微信音频、长课断网恢复、生产吞吐及端到端性能仍需对应环境验收。
+
+## 开发依赖剩余风险
+
+`dependency-summary.json`:生产树公告 0;完整树 12 个 high 条目来自 [braces 深度嵌套模式栈耗尽公告](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm),检查时 registry 最新 3.0.3 仍受影响、无修复版。没有强行降级 Angular 工具链去消除扫描数字。
+
+该包只进入构建/测试链,浏览器生产包不调用它。开发服务器和 Karma 仅绑定本机回环地址,构建只使用可信仓库中的 glob;不应把开发服务器暴露公网或把用户输入作为构建模式。这里记录限定范围的风险处置,**不宣称完整依赖扫描为零或该公告已修复**。出现修复版后仍需升级复测。
+
+## 你自行发布的顺序
+
+1. 备份数据库、云函数基线、前端旧包和现有 Mac 运行器,确认可恢复。选择维护窗口,停下直接同步写入和账号/财务修改;不在执行同步途中改数据库。
+2. 环境须已具有现行工资及直接同步 SQL 基础表/函数。`node scripts/migrate-launch-fixes.mjs --plan` 是离线清单;只有发布时才执行 `--apply`。脚本从环境读取 `XIAOSHU_MASTER_KEY` 与实际超级管理员的 `XIAOSHU_MIGRATION_OPERATOR_TOKEN`,不把密钥写入文档/任务;先添加 Parse 字段元数据,再在同一事务应用四份 SQL,最后删除临时迁移函数。失败时不要继续发布。
+3. SQL 通过后,发布本次云函数代码,版本应为 **2026-10-13-launch-security-v7**,再核对真实 `migration_status` 和新增改密/注销 action。旧线上网关尚未包含本次能力;本地页面接现有线上网关时,不能把它当新版功能验收环境。
+4. 从同一 Git 版本 `npm ci && npm run verify:local` 生成前端,发布 `dist/xiaoshu-mobile/browser`、`dist/xiaoshu-admin/browser`。核对 HTTPS、域名、代理/CORS、SPA 回退和缓存;登录失败且密码迁移历史不明的旧账号走管理员重置,不批量重开旧密码回退。
+5. 云端稳定后,在这台 Mac 执行 `npm run sync-worker:install-mac` 更新原 LaunchAgent,带入告警模块;安装的 RunAtLoad 会触发检查,不能把它当纯拷贝。凭据仍用已有 0600 文件,沿用直接接口;nightly/once/start 三个入口均已收敛到同一个安全执行器(start 为单次受控执行,持续调度由 LaunchAgent 完成)。检查 macOS 通知许可,用隔离通知或故障演练验收。实际 23 点执行仍要求 Mac 开机并登录。
+6. 用隔离学生、老师、管理员完成登录→改密→开课/排课→当天开始→多词库学习→结课→修改报告→学生评价→工资核验。核验后先生成草稿,月底来源齐全再审核;无需制造真实发薪。随后核对下一次 23 点自然触发回执、失败恢复、iOS/Android/微信与音频/PDF。
+
+SQL 和新版网关是配套变更;回滚不能只撤掉账号保护触发器。应维护窗口内按已备份兼容版本成套恢复并重新验证密码、会话及财务门禁。
+
+## 复现与交付范围
+
+- 根目录执行 `npm run verify:local`,全部离线验证,不自动部署或同步生产。
+- 本地开发服务使用 4202/4302 时,执行本目录三个 `*.cjs` 浏览器脚本;需要 Chrome 和 Playwright(可用 `PLAYWRIGHT_MODULE` 指定)。只允许本机地址及模拟接口,不能替换成生产地址跑写操作。
+- 本目录截图、JSON、PDF 均为隔离测试证据。私有凭据和真实客户原始数据不入库。
+- Git 仅交付本次修复;保留原工作区的学习页修改、原测试重排位置及旧迁移产物。暂存版本将错误嵌套的三条测试独立移到 describe 末尾,未采纳用户原来的另一种重排位置。

+ 28 - 0
docs/qa/launch-fixes-2026-10-13/dependency-summary.json

@@ -0,0 +1,28 @@
+{
+  "production": {
+    "info": 0,
+    "low": 0,
+    "moderate": 0,
+    "high": 0,
+    "critical": 0,
+    "total": 0
+  },
+  "fullTree": {
+    "info": 0,
+    "low": 0,
+    "moderate": 0,
+    "high": 12,
+    "critical": 0,
+    "total": 12
+  },
+  "advisories": [
+    {
+      "name": "braces",
+      "title": "braces vulnerable to stack-exhaustion denial of service through deeply nested patterns",
+      "url": "https://github.com/advisories/GHSA-vfj7-8cjw-p6xm",
+      "severity": "high",
+      "range": "<=3.0.3"
+    }
+  ],
+  "scope": "Remaining braces entries are build/test dependencies; no fixed release in registry at review time. Local dev/test servers bind loopback; only trusted repository glob patterns used."
+}

BIN
docs/qa/launch-fixes-2026-10-13/export-fixture.pdf


+ 212 - 0
docs/qa/launch-fixes-2026-10-13/local-page-probes.json

@@ -0,0 +1,212 @@
+[
+  {
+    "role": "student",
+    "width": 375,
+    "route": "/pages/home/index",
+    "runtimeErrors": 0,
+    "url": "/pages/home/index",
+    "overflow": false,
+    "body": "线上与线下,一对一陪练\n\n你好,本地审查账号\n\n21天抗遗忘计划\n抗遗忘计划暂时无法加载,点击重试\n坚持每天学习,记得更牢,成绩更好!\n查看计划\n\n统计暂时无法加载 重新加载\n\n—\n累积生词\n—\n剩余陪练时长\n—\n累计陪练时长\n—\n30分钟软件时长\n—\n60分钟软件时长\n科学抗遗忘\n今日复习任务\n专项训练\n分类强化词汇\n学习记录\n查看历次学习\n词汇测试\n检查掌握程度\n自主学习\n生词本\n选生词,开始学习\n勾选想学的单词,按自己的节奏练习\n进入生词本\n主页\n伴学\n我的",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "student",
+    "width": 375,
+    "route": "/pages/home/shop",
+    "runtimeErrors": 0,
+    "url": "/pages/home/shop",
+    "overflow": false,
+    "body": "实时同步暂时不可用,已保留当前内容\n陪练排课\n— 节\n刷新课表\n清除筛选\n今天\n明天\n本周\n全部\n全部 —\n计划未完成 —\n已结束 —\n‹\n2026年10月\n›\n一\n二\n三\n四\n五\n六\n日\n28\n29\n30\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n29\n30\n31\n1\n2\n3\n4\n5\n6\n7\n8\n\n2026-10-13 · — 节课\n\n课表暂时无法加载,请重试\n\n请求暂未完成,请检查网络后重试;已填写内容会保留。\n重新加载\n上一页\n共 — 条 · 1 / 1\n下一页\n主页\n伴学\n我的",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "student",
+    "width": 375,
+    "route": "/pages/home/ucenter",
+    "runtimeErrors": 0,
+    "url": "/pages/home/ucenter",
+    "overflow": false,
+    "body": "本地审查账号\n个人信息设置\n实时同步暂时不可用\n账号数据暂时无法加载\n重新加载\n账号与学习权益\n学生账号 · 大众会员\n待审核\n会员有效期\n未设置到期日\n累计陪练\n—\n统计暂不可用\n—\n剩余陪练时长\n—\n剩余30分钟软件\n—\n剩余60分钟软件\n查看\n课时余额与预留\n—\n可查陪练时长\n我的课程\n科学抗遗忘\n学习新词\n从“我的内容”选择已绑定课程和学习单元\n抗遗忘数据暂时无法加载\n重新加载\n我的服务\n专项训练\n生词本\n学习记录\n意见反馈\n当前版本:2.0.0-angular\n隐私政策\n服务协议\n隐私注销通道\n退出登录\n南昌小树陪练科技有限公司\n主页\n伴学\n我的",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "student",
+    "width": 375,
+    "route": "/pages/member/passwd",
+    "runtimeErrors": 0,
+    "url": "/pages/member/passwd",
+    "overflow": false,
+    "body": "修改登录密码\n修改登录密码\n\n修改成功后,需要重新登录。\n\n当前密码\n新密码\n确认新密码\n\n新密码为 8 至 72 位。\n\n保存并重新登录",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "teacher",
+    "width": 375,
+    "route": "/pages/home/index",
+    "runtimeErrors": 0,
+    "url": "/pages/home/index",
+    "overflow": false,
+    "body": "线上与线下,一对一陪练\n\n你好,本地审查账号\n\n陪练师工作台\n今日业务概览\n统计暂时无法加载\n重新加载\n关联学员复习情况\n抗遗忘带练\n抗遗忘计划暂时无法加载,点击重试\n查看全部学员复习计划\n陪练排课\n查看今天与未来课程\n销课记录\n核对已完成课程\n学员记录\n按学员查看学习成果\n抗遗忘工资\n查看抗遗忘复习工资\n主页\n陪练\n我的",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "teacher",
+    "width": 375,
+    "route": "/pages/home/shop",
+    "runtimeErrors": 0,
+    "url": "/pages/home/shop",
+    "overflow": false,
+    "body": "实时同步暂时不可用,已保留当前内容\n陪练排课\n— 节\n刷新课表\n清除筛选\n今天\n明天\n本周\n全部\n全部 —\n计划未完成 —\n已结束 —\n‹\n2026年10月\n›\n一\n二\n三\n四\n五\n六\n日\n28\n29\n30\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n29\n30\n31\n1\n2\n3\n4\n5\n6\n7\n8\n\n2026-10-13 · — 节课\n\n课表暂时无法加载,请重试\n\n请求暂未完成,请检查网络后重试;已填写内容会保留。\n重新加载\n上一页\n共 — 条 · 1 / 1\n下一页\n主页\n陪练\n我的",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "admin",
+    "width": 375,
+    "route": "/admin/dashboard",
+    "runtimeErrors": 0,
+    "url": "/admin/dashboard",
+    "overflow": false,
+    "body": "小树陪练\n运营管理后台\n\n运营总览\n\n工作台\n\n核心运营\n\n会员管理\n门店管理\n排课中心\n学习报表\n陪练老师\n工资结算\n\n内容运营\n\n词库管理\n专项训练\n\n账号与安全\n\n注销申请\n运营账号管理\n\n个人设置\n\n修改密码\n退出登录\n工作台\nL\n\nOPERATIONS OVERVIEW\n\n运营工作台\n刷新数据\n暂时无法载入运营工作台\n本地审查:模拟服务暂不可用\n重新加载",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "admin",
+    "width": 375,
+    "route": "/admin/schedule",
+    "runtimeErrors": 0,
+    "url": "/admin/schedule",
+    "overflow": false,
+    "body": "小树陪练\n运营管理后台\n\n运营总览\n\n工作台\n\n核心运营\n\n会员管理\n门店管理\n排课中心\n学习报表\n陪练老师\n工资结算\n\n内容运营\n\n词库管理\n专项训练\n\n账号与安全\n\n注销申请\n运营账号管理\n\n个人设置\n\n修改密码\n退出登录\n返回\n排课中心\nL\n\nSCHEDULING CENTER\n\n排课中心\n按陪练或时间快速排课,并在提交前发现冲突\n刷新\n新增排课\n日\n周\n列表\n日期\n2026年10月13日\n陪练筛选\n全部陪练\n全部陪练\n搜索排课\n业务状态\n全部状态\n待开始\n上课中\n陪练已结束\n学员已确认\n已完课\n已归档\n逾期未完成\n状态待核对\n已取消\n待分配 / 账号异常\n全部状态\n查找\n重置\n0\n条筛选结果\n当前范围共 0 条\n\n本地审查:模拟服务暂不可用\n\n预约到完课流程\n排课、上课、结课与评价\n老师一次结课,学生随后评价\n1\n运营创建预约\n选择学员已绑定课程、时间和陪练\n状态 0\n2\n陪练开始上课\n预约当天均可开始,记录实际上课时间\n上课中\n3\n老师结课并生成报告\n课堂结果、反馈与课时扣减一次完成\n已结课\n4\n学生评价\n学生查看报告后评分;未评价不阻塞结课\n评价独立\n当前范围课程\n—\n2026-10-13 至 2026-10-13\n待分配\n—\n未分配老师时不能开始\n在岗陪练\n—\n未配置可用时间会提示\n历史排课总量\n—\n— 至 —\n数据库更新\n新排课程、教学状态以新库为准\n陪练排课泳道\n点击排课查看明细与可用操作\n待开始\n上课中\n陪练已结束\n学员已确认\n已完课\n已归档\n逾期未完成\n状态待核对\n已取消\n课表未能加载,请重试",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "admin",
+    "width": 375,
+    "route": "/admin/payroll",
+    "runtimeErrors": 0,
+    "url": "/admin/payroll",
+    "overflow": false,
+    "body": "小树陪练\n运营管理后台\n\n运营总览\n\n工作台\n\n核心运营\n\n会员管理\n门店管理\n排课中心\n学习报表\n陪练老师\n工资结算\n\n内容运营\n\n词库管理\n专项训练\n\n账号与安全\n\n注销申请\n运营账号管理\n\n个人设置\n\n修改密码\n退出登录\n返回\n工资结算\nL\n\nPAYROLL CENTER\n\n工资结算\n按有效上课记录与抗遗忘工资核算工资,完成月度审核与发放登记\n刷新统计\n导出 Excel\n\n收益统计: 本地审查:模拟服务暂不可用;工资批次: 本地审查:模拟服务暂不可用;历史单价: 本地审查:模拟服务暂不可用;工资标准: 本地审查:模拟服务暂不可用;老师列表: 本地审查:模拟服务暂不可用。对应区域保留上次成功结果(如有),请重试。\n\n收益统计\n抗遗忘工资\n工资批次\n排课与工资标准\n统计月份\n2026年10月\n门店\n全部门店\n全部门店\n陪练搜索\n查询\n重置\n缓存数据 · 查询于\n核验 2026-10 结算来源",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "student",
+    "width": 1366,
+    "route": "/pages/home/index",
+    "runtimeErrors": 0,
+    "url": "/pages/home/index",
+    "overflow": false,
+    "body": "线上与线下,一对一陪练\n\n你好,本地审查账号\n\n21天抗遗忘计划\n抗遗忘计划暂时无法加载,点击重试\n坚持每天学习,记得更牢,成绩更好!\n查看计划\n\n统计暂时无法加载 重新加载\n\n—\n累积生词\n—\n剩余陪练时长\n—\n累计陪练时长\n—\n30分钟软件时长\n—\n60分钟软件时长\n科学抗遗忘\n今日复习任务\n专项训练\n分类强化词汇\n学习记录\n查看历次学习\n词汇测试\n检查掌握程度\n自主学习\n生词本\n选生词,开始学习\n勾选想学的单词,按自己的节奏练习\n进入生词本\n主页\n伴学\n我的",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "student",
+    "width": 1366,
+    "route": "/pages/home/shop",
+    "runtimeErrors": 0,
+    "url": "/pages/home/shop",
+    "overflow": false,
+    "body": "实时同步暂时不可用,已保留当前内容\n陪练排课\n— 节\n刷新课表\n清除筛选\n今天\n明天\n本周\n全部\n全部 —\n计划未完成 —\n已结束 —\n‹\n2026年10月\n›\n一\n二\n三\n四\n五\n六\n日\n28\n29\n30\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n29\n30\n31\n1\n2\n3\n4\n5\n6\n7\n8\n\n2026-10-13 · — 节课\n\n课表暂时无法加载,请重试\n\n请求暂未完成,请检查网络后重试;已填写内容会保留。\n重新加载\n上一页\n共 — 条 · 1 / 1\n下一页\n主页\n伴学\n我的",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "student",
+    "width": 1366,
+    "route": "/pages/home/ucenter",
+    "runtimeErrors": 0,
+    "url": "/pages/home/ucenter",
+    "overflow": false,
+    "body": "本地审查账号\n个人信息设置\n实时同步暂时不可用\n账号数据暂时无法加载\n重新加载\n账号与学习权益\n学生账号 · 大众会员\n待审核\n会员有效期\n未设置到期日\n累计陪练\n—\n统计暂不可用\n—\n剩余陪练时长\n—\n剩余30分钟软件\n—\n剩余60分钟软件\n查看\n课时余额与预留\n—\n可查陪练时长\n我的课程\n科学抗遗忘\n学习新词\n从“我的内容”选择已绑定课程和学习单元\n抗遗忘数据暂时无法加载\n重新加载\n我的服务\n专项训练\n生词本\n学习记录\n意见反馈\n当前版本:2.0.0-angular\n隐私政策\n服务协议\n隐私注销通道\n退出登录\n南昌小树陪练科技有限公司\n主页\n伴学\n我的",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "student",
+    "width": 1366,
+    "route": "/pages/member/passwd",
+    "runtimeErrors": 0,
+    "url": "/pages/member/passwd",
+    "overflow": false,
+    "body": "修改登录密码\n修改登录密码\n\n修改成功后,需要重新登录。\n\n当前密码\n新密码\n确认新密码\n\n新密码为 8 至 72 位。\n\n保存并重新登录",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "teacher",
+    "width": 1366,
+    "route": "/pages/home/index",
+    "runtimeErrors": 0,
+    "url": "/pages/home/index",
+    "overflow": false,
+    "body": "线上与线下,一对一陪练\n\n你好,本地审查账号\n\n陪练师工作台\n今日业务概览\n统计暂时无法加载\n重新加载\n关联学员复习情况\n抗遗忘带练\n抗遗忘计划暂时无法加载,点击重试\n查看全部学员复习计划\n陪练排课\n查看今天与未来课程\n销课记录\n核对已完成课程\n学员记录\n按学员查看学习成果\n抗遗忘工资\n查看抗遗忘复习工资\n主页\n陪练\n我的",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "teacher",
+    "width": 1366,
+    "route": "/pages/home/shop",
+    "runtimeErrors": 0,
+    "url": "/pages/home/shop",
+    "overflow": false,
+    "body": "实时同步暂时不可用,已保留当前内容\n陪练排课\n— 节\n刷新课表\n清除筛选\n今天\n明天\n本周\n全部\n全部 —\n计划未完成 —\n已结束 —\n‹\n2026年10月\n›\n一\n二\n三\n四\n五\n六\n日\n28\n29\n30\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n29\n30\n31\n1\n2\n3\n4\n5\n6\n7\n8\n\n2026-10-13 · — 节课\n\n课表暂时无法加载,请重试\n\n请求暂未完成,请检查网络后重试;已填写内容会保留。\n重新加载\n上一页\n共 — 条 · 1 / 1\n下一页\n主页\n陪练\n我的",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "admin",
+    "width": 1366,
+    "route": "/admin/dashboard",
+    "runtimeErrors": 0,
+    "url": "/admin/dashboard",
+    "overflow": false,
+    "body": "小树陪练\n运营管理后台\n\n运营总览\n\n工作台\n\n核心运营\n\n会员管理\n门店管理\n排课中心\n学习报表\n陪练老师\n工资结算\n\n内容运营\n\n词库管理\n专项训练\n\n账号与安全\n\n注销申请\n运营账号管理\n\n个人设置\n\n修改密码\n退出登录\n工作台\nL\n本地审查管理员\n\nOPERATIONS OVERVIEW\n\n运营工作台\n聚焦今天需要处理的用户、预约和教学任务\n刷新数据\n暂时无法载入运营工作台\n本地审查:模拟服务暂不可用\n重新加载",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "admin",
+    "width": 1366,
+    "route": "/admin/schedule",
+    "runtimeErrors": 0,
+    "url": "/admin/schedule",
+    "overflow": false,
+    "body": "小树陪练\n运营管理后台\n\n运营总览\n\n工作台\n\n核心运营\n\n会员管理\n门店管理\n排课中心\n学习报表\n陪练老师\n工资结算\n\n内容运营\n\n词库管理\n专项训练\n\n账号与安全\n\n注销申请\n运营账号管理\n\n个人设置\n\n修改密码\n退出登录\n返回\n排课中心\nL\n本地审查管理员\n\nSCHEDULING CENTER\n\n排课中心\n按陪练或时间快速排课,并在提交前发现冲突\n刷新\n新增排课\n日\n周\n列表\n日期\n2026年10月13日\n陪练筛选\n全部陪练\n全部陪练\n搜索排课\n业务状态\n全部状态\n待开始\n上课中\n陪练已结束\n学员已确认\n已完课\n已归档\n逾期未完成\n状态待核对\n已取消\n待分配 / 账号异常\n全部状态\n查找\n重置\n0\n条筛选结果\n当前范围共 0 条\n\n本地审查:模拟服务暂不可用\n\n预约到完课流程\n排课、上课、结课与评价\n老师一次结课,学生随后评价\n1\n运营创建预约\n选择学员已绑定课程、时间和陪练\n状态 0\n2\n陪练开始上课\n预约当天均可开始,记录实际上课时间\n上课中\n3\n老师结课并生成报告\n课堂结果、反馈与课时扣减一次完成\n已结课\n4\n学生评价\n学生查看报告后评分;未评价不阻塞结课\n评价独立\n当前范围课程\n—\n2026-10-13 至 2026-10-13\n待分配\n—\n未分配老师时不能开始\n在岗陪练\n—\n未配置可用时间会提示\n历史排课总量\n—\n— 至 —\n数据库更新\n新排课程、教学状态以新库为准\n陪练排课泳道\n点击排课查看明细与可用操作\n待开始\n上课中\n陪练已结束\n学员已确认\n已完课\n已归档\n逾期未完成\n状态待核对\n已取消\n课表未能加载,请重试",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "role": "admin",
+    "width": 1366,
+    "route": "/admin/payroll",
+    "runtimeErrors": 0,
+    "url": "/admin/payroll",
+    "overflow": false,
+    "body": "小树陪练\n运营管理后台\n\n运营总览\n\n工作台\n\n核心运营\n\n会员管理\n门店管理\n排课中心\n学习报表\n陪练老师\n工资结算\n\n内容运营\n\n词库管理\n专项训练\n\n账号与安全\n\n注销申请\n运营账号管理\n\n个人设置\n\n修改密码\n退出登录\n返回\n工资结算\nL\n本地审查管理员\n\nPAYROLL CENTER\n\n工资结算\n按有效上课记录与抗遗忘工资核算工资,完成月度审核与发放登记\n刷新统计\n导出 Excel\n\n收益统计: 本地审查:模拟服务暂不可用;工资批次: 本地审查:模拟服务暂不可用;历史单价: 本地审查:模拟服务暂不可用;工资标准: 本地审查:模拟服务暂不可用;老师列表: 本地审查:模拟服务暂不可用。对应区域保留上次成功结果(如有),请重试。\n\n收益统计\n抗遗忘工资\n工资批次\n排课与工资标准\n统计月份\n2026年10月\n门店\n全部门店\n全部门店\n陪练搜索\n查询\n重置\n缓存数据 · 查询于\n核验 2026-10 结算来源",
+    "unnamedInputs": [],
+    "pageErrors": []
+  },
+  {
+    "case": "unauthenticated",
+    "requested": "/pages/shop/get_order?id=901",
+    "actual": "/pages/member/wxauth",
+    "overflow": false
+  },
+  {
+    "case": "unauthenticated",
+    "requested": "/admin/payroll",
+    "actual": "/admin/login",
+    "overflow": false
+  }
+]

+ 18 - 0
docs/qa/launch-fixes-2026-10-13/migration-plan.json

@@ -0,0 +1,18 @@
+{
+  "apply": false,
+  "files": [
+    "account-security.sql",
+    "payroll-integrity.sql",
+    "payroll-cutoff.sql",
+    "account-deletion.sql"
+  ],
+  "parseClass": "_User",
+  "fields": [
+    "legacyPasswordMigrationAllowed",
+    "credentialRevision",
+    "credentialExpectedRevision",
+    "credentialMigrationAttempt",
+    "passwordResetRequired"
+  ],
+  "requires": "Back up database, apply existing payroll and direct-sync prerequisites, then run --apply before publishing gateways."
+}

BIN
docs/qa/launch-fixes-2026-10-13/payroll-cutoff.png


+ 10 - 0
docs/qa/launch-fixes-2026-10-13/probe-local-pages.cjs

@@ -0,0 +1,10 @@
+const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');const fs=require('fs');
+(async()=>{const b=await chromium.launch({headless:true,channel:'chrome'});const results=[];const root='docs/qa/launch-fixes-2026-10-13';
+for(const viewport of [{width:375,height:812},{width:1366,height:900}]){
+ for(const [role,port,routes] of [['student',4202,['/pages/home/index','/pages/home/shop','/pages/home/ucenter','/pages/member/passwd']],['teacher',4202,['/pages/home/index','/pages/home/shop']],['admin',4302,['/admin/dashboard','/admin/schedule','/admin/payroll']]]){
+ const p=await b.newPage({viewport});const errors=[];p.on('pageerror',e=>errors.push(e.message));await p.addInitScript(({role})=>{localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:audit-fixture',user:{userId:role==='teacher'?30:20,groupId:role==='teacher'?3:1,honeyName:'本地审查账号'},student:null}));localStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify({objectId:'audit',sessionToken:'r:audit-fixture',username:'local-audit',displayName:'本地审查管理员',roles:['ops-manager'],operationsRole:'ops-manager',company:{objectId:'fixture'}}));},{role});
+ await p.route('**/*',async r=>{const u=new URL(r.request().url());if(!['127.0.0.1','localhost'].includes(u.hostname))return r.abort();if(u.pathname.startsWith('/cloud-functions')||u.pathname.startsWith('/ncloud')||u.pathname.startsWith('/backend'))return r.fulfill({status:503,json:{retcode:-1,retmsg:'本地审查:模拟服务暂不可用',success:false,message:'本地审查:模拟服务暂不可用'}});return r.continue()});
+ for(const route of routes){await p.goto(`http://127.0.0.1:${port}${route}`);await p.waitForTimeout(2200);const record=await p.evaluate(()=>({runtimeErrors:JSON.parse(sessionStorage.getItem('xiaoshu.safe-diagnostics.v1')||'[]').filter(x=>x.kind==='runtime').length,url:location.pathname,overflow:document.documentElement.scrollWidth>innerWidth,body:document.body.innerText.slice(0,1700),unnamedInputs:[...document.querySelectorAll('input')].filter(e=>!e.labels?.length&&!e.getAttribute('aria-label')&&!e.getAttribute('aria-labelledby')&&e.type!=='hidden').map(e=>({name:e.name,type:e.type,placeholder:e.placeholder}))}));results.push({role,width:viewport.width,route,...record,pageErrors:[...errors]});if(viewport.width===375&&route==='/pages/home/index')await p.screenshot({path:`${root}/${role}-home-unavailable.png`,fullPage:true});}
+ await p.close();}
+}
+const p=await b.newPage({viewport:{width:375,height:812}});for(const [port,route] of [[4202,'/pages/shop/get_order?id=901'],[4302,'/admin/payroll']]){await p.goto(`http://127.0.0.1:${port}${route}`);await p.waitForTimeout(200);results.push({case:'unauthenticated',requested:route,actual:new URL(p.url()).pathname,overflow:await p.evaluate(()=>document.documentElement.scrollWidth>innerWidth)});}await b.close();if(results.some(x=>x.runtimeErrors||x.overflow||x.pageErrors?.length))throw Error('Runtime or layout regression: '+JSON.stringify(results));fs.writeFileSync(`${root}/local-page-probes.json`,JSON.stringify(results,null,2));console.log(JSON.stringify(results.map(({body,...r})=>r)));})().catch(e=>{console.error(e);process.exit(1)});

BIN
docs/qa/launch-fixes-2026-10-13/report-previews/appointment-375.png


BIN
docs/qa/launch-fixes-2026-10-13/report-previews/appointment-812.png


BIN
docs/qa/launch-fixes-2026-10-13/report-previews/edit-375.png


BIN
docs/qa/launch-fixes-2026-10-13/report-previews/edit-812.png


BIN
docs/qa/launch-fixes-2026-10-13/report-previews/report-375.png


BIN
docs/qa/launch-fixes-2026-10-13/report-previews/report-812.png


+ 35 - 0
docs/qa/launch-fixes-2026-10-13/report-ui-check.cjs

@@ -0,0 +1,35 @@
+const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
+const fs=require('fs');
+(async()=>{
+const browser=await chromium.launch({headless:true,channel:'chrome'});const page=await browser.newPage({viewport:{width:375,height:812}});
+const errors=[];page.on('pageerror',e=>errors.push(e.message));
+const feedback={contentSummary:'学习了八下4个,必修一6个,一共10个单词',reviewStatus:'复习上节课易错词',reviewSummary:'全部复习完成',trainingStatus:'正常进行',trainingSummary:'第4节课 / 20节',homework:'填写学习报表,并完成课后复习',noHomework:false,coachComment:'表现很好,继续保持',notes:'',vocabularyCount:10,durationMinutes:60,revision:'2026-10-13 10:26:09.101256+08'};
+const order={GeneralID:901,szyh:20,pl:30,HoneyName:'测试学员',kcmc:'新课标高中考纲3000词',yykcmc:'新课标高中考纲3000词',dslx:2,dszt:30,bxrq:'2026-10-13',sdsd:'16:00',yysj:'2026-10-13 16:00',jffs:'线上',plxm:'钱老师',allowedActions:['editFeedback'],reviewStatus:'pending',feedback};
+let saves=0;
+await page.addInitScript(()=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:isolated-ui',user:{userId:30,groupId:3,honeyName:'测试老师'},student:null})));
+await page.route('**/*',async r=>{if(!['127.0.0.1','localhost'].includes(new URL(r.request().url()).hostname))return r.abort();return r.continue();});
+await page.route('**/cloud-functions/**',async route=>{
+ const p=route.request().postDataJSON()?.params||{};let result=[];
+ if(p.action==='e_order_detail')result=[order];
+ if(p.action==='app_live_versions')result={version:'test=1',scopeVersions:{appointments:'1'},syncStatus:{state:'current'}};
+ if(p.action==='user_get')result={userId:20,groupId:1,honeyName:'测试学员'};
+ if(p.action==='content_list'&&Number(p.ModelID)===59)result=[{yyds:'901',pldp:feedback.coachComment,plpjsj:'2026-10-13 10:26:09.101256+08'}];
+ if(p.action==='app_lesson_report')result={order,feedback,records:[{generalId:1,dqrq:'20261013',learned:10,ygg:10,djq:0,con:'八下 · 必修一'}],antiForget:{total:10,completed:2},refreshedAt:'2026-10-13T03:26:00Z'};
+ if(p.action==='app_lesson_feedback_update'){if(!p.requestKey)throw Error('missing key');Object.assign(feedback,JSON.parse(p.feedback));saves++;result={saved:true};}
+ await route.fulfill({json:{retcode:0,result}});
+});
+const root='docs/qa/launch-fixes-2026-10-13/report-previews';fs.mkdirSync(root,{recursive:true});
+for(const width of [375,812]){
+ await page.setViewportSize({width,height:812});await page.goto('http://127.0.0.1:4202/pages/shop/baobiao?id=901');await page.getByText('本次课堂报告').waitFor();await page.screenshot({path:`${root}/report-${width}.png`,fullPage:true});
+ if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('report overflow '+width);
+ await page.goto('http://127.0.0.1:4202/pages/shop/get_order?id=901');await page.getByText('课堂内容与下次学习安排').waitFor();await page.screenshot({path:`${root}/appointment-${width}.png`,fullPage:true});
+ if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('appointment overflow '+width);
+ await page.getByRole('button',{name:'修改报告'}).last().click();await page.getByRole('dialog').waitFor();await page.screenshot({path:`${root}/edit-${width}.png`});
+ await page.getByRole('button',{name:'取消修改'}).click();
+}
+await page.getByRole('button',{name:'修改报告'}).last().click();await page.getByRole('textbox',{name:'本次学习的课本或强化内容'}).fill('已修改内容');await page.getByRole('button',{name:'保存修改'}).click();await page.getByText('报告修改已保存',{exact:true}).first().waitFor();
+await page.goto('http://127.0.0.1:4202/pages/shop/baobiao?id=901');await page.getByRole('link',{name:'修改报告'}).click();await page.getByRole('dialog').waitFor({timeout:8000}).catch(async e=>{console.log('EDIT_LINK_DEBUG',page.url(),await page.evaluate(()=>{const c=ng.getComponent(document.querySelector('app-appointment-page'));return {meta:c.meta,handled:c.handledEditNavigation,navigation:c.editNavigation(),submitting:c.submitting(),verified:c.detailVerified(),can:c.can('editFeedback'),student:c.isStudentView,editing:c.editingFeedback(),dialog:c.completionDialog()?.nativeElement.outerHTML.slice(0,800),feedback:c.feedback(),url:c.router.url,params:c.route.snapshot.queryParams}}),errors);throw e;});await page.getByRole('button',{name:'取消修改'}).click();
+await page.emulateMedia({reducedMotion:'reduce'});await page.setViewportSize({width:812,height:375});await page.getByRole('button',{name:'修改报告'}).last().click();await page.getByRole('button',{name:'保存修改'}).scrollIntoViewIfNeeded();if(await page.getByRole('dialog').evaluate(e=>e.scrollWidth>e.clientWidth))throw Error('landscape dialog overflow');
+if(await page.evaluate(()=>JSON.parse(sessionStorage.getItem('xiaoshu.safe-diagnostics.v1')||'[]').some(x=>x.kind==='runtime')))throw Error('Angular runtime error');if(saves!==1)throw Error('save count '+saves);if(errors.length)throw Error(errors.join('\n'));
+console.log(JSON.stringify({viewports:[375,812],overflow:false,saves,pageErrors:errors,screenshots:root}));await browser.close();
+})().catch(e=>{console.error(e);process.exit(1)});

+ 14 - 0
docs/qa/launch-fixes-2026-10-13/security-ui-check.cjs

@@ -0,0 +1,14 @@
+const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright');const fs=require('fs');const assert=require('assert');
+(async()=>{const browser=await chromium.launch({headless:true,channel:'chrome'});const results=[],errors=[];
+const root='docs/qa/launch-fixes-2026-10-13';let changes=0;
+const p=await browser.newPage({viewport:{width:375,height:812}});p.on('pageerror',e=>errors.push(e.message));
+await p.route('**/*',async r=>{const u=new URL(r.request().url());if(!['127.0.0.1','localhost'].includes(u.hostname))return r.abort();if(u.pathname.startsWith('/cloud-functions')){const params=r.request().postDataJSON()?.params||{};let result={};if(params.action==='user_login_passwd')result={userId:20,objectId:'fixture',groupId:1,honeyName:'测试学员',sessionToken:'r:fixture',passwordResetRequired:true};if(params.action==='user_get')return r.fulfill({json:{retcode:0,result:{userId:20,groupId:1,passwordResetRequired:true},addon:{State:1}}});if(params.action==='app_password_change'){changes++;if(changes===1)return r.fulfill({status:503,json:{retmsg:'本地测试:保存暂时不可用'}});result={changed:true};}if(params.action==='app_deletion_status')result={items:[]};return r.fulfill({json:{retcode:0,result}});}return r.continue();});
+await p.goto('http://127.0.0.1:4202/pages/member/wxauth');await p.getByLabel('账号',{exact:true}).fill('fixture');await p.getByLabel('密码',{exact:true}).fill('p'.repeat(72));await p.locator('input[name=privacy]').check();await p.getByRole('button',{name:'登录',exact:true}).click();await p.waitForURL('**/pages/member/passwd');await p.getByLabel('当前密码',{exact:true}).fill('p'.repeat(72));await p.getByLabel('新密码',{exact:true}).fill('updated-password');await p.getByLabel('确认新密码',{exact:true}).fill('updated-password');await p.getByRole('button',{name:'保存并重新登录'}).click();await p.getByRole('alert').waitFor();assert.equal(await p.getByLabel('新密码',{exact:true}).inputValue(),'updated-password');await p.getByRole('button',{name:'保存并重新登录'}).click();await p.waitForURL('**/wxauth?passwordChanged=1');await p.getByText('密码已修改,请使用新密码重新登录').waitFor();assert.equal(changes,2);results.push({case:'72-character login and forced reset, failure retains input, success relogin',passed:true});
+const admin=await browser.newPage({viewport:{width:1366,height:900}});admin.on('pageerror',e=>errors.push(e.message));await admin.addInitScript(()=>localStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify({objectId:'fixture',sessionToken:'r:fixture',username:'fixture',roles:['ops-manager'],operationsRole:'ops-manager',company:{objectId:'fixture'}})));
+let cutoff={month:'2026-10',version:'v1',runId:'fixture',cutoffAt:'2026-10-13T15:00:00Z',complete:true,pending:1,approved:false,monthClosed:false,datasets:[{model:54,source:1,matched:1}],conflicts:[{id:'f',dataset:'59',legacyKey:'1',student:'测试学员',course:'测试课程',date:'2026-10-12',fields:['teacherPaySnapshot'],current:{teacherPaySnapshot:20},fingerprint:'fp',cosmetic:false,resolved:false}]};let confirmations=0;
+await admin.route('**/*',async r=>{const u=new URL(r.request().url());if(!['127.0.0.1','localhost'].includes(u.hostname))return r.abort();if(u.pathname.startsWith('/cloud-functions')){const q=r.request().postDataJSON()?.params||{};let data={items:[],total:0,page:1,pageSize:20};if(q.operation==='meta')data={identity:{objectId:'fixture',roles:['ops-manager'],operationsRole:'ops-manager',isSuperAdmin:false,company:{objectId:'fixture'}},classes:0,cloudFunctions:1};if(q.operation==='ops/payroll/cutoff')data=cutoff;if(q.operation==='ops/payroll/cutoff-confirm'){confirmations++;assert.equal(q.payload.month,'2026-10');if(q.payload.conflictId){assert.equal(q.payload.fingerprint,'fp');cutoff={...cutoff,pending:0,version:'v2',conflicts:cutoff.conflicts.map(x=>({...x,resolved:true}))};}else cutoff={...cutoff,approved:true};data=cutoff;}if(q.operation==='ops/payroll/overview')data={month:'2026-10',coachItems:[],lines:[],stores:[],batches:[],coachCount:0,lessonCount:0,totalHours:0,baseAmount:0,reviewAmount:0,reviewCount:0,payableAmount:0,exceptionCount:0,sourceStatus:{mode:'parse',refreshedAt:'2026-10-13T15:00:00Z',sync:{state:'batch_verified',settlementReady:cutoff.approved,lastSuccessAt:'2026-10-13T15:00:00Z',lagSeconds:0}}};if(q.operation==='ops/teaching-rules/list')data={categories:[],rules:[],mappings:[],summary:{totalCourses:0,confirmed:0,pending:0,activeRules:0}};if(q.operation==='ops/vocabulary/tree')data={items:[],totalWords:0};if(q.operation==='ops/live/versions')data={version:'v',scopeVersions:{},syncStatus:{state:'current'}};return r.fulfill({json:{success:true,data}});}return r.continue();});
+await admin.goto('http://127.0.0.1:4302/admin/payroll');await admin.getByRole('button',{name:/核验 .*结算来源/}).click();await admin.getByText('待核对 1 条',{exact:false}).waitFor();await admin.getByLabel('核验依据').fill('核对旧记录后,保留当前工资快照');await admin.locator('app-payroll-cutoff summary').click();await admin.getByRole('button',{name:'确认此记录保留新系统'}).click();await admin.getByRole('button',{name:'确认该月份截止来源'}).click();await admin.getByText('来源已确认;数据变化后确认将自动失效。').waitFor();assert.equal(confirmations,2);results.push({case:'payroll cutoff explicit per-conflict confirmation and month approval',passed:true});await admin.evaluate(()=>window.scrollTo(0,0));await admin.screenshot({path:root+'/payroll-cutoff.png',fullPage:true});
+await admin.goto('http://127.0.0.1:4302/admin/special-training');const opener=admin.getByRole('button',{name:'新增专项',exact:true});await opener.click();await admin.getByRole('dialog',{name:'编辑专项训练'}).waitFor();await admin.waitForTimeout(100);assert(await admin.getByRole('dialog').evaluate(el=>el.contains(document.activeElement)));await admin.keyboard.press('Shift+Tab');assert(await admin.getByRole('dialog').evaluate(el=>el.contains(document.activeElement)));await admin.keyboard.press('Escape');await admin.getByRole('dialog').waitFor({state:'hidden'});assert(await opener.evaluate(el=>el===document.activeElement));results.push({case:'drawer initial focus, focus trap, Escape and focus return',passed:true});
+await admin.goto('http://127.0.0.1:4302/admin/vocabulary');await admin.locator('input[type=file]').first().setInputFiles({name:'oversized.xlsx',mimeType:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',buffer:Buffer.alloc(5*1024*1024+1)});await admin.getByRole('alert').filter({hasText:'5 MB'}).waitFor();const XLSX=require(process.cwd()+'/node_modules/xlsx');const book=XLSX.utils.book_new();XLSX.utils.book_append_sheet(book,XLSX.utils.aoa_to_sheet([['单词','释义'],['apple','苹果']]),'词库');await admin.locator('input[type=file]').first().setInputFiles({name:'fixture.xlsx',mimeType:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',buffer:XLSX.write(book,{type:'buffer',bookType:'xlsx',compression:true})});await admin.getByRole('dialog',{name:'导入词库'}).waitFor();results.push({case:'XLSX oversized rejection and valid compressed workbook preview',passed:true});
+const pdfPage=await browser.newPage();await pdfPage.setContent('<main id="report" style="width:700px;font:20px Arial">'+Array.from({length:65},(_,i)=>'<p>Learning report '+i+' — local export verification</p>').join('')+'</main>');await pdfPage.addScriptTag({path:process.cwd()+'/node_modules/html2pdf.js/dist/html2pdf.bundle.min.js'});const bytes=await pdfPage.evaluate(async()=>await html2pdf().set({margin:10,html2canvas:{scale:1},jsPDF:{unit:'mm',format:'a4',orientation:'portrait'}}).from(document.querySelector('#report')).toPdf().outputPdf('datauristring'));const pdf=Buffer.from(bytes.split(',')[1],'base64');fs.writeFileSync(root+'/export-fixture.pdf',pdf);const pages=(pdf.toString('latin1').match(/\/Type \/Page\b/g)||[]).length;assert(pages>=2);results.push({case:'html2pdf real multi-page PDF generation',pages,bytes:pdf.length,passed:true});
+for(const page of [p,admin]){assert.equal(await page.locator('#runtime-notice').count(),0);assert.deepEqual(await page.evaluate(()=>JSON.parse(sessionStorage.getItem('xiaoshu.safe-diagnostics.v1')||'[]').filter(x=>x.kind==='runtime')),[]);}assert.deepEqual(errors,[]);fs.writeFileSync(root+'/security-ui-results.json',JSON.stringify({results,pageErrors:errors},null,2));console.log(JSON.stringify(results));await browser.close();})().catch(e=>{console.error(e);process.exit(1)});

+ 27 - 0
docs/qa/launch-fixes-2026-10-13/security-ui-results.json

@@ -0,0 +1,27 @@
+{
+  "results": [
+    {
+      "case": "72-character login and forced reset, failure retains input, success relogin",
+      "passed": true
+    },
+    {
+      "case": "payroll cutoff explicit per-conflict confirmation and month approval",
+      "passed": true
+    },
+    {
+      "case": "drawer initial focus, focus trap, Escape and focus return",
+      "passed": true
+    },
+    {
+      "case": "XLSX oversized rejection and valid compressed workbook preview",
+      "passed": true
+    },
+    {
+      "case": "html2pdf real multi-page PDF generation",
+      "pages": 3,
+      "bytes": 415913,
+      "passed": true
+    }
+  ],
+  "pageErrors": []
+}

BIN
docs/qa/launch-fixes-2026-10-13/student-home-unavailable.png


BIN
docs/qa/launch-fixes-2026-10-13/teacher-home-unavailable.png


+ 58 - 0
docs/qa/launch-fixes-2026-10-13/verification-summary.json

@@ -0,0 +1,58 @@
+{
+  "baseCommit": "6d4e042",
+  "scope": "local isolated tests, staged source tree; no deployment or production writes",
+  "command": "npm run verify:local",
+  "exitCode": 0,
+  "mobile": {
+    "passed": 209,
+    "failed": 0
+  },
+  "admin": {
+    "passed": 261,
+    "failed": 0
+  },
+  "server": {
+    "passed": 169,
+    "failed": 0
+  },
+  "build": {
+    "mobile": "passed",
+    "admin": "passed"
+  },
+  "cloudDefinitions": 11,
+  "actions": {
+    "source": "local-code",
+    "requested": 118,
+    "implemented": 104,
+    "blocked": 21,
+    "missing": []
+  },
+  "browser": {
+    "faultStates": 18,
+    "unauthenticatedRedirects": 2,
+    "runtimeErrors": 0,
+    "horizontalOverflow": 0,
+    "report": {
+      "widths": [
+        375,
+        812
+      ],
+      "landscape": true,
+      "writes": 1
+    },
+    "securityChecks": 5
+  },
+  "onlineStatusProbe": "Read-only earlier probe still showed old gateway missing new actions; expected until user deploys",
+  "activeSchedulerUpdated": false,
+  "dependencyRisk": "Production audit 0; 12 development entries from one unresolved braces advisory",
+  "localServers": {
+    "4200": {
+      "html": 200,
+      "mainJs": 200
+    },
+    "4300": {
+      "html": 200,
+      "mainJs": 200
+    }
+  }
+}

+ 1913 - 0
docs/qa/launch-fixes-2026-10-13/verification.log

@@ -0,0 +1,1913 @@
+
+> xiaoshu-angular@0.0.0 verify:local
+> npm run test:ci && npm run test:server && npm run build && npm run cloud:validate && npm run app:actions:audit:local
+
+
+> xiaoshu-angular@0.0.0 test:ci
+> npm run test:mobile:ci && npm run test:admin:ci
+
+
+> xiaoshu-angular@0.0.0 test:mobile:ci
+> ng test xiaoshu-mobile --no-watch --no-progress --browsers=ChromeHeadless
+
+13 10 2026 13:20:51.729:INFO [karma-server]: Karma v6.4.4 server started at http://127.0.0.1:9876/
+13 10 2026 13:20:51.732:INFO [launcher]: Launching browsers ChromeHeadless with concurrency unlimited
+13 10 2026 13:20:51.737:INFO [launcher]: Starting browser ChromeHeadless
+13 10 2026 13:20:53.801:INFO [Chrome Headless 155.0.0.0 (Mac OS 10.15.7)]: Connected on socket BAiMR7e2Bjlf0cFdAAAA with id 79004735
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 0 of 209 SUCCESS (0 secs / 0 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 0 of 209 SUCCESS (0 secs / 0 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 0 of 209 SUCCESS (0 secs / 0 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 1 of 209 SUCCESS (0 secs / 0.976 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 2 of 209 SUCCESS (0 secs / 0.977 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 3 of 209 SUCCESS (0 secs / 0.977 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 4 of 209 SUCCESS (0 secs / 0.977 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 5 of 209 SUCCESS (0 secs / 0.977 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 6 of 209 SUCCESS (0 secs / 0.977 secs)
+13 10 2026 13:21:03.203:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/banana.mp3
+13 10 2026 13:21:03.322:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/banana.mp3
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 7 of 209 SUCCESS (0 secs / 9.875 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 8 of 209 SUCCESS (0 secs / 9.889 secs)
+13 10 2026 13:21:05.956:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/tend.mp3
+13 10 2026 13:21:05.964:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/1.mp3
+13 10 2026 13:21:05.965:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/tend.mp3
+13 10 2026 13:21:05.966:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/1.mp3
+13 10 2026 13:21:06.090:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/tend.mp3
+13 10 2026 13:21:06.090:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/1.mp3
+13 10 2026 13:21:06.210:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/tend.mp3
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 9 of 209 SUCCESS (0 secs / 12.751 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 10 of 209 SUCCESS (0 secs / 12.758 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 10 of 209 SUCCESS (0 secs / 12.758 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 11 of 209 SUCCESS (0 secs / 15.496 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 12 of 209 SUCCESS (0 secs / 15.539 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 12 of 209 SUCCESS (0 secs / 15.539 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 13 of 209 SUCCESS (0 secs / 15.549 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 14 of 209 SUCCESS (0 secs / 15.597 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 15 of 209 SUCCESS (0 secs / 15.619 secs)
+13 10 2026 13:21:11.694:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/1.mp3
+13 10 2026 13:21:11.712:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/1.mp3
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 15 of 209 SUCCESS (0 secs / 15.619 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 16 of 209 SUCCESS (0 secs / 21.103 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 17 of 209 SUCCESS (0 secs / 21.109 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 18 of 209 SUCCESS (0 secs / 21.116 secs)
+13 10 2026 13:21:17.185:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/banana.mp3
+13 10 2026 13:21:17.293:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/banana.mp3
+13 10 2026 13:21:17.323:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/banana.mp3
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 19 of 209 SUCCESS (0 secs / 23.728 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 20 of 209 SUCCESS (0 secs / 23.754 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 20 of 209 SUCCESS (0 secs / 23.754 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 21 of 209 SUCCESS (0 secs / 23.769 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 21 of 209 SUCCESS (0 secs / 23.769 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 22 of 209 SUCCESS (0 secs / 26.631 secs)
+13 10 2026 13:21:22.697:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/tend.mp3
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 23 of 209 SUCCESS (0 secs / 26.684 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 24 of 209 SUCCESS (0 secs / 26.694 secs)
+13 10 2026 13:21:25.502:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/1.mp3
+13 10 2026 13:21:25.503:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/1.mp3
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 25 of 209 SUCCESS (0 secs / 32.151 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 26 of 209 SUCCESS (0 secs / 32.159 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 27 of 209 SUCCESS (0 secs / 32.167 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 28 of 209 SUCCESS (0 secs / 32.192 secs)
+13 10 2026 13:21:31.003:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/1.mp3
+13 10 2026 13:21:31.005:WARN [web-server]: 404: /_karma_webpack_/UploadFiles/1.mp3
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 29 of 209 SUCCESS (0 secs / 37.649 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 30 of 209 SUCCESS (0 secs / 37.655 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 30 of 209 SUCCESS (0 secs / 37.655 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 31 of 209 SUCCESS (0 secs / 37.682 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 32 of 209 SUCCESS (0 secs / 37.697 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 33 of 209 SUCCESS (0 secs / 37.701 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 34 of 209 SUCCESS (0 secs / 37.74 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 34 of 209 SUCCESS (0 secs / 37.74 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 35 of 209 SUCCESS (0 secs / 37.748 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 36 of 209 SUCCESS (0 secs / 37.766 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 37 of 209 SUCCESS (0 secs / 40.495 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 37 of 209 SUCCESS (0 secs / 40.495 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 38 of 209 SUCCESS (0 secs / 40.505 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 39 of 209 SUCCESS (0 secs / 40.51 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 40 of 209 SUCCESS (0 secs / 40.511 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 41 of 209 SUCCESS (0 secs / 40.513 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 42 of 209 SUCCESS (0 secs / 40.514 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 43 of 209 SUCCESS (0 secs / 40.516 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 44 of 209 SUCCESS (0 secs / 40.516 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 45 of 209 SUCCESS (0 secs / 40.516 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 46 of 209 SUCCESS (0 secs / 40.516 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 47 of 209 SUCCESS (0 secs / 40.52 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 48 of 209 SUCCESS (0 secs / 40.524 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 49 of 209 SUCCESS (0 secs / 40.525 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 50 of 209 SUCCESS (0 secs / 40.527 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 51 of 209 SUCCESS (0 secs / 40.529 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 52 of 209 SUCCESS (0 secs / 40.531 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 53 of 209 SUCCESS (0 secs / 40.533 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 54 of 209 SUCCESS (0 secs / 40.534 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 55 of 209 SUCCESS (0 secs / 40.534 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 56 of 209 SUCCESS (0 secs / 40.534 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 57 of 209 SUCCESS (0 secs / 40.534 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 58 of 209 SUCCESS (0 secs / 40.535 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 59 of 209 SUCCESS (0 secs / 40.535 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 60 of 209 SUCCESS (0 secs / 40.535 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 61 of 209 SUCCESS (0 secs / 40.535 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 62 of 209 SUCCESS (0 secs / 40.535 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 63 of 209 SUCCESS (0 secs / 40.536 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 64 of 209 SUCCESS (0 secs / 40.536 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 65 of 209 SUCCESS (0 secs / 40.536 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 66 of 209 SUCCESS (0 secs / 40.537 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 67 of 209 SUCCESS (0 secs / 40.537 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 68 of 209 SUCCESS (0 secs / 40.538 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 69 of 209 SUCCESS (0 secs / 40.538 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 70 of 209 SUCCESS (0 secs / 40.539 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 71 of 209 SUCCESS (0 secs / 40.541 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 72 of 209 SUCCESS (0 secs / 40.542 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 73 of 209 SUCCESS (0 secs / 40.543 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 74 of 209 SUCCESS (0 secs / 40.543 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 75 of 209 SUCCESS (0 secs / 40.544 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 76 of 209 SUCCESS (0 secs / 40.545 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 77 of 209 SUCCESS (0 secs / 40.545 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 78 of 209 SUCCESS (0 secs / 40.546 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 79 of 209 SUCCESS (0 secs / 40.547 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 80 of 209 SUCCESS (0 secs / 40.549 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 81 of 209 SUCCESS (0 secs / 40.55 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 82 of 209 SUCCESS (0 secs / 40.551 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 83 of 209 SUCCESS (0 secs / 40.552 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 84 of 209 SUCCESS (0 secs / 40.553 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 85 of 209 SUCCESS (0 secs / 40.554 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 86 of 209 SUCCESS (0 secs / 40.557 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 87 of 209 SUCCESS (0 secs / 40.558 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 88 of 209 SUCCESS (0 secs / 40.559 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 89 of 209 SUCCESS (0 secs / 40.56 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 90 of 209 SUCCESS (0 secs / 40.562 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 91 of 209 SUCCESS (0 secs / 40.563 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 92 of 209 SUCCESS (0 secs / 40.564 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 93 of 209 SUCCESS (0 secs / 40.565 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 94 of 209 SUCCESS (0 secs / 40.566 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 95 of 209 SUCCESS (0 secs / 40.567 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 96 of 209 SUCCESS (0 secs / 40.568 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 97 of 209 SUCCESS (0 secs / 40.569 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 98 of 209 SUCCESS (0 secs / 40.571 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 99 of 209 SUCCESS (0 secs / 40.572 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 100 of 209 SUCCESS (0 secs / 40.573 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 101 of 209 SUCCESS (0 secs / 40.575 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 102 of 209 SUCCESS (0 secs / 40.576 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 103 of 209 SUCCESS (0 secs / 40.577 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 104 of 209 SUCCESS (0 secs / 40.613 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 105 of 209 SUCCESS (0 secs / 40.613 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 106 of 209 SUCCESS (0 secs / 40.616 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 107 of 209 SUCCESS (0 secs / 40.617 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 108 of 209 SUCCESS (0 secs / 40.645 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 109 of 209 SUCCESS (0 secs / 40.657 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 110 of 209 SUCCESS (0 secs / 40.678 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 111 of 209 SUCCESS (0 secs / 40.678 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 112 of 209 SUCCESS (0 secs / 40.68 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 113 of 209 SUCCESS (0 secs / 40.686 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 114 of 209 SUCCESS (0 secs / 40.686 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 115 of 209 SUCCESS (0 secs / 40.689 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 116 of 209 SUCCESS (0 secs / 40.693 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 117 of 209 SUCCESS (0 secs / 40.696 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 118 of 209 SUCCESS (0 secs / 40.699 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 119 of 209 SUCCESS (0 secs / 40.701 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 120 of 209 SUCCESS (0 secs / 40.704 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 121 of 209 SUCCESS (0 secs / 40.705 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 122 of 209 SUCCESS (0 secs / 40.705 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 123 of 209 SUCCESS (0 secs / 40.705 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 124 of 209 SUCCESS (0 secs / 40.706 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 125 of 209 SUCCESS (0 secs / 40.706 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 126 of 209 SUCCESS (0 secs / 40.706 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 127 of 209 SUCCESS (0 secs / 40.706 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 128 of 209 SUCCESS (0 secs / 40.707 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 129 of 209 SUCCESS (0 secs / 40.708 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 129 of 209 SUCCESS (0 secs / 40.708 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 130 of 209 SUCCESS (0 secs / 40.725 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 130 of 209 SUCCESS (0 secs / 40.725 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 131 of 209 SUCCESS (0 secs / 40.728 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 131 of 209 SUCCESS (0 secs / 40.728 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 132 of 209 SUCCESS (0 secs / 40.732 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 132 of 209 SUCCESS (0 secs / 40.732 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 133 of 209 SUCCESS (0 secs / 40.736 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 133 of 209 SUCCESS (0 secs / 40.736 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 134 of 209 SUCCESS (0 secs / 40.74 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 134 of 209 SUCCESS (0 secs / 40.74 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 135 of 209 SUCCESS (0 secs / 40.744 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 135 of 209 SUCCESS (0 secs / 40.744 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 136 of 209 SUCCESS (0 secs / 40.749 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 136 of 209 SUCCESS (0 secs / 40.749 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 137 of 209 SUCCESS (0 secs / 40.76 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 137 of 209 SUCCESS (0 secs / 40.76 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 138 of 209 SUCCESS (0 secs / 40.767 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 138 of 209 SUCCESS (0 secs / 40.767 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 139 of 209 SUCCESS (0 secs / 40.77 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 139 of 209 SUCCESS (0 secs / 40.77 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 140 of 209 SUCCESS (0 secs / 40.774 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 140 of 209 SUCCESS (0 secs / 40.774 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 141 of 209 SUCCESS (0 secs / 40.784 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 141 of 209 SUCCESS (0 secs / 40.784 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 142 of 209 SUCCESS (0 secs / 40.789 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 142 of 209 SUCCESS (0 secs / 40.789 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 143 of 209 SUCCESS (0 secs / 40.805 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 143 of 209 SUCCESS (0 secs / 40.805 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 144 of 209 SUCCESS (0 secs / 40.809 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 144 of 209 SUCCESS (0 secs / 40.809 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 145 of 209 SUCCESS (0 secs / 40.813 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 145 of 209 SUCCESS (0 secs / 40.813 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 146 of 209 SUCCESS (0 secs / 40.824 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 146 of 209 SUCCESS (0 secs / 40.824 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 147 of 209 SUCCESS (0 secs / 40.828 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 147 of 209 SUCCESS (0 secs / 40.828 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 148 of 209 SUCCESS (0 secs / 40.832 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 148 of 209 SUCCESS (0 secs / 40.832 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 149 of 209 SUCCESS (0 secs / 40.843 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 149 of 209 SUCCESS (0 secs / 40.843 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 150 of 209 SUCCESS (0 secs / 40.855 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 150 of 209 SUCCESS (0 secs / 40.855 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 151 of 209 SUCCESS (0 secs / 40.866 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 152 of 209 SUCCESS (0 secs / 40.866 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 152 of 209 SUCCESS (0 secs / 40.866 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 153 of 209 SUCCESS (0 secs / 40.872 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 153 of 209 SUCCESS (0 secs / 40.872 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 154 of 209 SUCCESS (0 secs / 40.876 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 154 of 209 SUCCESS (0 secs / 40.876 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 155 of 209 SUCCESS (0 secs / 40.88 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 155 of 209 SUCCESS (0 secs / 40.88 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 156 of 209 SUCCESS (0 secs / 40.886 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 156 of 209 SUCCESS (0 secs / 40.886 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 157 of 209 SUCCESS (0 secs / 40.892 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 157 of 209 SUCCESS (0 secs / 40.892 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 158 of 209 SUCCESS (0 secs / 40.904 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 158 of 209 SUCCESS (0 secs / 40.904 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 159 of 209 SUCCESS (0 secs / 40.918 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 159 of 209 SUCCESS (0 secs / 40.918 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 160 of 209 SUCCESS (0 secs / 40.923 secs)
+WARN: 'The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)'
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 160 of 209 SUCCESS (0 secs / 40.923 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 161 of 209 SUCCESS (0 secs / 40.929 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 162 of 209 SUCCESS (0 secs / 40.93 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 163 of 209 SUCCESS (0 secs / 40.93 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 164 of 209 SUCCESS (0 secs / 40.93 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 165 of 209 SUCCESS (0 secs / 40.93 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 166 of 209 SUCCESS (0 secs / 40.931 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 167 of 209 SUCCESS (0 secs / 40.933 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 168 of 209 SUCCESS (0 secs / 40.934 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 169 of 209 SUCCESS (0 secs / 40.935 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 170 of 209 SUCCESS (0 secs / 40.935 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 171 of 209 SUCCESS (0 secs / 40.936 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 172 of 209 SUCCESS (0 secs / 40.938 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 173 of 209 SUCCESS (0 secs / 40.948 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 174 of 209 SUCCESS (0 secs / 40.952 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 175 of 209 SUCCESS (0 secs / 40.958 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 176 of 209 SUCCESS (0 secs / 40.963 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 177 of 209 SUCCESS (0 secs / 40.971 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 178 of 209 SUCCESS (0 secs / 40.974 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 179 of 209 SUCCESS (0 secs / 40.975 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 180 of 209 SUCCESS (0 secs / 40.976 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 181 of 209 SUCCESS (0 secs / 40.976 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 182 of 209 SUCCESS (0 secs / 40.978 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 183 of 209 SUCCESS (0 secs / 40.978 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 184 of 209 SUCCESS (0 secs / 41.009 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 185 of 209 SUCCESS (0 secs / 41.031 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 186 of 209 SUCCESS (0 secs / 41.052 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 187 of 209 SUCCESS (0 secs / 41.071 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 188 of 209 SUCCESS (0 secs / 41.091 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 189 of 209 SUCCESS (0 secs / 41.111 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 190 of 209 SUCCESS (0 secs / 41.13 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 191 of 209 SUCCESS (0 secs / 41.148 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 192 of 209 SUCCESS (0 secs / 41.17 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 193 of 209 SUCCESS (0 secs / 41.171 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 194 of 209 SUCCESS (0 secs / 41.172 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 195 of 209 SUCCESS (0 secs / 41.174 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 196 of 209 SUCCESS (0 secs / 41.174 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 197 of 209 SUCCESS (0 secs / 41.174 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 198 of 209 SUCCESS (0 secs / 41.176 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 199 of 209 SUCCESS (0 secs / 41.177 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 200 of 209 SUCCESS (0 secs / 41.178 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 201 of 209 SUCCESS (0 secs / 41.179 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 202 of 209 SUCCESS (0 secs / 41.184 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 203 of 209 SUCCESS (0 secs / 41.189 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 204 of 209 SUCCESS (0 secs / 41.191 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 205 of 209 SUCCESS (0 secs / 41.192 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 206 of 209 SUCCESS (0 secs / 41.193 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 207 of 209 SUCCESS (0 secs / 41.193 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 208 of 209 SUCCESS (0 secs / 41.194 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 209 of 209 SUCCESS (0 secs / 41.194 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 209 of 209 SUCCESS (41.303 secs / 41.194 secs)
+TOTAL: 209 SUCCESS
+
+> xiaoshu-angular@0.0.0 test:admin:ci
+> ng test xiaoshu-admin --no-watch --no-progress --browsers=ChromeHeadless
+
+13 10 2026 13:21:48.152:INFO [karma-server]: Karma v6.4.4 server started at http://127.0.0.1:9876/
+13 10 2026 13:21:48.155:INFO [launcher]: Launching browsers ChromeHeadless with concurrency unlimited
+13 10 2026 13:21:48.168:INFO [launcher]: Starting browser ChromeHeadless
+13 10 2026 13:21:49.095:INFO [Chrome Headless 155.0.0.0 (Mac OS 10.15.7)]: Connected on socket mYlukOvfHR8IovhNAAAA with id 90431852
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 0 of 261 SUCCESS (0 secs / 0 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 1 of 261 SUCCESS (0 secs / 0.017 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 2 of 261 SUCCESS (0 secs / 0.021 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 3 of 261 SUCCESS (0 secs / 0.024 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 4 of 261 SUCCESS (0 secs / 0.028 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 5 of 261 SUCCESS (0 secs / 0.031 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 6 of 261 SUCCESS (0 secs / 0.033 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 7 of 261 SUCCESS (0 secs / 0.035 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 8 of 261 SUCCESS (0 secs / 0.038 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 9 of 261 SUCCESS (0 secs / 0.041 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 10 of 261 SUCCESS (0 secs / 0.044 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 11 of 261 SUCCESS (0 secs / 0.046 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 12 of 261 SUCCESS (0 secs / 0.118 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 13 of 261 SUCCESS (0 secs / 0.129 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 14 of 261 SUCCESS (0 secs / 0.151 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 15 of 261 SUCCESS (0 secs / 0.151 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 16 of 261 SUCCESS (0 secs / 0.151 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 17 of 261 SUCCESS (0 secs / 0.151 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 18 of 261 SUCCESS (0 secs / 0.187 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 19 of 261 SUCCESS (0 secs / 0.187 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 20 of 261 SUCCESS (0 secs / 0.187 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 21 of 261 SUCCESS (0 secs / 0.188 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 22 of 261 SUCCESS (0 secs / 0.189 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 23 of 261 SUCCESS (0 secs / 0.189 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 24 of 261 SUCCESS (0 secs / 0.19 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 25 of 261 SUCCESS (0 secs / 0.191 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 26 of 261 SUCCESS (0 secs / 0.191 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 27 of 261 SUCCESS (0 secs / 0.192 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 28 of 261 SUCCESS (0 secs / 0.205 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 29 of 261 SUCCESS (0 secs / 0.209 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 30 of 261 SUCCESS (0 secs / 0.211 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 31 of 261 SUCCESS (0 secs / 0.212 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 32 of 261 SUCCESS (0 secs / 0.216 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 33 of 261 SUCCESS (0 secs / 0.218 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 34 of 261 SUCCESS (0 secs / 0.219 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 35 of 261 SUCCESS (0 secs / 0.22 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 36 of 261 SUCCESS (0 secs / 0.221 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 37 of 261 SUCCESS (0 secs / 0.222 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 38 of 261 SUCCESS (0 secs / 0.224 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 39 of 261 SUCCESS (0 secs / 0.226 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 40 of 261 SUCCESS (0 secs / 0.227 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 41 of 261 SUCCESS (0 secs / 0.289 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 42 of 261 SUCCESS (0 secs / 0.299 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 43 of 261 SUCCESS (0 secs / 0.307 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 44 of 261 SUCCESS (0 secs / 0.314 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 45 of 261 SUCCESS (0 secs / 0.317 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 46 of 261 SUCCESS (0 secs / 0.318 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 47 of 261 SUCCESS (0 secs / 0.319 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 48 of 261 SUCCESS (0 secs / 0.32 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 49 of 261 SUCCESS (0 secs / 0.359 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 50 of 261 SUCCESS (0 secs / 0.393 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 51 of 261 SUCCESS (0 secs / 0.402 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 52 of 261 SUCCESS (0 secs / 0.41 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 53 of 261 SUCCESS (0 secs / 0.412 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 54 of 261 SUCCESS (0 secs / 0.418 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 55 of 261 SUCCESS (0 secs / 0.445 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 56 of 261 SUCCESS (0 secs / 0.447 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 57 of 261 SUCCESS (0 secs / 0.449 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 58 of 261 SUCCESS (0 secs / 0.45 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 59 of 261 SUCCESS (0 secs / 0.46 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 60 of 261 SUCCESS (0 secs / 0.46 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 61 of 261 SUCCESS (0 secs / 0.46 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 62 of 261 SUCCESS (0 secs / 0.469 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 63 of 261 SUCCESS (0 secs / 0.469 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 64 of 261 SUCCESS (0 secs / 0.469 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 65 of 261 SUCCESS (0 secs / 0.469 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 66 of 261 SUCCESS (0 secs / 0.469 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 67 of 261 SUCCESS (0 secs / 0.471 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 68 of 261 SUCCESS (0 secs / 0.472 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 69 of 261 SUCCESS (0 secs / 0.473 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 70 of 261 SUCCESS (0 secs / 0.475 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 71 of 261 SUCCESS (0 secs / 0.476 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 72 of 261 SUCCESS (0 secs / 0.476 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 73 of 261 SUCCESS (0 secs / 0.476 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 74 of 261 SUCCESS (0 secs / 0.478 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 75 of 261 SUCCESS (0 secs / 0.479 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 76 of 261 SUCCESS (0 secs / 0.48 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 77 of 261 SUCCESS (0 secs / 0.482 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 78 of 261 SUCCESS (0 secs / 0.483 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 79 of 261 SUCCESS (0 secs / 0.484 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 80 of 261 SUCCESS (0 secs / 0.486 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 81 of 261 SUCCESS (0 secs / 0.634 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 82 of 261 SUCCESS (0 secs / 0.637 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 83 of 261 SUCCESS (0 secs / 0.639 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 84 of 261 SUCCESS (0 secs / 0.641 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 85 of 261 SUCCESS (0 secs / 0.643 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 86 of 261 SUCCESS (0 secs / 0.646 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 87 of 261 SUCCESS (0 secs / 0.649 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 88 of 261 SUCCESS (0 secs / 0.652 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 89 of 261 SUCCESS (0 secs / 0.654 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 90 of 261 SUCCESS (0 secs / 0.657 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 91 of 261 SUCCESS (0 secs / 0.659 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 92 of 261 SUCCESS (0 secs / 0.661 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 93 of 261 SUCCESS (0 secs / 0.663 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 94 of 261 SUCCESS (0 secs / 0.665 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 95 of 261 SUCCESS (0 secs / 0.668 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 96 of 261 SUCCESS (0 secs / 0.67 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 97 of 261 SUCCESS (0 secs / 0.672 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 98 of 261 SUCCESS (0 secs / 0.674 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 99 of 261 SUCCESS (0 secs / 0.676 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 100 of 261 SUCCESS (0 secs / 0.678 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 101 of 261 SUCCESS (0 secs / 0.681 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 102 of 261 SUCCESS (0 secs / 0.683 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 103 of 261 SUCCESS (0 secs / 0.685 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 104 of 261 SUCCESS (0 secs / 0.687 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 105 of 261 SUCCESS (0 secs / 0.689 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 106 of 261 SUCCESS (0 secs / 0.691 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 107 of 261 SUCCESS (0 secs / 0.693 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 108 of 261 SUCCESS (0 secs / 0.695 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 109 of 261 SUCCESS (0 secs / 0.696 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 110 of 261 SUCCESS (0 secs / 0.697 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 111 of 261 SUCCESS (0 secs / 0.699 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 112 of 261 SUCCESS (0 secs / 0.701 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 113 of 261 SUCCESS (0 secs / 0.703 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 114 of 261 SUCCESS (0 secs / 0.705 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 115 of 261 SUCCESS (0 secs / 0.706 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 116 of 261 SUCCESS (0 secs / 0.708 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 117 of 261 SUCCESS (0 secs / 0.71 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 118 of 261 SUCCESS (0 secs / 0.712 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 119 of 261 SUCCESS (0 secs / 0.714 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 120 of 261 SUCCESS (0 secs / 0.716 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 121 of 261 SUCCESS (0 secs / 0.718 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 122 of 261 SUCCESS (0 secs / 0.72 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 123 of 261 SUCCESS (0 secs / 0.721 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 124 of 261 SUCCESS (0 secs / 0.723 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 125 of 261 SUCCESS (0 secs / 0.725 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 126 of 261 SUCCESS (0 secs / 0.727 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 127 of 261 SUCCESS (0 secs / 0.729 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 128 of 261 SUCCESS (0 secs / 0.731 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 129 of 261 SUCCESS (0 secs / 0.737 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 130 of 261 SUCCESS (0 secs / 0.739 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 131 of 261 SUCCESS (0 secs / 0.741 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 132 of 261 SUCCESS (0 secs / 0.743 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 133 of 261 SUCCESS (0 secs / 0.745 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 134 of 261 SUCCESS (0 secs / 0.746 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 135 of 261 SUCCESS (0 secs / 0.748 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 136 of 261 SUCCESS (0 secs / 0.75 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 137 of 261 SUCCESS (0 secs / 0.752 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 138 of 261 SUCCESS (0 secs / 0.754 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 139 of 261 SUCCESS (0 secs / 0.756 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 140 of 261 SUCCESS (0 secs / 0.758 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 141 of 261 SUCCESS (0 secs / 0.76 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 142 of 261 SUCCESS (0 secs / 0.762 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 143 of 261 SUCCESS (0 secs / 0.764 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 144 of 261 SUCCESS (0 secs / 0.766 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 145 of 261 SUCCESS (0 secs / 0.768 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 146 of 261 SUCCESS (0 secs / 0.77 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 147 of 261 SUCCESS (0 secs / 0.772 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 148 of 261 SUCCESS (0 secs / 0.774 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 149 of 261 SUCCESS (0 secs / 0.776 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 150 of 261 SUCCESS (0 secs / 0.778 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 151 of 261 SUCCESS (0 secs / 0.78 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 152 of 261 SUCCESS (0 secs / 0.782 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 153 of 261 SUCCESS (0 secs / 0.784 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 154 of 261 SUCCESS (0 secs / 0.786 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 155 of 261 SUCCESS (0 secs / 0.788 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 156 of 261 SUCCESS (0 secs / 0.79 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 157 of 261 SUCCESS (0 secs / 0.792 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 158 of 261 SUCCESS (0 secs / 0.794 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 159 of 261 SUCCESS (0 secs / 0.795 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 160 of 261 SUCCESS (0 secs / 0.796 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 161 of 261 SUCCESS (0 secs / 0.798 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 162 of 261 SUCCESS (0 secs / 0.8 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 163 of 261 SUCCESS (0 secs / 0.802 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 164 of 261 SUCCESS (0 secs / 0.803 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 165 of 261 SUCCESS (0 secs / 0.805 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 166 of 261 SUCCESS (0 secs / 0.807 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 167 of 261 SUCCESS (0 secs / 0.808 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 168 of 261 SUCCESS (0 secs / 0.809 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 169 of 261 SUCCESS (0 secs / 0.811 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 170 of 261 SUCCESS (0 secs / 0.814 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 171 of 261 SUCCESS (0 secs / 0.816 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 172 of 261 SUCCESS (0 secs / 0.818 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 173 of 261 SUCCESS (0 secs / 0.82 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 174 of 261 SUCCESS (0 secs / 0.822 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 175 of 261 SUCCESS (0 secs / 0.823 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 176 of 261 SUCCESS (0 secs / 0.824 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 177 of 261 SUCCESS (0 secs / 0.826 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 178 of 261 SUCCESS (0 secs / 0.828 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 179 of 261 SUCCESS (0 secs / 0.915 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 180 of 261 SUCCESS (0 secs / 0.923 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 181 of 261 SUCCESS (0 secs / 0.939 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 182 of 261 SUCCESS (0 secs / 0.952 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 183 of 261 SUCCESS (0 secs / 0.953 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 184 of 261 SUCCESS (0 secs / 0.953 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 185 of 261 SUCCESS (0 secs / 0.953 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 186 of 261 SUCCESS (0 secs / 0.954 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 187 of 261 SUCCESS (0 secs / 0.964 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 188 of 261 SUCCESS (0 secs / 0.97 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 189 of 261 SUCCESS (0 secs / 1.02 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 190 of 261 SUCCESS (0 secs / 1.028 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 191 of 261 SUCCESS (0 secs / 1.034 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 192 of 261 SUCCESS (0 secs / 1.042 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 193 of 261 SUCCESS (0 secs / 1.043 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 194 of 261 SUCCESS (0 secs / 1.044 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 195 of 261 SUCCESS (0 secs / 1.053 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 196 of 261 SUCCESS (0 secs / 1.105 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 197 of 261 SUCCESS (0 secs / 1.112 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 198 of 261 SUCCESS (0 secs / 1.119 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 199 of 261 SUCCESS (0 secs / 1.126 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 200 of 261 SUCCESS (0 secs / 1.132 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 201 of 261 SUCCESS (0 secs / 1.146 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 202 of 261 SUCCESS (0 secs / 1.153 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 203 of 261 SUCCESS (0 secs / 1.159 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 204 of 261 SUCCESS (0 secs / 1.168 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 205 of 261 SUCCESS (0 secs / 1.178 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 206 of 261 SUCCESS (0 secs / 1.183 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 207 of 261 SUCCESS (0 secs / 1.19 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 208 of 261 SUCCESS (0 secs / 1.196 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 209 of 261 SUCCESS (0 secs / 1.217 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 210 of 261 SUCCESS (0 secs / 1.219 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 211 of 261 SUCCESS (0 secs / 1.22 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 212 of 261 SUCCESS (0 secs / 1.225 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 213 of 261 SUCCESS (0 secs / 1.226 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 214 of 261 SUCCESS (0 secs / 1.229 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 215 of 261 SUCCESS (0 secs / 1.229 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 216 of 261 SUCCESS (0 secs / 1.23 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 217 of 261 SUCCESS (0 secs / 1.231 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 218 of 261 SUCCESS (0 secs / 1.232 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 219 of 261 SUCCESS (0 secs / 1.233 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 220 of 261 SUCCESS (0 secs / 1.234 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 221 of 261 SUCCESS (0 secs / 1.235 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 222 of 261 SUCCESS (0 secs / 1.236 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 223 of 261 SUCCESS (0 secs / 1.237 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 224 of 261 SUCCESS (0 secs / 1.237 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 225 of 261 SUCCESS (0 secs / 1.237 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 226 of 261 SUCCESS (0 secs / 1.238 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 227 of 261 SUCCESS (0 secs / 1.239 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 228 of 261 SUCCESS (0 secs / 1.24 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 229 of 261 SUCCESS (0 secs / 1.241 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 230 of 261 SUCCESS (0 secs / 1.242 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 231 of 261 SUCCESS (0 secs / 1.243 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 232 of 261 SUCCESS (0 secs / 1.257 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 233 of 261 SUCCESS (0 secs / 1.261 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 234 of 261 SUCCESS (0 secs / 1.262 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 235 of 261 SUCCESS (0 secs / 1.263 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 236 of 261 SUCCESS (0 secs / 1.312 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 237 of 261 SUCCESS (0 secs / 1.322 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 238 of 261 SUCCESS (0 secs / 1.331 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 239 of 261 SUCCESS (0 secs / 1.332 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 240 of 261 SUCCESS (0 secs / 1.332 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 241 of 261 SUCCESS (0 secs / 1.333 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 242 of 261 SUCCESS (0 secs / 1.368 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 243 of 261 SUCCESS (0 secs / 1.392 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 244 of 261 SUCCESS (0 secs / 1.459 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 245 of 261 SUCCESS (0 secs / 1.471 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 246 of 261 SUCCESS (0 secs / 1.474 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 247 of 261 SUCCESS (0 secs / 1.477 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 248 of 261 SUCCESS (0 secs / 1.479 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 249 of 261 SUCCESS (0 secs / 1.482 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 250 of 261 SUCCESS (0 secs / 1.529 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 251 of 261 SUCCESS (0 secs / 1.531 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 252 of 261 SUCCESS (0 secs / 1.532 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 253 of 261 SUCCESS (0 secs / 1.534 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 254 of 261 SUCCESS (0 secs / 1.536 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 255 of 261 SUCCESS (0 secs / 1.579 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 256 of 261 SUCCESS (0 secs / 1.586 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 257 of 261 SUCCESS (0 secs / 1.586 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 258 of 261 SUCCESS (0 secs / 1.592 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 259 of 261 SUCCESS (0 secs / 1.647 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 260 of 261 SUCCESS (0 secs / 1.653 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 261 of 261 SUCCESS (0 secs / 1.659 secs)
+Chrome Headless 155.0.0.0 (Mac OS 10.15.7): Executed 261 of 261 SUCCESS (1.748 secs / 1.659 secs)
+TOTAL: 261 SUCCESS
+
+> xiaoshu-angular@0.0.0 test:server
+> node --test --test-concurrency=2 scripts/tests/*.test.mjs
+
+TAP version 13
+# Subtest: 旧密码只允许显式首次迁移,已重置和历史不明账号拒绝且不调用旧系统
+ok 1 - 旧密码只允许显式首次迁移,已重置和历史不明账号拒绝且不调用旧系统
+  ---
+  duration_ms: 3.349791
+  type: 'test'
+  ...
+# Subtest: 一次性迁移核对身份并携带验证前密码版本,不能切换账号
+ok 2 - 一次性迁移核对身份并携带验证前密码版本,不能切换账号
+  ---
+  duration_ms: 1.048988
+  type: 'test'
+  ...
+# Subtest: 数据库密码变化原子关闭回退、撤销会话,拒绝并发旧版本及客户端重新启用
+ok 3 - 数据库密码变化原子关闭回退、撤销会话,拒绝并发旧版本及客户端重新启用
+  ---
+  duration_ms: 2101.324049
+  type: 'test'
+  ...
+# Subtest: 核对文件哈希和登录/工资深层路由,不能只凭首页成功判断发布成功
+ok 4 - 核对文件哈希和登录/工资深层路由,不能只凭首页成功判断发布成功
+  ---
+  duration_ms: 67.574342
+  type: 'test'
+  ...
+# Subtest: 拒绝版本仍旧或深层路由返回其他页面的发布
+ok 5 - 拒绝版本仍旧或深层路由返回其他页面的发布
+  ---
+  duration_ms: 24.562503
+  type: 'test'
+  ...
+# Subtest: 发布包拒绝密钥文件和符号链接
+ok 6 - 发布包拒绝密钥文件和符号链接
+  ---
+  duration_ms: 5.746295
+  type: 'test'
+  ...
+# Subtest: 完整来源超过5000条仍可分页、搜索较早记录,空末页保留真实总数且租户隔离
+ok 7 - 完整来源超过5000条仍可分页、搜索较早记录,空末页保留真实总数且租户隔离
+  ---
+  duration_ms: 2175.697642
+  type: 'test'
+  ...
+# Subtest: 排课学员姓名跳过空白字段,兼容旧昵称、预约标题和缺失资料
+ok 8 - 排课学员姓名跳过空白字段,兼容旧昵称、预约标题和缺失资料
+  ---
+  duration_ms: 2021.176607
+  type: 'test'
+  ...
+# Subtest: 非超级管理员、其他帐套与另一执行者不能访问或写入
+ok 9 - 非超级管理员、其他帐套与另一执行者不能访问或写入
+  ---
+  duration_ms: 2083.592815
+  type: 'test'
+  ...
+# Subtest: 主附表原子新增、批次重试复用结果,不允许同一批次编号换内容
+ok 10 - 主附表原子新增、批次重试复用结果,不允许同一批次编号换内容
+  ---
+  duration_ms: 44.365894
+  type: 'test'
+  ...
+# Subtest: 已保存投影可刷新,修改后的记录保留;微秒级版本变化也拒绝覆盖
+ok 11 - 已保存投影可刷新,修改后的记录保留;微秒级版本变化也拒绝覆盖
+  ---
+  duration_ms: 21.996256
+  type: 'test'
+  ...
+# Subtest: 服务端捕获真实源编号并重新核对投影,缺主附表时不能生成对账凭据
+ok 12 - 服务端捕获真实源编号并重新核对投影,缺主附表时不能生成对账凭据
+  ---
+  duration_ms: 18.465389
+  type: 'test'
+  ...
+# Subtest: 冲突记录重复保存不增加重复行
+ok 13 - 冲突记录重复保存不增加重复行
+  ---
+  duration_ms: 7.943434
+  type: 'test'
+  ...
+# Subtest: 字段差异按业务类型规范化,旧空评分不抹掉新评分
+ok 14 - 字段差异按业务类型规范化,旧空评分不抹掉新评分
+  ---
+  duration_ms: 0.4837
+  type: 'test'
+  ...
+# {"dataset":"专项训练","pagesDone":1,"pages":1,"created":0,"updated":0,"conflicts":0}
+# {"dataset":"词库","pagesDone":1,"pages":1,"created":0,"updated":0,"conflicts":0}
+# {"dataset":"课程绑定","pagesDone":1,"pages":1,"created":0,"updated":0,"conflicts":0}
+# {"dataset":"每日学习","pagesDone":1,"pages":1,"created":0,"updated":0,"conflicts":0}
+# {"dataset":"练习记录","pagesDone":1,"pages":1,"created":0,"updated":0,"conflicts":0}
+# {"dataset":"抗遗忘","pagesDone":1,"pages":1,"created":0,"updated":0,"conflicts":0}
+# {"dataset":"预约","pagesDone":1,"pages":1,"created":0,"updated":0,"conflicts":0}
+# {"dataset":"上课","pagesDone":1,"pages":1,"created":0,"updated":0,"conflicts":0}
+# {"dataset":"测评","pagesDone":1,"pages":1,"created":0,"updated":0,"conflicts":0}
+# Subtest: 每日运行入口只使用直接接口、按依赖顺序并写私有报告,未对账不报成功
+ok 15 - 每日运行入口只使用直接接口、按依赖顺序并写私有报告,未对账不报成功
+  ---
+  duration_ms: 43.381074
+  type: 'test'
+  ...
+# Subtest: 有新系统计薪凭据的复习不回退、不重算工资
+ok 16 - 有新系统计薪凭据的复习不回退、不重算工资
+  ---
+  duration_ms: 21.68124
+  type: 'test'
+  ...
+# Subtest: 北京时间23点前补昨天、23点后执行今天,跨UTC日期仍正确
+ok 17 - 北京时间23点前补昨天、23点后执行今天,跨UTC日期仍正确
+  ---
+  duration_ms: 21.905651
+  type: 'test'
+  ...
+# Subtest: 新增账号与栏目幂等补齐,已有账户余额和目录名称保持不变
+ok 18 - 新增账号与栏目幂等补齐,已有账户余额和目录名称保持不变
+  ---
+  duration_ms: 55.087389
+  type: 'test'
+  ...
+# {"dataset":"专项训练","pagesDone":1,"pages":1,"created":0,"updated":0,"conflicts":0}
+# Subtest: 未取得服务器对账证据时不完成运行;已退出进程的目录锁可恢复
+ok 19 - 未取得服务器对账证据时不完成运行;已退出进程的目录锁可恢复
+  ---
+  duration_ms: 15.173821
+  type: 'test'
+  ...
+# Subtest: 删除标记禁止恢复,阅读数字编号冲突不会当作小树身份匹配
+ok 20 - 删除标记禁止恢复,阅读数字编号冲突不会当作小树身份匹配
+  ---
+  duration_ms: 12.52567
+  type: 'test'
+  ...
+# Subtest: 只有全部数据集对账后才能完成,统计来自写入回执,完成重试幂等
+ok 21 - 只有全部数据集对账后才能完成,统计来自写入回执,完成重试幂等
+  ---
+  duration_ms: 88.450471
+  type: 'test'
+  ...
+# Subtest: 只清理七天前已完成运行的原始缓存,保留失败断点及报告
+ok 22 - 只清理七天前已完成运行的原始缓存,保留失败断点及报告
+  ---
+  duration_ms: 5.801371
+  type: 'test'
+  ...
+# Subtest: 账号编号冲突时停止业务数据同步,保留阻塞报告
+ok 23 - 账号编号冲突时停止业务数据同步,保留阻塞报告
+  ---
+  duration_ms: 3.546853
+  type: 'test'
+  ...
+# Subtest: 同步告警:失败去重、持续失败次日提醒、恢复一次提醒、差异增长提醒且不包含原始错误
+ok 24 - 同步告警:失败去重、持续失败次日提醒、恢复一次提醒、差异增长提醒且不包含原始错误
+  ---
+  duration_ms: 12.691083
+  type: 'test'
+  ...
+# Subtest: 注销:重复申请仅一条、跨帐套隔离、未实际注销不可登记完成、并发版本拒绝
+ok 25 - 注销:重复申请仅一条、跨帐套隔离、未实际注销不可登记完成、并发版本拒绝
+  ---
+  duration_ms: 2022.357336
+  type: 'test'
+  ...
+# Subtest: 工资截止核验:区分文字差异与金额差异、版本改变失效、缺失记录拦截、整月审核门槛
+ok 26 - 工资截止核验:区分文字差异与金额差异、版本改变失效、缺失记录拦截、整月审核门槛
+  ---
+  duration_ms: 1544.317951
+  type: 'test'
+  ...
+# Subtest: 月份筛选兼容三种日期格式,并按北京时间补齐缺失学习日期
+ok 27 - 月份筛选兼容三种日期格式,并按北京时间补齐缺失学习日期
+  ---
+  duration_ms: 2025.018839
+  type: 'test'
+  ...
+# Subtest: 全部月份包含历史记录,保持帐套隔离
+ok 28 - 全部月份包含历史记录,保持帐套隔离
+  ---
+  duration_ms: 2.453274
+  type: 'test'
+  ...
+# Subtest: 日期范围包含首尾当天,兼容不同保存格式
+ok 29 - 日期范围包含首尾当天,兼容不同保存格式
+  ---
+  duration_ms: 4.356893
+  type: 'test'
+  ...
+# Subtest: 报表展示与筛选使用同一个学习日期,斜线日期与 UTC 时间不会少一天
+ok 30 - 报表展示与筛选使用同一个学习日期,斜线日期与 UTC 时间不会少一天
+  ---
+  duration_ms: 20.804141
+  type: 'test'
+  ...
+# Subtest: 当天已过钟点的旧预约恢复唯一开课、工资快照和预留,重试不改余额或状态
+ok 31 - 当天已过钟点的旧预约恢复唯一开课、工资快照和预留,重试不改余额或状态
+  ---
+  duration_ms: 2020.610481
+  type: 'test'
+  ...
+# Subtest: 越权、跨帐套、过期、取消、新系统预约及已开课不能自动修复
+ok 32 - 越权、跨帐套、过期、取消、新系统预约及已开课不能自动修复
+  ---
+  duration_ms: 27.922118
+  type: 'test'
+  ...
+# Subtest: 课时不足时整个事务回滚,不激活开课、不保存快照或审计
+ok 33 - 课时不足时整个事务回滚,不激活开课、不保存快照或审计
+  ---
+  duration_ms: 9.811011
+  type: 'test'
+  ...
+# Subtest: 重复开课、回收开课、未确认课程分类不能猜测恢复
+ok 34 - 重复开课、回收开课、未确认课程分类不能猜测恢复
+  ---
+  duration_ms: 16.786031
+  type: 'test'
+  ...
+# Subtest: 新系统已有工资快照保留,正式规则覆盖默认版本,禁用版本不能回退同版本默认价
+ok 35 - 新系统已有工资快照保留,正式规则覆盖默认版本,禁用版本不能回退同版本默认价
+  ---
+  duration_ms: 21.312603
+  type: 'test'
+  ...
+# Subtest: 教师详情自动核对后返回开始权限与实际绑定;学习单元采用同一开课记录
+ok 36 - 教师详情自动核对后返回开始权限与实际绑定;学习单元采用同一开课记录
+  ---
+  duration_ms: 24.328115
+  type: 'test'
+  ...
+# Subtest: 审计失败回滚已激活的开课、课时预留和快照,修复可重试
+ok 37 - 审计失败回滚已激活的开课、课时预留和快照,修复可重试
+  ---
+  duration_ms: 15.34215
+  type: 'test'
+  ...
+# Subtest: 新导入的空分类开课只补明确的非写作分类;全局写作映射不能替代逐人确认
+ok 38 - 新导入的空分类开课只补明确的非写作分类;全局写作映射不能替代逐人确认
+  ---
+  duration_ms: 11.359732
+  type: 'test'
+  ...
+# Subtest: 旧系统预约按学员读取并保留日期、状态与课程信息
+ok 39 - 旧系统预约按学员读取并保留日期、状态与课程信息
+  ---
+  duration_ms: 5.377786
+  type: 'test'
+  ...
+# Subtest: 旧系统预约覆盖过期快照并补入后续计划
+ok 40 - 旧系统预约覆盖过期快照并补入后续计划
+  ---
+  duration_ms: 0.974855
+  type: 'test'
+  ...
+# Subtest: 旧系统学习记录按学员过滤,并规范化北京时间与单词明细
+ok 41 - 旧系统学习记录按学员过滤,并规范化北京时间与单词明细
+  ---
+  duration_ms: 5.467686
+  type: 'test'
+  ...
+# Subtest: 旧系统学习记录只在变化时原子写入,重复轮询不制造版本变化
+ok 42 - 旧系统学习记录只在变化时原子写入,重复轮询不制造版本变化
+  ---
+  duration_ms: 3.446096
+  type: 'test'
+  ...
+# Subtest: 移动网关实时版本逻辑不引用运营网关的局部工具函数
+ok 43 - 移动网关实时版本逻辑不引用运营网关的局部工具函数
+  ---
+  duration_ms: 0.798363
+  type: 'test'
+  ...
+# Subtest: 旧系统抗遗忘记录只接收当前学员并保留计划与完成状态
+ok 44 - 旧系统抗遗忘记录只接收当前学员并保留计划与完成状态
+  ---
+  duration_ms: 5.495623
+  type: 'test'
+  ...
+# Subtest: 抗遗忘记录首次补齐后重复核对不再写入
+ok 45 - 抗遗忘记录首次补齐后重复核对不再写入
+  ---
+  duration_ms: 3.531476
+  type: 'test'
+  ...
+# Subtest: one worker tick requests every configured data set exactly once
+ok 46 - one worker tick requests every configured data set exactly once
+  ---
+  duration_ms: 196.078998
+  type: 'test'
+  ...
+# Subtest: dependent review records wait until learning history is fully caught up
+ok 47 - dependent review records wait until learning history is fully caught up
+  ---
+  duration_ms: 168.400202
+  type: 'test'
+  ...
+# Subtest: 教师按预约所属学员获取所有有效开课,包括不同课程分类,排除待确认、回收、停用和他人课程
+ok 48 - 教师按预约所属学员获取所有有效开课,包括不同课程分类,排除待确认、回收、停用和他人课程
+  ---
+  duration_ms: 2045.110842
+  type: 'test'
+  ...
+# Subtest: 开始时验证实际选择的课程但保留排课课程、工资和扣减快照;旧版本和停用课程拒绝
+ok 49 - 开始时验证实际选择的课程但保留排课课程、工资和扣减快照;旧版本和停用课程拒绝
+  ---
+  duration_ms: 31.725998
+  type: 'test'
+  ...
+# Subtest: 学习记录接受本学员所选开课而非强制排课开课;拒绝他人开课和非所选词库的单词
+ok 50 - 学习记录接受本学员所选开课而非强制排课开课;拒绝他人开课和非所选词库的单词
+  ---
+  duration_ms: 7.362388
+  type: 'test'
+  ...
+# Subtest: 实际学习写入事务按所选开课隔离记录,重试和更新保留业务ID且不覆盖另一词库
+ok 51 - 实际学习写入事务按所选开课隔离记录,重试和更新保留业务ID且不覆盖另一词库
+  ---
+  duration_ms: 63.016161
+  type: 'test'
+  ...
+# Subtest: 老师一次结课生成报告和学习快照,重复并发只扣一次;学生随后评价不改结课状态
+ok 52 - 老师一次结课生成报告和学习快照,重复并发只扣一次;学生随后评价不改结课状态
+  ---
+  duration_ms: 2166.858329
+  type: 'test'
+  ...
+# Subtest: 报告写入失败回滚结课、课时和学习记录,可原请求重试
+ok 53 - 报告写入失败回滚结课、课时和学习记录,可原请求重试
+  ---
+  duration_ms: 30.046872
+  type: 'test'
+  ...
+# Subtest: 越权、未结课评价、过期版本、取消均拒绝
+ok 54 - 越权、未结课评价、过期版本、取消均拒绝
+  ---
+  duration_ms: 14.326327
+  type: 'test'
+  ...
+# Subtest: 旧11/20课次复用已扣凭据与评价,缺凭据不补扣
+ok 55 - 旧11/20课次复用已扣凭据与评价,缺凭据不补扣
+  ---
+  duration_ms: 30.462225
+  type: 'test'
+  ...
+# Subtest: 评价原子拒绝无效评分与超长文字
+ok 56 - 评价原子拒绝无效评分与超长文字
+  ---
+  duration_ms: 16.253389
+  type: 'test'
+  ...
+# Subtest: 线上数值型评分列允许首次评价并保留第一次评分
+ok 57 - 线上数值型评分列允许首次评价并保留第一次评分
+  ---
+  duration_ms: 25.714542
+  type: 'test'
+  ...
+# Subtest: 同日提前开课、午夜后继续、非预约当天与改派老师在数据库校验
+ok 58 - 同日提前开课、午夜后继续、非预约当天与改派老师在数据库校验
+  ---
+  duration_ms: 17.945934
+  type: 'test'
+  ...
+# Subtest: 同一真实排课从教师、学生分页查询与详情读取,不访问旧接口;版本感知新增排课
+ok 59 - 同一真实排课从教师、学生分页查询与详情读取,不访问旧接口;版本感知新增排课
+  ---
+  duration_ms: 149.772375
+  type: 'test'
+  ...
+# Subtest: 旧客户端状态写入和旧分步完课明确拒绝,不触发数据库扣费
+ok 60 - 旧客户端状态写入和旧分步完课明确拒绝,不触发数据库扣费
+  ---
+  duration_ms: 8.246029
+  type: 'test'
+  ...
+# Subtest: 同一预约多个已开通词库汇总学习词汇,按实际词库生成复习且仍只扣预约课时
+ok 61 - 同一预约多个已开通词库汇总学习词汇,按实际词库生成复习且仍只扣预约课时
+  ---
+  duration_ms: 18.333787
+  type: 'test'
+  ...
+# Subtest: 选填复习情况和训练状态允许留空或自由文本,报告保存原文且重试不重复扣费
+ok 62 - 选填复习情况和训练状态允许留空或自由文本,报告保存原文且重试不重复扣费
+  ---
+  duration_ms: 47.866884
+  type: 'test'
+  ...
+# Subtest: 修改报告只更新文字;重试幂等、旧版本和越权拒绝,保留课时、学习成果与评价
+ok 63 - 修改报告只更新文字;重试幂等、旧版本和越权拒绝,保留课时、学习成果与评价
+  ---
+  duration_ms: 31.439193
+  type: 'test'
+  ...
+# Subtest: 六个新分类完整可选,语法写作仅选择对应学阶
+ok 64 - 六个新分类完整可选,语法写作仅选择对应学阶
+  ---
+  duration_ms: 22.356627
+  type: 'test'
+  ...
+# Subtest: 零余额、配套陪练不足和空内容分别说明原因
+ok 65 - 零余额、配套陪练不足和空内容分别说明原因
+  ---
+  duration_ms: 2.675104
+  type: 'test'
+  ...
+# Subtest: 同一内容有多个开课分类时旧请求拒绝,指定 bindingId 匹配各自规则
+ok 66 - 同一内容有多个开课分类时旧请求拒绝,指定 bindingId 匹配各自规则
+  ---
+  duration_ms: 2.629872
+  type: 'test'
+  ...
+# Subtest: 开课请求成功后的重试复用结果,不重新校验已经耗尽的余额
+ok 67 - 开课请求成功后的重试复用结果,不重新校验已经耗尽的余额
+  ---
+  duration_ms: 3.058306
+  type: 'test'
+  ...
+# Subtest: 批量排课跨规则日期分别保存快照
+ok 68 - 批量排课跨规则日期分别保存快照
+  ---
+  duration_ms: 13.852221
+  type: 'test'
+  ...
+# Subtest: 新后台列表按学员精确查询,筛选发生在全站记录上限之前
+ok 69 - 新后台列表按学员精确查询,筛选发生在全站记录上限之前
+  ---
+  duration_ms: 3.343792
+  type: 'test'
+  ...
+# Subtest: 旧同步不能覆盖账号或云端创建的开课预约,未授权投影被拒绝
+ok 70 - 旧同步不能覆盖账号或云端创建的开课预约,未授权投影被拒绝
+  ---
+  duration_ms: 2.422534
+  type: 'test'
+  ...
+# Subtest: 用户端通用内容接口不能绕过开课、预留及完课事务
+ok 71 - 用户端通用内容接口不能绕过开课、预留及完课事务
+  ---
+  duration_ms: 5.8575
+  type: 'test'
+  ...
+# Subtest: 会员预约详情沿用开课分类并识别回收站取消状态
+ok 72 - 会员预约详情沿用开课分类并识别回收站取消状态
+  ---
+  duration_ms: 2.859996
+  type: 'test'
+  ...
+# Subtest: 会员上课记录使用教师姓名和开课分类,不将编号当姓名
+ok 73 - 会员上课记录使用教师姓名和开课分类,不将编号当姓名
+  ---
+  duration_ms: 2.343844
+  type: 'test'
+  ...
+# Subtest: 会员概览未结束数、体验记录与下次预约排除已取消,保留总历史数
+ok 74 - 会员概览未结束数、体验记录与下次预约排除已取消,保留总历史数
+  ---
+  duration_ms: 2101.426501
+  type: 'test'
+  ...
+# Subtest: 同日每半小时重复的60分钟课程提前识别批内重叠,可跳过冲突项
+ok 75 - 同日每半小时重复的60分钟课程提前识别批内重叠,可跳过冲突项
+  ---
+  duration_ms: 6.265817
+  type: 'test'
+  ...
+# Subtest: 总部入账、双方划拨、幂等与原请求校验
+ok 76 - 总部入账、双方划拨、幂等与原请求校验
+  ---
+  duration_ms: 2219.783424
+  type: 'test'
+  ...
+# Subtest: 退回原门店,重复退款和数量校验
+ok 77 - 退回原门店,重复退款和数量校验
+  ---
+  duration_ms: 16.30486
+  type: 'test'
+  ...
+# Subtest: 零余额不能开课,多账户规则全部满足
+ok 78 - 零余额不能开课,多账户规则全部满足
+  ---
+  duration_ms: 20.451736
+  type: 'test'
+  ...
+# Subtest: 同词表跨分类可开,同分类幂等且不扣课时,正确栏目和自动数量
+ok 79 - 同词表跨分类可开,同分类幂等且不扣课时,正确栏目和自动数量
+  ---
+  duration_ms: 27.551835
+  type: 'test'
+  ...
+# Subtest: 预约必须明确分类,预留阻止超额退款和重复预留
+ok 80 - 预约必须明确分类,预留阻止超额退款和重复预留
+  ---
+  duration_ms: 21.502039
+  type: 'test'
+  ...
+# Subtest: 完课单事务扣减、消费预留和学习事实,重试不重复
+ok 81 - 完课单事务扣减、消费预留和学习事实,重试不重复
+  ---
+  duration_ms: 17.811834
+  type: 'test'
+  ...
+# Subtest: 迁移可重复运行,已有分类和学习进度不重置
+ok 82 - 迁移可重复运行,已有分类和学习进度不重置
+  ---
+  duration_ms: 25.042788
+  type: 'test'
+  ...
+# Subtest: 排课保存、修改、取消恢复均同步预留,重试不重复
+ok 83 - 排课保存、修改、取消恢复均同步预留,重试不重复
+  ---
+  duration_ms: 54.58546
+  type: 'test'
+  ...
+# Subtest: 批量排课超余额整体回滚,不留下半张预约或预留
+ok 84 - 批量排课超余额整体回滚,不留下半张预约或预留
+  ---
+  duration_ms: 15.759779
+  type: 'test'
+  ...
+# Subtest: 余额更新不能被旧资料覆盖,空课程开课事务回滚
+ok 85 - 余额更新不能被旧资料覆盖,空课程开课事务回滚
+  ---
+  duration_ms: 6.800554
+  type: 'test'
+  ...
+# Subtest: 双流水或审计失败时总部入账与业务单全部回滚
+ok 86 - 双流水或审计失败时总部入账与业务单全部回滚
+  ---
+  duration_ms: 11.258977
+  type: 'test'
+  ...
+# Subtest: 最后一节课的竞争提交仅一张预约成功,完课后重试原开课仍成功
+ok 87 - 最后一节课的竞争提交仅一张预约成功,完课后重试原开课仍成功
+  ---
+  duration_ms: 49.348998
+  type: 'test'
+  ...
+# Subtest: 运营网关开课排课、开课授权与管理员审计纠错共用课时快照
+ok 88 - 运营网关开课排课、开课授权与管理员审计纠错共用课时快照
+  ---
+  duration_ms: 66.986378
+  type: 'test'
+  ...
+# Subtest: 回收保留进度,恢复重新校验余额并保留原分类
+ok 89 - 回收保留进度,恢复重新校验余额并保留原分类
+  ---
+  duration_ms: 13.927834
+  type: 'test'
+  ...
+# Subtest: 已有唯一约束下重复迁移,冲突历史绑定保留进度并标记待确认
+ok 90 - 已有唯一约束下重复迁移,冲突历史绑定保留进度并标记待确认
+  ---
+  duration_ms: 29.740497
+  type: 'test'
+  ...
+# Subtest: 课程全天有效:当天零点、提前与延后均可,前一天与次日零点不可
+ok 91 - 课程全天有效:当天零点、提前与延后均可,前一天与次日零点不可
+  ---
+  duration_ms: 4.872937
+  type: 'test'
+  ...
+# Subtest: 教师不能提前到预约前一天开课,拒绝后不写入开课状态
+ok 92 - 教师不能提前到预约前一天开课,拒绝后不写入开课状态
+  ---
+  duration_ms: 14.692468
+  type: 'test'
+  ...
+# Subtest: 当天未开课继续预留课时,昨日未开课不再占用,已开课保留至完课
+ok 93 - 当天未开课继续预留课时,昨日未开课不再占用,已开课保留至完课
+  ---
+  duration_ms: 30.315922
+  type: 'test'
+  ...
+# Subtest: 教学事务:重复及并发请求只增一次,关联记录同事务,失败整体回滚
+ok 94 - 教学事务:重复及并发请求只增一次,关联记录同事务,失败整体回滚
+  ---
+  duration_ms: 2108.061755
+  type: 'test'
+  ...
+# Subtest: 完课确认:学生与老师依次确认,重复确认幂等,越权和取消预约拒绝
+ok 95 - 完课确认:学生与老师依次确认,重复确认幂等,越权和取消预约拒绝
+  ---
+  duration_ms: 1431.804516
+  type: 'test'
+  ...
+# Subtest: 学习记录:统计已生成文章,未作答文章也计入,已完成记录保持关联
+ok 96 - 学习记录:统计已生成文章,未作答文章也计入,已完成记录保持关联
+  ---
+  duration_ms: 1513.238928
+  type: 'test'
+  ...
+# Subtest: nightly refuses writes when 404, and persists the failure
+ok 97 - nightly refuses writes when 404, and persists the failure
+  ---
+  duration_ms: 151.601857
+  type: 'test'
+  ...
+# Subtest: nightly refuses writes when expired, and persists the failure
+ok 98 - nightly refuses writes when expired, and persists the failure
+  ---
+  duration_ms: 139.084589
+  type: 'test'
+  ...
+# Subtest: nightly refuses writes when unconfigured, and persists the failure
+ok 99 - nightly refuses writes when unconfigured, and persists the failure
+  ---
+  duration_ms: 141.754276
+  type: 'test'
+  ...
+# Subtest: nightly refuses writes when unprotected, and persists the failure
+ok 100 - nightly refuses writes when unprotected, and persists the failure
+  ---
+  duration_ms: 146.049179
+  type: 'test'
+  ...
+# Subtest: nightly drains multiple pages before dependents and requires reconciliation
+ok 101 - nightly drains multiple pages before dependents and requires reconciliation
+  ---
+  duration_ms: 217.419109
+  type: 'test'
+  ...
+# Subtest: round limit is a failure and never starts a dependent
+ok 102 - round limit is a failure and never starts a dependent
+  ---
+  duration_ms: 205.495677
+  type: 'test'
+  ...
+# Subtest: retained conflicts are recorded and never called success
+ok 103 - retained conflicts are recorded and never called success
+  ---
+  duration_ms: 207.827767
+  type: 'test'
+  ...
+# Subtest: caught up without business ID checksums is not success
+ok 104 - caught up without business ID checksums is not success
+  ---
+  duration_ms: 182.131733
+  type: 'test'
+  ...
+# Subtest: 旧管理员仅兼容本帐套运营经理,普通会员不获得后台权限
+ok 105 - 旧管理员仅兼容本帐套运营经理,普通会员不获得后台权限
+  ---
+  duration_ms: 0.942703
+  type: 'test'
+  ...
+# Subtest: 显式角色优先,adminRoleKey 与数组一致识别,未知运营角色拒绝
+ok 106 - 显式角色优先,adminRoleKey 与数组一致识别,未知运营角色拒绝
+  ---
+  duration_ms: 0.265495
+  type: 'test'
+  ...
+# Subtest: 角色修改撤销旧会话且审计只保存业务字段
+ok 107 - 角色修改撤销旧会话且审计只保存业务字段
+  ---
+  duration_ms: 2074.596983
+  type: 'test'
+  ...
+# Subtest: 普通运营不能管理账号,经理不能越帐套或提权超级管理员
+ok 108 - 普通运营不能管理账号,经理不能越帐套或提权超级管理员
+  ---
+  duration_ms: 1487.119718
+  type: 'test'
+  ...
+# Subtest: 保护本人和版本冲突,停用后旧会话被清除
+ok 109 - 保护本人和版本冲突,停用后旧会话被清除
+  ---
+  duration_ms: 1479.49134
+  type: 'test'
+  ...
+# Subtest: 两个经理互相停用时后一个操作重新验证权限,保留有效经理
+ok 110 - 两个经理互相停用时后一个操作重新验证权限,保留有效经理
+  ---
+  duration_ms: 1461.044062
+  type: 'test'
+  ...
+# Subtest: SQL 与云函数角色口径一致
+ok 111 - SQL 与云函数角色口径一致
+  ---
+  duration_ms: 1411.962436
+  type: 'test'
+  ...
+# Subtest: 底层用户接口不能直接修改权限、跨帐套或构造管理员注册
+ok 112 - 底层用户接口不能直接修改权限、跨帐套或构造管理员注册
+  ---
+  duration_ms: 1511.785016
+  type: 'test'
+  ...
+# Subtest: 首次增量发布会保留一个旧预览入口
+ok 113 - 首次增量发布会保留一个旧预览入口
+  ---
+  duration_ms: 1.012757
+  type: 'test'
+  ...
+# Subtest: 重复增量发布会移除递归包装并保留真正的旧预览
+ok 114 - 重复增量发布会移除递归包装并保留真正的旧预览
+  ---
+  duration_ms: 0.186574
+  type: 'test'
+  ...
+# Subtest: 缺少历史预约预览入口时拒绝发布
+ok 115 - 缺少历史预约预览入口时拒绝发布
+  ---
+  duration_ms: 0.478121
+  type: 'test'
+  ...
+# Subtest: 历史预约预览保留数据库微秒精度,避免确认时误判预约已更新
+ok 116 - 历史预约预览保留数据库微秒精度,避免确认时误判预约已更新
+  ---
+  duration_ms: 1.64625
+  type: 'test'
+  ...
+# Subtest: 北京时间月界,工资快照含零元不被规则覆盖
+ok 117 - 北京时间月界,工资快照含零元不被规则覆盖
+  ---
+  duration_ms: 2134.69379
+  type: 'test'
+  ...
+# Subtest: 未知运营角色不能获得经理权限
+ok 118 - 未知运营角色不能获得经理权限
+  ---
+  duration_ms: 0.291114
+  type: 'test'
+  ...
+# Subtest: 创建、课次跨批次防重、失败不留半张工资单
+ok 119 - 创建、课次跨批次防重、失败不留半张工资单
+  ---
+  duration_ms: 36.187652
+  type: 'test'
+  ...
+# Subtest: 调整幂等、门店和月份完整,过期版本拒绝
+ok 120 - 调整幂等、门店和月份完整,过期版本拒绝
+  ---
+  duration_ms: 6.350327
+  type: 'test'
+  ...
+# Subtest: 重算已有明细保留调整,异常禁止审核,审计失败全部回滚
+ok 121 - 重算已有明细保留调整,异常禁止审核,审计失败全部回滚
+  ---
+  duration_ms: 20.651768
+  type: 'test'
+  ...
+# Subtest: 审核永久锁定、登记发放校验凭据
+ok 122 - 审核永久锁定、登记发放校验凭据
+  ---
+  duration_ms: 9.097515
+  type: 'test'
+  ...
+# Subtest: 新完课入工资、事实不覆盖快照、预览和结算来源一致
+ok 123 - 新完课入工资、事实不覆盖快照、预览和结算来源一致
+  ---
+  duration_ms: 18.92254
+  type: 'test'
+  ...
+# Subtest: 规则同日修订保留旧版本,禁止直接覆盖
+ok 124 - 规则同日修订保留旧版本,禁止直接覆盖
+  ---
+  duration_ms: 10.21017
+  type: 'test'
+  ...
+# Subtest: 按门店和老师导出,排除记录单列,汇总明细一致
+ok 125 - 按门店和老师导出,排除记录单列,汇总明细一致
+  ---
+  duration_ms: 9.417852
+  type: 'test'
+  ...
+# Subtest: 课时差额通过账户事务退回一次,预览扣除已退金额
+ok 126 - 课时差额通过账户事务退回一次,预览扣除已退金额
+  ---
+  duration_ms: 20.252851
+  type: 'test'
+  ...
+# Subtest: 历史预留同事务、重复提交不重复预留,异常时整体回滚
+ok 127 - 历史预留同事务、重复提交不重复预留,异常时整体回滚
+  ---
+  duration_ms: 20.56181
+  type: 'test'
+  ...
+# Subtest: 阅读写作确认不受无关更新时间影响,确认后不再返回待核对列表
+ok 128 - 阅读写作确认不受无关更新时间影响,确认后不再返回待核对列表
+  ---
+  duration_ms: 15.687405
+  type: 'test'
+  ...
+# Subtest: 奖励导出快照及游标不受分页期间新增记录影响
+ok 129 - 奖励导出快照及游标不受分页期间新增记录影响
+  ---
+  duration_ms: 8.318377
+  type: 'test'
+  ...
+# Subtest: 工资表禁止直接绕过事务写入,迁移重跑不改变已发放金额
+ok 130 - 工资表禁止直接绕过事务写入,迁移重跑不改变已发放金额
+  ---
+  duration_ms: 10.591327
+  type: 'test'
+  ...
+# Subtest: 历史课次门店名称跟随保存的门店编号而不是教师现门店
+ok 131 - 历史课次门店名称跟随保存的门店编号而不是教师现门店
+  ---
+  duration_ms: 3.53329
+  type: 'test'
+  ...
+# Subtest: 陪练默认按未来排课由近到远,再按历史排课由近到远
+ok 132 - 陪练默认按未来排课由近到远,再按历史排课由近到远
+  ---
+  duration_ms: 1.004297
+  type: 'test'
+  ...
+# Subtest: 陪练活跃记录忽略已取消预约和已结束的未来异常记录
+ok 133 - 陪练活跃记录忽略已取消预约和已结束的未来异常记录
+  ---
+  duration_ms: 0.52232
+  type: 'test'
+  ...
+# Subtest: 旧单价接口也保留修订记录且审计在同一事务中
+ok 134 - 旧单价接口也保留修订记录且审计在同一事务中
+  ---
+  duration_ms: 13.478358
+  type: 'test'
+  ...
+# Subtest: 旧系统复习凭据仅人工确认后计薪,旧月份拒绝且重复确认幂等
+ok 135 - 旧系统复习凭据仅人工确认后计薪,旧月份拒绝且重复确认幂等
+  ---
+  duration_ms: 20.766893
+  type: 'test'
+  ...
+# Subtest: 工资网关只消费数据库月份截止核验,不再请求旧桥
+ok 136 - 工资网关只消费数据库月份截止核验,不再请求旧桥
+  ---
+  duration_ms: 3.122996
+  type: 'test'
+  ...
+# Subtest: 所选工资月份的已完课写作预约可逐人核对,其他月份不混入
+ok 137 - 所选工资月份的已完课写作预约可逐人核对,其他月份不混入
+  ---
+  duration_ms: 14.072097
+  type: 'test'
+  ...
+# Subtest: 手动同步真实收据显示完成时间,但不能冒充结算准备完成或桥检查点
+ok 138 - 手动同步真实收据显示完成时间,但不能冒充结算准备完成或桥检查点
+  ---
+  duration_ms: 2.740992
+  type: 'test'
+  ...
+# Subtest: 学段核对页预填一致证据,旧档案空值回退有效,冲突时不自动选分类
+ok 139 - 学段核对页预填一致证据,旧档案空值回退有效,冲突时不自动选分类
+  ---
+  duration_ms: 11.861165
+  type: 'test'
+  ...
+# Subtest: 手机号相同也不能跨来源自动合并,乍兔及未知阅读账号均待核实
+ok 140 - 手机号相同也不能跨来源自动合并,乍兔及未知阅读账号均待核实
+  ---
+  duration_ms: 1.428698
+  type: 'test'
+  ...
+# Subtest: 已核实为小树且唯一匹配的阅读账号可合并
+ok 141 - 已核实为小树且唯一匹配的阅读账号可合并
+  ---
+  duration_ms: 1.662819
+  type: 'test'
+  ...
+# Subtest: 已核实但未匹配或歧义的阅读账号也不能生成旧系统编号
+ok 142 - 已核实但未匹配或歧义的阅读账号也不能生成旧系统编号
+  ---
+  duration_ms: 0.987293
+  type: 'test'
+  ...
+# Subtest: 已删除来源及已删除小树目标不能复活
+ok 143 - 已删除来源及已删除小树目标不能复活
+  ---
+  duration_ms: 0.226588
+  type: 'test'
+  ...
+# Subtest: 按对象身份原子注销,释放阅读伪编号,保留课时和业务记录;重复执行无副作用
+ok 144 - 按对象身份原子注销,释放阅读伪编号,保留课时和业务记录;重复执行无副作用
+  ---
+  duration_ms: 2026.14475
+  type: 'test'
+  ...
+# Subtest: 历史完成零金额,重复完成和重复迁移不补发
+ok 145 - 历史完成零金额,重复完成和重复迁移不补发
+  ---
+  duration_ms: 2093.466848
+  type: 'test'
+  ...
+# Subtest: 首次完成一元,快照老师和北京时间月份;并发重试不改变完成时间
+ok 146 - 首次完成一元,快照老师和北京时间月份;并发重试不改变完成时间
+  ---
+  duration_ms: 34.714828
+  type: 'test'
+  ...
+# Subtest: 独立计划分别奖励;新任务生成重复被数据库拒绝
+ok 147 - 独立计划分别奖励;新任务生成重复被数据库拒绝
+  ---
+  duration_ms: 4.596847
+  type: 'test'
+  ...
+# Subtest: 历史重复来源和缺失老师、来源的记录不计薪
+ok 148 - 历史重复来源和缺失老师、来源的记录不计薪
+  ---
+  duration_ms: 8.634607
+  type: 'test'
+  ...
+# Subtest: 已完成导入任务进入历史待核对
+ok 149 - 已完成导入任务进入历史待核对
+  ---
+  duration_ms: 3.353403
+  type: 'test'
+  ...
+# Subtest: 旧系统同步完成状态保留原完成时间且不自动计薪
+ok 150 - 旧系统同步完成状态保留原完成时间且不自动计薪
+  ---
+  duration_ms: 3.885802
+  type: 'test'
+  ...
+# Subtest: 奖励插入失败时完成状态一起回滚
+ok 151 - 奖励插入失败时完成状态一起回滚
+  ---
+  duration_ms: 7.389384
+  type: 'test'
+  ...
+# Subtest: 跨月批次防重,数据库校正金额,审核后禁止变更和删除
+ok 152 - 跨月批次防重,数据库校正金额,审核后禁止变更和删除
+  ---
+  duration_ms: 17.245194
+  type: 'test'
+  ...
+# Subtest: 老师端收入、奖励列表和工资来源同一凭据;分页筛选与跨月未结算
+ok 153 - 老师端收入、奖励列表和工资来源同一凭据;分页筛选与跨月未结算
+  ---
+  duration_ms: 11.592701
+  type: 'test'
+  ...
+# Subtest: 工资汇总分开课酬、奖励与调整;筛选和导出不泄漏其他老师或排除项
+ok 154 - 工资汇总分开课酬、奖励与调整;筛选和导出不泄漏其他老师或排除项
+  ---
+  duration_ms: 10.601218
+  type: 'test'
+  ...
+# Subtest: 实际工资草稿刷新路径反复调用仍只有两条待结算奖励,审核不重复吸收
+ok 155 - 实际工资草稿刷新路径反复调用仍只有两条待结算奖励,审核不重复吸收
+  ---
+  duration_ms: 61.238945
+  type: 'test'
+  ...
+# Subtest: 导入的待完成任务经新系统首次完成计薪一元,重试幂等且完成上下文不泄漏
+ok 156 - 导入的待完成任务经新系统首次完成计薪一元,重试幂等且完成上下文不泄漏
+  ---
+  duration_ms: 10.485973
+  type: 'test'
+  ...
+# Subtest: 完整旧系统快照覆盖同 ID 状态,移除已不在旧系统的迁移预约,保留新系统预约
+ok 157 - 完整旧系统快照覆盖同 ID 状态,移除已不在旧系统的迁移预约,保留新系统预约
+  ---
+  duration_ms: 25.101979
+  type: 'test'
+  ...
+# Subtest: 旧系统不可用、快照过期或分页不完整时不判定旧预约缺课
+ok 158 - 旧系统不可用、快照过期或分页不完整时不判定旧预约缺课
+  ---
+  duration_ms: 0.249505
+  type: 'test'
+  ...
+# Subtest: 实时排课分页以所选范围数量为准,不被全量历史数量误导
+ok 159 - 实时排课分页以所选范围数量为准,不被全量历史数量误导
+  ---
+  duration_ms: 1.306015
+  type: 'test'
+  ...
+# Subtest: 门店在学正常人数复用会员筛选,按归属汇总且不受门店分页影响(旧门店数据:false)
+ok 160 - 门店在学正常人数复用会员筛选,按归属汇总且不受门店分页影响(旧门店数据:false)
+  ---
+  duration_ms: 18.653532
+  type: 'test'
+  ...
+# Subtest: 门店在学正常人数复用会员筛选,按归属汇总且不受门店分页影响(旧门店数据:true)
+ok 161 - 门店在学正常人数复用会员筛选,按归属汇总且不受门店分页影响(旧门店数据:true)
+  ---
+  duration_ms: 2.016408
+  type: 'test'
+  ...
+# Subtest: writing maps only to its explicit school stage; generic writing is never suggested as word lessons
+ok 162 - writing maps only to its explicit school stage; generic writing is never suggested as word lessons
+  ---
+  duration_ms: 2066.723839
+  type: 'test'
+  ...
+# Subtest: real course query recovers missing Node courses, preserves tenant isolation and stage checks
+ok 163 - real course query recovers missing Node courses, preserves tenant isolation and stage checks
+  ---
+  duration_ms: 10.913026
+  type: 'test'
+  ...
+# Subtest: mapping rejects cross-stage writing, unknown courses and generic writing before saving
+ok 164 - mapping rejects cross-stage writing, unknown courses and generic writing before saving
+  ---
+  duration_ms: 10.498791
+  type: 'test'
+  ...
+# Subtest: confirmed course mapping activates a valid unambiguous historical binding
+ok 165 - confirmed course mapping activates a valid unambiguous historical binding
+  ---
+  duration_ms: 4.533298
+  type: 'test'
+  ...
+# Subtest: 明确年级和姓名标注可判断,数字年级不会误判十一年级为一年级
+ok 166 - 明确年级和姓名标注可判断,数字年级不会误判十一年级为一年级
+  ---
+  duration_ms: 1.831766
+  type: 'test'
+  ...
+# Subtest: 空年级仍能读取旧档案,城市学校名称和不确定描述不能推断学段
+ok 167 - 空年级仍能读取旧档案,城市学校名称和不确定描述不能推断学段
+  ---
+  duration_ms: 0.352226
+  type: 'test'
+  ...
+# Subtest: 同学年已确认预约可复用,其他学员和跨学年证据不会串用
+ok 168 - 同学年已确认预约可复用,其他学员和跨学年证据不会串用
+  ---
+  duration_ms: 0.34208
+  type: 'test'
+  ...
+# Subtest: 档案、姓名或既往确认冲突时保留人工核对,日期无效不自动确认
+ok 169 - 档案、姓名或既往确认冲突时保留人工核对,日期无效不自动确认
+  ---
+  duration_ms: 0.316452
+  type: 'test'
+  ...
+1..169
+# tests 169
+# suites 0
+# pass 169
+# fail 0
+# cancelled 0
+# skipped 0
+# todo 0
+# duration_ms 29134.461836
+
+> xiaoshu-angular@0.0.0 build
+> npm run build:mobile && npm run build:admin
+
+
+> xiaoshu-angular@0.0.0 build:mobile
+> ng build xiaoshu-mobile
+
+❯ Building...
+✔ Building...
+Initial chunk files   | Names                        |  Raw size | Estimated transfer size
+chunk-2ZSVCUAU.js     | -                            | 198.40 kB |                58.23 kB
+chunk-ZV7K7M5E.js     | -                            |  85.73 kB |                21.77 kB
+styles-CGT3BABS.css   | styles                       |  38.91 kB |                 6.44 kB
+polyfills-7OVLM5OY.js | polyfills                    |  34.59 kB |                11.33 kB
+main-ZBR2PXWW.js      | main                         |  33.04 kB |                 8.29 kB
+chunk-2UK3DCGA.js     | -                            |  25.08 kB |                 7.36 kB
+chunk-R2QGZ6OG.js     | -                            |   2.81 kB |                 1.01 kB
+chunk-HHHS5ZAZ.js     | -                            |   1.14 kB |               521 bytes
+chunk-J53ZV4QB.js     | -                            |   1.12 kB |               467 bytes
+chunk-FGAMUP6Z.js     | -                            |   1.04 kB |               502 bytes
+chunk-RNWVFPC3.js     | -                            | 737 bytes |               737 bytes
+chunk-KVPGUAQR.js     | -                            | 711 bytes |               711 bytes
+chunk-YDLMWXNK.js     | -                            | 212 bytes |               212 bytes
+
+                      | Initial total                | 423.52 kB |               117.59 kB
+
+Lazy chunk files      | Names                        |  Raw size | Estimated transfer size
+chunk-6GTZBJLV.js     | html2pdf                     | 774.14 kB |               192.59 kB
+chunk-4EIJEUZE.js     | jspdf-es-min                 | 411.50 kB |               113.09 kB
+chunk-3SKU3FEY.js     | -                            | 202.84 kB |                38.53 kB
+chunk-4L5GYR5V.js     | index-es                     | 159.27 kB |                47.00 kB
+chunk-N6X636K4.js     | learning-page-component      | 145.55 kB |                28.64 kB
+chunk-6S5WXGTM.js     | study-records-page-component |  61.32 kB |                13.66 kB
+chunk-DC5VMCLK.js     | appointment-page-component   |  50.33 kB |                11.38 kB
+chunk-WVKYABLH.js     | course-center-page-component |  50.26 kB |                11.88 kB
+chunk-ZSGOH755.js     | home-page-component          |  41.68 kB |                 8.76 kB
+chunk-OJFENDRK.js     | -                            |  38.24 kB |                 7.85 kB
+chunk-MS4UBJCK.js     | reading-exercise-component   |  35.95 kB |                 8.68 kB
+chunk-OAD5DQIB.js     | account-page-component       |  31.50 kB |                 7.08 kB
+chunk-3JW42YMA.js     | purify-es                    |  30.75 kB |                10.63 kB
+chunk-YZF32PBD.js     | -                            |  29.13 kB |                 8.99 kB
+chunk-JWU4G3VQ.js     | form-page-component          |  28.85 kB |                 8.22 kB
+...and 30 more lazy chunks files. Use "--verbose" to show all the files.
+
+Application bundle generation complete. [8.863 seconds] - 2026-10-13T05:22:32.919Z
+
+▲ [WARNING] projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 1.38 kB with a total of 9.38 kB.
+
+
+▲ [WARNING] projects/xiaoshu-mobile/src/app/features/home/account-page.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 1.40 kB with a total of 9.40 kB.
+
+
+▲ [WARNING] projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 2.12 kB with a total of 10.12 kB.
+
+
+▲ [WARNING] projects/xiaoshu-mobile/src/app/features/home/home-page.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 7.01 kB with a total of 15.01 kB.
+
+
+▲ [WARNING] projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 4.75 kB with a total of 12.75 kB.
+
+
+▲ [WARNING] projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 12.41 kB with a total of 20.41 kB.
+
+
+▲ [WARNING] projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 173 bytes with a total of 8.17 kB.
+
+
+▲ [WARNING] Module 'core-js/modules/es.promise.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/es.string.match.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/es.string.replace.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/es.string.starts-with.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/es.array.iterator.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/web.dom-collections.iterator.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/es.array.reduce.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/es.string.ends-with.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/es.string.split.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'raf' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/es.string.trim.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'rgbcolor' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/es.array.index-of.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/es.string.includes.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/es.array.reverse.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'core-js/modules/es.regexp.to-string.js' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/canvg/lib/index.es.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'html2canvas' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/jspdf/dist/jspdf.es.min.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'html2pdf.js' used by 'projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.ts' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+Output location: /private/var/folders/yx/cq4gbk4x6w702x_slllw80mw0000gn/T/xiaoshu-reviewed-tree-udeznx0q/dist/xiaoshu-mobile
+
+
+> xiaoshu-angular@0.0.0 build:admin
+> ng build xiaoshu-admin
+
+❯ Building...
+✔ Building...
+Initial chunk files   | Names                                 |  Raw size | Estimated transfer size
+chunk-6T7FPI5L.js     | -                                     | 177.94 kB |                52.83 kB
+chunk-4D6BBTXO.js     | -                                     |  86.18 kB |                21.88 kB
+polyfills-7OVLM5OY.js | polyfills                             |  34.59 kB |                11.33 kB
+styles-HW5G5S7R.css   | styles                                |  32.00 kB |                 5.59 kB
+chunk-Y2BTMIHR.js     | -                                     |  17.65 kB |                 5.25 kB
+main-3POXYJM7.js      | main                                  |  10.96 kB |                 3.08 kB
+chunk-ID25IB5X.js     | -                                     |   7.57 kB |                 2.14 kB
+chunk-K37DMEXX.js     | -                                     |   6.44 kB |                 2.25 kB
+chunk-GJLAZD5V.js     | -                                     |   4.39 kB |                 1.24 kB
+chunk-B3GFRI7C.js     | -                                     | 985 bytes |               985 bytes
+chunk-CDPKEPSI.js     | -                                     | 763 bytes |               763 bytes
+
+                      | Initial total                         | 379.47 kB |               107.35 kB
+
+Lazy chunk files      | Names                                 |  Raw size | Estimated transfer size
+chunk-US7ZSS72.js     | html2pdf                              | 981.80 kB |               231.32 kB
+chunk-XDXQD6GQ.js     | xlsx                                  | 502.57 kB |               135.71 kB
+chunk-SBDRUJSS.js     | admin-migration-component             | 252.22 kB |                30.29 kB
+chunk-6S6Z3USB.js     | admin-resource-component              | 195.00 kB |                29.63 kB
+chunk-3GSTDVGB.js     | operations-payroll-component          | 127.49 kB |                24.09 kB
+chunk-6QKDGXMJ.js     | operations-schedule-component         | 103.83 kB |                20.93 kB
+chunk-OIUM7H3H.js     | operations-user-detail-component      |  58.10 kB |                12.52 kB
+chunk-TN426TZ7.js     | operations-learning-reports-component |  57.96 kB |                12.91 kB
+chunk-EWIDKZYQ.js     | operations-coach-detail-component     |  53.41 kB |                10.89 kB
+chunk-XERAW27J.js     | operations-users-component            |  51.96 kB |                11.43 kB
+chunk-URFMRMK6.js     | operations-vocabulary-component       |  51.28 kB |                11.71 kB
+chunk-UQFJRL3R.js     | operations-store-detail-component     |  42.76 kB |                 9.17 kB
+chunk-NY4IDQYT.js     | -                                     |  40.22 kB |                 8.27 kB
+chunk-FM7PO5FC.js     | operations-special-training-component |  39.01 kB |                 9.22 kB
+chunk-OO7GCEQW.js     | operations-coaches-component          |  34.97 kB |                 8.24 kB
+...and 23 more lazy chunks files. Use "--verbose" to show all the files.
+
+Application bundle generation complete. [10.174 seconds] - 2026-10-13T05:22:44.136Z
+
+▲ [WARNING] projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 118 bytes with a total of 8.12 kB.
+
+
+▲ [WARNING] projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 1.66 kB with a total of 9.66 kB.
+
+
+▲ [WARNING] projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 1.31 kB with a total of 9.31 kB.
+
+
+▲ [WARNING] projects/xiaoshu-admin/src/app/pages/admin-resource.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 137 bytes with a total of 8.14 kB.
+
+
+▲ [WARNING] projects/xiaoshu-admin/src/app/pages/operations-payroll.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 4.33 kB with a total of 12.32 kB.
+
+
+▲ [WARNING] projects/xiaoshu-admin/src/app/pages/operations-users.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 2.97 kB with a total of 10.97 kB.
+
+
+▲ [WARNING] projects/xiaoshu-admin/src/app/pages/operations-schedule.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 12.46 kB with a total of 20.46 kB.
+
+
+▲ [WARNING] projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.scss exceeded maximum budget. Budget 8.00 kB was not met by 523 bytes with a total of 8.52 kB.
+
+
+▲ [WARNING] Module 'html2canvas' used by '../../../../../../../Users/mac/Desktop/编程项目/xiaoshu-angular/node_modules/html2pdf.js/dist/html2pdf.js' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+▲ [WARNING] Module 'html2pdf.js' used by 'projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.ts' is not ESM
+
+  CommonJS or AMD dependencies can cause optimization bailouts.
+  For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
+
+
+Output location: /private/var/folders/yx/cq4gbk4x6w702x_slllw80mw0000gn/T/xiaoshu-reviewed-tree-udeznx0q/dist/xiaoshu-admin
+
+
+> xiaoshu-angular@0.0.0 cloud:validate
+> node scripts/deploy-admin-functions.mjs --validate
+
+Validated 11 cloud function definitions.
+
+> xiaoshu-angular@0.0.0 app:actions:audit:local
+> node scripts/audit-app-cloud-actions.mjs --local
+
+{
+  "source": "local-code",
+  "passed": true,
+  "requested": 118,
+  "implemented": 104,
+  "blocked": 21,
+  "missing": []
+}

+ 3 - 0
karma.conf.cjs

@@ -0,0 +1,3 @@
+module.exports = function(config) {
+  config.set({basePath:'',frameworks:['jasmine','@angular-devkit/build-angular'],plugins:[require('karma-jasmine'),require('karma-chrome-launcher'),require('karma-jasmine-html-reporter'),require('karma-coverage'),require('@angular-devkit/build-angular/plugins/karma')],client:{clearContext:false},reporters:['progress'],hostname:'127.0.0.1',listenAddress:'127.0.0.1',port:9876,colors:true,autoWatch:true,browsers:['ChromeHeadless'],singleRun:false,restartOnFileChange:true});
+};

Plik diff jest za duży
+ 469 - 413
package-lock.json


+ 29 - 20
package.json

@@ -34,8 +34,8 @@
     "sync-bridge:package": "npm run sync-bridge:publish && bash legacy-sync-bridge/deployment/package.sh",
     "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",
     "sync-bridge:smoke": "dotnet build legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj -c Release --no-restore && node scripts/smoke-legacy-sync-bridge.mjs",
     "sync-worker:nightly": "node scripts/run-nightly-direct-sync.mjs",
     "sync-worker:nightly": "node scripts/run-nightly-direct-sync.mjs",
-    "sync-worker:once": "node scripts/run-legacy-sync-worker.mjs --once",
-    "sync-worker:start": "node scripts/run-legacy-sync-worker.mjs",
+    "sync-worker:once": "node scripts/run-nightly-direct-sync.mjs",
+    "sync-worker:start": "node scripts/run-nightly-direct-sync.mjs",
     "test:sync-worker": "node --test scripts/tests/legacy-sync-worker.test.mjs",
     "test:sync-worker": "node --test scripts/tests/legacy-sync-worker.test.mjs",
     "watch": "npm run watch:mobile",
     "watch": "npm run watch:mobile",
     "watch:mobile": "ng build xiaoshu-mobile --watch --configuration development",
     "watch:mobile": "ng build xiaoshu-mobile --watch --configuration development",
@@ -60,31 +60,34 @@
     "lessons:migrate": "node scripts/migrate-lesson-workflow.mjs",
     "lessons:migrate": "node scripts/migrate-lesson-workflow.mjs",
     "lessons:smoke": "node scripts/smoke-mobile-teaching-flow.mjs",
     "lessons:smoke": "node scripts/smoke-mobile-teaching-flow.mjs",
     "sync-worker:install-mac": "node scripts/install-direct-sync-launchagent.mjs",
     "sync-worker:install-mac": "node scripts/install-direct-sync-launchagent.mjs",
-    "test:direct-sync": "node --test scripts/tests/direct-nightly-sync.test.mjs scripts/tests/review-rewards.test.mjs"
+    "test:direct-sync": "node --test scripts/tests/direct-nightly-sync.test.mjs scripts/tests/review-rewards.test.mjs",
+    "test:server": "node --test --test-concurrency=2 scripts/tests/*.test.mjs",
+    "app:actions:audit:local": "node scripts/audit-app-cloud-actions.mjs --local",
+    "verify:local": "npm run test:ci && npm run test:server && npm run build && npm run cloud:validate && npm run app:actions:audit:local"
   },
   },
   "private": true,
   "private": true,
   "dependencies": {
   "dependencies": {
-    "@angular/animations": "^18.2.0",
-    "@angular/common": "^18.2.0",
-    "@angular/compiler": "^18.2.0",
-    "@angular/core": "^18.2.0",
-    "@angular/forms": "^18.2.0",
-    "@angular/platform-browser": "^18.2.0",
-    "@angular/platform-browser-dynamic": "^18.2.0",
-    "@angular/router": "^18.2.0",
-    "html2pdf.js": "^0.10.3",
-    "jspdf": "^3.0.4",
-    "lucide-angular": "^0.468.0",
+    "@angular/animations": "20.3.33",
+    "@angular/common": "20.3.33",
+    "@angular/compiler": "20.3.33",
+    "@angular/core": "20.3.33",
+    "@angular/forms": "20.3.33",
+    "@angular/platform-browser": "20.3.33",
+    "@angular/platform-browser-dynamic": "20.3.33",
+    "@angular/router": "20.3.33",
+    "html2pdf.js": "0.14.0",
+    "jspdf": "4.2.1",
+    "lucide-angular": "1.0.0",
     "mustache": "^4.2.0",
     "mustache": "^4.2.0",
     "rxjs": "~7.8.0",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0",
     "tslib": "^2.3.0",
-    "xlsx": "^0.18.5",
-    "zone.js": "~0.14.10"
+    "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
+    "zone.js": "~0.15.1"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@angular-devkit/build-angular": "^18.2.21",
-    "@angular/cli": "^18.2.21",
-    "@angular/compiler-cli": "^18.2.0",
+    "@angular-devkit/build-angular": "20.3.39",
+    "@angular/cli": "20.3.39",
+    "@angular/compiler-cli": "20.3.33",
     "@electric-sql/pglite": "^0.5.8",
     "@electric-sql/pglite": "^0.5.8",
     "@types/jasmine": "~5.1.0",
     "@types/jasmine": "~5.1.0",
     "@types/mustache": "^4.2.6",
     "@types/mustache": "^4.2.6",
@@ -94,6 +97,12 @@
     "karma-coverage": "~2.2.0",
     "karma-coverage": "~2.2.0",
     "karma-jasmine": "~5.1.0",
     "karma-jasmine": "~5.1.0",
     "karma-jasmine-html-reporter": "~2.1.0",
     "karma-jasmine-html-reporter": "~2.1.0",
-    "typescript": "~5.5.2"
+    "typescript": "~5.9.3"
+  },
+  "overrides": {
+    "webpack-dev-middleware": "7.4.5",
+    "sockjs": {
+      "uuid": "^11.1.1"
+    }
   }
   }
 }
 }

+ 27 - 0
projects/shared/diagnostics.ts

@@ -0,0 +1,27 @@
+import { ErrorHandler, Injectable } from '@angular/core';
+import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
+import { catchError, throwError } from 'rxjs';
+export const DIAGNOSTIC_VERSION = '2026-10-13-local-review-v1';
+interface Diagnostic {time:string;version:string;kind:'http'|'runtime';requestId:string;status:number;}
+const key='xiaoshu.safe-diagnostics.v1';
+export function recordDiagnostic(kind:Diagnostic['kind'],requestId='',status=0):void {
+  try {const previous=JSON.parse(sessionStorage.getItem(key)||'[]');const rows=Array.isArray(previous)?previous.slice(-49):[];
+    rows.push({time:new Date().toISOString(),version:DIAGNOSTIC_VERSION,kind,requestId:/^[a-z0-9-]{1,64}$/i.test(requestId)?requestId:'',status:Number(status)||0});sessionStorage.setItem(key,JSON.stringify(rows));
+  }catch{/* Diagnostics must not break the original interaction. */}
+}
+export function downloadDiagnostics():void {
+ const blob=new Blob([sessionStorage.getItem(key)||'[]'],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='xiaoshu-diagnostics.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
+}
+@Injectable()
+export class SafeErrorHandler implements ErrorHandler {
+ handleError(_error:unknown):void {recordDiagnostic('runtime');if(document.getElementById('runtime-notice'))return;
+ const box=document.createElement('aside');box.id='runtime-notice';box.setAttribute('role','alert');box.style.cssText='position:fixed;bottom:16px;left:16px;right:16px;z-index:99999;background:#fff4dc;color:#573b00;padding:16px;border:1px solid #d9b45b;border-radius:12px';
+ const text=document.createElement('span');text.textContent='页面遇到问题,请刷新后重试。';const button=document.createElement('button');button.textContent='下载诊断';button.onclick=downloadDiagnostics;const close=document.createElement('button');close.textContent='关闭提示';close.onclick=()=>box.remove();box.append(text,button,close);document.body.append(box);
+ }
+}
+export const diagnosticInterceptor:HttpInterceptorFn=(request,next)=>{
+ const id=crypto.randomUUID(),body=request.body;
+ // Carry only the correlation ID to our gateways; do not add CORS headers or log request bodies.
+ const correlated=body&&typeof body==='object'&&'params' in body&&body.params&&typeof body.params==='object'?request.clone({body:{...body,params:{...body.params,requestId:id}}}):request;
+ return next(correlated).pipe(catchError(error=>{recordDiagnostic('http',id,error instanceof HttpErrorResponse?error.status:0);return throwError(()=>error);}));
+};

+ 3 - 1
projects/xiaoshu-admin/src/app/admin-session.service.ts

@@ -38,10 +38,11 @@ export class AdminSessionService {
       `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`,
       `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`,
       { params: { action: 'user_login_passwd', name: username.trim(), passwd: password } },
       { params: { action: 'user_login_passwd', name: username.trim(), passwd: password } },
       { headers },
       { headers },
-    ));
+    ).pipe(timeout(20000)));
     if (envelope.retcode === -1 || !envelope.result) throw new Error(envelope.retmsg || '登录失败');
     if (envelope.retcode === -1 || !envelope.result) throw new Error(envelope.retmsg || '登录失败');
     const result = envelope.result;
     const result = envelope.result;
     const session: AdminSession = {
     const session: AdminSession = {
+      passwordResetRequired:result['passwordResetRequired']===true,
       objectId: String(result['objectId'] ?? ''),
       objectId: String(result['objectId'] ?? ''),
       username: String(result['userName'] ?? result['username'] ?? username),
       username: String(result['userName'] ?? result['username'] ?? username),
       sessionToken: String(result['sessionToken'] ?? ''),
       sessionToken: String(result['sessionToken'] ?? ''),
@@ -63,6 +64,7 @@ export class AdminSessionService {
       ...current,
       ...current,
       displayName: identity.displayName,
       displayName: identity.displayName,
       roles: identity.roles,
       roles: identity.roles,
+      passwordResetRequired: identity.passwordResetRequired ?? current.passwordResetRequired,
       isSuperAdmin: identity.isSuperAdmin,
       isSuperAdmin: identity.isSuperAdmin,
       operationsRole: identity.operationsRole,
       operationsRole: identity.operationsRole,
       company: identity.company ?? current.company,
       company: identity.company ?? current.company,

+ 1 - 1
projects/xiaoshu-admin/src/app/admin.constants.ts

@@ -31,7 +31,7 @@ export const OPERATIONS_NAVIGATION: readonly AdminNavigationGroup[] = [
     { label: '词库管理', icon: 'languages', route: '/admin/vocabulary' },
     { label: '词库管理', icon: 'languages', route: '/admin/vocabulary' },
     { label: '专项训练', icon: 'book-open', route: '/admin/special-training' },
     { label: '专项训练', icon: 'book-open', route: '/admin/special-training' },
   ] },
   ] },
-  { label: '账号与安全', items: [{ label: '运营账号管理', icon: 'shield-check', route: '/admin/operator-accounts' }] },
+  { label: '账号与安全', items: [{label:'注销申请',icon:'shield-check',route:'/admin/privacy-requests'}, { label: '运营账号管理', icon: 'shield-check', route: '/admin/operator-accounts' }] },
   { label: '个人设置', items: [{ label: '修改密码', icon: 'key-round', route: '/admin/change-password' }] },
   { label: '个人设置', items: [{ label: '修改密码', icon: 'key-round', route: '/admin/change-password' }] },
 ];
 ];
 
 

+ 1 - 0
projects/xiaoshu-admin/src/app/admin.guard.ts

@@ -6,6 +6,7 @@ import { CloudFunctionsService } from './cloud-functions.service';
 
 
 export const adminGuard: CanActivateFn = (_route, state) => {
 export const adminGuard: CanActivateFn = (_route, state) => {
   const sessions = inject(AdminSessionService);
   const sessions = inject(AdminSessionService);
+  if(sessions.session()?.passwordResetRequired&&state.url!=='/admin/change-password')return inject(Router).createUrlTree(['/admin/change-password']);
   if (sessions.isAuthenticated()) return true;
   if (sessions.isAuthenticated()) return true;
   return inject(Router).createUrlTree(['/admin/login'], { queryParams: { returnUrl: state.url } });
   return inject(Router).createUrlTree(['/admin/login'], { queryParams: { returnUrl: state.url } });
 };
 };

+ 2 - 0
projects/xiaoshu-admin/src/app/admin.models.ts

@@ -5,6 +5,7 @@ export interface ParsePointer {
 }
 }
 
 
 export interface AdminIdentity {
 export interface AdminIdentity {
+  passwordResetRequired?: boolean;
   objectId: string;
   objectId: string;
   username: string;
   username: string;
   displayName: string;
   displayName: string;
@@ -15,6 +16,7 @@ export interface AdminIdentity {
 }
 }
 
 
 export interface AdminSession {
 export interface AdminSession {
+  passwordResetRequired?: boolean;
   objectId: string;
   objectId: string;
   username: string;
   username: string;
   sessionToken: string;
   sessionToken: string;

+ 2 - 0
projects/xiaoshu-admin/src/app/admin.routes.ts

@@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
 import { adminGuard, operatorManagerGuard, superAdminGuard } from './admin.guard';
 import { adminGuard, operatorManagerGuard, superAdminGuard } from './admin.guard';
 
 
 const operationsChildren: Routes = [
 const operationsChildren: Routes = [
+  {path:'privacy-requests',data:{title:'注销申请',parentUrl:'/admin/dashboard'},canActivate:[operatorManagerGuard],loadComponent:()=>import('./pages/privacy-requests.component').then(m=>m.PrivacyRequestsComponent)},
   { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
   { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
   { path: 'dashboard', data: { title: '工作台', parentUrl: null, preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent) },
   { path: 'dashboard', data: { title: '工作台', parentUrl: null, preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent) },
   { path: 'users', pathMatch: 'full', redirectTo: 'members' },
   { path: 'users', pathMatch: 'full', redirectTo: 'members' },
@@ -40,6 +41,7 @@ export const adminRoutes: Routes = [
   {
   {
     path: 'admin',
     path: 'admin',
     canActivate: [adminGuard],
     canActivate: [adminGuard],
+    canActivateChild: [adminGuard],
     loadComponent: () => import('./layout/admin-shell.component').then((module) => module.AdminShellComponent),
     loadComponent: () => import('./layout/admin-shell.component').then((module) => module.AdminShellComponent),
     data: { mode: 'operations' },
     data: { mode: 'operations' },
     children: operationsChildren,
     children: operationsChildren,

+ 4 - 2
projects/xiaoshu-admin/src/app/app.config.ts

@@ -1,5 +1,6 @@
+import { SafeErrorHandler, diagnosticInterceptor } from '../../../shared/diagnostics';
 import { provideHttpClient, withInterceptors } from '@angular/common/http';
 import { provideHttpClient, withInterceptors } from '@angular/common/http';
-import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
+import { ApplicationConfig, ErrorHandler, provideZoneChangeDetection } from '@angular/core';
 import { provideRouter, RouteReuseStrategy, withPreloading } from '@angular/router';
 import { provideRouter, RouteReuseStrategy, withPreloading } from '@angular/router';
 import { AdminMenuPreloadingStrategy } from './admin-menu-preloading.strategy';
 import { AdminMenuPreloadingStrategy } from './admin-menu-preloading.strategy';
 import { OperationsRouteReuseStrategy } from './operations-route-reuse.strategy';
 import { OperationsRouteReuseStrategy } from './operations-route-reuse.strategy';
@@ -8,8 +9,9 @@ import { routes } from './app.routes';
 
 
 export const appConfig: ApplicationConfig = {
 export const appConfig: ApplicationConfig = {
   providers: [
   providers: [
+    {provide:ErrorHandler,useClass:SafeErrorHandler},
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideZoneChangeDetection({ eventCoalescing: true }),
-    provideHttpClient(withInterceptors([adminRequestActivityInterceptor])),
+    provideHttpClient(withInterceptors([diagnosticInterceptor, adminRequestActivityInterceptor])),
     provideRouter(routes, withPreloading(AdminMenuPreloadingStrategy)),
     provideRouter(routes, withPreloading(AdminMenuPreloadingStrategy)),
     { provide: RouteReuseStrategy, useClass: OperationsRouteReuseStrategy },
     { provide: RouteReuseStrategy, useClass: OperationsRouteReuseStrategy },
   ],
   ],

+ 6 - 1
projects/xiaoshu-admin/src/app/cloud-functions.service.spec.ts

@@ -1,6 +1,6 @@
 import { provideHttpClient } from '@angular/common/http';
 import { provideHttpClient } from '@angular/common/http';
 import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
 import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
-import { TestBed } from '@angular/core/testing';
+import { TestBed, fakeAsync, tick } from '@angular/core/testing';
 import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
 import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
 import { AdminSessionService } from './admin-session.service';
 import { AdminSessionService } from './admin-session.service';
 import { AppMigrationStatus } from './admin.models';
 import { AppMigrationStatus } from './admin.models';
@@ -64,4 +64,9 @@ describe('CloudFunctionsService', () => {
     await assertion;expect(TestBed.inject(AdminSessionService).invalidateSession).toHaveBeenCalledWith('r:admin-session');
     await assertion;expect(TestBed.inject(AdminSessionService).invalidateSession).toHaveBeenCalledWith('r:admin-session');
   });
   });
 
 
+  it('releases a hanging write after 20 seconds without retrying or logging out',fakeAsync(()=>{
+    let failure:unknown;service.operations('ops/payroll/create',{idempotencyKey:'keep-this-key'}).catch(e=>failure=e);
+    const req=http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);tick(20001);expect(req.cancelled).toBeTrue();expect(String(failure)).toContain('结果待核对');http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);expect(TestBed.inject(AdminSessionService).logout).not.toHaveBeenCalled();
+  }));
+
 });
 });

+ 4 - 4
projects/xiaoshu-admin/src/app/cloud-functions.service.ts

@@ -1,6 +1,6 @@
 import { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { inject, Injectable } from '@angular/core';
 import { inject, Injectable } from '@angular/core';
-import { firstValueFrom, Subject } from 'rxjs';
+import { firstValueFrom, Subject, timeout, throwError } from 'rxjs';
 import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
 import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
 import { AdminSessionService } from './admin-session.service';
 import { AdminSessionService } from './admin-session.service';
 import { CloudEnvelope, LegacyCloudEnvelope } from './admin.models';
 import { CloudEnvelope, LegacyCloudEnvelope } from './admin.models';
@@ -25,7 +25,7 @@ export class CloudFunctionsService {
       const envelope = await firstValueFrom(this.http.post<CloudEnvelope<T>>(
       const envelope = await firstValueFrom(this.http.post<CloudEnvelope<T>>(
         `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`,
         `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`,
         { token, params: { operation, ...params } },
         { token, params: { operation, ...params } },
-      ));
+      ).pipe(timeout({first:20000,with:()=>throwError(()=>new CloudFunctionError('请求暂未完成,结果待核对;请刷新后重试,已填写内容请保留。'))})));
       if (!envelope.success) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.message || envelope.error || '云函数执行失败'); }
       if (!envelope.success) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.message || envelope.error || '云函数执行失败'); }
       if (this.isMutation(operation)) this.announceDataChange();
       if (this.isMutation(operation)) this.announceDataChange();
       return envelope.data;
       return envelope.data;
@@ -72,7 +72,7 @@ export class CloudFunctionsService {
       const envelope = await firstValueFrom(this.http.post<CloudEnvelope<T>>(
       const envelope = await firstValueFrom(this.http.post<CloudEnvelope<T>>(
         `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`,
         `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`,
         { token, params: { operation, ...params } },
         { token, params: { operation, ...params } },
-      ));
+      ).pipe(timeout({first:20000,with:()=>throwError(()=>new CloudFunctionError('请求暂未完成,结果待核对;请刷新后重试,已填写内容请保留。'))})));
       if (!envelope.success) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.message || envelope.error || '运营云函数执行失败'); }
       if (!envelope.success) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.message || envelope.error || '运营云函数执行失败'); }
       if (this.isMutation(operation)) this.announceDataChange();
       if (this.isMutation(operation)) this.announceDataChange();
       return envelope.data;
       return envelope.data;
@@ -94,7 +94,7 @@ export class CloudFunctionsService {
       const envelope = await firstValueFrom(this.http.post<LegacyCloudEnvelope<T>>(
       const envelope = await firstValueFrom(this.http.post<LegacyCloudEnvelope<T>>(
         `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`,
         `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`,
         { token, params: { action, ...params } },
         { token, params: { action, ...params } },
-      ));
+      ).pipe(timeout({first:20000,with:()=>throwError(()=>new CloudFunctionError('请求暂未完成,结果待核对;请刷新后重试,已填写内容请保留。'))})));
       if (envelope.retcode === -1) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.retmsg || '云函数执行失败'); }
       if (envelope.retcode === -1) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.retmsg || '云函数执行失败'); }
       return envelope.result;
       return envelope.result;
     } catch (error) {
     } catch (error) {

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

@@ -21,7 +21,7 @@
       </label>
       </label>
       <label>
       <label>
         <span>登录密码</span>
         <span>登录密码</span>
-        <div class="control"><lucide-icon [img]="icons.LockKeyhole" [size]="19" /><input name="password" [(ngModel)]="password" [type]="showPassword() ? 'text' : 'password'" autocomplete="current-password" placeholder="请输入密码" /><button type="button" (click)="showPassword.set(!showPassword())"><lucide-icon [img]="showPassword() ? icons.EyeOff : icons.Eye" [size]="18" /></button></div>
+        <div class="control"><lucide-icon [img]="icons.LockKeyhole" [size]="19" /><input name="password" [(ngModel)]="password" [type]="showPassword() ? 'text' : 'password'" autocomplete="current-password" placeholder="请输入密码" /><button aria-label="显示或隐藏密码" type="button" (click)="showPassword.set(!showPassword())"><lucide-icon [img]="showPassword() ? icons.EyeOff : icons.Eye" [size]="18" /></button></div>
       </label>
       </label>
 
 
       @if (notice()) { <p class="notice" role="status">{{ notice() }}</p> }
       @if (notice()) { <p class="notice" role="status">{{ notice() }}</p> }

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/admin-login.component.ts

@@ -43,7 +43,7 @@ export class AdminLoginComponent {
     let attemptToken = '';
     let attemptToken = '';
     try {
     try {
       attemptToken = (await this.sessions.login(this.username, this.password)).sessionToken;
       attemptToken = (await this.sessions.login(this.username, this.password)).sessionToken;
-      const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') || '/admin/dashboard';
+      const returnUrl = this.sessions.session()?.passwordResetRequired ? '/admin/change-password' : this.route.snapshot.queryParamMap.get('returnUrl') || '/admin/dashboard';
       if (returnUrl.split('?')[0] === '/admin/dashboard') {
       if (returnUrl.split('?')[0] === '/admin/dashboard') {
         void this.operations.dashboard().catch(() => undefined);
         void this.operations.dashboard().catch(() => undefined);
         void import('../layout/admin-shell.component');
         void import('../layout/admin-shell.component');

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

@@ -152,7 +152,7 @@
 @if (editorOpen()) {
 @if (editorOpen()) {
   <button class="drawer-mask" type="button" aria-label="关闭编辑器" (click)="closeEditor()"></button>
   <button class="drawer-mask" type="button" aria-label="关闭编辑器" (click)="closeEditor()"></button>
   <aside class="editor-drawer">
   <aside class="editor-drawer">
-    <header><div><span>{{ !schema()?.writable ? '查看记录' : draftId() ? '编辑记录' : '新建记录' }}</span><strong>{{ schema()?.label }}</strong></div><button type="button" (click)="closeEditor()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
+    <header><div><span>{{ !schema()?.writable ? '查看记录' : draftId() ? '编辑记录' : '新建记录' }}</span><strong>{{ schema()?.label }}</strong></div><button aria-label="关闭编辑" type="button" (click)="closeEditor()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
     <div class="editor-fields">
     <div class="editor-fields">
       @if (className() === '_User' && !draftId()) {
       @if (className() === '_User' && !draftId()) {
         <section class="account-create">
         <section class="account-create">

+ 2 - 0
projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.spec.ts

@@ -36,7 +36,9 @@ describe('OperationsCoachDetailComponent month filters', () => {
   let fixture: ComponentFixture<OperationsCoachDetailComponent>;
   let fixture: ComponentFixture<OperationsCoachDetailComponent>;
   let operations: jasmine.SpyObj<OperationsService>;
   let operations: jasmine.SpyObj<OperationsService>;
 
 
+  afterEach(() => jasmine.clock().uninstall());
   beforeEach(async () => {
   beforeEach(async () => {
+    jasmine.clock().install(); jasmine.clock().mockDate(new Date('2026-09-15T00:00:00Z'));
     operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['coachDetail', 'mutate']);
     operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['coachDetail', 'mutate']);
     operations.coachDetail.and.callFake((_objectId, month) => Promise.resolve(detailFor(month || '2026-09')));
     operations.coachDetail.and.callFake((_objectId, month) => Promise.resolve(detailFor(month || '2026-09')));
 
 

+ 2 - 2
projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.ts

@@ -10,11 +10,11 @@ import { OperationsLiveRefreshService } from '../operations-live-refresh.service
 import { ScheduleStatusTone, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
 import { ScheduleStatusTone, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
 
 
 type MonthSection = 'schedule' | 'lessons' | 'payroll';
 type MonthSection = 'schedule' | 'lessons' | 'payroll';
-const CURRENT_MONTH = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }).slice(0, 7);
+const currentMonth = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }).slice(0, 7);
 
 
 @Component({selector:'app-operations-coach-detail',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coach-detail.component.html',styleUrls:['./operations-professional.scss','./operations-coach-detail.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 @Component({selector:'app-operations-coach-detail',standalone:true,imports: [DropdownDirective, 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,OnDestroy{
 export class OperationsCoachDetailComponent implements OnInit,OnDestroy{
- 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 currentMonth=CURRENT_MONTH;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 activeMonthPicker=signal<MonthSection|null>(null);readonly monthPickerYear=signal(new Date().getFullYear());readonly monthNames=['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];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,ChevronDown,ChevronLeft,ChevronRight,Clock3,GraduationCap,Phone,Plus,RefreshCw,Star,Trash2,UserRound,WalletCards};private readonly operations=inject(OperationsService);private readonly liveRefresh=inject(OperationsLiveRefreshService);private readonly objectId=inject(ActivatedRoute).snapshot.paramMap.get('objectId')||'';private stopLiveRefresh?:()=>void;private loadRequest=0;
+ 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 currentMonth=currentMonth();readonly summaryMonth=signal(currentMonth());readonly scheduleMonth=signal(currentMonth());readonly lessonsMonth=signal(currentMonth());readonly payrollMonth=signal(currentMonth());readonly lessonsPayroll=signal<CoachDetailData['currentPayroll']|null>(null);readonly activeMonthPicker=signal<MonthSection|null>(null);readonly monthPickerYear=signal(new Date().getFullYear());readonly monthNames=['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];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,ChevronDown,ChevronLeft,ChevronRight,Clock3,GraduationCap,Phone,Plus,RefreshCw,Star,Trash2,UserRound,WalletCards};private readonly operations=inject(OperationsService);private readonly liveRefresh=inject(OperationsLiveRefreshService);private readonly objectId=inject(ActivatedRoute).snapshot.paramMap.get('objectId')||'';private stopLiveRefresh?:()=>void;private loadRequest=0;
  ngOnInit():void{void this.load();this.stopLiveRefresh=this.liveRefresh.watch(['appointments','review','account'],()=>{if(this.loading()||this.sectionLoading()||this.saving())return false;void this.load(false,true);return true;});}
  ngOnInit():void{void this.load();this.stopLiveRefresh=this.liveRefresh.watch(['appointments','review','account'],()=>{if(this.loading()||this.sectionLoading()||this.saving())return false;void this.load(false,true);return true;});}
  ngOnDestroy():void{this.stopLiveRefresh?.();}
  ngOnDestroy():void{this.stopLiveRefresh?.();}
  async load(refresh=false,background=false):Promise<void>{const request=++this.loadRequest;if(!background)this.loading.set(true);this.error.set('');try{const detail=await this.operations.coachDetail(this.objectId,this.summaryMonth(),refresh);if(request!==this.loadRequest)return;const 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){if(request===this.loadRequest)this.error.set(error instanceof Error?error.message:'陪练详情加载失败');}finally{if(!background&&request===this.loadRequest)this.loading.set(false);}}
  async load(refresh=false,background=false):Promise<void>{const request=++this.loadRequest;if(!background)this.loading.set(true);this.error.set('');try{const detail=await this.operations.coachDetail(this.objectId,this.summaryMonth(),refresh);if(request!==this.loadRequest)return;const 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){if(request===this.loadRequest)this.error.set(error instanceof Error?error.message:'陪练详情加载失败');}finally{if(!background&&request===this.loadRequest)this.loading.set(false);}}

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-coaches.component.html

@@ -21,7 +21,7 @@
     @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>}
     @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>}
     @empty{<tr><td colspan="13"><div class="empty">没有符合条件的陪练老师</div></td></tr>}
   </tbody></table></div>
   </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>
+  <footer class="pagination"><span>第 {{page()?.page||1}} / {{pageCount()}} 页</span><div><button aria-label="上一页" 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>
 </section>
 </section>
 
 
 @if(drawerOpen()){
 @if(drawerOpen()){

Plik diff jest za duży
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html


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

@@ -1,3 +1,5 @@
+import { AccessibleDialogDirective } from '../shared/accessible-dialog.directive';
+import { PayrollCutoffComponent } from './payroll-cutoff.component';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { ActivatedRoute } from '@angular/router';
 import { ActivatedRoute } from '@angular/router';
 import { ReviewRewardsComponent } from './review-rewards.component';
 import { ReviewRewardsComponent } from './review-rewards.component';
@@ -21,7 +23,7 @@ export function teachingRateMatrix(rules:TeachingRule[],categories:TeachingCours
 
 
 interface WritingMappingRow {appointmentId:string;appointmentGeneralId:number;studentName:string;courseName:string;startsAt:string;grade:string;categoryKey:string;suggestedCategoryKey:string;evidence:string;updatedAt:string;assessmentReason?:string;canAutoConfirm?:boolean}
 interface WritingMappingRow {appointmentId:string;appointmentGeneralId:number;studentName:string;courseName:string;startsAt:string;grade:string;categoryKey:string;suggestedCategoryKey:string;evidence:string;updatedAt:string;assessmentReason?:string;canAutoConfirm?:boolean}
 
 
-@Component({selector:'app-operations-payroll',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,LucideAngularModule,ReviewRewardsComponent,OperationsDateFilterComponent],templateUrl:'./operations-payroll.component.html',styleUrls:['./operations-professional.scss','./operations-payroll.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+@Component({selector:'app-operations-payroll',standalone:true,imports: [AccessibleDialogDirective, PayrollCutoffComponent, DropdownDirective, CommonModule,FormsModule,LucideAngularModule,ReviewRewardsComponent,OperationsDateFilterComponent],templateUrl:'./operations-payroll.component.html',styleUrls:['./operations-professional.scss','./operations-payroll.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsPayrollComponent implements OnInit,OnDestroy{
 export class OperationsPayrollComponent implements OnInit,OnDestroy{
  readonly adjustmentCoaches=signal<Array<{coachId:number;coachName:string}>>([]);
  readonly adjustmentCoaches=signal<Array<{coachId:number;coachName:string}>>([]);
  readonly writingRows=signal<WritingMappingRow[]>([]);
  readonly writingRows=signal<WritingMappingRow[]>([]);

+ 7 - 7
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html

@@ -64,10 +64,10 @@
 </section>
 </section>
 
 
 <section class="summary-grid">
 <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>
-  <article class="summary-card"><span>在岗陪练</span><strong>{{data()?.coaches?.length||0}}</strong><small>未配置可用时间会提示</small></article>
-  <article class="summary-card"><span>历史排课总量</span><strong>{{data()?.totalAppointments||0}}</strong><small>{{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</small></article>
+  <article class="summary-card"><span>当前范围课程</span><strong>{{data()?activeAppointments():'—'}}</strong><small>{{range().from}} 至 {{range().to}}</small></article>
+  <article class="summary-card"><span>待分配</span><strong>{{data()?unassignedCount():'—'}}</strong><small>未分配老师时不能开始</small></article>
+  <article class="summary-card"><span>在岗陪练</span><strong>{{data()?.coaches?.length??'—'}}</strong><small>未配置可用时间会提示</small></article>
+  <article class="summary-card"><span>历史排课总量</span><strong>{{data()?.totalAppointments??'—'}}</strong><small>{{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</small></article>
   <article class="summary-card"><span>数据库更新</span><strong>{{data()?.refreshedAt|date:'MM-dd HH:mm:ss'}}</strong><small>新排课程、教学状态以新库为准</small></article>
   <article class="summary-card"><span>数据库更新</span><strong>{{data()?.refreshedAt|date:'MM-dd HH:mm:ss'}}</strong><small>新排课程、教学状态以新库为准</small></article>
 </section>
 </section>
 
 
@@ -89,13 +89,13 @@
   @if(view()==='list'){
   @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>
     <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 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"><button type="button" (click)="openDetails(item)">查看明细</button>@if(!item.readOnly){<button type="button" (click)="viewFeedback(item)">查看教学记录</button>}@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)="openDetails(item)">取消</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&&item.statusCode<11){<span class="read-only-label">按业务端状态流转</span>}}}</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"><button type="button" (click)="openDetails(item)">查看明细</button>@if(!item.readOnly){<button type="button" (click)="viewFeedback(item)">查看教学记录</button>}@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)="openDetails(item)">取消</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&&item.statusCode<11){<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>}
+      @empty{<tr><td colspan="7"><div class="empty">{{error()?'课表未能加载,请重试':hasScheduleFilters()?'没有符合筛选条件的排课':'当前范围暂无排课;历史数据范围为 '+(data()?.dataDateFrom||'—')+' 至 '+(data()?.dataDateTo||'—')}}</div></td></tr>}
     </tbody></table></div>
     </tbody></table></div>
   } @else {
   } @else {
     <div class="table-scroll"><div class="calendar">
     <div class="table-scroll"><div class="calendar">
       @for(coach of visibleCoaches();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&&!item.coachAccountMissing){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openDetails(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 visibleCoaches();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&&!item.coachAccountMissing){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openDetails(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 visibleAppointments();track item.targetId){@if(!item.coachId||item.coachAccountMissing){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openDetails(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>}
+      @empty{<div class="empty">{{error()?'课表未能加载,请重试':'当前范围暂无已排课的陪练'}}</div>}
+      @if(unassignedCount()){<div class="calendar-row"><div class="coach-label"><strong>待分配 / 历史账号</strong><span>{{data()?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)="openDetails(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>
     </div></div>
   }
   }
 </section>
 </section>

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


+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-special-training.component.ts

@@ -1,3 +1,4 @@
+import { AccessibleDialogDirective } from '../shared/accessible-dialog.directive';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
@@ -8,7 +9,7 @@ import { OperationsService } from '../operations.service';
 
 
 type SpecialDraft = Pick<SpecialTrainingItem,'targetId'|'title'|'synopsis'|'coverUrl'|'tags'|'contentHtml'|'videoUrl'|'author'|'source'|'orderId'|'status'|'updatedAt'>;
 type SpecialDraft = Pick<SpecialTrainingItem,'targetId'|'title'|'synopsis'|'coverUrl'|'tags'|'contentHtml'|'videoUrl'|'author'|'source'|'orderId'|'status'|'updatedAt'>;
 
 
-@Component({selector:'app-operations-special-training',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,LucideAngularModule],templateUrl:'./operations-special-training.component.html',styleUrls:['./operations-professional.scss','./operations-special-training.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+@Component({selector:'app-operations-special-training',standalone:true,imports: [AccessibleDialogDirective, DropdownDirective, 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{
 export class OperationsSpecialTrainingComponent implements OnInit{
   readonly icons={BookOpen,Eye,Image,LayoutGrid,List,Pencil,Plus,RefreshCw,RotateCcw,Search,Trash2,Upload,Video,X};
   readonly icons={BookOpen,Eye,Image,LayoutGrid,List,Pencil,Plus,RefreshCw,RotateCcw,Search,Trash2,Upload,Video,X};
   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 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('');

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-stores.component.html

@@ -39,7 +39,7 @@
       <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>
       <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="12"><div class="empty">没有符合条件的门店</div></td></tr>}
     </tr>}@empty{<tr><td colspan="12"><div class="empty">没有符合条件的门店</div></td></tr>}
   </tbody></table></div>
   </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>
+  <footer class="pagination"><span>第 {{page()?.page||1}} / {{pageCount()}} 页</span><div><button aria-label="上一页" 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>
 </section>
 </section>
 
 
 @if(drawerOpen()){
 @if(drawerOpen()){

+ 2 - 6
projects/xiaoshu-admin/src/app/pages/operations-users.component.ts

@@ -1,3 +1,4 @@
+import { readBoundedWorkbook } from '../shared/bounded-workbook';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
 import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
@@ -90,12 +91,7 @@ export class OperationsUsersComponent implements OnInit, OnDestroy {
     if (!/\.xlsx$/i.test(file.name)) { this.error.set('请选择 .xlsx 格式的会员导入文件'); return; }
     if (!/\.xlsx$/i.test(file.name)) { this.error.set('请选择 .xlsx 格式的会员导入文件'); return; }
     if (file.size > 5 * 1024 * 1024) { this.error.set('会员导入文件不能超过 5 MB'); return; }
     if (file.size > 5 * 1024 * 1024) { this.error.set('会员导入文件不能超过 5 MB'); return; }
     try {
     try {
-      const { read, utils } = await import('xlsx');
-      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 位会员,请拆分文件后重试');
+      const rows = await readBoundedWorkbook(file, 200);
       this.importFileName.set(file.name); this.importRows.set(rows); this.importIdempotencyKey.set(crypto.randomUUID());
       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 文件读取失败'); }
     } catch (error) { this.importFileName.set(''); this.importRows.set([]); this.error.set(error instanceof Error ? error.message : 'Excel 文件读取失败'); }
   }
   }

Plik diff jest za duży
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.html


+ 4 - 2
projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.ts

@@ -1,3 +1,5 @@
+import { AccessibleDialogDirective } from '../shared/accessible-dialog.directive';
+import { readBoundedWorkbook } from '../shared/bounded-workbook';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
@@ -11,7 +13,7 @@ type WordDraft = Pick<VocabularyWord, 'targetId'|'word'|'meaning'|'phonetic'|'ex
 
 
 @Component({
 @Component({
   selector: 'app-operations-vocabulary', standalone: true,
   selector: 'app-operations-vocabulary', standalone: true,
-  imports: [DropdownDirective, CommonModule, FormsModule, LucideAngularModule],
+  imports: [AccessibleDialogDirective, DropdownDirective, CommonModule, FormsModule, LucideAngularModule],
   templateUrl: './operations-vocabulary.component.html',
   templateUrl: './operations-vocabulary.component.html',
   styleUrls: ['./operations-professional.scss', './operations-vocabulary.component.scss'],
   styleUrls: ['./operations-professional.scss', './operations-vocabulary.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
   changeDetection: ChangeDetectionStrategy.OnPush,
@@ -137,7 +139,7 @@ export class OperationsVocabularyComponent implements OnInit {
   async saveNode():Promise<void>{const draft=this.nodeDraft();if(!draft.name.trim()||!this.reason().trim()){this.error.set('分类名称和操作原因必填');return;}this.saving.set(true);try{await this.operations.mutate('ops/vocabulary/node-save',draft.targetId,this.reason(),draft,draft.updatedAt);this.nodeOpen.set(false);await this.initialLoad();this.message.set('词库分类已保存');}catch(error){this.error.set(this.errorText(error,'分类保存失败'));}finally{this.saving.set(false);}}
   async saveNode():Promise<void>{const draft=this.nodeDraft();if(!draft.name.trim()||!this.reason().trim()){this.error.set('分类名称和操作原因必填');return;}this.saving.set(true);try{await this.operations.mutate('ops/vocabulary/node-save',draft.targetId,this.reason(),draft,draft.updatedAt);this.nodeOpen.set(false);await this.initialLoad();this.message.set('词库分类已保存');}catch(error){this.error.set(this.errorText(error,'分类保存失败'));}finally{this.saving.set(false);}}
   async chooseWorkbook(event: Event): Promise<void> {
   async chooseWorkbook(event: Event): Promise<void> {
     const file=(event.target as HTMLInputElement).files?.[0]; if(!file)return;
     const file=(event.target as HTMLInputElement).files?.[0]; if(!file)return;
-    try { const {read,utils}=await import('xlsx');const workbook=read(await file.arrayBuffer(),{type:'array',cellDates:true}); const sheet=workbook.Sheets[workbook.SheetNames[0]]; const rows=utils.sheet_to_json<Record<string,unknown>>(sheet,{defval:''}); if(!rows.length)throw new Error('Excel 没有可导入的数据'); this.importFileName.set(file.name);this.importRows.set(rows);this.preview.set(null);this.importOpen.set(true);this.reason.set('导入词库'); }
+    try { const rows=await readBoundedWorkbook(file,2000); this.importFileName.set(file.name);this.importRows.set(rows);this.preview.set(null);this.importOpen.set(true);this.reason.set('导入词库'); }
     catch(error){this.error.set(this.errorText(error,'Excel 读取失败'));} finally{(event.target as HTMLInputElement).value='';}
     catch(error){this.error.set(this.errorText(error,'Excel 读取失败'));} finally{(event.target as HTMLInputElement).value='';}
   }
   }
   async previewImport(): Promise<void> { if(!this.reason().trim()){this.error.set('请填写导入原因');return;}this.saving.set(true);try{this.preview.set(await this.operations.previewVocabularyImport(this.importFileName(),this.importRows(),this.reason()));}catch(error){this.error.set(this.errorText(error,'导入预检失败'));}finally{this.saving.set(false);} }
   async previewImport(): Promise<void> { if(!this.reason().trim()){this.error.set('请填写导入原因');return;}this.saving.set(true);try{this.preview.set(await this.operations.previewVocabularyImport(this.importFileName(),this.importRows(),this.reason()));}catch(error){this.error.set(this.errorText(error,'导入预检失败'));}finally{this.saving.set(false);} }

+ 25 - 0
projects/xiaoshu-admin/src/app/pages/payroll-cutoff.component.ts

@@ -0,0 +1,25 @@
+import { CommonModule } from '@angular/common';
+import { Component, input, output, signal, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { OperationsService } from '../operations.service';
+interface Difference {id:string;dataset:string;legacyKey:string;student:string;course:string;date:string;fields:string[];current:unknown;fingerprint:string;cosmetic:boolean;resolved:boolean;}
+interface Cutoff {month:string;version:string;runId:string;cutoffAt:string;complete:boolean;pending:number;conflicts:Difference[];approved:boolean;monthClosed:boolean;datasets:{model:number;source:number;matched:number}[];}
+@Component({selector:'app-payroll-cutoff',standalone:true,imports:[CommonModule,FormsModule],template:`
+<section class="cutoff"><button type="button" (click)="load()" [disabled]="busy()">{{busy()?'核验中…':'核验 '+month()+' 结算来源'}}</button>
+@if(error()){<p role="alert">{{error()}}</p>}
+@if(data();as d){<h3>{{d.month}} · 工资来源核验</h3><p>旧接口同步截止:{{d.cutoffAt|date:'yyyy-MM-dd HH:mm':'+0800'}}。{{d.monthClosed?'已覆盖整月':'当前仅可生成截止时间内的草稿,整月同步后才能审核'}}</p>
+<p>业务编号:{{d.complete?'对账一致':'凭据缺失或记录缺失,暂不能确认'}} · 待核对 {{d.pending}} 条</p>
+<p>姓名或录入人文字差异单独标为“不影响计薪”;其余差异逐条核实后才能确认保留新系统。</p>
+@for(source of d.datasets;track source.model){<span class="source">{{source.model===54?'预约':source.model===59?'完课':'复习'}} {{source.matched}} / {{source.source}}</span>}
+<label>核验依据<textarea [(ngModel)]="reason" rows="2" placeholder="说明核对依据,至少 2 个字"></textarea></label>
+<div class="differences">@for(row of d.conflicts;track row.id){<details><summary>{{row.student||row.legacyKey}} · {{row.course}} · {{row.cosmetic?'不影响计薪':row.resolved?'已确认':'待核对'}}</summary><p>{{sourceName(row.dataset)}}编号 {{row.legacyKey}} · {{row.date||'日期待核对'}}</p><p>以下为新系统当前记录,请与旧系统同编号记录核对后填写依据。</p><dl>@for(field of row.fields;track field){<dt>{{fieldName(field)}}</dt><dd>{{fieldValue(row,field)}}</dd>}@empty{<dt>差异说明</dt><dd>来源没有提供具体字段,请完整核对该记录。</dd>}</dl>@if(!row.resolved&&canConfirm()){<button type="button" [disabled]="busy()||reason.trim().length<2" (click)="confirm(row)">确认此记录保留新系统</button>}</details>}</div>
+@if(canConfirm()){<button type="button" [disabled]="busy()||!d.complete||d.pending>0||reason.trim().length<2" (click)="confirm()">确认该月份截止来源</button>}@if(d.approved){<p role="status">来源已确认;数据变化后确认将自动失效。</p>}
+}</section>`,styles:[`.cutoff{padding:20px;border:1px solid #d8e5e1;border-radius:16px;background:#fff;margin:16px 0;color:#28443a}button{min-height:44px;padding:8px 16px;border:1px solid #197664;background:#eff8f5;border-radius:8px;color:#155c4e;cursor:pointer}button:disabled{opacity:.5;cursor:default}label{display:grid;gap:8px;margin:12px 0}textarea{padding:10px;border:1px solid #b8ccc4;border-radius:8px}dl{display:grid;grid-template-columns:minmax(100px,1fr) 2fr;gap:8px;margin:12px 0}dt{color:#567168}dd{margin:0;overflow-wrap:anywhere}details{padding:10px 0;border-bottom:1px solid #edf2f0}.differences{max-height:480px;overflow:auto;margin:12px 0}.source{display:inline-block;margin-right:20px}`]})
+export class PayrollCutoffComponent {
+ readonly month=input.required<string>();readonly canConfirm=input(false);readonly changed=output<void>();readonly busy=signal(false);readonly error=signal('');readonly data=signal<Cutoff|null>(null);reason='';private readonly ops=inject(OperationsService);
+ sourceName(value:string):string{return ({'54':'预约','59':'完课','60':'复习'} as Record<string,string>)[value]||'来源记录';}
+ fieldName(value:string):string{return ({title:'学员姓名',inputer:'录入人',subtitle:'课程名称',teacherPaySnapshot:'课酬快照',dslx:'课时类型',dszt:'课程状态',yysj:'预约时间',szyh:'学员编号',pl:'陪练编号',fxpl:'复习老师编号',wcsj:'完成时间',kywrq:'复习日期',pldp:'陪练点评',plpjsj:'点评时间',yyds:'预约编号',lessonAt:'上课时间',durationMinutes:'上课分钟数',jffs:'交付方式'} as Record<string,string>)[value]||value;}
+ fieldValue(row:Difference,field:string):string{const value=field==='title'?row.student:field==='subtitle'?row.course:(row.current as Record<string,unknown>|null)?.[field];return value==null||value===''?'未记录':typeof value==='object'?JSON.stringify(value):String(value);}
+ async load():Promise<void>{this.busy.set(true);this.error.set('');try{this.data.set(await this.ops.request<Cutoff>('ops/payroll/cutoff',{month:this.month()}));}catch(e){this.error.set(e instanceof Error?e.message:'来源核验加载失败');}finally{this.busy.set(false);}}
+ async confirm(row?:Difference):Promise<void>{const d=this.data();if(!d||this.busy()||d.month!==this.month())return;this.busy.set(true);this.error.set('');try{this.data.set(await this.ops.mutate<Cutoff>('ops/payroll/cutoff-confirm','',this.reason,{month:d.month,version:d.version,conflictId:row?.id||'',fingerprint:row?.fingerprint||''}));this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'确认失败,请重新核验');}finally{this.busy.set(false);}}
+}

+ 12 - 0
projects/xiaoshu-admin/src/app/pages/privacy-requests.component.ts

@@ -0,0 +1,12 @@
+import { CommonModule } from '@angular/common';
+import { Component, inject, signal, OnInit } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { RouterLink } from '@angular/router';
+import { OperationsService } from '../operations.service';
+@Component({selector:'app-privacy-requests',standalone:true,imports:[CommonModule,FormsModule,RouterLink],template:`<header class="page-header"><div><h1>注销申请</h1><span>逐项核查账号、剩余权益和保留资料,再登记处理结果</span></div><button class="ui-button" (click)="load()" [disabled]="busy()">刷新</button></header>@if(error()){<p role="alert" class="notice error">{{error()}}</p>}<label>处理说明<input [(ngModel)]="reason" placeholder="至少 2 个字,用户可查看" maxlength="1000" /></label>@for(row of items();track row['id']){<section class="content-card"><header><strong>{{row['name']}} · ID {{row['legacyUserId']}}</strong><span>{{status(row['status'])}}</span></header><div class="panel"><p>{{row['reason']}}</p><p>{{row['response']||'尚未回复'}}</p><small>{{row['createdAt']|date:'yyyy-MM-dd HH:mm':'+0800'}}</small><p><a [routerLink]="['/admin/members',row['userId']]">查看会员并核对注销事项</a></p>@if(row['status']==='requested'||row['status']==='processing'){<button class="ui-button" [disabled]="busy()||reason.trim().length<2" (click)="process(row,'processing')">登记处理中</button> <button class="ui-button" [disabled]="busy()||reason.trim().length<2" (click)="process(row,'rejected')">说明未通过原因</button> <button class="ui-button primary" [disabled]="busy()||reason.trim().length<2" (click)="process(row,'completed')">账号注销后登记完成</button>}</div></section>}@empty{@if(!busy()&&!error()){<p>本页暂无申请</p>}}<footer><button class="ui-button" [disabled]="busy()||page===1" (click)="page=page-1;load()">上一页</button> 第 {{page}} 页 <button class="ui-button" [disabled]="busy()||items().length<50" (click)="page=page+1;load()">下一页</button></footer>`,styleUrls:['./operations-professional.scss']})
+export class PrivacyRequestsComponent implements OnInit{
+ private readonly ops=inject(OperationsService);readonly items=signal<Record<string,any>[]>([]);readonly busy=signal(false);readonly error=signal('');reason='';page=1;ngOnInit():void{void this.load();}
+ status(v:unknown):string{return ({requested:'待处理',processing:'处理中',rejected:'未通过',completed:'已完成'} as Record<string,string>)[String(v)]||'未知';}
+ async load():Promise<void>{this.busy.set(true);this.error.set('');try{this.items.set((await this.ops.request<{items:Record<string,any>[]}>('ops/privacy/list',{page:this.page})).items);}catch(e){this.error.set(e instanceof Error?e.message:'加载失败');}finally{this.busy.set(false);}}
+ async process(row:Record<string,any>,status:string):Promise<void>{if(this.busy())return;this.busy.set(true);this.error.set('');try{await this.ops.mutate('ops/privacy/process',row['id'],this.reason,{version:row['version'],status});await this.load();}catch(e){this.error.set(e instanceof Error?e.message:'处理失败');}finally{this.busy.set(false);}}
+}

+ 9 - 0
projects/xiaoshu-admin/src/app/shared/accessible-dialog.directive.ts

@@ -0,0 +1,9 @@
+import { AfterViewInit, Directive, ElementRef, HostListener, inject, OnDestroy, output } from '@angular/core';
+@Directive({selector:'[appAccessibleDialog]',standalone:true})
+export class AccessibleDialogDirective implements AfterViewInit,OnDestroy {
+ readonly dialogEscape=output<void>();private readonly element:HTMLElement=inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;private readonly previous=document.activeElement as HTMLElement|null;
+ private controls():HTMLElement[]{return Array.from(this.element.querySelectorAll<HTMLElement>('button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex="0"]')).filter(x=>x.getClientRects().length>0);}
+ ngAfterViewInit():void {this.element.tabIndex=-1;queueMicrotask(()=>{if(this.element.isConnected)(this.controls()[0]||this.element).focus();});}
+ @HostListener('keydown',['$event']) onKey(event:KeyboardEvent):void {if(event.key==='Escape'){event.preventDefault();event.stopPropagation();this.dialogEscape.emit();}if(event.key==='Tab'){const items=this.controls(),first=items[0],last=items[items.length-1];if(!first){event.preventDefault();this.element.focus();}else if(event.shiftKey&&(document.activeElement===first||document.activeElement===this.element)){event.preventDefault();last.focus();}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}}}
+ ngOnDestroy():void{if(this.previous?.isConnected)this.previous.focus();}
+}

+ 8 - 0
projects/xiaoshu-admin/src/app/shared/bounded-workbook.spec.ts

@@ -0,0 +1,8 @@
+import { checkWorkbookArchive, readBoundedWorkbook } from './bounded-workbook';
+import { utils, write } from 'xlsx';
+describe('Bounded spreadsheet imports',()=>{
+ it('reads exported workbooks and rejects excess rows before exposing a preview',async()=>{const book=utils.book_new();utils.book_append_sheet(book,utils.aoa_to_sheet([['姓名'],['小树'],['老师']]),'Sheet1');const bytes=write(book,{type:'array',bookType:'xlsx',compression:true});const file=new File([bytes],'members.xlsx');expect((await readBoundedWorkbook(file,2)).length).toBe(2);await expectAsync(readBoundedWorkbook(file,1)).toBeRejectedWithError(/最多/);});
+ it('rejects malformed archives and huge expanded data before parsing',()=>{expect(()=>checkWorkbookArchive(new ArrayBuffer(10))).toThrowError(/有效/);const buf=new ArrayBuffer(68),v=new DataView(buf);v.setUint32(0,0x02014b50,true);v.setUint32(24,21*1024*1024,true);v.setUint32(46,0x06054b50,true);v.setUint16(56,1,true);expect(()=>checkWorkbookArchive(buf)).toThrowError(/解压/);});
+ it('rejects compressed content whose declared expanded size was forged',async()=>{const book=utils.book_new();utils.book_append_sheet(book,utils.aoa_to_sheet([['姓名'],['小树'.repeat(10000)]]),'Sheet1');const bytes=write(book,{type:'array',bookType:'xlsx',compression:true}) as ArrayBuffer;const v=new DataView(bytes);for(let p=0;p+46<v.byteLength;p++){if(v.getUint32(p,true)===0x02014b50&&v.getUint16(p+10,true)===8){v.setUint32(p+24,1,true);break;}}await expectAsync(readBoundedWorkbook(new File([bytes],'forged.xlsx'),200)).toBeRejectedWithError(/实际解压内容超限/);});
+ it('rejects oversized files without reading their contents',async()=>{const file=new File([new Uint8Array(5*1024*1024+1)],'large.xlsx');const spy=spyOn(file,'arrayBuffer');await expectAsync(readBoundedWorkbook(file,2000)).toBeRejectedWithError(/5 MB/);expect(spy).not.toHaveBeenCalled();});
+});

+ 48 - 0
projects/xiaoshu-admin/src/app/shared/bounded-workbook.ts

@@ -0,0 +1,48 @@
+// Refuse oversized ZIP containers before handing bytes to the spreadsheet parser.
+interface ArchiveEntry {offset:number;compressed:number;expanded:number;method:number;}
+export function checkWorkbookArchive(buffer: ArrayBuffer): ArchiveEntry[] {
+  const view = new DataView(buffer); let end = -1;
+  for (let p = view.byteLength - 22; p >= Math.max(0, view.byteLength - 65557); p--) {
+    if (view.getUint32(p, true) === 0x06054b50) { end = p; break; }
+  }
+  if (end < 0) throw new Error('请选择有效的 .xlsx 文件');
+  const count = view.getUint16(end + 10, true), start = view.getUint32(end + 16, true);
+  if (count > 2000 || start >= end || view.getUint16(end + 4, true) !== 0) throw new Error('工作簿结构过大或不受支持,请拆分文件');
+  const entries:ArchiveEntry[]=[];let p = start, expanded = 0;
+  for (let i = 0; i < count; i++) {
+    if (p + 46 > end || view.getUint32(p, true) !== 0x02014b50) throw new Error('工作簿目录损坏');
+    const size = view.getUint32(p + 24, true), flags = view.getUint16(p + 8, true);
+    expanded += size;
+    if (flags & 1 || size === 0xffffffff || expanded > 20 * 1024 * 1024) throw new Error('工作簿解压内容超过 20 MB 或已加密,请拆分后重试');
+    entries.push({offset:view.getUint32(p+42,true),compressed:view.getUint32(p+20,true),expanded:size,method:view.getUint16(p+10,true)});
+    p += 46 + view.getUint16(p + 28, true) + view.getUint16(p + 30, true) + view.getUint16(p + 32, true);
+  }
+  if (p !== end) throw new Error('工作簿目录不完整');
+  return entries;
+}
+export async function readBoundedWorkbook(file: File, maxRows: number): Promise<Record<string, unknown>[]> {
+  if (!/\.xlsx$/i.test(file.name) || file.size > 5 * 1024 * 1024) throw new Error('请选择不超过 5 MB 的 .xlsx 文件');
+  const bytes = await file.arrayBuffer(); const entries=checkWorkbookArchive(bytes);
+  const view=new DataView(bytes);let expanded=0;
+  for(const entry of entries){
+    const p=entry.offset;if(p+30>bytes.byteLength||view.getUint32(p,true)!==0x04034b50)throw new Error('工作簿压缩条目损坏');
+    const start=p+30+view.getUint16(p+26,true)+view.getUint16(p+28,true);
+    if(start+entry.compressed>bytes.byteLength)throw new Error('工作簿压缩数据不完整');
+    let count=0;
+    if(entry.method===0){count=entry.compressed;expanded+=count;}
+    else if(entry.method===8){
+      const reader=new Blob([bytes.slice(start,start+entry.compressed)]).stream().pipeThrough(new DecompressionStream('deflate-raw')).getReader();
+      try{while(true){const chunk=await reader.read();if(chunk.done)break;count+=chunk.value.byteLength;expanded+=chunk.value.byteLength;if(count>entry.expanded||expanded>20*1024*1024){await reader.cancel();throw new Error('工作簿实际解压内容超限,请拆分文件');}}}finally{reader.releaseLock();}
+    }else throw new Error('不支持此工作簿压缩方式');
+    if(count!==entry.expanded||expanded>20*1024*1024)throw new Error('工作簿解压大小异常');
+  }
+  const { read, utils } = await import('xlsx');
+  const book = read(bytes, { type: 'array', cellDates: false, sheetRows: maxRows + 2, sheets: 0 });
+  const sheet = book.Sheets[book.SheetNames[0]];
+  if (!sheet) throw new Error('Excel 中没有工作表');
+  const range = utils.decode_range(String(sheet['!fullref'] || sheet['!ref'] || 'A1'));
+  if (range.e.r > maxRows || range.e.c > 100) throw new Error(`一次最多导入 ${maxRows} 行、101 列,请拆分文件`);
+  const rows = utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '', raw: false });
+  if (!rows.length) throw new Error('Excel 中没有可导入数据');
+  return rows;
+}

+ 5 - 3
projects/xiaoshu-mobile/src/app/app.config.ts

@@ -1,13 +1,15 @@
-import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
-import { provideHttpClient } from '@angular/common/http';
+import { SafeErrorHandler, diagnosticInterceptor } from '../../../shared/diagnostics';
+import { ApplicationConfig, ErrorHandler, provideZoneChangeDetection } from '@angular/core';
+import { provideHttpClient, withInterceptors } from '@angular/common/http';
 import { provideRouter } from '@angular/router';
 import { provideRouter } from '@angular/router';
 
 
 import { routes } from './app.routes';
 import { routes } from './app.routes';
 
 
 export const appConfig: ApplicationConfig = {
 export const appConfig: ApplicationConfig = {
   providers: [
   providers: [
+    {provide:ErrorHandler,useClass:SafeErrorHandler},
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideZoneChangeDetection({ eventCoalescing: true }),
-    provideHttpClient(),
+    provideHttpClient(withInterceptors([diagnosticInterceptor])),
     provideRouter(routes),
     provideRouter(routes),
   ],
   ],
 };
 };

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

@@ -1,6 +1,6 @@
 import { CanDeactivateFn, Route, Routes } from '@angular/router';
 import { CanDeactivateFn, Route, Routes } from '@angular/router';
 import { unavailableReason } from './core/feature-policy';
 import { unavailableReason } from './core/feature-policy';
-import { authGuard } from './core/auth.guard';
+import { authGuard, passwordResetGuard } from './core/auth.guard';
 import { PageKind } from './core/models';
 import { PageKind } from './core/models';
 import { PAGE_CATALOG } from './core/page-catalog';
 import { PAGE_CATALOG } from './core/page-catalog';
 import { AppShellComponent } from './layout/app-shell.component';
 import { AppShellComponent } from './layout/app-shell.component';
@@ -40,7 +40,7 @@ const components: Record<PageKind, ComponentLoader> = {
 
 
 const migratedRoutes: Routes = PAGE_CATALOG.map((meta): Route => ({
 const migratedRoutes: Routes = PAGE_CATALOG.map((meta): Route => ({
   path: meta.path,
   path: meta.path,
-  loadComponent: unavailableReason(meta) ? () => import('./features/resource/unavailable-page.component').then(m=>m.UnavailablePageComponent) : meta.path === 'pages/shop/baobiao' ? () => import('./features/appointment/lesson-report.component').then(m=>m.LessonReportComponent) : components[meta.kind],
+  loadComponent: meta.path === 'pages/member/acc_destory' ? () => import('./features/auth/deletion-page.component').then(m=>m.DeletionPageComponent) : meta.path === 'pages/member/passwd' ? () => import('./features/auth/password-page.component').then(m=>m.PasswordPageComponent) : unavailableReason(meta) ? () => import('./features/resource/unavailable-page.component').then(m=>m.UnavailablePageComponent) : meta.path === 'pages/shop/baobiao' ? () => import('./features/appointment/lesson-report.component').then(m=>m.LessonReportComponent) : components[meta.kind],
   canActivate: meta.auth ? [authGuard] : undefined,
   canActivate: meta.auth ? [authGuard] : undefined,
   data: { meta },
   data: { meta },
 }));
 }));
@@ -49,6 +49,7 @@ export const routes: Routes = [
   {
   {
     path: '',
     path: '',
     component: AppShellComponent,
     component: AppShellComponent,
+    canActivateChild:[passwordResetGuard],
     children: [
     children: [
       { path: '', pathMatch: 'full', redirectTo: 'pages/home/index' },
       { path: '', pathMatch: 'full', redirectTo: 'pages/home/index' },
       ...migratedRoutes,
       ...migratedRoutes,

+ 20 - 0
projects/xiaoshu-mobile/src/app/core/api.service.spec.ts

@@ -298,6 +298,13 @@ describe('ApiService', () => {
     expect((actual as ApiError).message).toBe('账号或密码错误');
     expect((actual as ApiError).message).toBe('账号或密码错误');
   });
   });
 
 
+  it('does not let an old 401 invalidate a newer login', fakeAsync(() => {
+    sessionToken='r:old'; api.get('user_get').subscribe({error:()=>{}});
+    const pending=http.expectOne(API_CONFIG.cloudFunctionUrl); sessionToken='r:new';
+    pending.flush({retmsg:'登录状态已失效'},{status:401,statusText:'Unauthorized'});tick();
+    expect(sessionLogout).not.toHaveBeenCalled();expect(routerNavigate).not.toHaveBeenCalled();
+  }));
+
   it('clears an expired Parse session and returns to login after an authenticated 401', fakeAsync(() => {
   it('clears an expired Parse session and returns to login after an authenticated 401', fakeAsync(() => {
     sessionToken = 'r:expired-session';
     sessionToken = 'r:expired-session';
     let actual: unknown;
     let actual: unknown;
@@ -306,6 +313,8 @@ describe('ApiService', () => {
       { retmsg: '会话已失效' },
       { retmsg: '会话已失效' },
       { status: 401, statusText: 'Unauthorized' },
       { status: 401, statusText: 'Unauthorized' },
     );
     );
+    expect(sessionLogout).not.toHaveBeenCalled();
+    http.expectOne('/ncloud/users/me').flush({code:209},{status:401,statusText:'Unauthorized'});
     tick();
     tick();
 
 
     expect(actual instanceof ApiError).toBeTrue();
     expect(actual instanceof ApiError).toBeTrue();
@@ -319,6 +328,17 @@ describe('ApiService', () => {
     );
     );
   }));
   }));
 
 
+  it('keeps the session when verification succeeds or is unavailable', fakeAsync(() => {
+    sessionToken='r:valid';
+    for(const healthy of [true,false]){
+      api.post('app_learning_overview',{uid:42}).subscribe({error:()=>{}});
+      http.expectOne(API_CONFIG.cloudFunctionUrl).flush({}, {status:401,statusText:'Unauthorized'});
+      const check=http.expectOne('/ncloud/users/me');
+      if(healthy)check.flush({objectId:'member-42'});else check.flush({}, {status:503,statusText:'Unavailable'});
+      tick();expect(sessionLogout).not.toHaveBeenCalled();
+    }
+  }));
+
   it('returns to login when a protected request has no usable session left', fakeAsync(() => {
   it('returns to login when a protected request has no usable session left', fakeAsync(() => {
     let actual: unknown;
     let actual: unknown;
     api.post('e_get_21list', { uid: 42 }).subscribe({ error: (error) => actual = error });
     api.post('e_get_21list', { uid: 42 }).subscribe({ error: (error) => actual = error });

+ 22 - 8
projects/xiaoshu-mobile/src/app/core/api.service.ts

@@ -46,14 +46,13 @@ export class ApiService {
       ...(sessionToken ? { token: sessionToken } : {}),
       ...(sessionToken ? { token: sessionToken } : {}),
       params: { action, ...params },
       params: { action, ...params },
     };
     };
-    const bounded = ['app_lesson_courses','user_get','e_order_detail','e_order_update_v2','app_companion_overview','app_live_versions','app_lesson_feedback_update','app_lesson_complete','app_lesson_review'].includes(action);
     const request = this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
     const request = this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
       headers: {
       headers: {
         'Content-Type': 'application/json',
         'Content-Type': 'application/json',
         'X-Parse-Application-Id': API_CONFIG.parseAppId,
         'X-Parse-Application-Id': API_CONFIG.parseAppId,
       },
       },
     });
     });
-    return this.resilient<T>(bounded ? request.pipe(timeout({first:20_000,with:()=>throwError(()=>new ApiError('请求暂未完成,请刷新核对;已填写内容会保留,重试不会重复扣课时。'))})) : request, sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action)).pipe(tap(() => {
+    return this.resilient<T>(request.pipe(timeout({first:20_000,with:()=>throwError(()=>new ApiError(this.isReadAction(action)?'请求暂未完成,请刷新重试。':'请求暂未完成,结果待核对;请刷新核对后重试,已填写内容会保留。'))})), sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action), sessionToken).pipe(tap(() => {
       if (!this.isReadAction(action)) {
       if (!this.isReadAction(action)) {
         this.invalidateOverviews();
         this.invalidateOverviews();
         this.announceDataChange();
         this.announceDataChange();
@@ -110,7 +109,7 @@ export class ApiService {
     });
     });
   }
   }
 
 
-  private resilient<T>(request: Observable<unknown>, authenticatedRequest = false, retryRead = true): Observable<ApiEnvelope<T>> {
+  private resilient<T>(request: Observable<unknown>, authenticatedRequest = false, retryRead = true, requestToken = ''): Observable<ApiEnvelope<T>> {
     return request.pipe(
     return request.pipe(
       retry({
       retry({
         count: retryRead ? 2 : 0,
         count: retryRead ? 2 : 0,
@@ -119,7 +118,7 @@ export class ApiService {
           : throwError(() => error),
           : throwError(() => error),
       }),
       }),
       map((response) => this.normalize<T>(response)),
       map((response) => this.normalize<T>(response)),
-      catchError((error) => this.handleError(error, authenticatedRequest)),
+      catchError((error) => this.handleError(error, authenticatedRequest, requestToken)),
     );
     );
   }
   }
 
 
@@ -166,10 +165,11 @@ export class ApiService {
     return JSON.stringify(value) ?? 'null';
     return JSON.stringify(value) ?? 'null';
   }
   }
 
 
-  private handleError(error: unknown, authenticatedRequest: boolean): Observable<never> {
+  private handleError(error: unknown, authenticatedRequest: boolean, requestToken: string): Observable<never> {
     if (error instanceof ApiError) return throwError(() => error);
     if (error instanceof ApiError) return throwError(() => error);
     if (error instanceof HttpErrorResponse) {
     if (error instanceof HttpErrorResponse) {
-      if (error.status === 401 && authenticatedRequest) this.expireSession();
+      if (error.status === 401 && authenticatedRequest) this.verifyFailedSession(requestToken);
+      if (error.error?.code === 'PASSWORD_RESET_REQUIRED' && this.parseSessionToken() === requestToken) void this.router?.navigateByUrl('/pages/member/passwd');
       const payload = error.error as { retmsg?: string } | string | null;
       const payload = error.error as { retmsg?: string } | string | null;
       const message = this.isTransientError(error)
       const message = this.isTransientError(error)
         ? '请求暂未完成,请检查网络后重试;已填写内容会保留。'
         ? '请求暂未完成,请检查网络后重试;已填写内容会保留。'
@@ -190,9 +190,23 @@ export class ApiService {
     return error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status);
     return error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status);
   }
   }
 
 
-  private expireSession(): void {
+  private readonly checkingSessions = new Set<string>();
+  private verifyFailedSession(token: string): void {
+    if(this.parseSessionToken() !== token)return;
+    if(!token){this.expireSession(token);return;}
+    if(this.checkingSessions.has(token))return;
+    this.checkingSessions.add(token);
+    const base = ['localhost','127.0.0.1'].includes(globalThis.location?.hostname ?? '') ? '/ncloud' : 'https://server.xiaoshu.pro/parse';
+    this.http.get<{objectId:string;isDisabled?:boolean;isDeleted?:boolean}>(base+'/users/me', {headers:{'X-Parse-Application-Id':API_CONFIG.parseAppId,'X-Parse-Session-Token':token}}).pipe(timeout(10000),finalize(()=>this.checkingSessions.delete(token))).subscribe({
+      next:user=>{const currentId=this.sessions.user()?.['objectId'];if(user.isDisabled||user.isDeleted||(currentId&&currentId!==user.objectId))this.expireSession(token);},
+      error:error=>{if(error instanceof HttpErrorResponse&&(error.status===401||Number(error.error?.code)===209))this.expireSession(token);},
+    });
+  }
+
+  private expireSession(requestToken: string): void {
+    if (this.parseSessionToken() !== requestToken) return;
     this.overviewRequests.clear();
     this.overviewRequests.clear();
-    this.sessions.logout();
+    this.sessions.logout(false);
     if (!this.router || this.sessionRedirecting) return;
     if (!this.router || this.sessionRedirecting) return;
     this.sessionRedirecting = true;
     this.sessionRedirecting = true;
     const currentUrl = this.router.url && !this.router.url.startsWith('/pages/member/wxauth')
     const currentUrl = this.router.url && !this.router.url.startsWith('/pages/member/wxauth')

+ 6 - 0
projects/xiaoshu-mobile/src/app/core/auth.guard.ts

@@ -5,7 +5,13 @@ import { SessionService } from './session.service';
 export const authGuard: CanActivateFn = (_route, state) => {
 export const authGuard: CanActivateFn = (_route, state) => {
   const session = inject(SessionService);
   const session = inject(SessionService);
   const router = inject(Router);
   const router = inject(Router);
+  if (session.isLoggedIn() && session.user()?.['passwordResetRequired'] === true && !state.url.startsWith('/pages/member/passwd')&&!state.url.startsWith('/pages/member/acc_destory')) return router.createUrlTree(['/pages/member/passwd']);
   return session.isLoggedIn()
   return session.isLoggedIn()
     ? true
     ? true
     : router.createUrlTree(['/pages/member/wxauth'], { queryParams: { returnUrl: state.url } });
     : router.createUrlTree(['/pages/member/wxauth'], { queryParams: { returnUrl: state.url } });
 };
 };
+
+export const passwordResetGuard:CanActivateFn=(_route,state)=>{
+ const session=inject(SessionService),router=inject(Router);
+ return session.isLoggedIn()&&session.user()?.['passwordResetRequired']===true&&!['/pages/member/passwd','/pages/member/wxauth','/pages/member/acc_destory','/pages/home/enjoy_item'].some(path=>state.url.startsWith(path))?router.createUrlTree(['/pages/member/passwd']):true;
+};

+ 24 - 0
projects/xiaoshu-mobile/src/app/core/diagnostics.spec.ts

@@ -0,0 +1,24 @@
+import { recordDiagnostic, SafeErrorHandler } from '../../../../shared/diagnostics';
+
+describe('Safe local diagnostics', () => {
+  const key = 'xiaoshu.safe-diagnostics.v1';
+  afterEach(() => { sessionStorage.removeItem(key); document.getElementById('runtime-notice')?.remove(); });
+  it('bounds history and excludes invalid correlation values', () => {
+    for (let i = 0; i < 60; i++) recordDiagnostic('http', `request-${i}`, 503);
+    recordDiagnostic('http', 'token=secret&student=private', 401);
+    const rows = JSON.parse(sessionStorage.getItem(key)!);
+    expect(rows.length).toBe(50);
+    expect(rows[0].requestId).toBe('request-11');
+    expect(rows[49].requestId).toBe('');
+    expect(Object.keys(rows[49]).sort()).toEqual(['kind', 'requestId', 'status', 'time', 'version']);
+  });
+  it('shows a recoverable notice without storing exception details', () => {
+    const handler = new SafeErrorHandler();
+    handler.handleError(new Error('password=secret student=private'));
+    handler.handleError(new Error('another private payload'));
+    expect(document.querySelectorAll('#runtime-notice').length).toBe(1);
+    expect(document.getElementById('runtime-notice')?.textContent).toContain('刷新后重试');
+    expect(sessionStorage.getItem(key)).not.toContain('secret');
+    expect(sessionStorage.getItem(key)).not.toContain('private');
+  });
+});

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

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

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

@@ -45,12 +45,13 @@ class LearningModulesApiService {
     if (action === 'e_record_detail') {
     if (action === 'e_record_detail') {
       return this.result<T>([{ GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果', check: 0 }]);
       return this.result<T>([{ GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果', check: 0 }]);
     }
     }
+    if (action === 'app_lesson_courses') { this.lastAppointmentCourseParams=params; return this.result<T>([{bindingId:'binding-801',kcid:801,Title:'七年级上册',courseName:'七年级上册',syjd:'{}'},{bindingId:'binding-802',kcid:802,Title:'阅读训练',courseName:'阅读训练'}]); }
     if (action === 'e_order_detail') {
     if (action === 'e_order_detail') {
       return this.result<T>([{
       return this.result<T>([{
         GeneralID: 901,
         GeneralID: 901,
         szyh: 100,
         szyh: 100,
         HoneyName: '张雅茜',
         HoneyName: '张雅茜',
-        kcid: 801,
+        kcid: 801, bindingId:'binding-801',
         yykcmc: '七年级上册',
         yykcmc: '七年级上册',
         dszt: this.appointmentStatus,
         dszt: this.appointmentStatus,
         updatedAt: "2026-10-11T00:00:00Z",
         updatedAt: "2026-10-11T00:00:00Z",
@@ -289,7 +290,7 @@ describe('home learning modules', () => {
   beforeEach(() => {
   beforeEach(() => {
     // Route tests do not depend on remote audio availability or its error-toast timer.
     // Route tests do not depend on remote audio availability or its error-toast timer.
     spyOn(HTMLMediaElement.prototype, 'play').and.returnValue(Promise.resolve());
     spyOn(HTMLMediaElement.prototype, 'play').and.returnValue(Promise.resolve());
-    localStorage.removeItem('xiaoshu.learning.300.100.901.skip_spelling_stage');
+    localStorage.removeItem('xiaoshu.learning.300.100.901.unbound.skip_spelling_stage');
     user.set({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 });
     user.set({ userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 });
     session.groupId.set(1);
     session.groupId.set(1);
     session.student.set(null);
     session.student.set(null);
@@ -886,62 +887,6 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('第 1–30 条 · 共 65 个单词');
     expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('第 1–30 条 · 共 65 个单词');
     expect(harness.routeNativeElement?.querySelector('.word-preview-pagination')?.textContent).toContain('1 / 3 页');
     expect(harness.routeNativeElement?.querySelector('.word-preview-pagination')?.textContent).toContain('1 / 3 页');
     (harness.routeNativeElement?.querySelector('[aria-label="第 2 页"]') as HTMLButtonElement).click();
     (harness.routeNativeElement?.querySelector('[aria-label="第 2 页"]') as HTMLButtonElement).click();
-  it('syncs a missing learning record and reopens its review words', async () => {
-    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
-    const post = api.post.bind(api);
-    let detailReads = 0;
-    spyOn(api, 'post').and.callFake((action, params) => {
-      if (action === 'e_record_detail') {
-        detailReads += 1;
-        return of({ retcode: 1, result: detailReads === 1 ? [] : [{ GeneralID: 701, Title: 'apple', sy: '苹果' }], addon: [] } as ApiEnvelope<any>);
-      }
-      if (action === 'app_learning_overview') return of({ retcode: 1, result: {} } as ApiEnvelope<any>);
-      return post(action, params);
-    });
-    const harness = await RouterTestingHarness.create();
-    await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
-    harness.fixture.detectChanges();
-    expect(detailReads).toBe(2);
-    expect(harness.routeNativeElement?.querySelector('.record-word')?.textContent).toContain('apple');
-    expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
-  });
-
-  it('reads the original word list when the detail stays empty after syncing', async () => {
-    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
-    const post = api.post.bind(api);
-    const get = api.get.bind(api);
-    spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
-      ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : action === 'app_learning_overview'
-        ? of({ retcode: 1, result: {} } as ApiEnvelope<any>) : post(action, params));
-    spyOn(api, 'get').and.callFake((action, params) => action === 'content_get'
-      ? of({ retcode: 1, result: [{ GeneralID: 864038, xxqs: JSON.stringify([{ GeneralID: 701, Title: 'apple' }]) }] } as ApiEnvelope<any>)
-      : get(action, params));
-    const harness = await RouterTestingHarness.create();
-    await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
-    harness.fixture.detectChanges();
-    expect(harness.routeNativeElement?.querySelector('.record-word')?.textContent).toContain('apple');
-    expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
-  });
-
-  it('identifies a legacy writing review with no original words and keeps it incomplete', async () => {
-    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
-    const post = api.post.bind(api);
-    const get = api.get.bind(api);
-    spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
-      ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : action === 'app_learning_overview'
-        ? of({ retcode: 1, result: {} } as ApiEnvelope<any>) : post(action, params));
-    spyOn(api, 'get').and.callFake((action, params) => action === 'content_get'
-      ? of({ retcode: 1, result: [{ GeneralID: 864038, xxqs: '', learned: 0, con: '完成了写作学习' }] } as ApiEnvelope<any>)
-      : get(action, params));
-    const harness = await RouterTestingHarness.create();
-    await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
-    harness.fixture.detectChanges();
-    expect(harness.routeNativeElement?.textContent).toContain('写作学习记录,原本没有单词');
-    expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull();
-    expect(harness.routeNativeElement?.querySelector('a[href="/pages/stu/stu_calendar"]')).not.toBeNull();
-    expect(harness.routeNativeElement?.textContent).not.toContain('结束本次复习');
-  });
-
     await harness.fixture.whenStable();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     harness.fixture.detectChanges();
     expect(wordApi.lastWordParams['cpage']).toBe(2);
     expect(wordApi.lastWordParams['cpage']).toBe(2);
@@ -1388,10 +1333,11 @@ describe('home learning modules', () => {
 
 
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     expect(session.student()?.userId).toBe(100);
     expect(session.student()?.userId).toBe(100);
-    expect(api.lastAppointmentCourseParams['myfield']).toBe('yhid=100');
+    expect(api.lastAppointmentCourseParams['orderId']).toBe('901');
     expect(api.lastNodeParams['userId']).toBe(100);
     expect(api.lastNodeParams['userId']).toBe(100);
     const courseSelect = harness.routeNativeElement?.querySelector('.course-section select') as HTMLSelectElement;
     const courseSelect = harness.routeNativeElement?.querySelector('.course-section select') as HTMLSelectElement;
-    expect(courseSelect).toBeNull();
+    expect(courseSelect).not.toBeNull();
+    expect(courseSelect.options.length).toBeGreaterThanOrEqual(2);
     expect(harness.routeNativeElement?.querySelector('.course-section')?.textContent).toContain('七年级上册');
     expect(harness.routeNativeElement?.querySelector('.course-section')?.textContent).toContain('七年级上册');
     expect(harness.routeNativeElement?.querySelectorAll('.unit-grid article').length).toBe(2);
     expect(harness.routeNativeElement?.querySelectorAll('.unit-grid article').length).toBe(2);
     const unitButtons = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.unit-select');
     const unitButtons = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.unit-select');
@@ -1550,11 +1496,11 @@ describe('home learning modules', () => {
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     api.appointmentStatus = 10;
     api.appointmentStatus = 10;
     const word = { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果' };
     const word = { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果' };
-    localStorage.setItem('xiaoshu.learning.300.100.901._page_word_orilist', JSON.stringify([[word]]));
-    localStorage.setItem('xiaoshu.learning.300.100.901._page_current_learn', '1');
+    localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_word_orilist', JSON.stringify([[word]]));
+    localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_current_learn', '1');
     // Stale page-count metadata must not create a nonexistent next group.
     // Stale page-count metadata must not create a nonexistent next group.
-    localStorage.setItem('xiaoshu.learning.300.100.901._page_total_page', '2');
-    localStorage.removeItem('xiaoshu.learning.300.100.901.learn_mode');
+    localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_total_page', '2');
+    localStorage.removeItem('xiaoshu.learning.300.100.901.unbound.learn_mode');
     const harness = await RouterTestingHarness.create();
     const harness = await RouterTestingHarness.create();
     const router = TestBed.inject(Router);
     const router = TestBed.inject(Router);
     const navigate = spyOn(router, 'navigate').and.callThrough();
     const navigate = spyOn(router, 'navigate').and.callThrough();
@@ -1606,9 +1552,9 @@ describe('home learning modules', () => {
     session.groupId.set(3);
     session.groupId.set(3);
     session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
     session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
     const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }, { GeneralID: 1002, Title: 'banana', sy: '香蕉' }], [{ GeneralID: 1003, Title: 'cherry', sy: '樱桃' }]];
     const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }, { GeneralID: 1002, Title: 'banana', sy: '香蕉' }], [{ GeneralID: 1003, Title: 'cherry', sy: '樱桃' }]];
-    localStorage.setItem('xiaoshu.learning.300.100.901._page_word_orilist', JSON.stringify(pages));
-    localStorage.setItem('xiaoshu.learning.300.100.901._page_total_page', '0');
-    localStorage.setItem('xiaoshu.learning.300.100.901._page_current_learn', '1');
+    localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_word_orilist', JSON.stringify(pages));
+    localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_total_page', '0');
+    localStorage.setItem('xiaoshu.learning.300.100.901.unbound._page_current_learn', '1');
     const harness = await RouterTestingHarness.create();
     const harness = await RouterTestingHarness.create();
     const router = TestBed.inject(Router);
     const router = TestBed.inject(Router);
     await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&orderId=901');
     await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&orderId=901');
@@ -1627,14 +1573,14 @@ describe('home learning modules', () => {
     await harness.fixture.whenStable();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     harness.fixture.detectChanges();
     expect(router.url).toBe('/pages/shop/get_order?id=901');
     expect(router.url).toBe('/pages/shop/get_order?id=901');
-    for (const key of ['_page_word_orilist', '_page_total_page', '_page_current_learn', 'skip_spelling_stage']) localStorage.removeItem(`xiaoshu.learning.300.100.901.${key}`);
+    for (const key of ['_page_word_orilist', '_page_total_page', '_page_current_learn', 'skip_spelling_stage']) localStorage.removeItem(`xiaoshu.learning.300.100.901.unbound.${key}`);
   });
   });
 
 
   it('keeps teacher-led spelling skipped through the remaining word groups', async () => {
   it('keeps teacher-led spelling skipped through the remaining word groups', async () => {
     user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
     user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
     session.groupId.set(3);
     session.groupId.set(3);
     session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
     session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
-    const prefix = 'xiaoshu.learning.300.100.901.';
+    const prefix = 'xiaoshu.learning.300.100.901.unbound.';
     const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }], [{ GeneralID: 1002, Title: 'banana', sy: '香蕉' }]];
     const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }], [{ GeneralID: 1002, Title: 'banana', sy: '香蕉' }]];
     localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify(pages));
     localStorage.setItem(prefix + '_page_word_orilist', JSON.stringify(pages));
     localStorage.setItem(prefix + '_page_current_learn', '1');
     localStorage.setItem(prefix + '_page_current_learn', '1');
@@ -1712,4 +1658,61 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(5);
     expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(5);
     expect((harness.routeNativeElement?.querySelectorAll('.record-pagination button')[1] as HTMLButtonElement).disabled).toBeTrue();
     expect((harness.routeNativeElement?.querySelectorAll('.record-pagination button')[1] as HTMLButtonElement).disabled).toBeTrue();
   });
   });
+  it('syncs a missing learning record and reopens its review words', async () => {
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const post = api.post.bind(api);
+    let detailReads = 0;
+    spyOn(api, 'post').and.callFake((action, params) => {
+      if (action === 'e_record_detail') {
+        detailReads += 1;
+        return of({ retcode: 1, result: detailReads === 1 ? [] : [{ GeneralID: 701, Title: 'apple', sy: '苹果' }], addon: [] } as ApiEnvelope<any>);
+      }
+      if (action === 'app_learning_overview') return of({ retcode: 1, result: {} } as ApiEnvelope<any>);
+      return post(action, params);
+    });
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
+    harness.fixture.detectChanges();
+    expect(detailReads).toBe(2);
+    expect(harness.routeNativeElement?.querySelector('.record-word')?.textContent).toContain('apple');
+    expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
+  });
+
+  it('reads the original word list when the detail stays empty after syncing', async () => {
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const post = api.post.bind(api);
+    const get = api.get.bind(api);
+    spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
+      ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : action === 'app_learning_overview'
+        ? of({ retcode: 1, result: {} } as ApiEnvelope<any>) : post(action, params));
+    spyOn(api, 'get').and.callFake((action, params) => action === 'content_get'
+      ? of({ retcode: 1, result: [{ GeneralID: 864038, xxqs: JSON.stringify([{ GeneralID: 701, Title: 'apple' }]) }] } as ApiEnvelope<any>)
+      : get(action, params));
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.record-word')?.textContent).toContain('apple');
+    expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
+  });
+
+  it('identifies a legacy writing review with no original words and keeps it incomplete', async () => {
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const post = api.post.bind(api);
+    const get = api.get.bind(api);
+    spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
+      ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : action === 'app_learning_overview'
+        ? of({ retcode: 1, result: {} } as ApiEnvelope<any>) : post(action, params));
+    spyOn(api, 'get').and.callFake((action, params) => action === 'content_get'
+      ? of({ retcode: 1, result: [{ GeneralID: 864038, xxqs: '', learned: 0, con: '完成了写作学习' }] } as ApiEnvelope<any>)
+      : get(action, params));
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.textContent).toContain('写作学习记录,原本没有单词');
+    expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull();
+    expect(harness.routeNativeElement?.querySelector('a[href="/pages/stu/stu_calendar"]')).not.toBeNull();
+    expect(harness.routeNativeElement?.textContent).not.toContain('结束本次复习');
+  });
+
+
 });
 });

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

@@ -1,3 +1,6 @@
+import {provideHttpClient} from '@angular/common/http';
+import {provideHttpClientTesting,HttpTestingController} from '@angular/common/http/testing';
+import {API_CONFIG} from './app.constants';
 import { TestBed } from '@angular/core/testing';
 import { TestBed } from '@angular/core/testing';
 import { SessionService } from './session.service';
 import { SessionService } from './session.service';
 
 
@@ -34,4 +37,17 @@ describe('SessionService', () => {
     expect(service.isLoggedIn()).toBeFalse();
     expect(service.isLoggedIn()).toBeFalse();
     expect(service.user()).toBeNull();
     expect(service.user()).toBeNull();
   });
   });
+  it('switches cross-tab identity, clears learning drafts and reloads stale page data',()=>{
+    const service=TestBed.inject(SessionService);service.setLogin({userId:1,sessionToken:'r:first'});
+    const reload=spyOn<any>(service,'reloadForIdentity');
+    window.dispatchEvent(new StorageEvent('storage',{key:'xiaoshu.angular.session',storageArea:localStorage,newValue:JSON.stringify({user:{userId:2},token:'r:second',student:null})}));
+    expect(service.user()?.userId).toBe(2);expect(service.token()).toBe('r:second');expect(reload).toHaveBeenCalledTimes(1);
+    window.dispatchEvent(new StorageEvent('storage',{key:'xiaoshu.angular.session',storageArea:localStorage,newValue:null}));expect(service.token()).toBe('');
+  });
+  it('revokes exactly the logged-out token and clears local identity immediately',()=>{
+    TestBed.configureTestingModule({providers:[provideHttpClient(),provideHttpClientTesting()]});const service=TestBed.inject(SessionService),http=TestBed.inject(HttpTestingController);
+    service.setLogin({userId:1,sessionToken:'r:first'});service.logout();expect(service.token()).toBe('');
+    const request=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(request.request.body.token).toBe('r:first');expect(request.request.body.params.action).toBe('app_logout');request.flush({retcode:0,result:{loggedOut:true}});http.verify();
+  });
+
 });
 });

+ 19 - 2
projects/xiaoshu-mobile/src/app/core/session.service.ts

@@ -1,4 +1,8 @@
-import { computed, effect, Injectable, signal } from '@angular/core';
+import {HttpClient} from '@angular/common/http';
+import {Router} from '@angular/router';
+import {timeout} from 'rxjs';
+import {API_CONFIG} from './app.constants';
+import { computed, DestroyRef, effect, inject, Injectable, signal } from '@angular/core';
 import { AppUser } from './models';
 import { AppUser } from './models';
 
 
 interface PersistedSession {
 interface PersistedSession {
@@ -17,7 +21,16 @@ export class SessionService {
   readonly isLoggedIn = computed(() => this.token().startsWith('r:') && Boolean(this.user()?.userId));
   readonly isLoggedIn = computed(() => this.token().startsWith('r:') && Boolean(this.user()?.userId));
   readonly groupId = computed(() => Number(this.user()?.groupId ?? 0));
   readonly groupId = computed(() => Number(this.user()?.groupId ?? 0));
 
 
+  private readonly http=inject(HttpClient,{optional:true});
+  private readonly router=inject(Router,{optional:true});
   constructor() {
   constructor() {
+    const onStorage=(event:StorageEvent)=>{
+      if(event.storageArea!==localStorage || event.key!==STORAGE_KEY)return;
+      const next=this.parse<PersistedSession>(event.newValue),oldId=this.user()?.userId;
+      if(next?.token?.startsWith('r:')&&next.user?.userId){if(oldId!==next.user.userId)this.clearLearningDrafts();this.user.set(next.user);this.student.set(next.student);this.token.set(next.token);if(oldId!==next.user.userId)this.reloadForIdentity();}
+      else {this.logout(false);void this.router?.navigateByUrl('/pages/member/wxauth');}
+    };
+    window.addEventListener('storage',onStorage);inject(DestroyRef).onDestroy(()=>window.removeEventListener('storage',onStorage));
     this.restore();
     this.restore();
     effect(() => {
     effect(() => {
       const value: PersistedSession = {
       const value: PersistedSession = {
@@ -29,6 +42,8 @@ export class SessionService {
     });
     });
   }
   }
 
 
+  private reloadForIdentity():void { globalThis.location.reload(); }
+
   setLogin(user: AppUser): void {
   setLogin(user: AppUser): void {
     const sessionToken = typeof user['sessionToken'] === 'string' ? user['sessionToken'].trim() : '';
     const sessionToken = typeof user['sessionToken'] === 'string' ? user['sessionToken'].trim() : '';
     if (!sessionToken.startsWith('r:')) {
     if (!sessionToken.startsWith('r:')) {
@@ -59,7 +74,9 @@ export class SessionService {
     localStorage.removeItem('xiaoshu.reading.user');
     localStorage.removeItem('xiaoshu.reading.user');
   }
   }
 
 
-  logout(): void {
+  logout(revoke=true): void {
+    const token=this.token();
+    if(revoke&&token.startsWith('r:'))this.http?.post(API_CONFIG.cloudFunctionUrl,{token,params:{action:'app_logout'}}).pipe(timeout(10000)).subscribe({error:()=>undefined});
     this.clearLearningDrafts();
     this.clearLearningDrafts();
     this.user.set(null);
     this.user.set(null);
     this.student.set(null);
     this.student.set(null);

+ 12 - 0
projects/xiaoshu-mobile/src/app/features/auth/deletion-page.component.ts

@@ -0,0 +1,12 @@
+import { CommonModule } from '@angular/common';
+import { Component, inject, signal, OnInit } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { firstValueFrom } from 'rxjs';
+import { ApiService } from '../../core/api.service';
+@Component({selector:'app-deletion-page',standalone:true,imports:[CommonModule,FormsModule],template:`<main><h1>申请注销账号</h1><p>提交后由管理员核对剩余课时、未完成课程及资料保留事项。申请受理不代表账号已注销,处理进度会显示在下方。</p>@if(error()){<p role="alert">{{error()}}</p>}<label for="deletion-reason">申请说明</label><textarea id="deletion-reason" [(ngModel)]="reason" maxlength="500" rows="4" placeholder="请填写需要注销的原因或希望协助处理的事项"></textarea><button type="button" [disabled]="busy()||reason.trim().length<2" (click)="submit()">{{busy()?'正在处理…':'提交注销申请'}}</button><button type="button" (click)="load()" [disabled]="busy()">刷新进度</button><h2>我的申请</h2>@for(row of items();track row['id']){<article><strong>{{status(row['status'])}}</strong><p>{{row['reason']}}</p><p>{{row['response']||'管理员尚未回复'}}</p><small>{{row['createdAt']|date:'yyyy-MM-dd HH:mm':'+0800'}}</small></article>}@empty{@if(!busy()&&!error()){<p>尚未提交申请</p>}}</main>`,styles:[`main{max-width:640px;margin:auto;padding:24px;color:#233f37}h1{font-size:24px}p{line-height:1.7}label,textarea{display:block;width:100%}textarea{box-sizing:border-box;padding:12px;border:1px solid #c5d8d0;border-radius:12px;margin:8px 0}button{min-height:44px;margin:8px 8px 8px 0;padding:10px 18px;background:#197765;border:0;border-radius:10px;color:white}button:disabled{opacity:.5}article{background:white;padding:16px;margin:12px 0;border-radius:12px}`]})
+export class DeletionPageComponent implements OnInit {
+ private readonly api=inject(ApiService);readonly busy=signal(false);readonly error=signal('');readonly items=signal<Record<string,any>[]>([]);reason='';ngOnInit():void{void this.load();}
+ status(value:unknown):string{return ({requested:'已提交',processing:'处理中',rejected:'未通过',completed:'已完成'} as Record<string,string>)[String(value)]||'待核对';}
+ async load():Promise<void>{this.busy.set(true);this.error.set('');try{const r=await firstValueFrom(this.api.get<{items:Record<string,any>[]}>('app_deletion_status',{}));this.items.set(r.result?.items||[]);}catch(e){this.error.set(e instanceof Error?e.message:'进度加载失败,请重试');}finally{this.busy.set(false);}}
+ async submit():Promise<void>{if(this.busy())return;this.busy.set(true);this.error.set('');try{await firstValueFrom(this.api.post('app_deletion_request',{reason:this.reason}));this.reason='';await this.load();}catch(e){this.error.set(e instanceof Error?e.message:'提交结果待核对,请刷新进度');}finally{this.busy.set(false);}}
+}

+ 7 - 7
projects/xiaoshu-mobile/src/app/features/auth/login-page.component.html

@@ -13,19 +13,19 @@
     @if (sessionMessage()) { <div class="session-notice" role="status">{{ sessionMessage() }}</div> }
     @if (sessionMessage()) { <div class="session-notice" role="status">{{ sessionMessage() }}</div> }
     @if (auditMessage()) { <div class="audit-notice" role="status">{{ auditMessage() }}</div> }
     @if (auditMessage()) { <div class="audit-notice" role="status">{{ auditMessage() }}</div> }
     @if (mode() === 'mobile') {
     @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>
-      <div class="input-wrap code"><lucide-icon [img]="icons.MessageSquareText" [size]="19" /><input name="code" inputmode="numeric" maxlength="6" [(ngModel)]="code" placeholder="6位短信验证码" /><button type="button" (click)="sendCode()" [disabled]="countdown() > 0">{{ countdown() ? countdown() + '秒' : '获取验证码' }}</button></div>
+      <label class="field-label" for="login-mobile">手机号码</label><div class="input-wrap"><lucide-icon [img]="icons.Phone" [size]="19" /><input aria-label="手机号码" id="login-mobile" name="mobile" type="tel" inputmode="numeric" autocomplete="tel" maxlength="11" [(ngModel)]="mobile" placeholder="11位手机号码" /></div>
+      <label class="field-label" for="login-code">短信验证码</label><div class="input-wrap code"><lucide-icon [img]="icons.MessageSquareText" [size]="19" /><input aria-label="短信验证码" id="login-code" name="code" inputmode="numeric" maxlength="6" [(ngModel)]="code" placeholder="6位短信验证码" /><button type="button" (click)="sendCode()" [disabled]="countdown() > 0">{{ countdown() ? countdown() + '秒' : '获取验证码' }}</button></div>
     } @else {
     } @else {
-      <div class="input-wrap"><lucide-icon [img]="icons.Phone" [size]="19" /><input name="account" autocomplete="username" [(ngModel)]="account" placeholder="手机号码或账号" /></div>
-      <div class="input-wrap"><lucide-icon [img]="icons.LockKeyhole" [size]="19" /><input name="password" [type]="showPassword() ? 'text' : 'password'" autocomplete="current-password" [(ngModel)]="password" placeholder="6至18位密码" /><button class="eye-button" type="button" aria-label="显示或隐藏密码" title="显示或隐藏密码" (click)="showPassword.set(!showPassword())"><lucide-icon [img]="showPassword() ? icons.EyeOff : icons.Eye" [size]="18" /></button></div>
-      @if (registering()) { <div class="input-wrap"><lucide-icon [img]="icons.LockKeyhole" [size]="19" /><input name="confirmPassword" type="password" autocomplete="new-password" [(ngModel)]="confirmPassword" placeholder="再次输入密码" /></div> }
+      <label class="field-label" for="login-account">账号</label><div class="input-wrap"><lucide-icon [img]="icons.Phone" [size]="19" /><input aria-label="账号" id="login-account" name="account" autocomplete="username" [(ngModel)]="account" placeholder="手机号码或账号" /></div>
+      <label class="field-label" for="login-password">密码</label><div class="input-wrap"><lucide-icon [img]="icons.LockKeyhole" [size]="19" /><input aria-label="密码" id="login-password" name="password" [type]="showPassword() ? 'text' : 'password'" autocomplete="current-password" [(ngModel)]="password" [placeholder]="registering() ? '8至72位密码' : '请输入密码'" /><button class="eye-button" type="button" aria-label="显示或隐藏密码" title="显示或隐藏密码" (click)="showPassword.set(!showPassword())"><lucide-icon [img]="showPassword() ? icons.EyeOff : icons.Eye" [size]="18" /></button></div>
+      @if (registering()) { <label class="field-label" for="login-confirmPassword">确认密码</label><div class="input-wrap"><lucide-icon [img]="icons.LockKeyhole" [size]="19" /><input aria-label="确认密码" id="login-confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" [(ngModel)]="confirmPassword" placeholder="再次输入密码" /></div> }
     }
     }
 
 
-    @if (registering()) { <div class="input-wrap"><lucide-icon [img]="icons.UserPlus" [size]="19" /><input name="inviteCode" [(ngModel)]="inviteCode" placeholder="邀请码(选填)" /></div> }
+    @if (registering()) { <label class="field-label" for="login-inviteCode">邀请码(选填)</label><div class="input-wrap"><lucide-icon [img]="icons.UserPlus" [size]="19" /><input aria-label="邀请码" id="login-inviteCode" name="inviteCode" [(ngModel)]="inviteCode" placeholder="邀请码(选填)" /></div> }
 
 
     <label class="privacy"><input name="privacy" type="checkbox" [(ngModel)]="privacyChecked" /><span>我已阅读并同意 <a routerLink="/pages/home/enjoy_item" [queryParams]="{ id: 120243 }">服务协议</a> 和 <a routerLink="/pages/home/enjoy_item" [queryParams]="{ id: 120242 }">隐私政策</a></span></label>
     <label class="privacy"><input name="privacy" type="checkbox" [(ngModel)]="privacyChecked" /><span>我已阅读并同意 <a routerLink="/pages/home/enjoy_item" [queryParams]="{ id: 120243 }">服务协议</a> 和 <a routerLink="/pages/home/enjoy_item" [queryParams]="{ id: 120242 }">隐私政策</a></span></label>
 
 
-    <button class="primary-button submit" type="submit">{{ registering() ? '注册并登录' : '登录' }}</button>
+    <button class="primary-button submit" type="submit" [disabled]="platform.loading()">{{ registering() ? '注册并登录' : '登录' }}</button>
     <button class="switch-register" type="button" (click)="registering.set(!registering())">{{ registering() ? '已有账号,返回登录' : '没有账号,立即注册' }}</button>
     <button class="switch-register" type="button" (click)="registering.set(!registering())">{{ registering() ? '已有账号,返回登录' : '没有账号,立即注册' }}</button>
   </form>
   </form>
 </main>
 </main>

+ 3 - 0
projects/xiaoshu-mobile/src/app/features/auth/login-page.component.scss

@@ -20,3 +20,6 @@ form { display: grid; gap: 14px; }
 .switch-register { min-height: 42px; color: var(--brand-strong); background: transparent; border: 0; }
 .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; }
 .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; }
 .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; }
+
+.field-label { display: block; margin: 12px 0 6px; color: var(--ink); font-size: 14px; font-weight: 600; }
+.eye-button { min-width: 44px; min-height: 44px; }

+ 7 - 0
projects/xiaoshu-mobile/src/app/features/auth/login-page.component.spec.ts

@@ -49,4 +49,11 @@ describe('LoginPageComponent', () => {
     sync.complete();
     sync.complete();
     expect(navigateByUrl).toHaveBeenCalledOnceWith('/pages/home/shop');
     expect(navigateByUrl).toHaveBeenCalledOnceWith('/pages/home/shop');
   });
   });
+  for(const length of [8,18,19,72])it(`accepts an existing ${length}-character password and enforces reset before fetching data`,async()=>{
+    const user={userId:42,sessionToken:'r:temporary',passwordResetRequired:true} as AppUser;
+    const login=jasmine.createSpy('login').and.returnValue(of(user)),navigateByUrl=jasmine.createSpy('navigateByUrl').and.resolveTo(true),post=jasmine.createSpy('post');
+    await TestBed.configureTestingModule({imports:[LoginPageComponent],providers:[{provide:ApiService,useValue:{post}},{provide:AuthService,useValue:{login}},{provide:SessionService,useValue:{setLogin:()=>{},isLoggedIn:()=>true}},{provide:PlatformService,useValue:{loading:signal(false),toast:()=>{}}},{provide:Router,useValue:{navigateByUrl}},{provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({})}}}]}).overrideComponent(LoginPageComponent,{set:{template:''}}).compileComponents();
+    const page=TestBed.createComponent(LoginPageComponent).componentInstance;page.account='student';page.password='p'.repeat(length);page.privacyChecked=true;page.submit();expect(login).toHaveBeenCalled();expect(post).not.toHaveBeenCalled();expect(navigateByUrl).toHaveBeenCalledOnceWith('/pages/member/passwd');
+  });
+
 });
 });

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

@@ -40,11 +40,12 @@ export class LoginPageComponent implements OnInit, OnDestroy {
   private readonly api = inject(ApiService);
   private readonly api = inject(ApiService);
   private readonly auth = inject(AuthService);
   private readonly auth = inject(AuthService);
   private readonly session = inject(SessionService);
   private readonly session = inject(SessionService);
-  private readonly platform = inject(PlatformService);
+  readonly platform = inject(PlatformService);
   private readonly router = inject(Router);
   private readonly router = inject(Router);
   private readonly route = inject(ActivatedRoute);
   private readonly route = inject(ActivatedRoute);
 
 
   ngOnInit(): void {
   ngOnInit(): void {
+    if(this.route.snapshot.queryParamMap.get('passwordChanged')==='1')this.sessionMessage.set('密码已修改,请使用新密码重新登录');
     if (this.route.snapshot.queryParamMap.get('expired') === '1') {
     if (this.route.snapshot.queryParamMap.get('expired') === '1') {
       this.sessionMessage.set('登录状态已过期,请重新登录后继续学习');
       this.sessionMessage.set('登录状态已过期,请重新登录后继续学习');
     }
     }
@@ -80,6 +81,7 @@ export class LoginPageComponent implements OnInit, OnDestroy {
   }
   }
 
 
   submit(): void {
   submit(): void {
+    if (this.platform.loading()) return;
     if (!this.privacyChecked) {
     if (!this.privacyChecked) {
       this.platform.toast('请先阅读并同意服务协议与隐私政策', 'error');
       this.platform.toast('请先阅读并同意服务协议与隐私政策', 'error');
       return;
       return;
@@ -97,7 +99,7 @@ export class LoginPageComponent implements OnInit, OnDestroy {
 
 
   private submitClassic(): void {
   private submitClassic(): void {
     if (!this.account.trim()) { this.platform.toast('请输入账号', 'error'); return; }
     if (!this.account.trim()) { this.platform.toast('请输入账号', 'error'); return; }
-    if (this.password.length < 6 || this.password.length > 18) { this.platform.toast('密码长度应为6至18位', 'error'); return; }
+    if (!this.password || this.password.length > 72 || (this.registering() && this.password.length < 8)) { this.platform.toast(this.registering() ? '新密码长度应为8至72位' : '请输入不超过72位的密码', 'error'); return; }
     if (this.registering() && this.password !== this.confirmPassword) { this.platform.toast('两次输入的密码不一致', 'error'); return; }
     if (this.registering() && this.password !== this.confirmPassword) { this.platform.toast('两次输入的密码不一致', 'error'); return; }
     if (this.registering()) {
     if (this.registering()) {
       const payload = { name: this.account, passwd: this.password, code: this.inviteCode };
       const payload = { name: this.account, passwd: this.password, code: this.inviteCode };
@@ -146,6 +148,7 @@ export class LoginPageComponent implements OnInit, OnDestroy {
       this.platform.toast('登录状态无效,请重新登录', 'error');
       this.platform.toast('登录状态无效,请重新登录', 'error');
       return;
       return;
     }
     }
+    if (user['passwordResetRequired'] === true) { this.platform.loading.set(false); void this.router.navigateByUrl('/pages/member/passwd'); return; }
     const enter = (synced: boolean) => {
     const enter = (synced: boolean) => {
       this.platform.toast(synced ? (registered ? '注册成功' : '登录成功') : '登录成功,数据同步暂不可用,请稍后手动刷新', synced ? 'success' : 'error');
       this.platform.toast(synced ? (registered ? '注册成功' : '登录成功') : '登录成功,数据同步暂不可用,请稍后手动刷新', synced ? 'success' : 'error');
       const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl');
       const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl');

+ 2 - 0
projects/xiaoshu-mobile/src/app/features/auth/password-page.component.spec.ts

@@ -0,0 +1,2 @@
+import {TestBed} from '@angular/core/testing';import {Router} from '@angular/router';import {Subject} from 'rxjs';import {PasswordPageComponent} from './password-page.component';import {ApiService} from '../../core/api.service';import {SessionService} from '../../core/session.service';
+describe('Password change',()=>{it('keeps entered values on failure, unlocks retry and clears all credentials after success',()=>{let reply=new Subject();const post=jasmine.createSpy('post').and.callFake(()=>reply.asObservable()),logout=jasmine.createSpy('logout'),navigate=jasmine.createSpy('navigate').and.resolveTo(true);TestBed.configureTestingModule({providers:[{provide:ApiService,useValue:{post}},{provide:SessionService,useValue:{user:()=>({}),logout}},{provide:Router,useValue:{navigate}}]});const page=TestBed.runInInjectionContext(()=>new PasswordPageComponent());page.oldPassword='old-password';page.newPassword=page.confirmation='new-password';page.save();reply.error(new Error('网络暂不可用'));expect(page.saving()).toBeFalse();expect(page.newPassword).toBe('new-password');expect(logout).not.toHaveBeenCalled();reply=new Subject();page.save();reply.next({});reply.complete();expect(logout).toHaveBeenCalledOnceWith(false);expect(page.oldPassword+page.newPassword+page.confirmation).toBe('');expect(navigate).toHaveBeenCalled();});});

+ 14 - 0
projects/xiaoshu-mobile/src/app/features/auth/password-page.component.ts

@@ -0,0 +1,14 @@
+import {Component, inject, signal} from '@angular/core';
+import {FormsModule} from '@angular/forms';
+import {Router} from '@angular/router';
+import {finalize} from 'rxjs';
+import {ApiService} from '../../core/api.service';
+import {SessionService} from '../../core/session.service';
+@Component({selector:'app-password-page',standalone:true,imports:[FormsModule],template:`
+ <main><h1>修改登录密码</h1><p>{{sessions.user()?.['passwordResetRequired'] ? '请先将临时密码改为自己的密码,再继续使用。' : '修改成功后,需要重新登录。'}}</p>
+ <form (ngSubmit)="save()"><fieldset [disabled]="saving()"><label>当前密码<input name="old" type="password" autocomplete="current-password" [(ngModel)]="oldPassword" required></label><label>新密码<input name="new" type="password" autocomplete="new-password" [(ngModel)]="newPassword" minlength="8" maxlength="72" required></label><label>确认新密码<input name="confirm" type="password" autocomplete="new-password" [(ngModel)]="confirmation" required></label><p>新密码为 8 至 72 位。</p>@if(error()){<p role="alert">{{error()}}</p>}<button class="primary-button" type="submit">{{saving()?'正在保存…':'保存并重新登录'}}</button></fieldset></form></main>`,styles:[`main{max-width:520px;margin:24px auto;padding:20px}fieldset{border:0;padding:0;display:grid;gap:16px}label{display:grid;gap:8px}input,button{min-height:44px}input{border:1px solid #cadbd6;border-radius:10px;padding:10px}p[role=alert]{color:#b42318}`]})
+export class PasswordPageComponent {
+ readonly sessions=inject(SessionService);private readonly api=inject(ApiService);private readonly router=inject(Router);
+ oldPassword='';newPassword='';confirmation='';readonly saving=signal(false);readonly error=signal('');
+ save(){if(this.saving())return;this.error.set('');if(!this.oldPassword||this.newPassword.length<8||this.newPassword.length>72||this.newPassword!==this.confirmation){this.error.set('请填写当前密码及一致的 8 至 72 位新密码');return;}this.saving.set(true);this.api.post('app_password_change',{currentPassword:this.oldPassword,newPassword:this.newPassword}).pipe(finalize(()=>this.saving.set(false))).subscribe({next:()=>{this.oldPassword=this.newPassword=this.confirmation='';this.sessions.logout(false);void this.router.navigate(['/pages/member/wxauth'],{queryParams:{passwordChanged:1}});},error:e=>this.error.set(e.message)});}
+}

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

@@ -4,15 +4,15 @@
     }
     }
     @if (!isCoach) {
     @if (!isCoach) {
       <section class="course-band">
       <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>{{ (loading()||error())&&!courses().length ? '—' : courses().length }} 项</span></header>
         @if (courses().length) {
         @if (courses().length) {
           <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>{{ courseCategory(item) ? courseCategory(item)+' · ' : '' }}{{ courseTitle(item) }}</strong><small>{{ courseHint(item) }}</small></span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button></li> }</ul>
           <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>{{ courseCategory(item) ? courseCategory(item)+' · ' : '' }}{{ 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> }
+        } @else { <p class="band-empty">{{ error() ? '学习内容暂时无法加载,请重试' : loading() ? '正在读取学习内容…' : '暂未分配学习内容,请联系老师' }}</p> }
       </section>
       </section>
     }
     }
 
 
     <section class="schedule-band">
     <section class="schedule-band">
-      <header><lucide-icon [img]="isCoach ? icons.GraduationCap : icons.CalendarDays" [size]="21" /><h2>{{ isHistory ? '销课记录' : isCoach ? '陪练排课' : '伴学列表' }}</h2><span>{{ isHistory || hasScheduleCalendar ? visibleSchedules().length + ' / ' + schedules().length : schedules().length }} 节</span></header>
+      <header><lucide-icon [img]="isCoach ? icons.GraduationCap : icons.CalendarDays" [size]="21" /><h2>{{ isHistory ? '销课记录' : isCoach ? '陪练排课' : '伴学列表' }}</h2><span>{{ (loading()||error())&&!schedules().length ? '—' : isHistory || hasScheduleCalendar ? visibleSchedules().length + ' / ' + schedules().length : schedules().length }} 节</span></header>
       <nav class="range-tabs" aria-label="课表操作"><button type="button" (click)="load(true)">刷新课表</button><button type="button" (click)="clearScheduleFilters()">清除筛选</button>@if (nextScheduleDate() && !isHistory) { <button type="button" (click)="showNextSchedule()">最近一节 · {{nextScheduleDate()}}</button> }</nav>
       <nav class="range-tabs" aria-label="课表操作"><button type="button" (click)="load(true)">刷新课表</button><button type="button" (click)="clearScheduleFilters()">清除筛选</button>@if (nextScheduleDate() && !isHistory) { <button type="button" (click)="showNextSchedule()">最近一节 · {{nextScheduleDate()}}</button> }</nav>
       @if (isHistory) {
       @if (isHistory) {
         <section class="history-filter-panel" aria-label="销课记录筛选">
         <section class="history-filter-panel" aria-label="销课记录筛选">
@@ -61,9 +61,9 @@
           </div>
           </div>
 
 
           <nav class="schedule-filters" [attr.aria-label]="isCoach ? '陪练排课状态筛选' : '伴学记录筛选'">
           <nav class="schedule-filters" [attr.aria-label]="isCoach ? '陪练排课状态筛选' : '伴学记录筛选'">
-            <button type="button" [class.active]="scheduleFilter() === 'all'" (click)="setScheduleFilter('all')">全部 <span>{{ scopedSchedules().length }}</span></button>
-            <button type="button" [class.active]="scheduleFilter() === 'pending'" (click)="setScheduleFilter('pending')">计划未完成 <span>{{ pendingCount() }}</span></button>
-            <button type="button" [class.active]="scheduleFilter() === 'finished'" (click)="setScheduleFilter('finished')">已结束 <span>{{ finishedCount() }}</span></button>
+            <button type="button" [class.active]="scheduleFilter() === 'all'" (click)="setScheduleFilter('all')">全部 <span>{{ (loading()||error())&&!schedules().length ? '—' : scopedSchedules().length }}</span></button>
+            <button type="button" [class.active]="scheduleFilter() === 'pending'" (click)="setScheduleFilter('pending')">计划未完成 <span>{{ (loading()||error())&&!schedules().length ? '—' : pendingCount() }}</span></button>
+            <button type="button" [class.active]="scheduleFilter() === 'finished'" (click)="setScheduleFilter('finished')">已结束 <span>{{ (loading()||error())&&!schedules().length ? '—' : finishedCount() }}</span></button>
           </nav>
           </nav>
 
 
           <div class="calendar-panel">
           <div class="calendar-panel">
@@ -96,13 +96,13 @@
             </div>
             </div>
           </div>
           </div>
 
 
-          <p class="schedule-summary">{{ scheduleRangeLabel() }} · {{ visibleSchedules().length }} 节课</p>
+          <p class="schedule-summary">{{ scheduleRangeLabel() }} · {{(loading()||error())&&!schedules().length?'—':visibleSchedules().length}} 节课</p>
         </div>
         </div>
       } @else if (!isCoach && schedules().length) {
       } @else if (!isCoach && schedules().length) {
         <nav class="schedule-filters" aria-label="伴学记录筛选">
         <nav class="schedule-filters" aria-label="伴学记录筛选">
           <button type="button" [class.active]="scheduleFilter() === 'all'" (click)="setScheduleFilter('all')">全部 <span>{{ listTotal() }}</span></button>
           <button type="button" [class.active]="scheduleFilter() === 'all'" (click)="setScheduleFilter('all')">全部 <span>{{ listTotal() }}</span></button>
-          <button type="button" [class.active]="scheduleFilter() === 'pending'" (click)="setScheduleFilter('pending')">计划未完成 <span>{{ pendingCount() }}</span></button>
-          <button type="button" [class.active]="scheduleFilter() === 'finished'" (click)="setScheduleFilter('finished')">已结束 <span>{{ finishedCount() }}</span></button>
+          <button type="button" [class.active]="scheduleFilter() === 'pending'" (click)="setScheduleFilter('pending')">计划未完成 <span>{{ (loading()||error())&&!schedules().length ? '—' : pendingCount() }}</span></button>
+          <button type="button" [class.active]="scheduleFilter() === 'finished'" (click)="setScheduleFilter('finished')">已结束 <span>{{ (loading()||error())&&!schedules().length ? '—' : finishedCount() }}</span></button>
         </nav>
         </nav>
       }
       }
       @if (visibleSchedules().length) {
       @if (visibleSchedules().length) {
@@ -120,10 +120,10 @@
             }
             }
           </div>
           </div>
         }
         }
-      } @else { <p class="band-empty">{{ loading() ? '正在读取排课数据…' : (isHistory && schedules().length && historyFilterActive() ? '没有符合筛选条件的销课记录' : (hasScheduleCalendar ? scheduleEmptyText() : (schedules().length ? '该分类暂无伴学记录' : (isCoach ? '暂无销课记录' : '暂无伴学记录')))) }}</p> }
+      } @else { <p class="band-empty">{{ error() ? '课表暂时无法加载,请重试' : loading() ? '正在读取排课数据…' : (isHistory && schedules().length && historyFilterActive() ? '没有符合筛选条件的销课记录' : (hasScheduleCalendar ? scheduleEmptyText() : (schedules().length ? '该分类暂无伴学记录' : (isCoach ? '暂无销课记录' : '暂无伴学记录')))) }}</p> }
     </section>
     </section>
 
 
     @if (error()) {
     @if (error()) {
-      <div class="center-state error"><lucide-icon [img]="icons.AlertCircle" [size]="25" /><span>{{ error() }}</span><button type="button" class="secondary-button" (click)="load(true)">重新加载</button></div>
+      <div class="center-state error"><lucide-icon [img]="icons.AlertCircle" [size]="25" /><span>{{ error() }}{{schedules().length?',当前保留上次加载结果':''}}</span><button type="button" class="secondary-button" (click)="load(true)">重新加载</button></div>
     }
     }
-<nav class="mobile-pagination"><button (click)="turnPage(-1)" [disabled]="loading()||listPage()<=1">上一页</button><span>共 {{listTotal()}} 条 · {{listPage()}} / {{listPages()}}</span><button (click)="turnPage(1)" [disabled]="loading()||listPage()>=listPages()">下一页</button></nav></section>
+<nav class="mobile-pagination"><button (click)="turnPage(-1)" [disabled]="loading()||listPage()<=1">上一页</button><span>共 {{(loading()||error())&&!schedules().length?'—':listTotal()}} 条 · {{listPage()}} / {{listPages()}}</span><button (click)="turnPage(1)" [disabled]="loading()||listPage()>=listPages()">下一页</button></nav></section>

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

@@ -19,31 +19,32 @@
     <div class="companion-summary">
     <div class="companion-summary">
       <span>累计陪练</span>
       <span>累计陪练</span>
       @if (session.groupId() === 3 && coachPanelLoading()) { <strong>正在同步...</strong> }
       @if (session.groupId() === 3 && coachPanelLoading()) { <strong>正在同步...</strong> }
+      @else if (!refreshedAt()) { <strong>—</strong><em>统计暂不可用</em> }
       @else if (session.groupId() === 3) { <strong>{{ coachPanel().totalHours | number:'1.0-1' }} 小时</strong><em>上课 {{ coachPanel().totalLessons }} 次</em> }
       @else if (session.groupId() === 3) { <strong>{{ coachPanel().totalHours | number:'1.0-1' }} 小时</strong><em>上课 {{ coachPanel().totalLessons }} 次</em> }
       @else if (companionLoading()) { <strong>正在同步...</strong> }
       @else if (companionLoading()) { <strong>正在同步...</strong> }
       @else { <strong>{{ companionSummary().durationLabel }}</strong><em>已结束 {{ companionSummary().completedSessions }} 次</em> }
       @else { <strong>{{ companionSummary().durationLabel }}</strong><em>已结束 {{ companionSummary().completedSessions }} 次</em> }
     </div>
     </div>
     @if (session.groupId() === 3) {
     @if (session.groupId() === 3) {
       <div class="learning-rights">
       <div class="learning-rights">
-        <div><strong>{{ coachPanel().course30 }}</strong><span>30分钟课程</span></div>
-        <div><strong>{{ coachPanel().course60 }}</strong><span>60分钟课程</span></div>
-        <div><strong>{{ coachPanel().trialLessons }}</strong><span>体验课</span></div>
+        <div><strong>{{ refreshedAt()?coachPanel().course30:'—' }}</strong><span>30分钟课程</span></div>
+        <div><strong>{{ refreshedAt()?coachPanel().course60:'—' }}</strong><span>60分钟课程</span></div>
+        <div><strong>{{ refreshedAt()?coachPanel().trialLessons:'—' }}</strong><span>体验课</span></div>
       </div>
       </div>
     } @else {
     } @else {
       <div class="learning-rights">
       <div class="learning-rights">
-        <div><strong>{{ membership().remainingCompanionHours }}</strong><span>剩余陪练时长</span></div>
-        <div><strong>{{ membership().software30 }}</strong><span>剩余30分钟软件</span></div>
-        <div><strong>{{ membership().software60 }}</strong><span>剩余60分钟软件</span></div>
+        <div><strong>{{ refreshedAt()?membership().remainingCompanionHours:'—' }}</strong><span>剩余陪练时长</span></div>
+        <div><strong>{{ refreshedAt()?membership().software30:'—' }}</strong><span>剩余30分钟软件</span></div>
+        <div><strong>{{ refreshedAt()?membership().software60:'—' }}</strong><span>剩余60分钟软件</span></div>
       </div>
       </div>
     }
     }
   </section>
   </section>
   @if (session.groupId() === 3) {
   @if (session.groupId() === 3) {
-    <section class="wallet-strip coach-wallet-strip"><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ coachPanel().reviewIncome | number:'1.0-2' }}</strong><span>抗遗忘工资</span></a><a routerLink="/pages/member/feedback"><strong>申请</strong><span>休息时间</span></a></section>
+    <section class="wallet-strip coach-wallet-strip"><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ refreshedAt()?(coachPanel().reviewIncome | number:'1.0-2'):'—' }}</strong><span>抗遗忘工资</span></a><a routerLink="/pages/member/feedback"><strong>申请</strong><span>休息时间</span></a></section>
   } @else {
   } @else {
-    <section class="wallet-strip"><a routerLink="/pages/member/purse"><strong>查看</strong><span>课时余额与预留</span></a><a routerLink="/pages/member/point"><strong>{{ session.user()?.['UserPoint'] || 0 }}</strong><span>可查陪练时长</span></a></section>
+    <section class="wallet-strip"><a routerLink="/pages/member/purse"><strong>查看</strong><span>课时余额与预留</span></a><a routerLink="/pages/member/point"><strong>{{ refreshedAt()?(session.user()?.['UserPoint']??'—'):'—' }}</strong><span>可查陪练时长</span></a></section>
   }
   }
   @if (session.groupId() === 3) {
   @if (session.groupId() === 3) {
-    <section class="role-panel"><header><h2>陪练专用</h2><a routerLink="/pages/member/feedback">休息时间申请</a></header><div class="order-strip coach-strip"><a routerLink="/pages/home/shop"><strong>{{ coachPanel().todaySchedules }}</strong><span>今日排课</span></a><a routerLink="/pages/member/ke_xiao"><strong>{{ coachPanel().completedLessons }}</strong><span>销课记录</span></a><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ coachPanel().reviewIncome | number:'1.0-2' }}</strong><span>抗遗忘工资</span></a></div></section>
+    <section class="role-panel"><header><h2>陪练专用</h2><a routerLink="/pages/member/feedback">休息时间申请</a></header><div class="order-strip coach-strip"><a routerLink="/pages/home/shop"><strong>{{ refreshedAt()?coachPanel().todaySchedules:'—' }}</strong><span>今日排课</span></a><a routerLink="/pages/member/ke_xiao"><strong>{{ refreshedAt()?coachPanel().completedLessons:'—' }}</strong><span>销课记录</span></a><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ refreshedAt()?(coachPanel().reviewIncome | number:'1.0-2'):'—' }}</strong><span>抗遗忘工资</span></a></div></section>
   } @else {
   } @else {
     <section class="role-panel course-memory">
     <section class="role-panel course-memory">
       <header><h2>我的课程</h2><a routerLink="/pages/stu/stu_calendar">科学抗遗忘</a></header>
       <header><h2>我的课程</h2><a routerLink="/pages/stu/stu_calendar">科学抗遗忘</a></header>
@@ -75,8 +76,8 @@
 
 
 <section class="settings-list">
 <section class="settings-list">
   <a routerLink="/pages/home/app_update"><lucide-icon [img]="icons.CircleHelp" [size]="20" /><span>当前版本:{{ version }}</span><lucide-icon [img]="icons.ChevronRight" [size]="17" /></a>
   <a routerLink="/pages/home/app_update"><lucide-icon [img]="icons.CircleHelp" [size]="20" /><span>当前版本:{{ version }}</span><lucide-icon [img]="icons.ChevronRight" [size]="17" /></a>
-  <a href="https://a055.2018.z01.com/Item/41414" target="_blank" rel="noopener"><lucide-icon [img]="icons.ShieldCheck" [size]="20" /><span>隐私政策</span><lucide-icon [img]="icons.ChevronRight" [size]="17" /></a>
-  <a href="https://a055.2018.z01.com/Item/41415" target="_blank" rel="noopener"><lucide-icon [img]="icons.ShieldCheck" [size]="20" /><span>服务协议</span><lucide-icon [img]="icons.ChevronRight" [size]="17" /></a>
+  <a routerLink="/pages/home/enjoy_item" [queryParams]="{ id: 120242 }"><lucide-icon [img]="icons.ShieldCheck" [size]="20" /><span>隐私政策</span><lucide-icon [img]="icons.ChevronRight" [size]="17" /></a>
+  <a routerLink="/pages/home/enjoy_item" [queryParams]="{ id: 120243 }"><lucide-icon [img]="icons.ShieldCheck" [size]="20" /><span>服务协议</span><lucide-icon [img]="icons.ChevronRight" [size]="17" /></a>
   @if (session.isLoggedIn()) { <a routerLink="/pages/member/acc_destory"><lucide-icon [img]="icons.ShieldCheck" [size]="20" /><span>隐私注销通道</span><lucide-icon [img]="icons.ChevronRight" [size]="17" /></a><button type="button" (click)="logout()"><lucide-icon [img]="icons.LogOut" [size]="20" /><span>退出登录</span></button> }
   @if (session.isLoggedIn()) { <a routerLink="/pages/member/acc_destory"><lucide-icon [img]="icons.ShieldCheck" [size]="20" /><span>隐私注销通道</span><lucide-icon [img]="icons.ChevronRight" [size]="17" /></a><button type="button" (click)="logout()"><lucide-icon [img]="icons.LogOut" [size]="20" /><span>退出登录</span></button> }
 </section>
 </section>
 <footer>南昌小树陪练科技有限公司</footer>
 <footer>南昌小树陪练科技有限公司</footer>

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts

@@ -142,7 +142,7 @@ export class AccountPageComponent implements OnInit, OnDestroy {
   liveStatusText(): string {
   liveStatusText(): string {
     const status = this.liveSyncStatus();
     const status = this.liveSyncStatus();
     if (status.state === 'delayed') return '数据同步延迟,正在追平';
     if (status.state === 'delayed') return '数据同步延迟,正在追平';
-    if (status.state === 'unavailable') return '实时同步暂时不可用';
+    if (status.state === 'unavailable') return this.refreshedAt()?'暂时无法刷新,当前为上次结果':'实时同步暂时不可用';
     return this.refreshedAt() ? `更新于 ${new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(new Date(this.refreshedAt()))}` : '正在核对最新数据';
     return this.refreshedAt() ? `更新于 ${new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(new Date(this.refreshedAt()))}` : '正在核对最新数据';
   }
   }
 
 

+ 7 - 5
projects/xiaoshu-mobile/src/app/features/home/home-page.component.html

@@ -93,12 +93,14 @@
       <a class="memory-footer" routerLink="/pages/stu/stu_calendar"><span><i><lucide-icon [img]="icons.Zap" [size]="16" /></i>坚持每天学习,记得更牢,成绩更好!</span><strong>查看计划<lucide-icon [img]="icons.ChevronRight" [size]="16" /></strong></a>
       <a class="memory-footer" routerLink="/pages/stu/stu_calendar"><span><i><lucide-icon [img]="icons.Zap" [size]="16" /></i>坚持每天学习,记得更牢,成绩更好!</span><strong>查看计划<lucide-icon [img]="icons.ChevronRight" [size]="16" /></strong></a>
     </section>
     </section>
 
 
+    @if(coachStatsError() || balanceError()){<p role="status">{{coachStatsError() || balanceError()}} <button type="button" (click)="retryStudentStats()">重新加载</button></p>}
+      @else if(balanceRefreshedAt()){<small>余额更新于 {{balanceRefreshedAt()}}</small>}
     <section class="student-stats" aria-label="学习统计">
     <section class="student-stats" aria-label="学习统计">
-      <div><strong>{{ stats()['t_total'] || 0 }}</strong><span>累积生词</span></div>
-      <div><strong>{{ session.user()?.['UserPoint'] || 0 }}</strong><span>剩余陪练时长</span></div>
-      <div><strong>{{ stats()['t_shichang'] || 0 }}</strong><span>累计陪练时长</span></div>
-      <div><strong>{{ session.user()?.purse || 0 }}</strong><span>30分钟软件时长</span></div>
-      <div><strong>{{ session.user()?.silverCoin || session.user()?.['SilverCoin'] || 0 }}</strong><span>60分钟软件时长</span></div>
+      <div><strong>{{ stat('t_total') }}</strong><span>累积生词</span></div>
+      <div><strong>{{ balance('UserPoint') }}</strong><span>剩余陪练时长</span></div>
+      <div><strong>{{ stat('t_shichang') }}</strong><span>累计陪练时长</span></div>
+      <div><strong>{{ balance('purse','Purse') }}</strong><span>30分钟软件时长</span></div>
+      <div><strong>{{ balance('silverCoin','SilverCoin') }}</strong><span>60分钟软件时长</span></div>
     </section>
     </section>
   }
   }
 
 

+ 14 - 2
projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts

@@ -25,6 +25,11 @@ export class HomePageComponent implements OnInit, OnDestroy {
   readonly stats = signal<Record<string, number>>({});
   readonly stats = signal<Record<string, number>>({});
   readonly statsLoading = signal(false);
   readonly statsLoading = signal(false);
   readonly coachStatsError = signal('');
   readonly coachStatsError = signal('');
+  readonly statsAvailable=signal(false);
+  readonly balances=signal<Record<string,unknown>|null>(null);
+  readonly balanceError=signal('');
+  readonly balanceLoading=signal(false);
+  readonly balanceRefreshedAt=signal('');
   readonly commissionHidden = signal(true);
   readonly commissionHidden = signal(true);
   readonly antiForgetItems = signal<Item[]>([]);
   readonly antiForgetItems = signal<Item[]>([]);
   readonly antiForgetLoading = signal(false);
   readonly antiForgetLoading = signal(false);
@@ -52,6 +57,7 @@ export class HomePageComponent implements OnInit, OnDestroy {
     const uid = this.session.user()?.userId;
     const uid = this.session.user()?.userId;
     if (uid) {
     if (uid) {
       this.loadStats(uid);
       this.loadStats(uid);
+      if(!this.isCoach())this.loadBalances(uid);
       this.loadAntiForget(uid);
       this.loadAntiForget(uid);
       this.stopLiveRefresh = this.liveData.watch({
       this.stopLiveRefresh = this.liveData.watch({
         scopes: ['appointments', 'review', 'account'],
         scopes: ['appointments', 'review', 'account'],
@@ -60,6 +66,7 @@ export class HomePageComponent implements OnInit, OnDestroy {
           const currentId = this.session.user()?.userId;
           const currentId = this.session.user()?.userId;
           if (!currentId) return;
           if (!currentId) return;
           this.loadStats(currentId, true);
           this.loadStats(currentId, true);
+          if(!this.isCoach())this.loadBalances(currentId);
           this.loadAntiForget(currentId, true);
           this.loadAntiForget(currentId, true);
         },
         },
       });
       });
@@ -142,9 +149,9 @@ export class HomePageComponent implements OnInit, OnDestroy {
     this.api.post<Record<string, number>>('e_order_tongji', { uid }).pipe(
     this.api.post<Record<string, number>>('e_order_tongji', { uid }).pipe(
       finalize(() => { if (!background) this.statsLoading.set(false); }),
       finalize(() => { if (!background) this.statsLoading.set(false); }),
     ).subscribe({
     ).subscribe({
-      next: ({ result }) => this.stats.set(result || {}),
+      next: ({ result }) => {this.stats.set(result || {});this.statsAvailable.set(Boolean(result));this.coachStatsError.set('');},
       error: () => {
       error: () => {
-        if (!background && this.isCoach()) this.coachStatsError.set('今日业务数据暂时无法加载');
+        this.coachStatsError.set(this.statsAvailable()?'统计暂时无法更新,当前显示上次结果':'统计暂时无法加载');
       },
       },
     });
     });
   }
   }
@@ -162,6 +169,11 @@ export class HomePageComponent implements OnInit, OnDestroy {
     });
     });
   }
   }
 
 
+  retryStudentStats():void { const uid=this.session.user()?.userId;if(uid){this.loadStats(uid);this.loadBalances(uid);} }
+  private loadBalances(uid:string|number):void {this.balanceLoading.set(true);this.api.get<{profile:Record<string,unknown>}>('app_account_overview',{uid}).pipe(finalize(()=>this.balanceLoading.set(false))).subscribe({next:({result})=>{this.balances.set(result.profile);this.balanceError.set('');this.balanceRefreshedAt.set(new Date().toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour:'2-digit',minute:'2-digit'}));},error:()=>this.balanceError.set('课时余额暂时无法更新')});}
+  stat(key:string):string|number {const value=this.stats()[key];return value!=null&&Number.isFinite(Number(value))?Number(value):'—';}
+  balance(...keys:string[]):string|number {const p=this.balances();if(!p)return '—';for(const key of keys)if(p[key]!=null&&p[key]!==''&&Number.isFinite(Number(p[key])))return Number(p[key]);return '—';}
+
   private today(): string {
   private today(): string {
     const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date());
     const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date());
     const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? '';
     const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? '';

+ 4 - 0
projects/xiaoshu-mobile/src/app/features/webview/webview-page.component.spec.ts

@@ -0,0 +1,4 @@
+import { approvedWebUrl } from './webview-page.component';
+describe('External page policy',()=>{
+ it('allows only exact HTTPS origins without credentials or custom ports',()=>{expect(approvedWebUrl('https://test.fmode.cn/engxs/Class_27/Default?sid=2')).toContain('https://test.fmode.cn/');for(const value of ['http://test.fmode.cn','https://test.fmode.cn.evil.test','https://test.fmode.cn@evil.test','https://name:secret@test.fmode.cn','https://test.fmode.cn:444','javascript:alert(1)','data:text/html,a','https%3A%2F%2Ftest.fmode.cn','//test.fmode.cn','https://evil.test'])expect(approvedWebUrl(value)).withContext(value).toBe('');});
+});

+ 15 - 7
projects/xiaoshu-mobile/src/app/features/webview/webview-page.component.ts

@@ -13,9 +13,9 @@ import { PageMeta } from '../../core/models';
   template: `
   template: `
     <section class="webview-page">
     <section class="webview-page">
       @if (url) {
       @if (url) {
-        <div class="webview-toolbar"><span>{{ toolbarUrl }}</span><a [href]="displayUrl" target="_blank" rel="noopener" aria-label="在新窗口打开" title="在新窗口打开"><lucide-icon [img]="ExternalLink" [size]="18" /></a></div>
-        <iframe [src]="url" [title]="meta.title" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-forms allow-scripts allow-same-origin allow-popups allow-downloads"></iframe>
-      } @else { <div class="empty-state"><p>未提供可打开的网址</p></div> }
+        <div class="webview-toolbar"><span>外部页面 · {{ toolbarUrl }}</span><a [href]="displayUrl" target="_blank" rel="noopener" aria-label="在新窗口打开" title="在新窗口打开"><lucide-icon [img]="ExternalLink" [size]="18" /></a></div>
+        <iframe [src]="url" [title]="meta.title" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts"></iframe>
+      } @else { <div class="empty-state"><p>该网址未获允许,请通过应用内的课程或帮助入口访问</p></div> }
     </section>
     </section>
   `,
   `,
   styles: [`
   styles: [`
@@ -36,24 +36,32 @@ export class WebviewPageComponent {
   readonly url: SafeResourceUrl | null = this.displayUrl ? inject(DomSanitizer).bypassSecurityTrustResourceUrl(this.displayUrl) : null;
   readonly url: SafeResourceUrl | null = this.displayUrl ? inject(DomSanitizer).bypassSecurityTrustResourceUrl(this.displayUrl) : null;
   private resolveUrl(): string {
   private resolveUrl(): string {
     const direct = this.route.snapshot.queryParamMap.get('url');
     const direct = this.route.snapshot.queryParamMap.get('url');
-    if (direct && /^https?:\/\//i.test(direct)) return direct;
+    if (direct) return approvedWebUrl(direct);
     const packed = this.route.snapshot.queryParamMap.get('params');
     const packed = this.route.snapshot.queryParamMap.get('params');
     if (packed) {
     if (packed) {
       try {
       try {
         const params = JSON.parse(packed) as { url?: string };
         const params = JSON.parse(packed) as { url?: string };
-        if (params.url && /^https?:\/\//i.test(params.url)) return params.url;
+        if (params.url) return approvedWebUrl(params.url);
       } catch {
       } catch {
         try {
         try {
           const params = JSON.parse(decodeURIComponent(packed)) as { url?: string };
           const params = JSON.parse(decodeURIComponent(packed)) as { url?: string };
-          if (params.url && /^https?:\/\//i.test(params.url)) return params.url;
+          if (params.url) return approvedWebUrl(params.url);
         } catch { return ''; }
         } catch { return ''; }
       }
       }
     }
     }
     if (this.meta.path === 'pages/shop/baobiao') {
     if (this.meta.path === 'pages/shop/baobiao') {
       const sid = this.route.snapshot.queryParamMap.get('sid') ?? this.route.snapshot.queryParamMap.get('id') ?? '';
       const sid = this.route.snapshot.queryParamMap.get('sid') ?? this.route.snapshot.queryParamMap.get('id') ?? '';
       if (!sid) return '';
       if (!sid) return '';
-      return `${API_CONFIG.baseUrl}/Class_27/Default?sid=${encodeURIComponent(sid)}`;
+      return approvedWebUrl(`https://test.fmode.cn/engxs/Class_27/Default?sid=${encodeURIComponent(sid)}`);
     }
     }
     return '';
     return '';
   }
   }
 }
 }
+
+export function approvedWebUrl(raw: string): string {
+  try {
+    const url = new URL(raw);
+    if (url.protocol !== 'https:' || url.username || url.password || url.port || !['test.fmode.cn', 'a018.2018.z01.com'].includes(url.hostname)) return '';
+    return url.href;
+  } catch { return ''; }
+}

+ 15 - 7
scripts/audit-app-cloud-actions.mjs

@@ -2,6 +2,7 @@
 
 
 import { readdir, readFile } from 'node:fs/promises';
 import { readdir, readFile } from 'node:fs/promises';
 import path from 'node:path';
 import path from 'node:path';
+import vm from 'node:vm';
 
 
 const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
 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(/\/$/, '');
 const FUNCTION_URL = (process.env.XIAOSHU_FUNCTION_URL || 'https://server.xiaoshu.pro/api/functions').replace(/\/$/, '');
@@ -33,18 +34,25 @@ const parityMatch = paritySource.match(/export const SOURCE_API_ACTIONS = (\[[\s
 if (!parityMatch) throw new Error('无法读取 SOURCE_API_ACTIONS');
 if (!parityMatch) throw new Error('无法读取 SOURCE_API_ACTIONS');
 for (const action of JSON.parse(parityMatch[1])) requested.add(String(action));
 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}`);
+let payload;
+const local=process.argv.includes('--local');
+if(local){
+ const source=await readFile(new URL('./deploy-admin-functions.mjs',import.meta.url),'utf8');
+ const code=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)?.[1];if(!code)throw Error('Cannot extract local gateway');
+ const context=vm.createContext({console,process:{env:{}},Date});vm.runInContext(code+'\n'+await readFile(new URL('./cloud/mobile-teaching.js',import.meta.url),'utf8'),context);
+ const response={json:body=>{payload=body;return response;},status:()=>response};
+ await context.handler({body:{params:{action:'migration_status'}}},response);
+ if(!payload?.result)throw Error('Local migration_status failed');
+}else{
+ 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(20000)});
+ payload=await response.json().catch(()=>({}));if(!response.ok||!payload.result)throw Error(`migration_status 请求失败:${response.status}`);
+}
 const implemented = new Set(Array.isArray(payload.result.implemented) ? payload.result.implemented.map(String) : []);
 const implemented = new Set(Array.isArray(payload.result.implemented) ? payload.result.implemented.map(String) : []);
 const blocked = new Set(Object.keys(payload.result.blocked || {}));
 const blocked = new Set(Object.keys(payload.result.blocked || {}));
 const covered = new Set([...implemented, ...blocked, ...(payload.result.disabledActions || [])]);
 const covered = new Set([...implemented, ...blocked, ...(payload.result.disabledActions || [])]);
 const missing = [...requested].filter((action) => !covered.has(action) && !action.startsWith('ops/')).sort();
 const missing = [...requested].filter((action) => !covered.has(action) && !action.startsWith('ops/')).sort();
 const report = {
 const report = {
+  source:local?'local-code':'online-gateway',
   passed: missing.length === 0,
   passed: missing.length === 0,
   requested: requested.size,
   requested: requested.size,
   implemented: implemented.size,
   implemented: implemented.size,

+ 5 - 0
scripts/cloud/account-deletion-ops.js

@@ -0,0 +1,5 @@
+async function opsDeletion(context,input,payload,process=false){
+ requireRole(context,['ops-manager']);const co=companyIdOf(context,input);if(!co)fail(400,'缺少帐套');
+ if(process)return(await Psql.one('SELECT xs_process_deletion($1,$2,$3,$4,$5,$6) AS result',[co,context.current.id,text(input.targetId),number(payload.version),text(payload.status),reasonOf(input)])).result;
+ const page=Math.max(1,number(input.page,1));return{items:await Psql.query('SELECT r.*,u."legacyUserId",COALESCE(u."realName",u.nickname,u.username) AS name FROM "AccountDeletionRequest" r LEFT JOIN "_User" u ON u.company=r.company AND u."objectId"=r."userId" WHERE r.company=$1 ORDER BY r."createdAt" DESC LIMIT 50 OFFSET $2',[co,(page-1)*50])};
+}

+ 5 - 0
scripts/cloud/account-deletion.js

@@ -0,0 +1,5 @@
+async function appDeletion(input,current){
+ const co=DEFAULT_COMPANY_ID;
+ if(input.action==='app_deletion_request')return(await Psql.one('SELECT xs_request_deletion($1,$2,$3) AS result',[co,current.id,String(input.reason||'')])).result;
+ return {items:await Psql.query('SELECT id,reason,status,response,"createdAt","updatedAt" FROM "AccountDeletionRequest" WHERE company=$1 AND "userId"=$2 ORDER BY "createdAt" DESC LIMIT 20',[co,current.id])};
+}

+ 32 - 0
scripts/cloud/account-security.js

@@ -0,0 +1,32 @@
+function mayMigrateLegacyPassword(target) {
+ return !!target && target.get('legacyPasswordMigrationAllowed') === true && target.get('passwordResetRequired') !== true && !target.get('isDisabled') && !target.get('isDeleted');
+}
+async function migrateLegacyLogin(username,password) {
+ const original=await migratedLoginTarget(username);
+ if(!mayMigrateLegacyPassword(original)) fail(401,'账号或密码错误;如首次登录旧账号,请联系管理员重置密码');
+ const revision=Number(original.get('credentialRevision')||0);
+ const verified=await verifyLegacyPassword(username,password),target=await migratedLoginTarget(username,verified);
+ if(!target || target.id!==original.id || !mayMigrateLegacyPassword(target)) fail(401,'账号验证未通过');
+ target.set('credentialExpectedRevision',revision);target.set('credentialMigrationAttempt',true);
+ target.setPassword(password);await target.save(null,{useMasterKey:true});
+ return Parse.User.logIn(String(target.get('username')),password);
+}
+async function changeAppPassword(input,current) {
+ const oldPassword=String(input.currentPassword||''),password=String(input.newPassword||'');
+ if(!oldPassword || password.length<8 || password.length>72)fail(400,'请输入当前密码及 8 至 72 位新密码');
+ if(oldPassword===password)fail(400,'新密码不能与当前密码相同');
+ const revision=Number(current.get('credentialRevision')||0);
+ let verified;try{verified=await Parse.User.logIn(String(current.get('username')),oldPassword);}catch(error){if(Number(error.code)===101)fail(400,'当前密码不正确');throw error;}
+ if(verified.id!==current.id)fail(403,'账号验证未通过');
+ current.set('credentialExpectedRevision',revision);current.setPassword(password);current.set('passwordResetRequired',false);
+ try{await current.save(null,{useMasterKey:true});await revokeUserSessions(current);}finally{
+  // Reauthentication may have issued a temporary session even when saving failed.
+  const q=new Parse.Query('_Session');q.equalTo('sessionToken',verified.getSessionToken());await Parse.Object.destroyAll(await q.find({useMasterKey:true}),{useMasterKey:true});
+ }
+ return {changed:true,loginRequired:true};
+}
+async function logoutAppSession(request,current) {
+ const token=String(request.body?.token||'');
+ if(token){const q=new Parse.Query('_Session');q.equalTo('user',current);q.equalTo('sessionToken',token);await Parse.Object.destroyAll(await q.find({useMasterKey:true}),{useMasterKey:true});}
+ return {loggedOut:true};
+}

+ 1 - 0
scripts/cloud/legacy-direct-supplement.js

@@ -29,6 +29,7 @@ async function directSupplement(p,co){
    user.set('identityType',value.body.legacyGroupId===3?'coach':value.body.legacyGroupId===2?'store':'member');user.setUsername(username);
    user.set('identityType',value.body.legacyGroupId===3?'coach':value.body.legacyGroupId===2?'store':'member');user.setUsername(username);
    user.setPassword((await Psql.one('SELECT gen_random_uuid()::text||gen_random_uuid()::text AS password')).password);
    user.setPassword((await Psql.one('SELECT gen_random_uuid()::text||gen_random_uuid()::text AS password')).password);
    await user.save(null,{useMasterKey:true});
    await user.save(null,{useMasterKey:true});
+   await Psql.one('SELECT xs_enable_initial_legacy_login($1,$2)',[co,user.id]);
    const check=await Psql.query('SELECT "objectId" FROM "_User" WHERE company=$1 AND "legacyUserId"=$2',[co,id]);if(check.length!==1)throw Error('new_legacy_user_identity_verification_failed');
    const check=await Psql.query('SELECT "objectId" FROM "_User" WHERE company=$1 AND "legacyUserId"=$2',[co,id]);if(check.length!==1)throw Error('new_legacy_user_identity_verification_failed');
    result.accounts.created++;
    result.accounts.created++;
    });
    });

+ 13 - 22
scripts/cloud/payroll-integrity.js

@@ -14,26 +14,17 @@ async function payrollSql(sql,args){try{return await Psql.one(sql,args);}catch(e
 function payrollIdentity(item){return text(item.standardLessonKey)||(number(item.legacyGeneralId)?'legacy:'+number(item.legacyGeneralId):'lesson:'+item.objectId);}
 function payrollIdentity(item){return text(item.standardLessonKey)||(number(item.legacyGeneralId)?'legacy:'+number(item.legacyGeneralId):'lesson:'+item.objectId);}
 const PAYROLL_SYNC_DATASETS=['appointments','lessons','memory-records'];
 const PAYROLL_SYNC_DATASETS=['appointments','lessons','memory-records'];
 async function payrollSettlementSyncStatus(context,input){
 async function payrollSettlementSyncStatus(context,input){
- const checkedAt=new Date().toISOString(),datasets=PAYROLL_SYNC_DATASETS.map(key=>({key,state:'never_synced',lastSuccessAt:'',lagSeconds:null,pendingFailures:0,conflicts:0,legacyCount:null,localCount:null})),config=syncConfig();
- const result={state:'not_configured',settlementReady:false,message:'旧系统持续同步尚未配置,工资统计可能缺少最新记录',checkedAt,lastSuccessAt:'',lagSeconds:null,datasets};
- if(!config.baseUrl||!config.keyId||!config.secret)return directPayrollSyncStatus(context,input,result);
- try{
-  const co=payrollTenant(context,input),[checkpoints,issues,localCounts,manifest]=await Promise.all([
-   Psql.query('SELECT "dataset","state","cursor","lastSuccessAt","lastWatermark" FROM "LegacySyncCheckpoint" WHERE "company"=$1 AND "dataset"=ANY($2::text[]) ORDER BY "updatedAt" DESC',[co,PAYROLL_SYNC_DATASETS]),
-   Psql.query('SELECT "dataset","kind",COUNT(*)::int AS total FROM (SELECT "dataset",\'failure\' AS kind FROM "LegacySyncDeadLetter" WHERE "company"=$1 AND COALESCE("resolved",FALSE)=FALSE UNION ALL SELECT "dataset",\'conflict\' AS kind FROM "LegacySyncConflict" WHERE "company"=$1 AND COALESCE("resolved",FALSE)=FALSE) x WHERE "dataset"=ANY($2::text[]) GROUP BY "dataset","kind"',[co,PAYROLL_SYNC_DATASETS]),
-   Psql.query('SELECT "modelId"::int AS model,ARRAY_AGG("generalId"::bigint ORDER BY "generalId"::bigint) AS keys FROM "CommonModel" WHERE "company"=$1 AND "modelId" IN (54,59,60) AND COALESCE("status",99)<>-2 AND ("sourceKey" LIKE \'legacy:model:%\' OR "sourceKey" LIKE \'legacy-sync:%\') GROUP BY "modelId"',[co]),
-   syncBridgeRequest('/v1/manifest')
-  ]);
-  const byKey=new Map();for(const row of checkpoints)if(!byKey.has(text(row.dataset)))byKey.set(text(row.dataset),row);
-  const manifestByKey=new Map((manifest.datasets||[]).map(row=>[text(row.key),row])),modelByKey={'appointments':54,'lessons':59,'memory-records':60},localByModel=new Map(localCounts.map(row=>[number(row.model),Array.isArray(row.keys)?row.keys.map(text):[]]));
-  for(const item of datasets){const checkpoint=byKey.get(item.key),remote=manifestByKey.get(item.key),keys=localByModel.get(modelByKey[item.key])||[];item.state=text(checkpoint?.state)||'never_synced';item.lastSuccessAt=payrollDateIso(checkpoint?.lastSuccessAt);item.lagSeconds=item.lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(item.lastSuccessAt).getTime())/1000)):null;item.legacyCount=remote?number(remote.count):null;item.localCount=keys.length;item.localChecksum=await sha256(keys.length?keys.join('\n')+'\n':'');item.pendingFailures=issues.filter(row=>row.dataset===item.key&&row.kind==='failure').reduce((sum,row)=>sum+number(row.total),0);item.conflicts=issues.filter(row=>row.dataset===item.key&&row.kind==='conflict').reduce((sum,row)=>sum+number(row.total),0);item.watermarkCurrent=Boolean(remote&&checkpoint&&number(checkpoint.lastWatermark)>=number(remote.watermark));item.complete=Boolean(checkpoint?.cursor&&item.state==='healthy'&&item.watermarkCurrent&&item.legacyCount!==null&&item.localCount===item.legacyCount&&item.localChecksum===text(remote?.checksum));}
-  result.lastSuccessAt=datasets.map(row=>row.lastSuccessAt).filter(Boolean).sort()[0]||'';result.lagSeconds=result.lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(result.lastSuccessAt).getTime())/1000)):null;
-  result.settlementReady=manifest.consistent===true&&datasets.every(row=>row.complete&&row.lagSeconds!==null&&row.lagSeconds<=120&&!row.pendingFailures&&!row.conflicts);
-  result.state=result.settlementReady?'synced':manifest.consistent===true?'delayed':'inconsistent';
-  result.message=result.settlementReady?'旧系统预约、上课和抗遗忘记录已追平':manifest.consistent!==true?'旧系统数据在核对期间发生变化,请刷新重试':datasets.filter(row=>!row.complete||row.lagSeconds===null||row.lagSeconds>120||row.pendingFailures||row.conflicts).map(row=>row.key+':'+(row.pendingFailures||row.conflicts?'有失败或冲突记录':row.state==='catching_up'||row.state==='running'?'仍在追赶':row.state!=='healthy'?'同步未完成':row.localCount!==row.legacyCount||row.localChecksum!==text(manifestByKey.get(row.key)?.checksum)?'旧库与新库记录主键不一致':!row.watermarkCurrent?'增量水位未追平':'最近同步超过两分钟')).join(';');
- }catch(error){result.state='error';result.message='旧系统同步状态无法核验:'+text(error?.message).slice(0,160);}
- return result;
+ const co=payrollTenant(context,input),month=text(input.month)||businessDate().slice(0,7);
+ try {const s=(await payrollSql('SELECT xs_payroll_cutoff_snapshot($1,$2) AS result',[co,month])).result;
+ return {state:s.approved?'cutoff_verified':'batch_verified',settlementReady:s.approved,cutoffVersion:s.version,monthClosed:s.monthClosed,lastSuccessAt:s.cutoffAt||'',lagSeconds:s.cutoffAt?Math.max(0,Math.floor((Date.now()-new Date(s.cutoffAt).getTime())/1000)):null,datasets:s.datasets,message:s.approved?(s.monthClosed?'该月份来源已核验;源数据变化后须重新确认':'已核验截至同步时间的来源,可生成草稿;整月同步后才能审核'):'旧接口来源截止核验待完成:'+(!s.complete?'凭据缺失或业务编号不一致':s.pending+' 条计薪差异待核对')};
+ }catch(error){return {state:'error',settlementReady:false,message:'工资来源核验暂不可用:'+text(error.message).slice(0,140),datasets:[]};}
 }
 }
+async function payrollCutoff(context,input,payload,confirm=false){
+ const co=payrollTenant(context,input),month=text(input.month||payload.month);
+ if(confirm){requireRole(context,['ops-manager']);return(await payrollSql('SELECT xs_payroll_cutoff_confirm($1,$2,$3,$4,$5,$6,$7) AS result',[co,context.current.id,month,text(payload.version),reasonOf(input),text(payload.conflictId),text(payload.fingerprint)])).result;}
+ return(await payrollSql('SELECT xs_payroll_cutoff_snapshot($1,$2) AS result',[co,month])).result;
+}
+
 async function payrollCanonicalSnapshot(context,input) {
 async function payrollCanonicalSnapshot(context,input) {
   const co=payrollTenant(context,input);
   const co=payrollTenant(context,input);
   const [snapshot,sync]=await Promise.all([payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,xs_payroll_source_version($1) AS version FROM xs_payroll_sources($1) s',[co]),payrollSettlementSyncStatus(context,input)]);
   const [snapshot,sync]=await Promise.all([payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,xs_payroll_source_version($1) AS version FROM xs_payroll_sources($1) s',[co]),payrollSettlementSyncStatus(context,input)]);
@@ -56,7 +47,7 @@ function payrollCalculatedLine(item,rates,settlementMonth,rules,mappings){
 }
 }
 async function payrollPreviewData(context,input,month,includeUsed=false,requireLive=false){
 async function payrollPreviewData(context,input,month,includeUsed=false,requireLive=false){
  if(!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))fail(400,'工资月份无效');
  if(!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))fail(400,'工资月份无效');
- const co=payrollTenant(context,input),[source,rates,rules,mappings,used]=await Promise.all([payrollCanonicalSnapshot(context,input),payRateRows(context,input),teachingRuleRows(context,input),courseTeachingRows(context,input),Psql.query('SELECT "standardLessonKey","batchId","excluded" FROM "PayrollLine" WHERE "company"=$1 AND "kind"=\'lesson\' AND COALESCE("excluded",false)=false',[co])]);
+ const co=payrollTenant(context,input),[source,rates,rules,mappings,used]=await Promise.all([payrollCanonicalSnapshot(context,{...input,month}),payRateRows(context,input),teachingRuleRows(context,input),courseTeachingRows(context,input),Psql.query('SELECT "standardLessonKey","batchId","excluded" FROM "PayrollLine" WHERE "company"=$1 AND "kind"=\'lesson\' AND COALESCE("excluded",false)=false',[co])]);
  const other=new Set(used.filter(r=>r.batchId!==input.targetId).map(r=>r.standardLessonKey)),mappingMap=new Map(mappings.filter(r=>r.confirmed).map(r=>[r.courseId,r]));
  const other=new Set(used.filter(r=>r.batchId!==input.targetId).map(r=>r.standardLessonKey)),mappingMap=new Map(mappings.filter(r=>r.confirmed).map(r=>[r.courseId,r]));
  const previous=month.endsWith('-01')?(Number(month.slice(0,4))-1)+'-12':month.slice(0,5)+String(Number(month.slice(5))-1).padStart(2,'0');
  const previous=month.endsWith('-01')?(Number(month.slice(0,4))-1)+'-12':month.slice(0,5)+String(Number(month.slice(5))-1).padStart(2,'0');
  const allowLate=!includeUsed&&Boolean((await Psql.one('SELECT EXISTS(SELECT 1 FROM "PayrollBatch" WHERE "company"=$1 AND "month"=$2 AND "status" IN (\'reviewed\',\'paid\')) AS value',[co,previous])).value);
  const allowLate=!includeUsed&&Boolean((await Psql.one('SELECT EXISTS(SELECT 1 FROM "PayrollBatch" WHERE "company"=$1 AND "month"=$2 AND "status" IN (\'reviewed\',\'paid\')) AS value',[co,previous])).value);
@@ -87,7 +78,7 @@ async function confirmLegacyReviewRewards(context,input,payload){
 async function payrollExecute(context,input,op,payload={}){
 async function payrollExecute(context,input,op,payload={}){
  requireRole(context,['ops-manager']);const co=payrollTenant(context,input),reason=reasonOf(input),target=text(input.targetId),req={reason,version:input.version??payload.version,...payload};
  requireRole(context,['ops-manager']);const co=payrollTenant(context,input),reason=reasonOf(input),target=text(input.targetId),req={reason,version:input.version??payload.version,...payload};
  if(!text(input.idempotencyKey))fail(400,'缺少工资操作请求编号');
  if(!text(input.idempotencyKey))fail(400,'缺少工资操作请求编号');
- if(['create','refresh','review'].includes(op)){const month=op==='create'?text(payload.month):(await payrollDetailData(context,input,target)).month;const preview=await payrollPreviewData(context,{...input,storeId:0,search:'',targetId:target},month,false);if(!preview.sourceStatus.sync?.settlementReady)fail(503,'工资来源尚未追平,暂不能生成或审核:'+text(preview.sourceStatus.sync?.message));req.month=month;req.lines=preview.lines.map(l=>({...l,lessonAt:l.lessonAt||null}));req.sourceVersion=preview.sourceVersion;}
+ if(['create','refresh','review'].includes(op)){const month=op==='create'?text(payload.month):(await payrollDetailData(context,input,target)).month;const preview=await payrollPreviewData(context,{...input,storeId:0,search:'',targetId:target},month,false);if(!preview.sourceStatus.sync?.settlementReady)fail(503,'工资来源尚未追平,暂不能生成或审核:'+text(preview.sourceStatus.sync?.message));req.month=month;req.lines=preview.lines.map(l=>({...l,lessonAt:l.lessonAt||null}));req.sourceVersion=preview.sourceVersion;req.cutoffVersion=preview.sourceStatus.sync.cutoffVersion;}
  const result=await payrollSql('SELECT xs_payroll_command($1,$2,$3,$4,$5::jsonb,$6) AS result',[co,context.current.id,op,target,JSON.stringify(req),input.idempotencyKey]);
  const result=await payrollSql('SELECT xs_payroll_command($1,$2,$3,$4,$5::jsonb,$6) AS result',[co,context.current.id,op,target,JSON.stringify(req),input.idempotencyKey]);
  return {...payrollSummary(result.result),version:result.result.version};
  return {...payrollSummary(result.result),version:result.result.version};
 }
 }
@@ -170,7 +161,7 @@ async function directSyncReceipt(context,input){
 }
 }
 async function directPayrollSyncStatus(context,input,fallback){
 async function directPayrollSyncStatus(context,input,fallback){
  try{const receipt=await directSyncReceipt(context,input);if(!receipt)return fallback;const lastSuccessAt=payrollDateIso(receipt.completedAt),lagSeconds=Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000));
  try{const receipt=await directSyncReceipt(context,input);if(!receipt)return fallback;const lastSuccessAt=payrollDateIso(receipt.completedAt),lagSeconds=Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000));
- return {...fallback,state:'batch_verified',lastSuccessAt,lagSeconds,settlementReady:false,message:'旧接口手动同步批次已完成并核对业务编号;保留差异仍待复核,结算前须核验工资截止范围。每日自动同步尚未验收。',datasets:fallback.datasets.map(r=>{const model={'appointments':54,'lessons':59,'memory-records':60}[r.key],d=receipt.datasets.find(d=>number(d.modelId)===model);return d?{...r,state:'batch_verified',lastSuccessAt,lagSeconds,legacyCount:number(d.source),localCount:number(d.source),conflicts:number(d.conflicts),pendingFailures:number(d.failed),complete:false}:r;})};
+ return {...fallback,state:'batch_verified',lastSuccessAt,lagSeconds,settlementReady:false,message:'旧接口同步已完成并核对业务编号;结算前请核验对应月份的工资截止范围。',datasets:fallback.datasets.map(r=>{const model={'appointments':54,'lessons':59,'memory-records':60}[r.key],d=receipt.datasets.find(d=>number(d.modelId)===model);return d?{...r,state:'batch_verified',lastSuccessAt,lagSeconds,legacyCount:number(d.source),localCount:number(d.source),conflicts:number(d.conflicts),pendingFailures:number(d.failed),complete:false}:r;})};
  }catch{return fallback;}
  }catch{return fallback;}
 }
 }
 async function operationalSyncIndicator(context,input){
 async function operationalSyncIndicator(context,input){

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


+ 1 - 1
scripts/install-direct-sync-launchagent.mjs

@@ -9,7 +9,7 @@ if(process.platform!=='darwin')throw Error('This installer requires macOS');
 if(new Intl.DateTimeFormat().resolvedOptions().timeZone!=='Asia/Shanghai')throw Error('macOS timezone must be Asia/Shanghai for the 23:00 calendar schedule');
 if(new Intl.DateTimeFormat().resolvedOptions().timeZone!=='Asia/Shanghai')throw Error('macOS timezone must be Asia/Shanghai for the 23:00 calendar schedule');
 if((await stat(env)).mode%512!==384)throw Error('Credential environment must have mode 0600');
 if((await stat(env)).mode%512!==384)throw Error('Credential environment must have mode 0600');
 await mkdir(join(root,'scripts/lib'),{recursive:true,mode:0o700});await mkdir(state,{recursive:true,mode:0o700});
 await mkdir(join(root,'scripts/lib'),{recursive:true,mode:0o700});await mkdir(state,{recursive:true,mode:0o700});
-for(const file of ['run-nightly-direct-sync.mjs','lib/legacy-direct-projection.mjs','lib/legacy-direct-specs.mjs']){const dest=join(root,'scripts',file);await copyFile(new URL(file,import.meta.url),dest);await chmod(dest,0o600);}
+for(const file of ['run-nightly-direct-sync.mjs','lib/legacy-direct-projection.mjs','lib/legacy-direct-specs.mjs','lib/sync-alerts.mjs']){const dest=join(root,'scripts',file);await copyFile(new URL(file,import.meta.url),dest);await chmod(dest,0o600);}
 const node=await realpath(process.execPath),script=join(root,'scripts/run-nightly-direct-sync.mjs');
 const node=await realpath(process.execPath),script=join(root,'scripts/run-nightly-direct-sync.mjs');
 const escape=s=>s.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;');
 const escape=s=>s.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;');
 const xml=`<?xml version="1.0" encoding="UTF-8"?>
 const xml=`<?xml version="1.0" encoding="UTF-8"?>

+ 23 - 0
scripts/lib/sync-alerts.mjs

@@ -0,0 +1,23 @@
+import { readFile, writeFile, rename } from 'node:fs/promises';
+import { join } from 'node:path';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+const execute=promisify(execFile);
+export async function macNotify(message) {
+ if(process.platform!=='darwin')throw Error('notification_platform_unavailable');
+ await execute('/usr/bin/osascript',['-e','on run argv\ndisplay notification (item 1 of argv) with title "小树夜间同步"\nend run',message],{timeout:10000});
+}
+// Only fixed text and aggregate counts are shown; raw errors and credentials never enter notifications.
+export async function syncAlert(report,root,notify=macNotify,now=Date.now()) {
+ const path=join(root,'direct-alert-state.json');let prior={};try{prior=JSON.parse(await readFile(path,'utf8'));}catch(e){if(e.code!=='ENOENT')throw e;}
+ const healthy=['completed','completed-with-preserved-conflicts','already_completed'].includes(report.status);
+ if(!healthy&&report.status!=='blocked')return;
+ const count=Number(report.conflicts??report.previous?.conflicts??0),growth=healthy&&Number.isFinite(prior.conflicts)&&count>=prior.conflicts+Math.max(100,Math.ceil(prior.conflicts*.25));
+ let kind=!healthy?'failure':prior.failed?'recovered':growth?'conflicts':'';
+ const key=kind==='failure'?'failure':kind==='conflicts'?'conflicts:'+count:kind;
+ const due=kind&&(prior.lastKey!==key||now-Number(prior.notifiedAt||0)>=24*3600000);
+ const next={...prior,failed:!healthy,...(healthy?{conflicts:count,lastSuccessAt:now}:{})};
+ if(due){await notify(kind==='failure'?'今晚的数据同步未完成,系统会重试。请查看同步报告并检查登录凭据。':kind==='recovered'?'数据同步已恢复,请查看本次报告。':'同步保留差异明显增加('+count+' 条),请在后台核对。');next.lastKey=key;next.notifiedAt=now;}
+ await writeFile(path+'.tmp',JSON.stringify(next),{mode:0o600});await rename(path+'.tmp',path);
+ return {notified:Boolean(due),kind};
+}

+ 18 - 0
scripts/migrate-launch-fixes.mjs

@@ -0,0 +1,18 @@
+#!/usr/bin/env node
+// --plan is offline. --apply is a publisher action and requires a backup plus a real operator session.
+import {readFile} from 'node:fs/promises';import {randomUUID} from 'node:crypto';
+const files=['account-security.sql','payroll-integrity.sql','payroll-cutoff.sql','account-deletion.sql'];
+const sql=(await Promise.all(files.map(name=>readFile(new URL('./sql/'+name,import.meta.url),'utf8')))).join('\n');
+const fields={legacyPasswordMigrationAllowed:{type:'Boolean'},credentialRevision:{type:'Number'},credentialExpectedRevision:{type:'Number'},credentialMigrationAttempt:{type:'Boolean'},passwordResetRequired:{type:'Boolean'}};
+if(!process.argv.includes('--apply')){console.log(JSON.stringify({apply:false,files,parseClass:'_User',fields:Object.keys(fields),requires:'Back up database, apply existing payroll and direct-sync prerequisites, then run --apply before publishing gateways.'},null,2));process.exit(0);}
+const master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_MIGRATION_OPERATOR_TOKEN;
+if(!master||!token)throw Error('Require XIAOSHU_MASTER_KEY and XIAOSHU_MIGRATION_OPERATOR_TOKEN in environment');
+const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
+async function api(path,method='GET',body,session=false){const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,...(session?{'X-Parse-Session-Token':token}:{'X-Parse-Master-Key':master})},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(120000)});const data=await r.json();if(!r.ok)throw Error('Migration API failed ('+r.status+')');return data;}
+const user=await api('/users/me','GET',undefined,true);if(user.isDisabled||user.isDeleted||!(user.roles||[]).includes('super-admin'))throw Error('An active super-admin session is required');
+const schema=await api('/schemas/_User');const missing=Object.fromEntries(Object.entries(fields).filter(([key])=>!schema.fields[key]));if(Object.keys(missing).length)await api('/schemas/_User','PUT',{fields:missing});
+const path='xiaoshu/system/launch-fixes-'+randomUUID();let id;
+try{const code=`async function handler(request,response){if(request.user?.id!==${JSON.stringify(user.objectId)})return response.status(403).json({success:false});try{await Psql.tx(t=>t.none(${JSON.stringify(sql)}));return response.json({success:true});}catch(e){return response.status(500).json({success:false,message:String(e.message)});}}`;
+ id=(await api('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})).objectId;
+ const r=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{}}),signal:AbortSignal.timeout(120000)});const result=await r.json();if(!r.ok||!result.success)throw Error(result.message||'Migration failed');console.log(JSON.stringify({applied:true,files}));
+}finally{if(id)await api('/classes/Function/'+id,'DELETE');}

+ 3 - 1
scripts/run-nightly-direct-sync.mjs

@@ -1,4 +1,5 @@
 #!/usr/bin/env node
 #!/usr/bin/env node
+import {syncAlert} from './lib/sync-alerts.mjs';
 import {readFile,writeFile,mkdir,rename,rm,readdir} from 'node:fs/promises';
 import {readFile,writeFile,mkdir,rename,rm,readdir} from 'node:fs/promises';
 import {homedir} from 'node:os';
 import {homedir} from 'node:os';
 import {join,resolve} from 'node:path';
 import {join,resolve} from 'node:path';
@@ -121,6 +122,7 @@ export async function runDirectSync(env=process.env,args=process.argv.slice(2)){
  finally{report.finishedAt=new Date().toISOString();await save(join(root,`${report.businessDate||new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date())}-direct-${runId||owner}.json`),report);if(locked)await rm(lock,{recursive:true});}
  finally{report.finishedAt=new Date().toISOString();await save(join(root,`${report.businessDate||new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date())}-direct-${runId||owner}.json`),report);if(locked)await rm(lock,{recursive:true});}
 }
 }
 if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){
 if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){
- const r=await runDirectSync();console.log(JSON.stringify({status:r.status,reason:r.reason||'',runId:r.runId,created:r.createdBusinessRecords,updated:r.refreshedBusinessRecords,conflicts:r.conflicts}));
+ const r=await runDirectSync();try{await syncAlert(r,resolve(process.env.XIAOSHU_SYNC_REPORT_DIR||join(homedir(),'.local/state/xiaoshu/legacy-sync')));}catch{console.error('同步通知发送失败,请检查 macOS 通知权限;运行报告已保存');}
+ console.log(JSON.stringify({status:r.status,reason:r.reason||'',runId:r.runId,created:r.createdBusinessRecords,updated:r.refreshedBusinessRecords,conflicts:r.conflicts}));
  process.exitCode=['completed','completed-with-preserved-conflicts','already_completed','ready'].includes(r.status)?0:1;
  process.exitCode=['completed','completed-with-preserved-conflicts','already_completed','ready'].includes(r.status)?0:1;
 }
 }

+ 25 - 0
scripts/sql/account-deletion.sql

@@ -0,0 +1,25 @@
+CREATE TABLE IF NOT EXISTS "AccountDeletionRequest" (id text PRIMARY KEY, company text NOT NULL, "userId" text NOT NULL, reason text NOT NULL, status text NOT NULL DEFAULT 'requested', response text NOT NULL DEFAULT '', actor text, version integer NOT NULL DEFAULT 1, "createdAt" timestamptz DEFAULT now(), "updatedAt" timestamptz DEFAULT now());
+CREATE UNIQUE INDEX IF NOT EXISTS xs_pending_deletion ON "AccountDeletionRequest" (company,"userId") WHERE status IN ('requested','processing');
+REVOKE ALL ON "AccountDeletionRequest" FROM PUBLIC;
+CREATE OR REPLACE FUNCTION xs_request_deletion(co text,uid text,why text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE r "AccountDeletionRequest";
+BEGIN
+ IF length(trim(why))<2 OR length(why)>500 THEN RAISE EXCEPTION '请填写 2 至 500 字的申请说明'; END IF;
+ IF NOT EXISTS(SELECT 1 FROM "_User" WHERE company=co AND "objectId"=uid AND NOT COALESCE("isDeleted",false)) THEN RAISE EXCEPTION '账号不存在'; END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('deletion:'||co||':'||uid));
+ SELECT * INTO r FROM "AccountDeletionRequest" WHERE company=co AND "userId"=uid AND status IN ('requested','processing');
+ IF FOUND THEN RETURN to_jsonb(r); END IF;
+ INSERT INTO "AccountDeletionRequest" (id,company,"userId",reason) VALUES(substr(md5(random()::text||clock_timestamp()::text),1,20),co,uid,trim(why)) RETURNING * INTO r;
+ RETURN to_jsonb(r);
+END $$;
+CREATE OR REPLACE FUNCTION xs_process_deletion(co text,operator_id text,rid text,expected integer,next_status text,note text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE r "AccountDeletionRequest";
+BEGIN
+ IF next_status NOT IN ('processing','rejected','completed') OR length(trim(note))<2 OR length(note)>1000 THEN RAISE EXCEPTION '请填写有效处理状态和说明'; END IF;
+ SELECT * INTO r FROM "AccountDeletionRequest" WHERE company=co AND id=rid FOR UPDATE;
+ IF NOT FOUND OR r.version<>expected THEN RAISE EXCEPTION '申请已更新,请刷新'; END IF;
+ IF r.status NOT IN ('requested','processing') THEN RAISE EXCEPTION '申请已结束'; END IF;
+ IF next_status='completed' AND NOT EXISTS(SELECT 1 FROM "_User" WHERE company=co AND "objectId"=r."userId" AND "isDeleted"=true) THEN RAISE EXCEPTION '请先完成账号资料核查和会员注销,再登记完成'; END IF;
+ UPDATE "AccountDeletionRequest" SET status=next_status,response=trim(note),actor=operator_id,version=version+1,"updatedAt"=now() WHERE id=rid RETURNING * INTO r;
+ RETURN to_jsonb(r);
+END $$;

+ 37 - 0
scripts/sql/account-security.sql

@@ -0,0 +1,37 @@
+-- Run after registering the corresponding additive Parse _User fields.
+-- Existing accounts deliberately default to no legacy fallback: their password history is unknown.
+ALTER TABLE "_User" ADD COLUMN IF NOT EXISTS "legacyPasswordMigrationAllowed" boolean DEFAULT false;
+ALTER TABLE "_User" ADD COLUMN IF NOT EXISTS "credentialRevision" integer DEFAULT 0;
+ALTER TABLE "_User" ADD COLUMN IF NOT EXISTS "credentialExpectedRevision" integer;
+ALTER TABLE "_User" ADD COLUMN IF NOT EXISTS "credentialMigrationAttempt" boolean DEFAULT false;
+ALTER TABLE "_User" ADD COLUMN IF NOT EXISTS "passwordResetRequired" boolean DEFAULT false;
+CREATE OR REPLACE FUNCTION xs_credential_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP='INSERT' THEN
+  NEW."legacyPasswordMigrationAllowed":=false; NEW."credentialRevision":=0;
+  NEW."credentialExpectedRevision":=NULL; NEW."credentialMigrationAttempt":=false;
+  RETURN NEW;
+ END IF;
+ IF NEW."legacyPasswordMigrationAllowed" IS TRUE AND OLD."legacyPasswordMigrationAllowed" IS DISTINCT FROM true AND COALESCE(current_setting('xiaoshu.credential_bootstrap',true),'')<>'on' THEN RAISE EXCEPTION '禁止重新启用旧密码'; END IF;
+ IF OLD."passwordResetRequired" IS TRUE AND NEW."passwordResetRequired" IS DISTINCT FROM true AND NEW."_hashed_password" IS NOT DISTINCT FROM OLD."_hashed_password" THEN RAISE EXCEPTION '须通过改密解除临时密码状态'; END IF;
+ IF NEW."credentialRevision" IS DISTINCT FROM OLD."credentialRevision" THEN RAISE EXCEPTION '密码版本由系统维护'; END IF;
+ IF NEW."_hashed_password" IS DISTINCT FROM OLD."_hashed_password" THEN
+  IF NEW."credentialExpectedRevision" IS NOT NULL AND NEW."credentialExpectedRevision"<>COALESCE(OLD."credentialRevision",0) THEN RAISE EXCEPTION '密码已更新,请重新登录后重试'; END IF;
+  IF NEW."credentialMigrationAttempt" IS TRUE AND (OLD."legacyPasswordMigrationAllowed" IS DISTINCT FROM true OR OLD."passwordResetRequired" IS TRUE) THEN RAISE EXCEPTION '旧密码迁移已关闭'; END IF;
+  NEW."credentialRevision":=COALESCE(OLD."credentialRevision",0)+1;
+  NEW."legacyPasswordMigrationAllowed":=false;
+  DELETE FROM "_Session" WHERE "user"=OLD."objectId";
+ END IF;
+ NEW."credentialExpectedRevision":=NULL; NEW."credentialMigrationAttempt":=false;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS xs_credential_guard_trigger ON "_User";
+CREATE TRIGGER xs_credential_guard_trigger BEFORE INSERT OR UPDATE ON "_User" FOR EACH ROW EXECUTE FUNCTION xs_credential_guard();
+-- Only the server-side importer calls this immediately after creating a random-password account.
+CREATE OR REPLACE FUNCTION xs_enable_initial_legacy_login(co text, uid text) RETURNS void LANGUAGE plpgsql AS $$
+BEGIN
+ PERFORM set_config('xiaoshu.credential_bootstrap','on',true);
+ UPDATE "_User" SET "legacyPasswordMigrationAllowed"=true WHERE "objectId"=uid AND company=co AND COALESCE("credentialRevision",0)=0 AND "createdAt">now()-interval '2 minutes' AND NOT COALESCE("passwordResetRequired",false);
+ IF NOT FOUND THEN RAISE EXCEPTION '账号不符合首次迁移条件'; END IF;
+ PERFORM set_config('xiaoshu.credential_bootstrap','off',true);
+END $$;

+ 75 - 0
scripts/sql/payroll-cutoff.sql

@@ -0,0 +1,75 @@
+-- Additive. Apply after payroll-integrity.sql and legacy-direct-sync.sql.
+CREATE TABLE IF NOT EXISTS "PayrollCutoffDecision" (company text, month text, "conflictId" text, fingerprint text, actor text, reason text, "createdAt" timestamptz DEFAULT now(), PRIMARY KEY(company,month,"conflictId"));
+CREATE TABLE IF NOT EXISTS "PayrollCutoffApproval" (company text, month text, version text, "runId" text, "cutoffAt" timestamptz, actor text, reason text, "createdAt" timestamptz DEFAULT now(), PRIMARY KEY(company,month));
+REVOKE ALL ON "PayrollCutoffDecision","PayrollCutoffApproval" FROM PUBLIC;
+CREATE OR REPLACE FUNCTION xs_payroll_cutoff_conflicts(co text, mon text) RETURNS SETOF jsonb LANGUAGE plpgsql AS $$
+DECLARE r record; item jsonb; payload jsonb; fields jsonb; dt text; v_fingerprint text; cosmetic boolean;
+BEGIN
+ FOR r IN SELECT f.*,c."modelId",c."itemId",c.title,c.subtitle FROM "LegacySyncConflict" f LEFT JOIN "CommonModel" c ON c.company=f.company AND c."generalId"::text=f."legacyKey" AND c."modelId"::text=f.dataset WHERE f.company=co AND f.dataset IN ('54','59','60') AND NOT COALESCE(f.resolved,false) ORDER BY f."objectId" LOOP
+  item:=NULL;
+  IF r.dataset='54' THEN SELECT to_jsonb(a) INTO item FROM "CourseAppointment" a WHERE a.company=co AND a.id::text=r."itemId"::text;
+  ELSIF r.dataset='59' THEN SELECT to_jsonb(a) INTO item FROM "LessonRecord" a WHERE a.company=co AND a.id::text=r."itemId"::text;
+  ELSE SELECT to_jsonb(a) INTO item FROM "MemoryPracticeRecord" a WHERE a.company=co AND a.id::text=r."itemId"::text; END IF;
+  dt:=CASE r.dataset WHEN '54' THEN item->>'yysj' WHEN '59' THEN COALESCE(NULLIF(item->>'lessonAt',''),NULLIF(item->>'plpjsj','')) ELSE COALESCE(NULLIF(item->>'wcsj',''),NULLIF(item->>'kywrq','')) END;
+  -- All older unresolved rewards can enter a later payroll; unknown dates always block.
+  IF xs_payroll_time(dt) IS NOT NULL AND to_char(xs_payroll_time(dt) AT TIME ZONE 'Asia/Shanghai','YYYY-MM')>mon THEN CONTINUE; END IF;
+  BEGIN payload:=r.message::jsonb; EXCEPTION WHEN others THEN payload:='{}'; END;
+  fields:=COALESCE(payload->'fields','[]');
+  IF jsonb_typeof(fields)<>'array' THEN fields:='[]'; END IF;
+  cosmetic:=jsonb_array_length(fields)>0 AND NOT EXISTS(SELECT 1 FROM jsonb_array_elements_text(fields) f WHERE f NOT IN ('title','inputer'));
+  v_fingerprint:=md5(jsonb_build_array(r.message,item,r.title,r.subtitle,(SELECT "runId" FROM "LegacyDirectRun" WHERE company=co AND status IN ('completed','completed-with-preserved-conflicts') ORDER BY "finishedAt" DESC LIMIT 1))::text);
+  RETURN NEXT jsonb_build_object('id',r."objectId",'dataset',r.dataset,'legacyKey',r."legacyKey",'student',r.title,'course',r.subtitle,'date',dt,'fields',fields,'reason',payload->>'reason','current',item,'fingerprint',v_fingerprint,'cosmetic',cosmetic,'resolved',cosmetic OR EXISTS(SELECT 1 FROM "PayrollCutoffDecision" d WHERE d.company=co AND d.month=mon AND d."conflictId"=r."objectId" AND d.fingerprint=v_fingerprint));
+ END LOOP;
+END $$;
+CREATE OR REPLACE FUNCTION xs_payroll_cutoff_snapshot(co text, mon text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE run "LegacyDirectRun"; ev record; table_name text; matches integer; complete boolean:=true; conflicts jsonb; version text; approval "PayrollCutoffApproval"; data_version text; pending integer; datasets jsonb:='[]'; ready boolean;
+BEGIN
+ IF mon !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN RAISE EXCEPTION '工资月份无效'; END IF;
+ SELECT * INTO run FROM "LegacyDirectRun" WHERE company=co AND status IN ('completed','completed-with-preserved-conflicts') ORDER BY "finishedAt" DESC LIMIT 1;
+ IF NOT FOUND THEN complete:=false; END IF;
+ IF (SELECT count(*) FROM "LegacyDirectEvidence" WHERE company=co AND "runId"=run."runId" AND model IN (54,59,60))<>3 THEN complete:=false; END IF;
+ FOR ev IN SELECT * FROM "LegacyDirectEvidence" WHERE company=co AND "runId"=run."runId" AND model IN (54,59,60) LOOP
+  table_name:=CASE ev.model WHEN 54 THEN 'CourseAppointment' WHEN 59 THEN 'LessonRecord' ELSE 'MemoryPracticeRecord' END;
+  EXECUTE format('SELECT count(*)::int FROM (SELECT c."generalId" FROM "CommonModel" c JOIN %I a ON a.company=c.company AND a.id::text=c."itemId"::text WHERE c.company=$1 AND c."modelId"=$2 AND c."generalId"::text IN (SELECT jsonb_array_elements_text($3)) GROUP BY c."generalId" HAVING count(*)=1) x',table_name) INTO matches USING co,ev.model,ev.ids;
+  complete:=complete AND matches=ev."sourceCount" AND ev."sourceChecksum"=ev."targetChecksum" AND jsonb_array_length(ev.ids)=ev."sourceCount" AND ev."sourceChecksum"=md5((SELECT COALESCE(string_agg(v,E'\n' ORDER BY v::numeric),'') FROM jsonb_array_elements_text(ev.ids) v));
+  datasets:=datasets||jsonb_build_array(jsonb_build_object('model',ev.model,'source',ev."sourceCount",'matched',matches,'checksum',ev."sourceChecksum"));
+ END LOOP;
+ SELECT COALESCE(jsonb_agg(c ORDER BY c->>'id'),'[]') INTO conflicts FROM xs_payroll_cutoff_conflicts(co,mon) c;
+ SELECT count(*) INTO pending FROM jsonb_array_elements(conflicts) c WHERE NOT (c->>'resolved')::boolean;
+ SELECT md5(COALESCE(string_agg(hash,'' ORDER BY kind,hash),'')) INTO data_version FROM (
+ SELECT 'common' kind,md5(to_jsonb(c)::text) hash FROM "CommonModel" c WHERE company=co AND "modelId" IN (54,59,60)
+ UNION ALL SELECT 'appointment',md5(to_jsonb(c)::text) FROM "CourseAppointment" c WHERE company=co
+ UNION ALL SELECT 'lesson',md5(to_jsonb(c)::text) FROM "LessonRecord" c WHERE company=co
+ UNION ALL SELECT 'review',md5(to_jsonb(c)::text) FROM "MemoryPracticeRecord" c WHERE company=co) current_rows;
+ version:=md5(jsonb_build_array(data_version,xs_payroll_source_version(co),run."runId",run."finishedAt",datasets,conflicts)::text);
+ SELECT * INTO approval FROM "PayrollCutoffApproval" WHERE company=co AND month=mon;
+ ready:=complete AND pending=0 AND approval.version=version AND NOT EXISTS(SELECT 1 FROM "LegacyDirectRun" WHERE company=co AND status='running');
+ RETURN jsonb_build_object('month',mon,'version',version,'sourceVersion',xs_payroll_source_version(co),'runId',run."runId",'cutoffAt',run."finishedAt",'complete',complete,'pending',pending,'conflicts',conflicts,'datasets',datasets,'approved',COALESCE(ready,false),'monthClosed',COALESCE(run."finishedAt">=((mon||'-01')::date+interval '1 month') AT TIME ZONE 'Asia/Shanghai',false),'approval',to_jsonb(approval));
+END $$;
+CREATE OR REPLACE FUNCTION xs_payroll_cutoff_confirm(co text,actor text,mon text,expected text,reason text,cid text DEFAULT '',fp text DEFAULT '') RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE snapshot jsonb; row jsonb;
+BEGIN
+ IF length(trim(reason))<2 THEN RAISE EXCEPTION '请填写核验依据'; END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-payroll:'||co));
+ PERFORM pg_advisory_xact_lock(hashtext('direct-sync:'||co));
+ LOCK TABLE "LegacyDirectRun","LegacyDirectEvidence","LegacySyncConflict","CommonModel","CourseAppointment","LessonRecord","MemoryPracticeRecord","ReviewReward","TeachingCompensationRule","CourseTeachingProfile","CoachPayRate","LessonRecordFact","PayrollCutoffDecision","PayrollCutoffApproval" IN SHARE MODE;
+ snapshot:=xs_payroll_cutoff_snapshot(co,mon);
+ IF snapshot->>'version' IS DISTINCT FROM expected THEN RAISE EXCEPTION '来源已更新,请重新核验'; END IF;
+ IF cid<>'' THEN
+  SELECT c INTO row FROM jsonb_array_elements(snapshot->'conflicts') c WHERE c->>'id'=cid AND c->>'fingerprint'=fp;
+  IF row IS NULL OR row->'current'='null'::jsonb THEN RAISE EXCEPTION '来源记录缺失或差异已变化,请先修复'; END IF;
+  INSERT INTO "PayrollCutoffDecision" VALUES(co,mon,cid,fp,actor,reason,now()) ON CONFLICT(company,month,"conflictId") DO UPDATE SET fingerprint=EXCLUDED.fingerprint,actor=EXCLUDED.actor,reason=EXCLUDED.reason,"createdAt"=now();
+ ELSE
+  IF NOT (snapshot->>'complete')::boolean OR (snapshot->>'pending')::int>0 OR EXISTS(SELECT 1 FROM "LegacyDirectRun" WHERE company=co AND status='running') THEN RAISE EXCEPTION '同步凭据不完整、仍有差异或同步正在执行'; END IF;
+  INSERT INTO "PayrollCutoffApproval" VALUES(co,mon,expected,snapshot->>'runId',(snapshot->>'cutoffAt')::timestamptz,actor,reason,now()) ON CONFLICT(company,month) DO UPDATE SET version=EXCLUDED.version,"runId"=EXCLUDED."runId","cutoffAt"=EXCLUDED."cutoffAt",actor=EXCLUDED.actor,reason=EXCLUDED.reason,"createdAt"=now();
+ END IF;
+ RETURN xs_payroll_cutoff_snapshot(co,mon);
+END $$;
+CREATE OR REPLACE FUNCTION xs_payroll_assert_cutoff(co text,mon text,op text,expected text) RETURNS void LANGUAGE plpgsql AS $$
+DECLARE s jsonb;
+BEGIN
+ LOCK TABLE "LegacyDirectRun","LegacyDirectEvidence","LegacySyncConflict","CommonModel","CourseAppointment","LessonRecord","MemoryPracticeRecord","ReviewReward","TeachingCompensationRule","CourseTeachingProfile","CoachPayRate","LessonRecordFact","PayrollCutoffDecision","PayrollCutoffApproval" IN SHARE MODE;
+ s:=xs_payroll_cutoff_snapshot(co,mon);
+ IF NOT (s->>'approved')::boolean OR s->>'version' IS DISTINCT FROM expected THEN RAISE EXCEPTION '工资截止来源确认已失效,请重新核验'; END IF;
+ IF op='review' AND NOT (s->>'monthClosed')::boolean THEN RAISE EXCEPTION '本月尚未完成整月同步,只能保存工资草稿'; END IF;
+END $$;

+ 3 - 2
scripts/sql/payroll-integrity.sql

@@ -161,7 +161,7 @@ BEGIN
  PERFORM set_config('xiaoshu.payroll_write','yes',true);
  PERFORM set_config('xiaoshu.payroll_write','yes',true);
  req:=req||jsonb_build_object('actor',actor,'operation',op,'target',target);
  req:=req||jsonb_build_object('actor',actor,'operation',op,'target',target);
  SELECT * INTO saved FROM "PayrollCommand" WHERE "company"=co AND "requestId"=idem;
  SELECT * INTO saved FROM "PayrollCommand" WHERE "company"=co AND "requestId"=idem;
- IF FOUND THEN IF saved."request" IS DISTINCT FROM (req-'lines'-'sourceVersion') THEN RAISE EXCEPTION '请求编号已用于不同操作'; END IF;RETURN saved."result";END IF;
+ IF FOUND THEN IF saved."request" IS DISTINCT FROM (req-'lines'-'sourceVersion'-'cutoffVersion') THEN RAISE EXCEPTION '请求编号已用于不同操作'; END IF;RETURN saved."result";END IF;
  IF op='create' THEN
  IF op='create' THEN
   IF COALESCE(req->>'month','') !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN RAISE EXCEPTION '工资月份无效'; END IF;
   IF COALESCE(req->>'month','') !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN RAISE EXCEPTION '工资月份无效'; END IF;
   INSERT INTO "PayrollBatch" ("objectId","company","sourceKey","month","status","createdAt","updatedAt","createdBy") VALUES(xs_object_id(),co,'payroll:'||co||':'||(req->>'month'),req->>'month','draft',now(),now(),actor) RETURNING * INTO b;
   INSERT INTO "PayrollBatch" ("objectId","company","sourceKey","month","status","createdAt","updatedAt","createdBy") VALUES(xs_object_id(),co,'payroll:'||co||':'||(req->>'month'),req->>'month','draft',now(),now(),actor) RETURNING * INTO b;
@@ -174,6 +174,7 @@ BEGIN
  IF op IN ('create','refresh','review') THEN
  IF op IN ('create','refresh','review') THEN
   IF b."status"<>'draft' THEN RAISE EXCEPTION '工资单已锁定'; END IF;
   IF b."status"<>'draft' THEN RAISE EXCEPTION '工资单已锁定'; END IF;
   IF req->>'sourceVersion' IS DISTINCT FROM xs_payroll_source_version(co) THEN RAISE EXCEPTION '计薪来源已更新,请重新核对'; END IF;
   IF req->>'sourceVersion' IS DISTINCT FROM xs_payroll_source_version(co) THEN RAISE EXCEPTION '计薪来源已更新,请重新核对'; END IF;
+  IF to_regprocedure('xs_payroll_assert_cutoff(text,text,text,text)') IS NOT NULL THEN PERFORM xs_payroll_assert_cutoff(co,b."month",op,req->>'cutoffVersion'); END IF;
   -- Only automated lines are rebuilt; explicit exclusions and adjustments survive.
   -- Only automated lines are rebuilt; explicit exclusions and adjustments survive.
   DELETE FROM "PayrollLine" WHERE "company"=co AND "batchId"=b."objectId" AND "kind" IN ('lesson','review_reward') AND NOT COALESCE("excluded",false);
   DELETE FROM "PayrollLine" WHERE "company"=co AND "batchId"=b."objectId" AND "kind" IN ('lesson','review_reward') AND NOT COALESCE("excluded",false);
   FOR line IN SELECT value FROM jsonb_array_elements(COALESCE(req->'lines','[]')) LOOP
   FOR line IN SELECT value FROM jsonb_array_elements(COALESCE(req->'lines','[]')) LOOP
@@ -203,7 +204,7 @@ BEGIN
  "paymentMethod"=CASE WHEN op='mark-paid' THEN req->>'paymentMethod' ELSE "paymentMethod" END,"paymentReference"=CASE WHEN op='mark-paid' THEN req->>'paymentReference' ELSE "paymentReference" END
  "paymentMethod"=CASE WHEN op='mark-paid' THEN req->>'paymentMethod' ELSE "paymentMethod" END,"paymentReference"=CASE WHEN op='mark-paid' THEN req->>'paymentReference' ELSE "paymentReference" END
  WHERE "company"=co AND "objectId"=b."objectId" RETURNING to_jsonb("PayrollBatch".*) INTO result;
  WHERE "company"=co AND "objectId"=b."objectId" RETURNING to_jsonb("PayrollBatch".*) INTO result;
  PERFORM xs_payroll_audit(co,actor,op,b."objectId",req->>'reason',previous,result);
  PERFORM xs_payroll_audit(co,actor,op,b."objectId",req->>'reason',previous,result);
- INSERT INTO "PayrollCommand" ("company","requestId","request","result") VALUES(co,idem,req-'lines'-'sourceVersion',result);
+ INSERT INTO "PayrollCommand" ("company","requestId","request","result") VALUES(co,idem,req-'lines'-'sourceVersion'-'cutoffVersion',result);
  RETURN result;
  RETURN result;
 END $$;
 END $$;
 
 

+ 15 - 0
scripts/tests/account-security.test.mjs

@@ -0,0 +1,15 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm';import {readFileSync} from 'node:fs';import {PGlite} from '@electric-sql/pglite';
+const code=readFileSync(new URL('../cloud/account-security.js',import.meta.url),'utf8');
+test('旧密码只允许显式首次迁移,已重置和历史不明账号拒绝且不调用旧系统',async()=>{
+ for(const state of [{},{legacyPasswordMigrationAllowed:false},{legacyPasswordMigrationAllowed:true,passwordResetRequired:true}]){
+ let verified=0,saved=0;const c=vm.createContext({migratedLoginTarget:async()=>({get:k=>state[k],save:async()=>saved++}),verifyLegacyPassword:async()=>verified++,fail:(status,msg)=>{throw Error(msg)}});vm.runInContext(code,c);await assert.rejects(c.migrateLegacyLogin('a','old'),/密码错误/);assert.equal(saved,0);assert.equal(verified,0);
+ }
+});
+test('一次性迁移核对身份并携带验证前密码版本,不能切换账号',async()=>{
+ const v={legacyPasswordMigrationAllowed:true,credentialRevision:3,username:'a'},sets={};const target={id:'a',get:k=>v[k],set:(k,x)=>sets[k]=x,setPassword:x=>sets.password=x,save:async()=>{}};
+ const c=vm.createContext({migratedLoginTarget:async()=>target,verifyLegacyPassword:async()=>({}),Parse:{User:{logIn:async()=>({ok:true})}},fail:(_,m)=>{throw Error(m)}});vm.runInContext(code,c);await c.migrateLegacyLogin('a','old');assert.equal(sets.credentialExpectedRevision,3);assert.equal(sets.credentialMigrationAttempt,true);
+ let calls=0;c.migratedLoginTarget=async()=>++calls===1?target:{...target,id:'b'};await assert.rejects(c.migrateLegacyLogin('a','old'),/验证未通过/);
+});
+test('数据库密码变化原子关闭回退、撤销会话,拒绝并发旧版本及客户端重新启用',async()=>{
+ const db=new PGlite();try{await db.exec(`CREATE TABLE "_User"("objectId" text primary key,company text,"_hashed_password" text,"createdAt" timestamptz default now());CREATE TABLE "_Session"("user" text);`);await db.exec(readFileSync(new URL('../sql/account-security.sql',import.meta.url),'utf8'));await db.exec(`INSERT INTO "_User"("objectId",company,"_hashed_password","legacyPasswordMigrationAllowed") VALUES('u','co','random',true);`);const one=async()=> (await db.query(`SELECT * FROM "_User"`)).rows[0];assert.equal((await one()).legacyPasswordMigrationAllowed,false);await db.query('SELECT xs_enable_initial_legacy_login($1,$2)',['co','u']);await db.exec(`INSERT INTO "_Session" VALUES('u');UPDATE "_User" SET "_hashed_password"='new',"passwordResetRequired"=true WHERE "objectId"='u';`);assert.equal((await one()).credentialRevision,1);assert.equal((await one()).legacyPasswordMigrationAllowed,false);assert.equal((await db.query('SELECT * FROM "_Session"')).rows.length,0);await assert.rejects(db.exec(`UPDATE "_User" SET "_hashed_password"='old',"credentialExpectedRevision"=0,"credentialMigrationAttempt"=true`),/密码已更新/);await assert.rejects(db.exec(`UPDATE "_User" SET "legacyPasswordMigrationAllowed"=true`),/禁止/);await assert.rejects(db.query('SELECT xs_enable_initial_legacy_login($1,$2)',['co','u']),/不符合/);assert.equal((await one())._hashed_password,'new');}finally{await db.close();}
+});

+ 3 - 1
scripts/tests/direct-nightly-sync.test.mjs

@@ -118,10 +118,12 @@ test('新增账号与栏目幂等补齐,已有账户余额和目录名称保
  await sql('INSERT INTO "_User" VALUES(\'existing-user\',\'co\',\'existing\',20,\'{"Purse":99}\')');
  await sql('INSERT INTO "_User" VALUES(\'existing-user\',\'co\',\'existing\',20,\'{"Purse":99}\')');
  await sql('INSERT INTO "Node" ("objectId",company,"nodeId","nodeName") VALUES(\'existing-node\',\'co\',8,\'新系统目录\')');
  await sql('INSERT INTO "Node" ("objectId",company,"nodeId","nodeName") VALUES(\'existing-node\',\'co\',8,\'新系统目录\')');
  await db.exec('ALTER TABLE "_User" ADD COLUMN "isDeleted" boolean, ADD COLUMN "sourceKey" text');
  await db.exec('ALTER TABLE "_User" ADD COLUMN "isDeleted" boolean, ADD COLUMN "sourceKey" text');
+ await db.exec('ALTER TABLE "_User" ADD COLUMN "createdAt" timestamptz DEFAULT now(),ADD COLUMN "_hashed_password" text;CREATE TABLE "_Session"("user" text)');
+ await db.exec(readFileSync(new URL('../sql/account-security.sql',import.meta.url),'utf8'));
  const previous=context.fetch;let passwords=0;
  const previous=context.fetch;let passwords=0;
  context.Parse.Query=class{constructor(className){assert.equal(className,'Company');}async get(id){return{id,className:'Company'};}};
  context.Parse.Query=class{constructor(className){assert.equal(className,'Company');}async get(id){return{id,className:'Company'};}};
  context.Parse.Object={createWithoutData:()=>{throw Error('Production SDK does not support two-argument createWithoutData');}};
  context.Parse.Object={createWithoutData:()=>{throw Error('Production SDK does not support two-argument createWithoutData');}};
- context.Parse.User=class{constructor(){this.fields={};}set(k,v){this.fields[k]=v;}setUsername(v){this.fields.username=v;}setPassword(v){assert.ok(v.length>60);passwords++;}async save(){const f=this.fields;assert.equal(f.identityType,'member');assert.equal(f.adminRoleKey,undefined);assert.equal(f.password,undefined);await sql('INSERT INTO "_User" ("objectId",company,username,"legacyUserId","legacyUserData") VALUES($1,$2,$3,$4,$5::jsonb)',['new-user',f.company.id,f.username,f.legacyUserId,JSON.stringify(f.legacyUserData)]);}};
+ context.Parse.User=class{constructor(){this.fields={};}set(k,v){this.fields[k]=v;}setUsername(v){this.fields.username=v;}setPassword(v){assert.ok(v.length>60);passwords++;}async save(){this.id='new-user';const f=this.fields;assert.equal(f.identityType,'member');assert.equal(f.adminRoleKey,undefined);assert.equal(f.password,undefined);await sql('INSERT INTO "_User" ("objectId",company,username,"legacyUserId","legacyUserData") VALUES($1,$2,$3,$4,$5::jsonb)',['new-user',f.company.id,f.username,f.legacyUserId,JSON.stringify(f.legacyUserData)]);}};
  context.fetch=async url=>{const action=new URL(url).searchParams.get('action');return {ok:true,json:async()=>({retcode:0,page:{pageCount:1},result:action==='user_list'?[{UserID:20,UserName:'existing',GroupID:1,Purse:1},{UserID:4000,UserName:'new-member',GroupID:1,Purse:10,Password:'ignored-hash'}]:[{NodeID:8,NodeName:'旧目录'},{NodeID:9,ParentID:8,NodeName:'新目录'}]})};};
  context.fetch=async url=>{const action=new URL(url).searchParams.get('action');return {ok:true,json:async()=>({retcode:0,page:{pageCount:1},result:action==='user_list'?[{UserID:20,UserName:'existing',GroupID:1,Purse:1},{UserID:4000,UserName:'new-member',GroupID:1,Purse:10,Password:'ignored-hash'}]:[{NodeID:8,NodeName:'旧目录'},{NodeID:9,ParentID:8,NodeName:'新目录'}]})};};
  try{let r=await call({action:'supplement'});assert.equal(r.success,true,r.message);assert.equal(r.data.accounts.created,1);assert.equal(r.data.nodes.created,1);
  try{let r=await call({action:'supplement'});assert.equal(r.success,true,r.message);assert.equal(r.data.accounts.created,1);assert.equal(r.data.nodes.created,1);
   r=await call({action:'supplement'});assert.equal(r.success,true,r.message);assert.equal(r.data.accounts.created,0);assert.equal(r.data.nodes.created,0);assert.equal(passwords,1);
   r=await call({action:'supplement'});assert.equal(r.success,true,r.message);assert.equal(r.data.accounts.created,0);assert.equal(r.data.nodes.created,0);assert.equal(passwords,1);

+ 14 - 0
scripts/tests/launch-fixes.test.mjs

@@ -0,0 +1,14 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import {readFile,writeFile,mkdtemp,rm} from 'node:fs/promises';import {tmpdir} from 'node:os';import {join} from 'node:path';import {PGlite} from '@electric-sql/pglite';import {syncAlert} from '../lib/sync-alerts.mjs';
+test('同步告警:失败去重、持续失败次日提醒、恢复一次提醒、差异增长提醒且不包含原始错误',async()=>{const root=await mkdtemp(join(tmpdir(),'xs-alert-'));try{const messages=[];const notify=async m=>messages.push(m);await syncAlert({status:'blocked',reason:'secret-token'},root,notify,1000);await syncAlert({status:'blocked'},root,notify,2000);assert.equal(messages.length,1);await syncAlert({status:'blocked'},root,notify,86402000);assert.equal(messages.length,2);await syncAlert({status:'completed',conflicts:100},root,notify,86403000);assert.equal(messages.length,3);await syncAlert({status:'already_completed',previous:{conflicts:100}},root,notify,86404000);assert.equal(messages.length,3);await syncAlert({status:'completed',conflicts:250},root,notify,86405000);assert.equal(messages.length,4);assert(!messages.join().includes('secret'));}finally{await rm(root,{recursive:true});}});
+test('注销:重复申请仅一条、跨帐套隔离、未实际注销不可登记完成、并发版本拒绝',async()=>{const db=new PGlite();try{await db.exec(`CREATE TABLE "_User"("objectId" text,company text,"isDeleted" boolean);INSERT INTO "_User" VALUES('u','co',false)`);await db.exec(await readFile(new URL('../sql/account-deletion.sql',import.meta.url),'utf8'));const call=async(sql,args)=>(await db.query(sql,args)).rows[0].r;const a=await call('SELECT xs_request_deletion($1,$2,$3) r',['co','u','申请注销']);const b=await call('SELECT xs_request_deletion($1,$2,$3) r',['co','u','再次提交']);assert.equal(a.id,b.id);await assert.rejects(call('SELECT xs_request_deletion($1,$2,$3) r',['other','u','申请注销']),/账号不存在/);await assert.rejects(call('SELECT xs_process_deletion($1,$2,$3,$4,$5,$6) r',['co','admin',a.id,1,'completed','已核对']),/请先完成/);const c=await call('SELECT xs_process_deletion($1,$2,$3,$4,$5,$6) r',['co','admin',a.id,1,'processing','核对权益']);assert.equal(c.version,2);await assert.rejects(call('SELECT xs_process_deletion($1,$2,$3,$4,$5,$6) r',['co','admin',a.id,1,'rejected','版本旧']),/已更新/);await db.exec(`UPDATE "_User" SET "isDeleted"=true`);assert.equal((await call('SELECT xs_process_deletion($1,$2,$3,$4,$5,$6) r',['co','admin',a.id,2,'completed','已完成注销'])).status,'completed');}finally{await db.close();}});
+test('工资截止核验:区分文字差异与金额差异、版本改变失效、缺失记录拦截、整月审核门槛',async()=>{const db=new PGlite();try{
+ await db.exec(`CREATE TABLE "CommonModel"("objectId" text,company text,"generalId" numeric,"modelId" numeric,"itemId" numeric,title text,subtitle text);CREATE TABLE "CourseAppointment"(company text,id numeric,yysj text);CREATE TABLE "LessonRecord"(company text,id numeric,"lessonAt" text,"teacherPaySnapshot" numeric);CREATE TABLE "MemoryPracticeRecord"(company text,id numeric,wcsj text);CREATE TABLE "LegacySyncConflict"("objectId" text,company text,dataset text,"legacyKey" text,message text,resolved boolean);CREATE TABLE "ReviewReward"(id text);CREATE TABLE "TeachingCompensationRule"(id text);CREATE TABLE "CourseTeachingProfile"(id text);CREATE TABLE "CoachPayRate"(id text);CREATE TABLE "LessonRecordFact"(id text);CREATE FUNCTION xs_payroll_time(v text) RETURNS timestamptz LANGUAGE plpgsql AS $$BEGIN RETURN v::timestamptz;EXCEPTION WHEN others THEN RETURN NULL;END$$;CREATE FUNCTION xs_payroll_source_version(co text) RETURNS text LANGUAGE sql AS $$SELECT md5(COALESCE(jsonb_agg(to_jsonb(l))::text,'')) FROM "LessonRecord" l WHERE company=co$$;`);
+ await db.exec(await readFile(new URL('../sql/legacy-direct-sync.sql',import.meta.url),'utf8'));await db.exec(await readFile(new URL('../sql/payroll-cutoff.sql',import.meta.url),'utf8'));
+ await db.exec(`INSERT INTO "LegacyDirectRun"(company,"businessDate","runId",owner,"leaseUntil",status,"finishedAt") VALUES('co','2026-10-13','run','owner',now(),'completed','2026-10-13T15:00:00Z');INSERT INTO "CourseAppointment" VALUES('co',54,'2026-10-02');INSERT INTO "LessonRecord" VALUES('co',59,'2026-10-02',20);INSERT INTO "MemoryPracticeRecord" VALUES('co',60,'2026-10-02');INSERT INTO "CommonModel" SELECT 'c'||i,'co',i,i,i,'学员','单词' FROM unnest(ARRAY[54,59,60]) i;INSERT INTO "LegacyDirectEvidence" SELECT 'co','run',i,to_jsonb(ARRAY[i]),1,md5(i::text),md5(i::text),0,'content' FROM unnest(ARRAY[54,59,60]) i;INSERT INTO "LegacySyncConflict" VALUES('cosmetic','co','54','54','{"fields":["title"]}',false),('pay','co','59','59','{"fields":["teacherPaySnapshot"]}',false);`);
+ const one=async(sql,args=[])=>(await db.query(sql,args)).rows[0].r;
+ const snapshot=()=>one(`SELECT xs_payroll_cutoff_snapshot('co','2026-10') r`);
+ let s=await snapshot();assert.equal(s.pending,1);assert.equal(s.complete,true);assert.equal(s.approved,false);
+ const confirm=(v,cid='',fp='')=>one(`SELECT xs_payroll_cutoff_confirm('co','manager','2026-10',$1,'人工核对记录',$2,$3) r`,[v,cid,fp]);
+ await assert.rejects(confirm(s.version),/仍有差异/);const diff=s.conflicts.find(x=>x.id==='pay');s=await confirm(s.version,diff.id,diff.fingerprint);assert.equal(s.pending,0);s=await confirm(s.version);assert.equal(s.approved,true);await db.query(`SELECT xs_payroll_assert_cutoff('co','2026-10','create',$1)`,[s.version]);await assert.rejects(db.query(`SELECT xs_payroll_assert_cutoff('co','2026-10','review',$1)`,[s.version]),/整月/);
+ await db.exec(`UPDATE "LessonRecord" SET "teacherPaySnapshot"=30`);const stale=await snapshot();assert.equal(stale.approved,false);assert.equal(stale.pending,1);await assert.rejects(confirm(s.version),/来源已更新/);await db.exec(`DELETE FROM "CourseAppointment"`);assert.equal((await snapshot()).complete,false);
+ }finally{await db.close();}});

+ 9 - 12
scripts/tests/payroll-integrity.test.mjs

@@ -58,16 +58,13 @@ test('旧系统复习凭据仅人工确认后计薪,旧月份拒绝且重复
  assert.equal((await invoke(current.objectId)).alreadyConfirmed,1);assert.equal(Number((await one('SELECT amount FROM "ReviewReward" WHERE "objectId"=$1',[current.objectId])).amount),1);
  assert.equal((await invoke(current.objectId)).alreadyConfirmed,1);assert.equal(Number((await one('SELECT amount FROM "ReviewReward" WHERE "objectId"=$1',[current.objectId])).amount),1);
  await assert.rejects(q('UPDATE "ReviewReward" SET amount=2 WHERE "objectId"=$1',[current.objectId]),/不可改写/);
  await assert.rejects(q('UPDATE "ReviewReward" SET amount=2 WHERE "objectId"=$1',[current.objectId]),/不可改写/);
 });
 });
-test('工资结算仅在三个数据集追平、两分钟内同步且无失败时开放',async()=>{
- const keys=['appointments','lessons','memory-records'],countByModel={54:[1,2],59:[3,4,5],60:[6,7,8,9]};
- let checkpoints=keys.map((dataset,index)=>({dataset,state:'healthy',cursor:'v2',lastSuccessAt:new Date().toISOString(),lastWatermark:index+10})),issues=[];
- const ctx=vm.createContext({Date,process:{env:{XIAOSHU_LEGACY_SYNC_BASE_URL:'https://example.test',XIAOSHU_LEGACY_SYNC_KEY_ID:'key',XIAOSHU_LEGACY_SYNC_SECRET:'secret'}},Psql:{query:async(sql)=>sql.includes('LegacySyncCheckpoint')?checkpoints:sql.includes('LegacySyncDeadLetter')?issues:Object.entries(countByModel).map(([model,keys])=>({model,keys}))}});
- vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),ctx);
- ctx.companyIdOf=()=> 'co';ctx.sha256=async(value)=>'digest-'+value.replaceAll('\n',',');ctx.syncBridgeRequest=async()=>({consistent:true,datasets:keys.map((key,index)=>({key,count:[2,3,4][index],watermark:index+10,checksum:'digest-'+countByModel[[54,59,60][index]].join(',')+','}))});
- const healthy=await ctx.payrollSettlementSyncStatus(manager,{});assert.equal(healthy.settlementReady,true,JSON.stringify(healthy));
- checkpoints=checkpoints.map(row=>row.dataset==='lessons'?{...row,state:'catching_up'}:row);assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).settlementReady,false);
- checkpoints=checkpoints.map(row=>({...row,state:'healthy'}));issues=[{dataset:'appointments',kind:'failure',total:1}];assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).settlementReady,false);
- issues=[];checkpoints=checkpoints.map(row=>row.dataset==='memory-records'?{...row,lastSuccessAt:new Date(Date.now()-121000).toISOString()}:row);assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).settlementReady,false);
+test('工资网关只消费数据库月份截止核验,不再请求旧桥',async()=>{
+ let state={approved:true,version:'verified',monthClosed:false,complete:true,pending:0,cutoffAt:'2026-10-12T15:00:00Z',datasets:[]};
+ const ctx=vm.createContext({Date,Psql:{one:async(sql,args)=>{assert(sql.includes('xs_payroll_cutoff_snapshot'));assert.equal(args[1],'2026-10');return{result:state};}}});
+ vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),ctx);ctx.companyIdOf=()=> 'co';ctx.syncBridgeRequest=()=>{throw Error('must not use bridge');};
+ assert.equal((await ctx.payrollSettlementSyncStatus(manager,{month:'2026-10'})).settlementReady,true);
+ state={...state,approved:false,pending:1};assert.equal((await ctx.payrollSettlementSyncStatus(manager,{month:'2026-10'})).settlementReady,false);
+ ctx.Psql.one=async()=>{throw Error('offline');};assert.equal((await ctx.payrollSettlementSyncStatus(manager,{month:'2026-10'})).settlementReady,false);
 });
 });
 
 
 test('所选工资月份的已完课写作预约可逐人核对,其他月份不混入',async()=>{
 test('所选工资月份的已完课写作预约可逐人核对,其他月份不混入',async()=>{
@@ -82,8 +79,8 @@ test('手动同步真实收据显示完成时间,但不能冒充结算准备
  const receipt={source:'old-api/WXAPP',status:'completed-with-preserved-conflicts',completedAt:'2026-10-12T08:39:11.787Z',datasets:[{modelId:54,source:100,failed:0,conflicts:2,reconciled:true}]};
  const receipt={source:'old-api/WXAPP',status:'completed-with-preserved-conflicts',completedAt:'2026-10-12T08:39:11.787Z',datasets:[{modelId:54,source:100,failed:0,conflicts:2,reconciled:true}]};
  const ctx=vm.createContext({Date,Psql:{query:async()=>[{detail:JSON.stringify({after:receipt})}]}});
  const ctx=vm.createContext({Date,Psql:{query:async()=>[{detail:JSON.stringify({after:receipt})}]}});
  vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),ctx);ctx.companyIdOf=()=> 'co';
  vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),ctx);ctx.companyIdOf=()=> 'co';
- const status=await ctx.payrollSettlementSyncStatus(manager,{});assert.equal(status.state,'batch_verified');assert.equal(status.lastSuccessAt,receipt.completedAt);assert.equal(status.settlementReady,false);assert.equal(status.datasets[0].conflicts,2);
- receipt.datasets[0].reconciled=false;assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).state,'not_configured');
+ const fallback={state:'not_configured',settlementReady:false,datasets:[{key:'appointments'}]};const status=await ctx.directPayrollSyncStatus(manager,{},fallback);assert.equal(status.state,'batch_verified');assert.equal(status.lastSuccessAt,receipt.completedAt);assert.equal(status.settlementReady,false);assert.equal(status.datasets[0].conflicts,2);
+ receipt.datasets[0].reconciled=false;assert.equal((await ctx.directPayrollSyncStatus(manager,{},fallback)).state,'not_configured');
 });
 });
 
 
 test('学段核对页预填一致证据,旧档案空值回退有效,冲突时不自动选分类',async()=>{
 test('学段核对页预填一致证据,旧档案空值回退有效,冲突时不自动选分类',async()=>{

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