Parcourir la source

test(training): add scene, mode and editor regression coverage

Record live virtual, physical and confrontation checks with screenshots; cover scene loading, cover persistence, metadata compatibility, flow colors and return-to-list tab behavior. Preserve known runtime integration limitations in the reports.
main
leiyun il y a 2 semaines
Parent
révision
530de6e566
100 fichiers modifiés avec 12537 ajouts et 4 suppressions
  1. +1
    -0
      .gitignore
  2. +1368
    -0
      reports/training-colors-20260906/evidence.json
  3. +1
    -0
      reports/training-colors-20260906/index.html
  4. BIN
     
  5. BIN
     
  6. BIN
     
  7. BIN
     
  8. BIN
     
  9. BIN
     
  10. BIN
     
  11. BIN
     
  12. +40
    -0
      reports/training-live-20260905/error-evidence.json
  13. +183
    -0
      reports/training-live-20260905/final-evidence.json
  14. +869
    -0
      reports/training-live-20260905/index.html
  15. +253
    -0
      reports/training-live-20260905/readonly-evidence.json
  16. +616
    -0
      reports/training-live-20260905/report.md
  17. BIN
     
  18. BIN
     
  19. BIN
     
  20. BIN
     
  21. BIN
     
  22. BIN
     
  23. BIN
     
  24. BIN
     
  25. BIN
     
  26. BIN
     
  27. BIN
     
  28. BIN
     
  29. BIN
     
  30. BIN
     
  31. BIN
     
  32. BIN
     
  33. BIN
     
  34. BIN
     
  35. BIN
     
  36. BIN
     
  37. BIN
     
  38. BIN
     
  39. BIN
     
  40. BIN
     
  41. +1177
    -0
      reports/training-modes-20260905/api-evidence.json
  42. +635
    -0
      reports/training-modes-20260905/evidence.json
  43. +2835
    -0
      reports/training-modes-20260905/index.html
  44. +150
    -0
      reports/training-modes-20260905/list-api-latency.json
  45. +262
    -0
      reports/training-modes-20260905/readonly-evidence.json
  46. +1989
    -0
      reports/training-modes-20260905/report.md
  47. BIN
     
  48. BIN
     
  49. BIN
     
  50. BIN
     
  51. BIN
     
  52. BIN
     
  53. BIN
     
  54. BIN
     
  55. BIN
     
  56. BIN
     
  57. BIN
     
  58. BIN
     
  59. BIN
     
  60. BIN
     
  61. BIN
     
  62. BIN
     
  63. BIN
     
  64. BIN
     
  65. BIN
     
  66. BIN
     
  67. BIN
     
  68. BIN
     
  69. BIN
     
  70. BIN
     
  71. BIN
     
  72. BIN
     
  73. BIN
     
  74. BIN
     
  75. BIN
     
  76. BIN
     
  77. +76
    -0
      reports/training-modes-20260905/studio-regression/html/index.html
  78. +332
    -0
      reports/training-modes-20260905/studio-regression/results.json
  79. BIN
     
  80. BIN
     
  81. BIN
     
  82. BIN
     
  83. BIN
     
  84. BIN
     
  85. BIN
     
  86. BIN
     
  87. +612
    -0
      reports/training-return-tabs-20260906/evidence.json
  88. +463
    -0
      reports/training-return-tabs-20260906/index.html
  89. BIN
     
  90. BIN
     
  91. BIN
     
  92. BIN
     
  93. BIN
     
  94. BIN
     
  95. BIN
     
  96. BIN
     
  97. +8
    -4
      tests/content-training-studio.spec.ts
  98. +237
    -0
      tools/build-training-live-report.mjs
  99. +194
    -0
      tools/build-training-modes-report.mjs
  100. +236
    -0
      tools/training-cover.test.mjs

+ 1
- 0
.gitignore Voir le fichier

@@ -3,6 +3,7 @@ node_modules/
.env.*
!.env.example
reports/**/build.log
reports/**/.last-run.json
artifacts/html-report/
artifacts/test-results/
artifacts/trace-inspect-*/


+ 1368
- 0
reports/training-colors-20260906/evidence.json
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 1
- 0
reports/training-colors-20260906/index.html Voir le fichier

