You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

245 line
24 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. // Evidence is prepared by the real browser run. This script never changes it.
  5. //
  6. // Expected shape (all fields other than `evidence` are optional):
  7. // {
  8. // date, baseURL, browser, role,
  9. // evidence: [{ title, status: 'PASS' | 'FAIL' | string, checks: [],
  10. // screenshot: 'screenshots/example.png', screenshots: [{ path, caption }], details,
  11. // superseded: true /* historical evidence excluded from current pass counts */ }],
  12. // correction: { title, summary, previousConclusion, rootCauses: [], fixes: [],
  13. // verification: [], screenshots: [{ path, caption }] },
  14. // projects: [{ id, name, finalStatus, finalVersionId, modelId, modelVersionId,
  15. // cover: { image, width, height, bytes, sizeBytes, assetId, sha256, storageUri } }],
  16. // finalListScreenshot, issues, followUpIssues, commands, retainedData, cleanup
  17. // }
  18. // Image values must be report-relative PNG/JPEG/WebP paths. Sensitive fields are removed
  19. // before rendering, so evidence may safely retain raw API responses locally.
  20. const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/scene-live-20260905')
  21. const sourcePath = path.join(reportDir, 'evidence.json')
  22. const sensitiveKey = /password|secret|token|ticket|authorization|cookie|signature|credential|api[_-]?key/i
  23. const cleanText = value => String(value ?? '')
  24. .replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
  25. .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[已隐藏]@')
  26. .replace(/\b((?:access|refresh)?[_-]?token|password|secret|uploadTicket|signature|api[_-]?key|X-Amz-(?:Signature|Credential|Security-Token))\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;&}]+)/gi, '$1=[已隐藏]')
  27. .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[已隐藏]')
  28. function sanitize(value) {
  29. if (typeof value === 'string') return cleanText(value)
  30. if (Array.isArray(value)) return value.map(sanitize)
  31. if (value && typeof value === 'object') {
  32. return Object.fromEntries(Object.entries(value)
  33. .filter(([key]) => !sensitiveKey.test(key))
  34. .map(([key, item]) => [key, sanitize(item)]))
  35. }
  36. return value
  37. }
  38. const data = sanitize(JSON.parse(fs.readFileSync(sourcePath, 'utf8').replace(/^\uFEFF/, '')))
  39. const asArray = value => Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]
  40. const evidence = asArray(data.evidence)
  41. const currentEvidence = evidence.filter(item => item.superseded !== true)
  42. const historicalCount = evidence.length - currentEvidence.length
  43. const correction = data.correction && typeof data.correction === 'object' ? data.correction : null
  44. const projects = asArray(data.projects)
  45. const commands = asArray(data.commands)
  46. const allIssues = asArray(data.issues)
  47. const issueText = issue => typeof issue === 'string' ? issue : JSON.stringify(issue ?? '')
  48. const fsvcTicketFinding = /(?:\bfsvc\b[\s\S]{0,160}(?:upload[\s_-]*ticket|上传票据|票据)|(?:upload[\s_-]*ticket|上传票据|票据)[\s\S]{0,160}\bfsvc\b)/i
  49. const isIndependent = issue => {
  50. if (!issue) return false
  51. if (typeof issue === 'object'
  52. && /^(?:existing|pre-existing|independent|follow-up)$/i.test(issue.scope || issue.category || '')) return true
  53. const text = issueText(issue)
  54. return fsvcTicketFinding.test(text)
  55. || /(?:既存|独立|后续).{0,30}票据|票据.{0,30}(?:既存|独立|后续)/.test(text)
  56. }
  57. const issues = allIssues.filter(issue => !isIndependent(issue))
  58. const independentIssues = [...new Map([...allIssues.filter(isIndependent), ...asArray(data.followUpIssues)]
  59. .map(issue => [JSON.stringify(issue), issue])).values()]
  60. const passCount = currentEvidence.filter(item => item.status === 'PASS').length
  61. const failCount = currentEvidence.filter(item => item.status === 'FAIL').length
  62. const otherCount = currentEvidence.length - passCount - failCount
  63. const html = value => cleanText(value).replace(/[&<>"']/g, char => ({
  64. '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  65. })[char])
  66. const md = value => html(value).replaceAll('\\', '\\\\').replace(/[|\[\]*_`]/g, char => `\\${char}`).replace(/\r?\n/g, ' ')
  67. const url = value => String(value).replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/')
  68. const pretty = value => typeof value === 'string' ? value : JSON.stringify(value, null, 2)
  69. const recorded = value => value === undefined || value === null || value === '' ? '未记录' : value
  70. const statusClass = value => value === 'PASS' ? 'pass' : value === 'FAIL' ? 'fail' : 'neutral'
  71. const imageFields = new Set(['screenshot', 'screenshots', 'coverImage', 'image', 'finalListScreenshot'])
  72. const imagePaths = new Set()
  73. function imageRefs(value) {
  74. return asArray(value).flatMap(item => {
  75. if (!item) return []
  76. if (typeof item === 'string') return [{ path: item, caption: '' }]
  77. if (typeof item === 'object') {
  78. const imagePath = item.path || item.src || item.image
  79. if (typeof imagePath === 'string' && imagePath) return [{ path: imagePath, caption: item.caption || item.title || '' }]
  80. }
  81. throw new Error('图片引用应为相对路径字符串或 { path, caption } 对象。')
  82. })
  83. }
  84. function collectImages(value) {
  85. if (Array.isArray(value)) return value.forEach(collectImages)
  86. if (!value || typeof value !== 'object') return
  87. for (const [key, item] of Object.entries(value)) {
  88. if (imageFields.has(key)) imageRefs(item).forEach(ref => imagePaths.add(ref.path))
  89. else collectImages(item)
  90. }
  91. }
  92. collectImages(data)
  93. // Fail before writing either report when any referenced image is missing or unsafe.
  94. const realReportDir = fs.realpathSync(reportDir)
  95. for (const relative of imagePaths) {
  96. if (path.isAbsolute(relative) || /^[a-z][a-z\d+.-]*:/i.test(relative)
  97. || !/\.(png|jpe?g|webp)$/i.test(relative)) {
  98. throw new Error(`图片必须是报告目录中的 PNG/JPEG/WebP 相对路径:${relative}`)
  99. }
  100. const absolute = path.resolve(reportDir, relative.replaceAll('\\', '/'))
  101. const underRoot = path.relative(reportDir, absolute)
  102. if (underRoot === '..' || underRoot.startsWith(`..${path.sep}`) || path.isAbsolute(underRoot)
  103. || !fs.existsSync(absolute) || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size === 0) {
  104. throw new Error(`图片不存在、为空或越出报告目录:${relative}`)
  105. }
  106. const realRelative = path.relative(realReportDir, fs.realpathSync(absolute))
  107. if (realRelative === '..' || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) {
  108. throw new Error(`图片链接目标越出报告目录:${relative}`)
  109. }
  110. }
  111. function picturesOf(item) {
  112. const refs = [item.screenshot, item.screenshots, item.coverImage].flatMap(imageRefs)
  113. return refs.filter((ref, index) => refs.findIndex(other => other.path === ref.path) === index)
  114. .map(ref => item.superseded === true
  115. ? { ...ref, caption: `历史截图(不作为修复后的视觉通过证据):${ref.caption || '原测试现场'}` }
  116. : ref)
  117. }
  118. const coverOf = project => imageRefs(project.cover?.image || project.coverImage || project.cover?.screenshot)[0]
  119. const finalList = imageRefs(data.finalListScreenshot)[0] || [...evidence].reverse()
  120. .filter(item => /(?:最终|倒序|排序).*列表|列表.*(?:最终|倒序|排序)/.test(item.title || ''))
  121. .flatMap(picturesOf)[0]
  122. const figure = (ref, caption, className = '') => ref
  123. ? `<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>`
  124. : '<p class="muted">尚未记录本地图片。</p>'
  125. const mdImage = (ref, caption) => ref
  126. ? `[![${md(ref.caption || caption)}](${url(ref.path)})](${url(ref.path)})`
  127. : '尚未记录本地图片。'
  128. const extraOf = item => Object.fromEntries(Object.entries(item)
  129. .filter(([key]) => !['title', 'status', 'checks', 'screenshot', 'screenshots', 'coverImage'].includes(key)))
  130. const checkText = check => typeof check === 'string' ? check : pretty(check)
  131. const evidenceStatus = item => item.superseded === true ? '历史记录(已更正)' : recorded(item.status)
  132. const correctionSections = correction ? [
  133. ['原结论与漏验', asArray(correction.previousConclusion)],
  134. ['根因', asArray(correction.rootCauses)],
  135. ['修复内容', asArray(correction.fixes)],
  136. ['修复后验证', asArray(correction.verification)],
  137. ].filter(([, items]) => items.length) : []
  138. const correctionPictures = correction ? picturesOf(correction) : []
  139. 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>` : ''
  140. const correctionMarkdown = correction ? [
  141. `## ${md(correction.title || '狐狸场景验证更正')}`, '',
  142. ...(correction.summary ? [`> **测试结论更正:** ${md(correction.summary)}`, ''] : []),
  143. ...correctionSections.flatMap(([title, items]) => [`### ${md(title)}`, '', ...items.map(item => `- ${md(checkText(item))}`), '']),
  144. ...correctionPictures.flatMap(ref => [mdImage(ref, '修复后视觉验证'), '']),
  145. '下方标为“历史记录(已更正)”的检查及截图保留用于追溯,不计入当前通过率,也不作为狐狸已经可见的证据。', '',
  146. ] : []
  147. const projectStatus = project => recorded(project.finalStatus || project.status)
  148. const projectVersion = project => recorded(project.finalVersionId || project.currentVersionId || project.cover?.versionId)
  149. const commandParts = item => typeof item === 'string'
  150. ? { command: item, result: '未记录执行结果' }
  151. : { command: item.command || item.cmd || item.name || '命令未记录',
  152. result: pretty(Object.fromEntries(Object.entries(item).filter(([key]) => !['command', 'cmd', 'name'].includes(key)))) }
  153. const featureText = '场景保存截图封面及列表按创建时间倒序的功能已实施。本报告记录使用现有模型创建真实场景、保存当前三维视口封面及异常回退的实际验证结果。'
  154. const environment = `${recorded(data.date)} · ${recorded(data.baseURL)} · ${recorded(data.browser)} · ${recorded(data.role)}`
  155. const retainedText = data.retainedData ? pretty(data.retainedData) : projects.length
  156. ? `本次创建的 ${projects.length} 条场景保留在开发环境:${projects.map(project => `${project.id}(${project.name || '未命名'})`).join('、')}。模型引用固定到下表记录的已发布版本。`
  157. : '尚未记录保留场景。'
  158. const cleanupText = data.cleanup ? pretty(data.cleanup) : '未记录额外清理操作;报告不据此宣称测试文件或历史版本已删除。'
  159. const limitations = [
  160. '手动保存从当前三维视口生成封面;新封面与场景内容、模型引用在同一次工程更新中关联保存。发布前的保存沿用该流程。',
  161. '截图或封面上传失败时保留旧封面;工程是否保存成功以实际更新请求为准。版本冲突不覆盖其他已保存的内容。',
  162. '自动恢复及重新打开工程不生成或上传新封面;恢复后的修改需由用户保存后更新封面。',
  163. '列表按创建时间倒序展示。再次保存不会改变创建时间,也不应因此改变创建时间排序。',
  164. ...asArray(data.limitations).map(checkText),
  165. ]
  166. const independentText = '既存受控文件资产的 fsvc URI 分支未完整覆盖上传票据绑定校验,属于本次场景封面变更前已存在的问题。本次保留现有流程,后续应独立补齐校验并验证历史资产、版本复制与引用兼容性;不计入本次场景功能失败数。'
  167. const countText = `当前证据项通过 ${passCount} / ${currentEvidence.length};失败 ${failCount};其他或未标注 ${otherCount};已更正历史记录 ${historicalCount};已校验图片文件 ${imagePaths.size}。`
  168. const outputHtml = `<!doctype html>
  169. <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
  170. <title>真实场景与截图封面验证报告 · ${html(data.date)}</title>
  171. <style>
  172. .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}
  173. :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}}
  174. </style></head><body><main>
  175. <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>
  176. ${correctionHtml}
  177. <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>
  178. <h2>保留场景与封面证据</h2><div class="covers">${projects.map(project => {
  179. const cover = project.cover || {}
  180. 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>`
  181. }).join('') || '<p>尚未记录场景项目。</p>'}</div>
  182. <h2>最终列表与创建时间倒序</h2><section class="panel">${figure(finalList, '保留场景的实际封面与创建时间倒序列表')}</section>
  183. <h2>逐项验证记录</h2><p class="muted">通过数仅统计未被更正且 status 明确为 PASS 的当前证据项;历史记录单独标注。checks 用于展示实际断言,不重复累加。故障注入中的预期错误响应不等于用例失败;缺少状态时不推断通过。图片文件存在校验不等于视觉内容验证。</p><div class="checks">${evidence.map((item, index) => {
  184. const pictures = picturesOf(item)
  185. const extra = extraOf(item)
  186. 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>`
  187. }).join('') || '<p>尚未记录验证结果。</p>'}</div>
  188. <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>
  189. <h2>本次问题记录</h2><section class="panel">${issues.length ? issues.map(issue => `<pre>${html(pretty(issue))}</pre>`).join('') : '<p>当前证据未登记本次新增的未解决问题。</p>'}</section>
  190. <h2>后续独立事项</h2><section class="panel"><p>${html(independentText)}</p>${independentIssues.map(issue => `<pre>${html(pretty(issue))}</pre>`).join('')}</section>
  191. <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>
  192. <h2>当前行为与限制</h2><section class="panel"><ul>${limitations.map(item => `<li>${html(item)}</li>`).join('')}</ul></section>
  193. <footer>报告来自 evidence.json,生成过程不修改原始证据。所有引用图片已校验存在且非空,点击可查看原图。通过数随证据更新,不固定用例数量。</footer>
  194. </main></body></html>`
  195. const outputMarkdown = [
  196. '# 真实场景与截图封面验证报告', '', featureText, '', md(environment), '', ...correctionMarkdown, countText, '',
  197. '## 保留场景与封面证据', '',
  198. ...projects.flatMap(project => [
  199. `### ${md(project.name || `场景 ${project.id}`)}`, '',
  200. `场景 ${md(project.id)};状态 ${md(projectStatus(project))};当前版本 ${md(projectVersion(project))};引用模型 ${md(recorded(project.modelId))} / 发布版本 ${md(recorded(project.modelVersionId))}。`, '',
  201. mdImage(coverOf(project), `场景 ${project.id} 保存后封面回读证据`), '',
  202. '<details><summary>封面持久化与项目证据</summary>', '', '<pre>', html(pretty(project)), '</pre>', '', '</details>', '',
  203. ]),
  204. '## 最终列表与创建时间倒序', '', mdImage(finalList, '保留场景的封面与创建时间倒序列表'), '',
  205. '## 逐项验证记录', '',
  206. '通过数仅统计未被更正且 status 明确为 PASS 的当前证据项;历史记录单独标注。checks 不重复累加。预期故障响应不等于用例失败,缺少状态不推断通过。图片文件存在校验不等于视觉内容验证。', '',
  207. '| 序号 | 检查项 | 结果 | 图片 |', '|---|---|---|---|',
  208. ...evidence.map((item, index) => `| ${index + 1} | ${md(item.title || '未命名检查')} | ${md(evidenceStatus(item))} | ${picturesOf(item).map((ref, i) => `[图片 ${i + 1}](${url(ref.path)})`).join(' · ') || '—'} |`), '',
  209. ...evidence.flatMap((item, index) => [
  210. `### ${index + 1}. ${md(item.title || '未命名检查')} · ${md(evidenceStatus(item))}`, '',
  211. ...asArray(item.checks).map(check => `- ${md(checkText(check))}`), '',
  212. ...(Object.keys(extraOf(item)).length ? ['<details><summary>检查详情</summary>', '', '<pre>', html(pretty(extraOf(item))), '</pre>', '', '</details>', ''] : []),
  213. ...picturesOf(item).flatMap(ref => [mdImage(ref, '验证现场'), '']),
  214. ]),
  215. '## 数据保留与清理', '', '<pre>', html(retainedText), '</pre>', '',
  216. '| 场景 | 模型 / 发布版本 | 最终状态 | 当前版本 |', '|---|---|---|---|',
  217. ...projects.map(project => `| ${md(project.id)} · ${md(project.name)} | ${md(recorded(project.modelId))} / ${md(recorded(project.modelVersionId))} | ${md(projectStatus(project))} | ${md(projectVersion(project))} |`), '',
  218. '<pre>', html(cleanupText), '</pre>', '',
  219. '## 本次问题记录', '',
  220. ...(issues.length ? issues.flatMap(issue => ['<pre>', html(pretty(issue)), '</pre>', '']) : ['当前证据未登记本次新增的未解决问题。', '']),
  221. '## 后续独立事项', '', independentText, '',
  222. ...independentIssues.flatMap(issue => ['<pre>', html(pretty(issue)), '</pre>', '']),
  223. '## 实际命令与结果', '',
  224. ...(commands.length ? commands.flatMap(item => { const entry = commandParts(item); return [`**${md(entry.command)}**`, '', '<pre>', html(entry.result), '</pre>', ''] })
  225. : ['evidence.json 尚未记录命令结果;不据此宣称构建或命令行测试通过。', '']),
  226. '## 当前行为与限制', '', ...limitations.map(item => `- ${item}`), '',
  227. '报告来自 evidence.json,未修改原始证据;所有引用图片已校验存在且非空,点击可查看原图。通过数随证据更新。', '',
  228. ].join('\n')
  229. fs.writeFileSync(path.join(reportDir, 'index.html'), outputHtml, 'utf8')
  230. fs.writeFileSync(path.join(reportDir, 'report.md'), outputMarkdown, 'utf8')
  231. console.log(JSON.stringify({ report: 'reports/scene-live-20260905/index.html', passed: passCount,
  232. total: currentEvidence.length, historical: historicalCount, failed: failCount, other: otherCount, imagesValidated: imagePaths.size,
  233. projectsRecorded: projects.length, commandsRecorded: commands.length }))