/* 移动端主页 — 数据库为主体,Agent 买电视助手为入口,小工具页挂前台工具。 四个前台工具在壳内 push 打开,一个手机壳一站到底。 复用 icons.jsx(Icon) / data.jsx(TVS) / images.js(IMG)。深链:?tab=tools · ?tool=about|room|light|inspect|setup */ const { useState, useEffect, useLayoutEffect, useRef } = React; const cx = (...a) => a.filter(Boolean).join(' '); const q = (k) => new URLSearchParams(location.search).get(k); const REDUCE = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); function Phone({ children }) { return (
{children}
); } const ASKS = AG_ASKS; // 建议问题文案 + 剧本路由的单一事实源在 agent.jsx const CMD = '我想买台客厅用的电视'; // 演示固定命令:点对话框打字机输入,点发送进入 Agent 对话 /* 前台只放四个固定工具;它们既可从本页独立打开,也可由 Agent 在对话中调用。 名称/图标以 data.jsx TOOLS_FRONT 为单一事实源,这里只补主页私有的缩略图与色调。 IMG.rev 是可选样片(_mkimg.py 缺图会跳过该键),与 mobile.jsx 同款兜底。 */ const TOOL_CARD_EXTRAS = { room: { img: () => IMG.wall, tone: 'room' }, light: { img: () => IMG.rev || IMG.orig, tone: 'light' }, inspect: { img: () => 'tool-inspect.webp', tone: 'inspect' }, setup: { img: () => 'tool-setup.webp', tone: 'setup' }, }; const LIVE_TOOLS = TOOLS_FRONT.map((t) => ({ ...t, ...TOOL_CARD_EXTRAS[t.k] })); const APPLIANCE_CATEGORIES = [ { k: 'tv', name: '电视', desc: '画质 · 系统 · 音质', meta: `${DB_STATS.inStock} 台实测`, icon: 'tv', tone: 'tv', ready: true }, { k: 'washer', name: '洗衣机', desc: '洗净 · 烘干 · 护理', meta: '即将上线', icon: 'washer', tone: 'washer' }, { k: 'aircon', name: '空调', desc: '制冷 · 能效 · 静音', meta: '即将上线', icon: 'aircon', tone: 'aircon' }, { k: 'fridge', name: '冰箱', desc: '保鲜 · 容量 · 能耗', meta: '即将上线', icon: 'fridge', tone: 'fridge' }, ]; function HomeScreen({ on, openAbout, openDb, openAgent, frozen, brandVisible = true }) { const [ask, setAsk] = useState(''); const [typing, setTyping] = useState(false); const tRef = useRef(null); useEffect(() => () => clearInterval(tRef.current), []); /* 点对话框:打字机敲入固定命令 */ const typeCmd = () => { if (typing || ask === CMD) return; clearInterval(tRef.current); setTyping(true); setAsk(''); let i = 0; tRef.current = setInterval(() => { i++; setAsk(CMD.slice(0, i)); if (i >= CMD.length) { clearInterval(tRef.current); setTyping(false); } }, REDUCE ? 4 : 52); }; /* 点发送:从 Agent 卡片 morph 进对话(空输入时直接用固定命令) */ const send = (e) => { if (typing) return; e.stopPropagation(); openAgent(e.currentTarget.closest('.agent'), ask || CMD); }; return (
先看
{/* AI 选购助手 — 主入口 */}
AI 选购助手
从选到装到售后,直接问
{ask || (typing ? '' : '想买多大?预算多少?点我试试…')} {typing && }
{ASKS.map((a) => )}
{/* 数据资产 — 用户视角:在库量 + 即将入库 */} {/* 四大品类 — 电视已开放,其余品类明确展示建设状态 */}
品类数据库 {APPLIANCE_CATEGORIES.length} 个品类
{REDUCE ? (
{APPLIANCE_CATEGORIES.map((category) => )}
) : (
{/* 壳内层打开时暂停跑马灯,省掉层下每帧重绘 */}
{APPLIANCE_CATEGORIES.map((category) => )}
)}
); } function AboutScreen({ onBack }) { const facts = [ { n: DB_STATS.inStock, u: '台', l: '电视实测在库' }, { n: DB_STATS.brands, u: '个', l: '主流品牌覆盖' }, { n: DB_STATS.incoming, u: '台', l: '即将完成入库' }, ]; const values = [ { icon: 'activity', title: '不是参数搬运', text: '每台电视都进入真实测试环境,记录画质、声音、系统和场景体验。' }, { icon: 'layers', title: '数据真的能横向比', text: '同一套设备、流程和口径,让不同品牌、不同价位回到同一把尺子上。' }, { icon: 'refresh', title: '答案持续生长', text: '新机持续入库,旧机价格与结论同步更新,让判断依据不过期。' }, ]; return (
先看
关于我们先看电视数据库
数据资产

我们在做全网最大的
电视实测数据库

把亮度、色准、控光、声音、系统体验和真实场景表现,放进同一套可比较、可追溯的测试标准里。

每一条结论,都能回到实测证据
{facts.map((f) => (
{f.n}{f.u}
{f.l}
))}
我们相信

买电视,不该靠猜。

{values.map((v, i) => (
{v.title}

{v.text}

0{i + 1}
))}
数据如何诞生

从一台真机,到一个可信答案

{['收样', '实测', '校验', '入库'].map((step, i) => (
{i + 1}{step}
{i < 3 && }
))}

我们的目标不是替你选一台电视,
而是把判断依据交到你手里。

让每一次选择,都有实测证据。
); } function CategoryCard({ category, openDb, duplicate = false }) { const ready = !!category.ready; return ( ); } /* DbScreen(详细机型库)在 dbview.jsx */ function ToolsScreen({ on, openTool }) { return (
小工具
{LIVE_TOOLS.map((t) => ( ))}
Agent 统一工具 {TOOLS_BACK.reduce((n, g) => n + g.tools.length, 0)}
{TOOLS_BACK.map((group) => (
{group.t} {group.tools.length}
{group.tools.map((name) => {name})}
))}
); } /* 壳内层:真几何 morph(对标 c0lor-MAG MorphWindow,"一镜到底")。 壳从来源卡片 rect 弹簧生长到全屏 —— 动真实几何(left/top/width/height),不是 clip-path 揭露; 内容按最终尺寸立即挂载、钉在壳左上随壳移动(挂载成本与逐帧重排都不落在动画帧里),~40ms 就开始淡入; 来源卡片的复制面叠在壳上随壳拉伸、中途溶解 —— t=0 看到的就是卡片本身在长大;阴影独立 bloom。 关闭走短 tween 而非弹簧(弹簧尾部亚像素 settle 会读作"空了一下才回来")。 rect 为 null(深链直开)时从屏幕中央小窗淡入张开。 */ const MORPH_MS = 560, CLOSE_MS = 300; const MORPH_SPRING = 'linear(0, 0.022, 0.0776, 0.1538, 0.241, 0.332, 0.4219, 0.5073, 0.5862, 0.6572, 0.72, 0.7745, 0.8211, 0.8604, 0.893, 0.9198, 0.9414, 0.9586, 0.9721, 0.9825, 0.9903, 0.996, 1.0001, 1.0029, 1.0047, 1.0057, 1.0062, 1.0063, 1.0061, 1.0057, 1.0051, 1.0046, 1.004, 1.0035, 1.0029, 1.0024, 1.002, 1.0016, 1.0013, 1.001, 1.0008, 1.0006, 1.0005, 1.0003, 1.0002, 1.0002, 1.0001, 1, 1)'; const SMOOTH_EASE = 'cubic-bezier(.32,.72,0,1)'; const geoKey = (g, r) => ({ left: g.left + 'px', top: g.top + 'px', width: g.width + 'px', height: g.height + 'px', borderRadius: r + 'px' }); function MorphLayer({ rect, face, closing, children }) { const rootRef = useRef(null), shellRef = useRef(null), shadowRef = useRef(null), faceRef = useRef(null), contentRef = useRef(null); const geoRef = useRef(null); useLayoutEffect(() => { const root = rootRef.current, W = root.offsetWidth, H = root.offsetHeight; const from = rect ? { left: rect.left, top: rect.top, width: W - rect.left - rect.right, height: H - rect.top - rect.bottom } : { left: W * .24, top: H * .42, width: W * .52, height: H * .16 }; /* 半径恒取卡片自身值、按起点高度截断(CSS 的自动 clamp 不作用于 WAAPI 数值插值) */ const r0 = Math.min((face && face.r) || 22, from.height / 2); geoRef.current = { from, r0, W }; const content = contentRef.current; content.style.width = W + 'px'; content.style.height = H + 'px'; /* fit 盒定尺寸必须同步、无条件 —— 曾放在 rAF 回调里,存在漏执行路径: fit 一旦没有显式宽度就会跟随动画中的容器宽,克隆在中间尺寸被压窄 → CJK 换行卡帧。 +2px 余量:rect 是分数像素,差 0.x px 就够换行(零余量布局不可靠) */ if (faceRef.current) { const fit0 = faceRef.current.firstElementChild; fit0.style.width = Math.ceil(from.width + 1) + 'px'; fit0.style.height = Math.ceil(from.height + 1) + 'px'; /* 行内类克隆(胶囊按钮等 fit-content 元素)保持 max-content;块级 / flex 卡片则 锁回来源元素的真实宽度。button 在脱离 grid 后 width:auto 会 shrink-to-fit,品牌卡 会从 163px 缩成约 102px,退出末帧因此先贴左再闪回;显式锁宽消除这次重排。 */ const c0 = fit0.firstElementChild; if (c0) { if (getComputedStyle(c0).display.indexOf('inline') === 0) fit0.style.minWidth = 'max-content'; else c0.style.width = from.width + 'px'; } } /* 深链没有来源卡片,不做伪造的中心卡片 morph。WAAPI 对 left/top 的 fill 在外层舞台 滚动后会继续以初始包含块结算,导致工具页整体上移;直接以全屏稳定态挂载。 */ if (!rect) { Object.assign(shellRef.current.style, { position: 'relative', inset: 'auto', left: 'auto', top: 'auto', right: 'auto', bottom: 'auto', width: '100%', height: '100%', opacity: '1', borderRadius: '0' }); shadowRef.current.style.display = 'none'; content.style.opacity = '1'; content.style.transform = 'none'; return; } if (REDUCE) { Object.assign(shellRef.current.style, { left: '0', top: '0', right: '0', bottom: '0', opacity: '1' }); content.style.opacity = '1'; if (faceRef.current) faceRef.current.style.display = 'none'; return; } /* 双 rAF:内容挂载与首次布局先落地,动画帧里只剩几何插值 */ let r2; const r1 = requestAnimationFrame(() => { r2 = requestAnimationFrame(() => { const frames = [geoKey(from, r0), geoKey({ left: 0, top: 0, width: W, height: H }, 0)]; const spring = { duration: MORPH_MS, easing: MORPH_SPRING, fill: 'both' }; shellRef.current.animate(frames, spring); if (!rect) shellRef.current.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200, easing: 'ease-out', fill: 'both' }); shadowRef.current.animate(frames, spring); shadowRef.current.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, easing: 'ease-out', fill: 'both' }); const f = faceRef.current; if (f) { /* 克隆保持原始尺寸排版、scale 跟随面层宽度(尺寸已在 effect 体内同步设好) */ const fit = f.firstElementChild; f.animate(frames, spring); fit.animate([{ transform: 'scale(1)' }, { transform: `scale(${W / from.width})` }], spring); f.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 240, easing: 'ease-out', fill: 'both' }) .onfinish = () => { f.style.display = 'none'; }; } /* 内容整页随壳等比缩放(不是左上角裁切):迷你页面长成全页,"一镜到底" */ content.animate([{ transform: `scale(${from.width / W})` }, { transform: 'scale(1)' }], spring); content.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200, delay: 40, easing: 'ease-out', fill: 'both' }); }); }); return () => { cancelAnimationFrame(r1); cancelAnimationFrame(r2); }; }, []); /* 关闭:从当前几何(可能仍在张开途中)tween 收回卡片。轮廓要撑到落地 —— 内容与阴影几乎全程在场(壳底色≈页面底色,提早撤掉就会中途"融进背景"), 复制面最后一刻才显形,落点读作"页面收成了那颗按钮"。 */ useEffect(() => { if (!closing || REDUCE) return; const { from, r0, W } = geoRef.current; const shell = shellRef.current, shadow = shadowRef.current, f = faceRef.current, content = contentRef.current; if (!rect) { shell.animate([{ opacity: 1 }, { opacity: 0 }], { duration: CLOSE_MS, easing: SMOOTH_EASE, fill: 'both' }); content.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, easing: 'ease-out', fill: 'both' }); return; } const s = getComputedStyle(shell); const cur = { left: s.left, top: s.top, width: s.width, height: s.height, borderRadius: s.borderRadius }; const end = geoKey(from, r0); const tween = { duration: CLOSE_MS, easing: SMOOTH_EASE, fill: 'both' }; shell.animate([cur, end], tween); /* 壳底色收尾淡出:真按钮是半透明胶囊,壳的不透明底色落在按钮位再消失会读作 "白胶囊沉底变透明"——最后 120ms 壳溶解,落点只剩克隆贴在页面背景上(=真按钮的样子) */ if (rect) shell.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 120, delay: CLOSE_MS - 120, easing: 'ease-out', fill: 'both' }); /* 整页等比缩回卡片宽(从当前 scale 起步,开到一半点关也连续) */ const tf = getComputedStyle(content).transform; const curScale = tf.startsWith('matrix') ? parseFloat(tf.slice(7)) || 1 : 1; content.animate([{ transform: `scale(${curScale})` }, { transform: `scale(${from.width / W})` }], tween); if (!rect) shell.animate([{ opacity: 1 }, { opacity: 0 }], { duration: CLOSE_MS, easing: 'ease-out', fill: 'both' }); shadow.animate([cur, end], tween); shadow.animate([{ opacity: getComputedStyle(shadow).opacity }, { opacity: 0 }], { duration: 220, delay: 80, easing: 'ease-out', fill: 'both' }); if (f) { f.style.display = ''; f.animate([cur, end], tween); const fit = f.firstElementChild; /* 兜底:任何原因导致 fit 没定过尺寸,关闭前补上(否则跟随动画中的容器宽 → 克隆换行) */ if (!fit.style.width) { fit.style.width = Math.ceil(from.width + 1) + 'px'; fit.style.height = Math.ceil(from.height + 1) + 'px'; } const clone = fit.firstElementChild; if (clone && getComputedStyle(clone).display.indexOf('inline') !== 0) clone.style.width = from.width + 'px'; fit.animate([{ transform: `scale(${parseFloat(s.width) / from.width})` }, { transform: 'scale(1)' }], tween); f.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 160, delay: 120, easing: 'ease-out', fill: 'both' }); } content.animate([{ opacity: getComputedStyle(content).opacity }, { opacity: 0 }], { duration: 240, easing: 'ease-out', fill: 'both' }); }, [closing]); const base = rect ? { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } : { left: '24%', top: '42%', right: '24%', bottom: '42%' }; return (
{children}
{face && }
); } /* 点击元素 → MorphLayer 的 rect/face 捕获(相对 host 容器,壳被缩放时按比例还原) */ function morphOrigin(e, hostSel) { const el = e && e.currentTarget; const host = el && el.closest(hostSel); if (!host) return { rect: null, face: null }; const hr = host.getBoundingClientRect(); const cr = el.getBoundingClientRect(); const s = hr.width ? host.offsetWidth / hr.width : 1; return { rect: { top: (cr.top - hr.top) * s, left: (cr.left - hr.left) * s, right: (hr.right - cr.right) * s, bottom: (hr.bottom - cr.bottom) * s }, face: { html: el.outerHTML, r: parseFloat(getComputedStyle(el).borderRadius) || 22 }, }; } Object.assign(window, { MorphLayer, morphOrigin, MORPH_CLOSE_WAIT: CLOSE_MS + 40 }); /* dbview 品牌展开复用 */ function HomeApp({ brandVisible = true }) { const rawTool = q('tool'); const tool0 = rawTool === 'compose' ? 'room' : ['about', 'room', 'light', 'inspect', 'setup', 'db', 'agent'].includes(rawTool) ? rawTool : null; const [tab, setTab] = useState(['room', 'light', 'inspect', 'setup'].includes(tool0) || q('tab') === 'tools' ? 'tools' : 'home'); const [tool, setTool] = useState(tool0); const [agentAsk, setAgentAsk] = useState(CMD); const [rect, setRect] = useState(null); const [face, setFace] = useState(null); const [closing, setClosing] = useState(false); const [morphKey, setMorphKey] = useState(0); /* 每次打开换 key:全新实例,避免"关闭中重开"复用半状态 */ const closeTimer = useRef(null); const originEl = useRef(null); /* morph 期间隐藏来源卡片(c0lor-MAG 同款):克隆才是画面上唯一的"它" */ const syncUrl = (fn) => { const u = new URL(location); fn(u.searchParams); history.replaceState(null, '', u); }; const pick = (t) => { if (t === tab) return; syncUrl((p) => { if (t === 'tools') p.set('tab', 'tools'); else p.delete('tab'); }); setTab(t); }; /* 记录点击卡片相对手机屏的 rect + 卡片复制面,morph 从这里长出 */ const openTool = (k, e) => { clearTimeout(closeTimer.current); /* 关闭动画中重开:撤掉待命的卸载定时器 */ if (originEl.current) originEl.current.style.visibility = ''; const o = morphOrigin(e, '.m-app'); originEl.current = (e && e.currentTarget) || null; if (originEl.current) originEl.current.style.visibility = 'hidden'; setRect(o.rect); setFace(o.face); syncUrl((p) => p.set('tool', k)); setClosing(false); setTool(k); setMorphKey((v) => v + 1); }; const closeTool = () => { /* 连工具私有深链参数一起清(rp/lp/ip/sp/…,同 mobile.jsx Stage.pick 的清单): 只删 tool 的话,下次点卡片重开会把 result 等旧相位状态原样复原。 'm' 不清 —— 与主页真机模式(?m=1/0)共用一个参数名。 */ syncUrl((p) => { p.delete('tool'); ['rp', 'sz', 'lp', 'slots', 'pv', 's', 'g', 'ip', 'sp', 'src', 'scene', 'b', 'dbv'].forEach((k) => p.delete(k)); }); setClosing(true); clearTimeout(closeTimer.current); closeTimer.current = setTimeout(() => { setTool(null); setClosing(false); if (originEl.current) { originEl.current.style.visibility = ''; originEl.current = null; } }, REDUCE ? 60 : CLOSE_MS + 40); }; const openToolsFromDb = () => { syncUrl((p) => { p.delete('tool'); ['b', 'dbv'].forEach((k) => p.delete(k)); p.set('tab', 'tools'); }); setTab('tools'); setClosing(true); clearTimeout(closeTimer.current); closeTimer.current = setTimeout(() => { setTool(null); setClosing(false); if (originEl.current) { originEl.current.style.visibility = ''; originEl.current = null; } }, REDUCE ? 60 : CLOSE_MS + 40); }; return (
openTool('about', e)} openDb={(e) => openTool('db', e)} openAgent={(el, text) => { setAgentAsk(text); openTool('agent', el && { currentTarget: el }); }} frozen={!!tool} brandVisible={brandVisible} /> {tool && ( {tool === 'about' ? : tool === 'db' ? : tool === 'agent' ? : tool === 'light' ? : tool === 'inspect' ? : tool === 'setup' ? : } )}
{[['home', '首页', 'database'], ['tools', '小工具', 'grid']].map(([k, l, ic]) => ( ))}
); } function StageHome({ brandVisible = true }) { return (

电视数据库 · 移动端主页

); } /* 真机模式:小屏(真手机/窄窗)全屏直跑,不套假手机壳;?m=1 强制真机、?m=0 强制评审舞台 */ const REAL = q('m') === '1' || (q('m') !== '0' && window.matchMedia('(max-width: 620px)').matches); if (REAL) document.documentElement.classList.add('real'); function RootExperience() { /* 工具/详情深链保持直达;主页根路径才播放品牌首页。?intro=0 可直接验收落地页。 */ const shouldShowIntro = typeof window.EntryIntro === 'function' && q('intro') !== '0' && !q('tab') && !q('tool'); const [introVisible, setIntroVisible] = useState(shouldShowIntro); const [revealing, setRevealing] = useState(!shouldShowIntro); const [brandVisible, setBrandVisible] = useState(!shouldShowIntro); const EntryComponent = window.EntryIntro; const completeIntro = () => { setIntroVisible(false); setRevealing(true); window.requestAnimationFrame(() => { document.querySelector('.entry-brand-target')?.focus({ preventScroll: true }); }); }; return ( <>
{REAL ?
: }
{introVisible && EntryComponent && ( setRevealing(true)} onHandoff={() => setBrandVisible(true)} onComplete={completeIntro} /> )} ); } ReactDOM.createRoot(document.getElementById('root')).render();