/* 移动端主页 — 数据库为主体,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 (
);
}
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) => )}
{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 (
关于我们先看电视数据库
数据资产
我们在做全网最大的
电视实测数据库
把亮度、色准、控光、声音、系统体验和真实场景表现,放进同一套可比较、可追溯的测试标准里。
每一条结论,都能回到实测证据
我们相信
买电视,不该靠猜。
{values.map((v, i) => (
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 (
);
}
/* 点击元素 → 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 (
<>
{introVisible && EntryComponent && (
setRevealing(true)}
onHandoff={() => setBrandVisible(true)}
onComplete={completeIntro}
/>
)}
>
);
}
ReactDOM.createRoot(document.getElementById('root')).render();