@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>高级编排配色修复</title><style>body{margin:0;background:#f2f6f7;color:#17344f;font:16px/1.7 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1200px;margin:auto;padding:28px}section{background:white;border:1px solid #d4e1e7;border-radius:12px;padding:24px;margin:18px 0}h1{margin:0}h2{font-size:20px}img{width:100%;display:block;border:1px solid #d4e1e7}a{color:#126b5b}li{margin:6px 0}code{overflow-wrap:anywhere}summary{cursor:pointer}p{overflow-wrap:anywhere}@media(max-width:600px){main{padding:12px}section{padding:16px}}</style><main><h1>高级编排配色修复</h1><p>2026-09-06 · 浏览器复核 9 项通过 · 构建通过</p><section><h2>修复内容</h2><p>原有 tone-* 强制背景覆盖节点白底,造成蓝绿底上的灰字难以辨认。现恢复浅底深字,类型色用于图标与边框;同时提高节点说明、节点库和时间轴文字对比。</p><p>当前检查的文字样式最低对比度 4.92:1。修改仅涉及 training-flow-authoring.css。</p><p>仅高级编排CSS。浏览器禁止内容业务写请求,节点添加/排布仅为本地样式检查。编辑器沿用固定浅色画布,分别切换四种宿主主题属性和系统暗色偏好验证。</p></section><section><h2>修复后:实际狐狸项目</h2><a href="screenshots/lan-after.png"><img src="screenshots/lan-after.png" alt="修复后的高级编排"></a><p>局域网实际页面;截图时在本地使用自动排布,不写入训练数据。</p></section><section><h2>修复前</h2><a href="screenshots/before.png"><img src="screenshots/before.png" alt="原有彩底灰字"></a></section><section><h2>全部类型及宿主主题检查</h2><details open><summary>military</summary><a href="screenshots/theme-military.png"><img loading="lazy" src="screenshots/theme-military.png" alt="military主题下全部节点类型"></a></details><details><summary>technology</summary><a href="screenshots/theme-technology.png"><img loading="lazy" src="screenshots/theme-technology.png" alt="technology主题下全部节点类型"></a></details><details><summary>graphite</summary><a href="screenshots/theme-graphite.png"><img loading="lazy" src="screenshots/theme-graphite.png" alt="graphite主题下全部节点类型"></a></details><details><summary>dark</summary><a href="screenshots/theme-dark.png"><img loading="lazy" src="screenshots/theme-dark.png" alt="dark主题下全部节点类型"></a></details></section><section><h2>验证记录</h2><ul><li>当前图节点、说明、图标及时间轴前景对比:通过</li><li>全部六类节点色调(本地增加节点,未保存):通过</li><li>宿主配色 military 下高级编排可读性:通过</li><li>宿主配色 technology 下高级编排可读性:通过</li><li>宿主配色 graphite 下高级编排可读性:通过</li><li>宿主配色 dark 下高级编排可读性:通过</li><li>系统暗色偏好下高级编排可读性:通过</li><li>选中、自动排布、时间轴播放/停止:通过</li><li>局域网6180真实页面及6100代理:通过</li><li>pnpm run build:通过(类型检查、Vite 构建与 iframe 分发校验)</li><li>控制台未发生页面异常</li></ul><p><a href="evidence.json">查看检查数据</a></p></section></main></html>









+ 40
- 0
reports/training-live-20260905/error-evidence.json Voir le fichier

@@ -0,0 +1,40 @@
{
"title": "真实浏览器异常分支验证(定向注入HTTP失败)",
"status": "PASS",
"checks": [
{
"title": "封面上传503:训练内容仍保存并保留原封面",
"status": "PASS",
"details": {
"beforeVersion": 6,
"afterVersion": 7,
"sameCover": true,
"stepCount": 2
},
"screenshot": "screenshots/12-cover-failure-preserves-old.png"
},
{
"title": "保存409:不覆盖服务端内容和封面,并保持未保存提示",
"status": "PASS",
"details": {
"version": 7,
"putAttempts": 1,
"sameCover": true
},
"screenshot": "screenshots/13-save-conflict.png"
},
{
"title": "切换场景下载失败:保持原场景、绑定期间禁止保存发布、零项目写入",
"status": "PASS",
"details": {
"retainedSceneProjectId": "160",
"blockedDownloads": 1,
"putCount": 0
},
"screenshots": [
"screenshots/14-binding-disables-save.png",
"screenshots/15-binding-failure-retains-scene.png"
]
}
]
}

+ 183
- 0
reports/training-live-20260905/final-evidence.json Voir le fichier

@@ -0,0 +1,183 @@
{
"title": "真实UI训练编排与发布实测",
"status": "PASS",
"finishedAt": "2026-09-05T15:23:29.909Z",
"projects": [
{
"id": "161",
"name": "实测训练-狐狸野外巡检-已验证-20260905",
"status": "PUBLISHED",
"currentVersionId": "213",
"publishedVersionId": "213",
"sceneProjectId": "160",
"sceneVersionId": "210",
"stepCount": 2,
"totalScore": 100,
"cover": {
"assetId": "2098",
"sha256": "7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267",
"sizeBytes": 52518,
"image": "screenshots/fox-final-cover.jpg"
}
},
{
"id": "162",
"name": "实测训练-牛奶卡车进场检查-已验证-20260905",
"status": "PUBLISHED",
"currentVersionId": "214",
"publishedVersionId": "214",
"sceneProjectId": "159",
"sceneVersionId": "209",
"stepCount": 2,
"totalScore": 100,
"cover": {
"assetId": "2100",
"sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
"sizeBytes": 42163,
"image": "screenshots/truck-final-cover.jpg"
}
}
],
"checks": [
{
"title": "使用真实已发布场景创建、编排并保留两条训练,列表显示截图封面",
"status": "PASS",
"screenshots": [
"screenshots/02-published-scenes.png",
"screenshots/20-training-list-final.png"
],
"details": {
"projects": [
{
"id": "161",
"status": "PUBLISHED",
"sceneProjectId": "160",
"sceneVersionId": "210",
"stepCount": 2,
"totalScore": 100
},
{
"id": "162",
"status": "PUBLISHED",
"sceneProjectId": "159",
"sceneVersionId": "209",
"stepCount": 2,
"totalScore": 100
}
]
}
},
{
"status": "PASS",
"title": "狐狸两步编排、复制删除、排序撤销、高级时间轴、项目名称保存及发布",
"id": "161",
"name": "实测训练-狐狸野外巡检-已验证-20260905",
"publishedVersionId": "211",
"coverId": "2095",
"steps": [
{
"title": "识别狐狸巡检目标",
"target": "scene:OBJ-1788605916565-6",
"stepCode": "VIRTUAL-S01",
"actionCode": "VIRTUAL-A01",
"score": 50
},
{
"title": "检查狐狸外观并确认状态",
"target": "scene:OBJ-1788605916565-6",
"stepCode": "VIRTUAL-S02",
"actionCode": "VIRTUAL-A02",
"score": 50
}
],
"errors": [],
"failed": [],
"screenshots": [
"screenshots/09-fox-advanced-timeline.png",
"screenshots/10-fox-preflight.png"
]
},
{
"status": "PASS",
"title": "狐狸三维点击选目标、调整视角并截图发布、异常恢复",
"id": "161",
"publishedVersionId": "213",
"pick": {
"x": 1065.015625,
"y": 750.5,
"targetId": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26"
},
"cameraBefore": [
-0.04179225073787585,
6.099634646023559,
17.355036987378313
],
"cameraAfter": [
-0.9408733025235376,
5.534741198620379,
15.26632686671827
],
"cover": {
"id": "2098",
"sizeBytes": 52518,
"sha256": "7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267",
"equalsCurrentViewport": true
},
"errors": [],
"screenshots": [
"screenshots/16-fox-mesh-picking.png",
"screenshots/17-fox-final-published.png"
]
},
{
"status": "PASS",
"title": "卡车三维拾取、项目名称及任务说明保存、隐藏外壳视角截图发布",
"id": "162",
"name": "实测训练-牛奶卡车进场检查-已验证-20260905",
"publishedVersionId": "214",
"coverId": "2100",
"pick": {
"x": 1065.015625,
"y": 750.5,
"targetId": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee"
},
"stepCount": 2,
"totalScore": 100,
"errors": [],
"screenshots": [
"screenshots/18-truck-mesh-picking.png",
"screenshots/19-truck-final-published.png"
]
}
],
"commands": [
{
"command": "pnpm run build",
"status": "PASS",
"details": "vue-tsc、Vite构建、iframe产物一致性验证通过;有既有chunk体积和DRACO路径构建提示。"
},
{
"command": "pnpm run test:training-legacy",
"status": "PASS",
"details": "训练原型迁移和野外场景共享定义两个合同均通过。"
},
{
"command": "node --test tools/training-cover.test.mjs tools/training-scene-api.test.mjs tools/training-model-placement.test.mjs tools/training-scene-targets.test.mjs tools/training-project-metadata.test.mjs",
"status": "PASS",
"details": "48/48通过。首次合跑1个测试夹具缺renderSaveState回调,修复夹具后全量复跑通过。"
},
{
"command": "node tools/training-scene-visibility.e2e.mjs",
"status": "PASS",
"details": "独立浏览器只读重开验证,最终版本变更后再次运行;细项以readonly-evidence.json为准。"
}
],
"limitations": [
"本次未创建教学下发任务,也未验证学员端完成训练;现有“预览训练”入口仍提示先发布再从虚实训练下发任务。",
"未配置讲解音频和必选安全前置条件,编辑器校验给出非阻断提醒;两条为轻量素材功能测试,不作为正式维修教学内容。",
"狐狸源场景实际已更新至V3(版本210),使用目录当时的真实发布版;未修改两个源场景或源模型。",
"训练封面截取保存时的当前视角;卡车封面通过现有“隐藏外壳”按钮隐藏围护结构,立柱仍遮挡部分车身。这是继承的源场景及视角,不是模型未加载;重新打开时仍按固定场景还原相机。",
"本轮没有新增需确认的功能变更,截图封面按本次明确授权实现;未改变数据库结构,无DDL变更。"
],
"pageErrors": []
}

+ 869
- 0
reports/training-live-20260905/index.html
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 253
- 0
reports/training-live-20260905/readonly-evidence.json Voir le fichier

@@ -0,0 +1,253 @@
{
"title": "独立浏览器重开、真实模型可见性及封面验证",
"status": "PASS",
"startedAt": "2026-09-05T15:23:16.479Z",
"checks": [
{
"title": "狐狸野外巡检:发布重开、模型实际可见、截图封面",
"status": "PASS",
"screenshots": [
"screenshots/fox-independent-reopen.png",
"screenshots/fox-final-cover.jpg"
],
"details": {
"state": "ready",
"meshes": 1,
"visibleMeshes": 1,
"skinnedMeshes": 1,
"position": [
-7.5,
0.10930174852949677,
0.3440109341705351
],
"scale": [
0.03616375932570189,
0.03616375932570189,
0.03616375932570189
],
"finiteBounds": true,
"size": [
0.9658796100519353,
2.811899945787403,
6
],
"camera": {
"position": [
-0.04179225073787585,
6.099634646023559,
17.355036987378313
],
"target": [
-7.512232860974557,
1.4059499759307126,
-2.2204460492503126e-16
]
},
"sample": {
"width": 320,
"height": 180,
"changedPixels": 827
},
"scene": {
"projectId": "160",
"versionId": "210"
},
"targets": [
{
"id": "scene:OBJ-1788605873329-2",
"name": "维修装备主体 02",
"source": "scene"
},
{
"id": "scene:OBJ-1788605916565-6",
"name": "狐狸野外巡检目标",
"source": "scene"
},
{
"id": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26",
"name": "fox",
"source": "scene"
}
],
"steps": [
{
"title": "识别狐狸巡检目标",
"targetId": "scene:OBJ-1788605916565-6",
"weight": 50
},
{
"title": "检查狐狸外观并确认状态",
"targetId": "scene:OBJ-1788605916565-6",
"weight": 50
}
],
"cover": {
"assetId": "2098",
"bytes": 52518,
"sha256": "7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267",
"width": 628,
"height": 640
},
"pageErrors": [],
"blockedWrites": []
}
},
{
"title": "牛奶卡车进场检查:发布重开、模型实际可见、截图封面",
"status": "PASS",
"screenshots": [
"screenshots/truck-independent-reopen.png",
"screenshots/truck-final-cover.jpg"
],
"details": {
"state": "ready",
"meshes": 5,
"visibleMeshes": 5,
"skinnedMeshes": 0,
"position": [
4.2,
0.20617168998977797,
-0.4356763541543951
],
"scale": [
1.2323086149245381,
1.2323086149245381,
1.2323086149245381
],
"finiteBounds": true,
"size": [
3.4406057210321332,
3.390913184034896,
6.000000000000001
],
"camera": {
"position": [
10.565439077211714,
5.0456876852867705,
7.37050840519251
],
"target": [
4.2,
1.695456592017448,
0
]
},
"sample": {
"width": 320,
"height": 180,
"changedPixels": 11669
},
"scene": {
"projectId": "159",
"versionId": "209"
},
"targets": [
{
"id": "scene:OBJ-1788605199171-2",
"name": "牛奶卡车检验主装备",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-1ba581e6",
"name": "Yup2Zup",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-004dec41",
"name": "Cesium_Milk_Truck",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee",
"name": "Cesium_Milk_Truck_1",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-607e4d81",
"name": "Cesium_Milk_Truck_2",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-5d7e48c8",
"name": "Cesium_Milk_Truck_3",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-5e7e4a5b",
"name": "Node",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-ce2feac4",
"name": "Wheels",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-637e523a",
"name": "Node001",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-f417f82d",
"name": "Wheels001",
"source": "scene"
}
],
"steps": [
{
"title": "识别牛奶卡车检验目标",
"targetId": "scene:OBJ-1788605199171-2",
"weight": 50
},
{
"title": "检查卡车外观并确认状态",
"targetId": "scene:OBJ-1788605199171-2",
"weight": 50
}
],
"cover": {
"assetId": "2100",
"bytes": 42163,
"sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
"width": 628,
"height": 640
},
"pageErrors": [],
"blockedWrites": []
}
}
],
"projects": [
{
"id": "161",
"name": "实测训练-狐狸野外巡检-已验证-20260905",
"status": "PUBLISHED",
"currentVersionId": "213",
"publishedVersionId": "213",
"sceneProjectId": "160",
"sceneVersionId": "210",
"stepCount": 2,
"totalScore": 100,
"cover": {
"assetId": "2098",
"image": "screenshots/fox-final-cover.jpg"
}
},
{
"id": "162",
"name": "实测训练-牛奶卡车进场检查-已验证-20260905",
"status": "PUBLISHED",
"currentVersionId": "214",
"publishedVersionId": "214",
"sceneProjectId": "159",
"sceneVersionId": "209",
"stepCount": 2,
"totalScore": 100,
"cover": {
"assetId": "2100",
"image": "screenshots/truck-final-cover.jpg"
}
}
],
"finishedAt": "2026-09-05T15:24:01.659Z"
}

+ 616
- 0
reports/training-live-20260905/report.md Voir le fichier

@@ -0,0 +1,616 @@
# 训练编排真实测试报告 · 2026-09-05

状态:**通过**。证据组:3/3 通过;明确检查:通过 9,失败 0,待验证 0;已校验截图 19 张。


## 1 · 资料

- 参考《文档/维修训练内容设计.pdf》(6 页)与已有场景制作成果。按“场景 → 任务步骤 → 学习 / 训练 / 评估”的资料结构检查实现。
- 本次使用已发布的狐狸场景和牛奶卡车场景验证训练编排,确认真实模型可见、步骤目标来自绑定场景、保存及发布使用服务端版本。

## 2 · 产品原型

- 沿用现有三维训练编排工作台:选择场景、选择三维对象、配置任务步骤、保存草稿和发布。
- 新建时的液压模板仅作为现有界面起点;绑定新场景后不得继续选择该场景中不存在的液压设备。

## 3 · 设计

- 两条独立训练分别绑定狐狸与卡车的明确发布版本;每条配置两步、每步 50 分,总计 100 分,并核验重新打开后的状态。
- 保存时以三维视口截图生成训练封面;模型可见性要通过实际截图及模型显示 / 隐藏的像素差异判断。
- 将正常流程、独立浏览器只读重开、资源失败、封面失败、版本冲突及并发切换分别留证。独立重开阻断所有业务写请求,核验模型像素可见性、封面哈希与发布依赖;不将其表述为只读角色权限测试。

## 4 · 开发

- 场景目录对接已发布内容版本;GLB 通过受控下载读取;训练依赖固定到所选场景版本,恢复项目时保留来源元数据。
- 修复蒙皮模型缩放后的骨骼矩阵与包围盒刷新;场景恢复、部位覆盖和高亮使用一致的尺寸计算。
- 绑定场景后移除不存在的内置操作目标,过滤隐藏或已删除对象及其子目标。
- 保存和发布等待异步操作完成;封面与内容在同一次版本保存中持久化;失败、冲突及并发切换必须保留可恢复状态。最终实现效果以下方真实测试记录为准。
- 修复旧工程快照回写覆盖最新名称与训练形态;场景绑定进行中禁止保存和发布,避免模型尚未加载成功就写入新的场景依赖。

## 保留训练数据

| 训练 ID | 名称 | 状态 | 当前版本 | 发布版本 | 场景 ID | 固定场景版本 | 步骤数 | 总分 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 161 | 实测训练-狐狸野外巡检-已验证-20260905 | PUBLISHED | 213 | 213 | 160 | 210 | 2 | 100 |
| 162 | 实测训练-牛奶卡车进场检查-已验证-20260905 | PUBLISHED | 214 | 214 | 159 | 209 | 2 | 100 |

保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。

## 5 · 测试

过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。


### 真实UI训练编排与发布实测 · 通过

- 使用真实已发布场景创建、编排并保留两条训练,列表显示截图封面:通过
- 狐狸两步编排、复制删除、排序撤销、高级时间轴、项目名称保存及发布:通过
- 狐狸三维点击选目标、调整视角并截图发布、异常恢复:通过
- 卡车三维拾取、项目名称及任务说明保存、隐藏外壳视角截图发布:通过

<details><summary>final-evidence.json · 脱敏完整记录</summary>

<pre>{
&quot;title&quot;: &quot;真实UI训练编排与发布实测&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;finishedAt&quot;: &quot;2026-09-05T15:23:29.909Z&quot;,
&quot;projects&quot;: [
{
&quot;id&quot;: &quot;161&quot;,
&quot;name&quot;: &quot;实测训练-狐狸野外巡检-已验证-20260905&quot;,
&quot;status&quot;: &quot;PUBLISHED&quot;,
&quot;currentVersionId&quot;: &quot;213&quot;,
&quot;publishedVersionId&quot;: &quot;213&quot;,
&quot;sceneProjectId&quot;: &quot;160&quot;,
&quot;sceneVersionId&quot;: &quot;210&quot;,
&quot;stepCount&quot;: 2,
&quot;totalScore&quot;: 100,
&quot;cover&quot;: {
&quot;assetId&quot;: &quot;2098&quot;,
&quot;sha256&quot;: &quot;7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267&quot;,
&quot;sizeBytes&quot;: 52518,
&quot;image&quot;: &quot;screenshots/fox-final-cover.jpg&quot;
}
},
{
&quot;id&quot;: &quot;162&quot;,
&quot;name&quot;: &quot;实测训练-牛奶卡车进场检查-已验证-20260905&quot;,
&quot;status&quot;: &quot;PUBLISHED&quot;,
&quot;currentVersionId&quot;: &quot;214&quot;,
&quot;publishedVersionId&quot;: &quot;214&quot;,
&quot;sceneProjectId&quot;: &quot;159&quot;,
&quot;sceneVersionId&quot;: &quot;209&quot;,
&quot;stepCount&quot;: 2,
&quot;totalScore&quot;: 100,
&quot;cover&quot;: {
&quot;assetId&quot;: &quot;2100&quot;,
&quot;sha256&quot;: &quot;efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4&quot;,
&quot;sizeBytes&quot;: 42163,
&quot;image&quot;: &quot;screenshots/truck-final-cover.jpg&quot;
}
}
],
&quot;checks&quot;: [
{
&quot;title&quot;: &quot;使用真实已发布场景创建、编排并保留两条训练,列表显示截图封面&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;screenshots&quot;: [
&quot;screenshots/02-published-scenes.png&quot;,
&quot;screenshots/20-training-list-final.png&quot;
],
&quot;details&quot;: {
&quot;projects&quot;: [
{
&quot;id&quot;: &quot;161&quot;,
&quot;status&quot;: &quot;PUBLISHED&quot;,
&quot;sceneProjectId&quot;: &quot;160&quot;,
&quot;sceneVersionId&quot;: &quot;210&quot;,
&quot;stepCount&quot;: 2,
&quot;totalScore&quot;: 100
},
{
&quot;id&quot;: &quot;162&quot;,
&quot;status&quot;: &quot;PUBLISHED&quot;,
&quot;sceneProjectId&quot;: &quot;159&quot;,
&quot;sceneVersionId&quot;: &quot;209&quot;,
&quot;stepCount&quot;: 2,
&quot;totalScore&quot;: 100
}
]
}
},
{
&quot;status&quot;: &quot;PASS&quot;,
&quot;title&quot;: &quot;狐狸两步编排、复制删除、排序撤销、高级时间轴、项目名称保存及发布&quot;,
&quot;id&quot;: &quot;161&quot;,
&quot;name&quot;: &quot;实测训练-狐狸野外巡检-已验证-20260905&quot;,
&quot;publishedVersionId&quot;: &quot;211&quot;,
&quot;coverId&quot;: &quot;2095&quot;,
&quot;steps&quot;: [
{
&quot;title&quot;: &quot;识别狐狸巡检目标&quot;,
&quot;target&quot;: &quot;scene:OBJ-1788605916565-6&quot;,
&quot;stepCode&quot;: &quot;VIRTUAL-S01&quot;,
&quot;actionCode&quot;: &quot;VIRTUAL-A01&quot;,
&quot;score&quot;: 50
},
{
&quot;title&quot;: &quot;检查狐狸外观并确认状态&quot;,
&quot;target&quot;: &quot;scene:OBJ-1788605916565-6&quot;,
&quot;stepCode&quot;: &quot;VIRTUAL-S02&quot;,
&quot;actionCode&quot;: &quot;VIRTUAL-A02&quot;,
&quot;score&quot;: 50
}
],
&quot;errors&quot;: [],
&quot;failed&quot;: [],
&quot;screenshots&quot;: [
&quot;screenshots/09-fox-advanced-timeline.png&quot;,
&quot;screenshots/10-fox-preflight.png&quot;
]
},
{
&quot;status&quot;: &quot;PASS&quot;,
&quot;title&quot;: &quot;狐狸三维点击选目标、调整视角并截图发布、异常恢复&quot;,
&quot;id&quot;: &quot;161&quot;,
&quot;publishedVersionId&quot;: &quot;213&quot;,
&quot;pick&quot;: {
&quot;x&quot;: 1065.015625,
&quot;y&quot;: 750.5,
&quot;targetId&quot;: &quot;scene:OBJ-1788605916565-6/part/interaction-a3d35c26&quot;
},
&quot;cameraBefore&quot;: [
-0.04179225073787585,
6.099634646023559,
17.355036987378313
],
&quot;cameraAfter&quot;: [
-0.9408733025235376,
5.534741198620379,
15.26632686671827
],
&quot;cover&quot;: {
&quot;id&quot;: &quot;2098&quot;,
&quot;sizeBytes&quot;: 52518,
&quot;sha256&quot;: &quot;7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267&quot;,
&quot;equalsCurrentViewport&quot;: true
},
&quot;errors&quot;: [],
&quot;screenshots&quot;: [
&quot;screenshots/16-fox-mesh-picking.png&quot;,
&quot;screenshots/17-fox-final-published.png&quot;
]
},
{
&quot;status&quot;: &quot;PASS&quot;,
&quot;title&quot;: &quot;卡车三维拾取、项目名称及任务说明保存、隐藏外壳视角截图发布&quot;,
&quot;id&quot;: &quot;162&quot;,
&quot;name&quot;: &quot;实测训练-牛奶卡车进场检查-已验证-20260905&quot;,
&quot;publishedVersionId&quot;: &quot;214&quot;,
&quot;coverId&quot;: &quot;2100&quot;,
&quot;pick&quot;: {
&quot;x&quot;: 1065.015625,
&quot;y&quot;: 750.5,
&quot;targetId&quot;: &quot;scene:OBJ-1788605199171-2/part/interaction-5f7e4bee&quot;
},
&quot;stepCount&quot;: 2,
&quot;totalScore&quot;: 100,
&quot;errors&quot;: [],
&quot;screenshots&quot;: [
&quot;screenshots/18-truck-mesh-picking.png&quot;,
&quot;screenshots/19-truck-final-published.png&quot;
]
}
],
&quot;commands&quot;: [
{
&quot;command&quot;: &quot;pnpm run build&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;vue-tsc、Vite构建、iframe产物一致性验证通过;有既有chunk体积和DRACO路径构建提示。&quot;
},
{
&quot;command&quot;: &quot;pnpm run test:training-legacy&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;训练原型迁移和野外场景共享定义两个合同均通过。&quot;
},
{
&quot;command&quot;: &quot;node --test tools/training-cover.test.mjs tools/training-scene-api.test.mjs tools/training-model-placement.test.mjs tools/training-scene-targets.test.mjs tools/training-project-metadata.test.mjs&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;48/48通过。首次合跑1个测试夹具缺renderSaveState回调,修复夹具后全量复跑通过。&quot;
},
{
&quot;command&quot;: &quot;node tools/training-scene-visibility.e2e.mjs&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;独立浏览器只读重开验证,最终版本变更后再次运行;细项以readonly-evidence.json为准。&quot;
}
],
&quot;limitations&quot;: [
&quot;本次未创建教学下发任务,也未验证学员端完成训练;现有“预览训练”入口仍提示先发布再从虚实训练下发任务。&quot;,
&quot;未配置讲解音频和必选安全前置条件,编辑器校验给出非阻断提醒;两条为轻量素材功能测试,不作为正式维修教学内容。&quot;,
&quot;狐狸源场景实际已更新至V3(版本210),使用目录当时的真实发布版;未修改两个源场景或源模型。&quot;,
&quot;训练封面截取保存时的当前视角;卡车封面通过现有“隐藏外壳”按钮隐藏围护结构,立柱仍遮挡部分车身。这是继承的源场景及视角,不是模型未加载;重新打开时仍按固定场景还原相机。&quot;,
&quot;本轮没有新增需确认的功能变更,截图封面按本次明确授权实现;未改变数据库结构,无DDL变更。&quot;
],
&quot;pageErrors&quot;: []
}</pre>

</details>

### 独立浏览器重开、真实模型可见性及封面验证 · 通过

- 狐狸野外巡检:发布重开、模型实际可见、截图封面:通过
- 牛奶卡车进场检查:发布重开、模型实际可见、截图封面:通过

<details><summary>readonly-evidence.json · 脱敏完整记录</summary>

<pre>{
&quot;title&quot;: &quot;独立浏览器重开、真实模型可见性及封面验证&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;startedAt&quot;: &quot;2026-09-05T15:23:16.479Z&quot;,
&quot;checks&quot;: [
{
&quot;title&quot;: &quot;狐狸野外巡检:发布重开、模型实际可见、截图封面&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;screenshots&quot;: [
&quot;screenshots/fox-independent-reopen.png&quot;,
&quot;screenshots/fox-final-cover.jpg&quot;
],
&quot;details&quot;: {
&quot;state&quot;: &quot;ready&quot;,
&quot;meshes&quot;: 1,
&quot;visibleMeshes&quot;: 1,
&quot;skinnedMeshes&quot;: 1,
&quot;position&quot;: [
-7.5,
0.10930174852949677,
0.3440109341705351
],
&quot;scale&quot;: [
0.03616375932570189,
0.03616375932570189,
0.03616375932570189
],
&quot;finiteBounds&quot;: true,
&quot;size&quot;: [
0.9658796100519353,
2.811899945787403,
6
],
&quot;camera&quot;: {
&quot;position&quot;: [
-0.04179225073787585,
6.099634646023559,
17.355036987378313
],
&quot;target&quot;: [
-7.512232860974557,
1.4059499759307126,
-2.2204460492503126e-16
]
},
&quot;sample&quot;: {
&quot;width&quot;: 320,
&quot;height&quot;: 180,
&quot;changedPixels&quot;: 827
},
&quot;scene&quot;: {
&quot;projectId&quot;: &quot;160&quot;,
&quot;versionId&quot;: &quot;210&quot;
},
&quot;targets&quot;: [
{
&quot;id&quot;: &quot;scene:OBJ-1788605873329-2&quot;,
&quot;name&quot;: &quot;维修装备主体 02&quot;,
&quot;source&quot;: &quot;scene&quot;
},
{
&quot;id&quot;: &quot;scene:OBJ-1788605916565-6&quot;,
&quot;name&quot;: &quot;狐狸野外巡检目标&quot;,
&quot;source&quot;: &quot;scene&quot;
},
{
&quot;id&quot;: &quot;scene:OBJ-1788605916565-6/part/interaction-a3d35c26&quot;,
&quot;name&quot;: &quot;fox&quot;,
&quot;source&quot;: &quot;scene&quot;
}
],
&quot;steps&quot;: [
{
&quot;title&quot;: &quot;识别狐狸巡检目标&quot;,
&quot;targetId&quot;: &quot;scene:OBJ-1788605916565-6&quot;,
&quot;weight&quot;: 50
},
{
&quot;title&quot;: &quot;检查狐狸外观并确认状态&quot;,
&quot;targetId&quot;: &quot;scene:OBJ-1788605916565-6&quot;,
&quot;weight&quot;: 50
}
],
&quot;cover&quot;: {
&quot;assetId&quot;: &quot;2098&quot;,
&quot;bytes&quot;: 52518,
&quot;sha256&quot;: &quot;7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267&quot;,
&quot;width&quot;: 628,
&quot;height&quot;: 640
},
&quot;pageErrors&quot;: [],
&quot;blockedWrites&quot;: []
}
},
{
&quot;title&quot;: &quot;牛奶卡车进场检查:发布重开、模型实际可见、截图封面&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;screenshots&quot;: [
&quot;screenshots/truck-independent-reopen.png&quot;,
&quot;screenshots/truck-final-cover.jpg&quot;
],
&quot;details&quot;: {
&quot;state&quot;: &quot;ready&quot;,
&quot;meshes&quot;: 5,
&quot;visibleMeshes&quot;: 5,
&quot;skinnedMeshes&quot;: 0,
&quot;position&quot;: [
4.2,
0.20617168998977797,
-0.4356763541543951
],
&quot;scale&quot;: [
1.2323086149245381,
1.2323086149245381,
1.2323086149245381
],
&quot;finiteBounds&quot;: true,
&quot;size&quot;: [
3.4406057210321332,
3.390913184034896,
6.000000000000001
],
&quot;camera&quot;: {
&quot;position&quot;: [
10.565439077211714,
5.0456876852867705,
7.37050840519251
],
&quot;target&quot;: [
4.2,
1.695456592017448,
0
]
},
&quot;sample&quot;: {
&quot;width&quot;: 320,
&quot;height&quot;: 180,
&quot;changedPixels&quot;: 11669
},
&quot;scene&quot;: {
&quot;projectId&quot;: &quot;159&quot;,
&quot;versionId&quot;: &quot;209&quot;
},
&quot;targets&quot;: [
{
&quot;id&quot;: &quot;scene:OBJ-1788605199171-2&quot;,
&quot;name&quot;: &quot;牛奶卡车检验主装备&quot;,
&quot;source&quot;: &quot;scene&quot;
},
{
&quot;id&quot;: &quot;scene:OBJ-1788605199171-2/part/interaction-1ba581e6&quot;,
&quot;name&quot;: &quot;Yup2Zup&quot;,
&quot;source&quot;: &quot;scene&quot;
},
{
&quot;id&quot;: &quot;scene:OBJ-1788605199171-2/part/interaction-004dec41&quot;,
&quot;name&quot;: &quot;Cesium_Milk_Truck&quot;,
&quot;source&quot;: &quot;scene&quot;
},
{
&quot;id&quot;: &quot;scene:OBJ-1788605199171-2/part/interaction-5f7e4bee&quot;,
&quot;name&quot;: &quot;Cesium_Milk_Truck_1&quot;,
&quot;source&quot;: &quot;scene&quot;
},
{
&quot;id&quot;: &quot;scene:OBJ-1788605199171-2/part/interaction-607e4d81&quot;,
&quot;name&quot;: &quot;Cesium_Milk_Truck_2&quot;,
&quot;source&quot;: &quot;scene&quot;
},
{
&quot;id&quot;: &quot;scene:OBJ-1788605199171-2/part/interaction-5d7e48c8&quot;,
&quot;name&quot;: &quot;Cesium_Milk_Truck_3&quot;,
&quot;source&quot;: &quot;scene&quot;
},
{
&quot;id&quot;: &quot;scene:OBJ-1788605199171-2/part/interaction-5e7e4a5b&quot;,
&quot;name&quot;: &quot;Node&quot;,
&quot;source&quot;: &quot;scene&quot;
},
{
&quot;id&quot;: &quot;scene:OBJ-1788605199171-2/part/interaction-ce2feac4&quot;,
&quot;name&quot;: &quot;Wheels&quot;,
&quot;source&quot;: &quot;scene&quot;
},
{
&quot;id&quot;: &quot;scene:OBJ-1788605199171-2/part/interaction-637e523a&quot;,
&quot;name&quot;: &quot;Node001&quot;,
&quot;source&quot;: &quot;scene&quot;
},
{
&quot;id&quot;: &quot;scene:OBJ-1788605199171-2/part/interaction-f417f82d&quot;,
&quot;name&quot;: &quot;Wheels001&quot;,
&quot;source&quot;: &quot;scene&quot;
}
],
&quot;steps&quot;: [
{
&quot;title&quot;: &quot;识别牛奶卡车检验目标&quot;,
&quot;targetId&quot;: &quot;scene:OBJ-1788605199171-2&quot;,
&quot;weight&quot;: 50
},
{
&quot;title&quot;: &quot;检查卡车外观并确认状态&quot;,
&quot;targetId&quot;: &quot;scene:OBJ-1788605199171-2&quot;,
&quot;weight&quot;: 50
}
],
&quot;cover&quot;: {
&quot;assetId&quot;: &quot;2100&quot;,
&quot;bytes&quot;: 42163,
&quot;sha256&quot;: &quot;efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4&quot;,
&quot;width&quot;: 628,
&quot;height&quot;: 640
},
&quot;pageErrors&quot;: [],
&quot;blockedWrites&quot;: []
}
}
],
&quot;projects&quot;: [
{
&quot;id&quot;: &quot;161&quot;,
&quot;name&quot;: &quot;实测训练-狐狸野外巡检-已验证-20260905&quot;,
&quot;status&quot;: &quot;PUBLISHED&quot;,
&quot;currentVersionId&quot;: &quot;213&quot;,
&quot;publishedVersionId&quot;: &quot;213&quot;,
&quot;sceneProjectId&quot;: &quot;160&quot;,
&quot;sceneVersionId&quot;: &quot;210&quot;,
&quot;stepCount&quot;: 2,
&quot;totalScore&quot;: 100,
&quot;cover&quot;: {
&quot;assetId&quot;: &quot;2098&quot;,
&quot;image&quot;: &quot;screenshots/fox-final-cover.jpg&quot;
}
},
{
&quot;id&quot;: &quot;162&quot;,
&quot;name&quot;: &quot;实测训练-牛奶卡车进场检查-已验证-20260905&quot;,
&quot;status&quot;: &quot;PUBLISHED&quot;,
&quot;currentVersionId&quot;: &quot;214&quot;,
&quot;publishedVersionId&quot;: &quot;214&quot;,
&quot;sceneProjectId&quot;: &quot;159&quot;,
&quot;sceneVersionId&quot;: &quot;209&quot;,
&quot;stepCount&quot;: 2,
&quot;totalScore&quot;: 100,
&quot;cover&quot;: {
&quot;assetId&quot;: &quot;2100&quot;,
&quot;image&quot;: &quot;screenshots/truck-final-cover.jpg&quot;
}
}
],
&quot;finishedAt&quot;: &quot;2026-09-05T15:24:01.659Z&quot;
}</pre>

</details>

### 真实浏览器异常分支验证(定向注入HTTP失败) · 通过

- 封面上传503:训练内容仍保存并保留原封面:通过
- 保存409:不覆盖服务端内容和封面,并保持未保存提示:通过
- 切换场景下载失败:保持原场景、绑定期间禁止保存发布、零项目写入:通过

<details><summary>error-evidence.json · 脱敏完整记录</summary>

<pre>{
&quot;title&quot;: &quot;真实浏览器异常分支验证(定向注入HTTP失败)&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;checks&quot;: [
{
&quot;title&quot;: &quot;封面上传503:训练内容仍保存并保留原封面&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: {
&quot;beforeVersion&quot;: 6,
&quot;afterVersion&quot;: 7,
&quot;sameCover&quot;: true,
&quot;stepCount&quot;: 2
},
&quot;screenshot&quot;: &quot;screenshots/12-cover-failure-preserves-old.png&quot;
},
{
&quot;title&quot;: &quot;保存409:不覆盖服务端内容和封面,并保持未保存提示&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: {
&quot;version&quot;: 7,
&quot;putAttempts&quot;: 1,
&quot;sameCover&quot;: true
},
&quot;screenshot&quot;: &quot;screenshots/13-save-conflict.png&quot;
},
{
&quot;title&quot;: &quot;切换场景下载失败:保持原场景、绑定期间禁止保存发布、零项目写入&quot;,
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: {
&quot;retainedSceneProjectId&quot;: &quot;160&quot;,
&quot;blockedDownloads&quot;: 1,
&quot;putCount&quot;: 0
},
&quot;screenshots&quot;: [
&quot;screenshots/14-binding-disables-save.png&quot;,
&quot;screenshots/15-binding-failure-retains-scene.png&quot;
]
}
]
}</pre>

</details>

### 执行命令与验证结果

- pnpm run build:通过。vue-tsc、Vite构建、iframe产物一致性验证通过;有既有chunk体积和DRACO路径构建提示。
- pnpm run test:training-legacy:通过。训练原型迁移和野外场景共享定义两个合同均通过。
- node --test tools/training-cover.test.mjs tools/training-scene-api.test.mjs tools/training-model-placement.test.mjs tools/training-scene-targets.test.mjs tools/training-project-metadata.test.mjs:通过。48/48通过。首次合跑1个测试夹具缺renderSaveState回调,修复夹具后全量复跑通过。
- node tools/training-scene-visibility.e2e.mjs:通过。独立浏览器只读重开验证,最终版本变更后再次运行;细项以readonly-evidence.json为准。

## 最终及异常验证截图

[![实测训练-狐狸野外巡检-已验证-20260905](screenshots/fox-final-cover.jpg)](screenshots/fox-final-cover.jpg)

[![实测训练-牛奶卡车进场检查-已验证-20260905](screenshots/truck-final-cover.jpg)](screenshots/truck-final-cover.jpg)

[![使用真实已发布场景创建、编排并保留两条训练,列表显示截图封面](screenshots/20-training-list-final.png)](screenshots/20-training-list-final.png)

[![狐狸两步编排、复制删除、排序撤销、高级时间轴、项目名称保存及发布](screenshots/09-fox-advanced-timeline.png)](screenshots/09-fox-advanced-timeline.png)

[![狐狸两步编排、复制删除、排序撤销、高级时间轴、项目名称保存及发布](screenshots/10-fox-preflight.png)](screenshots/10-fox-preflight.png)

[![狐狸三维点击选目标、调整视角并截图发布、异常恢复](screenshots/16-fox-mesh-picking.png)](screenshots/16-fox-mesh-picking.png)

[![狐狸三维点击选目标、调整视角并截图发布、异常恢复](screenshots/17-fox-final-published.png)](screenshots/17-fox-final-published.png)

[![卡车三维拾取、项目名称及任务说明保存、隐藏外壳视角截图发布](screenshots/18-truck-mesh-picking.png)](screenshots/18-truck-mesh-picking.png)

[![卡车三维拾取、项目名称及任务说明保存、隐藏外壳视角截图发布](screenshots/19-truck-final-published.png)](screenshots/19-truck-final-published.png)

[![狐狸野外巡检:发布重开、模型实际可见、截图封面](screenshots/fox-independent-reopen.png)](screenshots/fox-independent-reopen.png)

[![牛奶卡车进场检查:发布重开、模型实际可见、截图封面](screenshots/truck-independent-reopen.png)](screenshots/truck-independent-reopen.png)

[![封面上传503:训练内容仍保存并保留原封面](screenshots/12-cover-failure-preserves-old.png)](screenshots/12-cover-failure-preserves-old.png)

[![保存409:不覆盖服务端内容和封面,并保持未保存提示](screenshots/13-save-conflict.png)](screenshots/13-save-conflict.png)

[![切换场景下载失败:保持原场景、绑定期间禁止保存发布、零项目写入](screenshots/14-binding-disables-save.png)](screenshots/14-binding-disables-save.png)

[![切换场景下载失败:保持原场景、绑定期间禁止保存发布、零项目写入](screenshots/15-binding-failure-retains-scene.png)](screenshots/15-binding-failure-retains-scene.png)

## 过程截图 · 不计入最终通过结论

[![过程记录:初建训练仍显示默认液压模板,仅记录起点,不作为故障对照或最终通过证据。](screenshots/01-before-scene-catalog.png)](screenshots/01-before-scene-catalog.png)

[![过程记录:打开已发布场景目录。最终版本和固定依赖以最终证据为准。](screenshots/02-published-scenes.png)](screenshots/02-published-scenes.png)

[![过程记录:首次绑定卡车时仍有 8 个旧模板步骤;不代表最终两步编排通过。](screenshots/03-truck-bound.png)](screenshots/03-truck-bound.png)

[![过程记录:卡车训练阶段性保存;最终步骤、状态、版本和封面以最终证据为准。](screenshots/04-truck-saved.png)](screenshots/04-truck-saved.png)

## 范围、限制与功能确认结论

- 本次范围为训练编排、草稿保存、发布与重新打开,不包含创建教学任务、下发学员或完成学员训练。
- “预览训练”仍提示需发布后在教学实施中下发;本报告不将该提示视作学员训练已运行。
- 本轮仅修复已实现功能的问题,无新增需要用户确认的功能改动。

### 实测限制与后续项

- 本次未创建教学下发任务,也未验证学员端完成训练;现有“预览训练”入口仍提示先发布再从虚实训练下发任务。
- 未配置讲解音频和必选安全前置条件,编辑器校验给出非阻断提醒;两条为轻量素材功能测试,不作为正式维修教学内容。
- 狐狸源场景实际已更新至V3(版本210),使用目录当时的真实发布版;未修改两个源场景或源模型。
- 训练封面截取保存时的当前视角;卡车封面通过现有“隐藏外壳”按钮隐藏围护结构,立柱仍遮挡部分车身。这是继承的源场景及视角,不是模型未加载;重新打开时仍按固定场景还原相机。
- 本轮没有新增需确认的功能变更,截图封面按本次明确授权实现;未改变数据库结构,无DDL变更。

报告生成:2026/9/5 23:26:58(Asia/Shanghai)。

























+ 1177
- 0
reports/training-modes-20260905/api-evidence.json
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 635
- 0
reports/training-modes-20260905/evidence.json Voir le fichier

@@ -0,0 +1,635 @@
{
"title": "实装实训与对抗训练真实流程测试",
"startedAt": "2026-09-05T15:37:32.805Z",
"status": "PASS_WITH_LIMITATIONS",
"checks": [
{
"title": "实装:创建、绑定牛奶卡车、3步配置、保存重开与发布",
"status": "PASS",
"screenshots": [
"screenshots/physical-settings-reopen.png",
"screenshots/physical-authoring.png",
"screenshots/physical-preflight.png",
"screenshots/physical-published.png"
],
"details": {
"id": "163",
"version": "215",
"progress": [
33,
67,
100
],
"profile": {
"type": "PHYSICAL",
"stationName": "牛奶卡车测试工位 A",
"equipmentCode": "E2E-TRUCK-163",
"gatewayProtocol": "HTTP 测试网关",
"sensorChannels": "人工外观确认;测试状态点位",
"safetyInterlocks": "测试工位隔离确认"
}
}
},
{
"title": "对抗:每队人数为小数时阻止发布",
"status": "PASS",
"screenshots": [
"screenshots/confrontation-invalid-team-size.png"
],
"details": {
"input": 2.5,
"message": "发布校验未通过\n×\n发现 1 个必须处理的问题\n\n2 个步骤 · 1 个目标 · 0 个故障 · 及格线 80 分\n\n!\n每队人数必须是 1 到 200 的整数\nmodeConfig.teamSize\ni\n本步骤尚未配置必选安全前置条件\nsteps[0].safetyRules\ni\n本步骤尚未配置必选安全前置条件\nsteps[1].safetyRules\ni\n尚未绑定离线讲解文件,运行时将保留字幕并使用默认本地语音\nsteps.audio\n关闭"
}
},
{
"title": "对抗:显式清空岗位后保存重开,不恢复默认授权",
"status": "PASS",
"screenshots": [
"screenshots/confrontation-empty-positions.png"
],
"details": {
"allowedPositions": [

],
"published": false
}
},
{
"title": "对抗:狐狸场景、两步巡检、岗位配置、说明往返与发布",
"status": "PASS",
"screenshots": [
"screenshots/confrontation-positions.png",
"screenshots/confrontation-settings-reopen.png",
"screenshots/confrontation-authoring.png",
"screenshots/confrontation-preflight.png",
"screenshots/confrontation-published.png"
],
"details": {
"id": "164",
"version": "216",
"profile": {
"type": "CONFRONTATION",
"redTeamName": "狐狸巡检红队",
"blueTeamName": "狐狸巡检蓝队",
"teamSize": 2,
"roundDurationMinutes": 7,
"objective": "两队分别完成狐狸模型巡检并确认状态",
"winRule": "按已完成步骤与协同检查结果评分",
"submitPositions": [
"COMMANDER"
],
"roundDuration": 7,
"eventInjections": ""
},
"steps": [
{
"title": "识别狐狸巡检目标",
"summary": "红蓝两队分别识别场景中的狐狸模型,由指挥员确认目标。",
"allowedPositions": [
"COMMANDER"
]
},
{
"title": "完成狐狸状态检查",
"summary": "由指挥员完成状态确认,操作员协同观察;提交团队检查结果。",
"allowedPositions": [
"COMMANDER"
]
}
]
}
},
{
"title": "实装:说明兼容修复回写,重新截图封面及发布",
"status": "PASS",
"screenshots": [
"screenshots/physical-final-published.png"
],
"details": {
"projectId": "163",
"publishedVersionId": "217",
"apiTestVersionId": "215",
"note": "既有任务50固定215;本次仅回填说明与更新截图封面,未改写任务快照。",
"steps": [
{
"stepCode": "PHYSICAL-S01",
"actionCode": "PHYSICAL-A01",
"summary": "核对测试工位与牛奶卡车编号。",
"physicalEventRule": {
"source": "MANUAL",
"eventType": "step.physical-s01.confirmed",
"progressPercent": 33,
"manualConfirmAllowed": true
}
},
{
"stepCode": "PHYSICAL-S02",
"actionCode": "PHYSICAL-A02",
"summary": "人工确认卡车外观状态,使用受控事件记录检查。",
"physicalEventRule": {
"source": "MANUAL",
"eventType": "step.physical-s02.confirmed",
"progressPercent": 67,
"manualConfirmAllowed": true
}
},
{
"stepCode": "PHYSICAL-S03",
"actionCode": "PHYSICAL-A03",
"summary": "核实步骤记录并提交检查结果。",
"physicalEventRule": {
"source": "MANUAL",
"eventType": "step.physical-s03.confirmed",
"progressPercent": 100,
"manualConfirmAllowed": true
}
}
]
}
},
{
"title": "实装默认页面:离线播放与候选复核可操作,无法执行新卡车正式步骤",
"status": "BLOCKED",
"screenshots": [
"screenshots/physical-teaching-list.png",
"screenshots/physical-offline-preview.png",
"screenshots/physical-offline-confirmed.png"
],
"details": {
"assignmentId": "50",
"previewUrl": "/teaching/physical-training/preview/50",
"observed": "进入任务后仍显示固定45秒维修识别录像,确认只改变本次页面候选,未加载卡车三步正式运行。",
"businessWrites": [

],
"required": "需将正式任务快照、场景、人工/设备事件及运行记录接入工作台。"
}
},
{
"title": "对抗默认页面:新建下拉仅有本地液压预设,无法选择已发布狐狸训练",
"status": "BLOCKED",
"screenshots": [
"screenshots/confrontation-final-entry.png",
"screenshots/confrontation-final-local-create.png"
],
"details": {
"options": [
"桥梁架设装备液压系统检测与维修训练"
],
"formalAssignmentId": "51",
"formalAssignmentExists": true,
"required": "需将默认任务清单、创建表单和执行入口接入正式对抗任务服务,并确认通用巡检与五阶段排故的适用范围。"
}
},
{
"title": "训练列表两种模式展示各自真实截图封面",
"status": "PASS",
"screenshots": [
"screenshots/physical-list-cover.png",
"screenshots/confrontation-list-cover.png"
],
"details": {
"projects": [
"163",
"164"
],
"authenticatedImagesLoaded": true
}
},
{
"title": "牛奶卡车实装检查:发布重开、模型实际可见、截图封面",
"status": "PASS",
"screenshots": [
"screenshots/physical-independent-reopen.png",
"screenshots/physical-independent-settings.png",
"screenshots/physical-final-cover.jpg"
],
"details": {
"state": "ready",
"meshes": 5,
"visibleMeshes": 5,
"skinnedMeshes": 0,
"position": [
4.2,
0.20617168998977797,
-0.4356763541543951
],
"scale": [
1.2323086149245381,
1.2323086149245381,
1.2323086149245381
],
"finiteBounds": true,
"size": [
3.4406057210321332,
3.390913184034896,
6.000000000000001
],
"camera": {
"position": [
10.565439077211714,
5.0456876852867705,
7.37050840519251
],
"target": [
4.2,
1.695456592017448,
0
]
},
"sample": {
"width": 320,
"height": 180,
"changedPixels": 11669
},
"scene": {
"projectId": "159",
"versionId": "209"
},
"targets": [
{
"id": "scene:OBJ-1788605199171-2",
"name": "牛奶卡车检验主装备",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-1ba581e6",
"name": "Yup2Zup",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-004dec41",
"name": "Cesium_Milk_Truck",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee",
"name": "Cesium_Milk_Truck_1",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-607e4d81",
"name": "Cesium_Milk_Truck_2",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-5d7e48c8",
"name": "Cesium_Milk_Truck_3",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-5e7e4a5b",
"name": "Node",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-ce2feac4",
"name": "Wheels",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-637e523a",
"name": "Node001",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-f417f82d",
"name": "Wheels001",
"source": "scene"
}
],
"steps": [
{
"title": "核对卡车工位与装备",
"targetId": "scene:OBJ-1788605199171-2",
"weight": 30
},
{
"title": "检查卡车外观状态",
"targetId": "scene:OBJ-1788605199171-2",
"weight": 30
},
{
"title": "确认检查完成",
"targetId": "scene:OBJ-1788605199171-2",
"weight": 40
}
],
"cover": {
"assetId": "2111",
"bytes": 42163,
"sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
"width": 628,
"height": 640
},
"pageErrors": [

],
"blockedWrites": [

]
}
},
{
"title": "狐狸红蓝协同巡检:发布重开、模型实际可见、截图封面",
"status": "PASS",
"screenshots": [
"screenshots/confrontation-independent-reopen.png",
"screenshots/confrontation-independent-settings.png",
"screenshots/confrontation-final-cover.jpg"
],
"details": {
"state": "ready",
"meshes": 1,
"visibleMeshes": 1,
"skinnedMeshes": 1,
"position": [
-7.5,
0.10930174852949677,
0.3440109341705351
],
"scale": [
0.03616375932570189,
0.03616375932570189,
0.03616375932570189
],
"finiteBounds": true,
"size": [
0.9658796100519353,
2.811899945787403,
6
],
"camera": {
"position": [
-0.04179225073787585,
6.099634646023559,
17.355036987378313
],
"target": [
-7.512232860974557,
1.4059499759307126,
-2.2204460492503126E-16
]
},
"sample": {
"width": 320,
"height": 180,
"changedPixels": 827
},
"scene": {
"projectId": "160",
"versionId": "210"
},
"targets": [
{
"id": "scene:OBJ-1788605873329-2",
"name": "维修装备主体 02",
"source": "scene"
},
{
"id": "scene:OBJ-1788605916565-6",
"name": "狐狸野外巡检目标",
"source": "scene"
},
{
"id": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26",
"name": "fox",
"source": "scene"
}
],
"steps": [
{
"title": "识别狐狸巡检目标",
"targetId": "scene:OBJ-1788605916565-6",
"weight": 50
},
{
"title": "完成狐狸状态检查",
"targetId": "scene:OBJ-1788605916565-6",
"weight": 50
}
],
"cover": {
"assetId": "2108",
"bytes": 51257,
"sha256": "79779a9d1e40c0c160fa3dab25e83931639942f53e2ee3a5da12f9f86ad70081",
"width": 628,
"height": 640
},
"pageErrors": [

],
"blockedWrites": [

]
}
}
],
"projects": [
{
"id": "163",
"name": "实测训练-牛奶卡车实装检查-20260905",
"mode": "PHYSICAL",
"status": "PUBLISHED",
"currentVersionId": "217",
"publishedVersionId": "217",
"sceneProjectId": "159",
"sceneVersionId": "209",
"stepCount": 3,
"totalScore": 100,
"cover": {
"assetId": "2111",
"image": "screenshots/physical-final-cover.jpg"
},
"trainingMode": "PHYSICAL",
"modeConfig": {
"type": "PHYSICAL",
"stationName": "牛奶卡车测试工位 A",
"equipmentCode": "E2E-TRUCK-163",
"gatewayProtocol": "HTTP 测试网关",
"sensorChannels": "人工外观确认;测试状态点位",
"safetyInterlocks": "测试工位隔离确认"
},
"steps": [
{
"stepCode": "PHYSICAL-S01",
"actionCode": "PHYSICAL-A01",
"title": "核对卡车工位与装备",
"summary": "核对测试工位与牛奶卡车编号。",
"targetId": "scene:OBJ-1788605199171-2",
"allowedPositions": [

],
"physicalEventRule": {
"source": "MANUAL",
"eventType": "step.physical-s01.confirmed",
"progressPercent": 33,
"manualConfirmAllowed": true
},
"score": 30
},
{
"stepCode": "PHYSICAL-S02",
"actionCode": "PHYSICAL-A02",
"title": "检查卡车外观状态",
"summary": "人工确认卡车外观状态,使用受控事件记录检查。",
"targetId": "scene:OBJ-1788605199171-2",
"allowedPositions": [

],
"physicalEventRule": {
"source": "MANUAL",
"eventType": "step.physical-s02.confirmed",
"progressPercent": 67,
"manualConfirmAllowed": true
},
"score": 30
},
{
"stepCode": "PHYSICAL-S03",
"actionCode": "PHYSICAL-A03",
"title": "确认检查完成",
"summary": "核实步骤记录并提交检查结果。",
"targetId": "scene:OBJ-1788605199171-2",
"allowedPositions": [

],
"physicalEventRule": {
"source": "MANUAL",
"eventType": "step.physical-s03.confirmed",
"progressPercent": 100,
"manualConfirmAllowed": true
},
"score": 40
}
],
"apiTestVersionId": "215",
"apiVersionNote": "API任务50固定215;说明字段兼容修复后在217做UI及封面回归,未改写任务快照。"
},
{
"id": "164",
"name": "实测训练-狐狸红蓝协同巡检-20260905",
"mode": "CONFRONTATION",
"status": "PUBLISHED",
"currentVersionId": "216",
"publishedVersionId": "216",
"sceneProjectId": "160",
"sceneVersionId": "210",
"stepCount": 2,
"totalScore": 100,
"cover": {
"assetId": "2108",
"image": "screenshots/confrontation-final-cover.jpg"
},
"trainingMode": "CONFRONTATION",
"modeConfig": {
"type": "CONFRONTATION",
"redTeamName": "狐狸巡检红队",
"blueTeamName": "狐狸巡检蓝队",
"teamSize": 2,
"roundDurationMinutes": 7,
"objective": "两队分别完成狐狸模型巡检并确认状态",
"winRule": "按已完成步骤与协同检查结果评分",
"submitPositions": [
"COMMANDER"
],
"roundDuration": 7,
"eventInjections": ""
},
"steps": [
{
"stepCode": "CONFRONTATION-S01",
"actionCode": "CONFRONTATION-A01",
"title": "识别狐狸巡检目标",
"summary": "红蓝两队分别识别场景中的狐狸模型,由指挥员确认目标。",
"targetId": "scene:OBJ-1788605916565-6",
"allowedPositions": [
"COMMANDER"
],
"physicalEventRule": {

},
"score": 50
},
{
"stepCode": "CONFRONTATION-S02",
"actionCode": "CONFRONTATION-A02",
"title": "完成狐狸状态检查",
"summary": "由指挥员完成状态确认,操作员协同观察;提交团队检查结果。",
"targetId": "scene:OBJ-1788605916565-6",
"allowedPositions": [
"COMMANDER"
],
"physicalEventRule": {

},
"score": 50
}
],
"apiTestVersionId": "216"
}
],
"bugs": [

],
"limitations": [
"实装为MANUAL人工事件合同测试,没有接真实PLC、摄像头、视觉识别服务或安全联锁设备。",
"两步狐狸巡检用于通用步骤与红蓝岗位合同,不是需要CONF-INSPECT等五个固定编码的完整排故作业;未宣称排故工作台阶段回写已打通。",
"对抗验证服务端设置了7分钟截止时刻,未等待自然超时触发;未把API账号请求视为多人跨浏览器实时协同UI测试。",
"牛奶卡车沿用原发布场景的视角,车间立柱遮挡部分车身;真实模型已渲染且封面来自该视口。",
"实装当前发布217仅回填兼容说明并更新封面;已完成教学任务50仍固定不可变版本215。",
"历史仅positionActions设置动作岗位的定义在教学读取侧与当前发布预检存在口径差异;本次按现有发布规则配置非空allowedPositions,未改后端岗位合同。",
"开发环境列表切换曾出现一次15秒超时,刷新后恢复;独立复测两种模式共6次查询均HTTP 200、耗时247–403ms,未复现稳定慢查询。详见list-api-latency.json。"
],
"pageErrors": [

],
"development": [
"修复模式配置被旧channelProfiles覆盖,保持默认profile与modeConfig一致;对抗分钟/秒及旧roundDuration兼容。",
"保留显式空配置文本;每队人数范围与服务端1–200一致,无效数字阻止发布,不静默夹断。",
"保留步骤设备事件、人工确认、岗位与运行编码;增删排序后重新计算严格递增进度。",
"修复复制步骤编码冲突(含大小写),复制动作岗位映射随新动作编码迁移。",
"显式清空allowedPositions不再补回默认三岗位,保持权限边界并交由正式发布校验阻断。",
"修复instruction/summary双界面操作说明丢失及显式空提示恢复演示内容;用户实际编辑时同步。",
"正式targetId优先,目标选择同步语义引用、节点与动作,防止旧targetRef把新目标回退。",
"沿用上轮训练3D视口截图方案:受控TRAINING_COVER上传、同次保存资产与内容,保存/发布后列表正确显示截图。",
"本次均为正式工程合同适配与bugfix,未新增数据库字段、执行DDL或更改原型业务流程;测试数据通过开发API创建。"
],
"commands": [
{
"command": "pnpm run build",
"status": "PASS",
"details": "vue-tsc、Vite构建和legacy iframe分发校验均通过;仅已有包体积提示。"
},
{
"command": "node --test tools/training-*.test.mjs(本次8个聚焦文件)",
"status": "PASS",
"details": "94/94通过,涵盖封面、场景、真实模型、目标、元数据、模式与步骤兼容。"
},
{
"command": "pnpm run test:training-legacy",
"status": "PASS",
"details": "两项既有训练/地形合同检查通过。"
},
{
"command": "content-training-studio.spec.ts / Edge",
"status": "PASS",
"details": "7/7通过(mock API的工作台回归),无跳过、无不稳定用例,独立报告studio-regression/html/index.html。"
},
{
"command": "UTE2E_TRAINING_MODES=1 UTE2E_BASE_URL=http://192.168.31.168:6180 node tools/training-scene-visibility.e2e.mjs",
"status": "PASS",
"details": "独立Edge会话2/2通过,真实LAN服务、禁止业务写请求;模型可见像素差、固定场景依赖、JPEG尺寸与SHA-256全部通过。"
},
{
"command": "node tools/training-modes-api-live.mjs(163:215;164:216)",
"status": "PASS",
"details": "18/18检查组通过,90次正式教学API请求;含训练/场景/模型固定快照只读复核,详见api-evidence.json。"
}
],
"confirmationItems": [
"将实装工作台从固定离线识别回放扩展为读取正式任务、已发布场景和步骤,并接入人工/设备事件、提交与结果。",
"将对抗默认入口从本地预设任务接入正式任务清单、创建和执行链路;确认通用巡检步骤与五阶段排故工作台的适用范围。"
],
"finishedAt": "2026-09-05T15:58:39.239Z"
}

+ 2835
- 0
reports/training-modes-20260905/index.html
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 150
- 0
reports/training-modes-20260905/list-api-latency.json Voir le fichier

@@ -0,0 +1,150 @@
{
"checkedAt": "2026-09-05T15:59:47.158Z",
"method": "GET only, existing development administrator session; each request timeout 20 seconds",
"results": [
{
"round": 1,
"mode": "PHYSICAL",
"status": 200,
"elapsedMs": 247,
"total": 2,
"records": [
{
"id": "163",
"trainingMode": "PHYSICAL",
"hasCover": true
},
{
"id": "52",
"trainingMode": "PHYSICAL",
"hasCover": true
}
]
},
{
"round": 1,
"mode": "CONFRONTATION",
"status": 200,
"elapsedMs": 345,
"total": 4,
"records": [
{
"id": "164",
"trainingMode": "CONFRONTATION",
"hasCover": true
},
{
"id": "154",
"trainingMode": "CONFRONTATION",
"hasCover": true
},
{
"id": "98",
"trainingMode": "CONFRONTATION",
"hasCover": true
},
{
"id": "53",
"trainingMode": "CONFRONTATION",
"hasCover": true
}
]
},
{
"round": 2,
"mode": "PHYSICAL",
"status": 200,
"elapsedMs": 259,
"total": 2,
"records": [
{
"id": "163",
"trainingMode": "PHYSICAL",
"hasCover": true
},
{
"id": "52",
"trainingMode": "PHYSICAL",
"hasCover": true
}
]
},
{
"round": 2,
"mode": "CONFRONTATION",
"status": 200,
"elapsedMs": 344,
"total": 4,
"records": [
{
"id": "164",
"trainingMode": "CONFRONTATION",
"hasCover": true
},
{
"id": "154",
"trainingMode": "CONFRONTATION",
"hasCover": true
},
{
"id": "98",
"trainingMode": "CONFRONTATION",
"hasCover": true
},
{
"id": "53",
"trainingMode": "CONFRONTATION",
"hasCover": true
}
]
},
{
"round": 3,
"mode": "PHYSICAL",
"status": 200,
"elapsedMs": 308,
"total": 2,
"records": [
{
"id": "163",
"trainingMode": "PHYSICAL",
"hasCover": true
},
{
"id": "52",
"trainingMode": "PHYSICAL",
"hasCover": true
}
]
},
{
"round": 3,
"mode": "CONFRONTATION",
"status": 200,
"elapsedMs": 403,
"total": 4,
"records": [
{
"id": "164",
"trainingMode": "CONFRONTATION",
"hasCover": true
},
{
"id": "154",
"trainingMode": "CONFRONTATION",
"hasCover": true
},
{
"id": "98",
"trainingMode": "CONFRONTATION",
"hasCover": true
},
{
"id": "53",
"trainingMode": "CONFRONTATION",
"hasCover": true
}
]
}
]
}

+ 262
- 0
reports/training-modes-20260905/readonly-evidence.json Voir le fichier

@@ -0,0 +1,262 @@
{
"title": "独立浏览器重开、真实模型可见性及封面验证",
"status": "PASS",
"startedAt": "2026-09-05T15:52:55.523Z",
"checks": [
{
"title": "牛奶卡车实装检查:发布重开、模型实际可见、截图封面",
"status": "PASS",
"screenshots": [
"screenshots/physical-independent-reopen.png",
"screenshots/physical-independent-settings.png",
"screenshots/physical-final-cover.jpg"
],
"details": {
"state": "ready",
"meshes": 5,
"visibleMeshes": 5,
"skinnedMeshes": 0,
"position": [
4.2,
0.20617168998977797,
-0.4356763541543951
],
"scale": [
1.2323086149245381,
1.2323086149245381,
1.2323086149245381
],
"finiteBounds": true,
"size": [
3.4406057210321332,
3.390913184034896,
6.000000000000001
],
"camera": {
"position": [
10.565439077211714,
5.0456876852867705,
7.37050840519251
],
"target": [
4.2,
1.695456592017448,
0
]
},
"sample": {
"width": 320,
"height": 180,
"changedPixels": 11669
},
"scene": {
"projectId": "159",
"versionId": "209"
},
"targets": [
{
"id": "scene:OBJ-1788605199171-2",
"name": "牛奶卡车检验主装备",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-1ba581e6",
"name": "Yup2Zup",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-004dec41",
"name": "Cesium_Milk_Truck",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee",
"name": "Cesium_Milk_Truck_1",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-607e4d81",
"name": "Cesium_Milk_Truck_2",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-5d7e48c8",
"name": "Cesium_Milk_Truck_3",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-5e7e4a5b",
"name": "Node",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-ce2feac4",
"name": "Wheels",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-637e523a",
"name": "Node001",
"source": "scene"
},
{
"id": "scene:OBJ-1788605199171-2/part/interaction-f417f82d",
"name": "Wheels001",
"source": "scene"
}
],
"steps": [
{
"title": "核对卡车工位与装备",
"targetId": "scene:OBJ-1788605199171-2",
"weight": 30
},
{
"title": "检查卡车外观状态",
"targetId": "scene:OBJ-1788605199171-2",
"weight": 30
},
{
"title": "确认检查完成",
"targetId": "scene:OBJ-1788605199171-2",
"weight": 40
}
],
"cover": {
"assetId": "2111",
"bytes": 42163,
"sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
"width": 628,
"height": 640
},
"pageErrors": [],
"blockedWrites": []
}
},
{
"title": "狐狸红蓝协同巡检:发布重开、模型实际可见、截图封面",
"status": "PASS",
"screenshots": [
"screenshots/confrontation-independent-reopen.png",
"screenshots/confrontation-independent-settings.png",
"screenshots/confrontation-final-cover.jpg"
],
"details": {
"state": "ready",
"meshes": 1,
"visibleMeshes": 1,
"skinnedMeshes": 1,
"position": [
-7.5,
0.10930174852949677,
0.3440109341705351
],
"scale": [
0.03616375932570189,
0.03616375932570189,
0.03616375932570189
],
"finiteBounds": true,
"size": [
0.9658796100519353,
2.811899945787403,
6
],
"camera": {
"position": [
-0.04179225073787585,
6.099634646023559,
17.355036987378313
],
"target": [
-7.512232860974557,
1.4059499759307126,
-2.2204460492503126e-16
]
},
"sample": {
"width": 320,
"height": 180,
"changedPixels": 827
},
"scene": {
"projectId": "160",
"versionId": "210"
},
"targets": [
{
"id": "scene:OBJ-1788605873329-2",
"name": "维修装备主体 02",
"source": "scene"
},
{
"id": "scene:OBJ-1788605916565-6",
"name": "狐狸野外巡检目标",
"source": "scene"
},
{
"id": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26",
"name": "fox",
"source": "scene"
}
],
"steps": [
{
"title": "识别狐狸巡检目标",
"targetId": "scene:OBJ-1788605916565-6",
"weight": 50
},
{
"title": "完成狐狸状态检查",
"targetId": "scene:OBJ-1788605916565-6",
"weight": 50
}
],
"cover": {
"assetId": "2108",
"bytes": 51257,
"sha256": "79779a9d1e40c0c160fa3dab25e83931639942f53e2ee3a5da12f9f86ad70081",
"width": 628,
"height": 640
},
"pageErrors": [],
"blockedWrites": []
}
}
],
"projects": [
{
"id": "163",
"name": "实测训练-牛奶卡车实装检查-20260905",
"mode": "PHYSICAL",
"status": "PUBLISHED",
"currentVersionId": "217",
"publishedVersionId": "217",
"sceneProjectId": "159",
"sceneVersionId": "209",
"stepCount": 3,
"totalScore": 100,
"cover": {
"assetId": "2111",
"image": "screenshots/physical-final-cover.jpg"
}
},
{
"id": "164",
"name": "实测训练-狐狸红蓝协同巡检-20260905",
"mode": "CONFRONTATION",
"status": "PUBLISHED",
"currentVersionId": "216",
"publishedVersionId": "216",
"sceneProjectId": "160",
"sceneVersionId": "210",
"stepCount": 2,
"totalScore": 100,
"cover": {
"assetId": "2108",
"image": "screenshots/confrontation-final-cover.jpg"
}
}
],
"finishedAt": "2026-09-05T15:53:22.783Z"
}

+ 1989
- 0
reports/training-modes-20260905/report.md
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier
































+ 76
- 0
reports/training-modes-20260905/studio-regression/html/index.html
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 332
- 0
reports/training-modes-20260905/studio-regression/results.json Voir le fichier

@@ -0,0 +1,332 @@
{
"config": {
"configFile": "E:\\work\\edit_3d\\.codex-tmp\\training-studio-edge.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": "../unreal_tran/ute2e/reports/training-modes-20260905/studio-regression/html",
"open": "never"
}
],
[
"json",
{
"outputFile": "../unreal_tran/ute2e/reports/training-modes-20260905/studio-regression/results.json"
}
]
],
"reportSlowTests": {
"max": 5,
"threshold": 300000
},
"quiet": false,
"projects": [
{
"outputDir": "E:/work/edit_3d/unreal_tran/ute2e/reports/training-modes-20260905/studio-regression/test-results",
"repeatEach": 1,
"retries": 0,
"metadata": {
"actualWorkers": 1
},
"id": "edge-api-mock",
"name": "edge-api-mock",
"testDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
"testIgnore": [],
"testMatch": [
"content-training-studio.spec.ts"
],
"timeout": 90000
}
],
"shard": null,
"updateSnapshots": "missing",
"updateSourceMethod": "patch",
"version": "1.55.0",
"workers": 1,
"webServer": null
},
"suites": [
{
"title": "content-training-studio.spec.ts",
"file": "content-training-studio.spec.ts",
"column": 0,
"line": 0,
"specs": [],
"suites": [
{
"title": "训练编排工作台",
"file": "content-training-studio.spec.ts",
"line": 177,
"column": 6,
"specs": [
{
"title": "绑定场景解析目标、步骤编排与保存产出运行时编码",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 90000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "edge-api-mock",
"projectName": "edge-api-mock",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 5740,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T15:50:21.018Z",
"annotations": [],
"attachments": []
}
],
"status": "expected"
}
],
"id": "1a7f56b7a54b61c74698-9e443d39b12c3afac5d7",
"file": "content-training-studio.spec.ts",
"line": 178,
"column": 3
},
{
"title": "未固定场景时目标不可选并阻断发布",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 90000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "edge-api-mock",
"projectName": "edge-api-mock",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 2667,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T15:50:27.324Z",
"annotations": [],
"attachments": []
}
],
"status": "expected"
}
],
"id": "1a7f56b7a54b61c74698-14e619265f39e9179269",
"file": "content-training-studio.spec.ts",
"line": 260,
"column": 3
},
{
"title": "实装形态自动生成严格递增的事件进度",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 90000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "edge-api-mock",
"projectName": "edge-api-mock",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 1931,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T15:50:30.007Z",
"annotations": [],
"attachments": []
}
],
"status": "expected"
}
],
"id": "1a7f56b7a54b61c74698-79c43419f766d9ba63ff",
"file": "content-training-studio.spec.ts",
"line": 273,
"column": 3
},
{
"title": "对抗形态校验每队人数、岗位与可提交岗位",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 90000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "edge-api-mock",
"projectName": "edge-api-mock",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 3038,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T15:50:31.953Z",
"annotations": [],
"attachments": []
}
],
"status": "expected"
}
],
"id": "1a7f56b7a54b61c74698-dc1dddf62fa98b0f3c27",
"file": "content-training-studio.spec.ts",
"line": 301,
"column": 3
},
{
"title": "高级编排从线性流程派生并能加分支节点与时间轴",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 90000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "edge-api-mock",
"projectName": "edge-api-mock",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 2212,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T15:50:35.005Z",
"annotations": [],
"attachments": []
}
],
"status": "expected"
}
],
"id": "1a7f56b7a54b61c74698-7fe1949db429235b70fa",
"file": "content-training-studio.spec.ts",
"line": 324,
"column": 3
},
{
"title": "训练目标支持 Enter 换行并按非空行保存",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 90000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "edge-api-mock",
"projectName": "edge-api-mock",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 1646,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T15:50:37.228Z",
"annotations": [],
"attachments": []
}
],
"status": "expected"
}
],
"id": "1a7f56b7a54b61c74698-deca564eea6b78d08f2c",
"file": "content-training-studio.spec.ts",
"line": 383,
"column": 3
},
{
"title": "九个流程视图与工具、故障、评分视图均可渲染",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 90000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "edge-api-mock",
"projectName": "edge-api-mock",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 8786,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T15:50:38.883Z",
"annotations": [],
"attachments": []
}
],
"status": "expected"
}
],
"id": "1a7f56b7a54b61c74698-36c17cd11d90eb649e98",
"file": "content-training-studio.spec.ts",
"line": 400,
"column": 3
}
]
}
]
}
],
"errors": [],
"stats": {
"startTime": "2026-09-05T15:50:19.681Z",
"duration": 59440.333,
"expected": 7,
"skipped": 0,
"unexpected": 0,
"flaky": 0
}
}









