${escape(stageTitle)}
${escape(conclusion)}
${result.html}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 `
| ${escape(cell)} | `).join('')}
|---|
| ${cell} | `).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(summary)}
统计仅来自明确记录的步骤结论;缺失字段、仅有数据或仅有截图均不视为通过。
'); 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(safeText(project.description))}
`); md.push(safeText(project.description)); } const projectSteps = list(project.steps); 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('${escape(conclusion)}
${result.html}${warning}
`); md.push(warning); } const supplemental = list(evidence.evidence); if (supplemental.length) { const titles = { 'final-list': '最终项目列表', build: '构建检查', scope: '实测范围' }; html.push('本报告生成过程不修改项目数据。数据保留与清理结果以证据记录为准。
${noIssues}
`, '