import { existsSync } from 'node:fs'; import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const DEFAULT_REPORT_DIR = fileURLToPath(new URL('../reports/model-live-20260905/', import.meta.url)); const missing = '未记录'; const has = (value) => value !== undefined && value !== null && value !== ''; const list = (value) => Array.isArray(value) ? value : has(value) ? [value] : []; const escape = (value) => String(value).replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]); const markdown = (value) => String(value).replace(/([\\`*_[\]<>|])/g, '\\$1').replace(/\r?\n/g, ' / '); const display = (value) => has(value) ? String(value) : missing; const first = (...values) => values.find(has); const labels = { status: '状态', result: '结果', passed: '检查通过', success: '操作成功', server: '服务端', live: '页面运行时', sha256: 'SHA-256', bytes: '文件字节数', mutations: '变更记录', name: '名称', code: '编码', id: 'ID', httpStatus: 'HTTP 状态', message: '说明', details: '详情', note: '备注', reason: '原因', issue: '问题', saved: '已保存', persisted: '已持久化', reopened: '重开', published: '发布', publishedAt: '发布时间', version: '版本', title: '标题', file: '来源文件', sourcePath: '素材路径', assetUrl: '资源地址', modelUrl: '模型地址', url: '地址', objectCount: '对象数', meshCount: '网格数', animationCount: '动画数', animations: '动画', clip: '动画编号', clipName: '动画名称', clips: '动画数', playing: '正在播放', time: '时间', before: '变更前', after: '变更后', position: '位置', rotation: '旋转', scale: '缩放', visible: '可见', retained: '保留状态', cleanup: '清理记录', retainedData: '保留数据', nodes: '节点数', meshes: '网格数', materials: '材质数', textures: '纹理数', type: '类别', command: '执行命令', rows: '列表核验结果', }; function status(value) { if (value && typeof value === 'object') value = first(value.status, value.result, value.passed, value.success); if (!has(value)) return { label: missing, tone: 'unknown' }; if (value === true) return { label: '通过', tone: 'pass' }; if (value === false) return { label: '失败', tone: 'fail' }; const raw = String(value); const normalized = raw.toUpperCase(); if (['PASS', 'PASSED', 'SUCCESS', 'OK', '通过', '成功'].includes(normalized)) return { label: '通过', tone: 'pass' }; if (['FAIL', 'FAILED', 'ERROR', '失败', '错误'].includes(normalized)) return { label: '失败', tone: 'fail' }; if (['SKIP', 'SKIPPED', '跳过'].includes(normalized)) return { label: '跳过', tone: 'unknown' }; if (['PENDING', 'TODO', 'NOT_RUN', '未执行', '待验证'].includes(normalized)) return { label: '待验证', tone: 'unknown' }; if (normalized === 'PUBLISHED') return { label: '已发布', tone: 'info' }; if (normalized === 'DRAFT') return { label: '草稿', tone: 'info' }; return { label: raw, tone: 'info' }; } function safeText(value) { return String(value) .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [已隐藏]') .replace(/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, '[令牌已隐藏]') .slice(0, 700); } // Keep useful scalar evidence, with bounded nesting; never dump scene documents. function details(value, prefix = '', depth = 0) { if (!has(value)) return []; if (typeof value !== 'object') return [[prefix || '记录', safeText(value)]]; if (Array.isArray(value)) { if (!value.length) return [[prefix || '记录', '0 项']]; if (value.every((item) => item === null || typeof item !== 'object')) { return [[prefix || '记录', safeText(value.slice(0, 8).join('、')) + (value.length > 8 ? `(共 ${value.length} 项)` : '')]]; } if (depth >= 3) return [[prefix || '记录', `${value.length} 项,完整内容见 evidence.json`]]; return value.slice(0, 3).flatMap((item, index) => details(item, `${prefix || '记录'} ${index + 1}`, depth + 1)) .concat(value.length > 3 ? [[prefix || '记录', `共 ${value.length} 项,完整内容见 evidence.json`]] : []); } const rows = []; for (const [key, item] of Object.entries(value)) { if (['screenshot', 'screenshots'].includes(key) || !has(item)) continue; const label = [prefix, labels[key] || key].filter(Boolean).join(' / '); if (/password|passwd|secret|token|authorization|cookie|credential/i.test(key)) { rows.push([label, '[已隐藏]']); } else if (/^(modelObjects|objects|nodes|meshes|materials|textures|buffers|bufferViews|accessors)$/i.test(key)) { rows.push([label, typeof item === 'object' ? `${Array.isArray(item) ? item.length : Object.keys(item).length} 项(省略明细)` : safeText(item)]); } else if (depth >= 3 && typeof item === 'object') { rows.push([label, '已记录,完整内容见 evidence.json']); } else { rows.push(...details(item, label, depth + 1)); } if (rows.length >= 24) { rows.push(['更多记录', '见 evidence.json']); break; } } return rows; } function tableHtml(headers, rows) { return `
${headers.map((cell) => ``).join('')}${rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')}
${escape(cell)}
${cell}
`; } function tableMarkdown(headers, rows) { return [`| ${headers.map(markdown).join(' | ')} |`, `| ${headers.map(() => '---').join(' | ')} |`, ...rows.map((row) => `| ${row.map(markdown).join(' | ')} |`)].join('\n'); } function badge(value) { const item = status(value); return `${escape(item.label)}`; } function record(value) { const rows = details(value); return { html: rows.length ? `
${rows.map(([key, item]) => `
${escape(key)}
${escape(item)}
`).join('')}
` : `

${missing}

`, md: rows.length ? rows.map(([key, item]) => `- ${markdown(key)}:${markdown(item)}`).join('\n') : missing, }; } export function renderModelLiveReport(evidence, { reportDir = DEFAULT_REPORT_DIR } = {}) { if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) throw new Error('evidence.json 必须为对象'); const projects = list(evidence.projects).filter((item) => item && typeof item === 'object'); const steps = projects.flatMap((project) => list(project.steps)); const counts = { pass: 0, fail: 0, other: 0 }; for (const step of steps) { const tone = status(step).tone; counts[tone === 'pass' || tone === 'fail' ? tone : 'other'] += 1; } const title = `真实模型制作实测报告 · ${display(evidence.date)}`; const summary = `已记录 ${projects.length}/2 个模型项目、${steps.length} 项步骤:${counts.pass} 项通过,${counts.fail} 项失败,${counts.other} 项其他或未记录结论。`; const environment = [ ['测试日期', display(evidence.date)], ['页面地址', display(evidence.baseURL)], ['浏览器', display(evidence.browser)], ['账号角色', display(evidence.role)], ['账号显示名称', display(evidence.accountDisplayName)], ]; const html = [`

开发环境 · 真实模型制作

${escape(title)}

${escape(summary)}

`, '

环境与身份

', tableHtml(['项目', '记录'], environment.map((row) => row.map(escape))), '
']; const md = [`# ${title}`, summary, '统计仅来自明确记录的步骤结论;缺失字段、仅有数据或仅有截图均不视为通过。', '## 环境与身份', tableMarkdown(['项目', '记录'], environment)]; html.push('

统计仅来自明确记录的步骤结论;缺失字段、仅有数据或仅有截图均不视为通过。

'); const seenPictures = new Set(); let imageCount = 0; const imageProblems = []; function screenshots(value, fallback) { const pictures = []; function visit(item, caption) { if (!item || typeof item !== 'object') return; if (Array.isArray(item)) return item.forEach((child) => visit(child, caption)); const label = first(item.step, item.title, item.caption, item.name, caption); for (const field of ['screenshot', 'screenshots']) { for (const picture of list(item[field])) { const location = typeof picture === 'string' ? picture : first(picture?.path, picture?.screenshot, picture?.file); if (has(location)) pictures.push({ path: String(location).replaceAll('\\', '/'), title: first(picture?.caption, picture?.title, label, fallback) }); } } for (const [key, child] of Object.entries(item)) { if (!['screenshot', 'screenshots', 'modelObjects', 'objects', 'nodes', 'meshes', 'materials', 'textures', 'buffers', 'bufferViews', 'accessors'].includes(key) && typeof child === 'object') visit(child, label); } } visit(value, fallback); const htmlPictures = []; const mdPictures = []; for (const picture of pictures) { if (seenPictures.has(picture.path)) continue; seenPictures.add(picture.path); const safePath = /^screenshots\/[a-zA-Z0-9_\-./\u3400-\u9fff ]+\.(png|jpe?g|webp)$/i.test(picture.path) && !picture.path.split('/').some((part) => part === '.' || part === '..' || !part); if (!safePath || !existsSync(path.join(reportDir, picture.path))) { const problem = `${picture.title}:${safePath ? '截图文件未找到' : '截图路径不受支持'}(${picture.path})`; imageProblems.push(problem); htmlPictures.push(`

${escape(problem)}

`); mdPictures.push(markdown(problem)); continue; } imageCount += 1; const href = picture.path.split('/').map(encodeURIComponent).join('/'); htmlPictures.push(`
${escape(picture.title)}
${escape(picture.title)}
`); mdPictures.push(`![${markdown(picture.title)}](${href})`); } return { html: htmlPictures.length ? `` : '', md: mdPictures.join('\n\n') }; } for (const [index, project] of projects.entries()) { const name = display(project.name); const heading = `模型 ${index + 1}:${name}`; const nativeClips = project.imported?.live?.clips; const identity = [ ['项目名称', name], ['项目编码', display(project.code)], ['项目 ID', display(project.id)], ['来源素材', display(first(project.sourcePath, project.file))], ['测试节点', display(project.mesh)], ['动画', `${display(project.clipName)};编号 ${display(project.clip)};数量 ${display(project.clips)}`], ['原生动画片段', Array.isArray(nativeClips) ? `${nativeClips.length} 段${nativeClips.length ? `:${nativeClips.map((clip) => display(clip?.name)).join('、')}` : ''}` : missing], ]; html.push(`

${escape(heading)}

`, tableHtml(['项目', '记录'], identity.map((row) => row.map(escape)))); md.push(`## ${heading}`, tableMarkdown(['项目', '记录'], identity)); if (has(project.description)) { html.push(`

${escape(safeText(project.description))}

`); md.push(safeText(project.description)); } const projectSteps = list(project.steps); html.push('

操作步骤

'); md.push('### 操作步骤'); if (projectSteps.length) { const rows = projectSteps.map((step, stepIndex) => [String(stepIndex + 1), display(step?.step), status(step).label, details(typeof step === 'object' && step !== null ? Object.fromEntries(Object.entries(step).filter(([key]) => !['step', 'status', 'result', 'passed', 'success'].includes(key))) : undefined).map(([key, value]) => `${key}:${value}`).join(';') || missing]); html.push(tableHtml(['序号', '步骤', '结论', '证据摘要'], rows.map((row, stepIndex) => row.map((cell, cellIndex) => cellIndex === 2 ? badge(projectSteps[stepIndex]) : escape(cell))))); md.push(tableMarkdown(['序号', '步骤', '结论', '证据摘要'], rows)); } else { html.push(`

${missing}

`); md.push(missing); } const stages = [ ['导入校验', project.imported], ['编辑与保存', first(project.edited, project.saved)], ['保存后重开', project.reopened], ['动画检查', first(project.animationCheck, project.animation, project.animationChecks)], ['发布状态', project.published], ]; html.push('
'); for (const [stageTitle, value] of stages) { const result = record(value); const conclusion = has(value) && status(value).label === missing ? '已记录,未给出结论' : status(value).label; html.push(`

${escape(stageTitle)}

${escape(conclusion)}

${result.html}
`); md.push(`### ${stageTitle}`, `结论:${conclusion}`, result.md); } html.push('
'); const pictures = screenshots(project, name); if (pictures.html) { html.push('

现场截图

', pictures.html); md.push('### 现场截图', pictures.md); } html.push('
'); } if (projects.length < 2) { const warning = `当前仅记录 ${projects.length}/2 个项目,其余项目尚无证据。`; html.push(`

${warning}

`); md.push(warning); } const supplemental = list(evidence.evidence); if (supplemental.length) { const titles = { 'final-list': '最终项目列表', build: '构建检查', scope: '实测范围' }; html.push('

补充验证

'); md.push('## 补充验证'); for (const [index, item] of supplemental.entries()) { const heading = first(titles[item?.type], item?.title, item?.type, `补充记录 ${index + 1}`); const result = record(item); html.push(`

${escape(heading)}

${result.html}
`); md.push(`### ${heading}`, result.md); } html.push('
'); } const extraPictures = screenshots({ evidence: evidence.evidence, screenshots: evidence.screenshots, issues: evidence.issues }, '补充证据'); if (extraPictures.html) { html.push('

补充截图

', extraPictures.html, '
'); md.push('## 补充截图', extraPictures.md); } const retention = record(first(evidence.retainedData, evidence.retention, evidence.dataRetention, evidence.cleanup)); const retainedRows = projects.map((project) => [display(project.name), display(project.id), display(project.code), has(project.retained) ? (typeof project.retained === 'boolean' ? project.retained ? '保留' : '不保留' : String(project.retained)) : '未记录保留/清理结论']); html.push('

遗留数据与保留说明

', tableHtml(['项目', 'ID', '编码', '保留/清理结论'], retainedRows.map((row) => row.map(escape))), retention.html, '

本报告生成过程不修改项目数据。数据保留与清理结果以证据记录为准。

'); md.push('## 遗留数据与保留说明', tableMarkdown(['项目', 'ID', '编码', '保留/清理结论'], retainedRows), retention.md, '本报告生成过程不修改项目数据。数据保留与清理结果以证据记录为准。'); const issues = list(evidence.issues).map((issue) => record(issue)); projects.forEach((project) => list(project.issues).forEach((issue) => issues.push(record({ project: project.name, issue })))); imageProblems.forEach((problem) => issues.push(record(problem))); const noIssues = Array.isArray(evidence.issues) ? '问题数组为空;未执行或缺失的检查仍不视为通过。' : '问题清单未记录。'; html.push('

问题与待确认项

', issues.length ? issues.map((issue) => `
${issue.html}
`).join('') : `

${noIssues}

`, '
'); md.push('## 问题与待确认项', issues.length ? issues.map((issue, index) => `### ${index + 1}\n\n${issue.md}`).join('\n\n') : noIssues); const footer = `共 ${imageCount} 张可用截图。完整结构化记录见 evidence.json;模型对象明细未展开。`; html.push(`
`); md.push(footer); return { html: `${escape(title)}${html.join('\n')}`, markdown: `${md.join('\n\n')}\n`, summary: { projects: projects.length, steps: steps.length, ...counts, screenshots: imageCount, missingScreenshots: imageProblems.length }, }; } const STYLE = `*{box-sizing:border-box}body{margin:0;background:#eef3f2;color:#223c36;font:15px/1.7 "Microsoft YaHei","Segoe UI",sans-serif}header{padding:36px max(24px,calc((100vw - 1160px)/2));background:#153e34;color:#f5fffb}header h1{font-size:28px;line-height:1.45;margin:4px 0 12px}.eyebrow{font-size:13px;color:#b8dacf;margin:0}.links{display:flex;gap:24px}.links a{color:#d1f4e7}main{max-width:1200px;margin:24px auto;padding:0 20px 40px}section{background:white;border:1px solid #dce7e2;border-radius:12px;padding:28px;margin:20px 0}h2{font-size:22px;margin:0 0 18px}h3{font-size:17px;margin:18px 0 10px}p{margin:10px 0}a{color:#12674f}table{border-collapse:collapse;width:100%;font-size:14px}th,td{border:1px solid #dce7e2;text-align:left;vertical-align:top;padding:10px 12px;overflow-wrap:anywhere}th{background:#edf5f1}tbody tr:nth-child(even){background:#fafcfb}.table-wrap{overflow:auto}.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:13px;white-space:nowrap}.pass{color:#07513b;background:#dff5e8}.fail{color:#972328;background:#fee6e8}.unknown{color:#7c4b0d;background:#fff0d5}.info{color:#25566d;background:#e4f1f8}.notice{background:#fff5e1;border-left:4px solid #b5791a;padding:12px 16px;color:#6d4815}.muted{color:#576e65}.stages{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:20px 0}.stages article{border:1px solid #dce7e2;border-radius:8px;padding:0 16px 12px}.records{margin:8px 0;font-size:13px}.records>div{padding:6px 0;border-bottom:1px solid #edf2ef}.records dt{color:#5b7168}.records dd{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}.gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}figure{margin:0;border:1px solid #dce7e2;border-radius:8px;overflow:hidden;background:#f2f6f4}figure img{width:100%;height:auto;display:block}figcaption{padding:10px 14px;font-size:13px;overflow-wrap:anywhere}.issue{border-left:3px solid #b5791a;padding:0 14px;margin:16px 0}footer{font-size:13px;color:#576e65;margin:28px 0}@media(max-width:760px){header{padding:24px 20px}header h1{font-size:23px}main{padding:0 12px}section{padding:18px}.stages,.gallery{grid-template-columns:1fr}}@media print{body{background:white}header{background:white;color:#153e34;padding:0}header .eyebrow,header .links{display:none}main{max-width:none;padding:0}section{padding:18px 0;border:0;border-radius:0}.gallery{grid-template-columns:1fr}figure{break-inside:avoid}h2,h3{break-after:avoid}.stages{display:block}}`; export async function buildModelLiveReport(reportDir = DEFAULT_REPORT_DIR) { const evidence = JSON.parse((await readFile(path.join(reportDir, 'evidence.json'), 'utf8')).replace(/^\uFEFF/, '')); const result = renderModelLiveReport(evidence, { reportDir }); await writeFile(path.join(reportDir, 'index.html'), result.html, 'utf8'); await writeFile(path.join(reportDir, 'report.md'), result.markdown, 'utf8'); return result.summary; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { buildModelLiveReport().then((summary) => console.log(JSON.stringify(summary))).catch((error) => { console.error(`报告生成失败:${error.message}`); process.exitCode = 1; }); }