${html(project.name || `项目 ${project.id}`)}
${figure(cover.image, `项目 ${project.id} 最终封面`, 'cover-image')}封面持久化证据
${html(pretty(cover))}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
? `
尚未记录图片。
' 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 = `${html(featureText)}
${html(environment)}
Markdown 报告${html(pretty(cover))}通过数只统计 status 为 PASS 的记录;故障注入中预期的 503、401 或 404 不等于用例失败。
${html(pretty(extra))}${html(retainedText)}
| 项目 | 初始状态 | 最终状态 |
|---|---|---|
| ${html(project.id)} · ${html(project.name)} | ${html(status(project.originalStatus))} | ${html(status(project.finalStatus))} |
${html(cleanupText)}
${html(pretty(issue))}`).join('') : '当前证据未登记未解决问题。
'}${html(entry.result)}本次 evidence.json 尚未记录命令结果;不据此宣称构建或命令行测试通过。
'}', html(pretty(item.details)), '', '', '
', html(entry.result), '', ''] }) : ['本次 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 }))