Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

238 строки
23 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. // Browser runs own the evidence files. This generator only writes index.html
  5. // and report.md. Missing evidence remains pending, never an inferred pass.
  6. // Each JSON may use { status, title, checks/evidence/results/tests: [...],
  7. // projects: [...], commands: [...], limitations: [...] }. Unknown shapes remain
  8. // available as sanitized JSON details rather than being silently discarded.
  9. const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/training-live-20260905')
  10. fs.mkdirSync(reportDir, { recursive: true })
  11. const array = value => Array.isArray(value) ? value : value == null ? [] : [value]
  12. const sensitiveKey = /password|passwd|secret|token|ticket|authorization|cookie|signature|credential|api[_-]?key|username|loginName|accountName|headers|storageState/i
  13. const cleanText = value => String(value ?? '')
  14. .replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
  15. .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[已隐藏]@')
  16. .replace(/\b((?:access|refresh)?[_-]?token|password|passwd|secret|uploadTicket|signature|api[_-]?key|X-Amz-(?:Signature|Credential|Security-Token))\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;&}]+)/gi, '$1=[已隐藏]')
  17. .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[已隐藏]')
  18. function sanitize(value) {
  19. if (typeof value === 'string') return cleanText(value)
  20. if (Array.isArray(value)) return value.map(sanitize)
  21. if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
  22. .filter(([key]) => !sensitiveKey.test(key)).map(([key, item]) => [key, sanitize(item)]))
  23. return value
  24. }
  25. const html = value => cleanText(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
  26. const md = value => cleanText(value).replace(/[|\[\]*_`<>]/g, char => `\\${char}`).replace(/\r?\n/g, ' ')
  27. const pretty = value => typeof value === 'string' ? value : JSON.stringify(value, null, 2)
  28. const recorded = value => value === undefined || value === null || value === '' ? '待最终证据' : value
  29. const status = value => {
  30. const normalized = String(value ?? '').toUpperCase()
  31. return normalized === 'PASS' || normalized === 'PASSED' ? 'PASS'
  32. : normalized === 'FAIL' || normalized === 'FAILED' ? 'FAIL' : 'PENDING'
  33. }
  34. const statusLabel = value => ({ PASS: '通过', FAIL: '失败', PENDING: '待补充证据' })[status(value)]
  35. const statusClass = value => status(value).toLowerCase()
  36. const sourceDefinitions = [
  37. { file: 'final-evidence.json', title: '真实编排、保存、发布及重新打开' },
  38. { file: 'readonly-evidence.json', title: '独立浏览器只读重开与真实画面验证' },
  39. { file: 'error-evidence.json', title: '失败、冲突与并发处理' },
  40. ]
  41. const sources = sourceDefinitions.map(definition => {
  42. const absolute = path.join(reportDir, definition.file)
  43. const data = fs.existsSync(absolute)
  44. ? sanitize(JSON.parse(fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, ''))) : null
  45. const checks = data ? array(data.checks || data.evidence || data.results || data.tests) : []
  46. const assessed = checks.filter(item => item && item.superseded !== true && item.historical !== true)
  47. const sourceStatus = assessed.some(item => status(item.status) === 'FAIL') ? 'FAIL'
  48. : status(data?.status) !== 'PENDING' ? status(data.status)
  49. : assessed.length && assessed.every(item => status(item.status) === 'PASS') ? 'PASS'
  50. : assessed.some(item => status(item.status) === 'FAIL') ? 'FAIL' : 'PENDING'
  51. return { ...definition, data, checks, status: sourceStatus }
  52. })
  53. const final = sources[0].data || {}
  54. const projects = array(final.projects || final.retainedProjects || final.trainingProjects)
  55. const reopenedProjects = array(sources[1].data?.projects)
  56. const versionMismatches = projects.flatMap(project => {
  57. const expected = project.publishedVersionId || project.finalVersionId || project.currentVersionId
  58. if (!expected || !sources[1].data) return []
  59. const reopened = reopenedProjects.find(item => String(item.id || item.projectId) === String(project.id || project.projectId))
  60. const actual = reopened?.publishedVersionId || reopened?.currentVersionId
  61. return String(expected) === String(actual) ? [] : [{ projectId: project.id || project.projectId, expected, actual: actual || '未记录' }]
  62. })
  63. if (versionMismatches.length && sources[1].status !== 'FAIL') sources[1].status = 'PENDING'
  64. const allChecks = sources.flatMap(source => source.checks)
  65. .filter(item => item && item.superseded !== true && item.historical !== true)
  66. const passedChecks = allChecks.filter(item => status(item.status) === 'PASS').length
  67. const failedChecks = allChecks.filter(item => status(item.status) === 'FAIL').length
  68. const pendingChecks = allChecks.length - passedChecks - failedChecks
  69. const overall = sources.some(source => source.status === 'FAIL') || failedChecks ? 'FAIL'
  70. : sources.every(source => source.status === 'PASS') && !pendingChecks ? 'PASS' : 'PENDING'
  71. const commands = sources.flatMap(source => array(source.data?.commands))
  72. const limitations = [...new Map(sources.flatMap(source => array(source.data?.limitations || source.data?.followUpIssues))
  73. .map(item => [pretty(item), item])).values()]
  74. const generatedAt = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
  75. const history = [
  76. { path: 'screenshots/01-before-scene-catalog.png', caption: '过程记录:初建训练仍显示默认液压模板,仅记录起点,不作为故障对照或最终通过证据。' },
  77. { path: 'screenshots/02-published-scenes.png', caption: '过程记录:打开已发布场景目录。最终版本和固定依赖以最终证据为准。' },
  78. { path: 'screenshots/03-truck-bound.png', caption: '过程记录:首次绑定卡车时仍有 8 个旧模板步骤;不代表最终两步编排通过。' },
  79. { path: 'screenshots/04-truck-saved.png', caption: '过程记录:卡车训练阶段性保存;最终步骤、状态、版本和封面以最终证据为准。' },
  80. ].filter(item => fs.existsSync(path.join(reportDir, item.path)))
  81. const historyPaths = new Set(history.map(item => item.path))
  82. const screenshotPattern = /^screenshots\/[\w\W]+\.(?:png|jpe?g|webp)$/i
  83. const imagePaths = new Set()
  84. function imageRefs(value, inheritedCaption = '') {
  85. if (!value) return []
  86. if (typeof value === 'string') return screenshotPattern.test(value.replaceAll('\\', '/'))
  87. ? [{ path: value.replaceAll('\\', '/'), caption: inheritedCaption }] : []
  88. if (Array.isArray(value)) return value.flatMap(item => imageRefs(item, inheritedCaption))
  89. if (typeof value !== 'object') return []
  90. const caption = value.caption || value.title || value.label || value.name || inheritedCaption
  91. return Object.entries(value).flatMap(([key, item]) => imageRefs(item, caption || key))
  92. }
  93. function validateImage(relative) {
  94. const absolute = path.resolve(reportDir, relative)
  95. const relativeToRoot = path.relative(reportDir, absolute)
  96. if (path.isAbsolute(relative) || /^[a-z][a-z\d+.-]*:/i.test(relative)
  97. || relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)
  98. || !screenshotPattern.test(relative) || !fs.existsSync(absolute)
  99. || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size === 0) {
  100. throw new Error(`截图引用不存在、为空或不在报告目录内:${relative}`)
  101. }
  102. const realRelative = path.relative(fs.realpathSync(reportDir), fs.realpathSync(absolute))
  103. if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) throw new Error(`截图链接越出报告目录:${relative}`)
  104. imagePaths.add(relative)
  105. }
  106. const uniqueImages = refs => [...new Map(refs.map(ref => [ref.path, ref])).values()]
  107. const evidenceImages = uniqueImages(sources.flatMap(source => imageRefs(source.data)))
  108. evidenceImages.forEach(ref => validateImage(ref.path))
  109. history.forEach(ref => validateImage(ref.path))
  110. const finalImages = evidenceImages.filter(ref => !historyPaths.has(ref.path))
  111. const imageUrl = value => value.split('/').map(encodeURIComponent).join('/')
  112. const figure = ref => `<figure><a href="${html(imageUrl(ref.path))}" target="_blank" rel="noopener"><img src="${html(imageUrl(ref.path))}" alt="${html(ref.caption || ref.path)}" loading="lazy"></a><figcaption>${html(ref.caption || ref.path)} · 点击查看原图</figcaption></figure>`
  113. const mdFigure = ref => `[![${md(ref.caption || ref.path)}](${imageUrl(ref.path)})](${imageUrl(ref.path)})`
  114. const details = (value, title = '查看脱敏后的完整记录') => `<details><summary>${html(title)}</summary><pre>${html(pretty(value))}</pre></details>`
  115. const bullets = items => `<ul>${items.map(item => `<li>${html(item)}</li>`).join('')}</ul>`
  116. const mdBullets = items => items.map(item => `- ${md(item)}`).join('\n')
  117. const badge = value => `<span class="badge ${statusClass(value)}">${statusLabel(value)}</span>`
  118. const stages = [
  119. { title: '1 · 资料', items: [
  120. '参考《文档/维修训练内容设计.pdf》(6 页)与已有场景制作成果。按“场景 → 任务步骤 → 学习 / 训练 / 评估”的资料结构检查实现。',
  121. '本次使用已发布的狐狸场景和牛奶卡车场景验证训练编排,确认真实模型可见、步骤目标来自绑定场景、保存及发布使用服务端版本。',
  122. ] },
  123. { title: '2 · 产品原型', items: [
  124. '沿用现有三维训练编排工作台:选择场景、选择三维对象、配置任务步骤、保存草稿和发布。',
  125. '新建时的液压模板仅作为现有界面起点;绑定新场景后不得继续选择该场景中不存在的液压设备。',
  126. ] },
  127. { title: '3 · 设计', items: [
  128. '两条独立训练分别绑定狐狸与卡车的明确发布版本;每条配置两步、每步 50 分,总计 100 分,并核验重新打开后的状态。',
  129. '保存时以三维视口截图生成训练封面;模型可见性要通过实际截图及模型显示 / 隐藏的像素差异判断。',
  130. '将正常流程、独立浏览器只读重开、资源失败、封面失败、版本冲突及并发切换分别留证。独立重开阻断所有业务写请求,核验模型像素可见性、封面哈希与发布依赖;不将其表述为只读角色权限测试。',
  131. ] },
  132. { title: '4 · 开发', items: [
  133. '场景目录对接已发布内容版本;GLB 通过受控下载读取;训练依赖固定到所选场景版本,恢复项目时保留来源元数据。',
  134. '修复蒙皮模型缩放后的骨骼矩阵与包围盒刷新;场景恢复、部位覆盖和高亮使用一致的尺寸计算。',
  135. '绑定场景后移除不存在的内置操作目标,过滤隐藏或已删除对象及其子目标。',
  136. '保存和发布等待异步操作完成;封面与内容在同一次版本保存中持久化;失败、冲突及并发切换必须保留可恢复状态。最终实现效果以下方真实测试记录为准。',
  137. '修复旧工程快照回写覆盖最新名称与训练形态;场景绑定进行中禁止保存和发布,避免模型尚未加载成功就写入新的场景依赖。',
  138. ] },
  139. ]
  140. const scopeItems = [
  141. '本次范围为训练编排、草稿保存、发布与重新打开,不包含创建教学任务、下发学员或完成学员训练。',
  142. '“预览训练”仍提示需发布后在教学实施中下发;本报告不将该提示视作学员训练已运行。',
  143. '本轮仅修复已实现功能的问题,无新增需要用户确认的功能改动。',
  144. ]
  145. const projectValue = (project, names) => names.map(name => project?.[name]).find(value => value !== undefined && value !== null && value !== '')
  146. function projectRow(project) {
  147. const scene = project.scene || project.sceneDependency || project.boundScene || {}
  148. return [
  149. projectValue(project, ['id', 'projectId', 'trainingProjectId']),
  150. projectValue(project, ['name', 'title']),
  151. projectValue(project, ['finalStatus', 'status']),
  152. projectValue(project, ['finalVersionId', 'currentVersionId', 'versionId']),
  153. projectValue(project, ['publishedVersionId']),
  154. projectValue(project, ['sceneProjectId', 'sceneId']) || projectValue(scene, ['projectId', 'sceneId', 'id']),
  155. projectValue(project, ['sceneVersionId']) || projectValue(scene, ['versionId', 'publishedVersionId']),
  156. projectValue(project, ['stepCount']) ?? (Array.isArray(project.steps) ? project.steps.length : undefined),
  157. projectValue(project, ['totalScore', 'score']),
  158. ].map(recorded)
  159. }
  160. const projectHeaders = ['训练 ID', '名称', '状态', '当前版本', '发布版本', '场景 ID', '固定场景版本', '步骤数', '总分']
  161. const htmlProjects = projects.length ? `<div class="table-scroll"><table><thead><tr>${projectHeaders.map(value => `<th>${html(value)}</th>`).join('')}</tr></thead><tbody>${projects.map(project => `<tr>${projectRow(project).map(value => `<td>${html(value)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${projects.map(project => details(project, `训练 ${recorded(projectValue(project, ['id', 'projectId', 'trainingProjectId']))}:版本、步骤、封面与依赖记录`)).join('')}`
  162. : '<p class="pending-note">已开始创建训练 161(狐狸)与 162(卡车);最终状态、版本、封面与场景依赖等待 final-evidence.json,不以阶段性保存结果代替最终记录。</p>'
  163. const mdProjects = projects.length ? `| ${projectHeaders.map(md).join(' | ')} |\n| ${projectHeaders.map(() => '---').join(' | ')} |\n${projects.map(project => `| ${projectRow(project).map(md).join(' | ')} |`).join('\n')}`
  164. : '已开始创建训练 161(狐狸)与 162(卡车);最终状态、版本、封面与场景依赖等待 final-evidence.json。'
  165. function sourceHtml(source) {
  166. if (!source.data) return `<article class="test"><h3>${html(source.title)} ${badge('PENDING')}</h3><p>等待 ${html(source.file)}。未计为通过。</p></article>`
  167. return `<article class="test"><h3>${html(source.data.title || source.title)} ${badge(source.status)}</h3>${source.checks.map((check, index) => `<div class="check"><h4>${html(check.title || check.name || check.label || `检查 ${index + 1}`)} ${badge(check.status)}</h4>${check.superseded || check.historical ? '<p class="pending-note">历史记录,不计入当前通过数。</p>' : ''}${details(check)}</div>`).join('')}${details(source.data, `${source.file} · 完整记录`)}</article>`
  168. }
  169. function sourceMd(source) {
  170. const title = source.data?.title || source.title
  171. if (!source.data) return `### ${title} · 待补充证据\n\n等待 ${source.file},未计为通过。`
  172. const checks = source.checks.map((check, index) => `- ${md(check.title || check.name || check.label || `检查 ${index + 1}`)}:${statusLabel(check.status)}${check.superseded || check.historical ? '(历史记录,不计入当前通过数)' : ''}`).join('\n')
  173. return `### ${title} · ${statusLabel(source.status)}\n\n${checks}\n\n<details><summary>${html(source.file)} · 脱敏完整记录</summary>\n\n<pre>${html(pretty(source.data))}</pre>\n\n</details>`
  174. }
  175. const summary = `证据组:${sources.filter(source => source.status === 'PASS').length}/${sources.length} 通过;明确检查:通过 ${passedChecks},失败 ${failedChecks},待验证 ${pendingChecks};已校验截图 ${imagePaths.size} 张。`
  176. const htmlCommands = commands.map(command => `<div class="check"><h4>${html(command.command || command.name || '验证命令')} ${badge(command.status)}</h4><p>${html(command.details || command.result || '')}</p></div>`).join('')
  177. const mdCommands = commands.map(command => `- ${md(command.command || command.name || '验证命令')}:${statusLabel(command.status)}。${md(command.details || command.result || '')}`).join('\n')
  178. const outputHtml = `<!doctype html>
  179. <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>训练编排真实测试报告 · 2026-09-05</title>
  180. <style>
  181. :root{color-scheme:light;--ink:#203544;--muted:#607789;--line:#dce6ec;--green:#137d6e;--soft:#eef6f7}*{box-sizing:border-box}body{margin:0;background:#f3f6f8;color:var(--ink);font:15px/1.75 system-ui,-apple-system,"Microsoft YaHei",sans-serif}main{max-width:1220px;margin:auto;padding:32px 24px 64px}header{padding:30px 34px;background:#103c45;color:white;border-radius:16px}h1{font-size:29px;margin:0 0 12px}header p{color:#d7e8eb;margin:7px 0}nav{display:flex;gap:10px;flex-wrap:wrap;margin:22px 0}nav a{color:var(--green);text-decoration:none;background:white;border:1px solid var(--line);padding:6px 13px;border-radius:7px}section{background:white;border:1px solid var(--line);border-radius:12px;padding:26px 30px;margin:20px 0}h2{font-size:21px;margin:0 0 16px}h3{font-size:17px;margin:0 0 12px}h4{font-size:15px;margin:0 0 7px}p{margin:10px 0}ul{padding-left:22px}li+li{margin-top:7px}.badge{display:inline-block;vertical-align:middle;font-size:12px;font-weight:600;line-height:1.7;border-radius:4px;padding:2px 8px;margin-left:5px}.pass{color:#11634f;background:#e3f5ed}.fail{color:#9e2a35;background:#ffe9ec}.pending{color:#7b6121;background:#fff2cc}.muted{color:var(--muted)}.pending-note{padding:12px 16px;background:#fff8e7;border-left:3px solid #c09a3b}.stages{display:grid;grid-template-columns:1fr 1fr;gap:18px}.stage{border:1px solid var(--line);border-radius:8px;padding:20px}.stage ul{margin-bottom:0}.test+.test{border-top:1px solid var(--line);margin-top:22px;padding-top:22px}.check{border-left:3px solid var(--line);padding:8px 16px;margin:12px 0}details{margin:12px 0}summary{cursor:pointer;color:var(--green)}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:600px;overflow:auto;padding:16px;background:#f5f8fa;border:1px solid var(--line);border-radius:7px;font:12px/1.6 ui-monospace,Consolas,monospace}.gallery{display:grid;grid-template-columns:1fr 1fr;gap:20px}figure{margin:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#fafcfd}figure img{width:100%;height:auto;display:block}figcaption{padding:10px 13px;color:var(--muted);font-size:13px}.table-scroll{overflow:auto}table{border-collapse:collapse;width:100%;font-size:13px}th,td{border:1px solid var(--line);text-align:left;padding:10px;white-space:nowrap}th{background:var(--soft)}footer{color:var(--muted);font-size:13px;text-align:center}a{color:var(--green)}@media(max-width:800px){main{padding:16px 12px 40px}header,section{padding:20px}h1{font-size:24px}.stages,.gallery{grid-template-columns:1fr}}@media print{body{background:white}main{max-width:none;padding:0}header{color:var(--ink);background:white;border:1px solid var(--line)}header p{color:var(--muted)}nav{display:none}section{break-inside:avoid}.gallery{grid-template-columns:1fr}details{break-inside:avoid}}
  182. </style></head><body><main>
  183. <header><h1>训练编排真实测试报告 ${badge(overall)}</h1><p>狐狸与牛奶卡车 · 场景 → 任务步骤 → 保存与发布</p><p>2026-09-05 · ${html(final.baseURL || 'http://127.0.0.1:6180')} · ${html(final.browser || 'Microsoft Edge / Playwright')} · ${html(final.role || '教员编排与独立浏览器只读重开')}</p><p>${html(summary)}</p></header>
  184. <nav><a href="#method">资料与实施</a><a href="#projects">保留训练数据</a><a href="#tests">测试证据</a><a href="#screenshots">最终截图</a><a href="#history">过程记录</a><a href="#scope">范围与限制</a><a href="report.md">Markdown 报告</a></nav>
  185. ${overall === 'PENDING' ? '<p class="pending-note">报告草稿:最终证据尚不完整。已有过程截图和开发记录不能代替保存、发布及重新打开的最终验证。</p>' : ''}
  186. <section id="method"><h2>资料 → 产品原型 → 设计 → 开发</h2><div class="stages">${stages.map(stage => `<article class="stage"><h3>${html(stage.title)}</h3>${bullets(stage.items)}</article>`).join('')}</div></section>
  187. <section id="projects"><h2>保留训练数据</h2>${htmlProjects}<p class="muted">保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。</p></section>
  188. <section id="tests"><h2>5 · 测试</h2><p>${html(summary)}</p><p class="muted">过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。</p>${versionMismatches.length ? `<p class="pending-note">独立重开证据尚未覆盖最终发布版本,不能标记完整通过。</p>${details(versionMismatches, '待重验的版本')}` : ''}${sources.map(sourceHtml).join('')}${commands.length ? `<h3>执行命令与验证结果</h3>${htmlCommands}` : '<p class="muted">命令与最终构建结果等待证据记录。</p>'}</section>
  189. <section id="screenshots"><h2>最终及异常验证截图</h2>${finalImages.length ? `<div class="gallery">${finalImages.map(figure).join('')}</div>` : '<p class="pending-note">等待最终证据引用实际截图。尚无最终截图不等于模型已通过可见性检查。</p>'}</section>
  190. <section id="history"><h2>过程截图 · 不计入最终通过结论</h2><p class="muted">这些截图只记录测试中途状态;最终两步、总分、版本和封面以上方证据为准。</p><div class="gallery">${history.map(figure).join('')}</div></section>
  191. <section id="scope"><h2>范围、限制与功能确认结论</h2>${bullets(scopeItems)}${limitations.length ? `<h3>实测限制与后续项</h3>${bullets(limitations.map(pretty))}` : ''}</section>
  192. <footer>报告生成:${html(generatedAt)}(Asia/Shanghai) · 来源:${sourceDefinitions.map(item => item.file).join('、')} · 图片均经本地文件校验</footer>
  193. </main></body></html>`
  194. const outputMd = `# 训练编排真实测试报告 · 2026-09-05
  195. 状态:**${statusLabel(overall)}**。${summary}
  196. ${overall === 'PENDING' ? '> 报告草稿:最终证据尚不完整,过程截图不能代替最终保存、发布和重新打开验证。\n' : ''}
  197. ${stages.map(stage => `## ${stage.title}\n\n${mdBullets(stage.items)}`).join('\n\n')}
  198. ## 保留训练数据
  199. ${mdProjects}
  200. 保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。
  201. ## 5 · 测试
  202. 过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。
  203. ${versionMismatches.length ? `> 独立重开证据尚未覆盖最终发布版本,不能标记完整通过。\n\n<pre>${html(pretty(versionMismatches))}</pre>\n` : ''}
  204. ${sources.map(sourceMd).join('\n\n')}
  205. ${commands.length ? `### 执行命令与验证结果\n\n${mdCommands}` : '命令与最终构建结果等待证据记录。'}
  206. ## 最终及异常验证截图
  207. ${finalImages.length ? finalImages.map(mdFigure).join('\n\n') : '等待最终证据引用实际截图;尚未将模型可见性计为通过。'}
  208. ## 过程截图 · 不计入最终通过结论
  209. ${history.map(mdFigure).join('\n\n')}
  210. ## 范围、限制与功能确认结论
  211. ${mdBullets(scopeItems)}
  212. ${limitations.length ? `### 实测限制与后续项\n\n${mdBullets(limitations.map(pretty))}\n` : ''}
  213. 报告生成:${generatedAt}(Asia/Shanghai)。
  214. `
  215. fs.writeFileSync(path.join(reportDir, 'index.html'), outputHtml, 'utf8')
  216. fs.writeFileSync(path.join(reportDir, 'report.md'), outputMd, 'utf8')
  217. console.log(JSON.stringify({ report: 'reports/training-live-20260905/index.html', status: overall, sources: sources.map(({ file, status: state }) => ({ file, status: state })), checks: { pass: passedChecks, fail: failedChecks, pending: pendingChecks }, images: imagePaths.size }))