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.
 
 
 
 

159 regels
16 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/model-cover-20260905')
  5. const sourcePath = path.join(reportDir, 'evidence.json')
  6. const sensitiveKey = /password|secret|token|ticket|authorization|cookie|signature|credential/i
  7. const cleanText = value => String(value ?? '')
  8. .replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
  9. .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[已隐藏]@')
  10. .replace(/\b((?:access|refresh)?[_-]?token|password|secret|uploadTicket|signature|api[_-]?key)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)/gi, '$1=[已隐藏]')
  11. function sanitize(value) {
  12. if (typeof value === 'string') return cleanText(value)
  13. if (Array.isArray(value)) return value.map(sanitize)
  14. if (value && typeof value === 'object') {
  15. return Object.fromEntries(Object.entries(value)
  16. .filter(([key]) => !sensitiveKey.test(key))
  17. .map(([key, item]) => [key, sanitize(item)]))
  18. }
  19. return value
  20. }
  21. const data = sanitize(JSON.parse(fs.readFileSync(sourcePath, 'utf8').replace(/^\uFEFF/, '')))
  22. const evidence = Array.isArray(data.evidence) ? data.evidence : []
  23. const projects = Array.isArray(data.projects) ? data.projects : []
  24. const issues = Array.isArray(data.issues) ? data.issues : data.issues ? [data.issues] : []
  25. const commands = Array.isArray(data.commands) ? data.commands : data.commands ? [data.commands] : []
  26. const passCount = evidence.filter(item => item.status === 'PASS').length
  27. const nonPassCount = evidence.length - passCount
  28. const imageFields = new Set(['screenshot', 'screenshots', 'coverImage', 'image', 'finalListScreenshot'])
  29. const imagePaths = new Set()
  30. function collectImages(value, key = '') {
  31. if (typeof value === 'string' && imageFields.has(key) && value) imagePaths.add(value)
  32. else if (Array.isArray(value)) value.forEach(item => collectImages(item, key))
  33. else if (value && typeof value === 'object') {
  34. Object.entries(value).forEach(([childKey, item]) => collectImages(item, childKey))
  35. }
  36. }
  37. collectImages(data)
  38. const realReportDir = fs.realpathSync(reportDir)
  39. for (const relative of imagePaths) {
  40. const normalized = relative.replaceAll('\\', '/')
  41. if (path.isAbsolute(relative) || /^[a-z][a-z\d+.-]*:/i.test(relative)
  42. || !/\.(png|jpe?g|webp)$/i.test(relative)) {
  43. throw new Error(`图片必须是报告目录中的 PNG/JPEG/WebP 相对路径:${relative}`)
  44. }
  45. const absolute = path.resolve(reportDir, normalized)
  46. const underRoot = path.relative(reportDir, absolute)
  47. if (underRoot.startsWith('..') || path.isAbsolute(underRoot)
  48. || !fs.existsSync(absolute) || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size === 0) {
  49. throw new Error(`图片不存在、为空或越出报告目录:${relative}`)
  50. }
  51. const realRelative = path.relative(realReportDir, fs.realpathSync(absolute))
  52. if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) {
  53. throw new Error(`图片链接目标越出报告目录:${relative}`)
  54. }
  55. }
  56. const html = value => cleanText(value).replace(/[&<>"']/g, char => ({
  57. '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  58. })[char])
  59. const md = value => cleanText(value).replaceAll('\\', '\\\\').replaceAll('|', '\\|').replace(/\r?\n/g, ' ')
  60. const url = value => String(value).replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/')
  61. const pretty = value => typeof value === 'string' ? value : JSON.stringify(value, null, 2)
  62. const status = value => value || '未记录'
  63. const statusClass = value => value === 'PASS' ? 'pass' : value === 'FAIL' ? 'fail' : 'neutral'
  64. const picturesOf = item => [...new Set([
  65. item.screenshot,
  66. ...(Array.isArray(item.screenshots) ? item.screenshots : []),
  67. item.coverImage,
  68. ].filter(Boolean))]
  69. const figure = (src, caption, className = '') => src
  70. ? `<figure class="${className}"><a href="${html(url(src))}" target="_blank" rel="noopener"><img src="${html(url(src))}" alt="${html(caption)}" loading="lazy"></a><figcaption>${html(caption)} · 点击查看原图</figcaption></figure>`
  71. : '<p class="muted">尚未记录图片。</p>'
  72. const mdImage = (src, caption) => src ? `[![${md(caption)}](${url(src)})](${url(src)})` : '尚未记录图片。'
  73. const finalList = data.finalListScreenshot || [...evidence].reverse().find(item => item.screenshot
  74. && (/最终.*(?:列表|模型|封面)/.test(item.title || '') || /final.*(?:covers|list)/i.test(item.screenshot)))?.screenshot
  75. const cleanup = evidence.find(item => String(item.details?.id) === '158' && item.status === 'PASS'
  76. && (item.details?.readAfterDelete === 404 || /清理|软删/.test(item.title || '')))
  77. const cleanupText = cleanup
  78. ? '首次导入验证使用的临时项目 158 已通过业务软删除,删除后读取返回 404;报告保留操作证据,不宣称物理文件已删除。'
  79. : '临时项目 158 的业务软删除结果尚未在 evidence.json 中完整记录,请以补充后的证据为准。'
  80. const retainedText = data.retainedData ? pretty(data.retainedData) : projects.length
  81. ? `项目 ${projects.map(item => item.id).join('、')} 保留在开发环境,供继续查看模型、封面及历史版本。`
  82. : '尚未记录需要保留的项目。'
  83. const limitations = [
  84. '仅手动保存和首次导入模型时生成或更新封面,封面取自当前三维视口。',
  85. '自动保存只保存工程变更,不重新截图或上传封面。',
  86. '截图或封面上传失败时保留旧封面,并提示失败;工程保存仍按其实际请求结果判断。',
  87. ]
  88. const featureText = '截图封面功能已实施并完成下列真实浏览器验证:视口截图作为受控图片资产与工程关联保存,列表读取真实封面,模型列表按创建时间倒序显示。'
  89. const environment = `${data.date || '日期未记录'} · ${data.baseURL || '地址未记录'} · ${data.browser || '浏览器未记录'} · ${data.role || '角色未记录'}`
  90. const commandParts = item => typeof item === 'string'
  91. ? { command: item, result: '未记录执行结果' }
  92. : { command: item.command || item.cmd || item.name || '命令未记录',
  93. result: pretty(Object.fromEntries(Object.entries(item).filter(([key]) => !['command', 'cmd', 'name'].includes(key)))) }
  94. const outputHtml = `<!doctype html>
  95. <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
  96. <title>模型截图封面验证报告 · ${html(data.date)}</title>
  97. <style>
  98. :root{color-scheme:light;--ink:#172b34;--muted:#536872;--line:#dce5e7;--accent:#10664e}*{box-sizing:border-box}body{margin:0;background:#f2f6f6;color:var(--ink);font:15px/1.7 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1180px;margin:0 auto;padding:40px 28px 60px}header{padding-bottom:22px;border-bottom:1px solid var(--line)}.eyebrow{font-size:12px;letter-spacing:.14em;color:var(--accent);font-weight:700}h1{font-size:32px;line-height:1.35;margin:8px 0 12px}h2{font-size:23px;margin:32px 0 15px}h3{font-size:17px;margin:0 0 8px}p{margin:8px 0 12px}.muted,figcaption{color:var(--muted)}a{color:var(--accent);text-underline-offset:3px}.stats{display:flex;gap:12px;flex-wrap:wrap;margin:22px 0}.stat{background:white;border:1px solid var(--line);border-radius:10px;padding:12px 18px;min-width:150px}.stat strong{font-size:25px;display:block}.covers,.checks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.card,.check,.panel{background:white;border:1px solid var(--line);border-radius:12px;padding:20px;min-width:0}.cover-meta{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;font-size:13px;margin-top:12px}.cover-meta dt{color:var(--muted)}.cover-meta dd{margin:0;overflow-wrap:anywhere}figure{margin:12px 0 0}figure img{display:block;width:100%;height:auto;border:1px solid var(--line);border-radius:7px;background:#edf1f1}.cover-image img{aspect-ratio:640/346;object-fit:contain}.evidence-images{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.evidence-images.single{grid-template-columns:1fr}figcaption{font-size:12px;margin-top:5px}.badge{display:inline-block;font-size:12px;font-weight:700;padding:1px 9px;border-radius:12px;white-space:nowrap}.pass{background:#e1f3eb;color:#0c6144}.fail{background:#fde8e8;color:#ab2323}.neutral{background:#edf0f4;color:#425566}.check-title{display:flex;gap:10px;align-items:flex-start;justify-content:space-between}details{margin-top:12px}summary{cursor:pointer;color:var(--accent);font-size:13px}pre{margin:10px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.6 ui-monospace,Consolas,monospace;background:#f4f7f8;padding:12px;border-radius:7px;max-height:360px;overflow:auto}table{width:100%;border-collapse:collapse;font-size:13px}th,td{padding:9px 12px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}th{background:#f4f7f8}td{overflow-wrap:anywhere}ul{padding-left:22px}.command{margin-bottom:12px}.command pre{max-height:280px}footer{margin-top:28px;color:var(--muted);font-size:12px}@media(max-width:760px){main{padding:24px 16px 40px}h1{font-size:26px}.covers,.checks{grid-template-columns:1fr}.stat{flex:1;min-width:100px}.panel,.card,.check{padding:15px}.table-wrap{overflow:auto}}@media print{body{background:white}main{max-width:none;padding:0}details{display:block}pre{max-height:none}.card,.check,figure{break-inside:avoid}a{color:inherit}.checks{display:block}.check{margin-bottom:14px}}
  99. </style></head><body><main>
  100. <header><div class="eyebrow">MODEL COVER · IMPLEMENTED</div><h1>模型截图封面验证报告</h1><p>${html(featureText)}</p><p class="muted">${html(environment)}</p><a href="report.md">Markdown 报告</a></header>
  101. <div class="stats"><div class="stat"><strong>${passCount} / ${evidence.length}</strong>证据项通过</div><div class="stat"><strong>${nonPassCount}</strong>非 PASS 证据项</div><div class="stat"><strong>${imagePaths.size}</strong>图片文件已校验</div></div>
  102. <h2>最终模型封面</h2><div class="covers">${projects.map(project => {
  103. const cover = project.cover || {}
  104. return `<article class="card"><h3>${html(project.name || `项目 ${project.id}`)}</h3>${figure(cover.image, `项目 ${project.id} 最终封面`, 'cover-image')}<dl class="cover-meta"><dt>项目 / 最终状态</dt><dd>${html(project.id)} / ${html(status(project.finalStatus))}</dd><dt>工程版本 / 封面资产</dt><dd>${html(project.finalVersionId || cover.versionId || '未记录')} / ${html(cover.coverAssetId || '未记录')}</dd><dt>图片规格</dt><dd>${html(cover.width || '?')} × ${html(cover.height || '?')} · ${html(cover.bytes ?? '?')} 字节</dd></dl><details><summary>封面持久化证据</summary><pre>${html(pretty(cover))}</pre></details></article>`
  105. }).join('')}</div>
  106. <h2>最终列表现场</h2><section class="panel">${figure(finalList, '两条保留模型的真实封面与创建时间倒序列表')}</section>
  107. <h2>验证记录</h2><p class="muted">通过数只统计 status 为 PASS 的记录;故障注入中预期的 503、401 或 404 不等于用例失败。</p><div class="checks">${evidence.map((item, index) => {
  108. const pictures = picturesOf(item)
  109. const extra = Object.fromEntries(Object.entries(item).filter(([key]) => !['title', 'status', 'screenshot', 'screenshots', 'coverImage'].includes(key)))
  110. return `<article class="check"><div class="check-title"><h3>${index + 1}. ${html(item.title)}</h3><span class="badge ${statusClass(item.status)}">${html(status(item.status))}</span></div>${Object.keys(extra).length ? `<details><summary>检查细节</summary><pre>${html(pretty(extra))}</pre></details>` : ''}<div class="evidence-images ${pictures.length === 1 ? 'single' : ''}">${pictures.map(src => figure(src, /\.jpe?g$/i.test(src) ? '实际生成封面' : '浏览器现场')).join('')}</div></article>`
  111. }).join('')}</div>
  112. <h2>数据保留与清理</h2><section class="panel"><p>${html(retainedText)}</p><div class="table-wrap"><table><thead><tr><th>项目</th><th>初始状态</th><th>最终状态</th></tr></thead><tbody>${projects.map(project => `<tr><td>${html(project.id)} · ${html(project.name)}</td><td>${html(status(project.originalStatus))}</td><td>${html(status(project.finalStatus))}</td></tr>`).join('')}</tbody></table></div><p>${html(cleanupText)}</p></section>
  113. <h2>问题记录</h2><section class="panel">${issues.length ? issues.map(issue => `<pre>${html(pretty(issue))}</pre>`).join('') : '<p>当前证据未登记未解决问题。</p>'}</section>
  114. <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>
  115. <h2>当前行为与限制</h2><section class="panel"><ul>${limitations.map(item => `<li>${html(item)}</li>`).join('')}</ul></section>
  116. <footer>报告由 evidence.json 生成,未修改原始证据。所有引用图片在生成时校验为存在且非空;点击图片可查看原图。</footer>
  117. </main></body></html>`
  118. const outputMarkdown = [
  119. '# 模型截图封面验证报告', '', featureText, '', environment, '',
  120. `证据通过:**${passCount} / ${evidence.length}**;非 PASS:${nonPassCount};已校验图片:${imagePaths.size}。`, '',
  121. '## 最终模型封面', '',
  122. ...projects.flatMap(project => [
  123. `### ${project.name || `项目 ${project.id}`}`, '',
  124. `项目 ${project.id};最终状态:${status(project.finalStatus)};工程版本 ID:${project.finalVersionId || project.cover?.versionId || '未记录'};封面资产 ID:${project.cover?.coverAssetId || '未记录'}。`, '',
  125. mdImage(project.cover?.image, `项目 ${project.id} 最终封面`), '',
  126. ]),
  127. '## 最终列表现场', '', mdImage(finalList, '最终两条模型封面列表'), '',
  128. '## 验证记录', '', '通过统计只认 status 为 PASS;预期故障响应不视为用例失败。', '',
  129. '| 序号 | 检查项 | 结果 | 图片 |', '|---|---|---|---|',
  130. ...evidence.map((item, index) => `| ${index + 1} | ${md(item.title)} | ${md(status(item.status))} | ${picturesOf(item).map((src, i) => `[图片 ${i + 1}](${url(src)})`).join(' · ') || '—'} |`), '',
  131. ...evidence.filter(item => item.details !== undefined).flatMap(item => [
  132. `<details><summary>${html(item.title)}:检查细节</summary>`, '', '<pre>', html(pretty(item.details)), '</pre>', '', '</details>', '',
  133. ]),
  134. '## 数据保留与清理', '', retainedText, '',
  135. '| 项目 | 初始状态 | 最终状态 |', '|---|---|---|',
  136. ...projects.map(project => `| ${md(project.id)} · ${md(project.name)} | ${md(status(project.originalStatus))} | ${md(status(project.finalStatus))} |`), '',
  137. cleanupText, '', '## 问题记录', '',
  138. ...(issues.length ? issues.flatMap(issue => [pretty(issue), '']) : ['当前证据未登记未解决问题。', '']),
  139. '## 实际命令与结果', '',
  140. ...(commands.length ? commands.flatMap(item => { const entry = commandParts(item); return [`**${md(entry.command)}**`, '', '<pre>', html(entry.result), '</pre>', ''] })
  141. : ['本次 evidence.json 尚未记录命令结果;不据此宣称构建或命令行测试通过。', '']),
  142. '## 当前行为与限制', '', ...limitations.map(item => `- ${item}`), '',
  143. '报告由 evidence.json 生成,未修改原始证据;图片均已校验存在且非空,点击图片可查看原图。', '',
  144. ].join('\n')
  145. fs.writeFileSync(path.join(reportDir, 'index.html'), outputHtml, 'utf8')
  146. fs.writeFileSync(path.join(reportDir, 'report.md'), outputMarkdown, 'utf8')
  147. console.log(JSON.stringify({ report: 'reports/model-cover-20260905/index.html', passed: passCount,
  148. total: evidence.length, nonPass: nonPassCount, imagesValidated: imagePaths.size, commandsRecorded: commands.length }))