const { useState, useLayoutEffect, useRef, useEffect } = React; const cx = (...a) => a.filter(Boolean).join(' '); const TV_COLORS = { g5: '#f2617a', e8s: '#14b8a6', sny: '#5b8def', tcl: '#f59e0b' }; const ACCENTS = [ { name: '蓝', hex: '#0A84FF', rgb: '10, 132, 255' }, { name: '青绿', hex: '#0FB5AE', rgb: '15, 181, 174' }, { name: '紫', hex: '#8B5CF6', rgb: '139, 92, 246' }, { name: '橙', hex: '#FF9F0A', rgb: '255, 159, 10' }, { name: '洋红', hex: '#FF375F', rgb: '255, 55, 95' }, ]; /* ---------- primitives ---------- */ function Seg({ options, value, onChange, block, sm }) { const ref = useRef(null); const [st, setSt] = useState({ opacity: 0 }); useLayoutEffect(() => { const c = ref.current; if (!c) return; const on = c.querySelector('[data-on="1"]'); if (on) setSt({ width: on.offsetWidth, transform: `translateX(${on.offsetLeft}px)`, opacity: 1 }); }, [value, options]); return (
{options.map((o) => { const val = typeof o === 'string' ? o : o.value; const lab = typeof o === 'string' ? o : o.label; const on = val === value; return ( ); })}
); } function Btn({ v = 'glass', size, icon, iconR, block, children, ...rest }) { const isz = size === 'lg' ? 20 : size === 'sm' ? 16 : 18; return ( ); } function SHead({ icon, tone = 'blue', title, right }) { return (
{icon && } {title} {right && {right}}
); } /* ---------- radar ---------- */ function Radar({ ids, size = 268 }) { const c = size / 2, R = size / 2 - 40, keys = ATTR_KEYS, n = keys.length; const ang = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / n; const pt = (i, r) => [c + Math.cos(ang(i)) * r, c + Math.sin(ang(i)) * r]; const ringPoly = (f) => keys.map((_, i) => pt(i, R * f).join(',')).join(' '); const tvs = TVS.filter((t) => ids.includes(t.id)); return ( {[0.25, 0.5, 0.75, 1].map((f) => ( ))} {keys.map((k, i) => { const [x, y] = pt(i, R); return ; })} {tvs.map((t) => { const poly = keys.map((k, i) => pt(i, (R * (t.attrs[k] || 0)) / 100).join(',')).join(' '); return ( ); })} {keys.map((k, i) => { const [x, y] = pt(i, R + 16); return ( {k} ); })} ); } /* ---------- compare screen ---------- */ function scoreTV(tv, scene) { let s = 0; for (const k in scene.w) s += scene.w[k] * (tv.attrs[k] || 0); return s; } function topReasons(tv, scene) { return Object.keys(scene.w) .map((k) => ({ k, c: scene.w[k] * (tv.attrs[k] || 0) })) .sort((a, b) => b.c - a.c).slice(0, 3).map((x) => x.k); } function bestId(metric, ids) { if (!metric.better) return null; let best = null, bv = null; ids.forEach((id) => { const v = metric.vals[id]; if (typeof v !== 'number') return; if (bv === null || (metric.better === 'hi' ? v > bv : v < bv)) { bv = v; best = id; } }); return best; } function CompareScreen() { const [sel, setSel] = useState(['g5', 'e8s', 'sny']); const [dim, setDim] = useState('pic'); const [sc, setSc] = useState('living'); const scene = SCENES.find((s) => s.key === sc); const dimObj = DIMS.find((d) => d.key === dim); const selTvs = TVS.filter((t) => sel.includes(t.id)); const toggle = (id) => setSel((p) => (p.includes(id) ? (p.length > 2 ? p.filter((x) => x !== id) : p) : [...p, id])); const ranked = [...selTvs].map((t) => ({ t, s: scoreTV(t, scene) })).sort((a, b) => b.s - a.s); const winner = ranked[0]; const maxScore = Math.max(...ranked.map((r) => r.s), 1); return (
电视对比看板
勾选候选机型 · 按你家的场景推荐 · 实测数据逐行高亮最优
导出对比 加入清单
{/* 机型选择 */}
{TVS.map((t) => { const on = sel.includes(t.id); return ( ); })}
综合能力雷达 {selTvs.map((t) => ( {t.brand} ))}
({ value: s.key, label: s.label }))} value={sc} onChange={setSc} />
{scene.blurb}
首选 · {scene.label}
{winner.t.name}
{topReasons(winner.t, scene).map((r) => {r}优)}
{ranked.map((r, i) => (
{i + 1} {r.t.name} {Math.round(r.s)}
))}
({ value: d.key, label: d.label }))} value={dim} onChange={setDim} /> 绿色 = 该行最优
{selTvs.map((t) => ( ))} {dimObj.metrics.map((m) => { const best = bestId(m, sel); return ( {selTvs.map((t) => { const v = m.vals[t.id]; const isBest = best === t.id; let inner; if (v === '✓') inner = ; else if (v === '—') inner = ; else inner = v; return ( ); })} ); })}
指标 {t.name}
{m.name} {m.unit && {m.unit}} {inner}{isBest && }
); } /* ---------- data screen ---------- */ const TIER = { done: { label: '已测', tone: 'ok', bar: 'var(--green)' }, todo: { label: '可实测·待补', tone: 'acc', bar: 'var(--accent)' }, ext: { label: '需外部', tone: 'mut', bar: 'var(--ink-4)' }, }; function DataScreen() { const [f, setF] = useState('all'); const counts = { done: 0, todo: 0, ext: 0 }; DATA_ITEMS.forEach((i) => counts[i.tier]++); const maxN = Math.max(...DATA_ITEMS.map((i) => i.n)); const rows = DATA_ITEMS.filter((i) => f === 'all' || i.tier === f).slice().sort((a, b) => b.n - a.n); const tiles = [ { k: 'done', v: counts.done, label: '已测 · 评测已产出', tone: 'ok' }, { k: 'todo', v: counts.todo, label: '可实测 · 待补', tone: 'acc' }, { k: 'ext', v: counts.ext, label: '需外部 · 采集/众包/定义', tone: 'mut' }, ]; return (
原始数据清单
65 个工具去重后 55 项 · 按“能不能自己测”分三桶
{tiles.map((t) => (
{t.label}
{t.v}
))}
{rows.map((it) => { const t = TIER[it.tier]; return ( ); })}
数据项被工具依赖归属
{it.name} {it.n} {it.src || t.label}
); } /* ---------- tools screen ---------- */ function ToolsScreen() { const backN = TOOLS_BACK.reduce((s, b) => s + b.tools.length, 0); return (
工具库
AI Agent 后台调用 {backN} · 用户前台交互 {TOOLS_FRONT.length}
{TOOLS_FRONT.length}} />
{TOOLS_FRONT.map((t) => (
{t.n}
{t.act}
回传 → {t.ret.map((r) => {r})}
))}
{backN}} />
{TOOLS_BACK.map((b) => (
{b.t} {b.d} {b.tools.length}
{b.tools.map((x) => {x})}
))}
); } /* ---------- soon ---------- */ function SoonScreen() { return (
AI 选购助手
对话式入口:你问,Agent 调用后台工具、回结果卡片。
(本原型聚焦「对比看板」样板,此处为占位)
); } /* ---------- app shell ---------- */ const NAV = [ { cap: '工作台', items: [ { id: 'agent', label: 'AI 选购助手', icon: 'broadcast' }, { id: 'compare', label: '对比看板', icon: 'crosshair' }, ] }, { cap: '资料库', items: [ { id: 'data', label: '数据清单', icon: 'database', badge: '55' }, { id: 'tools', label: '工具库', icon: 'layers', badge: '65' }, ] }, ]; const CRUMB = { agent: 'AI 选购助手', compare: '对比看板', data: '数据清单', tools: '工具库' }; const SCREENS = ['agent', 'compare', 'data', 'tools']; function App() { const initScreen = location.hash.slice(1); const [screen, setScreenRaw] = useState(SCREENS.includes(initScreen) ? initScreen : 'compare'); const setScreen = (id) => { setScreenRaw(id); if (history.replaceState) history.replaceState(null, '', '#' + id); }; const [theme, setTheme] = useState(new URLSearchParams(location.search).get('theme') === 'light' ? 'light' : 'dark'); const [accent, setAccent] = useState(ACCENTS[0]); useEffect(() => { const h = document.documentElement; h.setAttribute('data-theme', theme); h.style.setProperty('--accent', accent.hex); h.style.setProperty('--accent-rgb', accent.rgb); h.style.setProperty('--accent-2', `color-mix(in srgb, ${accent.hex} 74%, #fff)`); h.style.setProperty('--on-accent', '#fff'); }, [theme, accent]); return (
电视一站
{CRUMB[screen]}
{ACCENTS.map((a) => ( ))}
实验室在线
{screen === 'compare' && } {screen === 'data' && } {screen === 'tools' && } {screen === 'agent' && }
); } ReactDOM.createRoot(document.getElementById('root')).render();