liver-components.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. // ==============================================================================
  2. // 江中肝纯片 · VOC 报告共享 UI 组件(复刻 houguyin-components.js,换 LIVER_BRAND + 补 amazon 平台)
  3. // ==============================================================================
  4. // 组件库内部默认使用 LIVER_BRAND,所有 render* 函数都接受 accent 参数覆盖
  5. // 设计:不依赖外部图表库,纯 CSS + SVG 轻量可视化
  6. // ----------------------------------------------------------------
  7. // LIVER_BRAND · 肝纯片专属色板
  8. // ----------------------------------------------------------------
  9. const BRAND = {
  10. JIANG_GREEN: '#2D9C5D', // 江中品牌主色(品牌/正向)
  11. LIVER_AMBER: '#D4A574', // 肝纯片 · 琥珀色(产品主色)
  12. TENGCHA_GREEN: '#4A8A3A', // 藤茶深绿(DMY 原料章节)
  13. DEEP_AMBER: '#B87333', // 深琥珀(礼品/传统)
  14. CREAM: '#FAE8D4', // 奶油米色(包装底色)
  15. TECH_BLUE: '#0085CA', // 科技蓝(数据/理论/权威)
  16. WARM_ORANGE: '#FF8C42', // 场景地图橙
  17. CORAL: '#FF6B6B', // 痛点/诘问/差评情绪
  18. SAGE: '#87A96B', // 药食同源绿(日常养护)
  19. PURPLE: '#8B5CF6', // KANO × JTBD 理论
  20. ROSE: '#FF4D8D', // 竞品对比玫瑰
  21. WINE_RED: '#8B2942', // 应酬解酒/酒饮场景
  22. GOLD: '#C8A04B', // 国礼级奢华金
  23. };
  24. const PLATFORM_LABELS = {
  25. xhs: { name: '小红书', color: '#FF2442', short: '红' },
  26. douyin: { name: '抖音', color: '#1A1A1A', short: '抖' },
  27. amazon: { name: 'Amazon', color: '#FF9900', short: 'Az' },
  28. taobao: { name: '淘宝', color: '#FF5000', short: '淘' },
  29. jd: { name: '京东', color: '#E1251B', short: '京' },
  30. tmall: { name: '天猫', color: '#FF0036', short: '猫' },
  31. zhihu: { name: '知乎', color: '#0084FF', short: '知' },
  32. unknown: { name: '其他', color: '#888', short: '—' },
  33. };
  34. const SENTIMENT_LABELS = {
  35. positive: { label: '好评', color: '#2D9C5D', icon: '▲' },
  36. neutral: { label: '中性', color: '#888', icon: '●' },
  37. negative: { label: '差评', color: '#FF6B6B', icon: '▼' },
  38. conflicted:{ label: '矛盾', color: '#FF8C42', icon: '◆' },
  39. };
  40. function esc(s) {
  41. return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  42. }
  43. function fmtLikes(n) {
  44. if (n == null) return '0';
  45. if (n >= 10000) return (n / 10000).toFixed(1).replace(/\.0$/, '') + 'w';
  46. if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
  47. return String(n);
  48. }
  49. function truncate(s, n = 160) {
  50. s = String(s ?? '');
  51. return s.length > n ? s.slice(0, n - 1) + '…' : s;
  52. }
  53. // ================================================================
  54. // 护肝上下文过滤器 · 确保 VOC 真正讨论护肝/保健品
  55. // ================================================================
  56. const LIVER_CONTEXT_RE = /肝|护肝|养肝|保肝|伤肝|转氨酶|脂肪肝|解酒|宿醉|保健品|补充剂|水飞蓟|藤茶|DMY|肝片|肝纯|肝功|奶蓟|护肝片|熬夜.*肝|酒.*肝|liver|supplement|hangover|milk.?thistle|silymarin|dihydromyricetin/i;
  57. // ================================================================
  58. // 英文检测 + 翻译系统(Amazon 护肝品评论)
  59. // ================================================================
  60. function isEnglish(text) {
  61. const t = String(text || '');
  62. if (t.length < 10) return false;
  63. const asciiLetters = t.replace(/[^a-zA-Z]/g, '').length;
  64. return asciiLetters / t.length > 0.45;
  65. }
  66. // 翻译查找表:key = content 前50字符(去空格小写)
  67. const EN_TRANSLATIONS = {
  68. // Ch1.2 positive
  69. 'nogimmicks,nononsense,itseriouslyworks': '真的有效,不是噱头。我有焦虑症,宿醉会引发恐慌发作——恶心、胃不舒服全会加重。用了 Cheers Restore 这些问题都消失了。不是万能药,但比不吃好 80%。药片稍微有点大。',
  70. 'i\'vetriedafewdifferenthangovercuresandt': '试过好几种解酒产品,这个目前效果最好、最明显。之前用 Pre-Alcohol 系列觉得有点用,但这个更强。打游戏喝多了的完美解药,方便好用,还含护肝成分。现在是我的首选——比之前用的还便宜!',
  71. // Ch1.2 negative
  72. 'thecheersprotectdailyliversupportsupple': 'Cheers Protect 日常护肝补充剂让我血压飙升得很厉害。',
  73. 'althoughididnotopenthepackage,it\'sobvio': '虽然没开封,但明显有几颗胶囊在运输中破损了。不能退货!想换一批也没有这个选项。',
  74. // Ch1.3 milk thistle / silymarin
  75. 'decidedtotrythisoneinsteadofaplainmilkt': '想试试这个替代纯水飞蓟产品。说不好有没有用,但 4 周胖了 5 磅。饮食和运动习惯都没变,唯一区别就是加了这个补充剂。喜欢 Now 品牌,但不会再买这款。',
  76. 'checksuppcoapptoeval': '可以用 suppco app 评估保健品。这家公司辅料成分安全,但蒲公英根和水飞蓟的功效没有评测数据。建议看看 Thorne 品牌。',
  77. 'don\'tbefooledbythe7500mgclaim': '别被 7500mg 的宣传忽悠了——这是在误导消费者!标签写"7500mg 含量",让人以为每粒有 7500mg 水飞蓟粉。实际每粒只含约 250mg 水飞蓟提取物(30:1 浓缩),所谓 7500mg 是"草本等效"换算。胶囊实际总重仅约 715mg(含大量填充物)。大多数消费者不懂这个区别。另外还有质量问题:部分药片是碎的。',
  78. // Ch1.3 silymarin (additional)
  79. 'ivebeentakingthisbronsonmilkthistlewithda': '一直在服用 Bronson 水飞蓟+蒲公英根补充剂。我喜欢它把水飞蓟素和蒲公英根结合在一起,都是常见的护肝抗氧化成分。配方简洁,成分清晰。胶囊大小标准,一瓶 360 粒方便长期服用。不含 GMO/麸质/大豆,美国 cGMP 标准生产。服用期间没有不适,效果虽然不明显,但作为日常保健可以接受。总体是一个老牌子的靠谱选择。',
  80. // Ch2.1 水飞蓟 competitor evidence
  81. 'gotmyannualbloodworklastsummermyaltlevel': '去年夏天年度体检,ALT 指标飙升(76→69),正常值上限约 44。最近两三年 ALT 一直偏高但还在正常范围。医生怀疑胆结石,超声检查正常。朋友推荐水飞蓟,2025年8月开始服用,复查血液指标明显改善。',
  82. 'beentakingthisdailyforaboutamonthandahal': '每天服用约一个半月,作为护肝日常方案。配方不错——水飞蓟提取物标准化到 80% 水飞蓟素含量,加上朝鲜蓟和蒲公英辅助。胶囊易吞咽,无胃部不适。NOW Foods 品控口碑好,这款也不例外。',
  83. // Ch4.2 fatty liver
  84. 'iwashesitanttopurchasethisbecauseihaveb': '一开始犹豫要不要买——之前买了很多补充剂都没用,还用过 Ozempic 减肥反弹了。一直感觉疲惫无力。想起 2007 年做过的护肝减重法,就搜了这个产品。每天喝两次,味道不错,21 天不做其他改变就瘦了 16 磅,感觉好多了,浮肿消了,胃部压力也没了。我戒断恢复 5 年半一直觉得身体不好,现在终于感觉正常健康了。强烈推荐!',
  85. // Common DHM / hangover reviews
  86. 'thesearealifesaver!firsttimeusingthesea': '这是救命神器!第一次用是朋友给的,我当时半信半疑,但喝多了第二天真的管用。',
  87. 'honestlyiwentinthinkingt': '说实话一开始觉得是浪费钱,但确实有惊喜。睡前吃几粒,第二天感觉好很多。',
  88. 'thisstuffdefinitelyhelpsmeavoidhangover': '这个确实帮我避免了宿醉,以前一直有这个问题。',
  89. 'iwasskepticalatfirstbutaftermyfirstuse': '一开始半信半疑,但第一次用后就信了。喝了不少酒第二天居然没事。',
  90. 'worksgreatforhangovers': '解酒效果很好,',
  91. 'greatsupplementforliversupport': '很好的护肝补充剂,',
  92. 'didnothelpwithmyhangoveratall': '对我的宿醉完全没用。',
  93. 'noticeabledifferenceinhowafeelafterdrin': '喝酒后感觉明显不同。',
  94. 'mademedizzyandnauseous': '让我头晕恶心。',
  95. 'goodqualityproduct,fastshipping': '产品质量好,发货快。',
  96. 'tasteisawful,hardtoswallow': '味道很差,难以下咽。',
  97. 'besthangoversupplementi\'veeverused': '用过的最好的解酒补充剂。',
  98. 'completewasteofmoney': '完全浪费钱。',
  99. 'helpswithliverdetox': '有助于肝脏排毒。',
  100. };
  101. function getTranslation(content) {
  102. const norm = s => String(s || '').slice(0, 50).replace(/[\s,.!?;:'"()\-]/g, '').toLowerCase();
  103. const key = norm(content);
  104. if (!key || key.length < 8) return null;
  105. for (const [k, v] of Object.entries(EN_TRANSLATIONS)) {
  106. const nk = norm(k);
  107. if (key.startsWith(nk.slice(0, 25)) || nk.startsWith(key.slice(0, 25))) return v;
  108. }
  109. return null;
  110. }
  111. // ================================================================
  112. // VOC 证据卡(核心组件)· 支持 XHS/抖音/Amazon 三平台
  113. // ================================================================
  114. function renderVocCard(item, opts = {}) {
  115. const { accent = BRAND.LIVER_AMBER, showBasis = false, compact = false, maxChars = 240 } = opts;
  116. const pf = PLATFORM_LABELS[item.platform] || PLATFORM_LABELS.unknown;
  117. const sen = SENTIMENT_LABELS[item.sentiment] || SENTIMENT_LABELS.neutral;
  118. const isSeed = (item.source || '').includes('pattern') || (item.source || '').includes('seed');
  119. const tags = Array.isArray(item.tags) ? item.tags.slice(0, 3) : [];
  120. const isAmz = item.platform === 'amazon';
  121. // Amazon 特殊:展示 rating + title
  122. const amzHeader = isAmz && item.rating != null
  123. ? `<div style="display:flex; align-items:center; gap:4px; font-size:0.8rem;">${
  124. Array.from({ length: 5 }).map((_, i) => `<span style="color:${i < item.rating ? BRAND.GOLD : 'var(--text-3)'}">★</span>`).join('')
  125. }${item.verified ? `<span style="margin-left:8px; padding:1px 6px; background:${BRAND.JIANG_GREEN}18; color:${BRAND.JIANG_GREEN}; border-radius:10px; font-size:0.62rem; font-family:var(--font-mono); font-weight:700;">已购</span>` : ''}</div>`
  126. : '';
  127. const amzTitle = isAmz && item.title
  128. ? `<div style="color:${accent}; font-size:${compact ? '0.82rem' : '0.88rem'}; font-weight:700; margin-bottom:2px;">${esc(item.title)}</div>`
  129. : '';
  130. 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'};">
  131. <div style="display:flex; justify-content:space-between; align-items:center; gap:8px; flex-wrap:wrap;">
  132. <div style="display:flex; align-items:center; gap:6px;">
  133. <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>
  134. <span style="color:var(--text-1); font-size:0.78rem; font-weight:600;">${esc(item.nickname || '匿名用户')}</span>
  135. ${item.ip ? `<span style="color:var(--text-3); font-size:0.68rem;">· ${esc(item.ip)}</span>` : ''}
  136. ${item.keyword ? `<span style="color:var(--text-3); font-size:0.66rem; font-family:var(--font-mono);">· ${esc(item.keyword)}</span>` : ''}
  137. </div>
  138. <div style="display:flex; align-items:center; gap:6px;">
  139. <span style="color:${sen.color}; font-size:0.7rem; font-weight:600;">${sen.icon} ${sen.label}</span>
  140. <span style="color:var(--text-3); font-size:0.68rem; font-family:var(--font-mono);">${isAmz ? '👍' : '♥'}${fmtLikes(item.likes)}</span>
  141. ${isSeed ? `<span style="padding:1px 6px; background:${BRAND.LIVER_AMBER}18; color:${BRAND.LIVER_AMBER}; 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>`}
  142. </div>
  143. </div>
  144. ${amzHeader}
  145. ${amzTitle}
  146. ${(() => {
  147. const content = truncate(item.content, maxChars);
  148. if (isEnglish(content)) {
  149. const trans = getTranslation(item.content);
  150. if (trans) {
  151. return `<div style="color:var(--text-1); font-size:${compact ? '0.86rem' : '0.92rem'}; line-height:1.62;">"${esc(trans)}"</div>
  152. <div style="color:var(--text-3); font-size:0.72rem; line-height:1.5; margin-top:4px; padding:6px 8px; background:var(--bg-2); border-radius:3px; font-style:italic;">原文: "${esc(truncate(content, 120))}"</div>
  153. <span style="padding:1px 6px; background:${BRAND.WARM_ORANGE}18; color:${BRAND.WARM_ORANGE}; border-radius:10px; font-size:0.58rem; font-family:var(--font-mono); font-weight:700;">🔄 已翻译</span>`;
  154. } else {
  155. return `<div style="color:var(--text-1); font-size:${compact ? '0.86rem' : '0.92rem'}; line-height:1.62;">"${esc(content)}"</div>
  156. <span style="padding:1px 6px; background:${BRAND.WARM_ORANGE}18; color:${BRAND.WARM_ORANGE}; border-radius:10px; font-size:0.58rem; font-family:var(--font-mono); font-weight:700;">EN 英文原评</span>`;
  157. }
  158. }
  159. return `<div style="color:var(--text-1); font-size:${compact ? '0.86rem' : '0.92rem'}; line-height:1.62;">"${esc(content)}"</div>`;
  160. })()}
  161. ${tags.length ? `<div style="display:flex; flex-wrap:wrap; gap:4px;">
  162. ${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('')}
  163. </div>` : ''}
  164. ${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>` : ''}
  165. </div>`;
  166. }
  167. // ================================================================
  168. // VOC 网格(多条)
  169. // ================================================================
  170. function renderVocGrid(items, opts = {}) {
  171. const { columns = 2, accent = BRAND.LIVER_AMBER, compact = false, maxChars } = opts;
  172. return `<div style="display:grid; grid-template-columns:repeat(${columns}, 1fr); gap:14px;">
  173. ${items.map((it) => renderVocCard(it, { accent, compact, maxChars })).join('')}
  174. </div>`;
  175. }
  176. // ================================================================
  177. // Insight Hero · 大洞察摘要条
  178. // ================================================================
  179. function renderInsightHero({ eyebrow, title, subtitle, color = BRAND.LIVER_AMBER }) {
  180. 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;">
  181. ${eyebrow ? `<div class="text-mono text-xs" style="color:${color}; letter-spacing:0.18em; margin-bottom:10px; font-weight:700;">${esc(eyebrow)}</div>` : ''}
  182. <div style="color:var(--text-1); font-size:1.18rem; line-height:1.72; font-weight:600;">${title}</div>
  183. ${subtitle ? `<div style="color:var(--text-2); font-size:0.92rem; line-height:1.7; margin-top:10px;">${subtitle}</div>` : ''}
  184. </div>`;
  185. }
  186. // ================================================================
  187. // 决策项列表(Takeaways)
  188. // ================================================================
  189. function renderDecisionList(items, opts = {}) {
  190. const { accent = BRAND.JIANG_GREEN, title = 'DECISION · 落地决策项', columns = 2 } = opts;
  191. 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);">
  192. <div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.2em; margin-bottom:14px; font-weight:700;">${esc(title)}</div>
  193. <div style="display:grid; grid-template-columns:repeat(${columns}, 1fr); gap:14px;">
  194. ${items.map((it, i) => `
  195. <div style="display:flex; gap:10px; align-items:flex-start;">
  196. <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>
  197. <div style="color:var(--text-1); font-size:0.9rem; line-height:1.64;">${it}</div>
  198. </div>`).join('')}
  199. </div>
  200. </div>`;
  201. }
  202. // ================================================================
  203. // 理论映射盒(KANO/JTBD/场景三元素)
  204. // ================================================================
  205. function renderTheoryBox({ theory, mapping, accent = BRAND.PURPLE }) {
  206. 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);">
  207. <div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.2em; margin-bottom:12px; font-weight:700;">THEORY · ${esc(theory)}</div>
  208. <div style="color:var(--text-1); font-size:0.95rem; line-height:1.72;">${mapping}</div>
  209. </div>`;
  210. }
  211. // ================================================================
  212. // Tag 频次云(用于某假设的主题分布)
  213. // ================================================================
  214. function renderTagCloud(tagGroups, opts = {}) {
  215. const { accent = BRAND.LIVER_AMBER, top = 15, title = '主题分布' } = opts;
  216. const list = tagGroups.slice(0, top);
  217. if (!list.length) return '';
  218. const maxCount = Math.max(...list.map((g) => g.count));
  219. return `<div style="padding:20px 22px; background:var(--bg-1); border:1px solid var(--border); border-radius:var(--radius-md);">
  220. <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>
  221. <div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;">
  222. ${list.map((g) => {
  223. const ratio = g.count / maxCount;
  224. const fs = (0.72 + ratio * 0.45).toFixed(2);
  225. const bg = Math.round(10 + ratio * 30);
  226. 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>`;
  227. }).join('')}
  228. </div>
  229. </div>`;
  230. }
  231. // ================================================================
  232. // 数据摘要条(某假设下的统计总览)
  233. // ================================================================
  234. function renderStatStrip(stats, opts = {}) {
  235. const { accent = BRAND.LIVER_AMBER } = opts;
  236. 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);">
  237. ${stats.map((s) => `
  238. <div style="text-align:center;">
  239. <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>
  240. <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>
  241. ${s.delta ? `<div style="color:${s.deltaColor || accent}; font-size:0.7rem; margin-top:2px;">${esc(s.delta)}</div>` : ''}
  242. </div>`).join('')}
  243. </div>`;
  244. }
  245. // ================================================================
  246. // 水平条形对比图(SVG)
  247. // ================================================================
  248. function renderBarChart(rows, opts = {}) {
  249. const { accent = BRAND.LIVER_AMBER, height = 260, title = '' } = opts;
  250. const max = Math.max(...rows.map((r) => r.value), 1);
  251. const barH = 28;
  252. const gap = 10;
  253. const padL = 160;
  254. const padR = 80;
  255. const padV = 16;
  256. const totalH = rows.length * (barH + gap) + padV * 2;
  257. const W = 900;
  258. return `<div style="padding:18px 22px; background:var(--bg-1); border:1px solid var(--border); border-radius:var(--radius-md);">
  259. ${title ? `<div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.15em; margin-bottom:12px; font-weight:700;">${esc(title)}</div>` : ''}
  260. <svg viewBox="0 0 ${W} ${totalH}" style="width:100%; height:auto; max-height:${height}px;" preserveAspectRatio="xMidYMid meet">
  261. ${rows.map((r, i) => {
  262. const y = padV + i * (barH + gap);
  263. const w = (r.value / max) * (W - padL - padR);
  264. const color = r.color || accent;
  265. return `
  266. <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>
  267. <rect x="${padL}" y="${y}" width="${w}" height="${barH}" rx="4" fill="${color}" opacity="0.85"/>
  268. <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>
  269. `;
  270. }).join('')}
  271. </svg>
  272. </div>`;
  273. }
  274. // ================================================================
  275. // 2×2 矩阵图(四象限)
  276. // ================================================================
  277. function renderMatrix2x2({ xLabel, yLabel, xLow, xHigh, yLow, yHigh, quadrants, accent = BRAND.LIVER_AMBER }) {
  278. const quadStyle = (q) => ({
  279. color: q?.color || accent,
  280. title: q?.title || '',
  281. note: q?.note || '',
  282. items: q?.items || [],
  283. });
  284. const q1 = quadStyle(quadrants.q1);
  285. const q2 = quadStyle(quadrants.q2);
  286. const q3 = quadStyle(quadrants.q3);
  287. const q4 = quadStyle(quadrants.q4);
  288. const renderQuad = (q) => `
  289. <div style="padding:16px; background:${q.color}08; border:1px solid ${q.color}40; border-radius:6px; height:100%; display:flex; flex-direction:column;">
  290. <div class="text-mono text-xs" style="color:${q.color}; letter-spacing:0.15em; margin-bottom:8px; font-weight:700;">${esc(q.title)}</div>
  291. <ul style="color:var(--text-1); font-size:0.82rem; line-height:1.55; margin:0; padding-left:18px; list-style:disc;">
  292. ${q.items.map((it) => `<li style="margin-bottom:4px;">${it}</li>`).join('')}
  293. </ul>
  294. ${q.note ? `<div style="margin-top:auto; padding-top:10px; color:${q.color}; font-size:0.74rem; font-style:italic;">${q.note}</div>` : ''}
  295. </div>`;
  296. return `<div style="padding:20px 26px; background:var(--bg-1); border:1px solid var(--border); border-radius:var(--radius-md);">
  297. <div style="display:grid; grid-template-columns:52px 1fr 1fr; grid-template-rows:auto 1fr 1fr 40px; gap:12px; height:520px;">
  298. <div style="grid-row:2 / span 2; grid-column:1; display:flex; align-items:center; justify-content:center; transform:rotate(-90deg); white-space:nowrap;">
  299. <span class="text-mono text-xs" style="color:${accent}; letter-spacing:0.2em; font-weight:700;">${esc(yLabel)}</span>
  300. </div>
  301. <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>
  302. <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>
  303. <div style="grid-row:2; grid-column:2;">${renderQuad(q2)}</div>
  304. <div style="grid-row:2; grid-column:3;">${renderQuad(q1)}</div>
  305. <div style="grid-row:3; grid-column:2;">${renderQuad(q3)}</div>
  306. <div style="grid-row:3; grid-column:3;">${renderQuad(q4)}</div>
  307. <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>
  308. <div style="grid-row:4; grid-column:3;"></div>
  309. </div>
  310. </div>`;
  311. }
  312. // ================================================================
  313. // 章节 section head(标题区通用)
  314. // ================================================================
  315. function renderSectionHead({ eyebrow, title, subtitle, color = BRAND.LIVER_AMBER }) {
  316. return `<div class="section-head rise-in">
  317. <div class="section-eyebrow" style="color:${color};">${esc(eyebrow)}</div>
  318. <h2 class="text-h1"><span class="headline-anim">${title}</span></h2>
  319. ${subtitle ? `<p class="section-sub">${subtitle}</p>` : ''}
  320. </div>`;
  321. }
  322. // ================================================================
  323. // 对比表格(Pro vs Con / 本品 vs 竞品)
  324. // ================================================================
  325. function renderCompareTable({ title, headers, rows, accent = BRAND.LIVER_AMBER }) {
  326. return `<div style="padding:18px 22px; background:var(--bg-1); border:1px solid var(--border); border-radius:var(--radius-md);">
  327. ${title ? `<div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.15em; margin-bottom:12px; font-weight:700;">${esc(title)}</div>` : ''}
  328. <table style="width:100%; border-collapse:collapse; font-size:0.88rem;">
  329. <thead>
  330. <tr style="border-bottom:2px solid ${accent}33;">
  331. ${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('')}
  332. </tr>
  333. </thead>
  334. <tbody>
  335. ${rows.map((r, ri) => `<tr style="border-bottom:1px solid var(--border);">
  336. ${r.map((cell, ci) => {
  337. const isHeader = ci === 0;
  338. const content = typeof cell === 'string' ? cell : (cell.html || '');
  339. const cellColor = typeof cell === 'object' && cell.color ? cell.color : (isHeader ? 'var(--text-2)' : 'var(--text-1)');
  340. const weight = typeof cell === 'object' && cell.bold ? '700' : '400';
  341. return `<td style="padding:${ri === rows.length - 1 ? '12px' : '10px'} 6px; color:${cellColor}; text-align:${isHeader ? 'left' : 'center'}; font-weight:${weight};">${content}</td>`;
  342. }).join('')}
  343. </tr>`).join('')}
  344. </tbody>
  345. </table>
  346. </div>`;
  347. }
  348. // ================================================================
  349. // 洞察卡片(纯文字 Finding)
  350. // ================================================================
  351. function renderFindingCard({ icon = '💡', title, body, accent = BRAND.LIVER_AMBER }) {
  352. return `<div style="padding:20px 24px; background:${accent}08; border-left:3px solid ${accent}; border-radius:6px;">
  353. <div style="display:flex; align-items:center; gap:10px; margin-bottom:10px;">
  354. <span style="font-size:1.4rem;">${icon}</span>
  355. <div style="color:${accent}; font-weight:700; font-size:0.96rem;">${esc(title)}</div>
  356. </div>
  357. <div style="color:var(--text-1); font-size:0.88rem; line-height:1.7;">${body}</div>
  358. </div>`;
  359. }
  360. // ================================================================
  361. // 假设标签(H1-H8 glyph)
  362. // ================================================================
  363. function renderHypothesisBadge(h, opts = {}) {
  364. const { accent = BRAND.LIVER_AMBER, size = 'md' } = opts;
  365. const fs = size === 'sm' ? '0.64rem' : size === 'lg' ? '0.82rem' : '0.72rem';
  366. const px = size === 'sm' ? '3px 8px' : size === 'lg' ? '6px 14px' : '4px 10px';
  367. 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>`;
  368. }
  369. module.exports = {
  370. BRAND,
  371. PLATFORM_LABELS,
  372. SENTIMENT_LABELS,
  373. LIVER_CONTEXT_RE,
  374. esc,
  375. fmtLikes,
  376. truncate,
  377. isEnglish,
  378. getTranslation,
  379. renderVocCard,
  380. renderVocGrid,
  381. renderInsightHero,
  382. renderDecisionList,
  383. renderTheoryBox,
  384. renderTagCloud,
  385. renderStatStrip,
  386. renderBarChart,
  387. renderMatrix2x2,
  388. renderSectionHead,
  389. renderCompareTable,
  390. renderFindingCard,
  391. renderHypothesisBadge,
  392. };