components.template.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. // ==============================================================================
  2. // <品类名> · VOC 报告 UI 组件库(模板)
  3. // ==============================================================================
  4. // 职责:
  5. // - BRAND 色板(品类专属)
  6. // - 10 个核心组件(Cover / Agenda / Divider / SectionHead / VocCard
  7. // StatStrip / CompareTable / Matrix2x2 / InsightHero / DecisionList)
  8. // - esc/fmtLikes 等工具
  9. // ==============================================================================
  10. // ==========================================================
  11. // 1. BRAND 色板(按品类定制)
  12. // TODO: 修改为当前品类的主题色
  13. // ==========================================================
  14. const BRAND = {
  15. // 品类主色
  16. MAIN: '#4A90E2',
  17. MAIN_GLOW: 'rgba(74,144,226,0.15)',
  18. // 品牌背书色(江中国货等)
  19. JIANG_GREEN: '#1A7C5F',
  20. OTC_BLUE: '#0071CE',
  21. // 情绪表达色
  22. CORAL: '#FF6B6B', // 痛点 / 焦虑
  23. LACTIC_MINT: '#8FD3B8', // 平和 / 解法
  24. WINE_RED: '#722F37', // 反向 / 禁区
  25. GOLD: '#D4AF37', // 尊贵 / 溢价
  26. // 辅助色
  27. TECH_BLUE: '#4A90E2',
  28. PURPLE: '#8B5CF6',
  29. SAGE: '#9CAF88',
  30. BERRY_PINK: '#E63E7B',
  31. ROSE: '#FF4D8D',
  32. SKY_BLUE: '#56CCF2',
  33. WARM_ORANGE: '#FF9F43',
  34. SUNNY_YELLOW: '#FFCC33',
  35. // 分龄色(儿童品类可选)
  36. BABY_PEACH: '#FFB6A3',
  37. PRESCHOOL_LIME: '#B5E48C',
  38. SCHOOL_LAVENDER: '#B5A3E0',
  39. };
  40. // ==========================================================
  41. // 2. 工具函数
  42. // ==========================================================
  43. function esc(s) {
  44. return String(s || '')
  45. .replace(/&/g, '&amp;')
  46. .replace(/</g, '&lt;')
  47. .replace(/>/g, '&gt;')
  48. .replace(/"/g, '&quot;');
  49. }
  50. function fmtLikes(n) {
  51. if (!n) return '0';
  52. if (n >= 10000) return (n / 10000).toFixed(1) + 'w';
  53. if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
  54. return String(n);
  55. }
  56. // ==========================================================
  57. // 3. renderVocCard · VOC 证据卡(核心组件)
  58. // ==========================================================
  59. function renderVocCard(item, opts = {}) {
  60. if (!item) return '';
  61. const accent = opts.accent || BRAND.MAIN;
  62. const maxChars = opts.maxChars || 200;
  63. const content = String(item.content || '').slice(0, maxChars);
  64. const more = (item.content || '').length > maxChars ? '…' : '';
  65. return `
  66. <div style="padding:14px 16px; background:var(--bg-1); border:1px solid var(--border);
  67. border-left:3px solid ${accent}; border-radius:6px;">
  68. <div style="display:flex; justify-content:space-between; gap:10px; margin-bottom:8px; font-size:0.78rem;">
  69. <div style="flex:1; min-width:0;">
  70. <span class="text-mono" style="color:${accent}; font-weight:700; letter-spacing:0.1em;">
  71. ${(item.platform || 'XHS').toUpperCase()}
  72. </span>
  73. <span style="color:var(--text-3);"> · ${esc(item.nickname || '匿名')}</span>
  74. ${item.ip ? `<span style="color:var(--text-3);"> · ${esc(item.ip)}</span>` : ''}
  75. </div>
  76. <div style="color:${accent}; font-family:var(--font-mono); font-weight:700; white-space:nowrap;">
  77. ♥${fmtLikes(item.likes)}
  78. </div>
  79. </div>
  80. <div style="color:var(--text-1); font-size:0.88rem; line-height:1.7;">
  81. 「${esc(content)}${more}」
  82. </div>
  83. </div>`;
  84. }
  85. // ==========================================================
  86. // 4. renderSectionHead · 子章节 Hero
  87. // ==========================================================
  88. function renderSectionHead(opts = {}) {
  89. const { eyebrow, title, subtitle, color = BRAND.MAIN } = opts;
  90. return `
  91. <div style="margin-bottom:20px;">
  92. ${eyebrow ? `<div class="text-mono text-xs" style="color:${color}; letter-spacing:0.2em; margin-bottom:10px; font-weight:700;">${eyebrow}</div>` : ''}
  93. <h2 style="font-family:var(--font-head); font-weight:800; font-size:1.9rem; line-height:1.3; color:var(--text-1); margin:0 0 12px 0;">${title || ''}</h2>
  94. ${subtitle ? `<div style="color:var(--text-2); font-size:0.95rem; line-height:1.75; max-width:900px;">${subtitle}</div>` : ''}
  95. </div>`;
  96. }
  97. // ==========================================================
  98. // 5. renderStatStrip · KPI 数字条
  99. // ==========================================================
  100. function renderStatStrip(stats, opts = {}) {
  101. const accent = opts.accent || BRAND.MAIN;
  102. return `
  103. <div class="grid" style="grid-template-columns:repeat(${stats.length},1fr); gap:12px; margin:20px 0;">
  104. ${stats.map((s) => `
  105. <div style="padding:18px; background:${s.color || accent}0C; border:1px solid ${s.color || accent}33; border-radius:8px; text-align:center;">
  106. <div style="color:${s.color || accent}; font-family:var(--font-head); font-weight:900; font-size:2rem; line-height:1;">${esc(s.num)}</div>
  107. <div style="color:var(--text-2); font-size:0.82rem; margin-top:6px;">${esc(s.label)}</div>
  108. ${s.delta ? `<div style="color:${s.color || accent}; font-size:0.72rem; font-family:var(--font-mono); margin-top:4px;">${esc(s.delta)}</div>` : ''}
  109. </div>`).join('')}
  110. </div>`;
  111. }
  112. // ==========================================================
  113. // 6. renderDecisionList · 落地决策列表(每子章收尾)
  114. // ==========================================================
  115. function renderDecisionList(items, opts = {}) {
  116. const accent = opts.accent || BRAND.MAIN;
  117. const title = opts.title || '落地决策';
  118. const columns = opts.columns || 2;
  119. return `
  120. <div style="margin-top:24px; padding:20px 24px; background:${accent}08; border:1px solid ${accent}33; border-radius:var(--radius-md);">
  121. <div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.18em; margin-bottom:14px; font-weight:700;">🎯 ${esc(title)}</div>
  122. <div class="grid" style="grid-template-columns:repeat(${columns},1fr); gap:10px;">
  123. ${items.map((x) => `
  124. <div style="padding:10px 14px; background:var(--bg-1); border-left:3px solid ${accent}; border-radius:4px; color:var(--text-1); font-size:0.86rem; line-height:1.65;">
  125. ${x}
  126. </div>`).join('')}
  127. </div>
  128. </div>`;
  129. }
  130. // ==========================================================
  131. // 7. renderInsightHero · 金句观点卡
  132. // ==========================================================
  133. function renderInsightHero(opts = {}) {
  134. const { eyebrow, title, subtitle, color = BRAND.MAIN } = opts;
  135. return `
  136. <div style="padding:24px 28px; background:linear-gradient(135deg, ${color}14, ${color}04); border:2px solid ${color}44; border-radius:var(--radius-lg); margin:20px 0;">
  137. ${eyebrow ? `<div class="text-mono text-xs" style="color:${color}; letter-spacing:0.2em; margin-bottom:12px; font-weight:700;">${eyebrow}</div>` : ''}
  138. <div style="color:var(--text-1); font-size:1.1rem; line-height:1.8;">${title || ''}</div>
  139. ${subtitle ? `<div style="color:var(--text-2); font-size:0.9rem; line-height:1.7; margin-top:10px;">${subtitle}</div>` : ''}
  140. </div>`;
  141. }
  142. // ==========================================================
  143. // 8. renderCompareTable · 对比表格
  144. // ==========================================================
  145. function renderCompareTable(opts = {}) {
  146. const { title, headers = [], rows = [], accent = BRAND.MAIN } = opts;
  147. return `
  148. <div style="margin:20px 0;">
  149. ${title ? `<div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.18em; margin-bottom:12px; font-weight:700;">${esc(title)}</div>` : ''}
  150. <div style="overflow-x:auto;">
  151. <table style="width:100%; border-collapse:collapse; font-size:0.86rem;">
  152. <thead>
  153. <tr style="border-bottom:2px solid ${accent}44;">
  154. ${headers.map((h) => `<th style="text-align:left; padding:10px 12px; color:var(--text-3); font-weight:700; font-size:0.78rem; letter-spacing:0.05em;">${esc(h)}</th>`).join('')}
  155. </tr>
  156. </thead>
  157. <tbody>
  158. ${rows.map((row) => `
  159. <tr style="border-bottom:1px solid var(--border);">
  160. ${row.map((cell) => {
  161. const c = typeof cell === 'object' ? cell : { html: cell };
  162. return `<td style="padding:10px 12px; color:${c.color || 'var(--text-1)'}; ${c.bold ? 'font-weight:700;' : ''}">${c.html || c}</td>`;
  163. }).join('')}
  164. </tr>`).join('')}
  165. </tbody>
  166. </table>
  167. </div>
  168. </div>`;
  169. }
  170. // ==========================================================
  171. // 9. renderMatrix2x2 · 无人地带四象限
  172. // ==========================================================
  173. function renderMatrix2x2(opts = {}) {
  174. const { xLabel, yLabel, xLow, xHigh, yLow, yHigh, quadrants = {}, accent = BRAND.MAIN } = opts;
  175. const renderQ = (q) => {
  176. if (!q) return '<div></div>';
  177. return `
  178. <div style="padding:16px; background:${(q.color || accent)}0C; border:1px solid ${(q.color || accent)}33; border-radius:6px;">
  179. <div class="text-mono text-xs" style="color:${q.color || accent}; font-weight:700; margin-bottom:8px;">${q.title || ''}</div>
  180. <ul style="margin:0; padding-left:18px; color:var(--text-1); font-size:0.82rem; line-height:1.7;">
  181. ${(q.items || []).map((i) => `<li>${i}</li>`).join('')}
  182. </ul>
  183. ${q.note ? `<div style="color:var(--text-3); font-size:0.76rem; margin-top:8px; font-style:italic;">${esc(q.note)}</div>` : ''}
  184. </div>`;
  185. };
  186. return `
  187. <div style="margin:24px 0;">
  188. <div style="display:grid; grid-template-columns:auto 1fr 1fr; grid-template-rows:auto 1fr 1fr auto; gap:12px; align-items:stretch;">
  189. <div></div>
  190. <div style="text-align:center; color:var(--text-3); font-size:0.78rem; font-weight:700;">${esc(xLow || '')}</div>
  191. <div style="text-align:center; color:var(--text-3); font-size:0.78rem; font-weight:700;">${esc(xHigh || '')}</div>
  192. <div style="writing-mode:vertical-rl; text-align:center; color:var(--text-3); font-size:0.78rem; font-weight:700; padding:8px 0;">${esc(yHigh || '')}</div>
  193. ${renderQ(quadrants.q2)}
  194. ${renderQ(quadrants.q1)}
  195. <div style="writing-mode:vertical-rl; text-align:center; color:var(--text-3); font-size:0.78rem; font-weight:700; padding:8px 0;">${esc(yLow || '')}</div>
  196. ${renderQ(quadrants.q3)}
  197. ${renderQ(quadrants.q4)}
  198. <div></div>
  199. <div style="grid-column:2/4; text-align:center; color:${accent}; font-family:var(--font-mono); font-size:0.82rem; letter-spacing:0.12em; margin-top:4px;">← ${esc(xLabel || '')} →</div>
  200. </div>
  201. ${yLabel ? `<div style="text-align:center; color:${accent}; font-family:var(--font-mono); font-size:0.82rem; letter-spacing:0.12em; margin-top:8px;">Y: ${esc(yLabel)}</div>` : ''}
  202. </div>`;
  203. }
  204. // ==========================================================
  205. // 10. renderTheoryBox · 理论盒子(mapping 类)
  206. // ==========================================================
  207. function renderTheoryBox(opts = {}) {
  208. const { theory, accent = BRAND.MAIN, mapping } = opts;
  209. return `
  210. <div style="padding:16px 18px; background:${accent}08; border-left:3px solid ${accent}; border-radius:var(--radius-md);">
  211. ${theory ? `<div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.15em; font-weight:700; margin-bottom:10px;">${esc(theory)}</div>` : ''}
  212. ${mapping || ''}
  213. </div>`;
  214. }
  215. // ==========================================================
  216. // 11. renderFindingCard · 发现洞察卡
  217. // ==========================================================
  218. function renderFindingCard(opts = {}) {
  219. const { icon, title, body, accent = BRAND.MAIN } = opts;
  220. return `
  221. <div style="padding:16px 18px; background:var(--bg-1); border:1px solid ${accent}33; border-left:4px solid ${accent}; border-radius:var(--radius-md);">
  222. <div style="display:flex; align-items:flex-start; gap:12px;">
  223. ${icon ? `<div style="font-size:1.6rem; line-height:1;">${icon}</div>` : ''}
  224. <div style="flex:1;">
  225. ${title ? `<div style="color:var(--text-1); font-weight:700; font-size:0.95rem; margin-bottom:6px;">${title}</div>` : ''}
  226. <div style="color:var(--text-2); font-size:0.84rem; line-height:1.7;">${body || ''}</div>
  227. </div>
  228. </div>
  229. </div>`;
  230. }
  231. // ==========================================================
  232. // 12. 导出
  233. // ==========================================================
  234. module.exports = {
  235. BRAND,
  236. esc,
  237. fmtLikes,
  238. renderVocCard,
  239. renderSectionHead,
  240. renderStatStrip,
  241. renderDecisionList,
  242. renderInsightHero,
  243. renderCompareTable,
  244. renderMatrix2x2,
  245. renderTheoryBox,
  246. renderFindingCard,
  247. };