+ 612
- 0
reports/training-return-tabs-20260906/evidence.json Voir le fichier

@@ -0,0 +1,612 @@
{
"title": "训练编排返回列表与页面标签聚焦回归",
"status": "PASS",
"startedAt": "2026-09-05T16:40:46.677Z",
"environment": {
"browser": "Microsoft Edge / Playwright",
"role": "真实管理员登录后切换教员",
"origins": [
"http://127.0.0.1:6180",
"http://192.168.31.168:6180"
]
},
"checks": [
{
"title": "手动切换标签保留原编辑页",
"status": "PASS",
"details": {
"before": {
"path": "/content/training-projects/162/legacy",
"entries": [
{
"key": "/dashboard",
"fullPath": "/dashboard",
"title": "工作总览",
"locked": false
},
{
"key": "/profile",
"fullPath": "/profile",
"title": "个人中心",
"locked": false
},
{
"key": "/content/training-projects",
"fullPath": "/content/training-projects",
"title": "训练编排",
"locked": false
},
{
"key": "/content/training-projects/161/legacy",
"fullPath": "/content/training-projects/161/legacy",
"title": "训练编排",
"locked": false
},
{
"key": "/content/training-projects/162/legacy",
"fullPath": "/content/training-projects/162/legacy",
"title": "训练编排",
"locked": false
}
],
"labels": [
{
"title": "工作总览",
"active": false
},
{
"title": "个人中心",
"active": false
},
{
"title": "训练编排",
"active": false
},
{
"title": "训练编排",
"active": false
},
{
"title": "训练编排",
"active": true
}
]
},
"after": {
"path": "/content/training-projects/161/legacy",
"entries": [
{
"key": "/dashboard",
"fullPath": "/dashboard",
"title": "工作总览",
"locked": false
},
{
"key": "/profile",
"fullPath": "/profile",
"title": "个人中心",
"locked": false
},
{
"key": "/content/training-projects",
"fullPath": "/content/training-projects",
"title": "训练编排",
"locked": false
},
{
"key": "/content/training-projects/161/legacy",
"fullPath": "/content/training-projects/161/legacy",
"title": "训练编排",
"locked": false
},
{
"key": "/content/training-projects/162/legacy",
"fullPath": "/content/training-projects/162/legacy",
"title": "训练编排",
"locked": false
}
],
"labels": [
{
"title": "工作总览",
"active": false
},
{
"title": "个人中心",
"active": false
},
{
"title": "训练编排",
"active": false
},
{
"title": "训练编排",
"active": true
},
{
"title": "训练编排",
"active": false
}
]
},
"method": "真实页面标签点击"
},
"screenshots": [
"screenshots/01-manual-switch-keeps-editors.png"
]
},
{
"title": "顶部返回只关闭当前编辑标签,保留列表及另一编辑标签",
"status": "PASS",
"details": {
"path": "/content/training-projects",
"entries": [
{
"key": "/dashboard",
"fullPath": "/dashboard",
"title": "工作总览",
"locked": false
},
{
"key": "/profile",
"fullPath": "/profile",
"title": "个人中心",
"locked": false
},
{
"key": "/content/training-projects",
"fullPath": "/content/training-projects",
"title": "训练编排",
"locked": false
},
{
"key": "/content/training-projects/162/legacy",
"fullPath": "/content/training-projects/162/legacy",
"title": "训练编排",
"locked": false
}
],
"labels": [
{
"title": "工作总览",
"active": false
},
{
"title": "个人中心",
"active": false
},
{
"title": "训练编排",
"active": true
},
{
"title": "训练编排",
"active": false
}
]
},
"screenshots": [
"screenshots/02-return-closes-current.png"
]
},
{
"title": "刷新后已关闭标签不复活",
"status": "PASS",
"details": {
"path": "/content/training-projects",
"entries": [
{
"key": "/dashboard",
"fullPath": "/dashboard",
"title": "工作总览",
"locked": false
},
{
"key": "/profile",
"fullPath": "/profile",
"title": "个人中心",
"locked": false
},
{
"key": "/content/training-projects",
"fullPath": "/content/training-projects",
"title": "训练编排",
"locked": false
},
{
"key": "/content/training-projects/162/legacy",
"fullPath": "/content/training-projects/162/legacy",
"title": "训练编排",
"locked": false
}
],
"labels": [
{
"title": "工作总览",
"active": false
},
{
"title": "个人中心",
"active": false
},
{
"title": "训练编排",
"active": true
},
{
"title": "训练编排",
"active": false
}
]
},
"screenshots": [
"screenshots/03-refresh-no-resurrection.png"
]
},
{
"title": "未保存时 Escape 取消返回,原页与修改保留",
"status": "PASS",
"details": {
"path": "/content/training-projects/161/legacy",
"entries": [
{
"key": "/dashboard",
"fullPath": "/dashboard",
"title": "工作总览",
"locked": false
},
{
"key": "/profile",
"fullPath": "/profile",
"title": "个人中心",
"locked": false
},
{
"key": "/content/training-projects",
"fullPath": "/content/training-projects",
"title": "训练编排",
"locked": false
},
{
"key": "/content/training-projects/162/legacy",
"fullPath": "/content/training-projects/162/legacy",
"title": "训练编排",
"locked": false
},
{
"key": "/content/training-projects/161/legacy",
"fullPath": "/content/training-projects/161/legacy",
"title": "训练编排",
"locked": false
}
],
"labels": [
{
"title": "工作总览",
"active": false
},
{
"title": "个人中心",
"active": false
},
{
"title": "训练编排",
"active": false
},
{
"title": "训练编排",
"active": false
},
{
"title": "训练编排",
"active": true
}
]
},
"screenshots": [
"screenshots/04-unsaved-return-confirmation.png",
"screenshots/05-escape-keeps-editor.png"
]
},
{
"title": "保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503)",
"status": "PASS",
"details": {
"tabs": {
"path": "/content/training-projects/161/legacy",
"entries": [
{
"key": "/dashboard",
"fullPath": "/dashboard",
"title": "工作总览",
"locked": false
},
{
"key": "/profile",
"fullPath": "/profile",
"title": "个人中心",
"locked": false
},
{
"key": "/content/training-projects",
"fullPath": "/content/training-projects",
"title": "训练编排",
"locked": false
},
{
"key": "/content/training-projects/162/legacy",
"fullPath": "/content/training-projects/162/legacy",
"title": "训练编排",
"locked": false
},
{
"key": "/content/training-projects/161/legacy",
"fullPath": "/content/training-projects/161/legacy",
"title": "训练编排",
"locked": false
}
],
"labels": [
{
"title": "工作总览",
"active": false
},
{
"title": "个人中心",
"active": false
},
{
"title": "训练编排",
"active": false
},
{
"title": "训练编排",
"active": false
},
{
"title": "训练编排",
"active": true
}
]
},
"writes": [
{
"origin": "http://127.0.0.1:6180",
"path": "/api/tran/v1/content/projects/161/assets/upload",
"method": "POST",
"response": 503,
"forwarded": false
},
{
"origin": "http://127.0.0.1:6180",
"path": "/api/tran/v1/content/projects/161",
"method": "PUT",
"response": 503,
"forwarded": false
}
],
"simulated": true
},
"screenshots": [
"screenshots/06-save-failure-keeps-editor.png"
]
},
{
"title": "明确放弃修改后返回并关闭当前编辑标签",
"status": "PASS",
"details": {
"path": "/content/training-projects",
"entries": [
{
"key": "/dashboard",
"fullPath": "/dashboard",
"title": "工作总览",
"locked": false
},
{
"key": "/profile",
"fullPath": "/profile",
"title": "个人中心",
"locked": false
},
{
"key": "/content/training-projects",
"fullPath": "/content/training-projects",
"title": "训练编排",
"locked": false
},
{
"key": "/content/training-projects/162/legacy",
"fullPath": "/content/training-projects/162/legacy",
"title": "训练编排",
"locked": false
}
],
"labels": [
{
"title": "工作总览",
"active": false
},
{
"title": "个人中心",
"active": false
},
{
"title": "训练编排",
"active": true
},
{
"title": "训练编排",
"active": false
}
]
},
"screenshots": [
"screenshots/07-discard-closes-current.png"
]
},
{
"title": "服务端两项目版本、封面与内容校验值未变",
"status": "PASS",
"details": {
"projects": [
{
"id": "161",
"version": 9,
"currentVersionId": "213",
"publishedVersionId": "213",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/32b2fb56adf53691ede867a8c957c573.jpg",
"checksum": "d5a0f4c872c8e275db980808375d9351bd51ea0aaf6b52bf453b7e8f9cc55605",
"unchanged": true,
"after": {
"id": "161",
"version": 9,
"currentVersionId": "213",
"publishedVersionId": "213",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/32b2fb56adf53691ede867a8c957c573.jpg",
"checksum": "d5a0f4c872c8e275db980808375d9351bd51ea0aaf6b52bf453b7e8f9cc55605"
}
},
{
"id": "162",
"version": 6,
"currentVersionId": "214",
"publishedVersionId": "214",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/be78d4ad687c3872a15001b8333f1afe.jpg",
"checksum": "e13b82f8fbc38ad327d8f83baec8295316e614132acf5eeeba25ea7371dfd9d3",
"unchanged": true,
"after": {
"id": "162",
"version": 6,
"currentVersionId": "214",
"publishedVersionId": "214",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/be78d4ad687c3872a15001b8333f1afe.jpg",
"checksum": "e13b82f8fbc38ad327d8f83baec8295316e614132acf5eeeba25ea7371dfd9d3"
}
}
],
"forwardedBusinessWrites": 0
},
"screenshots": []
},
{
"title": "局域网地址返回列表也关闭当前编辑标签",
"status": "PASS",
"details": {
"origin": "http://192.168.31.168:6180",
"tabs": {
"path": "/content/training-projects",
"entries": [
{
"key": "/dashboard",
"fullPath": "/dashboard",
"title": "工作总览",
"locked": false
},
{
"key": "/profile",
"fullPath": "/profile",
"title": "个人中心",
"locked": false
},
{
"key": "/content/training-projects",
"fullPath": "/content/training-projects",
"title": "训练编排",
"locked": false
}
],
"labels": [
{
"title": "工作总览",
"active": false
},
{
"title": "个人中心",
"active": false
},
{
"title": "训练编排",
"active": true
}
]
}
},
"screenshots": [
"screenshots/08-lan-return-closes-current.png"
]
}
],
"blockedWrites": [
{
"origin": "http://127.0.0.1:6180",
"path": "/api/tran/v1/content/projects/161/assets/upload",
"method": "POST",
"response": 503,
"forwarded": false
},
{
"origin": "http://127.0.0.1:6180",
"path": "/api/tran/v1/content/projects/161",
"method": "PUT",
"response": 503,
"forwarded": false
}
],
"pageErrors": [],
"projects": [
{
"id": "161",
"version": 9,
"currentVersionId": "213",
"publishedVersionId": "213",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/32b2fb56adf53691ede867a8c957c573.jpg",
"checksum": "d5a0f4c872c8e275db980808375d9351bd51ea0aaf6b52bf453b7e8f9cc55605",
"unchanged": true,
"after": {
"id": "161",
"version": 9,
"currentVersionId": "213",
"publishedVersionId": "213",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/32b2fb56adf53691ede867a8c957c573.jpg",
"checksum": "d5a0f4c872c8e275db980808375d9351bd51ea0aaf6b52bf453b7e8f9cc55605"
}
},
{
"id": "162",
"version": 6,
"currentVersionId": "214",
"publishedVersionId": "214",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/be78d4ad687c3872a15001b8333f1afe.jpg",
"checksum": "e13b82f8fbc38ad327d8f83baec8295316e614132acf5eeeba25ea7371dfd9d3",
"unchanged": true,
"after": {
"id": "162",
"version": 6,
"currentVersionId": "214",
"publishedVersionId": "214",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/be78d4ad687c3872a15001b8333f1afe.jpg",
"checksum": "e13b82f8fbc38ad327d8f83baec8295316e614132acf5eeeba25ea7371dfd9d3"
}
}
],
"preparationNotes": [],
"scope": [
"仅验证顶部返回列表及现有离开确认。打开已有训练 161/162,业务写请求全部由浏览器拦截。",
"“保存并离开”的失败由浏览器模拟 HTTP 503;不代表真实服务端故障。没有真实保存、发布、上传或修改这两条项目。",
"本轮未测试成功保存或成功发布;其业务路径未被本次代码修改。"
],
"commands": [
{
"command": "pnpm run build",
"status": "PASS",
"details": "根代理执行,含 vue-tsc、Vite 与 iframe 校验;既有构建警告不影响退出码 0。"
},
{
"command": "git diff --check",
"status": "PASS",
"details": "根代理执行,退出码 0。"
}
],
"finishedAt": "2026-09-05T16:41:29.185Z"
}

