Files
ewoooc/MOMO Pro/app/page-dashboard.jsx

609 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// EwoooC - 商品看板Nothing × Claude 美學安靜、結構化、Mono 為主)
// ===== 編號標籤(呼應 sidebar 的 01/02/03 =====
const SectionLabel = ({ num, children, sub }) => (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 14 }}>
<span className="momo-mono" style={{
fontSize: 12, fontWeight: 700, color: 'var(--momo-text-secondary)',
letterSpacing: '0.08em',
}}>{num}</span>
<span aria-hidden="true" style={{
flex: '0 0 56px', height: 6, alignSelf: 'center',
backgroundImage: 'radial-gradient(circle, var(--momo-text-tertiary) 1px, transparent 1px)',
backgroundSize: '6px 6px',
opacity: 0.5,
}} />
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--momo-text-primary)', letterSpacing: '0.01em' }}>
{children}
</span>
{sub && (
<span className="momo-mono" style={{ fontSize: 11, color: 'var(--momo-text-secondary)', marginLeft: 'auto' }}>
{sub}
</span>
)}
</div>
);
// ===== KPI 大數字6 顆並排,對齊正式環境) =====
const KPIRow = ({ stats, dynamics, onAiClick }) => {
const items = [
{ label: '比價覆蓋率', value: '31.5%', sub: `${(2121).toLocaleString()} / ${stats.total.toLocaleString()} ACTIVE`, tone: 'caramel' },
{ label: 'PChome 領先', value: 784, sub: '平均壓低 +12.0%', tone: 'ink', accent: true },
{ label: 'MOMO 領先', value: 952, sub: 'MOMO 價格低於 PChome' },
{ label: 'AI 挑品', value: 50, sub: '查看 50 項清單', tone: 'honey', interactive: true, action: 'ai' },
{ label: '待比對', value: '4,615', sub: '高優先級盡快比對' },
{ label: '資料新鮮度', value: '已更新', sub: `2026-05-01 06:52`, tone: 'success' },
];
const colorMap = {
caramel: 'var(--momo-warm-caramel)',
honey: 'var(--momo-warm-honey)',
rust: 'var(--momo-warm-rust)',
success: 'var(--momo-success)',
};
return (
<div className="dash-kpis" style={{
display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)',
background: 'var(--momo-bg-surface)',
border: '1px solid var(--momo-border-light)',
borderRadius: 8, overflow: 'hidden',
}}>
{items.map((it, i) => (
<div key={i}
onClick={it.interactive && it.action === 'ai' ? () => onAiClick && onAiClick() : undefined}
role={it.interactive ? 'button' : undefined}
tabIndex={it.interactive ? 0 : undefined}
style={{
padding: '18px 20px',
borderRight: i < items.length - 1 ? '1px solid var(--momo-border-light)' : 'none',
background: it.accent ? 'var(--momo-ink)' : 'transparent',
color: it.accent ? '#faf7f0' : 'inherit',
position: 'relative',
overflow: 'hidden',
cursor: it.interactive ? 'pointer' : 'default',
transition: 'var(--momo-transition-base)',
}}
onMouseEnter={it.interactive ? (e => e.currentTarget.style.background = it.accent ? 'var(--momo-ink-soft)' : 'var(--momo-bg-paper)') : undefined}
onMouseLeave={it.interactive ? (e => e.currentTarget.style.background = it.accent ? 'var(--momo-ink)' : 'transparent') : undefined}>
{/* 點陣背景:深色卡濃一點,淺色卡淡一點當紋理 */}
<div className={it.accent ? 'momo-dot-bg' : 'momo-dot-bg-dark'}
style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
opacity: it.accent ? 0.55 : 0.35,
}} />
<div style={{ position: 'relative' }}>
<div className="momo-mono" style={{
fontSize: 11, fontWeight: 700, letterSpacing: '0.1em',
color: it.accent ? 'rgba(250,247,240,0.65)' : 'var(--momo-text-secondary)',
textTransform: 'uppercase', marginBottom: 12,
}}>
{it.label}
</div>
<div className="momo-mono dash-kpi-num" style={{
fontSize: 32, fontWeight: 700,
color: it.accent ? '#faf7f0' : (colorMap[it.tone] || 'var(--momo-text-primary)'),
letterSpacing: '-0.03em', lineHeight: 1, marginBottom: 10,
}}>
{typeof it.value === 'number' ? it.value.toLocaleString() : it.value}
</div>
<div className="momo-mono" style={{
fontSize: 12,
color: it.accent ? 'rgba(250,247,240,0.75)' : 'var(--momo-text-secondary)',
lineHeight: 1.4,
}}>
{it.sub}{it.interactive && (
<span aria-hidden="true" style={{
marginLeft: 6, opacity: 0.6,
fontSize: 10, fontFamily: 'var(--momo-font-family-mono)',
}}></span>
)}
</div>
</div>
</div>
))}
</div>
);
};
// ===== 焦點 + 排程(雙欄,安靜版) =====
const FocusRow = ({ dynamics, schedule, stats }) => (
<div className="dash-focus" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
{/* 最活躍分類 */}
<div style={{
padding: 16, background: 'var(--momo-bg-surface)',
border: '1px solid var(--momo-border-light)', borderRadius: 8,
}}>
<div className="momo-mono" style={{
fontSize: 11, fontWeight: 700, letterSpacing: '0.1em',
color: 'var(--momo-text-secondary)', textTransform: 'uppercase', marginBottom: 10,
}}>最活躍分類</div>
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--momo-text-primary)', marginBottom: 6, lineHeight: 1.3 }}>
{dynamics.hottestCategory}
</div>
<div className="momo-mono" style={{ fontSize: 12, color: 'var(--momo-text-secondary)' }}>
{dynamics.hottestCount} 件商品變動
</div>
</div>
{/* 最大變動 */}
<div style={{
padding: 16, background: 'var(--momo-bg-surface)',
border: '1px solid var(--momo-border-light)', borderRadius: 8,
}}>
<div className="momo-mono" style={{
fontSize: 11, fontWeight: 700, letterSpacing: '0.1em',
color: 'var(--momo-text-secondary)', textTransform: 'uppercase', marginBottom: 10,
}}>最大變動</div>
<div className="momo-mono" style={{
fontSize: 26, fontWeight: 700, color: 'var(--momo-warm-rust)',
letterSpacing: '-0.02em', lineHeight: 1, marginBottom: 8,
}}>
+${dynamics.biggestChange.amount.toLocaleString()}
</div>
<div style={{
fontSize: 13, color: 'var(--momo-text-secondary)', lineHeight: 1.4,
display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>{dynamics.biggestChange.product}</div>
</div>
{/* 爬蟲排程 */}
<div style={{
padding: 16, background: 'var(--momo-bg-surface)',
border: '1px solid var(--momo-border-light)', borderRadius: 8,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span className="momo-mono" style={{
fontSize: 11, fontWeight: 700, letterSpacing: '0.1em',
color: 'var(--momo-text-secondary)', textTransform: 'uppercase',
}}>爬蟲排程</span>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
fontSize: 11, fontWeight: 700,
color: 'var(--momo-success)',
fontFamily: 'var(--momo-font-family-mono)',
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--momo-success)' }} />
ACTIVE
</span>
</div>
<div className="momo-mono" style={{
fontSize: 22, fontWeight: 700, color: 'var(--momo-text-primary)',
letterSpacing: '-0.02em', lineHeight: 1.1, marginBottom: 8,
}}>{schedule.lastRun}</div>
<div className="momo-mono" style={{ fontSize: 12, color: 'var(--momo-text-secondary)' }}>
掃描 {schedule.scanned.toLocaleString()} · 新增 +{schedule.added}
</div>
</div>
</div>
);
// ===== 篩選列(簡潔版) =====
const FilterBar = ({ search, setSearch, category, setCategory, tab, setTab }) => {
const tabs = [
{ id: 'all', label: '全部' },
{ id: 'ai', label: 'AI 挑品' },
{ id: 'new', label: '新上架' },
{ id: 'up', label: '漲價' },
{ id: 'down', label: '降價' },
{ id: 'off', label: '下架' },
];
return (
<div style={{
padding: '12px 16px', background: 'var(--momo-bg-surface)',
border: '1px solid var(--momo-border-light)', borderRadius: 8,
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
}}>
<div style={{ flex: '1 1 240px', minWidth: 200, maxWidth: 320 }}>
<Input icon="search" placeholder="搜尋商品名稱或品號..." value={search} onChange={e => setSearch(e.target.value)} size="sm" />
</div>
<select value={category} onChange={e => setCategory(e.target.value)} style={{
padding: '7px 12px', border: '1px solid var(--momo-border)',
borderRadius: 4, background: 'var(--momo-bg-surface)',
fontSize: 12, color: 'var(--momo-text-primary)', minWidth: 160,
fontFamily: 'var(--momo-font-family-base)',
}}>
<option value="all">所有分類</option>
<option value="止汗體香">止汗體香</option>
<option value="美妝保養">美妝保養</option>
<option value="保健食品">保健食品</option>
<option value="生活雜貨">生活雜貨</option>
</select>
{/* segmented tabs */}
<div style={{
display: 'inline-flex',
background: 'var(--momo-bg-paper)',
border: '1px solid var(--momo-border-light)',
borderRadius: 4, padding: 2, gap: 0,
}}>
{tabs.map(t => {
const active = tab === t.id;
return (
<button key={t.id} onClick={() => setTab(t.id)} style={{
padding: '5px 12px', fontSize: 12, fontWeight: 600,
background: active ? 'var(--momo-ink)' : 'transparent',
color: active ? '#faf7f0' : 'var(--momo-text-secondary)',
border: 'none', borderRadius: 3,
transition: 'var(--momo-transition-base)',
}}>{t.label}</button>
);
})}
</div>
<div style={{ flex: 1 }} />
<div style={{ display: 'flex', gap: 6 }}>
<Button variant="secondary" size="sm" icon="refresh">更新</Button>
<Button variant="solid" size="sm" icon="bell" style={{ background: 'var(--momo-ink)', color: '#faf7f0', border: 'none' }}>發送通知</Button>
</div>
</div>
);
};
// ===== 漲跌格子 =====
const ChangeCell = ({ value }) => {
if (value == null) return <span style={{ color: 'var(--momo-text-tertiary)', fontFamily: 'var(--momo-font-family-mono)' }}></span>;
if (value === 0) return <span style={{ color: 'var(--momo-text-tertiary)', fontFamily: 'var(--momo-font-family-mono)' }}>0</span>;
const up = value > 0;
return (
<span className="momo-mono" style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
color: up ? 'var(--momo-danger)' : 'var(--momo-success)',
fontWeight: 700,
}}>
<span style={{ fontSize: 9 }}>{up ? '▲' : '▼'}</span>
{up ? '+' : ''}{value.toLocaleString()}
</span>
);
};
// ===== Helper模擬 PChome 價格與警報判斷(接真資料前的 placeholder =====
const mockPchomePrice = (p) => {
const seed = parseInt(String(p.id).slice(-3), 10) || 0;
const offset = ((seed % 11) - 5) * 0.04; // ±20% 區間
const v = Math.round(p.price * (1 + offset));
if (seed % 7 === 0) return null; // 部分商品標「待比對」
return v;
};
const judgeAlert = (momo, pchome) => {
if (pchome == null) return { tone: 'muted', label: '待比對', sub: '無 PChome 資料' };
const diff = momo - pchome;
const pct = (diff / pchome) * 100;
if (Math.abs(pct) < 1) return { tone: 'earth', label: '價格相近', sub: `$${Math.abs(diff)}` };
if (pct < 0) return { tone: 'caramel', label: 'MOMO 較低', sub: `領先 $${Math.abs(diff)} (${pct.toFixed(1)}%)` };
return { tone: 'rust', label: 'MOMO 偏高', sub: `落後 $${diff} (+${pct.toFixed(1)}%)` };
};
// ===== 雙平台價格對比卡(業界主流:主價大 + 對比價小一階 + 差額標示) =====
const PriceCompareCell = ({ momo, pchome }) => {
const lower = pchome != null && pchome < momo;
const higher = pchome != null && pchome > momo;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
{/* MOMO 主價 */}
<div style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
<span className="momo-mono" style={{ fontSize: 10, fontWeight: 700, color: 'var(--momo-warm-caramel)', letterSpacing: '0.08em' }}>MOMO</span>
<span className="momo-mono" style={{
fontSize: 15, fontWeight: 700,
color: higher ? 'var(--momo-warm-rust)' : 'var(--momo-text-primary)',
}}>${momo.toLocaleString()}</span>
</div>
{/* PChome 對比價 */}
<div style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
<span className="momo-mono" style={{ fontSize: 10, fontWeight: 700, color: 'var(--momo-text-tertiary)', letterSpacing: '0.08em' }}>PChome</span>
{pchome != null ? (
<span className="momo-mono" style={{
fontSize: 13, fontWeight: 600,
color: lower ? 'var(--momo-warm-caramel)' : 'var(--momo-text-secondary)',
}}>${pchome.toLocaleString()}</span>
) : (
<span className="momo-mono" style={{ fontSize: 12, color: 'var(--momo-text-tertiary)' }}></span>
)}
</div>
</div>
);
};
const ProductTable = ({ products, total, schedule, onRowClick }) => {
const PER_PAGE = 50;
const [page, setPage] = React.useState(1);
React.useEffect(() => { setPage(1); }, [products.length]);
const totalPages = Math.max(1, Math.ceil(products.length / PER_PAGE));
const visible = products.slice((page - 1) * PER_PAGE, page * PER_PAGE);
return (
<div style={{
background: 'var(--momo-bg-surface)',
border: '1px solid var(--momo-border-light)', borderRadius: 8, overflow: 'hidden',
}}>
<div style={{
padding: '14px 20px', borderBottom: '1px solid var(--momo-border-light)',
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span className="momo-mono" style={{
fontSize: 12, fontWeight: 700, color: 'var(--momo-text-secondary)', letterSpacing: '0.08em',
}}>04</span>
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--momo-text-primary)' }}>商品列表</span>
<span className="momo-mono" style={{ fontSize: 12, color: 'var(--momo-text-secondary)' }}>
{total.toLocaleString()}
</span>
</div>
<span style={{ width: 1, height: 14, background: 'var(--momo-border-light)' }} />
<span className="momo-mono" style={{ fontSize: 12, color: 'var(--momo-text-secondary)', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--momo-success)' }} />
排程 {schedule.lastRun} · 掃描 {schedule.scanned.toLocaleString()} · 新增 +{schedule.added}
</span>
<div style={{ flex: 1 }} />
<Button variant="secondary" size="sm" icon="download">匯出報表</Button>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--momo-font-size-sm)' }}>
<thead>
<tr style={{ background: 'var(--momo-bg-paper)', borderBottom: '1px solid var(--momo-border-light)' }}>
{[
{ label: '分類', w: 120 },
{ label: '商品名稱' },
{ label: '雙平台價格', w: 180, align: 'right' },
{ label: '警報判斷', w: 150 },
{ label: '昨日', w: 90, align: 'right' },
{ label: '本週', w: 90, align: 'right' },
{ label: '更新時間', w: 110, align: 'right' },
].map((h, i) => (
<th key={i} style={{
padding: '11px 16px', textAlign: h.align || 'left', width: h.w,
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap',
color: 'var(--momo-text-secondary)',
fontFamily: 'var(--momo-font-family-mono)',
letterSpacing: '0.08em', textTransform: 'uppercase',
}}>
{h.label}
<span style={{ fontSize: 9, opacity: 0.5, marginLeft: 4 }}></span>
</th>
))}
</tr>
</thead>
<tbody>
{visible.map((p, idx) => {
const pchome = mockPchomePrice(p);
const alert = judgeAlert(p.price, pchome);
return (
<tr key={p.id} onClick={() => onRowClick && onRowClick(p)}
style={{
borderTop: idx === 0 ? 'none' : '1px solid var(--momo-border-light)',
cursor: 'pointer', transition: 'var(--momo-transition-base)',
boxShadow: 'inset 0 0 0 0 var(--momo-warm-caramel)',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'var(--momo-bg-paper)';
e.currentTarget.style.boxShadow = 'inset 3px 0 0 0 var(--momo-warm-caramel)';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.boxShadow = 'inset 0 0 0 0 var(--momo-warm-caramel)';
}}>
<td style={{ padding: '14px 16px' }}>
<Tag tone="earth" size="sm">{p.category}</Tag>
</td>
<td style={{ padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 40, height: 40, borderRadius: 4,
background: 'var(--momo-bg-paper)', border: '1px solid var(--momo-border-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, flexShrink: 0,
}}>{p.emoji}</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{
fontSize: 14, fontWeight: 500, color: 'var(--momo-text-primary)',
lineHeight: 1.4, marginBottom: 3,
display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>{p.name}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--momo-text-secondary)' }}>
<span className="momo-mono">ID · {p.id}</span>
</div>
</div>
</div>
</td>
<td style={{ padding: '14px 16px', textAlign: 'right' }}>
<PriceCompareCell momo={p.price} pchome={pchome} />
</td>
<td style={{ padding: '14px 16px', minWidth: 140 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, alignItems: 'flex-start', whiteSpace: 'nowrap' }}>
<Tag tone={alert.tone} size="sm">{alert.label}</Tag>
<span className="momo-mono" style={{ fontSize: 11, color: 'var(--momo-text-secondary)' }}>{alert.sub}</span>
</div>
</td>
<td style={{ padding: '14px 16px', textAlign: 'right' }}><ChangeCell value={p.yesterdayChange} /></td>
<td style={{ padding: '14px 16px', textAlign: 'right' }}><ChangeCell value={p.weekChange} /></td>
<td style={{ padding: '14px 16px', textAlign: 'right', fontFamily: 'var(--momo-font-family-mono)', fontSize: 12, color: 'var(--momo-text-secondary)' }}>{p.updatedAt}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 分頁器 */}
{products.length > 0 && (
<div style={{
padding: '10px 20px', borderTop: '1px solid var(--momo-border-light)',
display: 'flex', alignItems: 'center', gap: 12,
background: 'var(--momo-bg-paper)',
}}>
<span className="momo-mono" style={{ fontSize: 12, color: 'var(--momo-text-secondary)' }}>
{((page - 1) * PER_PAGE + 1).toLocaleString()}{Math.min(page * PER_PAGE, products.length).toLocaleString()} · {products.length.toLocaleString()}
</span>
<div style={{ flex: 1 }} />
<div style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} style={{
padding: '5px 10px', fontSize: 12, fontWeight: 600,
background: 'var(--momo-bg-surface)', border: '1px solid var(--momo-border-light)',
borderRadius: 4, color: page === 1 ? 'var(--momo-text-tertiary)' : 'var(--momo-text-primary)',
cursor: page === 1 ? 'not-allowed' : 'pointer',
fontFamily: 'var(--momo-font-family-mono)',
}}> 上一頁</button>
<span className="momo-mono" style={{ fontSize: 12, color: 'var(--momo-text-secondary)', padding: '0 8px' }}>
{page} / {totalPages}
</span>
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages} style={{
padding: '5px 10px', fontSize: 12, fontWeight: 600,
background: 'var(--momo-bg-surface)', border: '1px solid var(--momo-border-light)',
borderRadius: 4, color: page === totalPages ? 'var(--momo-text-tertiary)' : 'var(--momo-text-primary)',
cursor: page === totalPages ? 'not-allowed' : 'pointer',
fontFamily: 'var(--momo-font-family-mono)',
}}>下一頁 </button>
</div>
</div>
)}
</div>
);
};
// ===== 比價決策焦點3 欄商品卡片) =====
const FocusCard = ({ title, badge, products, accentTone = 'caramel' }) => {
const accentColor = {
caramel: 'var(--momo-warm-caramel)',
rust: 'var(--momo-warm-rust)',
honey: 'var(--momo-warm-honey)',
}[accentTone] || 'var(--momo-warm-caramel)';
return (
<div style={{
background: 'var(--momo-bg-surface)',
border: '1px solid var(--momo-border-light)',
borderRadius: 8, overflow: 'hidden',
display: 'flex', flexDirection: 'column',
position: 'relative',
}}>
{/* 左上 accent 條 */}
<span aria-hidden="true" style={{
position: 'absolute', top: 0, left: 0, width: 3, height: 36,
background: accentColor,
}} />
{/* 微點陣 */}
<div className="momo-dot-bg-dark" aria-hidden="true" style={{
position: 'absolute', inset: 0, opacity: 0.18, pointerEvents: 'none',
}} />
<div style={{
padding: '12px 16px', borderBottom: '1px solid var(--momo-border-light)',
display: 'flex', alignItems: 'center', gap: 8, background: 'var(--momo-bg-paper)',
position: 'relative',
}}>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--momo-text-primary)' }}>{title}</span>
{badge && <Tag tone={accentTone} size="sm">{badge}</Tag>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', position: 'relative' }}>
{products.map((it, i) => {
const pchome = mockPchomePrice(it);
const a = judgeAlert(it.price, pchome);
return (
<div key={i} style={{
padding: '12px 16px',
borderTop: i === 0 ? 'none' : '1px solid var(--momo-border-light)',
display: 'flex', flexDirection: 'column', gap: 6,
}}>
<div style={{
fontSize: 13, fontWeight: 500, color: 'var(--momo-text-primary)',
lineHeight: 1.4,
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>{it.name}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Tag tone={a.tone} size="xs">{a.label}</Tag>
<span className="momo-mono" style={{ fontSize: 11, color: 'var(--momo-text-secondary)' }}>
MOMO ${it.price.toLocaleString()}{pchome != null && ` · PChome $${pchome.toLocaleString()}`}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Tag tone="ink" size="xs" mono>MOMO {it.id}</Tag>
<Tag tone="muted" size="xs" mono>PChome {pchome != null ? 'OK' : '待比對'}</Tag>
</div>
</div>
);
})}
</div>
</div>
);
};
const FocusFocusRow = ({ products }) => {
const sorted = [...products];
const todayDeals = sorted.filter(p => p.yesterdayChange != null && p.yesterdayChange < 0).slice(0, 3);
const volatile = [...sorted].sort((a,b) => Math.abs(b.weekChange||0) - Math.abs(a.weekChange||0)).slice(0, 3);
const restock = sorted.filter(p => p.isNew || (p.yesterdayChange != null && p.yesterdayChange > 0)).slice(0, 3);
const fb = (arr) => arr.length ? arr : sorted.slice(0, 3);
return (
<div className="dash-focus" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<FocusCard title="今日優惠先銷" badge="降價" products={fb(todayDeals)} accentTone="caramel" />
<FocusCard title="價格波動" badge="重點關注" products={fb(volatile)} accentTone="rust" />
<FocusCard title="補貨補先" badge="新動向" products={fb(restock)} accentTone="honey" />
</div>
);
};
// ===== Page =====
const DashboardPage = ({ density = 'comfortable', onProductClick }) => {
const D = EWOOOC_DATA;
const m = { ...D.monitorStats, stableCount: D.monitorStats.stableCount ?? 869 };
const p = D.priceDynamics;
const [search, setSearch] = React.useState('');
const [category, setCategory] = React.useState('all');
const [tab, setTab] = React.useState('all');
const listRef = React.useRef(null);
const handleAiClick = () => {
setTab('ai');
requestAnimationFrame(() => {
const el = listRef.current;
if (!el) return;
const main = el.closest('main');
if (main) main.scrollTo({ top: el.offsetTop - 16, behavior: 'smooth' });
});
};
const filtered = D.products.filter(x => {
if (category !== 'all' && x.category !== category) return false;
if (search && !x.name.includes(search) && !String(x.id).includes(search)) return false;
if (tab === 'up' && !(x.yesterdayChange > 0)) return false;
if (tab === 'down' && !(x.yesterdayChange < 0 || x.weekChange < 0)) return false;
if (tab === 'new' && !x.isNew) return false;
if (tab === 'ai' && !((parseInt(String(x.id).slice(-1), 10) || 0) % 3 === 0)) return false;
return true;
});
return (
<div className="dash-page" style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* 區塊 1KPI 一排 */}
<section>
<SectionLabel num="01" sub="LIVE · 更新於 12:54">比價監控總覽</SectionLabel>
<KPIRow stats={m} dynamics={p} onAiClick={handleAiClick} />
</section>
{/* 區塊 2焦點數據保留現況 */}
<section>
<SectionLabel num="02" sub="今日">焦點數據</SectionLabel>
<FocusRow dynamics={p} schedule={D.schedule} stats={m} />
</section>
{/* 區塊 3比價決策焦點新增 */}
<section>
<SectionLabel num="03" sub={`${D.products.length} 項候選`}>比價決策焦點</SectionLabel>
<FocusFocusRow products={D.products} />
</section>
{/* 區塊 4篩選 + 列表 */}
<section ref={listRef} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<SectionLabel num="04" sub={`${filtered.length} / ${m.total.toLocaleString()}`}>商品列表</SectionLabel>
<FilterBar
search={search} setSearch={setSearch}
category={category} setCategory={setCategory}
tab={tab} setTab={setTab}
/>
<ProductTable products={filtered} total={m.total} schedule={D.schedule} onRowClick={onProductClick} />
</section>
</div>
);
};
window.DashboardPage = DashboardPage;