403 lines
17 KiB
JavaScript
403 lines
17 KiB
JavaScript
// EwoooC - 後台外殼(Sidebar + Topbar)Nothing × Claude 風格
|
||
// 對應截圖的「商品看板 / 活動看板 / 分析報表 / 廠商缺貨 / AI 助手 / 雲端匯入 / 系統管理」
|
||
|
||
const NAV_GROUPS = [
|
||
{
|
||
title: '監控',
|
||
items: [
|
||
{ id: 'dashboard', label: '商品看板', icon: 'dashboard', code: '01' },
|
||
{ id: 'campaigns', label: '活動看板', icon: 'marketing', code: '02' },
|
||
{
|
||
id: 'analytics', label: '分析報表', icon: 'analytics', code: '03',
|
||
children: [
|
||
{ id: 'analytics-sales', label: '業績分析', code: '03·1' },
|
||
{ id: 'analytics-daily', label: '當日業績', code: '03·2' },
|
||
{ id: 'analytics-growth', label: '成長分析', code: '03·3' },
|
||
{ id: 'analytics-monthly', label: '月份總表', code: '03·4' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
title: '營運',
|
||
items: [
|
||
{ id: 'outofstock',label: '廠商缺貨', icon: 'inventory', code: '04', badge: 48 },
|
||
{ id: 'ai', label: 'AI 助手', icon: 'sparkle', code: '05' },
|
||
{ id: 'cloud', label: '雲端匯入', icon: 'download', code: '06' },
|
||
],
|
||
},
|
||
{
|
||
title: '系統',
|
||
items: [
|
||
{ id: 'settings', label: '系統管理', icon: 'settings', code: '07' },
|
||
],
|
||
},
|
||
];
|
||
|
||
// ===== Sidebar =====
|
||
const Sidebar = ({ active, onNavigate, collapsed, sidebarTheme }) => {
|
||
const isDark = sidebarTheme === 'dark';
|
||
const width = collapsed ? 72 : 240;
|
||
|
||
// 子選單展開狀態:父 id 命中(active 為自己或子項之一)就展開
|
||
const parentOf = React.useMemo(() => {
|
||
const map = {};
|
||
NAV_GROUPS.forEach(g => g.items.forEach(it => {
|
||
if (it.children) it.children.forEach(c => { map[c.id] = it.id; });
|
||
}));
|
||
return map;
|
||
}, []);
|
||
const [openMap, setOpenMap] = React.useState({});
|
||
const isOpen = (id) => openMap[id] ?? (active === id || parentOf[active] === id);
|
||
|
||
// 淺色側邊欄改用米色 paper(不純白),active 用焦糖橘
|
||
const bg = isDark ? '#1f1a14' : 'var(--momo-bg-paper)';
|
||
const textMuted = isDark ? 'rgba(255,247,240,0.55)' : 'var(--momo-text-secondary)';
|
||
const text = isDark ? '#faf7f0' : 'var(--momo-text-primary)';
|
||
const itemHoverBg = isDark ? 'rgba(255,247,240,0.06)' : 'rgba(201,100,66,0.08)';
|
||
const itemActiveBg = isDark ? 'rgba(201,100,66,0.18)' : 'var(--momo-accent)';
|
||
const itemActiveText = isDark ? '#faf7f0' : '#faf7f0';
|
||
const itemActiveBorder = 'var(--momo-accent)';
|
||
const groupTitle = isDark ? 'rgba(255,247,240,0.4)' : 'var(--momo-text-tertiary)';
|
||
const borderC = isDark ? 'rgba(255,247,240,0.08)' : 'var(--momo-border-light)';
|
||
|
||
return (
|
||
<aside style={{
|
||
width, flexShrink: 0,
|
||
background: bg,
|
||
borderRight: `1px solid ${borderC}`,
|
||
display: 'flex', flexDirection: 'column',
|
||
transition: 'width var(--momo-duration-normal) var(--momo-ease-in-out)',
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
zIndex: 2,
|
||
}}>
|
||
{/* Logo */}
|
||
<div style={{
|
||
height: 64, flexShrink: 0,
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
padding: collapsed ? '0' : '0 20px',
|
||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||
borderBottom: `1px solid ${borderC}`,
|
||
}}>
|
||
<div style={{
|
||
width: 32, height: 32,
|
||
borderRadius: 2,
|
||
background: isDark ? '#faf7f0' : 'var(--momo-ink)',
|
||
color: isDark ? 'var(--momo-ink)' : '#faf7f0',
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||
gridTemplateRows: 'repeat(3, 1fr)',
|
||
gap: 1.5, padding: 5,
|
||
flexShrink: 0,
|
||
}}>
|
||
{[1,1,1,1,0,1,1,1,1].map((on, i) => (
|
||
<span key={i} style={{ background: on ? 'currentColor' : 'transparent', borderRadius: '50%' }} />
|
||
))}
|
||
</div>
|
||
{!collapsed && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', lineHeight: 1.05 }}>
|
||
<span className="momo-display" style={{
|
||
fontSize: 18, fontWeight: 700, color: text, letterSpacing: '-0.02em',
|
||
}}>EwoooC</span>
|
||
<span className="momo-label" style={{ color: textMuted }}>價格監控 v2.4</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Nav */}
|
||
<nav className="momo-scroll" style={{ flex: 1, overflowY: 'auto', padding: '12px 8px' }}>
|
||
{NAV_GROUPS.map((group, gi) => (
|
||
<div key={gi} style={{ marginBottom: 4 }}>
|
||
{!collapsed && (
|
||
<div className="momo-label" style={{
|
||
padding: '14px 12px 8px',
|
||
color: groupTitle,
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
}}>
|
||
<span style={{ flex: 1 }}>{group.title}</span>
|
||
<span style={{ height: 1, flex: 2, background: borderC }} />
|
||
</div>
|
||
)}
|
||
{collapsed && gi > 0 && <div style={{ height: 1, background: borderC, margin: '8px 12px' }} />}
|
||
{group.items.map(item => {
|
||
const hasChildren = !!item.children;
|
||
const childActive = hasChildren && item.children.some(c => c.id === active);
|
||
const isActive = active === item.id || (hasChildren && childActive && collapsed);
|
||
const expanded = !collapsed && hasChildren && (isOpen(item.id) || childActive);
|
||
const onClick = () => {
|
||
if (hasChildren && !collapsed) {
|
||
// 父層點擊 = 切換展開;若尚未指向任何子項,順手導向第一個子項
|
||
setOpenMap(m => ({ ...m, [item.id]: !isOpen(item.id) }));
|
||
if (!childActive) onNavigate(item.children[0].id);
|
||
} else {
|
||
onNavigate(item.id);
|
||
}
|
||
};
|
||
return (
|
||
<React.Fragment key={item.id}>
|
||
<button onClick={onClick}
|
||
title={collapsed ? item.label : ''}
|
||
style={{
|
||
width: '100%',
|
||
display: 'flex', alignItems: 'center',
|
||
gap: 12,
|
||
padding: collapsed ? '10px' : '9px 12px',
|
||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||
borderRadius: 4,
|
||
background: isActive ? itemActiveBg : 'transparent',
|
||
color: isActive ? itemActiveText : (isDark ? textMuted : text),
|
||
fontSize: 'var(--momo-font-size-sm)',
|
||
fontWeight: isActive ? 'var(--momo-font-weight-semibold)' : 'var(--momo-font-weight-medium)',
|
||
transition: 'var(--momo-transition-base)',
|
||
marginBottom: 2,
|
||
position: 'relative',
|
||
border: isActive && isDark ? `1px solid ${itemActiveBorder}` : '1px solid transparent',
|
||
}}
|
||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = itemHoverBg; }}
|
||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
|
||
>
|
||
<Icon name={item.icon} size={16} />
|
||
{!collapsed && (
|
||
<>
|
||
<span style={{ flex: 1, textAlign: 'left' }}>{item.label}</span>
|
||
{hasChildren && (
|
||
<Icon name="chevronDown" size={12} style={{
|
||
opacity: 0.55,
|
||
transform: expanded ? 'rotate(0deg)' : 'rotate(-90deg)',
|
||
transition: 'transform 0.15s ease',
|
||
}} />
|
||
)}
|
||
{!hasChildren && item.code && (
|
||
<span className="momo-mono" style={{
|
||
fontSize: 10, opacity: isActive ? 0.8 : 0.45, fontWeight: 600,
|
||
}}>{item.code}</span>
|
||
)}
|
||
{item.badge && (
|
||
<span className="momo-mono" style={{
|
||
background: 'var(--momo-accent)',
|
||
color: '#fff',
|
||
fontSize: 10, fontWeight: 700,
|
||
padding: '1px 6px',
|
||
borderRadius: 2,
|
||
minWidth: 20, textAlign: 'center',
|
||
}}>{item.badge}</span>
|
||
)}
|
||
</>
|
||
)}
|
||
{collapsed && item.badge && (
|
||
<span style={{
|
||
position: 'absolute', top: 6, right: 8,
|
||
width: 8, height: 8, borderRadius: '50%',
|
||
background: 'var(--momo-accent)',
|
||
border: `2px solid ${isDark ? '#1f1a14' : 'var(--momo-bg-paper)'}`,
|
||
}} />
|
||
)}
|
||
</button>
|
||
|
||
{/* 子選單 */}
|
||
{expanded && item.children.map(child => {
|
||
const cActive = active === child.id;
|
||
return (
|
||
<button key={child.id} onClick={() => onNavigate(child.id)}
|
||
style={{
|
||
width: '100%',
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
padding: '7px 12px 7px 36px',
|
||
borderRadius: 4,
|
||
background: cActive ? itemHoverBg : 'transparent',
|
||
color: cActive ? (isDark ? '#faf7f0' : 'var(--momo-accent)') : (isDark ? textMuted : 'var(--momo-text-secondary)'),
|
||
fontSize: 12,
|
||
fontWeight: cActive ? 600 : 500,
|
||
transition: 'var(--momo-transition-base)',
|
||
marginBottom: 1,
|
||
position: 'relative',
|
||
border: '1px solid transparent',
|
||
}}
|
||
onMouseEnter={e => { if (!cActive) e.currentTarget.style.background = itemHoverBg; }}
|
||
onMouseLeave={e => { if (!cActive) e.currentTarget.style.background = 'transparent'; }}
|
||
>
|
||
{/* 縮排線 */}
|
||
<span style={{
|
||
position: 'absolute', left: 22, top: 6, bottom: 6, width: 2,
|
||
background: cActive ? 'var(--momo-accent)' : borderC, borderRadius: 1,
|
||
}} />
|
||
<span style={{ flex: 1, textAlign: 'left' }}>{child.label}</span>
|
||
{child.code && (
|
||
<span className="momo-mono" style={{
|
||
fontSize: 9, opacity: cActive ? 0.8 : 0.45, fontWeight: 600,
|
||
}}>{child.code}</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</nav>
|
||
|
||
{/* Bottom: 爬蟲狀態面板(暖墨底)*/}
|
||
{!collapsed && (
|
||
<div style={{ padding: 12 }}>
|
||
<div style={{
|
||
background: '#1f1a14',
|
||
color: '#faf7f0',
|
||
border: `1px solid rgba(201,100,66,0.35)`,
|
||
borderRadius: 4,
|
||
padding: 14,
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
}}>
|
||
<div style={{ position: 'absolute', inset: 0,
|
||
backgroundImage: 'radial-gradient(circle, rgba(201,100,66,0.12) 1px, transparent 1px)',
|
||
backgroundSize: '6px 6px', pointerEvents: 'none' }} />
|
||
<div style={{ position: 'relative' }}>
|
||
<div className="momo-label" style={{ color: 'rgba(255,247,240,0.55)', marginBottom: 8 }}>
|
||
爬蟲狀態
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--momo-accent)', boxShadow: '0 0 8px var(--momo-accent)', animation: 'momo-pulse-dot 2s infinite' }} />
|
||
<span className="momo-mono" style={{ fontSize: 11, fontWeight: 600 }}>執行中</span>
|
||
</div>
|
||
<div className="momo-mono" style={{ fontSize: 10, color: 'rgba(255,247,240,0.55)', lineHeight: 1.7 }}>
|
||
上次執行 12:54:23<br/>
|
||
掃描筆數 1,569<br/>
|
||
新增筆數 +0
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
);
|
||
};
|
||
|
||
// ===== Topbar =====
|
||
const Topbar = ({ onToggleSidebar, onOpenCmd }) => (
|
||
<header className="momo-topbar" style={{
|
||
height: 64, flexShrink: 0,
|
||
background: 'var(--momo-bg-surface)',
|
||
borderBottom: '1px solid var(--momo-border-light)',
|
||
display: 'flex', alignItems: 'center',
|
||
padding: '0 24px',
|
||
gap: 16,
|
||
zIndex: 1,
|
||
containerType: 'inline-size',
|
||
}}>
|
||
<button onClick={onToggleSidebar}
|
||
style={{
|
||
width: 36, height: 36,
|
||
borderRadius: 4,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
color: 'var(--momo-text-secondary)',
|
||
transition: 'var(--momo-transition-base)',
|
||
}}
|
||
onMouseEnter={e => e.currentTarget.style.background = 'var(--momo-bg-subtle)'}
|
||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||
<Icon name="menu" size={18} />
|
||
</button>
|
||
|
||
<button onClick={onOpenCmd}
|
||
className="momo-search"
|
||
style={{
|
||
flex: 1, maxWidth: 480, minWidth: 0,
|
||
height: 38,
|
||
background: 'var(--momo-bg-paper)',
|
||
border: '1px solid var(--momo-border)',
|
||
borderRadius: 4,
|
||
padding: '0 12px',
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
color: 'var(--momo-text-secondary)',
|
||
fontSize: 'var(--momo-font-size-sm)',
|
||
transition: 'var(--momo-transition-base)',
|
||
overflow: 'hidden',
|
||
}}
|
||
onMouseEnter={e => { e.currentTarget.style.background = '#fff'; }}
|
||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--momo-bg-paper)'; }}
|
||
>
|
||
<Icon name="search" size={15} />
|
||
<span style={{ flex: 1, minWidth: 0, textAlign: 'left', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} className="momo-mono momo-search-text">搜尋商品名稱、編號、品牌⋯</span>
|
||
<kbd className="momo-mono" style={{
|
||
fontSize: 10, fontWeight: 600,
|
||
padding: '2px 6px',
|
||
background: 'var(--momo-accent)',
|
||
color: '#faf7f0',
|
||
borderRadius: 2,
|
||
}}>⌘K</kbd>
|
||
</button>
|
||
|
||
<div style={{ flex: 1 }} />
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
{/* 排程徽章 */}
|
||
<div className="momo-schedule-pill" style={{
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
padding: '6px 12px',
|
||
background: '#1f1a14',
|
||
color: '#faf7f0',
|
||
borderRadius: 4,
|
||
fontSize: 11,
|
||
border: '1px solid rgba(201,100,66,0.35)',
|
||
}}>
|
||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--momo-accent)', boxShadow: '0 0 8px var(--momo-accent)', animation: 'momo-pulse-dot 2s infinite' }} />
|
||
<span className="momo-mono" style={{ color: 'rgba(255,247,240,0.55)' }}>下次排程</span>
|
||
<span className="momo-mono" style={{ fontWeight: 600 }}>13:00</span>
|
||
</div>
|
||
|
||
<button title="發送通知"
|
||
style={{
|
||
width: 36, height: 36, borderRadius: 4,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
color: 'var(--momo-text-secondary)',
|
||
transition: 'var(--momo-transition-base)',
|
||
}}
|
||
onMouseEnter={e => e.currentTarget.style.background = 'var(--momo-bg-subtle)'}
|
||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||
<Icon name="helpCircle" size={18} />
|
||
</button>
|
||
|
||
<button title="通知"
|
||
style={{
|
||
width: 36, height: 36, borderRadius: 4,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
color: 'var(--momo-text-secondary)',
|
||
position: 'relative',
|
||
transition: 'var(--momo-transition-base)',
|
||
}}
|
||
onMouseEnter={e => e.currentTarget.style.background = 'var(--momo-bg-subtle)'}
|
||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||
<Icon name="bell" size={18} />
|
||
<span style={{
|
||
position: 'absolute', top: 8, right: 9,
|
||
width: 8, height: 8, borderRadius: '50%',
|
||
background: 'var(--momo-accent)',
|
||
border: '2px solid var(--momo-bg-surface)',
|
||
}} />
|
||
</button>
|
||
|
||
<div style={{ width: 1, height: 24, background: 'var(--momo-border-light)', margin: '0 4px' }} />
|
||
|
||
<button style={{
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
padding: '4px 10px 4px 4px',
|
||
height: 40,
|
||
borderRadius: 'var(--momo-radius-pill)',
|
||
transition: 'var(--momo-transition-base)',
|
||
}}
|
||
onMouseEnter={e => e.currentTarget.style.background = 'var(--momo-bg-subtle)'}
|
||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||
<Avatar name="蜡蜡佯" size={32} gradient />
|
||
<div className="momo-user-meta" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', lineHeight: 1.2 }}>
|
||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--momo-text-primary)' }}>蜡蜡佯</span>
|
||
<span style={{ fontSize: 11, color: 'var(--momo-text-tertiary)' }}>管理員</span>
|
||
</div>
|
||
<Icon name="chevronDown" size={14} color="var(--momo-text-tertiary)" />
|
||
</button>
|
||
</div>
|
||
</header>
|
||
);
|
||
|
||
Object.assign(window, { Sidebar, Topbar, NAV_GROUPS });
|