25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 

266 satır
20 KiB

  1. import { existsSync } from 'node:fs';
  2. import { readFile, writeFile } from 'node:fs/promises';
  3. import path from 'node:path';
  4. import { fileURLToPath } from 'node:url';
  5. const DEFAULT_REPORT_DIR = fileURLToPath(new URL('../reports/model-live-20260905/', import.meta.url));
  6. const missing = '未记录';
  7. const has = (value) => value !== undefined && value !== null && value !== '';
  8. const list = (value) => Array.isArray(value) ? value : has(value) ? [value] : [];
  9. const escape = (value) => String(value).replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
  10. const markdown = (value) => String(value).replace(/([\\`*_[\]<>|])/g, '\\$1').replace(/\r?\n/g, ' / ');
  11. const display = (value) => has(value) ? String(value) : missing;
  12. const first = (...values) => values.find(has);
  13. const labels = {
  14. status: '状态', result: '结果', passed: '检查通过', success: '操作成功',
  15. server: '服务端', live: '页面运行时', sha256: 'SHA-256', bytes: '文件字节数',
  16. mutations: '变更记录', name: '名称', code: '编码', id: 'ID', httpStatus: 'HTTP 状态',
  17. message: '说明', details: '详情', note: '备注', reason: '原因', issue: '问题',
  18. saved: '已保存', persisted: '已持久化', reopened: '重开', published: '发布',
  19. publishedAt: '发布时间', version: '版本', title: '标题', file: '来源文件',
  20. sourcePath: '素材路径', assetUrl: '资源地址', modelUrl: '模型地址', url: '地址',
  21. objectCount: '对象数', meshCount: '网格数', animationCount: '动画数',
  22. animations: '动画', clip: '动画编号', clipName: '动画名称', clips: '动画数',
  23. playing: '正在播放', time: '时间', before: '变更前', after: '变更后',
  24. position: '位置', rotation: '旋转', scale: '缩放', visible: '可见',
  25. retained: '保留状态', cleanup: '清理记录', retainedData: '保留数据',
  26. nodes: '节点数', meshes: '网格数', materials: '材质数', textures: '纹理数',
  27. type: '类别', command: '执行命令', rows: '列表核验结果',
  28. };
  29. function status(value) {
  30. if (value && typeof value === 'object') value = first(value.status, value.result, value.passed, value.success);
  31. if (!has(value)) return { label: missing, tone: 'unknown' };
  32. if (value === true) return { label: '通过', tone: 'pass' };
  33. if (value === false) return { label: '失败', tone: 'fail' };
  34. const raw = String(value);
  35. const normalized = raw.toUpperCase();
  36. if (['PASS', 'PASSED', 'SUCCESS', 'OK', '通过', '成功'].includes(normalized)) return { label: '通过', tone: 'pass' };
  37. if (['FAIL', 'FAILED', 'ERROR', '失败', '错误'].includes(normalized)) return { label: '失败', tone: 'fail' };
  38. if (['SKIP', 'SKIPPED', '跳过'].includes(normalized)) return { label: '跳过', tone: 'unknown' };
  39. if (['PENDING', 'TODO', 'NOT_RUN', '未执行', '待验证'].includes(normalized)) return { label: '待验证', tone: 'unknown' };
  40. if (normalized === 'PUBLISHED') return { label: '已发布', tone: 'info' };
  41. if (normalized === 'DRAFT') return { label: '草稿', tone: 'info' };
  42. return { label: raw, tone: 'info' };
  43. }
  44. function safeText(value) {
  45. return String(value)
  46. .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [已隐藏]')
  47. .replace(/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, '[令牌已隐藏]')
  48. .slice(0, 700);
  49. }
  50. // Keep useful scalar evidence, with bounded nesting; never dump scene documents.
  51. function details(value, prefix = '', depth = 0) {
  52. if (!has(value)) return [];
  53. if (typeof value !== 'object') return [[prefix || '记录', safeText(value)]];
  54. if (Array.isArray(value)) {
  55. if (!value.length) return [[prefix || '记录', '0 项']];
  56. if (value.every((item) => item === null || typeof item !== 'object')) {
  57. return [[prefix || '记录', safeText(value.slice(0, 8).join('、')) + (value.length > 8 ? `(共 ${value.length} 项)` : '')]];
  58. }
  59. if (depth >= 3) return [[prefix || '记录', `${value.length} 项,完整内容见 evidence.json`]];
  60. return value.slice(0, 3).flatMap((item, index) => details(item, `${prefix || '记录'} ${index + 1}`, depth + 1))
  61. .concat(value.length > 3 ? [[prefix || '记录', `共 ${value.length} 项,完整内容见 evidence.json`]] : []);
  62. }
  63. const rows = [];
  64. for (const [key, item] of Object.entries(value)) {
  65. if (['screenshot', 'screenshots'].includes(key) || !has(item)) continue;
  66. const label = [prefix, labels[key] || key].filter(Boolean).join(' / ');
  67. if (/password|passwd|secret|token|authorization|cookie|credential/i.test(key)) {
  68. rows.push([label, '[已隐藏]']);
  69. } else if (/^(modelObjects|objects|nodes|meshes|materials|textures|buffers|bufferViews|accessors)$/i.test(key)) {
  70. rows.push([label, typeof item === 'object'
  71. ? `${Array.isArray(item) ? item.length : Object.keys(item).length} 项(省略明细)`
  72. : safeText(item)]);
  73. } else if (depth >= 3 && typeof item === 'object') {
  74. rows.push([label, '已记录,完整内容见 evidence.json']);
  75. } else {
  76. rows.push(...details(item, label, depth + 1));
  77. }
  78. if (rows.length >= 24) {
  79. rows.push(['更多记录', '见 evidence.json']);
  80. break;
  81. }
  82. }
  83. return rows;
  84. }
  85. function tableHtml(headers, rows) {
  86. 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>`;
  87. }
  88. function tableMarkdown(headers, rows) {
  89. return [`| ${headers.map(markdown).join(' | ')} |`, `| ${headers.map(() => '---').join(' | ')} |`, ...rows.map((row) => `| ${row.map(markdown).join(' | ')} |`)].join('\n');
  90. }
  91. function badge(value) {
  92. const item = status(value);
  93. return `<span class="badge ${item.tone}">${escape(item.label)}</span>`;
  94. }
  95. function record(value) {
  96. const rows = details(value);
  97. return {
  98. 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>`,
  99. md: rows.length ? rows.map(([key, item]) => `- ${markdown(key)}:${markdown(item)}`).join('\n') : missing,
  100. };
  101. }
  102. export function renderModelLiveReport(evidence, { reportDir = DEFAULT_REPORT_DIR } = {}) {
  103. if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) throw new Error('evidence.json 必须为对象');
  104. const projects = list(evidence.projects).filter((item) => item && typeof item === 'object');
  105. const steps = projects.flatMap((project) => list(project.steps));
  106. const counts = { pass: 0, fail: 0, other: 0 };
  107. for (const step of steps) {
  108. const tone = status(step).tone;
  109. counts[tone === 'pass' || tone === 'fail' ? tone : 'other'] += 1;
  110. }
  111. const title = `真实模型制作实测报告 · ${display(evidence.date)}`;
  112. const summary = `已记录 ${projects.length}/2 个模型项目、${steps.length} 项步骤:${counts.pass} 项通过,${counts.fail} 项失败,${counts.other} 项其他或未记录结论。`;
  113. const environment = [
  114. ['测试日期', display(evidence.date)], ['页面地址', display(evidence.baseURL)],
  115. ['浏览器', display(evidence.browser)], ['账号角色', display(evidence.role)],
  116. ['账号显示名称', display(evidence.accountDisplayName)],
  117. ];
  118. 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>'];
  119. const md = [`# ${title}`, summary, '统计仅来自明确记录的步骤结论;缺失字段、仅有数据或仅有截图均不视为通过。', '## 环境与身份', tableMarkdown(['项目', '记录'], environment)];
  120. html.push('<p class="notice">统计仅来自明确记录的步骤结论;缺失字段、仅有数据或仅有截图均不视为通过。</p>');
  121. const seenPictures = new Set();
  122. let imageCount = 0;
  123. const imageProblems = [];
  124. function screenshots(value, fallback) {
  125. const pictures = [];
  126. function visit(item, caption) {
  127. if (!item || typeof item !== 'object') return;
  128. if (Array.isArray(item)) return item.forEach((child) => visit(child, caption));
  129. const label = first(item.step, item.title, item.caption, item.name, caption);
  130. for (const field of ['screenshot', 'screenshots']) {
  131. for (const picture of list(item[field])) {
  132. const location = typeof picture === 'string' ? picture : first(picture?.path, picture?.screenshot, picture?.file);
  133. if (has(location)) pictures.push({ path: String(location).replaceAll('\\', '/'), title: first(picture?.caption, picture?.title, label, fallback) });
  134. }
  135. }
  136. for (const [key, child] of Object.entries(item)) {
  137. if (!['screenshot', 'screenshots', 'modelObjects', 'objects', 'nodes', 'meshes', 'materials', 'textures', 'buffers', 'bufferViews', 'accessors'].includes(key) && typeof child === 'object') visit(child, label);
  138. }
  139. }
  140. visit(value, fallback);
  141. const htmlPictures = [];
  142. const mdPictures = [];
  143. for (const picture of pictures) {
  144. if (seenPictures.has(picture.path)) continue;
  145. seenPictures.add(picture.path);
  146. 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);
  147. if (!safePath || !existsSync(path.join(reportDir, picture.path))) {
  148. const problem = `${picture.title}:${safePath ? '截图文件未找到' : '截图路径不受支持'}(${picture.path})`;
  149. imageProblems.push(problem);
  150. htmlPictures.push(`<p class="notice">${escape(problem)}</p>`);
  151. mdPictures.push(markdown(problem));
  152. continue;
  153. }
  154. imageCount += 1;
  155. const href = picture.path.split('/').map(encodeURIComponent).join('/');
  156. htmlPictures.push(`<figure><a href="${href}"><img loading="lazy" src="${href}" alt="${escape(picture.title)}"></a><figcaption>${escape(picture.title)}</figcaption></figure>`);
  157. mdPictures.push(`![${markdown(picture.title)}](${href})`);
  158. }
  159. return { html: htmlPictures.length ? `<div class="gallery">${htmlPictures.join('')}</div>` : '', md: mdPictures.join('\n\n') };
  160. }
  161. for (const [index, project] of projects.entries()) {
  162. const name = display(project.name);
  163. const heading = `模型 ${index + 1}:${name}`;
  164. const nativeClips = project.imported?.live?.clips;
  165. const identity = [
  166. ['项目名称', name], ['项目编码', display(project.code)], ['项目 ID', display(project.id)],
  167. ['来源素材', display(first(project.sourcePath, project.file))],
  168. ['测试节点', display(project.mesh)],
  169. ['动画', `${display(project.clipName)};编号 ${display(project.clip)};数量 ${display(project.clips)}`],
  170. ['原生动画片段', Array.isArray(nativeClips)
  171. ? `${nativeClips.length} 段${nativeClips.length ? `:${nativeClips.map((clip) => display(clip?.name)).join('、')}` : ''}`
  172. : missing],
  173. ];
  174. html.push(`<section id="model-${index + 1}"><h2>${escape(heading)}</h2>`, tableHtml(['项目', '记录'], identity.map((row) => row.map(escape))));
  175. md.push(`## ${heading}`, tableMarkdown(['项目', '记录'], identity));
  176. if (has(project.description)) { html.push(`<p>${escape(safeText(project.description))}</p>`); md.push(safeText(project.description)); }
  177. const projectSteps = list(project.steps);
  178. html.push('<h3>操作步骤</h3>'); md.push('### 操作步骤');
  179. if (projectSteps.length) {
  180. const rows = projectSteps.map((step, stepIndex) => [String(stepIndex + 1), display(step?.step), status(step).label,
  181. 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]);
  182. html.push(tableHtml(['序号', '步骤', '结论', '证据摘要'], rows.map((row, stepIndex) => row.map((cell, cellIndex) => cellIndex === 2 ? badge(projectSteps[stepIndex]) : escape(cell)))));
  183. md.push(tableMarkdown(['序号', '步骤', '结论', '证据摘要'], rows));
  184. } else { html.push(`<p class="muted">${missing}</p>`); md.push(missing); }
  185. const stages = [
  186. ['导入校验', project.imported], ['编辑与保存', first(project.edited, project.saved)],
  187. ['保存后重开', project.reopened], ['动画检查', first(project.animationCheck, project.animation, project.animationChecks)],
  188. ['发布状态', project.published],
  189. ];
  190. html.push('<div class="stages">');
  191. for (const [stageTitle, value] of stages) {
  192. const result = record(value);
  193. const conclusion = has(value) && status(value).label === missing ? '已记录,未给出结论' : status(value).label;
  194. html.push(`<article><h3>${escape(stageTitle)}</h3><p class="muted">${escape(conclusion)}</p>${result.html}</article>`);
  195. md.push(`### ${stageTitle}`, `结论:${conclusion}`, result.md);
  196. }
  197. html.push('</div>');
  198. const pictures = screenshots(project, name);
  199. if (pictures.html) { html.push('<h3>现场截图</h3>', pictures.html); md.push('### 现场截图', pictures.md); }
  200. html.push('</section>');
  201. }
  202. if (projects.length < 2) {
  203. const warning = `当前仅记录 ${projects.length}/2 个项目,其余项目尚无证据。`;
  204. html.push(`<p class="notice">${warning}</p>`); md.push(warning);
  205. }
  206. const supplemental = list(evidence.evidence);
  207. if (supplemental.length) {
  208. const titles = { 'final-list': '最终项目列表', build: '构建检查', scope: '实测范围' };
  209. html.push('<section><h2>补充验证</h2>'); md.push('## 补充验证');
  210. for (const [index, item] of supplemental.entries()) {
  211. const heading = first(titles[item?.type], item?.title, item?.type, `补充记录 ${index + 1}`);
  212. const result = record(item);
  213. html.push(`<article><h3>${escape(heading)}</h3>${result.html}</article>`);
  214. md.push(`### ${heading}`, result.md);
  215. }
  216. html.push('</section>');
  217. }
  218. const extraPictures = screenshots({ evidence: evidence.evidence, screenshots: evidence.screenshots, issues: evidence.issues }, '补充证据');
  219. if (extraPictures.html) { html.push('<section><h2>补充截图</h2>', extraPictures.html, '</section>'); md.push('## 补充截图', extraPictures.md); }
  220. const retention = record(first(evidence.retainedData, evidence.retention, evidence.dataRetention, evidence.cleanup));
  221. 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)) : '未记录保留/清理结论']);
  222. html.push('<section><h2>遗留数据与保留说明</h2>', tableHtml(['项目', 'ID', '编码', '保留/清理结论'], retainedRows.map((row) => row.map(escape))), retention.html, '<p class="muted">本报告生成过程不修改项目数据。数据保留与清理结果以证据记录为准。</p></section>');
  223. md.push('## 遗留数据与保留说明', tableMarkdown(['项目', 'ID', '编码', '保留/清理结论'], retainedRows), retention.md, '本报告生成过程不修改项目数据。数据保留与清理结果以证据记录为准。');
  224. const issues = list(evidence.issues).map((issue) => record(issue));
  225. projects.forEach((project) => list(project.issues).forEach((issue) => issues.push(record({ project: project.name, issue }))));
  226. imageProblems.forEach((problem) => issues.push(record(problem)));
  227. const noIssues = Array.isArray(evidence.issues) ? '问题数组为空;未执行或缺失的检查仍不视为通过。' : '问题清单未记录。';
  228. html.push('<section><h2>问题与待确认项</h2>', issues.length ? issues.map((issue) => `<article class="issue">${issue.html}</article>`).join('') : `<p>${noIssues}</p>`, '</section>');
  229. md.push('## 问题与待确认项', issues.length ? issues.map((issue, index) => `### ${index + 1}\n\n${issue.md}`).join('\n\n') : noIssues);
  230. const footer = `共 ${imageCount} 张可用截图。完整结构化记录见 evidence.json;模型对象明细未展开。`;
  231. html.push(`<footer>${escape(footer)}</footer></main>`); md.push(footer);
  232. return {
  233. 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>`,
  234. markdown: `${md.join('\n\n')}\n`,
  235. summary: { projects: projects.length, steps: steps.length, ...counts, screenshots: imageCount, missingScreenshots: imageProblems.length },
  236. };
  237. }
  238. 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}}`;
  239. export async function buildModelLiveReport(reportDir = DEFAULT_REPORT_DIR) {
  240. const evidence = JSON.parse((await readFile(path.join(reportDir, 'evidence.json'), 'utf8')).replace(/^\uFEFF/, ''));
  241. const result = renderModelLiveReport(evidence, { reportDir });
  242. await writeFile(path.join(reportDir, 'index.html'), result.html, 'utf8');
  243. await writeFile(path.join(reportDir, 'report.md'), result.markdown, 'utf8');
  244. return result.summary;
  245. }
  246. if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  247. buildModelLiveReport().then((summary) => console.log(JSON.stringify(summary))).catch((error) => {
  248. console.error(`报告生成失败:${error.message}`);
  249. process.exitCode = 1;
  250. });
  251. }