/** * build-report.mjs — HTML PPT 生成(lib 复制 + 数据注入) * * 读采集清单.json + 追问回答.json → 注入 report-template.html(skill-present 标准)→ * 输出独立报告目录(report.html + 全套 lib 复制 + report-data.json)。 * * 规则(borrow skill-present 报告策略):数据优先 / 证据链 / 来源标注 / 数字动画。 * 缺数据段优雅降级:三问未答 → 标注【待补充:用户口述】;交付物无 URL → 虚线占位卡。 * * 用法:node build-report.mjs --collect collect.json [--answers answers.json] \ * [--name 姓名] [--enterprise 企业] [--out 输出目录] */ import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; const SKILL_DIR = path.resolve(path.dirname(decodeURIComponent(new URL(import.meta.url).pathname)), '..'); function argVal(flag, dflt) { const i = process.argv.indexOf(flag); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt; } function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function fmtDate(iso) { try { return iso.slice(0, 10); } catch { return iso; } } /** lib 全套独立复制(每个文件独立复制确保可用——v2.4.1 base.css 缺失教训) */ function copyLib(outDir) { const srcLib = path.join(SKILL_DIR, 'lib'); const dstLib = path.join(outDir, 'lib'); const REQUIRED = [ 'theme/tokens.css', 'theme/base.css', 'theme/theme.js', 'player/deck.js', 'player/canvas-scale.js', 'components/chat-window.css', 'components/chat-window.js', 'components/group-chat.css', 'components/group-chat.js', 'components/logic-flow.css', 'components/logic-flow.js', 'vendor/gsap/gsap.min.js', ]; const copied = []; fs.mkdirSync(dstLib, { recursive: true }); for (const rel of REQUIRED) { const src = path.join(srcLib, rel); const dst = path.join(dstLib, rel); if (!fs.existsSync(src)) throw new Error(`lib 缺失(安装不完整):${rel} —— 重新克隆本仓库`); fs.mkdirSync(path.dirname(dst), { recursive: true }); fs.copyFileSync(src, dst); copied.push(rel); } return copied; } /* ── 各段渲染 ─────────────────────────────────────────────── */ function renderDistRows(bySource) { const entries = Object.entries(bySource).sort((a, b) => b[1] - a[1]); const max = Math.max(1, ...entries.map(e => e[1])); return entries.map(([name, n]) => { const w = Math.max(2, Math.round((n / max) * 560)); return `
${esc(name)}
${n}
`; }).join('\n '); } /** 交付物墙:优先在线 URL(可 iframe 嵌套),本地文件给占位卡 */ function renderWall(items) { const candidates = items .filter(i => i.type === '交付物' || /\.(html)$/i.test(i.path || '')) .slice(0, 6); if (candidates.length === 0) { return `
交付物墙占位
缺数据段 · 优雅降级
`; } return candidates.map(i => { const online = /^https?:\/\//.test(i.url || ''); if (online) { const iframeOk = i.url.includes('fmode.cn') || i.url.includes('obs.') || i.url.includes('s3.') || i.iframable === true; return `
${iframeOk ? `` : ``}
${esc(i.summary.slice(0, 40))}
${esc(i.source)} · ${fmtDate(i.time)}
${iframeOk ? '
✓ 在线项目 · 可 iframe 嵌套展示
' : '
仅链接 · 不强行嵌套
'}
`; } return `
${esc(i.summary.slice(0, 44))}
${esc(i.source)} · ${fmtDate(i.time)} · 本地路径已入附录
`; }).join('\n '); } function renderTimeline(items, n = 7) { const picks = items.slice(0, n); if (picks.length === 0) return `
采集窗口内无会话/文件记录
`; return picks.map(i => `
${esc(i.time.slice(5, 16).replace('T', ' '))} · ${esc(i.source)} · ${esc(i.type)}
${esc(i.summary.slice(0, 84))}
`).join('\n '); } function renderQBlock(answer, pending, links, pendingBadgeHtml) { const body = answer ? `
${esc(answer)}
` : `
(本轮未采集到口述内容)
`; const ev = (links || []).map(l => `
↳ 证据 [${esc(l.source)} ${fmtDate(l.time)}] ${esc(l.evidence)}
`).join('\n '); return body + pendingBadgeHtml + (ev ? `\n
\n ${ev}\n
` : ''); } function renderAppendix(items, n = 14) { const rows = items.slice(0, n).map(i => ` ${esc(i.time.slice(5, 16).replace('T', ' '))} ${esc(i.source)} ${esc(i.summary.slice(0, 80))} ${esc(i.url || i.path || '—')} `).join('\n '); return rows || `无采集记录`; } /* ── 主流程 ─────────────────────────────────────────────── */ export function buildReport({ collect, answers, name = '学员', enterprise = '企业 AI 落地研修', outDir = null }) { const tplPath = path.join(SKILL_DIR, 'templates', 'report-template.html'); if (!fs.existsSync(tplPath)) throw new Error('模板缺失:templates/report-template.html'); const tpl = fs.readFileSync(tplPath, 'utf-8'); const stats = collect.stats || {}; const dateRange = `${fmtDate(collect.meta.windowStart)} ~ ${fmtDate(collect.meta.generatedAt)}`; const toolList = Object.keys(stats.bySource || {}).join(' / ') || '—'; const messages = collect.items.reduce((s, i) => { const m = i.summary.match(/用户消息 (\d+) 条 \/ 助手 (\d+) 条/); return s + (m ? Number(m[1]) + Number(m[2]) : 0); }, 0); const ans = (answers && answers.answers) || {}; const links = (answers && answers.evidenceLinks) || {}; /* 无 answers 文件 = 全部三问视为待补充;有文件时按 pending 标记 */ const noAnswersAtAll = !answers; const pendingBadge = k => (noAnswersAtAll || ans[k + '_pending']) ? `
【待补充:用户口述】
` : ''; const qHtml = key => renderQBlock(ans[key] || '', ans[key + '_pending'], links[key], pendingBadge(key)); const html = tpl .replace(/\{\{REPORT_TITLE\}\}/g, esc(`${name} · 学习复盘报告`)) .replace(/\{\{NAME\}\}/g, esc(name)) .replace(/\{\{ENTERPRISE\}\}/g, esc(enterprise)) .replace(/\{\{DATE_RANGE\}\}/g, esc(dateRange)) .replace(/\{\{SESSIONS\}\}/g, String(stats.sessionCount ?? 0)) .replace(/\{\{TOOLKINDS\}\}/g, String(Object.keys(stats.bySource || {}).length)) .replace(/\{\{ARTIFACTS\}\}/g, String(stats.artifactCount ?? 0)) .replace(/\{\{MESSAGES\}\}/g, String(messages)) .replace(/\{\{HOURS\}\}/g, String(collect.meta.windowHours ?? 48)) .replace(/\{\{WINDOW\}\}/g, esc(dateRange)) .replace(/\{\{TOOL_LIST\}\}/g, esc(toolList)) .replace(/\{\{TOOL_DIST_ROWS\}\}/g, renderDistRows(stats.bySource || {})) .replace(/\{\{ARTIFACT_WALL\}\}/g, renderWall(collect.items || [])) .replace(/\{\{TIMELINE\}\}/g, renderTimeline(collect.items || [])) .replace(/\{\{ANSWER_PAST\}\}/g, '') .replace(/\{\{ANSWER_PRESENT\}\}/g, '') .replace(/\{\{ANSWER_FUTURE\}\}/g, '') .replace(/\{\{EVIDENCE_PAST\}\}/g, '') .replace(/\{\{EVIDENCE_PRESENT\}\}/g, '') .replace(/\{\{EVIDENCE_FUTURE\}\}/g, '') .replace(/\{\{PAST_PENDING\}\}/g, '') .replace(/\{\{PRESENT_PENDING\}\}/g, '') .replace(/\{\{FUTURE_PENDING\}\}/g, '') .replace(/\{\{APPENDIX_ROWS\}\}/g, renderAppendix(collect.items || [])) .replace(/\{\{TOTAL\}\}/g, String(stats.total ?? 0)) .replace(/\{\{APPENDIX_N\}\}/g, String(Math.min(14, stats.total ?? 0))); /* 三问块在模板中带占位符嵌套,这里二次注入(避免 regex 嵌套转义地狱) */ const inject = (screenId, inner, source) => { const re = new RegExp(`(
[\\s\\S]*?
[\\s\\S]*?
\\n)([\\s\\S]*?)(\\n \\n )`); return (source || html).replace(re, (m, head, _body, tail) => head + inner + tail); }; let finalHtml = html; finalHtml = inject.call(null, 'p5', qHtml('past').trim(), finalHtml); finalHtml = inject.call(null, 'p6', qHtml('present').trim(), finalHtml); finalHtml = inject.call(null, 'p7', qHtml('future').trim(), finalHtml); /* 输出目录:report.html + lib 全套 + report-data.json */ const dir = outDir || path.join(process.cwd(), 'study-report-out'); fs.mkdirSync(dir, { recursive: true }); const copied = copyLib(dir); fs.writeFileSync(path.join(dir, 'report.html'), finalHtml); fs.writeFileSync(path.join(dir, 'report-data.json'), JSON.stringify({ generatedAt: new Date().toISOString(), name, enterprise, dateRange, collect: { meta: collect.meta, stats: collect.stats, toolsStatus: collect.toolsStatus, items: collect.items, artifacts: collect.artifacts }, answers: answers || null, libCopied: copied, }, null, 2)); return { dir, reportPath: path.join(dir, 'report.html'), dataPath: path.join(dir, 'report-data.json'), pages: 9, stats, }; } /* ── CLI ─────────────────────────────────────────────── */ const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(decodeURIComponent(new URL(import.meta.url).pathname)); if (isMain) { const collectFile = argVal('--collect', null); const answersFile = argVal('--answers', null); if (!collectFile || !fs.existsSync(collectFile)) { console.error('[build-report] 缺 --collect <采集清单.json>(先跑 collect.mjs --out)'); process.exit(2); } const collect = JSON.parse(fs.readFileSync(collectFile, 'utf-8')); let answers = null; if (answersFile && fs.existsSync(answersFile)) { answers = JSON.parse(fs.readFileSync(answersFile, 'utf-8')); } else { console.log('[build-report] 未提供 --answers:三问标注【待补充:用户口述】,用采集数据先行出报告。'); } const r = buildReport({ collect, answers, name: argVal('--name', '学员'), enterprise: argVal('--enterprise', '企业 AI 落地研修'), outDir: argVal('--out', null), }); console.log(`\n✅ 报告已生成:${r.reportPath}`); console.log(` 页数:${r.pages} · 会话 ${r.stats.sessionCount ?? 0} · 产出物 ${r.stats.artifactCount ?? 0}`); console.log(` lib 已复制 ${path.basename(r.dir)}/lib/(base.css 教训:逐文件校验复制)`); console.log(` 本地预览:file://${pathToFileURL(r.reportPath).href.replace('file://', '')}`); }