|
- 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 `<div class="table-wrap"><table><thead><tr>${headers.map((cell) => `<th>${escape(cell)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
- }
-
- 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 `<span class="badge ${item.tone}">${escape(item.label)}</span>`;
- }
-
- function record(value) {
- const rows = details(value);
- return {
- html: rows.length ? `<dl class="records">${rows.map(([key, item]) => `<div><dt>${escape(key)}</dt><dd>${escape(item)}</dd></div>`).join('')}</dl>` : `<p class="muted">${missing}</p>`,
- 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 = [`<header><p class="eyebrow">开发环境 · 真实模型制作</p><h1>${escape(title)}</h1><p>${escape(summary)}</p><p class="links"><a href="report.md">Markdown 报告</a><a href="evidence.json">原始证据</a></p></header><main>`, '<section><h2>环境与身份</h2>', tableHtml(['项目', '记录'], environment.map((row) => row.map(escape))), '</section>'];
- const md = [`# ${title}`, summary, '统计仅来自明确记录的步骤结论;缺失字段、仅有数据或仅有截图均不视为通过。', '## 环境与身份', tableMarkdown(['项目', '记录'], environment)];
- html.push('<p class="notice">统计仅来自明确记录的步骤结论;缺失字段、仅有数据或仅有截图均不视为通过。</p>');
- 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(`<p class="notice">${escape(problem)}</p>`);
- mdPictures.push(markdown(problem));
- continue;
- }
- imageCount += 1;
- const href = picture.path.split('/').map(encodeURIComponent).join('/');
- htmlPictures.push(`<figure><a href="${href}"><img loading="lazy" src="${href}" alt="${escape(picture.title)}"></a><figcaption>${escape(picture.title)}</figcaption></figure>`);
- mdPictures.push(``);
- }
- return { html: htmlPictures.length ? `<div class="gallery">${htmlPictures.join('')}</div>` : '', 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(`<section id="model-${index + 1}"><h2>${escape(heading)}</h2>`, tableHtml(['项目', '记录'], identity.map((row) => row.map(escape))));
- md.push(`## ${heading}`, tableMarkdown(['项目', '记录'], identity));
- if (has(project.description)) { html.push(`<p>${escape(safeText(project.description))}</p>`); md.push(safeText(project.description)); }
- const projectSteps = list(project.steps);
- html.push('<h3>操作步骤</h3>'); 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(`<p class="muted">${missing}</p>`); 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('<div class="stages">');
- for (const [stageTitle, value] of stages) {
- const result = record(value);
- const conclusion = has(value) && status(value).label === missing ? '已记录,未给出结论' : status(value).label;
- html.push(`<article><h3>${escape(stageTitle)}</h3><p class="muted">${escape(conclusion)}</p>${result.html}</article>`);
- md.push(`### ${stageTitle}`, `结论:${conclusion}`, result.md);
- }
- html.push('</div>');
- const pictures = screenshots(project, name);
- if (pictures.html) { html.push('<h3>现场截图</h3>', pictures.html); md.push('### 现场截图', pictures.md); }
- html.push('</section>');
- }
- if (projects.length < 2) {
- const warning = `当前仅记录 ${projects.length}/2 个项目,其余项目尚无证据。`;
- html.push(`<p class="notice">${warning}</p>`); md.push(warning);
- }
- const supplemental = list(evidence.evidence);
- if (supplemental.length) {
- const titles = { 'final-list': '最终项目列表', build: '构建检查', scope: '实测范围' };
- html.push('<section><h2>补充验证</h2>'); 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(`<article><h3>${escape(heading)}</h3>${result.html}</article>`);
- md.push(`### ${heading}`, result.md);
- }
- html.push('</section>');
- }
- const extraPictures = screenshots({ evidence: evidence.evidence, screenshots: evidence.screenshots, issues: evidence.issues }, '补充证据');
- if (extraPictures.html) { html.push('<section><h2>补充截图</h2>', extraPictures.html, '</section>'); 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('<section><h2>遗留数据与保留说明</h2>', tableHtml(['项目', 'ID', '编码', '保留/清理结论'], retainedRows.map((row) => row.map(escape))), retention.html, '<p class="muted">本报告生成过程不修改项目数据。数据保留与清理结果以证据记录为准。</p></section>');
- 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('<section><h2>问题与待确认项</h2>', issues.length ? issues.map((issue) => `<article class="issue">${issue.html}</article>`).join('') : `<p>${noIssues}</p>`, '</section>');
- 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(`<footer>${escape(footer)}</footer></main>`); md.push(footer);
- return {
- html: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body>${html.join('\n')}</body></html>`,
- 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;
- });
- }
|