diff --git a/reports/review-20260905.zip b/reports/review-20260905.zip
new file mode 100644
index 0000000..59f5724
Binary files /dev/null and b/reports/review-20260905.zip differ
diff --git a/reports/review-20260905/backend-mvn-test.log b/reports/review-20260905/backend-mvn-test.log
new file mode 100644
index 0000000..94a2054
Binary files /dev/null and b/reports/review-20260905/backend-mvn-test.log differ
diff --git a/reports/review-20260905/backend-package.log b/reports/review-20260905/backend-package.log
new file mode 100644
index 0000000..7c38cfe
Binary files /dev/null and b/reports/review-20260905/backend-package.log differ
diff --git a/reports/review-20260905/backend-repro.log b/reports/review-20260905/backend-repro.log
new file mode 100644
index 0000000..6f7cbfa
Binary files /dev/null and b/reports/review-20260905/backend-repro.log differ
diff --git a/reports/review-20260905/backend-review.html b/reports/review-20260905/backend-review.html
new file mode 100644
index 0000000..c3f7879
--- /dev/null
+++ b/reports/review-20260905/backend-review.html
@@ -0,0 +1,67 @@
+
backend-review · Code Review 后端增量 Code Review
+日期:2026-09-05。仓库:unreal_tran_api;基线:3307c23 → b683bb7,57 个文件。审查开始时后端工作树干净;审查完成后按用户授权提交为 53d860b。
+资料 → 原型 → 设计 → 开发 → 测试
+资料和原型追溯见同目录 reference-review.md 。本轮沿用既有“三渠道共享训练定义、失败可以重试、全过程留痕”的设计,未扩展评分、提前提交或渠道分类。
+后端对照以下正式设计审查:wiki/19-训练定义渠道合同.md、wiki/20-教学命令幂等.md、wiki/21-教学步骤运行事实.md,并交叉核对 wiki/14-教学实施模块.md、接口总览、033/034 增量和完整 DDL。审查覆盖多渠道发布预检与固定快照、教员内容写权限、三个登录身份、四类命令回执、逐步推进/失败/重试/提交门槛、历史无事实兼容、正式考核和学员访问隔离。
+已修复:旧尝试的迟到证据错误完成重试
+严重度:P2。位置:TeachingStepRunService.validateEvidenceTime。
+重现顺序:步骤首次开始时间为 100,失败时间为 110,重新开始时间为 120;重试后补报发生于 115 的成功事件。旧实现只与首次 startedAt 和最近 failedAt 比较,因此把重试开始前的证据计入第二次尝试,错误将步骤变为 COMPLETED。同类迟到失败事件也可错误增加当前尝试失败次数。
+先添加回归用例并在旧实现运行,测试失败,错误为“预期抛出 BusinessException,但没有抛出”。证据:backend-repro.log 。
+修复使用已有 last_event_id 指向的重试激活事件,比较其发生时间;旧尝试的 MATCHED/UNMATCHED 返回 HTTP 400。首次开始时间仍保留,同秒或重试之后的有效事件仍可推进。缺失或跨运行的激活证据拒绝处理。没有新增字段、接口或训练步骤,也没有回填历史事实。
+新增测试同时验证迟到成功、迟到失败都不改变当前状态/尝试数/失败数/最近证据;重试同秒的有效成功可以完成,并保留首次开始时间。另更新四个服务测试中的依赖装配,并同步 wiki 01/14/21。
+其他审查结论
+
+多渠道配置由发布预检验证全部启用渠道;任务按渠道固化选中的 profile,运行读取固定快照。未发现需要改变产品行为的问题。
+内容写操作增加有效教员身份检查,并保留页面权限、动作权限和数据范围校验;纯管理员身份不能绕过教员要求。
+命令回执在运行锁内检查,同载荷回放先于旧版本校验;首次执行的运行、事件、步骤和回执共享事务。模拟服务回归覆盖重放与冲突;真实并发和回滚以主 E2E/数据库报告为准。
+新运行按发布步骤初始化,最后一步目标进度为 100%;历史无步骤行仅兼容读取/旧提交,不在 GET 中补写。
+学员越权、正式考核当前步骤投影、未完成提交、撤回时不删除执行事实均有现有测试覆盖。
+主审查在隔离 KingBase schema 发现 034 的独立唯一索引不能作为该目标版本的组合外键目标,由主审查负责新增 035 和完整 DDL 向前修复;数据库执行、恢复点与复跑证据由主报告记录。本后端子任务未连接或执行数据库。
+
+实际验证
+环境为 Temurin Java 17.0.20、Maven 3.9.14。
+
+
+
+验证
+结果
+证据
+
+
+
+
+修改前 mvn test -B -ntp
+401 项通过:Gateway 1、Auth 86、Tran 314
+Maven 退出码 0,完成时间 11:19:45
+
+
+新增回归用例运行于旧实现
+1 项失败,成功复现缺陷
+backend-repro.log
+
+
+四个相关服务类定向测试
+23 项通过
+backend-targeted.log
+
+
+修改后全量 mvn test -B -ntp
+402 项通过:Gateway 1、Auth 86、Tran 315;0 失败、0 错误、0 跳过
+backend-mvn-test.log ,11:24:52 完成
+
+
+mvn -DskipTests package -B -ntp
+三个服务打包成功,退出码 0
+backend-package.log ,11:25:19 完成
+
+
+git diff --check
+通过,无空白错误
+仅出现既有 CRLF 归一化提示
+
+
+
+测试为本地单元/服务装配验证。名称含 IntegrationTest 的相关测试仍使用 Mockito mapper;不能把它们作为真实数据库并发、设备接入或浏览器 E2E 已通过的证据。带截图的浏览器验证和真实 API/数据库检查由同目录主报告汇总。
+已观察到的非阻断警告:JVM 类共享提示、现有弃用/unchecked 编译警告、测试刻意模拟 Redis 失败后的数据库回源警告。没有据此修改依赖版本。
+功能确认边界
+本后端修复没有功能扩展;资料与现有产品的四项差异统一列于 reference-review.md ,随主测试报告交用户确认。未放宽“全部步骤完成才可提交”,未引入自动评分或新渠道。
2026-09-05 · 开发环境审查 · 图片可点击查看原尺寸
\ No newline at end of file
diff --git a/reports/review-20260905/backend-review.md b/reports/review-20260905/backend-review.md
new file mode 100644
index 0000000..093ace6
--- /dev/null
+++ b/reports/review-20260905/backend-review.md
@@ -0,0 +1,51 @@
+# 后端增量 Code Review
+
+日期:2026-09-05。仓库:`unreal_tran_api`;基线:`3307c23 → b683bb7`,57 个文件。审查开始时后端工作树干净;审查完成后按用户授权提交为 `53d860b`。
+
+## 资料 → 原型 → 设计 → 开发 → 测试
+
+资料和原型追溯见同目录 [reference-review.md](reference-review.md)。本轮沿用既有“三渠道共享训练定义、失败可以重试、全过程留痕”的设计,未扩展评分、提前提交或渠道分类。
+
+后端对照以下正式设计审查:`wiki/19-训练定义渠道合同.md`、`wiki/20-教学命令幂等.md`、`wiki/21-教学步骤运行事实.md`,并交叉核对 `wiki/14-教学实施模块.md`、接口总览、033/034 增量和完整 DDL。审查覆盖多渠道发布预检与固定快照、教员内容写权限、三个登录身份、四类命令回执、逐步推进/失败/重试/提交门槛、历史无事实兼容、正式考核和学员访问隔离。
+
+## 已修复:旧尝试的迟到证据错误完成重试
+
+严重度:P2。位置:`TeachingStepRunService.validateEvidenceTime`。
+
+重现顺序:步骤首次开始时间为 100,失败时间为 110,重新开始时间为 120;重试后补报发生于 115 的成功事件。旧实现只与首次 `startedAt` 和最近 `failedAt` 比较,因此把重试开始前的证据计入第二次尝试,错误将步骤变为 `COMPLETED`。同类迟到失败事件也可错误增加当前尝试失败次数。
+
+先添加回归用例并在旧实现运行,测试失败,错误为“预期抛出 BusinessException,但没有抛出”。证据:[backend-repro.log](backend-repro.log)。
+
+修复使用已有 `last_event_id` 指向的重试激活事件,比较其发生时间;旧尝试的 `MATCHED/UNMATCHED` 返回 HTTP 400。首次开始时间仍保留,同秒或重试之后的有效事件仍可推进。缺失或跨运行的激活证据拒绝处理。没有新增字段、接口或训练步骤,也没有回填历史事实。
+
+新增测试同时验证迟到成功、迟到失败都不改变当前状态/尝试数/失败数/最近证据;重试同秒的有效成功可以完成,并保留首次开始时间。另更新四个服务测试中的依赖装配,并同步 wiki 01/14/21。
+
+## 其他审查结论
+
+- 多渠道配置由发布预检验证全部启用渠道;任务按渠道固化选中的 profile,运行读取固定快照。未发现需要改变产品行为的问题。
+- 内容写操作增加有效教员身份检查,并保留页面权限、动作权限和数据范围校验;纯管理员身份不能绕过教员要求。
+- 命令回执在运行锁内检查,同载荷回放先于旧版本校验;首次执行的运行、事件、步骤和回执共享事务。模拟服务回归覆盖重放与冲突;真实并发和回滚以主 E2E/数据库报告为准。
+- 新运行按发布步骤初始化,最后一步目标进度为 100%;历史无步骤行仅兼容读取/旧提交,不在 GET 中补写。
+- 学员越权、正式考核当前步骤投影、未完成提交、撤回时不删除执行事实均有现有测试覆盖。
+- 主审查在隔离 KingBase schema 发现 034 的独立唯一索引不能作为该目标版本的组合外键目标,由主审查负责新增 035 和完整 DDL 向前修复;数据库执行、恢复点与复跑证据由主报告记录。本后端子任务未连接或执行数据库。
+
+## 实际验证
+
+环境为 Temurin Java 17.0.20、Maven 3.9.14。
+
+| 验证 | 结果 | 证据 |
+|---|---|---|
+| 修改前 `mvn test -B -ntp` | 401 项通过:Gateway 1、Auth 86、Tran 314 | Maven 退出码 0,完成时间 11:19:45 |
+| 新增回归用例运行于旧实现 | 1 项失败,成功复现缺陷 | [backend-repro.log](backend-repro.log) |
+| 四个相关服务类定向测试 | 23 项通过 | [backend-targeted.log](backend-targeted.log) |
+| 修改后全量 `mvn test -B -ntp` | 402 项通过:Gateway 1、Auth 86、Tran 315;0 失败、0 错误、0 跳过 | [backend-mvn-test.log](backend-mvn-test.log),11:24:52 完成 |
+| `mvn -DskipTests package -B -ntp` | 三个服务打包成功,退出码 0 | [backend-package.log](backend-package.log),11:25:19 完成 |
+| `git diff --check` | 通过,无空白错误 | 仅出现既有 CRLF 归一化提示 |
+
+测试为本地单元/服务装配验证。名称含 `IntegrationTest` 的相关测试仍使用 Mockito mapper;不能把它们作为真实数据库并发、设备接入或浏览器 E2E 已通过的证据。带截图的浏览器验证和真实 API/数据库检查由同目录主报告汇总。
+
+已观察到的非阻断警告:JVM 类共享提示、现有弃用/unchecked 编译警告、测试刻意模拟 Redis 失败后的数据库回源警告。没有据此修改依赖版本。
+
+## 功能确认边界
+
+本后端修复没有功能扩展;资料与现有产品的四项差异统一列于 [reference-review.md](reference-review.md),随主测试报告交用户确认。未放宽“全部步骤完成才可提交”,未引入自动评分或新渠道。
diff --git a/reports/review-20260905/backend-targeted.log b/reports/review-20260905/backend-targeted.log
new file mode 100644
index 0000000..ceb695e
Binary files /dev/null and b/reports/review-20260905/backend-targeted.log differ
diff --git a/reports/review-20260905/cleanup.json b/reports/review-20260905/cleanup.json
new file mode 100644
index 0000000..7295865
--- /dev/null
+++ b/reports/review-20260905/cleanup.json
@@ -0,0 +1,56 @@
+{
+ "tasks": [
+ {
+ "id": 48,
+ "assignment_code": "UTE2E-REVIEW-MTNUE3OB-VIRTUAL"
+ },
+ {
+ "id": 49,
+ "assignment_code": "UTE2E-REVIEW-MTNUE3OB-PHYSICAL"
+ }
+ ],
+ "deleted": {
+ "ut_teaching_step_run": 12,
+ "ut_teaching_command_receipt": 13,
+ "ut_teaching_xr_session": 0,
+ "ut_assistance_alert": 0,
+ "ut_assistance_evaluation": 0,
+ "ut_data_observation": 0,
+ "ut_teaching_event": 19,
+ "ut_teaching_intervention": 0,
+ "ut_teaching_assignment_fault": 0,
+ "ut_teaching_run_member": 2,
+ "ut_teaching_run": 2,
+ "ut_teaching_member": 2,
+ "ut_teaching_assignment": 2
+ },
+ "time": "2026-09-05T11:48:55.347416",
+ "testUsersDisabled": [
+ 76,
+ 77,
+ 81,
+ 78,
+ 79,
+ 74,
+ 75,
+ 88,
+ 80,
+ 82,
+ 83,
+ 85,
+ 86,
+ 87,
+ 91,
+ 93,
+ 90,
+ 94,
+ 92
+ ],
+ "originalRowsUnchanged": {
+ "ut_teaching_assignment": true,
+ "ut_teaching_run": true,
+ "ut_teaching_run_member": true,
+ "ut_teaching_event": true
+ },
+ "remainingActiveTestUsers": 0
+}
\ No newline at end of file
diff --git a/reports/review-20260905/database-migration.json b/reports/review-20260905/database-migration.json
new file mode 100644
index 0000000..9148fdb
--- /dev/null
+++ b/reports/review-20260905/database-migration.json
@@ -0,0 +1,143 @@
+{
+ "environment": "development unreal_tran/public",
+ "time": "2026-09-05T11:22:17.562488",
+ "backup": "E:\\work\\edit_3d\\.codex-tmp\\review-20260905\\database-before.json",
+ "validation": [
+ {
+ "interrupted": false,
+ "first": [
+ {
+ "file": "20260904_033_teaching_command_receipt.sql",
+ "sha256": "3b62137942c54d2fc50f6ee9975d20c44f8d2b240d8fff0c153970052e5b8d43",
+ "checks": [
+ {
+ "invalid_teaching_command_receipts": 0
+ },
+ {
+ "orphan_teaching_command_receipts": 0
+ }
+ ]
+ },
+ {
+ "file": "20260905_035_teaching_step_run_kingbase_constraints.sql",
+ "sha256": "53d0baae9b90e37172277dd5e346542cce3d95b02595dc94e742c4ecaa8e3c31",
+ "checks": [
+ {
+ "invalid_teaching_step_runs": 0
+ },
+ {
+ "orphan_teaching_step_runs": 0
+ }
+ ]
+ }
+ ],
+ "repeat": [
+ {
+ "file": "20260904_033_teaching_command_receipt.sql",
+ "sha256": "3b62137942c54d2fc50f6ee9975d20c44f8d2b240d8fff0c153970052e5b8d43",
+ "checks": [
+ {
+ "invalid_teaching_command_receipts": 0
+ },
+ {
+ "orphan_teaching_command_receipts": 0
+ }
+ ]
+ },
+ {
+ "file": "20260905_035_teaching_step_run_kingbase_constraints.sql",
+ "sha256": "53d0baae9b90e37172277dd5e346542cce3d95b02595dc94e742c4ecaa8e3c31",
+ "checks": [
+ {
+ "invalid_teaching_step_runs": 0
+ },
+ {
+ "orphan_teaching_step_runs": 0
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "interrupted": true,
+ "first": [
+ {
+ "file": "20260904_033_teaching_command_receipt.sql",
+ "sha256": "3b62137942c54d2fc50f6ee9975d20c44f8d2b240d8fff0c153970052e5b8d43",
+ "checks": [
+ {
+ "invalid_teaching_command_receipts": 0
+ },
+ {
+ "orphan_teaching_command_receipts": 0
+ }
+ ]
+ },
+ {
+ "file": "20260905_035_teaching_step_run_kingbase_constraints.sql",
+ "sha256": "53d0baae9b90e37172277dd5e346542cce3d95b02595dc94e742c4ecaa8e3c31",
+ "checks": [
+ {
+ "invalid_teaching_step_runs": 0
+ },
+ {
+ "orphan_teaching_step_runs": 0
+ }
+ ]
+ }
+ ],
+ "repeat": [
+ {
+ "file": "20260904_033_teaching_command_receipt.sql",
+ "sha256": "3b62137942c54d2fc50f6ee9975d20c44f8d2b240d8fff0c153970052e5b8d43",
+ "checks": [
+ {
+ "invalid_teaching_command_receipts": 0
+ },
+ {
+ "orphan_teaching_command_receipts": 0
+ }
+ ]
+ },
+ {
+ "file": "20260905_035_teaching_step_run_kingbase_constraints.sql",
+ "sha256": "53d0baae9b90e37172277dd5e346542cce3d95b02595dc94e742c4ecaa8e3c31",
+ "checks": [
+ {
+ "invalid_teaching_step_runs": 0
+ },
+ {
+ "orphan_teaching_step_runs": 0
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "executed": [
+ {
+ "file": "20260904_033_teaching_command_receipt.sql",
+ "sha256": "3b62137942c54d2fc50f6ee9975d20c44f8d2b240d8fff0c153970052e5b8d43",
+ "checks": [
+ {
+ "invalid_teaching_command_receipts": 0
+ },
+ {
+ "orphan_teaching_command_receipts": 0
+ }
+ ]
+ },
+ {
+ "file": "20260905_035_teaching_step_run_kingbase_constraints.sql",
+ "sha256": "53d0baae9b90e37172277dd5e346542cce3d95b02595dc94e742c4ecaa8e3c31",
+ "checks": [
+ {
+ "invalid_teaching_step_runs": 0
+ },
+ {
+ "orphan_teaching_step_runs": 0
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/review-20260905/database-review.html b/reports/review-20260905/database-review.html
new file mode 100644
index 0000000..cc0f5f9
--- /dev/null
+++ b/reports/review-20260905/database-review.html
@@ -0,0 +1,73 @@
+database-review · Code Review 数据库迁移与清理独立复核
+复核日期:2026-09-05。本附录只读取迁移文件、全量 DDL、tools/review-db.py 和执行 JSON,并做静态比较;没有再次连接数据库、执行迁移或修改业务数据。实际执行证据来自主审查生成的 database-migration.json 和 cleanup.json 。
+结论与原始缺陷
+原 034 为运行、运行成员和事件建立独立唯一索引,再以组合外键引用。主执行者在目标 KingbaseES V008R006C009B0014 的隔离 schema 验证发现,该版本要求外键目标为显式 UNIQUE 约束,仅有独立唯一索引时创建步骤表失败,报外键没有匹配唯一约束(SQLSTATE 42830)。这会阻断新步骤事实表部署。
+修复新增 20260905_035_teaching_step_run_kingbase_constraints.sql:在 005 基础结构和 033 后执行,以 035 替代 034;历史 034 文件保持原样。035 使用系统目录判断并创建三个 _aggregate_fk 显式唯一约束,再幂等建立步骤表和查询索引。它不包含业务行更新、删除或历史事实回填。
+部署文档已同步为当前开发库执行 033、035;其他环境必须独立核对迁移状态。035 含 DO $$...$$,执行客户端必须正确识别 dollar quote,不能简单按分号切分。
+035 与全量 DDL 一致性
+独立比较 035 与 database/unreal_tran_full.sql 的步骤表定义,除 IF NOT EXISTS 和空白格式外完全一致。
+
+
+
+对照项
+结果
+
+
+
+
+父表聚合 UNIQUE 约束
+3 个;名称与列顺序一致:运行 (id, assignment_id)、运行成员 (id, run_id)、事件 (id, run_id, assignment_id)
+
+
+步骤表外键
+4 个:任务、运行归属、运行成员归属、事件归属;均为 RESTRICT
+
+
+步骤活动唯一键
+2 个:(run_id, step_code, delete_token) 和 (run_id, step_order, delete_token)
+
+
+CHECK 约束
+11 个,覆盖顺序、目标进度、状态、尝试/失败次数、证据来源、时间、回填标记、版本和软删除字段
+
+
+步骤查询索引
+3 个,按任务状态、运行当前步骤和最近事件查询
+
+
+
+父表新增组合唯一键都包含已有主键 id,不改变既有行的业务唯一性。步骤表字段和四个外键引用列一致,未发现本次修复会破坏既有业务行或放松运行归属校验的问题。
+实际执行与恢复验证边界
+database-migration.json 记录的目标为开发环境 unreal_tran/public,迁移前记录时间为 2026-09-05T11:22:17.562488。执行文件指纹与复核时工作区文件一致:
+
+
+
+文件
+SHA-256
+
+
+
+
+20260904_033_teaching_command_receipt.sql
+3b62137942c54d2fc50f6ee9975d20c44f8d2b240d8fff0c153970052e5b8d43
+
+
+20260905_035_teaching_step_run_kingbase_constraints.sql
+53d0baae9b90e37172277dd5e346542cce3d95b02595dc94e742c4ecaa8e3c31
+
+
+
+执行工具先读取本地开发配置并核对数据库/schema,再执行以下路径:
+
+在专用隔离 schema 建立最小父表,首次完整执行 033、035,再完整重跑。
+在另一专用隔离 schema 模拟“033 回执表已提交、035 首个父表唯一约束已提交”的中断状态,再完整执行和重跑。
+隔离检查成功后,切回 public 执行 033、035。
+
+JSON 中上述各路径的无效记录和孤儿记录检查均为 0。当前执行工具也会在迁移后检查出现非零结果时抛错,避免继续报告成功。隔离验证证明的是这两个增量在最小父表上的首次、重复和指定中断点恢复;它不是全量建库脚本、任意中断点或历史数据回填的验收。
+此执行记录只证明所列开发环境已迁移,不表示其他开发实例、测试库或生产库已执行。
+备份与测试数据清理
+执行前的 database-before.json 位于工作区 .codex-tmp/review-20260905/,复核确认文件存在。它保存任务、运行、运行成员、事件四张相关表的既有数据以及列、索引元数据,是本次扩展式迁移的本地对照快照;不是整库物理备份,也未据此宣称完成整库恢复演练。报告不复制快照中的业务明细或连接凭据。
+清理工具按 UTE2E-REVIEW- 测试任务前缀选取记录,并排除迁移前快照已有任务 ID。删除在同一事务中按外键依赖顺序执行:先步骤事实、命令回执及运行附属测试数据,再事件、干预/故障关联、运行成员、运行、教学成员和任务。ut_teaching_run.member_id 引用的教学成员在运行删除之后处理。
+测试用户按本轮时间及测试标识筛选,临时管理员另按本地记录的精确 ID、用户名和非保护身份确认。清理撤销其会话,并软删除角色关系、凭证与用户,同时停用用户、递增令牌版本;并非物理删除所有审计记录。临时管理员明文凭据文件在清理后移除。
+复核读取的清理记录中,四张快照表既有行的 originalRowsUnchanged 均为 true,remainingActiveTestUsers 为 0。该比较只覆盖这四张表的快照既有行,不代表逐字比较了整库全部数据。重复截图测试后的最终清理时间、任务列表和数量以最新 cleanup.json 为准,本附录不固定清理数量。
+未发现当前执行记录反映出误删既有业务数据或约束损坏。复核提出的清理验收建议已落实:工具写出记录后,若任一既有行对照不一致或仍有活动测试用户,就以错误退出。主任务负责最终清理和主报告汇总。
2026-09-05 · 开发环境审查 · 图片可点击查看原尺寸
\ No newline at end of file
diff --git a/reports/review-20260905/database-review.md b/reports/review-20260905/database-review.md
new file mode 100644
index 0000000..06138ba
--- /dev/null
+++ b/reports/review-20260905/database-review.md
@@ -0,0 +1,56 @@
+# 数据库迁移与清理独立复核
+
+复核日期:2026-09-05。本附录只读取迁移文件、全量 DDL、`tools/review-db.py` 和执行 JSON,并做静态比较;没有再次连接数据库、执行迁移或修改业务数据。实际执行证据来自主审查生成的 [database-migration.json](database-migration.json) 和 [cleanup.json](cleanup.json)。
+
+## 结论与原始缺陷
+
+原 034 为运行、运行成员和事件建立独立唯一索引,再以组合外键引用。主执行者在目标 KingbaseES `V008R006C009B0014` 的隔离 schema 验证发现,该版本要求外键目标为显式 `UNIQUE` 约束,仅有独立唯一索引时创建步骤表失败,报外键没有匹配唯一约束(SQLSTATE `42830`)。这会阻断新步骤事实表部署。
+
+修复新增 `20260905_035_teaching_step_run_kingbase_constraints.sql`:在 005 基础结构和 033 后执行,以 035 替代 034;历史 034 文件保持原样。035 使用系统目录判断并创建三个 `_aggregate_fk` 显式唯一约束,再幂等建立步骤表和查询索引。它不包含业务行更新、删除或历史事实回填。
+
+部署文档已同步为当前开发库执行 033、035;其他环境必须独立核对迁移状态。035 含 `DO $$...$$`,执行客户端必须正确识别 dollar quote,不能简单按分号切分。
+
+## 035 与全量 DDL 一致性
+
+独立比较 `035` 与 `database/unreal_tran_full.sql` 的步骤表定义,除 `IF NOT EXISTS` 和空白格式外完全一致。
+
+| 对照项 | 结果 |
+|---|---|
+| 父表聚合 UNIQUE 约束 | 3 个;名称与列顺序一致:运行 `(id, assignment_id)`、运行成员 `(id, run_id)`、事件 `(id, run_id, assignment_id)` |
+| 步骤表外键 | 4 个:任务、运行归属、运行成员归属、事件归属;均为 `RESTRICT` |
+| 步骤活动唯一键 | 2 个:`(run_id, step_code, delete_token)` 和 `(run_id, step_order, delete_token)` |
+| CHECK 约束 | 11 个,覆盖顺序、目标进度、状态、尝试/失败次数、证据来源、时间、回填标记、版本和软删除字段 |
+| 步骤查询索引 | 3 个,按任务状态、运行当前步骤和最近事件查询 |
+
+父表新增组合唯一键都包含已有主键 `id`,不改变既有行的业务唯一性。步骤表字段和四个外键引用列一致,未发现本次修复会破坏既有业务行或放松运行归属校验的问题。
+
+## 实际执行与恢复验证边界
+
+[database-migration.json](database-migration.json) 记录的目标为开发环境 `unreal_tran/public`,迁移前记录时间为 `2026-09-05T11:22:17.562488`。执行文件指纹与复核时工作区文件一致:
+
+| 文件 | SHA-256 |
+|---|---|
+| `20260904_033_teaching_command_receipt.sql` | `3b62137942c54d2fc50f6ee9975d20c44f8d2b240d8fff0c153970052e5b8d43` |
+| `20260905_035_teaching_step_run_kingbase_constraints.sql` | `53d0baae9b90e37172277dd5e346542cce3d95b02595dc94e742c4ecaa8e3c31` |
+
+执行工具先读取本地开发配置并核对数据库/schema,再执行以下路径:
+
+1. 在专用隔离 schema 建立最小父表,首次完整执行 033、035,再完整重跑。
+2. 在另一专用隔离 schema 模拟“033 回执表已提交、035 首个父表唯一约束已提交”的中断状态,再完整执行和重跑。
+3. 隔离检查成功后,切回 `public` 执行 033、035。
+
+JSON 中上述各路径的无效记录和孤儿记录检查均为 0。当前执行工具也会在迁移后检查出现非零结果时抛错,避免继续报告成功。隔离验证证明的是这两个增量在最小父表上的首次、重复和指定中断点恢复;它不是全量建库脚本、任意中断点或历史数据回填的验收。
+
+此执行记录只证明所列开发环境已迁移,不表示其他开发实例、测试库或生产库已执行。
+
+## 备份与测试数据清理
+
+执行前的 `database-before.json` 位于工作区 `.codex-tmp/review-20260905/`,复核确认文件存在。它保存任务、运行、运行成员、事件四张相关表的既有数据以及列、索引元数据,是本次扩展式迁移的本地对照快照;不是整库物理备份,也未据此宣称完成整库恢复演练。报告不复制快照中的业务明细或连接凭据。
+
+清理工具按 `UTE2E-REVIEW-` 测试任务前缀选取记录,并排除迁移前快照已有任务 ID。删除在同一事务中按外键依赖顺序执行:先步骤事实、命令回执及运行附属测试数据,再事件、干预/故障关联、运行成员、运行、教学成员和任务。`ut_teaching_run.member_id` 引用的教学成员在运行删除之后处理。
+
+测试用户按本轮时间及测试标识筛选,临时管理员另按本地记录的精确 ID、用户名和非保护身份确认。清理撤销其会话,并软删除角色关系、凭证与用户,同时停用用户、递增令牌版本;并非物理删除所有审计记录。临时管理员明文凭据文件在清理后移除。
+
+复核读取的清理记录中,四张快照表既有行的 `originalRowsUnchanged` 均为 `true`,`remainingActiveTestUsers` 为 0。该比较只覆盖这四张表的快照既有行,不代表逐字比较了整库全部数据。重复截图测试后的最终清理时间、任务列表和数量以最新 [cleanup.json](cleanup.json) 为准,本附录不固定清理数量。
+
+未发现当前执行记录反映出误删既有业务数据或约束损坏。复核提出的清理验收建议已落实:工具写出记录后,若任一既有行对照不一致或仍有活动测试用户,就以错误退出。主任务负责最终清理和主报告汇总。
diff --git a/reports/review-20260905/diagnostics/cleanup-h264-probe.json b/reports/review-20260905/diagnostics/cleanup-h264-probe.json
new file mode 100644
index 0000000..ee138ee
--- /dev/null
+++ b/reports/review-20260905/diagnostics/cleanup-h264-probe.json
@@ -0,0 +1,54 @@
+{
+ "tasks": [
+ {
+ "id": 46,
+ "assignment_code": "UTE2E-REVIEW-MTNU5WZM-VIRTUAL"
+ },
+ {
+ "id": 47,
+ "assignment_code": "UTE2E-REVIEW-MTNU5WZM-PHYSICAL"
+ }
+ ],
+ "deleted": {
+ "ut_teaching_step_run": 12,
+ "ut_teaching_command_receipt": 13,
+ "ut_teaching_xr_session": 0,
+ "ut_assistance_alert": 0,
+ "ut_assistance_evaluation": 0,
+ "ut_data_observation": 0,
+ "ut_teaching_event": 19,
+ "ut_teaching_intervention": 0,
+ "ut_teaching_assignment_fault": 0,
+ "ut_teaching_run_member": 2,
+ "ut_teaching_run": 2,
+ "ut_teaching_member": 2,
+ "ut_teaching_assignment": 2
+ },
+ "time": "2026-09-05T11:46:59.169164",
+ "testUsersDisabled": [
+ 76,
+ 77,
+ 81,
+ 78,
+ 79,
+ 74,
+ 75,
+ 88,
+ 80,
+ 82,
+ 83,
+ 85,
+ 86,
+ 87,
+ 91,
+ 90,
+ 89
+ ],
+ "originalRowsUnchanged": {
+ "ut_teaching_assignment": true,
+ "ut_teaching_run": true,
+ "ut_teaching_run_member": true,
+ "ut_teaching_event": true
+ },
+ "remainingActiveTestUsers": 0
+}
\ No newline at end of file
diff --git a/reports/review-20260905/diagnostics/cleanup-initial.json b/reports/review-20260905/diagnostics/cleanup-initial.json
new file mode 100644
index 0000000..197d2a3
--- /dev/null
+++ b/reports/review-20260905/diagnostics/cleanup-initial.json
@@ -0,0 +1,52 @@
+{
+ "tasks": [
+ {
+ "id": 41,
+ "assignment_code": "UTE2E-REVIEW-MTNTPI4D-VIRTUAL"
+ },
+ {
+ "id": 42,
+ "assignment_code": "UTE2E-REVIEW-MTNTSDB9-VIRTUAL"
+ },
+ {
+ "id": 43,
+ "assignment_code": "UTE2E-REVIEW-MTNTSDB9-PHYSICAL"
+ }
+ ],
+ "deleted": {
+ "ut_teaching_step_run": 20,
+ "ut_teaching_command_receipt": 14,
+ "ut_teaching_xr_session": 0,
+ "ut_assistance_alert": 0,
+ "ut_assistance_evaluation": 0,
+ "ut_data_observation": 0,
+ "ut_teaching_event": 23,
+ "ut_teaching_intervention": 0,
+ "ut_teaching_assignment_fault": 0,
+ "ut_teaching_run_member": 3,
+ "ut_teaching_run": 3,
+ "ut_teaching_member": 3,
+ "ut_teaching_assignment": 3
+ },
+ "time": "2026-09-05T11:33:17.346755",
+ "testUsersDisabled": [
+ 76,
+ 77,
+ 81,
+ 78,
+ 79,
+ 74,
+ 75,
+ 80,
+ 82,
+ 83,
+ 73
+ ],
+ "originalRowsUnchanged": {
+ "ut_teaching_assignment": true,
+ "ut_teaching_run": true,
+ "ut_teaching_run_member": true,
+ "ut_teaching_event": true
+ },
+ "remainingActiveTestUsers": 0
+}
\ No newline at end of file
diff --git a/reports/review-20260905/diagnostics/cleanup-resource-wait.json b/reports/review-20260905/diagnostics/cleanup-resource-wait.json
new file mode 100644
index 0000000..4e15a8e
--- /dev/null
+++ b/reports/review-20260905/diagnostics/cleanup-resource-wait.json
@@ -0,0 +1,52 @@
+{
+ "tasks": [
+ {
+ "id": 44,
+ "assignment_code": "UTE2E-REVIEW-MTNU0I0K-VIRTUAL"
+ },
+ {
+ "id": 45,
+ "assignment_code": "UTE2E-REVIEW-MTNU4T2Z-PHYSICAL"
+ }
+ ],
+ "deleted": {
+ "ut_teaching_step_run": 12,
+ "ut_teaching_command_receipt": 13,
+ "ut_teaching_xr_session": 0,
+ "ut_assistance_alert": 0,
+ "ut_assistance_evaluation": 0,
+ "ut_data_observation": 0,
+ "ut_teaching_event": 19,
+ "ut_teaching_intervention": 0,
+ "ut_teaching_assignment_fault": 0,
+ "ut_teaching_run_member": 2,
+ "ut_teaching_run": 2,
+ "ut_teaching_member": 2,
+ "ut_teaching_assignment": 2
+ },
+ "time": "2026-09-05T11:41:19.926058",
+ "testUsersDisabled": [
+ 76,
+ 77,
+ 81,
+ 78,
+ 79,
+ 74,
+ 75,
+ 88,
+ 80,
+ 82,
+ 83,
+ 85,
+ 86,
+ 87,
+ 84
+ ],
+ "originalRowsUnchanged": {
+ "ut_teaching_assignment": true,
+ "ut_teaching_run": true,
+ "ut_teaching_run_member": true,
+ "ut_teaching_event": true
+ },
+ "remainingActiveTestUsers": 0
+}
\ No newline at end of file
diff --git a/reports/review-20260905/diagnostics/failure-live-login-roles-and-formal-virtual-rout.png b/reports/review-20260905/diagnostics/failure-live-login-roles-and-formal-virtual-rout.png
new file mode 100644
index 0000000..18c5f21
Binary files /dev/null and b/reports/review-20260905/diagnostics/failure-live-login-roles-and-formal-virtual-rout.png differ
diff --git a/reports/review-20260905/diagnostics/failure-live-physical-failure-retry-rejects-stal.png b/reports/review-20260905/diagnostics/failure-live-physical-failure-retry-rejects-stal.png
new file mode 100644
index 0000000..2747763
Binary files /dev/null and b/reports/review-20260905/diagnostics/failure-live-physical-failure-retry-rejects-stal.png differ
diff --git a/reports/review-20260905/diagnostics/failure-live-teacher-role-and-content-write-boun.png b/reports/review-20260905/diagnostics/failure-live-teacher-role-and-content-write-boun.png
new file mode 100644
index 0000000..7f8dff3
Binary files /dev/null and b/reports/review-20260905/diagnostics/failure-live-teacher-role-and-content-write-boun.png differ
diff --git a/reports/review-20260905/diagnostics/failure-live-virtual-start-replay-step-facts-sub.png b/reports/review-20260905/diagnostics/failure-live-virtual-start-replay-step-facts-sub.png
new file mode 100644
index 0000000..7c9d5b9
Binary files /dev/null and b/reports/review-20260905/diagnostics/failure-live-virtual-start-replay-step-facts-sub.png differ
diff --git a/reports/review-20260905/diagnostics/live-chromium-h264-unsupported.json b/reports/review-20260905/diagnostics/live-chromium-h264-unsupported.json
new file mode 100644
index 0000000..8d674f5
--- /dev/null
+++ b/reports/review-20260905/diagnostics/live-chromium-h264-unsupported.json
@@ -0,0 +1,315 @@
+{
+ "config": {
+ "configFile": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\review-live.config.ts",
+ "rootDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
+ "forbidOnly": false,
+ "fullyParallel": false,
+ "globalSetup": null,
+ "globalTeardown": null,
+ "globalTimeout": 0,
+ "grep": {},
+ "grepInvert": null,
+ "maxFailures": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "preserveOutput": "always",
+ "reporter": [
+ [
+ "list",
+ null
+ ],
+ [
+ "html",
+ {
+ "outputFolder": "reports/review-20260905/live/html",
+ "open": "never"
+ }
+ ],
+ [
+ "json",
+ {
+ "outputFile": "reports/review-20260905/live/results.json"
+ }
+ ]
+ ],
+ "reportSlowTests": {
+ "max": 5,
+ "threshold": 300000
+ },
+ "quiet": false,
+ "projects": [
+ {
+ "outputDir": "E:/work/edit_3d/unreal_tran/ute2e/reports/review-20260905/live/results",
+ "repeatEach": 1,
+ "retries": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "id": "",
+ "name": "",
+ "testDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
+ "testIgnore": [],
+ "testMatch": [
+ "review-live.spec.ts"
+ ],
+ "timeout": 120000
+ }
+ ],
+ "shard": null,
+ "updateSnapshots": "missing",
+ "updateSourceMethod": "patch",
+ "version": "1.55.0",
+ "workers": 1,
+ "webServer": null
+ },
+ "suites": [
+ {
+ "title": "review-live.spec.ts",
+ "file": "review-live.spec.ts",
+ "column": 0,
+ "line": 0,
+ "specs": [
+ {
+ "title": "live login roles and formal virtual route",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 120000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 9181,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:41:24.437Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "01-login-three-roles",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-login-roles-and-formal-virtual-route\\attachments\\01-login-three-roles-f86162e84bea73eafa87bb0016f894f5e08d87b7.png"
+ },
+ {
+ "name": "02-virtual-formal-task-center",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-login-roles-and-formal-virtual-route\\attachments\\02-virtual-formal-task-center-b028ec31b409a20f12f7cf73dc7811535baab32e.png"
+ },
+ {
+ "name": "03-virtual-1920",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-login-roles-and-formal-virtual-route\\attachments\\03-virtual-1920-a16d403e0842f961f464136f9289448b6ab9b665.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "351158f247962162d567-8b85e2f4bb6d5591db73",
+ "file": "review-live.spec.ts",
+ "line": 101,
+ "column": 1
+ },
+ {
+ "title": "live teacher role and content write boundary",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 120000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 3742,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:41:37.239Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "04-teacher-content",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-teacher-role-and-content-write-boundary\\attachments\\04-teacher-content-9ec9c5654b6ad956d784269cf0e1e26d7dbe0821.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "351158f247962162d567-ccf9c84f5598cdf4198c",
+ "file": "review-live.spec.ts",
+ "line": 123,
+ "column": 1
+ },
+ {
+ "title": "live virtual start replay step facts submit and review",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 240000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 96799,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:41:40.993Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "05-virtual-started-no-exam-overlay",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-virtual-s-f74ac-tep-facts-submit-and-review\\attachments\\05-virtual-started-no-exam-overlay-75bbe0fc16a0254e73c609a1a3640d65844c77c7.png"
+ },
+ {
+ "name": "06-virtual-all-steps-completed",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-virtual-s-f74ac-tep-facts-submit-and-review\\attachments\\06-virtual-all-steps-completed-1fb317f851bbfea81cb72348d0c3fa6677c15524.png"
+ },
+ {
+ "name": "07-virtual-reviewed",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-virtual-s-f74ac-tep-facts-submit-and-review\\attachments\\07-virtual-reviewed-c18c9c866c30a33295adba3a8277bc7dd76aab69.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "351158f247962162d567-3ecee091831f9a0e83fd",
+ "file": "review-live.spec.ts",
+ "line": 134,
+ "column": 1
+ },
+ {
+ "title": "live physical failure retry rejects stale evidence",
+ "ok": false,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 120000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "failed",
+ "duration": 56458,
+ "error": {
+ "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBeGreaterThanOrEqual\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m\n\nExpected: >= \u001b[32m2\u001b[39m\nReceived: \u001b[31m0\u001b[39m\n\nCall Log:\n- Timeout 45000ms exceeded while waiting on the predicate",
+ "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBeGreaterThanOrEqual\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m\n\nExpected: >= \u001b[32m2\u001b[39m\nReceived: \u001b[31m0\u001b[39m\n\nCall Log:\n- Timeout 45000ms exceeded while waiting on the predicate\n at E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\review-live.spec.ts:233:3",
+ "location": {
+ "file": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\review-live.spec.ts",
+ "column": 3,
+ "line": 233
+ },
+ "snippet": " 231 | const video = page.locator('[data-physical-video=\"primary\"]')\n 232 | await expect(video).toBeVisible()\n> 233 | await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.readyState), { timeout: 45000 }).toBeGreaterThanOrEqual(2)\n | ^\n 234 | await video.evaluate((element: HTMLVideoElement) => { element.pause(); element.currentTime = 3 })\n 235 | await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.seeking && element.readyState >= 2)).toBe(true)\n 236 | await shot(page, info, '09-physical-offline-replay-boundary')"
+ },
+ "errors": [
+ {
+ "location": {
+ "file": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\review-live.spec.ts",
+ "column": 3,
+ "line": 233
+ },
+ "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBeGreaterThanOrEqual\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m\n\nExpected: >= \u001b[32m2\u001b[39m\nReceived: \u001b[31m0\u001b[39m\n\nCall Log:\n- Timeout 45000ms exceeded while waiting on the predicate\n\n 231 | const video = page.locator('[data-physical-video=\"primary\"]')\n 232 | await expect(video).toBeVisible()\n> 233 | await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.readyState), { timeout: 45000 }).toBeGreaterThanOrEqual(2)\n | ^\n 234 | await video.evaluate((element: HTMLVideoElement) => { element.pause(); element.currentTime = 3 })\n 235 | await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.seeking && element.readyState >= 2)).toBe(true)\n 236 | await shot(page, info, '09-physical-offline-replay-boundary')\n at E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\review-live.spec.ts:233:3"
+ }
+ ],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "steps": [
+ {
+ "title": "Expect \"poll toBeGreaterThanOrEqual\"",
+ "duration": 44540,
+ "error": {
+ "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBeGreaterThanOrEqual\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m\n\nExpected: >= \u001b[32m2\u001b[39m\nReceived: \u001b[31m0\u001b[39m\n\nCall Log:\n- Timeout 45000ms exceeded while waiting on the predicate",
+ "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBeGreaterThanOrEqual\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m\n\nExpected: >= \u001b[32m2\u001b[39m\nReceived: \u001b[31m0\u001b[39m\n\nCall Log:\n- Timeout 45000ms exceeded while waiting on the predicate\n at E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\review-live.spec.ts:233:3",
+ "location": {
+ "file": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\review-live.spec.ts",
+ "column": 3,
+ "line": 233
+ },
+ "snippet": " 231 | const video = page.locator('[data-physical-video=\"primary\"]')\n 232 | await expect(video).toBeVisible()\n> 233 | await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.readyState), { timeout: 45000 }).toBeGreaterThanOrEqual(2)\n | ^\n 234 | await video.evaluate((element: HTMLVideoElement) => { element.pause(); element.currentTime = 3 })\n 235 | await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.seeking && element.readyState >= 2)).toBe(true)\n 236 | await shot(page, info, '09-physical-offline-replay-boundary')"
+ }
+ }
+ ],
+ "startTime": "2026-09-05T03:43:17.807Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "08-physical-api-task-list",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-physical--961b7-etry-rejects-stale-evidence\\attachments\\08-physical-api-task-list-7e7551f3cc59abf587e5d2dc7d0767de16241cf6.png"
+ },
+ {
+ "name": "failure-live-physical-failure-retry-rejects-stal",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-physical--961b7-etry-rejects-stale-evidence\\attachments\\failure-live-physical-failure-retry-rejects-stal-9b9b8ac01847809d43c66bfa7b814a6e686ef35e.png"
+ },
+ {
+ "name": "error-context",
+ "contentType": "text/markdown",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-physical--961b7-etry-rejects-stale-evidence\\error-context.md"
+ }
+ ],
+ "errorLocation": {
+ "file": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\review-live.spec.ts",
+ "column": 3,
+ "line": 233
+ }
+ }
+ ],
+ "status": "unexpected"
+ }
+ ],
+ "id": "351158f247962162d567-f2520dc34ab1c6ead4ee",
+ "file": "review-live.spec.ts",
+ "line": 201,
+ "column": 1
+ }
+ ]
+ }
+ ],
+ "errors": [],
+ "stats": {
+ "startTime": "2026-09-05T03:41:23.265Z",
+ "duration": 172634.013,
+ "expected": 3,
+ "skipped": 0,
+ "unexpected": 1,
+ "flaky": 0
+ }
+}
\ No newline at end of file
diff --git a/reports/review-20260905/diagnostics/live-resource-wait-timeout.json b/reports/review-20260905/diagnostics/live-resource-wait-timeout.json
new file mode 100644
index 0000000..5cad86e
--- /dev/null
+++ b/reports/review-20260905/diagnostics/live-resource-wait-timeout.json
@@ -0,0 +1,283 @@
+{
+ "config": {
+ "configFile": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\review-live.config.ts",
+ "rootDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
+ "forbidOnly": false,
+ "fullyParallel": false,
+ "globalSetup": null,
+ "globalTeardown": null,
+ "globalTimeout": 0,
+ "grep": {},
+ "grepInvert": null,
+ "maxFailures": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "preserveOutput": "always",
+ "reporter": [
+ [
+ "list",
+ null
+ ],
+ [
+ "html",
+ {
+ "outputFolder": "reports/review-20260905/live/html",
+ "open": "never"
+ }
+ ],
+ [
+ "json",
+ {
+ "outputFile": "reports/review-20260905/live/results.json"
+ }
+ ]
+ ],
+ "reportSlowTests": {
+ "max": 5,
+ "threshold": 300000
+ },
+ "quiet": false,
+ "projects": [
+ {
+ "outputDir": "E:/work/edit_3d/unreal_tran/ute2e/reports/review-20260905/live/results",
+ "repeatEach": 1,
+ "retries": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "id": "",
+ "name": "",
+ "testDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
+ "testIgnore": [],
+ "testMatch": [
+ "review-live.spec.ts"
+ ],
+ "timeout": 120000
+ }
+ ],
+ "shard": null,
+ "updateSnapshots": "missing",
+ "updateSourceMethod": "patch",
+ "version": "1.55.0",
+ "workers": 1,
+ "webServer": null
+ },
+ "suites": [
+ {
+ "title": "review-live.spec.ts",
+ "file": "review-live.spec.ts",
+ "column": 0,
+ "line": 0,
+ "specs": [
+ {
+ "title": "live login roles and formal virtual route",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 120000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 10256,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:37:11.787Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "01-login-three-roles",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-login-roles-and-formal-virtual-route\\attachments\\01-login-three-roles-f86162e84bea73eafa87bb0016f894f5e08d87b7.png"
+ },
+ {
+ "name": "02-virtual-formal-task-center",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-login-roles-and-formal-virtual-route\\attachments\\02-virtual-formal-task-center-b028ec31b409a20f12f7cf73dc7811535baab32e.png"
+ },
+ {
+ "name": "03-virtual-1920",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-login-roles-and-formal-virtual-route\\attachments\\03-virtual-1920-a16d403e0842f961f464136f9289448b6ab9b665.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "351158f247962162d567-8b85e2f4bb6d5591db73",
+ "file": "review-live.spec.ts",
+ "line": 101,
+ "column": 1
+ },
+ {
+ "title": "live teacher role and content write boundary",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 120000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 52926,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:37:25.888Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "04-teacher-content",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-teacher-role-and-content-write-boundary\\attachments\\04-teacher-content-9ec9c5654b6ad956d784269cf0e1e26d7dbe0821.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "351158f247962162d567-ccf9c84f5598cdf4198c",
+ "file": "review-live.spec.ts",
+ "line": 123,
+ "column": 1
+ },
+ {
+ "title": "live virtual start replay step facts submit and review",
+ "ok": false,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 120000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "timedOut",
+ "duration": 131103,
+ "error": {
+ "message": "\u001b[31mTest timeout of 120000ms exceeded.\u001b[39m",
+ "stack": "\u001b[31mTest timeout of 120000ms exceeded.\u001b[39m"
+ },
+ "errors": [
+ {
+ "message": "\u001b[31mTest timeout of 120000ms exceeded.\u001b[39m"
+ }
+ ],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:38:18.835Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "05-virtual-started-no-exam-overlay",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-virtual-s-f74ac-tep-facts-submit-and-review\\attachments\\05-virtual-started-no-exam-overlay-75bbe0fc16a0254e73c609a1a3640d65844c77c7.png"
+ },
+ {
+ "name": "06-virtual-all-steps-completed",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-virtual-s-f74ac-tep-facts-submit-and-review\\attachments\\06-virtual-all-steps-completed-1fb317f851bbfea81cb72348d0c3fa6677c15524.png"
+ },
+ {
+ "name": "07-virtual-reviewed",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-virtual-s-f74ac-tep-facts-submit-and-review\\attachments\\07-virtual-reviewed-c18c9c866c30a33295adba3a8277bc7dd76aab69.png"
+ },
+ {
+ "name": "failure-live-virtual-start-replay-step-facts-sub",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-virtual-s-f74ac-tep-facts-submit-and-review\\attachments\\failure-live-virtual-start-replay-step-facts-sub-cd0e8dd222e097c02b3a85ee382e8ce140e70b84.png"
+ },
+ {
+ "name": "error-context",
+ "contentType": "text/markdown",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-virtual-s-f74ac-tep-facts-submit-and-review\\error-context.md"
+ }
+ ]
+ }
+ ],
+ "status": "unexpected"
+ }
+ ],
+ "id": "351158f247962162d567-3ecee091831f9a0e83fd",
+ "file": "review-live.spec.ts",
+ "line": 134,
+ "column": 1
+ },
+ {
+ "title": "live physical failure retry rejects stale evidence",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 120000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 1,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 10859,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:40:32.720Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "08-physical-api-task-list",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-physical--961b7-etry-rejects-stale-evidence\\attachments\\08-physical-api-task-list-7e7551f3cc59abf587e5d2dc7d0767de16241cf6.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "351158f247962162d567-f2520dc34ab1c6ead4ee",
+ "file": "review-live.spec.ts",
+ "line": 193,
+ "column": 1
+ }
+ ]
+ }
+ ],
+ "errors": [],
+ "stats": {
+ "startTime": "2026-09-05T03:37:09.607Z",
+ "duration": 219129.49300000002,
+ "expected": 3,
+ "skipped": 0,
+ "unexpected": 1,
+ "flaky": 0
+ }
+}
\ No newline at end of file
diff --git a/reports/review-20260905/diagnostics/video-probe-msedge.json b/reports/review-20260905/diagnostics/video-probe-msedge.json
new file mode 100644
index 0000000..262114b
--- /dev/null
+++ b/reports/review-20260905/diagnostics/video-probe-msedge.json
@@ -0,0 +1,41 @@
+{
+ "video": {
+ "source": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "currentSrc": "http://127.0.0.1:6180/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "readyState": 4,
+ "networkState": 1,
+ "error": null,
+ "mp4": "probably",
+ "webm": "probably"
+ },
+ "events": [
+ {
+ "path": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "status": 200,
+ "type": "video/mp4"
+ },
+ {
+ "path": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "failed": "net::ERR_ABORTED"
+ },
+ {
+ "path": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "status": 206,
+ "type": "video/mp4"
+ },
+ {
+ "path": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "failed": "net::ERR_ABORTED"
+ },
+ {
+ "path": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "status": 206,
+ "type": "video/mp4"
+ },
+ {
+ "path": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "status": 206,
+ "type": "video/mp4"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/review-20260905/diagnostics/video-probe.json b/reports/review-20260905/diagnostics/video-probe.json
new file mode 100644
index 0000000..bc76aa0
--- /dev/null
+++ b/reports/review-20260905/diagnostics/video-probe.json
@@ -0,0 +1,36 @@
+{
+ "video": {
+ "source": null,
+ "currentSrc": "http://127.0.0.1:6180/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "readyState": 0,
+ "networkState": 0,
+ "error": null,
+ "mp4": "",
+ "webm": "probably"
+ },
+ "events": [
+ {
+ "path": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "status": 200,
+ "type": "video/mp4"
+ },
+ {
+ "path": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "failed": "net::ERR_ABORTED"
+ },
+ {
+ "path": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "status": 206,
+ "type": "video/mp4"
+ },
+ {
+ "path": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "failed": "net::ERR_ABORTED"
+ },
+ {
+ "path": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "status": 206,
+ "type": "video/mp4"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/review-20260905/environment.json b/reports/review-20260905/environment.json
new file mode 100644
index 0000000..450664d
--- /dev/null
+++ b/reports/review-20260905/environment.json
@@ -0,0 +1,29 @@
+{
+ "time": "2026-09-05T03:49:45.613Z",
+ "results": [
+ {
+ "url": "http://127.0.0.1:6180/",
+ "status": 200
+ },
+ {
+ "url": "http://127.0.0.1:6180/api/auth/v1/auth/login-context",
+ "status": 200
+ },
+ {
+ "url": "http://127.0.0.1:6100/actuator/health",
+ "status": 200
+ },
+ {
+ "url": "http://127.0.0.1:6101/actuator/health",
+ "status": 200
+ },
+ {
+ "url": "http://127.0.0.1:6102/actuator/health",
+ "status": 200
+ },
+ {
+ "url": "http://192.168.31.168:6180/",
+ "status": 200
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/review-20260905/frontend-build.log b/reports/review-20260905/frontend-build.log
new file mode 100644
index 0000000..781cf1c
Binary files /dev/null and b/reports/review-20260905/frontend-build.log differ
diff --git a/reports/review-20260905/frontend-contract-tests.log b/reports/review-20260905/frontend-contract-tests.log
new file mode 100644
index 0000000..1dda144
Binary files /dev/null and b/reports/review-20260905/frontend-contract-tests.log differ
diff --git a/reports/review-20260905/frontend-e2e/README.md b/reports/review-20260905/frontend-e2e/README.md
new file mode 100644
index 0000000..701dada
--- /dev/null
+++ b/reports/review-20260905/frontend-e2e/README.md
@@ -0,0 +1,67 @@
+# 前端浏览器回归报告(API mock)
+
+2026-09-05 11:37:55(Asia/Shanghai)开始,最终 **15 项通过,0 项失败,0 项跳过**,耗时117.7秒。共保存17张截图,截图同时嵌入 [Playwright HTML报告](html/index.html)。机器结果见 [results.json](results.json)。
+
+本组使用真实 Vite 页面与 Chromium,接口全部由 Playwright 按契约模拟;没有修改开发数据库。这些结果证明界面交互、保存请求与条件渲染通过回归,不等于真实后端鉴权、持久化、并发事务或现场硬件验收。真实服务 E2E 见主报告的 live 部分。
+
+## 测试结果
+
+| 测试 | 数量 | 验证结果 |
+|---|---:|---|
+| 已保存步骤上移、删除、复制 | 3 | VIRTUAL为默认且PHYSICAL同时启用;改变已保存步骤后,发出的PUT顺序连续,进度分别为33/67/100、50/100、25/50/75/100;原稳定步骤编码保留,复制没有重号。保存后刷新仍保持顺序。保存请求JSON作为HTML报告附件。 |
+| 通用编辑器多渠道保存 | 1 | 修改VIRTUAL渲染配置后保存并刷新仍保留;PHYSICAL原有工位与CONFRONTATION原配置不丢;切换默认对抗时读取既有6人/45分钟,改60分钟后保存刷新保持;顶层modeConfig与默认profile一致,三个启用渠道保留。归一化补入默认字段属于现有契约允许行为。 |
+| 失败步骤重试与结束状态 | 3 | 运行IN_PROGRESS且当前步骤FAILED时显示“重试当前步骤”;点击发出一个start,body.commandId与Idempotency-Key一致;返回事实后尝试2次、未通过1次,重试按钮消失,刷新一致。SUBMITTED/TERMINATED即使有FAILED事实也没有重试入口。 |
+| 关闭遮罩状态矩阵 | 6 | 普通虚拟、实装、对抗组件及进行中正式考核均无关闭遮罩且存在Canvas;正式考核ACCEPTED/SUBMITTED有关闭遮罩且无Canvas。六例没有页面运行时异常。 |
+| 军工绿/暗色主题视觉验证 | 2 | 场景宿主与场景窗宽高差不超过2px;安全确认卡为不透明主题背景,标题/复选框文字对比度均不低于4.5;点击实际复选框后显示1/2,保留截图与尺寸/颜色测量附件。 |
+
+实装、对抗遮罩案例通过正式虚拟运行路由承载公共 `TeachingRunView` 并传入相应渠道响应,用于隔离检查组件渲染。它们没有宣称默认实装离线回放页面已经接通真实事件,也没有宣称默认对抗入口已完成服务端迁移。
+
+## 截图证据
+
+### 步骤重排后保存
+
+
+
+### 多渠道配置切换与保存
+
+对抗原每队6人保留,单局时长由45改为60,保存刷新后保持。
+
+
+
+### 失败步骤重试
+
+重试前:
+
+
+
+重试后:
+
+
+
+运行图使用最小场景夹具,网格/缺少模型提示不作为三维资源正确性的证据。真实模型渲染以live报告截图为准。
+
+### 正式考核提交后关闭场景
+
+
+
+### 场景高度与安全确认区主题
+
+原有场景根元素定位被scoped样式覆盖,导致画布只占场景窗上部;安全确认区缺少独立背景,浅主题文字落在深色场景上。CSS修复后,画布铺满场景窗,安全条件成为可读且可交互的主题卡片。
+
+1440×1100浏览器视口中,两主题的场景宿主与场景窗均为630×583.72px,尺寸一致。标题/复选框文字对比度:军工绿12.00,暗色15.30(断言下限4.5)。
+
+
+
+
+
+## 复现与检查
+
+测试代码:[review-frontend-regressions.spec.ts](../../../tests/review-frontend-regressions.spec.ts)。专用配置:[review-frontend.config.ts](../../../review-frontend.config.ts)。
+
+在 `E:/work/edit_3d/unreal_tran/ute2e` 且前端6180运行时执行:
+
+```powershell
+node node_modules/@playwright/test/cli.js test --config review-frontend.config.ts
+```
+
+新增测试文件及专用配置已通过TypeScript严格检查。最终结果未使用自动重试掩盖失败(retries=0)。测试开发阶段修正过夹具权限、Vite模块拦截范围、Element Plus复选框点击目标和断言文案,最终15例为同一轮完整运行结果。
diff --git a/reports/review-20260905/frontend-e2e/html/data/1d34a3447b62516187091f674829111047fdd29a.png b/reports/review-20260905/frontend-e2e/html/data/1d34a3447b62516187091f674829111047fdd29a.png
new file mode 100644
index 0000000..42b9faf
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/1d34a3447b62516187091f674829111047fdd29a.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/25157efb4ca9feae31555d1aa878771cfe0f081e.png b/reports/review-20260905/frontend-e2e/html/data/25157efb4ca9feae31555d1aa878771cfe0f081e.png
new file mode 100644
index 0000000..fc7274e
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/25157efb4ca9feae31555d1aa878771cfe0f081e.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/27aa542c750cf05e5b9f3fb810b745364f4e67fb.png b/reports/review-20260905/frontend-e2e/html/data/27aa542c750cf05e5b9f3fb810b745364f4e67fb.png
new file mode 100644
index 0000000..44953d8
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/27aa542c750cf05e5b9f3fb810b745364f4e67fb.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/53a79f260da65455fe8292ac925458e182e4a51e.png b/reports/review-20260905/frontend-e2e/html/data/53a79f260da65455fe8292ac925458e182e4a51e.png
new file mode 100644
index 0000000..6ebb143
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/53a79f260da65455fe8292ac925458e182e4a51e.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/5574046f252300a9cbf0b274615079bc65fcb29a.png b/reports/review-20260905/frontend-e2e/html/data/5574046f252300a9cbf0b274615079bc65fcb29a.png
new file mode 100644
index 0000000..64134e1
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/5574046f252300a9cbf0b274615079bc65fcb29a.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/586c15b138840fed956bb58e6d162d8d232cb653.png b/reports/review-20260905/frontend-e2e/html/data/586c15b138840fed956bb58e6d162d8d232cb653.png
new file mode 100644
index 0000000..4e892a6
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/586c15b138840fed956bb58e6d162d8d232cb653.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/6f1e1027e07d216f665111bcad618329fa2a4305.png b/reports/review-20260905/frontend-e2e/html/data/6f1e1027e07d216f665111bcad618329fa2a4305.png
new file mode 100644
index 0000000..a05ad2f
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/6f1e1027e07d216f665111bcad618329fa2a4305.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/6f2e019fc9ce7e35c4af5f9f4ecd3cce13cf7fd1.png b/reports/review-20260905/frontend-e2e/html/data/6f2e019fc9ce7e35c4af5f9f4ecd3cce13cf7fd1.png
new file mode 100644
index 0000000..f4dc6ee
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/6f2e019fc9ce7e35c4af5f9f4ecd3cce13cf7fd1.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/6f8cbf0816c225b1d4bee85e045bc07e4cc2b0c2.png b/reports/review-20260905/frontend-e2e/html/data/6f8cbf0816c225b1d4bee85e045bc07e4cc2b0c2.png
new file mode 100644
index 0000000..d2d4ede
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/6f8cbf0816c225b1d4bee85e045bc07e4cc2b0c2.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/7779233c92405619737f2cd921733277347ce289.png b/reports/review-20260905/frontend-e2e/html/data/7779233c92405619737f2cd921733277347ce289.png
new file mode 100644
index 0000000..1201265
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/7779233c92405619737f2cd921733277347ce289.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/7a7ffa0023a061eaef3f89a19ecab4e2ac7bf574.png b/reports/review-20260905/frontend-e2e/html/data/7a7ffa0023a061eaef3f89a19ecab4e2ac7bf574.png
new file mode 100644
index 0000000..2cdb90e
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/7a7ffa0023a061eaef3f89a19ecab4e2ac7bf574.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/7c69ef416e7230699fb418a4221fe8efd6477433.png b/reports/review-20260905/frontend-e2e/html/data/7c69ef416e7230699fb418a4221fe8efd6477433.png
new file mode 100644
index 0000000..9f96350
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/7c69ef416e7230699fb418a4221fe8efd6477433.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/7ffd9d0688c7175b26d83924214044c5bdc1504a.png b/reports/review-20260905/frontend-e2e/html/data/7ffd9d0688c7175b26d83924214044c5bdc1504a.png
new file mode 100644
index 0000000..aab97ff
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/7ffd9d0688c7175b26d83924214044c5bdc1504a.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/85b8639674d3489909c68bd55c40604a5edcd883.png b/reports/review-20260905/frontend-e2e/html/data/85b8639674d3489909c68bd55c40604a5edcd883.png
new file mode 100644
index 0000000..76ee5f3
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/85b8639674d3489909c68bd55c40604a5edcd883.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/8779c611763d255208729df4919647d94d750b6c.png b/reports/review-20260905/frontend-e2e/html/data/8779c611763d255208729df4919647d94d750b6c.png
new file mode 100644
index 0000000..090df89
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/8779c611763d255208729df4919647d94d750b6c.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/8b54b127a3f4c16eb282ebcad3b6345257e236bd.png b/reports/review-20260905/frontend-e2e/html/data/8b54b127a3f4c16eb282ebcad3b6345257e236bd.png
new file mode 100644
index 0000000..d033928
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/8b54b127a3f4c16eb282ebcad3b6345257e236bd.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/data/d277a85ec9cd5fe221a423c1ce8daf51c0a862f8.png b/reports/review-20260905/frontend-e2e/html/data/d277a85ec9cd5fe221a423c1ce8daf51c0a862f8.png
new file mode 100644
index 0000000..a0ea4f5
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/html/data/d277a85ec9cd5fe221a423c1ce8daf51c0a862f8.png differ
diff --git a/reports/review-20260905/frontend-e2e/html/index.html b/reports/review-20260905/frontend-e2e/html/index.html
new file mode 100644
index 0000000..9e855af
--- /dev/null
+++ b/reports/review-20260905/frontend-e2e/html/index.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/reports/review-20260905/frontend-e2e/results.json b/reports/review-20260905/frontend-e2e/results.json
new file mode 100644
index 0000000..b4f7cbe
--- /dev/null
+++ b/reports/review-20260905/frontend-e2e/results.json
@@ -0,0 +1,794 @@
+{
+ "config": {
+ "configFile": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\review-frontend.config.ts",
+ "rootDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
+ "forbidOnly": false,
+ "fullyParallel": false,
+ "globalSetup": null,
+ "globalTeardown": null,
+ "globalTimeout": 0,
+ "grep": {},
+ "grepInvert": null,
+ "maxFailures": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "preserveOutput": "always",
+ "reporter": [
+ [
+ "list",
+ null
+ ],
+ [
+ "html",
+ {
+ "outputFolder": "reports/review-20260905/frontend-e2e/html",
+ "open": "never"
+ }
+ ],
+ [
+ "json",
+ {
+ "outputFile": "reports/review-20260905/frontend-e2e/results.json"
+ }
+ ]
+ ],
+ "reportSlowTests": {
+ "max": 5,
+ "threshold": 300000
+ },
+ "quiet": false,
+ "projects": [
+ {
+ "outputDir": "E:/work/edit_3d/unreal_tran/ute2e/reports/review-20260905/frontend-e2e/test-results",
+ "repeatEach": 1,
+ "retries": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "id": "chromium-api-mock",
+ "name": "chromium-api-mock",
+ "testDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
+ "testIgnore": [],
+ "testMatch": [
+ "review-frontend-regressions.spec.ts"
+ ],
+ "timeout": 90000
+ }
+ ],
+ "shard": null,
+ "updateSnapshots": "missing",
+ "updateSourceMethod": "patch",
+ "version": "1.55.0",
+ "workers": 1,
+ "webServer": null
+ },
+ "suites": [
+ {
+ "title": "review-frontend-regressions.spec.ts",
+ "file": "review-frontend-regressions.spec.ts",
+ "column": 0,
+ "line": 0,
+ "specs": [
+ {
+ "title": "API mock浏览器回归:通用编辑器保存并切换默认渠道不丢配置",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 5207,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "steps": [
+ {
+ "title": "Expect \"poll toBe\"",
+ "duration": 1
+ },
+ {
+ "title": "Expect \"poll toBe\"",
+ "duration": 1
+ }
+ ],
+ "startTime": "2026-09-05T03:38:08.136Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "02-multichannel-virtual-save",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regressions-API-mock浏览器回归:通用编辑器保存并切换默认渠道不丢配置-chromium-api-mock\\attachments\\02-multichannel-virtual-save-f8e49c240b66150dd1858ef106264dc91130c97a.png"
+ },
+ {
+ "name": "03-multichannel-confrontation-save",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regressions-API-mock浏览器回归:通用编辑器保存并切换默认渠道不丢配置-chromium-api-mock\\attachments\\03-multichannel-confrontation-save-5cf678a81bd438a1b8b4f49a1ecdeb78961c06d6.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-711264a873cad041a2cf",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 245,
+ "column": 1
+ }
+ ],
+ "suites": [
+ {
+ "title": "API mock浏览器回归:已保存的实装步骤",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 204,
+ "column": 6,
+ "specs": [
+ {
+ "title": "move后保存重算顺序和实装进度",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 4112,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "steps": [
+ {
+ "title": "Expect \"poll toBe\"",
+ "duration": 2
+ }
+ ],
+ "startTime": "2026-09-05T03:37:57.110Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "保存请求中的训练内容(API mock)",
+ "contentType": "application/json",
+ "body": "ewogICJzY2hlbWEiOiAidW5yZWFsLXRyYW4udHJhaW5pbmcvdjEiLAogICJ2ZXJzaW9uIjogMSwKICAidHJhaW5pbmdNb2RlIjogIlZJUlRVQUwiLAogICJlbmFibGVkQ2hhbm5lbHMiOiBbCiAgICAiVklSVFVBTCIsCiAgICAiUEhZU0lDQUwiLAogICAgIkNPTkZST05UQVRJT04iCiAgXSwKICAiY2hhbm5lbFByb2ZpbGVzIjogewogICAgIlZJUlRVQUwiOiB7CiAgICAgICJ0eXBlIjogIlZJUlRVQUwiLAogICAgICAicmVuZGVyTW9kZSI6ICLljp/mnInmuLLmn5PmlrnlvI8iLAogICAgICAiaW50ZXJhY3Rpb25Nb2RlIjogIuS4iee7tOaTjeS9nOOAgeWKqOeUu+W8leWvvOS4juinhOWImeiHquWKqOWIpOWumiIsCiAgICAgICJhc3Npc3RhbmNlIjogIua8lOekuuOAgeiuree7g+S4juaooeaLn+iAg+ivlSIKICAgIH0sCiAgICAiUEhZU0lDQUwiOiB7CiAgICAgICJ0eXBlIjogIlBIWVNJQ0FMIiwKICAgICAgInN0YXRpb25OYW1lIjogIuS/neeVmeeahOWunuijheW3peS9jSIsCiAgICAgICJlcXVpcG1lbnRDb2RlIjogIiIsCiAgICAgICJnYXRld2F5UHJvdG9jb2wiOiAiT1BDIFVBIC8gTW9kYnVzIFRDUCIsCiAgICAgICJzZW5zb3JDaGFubmVscyI6ICIiLAogICAgICAic2FmZXR5SW50ZXJsb2NrcyI6ICIiLAogICAgICAibWFudWFsQ29uZmlybUFsbG93ZWQiOiB0cnVlCiAgICB9LAogICAgIkNPTkZST05UQVRJT04iOiB7CiAgICAgICJ0eXBlIjogIkNPTkZST05UQVRJT04iLAogICAgICAicmVkVGVhbU5hbWUiOiAi57qi5pa5IiwKICAgICAgImJsdWVUZWFtTmFtZSI6ICLok53mlrkiLAogICAgICAidGVhbVNpemUiOiA2LAogICAgICAicm91bmREdXJhdGlvbk1pbnV0ZXMiOiA0NSwKICAgICAgIm9iamVjdGl2ZSI6ICLljY/lkIznm67moIflupTkv53nlZkiLAogICAgICAid2luUnVsZSI6ICIiLAogICAgICAic3VibWl0UG9zaXRpb25zIjogWwogICAgICAgICJDT01NQU5ERVIiCiAgICAgIF0KICAgIH0KICB9LAogICJsZXZlbCI6ICLkuK3nuqciLAogICJkdXJhdGlvbk1pbnV0ZXMiOiAxNSwKICAidG90YWxTY29yZSI6IDEwMCwKICAib2JqZWN0aXZlcyI6IFtdLAogICJzdGVwcyI6IFsKICAgIHsKICAgICAgImlkIjogInNhdmVkLTEiLAogICAgICAic3RlcENvZGUiOiAiRklYRUQtUzAxIiwKICAgICAgIm9yZGVyIjogMSwKICAgICAgInRpdGxlIjogIuW3suS/neWtmOatpemqpDEiLAogICAgICAic3VtbWFyeSI6ICLmtY/op4jlmajlpZHnuqblm57lvZLmraXpqqQiLAogICAgICAic2NvcmUiOiAzMywKICAgICAgImFjdGlvbkNvZGUiOiAiRklYRUQtQTAxIiwKICAgICAgImFsbG93ZWRQb3NpdGlvbnMiOiBbCiAgICAgICAgIkNPTU1BTkRFUiIKICAgICAgXSwKICAgICAgInBoeXNpY2FsRXZlbnRSdWxlIjogewogICAgICAgICJzb3VyY2UiOiAiTUFOVUFMIiwKICAgICAgICAiZXZlbnRUeXBlIjogIlJFVklFV19FVkVOVF8wMSIsCiAgICAgICAgInByb2dyZXNzUGVyY2VudCI6IDMzLAogICAgICAgICJtYW51YWxDb25maXJtQWxsb3dlZCI6IHRydWUKICAgICAgfSwKICAgICAgInRhcmdldElkIjogImN5bGluZGVyLW1haW4iLAogICAgICAidGFyZ2V0TmFtZSI6ICLmlK/ohb/mtrLljovnvLjmgLvmiJAiLAogICAgICAibW9kZSI6ICJpbnNwZWN0IiwKICAgICAgImludGVyYWN0aW9uIjogInRhcmdldC1jb25maXJtIiwKICAgICAgImluc3RydWN0aW9uIjogIiIsCiAgICAgICJndWlkYW5jZSI6ICIiLAogICAgICAid2FybmluZyI6ICIiLAogICAgICAiZGVzdGluYXRpb24iOiAiIiwKICAgICAgInN1Y2Nlc3MiOiAiIiwKICAgICAgImFjdGlvbiI6IHsKICAgICAgICAia2luZCI6ICJydWxlLWNoZWNrIiwKICAgICAgICAidGFyZ2V0SWQiOiAiIiwKICAgICAgICAibW9kZSI6ICJpbnNwZWN0IiwKICAgICAgICAiZHVyYXRpb25NcyI6IDY1MCwKICAgICAgICAidG9sZXJhbmNlIjogMCwKICAgICAgICAicmVxdWlyZXNQbGFjZW1lbnQiOiBmYWxzZQogICAgICB9LAogICAgICAidG9vbElkIjogIiIsCiAgICAgICJzYWZldHlSdWxlcyI6IFsKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAicHBlQ29uZmlybWVkIiwKICAgICAgICAgICJsYWJlbCI6ICLkuKrkurrpmLLmiqTnlKjlk4HpvZDlhagiLAogICAgICAgICAgInJlcXVpcmVkIjogdHJ1ZQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgImlkIjogInJvdXRlQXV0aG9yaXplZCIsCiAgICAgICAgICAibGFiZWwiOiAi5L2c5Lia6Lev57q/5bey5o6I5p2DIiwKICAgICAgICAgICJyZXF1aXJlZCI6IHRydWUKICAgICAgICB9CiAgICAgIF0sCiAgICAgICJhdWRpbyI6IHsKICAgICAgICAibmFycmF0aW9uVGV4dCI6ICIiLAogICAgICAgICJuYXJyYXRpb25Bc3NldENvZGUiOiAiIiwKICAgICAgICAic2FmZXR5VGV4dCI6ICIiLAogICAgICAgICJzYWZldHlBc3NldENvZGUiOiAiIiwKICAgICAgICAic3VjY2Vzc0N1ZSI6ICJ1aSIsCiAgICAgICAgImVycm9yQ3VlIjogImNvbGxpc2lvbi1lcnJvciIKICAgICAgfSwKICAgICAgImFuaW1hdGlvbkJpbmRpbmciOiBudWxsLAogICAgICAiZmF1bHRJZHMiOiBbXSwKICAgICAgImFzc2Vzc21lbnQiOiB7CiAgICAgICAgIndlaWdodCI6IDMzLAogICAgICAgICJyZXF1aXJlZERpbWVuc2lvbnMiOiBbCiAgICAgICAgICAiYWN0aW9uIiwKICAgICAgICAgICJzZXF1ZW5jZSIsCiAgICAgICAgICAic2FmZXR5IgogICAgICAgIF0sCiAgICAgICAgInBhc3NQb2xpY3kiOiAiYWxsLXJlcXVpcmVkIgogICAgICB9CiAgICB9LAogICAgewogICAgICAiaWQiOiAic2F2ZWQtMyIsCiAgICAgICJzdGVwQ29kZSI6ICJGSVhFRC1TMDMiLAogICAgICAib3JkZXIiOiAyLAogICAgICAidGl0bGUiOiAi5bey5L+d5a2Y5q2l6aqkMyIsCiAgICAgICJzdW1tYXJ5IjogIua1j+iniOWZqOWlkee6puWbnuW9kuatpemqpCIsCiAgICAgICJzY29yZSI6IDM0LAogICAgICAiYWN0aW9uQ29kZSI6ICJGSVhFRC1BMDMiLAogICAgICAiYWxsb3dlZFBvc2l0aW9ucyI6IFsKICAgICAgICAiQ09NTUFOREVSIgogICAgICBdLAogICAgICAicGh5c2ljYWxFdmVudFJ1bGUiOiB7CiAgICAgICAgInNvdXJjZSI6ICJNQU5VQUwiLAogICAgICAgICJldmVudFR5cGUiOiAiUkVWSUVXX0VWRU5UXzAzIiwKICAgICAgICAicHJvZ3Jlc3NQZXJjZW50IjogNjcsCiAgICAgICAgIm1hbnVhbENvbmZpcm1BbGxvd2VkIjogdHJ1ZQogICAgICB9LAogICAgICAidGFyZ2V0SWQiOiAiY3lsaW5kZXItbWFpbiIsCiAgICAgICJ0YXJnZXROYW1lIjogIuaUr+iFv+a2suWOi+e8uOaAu+aIkCIsCiAgICAgICJtb2RlIjogImluc3BlY3QiLAogICAgICAiaW50ZXJhY3Rpb24iOiAidGFyZ2V0LWNvbmZpcm0iLAogICAgICAiaW5zdHJ1Y3Rpb24iOiAiIiwKICAgICAgImd1aWRhbmNlIjogIiIsCiAgICAgICJ3YXJuaW5nIjogIiIsCiAgICAgICJkZXN0aW5hdGlvbiI6ICIiLAogICAgICAic3VjY2VzcyI6ICIiLAogICAgICAiYWN0aW9uIjogewogICAgICAgICJraW5kIjogInJ1bGUtY2hlY2siLAogICAgICAgICJ0YXJnZXRJZCI6ICIiLAogICAgICAgICJtb2RlIjogImluc3BlY3QiLAogICAgICAgICJkdXJhdGlvbk1zIjogNjUwLAogICAgICAgICJ0b2xlcmFuY2UiOiAwLAogICAgICAgICJyZXF1aXJlc1BsYWNlbWVudCI6IGZhbHNlCiAgICAgIH0sCiAgICAgICJ0b29sSWQiOiAiIiwKICAgICAgInNhZmV0eVJ1bGVzIjogWwogICAgICAgIHsKICAgICAgICAgICJpZCI6ICJwcGVDb25maXJtZWQiLAogICAgICAgICAgImxhYmVsIjogIuS4quS6uumYsuaKpOeUqOWTgem9kOWFqCIsCiAgICAgICAgICAicmVxdWlyZWQiOiB0cnVlCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAicm91dGVBdXRob3JpemVkIiwKICAgICAgICAgICJsYWJlbCI6ICLkvZzkuJrot6/nur/lt7LmjojmnYMiLAogICAgICAgICAgInJlcXVpcmVkIjogdHJ1ZQogICAgICAgIH0KICAgICAgXSwKICAgICAgImF1ZGlvIjogewogICAgICAgICJuYXJyYXRpb25UZXh0IjogIiIsCiAgICAgICAgIm5hcnJhdGlvbkFzc2V0Q29kZSI6ICIiLAogICAgICAgICJzYWZldHlUZXh0IjogIiIsCiAgICAgICAgInNhZmV0eUFzc2V0Q29kZSI6ICIiLAogICAgICAgICJzdWNjZXNzQ3VlIjogInVpIiwKICAgICAgICAiZXJyb3JDdWUiOiAiY29sbGlzaW9uLWVycm9yIgogICAgICB9LAogICAgICAiYW5pbWF0aW9uQmluZGluZyI6IG51bGwsCiAgICAgICJmYXVsdElkcyI6IFtdLAogICAgICAiYXNzZXNzbWVudCI6IHsKICAgICAgICAid2VpZ2h0IjogMzQsCiAgICAgICAgInJlcXVpcmVkRGltZW5zaW9ucyI6IFsKICAgICAgICAgICJhY3Rpb24iLAogICAgICAgICAgInNlcXVlbmNlIiwKICAgICAgICAgICJzYWZldHkiCiAgICAgICAgXSwKICAgICAgICAicGFzc1BvbGljeSI6ICJhbGwtcmVxdWlyZWQiCiAgICAgIH0KICAgIH0sCiAgICB7CiAgICAgICJpZCI6ICJzYXZlZC0yIiwKICAgICAgInN0ZXBDb2RlIjogIkZJWEVELVMwMiIsCiAgICAgICJvcmRlciI6IDMsCiAgICAgICJ0aXRsZSI6ICLlt7Lkv53lrZjmraXpqqQyIiwKICAgICAgInN1bW1hcnkiOiAi5rWP6KeI5Zmo5aWR57qm5Zue5b2S5q2l6aqkIiwKICAgICAgInNjb3JlIjogMzMsCiAgICAgICJhY3Rpb25Db2RlIjogIkZJWEVELUEwMiIsCiAgICAgICJhbGxvd2VkUG9zaXRpb25zIjogWwogICAgICAgICJDT01NQU5ERVIiCiAgICAgIF0sCiAgICAgICJwaHlzaWNhbEV2ZW50UnVsZSI6IHsKICAgICAgICAic291cmNlIjogIk1BTlVBTCIsCiAgICAgICAgImV2ZW50VHlwZSI6ICJSRVZJRVdfRVZFTlRfMDIiLAogICAgICAgICJwcm9ncmVzc1BlcmNlbnQiOiAxMDAsCiAgICAgICAgIm1hbnVhbENvbmZpcm1BbGxvd2VkIjogdHJ1ZQogICAgICB9LAogICAgICAidGFyZ2V0SWQiOiAiY3lsaW5kZXItbWFpbiIsCiAgICAgICJ0YXJnZXROYW1lIjogIuaUr+iFv+a2suWOi+e8uOaAu+aIkCIsCiAgICAgICJtb2RlIjogImluc3BlY3QiLAogICAgICAiaW50ZXJhY3Rpb24iOiAidGFyZ2V0LWNvbmZpcm0iLAogICAgICAiaW5zdHJ1Y3Rpb24iOiAiIiwKICAgICAgImd1aWRhbmNlIjogIiIsCiAgICAgICJ3YXJuaW5nIjogIiIsCiAgICAgICJkZXN0aW5hdGlvbiI6ICIiLAogICAgICAic3VjY2VzcyI6ICIiLAogICAgICAiYWN0aW9uIjogewogICAgICAgICJraW5kIjogInJ1bGUtY2hlY2siLAogICAgICAgICJ0YXJnZXRJZCI6ICIiLAogICAgICAgICJtb2RlIjogImluc3BlY3QiLAogICAgICAgICJkdXJhdGlvbk1zIjogNjUwLAogICAgICAgICJ0b2xlcmFuY2UiOiAwLAogICAgICAgICJyZXF1aXJlc1BsYWNlbWVudCI6IGZhbHNlCiAgICAgIH0sCiAgICAgICJ0b29sSWQiOiAiIiwKICAgICAgInNhZmV0eVJ1bGVzIjogWwogICAgICAgIHsKICAgICAgICAgICJpZCI6ICJwcGVDb25maXJtZWQiLAogICAgICAgICAgImxhYmVsIjogIuS4quS6uumYsuaKpOeUqOWTgem9kOWFqCIsCiAgICAgICAgICAicmVxdWlyZWQiOiB0cnVlCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAicm91dGVBdXRob3JpemVkIiwKICAgICAgICAgICJsYWJlbCI6ICLkvZzkuJrot6/nur/lt7LmjojmnYMiLAogICAgICAgICAgInJlcXVpcmVkIjogdHJ1ZQogICAgICAgIH0KICAgICAgXSwKICAgICAgImF1ZGlvIjogewogICAgICAgICJuYXJyYXRpb25UZXh0IjogIiIsCiAgICAgICAgIm5hcnJhdGlvbkFzc2V0Q29kZSI6ICIiLAogICAgICAgICJzYWZldHlUZXh0IjogIiIsCiAgICAgICAgInNhZmV0eUFzc2V0Q29kZSI6ICIiLAogICAgICAgICJzdWNjZXNzQ3VlIjogInVpIiwKICAgICAgICAiZXJyb3JDdWUiOiAiY29sbGlzaW9uLWVycm9yIgogICAgICB9LAogICAgICAiYW5pbWF0aW9uQmluZGluZyI6IG51bGwsCiAgICAgICJmYXVsdElkcyI6IFtdLAogICAgICAiYXNzZXNzbWVudCI6IHsKICAgICAgICAid2VpZ2h0IjogMzMsCiAgICAgICAgInJlcXVpcmVkRGltZW5zaW9ucyI6IFsKICAgICAgICAgICJhY3Rpb24iLAogICAgICAgICAgInNlcXVlbmNlIiwKICAgICAgICAgICJzYWZldHkiCiAgICAgICAgXSwKICAgICAgICAicGFzc1BvbGljeSI6ICJhbGwtcmVxdWlyZWQiCiAgICAgIH0KICAgIH0KICBdLAogICJtb2RlQ29uZmlnIjogewogICAgInR5cGUiOiAiVklSVFVBTCIsCiAgICAicmVuZGVyTW9kZSI6ICLljp/mnInmuLLmn5PmlrnlvI8iLAogICAgImludGVyYWN0aW9uTW9kZSI6ICLkuInnu7Tmk43kvZzjgIHliqjnlLvlvJXlr7zkuI7op4TliJnoh6rliqjliKTlrpoiLAogICAgImFzc2lzdGFuY2UiOiAi5ryU56S644CB6K6t57uD5LiO5qih5ouf6ICD6K+VIgogIH0sCiAgInRhc2siOiB7CiAgICAiY29kZSI6ICIiLAogICAgInRpdGxlIjogIiIsCiAgICAiY2F0ZWdvcnkiOiAiIiwKICAgICJsZXZlbCI6ICIiLAogICAgImR1cmF0aW9uIjogMzAsCiAgICAiZGVzY3JpcHRpb24iOiAiIiwKICAgICJza2lsbHMiOiBbXQogIH0sCiAgInNjZW5hcmlvIjogewogICAgImVudmlyb25tZW50TmFtZSI6ICIiLAogICAgInNjZW5lTGF5b3V0IjogIiIsCiAgICAiZXF1aXBtZW50U3RhdGUiOiAiIiwKICAgICJzY2VuZVRhZ3MiOiBbXSwKICAgICJ2aXNpYmxlUGFydHMiOiBbXSwKICAgICJvcGVyYWJsZVBhcnRzIjogW10sCiAgICAic2Vuc29yRmVlZHMiOiBbXQogIH0sCiAgInRvb2xzIjogW10sCiAgImZhdWx0cyI6IFtdLAogICJhdXRob3JpbmciOiB7CiAgICAic2NoZW1hIjogInVucmVhbC10cmFuLnRyYWluaW5nLWF1dGhvcmluZy92MSIsCiAgICAidmVyc2lvbiI6IDEsCiAgICAibm9kZXMiOiBbCiAgICAgIHsKICAgICAgICAiaWQiOiAiZmxvdy1zdGFydCIsCiAgICAgICAgInR5cGUiOiAic3RhcnQiLAogICAgICAgICJ0aXRsZSI6ICLorq3nu4PlvIDlp4siLAogICAgICAgICJ4IjogMzIsCiAgICAgICAgInkiOiAxMjAsCiAgICAgICAgImNvbmZpZyI6IHt9CiAgICAgIH0sCiAgICAgIHsKICAgICAgICAiaWQiOiAic3RlcC1ub2RlOnNhdmVkLTEiLAogICAgICAgICJ0eXBlIjogInN0ZXAiLAogICAgICAgICJ0aXRsZSI6ICLlt7Lkv53lrZjmraXpqqQxIiwKICAgICAgICAic3RlcElkIjogInNhdmVkLTEiLAogICAgICAgICJ4IjogMjQwLAogICAgICAgICJ5IjogMTIwLAogICAgICAgICJjb25maWciOiB7fQogICAgICB9LAogICAgICB7CiAgICAgICAgImlkIjogInN0ZXAtbm9kZTpzYXZlZC0yIiwKICAgICAgICAidHlwZSI6ICJzdGVwIiwKICAgICAgICAidGl0bGUiOiAi5bey5L+d5a2Y5q2l6aqkMiIsCiAgICAgICAgInN0ZXBJZCI6ICJzYXZlZC0yIiwKICAgICAgICAieCI6IDUwMCwKICAgICAgICAieSI6IDEyMCwKICAgICAgICAiY29uZmlnIjoge30KICAgICAgfSwKICAgICAgewogICAgICAgICJpZCI6ICJzdGVwLW5vZGU6c2F2ZWQtMyIsCiAgICAgICAgInR5cGUiOiAic3RlcCIsCiAgICAgICAgInRpdGxlIjogIuW3suS/neWtmOatpemqpDMiLAogICAgICAgICJzdGVwSWQiOiAic2F2ZWQtMyIsCiAgICAgICAgIngiOiA3NjAsCiAgICAgICAgInkiOiAxMjAsCiAgICAgICAgImNvbmZpZyI6IHt9CiAgICAgIH0sCiAgICAgIHsKICAgICAgICAiaWQiOiAiZmxvdy1lbmQiLAogICAgICAgICJ0eXBlIjogImVuZCIsCiAgICAgICAgInRpdGxlIjogIuiuree7g+e7k+adnyIsCiAgICAgICAgIngiOiAyNDAsCiAgICAgICAgInkiOiAyNzAsCiAgICAgICAgImNvbmZpZyI6IHt9CiAgICAgIH0KICAgIF0sCiAgICAiZWRnZXMiOiBbCiAgICAgIHsKICAgICAgICAiaWQiOiAiZWRnZTpmbG93LXN0YXJ0OnN0ZXAtbm9kZTpzYXZlZC0xIiwKICAgICAgICAiZnJvbSI6ICJmbG93LXN0YXJ0IiwKICAgICAgICAidG8iOiAic3RlcC1ub2RlOnNhdmVkLTEiLAogICAgICAgICJsYWJlbCI6ICIiLAogICAgICAgICJraW5kIjogImJhc2Utc2VxdWVuY2UiLAogICAgICAgICJydWxlIjogIiIKICAgICAgfSwKICAgICAgewogICAgICAgICJpZCI6ICJlZGdlOnN0ZXAtbm9kZTpzYXZlZC0xOnN0ZXAtbm9kZTpzYXZlZC0yIiwKICAgICAgICAiZnJvbSI6ICJzdGVwLW5vZGU6c2F2ZWQtMSIsCiAgICAgICAgInRvIjogInN0ZXAtbm9kZTpzYXZlZC0yIiwKICAgICAgICAibGFiZWwiOiAiIiwKICAgICAgICAia2luZCI6ICJiYXNlLXNlcXVlbmNlIiwKICAgICAgICAicnVsZSI6ICIiCiAgICAgIH0sCiAgICAgIHsKICAgICAgICAiaWQiOiAiZWRnZTpzdGVwLW5vZGU6c2F2ZWQtMjpzdGVwLW5vZGU6c2F2ZWQtMyIsCiAgICAgICAgImZyb20iOiAic3RlcC1ub2RlOnNhdmVkLTIiLAogICAgICAgICJ0byI6ICJzdGVwLW5vZGU6c2F2ZWQtMyIsCiAgICAgICAgImxhYmVsIjogIiIsCiAgICAgICAgImtpbmQiOiAiYmFzZS1zZXF1ZW5jZSIsCiAgICAgICAgInJ1bGUiOiAiIgogICAgICB9LAogICAgICB7CiAgICAgICAgImlkIjogImVkZ2U6c3RlcC1ub2RlOnNhdmVkLTM6Zmxvdy1lbmQiLAogICAgICAgICJmcm9tIjogInN0ZXAtbm9kZTpzYXZlZC0zIiwKICAgICAgICAidG8iOiAiZmxvdy1lbmQiLAogICAgICAgICJsYWJlbCI6ICIiLAogICAgICAgICJraW5kIjogImJhc2Utc2VxdWVuY2UiLAogICAgICAgICJydWxlIjogIiIKICAgICAgfQogICAgXSwKICAgICJ0aW1lbGluZSI6IHsKICAgICAgImN1cnJlbnRUaW1lIjogMCwKICAgICAgInRyYWNrcyI6IHsKICAgICAgICAic2F2ZWQtMSI6IHsKICAgICAgICAgICJkdXJhdGlvbiI6IDgsCiAgICAgICAgICAiYWN0aW9uIjogInJ1bGUtY2hlY2siLAogICAgICAgICAgImNhbWVyYSI6ICLot5/pmo/nm67moIciLAogICAgICAgICAgImF1ZGlvQXNzZXRDb2RlIjogIiIsCiAgICAgICAgICAiYW5pbWF0aW9uQXNzZXRJZCI6ICIiLAogICAgICAgICAgImFuaW1hdGlvbk5hbWUiOiAiIgogICAgICAgIH0sCiAgICAgICAgInNhdmVkLTIiOiB7CiAgICAgICAgICAiZHVyYXRpb24iOiA4LAogICAgICAgICAgImFjdGlvbiI6ICJydWxlLWNoZWNrIiwKICAgICAgICAgICJjYW1lcmEiOiAi6Lef6ZqP55uu5qCHIiwKICAgICAgICAgICJhdWRpb0Fzc2V0Q29kZSI6ICIiLAogICAgICAgICAgImFuaW1hdGlvbkFzc2V0SWQiOiAiIiwKICAgICAgICAgICJhbmltYXRpb25OYW1lIjogIiIKICAgICAgICB9LAogICAgICAgICJzYXZlZC0zIjogewogICAgICAgICAgImR1cmF0aW9uIjogOCwKICAgICAgICAgICJhY3Rpb24iOiAicnVsZS1jaGVjayIsCiAgICAgICAgICAiY2FtZXJhIjogIui3n+maj+ebruaghyIsCiAgICAgICAgICAiYXVkaW9Bc3NldENvZGUiOiAiIiwKICAgICAgICAgICJhbmltYXRpb25Bc3NldElkIjogIiIsCiAgICAgICAgICAiYW5pbWF0aW9uTmFtZSI6ICIiCiAgICAgICAgfQogICAgICB9CiAgICB9CiAgfSwKICAiYXNzZXNzbWVudCI6IHsKICAgICJwYXNzU2NvcmUiOiA4MCwKICAgICJtYXhTY29yZSI6IDEwMCwKICAgICJzY29yaW5nTW9kZSI6ICJ3ZWlnaHRlZC1ydWxlcyIsCiAgICAiYWxsb3dSZXRyeSI6IHRydWUsCiAgICAicmVjb3JkUHJvY2VzcyI6IHRydWUKICB9LAogICJydW50aW1lIjogewogICAgInN0YXJ0TW9kZSI6ICJwcmFjdGljZSIsCiAgICAidGVhY2hlckVuYWJsZWQiOiB0cnVlLAogICAgInRlYWNoZXJWb2ljZUVuYWJsZWQiOiB0cnVlLAogICAgImFjdGlvblNvdW5kRW5hYmxlZCI6IHRydWUsCiAgICAic2hvd1N0ZXBQYW5lbCI6IHRydWUsCiAgICAic2hvd1Byb2dyZXNzIjogdHJ1ZSwKICAgICJhbGxvd1JlcGxheSI6IHRydWUsCiAgICAibGFuZ3VhZ2UiOiAiemgtQ04iCiAgfQp9"
+ },
+ {
+ "name": "01-saved-step-move",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regression-38f8a-归:已保存的实装步骤-move后保存重算顺序和实装进度-chromium-api-mock\\attachments\\01-saved-step-move-ddb2e8c6700a4d2ab4c07a40315828bba9317fe2.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-2f99ec2f965f4aec9696",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 206,
+ "column": 5
+ },
+ {
+ "title": "delete后保存重算顺序和实装进度",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 3337,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "steps": [
+ {
+ "title": "Expect \"poll toBe\"",
+ "duration": 1
+ }
+ ],
+ "startTime": "2026-09-05T03:38:01.925Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "保存请求中的训练内容(API mock)",
+ "contentType": "application/json",
+ "body": "ewogICJzY2hlbWEiOiAidW5yZWFsLXRyYW4udHJhaW5pbmcvdjEiLAogICJ2ZXJzaW9uIjogMSwKICAidHJhaW5pbmdNb2RlIjogIlZJUlRVQUwiLAogICJlbmFibGVkQ2hhbm5lbHMiOiBbCiAgICAiVklSVFVBTCIsCiAgICAiUEhZU0lDQUwiLAogICAgIkNPTkZST05UQVRJT04iCiAgXSwKICAiY2hhbm5lbFByb2ZpbGVzIjogewogICAgIlZJUlRVQUwiOiB7CiAgICAgICJ0eXBlIjogIlZJUlRVQUwiLAogICAgICAicmVuZGVyTW9kZSI6ICLljp/mnInmuLLmn5PmlrnlvI8iLAogICAgICAiaW50ZXJhY3Rpb25Nb2RlIjogIuS4iee7tOaTjeS9nOOAgeWKqOeUu+W8leWvvOS4juinhOWImeiHquWKqOWIpOWumiIsCiAgICAgICJhc3Npc3RhbmNlIjogIua8lOekuuOAgeiuree7g+S4juaooeaLn+iAg+ivlSIKICAgIH0sCiAgICAiUEhZU0lDQUwiOiB7CiAgICAgICJ0eXBlIjogIlBIWVNJQ0FMIiwKICAgICAgInN0YXRpb25OYW1lIjogIuS/neeVmeeahOWunuijheW3peS9jSIsCiAgICAgICJlcXVpcG1lbnRDb2RlIjogIiIsCiAgICAgICJnYXRld2F5UHJvdG9jb2wiOiAiT1BDIFVBIC8gTW9kYnVzIFRDUCIsCiAgICAgICJzZW5zb3JDaGFubmVscyI6ICIiLAogICAgICAic2FmZXR5SW50ZXJsb2NrcyI6ICIiLAogICAgICAibWFudWFsQ29uZmlybUFsbG93ZWQiOiB0cnVlCiAgICB9LAogICAgIkNPTkZST05UQVRJT04iOiB7CiAgICAgICJ0eXBlIjogIkNPTkZST05UQVRJT04iLAogICAgICAicmVkVGVhbU5hbWUiOiAi57qi5pa5IiwKICAgICAgImJsdWVUZWFtTmFtZSI6ICLok53mlrkiLAogICAgICAidGVhbVNpemUiOiA2LAogICAgICAicm91bmREdXJhdGlvbk1pbnV0ZXMiOiA0NSwKICAgICAgIm9iamVjdGl2ZSI6ICLljY/lkIznm67moIflupTkv53nlZkiLAogICAgICAid2luUnVsZSI6ICIiLAogICAgICAic3VibWl0UG9zaXRpb25zIjogWwogICAgICAgICJDT01NQU5ERVIiCiAgICAgIF0KICAgIH0KICB9LAogICJsZXZlbCI6ICLkuK3nuqciLAogICJkdXJhdGlvbk1pbnV0ZXMiOiAxNSwKICAidG90YWxTY29yZSI6IDY2LAogICJvYmplY3RpdmVzIjogW10sCiAgInN0ZXBzIjogWwogICAgewogICAgICAiaWQiOiAic2F2ZWQtMSIsCiAgICAgICJzdGVwQ29kZSI6ICJGSVhFRC1TMDEiLAogICAgICAib3JkZXIiOiAxLAogICAgICAidGl0bGUiOiAi5bey5L+d5a2Y5q2l6aqkMSIsCiAgICAgICJzdW1tYXJ5IjogIua1j+iniOWZqOWlkee6puWbnuW9kuatpemqpCIsCiAgICAgICJzY29yZSI6IDMzLAogICAgICAiYWN0aW9uQ29kZSI6ICJGSVhFRC1BMDEiLAogICAgICAiYWxsb3dlZFBvc2l0aW9ucyI6IFsKICAgICAgICAiQ09NTUFOREVSIgogICAgICBdLAogICAgICAicGh5c2ljYWxFdmVudFJ1bGUiOiB7CiAgICAgICAgInNvdXJjZSI6ICJNQU5VQUwiLAogICAgICAgICJldmVudFR5cGUiOiAiUkVWSUVXX0VWRU5UXzAxIiwKICAgICAgICAicHJvZ3Jlc3NQZXJjZW50IjogNTAsCiAgICAgICAgIm1hbnVhbENvbmZpcm1BbGxvd2VkIjogdHJ1ZQogICAgICB9LAogICAgICAidGFyZ2V0SWQiOiAiY3lsaW5kZXItbWFpbiIsCiAgICAgICJ0YXJnZXROYW1lIjogIuaUr+iFv+a2suWOi+e8uOaAu+aIkCIsCiAgICAgICJtb2RlIjogImluc3BlY3QiLAogICAgICAiaW50ZXJhY3Rpb24iOiAidGFyZ2V0LWNvbmZpcm0iLAogICAgICAiaW5zdHJ1Y3Rpb24iOiAiIiwKICAgICAgImd1aWRhbmNlIjogIiIsCiAgICAgICJ3YXJuaW5nIjogIiIsCiAgICAgICJkZXN0aW5hdGlvbiI6ICIiLAogICAgICAic3VjY2VzcyI6ICIiLAogICAgICAiYWN0aW9uIjogewogICAgICAgICJraW5kIjogInJ1bGUtY2hlY2siLAogICAgICAgICJ0YXJnZXRJZCI6ICIiLAogICAgICAgICJtb2RlIjogImluc3BlY3QiLAogICAgICAgICJkdXJhdGlvbk1zIjogNjUwLAogICAgICAgICJ0b2xlcmFuY2UiOiAwLAogICAgICAgICJyZXF1aXJlc1BsYWNlbWVudCI6IGZhbHNlCiAgICAgIH0sCiAgICAgICJ0b29sSWQiOiAiIiwKICAgICAgInNhZmV0eVJ1bGVzIjogWwogICAgICAgIHsKICAgICAgICAgICJpZCI6ICJwcGVDb25maXJtZWQiLAogICAgICAgICAgImxhYmVsIjogIuS4quS6uumYsuaKpOeUqOWTgem9kOWFqCIsCiAgICAgICAgICAicmVxdWlyZWQiOiB0cnVlCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAicm91dGVBdXRob3JpemVkIiwKICAgICAgICAgICJsYWJlbCI6ICLkvZzkuJrot6/nur/lt7LmjojmnYMiLAogICAgICAgICAgInJlcXVpcmVkIjogdHJ1ZQogICAgICAgIH0KICAgICAgXSwKICAgICAgImF1ZGlvIjogewogICAgICAgICJuYXJyYXRpb25UZXh0IjogIiIsCiAgICAgICAgIm5hcnJhdGlvbkFzc2V0Q29kZSI6ICIiLAogICAgICAgICJzYWZldHlUZXh0IjogIiIsCiAgICAgICAgInNhZmV0eUFzc2V0Q29kZSI6ICIiLAogICAgICAgICJzdWNjZXNzQ3VlIjogInVpIiwKICAgICAgICAiZXJyb3JDdWUiOiAiY29sbGlzaW9uLWVycm9yIgogICAgICB9LAogICAgICAiYW5pbWF0aW9uQmluZGluZyI6IG51bGwsCiAgICAgICJmYXVsdElkcyI6IFtdLAogICAgICAiYXNzZXNzbWVudCI6IHsKICAgICAgICAid2VpZ2h0IjogMzMsCiAgICAgICAgInJlcXVpcmVkRGltZW5zaW9ucyI6IFsKICAgICAgICAgICJhY3Rpb24iLAogICAgICAgICAgInNlcXVlbmNlIiwKICAgICAgICAgICJzYWZldHkiCiAgICAgICAgXSwKICAgICAgICAicGFzc1BvbGljeSI6ICJhbGwtcmVxdWlyZWQiCiAgICAgIH0KICAgIH0sCiAgICB7CiAgICAgICJpZCI6ICJzYXZlZC0yIiwKICAgICAgInN0ZXBDb2RlIjogIkZJWEVELVMwMiIsCiAgICAgICJvcmRlciI6IDIsCiAgICAgICJ0aXRsZSI6ICLlt7Lkv53lrZjmraXpqqQyIiwKICAgICAgInN1bW1hcnkiOiAi5rWP6KeI5Zmo5aWR57qm5Zue5b2S5q2l6aqkIiwKICAgICAgInNjb3JlIjogMzMsCiAgICAgICJhY3Rpb25Db2RlIjogIkZJWEVELUEwMiIsCiAgICAgICJhbGxvd2VkUG9zaXRpb25zIjogWwogICAgICAgICJDT01NQU5ERVIiCiAgICAgIF0sCiAgICAgICJwaHlzaWNhbEV2ZW50UnVsZSI6IHsKICAgICAgICAic291cmNlIjogIk1BTlVBTCIsCiAgICAgICAgImV2ZW50VHlwZSI6ICJSRVZJRVdfRVZFTlRfMDIiLAogICAgICAgICJwcm9ncmVzc1BlcmNlbnQiOiAxMDAsCiAgICAgICAgIm1hbnVhbENvbmZpcm1BbGxvd2VkIjogdHJ1ZQogICAgICB9LAogICAgICAidGFyZ2V0SWQiOiAiY3lsaW5kZXItbWFpbiIsCiAgICAgICJ0YXJnZXROYW1lIjogIuaUr+iFv+a2suWOi+e8uOaAu+aIkCIsCiAgICAgICJtb2RlIjogImluc3BlY3QiLAogICAgICAiaW50ZXJhY3Rpb24iOiAidGFyZ2V0LWNvbmZpcm0iLAogICAgICAiaW5zdHJ1Y3Rpb24iOiAiIiwKICAgICAgImd1aWRhbmNlIjogIiIsCiAgICAgICJ3YXJuaW5nIjogIiIsCiAgICAgICJkZXN0aW5hdGlvbiI6ICIiLAogICAgICAic3VjY2VzcyI6ICIiLAogICAgICAiYWN0aW9uIjogewogICAgICAgICJraW5kIjogInJ1bGUtY2hlY2siLAogICAgICAgICJ0YXJnZXRJZCI6ICIiLAogICAgICAgICJtb2RlIjogImluc3BlY3QiLAogICAgICAgICJkdXJhdGlvbk1zIjogNjUwLAogICAgICAgICJ0b2xlcmFuY2UiOiAwLAogICAgICAgICJyZXF1aXJlc1BsYWNlbWVudCI6IGZhbHNlCiAgICAgIH0sCiAgICAgICJ0b29sSWQiOiAiIiwKICAgICAgInNhZmV0eVJ1bGVzIjogWwogICAgICAgIHsKICAgICAgICAgICJpZCI6ICJwcGVDb25maXJtZWQiLAogICAgICAgICAgImxhYmVsIjogIuS4quS6uumYsuaKpOeUqOWTgem9kOWFqCIsCiAgICAgICAgICAicmVxdWlyZWQiOiB0cnVlCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAicm91dGVBdXRob3JpemVkIiwKICAgICAgICAgICJsYWJlbCI6ICLkvZzkuJrot6/nur/lt7LmjojmnYMiLAogICAgICAgICAgInJlcXVpcmVkIjogdHJ1ZQogICAgICAgIH0KICAgICAgXSwKICAgICAgImF1ZGlvIjogewogICAgICAgICJuYXJyYXRpb25UZXh0IjogIiIsCiAgICAgICAgIm5hcnJhdGlvbkFzc2V0Q29kZSI6ICIiLAogICAgICAgICJzYWZldHlUZXh0IjogIiIsCiAgICAgICAgInNhZmV0eUFzc2V0Q29kZSI6ICIiLAogICAgICAgICJzdWNjZXNzQ3VlIjogInVpIiwKICAgICAgICAiZXJyb3JDdWUiOiAiY29sbGlzaW9uLWVycm9yIgogICAgICB9LAogICAgICAiYW5pbWF0aW9uQmluZGluZyI6IG51bGwsCiAgICAgICJmYXVsdElkcyI6IFtdLAogICAgICAiYXNzZXNzbWVudCI6IHsKICAgICAgICAid2VpZ2h0IjogMzMsCiAgICAgICAgInJlcXVpcmVkRGltZW5zaW9ucyI6IFsKICAgICAgICAgICJhY3Rpb24iLAogICAgICAgICAgInNlcXVlbmNlIiwKICAgICAgICAgICJzYWZldHkiCiAgICAgICAgXSwKICAgICAgICAicGFzc1BvbGljeSI6ICJhbGwtcmVxdWlyZWQiCiAgICAgIH0KICAgIH0KICBdLAogICJtb2RlQ29uZmlnIjogewogICAgInR5cGUiOiAiVklSVFVBTCIsCiAgICAicmVuZGVyTW9kZSI6ICLljp/mnInmuLLmn5PmlrnlvI8iLAogICAgImludGVyYWN0aW9uTW9kZSI6ICLkuInnu7Tmk43kvZzjgIHliqjnlLvlvJXlr7zkuI7op4TliJnoh6rliqjliKTlrpoiLAogICAgImFzc2lzdGFuY2UiOiAi5ryU56S644CB6K6t57uD5LiO5qih5ouf6ICD6K+VIgogIH0sCiAgInRhc2siOiB7CiAgICAiY29kZSI6ICIiLAogICAgInRpdGxlIjogIiIsCiAgICAiY2F0ZWdvcnkiOiAiIiwKICAgICJsZXZlbCI6ICIiLAogICAgImR1cmF0aW9uIjogMzAsCiAgICAiZGVzY3JpcHRpb24iOiAiIiwKICAgICJza2lsbHMiOiBbXQogIH0sCiAgInNjZW5hcmlvIjogewogICAgImVudmlyb25tZW50TmFtZSI6ICIiLAogICAgInNjZW5lTGF5b3V0IjogIiIsCiAgICAiZXF1aXBtZW50U3RhdGUiOiAiIiwKICAgICJzY2VuZVRhZ3MiOiBbXSwKICAgICJ2aXNpYmxlUGFydHMiOiBbXSwKICAgICJvcGVyYWJsZVBhcnRzIjogW10sCiAgICAic2Vuc29yRmVlZHMiOiBbXQogIH0sCiAgInRvb2xzIjogW10sCiAgImZhdWx0cyI6IFtdLAogICJhdXRob3JpbmciOiB7CiAgICAic2NoZW1hIjogInVucmVhbC10cmFuLnRyYWluaW5nLWF1dGhvcmluZy92MSIsCiAgICAidmVyc2lvbiI6IDEsCiAgICAibm9kZXMiOiBbCiAgICAgIHsKICAgICAgICAiaWQiOiAiZmxvdy1zdGFydCIsCiAgICAgICAgInR5cGUiOiAic3RhcnQiLAogICAgICAgICJ0aXRsZSI6ICLorq3nu4PlvIDlp4siLAogICAgICAgICJ4IjogMzIsCiAgICAgICAgInkiOiAxMjAsCiAgICAgICAgImNvbmZpZyI6IHt9CiAgICAgIH0sCiAgICAgIHsKICAgICAgICAiaWQiOiAic3RlcC1ub2RlOnNhdmVkLTEiLAogICAgICAgICJ0eXBlIjogInN0ZXAiLAogICAgICAgICJ0aXRsZSI6ICLlt7Lkv53lrZjmraXpqqQxIiwKICAgICAgICAic3RlcElkIjogInNhdmVkLTEiLAogICAgICAgICJ4IjogMjQwLAogICAgICAgICJ5IjogMTIwLAogICAgICAgICJjb25maWciOiB7fQogICAgICB9LAogICAgICB7CiAgICAgICAgImlkIjogInN0ZXAtbm9kZTpzYXZlZC0yIiwKICAgICAgICAidHlwZSI6ICJzdGVwIiwKICAgICAgICAidGl0bGUiOiAi5bey5L+d5a2Y5q2l6aqkMiIsCiAgICAgICAgInN0ZXBJZCI6ICJzYXZlZC0yIiwKICAgICAgICAieCI6IDUwMCwKICAgICAgICAieSI6IDEyMCwKICAgICAgICAiY29uZmlnIjoge30KICAgICAgfSwKICAgICAgewogICAgICAgICJpZCI6ICJzdGVwLW5vZGU6c2F2ZWQtMyIsCiAgICAgICAgInR5cGUiOiAic3RlcCIsCiAgICAgICAgInRpdGxlIjogIuW3suS/neWtmOatpemqpDMiLAogICAgICAgICJzdGVwSWQiOiAic2F2ZWQtMyIsCiAgICAgICAgIngiOiA3NjAsCiAgICAgICAgInkiOiAxMjAsCiAgICAgICAgImNvbmZpZyI6IHt9CiAgICAgIH0sCiAgICAgIHsKICAgICAgICAiaWQiOiAiZmxvdy1lbmQiLAogICAgICAgICJ0eXBlIjogImVuZCIsCiAgICAgICAgInRpdGxlIjogIuiuree7g+e7k+adnyIsCiAgICAgICAgIngiOiAyNDAsCiAgICAgICAgInkiOiAyNzAsCiAgICAgICAgImNvbmZpZyI6IHt9CiAgICAgIH0KICAgIF0sCiAgICAiZWRnZXMiOiBbCiAgICAgIHsKICAgICAgICAiaWQiOiAiZWRnZTpmbG93LXN0YXJ0OnN0ZXAtbm9kZTpzYXZlZC0xIiwKICAgICAgICAiZnJvbSI6ICJmbG93LXN0YXJ0IiwKICAgICAgICAidG8iOiAic3RlcC1ub2RlOnNhdmVkLTEiLAogICAgICAgICJsYWJlbCI6ICIiLAogICAgICAgICJraW5kIjogImJhc2Utc2VxdWVuY2UiLAogICAgICAgICJydWxlIjogIiIKICAgICAgfSwKICAgICAgewogICAgICAgICJpZCI6ICJlZGdlOnN0ZXAtbm9kZTpzYXZlZC0xOnN0ZXAtbm9kZTpzYXZlZC0yIiwKICAgICAgICAiZnJvbSI6ICJzdGVwLW5vZGU6c2F2ZWQtMSIsCiAgICAgICAgInRvIjogInN0ZXAtbm9kZTpzYXZlZC0yIiwKICAgICAgICAibGFiZWwiOiAiIiwKICAgICAgICAia2luZCI6ICJiYXNlLXNlcXVlbmNlIiwKICAgICAgICAicnVsZSI6ICIiCiAgICAgIH0sCiAgICAgIHsKICAgICAgICAiaWQiOiAiZWRnZTpzdGVwLW5vZGU6c2F2ZWQtMjpzdGVwLW5vZGU6c2F2ZWQtMyIsCiAgICAgICAgImZyb20iOiAic3RlcC1ub2RlOnNhdmVkLTIiLAogICAgICAgICJ0byI6ICJzdGVwLW5vZGU6c2F2ZWQtMyIsCiAgICAgICAgImxhYmVsIjogIiIsCiAgICAgICAgImtpbmQiOiAiYmFzZS1zZXF1ZW5jZSIsCiAgICAgICAgInJ1bGUiOiAiIgogICAgICB9LAogICAgICB7CiAgICAgICAgImlkIjogImVkZ2U6c3RlcC1ub2RlOnNhdmVkLTM6Zmxvdy1lbmQiLAogICAgICAgICJmcm9tIjogInN0ZXAtbm9kZTpzYXZlZC0zIiwKICAgICAgICAidG8iOiAiZmxvdy1lbmQiLAogICAgICAgICJsYWJlbCI6ICIiLAogICAgICAgICJraW5kIjogImJhc2Utc2VxdWVuY2UiLAogICAgICAgICJydWxlIjogIiIKICAgICAgfQogICAgXSwKICAgICJ0aW1lbGluZSI6IHsKICAgICAgImN1cnJlbnRUaW1lIjogMCwKICAgICAgInRyYWNrcyI6IHsKICAgICAgICAic2F2ZWQtMSI6IHsKICAgICAgICAgICJkdXJhdGlvbiI6IDgsCiAgICAgICAgICAiYWN0aW9uIjogInJ1bGUtY2hlY2siLAogICAgICAgICAgImNhbWVyYSI6ICLot5/pmo/nm67moIciLAogICAgICAgICAgImF1ZGlvQXNzZXRDb2RlIjogIiIsCiAgICAgICAgICAiYW5pbWF0aW9uQXNzZXRJZCI6ICIiLAogICAgICAgICAgImFuaW1hdGlvbk5hbWUiOiAiIgogICAgICAgIH0sCiAgICAgICAgInNhdmVkLTIiOiB7CiAgICAgICAgICAiZHVyYXRpb24iOiA4LAogICAgICAgICAgImFjdGlvbiI6ICJydWxlLWNoZWNrIiwKICAgICAgICAgICJjYW1lcmEiOiAi6Lef6ZqP55uu5qCHIiwKICAgICAgICAgICJhdWRpb0Fzc2V0Q29kZSI6ICIiLAogICAgICAgICAgImFuaW1hdGlvbkFzc2V0SWQiOiAiIiwKICAgICAgICAgICJhbmltYXRpb25OYW1lIjogIiIKICAgICAgICB9LAogICAgICAgICJzYXZlZC0zIjogewogICAgICAgICAgImR1cmF0aW9uIjogOCwKICAgICAgICAgICJhY3Rpb24iOiAicnVsZS1jaGVjayIsCiAgICAgICAgICAiY2FtZXJhIjogIui3n+maj+ebruaghyIsCiAgICAgICAgICAiYXVkaW9Bc3NldENvZGUiOiAiIiwKICAgICAgICAgICJhbmltYXRpb25Bc3NldElkIjogIiIsCiAgICAgICAgICAiYW5pbWF0aW9uTmFtZSI6ICIiCiAgICAgICAgfQogICAgICB9CiAgICB9CiAgfSwKICAiYXNzZXNzbWVudCI6IHsKICAgICJwYXNzU2NvcmUiOiA4MCwKICAgICJtYXhTY29yZSI6IDEwMCwKICAgICJzY29yaW5nTW9kZSI6ICJ3ZWlnaHRlZC1ydWxlcyIsCiAgICAiYWxsb3dSZXRyeSI6IHRydWUsCiAgICAicmVjb3JkUHJvY2VzcyI6IHRydWUKICB9LAogICJydW50aW1lIjogewogICAgInN0YXJ0TW9kZSI6ICJwcmFjdGljZSIsCiAgICAidGVhY2hlckVuYWJsZWQiOiB0cnVlLAogICAgInRlYWNoZXJWb2ljZUVuYWJsZWQiOiB0cnVlLAogICAgImFjdGlvblNvdW5kRW5hYmxlZCI6IHRydWUsCiAgICAic2hvd1N0ZXBQYW5lbCI6IHRydWUsCiAgICAic2hvd1Byb2dyZXNzIjogdHJ1ZSwKICAgICJhbGxvd1JlcGxheSI6IHRydWUsCiAgICAibGFuZ3VhZ2UiOiAiemgtQ04iCiAgfQp9"
+ },
+ {
+ "name": "01-saved-step-delete",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regression-5a943-已保存的实装步骤-delete后保存重算顺序和实装进度-chromium-api-mock\\attachments\\01-saved-step-delete-9966bb7724492730a9a005464a6d080b1fd6aefd.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-646651395872ef0bca20",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 206,
+ "column": 5
+ },
+ {
+ "title": "duplicate后保存重算顺序和实装进度",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 2845,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "steps": [
+ {
+ "title": "Expect \"poll toBe\"",
+ "duration": 1
+ }
+ ],
+ "startTime": "2026-09-05T03:38:05.274Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "保存请求中的训练内容(API mock)",
+ "contentType": "application/json",
+ "body": "ewogICJzY2hlbWEiOiAidW5yZWFsLXRyYW4udHJhaW5pbmcvdjEiLAogICJ2ZXJzaW9uIjogMSwKICAidHJhaW5pbmdNb2RlIjogIlZJUlRVQUwiLAogICJlbmFibGVkQ2hhbm5lbHMiOiBbCiAgICAiVklSVFVBTCIsCiAgICAiUEhZU0lDQUwiLAogICAgIkNPTkZST05UQVRJT04iCiAgXSwKICAiY2hhbm5lbFByb2ZpbGVzIjogewogICAgIlZJUlRVQUwiOiB7CiAgICAgICJ0eXBlIjogIlZJUlRVQUwiLAogICAgICAicmVuZGVyTW9kZSI6ICLljp/mnInmuLLmn5PmlrnlvI8iLAogICAgICAiaW50ZXJhY3Rpb25Nb2RlIjogIuS4iee7tOaTjeS9nOOAgeWKqOeUu+W8leWvvOS4juinhOWImeiHquWKqOWIpOWumiIsCiAgICAgICJhc3Npc3RhbmNlIjogIua8lOekuuOAgeiuree7g+S4juaooeaLn+iAg+ivlSIKICAgIH0sCiAgICAiUEhZU0lDQUwiOiB7CiAgICAgICJ0eXBlIjogIlBIWVNJQ0FMIiwKICAgICAgInN0YXRpb25OYW1lIjogIuS/neeVmeeahOWunuijheW3peS9jSIsCiAgICAgICJlcXVpcG1lbnRDb2RlIjogIiIsCiAgICAgICJnYXRld2F5UHJvdG9jb2wiOiAiT1BDIFVBIC8gTW9kYnVzIFRDUCIsCiAgICAgICJzZW5zb3JDaGFubmVscyI6ICIiLAogICAgICAic2FmZXR5SW50ZXJsb2NrcyI6ICIiLAogICAgICAibWFudWFsQ29uZmlybUFsbG93ZWQiOiB0cnVlCiAgICB9LAogICAgIkNPTkZST05UQVRJT04iOiB7CiAgICAgICJ0eXBlIjogIkNPTkZST05UQVRJT04iLAogICAgICAicmVkVGVhbU5hbWUiOiAi57qi5pa5IiwKICAgICAgImJsdWVUZWFtTmFtZSI6ICLok53mlrkiLAogICAgICAidGVhbVNpemUiOiA2LAogICAgICAicm91bmREdXJhdGlvbk1pbnV0ZXMiOiA0NSwKICAgICAgIm9iamVjdGl2ZSI6ICLljY/lkIznm67moIflupTkv53nlZkiLAogICAgICAid2luUnVsZSI6ICIiLAogICAgICAic3VibWl0UG9zaXRpb25zIjogWwogICAgICAgICJDT01NQU5ERVIiCiAgICAgIF0KICAgIH0KICB9LAogICJsZXZlbCI6ICLkuK3nuqciLAogICJkdXJhdGlvbk1pbnV0ZXMiOiAxNSwKICAidG90YWxTY29yZSI6IDEzMywKICAib2JqZWN0aXZlcyI6IFtdLAogICJzdGVwcyI6IFsKICAgIHsKICAgICAgImlkIjogInNhdmVkLTEiLAogICAgICAic3RlcENvZGUiOiAiRklYRUQtUzAxIiwKICAgICAgIm9yZGVyIjogMSwKICAgICAgInRpdGxlIjogIuW3suS/neWtmOatpemqpDEiLAogICAgICAic3VtbWFyeSI6ICLmtY/op4jlmajlpZHnuqblm57lvZLmraXpqqQiLAogICAgICAic2NvcmUiOiAzMywKICAgICAgImFjdGlvbkNvZGUiOiAiRklYRUQtQTAxIiwKICAgICAgImFsbG93ZWRQb3NpdGlvbnMiOiBbCiAgICAgICAgIkNPTU1BTkRFUiIKICAgICAgXSwKICAgICAgInBoeXNpY2FsRXZlbnRSdWxlIjogewogICAgICAgICJzb3VyY2UiOiAiTUFOVUFMIiwKICAgICAgICAiZXZlbnRUeXBlIjogIlJFVklFV19FVkVOVF8wMSIsCiAgICAgICAgInByb2dyZXNzUGVyY2VudCI6IDI1LAogICAgICAgICJtYW51YWxDb25maXJtQWxsb3dlZCI6IHRydWUKICAgICAgfSwKICAgICAgInRhcmdldElkIjogImN5bGluZGVyLW1haW4iLAogICAgICAidGFyZ2V0TmFtZSI6ICLmlK/ohb/mtrLljovnvLjmgLvmiJAiLAogICAgICAibW9kZSI6ICJpbnNwZWN0IiwKICAgICAgImludGVyYWN0aW9uIjogInRhcmdldC1jb25maXJtIiwKICAgICAgImluc3RydWN0aW9uIjogIiIsCiAgICAgICJndWlkYW5jZSI6ICIiLAogICAgICAid2FybmluZyI6ICIiLAogICAgICAiZGVzdGluYXRpb24iOiAiIiwKICAgICAgInN1Y2Nlc3MiOiAiIiwKICAgICAgImFjdGlvbiI6IHsKICAgICAgICAia2luZCI6ICJydWxlLWNoZWNrIiwKICAgICAgICAidGFyZ2V0SWQiOiAiIiwKICAgICAgICAibW9kZSI6ICJpbnNwZWN0IiwKICAgICAgICAiZHVyYXRpb25NcyI6IDY1MCwKICAgICAgICAidG9sZXJhbmNlIjogMCwKICAgICAgICAicmVxdWlyZXNQbGFjZW1lbnQiOiBmYWxzZQogICAgICB9LAogICAgICAidG9vbElkIjogIiIsCiAgICAgICJzYWZldHlSdWxlcyI6IFsKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAicHBlQ29uZmlybWVkIiwKICAgICAgICAgICJsYWJlbCI6ICLkuKrkurrpmLLmiqTnlKjlk4HpvZDlhagiLAogICAgICAgICAgInJlcXVpcmVkIjogdHJ1ZQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgImlkIjogInJvdXRlQXV0aG9yaXplZCIsCiAgICAgICAgICAibGFiZWwiOiAi5L2c5Lia6Lev57q/5bey5o6I5p2DIiwKICAgICAgICAgICJyZXF1aXJlZCI6IHRydWUKICAgICAgICB9CiAgICAgIF0sCiAgICAgICJhdWRpbyI6IHsKICAgICAgICAibmFycmF0aW9uVGV4dCI6ICIiLAogICAgICAgICJuYXJyYXRpb25Bc3NldENvZGUiOiAiIiwKICAgICAgICAic2FmZXR5VGV4dCI6ICIiLAogICAgICAgICJzYWZldHlBc3NldENvZGUiOiAiIiwKICAgICAgICAic3VjY2Vzc0N1ZSI6ICJ1aSIsCiAgICAgICAgImVycm9yQ3VlIjogImNvbGxpc2lvbi1lcnJvciIKICAgICAgfSwKICAgICAgImFuaW1hdGlvbkJpbmRpbmciOiBudWxsLAogICAgICAiZmF1bHRJZHMiOiBbXSwKICAgICAgImFzc2Vzc21lbnQiOiB7CiAgICAgICAgIndlaWdodCI6IDMzLAogICAgICAgICJyZXF1aXJlZERpbWVuc2lvbnMiOiBbCiAgICAgICAgICAiYWN0aW9uIiwKICAgICAgICAgICJzZXF1ZW5jZSIsCiAgICAgICAgICAic2FmZXR5IgogICAgICAgIF0sCiAgICAgICAgInBhc3NQb2xpY3kiOiAiYWxsLXJlcXVpcmVkIgogICAgICB9CiAgICB9LAogICAgewogICAgICAiaWQiOiAic3RlcC00LXdhbWhpOCIsCiAgICAgICJzdGVwQ29kZSI6ICJWSVJUVUFMLVMwMiIsCiAgICAgICJvcmRlciI6IDIsCiAgICAgICJ0aXRsZSI6ICLlt7Lkv53lrZjmraXpqqQx77yI5Ymv5pys77yJIiwKICAgICAgInN1bW1hcnkiOiAi5rWP6KeI5Zmo5aWR57qm5Zue5b2S5q2l6aqkIiwKICAgICAgInNjb3JlIjogMzMsCiAgICAgICJhY3Rpb25Db2RlIjogIlZJUlRVQUwtQTAyIiwKICAgICAgImFsbG93ZWRQb3NpdGlvbnMiOiBbCiAgICAgICAgIkNPTU1BTkRFUiIKICAgICAgXSwKICAgICAgInBoeXNpY2FsRXZlbnRSdWxlIjogewogICAgICAgICJzb3VyY2UiOiAiTUFOVUFMIiwKICAgICAgICAiZXZlbnRUeXBlIjogIlJFVklFV19FVkVOVF8wMSIsCiAgICAgICAgInByb2dyZXNzUGVyY2VudCI6IDUwLAogICAgICAgICJtYW51YWxDb25maXJtQWxsb3dlZCI6IHRydWUKICAgICAgfSwKICAgICAgInRhcmdldElkIjogImN5bGluZGVyLW1haW4iLAogICAgICAidGFyZ2V0TmFtZSI6ICLmlK/ohb/mtrLljovnvLjmgLvmiJAiLAogICAgICAibW9kZSI6ICJpbnNwZWN0IiwKICAgICAgImludGVyYWN0aW9uIjogInRhcmdldC1jb25maXJtIiwKICAgICAgImluc3RydWN0aW9uIjogIiIsCiAgICAgICJndWlkYW5jZSI6ICIiLAogICAgICAid2FybmluZyI6ICIiLAogICAgICAiZGVzdGluYXRpb24iOiAiIiwKICAgICAgInN1Y2Nlc3MiOiAiIiwKICAgICAgImFjdGlvbiI6IHsKICAgICAgICAia2luZCI6ICJydWxlLWNoZWNrIiwKICAgICAgICAidGFyZ2V0SWQiOiAiIiwKICAgICAgICAibW9kZSI6ICJpbnNwZWN0IiwKICAgICAgICAiZHVyYXRpb25NcyI6IDY1MCwKICAgICAgICAidG9sZXJhbmNlIjogMCwKICAgICAgICAicmVxdWlyZXNQbGFjZW1lbnQiOiBmYWxzZQogICAgICB9LAogICAgICAidG9vbElkIjogIiIsCiAgICAgICJzYWZldHlSdWxlcyI6IFsKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAicHBlQ29uZmlybWVkIiwKICAgICAgICAgICJsYWJlbCI6ICLkuKrkurrpmLLmiqTnlKjlk4HpvZDlhagiLAogICAgICAgICAgInJlcXVpcmVkIjogdHJ1ZQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgImlkIjogInJvdXRlQXV0aG9yaXplZCIsCiAgICAgICAgICAibGFiZWwiOiAi5L2c5Lia6Lev57q/5bey5o6I5p2DIiwKICAgICAgICAgICJyZXF1aXJlZCI6IHRydWUKICAgICAgICB9CiAgICAgIF0sCiAgICAgICJhdWRpbyI6IHsKICAgICAgICAibmFycmF0aW9uVGV4dCI6ICIiLAogICAgICAgICJuYXJyYXRpb25Bc3NldENvZGUiOiAiIiwKICAgICAgICAic2FmZXR5VGV4dCI6ICIiLAogICAgICAgICJzYWZldHlBc3NldENvZGUiOiAiIiwKICAgICAgICAic3VjY2Vzc0N1ZSI6ICJ1aSIsCiAgICAgICAgImVycm9yQ3VlIjogImNvbGxpc2lvbi1lcnJvciIKICAgICAgfSwKICAgICAgImFuaW1hdGlvbkJpbmRpbmciOiBudWxsLAogICAgICAiZmF1bHRJZHMiOiBbXSwKICAgICAgImFzc2Vzc21lbnQiOiB7CiAgICAgICAgIndlaWdodCI6IDMzLAogICAgICAgICJyZXF1aXJlZERpbWVuc2lvbnMiOiBbCiAgICAgICAgICAiYWN0aW9uIiwKICAgICAgICAgICJzZXF1ZW5jZSIsCiAgICAgICAgICAic2FmZXR5IgogICAgICAgIF0sCiAgICAgICAgInBhc3NQb2xpY3kiOiAiYWxsLXJlcXVpcmVkIgogICAgICB9CiAgICB9LAogICAgewogICAgICAiaWQiOiAic2F2ZWQtMiIsCiAgICAgICJzdGVwQ29kZSI6ICJGSVhFRC1TMDIiLAogICAgICAib3JkZXIiOiAzLAogICAgICAidGl0bGUiOiAi5bey5L+d5a2Y5q2l6aqkMiIsCiAgICAgICJzdW1tYXJ5IjogIua1j+iniOWZqOWlkee6puWbnuW9kuatpemqpCIsCiAgICAgICJzY29yZSI6IDMzLAogICAgICAiYWN0aW9uQ29kZSI6ICJGSVhFRC1BMDIiLAogICAgICAiYWxsb3dlZFBvc2l0aW9ucyI6IFsKICAgICAgICAiQ09NTUFOREVSIgogICAgICBdLAogICAgICAicGh5c2ljYWxFdmVudFJ1bGUiOiB7CiAgICAgICAgInNvdXJjZSI6ICJNQU5VQUwiLAogICAgICAgICJldmVudFR5cGUiOiAiUkVWSUVXX0VWRU5UXzAyIiwKICAgICAgICAicHJvZ3Jlc3NQZXJjZW50IjogNzUsCiAgICAgICAgIm1hbnVhbENvbmZpcm1BbGxvd2VkIjogdHJ1ZQogICAgICB9LAogICAgICAidGFyZ2V0SWQiOiAiY3lsaW5kZXItbWFpbiIsCiAgICAgICJ0YXJnZXROYW1lIjogIuaUr+iFv+a2suWOi+e8uOaAu+aIkCIsCiAgICAgICJtb2RlIjogImluc3BlY3QiLAogICAgICAiaW50ZXJhY3Rpb24iOiAidGFyZ2V0LWNvbmZpcm0iLAogICAgICAiaW5zdHJ1Y3Rpb24iOiAiIiwKICAgICAgImd1aWRhbmNlIjogIiIsCiAgICAgICJ3YXJuaW5nIjogIiIsCiAgICAgICJkZXN0aW5hdGlvbiI6ICIiLAogICAgICAic3VjY2VzcyI6ICIiLAogICAgICAiYWN0aW9uIjogewogICAgICAgICJraW5kIjogInJ1bGUtY2hlY2siLAogICAgICAgICJ0YXJnZXRJZCI6ICIiLAogICAgICAgICJtb2RlIjogImluc3BlY3QiLAogICAgICAgICJkdXJhdGlvbk1zIjogNjUwLAogICAgICAgICJ0b2xlcmFuY2UiOiAwLAogICAgICAgICJyZXF1aXJlc1BsYWNlbWVudCI6IGZhbHNlCiAgICAgIH0sCiAgICAgICJ0b29sSWQiOiAiIiwKICAgICAgInNhZmV0eVJ1bGVzIjogWwogICAgICAgIHsKICAgICAgICAgICJpZCI6ICJwcGVDb25maXJtZWQiLAogICAgICAgICAgImxhYmVsIjogIuS4quS6uumYsuaKpOeUqOWTgem9kOWFqCIsCiAgICAgICAgICAicmVxdWlyZWQiOiB0cnVlCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAicm91dGVBdXRob3JpemVkIiwKICAgICAgICAgICJsYWJlbCI6ICLkvZzkuJrot6/nur/lt7LmjojmnYMiLAogICAgICAgICAgInJlcXVpcmVkIjogdHJ1ZQogICAgICAgIH0KICAgICAgXSwKICAgICAgImF1ZGlvIjogewogICAgICAgICJuYXJyYXRpb25UZXh0IjogIiIsCiAgICAgICAgIm5hcnJhdGlvbkFzc2V0Q29kZSI6ICIiLAogICAgICAgICJzYWZldHlUZXh0IjogIiIsCiAgICAgICAgInNhZmV0eUFzc2V0Q29kZSI6ICIiLAogICAgICAgICJzdWNjZXNzQ3VlIjogInVpIiwKICAgICAgICAiZXJyb3JDdWUiOiAiY29sbGlzaW9uLWVycm9yIgogICAgICB9LAogICAgICAiYW5pbWF0aW9uQmluZGluZyI6IG51bGwsCiAgICAgICJmYXVsdElkcyI6IFtdLAogICAgICAiYXNzZXNzbWVudCI6IHsKICAgICAgICAid2VpZ2h0IjogMzMsCiAgICAgICAgInJlcXVpcmVkRGltZW5zaW9ucyI6IFsKICAgICAgICAgICJhY3Rpb24iLAogICAgICAgICAgInNlcXVlbmNlIiwKICAgICAgICAgICJzYWZldHkiCiAgICAgICAgXSwKICAgICAgICAicGFzc1BvbGljeSI6ICJhbGwtcmVxdWlyZWQiCiAgICAgIH0KICAgIH0sCiAgICB7CiAgICAgICJpZCI6ICJzYXZlZC0zIiwKICAgICAgInN0ZXBDb2RlIjogIkZJWEVELVMwMyIsCiAgICAgICJvcmRlciI6IDQsCiAgICAgICJ0aXRsZSI6ICLlt7Lkv53lrZjmraXpqqQzIiwKICAgICAgInN1bW1hcnkiOiAi5rWP6KeI5Zmo5aWR57qm5Zue5b2S5q2l6aqkIiwKICAgICAgInNjb3JlIjogMzQsCiAgICAgICJhY3Rpb25Db2RlIjogIkZJWEVELUEwMyIsCiAgICAgICJhbGxvd2VkUG9zaXRpb25zIjogWwogICAgICAgICJDT01NQU5ERVIiCiAgICAgIF0sCiAgICAgICJwaHlzaWNhbEV2ZW50UnVsZSI6IHsKICAgICAgICAic291cmNlIjogIk1BTlVBTCIsCiAgICAgICAgImV2ZW50VHlwZSI6ICJSRVZJRVdfRVZFTlRfMDMiLAogICAgICAgICJwcm9ncmVzc1BlcmNlbnQiOiAxMDAsCiAgICAgICAgIm1hbnVhbENvbmZpcm1BbGxvd2VkIjogdHJ1ZQogICAgICB9LAogICAgICAidGFyZ2V0SWQiOiAiY3lsaW5kZXItbWFpbiIsCiAgICAgICJ0YXJnZXROYW1lIjogIuaUr+iFv+a2suWOi+e8uOaAu+aIkCIsCiAgICAgICJtb2RlIjogImluc3BlY3QiLAogICAgICAiaW50ZXJhY3Rpb24iOiAidGFyZ2V0LWNvbmZpcm0iLAogICAgICAiaW5zdHJ1Y3Rpb24iOiAiIiwKICAgICAgImd1aWRhbmNlIjogIiIsCiAgICAgICJ3YXJuaW5nIjogIiIsCiAgICAgICJkZXN0aW5hdGlvbiI6ICIiLAogICAgICAic3VjY2VzcyI6ICIiLAogICAgICAiYWN0aW9uIjogewogICAgICAgICJraW5kIjogInJ1bGUtY2hlY2siLAogICAgICAgICJ0YXJnZXRJZCI6ICIiLAogICAgICAgICJtb2RlIjogImluc3BlY3QiLAogICAgICAgICJkdXJhdGlvbk1zIjogNjUwLAogICAgICAgICJ0b2xlcmFuY2UiOiAwLAogICAgICAgICJyZXF1aXJlc1BsYWNlbWVudCI6IGZhbHNlCiAgICAgIH0sCiAgICAgICJ0b29sSWQiOiAiIiwKICAgICAgInNhZmV0eVJ1bGVzIjogWwogICAgICAgIHsKICAgICAgICAgICJpZCI6ICJwcGVDb25maXJtZWQiLAogICAgICAgICAgImxhYmVsIjogIuS4quS6uumYsuaKpOeUqOWTgem9kOWFqCIsCiAgICAgICAgICAicmVxdWlyZWQiOiB0cnVlCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAicm91dGVBdXRob3JpemVkIiwKICAgICAgICAgICJsYWJlbCI6ICLkvZzkuJrot6/nur/lt7LmjojmnYMiLAogICAgICAgICAgInJlcXVpcmVkIjogdHJ1ZQogICAgICAgIH0KICAgICAgXSwKICAgICAgImF1ZGlvIjogewogICAgICAgICJuYXJyYXRpb25UZXh0IjogIiIsCiAgICAgICAgIm5hcnJhdGlvbkFzc2V0Q29kZSI6ICIiLAogICAgICAgICJzYWZldHlUZXh0IjogIiIsCiAgICAgICAgInNhZmV0eUFzc2V0Q29kZSI6ICIiLAogICAgICAgICJzdWNjZXNzQ3VlIjogInVpIiwKICAgICAgICAiZXJyb3JDdWUiOiAiY29sbGlzaW9uLWVycm9yIgogICAgICB9LAogICAgICAiYW5pbWF0aW9uQmluZGluZyI6IG51bGwsCiAgICAgICJmYXVsdElkcyI6IFtdLAogICAgICAiYXNzZXNzbWVudCI6IHsKICAgICAgICAid2VpZ2h0IjogMzQsCiAgICAgICAgInJlcXVpcmVkRGltZW5zaW9ucyI6IFsKICAgICAgICAgICJhY3Rpb24iLAogICAgICAgICAgInNlcXVlbmNlIiwKICAgICAgICAgICJzYWZldHkiCiAgICAgICAgXSwKICAgICAgICAicGFzc1BvbGljeSI6ICJhbGwtcmVxdWlyZWQiCiAgICAgIH0KICAgIH0KICBdLAogICJtb2RlQ29uZmlnIjogewogICAgInR5cGUiOiAiVklSVFVBTCIsCiAgICAicmVuZGVyTW9kZSI6ICLljp/mnInmuLLmn5PmlrnlvI8iLAogICAgImludGVyYWN0aW9uTW9kZSI6ICLkuInnu7Tmk43kvZzjgIHliqjnlLvlvJXlr7zkuI7op4TliJnoh6rliqjliKTlrpoiLAogICAgImFzc2lzdGFuY2UiOiAi5ryU56S644CB6K6t57uD5LiO5qih5ouf6ICD6K+VIgogIH0sCiAgInRhc2siOiB7CiAgICAiY29kZSI6ICIiLAogICAgInRpdGxlIjogIiIsCiAgICAiY2F0ZWdvcnkiOiAiIiwKICAgICJsZXZlbCI6ICIiLAogICAgImR1cmF0aW9uIjogMzAsCiAgICAiZGVzY3JpcHRpb24iOiAiIiwKICAgICJza2lsbHMiOiBbXQogIH0sCiAgInNjZW5hcmlvIjogewogICAgImVudmlyb25tZW50TmFtZSI6ICIiLAogICAgInNjZW5lTGF5b3V0IjogIiIsCiAgICAiZXF1aXBtZW50U3RhdGUiOiAiIiwKICAgICJzY2VuZVRhZ3MiOiBbXSwKICAgICJ2aXNpYmxlUGFydHMiOiBbXSwKICAgICJvcGVyYWJsZVBhcnRzIjogW10sCiAgICAic2Vuc29yRmVlZHMiOiBbXQogIH0sCiAgInRvb2xzIjogW10sCiAgImZhdWx0cyI6IFtdLAogICJhdXRob3JpbmciOiB7CiAgICAic2NoZW1hIjogInVucmVhbC10cmFuLnRyYWluaW5nLWF1dGhvcmluZy92MSIsCiAgICAidmVyc2lvbiI6IDEsCiAgICAibm9kZXMiOiBbCiAgICAgIHsKICAgICAgICAiaWQiOiAiZmxvdy1zdGFydCIsCiAgICAgICAgInR5cGUiOiAic3RhcnQiLAogICAgICAgICJ0aXRsZSI6ICLorq3nu4PlvIDlp4siLAogICAgICAgICJ4IjogMzIsCiAgICAgICAgInkiOiAxMjAsCiAgICAgICAgImNvbmZpZyI6IHt9CiAgICAgIH0sCiAgICAgIHsKICAgICAgICAiaWQiOiAic3RlcC1ub2RlOnNhdmVkLTEiLAogICAgICAgICJ0eXBlIjogInN0ZXAiLAogICAgICAgICJ0aXRsZSI6ICLlt7Lkv53lrZjmraXpqqQxIiwKICAgICAgICAic3RlcElkIjogInNhdmVkLTEiLAogICAgICAgICJ4IjogMjQwLAogICAgICAgICJ5IjogMTIwLAogICAgICAgICJjb25maWciOiB7fQogICAgICB9LAogICAgICB7CiAgICAgICAgImlkIjogInN0ZXAtbm9kZTpzYXZlZC0yIiwKICAgICAgICAidHlwZSI6ICJzdGVwIiwKICAgICAgICAidGl0bGUiOiAi5bey5L+d5a2Y5q2l6aqkMiIsCiAgICAgICAgInN0ZXBJZCI6ICJzYXZlZC0yIiwKICAgICAgICAieCI6IDUwMCwKICAgICAgICAieSI6IDEyMCwKICAgICAgICAiY29uZmlnIjoge30KICAgICAgfSwKICAgICAgewogICAgICAgICJpZCI6ICJzdGVwLW5vZGU6c2F2ZWQtMyIsCiAgICAgICAgInR5cGUiOiAic3RlcCIsCiAgICAgICAgInRpdGxlIjogIuW3suS/neWtmOatpemqpDMiLAogICAgICAgICJzdGVwSWQiOiAic2F2ZWQtMyIsCiAgICAgICAgIngiOiA3NjAsCiAgICAgICAgInkiOiAxMjAsCiAgICAgICAgImNvbmZpZyI6IHt9CiAgICAgIH0sCiAgICAgIHsKICAgICAgICAiaWQiOiAiZmxvdy1lbmQiLAogICAgICAgICJ0eXBlIjogImVuZCIsCiAgICAgICAgInRpdGxlIjogIuiuree7g+e7k+adnyIsCiAgICAgICAgIngiOiAyNDAsCiAgICAgICAgInkiOiAyNzAsCiAgICAgICAgImNvbmZpZyI6IHt9CiAgICAgIH0KICAgIF0sCiAgICAiZWRnZXMiOiBbCiAgICAgIHsKICAgICAgICAiaWQiOiAiZWRnZTpmbG93LXN0YXJ0OnN0ZXAtbm9kZTpzYXZlZC0xIiwKICAgICAgICAiZnJvbSI6ICJmbG93LXN0YXJ0IiwKICAgICAgICAidG8iOiAic3RlcC1ub2RlOnNhdmVkLTEiLAogICAgICAgICJsYWJlbCI6ICIiLAogICAgICAgICJraW5kIjogImJhc2Utc2VxdWVuY2UiLAogICAgICAgICJydWxlIjogIiIKICAgICAgfSwKICAgICAgewogICAgICAgICJpZCI6ICJlZGdlOnN0ZXAtbm9kZTpzYXZlZC0xOnN0ZXAtbm9kZTpzYXZlZC0yIiwKICAgICAgICAiZnJvbSI6ICJzdGVwLW5vZGU6c2F2ZWQtMSIsCiAgICAgICAgInRvIjogInN0ZXAtbm9kZTpzYXZlZC0yIiwKICAgICAgICAibGFiZWwiOiAiIiwKICAgICAgICAia2luZCI6ICJiYXNlLXNlcXVlbmNlIiwKICAgICAgICAicnVsZSI6ICIiCiAgICAgIH0sCiAgICAgIHsKICAgICAgICAiaWQiOiAiZWRnZTpzdGVwLW5vZGU6c2F2ZWQtMjpzdGVwLW5vZGU6c2F2ZWQtMyIsCiAgICAgICAgImZyb20iOiAic3RlcC1ub2RlOnNhdmVkLTIiLAogICAgICAgICJ0byI6ICJzdGVwLW5vZGU6c2F2ZWQtMyIsCiAgICAgICAgImxhYmVsIjogIiIsCiAgICAgICAgImtpbmQiOiAiYmFzZS1zZXF1ZW5jZSIsCiAgICAgICAgInJ1bGUiOiAiIgogICAgICB9LAogICAgICB7CiAgICAgICAgImlkIjogImVkZ2U6c3RlcC1ub2RlOnNhdmVkLTM6Zmxvdy1lbmQiLAogICAgICAgICJmcm9tIjogInN0ZXAtbm9kZTpzYXZlZC0zIiwKICAgICAgICAidG8iOiAiZmxvdy1lbmQiLAogICAgICAgICJsYWJlbCI6ICIiLAogICAgICAgICJraW5kIjogImJhc2Utc2VxdWVuY2UiLAogICAgICAgICJydWxlIjogIiIKICAgICAgfQogICAgXSwKICAgICJ0aW1lbGluZSI6IHsKICAgICAgImN1cnJlbnRUaW1lIjogMCwKICAgICAgInRyYWNrcyI6IHsKICAgICAgICAic2F2ZWQtMSI6IHsKICAgICAgICAgICJkdXJhdGlvbiI6IDgsCiAgICAgICAgICAiYWN0aW9uIjogInJ1bGUtY2hlY2siLAogICAgICAgICAgImNhbWVyYSI6ICLot5/pmo/nm67moIciLAogICAgICAgICAgImF1ZGlvQXNzZXRDb2RlIjogIiIsCiAgICAgICAgICAiYW5pbWF0aW9uQXNzZXRJZCI6ICIiLAogICAgICAgICAgImFuaW1hdGlvbk5hbWUiOiAiIgogICAgICAgIH0sCiAgICAgICAgInNhdmVkLTIiOiB7CiAgICAgICAgICAiZHVyYXRpb24iOiA4LAogICAgICAgICAgImFjdGlvbiI6ICJydWxlLWNoZWNrIiwKICAgICAgICAgICJjYW1lcmEiOiAi6Lef6ZqP55uu5qCHIiwKICAgICAgICAgICJhdWRpb0Fzc2V0Q29kZSI6ICIiLAogICAgICAgICAgImFuaW1hdGlvbkFzc2V0SWQiOiAiIiwKICAgICAgICAgICJhbmltYXRpb25OYW1lIjogIiIKICAgICAgICB9LAogICAgICAgICJzYXZlZC0zIjogewogICAgICAgICAgImR1cmF0aW9uIjogOCwKICAgICAgICAgICJhY3Rpb24iOiAicnVsZS1jaGVjayIsCiAgICAgICAgICAiY2FtZXJhIjogIui3n+maj+ebruaghyIsCiAgICAgICAgICAiYXVkaW9Bc3NldENvZGUiOiAiIiwKICAgICAgICAgICJhbmltYXRpb25Bc3NldElkIjogIiIsCiAgICAgICAgICAiYW5pbWF0aW9uTmFtZSI6ICIiCiAgICAgICAgfQogICAgICB9CiAgICB9CiAgfSwKICAiYXNzZXNzbWVudCI6IHsKICAgICJwYXNzU2NvcmUiOiA4MCwKICAgICJtYXhTY29yZSI6IDEwMCwKICAgICJzY29yaW5nTW9kZSI6ICJ3ZWlnaHRlZC1ydWxlcyIsCiAgICAiYWxsb3dSZXRyeSI6IHRydWUsCiAgICAicmVjb3JkUHJvY2VzcyI6IHRydWUKICB9LAogICJydW50aW1lIjogewogICAgInN0YXJ0TW9kZSI6ICJwcmFjdGljZSIsCiAgICAidGVhY2hlckVuYWJsZWQiOiB0cnVlLAogICAgInRlYWNoZXJWb2ljZUVuYWJsZWQiOiB0cnVlLAogICAgImFjdGlvblNvdW5kRW5hYmxlZCI6IHRydWUsCiAgICAic2hvd1N0ZXBQYW5lbCI6IHRydWUsCiAgICAic2hvd1Byb2dyZXNzIjogdHJ1ZSwKICAgICJhbGxvd1JlcGxheSI6IHRydWUsCiAgICAibGFuZ3VhZ2UiOiAiemgtQ04iCiAgfQp9"
+ },
+ {
+ "name": "01-saved-step-duplicate",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regression-72698-的实装步骤-duplicate后保存重算顺序和实装进度-chromium-api-mock\\attachments\\01-saved-step-duplicate-f477840b1611e438ed4d0ff915ffab50fc3ecde3.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-7978673a79614b8c453a",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 206,
+ "column": 5
+ }
+ ]
+ },
+ {
+ "title": "API mock浏览器回归:失败步骤重试",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 308,
+ "column": 6,
+ "specs": [
+ {
+ "title": "运行IN_PROGRESS的失败步骤重试入口",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 7327,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:38:13.354Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "05-failed-before-retry",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regression-3096a-步骤重试-运行IN-PROGRESS的失败步骤重试入口-chromium-api-mock\\attachments\\05-failed-before-retry-ea908a260c8fc01055e3a056f103ca73ef3ece84.png"
+ },
+ {
+ "name": "06-failed-after-retry",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regression-3096a-步骤重试-运行IN-PROGRESS的失败步骤重试入口-chromium-api-mock\\attachments\\06-failed-after-retry-12878902d1023fc18f124969fe6222a0cffa67ac.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-e26878a249d098a182f0",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 310,
+ "column": 5
+ },
+ {
+ "title": "运行SUBMITTED的失败步骤重试入口",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 4237,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:38:20.698Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "07-no-retry-SUBMITTED",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regression-fa8c9-失败步骤重试-运行SUBMITTED的失败步骤重试入口-chromium-api-mock\\attachments\\07-no-retry-SUBMITTED-8410b28aca6dc508d4b1e34b57b939c0ca52604e.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-cffe5a26ad0d017064c4",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 310,
+ "column": 5
+ },
+ {
+ "title": "运行TERMINATED的失败步骤重试入口",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 5489,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:38:24.955Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "07-no-retry-TERMINATED",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regression-a60a1-败步骤重试-运行TERMINATED的失败步骤重试入口-chromium-api-mock\\attachments\\07-no-retry-TERMINATED-436f08e12308f405ac3cc1f44242f2ffbb669aa3.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-47f27feb292e2f9c0eb7",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 310,
+ "column": 5
+ }
+ ]
+ },
+ {
+ "title": "API mock浏览器回归:执行场景关闭遮罩",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 364,
+ "column": 6,
+ "specs": [
+ {
+ "title": "普通虚拟训练",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 5513,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:38:30.469Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "04-overlay-1-VIRTUAL-IN_PROGRESS",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-普通虚拟训练-chromium-api-mock\\attachments\\04-overlay-1-VIRTUAL-IN-PROGRESS-fcda79d3ca4940c528aac7c483a2c9ead2d0dbcc.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-d9477edb7de794baa928",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 366,
+ "column": 5
+ },
+ {
+ "title": "普通实装组件",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 7663,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:38:36.002Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "04-overlay-2-PHYSICAL-IN_PROGRESS",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-普通实装组件-chromium-api-mock\\attachments\\04-overlay-2-PHYSICAL-IN-PROGRESS-26c5ea686a3aa8f70463e45fcc20a07c677acc44.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-f2c5e1edc833569f4b2f",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 366,
+ "column": 5
+ },
+ {
+ "title": "普通对抗组件",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 6557,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:38:43.768Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "04-overlay-3-CONFRONTATION-IN_PROGRESS",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-普通对抗组件-chromium-api-mock\\attachments\\04-overlay-3-CONFRONTATION-IN-PROGRESS-c4f7eecc6538fd64d8ce5d72c8c8f28ca92b4ecf.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-ab5150f5e91297e2ed5f",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 366,
+ "column": 5
+ },
+ {
+ "title": "进行中正式考核",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 22250,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:38:50.350Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "04-overlay-4-VIRTUAL-IN_PROGRESS",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-进行中正式考核-chromium-api-mock\\attachments\\04-overlay-4-VIRTUAL-IN-PROGRESS-8d726fbc53c0c8daaaaf0ec8197379c556452b34.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-298684ab236050d8b09c",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 366,
+ "column": 5
+ },
+ {
+ "title": "未开始正式考核",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 5153,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:39:12.617Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "04-overlay-5-VIRTUAL-ACCEPTED",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-未开始正式考核-chromium-api-mock\\attachments\\04-overlay-5-VIRTUAL-ACCEPTED-4e0516cf08fd71c30d92aeeb023a9010a661836a.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-4ffb1fe023b37137c9c7",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 366,
+ "column": 5
+ },
+ {
+ "title": "已提交正式考核",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 5831,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:39:17.792Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "04-overlay-6-VIRTUAL-SUBMITTED",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-已提交正式考核-chromium-api-mock\\attachments\\04-overlay-6-VIRTUAL-SUBMITTED-6c54dd97660bb2ce8d10b7956c0e936b04ac2bcc.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-28b2f1d4379a932e815f",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 366,
+ "column": 5
+ }
+ ]
+ },
+ {
+ "title": "API mock浏览器视觉回归:场景填充与安全确认区",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 396,
+ "column": 6,
+ "specs": [
+ {
+ "title": "military主题画布铺满且安全文字可读",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 9000,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "steps": [
+ {
+ "title": "Expect \"poll toBeLessThanOrEqual\"",
+ "duration": 172
+ }
+ ],
+ "startTime": "2026-09-05T03:39:23.660Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "视觉测量(API mock)",
+ "contentType": "application/json",
+ "body": "ewogICJ0aGVtZSI6ICJtaWxpdGFyeSIsCiAgInNjZW5lQm94IjogewogICAgIngiOiA0OTksCiAgICAieSI6IDM2My41NjI1LAogICAgIndpZHRoIjogNjMwLAogICAgImhlaWdodCI6IDU4My43MTg3NQogIH0sCiAgInZpZXdwb3J0Qm94IjogewogICAgIngiOiA0OTksCiAgICAieSI6IDM2My41NjI1LAogICAgIndpZHRoIjogNjMwLAogICAgImhlaWdodCI6IDU4My43MTg3NQogIH0sCiAgImNvbG9ycyI6IHsKICAgICJiYWNrZ3JvdW5kIjogWwogICAgICAyNTUsCiAgICAgIDI1NSwKICAgICAgMjU1LAogICAgICAyNTUKICAgIF0sCiAgICAiaGVhZGVyIjogWwogICAgICAyMCwKICAgICAgNjEsCiAgICAgIDU0LAogICAgICAyNTUKICAgIF0sCiAgICAibGFiZWwiOiBbCiAgICAgIDIwLAogICAgICA2MSwKICAgICAgNTQsCiAgICAgIDI1NQogICAgXQogIH0sCiAgImhlYWRlckNvbnRyYXN0IjogMTEuOTk2NTAyMTkzODUyMzUsCiAgImNoZWNrYm94Q29udHJhc3QiOiAxMS45OTY1MDIxOTM4NTIzNQp9"
+ },
+ {
+ "name": "08-visual-safety-military",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regression-74d27-安全确认区-military主题画布铺满且安全文字可读-chromium-api-mock\\attachments\\08-visual-safety-military-cd7c91ce9d2626243b11017b67ec6d3729437a20.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-451d5e5f4815308fbbf7",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 398,
+ "column": 5
+ },
+ {
+ "title": "dark主题画布铺满且安全文字可读",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 90000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium-api-mock",
+ "projectName": "chromium-api-mock",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 7654,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "steps": [
+ {
+ "title": "Expect \"poll toBeLessThanOrEqual\"",
+ "duration": 289
+ }
+ ],
+ "startTime": "2026-09-05T03:39:32.724Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "视觉测量(API mock)",
+ "contentType": "application/json",
+ "body": "ewogICJ0aGVtZSI6ICJkYXJrIiwKICAic2NlbmVCb3giOiB7CiAgICAieCI6IDQ5OSwKICAgICJ5IjogMzYzLjU2MjUsCiAgICAid2lkdGgiOiA2MzAsCiAgICAiaGVpZ2h0IjogNTgzLjcxODc1CiAgfSwKICAidmlld3BvcnRCb3giOiB7CiAgICAieCI6IDQ5OSwKICAgICJ5IjogMzYzLjU2MjUsCiAgICAid2lkdGgiOiA2MzAsCiAgICAiaGVpZ2h0IjogNTgzLjcxODc1CiAgfSwKICAiY29sb3JzIjogewogICAgImJhY2tncm91bmQiOiBbCiAgICAgIDE0LAogICAgICAzNiwKICAgICAgMzIsCiAgICAgIDI1NQogICAgXSwKICAgICJoZWFkZXIiOiBbCiAgICAgIDI0MSwKICAgICAgMjUwLAogICAgICAyNDgsCiAgICAgIDI1NQogICAgXSwKICAgICJsYWJlbCI6IFsKICAgICAgMjQxLAogICAgICAyNTAsCiAgICAgIDI0OCwKICAgICAgMjU1CiAgICBdCiAgfSwKICAiaGVhZGVyQ29udHJhc3QiOiAxNS4zMDMxNzIwNzIzMDk1OTUsCiAgImNoZWNrYm94Q29udHJhc3QiOiAxNS4zMDMxNzIwNzIzMDk1OTUKfQ=="
+ },
+ {
+ "name": "08-visual-safety-dark",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\frontend-e2e\\test-results\\review-frontend-regression-5da9e-景填充与安全确认区-dark主题画布铺满且安全文字可读-chromium-api-mock\\attachments\\08-visual-safety-dark-7d378969f0634d873015c3086d344be7b0b06b29.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "245d7b2eb94e583ba125-6ca5eb4547b74ea3dbfd",
+ "file": "review-frontend-regressions.spec.ts",
+ "line": 398,
+ "column": 5
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "errors": [],
+ "stats": {
+ "startTime": "2026-09-05T03:37:55.892Z",
+ "duration": 117710.76000000001,
+ "expected": 15,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ }
+}
\ No newline at end of file
diff --git a/reports/review-20260905/frontend-e2e/screenshots/01-saved-step-delete.png b/reports/review-20260905/frontend-e2e/screenshots/01-saved-step-delete.png
new file mode 100644
index 0000000..64134e1
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/01-saved-step-delete.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/01-saved-step-duplicate.png b/reports/review-20260905/frontend-e2e/screenshots/01-saved-step-duplicate.png
new file mode 100644
index 0000000..aab97ff
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/01-saved-step-duplicate.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/01-saved-step-move.png b/reports/review-20260905/frontend-e2e/screenshots/01-saved-step-move.png
new file mode 100644
index 0000000..a05ad2f
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/01-saved-step-move.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/02-multichannel-virtual-save.png b/reports/review-20260905/frontend-e2e/screenshots/02-multichannel-virtual-save.png
new file mode 100644
index 0000000..9f96350
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/02-multichannel-virtual-save.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/03-multichannel-confrontation-save.png b/reports/review-20260905/frontend-e2e/screenshots/03-multichannel-confrontation-save.png
new file mode 100644
index 0000000..44953d8
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/03-multichannel-confrontation-save.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/04-overlay-1-VIRTUAL-IN_PROGRESS.png b/reports/review-20260905/frontend-e2e/screenshots/04-overlay-1-VIRTUAL-IN_PROGRESS.png
new file mode 100644
index 0000000..d2d4ede
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/04-overlay-1-VIRTUAL-IN_PROGRESS.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/04-overlay-2-PHYSICAL-IN_PROGRESS.png b/reports/review-20260905/frontend-e2e/screenshots/04-overlay-2-PHYSICAL-IN_PROGRESS.png
new file mode 100644
index 0000000..6ebb143
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/04-overlay-2-PHYSICAL-IN_PROGRESS.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/04-overlay-3-CONFRONTATION-IN_PROGRESS.png b/reports/review-20260905/frontend-e2e/screenshots/04-overlay-3-CONFRONTATION-IN_PROGRESS.png
new file mode 100644
index 0000000..1201265
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/04-overlay-3-CONFRONTATION-IN_PROGRESS.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/04-overlay-4-VIRTUAL-IN_PROGRESS.png b/reports/review-20260905/frontend-e2e/screenshots/04-overlay-4-VIRTUAL-IN_PROGRESS.png
new file mode 100644
index 0000000..f4dc6ee
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/04-overlay-4-VIRTUAL-IN_PROGRESS.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/04-overlay-5-VIRTUAL-ACCEPTED.png b/reports/review-20260905/frontend-e2e/screenshots/04-overlay-5-VIRTUAL-ACCEPTED.png
new file mode 100644
index 0000000..a0ea4f5
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/04-overlay-5-VIRTUAL-ACCEPTED.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/04-overlay-6-VIRTUAL-SUBMITTED.png b/reports/review-20260905/frontend-e2e/screenshots/04-overlay-6-VIRTUAL-SUBMITTED.png
new file mode 100644
index 0000000..090df89
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/04-overlay-6-VIRTUAL-SUBMITTED.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/05-failed-before-retry.png b/reports/review-20260905/frontend-e2e/screenshots/05-failed-before-retry.png
new file mode 100644
index 0000000..fc7274e
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/05-failed-before-retry.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/06-failed-after-retry.png b/reports/review-20260905/frontend-e2e/screenshots/06-failed-after-retry.png
new file mode 100644
index 0000000..42b9faf
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/06-failed-after-retry.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/07-no-retry-SUBMITTED.png b/reports/review-20260905/frontend-e2e/screenshots/07-no-retry-SUBMITTED.png
new file mode 100644
index 0000000..76ee5f3
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/07-no-retry-SUBMITTED.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/07-no-retry-TERMINATED.png b/reports/review-20260905/frontend-e2e/screenshots/07-no-retry-TERMINATED.png
new file mode 100644
index 0000000..4e892a6
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/07-no-retry-TERMINATED.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/08-visual-safety-dark.png b/reports/review-20260905/frontend-e2e/screenshots/08-visual-safety-dark.png
new file mode 100644
index 0000000..d033928
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/08-visual-safety-dark.png differ
diff --git a/reports/review-20260905/frontend-e2e/screenshots/08-visual-safety-military.png b/reports/review-20260905/frontend-e2e/screenshots/08-visual-safety-military.png
new file mode 100644
index 0000000..2cdb90e
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/screenshots/08-visual-safety-military.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/.last-run.json b/reports/review-20260905/frontend-e2e/test-results/.last-run.json
new file mode 100644
index 0000000..cbcc1fb
--- /dev/null
+++ b/reports/review-20260905/frontend-e2e/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-3096a-步骤重试-运行IN-PROGRESS的失败步骤重试入口-chromium-api-mock/attachments/05-failed-before-retry-ea908a260c8fc01055e3a056f103ca73ef3ece84.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-3096a-步骤重试-运行IN-PROGRESS的失败步骤重试入口-chromium-api-mock/attachments/05-failed-before-retry-ea908a260c8fc01055e3a056f103ca73ef3ece84.png
new file mode 100644
index 0000000..fc7274e
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-3096a-步骤重试-运行IN-PROGRESS的失败步骤重试入口-chromium-api-mock/attachments/05-failed-before-retry-ea908a260c8fc01055e3a056f103ca73ef3ece84.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-3096a-步骤重试-运行IN-PROGRESS的失败步骤重试入口-chromium-api-mock/attachments/06-failed-after-retry-12878902d1023fc18f124969fe6222a0cffa67ac.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-3096a-步骤重试-运行IN-PROGRESS的失败步骤重试入口-chromium-api-mock/attachments/06-failed-after-retry-12878902d1023fc18f124969fe6222a0cffa67ac.png
new file mode 100644
index 0000000..42b9faf
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-3096a-步骤重试-运行IN-PROGRESS的失败步骤重试入口-chromium-api-mock/attachments/06-failed-after-retry-12878902d1023fc18f124969fe6222a0cffa67ac.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-38f8a-归:已保存的实装步骤-move后保存重算顺序和实装进度-chromium-api-mock/attachments/01-saved-step-move-ddb2e8c6700a4d2ab4c07a40315828bba9317fe2.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-38f8a-归:已保存的实装步骤-move后保存重算顺序和实装进度-chromium-api-mock/attachments/01-saved-step-move-ddb2e8c6700a4d2ab4c07a40315828bba9317fe2.png
new file mode 100644
index 0000000..a05ad2f
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-38f8a-归:已保存的实装步骤-move后保存重算顺序和实装进度-chromium-api-mock/attachments/01-saved-step-move-ddb2e8c6700a4d2ab4c07a40315828bba9317fe2.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-5a943-已保存的实装步骤-delete后保存重算顺序和实装进度-chromium-api-mock/attachments/01-saved-step-delete-9966bb7724492730a9a005464a6d080b1fd6aefd.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-5a943-已保存的实装步骤-delete后保存重算顺序和实装进度-chromium-api-mock/attachments/01-saved-step-delete-9966bb7724492730a9a005464a6d080b1fd6aefd.png
new file mode 100644
index 0000000..64134e1
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-5a943-已保存的实装步骤-delete后保存重算顺序和实装进度-chromium-api-mock/attachments/01-saved-step-delete-9966bb7724492730a9a005464a6d080b1fd6aefd.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-5da9e-景填充与安全确认区-dark主题画布铺满且安全文字可读-chromium-api-mock/attachments/08-visual-safety-dark-7d378969f0634d873015c3086d344be7b0b06b29.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-5da9e-景填充与安全确认区-dark主题画布铺满且安全文字可读-chromium-api-mock/attachments/08-visual-safety-dark-7d378969f0634d873015c3086d344be7b0b06b29.png
new file mode 100644
index 0000000..d033928
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-5da9e-景填充与安全确认区-dark主题画布铺满且安全文字可读-chromium-api-mock/attachments/08-visual-safety-dark-7d378969f0634d873015c3086d344be7b0b06b29.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-72698-的实装步骤-duplicate后保存重算顺序和实装进度-chromium-api-mock/attachments/01-saved-step-duplicate-f477840b1611e438ed4d0ff915ffab50fc3ecde3.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-72698-的实装步骤-duplicate后保存重算顺序和实装进度-chromium-api-mock/attachments/01-saved-step-duplicate-f477840b1611e438ed4d0ff915ffab50fc3ecde3.png
new file mode 100644
index 0000000..aab97ff
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-72698-的实装步骤-duplicate后保存重算顺序和实装进度-chromium-api-mock/attachments/01-saved-step-duplicate-f477840b1611e438ed4d0ff915ffab50fc3ecde3.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-74d27-安全确认区-military主题画布铺满且安全文字可读-chromium-api-mock/attachments/08-visual-safety-military-cd7c91ce9d2626243b11017b67ec6d3729437a20.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-74d27-安全确认区-military主题画布铺满且安全文字可读-chromium-api-mock/attachments/08-visual-safety-military-cd7c91ce9d2626243b11017b67ec6d3729437a20.png
new file mode 100644
index 0000000..2cdb90e
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-74d27-安全确认区-military主题画布铺满且安全文字可读-chromium-api-mock/attachments/08-visual-safety-military-cd7c91ce9d2626243b11017b67ec6d3729437a20.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-a60a1-败步骤重试-运行TERMINATED的失败步骤重试入口-chromium-api-mock/attachments/07-no-retry-TERMINATED-436f08e12308f405ac3cc1f44242f2ffbb669aa3.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-a60a1-败步骤重试-运行TERMINATED的失败步骤重试入口-chromium-api-mock/attachments/07-no-retry-TERMINATED-436f08e12308f405ac3cc1f44242f2ffbb669aa3.png
new file mode 100644
index 0000000..4e892a6
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-a60a1-败步骤重试-运行TERMINATED的失败步骤重试入口-chromium-api-mock/attachments/07-no-retry-TERMINATED-436f08e12308f405ac3cc1f44242f2ffbb669aa3.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-fa8c9-失败步骤重试-运行SUBMITTED的失败步骤重试入口-chromium-api-mock/attachments/07-no-retry-SUBMITTED-8410b28aca6dc508d4b1e34b57b939c0ca52604e.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-fa8c9-失败步骤重试-运行SUBMITTED的失败步骤重试入口-chromium-api-mock/attachments/07-no-retry-SUBMITTED-8410b28aca6dc508d4b1e34b57b939c0ca52604e.png
new file mode 100644
index 0000000..76ee5f3
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regression-fa8c9-失败步骤重试-运行SUBMITTED的失败步骤重试入口-chromium-api-mock/attachments/07-no-retry-SUBMITTED-8410b28aca6dc508d4b1e34b57b939c0ca52604e.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-已提交正式考核-chromium-api-mock/attachments/04-overlay-6-VIRTUAL-SUBMITTED-6c54dd97660bb2ce8d10b7956c0e936b04ac2bcc.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-已提交正式考核-chromium-api-mock/attachments/04-overlay-6-VIRTUAL-SUBMITTED-6c54dd97660bb2ce8d10b7956c0e936b04ac2bcc.png
new file mode 100644
index 0000000..090df89
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-已提交正式考核-chromium-api-mock/attachments/04-overlay-6-VIRTUAL-SUBMITTED-6c54dd97660bb2ce8d10b7956c0e936b04ac2bcc.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-普通实装组件-chromium-api-mock/attachments/04-overlay-2-PHYSICAL-IN-PROGRESS-26c5ea686a3aa8f70463e45fcc20a07c677acc44.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-普通实装组件-chromium-api-mock/attachments/04-overlay-2-PHYSICAL-IN-PROGRESS-26c5ea686a3aa8f70463e45fcc20a07c677acc44.png
new file mode 100644
index 0000000..6ebb143
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-普通实装组件-chromium-api-mock/attachments/04-overlay-2-PHYSICAL-IN-PROGRESS-26c5ea686a3aa8f70463e45fcc20a07c677acc44.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-普通对抗组件-chromium-api-mock/attachments/04-overlay-3-CONFRONTATION-IN-PROGRESS-c4f7eecc6538fd64d8ce5d72c8c8f28ca92b4ecf.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-普通对抗组件-chromium-api-mock/attachments/04-overlay-3-CONFRONTATION-IN-PROGRESS-c4f7eecc6538fd64d8ce5d72c8c8f28ca92b4ecf.png
new file mode 100644
index 0000000..1201265
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-普通对抗组件-chromium-api-mock/attachments/04-overlay-3-CONFRONTATION-IN-PROGRESS-c4f7eecc6538fd64d8ce5d72c8c8f28ca92b4ecf.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-普通虚拟训练-chromium-api-mock/attachments/04-overlay-1-VIRTUAL-IN-PROGRESS-fcda79d3ca4940c528aac7c483a2c9ead2d0dbcc.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-普通虚拟训练-chromium-api-mock/attachments/04-overlay-1-VIRTUAL-IN-PROGRESS-fcda79d3ca4940c528aac7c483a2c9ead2d0dbcc.png
new file mode 100644
index 0000000..d2d4ede
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-普通虚拟训练-chromium-api-mock/attachments/04-overlay-1-VIRTUAL-IN-PROGRESS-fcda79d3ca4940c528aac7c483a2c9ead2d0dbcc.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-未开始正式考核-chromium-api-mock/attachments/04-overlay-5-VIRTUAL-ACCEPTED-4e0516cf08fd71c30d92aeeb023a9010a661836a.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-未开始正式考核-chromium-api-mock/attachments/04-overlay-5-VIRTUAL-ACCEPTED-4e0516cf08fd71c30d92aeeb023a9010a661836a.png
new file mode 100644
index 0000000..a0ea4f5
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-未开始正式考核-chromium-api-mock/attachments/04-overlay-5-VIRTUAL-ACCEPTED-4e0516cf08fd71c30d92aeeb023a9010a661836a.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-进行中正式考核-chromium-api-mock/attachments/04-overlay-4-VIRTUAL-IN-PROGRESS-8d726fbc53c0c8daaaaf0ec8197379c556452b34.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-进行中正式考核-chromium-api-mock/attachments/04-overlay-4-VIRTUAL-IN-PROGRESS-8d726fbc53c0c8daaaaf0ec8197379c556452b34.png
new file mode 100644
index 0000000..f4dc6ee
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:执行场景关闭遮罩-进行中正式考核-chromium-api-mock/attachments/04-overlay-4-VIRTUAL-IN-PROGRESS-8d726fbc53c0c8daaaaf0ec8197379c556452b34.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:通用编辑器保存并切换默认渠道不丢配置-chromium-api-mock/attachments/02-multichannel-virtual-save-f8e49c240b66150dd1858ef106264dc91130c97a.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:通用编辑器保存并切换默认渠道不丢配置-chromium-api-mock/attachments/02-multichannel-virtual-save-f8e49c240b66150dd1858ef106264dc91130c97a.png
new file mode 100644
index 0000000..9f96350
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:通用编辑器保存并切换默认渠道不丢配置-chromium-api-mock/attachments/02-multichannel-virtual-save-f8e49c240b66150dd1858ef106264dc91130c97a.png differ
diff --git a/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:通用编辑器保存并切换默认渠道不丢配置-chromium-api-mock/attachments/03-multichannel-confrontation-save-5cf678a81bd438a1b8b4f49a1ecdeb78961c06d6.png b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:通用编辑器保存并切换默认渠道不丢配置-chromium-api-mock/attachments/03-multichannel-confrontation-save-5cf678a81bd438a1b8b4f49a1ecdeb78961c06d6.png
new file mode 100644
index 0000000..44953d8
Binary files /dev/null and b/reports/review-20260905/frontend-e2e/test-results/review-frontend-regressions-API-mock浏览器回归:通用编辑器保存并切换默认渠道不丢配置-chromium-api-mock/attachments/03-multichannel-confrontation-save-5cf678a81bd438a1b8b4f49a1ecdeb78961c06d6.png differ
diff --git a/reports/review-20260905/frontend-review.html b/reports/review-20260905/frontend-review.html
new file mode 100644
index 0000000..db466bd
--- /dev/null
+++ b/reports/review-20260905/frontend-review.html
@@ -0,0 +1,100 @@
+frontend-review · Code Review 前端增量 Code Review
+范围:unreal_tran_web 的 0aac918..1576728,以及新增正式入口所依赖的运行页与通用内容编辑器。按资料、产品原型、设计、开发、测试核对;审查完成后按用户授权提交为 3c7f295。
+资料与产品原型
+
+原始资料与确认原型的详细比对由本次资料审查分工提供,见同目录汇总报告。本分工未独立宣称核读全部 PDF。
+已完整阅读前端 AGENTS.md:正式教学页面必须消费 API;训练编排复用已发布场景目标;工程修改须保持教员身份门槛;保留原型工作台与接口工作台已有入口。
+保留既有功能与页面结构,只修复排序、保存兼容和错误遮罩。实装默认离线回放、正式虚拟训练与原型交互能力的差异属于汇总报告中的功能确认事项,本分工未改路由或扩建交互。
+
+设计合同
+
+unreal_tran_api/wiki/19-训练定义渠道合同.md:一份公共步骤、多渠道差异配置;默认渠道必须启用;modeConfig 与默认渠道 profile 一致。
+unreal_tran_api/wiki/20-教学命令幂等.md:四类命令 body/header 使用同一 commandId;结果未知时复用原命令号与冻结载荷。
+unreal_tran_api/wiki/21-教学步骤运行事实.md:新运行展示服务端步骤状态;空列表仅兼容历史运行;全部步骤完成方可提交。
+后端 TeachingAssignmentService.validateRuntimeDefinition 明确要求实装步骤进度按顺序严格递增,末步 100%。
+
+已修复问题
+
+
+
+编号
+问题与影响
+根因
+最小修复
+
+
+
+
+WEB-01
+实装步骤倒序、删除或复制后,保存保留旧序号和旧进度,导致排序事实不一致、发布被拒。多渠道工程同样受影响。
+新 resequenceSteps 直接调用归一化函数,而归一化优先保留已有 order/progressPercent。例如三步反转产生序号 3,2,1 与进度 100,67,33。
+按编辑后的数组重算连续序号、实装严格递增进度;保留稳定 stepCode 及已有事件规则扩展字段。
+
+
+WEB-02
+通用编辑器修改默认配置后仅更新旧 modeConfig,与已有 channelProfiles 冲突;在接口编排器重开后新值被旧 profile 覆盖。切换默认渠道也未同步正式字段。
+通用编辑器未跟进新渠道合同,只读写单个旧配置对象;对抗时长仍只写旧字段。
+各渠道按对应 profile 回填;保存同步默认渠道、启用渠道、profile 和兼容镜像;保留其他渠道。对抗时长新旧字段同步,兼容历史 roundDuration,避免 45 分钟被默认 30 分钟替代。
+
+
+WEB-03
+普通虚拟训练、实装、对抗以及执行中的正式考核,可能错误显示“正式考核执行场景已关闭”。该问题在正式虚拟页面成为默认入口后更容易暴露。
+遮罩的 v-else 实际绑定紧邻的“虚拟拆卸存放区”条件,而非正式考核生命周期。
+改为显式判断“正式考核且运行不在 IN_PROGRESS”,其他训练与执行中的考核不显示关闭遮罩。
+
+
+WEB-04
+当前步骤失败后聚合运行仍为 IN_PROGRESS,页面没有可进入正式重试状态的入口。
+原“开始训练”仅在 ACCEPTED 显示,未覆盖新步骤事实的 FAILED→IN_PROGRESS 重试合同。
+仅当学员的运行仍在 IN_PROGRESS 且当前步骤事实为 FAILED 时显示“重试当前步骤”,复用原 start 命令及幂等 tracker;已提交、归档和已完成步骤不显示。
+
+
+WEB-05
+真实 E2E 截图显示场景只占视口上方约 315px,其下安全确认区在深色场景底色上显示深色文字,难以阅读。
+场景组件 scoped position: relative 覆盖全局绝对定位,运行页缺少预览页已有的填充规则;runtime-safety-gate 没有任何对应样式。
+仅在运行视口下补场景 absolute/inset 定位;安全区使用已有主题 surface/text 颜色,补齐卡片间距、14px 复选框文字和底部定位,不修改安全规则或推进逻辑。
+
+
+WEB-06
+离线回放默认视频被替换成约 32 秒的新片,但候选与抓拍仍对应约 45 秒已验收片;34 秒与 45.2 秒两个候选不可达。
+当前默认文件实际 31.973991 秒;旧 12 候选时间轴不按 metadata 缩放,视频结束却会把五阶段标记为已回放。既有视频 SHA 契约实际失败。
+默认引用改为仓库保留的 pose1.mp4,该文件 42,865,492 字节、SHA-256 为 B588101115196061E0D2BCE4B0BD0CB1B01CAEB7561B31D0F26F5C0E980696D4,与既有已验收视频契约完全一致,实际 45.422 秒。新 32 秒文件原样保留,是否换用新片及重标候选/抓拍列 F5 待确认,不猜测新时间轴。
+
+
+
+修改文件:
+
+unreal_tran_web/src/features/training/trainingDocument.ts
+unreal_tran_web/src/views/content/ContentEditorView.vue
+unreal_tran_web/src/views/teaching/TeachingRunView.vue
+unreal_tran_web/src/styles/teaching-run-workspaces.scss
+unreal_tran_web/src/features/physical-legacy/runtime.ts
+unreal_tran_web/scripts/physical-offline-replay.contract.test.mjs
+unreal_tran_web/scripts/training-channel-profiles.contract.test.mjs
+unreal_tran_web/scripts/teaching-step-run.contract.test.mjs
+
+验证
+先加入已保存步骤排序回归:修改前 8 项测试中 2 项失败,实际观察到序号 [3,2,1] 与 [2,1],确认不是推测问题。
+修复后的命令:
+node --experimental-strip-types --test scripts/physical-offline-replay.contract.test.mjs scripts/training-channel-profiles.contract.test.mjs scripts/teaching-step-run.contract.test.mjs scripts/teaching-command-idempotency.contract.test.mjs scripts/core-role-contract.test.mjs scripts/virtual-training-formal-route.contract.test.mjs scripts/training-scene-grounding.test.mjs
+pnpm run build
+git diff --check
+
+
+Node 回归:25/25 通过,详见 frontend-contract-tests.log 。其中包含实际执行的渠道转换、已保存步骤重排、旧文档迁移、配置保存再打开、冻结命令载荷、失败步骤重试资格矩阵及离线视频 SHA/12 候选/抓拍资源等断言;路由与模板条件部分属于静态契约检查。
+最后一轮构建通过(退出码 0):包含 vue-tsc --noEmit、Vite 打包与旧模型 iframe 校验,详见 frontend-build.log 。已有资源 URL、包体大小告警不按构建失败统计。
+git diff --check 未发现空白错误;工作区 Git 的 LF/CRLF 提醒属于换行提示。
+浏览器 API mock 回归 15/15 通过,详见 frontend-e2e/README.md 。其中军工绿/暗色两套视觉用例确认场景和 host 同为 630×583.72px;安全区标题及复选框文本对比分别为 12.00/15.30,17 张截图均保留。真实 API E2E 另见同目录汇总,不能以 mock 或 Node 检查替代。
+视频兼容性单独核实:静态服务 HEAD 200、Range 206、video/mp4;ffprobe 显示 H.264 Main/yuv420p + AAC LC。bundled Chromium 对新旧 H.264 都返回不支持并触发 DEMUXER_ERROR_NO_SUPPORTED_STREAMS,不属于该视频的产品解码缺陷;根代理使用本机 Edge 验证 H.264 可播放,真实离线回放使用 Edge 完成。
+
+交给 E2E 的验证点
+
+在接口编排器重排已保存的实装/多渠道步骤,保存后检查序号与实装进度;删除末步后新末步为 100%。
+通用编辑器修改默认配置并保存,再进接口编排器,确认新值未丢失,未修改渠道原配置保留;切默认渠道后合同字段一致。
+普通三渠道运行以及执行中的正式考核不出现考核关闭遮罩;未开始/提交后的正式考核显示关闭态。
+正式虚拟主入口、旧 /api 重定向、命令丢响应后的重放、步骤事实失败与重试、教员/管理员身份边界继续验证。
+失败步骤显示“重试当前步骤”;点击后通过 start 端点进入执行中、尝试次数增加、失败次数保持,重试按钮消失。
+等待 .teaching-three-scene 的 aria-label/.teaching-scene-status 成为“已加载发布训练场景 · N 个对象”且无资产失败提示,再检查真实资产下载响应与截图。canvas 存在不等于 GLB 就绪。场景 host 应填满运行视口;明暗主题下安全确认区均应可读。
+本机 Edge 播放离线素材,3 秒出现首条候选,跳转至 video.duration - 0.03 后 [data-event-timeline] [data-physical-event][data-offline-replay="true"] 应为 12 条,包含 offline-replay:replay-complete;候选仍只供教员复核,不写正式运行进度。
+
+本分工没有数据库变更、依赖版本变更或新增产品功能。所有待确认功能范围统一列入总报告,避免分散审批。
2026-09-05 · 开发环境审查 · 图片可点击查看原尺寸
\ No newline at end of file
diff --git a/reports/review-20260905/frontend-review.md b/reports/review-20260905/frontend-review.md
new file mode 100644
index 0000000..99fb123
--- /dev/null
+++ b/reports/review-20260905/frontend-review.md
@@ -0,0 +1,68 @@
+# 前端增量 Code Review
+
+范围:`unreal_tran_web` 的 `0aac918..1576728`,以及新增正式入口所依赖的运行页与通用内容编辑器。按资料、产品原型、设计、开发、测试核对;审查完成后按用户授权提交为 `3c7f295`。
+
+## 资料与产品原型
+
+- 原始资料与确认原型的详细比对由本次资料审查分工提供,见同目录汇总报告。本分工未独立宣称核读全部 PDF。
+- 已完整阅读前端 `AGENTS.md`:正式教学页面必须消费 API;训练编排复用已发布场景目标;工程修改须保持教员身份门槛;保留原型工作台与接口工作台已有入口。
+- 保留既有功能与页面结构,只修复排序、保存兼容和错误遮罩。实装默认离线回放、正式虚拟训练与原型交互能力的差异属于汇总报告中的功能确认事项,本分工未改路由或扩建交互。
+
+## 设计合同
+
+- `unreal_tran_api/wiki/19-训练定义渠道合同.md`:一份公共步骤、多渠道差异配置;默认渠道必须启用;`modeConfig` 与默认渠道 profile 一致。
+- `unreal_tran_api/wiki/20-教学命令幂等.md`:四类命令 body/header 使用同一 commandId;结果未知时复用原命令号与冻结载荷。
+- `unreal_tran_api/wiki/21-教学步骤运行事实.md`:新运行展示服务端步骤状态;空列表仅兼容历史运行;全部步骤完成方可提交。
+- 后端 `TeachingAssignmentService.validateRuntimeDefinition` 明确要求实装步骤进度按顺序严格递增,末步 100%。
+
+## 已修复问题
+
+| 编号 | 问题与影响 | 根因 | 最小修复 |
+| --- | --- | --- | --- |
+| WEB-01 | 实装步骤倒序、删除或复制后,保存保留旧序号和旧进度,导致排序事实不一致、发布被拒。多渠道工程同样受影响。 | 新 `resequenceSteps` 直接调用归一化函数,而归一化优先保留已有 `order/progressPercent`。例如三步反转产生序号 `3,2,1` 与进度 `100,67,33`。 | 按编辑后的数组重算连续序号、实装严格递增进度;保留稳定 stepCode 及已有事件规则扩展字段。 |
+| WEB-02 | 通用编辑器修改默认配置后仅更新旧 `modeConfig`,与已有 `channelProfiles` 冲突;在接口编排器重开后新值被旧 profile 覆盖。切换默认渠道也未同步正式字段。 | 通用编辑器未跟进新渠道合同,只读写单个旧配置对象;对抗时长仍只写旧字段。 | 各渠道按对应 profile 回填;保存同步默认渠道、启用渠道、profile 和兼容镜像;保留其他渠道。对抗时长新旧字段同步,兼容历史 `roundDuration`,避免 45 分钟被默认 30 分钟替代。 |
+| WEB-03 | 普通虚拟训练、实装、对抗以及执行中的正式考核,可能错误显示“正式考核执行场景已关闭”。该问题在正式虚拟页面成为默认入口后更容易暴露。 | 遮罩的 `v-else` 实际绑定紧邻的“虚拟拆卸存放区”条件,而非正式考核生命周期。 | 改为显式判断“正式考核且运行不在 IN_PROGRESS”,其他训练与执行中的考核不显示关闭遮罩。 |
+| WEB-04 | 当前步骤失败后聚合运行仍为 IN_PROGRESS,页面没有可进入正式重试状态的入口。 | 原“开始训练”仅在 ACCEPTED 显示,未覆盖新步骤事实的 FAILED→IN_PROGRESS 重试合同。 | 仅当学员的运行仍在 IN_PROGRESS 且当前步骤事实为 FAILED 时显示“重试当前步骤”,复用原 start 命令及幂等 tracker;已提交、归档和已完成步骤不显示。 |
+| WEB-05 | 真实 E2E 截图显示场景只占视口上方约 315px,其下安全确认区在深色场景底色上显示深色文字,难以阅读。 | 场景组件 scoped `position: relative` 覆盖全局绝对定位,运行页缺少预览页已有的填充规则;`runtime-safety-gate` 没有任何对应样式。 | 仅在运行视口下补场景 absolute/inset 定位;安全区使用已有主题 surface/text 颜色,补齐卡片间距、14px 复选框文字和底部定位,不修改安全规则或推进逻辑。 |
+| WEB-06 | 离线回放默认视频被替换成约 32 秒的新片,但候选与抓拍仍对应约 45 秒已验收片;34 秒与 45.2 秒两个候选不可达。 | 当前默认文件实际 31.973991 秒;旧 12 候选时间轴不按 metadata 缩放,视频结束却会把五阶段标记为已回放。既有视频 SHA 契约实际失败。 | 默认引用改为仓库保留的 `pose1.mp4`,该文件 42,865,492 字节、SHA-256 为 `B588101115196061E0D2BCE4B0BD0CB1B01CAEB7561B31D0F26F5C0E980696D4`,与既有已验收视频契约完全一致,实际 45.422 秒。新 32 秒文件原样保留,是否换用新片及重标候选/抓拍列 F5 待确认,不猜测新时间轴。 |
+
+修改文件:
+
+- `unreal_tran_web/src/features/training/trainingDocument.ts`
+- `unreal_tran_web/src/views/content/ContentEditorView.vue`
+- `unreal_tran_web/src/views/teaching/TeachingRunView.vue`
+- `unreal_tran_web/src/styles/teaching-run-workspaces.scss`
+- `unreal_tran_web/src/features/physical-legacy/runtime.ts`
+- `unreal_tran_web/scripts/physical-offline-replay.contract.test.mjs`
+- `unreal_tran_web/scripts/training-channel-profiles.contract.test.mjs`
+- `unreal_tran_web/scripts/teaching-step-run.contract.test.mjs`
+
+## 验证
+
+先加入已保存步骤排序回归:修改前 8 项测试中 2 项失败,实际观察到序号 `[3,2,1]` 与 `[2,1]`,确认不是推测问题。
+
+修复后的命令:
+
+```text
+node --experimental-strip-types --test scripts/physical-offline-replay.contract.test.mjs scripts/training-channel-profiles.contract.test.mjs scripts/teaching-step-run.contract.test.mjs scripts/teaching-command-idempotency.contract.test.mjs scripts/core-role-contract.test.mjs scripts/virtual-training-formal-route.contract.test.mjs scripts/training-scene-grounding.test.mjs
+pnpm run build
+git diff --check
+```
+
+- Node 回归:25/25 通过,详见 [frontend-contract-tests.log](frontend-contract-tests.log)。其中包含实际执行的渠道转换、已保存步骤重排、旧文档迁移、配置保存再打开、冻结命令载荷、失败步骤重试资格矩阵及离线视频 SHA/12 候选/抓拍资源等断言;路由与模板条件部分属于静态契约检查。
+- 最后一轮构建通过(退出码 0):包含 `vue-tsc --noEmit`、Vite 打包与旧模型 iframe 校验,详见 [frontend-build.log](frontend-build.log)。已有资源 URL、包体大小告警不按构建失败统计。
+- `git diff --check` 未发现空白错误;工作区 Git 的 LF/CRLF 提醒属于换行提示。
+- 浏览器 API mock 回归 15/15 通过,详见 [frontend-e2e/README.md](frontend-e2e/README.md)。其中军工绿/暗色两套视觉用例确认场景和 host 同为 630×583.72px;安全区标题及复选框文本对比分别为 12.00/15.30,17 张截图均保留。真实 API E2E 另见同目录汇总,不能以 mock 或 Node 检查替代。
+- 视频兼容性单独核实:静态服务 HEAD 200、Range 206、`video/mp4`;ffprobe 显示 H.264 Main/yuv420p + AAC LC。bundled Chromium 对新旧 H.264 都返回不支持并触发 `DEMUXER_ERROR_NO_SUPPORTED_STREAMS`,不属于该视频的产品解码缺陷;根代理使用本机 Edge 验证 H.264 可播放,真实离线回放使用 Edge 完成。
+
+## 交给 E2E 的验证点
+
+1. 在接口编排器重排已保存的实装/多渠道步骤,保存后检查序号与实装进度;删除末步后新末步为 100%。
+2. 通用编辑器修改默认配置并保存,再进接口编排器,确认新值未丢失,未修改渠道原配置保留;切默认渠道后合同字段一致。
+3. 普通三渠道运行以及执行中的正式考核不出现考核关闭遮罩;未开始/提交后的正式考核显示关闭态。
+4. 正式虚拟主入口、旧 `/api` 重定向、命令丢响应后的重放、步骤事实失败与重试、教员/管理员身份边界继续验证。
+5. 失败步骤显示“重试当前步骤”;点击后通过 start 端点进入执行中、尝试次数增加、失败次数保持,重试按钮消失。
+6. 等待 `.teaching-three-scene` 的 `aria-label`/`.teaching-scene-status` 成为“已加载发布训练场景 · N 个对象”且无资产失败提示,再检查真实资产下载响应与截图。canvas 存在不等于 GLB 就绪。场景 host 应填满运行视口;明暗主题下安全确认区均应可读。
+7. 本机 Edge 播放离线素材,3 秒出现首条候选,跳转至 `video.duration - 0.03` 后 `[data-event-timeline] [data-physical-event][data-offline-replay="true"]` 应为 12 条,包含 `offline-replay:replay-complete`;候选仍只供教员复核,不写正式运行进度。
+
+本分工没有数据库变更、依赖版本变更或新增产品功能。所有待确认功能范围统一列入总报告,避免分散审批。
diff --git a/reports/review-20260905/index.html b/reports/review-20260905/index.html
new file mode 100644
index 0000000..6d7771d
--- /dev/null
+++ b/reports/review-20260905/index.html
@@ -0,0 +1,295 @@
+review-report · Code Review 更新项目 Code Review 与 E2E 报告
+审查日期:2026-09-05 · 开发环境:E:/work/edit_3d · 状态:审查与验证完成,4 项功能差异和 1 项素材选择待确认
+本轮按 资料 → 产品原型 → 设计 → 开发 → 测试 审查更新,定位并修复 8 处问题:前端 6 处、后端 1 处、数据库迁移 1 处。修复沿用现有产品和接口合同。另有 4 项资料与现有功能的差异,以及 1 项素材选择,列在本报告末尾待确认。
+前端与三个后端服务已启动。页面地址:本机 6180 ,局域网 6180 。审查完成后已按用户授权提交:API 53d860b、Web 3c7f295。报告与测试脚本在 ute2e 独立提交。
+1. 更新了什么
+
+
+
+仓库与范围
+同事本次更新的主要内容
+本次重点
+
+
+
+
+unreal_tran_api:3307c23..b683bb7,57 个文件
+多渠道训练定义;开始/推进/提交/评定命令幂等;步骤运行事实;三个登录身份与教员内容写权限;029–034 SQL
+发布快照、权限、重复命令、失败重试、提交门槛、数据库兼容性
+
+
+unreal_tran_web:0aac918..1576728,29 个文件
+虚拟训练切到正式 API 主入口;旧链接重定向;渠道编辑;步骤状态展示;命令追踪;角色、Three 资源及回放内容调整
+默认入口可用性、编辑保存一致性、步骤顺序、运行页和重试入口
+
+
+
+2. 从资料到测试的追溯
+已逐页查看《维修训练内容设计.pdf》6 页和《电器控制系统故障维修脚本.pdf》5 页,并保留 11 页资料图像 。这些文件没有可提取文本层;维修设计第 6 页下半流程图扫描过淡,未据此臆测执行规则。
+AGENTS.md 指定的相邻 unreal_tran_demo 当前缺失。本次结合正式前端中移植的原型源码、适配层和 API wiki 追溯,未把废弃原型目录当作最新定稿。完整依据见 资料与原型审查 。
+
+
+
+资料要求
+原型与设计
+开发检查
+测试证据
+
+
+
+
+同一任务贯穿不同训练方式
+移植训练原型;wiki 19:公共步骤与渠道 profile、固定发布快照
+渠道配置读写与默认配置镜像;切换默认渠道;保留其他渠道
+浏览器模拟接口回归验证保存载荷与重新打开;Node 执行渠道转换逻辑
+
+
+教员编排、学员训练、权限分工
+现有角色合同与 wiki 14
+三种登录角色;内容写入需有效教员身份
+真实登录与教员列表;纯管理员创建内容返回 403;后端权限回归
+
+
+逐步骤留痕、失败可重试
+电气脚本五阶段;wiki 21 的四种步骤状态
+顺序/进度;开始、失败、重试、完成;迟到证据
+真实 API/数据库步骤事实、迟到事件 400;浏览器重试按钮与状态变化
+
+
+操作不重复记账
+wiki 20 的命令号与冻结载荷
+同载荷重放、异载荷冲突、运行版本与事务边界
+真实 start/progress/review 重放不增加版本,异载荷 409;服务测试
+
+
+完成后提交、结果可查询
+当前 wiki 21 要求全步骤完成;资料的提前交卷另列确认
+提交门槛、评定、刷新后的服务端结果
+真实未完成提交 409;8 步完成后 UI 提交;评定后刷新
+
+
+训练页面可操作、状态清晰
+正式 API 主入口与已有工作台设计
+错误考核遮罩、场景尺寸、安全提示对比度
+真实场景就绪截图;军工绿/暗色主题尺寸与文字对比测量
+
+
+
+3. 已修复的 8 处问题
+P1 表示阻断目标环境部署;P2 表示功能、状态或界面正确性问题。下列均已修改代码或 DDL,详细复现、修改文件与验证见 前端审查 、后端审查 和 数据库审查 。
+
+
+
+编号
+级别
+触发与原行为
+修复后的行为
+
+
+
+
+DB-01
+P1
+KingbaseES 当前版本不接受 034 中独立 UNIQUE INDEX 作为组合外键目标,步骤事实表迁移失败
+新增 035,使用真正 UNIQUE 约束;同步全量 DDL,保留历史 034;开发库执行 033 → 035
+
+
+API-01
+P2
+步骤失败后重试,收到重试开始前的迟到成功/失败事件,可能错误完成当前尝试或增加失败次数
+从已有激活事件校验本次尝试开始时间;旧事件返回 400,不改变当前事实
+
+
+WEB-01
+P2
+实装步骤移动、删除或复制后仍保留旧顺序和进度,如 3,2,1 / 100,67,33,发布可能被拒
+按编辑后的顺序重算顺序号(order)和严格递增进度,末步 100%;保留步骤标识和事件扩展字段
+
+
+WEB-02
+P2
+通用编辑器只修改旧 modeConfig,重开被旧渠道 profile 覆盖;切默认渠道字段不一致
+同步默认渠道、启用渠道、profile 和兼容镜像;保留其他渠道与历史对抗时长
+
+
+WEB-03
+P2
+v-else 绑定到拆卸侧栏条件,普通训练也显示“正式考核执行场景已关闭”
+仅正式考核且不在执行中显示关闭态
+
+
+WEB-04
+P2
+当前步骤 FAILED,但运行仍 IN_PROGRESS,学员没有重试入口
+补“重试当前步骤”,复用已有 start 和幂等命令;结束状态不出现该按钮
+
+
+WEB-05
+P2
+场景仅占视口上方,安全确认区暗底深字
+补运行场景填充定位和已有主题颜色、间距;安全确认规则保持原有语义
+
+
+WEB-06
+P2
+默认离线视频替换为约 32 秒的新素材,但候选、阶段与抓拍仍对应 45 秒旧片;34 秒和 45.2 秒候选无法到达
+将默认映射指向仓库保留、哈希与既有验收合同一致的 45 秒视频;新素材保留,重标与切换列 F5 确认
+
+
+
+4. 测试结果与边界
+
+
+
+验证
+最终结果
+证据与范围
+
+
+
+
+后端全量测试
+402/402 通过,0 失败、错误、跳过
+Gateway 1、Auth 86、Tran 315;含新增迟到事件回归
+
+
+后端构建
+通过
+Java 17 / Maven;三个服务打包完成
+
+
+前端 Node 回归
+25/25 通过
+原 24 项加视频资产、候选与抓拍合同
+
+
+前端构建
+通过
+vue-tsc、Vite、旧模型 iframe 校验
+
+
+真实服务 E2E(Edge)
+4/4 通过,0 失败/跳过/重试
+62.3 秒;开始时间 2026-09-05T03:47:45.212Z(UTC)
+
+
+浏览器模拟接口回归
+15/15 通过,0 失败/跳过/重试
+117.7 秒;编辑保存、状态组合、军工绿/暗色主题
+
+
+数据库迁移
+通过
+033、035;首次、重跑和指定中断点恢复;开发库检查值均为 0
+
+
+测试脚本类型检查、Git 空白检查
+通过
+两份专用 Playwright 测试与配置;API/Web/E2E 仓库
+
+
+
+最终通过结果来自各自一次完整运行,未启用自动重试。补入三次完整场景加载与截图后,真实虚拟用例曾超过原 120 秒测试总预算,断言和评定已完成但截图收尾超时;保留 该轮记录 ,随后将该用例预算调到 240 秒,并在其他浏览器回归结束后重新完整运行。单次资源就绪仍有 60 秒上限。
+实装视频新增检查还发现本机 Playwright 自带 Chromium 不支持 H.264(canPlayType 返回空字符串);失败轮次 与Edge 播放探针 保留。最终真实 E2E 使用已安装 Microsoft Edge,模拟接口回归继续使用 Chromium。另独立修复了实际素材与候选配置不匹配的 WEB-06;最终用例验证默认视频可播、片长覆盖末尾候选,并在结尾得到 12 条候选。测试开发阶段的权限夹具、请求拦截范围与 UI 选择器错误已修正,不计为产品缺陷。
+真实 E2E 使用本机正在运行的 Web、Gateway、Auth、Tran 与开发数据库,没有拦截 /api。通过专用临时账号和临时任务验证,避免改动现有训练记录。
+浏览器回归使用真实前端组件与 Playwright,但对 API 返回进行模拟,以稳定重现排序、多渠道保存、遮罩及失败重试的状态组合。它能验证页面与请求载荷,不能作为后端持久化已通过的证据。
+真实虚拟训练用例通过 UI 开始、UI 提交,通过真实 API 逐步推进 8 个步骤并验证服务端事实和重放。它验证了页面、接口与数据库的联通,没有逐个通过三维鼠标操作完成训练动作 。三维资源加载与场景尺寸单独等待和检查。
+实装测试验证的是失败 → 重试 → 拒绝旧证据 → 接受新证据的真实 API/数据库流程。现有实装样例的源项目已回到 DRAFT,因此复制其历史发布快照为隔离测试任务并配置人工事件;未更改原项目。默认实装运行仍是离线回放,现场 AI、硬件、MR、完整电气训练和自动评分不属于本轮通过项。真实并发压力与故障注入恢复未作为完整系统验收执行。
+5. 截图证据
+以下截图来自最终通过轮次。点击图像可查看原尺寸。模拟接口截图的图注明确标注,资料扫描不计入 E2E 截图。
+共 27 张最终 E2E 截图。
+真实服务与页面
+
+
真实服务:登录仅展示教员、学员、管理员
+
真实服务:正式虚拟任务中心
+
真实服务:1920 宽度任务中心
+
真实服务:教员训练编排列表已加载
+
真实服务:开始训练、模型就绪,无错误考核遮罩
+
真实服务:8/8 步骤完成,真实模型已加载
+
真实服务:提交评定后刷新
+
真实服务:实装任务清单
+
真实页面:离线视频就绪,并标明不推进正式进度、不计分
+
真实页面:匹配视频到达末尾,12 条离线候选可达、等待复核
+
+模拟接口浏览器回归
+
+
模拟接口:删除步骤后末步进度 100%
+
模拟接口:复制步骤后重新编号
+
模拟接口:移动已保存步骤后保持正确顺序
+
模拟接口:虚拟渠道配置保存与重开
+
模拟接口:对抗渠道配置保存与重开
+
模拟接口:关闭遮罩状态矩阵 1-VIRTUAL-IN_PROGRESS
+
模拟接口:关闭遮罩状态矩阵 2-PHYSICAL-IN_PROGRESS
+
模拟接口:关闭遮罩状态矩阵 3-CONFRONTATION-IN_PROGRESS
+
模拟接口:关闭遮罩状态矩阵 4-VIRTUAL-IN_PROGRESS
+
模拟接口:关闭遮罩状态矩阵 5-VIRTUAL-ACCEPTED
+
模拟接口:关闭遮罩状态矩阵 6-VIRTUAL-SUBMITTED
+
模拟接口:失败步骤出现重试入口
+
模拟接口:重试后尝试 2 次、失败 1 次
+
模拟接口:已提交不出现重试按钮
+
模拟接口:已终止不出现重试按钮
+
模拟接口:暗色主题安全区,对比度 15.30
+
模拟接口:军工绿主题安全区,对比度 12.00
+
+
+6. 数据库、运行环境与清理
+目标数据库为开发环境 unreal_tran/public,KingbaseES V008R006C009B0014。迁移前保存四张教学主表原始行、字段和索引信息,恢复快照位于 E:/work/edit_3d/.codex-tmp/review-20260905/database-before.json;该文件含业务数据,未打进报告包。
+实际执行 033 与新增 035。029–032 未在本次审查中重新执行,未注入新的前泵课目。035 与全量 DDL 的步骤表、父表约束保持一致;验证了隔离 schema 首次执行、重复执行、中途提交后续跑,所有无效行/孤儿行检查为 0。隔离 schema 已删除。详见 迁移执行记录 。其他环境部署应先核对现状,按文档执行 033 → 035 ,迁移工具需支持 dollar quote。
+最终清理时间:2026-09-05T11:48:55.347416。删除本轮临时任务及关联记录(step_run 12, command_receipt 13, event 19, run_member 2, run 2, member 2, assignment 2),测试账号已停用/软删除,会话已撤销,临时管理员密码文件已移除。四张原始教学表的既有行逐行对照均未变化,剩余活动测试账号为 0。审计与软删除记录按系统机制保留。见 最终清理记录 ;较早轮次见 diagnostics/cleanup-*.json。
+环境检查记录见 environment.json 。三个服务健康接口及本机、局域网前端访问返回 200。Nacos 与 Redis 保留运行。
+构建中的既有资源 URL、包体积和部分 Java 弃用警告未阻断打包;未因此升级依赖版本。报告不包含密码或会话令牌。
+7. 待你确认的功能差异与素材选择
+这些涉及产品口径或新增接入,未作为本轮 bugfix 擅自扩展。建议逐项确认后安排对应实现与验收。
+
+
+
+编号
+现状与资料差异
+需要确认的决定
+
+
+
+
+F1:实装正式训练
+电气资料及移植原型为五阶段、15 分钟;默认实装页面当前是约 45 秒离线识别回放,未接入正式步骤与计分
+此次交付继续保留回放,还是把电气模板发布为正式内容、接入真实事件/人工证据、提交与评定?
+
+
+F2:混合现实归属
+资料列虚拟、混合现实、AI 实装;当前渠道为 VIRTUAL、PHYSICAL、CONFRONTATION
+MR 作为虚拟渠道的呈现能力/沉浸入口,还是新增渠道?对抗是否属于另一维度的组织形式?
+
+
+F3:提前提交规则
+资料允许未收尾提交并扣分;当前新运行要求全部步骤 COMPLETED 才能提交
+保留全部完成门槛,还是新增提前交卷/放弃结算及未完成、失败、超时计分规则?
+
+
+F4:评分与复盘范围
+资料包括自动结算、90/80/70 分档、全过程复盘和知识拓展;当前以服务端规则/教员评定为权威
+这些是否纳入此次交付?若纳入,需要确认评分规则、等级文案、复盘证据及知识资源来源。
+
+
+F5:离线回放素材
+新增约 32 秒素材与现有 12 条候选/抓拍不匹配;本轮默认恢复指向已验收的约 45 秒素材
+继续使用当前匹配素材,还是采用新片?采用新片需要先重新标注候选、阶段时间与证据抓拍,再整体切换。
+
+
+
+各项 PDF 页码、原型和代码依据见 完整差异说明 。
+8. 报告文件与复跑
+
+测试源码位于 ute2e/tests/review-live.spec.ts 和 tests/review-frontend-regressions.spec.ts,配置为 review-live.config.ts / review-frontend.config.ts。回归脚本读取本地配置,不内嵌密码。
+已具备开发库表结构时,可在 ute2e 顺序运行:
+python tools/review-db.py test-admin
+pnpm exec playwright test --config review-live.config.ts
+python tools/review-db.py cleanup
+pnpm exec playwright test --config review-frontend.config.ts
+
+真实测试后即使失败也需执行 cleanup。当前开发库已完成迁移,prepare 会因目标表存在而拒绝重跑。报告 JSON 保留时间、断言和最终结果,可据此核验,测试计数不等同于完整功能覆盖率。
2026-09-05 · 开发环境审查 · 图片可点击查看原尺寸
\ No newline at end of file
diff --git a/reports/review-20260905/live/api-evidence.json b/reports/review-20260905/live/api-evidence.json
new file mode 100644
index 0000000..37034a5
--- /dev/null
+++ b/reports/review-20260905/live/api-evidence.json
@@ -0,0 +1,119 @@
+[
+ {
+ "case": "admin content create requires teacher role",
+ "status": 403
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E5%9C%B0%E5%BD%A2.glb",
+ "status": 200
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E8%BD%A6.glb",
+ "status": 200
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E5%9C%B0%E5%BD%A2.glb",
+ "status": 200
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E5%9C%B0%E5%BD%A2.glb",
+ "status": 200
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E8%BD%A6.glb",
+ "status": 200
+ },
+ {
+ "case": "published scene ready",
+ "status": "已加载发布训练场景 · 2 个对象",
+ "height": 1149.40625
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E5%9C%B0%E5%BD%A2.glb",
+ "status": 200
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E8%BD%A6.glb",
+ "status": 200
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E5%9C%B0%E5%BD%A2.glb",
+ "status": 200
+ },
+ {
+ "case": "published scene ready",
+ "status": "已加载发布训练场景 · 2 个对象",
+ "height": 1149.40625
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E8%BD%A6.glb",
+ "status": 200
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E5%9C%B0%E5%BD%A2.glb",
+ "status": 200
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E8%BD%A6.glb",
+ "status": 200
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E5%9C%B0%E5%BD%A2.glb",
+ "status": 200
+ },
+ {
+ "case": "scene asset response",
+ "path": "/models/%E8%BD%A6.glb",
+ "status": 200
+ },
+ {
+ "case": "published scene ready",
+ "status": "已加载发布训练场景 · 2 个对象",
+ "height": 1149.40625
+ },
+ {
+ "case": "virtual lifecycle",
+ "runId": "42",
+ "steps": 8,
+ "startReplay": true,
+ "progressReplay": true,
+ "reviewReplay": true,
+ "earlySubmit": 409,
+ "conflictingCommand": 409
+ },
+ {
+ "case": "physical offline replay",
+ "readyState": 4,
+ "duration": 45.422,
+ "candidates": 12,
+ "formalProgressAutomatic": false
+ },
+ {
+ "case": "physical retry evidence",
+ "runId": "43",
+ "staleStatus": 400,
+ "final": "COMPLETED",
+ "attemptCount": 2,
+ "failureCount": 1
+ },
+ {
+ "cleanupUser": "93",
+ "status": 200
+ },
+ {
+ "cleanupUser": "94",
+ "status": 200
+ }
+]
\ No newline at end of file
diff --git a/reports/review-20260905/live/cleanup-manifest.json b/reports/review-20260905/live/cleanup-manifest.json
new file mode 100644
index 0000000..b06e5c0
--- /dev/null
+++ b/reports/review-20260905/live/cleanup-manifest.json
@@ -0,0 +1,24 @@
+{
+ "prefix": "UTE2E-REVIEW-MTNUE3OB",
+ "tasks": [
+ {
+ "id": "48",
+ "code": "UTE2E-REVIEW-MTNUE3OB-VIRTUAL"
+ },
+ {
+ "id": "49",
+ "code": "UTE2E-REVIEW-MTNUE3OB-PHYSICAL",
+ "version": 0
+ }
+ ],
+ "users": [
+ {
+ "id": "93",
+ "username": "rv_teacher_mtnue4h0"
+ },
+ {
+ "id": "94",
+ "username": "rv_student_mtnue4z2"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/review-20260905/live/html/data/07a852b9c40e3add6dff015c4d1735af88a44497.png b/reports/review-20260905/live/html/data/07a852b9c40e3add6dff015c4d1735af88a44497.png
new file mode 100644
index 0000000..aa17138
Binary files /dev/null and b/reports/review-20260905/live/html/data/07a852b9c40e3add6dff015c4d1735af88a44497.png differ
diff --git a/reports/review-20260905/live/html/data/4e5983c2ad0ad7274dea595854f5c4f11ec95125.png b/reports/review-20260905/live/html/data/4e5983c2ad0ad7274dea595854f5c4f11ec95125.png
new file mode 100644
index 0000000..844f349
Binary files /dev/null and b/reports/review-20260905/live/html/data/4e5983c2ad0ad7274dea595854f5c4f11ec95125.png differ
diff --git a/reports/review-20260905/live/html/data/4eaa537cf53dbe4956004917bc7d8978b39d0bb0.png b/reports/review-20260905/live/html/data/4eaa537cf53dbe4956004917bc7d8978b39d0bb0.png
new file mode 100644
index 0000000..184a232
Binary files /dev/null and b/reports/review-20260905/live/html/data/4eaa537cf53dbe4956004917bc7d8978b39d0bb0.png differ
diff --git a/reports/review-20260905/live/html/data/7da9425dc3b97c867e069012584a8191e88bb35e.png b/reports/review-20260905/live/html/data/7da9425dc3b97c867e069012584a8191e88bb35e.png
new file mode 100644
index 0000000..e2c5492
Binary files /dev/null and b/reports/review-20260905/live/html/data/7da9425dc3b97c867e069012584a8191e88bb35e.png differ
diff --git a/reports/review-20260905/live/html/data/965fff8b59ad6a49ceff8467473260c51634e10c.png b/reports/review-20260905/live/html/data/965fff8b59ad6a49ceff8467473260c51634e10c.png
new file mode 100644
index 0000000..9d6c9b8
Binary files /dev/null and b/reports/review-20260905/live/html/data/965fff8b59ad6a49ceff8467473260c51634e10c.png differ
diff --git a/reports/review-20260905/live/html/data/ad64601ca429923944a341eb1e1add95a8c0b2f7.png b/reports/review-20260905/live/html/data/ad64601ca429923944a341eb1e1add95a8c0b2f7.png
new file mode 100644
index 0000000..4c6b232
Binary files /dev/null and b/reports/review-20260905/live/html/data/ad64601ca429923944a341eb1e1add95a8c0b2f7.png differ
diff --git a/reports/review-20260905/live/html/data/aeb3505b7a5295fdcfa1ed87124836ca89273d03.png b/reports/review-20260905/live/html/data/aeb3505b7a5295fdcfa1ed87124836ca89273d03.png
new file mode 100644
index 0000000..1a77b6f
Binary files /dev/null and b/reports/review-20260905/live/html/data/aeb3505b7a5295fdcfa1ed87124836ca89273d03.png differ
diff --git a/reports/review-20260905/live/html/data/c03b4c76712b989c026f50a71b669b3286cd3b88.png b/reports/review-20260905/live/html/data/c03b4c76712b989c026f50a71b669b3286cd3b88.png
new file mode 100644
index 0000000..ea73c1a
Binary files /dev/null and b/reports/review-20260905/live/html/data/c03b4c76712b989c026f50a71b669b3286cd3b88.png differ
diff --git a/reports/review-20260905/live/html/data/c04c0064fbaf9a66a4dc32d64bc29e74507a3400.png b/reports/review-20260905/live/html/data/c04c0064fbaf9a66a4dc32d64bc29e74507a3400.png
new file mode 100644
index 0000000..3cca49c
Binary files /dev/null and b/reports/review-20260905/live/html/data/c04c0064fbaf9a66a4dc32d64bc29e74507a3400.png differ
diff --git a/reports/review-20260905/live/html/data/c85874a1a6165d6fed294cf72c6f60c5df62aab7.png b/reports/review-20260905/live/html/data/c85874a1a6165d6fed294cf72c6f60c5df62aab7.png
new file mode 100644
index 0000000..0e73056
Binary files /dev/null and b/reports/review-20260905/live/html/data/c85874a1a6165d6fed294cf72c6f60c5df62aab7.png differ
diff --git a/reports/review-20260905/live/html/index.html b/reports/review-20260905/live/html/index.html
new file mode 100644
index 0000000..56b4c1d
--- /dev/null
+++ b/reports/review-20260905/live/html/index.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/reports/review-20260905/live/results.json b/reports/review-20260905/live/results.json
new file mode 100644
index 0000000..ab1b817
--- /dev/null
+++ b/reports/review-20260905/live/results.json
@@ -0,0 +1,285 @@
+{
+ "config": {
+ "configFile": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\review-live.config.ts",
+ "rootDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
+ "forbidOnly": false,
+ "fullyParallel": false,
+ "globalSetup": null,
+ "globalTeardown": null,
+ "globalTimeout": 0,
+ "grep": {},
+ "grepInvert": null,
+ "maxFailures": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "preserveOutput": "always",
+ "reporter": [
+ [
+ "list",
+ null
+ ],
+ [
+ "html",
+ {
+ "outputFolder": "reports/review-20260905/live/html",
+ "open": "never"
+ }
+ ],
+ [
+ "json",
+ {
+ "outputFile": "reports/review-20260905/live/results.json"
+ }
+ ]
+ ],
+ "reportSlowTests": {
+ "max": 5,
+ "threshold": 300000
+ },
+ "quiet": false,
+ "projects": [
+ {
+ "outputDir": "E:/work/edit_3d/unreal_tran/ute2e/reports/review-20260905/live/results",
+ "repeatEach": 1,
+ "retries": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "id": "",
+ "name": "",
+ "testDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
+ "testIgnore": [],
+ "testMatch": [
+ "review-live.spec.ts"
+ ],
+ "timeout": 120000
+ }
+ ],
+ "shard": null,
+ "updateSnapshots": "missing",
+ "updateSourceMethod": "patch",
+ "version": "1.55.0",
+ "workers": 1,
+ "webServer": null
+ },
+ "suites": [
+ {
+ "title": "review-live.spec.ts",
+ "file": "review-live.spec.ts",
+ "column": 0,
+ "line": 0,
+ "specs": [
+ {
+ "title": "live login roles and formal virtual route",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 120000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 10075,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:47:46.359Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "01-login-three-roles",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-login-roles-and-formal-virtual-route\\attachments\\01-login-three-roles-f86162e84bea73eafa87bb0016f894f5e08d87b7.png"
+ },
+ {
+ "name": "02-virtual-formal-task-center",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-login-roles-and-formal-virtual-route\\attachments\\02-virtual-formal-task-center-b028ec31b409a20f12f7cf73dc7811535baab32e.png"
+ },
+ {
+ "name": "03-virtual-1920",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-login-roles-and-formal-virtual-route\\attachments\\03-virtual-1920-a16d403e0842f961f464136f9289448b6ab9b665.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "351158f247962162d567-8b85e2f4bb6d5591db73",
+ "file": "review-live.spec.ts",
+ "line": 101,
+ "column": 1
+ },
+ {
+ "title": "live teacher role and content write boundary",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 120000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 3621,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:48:00.081Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "04-teacher-content",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-teacher-role-and-content-write-boundary\\attachments\\04-teacher-content-9ec9c5654b6ad956d784269cf0e1e26d7dbe0821.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "351158f247962162d567-ccf9c84f5598cdf4198c",
+ "file": "review-live.spec.ts",
+ "line": 123,
+ "column": 1
+ },
+ {
+ "title": "live virtual start replay step facts submit and review",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 240000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 29615,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-05T03:48:03.714Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "05-virtual-started-no-exam-overlay",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-virtual-s-f74ac-tep-facts-submit-and-review\\attachments\\05-virtual-started-no-exam-overlay-75bbe0fc16a0254e73c609a1a3640d65844c77c7.png"
+ },
+ {
+ "name": "06-virtual-all-steps-completed",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-virtual-s-f74ac-tep-facts-submit-and-review\\attachments\\06-virtual-all-steps-completed-1fb317f851bbfea81cb72348d0c3fa6677c15524.png"
+ },
+ {
+ "name": "07-virtual-reviewed",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-virtual-s-f74ac-tep-facts-submit-and-review\\attachments\\07-virtual-reviewed-c18c9c866c30a33295adba3a8277bc7dd76aab69.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "351158f247962162d567-3ecee091831f9a0e83fd",
+ "file": "review-live.spec.ts",
+ "line": 134,
+ "column": 1
+ },
+ {
+ "title": "live physical failure retry rejects stale evidence",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 120000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "",
+ "projectName": "",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 12182,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "steps": [
+ {
+ "title": "Expect \"poll toBeGreaterThanOrEqual\"",
+ "duration": 37
+ },
+ {
+ "title": "Expect \"poll toBe\"",
+ "duration": 182
+ }
+ ],
+ "startTime": "2026-09-05T03:48:33.345Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "08-physical-api-task-list",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-physical--961b7-etry-rejects-stale-evidence\\attachments\\08-physical-api-task-list-7e7551f3cc59abf587e5d2dc7d0767de16241cf6.png"
+ },
+ {
+ "name": "09-physical-offline-replay-boundary",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-physical--961b7-etry-rejects-stale-evidence\\attachments\\09-physical-offline-replay-boundary-8d0df1dc3e5920624b8c5bf326857aceffaacf5f.png"
+ },
+ {
+ "name": "10-physical-twelve-candidates",
+ "contentType": "image/png",
+ "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\review-20260905\\live\\results\\review-live-live-physical--961b7-etry-rejects-stale-evidence\\attachments\\10-physical-twelve-candidates-6f4ae9579c7b7e5444e0b2ddbc0742a662f98d70.png"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "351158f247962162d567-f2520dc34ab1c6ead4ee",
+ "file": "review-live.spec.ts",
+ "line": 201,
+ "column": 1
+ }
+ ]
+ }
+ ],
+ "errors": [],
+ "stats": {
+ "startTime": "2026-09-05T03:47:45.212Z",
+ "duration": 62279.288,
+ "expected": 4,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ }
+}
\ No newline at end of file
diff --git a/reports/review-20260905/live/results/.last-run.json b/reports/review-20260905/live/results/.last-run.json
new file mode 100644
index 0000000..cbcc1fb
--- /dev/null
+++ b/reports/review-20260905/live/results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/reports/review-20260905/live/results/review-live-live-login-roles-and-formal-virtual-route/attachments/01-login-three-roles-f86162e84bea73eafa87bb0016f894f5e08d87b7.png b/reports/review-20260905/live/results/review-live-live-login-roles-and-formal-virtual-route/attachments/01-login-three-roles-f86162e84bea73eafa87bb0016f894f5e08d87b7.png
new file mode 100644
index 0000000..1a77b6f
Binary files /dev/null and b/reports/review-20260905/live/results/review-live-live-login-roles-and-formal-virtual-route/attachments/01-login-three-roles-f86162e84bea73eafa87bb0016f894f5e08d87b7.png differ
diff --git a/reports/review-20260905/live/results/review-live-live-login-roles-and-formal-virtual-route/attachments/02-virtual-formal-task-center-b028ec31b409a20f12f7cf73dc7811535baab32e.png b/reports/review-20260905/live/results/review-live-live-login-roles-and-formal-virtual-route/attachments/02-virtual-formal-task-center-b028ec31b409a20f12f7cf73dc7811535baab32e.png
new file mode 100644
index 0000000..9d6c9b8
Binary files /dev/null and b/reports/review-20260905/live/results/review-live-live-login-roles-and-formal-virtual-route/attachments/02-virtual-formal-task-center-b028ec31b409a20f12f7cf73dc7811535baab32e.png differ
diff --git a/reports/review-20260905/live/results/review-live-live-login-roles-and-formal-virtual-route/attachments/03-virtual-1920-a16d403e0842f961f464136f9289448b6ab9b665.png b/reports/review-20260905/live/results/review-live-live-login-roles-and-formal-virtual-route/attachments/03-virtual-1920-a16d403e0842f961f464136f9289448b6ab9b665.png
new file mode 100644
index 0000000..844f349
Binary files /dev/null and b/reports/review-20260905/live/results/review-live-live-login-roles-and-formal-virtual-route/attachments/03-virtual-1920-a16d403e0842f961f464136f9289448b6ab9b665.png differ
diff --git a/reports/review-20260905/live/results/review-live-live-physical--961b7-etry-rejects-stale-evidence/attachments/08-physical-api-task-list-7e7551f3cc59abf587e5d2dc7d0767de16241cf6.png b/reports/review-20260905/live/results/review-live-live-physical--961b7-etry-rejects-stale-evidence/attachments/08-physical-api-task-list-7e7551f3cc59abf587e5d2dc7d0767de16241cf6.png
new file mode 100644
index 0000000..4c6b232
Binary files /dev/null and b/reports/review-20260905/live/results/review-live-live-physical--961b7-etry-rejects-stale-evidence/attachments/08-physical-api-task-list-7e7551f3cc59abf587e5d2dc7d0767de16241cf6.png differ
diff --git a/reports/review-20260905/live/results/review-live-live-physical--961b7-etry-rejects-stale-evidence/attachments/09-physical-offline-replay-boundary-8d0df1dc3e5920624b8c5bf326857aceffaacf5f.png b/reports/review-20260905/live/results/review-live-live-physical--961b7-etry-rejects-stale-evidence/attachments/09-physical-offline-replay-boundary-8d0df1dc3e5920624b8c5bf326857aceffaacf5f.png
new file mode 100644
index 0000000..e2c5492
Binary files /dev/null and b/reports/review-20260905/live/results/review-live-live-physical--961b7-etry-rejects-stale-evidence/attachments/09-physical-offline-replay-boundary-8d0df1dc3e5920624b8c5bf326857aceffaacf5f.png differ
diff --git a/reports/review-20260905/live/results/review-live-live-physical--961b7-etry-rejects-stale-evidence/attachments/10-physical-twelve-candidates-6f4ae9579c7b7e5444e0b2ddbc0742a662f98d70.png b/reports/review-20260905/live/results/review-live-live-physical--961b7-etry-rejects-stale-evidence/attachments/10-physical-twelve-candidates-6f4ae9579c7b7e5444e0b2ddbc0742a662f98d70.png
new file mode 100644
index 0000000..0e73056
Binary files /dev/null and b/reports/review-20260905/live/results/review-live-live-physical--961b7-etry-rejects-stale-evidence/attachments/10-physical-twelve-candidates-6f4ae9579c7b7e5444e0b2ddbc0742a662f98d70.png differ
diff --git a/reports/review-20260905/live/results/review-live-live-teacher-role-and-content-write-boundary/attachments/04-teacher-content-9ec9c5654b6ad956d784269cf0e1e26d7dbe0821.png b/reports/review-20260905/live/results/review-live-live-teacher-role-and-content-write-boundary/attachments/04-teacher-content-9ec9c5654b6ad956d784269cf0e1e26d7dbe0821.png
new file mode 100644
index 0000000..184a232
Binary files /dev/null and b/reports/review-20260905/live/results/review-live-live-teacher-role-and-content-write-boundary/attachments/04-teacher-content-9ec9c5654b6ad956d784269cf0e1e26d7dbe0821.png differ
diff --git a/reports/review-20260905/live/results/review-live-live-virtual-s-f74ac-tep-facts-submit-and-review/attachments/05-virtual-started-no-exam-overlay-75bbe0fc16a0254e73c609a1a3640d65844c77c7.png b/reports/review-20260905/live/results/review-live-live-virtual-s-f74ac-tep-facts-submit-and-review/attachments/05-virtual-started-no-exam-overlay-75bbe0fc16a0254e73c609a1a3640d65844c77c7.png
new file mode 100644
index 0000000..3cca49c
Binary files /dev/null and b/reports/review-20260905/live/results/review-live-live-virtual-s-f74ac-tep-facts-submit-and-review/attachments/05-virtual-started-no-exam-overlay-75bbe0fc16a0254e73c609a1a3640d65844c77c7.png differ
diff --git a/reports/review-20260905/live/results/review-live-live-virtual-s-f74ac-tep-facts-submit-and-review/attachments/06-virtual-all-steps-completed-1fb317f851bbfea81cb72348d0c3fa6677c15524.png b/reports/review-20260905/live/results/review-live-live-virtual-s-f74ac-tep-facts-submit-and-review/attachments/06-virtual-all-steps-completed-1fb317f851bbfea81cb72348d0c3fa6677c15524.png
new file mode 100644
index 0000000..aa17138
Binary files /dev/null and b/reports/review-20260905/live/results/review-live-live-virtual-s-f74ac-tep-facts-submit-and-review/attachments/06-virtual-all-steps-completed-1fb317f851bbfea81cb72348d0c3fa6677c15524.png differ
diff --git a/reports/review-20260905/live/results/review-live-live-virtual-s-f74ac-tep-facts-submit-and-review/attachments/07-virtual-reviewed-c18c9c866c30a33295adba3a8277bc7dd76aab69.png b/reports/review-20260905/live/results/review-live-live-virtual-s-f74ac-tep-facts-submit-and-review/attachments/07-virtual-reviewed-c18c9c866c30a33295adba3a8277bc7dd76aab69.png
new file mode 100644
index 0000000..ea73c1a
Binary files /dev/null and b/reports/review-20260905/live/results/review-live-live-virtual-s-f74ac-tep-facts-submit-and-review/attachments/07-virtual-reviewed-c18c9c866c30a33295adba3a8277bc7dd76aab69.png differ
diff --git a/reports/review-20260905/live/screenshots/01-login-three-roles.png b/reports/review-20260905/live/screenshots/01-login-three-roles.png
new file mode 100644
index 0000000..1a77b6f
Binary files /dev/null and b/reports/review-20260905/live/screenshots/01-login-three-roles.png differ
diff --git a/reports/review-20260905/live/screenshots/02-virtual-formal-task-center.png b/reports/review-20260905/live/screenshots/02-virtual-formal-task-center.png
new file mode 100644
index 0000000..9d6c9b8
Binary files /dev/null and b/reports/review-20260905/live/screenshots/02-virtual-formal-task-center.png differ
diff --git a/reports/review-20260905/live/screenshots/03-virtual-1920.png b/reports/review-20260905/live/screenshots/03-virtual-1920.png
new file mode 100644
index 0000000..844f349
Binary files /dev/null and b/reports/review-20260905/live/screenshots/03-virtual-1920.png differ
diff --git a/reports/review-20260905/live/screenshots/04-teacher-content.png b/reports/review-20260905/live/screenshots/04-teacher-content.png
new file mode 100644
index 0000000..184a232
Binary files /dev/null and b/reports/review-20260905/live/screenshots/04-teacher-content.png differ
diff --git a/reports/review-20260905/live/screenshots/05-virtual-started-no-exam-overlay.png b/reports/review-20260905/live/screenshots/05-virtual-started-no-exam-overlay.png
new file mode 100644
index 0000000..3cca49c
Binary files /dev/null and b/reports/review-20260905/live/screenshots/05-virtual-started-no-exam-overlay.png differ
diff --git a/reports/review-20260905/live/screenshots/06-virtual-all-steps-completed.png b/reports/review-20260905/live/screenshots/06-virtual-all-steps-completed.png
new file mode 100644
index 0000000..aa17138
Binary files /dev/null and b/reports/review-20260905/live/screenshots/06-virtual-all-steps-completed.png differ
diff --git a/reports/review-20260905/live/screenshots/07-virtual-reviewed.png b/reports/review-20260905/live/screenshots/07-virtual-reviewed.png
new file mode 100644
index 0000000..ea73c1a
Binary files /dev/null and b/reports/review-20260905/live/screenshots/07-virtual-reviewed.png differ
diff --git a/reports/review-20260905/live/screenshots/08-physical-api-task-list.png b/reports/review-20260905/live/screenshots/08-physical-api-task-list.png
new file mode 100644
index 0000000..4c6b232
Binary files /dev/null and b/reports/review-20260905/live/screenshots/08-physical-api-task-list.png differ
diff --git a/reports/review-20260905/live/screenshots/09-physical-offline-replay-boundary.png b/reports/review-20260905/live/screenshots/09-physical-offline-replay-boundary.png
new file mode 100644
index 0000000..e2c5492
Binary files /dev/null and b/reports/review-20260905/live/screenshots/09-physical-offline-replay-boundary.png differ
diff --git a/reports/review-20260905/live/screenshots/10-physical-twelve-candidates.png b/reports/review-20260905/live/screenshots/10-physical-twelve-candidates.png
new file mode 100644
index 0000000..0e73056
Binary files /dev/null and b/reports/review-20260905/live/screenshots/10-physical-twelve-candidates.png differ
diff --git a/reports/review-20260905/reference-review.html b/reports/review-20260905/reference-review.html
new file mode 100644
index 0000000..fdad9d7
--- /dev/null
+++ b/reports/review-20260905/reference-review.html
@@ -0,0 +1,184 @@
+reference-review · Code Review 资料 → 产品原型 → 设计追溯审查
+审查日期:2026-09-05。范围:后端 3307c23 → b683bb7,前端 0aac918 → 1576728。本附录为资料、原型和静态代码证据;运行测试结果以同目录主报告为准。
+结论
+本次多渠道定义、命令幂等和逐步骤留痕与资料的“同一任务贯穿训练、错误可重试、全过程留痕”方向一致。应按现有正式设计修复可复现缺陷,不把资料中尚未接入的功能直接补成新业务。
+有四项需要产品确认:默认实装页面仍是离线识别回放;混合现实与现有三渠道的关系尚无直接映射;“全部步骤完成才可提交”与电气脚本的“未收尾提交后扣分”口径不同;脚本的自动评分、分级和训练后复盘是否应成为此次交付范围。以下均没有作为 bug 自动修改。
+1. 原始资料与阅读证据
+实际工作区为 E:/work/edit_3d,用户文本中的 edit\_3d 按该目录解释。两份 PDF 都没有可提取的文本层,已用 Poppler 将全部 11 页渲染为 PNG 并逐页查看。页码指 PDF 文件页序。
+
+
+
+来源
+页数
+SHA-256
+本次采用的产品要求
+
+
+
+
+维修训练内容设计.pdf
+6
+8B5B0730D82CB6285A678584869B0B8E26C3C5A76817A7A52ABAD4BEBE46F2E8
+P1-2:虚拟、混合现实、AI 指导实装三种方式;同任务贯穿;教员构建场景/任务、分组、下达,学员训练,数据记录分析。P3-6为具体案例,本次不扩展其中的维修操作内容。
+
+
+电器控制系统故障维修脚本.pdf
+5
+A68BEB52E14FC6A39D6F81064B986DC00A868F695B30E9FBD5A309F30E60FD33
+P1:15分钟、分步计分、容错、操作留痕、AI助教。P2-4:五阶段;P5:计分、成绩等级、重试、复盘及知识拓展。
+
+
+
+references/training-design-1.png 至 training-design-6.png、references/electrical-script-1.png 至 electrical-script-5.png 保存了原始页面渲染。它们是资料截图,不是应用 E2E 截图。维修设计 P6 下半页流程图的扫描原件过淡,不能可靠逐字识别,未据此推导执行规则。
+关键页预览:
+
+维修设计 P1:训练方式与全流程
+
+
+
+电气脚本 P5:评分、重试和复盘
+
+
+2. 产品原型的实际来源
+unreal_tran_web/AGENTS.md:7 指定相邻 unreal_tran_demo 为当前交互参考,同时明确 unreal_tran 已废弃。本工作区没有 unreal_tran_demo,不能把现存旧目录默认为最新产品定稿。本次以正式代码中的已移植原型、适配层、当前路由和 API wiki 的已确认决策追溯。
+
+
+
+实际来源
+证据与解释
+
+
+
+
+unreal_tran_web/src/features/guide-legacy/demo-src/
+原型源码的整树副本,不只包含作业指导书。physical-training-runtime.js:152-339,492-501 含电气训练五阶段,参考时长15分钟,阶段分值15/20/30/20/15。与电气脚本的结构相符,证明同事已经实现过相关原型。
+
+
+unreal_tran_web/src/features/virtual-training-legacy/upstream/
+虚拟训练兼容原型副本。adapter/runtime.ts:22-25,97-101 明确本地演示数据不入正式库。其页面成功不能单独证明正式 API 训练闭环。
+
+
+unreal_tran_web/src/router/index.ts:275-326
+本次虚拟训练主入口已改为正式 TeachingTaskCenterView/TeachingRunView/TeachingPreviewView;旧 /api 入口重定向;本地版保留于显式 /legacy。应优先验收正式主入口与旧链接重定向。
+
+
+unreal_tran_web/src/features/physical-legacy/runtime.ts:12-21,64-85
+当前实装宿主固定注入离线回放:45秒左右的视频,零分值,不写正式训练事件、不自动完成步骤。
+
+
+unreal_tran_web/src/views/teaching/LegacyPhysicalTrainingPageView.vue:137-150
+实际传入 legacyPhysicalOfflineReplay.task、formalExam: false、空 edgeUrl,而非将已发布任务完整步骤交给真实训练运行域。
+
+
+unreal_tran_api/wiki/11-内容制作模块.md:35-44
+训练编排默认本地原型工作台;正式可入库发布的接口工作台并存。这是既有产品取舍,不能在本轮顺手合并两个工作台。
+
+
+
+3. 资料、设计、实现和测试映射
+
+
+
+资料目标
+产品/设计依据
+当前实现证据
+审查判断与验收重点
+
+
+
+
+同一任务贯穿多种训练方式(维修设计 P1-2)
+wiki19 第1-5节:公共步骤及场景依赖唯一,各渠道 profile 分开,旧单渠道兼容,任务固定快照
+src/features/training/trainingDocument.ts、TrainingChannelContract.java、TeachingSceneSnapshotService.java
+方向一致。E2E需检查多渠道保存/重开和任务渠道选择;创建任务后修改草稿不能改变已运行快照。不能将“混合现实”直接替换成“对抗”。
+
+
+教员建任务/分组,学员执行(维修设计 P1)
+wiki14 第1、2节;本次角色合同收敛为教员、学员、管理员;内容写入需有效教员身份
+CoreRoleContract.java、ContentAuthorizer.java、src/config/roles.ts、src/stores/auth.ts
+资料不定义管理员可绕过教员身份。按当前权限合同测试教员写入、学员拒绝、管理员纯管理身份不冒充教员;不自行放宽权限。
+
+
+逐步骤计时、错误容错、全程留痕(电气脚本 P1-5)
+wiki21:PENDING/IN_PROGRESS/FAILED/COMPLETED事实;尝试、失败、时间、证据;历史不补假数据
+TeachingStepRunService.java、TeachingAssignmentService.java、src/views/teaching/TeachingRunView.vue
+新事实表支持这一需求。验收应覆盖首次开始、逐步推进、失败/重试、完成后提交、历史空事实纯读取、越权读取。
+
+
+正确操作只推进一次,重试不重复加分/留痕(电气脚本 P2-5的容错目标)
+wiki20:开始/推进/提交/评定四命令;同一意图复用 commandId,同载荷重放,异载荷409
+src/api/teaching.ts:120-148、TeachingCommandReceiptService.java、TeachingController.java
+幂等是实现该目标的技术约束,PDF没有直接规定 commandId。验收需有断网/超时重试证据和服务端事件/事实不重复证据;不能只看按钮禁用。
+
+
+五阶段15分钟、阶段分值15/20/30/20/15(电气脚本 P1-5)
+已移植原型 physical-training-runtime.js:152-339,492-501
+该模板存在,但默认实装宿主用离线45秒回放;本次032是前泵互救的另一份五步任务,见 wiki14:610-617
+模板存在不等于正式任务已接通;前泵五步不等于电气五阶段。测试报告应明确所测任务,避免把不同课目覆盖率合并。
+
+
+AI全程提示、错误预警、成绩与报告(电气脚本 P1、P5)
+wiki14:101-106普通虚拟训练可配置数字教员;正式考核/非虚拟通道限制不同;wiki14:228-229客户端自动结果不是权威成绩
+AI Person绑定、正式考核投影和现有教员评定接口;实装离线候选需教员复核
+不能通过恢复前端本地打分来补“自动评分”。应分别验收普通训练提示和正式考核隔离;是否扩大实装AI、自动结算、复盘范围需产品确认。
+
+
+任务状态和结果可解释(维修设计 P1、电气脚本 P5)
+wiki14 第8节:中文状态、真实成员、考场/对抗监控、结束/归档前置
+TeachingTaskCenterView.vue、TeachingConfrontationMonitorView.vue、TeachingExamMonitorView.vue
+E2E需检查已接收记录详情与进行中观察的不同入口,结束/未开始记录不请求只允许进行中的execution投影。
+
+
+
+4. 需要用户确认的功能差异
+下表列的是资料与当前产品边界的差异,不是已经认定的本轮回归 bug。本附录没有修改业务代码、状态机、种子数据或 DDL。
+
+
+
+编号
+现状与证据
+需要确认的具体选择
+确认前的处理
+
+
+
+
+F1
+电气脚本要求完整训练;现默认实装运行/预览固定离线回放(physical-legacy/runtime.ts:12-21;LegacyPhysicalTrainingPageView.vue:137-150)。原型电气模板已有五阶段。
+此次交付继续保留售前回放,还是另立功能任务将电气模板发布为正式内容、接入真实事件/步骤事实、提交与评定?若接入,需要明确所选训练版本与设备/人工证据来源。
+只验收回放明确标识及现有API合同,不把候选视频当真实训练完成。
+
+
+F2
+维修设计 P1-2的方式为虚拟/混合现实/AI实装;wiki19渠道为虚拟/实装/对抗。现有 WebXR 能力见 wiki14:392-405;设备面板明确健康检查不等于现场硬件已连接。
+混合现实是否作为VIRTUAL渠道下的呈现能力/独立沉浸入口,还是需要新渠道?对抗是否是另一维度的组织形式?
+保留现有渠道,单独列硬件未验收边界,不新增枚举/菜单/表。
+
+
+F3
+电气脚本 P5允许未完成收尾时提交并扣分;wiki21:26要求新运行全部COMPLETED。实现 TeachingAssignmentService.java:734-735 明确拒绝未完成提交。
+是否保持全部完成的强制流程,或新增“提前交卷/放弃并结算”语义?若允许提前结算,需要定义未完成、失败、超时各自的成绩及审计规则。
+按当前设计测试“未完成提交被拒绝”;不伪造完成事实,不放开现有submit。
+
+
+F4
+电气脚本 P5规定自动结算、四档成绩(90/80/70门槛)、全过程复盘与知识拓展;现有正式权威结果依服务端规则/教员评定,当前实装回放明确不自动计分。
+这些是否属于此次交付的必验功能?若纳入,需要确认自动评分规则、等级文案、复盘证据与知识资源来源。
+不把旧原型评分直接恢复为正式成绩;列为后续功能任务,与F1一并评审可减少重复工作。
+
+
+
+5. 主测试报告应保留的证据
+
+真实登录进入正式虚拟入口、任务详情/预览、学员运行;保留带任务标题的截图。
+多渠道编辑保存与重开;确认默认渠道仍在启用集合内,停用渠道配置不会被任务误用。
+首次开始和一次推进前后步骤表/界面;重试同命令不重复创建事件或递增次数;异载荷重用命令被拒绝。
+未完成提交被拒绝,完成全部步骤后提交成功;教员评定可见且刷新一致。
+正式考核不暴露提示、故障答案、评分私有规则与数字教员;他人运行/步骤访问被拒绝。
+实装页截图必须包含“离线识别回放/需教员复核/不自动计分”边界;不能将其写成现场AI或实装闭环验收通过。
+本轮数据库实际采用 033 → 新增035 :035针对当前KingbaseES以真正的UNIQUE约束满足步骤事实表组合外键要求,历史034保留,同步更新全量DDL。执行、重跑、恢复快照和一致性核对见数据库审查 及迁移执行记录 。本附录的资料审查过程只读设计,未直接连接或执行数据库。
+
+浏览器截图、HTTP结果、自动化测试结果由主审查任务补入主报告;本资料审查不宣称任何尚未执行的 E2E 已通过。
+6. 补充素材选择待确认(F5)
+F5来自本轮视频素材合同检查,与上文四项PDF资料差异分开记录。原默认回放视频被替换为约31.974秒的新片,但页面的12条候选及抓拍仍对应原约45秒视频。仓库中的 china-military-repair-workshop-hd-45s-system-pose1.mp4 与原验收素材SHA一致,实测约45.422秒;本轮最小修复已将默认映射恢复到匹配素材,约32秒新片保留。
+需要确认:后续继续采用匹配现有阶段、候选和抓拍的原视频,还是采用新片并重新标定阶段时间、识别候选与抓拍?这不是PDF提出的新要求,也不表示视频回放已成为现场AI或正式实装训练。修复证据、视频就绪验收及最终素材确认项以主报告 和前端审查 为准。
2026-09-05 · 开发环境审查 · 图片可点击查看原尺寸
\ No newline at end of file
diff --git a/reports/review-20260905/reference-review.md b/reports/review-20260905/reference-review.md
new file mode 100644
index 0000000..10d3f3b
--- /dev/null
+++ b/reports/review-20260905/reference-review.md
@@ -0,0 +1,88 @@
+# 资料 → 产品原型 → 设计追溯审查
+
+审查日期:2026-09-05。范围:后端 `3307c23 → b683bb7`,前端 `0aac918 → 1576728`。本附录为资料、原型和静态代码证据;运行测试结果以同目录主报告为准。
+
+## 结论
+
+本次多渠道定义、命令幂等和逐步骤留痕与资料的“同一任务贯穿训练、错误可重试、全过程留痕”方向一致。应按现有正式设计修复可复现缺陷,不把资料中尚未接入的功能直接补成新业务。
+
+有四项需要产品确认:默认实装页面仍是离线识别回放;混合现实与现有三渠道的关系尚无直接映射;“全部步骤完成才可提交”与电气脚本的“未收尾提交后扣分”口径不同;脚本的自动评分、分级和训练后复盘是否应成为此次交付范围。以下均没有作为 bug 自动修改。
+
+## 1. 原始资料与阅读证据
+
+实际工作区为 `E:/work/edit_3d`,用户文本中的 `edit\_3d` 按该目录解释。两份 PDF 都没有可提取的文本层,已用 Poppler 将全部 11 页渲染为 PNG 并逐页查看。页码指 PDF 文件页序。
+
+| 来源 | 页数 | SHA-256 | 本次采用的产品要求 |
+|---|---:|---|---|
+| [维修训练内容设计.pdf](../../../../文档/维修训练内容设计.pdf) | 6 | `8B5B0730D82CB6285A678584869B0B8E26C3C5A76817A7A52ABAD4BEBE46F2E8` | P1-2:虚拟、混合现实、AI 指导实装三种方式;同任务贯穿;教员构建场景/任务、分组、下达,学员训练,数据记录分析。P3-6为具体案例,本次不扩展其中的维修操作内容。 |
+| [电器控制系统故障维修脚本.pdf](../../../../文档/电器控制系统故障维修脚本.pdf) | 5 | `A68BEB52E14FC6A39D6F81064B986DC00A868F695B30E9FBD5A309F30E60FD33` | P1:15分钟、分步计分、容错、操作留痕、AI助教。P2-4:五阶段;P5:计分、成绩等级、重试、复盘及知识拓展。 |
+
+`references/training-design-1.png` 至 `training-design-6.png`、`references/electrical-script-1.png` 至 `electrical-script-5.png` 保存了原始页面渲染。它们是资料截图,不是应用 E2E 截图。维修设计 P6 下半页流程图的扫描原件过淡,不能可靠逐字识别,未据此推导执行规则。
+
+关键页预览:
+
+维修设计 P1:训练方式与全流程
+
+
+
+
+
+电气脚本 P5:评分、重试和复盘
+
+
+
+
+
+## 2. 产品原型的实际来源
+
+`unreal_tran_web/AGENTS.md:7` 指定相邻 `unreal_tran_demo` 为当前交互参考,同时明确 `unreal_tran` 已废弃。本工作区没有 `unreal_tran_demo`,不能把现存旧目录默认为最新产品定稿。本次以正式代码中的已移植原型、适配层、当前路由和 API wiki 的已确认决策追溯。
+
+| 实际来源 | 证据与解释 |
+|---|---|
+| `unreal_tran_web/src/features/guide-legacy/demo-src/` | 原型源码的整树副本,不只包含作业指导书。`physical-training-runtime.js:152-339,492-501` 含电气训练五阶段,参考时长15分钟,阶段分值15/20/30/20/15。与电气脚本的结构相符,证明同事已经实现过相关原型。 |
+| `unreal_tran_web/src/features/virtual-training-legacy/upstream/` | 虚拟训练兼容原型副本。`adapter/runtime.ts:22-25,97-101` 明确本地演示数据不入正式库。其页面成功不能单独证明正式 API 训练闭环。 |
+| `unreal_tran_web/src/router/index.ts:275-326` | 本次虚拟训练主入口已改为正式 `TeachingTaskCenterView/TeachingRunView/TeachingPreviewView`;旧 `/api` 入口重定向;本地版保留于显式 `/legacy`。应优先验收正式主入口与旧链接重定向。 |
+| `unreal_tran_web/src/features/physical-legacy/runtime.ts:12-21,64-85` | 当前实装宿主固定注入离线回放:45秒左右的视频,零分值,不写正式训练事件、不自动完成步骤。 |
+| `unreal_tran_web/src/views/teaching/LegacyPhysicalTrainingPageView.vue:137-150` | 实际传入 `legacyPhysicalOfflineReplay.task`、`formalExam: false`、空 `edgeUrl`,而非将已发布任务完整步骤交给真实训练运行域。 |
+| `unreal_tran_api/wiki/11-内容制作模块.md:35-44` | 训练编排默认本地原型工作台;正式可入库发布的接口工作台并存。这是既有产品取舍,不能在本轮顺手合并两个工作台。 |
+
+## 3. 资料、设计、实现和测试映射
+
+| 资料目标 | 产品/设计依据 | 当前实现证据 | 审查判断与验收重点 |
+|---|---|---|---|
+| 同一任务贯穿多种训练方式(维修设计 P1-2) | wiki19 第1-5节:公共步骤及场景依赖唯一,各渠道 profile 分开,旧单渠道兼容,任务固定快照 | `src/features/training/trainingDocument.ts`、`TrainingChannelContract.java`、`TeachingSceneSnapshotService.java` | 方向一致。E2E需检查多渠道保存/重开和任务渠道选择;创建任务后修改草稿不能改变已运行快照。不能将“混合现实”直接替换成“对抗”。 |
+| 教员建任务/分组,学员执行(维修设计 P1) | wiki14 第1、2节;本次角色合同收敛为教员、学员、管理员;内容写入需有效教员身份 | `CoreRoleContract.java`、`ContentAuthorizer.java`、`src/config/roles.ts`、`src/stores/auth.ts` | 资料不定义管理员可绕过教员身份。按当前权限合同测试教员写入、学员拒绝、管理员纯管理身份不冒充教员;不自行放宽权限。 |
+| 逐步骤计时、错误容错、全程留痕(电气脚本 P1-5) | wiki21:PENDING/IN_PROGRESS/FAILED/COMPLETED事实;尝试、失败、时间、证据;历史不补假数据 | `TeachingStepRunService.java`、`TeachingAssignmentService.java`、`src/views/teaching/TeachingRunView.vue` | 新事实表支持这一需求。验收应覆盖首次开始、逐步推进、失败/重试、完成后提交、历史空事实纯读取、越权读取。 |
+| 正确操作只推进一次,重试不重复加分/留痕(电气脚本 P2-5的容错目标) | wiki20:开始/推进/提交/评定四命令;同一意图复用 commandId,同载荷重放,异载荷409 | `src/api/teaching.ts:120-148`、`TeachingCommandReceiptService.java`、`TeachingController.java` | 幂等是实现该目标的技术约束,PDF没有直接规定 commandId。验收需有断网/超时重试证据和服务端事件/事实不重复证据;不能只看按钮禁用。 |
+| 五阶段15分钟、阶段分值15/20/30/20/15(电气脚本 P1-5) | 已移植原型 `physical-training-runtime.js:152-339,492-501` | 该模板存在,但默认实装宿主用离线45秒回放;本次032是前泵互救的另一份五步任务,见 wiki14:610-617 | 模板存在不等于正式任务已接通;前泵五步不等于电气五阶段。测试报告应明确所测任务,避免把不同课目覆盖率合并。 |
+| AI全程提示、错误预警、成绩与报告(电气脚本 P1、P5) | wiki14:101-106普通虚拟训练可配置数字教员;正式考核/非虚拟通道限制不同;wiki14:228-229客户端自动结果不是权威成绩 | AI Person绑定、正式考核投影和现有教员评定接口;实装离线候选需教员复核 | 不能通过恢复前端本地打分来补“自动评分”。应分别验收普通训练提示和正式考核隔离;是否扩大实装AI、自动结算、复盘范围需产品确认。 |
+| 任务状态和结果可解释(维修设计 P1、电气脚本 P5) | wiki14 第8节:中文状态、真实成员、考场/对抗监控、结束/归档前置 | `TeachingTaskCenterView.vue`、`TeachingConfrontationMonitorView.vue`、`TeachingExamMonitorView.vue` | E2E需检查已接收记录详情与进行中观察的不同入口,结束/未开始记录不请求只允许进行中的execution投影。 |
+
+## 4. 需要用户确认的功能差异
+
+下表列的是资料与当前产品边界的差异,不是已经认定的本轮回归 bug。本附录没有修改业务代码、状态机、种子数据或 DDL。
+
+| 编号 | 现状与证据 | 需要确认的具体选择 | 确认前的处理 |
+|---|---|---|---|
+| F1 | 电气脚本要求完整训练;现默认实装运行/预览固定离线回放(`physical-legacy/runtime.ts:12-21`;`LegacyPhysicalTrainingPageView.vue:137-150`)。原型电气模板已有五阶段。 | 此次交付继续保留售前回放,还是另立功能任务将电气模板发布为正式内容、接入真实事件/步骤事实、提交与评定?若接入,需要明确所选训练版本与设备/人工证据来源。 | 只验收回放明确标识及现有API合同,不把候选视频当真实训练完成。 |
+| F2 | 维修设计 P1-2的方式为虚拟/混合现实/AI实装;wiki19渠道为虚拟/实装/对抗。现有 WebXR 能力见 wiki14:392-405;设备面板明确健康检查不等于现场硬件已连接。 | 混合现实是否作为VIRTUAL渠道下的呈现能力/独立沉浸入口,还是需要新渠道?对抗是否是另一维度的组织形式? | 保留现有渠道,单独列硬件未验收边界,不新增枚举/菜单/表。 |
+| F3 | 电气脚本 P5允许未完成收尾时提交并扣分;wiki21:26要求新运行全部COMPLETED。实现 `TeachingAssignmentService.java:734-735` 明确拒绝未完成提交。 | 是否保持全部完成的强制流程,或新增“提前交卷/放弃并结算”语义?若允许提前结算,需要定义未完成、失败、超时各自的成绩及审计规则。 | 按当前设计测试“未完成提交被拒绝”;不伪造完成事实,不放开现有submit。 |
+| F4 | 电气脚本 P5规定自动结算、四档成绩(90/80/70门槛)、全过程复盘与知识拓展;现有正式权威结果依服务端规则/教员评定,当前实装回放明确不自动计分。 | 这些是否属于此次交付的必验功能?若纳入,需要确认自动评分规则、等级文案、复盘证据与知识资源来源。 | 不把旧原型评分直接恢复为正式成绩;列为后续功能任务,与F1一并评审可减少重复工作。 |
+
+## 5. 主测试报告应保留的证据
+
+1. 真实登录进入正式虚拟入口、任务详情/预览、学员运行;保留带任务标题的截图。
+2. 多渠道编辑保存与重开;确认默认渠道仍在启用集合内,停用渠道配置不会被任务误用。
+3. 首次开始和一次推进前后步骤表/界面;重试同命令不重复创建事件或递增次数;异载荷重用命令被拒绝。
+4. 未完成提交被拒绝,完成全部步骤后提交成功;教员评定可见且刷新一致。
+5. 正式考核不暴露提示、故障答案、评分私有规则与数字教员;他人运行/步骤访问被拒绝。
+6. 实装页截图必须包含“离线识别回放/需教员复核/不自动计分”边界;不能将其写成现场AI或实装闭环验收通过。
+7. 本轮数据库实际采用 **033 → 新增035**:035针对当前KingbaseES以真正的UNIQUE约束满足步骤事实表组合外键要求,历史034保留,同步更新全量DDL。执行、重跑、恢复快照和一致性核对见[数据库审查](database-review.md)及[迁移执行记录](database-migration.json)。本附录的资料审查过程只读设计,未直接连接或执行数据库。
+
+浏览器截图、HTTP结果、自动化测试结果由主审查任务补入主报告;本资料审查不宣称任何尚未执行的 E2E 已通过。
+
+## 6. 补充素材选择待确认(F5)
+
+F5来自本轮视频素材合同检查,与上文四项PDF资料差异分开记录。原默认回放视频被替换为约31.974秒的新片,但页面的12条候选及抓拍仍对应原约45秒视频。仓库中的 `china-military-repair-workshop-hd-45s-system-pose1.mp4` 与原验收素材SHA一致,实测约45.422秒;本轮最小修复已将默认映射恢复到匹配素材,约32秒新片保留。
+
+需要确认:后续继续采用匹配现有阶段、候选和抓拍的原视频,还是采用新片并重新标定阶段时间、识别候选与抓拍?这不是PDF提出的新要求,也不表示视频回放已成为现场AI或正式实装训练。修复证据、视频就绪验收及最终素材确认项以[主报告](review-report.md)和[前端审查](frontend-review.md)为准。
diff --git a/reports/review-20260905/references/electrical-script-1.png b/reports/review-20260905/references/electrical-script-1.png
new file mode 100644
index 0000000..1edad51
Binary files /dev/null and b/reports/review-20260905/references/electrical-script-1.png differ
diff --git a/reports/review-20260905/references/electrical-script-2.png b/reports/review-20260905/references/electrical-script-2.png
new file mode 100644
index 0000000..039cc7a
Binary files /dev/null and b/reports/review-20260905/references/electrical-script-2.png differ
diff --git a/reports/review-20260905/references/electrical-script-3.png b/reports/review-20260905/references/electrical-script-3.png
new file mode 100644
index 0000000..541d2fc
Binary files /dev/null and b/reports/review-20260905/references/electrical-script-3.png differ
diff --git a/reports/review-20260905/references/electrical-script-4.png b/reports/review-20260905/references/electrical-script-4.png
new file mode 100644
index 0000000..437f75c
Binary files /dev/null and b/reports/review-20260905/references/electrical-script-4.png differ
diff --git a/reports/review-20260905/references/electrical-script-5.png b/reports/review-20260905/references/electrical-script-5.png
new file mode 100644
index 0000000..3f813aa
Binary files /dev/null and b/reports/review-20260905/references/electrical-script-5.png differ
diff --git a/reports/review-20260905/references/training-design-1.png b/reports/review-20260905/references/training-design-1.png
new file mode 100644
index 0000000..86dd862
Binary files /dev/null and b/reports/review-20260905/references/training-design-1.png differ
diff --git a/reports/review-20260905/references/training-design-2.png b/reports/review-20260905/references/training-design-2.png
new file mode 100644
index 0000000..72f3389
Binary files /dev/null and b/reports/review-20260905/references/training-design-2.png differ
diff --git a/reports/review-20260905/references/training-design-3.png b/reports/review-20260905/references/training-design-3.png
new file mode 100644
index 0000000..4e694b1
Binary files /dev/null and b/reports/review-20260905/references/training-design-3.png differ
diff --git a/reports/review-20260905/references/training-design-4.png b/reports/review-20260905/references/training-design-4.png
new file mode 100644
index 0000000..f61986c
Binary files /dev/null and b/reports/review-20260905/references/training-design-4.png differ
diff --git a/reports/review-20260905/references/training-design-5.png b/reports/review-20260905/references/training-design-5.png
new file mode 100644
index 0000000..1c93104
Binary files /dev/null and b/reports/review-20260905/references/training-design-5.png differ
diff --git a/reports/review-20260905/references/training-design-6.png b/reports/review-20260905/references/training-design-6.png
new file mode 100644
index 0000000..250a137
Binary files /dev/null and b/reports/review-20260905/references/training-design-6.png differ
diff --git a/reports/review-20260905/report-qa/01-report-overview.png b/reports/review-20260905/report-qa/01-report-overview.png
new file mode 100644
index 0000000..d7af8f8
Binary files /dev/null and b/reports/review-20260905/report-qa/01-report-overview.png differ
diff --git a/reports/review-20260905/report-qa/02-screenshot-gallery.png b/reports/review-20260905/report-qa/02-screenshot-gallery.png
new file mode 100644
index 0000000..f47c293
Binary files /dev/null and b/reports/review-20260905/report-qa/02-screenshot-gallery.png differ
diff --git a/reports/review-20260905/report-qa/03-confirmation-items.png b/reports/review-20260905/report-qa/03-confirmation-items.png
new file mode 100644
index 0000000..9e2bf7b
Binary files /dev/null and b/reports/review-20260905/report-qa/03-confirmation-items.png differ
diff --git a/reports/review-20260905/report-qa/verification.json b/reports/review-20260905/report-qa/verification.json
new file mode 100644
index 0000000..0a3442c
--- /dev/null
+++ b/reports/review-20260905/report-qa/verification.json
@@ -0,0 +1,6 @@
+{
+ "imageCount": 27,
+ "localLinksChecked": 55,
+ "documentWidth": 1440,
+ "viewportWidth": 1440
+}
\ No newline at end of file
diff --git a/reports/review-20260905/review-report.html b/reports/review-20260905/review-report.html
new file mode 100644
index 0000000..6d7771d
--- /dev/null
+++ b/reports/review-20260905/review-report.html
@@ -0,0 +1,295 @@
+review-report · Code Review 更新项目 Code Review 与 E2E 报告
+审查日期:2026-09-05 · 开发环境:E:/work/edit_3d · 状态:审查与验证完成,4 项功能差异和 1 项素材选择待确认
+本轮按 资料 → 产品原型 → 设计 → 开发 → 测试 审查更新,定位并修复 8 处问题:前端 6 处、后端 1 处、数据库迁移 1 处。修复沿用现有产品和接口合同。另有 4 项资料与现有功能的差异,以及 1 项素材选择,列在本报告末尾待确认。
+前端与三个后端服务已启动。页面地址:本机 6180 ,局域网 6180 。审查完成后已按用户授权提交:API 53d860b、Web 3c7f295。报告与测试脚本在 ute2e 独立提交。
+1. 更新了什么
+
+
+
+仓库与范围
+同事本次更新的主要内容
+本次重点
+
+
+
+
+unreal_tran_api:3307c23..b683bb7,57 个文件
+多渠道训练定义;开始/推进/提交/评定命令幂等;步骤运行事实;三个登录身份与教员内容写权限;029–034 SQL
+发布快照、权限、重复命令、失败重试、提交门槛、数据库兼容性
+
+
+unreal_tran_web:0aac918..1576728,29 个文件
+虚拟训练切到正式 API 主入口;旧链接重定向;渠道编辑;步骤状态展示;命令追踪;角色、Three 资源及回放内容调整
+默认入口可用性、编辑保存一致性、步骤顺序、运行页和重试入口
+
+
+
+2. 从资料到测试的追溯
+已逐页查看《维修训练内容设计.pdf》6 页和《电器控制系统故障维修脚本.pdf》5 页,并保留 11 页资料图像 。这些文件没有可提取文本层;维修设计第 6 页下半流程图扫描过淡,未据此臆测执行规则。
+AGENTS.md 指定的相邻 unreal_tran_demo 当前缺失。本次结合正式前端中移植的原型源码、适配层和 API wiki 追溯,未把废弃原型目录当作最新定稿。完整依据见 资料与原型审查 。
+
+
+
+资料要求
+原型与设计
+开发检查
+测试证据
+
+
+
+
+同一任务贯穿不同训练方式
+移植训练原型;wiki 19:公共步骤与渠道 profile、固定发布快照
+渠道配置读写与默认配置镜像;切换默认渠道;保留其他渠道
+浏览器模拟接口回归验证保存载荷与重新打开;Node 执行渠道转换逻辑
+
+
+教员编排、学员训练、权限分工
+现有角色合同与 wiki 14
+三种登录角色;内容写入需有效教员身份
+真实登录与教员列表;纯管理员创建内容返回 403;后端权限回归
+
+
+逐步骤留痕、失败可重试
+电气脚本五阶段;wiki 21 的四种步骤状态
+顺序/进度;开始、失败、重试、完成;迟到证据
+真实 API/数据库步骤事实、迟到事件 400;浏览器重试按钮与状态变化
+
+
+操作不重复记账
+wiki 20 的命令号与冻结载荷
+同载荷重放、异载荷冲突、运行版本与事务边界
+真实 start/progress/review 重放不增加版本,异载荷 409;服务测试
+
+
+完成后提交、结果可查询
+当前 wiki 21 要求全步骤完成;资料的提前交卷另列确认
+提交门槛、评定、刷新后的服务端结果
+真实未完成提交 409;8 步完成后 UI 提交;评定后刷新
+
+
+训练页面可操作、状态清晰
+正式 API 主入口与已有工作台设计
+错误考核遮罩、场景尺寸、安全提示对比度
+真实场景就绪截图;军工绿/暗色主题尺寸与文字对比测量
+
+
+
+3. 已修复的 8 处问题
+P1 表示阻断目标环境部署;P2 表示功能、状态或界面正确性问题。下列均已修改代码或 DDL,详细复现、修改文件与验证见 前端审查 、后端审查 和 数据库审查 。
+
+
+
+编号
+级别
+触发与原行为
+修复后的行为
+
+
+
+
+DB-01
+P1
+KingbaseES 当前版本不接受 034 中独立 UNIQUE INDEX 作为组合外键目标,步骤事实表迁移失败
+新增 035,使用真正 UNIQUE 约束;同步全量 DDL,保留历史 034;开发库执行 033 → 035
+
+
+API-01
+P2
+步骤失败后重试,收到重试开始前的迟到成功/失败事件,可能错误完成当前尝试或增加失败次数
+从已有激活事件校验本次尝试开始时间;旧事件返回 400,不改变当前事实
+
+
+WEB-01
+P2
+实装步骤移动、删除或复制后仍保留旧顺序和进度,如 3,2,1 / 100,67,33,发布可能被拒
+按编辑后的顺序重算顺序号(order)和严格递增进度,末步 100%;保留步骤标识和事件扩展字段
+
+
+WEB-02
+P2
+通用编辑器只修改旧 modeConfig,重开被旧渠道 profile 覆盖;切默认渠道字段不一致
+同步默认渠道、启用渠道、profile 和兼容镜像;保留其他渠道与历史对抗时长
+
+
+WEB-03
+P2
+v-else 绑定到拆卸侧栏条件,普通训练也显示“正式考核执行场景已关闭”
+仅正式考核且不在执行中显示关闭态
+
+
+WEB-04
+P2
+当前步骤 FAILED,但运行仍 IN_PROGRESS,学员没有重试入口
+补“重试当前步骤”,复用已有 start 和幂等命令;结束状态不出现该按钮
+
+
+WEB-05
+P2
+场景仅占视口上方,安全确认区暗底深字
+补运行场景填充定位和已有主题颜色、间距;安全确认规则保持原有语义
+
+
+WEB-06
+P2
+默认离线视频替换为约 32 秒的新素材,但候选、阶段与抓拍仍对应 45 秒旧片;34 秒和 45.2 秒候选无法到达
+将默认映射指向仓库保留、哈希与既有验收合同一致的 45 秒视频;新素材保留,重标与切换列 F5 确认
+
+
+
+4. 测试结果与边界
+
+
+
+验证
+最终结果
+证据与范围
+
+
+
+
+后端全量测试
+402/402 通过,0 失败、错误、跳过
+Gateway 1、Auth 86、Tran 315;含新增迟到事件回归
+
+
+后端构建
+通过
+Java 17 / Maven;三个服务打包完成
+
+
+前端 Node 回归
+25/25 通过
+原 24 项加视频资产、候选与抓拍合同
+
+
+前端构建
+通过
+vue-tsc、Vite、旧模型 iframe 校验
+
+
+真实服务 E2E(Edge)
+4/4 通过,0 失败/跳过/重试
+62.3 秒;开始时间 2026-09-05T03:47:45.212Z(UTC)
+
+
+浏览器模拟接口回归
+15/15 通过,0 失败/跳过/重试
+117.7 秒;编辑保存、状态组合、军工绿/暗色主题
+
+
+数据库迁移
+通过
+033、035;首次、重跑和指定中断点恢复;开发库检查值均为 0
+
+
+测试脚本类型检查、Git 空白检查
+通过
+两份专用 Playwright 测试与配置;API/Web/E2E 仓库
+
+
+
+最终通过结果来自各自一次完整运行,未启用自动重试。补入三次完整场景加载与截图后,真实虚拟用例曾超过原 120 秒测试总预算,断言和评定已完成但截图收尾超时;保留 该轮记录 ,随后将该用例预算调到 240 秒,并在其他浏览器回归结束后重新完整运行。单次资源就绪仍有 60 秒上限。
+实装视频新增检查还发现本机 Playwright 自带 Chromium 不支持 H.264(canPlayType 返回空字符串);失败轮次 与Edge 播放探针 保留。最终真实 E2E 使用已安装 Microsoft Edge,模拟接口回归继续使用 Chromium。另独立修复了实际素材与候选配置不匹配的 WEB-06;最终用例验证默认视频可播、片长覆盖末尾候选,并在结尾得到 12 条候选。测试开发阶段的权限夹具、请求拦截范围与 UI 选择器错误已修正,不计为产品缺陷。
+真实 E2E 使用本机正在运行的 Web、Gateway、Auth、Tran 与开发数据库,没有拦截 /api。通过专用临时账号和临时任务验证,避免改动现有训练记录。
+浏览器回归使用真实前端组件与 Playwright,但对 API 返回进行模拟,以稳定重现排序、多渠道保存、遮罩及失败重试的状态组合。它能验证页面与请求载荷,不能作为后端持久化已通过的证据。
+真实虚拟训练用例通过 UI 开始、UI 提交,通过真实 API 逐步推进 8 个步骤并验证服务端事实和重放。它验证了页面、接口与数据库的联通,没有逐个通过三维鼠标操作完成训练动作 。三维资源加载与场景尺寸单独等待和检查。
+实装测试验证的是失败 → 重试 → 拒绝旧证据 → 接受新证据的真实 API/数据库流程。现有实装样例的源项目已回到 DRAFT,因此复制其历史发布快照为隔离测试任务并配置人工事件;未更改原项目。默认实装运行仍是离线回放,现场 AI、硬件、MR、完整电气训练和自动评分不属于本轮通过项。真实并发压力与故障注入恢复未作为完整系统验收执行。
+5. 截图证据
+以下截图来自最终通过轮次。点击图像可查看原尺寸。模拟接口截图的图注明确标注,资料扫描不计入 E2E 截图。
+共 27 张最终 E2E 截图。
+真实服务与页面
+
+
真实服务:登录仅展示教员、学员、管理员
+
真实服务:正式虚拟任务中心
+
真实服务:1920 宽度任务中心
+
真实服务:教员训练编排列表已加载
+
真实服务:开始训练、模型就绪,无错误考核遮罩
+
真实服务:8/8 步骤完成,真实模型已加载
+
真实服务:提交评定后刷新
+
真实服务:实装任务清单
+
真实页面:离线视频就绪,并标明不推进正式进度、不计分
+
真实页面:匹配视频到达末尾,12 条离线候选可达、等待复核
+
+模拟接口浏览器回归
+
+
模拟接口:删除步骤后末步进度 100%
+
模拟接口:复制步骤后重新编号
+
模拟接口:移动已保存步骤后保持正确顺序
+
模拟接口:虚拟渠道配置保存与重开
+
模拟接口:对抗渠道配置保存与重开
+
模拟接口:关闭遮罩状态矩阵 1-VIRTUAL-IN_PROGRESS
+
模拟接口:关闭遮罩状态矩阵 2-PHYSICAL-IN_PROGRESS
+
模拟接口:关闭遮罩状态矩阵 3-CONFRONTATION-IN_PROGRESS
+
模拟接口:关闭遮罩状态矩阵 4-VIRTUAL-IN_PROGRESS
+
模拟接口:关闭遮罩状态矩阵 5-VIRTUAL-ACCEPTED
+
模拟接口:关闭遮罩状态矩阵 6-VIRTUAL-SUBMITTED
+
模拟接口:失败步骤出现重试入口
+
模拟接口:重试后尝试 2 次、失败 1 次
+
模拟接口:已提交不出现重试按钮
+
模拟接口:已终止不出现重试按钮
+
模拟接口:暗色主题安全区,对比度 15.30
+
模拟接口:军工绿主题安全区,对比度 12.00
+
+
+6. 数据库、运行环境与清理
+目标数据库为开发环境 unreal_tran/public,KingbaseES V008R006C009B0014。迁移前保存四张教学主表原始行、字段和索引信息,恢复快照位于 E:/work/edit_3d/.codex-tmp/review-20260905/database-before.json;该文件含业务数据,未打进报告包。
+实际执行 033 与新增 035。029–032 未在本次审查中重新执行,未注入新的前泵课目。035 与全量 DDL 的步骤表、父表约束保持一致;验证了隔离 schema 首次执行、重复执行、中途提交后续跑,所有无效行/孤儿行检查为 0。隔离 schema 已删除。详见 迁移执行记录 。其他环境部署应先核对现状,按文档执行 033 → 035 ,迁移工具需支持 dollar quote。
+最终清理时间:2026-09-05T11:48:55.347416。删除本轮临时任务及关联记录(step_run 12, command_receipt 13, event 19, run_member 2, run 2, member 2, assignment 2),测试账号已停用/软删除,会话已撤销,临时管理员密码文件已移除。四张原始教学表的既有行逐行对照均未变化,剩余活动测试账号为 0。审计与软删除记录按系统机制保留。见 最终清理记录 ;较早轮次见 diagnostics/cleanup-*.json。
+环境检查记录见 environment.json 。三个服务健康接口及本机、局域网前端访问返回 200。Nacos 与 Redis 保留运行。
+构建中的既有资源 URL、包体积和部分 Java 弃用警告未阻断打包;未因此升级依赖版本。报告不包含密码或会话令牌。
+7. 待你确认的功能差异与素材选择
+这些涉及产品口径或新增接入,未作为本轮 bugfix 擅自扩展。建议逐项确认后安排对应实现与验收。
+
+
+
+编号
+现状与资料差异
+需要确认的决定
+
+
+
+
+F1:实装正式训练
+电气资料及移植原型为五阶段、15 分钟;默认实装页面当前是约 45 秒离线识别回放,未接入正式步骤与计分
+此次交付继续保留回放,还是把电气模板发布为正式内容、接入真实事件/人工证据、提交与评定?
+
+
+F2:混合现实归属
+资料列虚拟、混合现实、AI 实装;当前渠道为 VIRTUAL、PHYSICAL、CONFRONTATION
+MR 作为虚拟渠道的呈现能力/沉浸入口,还是新增渠道?对抗是否属于另一维度的组织形式?
+
+
+F3:提前提交规则
+资料允许未收尾提交并扣分;当前新运行要求全部步骤 COMPLETED 才能提交
+保留全部完成门槛,还是新增提前交卷/放弃结算及未完成、失败、超时计分规则?
+
+
+F4:评分与复盘范围
+资料包括自动结算、90/80/70 分档、全过程复盘和知识拓展;当前以服务端规则/教员评定为权威
+这些是否纳入此次交付?若纳入,需要确认评分规则、等级文案、复盘证据及知识资源来源。
+
+
+F5:离线回放素材
+新增约 32 秒素材与现有 12 条候选/抓拍不匹配;本轮默认恢复指向已验收的约 45 秒素材
+继续使用当前匹配素材,还是采用新片?采用新片需要先重新标注候选、阶段时间与证据抓拍,再整体切换。
+
+
+
+各项 PDF 页码、原型和代码依据见 完整差异说明 。
+8. 报告文件与复跑
+
+测试源码位于 ute2e/tests/review-live.spec.ts 和 tests/review-frontend-regressions.spec.ts,配置为 review-live.config.ts / review-frontend.config.ts。回归脚本读取本地配置,不内嵌密码。
+已具备开发库表结构时,可在 ute2e 顺序运行:
+python tools/review-db.py test-admin
+pnpm exec playwright test --config review-live.config.ts
+python tools/review-db.py cleanup
+pnpm exec playwright test --config review-frontend.config.ts
+
+真实测试后即使失败也需执行 cleanup。当前开发库已完成迁移,prepare 会因目标表存在而拒绝重跑。报告 JSON 保留时间、断言和最终结果,可据此核验,测试计数不等同于完整功能覆盖率。
2026-09-05 · 开发环境审查 · 图片可点击查看原尺寸
\ No newline at end of file
diff --git a/reports/review-20260905/review-report.md b/reports/review-20260905/review-report.md
new file mode 100644
index 0000000..0a3b421
--- /dev/null
+++ b/reports/review-20260905/review-report.md
@@ -0,0 +1,154 @@
+# 更新项目 Code Review 与 E2E 报告
+
+审查日期:2026-09-05 · 开发环境:`E:/work/edit_3d` · 状态:审查与验证完成,4 项功能差异和 1 项素材选择待确认
+
+本轮按 **资料 → 产品原型 → 设计 → 开发 → 测试** 审查更新,定位并修复 8 处问题:前端 6 处、后端 1 处、数据库迁移 1 处。修复沿用现有产品和接口合同。另有 4 项资料与现有功能的差异,以及 1 项素材选择,列在本报告末尾待确认。
+
+前端与三个后端服务已启动。页面地址:[本机 6180](http://127.0.0.1:6180/),[局域网 6180](http://192.168.31.168:6180/)。审查完成后已按用户授权提交:API `53d860b`、Web `3c7f295`。报告与测试脚本在 `ute2e` 独立提交。
+
+## 1. 更新了什么
+
+| 仓库与范围 | 同事本次更新的主要内容 | 本次重点 |
+|---|---|---|
+| `unreal_tran_api`:`3307c23..b683bb7`,57 个文件 | 多渠道训练定义;开始/推进/提交/评定命令幂等;步骤运行事实;三个登录身份与教员内容写权限;029–034 SQL | 发布快照、权限、重复命令、失败重试、提交门槛、数据库兼容性 |
+| `unreal_tran_web`:`0aac918..1576728`,29 个文件 | 虚拟训练切到正式 API 主入口;旧链接重定向;渠道编辑;步骤状态展示;命令追踪;角色、Three 资源及回放内容调整 | 默认入口可用性、编辑保存一致性、步骤顺序、运行页和重试入口 |
+
+## 2. 从资料到测试的追溯
+
+已逐页查看《维修训练内容设计.pdf》6 页和《电器控制系统故障维修脚本.pdf》5 页,并保留 [11 页资料图像](reference-review.html#1-原始资料与阅读证据)。这些文件没有可提取文本层;维修设计第 6 页下半流程图扫描过淡,未据此臆测执行规则。
+
+`AGENTS.md` 指定的相邻 `unreal_tran_demo` 当前缺失。本次结合正式前端中移植的原型源码、适配层和 API wiki 追溯,未把废弃原型目录当作最新定稿。完整依据见 [资料与原型审查](reference-review.html)。
+
+| 资料要求 | 原型与设计 | 开发检查 | 测试证据 |
+|---|---|---|---|
+| 同一任务贯穿不同训练方式 | 移植训练原型;wiki 19:公共步骤与渠道 profile、固定发布快照 | 渠道配置读写与默认配置镜像;切换默认渠道;保留其他渠道 | 浏览器模拟接口回归验证保存载荷与重新打开;Node 执行渠道转换逻辑 |
+| 教员编排、学员训练、权限分工 | 现有角色合同与 wiki 14 | 三种登录角色;内容写入需有效教员身份 | 真实登录与教员列表;纯管理员创建内容返回 403;后端权限回归 |
+| 逐步骤留痕、失败可重试 | 电气脚本五阶段;wiki 21 的四种步骤状态 | 顺序/进度;开始、失败、重试、完成;迟到证据 | 真实 API/数据库步骤事实、迟到事件 400;浏览器重试按钮与状态变化 |
+| 操作不重复记账 | wiki 20 的命令号与冻结载荷 | 同载荷重放、异载荷冲突、运行版本与事务边界 | 真实 start/progress/review 重放不增加版本,异载荷 409;服务测试 |
+| 完成后提交、结果可查询 | 当前 wiki 21 要求全步骤完成;资料的提前交卷另列确认 | 提交门槛、评定、刷新后的服务端结果 | 真实未完成提交 409;8 步完成后 UI 提交;评定后刷新 |
+| 训练页面可操作、状态清晰 | 正式 API 主入口与已有工作台设计 | 错误考核遮罩、场景尺寸、安全提示对比度 | 真实场景就绪截图;军工绿/暗色主题尺寸与文字对比测量 |
+
+## 3. 已修复的 8 处问题
+
+P1 表示阻断目标环境部署;P2 表示功能、状态或界面正确性问题。下列均已修改代码或 DDL,详细复现、修改文件与验证见 [前端审查](frontend-review.html)、[后端审查](backend-review.html) 和 [数据库审查](database-review.html)。
+
+| 编号 | 级别 | 触发与原行为 | 修复后的行为 |
+|---|---|---|---|
+| DB-01 | P1 | KingbaseES 当前版本不接受 034 中独立 UNIQUE INDEX 作为组合外键目标,步骤事实表迁移失败 | 新增 035,使用真正 UNIQUE 约束;同步全量 DDL,保留历史 034;开发库执行 033 → 035 |
+| API-01 | P2 | 步骤失败后重试,收到重试开始前的迟到成功/失败事件,可能错误完成当前尝试或增加失败次数 | 从已有激活事件校验本次尝试开始时间;旧事件返回 400,不改变当前事实 |
+| WEB-01 | P2 | 实装步骤移动、删除或复制后仍保留旧顺序和进度,如 `3,2,1` / `100,67,33`,发布可能被拒 | 按编辑后的顺序重算顺序号(order)和严格递增进度,末步 100%;保留步骤标识和事件扩展字段 |
+| WEB-02 | P2 | 通用编辑器只修改旧 `modeConfig`,重开被旧渠道 profile 覆盖;切默认渠道字段不一致 | 同步默认渠道、启用渠道、profile 和兼容镜像;保留其他渠道与历史对抗时长 |
+| WEB-03 | P2 | `v-else` 绑定到拆卸侧栏条件,普通训练也显示“正式考核执行场景已关闭” | 仅正式考核且不在执行中显示关闭态 |
+| WEB-04 | P2 | 当前步骤 FAILED,但运行仍 IN_PROGRESS,学员没有重试入口 | 补“重试当前步骤”,复用已有 start 和幂等命令;结束状态不出现该按钮 |
+| WEB-05 | P2 | 场景仅占视口上方,安全确认区暗底深字 | 补运行场景填充定位和已有主题颜色、间距;安全确认规则保持原有语义 |
+| WEB-06 | P2 | 默认离线视频替换为约 32 秒的新素材,但候选、阶段与抓拍仍对应 45 秒旧片;34 秒和 45.2 秒候选无法到达 | 将默认映射指向仓库保留、哈希与既有验收合同一致的 45 秒视频;新素材保留,重标与切换列 F5 确认 |
+
+## 4. 测试结果与边界
+
+| 验证 | 最终结果 | 证据与范围 |
+|---|---|---|
+| 后端全量测试 | 402/402 通过,0 失败、错误、跳过 | Gateway 1、Auth 86、Tran 315;含新增迟到事件回归 |
+| 后端构建 | 通过 | Java 17 / Maven;三个服务打包完成 |
+| 前端 Node 回归 | 25/25 通过 | 原 24 项加视频资产、候选与抓拍合同 |
+| 前端构建 | 通过 | vue-tsc、Vite、旧模型 iframe 校验 |
+| 真实服务 E2E(Edge) | 4/4 通过,0 失败/跳过/重试 | 62.3 秒;开始时间 2026-09-05T03:47:45.212Z(UTC) |
+| 浏览器模拟接口回归 | 15/15 通过,0 失败/跳过/重试 | 117.7 秒;编辑保存、状态组合、军工绿/暗色主题 |
+| 数据库迁移 | 通过 | 033、035;首次、重跑和指定中断点恢复;开发库检查值均为 0 |
+| 测试脚本类型检查、Git 空白检查 | 通过 | 两份专用 Playwright 测试与配置;API/Web/E2E 仓库 |
+
+最终通过结果来自各自一次完整运行,未启用自动重试。补入三次完整场景加载与截图后,真实虚拟用例曾超过原 120 秒测试总预算,断言和评定已完成但截图收尾超时;保留 [该轮记录](diagnostics/live-resource-wait-timeout.json),随后将该用例预算调到 240 秒,并在其他浏览器回归结束后重新完整运行。单次资源就绪仍有 60 秒上限。
+
+实装视频新增检查还发现本机 Playwright 自带 Chromium 不支持 H.264(`canPlayType` 返回空字符串);[失败轮次](diagnostics/live-chromium-h264-unsupported.json)与[Edge 播放探针](diagnostics/video-probe-msedge.json)保留。最终真实 E2E 使用已安装 Microsoft Edge,模拟接口回归继续使用 Chromium。另独立修复了实际素材与候选配置不匹配的 WEB-06;最终用例验证默认视频可播、片长覆盖末尾候选,并在结尾得到 12 条候选。测试开发阶段的权限夹具、请求拦截范围与 UI 选择器错误已修正,不计为产品缺陷。
+
+真实 E2E 使用本机正在运行的 Web、Gateway、Auth、Tran 与开发数据库,没有拦截 `/api`。通过专用临时账号和临时任务验证,避免改动现有训练记录。
+
+浏览器回归使用真实前端组件与 Playwright,但对 API 返回进行模拟,以稳定重现排序、多渠道保存、遮罩及失败重试的状态组合。它能验证页面与请求载荷,不能作为后端持久化已通过的证据。
+
+真实虚拟训练用例通过 UI 开始、UI 提交,通过真实 API 逐步推进 8 个步骤并验证服务端事实和重放。它验证了页面、接口与数据库的联通,**没有逐个通过三维鼠标操作完成训练动作**。三维资源加载与场景尺寸单独等待和检查。
+
+实装测试验证的是失败 → 重试 → 拒绝旧证据 → 接受新证据的真实 API/数据库流程。现有实装样例的源项目已回到 DRAFT,因此复制其历史发布快照为隔离测试任务并配置人工事件;未更改原项目。默认实装运行仍是离线回放,现场 AI、硬件、MR、完整电气训练和自动评分不属于本轮通过项。真实并发压力与故障注入恢复未作为完整系统验收执行。
+
+## 5. 截图证据
+
+以下截图来自最终通过轮次。点击图像可查看原尺寸。模拟接口截图的图注明确标注,资料扫描不计入 E2E 截图。
+
+共 27 张最终 E2E 截图。
+
+真实服务与页面
+
真实服务:登录仅展示教员、学员、管理员
+
真实服务:正式虚拟任务中心
+
真实服务:1920 宽度任务中心
+
真实服务:教员训练编排列表已加载
+
真实服务:开始训练、模型就绪,无错误考核遮罩
+
真实服务:8/8 步骤完成,真实模型已加载
+
真实服务:提交评定后刷新
+
真实服务:实装任务清单
+
真实页面:离线视频就绪,并标明不推进正式进度、不计分
+
真实页面:匹配视频到达末尾,12 条离线候选可达、等待复核
+
+模拟接口浏览器回归
+
模拟接口:删除步骤后末步进度 100%
+
模拟接口:复制步骤后重新编号
+
模拟接口:移动已保存步骤后保持正确顺序
+
模拟接口:虚拟渠道配置保存与重开
+
模拟接口:对抗渠道配置保存与重开
+
模拟接口:关闭遮罩状态矩阵 1-VIRTUAL-IN_PROGRESS
+
模拟接口:关闭遮罩状态矩阵 2-PHYSICAL-IN_PROGRESS
+
模拟接口:关闭遮罩状态矩阵 3-CONFRONTATION-IN_PROGRESS
+
模拟接口:关闭遮罩状态矩阵 4-VIRTUAL-IN_PROGRESS
+
模拟接口:关闭遮罩状态矩阵 5-VIRTUAL-ACCEPTED
+
模拟接口:关闭遮罩状态矩阵 6-VIRTUAL-SUBMITTED
+
模拟接口:失败步骤出现重试入口
+
模拟接口:重试后尝试 2 次、失败 1 次
+
模拟接口:已提交不出现重试按钮
+
模拟接口:已终止不出现重试按钮
+
模拟接口:暗色主题安全区,对比度 15.30
+
模拟接口:军工绿主题安全区,对比度 12.00
+
+
+## 6. 数据库、运行环境与清理
+
+目标数据库为开发环境 `unreal_tran/public`,KingbaseES V008R006C009B0014。迁移前保存四张教学主表原始行、字段和索引信息,恢复快照位于 `E:/work/edit_3d/.codex-tmp/review-20260905/database-before.json`;该文件含业务数据,未打进报告包。
+
+实际执行 033 与新增 035。029–032 未在本次审查中重新执行,未注入新的前泵课目。035 与全量 DDL 的步骤表、父表约束保持一致;验证了隔离 schema 首次执行、重复执行、中途提交后续跑,所有无效行/孤儿行检查为 0。隔离 schema 已删除。详见 [迁移执行记录](database-migration.json)。其他环境部署应先核对现状,按文档执行 **033 → 035**,迁移工具需支持 dollar quote。
+
+最终清理时间:`2026-09-05T11:48:55.347416`。删除本轮临时任务及关联记录(step_run 12, command_receipt 13, event 19, run_member 2, run 2, member 2, assignment 2),测试账号已停用/软删除,会话已撤销,临时管理员密码文件已移除。四张原始教学表的既有行逐行对照均未变化,剩余活动测试账号为 0。审计与软删除记录按系统机制保留。见 [最终清理记录](cleanup.json);较早轮次见 `diagnostics/cleanup-*.json`。
+
+环境检查记录见 [environment.json](environment.json)。三个服务健康接口及本机、局域网前端访问返回 200。Nacos 与 Redis 保留运行。
+
+构建中的既有资源 URL、包体积和部分 Java 弃用警告未阻断打包;未因此升级依赖版本。报告不包含密码或会话令牌。
+
+## 7. 待你确认的功能差异与素材选择
+
+这些涉及产品口径或新增接入,未作为本轮 bugfix 擅自扩展。建议逐项确认后安排对应实现与验收。
+
+| 编号 | 现状与资料差异 | 需要确认的决定 |
+|---|---|---|
+| F1:实装正式训练 | 电气资料及移植原型为五阶段、15 分钟;默认实装页面当前是约 45 秒离线识别回放,未接入正式步骤与计分 | 此次交付继续保留回放,还是把电气模板发布为正式内容、接入真实事件/人工证据、提交与评定? |
+| F2:混合现实归属 | 资料列虚拟、混合现实、AI 实装;当前渠道为 VIRTUAL、PHYSICAL、CONFRONTATION | MR 作为虚拟渠道的呈现能力/沉浸入口,还是新增渠道?对抗是否属于另一维度的组织形式? |
+| F3:提前提交规则 | 资料允许未收尾提交并扣分;当前新运行要求全部步骤 COMPLETED 才能提交 | 保留全部完成门槛,还是新增提前交卷/放弃结算及未完成、失败、超时计分规则? |
+| F4:评分与复盘范围 | 资料包括自动结算、90/80/70 分档、全过程复盘和知识拓展;当前以服务端规则/教员评定为权威 | 这些是否纳入此次交付?若纳入,需要确认评分规则、等级文案、复盘证据及知识资源来源。 |
+| F5:离线回放素材 | 新增约 32 秒素材与现有 12 条候选/抓拍不匹配;本轮默认恢复指向已验收的约 45 秒素材 | 继续使用当前匹配素材,还是采用新片?采用新片需要先重新标注候选、阶段时间与证据抓拍,再整体切换。 |
+
+各项 PDF 页码、原型和代码依据见 [完整差异说明](reference-review.html#4-需要用户确认的功能差异)。
+
+## 8. 报告文件与复跑
+
+- [真实接口 Playwright 报告](live/html/index.html) · [JSON](live/results.json) · [API 断言证据](live/api-evidence.json)
+- [前端模拟接口 Playwright 报告](frontend-e2e/html/index.html) · [JSON](frontend-e2e/results.json)
+- [后端测试日志](backend-mvn-test.log) · [后端打包日志](backend-package.log)
+- [前端回归日志](frontend-contract-tests.log) · [前端构建日志](frontend-build.log)
+- [资料与原型](reference-review.html) · [前端审查](frontend-review.html) · [后端审查](backend-review.html) · [数据库审查](database-review.html)
+
+测试源码位于 `ute2e/tests/review-live.spec.ts` 和 `tests/review-frontend-regressions.spec.ts`,配置为 `review-live.config.ts` / `review-frontend.config.ts`。回归脚本读取本地配置,不内嵌密码。
+
+已具备开发库表结构时,可在 `ute2e` 顺序运行:
+
+```powershell
+python tools/review-db.py test-admin
+pnpm exec playwright test --config review-live.config.ts
+python tools/review-db.py cleanup
+pnpm exec playwright test --config review-frontend.config.ts
+```
+
+真实测试后即使失败也需执行 cleanup。当前开发库已完成迁移,`prepare` 会因目标表存在而拒绝重跑。报告 JSON 保留时间、断言和最终结果,可据此核验,测试计数不等同于完整功能覆盖率。
diff --git a/reports/review-20260905/video-compatibility-probe.json b/reports/review-20260905/video-compatibility-probe.json
new file mode 100644
index 0000000..54930cf
--- /dev/null
+++ b/reports/review-20260905/video-compatibility-probe.json
@@ -0,0 +1,78 @@
+[{
+ "source": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose.mp4",
+ "event": "error",
+ "canPlayH264": "",
+ "readyState": 0,
+ "networkState": 3,
+ "duration": null,
+ "width": 0,
+ "height": 0,
+ "error": {
+ "code": 4,
+ "message": "DEMUXER_ERROR_NO_SUPPORTED_STREAMS: FFmpegDemuxer: no supported streams"
+ },
+ "responses": [
+ {
+ "status": 206,
+ "headers": {
+ "cache-control": "no-cache",
+ "etag": "W/\"57781506-1788573880243\"",
+ "connection": "keep-alive",
+ "content-range": "bytes 0-57781505/57781506",
+ "accept-ranges": "bytes",
+ "keep-alive": "timeout=5",
+ "content-length": "57781506",
+ "date": "Sat, 05 Sep 2026 03:45:41 GMT",
+ "content-type": "video/mp4",
+ "vary": "Origin",
+ "last-modified": "Sat, 05 Sep 2026 02:04:40 GMT"
+ }
+ },
+ {
+ "status": 206,
+ "headers": {
+ "cache-control": "no-cache",
+ "etag": "W/\"57781506-1788573880243\"",
+ "content-range": "bytes 57704448-57781505/57781506",
+ "accept-ranges": "bytes",
+ "content-length": "77058",
+ "date": "Sat, 05 Sep 2026 03:45:41 GMT",
+ "last-modified": "Sat, 05 Sep 2026 02:04:40 GMT",
+ "vary": "Origin",
+ "content-type": "video/mp4"
+ }
+ }
+ ]
+},{
+ "source": "/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose1.mp4",
+ "event": "error",
+ "canPlayH264": "",
+ "readyState": 0,
+ "networkState": 3,
+ "duration": null,
+ "width": 0,
+ "height": 0,
+ "error": {
+ "code": 4,
+ "message": "DEMUXER_ERROR_NO_SUPPORTED_STREAMS: FFmpegDemuxer: no supported streams"
+ },
+ "responses": [
+ {
+ "status": 206,
+ "headers": {
+ "cache-control": "no-cache",
+ "etag": "W/\"42865492-1788573880448\"",
+ "connection": "keep-alive",
+ "content-range": "bytes 0-42865491/42865492",
+ "accept-ranges": "bytes",
+ "keep-alive": "timeout=5",
+ "content-length": "42865492",
+ "date": "Sat, 05 Sep 2026 03:45:42 GMT",
+ "content-type": "video/mp4",
+ "vary": "Origin",
+ "last-modified": "Sat, 05 Sep 2026 02:04:40 GMT"
+ }
+ }
+ ]
+}
+]
\ No newline at end of file
diff --git a/reports/review-20260905/video-compatibility-probe.mjs b/reports/review-20260905/video-compatibility-probe.mjs
new file mode 100644
index 0000000..b2c906f
--- /dev/null
+++ b/reports/review-20260905/video-compatibility-probe.mjs
@@ -0,0 +1,34 @@
+import { chromium } from '@playwright/test'
+
+const browser = await chromium.launch({ headless: true })
+const results = []
+try {
+ for (const suffix of ['', '1']) {
+ const page = await browser.newPage()
+ const responses = []
+ page.on('response', (response) => {
+ if (response.url().endsWith('.mp4')) responses.push({ status: response.status(), headers: response.headers() })
+ })
+ await page.goto('http://127.0.0.1:6180/')
+ const result = await page.evaluate(async (suffix) => {
+ const video = document.createElement('video')
+ document.body.replaceChildren(video)
+ const source = `/videos/chinese-maintenance/china-military-repair-workshop-hd-45s-system-pose${suffix}.mp4`
+ video.muted = true
+ video.preload = 'auto'
+ video.autoplay = true
+ const event = await new Promise((resolve) => {
+ const timer = setTimeout(() => resolve('timeout'), 15000)
+ for (const name of ['loadeddata', 'error']) video.addEventListener(name, () => { clearTimeout(timer); resolve(name) }, { once: true })
+ video.src = source
+ video.load()
+ })
+ return { source, event, canPlayH264: video.canPlayType('video/mp4; codecs="avc1.4d402a, mp4a.40.2"'), readyState: video.readyState, networkState: video.networkState, duration: Number.isFinite(video.duration) ? video.duration : null, width: video.videoWidth, height: video.videoHeight, error: video.error ? { code: video.error.code, message: video.error.message } : null }
+ }, suffix)
+ results.push({ ...result, responses })
+ await page.close()
+ }
+} finally {
+ await browser.close()
+}
+console.log(JSON.stringify(results, null, 2))
diff --git a/review-frontend.config.ts b/review-frontend.config.ts
new file mode 100644
index 0000000..358902f
--- /dev/null
+++ b/review-frontend.config.ts
@@ -0,0 +1,30 @@
+import { defineConfig, devices } from '@playwright/test'
+
+// Isolated browser/API-mock regressions; the live API suite has a separate report.
+export default defineConfig({
+ testDir: './tests',
+ testMatch: 'review-frontend-regressions.spec.ts',
+ outputDir: './reports/review-20260905/frontend-e2e/test-results',
+ fullyParallel: false,
+ workers: 1,
+ retries: 0,
+ timeout: 90_000,
+ expect: { timeout: 15_000 },
+ reporter: [
+ ['list'],
+ ['html', { outputFolder: 'reports/review-20260905/frontend-e2e/html', open: 'never' }],
+ ['json', { outputFile: 'reports/review-20260905/frontend-e2e/results.json' }],
+ ],
+ use: {
+ baseURL: 'http://127.0.0.1:6180',
+ locale: 'zh-CN',
+ timezoneId: 'Asia/Shanghai',
+ viewport: { width: 1440, height: 1100 },
+ actionTimeout: 15_000,
+ navigationTimeout: 40_000,
+ screenshot: 'only-on-failure',
+ trace: 'off',
+ video: 'off',
+ },
+ projects: [{ name: 'chromium-api-mock', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 1100 } } }],
+})
diff --git a/review-live.config.ts b/review-live.config.ts
new file mode 100644
index 0000000..fe93474
--- /dev/null
+++ b/review-live.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig, devices } from '@playwright/test'
+export default defineConfig({
+ testDir: './tests', testMatch: 'review-live.spec.ts', workers: 1, fullyParallel: false,
+ timeout: 120_000, expect: { timeout: 20_000 },
+ outputDir: 'reports/review-20260905/live/results',
+ reporter: [['list'], ['html', { outputFolder: 'reports/review-20260905/live/html', open: 'never' }], ['json', { outputFile: 'reports/review-20260905/live/results.json' }]],
+ // Installed Edge provides the H.264 codec used by the actual offline training video.
+ use: { ...devices['Desktop Edge'], channel: 'msedge', baseURL: 'http://127.0.0.1:6180', viewport: { width: 1440, height: 1000 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', trace: 'off', video: 'off', screenshot: 'off' },
+})
diff --git a/tests/review-frontend-regressions.spec.ts b/tests/review-frontend-regressions.spec.ts
new file mode 100644
index 0000000..9f000ee
--- /dev/null
+++ b/tests/review-frontend-regressions.spec.ts
@@ -0,0 +1,468 @@
+import { expect, test, type Page, type Route } from '@playwright/test'
+import { TeachingContractMock } from './teaching-contract-mock'
+import fs from 'node:fs'
+import path from 'node:path'
+
+/** Browser regressions for the 2026-09-05 review. All API responses are mocked.
+ * These verify real Vue UI interactions and outgoing payloads, not database persistence.
+ * TeachingRunView channel cases use its formal virtual route as a component host;
+ * they do not claim that the default PHYSICAL legacy replay is a live API workflow.
+ */
+
+type Json = Record
+
+const SHOTS = path.resolve('reports/review-20260905/frontend-e2e/screenshots')
+fs.mkdirSync(SHOTS, { recursive: true })
+
+const envelope = (data: unknown) => ({ code: 200, message: 'OK', data, timestamp: Date.now(), requestId: 'ute2e-training' })
+const ok = (route: Route, data: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
+const fail = (route: Route, status: number, message: string) => route.fulfill({
+ status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-training' }),
+})
+const pageOf = (records: unknown[]) => ({ records, total: records.length, page: 1, size: 20 })
+
+/** 绑定场景:两个可操作对象、一个环境设施、一个已软删除对象。 */
+const sceneContent = (): Json => ({
+ schema: 'unreal-tran.scene',
+ version: '2.0',
+ objects: [
+ {
+ id: 'o1', name: '支腿液压缸总成', visible: true, deleted: false,
+ semantic: { category: '液压执行机构', deviceId: 'DEV-01', interactionId: 'cylinder-main', operable: true, capabilities: ['select', 'move', 'inspect', 'measure'], tags: [] },
+ },
+ {
+ id: 'o2', name: '活塞密封圈', visible: true, deleted: false,
+ semantic: { category: '密封件', deviceId: 'DEV-02', interactionId: 'seal-ring', operable: true, capabilities: ['select', 'move'], tags: [] },
+ },
+ {
+ id: 'o3', name: '车间地面', visible: true, deleted: false,
+ semantic: { category: '环境设施', deviceId: '', interactionId: '', operable: false, capabilities: [], tags: [] },
+ },
+ {
+ id: 'o4', name: '历史对象', visible: true, deleted: true,
+ semantic: { category: '液压', deviceId: 'DEV-04', interactionId: 'ghost', operable: true, capabilities: ['select'], tags: [] },
+ },
+ ],
+})
+
+const legacyTrainingContent = (mode: string): Json => ({
+ level: '中级',
+ durationMinutes: 25,
+ totalScore: 100,
+ modeConfig: mode === 'CONFRONTATION'
+ ? { type: 'CONFRONTATION', redTeamName: '红方', blueTeamName: '蓝方', teamSize: 3, roundDurationMinutes: 30, submitPositions: ['COMMANDER'], objective: '限时排除故障', winRule: '' }
+ : { type: mode },
+ // 历史工程只有 title/summary/score,缺 stepCode/actionCode/目标,归一化后必须补齐编码。
+ steps: [
+ { id: 'step_1c9f0e2a-8f11-4c0b-9d6e-77a1f3b5c9d0', title: '检查缸体外观', summary: '目视检查缸筒是否存在划痕', score: 40 },
+ { id: 'step_2d8e1f3b-7a22-4b1c-8e5f-66b2e4c6d8e1', title: '拆卸密封圈', summary: '按对角顺序拆除固定螺栓', score: 60 },
+ ],
+})
+
+class TrainingMock {
+ readonly requests: Array<{ method: string; path: string; body: Json }> = []
+ mode = 'VIRTUAL'
+ content: Json = legacyTrainingContent('VIRTUAL')
+ version = 2
+ sceneBound = true
+
+ private dependencies() {
+ return this.sceneBound
+ ? [{
+ targetProjectId: '701', targetVersionId: '25', relationType: 'TRAINING_SCENE', required: true,
+ targetType: 'SCENE', targetCode: 'SC-2026-014', targetName: '支腿检修车间', targetVersionNumber: 3,
+ }]
+ : []
+ }
+
+ private project() {
+ return {
+ id: '901', type: 'TRAINING', code: 'TR-2026-071', name: '支腿液压缸中修训练',
+ description: '整机支腿液压缸中修标准训练', categoryCode: 'MEDIUM_REPAIR', coverUri: '',
+ status: 'DRAFT', trainingMode: this.mode, version: this.version,
+ currentVersionId: '31', currentVersionNo: 2, currentVersionCode: 'V1.1.0', currentVersionStatus: 'DRAFT',
+ publishedVersionId: null, ownerUserId: '7', ownerName: '内容作者',
+ departmentId: '20', departmentName: '维修保障中心',
+ addTime: 1786387200, updateTime: 1786387200, publishedTime: null,
+ usageCount: 0, readCount: 0, downloadCount: 0,
+ dependencyCount: this.dependencies().length, assetCount: 0,
+ }
+ }
+
+ private detail() {
+ return {
+ project: this.project(),
+ currentVersion: {
+ id: '31', projectId: '901', versionNo: 2, versionCode: 'V1.1.0', status: 'DRAFT',
+ content: this.content, dependencies: this.dependencies(), assets: [],
+ createdByName: '内容作者', publishedByName: '', publishedTime: null,
+ addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '',
+ },
+ }
+ }
+
+ async install(page: Page) {
+ await page.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
+ await page.addInitScript(() => {
+ sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-training-token')
+ })
+ await page.route('**/api/auth/v1/auth/me', (route) => ok(route, {
+ user: { id: '1', username: 'review.teacher', displayName: '回归测试教员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 },
+ activeRoleId: 'role-teacher',
+ roles: [{ id: 'role-teacher', code: 'teacher', name: '教员', shortName: '教', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'ALL' }],
+ permissions: ['content.training', 'content.model', 'content.scene', 'content.ofd', 'content.create', 'content.update', 'content.publish', 'content.delete'],
+ authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200,
+ }))
+ await page.route('**/api/auth/v1/menus/navigation', (route) => ok(route, []))
+ await page.route('**/api/tran/v1/content/**', (route) => this.handle(route))
+ }
+
+ private async handle(route: Route) {
+ const request = route.request()
+ const url = new URL(request.url())
+ const target = url.pathname.replace(/^.*\/v1\/content/, '')
+ const method = request.method()
+ let body: Json = {}
+ try {
+ const text = request.postData()
+ if (text) body = JSON.parse(text) as Json
+ } catch { body = {} }
+ this.requests.push({ method, path: target, body })
+
+ if (method === 'GET' && target === '/projects') return ok(route, pageOf([this.project()]))
+ if (method === 'GET' && target === '/projects/summary') {
+ return ok(route, { total: 1, draft: 1, review: 0, published: 0, byType: [], byTrainingMode: [] })
+ }
+ if (method === 'GET' && target === '/catalog') return ok(route, pageOf([]))
+ if (method === 'GET' && target === '/projects/901') return ok(route, this.detail())
+ if (method === 'PUT' && target === '/projects/901') {
+ if (body.content && typeof body.content === 'object') this.content = body.content as Json
+ if (typeof body.trainingMode === 'string') this.mode = body.trainingMode
+ this.version += 1
+ return ok(route, this.detail())
+ }
+ if (method === 'GET' && target === '/projects/901/versions') return ok(route, pageOf([this.detail().currentVersion]))
+ if (method === 'GET' && target === '/projects/901/versions/31') return ok(route, this.detail().currentVersion)
+ if (method === 'GET' && target === '/projects/901/reviews') return ok(route, [])
+ // 绑定场景的已发布版本,工作台据此解析可操作目标
+ if (method === 'GET' && target === '/projects/701/versions/25') {
+ return ok(route, {
+ id: '25', projectId: '701', versionNo: 3, versionCode: 'V1.2.0', status: 'PUBLISHED',
+ content: sceneContent(), dependencies: [], assets: [],
+ createdByName: '内容作者', publishedByName: '内容作者', publishedTime: 1786387200,
+ addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '',
+ })
+ }
+ if (method === 'POST' && target === '/projects/901/metrics') return ok(route, {})
+ return fail(route, 404, `未打桩的内容接口 ${method} ${target}`)
+ }
+
+ /** 最后一次保存提交的 content,用于断言工作台真正回写了什么。 */
+ savedContent(): Json {
+ const last = [...this.requests].reverse().find((item) => item.method === 'PUT')
+ return (last?.body.content ?? {}) as Json
+ }
+
+ savedSteps(): Json[] {
+ const content = this.savedContent()
+ return Array.isArray(content.steps) ? content.steps as Json[] : []
+ }
+}
+
+const multiChannelContent = (): Json => ({
+ schema: 'unreal-tran.training/v1', trainingMode: 'VIRTUAL',
+ enabledChannels: ['VIRTUAL', 'PHYSICAL', 'CONFRONTATION'],
+ modeConfig: { type: 'VIRTUAL', renderMode: '原有渲染方式' },
+ channelProfiles: {
+ VIRTUAL: { type: 'VIRTUAL', renderMode: '原有渲染方式' },
+ PHYSICAL: { type: 'PHYSICAL', stationName: '保留的实装工位', manualConfirmAllowed: true },
+ CONFRONTATION: { type: 'CONFRONTATION', teamSize: 6, roundDurationMinutes: 45, submitPositions: ['COMMANDER'], objective: '协同目标应保留' },
+ },
+ level: '中级', durationMinutes: 15, totalScore: 100,
+ steps: [1, 2, 3].map((n) => ({
+ id: `saved-${n}`, stepCode: `FIXED-S0${n}`, actionCode: `FIXED-A0${n}`, order: n,
+ title: `已保存步骤${n}`, summary: '浏览器契约回归步骤', targetId: 'cylinder-main',
+ targetName: '支腿液压缸总成', mode: 'inspect', interaction: 'target-confirm', score: n === 3 ? 34 : 33,
+ physicalEventRule: { source: 'MANUAL', eventType: `REVIEW_EVENT_0${n}`, progressPercent: [33, 67, 100][n - 1], manualConfirmAllowed: true },
+ allowedPositions: ['COMMANDER'],
+ })),
+})
+
+const shot = async (page: Page, name: string) => {
+ const filename = path.join(SHOTS, `${name}.png`)
+ await page.screenshot({ path: filename, fullPage: true })
+ await test.info().attach(name, { path: filename, contentType: 'image/png' })
+}
+
+const saveStudio = async (page: Page, mock: TrainingMock) => {
+ const count = mock.requests.filter((item) => item.method === 'PUT').length
+ await page.locator('.content-action-bar').getByRole('button', { name: '保存', exact: true }).click()
+ await expect.poll(() => mock.requests.filter((item) => item.method === 'PUT').length).toBe(count + 1)
+ await expect(page.locator('.studio-dirty')).toHaveCount(0)
+}
+
+test.describe('API mock浏览器回归:已保存的实装步骤', () => {
+ for (const action of ['move', 'delete', 'duplicate'] as const) {
+ test(`${action}后保存重算顺序和实装进度`, async ({ page }) => {
+ const mock = new TrainingMock()
+ // The default remains VIRTUAL; PHYSICAL enabled as a secondary channel must still be correct.
+ mock.content = multiChannelContent()
+ await mock.install(page)
+ await page.goto('/content/training-projects/901/studio?view=steps')
+ const rows = page.locator('.step-list > li')
+ await expect(rows).toHaveCount(3)
+ if (action === 'move') {
+ await rows.nth(2).locator('.step-row').click()
+ await rows.nth(2).getByRole('button', { name: '上移', exact: true }).click()
+ await expect(rows.nth(1)).toContainText('已保存步骤3')
+ } else if (action === 'delete') {
+ await rows.nth(2).locator('.step-row').click()
+ await rows.nth(2).getByRole('button', { name: '删除', exact: true }).click()
+ await page.getByRole('button', { name: '确认删除', exact: true }).click()
+ await expect(rows).toHaveCount(2)
+ } else {
+ await rows.nth(0).getByRole('button', { name: '复制', exact: true }).click()
+ await expect(rows).toHaveCount(4)
+ }
+ await saveStudio(page, mock)
+ const saved = mock.savedSteps()
+ expect(saved.map((step) => step.order)).toEqual(saved.map((_, index) => index + 1))
+ expect(saved.map((step) => (step.physicalEventRule as Json).progressPercent)).toEqual(
+ action === 'move' ? [33, 67, 100] : action === 'delete' ? [50, 100] : [25, 50, 75, 100],
+ )
+ if (action === 'move') expect(saved.map((step) => step.stepCode)).toEqual(['FIXED-S01', 'FIXED-S03', 'FIXED-S02'])
+ if (action === 'delete') expect(saved.map((step) => step.stepCode)).toEqual(['FIXED-S01', 'FIXED-S02'])
+ expect(new Set(saved.map((step) => step.stepCode)).size).toBe(saved.length)
+ await test.info().attach('保存请求中的训练内容(API mock)', { body: Buffer.from(JSON.stringify(mock.savedContent(), null, 2)), contentType: 'application/json' })
+ await page.reload()
+ await expect(rows).toHaveCount(saved.length)
+ await page.locator('.el-form-item', { hasText: '完成后进度' }).scrollIntoViewIfNeeded()
+ await shot(page, `01-saved-step-${action}`)
+ })
+ }
+})
+
+test('API mock浏览器回归:通用编辑器保存并切换默认渠道不丢配置', async ({ page }) => {
+ const mock = new TrainingMock()
+ mock.content = multiChannelContent()
+ const originalProfiles = structuredClone(mock.content.channelProfiles) as Record
+ await mock.install(page)
+ await page.goto('/content/training-projects/901/edit')
+ await page.getByRole('tab', { name: '结构化内容', exact: true }).click()
+ const renderMode = page.locator('.el-form-item', { hasText: '渲染方式' }).locator('input')
+ await expect(renderMode).toHaveValue('原有渲染方式')
+ await renderMode.fill('回归验证后的渲染方式')
+ const save = async () => {
+ const count = mock.requests.filter((item) => item.method === 'PUT').length
+ await page.getByRole('button', { name: '保存草稿', exact: true }).click()
+ await expect.poll(() => mock.requests.filter((item) => item.method === 'PUT').length).toBe(count + 1)
+ await expect(page.getByRole('button', { name: '保存草稿', exact: true })).toBeEnabled()
+ }
+ await save()
+ let saved = mock.savedContent()
+ let profiles = saved.channelProfiles as Record
+ expect(saved.enabledChannels).toEqual(['VIRTUAL', 'PHYSICAL', 'CONFRONTATION'])
+ expect(saved.modeConfig).toEqual(profiles.VIRTUAL)
+ expect(profiles.VIRTUAL!.renderMode).toBe('回归验证后的渲染方式')
+ // Normalization may add documented defaults; every existing value must survive.
+ expect(profiles.PHYSICAL).toMatchObject(originalProfiles.PHYSICAL!)
+ expect(profiles.CONFRONTATION).toMatchObject(originalProfiles.CONFRONTATION!)
+ await page.reload()
+ await page.getByRole('tab', { name: '结构化内容', exact: true }).click()
+ await expect(renderMode).toHaveValue('回归验证后的渲染方式')
+ await shot(page, '02-multichannel-virtual-save')
+ await page.getByRole('tab', { name: '基本信息', exact: true }).click()
+ await page.locator('.el-form-item', { hasText: '训练模式' }).locator('.el-select').click()
+ await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '对抗' }).click()
+ await page.getByRole('tab', { name: '结构化内容', exact: true }).click()
+ await expect(page.locator('.el-form-item', { hasText: '每队人数' }).locator('input')).toHaveValue('6')
+ const roundDuration = page.locator('.el-form-item', { hasText: '单局时长' }).locator('input')
+ await expect(roundDuration).toHaveValue('45')
+ await roundDuration.fill('60')
+ await roundDuration.press('Tab')
+ await save()
+ saved = mock.savedContent()
+ profiles = saved.channelProfiles as Record
+ expect(saved.trainingMode).toBe('CONFRONTATION')
+ expect(saved.modeConfig).toEqual(profiles.CONFRONTATION)
+ expect(profiles.CONFRONTATION!.roundDurationMinutes).toBe(60)
+ expect(profiles.CONFRONTATION!.teamSize).toBe(6)
+ expect(profiles.VIRTUAL!.renderMode).toBe('回归验证后的渲染方式')
+ expect(profiles.PHYSICAL).toMatchObject(originalProfiles.PHYSICAL!)
+ expect(saved.enabledChannels).toEqual(['VIRTUAL', 'PHYSICAL', 'CONFRONTATION'])
+ await page.reload()
+ await page.getByRole('tab', { name: '结构化内容', exact: true }).click()
+ await expect(roundDuration).toHaveValue('60')
+ await shot(page, '03-multichannel-confrontation-save')
+})
+
+const overlayCases = [
+ { name: '普通虚拟训练', channel: 'VIRTUAL', kind: 'TRAINING', status: 'IN_PROGRESS', closed: false },
+ { name: '普通实装组件', channel: 'PHYSICAL', kind: 'TRAINING', status: 'IN_PROGRESS', closed: false },
+ { name: '普通对抗组件', channel: 'CONFRONTATION', kind: 'TRAINING', status: 'IN_PROGRESS', closed: false },
+ { name: '进行中正式考核', channel: 'VIRTUAL', kind: 'FORMAL_EXAM', status: 'IN_PROGRESS', closed: false },
+ { name: '未开始正式考核', channel: 'VIRTUAL', kind: 'FORMAL_EXAM', status: 'ACCEPTED', closed: true },
+ { name: '已提交正式考核', channel: 'VIRTUAL', kind: 'FORMAL_EXAM', status: 'SUBMITTED', closed: true },
+] as const
+
+test.describe('API mock浏览器回归:失败步骤重试', () => {
+ for (const status of ['IN_PROGRESS', 'SUBMITTED', 'TERMINATED'] as const) {
+ test(`运行${status}的失败步骤重试入口`, async ({ page }) => {
+ await page.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
+ const contract = new TeachingContractMock('studentLeader')
+ const run: Json = contract.seedTerminableRun()
+ run.status = status
+ const task = contract.assignments.get('assignment-virtual-common')!
+ task.name = 'API mock:失败步骤重试'
+ task.digitalHumanAllowed = false
+ task.digitalInstructorAgentSlug = ''
+ ;(task.definitionSnapshot as Json).scenario = { schema: 'unreal-tran.scene', version: '2.0', objects: [] }
+ const facts = [{
+ id: 'step-fact-1', runId: run.id, assignmentId: task.id, stepCode: 'STEP-01', stepOrder: 1,
+ targetProgressPercent: 50, statusCode: 'FAILED', attemptCount: 1, failureCount: 1,
+ lastFailureCode: 'REVIEW_RETRY', evidenceSourceCode: 'DEVICE', lastActorUserId: '4',
+ lastRunMemberId: 'member-red-leader', lastEventId: 'event-failed', startedAt: 1786387200,
+ completedAt: null, failedAt: 1786387210, isBackfilled: 0, dataVersion: 1,
+ }]
+ await contract.install(page)
+ await page.route('**/api/tran/v1/teaching/runs/*/steps', (route) => ok(route, facts))
+ const commands: Json[] = []
+ await page.route('**/api/tran/v1/teaching/runs/*/start', async (route) => {
+ const body = route.request().postDataJSON() as Json
+ commands.push(body)
+ expect(route.request().headers()['idempotency-key']).toBe(body.commandId)
+ Object.assign(facts[0]!, { statusCode: 'IN_PROGRESS', attemptCount: 2, dataVersion: 2 })
+ run.version = Number(run.version) + 1
+ await ok(route, run)
+ })
+ await page.goto(`/teaching/virtual-training/runs/${run.id}`)
+ await expect(page.locator('.runtime-stage-card')).toBeVisible()
+ const retry = page.getByRole('button', { name: '重试当前步骤', exact: true })
+ if (status === 'IN_PROGRESS') {
+ await expect(retry).toBeVisible()
+ await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('未通过 1')
+ await shot(page, '05-failed-before-retry')
+ await retry.click()
+ await expect(retry).toHaveCount(0)
+ expect(commands).toHaveLength(1)
+ expect(String(commands[0]!.commandId)).toMatch(/^[a-f0-9-]{36}$/i)
+ await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('尝试 2')
+ await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('未通过 1')
+ await page.reload()
+ await expect(retry).toHaveCount(0)
+ await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('尝试 2')
+ await shot(page, '06-failed-after-retry')
+ } else {
+ await expect(retry).toHaveCount(0)
+ expect(commands).toHaveLength(0)
+ await shot(page, `07-no-retry-${status}`)
+ }
+ })
+ }
+})
+
+test.describe('API mock浏览器回归:执行场景关闭遮罩', () => {
+ for (const [index, item] of overlayCases.entries()) {
+ test(item.name, async ({ page }) => {
+ await page.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
+ const contract = new TeachingContractMock('studentLeader')
+ const task = contract.assignments.get('assignment-exam')!
+ Object.assign(task, { channel: item.channel, assignmentKind: item.kind, executionMode: item.kind === 'FORMAL_EXAM' ? 'EXAM' : 'PRACTICE', digitalHumanAllowed: false, digitalInstructorAgentSlug: '', name: `API mock:${item.name}` })
+ const definition = task.definitionSnapshot as Json
+ // Primitive-only fixture avoids unrelated large GLB downloads and external resources.
+ definition.scenario = { schema: 'unreal-tran.scene', version: '2.0', objects: [] }
+ const run = contract.runs.get('run-exam')!
+ Object.assign(run, { status: item.status, progressPercent: item.status === 'SUBMITTED' ? 100 : 0 })
+ Object.assign(run.assignment as Json, { channel: item.channel, assignmentKind: item.kind, executionMode: task.executionMode, name: task.name, digitalHumanAllowed: false, digitalInstructorAgentSlug: '' })
+ await contract.install(page)
+ await page.route('**/api/tran/v1/teaching/runs/*/steps', (route) => ok(route, []))
+ const errors: string[] = []
+ page.on('pageerror', (error) => errors.push(error.message))
+ await page.goto('/teaching/virtual-training/runs/run-exam')
+ await expect(page.locator('.runtime-stage-card')).toBeVisible()
+ await expect(page.locator('.runtime-formal-scene-closed')).toHaveCount(item.closed ? 1 : 0)
+ if (item.closed) {
+ await expect(page.getByText('正式考核执行场景已关闭', { exact: true })).toBeVisible()
+ await expect(page.locator('.runtime-scene-window canvas')).toHaveCount(0)
+ } else {
+ await expect(page.locator('.runtime-scene-window canvas')).toBeVisible()
+ }
+ expect(errors).toEqual([])
+ await shot(page, `04-overlay-${index + 1}-${item.channel}-${item.status}`)
+ })
+ }
+})
+
+test.describe('API mock浏览器视觉回归:场景填充与安全确认区', () => {
+ for (const theme of ['military', 'dark'] as const) {
+ test(`${theme}主题画布铺满且安全文字可读`, async ({ page }) => {
+ await page.addInitScript((themeMode) => {
+ localStorage.setItem('unreal-tran:web:preferences:v1', JSON.stringify({ themeMode }))
+ }, theme)
+ await page.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
+ const contract = new TeachingContractMock('studentLeader')
+ const run = contract.seedTerminableRun()
+ const task = contract.assignments.get('assignment-virtual-common')!
+ task.name = `API mock:${theme}场景与安全确认`
+ task.digitalHumanAllowed = false
+ task.digitalInstructorAgentSlug = ''
+ const definition = task.definitionSnapshot as Json
+ definition.scenario = { schema: 'unreal-tran.scene', version: '2.0', objects: [] }
+ ;(definition.steps as Json[])[0]!.safetyRules = [
+ { key: 'safety-1', text: '确认作业区已隔离且无无关人员进入', required: true },
+ { key: 'safety-2', text: '确认所需防护用品已佩戴并检查完好', required: true },
+ ]
+ await contract.install(page)
+ await page.route('**/api/tran/v1/teaching/runs/*/steps', (route) => ok(route, []))
+ await page.goto(`/teaching/virtual-training/runs/${run.id}`)
+ const gate = page.locator('.runtime-safety-gate')
+ const scene = page.locator('.runtime-scene-window > .teaching-three-scene')
+ const viewport = page.locator('.runtime-scene-window')
+ await expect(gate).toBeVisible()
+ await expect(gate.getByRole('checkbox')).toHaveCount(2)
+ await expect(page.locator('html')).toHaveAttribute('data-theme', theme === 'dark' ? 'dark' : 'light')
+ await expect.poll(async () => {
+ const host = await scene.boundingBox()
+ const window = await viewport.boundingBox()
+ return host && window ? Math.abs(host.height - window.height) : 999
+ }).toBeLessThanOrEqual(2)
+ const sceneBox = (await scene.boundingBox())!
+ const viewportBox = (await viewport.boundingBox())!
+ expect(Math.abs(sceneBox.width - viewportBox.width)).toBeLessThanOrEqual(2)
+ const colors = await gate.evaluate((element) => {
+ const canvas = document.createElement('canvas')
+ canvas.width = canvas.height = 1
+ const context = canvas.getContext('2d')!
+ const rgba = (value: string) => {
+ context.clearRect(0, 0, 1, 1)
+ context.fillStyle = value
+ context.fillRect(0, 0, 1, 1)
+ return Array.from(context.getImageData(0, 0, 1, 1).data)
+ }
+ const color = (target: Element, property: 'color' | 'backgroundColor') => rgba(getComputedStyle(target)[property])
+ return {
+ background: color(element, 'backgroundColor'),
+ header: color(element.querySelector('header b')!, 'color'),
+ label: color(element.querySelector('.el-checkbox__label')!, 'color'),
+ }
+ })
+ const luminance = (color: number[]) => color.slice(0, 3).map((value) => {
+ const channel = value / 255
+ return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
+ }).reduce((result, value, index) => result + value * [0.2126, 0.7152, 0.0722][index]!, 0)
+ const contrast = (text: number[]) => {
+ const values = [luminance(text), luminance(colors.background)].sort((a, b) => b - a)
+ return (values[0]! + 0.05) / (values[1]! + 0.05)
+ }
+ expect(colors.background[3], '安全确认区应有独立不透明背景').toBe(255)
+ expect(contrast(colors.header), '标题与背景对比度').toBeGreaterThanOrEqual(4.5)
+ expect(contrast(colors.label), '复选框文字与背景对比度').toBeGreaterThanOrEqual(4.5)
+ // Element Plus visually replaces its hidden native input; click the rendered label.
+ await gate.locator('.el-checkbox').first().click()
+ await expect(gate.locator('header em')).toHaveText('1 / 2')
+ await expect(gate.getByRole('checkbox').first()).toBeChecked()
+ await test.info().attach('视觉测量(API mock)', { body: Buffer.from(JSON.stringify({ theme, sceneBox, viewportBox, colors, headerContrast: contrast(colors.header), checkboxContrast: contrast(colors.label) }, null, 2)), contentType: 'application/json' })
+ await shot(page, `08-visual-safety-${theme}`)
+ })
+ }
+})
diff --git a/tests/review-live.spec.ts b/tests/review-live.spec.ts
new file mode 100644
index 0000000..588bc34
--- /dev/null
+++ b/tests/review-live.spec.ts
@@ -0,0 +1,246 @@
+import { test, expect, type Page, type TestInfo } from '@playwright/test'
+import fs from 'node:fs'
+import path from 'node:path'
+import crypto from 'node:crypto'
+import { execFileSync } from 'node:child_process'
+// @ts-ignore Local support module keeps credentials out of test sources and reports.
+import { api, localCredentials } from '../tools/review-api.mjs'
+
+const report = path.resolve('reports/review-20260905/live')
+const prefix = `UTE2E-REVIEW-${Date.now().toString(36).toUpperCase()}`
+const sessions: Record = {}
+const users: Record = {}
+const tasks: any[] = []
+let templates: any[] = []
+const password = `Rv!${crypto.randomBytes(7).toString('hex')}`
+const evidence: any[] = []
+const request = async (url: string, token?: string, body?: any, method?: string) => {
+ const result = await api(url, token, body, method)
+ if (result.status !== 200) throw new Error(`${method ?? (body ? 'POST' : 'GET')} ${url}: HTTP ${result.status} ${result.message ?? ''}`)
+ return result.data
+}
+const cmd = () => crypto.randomUUID()
+async function shot(page: Page, info: TestInfo, name: string) {
+ await expect(page.locator('.el-loading-mask:visible')).toHaveCount(0)
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
+ fs.mkdirSync(path.join(report, 'screenshots'), { recursive: true })
+ const target = path.join(report, 'screenshots', `${name}.png`)
+ await page.screenshot({ path: target, fullPage: true, animations: 'disabled' })
+ await info.attach(name, { path: target, contentType: 'image/png' })
+}
+async function sceneReady(page: Page) {
+ const scene = page.locator('.runtime-scene-window .teaching-three-scene')
+ await expect(scene).toHaveAttribute('aria-label', /^已加载发布训练场景 · [1-9]\d* 个对象$/, { timeout: 60000 })
+ const bounds = await scene.boundingBox()
+ const viewport = await page.locator('.runtime-scene-window').boundingBox()
+ expect(bounds).not.toBeNull()
+ expect(viewport).not.toBeNull()
+ expect(Math.abs(bounds!.height - viewport!.height)).toBeLessThanOrEqual(2)
+ evidence.push({ case: 'published scene ready', status: await scene.getAttribute('aria-label'), height: bounds!.height })
+}
+async function asRole(page: Page, role: string) {
+ await page.addInitScript(token => { sessionStorage.setItem('unreal-tran:web:access-token:v1', token); localStorage.removeItem('unreal-tran:web:access-token:v1') }, sessions[role])
+}
+async function newTask(channel: string) {
+ if (channel === 'PHYSICAL') {
+ // Isolated fixture uses the historical published snapshot: its source project is now
+ // DRAFT, so creating a new production task from that source would correctly be rejected.
+ const result = JSON.parse(execFileSync('python', ['tools/review-db.py', 'clone-physical', '--code', `${prefix}-PHYSICAL`], { encoding: 'utf8' }))
+ tasks.push(result)
+ fs.writeFileSync(path.join(report, 'cleanup-manifest.json'), JSON.stringify({ prefix, tasks, users: Object.values(users).map(x => ({ id: x.id, username: x.username })) }, null, 2))
+ return result
+ }
+ const row = templates.find(x => x.channel === channel && x.assignmentKind === 'TRAINING')
+ if (!row) throw new Error(`No published ${channel} training fixture`)
+ const source = await request(`/api/tran/v1/teaching/assignments/${row.id}`, sessions.admin)
+ const result = await request('/api/tran/v1/teaching/assignments', sessions.admin, {
+ code: `${prefix}-${channel}`, name: `${prefix} ${channel} 回归`, description: 'Temporary review fixture; removed after verification',
+ channel, assignmentKind: 'TRAINING', executionMode: 'PRACTICE', audienceType: 'COMMON', collaborationMode: 'INDIVIDUAL',
+ digitalHumanAllowed: false, contentProjectId: source.contentProjectId, contentVersionId: source.contentVersionId, members: [],
+ })
+ tasks.push({ id: result.id, code: result.code })
+ fs.writeFileSync(path.join(report, 'cleanup-manifest.json'), JSON.stringify({ prefix, tasks, users: Object.values(users).map(x => ({ id: x.id, username: x.username })) }, null, 2))
+ return request(`/api/tran/v1/teaching/assignments/${result.id}/publish`, sessions.admin, { version: result.version })
+}
+
+test.beforeAll(async () => {
+ fs.mkdirSync(report, { recursive: true })
+ const login = await request('/api/auth/v1/auth/login', undefined, { ...localCredentials(), roleCode: 'admin', rememberMe: false })
+ sessions.admin = login.accessToken
+ const rolesResult = await request('/api/auth/v1/roles?size=100', sessions.admin)
+ const roles = rolesResult.records ?? rolesResult
+ const context = await request('/api/auth/v1/auth/login-context')
+ const department = context.departments[0]?.children?.[0] ?? context.departments[0]
+ if (!department) throw new Error('No active department for isolated test users')
+ for (const roleCode of ['teacher', 'student']) {
+ const role = roles.find((x: any) => x.code === roleCode)
+ const user = await request('/api/auth/v1/users', sessions.admin, { username: `rv_${roleCode}_${Date.now().toString(36)}`, displayName: `${prefix}-${roleCode}`, departmentId: department.id, roleIds: [role.id], defaultRoleId: role.id, enabled: true, password, remark: prefix })
+ users[roleCode] = user.user ?? user
+ const logged = await request('/api/auth/v1/auth/login', undefined, { userId: users[roleCode].id, roleCode, departmentId: roleCode === 'teacher' ? department.id : null, password, rememberMe: false })
+ sessions[roleCode] = logged.accessToken
+ }
+ templates = (await request('/api/tran/v1/teaching/assignments?size=100', sessions.admin)).records
+ fs.writeFileSync(path.join(report, 'cleanup-manifest.json'), JSON.stringify({ prefix, tasks, users: Object.values(users).map(x => ({ id: x.id, username: x.username })) }, null, 2))
+})
+
+test.afterEach(async ({ page }, info) => {
+ if (info.status !== info.expectedStatus && !page.isClosed()) await shot(page, info, `failure-${info.title.replace(/[^a-z0-9]+/gi, '-').slice(0, 40)}`).catch(() => {})
+})
+test.afterAll(async () => {
+ fs.writeFileSync(path.join(report, 'api-evidence.json'), JSON.stringify(evidence, null, 2))
+ for (const [role, token] of Object.entries(sessions)) if (role !== 'admin') await api('/api/auth/v1/auth/logout', token, {}).catch(() => {})
+ for (const user of Object.values(users)) {
+ const current = await api(`/api/auth/v1/users/${user.id}`, sessions.admin)
+ const result = await api(`/api/auth/v1/users/${user.id}`, sessions.admin, { version: current.data?.dataVersion }, 'DELETE')
+ evidence.push({ cleanupUser: user.id, status: result.status })
+ }
+ if (sessions.admin) await api('/api/auth/v1/auth/logout', sessions.admin, {}).catch(() => {})
+ fs.writeFileSync(path.join(report, 'api-evidence.json'), JSON.stringify(evidence, null, 2))
+})
+
+test('live login roles and formal virtual route', async ({ page }, info) => {
+ await page.goto('/login')
+ await expect(page.locator('[data-role-code]')).toHaveCount(3)
+ await expect(page.locator('[data-role-code="administrative"]')).toHaveCount(0)
+ await shot(page, info, '01-login-three-roles')
+ const credentials = localCredentials()
+ await page.locator('[data-role-code="admin"]').click()
+ await page.locator('input[name="username"]').fill(credentials.username)
+ await page.locator('input[name="password"]').fill(credentials.password)
+ await page.getByRole('button', { name: /登录系统|正在验证身份/ }).click()
+ await expect(page.locator('.platform-shell')).toBeVisible()
+ await page.goto('/teaching/virtual-training/api?view=records')
+ await expect(page).toHaveURL(/\/teaching\/virtual-training\?view=records/)
+ await expect(page.getByTestId('teaching-task-center')).toBeVisible()
+ await page.goto('/teaching/virtual-training')
+ await expect(page.locator('.ttc-assignment-card').first()).toBeVisible()
+ await expect(page.getByTestId('teaching-task-center').locator('iframe')).toHaveCount(0)
+ await shot(page, info, '02-virtual-formal-task-center')
+ await page.setViewportSize({ width: 1920, height: 1080 })
+ await shot(page, info, '03-virtual-1920')
+})
+
+test('live teacher role and content write boundary', async ({ page }, info) => {
+ await asRole(page, 'teacher')
+ await page.goto('/content/training-projects')
+ await expect(page.locator('.platform-shell')).toBeVisible()
+ await expect(page.locator('.training-project-card').first()).toBeVisible()
+ await shot(page, info, '04-teacher-content')
+ const denied = await api('/api/tran/v1/content/projects', sessions.admin, { type: 'TRAINING', code: `${prefix}-DENIED`, name: 'Permission boundary', categoryCode: '', trainingMode: 'VIRTUAL', description: '', coverUri: '', content: {} })
+ expect(denied.status).toBe(403)
+ evidence.push({ case: 'admin content create requires teacher role', status: denied.status })
+})
+
+test('live virtual start replay step facts submit and review', async ({ page }, info) => {
+ // Three complete scene loads plus full-page screenshots are expensive under software WebGL.
+ test.setTimeout(240_000)
+ page.on('response', response => {
+ const resource = new URL(response.url()).pathname
+ if (response.ok() && (/\.glb$/i.test(resource) || /\/assets\/[^/]+\/download$/.test(resource))) {
+ evidence.push({ case: 'scene asset response', path: resource, status: response.status() })
+ }
+ })
+ const task = await newTask('VIRTUAL')
+ let run = await request(`/api/tran/v1/teaching/assignments/${task.id}/accept`, sessions.student, { assignmentVersion: task.version, teamCode: 'NEUTRAL', positionCode: 'OPERATOR' })
+ const initial = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
+ expect(initial.length).toBeGreaterThan(0)
+ expect(initial.every((x: any) => x.statusCode === 'PENDING')).toBe(true)
+ await asRole(page, 'student')
+ await page.goto(`/teaching/virtual-training/runs/${run.id}`)
+ await expect(page.getByRole('button', { name: '开始训练', exact: true })).toBeVisible()
+ const startedResponse = page.waitForResponse(r => r.url().endsWith(`/runs/${run.id}/start`) && r.request().method() === 'POST')
+ await page.getByRole('button', { name: '开始训练', exact: true }).click()
+ const startResponse = await startedResponse
+ expect(startResponse.status()).toBe(200)
+ const payload = startResponse.request().postDataJSON()
+ run = (await startResponse.json()).data
+ const replay = await request(`/api/tran/v1/teaching/runs/${run.id}/start`, sessions.student, payload)
+ expect(replay.version).toBe(run.version)
+ const conflicting = await api(`/api/tran/v1/teaching/runs/${run.id}/start`, sessions.student, { ...payload, version: payload.version + 1 })
+ expect(conflicting.status).toBe(409)
+ const early = await api(`/api/tran/v1/teaching/runs/${run.id}/submit`, sessions.student, { version: run.version, commandId: cmd(), summary: 'Early submit must fail' })
+ expect(early.status).toBe(409)
+ await expect(page.locator('.runtime-formal-scene-closed')).toHaveCount(0)
+ await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('尝试 1 次')
+ await sceneReady(page)
+ await shot(page, info, '05-virtual-started-no-exam-overlay')
+ for (let i = 0; i < initial.length; i++) {
+ const execution = await request(`/api/tran/v1/teaching/runs/${run.id}/execution`, sessions.student)
+ const definitions = execution.definitionSnapshot?.steps ?? execution.definition?.steps ?? execution.steps ?? []
+ const step = execution.currentStep ?? definitions.find((x: any) => (x.stepCode ?? x.code) === run.currentStepCode) ?? definitions[i]
+ if (!step) throw new Error(`Missing current public step; execution keys: ${Object.keys(execution).join(',')}`)
+ const data = { version: run.version, commandId: cmd(), completedStepCode: step.stepCode ?? step.code, actionCode: step.actionCode, checkpoint: {}, event: { type: 'training.action.completed', title: 'Review regression step', source: 'web', visibility: 'PUBLIC', publicPayload: {} } }
+ run = await request(`/api/tran/v1/teaching/runs/${run.id}/progress`, sessions.student, data, 'PUT')
+ const again = await request(`/api/tran/v1/teaching/runs/${run.id}/progress`, sessions.student, data, 'PUT')
+ expect(again.version).toBe(run.version)
+ }
+ const facts = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
+ expect(facts.every((x: any) => x.statusCode === 'COMPLETED' && x.attemptCount === 1)).toBe(true)
+ await page.reload()
+ await expect(page.getByRole('button', { name: '提交训练结果', exact: true })).toBeVisible()
+ await sceneReady(page)
+ await shot(page, info, '06-virtual-all-steps-completed')
+ await page.getByRole('button', { name: '提交训练结果', exact: true }).click()
+ const submittedResponse = page.waitForResponse(r => r.url().endsWith(`/runs/${run.id}/submit`) && r.request().method() === 'POST')
+ await page.getByRole('button', { name: '确认提交', exact: true }).click()
+ const submitted = await submittedResponse
+ expect(submitted.status()).toBe(200)
+ run = (await submitted.json()).data
+ const review = { version: run.version, commandId: cmd(), score: 90, passed: true, feedback: 'Isolated code review regression' }
+ const reviewed = await request(`/api/tran/v1/teaching/runs/${run.id}/review`, sessions.admin, review)
+ const reviewReplay = await request(`/api/tran/v1/teaching/runs/${run.id}/review`, sessions.admin, review)
+ expect(reviewed.status).toBe('REVIEWED')
+ expect(reviewReplay.version).toBe(reviewed.version)
+ await page.reload()
+ await expect(page.locator('.platform-shell')).toBeVisible()
+ await sceneReady(page)
+ await shot(page, info, '07-virtual-reviewed')
+ evidence.push({ case: 'virtual lifecycle', runId: run.id, steps: facts.length, startReplay: true, progressReplay: true, reviewReplay: true, earlySubmit: early.status, conflictingCommand: conflicting.status })
+})
+
+test('live physical failure retry rejects stale evidence', async ({ page }, info) => {
+ const task = await newTask('PHYSICAL')
+ let run = await request(`/api/tran/v1/teaching/assignments/${task.id}/accept`, sessions.student, { assignmentVersion: task.version, teamCode: 'NEUTRAL', positionCode: 'OPERATOR' })
+ run = await request(`/api/tran/v1/teaching/runs/${run.id}/start`, sessions.student, { version: run.version, commandId: cmd() })
+ const firstTime = Math.floor(Date.now() / 1000)
+ const execution = await request(`/api/tran/v1/teaching/runs/${run.id}/execution`, sessions.student)
+ const defs = execution.definitionSnapshot?.steps ?? execution.definition?.steps ?? execution.steps ?? []
+ const step = execution.currentStep ?? defs.find((x: any) => (x.stepCode ?? x.code) === run.currentStepCode)
+ if (!step) throw new Error(`Missing physical step; keys: ${Object.keys(execution).join(',')}`)
+ const base = { source: 'MANUAL', stepCode: run.currentStepCode, eventType: 'review.step.confirmed', publicPayload: {} }
+ await request(`/api/tran/v1/teaching/runs/${run.id}/physical-events`, sessions.student, { ...base, runVersion: run.version, eventId: cmd(), manualConfirmed: false, occurredAt: firstTime })
+ let facts = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
+ expect(facts[0].statusCode).toBe('FAILED')
+ await new Promise(resolve => setTimeout(resolve, 2200))
+ run = await request(`/api/tran/v1/teaching/runs/${run.id}`, sessions.student)
+ run = await request(`/api/tran/v1/teaching/runs/${run.id}/start`, sessions.student, { version: run.version, commandId: cmd() })
+ const stale = await api(`/api/tran/v1/teaching/runs/${run.id}/physical-events`, sessions.student, { ...base, runVersion: run.version, eventId: cmd(), manualConfirmed: true, occurredAt: firstTime + 1 })
+ expect(stale.status).toBe(400)
+ facts = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
+ expect(facts[0]).toMatchObject({ statusCode: 'IN_PROGRESS', attemptCount: 2, failureCount: 1 })
+ await request(`/api/tran/v1/teaching/runs/${run.id}/physical-events`, sessions.student, { ...base, runVersion: run.version, eventId: cmd(), manualConfirmed: true, occurredAt: Math.floor(Date.now() / 1000) })
+ facts = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
+ expect(facts[0].statusCode).toBe('COMPLETED')
+ await asRole(page, 'admin')
+ await page.goto('/teaching/physical-training')
+ await expect(page.getByTestId('teaching-task-center')).toBeVisible()
+ await expect(page.locator('.ttc-assignment-card').first()).toBeVisible()
+ await shot(page, info, '08-physical-api-task-list')
+ await page.goto(`/teaching/physical-training/preview/${task.id}`)
+ await expect(page.locator('.physical-offline-policy')).toContainText('不推进正式训练进度,不自动计分')
+ const video = page.locator('[data-physical-video="primary"]')
+ await expect(video).toBeVisible()
+ await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.readyState), { timeout: 45000 }).toBeGreaterThanOrEqual(2)
+ await video.evaluate((element: HTMLVideoElement) => { element.pause(); element.currentTime = 3 })
+ await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.seeking && element.readyState >= 2)).toBe(true)
+ await shot(page, info, '09-physical-offline-replay-boundary')
+ const duration = await video.evaluate((element: HTMLVideoElement) => element.duration)
+ expect(duration).toBeGreaterThan(45.2)
+ expect(duration).toBeLessThan(46)
+ await video.evaluate((element: HTMLVideoElement) => { element.currentTime = element.duration - 0.03 })
+ await expect(page.locator('[data-event-timeline] [data-physical-event][data-offline-replay="true"]')).toHaveCount(12)
+ await expect(page.locator('[data-event-timeline] [data-physical-event="offline-replay:replay-complete"]')).toBeVisible()
+ await shot(page, info, '10-physical-twelve-candidates')
+ evidence.push({ case: 'physical offline replay', readyState: await video.evaluate((element: HTMLVideoElement) => element.readyState), duration, candidates: 12, formalProgressAutomatic: false })
+ evidence.push({ case: 'physical retry evidence', runId: run.id, staleStatus: stale.status, final: facts[0].statusCode, attemptCount: facts[0].attemptCount, failureCount: facts[0].failureCount })
+})
diff --git a/tools/build-review-report.py b/tools/build-review-report.py
new file mode 100644
index 0000000..6d9119f
--- /dev/null
+++ b/tools/build-review-report.py
@@ -0,0 +1,99 @@
+"""Build the portable report from completed test evidence, without credentials."""
+import html
+import json
+import re
+import zipfile
+from pathlib import Path
+
+import markdown
+from markdown.extensions.toc import slugify_unicode
+
+ROOT = Path(__file__).resolve().parents[1]
+REPORT = ROOT / 'reports/review-20260905'
+
+
+def main():
+ live = json.loads((REPORT / 'live/results.json').read_text(encoding='utf-8'))['stats']
+ front = json.loads((REPORT / 'frontend-e2e/results.json').read_text(encoding='utf-8'))['stats']
+ cleanup = json.loads((REPORT / 'cleanup.json').read_text(encoding='utf-8'))
+ if any(value['unexpected'] or value['skipped'] or value['flaky'] for value in (live, front)):
+ raise RuntimeError('Final browser evidence is not fully passing')
+ if not all(cleanup['originalRowsUnchanged'].values()) or cleanup['remainingActiveTestUsers']:
+ raise RuntimeError('Cleanup evidence is not passing')
+ source = (REPORT / 'review-report.md').read_text(encoding='utf-8')
+ source = source.replace('状态:最终浏览器复核中', '状态:审查与验证完成,4 项功能差异和 1 项素材选择待确认')
+ summary = f'''| 验证 | 最终结果 | 证据与范围 |
+|---|---|---|
+| 后端全量测试 | 402/402 通过,0 失败、错误、跳过 | Gateway 1、Auth 86、Tran 315;含新增迟到事件回归 |
+| 后端构建 | 通过 | Java 17 / Maven;三个服务打包完成 |
+| 前端 Node 回归 | 25/25 通过 | 原 24 项加视频资产、候选与抓拍合同 |
+| 前端构建 | 通过 | vue-tsc、Vite、旧模型 iframe 校验 |
+| 真实服务 E2E(Edge) | {live['expected']}/4 通过,0 失败/跳过/重试 | {live['duration']/1000:.1f} 秒;开始时间 {live['startTime']}(UTC) |
+| 浏览器模拟接口回归 | {front['expected']}/15 通过,0 失败/跳过/重试 | {front['duration']/1000:.1f} 秒;编辑保存、状态组合、军工绿/暗色主题 |
+| 数据库迁移 | 通过 | 033、035;首次、重跑和指定中断点恢复;开发库检查值均为 0 |
+| 测试脚本类型检查、Git 空白检查 | 通过 | 两份专用 Playwright 测试与配置;API/Web/E2E 仓库 |
+
+最终通过结果来自各自一次完整运行,未启用自动重试。补入三次完整场景加载与截图后,真实虚拟用例曾超过原 120 秒测试总预算,断言和评定已完成但截图收尾超时;保留 [该轮记录](diagnostics/live-resource-wait-timeout.json),随后将该用例预算调到 240 秒,并在其他浏览器回归结束后重新完整运行。单次资源就绪仍有 60 秒上限。
+
+实装视频新增检查还发现本机 Playwright 自带 Chromium 不支持 H.264(`canPlayType` 返回空字符串);[失败轮次](diagnostics/live-chromium-h264-unsupported.json)与[Edge 播放探针](diagnostics/video-probe-msedge.json)保留。最终真实 E2E 使用已安装 Microsoft Edge,模拟接口回归继续使用 Chromium。另独立修复了实际素材与候选配置不匹配的 WEB-06;最终用例验证默认视频可播、片长覆盖末尾候选,并在结尾得到 12 条候选。测试开发阶段的权限夹具、请求拦截范围与 UI 选择器错误已修正,不计为产品缺陷。'''
+ source = source.replace('', summary)
+ cleared = ', '.join(f"{table.removeprefix('ut_teaching_')} {count}" for table, count in cleanup['deleted'].items() if count)
+ source = source.replace('', f"最终清理时间:`{cleanup['time']}`。删除本轮临时任务及关联记录({cleared}),测试账号已停用/软删除,会话已撤销,临时管理员密码文件已移除。四张原始教学表的既有行逐行对照均未变化,剩余活动测试账号为 0。审计与软删除记录按系统机制保留。见 [最终清理记录](cleanup.json);较早轮次见 `diagnostics/cleanup-*.json`。")
+ titles = {
+ '01-login-three-roles': '真实服务:登录仅展示教员、学员、管理员',
+ '02-virtual-formal-task-center': '真实服务:正式虚拟任务中心',
+ '03-virtual-1920': '真实服务:1920 宽度任务中心',
+ '04-teacher-content': '真实服务:教员训练编排列表已加载',
+ '05-virtual-started-no-exam-overlay': '真实服务:开始训练、模型就绪,无错误考核遮罩',
+ '06-virtual-all-steps-completed': '真实服务:8/8 步骤完成,真实模型已加载',
+ '07-virtual-reviewed': '真实服务:提交评定后刷新',
+ '08-physical-api-task-list': '真实服务:实装任务清单',
+ '09-physical-offline-replay-boundary': '真实页面:离线视频就绪,并标明不推进正式进度、不计分',
+ '10-physical-twelve-candidates': '真实页面:匹配视频到达末尾,12 条离线候选可达、等待复核',
+ '01-saved-step-move': '模拟接口:移动已保存步骤后保持正确顺序',
+ '01-saved-step-delete': '模拟接口:删除步骤后末步进度 100%',
+ '01-saved-step-duplicate': '模拟接口:复制步骤后重新编号',
+ '02-multichannel-virtual-save': '模拟接口:虚拟渠道配置保存与重开',
+ '03-multichannel-confrontation-save': '模拟接口:对抗渠道配置保存与重开',
+ '05-failed-before-retry': '模拟接口:失败步骤出现重试入口',
+ '06-failed-after-retry': '模拟接口:重试后尝试 2 次、失败 1 次',
+ '07-no-retry-SUBMITTED': '模拟接口:已提交不出现重试按钮',
+ '07-no-retry-TERMINATED': '模拟接口:已终止不出现重试按钮',
+ '08-visual-safety-military': '模拟接口:军工绿主题安全区,对比度 12.00',
+ '08-visual-safety-dark': '模拟接口:暗色主题安全区,对比度 15.30',
+ }
+ gallery = []
+ count = 0
+ for folder, caption in [('live', '真实服务与页面'), ('frontend-e2e', '模拟接口浏览器回归')]:
+ gallery.append(f'{caption} ')
+ for picture in sorted((REPORT / folder / 'screenshots').glob('*.png')):
+ if picture.name.startswith('failure-'):
+ continue
+ relative = picture.relative_to(REPORT).as_posix()
+ title = titles.get(picture.stem, '模拟接口:关闭遮罩状态矩阵 ' + picture.stem.replace('04-overlay-', ''))
+ gallery.append(f'
{html.escape(title)} ')
+ count += 1
+ gallery.append('
')
+ source = source.replace('', f'共 {count} 张最终 E2E 截图。\n\n' + '\n'.join(gallery))
+ (REPORT / 'review-report.md').write_text(source, encoding='utf-8')
+ style = '''*{box-sizing:border-box}body{margin:0;background:#edf2f7;color:#203248;font:16px/1.75 "Microsoft YaHei","Segoe UI",sans-serif}header{background:#0c2b47;color:white;padding:22px max(24px,calc((100vw - 1160px)/2))}header a{color:#c1e6ff;margin-right:24px}main{max-width:1200px;margin:24px auto 60px;padding:36px;background:white;border-radius:12px;box-shadow:0 6px 24px #1028400c}h1{font-size:30px;line-height:1.4;margin-top:0}h2{font-size:22px;margin:36px 0 14px;border-bottom:2px solid #dbe8f2;padding-bottom:8px}h3{font-size:18px}p{margin:13px 0}a{color:#126fba;text-decoration:none}a:hover{text-decoration:underline}table{border-collapse:collapse;width:100%;margin:18px 0;font-size:14px;table-layout:auto}th,td{padding:11px 13px;border:1px solid #dce5ed;text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#edf5fc}tr:nth-child(even){background:#f9fbfd}code{font:13px/1.5 Consolas,monospace;background:#edf2f7;padding:2px 5px;border-radius:4px;overflow-wrap:anywhere}pre{padding:18px;overflow:auto;background:#edf2f7;border-radius:8px}pre code{padding:0}img{max-width:100%;height:auto}details{padding:12px;background:#f7f9fc;margin:12px 0}.gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}figure{margin:0;border:1px solid #dce5ed;border-radius:8px;overflow:hidden;background:#f8fafc}figure img{display:block;width:100%;height:300px;object-fit:contain;background:#e8eef3}figcaption{padding:12px;font-size:14px;color:#37526b}footer{color:#657c91;font-size:13px;margin-top:36px}li{margin:8px 0}@media(max-width:760px){main{margin:0;padding:20px;border-radius:0}.gallery{grid-template-columns:1fr}table{display:block;overflow:auto}h1{font-size:25px}header{padding:16px}}@media print{body{background:white}main{margin:0;box-shadow:none;max-width:none}header{display:none}figure{break-inside:avoid}h2{break-after:avoid}}'''
+ for item in REPORT.glob('*.md'):
+ content = item.read_text(encoding='utf-8')
+ content = re.sub(r'\(([^()]+)\.md(#[^()]*)?\)', lambda m: '(' + m[1] + '.html' + (m[2] or '') + ')' if (REPORT / (m[1] + '.md')).exists() else m[0], content)
+ content = content.replace('', '')
+ body = markdown.markdown(content, extensions=['markdown.extensions.tables', 'markdown.extensions.fenced_code', 'markdown.extensions.md_in_html', 'markdown.extensions.toc'], extension_configs={'markdown.extensions.toc': {'slugify': slugify_unicode}})
+ document = f'{html.escape(item.stem)} · Code Review {body}2026-09-05 · 开发环境审查 · 图片可点击查看原尺寸 '
+ item.with_suffix('.html').write_text(document, encoding='utf-8')
+ if item.name == 'review-report.md':
+ (REPORT / 'index.html').write_text(document, encoding='utf-8')
+ archive = REPORT.parent / 'review-20260905.zip'
+ with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as zipped:
+ for item in REPORT.rglob('*'):
+ if not item.is_file() or item.name.startswith('failure-') or 'test-results' in item.parts or 'results' in item.parts:
+ continue
+ zipped.write(item, item.relative_to(REPORT.parent).as_posix())
+ print(json.dumps({'screenshots': count, 'live': live, 'frontend': front, 'archiveBytes': archive.stat().st_size}, ensure_ascii=False))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/check-review-report.mjs b/tools/check-review-report.mjs
new file mode 100644
index 0000000..51d137c
--- /dev/null
+++ b/tools/check-review-report.mjs
@@ -0,0 +1,34 @@
+import { chromium } from '@playwright/test'
+import fs from 'node:fs'
+import path from 'node:path'
+import { pathToFileURL, fileURLToPath } from 'node:url'
+
+const root = path.resolve('reports/review-20260905')
+const browser = await chromium.launch()
+try {
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
+ await page.goto(pathToFileURL(path.join(root, 'index.html')).href)
+ await page.locator('img').evaluateAll(elements => elements.forEach(image => image.loading = 'eager'))
+ await page.waitForFunction(() => Array.from(document.images).every(image => image.complete))
+ const imageState = await page.locator('img').evaluateAll(elements => elements.map(image => ({ source: image.getAttribute('src'), width: image.naturalWidth, height: image.naturalHeight })))
+ if (imageState.some(image => !image.width)) throw new Error('Report has missing images')
+ const links = await page.locator('a[href]').evaluateAll(elements => elements.map(element => element.href))
+ for (const link of links) {
+ if (!link.startsWith('file:')) continue
+ const url = new URL(link)
+ url.hash = ''
+ if (!fs.existsSync(fileURLToPath(url))) throw new Error(`Missing report link: ${url.pathname}`)
+ }
+ fs.mkdirSync(path.join(root, 'report-qa'), { recursive: true })
+ await page.screenshot({ path: path.join(root, 'report-qa/01-report-overview.png') })
+ await page.getByRole('heading', { name: '5. 截图证据', exact: true }).scrollIntoViewIfNeeded()
+ await page.screenshot({ path: path.join(root, 'report-qa/02-screenshot-gallery.png') })
+ await page.getByRole('heading', { name: '7. 待你确认的功能差异与素材选择', exact: true }).scrollIntoViewIfNeeded()
+ await page.screenshot({ path: path.join(root, 'report-qa/03-confirmation-items.png') })
+ const result = { imageCount: imageState.length, localLinksChecked: links.filter(link => link.startsWith('file:')).length, documentWidth: await page.evaluate(() => document.documentElement.scrollWidth), viewportWidth: 1440 }
+ if (result.documentWidth > 1440) throw new Error('Report overflows its viewport')
+ fs.writeFileSync(path.join(root, 'report-qa/verification.json'), JSON.stringify(result, null, 2))
+ console.log(JSON.stringify(result))
+} finally {
+ await browser.close()
+}
diff --git a/tools/review-api.mjs b/tools/review-api.mjs
new file mode 100644
index 0000000..1541ec3
--- /dev/null
+++ b/tools/review-api.mjs
@@ -0,0 +1,36 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
+export function localCredentials() {
+ const temporary = path.join(root, '../.codex-tmp/review-20260905/test-admin.json');
+ if (fs.existsSync(temporary)) {
+ const { username, password } = JSON.parse(fs.readFileSync(temporary, 'utf8'));
+ return { username, password };
+ }
+ const raw = fs.readFileSync(path.join(root, '账号.MD'), 'utf8').replace(/^\uFEFF/, '');
+ const lines = raw.split(/\r?\n/).map(x => x.trim()).filter(Boolean);
+ const values = lines.map(line => line.replace(/^.*?[::]\s*/, '').trim());
+ if (values.length < 2 || !values[0] || !values[1]) throw new Error('Local credential file has an unsupported format');
+ return { username: values[0], password: values[1] };
+}
+export async function api(pathname, token, body, method = body === undefined ? 'GET' : 'POST') {
+ const headers = { 'Content-Type': 'application/json' };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ if (body?.commandId) headers['Idempotency-Key'] = body.commandId;
+ const response = await fetch(`http://127.0.0.1:6180${pathname}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
+ const value = await response.json();
+ return { status: response.status, data: value.data, message: value.message };
+}
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ const result = await api('/api/auth/v1/auth/login', null, { ...localCredentials(), roleCode: 'admin', rememberMe: false });
+ console.log('login status', result.status);
+ if (!result.data?.accessToken) throw new Error('Local account authentication failed');
+ const token = result.data.accessToken;
+ try {
+ const roles = await api('/api/auth/v1/roles?size=100', token);
+ console.log('roles', (roles.data?.records ?? roles.data ?? []).map(x => ({ id: x.id, code: x.code })));
+ const tasks = await api('/api/tran/v1/teaching/assignments?size=100', token);
+ console.log('tasks', (tasks.data?.records ?? []).map(x => ({ id: x.id, code: x.code, channel: x.channel, status: x.status, kind: x.assignmentKind })));
+ } finally { await api('/api/auth/v1/auth/logout', token, {}); }
+}
diff --git a/tools/review-db.py b/tools/review-db.py
new file mode 100644
index 0000000..732684d
--- /dev/null
+++ b/tools/review-db.py
@@ -0,0 +1,197 @@
+"""Development review support: credentials stay in the local YAML, never in output."""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+from urllib.parse import urlparse
+import datetime
+import psycopg
+from psycopg.rows import dict_row
+from psycopg import sql
+import yaml
+import sqlparse
+import secrets
+import bcrypt
+
+ROOT = Path(__file__).resolve().parents[2]
+API = ROOT / 'unreal_tran_api'
+WORK = ROOT.parent / '.codex-tmp' / 'review-20260905'
+REPORT = ROOT / 'ute2e' / 'reports' / 'review-20260905'
+MIGRATIONS = ['20260904_033_teaching_command_receipt.sql', '20260905_035_teaching_step_run_kingbase_constraints.sql']
+PARENTS = ['ut_teaching_assignment', 'ut_teaching_run', 'ut_teaching_run_member', 'ut_teaching_event']
+TABLES = ['ut_teaching_command_receipt', 'ut_teaching_step_run']
+
+def connect():
+ conf = yaml.safe_load((API / 'config/application-auth-local.yml').read_text(encoding='utf-8-sig'))
+ source = conf['spring']['datasource']
+ # Existing configuration can wrap the JDBC properties in Druid.
+ if 'url' not in source:
+ source = source['druid']
+ url = urlparse(source['url'].replace('jdbc:kingbase8:', 'postgresql:', 1))
+ connection = psycopg.connect(host=url.hostname, port=url.port, dbname=url.path.lstrip('/'), user=source['username'], password=source['password'], row_factory=dict_row, autocommit=True)
+ with connection.cursor() as cursor:
+ cursor.execute('select current_database() as db, current_schema() as schema')
+ identity = cursor.fetchone()
+ if identity != {'db': 'unreal_tran', 'schema': 'public'}:
+ raise RuntimeError('Unexpected development database/schema; stopped')
+ return connection
+
+def apply(cursor, name):
+ source = (API / 'database' / name).read_text(encoding='utf-8-sig')
+ results = []
+ for statement in sqlparse.split(source):
+ cursor.execute(statement)
+ if cursor.description:
+ results.extend(cursor.fetchall())
+ if any(value != 0 for row in results for value in row.values()):
+ raise RuntimeError(f'Migration post-check did not return zero: {name}')
+ return {'file': name, 'sha256': hashlib.sha256((API / 'database' / name).read_bytes()).hexdigest(), 'checks': results}
+
+def isolate(cursor, schema, interrupt=False):
+ if schema not in {'review_20260905_first', 'review_20260905_resume'}:
+ raise RuntimeError('Unapproved temporary schema')
+ cursor.execute(f'DROP SCHEMA IF EXISTS {schema} CASCADE')
+ cursor.execute(f'CREATE SCHEMA {schema}')
+ cursor.execute(f'SET search_path TO {schema}')
+ cursor.execute('CREATE TABLE ut_teaching_assignment(id BIGINT PRIMARY KEY); CREATE TABLE ut_teaching_run(id BIGINT PRIMARY KEY, assignment_id BIGINT NOT NULL); CREATE TABLE ut_teaching_run_member(id BIGINT PRIMARY KEY, run_id BIGINT NOT NULL); CREATE TABLE ut_teaching_event(id BIGINT PRIMARY KEY, run_id BIGINT NOT NULL, assignment_id BIGINT NOT NULL)')
+ if interrupt:
+ # Simulate a committed interruption after the first table / first unique index.
+ for name, marker in [(MIGRATIONS[0], "COMMENT='教学写命令幂等回执';"), (MIGRATIONS[1], '$$;')]:
+ source = (API / 'database' / name).read_text(encoding='utf-8-sig')
+ partial = source[:source.index(marker) + len(marker)]
+ for statement in sqlparse.split(partial + '\nCOMMIT;'):
+ cursor.execute(statement)
+ first = [apply(cursor, name) for name in MIGRATIONS]
+ repeat = [apply(cursor, name) for name in MIGRATIONS]
+ cursor.execute('SET search_path TO public')
+ cursor.execute(f'DROP SCHEMA {schema} CASCADE')
+ return {'interrupted': interrupt, 'first': first, 'repeat': repeat}
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('action', choices=['prepare', 'check', 'test-admin', 'cleanup', 'clone-physical'])
+ parser.add_argument('--code')
+ args = parser.parse_args()
+ WORK.mkdir(parents=True, exist_ok=True)
+ REPORT.mkdir(parents=True, exist_ok=True)
+ with connect() as db, db.cursor() as cursor:
+ if args.action == 'cleanup':
+ backup = json.loads((WORK / 'database-before.json').read_text(encoding='utf-8'))
+ original_ids = [row['id'] for row in backup['tables']['ut_teaching_assignment']]
+ cursor.execute("SELECT id,assignment_code FROM ut_teaching_assignment WHERE assignment_code LIKE 'UTE2E-REVIEW-%%' AND NOT (id = ANY(%s))", (original_ids,))
+ temporary = cursor.fetchall()
+ task_ids = [row['id'] for row in temporary]
+ result = {'tasks': temporary, 'deleted': {}, 'time': datetime.datetime.now().isoformat()}
+ with db.transaction():
+ if task_ids:
+ cursor.execute('SELECT id FROM ut_teaching_run WHERE assignment_id = ANY(%s)', (task_ids,))
+ run_ids = [row['id'] for row in cursor.fetchall()]
+ # Every affected row is tied to a test task absent from the recovery snapshot.
+ for table, column, ids in [
+ ('ut_teaching_step_run','assignment_id',task_ids),
+ ('ut_teaching_command_receipt','assignment_id',task_ids),
+ ('ut_teaching_xr_session','assignment_id',task_ids),
+ ('ut_assistance_alert','assignment_id',task_ids),
+ ('ut_assistance_evaluation','assignment_id',task_ids),
+ ('ut_data_observation','run_id',run_ids),
+ ('ut_teaching_event','assignment_id',task_ids),
+ ('ut_teaching_intervention','assignment_id',task_ids),
+ ('ut_teaching_assignment_fault','assignment_id',task_ids),
+ ('ut_teaching_run_member','run_id',run_ids),
+ ('ut_teaching_run','assignment_id',task_ids),
+ ('ut_teaching_member','assignment_id',task_ids),
+ ('ut_teaching_assignment','id',task_ids),
+ ]:
+ cursor.execute(sql.SQL('DELETE FROM {} WHERE {} = ANY(%s)').format(sql.Identifier(table),sql.Identifier(column)), (ids,))
+ result['deleted'][table] = cursor.rowcount
+ since = int(datetime.datetime.fromisoformat(backup['time']).timestamp())
+ cursor.execute("SELECT id FROM ut_sys_user WHERE remark LIKE 'UTE2E-REVIEW-%%' AND username LIKE 'rv_%%' AND add_time >= %s", (since,))
+ user_ids = [row['id'] for row in cursor.fetchall()]
+ temp_admin = WORK / 'test-admin.json'
+ if temp_admin.exists():
+ admin = json.loads(temp_admin.read_text(encoding='utf-8'))
+ cursor.execute('SELECT id FROM ut_sys_user WHERE id=%s AND username=%s AND is_protected=0', (admin['id'],admin['username']))
+ matched = cursor.fetchone()
+ if matched:
+ user_ids.append(matched['id'])
+ cursor.execute("UPDATE ut_auth_session SET is_revoked=1,revoked_time=UNIX_TIMESTAMP(),revoke_reason='review cleanup' WHERE user_id = ANY(%s)", (user_ids,))
+ for table in ['ut_sys_user_role','ut_auth_user_credential','ut_sys_user']:
+ column = 'id' if table == 'ut_sys_user' else 'user_id'
+ extra = ', status=0, token_version=token_version+1' if table == 'ut_sys_user' else ''
+ cursor.execute(sql.SQL('UPDATE {} SET is_delete=1,delete_token=id' + extra + ' WHERE {} = ANY(%s) AND is_delete=0').format(sql.Identifier(table),sql.Identifier(column)), (user_ids,))
+ result['testUsersDisabled'] = user_ids
+ result['originalRowsUnchanged'] = {}
+ for table, rows in backup['tables'].items():
+ ids = [row['id'] for row in rows]
+ cursor.execute(sql.SQL('SELECT * FROM {} WHERE id = ANY(%s)').format(sql.Identifier(table)), (ids,))
+ after = cursor.fetchall()
+ normalize = lambda values: json.dumps(sorted(values,key=lambda row:row['id']),sort_keys=True,default=str)
+ result['originalRowsUnchanged'][table] = normalize(rows) == normalize(after)
+ cursor.execute("SELECT COUNT(*) AS count FROM ut_sys_user WHERE username LIKE 'rv_%%' AND remark LIKE 'UTE2E-REVIEW-%%' AND is_delete=0 AND add_time >= %s", (since,))
+ result['remainingActiveTestUsers'] = cursor.fetchone()['count']
+ (REPORT / 'cleanup.json').write_text(json.dumps(result,ensure_ascii=False,indent=2,default=str),encoding='utf-8')
+ if not all(result['originalRowsUnchanged'].values()) or result['remainingActiveTestUsers'] != 0:
+ raise RuntimeError('Cleanup verification failed; inspect cleanup.json before continuing')
+ if temp_admin.exists():
+ temp_admin.unlink()
+ print(json.dumps(result,ensure_ascii=True))
+ return
+ if args.action == 'clone-physical':
+ if not args.code or not args.code.startswith('UTE2E-REVIEW-') or not args.code.endswith('-PHYSICAL'):
+ raise RuntimeError('Invalid isolated fixture prefix')
+ cursor.execute("SELECT * FROM ut_teaching_assignment WHERE assignment_code='UAT-TEACH-PHYSICAL-001' AND is_delete=0")
+ record = cursor.fetchone()
+ record.pop('id')
+ record.update(assignment_code=args.code, assignment_name=args.code, audience_type='COMMON', collaboration_mode='INDIVIDUAL', status_code='PUBLISHED', data_version=0, schedule_start_at=None, due_at=None, digital_human_allowed=0)
+ definition = json.loads(record['definition_snapshot'])
+ steps = definition['steps']
+ for index, step in enumerate(steps):
+ step['physicalEventRule'] = {'source':'MANUAL','eventType':'review.step.confirmed','progressPercent':round((index+1)*100/len(steps))}
+ definition['modeConfig'] = {'type':'PHYSICAL','manualConfirmAllowed':True}
+ record['definition_snapshot'] = json.dumps(definition,ensure_ascii=False,separators=(',',':'))
+ record['definition_checksum'] = hashlib.sha256(record['definition_snapshot'].encode()).hexdigest()
+ cursor.execute(sql.SQL('INSERT INTO ut_teaching_assignment ({}) VALUES ({}) RETURNING id').format(sql.SQL(',').join(map(sql.Identifier, record)),sql.SQL(',').join(sql.Placeholder() for _ in record)),list(record.values()))
+ result = cursor.fetchone()
+ print(json.dumps({'id':str(result['id']),'code':args.code,'version':0}))
+ return
+ if args.action == 'test-admin':
+ username = 'rv_admin_' + secrets.token_hex(4)
+ password = 'Rv!' + secrets.token_hex(7)
+ with db.transaction():
+ cursor.execute("SELECT id,department_id FROM ut_sys_user WHERE is_delete=0 AND is_protected=1 LIMIT 1")
+ department = cursor.fetchone()['department_id']
+ cursor.execute("SELECT id FROM ut_sys_role WHERE role_code='admin' AND status=1 AND is_delete=0")
+ role = cursor.fetchone()['id']
+ cursor.execute("INSERT INTO ut_sys_user(username,username_normalized,display_name,department_id,default_role_id,status,is_protected,must_change_password,remark) VALUES(%s,%s,%s,%s,%s,1,0,0,%s) RETURNING id", (username,username,'UTE2E-REVIEW-ADMIN',department,role,'Temporary review administrator'))
+ user_id = cursor.fetchone()['id']
+ cursor.execute('INSERT INTO ut_sys_user_role(user_id,role_id) VALUES(%s,%s)', (user_id,role))
+ digest = '{bcrypt}' + bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
+ cursor.execute('INSERT INTO ut_auth_user_credential(user_id,password_hash) VALUES(%s,%s)', (user_id,digest))
+ (WORK / 'test-admin.json').write_text(json.dumps({'id':user_id,'username':username,'password':password}),encoding='utf-8')
+ print('Created isolated temporary review administrator; credentials stored only in local scratch file')
+ return
+ if args.action == 'prepare':
+ cursor.execute("SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_name = ANY(%s)", (TABLES,))
+ if cursor.fetchall():
+ raise RuntimeError('Target tables already exist; inspect instead of reapplying')
+ backup = {'time': datetime.datetime.now().isoformat(), 'tables': {}}
+ for name in PARENTS:
+ cursor.execute(f'SELECT * FROM {name}')
+ backup['tables'][name] = cursor.fetchall()
+ cursor.execute("SELECT tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='public' AND tablename = ANY(%s)", (PARENTS,))
+ backup['indexes'] = cursor.fetchall()
+ cursor.execute("SELECT table_name,column_name,data_type,column_default,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name = ANY(%s) ORDER BY table_name,ordinal_position", (PARENTS,))
+ backup['columns'] = cursor.fetchall()
+ (WORK / 'database-before.json').write_text(json.dumps(backup, ensure_ascii=False, indent=2, default=str), encoding='utf-8')
+ result = {'environment': 'development unreal_tran/public', 'time': backup['time'], 'backup': str(WORK / 'database-before.json'), 'validation': []}
+ for schema, interrupted in [('review_20260905_first', False), ('review_20260905_resume', True)]:
+ result['validation'].append(isolate(cursor, schema, interrupted))
+ result['executed'] = [apply(cursor, name) for name in MIGRATIONS]
+ (REPORT / 'database-migration.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
+ print('PASS: recovery snapshot, isolated first/repeat/interruption recovery, development migrations 033/035')
+ for name in TABLES:
+ cursor.execute(f'SELECT COUNT(*) AS count FROM {name}')
+ print(name, cursor.fetchone())
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/review-video-probe.mjs b/tools/review-video-probe.mjs
new file mode 100644
index 0000000..b69695d
--- /dev/null
+++ b/tools/review-video-probe.mjs
@@ -0,0 +1,27 @@
+import { chromium } from '@playwright/test'
+import fs from 'node:fs'
+import { api, localCredentials } from './review-api.mjs'
+const login = await api('/api/auth/v1/auth/login', undefined, { ...localCredentials(), roleCode: 'admin', rememberMe: false })
+if (login.status !== 200) throw new Error('Probe login failed')
+const token = login.data.accessToken
+const manifest = JSON.parse(fs.readFileSync('reports/review-20260905/live/cleanup-manifest.json', 'utf8'))
+const task = manifest.tasks.find(x => x.code.endsWith('-PHYSICAL'))
+const browser = await chromium.launch({ channel: process.env.REVIEW_BROWSER_CHANNEL || undefined })
+try {
+ const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
+ await context.addInitScript(value => sessionStorage.setItem('unreal-tran:web:access-token:v1', value), token)
+ const page = await context.newPage()
+ const events = []
+ page.on('response', response => { if (response.url().includes('.mp4')) events.push({ path: new URL(response.url()).pathname, status: response.status(), type: response.headers()['content-type'] }) })
+ page.on('requestfailed', request => { if (request.url().includes('.mp4')) events.push({ path: new URL(request.url()).pathname, failed: request.failure()?.errorText }) })
+ await page.goto(`http://127.0.0.1:6180/teaching/physical-training/preview/${task.id}`)
+ await page.locator('[data-physical-video="primary"]').waitFor()
+ await page.waitForTimeout(10000)
+ const video = await page.locator('[data-physical-video="primary"]').evaluate(element => ({ source: element.getAttribute('src'), currentSrc: element.currentSrc, readyState: element.readyState, networkState: element.networkState, error: element.error ? {code: element.error.code, message: element.error.message} : null, mp4: element.canPlayType('video/mp4; codecs="avc1.42E01E"'), webm: element.canPlayType('video/webm; codecs="vp9"') }))
+ const result = { video, events }
+ fs.writeFileSync(`reports/review-20260905/diagnostics/video-probe-${process.env.REVIEW_BROWSER_CHANNEL || 'chromium'}.json`, JSON.stringify(result, null, 2))
+ console.log(JSON.stringify(result))
+} finally {
+ await browser.close()
+ await api('/api/auth/v1/auth/logout', token, {})
+}