Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 

301 рядки
65 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import crypto from 'node:crypto'
  4. import { fileURLToPath } from 'node:url'
  5. import { resolveBrowserProof, screenshotProofStatus } from './model-editor-report-evidence.mjs'
  6. // This tool renders evidence. It neither exercises the application nor reads credentials.
  7. const toolDir = path.dirname(fileURLToPath(import.meta.url))
  8. const reportDir = path.resolve(toolDir, '../reports/model-editor-closure-20260906')
  9. const workspace = path.resolve(toolDir, '../../..')
  10. fs.mkdirSync(path.join(reportDir, 'data'), { recursive: true })
  11. const baseline = path.join(workspace, '.codex-tmp/model-closure-20260906/inventory-main.md')
  12. if (fs.existsSync(baseline)) fs.copyFileSync(baseline, path.join(reportDir, 'data/inventory-main.md'))
  13. const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
  14. const arr = value => Array.isArray(value) ? value : []
  15. const encode = value => value.replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/')
  16. const clean = value => typeof value === 'string' ? value.replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]').replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+\b/g, '[已隐藏]')
  17. : Array.isArray(value) ? value.map(clean)
  18. : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).filter(([key]) => !/^(password|secret|accessToken|refreshToken|token|authorization|cookie|uploadTicket|credentials)$/i.test(key)).map(([key, child]) => [key, clean(child)])) : value
  19. const missing = [], malformed = []
  20. function read(name, required = false) {
  21. const file = path.join(reportDir, name)
  22. if (!fs.existsSync(file)) { if (required) missing.push(name); return null }
  23. try { return clean(JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))) }
  24. catch (error) { malformed.push({ file: name, error: error.message }); return null }
  25. }
  26. function state(value) {
  27. if (value === true) return 'PASS'
  28. if (value === false) return 'FAIL'
  29. const text = String(value ?? '').toUpperCase()
  30. if (['PASS', 'PASSED', 'SUCCESS', 'SUCCEEDED', 'EXPECTED'].includes(text)) return 'PASS'
  31. if (['FAIL', 'FAILED', 'ERROR', 'UNEXPECTED', 'TIMEDOUT', 'INTERRUPTED'].includes(text)) return 'FAIL'
  32. if (['FLAKY', 'WARN', 'WARNING'].includes(text)) return 'WARN'
  33. if (['SKIP', 'SKIPPED'].includes(text)) return 'SKIP'
  34. return 'PENDING'
  35. }
  36. const labels = { PASS: '已验证', FAIL: '失败', WARN: '需复核', PENDING: '待验证', SKIP: '未执行' }
  37. const badge = (value, label) => `<span class="badge ${value.toLowerCase()}">${esc(label || labels[value] || value)}</span>`
  38. const link = (file, label = file) => fs.existsSync(path.resolve(reportDir, file)) ? `<a href="${encode(file)}">${esc(label)}</a>` : `<span class="muted">${esc(label)} · 待提供</span>`
  39. const pretty = value => `<pre>${esc(JSON.stringify(value, null, 2))}</pre>`
  40. const sourceFiles = { main: '../../../unreal_tran/src/main.js', timeline: '../../../unreal_tran/src/timeline.js', blueprint: '../../../unreal_tran/src/blueprint.js', curve: '../../../unreal_tran/src/model-curve-editor.js', state: '../../../unreal_tran/src/model-editor-state.js', api: '../../../unreal_tran/src/model-content-api.js', io: '../../../unreal_tran/src/editor-project-io.js', host: '../../../unreal_tran_web/src/views/content/LegacyModelEditorView.vue', bridge: '../../../unreal_tran_web/src/features/editors/model/legacy-iframe-permissions.ts' }
  41. // Implementation state and verification state are separate. No row is verified by source review alone.
  42. // [id, name, source locator, implementation, boundary, fix, planned acceptance]
  43. const groups = [
  44. ['工程与数据', [
  45. ['project.new', '新建工程', 'host:createNewProject', '本轮修复', '宿主创建新项目 ID 并跳转;新建不是清空并覆盖当前工程。', '新建复用正式 create API;创建失败或取消保留原工程。', '新建前后 ID 不同,原工程内容/版本不被覆盖;取消不发创建请求。'],
  46. ['project.import-glb', '导入装备 GLB', 'main:importModelFile', '已有实现', '只支持单文件 GLB;上传→登记→解析→最终保存。', '延续导入 busy/失败阻断及稳定资产校验。', '选择轻量 GLB,真实模型可见;刷新仍加载同一资产。'],
  47. ['project.import-json', '导入工程 JSON', 'main:importProjectFile', '本轮修复', '导入到当前工程,不创建副本;受控引用必须属于当前可用资产。JSON 不含 GLB 字节。', '导入前 materialize 稳定资产引用。', '同项目导出后再导入,材质/动画/环境不丢;无效或跨项目缺失引用报错。'],
  48. ['project.save', '手动保存 / Ctrl+S', 'main:saveProject', '已有实现', '同一工程保存内容和当前视口封面;成功以 API 响应为准。', '保留队列、版本保护和封面失败回退。', '保存真实 PUT 成功,刷新字段不丢;重复点击不交错。'],
  49. ['project.autosave', '30 秒自动保存', 'main:bindShellEvents', '已有实现', '自动保存工程内容;不反复截图更新封面,恢复稿待确认期间暂停。', '导入/封面保存期间阻断重入。', '修改后等待自动保存并刷新;忙碌时不提交半成品。'],
  50. ['project.name', '修改工程名', 'main:projectNameInput', '已有实现', '输入→失焦/Enter→保存;当前模型工程名。', '无本轮专门改动。', '改名保存后列表和重新打开一致。'],
  51. ['project.export-json', '另存项目 JSON', 'main:saveProjectAs', '已有实现', '下载工程 JSON;不是另建服务端项目,也不内嵌 GLB 文件。', '稳定资产引用保留,临时 blob 地址清除。', '下载文件可解析,包含轨道/蓝图/自定义字段,配套资源可重新解析。'],
  52. ['project.undo', '撤销 / Ctrl+Z', 'main:class HistoryStack', '本轮修复', '最多 60 个历史快照;不是数据库版本回滚。', '异步互斥,失败不移动索引,托管 GLB 快照重新解析。', '属性/曲线修改撤销;连续点击不并发;下载失败索引与修改保留。'],
  53. ['project.redo', '重做 / Ctrl+Y', 'main:class HistoryStack', '本轮修复', '撤销后重做;产生新编辑时清掉旧 redo 分支。', '与撤销共用异步恢复保护。', '重做恢复相同字段及姿态,不回放无效分支。'],
  54. ['project.recovery', '异常恢复副本', 'main:reconcileApiRecovery', '本轮修复', '同版本恢复稿需用户确认;旧版本隔离/下载,不静默覆盖服务器。', '恢复稿重新解析受控资源;指纹忽略对象键顺序,避免已保存页面刷新误生恢复稿。', '同版本恢复可见;旧版本保留/下载;主动保存前不自动 PUT。'],
  55. ['project.conflict', '保存/发布版本冲突', 'api:queueSave', '已有实现', '409 阻断;恢复稿保留。', '发布封面队列同样遵守冲突保护。', '定向模拟 409,无后续发布请求,不误报成功。'],
  56. ['project.permissions', '查看/编辑/新增/发布权限', 'bridge:installLegacyModelPermissions', '本轮修复', '按 content.model.* 权限,不按 teacher/admin 身份推断写权限;只读仍能搜索、选择原生动画和键盘激活预览。', '新建接真实 create 能力;只发布可发布已保存版本;修复查看控件键盘事件误拦截。', '只读查看操作可用且无内容写请求;不可编辑/保存;publisher 不上传/PUT,正常发布已有版本。'],
  57. ['project.leave', '离开/切角色未保存确认', 'host:confirmLeave', '已有实现', '取消留在当前页;保存失败不得离开;上传/保存/发布忙时保留状态。', '新建也复用现有未保存确认。', '取消、放弃、保存失败与 busy 分别验证,没有误清 dirty。'],
  58. ]],
  59. ['资源库与场景树', [
  60. ['resource.catalog', '资源库浏览 / 选择装备', 'main:renderAssets', '本轮修复', '切换装备可能应用其基准时间轴/蓝图;不是把第二个主装备追加进当前模型。', '修复单节点选择器误用导致启动报错;忙碌期间禁止切换资源。', '完整资源库 DOM 初始化、卡片事件、空目录启动正常;切换实际几何可见,保存后资源一致。'],
  61. ['resource.search', '资源搜索', 'main:applyAssetSearch', '已有实现', '按资源名称/关键词过滤现有目录,不搜索服务器全站。', '无本轮专门改动。', '关键字命中、空结果与清空恢复。'],
  62. ['environment.builtin', '内置车间 / 野外 / 装备车库', 'main:resetEnvironmentPlaceholder', '已有实现', '车间/野外为程序化环境;车库为随包 GLB。', '保留真实 GLB 和占位环境来源区别。', '三种切换与重新打开可重现相应环境。'],
  63. ['environment.external', '导入外部车间 / 野外 GLB', 'main:loadEnvironmentSceneFile', '本轮修复', '一个环境根;受控 MODEL_FILE 资产 metadata.usage=environment,与主装备独立。', '上传、最终保存、JSON 与刷新恢复稳定引用;失败阻断半提交。', '小 GLB 上传后主装备不被替换;环境刷新仍可见且资产引用一致。'],
  64. ['resource.loading', '模型加载进度 / 失败 / 重试', 'host:installLegacyModelIframeLoadingScope', '已有实现', '局部加载状态;错误短提示,中性失败状态可重试。', '复用前轮加载范围与错误消息修复。', '资源失败不无限转圈;重试恢复;无凭据泄露。'],
  65. ['tree.browse', '场景树节点与计数', 'main:buildSceneTree', '现有边界', '树行上限 220;统计节点总数不等于当前展开行数。', '无本轮容量重构。', '模型/环境节点可达,计数说明不当作全部节点已经逐项验收。'],
  66. ['tree.expand', '节点展开 / 折叠', 'main:buildSceneTree', '已有实现', '当前界面浏览状态;无须持久化展开布局。', '无本轮专门改动。', '展开和折叠子级不修改模型对象。'],
  67. ['tree.search', '场景树搜索', 'main:buildSceneTree', '已有实现', '搜索当前载入对象,不新增/删除对象。', '无本轮专门改动。', '搜索节点后选中正确对象,清空恢复树。'],
  68. ['tree.visibility', '显示 / 隐藏对象', 'main:buildSceneTree', '本轮修复', '对象 visible 保存;隐藏祖先的子对象不接受射线拾取。', '拾取与高亮统一检查祖先可见性。', '隐藏父级后不能点到子级,保存重开仍隐藏。'],
  69. ['selection.scope', '整体 / 部件级选择', 'main:setSelectionScope', '已有实现', '整体作用主装备根;部件作用实际节点。', '保留当前选择层级合同。', '点击可见 mesh,整体/部件切换选择目标不同。'],
  70. ['selection.sync', '树 / 视口 / 属性选择联动', 'main:selectObject', '本轮修复', '显示实际对象并更新辅助框;环境也是可编辑对象。', '关闭轮廓后重选不恢复辅助框。', '三处名称/对象 ID 一致,重选后轮廓状态保持。'],
  71. ]],
  72. ['三维视图与操作', [
  73. ['view.orbit', '鼠标旋转 / 平移 / 缩放视角', 'main:OrbitControls', '已有实现', '当前三维相机;不是模型变换。', '无本轮专门改动。', '鼠标操作改变镜头但不改变对象局部坐标。'],
  74. ['view.focus', '聚焦选中 / F', 'main:fitCamera', '已有实现', '按当前对象包围盒定位相机。', '无本轮专门改动。', '小模型/部件聚焦后仍可见,不被裁剪。'],
  75. ['view.move', '移动工具 / W', 'main:setTransformMode', '本轮修复', '拖拽编辑对象局部位置,保存后重开一致。', '动画姿态下更新基值只扣实际偏移。', '移动后保存、刷新及动画再播放不漂移。'],
  76. ['view.rotate', '旋转工具 / E', 'main:setTransformMode', '本轮修复', '对象旋转;与当前时间轴旋转叠加需要一致。', '统一动画基值处理。', '旋转后保存/撤销/重做姿态一致。'],
  77. ['view.scale', '缩放工具 / R', 'main:setTransformMode', '已有实现', '对象缩放持久化;时间轴没有缩放轨道 UI。', '无新增缩放动画能力。', '对象缩放保存和重开正确。'],
  78. ['view.grid', '网格开关', 'main:toggleGrid', '已有实现', '临时视图选项,不要求写入工程。', '非持久化不计保存缺陷。', '开关只影响辅助网格,不改变模型。'],
  79. ['view.pbr', 'PBR 光照开关', 'main:handleView', '已有实现', '当前照明预览选项;不是批量材质编辑。', '非持久化不计保存缺陷。', '开关改变显示,物体材质字段不被覆盖。'],
  80. ['view.wireframe', '线框开关', 'main:handleView', '已有实现', '当前视图选项;不承诺保存到工程。', '非持久化不计保存缺陷。', '线框开关恢复正常着色。'],
  81. ['view.outline', '选中轮廓开关', 'main:handleView', '本轮修复', '当前会话偏好,进出预览后保持;不要求跨重载存储。', '重选时不重新泄露轮廓。', '关闭→重选→预览→退出,轮廓仍关闭。'],
  82. ['view.perspective', '透视视角按钮', 'main:handleView', '现有边界', '重置透视观察角度;当前没有正交视角菜单。', '不把临时视图状态当作保存缺陷。', '点击后相机和聚焦目标正常。'],
  83. ['view.snapshot', '下载视口截图', 'main:downloadSnapshot', '已有实现', '当前 canvas PNG,可包含编辑辅助元素;保存封面是独立截图流程。', '无本轮专门改动。', 'PNG 下载可打开,内容与当前视口对应。'],
  84. ['view.panels', '底部页签 / Tab / 面板高度', 'main:switchBottomMode', '已有实现', '时间轴、蓝图、曲线、日志切换;拖分割条只调整当前工作区。', '曲线入口接真实数据。', '各页签显示正确内容,调整高度后画布正确 resize。'],
  85. ['view.preview', '进入 / 退出运行预览', 'main:togglePreview', '本轮修复', '真实当前三维场景;隐藏辅助工具,退出不丢编辑状态。', '进入显式派发 auto;退出停止运行并恢复轮廓策略。', '进入/退出与 Esc,三维仍可见,无辅助框泄漏。'],
  86. ['view.preview-step', '预览播放 / 前后导航', 'main:bindShellEvents', '现有边界', '播放运行蓝图;前后按钮当前按时间轴 ±2 秒,不是按业务工序跳转。', '无新增业务工序导航。', '播放触发真实流程;前后跳时与时间轴一致。'],
  87. ]],
  88. ['对象属性与扩展标记', [
  89. ['property.name', '对象名称', 'main:bindInspectorEvents', '已有实现', '修改当前节点名,标识保持稳定。', '无本轮专门改动。', '树和属性同步,保存重开名称保留。'],
  90. ['property.identity', '对象标识 / 状态说明', 'main:renderInspector', '本轮修复', '标识只读;可见性含祖先状态,阴影取实际castShadow;编辑能力由宿主权限注入。', '不再固定显示“可见/投射阴影”,补宿主可编辑状态钩子。', '标识不可编辑;选中对象、隐藏状态、阴影及只读权限标签一致。'],
  91. ['property.position', '位置 XYZ 数值', 'main:bindInspectorEvents', '本轮修复', '局部坐标;动画当前帧下编辑也能生成正确基值。', '中性姿态与实际应用偏移统一。', '三个轴编辑、保存重开、动画回放一致。'],
  92. ['property.rotation', '旋转 XYZ 数值', 'main:bindInspectorEvents', '本轮修复', '输入角度转弧度存储,显示角度。', '多轨旋转基值不重复扣除。', '度数显示与存储/播放的弧度一致。'],
  93. ['property.scale', '缩放 XYZ 数值', 'main:bindInspectorEvents', '已有实现', '对象当前缩放,不添加新的缩放轨道。', '无本轮专门改动。', '非均匀缩放可保存和重开。'],
  94. ['property.color', '材质基础色', 'main:bindInspectorEvents', '现有边界', '只编辑当前对象第一个材质槽;共享材质可能影响共享该实例的网格。', '界面明确材质槽 1/N。', '颜色修改、保存重开对应当前槽;不宣称所有槽可编辑。'],
  95. ['property.metalness', '材质金属度', 'main:bindInspectorEvents', '现有边界', '当前首槽 PBR 参数 0~1。', '标明材质槽范围。', '拖动参数有效,保存重开值一致。'],
  96. ['property.roughness', '材质粗糙度', 'main:bindInspectorEvents', '现有边界', '当前首槽 PBR 参数 0~1。', '标明材质槽范围。', '参数与当前槽持久化一致。'],
  97. ['property.opacity', '材质透明度', 'main:bindInspectorEvents', '现有边界', '当前首槽;联动 transparent/depthWrite。', '保留实际透明度保存字段。', '透明效果与重开结果一致。'],
  98. ['property.code', '自定义资源编码', 'main:bindInspectorEvents', '本轮修复', '对象 customProperties.resourceCode,不是服务器资产主键。', '输入真正回写、保存、恢复及撤销。', '编辑、清空、重选、JSON 往返、刷新均一致。'],
  99. ['property.status', '自定义运行状态', 'main:bindInspectorEvents', '本轮修复', 'NORMAL/SERVICE 标签,不会发起真实设备控制。', '真正写回对象,首次变更可撤销。', '两状态切换及刷新保留。'],
  100. ['property.binding', '流程绑定说明', 'main:renderInspector', '现有边界', '只读显示模板/时间轴名称,不是教学任务的设备或物联网绑定。', '无本轮业务绑定改造。', '只读,不将其当作任务绑定操作验收。'],
  101. ['component.inspect', '组件页内置能力说明', 'main:renderComponents', '现有边界', '变换、场景节点、渲染等派生说明,没有通用组件启停系统。', '区分内置说明与自定义标记。', '显示与对象类型一致,不虚报可执行组件配置。'],
  102. ['component.tag', '添加扩展标记', 'main:renderComponents', '本轮澄清', '只保存名称标签;没有脚本、启停、运行时行为。', '从“添加组件”改为“扩展标记,仅保存标签”。', '新增标签保存/重开;不得声称已启用新行为。'],
  103. ]],
  104. ['时间轴与曲线', [
  105. ['timeline.play', '播放 / 暂停 / Space', 'timeline:play()', '已有实现', '播放当前编辑时间轴;原生 GLB 动画单独控制。', '停止清理与时间同步由本轮补强。', '播放进度/真实对象姿态变化,暂停停在当前帧。'],
  106. ['timeline.stop', '停止', 'timeline:stop()', '已有实现', '停止并回到起始时间,按起始帧应用动画。', '无本轮语义扩展。', '按钮停止后不再自行前进。'],
  107. ['timeline.loop', '循环开关', 'timeline:#bindEvents', '本轮修复', '循环设置随 timeline 保存;不循环到达终点只完成一次。', '循环变更进入历史/dirty。', '开关、保存重开、到末尾/循环边界正确。'],
  108. ['timeline.key-nav', '上一 / 下一关键帧', 'timeline:#onClick', '已有实现', '按时间轴关键帧时间定位。', '无本轮专门改动。', '多个轨道时跳到正确相邻关键时刻。'],
  109. ['timeline.seek', '拖播放头 / 点击刻度', 'timeline:#onPointerDown', '本轮修复', '实时求值当前帧;键盘左右移动一帧,Shift 移动一秒。', '拖动补发 timechange,解绑指针监听。', '时间码、曲线和视口姿态同步。'],
  110. ['timeline.add-track', '添加轨道', 'timeline:addTrack(', '已有实现', '当前主装备节点的变换轨道。', '无新增轨道类型。', '增加后可绑定节点并保存。'],
  111. ['timeline.remove-track', '删除轨道', 'timeline:removeTrack(', '本轮修复', '删除后不再保留该轨道施加的旧姿态。', '宿主恢复此前作用的属性基值。', '删除即时复原,再保存/重开一致。'],
  112. ['timeline.target', '修改轨道目标节点', 'timeline:updateSelectedTrack', '本轮修复', '用稳定对象标识绑定,不靠同名第一项。', '重绑清理旧目标的应用姿态。', '从 A 改为 B,A 复原,B 播放。'],
  113. ['timeline.property', '选择位置 / 旋转属性', 'timeline:updateSelectedTrack', '已有实现', '位置 XYZ 向量、旋转 X/Y/Z;没有缩放轨道 UI。', '保留当前实际能力。', '属性切换后关键帧类型和播放一致。'],
  114. ['timeline.mute', '轨道静音', 'timeline:#onClick', '本轮修复', '仅忽略当前轨道,不关闭其他轨道。', '不再停留旧姿态或保存时重复扣值。', '静音/恢复后实际姿态与重开一致。'],
  115. ['timeline.record', '记录当前姿态关键帧', 'main:addKeyframeFromSelection', '本轮修复', '记录相对对象基值的当前值。', '与多轨实际应用/保存采用同一基值。', '操作对象后记录,回放命中相同姿态。'],
  116. ['timeline.add-key', '双击新增关键帧', 'timeline:#onDoubleClick', '已有实现', '在轨道对应时刻创建/更新关键帧。', '时间碰撞保留当前编辑帧。', '新增后节点可选,时间/数值可保存。'],
  117. ['timeline.delete-key', '删除关键帧 / Delete', 'timeline:deleteSelected', '已有实现', '删除选中的关键帧;输入框编辑时不截获删除键。', '删除和监听清理回归。', '删除后曲线和播放变化,撤销可恢复。'],
  118. ['timeline.drag-key', '关键帧拖动 / Alt 取消吸附', 'timeline:#onPointerDown', '本轮修复', '默认 1/30 秒吸附;重叠时合并,保留正在编辑帧。', '拖动时间同步,碰撞处理确定。', '拖动/重叠/Alt 行为和保存后时间正确。'],
  119. ['timeline.zoom', '时间轴缩放', 'timeline:setZoom', '已有实现', '显示缩放随 timeline 记录;不改变时长/关键帧数值。', '无本轮专门改动。', '缩放不改变动作内容。'],
  120. ['timeline.multitrack', '多轨组合与保存基值', 'state:applyEditorTimelinePose', '本轮修复', '不同属性组合;同对象同属性最后有效轨道生效。', '求值、手工编辑基值、序列化一致。', '两轨同属性、静音、删轨、重绑、重开无漂移。'],
  121. ['timeline.baseline', '载入装备基准动作', 'main:loadBaselineAnimation', '现有边界', '匹配部件后直接替换当前时间轴/蓝图;当前没有单独确认框,不是任意维修流程自动生成。', '保持当前装备限定;无匹配只提示,不改工程。', '匹配时载入/保存/重开;无匹配时保留原数据并给出提示。'],
  122. ['timeline.native', 'GLB 原生动画选择 / 播放', 'main:selectNativeAnimation', '已有实现', '真实 GLB 自带片段;保存选择索引,不是蓝图 clip 字段。', '无片段时禁用。', '狐狸实际动画播放/暂停,选择保存后重开一致。'],
  123. ['timeline.curve', '曲线选择轨道 / 关键帧', 'curve:class ModelCurveEditor', '本轮修复', '曲线与时间轴共用一份轨道和关键帧数据。', '移除固定 SVG 假数据。', '实际轨道名和点数匹配时间轴,点击同步当前帧。'],
  124. ['curve.time', '曲线编辑时间', 'curve:change(input)', '本轮修复', '更新实际 key.time,约束在时间轴时长内。', '调用 timeline.updateKeyframe 并保持编辑目标。', '改时间后重排、撤销、JSON 往返一致。'],
  125. ['curve.value', '曲线编辑标量 / XYZ 数值', 'curve:change(input)', '本轮修复', '修改实际关键帧值,向量编辑保留其他分量。', '无效空值拒绝提交。', '标量和向量都驱动真实姿态并保存。'],
  126. ['curve.easing', '曲线插值', 'curve:change(input)', '本轮修复', '支持线性、平滑、缓入、缓出、阶梯;没有 Bezier 切线 UI。', '阶梯插值与已有下游时间轴求值一致。', '修改插值改变中间帧,重开保留。'],
  127. ['curve.axis', '曲线 XYZ 分量显示', 'curve:change(input)', '已有实现', '改变图上显示分量,不改另外两轴的数据。', '当前实际向量轨道才出现。', '切分量显示不同曲线,数值不被改写。'],
  128. ]],
  129. ['蓝图与交互反馈', [
  130. ['blueprint.palette', '节点库搜索 / 拖入节点', 'blueprint:_renderPalette', '已有实现', '当前七类节点:触发、时间轴、等待、高亮、提示、条件、完成。', '不新增任意脚本节点。', '搜索、拖入后节点类型和参数可保存。'],
  131. ['blueprint.add', '添加节点按钮', 'blueprint:_bind()', '已有实现', '工具栏默认添加提示节点;其他类型来自节点库。', '无本轮专门改动。', '新增进入画布、历史与 JSON。'],
  132. ['blueprint.parameters', '节点标题 / 参数编辑', 'blueprint:_renderInspector', '已有实现', '按节点类型提供数字、文字、枚举、复选框。', '保留数值边界与真实数据写回。', '改参数与标题后重开保留。'],
  133. ['blueprint.move', '节点拖动 / 多选框选', 'blueprint:_onViewportPointerDown', '已有实现', '节点布局随蓝图 JSON 保存。', '无本轮专门改动。', '多选移动位置正确,撤销恢复。'],
  134. ['blueprint.connect', '连接 / 替换 / 删除执行线', 'blueprint:connect(', '本轮修复', '单输出重连替换;允许多个来源汇聚同一输入。', '不再清掉同一输入的其他合法来源。', 'true/false 汇聚保留两条,替换输出只影响该线。'],
  135. ['blueprint.delete', '删除节点 / 连线', 'blueprint:deleteSelection', '已有实现', '删除节点一并清理相关边。', '复制/剪切使用同一图数据合同。', '无悬空边;撤销恢复完整图。'],
  136. ['blueprint.copy', '复制 / 剪切 / 粘贴', 'blueprint:copySelection', '本轮修复', 'Ctrl/Cmd+C/X/V;复制选区内部边,不复制指向外部的边。', '新节点 ID 和内部连线同步生成,阻止快捷键冒泡。', '副本内部连接正确,不误删外部对象。'],
  137. ['blueprint.canvas', '平移 / 缩放 / 适应画布', 'blueprint:frameAll', '已有实现', '蓝图画布浏览;不是三维相机。', '无本轮专门改动。', '滚轮、Space 拖动和适应可见全部节点。'],
  138. ['blueprint.run', '运行 / F5', 'blueprint:async run(', '本轮修复', '在当前模型编辑器驱动真实场景;有 200 步循环上限。', '条件与等待流程回归。', '执行高亮、动作、反馈;循环图可终止不挂死。'],
  139. ['blueprint.stop', '停止运行', 'blueprint:stop(', '本轮修复', '停止中断挂起等待,旧运行不覆盖新运行状态。', 'AbortSignal 贯穿宿主动作与高亮清理。', '等待/动画中停止及时退出,无迟到完成提示。'],
  140. ['blueprint.reset', '重置执行', 'blueprint:resetExecution', '已有实现', '复位执行状态、时间轴和高亮;保留蓝图编辑内容。', '高亮恢复原材质。', '运行后重置,进度归零且节点图不丢。'],
  141. ['blueprint.click', '点击触发目标', 'blueprint:async trigger(', '本轮修复', '匹配事件和目标 ID/名称/祖先别名;空目标可通配。', '不再点击 B 触发 A。', '两个不同目标分别点击,只运行匹配分支。'],
  142. ['blueprint.double-click', '双击触发目标', 'main:pickObject', '本轮修复', '双击独立派发 double-click,与单击区分。', '增加真实视口双击派发并抑制待发单击。', '双击只触发目标的双击分支。'],
  143. ['blueprint.auto', '自动触发', 'main:togglePreview', '本轮修复', '仅进入预览显式派发 auto,不把每次单击当 auto。', '事件匹配与进入预览联动。', '进入预览触发一次,普通点击不重放自动分支。'],
  144. ['blueprint.timeline', '播放时间轴节点', 'main:playTimelineForBlueprint', '本轮修复', '播放当前唯一时间轴,动作名称仅描述;支持起始时间、速度、等待。', '起始末帧不反跳零,终止可取消。', '起始时间和速度实际生效,等待与不等待分支正确。'],
  145. ['blueprint.wait', '等待节点', 'blueprint:NODE_TYPES', '已有实现', '指定毫秒等待;可被停止取消。', '异步停止及时结束。', '等待期间停止不会继续执行后续节点。'],
  146. ['blueprint.highlight', '高亮节点 / 呼吸效果', 'main:highlightPart', '本轮修复', '准确命中可见对象;同名歧义要求标识;没有可高亮材质则报错。', '拒绝错误整机回退,真实呼吸效果,保存忽略临时高亮。', '目标正确、找不到报错、结束恢复、保存不永久染色。'],
  147. ['blueprint.message', '操作提示节点', 'main:showTeachingMessage', '现有边界', '显示标题/文本,可点击关闭或约 1.8/2.5 秒自动继续;不是强制人员确认。', '不将其当作业务审核/签名。', '文本出现、关闭和停止不留弹层。'],
  148. ['blueprint.condition', '条件判断节点', 'main:evaluateBlueprintCondition', '本轮修复', '当前运行变量、嵌套字段、truthy/布尔/数字比较。', 'false 没有出口不走 true,字符串布尔正确解析。', '真假分支分别验证,缺出口不误执行。'],
  149. ['blueprint.finish', '流程完成节点', 'main:initEditors', '已有实现', '本次预览结果/进度提示,不写教学任务成绩。', '保持本次运行反馈边界。', '成功/完成提示与实际执行结果一致。'],
  150. ]],
  151. ['发布、交付与运行信息', [
  152. ['publish.preflight', '生成 / 发布检查面板', 'main:togglePublishPanel', '现有边界', '本地主要检查资源引用存在及轨道/节点数量,不执行完整蓝图可达性、终止性或业务校验;服务端发布校验最终生效。', '发布目标与离线交付能力明确。', '可打开关闭;不完整工程不能凭界面绿标绕过服务端。'],
  153. ['publish.api', '发布到场景资源库', 'main:publishToSceneLibrary', '本轮修复', '先保存当前工程再发布固定版本;纯 publisher 发布已保存版本。', '整个保存/发布队列互斥,失败不发后续发布。', '发布版本/模型资产/列表状态一致,保存失败不发布。'],
  154. ['publish.cover', '发布时当前三维截图封面', 'main:publishToSceneLibrary', '本轮修复', 'JPEG 当前视口;截图或上传失败可保留旧封面并明确提示。', '与手动保存共用封面链路,409 阻断。', '真实封面图对应当前模型;上传失败提示一次,旧图保留。'],
  155. ['publish.html', '导出步骤演示 HTML', 'main:exportStandaloneHtml', '本轮澄清', '工程数据、静态截图和步骤演示;不包含 GLB、不执行真实三维交互。', '生成面板和按钮移除完整离线运行的误导。', '下载可打开、步骤导航可用,醒目标明不含三维模型。'],
  156. ['publish.downstream', '下游读取模型固定版本', 'api:publishDocument', '现有边界', '已发布模型作为资源引用;下游自动执行模型 timeline/blueprint 尚无统一运行桥接。', '本轮不将资源加载成功等同动作自动运行成功。', '固定引用/可见性单独测;自动动作执行列为待确认范围。'],
  157. ['feedback.log', '运行日志', 'main:log(message', '已有实现', '本次内存运行日志,最多 160 条;不是服务端审计日志。', '错误信息转义,保留清晰运行结果。', '选中/运行/保存产生相应日志,不要求刷新保留。'],
  158. ['feedback.stats', '节点 / 三角面 / FPS 等状态', 'main:updateStats', '现有边界', '节点与三角面统计、当前 FPS;内存为估算值,不是精确浏览器内存剖析。', '无本轮性能仪表重构。', '载入模型后统计有合理变化,状态不作为性能验收数据。'],
  159. ['feedback.toast', '成功 / 错误短消息', 'main:toast(message', '已有实现', '瞬时反馈;保存/发布成功以接口完成为准。', '封面失败不伪报全部成功。', '失败出现一次明确提示,自动消失,不形成重复常驻错误条。'],
  160. ]],
  161. ]
  162. const sourceCache = new Map()
  163. function locate(locator) {
  164. const colon = locator.indexOf(':'), key = locator.slice(0, colon), needle = locator.slice(colon + 1), file = sourceFiles[key]
  165. if (!sourceCache.has(file)) sourceCache.set(file, fs.existsSync(path.resolve(reportDir, file)) ? fs.readFileSync(path.resolve(reportDir, file), 'utf8') : '')
  166. const lines = sourceCache.get(file).split(/\r?\n/)
  167. const name = needle.replace(/^async /, '').replace(/\(.*/, '')
  168. const declaration = new RegExp(`^\\s*(?:export\\s+)?(?:async\\s+)?(?:function\\s+)?${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`)
  169. const declared = lines.findIndex(line => declaration.test(line))
  170. const index = declared >= 0 ? declared : lines.findIndex(line => line.includes(needle))
  171. return { file, line: index < 0 ? null : index + 1, symbol: needle }
  172. }
  173. const catalog = groups.flatMap(([group, rows]) => rows.map(([id, name, locator, implementation, boundary, fix, acceptance]) => ({ id, group, name, source: locate(locator), implementation, boundary, fix, acceptance })))
  174. fs.writeFileSync(path.join(reportDir, 'feature-catalog.json'), JSON.stringify({ schemaVersion: 1, features: catalog }, null, 2))
  175. const results = read('results.json', true), validation = read('validation.json', true), cleanup = read('cleanup.json'), artifactChecks = read('artifact-checks.json'), runHistory = read('run-history.json'), finalCorrections = read('data/final-corrections.json')
  176. const proofFiles = fs.existsSync(path.join(reportDir, 'evidence')) ? fs.readdirSync(path.join(reportDir, 'evidence')).filter(name => name.endsWith('.json')).sort().map(name => `evidence/${name}`) : []
  177. const proofData = proofFiles.map(file => ({ file, data: read(file) }))
  178. function e2eRows(suites, parents = []) {
  179. return arr(suites).flatMap(suite => [
  180. ...arr(suite.specs).flatMap(spec => arr(spec.tests).map(item => {
  181. const last = arr(item.results).at(-1)
  182. return { title: [...parents, spec.title].filter(Boolean).join(' · '), rawTitle: spec.title, status: item.status === 'unexpected' ? 'FAIL' : item.status === 'flaky' ? 'WARN' : state(last?.status), startTime: last?.startTime, duration: last?.duration, attachments: arr(last?.attachments).map(a => ({ name: a.name, path: a.path, contentType: a.contentType })), errors: arr(last?.errors).map(error => error.message || String(error)) }
  183. })), ...e2eRows(suite.suites, suite.file ? parents : [...parents, suite.title].filter(Boolean)),
  184. ])
  185. }
  186. const browserRows = e2eRows(results?.suites)
  187. const proofs = []
  188. function collect(value, file, inherited = {}) {
  189. if (!value || typeof value !== 'object') return
  190. const ownStatus = state(value.status ?? value.result ?? value.overallStatus), status = ownStatus === 'PENDING' ? inherited.status : ownStatus
  191. const testTitle = value.testTitle || value.test || inherited.testTitle
  192. const recordedAt = value.recordedAt || value.time || inherited.recordedAt
  193. const kind = value.kind || value.type || inherited.kind || 'evidence'
  194. const ids = [...arr(value.featureIds), ...arr(value.features), ...(typeof value.featureId === 'string' ? [value.featureId] : [])].filter(item => typeof item === 'string')
  195. if (ids.length) proofs.push({ ids, file, status: status || 'PENDING', testTitle, recordedAt, kind, title: value.title || value.name || testTitle || file, screenshots: arr(value.screenshots), details: value.scope || value.note || value.details || '' })
  196. for (const [key, child] of Object.entries(value)) if (child && typeof child === 'object' && !['features', 'featureIds', 'screenshots', 'attachments'].includes(key)) {
  197. if (Array.isArray(child)) child.forEach(item => collect(item, file, { status, testTitle, recordedAt, kind }))
  198. else collect(child, file, { status, testTitle, recordedAt, kind })
  199. }
  200. }
  201. proofData.forEach(({ file, data }) => collect(data, file))
  202. collect(validation?.coverage, 'validation.json')
  203. for (let index = 0; index < proofs.length; index++) proofs[index] = resolveBrowserProof(proofs[index], browserRows)
  204. const coverage = catalog.map(feature => {
  205. const evidence = proofs.filter(item => item.ids.includes(feature.id)), statuses = evidence.map(item => item.status)
  206. const status = statuses.includes('FAIL') ? 'FAIL' : statuses.includes('WARN') ? 'WARN' : statuses.includes('PASS') ? 'PASS' : 'PENDING'
  207. return { ...feature, verification: status, evidence }
  208. })
  209. const count = rows => ({ total: rows.length, pass: rows.filter(row => row.status === 'PASS').length, fail: rows.filter(row => row.status === 'FAIL').length, pending: rows.filter(row => !['PASS', 'FAIL'].includes(row.status)).length })
  210. const browserCount = count(browserRows), featureCount = count(coverage.map(row => ({ status: row.verification })))
  211. // A runtime result:false can be the expected result of an unmatched trigger.
  212. // Only explicit check outcomes / failed commands are failures, not arbitrary booleans in snapshots.
  213. const anyFailure = value => value && typeof value === 'object' && (state(value.overallStatus ?? value.status ?? (typeof value.result === 'string' ? value.result : undefined)) === 'FAIL' || (Number.isInteger(value.exitCode) && value.exitCode !== 0) || Object.values(value).some(child => child && typeof child === 'object' && (Array.isArray(child) ? child.some(anyFailure) : anyFailure(child))))
  214. const failed = browserCount.fail || featureCount.fail || arr(results?.errors).length || Number(results?.stats?.unexpected || 0) || anyFailure(validation) || anyFailure(cleanup) || anyFailure(artifactChecks) || malformed.length || proofData.some(item => anyFailure(item.data))
  215. const finalRun = validation?.e2eComplete === true || validation?.finalRun === true || validation?.e2e?.complete === true
  216. const runVerified = results && validation && browserCount.total > 0 && browserCount.pending === 0 && finalRun && state(validation.overallStatus ?? validation.status) === 'PASS'
  217. const finalArtifactsVerified = artifactChecks?.finalArtifactScan === true && state(artifactChecks.status ?? artifactChecks.secretScan) === 'PASS'
  218. && ['results.json', 'validation.json', 'cleanup.json', 'playwright/index.html'].every(file => {
  219. const target = path.join(reportDir, file)
  220. return fs.existsSync(target) && artifactChecks.finalInputSha256?.[file] === crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex')
  221. })
  222. const overall = failed ? 'FAIL' : runVerified && featureCount.pending === 0 && finalArtifactsVerified ? 'PASS' : 'PENDING'
  223. const overallTitle = failed ? '存在失败,当前验收未通过' : overall === 'PASS' ? '已列功能的记录验证通过' : runVerified && featureCount.pending === 0 ? '功能验收已通过,最终交付物扫描待完成' : runVerified ? '已执行验收通过,部分功能尚未逐项验证' : '实现已整理,最终验收证据待完成'
  224. fs.writeFileSync(path.join(reportDir, 'coverage.json'), JSON.stringify({ generatedAt: new Date().toISOString(), overall, featureCount, browserCount, finalRun, finalArtifactsVerified, features: coverage }, null, 2))
  225. const screenshotDir = path.join(reportDir, 'screenshots')
  226. const screenshots = fs.existsSync(screenshotDir) ? fs.readdirSync(screenshotDir).filter(name => /\.(png|jpe?g|webp)$/i.test(name)).sort() : []
  227. const captions = read('screenshot-captions.json') || {}
  228. const requirements = [
  229. ['部件级选择与操作', '整体与部件区分;树、视口和属性联动;操作后可保存重开。', 'selection.scope / selection.sync / property.*'],
  230. ['动作流程', '时间轴关键帧、曲线插值、蓝图顺序和真假分支有实际执行结果。', 'timeline.* / curve.* / blueprint.*'],
  231. ['交互反馈', '点击/双击/自动事件、高亮、文字提示、停止和错误反馈与对象匹配。', 'blueprint.click / double-click / auto / highlight / message'],
  232. ['结果追踪', '模型版本、封面、引用和本次运行结果有证据;明确运行日志不是教学成绩或审计。', 'publish.* / feedback.* / project.conflict'],
  233. ]
  234. const decisions = [
  235. { id: 'D1', title: '真正的组件运行系统', current: '“组件”页目前是内置能力说明;新增项只保存扩展标记名称,没有启停、参数执行或生命周期。', behavior: '用户新增一种行为组件,配置参数后能在当前模型和约定下游环境执行;停用后立即停止,重开后按保存状态恢复。', scope: '先定义有限组件目录、参数 schema、权限与版本;实现初始化/更新/销毁及错误隔离。不要直接开放任意脚本。', acceptance: '每种批准组件可添加、编辑、启停、删除、保存重开;重复载入不重复绑定事件;失败组件不阻断其他对象,运行结果可定位。' },
  236. { id: 'D2', title: '完整三维离线运行包', current: '现有 HTML 是嵌入静态截图与工程数据的步骤演示,可离线查看;没有 GLB 字节和完整三维运行时。', behavior: '断网电脑打开交付包,真实模型/材质/原生动画/编辑时间轴与交互节点可以运行,且不依赖服务端 blob URL。', scope: '明确交付形式(目录/ZIP或单文件)、体积约束、资产授权和三维依赖;打包全部受控资产、运行时和相对资源清单。', acceptance: '清缓存断网运行;模型和环境完整可见,动作/分支/高亮可交互;资源缺失明确报错;包内清单/哈希与发布版本一致。' },
  237. { id: 'D3', title: '下游场景 / 训练自动执行模型动画与蓝图', current: '发布模型可供下游按固定版本引用;不同下游已有局部动画能力,但没有统一的模型 timeline/blueprint 自动执行桥接。加载成功不等于动作已自动执行。', behavior: '在场景或训练放入模型实例后,按明确触发策略启动该发布版本的时间轴/蓝图;多个实例互不干扰。', scope: '统一实例目标命名空间、事件总线、生命周期和停止规则;明确自动播放、用户触发与训练步骤触发优先级,以及错误/完成结果回传。', acceptance: '两个同模型实例分别触发不串台;锁定发布版本;停止/切步骤/卸载无迟到动作;真实对象姿态、分支和结果回传均有 E2E 证据。' },
  238. ]
  239. const table = (heads, rows) => `<div class="table-scroll"><table><thead><tr>${heads.map(head => `<th>${esc(head)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`
  240. const sourceLink = source => link(source.file, `${path.basename(source.file)}${source.line ? `:${source.line}` : ''} · ${source.symbol}`)
  241. const evidenceLinks = ['results.json', 'run-history.json', 'validation.json', 'cleanup.json', 'artifact-checks.json', 'coverage.json', 'feature-catalog.json', 'coverage-plan.md', 'data/inventory-main.md', 'data/unit-coverage-plan.json', 'data/final-corrections.json', 'data/kingbase-validation-probe-result.json', 'data/visual-qa.json', 'screenshot-captions.json', 'screenshot-verification.json', ...proofFiles]
  242. const fileHashes = new Map()
  243. const fileHash = file => {
  244. if (!file || !fs.existsSync(file)) return null
  245. if (!fileHashes.has(file)) fileHashes.set(file, crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'))
  246. return fileHashes.get(file)
  247. }
  248. function attachmentFile(attachment) {
  249. const normalized = String(attachment.path || '').replaceAll('\\', '/')
  250. const marker = normalized.lastIndexOf('/test-results/')
  251. // Remap Playwright absolute paths to this checked-out report directory.
  252. const candidate = path.resolve(reportDir, marker >= 0 ? normalized.slice(marker + 1) : normalized)
  253. const relative = path.relative(reportDir, candidate)
  254. return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? candidate : null
  255. }
  256. const screenshotEvidence = []
  257. const gallery = screenshots.map((filename, index) => {
  258. const base = filename.replace(/\.[^.]+$/, ''), caption = captions[filename] || captions[base]
  259. const title = typeof caption === 'string' ? caption : caption?.title || base.replace(/^\d+[a-z]?[-_]?/, '').replaceAll('-', ' ')
  260. const detail = caption?.description || caption?.detail || '过程画面;只有关联断言通过时才作为相应功能的验证证据。'
  261. const associated = browserRows.flatMap(row => row.attachments.filter(attachment => attachment.name === base || attachment.name === filename).map(attachment => ({
  262. title: row.title, status: row.status, hashMatches: fileHash(attachmentFile(attachment)) === fileHash(path.join(screenshotDir, filename)),
  263. })))
  264. const verification = screenshotProofStatus(associated)
  265. screenshotEvidence.push({ file: `screenshots/${filename}`, title, status: verification, associations: associated })
  266. const note = verification === 'PASS' ? '本轮附件一致' : verification === 'FAIL' ? '本轮用例失败' : verification === 'WARN' ? '用例需复核' : associated.length ? '图片与本轮附件待核对' : '过程截图'
  267. return `<figure><a class="shot" href="screenshots/${encode(filename)}" data-caption="${esc(title)}"><img src="screenshots/${encode(filename)}" loading="lazy" alt="${esc(title)}"></a><figcaption><span class="shot-number">${String(index + 1).padStart(2, '0')}</span><strong>${esc(title)}</strong>${badge(verification, note)}<p>${esc(detail)}</p><small>${esc(filename)}</small></figcaption></figure>`
  268. }).join('')
  269. fs.writeFileSync(path.join(reportDir, 'screenshot-verification.json'), JSON.stringify({ time: new Date().toISOString(), scope: '截图与当前 results.json 的附件逐字节 SHA256 一致性;同名预跑或重拍不自动计本轮通过。', screenshots: screenshotEvidence }, null, 2))
  270. const statusCountCards = `<div class="stats"><div><span>功能清单</span><b>${catalog.length}</b><small>全部可达动作逐项整理</small></div><div><span>逐项证据</span><b>${featureCount.pass}<i> / ${featureCount.total}</i></b><small>${featureCount.pending} 项仍待验证</small></div><div><span>浏览器 E2E</span><b>${browserCount.pass}<i> / ${browserCount.total}</i></b><small>${browserCount.fail} 失败 · ${finalRun ? '最终轮已标记' : '最终轮未确认'}</small></div><div><span>截图 / 待确认</span><b>${screenshots.length}<i> / 3</i></b><small>截图按实际文件读取</small></div></div>`
  271. const document = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>装备模型编辑器 · 全功能闭环验收</title><style>
  272. :root{--bg:#f3f5f7;--paper:#fff;--ink:#182f3e;--muted:#617481;--line:#dce4e8;--green:#0c796a;--navy:#102d40;--amber:#9b650d;--red:#ae343d}*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:75px}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.7 "Microsoft YaHei","Segoe UI",sans-serif}a{color:#08798a;text-decoration:none}a:hover{text-decoration:underline}header{background:linear-gradient(120deg,#102d40,#135744);color:#fff;padding:50px max(24px,calc((100vw - 1480px)/2)) 40px}header p{color:#d2e4e8;max-width:960px;margin:10px 0 0}.eyebrow{text-transform:uppercase;font-size:12px;letter-spacing:2px;color:#9cdacb}h1{font-size:36px;line-height:1.4;letter-spacing:1px;margin:12px 0}h2{font-size:27px;margin:3px 0 18px}h3{font-size:19px;margin:0 0 14px}h4{margin:0 0 9px;font-size:16px}p{margin:8px 0 15px}nav{position:sticky;top:0;background:#fffefc;z-index:5;border-bottom:1px solid var(--line);padding:13px 22px;display:flex;justify-content:center;gap:28px;flex-wrap:wrap}nav a{font-weight:600;color:#375565}main{max-width:1480px;margin:auto;padding:28px 24px 65px}section{margin:38px 0}.section-label{font-size:12px;letter-spacing:2px;color:#698392;font-weight:bold}.panel,.notice{padding:25px;background:var(--paper);border:1px solid var(--line);border-radius:13px;margin-bottom:18px}.notice{border-left:5px solid #7096b0}.notice.fail{background:#fff4f4;border-left-color:var(--red)}.notice.pass{background:#eef9f4;border-left-color:var(--green)}.notice.pending,.notice.warn{background:#fffbef;border-left-color:#ca941f}.notice strong{font-size:19px}.muted,small{color:var(--muted)}.badge{display:inline-block;white-space:nowrap;padding:2px 9px;border-radius:20px;font-size:12px;font-weight:600;line-height:1.7;background:#edf1f4;color:#576f80}.badge.pass{background:#dcf2e6;color:#086343}.badge.fail{background:#ffe0e0;color:#a52c37}.badge.pending,.badge.warn{background:#fff0c8;color:#825508}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-top:23px}.stats>div{border:1px solid var(--line);background:#fff;padding:19px 23px;border-radius:12px}.stats span,.stats small{display:block}.stats b{font-size:31px;color:var(--navy);font-variant-numeric:tabular-nums}.stats i{font-style:normal;font-size:17px;font-weight:400;color:#708795}.flow{display:grid;grid-template-columns:repeat(5,1fr);gap:14px}.flow article{background:#fff;border:1px solid var(--line);padding:18px;border-radius:10px}.flow b{display:block;color:#17655d}.flow p{font-size:13px;margin-bottom:0}.grid2{display:grid;grid-template-columns:1fr 1fr;gap:18px}.table-scroll{overflow:auto;border:1px solid var(--line);border-radius:9px}table{border-collapse:collapse;width:100%;font-size:13px;text-align:left}th{background:#eaf0f3;color:#314f60;padding:12px;white-space:nowrap}td{padding:13px 12px;border-top:1px solid var(--line);vertical-align:top;min-width:130px}tr:nth-child(even)td{background:#fafcfd}.matrix td:first-child{min-width:220px}.matrix td:nth-child(3){min-width:240px}.matrix td:nth-child(4){min-width:220px}.matrix td:nth-child(5){min-width:250px}.matrix strong,.matrix code{display:block}.matrix code{font-size:11px;color:#738a96;margin-top:4px}.matrix small{display:block}.matrix details summary{cursor:pointer;color:#097688}.matrix-group{margin-top:25px}.filters{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:18px 0}.filters input,.filters select{font:inherit;border:1px solid #c8d6df;padding:9px 13px;border-radius:7px;background:white}.filters input{min-width:260px;flex:1}.legend{font-size:13px;color:var(--muted)}.decision{border-top:4px solid #c5912e}.decision .decision-id{font-size:12px;color:#8a6018;font-weight:bold}.decision p{font-size:14px}.decision dt{font-weight:bold;color:#25495a;margin-top:14px}.decision dd{margin:3px 0 0;color:#536c7a;font-size:14px}.gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}figure{margin:0;background:#fff;border:1px solid var(--line);border-radius:11px;overflow:hidden}figure img{display:block;aspect-ratio:16/9;width:100%;object-fit:contain;background:#dbe3e9}figcaption{padding:17px}figcaption strong{display:inline}figcaption .badge{margin-left:9px}figcaption p{font-size:13px;margin:9px 0}figcaption small{font-size:11px;overflow-wrap:anywhere}.shot-number{font-size:12px;color:#71929c;margin-right:9px}pre{overflow:auto;max-height:420px;white-space:pre-wrap;overflow-wrap:anywhere;background:#102c3f;color:#dfedf4;padding:20px;border-radius:9px;font:12px/1.65 Consolas,monospace}details summary{cursor:pointer;font-weight:600}details[open] summary{margin-bottom:12px}.links{display:flex;flex-wrap:wrap;gap:8px 22px}.links a{font-size:13px;overflow-wrap:anywhere}footer{border-top:1px solid var(--line);padding-top:20px;font-size:12px;color:var(--muted)}dialog{border:0;border-radius:12px;padding:0;width:min(96vw,1900px);max-width:none;background:#102638;color:white}dialog::backdrop{background:#03111bd9}dialog img{display:block;width:100%;max-height:85vh;object-fit:contain}.dialog-bar{padding:13px 20px;display:flex;gap:20px;align-items:center;justify-content:space-between}.dialog-bar button{padding:7px 15px;border:1px solid #67828d;color:white;background:transparent;border-radius:6px;cursor:pointer}.dialog-bar a{color:#a0deec}.hidden{display:none!important}@media(max-width:1000px){.gallery,.flow{grid-template-columns:repeat(2,1fr)}.stats{grid-template-columns:repeat(2,1fr)}.grid2{grid-template-columns:1fr}h1{font-size:28px}}@media(max-width:600px){main{padding:20px 12px}.panel{padding:17px}.gallery,.flow{grid-template-columns:1fr}nav{gap:8px 15px;font-size:12px}}@media print{nav,.filters{display:none}.gallery{grid-template-columns:1fr 1fr}section{break-before:auto}.table-scroll{overflow:visible}header{padding:25px}.panel{break-inside:avoid}body{background:#fff}}
  273. </style></head><body><header><div class="eyebrow">MODEL AUTHORING · CLOSURE REVIEW · 2026.09.06</div><h1>装备模型编辑器<br>全功能闭环审查与验收</h1><p>沿资料 → 产品原型 → 设计 → 开发 → 测试核查每一个可达动作。用狐狸、牛奶卡车等轻量模型验证通用编排能力,保留现有业务流程与版本数据。</p></header>
  274. <nav><a href="#overview">验收概况</a><a href="#materials">01 资料</a><a href="#prototype">02 原型</a><a href="#design">03 设计</a><a href="#development">04 开发</a><a href="#testing">05 测试</a><a href="#decisions">待确认范围</a><a href="#screenshots">截图证据</a></nav><main>
  275. <section id="overview"><div class="notice ${overall.toLowerCase()}"><strong>${esc(overallTitle)}</strong><p>${badge(overall)} · 报告生成:${esc(new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }))}(北京时间)。源码已接通、合同测试和真实浏览器验证分别记录;没有断言关联的功能仍为待验证。</p>${missing.length ? `<p>待提供:${missing.map(esc).join('、')}。</p>` : ''}${malformed.length ? `<p>证据读取失败:${esc(JSON.stringify(malformed))}</p>` : ''}</div>${statusCountCards}<p class="legend">“已实现/本轮修复”是源码状态;绿色“已验证”要求独立证据。过程截图与测试总数不能自动把 ${catalog.length} 个功能全部变成通过。</p></section>
  276. <section id="materials"><div class="section-label">01 / 资料</div><h2>从参考资料提取通用能力</h2><div class="panel"><p>原始两份 PDF 共 11 页已由主代理完整渲染并视觉阅读。原始诉求集中在部件选择、操作步骤、交互反馈和结果追踪。本轮以狐狸、牛奶卡车等轻量模型验证部件选择、流程编排、交互反馈和结果保存。</p><p>${link('../../../../文档/维修训练内容设计.pdf', '维修训练内容设计.pdf')} · ${link('../../../../文档/电器控制系统故障维修脚本.pdf', '电器控制系统故障维修脚本.pdf')}。报告仅链接原文,不复制全文或整页图片。</p>${table(['资料能力', '本轮可观察结果', '矩阵追踪'], requirements.map(row => row.map(esc)))}</div></section>
  277. <section id="prototype"><div class="section-label">02 / 产品原型</div><h2>确认用户实际使用的执行入口</h2><div class="flow"><article><b>内容模型列表</b><p>选中正式项目,进入模型编辑路由。</p></article><article><b>Vue 宿主</b><p>传 projectId / api=1,注入权限与未保存确认。</p></article><article><b>同源 iframe</b><p>承载 legacy-model-editor/editor.html。</p></article><article><b>真实原型源码</b><p>main.js + timeline.js + blueprint.js,经 stage 构建。</p></article><article><b>内容 API</b><p>受控资产、项目版本、保存和发布。</p></article></div><div class="panel"><p>当前默认入口由 ${link('../../../unreal_tran_web/src/config/content.ts', 'content.ts')} 和 ${link(sourceFiles.host, 'LegacyModelEditorView.vue')} 确定,${link('../../../unreal_tran_web/scripts/stage-legacy-model-editor.mjs', 'stage-legacy-model-editor.mjs')} 将真实原型源码打入静态资源。备用 DigitalTwinEditor.js 不是本轮默认入口。</p><p>覆盖顶部工具、场景树/资源库、三维视口、对象属性/组件说明、时间轴、曲线、蓝图、发布面板、运行日志和快捷键。代码中保留但没有可达按钮的“训练场景 JSON 导入”不计为用户可达功能。</p></div></section>
  278. <section id="design"><div class="section-label">03 / 设计</div><h2>明确保存、运行与验证的边界</h2><div class="grid2"><article class="panel"><h3>工程数据闭环</h3><p>对象变换/材质/自定义字段、时间轴/蓝图和环境稳定引用必须能保存、重开、撤销及 JSON 往返。临时 blob 只用于渲染,服务端资产标识用于持久化。</p><p>导入、截图、保存与发布期间保留互斥和乐观锁;失败不得用成功消息掩盖,也不得发布半绑定资源。</p></article><article class="panel"><h3>界面与业务范围</h3><p>网格、线框、PBR、透视和展开布局属于当前视图选项,未跨刷新保存不作为缺陷。运行日志属于本次运行记录,不等同审计和教学成绩。</p><p>现有组件新增是名称标记;现有离线 HTML 是步骤演示;下游自动执行模型动作须另行确认。</p></article></div></section>
  279. <section id="development"><div class="section-label">04 / 开发</div><h2>全功能覆盖矩阵</h2><p>每行列出实际执行入口、实现状态、边界、对应修复及独立验证要求。${link('feature-catalog.json', '静态功能清单')} · ${link('coverage.json', '带证据的矩阵 JSON')} · ${link('data/inventory-main.md', '修复前基线与实施记录')}。</p><div class="filters"><input id="feature-search" type="search" placeholder="搜索功能、ID、边界或修复"><select id="feature-state" aria-label="验证状态筛选"><option value="all">全部验证状态</option><option value="PENDING">待验证</option><option value="PASS">已验证</option><option value="FAIL">失败</option><option value="WARN">需复核</option></select><span id="feature-visible">${catalog.length} 项</span></div>
  280. ${finalCorrections ? `<div class="grid2">${arr(finalCorrections.items).map(item => `<article class="panel"><h3>${esc(item.title)}</h3><p>${esc(item.problem)}</p><p>${esc(item.change)}</p><p class="muted">${esc(item.validation)}</p><small>${link(item.source, '源码')}${item.secondSource ? ` · ${link(item.secondSource, '另一模块源码')}` : ''}${item.testSource ? ` · ${link(item.testSource, '聚焦测试')}` : ''}${item.evidence ? ` · ${link(item.evidence, '验证证据')}` : ''}</small></article>`).join('')}</div>` : ''}
  281. ${groups.map(([group]) => `<article class="matrix-group" data-group><h3>${esc(group)}</h3><div class="table-scroll"><table class="matrix"><thead><tr><th>功能 / 真实执行路径</th><th>实现状态</th><th>闭环与边界</th><th>本轮对应修复</th><th>测试证据 / 验收要求</th></tr></thead><tbody>${coverage.filter(row => row.group === group).map(row => `<tr data-feature-state="${row.verification}"><td><strong>${esc(row.name)}</strong><code>${esc(row.id)}</code><small>${sourceLink(row.source)}</small></td><td>${esc(row.implementation)}</td><td>${esc(row.boundary)}</td><td>${esc(row.fix)}</td><td>${badge(row.verification)}${row.evidence.length ? row.evidence.map(proof => `<small>${link(proof.file, proof.title)} · ${esc(proof.kind)}${proof.verificationNote ? ` · ${esc(proof.verificationNote)}` : ''}</small>`).join('') : '<small>尚未关联逐项测试证据;源码审查不计通过。</small>'}<details><summary>验收要求与证据范围</summary><small>${esc(row.acceptance)}</small>${row.evidence.filter(proof => proof.details).map(proof => `<p><small><b>${esc(proof.kind)}</b>:${esc(proof.details)}</small></p>`).join('')}</details></td></tr>`).join('')}</tbody></table></div></article>`).join('')}</section>
  282. <section id="testing"><div class="section-label">05 / 测试</div><h2>执行结果与证据分层</h2><article class="panel"><h3>浏览器 E2E</h3>${results ? `<p>${browserCount.pass}/${browserCount.total} 项通过 · ${badge(browserCount.fail ? 'FAIL' : browserCount.pending || !browserCount.total ? 'PENDING' : 'PASS')} · ${link('results.json', 'Playwright 原始结果')}</p>${table(['用例', '结果', '耗时 / 失败原因'], browserRows.map(row => [esc(row.title), badge(row.status), `${row.duration == null ? '未记录' : `${(row.duration / 1000).toFixed(1)} 秒`}${row.errors.length ? `<details open><summary>失败信息</summary>${pretty(row.errors)}</details>` : ''} `]))}` : '<div class="notice pending">尚未提供 results.json,不计任何 E2E 通过。</div>'}<p class="muted">通过浏览器定向注入的 409/503 等异常仅验证失败处理,不代表开发服务真实发生该故障。UI 点击、脚本调用运行时和纯 API 检查按各用例证据说明区分。</p></article>
  283. ${runHistory ? `<article class="panel"><h3>保留首轮失败与定向重跑事实</h3><p>${esc(runHistory.scope)} ${link('run-history.json', '安全迭代记录')}</p>${table(['历史轮次', '原始结果', '解释范围'], arr(runHistory.runs).map(run => [esc(run.name), `${Number(run.passed)}/${Number(run.tests)} 通过 · ${badge(state(run.outcome), state(run.outcome) === 'PASS' ? '该轮通过' : '该轮失败')}`, esc(run.note || '保留原始失败,不以随后同名证据覆盖。')]))}<p class="muted">${esc(runHistory.investigation)}</p><p>${esc(runHistory.finalAcceptance)}</p></article>` : ''}
  284. <article class="panel"><h3>真实 API / 运行时断言</h3><p>逐份读取 evidence/*.json。文件存在本身不代表通过;每份证据须注明断言结果和覆盖的 featureIds。</p>${proofData.length ? proofData.map(({ file, data }) => `<details><summary>${link(file)} ${badge(anyFailure(data) ? 'FAIL' : state(data?.status ?? data?.data?.status ?? data?.result))}</summary>${pretty(data)}</details>`).join('') : '<p class="muted">尚无接口或运行时证据文件。</p>'}</article>
  285. <article class="panel"><h3>构建、聚焦单测与版本检查</h3>${validation ? `<p>${link('validation.json', '完整验证记录')}。单测/合同测试不并入真实 E2E 数量。</p>${pretty(validation)}` : '<div class="notice pending">validation.json 待提供。已由子任务运行的聚焦测试需最终记录确认;报告不硬编码历史通过数。</div>'}</article>
  286. <article class="panel"><h3>测试数据保留与清理</h3>${cleanup ? `<p>${link('cleanup.json', '清理结果')}</p>${pretty(cleanup)}` : '<p class="muted">清理或保留结果待记录;不声明测试数据已全部清理。原始业务工程与其发布版本应以最终证据核对。</p>'}</article>
  287. <article class="panel"><h3>交付物凭据与附件检查</h3>${artifactChecks ? `<p>${badge(finalArtifactsVerified ? 'PASS' : anyFailure(artifactChecks) ? 'FAIL' : 'PENDING', finalArtifactsVerified ? '最终扫描通过' : artifactChecks.phase === 'preliminary' ? '初查完成,待最终扫描' : '待核验')} · ${link('artifact-checks.json', '脱敏扫描结果')}</p><p>${esc(artifactChecks.scope)}</p><p class="muted">扫描 ${Number(artifactChecks.textArtifactsScanned || 0)} 份文本、${Number(artifactChecks.pngMetadataFilesScanned || 0)} 张图片的文本元数据及 ${arr(artifactChecks.archives).length} 个归档;图片正文不以文本扫描代替视觉 QA。</p><details><summary>检查范围、历史常量排除与限制</summary>${pretty(artifactChecks)}</details>` : '<div class="notice pending">交付物扫描结果待提供;不声明报告已完成最终凭据检查。</div>'}</article></section>
  288. <section id="decisions"><div class="section-label">需要产品确认 / 3 项</div><h2>较大调整的具体范围</h2><p>以下能力超出现有小范围修复。本轮保留入口并明确现状;确认范围后才能据此另行实现和验收。</p>${decisions.map(item => `<article class="panel decision"><span class="decision-id">${item.id}</span><h3>${esc(item.title)}</h3><dl><dt>当前行为</dt><dd>${esc(item.current)}</dd><dt>拟实现的可观察行为</dt><dd>${esc(item.behavior)}</dd><dt>建议确认的实施范围</dt><dd>${esc(item.scope)}</dd><dt>验收条件</dt><dd>${esc(item.acceptance)}</dd></dl></article>`).join('')}</section>
  289. <section id="screenshots"><div class="section-label">可视证据</div><h2>${screenshots.length} 张截图 · 点击放大</h2><p class="muted">截图来自实际目录;首次加载、过程画面和最终结果按图注说明,不把空场景或加载遮罩当作模型可见证据。</p><div class="gallery">${gallery || '<div class="notice pending">尚未生成截图。不会补画或伪造测试截图。</div>'}</div></section>
  290. <section><div class="section-label">交付文件</div><h2>证据与复现入口</h2><div class="panel links">${evidenceLinks.map(file => link(file)).join('')}</div></section><footer>生成命令:<code>node tools/build-model-editor-closure-report.mjs</code>。功能实现状态与验证状态独立;缺失、失败或未确认的最终轮结果绝不自动改为 PASS。报告不读取私有账号文件、会话配置或数据库备份。</footer></main>
  291. <dialog id="image-viewer" aria-label="截图放大"><div class="dialog-bar"><span id="image-title"></span><div><a id="image-original" target="_blank" rel="noopener">原图</a> <button id="image-close" type="button">关闭 ×</button></div></div><img alt=""></dialog><script>
  292. const query=document.getElementById('feature-search'), filter=document.getElementById('feature-state');function applyFilter(){let count=0;const search=query.value.toLowerCase();document.querySelectorAll('[data-feature-state]').forEach(row=>{const visible=(filter.value==='all'||row.dataset.featureState===filter.value)&&row.textContent.toLowerCase().includes(search);row.classList.toggle('hidden',!visible);if(visible)count++});document.querySelectorAll('[data-group]').forEach(group=>group.classList.toggle('hidden',!group.querySelector('[data-feature-state]:not(.hidden)')));document.getElementById('feature-visible').textContent=count+' 项'}query.addEventListener('input',applyFilter);filter.addEventListener('change',applyFilter);const viewer=document.getElementById('image-viewer'),large=viewer.querySelector('img');document.querySelectorAll('a.shot').forEach(link=>link.addEventListener('click',event=>{event.preventDefault();large.src=link.href;large.alt=link.dataset.caption;document.getElementById('image-title').textContent=link.dataset.caption;document.getElementById('image-original').href=link.href;viewer.showModal()}));document.getElementById('image-close').addEventListener('click',()=>viewer.close());viewer.addEventListener('click',event=>{if(event.target===viewer)viewer.close()});
  293. </script></body></html>`
  294. fs.writeFileSync(path.join(reportDir, 'index.html'), document, 'utf8')
  295. console.log(JSON.stringify({ report: path.join(reportDir, 'index.html'), overall, features: featureCount, e2e: browserCount, finalRun, screenshots: screenshots.length, evidenceFiles: proofFiles.length, missing, malformed }, null, 2))