Browse Source

test(content): add model and scene cover regression reports

main
leiyun 2 weeks ago
parent
commit
b978213bee
98 changed files with 29601 additions and 0 deletions
  1. +1
    -0
      .gitignore
  2. BIN
     
  3. BIN
     
  4. BIN
     
  5. +71
    -0
      reports/login-role-layout-20260905/verification.json
  6. +284
    -0
      reports/model-cover-20260905/evidence.json
  7. +200
    -0
      reports/model-cover-20260905/index.html
  8. +320
    -0
      reports/model-cover-20260905/report.md
  9. BIN
     
  10. BIN
     
  11. BIN
     
  12. BIN
     
  13. BIN
     
  14. BIN
     
  15. BIN
     
  16. BIN
     
  17. BIN
     
  18. BIN
     
  19. BIN
     
  20. BIN
     
  21. BIN
     
  22. BIN
     
  23. BIN
     
  24. +27
    -0
      reports/model-created-sort-20260905/cover-plan.md
  25. +280
    -0
      reports/model-created-sort-20260905/evidence.json
  26. +1
    -0
      reports/model-created-sort-20260905/index.html
  27. BIN
     
  28. BIN
     
  29. BIN
     
  30. +24797
    -0
      reports/model-live-20260905/evidence.json
  31. +55
    -0
      reports/model-live-20260905/index.html
  32. +657
    -0
      reports/model-live-20260905/report.md
  33. BIN
     
  34. BIN
     
  35. BIN
     
  36. BIN
     
  37. BIN
     
  38. BIN
     
  39. BIN
     
  40. BIN
     
  41. BIN
     
  42. BIN
     
  43. BIN
     
  44. BIN
     
  45. BIN
     
  46. BIN
     
  47. BIN
     
  48. BIN
     
  49. BIN
     
  50. BIN
     
  51. BIN
     
  52. BIN
     
  53. BIN
     
  54. BIN
     
  55. BIN
     
  56. BIN
     
  57. BIN
     
  58. +293
    -0
      reports/scene-live-20260905/evidence.json
  59. +157
    -0
      reports/scene-live-20260905/index.html
  60. +451
    -0
      reports/scene-live-20260905/report.md
  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. BIN
     
  78. BIN
     
  79. BIN
     
  80. BIN
     
  81. BIN
     
  82. BIN
     
  83. BIN
     
  84. BIN
     
  85. BIN
     
  86. BIN
     
  87. BIN
     
  88. BIN
     
  89. +183
    -0
      reports/scene-live-20260905/visibility-evidence.json
  90. +158
    -0
      tools/build-model-cover-report.mjs
  91. +265
    -0
      tools/build-model-live-report.mjs
  92. +244
    -0
      tools/build-scene-live-report.mjs
  93. +272
    -0
      tools/model-cover-api.test.mjs
  94. +141
    -0
      tools/model-cover-capture.test.mjs
  95. +207
    -0
      tools/scene-cover-api.test.mjs
  96. +190
    -0
      tools/scene-cover-capture.test.mjs
  97. +123
    -0
      tools/scene-model-placement.test.mjs
  98. +224
    -0
      tools/scene-model-visibility.e2e.mjs

+ 1
- 0
.gitignore View File

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


BIN
View File


BIN
View File


BIN
View File


+ 71
- 0
reports/login-role-layout-20260905/verification.json View File

@@ -0,0 +1,71 @@
{
"time": "2026-09-05T08:14:53.190Z",
"results": [
{
"viewport": 1920,
"columns": "124.656px 124.672px 124.656px",
"container": 388,
"roles": [
{
"role": "teacher",
"width": 124.65625,
"text": "教员"
},
{
"role": "student",
"width": 124.671875,
"text": "学员"
},
{
"role": "admin",
"width": 124.65625,
"text": "系统管理员"
}
]
},
{
"viewport": 1440,
"columns": "124.656px 124.672px 124.656px",
"container": 388,
"roles": [
{
"role": "teacher",
"width": 124.65625,
"text": "教员"
},
{
"role": "student",
"width": 124.671875,
"text": "学员"
},
{
"role": "admin",
"width": 124.65625,
"text": "系统管理员"
}
]
},
{
"viewport": 390,
"columns": "102px 102px 102px",
"container": 320,
"roles": [
{
"role": "teacher",
"width": 102,
"text": "教员"
},
{
"role": "student",
"width": 102,
"text": "学员"
},
{
"role": "admin",
"width": 102,
"text": "系统管理员"
}
]
}
]
}

+ 284
- 0
reports/model-cover-20260905/evidence.json View File

@@ -0,0 +1,284 @@
{
"date": "2026-09-05",
"baseURL": "http://127.0.0.1:6180",
"browser": "Microsoft Edge",
"role": "教员",
"evidence": [
{
"title": "首次手动保存卡车:截取当前场景并绑定JPEG封面",
"status": "PASS",
"details": {
"id": "156",
"name": "实测模型-牛奶卡车-已验证-20260905",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/4e12ee526680085650c48934bc0305e1.jpg",
"versionId": "203",
"projectVersion": 13,
"coverAssetId": "2040",
"sha256": "15caa821e90a70d5b0f8ba1554c0d0d6db332a9b5e53b0d5e94de24d7663533e",
"bytes": 17889,
"width": 640,
"height": 346,
"image": "screenshots/01-truck-cover.jpg",
"cache": "no-cache, private"
},
"helpersRestored": true,
"screenshot": "screenshots/01-truck-cover-editor.png",
"coverImage": "screenshots/01-truck-cover.jpg"
},
{
"title": "更换卡车视角再次保存:替换当前封面且辅助控件恢复",
"status": "PASS",
"details": {
"id": "156",
"name": "实测模型-牛奶卡车-已验证-20260905",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/67b2a5d15f9c1fc47a0a760f28fd2e2e.jpg",
"versionId": "204",
"projectVersion": 18,
"coverAssetId": "2055",
"sha256": "7b7cc4cc75a3a355f26e2ff15ae1d26b5964ad7b370061691a94ce5f6f4b9158",
"bytes": 17821,
"width": 640,
"height": 346,
"image": "screenshots/02-truck-new-view.jpg",
"cache": "no-cache, private"
},
"helpersRestored": true,
"screenshot": "screenshots/02-truck-new-view-editor.png",
"coverImage": "screenshots/02-truck-new-view.jpg"
},
{
"title": "更换相机视角更新封面,已发布历史版本保留旧封面",
"status": "PASS",
"details": {
"oldVersionId": "203",
"newVersionId": "204",
"oldSha256": "15caa821e90a70d5b0f8ba1554c0d0d6db332a9b5e53b0d5e94de24d7663533e",
"newSha256": "7b7cc4cc75a3a355f26e2ff15ae1d26b5964ad7b370061691a94ce5f6f4b9158"
},
"coverImage": "screenshots/02-truck-new-view.jpg"
},
{
"title": "手动保存狐狸:生成与卡车不同的真实模型封面",
"status": "PASS",
"details": {
"id": "157",
"name": "实测模型-狐狸动画-已验证-20260905",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/3d78768edb7ce9b4a89a75c4d68de597.jpg",
"versionId": "202",
"projectVersion": 12,
"coverAssetId": "2058",
"sha256": "6ace0cf199626a3e8ecd4b360d62c0a6ec9b39309293554765dca8128a2ec5e9",
"bytes": 13163,
"width": 640,
"height": 346,
"image": "screenshots/03-fox-cover.jpg",
"cache": "no-cache, private"
},
"helpersRestored": true,
"screenshot": "screenshots/03-fox-cover-editor.png",
"coverImage": "screenshots/03-fox-cover.jpg"
},
{
"title": "注入封面上传503:模型位置保存成功,旧封面保留并提示",
"status": "PASS",
"details": {
"httpStatus": 503,
"contentSaveStatus": 200,
"originalX": 0.25,
"savedX": 0.35,
"coverUnchanged": true
},
"screenshot": "screenshots/04-upload-failure-content-saved.png"
},
{
"title": "实际自动保存周期保存位置变更,不再次上传封面",
"status": "PASS",
"details": {
"elapsedMs": 16159,
"coverUploads": 0,
"savedX": 0.45,
"coverUnchanged": true
},
"screenshot": "screenshots/05-auto-save-no-cover-upload.png"
},
{
"title": "解除故障并恢复原模型位置:封面再次更新成功",
"status": "PASS",
"details": {
"id": "157",
"name": "实测模型-狐狸动画-已验证-20260905",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/c09a49efd4423dcf8ccbc46b46d1e209.jpg",
"versionId": "202",
"projectVersion": 15,
"coverAssetId": "2067",
"sha256": "6ace0cf199626a3e8ecd4b360d62c0a6ec9b39309293554765dca8128a2ec5e9",
"bytes": 13163,
"width": 640,
"height": 346,
"image": "screenshots/06-fox-restored-cover.jpg",
"cache": "no-cache, private"
},
"helpersRestored": true,
"screenshot": "screenshots/06-fox-restored-cover-editor.png",
"coverImage": "screenshots/06-fox-restored-cover.jpg"
},
{
"title": "列表显示真实封面;读取失败回退后,同条件搜索可以重试恢复",
"status": "PASS",
"details": {
"failedReads": 1,
"restoredImages": 2,
"sort": "CREATED_DESC"
},
"screenshot": "screenshots/07-real-model-covers-list.png"
},
{
"title": "离开缓存列表后取消封面请求并释放Blob URL",
"status": "PASS",
"details": {
"releasedUrls": 2
}
},
{
"title": "首次导入GLB自动生成封面,模型文件与图片均完成受控绑定",
"status": "PASS",
"details": {
"id": "158",
"source": "01-Box.glb",
"uploads": [
{
"status": 200,
"type": "MODEL_FILE",
"code": "MODEL_MTO82QGT_01-BOX"
},
{
"status": 200,
"type": "IMAGE",
"code": "MODEL_COVER"
}
],
"sha256": "6e99933505dfeb91a14d16ef1ddf53df2ea022add1b866de835c08403685b7b6",
"bytes": 11446,
"width": 640,
"height": 346,
"image": "screenshots/08-import-box-cover.jpg",
"cache": "no-cache, private"
},
"screenshot": "screenshots/08-import-box-editor.png",
"coverImage": "screenshots/08-import-box-cover.jpg"
},
{
"title": "清理首次导入临时模型",
"status": "PASS",
"details": {
"id": "158",
"status": "业务软删除",
"readAfterDelete": 404
}
},
{
"title": "封面读取边界:未登录401、无封面404、图片Range正确",
"status": "PASS",
"details": {
"anonymous": 401,
"missing": 404,
"range": 206,
"rangeBytes": 32
}
},
{
"title": "最终两条保留模型显示独立封面,创建时间倒序保持",
"status": "PASS",
"screenshot": "screenshots/09-final-two-covers.png"
},
{
"title": "全新浏览器会话显示两张持久化封面,重开模型不自动上传图片",
"status": "PASS",
"details": {
"projects": [
{
"id": "156",
"status": "PUBLISHED",
"versionId": "204"
},
{
"id": "157",
"status": "DRAFT",
"versionId": "202"
}
],
"unexpectedUploads": 0
},
"screenshot": "screenshots/10-fresh-session-covers.png"
}
],
"projects": [
{
"id": "156",
"name": "实测模型-牛奶卡车-已验证-20260905",
"originalStatus": "PUBLISHED",
"originalCover": "",
"originalVersionId": "200",
"cover": {
"id": "156",
"name": "实测模型-牛奶卡车-已验证-20260905",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/67b2a5d15f9c1fc47a0a760f28fd2e2e.jpg",
"versionId": "204",
"projectVersion": 18,
"coverAssetId": "2055",
"sha256": "7b7cc4cc75a3a355f26e2ff15ae1d26b5964ad7b370061691a94ce5f6f4b9158",
"bytes": 17821,
"width": 640,
"height": 346,
"image": "screenshots/02-truck-new-view.jpg",
"cache": "no-cache, private"
},
"finalStatus": "PUBLISHED",
"finalVersionId": "204",
"retained": true
},
{
"id": "157",
"name": "实测模型-狐狸动画-已验证-20260905",
"originalStatus": "DRAFT",
"originalCover": "",
"originalVersionId": "202",
"cover": {
"id": "157",
"name": "实测模型-狐狸动画-已验证-20260905",
"coverUri": "fsvc://unreal-tran/image/2026/09/05/c09a49efd4423dcf8ccbc46b46d1e209.jpg",
"versionId": "202",
"projectVersion": 15,
"coverAssetId": "2067",
"sha256": "6ace0cf199626a3e8ecd4b360d62c0a6ec9b39309293554765dca8128a2ec5e9",
"bytes": 13163,
"width": 640,
"height": 346,
"image": "screenshots/06-fox-restored-cover.jpg",
"cache": "no-cache, private"
},
"finalStatus": "DRAFT",
"finalVersionId": "202",
"retained": true
}
],
"issues": [],
"commands": [
{
"command": "pnpm run build",
"status": "PASS",
"details": "最终web类型检查、Vite构建、10个生成包payload哈希与API桥接/凭据门禁通过;现有Three资源URL与chunk体积警告未阻断构建。"
},
{
"command": "mvn -B -ntp -pl ut_tran -Dtest=ContentProjectServiceTest,ContentAssetServiceTest,ContentControllerRangeTest,ContentAuthorizerTest test",
"status": "PASS",
"details": "Java17,93项通过。"
},
{
"command": "node --test --test-reporter=spec tools/model-cover-capture.test.mjs tools/model-cover-api.test.mjs",
"status": "PASS",
"details": "截图12项+封面事务8项,20/20通过;既有模型API与导入失败保护回归也通过。"
}
],
"retainedData": "保留156卡车、157狐狸及新封面;卡车恢复已发布,狐狸保留原草稿状态。故障测试位置改动已恢复。158首次导入临时工程已通过业务接口软删除。历史发布版本及封面保留。"
}

+ 200
- 0
reports/model-cover-20260905/index.html View File

@@ -0,0 +1,200 @@
<!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:#172b34;--muted:#536872;--line:#dce5e7;--accent:#10664e}*{box-sizing:border-box}body{margin:0;background:#f2f6f6;color:var(--ink);font:15px/1.7 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1180px;margin:0 auto;padding:40px 28px 60px}header{padding-bottom:22px;border-bottom:1px solid var(--line)}.eyebrow{font-size:12px;letter-spacing:.14em;color:var(--accent);font-weight:700}h1{font-size:32px;line-height:1.35;margin:8px 0 12px}h2{font-size:23px;margin:32px 0 15px}h3{font-size:17px;margin:0 0 8px}p{margin:8px 0 12px}.muted,figcaption{color:var(--muted)}a{color:var(--accent);text-underline-offset:3px}.stats{display:flex;gap:12px;flex-wrap:wrap;margin:22px 0}.stat{background:white;border:1px solid var(--line);border-radius:10px;padding:12px 18px;min-width:150px}.stat strong{font-size:25px;display:block}.covers,.checks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.card,.check,.panel{background:white;border:1px solid var(--line);border-radius:12px;padding:20px;min-width:0}.cover-meta{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;font-size:13px;margin-top:12px}.cover-meta dt{color:var(--muted)}.cover-meta dd{margin:0;overflow-wrap:anywhere}figure{margin:12px 0 0}figure img{display:block;width:100%;height:auto;border:1px solid var(--line);border-radius:7px;background:#edf1f1}.cover-image img{aspect-ratio:640/346;object-fit:contain}.evidence-images{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.evidence-images.single{grid-template-columns:1fr}figcaption{font-size:12px;margin-top:5px}.badge{display:inline-block;font-size:12px;font-weight:700;padding:1px 9px;border-radius:12px;white-space:nowrap}.pass{background:#e1f3eb;color:#0c6144}.fail{background:#fde8e8;color:#ab2323}.neutral{background:#edf0f4;color:#425566}.check-title{display:flex;gap:10px;align-items:flex-start;justify-content:space-between}details{margin-top:12px}summary{cursor:pointer;color:var(--accent);font-size:13px}pre{margin:10px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.6 ui-monospace,Consolas,monospace;background:#f4f7f8;padding:12px;border-radius:7px;max-height:360px;overflow:auto}table{width:100%;border-collapse:collapse;font-size:13px}th,td{padding:9px 12px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}th{background:#f4f7f8}td{overflow-wrap:anywhere}ul{padding-left:22px}.command{margin-bottom:12px}.command pre{max-height:280px}footer{margin-top:28px;color:var(--muted);font-size:12px}@media(max-width:760px){main{padding:24px 16px 40px}h1{font-size:26px}.covers,.checks{grid-template-columns:1fr}.stat{flex:1;min-width:100px}.panel,.card,.check{padding:15px}.table-wrap{overflow:auto}}@media print{body{background:white}main{max-width:none;padding:0}details{display:block}pre{max-height:none}.card,.check,figure{break-inside:avoid}a{color:inherit}.checks{display:block}.check{margin-bottom:14px}}
</style></head><body><main>
<header><div class="eyebrow">MODEL COVER · IMPLEMENTED</div><h1>模型截图封面验证报告</h1><p>截图封面功能已实施并完成下列真实浏览器验证:视口截图作为受控图片资产与工程关联保存,列表读取真实封面,模型列表按创建时间倒序显示。</p><p class="muted">2026-09-05 · http://127.0.0.1:6180 · Microsoft Edge · 教员</p><a href="report.md">Markdown 报告</a></header>
<div class="stats"><div class="stat"><strong>14 / 14</strong>证据项通过</div><div class="stat"><strong>0</strong>非 PASS 证据项</div><div class="stat"><strong>15</strong>图片文件已校验</div></div>
<h2>最终模型封面</h2><div class="covers"><article class="card"><h3>实测模型-牛奶卡车-已验证-20260905</h3><figure class="cover-image"><a href="screenshots/02-truck-new-view.jpg" target="_blank" rel="noopener"><img src="screenshots/02-truck-new-view.jpg" alt="项目 156 最终封面" loading="lazy"></a><figcaption>项目 156 最终封面 · 点击查看原图</figcaption></figure><dl class="cover-meta"><dt>项目 / 最终状态</dt><dd>156 / PUBLISHED</dd><dt>工程版本 / 封面资产</dt><dd>204 / 2055</dd><dt>图片规格</dt><dd>640 × 346 · 17821 字节</dd></dl><details><summary>封面持久化证据</summary><pre>{
&quot;id&quot;: &quot;156&quot;,
&quot;name&quot;: &quot;实测模型-牛奶卡车-已验证-20260905&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/67b2a5d15f9c1fc47a0a760f28fd2e2e.jpg&quot;,
&quot;versionId&quot;: &quot;204&quot;,
&quot;projectVersion&quot;: 18,
&quot;coverAssetId&quot;: &quot;2055&quot;,
&quot;sha256&quot;: &quot;7b7cc4cc75a3a355f26e2ff15ae1d26b5964ad7b370061691a94ce5f6f4b9158&quot;,
&quot;bytes&quot;: 17821,
&quot;width&quot;: 640,
&quot;height&quot;: 346,
&quot;image&quot;: &quot;screenshots/02-truck-new-view.jpg&quot;,
&quot;cache&quot;: &quot;no-cache, private&quot;
}</pre></details></article><article class="card"><h3>实测模型-狐狸动画-已验证-20260905</h3><figure class="cover-image"><a href="screenshots/06-fox-restored-cover.jpg" target="_blank" rel="noopener"><img src="screenshots/06-fox-restored-cover.jpg" alt="项目 157 最终封面" loading="lazy"></a><figcaption>项目 157 最终封面 · 点击查看原图</figcaption></figure><dl class="cover-meta"><dt>项目 / 最终状态</dt><dd>157 / DRAFT</dd><dt>工程版本 / 封面资产</dt><dd>202 / 2067</dd><dt>图片规格</dt><dd>640 × 346 · 13163 字节</dd></dl><details><summary>封面持久化证据</summary><pre>{
&quot;id&quot;: &quot;157&quot;,
&quot;name&quot;: &quot;实测模型-狐狸动画-已验证-20260905&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/c09a49efd4423dcf8ccbc46b46d1e209.jpg&quot;,
&quot;versionId&quot;: &quot;202&quot;,
&quot;projectVersion&quot;: 15,
&quot;coverAssetId&quot;: &quot;2067&quot;,
&quot;sha256&quot;: &quot;6ace0cf199626a3e8ecd4b360d62c0a6ec9b39309293554765dca8128a2ec5e9&quot;,
&quot;bytes&quot;: 13163,
&quot;width&quot;: 640,
&quot;height&quot;: 346,
&quot;image&quot;: &quot;screenshots/06-fox-restored-cover.jpg&quot;,
&quot;cache&quot;: &quot;no-cache, private&quot;
}</pre></details></article></div>
<h2>最终列表现场</h2><section class="panel"><figure class=""><a href="screenshots/09-final-two-covers.png" target="_blank" rel="noopener"><img src="screenshots/09-final-two-covers.png" alt="两条保留模型的真实封面与创建时间倒序列表" loading="lazy"></a><figcaption>两条保留模型的真实封面与创建时间倒序列表 · 点击查看原图</figcaption></figure></section>
<h2>验证记录</h2><p class="muted">通过数只统计 status 为 PASS 的记录;故障注入中预期的 503、401 或 404 不等于用例失败。</p><div class="checks"><article class="check"><div class="check-title"><h3>1. 首次手动保存卡车:截取当前场景并绑定JPEG封面</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;id&quot;: &quot;156&quot;,
&quot;name&quot;: &quot;实测模型-牛奶卡车-已验证-20260905&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/4e12ee526680085650c48934bc0305e1.jpg&quot;,
&quot;versionId&quot;: &quot;203&quot;,
&quot;projectVersion&quot;: 13,
&quot;coverAssetId&quot;: &quot;2040&quot;,
&quot;sha256&quot;: &quot;15caa821e90a70d5b0f8ba1554c0d0d6db332a9b5e53b0d5e94de24d7663533e&quot;,
&quot;bytes&quot;: 17889,
&quot;width&quot;: 640,
&quot;height&quot;: 346,
&quot;image&quot;: &quot;screenshots/01-truck-cover.jpg&quot;,
&quot;cache&quot;: &quot;no-cache, private&quot;
},
&quot;helpersRestored&quot;: true
}</pre></details><div class="evidence-images "><figure class=""><a href="screenshots/01-truck-cover-editor.png" target="_blank" rel="noopener"><img src="screenshots/01-truck-cover-editor.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/01-truck-cover.jpg" target="_blank" rel="noopener"><img src="screenshots/01-truck-cover.jpg" alt="实际生成封面" loading="lazy"></a><figcaption>实际生成封面 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>2. 更换卡车视角再次保存:替换当前封面且辅助控件恢复</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;id&quot;: &quot;156&quot;,
&quot;name&quot;: &quot;实测模型-牛奶卡车-已验证-20260905&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/67b2a5d15f9c1fc47a0a760f28fd2e2e.jpg&quot;,
&quot;versionId&quot;: &quot;204&quot;,
&quot;projectVersion&quot;: 18,
&quot;coverAssetId&quot;: &quot;2055&quot;,
&quot;sha256&quot;: &quot;7b7cc4cc75a3a355f26e2ff15ae1d26b5964ad7b370061691a94ce5f6f4b9158&quot;,
&quot;bytes&quot;: 17821,
&quot;width&quot;: 640,
&quot;height&quot;: 346,
&quot;image&quot;: &quot;screenshots/02-truck-new-view.jpg&quot;,
&quot;cache&quot;: &quot;no-cache, private&quot;
},
&quot;helpersRestored&quot;: true
}</pre></details><div class="evidence-images "><figure class=""><a href="screenshots/02-truck-new-view-editor.png" target="_blank" rel="noopener"><img src="screenshots/02-truck-new-view-editor.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/02-truck-new-view.jpg" target="_blank" rel="noopener"><img src="screenshots/02-truck-new-view.jpg" alt="实际生成封面" loading="lazy"></a><figcaption>实际生成封面 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>3. 更换相机视角更新封面,已发布历史版本保留旧封面</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;oldVersionId&quot;: &quot;203&quot;,
&quot;newVersionId&quot;: &quot;204&quot;,
&quot;oldSha256&quot;: &quot;15caa821e90a70d5b0f8ba1554c0d0d6db332a9b5e53b0d5e94de24d7663533e&quot;,
&quot;newSha256&quot;: &quot;7b7cc4cc75a3a355f26e2ff15ae1d26b5964ad7b370061691a94ce5f6f4b9158&quot;
}
}</pre></details><div class="evidence-images single"><figure class=""><a href="screenshots/02-truck-new-view.jpg" target="_blank" rel="noopener"><img src="screenshots/02-truck-new-view.jpg" alt="实际生成封面" loading="lazy"></a><figcaption>实际生成封面 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>4. 手动保存狐狸:生成与卡车不同的真实模型封面</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;id&quot;: &quot;157&quot;,
&quot;name&quot;: &quot;实测模型-狐狸动画-已验证-20260905&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/3d78768edb7ce9b4a89a75c4d68de597.jpg&quot;,
&quot;versionId&quot;: &quot;202&quot;,
&quot;projectVersion&quot;: 12,
&quot;coverAssetId&quot;: &quot;2058&quot;,
&quot;sha256&quot;: &quot;6ace0cf199626a3e8ecd4b360d62c0a6ec9b39309293554765dca8128a2ec5e9&quot;,
&quot;bytes&quot;: 13163,
&quot;width&quot;: 640,
&quot;height&quot;: 346,
&quot;image&quot;: &quot;screenshots/03-fox-cover.jpg&quot;,
&quot;cache&quot;: &quot;no-cache, private&quot;
},
&quot;helpersRestored&quot;: true
}</pre></details><div class="evidence-images "><figure class=""><a href="screenshots/03-fox-cover-editor.png" target="_blank" rel="noopener"><img src="screenshots/03-fox-cover-editor.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/03-fox-cover.jpg" target="_blank" rel="noopener"><img src="screenshots/03-fox-cover.jpg" alt="实际生成封面" loading="lazy"></a><figcaption>实际生成封面 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>5. 注入封面上传503:模型位置保存成功,旧封面保留并提示</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;httpStatus&quot;: 503,
&quot;contentSaveStatus&quot;: 200,
&quot;originalX&quot;: 0.25,
&quot;savedX&quot;: 0.35,
&quot;coverUnchanged&quot;: true
}
}</pre></details><div class="evidence-images single"><figure class=""><a href="screenshots/04-upload-failure-content-saved.png" target="_blank" rel="noopener"><img src="screenshots/04-upload-failure-content-saved.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>6. 实际自动保存周期保存位置变更,不再次上传封面</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;elapsedMs&quot;: 16159,
&quot;coverUploads&quot;: 0,
&quot;savedX&quot;: 0.45,
&quot;coverUnchanged&quot;: true
}
}</pre></details><div class="evidence-images single"><figure class=""><a href="screenshots/05-auto-save-no-cover-upload.png" target="_blank" rel="noopener"><img src="screenshots/05-auto-save-no-cover-upload.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>7. 解除故障并恢复原模型位置:封面再次更新成功</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;id&quot;: &quot;157&quot;,
&quot;name&quot;: &quot;实测模型-狐狸动画-已验证-20260905&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/c09a49efd4423dcf8ccbc46b46d1e209.jpg&quot;,
&quot;versionId&quot;: &quot;202&quot;,
&quot;projectVersion&quot;: 15,
&quot;coverAssetId&quot;: &quot;2067&quot;,
&quot;sha256&quot;: &quot;6ace0cf199626a3e8ecd4b360d62c0a6ec9b39309293554765dca8128a2ec5e9&quot;,
&quot;bytes&quot;: 13163,
&quot;width&quot;: 640,
&quot;height&quot;: 346,
&quot;image&quot;: &quot;screenshots/06-fox-restored-cover.jpg&quot;,
&quot;cache&quot;: &quot;no-cache, private&quot;
},
&quot;helpersRestored&quot;: true
}</pre></details><div class="evidence-images "><figure class=""><a href="screenshots/06-fox-restored-cover-editor.png" target="_blank" rel="noopener"><img src="screenshots/06-fox-restored-cover-editor.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/06-fox-restored-cover.jpg" target="_blank" rel="noopener"><img src="screenshots/06-fox-restored-cover.jpg" alt="实际生成封面" loading="lazy"></a><figcaption>实际生成封面 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>8. 列表显示真实封面;读取失败回退后,同条件搜索可以重试恢复</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;failedReads&quot;: 1,
&quot;restoredImages&quot;: 2,
&quot;sort&quot;: &quot;CREATED_DESC&quot;
}
}</pre></details><div class="evidence-images single"><figure class=""><a href="screenshots/07-real-model-covers-list.png" target="_blank" rel="noopener"><img src="screenshots/07-real-model-covers-list.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>9. 离开缓存列表后取消封面请求并释放Blob URL</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;releasedUrls&quot;: 2
}
}</pre></details><div class="evidence-images "></div></article><article class="check"><div class="check-title"><h3>10. 首次导入GLB自动生成封面,模型文件与图片均完成受控绑定</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;id&quot;: &quot;158&quot;,
&quot;source&quot;: &quot;01-Box.glb&quot;,
&quot;uploads&quot;: [
{
&quot;status&quot;: 200,
&quot;type&quot;: &quot;MODEL_FILE&quot;,
&quot;code&quot;: &quot;MODEL_MTO82QGT_01-BOX&quot;
},
{
&quot;status&quot;: 200,
&quot;type&quot;: &quot;IMAGE&quot;,
&quot;code&quot;: &quot;MODEL_COVER&quot;
}
],
&quot;sha256&quot;: &quot;6e99933505dfeb91a14d16ef1ddf53df2ea022add1b866de835c08403685b7b6&quot;,
&quot;bytes&quot;: 11446,
&quot;width&quot;: 640,
&quot;height&quot;: 346,
&quot;image&quot;: &quot;screenshots/08-import-box-cover.jpg&quot;,
&quot;cache&quot;: &quot;no-cache, private&quot;
}
}</pre></details><div class="evidence-images "><figure class=""><a href="screenshots/08-import-box-editor.png" target="_blank" rel="noopener"><img src="screenshots/08-import-box-editor.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/08-import-box-cover.jpg" target="_blank" rel="noopener"><img src="screenshots/08-import-box-cover.jpg" alt="实际生成封面" loading="lazy"></a><figcaption>实际生成封面 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>11. 清理首次导入临时模型</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;id&quot;: &quot;158&quot;,
&quot;status&quot;: &quot;业务软删除&quot;,
&quot;readAfterDelete&quot;: 404
}
}</pre></details><div class="evidence-images "></div></article><article class="check"><div class="check-title"><h3>12. 封面读取边界:未登录401、无封面404、图片Range正确</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;anonymous&quot;: 401,
&quot;missing&quot;: 404,
&quot;range&quot;: 206,
&quot;rangeBytes&quot;: 32
}
}</pre></details><div class="evidence-images "></div></article><article class="check"><div class="check-title"><h3>13. 最终两条保留模型显示独立封面,创建时间倒序保持</h3><span class="badge pass">PASS</span></div><div class="evidence-images single"><figure class=""><a href="screenshots/09-final-two-covers.png" target="_blank" rel="noopener"><img src="screenshots/09-final-two-covers.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>14. 全新浏览器会话显示两张持久化封面,重开模型不自动上传图片</h3><span class="badge pass">PASS</span></div><details><summary>检查细节</summary><pre>{
&quot;details&quot;: {
&quot;projects&quot;: [
{
&quot;id&quot;: &quot;156&quot;,
&quot;status&quot;: &quot;PUBLISHED&quot;,
&quot;versionId&quot;: &quot;204&quot;
},
{
&quot;id&quot;: &quot;157&quot;,
&quot;status&quot;: &quot;DRAFT&quot;,
&quot;versionId&quot;: &quot;202&quot;
}
],
&quot;unexpectedUploads&quot;: 0
}
}</pre></details><div class="evidence-images single"><figure class=""><a href="screenshots/10-fresh-session-covers.png" target="_blank" rel="noopener"><img src="screenshots/10-fresh-session-covers.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure></div></article></div>
<h2>数据保留与清理</h2><section class="panel"><p>保留156卡车、157狐狸及新封面;卡车恢复已发布,狐狸保留原草稿状态。故障测试位置改动已恢复。158首次导入临时工程已通过业务接口软删除。历史发布版本及封面保留。</p><div class="table-wrap"><table><thead><tr><th>项目</th><th>初始状态</th><th>最终状态</th></tr></thead><tbody><tr><td>156 · 实测模型-牛奶卡车-已验证-20260905</td><td>PUBLISHED</td><td>PUBLISHED</td></tr><tr><td>157 · 实测模型-狐狸动画-已验证-20260905</td><td>DRAFT</td><td>DRAFT</td></tr></tbody></table></div><p>首次导入验证使用的临时项目 158 已通过业务软删除,删除后读取返回 404;报告保留操作证据,不宣称物理文件已删除。</p></section>
<h2>问题记录</h2><section class="panel"><p>当前证据未登记未解决问题。</p></section>
<h2>实际命令与结果</h2><section class="panel"><div class="command"><h3>pnpm run build</h3><pre>{
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;最终web类型检查、Vite构建、10个生成包payload哈希与API桥接/凭据门禁通过;现有Three资源URL与chunk体积警告未阻断构建。&quot;
}</pre></div><div class="command"><h3>mvn -B -ntp -pl ut_tran -Dtest=ContentProjectServiceTest,ContentAssetServiceTest,ContentControllerRangeTest,ContentAuthorizerTest test</h3><pre>{
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;Java17,93项通过。&quot;
}</pre></div><div class="command"><h3>node --test --test-reporter=spec tools/model-cover-capture.test.mjs tools/model-cover-api.test.mjs</h3><pre>{
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;截图12项+封面事务8项,20/20通过;既有模型API与导入失败保护回归也通过。&quot;
}</pre></div></section>
<h2>当前行为与限制</h2><section class="panel"><ul><li>仅手动保存和首次导入模型时生成或更新封面,封面取自当前三维视口。</li><li>自动保存只保存工程变更,不重新截图或上传封面。</li><li>截图或封面上传失败时保留旧封面,并提示失败;工程保存仍按其实际请求结果判断。</li></ul></section>
<footer>报告由 evidence.json 生成,未修改原始证据。所有引用图片在生成时校验为存在且非空;点击图片可查看原图。</footer>
</main></body></html>

+ 320
- 0
reports/model-cover-20260905/report.md View File

@@ -0,0 +1,320 @@
# 模型截图封面验证报告

截图封面功能已实施并完成下列真实浏览器验证:视口截图作为受控图片资产与工程关联保存,列表读取真实封面,模型列表按创建时间倒序显示。

2026-09-05 · http://127.0.0.1:6180 · Microsoft Edge · 教员

证据通过:**14 / 14**;非 PASS:0;已校验图片:15。

## 最终模型封面

### 实测模型-牛奶卡车-已验证-20260905

项目 156;最终状态:PUBLISHED;工程版本 ID:204;封面资产 ID:2055。

[![项目 156 最终封面](screenshots/02-truck-new-view.jpg)](screenshots/02-truck-new-view.jpg)

### 实测模型-狐狸动画-已验证-20260905

项目 157;最终状态:DRAFT;工程版本 ID:202;封面资产 ID:2067。

[![项目 157 最终封面](screenshots/06-fox-restored-cover.jpg)](screenshots/06-fox-restored-cover.jpg)

## 最终列表现场

[![最终两条模型封面列表](screenshots/09-final-two-covers.png)](screenshots/09-final-two-covers.png)

## 验证记录

通过统计只认 status 为 PASS;预期故障响应不视为用例失败。

| 序号 | 检查项 | 结果 | 图片 |
|---|---|---|---|
| 1 | 首次手动保存卡车:截取当前场景并绑定JPEG封面 | PASS | [图片 1](screenshots/01-truck-cover-editor.png) · [图片 2](screenshots/01-truck-cover.jpg) |
| 2 | 更换卡车视角再次保存:替换当前封面且辅助控件恢复 | PASS | [图片 1](screenshots/02-truck-new-view-editor.png) · [图片 2](screenshots/02-truck-new-view.jpg) |
| 3 | 更换相机视角更新封面,已发布历史版本保留旧封面 | PASS | [图片 1](screenshots/02-truck-new-view.jpg) |
| 4 | 手动保存狐狸:生成与卡车不同的真实模型封面 | PASS | [图片 1](screenshots/03-fox-cover-editor.png) · [图片 2](screenshots/03-fox-cover.jpg) |
| 5 | 注入封面上传503:模型位置保存成功,旧封面保留并提示 | PASS | [图片 1](screenshots/04-upload-failure-content-saved.png) |
| 6 | 实际自动保存周期保存位置变更,不再次上传封面 | PASS | [图片 1](screenshots/05-auto-save-no-cover-upload.png) |
| 7 | 解除故障并恢复原模型位置:封面再次更新成功 | PASS | [图片 1](screenshots/06-fox-restored-cover-editor.png) · [图片 2](screenshots/06-fox-restored-cover.jpg) |
| 8 | 列表显示真实封面;读取失败回退后,同条件搜索可以重试恢复 | PASS | [图片 1](screenshots/07-real-model-covers-list.png) |
| 9 | 离开缓存列表后取消封面请求并释放Blob URL | PASS | — |
| 10 | 首次导入GLB自动生成封面,模型文件与图片均完成受控绑定 | PASS | [图片 1](screenshots/08-import-box-editor.png) · [图片 2](screenshots/08-import-box-cover.jpg) |
| 11 | 清理首次导入临时模型 | PASS | — |
| 12 | 封面读取边界:未登录401、无封面404、图片Range正确 | PASS | — |
| 13 | 最终两条保留模型显示独立封面,创建时间倒序保持 | PASS | [图片 1](screenshots/09-final-two-covers.png) |
| 14 | 全新浏览器会话显示两张持久化封面,重开模型不自动上传图片 | PASS | [图片 1](screenshots/10-fresh-session-covers.png) |

<details><summary>首次手动保存卡车:截取当前场景并绑定JPEG封面:检查细节</summary>

<pre>
{
&quot;id&quot;: &quot;156&quot;,
&quot;name&quot;: &quot;实测模型-牛奶卡车-已验证-20260905&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/4e12ee526680085650c48934bc0305e1.jpg&quot;,
&quot;versionId&quot;: &quot;203&quot;,
&quot;projectVersion&quot;: 13,
&quot;coverAssetId&quot;: &quot;2040&quot;,
&quot;sha256&quot;: &quot;15caa821e90a70d5b0f8ba1554c0d0d6db332a9b5e53b0d5e94de24d7663533e&quot;,
&quot;bytes&quot;: 17889,
&quot;width&quot;: 640,
&quot;height&quot;: 346,
&quot;image&quot;: &quot;screenshots/01-truck-cover.jpg&quot;,
&quot;cache&quot;: &quot;no-cache, private&quot;
}
</pre>

</details>

<details><summary>更换卡车视角再次保存:替换当前封面且辅助控件恢复:检查细节</summary>

<pre>
{
&quot;id&quot;: &quot;156&quot;,
&quot;name&quot;: &quot;实测模型-牛奶卡车-已验证-20260905&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/67b2a5d15f9c1fc47a0a760f28fd2e2e.jpg&quot;,
&quot;versionId&quot;: &quot;204&quot;,
&quot;projectVersion&quot;: 18,
&quot;coverAssetId&quot;: &quot;2055&quot;,
&quot;sha256&quot;: &quot;7b7cc4cc75a3a355f26e2ff15ae1d26b5964ad7b370061691a94ce5f6f4b9158&quot;,
&quot;bytes&quot;: 17821,
&quot;width&quot;: 640,
&quot;height&quot;: 346,
&quot;image&quot;: &quot;screenshots/02-truck-new-view.jpg&quot;,
&quot;cache&quot;: &quot;no-cache, private&quot;
}
</pre>

</details>

<details><summary>更换相机视角更新封面,已发布历史版本保留旧封面:检查细节</summary>

<pre>
{
&quot;oldVersionId&quot;: &quot;203&quot;,
&quot;newVersionId&quot;: &quot;204&quot;,
&quot;oldSha256&quot;: &quot;15caa821e90a70d5b0f8ba1554c0d0d6db332a9b5e53b0d5e94de24d7663533e&quot;,
&quot;newSha256&quot;: &quot;7b7cc4cc75a3a355f26e2ff15ae1d26b5964ad7b370061691a94ce5f6f4b9158&quot;
}
</pre>

</details>

<details><summary>手动保存狐狸:生成与卡车不同的真实模型封面:检查细节</summary>

<pre>
{
&quot;id&quot;: &quot;157&quot;,
&quot;name&quot;: &quot;实测模型-狐狸动画-已验证-20260905&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/3d78768edb7ce9b4a89a75c4d68de597.jpg&quot;,
&quot;versionId&quot;: &quot;202&quot;,
&quot;projectVersion&quot;: 12,
&quot;coverAssetId&quot;: &quot;2058&quot;,
&quot;sha256&quot;: &quot;6ace0cf199626a3e8ecd4b360d62c0a6ec9b39309293554765dca8128a2ec5e9&quot;,
&quot;bytes&quot;: 13163,
&quot;width&quot;: 640,
&quot;height&quot;: 346,
&quot;image&quot;: &quot;screenshots/03-fox-cover.jpg&quot;,
&quot;cache&quot;: &quot;no-cache, private&quot;
}
</pre>

</details>

<details><summary>注入封面上传503:模型位置保存成功,旧封面保留并提示:检查细节</summary>

<pre>
{
&quot;httpStatus&quot;: 503,
&quot;contentSaveStatus&quot;: 200,
&quot;originalX&quot;: 0.25,
&quot;savedX&quot;: 0.35,
&quot;coverUnchanged&quot;: true
}
</pre>

</details>

<details><summary>实际自动保存周期保存位置变更,不再次上传封面:检查细节</summary>

<pre>
{
&quot;elapsedMs&quot;: 16159,
&quot;coverUploads&quot;: 0,
&quot;savedX&quot;: 0.45,
&quot;coverUnchanged&quot;: true
}
</pre>

</details>

<details><summary>解除故障并恢复原模型位置:封面再次更新成功:检查细节</summary>

<pre>
{
&quot;id&quot;: &quot;157&quot;,
&quot;name&quot;: &quot;实测模型-狐狸动画-已验证-20260905&quot;,
&quot;coverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/c09a49efd4423dcf8ccbc46b46d1e209.jpg&quot;,
&quot;versionId&quot;: &quot;202&quot;,
&quot;projectVersion&quot;: 15,
&quot;coverAssetId&quot;: &quot;2067&quot;,
&quot;sha256&quot;: &quot;6ace0cf199626a3e8ecd4b360d62c0a6ec9b39309293554765dca8128a2ec5e9&quot;,
&quot;bytes&quot;: 13163,
&quot;width&quot;: 640,
&quot;height&quot;: 346,
&quot;image&quot;: &quot;screenshots/06-fox-restored-cover.jpg&quot;,
&quot;cache&quot;: &quot;no-cache, private&quot;
}
</pre>

</details>

<details><summary>列表显示真实封面;读取失败回退后,同条件搜索可以重试恢复:检查细节</summary>

<pre>
{
&quot;failedReads&quot;: 1,
&quot;restoredImages&quot;: 2,
&quot;sort&quot;: &quot;CREATED_DESC&quot;
}
</pre>

</details>

<details><summary>离开缓存列表后取消封面请求并释放Blob URL:检查细节</summary>

<pre>
{
&quot;releasedUrls&quot;: 2
}
</pre>

</details>

<details><summary>首次导入GLB自动生成封面,模型文件与图片均完成受控绑定:检查细节</summary>

<pre>
{
&quot;id&quot;: &quot;158&quot;,
&quot;source&quot;: &quot;01-Box.glb&quot;,
&quot;uploads&quot;: [
{
&quot;status&quot;: 200,
&quot;type&quot;: &quot;MODEL_FILE&quot;,
&quot;code&quot;: &quot;MODEL_MTO82QGT_01-BOX&quot;
},
{
&quot;status&quot;: 200,
&quot;type&quot;: &quot;IMAGE&quot;,
&quot;code&quot;: &quot;MODEL_COVER&quot;
}
],
&quot;sha256&quot;: &quot;6e99933505dfeb91a14d16ef1ddf53df2ea022add1b866de835c08403685b7b6&quot;,
&quot;bytes&quot;: 11446,
&quot;width&quot;: 640,
&quot;height&quot;: 346,
&quot;image&quot;: &quot;screenshots/08-import-box-cover.jpg&quot;,
&quot;cache&quot;: &quot;no-cache, private&quot;
}
</pre>

</details>

<details><summary>清理首次导入临时模型:检查细节</summary>

<pre>
{
&quot;id&quot;: &quot;158&quot;,
&quot;status&quot;: &quot;业务软删除&quot;,
&quot;readAfterDelete&quot;: 404
}
</pre>

</details>

<details><summary>封面读取边界:未登录401、无封面404、图片Range正确:检查细节</summary>

<pre>
{
&quot;anonymous&quot;: 401,
&quot;missing&quot;: 404,
&quot;range&quot;: 206,
&quot;rangeBytes&quot;: 32
}
</pre>

</details>

<details><summary>全新浏览器会话显示两张持久化封面,重开模型不自动上传图片:检查细节</summary>

<pre>
{
&quot;projects&quot;: [
{
&quot;id&quot;: &quot;156&quot;,
&quot;status&quot;: &quot;PUBLISHED&quot;,
&quot;versionId&quot;: &quot;204&quot;
},
{
&quot;id&quot;: &quot;157&quot;,
&quot;status&quot;: &quot;DRAFT&quot;,
&quot;versionId&quot;: &quot;202&quot;
}
],
&quot;unexpectedUploads&quot;: 0
}
</pre>

</details>

## 数据保留与清理

保留156卡车、157狐狸及新封面;卡车恢复已发布,狐狸保留原草稿状态。故障测试位置改动已恢复。158首次导入临时工程已通过业务接口软删除。历史发布版本及封面保留。

| 项目 | 初始状态 | 最终状态 |
|---|---|---|
| 156 · 实测模型-牛奶卡车-已验证-20260905 | PUBLISHED | PUBLISHED |
| 157 · 实测模型-狐狸动画-已验证-20260905 | DRAFT | DRAFT |

首次导入验证使用的临时项目 158 已通过业务软删除,删除后读取返回 404;报告保留操作证据,不宣称物理文件已删除。

## 问题记录

当前证据未登记未解决问题。

## 实际命令与结果

**pnpm run build**

<pre>
{
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;最终web类型检查、Vite构建、10个生成包payload哈希与API桥接/凭据门禁通过;现有Three资源URL与chunk体积警告未阻断构建。&quot;
}
</pre>

**mvn -B -ntp -pl ut_tran -Dtest=ContentProjectServiceTest,ContentAssetServiceTest,ContentControllerRangeTest,ContentAuthorizerTest test**

<pre>
{
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;Java17,93项通过。&quot;
}
</pre>

**node --test --test-reporter=spec tools/model-cover-capture.test.mjs tools/model-cover-api.test.mjs**

<pre>
{
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;截图12项+封面事务8项,20/20通过;既有模型API与导入失败保护回归也通过。&quot;
}
</pre>

## 当前行为与限制

- 仅手动保存和首次导入模型时生成或更新封面,封面取自当前三维视口。
- 自动保存只保存工程变更,不重新截图或上传封面。
- 截图或封面上传失败时保留旧封面,并提示失败;工程保存仍按其实际请求结果判断。

报告由 evidence.json 生成,未修改原始证据;图片均已校验存在且非空,点击图片可查看原图。

BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


+ 27
- 0
reports/model-created-sort-20260905/cover-plan.md View File

@@ -0,0 +1,27 @@
# 模型文件存储与截图封面方案

2026-09-05。用户已确认,本方案已实施;验证结果见[截图封面实施报告](../model-cover-20260905/index.html)。以下保留方案与存储说明。

## 当前文件存储

GLB 经 `/api/tran/v1/content/projects/{id}/assets/upload` 上传到业务服务,完成格式和大小校验后,由业务服务提交至 `http://1.14.103.234:6180/fileapi/upload`。

文件按 `unreal-tran/model/年/月/日/随机ID.glb` 归档。当前 file_svc 部署配置根目录为 `/data/file_svc/files`,未登录远端核查实际磁盘目录。数据库保存 `fsvc://` 文件引用、大小、SHA-256、项目及版本关联;工程 JSON 保存节点、材质和动画。上传成功后删除本机临时文件。

应用内模型读取经过鉴权下载接口。远端 `/f/` 是静态读取地址;本次对两条测试资源的匿名 HEAD 均返回 200,不能将文件本体描述为完全私有。

## 建议的保存封面流程

1. 用户调整到希望保留的相机视角,点击“保存”。
2. 仅截取 3D 视口,沿用当前相机和模型姿态;临时隐藏选中框、变换控件和网格,截图后立即恢复,不截菜单和属性面板。
3. 同步复制已渲染画布,缩放并编码 JPEG,建议最长边 640 像素,避免黑图与过大图片。
4. 通过既有上传接口上传 IMAGE 资产,将上传票据、封面资产与工程放入同一保存队列,保存成功后更新 `coverUri`。
5. 列表通过受控图片下载解析为 Blob URL 展示,并在页面释放时回收;不把 `fsvc://` 直接当作图片地址,也不为读取缩略图逐条加载完整模型工程。

复用现有 `coverUri` 和 IMAGE 资产类型,无需新增表。固定封面资产编码用于当前版本替换,历史版本引用保留。

建议首次导入完成与手动保存时更新封面;每 30 秒的自动保存仅保存工程,避免持续生成图片。截图或上传失败时继续保存模型内容,保留旧封面并提示封面更新失败。

## 实施时需要补齐的接点

当前编辑器已有视口截图方法,但尚未连接保存流程。模型上传方法写死 MODEL_FILE,需要增加 IMAGE 分支;保存指纹需包含封面变化。列表当前直接使用 `img src=coverUri`,必须同步补图片资产读取映射及鉴权加载,才是完整功能。

+ 280
- 0
reports/model-created-sort-20260905/evidence.json View File

@@ -0,0 +1,280 @@
{
"date": "2026-09-05",
"baseURL": "http://127.0.0.1:6180",
"browser": "Microsoft Edge",
"role": "系统管理员",
"retainedData": "本次仅查询和筛选,未创建或修改模型数据。",
"build": {
"command": "pnpm run build",
"status": "PASS",
"details": "类型检查、Vite构建与产物门禁通过;现有Three资源URL和chunk体积警告仍在。"
},
"results": [
{
"title": "默认按创建时间倒序,列表与服务端分页一致",
"status": "PASS",
"type": "MODEL",
"sort": "CREATED_DESC",
"rows": [
{
"id": "157",
"name": "实测模型-狐狸动画-已验证-20260905",
"created": 1788600787,
"updated": "2026-09-05T17:53:56"
},
{
"id": "156",
"name": "实测模型-牛奶卡车-已验证-20260905",
"created": 1788600271,
"updated": "2026-09-05T17:47:29"
},
{
"id": "155",
"name": "测试",
"created": 1788596734,
"updated": "2026-09-05T17:53:11"
},
{
"id": "151",
"name": "野外山地场景与桥梁架设装备",
"created": 1788316127,
"updated": "2026-09-02T11:02:50"
},
{
"id": "94",
"name": "桥梁架设装备整机模型",
"created": 1788074748,
"updated": "2026-09-02T11:02:50"
},
{
"id": "48",
"name": "UAT 挖掘机装备模型",
"created": 1786413935,
"updated": "2026-09-01T09:48:57"
}
],
"screenshot": "screenshots/01-created-default.png"
},
{
"title": "手动选择排序仍可用,URL保留显式选择",
"status": "PASS",
"type": "MODEL",
"sort": "UPDATED_DESC",
"rows": [
{
"id": "157",
"name": "实测模型-狐狸动画-已验证-20260905",
"created": 1788600787,
"updated": "2026-09-05T17:53:56"
},
{
"id": "155",
"name": "测试",
"created": 1788596734,
"updated": "2026-09-05T17:53:11"
},
{
"id": "156",
"name": "实测模型-牛奶卡车-已验证-20260905",
"created": 1788600271,
"updated": "2026-09-05T17:47:29"
},
{
"id": "151",
"name": "野外山地场景与桥梁架设装备",
"created": 1788316127,
"updated": "2026-09-02T11:02:50"
},
{
"id": "94",
"name": "桥梁架设装备整机模型",
"created": 1788074748,
"updated": "2026-09-02T11:02:50"
},
{
"id": "48",
"name": "UAT 挖掘机装备模型",
"created": 1786413935,
"updated": "2026-09-01T09:48:57"
}
]
},
{
"title": "重置恢复创建时间倒序",
"status": "PASS",
"type": "MODEL",
"sort": "CREATED_DESC",
"rows": [
{
"id": "157",
"name": "实测模型-狐狸动画-已验证-20260905",
"created": 1788600787,
"updated": "2026-09-05T17:53:56"
},
{
"id": "156",
"name": "实测模型-牛奶卡车-已验证-20260905",
"created": 1788600271,
"updated": "2026-09-05T17:47:29"
},
{
"id": "155",
"name": "测试",
"created": 1788596734,
"updated": "2026-09-05T17:53:11"
},
{
"id": "151",
"name": "野外山地场景与桥梁架设装备",
"created": 1788316127,
"updated": "2026-09-02T11:02:50"
},
{
"id": "94",
"name": "桥梁架设装备整机模型",
"created": 1788074748,
"updated": "2026-09-02T11:02:50"
},
{
"id": "48",
"name": "UAT 挖掘机装备模型",
"created": 1786413935,
"updated": "2026-09-01T09:48:57"
}
],
"screenshot": "screenshots/02-created-reset.png"
},
{
"title": "整页刷新保持默认创建时间倒序",
"status": "PASS",
"type": "MODEL",
"sort": "CREATED_DESC",
"rows": [
{
"id": "157",
"name": "实测模型-狐狸动画-已验证-20260905",
"created": 1788600787,
"updated": "2026-09-05T17:53:56"
},
{
"id": "156",
"name": "实测模型-牛奶卡车-已验证-20260905",
"created": 1788600271,
"updated": "2026-09-05T17:47:29"
},
{
"id": "155",
"name": "测试",
"created": 1788596734,
"updated": "2026-09-05T17:53:11"
},
{
"id": "151",
"name": "野外山地场景与桥梁架设装备",
"created": 1788316127,
"updated": "2026-09-02T11:02:50"
},
{
"id": "94",
"name": "桥梁架设装备整机模型",
"created": 1788074748,
"updated": "2026-09-02T11:02:50"
},
{
"id": "48",
"name": "UAT 挖掘机装备模型",
"created": 1786413935,
"updated": "2026-09-01T09:48:57"
}
]
},
{
"title": "无效排序参数回退到创建时间倒序",
"status": "PASS",
"type": "MODEL",
"sort": "CREATED_DESC",
"rows": [
{
"id": "157",
"name": "实测模型-狐狸动画-已验证-20260905",
"created": 1788600787,
"updated": "2026-09-05T17:53:56"
},
{
"id": "156",
"name": "实测模型-牛奶卡车-已验证-20260905",
"created": 1788600271,
"updated": "2026-09-05T17:47:29"
},
{
"id": "155",
"name": "测试",
"created": 1788596734,
"updated": "2026-09-05T17:53:11"
},
{
"id": "151",
"name": "野外山地场景与桥梁架设装备",
"created": 1788316127,
"updated": "2026-09-02T11:02:50"
},
{
"id": "94",
"name": "桥梁架设装备整机模型",
"created": 1788074748,
"updated": "2026-09-02T11:02:50"
},
{
"id": "48",
"name": "UAT 挖掘机装备模型",
"created": 1786413935,
"updated": "2026-09-01T09:48:57"
}
]
},
{
"title": "场景列表保留现有默认排序",
"status": "PASS",
"type": "SCENE",
"sort": "UPDATED_DESC",
"rows": [
{
"id": "131",
"name": "真实装备车库整机进场点检场景",
"created": 1788139208,
"updated": "2026-09-02T14:48:53"
},
{
"id": "152",
"name": "真实野外山地桥梁架设训练场",
"created": 1788316127,
"updated": "2026-09-02T11:02:50"
},
{
"id": "49",
"name": "UAT 综合检验车间",
"created": 1786413935,
"updated": "2026-08-31T10:42:12"
},
{
"id": "95",
"name": "桥梁架设装备架桥作业训练场",
"created": 1788074748,
"updated": "2026-08-31T10:34:44"
},
{
"id": "50",
"name": "UAT 室外对抗训练场",
"created": 1786413935,
"updated": "2026-08-11T02:05:35"
}
],
"screenshot": "screenshots/03-scene-default.png"
},
{
"title": "局域网前端访问正常",
"status": "PASS",
"httpStatus": 200
}
]
}

+ 1
- 0
reports/model-created-sort-20260905/index.html View File

@@ -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{max-width:1160px;margin:32px auto;padding:0 20px;font:16px/1.7 "Microsoft YaHei",sans-serif;color:#23483d;background:#f1f6f4}article{background:white;padding:20px;margin:16px 0;border-radius:8px}img{width:100%;height:auto}small{color:#576e65}table{border-collapse:collapse;width:100%}td,th{padding:8px;border:1px solid #cddfd7;text-align:left}</style><h1>模型列表创建时间排序验证</h1><p>7/7 项通过 · 2026-09-05 · Microsoft Edge</p><p>本次仅查询和筛选,未创建或修改模型数据。</p><p><a href="evidence.json">完整数据证据</a> · <a href="cover-plan.md">文件存储与截图封面方案(待确认)</a></p><p>pnpm run build:通过类型检查、Vite构建与产物门禁。既有Three资源URL和chunk体积警告未阻断构建。</p><article><h2>默认按创建时间倒序,列表与服务端分页一致:PASS</h2><p>实际请求:MODEL / CREATED_DESC</p><a href="screenshots/01-created-default.png"><img src="screenshots/01-created-default.png" alt="默认按创建时间倒序,列表与服务端分页一致"></a></article><article><h2>手动选择排序仍可用,URL保留显式选择:PASS</h2><p>实际请求:MODEL / UPDATED_DESC</p></article><article><h2>重置恢复创建时间倒序:PASS</h2><p>实际请求:MODEL / CREATED_DESC</p><a href="screenshots/02-created-reset.png"><img src="screenshots/02-created-reset.png" alt="重置恢复创建时间倒序"></a></article><article><h2>整页刷新保持默认创建时间倒序:PASS</h2><p>实际请求:MODEL / CREATED_DESC</p></article><article><h2>无效排序参数回退到创建时间倒序:PASS</h2><p>实际请求:MODEL / CREATED_DESC</p></article><article><h2>场景列表保留现有默认排序:PASS</h2><p>实际请求:SCENE / UPDATED_DESC</p><a href="screenshots/03-scene-default.png"><img src="screenshots/03-scene-default.png" alt="场景列表保留现有默认排序"></a></article><article><h2>局域网前端访问正常:PASS</h2></article></html>

BIN
View File


BIN
View File


BIN
View File


+ 24797
- 0
reports/model-live-20260905/evidence.json
File diff suppressed because it is too large
View File


+ 55
- 0
reports/model-live-20260905/index.html View File

@@ -0,0 +1,55 @@
<!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>*{box-sizing:border-box}body{margin:0;background:#eef3f2;color:#223c36;font:15px/1.7 "Microsoft YaHei","Segoe UI",sans-serif}header{padding:36px max(24px,calc((100vw - 1160px)/2));background:#153e34;color:#f5fffb}header h1{font-size:28px;line-height:1.45;margin:4px 0 12px}.eyebrow{font-size:13px;color:#b8dacf;margin:0}.links{display:flex;gap:24px}.links a{color:#d1f4e7}main{max-width:1200px;margin:24px auto;padding:0 20px 40px}section{background:white;border:1px solid #dce7e2;border-radius:12px;padding:28px;margin:20px 0}h2{font-size:22px;margin:0 0 18px}h3{font-size:17px;margin:18px 0 10px}p{margin:10px 0}a{color:#12674f}table{border-collapse:collapse;width:100%;font-size:14px}th,td{border:1px solid #dce7e2;text-align:left;vertical-align:top;padding:10px 12px;overflow-wrap:anywhere}th{background:#edf5f1}tbody tr:nth-child(even){background:#fafcfb}.table-wrap{overflow:auto}.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:13px;white-space:nowrap}.pass{color:#07513b;background:#dff5e8}.fail{color:#972328;background:#fee6e8}.unknown{color:#7c4b0d;background:#fff0d5}.info{color:#25566d;background:#e4f1f8}.notice{background:#fff5e1;border-left:4px solid #b5791a;padding:12px 16px;color:#6d4815}.muted{color:#576e65}.stages{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:20px 0}.stages article{border:1px solid #dce7e2;border-radius:8px;padding:0 16px 12px}.records{margin:8px 0;font-size:13px}.records>div{padding:6px 0;border-bottom:1px solid #edf2ef}.records dt{color:#5b7168}.records dd{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}.gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}figure{margin:0;border:1px solid #dce7e2;border-radius:8px;overflow:hidden;background:#f2f6f4}figure img{width:100%;height:auto;display:block}figcaption{padding:10px 14px;font-size:13px;overflow-wrap:anywhere}.issue{border-left:3px solid #b5791a;padding:0 14px;margin:16px 0}footer{font-size:13px;color:#576e65;margin:28px 0}@media(max-width:760px){header{padding:24px 20px}header h1{font-size:23px}main{padding:0 12px}section{padding:18px}.stages,.gallery{grid-template-columns:1fr}}@media print{body{background:white}header{background:white;color:#153e34;padding:0}header .eyebrow,header .links{display:none}main{max-width:none;padding:0}section{padding:18px 0;border:0;border-radius:0}.gallery{grid-template-columns:1fr}figure{break-inside:avoid}h2,h3{break-after:avoid}.stages{display:block}}</style></head><body><header><p class="eyebrow">开发环境 · 真实模型制作</p><h1>真实模型制作实测报告 · 2026-09-05</h1><p>已记录 2/2 个模型项目、23 项步骤:23 项通过,0 项失败,0 项其他或未记录结论。</p><p class="links"><a href="report.md">Markdown 报告</a><a href="evidence.json">原始证据</a></p></header><main>
<section><h2>环境与身份</h2>
<div class="table-wrap"><table><thead><tr><th>项目</th><th>记录</th></tr></thead><tbody><tr><td>测试日期</td><td>2026-09-05</td></tr><tr><td>页面地址</td><td>http://127.0.0.1:6180</td></tr><tr><td>浏览器</td><td>Microsoft Edge</td></tr><tr><td>账号角色</td><td>teacher</td></tr><tr><td>账号显示名称</td><td>系统管理员</td></tr></tbody></table></div>
</section>
<p class="notice">统计仅来自明确记录的步骤结论;缺失字段、仅有数据或仅有截图均不视为通过。</p>
<section id="model-1"><h2>模型 1:实测模型-牛奶卡车-已验证-20260905</h2>
<div class="table-wrap"><table><thead><tr><th>项目</th><th>记录</th></tr></thead><tbody><tr><td>项目名称</td><td>实测模型-牛奶卡车-已验证-20260905</td></tr><tr><td>项目编码</td><td>MODEL-3FA24629D59E4509</td></tr><tr><td>项目 ID</td><td>156</td></tr><tr><td>来源素材</td><td>08-CesiumMilkTruck.glb</td></tr><tr><td>测试节点</td><td>Cesium_Milk_Truck</td></tr><tr><td>动画</td><td>Wheels;编号 0;数量 1</td></tr><tr><td>原生动画片段</td><td>1 段:Wheels</td></tr></tbody></table></div>
<p>轻量 GLB 实测:车辆多部件、节点编辑、车轮动画、保存重开和发布。保留供后续模型/场景测试使用。</p>
<h3>操作步骤</h3>
<div class="table-wrap"><table><thead><tr><th>序号</th><th>步骤</th><th>结论</th><th>证据摘要</th></tr></thead><tbody><tr><td>1</td><td>UI创建项目</td><td><span class="badge pass">通过</span></td><td>HTTP 状态:200</td></tr><tr><td>2</td><td>文件服务地址修复后重新上传</td><td><span class="badge pass">通过</span></td><td>HTTP 状态:200;详情:首次上传失败保留于问题证据;修复地址后已实际上传成功并校验文件哈希;initialHttpStatus:503</td></tr><tr><td>3</td><td>GLB上传、两阶段绑定、SHA256与活跃模型核验</td><td><span class="badge pass">通过</span></td><td>未记录</td></tr><tr><td>4</td><td>组合节点 X 位置与原生动画选择保存</td><td><span class="badge pass">通过</span></td><td>详情 / x:0.35;详情 / 动画编号:Wheels;详情 / versionBefore:5;详情 / versionAfter:6</td></tr><tr><td>5</td><td>整页刷新后模型、节点、材质、动画选择恢复</td><td><span class="badge pass">通过</span></td><td>未记录</td></tr><tr><td>6</td><td>原生动画实播:播放时间与轮子/骨骼姿态确实变化</td><td><span class="badge pass">通过</span></td><td>未记录</td></tr><tr><td>7</td><td>车身子网格材质粗糙度修改、保存、刷新恢复</td><td><span class="badge pass">通过</span></td><td>详情 / node:Cesium_Milk_Truck_1;详情 / roughness:0.3;详情 / persistedAfterReload:true</td></tr><tr><td>8</td><td>发布到场景资源库并核对服务端发布版本</td><td><span class="badge pass">通过</span></td><td>HTTP 状态:200</td></tr><tr><td>9</td><td>全新浏览器上下文从服务端载入已发布模型</td><td><span class="badge pass">通过</span></td><td>详情 / source:08-CesiumMilkTruck.glb;详情 / apiMode:true;详情 / 动画编号:Wheels</td></tr><tr><td>10</td><td>已发布模型改名保存为 V2 草稿,项目及版本更新时间刷新</td><td><span class="badge pass">通过</span></td><td>详情 / 状态:PASS;详情 / 变更前 / project:2026-09-05T17:24:34;详情 / 变更前 / 版本:2026-09-05T17:24:34;详情 / 变更后 / project:2026-09-05T17:47:05;详情 / 变更后 / 版本:2026-09-05T17:47:05;详情 / draftVersionId:200;详情 / versionNo:2</td></tr><tr><td>11</td><td>V2 重新发布成功,发布版本及更新时间正确</td><td><span class="badge pass">通过</span></td><td>详情 / versionNo:2;详情 / versionId:200;详情 / updateTime:2026-09-05T17:47:29</td></tr><tr><td>12</td><td>V2 全新浏览器上下文重开,模型文件、动画选择、发布版本均正确</td><td><span class="badge pass">通过</span></td><td>详情 / source:08-CesiumMilkTruck.glb;详情 / 动画编号:Wheels;详情 / versionNo:2</td></tr></tbody></table></div>
<div class="stages">
<article><h3>导入校验</h3><p class="muted">通过</p><dl class="records"><div><dt>SHA-256</dt><dd>09371b34608116de5842d23abe260bf11acf3e1554daf334a647eb566eee5c49</dd></div><div><dt>文件字节数</dt><dd>369980</dd></div><div><dt>变更记录 1 / 状态</dt><dd>200</dd></div><div><dt>变更记录 1 / registeredTicket</dt><dd>true</dd></div><div><dt>变更记录 1 / sourceName</dt><dd>bridge-erector-vehicle.glb</dd></div><div><dt>变更记录 1 / assetCode</dt><dd>MODEL_BRIDGE-ERECTOR-VEHICLE</dd></div><div><dt>变更记录 2 / 状态</dt><dd>200</dd></div><div><dt>变更记录 2 / registeredTicket</dt><dd>false</dd></div><div><dt>变更记录 2 / sourceName</dt><dd>08-CesiumMilkTruck.glb</dd></div><div><dt>变更记录 2 / assetCode</dt><dd>MODEL_MTO6WI6K_08-CESIUMMILKTRUCK</dd></div><div><dt>服务端 / projectId</dt><dd>156</dd></div><div><dt>服务端 / 编码</dt><dd>MODEL-3FA24629D59E4509</dd></div><div><dt>服务端 / 名称</dt><dd>实测模型-牛奶卡车-20260905</dd></div><div><dt>服务端 / 状态</dt><dd>DRAFT</dd></div><div><dt>服务端 / 版本</dt><dd>4</dd></div><div><dt>服务端 / currentVersionId</dt><dd>198</dd></div><div><dt>服务端 / versionNo</dt><dd>1</dd></div><div><dt>服务端 / checksum</dt><dd>30ee74fe8f8763e6342f1bbc36607a06db0f6f79061471ab7334e61028029d01</dd></div><div><dt>服务端 / schema</dt><dd>digital-twin-editor-project</dd></div><div><dt>服务端 / modelResource / kind</dt><dd>imported</dd></div><div><dt>服务端 / modelResource / ID</dt><dd>imported-model_mto6wi6k_08-cesiummilktruck</dd></div><div><dt>服务端 / modelResource / 名称</dt><dd>08-CesiumMilkTruck</dd></div><div><dt>服务端 / modelResource / assetCode</dt><dd>MODEL_MTO6WI6K_08-CESIUMMILKTRUCK</dd></div><div><dt>服务端 / modelResource / 编码</dt><dd>MODEL_MTO6WI6K_08-CESIUMMILKTRUCK</dd></div><div><dt>服务端 / modelResource / storageUri</dt><dd>fsvc://unreal-tran/model/2026/09/05/a75de903d95b0590307632d7bed44111.glb</dd></div><div><dt>服务端 / modelResource / sourceName</dt><dd>08-CesiumMilkTruck.glb</dd></div><div><dt>服务端 / modelResource / sizeBytes</dt><dd>369980</dd></div><div><dt>服务端 / modelResource / lastModified</dt><dd>0</dd></div><div><dt>服务端 / modelResource / mimeType</dt><dd>model/gltf-binary</dd></div><div><dt>服务端 / modelResource / vertices</dt><dd>4823</dd></div><div><dt>服务端 / modelResource / triangles</dt><dd>3624</dd></div><div><dt>服务端 / modelResource / 节点数</dt><dd>10</dd></div><div><dt>服务端 / modelResource / 网格数</dt><dd>5</dd></div><div><dt>服务端 / modelResource / 材质数</dt><dd>5</dd></div><div><dt>服务端 / modelResource / 纹理数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / 动画</dt><dd>1</dd></div><div><dt>服务端 / modelResource / animationDurationSeconds</dt><dd>1.25</dd></div><div><dt>服务端 / modelResource / modelMetadata / nodeCount</dt><dd>10</dd></div><div><dt>服务端 / modelResource / modelMetadata / 网格数</dt><dd>5</dd></div><div><dt>服务端 / modelResource / modelMetadata / materialCount</dt><dd>5</dd></div><div><dt>服务端 / modelResource / modelMetadata / textureCount</dt><dd>1</dd></div><div><dt>服务端 / modelResource / modelMetadata / 动画</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>服务端 / modelResource / modelMetadata / animationDurationSeconds</dt><dd>1.25</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div></dl></article>
<article><h3>编辑与保存</h3><p class="muted">通过</p><dl class="records"><div><dt>服务端 / projectId</dt><dd>156</dd></div><div><dt>服务端 / 编码</dt><dd>MODEL-3FA24629D59E4509</dd></div><div><dt>服务端 / 名称</dt><dd>实测模型-牛奶卡车-20260905</dd></div><div><dt>服务端 / 状态</dt><dd>DRAFT</dd></div><div><dt>服务端 / 版本</dt><dd>6</dd></div><div><dt>服务端 / currentVersionId</dt><dd>198</dd></div><div><dt>服务端 / versionNo</dt><dd>1</dd></div><div><dt>服务端 / checksum</dt><dd>495aaedac19971b06dec73ad43bdebfcfb63d0604fabaf48f460c7bc1cdcc229</dd></div><div><dt>服务端 / schema</dt><dd>digital-twin-editor-project</dd></div><div><dt>服务端 / modelResource / kind</dt><dd>imported</dd></div><div><dt>服务端 / modelResource / ID</dt><dd>imported-model_mto6wi6k_08-cesiummilktruck</dd></div><div><dt>服务端 / modelResource / 名称</dt><dd>08-CesiumMilkTruck</dd></div><div><dt>服务端 / modelResource / assetCode</dt><dd>MODEL_MTO6WI6K_08-CESIUMMILKTRUCK</dd></div><div><dt>服务端 / modelResource / 编码</dt><dd>MODEL_MTO6WI6K_08-CESIUMMILKTRUCK</dd></div><div><dt>服务端 / modelResource / storageUri</dt><dd>fsvc://unreal-tran/model/2026/09/05/a75de903d95b0590307632d7bed44111.glb</dd></div><div><dt>服务端 / modelResource / sourceName</dt><dd>08-CesiumMilkTruck.glb</dd></div><div><dt>服务端 / modelResource / sizeBytes</dt><dd>369980</dd></div><div><dt>服务端 / modelResource / lastModified</dt><dd>0</dd></div><div><dt>服务端 / modelResource / mimeType</dt><dd>model/gltf-binary</dd></div><div><dt>服务端 / modelResource / vertices</dt><dd>4823</dd></div><div><dt>服务端 / modelResource / triangles</dt><dd>3624</dd></div><div><dt>服务端 / modelResource / 节点数</dt><dd>10</dd></div><div><dt>服务端 / modelResource / 网格数</dt><dd>5</dd></div><div><dt>服务端 / modelResource / 材质数</dt><dd>5</dd></div><div><dt>服务端 / modelResource / 纹理数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / 动画</dt><dd>1</dd></div><div><dt>服务端 / modelResource / animationDurationSeconds</dt><dd>1.25</dd></div><div><dt>服务端 / modelResource / modelMetadata / nodeCount</dt><dd>10</dd></div><div><dt>服务端 / modelResource / modelMetadata / 网格数</dt><dd>5</dd></div><div><dt>服务端 / modelResource / modelMetadata / materialCount</dt><dd>5</dd></div><div><dt>服务端 / modelResource / modelMetadata / textureCount</dt><dd>1</dd></div><div><dt>服务端 / modelResource / modelMetadata / 动画</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>服务端 / modelResource / modelMetadata / animationDurationSeconds</dt><dd>1.25</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div></dl></article>
<article><h3>保存后重开</h3><p class="muted">通过</p><dl class="records"><div><dt>服务端 / projectId</dt><dd>156</dd></div><div><dt>服务端 / 编码</dt><dd>MODEL-3FA24629D59E4509</dd></div><div><dt>服务端 / 名称</dt><dd>实测模型-牛奶卡车-20260905</dd></div><div><dt>服务端 / 状态</dt><dd>DRAFT</dd></div><div><dt>服务端 / 版本</dt><dd>6</dd></div><div><dt>服务端 / currentVersionId</dt><dd>198</dd></div><div><dt>服务端 / versionNo</dt><dd>1</dd></div><div><dt>服务端 / checksum</dt><dd>495aaedac19971b06dec73ad43bdebfcfb63d0604fabaf48f460c7bc1cdcc229</dd></div><div><dt>服务端 / schema</dt><dd>digital-twin-editor-project</dd></div><div><dt>服务端 / modelResource / kind</dt><dd>imported</dd></div><div><dt>服务端 / modelResource / ID</dt><dd>imported-model_mto6wi6k_08-cesiummilktruck</dd></div><div><dt>服务端 / modelResource / 名称</dt><dd>08-CesiumMilkTruck</dd></div><div><dt>服务端 / modelResource / assetCode</dt><dd>MODEL_MTO6WI6K_08-CESIUMMILKTRUCK</dd></div><div><dt>服务端 / modelResource / 编码</dt><dd>MODEL_MTO6WI6K_08-CESIUMMILKTRUCK</dd></div><div><dt>服务端 / modelResource / storageUri</dt><dd>fsvc://unreal-tran/model/2026/09/05/a75de903d95b0590307632d7bed44111.glb</dd></div><div><dt>服务端 / modelResource / sourceName</dt><dd>08-CesiumMilkTruck.glb</dd></div><div><dt>服务端 / modelResource / sizeBytes</dt><dd>369980</dd></div><div><dt>服务端 / modelResource / lastModified</dt><dd>0</dd></div><div><dt>服务端 / modelResource / mimeType</dt><dd>model/gltf-binary</dd></div><div><dt>服务端 / modelResource / vertices</dt><dd>4823</dd></div><div><dt>服务端 / modelResource / triangles</dt><dd>3624</dd></div><div><dt>服务端 / modelResource / 节点数</dt><dd>10</dd></div><div><dt>服务端 / modelResource / 网格数</dt><dd>5</dd></div><div><dt>服务端 / modelResource / 材质数</dt><dd>5</dd></div><div><dt>服务端 / modelResource / 纹理数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / 动画</dt><dd>1</dd></div><div><dt>服务端 / modelResource / animationDurationSeconds</dt><dd>1.25</dd></div><div><dt>服务端 / modelResource / modelMetadata / nodeCount</dt><dd>10</dd></div><div><dt>服务端 / modelResource / modelMetadata / 网格数</dt><dd>5</dd></div><div><dt>服务端 / modelResource / modelMetadata / materialCount</dt><dd>5</dd></div><div><dt>服务端 / modelResource / modelMetadata / textureCount</dt><dd>1</dd></div><div><dt>服务端 / modelResource / modelMetadata / 动画</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>服务端 / modelResource / modelMetadata / animationDurationSeconds</dt><dd>1.25</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div></dl></article>
<article><h3>动画检查</h3><p class="muted">通过</p><dl class="records"><div><dt>动画编号</dt><dd>Wheels</dd></div><div><dt>变更前 / sourceName</dt><dd>08-CesiumMilkTruck.glb</dd></div><div><dt>变更前 / assetCode</dt><dd>MODEL_MTO6WI6K_08-CESIUMMILKTRUCK</dd></div><div><dt>变更前 / 节点数</dt><dd>10</dd></div><div><dt>变更前 / 网格数</dt><dd>5</dd></div><div><dt>变更前 / 材质数</dt><dd>5</dd></div><div><dt>变更前 / 动画数 1 / 名称</dt><dd>Wheels</dd></div><div><dt>变更前 / 动画数 1 / duration</dt><dd>1.25</dd></div><div><dt>变更前 / 动画数 1 / tracks</dt><dd>2</dd></div><div><dt>变更前 / clipIndex</dt><dd>0</dd></div><div><dt>变更前 / 正在播放</dt><dd>true</dd></div><div><dt>变更前 / 时间</dt><dd>0.017299999952316285</dd></div><div><dt>变更前 / mesh / 名称</dt><dd>Cesium_Milk_Truck</dd></div><div><dt>变更前 / mesh / editorId</dt><dd>model-imported-model_mto6wi6k_08-cesiummilktruck-2</dd></div><div><dt>变更前 / mesh / 位置</dt><dd>0.35、0、0</dd></div><div><dt>变更前 / mesh / 缩放</dt><dd>1、1、1</dd></div><div><dt>变更前 / mesh / isSkinnedMesh</dt><dd>false</dd></div><div><dt>变更前 / mesh / bones</dt><dd>0</dd></div><div><dt>变更前 / moving 1 / 名称</dt><dd>Wheels</dd></div><div><dt>变更前 / moving 1 / quaternion</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>变更前 / moving 2 / 名称</dt><dd>Wheels001</dd></div><div><dt>变更前 / moving 2 / quaternion</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>变更前 / onlyActivePivot</dt><dd>true</dd></div><div><dt>变更后 / sourceName</dt><dd>08-CesiumMilkTruck.glb</dd></div><div><dt>变更后 / assetCode</dt><dd>MODEL_MTO6WI6K_08-CESIUMMILKTRUCK</dd></div><div><dt>变更后 / 节点数</dt><dd>10</dd></div><div><dt>变更后 / 网格数</dt><dd>5</dd></div><div><dt>变更后 / 材质数</dt><dd>5</dd></div><div><dt>变更后 / 动画数 1 / 名称</dt><dd>Wheels</dd></div><div><dt>变更后 / 动画数 1 / duration</dt><dd>1.25</dd></div><div><dt>变更后 / 动画数 1 / tracks</dt><dd>2</dd></div><div><dt>变更后 / clipIndex</dt><dd>0</dd></div><div><dt>变更后 / 正在播放</dt><dd>true</dd></div><div><dt>变更后 / 时间</dt><dd>0.11699999999999999</dd></div><div><dt>变更后 / mesh / 名称</dt><dd>Cesium_Milk_Truck</dd></div><div><dt>变更后 / mesh / editorId</dt><dd>model-imported-model_mto6wi6k_08-cesiummilktruck-2</dd></div><div><dt>变更后 / mesh / 位置</dt><dd>0.35、0、0</dd></div><div><dt>变更后 / mesh / 缩放</dt><dd>1、1、1</dd></div><div><dt>变更后 / mesh / isSkinnedMesh</dt><dd>false</dd></div><div><dt>变更后 / mesh / bones</dt><dd>0</dd></div><div><dt>变更后 / moving 1 / 名称</dt><dd>Wheels</dd></div><div><dt>变更后 / moving 1 / quaternion</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>变更后 / moving 2 / 名称</dt><dd>Wheels001</dd></div><div><dt>变更后 / moving 2 / quaternion</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>变更后 / onlyActivePivot</dt><dd>true</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div></dl></article>
<article><h3>发布状态</h3><p class="muted">已发布</p><dl class="records"><div><dt>projectId</dt><dd>156</dd></div><div><dt>编码</dt><dd>MODEL-3FA24629D59E4509</dd></div><div><dt>名称</dt><dd>实测模型-牛奶卡车-已验证-20260905</dd></div><div><dt>状态</dt><dd>PUBLISHED</dd></div><div><dt>版本</dt><dd>12</dd></div><div><dt>publishedVersionId</dt><dd>200</dd></div><div><dt>currentVersionId</dt><dd>200</dd></div><div><dt>versionNo</dt><dd>2</dd></div><div><dt>checksum</dt><dd>1c9b1233dda661537c9bd4014208f1eb2851d392e5cd9b4260e53570b0820751</dd></div><div><dt>schema</dt><dd>digital-twin-editor-project</dd></div><div><dt>modelResource / kind</dt><dd>imported</dd></div><div><dt>modelResource / ID</dt><dd>imported-model_mto6wi6k_08-cesiummilktruck</dd></div><div><dt>modelResource / 名称</dt><dd>08-CesiumMilkTruck</dd></div><div><dt>modelResource / assetCode</dt><dd>MODEL_MTO6WI6K_08-CESIUMMILKTRUCK</dd></div><div><dt>modelResource / 编码</dt><dd>MODEL_MTO6WI6K_08-CESIUMMILKTRUCK</dd></div><div><dt>modelResource / storageUri</dt><dd>fsvc://unreal-tran/model/2026/09/05/a75de903d95b0590307632d7bed44111.glb</dd></div><div><dt>modelResource / sourceName</dt><dd>08-CesiumMilkTruck.glb</dd></div><div><dt>modelResource / sizeBytes</dt><dd>369980</dd></div><div><dt>modelResource / lastModified</dt><dd>0</dd></div><div><dt>modelResource / mimeType</dt><dd>model/gltf-binary</dd></div><div><dt>modelResource / vertices</dt><dd>4823</dd></div><div><dt>modelResource / triangles</dt><dd>3624</dd></div><div><dt>modelResource / 节点数</dt><dd>10</dd></div><div><dt>modelResource / 网格数</dt><dd>5</dd></div><div><dt>modelResource / 材质数</dt><dd>5</dd></div><div><dt>modelResource / 纹理数</dt><dd>1</dd></div><div><dt>modelResource / 动画</dt><dd>1</dd></div><div><dt>modelResource / animationDurationSeconds</dt><dd>1.25</dd></div><div><dt>modelResource / modelMetadata / nodeCount</dt><dd>10</dd></div><div><dt>modelResource / modelMetadata / 网格数</dt><dd>5</dd></div><div><dt>modelResource / modelMetadata / materialCount</dt><dd>5</dd></div><div><dt>modelResource / modelMetadata / textureCount</dt><dd>1</dd></div><div><dt>modelResource / modelMetadata / 动画</dt><dd>1 项,完整内容见 evidence.json</dd></div><div><dt>modelResource / modelMetadata / animationDurationSeconds</dt><dd>1.25</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div></dl></article>
</div>
<h3>现场截图</h3>
<div class="gallery"><figure><a href="screenshots/03b-upload-error-log.png"><img loading="lazy" src="screenshots/03b-upload-error-log.png" alt="文件服务地址修复后重新上传"></a><figcaption>文件服务地址修复后重新上传</figcaption></figure><figure><a href="screenshots/1-01-imported.png"><img loading="lazy" src="screenshots/1-01-imported.png" alt="GLB上传、两阶段绑定、SHA256与活跃模型核验"></a><figcaption>GLB上传、两阶段绑定、SHA256与活跃模型核验</figcaption></figure><figure><a href="screenshots/1-02-edited.png"><img loading="lazy" src="screenshots/1-02-edited.png" alt="组合节点 X 位置与原生动画选择保存"></a><figcaption>组合节点 X 位置与原生动画选择保存</figcaption></figure><figure><a href="screenshots/1-03-reopened.png"><img loading="lazy" src="screenshots/1-03-reopened.png" alt="整页刷新后模型、节点、材质、动画选择恢复"></a><figcaption>整页刷新后模型、节点、材质、动画选择恢复</figcaption></figure><figure><a href="screenshots/1-04-animation.png"><img loading="lazy" src="screenshots/1-04-animation.png" alt="原生动画实播:播放时间与轮子/骨骼姿态确实变化"></a><figcaption>原生动画实播:播放时间与轮子/骨骼姿态确实变化</figcaption></figure><figure><a href="screenshots/1-04b-material-reopened.png"><img loading="lazy" src="screenshots/1-04b-material-reopened.png" alt="车身子网格材质粗糙度修改、保存、刷新恢复"></a><figcaption>车身子网格材质粗糙度修改、保存、刷新恢复</figcaption></figure><figure><a href="screenshots/1-05-published.png"><img loading="lazy" src="screenshots/1-05-published.png" alt="发布到场景资源库并核对服务端发布版本"></a><figcaption>发布到场景资源库并核对服务端发布版本</figcaption></figure><figure><a href="screenshots/1-06-fresh-session.png"><img loading="lazy" src="screenshots/1-06-fresh-session.png" alt="全新浏览器上下文从服务端载入已发布模型"></a><figcaption>全新浏览器上下文从服务端载入已发布模型</figcaption></figure><figure><a href="screenshots/1-07-update-time.png"><img loading="lazy" src="screenshots/1-07-update-time.png" alt="已发布模型改名保存为 V2 草稿,项目及版本更新时间刷新"></a><figcaption>已发布模型改名保存为 V2 草稿,项目及版本更新时间刷新</figcaption></figure><figure><a href="screenshots/1-08-published-v2.png"><img loading="lazy" src="screenshots/1-08-published-v2.png" alt="V2 重新发布成功,发布版本及更新时间正确"></a><figcaption>V2 重新发布成功,发布版本及更新时间正确</figcaption></figure><figure><a href="screenshots/1-09-fresh-session-v2.png"><img loading="lazy" src="screenshots/1-09-fresh-session-v2.png" alt="V2 全新浏览器上下文重开,模型文件、动画选择、发布版本均正确"></a><figcaption>V2 全新浏览器上下文重开,模型文件、动画选择、发布版本均正确</figcaption></figure></div>
</section>
<section id="model-2"><h2>模型 2:实测模型-狐狸动画-已验证-20260905</h2>
<div class="table-wrap"><table><thead><tr><th>项目</th><th>记录</th></tr></thead><tbody><tr><td>项目名称</td><td>实测模型-狐狸动画-已验证-20260905</td></tr><tr><td>项目编码</td><td>MODEL-70875AA932B94685</td></tr><tr><td>项目 ID</td><td>157</td></tr><tr><td>来源素材</td><td>09-Fox.glb</td></tr><tr><td>测试节点</td><td>fox</td></tr><tr><td>动画</td><td>Run;编号 2;数量 3</td></tr><tr><td>原生动画片段</td><td>3 段:Survey、Walk、Run</td></tr></tbody></table></div>
<p>轻量 GLB 实测:骨骼蒙皮、Survey/Walk/Run 动画、节点编辑、保存重开和发布。保留供后续模型/场景测试使用。</p>
<h3>操作步骤</h3>
<div class="table-wrap"><table><thead><tr><th>序号</th><th>步骤</th><th>结论</th><th>证据摘要</th></tr></thead><tbody><tr><td>1</td><td>UI创建项目</td><td><span class="badge pass">通过</span></td><td>HTTP 状态:200</td></tr><tr><td>2</td><td>GLB上传、两阶段绑定、SHA256与活跃模型核验</td><td><span class="badge pass">通过</span></td><td>未记录</td></tr><tr><td>3</td><td>节点 X 位置、材质粗糙度、原生动画选择保存</td><td><span class="badge pass">通过</span></td><td>详情 / x:0.25;详情 / roughness:0.3;详情 / 动画编号:Run;详情 / versionBefore:4;详情 / versionAfter:5</td></tr><tr><td>4</td><td>整页刷新后模型、节点、材质、动画选择恢复</td><td><span class="badge pass">通过</span></td><td>未记录</td></tr><tr><td>5</td><td>原生动画实播:播放时间与轮子/骨骼姿态确实变化</td><td><span class="badge pass">通过</span></td><td>未记录</td></tr><tr><td>6</td><td>Survey、Walk、Run 三个原生片段逐一实播</td><td><span class="badge pass">通过</span></td><td>详情 1 / 名称:Survey;详情 1 / 正在播放:true;详情 1 / timeAdvanced:true;详情 1 / bonesChanged:true;详情 2 / 名称:Walk;详情 2 / 正在播放:true;详情 2 / timeAdvanced:true;详情 2 / bonesChanged:true;详情 3 / 名称:Run;详情 3 / 正在播放:true;详情 3 / timeAdvanced:true;详情 3 / bonesChanged:true</td></tr><tr><td>7</td><td>发布到场景资源库并核对服务端发布版本</td><td><span class="badge pass">通过</span></td><td>HTTP 状态:200</td></tr><tr><td>8</td><td>全新浏览器上下文从服务端载入已发布模型</td><td><span class="badge pass">通过</span></td><td>详情 / source:09-Fox.glb;详情 / apiMode:true;详情 / 动画编号:Run</td></tr><tr><td>9</td><td>已发布模型改名保存为 V2 草稿,项目及版本更新时间刷新</td><td><span class="badge pass">通过</span></td><td>详情 / 状态:PASS;详情 / 变更前 / project:2026-09-05T17:33:10;详情 / 变更前 / 版本:2026-09-05T17:33:10;详情 / 变更后 / project:2026-09-05T17:47:39;详情 / 变更后 / 版本:2026-09-05T17:47:39;详情 / draftVersionId:201;详情 / versionNo:2</td></tr><tr><td>10</td><td>V2 重新发布成功,发布版本及更新时间正确</td><td><span class="badge pass">通过</span></td><td>详情 / versionNo:2;详情 / versionId:201;详情 / updateTime:2026-09-05T17:48:04</td></tr><tr><td>11</td><td>V2 全新浏览器上下文重开,模型文件、动画选择、发布版本均正确</td><td><span class="badge pass">通过</span></td><td>详情 / source:09-Fox.glb;详情 / 动画编号:Run;详情 / versionNo:2</td></tr></tbody></table></div>
<div class="stages">
<article><h3>导入校验</h3><p class="muted">通过</p><dl class="records"><div><dt>SHA-256</dt><dd>d97044e701822bac5a62696459b27d7b375aada5de8574ed4362edbba94771f7</dd></div><div><dt>文件字节数</dt><dd>162852</dd></div><div><dt>变更记录 1 / 状态</dt><dd>200</dd></div><div><dt>变更记录 1 / registeredTicket</dt><dd>true</dd></div><div><dt>变更记录 1 / sourceName</dt><dd>bridge-erector-vehicle.glb</dd></div><div><dt>变更记录 1 / assetCode</dt><dd>MODEL_BRIDGE-ERECTOR-VEHICLE</dd></div><div><dt>变更记录 2 / 状态</dt><dd>200</dd></div><div><dt>变更记录 2 / registeredTicket</dt><dd>false</dd></div><div><dt>变更记录 2 / sourceName</dt><dd>09-Fox.glb</dd></div><div><dt>变更记录 2 / assetCode</dt><dd>MODEL_MTO6V5W6_09-FOX</dd></div><div><dt>服务端 / projectId</dt><dd>157</dd></div><div><dt>服务端 / 编码</dt><dd>MODEL-70875AA932B94685</dd></div><div><dt>服务端 / 名称</dt><dd>实测模型-狐狸动画-20260905</dd></div><div><dt>服务端 / 状态</dt><dd>DRAFT</dd></div><div><dt>服务端 / 版本</dt><dd>4</dd></div><div><dt>服务端 / currentVersionId</dt><dd>199</dd></div><div><dt>服务端 / versionNo</dt><dd>1</dd></div><div><dt>服务端 / checksum</dt><dd>3bfa16814a65c4576b1627e92032c61d2d986145a19d1b4027e570209e306e0d</dd></div><div><dt>服务端 / schema</dt><dd>digital-twin-editor-project</dd></div><div><dt>服务端 / modelResource / kind</dt><dd>imported</dd></div><div><dt>服务端 / modelResource / ID</dt><dd>imported-model_mto6v5w6_09-fox</dd></div><div><dt>服务端 / modelResource / 名称</dt><dd>09-Fox</dd></div><div><dt>服务端 / modelResource / assetCode</dt><dd>MODEL_MTO6V5W6_09-FOX</dd></div><div><dt>服务端 / modelResource / 编码</dt><dd>MODEL_MTO6V5W6_09-FOX</dd></div><div><dt>服务端 / modelResource / storageUri</dt><dd>fsvc://unreal-tran/model/2026/09/05/3fc69ae0d435f869afca1fcd943486d1.glb</dd></div><div><dt>服务端 / modelResource / sourceName</dt><dd>09-Fox.glb</dd></div><div><dt>服务端 / modelResource / sizeBytes</dt><dd>162852</dd></div><div><dt>服务端 / modelResource / lastModified</dt><dd>0</dd></div><div><dt>服务端 / modelResource / mimeType</dt><dd>model/gltf-binary</dd></div><div><dt>服务端 / modelResource / vertices</dt><dd>1728</dd></div><div><dt>服务端 / modelResource / triangles</dt><dd>576</dd></div><div><dt>服务端 / modelResource / 节点数</dt><dd>27</dd></div><div><dt>服务端 / modelResource / 网格数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / 材质数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / 纹理数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / 动画</dt><dd>3</dd></div><div><dt>服务端 / modelResource / animationDurationSeconds</dt><dd>3.4166667461395264</dd></div><div><dt>服务端 / modelResource / modelMetadata / nodeCount</dt><dd>27</dd></div><div><dt>服务端 / modelResource / modelMetadata / 网格数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / modelMetadata / materialCount</dt><dd>1</dd></div><div><dt>服务端 / modelResource / modelMetadata / textureCount</dt><dd>1</dd></div><div><dt>服务端 / modelResource / modelMetadata / 动画</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>服务端 / modelResource / modelMetadata / animationDurationSeconds</dt><dd>3.4166667461395264</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div></dl></article>
<article><h3>编辑与保存</h3><p class="muted">通过</p><dl class="records"><div><dt>服务端 / projectId</dt><dd>157</dd></div><div><dt>服务端 / 编码</dt><dd>MODEL-70875AA932B94685</dd></div><div><dt>服务端 / 名称</dt><dd>实测模型-狐狸动画-20260905</dd></div><div><dt>服务端 / 状态</dt><dd>DRAFT</dd></div><div><dt>服务端 / 版本</dt><dd>5</dd></div><div><dt>服务端 / currentVersionId</dt><dd>199</dd></div><div><dt>服务端 / versionNo</dt><dd>1</dd></div><div><dt>服务端 / checksum</dt><dd>041667300586ffea8aaba2ba29004cf4ca50dc0f01351215a664f64fa82ab5b9</dd></div><div><dt>服务端 / schema</dt><dd>digital-twin-editor-project</dd></div><div><dt>服务端 / modelResource / kind</dt><dd>imported</dd></div><div><dt>服务端 / modelResource / ID</dt><dd>imported-model_mto6v5w6_09-fox</dd></div><div><dt>服务端 / modelResource / 名称</dt><dd>09-Fox</dd></div><div><dt>服务端 / modelResource / assetCode</dt><dd>MODEL_MTO6V5W6_09-FOX</dd></div><div><dt>服务端 / modelResource / 编码</dt><dd>MODEL_MTO6V5W6_09-FOX</dd></div><div><dt>服务端 / modelResource / storageUri</dt><dd>fsvc://unreal-tran/model/2026/09/05/3fc69ae0d435f869afca1fcd943486d1.glb</dd></div><div><dt>服务端 / modelResource / sourceName</dt><dd>09-Fox.glb</dd></div><div><dt>服务端 / modelResource / sizeBytes</dt><dd>162852</dd></div><div><dt>服务端 / modelResource / lastModified</dt><dd>0</dd></div><div><dt>服务端 / modelResource / mimeType</dt><dd>model/gltf-binary</dd></div><div><dt>服务端 / modelResource / vertices</dt><dd>1728</dd></div><div><dt>服务端 / modelResource / triangles</dt><dd>576</dd></div><div><dt>服务端 / modelResource / 节点数</dt><dd>27</dd></div><div><dt>服务端 / modelResource / 网格数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / 材质数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / 纹理数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / 动画</dt><dd>3</dd></div><div><dt>服务端 / modelResource / animationDurationSeconds</dt><dd>3.4166667461395264</dd></div><div><dt>服务端 / modelResource / modelMetadata / nodeCount</dt><dd>27</dd></div><div><dt>服务端 / modelResource / modelMetadata / 网格数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / modelMetadata / materialCount</dt><dd>1</dd></div><div><dt>服务端 / modelResource / modelMetadata / textureCount</dt><dd>1</dd></div><div><dt>服务端 / modelResource / modelMetadata / 动画</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>服务端 / modelResource / modelMetadata / animationDurationSeconds</dt><dd>3.4166667461395264</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div></dl></article>
<article><h3>保存后重开</h3><p class="muted">通过</p><dl class="records"><div><dt>服务端 / projectId</dt><dd>157</dd></div><div><dt>服务端 / 编码</dt><dd>MODEL-70875AA932B94685</dd></div><div><dt>服务端 / 名称</dt><dd>实测模型-狐狸动画-20260905</dd></div><div><dt>服务端 / 状态</dt><dd>DRAFT</dd></div><div><dt>服务端 / 版本</dt><dd>5</dd></div><div><dt>服务端 / currentVersionId</dt><dd>199</dd></div><div><dt>服务端 / versionNo</dt><dd>1</dd></div><div><dt>服务端 / checksum</dt><dd>041667300586ffea8aaba2ba29004cf4ca50dc0f01351215a664f64fa82ab5b9</dd></div><div><dt>服务端 / schema</dt><dd>digital-twin-editor-project</dd></div><div><dt>服务端 / modelResource / kind</dt><dd>imported</dd></div><div><dt>服务端 / modelResource / ID</dt><dd>imported-model_mto6v5w6_09-fox</dd></div><div><dt>服务端 / modelResource / 名称</dt><dd>09-Fox</dd></div><div><dt>服务端 / modelResource / assetCode</dt><dd>MODEL_MTO6V5W6_09-FOX</dd></div><div><dt>服务端 / modelResource / 编码</dt><dd>MODEL_MTO6V5W6_09-FOX</dd></div><div><dt>服务端 / modelResource / storageUri</dt><dd>fsvc://unreal-tran/model/2026/09/05/3fc69ae0d435f869afca1fcd943486d1.glb</dd></div><div><dt>服务端 / modelResource / sourceName</dt><dd>09-Fox.glb</dd></div><div><dt>服务端 / modelResource / sizeBytes</dt><dd>162852</dd></div><div><dt>服务端 / modelResource / lastModified</dt><dd>0</dd></div><div><dt>服务端 / modelResource / mimeType</dt><dd>model/gltf-binary</dd></div><div><dt>服务端 / modelResource / vertices</dt><dd>1728</dd></div><div><dt>服务端 / modelResource / triangles</dt><dd>576</dd></div><div><dt>服务端 / modelResource / 节点数</dt><dd>27</dd></div><div><dt>服务端 / modelResource / 网格数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / 材质数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / 纹理数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / 动画</dt><dd>3</dd></div><div><dt>服务端 / modelResource / animationDurationSeconds</dt><dd>3.4166667461395264</dd></div><div><dt>服务端 / modelResource / modelMetadata / nodeCount</dt><dd>27</dd></div><div><dt>服务端 / modelResource / modelMetadata / 网格数</dt><dd>1</dd></div><div><dt>服务端 / modelResource / modelMetadata / materialCount</dt><dd>1</dd></div><div><dt>服务端 / modelResource / modelMetadata / textureCount</dt><dd>1</dd></div><div><dt>服务端 / modelResource / modelMetadata / 动画</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>服务端 / modelResource / modelMetadata / animationDurationSeconds</dt><dd>3.4166667461395264</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div></dl></article>
<article><h3>动画检查</h3><p class="muted">通过</p><dl class="records"><div><dt>动画编号</dt><dd>Run</dd></div><div><dt>变更前 / sourceName</dt><dd>09-Fox.glb</dd></div><div><dt>变更前 / assetCode</dt><dd>MODEL_MTO6V5W6_09-FOX</dd></div><div><dt>变更前 / 节点数</dt><dd>27</dd></div><div><dt>变更前 / 网格数</dt><dd>1</dd></div><div><dt>变更前 / 材质数</dt><dd>1</dd></div><div><dt>变更前 / 动画数 1 / 名称</dt><dd>Survey</dd></div><div><dt>变更前 / 动画数 1 / duration</dt><dd>3.4166667461395264</dd></div><div><dt>变更前 / 动画数 1 / tracks</dt><dd>21</dd></div><div><dt>变更前 / 动画数 2 / 名称</dt><dd>Walk</dd></div><div><dt>变更前 / 动画数 2 / duration</dt><dd>0.7083333134651184</dd></div><div><dt>变更前 / 动画数 2 / tracks</dt><dd>21</dd></div><div><dt>变更前 / 动画数 3 / 名称</dt><dd>Run</dd></div><div><dt>变更前 / 动画数 3 / duration</dt><dd>1.1583333015441895</dd></div><div><dt>变更前 / 动画数 3 / tracks</dt><dd>21</dd></div><div><dt>变更前 / clipIndex</dt><dd>2</dd></div><div><dt>变更前 / 正在播放</dt><dd>true</dd></div><div><dt>变更前 / 时间</dt><dd>0.017099999904632568</dd></div><div><dt>变更前 / mesh / 名称</dt><dd>fox</dd></div><div><dt>变更前 / mesh / editorId</dt><dd>model-imported-model_mto6v5w6_09-fox-26</dd></div><div><dt>变更前 / mesh / 位置</dt><dd>0.25、0、0</dd></div><div><dt>变更前 / mesh / 缩放</dt><dd>1、1、1</dd></div><div><dt>变更前 / mesh / isSkinnedMesh</dt><dd>true</dd></div><div><dt>变更前 / mesh / bones</dt><dd>24</dd></div><div><dt>变更前 / moving 1 / 名称</dt><dd>_rootJoint</dd></div><div><dt>变更前 / moving 1 / quaternion</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>变更前 / moving 2 / 名称</dt><dd>b_Root_00</dd></div><div><dt>变更前 / moving 2 / quaternion</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>变更前 / moving 3 / 名称</dt><dd>b_Hip_01</dd></div><div><dt>变更前 / moving 3 / quaternion</dt><dd>已记录,完整内容见 evidence.json</dd></div><div><dt>变更前 / moving</dt><dd>共 24 项,完整内容见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div></dl></article>
<article><h3>发布状态</h3><p class="muted">已发布</p><dl class="records"><div><dt>projectId</dt><dd>157</dd></div><div><dt>编码</dt><dd>MODEL-70875AA932B94685</dd></div><div><dt>名称</dt><dd>实测模型-狐狸动画-已验证-20260905</dd></div><div><dt>状态</dt><dd>PUBLISHED</dd></div><div><dt>版本</dt><dd>10</dd></div><div><dt>publishedVersionId</dt><dd>201</dd></div><div><dt>currentVersionId</dt><dd>201</dd></div><div><dt>versionNo</dt><dd>2</dd></div><div><dt>checksum</dt><dd>fbb1c9ad102a20aa3f0add103167a165d2454a9935f470df2e415731ff0a160d</dd></div><div><dt>schema</dt><dd>digital-twin-editor-project</dd></div><div><dt>modelResource / kind</dt><dd>imported</dd></div><div><dt>modelResource / ID</dt><dd>imported-model_mto6v5w6_09-fox</dd></div><div><dt>modelResource / 名称</dt><dd>09-Fox</dd></div><div><dt>modelResource / assetCode</dt><dd>MODEL_MTO6V5W6_09-FOX</dd></div><div><dt>modelResource / 编码</dt><dd>MODEL_MTO6V5W6_09-FOX</dd></div><div><dt>modelResource / storageUri</dt><dd>fsvc://unreal-tran/model/2026/09/05/3fc69ae0d435f869afca1fcd943486d1.glb</dd></div><div><dt>modelResource / sourceName</dt><dd>09-Fox.glb</dd></div><div><dt>modelResource / sizeBytes</dt><dd>162852</dd></div><div><dt>modelResource / lastModified</dt><dd>0</dd></div><div><dt>modelResource / mimeType</dt><dd>model/gltf-binary</dd></div><div><dt>modelResource / vertices</dt><dd>1728</dd></div><div><dt>modelResource / triangles</dt><dd>576</dd></div><div><dt>modelResource / 节点数</dt><dd>27</dd></div><div><dt>modelResource / 网格数</dt><dd>1</dd></div><div><dt>modelResource / 材质数</dt><dd>1</dd></div><div><dt>modelResource / 纹理数</dt><dd>1</dd></div><div><dt>modelResource / 动画</dt><dd>3</dd></div><div><dt>modelResource / animationDurationSeconds</dt><dd>3.4166667461395264</dd></div><div><dt>modelResource / modelMetadata / nodeCount</dt><dd>27</dd></div><div><dt>modelResource / modelMetadata / 网格数</dt><dd>1</dd></div><div><dt>modelResource / modelMetadata / materialCount</dt><dd>1</dd></div><div><dt>modelResource / modelMetadata / textureCount</dt><dd>1</dd></div><div><dt>modelResource / modelMetadata / 动画</dt><dd>3 项,完整内容见 evidence.json</dd></div><div><dt>modelResource / modelMetadata / animationDurationSeconds</dt><dd>3.4166667461395264</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div><div><dt>更多记录</dt><dd>见 evidence.json</dd></div></dl></article>
</div>
<h3>现场截图</h3>
<div class="gallery"><figure><a href="screenshots/2-01-imported.png"><img loading="lazy" src="screenshots/2-01-imported.png" alt="GLB上传、两阶段绑定、SHA256与活跃模型核验"></a><figcaption>GLB上传、两阶段绑定、SHA256与活跃模型核验</figcaption></figure><figure><a href="screenshots/2-02-edited.png"><img loading="lazy" src="screenshots/2-02-edited.png" alt="节点 X 位置、材质粗糙度、原生动画选择保存"></a><figcaption>节点 X 位置、材质粗糙度、原生动画选择保存</figcaption></figure><figure><a href="screenshots/2-03-reopened.png"><img loading="lazy" src="screenshots/2-03-reopened.png" alt="整页刷新后模型、节点、材质、动画选择恢复"></a><figcaption>整页刷新后模型、节点、材质、动画选择恢复</figcaption></figure><figure><a href="screenshots/2-04-animation.png"><img loading="lazy" src="screenshots/2-04-animation.png" alt="原生动画实播:播放时间与轮子/骨骼姿态确实变化"></a><figcaption>原生动画实播:播放时间与轮子/骨骼姿态确实变化</figcaption></figure><figure><a href="screenshots/2-05-published.png"><img loading="lazy" src="screenshots/2-05-published.png" alt="发布到场景资源库并核对服务端发布版本"></a><figcaption>发布到场景资源库并核对服务端发布版本</figcaption></figure><figure><a href="screenshots/2-06-fresh-session.png"><img loading="lazy" src="screenshots/2-06-fresh-session.png" alt="全新浏览器上下文从服务端载入已发布模型"></a><figcaption>全新浏览器上下文从服务端载入已发布模型</figcaption></figure><figure><a href="screenshots/2-07-update-time.png"><img loading="lazy" src="screenshots/2-07-update-time.png" alt="已发布模型改名保存为 V2 草稿,项目及版本更新时间刷新"></a><figcaption>已发布模型改名保存为 V2 草稿,项目及版本更新时间刷新</figcaption></figure><figure><a href="screenshots/2-08-published-v2.png"><img loading="lazy" src="screenshots/2-08-published-v2.png" alt="V2 重新发布成功,发布版本及更新时间正确"></a><figcaption>V2 重新发布成功,发布版本及更新时间正确</figcaption></figure><figure><a href="screenshots/2-09-fresh-session-v2.png"><img loading="lazy" src="screenshots/2-09-fresh-session-v2.png" alt="V2 全新浏览器上下文重开,模型文件、动画选择、发布版本均正确"></a><figcaption>V2 全新浏览器上下文重开,模型文件、动画选择、发布版本均正确</figcaption></figure></div>
</section>
<section><h2>补充验证</h2>
<article><h3>最终项目列表</h3><dl class="records"><div><dt>类别</dt><dd>final-list</dd></div><div><dt>状态</dt><dd>PASS</dd></div><div><dt>列表核验结果</dt><dd>MODEL-70875AA932B94685 · 整机装备实测模型-狐狸动画-20260905节点 27 · 材质 1 · 动画 1系统管理员更新:2026/09/05 17:33已发布进入编辑更多、MODEL-3FA24629D59E4509 · 整机装备实测模型-牛奶卡车-20260905节点 10 · 材质 5 · 动画 1系统管理员更新:2026/09/05 17:24已发布进入编辑更多</dd></div></dl></article>
<article><h3>构建检查</h3><dl class="records"><div><dt>类别</dt><dd>build</dd></div><div><dt>状态</dt><dd>PASS</dd></div><div><dt>执行命令</dt><dd>pnpm run build</dd></div><div><dt>详情</dt><dd>类型检查、Vite 构建、public/dist hash、API 桥接与凭据门禁均通过</dd></div></dl></article>
<article><h3>实测范围</h3><dl class="records"><div><dt>类别</dt><dd>scope</dd></div><div><dt>详情</dt><dd>通过实际页面操作创建/导入/编辑/播放/保存/发布;只读 API 与全新浏览器会话交叉核验。未创建场景,未执行训练业务蓝图,未改表结构或账号权限。</dd></div></dl></article>
<article><h3>最终项目列表</h3><dl class="records"><div><dt>类别</dt><dd>final-list</dd></div><div><dt>状态</dt><dd>PASS</dd></div><div><dt>详情</dt><dd>最终 V2 列表:狐狸 17:48 排在卡车 17:47 之前,最近更新排序与服务端保存、发布时间一致。</dd></div><div><dt>列表核验结果</dt><dd>MODEL-70875AA932B94685 · 整机装备实测模型-狐狸动画-已验证-20260905节点 27 · 材质 1 · 动画 1系统管理员更新:2026/09/05 17:48已发布进入编辑更多、MODEL-3FA24629D59E4509 · 整机装备实测模型-牛奶卡车-已验证-20260905节点 10 · 材质 5 · 动画 1系统管理员更新:2026/09/05 17:47已发布进入编辑更多</dd></div></dl></article>
<article><h3>backend-regression</h3><dl class="records"><div><dt>类别</dt><dd>backend-regression</dd></div><div><dt>状态</dt><dd>PASS</dd></div><div><dt>执行命令</dt><dd>mvn -B -ntp -pl ut_tran -Dtest=ContentProjectServiceTest test</dd></div><div><dt>详情</dt><dd>Java 17;70项用例通过,0失败/错误/跳过。新增用例先复现旧时间写回,再验证修复。</dd></div></dl></article>
<article><h3>build-final</h3><dl class="records"><div><dt>类别</dt><dd>build-final</dd></div><div><dt>状态</dt><dd>PASS</dd></div><div><dt>详情</dt><dd>最终 pnpm run build 通过;stage生成camera-fit统一LF后,10个payload的public/dist哈希、API桥接与凭据门禁通过,git diff --check通过。</dd></div><div><dt>执行命令</dt><dd>pnpm run build</dd></div></dl></article>
<article><h3>cover-display</h3><dl class="records"><div><dt>类别</dt><dd>cover-display</dd></div><div><dt>详情</dt><dd>两条测试数据未设置自定义封面,列表沿用现有默认装备缩略图;编辑器内已分别验证真实卡车与狐狸。若需要导入后自动生成模型封面,属于另行确认的功能增强。</dd></div></dl></article>
</section>
<section><h2>补充截图</h2>
<div class="gallery"><figure><a href="screenshots/00-two-published-models.png"><img loading="lazy" src="screenshots/00-two-published-models.png" alt="补充证据"></a><figcaption>补充证据</figcaption></figure><figure><a href="screenshots/00b-two-published-models-v2.png"><img loading="lazy" src="screenshots/00b-two-published-models-v2.png" alt="补充证据"></a><figcaption>补充证据</figcaption></figure><figure><a href="screenshots/01-editor-initialization.png"><img loading="lazy" src="screenshots/01-editor-initialization.png" alt="模型编辑器生成包陈旧,页面实际运行本地模式"></a><figcaption>模型编辑器生成包陈旧,页面实际运行本地模式</figcaption></figure></div>
</section>
<section><h2>遗留数据与保留说明</h2>
<div class="table-wrap"><table><thead><tr><th>项目</th><th>ID</th><th>编码</th><th>保留/清理结论</th></tr></thead><tbody><tr><td>实测模型-牛奶卡车-已验证-20260905</td><td>156</td><td>MODEL-3FA24629D59E4509</td><td>保留</td></tr><tr><td>实测模型-狐狸动画-已验证-20260905</td><td>157</td><td>MODEL-70875AA932B94685</td><td>保留</td></tr></tbody></table></div>
<dl class="records"><div><dt>记录</dt><dd>保留本次创建的两条模型工程与上传资产,供用户继续测试。</dd></div></dl>
<p class="muted">本报告生成过程不修改项目数据。数据保留与清理结果以证据记录为准。</p></section>
<section><h2>问题与待确认项</h2>
<article class="issue"><dl class="records"><div><dt>标题</dt><dd>模型编辑器生成包陈旧,页面实际运行本地模式</dd></div><div><dt>状态</dt><dd>已修复并验证</dd></div><div><dt>impact</dt><dd>新建记录存在,但旧编辑器未读取项目 ID,模型编辑和自动保存未写入内容制作 API。</dd></div><div><dt>fix</dt><dd>从现有接口版源码重建并暂存编辑器包,增加 build 产物 API 桥接检查。</dd></div></dl></article><article class="issue"><dl class="records"><div><dt>标题</dt><dd>GLB 上传被文件服务 503 阻断</dd></div><div><dt>状态</dt><dd>已修复并验证</dd></div><div><dt>impact</dt><dd>上传未成功,原工程模型与资产仍保留。</dd></div><div><dt>requestId</dt><dd>69785305-558a-4a43-99bf-655b4adda274</dd></div><div><dt>cause</dt><dd>用户级 UT_FILE_SERVICE_BASE_URL / CONTENT_BASE_URL 残留关闭的 127.0.0.1:17180 代理地址</dd></div><div><dt>fix</dt><dd>恢复到现有配置的远端真实文件服务,保留密钥,仅重启 ut-tran;实际 GLB 上传已返回200</dd></div></dl></article><article class="issue"><dl class="records"><div><dt>标题</dt><dd>列表“动画”统计口径需确认</dd></div><div><dt>状态</dt><dd>待确认,未改功能</dd></div><div><dt>impact</dt><dd>狐狸实际有 Survey、Walk、Run 3 个原生片段,全部实播通过;列表显示动画1,统计的是1条自编时间轴轨道。</dd></div><div><dt>suggestion</dt><dd>可将列表文案明确为“动画轨道”,或增加“原生动画3段 / 自编轨道1条”的分项展示,待用户确认后实施。</dd></div></dl></article><article class="issue"><dl class="records"><div><dt>标题</dt><dd>保存和发布没有刷新项目/版本更新时间</dd></div><div><dt>状态</dt><dd>已修复并验证</dd></div><div><dt>cause</dt><dd>读取实体携带旧 updateTime,updateById 再次写回旧值,数据库自动更新时间未生效。</dd></div><div><dt>fix</dt><dd>项目、版本两个统一更新方法显式设置当前秒精度时间;无表结构变更。</dd></div><div><dt>verification</dt><dd>两模型改名生成 V2 草稿并重新发布;API时间、页面时间、最近更新排序和全新浏览器重开均通过。</dd></div></dl></article>
</section>
<footer>共 23 张可用截图。完整结构化记录见 evidence.json;模型对象明细未展开。</footer></main></body></html>

+ 657
- 0
reports/model-live-20260905/report.md View File

@@ -0,0 +1,657 @@
# 真实模型制作实测报告 · 2026-09-05

已记录 2/2 个模型项目、23 项步骤:23 项通过,0 项失败,0 项其他或未记录结论。

统计仅来自明确记录的步骤结论;缺失字段、仅有数据或仅有截图均不视为通过。

## 环境与身份

| 项目 | 记录 |
| --- | --- |
| 测试日期 | 2026-09-05 |
| 页面地址 | http://127.0.0.1:6180 |
| 浏览器 | Microsoft Edge |
| 账号角色 | teacher |
| 账号显示名称 | 系统管理员 |

## 模型 1:实测模型-牛奶卡车-已验证-20260905

| 项目 | 记录 |
| --- | --- |
| 项目名称 | 实测模型-牛奶卡车-已验证-20260905 |
| 项目编码 | MODEL-3FA24629D59E4509 |
| 项目 ID | 156 |
| 来源素材 | 08-CesiumMilkTruck.glb |
| 测试节点 | Cesium\_Milk\_Truck |
| 动画 | Wheels;编号 0;数量 1 |
| 原生动画片段 | 1 段:Wheels |

轻量 GLB 实测:车辆多部件、节点编辑、车轮动画、保存重开和发布。保留供后续模型/场景测试使用。

### 操作步骤

| 序号 | 步骤 | 结论 | 证据摘要 |
| --- | --- | --- | --- |
| 1 | UI创建项目 | 通过 | HTTP 状态:200 |
| 2 | 文件服务地址修复后重新上传 | 通过 | HTTP 状态:200;详情:首次上传失败保留于问题证据;修复地址后已实际上传成功并校验文件哈希;initialHttpStatus:503 |
| 3 | GLB上传、两阶段绑定、SHA256与活跃模型核验 | 通过 | 未记录 |
| 4 | 组合节点 X 位置与原生动画选择保存 | 通过 | 详情 / x:0.35;详情 / 动画编号:Wheels;详情 / versionBefore:5;详情 / versionAfter:6 |
| 5 | 整页刷新后模型、节点、材质、动画选择恢复 | 通过 | 未记录 |
| 6 | 原生动画实播:播放时间与轮子/骨骼姿态确实变化 | 通过 | 未记录 |
| 7 | 车身子网格材质粗糙度修改、保存、刷新恢复 | 通过 | 详情 / node:Cesium\_Milk\_Truck\_1;详情 / roughness:0.3;详情 / persistedAfterReload:true |
| 8 | 发布到场景资源库并核对服务端发布版本 | 通过 | HTTP 状态:200 |
| 9 | 全新浏览器上下文从服务端载入已发布模型 | 通过 | 详情 / source:08-CesiumMilkTruck.glb;详情 / apiMode:true;详情 / 动画编号:Wheels |
| 10 | 已发布模型改名保存为 V2 草稿,项目及版本更新时间刷新 | 通过 | 详情 / 状态:PASS;详情 / 变更前 / project:2026-09-05T17:24:34;详情 / 变更前 / 版本:2026-09-05T17:24:34;详情 / 变更后 / project:2026-09-05T17:47:05;详情 / 变更后 / 版本:2026-09-05T17:47:05;详情 / draftVersionId:200;详情 / versionNo:2 |
| 11 | V2 重新发布成功,发布版本及更新时间正确 | 通过 | 详情 / versionNo:2;详情 / versionId:200;详情 / updateTime:2026-09-05T17:47:29 |
| 12 | V2 全新浏览器上下文重开,模型文件、动画选择、发布版本均正确 | 通过 | 详情 / source:08-CesiumMilkTruck.glb;详情 / 动画编号:Wheels;详情 / versionNo:2 |

### 导入校验

结论:通过

- SHA-256:09371b34608116de5842d23abe260bf11acf3e1554daf334a647eb566eee5c49
- 文件字节数:369980
- 变更记录 1 / 状态:200
- 变更记录 1 / registeredTicket:true
- 变更记录 1 / sourceName:bridge-erector-vehicle.glb
- 变更记录 1 / assetCode:MODEL\_BRIDGE-ERECTOR-VEHICLE
- 变更记录 2 / 状态:200
- 变更记录 2 / registeredTicket:false
- 变更记录 2 / sourceName:08-CesiumMilkTruck.glb
- 变更记录 2 / assetCode:MODEL\_MTO6WI6K\_08-CESIUMMILKTRUCK
- 服务端 / projectId:156
- 服务端 / 编码:MODEL-3FA24629D59E4509
- 服务端 / 名称:实测模型-牛奶卡车-20260905
- 服务端 / 状态:DRAFT
- 服务端 / 版本:4
- 服务端 / currentVersionId:198
- 服务端 / versionNo:1
- 服务端 / checksum:30ee74fe8f8763e6342f1bbc36607a06db0f6f79061471ab7334e61028029d01
- 服务端 / schema:digital-twin-editor-project
- 服务端 / modelResource / kind:imported
- 服务端 / modelResource / ID:imported-model\_mto6wi6k\_08-cesiummilktruck
- 服务端 / modelResource / 名称:08-CesiumMilkTruck
- 服务端 / modelResource / assetCode:MODEL\_MTO6WI6K\_08-CESIUMMILKTRUCK
- 服务端 / modelResource / 编码:MODEL\_MTO6WI6K\_08-CESIUMMILKTRUCK
- 服务端 / modelResource / storageUri:fsvc://unreal-tran/model/2026/09/05/a75de903d95b0590307632d7bed44111.glb
- 服务端 / modelResource / sourceName:08-CesiumMilkTruck.glb
- 服务端 / modelResource / sizeBytes:369980
- 服务端 / modelResource / lastModified:0
- 服务端 / modelResource / mimeType:model/gltf-binary
- 服务端 / modelResource / vertices:4823
- 服务端 / modelResource / triangles:3624
- 服务端 / modelResource / 节点数:10
- 服务端 / modelResource / 网格数:5
- 服务端 / modelResource / 材质数:5
- 服务端 / modelResource / 纹理数:1
- 服务端 / modelResource / 动画:1
- 服务端 / modelResource / animationDurationSeconds:1.25
- 服务端 / modelResource / modelMetadata / nodeCount:10
- 服务端 / modelResource / modelMetadata / 网格数:5
- 服务端 / modelResource / modelMetadata / materialCount:5
- 服务端 / modelResource / modelMetadata / textureCount:1
- 服务端 / modelResource / modelMetadata / 动画:已记录,完整内容见 evidence.json
- 服务端 / modelResource / modelMetadata / animationDurationSeconds:1.25
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json

### 编辑与保存

结论:通过

- 服务端 / projectId:156
- 服务端 / 编码:MODEL-3FA24629D59E4509
- 服务端 / 名称:实测模型-牛奶卡车-20260905
- 服务端 / 状态:DRAFT
- 服务端 / 版本:6
- 服务端 / currentVersionId:198
- 服务端 / versionNo:1
- 服务端 / checksum:495aaedac19971b06dec73ad43bdebfcfb63d0604fabaf48f460c7bc1cdcc229
- 服务端 / schema:digital-twin-editor-project
- 服务端 / modelResource / kind:imported
- 服务端 / modelResource / ID:imported-model\_mto6wi6k\_08-cesiummilktruck
- 服务端 / modelResource / 名称:08-CesiumMilkTruck
- 服务端 / modelResource / assetCode:MODEL\_MTO6WI6K\_08-CESIUMMILKTRUCK
- 服务端 / modelResource / 编码:MODEL\_MTO6WI6K\_08-CESIUMMILKTRUCK
- 服务端 / modelResource / storageUri:fsvc://unreal-tran/model/2026/09/05/a75de903d95b0590307632d7bed44111.glb
- 服务端 / modelResource / sourceName:08-CesiumMilkTruck.glb
- 服务端 / modelResource / sizeBytes:369980
- 服务端 / modelResource / lastModified:0
- 服务端 / modelResource / mimeType:model/gltf-binary
- 服务端 / modelResource / vertices:4823
- 服务端 / modelResource / triangles:3624
- 服务端 / modelResource / 节点数:10
- 服务端 / modelResource / 网格数:5
- 服务端 / modelResource / 材质数:5
- 服务端 / modelResource / 纹理数:1
- 服务端 / modelResource / 动画:1
- 服务端 / modelResource / animationDurationSeconds:1.25
- 服务端 / modelResource / modelMetadata / nodeCount:10
- 服务端 / modelResource / modelMetadata / 网格数:5
- 服务端 / modelResource / modelMetadata / materialCount:5
- 服务端 / modelResource / modelMetadata / textureCount:1
- 服务端 / modelResource / modelMetadata / 动画:已记录,完整内容见 evidence.json
- 服务端 / modelResource / modelMetadata / animationDurationSeconds:1.25
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json

### 保存后重开

结论:通过

- 服务端 / projectId:156
- 服务端 / 编码:MODEL-3FA24629D59E4509
- 服务端 / 名称:实测模型-牛奶卡车-20260905
- 服务端 / 状态:DRAFT
- 服务端 / 版本:6
- 服务端 / currentVersionId:198
- 服务端 / versionNo:1
- 服务端 / checksum:495aaedac19971b06dec73ad43bdebfcfb63d0604fabaf48f460c7bc1cdcc229
- 服务端 / schema:digital-twin-editor-project
- 服务端 / modelResource / kind:imported
- 服务端 / modelResource / ID:imported-model\_mto6wi6k\_08-cesiummilktruck
- 服务端 / modelResource / 名称:08-CesiumMilkTruck
- 服务端 / modelResource / assetCode:MODEL\_MTO6WI6K\_08-CESIUMMILKTRUCK
- 服务端 / modelResource / 编码:MODEL\_MTO6WI6K\_08-CESIUMMILKTRUCK
- 服务端 / modelResource / storageUri:fsvc://unreal-tran/model/2026/09/05/a75de903d95b0590307632d7bed44111.glb
- 服务端 / modelResource / sourceName:08-CesiumMilkTruck.glb
- 服务端 / modelResource / sizeBytes:369980
- 服务端 / modelResource / lastModified:0
- 服务端 / modelResource / mimeType:model/gltf-binary
- 服务端 / modelResource / vertices:4823
- 服务端 / modelResource / triangles:3624
- 服务端 / modelResource / 节点数:10
- 服务端 / modelResource / 网格数:5
- 服务端 / modelResource / 材质数:5
- 服务端 / modelResource / 纹理数:1
- 服务端 / modelResource / 动画:1
- 服务端 / modelResource / animationDurationSeconds:1.25
- 服务端 / modelResource / modelMetadata / nodeCount:10
- 服务端 / modelResource / modelMetadata / 网格数:5
- 服务端 / modelResource / modelMetadata / materialCount:5
- 服务端 / modelResource / modelMetadata / textureCount:1
- 服务端 / modelResource / modelMetadata / 动画:已记录,完整内容见 evidence.json
- 服务端 / modelResource / modelMetadata / animationDurationSeconds:1.25
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json

### 动画检查

结论:通过

- 动画编号:Wheels
- 变更前 / sourceName:08-CesiumMilkTruck.glb
- 变更前 / assetCode:MODEL\_MTO6WI6K\_08-CESIUMMILKTRUCK
- 变更前 / 节点数:10
- 变更前 / 网格数:5
- 变更前 / 材质数:5
- 变更前 / 动画数 1 / 名称:Wheels
- 变更前 / 动画数 1 / duration:1.25
- 变更前 / 动画数 1 / tracks:2
- 变更前 / clipIndex:0
- 变更前 / 正在播放:true
- 变更前 / 时间:0.017299999952316285
- 变更前 / mesh / 名称:Cesium\_Milk\_Truck
- 变更前 / mesh / editorId:model-imported-model\_mto6wi6k\_08-cesiummilktruck-2
- 变更前 / mesh / 位置:0.35、0、0
- 变更前 / mesh / 缩放:1、1、1
- 变更前 / mesh / isSkinnedMesh:false
- 变更前 / mesh / bones:0
- 变更前 / moving 1 / 名称:Wheels
- 变更前 / moving 1 / quaternion:已记录,完整内容见 evidence.json
- 变更前 / moving 2 / 名称:Wheels001
- 变更前 / moving 2 / quaternion:已记录,完整内容见 evidence.json
- 变更前 / onlyActivePivot:true
- 变更后 / sourceName:08-CesiumMilkTruck.glb
- 变更后 / assetCode:MODEL\_MTO6WI6K\_08-CESIUMMILKTRUCK
- 变更后 / 节点数:10
- 变更后 / 网格数:5
- 变更后 / 材质数:5
- 变更后 / 动画数 1 / 名称:Wheels
- 变更后 / 动画数 1 / duration:1.25
- 变更后 / 动画数 1 / tracks:2
- 变更后 / clipIndex:0
- 变更后 / 正在播放:true
- 变更后 / 时间:0.11699999999999999
- 变更后 / mesh / 名称:Cesium\_Milk\_Truck
- 变更后 / mesh / editorId:model-imported-model\_mto6wi6k\_08-cesiummilktruck-2
- 变更后 / mesh / 位置:0.35、0、0
- 变更后 / mesh / 缩放:1、1、1
- 变更后 / mesh / isSkinnedMesh:false
- 变更后 / mesh / bones:0
- 变更后 / moving 1 / 名称:Wheels
- 变更后 / moving 1 / quaternion:已记录,完整内容见 evidence.json
- 变更后 / moving 2 / 名称:Wheels001
- 变更后 / moving 2 / quaternion:已记录,完整内容见 evidence.json
- 变更后 / onlyActivePivot:true
- 更多记录:见 evidence.json

### 发布状态

结论:已发布

- projectId:156
- 编码:MODEL-3FA24629D59E4509
- 名称:实测模型-牛奶卡车-已验证-20260905
- 状态:PUBLISHED
- 版本:12
- publishedVersionId:200
- currentVersionId:200
- versionNo:2
- checksum:1c9b1233dda661537c9bd4014208f1eb2851d392e5cd9b4260e53570b0820751
- schema:digital-twin-editor-project
- modelResource / kind:imported
- modelResource / ID:imported-model\_mto6wi6k\_08-cesiummilktruck
- modelResource / 名称:08-CesiumMilkTruck
- modelResource / assetCode:MODEL\_MTO6WI6K\_08-CESIUMMILKTRUCK
- modelResource / 编码:MODEL\_MTO6WI6K\_08-CESIUMMILKTRUCK
- modelResource / storageUri:fsvc://unreal-tran/model/2026/09/05/a75de903d95b0590307632d7bed44111.glb
- modelResource / sourceName:08-CesiumMilkTruck.glb
- modelResource / sizeBytes:369980
- modelResource / lastModified:0
- modelResource / mimeType:model/gltf-binary
- modelResource / vertices:4823
- modelResource / triangles:3624
- modelResource / 节点数:10
- modelResource / 网格数:5
- modelResource / 材质数:5
- modelResource / 纹理数:1
- modelResource / 动画:1
- modelResource / animationDurationSeconds:1.25
- modelResource / modelMetadata / nodeCount:10
- modelResource / modelMetadata / 网格数:5
- modelResource / modelMetadata / materialCount:5
- modelResource / modelMetadata / textureCount:1
- modelResource / modelMetadata / 动画:1 项,完整内容见 evidence.json
- modelResource / modelMetadata / animationDurationSeconds:1.25
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json

### 现场截图

![文件服务地址修复后重新上传](screenshots/03b-upload-error-log.png)

![GLB上传、两阶段绑定、SHA256与活跃模型核验](screenshots/1-01-imported.png)

![组合节点 X 位置与原生动画选择保存](screenshots/1-02-edited.png)

![整页刷新后模型、节点、材质、动画选择恢复](screenshots/1-03-reopened.png)

![原生动画实播:播放时间与轮子/骨骼姿态确实变化](screenshots/1-04-animation.png)

![车身子网格材质粗糙度修改、保存、刷新恢复](screenshots/1-04b-material-reopened.png)

![发布到场景资源库并核对服务端发布版本](screenshots/1-05-published.png)

![全新浏览器上下文从服务端载入已发布模型](screenshots/1-06-fresh-session.png)

![已发布模型改名保存为 V2 草稿,项目及版本更新时间刷新](screenshots/1-07-update-time.png)

![V2 重新发布成功,发布版本及更新时间正确](screenshots/1-08-published-v2.png)

![V2 全新浏览器上下文重开,模型文件、动画选择、发布版本均正确](screenshots/1-09-fresh-session-v2.png)

## 模型 2:实测模型-狐狸动画-已验证-20260905

| 项目 | 记录 |
| --- | --- |
| 项目名称 | 实测模型-狐狸动画-已验证-20260905 |
| 项目编码 | MODEL-70875AA932B94685 |
| 项目 ID | 157 |
| 来源素材 | 09-Fox.glb |
| 测试节点 | fox |
| 动画 | Run;编号 2;数量 3 |
| 原生动画片段 | 3 段:Survey、Walk、Run |

轻量 GLB 实测:骨骼蒙皮、Survey/Walk/Run 动画、节点编辑、保存重开和发布。保留供后续模型/场景测试使用。

### 操作步骤

| 序号 | 步骤 | 结论 | 证据摘要 |
| --- | --- | --- | --- |
| 1 | UI创建项目 | 通过 | HTTP 状态:200 |
| 2 | GLB上传、两阶段绑定、SHA256与活跃模型核验 | 通过 | 未记录 |
| 3 | 节点 X 位置、材质粗糙度、原生动画选择保存 | 通过 | 详情 / x:0.25;详情 / roughness:0.3;详情 / 动画编号:Run;详情 / versionBefore:4;详情 / versionAfter:5 |
| 4 | 整页刷新后模型、节点、材质、动画选择恢复 | 通过 | 未记录 |
| 5 | 原生动画实播:播放时间与轮子/骨骼姿态确实变化 | 通过 | 未记录 |
| 6 | Survey、Walk、Run 三个原生片段逐一实播 | 通过 | 详情 1 / 名称:Survey;详情 1 / 正在播放:true;详情 1 / timeAdvanced:true;详情 1 / bonesChanged:true;详情 2 / 名称:Walk;详情 2 / 正在播放:true;详情 2 / timeAdvanced:true;详情 2 / bonesChanged:true;详情 3 / 名称:Run;详情 3 / 正在播放:true;详情 3 / timeAdvanced:true;详情 3 / bonesChanged:true |
| 7 | 发布到场景资源库并核对服务端发布版本 | 通过 | HTTP 状态:200 |
| 8 | 全新浏览器上下文从服务端载入已发布模型 | 通过 | 详情 / source:09-Fox.glb;详情 / apiMode:true;详情 / 动画编号:Run |
| 9 | 已发布模型改名保存为 V2 草稿,项目及版本更新时间刷新 | 通过 | 详情 / 状态:PASS;详情 / 变更前 / project:2026-09-05T17:33:10;详情 / 变更前 / 版本:2026-09-05T17:33:10;详情 / 变更后 / project:2026-09-05T17:47:39;详情 / 变更后 / 版本:2026-09-05T17:47:39;详情 / draftVersionId:201;详情 / versionNo:2 |
| 10 | V2 重新发布成功,发布版本及更新时间正确 | 通过 | 详情 / versionNo:2;详情 / versionId:201;详情 / updateTime:2026-09-05T17:48:04 |
| 11 | V2 全新浏览器上下文重开,模型文件、动画选择、发布版本均正确 | 通过 | 详情 / source:09-Fox.glb;详情 / 动画编号:Run;详情 / versionNo:2 |

### 导入校验

结论:通过

- SHA-256:d97044e701822bac5a62696459b27d7b375aada5de8574ed4362edbba94771f7
- 文件字节数:162852
- 变更记录 1 / 状态:200
- 变更记录 1 / registeredTicket:true
- 变更记录 1 / sourceName:bridge-erector-vehicle.glb
- 变更记录 1 / assetCode:MODEL\_BRIDGE-ERECTOR-VEHICLE
- 变更记录 2 / 状态:200
- 变更记录 2 / registeredTicket:false
- 变更记录 2 / sourceName:09-Fox.glb
- 变更记录 2 / assetCode:MODEL\_MTO6V5W6\_09-FOX
- 服务端 / projectId:157
- 服务端 / 编码:MODEL-70875AA932B94685
- 服务端 / 名称:实测模型-狐狸动画-20260905
- 服务端 / 状态:DRAFT
- 服务端 / 版本:4
- 服务端 / currentVersionId:199
- 服务端 / versionNo:1
- 服务端 / checksum:3bfa16814a65c4576b1627e92032c61d2d986145a19d1b4027e570209e306e0d
- 服务端 / schema:digital-twin-editor-project
- 服务端 / modelResource / kind:imported
- 服务端 / modelResource / ID:imported-model\_mto6v5w6\_09-fox
- 服务端 / modelResource / 名称:09-Fox
- 服务端 / modelResource / assetCode:MODEL\_MTO6V5W6\_09-FOX
- 服务端 / modelResource / 编码:MODEL\_MTO6V5W6\_09-FOX
- 服务端 / modelResource / storageUri:fsvc://unreal-tran/model/2026/09/05/3fc69ae0d435f869afca1fcd943486d1.glb
- 服务端 / modelResource / sourceName:09-Fox.glb
- 服务端 / modelResource / sizeBytes:162852
- 服务端 / modelResource / lastModified:0
- 服务端 / modelResource / mimeType:model/gltf-binary
- 服务端 / modelResource / vertices:1728
- 服务端 / modelResource / triangles:576
- 服务端 / modelResource / 节点数:27
- 服务端 / modelResource / 网格数:1
- 服务端 / modelResource / 材质数:1
- 服务端 / modelResource / 纹理数:1
- 服务端 / modelResource / 动画:3
- 服务端 / modelResource / animationDurationSeconds:3.4166667461395264
- 服务端 / modelResource / modelMetadata / nodeCount:27
- 服务端 / modelResource / modelMetadata / 网格数:1
- 服务端 / modelResource / modelMetadata / materialCount:1
- 服务端 / modelResource / modelMetadata / textureCount:1
- 服务端 / modelResource / modelMetadata / 动画:已记录,完整内容见 evidence.json
- 服务端 / modelResource / modelMetadata / animationDurationSeconds:3.4166667461395264
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json

### 编辑与保存

结论:通过

- 服务端 / projectId:157
- 服务端 / 编码:MODEL-70875AA932B94685
- 服务端 / 名称:实测模型-狐狸动画-20260905
- 服务端 / 状态:DRAFT
- 服务端 / 版本:5
- 服务端 / currentVersionId:199
- 服务端 / versionNo:1
- 服务端 / checksum:041667300586ffea8aaba2ba29004cf4ca50dc0f01351215a664f64fa82ab5b9
- 服务端 / schema:digital-twin-editor-project
- 服务端 / modelResource / kind:imported
- 服务端 / modelResource / ID:imported-model\_mto6v5w6\_09-fox
- 服务端 / modelResource / 名称:09-Fox
- 服务端 / modelResource / assetCode:MODEL\_MTO6V5W6\_09-FOX
- 服务端 / modelResource / 编码:MODEL\_MTO6V5W6\_09-FOX
- 服务端 / modelResource / storageUri:fsvc://unreal-tran/model/2026/09/05/3fc69ae0d435f869afca1fcd943486d1.glb
- 服务端 / modelResource / sourceName:09-Fox.glb
- 服务端 / modelResource / sizeBytes:162852
- 服务端 / modelResource / lastModified:0
- 服务端 / modelResource / mimeType:model/gltf-binary
- 服务端 / modelResource / vertices:1728
- 服务端 / modelResource / triangles:576
- 服务端 / modelResource / 节点数:27
- 服务端 / modelResource / 网格数:1
- 服务端 / modelResource / 材质数:1
- 服务端 / modelResource / 纹理数:1
- 服务端 / modelResource / 动画:3
- 服务端 / modelResource / animationDurationSeconds:3.4166667461395264
- 服务端 / modelResource / modelMetadata / nodeCount:27
- 服务端 / modelResource / modelMetadata / 网格数:1
- 服务端 / modelResource / modelMetadata / materialCount:1
- 服务端 / modelResource / modelMetadata / textureCount:1
- 服务端 / modelResource / modelMetadata / 动画:已记录,完整内容见 evidence.json
- 服务端 / modelResource / modelMetadata / animationDurationSeconds:3.4166667461395264
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json

### 保存后重开

结论:通过

- 服务端 / projectId:157
- 服务端 / 编码:MODEL-70875AA932B94685
- 服务端 / 名称:实测模型-狐狸动画-20260905
- 服务端 / 状态:DRAFT
- 服务端 / 版本:5
- 服务端 / currentVersionId:199
- 服务端 / versionNo:1
- 服务端 / checksum:041667300586ffea8aaba2ba29004cf4ca50dc0f01351215a664f64fa82ab5b9
- 服务端 / schema:digital-twin-editor-project
- 服务端 / modelResource / kind:imported
- 服务端 / modelResource / ID:imported-model\_mto6v5w6\_09-fox
- 服务端 / modelResource / 名称:09-Fox
- 服务端 / modelResource / assetCode:MODEL\_MTO6V5W6\_09-FOX
- 服务端 / modelResource / 编码:MODEL\_MTO6V5W6\_09-FOX
- 服务端 / modelResource / storageUri:fsvc://unreal-tran/model/2026/09/05/3fc69ae0d435f869afca1fcd943486d1.glb
- 服务端 / modelResource / sourceName:09-Fox.glb
- 服务端 / modelResource / sizeBytes:162852
- 服务端 / modelResource / lastModified:0
- 服务端 / modelResource / mimeType:model/gltf-binary
- 服务端 / modelResource / vertices:1728
- 服务端 / modelResource / triangles:576
- 服务端 / modelResource / 节点数:27
- 服务端 / modelResource / 网格数:1
- 服务端 / modelResource / 材质数:1
- 服务端 / modelResource / 纹理数:1
- 服务端 / modelResource / 动画:3
- 服务端 / modelResource / animationDurationSeconds:3.4166667461395264
- 服务端 / modelResource / modelMetadata / nodeCount:27
- 服务端 / modelResource / modelMetadata / 网格数:1
- 服务端 / modelResource / modelMetadata / materialCount:1
- 服务端 / modelResource / modelMetadata / textureCount:1
- 服务端 / modelResource / modelMetadata / 动画:已记录,完整内容见 evidence.json
- 服务端 / modelResource / modelMetadata / animationDurationSeconds:3.4166667461395264
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json

### 动画检查

结论:通过

- 动画编号:Run
- 变更前 / sourceName:09-Fox.glb
- 变更前 / assetCode:MODEL\_MTO6V5W6\_09-FOX
- 变更前 / 节点数:27
- 变更前 / 网格数:1
- 变更前 / 材质数:1
- 变更前 / 动画数 1 / 名称:Survey
- 变更前 / 动画数 1 / duration:3.4166667461395264
- 变更前 / 动画数 1 / tracks:21
- 变更前 / 动画数 2 / 名称:Walk
- 变更前 / 动画数 2 / duration:0.7083333134651184
- 变更前 / 动画数 2 / tracks:21
- 变更前 / 动画数 3 / 名称:Run
- 变更前 / 动画数 3 / duration:1.1583333015441895
- 变更前 / 动画数 3 / tracks:21
- 变更前 / clipIndex:2
- 变更前 / 正在播放:true
- 变更前 / 时间:0.017099999904632568
- 变更前 / mesh / 名称:fox
- 变更前 / mesh / editorId:model-imported-model\_mto6v5w6\_09-fox-26
- 变更前 / mesh / 位置:0.25、0、0
- 变更前 / mesh / 缩放:1、1、1
- 变更前 / mesh / isSkinnedMesh:true
- 变更前 / mesh / bones:24
- 变更前 / moving 1 / 名称:\_rootJoint
- 变更前 / moving 1 / quaternion:已记录,完整内容见 evidence.json
- 变更前 / moving 2 / 名称:b\_Root\_00
- 变更前 / moving 2 / quaternion:已记录,完整内容见 evidence.json
- 变更前 / moving 3 / 名称:b\_Hip\_01
- 变更前 / moving 3 / quaternion:已记录,完整内容见 evidence.json
- 变更前 / moving:共 24 项,完整内容见 evidence.json
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json

### 发布状态

结论:已发布

- projectId:157
- 编码:MODEL-70875AA932B94685
- 名称:实测模型-狐狸动画-已验证-20260905
- 状态:PUBLISHED
- 版本:10
- publishedVersionId:201
- currentVersionId:201
- versionNo:2
- checksum:fbb1c9ad102a20aa3f0add103167a165d2454a9935f470df2e415731ff0a160d
- schema:digital-twin-editor-project
- modelResource / kind:imported
- modelResource / ID:imported-model\_mto6v5w6\_09-fox
- modelResource / 名称:09-Fox
- modelResource / assetCode:MODEL\_MTO6V5W6\_09-FOX
- modelResource / 编码:MODEL\_MTO6V5W6\_09-FOX
- modelResource / storageUri:fsvc://unreal-tran/model/2026/09/05/3fc69ae0d435f869afca1fcd943486d1.glb
- modelResource / sourceName:09-Fox.glb
- modelResource / sizeBytes:162852
- modelResource / lastModified:0
- modelResource / mimeType:model/gltf-binary
- modelResource / vertices:1728
- modelResource / triangles:576
- modelResource / 节点数:27
- modelResource / 网格数:1
- modelResource / 材质数:1
- modelResource / 纹理数:1
- modelResource / 动画:3
- modelResource / animationDurationSeconds:3.4166667461395264
- modelResource / modelMetadata / nodeCount:27
- modelResource / modelMetadata / 网格数:1
- modelResource / modelMetadata / materialCount:1
- modelResource / modelMetadata / textureCount:1
- modelResource / modelMetadata / 动画:3 项,完整内容见 evidence.json
- modelResource / modelMetadata / animationDurationSeconds:3.4166667461395264
- 更多记录:见 evidence.json
- 更多记录:见 evidence.json

### 现场截图

![GLB上传、两阶段绑定、SHA256与活跃模型核验](screenshots/2-01-imported.png)

![节点 X 位置、材质粗糙度、原生动画选择保存](screenshots/2-02-edited.png)

![整页刷新后模型、节点、材质、动画选择恢复](screenshots/2-03-reopened.png)

![原生动画实播:播放时间与轮子/骨骼姿态确实变化](screenshots/2-04-animation.png)

![发布到场景资源库并核对服务端发布版本](screenshots/2-05-published.png)

![全新浏览器上下文从服务端载入已发布模型](screenshots/2-06-fresh-session.png)

![已发布模型改名保存为 V2 草稿,项目及版本更新时间刷新](screenshots/2-07-update-time.png)

![V2 重新发布成功,发布版本及更新时间正确](screenshots/2-08-published-v2.png)

![V2 全新浏览器上下文重开,模型文件、动画选择、发布版本均正确](screenshots/2-09-fresh-session-v2.png)

## 补充验证

### 最终项目列表

- 类别:final-list
- 状态:PASS
- 列表核验结果:MODEL-70875AA932B94685 · 整机装备实测模型-狐狸动画-20260905节点 27 · 材质 1 · 动画 1系统管理员更新:2026/09/05 17:33已发布进入编辑更多、MODEL-3FA24629D59E4509 · 整机装备实测模型-牛奶卡车-20260905节点 10 · 材质 5 · 动画 1系统管理员更新:2026/09/05 17:24已发布进入编辑更多

### 构建检查

- 类别:build
- 状态:PASS
- 执行命令:pnpm run build
- 详情:类型检查、Vite 构建、public/dist hash、API 桥接与凭据门禁均通过

### 实测范围

- 类别:scope
- 详情:通过实际页面操作创建/导入/编辑/播放/保存/发布;只读 API 与全新浏览器会话交叉核验。未创建场景,未执行训练业务蓝图,未改表结构或账号权限。

### 最终项目列表

- 类别:final-list
- 状态:PASS
- 详情:最终 V2 列表:狐狸 17:48 排在卡车 17:47 之前,最近更新排序与服务端保存、发布时间一致。
- 列表核验结果:MODEL-70875AA932B94685 · 整机装备实测模型-狐狸动画-已验证-20260905节点 27 · 材质 1 · 动画 1系统管理员更新:2026/09/05 17:48已发布进入编辑更多、MODEL-3FA24629D59E4509 · 整机装备实测模型-牛奶卡车-已验证-20260905节点 10 · 材质 5 · 动画 1系统管理员更新:2026/09/05 17:47已发布进入编辑更多

### backend-regression

- 类别:backend-regression
- 状态:PASS
- 执行命令:mvn -B -ntp -pl ut\_tran -Dtest=ContentProjectServiceTest test
- 详情:Java 17;70项用例通过,0失败/错误/跳过。新增用例先复现旧时间写回,再验证修复。

### build-final

- 类别:build-final
- 状态:PASS
- 详情:最终 pnpm run build 通过;stage生成camera-fit统一LF后,10个payload的public/dist哈希、API桥接与凭据门禁通过,git diff --check通过。
- 执行命令:pnpm run build

### cover-display

- 类别:cover-display
- 详情:两条测试数据未设置自定义封面,列表沿用现有默认装备缩略图;编辑器内已分别验证真实卡车与狐狸。若需要导入后自动生成模型封面,属于另行确认的功能增强。

## 补充截图

![补充证据](screenshots/00-two-published-models.png)

![补充证据](screenshots/00b-two-published-models-v2.png)

![模型编辑器生成包陈旧,页面实际运行本地模式](screenshots/01-editor-initialization.png)

## 遗留数据与保留说明

| 项目 | ID | 编码 | 保留/清理结论 |
| --- | --- | --- | --- |
| 实测模型-牛奶卡车-已验证-20260905 | 156 | MODEL-3FA24629D59E4509 | 保留 |
| 实测模型-狐狸动画-已验证-20260905 | 157 | MODEL-70875AA932B94685 | 保留 |

- 记录:保留本次创建的两条模型工程与上传资产,供用户继续测试。

本报告生成过程不修改项目数据。数据保留与清理结果以证据记录为准。

## 问题与待确认项

### 1

- 标题:模型编辑器生成包陈旧,页面实际运行本地模式
- 状态:已修复并验证
- impact:新建记录存在,但旧编辑器未读取项目 ID,模型编辑和自动保存未写入内容制作 API。
- fix:从现有接口版源码重建并暂存编辑器包,增加 build 产物 API 桥接检查。

### 2

- 标题:GLB 上传被文件服务 503 阻断
- 状态:已修复并验证
- impact:上传未成功,原工程模型与资产仍保留。
- requestId:69785305-558a-4a43-99bf-655b4adda274
- cause:用户级 UT\_FILE\_SERVICE\_BASE\_URL / CONTENT\_BASE\_URL 残留关闭的 127.0.0.1:17180 代理地址
- fix:恢复到现有配置的远端真实文件服务,保留密钥,仅重启 ut-tran;实际 GLB 上传已返回200

### 3

- 标题:列表“动画”统计口径需确认
- 状态:待确认,未改功能
- impact:狐狸实际有 Survey、Walk、Run 3 个原生片段,全部实播通过;列表显示动画1,统计的是1条自编时间轴轨道。
- suggestion:可将列表文案明确为“动画轨道”,或增加“原生动画3段 / 自编轨道1条”的分项展示,待用户确认后实施。

### 4

- 标题:保存和发布没有刷新项目/版本更新时间
- 状态:已修复并验证
- cause:读取实体携带旧 updateTime,updateById 再次写回旧值,数据库自动更新时间未生效。
- fix:项目、版本两个统一更新方法显式设置当前秒精度时间;无表结构变更。
- verification:两模型改名生成 V2 草稿并重新发布;API时间、页面时间、最近更新排序和全新浏览器重开均通过。

共 23 张可用截图。完整结构化记录见 evidence.json;模型对象明细未展开。

BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


+ 293
- 0
reports/scene-live-20260905/evidence.json View File

@@ -0,0 +1,293 @@
{
"date": "2026-09-05",
"baseURL": "http://127.0.0.1:6180",
"browser": "Microsoft Edge",
"role": "教员",
"correction": {
"title": "狐狸场景验证更正:撤回原完整通过结论",
"summary": "用户指出狐狸没有出现在场景中,经复现确认属实。原报告的 7/7 仅证明引用、保存、封面上传和发布等链路,漏掉了模型真正可见的视觉检查,因此撤回原“完整 E2E 通过”结论。下列修复后证据增加了真实画面和像素差异验证。",
"previousConclusion": "原狐狸检查把对象树存在 fox、模型依赖有效以及接口返回成功当作模型已加载可见;旧截图实际没有显示狐狸。两项旧狐狸证据标记为已更正历史记录并移出当前通过率,原文件继续保留用于追溯。",
"rootCauses": [
"计算蒙皮模型包围盒前没有同步骨骼矩阵,得到失真的尺寸。",
"适配场景尺寸时使用 setScalar 覆盖模型已有的归一化缩放;两者叠加将狐狸放大到约 1000 个场景单位,使模型超出视锥。"
],
"fixes": [
"同步蒙皮矩阵后计算真实包围盒,并保留已有归一化缩放进行相对缩放;新导入模型与已保存模型均使用修正后的尺寸处理。",
"修复场景 160 的狐狸比例和默认镜头,保存并发布为版本 208;重新生成服务器 JPEG 封面资产 2084。狐狸包围盒约为 0.966 × 2.812 × 6 个场景单位。",
"同样复核并修正场景 159,保存并发布为版本 209,重新生成服务器 JPEG 封面资产 2087。未修改模型发布版本或数据库表结构。"
],
"verification": [
"真实 UI 从资源库新增狐狸后立即可见,最长边为 6;该新增操作仅在隔离浏览器会话中测试,未保存、无写请求。",
"两个场景修改、保存、发布后,通过独立新浏览器会话重新打开,使用保存的默认镜头验证;没有未完成模型加载,也没有产生写请求。",
"关闭模型投影后,对同一画面中模型显示与隐藏的像素进行比较:320 × 180 采样中狐狸改变 4472 像素、牛奶卡车改变 7515 像素,均超过 500 像素阈值。截图中可直接辨认狐狸与卡车。",
"牛奶卡车默认镜头下部分车身被场景立柱遮挡,但模型确实可见;报告保留这一限制。",
"最终列表显示狐狸在卡车之前,虽然卡车更新于 19:32、狐狸更新于 19:31,确认排序依据仍为创建时间。"
],
"screenshots": [
{
"path": "screenshots/26-fox-visual-regression.png",
"caption": "修复后独立浏览器重新打开场景 160:狐狸模型清晰可见,并完成像素差异验证。"
}
]
},
"evidence": [
{
"title": "创建牛奶卡车检验区场景",
"status": "PASS",
"checks": [
"通过列表新建并进入真实场景编辑器",
"首次加载认证服务短暂503,点击重新加载后成功;原创建记录159复用"
],
"screenshot": "screenshots/03-truck-created-ready.png"
},
{
"title": "牛奶卡车场景保存并回读封面",
"status": "PASS",
"checks": [
"场景资源库双击引用模型 156 的已发布版本 204",
"编辑对象坐标与设备绑定后,未保存状态 2.5 秒内无上传或项目写入",
"保存草稿发出 SCENE_COVER JPEG 上传与同一次项目 PUT",
"认证访问 /cover 返回 JPEG,封面尺寸 640×408,列表可用的 coverUri 已写入"
],
"screenshots": [
"screenshots/05-truck-model-imported.png",
"screenshots/06-truck-transformed-unsaved.png",
"screenshots/07-truck-scene-cover.png",
"screenshots/08-truck-saved-with-cover.png"
]
},
{
"title": "牛奶卡车场景刷新恢复并发布",
"status": "PASS",
"checks": [
"整页刷新后模型 156/版本 204、对象名称、X=4.2 和设备编号均恢复",
"发布前校验 8 项全部通过",
"发布动作先成功保存再成功发布,最终状态为 PUBLISHED"
],
"screenshots": [
"screenshots/09-truck-reloaded-persisted.png",
"screenshots/10-truck-published.png"
],
"details": {
"total": 8,
"warnings": 0,
"text": "✓\n场景环境已配置\n真实装备车库\n✓\n模型资源加载完成\n全部受控模型已就绪\n✓\n场景用途配置有效\n1 个实训装备\n✓\n对象设备编号完整\n用于接收物理设备和消息事件\n✓\n物模型绑定完整\n用于训练步骤和动作映射\n✓\n设备编号不重复\n设备编号唯一\n✓\n物模型编号不重复\n物模型编号唯一\n✓\n受控资源引用正常\n2 个导入模型可跨刷新恢复"
}
},
{
"title": "狐狸野外场景保存并锁定已发布模型版本",
"status": "PASS",
"superseded": true,
"checks": [
"切换到真实野外训练场模板并导入模型 157",
"模型 157 当前草稿版本为 202,但场景严格依赖其已发布版本 201",
"保存草稿上传 SCENE_COVER JPEG,并在同次项目 PUT 写入 content、依赖和 coverUri",
"认证回读 /cover 返回 JPEG,尺寸 640×408"
],
"screenshots": [
"screenshots/12-fox-outdoor-template.png",
"screenshots/13-fox-model-imported.png",
"screenshots/14-fox-scene-cover.png",
"screenshots/15-fox-saved-with-cover.png"
]
},
{
"title": "场景封面上传失败保留原封面",
"status": "PASS",
"checks": [
"对 SCENE_COVER 上传实施浏览器网络故障注入",
"保存请求仍成功,场景内容继续提交",
"coverUri 保持为保存前的旧值,未产生空封面或错误替换"
],
"screenshot": "screenshots/16-fox-cover-upload-failed-content-saved.png",
"details": {
"sceneId": "160",
"oldCoverUri": "fsvc://unreal-tran/image/2026/09/05/bb1e8717cd2e7ba6ca10f16b817b1ec5.jpg",
"putRequests": 1
}
},
{
"title": "狐狸野外场景刷新恢复并发布",
"status": "PASS",
"superseded": true,
"checks": [
"整页刷新后狐狸模型依赖仍为项目 157 的已发布版本 201",
"封面上传失败后的场景正文编辑 X=-7.5 已恢复",
"8 项发布校验全部通过,发布时重新保存并成功发布",
"发布后的 coverUri 有效;保存前封面与发布后封面均由当前场景视图生成"
],
"screenshots": [
"screenshots/17-fox-reloaded-persisted.png",
"screenshots/18-fox-published.png"
],
"details": {
"validation": {
"total": 8,
"warnings": 0,
"text": "✓\n场景环境已配置\n真实野外训练场\n✓\n模型资源加载完成\n全部受控模型已就绪\n✓\n场景用途配置有效\n3 个实训装备\n✓\n对象设备编号完整\n用于接收物理设备和消息事件\n✓\n物模型绑定完整\n用于训练步骤和动作映射\n✓\n设备编号不重复\n设备编号唯一\n✓\n物模型编号不重复\n物模型编号唯一\n✓\n受控资源引用正常\n2 个导入模型可跨刷新恢复"
},
"coverBeforePublish": "fsvc://unreal-tran/image/2026/09/05/bb1e8717cd2e7ba6ca10f16b817b1ec5.jpg",
"coverAfterPublish": "fsvc://unreal-tran/image/2026/09/05/62e5d4a05316badb631e252f44ecbec3.jpg"
}
},
{
"title": "场景列表创建时间倒序与封面展示",
"status": "PASS",
"checks": [
"列表请求默认携带 sort=CREATED_DESC",
"较晚创建的狐狸野外场景显示在较早创建的牛奶卡车场景之前",
"卡车于 19:32 更新,晚于狐狸的 19:31,但狐狸仍排在卡车之前,证实没有按更新时间倒序",
"两条场景均通过受控 /cover 接口展示修复后服务器 JPEG 封面;列表中可辨认狐狸和卡车"
],
"screenshot": "screenshots/28-scene-list-fixed.png",
"details": {
"foxIndex": 0,
"truckIndex": 1,
"rows": [
{ "name": "狐狸野外场景", "projectId": "160", "addTime": 1788605625, "updateTime": "2026-09-05T19:31:30", "imageIsBlob": true },
{ "name": "牛奶卡车场景", "projectId": "159", "addTime": 1788603808, "updateTime": "2026-09-05T19:32:28", "imageIsBlob": true }
]
}
},
{
"title": "修复后狐狸新增导入、保存发布与刷新可见性",
"status": "PASS",
"checks": [
"从真实 UI 资源库新增模型 157 / 已发布版本 201,默认缩放约 0.0361637593,包围盒约 0.966 × 2.812 × 6,狐狸在视口中可见;该临时新增未保存、无写请求",
"场景 160 原狐狸修复尺寸和镜头后,保存并发布为版本 208;服务器封面资产 2084 为真实 JPEG,640 × 408,45706 字节",
"独立 Edge / Playwright 新会话按保存的镜头重新打开场景,狐狸网格已加载且可见,pendingLoads=0",
"显示/隐藏狐狸前后进行像素对比,禁用模型投影排除仅阴影可见:320 × 180 采样改变 4472 像素(7.764%),超过 500 像素阈值",
"实际检查发布截图、独立刷新截图及服务器 JPEG,均可辨认狐狸;模型 157 草稿 202 仍为 DRAFT,已发布版本仍为 201"
],
"screenshots": [
{ "path": "screenshots/25-fox-fresh-import-visible.png", "caption": "真实 UI 新增狐狸后尺寸正常且可见;临时测试未保存。" },
{ "path": "screenshots/21-fox-corrected-published.png", "caption": "场景 160 修复后保存发布为版本 208。" },
{ "path": "screenshots/22-fox-final-cover.jpg", "caption": "从服务器回读的最终狐狸 JPEG 封面,资产 2084。" },
{ "path": "screenshots/26-fox-visual-regression.png", "caption": "独立浏览器重新打开后狐狸可见;像素差异 4472。" }
],
"details": {
"sceneId": "160",
"currentVersionId": "208",
"publishedVersionId": "208",
"modelProjectId": "157",
"modelVersionId": "201",
"newImportWriteRequests": [],
"visibilityEvidence": "visibility-evidence.json",
"changedPixels": 4472,
"changedPercentage": 7.764,
"pendingLoads": 0,
"boundsSize": [0.9658796100519353, 2.811899945787403, 6]
}
},
{
"title": "修复后牛奶卡车保存发布与刷新可见性",
"status": "PASS",
"checks": [
"场景 159 保持模型 156 / 已发布版本 204,修复比例和镜头后保存并发布为版本 209",
"独立 Edge / Playwright 新会话重新打开场景,按保存镜头确认卡车 5 个网格均可见、pendingLoads=0",
"显示/隐藏卡车前后对比,禁用模型投影:320 × 180 采样改变 7515 像素(13.047%),超过 500 像素阈值",
"服务器回读最终封面资产 2087 为真实 JPEG,640 × 408,32937 字节;封面及刷新截图可辨认卡车",
"默认镜头部分车身被立柱遮挡,属于本次可见画面的已知限制"
],
"screenshots": [
{ "path": "screenshots/23-truck-corrected-published.png", "caption": "场景 159 修复后保存发布为版本 209。" },
{ "path": "screenshots/24-truck-final-cover.jpg", "caption": "从服务器回读的最终卡车 JPEG 封面,资产 2087;部分车身被立柱遮挡。" },
{ "path": "screenshots/27-truck-visual-regression.png", "caption": "独立浏览器重新打开后卡车可见;像素差异 7515。" }
],
"details": {
"sceneId": "159",
"currentVersionId": "209",
"publishedVersionId": "209",
"modelProjectId": "156",
"modelVersionId": "204",
"visibilityEvidence": "visibility-evidence.json",
"changedPixels": 7515,
"changedPercentage": 13.047,
"pendingLoads": 0,
"boundsSize": [3.4406057210321332, 3.390913184034896, 6.000000000000001]
}
}
],
"projects": [
{
"id": "159",
"name": "实测场景-牛奶卡车检验区-20260905",
"modelId": "156",
"modelVersionId": "204",
"finalStatus": "PUBLISHED",
"finalVersionId": "209",
"cover": {
"image": "screenshots/24-truck-final-cover.jpg",
"width": 640,
"height": 408,
"sizeBytes": 32937,
"assetId": "2087",
"versionId": "209",
"mimeType": "image/jpeg",
"sha256": "e103a473b74f3809365890296180c42f6c37a8005f2fd4c27fb64c437c245786",
"storageUri": "fsvc://unreal-tran/image/2026/09/05/d29810b273d75e43dff07622424f333f.jpg"
}
},
{
"id": "160",
"name": "实测场景-狐狸野外训练区-20260905",
"modelId": "157",
"modelVersionId": "201",
"finalStatus": "PUBLISHED",
"finalVersionId": "208",
"cover": {
"image": "screenshots/22-fox-final-cover.jpg",
"width": 640,
"height": 408,
"sizeBytes": 45706,
"assetId": "2084",
"versionId": "208",
"mimeType": "image/jpeg",
"sha256": "1b6e4d1eb6e65ccc15ba820a8ae4bd40ab1bee900cf91d4ec3db9af82153618d",
"storageUri": "fsvc://unreal-tran/image/2026/09/05/c176d359087fc9bea4235e43dd43a691.jpg"
}
}
],
"finalListScreenshot": "screenshots/28-scene-list-fixed.png",
"issues": [
{
"title": "狐狸存在于对象树但不在真实视口中显示",
"status": "FIXED_AND_VISUALLY_VERIFIED",
"details": "原完整 E2E 通过结论已撤回。同步蒙皮矩阵并保留归一化缩放,修复场景数据和镜头后,新增导入、保存发布、独立刷新与像素可见性均通过;详见更正区。"
}
],
"limitations": [
"牛奶卡车场景保存的默认镜头中,车身部分被场景立柱遮挡;真实网格可见且像素差异验证通过。需要观察完整车身时可旋转镜头。"
],
"followUpIssues": [
{
"scope": "independent",
"title": "fsvc 新资产上传票据绑定校验",
"details": "既存流程的 fsvc URI 分支未完整覆盖上传票据绑定校验;本次未扩大修改范围,应单独补齐并验证历史资产、版本复制与引用兼容性。"
}
],
"commands": [
{
"command": "pnpm run build",
"status": "PASS",
"details": "前端生产构建及遗留模型编辑器产物校验通过。"
},
{
"command": "node --test tools/scene-cover-api.test.mjs tools/scene-cover-capture.test.mjs tools/scene-model-placement.test.mjs",
"status": "PASS",
"details": "33 / 33 通过:封面同次 PUT、异常回退、并发队列、项目切换、截图尺寸、渲染状态恢复,以及蒙皮模型包围盒与归一化缩放回归。"
},
{
"command": "原 Microsoft Edge 场景链路 E2E(更正说明)",
"status": "SUPERSEDED",
"details": "原 7/7 只覆盖模型引用、保存、封面上传和发布等链路,未证明狐狸真实可见;完整通过结论已撤回,当前视觉验证见下列独立浏览器 E2E。"
},
{
"command": "node tools/scene-model-visibility.e2e.mjs",
"status": "PASS",
"details": "独立 Edge / Playwright 会话重开 160/208 与 159/209,使用保存镜头,显示/隐藏模型并禁用投影进行像素比较;狐狸 4472 像素、卡车 7515 像素,均超过 500 阈值。证据保存在 visibility-evidence.json,截图 26 / 27;无写请求。"
}
],
"retainedData": "保留开发环境场景 159(牛奶卡车检验区,PUBLISHED,当前及已发布版本 209,模型 156 / 已发布版本 204)和场景 160(狐狸野外训练区,PUBLISHED,当前及已发布版本 208,模型 157 / 已发布版本 201)。模型原发布版本未修改;模型 157 的当前草稿版本 202 继续保留,未发布或修改。本次无需 DDL 变更。",
"cleanup": "保留两条真实场景及历史截图用于后续测试和追溯。新增导入狐狸仅在隔离浏览器会话中测试,未保存、无写请求;测试结束后丢弃临时对象。"
}

+ 157
- 0
reports/scene-live-20260905/index.html View File

@@ -0,0 +1,157 @@
<!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>
.correction{margin:24px 0;padding:22px 24px;border:2px solid #c56b13;border-left-width:7px;border-radius:12px;background:#fff7e8}.correction h2{margin-top:0;color:#823b08}.correction h3{margin-top:18px}.correction li{white-space:pre-wrap;overflow-wrap:anywhere}
:root{color-scheme:light;--ink:#172d35;--muted:#536872;--line:#dbe5e7;--accent:#10664e}*{box-sizing:border-box}body{margin:0;background:#f2f6f6;color:var(--ink);font:15px/1.7 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1180px;margin:auto;padding:38px 28px 60px}header{padding-bottom:20px;border-bottom:1px solid var(--line)}.eyebrow{font-size:12px;letter-spacing:.12em;font-weight:700;color:var(--accent)}h1{font-size:32px;line-height:1.4;margin:7px 0 12px}h2{font-size:23px;margin:30px 0 14px}h3{font-size:17px;margin:0 0 8px}p{margin:8px 0 12px}.muted,figcaption{color:var(--muted)}a{color:var(--accent);text-underline-offset:3px}.stats{display:flex;flex-wrap:wrap;gap:12px;margin:22px 0}.stat{background:white;border:1px solid var(--line);border-radius:10px;padding:12px 18px;min-width:150px}.stat strong{display:block;font-size:25px}.covers,.checks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.card,.check,.panel{background:white;border:1px solid var(--line);border-radius:12px;padding:20px;min-width:0}.meta{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:13px}.meta dt{color:var(--muted)}.meta dd{margin:0;overflow-wrap:anywhere}figure{margin:12px 0 0}figure img{display:block;width:100%;height:auto;background:#edf1f1;border:1px solid var(--line);border-radius:7px}.cover-image img{aspect-ratio:16/9;object-fit:contain}figcaption{font-size:12px;margin-top:5px}.images{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.images.single{grid-template-columns:1fr}.badge{display:inline-block;padding:1px 9px;border-radius:12px;font-size:12px;font-weight:700;white-space:nowrap}.pass{background:#e1f3eb;color:#0c6144}.fail{background:#fde8e8;color:#ab2323}.neutral{background:#edf0f4;color:#425566}.check-title{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}details{margin-top:12px}summary{cursor:pointer;color:var(--accent);font-size:13px}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f4f7f8;border-radius:7px;padding:12px;font:12px/1.6 ui-monospace,Consolas,monospace;max-height:360px;overflow:auto}ul{padding-left:21px}.assertions{font-size:13px}.assertions li{margin:6px 0;white-space:pre-wrap;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse;font-size:13px}th,td{padding:9px 11px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}th{background:#f4f7f8}td{overflow-wrap:anywhere}.command{margin-bottom:16px}.command h3{font-family:ui-monospace,Consolas,monospace;font-size:13px;overflow-wrap:anywhere}footer{margin-top:28px;color:var(--muted);font-size:12px}@media(max-width:760px){main{padding:24px 16px 40px}h1{font-size:26px}.covers,.checks{grid-template-columns:1fr}.stat{flex:1;min-width:115px}.panel,.card,.check{padding:15px}.table-wrap{overflow:auto}}@media print{body{background:white}main{max-width:none;padding:0}.card,.check,figure{break-inside:avoid}.checks{display:block}.check{margin-bottom:14px}pre{max-height:none}a{color:inherit}}
</style></head><body><main>
<header><div class="eyebrow">SCENE LIVE · 功能已实施</div><h1>真实场景与截图封面验证报告</h1><p>场景保存截图封面及列表按创建时间倒序的功能已实施。本报告记录使用现有模型创建真实场景、保存当前三维视口封面及异常回退的实际验证结果。</p><p class="muted">2026-09-05 · http://127.0.0.1:6180 · Microsoft Edge · 教员</p><a href="report.md">Markdown 报告</a></header>
<section class="correction" aria-label="测试结论更正"><h2>狐狸场景验证更正:撤回原完整通过结论</h2><p>用户指出狐狸没有出现在场景中,经复现确认属实。原报告的 7/7 仅证明引用、保存、封面上传和发布等链路,漏掉了模型真正可见的视觉检查,因此撤回原“完整 E2E 通过”结论。下列修复后证据增加了真实画面和像素差异验证。</p><h3>原结论与漏验</h3><ul><li>原狐狸检查把对象树存在 fox、模型依赖有效以及接口返回成功当作模型已加载可见;旧截图实际没有显示狐狸。两项旧狐狸证据标记为已更正历史记录并移出当前通过率,原文件继续保留用于追溯。</li></ul><h3>根因</h3><ul><li>计算蒙皮模型包围盒前没有同步骨骼矩阵,得到失真的尺寸。</li><li>适配场景尺寸时使用 setScalar 覆盖模型已有的归一化缩放;两者叠加将狐狸放大到约 1000 个场景单位,使模型超出视锥。</li></ul><h3>修复内容</h3><ul><li>同步蒙皮矩阵后计算真实包围盒,并保留已有归一化缩放进行相对缩放;新导入模型与已保存模型均使用修正后的尺寸处理。</li><li>修复场景 160 的狐狸比例和默认镜头,保存并发布为版本 208;重新生成服务器 JPEG 封面资产 2084。狐狸包围盒约为 0.966 × 2.812 × 6 个场景单位。</li><li>同样复核并修正场景 159,保存并发布为版本 209,重新生成服务器 JPEG 封面资产 2087。未修改模型发布版本或数据库表结构。</li></ul><h3>修复后验证</h3><ul><li>真实 UI 从资源库新增狐狸后立即可见,最长边为 6;该新增操作仅在隔离浏览器会话中测试,未保存、无写请求。</li><li>两个场景修改、保存、发布后,通过独立新浏览器会话重新打开,使用保存的默认镜头验证;没有未完成模型加载,也没有产生写请求。</li><li>关闭模型投影后,对同一画面中模型显示与隐藏的像素进行比较:320 × 180 采样中狐狸改变 4472 像素、牛奶卡车改变 7515 像素,均超过 500 像素阈值。截图中可直接辨认狐狸与卡车。</li><li>牛奶卡车默认镜头下部分车身被场景立柱遮挡,但模型确实可见;报告保留这一限制。</li><li>最终列表显示狐狸在卡车之前,虽然卡车更新于 19:32、狐狸更新于 19:31,确认排序依据仍为创建时间。</li></ul><div class="images single"><figure class=""><a href="screenshots/26-fox-visual-regression.png" target="_blank" rel="noopener"><img src="screenshots/26-fox-visual-regression.png" alt="修复后独立浏览器重新打开场景 160:狐狸模型清晰可见,并完成像素差异验证。" loading="lazy"></a><figcaption>修复后独立浏览器重新打开场景 160:狐狸模型清晰可见,并完成像素差异验证。 · 点击查看原图</figcaption></figure></div><p class="muted">下方标为“历史记录(已更正)”的检查及截图保留用于追溯,不计入当前通过率,也不作为狐狸已经可见的证据。</p></section>
<div class="stats"><div class="stat"><strong>7 / 7</strong>当前证据项通过</div><div class="stat"><strong>0</strong>失败</div><div class="stat"><strong>0</strong>其他或未标注</div><div class="stat"><strong>2</strong>已更正历史记录</div><div class="stat"><strong>22</strong>图片文件已校验</div></div>
<h2>保留场景与封面证据</h2><div class="covers"><article class="card"><h3>实测场景-牛奶卡车检验区-20260905</h3><figure class="cover-image"><a href="screenshots/24-truck-final-cover.jpg" target="_blank" rel="noopener"><img src="screenshots/24-truck-final-cover.jpg" alt="场景 159 保存后封面回读证据" loading="lazy"></a><figcaption>场景 159 保存后封面回读证据 · 点击查看原图</figcaption></figure><dl class="meta"><dt>场景 / 状态</dt><dd>159 / PUBLISHED</dd><dt>当前工程版本</dt><dd>209</dd><dt>引用模型 / 版本</dt><dd>156 / 204</dd><dt>封面大小</dt><dd>32937 字节 · 640 × 408</dd></dl><details><summary>封面持久化与项目证据</summary><pre>{
&quot;id&quot;: &quot;159&quot;,
&quot;name&quot;: &quot;实测场景-牛奶卡车检验区-20260905&quot;,
&quot;modelId&quot;: &quot;156&quot;,
&quot;modelVersionId&quot;: &quot;204&quot;,
&quot;finalStatus&quot;: &quot;PUBLISHED&quot;,
&quot;finalVersionId&quot;: &quot;209&quot;,
&quot;cover&quot;: {
&quot;image&quot;: &quot;screenshots/24-truck-final-cover.jpg&quot;,
&quot;width&quot;: 640,
&quot;height&quot;: 408,
&quot;sizeBytes&quot;: 32937,
&quot;assetId&quot;: &quot;2087&quot;,
&quot;versionId&quot;: &quot;209&quot;,
&quot;mimeType&quot;: &quot;image/jpeg&quot;,
&quot;sha256&quot;: &quot;e103a473b74f3809365890296180c42f6c37a8005f2fd4c27fb64c437c245786&quot;,
&quot;storageUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/d29810b273d75e43dff07622424f333f.jpg&quot;
}
}</pre></details></article><article class="card"><h3>实测场景-狐狸野外训练区-20260905</h3><figure class="cover-image"><a href="screenshots/22-fox-final-cover.jpg" target="_blank" rel="noopener"><img src="screenshots/22-fox-final-cover.jpg" alt="场景 160 保存后封面回读证据" loading="lazy"></a><figcaption>场景 160 保存后封面回读证据 · 点击查看原图</figcaption></figure><dl class="meta"><dt>场景 / 状态</dt><dd>160 / PUBLISHED</dd><dt>当前工程版本</dt><dd>208</dd><dt>引用模型 / 版本</dt><dd>157 / 201</dd><dt>封面大小</dt><dd>45706 字节 · 640 × 408</dd></dl><details><summary>封面持久化与项目证据</summary><pre>{
&quot;id&quot;: &quot;160&quot;,
&quot;name&quot;: &quot;实测场景-狐狸野外训练区-20260905&quot;,
&quot;modelId&quot;: &quot;157&quot;,
&quot;modelVersionId&quot;: &quot;201&quot;,
&quot;finalStatus&quot;: &quot;PUBLISHED&quot;,
&quot;finalVersionId&quot;: &quot;208&quot;,
&quot;cover&quot;: {
&quot;image&quot;: &quot;screenshots/22-fox-final-cover.jpg&quot;,
&quot;width&quot;: 640,
&quot;height&quot;: 408,
&quot;sizeBytes&quot;: 45706,
&quot;assetId&quot;: &quot;2084&quot;,
&quot;versionId&quot;: &quot;208&quot;,
&quot;mimeType&quot;: &quot;image/jpeg&quot;,
&quot;sha256&quot;: &quot;1b6e4d1eb6e65ccc15ba820a8ae4bd40ab1bee900cf91d4ec3db9af82153618d&quot;,
&quot;storageUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/c176d359087fc9bea4235e43dd43a691.jpg&quot;
}
}</pre></details></article></div>
<h2>最终列表与创建时间倒序</h2><section class="panel"><figure class=""><a href="screenshots/28-scene-list-fixed.png" target="_blank" rel="noopener"><img src="screenshots/28-scene-list-fixed.png" alt="保留场景的实际封面与创建时间倒序列表" loading="lazy"></a><figcaption>保留场景的实际封面与创建时间倒序列表 · 点击查看原图</figcaption></figure></section>
<h2>逐项验证记录</h2><p class="muted">通过数仅统计未被更正且 status 明确为 PASS 的当前证据项;历史记录单独标注。checks 用于展示实际断言,不重复累加。故障注入中的预期错误响应不等于用例失败;缺少状态时不推断通过。图片文件存在校验不等于视觉内容验证。</p><div class="checks"><article class="check"><div class="check-title"><h3>1. 创建牛奶卡车检验区场景</h3><span class="badge pass">PASS</span></div><ul class="assertions"><li>通过列表新建并进入真实场景编辑器</li><li>首次加载认证服务短暂503,点击重新加载后成功;原创建记录159复用</li></ul><div class="images single"><figure class=""><a href="screenshots/03-truck-created-ready.png" target="_blank" rel="noopener"><img src="screenshots/03-truck-created-ready.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>2. 牛奶卡车场景保存并回读封面</h3><span class="badge pass">PASS</span></div><ul class="assertions"><li>场景资源库双击引用模型 156 的已发布版本 204</li><li>编辑对象坐标与设备绑定后,未保存状态 2.5 秒内无上传或项目写入</li><li>保存草稿发出 SCENE_COVER JPEG 上传与同一次项目 PUT</li><li>认证访问 /cover 返回 JPEG,封面尺寸 640×408,列表可用的 coverUri 已写入</li></ul><div class="images "><figure class=""><a href="screenshots/05-truck-model-imported.png" target="_blank" rel="noopener"><img src="screenshots/05-truck-model-imported.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/06-truck-transformed-unsaved.png" target="_blank" rel="noopener"><img src="screenshots/06-truck-transformed-unsaved.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/07-truck-scene-cover.png" target="_blank" rel="noopener"><img src="screenshots/07-truck-scene-cover.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/08-truck-saved-with-cover.png" target="_blank" rel="noopener"><img src="screenshots/08-truck-saved-with-cover.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>3. 牛奶卡车场景刷新恢复并发布</h3><span class="badge pass">PASS</span></div><ul class="assertions"><li>整页刷新后模型 156/版本 204、对象名称、X=4.2 和设备编号均恢复</li><li>发布前校验 8 项全部通过</li><li>发布动作先成功保存再成功发布,最终状态为 PUBLISHED</li></ul><details><summary>检查详情</summary><pre>{
&quot;details&quot;: {
&quot;total&quot;: 8,
&quot;warnings&quot;: 0,
&quot;text&quot;: &quot;✓\n场景环境已配置\n真实装备车库\n✓\n模型资源加载完成\n全部受控模型已就绪\n✓\n场景用途配置有效\n1 个实训装备\n✓\n对象设备编号完整\n用于接收物理设备和消息事件\n✓\n物模型绑定完整\n用于训练步骤和动作映射\n✓\n设备编号不重复\n设备编号唯一\n✓\n物模型编号不重复\n物模型编号唯一\n✓\n受控资源引用正常\n2 个导入模型可跨刷新恢复&quot;
}
}</pre></details><div class="images "><figure class=""><a href="screenshots/09-truck-reloaded-persisted.png" target="_blank" rel="noopener"><img src="screenshots/09-truck-reloaded-persisted.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/10-truck-published.png" target="_blank" rel="noopener"><img src="screenshots/10-truck-published.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>4. 狐狸野外场景保存并锁定已发布模型版本</h3><span class="badge neutral">历史记录(已更正)</span></div><ul class="assertions"><li>切换到真实野外训练场模板并导入模型 157</li><li>模型 157 当前草稿版本为 202,但场景严格依赖其已发布版本 201</li><li>保存草稿上传 SCENE_COVER JPEG,并在同次项目 PUT 写入 content、依赖和 coverUri</li><li>认证回读 /cover 返回 JPEG,尺寸 640×408</li></ul><details><summary>检查详情</summary><pre>{
&quot;superseded&quot;: true
}</pre></details><div class="images "><figure class=""><a href="screenshots/12-fox-outdoor-template.png" target="_blank" rel="noopener"><img src="screenshots/12-fox-outdoor-template.png" alt="历史截图(不作为修复后的视觉通过证据):原测试现场" loading="lazy"></a><figcaption>历史截图(不作为修复后的视觉通过证据):原测试现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/13-fox-model-imported.png" target="_blank" rel="noopener"><img src="screenshots/13-fox-model-imported.png" alt="历史截图(不作为修复后的视觉通过证据):原测试现场" loading="lazy"></a><figcaption>历史截图(不作为修复后的视觉通过证据):原测试现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/14-fox-scene-cover.png" target="_blank" rel="noopener"><img src="screenshots/14-fox-scene-cover.png" alt="历史截图(不作为修复后的视觉通过证据):原测试现场" loading="lazy"></a><figcaption>历史截图(不作为修复后的视觉通过证据):原测试现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/15-fox-saved-with-cover.png" target="_blank" rel="noopener"><img src="screenshots/15-fox-saved-with-cover.png" alt="历史截图(不作为修复后的视觉通过证据):原测试现场" loading="lazy"></a><figcaption>历史截图(不作为修复后的视觉通过证据):原测试现场 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>5. 场景封面上传失败保留原封面</h3><span class="badge pass">PASS</span></div><ul class="assertions"><li>对 SCENE_COVER 上传实施浏览器网络故障注入</li><li>保存请求仍成功,场景内容继续提交</li><li>coverUri 保持为保存前的旧值,未产生空封面或错误替换</li></ul><details><summary>检查详情</summary><pre>{
&quot;details&quot;: {
&quot;sceneId&quot;: &quot;160&quot;,
&quot;oldCoverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/bb1e8717cd2e7ba6ca10f16b817b1ec5.jpg&quot;,
&quot;putRequests&quot;: 1
}
}</pre></details><div class="images single"><figure class=""><a href="screenshots/16-fox-cover-upload-failed-content-saved.png" target="_blank" rel="noopener"><img src="screenshots/16-fox-cover-upload-failed-content-saved.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>6. 狐狸野外场景刷新恢复并发布</h3><span class="badge neutral">历史记录(已更正)</span></div><ul class="assertions"><li>整页刷新后狐狸模型依赖仍为项目 157 的已发布版本 201</li><li>封面上传失败后的场景正文编辑 X=-7.5 已恢复</li><li>8 项发布校验全部通过,发布时重新保存并成功发布</li><li>发布后的 coverUri 有效;保存前封面与发布后封面均由当前场景视图生成</li></ul><details><summary>检查详情</summary><pre>{
&quot;superseded&quot;: true,
&quot;details&quot;: {
&quot;validation&quot;: {
&quot;total&quot;: 8,
&quot;warnings&quot;: 0,
&quot;text&quot;: &quot;✓\n场景环境已配置\n真实野外训练场\n✓\n模型资源加载完成\n全部受控模型已就绪\n✓\n场景用途配置有效\n3 个实训装备\n✓\n对象设备编号完整\n用于接收物理设备和消息事件\n✓\n物模型绑定完整\n用于训练步骤和动作映射\n✓\n设备编号不重复\n设备编号唯一\n✓\n物模型编号不重复\n物模型编号唯一\n✓\n受控资源引用正常\n2 个导入模型可跨刷新恢复&quot;
},
&quot;coverBeforePublish&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/bb1e8717cd2e7ba6ca10f16b817b1ec5.jpg&quot;,
&quot;coverAfterPublish&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/62e5d4a05316badb631e252f44ecbec3.jpg&quot;
}
}</pre></details><div class="images "><figure class=""><a href="screenshots/17-fox-reloaded-persisted.png" target="_blank" rel="noopener"><img src="screenshots/17-fox-reloaded-persisted.png" alt="历史截图(不作为修复后的视觉通过证据):原测试现场" loading="lazy"></a><figcaption>历史截图(不作为修复后的视觉通过证据):原测试现场 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/18-fox-published.png" target="_blank" rel="noopener"><img src="screenshots/18-fox-published.png" alt="历史截图(不作为修复后的视觉通过证据):原测试现场" loading="lazy"></a><figcaption>历史截图(不作为修复后的视觉通过证据):原测试现场 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>7. 场景列表创建时间倒序与封面展示</h3><span class="badge pass">PASS</span></div><ul class="assertions"><li>列表请求默认携带 sort=CREATED_DESC</li><li>较晚创建的狐狸野外场景显示在较早创建的牛奶卡车场景之前</li><li>卡车于 19:32 更新,晚于狐狸的 19:31,但狐狸仍排在卡车之前,证实没有按更新时间倒序</li><li>两条场景均通过受控 /cover 接口展示修复后服务器 JPEG 封面;列表中可辨认狐狸和卡车</li></ul><details><summary>检查详情</summary><pre>{
&quot;details&quot;: {
&quot;foxIndex&quot;: 0,
&quot;truckIndex&quot;: 1,
&quot;rows&quot;: [
{
&quot;name&quot;: &quot;狐狸野外场景&quot;,
&quot;projectId&quot;: &quot;160&quot;,
&quot;addTime&quot;: 1788605625,
&quot;updateTime&quot;: &quot;2026-09-05T19:31:30&quot;,
&quot;imageIsBlob&quot;: true
},
{
&quot;name&quot;: &quot;牛奶卡车场景&quot;,
&quot;projectId&quot;: &quot;159&quot;,
&quot;addTime&quot;: 1788603808,
&quot;updateTime&quot;: &quot;2026-09-05T19:32:28&quot;,
&quot;imageIsBlob&quot;: true
}
]
}
}</pre></details><div class="images single"><figure class=""><a href="screenshots/28-scene-list-fixed.png" target="_blank" rel="noopener"><img src="screenshots/28-scene-list-fixed.png" alt="浏览器现场" loading="lazy"></a><figcaption>浏览器现场 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>8. 修复后狐狸新增导入、保存发布与刷新可见性</h3><span class="badge pass">PASS</span></div><ul class="assertions"><li>从真实 UI 资源库新增模型 157 / 已发布版本 201,默认缩放约 0.0361637593,包围盒约 0.966 × 2.812 × 6,狐狸在视口中可见;该临时新增未保存、无写请求</li><li>场景 160 原狐狸修复尺寸和镜头后,保存并发布为版本 208;服务器封面资产 2084 为真实 JPEG,640 × 408,45706 字节</li><li>独立 Edge / Playwright 新会话按保存的镜头重新打开场景,狐狸网格已加载且可见,pendingLoads=0</li><li>显示/隐藏狐狸前后进行像素对比,禁用模型投影排除仅阴影可见:320 × 180 采样改变 4472 像素(7.764%),超过 500 像素阈值</li><li>实际检查发布截图、独立刷新截图及服务器 JPEG,均可辨认狐狸;模型 157 草稿 202 仍为 DRAFT,已发布版本仍为 201</li></ul><details><summary>检查详情</summary><pre>{
&quot;details&quot;: {
&quot;sceneId&quot;: &quot;160&quot;,
&quot;currentVersionId&quot;: &quot;208&quot;,
&quot;publishedVersionId&quot;: &quot;208&quot;,
&quot;modelProjectId&quot;: &quot;157&quot;,
&quot;modelVersionId&quot;: &quot;201&quot;,
&quot;newImportWriteRequests&quot;: [],
&quot;visibilityEvidence&quot;: &quot;visibility-evidence.json&quot;,
&quot;changedPixels&quot;: 4472,
&quot;changedPercentage&quot;: 7.764,
&quot;pendingLoads&quot;: 0,
&quot;boundsSize&quot;: [
0.9658796100519353,
2.811899945787403,
6
]
}
}</pre></details><div class="images "><figure class=""><a href="screenshots/25-fox-fresh-import-visible.png" target="_blank" rel="noopener"><img src="screenshots/25-fox-fresh-import-visible.png" alt="真实 UI 新增狐狸后尺寸正常且可见;临时测试未保存。" loading="lazy"></a><figcaption>真实 UI 新增狐狸后尺寸正常且可见;临时测试未保存。 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/21-fox-corrected-published.png" target="_blank" rel="noopener"><img src="screenshots/21-fox-corrected-published.png" alt="场景 160 修复后保存发布为版本 208。" loading="lazy"></a><figcaption>场景 160 修复后保存发布为版本 208。 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/22-fox-final-cover.jpg" target="_blank" rel="noopener"><img src="screenshots/22-fox-final-cover.jpg" alt="从服务器回读的最终狐狸 JPEG 封面,资产 2084。" loading="lazy"></a><figcaption>从服务器回读的最终狐狸 JPEG 封面,资产 2084。 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/26-fox-visual-regression.png" target="_blank" rel="noopener"><img src="screenshots/26-fox-visual-regression.png" alt="独立浏览器重新打开后狐狸可见;像素差异 4472。" loading="lazy"></a><figcaption>独立浏览器重新打开后狐狸可见;像素差异 4472。 · 点击查看原图</figcaption></figure></div></article><article class="check"><div class="check-title"><h3>9. 修复后牛奶卡车保存发布与刷新可见性</h3><span class="badge pass">PASS</span></div><ul class="assertions"><li>场景 159 保持模型 156 / 已发布版本 204,修复比例和镜头后保存并发布为版本 209</li><li>独立 Edge / Playwright 新会话重新打开场景,按保存镜头确认卡车 5 个网格均可见、pendingLoads=0</li><li>显示/隐藏卡车前后对比,禁用模型投影:320 × 180 采样改变 7515 像素(13.047%),超过 500 像素阈值</li><li>服务器回读最终封面资产 2087 为真实 JPEG,640 × 408,32937 字节;封面及刷新截图可辨认卡车</li><li>默认镜头部分车身被立柱遮挡,属于本次可见画面的已知限制</li></ul><details><summary>检查详情</summary><pre>{
&quot;details&quot;: {
&quot;sceneId&quot;: &quot;159&quot;,
&quot;currentVersionId&quot;: &quot;209&quot;,
&quot;publishedVersionId&quot;: &quot;209&quot;,
&quot;modelProjectId&quot;: &quot;156&quot;,
&quot;modelVersionId&quot;: &quot;204&quot;,
&quot;visibilityEvidence&quot;: &quot;visibility-evidence.json&quot;,
&quot;changedPixels&quot;: 7515,
&quot;changedPercentage&quot;: 13.047,
&quot;pendingLoads&quot;: 0,
&quot;boundsSize&quot;: [
3.4406057210321332,
3.390913184034896,
6.000000000000001
]
}
}</pre></details><div class="images "><figure class=""><a href="screenshots/23-truck-corrected-published.png" target="_blank" rel="noopener"><img src="screenshots/23-truck-corrected-published.png" alt="场景 159 修复后保存发布为版本 209。" loading="lazy"></a><figcaption>场景 159 修复后保存发布为版本 209。 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/24-truck-final-cover.jpg" target="_blank" rel="noopener"><img src="screenshots/24-truck-final-cover.jpg" alt="从服务器回读的最终卡车 JPEG 封面,资产 2087;部分车身被立柱遮挡。" loading="lazy"></a><figcaption>从服务器回读的最终卡车 JPEG 封面,资产 2087;部分车身被立柱遮挡。 · 点击查看原图</figcaption></figure><figure class=""><a href="screenshots/27-truck-visual-regression.png" target="_blank" rel="noopener"><img src="screenshots/27-truck-visual-regression.png" alt="独立浏览器重新打开后卡车可见;像素差异 7515。" loading="lazy"></a><figcaption>独立浏览器重新打开后卡车可见;像素差异 7515。 · 点击查看原图</figcaption></figure></div></article></div>
<h2>数据保留与清理</h2><section class="panel"><pre>保留开发环境场景 159(牛奶卡车检验区,PUBLISHED,当前及已发布版本 209,模型 156 / 已发布版本 204)和场景 160(狐狸野外训练区,PUBLISHED,当前及已发布版本 208,模型 157 / 已发布版本 201)。模型原发布版本未修改;模型 157 的当前草稿版本 202 继续保留,未发布或修改。本次无需 DDL 变更。</pre><div class="table-wrap"><table><thead><tr><th>场景</th><th>模型 / 发布版本</th><th>最终状态</th><th>当前版本</th></tr></thead><tbody><tr><td>159 · 实测场景-牛奶卡车检验区-20260905</td><td>156 / 204</td><td>PUBLISHED</td><td>209</td></tr><tr><td>160 · 实测场景-狐狸野外训练区-20260905</td><td>157 / 201</td><td>PUBLISHED</td><td>208</td></tr></tbody></table></div><pre>保留两条真实场景及历史截图用于后续测试和追溯。新增导入狐狸仅在隔离浏览器会话中测试,未保存、无写请求;测试结束后丢弃临时对象。</pre></section>
<h2>本次问题记录</h2><section class="panel"><pre>{
&quot;title&quot;: &quot;狐狸存在于对象树但不在真实视口中显示&quot;,
&quot;status&quot;: &quot;FIXED_AND_VISUALLY_VERIFIED&quot;,
&quot;details&quot;: &quot;原完整 E2E 通过结论已撤回。同步蒙皮矩阵并保留归一化缩放,修复场景数据和镜头后,新增导入、保存发布、独立刷新与像素可见性均通过;详见更正区。&quot;
}</pre></section>
<h2>后续独立事项</h2><section class="panel"><p>既存受控文件资产的 fsvc URI 分支未完整覆盖上传票据绑定校验,属于本次场景封面变更前已存在的问题。本次保留现有流程,后续应独立补齐校验并验证历史资产、版本复制与引用兼容性;不计入本次场景功能失败数。</p><pre>{
&quot;scope&quot;: &quot;independent&quot;,
&quot;title&quot;: &quot;fsvc 新资产上传票据绑定校验&quot;,
&quot;details&quot;: &quot;既存流程的 fsvc URI 分支未完整覆盖上传票据绑定校验;本次未扩大修改范围,应单独补齐并验证历史资产、版本复制与引用兼容性。&quot;
}</pre></section>
<h2>实际命令与结果</h2><section class="panel"><div class="command"><h3>pnpm run build</h3><pre>{
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;前端生产构建及遗留模型编辑器产物校验通过。&quot;
}</pre></div><div class="command"><h3>node --test tools/scene-cover-api.test.mjs tools/scene-cover-capture.test.mjs tools/scene-model-placement.test.mjs</h3><pre>{
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;33 / 33 通过:封面同次 PUT、异常回退、并发队列、项目切换、截图尺寸、渲染状态恢复,以及蒙皮模型包围盒与归一化缩放回归。&quot;
}</pre></div><div class="command"><h3>原 Microsoft Edge 场景链路 E2E(更正说明)</h3><pre>{
&quot;status&quot;: &quot;SUPERSEDED&quot;,
&quot;details&quot;: &quot;原 7/7 只覆盖模型引用、保存、封面上传和发布等链路,未证明狐狸真实可见;完整通过结论已撤回,当前视觉验证见下列独立浏览器 E2E。&quot;
}</pre></div><div class="command"><h3>node tools/scene-model-visibility.e2e.mjs</h3><pre>{
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;独立 Edge / Playwright 会话重开 160/208 与 159/209,使用保存镜头,显示/隐藏模型并禁用投影进行像素比较;狐狸 4472 像素、卡车 7515 像素,均超过 500 阈值。证据保存在 visibility-evidence.json,截图 26 / 27;无写请求。&quot;
}</pre></div></section>
<h2>当前行为与限制</h2><section class="panel"><ul><li>手动保存从当前三维视口生成封面;新封面与场景内容、模型引用在同一次工程更新中关联保存。发布前的保存沿用该流程。</li><li>截图或封面上传失败时保留旧封面;工程是否保存成功以实际更新请求为准。版本冲突不覆盖其他已保存的内容。</li><li>自动恢复及重新打开工程不生成或上传新封面;恢复后的修改需由用户保存后更新封面。</li><li>列表按创建时间倒序展示。再次保存不会改变创建时间,也不应因此改变创建时间排序。</li><li>牛奶卡车场景保存的默认镜头中,车身部分被场景立柱遮挡;真实网格可见且像素差异验证通过。需要观察完整车身时可旋转镜头。</li></ul></section>
<footer>报告来自 evidence.json,生成过程不修改原始证据。所有引用图片已校验存在且非空,点击可查看原图。通过数随证据更新,不固定用例数量。</footer>
</main></body></html>

+ 451
- 0
reports/scene-live-20260905/report.md View File

@@ -0,0 +1,451 @@
# 真实场景与截图封面验证报告

场景保存截图封面及列表按创建时间倒序的功能已实施。本报告记录使用现有模型创建真实场景、保存当前三维视口封面及异常回退的实际验证结果。

2026-09-05 · http://127.0.0.1:6180 · Microsoft Edge · 教员

## 狐狸场景验证更正:撤回原完整通过结论

> **测试结论更正:** 用户指出狐狸没有出现在场景中,经复现确认属实。原报告的 7/7 仅证明引用、保存、封面上传和发布等链路,漏掉了模型真正可见的视觉检查,因此撤回原“完整 E2E 通过”结论。下列修复后证据增加了真实画面和像素差异验证。

### 原结论与漏验

- 原狐狸检查把对象树存在 fox、模型依赖有效以及接口返回成功当作模型已加载可见;旧截图实际没有显示狐狸。两项旧狐狸证据标记为已更正历史记录并移出当前通过率,原文件继续保留用于追溯。

### 根因

- 计算蒙皮模型包围盒前没有同步骨骼矩阵,得到失真的尺寸。
- 适配场景尺寸时使用 setScalar 覆盖模型已有的归一化缩放;两者叠加将狐狸放大到约 1000 个场景单位,使模型超出视锥。

### 修复内容

- 同步蒙皮矩阵后计算真实包围盒,并保留已有归一化缩放进行相对缩放;新导入模型与已保存模型均使用修正后的尺寸处理。
- 修复场景 160 的狐狸比例和默认镜头,保存并发布为版本 208;重新生成服务器 JPEG 封面资产 2084。狐狸包围盒约为 0.966 × 2.812 × 6 个场景单位。
- 同样复核并修正场景 159,保存并发布为版本 209,重新生成服务器 JPEG 封面资产 2087。未修改模型发布版本或数据库表结构。

### 修复后验证

- 真实 UI 从资源库新增狐狸后立即可见,最长边为 6;该新增操作仅在隔离浏览器会话中测试,未保存、无写请求。
- 两个场景修改、保存、发布后,通过独立新浏览器会话重新打开,使用保存的默认镜头验证;没有未完成模型加载,也没有产生写请求。
- 关闭模型投影后,对同一画面中模型显示与隐藏的像素进行比较:320 × 180 采样中狐狸改变 4472 像素、牛奶卡车改变 7515 像素,均超过 500 像素阈值。截图中可直接辨认狐狸与卡车。
- 牛奶卡车默认镜头下部分车身被场景立柱遮挡,但模型确实可见;报告保留这一限制。
- 最终列表显示狐狸在卡车之前,虽然卡车更新于 19:32、狐狸更新于 19:31,确认排序依据仍为创建时间。

[![修复后独立浏览器重新打开场景 160:狐狸模型清晰可见,并完成像素差异验证。](screenshots/26-fox-visual-regression.png)](screenshots/26-fox-visual-regression.png)

下方标为“历史记录(已更正)”的检查及截图保留用于追溯,不计入当前通过率,也不作为狐狸已经可见的证据。

当前证据项通过 7 / 7;失败 0;其他或未标注 0;已更正历史记录 2;已校验图片文件 22。

## 保留场景与封面证据

### 实测场景-牛奶卡车检验区-20260905

场景 159;状态 PUBLISHED;当前版本 209;引用模型 156 / 发布版本 204。

[![场景 159 保存后封面回读证据](screenshots/24-truck-final-cover.jpg)](screenshots/24-truck-final-cover.jpg)

<details><summary>封面持久化与项目证据</summary>

<pre>
{
&quot;id&quot;: &quot;159&quot;,
&quot;name&quot;: &quot;实测场景-牛奶卡车检验区-20260905&quot;,
&quot;modelId&quot;: &quot;156&quot;,
&quot;modelVersionId&quot;: &quot;204&quot;,
&quot;finalStatus&quot;: &quot;PUBLISHED&quot;,
&quot;finalVersionId&quot;: &quot;209&quot;,
&quot;cover&quot;: {
&quot;image&quot;: &quot;screenshots/24-truck-final-cover.jpg&quot;,
&quot;width&quot;: 640,
&quot;height&quot;: 408,
&quot;sizeBytes&quot;: 32937,
&quot;assetId&quot;: &quot;2087&quot;,
&quot;versionId&quot;: &quot;209&quot;,
&quot;mimeType&quot;: &quot;image/jpeg&quot;,
&quot;sha256&quot;: &quot;e103a473b74f3809365890296180c42f6c37a8005f2fd4c27fb64c437c245786&quot;,
&quot;storageUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/d29810b273d75e43dff07622424f333f.jpg&quot;
}
}
</pre>

</details>

### 实测场景-狐狸野外训练区-20260905

场景 160;状态 PUBLISHED;当前版本 208;引用模型 157 / 发布版本 201。

[![场景 160 保存后封面回读证据](screenshots/22-fox-final-cover.jpg)](screenshots/22-fox-final-cover.jpg)

<details><summary>封面持久化与项目证据</summary>

<pre>
{
&quot;id&quot;: &quot;160&quot;,
&quot;name&quot;: &quot;实测场景-狐狸野外训练区-20260905&quot;,
&quot;modelId&quot;: &quot;157&quot;,
&quot;modelVersionId&quot;: &quot;201&quot;,
&quot;finalStatus&quot;: &quot;PUBLISHED&quot;,
&quot;finalVersionId&quot;: &quot;208&quot;,
&quot;cover&quot;: {
&quot;image&quot;: &quot;screenshots/22-fox-final-cover.jpg&quot;,
&quot;width&quot;: 640,
&quot;height&quot;: 408,
&quot;sizeBytes&quot;: 45706,
&quot;assetId&quot;: &quot;2084&quot;,
&quot;versionId&quot;: &quot;208&quot;,
&quot;mimeType&quot;: &quot;image/jpeg&quot;,
&quot;sha256&quot;: &quot;1b6e4d1eb6e65ccc15ba820a8ae4bd40ab1bee900cf91d4ec3db9af82153618d&quot;,
&quot;storageUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/c176d359087fc9bea4235e43dd43a691.jpg&quot;
}
}
</pre>

</details>

## 最终列表与创建时间倒序

[![保留场景的封面与创建时间倒序列表](screenshots/28-scene-list-fixed.png)](screenshots/28-scene-list-fixed.png)

## 逐项验证记录

通过数仅统计未被更正且 status 明确为 PASS 的当前证据项;历史记录单独标注。checks 不重复累加。预期故障响应不等于用例失败,缺少状态不推断通过。图片文件存在校验不等于视觉内容验证。

| 序号 | 检查项 | 结果 | 图片 |
|---|---|---|---|
| 1 | 创建牛奶卡车检验区场景 | PASS | [图片 1](screenshots/03-truck-created-ready.png) |
| 2 | 牛奶卡车场景保存并回读封面 | PASS | [图片 1](screenshots/05-truck-model-imported.png) · [图片 2](screenshots/06-truck-transformed-unsaved.png) · [图片 3](screenshots/07-truck-scene-cover.png) · [图片 4](screenshots/08-truck-saved-with-cover.png) |
| 3 | 牛奶卡车场景刷新恢复并发布 | PASS | [图片 1](screenshots/09-truck-reloaded-persisted.png) · [图片 2](screenshots/10-truck-published.png) |
| 4 | 狐狸野外场景保存并锁定已发布模型版本 | 历史记录(已更正) | [图片 1](screenshots/12-fox-outdoor-template.png) · [图片 2](screenshots/13-fox-model-imported.png) · [图片 3](screenshots/14-fox-scene-cover.png) · [图片 4](screenshots/15-fox-saved-with-cover.png) |
| 5 | 场景封面上传失败保留原封面 | PASS | [图片 1](screenshots/16-fox-cover-upload-failed-content-saved.png) |
| 6 | 狐狸野外场景刷新恢复并发布 | 历史记录(已更正) | [图片 1](screenshots/17-fox-reloaded-persisted.png) · [图片 2](screenshots/18-fox-published.png) |
| 7 | 场景列表创建时间倒序与封面展示 | PASS | [图片 1](screenshots/28-scene-list-fixed.png) |
| 8 | 修复后狐狸新增导入、保存发布与刷新可见性 | PASS | [图片 1](screenshots/25-fox-fresh-import-visible.png) · [图片 2](screenshots/21-fox-corrected-published.png) · [图片 3](screenshots/22-fox-final-cover.jpg) · [图片 4](screenshots/26-fox-visual-regression.png) |
| 9 | 修复后牛奶卡车保存发布与刷新可见性 | PASS | [图片 1](screenshots/23-truck-corrected-published.png) · [图片 2](screenshots/24-truck-final-cover.jpg) · [图片 3](screenshots/27-truck-visual-regression.png) |

### 1. 创建牛奶卡车检验区场景 · PASS

- 通过列表新建并进入真实场景编辑器
- 首次加载认证服务短暂503,点击重新加载后成功;原创建记录159复用

[![验证现场](screenshots/03-truck-created-ready.png)](screenshots/03-truck-created-ready.png)

### 2. 牛奶卡车场景保存并回读封面 · PASS

- 场景资源库双击引用模型 156 的已发布版本 204
- 编辑对象坐标与设备绑定后,未保存状态 2.5 秒内无上传或项目写入
- 保存草稿发出 SCENE\_COVER JPEG 上传与同一次项目 PUT
- 认证访问 /cover 返回 JPEG,封面尺寸 640×408,列表可用的 coverUri 已写入

[![验证现场](screenshots/05-truck-model-imported.png)](screenshots/05-truck-model-imported.png)

[![验证现场](screenshots/06-truck-transformed-unsaved.png)](screenshots/06-truck-transformed-unsaved.png)

[![验证现场](screenshots/07-truck-scene-cover.png)](screenshots/07-truck-scene-cover.png)

[![验证现场](screenshots/08-truck-saved-with-cover.png)](screenshots/08-truck-saved-with-cover.png)

### 3. 牛奶卡车场景刷新恢复并发布 · PASS

- 整页刷新后模型 156/版本 204、对象名称、X=4.2 和设备编号均恢复
- 发布前校验 8 项全部通过
- 发布动作先成功保存再成功发布,最终状态为 PUBLISHED

<details><summary>检查详情</summary>

<pre>
{
&quot;details&quot;: {
&quot;total&quot;: 8,
&quot;warnings&quot;: 0,
&quot;text&quot;: &quot;✓\n场景环境已配置\n真实装备车库\n✓\n模型资源加载完成\n全部受控模型已就绪\n✓\n场景用途配置有效\n1 个实训装备\n✓\n对象设备编号完整\n用于接收物理设备和消息事件\n✓\n物模型绑定完整\n用于训练步骤和动作映射\n✓\n设备编号不重复\n设备编号唯一\n✓\n物模型编号不重复\n物模型编号唯一\n✓\n受控资源引用正常\n2 个导入模型可跨刷新恢复&quot;
}
}
</pre>

</details>

[![验证现场](screenshots/09-truck-reloaded-persisted.png)](screenshots/09-truck-reloaded-persisted.png)

[![验证现场](screenshots/10-truck-published.png)](screenshots/10-truck-published.png)

### 4. 狐狸野外场景保存并锁定已发布模型版本 · 历史记录(已更正)

- 切换到真实野外训练场模板并导入模型 157
- 模型 157 当前草稿版本为 202,但场景严格依赖其已发布版本 201
- 保存草稿上传 SCENE\_COVER JPEG,并在同次项目 PUT 写入 content、依赖和 coverUri
- 认证回读 /cover 返回 JPEG,尺寸 640×408

<details><summary>检查详情</summary>

<pre>
{
&quot;superseded&quot;: true
}
</pre>

</details>

[![历史截图(不作为修复后的视觉通过证据):原测试现场](screenshots/12-fox-outdoor-template.png)](screenshots/12-fox-outdoor-template.png)

[![历史截图(不作为修复后的视觉通过证据):原测试现场](screenshots/13-fox-model-imported.png)](screenshots/13-fox-model-imported.png)

[![历史截图(不作为修复后的视觉通过证据):原测试现场](screenshots/14-fox-scene-cover.png)](screenshots/14-fox-scene-cover.png)

[![历史截图(不作为修复后的视觉通过证据):原测试现场](screenshots/15-fox-saved-with-cover.png)](screenshots/15-fox-saved-with-cover.png)

### 5. 场景封面上传失败保留原封面 · PASS

- 对 SCENE\_COVER 上传实施浏览器网络故障注入
- 保存请求仍成功,场景内容继续提交
- coverUri 保持为保存前的旧值,未产生空封面或错误替换

<details><summary>检查详情</summary>

<pre>
{
&quot;details&quot;: {
&quot;sceneId&quot;: &quot;160&quot;,
&quot;oldCoverUri&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/bb1e8717cd2e7ba6ca10f16b817b1ec5.jpg&quot;,
&quot;putRequests&quot;: 1
}
}
</pre>

</details>

[![验证现场](screenshots/16-fox-cover-upload-failed-content-saved.png)](screenshots/16-fox-cover-upload-failed-content-saved.png)

### 6. 狐狸野外场景刷新恢复并发布 · 历史记录(已更正)

- 整页刷新后狐狸模型依赖仍为项目 157 的已发布版本 201
- 封面上传失败后的场景正文编辑 X=-7.5 已恢复
- 8 项发布校验全部通过,发布时重新保存并成功发布
- 发布后的 coverUri 有效;保存前封面与发布后封面均由当前场景视图生成

<details><summary>检查详情</summary>

<pre>
{
&quot;superseded&quot;: true,
&quot;details&quot;: {
&quot;validation&quot;: {
&quot;total&quot;: 8,
&quot;warnings&quot;: 0,
&quot;text&quot;: &quot;✓\n场景环境已配置\n真实野外训练场\n✓\n模型资源加载完成\n全部受控模型已就绪\n✓\n场景用途配置有效\n3 个实训装备\n✓\n对象设备编号完整\n用于接收物理设备和消息事件\n✓\n物模型绑定完整\n用于训练步骤和动作映射\n✓\n设备编号不重复\n设备编号唯一\n✓\n物模型编号不重复\n物模型编号唯一\n✓\n受控资源引用正常\n2 个导入模型可跨刷新恢复&quot;
},
&quot;coverBeforePublish&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/bb1e8717cd2e7ba6ca10f16b817b1ec5.jpg&quot;,
&quot;coverAfterPublish&quot;: &quot;fsvc://unreal-tran/image/2026/09/05/62e5d4a05316badb631e252f44ecbec3.jpg&quot;
}
}
</pre>

</details>

[![历史截图(不作为修复后的视觉通过证据):原测试现场](screenshots/17-fox-reloaded-persisted.png)](screenshots/17-fox-reloaded-persisted.png)

[![历史截图(不作为修复后的视觉通过证据):原测试现场](screenshots/18-fox-published.png)](screenshots/18-fox-published.png)

### 7. 场景列表创建时间倒序与封面展示 · PASS

- 列表请求默认携带 sort=CREATED\_DESC
- 较晚创建的狐狸野外场景显示在较早创建的牛奶卡车场景之前
- 卡车于 19:32 更新,晚于狐狸的 19:31,但狐狸仍排在卡车之前,证实没有按更新时间倒序
- 两条场景均通过受控 /cover 接口展示修复后服务器 JPEG 封面;列表中可辨认狐狸和卡车

<details><summary>检查详情</summary>

<pre>
{
&quot;details&quot;: {
&quot;foxIndex&quot;: 0,
&quot;truckIndex&quot;: 1,
&quot;rows&quot;: [
{
&quot;name&quot;: &quot;狐狸野外场景&quot;,
&quot;projectId&quot;: &quot;160&quot;,
&quot;addTime&quot;: 1788605625,
&quot;updateTime&quot;: &quot;2026-09-05T19:31:30&quot;,
&quot;imageIsBlob&quot;: true
},
{
&quot;name&quot;: &quot;牛奶卡车场景&quot;,
&quot;projectId&quot;: &quot;159&quot;,
&quot;addTime&quot;: 1788603808,
&quot;updateTime&quot;: &quot;2026-09-05T19:32:28&quot;,
&quot;imageIsBlob&quot;: true
}
]
}
}
</pre>

</details>

[![验证现场](screenshots/28-scene-list-fixed.png)](screenshots/28-scene-list-fixed.png)

### 8. 修复后狐狸新增导入、保存发布与刷新可见性 · PASS

- 从真实 UI 资源库新增模型 157 / 已发布版本 201,默认缩放约 0.0361637593,包围盒约 0.966 × 2.812 × 6,狐狸在视口中可见;该临时新增未保存、无写请求
- 场景 160 原狐狸修复尺寸和镜头后,保存并发布为版本 208;服务器封面资产 2084 为真实 JPEG,640 × 408,45706 字节
- 独立 Edge / Playwright 新会话按保存的镜头重新打开场景,狐狸网格已加载且可见,pendingLoads=0
- 显示/隐藏狐狸前后进行像素对比,禁用模型投影排除仅阴影可见:320 × 180 采样改变 4472 像素(7.764%),超过 500 像素阈值
- 实际检查发布截图、独立刷新截图及服务器 JPEG,均可辨认狐狸;模型 157 草稿 202 仍为 DRAFT,已发布版本仍为 201

<details><summary>检查详情</summary>

<pre>
{
&quot;details&quot;: {
&quot;sceneId&quot;: &quot;160&quot;,
&quot;currentVersionId&quot;: &quot;208&quot;,
&quot;publishedVersionId&quot;: &quot;208&quot;,
&quot;modelProjectId&quot;: &quot;157&quot;,
&quot;modelVersionId&quot;: &quot;201&quot;,
&quot;newImportWriteRequests&quot;: [],
&quot;visibilityEvidence&quot;: &quot;visibility-evidence.json&quot;,
&quot;changedPixels&quot;: 4472,
&quot;changedPercentage&quot;: 7.764,
&quot;pendingLoads&quot;: 0,
&quot;boundsSize&quot;: [
0.9658796100519353,
2.811899945787403,
6
]
}
}
</pre>

</details>

[![真实 UI 新增狐狸后尺寸正常且可见;临时测试未保存。](screenshots/25-fox-fresh-import-visible.png)](screenshots/25-fox-fresh-import-visible.png)

[![场景 160 修复后保存发布为版本 208。](screenshots/21-fox-corrected-published.png)](screenshots/21-fox-corrected-published.png)

[![从服务器回读的最终狐狸 JPEG 封面,资产 2084。](screenshots/22-fox-final-cover.jpg)](screenshots/22-fox-final-cover.jpg)

[![独立浏览器重新打开后狐狸可见;像素差异 4472。](screenshots/26-fox-visual-regression.png)](screenshots/26-fox-visual-regression.png)

### 9. 修复后牛奶卡车保存发布与刷新可见性 · PASS

- 场景 159 保持模型 156 / 已发布版本 204,修复比例和镜头后保存并发布为版本 209
- 独立 Edge / Playwright 新会话重新打开场景,按保存镜头确认卡车 5 个网格均可见、pendingLoads=0
- 显示/隐藏卡车前后对比,禁用模型投影:320 × 180 采样改变 7515 像素(13.047%),超过 500 像素阈值
- 服务器回读最终封面资产 2087 为真实 JPEG,640 × 408,32937 字节;封面及刷新截图可辨认卡车
- 默认镜头部分车身被立柱遮挡,属于本次可见画面的已知限制

<details><summary>检查详情</summary>

<pre>
{
&quot;details&quot;: {
&quot;sceneId&quot;: &quot;159&quot;,
&quot;currentVersionId&quot;: &quot;209&quot;,
&quot;publishedVersionId&quot;: &quot;209&quot;,
&quot;modelProjectId&quot;: &quot;156&quot;,
&quot;modelVersionId&quot;: &quot;204&quot;,
&quot;visibilityEvidence&quot;: &quot;visibility-evidence.json&quot;,
&quot;changedPixels&quot;: 7515,
&quot;changedPercentage&quot;: 13.047,
&quot;pendingLoads&quot;: 0,
&quot;boundsSize&quot;: [
3.4406057210321332,
3.390913184034896,
6.000000000000001
]
}
}
</pre>

</details>

[![场景 159 修复后保存发布为版本 209。](screenshots/23-truck-corrected-published.png)](screenshots/23-truck-corrected-published.png)

[![从服务器回读的最终卡车 JPEG 封面,资产 2087;部分车身被立柱遮挡。](screenshots/24-truck-final-cover.jpg)](screenshots/24-truck-final-cover.jpg)

[![独立浏览器重新打开后卡车可见;像素差异 7515。](screenshots/27-truck-visual-regression.png)](screenshots/27-truck-visual-regression.png)

## 数据保留与清理

<pre>
保留开发环境场景 159(牛奶卡车检验区,PUBLISHED,当前及已发布版本 209,模型 156 / 已发布版本 204)和场景 160(狐狸野外训练区,PUBLISHED,当前及已发布版本 208,模型 157 / 已发布版本 201)。模型原发布版本未修改;模型 157 的当前草稿版本 202 继续保留,未发布或修改。本次无需 DDL 变更。
</pre>

| 场景 | 模型 / 发布版本 | 最终状态 | 当前版本 |
|---|---|---|---|
| 159 · 实测场景-牛奶卡车检验区-20260905 | 156 / 204 | PUBLISHED | 209 |
| 160 · 实测场景-狐狸野外训练区-20260905 | 157 / 201 | PUBLISHED | 208 |

<pre>
保留两条真实场景及历史截图用于后续测试和追溯。新增导入狐狸仅在隔离浏览器会话中测试,未保存、无写请求;测试结束后丢弃临时对象。
</pre>

## 本次问题记录

<pre>
{
&quot;title&quot;: &quot;狐狸存在于对象树但不在真实视口中显示&quot;,
&quot;status&quot;: &quot;FIXED_AND_VISUALLY_VERIFIED&quot;,
&quot;details&quot;: &quot;原完整 E2E 通过结论已撤回。同步蒙皮矩阵并保留归一化缩放,修复场景数据和镜头后,新增导入、保存发布、独立刷新与像素可见性均通过;详见更正区。&quot;
}
</pre>

## 后续独立事项

既存受控文件资产的 fsvc URI 分支未完整覆盖上传票据绑定校验,属于本次场景封面变更前已存在的问题。本次保留现有流程,后续应独立补齐校验并验证历史资产、版本复制与引用兼容性;不计入本次场景功能失败数。

<pre>
{
&quot;scope&quot;: &quot;independent&quot;,
&quot;title&quot;: &quot;fsvc 新资产上传票据绑定校验&quot;,
&quot;details&quot;: &quot;既存流程的 fsvc URI 分支未完整覆盖上传票据绑定校验;本次未扩大修改范围,应单独补齐并验证历史资产、版本复制与引用兼容性。&quot;
}
</pre>

## 实际命令与结果

**pnpm run build**

<pre>
{
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;前端生产构建及遗留模型编辑器产物校验通过。&quot;
}
</pre>

**node --test tools/scene-cover-api.test.mjs tools/scene-cover-capture.test.mjs tools/scene-model-placement.test.mjs**

<pre>
{
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;33 / 33 通过:封面同次 PUT、异常回退、并发队列、项目切换、截图尺寸、渲染状态恢复,以及蒙皮模型包围盒与归一化缩放回归。&quot;
}
</pre>

**原 Microsoft Edge 场景链路 E2E(更正说明)**

<pre>
{
&quot;status&quot;: &quot;SUPERSEDED&quot;,
&quot;details&quot;: &quot;原 7/7 只覆盖模型引用、保存、封面上传和发布等链路,未证明狐狸真实可见;完整通过结论已撤回,当前视觉验证见下列独立浏览器 E2E。&quot;
}
</pre>

**node tools/scene-model-visibility.e2e.mjs**

<pre>
{
&quot;status&quot;: &quot;PASS&quot;,
&quot;details&quot;: &quot;独立 Edge / Playwright 会话重开 160/208 与 159/209,使用保存镜头,显示/隐藏模型并禁用投影进行像素比较;狐狸 4472 像素、卡车 7515 像素,均超过 500 阈值。证据保存在 visibility-evidence.json,截图 26 / 27;无写请求。&quot;
}
</pre>

## 当前行为与限制

- 手动保存从当前三维视口生成封面;新封面与场景内容、模型引用在同一次工程更新中关联保存。发布前的保存沿用该流程。
- 截图或封面上传失败时保留旧封面;工程是否保存成功以实际更新请求为准。版本冲突不覆盖其他已保存的内容。
- 自动恢复及重新打开工程不生成或上传新封面;恢复后的修改需由用户保存后更新封面。
- 列表按创建时间倒序展示。再次保存不会改变创建时间,也不应因此改变创建时间排序。
- 牛奶卡车场景保存的默认镜头中,车身部分被场景立柱遮挡;真实网格可见且像素差异验证通过。需要观察完整车身时可旋转镜头。

报告来自 evidence.json,未修改原始证据;所有引用图片已校验存在且非空,点击可查看原图。通过数随证据更新。

BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


BIN
View File


+ 183
- 0
reports/scene-live-20260905/visibility-evidence.json View File

@@ -0,0 +1,183 @@
{
"generatedAt": "2026-09-05T11:32:36.450Z",
"baseURL": "http://127.0.0.1:6180",
"browser": "Microsoft Edge / Playwright",
"method": "Fresh context; saved camera; compare rendered pixels with model roots visible versus hidden; model shadow casting disabled in both samples.",
"threshold": {
"sampleWidth": 320,
"sampleHeight": 180,
"minimumChangedPixelsExclusive": 500,
"minimumRgbDelta": 12
},
"status": "PASS",
"results": [
{
"sceneId": "160",
"modelProjectId": "157",
"modelVersionId": "201",
"label": "狐狸野外训练区",
"screenshot": "screenshots/26-fox-visual-regression.png",
"status": "PASS",
"blockedWrites": [],
"session": {
"role": "teacher",
"roleSwitchAttempts": 1
},
"project": {
"id": "160",
"name": "实测场景-狐狸野外训练区-20260905",
"status": "PUBLISHED",
"currentVersionId": "208",
"publishedVersionId": "208"
},
"visibility": {
"camera": {
"position": [
-2.508014030799152,
4.171439329448699,
5.794358645466257
],
"target": [
-7.512232860974557,
1.4059499759307126,
-2.2204460492503126e-16
],
"fov": 45
},
"ready": true,
"pendingLoads": 0,
"modelRoots": [
{
"name": "狐狸野外巡检目标",
"sceneObjectId": "OBJ-1788605916565-6",
"targetProjectId": "157",
"targetVersionId": "201",
"visible": true,
"meshes": 1,
"visibleMeshes": 1,
"skinnedMeshes": 1,
"position": [
-7.5,
0.10930174852949677,
0.3440109341705351
],
"scale": [
0.03616375932570189,
0.03616375932570189,
0.03616375932570189
],
"bounds": {
"min": [
-7.995172666000524,
3.037011225837105e-9,
-3.0000000000000004
],
"max": [
-7.029293055948589,
2.811899948824414,
3
],
"size": [
0.9658796100519353,
2.811899945787403,
6
]
},
"finiteBounds": true
}
],
"sample": {
"width": 320,
"height": 180,
"changedPixels": 4472,
"changedPercentage": 7.764,
"rgbDeltaSum": 683026
}
}
},
{
"sceneId": "159",
"modelProjectId": "156",
"modelVersionId": "204",
"label": "牛奶卡车检验区",
"screenshot": "screenshots/27-truck-visual-regression.png",
"status": "PASS",
"blockedWrites": [],
"session": {
"role": "teacher",
"roleSwitchAttempts": 1
},
"project": {
"id": "159",
"name": "实测场景-牛奶卡车检验区-20260905",
"status": "PUBLISHED",
"currentVersionId": "209",
"publishedVersionId": "209"
},
"visibility": {
"camera": {
"position": [
10.565439077211714,
5.0456876852867705,
7.37050840519251
],
"target": [
4.2,
1.695456592017448,
0
],
"fov": 45
},
"ready": true,
"pendingLoads": 0,
"modelRoots": [
{
"name": "牛奶卡车检验主装备",
"sceneObjectId": "OBJ-1788605199171-2",
"targetProjectId": "156",
"targetVersionId": "204",
"visible": true,
"meshes": 5,
"visibleMeshes": 5,
"skinnedMeshes": 0,
"position": [
4.2,
0.20617168998977797,
-0.4356763541543951
],
"scale": [
1.2323086149245381,
1.2323086149245381,
1.2323086149245381
],
"bounds": {
"min": [
2.4796971394839336,
0,
-3.0000000000000004
],
"max": [
5.920302860516067,
3.390913184034896,
3.0000000000000004
],
"size": [
3.4406057210321332,
3.390913184034896,
6.000000000000001
]
},
"finiteBounds": true
}
],
"sample": {
"width": 320,
"height": 180,
"changedPixels": 7515,
"changedPercentage": 13.047,
"rgbDeltaSum": 948690
}
}
}
]
}

+ 158
- 0
tools/build-model-cover-report.mjs View File

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

const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/model-cover-20260905')
const sourcePath = path.join(reportDir, 'evidence.json')
const sensitiveKey = /password|secret|token|ticket|authorization|cookie|signature|credential/i
const cleanText = value => String(value ?? '')
.replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
.replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[已隐藏]@')
.replace(/\b((?:access|refresh)?[_-]?token|password|secret|uploadTicket|signature|api[_-]?key)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)/gi, '$1=[已隐藏]')

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 data = sanitize(JSON.parse(fs.readFileSync(sourcePath, 'utf8').replace(/^\uFEFF/, '')))
const evidence = Array.isArray(data.evidence) ? data.evidence : []
const projects = Array.isArray(data.projects) ? data.projects : []
const issues = Array.isArray(data.issues) ? data.issues : data.issues ? [data.issues] : []
const commands = Array.isArray(data.commands) ? data.commands : data.commands ? [data.commands] : []
const passCount = evidence.filter(item => item.status === 'PASS').length
const nonPassCount = evidence.length - passCount
const imageFields = new Set(['screenshot', 'screenshots', 'coverImage', 'image', 'finalListScreenshot'])
const imagePaths = new Set()

function collectImages(value, key = '') {
if (typeof value === 'string' && imageFields.has(key) && value) imagePaths.add(value)
else if (Array.isArray(value)) value.forEach(item => collectImages(item, key))
else if (value && typeof value === 'object') {
Object.entries(value).forEach(([childKey, item]) => collectImages(item, childKey))
}
}
collectImages(data)

const realReportDir = fs.realpathSync(reportDir)
for (const relative of imagePaths) {
const normalized = relative.replaceAll('\\', '/')
if (path.isAbsolute(relative) || /^[a-z][a-z\d+.-]*:/i.test(relative)
|| !/\.(png|jpe?g|webp)$/i.test(relative)) {
throw new Error(`图片必须是报告目录中的 PNG/JPEG/WebP 相对路径:${relative}`)
}
const absolute = path.resolve(reportDir, normalized)
const underRoot = path.relative(reportDir, absolute)
if (underRoot.startsWith('..') || path.isAbsolute(underRoot)
|| !fs.existsSync(absolute) || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size === 0) {
throw new Error(`图片不存在、为空或越出报告目录:${relative}`)
}
const realRelative = path.relative(realReportDir, fs.realpathSync(absolute))
if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) {
throw new Error(`图片链接目标越出报告目录:${relative}`)
}
}

const html = value => cleanText(value).replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[char])
const md = value => cleanText(value).replaceAll('\\', '\\\\').replaceAll('|', '\\|').replace(/\r?\n/g, ' ')
const url = value => String(value).replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/')
const pretty = value => typeof value === 'string' ? value : JSON.stringify(value, null, 2)
const status = value => value || '未记录'
const statusClass = value => value === 'PASS' ? 'pass' : value === 'FAIL' ? 'fail' : 'neutral'
const picturesOf = item => [...new Set([
item.screenshot,
...(Array.isArray(item.screenshots) ? item.screenshots : []),
item.coverImage,
].filter(Boolean))]
const figure = (src, caption, className = '') => src
? `<figure class="${className}"><a href="${html(url(src))}" target="_blank" rel="noopener"><img src="${html(url(src))}" alt="${html(caption)}" loading="lazy"></a><figcaption>${html(caption)} · 点击查看原图</figcaption></figure>`
: '<p class="muted">尚未记录图片。</p>'
const mdImage = (src, caption) => src ? `[![${md(caption)}](${url(src)})](${url(src)})` : '尚未记录图片。'

const finalList = data.finalListScreenshot || [...evidence].reverse().find(item => item.screenshot
&& (/最终.*(?:列表|模型|封面)/.test(item.title || '') || /final.*(?:covers|list)/i.test(item.screenshot)))?.screenshot
const cleanup = evidence.find(item => String(item.details?.id) === '158' && item.status === 'PASS'
&& (item.details?.readAfterDelete === 404 || /清理|软删/.test(item.title || '')))
const cleanupText = cleanup
? '首次导入验证使用的临时项目 158 已通过业务软删除,删除后读取返回 404;报告保留操作证据,不宣称物理文件已删除。'
: '临时项目 158 的业务软删除结果尚未在 evidence.json 中完整记录,请以补充后的证据为准。'
const retainedText = data.retainedData ? pretty(data.retainedData) : projects.length
? `项目 ${projects.map(item => item.id).join('、')} 保留在开发环境,供继续查看模型、封面及历史版本。`
: '尚未记录需要保留的项目。'
const limitations = [
'仅手动保存和首次导入模型时生成或更新封面,封面取自当前三维视口。',
'自动保存只保存工程变更,不重新截图或上传封面。',
'截图或封面上传失败时保留旧封面,并提示失败;工程保存仍按其实际请求结果判断。',
]
const featureText = '截图封面功能已实施并完成下列真实浏览器验证:视口截图作为受控图片资产与工程关联保存,列表读取真实封面,模型列表按创建时间倒序显示。'
const environment = `${data.date || '日期未记录'} · ${data.baseURL || '地址未记录'} · ${data.browser || '浏览器未记录'} · ${data.role || '角色未记录'}`
const commandParts = item => typeof item === 'string'
? { command: item, result: '未记录执行结果' }
: { command: item.command || item.cmd || item.name || '命令未记录',
result: pretty(Object.fromEntries(Object.entries(item).filter(([key]) => !['command', 'cmd', 'name'].includes(key)))) }

const outputHtml = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>模型截图封面验证报告 · ${html(data.date)}</title>
<style>
:root{color-scheme:light;--ink:#172b34;--muted:#536872;--line:#dce5e7;--accent:#10664e}*{box-sizing:border-box}body{margin:0;background:#f2f6f6;color:var(--ink);font:15px/1.7 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1180px;margin:0 auto;padding:40px 28px 60px}header{padding-bottom:22px;border-bottom:1px solid var(--line)}.eyebrow{font-size:12px;letter-spacing:.14em;color:var(--accent);font-weight:700}h1{font-size:32px;line-height:1.35;margin:8px 0 12px}h2{font-size:23px;margin:32px 0 15px}h3{font-size:17px;margin:0 0 8px}p{margin:8px 0 12px}.muted,figcaption{color:var(--muted)}a{color:var(--accent);text-underline-offset:3px}.stats{display:flex;gap:12px;flex-wrap:wrap;margin:22px 0}.stat{background:white;border:1px solid var(--line);border-radius:10px;padding:12px 18px;min-width:150px}.stat strong{font-size:25px;display:block}.covers,.checks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.card,.check,.panel{background:white;border:1px solid var(--line);border-radius:12px;padding:20px;min-width:0}.cover-meta{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;font-size:13px;margin-top:12px}.cover-meta dt{color:var(--muted)}.cover-meta dd{margin:0;overflow-wrap:anywhere}figure{margin:12px 0 0}figure img{display:block;width:100%;height:auto;border:1px solid var(--line);border-radius:7px;background:#edf1f1}.cover-image img{aspect-ratio:640/346;object-fit:contain}.evidence-images{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.evidence-images.single{grid-template-columns:1fr}figcaption{font-size:12px;margin-top:5px}.badge{display:inline-block;font-size:12px;font-weight:700;padding:1px 9px;border-radius:12px;white-space:nowrap}.pass{background:#e1f3eb;color:#0c6144}.fail{background:#fde8e8;color:#ab2323}.neutral{background:#edf0f4;color:#425566}.check-title{display:flex;gap:10px;align-items:flex-start;justify-content:space-between}details{margin-top:12px}summary{cursor:pointer;color:var(--accent);font-size:13px}pre{margin:10px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.6 ui-monospace,Consolas,monospace;background:#f4f7f8;padding:12px;border-radius:7px;max-height:360px;overflow:auto}table{width:100%;border-collapse:collapse;font-size:13px}th,td{padding:9px 12px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}th{background:#f4f7f8}td{overflow-wrap:anywhere}ul{padding-left:22px}.command{margin-bottom:12px}.command pre{max-height:280px}footer{margin-top:28px;color:var(--muted);font-size:12px}@media(max-width:760px){main{padding:24px 16px 40px}h1{font-size:26px}.covers,.checks{grid-template-columns:1fr}.stat{flex:1;min-width:100px}.panel,.card,.check{padding:15px}.table-wrap{overflow:auto}}@media print{body{background:white}main{max-width:none;padding:0}details{display:block}pre{max-height:none}.card,.check,figure{break-inside:avoid}a{color:inherit}.checks{display:block}.check{margin-bottom:14px}}
</style></head><body><main>
<header><div class="eyebrow">MODEL COVER · IMPLEMENTED</div><h1>模型截图封面验证报告</h1><p>${html(featureText)}</p><p class="muted">${html(environment)}</p><a href="report.md">Markdown 报告</a></header>
<div class="stats"><div class="stat"><strong>${passCount} / ${evidence.length}</strong>证据项通过</div><div class="stat"><strong>${nonPassCount}</strong>非 PASS 证据项</div><div class="stat"><strong>${imagePaths.size}</strong>图片文件已校验</div></div>
<h2>最终模型封面</h2><div class="covers">${projects.map(project => {
const cover = project.cover || {}
return `<article class="card"><h3>${html(project.name || `项目 ${project.id}`)}</h3>${figure(cover.image, `项目 ${project.id} 最终封面`, 'cover-image')}<dl class="cover-meta"><dt>项目 / 最终状态</dt><dd>${html(project.id)} / ${html(status(project.finalStatus))}</dd><dt>工程版本 / 封面资产</dt><dd>${html(project.finalVersionId || cover.versionId || '未记录')} / ${html(cover.coverAssetId || '未记录')}</dd><dt>图片规格</dt><dd>${html(cover.width || '?')} × ${html(cover.height || '?')} · ${html(cover.bytes ?? '?')} 字节</dd></dl><details><summary>封面持久化证据</summary><pre>${html(pretty(cover))}</pre></details></article>`
}).join('')}</div>
<h2>最终列表现场</h2><section class="panel">${figure(finalList, '两条保留模型的真实封面与创建时间倒序列表')}</section>
<h2>验证记录</h2><p class="muted">通过数只统计 status 为 PASS 的记录;故障注入中预期的 503、401 或 404 不等于用例失败。</p><div class="checks">${evidence.map((item, index) => {
const pictures = picturesOf(item)
const extra = Object.fromEntries(Object.entries(item).filter(([key]) => !['title', 'status', 'screenshot', 'screenshots', 'coverImage'].includes(key)))
return `<article class="check"><div class="check-title"><h3>${index + 1}. ${html(item.title)}</h3><span class="badge ${statusClass(item.status)}">${html(status(item.status))}</span></div>${Object.keys(extra).length ? `<details><summary>检查细节</summary><pre>${html(pretty(extra))}</pre></details>` : ''}<div class="evidence-images ${pictures.length === 1 ? 'single' : ''}">${pictures.map(src => figure(src, /\.jpe?g$/i.test(src) ? '实际生成封面' : '浏览器现场')).join('')}</div></article>`
}).join('')}</div>
<h2>数据保留与清理</h2><section class="panel"><p>${html(retainedText)}</p><div class="table-wrap"><table><thead><tr><th>项目</th><th>初始状态</th><th>最终状态</th></tr></thead><tbody>${projects.map(project => `<tr><td>${html(project.id)} · ${html(project.name)}</td><td>${html(status(project.originalStatus))}</td><td>${html(status(project.finalStatus))}</td></tr>`).join('')}</tbody></table></div><p>${html(cleanupText)}</p></section>
<h2>问题记录</h2><section class="panel">${issues.length ? issues.map(issue => `<pre>${html(pretty(issue))}</pre>`).join('') : '<p>当前证据未登记未解决问题。</p>'}</section>
<h2>实际命令与结果</h2><section class="panel">${commands.length ? commands.map(item => { const entry = commandParts(item); return `<div class="command"><h3>${html(entry.command)}</h3><pre>${html(entry.result)}</pre></div>` }).join('') : '<p>本次 evidence.json 尚未记录命令结果;不据此宣称构建或命令行测试通过。</p>'}</section>
<h2>当前行为与限制</h2><section class="panel"><ul>${limitations.map(item => `<li>${html(item)}</li>`).join('')}</ul></section>
<footer>报告由 evidence.json 生成,未修改原始证据。所有引用图片在生成时校验为存在且非空;点击图片可查看原图。</footer>
</main></body></html>`

const outputMarkdown = [
'# 模型截图封面验证报告', '', featureText, '', environment, '',
`证据通过:**${passCount} / ${evidence.length}**;非 PASS:${nonPassCount};已校验图片:${imagePaths.size}。`, '',
'## 最终模型封面', '',
...projects.flatMap(project => [
`### ${project.name || `项目 ${project.id}`}`, '',
`项目 ${project.id};最终状态:${status(project.finalStatus)};工程版本 ID:${project.finalVersionId || project.cover?.versionId || '未记录'};封面资产 ID:${project.cover?.coverAssetId || '未记录'}。`, '',
mdImage(project.cover?.image, `项目 ${project.id} 最终封面`), '',
]),
'## 最终列表现场', '', mdImage(finalList, '最终两条模型封面列表'), '',
'## 验证记录', '', '通过统计只认 status 为 PASS;预期故障响应不视为用例失败。', '',
'| 序号 | 检查项 | 结果 | 图片 |', '|---|---|---|---|',
...evidence.map((item, index) => `| ${index + 1} | ${md(item.title)} | ${md(status(item.status))} | ${picturesOf(item).map((src, i) => `[图片 ${i + 1}](${url(src)})`).join(' · ') || '—'} |`), '',
...evidence.filter(item => item.details !== undefined).flatMap(item => [
`<details><summary>${html(item.title)}:检查细节</summary>`, '', '<pre>', html(pretty(item.details)), '</pre>', '', '</details>', '',
]),
'## 数据保留与清理', '', retainedText, '',
'| 项目 | 初始状态 | 最终状态 |', '|---|---|---|',
...projects.map(project => `| ${md(project.id)} · ${md(project.name)} | ${md(status(project.originalStatus))} | ${md(status(project.finalStatus))} |`), '',
cleanupText, '', '## 问题记录', '',
...(issues.length ? issues.flatMap(issue => [pretty(issue), '']) : ['当前证据未登记未解决问题。', '']),
'## 实际命令与结果', '',
...(commands.length ? commands.flatMap(item => { const entry = commandParts(item); return [`**${md(entry.command)}**`, '', '<pre>', html(entry.result), '</pre>', ''] })
: ['本次 evidence.json 尚未记录命令结果;不据此宣称构建或命令行测试通过。', '']),
'## 当前行为与限制', '', ...limitations.map(item => `- ${item}`), '',
'报告由 evidence.json 生成,未修改原始证据;图片均已校验存在且非空,点击图片可查看原图。', '',
].join('\n')

fs.writeFileSync(path.join(reportDir, 'index.html'), outputHtml, 'utf8')
fs.writeFileSync(path.join(reportDir, 'report.md'), outputMarkdown, 'utf8')
console.log(JSON.stringify({ report: 'reports/model-cover-20260905/index.html', passed: passCount,
total: evidence.length, nonPass: nonPassCount, imagesValidated: imagePaths.size, commandsRecorded: commands.length }))

+ 265
- 0
tools/build-model-live-report.mjs View File

@@ -0,0 +1,265 @@
import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const DEFAULT_REPORT_DIR = fileURLToPath(new URL('../reports/model-live-20260905/', import.meta.url));
const missing = '未记录';
const has = (value) => value !== undefined && value !== null && value !== '';
const list = (value) => Array.isArray(value) ? value : has(value) ? [value] : [];
const escape = (value) => String(value).replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
const markdown = (value) => String(value).replace(/([\\`*_[\]<>|])/g, '\\$1').replace(/\r?\n/g, ' / ');
const display = (value) => has(value) ? String(value) : missing;
const first = (...values) => values.find(has);
const labels = {
status: '状态', result: '结果', passed: '检查通过', success: '操作成功',
server: '服务端', live: '页面运行时', sha256: 'SHA-256', bytes: '文件字节数',
mutations: '变更记录', name: '名称', code: '编码', id: 'ID', httpStatus: 'HTTP 状态',
message: '说明', details: '详情', note: '备注', reason: '原因', issue: '问题',
saved: '已保存', persisted: '已持久化', reopened: '重开', published: '发布',
publishedAt: '发布时间', version: '版本', title: '标题', file: '来源文件',
sourcePath: '素材路径', assetUrl: '资源地址', modelUrl: '模型地址', url: '地址',
objectCount: '对象数', meshCount: '网格数', animationCount: '动画数',
animations: '动画', clip: '动画编号', clipName: '动画名称', clips: '动画数',
playing: '正在播放', time: '时间', before: '变更前', after: '变更后',
position: '位置', rotation: '旋转', scale: '缩放', visible: '可见',
retained: '保留状态', cleanup: '清理记录', retainedData: '保留数据',
nodes: '节点数', meshes: '网格数', materials: '材质数', textures: '纹理数',
type: '类别', command: '执行命令', rows: '列表核验结果',
};

function status(value) {
if (value && typeof value === 'object') value = first(value.status, value.result, value.passed, value.success);
if (!has(value)) return { label: missing, tone: 'unknown' };
if (value === true) return { label: '通过', tone: 'pass' };
if (value === false) return { label: '失败', tone: 'fail' };
const raw = String(value);
const normalized = raw.toUpperCase();
if (['PASS', 'PASSED', 'SUCCESS', 'OK', '通过', '成功'].includes(normalized)) return { label: '通过', tone: 'pass' };
if (['FAIL', 'FAILED', 'ERROR', '失败', '错误'].includes(normalized)) return { label: '失败', tone: 'fail' };
if (['SKIP', 'SKIPPED', '跳过'].includes(normalized)) return { label: '跳过', tone: 'unknown' };
if (['PENDING', 'TODO', 'NOT_RUN', '未执行', '待验证'].includes(normalized)) return { label: '待验证', tone: 'unknown' };
if (normalized === 'PUBLISHED') return { label: '已发布', tone: 'info' };
if (normalized === 'DRAFT') return { label: '草稿', tone: 'info' };
return { label: raw, tone: 'info' };
}

function safeText(value) {
return String(value)
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [已隐藏]')
.replace(/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, '[令牌已隐藏]')
.slice(0, 700);
}

// Keep useful scalar evidence, with bounded nesting; never dump scene documents.
function details(value, prefix = '', depth = 0) {
if (!has(value)) return [];
if (typeof value !== 'object') return [[prefix || '记录', safeText(value)]];
if (Array.isArray(value)) {
if (!value.length) return [[prefix || '记录', '0 项']];
if (value.every((item) => item === null || typeof item !== 'object')) {
return [[prefix || '记录', safeText(value.slice(0, 8).join('、')) + (value.length > 8 ? `(共 ${value.length} 项)` : '')]];
}
if (depth >= 3) return [[prefix || '记录', `${value.length} 项,完整内容见 evidence.json`]];
return value.slice(0, 3).flatMap((item, index) => details(item, `${prefix || '记录'} ${index + 1}`, depth + 1))
.concat(value.length > 3 ? [[prefix || '记录', `共 ${value.length} 项,完整内容见 evidence.json`]] : []);
}
const rows = [];
for (const [key, item] of Object.entries(value)) {
if (['screenshot', 'screenshots'].includes(key) || !has(item)) continue;
const label = [prefix, labels[key] || key].filter(Boolean).join(' / ');
if (/password|passwd|secret|token|authorization|cookie|credential/i.test(key)) {
rows.push([label, '[已隐藏]']);
} else if (/^(modelObjects|objects|nodes|meshes|materials|textures|buffers|bufferViews|accessors)$/i.test(key)) {
rows.push([label, typeof item === 'object'
? `${Array.isArray(item) ? item.length : Object.keys(item).length} 项(省略明细)`
: safeText(item)]);
} else if (depth >= 3 && typeof item === 'object') {
rows.push([label, '已记录,完整内容见 evidence.json']);
} else {
rows.push(...details(item, label, depth + 1));
}
if (rows.length >= 24) {
rows.push(['更多记录', '见 evidence.json']);
break;
}
}
return rows;
}

function tableHtml(headers, rows) {
return `<div class="table-wrap"><table><thead><tr>${headers.map((cell) => `<th>${escape(cell)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
}

function tableMarkdown(headers, rows) {
return [`| ${headers.map(markdown).join(' | ')} |`, `| ${headers.map(() => '---').join(' | ')} |`, ...rows.map((row) => `| ${row.map(markdown).join(' | ')} |`)].join('\n');
}

function badge(value) {
const item = status(value);
return `<span class="badge ${item.tone}">${escape(item.label)}</span>`;
}

function record(value) {
const rows = details(value);
return {
html: rows.length ? `<dl class="records">${rows.map(([key, item]) => `<div><dt>${escape(key)}</dt><dd>${escape(item)}</dd></div>`).join('')}</dl>` : `<p class="muted">${missing}</p>`,
md: rows.length ? rows.map(([key, item]) => `- ${markdown(key)}:${markdown(item)}`).join('\n') : missing,
};
}

export function renderModelLiveReport(evidence, { reportDir = DEFAULT_REPORT_DIR } = {}) {
if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) throw new Error('evidence.json 必须为对象');
const projects = list(evidence.projects).filter((item) => item && typeof item === 'object');
const steps = projects.flatMap((project) => list(project.steps));
const counts = { pass: 0, fail: 0, other: 0 };
for (const step of steps) {
const tone = status(step).tone;
counts[tone === 'pass' || tone === 'fail' ? tone : 'other'] += 1;
}
const title = `真实模型制作实测报告 · ${display(evidence.date)}`;
const summary = `已记录 ${projects.length}/2 个模型项目、${steps.length} 项步骤:${counts.pass} 项通过,${counts.fail} 项失败,${counts.other} 项其他或未记录结论。`;
const environment = [
['测试日期', display(evidence.date)], ['页面地址', display(evidence.baseURL)],
['浏览器', display(evidence.browser)], ['账号角色', display(evidence.role)],
['账号显示名称', display(evidence.accountDisplayName)],
];
const html = [`<header><p class="eyebrow">开发环境 · 真实模型制作</p><h1>${escape(title)}</h1><p>${escape(summary)}</p><p class="links"><a href="report.md">Markdown 报告</a><a href="evidence.json">原始证据</a></p></header><main>`, '<section><h2>环境与身份</h2>', tableHtml(['项目', '记录'], environment.map((row) => row.map(escape))), '</section>'];
const md = [`# ${title}`, summary, '统计仅来自明确记录的步骤结论;缺失字段、仅有数据或仅有截图均不视为通过。', '## 环境与身份', tableMarkdown(['项目', '记录'], environment)];
html.push('<p class="notice">统计仅来自明确记录的步骤结论;缺失字段、仅有数据或仅有截图均不视为通过。</p>');
const seenPictures = new Set();
let imageCount = 0;
const imageProblems = [];

function screenshots(value, fallback) {
const pictures = [];
function visit(item, caption) {
if (!item || typeof item !== 'object') return;
if (Array.isArray(item)) return item.forEach((child) => visit(child, caption));
const label = first(item.step, item.title, item.caption, item.name, caption);
for (const field of ['screenshot', 'screenshots']) {
for (const picture of list(item[field])) {
const location = typeof picture === 'string' ? picture : first(picture?.path, picture?.screenshot, picture?.file);
if (has(location)) pictures.push({ path: String(location).replaceAll('\\', '/'), title: first(picture?.caption, picture?.title, label, fallback) });
}
}
for (const [key, child] of Object.entries(item)) {
if (!['screenshot', 'screenshots', 'modelObjects', 'objects', 'nodes', 'meshes', 'materials', 'textures', 'buffers', 'bufferViews', 'accessors'].includes(key) && typeof child === 'object') visit(child, label);
}
}
visit(value, fallback);
const htmlPictures = [];
const mdPictures = [];
for (const picture of pictures) {
if (seenPictures.has(picture.path)) continue;
seenPictures.add(picture.path);
const safePath = /^screenshots\/[a-zA-Z0-9_\-./\u3400-\u9fff ]+\.(png|jpe?g|webp)$/i.test(picture.path) && !picture.path.split('/').some((part) => part === '.' || part === '..' || !part);
if (!safePath || !existsSync(path.join(reportDir, picture.path))) {
const problem = `${picture.title}:${safePath ? '截图文件未找到' : '截图路径不受支持'}(${picture.path})`;
imageProblems.push(problem);
htmlPictures.push(`<p class="notice">${escape(problem)}</p>`);
mdPictures.push(markdown(problem));
continue;
}
imageCount += 1;
const href = picture.path.split('/').map(encodeURIComponent).join('/');
htmlPictures.push(`<figure><a href="${href}"><img loading="lazy" src="${href}" alt="${escape(picture.title)}"></a><figcaption>${escape(picture.title)}</figcaption></figure>`);
mdPictures.push(`![${markdown(picture.title)}](${href})`);
}
return { html: htmlPictures.length ? `<div class="gallery">${htmlPictures.join('')}</div>` : '', md: mdPictures.join('\n\n') };
}

for (const [index, project] of projects.entries()) {
const name = display(project.name);
const heading = `模型 ${index + 1}:${name}`;
const nativeClips = project.imported?.live?.clips;
const identity = [
['项目名称', name], ['项目编码', display(project.code)], ['项目 ID', display(project.id)],
['来源素材', display(first(project.sourcePath, project.file))],
['测试节点', display(project.mesh)],
['动画', `${display(project.clipName)};编号 ${display(project.clip)};数量 ${display(project.clips)}`],
['原生动画片段', Array.isArray(nativeClips)
? `${nativeClips.length} 段${nativeClips.length ? `:${nativeClips.map((clip) => display(clip?.name)).join('、')}` : ''}`
: missing],
];
html.push(`<section id="model-${index + 1}"><h2>${escape(heading)}</h2>`, tableHtml(['项目', '记录'], identity.map((row) => row.map(escape))));
md.push(`## ${heading}`, tableMarkdown(['项目', '记录'], identity));
if (has(project.description)) { html.push(`<p>${escape(safeText(project.description))}</p>`); md.push(safeText(project.description)); }
const projectSteps = list(project.steps);
html.push('<h3>操作步骤</h3>'); md.push('### 操作步骤');
if (projectSteps.length) {
const rows = projectSteps.map((step, stepIndex) => [String(stepIndex + 1), display(step?.step), status(step).label,
details(typeof step === 'object' && step !== null ? Object.fromEntries(Object.entries(step).filter(([key]) => !['step', 'status', 'result', 'passed', 'success'].includes(key))) : undefined).map(([key, value]) => `${key}:${value}`).join(';') || missing]);
html.push(tableHtml(['序号', '步骤', '结论', '证据摘要'], rows.map((row, stepIndex) => row.map((cell, cellIndex) => cellIndex === 2 ? badge(projectSteps[stepIndex]) : escape(cell)))));
md.push(tableMarkdown(['序号', '步骤', '结论', '证据摘要'], rows));
} else { html.push(`<p class="muted">${missing}</p>`); md.push(missing); }
const stages = [
['导入校验', project.imported], ['编辑与保存', first(project.edited, project.saved)],
['保存后重开', project.reopened], ['动画检查', first(project.animationCheck, project.animation, project.animationChecks)],
['发布状态', project.published],
];
html.push('<div class="stages">');
for (const [stageTitle, value] of stages) {
const result = record(value);
const conclusion = has(value) && status(value).label === missing ? '已记录,未给出结论' : status(value).label;
html.push(`<article><h3>${escape(stageTitle)}</h3><p class="muted">${escape(conclusion)}</p>${result.html}</article>`);
md.push(`### ${stageTitle}`, `结论:${conclusion}`, result.md);
}
html.push('</div>');
const pictures = screenshots(project, name);
if (pictures.html) { html.push('<h3>现场截图</h3>', pictures.html); md.push('### 现场截图', pictures.md); }
html.push('</section>');
}
if (projects.length < 2) {
const warning = `当前仅记录 ${projects.length}/2 个项目,其余项目尚无证据。`;
html.push(`<p class="notice">${warning}</p>`); md.push(warning);
}
const supplemental = list(evidence.evidence);
if (supplemental.length) {
const titles = { 'final-list': '最终项目列表', build: '构建检查', scope: '实测范围' };
html.push('<section><h2>补充验证</h2>'); md.push('## 补充验证');
for (const [index, item] of supplemental.entries()) {
const heading = first(titles[item?.type], item?.title, item?.type, `补充记录 ${index + 1}`);
const result = record(item);
html.push(`<article><h3>${escape(heading)}</h3>${result.html}</article>`);
md.push(`### ${heading}`, result.md);
}
html.push('</section>');
}
const extraPictures = screenshots({ evidence: evidence.evidence, screenshots: evidence.screenshots, issues: evidence.issues }, '补充证据');
if (extraPictures.html) { html.push('<section><h2>补充截图</h2>', extraPictures.html, '</section>'); md.push('## 补充截图', extraPictures.md); }
const retention = record(first(evidence.retainedData, evidence.retention, evidence.dataRetention, evidence.cleanup));
const retainedRows = projects.map((project) => [display(project.name), display(project.id), display(project.code), has(project.retained) ? (typeof project.retained === 'boolean' ? project.retained ? '保留' : '不保留' : String(project.retained)) : '未记录保留/清理结论']);
html.push('<section><h2>遗留数据与保留说明</h2>', tableHtml(['项目', 'ID', '编码', '保留/清理结论'], retainedRows.map((row) => row.map(escape))), retention.html, '<p class="muted">本报告生成过程不修改项目数据。数据保留与清理结果以证据记录为准。</p></section>');
md.push('## 遗留数据与保留说明', tableMarkdown(['项目', 'ID', '编码', '保留/清理结论'], retainedRows), retention.md, '本报告生成过程不修改项目数据。数据保留与清理结果以证据记录为准。');
const issues = list(evidence.issues).map((issue) => record(issue));
projects.forEach((project) => list(project.issues).forEach((issue) => issues.push(record({ project: project.name, issue }))));
imageProblems.forEach((problem) => issues.push(record(problem)));
const noIssues = Array.isArray(evidence.issues) ? '问题数组为空;未执行或缺失的检查仍不视为通过。' : '问题清单未记录。';
html.push('<section><h2>问题与待确认项</h2>', issues.length ? issues.map((issue) => `<article class="issue">${issue.html}</article>`).join('') : `<p>${noIssues}</p>`, '</section>');
md.push('## 问题与待确认项', issues.length ? issues.map((issue, index) => `### ${index + 1}\n\n${issue.md}`).join('\n\n') : noIssues);
const footer = `共 ${imageCount} 张可用截图。完整结构化记录见 evidence.json;模型对象明细未展开。`;
html.push(`<footer>${escape(footer)}</footer></main>`); md.push(footer);
return {
html: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body>${html.join('\n')}</body></html>`,
markdown: `${md.join('\n\n')}\n`,
summary: { projects: projects.length, steps: steps.length, ...counts, screenshots: imageCount, missingScreenshots: imageProblems.length },
};
}

const STYLE = `*{box-sizing:border-box}body{margin:0;background:#eef3f2;color:#223c36;font:15px/1.7 "Microsoft YaHei","Segoe UI",sans-serif}header{padding:36px max(24px,calc((100vw - 1160px)/2));background:#153e34;color:#f5fffb}header h1{font-size:28px;line-height:1.45;margin:4px 0 12px}.eyebrow{font-size:13px;color:#b8dacf;margin:0}.links{display:flex;gap:24px}.links a{color:#d1f4e7}main{max-width:1200px;margin:24px auto;padding:0 20px 40px}section{background:white;border:1px solid #dce7e2;border-radius:12px;padding:28px;margin:20px 0}h2{font-size:22px;margin:0 0 18px}h3{font-size:17px;margin:18px 0 10px}p{margin:10px 0}a{color:#12674f}table{border-collapse:collapse;width:100%;font-size:14px}th,td{border:1px solid #dce7e2;text-align:left;vertical-align:top;padding:10px 12px;overflow-wrap:anywhere}th{background:#edf5f1}tbody tr:nth-child(even){background:#fafcfb}.table-wrap{overflow:auto}.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:13px;white-space:nowrap}.pass{color:#07513b;background:#dff5e8}.fail{color:#972328;background:#fee6e8}.unknown{color:#7c4b0d;background:#fff0d5}.info{color:#25566d;background:#e4f1f8}.notice{background:#fff5e1;border-left:4px solid #b5791a;padding:12px 16px;color:#6d4815}.muted{color:#576e65}.stages{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:20px 0}.stages article{border:1px solid #dce7e2;border-radius:8px;padding:0 16px 12px}.records{margin:8px 0;font-size:13px}.records>div{padding:6px 0;border-bottom:1px solid #edf2ef}.records dt{color:#5b7168}.records dd{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}.gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}figure{margin:0;border:1px solid #dce7e2;border-radius:8px;overflow:hidden;background:#f2f6f4}figure img{width:100%;height:auto;display:block}figcaption{padding:10px 14px;font-size:13px;overflow-wrap:anywhere}.issue{border-left:3px solid #b5791a;padding:0 14px;margin:16px 0}footer{font-size:13px;color:#576e65;margin:28px 0}@media(max-width:760px){header{padding:24px 20px}header h1{font-size:23px}main{padding:0 12px}section{padding:18px}.stages,.gallery{grid-template-columns:1fr}}@media print{body{background:white}header{background:white;color:#153e34;padding:0}header .eyebrow,header .links{display:none}main{max-width:none;padding:0}section{padding:18px 0;border:0;border-radius:0}.gallery{grid-template-columns:1fr}figure{break-inside:avoid}h2,h3{break-after:avoid}.stages{display:block}}`;

export async function buildModelLiveReport(reportDir = DEFAULT_REPORT_DIR) {
const evidence = JSON.parse((await readFile(path.join(reportDir, 'evidence.json'), 'utf8')).replace(/^\uFEFF/, ''));
const result = renderModelLiveReport(evidence, { reportDir });
await writeFile(path.join(reportDir, 'index.html'), result.html, 'utf8');
await writeFile(path.join(reportDir, 'report.md'), result.markdown, 'utf8');
return result.summary;
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
buildModelLiveReport().then((summary) => console.log(JSON.stringify(summary))).catch((error) => {
console.error(`报告生成失败:${error.message}`);
process.exitCode = 1;
});
}

+ 244
- 0
tools/build-scene-live-report.mjs View File

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

// Evidence is prepared by the real browser run. This script never changes it.
//
// Expected shape (all fields other than `evidence` are optional):
// {
// date, baseURL, browser, role,
// evidence: [{ title, status: 'PASS' | 'FAIL' | string, checks: [],
// screenshot: 'screenshots/example.png', screenshots: [{ path, caption }], details,
// superseded: true /* historical evidence excluded from current pass counts */ }],
// correction: { title, summary, previousConclusion, rootCauses: [], fixes: [],
// verification: [], screenshots: [{ path, caption }] },
// projects: [{ id, name, finalStatus, finalVersionId, modelId, modelVersionId,
// cover: { image, width, height, bytes, sizeBytes, assetId, sha256, storageUri } }],
// finalListScreenshot, issues, followUpIssues, commands, retainedData, cleanup
// }
// Image values must be report-relative PNG/JPEG/WebP paths. Sensitive fields are removed
// before rendering, so evidence may safely retain raw API responses locally.
const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/scene-live-20260905')
const sourcePath = path.join(reportDir, 'evidence.json')
const sensitiveKey = /password|secret|token|ticket|authorization|cookie|signature|credential|api[_-]?key/i
const cleanText = value => String(value ?? '')
.replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
.replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[已隐藏]@')
.replace(/\b((?:access|refresh)?[_-]?token|password|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 data = sanitize(JSON.parse(fs.readFileSync(sourcePath, 'utf8').replace(/^\uFEFF/, '')))
const asArray = value => Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]
const evidence = asArray(data.evidence)
const currentEvidence = evidence.filter(item => item.superseded !== true)
const historicalCount = evidence.length - currentEvidence.length
const correction = data.correction && typeof data.correction === 'object' ? data.correction : null
const projects = asArray(data.projects)
const commands = asArray(data.commands)
const allIssues = asArray(data.issues)
const issueText = issue => typeof issue === 'string' ? issue : JSON.stringify(issue ?? '')
const fsvcTicketFinding = /(?:\bfsvc\b[\s\S]{0,160}(?:upload[\s_-]*ticket|上传票据|票据)|(?:upload[\s_-]*ticket|上传票据|票据)[\s\S]{0,160}\bfsvc\b)/i
const isIndependent = issue => {
if (!issue) return false
if (typeof issue === 'object'
&& /^(?:existing|pre-existing|independent|follow-up)$/i.test(issue.scope || issue.category || '')) return true
const text = issueText(issue)
return fsvcTicketFinding.test(text)
|| /(?:既存|独立|后续).{0,30}票据|票据.{0,30}(?:既存|独立|后续)/.test(text)
}
const issues = allIssues.filter(issue => !isIndependent(issue))
const independentIssues = [...new Map([...allIssues.filter(isIndependent), ...asArray(data.followUpIssues)]
.map(issue => [JSON.stringify(issue), issue])).values()]
const passCount = currentEvidence.filter(item => item.status === 'PASS').length
const failCount = currentEvidence.filter(item => item.status === 'FAIL').length
const otherCount = currentEvidence.length - passCount - failCount

const html = value => cleanText(value).replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[char])
const md = value => html(value).replaceAll('\\', '\\\\').replace(/[|\[\]*_`]/g, char => `\\${char}`).replace(/\r?\n/g, ' ')
const url = value => String(value).replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/')
const pretty = value => typeof value === 'string' ? value : JSON.stringify(value, null, 2)
const recorded = value => value === undefined || value === null || value === '' ? '未记录' : value
const statusClass = value => value === 'PASS' ? 'pass' : value === 'FAIL' ? 'fail' : 'neutral'
const imageFields = new Set(['screenshot', 'screenshots', 'coverImage', 'image', 'finalListScreenshot'])
const imagePaths = new Set()

function imageRefs(value) {
return asArray(value).flatMap(item => {
if (!item) return []
if (typeof item === 'string') return [{ path: item, caption: '' }]
if (typeof item === 'object') {
const imagePath = item.path || item.src || item.image
if (typeof imagePath === 'string' && imagePath) return [{ path: imagePath, caption: item.caption || item.title || '' }]
}
throw new Error('图片引用应为相对路径字符串或 { path, caption } 对象。')
})
}

function collectImages(value) {
if (Array.isArray(value)) return value.forEach(collectImages)
if (!value || typeof value !== 'object') return
for (const [key, item] of Object.entries(value)) {
if (imageFields.has(key)) imageRefs(item).forEach(ref => imagePaths.add(ref.path))
else collectImages(item)
}
}
collectImages(data)

// Fail before writing either report when any referenced image is missing or unsafe.
const realReportDir = fs.realpathSync(reportDir)
for (const relative of imagePaths) {
if (path.isAbsolute(relative) || /^[a-z][a-z\d+.-]*:/i.test(relative)
|| !/\.(png|jpe?g|webp)$/i.test(relative)) {
throw new Error(`图片必须是报告目录中的 PNG/JPEG/WebP 相对路径:${relative}`)
}
const absolute = path.resolve(reportDir, relative.replaceAll('\\', '/'))
const underRoot = path.relative(reportDir, absolute)
if (underRoot === '..' || underRoot.startsWith(`..${path.sep}`) || path.isAbsolute(underRoot)
|| !fs.existsSync(absolute) || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size === 0) {
throw new Error(`图片不存在、为空或越出报告目录:${relative}`)
}
const realRelative = path.relative(realReportDir, fs.realpathSync(absolute))
if (realRelative === '..' || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) {
throw new Error(`图片链接目标越出报告目录:${relative}`)
}
}

function picturesOf(item) {
const refs = [item.screenshot, item.screenshots, item.coverImage].flatMap(imageRefs)
return refs.filter((ref, index) => refs.findIndex(other => other.path === ref.path) === index)
.map(ref => item.superseded === true
? { ...ref, caption: `历史截图(不作为修复后的视觉通过证据):${ref.caption || '原测试现场'}` }
: ref)
}
const coverOf = project => imageRefs(project.cover?.image || project.coverImage || project.cover?.screenshot)[0]
const finalList = imageRefs(data.finalListScreenshot)[0] || [...evidence].reverse()
.filter(item => /(?:最终|倒序|排序).*列表|列表.*(?:最终|倒序|排序)/.test(item.title || ''))
.flatMap(picturesOf)[0]
const figure = (ref, caption, className = '') => ref
? `<figure class="${className}"><a href="${html(url(ref.path))}" target="_blank" rel="noopener"><img src="${html(url(ref.path))}" alt="${html(ref.caption || caption)}" loading="lazy"></a><figcaption>${html(ref.caption || caption)} · 点击查看原图</figcaption></figure>`
: '<p class="muted">尚未记录本地图片。</p>'
const mdImage = (ref, caption) => ref
? `[![${md(ref.caption || caption)}](${url(ref.path)})](${url(ref.path)})`
: '尚未记录本地图片。'
const extraOf = item => Object.fromEntries(Object.entries(item)
.filter(([key]) => !['title', 'status', 'checks', 'screenshot', 'screenshots', 'coverImage'].includes(key)))
const checkText = check => typeof check === 'string' ? check : pretty(check)
const evidenceStatus = item => item.superseded === true ? '历史记录(已更正)' : recorded(item.status)
const correctionSections = correction ? [
['原结论与漏验', asArray(correction.previousConclusion)],
['根因', asArray(correction.rootCauses)],
['修复内容', asArray(correction.fixes)],
['修复后验证', asArray(correction.verification)],
].filter(([, items]) => items.length) : []
const correctionPictures = correction ? picturesOf(correction) : []
const correctionHtml = correction ? `<section class="correction" aria-label="测试结论更正"><h2>${html(correction.title || '狐狸场景验证更正')}</h2>${correction.summary ? `<p>${html(correction.summary)}</p>` : ''}${correctionSections.map(([title, items]) => `<h3>${html(title)}</h3><ul>${items.map(item => `<li>${html(checkText(item))}</li>`).join('')}</ul>`).join('')}<div class="images ${correctionPictures.length === 1 ? 'single' : ''}">${correctionPictures.map(ref => figure(ref, '修复后视觉验证')).join('')}</div><p class="muted">下方标为“历史记录(已更正)”的检查及截图保留用于追溯,不计入当前通过率,也不作为狐狸已经可见的证据。</p></section>` : ''
const correctionMarkdown = correction ? [
`## ${md(correction.title || '狐狸场景验证更正')}`, '',
...(correction.summary ? [`> **测试结论更正:** ${md(correction.summary)}`, ''] : []),
...correctionSections.flatMap(([title, items]) => [`### ${md(title)}`, '', ...items.map(item => `- ${md(checkText(item))}`), '']),
...correctionPictures.flatMap(ref => [mdImage(ref, '修复后视觉验证'), '']),
'下方标为“历史记录(已更正)”的检查及截图保留用于追溯,不计入当前通过率,也不作为狐狸已经可见的证据。', '',
] : []
const projectStatus = project => recorded(project.finalStatus || project.status)
const projectVersion = project => recorded(project.finalVersionId || project.currentVersionId || project.cover?.versionId)
const commandParts = item => typeof item === 'string'
? { command: item, result: '未记录执行结果' }
: { command: item.command || item.cmd || item.name || '命令未记录',
result: pretty(Object.fromEntries(Object.entries(item).filter(([key]) => !['command', 'cmd', 'name'].includes(key)))) }
const featureText = '场景保存截图封面及列表按创建时间倒序的功能已实施。本报告记录使用现有模型创建真实场景、保存当前三维视口封面及异常回退的实际验证结果。'
const environment = `${recorded(data.date)} · ${recorded(data.baseURL)} · ${recorded(data.browser)} · ${recorded(data.role)}`
const retainedText = data.retainedData ? pretty(data.retainedData) : projects.length
? `本次创建的 ${projects.length} 条场景保留在开发环境:${projects.map(project => `${project.id}(${project.name || '未命名'})`).join('、')}。模型引用固定到下表记录的已发布版本。`
: '尚未记录保留场景。'
const cleanupText = data.cleanup ? pretty(data.cleanup) : '未记录额外清理操作;报告不据此宣称测试文件或历史版本已删除。'
const limitations = [
'手动保存从当前三维视口生成封面;新封面与场景内容、模型引用在同一次工程更新中关联保存。发布前的保存沿用该流程。',
'截图或封面上传失败时保留旧封面;工程是否保存成功以实际更新请求为准。版本冲突不覆盖其他已保存的内容。',
'自动恢复及重新打开工程不生成或上传新封面;恢复后的修改需由用户保存后更新封面。',
'列表按创建时间倒序展示。再次保存不会改变创建时间,也不应因此改变创建时间排序。',
...asArray(data.limitations).map(checkText),
]
const independentText = '既存受控文件资产的 fsvc URI 分支未完整覆盖上传票据绑定校验,属于本次场景封面变更前已存在的问题。本次保留现有流程,后续应独立补齐校验并验证历史资产、版本复制与引用兼容性;不计入本次场景功能失败数。'
const countText = `当前证据项通过 ${passCount} / ${currentEvidence.length};失败 ${failCount};其他或未标注 ${otherCount};已更正历史记录 ${historicalCount};已校验图片文件 ${imagePaths.size}。`

const outputHtml = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>真实场景与截图封面验证报告 · ${html(data.date)}</title>
<style>
.correction{margin:24px 0;padding:22px 24px;border:2px solid #c56b13;border-left-width:7px;border-radius:12px;background:#fff7e8}.correction h2{margin-top:0;color:#823b08}.correction h3{margin-top:18px}.correction li{white-space:pre-wrap;overflow-wrap:anywhere}
:root{color-scheme:light;--ink:#172d35;--muted:#536872;--line:#dbe5e7;--accent:#10664e}*{box-sizing:border-box}body{margin:0;background:#f2f6f6;color:var(--ink);font:15px/1.7 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1180px;margin:auto;padding:38px 28px 60px}header{padding-bottom:20px;border-bottom:1px solid var(--line)}.eyebrow{font-size:12px;letter-spacing:.12em;font-weight:700;color:var(--accent)}h1{font-size:32px;line-height:1.4;margin:7px 0 12px}h2{font-size:23px;margin:30px 0 14px}h3{font-size:17px;margin:0 0 8px}p{margin:8px 0 12px}.muted,figcaption{color:var(--muted)}a{color:var(--accent);text-underline-offset:3px}.stats{display:flex;flex-wrap:wrap;gap:12px;margin:22px 0}.stat{background:white;border:1px solid var(--line);border-radius:10px;padding:12px 18px;min-width:150px}.stat strong{display:block;font-size:25px}.covers,.checks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.card,.check,.panel{background:white;border:1px solid var(--line);border-radius:12px;padding:20px;min-width:0}.meta{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:13px}.meta dt{color:var(--muted)}.meta dd{margin:0;overflow-wrap:anywhere}figure{margin:12px 0 0}figure img{display:block;width:100%;height:auto;background:#edf1f1;border:1px solid var(--line);border-radius:7px}.cover-image img{aspect-ratio:16/9;object-fit:contain}figcaption{font-size:12px;margin-top:5px}.images{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.images.single{grid-template-columns:1fr}.badge{display:inline-block;padding:1px 9px;border-radius:12px;font-size:12px;font-weight:700;white-space:nowrap}.pass{background:#e1f3eb;color:#0c6144}.fail{background:#fde8e8;color:#ab2323}.neutral{background:#edf0f4;color:#425566}.check-title{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}details{margin-top:12px}summary{cursor:pointer;color:var(--accent);font-size:13px}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f4f7f8;border-radius:7px;padding:12px;font:12px/1.6 ui-monospace,Consolas,monospace;max-height:360px;overflow:auto}ul{padding-left:21px}.assertions{font-size:13px}.assertions li{margin:6px 0;white-space:pre-wrap;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse;font-size:13px}th,td{padding:9px 11px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}th{background:#f4f7f8}td{overflow-wrap:anywhere}.command{margin-bottom:16px}.command h3{font-family:ui-monospace,Consolas,monospace;font-size:13px;overflow-wrap:anywhere}footer{margin-top:28px;color:var(--muted);font-size:12px}@media(max-width:760px){main{padding:24px 16px 40px}h1{font-size:26px}.covers,.checks{grid-template-columns:1fr}.stat{flex:1;min-width:115px}.panel,.card,.check{padding:15px}.table-wrap{overflow:auto}}@media print{body{background:white}main{max-width:none;padding:0}.card,.check,figure{break-inside:avoid}.checks{display:block}.check{margin-bottom:14px}pre{max-height:none}a{color:inherit}}
</style></head><body><main>
<header><div class="eyebrow">SCENE LIVE · 功能已实施</div><h1>真实场景与截图封面验证报告</h1><p>${html(featureText)}</p><p class="muted">${html(environment)}</p><a href="report.md">Markdown 报告</a></header>
${correctionHtml}
<div class="stats"><div class="stat"><strong>${passCount} / ${currentEvidence.length}</strong>当前证据项通过</div><div class="stat"><strong>${failCount}</strong>失败</div><div class="stat"><strong>${otherCount}</strong>其他或未标注</div>${historicalCount ? `<div class="stat"><strong>${historicalCount}</strong>已更正历史记录</div>` : ''}<div class="stat"><strong>${imagePaths.size}</strong>图片文件已校验</div></div>
<h2>保留场景与封面证据</h2><div class="covers">${projects.map(project => {
const cover = project.cover || {}
return `<article class="card"><h3>${html(project.name || `场景 ${project.id}`)}</h3>${figure(coverOf(project), `场景 ${project.id} 保存后封面回读证据`, 'cover-image')}<dl class="meta"><dt>场景 / 状态</dt><dd>${html(project.id)} / ${html(projectStatus(project))}</dd><dt>当前工程版本</dt><dd>${html(projectVersion(project))}</dd><dt>引用模型 / 版本</dt><dd>${html(recorded(project.modelId))} / ${html(recorded(project.modelVersionId))}</dd><dt>封面大小</dt><dd>${html(recorded(cover.sizeBytes ?? cover.bytes))} 字节${cover.width && cover.height ? ` · ${html(cover.width)} × ${html(cover.height)}` : ''}</dd></dl><details><summary>封面持久化与项目证据</summary><pre>${html(pretty(project))}</pre></details></article>`
}).join('') || '<p>尚未记录场景项目。</p>'}</div>
<h2>最终列表与创建时间倒序</h2><section class="panel">${figure(finalList, '保留场景的实际封面与创建时间倒序列表')}</section>
<h2>逐项验证记录</h2><p class="muted">通过数仅统计未被更正且 status 明确为 PASS 的当前证据项;历史记录单独标注。checks 用于展示实际断言,不重复累加。故障注入中的预期错误响应不等于用例失败;缺少状态时不推断通过。图片文件存在校验不等于视觉内容验证。</p><div class="checks">${evidence.map((item, index) => {
const pictures = picturesOf(item)
const extra = extraOf(item)
return `<article class="check"><div class="check-title"><h3>${index + 1}. ${html(item.title || '未命名检查')}</h3><span class="badge ${item.superseded === true ? 'neutral' : statusClass(item.status)}">${html(evidenceStatus(item))}</span></div>${asArray(item.checks).length ? `<ul class="assertions">${asArray(item.checks).map(check => `<li>${html(checkText(check))}</li>`).join('')}</ul>` : ''}${Object.keys(extra).length ? `<details><summary>检查详情</summary><pre>${html(pretty(extra))}</pre></details>` : ''}<div class="images ${pictures.length === 1 ? 'single' : ''}">${pictures.map(ref => figure(ref, /\.jpe?g$/i.test(ref.path) ? '实际生成的场景封面' : '浏览器现场')).join('')}</div></article>`
}).join('') || '<p>尚未记录验证结果。</p>'}</div>
<h2>数据保留与清理</h2><section class="panel"><pre>${html(retainedText)}</pre><div class="table-wrap"><table><thead><tr><th>场景</th><th>模型 / 发布版本</th><th>最终状态</th><th>当前版本</th></tr></thead><tbody>${projects.map(project => `<tr><td>${html(project.id)} · ${html(project.name)}</td><td>${html(recorded(project.modelId))} / ${html(recorded(project.modelVersionId))}</td><td>${html(projectStatus(project))}</td><td>${html(projectVersion(project))}</td></tr>`).join('')}</tbody></table></div><pre>${html(cleanupText)}</pre></section>
<h2>本次问题记录</h2><section class="panel">${issues.length ? issues.map(issue => `<pre>${html(pretty(issue))}</pre>`).join('') : '<p>当前证据未登记本次新增的未解决问题。</p>'}</section>
<h2>后续独立事项</h2><section class="panel"><p>${html(independentText)}</p>${independentIssues.map(issue => `<pre>${html(pretty(issue))}</pre>`).join('')}</section>
<h2>实际命令与结果</h2><section class="panel">${commands.length ? commands.map(item => { const entry = commandParts(item); return `<div class="command"><h3>${html(entry.command)}</h3><pre>${html(entry.result)}</pre></div>` }).join('') : '<p>evidence.json 尚未记录命令结果;不据此宣称构建或命令行测试通过。</p>'}</section>
<h2>当前行为与限制</h2><section class="panel"><ul>${limitations.map(item => `<li>${html(item)}</li>`).join('')}</ul></section>
<footer>报告来自 evidence.json,生成过程不修改原始证据。所有引用图片已校验存在且非空,点击可查看原图。通过数随证据更新,不固定用例数量。</footer>
</main></body></html>`

const outputMarkdown = [
'# 真实场景与截图封面验证报告', '', featureText, '', md(environment), '', ...correctionMarkdown, countText, '',
'## 保留场景与封面证据', '',
...projects.flatMap(project => [
`### ${md(project.name || `场景 ${project.id}`)}`, '',
`场景 ${md(project.id)};状态 ${md(projectStatus(project))};当前版本 ${md(projectVersion(project))};引用模型 ${md(recorded(project.modelId))} / 发布版本 ${md(recorded(project.modelVersionId))}。`, '',
mdImage(coverOf(project), `场景 ${project.id} 保存后封面回读证据`), '',
'<details><summary>封面持久化与项目证据</summary>', '', '<pre>', html(pretty(project)), '</pre>', '', '</details>', '',
]),
'## 最终列表与创建时间倒序', '', mdImage(finalList, '保留场景的封面与创建时间倒序列表'), '',
'## 逐项验证记录', '',
'通过数仅统计未被更正且 status 明确为 PASS 的当前证据项;历史记录单独标注。checks 不重复累加。预期故障响应不等于用例失败,缺少状态不推断通过。图片文件存在校验不等于视觉内容验证。', '',
'| 序号 | 检查项 | 结果 | 图片 |', '|---|---|---|---|',
...evidence.map((item, index) => `| ${index + 1} | ${md(item.title || '未命名检查')} | ${md(evidenceStatus(item))} | ${picturesOf(item).map((ref, i) => `[图片 ${i + 1}](${url(ref.path)})`).join(' · ') || '—'} |`), '',
...evidence.flatMap((item, index) => [
`### ${index + 1}. ${md(item.title || '未命名检查')} · ${md(evidenceStatus(item))}`, '',
...asArray(item.checks).map(check => `- ${md(checkText(check))}`), '',
...(Object.keys(extraOf(item)).length ? ['<details><summary>检查详情</summary>', '', '<pre>', html(pretty(extraOf(item))), '</pre>', '', '</details>', ''] : []),
...picturesOf(item).flatMap(ref => [mdImage(ref, '验证现场'), '']),
]),
'## 数据保留与清理', '', '<pre>', html(retainedText), '</pre>', '',
'| 场景 | 模型 / 发布版本 | 最终状态 | 当前版本 |', '|---|---|---|---|',
...projects.map(project => `| ${md(project.id)} · ${md(project.name)} | ${md(recorded(project.modelId))} / ${md(recorded(project.modelVersionId))} | ${md(projectStatus(project))} | ${md(projectVersion(project))} |`), '',
'<pre>', html(cleanupText), '</pre>', '',
'## 本次问题记录', '',
...(issues.length ? issues.flatMap(issue => ['<pre>', html(pretty(issue)), '</pre>', '']) : ['当前证据未登记本次新增的未解决问题。', '']),
'## 后续独立事项', '', independentText, '',
...independentIssues.flatMap(issue => ['<pre>', html(pretty(issue)), '</pre>', '']),
'## 实际命令与结果', '',
...(commands.length ? commands.flatMap(item => { const entry = commandParts(item); return [`**${md(entry.command)}**`, '', '<pre>', html(entry.result), '</pre>', ''] })
: ['evidence.json 尚未记录命令结果;不据此宣称构建或命令行测试通过。', '']),
'## 当前行为与限制', '', ...limitations.map(item => `- ${item}`), '',
'报告来自 evidence.json,未修改原始证据;所有引用图片已校验存在且非空,点击可查看原图。通过数随证据更新。', '',
].join('\n')

fs.writeFileSync(path.join(reportDir, 'index.html'), outputHtml, 'utf8')
fs.writeFileSync(path.join(reportDir, 'report.md'), outputMarkdown, 'utf8')
console.log(JSON.stringify({ report: 'reports/scene-live-20260905/index.html', passed: passCount,
total: currentEvidence.length, historical: historicalCount, failed: failCount, other: otherCount, imagesValidated: imagePaths.size,
projectsRecorded: projects.length, commandsRecorded: commands.length }))

+ 272
- 0
tools/model-cover-api.test.mjs View File

@@ -0,0 +1,272 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
ModelApiError,
ModelContentProjectSession,
} from '../../unreal_tran/src/model-content-api.js';

const clone = value => structuredClone(value);
const jpeg = () => new Blob(['test-jpeg'], { type: 'image/jpeg' });
const deferred = () => {
let resolve;
let reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
};
const tick = () => new Promise(resolve => setImmediate(resolve));
const oldCoverUri = `content://sha256/${'b'.repeat(64)}`;
const initialDocument = () => ({
schema: 'digital-twin-editor-project',
version: 4,
name: 'Original model',
modelId: 'test-model',
modelResource: {
kind: 'imported',
assetCode: 'MODEL_MAIN',
code: 'MODEL_MAIN',
storageUri: `content://sha256/${'a'.repeat(64)}`,
sourceName: 'model.glb',
},
model: { objects: {} },
camera: { position: [1, 2, 3], target: [0, 0, 0] },
});

async function fixture() {
const state = {
id: '876',
type: 'MODEL',
code: 'MODEL_TEST',
name: 'Original model',
description: 'Cover transaction test',
categoryCode: 'EQUIPMENT',
status: 'DRAFT',
version: 7,
coverUri: oldCoverUri,
content: initialDocument(),
assets: [
{
id: '101', code: 'MODEL_MAIN', name: 'model.glb', type: 'MODEL_FILE',
storageUri: `content://sha256/${'a'.repeat(64)}`, mimeType: 'model/gltf-binary',
sizeBytes: 100, sha256: 'a'.repeat(64), metadata: {}, status: 'READY', sortOrder: 0,
},
{
id: '102', code: 'MODEL_COVER', name: 'Old cover', type: 'IMAGE',
storageUri: oldCoverUri, mimeType: 'image/jpeg',
sizeBytes: 40, sha256: 'b'.repeat(64), metadata: {}, status: 'READY', sortOrder: 1,
},
],
dependencies: [],
};
const controls = { postError: null, putError: null, postBarrier: null, putBarrier: null };
const requests = [];
const tickets = new Map();
let uploadCount = 0;
const response = () => ({
project: { ...clone(state), content: undefined, assets: undefined, currentVersionId: String(700 + state.version) },
currentVersion: {
id: String(700 + state.version), versionNo: state.version,
content: clone(state.content), assets: clone(state.assets), dependencies: clone(state.dependencies),
},
});
const client = {
async request(path, options = {}) {
const method = options.method ?? 'GET';
assert.ok(path === '/content/projects/876' || path === '/content/projects/876/assets/upload');
if (method === 'GET') return response();
if (method === 'POST') {
assert.equal(path, '/content/projects/876/assets/upload');
const form = options.body;
const request = {
method, version: Number(form.get('projectVersion')),
code: form.get('code'), type: form.get('type'),
file: form.get('file'), sortOrder: Number(form.get('sortOrder')),
metadata: JSON.parse(form.get('metadata')),
};
requests.push(request);
if (controls.postBarrier) await controls.postBarrier.promise;
if (controls.postError) throw controls.postError;
assert.equal(request.version, state.version, 'upload must use the current project version');
uploadCount += 1;
const sha256 = String(uploadCount + 2).repeat(64);
const asset = {
code: request.code, name: form.get('name'), type: request.type,
storageUri: `content://sha256/${sha256}`, mimeType: request.file.type,
sizeBytes: request.file.size, sha256, metadata: request.metadata,
status: 'READY', sortOrder: request.sortOrder, uploadTicket: `cover-ticket-${uploadCount}`,
};
tickets.set(asset.uploadTicket, clone(asset));
return clone(asset);
}
assert.equal(method, 'PUT');
const payload = clone(options.json);
requests.push({ method, payload });
if (controls.putBarrier) await controls.putBarrier.promise;
if (controls.putError) throw controls.putError;
assert.equal(payload.version, state.version, 'save must use the current project version');
assert.equal(new Set(payload.assets.map(asset => asset.code)).size, payload.assets.length);
for (const asset of payload.assets) {
if (!asset.uploadTicket) continue;
const uploaded = tickets.get(asset.uploadTicket);
assert.ok(uploaded, 'each upload ticket must exist and be consumed once');
assert.equal(asset.storageUri, uploaded.storageUri);
tickets.delete(asset.uploadTicket);
}
state.version += 1;
state.name = payload.name;
state.content = clone(payload.content);
state.coverUri = payload.coverUri;
state.assets = payload.assets.map((asset, index) => {
const persisted = { ...asset, id: String(200 + index), versionId: String(700 + state.version) };
delete persisted.uploadTicket;
return persisted;
});
return response();
},
};
const api = new ModelContentProjectSession({ projectId: state.id, client });
await api.loadProject();
api.markDocumentLoaded(state.content);
return { api, state, controls, requests, tickets };
}

test('persists edited content and the uploaded cover in one PUT, replacing fixed MODEL_COVER', async () => {
const f = await fixture();
const document = { ...initialDocument(), name: 'Edited model' };
await f.api.queueSave(document, { coverImage: jpeg() });
assert.deepEqual(f.requests.map(request => request.method), ['POST', 'PUT']);
const post = f.requests[0];
assert.equal(post.code, 'MODEL_COVER');
assert.equal(post.type, 'IMAGE');
assert.equal(post.file.name, 'model-cover.jpg');
assert.equal(post.file.type, 'image/jpeg');
assert.equal(post.sortOrder, 1);
assert.equal(post.metadata.usage, 'model-cover');
const payload = f.requests[1].payload;
assert.equal(payload.content.name, 'Edited model');
assert.equal(payload.assets.length, 2);
const cover = payload.assets.find(asset => asset.code === 'MODEL_COVER');
assert.equal(payload.coverUri, cover.storageUri);
assert.equal(cover.uploadTicket, 'cover-ticket-1');
assert.equal(f.api.project.coverUri, payload.coverUri);
assert.equal(f.api.project.version, 8);
assert.equal(f.api.pendingUploads.size, 0);
assert.equal(f.tickets.size, 0);

await f.api.queueSave(document, { coverImage: jpeg() });
assert.equal(f.state.assets.filter(asset => asset.code === 'MODEL_COVER').length, 1);
assert.notEqual(f.state.coverUri, payload.coverUri);
assert.equal(f.state.assets.find(asset => asset.code === 'MODEL_MAIN').storageUri, initialDocument().modelResource.storageUri);
});

test('cover POST failure still saves edited content with the previous cover', async () => {
const f = await fixture();
const error = new ModelApiError('upload unavailable', 'SERVER_ERROR', '', 503);
f.controls.postError = error;
const warnings = [];
await f.api.queueSave({ ...initialDocument(), name: 'Content survives' }, {
coverImage: jpeg(), onCoverError: value => warnings.push(value),
});
assert.deepEqual(warnings, [error]);
assert.deepEqual(f.requests.map(request => request.method), ['POST', 'PUT']);
assert.equal(f.state.content.name, 'Content survives');
assert.equal(f.state.coverUri, oldCoverUri);
assert.equal(f.requests[1].payload.assets.some(asset => asset.uploadTicket), false);
});

test('cover POST 409 stops the transaction before any PUT', async () => {
const f = await fixture();
const error = new ModelApiError('version conflict', 'CONFLICT', '', 409);
f.controls.postError = error;
const previous = clone(f.api.project);
let warnings = 0;
await assert.rejects(f.api.queueSave({ ...initialDocument(), name: 'Stale content' }, {
coverImage: jpeg(), onCoverError: () => { warnings += 1; },
}), actual => actual === error);
assert.deepEqual(f.requests.map(request => request.method), ['POST']);
assert.deepEqual(f.api.project, previous);
assert.equal(warnings, 0);
});

for (const status of [409, 503]) {
test(`final PUT ${status} preserves the old cover and cannot leak its ticket into a later autosave`, async () => {
const f = await fixture();
const error = new ModelApiError('save failed', 'SAVE_FAILED', '', status);
f.controls.putError = error;
const previous = clone(f.api.project);
const fingerprint = f.api.lastSavedFingerprint;
const document = { ...initialDocument(), name: 'Still unsaved' };
await assert.rejects(f.api.queueSave(document, { coverImage: jpeg() }), actual => actual === error);
assert.deepEqual(f.api.project, previous);
assert.equal(f.api.lastSavedFingerprint, fingerprint);
assert.equal(f.api.pendingUploads.size, 0);
f.controls.putError = null;
await f.api.queueSave(document);
const later = f.requests.at(-1).payload;
assert.equal(later.coverUri, oldCoverUri);
assert.equal(later.assets.some(asset => asset.uploadTicket), false);
assert.equal(f.state.coverUri, oldCoverUri);
assert.equal(f.requests.filter(request => request.method === 'POST').length, 1);
});
}

test('automatic saves never upload a cover, and unchanged content skips PUT before and after cover replacement', async () => {
const f = await fixture();
const document = initialDocument();
await f.api.queueSave(document);
assert.equal(f.requests.length, 0);
document.name = 'Automatic content edit';
await f.api.queueSave(document);
await f.api.queueSave(document);
assert.deepEqual(f.requests.map(request => request.method), ['PUT']);
await f.api.queueSave(document, { coverImage: jpeg() });
const count = f.requests.length;
await f.api.queueSave(document);
assert.equal(f.requests.length, count, 'consumed cover ticket must not cause another save');
});

test('asynchronous JPEG encoding owns the queue until upload and final PUT finish', async () => {
const f = await fixture();
const image = deferred();
const postBarrier = deferred();
f.controls.postBarrier = postBarrier;
const first = f.api.queueSave({ ...initialDocument(), name: 'Manual snapshot' }, { coverImage: image.promise });
const laterDocument = { ...initialDocument(), name: 'Later automatic snapshot' };
const later = f.api.queueSave(laterDocument);
laterDocument.name = 'Mutation after enqueue must not leak';
await tick();
assert.equal(f.requests.length, 0, 'later autosave cannot pass pending JPEG encoding');
image.resolve(jpeg());
await tick();
assert.deepEqual(f.requests.map(request => request.method), ['POST']);
postBarrier.resolve();
await Promise.all([first, later]);
assert.deepEqual(f.requests.map(request => request.method), ['POST', 'PUT', 'PUT']);
assert.equal(f.requests[0].version, 7);
assert.equal(f.requests[1].payload.version, 7);
assert.equal(f.requests[2].payload.version, 8);
assert.equal(f.requests[1].payload.content.name, 'Manual snapshot');
assert.equal(f.state.content.name, 'Later automatic snapshot');
assert.equal(f.requests[2].payload.coverUri, f.requests[1].payload.coverUri);
});

test('JPEG rejection while another save owns the queue is observed immediately and falls back safely', async () => {
const f = await fixture();
const barrier = deferred();
f.controls.putBarrier = barrier;
const first = f.api.queueSave({ ...initialDocument(), name: 'First save' });
await tick();
const captureError = new Error('JPEG encoding failed');
const image = deferred();
const warnings = [];
const later = f.api.queueSave({ ...initialDocument(), name: 'Fallback content' }, {
coverImage: image.promise, onCoverError: error => warnings.push(error),
});
image.reject(captureError);
await tick();
barrier.resolve();
await Promise.all([first, later]);
assert.deepEqual(warnings, [captureError]);
assert.deepEqual(f.requests.map(request => request.method), ['PUT', 'PUT']);
assert.equal(f.state.content.name, 'Fallback content');
assert.equal(f.state.coverUri, oldCoverUri);
});

+ 141
- 0
tools/model-cover-capture.test.mjs View File

@@ -0,0 +1,141 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { captureModelCover } from '../../unreal_tran/src/model-cover-capture.js';

function fixture({ width = 1600, height = 900, drawError, renderError, encodeError, blob } = {}) {
const events = [];
const selectionHelper = { visible: true };
const transformHelper = { visible: false };
const grid = { visible: true };
const originalVisibility = [true, false, true];
const visibility = () => [selectionHelper.visible, transformHelper.visible, grid.visible];
const scene = {};
const camera = Object.freeze({ position: Object.freeze({ x: 1, y: 2, z: 3 }) });
const modelPivot = {};
const source = { width, height };
let renders = 0;
const renderer = {
domElement: source,
render(actualScene, actualCamera) {
assert.equal(actualScene, scene);
assert.equal(actualCamera, camera);
events.push({ operation: 'render', visibility: visibility() });
renders += 1;
if (renders === 1 && renderError) throw renderError;
},
};
const editor = {
renderer, scene, camera, modelPivot, selectionHelper, grid,
transform: { getHelper: () => transformHelper },
selected: { name: 'selected-model-node' },
};
const canvas = {
width: 0,
height: 0,
getContext(kind) {
assert.equal(kind, '2d');
return {
drawImage(...args) {
events.push({ operation: 'copy', visibility: visibility() });
assert.deepEqual(args, [source, 0, 0, canvas.width, canvas.height]);
if (drawError) throw drawError;
},
};
},
toBlob(callback, type, quality) {
events.push({ operation: 'encode', visibility: visibility(), type, quality });
if (encodeError) throw encodeError;
queueMicrotask(() => callback(blob === undefined ? new Blob(['jpeg'], { type }) : blob));
},
};
const document = { createElement: tag => { assert.equal(tag, 'canvas'); return canvas; } };
return { editor, canvas, document, events, visibility, originalVisibility };
}

test('copies the helper-free frame synchronously, restores the view, then encodes JPEG', async () => {
const f = fixture();
const selected = f.editor.selected;
const pending = captureModelCover(f.editor, { document: f.document });
// No await has occurred: copying and restoration must already be complete.
assert.deepEqual(f.events.map(event => event.operation), ['render', 'copy', 'render', 'encode']);
assert.deepEqual(f.events[0].visibility, [false, false, false]);
assert.deepEqual(f.events[1].visibility, [false, false, false]);
assert.deepEqual(f.events[2].visibility, f.originalVisibility);
assert.deepEqual(f.events[3].visibility, f.originalVisibility);
assert.equal(f.events[3].type, 'image/jpeg');
assert.equal(f.events[3].quality, 0.85);
assert.equal(f.editor.selected, selected);
assert.deepEqual(f.editor.camera.position, { x: 1, y: 2, z: 3 });
const result = await pending;
assert.equal(result.type, 'image/jpeg');
assert.equal(result.size, 4);
});

for (const { width, height, maxWidth, expected } of [
{ width: 1600, height: 900, expected: [640, 360] },
{ width: 900, height: 1600, expected: [360, 640] },
{ width: 320, height: 180, expected: [320, 180] },
{ width: 1600, height: 900, maxWidth: 320, expected: [320, 180] },
]) {
test(`bounds a ${width} x ${height} view at ${maxWidth ?? 640} pixels without stretching`, async () => {
const f = fixture({ width, height });
await captureModelCover(f.editor, { document: f.document, maxWidth });
assert.deepEqual([f.canvas.width, f.canvas.height], expected);
});
}

for (const failure of ['drawError', 'renderError']) {
test(`restores every helper and redraws the original viewport after ${failure}`, async () => {
const error = new Error(failure);
const f = fixture({ [failure]: error });
await assert.rejects(captureModelCover(f.editor, { document: f.document }), actual => actual === error);
assert.deepEqual(f.visibility(), f.originalVisibility);
assert.equal(f.events.at(-1).operation, 'render');
assert.deepEqual(f.events.at(-1).visibility, f.originalVisibility);
assert.equal(f.events.some(event => event.operation === 'encode'), false);
});
}

test('restores a helper referenced in more than one editor slot exactly once', async () => {
const f = fixture();
f.editor.transform.getHelper = () => f.editor.selectionHelper;
await captureModelCover(f.editor, { document: f.document });
assert.equal(f.editor.selectionHelper.visible, true);
assert.equal(f.editor.grid.visible, true);
});

for (const blob of [null, new Blob([])]) {
test(`rejects ${blob === null ? 'missing' : 'empty'} encoded images after restoring the viewport`, async () => {
const f = fixture({ blob });
await assert.rejects(captureModelCover(f.editor, { document: f.document }), /empty image/);
assert.deepEqual(f.visibility(), f.originalVisibility);
});
}

test('propagates encoder errors with the original viewport already restored', async () => {
const error = new Error('encoding failed');
const f = fixture({ encodeError: error });
await assert.rejects(captureModelCover(f.editor, { document: f.document }), actual => actual === error);
assert.deepEqual(f.visibility(), f.originalVisibility);
});

test('rejects unready viewports and invalid sizes before touching editor helpers', async () => {
for (const change of [
f => { f.editor.renderer = null; },
f => { f.editor.renderer.domElement.width = 0; },
f => { f.editor.renderer.domElement.height = Number.NaN; },
f => { f.editor.scene = null; },
f => { f.editor.camera = null; },
f => { f.editor.modelPivot = null; },
]) {
const f = fixture();
change(f);
await assert.rejects(captureModelCover(f.editor, { document: f.document }), /not ready/);
assert.deepEqual(f.visibility(), f.originalVisibility);
assert.equal(f.events.length, 0);
}
const f = fixture();
await assert.rejects(captureModelCover(f.editor, { document: f.document, maxWidth: 0 }), /positive/);
assert.deepEqual(f.visibility(), f.originalVisibility);
assert.equal(f.events.length, 0);
});

+ 207
- 0
tools/scene-cover-api.test.mjs View File

@@ -0,0 +1,207 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'

const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
const ts = requireWeb('typescript')
const source = readFileSync(new URL('../../unreal_tran_web/src/features/scene-legacy/contentApiClient.ts', import.meta.url), 'utf8')
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
const { LegacySceneContentApiClient, LegacySceneOperationQueue, saveSceneDocumentWithCover } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
const clone = value => structuredClone(value)
const jpeg = () => new Blob(['scene screenshot'], { type: 'image/jpeg' })
const oldUri = `content://sha256/${'a'.repeat(64)}`
const defer = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}

function fixture() {
const state = {
id: '987', type: 'SCENE', code: 'SCENE_TEST', name: 'Test scene', description: '', categoryCode: 'OUTDOOR',
version: 7, status: 'DRAFT', currentVersionId: '107', publishedVersionId: '106', coverUri: oldUri,
content: { objects: [] }, dependencies: [],
assets: [{ id: '200', code: 'SCENE_COVER', name: 'Old cover', type: 'IMAGE', storageUri: oldUri,
mimeType: 'image/jpeg', sizeBytes: 12, sha256: 'a'.repeat(64), metadata: {}, status: 'READY', sortOrder: 2 }],
}
const controls = { uploadStatus: 200, putStatus: 200, uploadBarrier: null, uploadPatch: null }
const requests = []
const published = clone(state)
const client = new LegacySceneContentApiClient(state.id, {
tokenStorage: { accessToken: () => '', refreshToken: () => '', store() {}, clear() {}, notifyExpired() {} },
lockManager: null,
fetchImpl: async (url, init) => {
const reply = (status, data) => new Response(JSON.stringify({ code: status, message: status >= 400 ? 'test failure' : 'ok', data }), { status })
if (init.method === 'POST') {
assert.equal(url, '/api/tran/v1/content/projects/987/assets/upload')
const form = init.body
requests.push({ method: 'POST', version: Number(form.get('projectVersion')), code: form.get('code'),
type: form.get('type'), file: form.get('file'), metadata: JSON.parse(form.get('metadata')) })
if (controls.uploadBarrier) await controls.uploadBarrier.promise
if (controls.uploadStatus !== 200) return reply(controls.uploadStatus, null)
assert.equal(Number(form.get('projectVersion')), state.version)
return reply(200, { code: form.get('code'), name: form.get('name'), type: form.get('type'),
storageUri: `content://sha256/${'b'.repeat(64)}`, mimeType: form.get('file').type, sizeBytes: form.get('file').size,
sha256: 'b'.repeat(64), metadata: JSON.parse(form.get('metadata')), status: 'READY',
sortOrder: Number(form.get('sortOrder')), uploadTicket: 'scene-cover-ticket', ...controls.uploadPatch })
}
assert.equal(init.method, 'PUT')
const body = JSON.parse(init.body)
requests.push({ method: 'PUT', body })
if (controls.putStatus !== 200) return reply(controls.putStatus, null)
assert.equal(body.version, state.version)
Object.assign(state, clone(body), { version: state.version + 1 })
state.assets.forEach(asset => { delete asset.uploadTicket })
return reply(200, state)
},
})
return { client, state, controls, requests, published }
}

test('scene image ticket, fixed asset replacement, dependencies and coverUri commit in one versioned PUT', async () => {
const f = fixture()
const original = clone(f.state)
const document = { objects: [{ targetProjectId: '156', targetVersionId: '184', position: [2, 0, 0] }] }
const saved = await saveSceneDocumentWithCover(f.client, original, document, { image: jpeg() })
assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT'])
assert.equal(f.requests[0].code, 'SCENE_COVER')
assert.equal(f.requests[0].type, 'IMAGE')
assert.equal(f.requests[0].file.type, 'image/jpeg')
assert.equal(f.requests[0].file.name, 'scene-cover.jpg')
assert.equal(f.requests[0].metadata.usage, 'scene-cover')
const put = f.requests[1].body
assert.equal(put.assets.length, 1)
assert.equal(put.assets[0].uploadTicket, 'scene-cover-ticket')
assert.equal(put.assets[0].sortOrder, 2)
assert.equal(put.coverUri, put.assets[0].storageUri)
assert.deepEqual(put.content, document)
assert.deepEqual(put.dependencies, [{ targetProjectId: '156', targetVersionId: '184', relationType: 'SCENE_MODEL', required: true }])
assert.equal(saved.project.version, 8)
assert.equal(saved.coverFailed, false)
assert.deepEqual(original, f.published, 'input and immutable published snapshot remain unchanged')
})

test('capture failure saves content with the previous cover and reports a warning result', async () => {
const f = fixture()
const saved = await saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'edited' }, { error: new Error('tainted canvas') })
assert.deepEqual(f.requests.map(r => r.method), ['PUT'])
assert.equal(saved.coverFailed, true)
assert.equal(saved.project.coverUri, oldUri)
assert.equal(saved.project.content.label, 'edited')
})

test('upload failure keeps the previous cover but still saves scene content once', async () => {
const f = fixture()
f.controls.uploadStatus = 503
const saved = await saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'edited' }, { image: jpeg() })
assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT'])
assert.equal(saved.coverFailed, true)
assert.equal(saved.project.coverUri, oldUri)
assert.equal(f.requests[1].body.assets[0].uploadTicket, undefined)
})

test('upload conflict propagates without any content PUT', async () => {
const f = fixture()
f.controls.uploadStatus = 409
await assert.rejects(saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [] }, { image: jpeg() }), error => error.status === 409)
assert.deepEqual(f.requests.map(r => r.method), ['POST'])
assert.equal(f.state.version, 7)
})

for (const status of [409, 503]) {
test(`final PUT ${status} keeps old project and never leaks the ticket into a later plain save`, async () => {
const f = fixture()
const original = clone(f.state)
f.controls.putStatus = status
await assert.rejects(saveSceneDocumentWithCover(f.client, original, { objects: [], label: 'failed' }, { image: jpeg() }), error => error.status === status)
assert.deepEqual(original, f.published)
assert.deepEqual(f.state, f.published)
f.controls.putStatus = 200
const saved = await saveSceneDocumentWithCover(f.client, original, { objects: [], label: 'plain' })
assert.equal(saved.project.coverUri, oldUri)
assert.equal(f.requests.at(-1).body.assets[0].uploadTicket, undefined)
assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT', 'PUT'])
})
}

test('malformed image upload response is treated as optional cover failure', async () => {
const f = fixture()
f.controls.uploadPatch = { storageUri: 'blob:temporary' }
const saved = await saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [] }, { image: jpeg() })
assert.equal(saved.coverFailed, true)
assert.equal(saved.project.coverUri, oldUri)
})

test('switching project while upload is pending prevents the old document PUT', async () => {
const f = fixture()
f.controls.uploadBarrier = defer()
let current = true
const pending = saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [] }, { image: jpeg() }, () => {
if (!current) throw new Error('project switched')
})
current = false
f.controls.uploadBarrier.resolve()
await assert.rejects(pending, /project switched/)
assert.deepEqual(f.requests.map(r => r.method), ['POST'])
assert.equal(f.state.version, 7)
})

test('the shared operation queue serializes cover upload and final PUT before the next write', async () => {
const f = fixture()
const queue = new LegacySceneOperationQueue()
f.controls.uploadBarrier = defer()
const first = queue.enqueue(() => saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'cover' }, { image: jpeg() }))
const second = queue.enqueue(() => saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'next' }))
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(f.requests.map(r => r.method), ['POST'])
f.controls.uploadBarrier.resolve()
await Promise.all([first, second])
assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT', 'PUT'])
assert.deepEqual(f.requests.filter(r => r.body).map(r => r.body.version), [7, 8])
})

const editorSource = readFileSync(new URL('../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-editor.js', import.meta.url), 'utf8').replace(/\r\n?/g, '\n')
const saveMethod = editorSource.slice(editorSource.indexOf(' async saveProject() {'), editorSource.indexOf(' /**\n * 读取已存场景')).trim()
const { saveProject } = new Function(`return ({ ${saveMethod} })`)()
function editorFixture(saveDocument) {
return {
readySettled: true, pendingLoads: 0, readOnly: false, destroyed: false, saving: false, dirty: false, changeRevision: 0,
context: { saveDocument }, serialize: () => ({ objects: [] }), applyAccessMode() {}, setSaveState() {},
updateHistoryControls() {}, messages: [], toast(message, type) { this.messages.push({ message, type }) },
}
}

test('editor displays the partial-save warning instead of a success toast', async () => {
const editor = editorFixture(async () => ({ warning: '场景已保存,封面更新失败,已保留原封面' }))
await saveProject.call(editor)
assert.equal(editor.dirty, false)
assert.equal(editor.saving, false)
assert.equal(editor.messages.at(-1).type, 'warn')
assert.match(editor.messages.at(-1).message, /封面更新失败/)
})

test('editor failure remains dirty and duplicate clicks do not issue a second save', async () => {
const barrier = defer()
let calls = 0
const editor = editorFixture(async () => { calls += 1; await barrier.promise })
const pending = saveProject.call(editor)
await saveProject.call(editor)
assert.equal(calls, 1)
barrier.reject(new Error('conflict'))
await pending
assert.equal(editor.dirty, true)
assert.equal(editor.saving, false)
assert.match(editor.messages.at(-1).message, /保存失败.*conflict/)
})

test('changes made while cover uploads remain dirty after the earlier snapshot saves', async () => {
const barrier = defer()
const editor = editorFixture(() => barrier.promise)
const pending = saveProject.call(editor)
editor.changeRevision += 1
barrier.resolve({})
await pending
assert.equal(editor.dirty, true)
assert.match(editor.messages.at(-1).message, /新修改仍待保存/)
})

+ 190
- 0
tools/scene-cover-capture.test.mjs View File

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

const sourceUrl = new URL('../../unreal_tran_web/src/features/scene-legacy/sceneCoverCapture.ts', import.meta.url)
const source = await readFile(sourceUrl, 'utf8')
const output = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
reportDiagnostics: true,
})
assert.deepEqual(output.diagnostics, [])
const { captureSceneCover } = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString('base64')}`)

function fixture({ width = 1600, height = 900, renderError, drawError, encodeError, blob } = {}) {
const events = []
const transformHelper = { visible: true }
const grid = { visible: false }
const boundsHelper = { visible: true }
const materials = [
{ original: { id: 'single-original' }, current: { id: 'single-highlighted' } },
{ original: [{ id: 'array-original-1' }, { id: 'array-original-2' }], current: [{ id: 'array-highlighted-1' }, { id: 'array-highlighted-2' }] },
]
const entries = materials.map(({ original, current }) => ({ child: { material: current }, original, highlighted: current }))
const sourceCanvas = { width, height }
const scene = { children: [transformHelper, grid, boundsHelper, ...entries.map(entry => entry.child)] }
const camera = Object.freeze({ position: Object.freeze({ x: 1, y: 2, z: 3 }) })
const selected = entries[0].child
const visibility = () => [transformHelper.visible, grid.visible, boundsHelper.visible]
const snapshot = operation => ({ operation, visibility: visibility(), materials: entries.map(entry => entry.child.material) })
let renders = 0
const neverCalled = () => assert.fail('capture must not reset, detach, or dispose selection')
const editor = {
scene, camera, destroyed: false, selected,
controls: { target: Object.freeze({ x: 0, y: 0, z: 0 }), update: neverCalled },
transform: { getHelper: () => transformHelper, detach: neverCalled },
grid,
selectionHighlight: {
helper: boundsHelper, materialEntries: entries, object: selected,
setObject: neverCalled, clear: neverCalled, update: neverCalled,
},
renderer: {
domElement: sourceCanvas,
render(actualScene, actualCamera) {
assert.equal(actualScene, scene)
assert.equal(actualCamera, camera)
events.push(snapshot('render'))
renders += 1
if (renders === 1 && renderError) throw renderError
},
},
}
const canvas = {
width: 0, height: 0,
getContext(kind) {
assert.equal(kind, '2d')
return {
drawImage(...args) {
assert.deepEqual(args, [sourceCanvas, 0, 0, canvas.width, canvas.height])
events.push(snapshot('copy'))
if (drawError) throw drawError
},
}
},
toBlob(callback, type, quality) {
events.push({ ...snapshot('encode'), type, quality })
if (encodeError) throw encodeError
queueMicrotask(() => callback(blob === undefined ? new Blob(['jpeg'], { type }) : blob))
},
}
const document = { createElement: tag => { assert.equal(tag, 'canvas'); return canvas } }
const assertRestored = () => {
assert.deepEqual(visibility(), [true, false, true])
entries.forEach((entry, index) => {
assert.equal(entry.child.material, materials[index].current)
assert.equal(entry.original, materials[index].original)
assert.equal(entry.highlighted, materials[index].current)
})
assert.equal(editor.selectionHighlight.materialEntries, entries)
assert.equal(editor.selectionHighlight.object, selected)
assert.equal(editor.selected, selected)
assert.equal(scene.children.length, 5)
assert.deepEqual(camera.position, { x: 1, y: 2, z: 3 })
assert.deepEqual(editor.controls.target, { x: 0, y: 0, z: 0 })
}
return { editor, document, events, canvas, materials, entries, assertRestored }
}

test('copies synchronously without bounds, gizmo, grid or selection glow, then restores before JPEG encoding', async () => {
const f = fixture()
const pending = captureSceneCover(f.editor, { document: f.document })
assert.deepEqual(f.events.map(event => event.operation), ['render', 'copy', 'render', 'encode'])
for (const event of f.events.slice(0, 2)) {
assert.deepEqual(event.visibility, [false, false, false])
event.materials.forEach((material, index) => assert.equal(material, f.materials[index].original))
}
for (const event of f.events.slice(2)) {
assert.deepEqual(event.visibility, [true, false, true])
event.materials.forEach((material, index) => assert.equal(material, f.materials[index].current))
}
f.assertRestored()
assert.equal(f.events[3].type, 'image/jpeg')
assert.equal(f.events[3].quality, 0.85)
const result = await pending
assert.equal(result.type, 'image/jpeg')
assert.equal(result.size, 4)
})

for (const { width, height, maxWidth, expected } of [
{ width: 1600, height: 900, expected: [640, 360] },
{ width: 900, height: 1600, expected: [360, 640] },
{ width: 320, height: 180, expected: [320, 180] },
{ width: 1600, height: 900, maxWidth: 320, expected: [320, 180] },
]) {
test(`preserves the ${width} x ${height} ratio within ${maxWidth ?? 640}px and never upscales`, async () => {
const f = fixture({ width, height })
await captureSceneCover(f.editor, { document: f.document, maxWidth })
assert.deepEqual([f.canvas.width, f.canvas.height], expected)
f.assertRestored()
})
}

for (const failure of ['drawError', 'renderError', 'encodeError']) {
test(`restores helper visibility, scalar/array materials and selection after ${failure}`, async () => {
const error = new Error(failure)
const f = fixture({ [failure]: error })
await assert.rejects(captureSceneCover(f.editor, { document: f.document }), actual => actual === error)
f.assertRestored()
assert.equal(f.events.filter(event => event.operation === 'render').length, 2)
if (failure !== 'encodeError') assert.equal(f.events.some(event => event.operation === 'encode'), false)
})
}

test('restores the actual material reference even if it differs from the recorded highlighted clone', async () => {
const f = fixture()
const replacement = { id: 'material-changed-after-selection' }
f.entries[0].child.material = replacement
await captureSceneCover(f.editor, { document: f.document })
assert.equal(f.entries[0].child.material, replacement)
assert.equal(f.events[0].materials[0], f.materials[0].original)
})

test('supports a ready scene without selection helpers', async () => {
const f = fixture()
delete f.editor.transform
delete f.editor.grid
delete f.editor.selectionHighlight
assert.equal((await captureSceneCover(f.editor, { document: f.document })).type, 'image/jpeg')
})

test('deduplicates shared helper objects before recording visibility', async () => {
const f = fixture()
f.editor.transform.getHelper = () => f.editor.selectionHighlight.helper
await captureSceneCover(f.editor, { document: f.document })
f.assertRestored()
})

for (const blob of [null, new Blob([])]) {
test(`rejects ${blob === null ? 'missing' : 'empty'} encoded images without disturbing the editor`, async () => {
const f = fixture({ blob })
await assert.rejects(captureSceneCover(f.editor, { document: f.document }), /编码结果为空/)
f.assertRestored()
})
}

test('refuses an unready or destroyed renderer before changing any scene state', async () => {
for (const change of [
f => { f.editor.destroyed = true },
f => { f.editor.renderer = null },
f => { f.editor.renderer.domElement.width = 0 },
f => { f.editor.renderer.domElement.height = Number.NaN },
f => { f.editor.scene = null },
f => { f.editor.camera = null },
]) {
const f = fixture()
change(f)
await assert.rejects(captureSceneCover(f.editor, { document: f.document }), /视口尚未准备好/)
f.assertRestored()
assert.equal(f.events.length, 0)
}
})

test('refuses invalid dimensions and unavailable 2D canvas before changing scene state', async () => {
const f = fixture()
await assert.rejects(captureSceneCover(f.editor, { document: f.document, maxWidth: 0 }), /正数像素/)
f.canvas.getContext = () => null
await assert.rejects(captureSceneCover(f.editor, { document: f.document }), /不支持场景封面编码/)
f.assertRestored()
assert.equal(f.events.length, 0)
})

+ 123
- 0
tools/scene-model-placement.test.mjs View File

@@ -0,0 +1,123 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { pathToFileURL } from 'node:url'
import test from 'node:test'
import ts from 'typescript'

const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
const threeUrl = pathToFileURL(requireWeb.resolve('three')).href
const THREE = await import(threeUrl)
const source = await readFile(new URL('../../unreal_tran_web/src/features/scene-legacy/modelPlacement.ts', import.meta.url), 'utf8')
const output = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
reportDiagnostics: true,
})
assert.deepEqual(output.diagnostics, [])
const compiled = output.outputText.replace(/from 'three'/g, `from '${threeUrl}'`)
const { fitSceneModel, refreshSceneModelBounds, parseSceneScale, formatSceneScale } = await import(
`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`,
)

function close(actual, expected, tolerance = 1e-8) {
assert.ok(Math.abs(actual - expected) <= tolerance, `${actual} != ${expected}`)
}

function skinFixture() {
const root = new THREE.Group()
const bone = new THREE.Bone()
const geometry = new THREE.BoxGeometry(2, 4, 10)
geometry.translate(0, 2, 0)
const count = geometry.getAttribute('position').count
const indices = new Uint16Array(count * 4)
const weights = new Float32Array(count * 4)
for (let i = 0; i < count; i += 1) weights[i * 4] = 1
geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4))
geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4))
const mesh = new THREE.SkinnedMesh(geometry, new THREE.MeshBasicMaterial())
root.add(bone, mesh)
root.updateMatrixWorld(true)
mesh.bind(new THREE.Skeleton([bone]))
// The published model has already been normalized and may be saved at an
// animation pose. This is the same stale matrix/cache state as loaded GLTF.
root.scale.setScalar(0.1)
root.position.set(1, 2, 3)
bone.position.y = 5
return { root, bone, mesh }
}

test('fresh skinned placement reaches the requested size and floor after authored scale/pose replay', () => {
const { root, mesh } = skinFixture()
// Populate the wrong cached skin box just as the previous import path did.
const stale = new THREE.Box3().setFromObject(root).getSize(new THREE.Vector3())
assert.ok(stale.z < 1)
const box = fitSceneModel(root, 6)
const size = box.getSize(new THREE.Vector3())
close(size.x, 1.2)
close(size.y, 2.4)
close(size.z, 6)
close(root.scale.x, 0.6)
close(box.min.y, 0)
close(box.getCenter(new THREE.Vector3()).x, 0)
close(box.getCenter(new THREE.Vector3()).z, 0)
const vertex = new THREE.Vector3()
for (let i = 0; i < mesh.geometry.getAttribute('position').count; i += 1) {
mesh.getVertexPosition(i, vertex)
assert.ok(mesh.boundingSphere.containsPoint(vertex))
}
})

test('fresh static placement preserves authored non-uniform proportions', () => {
const root = new THREE.Group()
root.add(new THREE.Mesh(new THREE.BoxGeometry(2, 4, 10), new THREE.MeshBasicMaterial()))
root.scale.set(2, 0.5, 3)
root.position.set(-4, 8, 6)
const box = fitSceneModel(root, 6)
root.scale.toArray().forEach((value, i) => close(value, [0.4, 0.1, 0.6][i]))
close(box.getSize(new THREE.Vector3()).z, 6)
close(box.min.y, 0)
})

test('restoring skin caches keeps existing scene transforms and authored bone poses unchanged', () => {
const { root, bone, mesh } = skinFixture()
refreshSceneModelBounds(root)
root.position.set(-7.5, 1.06, -0.45)
root.rotation.set(0.1, 0.4, -0.2)
root.scale.set(6, 4, 2)
bone.position.set(1, 17, 3)
const before = { position: root.position.toArray(), rotation: root.rotation.toArray(), scale: root.scale.toArray(), bone: bone.position.toArray() }
const staleBox = mesh.boundingBox.clone()
const box = refreshSceneModelBounds(root)
assert.deepEqual({ position: root.position.toArray(), rotation: root.rotation.toArray(), scale: root.scale.toArray(), bone: bone.position.toArray() }, before)
assert.notDeepEqual(mesh.boundingBox, staleBox)
const vertex = new THREE.Vector3()
const generousBox = box.clone().expandByScalar(1e-8)
for (let i = 0; i < mesh.geometry.getAttribute('position').count; i += 1) {
mesh.getVertexPosition(i, vertex)
assert.ok(vertex.distanceTo(mesh.boundingSphere.center) <= mesh.boundingSphere.radius + 1e-8)
assert.ok(generousBox.containsPoint(vertex.applyMatrix4(mesh.matrixWorld)))
}
})

test('declared terrain ground datum stays on the scene floor', () => {
const root = new THREE.Group()
root.add(new THREE.Mesh(new THREE.BoxGeometry(8, 4, 10), new THREE.MeshBasicMaterial()))
fitSceneModel(root, 20, -1.5)
close(-1.5 * root.scale.y + root.position.y, 0)
})

test('scale input accepts real GLTF normalization factors below 0.05', () => {
close(parseSceneScale('0.0361637593257', 6), 0.0361637593257)
close(parseSceneScale('0.000001', 6), 0.000001)
close(parseSceneScale('1e-9', 6), 0.000001)
for (const value of ['', ' ', 'invalid', 'NaN', 'Infinity', '0', '-2']) {
close(parseSceneScale(value, 0.036164), 0.036164)
}
})

test('scale display preserves small factors and trims unnecessary zeros', () => {
assert.equal(formatSceneScale(0.0361637593257), '0.03616376')
assert.equal(formatSceneScale(0.000001), '0.000001')
assert.equal(formatSceneScale(1), '1')
assert.equal(formatSceneScale(0.6), '0.6')
})

+ 224
- 0
tools/scene-model-visibility.e2e.mjs View File

@@ -0,0 +1,224 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium, expect } from '@playwright/test';
import { localCredentials } from './review-api.mjs';

// Run against the local Vite application after saving the intended scene camera:
// node tools/scene-model-visibility.e2e.mjs
// Every scene uses a fresh browser context. Content and asset writes are blocked;
// the only server mutations permitted are login and active-role session changes.
const baseURL = process.env.SCENE_VISIBILITY_BASE_URL || 'http://127.0.0.1:6180';
const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/scene-live-20260905');
const cases = [
{ sceneId: '160', modelProjectId: '157', modelVersionId: '201', label: '狐狸野外训练区', screenshot: '26-fox-visual-regression.png' },
{ sceneId: '159', modelProjectId: '156', modelVersionId: '204', label: '牛奶卡车检验区', screenshot: '27-truck-visual-regression.png' },
];
const credentials = localCredentials();
const evidence = {
generatedAt: new Date().toISOString(),
baseURL,
browser: 'Microsoft Edge / Playwright',
method: 'Fresh context; saved camera; compare rendered pixels with model roots visible versus hidden; model shadow casting disabled in both samples.',
threshold: { sampleWidth: 320, sampleHeight: 180, minimumChangedPixelsExclusive: 500, minimumRgbDelta: 12 },
status: 'RUNNING',
results: [],
};
fs.mkdirSync(path.join(reportDir, 'screenshots'), { recursive: true });

function persist() {
fs.writeFileSync(path.join(reportDir, 'visibility-evidence.json'), `${JSON.stringify(evidence, null, 2)}\n`);
}

function safeError(error) {
let message = error instanceof Error ? error.message : String(error);
for (const value of [credentials.username, credentials.password]) {
if (value) message = message.split(value).join('[redacted]');
}
return message.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]');
}

async function signIn(page) {
await page.goto('/login', { waitUntil: 'domcontentloaded' });
await expect(page.locator('[data-role-code="admin"]')).toBeVisible({ timeout: 45_000 });
await page.locator('[data-role-code="admin"]').click();
await page.locator('input[name="username"]').fill(credentials.username);
await page.locator('input[name="password"]').fill(credentials.password);
await page.getByRole('button', { name: '登录系统', exact: true }).click();
await expect(page.locator('.platform-shell')).toBeVisible({ timeout: 60_000 });
await page.goto('/profile', { waitUntil: 'domcontentloaded' });
const teacher = page.locator('.profile-role-switcher button').filter({ hasText: '教员' });
await expect(teacher).toBeVisible();
let roleSwitchAttempts = 0;
while (!(await teacher.getAttribute('class'))?.includes('active') && roleSwitchAttempts < 3) {
roleSwitchAttempts += 1;
await teacher.click();
try {
await expect(teacher).toHaveClass(/active/, { timeout: 15_000 });
} catch {
// The local auth service occasionally rejects the initial role switch.
// Retry only while the requested role is still inactive; never retry or
// weaken any scene, version, visibility or pixel-contribution assertion.
if (roleSwitchAttempts === 3) throw new Error('Teacher role remained inactive after three session-switch attempts');
}
}
await expect(teacher).toHaveClass(/active/);
return { role: 'teacher', roleSwitchAttempts };
}

async function readPixelContribution(page, testCase) {
return page.evaluate(async ({ modelProjectId, modelVersionId }) => {
const editor = globalThis.__visibilityScene;
const THREE = globalThis.__visibilityThree;
if (!editor?.readySettled || editor.destroyed) throw new Error('Scene editor is not ready');
if (!THREE) throw new Error('The editor Three runtime was not exposed');
editor.selectObject(null);
const roots = editor.objects.filter(object => String(object.userData?.targetProjectId) === modelProjectId);
if (!roots.length) throw new Error(`Scene contains no model reference for project ${modelProjectId}`);
if (roots.some(object => String(object.userData?.targetVersionId) !== modelVersionId)) {
throw new Error(`Scene references an unexpected published model version for project ${modelProjectId}`);
}

const inheritedVisible = object => {
for (let node = object; node; node = node.parent) if (!node.visible) return false;
return true;
};
const measurements = roots.map(root => {
root.updateMatrixWorld(true);
const bounds = new THREE.Box3().setFromObject(root);
const size = bounds.getSize(new THREE.Vector3());
let meshes = 0;
let visibleMeshes = 0;
let skinnedMeshes = 0;
root.traverse(node => {
if (!node.isMesh) return;
meshes += 1;
if (inheritedVisible(node)) visibleMeshes += 1;
if (node.isSkinnedMesh) skinnedMeshes += 1;
});
return {
name: root.userData.displayName || root.name,
sceneObjectId: root.userData.sceneId,
targetProjectId: String(root.userData.targetProjectId),
targetVersionId: String(root.userData.targetVersionId),
visible: inheritedVisible(root), meshes, visibleMeshes, skinnedMeshes,
position: root.position.toArray(), scale: root.scale.toArray(),
bounds: { min: bounds.min.toArray(), max: bounds.max.toArray(), size: size.toArray() },
finiteBounds: !bounds.isEmpty() && [...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite),
};
});

const sample = document.createElement('canvas');
sample.width = 320;
sample.height = 180;
const sampleContext = sample.getContext('2d', { willReadFrequently: true });
if (!sampleContext) throw new Error('2D pixel sampling is unavailable');
const readPixels = () => {
editor.renderer.render(editor.scene, editor.camera);
sampleContext.clearRect(0, 0, sample.width, sample.height);
sampleContext.drawImage(editor.renderer.domElement, 0, 0, sample.width, sample.height);
return sampleContext.getImageData(0, 0, sample.width, sample.height).data;
};
const visibility = roots.map(root => root.visible);
const shadowCasters = [];
let before;
let withoutModels;
try {
// A shadow alone must not satisfy the visibility check.
for (const root of roots) root.traverse(node => {
if (node.isMesh) { shadowCasters.push([node, node.castShadow]); node.castShadow = false; }
});
editor.renderer.shadowMap.needsUpdate = true;
before = readPixels();
for (const root of roots) root.visible = false;
editor.renderer.shadowMap.needsUpdate = true;
withoutModels = readPixels();
} finally {
roots.forEach((root, index) => { root.visible = visibility[index]; });
shadowCasters.forEach(([mesh, castShadow]) => { mesh.castShadow = castShadow; });
editor.renderer.shadowMap.needsUpdate = true;
editor.renderer.render(editor.scene, editor.camera);
}
let changedPixels = 0;
let rgbDeltaSum = 0;
for (let offset = 0; offset < before.length; offset += 4) {
const delta = Math.abs(before[offset] - withoutModels[offset])
+ Math.abs(before[offset + 1] - withoutModels[offset + 1])
+ Math.abs(before[offset + 2] - withoutModels[offset + 2]);
rgbDeltaSum += delta;
if (delta >= 12) changedPixels += 1;
}
return {
camera: { position: editor.camera.position.toArray(), target: editor.controls.target.toArray(), fov: editor.camera.fov },
ready: editor.readySettled, pendingLoads: editor.pendingLoads, modelRoots: measurements,
sample: { width: 320, height: 180, changedPixels, changedPercentage: Number((changedPixels / 576).toFixed(3)), rgbDeltaSum },
};
}, testCase);
}

const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
for (const testCase of cases) {
const blockedWrites = [];
const context = await browser.newContext({ baseURL, viewport: { width: 1920, height: 1080 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' });
const result = { ...testCase, status: 'RUNNING', blockedWrites };
evidence.results.push(result);
try {
await context.route('**/*', async route => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && (pathname.startsWith('/api/tran/') || pathname.startsWith('/fileapi/'))) {
blockedWrites.push({ method: request.method(), path: pathname });
await route.abort('blockedbyclient');
return;
}
await route.continue();
});
const page = await context.newPage();
page.setDefaultTimeout(45_000);
// Read handles are injected into the module already loaded by the page.
// Re-importing an unversioned URL after HMR would create a second editor
// module and observe the wrong instance. No business logic is replaced.
await page.route('**/src/features/guide-legacy/demo-src/scene-editor.js*', async route => {
const response = await route.fetch();
const body = await response.text();
const marker = 'activeSceneEditor = new WorkshopSceneEditor(container, context);';
assert.ok(body.includes(marker), 'Scene editor instrumentation anchor must exist');
await route.fulfill({ response, body: body.replace(marker,
`${marker} globalThis.__visibilityScene = activeSceneEditor; globalThis.__visibilityThree = THREE;`) });
});
result.session = await signIn(page);
const sceneRead = page.waitForResponse(response => response.request().method() === 'GET'
&& new URL(response.url()).pathname === `/api/tran/v1/content/projects/${testCase.sceneId}`);
await page.goto(`/content/scenes/${testCase.sceneId}/edit`, { waitUntil: 'domcontentloaded' });
const response = await sceneRead;
assert.equal(response.status(), 200, 'Scene project API must return 200');
const { data } = await response.json();
result.project = { id: String(data.project.id), name: data.project.name, status: data.project.status, currentVersionId: String(data.currentVersion.id), publishedVersionId: String(data.project.publishedVersionId) };
const shell = page.locator('.se-shell');
await expect(shell).toHaveAttribute('data-ready', 'true', { timeout: 180_000 });
await expect(shell).not.toHaveClass(/model-loading/, { timeout: 180_000 });
result.visibility = await readPixelContribution(page, testCase);
result.screenshot = `screenshots/${testCase.screenshot}`;
await page.screenshot({ path: path.join(reportDir, result.screenshot), fullPage: true, timeout: 60_000 });
assert.ok(result.visibility.modelRoots.every(root => root.visible && root.visibleMeshes > 0 && root.finiteBounds), 'Model meshes and finite bounds must be visible');
assert.ok(result.visibility.sample.changedPixels > 500, `Expected more than 500 model-contributed pixels; received ${result.visibility.sample.changedPixels}`);
assert.equal(blockedWrites.length, 0, 'Read-only regression must not attempt content or file writes');
result.status = 'PASS';
console.log(JSON.stringify({ sceneId: testCase.sceneId, status: result.status, changedPixels: result.visibility.sample.changedPixels, screenshot: result.screenshot }));
} catch (error) {
result.status = 'FAIL';
result.error = safeError(error);
console.error(JSON.stringify({ sceneId: testCase.sceneId, status: result.status, error: result.error }));
} finally {
await context.close();
persist();
}
}
evidence.status = evidence.results.every(result => result.status === 'PASS') ? 'PASS' : 'FAIL';
persist();
if (evidence.status !== 'PASS') process.exitCode = 1;
} finally {
await browser.close();
}

Loading…
Cancel
Save