+ 463
- 0
reports/training-return-tabs-20260906/index.html Voir le fichier

@@ -0,0 +1,463 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>训练编排返回列表与页面标签聚焦回归</title><style>*{box-sizing:border-box}body{margin:0;background:#f2f6f8;color:#203b46;font:15px/1.75 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1180px;margin:auto;padding:28px 22px}header{background:#103c45;color:white;padding:26px 30px;border-radius:12px}h1{font-size:26px;margin:0 0 12px}h2{font-size:21px}h3{font-size:17px;overflow-wrap:anywhere}section,article{background:white;border:1px solid #dce6ec;border-radius:10px;padding:22px 26px;margin:20px 0}.pass{color:#14744f}.fail{color:#b5373e}header .pass{color:#a9f0d2}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f6f8fa;padding:15px;max-height:440px;overflow:auto;font-size:12px}summary{color:#137d6e;cursor:pointer}figure{margin:18px 0 0}img{display:block;width:100%;height:auto;border:1px solid #dce6ec}figcaption,footer{font-size:13px;color:#637f8c;overflow-wrap:anywhere}li,p{overflow-wrap:anywhere}@media(max-width:700px){main{padding:14px 10px}header,section,article{padding:18px}h1{font-size:23px}}</style></head><body><main><header><h1>训练编排返回列表与页面标签聚焦回归 <span class="pass">PASS</span></h1><p>真实教员身份 · 已有狐狸 161 / 牛奶卡车 162 · localhost 与局域网</p><p>通过 8 / 8 项 · 业务真实写入 0 次</p></header><section><h2>资料 → 原型 → 设计 → 开发</h2><p>依据用户要求及现有标签约定:点击顶部“返回列表”完成导航后关闭当前编辑标签;导航取消或保存失败时保留。手动切换标签不关闭工程。</p><p>本次仅修改 LegacyTrainingPageView.vue 的返回回调,先等待路由与离开确认完成,再关闭捕获的原标签。保存、发布、标签 store 与 PageTabs 的路径保持现状。</p><ul><li>仅验证顶部返回列表及现有离开确认。打开已有训练 161/162,业务写请求全部由浏览器拦截。</li><li>“保存并离开”的失败由浏览器模拟 HTTP 503;不代表真实服务端故障。没有真实保存、发布、上传或修改这两条项目。</li><li>本轮未测试成功保存或成功发布;其业务路径未被本次代码修改。</li></ul></section><section><h2>测试与构建</h2><p><b>pnpm run build:PASS</b> · 根代理执行,含 vue-tsc、Vite 与 iframe 校验;既有构建警告不影响退出码 0。</p><p><b>git diff --check:PASS</b> · 根代理执行,退出码 0。</p><p>JSON 证据:<a href="evidence.json">evidence.json</a>。所有截图来自实际浏览器,测试凭据没有写入报告。</p></section><article><h3>手动切换标签保留原编辑页 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{
&quot;before&quot;: {
&quot;path&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;entries&quot;: [
{
&quot;key&quot;: &quot;/dashboard&quot;,
&quot;fullPath&quot;: &quot;/dashboard&quot;,
&quot;title&quot;: &quot;工作总览&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/profile&quot;,
&quot;fullPath&quot;: &quot;/profile&quot;,
&quot;title&quot;: &quot;个人中心&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects/161/legacy&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects/161/legacy&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
}
],
&quot;labels&quot;: [
{
&quot;title&quot;: &quot;工作总览&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;个人中心&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: true
}
]
},
&quot;after&quot;: {
&quot;path&quot;: &quot;/content/training-projects/161/legacy&quot;,
&quot;entries&quot;: [
{
&quot;key&quot;: &quot;/dashboard&quot;,
&quot;fullPath&quot;: &quot;/dashboard&quot;,
&quot;title&quot;: &quot;工作总览&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/profile&quot;,
&quot;fullPath&quot;: &quot;/profile&quot;,
&quot;title&quot;: &quot;个人中心&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects/161/legacy&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects/161/legacy&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
}
],
&quot;labels&quot;: [
{
&quot;title&quot;: &quot;工作总览&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;个人中心&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: true
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: false
}
]
},
&quot;method&quot;: &quot;真实页面标签点击&quot;
}</pre></details><figure><a href="screenshots/01-manual-switch-keeps-editors.png" target="_blank"><img src="screenshots/01-manual-switch-keeps-editors.png" alt="手动切换标签保留原编辑页" loading="lazy"></a><figcaption>手动切换标签保留原编辑页 · screenshots/01-manual-switch-keeps-editors.png</figcaption></figure></article><article><h3>顶部返回只关闭当前编辑标签,保留列表及另一编辑标签 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{
&quot;path&quot;: &quot;/content/training-projects&quot;,
&quot;entries&quot;: [
{
&quot;key&quot;: &quot;/dashboard&quot;,
&quot;fullPath&quot;: &quot;/dashboard&quot;,
&quot;title&quot;: &quot;工作总览&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/profile&quot;,
&quot;fullPath&quot;: &quot;/profile&quot;,
&quot;title&quot;: &quot;个人中心&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
}
],
&quot;labels&quot;: [
{
&quot;title&quot;: &quot;工作总览&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;个人中心&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: true
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: false
}
]
}</pre></details><figure><a href="screenshots/02-return-closes-current.png" target="_blank"><img src="screenshots/02-return-closes-current.png" alt="顶部返回只关闭当前编辑标签,保留列表及另一编辑标签" loading="lazy"></a><figcaption>顶部返回只关闭当前编辑标签,保留列表及另一编辑标签 · screenshots/02-return-closes-current.png</figcaption></figure></article><article><h3>刷新后已关闭标签不复活 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{
&quot;path&quot;: &quot;/content/training-projects&quot;,
&quot;entries&quot;: [
{
&quot;key&quot;: &quot;/dashboard&quot;,
&quot;fullPath&quot;: &quot;/dashboard&quot;,
&quot;title&quot;: &quot;工作总览&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/profile&quot;,
&quot;fullPath&quot;: &quot;/profile&quot;,
&quot;title&quot;: &quot;个人中心&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
}
],
&quot;labels&quot;: [
{
&quot;title&quot;: &quot;工作总览&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;个人中心&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: true
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: false
}
]
}</pre></details><figure><a href="screenshots/03-refresh-no-resurrection.png" target="_blank"><img src="screenshots/03-refresh-no-resurrection.png" alt="刷新后已关闭标签不复活" loading="lazy"></a><figcaption>刷新后已关闭标签不复活 · screenshots/03-refresh-no-resurrection.png</figcaption></figure></article><article><h3>未保存时 Escape 取消返回,原页与修改保留 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{
&quot;path&quot;: &quot;/content/training-projects/161/legacy&quot;,
&quot;entries&quot;: [
{
&quot;key&quot;: &quot;/dashboard&quot;,
&quot;fullPath&quot;: &quot;/dashboard&quot;,
&quot;title&quot;: &quot;工作总览&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/profile&quot;,
&quot;fullPath&quot;: &quot;/profile&quot;,
&quot;title&quot;: &quot;个人中心&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects/161/legacy&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects/161/legacy&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
}
],
&quot;labels&quot;: [
{
&quot;title&quot;: &quot;工作总览&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;个人中心&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: true
}
]
}</pre></details><figure><a href="screenshots/04-unsaved-return-confirmation.png" target="_blank"><img src="screenshots/04-unsaved-return-confirmation.png" alt="未保存时 Escape 取消返回,原页与修改保留" loading="lazy"></a><figcaption>未保存时 Escape 取消返回,原页与修改保留 · screenshots/04-unsaved-return-confirmation.png</figcaption></figure><figure><a href="screenshots/05-escape-keeps-editor.png" target="_blank"><img src="screenshots/05-escape-keeps-editor.png" alt="未保存时 Escape 取消返回,原页与修改保留" loading="lazy"></a><figcaption>未保存时 Escape 取消返回,原页与修改保留 · screenshots/05-escape-keeps-editor.png</figcaption></figure></article><article><h3>保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503) <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{
&quot;tabs&quot;: {
&quot;path&quot;: &quot;/content/training-projects/161/legacy&quot;,
&quot;entries&quot;: [
{
&quot;key&quot;: &quot;/dashboard&quot;,
&quot;fullPath&quot;: &quot;/dashboard&quot;,
&quot;title&quot;: &quot;工作总览&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/profile&quot;,
&quot;fullPath&quot;: &quot;/profile&quot;,
&quot;title&quot;: &quot;个人中心&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects/161/legacy&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects/161/legacy&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
}
],
&quot;labels&quot;: [
{
&quot;title&quot;: &quot;工作总览&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;个人中心&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: true
}
]
},
&quot;writes&quot;: [
{
&quot;origin&quot;: &quot;http://127.0.0.1:6180&quot;,
&quot;path&quot;: &quot;/api/tran/v1/content/projects/161/assets/upload&quot;,
&quot;method&quot;: &quot;POST&quot;,
&quot;response&quot;: 503,
&quot;forwarded&quot;: false
},
{
&quot;origin&quot;: &quot;http://127.0.0.1:6180&quot;,
&quot;path&quot;: &quot;/api/tran/v1/content/projects/161&quot;,
&quot;method&quot;: &quot;PUT&quot;,
&quot;response&quot;: 503,
&quot;forwarded&quot;: false
}
],
&quot;simulated&quot;: true
}</pre></details><figure><a href="screenshots/06-save-failure-keeps-editor.png" target="_blank"><img src="screenshots/06-save-failure-keeps-editor.png" alt="保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503)" loading="lazy"></a><figcaption>保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503) · screenshots/06-save-failure-keeps-editor.png</figcaption></figure></article><article><h3>明确放弃修改后返回并关闭当前编辑标签 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{
&quot;path&quot;: &quot;/content/training-projects&quot;,
&quot;entries&quot;: [
{
&quot;key&quot;: &quot;/dashboard&quot;,
&quot;fullPath&quot;: &quot;/dashboard&quot;,
&quot;title&quot;: &quot;工作总览&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/profile&quot;,
&quot;fullPath&quot;: &quot;/profile&quot;,
&quot;title&quot;: &quot;个人中心&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects/162/legacy&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
}
],
&quot;labels&quot;: [
{
&quot;title&quot;: &quot;工作总览&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;个人中心&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: true
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: false
}
]
}</pre></details><figure><a href="screenshots/07-discard-closes-current.png" target="_blank"><img src="screenshots/07-discard-closes-current.png" alt="明确放弃修改后返回并关闭当前编辑标签" loading="lazy"></a><figcaption>明确放弃修改后返回并关闭当前编辑标签 · screenshots/07-discard-closes-current.png</figcaption></figure></article><article><h3>服务端两项目版本、封面与内容校验值未变 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{
&quot;projects&quot;: [
{
&quot;id&quot;: &quot;161&quot;,
&quot;version&quot;: 9,
&quot;currentVersionId&quot;: &quot;213&quot;,
&quot;publishedVersionId&quot;: &quot;213&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/32b2fb56adf53691ede867a8c957c573.jpg&quot;,
&quot;checksum&quot;: &quot;d5a0f4c872c8e275db980808375d9351bd51ea0aaf6b52bf453b7e8f9cc55605&quot;,
&quot;unchanged&quot;: true,
&quot;after&quot;: {
&quot;id&quot;: &quot;161&quot;,
&quot;version&quot;: 9,
&quot;currentVersionId&quot;: &quot;213&quot;,
&quot;publishedVersionId&quot;: &quot;213&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/32b2fb56adf53691ede867a8c957c573.jpg&quot;,
&quot;checksum&quot;: &quot;d5a0f4c872c8e275db980808375d9351bd51ea0aaf6b52bf453b7e8f9cc55605&quot;
}
},
{
&quot;id&quot;: &quot;162&quot;,
&quot;version&quot;: 6,
&quot;currentVersionId&quot;: &quot;214&quot;,
&quot;publishedVersionId&quot;: &quot;214&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/be78d4ad687c3872a15001b8333f1afe.jpg&quot;,
&quot;checksum&quot;: &quot;e13b82f8fbc38ad327d8f83baec8295316e614132acf5eeeba25ea7371dfd9d3&quot;,
&quot;unchanged&quot;: true,
&quot;after&quot;: {
&quot;id&quot;: &quot;162&quot;,
&quot;version&quot;: 6,
&quot;currentVersionId&quot;: &quot;214&quot;,
&quot;publishedVersionId&quot;: &quot;214&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/be78d4ad687c3872a15001b8333f1afe.jpg&quot;,
&quot;checksum&quot;: &quot;e13b82f8fbc38ad327d8f83baec8295316e614132acf5eeeba25ea7371dfd9d3&quot;
}
}
],
&quot;forwardedBusinessWrites&quot;: 0
}</pre></details></article><article><h3>局域网地址返回列表也关闭当前编辑标签 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{
&quot;origin&quot;: &quot;http://192.168.31.168:6180&quot;,
&quot;tabs&quot;: {
&quot;path&quot;: &quot;/content/training-projects&quot;,
&quot;entries&quot;: [
{
&quot;key&quot;: &quot;/dashboard&quot;,
&quot;fullPath&quot;: &quot;/dashboard&quot;,
&quot;title&quot;: &quot;工作总览&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/profile&quot;,
&quot;fullPath&quot;: &quot;/profile&quot;,
&quot;title&quot;: &quot;个人中心&quot;,
&quot;locked&quot;: false
},
{
&quot;key&quot;: &quot;/content/training-projects&quot;,
&quot;fullPath&quot;: &quot;/content/training-projects&quot;,
&quot;title&quot;: &quot;训练编排&quot;,
&quot;locked&quot;: false
}
],
&quot;labels&quot;: [
{
&quot;title&quot;: &quot;工作总览&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;个人中心&quot;,
&quot;active&quot;: false
},
{
&quot;title&quot;: &quot;训练编排&quot;,
&quot;active&quot;: true
}
]
}
}</pre></details><figure><a href="screenshots/08-lan-return-closes-current.png" target="_blank"><img src="screenshots/08-lan-return-closes-current.png" alt="局域网地址返回列表也关闭当前编辑标签" loading="lazy"></a><figcaption>局域网地址返回列表也关闭当前编辑标签 · screenshots/08-lan-return-closes-current.png</figcaption></figure></article><footer>开始 2026-09-05T16:40:46.677Z · 完成 2026-09-05T16:41:29.185Z · 新增业务数据 0;浏览器内临时修改已放弃。</footer></main></body></html>









