| @@ -2,6 +2,7 @@ node_modules/ | |||
| .env | |||
| .env.* | |||
| !.env.example | |||
| reports/**/build.log | |||
| artifacts/html-report/ | |||
| artifacts/test-results/ | |||
| artifacts/trace-inspect-*/ | |||
| @@ -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": "系统管理员" | |||
| } | |||
| ] | |||
| } | |||
| ] | |||
| } | |||
| @@ -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首次导入临时工程已通过业务接口软删除。历史发布版本及封面保留。" | |||
| } | |||
| @@ -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>{ | |||
| "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" | |||
| }</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>{ | |||
| "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" | |||
| }</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>{ | |||
| "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 | |||
| }</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>{ | |||
| "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 | |||
| }</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>{ | |||
| "details": { | |||
| "oldVersionId": "203", | |||
| "newVersionId": "204", | |||
| "oldSha256": "15caa821e90a70d5b0f8ba1554c0d0d6db332a9b5e53b0d5e94de24d7663533e", | |||
| "newSha256": "7b7cc4cc75a3a355f26e2ff15ae1d26b5964ad7b370061691a94ce5f6f4b9158" | |||
| } | |||
| }</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>{ | |||
| "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 | |||
| }</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>{ | |||
| "details": { | |||
| "httpStatus": 503, | |||
| "contentSaveStatus": 200, | |||
| "originalX": 0.25, | |||
| "savedX": 0.35, | |||
| "coverUnchanged": 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>{ | |||
| "details": { | |||
| "elapsedMs": 16159, | |||
| "coverUploads": 0, | |||
| "savedX": 0.45, | |||
| "coverUnchanged": 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>{ | |||
| "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 | |||
| }</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>{ | |||
| "details": { | |||
| "failedReads": 1, | |||
| "restoredImages": 2, | |||
| "sort": "CREATED_DESC" | |||
| } | |||
| }</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>{ | |||
| "details": { | |||
| "releasedUrls": 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>{ | |||
| "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" | |||
| } | |||
| }</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>{ | |||
| "details": { | |||
| "id": "158", | |||
| "status": "业务软删除", | |||
| "readAfterDelete": 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>{ | |||
| "details": { | |||
| "anonymous": 401, | |||
| "missing": 404, | |||
| "range": 206, | |||
| "rangeBytes": 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>{ | |||
| "details": { | |||
| "projects": [ | |||
| { | |||
| "id": "156", | |||
| "status": "PUBLISHED", | |||
| "versionId": "204" | |||
| }, | |||
| { | |||
| "id": "157", | |||
| "status": "DRAFT", | |||
| "versionId": "202" | |||
| } | |||
| ], | |||
| "unexpectedUploads": 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>{ | |||
| "status": "PASS", | |||
| "details": "最终web类型检查、Vite构建、10个生成包payload哈希与API桥接/凭据门禁通过;现有Three资源URL与chunk体积警告未阻断构建。" | |||
| }</pre></div><div class="command"><h3>mvn -B -ntp -pl ut_tran -Dtest=ContentProjectServiceTest,ContentAssetServiceTest,ContentControllerRangeTest,ContentAuthorizerTest test</h3><pre>{ | |||
| "status": "PASS", | |||
| "details": "Java17,93项通过。" | |||
| }</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>{ | |||
| "status": "PASS", | |||
| "details": "截图12项+封面事务8项,20/20通过;既有模型API与导入失败保护回归也通过。" | |||
| }</pre></div></section> | |||
| <h2>当前行为与限制</h2><section class="panel"><ul><li>仅手动保存和首次导入模型时生成或更新封面,封面取自当前三维视口。</li><li>自动保存只保存工程变更,不重新截图或上传封面。</li><li>截图或封面上传失败时保留旧封面,并提示失败;工程保存仍按其实际请求结果判断。</li></ul></section> | |||
| <footer>报告由 evidence.json 生成,未修改原始证据。所有引用图片在生成时校验为存在且非空;点击图片可查看原图。</footer> | |||
| </main></body></html> | |||
| @@ -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。 | |||
| [](screenshots/02-truck-new-view.jpg) | |||
| ### 实测模型-狐狸动画-已验证-20260905 | |||
| 项目 157;最终状态:DRAFT;工程版本 ID:202;封面资产 ID:2067。 | |||
| [](screenshots/06-fox-restored-cover.jpg) | |||
| ## 最终列表现场 | |||
| [](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> | |||
| { | |||
| "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" | |||
| } | |||
| </pre> | |||
| </details> | |||
| <details><summary>更换卡车视角再次保存:替换当前封面且辅助控件恢复:检查细节</summary> | |||
| <pre> | |||
| { | |||
| "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" | |||
| } | |||
| </pre> | |||
| </details> | |||
| <details><summary>更换相机视角更新封面,已发布历史版本保留旧封面:检查细节</summary> | |||
| <pre> | |||
| { | |||
| "oldVersionId": "203", | |||
| "newVersionId": "204", | |||
| "oldSha256": "15caa821e90a70d5b0f8ba1554c0d0d6db332a9b5e53b0d5e94de24d7663533e", | |||
| "newSha256": "7b7cc4cc75a3a355f26e2ff15ae1d26b5964ad7b370061691a94ce5f6f4b9158" | |||
| } | |||
| </pre> | |||
| </details> | |||
| <details><summary>手动保存狐狸:生成与卡车不同的真实模型封面:检查细节</summary> | |||
| <pre> | |||
| { | |||
| "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" | |||
| } | |||
| </pre> | |||
| </details> | |||
| <details><summary>注入封面上传503:模型位置保存成功,旧封面保留并提示:检查细节</summary> | |||
| <pre> | |||
| { | |||
| "httpStatus": 503, | |||
| "contentSaveStatus": 200, | |||
| "originalX": 0.25, | |||
| "savedX": 0.35, | |||
| "coverUnchanged": true | |||
| } | |||
| </pre> | |||
| </details> | |||
| <details><summary>实际自动保存周期保存位置变更,不再次上传封面:检查细节</summary> | |||
| <pre> | |||
| { | |||
| "elapsedMs": 16159, | |||
| "coverUploads": 0, | |||
| "savedX": 0.45, | |||
| "coverUnchanged": true | |||
| } | |||
| </pre> | |||
| </details> | |||
| <details><summary>解除故障并恢复原模型位置:封面再次更新成功:检查细节</summary> | |||
| <pre> | |||
| { | |||
| "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" | |||
| } | |||
| </pre> | |||
| </details> | |||
| <details><summary>列表显示真实封面;读取失败回退后,同条件搜索可以重试恢复:检查细节</summary> | |||
| <pre> | |||
| { | |||
| "failedReads": 1, | |||
| "restoredImages": 2, | |||
| "sort": "CREATED_DESC" | |||
| } | |||
| </pre> | |||
| </details> | |||
| <details><summary>离开缓存列表后取消封面请求并释放Blob URL:检查细节</summary> | |||
| <pre> | |||
| { | |||
| "releasedUrls": 2 | |||
| } | |||
| </pre> | |||
| </details> | |||
| <details><summary>首次导入GLB自动生成封面,模型文件与图片均完成受控绑定:检查细节</summary> | |||
| <pre> | |||
| { | |||
| "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" | |||
| } | |||
| </pre> | |||
| </details> | |||
| <details><summary>清理首次导入临时模型:检查细节</summary> | |||
| <pre> | |||
| { | |||
| "id": "158", | |||
| "status": "业务软删除", | |||
| "readAfterDelete": 404 | |||
| } | |||
| </pre> | |||
| </details> | |||
| <details><summary>封面读取边界:未登录401、无封面404、图片Range正确:检查细节</summary> | |||
| <pre> | |||
| { | |||
| "anonymous": 401, | |||
| "missing": 404, | |||
| "range": 206, | |||
| "rangeBytes": 32 | |||
| } | |||
| </pre> | |||
| </details> | |||
| <details><summary>全新浏览器会话显示两张持久化封面,重开模型不自动上传图片:检查细节</summary> | |||
| <pre> | |||
| { | |||
| "projects": [ | |||
| { | |||
| "id": "156", | |||
| "status": "PUBLISHED", | |||
| "versionId": "204" | |||
| }, | |||
| { | |||
| "id": "157", | |||
| "status": "DRAFT", | |||
| "versionId": "202" | |||
| } | |||
| ], | |||
| "unexpectedUploads": 0 | |||
| } | |||
| </pre> | |||
| </details> | |||
| ## 数据保留与清理 | |||
| 保留156卡车、157狐狸及新封面;卡车恢复已发布,狐狸保留原草稿状态。故障测试位置改动已恢复。158首次导入临时工程已通过业务接口软删除。历史发布版本及封面保留。 | |||
| | 项目 | 初始状态 | 最终状态 | | |||
| |---|---|---| | |||
| | 156 · 实测模型-牛奶卡车-已验证-20260905 | PUBLISHED | PUBLISHED | | |||
| | 157 · 实测模型-狐狸动画-已验证-20260905 | DRAFT | DRAFT | | |||
| 首次导入验证使用的临时项目 158 已通过业务软删除,删除后读取返回 404;报告保留操作证据,不宣称物理文件已删除。 | |||
| ## 问题记录 | |||
| 当前证据未登记未解决问题。 | |||
| ## 实际命令与结果 | |||
| **pnpm run build** | |||
| <pre> | |||
| { | |||
| "status": "PASS", | |||
| "details": "最终web类型检查、Vite构建、10个生成包payload哈希与API桥接/凭据门禁通过;现有Three资源URL与chunk体积警告未阻断构建。" | |||
| } | |||
| </pre> | |||
| **mvn -B -ntp -pl ut_tran -Dtest=ContentProjectServiceTest,ContentAssetServiceTest,ContentControllerRangeTest,ContentAuthorizerTest test** | |||
| <pre> | |||
| { | |||
| "status": "PASS", | |||
| "details": "Java17,93项通过。" | |||
| } | |||
| </pre> | |||
| **node --test --test-reporter=spec tools/model-cover-capture.test.mjs tools/model-cover-api.test.mjs** | |||
| <pre> | |||
| { | |||
| "status": "PASS", | |||
| "details": "截图12项+封面事务8项,20/20通过;既有模型API与导入失败保护回归也通过。" | |||
| } | |||
| </pre> | |||
| ## 当前行为与限制 | |||
| - 仅手动保存和首次导入模型时生成或更新封面,封面取自当前三维视口。 | |||
| - 自动保存只保存工程变更,不重新截图或上传封面。 | |||
| - 截图或封面上传失败时保留旧封面,并提示失败;工程保存仍按其实际请求结果判断。 | |||
| 报告由 evidence.json 生成,未修改原始证据;图片均已校验存在且非空,点击图片可查看原图。 | |||
| @@ -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`,必须同步补图片资产读取映射及鉴权加载,才是完整功能。 | |||
| @@ -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 | |||
| } | |||
| ] | |||
| } | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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 | |||
| ### 现场截图 | |||
|  | |||
|  | |||
|  | |||
|  | |||
|  | |||
|  | |||
|  | |||
|  | |||
|  | |||
|  | |||
|  | |||
| ## 模型 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 | |||
| ### 现场截图 | |||
|  | |||
|  | |||
|  | |||
|  | |||
|  | |||
|  | |||
|  | |||
|  | |||
|  | |||
| ## 补充验证 | |||
| ### 最终项目列表 | |||
| - 类别: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 | |||
| - 详情:两条测试数据未设置自定义封面,列表沿用现有默认装备缩略图;编辑器内已分别验证真实卡车与狐狸。若需要导入后自动生成模型封面,属于另行确认的功能增强。 | |||
| ## 补充截图 | |||
|  | |||
|  | |||
|  | |||
| ## 遗留数据与保留说明 | |||
| | 项目 | 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;模型对象明细未展开。 | |||
| @@ -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": "保留两条真实场景及历史截图用于后续测试和追溯。新增导入狐狸仅在隔离浏览器会话中测试,未保存、无写请求;测试结束后丢弃临时对象。" | |||
| } | |||
| @@ -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>{ | |||
| "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" | |||
| } | |||
| }</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>{ | |||
| "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" | |||
| } | |||
| }</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>{ | |||
| "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 个导入模型可跨刷新恢复" | |||
| } | |||
| }</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>{ | |||
| "superseded": 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>{ | |||
| "details": { | |||
| "sceneId": "160", | |||
| "oldCoverUri": "fsvc://unreal-tran/image/2026/09/05/bb1e8717cd2e7ba6ca10f16b817b1ec5.jpg", | |||
| "putRequests": 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>{ | |||
| "superseded": true, | |||
| "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" | |||
| } | |||
| }</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>{ | |||
| "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 | |||
| } | |||
| ] | |||
| } | |||
| }</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>{ | |||
| "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 | |||
| ] | |||
| } | |||
| }</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>{ | |||
| "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 | |||
| ] | |||
| } | |||
| }</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>{ | |||
| "title": "狐狸存在于对象树但不在真实视口中显示", | |||
| "status": "FIXED_AND_VISUALLY_VERIFIED", | |||
| "details": "原完整 E2E 通过结论已撤回。同步蒙皮矩阵并保留归一化缩放,修复场景数据和镜头后,新增导入、保存发布、独立刷新与像素可见性均通过;详见更正区。" | |||
| }</pre></section> | |||
| <h2>后续独立事项</h2><section class="panel"><p>既存受控文件资产的 fsvc URI 分支未完整覆盖上传票据绑定校验,属于本次场景封面变更前已存在的问题。本次保留现有流程,后续应独立补齐校验并验证历史资产、版本复制与引用兼容性;不计入本次场景功能失败数。</p><pre>{ | |||
| "scope": "independent", | |||
| "title": "fsvc 新资产上传票据绑定校验", | |||
| "details": "既存流程的 fsvc URI 分支未完整覆盖上传票据绑定校验;本次未扩大修改范围,应单独补齐并验证历史资产、版本复制与引用兼容性。" | |||
| }</pre></section> | |||
| <h2>实际命令与结果</h2><section class="panel"><div class="command"><h3>pnpm run build</h3><pre>{ | |||
| "status": "PASS", | |||
| "details": "前端生产构建及遗留模型编辑器产物校验通过。" | |||
| }</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>{ | |||
| "status": "PASS", | |||
| "details": "33 / 33 通过:封面同次 PUT、异常回退、并发队列、项目切换、截图尺寸、渲染状态恢复,以及蒙皮模型包围盒与归一化缩放回归。" | |||
| }</pre></div><div class="command"><h3>原 Microsoft Edge 场景链路 E2E(更正说明)</h3><pre>{ | |||
| "status": "SUPERSEDED", | |||
| "details": "原 7/7 只覆盖模型引用、保存、封面上传和发布等链路,未证明狐狸真实可见;完整通过结论已撤回,当前视觉验证见下列独立浏览器 E2E。" | |||
| }</pre></div><div class="command"><h3>node tools/scene-model-visibility.e2e.mjs</h3><pre>{ | |||
| "status": "PASS", | |||
| "details": "独立 Edge / Playwright 会话重开 160/208 与 159/209,使用保存镜头,显示/隐藏模型并禁用投影进行像素比较;狐狸 4472 像素、卡车 7515 像素,均超过 500 阈值。证据保存在 visibility-evidence.json,截图 26 / 27;无写请求。" | |||
| }</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> | |||
| @@ -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,确认排序依据仍为创建时间。 | |||
| [](screenshots/26-fox-visual-regression.png) | |||
| 下方标为“历史记录(已更正)”的检查及截图保留用于追溯,不计入当前通过率,也不作为狐狸已经可见的证据。 | |||
| 当前证据项通过 7 / 7;失败 0;其他或未标注 0;已更正历史记录 2;已校验图片文件 22。 | |||
| ## 保留场景与封面证据 | |||
| ### 实测场景-牛奶卡车检验区-20260905 | |||
| 场景 159;状态 PUBLISHED;当前版本 209;引用模型 156 / 发布版本 204。 | |||
| [](screenshots/24-truck-final-cover.jpg) | |||
| <details><summary>封面持久化与项目证据</summary> | |||
| <pre> | |||
| { | |||
| "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" | |||
| } | |||
| } | |||
| </pre> | |||
| </details> | |||
| ### 实测场景-狐狸野外训练区-20260905 | |||
| 场景 160;状态 PUBLISHED;当前版本 208;引用模型 157 / 发布版本 201。 | |||
| [](screenshots/22-fox-final-cover.jpg) | |||
| <details><summary>封面持久化与项目证据</summary> | |||
| <pre> | |||
| { | |||
| "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" | |||
| } | |||
| } | |||
| </pre> | |||
| </details> | |||
| ## 最终列表与创建时间倒序 | |||
| [](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) | |||
| ### 2. 牛奶卡车场景保存并回读封面 · PASS | |||
| - 场景资源库双击引用模型 156 的已发布版本 204 | |||
| - 编辑对象坐标与设备绑定后,未保存状态 2.5 秒内无上传或项目写入 | |||
| - 保存草稿发出 SCENE\_COVER JPEG 上传与同一次项目 PUT | |||
| - 认证访问 /cover 返回 JPEG,封面尺寸 640×408,列表可用的 coverUri 已写入 | |||
| [](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) | |||
| ### 3. 牛奶卡车场景刷新恢复并发布 · PASS | |||
| - 整页刷新后模型 156/版本 204、对象名称、X=4.2 和设备编号均恢复 | |||
| - 发布前校验 8 项全部通过 | |||
| - 发布动作先成功保存再成功发布,最终状态为 PUBLISHED | |||
| <details><summary>检查详情</summary> | |||
| <pre> | |||
| { | |||
| "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 个导入模型可跨刷新恢复" | |||
| } | |||
| } | |||
| </pre> | |||
| </details> | |||
| [](screenshots/09-truck-reloaded-persisted.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> | |||
| { | |||
| "superseded": true | |||
| } | |||
| </pre> | |||
| </details> | |||
| [](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) | |||
| ### 5. 场景封面上传失败保留原封面 · PASS | |||
| - 对 SCENE\_COVER 上传实施浏览器网络故障注入 | |||
| - 保存请求仍成功,场景内容继续提交 | |||
| - coverUri 保持为保存前的旧值,未产生空封面或错误替换 | |||
| <details><summary>检查详情</summary> | |||
| <pre> | |||
| { | |||
| "details": { | |||
| "sceneId": "160", | |||
| "oldCoverUri": "fsvc://unreal-tran/image/2026/09/05/bb1e8717cd2e7ba6ca10f16b817b1ec5.jpg", | |||
| "putRequests": 1 | |||
| } | |||
| } | |||
| </pre> | |||
| </details> | |||
| [](screenshots/16-fox-cover-upload-failed-content-saved.png) | |||
| ### 6. 狐狸野外场景刷新恢复并发布 · 历史记录(已更正) | |||
| - 整页刷新后狐狸模型依赖仍为项目 157 的已发布版本 201 | |||
| - 封面上传失败后的场景正文编辑 X=-7.5 已恢复 | |||
| - 8 项发布校验全部通过,发布时重新保存并成功发布 | |||
| - 发布后的 coverUri 有效;保存前封面与发布后封面均由当前场景视图生成 | |||
| <details><summary>检查详情</summary> | |||
| <pre> | |||
| { | |||
| "superseded": true, | |||
| "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" | |||
| } | |||
| } | |||
| </pre> | |||
| </details> | |||
| [](screenshots/17-fox-reloaded-persisted.png) | |||
| [](screenshots/18-fox-published.png) | |||
| ### 7. 场景列表创建时间倒序与封面展示 · PASS | |||
| - 列表请求默认携带 sort=CREATED\_DESC | |||
| - 较晚创建的狐狸野外场景显示在较早创建的牛奶卡车场景之前 | |||
| - 卡车于 19:32 更新,晚于狐狸的 19:31,但狐狸仍排在卡车之前,证实没有按更新时间倒序 | |||
| - 两条场景均通过受控 /cover 接口展示修复后服务器 JPEG 封面;列表中可辨认狐狸和卡车 | |||
| <details><summary>检查详情</summary> | |||
| <pre> | |||
| { | |||
| "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 | |||
| } | |||
| ] | |||
| } | |||
| } | |||
| </pre> | |||
| </details> | |||
| [](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> | |||
| { | |||
| "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 | |||
| ] | |||
| } | |||
| } | |||
| </pre> | |||
| </details> | |||
| [](screenshots/25-fox-fresh-import-visible.png) | |||
| [](screenshots/21-fox-corrected-published.png) | |||
| [](screenshots/22-fox-final-cover.jpg) | |||
| [](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> | |||
| { | |||
| "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 | |||
| ] | |||
| } | |||
| } | |||
| </pre> | |||
| </details> | |||
| [](screenshots/23-truck-corrected-published.png) | |||
| [](screenshots/24-truck-final-cover.jpg) | |||
| [](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> | |||
| { | |||
| "title": "狐狸存在于对象树但不在真实视口中显示", | |||
| "status": "FIXED_AND_VISUALLY_VERIFIED", | |||
| "details": "原完整 E2E 通过结论已撤回。同步蒙皮矩阵并保留归一化缩放,修复场景数据和镜头后,新增导入、保存发布、独立刷新与像素可见性均通过;详见更正区。" | |||
| } | |||
| </pre> | |||
| ## 后续独立事项 | |||
| 既存受控文件资产的 fsvc URI 分支未完整覆盖上传票据绑定校验,属于本次场景封面变更前已存在的问题。本次保留现有流程,后续应独立补齐校验并验证历史资产、版本复制与引用兼容性;不计入本次场景功能失败数。 | |||
| <pre> | |||
| { | |||
| "scope": "independent", | |||
| "title": "fsvc 新资产上传票据绑定校验", | |||
| "details": "既存流程的 fsvc URI 分支未完整覆盖上传票据绑定校验;本次未扩大修改范围,应单独补齐并验证历史资产、版本复制与引用兼容性。" | |||
| } | |||
| </pre> | |||
| ## 实际命令与结果 | |||
| **pnpm run build** | |||
| <pre> | |||
| { | |||
| "status": "PASS", | |||
| "details": "前端生产构建及遗留模型编辑器产物校验通过。" | |||
| } | |||
| </pre> | |||
| **node --test tools/scene-cover-api.test.mjs tools/scene-cover-capture.test.mjs tools/scene-model-placement.test.mjs** | |||
| <pre> | |||
| { | |||
| "status": "PASS", | |||
| "details": "33 / 33 通过:封面同次 PUT、异常回退、并发队列、项目切换、截图尺寸、渲染状态恢复,以及蒙皮模型包围盒与归一化缩放回归。" | |||
| } | |||
| </pre> | |||
| **原 Microsoft Edge 场景链路 E2E(更正说明)** | |||
| <pre> | |||
| { | |||
| "status": "SUPERSEDED", | |||
| "details": "原 7/7 只覆盖模型引用、保存、封面上传和发布等链路,未证明狐狸真实可见;完整通过结论已撤回,当前视觉验证见下列独立浏览器 E2E。" | |||
| } | |||
| </pre> | |||
| **node tools/scene-model-visibility.e2e.mjs** | |||
| <pre> | |||
| { | |||
| "status": "PASS", | |||
| "details": "独立 Edge / Playwright 会话重开 160/208 与 159/209,使用保存镜头,显示/隐藏模型并禁用投影进行像素比较;狐狸 4472 像素、卡车 7515 像素,均超过 500 阈值。证据保存在 visibility-evidence.json,截图 26 / 27;无写请求。" | |||
| } | |||
| </pre> | |||
| ## 当前行为与限制 | |||
| - 手动保存从当前三维视口生成封面;新封面与场景内容、模型引用在同一次工程更新中关联保存。发布前的保存沿用该流程。 | |||
| - 截图或封面上传失败时保留旧封面;工程是否保存成功以实际更新请求为准。版本冲突不覆盖其他已保存的内容。 | |||
| - 自动恢复及重新打开工程不生成或上传新封面;恢复后的修改需由用户保存后更新封面。 | |||
| - 列表按创建时间倒序展示。再次保存不会改变创建时间,也不应因此改变创建时间排序。 | |||
| - 牛奶卡车场景保存的默认镜头中,车身部分被场景立柱遮挡;真实网格可见且像素差异验证通过。需要观察完整车身时可旋转镜头。 | |||
| 报告来自 evidence.json,未修改原始证据;所有引用图片已校验存在且非空,点击可查看原图。通过数随证据更新。 | |||
| @@ -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 | |||
| } | |||
| } | |||
| } | |||
| ] | |||
| } | |||
| @@ -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 => ({ | |||
| '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', | |||
| })[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 ? `[})](${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 })) | |||
| @@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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(``); | |||
| } | |||
| 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; | |||
| }); | |||
| } | |||
| @@ -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 => ({ | |||
| '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', | |||
| })[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 | |||
| ? `[})](${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 })) | |||
| @@ -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); | |||
| }); | |||
| @@ -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); | |||
| }); | |||
| @@ -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, /新修改仍待保存/) | |||
| }) | |||
| @@ -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) | |||
| }) | |||
| @@ -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') | |||
| }) | |||
| @@ -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(); | |||
| } | |||