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