+ 8
- 4
tests/content-training-studio.spec.ts Voir le fichier

@@ -104,13 +104,14 @@ class TrainingMock {
}

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: 'admin', displayName: '管理员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 },
activeRoleId: 'role-admin',
roles: [{ id: 'role-admin', code: 'admin', name: '管理员', shortName: '管', status: 1, builtIn: 1, isSuperAdmin: 1, dataScopeCode: 'ALL' }],
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,
}))
@@ -301,6 +302,9 @@ test.describe('训练编排工作台', () => {
const mock = new TrainingMock()
mock.mode = 'CONFRONTATION'
mock.content = legacyTrainingContent('CONFRONTATION')
// Missing fields in old documents now use compatibility defaults; an explicit empty
// grant must stay empty and be blocked by publish validation.
for (const step of mock.content.steps as Json[]) step.allowedPositions = []
await mock.install(page)

await page.goto('/content/training-projects/901/studio?view=overview')
@@ -311,7 +315,7 @@ test.describe('训练编排工作台', () => {
await page.locator('.step-row').first().click()
await expect(page.locator('.inspector-panel')).toContainText('执行岗位')

// 历史工程的对抗步骤没有岗位,发布校验必须逐步骤阻断
// 明确清空对抗步骤岗位后,发布校验必须逐步骤阻断,不能恢复默认权限。
await page.goto('/content/training-projects/901/studio?view=preflight')
await expect(page.locator('.issue-list').first()).toContainText('尚未指定执行岗位')
await page.screenshot({ path: path.join(SHOTS, '06-对抗校验.png'), fullPage: true })


+ 237
- 0
tools/build-training-live-report.mjs Voir le fichier

@@ -0,0 +1,237 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

// Browser runs own the evidence files. This generator only writes index.html
// and report.md. Missing evidence remains pending, never an inferred pass.
// Each JSON may use { status, title, checks/evidence/results/tests: [...],
// projects: [...], commands: [...], limitations: [...] }. Unknown shapes remain
// available as sanitized JSON details rather than being silently discarded.
const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/training-live-20260905')
fs.mkdirSync(reportDir, { recursive: true })
const array = value => Array.isArray(value) ? value : value == null ? [] : [value]
const sensitiveKey = /password|passwd|secret|token|ticket|authorization|cookie|signature|credential|api[_-]?key|username|loginName|accountName|headers|storageState/i
const cleanText = value => String(value ?? '')
.replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
.replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[已隐藏]@')
.replace(/\b((?:access|refresh)?[_-]?token|password|passwd|secret|uploadTicket|signature|api[_-]?key|X-Amz-(?:Signature|Credential|Security-Token))\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;&}]+)/gi, '$1=[已隐藏]')
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[已隐藏]')
function sanitize(value) {
if (typeof value === 'string') return cleanText(value)
if (Array.isArray(value)) return value.map(sanitize)
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
.filter(([key]) => !sensitiveKey.test(key)).map(([key, item]) => [key, sanitize(item)]))
return value
}
const html = value => cleanText(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
const md = value => cleanText(value).replace(/[|\[\]*_`<>]/g, char => `\\${char}`).replace(/\r?\n/g, ' ')
const pretty = value => typeof value === 'string' ? value : JSON.stringify(value, null, 2)
const recorded = value => value === undefined || value === null || value === '' ? '待最终证据' : value
const status = value => {
const normalized = String(value ?? '').toUpperCase()
return normalized === 'PASS' || normalized === 'PASSED' ? 'PASS'
: normalized === 'FAIL' || normalized === 'FAILED' ? 'FAIL' : 'PENDING'
}
const statusLabel = value => ({ PASS: '通过', FAIL: '失败', PENDING: '待补充证据' })[status(value)]
const statusClass = value => status(value).toLowerCase()

const sourceDefinitions = [
{ file: 'final-evidence.json', title: '真实编排、保存、发布及重新打开' },
{ file: 'readonly-evidence.json', title: '独立浏览器只读重开与真实画面验证' },
{ file: 'error-evidence.json', title: '失败、冲突与并发处理' },
]
const sources = sourceDefinitions.map(definition => {
const absolute = path.join(reportDir, definition.file)
const data = fs.existsSync(absolute)
? sanitize(JSON.parse(fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, ''))) : null
const checks = data ? array(data.checks || data.evidence || data.results || data.tests) : []
const assessed = checks.filter(item => item && item.superseded !== true && item.historical !== true)
const sourceStatus = assessed.some(item => status(item.status) === 'FAIL') ? 'FAIL'
: status(data?.status) !== 'PENDING' ? status(data.status)
: assessed.length && assessed.every(item => status(item.status) === 'PASS') ? 'PASS'
: assessed.some(item => status(item.status) === 'FAIL') ? 'FAIL' : 'PENDING'
return { ...definition, data, checks, status: sourceStatus }
})
const final = sources[0].data || {}
const projects = array(final.projects || final.retainedProjects || final.trainingProjects)
const reopenedProjects = array(sources[1].data?.projects)
const versionMismatches = projects.flatMap(project => {
const expected = project.publishedVersionId || project.finalVersionId || project.currentVersionId
if (!expected || !sources[1].data) return []
const reopened = reopenedProjects.find(item => String(item.id || item.projectId) === String(project.id || project.projectId))
const actual = reopened?.publishedVersionId || reopened?.currentVersionId
return String(expected) === String(actual) ? [] : [{ projectId: project.id || project.projectId, expected, actual: actual || '未记录' }]
})
if (versionMismatches.length && sources[1].status !== 'FAIL') sources[1].status = 'PENDING'
const allChecks = sources.flatMap(source => source.checks)
.filter(item => item && item.superseded !== true && item.historical !== true)
const passedChecks = allChecks.filter(item => status(item.status) === 'PASS').length
const failedChecks = allChecks.filter(item => status(item.status) === 'FAIL').length
const pendingChecks = allChecks.length - passedChecks - failedChecks
const overall = sources.some(source => source.status === 'FAIL') || failedChecks ? 'FAIL'
: sources.every(source => source.status === 'PASS') && !pendingChecks ? 'PASS' : 'PENDING'
const commands = sources.flatMap(source => array(source.data?.commands))
const limitations = [...new Map(sources.flatMap(source => array(source.data?.limitations || source.data?.followUpIssues))
.map(item => [pretty(item), item])).values()]
const generatedAt = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })

const history = [
{ path: 'screenshots/01-before-scene-catalog.png', caption: '过程记录:初建训练仍显示默认液压模板,仅记录起点,不作为故障对照或最终通过证据。' },
{ path: 'screenshots/02-published-scenes.png', caption: '过程记录:打开已发布场景目录。最终版本和固定依赖以最终证据为准。' },
{ path: 'screenshots/03-truck-bound.png', caption: '过程记录:首次绑定卡车时仍有 8 个旧模板步骤;不代表最终两步编排通过。' },
{ path: 'screenshots/04-truck-saved.png', caption: '过程记录:卡车训练阶段性保存;最终步骤、状态、版本和封面以最终证据为准。' },
].filter(item => fs.existsSync(path.join(reportDir, item.path)))
const historyPaths = new Set(history.map(item => item.path))
const screenshotPattern = /^screenshots\/[\w\W]+\.(?:png|jpe?g|webp)$/i
const imagePaths = new Set()
function imageRefs(value, inheritedCaption = '') {
if (!value) return []
if (typeof value === 'string') return screenshotPattern.test(value.replaceAll('\\', '/'))
? [{ path: value.replaceAll('\\', '/'), caption: inheritedCaption }] : []
if (Array.isArray(value)) return value.flatMap(item => imageRefs(item, inheritedCaption))
if (typeof value !== 'object') return []
const caption = value.caption || value.title || value.label || value.name || inheritedCaption
return Object.entries(value).flatMap(([key, item]) => imageRefs(item, caption || key))
}
function validateImage(relative) {
const absolute = path.resolve(reportDir, relative)
const relativeToRoot = path.relative(reportDir, absolute)
if (path.isAbsolute(relative) || /^[a-z][a-z\d+.-]*:/i.test(relative)
|| relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)
|| !screenshotPattern.test(relative) || !fs.existsSync(absolute)
|| !fs.statSync(absolute).isFile() || fs.statSync(absolute).size === 0) {
throw new Error(`截图引用不存在、为空或不在报告目录内:${relative}`)
}
const realRelative = path.relative(fs.realpathSync(reportDir), fs.realpathSync(absolute))
if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) throw new Error(`截图链接越出报告目录:${relative}`)
imagePaths.add(relative)
}
const uniqueImages = refs => [...new Map(refs.map(ref => [ref.path, ref])).values()]
const evidenceImages = uniqueImages(sources.flatMap(source => imageRefs(source.data)))
evidenceImages.forEach(ref => validateImage(ref.path))
history.forEach(ref => validateImage(ref.path))
const finalImages = evidenceImages.filter(ref => !historyPaths.has(ref.path))
const imageUrl = value => value.split('/').map(encodeURIComponent).join('/')
const figure = ref => `<figure><a href="${html(imageUrl(ref.path))}" target="_blank" rel="noopener"><img src="${html(imageUrl(ref.path))}" alt="${html(ref.caption || ref.path)}" loading="lazy"></a><figcaption>${html(ref.caption || ref.path)} · 点击查看原图</figcaption></figure>`
const mdFigure = ref => `[![${md(ref.caption || ref.path)}](${imageUrl(ref.path)})](${imageUrl(ref.path)})`
const details = (value, title = '查看脱敏后的完整记录') => `<details><summary>${html(title)}</summary><pre>${html(pretty(value))}</pre></details>`
const bullets = items => `<ul>${items.map(item => `<li>${html(item)}</li>`).join('')}</ul>`
const mdBullets = items => items.map(item => `- ${md(item)}`).join('\n')
const badge = value => `<span class="badge ${statusClass(value)}">${statusLabel(value)}</span>`

const stages = [
{ title: '1 · 资料', items: [
'参考《文档/维修训练内容设计.pdf》(6 页)与已有场景制作成果。按“场景 → 任务步骤 → 学习 / 训练 / 评估”的资料结构检查实现。',
'本次使用已发布的狐狸场景和牛奶卡车场景验证训练编排,确认真实模型可见、步骤目标来自绑定场景、保存及发布使用服务端版本。',
] },
{ title: '2 · 产品原型', items: [
'沿用现有三维训练编排工作台:选择场景、选择三维对象、配置任务步骤、保存草稿和发布。',
'新建时的液压模板仅作为现有界面起点;绑定新场景后不得继续选择该场景中不存在的液压设备。',
] },
{ title: '3 · 设计', items: [
'两条独立训练分别绑定狐狸与卡车的明确发布版本;每条配置两步、每步 50 分,总计 100 分,并核验重新打开后的状态。',
'保存时以三维视口截图生成训练封面;模型可见性要通过实际截图及模型显示 / 隐藏的像素差异判断。',
'将正常流程、独立浏览器只读重开、资源失败、封面失败、版本冲突及并发切换分别留证。独立重开阻断所有业务写请求,核验模型像素可见性、封面哈希与发布依赖;不将其表述为只读角色权限测试。',
] },
{ title: '4 · 开发', items: [
'场景目录对接已发布内容版本;GLB 通过受控下载读取;训练依赖固定到所选场景版本,恢复项目时保留来源元数据。',
'修复蒙皮模型缩放后的骨骼矩阵与包围盒刷新;场景恢复、部位覆盖和高亮使用一致的尺寸计算。',
'绑定场景后移除不存在的内置操作目标,过滤隐藏或已删除对象及其子目标。',
'保存和发布等待异步操作完成;封面与内容在同一次版本保存中持久化;失败、冲突及并发切换必须保留可恢复状态。最终实现效果以下方真实测试记录为准。',
'修复旧工程快照回写覆盖最新名称与训练形态;场景绑定进行中禁止保存和发布,避免模型尚未加载成功就写入新的场景依赖。',
] },
]
const scopeItems = [
'本次范围为训练编排、草稿保存、发布与重新打开,不包含创建教学任务、下发学员或完成学员训练。',
'“预览训练”仍提示需发布后在教学实施中下发;本报告不将该提示视作学员训练已运行。',
'本轮仅修复已实现功能的问题,无新增需要用户确认的功能改动。',
]
const projectValue = (project, names) => names.map(name => project?.[name]).find(value => value !== undefined && value !== null && value !== '')
function projectRow(project) {
const scene = project.scene || project.sceneDependency || project.boundScene || {}
return [
projectValue(project, ['id', 'projectId', 'trainingProjectId']),
projectValue(project, ['name', 'title']),
projectValue(project, ['finalStatus', 'status']),
projectValue(project, ['finalVersionId', 'currentVersionId', 'versionId']),
projectValue(project, ['publishedVersionId']),
projectValue(project, ['sceneProjectId', 'sceneId']) || projectValue(scene, ['projectId', 'sceneId', 'id']),
projectValue(project, ['sceneVersionId']) || projectValue(scene, ['versionId', 'publishedVersionId']),
projectValue(project, ['stepCount']) ?? (Array.isArray(project.steps) ? project.steps.length : undefined),
projectValue(project, ['totalScore', 'score']),
].map(recorded)
}
const projectHeaders = ['训练 ID', '名称', '状态', '当前版本', '发布版本', '场景 ID', '固定场景版本', '步骤数', '总分']
const htmlProjects = projects.length ? `<div class="table-scroll"><table><thead><tr>${projectHeaders.map(value => `<th>${html(value)}</th>`).join('')}</tr></thead><tbody>${projects.map(project => `<tr>${projectRow(project).map(value => `<td>${html(value)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${projects.map(project => details(project, `训练 ${recorded(projectValue(project, ['id', 'projectId', 'trainingProjectId']))}:版本、步骤、封面与依赖记录`)).join('')}`
: '<p class="pending-note">已开始创建训练 161(狐狸)与 162(卡车);最终状态、版本、封面与场景依赖等待 final-evidence.json,不以阶段性保存结果代替最终记录。</p>'
const mdProjects = projects.length ? `| ${projectHeaders.map(md).join(' | ')} |\n| ${projectHeaders.map(() => '---').join(' | ')} |\n${projects.map(project => `| ${projectRow(project).map(md).join(' | ')} |`).join('\n')}`
: '已开始创建训练 161(狐狸)与 162(卡车);最终状态、版本、封面与场景依赖等待 final-evidence.json。'
function sourceHtml(source) {
if (!source.data) return `<article class="test"><h3>${html(source.title)} ${badge('PENDING')}</h3><p>等待 ${html(source.file)}。未计为通过。</p></article>`
return `<article class="test"><h3>${html(source.data.title || source.title)} ${badge(source.status)}</h3>${source.checks.map((check, index) => `<div class="check"><h4>${html(check.title || check.name || check.label || `检查 ${index + 1}`)} ${badge(check.status)}</h4>${check.superseded || check.historical ? '<p class="pending-note">历史记录,不计入当前通过数。</p>' : ''}${details(check)}</div>`).join('')}${details(source.data, `${source.file} · 完整记录`)}</article>`
}
function sourceMd(source) {
const title = source.data?.title || source.title
if (!source.data) return `### ${title} · 待补充证据\n\n等待 ${source.file},未计为通过。`
const checks = source.checks.map((check, index) => `- ${md(check.title || check.name || check.label || `检查 ${index + 1}`)}:${statusLabel(check.status)}${check.superseded || check.historical ? '(历史记录,不计入当前通过数)' : ''}`).join('\n')
return `### ${title} · ${statusLabel(source.status)}\n\n${checks}\n\n<details><summary>${html(source.file)} · 脱敏完整记录</summary>\n\n<pre>${html(pretty(source.data))}</pre>\n\n</details>`
}
const summary = `证据组:${sources.filter(source => source.status === 'PASS').length}/${sources.length} 通过;明确检查:通过 ${passedChecks},失败 ${failedChecks},待验证 ${pendingChecks};已校验截图 ${imagePaths.size} 张。`
const htmlCommands = commands.map(command => `<div class="check"><h4>${html(command.command || command.name || '验证命令')} ${badge(command.status)}</h4><p>${html(command.details || command.result || '')}</p></div>`).join('')
const mdCommands = commands.map(command => `- ${md(command.command || command.name || '验证命令')}:${statusLabel(command.status)}。${md(command.details || command.result || '')}`).join('\n')
const outputHtml = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>训练编排真实测试报告 · 2026-09-05</title>
<style>
:root{color-scheme:light;--ink:#203544;--muted:#607789;--line:#dce6ec;--green:#137d6e;--soft:#eef6f7}*{box-sizing:border-box}body{margin:0;background:#f3f6f8;color:var(--ink);font:15px/1.75 system-ui,-apple-system,"Microsoft YaHei",sans-serif}main{max-width:1220px;margin:auto;padding:32px 24px 64px}header{padding:30px 34px;background:#103c45;color:white;border-radius:16px}h1{font-size:29px;margin:0 0 12px}header p{color:#d7e8eb;margin:7px 0}nav{display:flex;gap:10px;flex-wrap:wrap;margin:22px 0}nav a{color:var(--green);text-decoration:none;background:white;border:1px solid var(--line);padding:6px 13px;border-radius:7px}section{background:white;border:1px solid var(--line);border-radius:12px;padding:26px 30px;margin:20px 0}h2{font-size:21px;margin:0 0 16px}h3{font-size:17px;margin:0 0 12px}h4{font-size:15px;margin:0 0 7px}p{margin:10px 0}ul{padding-left:22px}li+li{margin-top:7px}.badge{display:inline-block;vertical-align:middle;font-size:12px;font-weight:600;line-height:1.7;border-radius:4px;padding:2px 8px;margin-left:5px}.pass{color:#11634f;background:#e3f5ed}.fail{color:#9e2a35;background:#ffe9ec}.pending{color:#7b6121;background:#fff2cc}.muted{color:var(--muted)}.pending-note{padding:12px 16px;background:#fff8e7;border-left:3px solid #c09a3b}.stages{display:grid;grid-template-columns:1fr 1fr;gap:18px}.stage{border:1px solid var(--line);border-radius:8px;padding:20px}.stage ul{margin-bottom:0}.test+.test{border-top:1px solid var(--line);margin-top:22px;padding-top:22px}.check{border-left:3px solid var(--line);padding:8px 16px;margin:12px 0}details{margin:12px 0}summary{cursor:pointer;color:var(--green)}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:600px;overflow:auto;padding:16px;background:#f5f8fa;border:1px solid var(--line);border-radius:7px;font:12px/1.6 ui-monospace,Consolas,monospace}.gallery{display:grid;grid-template-columns:1fr 1fr;gap:20px}figure{margin:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#fafcfd}figure img{width:100%;height:auto;display:block}figcaption{padding:10px 13px;color:var(--muted);font-size:13px}.table-scroll{overflow:auto}table{border-collapse:collapse;width:100%;font-size:13px}th,td{border:1px solid var(--line);text-align:left;padding:10px;white-space:nowrap}th{background:var(--soft)}footer{color:var(--muted);font-size:13px;text-align:center}a{color:var(--green)}@media(max-width:800px){main{padding:16px 12px 40px}header,section{padding:20px}h1{font-size:24px}.stages,.gallery{grid-template-columns:1fr}}@media print{body{background:white}main{max-width:none;padding:0}header{color:var(--ink);background:white;border:1px solid var(--line)}header p{color:var(--muted)}nav{display:none}section{break-inside:avoid}.gallery{grid-template-columns:1fr}details{break-inside:avoid}}
</style></head><body><main>
<header><h1>训练编排真实测试报告 ${badge(overall)}</h1><p>狐狸与牛奶卡车 · 场景 → 任务步骤 → 保存与发布</p><p>2026-09-05 · ${html(final.baseURL || 'http://127.0.0.1:6180')} · ${html(final.browser || 'Microsoft Edge / Playwright')} · ${html(final.role || '教员编排与独立浏览器只读重开')}</p><p>${html(summary)}</p></header>
<nav><a href="#method">资料与实施</a><a href="#projects">保留训练数据</a><a href="#tests">测试证据</a><a href="#screenshots">最终截图</a><a href="#history">过程记录</a><a href="#scope">范围与限制</a><a href="report.md">Markdown 报告</a></nav>
${overall === 'PENDING' ? '<p class="pending-note">报告草稿:最终证据尚不完整。已有过程截图和开发记录不能代替保存、发布及重新打开的最终验证。</p>' : ''}
<section id="method"><h2>资料 → 产品原型 → 设计 → 开发</h2><div class="stages">${stages.map(stage => `<article class="stage"><h3>${html(stage.title)}</h3>${bullets(stage.items)}</article>`).join('')}</div></section>
<section id="projects"><h2>保留训练数据</h2>${htmlProjects}<p class="muted">保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。</p></section>
<section id="tests"><h2>5 · 测试</h2><p>${html(summary)}</p><p class="muted">过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。</p>${versionMismatches.length ? `<p class="pending-note">独立重开证据尚未覆盖最终发布版本,不能标记完整通过。</p>${details(versionMismatches, '待重验的版本')}` : ''}${sources.map(sourceHtml).join('')}${commands.length ? `<h3>执行命令与验证结果</h3>${htmlCommands}` : '<p class="muted">命令与最终构建结果等待证据记录。</p>'}</section>
<section id="screenshots"><h2>最终及异常验证截图</h2>${finalImages.length ? `<div class="gallery">${finalImages.map(figure).join('')}</div>` : '<p class="pending-note">等待最终证据引用实际截图。尚无最终截图不等于模型已通过可见性检查。</p>'}</section>
<section id="history"><h2>过程截图 · 不计入最终通过结论</h2><p class="muted">这些截图只记录测试中途状态;最终两步、总分、版本和封面以上方证据为准。</p><div class="gallery">${history.map(figure).join('')}</div></section>
<section id="scope"><h2>范围、限制与功能确认结论</h2>${bullets(scopeItems)}${limitations.length ? `<h3>实测限制与后续项</h3>${bullets(limitations.map(pretty))}` : ''}</section>
<footer>报告生成:${html(generatedAt)}(Asia/Shanghai) · 来源:${sourceDefinitions.map(item => item.file).join('、')} · 图片均经本地文件校验</footer>
</main></body></html>`
const outputMd = `# 训练编排真实测试报告 · 2026-09-05

状态:**${statusLabel(overall)}**。${summary}

${overall === 'PENDING' ? '> 报告草稿:最终证据尚不完整,过程截图不能代替最终保存、发布和重新打开验证。\n' : ''}
${stages.map(stage => `## ${stage.title}\n\n${mdBullets(stage.items)}`).join('\n\n')}

## 保留训练数据

${mdProjects}

保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。

## 5 · 测试

过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。

${versionMismatches.length ? `> 独立重开证据尚未覆盖最终发布版本,不能标记完整通过。\n\n<pre>${html(pretty(versionMismatches))}</pre>\n` : ''}
${sources.map(sourceMd).join('\n\n')}

${commands.length ? `### 执行命令与验证结果\n\n${mdCommands}` : '命令与最终构建结果等待证据记录。'}

## 最终及异常验证截图

${finalImages.length ? finalImages.map(mdFigure).join('\n\n') : '等待最终证据引用实际截图;尚未将模型可见性计为通过。'}

## 过程截图 · 不计入最终通过结论

${history.map(mdFigure).join('\n\n')}

## 范围、限制与功能确认结论

${mdBullets(scopeItems)}

${limitations.length ? `### 实测限制与后续项\n\n${mdBullets(limitations.map(pretty))}\n` : ''}
报告生成:${generatedAt}(Asia/Shanghai)。
`
fs.writeFileSync(path.join(reportDir, 'index.html'), outputHtml, 'utf8')
fs.writeFileSync(path.join(reportDir, 'report.md'), outputMd, 'utf8')
console.log(JSON.stringify({ report: 'reports/training-live-20260905/index.html', status: overall, sources: sources.map(({ file, status: state }) => ({ file, status: state })), checks: { pass: passedChecks, fail: failedChecks, pending: pendingChecks }, images: imagePaths.size }))

+ 194
- 0
tools/build-training-modes-report.mjs Voir le fichier

@@ -0,0 +1,194 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

// Evidence is owned by the actual UI/API test runs. Missing evidence stays pending.
// This script only generates the two report documents and never calls a business API.
const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/training-modes-20260905')
fs.mkdirSync(reportDir, { recursive: true })
const array = value => Array.isArray(value) ? value : value == null ? [] : [value]
const sensitive = /password|passwd|secret|token|ticket|authorization|cookie|signature|credential|api[_-]?key|username|loginName|accountName|headers|storageState/i
const cleanText = value => String(value ?? '')
.replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
.replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[已隐藏]@')
.replace(/\b((?:access|refresh)?[_-]?token|password|passwd|secret|uploadTicket|signature|api[_-]?key|X-Amz-(?:Signature|Credential|Security-Token))\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;&}]+)/gi, '$1=[已隐藏]')
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[已隐藏]')
function sanitize(value) {
if (typeof value === 'string') return cleanText(value)
if (Array.isArray(value)) return value.map(sanitize)
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
.filter(([key]) => !sensitive.test(key)).map(([key, item]) => [key, sanitize(item)]))
return value
}
const html = value => cleanText(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
const md = value => cleanText(value).replace(/[|\[\]*_`<>]/g, char => `\\${char}`).replace(/\r?\n/g, ' ')
const pretty = value => typeof value === 'string' ? value : JSON.stringify(value, null, 2)
const recorded = value => value === undefined || value === null || value === '' ? '未记录' : value
const state = value => /^(PASS|PASSED)$/i.test(String(value)) ? 'PASS'
: /^(FAIL|FAILED)$/i.test(String(value)) ? 'FAIL'
: /^(BLOCKED|LIMITED)$/i.test(String(value)) ? 'BLOCKED'
: /^PASS_WITH_LIMITATIONS$/i.test(String(value)) ? 'PASS_WITH_LIMITATIONS' : 'PENDING'
const label = value => ({ PASS: '通过', FAIL: '失败', BLOCKED: '受限(需功能接线)', PASS_WITH_LIMITATIONS: '部分链路已验证,页面闭环受限', PENDING: '待补充证据' })[state(value)]
const badge = value => `<span class="badge ${state(value).toLowerCase()}">${label(value)}</span>`
const details = (value, title = '查看脱敏详细记录') => `<details><summary>${html(title)}</summary><pre>${html(pretty(value))}</pre></details>`
const bullets = items => `<ul>${items.map(item => `<li>${html(pretty(item))}</li>`).join('')}</ul>`
const mdBullets = items => items.map(item => `- ${md(pretty(item))}`).join('\n')
const sources = [
{ file: 'evidence.json', title: '浏览器编排、重新打开及教学入口实测', kind: 'UI' },
{ file: 'api-evidence.json', title: '正式教学 API:人工事件与红蓝岗位运行', kind: 'API' },
].map(source => {
const absolute = path.join(reportDir, source.file)
const data = fs.existsSync(absolute) ? sanitize(JSON.parse(fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, ''))) : null
const checks = array(data?.checks || data?.results || data?.tests).filter(item => item && !item.historical && !item.superseded)
const status = checks.some(check => state(check.status) === 'FAIL') ? 'FAIL'
: data?.status ? state(data.status) === 'PASS' && checks.some(check => state(check.status) === 'BLOCKED') ? 'BLOCKED' : state(data.status)
: checks.some(check => state(check.status) === 'BLOCKED') ? 'BLOCKED'
: checks.length && checks.every(check => state(check.status) === 'PASS') ? 'PASS' : 'PENDING'
return { ...source, data, checks, status }
})
const ui = sources[0].data || {}
const api = sources[1].data || {}
const projects = array(ui.projects || ui.trainingProjects || ui.retainedProjects)
const versionMismatches = Object.entries(api.references || {}).flatMap(([channel, reference]) => {
if (!reference) return []
const project = projects.find(item => String(item.id || item.projectId) === String(reference.projectId))
if (!projects.length) return []
const expected = project?.publishedVersionId || project?.finalVersionId
if (project?.apiTestVersionId && project?.apiVersionNote && String(project.apiTestVersionId) === String(reference.versionId)) return []
return expected && String(expected) === String(reference.versionId) ? [] : [{ channel, projectId: reference.projectId, uiPublishedVersionId: expected || '未记录', apiVersionId: reference.versionId }]
})
const checks = sources.flatMap(source => source.checks)
const counts = { passed: checks.filter(check => state(check.status) === 'PASS').length, blocked: checks.filter(check => state(check.status) === 'BLOCKED').length, failed: checks.filter(check => state(check.status) === 'FAIL').length, pending: checks.filter(check => state(check.status) === 'PENDING').length }
const overall = sources.some(source => source.status === 'FAIL') || counts.failed ? 'FAIL'
: sources.some(source => source.status === 'PENDING') || counts.pending || versionMismatches.length ? 'PENDING'
: counts.blocked || sources.some(source => ['BLOCKED', 'PASS_WITH_LIMITATIONS'].includes(source.status)) ? 'PASS_WITH_LIMITATIONS' : 'PASS'
const limitations = [...new Map(sources.flatMap(source => array(source.data?.limitations)).map(item => [pretty(item), item])).values()]
const followups = array(ui.confirmationItems || ui.followUpIssues || ui.featureConfirmations)
const commands = sources.flatMap(source => array(source.data?.commands))
const imagePattern = /^screenshots\/.+\.(?:png|jpe?g|webp)$/i
function imageRefs(value, inheritedCaption = '') {
if (typeof value === 'string') return imagePattern.test(value.replaceAll('\\', '/')) ? [{ path: value.replaceAll('\\', '/'), caption: inheritedCaption }] : []
if (Array.isArray(value)) return value.flatMap(item => imageRefs(item, inheritedCaption))
if (!value || typeof value !== 'object') return []
const caption = value.caption || value.title || value.label || value.name || inheritedCaption
return Object.values(value).flatMap(item => imageRefs(item, caption))
}
const images = [...new Map(sources.flatMap(source => imageRefs(source.data)).map(item => [item.path, item])).values()]
for (const image of images) {
const absolute = path.resolve(reportDir, image.path)
const relative = path.relative(reportDir, absolute)
if (path.isAbsolute(image.path) || relative.startsWith('..') || path.isAbsolute(relative) || !fs.existsSync(absolute) || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size === 0) throw new Error(`截图不存在或路径非法:${image.path}`)
const real = path.relative(fs.realpathSync(reportDir), fs.realpathSync(absolute))
if (real.startsWith('..') || path.isAbsolute(real)) throw new Error(`截图越出目录:${image.path}`)
}
const imageUrl = value => value.split('/').map(encodeURIComponent).join('/')
const figure = image => `<figure><a href="${html(imageUrl(image.path))}" target="_blank" rel="noopener"><img src="${html(imageUrl(image.path))}" alt="${html(image.caption || image.path)}" loading="lazy"></a><figcaption>${html(image.caption || image.path)} · 点击查看原图</figcaption></figure>`
const mdFigure = image => `[![${md(image.caption || image.path)}](${imageUrl(image.path)})](${imageUrl(image.path)})`
const stages = [
{ title: '1 · 资料', items: [
'实际查看《维修训练内容设计.pdf》第 1–3、5–6 页扫描件。第 1–2 页将虚拟、混合现实、AI 指导下的实装组织为递进训练,流程包含构建场景、编排任务、组班下发、学员训练与数据报告。',
'本次使用狐狸与牛奶卡车测试素材验证软件链路,不将其视为原文装备维修内容或真实维修能力考核。原文已查看页面没有单列红蓝对抗规则;对抗依据现有教学实施、渠道合同、命令幂等及步骤事实文档验证。',
'合同来源:unreal_tran_api/wiki/14-教学实施模块.md、19-训练定义渠道合同.md、20-教学命令幂等.md、21-教学步骤运行事实.md。',
] },
{ title: '2 · 产品原型', items: [
'沿用现有训练编排工作台与模式配置表单,分别建立实装和对抗内容;保存、发布后重新打开检查步骤、分值、封面与模式参数。',
'实装默认运行入口为离线识别回放;对抗默认入口为本地工作台。分别记录界面实际行为,再通过正式 API 验证服务端教学闭环。',
] },
{ title: '3 · 设计', items: [
'实装配置三步人工确认,分值 30 / 30 / 40;完成进度按步骤推导为 33 / 67 / 100。验证失败重试、顺序约束、事件身份及重复事件处理。',
'对抗配置红蓝两队、每队两人,步骤和提交均由 COMMANDER 执行;OPERATOR 用于岗位拒绝测试。验证同队共享运行、跨队隔离、服务器时限、提交与教员评定。',
'任务固定引用已发布训练版本;只创建本次开发测试数据。人工确认和客户端模拟参数均明确标识为测试,不连接真实工位、PLC、摄像头或视觉服务。',
] },
{ title: '4 · 开发', items: array(ui.development || ui.fixes).length ? array(ui.development || ui.fixes) : [
'本轮业务修复与验证结果等待浏览器 evidence.json;本报告生成器不会修改业务数据或将待实施功能标为已完成。',
] },
]
const scope = [
'本报告区分编排 UI、默认教学 UI 和正式教学 API 三类证据;API 成功不能证明默认 UI 已连接该服务端运行。',
'实装的设备或视觉事件链路未接真实设备验证;人工确认测试不能替代实机联调、安全联锁或实际操作技能考核。',
'对抗的默认本地工作台与正式服务端任务分别留证;无证据时不声称多人跨浏览器实时协作或正式比赛胜负已联调。',
'正式 API 对抗由四个隔离学员会话依次操作;检查服务端截止时间已生效,未等待整局自然超时,不作为多人并发或网络同步压力测试。',
'100 分评定仅验证接口和结果留痕,不代表学员实际维修能力。测试不会向他人发送消息或下发真实设备指令。',
]
const modeRows = [
['实装实训', 'PHYSICAL', '正式 API 的 MANUAL 人工事件', '三步顺序、失败重试、来源权限、事件幂等、提交与评定', '默认离线识别页;设备 / 视觉接入另行联调'],
['对抗训练', 'CONFRONTATION', '正式 API 的红蓝两队共享运行', '岗位权限、两队隔离、顺序、时限、命令幂等、提交与评定', '默认本地工作台;不混同正式 API 运行'],
]
function tableHtml(headers, rows) { return `<div class="table-scroll"><table><thead><tr>${headers.map(value => `<th>${html(value)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(value => `<td>${html(recorded(value))}</td>`).join('')}</tr>`).join('')}</tbody></table></div>` }
function tableMd(headers, rows) { return `| ${headers.map(md).join(' | ')} |\n| ${headers.map(() => '---').join(' | ')} |\n${rows.map(row => `| ${row.map(value => md(recorded(value))).join(' | ')} |`).join('\n')}` }
const modeHeaders = ['模式', '正式渠道', '本轮 API 验证方法', '必检项', '默认界面边界']
const projectHeaders = ['训练 ID', '名称', '模式', '状态', '当前发布版本', 'API 已测版本', '步骤数']
const projectRows = projects.map(project => [project.id || project.projectId, project.name || project.title, project.trainingMode || project.mode || project.channel, project.finalStatus || project.status, project.publishedVersionId || project.finalVersionId, Object.values(api.references || {}).find(reference => reference && String(reference.projectId) === String(project.id || project.projectId))?.versionId, project.stepCount ?? project.steps?.length])
const runHeaders = ['任务 ID', '运行 ID', '渠道', '队伍', '状态', '完成度', '评定分']
const runRows = array(api.runs).map(run => [run.assignmentId, run.id, run.channel, run.team, run.status, run.progressPercent, run.score])
function sourceHtml(source) {
if (!source.data) return `<article class="test"><h3>${html(source.title)} ${badge('PENDING')}</h3><p>等待 ${html(source.file)},未计为通过。</p></article>`
return `<article class="test"><h3>${html(source.data.title || source.title)} ${badge(source.status)}</h3><p class="muted">证据类型:${source.kind} · ${html(source.file)}</p>${source.checks.map(check => `<div class="check"><h4>${html(check.title || check.name || '检查')} ${badge(check.status)}</h4>${details(check)}</div>`).join('')}${details(source.data, `${source.file} · 完整记录`)}</article>`
}
function sourceMd(source) {
return `### ${md(source.data?.title || source.title)} · ${label(source.status)}\n\n证据类型:${source.kind}。\n\n${source.data ? source.checks.map(check => `- ${md(check.title || check.name || '检查')}:${label(check.status)}`).join('\n') : `等待 ${source.file},未计为通过。`}\n\n${source.data ? `<details><summary>${html(source.file)} · 脱敏完整记录</summary>\n\n<pre>${html(pretty(source.data))}</pre>\n\n</details>` : ''}`
}
const summary = `证据组 ${sources.filter(source => source.status !== 'PENDING').length}/${sources.length} 已完成;检查通过 ${counts.passed},受限 ${counts.blocked},失败 ${counts.failed},待验证 ${counts.pending};已校验截图 ${images.length} 张。`
const conclusion = overall === 'PASS_WITH_LIMITATIONS' ? '编排与 API 已验证,教学页面闭环受限。需完成默认教学页面与正式任务运行的接线,才能从页面走完新模型对应的训练。' : ''
const generatedAt = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
const outputHtml = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>实装与对抗训练实测报告 · 2026-09-05</title><style>
:root{color-scheme:light;--ink:#203544;--muted:#607789;--line:#dce6ec;--green:#137d6e}*{box-sizing:border-box}body{margin:0;background:#f3f6f8;color:var(--ink);font:15px/1.75 system-ui,-apple-system,"Microsoft YaHei",sans-serif}main{max-width:1220px;margin:auto;padding:32px 24px 64px}header{padding:30px 34px;background:#103c45;color:white;border-radius:16px}h1{font-size:29px;margin:0 0 12px}header p{color:#d7e8eb;margin:7px 0}nav{display:flex;gap:10px;flex-wrap:wrap;margin:22px 0}nav a{color:var(--green);text-decoration:none;background:white;border:1px solid var(--line);padding:6px 13px;border-radius:7px}section{background:white;border:1px solid var(--line);border-radius:12px;padding:26px 30px;margin:20px 0}h2{font-size:21px;margin:0 0 16px}h3{font-size:17px;margin:0 0 12px}h4{font-size:15px;margin:0 0 7px}p{margin:10px 0}ul{padding-left:22px}li+li{margin-top:7px}.badge{display:inline-block;vertical-align:middle;font-size:12px;font-weight:600;line-height:1.7;border-radius:4px;padding:2px 8px;margin-left:5px}.pass{color:#11634f;background:#e3f5ed}.fail{color:#9e2a35;background:#ffe9ec}.pending,.blocked,.pass_with_limitations{color:#7b6121;background:#fff2cc}.muted{color:var(--muted)}.note{padding:12px 16px;background:#fff8e7;border-left:3px solid #c09a3b}.stages{display:grid;grid-template-columns:1fr 1fr;gap:18px}.stage{border:1px solid var(--line);border-radius:8px;padding:20px;min-width:0}.test+.test{border-top:1px solid var(--line);margin-top:22px;padding-top:22px}.check{border-left:3px solid var(--line);padding:8px 16px;margin:12px 0}details{margin:12px 0}summary{cursor:pointer;color:var(--green)}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:600px;overflow:auto;padding:16px;background:#f5f8fa;border:1px solid var(--line);border-radius:7px;font:12px/1.6 ui-monospace,Consolas,monospace}.gallery{display:grid;grid-template-columns:1fr 1fr;gap:20px}figure{margin:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#fafcfd;min-width:0}figure img{width:100%;height:auto;display:block}figcaption{padding:10px 13px;color:var(--muted);font-size:13px}.table-scroll{overflow:auto}table{border-collapse:collapse;width:100%;font-size:13px}th,td{border:1px solid var(--line);text-align:left;padding:10px;min-width:90px}th{background:#eef6f7}footer{color:var(--muted);font-size:13px;text-align:center}a{color:var(--green)}p,li,figcaption,summary{overflow-wrap:anywhere}@media(max-width:800px){main{padding:16px 12px 40px}header,section{padding:20px}h1{font-size:24px}.stages,.gallery{grid-template-columns:1fr}}@media print{body{background:white}main{max-width:none;padding:0}header{color:var(--ink);background:white;border:1px solid var(--line)}header p{color:var(--muted)}nav{display:none}.gallery{grid-template-columns:1fr}}
h1,h2,h3,h4{overflow-wrap:anywhere}.check{min-width:0}
</style></head><body><main><header><h1>实装与对抗训练实测报告 ${badge(overall)}</h1><p>编排 UI · 默认教学入口 · 正式教学 API</p>${conclusion ? `<p>${html(conclusion)}</p>` : ''}<p>2026-09-05 · ${html(ui.baseURL || api.baseURL || 'http://127.0.0.1:6180')}</p><p>${html(summary)}</p></header><nav><a href="#method">资料与实施</a><a href="#data">保留数据</a><a href="#tests">测试证据</a><a href="#images">截图</a><a href="#scope">范围与待确认项</a><a href="report.md">Markdown 报告</a></nav>
${overall === 'PENDING' ? '<p class="note">报告草稿:最终证据尚不完整,不以配置完成或单独 API 成功替代界面全流程验证。</p>' : ''}
<section id="method"><h2>资料 → 产品原型 → 设计 → 开发</h2><div class="stages">${stages.map(stage => `<article class="stage"><h3>${html(stage.title)}</h3>${bullets(stage.items)}</article>`).join('')}</div><h3 style="margin-top:24px">两种模式的验证边界</h3>${tableHtml(modeHeaders, modeRows)}</section>
<section id="data"><h2>保留开发测试数据</h2>${projects.length ? tableHtml(projectHeaders, projectRows) + projects.map(project => `${project.apiVersionNote ? `<p class="note">训练 ${html(project.id || project.projectId)}:${html(project.apiVersionNote)}</p>` : ''}${details(project, `训练 ${project.id || project.projectId} · 发布与配置记录`)}`).join('') : '<p class="note">等待 UI 最终项目记录。</p>'}<h3>正式教学任务与运行</h3>${runRows.length ? tableHtml(runHeaders, runRows) : '<p class="note">等待 API 运行结果。</p>'}${api.retainedData ? details(api.retainedData, '本次新增数据 ID') : ''}<p class="muted">随机测试密码与登录会话不写入报告;保留新建数据用于追溯。</p></section>
<section id="tests"><h2>5 · 测试</h2><p>${html(summary)}</p>${versionMismatches.length ? `<p class="note">UI 与 API 证据引用的发布版本尚未一致,不能标记完整通过。</p>${details(versionMismatches)}` : ''}${sources.map(sourceHtml).join('')}${commands.length ? `<h3>测试命令与构建</h3>${commands.map(command => `<div class="check"><h4>${html(command.command || command.name)} ${badge(command.status)}</h4><p>${html(command.details || command.result || '')}</p></div>`).join('')}` : ''}</section>
<section id="images"><h2>真实浏览器截图</h2><p class="muted">截图只来自实际存在的文件;API 请求结果单列,不以 UI 截图代替。</p>${images.length ? `<div class="gallery">${images.map(figure).join('')}</div>` : '<p class="note">等待 evidence.json 引用实际截图。</p>'}</section>
<section id="scope"><h2>范围、限制与待确认项</h2>${bullets(scope)}${limitations.length ? `<h3>实测边界与限制</h3>${bullets(limitations)}` : ''}<h3>需要另行确认的功能范围</h3>${followups.length ? bullets(followups) : '<p>本栏等待最终证据中的功能范围结论;默认教学 UI 与正式运行的接线现状需按实际结果评估,不自动扩展实现。</p>'}</section><footer>报告生成:${html(generatedAt)}(Asia/Shanghai) · evidence.json / api-evidence.json · 图片经本地文件校验</footer></main></body></html>`
const outputMd = `# 实装与对抗训练实测报告 · 2026-09-05

状态:**${label(overall)}**。${summary}

${conclusion}

范围:编排 UI、默认教学入口、正式教学 API 分开验证。

${stages.map(stage => `## ${stage.title}\n\n${mdBullets(stage.items)}`).join('\n\n')}

## 两种模式的验证边界

${tableMd(modeHeaders, modeRows)}

## 保留开发测试数据

${projects.length ? tableMd(projectHeaders, projectRows) : '等待最终 UI 项目记录。'}

${projects.filter(project => project.apiVersionNote).map(project => `> 训练 ${md(project.id || project.projectId)}:${md(project.apiVersionNote)}`).join('\n\n')}

### 正式教学任务与运行

${runRows.length ? tableMd(runHeaders, runRows) : '等待 API 运行结果。'}

随机测试密码与登录会话不写入报告;保留新建数据用于追溯。

## 5 · 测试

${versionMismatches.length ? `> UI 与 API 发布版本不一致,未计为完整通过。\n\n<pre>${html(pretty(versionMismatches))}</pre>\n` : ''}
${sources.map(sourceMd).join('\n\n')}

${commands.length ? `### 测试命令与构建\n\n${commands.map(command => `- ${md(command.command || command.name)}:${label(command.status)}。${md(command.details || command.result || '')}`).join('\n')}` : ''}

## 真实浏览器截图

${images.length ? images.map(mdFigure).join('\n\n') : '等待最终证据引用实际截图。'}

## 范围、限制与待确认项

${mdBullets(scope)}

${limitations.length ? `### 实测边界与限制\n\n${mdBullets(limitations)}\n` : ''}
### 需要另行确认的功能范围

${followups.length ? mdBullets(followups) : '等待最终证据中的功能范围结论;默认教学 UI 与正式运行接线现状按实际结果评估,不自动扩展实现。'}

报告生成:${generatedAt}(Asia/Shanghai)。
`
fs.writeFileSync(path.join(reportDir, 'index.html'), outputHtml, 'utf8')
fs.writeFileSync(path.join(reportDir, 'report.md'), outputMd, 'utf8')
console.log(JSON.stringify({ report: 'reports/training-modes-20260905/index.html', status: overall, checks: counts, images: images.length, sources: sources.map(({ file, status }) => ({ file, status })) }))

+ 236
- 0
tools/training-cover.test.mjs Voir le fichier

@@ -0,0 +1,236 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import ts from 'typescript'

const features = new URL('../../unreal_tran_web/src/features/', import.meta.url)
async function moduleUrl(relative, replacements = {}) {
let source = await readFile(new URL(relative, features), 'utf8')
for (const [from, to] of Object.entries(replacements)) source = source.replace(from, to)
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } })
return `data:text/javascript;base64,${Buffer.from(output.outputText).toString('base64')}`
}
const sceneCaptureUrl = await moduleUrl('scene-legacy/sceneCoverCapture.ts')
const { captureTrainingCover } = await import(await moduleUrl('training-legacy/trainingCoverCapture.ts', {
'../scene-legacy/sceneCoverCapture': sceneCaptureUrl,
}))
const { saveTrainingWithCover } = await import(await moduleUrl('training-legacy/trainingCoverPersistence.ts'))
const oldCover = { code: 'TRAINING_COVER', storageUri: 'fsvc://old', sortOrder: 1 }
const audio = { code: 'NARRATION', storageUri: 'fsvc://audio', sortOrder: 0 }
const newCover = { code: 'TRAINING_COVER', storageUri: 'fsvc://new', sortOrder: 1, uploadTicket: 'test-only-ticket' }
function persistenceFixture({ captureError, uploadError, updateError, invalidateAfterUpload = false } = {}) {
const calls = []
let current = true
const project = { id: '161', version: 8, coverUri: oldCover.storageUri, assets: [audio, oldCover] }
const input = { type: 'TRAINING', version: 8, assets: [audio, oldCover], content: { steps: [{ stepCode: 'STEP-1' }] }, dependencies: [{ targetProjectId: '160', targetVersionId: '210', relationType: 'TRAINING_SCENE', required: true }] }
const capture = async () => { if (captureError) throw captureError; return new Blob(['jpeg'], { type: 'image/jpeg' }) }
const api = {
async upload(file, params) { calls.push({ method: 'upload', file, params }); if (uploadError) throw uploadError; if (invalidateAfterUpload) current = false; return newCover },
async update(body) { calls.push({ method: 'PUT', body }); if (updateError) throw updateError; return { ...project, ...body, version: 9 } },
}
const run = () => saveTrainingWithCover(project, input, capture, api, () => { if (!current) throw new Error('Project switched') })
return { calls, project, input, run }
}

test('training cover ticket, content and fixed scene dependency share exactly one PUT', async () => {
const f = persistenceFixture()
const result = await f.run()
assert.equal(result.coverFailed, false)
assert.deepEqual(f.calls.map(x => x.method), ['upload', 'PUT'])
assert.equal(f.calls[0].file.type, 'image/jpeg')
assert.equal(f.calls[0].params.projectVersion, 8)
assert.equal(f.calls[0].params.code, 'TRAINING_COVER')
assert.deepEqual(f.calls[1].body.assets, [audio, newCover])
assert.equal(f.calls[1].body.coverUri, 'fsvc://new')
assert.deepEqual(f.calls[1].body.dependencies, f.input.dependencies)
assert.deepEqual(f.calls[1].body.content, f.input.content)
assert.deepEqual(f.input.assets, [audio, oldCover])
})

for (const failure of ['captureError', 'uploadError']) {
test(`${failure} retains the old cover while saving training content`, async () => {
const f = persistenceFixture({ [failure]: new Error('Unavailable') })
const result = await f.run()
assert.equal(result.coverFailed, true)
const puts = f.calls.filter(x => x.method === 'PUT')
assert.equal(puts.length, 1)
assert.equal(puts[0].body.coverUri, 'fsvc://old')
assert.deepEqual(puts[0].body.assets, [audio, oldCover])
})
}
test('upload 409 stops without attempting a fallback PUT', async () => {
const conflict = Object.assign(new Error('Conflict'), { status: 409 })
const f = persistenceFixture({ uploadError: conflict })
await assert.rejects(f.run(), error => error === conflict)
assert.deepEqual(f.calls.map(x => x.method), ['upload'])
})
test('PUT 409 is propagated without retrying or replacing the prior cover', async () => {
const conflict = Object.assign(new Error('Conflict'), { status: 409 })
const f = persistenceFixture({ updateError: conflict })
await assert.rejects(f.run(), error => error === conflict)
assert.deepEqual(f.calls.map(x => x.method), ['upload', 'PUT'])
assert.equal(f.project.coverUri, oldCover.storageUri)
})
test('project switch while uploading stops the final update', async () => {
const f = persistenceFixture({ invalidateAfterUpload: true })
await assert.rejects(f.run(), /Project switched/)
assert.deepEqual(f.calls.map(x => x.method), ['upload'])
})

const value = initial => ({ value: initial, clone() { return value(this.value) }, copy(other) { this.value = other.value; return this } })
function captureFixture({ drawError } = {}) {
const grid = { type: 'GridHelper', visible: true }, path = { visible: true }
const material = { emissive: value('selected'), emissiveIntensity: 0.55 }
const object = { scale: value(1.04) }
const events = []
const record = action => events.push({ action, grid: grid.visible, path: path.visible, color: material.emissive.value, intensity: material.emissiveIntensity, scale: object.scale.value })
const canvas = { width: 0, height: 0, getContext: () => ({ drawImage() { record('copy'); if (drawError) throw drawError } }), toBlob(callback, type) { queueMicrotask(() => callback(new Blob(['image'], { type }))) } }
const editor = {
scene: { traverse: visitor => visitor(grid) }, camera: {}, pathPreview: path,
targetMaterials: new Map([['fox', [{ material, color: value('original'), intensity: 0 }]]]),
targetScales: new Map([['fox', value(1)]]), targetObjects: new Map([['fox', object]]),
renderer: { domElement: { width: 1600, height: 900 }, render() { record('render') } },
}
const options = { document: { createElement: () => canvas } }
const restored = () => {
assert.equal(grid.visible, true); assert.equal(path.visible, true)
assert.equal(material.emissive.value, 'selected'); assert.equal(material.emissiveIntensity, 0.55)
assert.equal(object.scale.value, 1.04)
}
return { editor, options, canvas, events, restored }
}
test('capture omits training selection glow, scale and helpers, preserving the current camera', async () => {
const f = captureFixture()
const camera = f.editor.camera
const image = await captureTrainingCover(f.editor, f.options)
const copied = f.events.find(event => event.action === 'copy')
assert.deepEqual(copied, { action: 'copy', grid: false, path: false, color: 'original', intensity: 0, scale: 1 })
f.restored()
assert.equal(f.editor.camera, camera)
assert.equal(f.canvas.width, 640); assert.equal(f.canvas.height, 360)
assert.equal(image.type, 'image/jpeg')
})
test('capture failures restore all transient training presentation state', async () => {
const f = captureFixture({ drawError: new Error('Canvas blocked') })
await assert.rejects(captureTrainingCover(f.editor, f.options), /Canvas blocked/)
f.restored()
})
test('loading, failed and disposed training viewports cannot overwrite the cover with a placeholder', async () => {
for (const state of ['loading', 'error']) {
const f = captureFixture(); f.editor.container = { dataset: { state } }
await assert.rejects(captureTrainingCover(f.editor, f.options), /尚未加载完成/)
assert.equal(f.events.length, 0)
}
const f = captureFixture(); f.editor.disposed = true
await assert.rejects(captureTrainingCover(f.editor, f.options), /尚未加载完成/)
const hidden = captureFixture(); hidden.editor.renderer.domElement.width = 2
await assert.rejects(captureTrainingCover(hidden.editor, hidden.options), /尚未加载完成/)
})

// Exercise the actual vendored methods with a minimal host, avoiding a mock reimplementation.
const editorSource = await readFile(new URL('guide-legacy/demo-src/training-editor.js', features), 'utf8')
const saveMethod = editorSource.slice(editorSource.indexOf(' async saveDraft('), editorSource.indexOf(' showValidation()'))
const publishMethod = editorSource.slice(editorSource.indexOf(' async publishProject('), editorSource.indexOf(' async runProject()'))
const bindMethod = editorSource.slice(editorSource.indexOf(' async bindPublishedScene('), editorSource.indexOf(' async previewAudio('))
const EditorMethods = new Function('saveTrainingProject', 'clone', 'publishTrainingProject', 'ENABLE_TRAINING_TOOLS', `return class { ${saveMethod} ${publishMethod} ${bindMethod} }`)(
(_storage, project, options) => ({ ...project, status: options.status }), structuredClone,
() => { throw new Error('Host-backed editor must not publish locally first') },
false,
)
function editorFixture() {
let resolve
const pending = new Promise(done => { resolve = done })
const editor = new EditorMethods()
Object.assign(editor, {
project: { id: '161', status: 'draft', scenario: {}, steps: [] }, storage: {}, editRevision: 1,
persisting: false, dirty: true, serverPending: true, root: { querySelector: () => null, querySelectorAll: () => [] },
onPersist: () => pending, assetStore: { validateReferences: async () => ({ valid: true }) },
})
for (const name of ['renderSaveState', 'renderProjects', 'renderProjectHeader', 'rememberSavedBaseline', 'replaceCurrentHistoryState', 'renderAll', 'toast', 'showValidation']) editor[name] = () => {}
return { editor, resolve }
}
test('manual save remains pending until server acknowledgment and blocks duplicate writes', async () => {
const f = editorFixture()
const pending = f.editor.saveDraft({ notify: true })
assert.equal(f.editor.persisting, true)
assert.equal(await f.editor.saveDraft({ notify: true }), null)
f.resolve({ status: 'draft' })
assert.ok(await pending)
assert.equal(f.editor.serverPending, false)
assert.equal(f.editor.persisting, false)
})
test('failed host save stays dirty and never reports a successful local result', async () => {
const f = editorFixture()
const pending = f.editor.saveDraft({ notify: true })
f.resolve(false)
assert.equal(await pending, null)
assert.equal(f.editor.serverPending, true)
assert.equal(f.editor.dirty, true)
})
test('new edits during a server save remain pending after the older snapshot succeeds', async () => {
const f = editorFixture()
const pending = f.editor.saveDraft({ notify: true })
f.editor.editRevision += 1
f.editor.dirty = true
f.resolve({ status: 'draft' })
await pending
assert.equal(f.editor.serverPending, true)
assert.equal(f.editor.dirty, true)
})
test('publishing keeps the editor in draft until the server confirms publication', async () => {
const f = editorFixture()
const pending = f.editor.publishProject()
await Promise.resolve()
assert.equal(f.editor.project.status, 'draft')
assert.equal(f.editor.persisting, true)
f.resolve({ status: 'published' })
assert.ok(await pending)
assert.equal(f.editor.project.status, 'published')
assert.equal(f.editor.serverPending, false)
})
test('server-rejected publication leaves draft status and pending changes intact', async () => {
const f = editorFixture()
const pending = f.editor.publishProject()
f.resolve(false)
assert.equal(await pending, null)
assert.equal(f.editor.project.status, 'draft')
assert.equal(f.editor.serverPending, true)
})

test('failed scene binding cannot persist a half-bound scene through save or publish', async () => {
const { editor } = editorFixture()
editor.project.scenario = { publishedSceneId: 'old@1', environment: {} }
editor.dirty = false
editor.serverPending = false
editor.clearEnvironmentPreview = () => {}
let writes = 0
editor.onPersist = async () => { writes += 1; return { status: 'draft' } }
editor.loadServerScene = async () => ({ id: 'new@2', name: 'New scene', sourceProjectId: '2', sourceVersionId: '2', objects: [] })
let finishRender
const loading = new Promise(resolve => { finishRender = resolve })
let renders = 0
editor.renderScene = () => ++renders === 1 ? loading : Promise.resolve({})
const binding = editor.bindPublishedScene('new@2')
await Promise.resolve(); await Promise.resolve(); await Promise.resolve()
assert.equal(editor.bindingScene, true)
assert.equal(editor.project.scenario.publishedSceneId, 'new@2')
assert.equal(await editor.saveDraft({ notify: true }), null)
assert.equal(await editor.publishProject(), null)
assert.equal(writes, 0)
finishRender({ error: new Error('Model loader failed') })
await binding
assert.equal(editor.bindingScene, false)
assert.equal(editor.project.scenario.publishedSceneId, 'old@1')
assert.equal(writes, 0)
assert.equal(editor.dirty, false)
assert.equal(editor.serverPending, false)
})

test('scene binding cannot replace the document while an existing save is in flight', async () => {
const { editor } = editorFixture()
editor.persisting = true
let loads = 0
editor.applyPublishedScene = async () => { loads += 1 }
await editor.bindPublishedScene('new@2')
assert.equal(loads, 0)
})

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff

Chargement…
Annuler
Enregistrer