render.template.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. // ==============================================================================
  2. // <品类名> · VOC 报告主渲染器(模板)
  3. // ==============================================================================
  4. // 职责:
  5. // - 定义 CHAPTERS 元信息(章 × 子章 × tone)
  6. // - 装配 Cover + Agenda + Divider + Sections + Coming Soon
  7. // - 生成完整 HTML(含 CSS + 导航 JS)
  8. // ==============================================================================
  9. const LA = require('./<品类>-analyze.js');
  10. const C = require('./<品类>-components.js');
  11. const { BRAND, esc, fmtLikes } = C;
  12. // ==========================================================
  13. // 1. 章节元信息
  14. // TODO: 按 2.VOC深度思路.md 的章节骨架填写
  15. // ==========================================================
  16. const CHAPTERS = [
  17. {
  18. num: 1, key: 'challenge', title: '诘问起点', tone: 'urgent',
  19. subs: ['1-1', '1-2', '1-3'],
  20. heroLine: '<章 1 一句话总结>',
  21. },
  22. {
  23. num: 2, key: 'ch2', title: '<赛道 1> VOC', tone: 'critical',
  24. subs: ['2-1', '2-2', '2-3', '2-4'],
  25. heroLine: '<章 2 一句话总结>',
  26. },
  27. {
  28. num: 3, key: 'ch3', title: '<赛道 2> VOC', tone: 'analytical',
  29. subs: ['3-1', '3-2', '3-3', '3-4'],
  30. heroLine: '<章 3 一句话总结>',
  31. },
  32. {
  33. num: 4, key: 'kano', title: 'KANO × JTBD', tone: 'insight',
  34. subs: ['4-1', '4-2', '4-3', '4-4', '4-5'],
  35. heroLine: '<章 4 一句话总结>',
  36. },
  37. {
  38. num: 5, key: 'scene', title: '场景四元素地图', tone: 'empathy',
  39. subs: ['5-1', '5-2', '5-3', '5-4'],
  40. heroLine: '<章 5 一句话总结>',
  41. },
  42. {
  43. num: 6, key: 'competitor', title: '三轴 × 无人地带', tone: 'strategic',
  44. subs: ['6-1', '6-2', '6-3'],
  45. heroLine: '<章 6 一句话总结>',
  46. },
  47. {
  48. num: 7, key: 'opportunity', title: '新机会 × 新人群', tone: 'forward',
  49. subs: ['7-1', '7-2', '7-3'],
  50. heroLine: '<章 7 一句话总结>',
  51. },
  52. {
  53. num: 8, key: 'blueprint', title: '4P 配称蓝图', tone: 'blueprint',
  54. subs: ['8-1', '8-2'],
  55. heroLine: '<章 8 一句话总结>',
  56. },
  57. ];
  58. // ==========================================================
  59. // 2. Slide 渲染器集合(从各章节模块导入)
  60. // ==========================================================
  61. const SLIDE_RENDERERS = {
  62. ...require('./<品类>-ch1-challenge.js'),
  63. ...require('./<品类>-ch2-xxx.js'),
  64. ...require('./<品类>-ch3-xxx.js'),
  65. ...require('./<品类>-ch4-kano.js'),
  66. ...require('./<品类>-ch5-scene.js'),
  67. ...require('./<品类>-ch6-competitor.js'),
  68. ...require('./<品类>-ch7-opportunity.js'),
  69. ...require('./<品类>-ch8-blueprint.js'),
  70. };
  71. // ==========================================================
  72. // 3. 封面
  73. // ==========================================================
  74. function renderCover(data) {
  75. const m = data.meta || {};
  76. const total = m.count || (data.items?.length || 0);
  77. const platforms = Object.entries(m.platforms || {})
  78. .map(([k, v]) => `${k}: ${v}`)
  79. .join(' · ');
  80. return `
  81. <section class="report-section cover" data-tone="hero">
  82. <div class="section-inner">
  83. <div class="text-mono text-xs" style="color:${BRAND.MAIN}; letter-spacing:0.25em; font-weight:700;">VOC DEEP INSIGHT · 反向定位</div>
  84. <h1 style="font-family:var(--font-head); font-weight:900; font-size:3.6rem; line-height:1.15; color:var(--text-1); margin:20px 0 16px 0;">
  85. <span style="color:${BRAND.MAIN};"><品类名></span><br/>
  86. VOC 深度洞察报告
  87. </h1>
  88. <div style="color:var(--text-2); font-size:1.1rem; line-height:1.75; max-width:800px; margin-bottom:32px;">
  89. 基于<strong style="color:${BRAND.MAIN};">${total} 条真实 VOC</strong> × <strong>多平台</strong> × <strong>H1-H8 八大假设</strong>,从用户真实原声反向推演<strong><金额目标></strong>增长路径。
  90. </div>
  91. <div style="display:grid; grid-template-columns:repeat(4,1fr); gap:14px; max-width:900px;">
  92. <div style="padding:16px; background:${BRAND.MAIN}10; border:1px solid ${BRAND.MAIN}33; border-radius:8px;">
  93. <div style="color:${BRAND.MAIN}; font-family:var(--font-head); font-weight:900; font-size:1.8rem;">${total}</div>
  94. <div style="color:var(--text-2); font-size:0.82rem; margin-top:4px;">真实 VOC 条数</div>
  95. </div>
  96. <div style="padding:16px; background:${BRAND.JIANG_GREEN}10; border:1px solid ${BRAND.JIANG_GREEN}33; border-radius:8px;">
  97. <div style="color:${BRAND.JIANG_GREEN}; font-family:var(--font-head); font-weight:900; font-size:1.8rem;">${Object.keys(m.platforms || {}).length}</div>
  98. <div style="color:var(--text-2); font-size:0.82rem; margin-top:4px;">覆盖平台</div>
  99. </div>
  100. <div style="padding:16px; background:${BRAND.CORAL}10; border:1px solid ${BRAND.CORAL}33; border-radius:8px;">
  101. <div style="color:${BRAND.CORAL}; font-family:var(--font-head); font-weight:900; font-size:1.8rem;">${(m.products || []).length}</div>
  102. <div style="color:var(--text-2); font-size:0.82rem; margin-top:4px;">产品维度</div>
  103. </div>
  104. <div style="padding:16px; background:${BRAND.PURPLE}10; border:1px solid ${BRAND.PURPLE}33; border-radius:8px;">
  105. <div style="color:${BRAND.PURPLE}; font-family:var(--font-head); font-weight:900; font-size:1.8rem;">${CHAPTERS.length}</div>
  106. <div style="color:var(--text-2); font-size:0.82rem; margin-top:4px;">深度章节</div>
  107. </div>
  108. </div>
  109. <div style="margin-top:40px; color:var(--text-3); font-family:var(--font-mono); font-size:0.78rem;">
  110. ${platforms}<br/>
  111. 采集时间: ${new Date(m.collected_at || Date.now()).toISOString().slice(0, 10)}
  112. </div>
  113. </div>
  114. </section>`;
  115. }
  116. // ==========================================================
  117. // 4. 目录
  118. // ==========================================================
  119. function renderAgenda() {
  120. return `
  121. <section class="report-section agenda" data-tone="index">
  122. <div class="section-inner">
  123. <div class="text-mono text-xs" style="color:${BRAND.MAIN}; letter-spacing:0.2em; font-weight:700;">📋 AGENDA · 章节导航</div>
  124. <h2 style="font-family:var(--font-head); font-weight:800; font-size:2.4rem; margin:16px 0 32px 0; color:var(--text-1);">8 章 · ${CHAPTERS.reduce((s, c) => s + c.subs.length, 0)} 子章</h2>
  125. <div class="grid" style="grid-template-columns:1fr 1fr; gap:18px;">
  126. ${CHAPTERS.map((c) => `
  127. <a href="#${c.key}-divider" style="padding:18px 22px; background:var(--bg-1); border-left:4px solid ${BRAND.MAIN}; border-radius:6px; text-decoration:none; color:inherit; transition:background .15s;">
  128. <div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:6px;">
  129. <div style="font-family:var(--font-head); font-weight:800; font-size:1.1rem; color:var(--text-1);">
  130. <span style="color:${BRAND.MAIN};">Ch${c.num}</span> · ${esc(c.title)}
  131. </div>
  132. <div class="text-mono text-xs" style="color:var(--text-3);">${c.subs.length} slides</div>
  133. </div>
  134. <div style="color:var(--text-2); font-size:0.86rem; line-height:1.6;">${esc(c.heroLine || '')}</div>
  135. </a>`).join('')}
  136. </div>
  137. </div>
  138. </section>`;
  139. }
  140. // ==========================================================
  141. // 5. 章分隔
  142. // ==========================================================
  143. function renderDivider(chapter) {
  144. return `
  145. <section class="report-section divider" id="${chapter.key}-divider" data-tone="${chapter.tone}">
  146. <div class="section-inner" style="text-align:center; padding:120px 40px;">
  147. <div class="text-mono" style="color:${BRAND.MAIN}; letter-spacing:0.35em; font-size:1rem; font-weight:700;">CHAPTER ${chapter.num.toString().padStart(2, '0')}</div>
  148. <h1 style="font-family:var(--font-head); font-weight:900; font-size:5rem; line-height:1.05; color:var(--text-1); margin:28px 0;">${esc(chapter.title)}</h1>
  149. <div style="color:var(--text-2); font-size:1.15rem; max-width:800px; margin:0 auto; line-height:1.7;">${chapter.heroLine || ''}</div>
  150. </div>
  151. </section>`;
  152. }
  153. // ==========================================================
  154. // 6. Coming Soon 占位
  155. // ==========================================================
  156. function renderComingSoon(chapter, subIdx) {
  157. return `
  158. <section class="report-section" id="${chapter.key}-${subIdx}" data-tone="${chapter.tone}">
  159. <div class="section-inner" style="padding:80px 40px; text-align:center;">
  160. <div class="text-mono text-xs" style="color:${BRAND.WARM_ORANGE || '#FF9F43'}; letter-spacing:0.2em;">COMING IN BATCH</div>
  161. <h2 style="font-family:var(--font-head); font-size:2.4rem; margin:20px 0; color:var(--text-1);">${chapter.num}.${subIdx} · 建设中</h2>
  162. <div style="color:var(--text-3);">此子章正在填充中</div>
  163. </div>
  164. </section>`;
  165. }
  166. // ==========================================================
  167. // 7. CSS
  168. // ==========================================================
  169. const CSS = `
  170. :root {
  171. --bg-0: #0A0A0A;
  172. --bg-1: #111111;
  173. --bg-2: #1A1A1A;
  174. --bg-3: #252525;
  175. --border: rgba(255,255,255,0.08);
  176. --border-strong: rgba(255,255,255,0.16);
  177. --text-1: #F5F5F5;
  178. --text-2: #A8A8A8;
  179. --text-3: #6B6B6B;
  180. --font-head: 'Inter','Noto Sans SC','PingFang SC','Microsoft YaHei',system-ui,sans-serif;
  181. --font-mono: 'JetBrains Mono','SFMono-Regular',Consolas,monospace;
  182. --font-body: 'Inter','Noto Sans SC',system-ui,sans-serif;
  183. --radius-sm: 4px;
  184. --radius-md: 8px;
  185. --radius-lg: 12px;
  186. }
  187. * { box-sizing: border-box; }
  188. body { font-family: var(--font-body); background: var(--bg-0); color: var(--text-1); margin: 0; line-height: 1.6; }
  189. .text-mono { font-family: var(--font-mono); }
  190. .text-xs { font-size: 0.72rem; }
  191. .grid { display: grid; }
  192. h1,h2,h3 { margin-top: 0; }
  193. .report-section { min-height: 100vh; padding: 60px 48px; border-bottom: 1px solid var(--border); scroll-margin-top: 20px; }
  194. .section-inner { max-width: 1280px; margin: 0 auto; }
  195. .cover { background: linear-gradient(180deg, #0A0A0A 0%, #111 100%); }
  196. .divider { background: linear-gradient(180deg, #0F0F0F 0%, #000 100%); display:flex; align-items:center; }
  197. a { color: inherit; }
  198. a:hover { opacity: 0.85; }
  199. `;
  200. // ==========================================================
  201. // 8. 导航 JS
  202. // ==========================================================
  203. const NAV_JS = `
  204. (function() {
  205. const sections = document.querySelectorAll('.report-section');
  206. let idx = 0;
  207. function go(i) {
  208. idx = Math.max(0, Math.min(sections.length - 1, i));
  209. sections[idx].scrollIntoView({ behavior: 'smooth' });
  210. if (sections[idx].id) history.replaceState(null, '', '#' + sections[idx].id);
  211. }
  212. document.addEventListener('keydown', (e) => {
  213. if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); go(idx + 1); }
  214. if (e.key === 'ArrowLeft') { e.preventDefault(); go(idx - 1); }
  215. if (e.key === 'Home') go(0);
  216. if (e.key === 'End') go(sections.length - 1);
  217. });
  218. if (location.hash) {
  219. const i = [...sections].findIndex((s) => '#' + s.id === location.hash);
  220. if (i >= 0) idx = i;
  221. }
  222. })();
  223. `;
  224. // ==========================================================
  225. // 9. 主装配
  226. // ==========================================================
  227. function buildHTML(data) {
  228. const slides = [];
  229. slides.push(renderCover(data));
  230. slides.push(renderAgenda());
  231. const slideTotal = CHAPTERS.reduce((s, c) => s + c.subs.length, 0) + 2;
  232. let slideNum = 3;
  233. for (const chapter of CHAPTERS) {
  234. slides.push(renderDivider(chapter));
  235. for (const sub of chapter.subs) {
  236. const subIdx = sub.split('-')[1];
  237. const key = `${chapter.key}-${subIdx}`;
  238. const renderer = SLIDE_RENDERERS[key];
  239. if (!renderer) {
  240. slides.push(renderComingSoon(chapter, subIdx));
  241. } else {
  242. slides.push(renderer({ chapter, subIdx, sub, slideNum, slideTotal, data }));
  243. }
  244. slideNum++;
  245. }
  246. }
  247. return `<!DOCTYPE html>
  248. <html lang="zh-CN">
  249. <head>
  250. <meta charset="UTF-8"/>
  251. <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  252. <title><品类名> · VOC 深度洞察报告</title>
  253. <style>${CSS}</style>
  254. </head>
  255. <body>
  256. ${slides.join('\n')}
  257. <script>${NAV_JS}</script>
  258. </body>
  259. </html>`;
  260. }
  261. module.exports = { buildHTML, CHAPTERS };