609 lines
28 KiB
JavaScript
609 lines
28 KiB
JavaScript
// 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 }}>
|
||
{/* 區塊 1:KPI 一排 */}
|
||
<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;
|