| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268 |
- // 江中猴菇饮 · VOC 报告共享 UI 组件
- // 所有子章渲染器通用:VOC 证据卡 / 理论盒 / 决策项 / Tag 云 / 统计条
- //
- // 设计:不依赖外部图表库,纯 CSS + SVG 实现轻量可视化;
- // 所有 pattern-curated 样本自动加「◈ 模式归纳」徽章以保证透明度。
- const BRAND = {
- JIANG_GREEN: '#2D9C5D',
- HOUGU_TAN: '#E8A87C',
- DEEP_AMBER: '#C96E3F',
- CREAM: '#F5E6D3',
- TECH_BLUE: '#0085CA',
- WARM_ORANGE: '#FF8C42',
- CORAL: '#FF6B6B',
- SAGE: '#87A96B',
- PURPLE: '#8B5CF6',
- ROSE: '#FF4D8D',
- };
- const PLATFORM_LABELS = {
- xhs: { name: '小红书', color: '#FF2442', short: '红' },
- douyin: { name: '抖音', color: '#1A1A1A', short: '抖' },
- taobao: { name: '淘宝', color: '#FF5000', short: '淘' },
- jd: { name: '京东', color: '#E1251B', short: '京' },
- zhihu: { name: '知乎', color: '#0084FF', short: '知' },
- dingxiang: { name: '丁香医生', color: '#00B368', short: '丁' },
- tmall: { name: '天猫', color: '#FF0036', short: '猫' },
- unknown: { name: '其他', color: '#888', short: '—' },
- };
- const SENTIMENT_LABELS = {
- positive: { label: '好评', color: '#2D9C5D', icon: '▲' },
- neutral: { label: '中性', color: '#888', icon: '●' },
- negative: { label: '差评', color: '#FF6B6B', icon: '▼' },
- conflicted:{ label: '矛盾', color: '#FF8C42', icon: '◆' },
- };
- function esc(s) {
- return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
- }
- function fmtLikes(n) {
- if (n == null) return '0';
- if (n >= 10000) return (n / 10000).toFixed(1).replace(/\.0$/, '') + 'w';
- if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
- return String(n);
- }
- // ================================================================
- // VOC 证据卡(单条)· 核心组件
- // ================================================================
- function renderVocCard(item, opts = {}) {
- const { accent = BRAND.HOUGU_TAN, showBasis = false, compact = false } = opts;
- const pf = PLATFORM_LABELS[item.platform] || PLATFORM_LABELS.unknown;
- const sen = SENTIMENT_LABELS[item.sentiment] || SENTIMENT_LABELS.neutral;
- const isSeed = (item.source || '').includes('pattern') || (item.source || '').includes('seed');
- const tags = Array.isArray(item.tags) ? item.tags.slice(0, 3) : [];
- 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'};">
- <div style="display:flex; justify-content:space-between; align-items:center; gap:8px; flex-wrap:wrap;">
- <div style="display:flex; align-items:center; gap:6px;">
- <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>
- <span style="color:var(--text-1); font-size:0.78rem; font-weight:600;">${esc(item.nickname || '匿名')}</span>
- <span style="color:var(--text-3); font-size:0.68rem;">· ${esc(item.ip || '—')}</span>
- </div>
- <div style="display:flex; align-items:center; gap:6px;">
- <span style="color:${sen.color}; font-size:0.7rem; font-weight:600;">${sen.icon} ${sen.label}</span>
- <span style="color:var(--text-3); font-size:0.68rem; font-family:var(--font-mono);">♥${fmtLikes(item.likes)}</span>
- ${isSeed ? `<span style="padding:1px 6px; background:${BRAND.HOUGU_TAN}18; color:${BRAND.HOUGU_TAN}; border-radius:10px; font-size:0.6rem; font-family:var(--font-mono); font-weight:700;" title="${esc(item.basis || '公开讨论模式归纳')}">◈模式</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>`}
- </div>
- </div>
- <div style="color:var(--text-1); font-size:${compact ? '0.86rem' : '0.92rem'}; line-height:1.62;">"${esc(item.content || '')}"</div>
- ${tags.length ? `<div style="display:flex; flex-wrap:wrap; gap:4px;">
- ${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('')}
- </div>` : ''}
- ${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>` : ''}
- </div>`;
- }
- // ================================================================
- // VOC 网格(多条)
- // ================================================================
- function renderVocGrid(items, opts = {}) {
- const { columns = 2, accent = BRAND.HOUGU_TAN, compact = false } = opts;
- return `<div style="display:grid; grid-template-columns:repeat(${columns}, 1fr); gap:14px;">
- ${items.map((it) => renderVocCard(it, { accent, compact })).join('')}
- </div>`;
- }
- // ================================================================
- // Insight Hero · 大洞察摘要条
- // ================================================================
- function renderInsightHero({ eyebrow, title, subtitle, color = BRAND.HOUGU_TAN }) {
- 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;">
- ${eyebrow ? `<div class="text-mono text-xs" style="color:${color}; letter-spacing:0.18em; margin-bottom:10px; font-weight:700;">${esc(eyebrow)}</div>` : ''}
- <div style="color:var(--text-1); font-size:1.18rem; line-height:1.72; font-weight:600;">${title}</div>
- ${subtitle ? `<div style="color:var(--text-2); font-size:0.92rem; line-height:1.7; margin-top:10px;">${subtitle}</div>` : ''}
- </div>`;
- }
- // ================================================================
- // 决策项列表(Takeaways)
- // ================================================================
- function renderDecisionList(items, opts = {}) {
- const { accent = BRAND.JIANG_GREEN, title = 'DECISION · 落地决策项', columns = 2 } = opts;
- 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);">
- <div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.2em; margin-bottom:14px; font-weight:700;">${esc(title)}</div>
- <div style="display:grid; grid-template-columns:repeat(${columns}, 1fr); gap:14px;">
- ${items.map((it, i) => `
- <div style="display:flex; gap:10px; align-items:flex-start;">
- <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>
- <div style="color:var(--text-1); font-size:0.9rem; line-height:1.64;">${it}</div>
- </div>`).join('')}
- </div>
- </div>`;
- }
- // ================================================================
- // 理论映射盒(KANO/JTBD/场景三元素)
- // ================================================================
- function renderTheoryBox({ theory, mapping, accent = BRAND.PURPLE }) {
- 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);">
- <div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.2em; margin-bottom:12px; font-weight:700;">THEORY · ${esc(theory)}</div>
- <div style="color:var(--text-1); font-size:0.95rem; line-height:1.72;">${mapping}</div>
- </div>`;
- }
- // ================================================================
- // Tag 频次云(用于某假设的主题分布)
- // ================================================================
- function renderTagCloud(tagGroups, opts = {}) {
- const { accent = BRAND.HOUGU_TAN, top = 15, title = '主题分布' } = opts;
- const list = tagGroups.slice(0, top);
- if (!list.length) return '';
- const maxCount = Math.max(...list.map((g) => g.count));
- return `<div style="padding:20px 22px; background:var(--bg-1); border:1px solid var(--border); border-radius:var(--radius-md);">
- <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>
- <div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;">
- ${list.map((g) => {
- const ratio = g.count / maxCount;
- const fs = (0.72 + ratio * 0.45).toFixed(2);
- const bg = Math.round(10 + ratio * 30);
- 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>`;
- }).join('')}
- </div>
- </div>`;
- }
- // ================================================================
- // 数据摘要条(某假设下的统计总览)
- // ================================================================
- function renderStatStrip(stats, opts = {}) {
- const { accent = BRAND.HOUGU_TAN } = opts;
- 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);">
- ${stats.map((s) => `
- <div style="text-align:center;">
- <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>
- <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>
- ${s.delta ? `<div style="color:${s.deltaColor || accent}; font-size:0.7rem; margin-top:2px;">${esc(s.delta)}</div>` : ''}
- </div>`).join('')}
- </div>`;
- }
- // ================================================================
- // 水平条形对比图(SVG,适用于多类别对比)
- // ================================================================
- function renderBarChart(rows, opts = {}) {
- const { accent = BRAND.HOUGU_TAN, height = 260, title = '' } = opts;
- const max = Math.max(...rows.map((r) => r.value), 1);
- const barH = 28;
- const gap = 10;
- const padL = 160;
- const padR = 60;
- const padV = 16;
- const totalH = rows.length * (barH + gap) + padV * 2;
- const W = 900;
- return `<div style="padding:18px 22px; background:var(--bg-1); border:1px solid var(--border); border-radius:var(--radius-md);">
- ${title ? `<div class="text-mono text-xs" style="color:${accent}; letter-spacing:0.15em; margin-bottom:12px; font-weight:700;">${esc(title)}</div>` : ''}
- <svg viewBox="0 0 ${W} ${totalH}" style="width:100%; height:auto; max-height:${height}px;" preserveAspectRatio="xMidYMid meet">
- ${rows.map((r, i) => {
- const y = padV + i * (barH + gap);
- const w = (r.value / max) * (W - padL - padR);
- const color = r.color || accent;
- return `
- <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>
- <rect x="${padL}" y="${y}" width="${w}" height="${barH}" rx="4" fill="${color}" opacity="0.85"/>
- <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>
- `;
- }).join('')}
- </svg>
- </div>`;
- }
- // ================================================================
- // 2×2 矩阵图(四象限,用于未满足需求地图等)
- // ================================================================
- function renderMatrix2x2({ xLabel, yLabel, xLow, xHigh, yLow, yHigh, quadrants, accent = BRAND.HOUGU_TAN }) {
- // quadrants: { q1: {title, color, items, note}, q2: ..., q3: ..., q4: ... }
- // q1=右上, q2=左上, q3=左下, q4=右下
- const W = 760, H = 520, pad = 60;
- const cx = W / 2, cy = H / 2;
- const quadStyle = (q) => ({
- color: q?.color || accent,
- title: q?.title || '',
- note: q?.note || '',
- items: q?.items || [],
- });
- const q1 = quadStyle(quadrants.q1);
- const q2 = quadStyle(quadrants.q2);
- const q3 = quadStyle(quadrants.q3);
- const q4 = quadStyle(quadrants.q4);
- const renderQuad = (q, x, y) => `
- <div style="padding:16px; background:${q.color}08; border:1px solid ${q.color}40; border-radius:6px; height:100%; display:flex; flex-direction:column;">
- <div class="text-mono text-xs" style="color:${q.color}; letter-spacing:0.15em; margin-bottom:8px; font-weight:700;">${esc(q.title)}</div>
- <ul style="color:var(--text-1); font-size:0.82rem; line-height:1.55; margin:0; padding-left:18px; list-style:disc;">
- ${q.items.map((it) => `<li style="margin-bottom:4px;">${it}</li>`).join('')}
- </ul>
- ${q.note ? `<div style="margin-top:auto; padding-top:10px; color:${q.color}; font-size:0.74rem; font-style:italic;">${q.note}</div>` : ''}
- </div>`;
- return `<div style="padding:20px 26px; background:var(--bg-1); border:1px solid var(--border); border-radius:var(--radius-md);">
- <div style="display:grid; grid-template-columns:52px 1fr 1fr; grid-template-rows:auto 1fr 1fr 40px; gap:12px; height:520px;">
- <div style="grid-row:2 / span 2; grid-column:1; display:flex; align-items:center; justify-content:center; transform:rotate(-90deg); white-space:nowrap;">
- <span class="text-mono text-xs" style="color:${accent}; letter-spacing:0.2em; font-weight:700;">${esc(yLabel)}</span>
- </div>
- <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>
- <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>
- <div style="grid-row:2; grid-column:2;">${renderQuad(q2, 0, 0)}</div>
- <div style="grid-row:2; grid-column:3;">${renderQuad(q1, 1, 0)}</div>
- <div style="grid-row:3; grid-column:2;">${renderQuad(q3, 0, 1)}</div>
- <div style="grid-row:3; grid-column:3;">${renderQuad(q4, 1, 1)}</div>
- <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>
- <div style="grid-row:4; grid-column:3;"></div>
- </div>
- </div>`;
- }
- // ================================================================
- // 章节 section head(标题区通用)
- // ================================================================
- function renderSectionHead({ eyebrow, title, subtitle, color = BRAND.HOUGU_TAN }) {
- return `<div class="section-head rise-in">
- <div class="section-eyebrow" style="color:${color};">${esc(eyebrow)}</div>
- <h2 class="text-h1"><span class="headline-anim">${title}</span></h2>
- ${subtitle ? `<p class="section-sub">${subtitle}</p>` : ''}
- </div>`;
- }
- module.exports = {
- BRAND,
- PLATFORM_LABELS,
- SENTIMENT_LABELS,
- esc,
- fmtLikes,
- renderVocCard,
- renderVocGrid,
- renderInsightHero,
- renderDecisionList,
- renderTheoryBox,
- renderTagCloud,
- renderStatStrip,
- renderBarChart,
- renderMatrix2x2,
- renderSectionHead,
- };
|