lactic-components.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. // ==============================================================================
  2. // 江中乳酸菌素片儿童版 · VOC 报告共享 UI 组件(复刻 liver-components.js,换 LACTIC_BRAND)
  3. // ==============================================================================
  4. // ----------------------------------------------------------------
  5. // LACTIC_BRAND · 乳酸菌素片儿童版专属色板(儿童友好 + 江中品牌主色)
  6. // ----------------------------------------------------------------
  7. const BRAND = {
  8. JIANG_GREEN: '#2D9C5D', // 江中品牌主色
  9. LACTIC_MINT: '#7ED4C2', // 乳酸菌清爽薄荷(产品主色·清洁感)
  10. BERRY_PINK: '#FF8BA7', // 莓果粉(儿童可爱)
  11. SKY_BLUE: '#6EC1E4', // 晴空蓝(安全信任)
  12. SUNNY_YELLOW: '#FFD166', // 鹅黄(儿童活力)
  13. CREAM: '#FFF4E6', // 奶油米色
  14. CORAL: '#FF6B6B', // 痛点/诘问/差评情绪
  15. WARM_ORANGE: '#FF8C42', // 温暖橙(场景地图)
  16. TECH_BLUE: '#0085CA', // OTC 专业蓝(药品信任)
  17. SAGE: '#87A96B', // 药食同源绿
  18. PURPLE: '#8B5CF6', // KANO × JTBD 理论
  19. ROSE: '#FF4D8D', // 竞品对比玫瑰
  20. WINE_RED: '#8B2942', // 警示红
  21. GOLD: '#C8A04B', // 礼品奢华金
  22. BABY_PEACH: '#FFCBA4', // 婴儿桃(0-3 岁人群)
  23. PRESCHOOL_LIME: '#C5E063', // 学龄前青柠(3-6 岁)
  24. SCHOOL_LAVENDER: '#B4A7D6', // 学龄期薰衣草(6-12 岁)
  25. };
  26. const PLATFORM_LABELS = {
  27. xhs: { name: '小红书', color: '#FF2442', short: '红' },
  28. douyin: { name: '抖音', color: '#1A1A1A', short: '抖' },
  29. taobao: { name: '淘宝', color: '#FF5000', short: '淘' },
  30. jd: { name: '京东', color: '#E1251B', short: '京' },
  31. tmall: { name: '天猫', color: '#FF0036', short: '猫' },
  32. zhihu: { name: '知乎', color: '#0084FF', short: '知' },
  33. unknown: { name: '其他', color: '#888', short: '—' },
  34. };
  35. const SENTIMENT_LABELS = {
  36. positive: { label: '好评', color: '#2D9C5D', icon: '▲' },
  37. neutral: { label: '中性', color: '#888', icon: '●' },
  38. negative: { label: '差评', color: '#FF6B6B', icon: '▼' },
  39. conflicted:{ label: '矛盾', color: '#FF8C42', icon: '◆' },
  40. };
  41. function esc(s) {
  42. return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  43. }
  44. function fmtLikes(n) {
  45. if (n == null) return '0';
  46. if (n >= 10000) return (n / 10000).toFixed(1).replace(/\.0$/, '') + 'w';
  47. if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
  48. return String(n);
  49. }
  50. function truncate(s, n = 160) {
  51. s = String(s ?? '');
  52. return s.length > n ? s.slice(0, n - 1) + '…' : s;
  53. }
  54. // ================================================================
  55. // VOC 证据卡(核心组件)
  56. // ================================================================
  57. function renderVocCard(item, opts = {}) {
  58. const { accent = BRAND.LACTIC_MINT, showBasis = false, compact = false, maxChars = 240 } = opts;
  59. const pf = PLATFORM_LABELS[item.platform] || PLATFORM_LABELS.unknown;
  60. const sen = SENTIMENT_LABELS[item.sentiment] || SENTIMENT_LABELS.neutral;
  61. const isSeed = (item.source || '').includes('pattern') || (item.source || '').includes('seed');
  62. const tags = Array.isArray(item.tags) ? item.tags.slice(0, 3) : [];
  63. return `<div style="background:var(--bg-1); border:1px solid var(--border); border-left:3px solid ${accent}; border-radius:var(--radius-sm); padding:${compact ? '12px 14px' : '16px 18px'}; display:flex; flex-direction:column; gap:${compact ? '8px' : '10px'};">
  64. <div style="display:flex; justify-content:space-between; align-items:center; gap:8px; flex-wrap:wrap;">
  65. <div style="display:flex; align-items:center; gap:6px;">
  66. <span style="display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:50%; background:${pf.color}22; color:${pf.color}; font-size:0.62rem; font-weight:700;">${pf.short}</span>
  67. <span style="color:var(--text-1); font-size:0.78rem; font-weight:600;">${esc(item.nickname || '匿名用户')}</span>
  68. ${item.ip ? `<span style="color:var(--text-3); font-size:0.68rem;">· ${esc(item.ip)}</span>` : ''}
  69. ${item.keyword ? `<span style="color:var(--text-3); font-size:0.66rem; font-family:var(--font-mono);">· ${esc(item.keyword)}</span>` : ''}
  70. </div>
  71. <div style="display:flex; align-items:center; gap:6px;">
  72. <span style="color:${sen.color}; font-size:0.7rem; font-weight:600;">${sen.icon} ${sen.label}</span>
  73. <span style="color:var(--text-3); font-size:0.68rem; font-family:var(--font-mono);">♥${fmtLikes(item.likes)}</span>
  74. ${isSeed ? `<span style="padding:1px 6px; background:${BRAND.BERRY_PINK}18; color:${BRAND.BERRY_PINK}; border-radius:10px; font-size:0.6rem; font-family:var(--font-mono); font-weight:700;">◈模式</span>` : `<span style="padding:1px 6px; background:${BRAND.JIANG_GREEN}18; color:${BRAND.JIANG_GREEN}; border-radius:10px; font-size:0.6rem; font-family:var(--font-mono); font-weight:700;">✓真实</span>`}
  75. </div>
  76. </div>
  77. <div style="color:var(--text-1); font-size:${compact ? '0.86rem' : '0.92rem'}; line-height:1.62;">"${esc(truncate(item.content, maxChars))}"</div>
  78. ${tags.length ? `<div style="display:flex; flex-wrap:wrap; gap:4px;">
  79. ${tags.map((t) => `<span style="padding:2px 8px; background:${accent}12; color:${accent}; border-radius:3px; font-size:0.68rem; font-weight:600;">${esc(t)}</span>`).join('')}
  80. </div>` : ''}
  81. ${showBasis && item.basis ? `<div style="color:var(--text-3); font-size:0.66rem; font-family:var(--font-mono); letter-spacing:0.05em; padding-top:6px; border-top:1px dashed var(--border);">依据:${esc(item.basis)}</div>` : ''}
  82. </div>`;
  83. }
  84. function renderVocGrid(items, opts = {}) {
  85. const { columns = 2, accent = BRAND.LACTIC_MINT, compact = false, maxChars } = opts;
  86. return `<div style="display:grid; grid-template-columns:repeat(${columns}, 1fr); gap:14px;">
  87. ${items.map((it) => renderVocCard(it, { accent, compact, maxChars })).join('')}
  88. </div>`;
  89. }
  90. function renderInsightHero({ eyebrow, title, subtitle, color = BRAND.LACTIC_MINT }) {
  91. return `<div class="rise-in" style="padding:28px 32px; background:linear-gradient(135deg, ${color}12, ${color}04); border-left:4px solid ${color}; border-radius:var(--radius-md); margin-bottom:32px;">
  92. ${eyebrow ? `<div class="text-mono text-xs" style="color:${color}; letter-spacing:0.18em; margin-bottom:10px; font-weight:700;">${esc(eyebrow)}</div>` : ''}
  93. <div style="color:var(--text-1); font-size:1.18rem; line-height:1.72; font-weight:600;">${title}</div>
  94. ${subtitle ? `<div style="color:var(--text-2); font-size:0.92rem; line-height:1.7; margin-top:10px;">${subtitle}</div>` : ''}
  95. </div>`;
  96. }
  97. function renderDecisionList(items, opts = {}) {
  98. const { accent = BRAND.JIANG_GREEN, title = 'DECISION · 落地决策项', columns = 2 } = opts;
  99. return `<div class="fade-up" style="margin-top:24px; padding:22px 26px; background:${accent}08; border:1px solid ${accent}33; border-radius:var(--radius-md);">
  100. <div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.2em; margin-bottom:14px; font-weight:700;">${esc(title)}</div>
  101. <div style="display:grid; grid-template-columns:repeat(${columns}, 1fr); gap:14px;">
  102. ${items.map((it, i) => `
  103. <div style="display:flex; gap:10px; align-items:flex-start;">
  104. <div style="flex-shrink:0; width:22px; height:22px; border-radius:50%; background:${accent}; color:#fff; font-size:0.72rem; font-weight:700; display:flex; align-items:center; justify-content:center;">${i + 1}</div>
  105. <div style="color:var(--text-1); font-size:0.9rem; line-height:1.64;">${it}</div>
  106. </div>`).join('')}
  107. </div>
  108. </div>`;
  109. }
  110. function renderTheoryBox({ theory, mapping, accent = BRAND.PURPLE }) {
  111. return `<div class="fade-up" style="margin-top:24px; padding:22px 26px; background:linear-gradient(135deg, ${accent}0E, transparent); border:1px solid ${accent}30; border-radius:var(--radius-md);">
  112. <div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.2em; margin-bottom:12px; font-weight:700;">THEORY · ${esc(theory)}</div>
  113. <div style="color:var(--text-1); font-size:0.95rem; line-height:1.72;">${mapping}</div>
  114. </div>`;
  115. }
  116. function renderTagCloud(tagGroups, opts = {}) {
  117. const { accent = BRAND.LACTIC_MINT, top = 15, title = '主题分布' } = opts;
  118. const list = tagGroups.slice(0, top);
  119. if (!list.length) return '';
  120. const maxCount = Math.max(...list.map((g) => g.count));
  121. return `<div style="padding:20px 22px; background:var(--bg-1); border:1px solid var(--border); border-radius:var(--radius-md);">
  122. <div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.15em; margin-bottom:14px; font-weight:700;">${esc(title)} · TOP ${list.length}</div>
  123. <div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;">
  124. ${list.map((g) => {
  125. const ratio = g.count / maxCount;
  126. const fs = (0.72 + ratio * 0.45).toFixed(2);
  127. const bg = Math.round(10 + ratio * 30);
  128. return `<span style="padding:6px 12px; background:${accent}${String(bg).padStart(2, '0')}; color:${accent}; border-radius:16px; font-size:${fs}rem; font-weight:${Math.round(500 + ratio * 300)}; letter-spacing:0.02em;">${esc(g.tag)} <span style="opacity:0.7; font-size:0.8em; font-family:var(--font-mono);">×${g.count}</span></span>`;
  129. }).join('')}
  130. </div>
  131. </div>`;
  132. }
  133. function renderStatStrip(stats, opts = {}) {
  134. const { accent = BRAND.LACTIC_MINT } = opts;
  135. return `<div style="display:grid; grid-template-columns:repeat(${stats.length}, 1fr); gap:12px; padding:18px 22px; background:var(--bg-1); border:1px solid var(--border); border-radius:var(--radius-md);">
  136. ${stats.map((s) => `
  137. <div style="text-align:center;">
  138. <div style="font-family:var(--font-head); font-size:1.8rem; font-weight:800; color:${s.color || accent}; letter-spacing:-0.02em;">${s.num}</div>
  139. <div style="color:var(--text-3); font-size:0.72rem; font-family:var(--font-mono); letter-spacing:0.1em; margin-top:4px;">${esc(s.label)}</div>
  140. ${s.delta ? `<div style="color:${s.deltaColor || accent}; font-size:0.7rem; margin-top:2px;">${esc(s.delta)}</div>` : ''}
  141. </div>`).join('')}
  142. </div>`;
  143. }
  144. function renderBarChart(rows, opts = {}) {
  145. const { accent = BRAND.LACTIC_MINT, height = 260, title = '' } = opts;
  146. const max = Math.max(...rows.map((r) => r.value), 1);
  147. const barH = 28;
  148. const gap = 10;
  149. const padL = 160;
  150. const padR = 80;
  151. const padV = 16;
  152. const totalH = rows.length * (barH + gap) + padV * 2;
  153. const W = 900;
  154. return `<div style="padding:18px 22px; background:var(--bg-1); border:1px solid var(--border); border-radius:var(--radius-md);">
  155. ${title ? `<div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.15em; margin-bottom:12px; font-weight:700;">${esc(title)}</div>` : ''}
  156. <svg viewBox="0 0 ${W} ${totalH}" style="width:100%; height:auto; max-height:${height}px;" preserveAspectRatio="xMidYMid meet">
  157. ${rows.map((r, i) => {
  158. const y = padV + i * (barH + gap);
  159. const w = (r.value / max) * (W - padL - padR);
  160. const color = r.color || accent;
  161. return `
  162. <text x="${padL - 10}" y="${y + barH / 2 + 5}" text-anchor="end" font-size="13" font-weight="600" fill="var(--text-1, #333)">${esc(r.label)}</text>
  163. <rect x="${padL}" y="${y}" width="${w}" height="${barH}" rx="4" fill="${color}" opacity="0.85"/>
  164. <text x="${padL + w + 8}" y="${y + barH / 2 + 5}" font-size="13" font-weight="700" fill="${color}" font-family="monospace">${esc(r.valueLabel || r.value)}</text>
  165. `;
  166. }).join('')}
  167. </svg>
  168. </div>`;
  169. }
  170. function renderMatrix2x2({ xLabel, yLabel, xLow, xHigh, yLow, yHigh, quadrants, accent = BRAND.LACTIC_MINT }) {
  171. const quadStyle = (q) => ({
  172. color: q?.color || accent,
  173. title: q?.title || '',
  174. note: q?.note || '',
  175. items: q?.items || [],
  176. });
  177. const q1 = quadStyle(quadrants.q1);
  178. const q2 = quadStyle(quadrants.q2);
  179. const q3 = quadStyle(quadrants.q3);
  180. const q4 = quadStyle(quadrants.q4);
  181. const renderQuad = (q) => `
  182. <div style="padding:16px; background:${q.color}08; border:1px solid ${q.color}40; border-radius:6px; height:100%; display:flex; flex-direction:column;">
  183. <div class="text-mono text-xs" style="color:${q.color}; letter-spacing:0.15em; margin-bottom:8px; font-weight:700;">${esc(q.title)}</div>
  184. <ul style="color:var(--text-1); font-size:0.82rem; line-height:1.55; margin:0; padding-left:18px; list-style:disc;">
  185. ${q.items.map((it) => `<li style="margin-bottom:4px;">${it}</li>`).join('')}
  186. </ul>
  187. ${q.note ? `<div style="margin-top:auto; padding-top:10px; color:${q.color}; font-size:0.74rem; font-style:italic;">${q.note}</div>` : ''}
  188. </div>`;
  189. return `<div style="padding:20px 26px; background:var(--bg-1); border:1px solid var(--border); border-radius:var(--radius-md);">
  190. <div style="display:grid; grid-template-columns:52px 1fr 1fr; grid-template-rows:auto 1fr 1fr 40px; gap:12px; height:520px;">
  191. <div style="grid-row:2 / span 2; grid-column:1; display:flex; align-items:center; justify-content:center; transform:rotate(-90deg); white-space:nowrap;">
  192. <span class="text-mono text-xs" style="color:${accent}; letter-spacing:0.2em; font-weight:700;">${esc(yLabel)}</span>
  193. </div>
  194. <div style="grid-row:1; grid-column:2; text-align:center;"><span class="text-mono text-xs" style="color:${q2.color}; font-weight:700;">${esc(yHigh)}</span></div>
  195. <div style="grid-row:1; grid-column:3; text-align:center;"><span class="text-mono text-xs" style="color:${q1.color}; font-weight:700;">${esc(yHigh)}</span></div>
  196. <div style="grid-row:2; grid-column:2;">${renderQuad(q2)}</div>
  197. <div style="grid-row:2; grid-column:3;">${renderQuad(q1)}</div>
  198. <div style="grid-row:3; grid-column:2;">${renderQuad(q3)}</div>
  199. <div style="grid-row:3; grid-column:3;">${renderQuad(q4)}</div>
  200. <div style="grid-row:4; grid-column:2; text-align:center;"><span class="text-mono text-xs" style="color:var(--text-3); letter-spacing:0.15em;">${esc(xLow)} ← ${esc(xLabel)} → ${esc(xHigh)}</span></div>
  201. <div style="grid-row:4; grid-column:3;"></div>
  202. </div>
  203. </div>`;
  204. }
  205. function renderSectionHead({ eyebrow, title, subtitle, color = BRAND.LACTIC_MINT }) {
  206. return `<div class="section-head rise-in">
  207. <div class="section-eyebrow" style="color:${color};">${esc(eyebrow)}</div>
  208. <h2 class="text-h1"><span class="headline-anim">${title}</span></h2>
  209. ${subtitle ? `<p class="section-sub">${subtitle}</p>` : ''}
  210. </div>`;
  211. }
  212. function renderCompareTable({ title, headers, rows, accent = BRAND.LACTIC_MINT }) {
  213. return `<div style="padding:18px 22px; background:var(--bg-1); border:1px solid var(--border); border-radius:var(--radius-md);">
  214. ${title ? `<div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.15em; margin-bottom:12px; font-weight:700;">${esc(title)}</div>` : ''}
  215. <table style="width:100%; border-collapse:collapse; font-size:0.88rem;">
  216. <thead>
  217. <tr style="border-bottom:2px solid ${accent}33;">
  218. ${headers.map((h, i) => `<th style="text-align:${i === 0 ? 'left' : 'center'}; padding:10px 6px; color:${i === 0 ? 'var(--text-3)' : accent}; font-weight:700; font-size:0.8rem; letter-spacing:0.05em;">${esc(h)}</th>`).join('')}
  219. </tr>
  220. </thead>
  221. <tbody>
  222. ${rows.map((r, ri) => `<tr style="border-bottom:1px solid var(--border);">
  223. ${r.map((cell, ci) => {
  224. const isHeader = ci === 0;
  225. const content = typeof cell === 'string' ? cell : (cell.html || '');
  226. const cellColor = typeof cell === 'object' && cell.color ? cell.color : (isHeader ? 'var(--text-2)' : 'var(--text-1)');
  227. const weight = typeof cell === 'object' && cell.bold ? '700' : '400';
  228. return `<td style="padding:${ri === rows.length - 1 ? '12px' : '10px'} 6px; color:${cellColor}; text-align:${isHeader ? 'left' : 'center'}; font-weight:${weight};">${content}</td>`;
  229. }).join('')}
  230. </tr>`).join('')}
  231. </tbody>
  232. </table>
  233. </div>`;
  234. }
  235. function renderFindingCard({ icon = '💡', title, body, accent = BRAND.LACTIC_MINT }) {
  236. return `<div style="padding:20px 24px; background:${accent}08; border-left:3px solid ${accent}; border-radius:6px;">
  237. <div style="display:flex; align-items:center; gap:10px; margin-bottom:10px;">
  238. <span style="font-size:1.4rem;">${icon}</span>
  239. <div style="color:${accent}; font-weight:700; font-size:0.96rem;">${esc(title)}</div>
  240. </div>
  241. <div style="color:var(--text-1); font-size:0.88rem; line-height:1.7;">${body}</div>
  242. </div>`;
  243. }
  244. function renderHypothesisBadge(h, opts = {}) {
  245. const { accent = BRAND.LACTIC_MINT, size = 'md' } = opts;
  246. const fs = size === 'sm' ? '0.64rem' : size === 'lg' ? '0.82rem' : '0.72rem';
  247. const px = size === 'sm' ? '3px 8px' : size === 'lg' ? '6px 14px' : '4px 10px';
  248. return `<span style="padding:${px}; background:${accent}15; color:${accent}; border:1px solid ${accent}44; border-radius:12px; font-size:${fs}; font-family:var(--font-mono); letter-spacing:0.05em; font-weight:700;">${esc(h)}</span>`;
  249. }
  250. module.exports = {
  251. BRAND,
  252. PLATFORM_LABELS,
  253. SENTIMENT_LABELS,
  254. esc,
  255. fmtLikes,
  256. truncate,
  257. renderVocCard,
  258. renderVocGrid,
  259. renderInsightHero,
  260. renderDecisionList,
  261. renderTheoryBox,
  262. renderTagCloud,
  263. renderStatStrip,
  264. renderBarChart,
  265. renderMatrix2x2,
  266. renderSectionHead,
  267. renderCompareTable,
  268. renderFindingCard,
  269. renderHypothesisBadge,
  270. };