docs(frontend): 建立 V2 視覺基準
@@ -132,6 +132,7 @@
|
||||
- 部署 SOP: `docs/guides/deployment_sop.md`
|
||||
- DevOps 手冊: `docs/guides/devops_handbook.md`
|
||||
- 模組化治理: `docs/guides/modularization_governance.md`
|
||||
- 前端更版路線圖: `docs/guides/frontend_upgrade_roadmap.md`
|
||||
- AI 自動化 Session SOP: `docs/guides/ai_automation_session_sop.md`
|
||||
- AI 競價情報 SOT: `docs/AI_INTELLIGENCE_MODULE_SOT.md`
|
||||
- Agent 角色矩陣: `docs/guides/codex_agent_roles.md`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 本文件定義專案開發的核心準則與不可違反的規範
|
||||
> **建立日期**: 2026-01-12
|
||||
> **當前版本**: V10.31 (CD sync app/config mount drift guard)
|
||||
> **當前版本**: V10.32 (Frontend v2 visual baseline)
|
||||
> **最後更新**: 2026-04-30
|
||||
|
||||
---
|
||||
@@ -108,28 +108,29 @@
|
||||
|
||||
## 第四章:前端 UI/UX 規範
|
||||
|
||||
### 第 11 條:設計系統色彩(絕對禁止違反)
|
||||
- ✅ **主題色**: 紫色漸變 `#667eea` → `#764ba2`
|
||||
- ✅ **漲價**: 紅色 `#dc3545` / `#ff6b6b`
|
||||
- ✅ **降價**: 綠色 `#28a745` / `#51cf66`
|
||||
- ❌ **禁止**: 使用其他顏色作為主題色(除非整體改版)
|
||||
- **理由**: 保持與 Daily Sales 頁面視覺一致性
|
||||
### 第 11 條:前端 V2 視覺基準(絕對禁止違反)
|
||||
- ✅ **正式基準**: 前端相關視覺呈現風格與 UI/UX,以 `MOMO Pro/` 新版原型為主要依據。
|
||||
- ✅ **設計語言**: 米色工作台背景、暖墨文字、焦糖橘 accent、細線框、克制陰影、側邊欄 + 頂部工具列。
|
||||
- ✅ **資料字體**: 數字、商品 ID、時間、價格與表格欄位標籤優先使用等寬字體。
|
||||
- ✅ **漲價**: 使用新版 token 的 danger 色系(紅色)。
|
||||
- ✅ **降價**: 使用新版 token 的 success 色系(綠色)。
|
||||
- ❌ **禁止**: 新增頁面或更版頁面回到舊紫藍漸層主題、彩色表頭、厚重 KPI 卡片或五彩按鈕風格。
|
||||
- **依據**: `MOMO Pro/HANDOFF.md`、`MOMO Pro/design-tokens.css`、`docs/guides/frontend_upgrade_roadmap.md`
|
||||
|
||||
### 第 12 條:響應式設計(強制要求)
|
||||
- ✅ **正確**: 所有頁面必須支援手機版(< 768px)
|
||||
- ✅ **正確**: 使用 Bootstrap 5.3.3 的響應式網格系統
|
||||
- ✅ **正確**: 既有 Flask/Jinja 頁面可沿用 Bootstrap 5.3.3,但新版 UI 優先使用 V2 tokens 與新版 shell 規範。
|
||||
- ✅ **正確**: 表格與圖表必須支援橫向滾動(手機版)
|
||||
- ❌ **禁止**: 僅針對桌面版設計
|
||||
|
||||
### 第 13 條:互動體驗(強制要求)
|
||||
- ✅ **正確**: 所有按鈕必須有 hover 效果(陰影、位移、顏色變化)
|
||||
- ✅ **正確**: 所有卡片必須有 hover 動畫
|
||||
- ✅ **正確**: 使用 `transition: all 0.3s ease` 實現平滑過渡
|
||||
- ✅ **正確**: 所有按鈕、導覽項、表格列與可點擊卡片必須有 hover / active 狀態。
|
||||
- ✅ **正確**: 使用 V2 token 的 transition,保持快速、安靜、克制。
|
||||
- ❌ **禁止**: 靜態無互動的 UI 元素
|
||||
|
||||
### 第 14 條:字體與可讀性(強制要求)
|
||||
- ✅ **正確**: 主要文字顏色 `#2c3e50`(深灰)
|
||||
- ✅ **正確**: 表格表頭使用白色文字 `#fff`
|
||||
- ✅ **正確**: 主要文字使用新版暖墨色 token,避免純黑造成刺眼。
|
||||
- ✅ **正確**: 表格表頭使用米色 paper 底 + 等寬小字標籤;活動 hero 等深色區塊才使用反白文字。
|
||||
- ❌ **禁止**: 使用純黑色 `#000`(刺眼)
|
||||
- ❌ **禁止**: 使用低對比度顏色組合
|
||||
|
||||
|
||||
134
MOMO Pro/EwoooC 後台原型.html
Normal file
@@ -0,0 +1,134 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>EwoooC · 商家後台</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="design-tokens.css?v=3">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang TC", "Microsoft JhengHei", sans-serif; background: #f0eee9; }
|
||||
#root { width: 100vw; height: 100vh; }
|
||||
/* hover 微互動 */
|
||||
.momo-btn:hover:not(:disabled) { filter: brightness(1.05); transform: translateY(-1px); }
|
||||
.momo-btn:active:not(:disabled) { transform: translateY(0); filter: brightness(0.97); }
|
||||
|
||||
/* Topbar responsive — inline 確保即使 design-tokens.css 被快取也生效 */
|
||||
.momo-topbar { container-type: inline-size; }
|
||||
.momo-search-text {
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
min-width: 0;
|
||||
}
|
||||
@container (max-width: 1024px) { .momo-schedule-pill { display: none !important; } }
|
||||
@container (max-width: 880px) { .momo-user-meta { display: none !important; } }
|
||||
@container (max-width: 720px) { .momo-search-text { display: none !important; } }
|
||||
@media (max-width: 1024px) { .momo-schedule-pill { display: none !important; } }
|
||||
@media (max-width: 880px) { .momo-user-meta { display: none !important; } }
|
||||
@media (max-width: 720px) { .momo-search-text { display: none !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||
|
||||
<script type="text/babel" src="app/icons.jsx"></script>
|
||||
<script type="text/babel" src="app/data.jsx"></script>
|
||||
<script type="text/babel" src="app/ui.jsx"></script>
|
||||
<script type="text/babel" src="app/shell.jsx"></script>
|
||||
<script type="text/babel" src="app/page-dashboard.jsx"></script>
|
||||
<script type="text/babel" src="app/page-campaigns.jsx"></script>
|
||||
<script type="text/babel" src="app/page-orders.jsx"></script>
|
||||
<script type="text/babel" src="app/page-products.jsx"></script>
|
||||
<script type="text/babel" src="app/modals.jsx"></script>
|
||||
<script type="text/babel" src="app/main.jsx"></script>
|
||||
|
||||
<script type="text/babel" data-presets="env,react">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function Root() {
|
||||
const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||
return (
|
||||
<div data-screen-label="EwoooC · 後台原型">
|
||||
<MomoApp tweaks={tweaks} setTweak={setTweak} />
|
||||
|
||||
<TweaksPanel title="Tweaks">
|
||||
<TweakSection title="頁面切換">
|
||||
<TweakSelect label="目前頁面" value={tweaks.page}
|
||||
onChange={v => setTweak('page', v)}
|
||||
options={[
|
||||
{ value: 'dashboard', label: '儀表板' },
|
||||
{ value: 'orders', label: '訂單管理' },
|
||||
{ value: 'products', label: '商品管理' },
|
||||
{ value: 'inventory', label: '庫存管理' },
|
||||
{ value: 'members', label: '會員管理' },
|
||||
{ value: 'marketing', label: '行銷活動' },
|
||||
{ value: 'analytics', label: '數據分析' },
|
||||
{ value: 'settings', label: '系統設定' },
|
||||
]}
|
||||
/>
|
||||
</TweakSection>
|
||||
|
||||
<TweakSection title="側邊欄">
|
||||
<TweakToggle label="收合側邊欄" value={tweaks.sidebarCollapsed}
|
||||
onChange={v => setTweak('sidebarCollapsed', v)} />
|
||||
<TweakRadio label="主題" value={tweaks.sidebarTheme}
|
||||
onChange={v => setTweak('sidebarTheme', v)}
|
||||
options={[
|
||||
{ value: 'light', label: '淺色' },
|
||||
{ value: 'dark', label: '深色漸層' },
|
||||
]}
|
||||
/>
|
||||
</TweakSection>
|
||||
|
||||
<TweakSection title="版面">
|
||||
<TweakRadio label="資訊密度" value={tweaks.density}
|
||||
onChange={v => setTweak('density', v)}
|
||||
options={[
|
||||
{ value: 'comfortable', label: '舒適' },
|
||||
{ value: 'compact', label: '緊湊' },
|
||||
]}
|
||||
/>
|
||||
<TweakRadio label="卡片樣式" value={tweaks.cardStyle}
|
||||
onChange={v => setTweak('cardStyle', v)}
|
||||
options={[
|
||||
{ value: 'shadow', label: '陰影' },
|
||||
{ value: 'flat', label: '平面' },
|
||||
{ value: 'bordered', label: '描邊' },
|
||||
]}
|
||||
/>
|
||||
<TweakRadio label="主要按鈕" value={tweaks.buttonStyle}
|
||||
onChange={v => setTweak('buttonStyle', v)}
|
||||
options={[
|
||||
{ value: 'gradient', label: '漸層' },
|
||||
{ value: 'solid', label: '純色' },
|
||||
{ value: 'outline', label: '外框' },
|
||||
]}
|
||||
/>
|
||||
</TweakSection>
|
||||
|
||||
<TweakSection title="提示">
|
||||
<div style={{ fontSize: 11, color: '#6c757d', lineHeight: 1.6, padding: '4px 0' }}>
|
||||
按 <kbd style={{ padding: '1px 5px', background: '#f5f7fa', border: '1px solid #dee2e6', borderRadius: 3, fontFamily: 'monospace', fontSize: 10 }}>⌘K</kbd> 開啟命令面板,<br/>
|
||||
點商品列表的編輯圖示可開啟編輯彈窗。
|
||||
</div>
|
||||
</TweakSection>
|
||||
</TweaksPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<Root />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
419
MOMO Pro/HANDOFF.md
Normal file
@@ -0,0 +1,419 @@
|
||||
# EwoooC 後台 — Codex 開發交接規格 (HANDOFF.md)
|
||||
|
||||
> 這份文件是給 Codex(或任何工程團隊)將 prototype 重建為正式 production 專案的完整規格。
|
||||
> Prototype 是 React 18 + Babel Standalone(瀏覽器即時 transpile),**不是 production build**。
|
||||
> 請依下方規格,重建為正式專案。
|
||||
|
||||
---
|
||||
|
||||
## 0. 專案概覽
|
||||
|
||||
**產品名稱**:EwoooC(內部後台 / 比價爬蟲監控系統)
|
||||
**目標使用者**:採購、行銷、營運人員
|
||||
**核心功能**:商品價格監控、活動看板、廠商缺貨追蹤、AI 助手、雲端匯入
|
||||
**資料規模**:監控 7,000+ 商品,每日掃描多次,含歷史價格走勢
|
||||
|
||||
**設計語言(必須沿用)**:Nothing × Claude 混合風格
|
||||
- 米色基底(warm paper)+ 黑灰主體 + 焦糖橘 accent
|
||||
- JetBrains Mono 用於所有數字、ID、時間戳
|
||||
- Inter 用於介面文字、繁體中文用 Noto Sans TC
|
||||
- 安靜、結構化、靠留白和排版取勝;**避免**漸層厚重 hero、彩色表頭、五彩按鈕
|
||||
|
||||
---
|
||||
|
||||
## 1. 技術棧建議
|
||||
|
||||
| 類別 | 建議 | 備註 |
|
||||
|------|------|------|
|
||||
| Framework | **Next.js 14 + App Router** | SSR、RSC、檔案路由 |
|
||||
| 語言 | **TypeScript** | 全面型別化 |
|
||||
| 樣式 | **Tailwind CSS + CSS Variables** | tokens 走 CSS vars,元件用 Tailwind |
|
||||
| 狀態 / 資料 | **TanStack Query (v5)** | 抓 API、cache、revalidate |
|
||||
| 表單 | React Hook Form + Zod | 表單驗證 |
|
||||
| 圖表 | **Recharts** 或 **Visx** | 價格走勢圖、KPI 趨勢 |
|
||||
| 表格 | **TanStack Table v8** | 排序、篩選、虛擬化 |
|
||||
| Icon | **Lucide React**(替代現有 SVG icons) | 與 prototype 風格相容 |
|
||||
| 字型 | next/font 載入 JetBrains Mono + Inter + Noto Sans TC | self-host |
|
||||
| 部署 | Vercel / 自架 Docker | — |
|
||||
|
||||
---
|
||||
|
||||
## 2. 路由表(Next.js App Router)
|
||||
|
||||
```
|
||||
app/
|
||||
├── layout.tsx # 全域 layout(載字型、tokens、TanStack Provider)
|
||||
├── (auth)/
|
||||
│ └── login/page.tsx # 登入頁
|
||||
├── (admin)/
|
||||
│ ├── layout.tsx # Sidebar + Topbar shell
|
||||
│ ├── dashboard/page.tsx # 商品看板(首頁,重導 /)
|
||||
│ ├── campaigns/
|
||||
│ │ ├── page.tsx # 活動看板(含 tab 切換)
|
||||
│ │ └── [id]/page.tsx # 單一活動詳情(可選)
|
||||
│ ├── products/
|
||||
│ │ ├── page.tsx # 全商品列表
|
||||
│ │ └── [id]/page.tsx # 商品詳情 + 30 天走勢
|
||||
│ ├── analytics/page.tsx # 分析報表
|
||||
│ ├── vendors/page.tsx # 廠商缺貨
|
||||
│ ├── ai-assistant/page.tsx # AI 助手
|
||||
│ ├── cloud-import/page.tsx # 雲端匯入
|
||||
│ └── settings/page.tsx # 系統管理
|
||||
└── api/ # 若用 BFF,放這
|
||||
```
|
||||
|
||||
**Sidebar 對應**:
|
||||
- 監控:商品看板(dashboard)、活動看板(campaigns)、分析報表(analytics)
|
||||
- 營運:廠商缺貨(vendors)、商品列表(products)、訂單(orders)
|
||||
- 智能:AI 助手(ai-assistant)、雲端匯入(cloud-import)
|
||||
- 系統:系統管理(settings)
|
||||
|
||||
---
|
||||
|
||||
## 3. Design Tokens(必讀,直接搬)
|
||||
|
||||
來源:`design-tokens.css`(已在交付包根目錄)
|
||||
|
||||
**核心變數**(節錄,完整見檔案):
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 基底色 */
|
||||
--momo-bg-primary: #f0eee9; /* 米色頁底 */
|
||||
--momo-bg-surface: #ffffff; /* 卡片白 */
|
||||
--momo-bg-paper: #f7f5f0; /* 微暖灰白(hover/header) */
|
||||
--momo-bg-subtle: #ebe8e0;
|
||||
--momo-bg-muted: #e6e2d8;
|
||||
|
||||
/* 主體色 */
|
||||
--momo-ink: #1a1a1a; /* Nothing 黑 */
|
||||
--momo-text-primary: #1a1a1a;
|
||||
--momo-text-secondary:#4a4a4a;
|
||||
--momo-text-tertiary: #8a8a8a;
|
||||
|
||||
/* Accent */
|
||||
--momo-accent: #c96442; /* Claude 焦糖橘 */
|
||||
--momo-accent-hover: #b3553a;
|
||||
|
||||
/* 語意色 */
|
||||
--momo-success: #16a34a;
|
||||
--momo-danger: #dc2626;
|
||||
--momo-warning: #ea580c;
|
||||
--momo-info: #3b5cb8;
|
||||
|
||||
/* 邊線 */
|
||||
--momo-border: #d6d2c8;
|
||||
--momo-border-light: #e6e2d8;
|
||||
|
||||
/* 字型 */
|
||||
--momo-font-family-base: 'Inter', 'Noto Sans TC', -apple-system, sans-serif;
|
||||
--momo-font-family-mono: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
|
||||
/* 字級 */
|
||||
--momo-font-size-xs: 11px;
|
||||
--momo-font-size-sm: 13px;
|
||||
--momo-font-size-base: 14px;
|
||||
--momo-font-size-lg: 16px;
|
||||
|
||||
/* 圓角 */
|
||||
--momo-radius-sm: 3px;
|
||||
--momo-radius-md: 4px;
|
||||
--momo-radius-lg: 8px;
|
||||
--momo-radius-pill: 999px;
|
||||
|
||||
/* 陰影 */
|
||||
--momo-shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
||||
--momo-shadow-md: 0 2px 8px rgba(0,0,0,0.06);
|
||||
|
||||
/* 過場 */
|
||||
--momo-transition-base: all 0.15s ease;
|
||||
}
|
||||
```
|
||||
|
||||
**Tailwind 對應建議**:在 `tailwind.config.ts` 用 `theme.extend.colors`、`fontFamily`、`borderRadius` 直接對應上述變數。
|
||||
|
||||
---
|
||||
|
||||
## 4. 元件清單(重建依據 `app/ui.jsx`)
|
||||
|
||||
| 元件 | Props (核心) | 行為 |
|
||||
|------|--------------|------|
|
||||
| `<Button>` | variant: gradient \| solid \| outline \| ghost \| secondary \| danger; size: sm/md/lg; icon; iconRight; loading | 主色為 accent,secondary 為米底+黑邊 |
|
||||
| `<Badge>` | tone: primary/success/danger/warning/info/secondary; dot | 圓角 pill |
|
||||
| `<Card>` | cardStyle: flat/elevated; padding | flat 為主(border-light + 白底) |
|
||||
| `<Input>` | icon; size; error | 米底外框,focus 時黑邊 |
|
||||
| `<Avatar>` | name; size; gradient | 圓形,預設取首字 |
|
||||
| `<Icon>` | name; size | 統一用 Lucide 替代 |
|
||||
| `<Modal>` | open; onClose; title; size | 中央 modal,漸出 |
|
||||
| `<SectionLabel>` | num; children; sub | **重要** — Dashboard 大區塊用「01 / 02 / 03」編號標籤 |
|
||||
|
||||
**重要規範**:
|
||||
- 所有「數字、ID、時間戳、價格」必須使用 Mono 字型(class `momo-mono` 或 Tailwind `font-mono`)
|
||||
- 表頭 label 用 Mono + uppercase + letter-spacing 0.08em(看起來像終端機)
|
||||
- 表格不要用紫色漸層 header(已棄用),改用米色 paper 底 + Mono 小字 label
|
||||
|
||||
---
|
||||
|
||||
## 5. 頁面規格
|
||||
|
||||
### 5.1 商品看板 / Dashboard(`/dashboard`)
|
||||
|
||||
**參考**:`app/page-dashboard.jsx`
|
||||
|
||||
**佈局**(從上到下):
|
||||
|
||||
1. **區塊 01:監控總覽** — 一排 4 個 KPI(horizontal divider 分隔),第二顆「今日變動」用黑底反白
|
||||
- 監控總數 / 今日變動(accent)/ 漲價(danger)/ 降價(success)
|
||||
- 大字 44px Mono,下方 sub 11px
|
||||
|
||||
2. **區塊 02:焦點數據** — 三欄並排
|
||||
- 最活躍分類 / 最大變動(紅色 +金額)/ 爬蟲排程(綠點 ACTIVE + 上次執行時間)
|
||||
|
||||
3. **區塊 03:商品列表**
|
||||
- 篩選列:搜尋 + 分類 select + segmented tabs(全部/新上架/漲價/降價/下架)+ 更新/發送通知按鈕
|
||||
- 表格:分類 / 商品名稱(含 emoji 縮圖 + ID)/ 當天價格 / 昨日漲跌 / 週漲跌 / 更新時間 / 上架時間
|
||||
- 表頭:米色 paper 底,Mono uppercase 小字
|
||||
- 漲跌格子:紅色▲ / 綠色▼ + Mono 數字
|
||||
|
||||
**互動**:
|
||||
- 點 row → 開 Modal 顯示 30 天價格走勢(用 Recharts LineChart)
|
||||
- 篩選即時觸發(client-side 過濾或 query params)
|
||||
|
||||
---
|
||||
|
||||
### 5.2 活動看板(`/campaigns`)
|
||||
|
||||
**參考**:`app/page-campaigns.jsx`
|
||||
|
||||
**佈局**:
|
||||
|
||||
1. **活動切換 segmented tabs**(限時搶購 / 1.1狂歡 / 母親節 / 520 / 勞動節)
|
||||
- 每顆 tab 含 icon + 名稱 + 商品數 pill
|
||||
|
||||
2. **Hero 雙欄**
|
||||
- 左 2/3:活動主資訊卡(**漸層背景,色相依活動切換**)
|
||||
- flash: 紅橘漸層 / festival: 紫 / mothers: 玫紅 / valentine: 情人紅 / laborday: 藍
|
||||
- 點陣裝飾背景 + 大圖示 watermark
|
||||
- CAMPAIGN 標籤 + ID + 大標題 + meta(時段 / 最後更新 / 商品總數)+ 操作按鈕
|
||||
- 右 1/3:活動數據 KPI(上架/新品/漲價/降價 2x2)+ 底部排程 footer
|
||||
|
||||
3. **時段時間軸**(僅限時搶購顯示)
|
||||
- 24h 條狀圖,當前時段顯示 NOW 標籤
|
||||
- bar 高度依商品數,accent 色為當前
|
||||
|
||||
4. **分類 chips**(festival / mothers / valentine / laborday)
|
||||
- 橫向 wrap,每個 chip 有名稱 + count pill,可點選
|
||||
|
||||
5. **商品列表**
|
||||
- 同 Dashboard 表格樣式,但欄位:分類 / 商品資訊 / 價格 / 倒數組數 (限時) 或狀態
|
||||
|
||||
---
|
||||
|
||||
### 5.3 商品列表(`/products`)
|
||||
|
||||
**參考**:`app/page-products.jsx`
|
||||
全商品 CRUD + 篩選 + 編輯 modal。可重用 Dashboard 的 ProductTable 元件。
|
||||
|
||||
---
|
||||
|
||||
### 5.4 廠商缺貨(`/vendors`)
|
||||
|
||||
簡單表格:vendor / count / lastSeen,資料來源 `EWOOOC_DATA.outOfStock`。
|
||||
|
||||
---
|
||||
|
||||
### 5.5 其他頁面
|
||||
|
||||
- 分析報表:圖表為主(Recharts),尚未在 prototype 完整定義 → 可先做 placeholder
|
||||
- AI 助手 / 雲端匯入 / 系統管理:prototype 為佔位 → 與 PM 確認需求
|
||||
|
||||
---
|
||||
|
||||
## 6. 資料模型 / API 規格
|
||||
|
||||
### 6.1 Mock 資料來源
|
||||
|
||||
`app/data.jsx` 中 `EWOOOC_DATA` 為所有假資料。**重建時請替換為 API 呼叫**。
|
||||
|
||||
### 6.2 建議的 OpenAPI Endpoints
|
||||
|
||||
```yaml
|
||||
GET /api/dashboard/summary # 監控總覽 + 今日變動
|
||||
GET /api/dashboard/focus # 最活躍分類 / 最大變動 / 排程狀態
|
||||
GET /api/products?category=&q=&tab=&page= # 商品列表(含篩選)
|
||||
GET /api/products/{id} # 商品詳情
|
||||
GET /api/products/{id}/price-history?days=30 # 30 天價格走勢
|
||||
POST /api/products/{id}/notify # 發送通知
|
||||
|
||||
GET /api/campaigns # 所有活動清單
|
||||
GET /api/campaigns/{id} # 單一活動詳情(含 timeSlots, categories, products, stats)
|
||||
POST /api/campaigns/{id}/refresh # 手動觸發爬蟲
|
||||
POST /api/campaigns/{id}/notify
|
||||
|
||||
GET /api/vendors/out-of-stock # 廠商缺貨
|
||||
GET /api/schedule/status # 爬蟲排程狀態
|
||||
```
|
||||
|
||||
### 6.3 Schema 範例(TypeScript)
|
||||
|
||||
```ts
|
||||
interface Product {
|
||||
id: string;
|
||||
category: string;
|
||||
name: string;
|
||||
emoji?: string; // 暫時佔位,正式版改為 imageUrl
|
||||
imageUrl?: string;
|
||||
price: number;
|
||||
yesterdayChange: number | null;
|
||||
weekChange: number | null;
|
||||
updatedAt: string; // ISO
|
||||
listedAt: string; // ISO
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
interface DashboardSummary {
|
||||
monitor: { total: number; todayAdded: number; weekGrowth: number; stableCount: number };
|
||||
dynamics: {
|
||||
priceUp: number; priceDown: number; delisted: number;
|
||||
avgUp: number; avgDown: number;
|
||||
activity: number; activeCount: number;
|
||||
hottestCategory: string; hottestCount: number;
|
||||
biggestChange: { product: string; amount: number };
|
||||
};
|
||||
schedule: { lastRun: string; scanned: number; added: number; status: 'success' | 'running' | 'failed' };
|
||||
}
|
||||
|
||||
interface Campaign {
|
||||
id: 'flash' | 'festival' | 'mothers' | 'valentine' | 'laborday';
|
||||
name: string;
|
||||
icon: string;
|
||||
time: string;
|
||||
lastUpdate: string;
|
||||
total: number;
|
||||
schedule: { lastRun: string; anomalies: number; status: string };
|
||||
timeSlots?: { time: string; count: number; active?: boolean }[];
|
||||
categories?: { name: string; count: number; active?: boolean }[];
|
||||
stats: { listed: number; new: number; up: number; down: number; delisted: number };
|
||||
products: CampaignProduct[];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 互動行為清單
|
||||
|
||||
| 行為 | 描述 |
|
||||
|------|------|
|
||||
| Sidebar 折疊 | 寬度從 240 → 64,icon-only |
|
||||
| Topbar 搜尋 | ⌘K / Ctrl+K 開啟全域搜尋 |
|
||||
| 表格 row click | 開啟商品詳情 Modal(含 30 天走勢圖) |
|
||||
| 漲跌欄排序 | 點欄頭 ↕ 切換 asc/desc |
|
||||
| 篩選 tabs | 即時過濾,URL 同步 query string |
|
||||
| 匯出報表 | 後端產 CSV 後 download |
|
||||
| 發送通知 | trigger backend webhook,回應 toast |
|
||||
| 活動切換 tab | URL `?tab=flash` 同步 |
|
||||
| Tweaks 面板 | **正式版可移除**(prototype 用) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 字型與資源
|
||||
|
||||
**Self-host 字型**:
|
||||
```ts
|
||||
// app/layout.tsx
|
||||
import { Inter, JetBrains_Mono, Noto_Sans_TC } from 'next/font/google';
|
||||
// 對應 CSS variable
|
||||
```
|
||||
|
||||
**Icon**:用 [Lucide](https://lucide.dev/) 替換現有 SVG,名稱對照:
|
||||
- dashboard → `LayoutDashboard`
|
||||
- megaphone → `Megaphone`
|
||||
- chart → `BarChart3`
|
||||
- box / package → `Package`
|
||||
- robot / ai → `Bot`
|
||||
- cloud → `Cloud`
|
||||
- settings → `Settings`
|
||||
- search → `Search`
|
||||
- bell → `Bell`
|
||||
- refresh → `RefreshCw`
|
||||
- copy → `Copy`
|
||||
- download → `Download`
|
||||
- trendUp → `TrendingUp`
|
||||
- arrowUp / arrowDown → `ArrowUp` / `ArrowDown`
|
||||
- clock → `Clock`
|
||||
- tag → `Tag`
|
||||
|
||||
---
|
||||
|
||||
## 9. 必須遵守的視覺紀律 ⚠️
|
||||
|
||||
❌ **不要**:
|
||||
- 不要回到原本的紫色漸層 + emoji 圖示風格(舊 momo 後台)
|
||||
- 不要在表頭用紫色漸層
|
||||
- 不要用五彩繽紛的按鈕
|
||||
- 不要在 KPI 卡用厚重藍色 hero 漸層
|
||||
- 不要使用 Inter 以外的 sans-serif 主體(避開 Roboto, system-ui)
|
||||
- 數字、ID、時間**不要**用 sans-serif
|
||||
|
||||
✅ **要**:
|
||||
- 米色底 + 黑灰主體 + 焦糖橘 accent
|
||||
- 區塊用「01 / 02 / 03」編號 + uppercase Mono label
|
||||
- KPI 大數字 44px+ Mono,靠 horizontal divider 分隔,不要每顆都包卡片
|
||||
- 活動 hero 才用漸層(且色相依活動主題)
|
||||
- 表格安靜,靠 Mono 字體和留白做出層次
|
||||
- 邊框細且淺(border-light),陰影克制
|
||||
|
||||
---
|
||||
|
||||
## 10. 開發里程碑建議
|
||||
|
||||
1. **Week 1**:架構 + tokens + 共用元件(Button/Card/Badge/Input/Modal/Icon/SectionLabel)
|
||||
2. **Week 2**:Sidebar + Topbar shell + 路由 + Auth
|
||||
3. **Week 3**:Dashboard(KPIRow / FocusRow / ProductTable)+ API 串接
|
||||
4. **Week 4**:Campaigns(5 個 tab + Hero + Timeline + Products)
|
||||
5. **Week 5**:Products 詳情 + 30 天走勢圖 modal
|
||||
6. **Week 6**:Vendors / Analytics / 其他頁面 + 微調 + QA
|
||||
|
||||
---
|
||||
|
||||
## 11. 交付包檔案清單
|
||||
|
||||
```
|
||||
ewoooc-handoff/
|
||||
├── HANDOFF.md # ← 本文件
|
||||
├── EwoooC 後台原型.html # 入口(瀏覽器直開)
|
||||
├── design-tokens.css # 所有 CSS 變數
|
||||
├── data.jsx # (根目錄舊版,可忽略)
|
||||
├── design-canvas.jsx # (prototype 工具,可忽略)
|
||||
├── tweaks-panel.jsx # (prototype 工具,可忽略)
|
||||
└── app/
|
||||
├── data.jsx # 假資料(轉為 API schema 參考)
|
||||
├── icons.jsx # SVG icon 定義(轉為 Lucide)
|
||||
├── ui.jsx # 共用元件(轉為 TS components)
|
||||
├── shell.jsx # Sidebar + Topbar
|
||||
├── main.jsx # 路由分派 + Tweaks
|
||||
├── modals.jsx # 商品詳情 / 編輯 modal
|
||||
├── page-dashboard.jsx # 商品看板
|
||||
├── page-campaigns.jsx # 活動看板
|
||||
├── page-products.jsx # 商品列表
|
||||
└── page-orders.jsx # 訂單(範例頁)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 與 PM / Designer 確認清單
|
||||
|
||||
開發前請與 PM / Designer 對齊:
|
||||
- [ ] 真實 API endpoint 文件
|
||||
- [ ] 認證 / 權限模型(RBAC?單一管理者?)
|
||||
- [ ] 商品 emoji 是否替換為真實圖片(CDN?)
|
||||
- [ ] Analytics 頁面圖表規格
|
||||
- [ ] AI 助手 / 雲端匯入功能定義
|
||||
- [ ] 多語系需求(目前只有繁中)
|
||||
- [ ] 部署環境(Vercel / 自架 / on-prem)
|
||||
- [ ] 監控與錯誤追蹤(Sentry?)
|
||||
|
||||
---
|
||||
|
||||
> 有任何視覺或行為不確定的地方,**以 prototype 的呈現為準**。
|
||||
> Prototype 路徑:開啟 `EwoooC 後台原型.html` 直接看 live demo,並用右下角 Tweaks 面板觀察可調整的設計變數。
|
||||
223
MOMO Pro/app/data.jsx
Normal file
@@ -0,0 +1,223 @@
|
||||
// EwoooC - 比價爬蟲系統資料
|
||||
|
||||
const EWOOOC_DATA = {
|
||||
// 商品監控總覽(對應截圖的「商品監控概況」)
|
||||
monitorStats: {
|
||||
total: 7057,
|
||||
todayAdded: 4,
|
||||
weekGrowth: 92,
|
||||
weekChanged: 850,
|
||||
stableCount: 869,
|
||||
},
|
||||
|
||||
// 今日價格動態
|
||||
priceDynamics: {
|
||||
priceUp: 39,
|
||||
priceDown: 60,
|
||||
delisted: 0,
|
||||
avgUp: 244,
|
||||
avgDown: -131,
|
||||
activity: 1.4,
|
||||
activeCount: 99,
|
||||
hottestCategory: '私密保養推薦品牌',
|
||||
hottestCount: 17,
|
||||
biggestChange: { product: '【LA MER 海洋拉娜】緊緻精華霜 ...', amount: 1200 },
|
||||
},
|
||||
|
||||
// 排程狀態
|
||||
schedule: {
|
||||
lastRun: '12:54:23',
|
||||
scanned: 1569,
|
||||
added: 0,
|
||||
status: 'success',
|
||||
},
|
||||
|
||||
// 商品列表(對應截圖商品列表)
|
||||
products: [
|
||||
{ id: '5465944', category: '止汗體香', name: '【kiret】腋下止汗貼腋下專用吸汗貼 30入(吸汗速乾 腋下貼 吸汗貼)', price: 299, yesterdayChange: null, weekChange: -80, updatedAt: '04-30 14:00', listedAt: '01-07 18:24', emoji: '🧴' },
|
||||
{ id: '14333992', category: '止汗體香', name: '【NOOSA BASICS】任選1款植萃體香滾珠 50ml(檀香/玫瑰乳香/檸檬香桃木/甜橙薰衣草-國...', price: 468, yesterdayChange: null, weekChange: -97, updatedAt: '04-30 14:00', listedAt: '04-14 10:12', emoji: '🌿' },
|
||||
{ id: '14305052', category: '止汗體香', name: '【NIVEA 妮維雅】男士止汗爽身乳液50ml 瞬間酷涼系列x3入(麝香/柑橘止汗滾珠/涼感)', price: 419, yesterdayChange: null, weekChange: -10, updatedAt: '04-30 14:00', listedAt: '04-10 10:49', emoji: '💧' },
|
||||
{ id: 'TP00022180001470',category: '止汗體香', name: '【ban 盼】滾珠式體香劑103ml/體香膏73g (原味花香/爽身粉香/沐浴清香/無香)', price: 169, yesterdayChange: null, weekChange: null, updatedAt: '04-30 14:00', listedAt: '04-03 10:47', emoji: '🛁' },
|
||||
{ id: '13723381', category: '止汗體香', name: '【GATSBY】止汗體香滾珠50ml(4款任選)', price: 139, yesterdayChange: null, weekChange: null, updatedAt: '04-30 14:00', listedAt: '01-18 17:19', emoji: '🧪' },
|
||||
{ id: '12894521', category: '美妝保養', name: '【LA MER 海洋拉娜】緊緻精華霜 30ml(明星商品 限量組)', price: 14800, yesterdayChange: 1200, weekChange: 1200, updatedAt: '04-30 14:00', listedAt: '03-22 09:15', emoji: '✨' },
|
||||
{ id: '11203847', category: '美妝保養', name: '【SK-II】青春露 230ml 神仙水', price: 5980, yesterdayChange: -120, weekChange: -380, updatedAt: '04-30 14:00', listedAt: '02-14 11:22', emoji: '💎' },
|
||||
{ id: '15672891', category: '美妝保養', name: '【ESTÉE LAUDER 雅詩蘭黛】小棕瓶肌透修護精華 50ml', price: 4280, yesterdayChange: 280, weekChange: 480, updatedAt: '04-30 14:00', listedAt: '03-08 14:50', emoji: '🍂' },
|
||||
{ id: '14598732', category: '保健食品', name: '【善存】銀寶善存 50+ 100錠', price: 899, yesterdayChange: null, weekChange: -45, updatedAt: '04-30 14:00', listedAt: '04-21 16:33', emoji: '💊' },
|
||||
{ id: '13284756', category: '生活雜貨', name: '【3M】百利菜瓜布 廚房去油專用 6片裝', price: 89, yesterdayChange: null, weekChange: null, updatedAt: '04-30 14:00', listedAt: '01-05 10:00', emoji: '🧽' },
|
||||
],
|
||||
|
||||
// 廠商缺貨清單
|
||||
outOfStock: [
|
||||
{ vendor: 'momo購物網', count: 12, lastSeen: '04-30 13:42' },
|
||||
{ vendor: 'PChome 24h', count: 8, lastSeen: '04-30 13:22' },
|
||||
{ vendor: '蝦皮商城', count: 23, lastSeen: '04-30 12:58' },
|
||||
{ vendor: 'Yahoo 購物中心', count: 5, lastSeen: '04-30 11:17' },
|
||||
],
|
||||
|
||||
// 30 天價格走勢(用於彈窗圖表)
|
||||
priceHistory: Array.from({ length: 30 }, (_, i) => ({
|
||||
date: `04-${String(i+1).padStart(2,'0')}`,
|
||||
price: 280 + Math.round(Math.sin(i / 3) * 30 + Math.random() * 30 + i * 1.5),
|
||||
})),
|
||||
|
||||
// ===== 活動看板資料 =====
|
||||
campaigns: {
|
||||
flash: {
|
||||
id: 'flash',
|
||||
name: '限時搶購',
|
||||
icon: '🔥',
|
||||
time: '活動時間:04/30 18:00~04/30 21:59',
|
||||
lastUpdate: '2026-04-30 19:05',
|
||||
total: 310,
|
||||
schedule: { lastRun: '2026-04-30 19:06:34', anomalies: 107, status: 'success' },
|
||||
timeSlots: [
|
||||
{ time: '00:00', count: 32 },
|
||||
{ time: '07:00', count: 34 },
|
||||
{ time: '11:00', count: 19 },
|
||||
{ time: '14:00', count: 0 },
|
||||
{ time: '18:00', count: 39 },
|
||||
{ time: '22:00', count: 26, active: true },
|
||||
],
|
||||
stats: { listed: 54, new: 27, up: 0, down: 0, delisted: 29 },
|
||||
products: [
|
||||
{ id: '13135914', cat: '精萃液', name: '蘭蔻 小黑瓶PRO50ml', price: 4092, emoji: '🧪', new: true, off: '55折', limit: 200 },
|
||||
{ id: '13062545', cat: '底妝_隔離霜', name: '肌膚之鑰 妝前凝霜', price: 1199, emoji: '🧴', new: true, off: '57折', limit: 250 },
|
||||
{ id: '13233302', cat: '嬰幼身體保養品牌旗艦', name: '凡士林 精華凝乳3入', price: 529, emoji: '🍼', new: true, off: '44折', limit: 300 },
|
||||
{ id: '9135192', cat: '嬰幼身體保養品牌旗艦', name: 'SEBAMED 潔膚露2入', price: 688, emoji: '🍼', off: '下架', limit: 380, delisted: true },
|
||||
{ id: '14511798', cat: '未分類', name: 'TAKASHIMA 高島 筋負 五行美體養生椅', price: 29999, emoji: '🪑', new: true, off: '5折', limit: 18 },
|
||||
{ id: '15087322', cat: '未分類', name: 'BGYM H动 G55 純纖綁徒陪式步機-發山老技手版 (DD01/原版/水双纤维)', price: 18900, emoji: '🚲', new: true, off: '6折', limit: 12 },
|
||||
],
|
||||
},
|
||||
festival: {
|
||||
id: 'festival',
|
||||
name: '1.1狂歡購物節',
|
||||
icon: '🔥',
|
||||
time: '1.1狂歡購物節',
|
||||
lastUpdate: '2026-04-30 17:55',
|
||||
total: 226,
|
||||
schedule: { lastRun: '2026-04-30 19:13:11', anomalies: 0, status: 'success' },
|
||||
categories: [
|
||||
{ name: 'mo+商城', count: 10, active: true },
|
||||
{ name: '今日限定 偽低狂殺', count: 12 },
|
||||
{ name: '口腔護理', count: 10 },
|
||||
{ name: '專櫃精選', count: 6 },
|
||||
{ name: '專櫃彩妝', count: 10 },
|
||||
{ name: '探索更多分類', count: 6 },
|
||||
{ name: '新品速報 搶先入手', count: 3 },
|
||||
{ name: '最強寵愛攻略', count: 3 },
|
||||
{ name: '沙龍洗沐', count: 10 },
|
||||
{ name: '洗沐美髮', count: 9 },
|
||||
{ name: '流行彩妝', count: 10 },
|
||||
{ name: '焦點大牌 強檔鉅獻', count: 48 },
|
||||
{ name: '狂歡夯品 優惠爆發', count: 10 },
|
||||
{ name: '獨家活動 強勢登場', count: 2 },
|
||||
{ name: '話題強牌 精選推薦', count: 12 },
|
||||
{ name: '超值大組 買多省多', count: 10 },
|
||||
{ name: '醫美修護', count: 10 },
|
||||
{ name: '開架保養', count: 10 },
|
||||
{ name: '香水香氛', count: 10 },
|
||||
{ name: '驚喜獻禮 首選必buy', count: 10 },
|
||||
],
|
||||
stats: { listed: 10, new: 10, up: 1, down: 0, delisted: 0 },
|
||||
products: [
|
||||
{ id: 'TP00007400000083', cat: '未分類', name: '達特仕 水樹酸棉片2罐', price: 5948, emoji: '🧴', new: true, status: '活動中' },
|
||||
{ id: 'TP00080550000244', cat: '未分類', name: 'Medicube 美容儀', price: 4999, emoji: '💆', new: true, status: '活動中' },
|
||||
{ id: 'TP00056060000035', cat: '未分類', name: '艾沛膚AD水潤露沐浴組', price: 1630, oldPrice: 1287, change: 343, changePct: 27, emoji: '🧴', up: true, status: '活動中' },
|
||||
{ id: 'TP00018590000010', cat: '未分類', name: '茶碳光感上色亮髮乳', price: 1399, emoji: '💇', new: true, status: '活動中' },
|
||||
{ id: 'TP00042310000058', cat: '未分類', name: 'Olay 玻尿酸保濕霜 50ml', price: 899, emoji: '🧴', new: true, status: '活動中' },
|
||||
{ id: 'TP00071230000091', cat: '未分類', name: 'KOSE 雪肌精化妝水 200ml', price: 1280, emoji: '💧', new: true, status: '活動中' },
|
||||
],
|
||||
},
|
||||
mothers: {
|
||||
id: 'mothers',
|
||||
name: '母親節',
|
||||
icon: '🔥',
|
||||
time: '活動時間:母親節超值限時購',
|
||||
lastUpdate: '2026-04-30 19:30',
|
||||
total: 278,
|
||||
schedule: { lastRun: '2026-04-30 19:06:34', anomalies: 107, status: 'success' },
|
||||
categories: [
|
||||
{ name: 'moPro專屬版', count: 9, active: true },
|
||||
{ name: '今日主打', count: 122 },
|
||||
{ name: '品牌鉅獻', count: 8 },
|
||||
{ name: '夯品特開', count: 13 },
|
||||
{ name: '樂購mo店+', count: 27 },
|
||||
{ name: '母親節搶先開賣', count: 18 },
|
||||
{ name: '熱搜清單', count: 20 },
|
||||
{ name: '爆殺神券', count: 1 },
|
||||
{ name: '生活超市', count: 13 },
|
||||
{ name: '精選情實好物', count: 9 },
|
||||
{ name: '送禮嚴選', count: 11 },
|
||||
{ name: '點點頭計畫', count: 21 },
|
||||
],
|
||||
stats: { listed: 9, new: 0, up: 0, down: 0, delisted: 0 },
|
||||
products: [
|
||||
{ id: '15093144', cat: '素然氪', name: '【西西露】 韓國面膜', price: 69, emoji: '🧖', new: true, status: '活動中' },
|
||||
{ id: '14118016', cat: '未分類', name: '【亞梭】 CS-Black椅', price: 29388, emoji: '🪑', new: true, status: '活動中' },
|
||||
{ id: '15052867', cat: '未分類', name: '【Shark】 涼感頂霧扇', price: 4099, emoji: '🌀', new: true, status: '活動中' },
|
||||
{ id: '14896469', cat: '未分類', name: '【舒潔】 衛生紙', price: 688, emoji: '🧻', new: true, status: '活動中' },
|
||||
{ id: '14260664', cat: '未分類', name: '【味丹】 多喝水', price: 638, emoji: '💧', new: true, status: '活動中' },
|
||||
{ id: '15123887', cat: '未分類', name: '【金門高梁】 58度 600ml', price: 599, emoji: '🥃', new: true, status: '活動中' },
|
||||
],
|
||||
},
|
||||
valentine: {
|
||||
id: 'valentine',
|
||||
name: '520情人節',
|
||||
icon: '💖',
|
||||
time: '活動時間:520情人節限定',
|
||||
lastUpdate: '2026-04-30 18:00',
|
||||
total: 184,
|
||||
schedule: { lastRun: '2026-04-30 19:00:00', anomalies: 12, status: 'success' },
|
||||
categories: [
|
||||
{ name: '情侶禮物', count: 24, active: true },
|
||||
{ name: '香水香氛', count: 18 },
|
||||
{ name: '飾品配件', count: 32 },
|
||||
{ name: '巧克力禮盒', count: 15 },
|
||||
],
|
||||
stats: { listed: 24, new: 8, up: 2, down: 1, delisted: 0 },
|
||||
products: [
|
||||
{ id: '17234561', cat: '飾品配件', name: 'Tiffany 經典項鍊', price: 28800, emoji: '💎', new: true, status: '活動中' },
|
||||
{ id: '17234562', cat: '香水香氛', name: 'Chanel No.5 50ml', price: 4280, emoji: '🌹', new: true, status: '活動中' },
|
||||
{ id: '17234563', cat: '巧克力禮盒', name: 'GODIVA 心型禮盒', price: 1580, emoji: '🍫', new: true, status: '活動中' },
|
||||
],
|
||||
},
|
||||
laborday: {
|
||||
id: 'laborday',
|
||||
name: '勞動節',
|
||||
icon: '🛠️',
|
||||
time: '活動時間:勞動節 04/30~05/01',
|
||||
lastUpdate: '2026-04-30 17:00',
|
||||
total: 96,
|
||||
schedule: { lastRun: '2026-04-30 18:30:00', anomalies: 3, status: 'success' },
|
||||
categories: [
|
||||
{ name: '家電特賣', count: 28, active: true },
|
||||
{ name: '工具用品', count: 22 },
|
||||
{ name: '居家修繕', count: 18 },
|
||||
],
|
||||
stats: { listed: 28, new: 4, up: 0, down: 3, delisted: 0 },
|
||||
products: [
|
||||
{ id: '18234561', cat: '家電特賣', name: 'Dyson V12 無線吸塵器', price: 18800, emoji: '🧹', new: true, status: '活動中' },
|
||||
{ id: '18234562', cat: '工具用品', name: 'Bosch 18V 電鑽組', price: 4280, emoji: '🔧', status: '活動中' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// KPI 卡片(給 dashboard 用)
|
||||
kpis: [
|
||||
{ id: 'monitor', label: 'MONITORED', zhLabel: '監控總數', value: '7,057', delta: 1.32, deltaLabel: '本週 +92', icon: 'package' },
|
||||
{ id: 'change', label: 'PRICE CHANGES', zhLabel: '今日變動', value: '99', delta: 1.4, deltaLabel: '活躍度', icon: 'trendUp' },
|
||||
{ id: 'up', label: 'PRICE UP', zhLabel: '漲價商品', value: '39', delta: 244, deltaLabel: '平均 +$244', icon: 'arrowUp', tone: 'danger' },
|
||||
{ id: 'down', label: 'PRICE DOWN', zhLabel: '降價商品', value: '60', delta: -131, deltaLabel: '平均 -$131', icon: 'arrowDown', tone: 'success' },
|
||||
],
|
||||
};
|
||||
|
||||
const STATUS_MAP = {
|
||||
success: { label: '成功', tone: 'success' },
|
||||
running: { label: '掃描中', tone: 'info' },
|
||||
failed: { label: '失敗', tone: 'danger' },
|
||||
};
|
||||
|
||||
// 為了相容舊 page-orders/page-products 等檔案的引用,保留別名
|
||||
window.MOMO_DATA = EWOOOC_DATA;
|
||||
window.EWOOOC_DATA = EWOOOC_DATA;
|
||||
window.STATUS_MAP = STATUS_MAP;
|
||||
65
MOMO Pro/app/icons.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
// MOMO Pro - Icons (簡單 SVG 圖示)
|
||||
const Icon = ({ name, size = 16, color = 'currentColor', strokeWidth = 2, style }) => {
|
||||
const paths = {
|
||||
dashboard: <><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></>,
|
||||
orders: <><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></>,
|
||||
products: <><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></>,
|
||||
inventory: <><path d="M21 8V21H3V8"/><path d="M1 3h22v5H1z"/><path d="M10 12h4"/></>,
|
||||
members: <><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></>,
|
||||
marketing: <><path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></>,
|
||||
analytics: <><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></>,
|
||||
settings: <><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></>,
|
||||
search: <><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></>,
|
||||
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></>,
|
||||
plus: <><path d="M12 5v14"/><path d="M5 12h14"/></>,
|
||||
chevronDown: <><path d="m6 9 6 6 6-6"/></>,
|
||||
chevronRight: <><path d="m9 18 6-6-6-6"/></>,
|
||||
chevronLeft: <><path d="m15 18-6-6 6-6"/></>,
|
||||
chevronUp: <><path d="m18 15-6-6-6 6"/></>,
|
||||
arrowUp: <><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></>,
|
||||
arrowDown: <><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></>,
|
||||
trendUp: <><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></>,
|
||||
trendDown: <><polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/></>,
|
||||
moreHorizontal: <><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></>,
|
||||
moreVertical: <><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></>,
|
||||
edit: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></>,
|
||||
trash: <><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></>,
|
||||
eye: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></>,
|
||||
filter: <><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></>,
|
||||
download: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></>,
|
||||
upload: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></>,
|
||||
calendar: <><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></>,
|
||||
check: <><polyline points="20 6 9 17 4 12"/></>,
|
||||
checkCircle: <><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></>,
|
||||
x: <><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></>,
|
||||
xCircle: <><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></>,
|
||||
clock: <><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></>,
|
||||
truck: <><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></>,
|
||||
package: <><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></>,
|
||||
user: <><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></>,
|
||||
dollar: <><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></>,
|
||||
shoppingBag: <><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/></>,
|
||||
refresh: <><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></>,
|
||||
copy: <><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></>,
|
||||
menu: <><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></>,
|
||||
helpCircle: <><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></>,
|
||||
star: <><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></>,
|
||||
tag: <><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></>,
|
||||
creditCard: <><rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/></>,
|
||||
mail: <><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></>,
|
||||
logout: <><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></>,
|
||||
grid: <><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></>,
|
||||
list: <><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></>,
|
||||
command: <><path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/></>,
|
||||
sparkle: <><path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z"/></>,
|
||||
};
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, ...style }}>
|
||||
{paths[name] || null}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
window.Icon = Icon;
|
||||
132
MOMO Pro/app/main.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
// EwoooC - 主應用
|
||||
|
||||
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
"sidebarCollapsed": false,
|
||||
"sidebarTheme": "light",
|
||||
"density": "comfortable",
|
||||
"cardStyle": "shadow",
|
||||
"buttonStyle": "gradient",
|
||||
"page": "dashboard"
|
||||
}/*EDITMODE-END*/;
|
||||
|
||||
const MomoApp = ({ tweaks, setTweak, fixedPage, label }) => {
|
||||
const [cmdOpen, setCmdOpen] = React.useState(false);
|
||||
const [editProduct, setEditProduct] = React.useState(null);
|
||||
|
||||
const page = fixedPage || tweaks.page;
|
||||
|
||||
// Cmd+K 開啟命令面板
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setCmdOpen(true);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setCmdOpen(false);
|
||||
setEditProduct(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="momo-app" style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Sidebar
|
||||
active={page}
|
||||
onNavigate={(id) => setTweak && setTweak('page', id)}
|
||||
collapsed={tweaks.sidebarCollapsed}
|
||||
sidebarTheme={tweaks.sidebarTheme}
|
||||
/>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
|
||||
<Topbar
|
||||
onToggleSidebar={() => setTweak && setTweak('sidebarCollapsed', !tweaks.sidebarCollapsed)}
|
||||
onOpenCmd={() => setCmdOpen(true)}
|
||||
/>
|
||||
{label && (
|
||||
<div style={{
|
||||
padding: '6px 24px',
|
||||
background: 'var(--momo-primary-50)',
|
||||
borderBottom: '1px solid var(--momo-primary-100)',
|
||||
fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--momo-primary-700)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||
}}>{label}</div>
|
||||
)}
|
||||
<main className="momo-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', minHeight: 0,
|
||||
padding: tweaks.density === 'compact' ? '20px 24px' : '28px 32px',
|
||||
background: 'var(--momo-bg-body)',
|
||||
}}>
|
||||
{page === 'dashboard' && (
|
||||
<DashboardPage density={tweaks.density} />
|
||||
)}
|
||||
{page === 'campaigns' && (
|
||||
<CampaignsPage density={tweaks.density} />
|
||||
)}
|
||||
{page === 'orders' && (
|
||||
<OrdersPage density={tweaks.density} cardStyle={tweaks.cardStyle} buttonStyle={tweaks.buttonStyle} />
|
||||
)}
|
||||
{page === 'products' && (
|
||||
<ProductsPage density={tweaks.density} cardStyle={tweaks.cardStyle} buttonStyle={tweaks.buttonStyle}
|
||||
onEditProduct={setEditProduct} />
|
||||
)}
|
||||
{!['dashboard','orders','products','campaigns'].includes(page) && (
|
||||
<EmptyPage page={page} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CommandPalette
|
||||
open={cmdOpen}
|
||||
onClose={() => setCmdOpen(false)}
|
||||
onNavigate={(id) => setTweak && setTweak('page', id)}
|
||||
/>
|
||||
<ProductEditModal
|
||||
open={!!editProduct}
|
||||
product={editProduct}
|
||||
onClose={() => setEditProduct(null)}
|
||||
buttonStyle={tweaks.buttonStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyPage = ({ page }) => {
|
||||
const labels = {
|
||||
inventory: '庫存管理', members: '會員管理', marketing: '行銷活動',
|
||||
analytics: '數據分析', settings: '系統設定',
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title={labels[page] || '頁面'} subtitle="此頁面尚在規劃中" />
|
||||
<Card style={{ padding: 64, textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: 64, height: 64,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--momo-primary-100)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 16px',
|
||||
color: 'var(--momo-primary)',
|
||||
}}>
|
||||
<Icon name="sparkle" size={28} />
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--momo-text-primary)', marginBottom: 6 }}>
|
||||
{labels[page]} 即將推出
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--momo-text-secondary)', marginBottom: 20 }}>
|
||||
此區尚未建置示意內容,歡迎在 Tweaks 切換到其他頁面預覽。
|
||||
</div>
|
||||
<Button variant="gradient">通知我上線時間</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.MomoApp = MomoApp;
|
||||
window.TWEAK_DEFAULTS = TWEAK_DEFAULTS;
|
||||
327
MOMO Pro/app/modals.jsx
Normal file
@@ -0,0 +1,327 @@
|
||||
// MOMO Pro - Modal & 命令面板
|
||||
|
||||
// ===== 通用 Modal 容器 =====
|
||||
const Modal = ({ open, onClose, title, children, footer, size = 'md' }) => {
|
||||
if (!open) return null;
|
||||
const widths = { sm: 480, md: 640, lg: 880 };
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'var(--momo-bg-overlay)',
|
||||
zIndex: 'var(--momo-z-modal-backdrop)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
animation: 'momo-fade-in 0.15s ease-out',
|
||||
}} onClick={onClose}>
|
||||
<div onClick={e => e.stopPropagation()} style={{
|
||||
background: 'var(--momo-bg-surface)',
|
||||
borderRadius: 'var(--momo-radius-lg)',
|
||||
boxShadow: 'var(--momo-shadow-lg)',
|
||||
width: '100%', maxWidth: widths[size],
|
||||
maxHeight: '90%',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
animation: 'momo-slide-up 0.2s ease-out',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '18px 24px',
|
||||
borderBottom: '1px solid var(--momo-border-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--momo-text-primary)' }}>{title}</h3>
|
||||
<button onClick={onClose} style={{
|
||||
width: 32, height: 32, borderRadius: 'var(--momo-radius-md)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--momo-text-secondary)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--momo-bg-subtle)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<Icon name="x" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="momo-scroll" style={{ flex: 1, overflowY: 'auto', padding: '20px 24px' }}>
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div style={{
|
||||
padding: '14px 24px',
|
||||
borderTop: '1px solid var(--momo-border-light)',
|
||||
display: 'flex', justifyContent: 'flex-end', gap: 8,
|
||||
background: 'var(--momo-bg-subtle)',
|
||||
}}>{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== 編輯商品 Modal =====
|
||||
const ProductEditModal = ({ open, product, onClose, buttonStyle = 'gradient' }) => {
|
||||
if (!product) return null;
|
||||
const Field = ({ label, children, hint }) => (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{
|
||||
display: 'block', fontSize: 12, fontWeight: 600,
|
||||
color: 'var(--momo-text-primary)', marginBottom: 6,
|
||||
}}>{label}</label>
|
||||
{children}
|
||||
{hint && <div style={{ fontSize: 11, color: 'var(--momo-text-tertiary)', marginTop: 4 }}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
const fieldStyle = {
|
||||
width: '100%', padding: '8px 12px',
|
||||
border: '1px solid var(--momo-border)',
|
||||
borderRadius: 'var(--momo-radius-md)',
|
||||
fontSize: 13, outline: 'none',
|
||||
background: 'var(--momo-bg-surface)',
|
||||
transition: 'var(--momo-transition-base)',
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} size="lg" title={`編輯商品 · ${product.id}`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button variant="ghost-hover">儲存草稿</Button>
|
||||
<Button variant={buttonStyle} icon="check" onClick={onClose}>儲存並上架</Button>
|
||||
</>
|
||||
}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 220px', gap: 24 }}>
|
||||
<div>
|
||||
<Field label="商品名稱">
|
||||
<input defaultValue={product.name} style={fieldStyle} />
|
||||
</Field>
|
||||
<Field label="商品描述" hint="支援 Markdown 格式">
|
||||
<textarea rows="4" defaultValue="精選材質,舒適耐用,享受高品質體驗。30 天滿意保證,免費退換貨。" style={{ ...fieldStyle, resize: 'vertical', lineHeight: 1.6 }} />
|
||||
</Field>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<Field label="售價(NT$)">
|
||||
<input type="number" defaultValue={product.price} style={fieldStyle} />
|
||||
</Field>
|
||||
<Field label="原價(NT$)">
|
||||
<input type="number" defaultValue={Math.round(product.price * 1.3)} style={fieldStyle} />
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<Field label="庫存量">
|
||||
<input type="number" defaultValue={product.stock} style={fieldStyle} />
|
||||
</Field>
|
||||
<Field label="SKU">
|
||||
<input defaultValue={product.sku} style={{ ...fieldStyle, fontFamily: 'var(--momo-font-family-mono)' }} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="商品分類">
|
||||
<select defaultValue={product.category} style={fieldStyle}>
|
||||
{[...new Set(MOMO_DATA.products.map(p => p.category))].map(c => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="商品標籤">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, padding: 8,
|
||||
border: '1px solid var(--momo-border)', borderRadius: 'var(--momo-radius-md)',
|
||||
background: 'var(--momo-bg-surface)' }}>
|
||||
{['熱銷', '新品上市', '限時優惠'].map(t => (
|
||||
<span key={t} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '3px 8px', fontSize: 12,
|
||||
background: 'var(--momo-primary-100)', color: 'var(--momo-primary-700)',
|
||||
borderRadius: 'var(--momo-radius-sm)', fontWeight: 500,
|
||||
}}>
|
||||
<Icon name="tag" size={11} />
|
||||
{t}
|
||||
<Icon name="x" size={11} />
|
||||
</span>
|
||||
))}
|
||||
<input placeholder="新增標籤…" style={{ flex: 1, minWidth: 100, border: 'none', outline: 'none', fontSize: 13 }} />
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Field label="商品圖片">
|
||||
<div style={{
|
||||
aspectRatio: '1', borderRadius: 'var(--momo-radius-md)',
|
||||
background: 'var(--momo-gradient-subtle)',
|
||||
border: '1px solid var(--momo-border-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 80, marginBottom: 8,
|
||||
}}>{product.emoji}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6, marginBottom: 8 }}>
|
||||
{[1,2,3].map(i => (
|
||||
<div key={i} style={{
|
||||
aspectRatio: '1', borderRadius: 'var(--momo-radius-sm)',
|
||||
background: 'var(--momo-bg-subtle)',
|
||||
border: '1px solid var(--momo-border-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 24, opacity: 0.4,
|
||||
}}>{product.emoji}</div>
|
||||
))}
|
||||
<button style={{
|
||||
aspectRatio: '1', borderRadius: 'var(--momo-radius-sm)',
|
||||
background: 'var(--momo-bg-surface)',
|
||||
border: '1.5px dashed var(--momo-border-dark)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--momo-text-tertiary)',
|
||||
}}><Icon name="plus" size={16} /></button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="商品狀態">
|
||||
<select defaultValue={product.status} style={fieldStyle}>
|
||||
<option value="active">上架中</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="soldout">下架</option>
|
||||
</select>
|
||||
</Field>
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: 'var(--momo-info-bg)',
|
||||
border: '1px solid var(--momo-info-border)',
|
||||
borderRadius: 'var(--momo-radius-md)',
|
||||
fontSize: 11, color: 'var(--momo-info-text)',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600, marginBottom: 4 }}>
|
||||
<Icon name="helpCircle" size={12} />
|
||||
SEO 提示
|
||||
</div>
|
||||
標題建議 30 字內,描述含 3-5 個關鍵字,可提升搜尋曝光。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== 命令面板 =====
|
||||
const CommandPalette = ({ open, onClose, onNavigate }) => {
|
||||
const [query, setQuery] = React.useState('');
|
||||
React.useEffect(() => { if (open) setQuery(''); }, [open]);
|
||||
if (!open) return null;
|
||||
|
||||
const cmds = [
|
||||
{ id: 'go-dashboard', label: '前往儀表板', kind: '導覽', icon: 'dashboard', action: () => { onNavigate('dashboard'); onClose(); } },
|
||||
{ id: 'go-orders', label: '前往訂單管理', kind: '導覽', icon: 'orders', action: () => { onNavigate('orders'); onClose(); } },
|
||||
{ id: 'go-products', label: '前往商品管理', kind: '導覽', icon: 'products', action: () => { onNavigate('products'); onClose(); } },
|
||||
{ id: 'create-order', label: '建立新訂單', kind: '快速操作', icon: 'plus', shortcut: 'O' },
|
||||
{ id: 'create-product', label: '新增商品', kind: '快速操作', icon: 'plus', shortcut: 'P' },
|
||||
{ id: 'export-report', label: '匯出本月報表', kind: '快速操作', icon: 'download' },
|
||||
{ id: 'recent-1', label: 'MM-202604-08742 · 陳俊宏', kind: '最近訂單', icon: 'orders' },
|
||||
{ id: 'recent-2', label: 'P-2451 · 無線藍牙降噪耳機 Pro', kind: '最近商品', icon: 'package' },
|
||||
];
|
||||
const filtered = query
|
||||
? cmds.filter(c => c.label.toLowerCase().includes(query.toLowerCase()))
|
||||
: cmds;
|
||||
|
||||
// 依分類分群
|
||||
const groups = {};
|
||||
filtered.forEach(c => { (groups[c.kind] = groups[c.kind] || []).push(c); });
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'rgba(20,25,40,0.45)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 'var(--momo-z-modal)',
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
|
||||
paddingTop: 100,
|
||||
}} onClick={onClose}>
|
||||
<div onClick={e => e.stopPropagation()} style={{
|
||||
width: 560, maxWidth: '90%',
|
||||
background: 'var(--momo-bg-surface)',
|
||||
borderRadius: 'var(--momo-radius-lg)',
|
||||
boxShadow: 'var(--momo-shadow-lg)',
|
||||
overflow: 'hidden',
|
||||
animation: 'momo-slide-up 0.18s ease-out',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '14px 18px',
|
||||
borderBottom: '1px solid var(--momo-border-light)',
|
||||
}}>
|
||||
<Icon name="search" size={18} color="var(--momo-text-secondary)" />
|
||||
<input autoFocus value={query} onChange={e => setQuery(e.target.value)}
|
||||
placeholder="搜尋指令、訂單、商品、會員…"
|
||||
style={{
|
||||
flex: 1, border: 'none', outline: 'none',
|
||||
fontSize: 15, color: 'var(--momo-text-primary)',
|
||||
background: 'transparent',
|
||||
}} />
|
||||
<kbd style={{
|
||||
fontSize: 10, fontWeight: 600,
|
||||
padding: '3px 7px',
|
||||
background: 'var(--momo-bg-subtle)',
|
||||
border: '1px solid var(--momo-border)',
|
||||
borderRadius: 4,
|
||||
color: 'var(--momo-text-secondary)',
|
||||
fontFamily: 'var(--momo-font-family-mono)',
|
||||
}}>ESC</kbd>
|
||||
</div>
|
||||
<div className="momo-scroll" style={{ maxHeight: 400, overflowY: 'auto', padding: 8 }}>
|
||||
{Object.keys(groups).length === 0 && (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--momo-text-tertiary)', fontSize: 13 }}>
|
||||
找不到相符的結果
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(groups).map(([kind, items]) => (
|
||||
<div key={kind} style={{ marginBottom: 4 }}>
|
||||
<div style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 10, fontWeight: 700,
|
||||
color: 'var(--momo-text-tertiary)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||
}}>{kind}</div>
|
||||
{items.map((c, i) => (
|
||||
<button key={c.id} onClick={c.action}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 'var(--momo-radius-md)',
|
||||
fontSize: 13,
|
||||
color: 'var(--momo-text-primary)',
|
||||
transition: 'background var(--momo-duration-fast)',
|
||||
background: i === 0 && kind === Object.keys(groups)[0] ? 'var(--momo-bg-subtle)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--momo-primary-50)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = i === 0 && kind === Object.keys(groups)[0] ? 'var(--momo-bg-subtle)' : 'transparent'}>
|
||||
<Icon name={c.icon} size={16} color="var(--momo-text-secondary)" />
|
||||
<span style={{ flex: 1, textAlign: 'left' }}>{c.label}</span>
|
||||
{c.shortcut && (
|
||||
<kbd style={{
|
||||
fontSize: 10, fontWeight: 600,
|
||||
padding: '2px 6px',
|
||||
background: 'var(--momo-bg-subtle)',
|
||||
border: '1px solid var(--momo-border-light)',
|
||||
borderRadius: 4,
|
||||
color: 'var(--momo-text-secondary)',
|
||||
fontFamily: 'var(--momo-font-family-mono)',
|
||||
}}>⌘{c.shortcut}</kbd>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '8px 14px',
|
||||
borderTop: '1px solid var(--momo-border-light)',
|
||||
background: 'var(--momo-bg-subtle)',
|
||||
display: 'flex', alignItems: 'center', gap: 16,
|
||||
fontSize: 11, color: 'var(--momo-text-tertiary)',
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<kbd style={{ fontSize: 10, padding: '1px 5px', background: '#fff', border: '1px solid var(--momo-border)', borderRadius: 3, fontFamily: 'var(--momo-font-family-mono)' }}>↑↓</kbd>
|
||||
選擇
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<kbd style={{ fontSize: 10, padding: '1px 5px', background: '#fff', border: '1px solid var(--momo-border)', borderRadius: 3, fontFamily: 'var(--momo-font-family-mono)' }}>↵</kbd>
|
||||
執行
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { Modal, ProductEditModal, CommandPalette });
|
||||
465
MOMO Pro/app/page-campaigns.jsx
Normal file
@@ -0,0 +1,465 @@
|
||||
// EwoooC - 活動看板(新視覺語言:與 Dashboard 一致的設計語彙)
|
||||
|
||||
// ===== 活動切換 — 精緻 segmented tabs =====
|
||||
const CampaignSwitcher = ({ active, setActive, campaigns }) => {
|
||||
const ids = Object.keys(campaigns);
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
background: 'var(--momo-bg-surface)',
|
||||
border: '1px solid var(--momo-border-light)',
|
||||
borderRadius: 6, padding: 4,
|
||||
gap: 2,
|
||||
}}>
|
||||
{ids.map(id => {
|
||||
const c = campaigns[id];
|
||||
const isActive = id === active;
|
||||
return (
|
||||
<button key={id} onClick={() => setActive(id)} style={{
|
||||
padding: '8px 14px',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
fontSize: 13, fontWeight: 600,
|
||||
color: isActive ? '#fff' : 'var(--momo-text-secondary)',
|
||||
background: isActive ? 'var(--momo-ink)' : 'transparent',
|
||||
border: 'none', borderRadius: 4,
|
||||
transition: 'var(--momo-transition-base)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<span style={{ fontSize: 13 }}>{c.icon}</span>
|
||||
<span>{c.name}</span>
|
||||
<span className="momo-mono" style={{
|
||||
fontSize: 10, fontWeight: 700,
|
||||
padding: '1px 6px', borderRadius: 8,
|
||||
background: isActive ? 'rgba(255,255,255,0.2)' : 'var(--momo-bg-subtle)',
|
||||
color: isActive ? '#fff' : 'var(--momo-text-tertiary)',
|
||||
}}>{c.total}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Hero - 活動主資訊卡(紫色漸層,呼應 Dashboard 監控概況) =====
|
||||
const CampaignHero = ({ c, activeId }) => {
|
||||
// 不同活動用不同色相
|
||||
const palettes = {
|
||||
flash: ['#dc2626', '#ea580c', '#f97316'], // 火焰紅橘
|
||||
festival: ['#7c3aed', '#6d28d9', '#5b21b6'], // 紫
|
||||
mothers: ['#db2777', '#be185d', '#9d174d'], // 玫紅
|
||||
valentine: ['#e11d48', '#be123c', '#9f1239'], // 情人節紅
|
||||
laborday: ['#0891b2', '#0e7490', '#155e75'], // 藍
|
||||
};
|
||||
const [c1, c2, c3] = palettes[activeId] || palettes.flash;
|
||||
|
||||
return (
|
||||
<Card cardStyle="flat" padding={false} style={{
|
||||
padding: 24,
|
||||
background: `linear-gradient(160deg, ${c1} 0%, ${c2} 50%, ${c3} 100%)`,
|
||||
color: '#fff', border: 'none', borderRadius: 8,
|
||||
position: 'relative', overflow: 'hidden',
|
||||
minHeight: 200,
|
||||
}}>
|
||||
{/* 點陣裝飾 */}
|
||||
<div style={{ position: 'absolute', inset: 0,
|
||||
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.08) 1px, transparent 1px)',
|
||||
backgroundSize: '12px 12px', pointerEvents: 'none' }} />
|
||||
{/* 大圖示 */}
|
||||
<div style={{ position: 'absolute', right: -10, bottom: -40, fontSize: 220, lineHeight: 1, opacity: 0.12, pointerEvents: 'none' }}>{c.icon}</div>
|
||||
|
||||
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 16, height: '100%' }}>
|
||||
{/* 標籤列 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
padding: '3px 10px', fontSize: 10, fontWeight: 700,
|
||||
letterSpacing: '0.08em', textTransform: 'uppercase',
|
||||
background: 'rgba(255,255,255,0.18)',
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
borderRadius: 12,
|
||||
}}>CAMPAIGN</span>
|
||||
<span className="momo-mono" style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)' }}>
|
||||
ID · {activeId.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 標題 */}
|
||||
<h1 style={{ margin: 0, fontSize: 36, fontWeight: 800, lineHeight: 1.1, letterSpacing: '-0.02em' }}>
|
||||
{c.name}
|
||||
</h1>
|
||||
|
||||
{/* meta 列 */}
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', fontSize: 12, fontFamily: 'var(--momo-font-family-mono)' }}>
|
||||
<div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.6)', marginBottom: 2 }}>活動時段</div>
|
||||
<div style={{ color: '#fff', fontWeight: 600 }}>{c.time}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.6)', marginBottom: 2 }}>最後更新</div>
|
||||
<div style={{ color: '#fff', fontWeight: 600 }}>{c.lastUpdate}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.6)', marginBottom: 2 }}>商品總數</div>
|
||||
<div className="momo-mono" style={{ color: '#fff', fontWeight: 800, fontSize: 20, letterSpacing: '-0.02em' }}>
|
||||
{c.total.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 'auto' }}>
|
||||
<button style={{
|
||||
padding: '8px 14px', fontSize: 13, fontWeight: 600,
|
||||
background: 'rgba(255,255,255,0.15)', color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.25)', borderRadius: 4,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<Icon name="refresh" size={12} /> 手動更新
|
||||
</button>
|
||||
{activeId === 'flash' && (
|
||||
<button style={{
|
||||
padding: '8px 14px', fontSize: 13, fontWeight: 600,
|
||||
background: '#fff', color: c1,
|
||||
border: 'none', borderRadius: 4,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<Icon name="bell" size={12} /> 發送通知
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Hero KPI 區(右側,4 顆數字) =====
|
||||
const CampaignKPIs = ({ stats, schedule }) => {
|
||||
const kpis = [
|
||||
{ label: '上架商品', value: stats.listed, color: 'var(--momo-text-primary)' },
|
||||
{ label: '新品', value: stats.new, color: '#3b82f6' },
|
||||
{ label: '漲價', value: stats.up, color: 'var(--momo-danger)' },
|
||||
{ label: '降價', value: stats.down, color: 'var(--momo-success)' },
|
||||
];
|
||||
return (
|
||||
<Card cardStyle="flat" padding={false} style={{
|
||||
background: 'var(--momo-bg-surface)',
|
||||
border: '1px solid var(--momo-border-light)', borderRadius: 8,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--momo-border-light)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Icon name="trendUp" size={14} />
|
||||
<span style={{ fontSize: 13, fontWeight: 700, letterSpacing: '0.02em' }}>活動數據</span>
|
||||
</div>
|
||||
<div style={{ padding: 20, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, flex: 1 }}>
|
||||
{kpis.map(k => (
|
||||
<div key={k.label} style={{
|
||||
padding: 12, background: 'var(--momo-bg-paper)',
|
||||
border: '1px solid var(--momo-border-light)', borderRadius: 6,
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: 'var(--momo-text-secondary)' }}>{k.label}</div>
|
||||
<div className="momo-mono" style={{ fontSize: 26, fontWeight: 800, color: k.color, letterSpacing: '-0.02em', lineHeight: 1 }}>
|
||||
{k.value.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 排程列 */}
|
||||
<div style={{
|
||||
padding: '10px 20px', borderTop: '1px solid var(--momo-border-light)',
|
||||
background: 'var(--momo-bg-paper)',
|
||||
display: 'flex', alignItems: 'center', gap: 8, fontSize: 11,
|
||||
fontFamily: 'var(--momo-font-family-mono)', color: 'var(--momo-text-secondary)',
|
||||
borderRadius: '0 0 8px 8px',
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--momo-success)' }} />
|
||||
<span>排程 · {schedule.lastRun}</span>
|
||||
<span style={{ color: 'var(--momo-border)' }}>|</span>
|
||||
<span>異動 {schedule.anomalies} 筆</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== 時段切片改為時間軸(限時搶購用) =====
|
||||
const TimeSlotTimeline = ({ slots }) => {
|
||||
const max = Math.max(...slots.map(s => s.count), 1);
|
||||
return (
|
||||
<Card cardStyle="flat" padding={false} style={{
|
||||
padding: 20, background: 'var(--momo-bg-surface)',
|
||||
border: '1px solid var(--momo-border-light)', borderRadius: 8,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Icon name="clock" size={14} />
|
||||
<span style={{ fontSize: 13, fontWeight: 700, letterSpacing: '0.02em' }}>時段排程</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--momo-text-tertiary)', fontFamily: 'var(--momo-font-family-mono)' }}>
|
||||
24H · {slots.reduce((a, b) => a + b.count, 0)} 件
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${slots.length}, 1fr)`, gap: 8, alignItems: 'end' }}>
|
||||
{slots.map((s, i) => {
|
||||
const heightPct = max > 0 ? (s.count / max) * 100 : 0;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
{/* bar 區,高度固定 80px */}
|
||||
<div style={{ height: 80, width: '100%', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 36,
|
||||
height: `${Math.max(heightPct, 4)}%`,
|
||||
background: s.active ? 'var(--momo-accent)' : 'var(--momo-text-primary)',
|
||||
opacity: s.active ? 1 : (s.count === 0 ? 0.15 : 0.7),
|
||||
borderRadius: '3px 3px 0 0',
|
||||
transition: 'all 0.2s',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{s.active && (
|
||||
<div style={{
|
||||
position: 'absolute', top: -22, left: '50%', transform: 'translateX(-50%)',
|
||||
padding: '2px 6px', borderRadius: 3,
|
||||
background: 'var(--momo-accent)', color: '#fff',
|
||||
fontSize: 10, fontWeight: 700, fontFamily: 'var(--momo-font-family-mono)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>NOW</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="momo-mono" style={{
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--momo-text-primary)',
|
||||
}}>{s.time}</div>
|
||||
<div className="momo-mono" style={{
|
||||
fontSize: 10, color: s.active ? 'var(--momo-accent)' : 'var(--momo-text-tertiary)',
|
||||
fontWeight: s.active ? 700 : 500,
|
||||
}}>{s.count}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== 分類 chip 列(橫向 scroll) =====
|
||||
const CategoryChips = ({ cats, activeIdx, setActive }) => (
|
||||
<Card cardStyle="flat" padding={false} style={{
|
||||
padding: '14px 20px', background: 'var(--momo-bg-surface)',
|
||||
border: '1px solid var(--momo-border-light)', borderRadius: 8,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
||||
<Icon name="tag" size={13} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.02em', color: 'var(--momo-text-primary)' }}>
|
||||
分類 <span className="momo-mono" style={{ fontWeight: 500, color: 'var(--momo-text-tertiary)', marginLeft: 4 }}>{cats.length}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{cats.map((cat, i) => {
|
||||
const isActive = activeIdx == null ? cat.active : i === activeIdx;
|
||||
return (
|
||||
<button key={i} onClick={() => setActive && setActive(i)} style={{
|
||||
padding: '6px 12px',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
fontSize: 12, fontWeight: 500,
|
||||
border: `1px solid ${isActive ? 'var(--momo-ink)' : 'var(--momo-border-light)'}`,
|
||||
background: isActive ? 'var(--momo-ink)' : 'var(--momo-bg-paper)',
|
||||
color: isActive ? '#fff' : 'var(--momo-text-secondary)',
|
||||
borderRadius: 4,
|
||||
transition: 'var(--momo-transition-base)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<span>{cat.name}</span>
|
||||
<span className="momo-mono" style={{
|
||||
fontSize: 10, fontWeight: 700,
|
||||
padding: '1px 5px', borderRadius: 2,
|
||||
background: isActive ? 'rgba(255,255,255,0.18)' : 'var(--momo-bg-subtle)',
|
||||
color: isActive ? 'rgba(255,255,255,0.9)' : 'var(--momo-text-tertiary)',
|
||||
}}>{cat.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// ===== 商品列表(與 Dashboard 同視覺) =====
|
||||
const Tag = ({ children, tone }) => {
|
||||
const tones = {
|
||||
info: { bg: '#dbeafe', fg: '#1e40af' },
|
||||
danger: { bg: '#fee2e2', fg: '#b91c1c' },
|
||||
success: { bg: '#d1fae5', fg: '#047857' },
|
||||
warning: { bg: '#fef3c7', fg: '#92400e' },
|
||||
};
|
||||
const t = tones[tone] || tones.info;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '1px 6px',
|
||||
fontSize: 10, fontWeight: 700,
|
||||
background: t.bg, color: t.fg, borderRadius: 3,
|
||||
letterSpacing: '0.02em',
|
||||
}}>{children}</span>
|
||||
);
|
||||
};
|
||||
|
||||
const CampaignProductRow = ({ p, isFlash, idx }) => (
|
||||
<tr style={{
|
||||
borderTop: idx === 0 ? 'none' : '1px solid var(--momo-border-light)',
|
||||
cursor: 'pointer', transition: 'var(--momo-transition-base)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--momo-bg-paper)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={{ padding: '14px 16px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '3px 10px', fontSize: 11,
|
||||
background: '#fff3cd', color: '#7a4f01', borderRadius: 12, whiteSpace: 'nowrap',
|
||||
}}>{p.cat}</span>
|
||||
</td>
|
||||
<td style={{ padding: '14px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 4,
|
||||
background: 'var(--momo-bg-subtle)', border: '1px solid var(--momo-border-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, flexShrink: 0,
|
||||
}}>{p.emoji}</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 500, color: 'var(--momo-text-primary)',
|
||||
lineHeight: 1.4, marginBottom: 2,
|
||||
display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||
}}>{p.name}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--momo-text-tertiary)' }}>
|
||||
<span className="momo-mono">ID: {p.id}</span>
|
||||
<span style={{ width: 14, height: 14, color: 'var(--momo-text-tertiary)', display: 'inline-flex' }}>
|
||||
<Icon name="copy" size={11} />
|
||||
</span>
|
||||
{p.new && <Tag tone="info">NEW</Tag>}
|
||||
{p.delisted && <Tag tone="warning">下架</Tag>}
|
||||
{p.up && <Tag tone="danger">漲價</Tag>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '14px 16px', textAlign: 'right' }}>
|
||||
{p.up && p.change ? (
|
||||
<div>
|
||||
<div className="momo-mono" style={{ fontSize: 11, color: 'var(--momo-danger)', fontWeight: 600, display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 9 }}>▲</span> +${p.change} ({p.changePct}%)
|
||||
</div>
|
||||
<div className="momo-mono" style={{ marginTop: 2, display: 'flex', justifyContent: 'flex-end', alignItems: 'baseline', gap: 6 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--momo-text-tertiary)', textDecoration: 'line-through' }}>
|
||||
${p.oldPrice.toLocaleString()}
|
||||
</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--momo-text-primary)' }}>
|
||||
${p.price.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="momo-mono" style={{ fontSize: 14, fontWeight: 700, color: 'var(--momo-text-primary)' }}>
|
||||
${p.price.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '14px 16px', textAlign: 'right' }}>
|
||||
{isFlash && p.limit ? (
|
||||
<span className="momo-mono" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '4px 10px',
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
color: '#7a4f01', border: '1px solid #f9d77a',
|
||||
borderRadius: 12, fontSize: 12, fontWeight: 700,
|
||||
}}>
|
||||
🔥 {p.limit}組
|
||||
</span>
|
||||
) : p.off ? (
|
||||
<Tag tone="danger">{p.off}</Tag>
|
||||
) : (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
padding: '3px 10px',
|
||||
background: 'var(--momo-bg-subtle)', color: 'var(--momo-text-secondary)',
|
||||
borderRadius: 12, fontSize: 11, fontWeight: 500,
|
||||
}}>{p.status || '活動中'}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const CampaignProductTable = ({ products, total, isFlash }) => (
|
||||
<Card cardStyle="flat" padding={false} style={{
|
||||
background: 'var(--momo-bg-surface)',
|
||||
border: '1px solid var(--momo-border-light)', borderRadius: 8,
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '14px 20px', borderBottom: '1px solid var(--momo-border-light)',
|
||||
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--momo-text-primary)' }}>
|
||||
商品列表 <span style={{ fontWeight: 500, color: 'var(--momo-text-secondary)' }}>({total.toLocaleString()} 筆)</span>
|
||||
</div>
|
||||
<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: 'linear-gradient(90deg, #6c5ce7 0%, #5a4cdb 100%)', color: '#fff' }}>
|
||||
{[
|
||||
{ label: '分類', w: 140 },
|
||||
{ label: '商品資訊' },
|
||||
{ label: '價格', w: 150, align: 'right' },
|
||||
{ label: isFlash ? '倒數組數' : '狀態', w: 130, align: 'right' },
|
||||
].map((h, i) => (
|
||||
<th key={i} style={{
|
||||
padding: '12px 16px', textAlign: h.align || 'left', width: h.w,
|
||||
fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{h.label} <span style={{ fontSize: 9, opacity: 0.7, marginLeft: 2 }}>↕</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p, i) => <CampaignProductRow key={p.id} p={p} isFlash={isFlash} idx={i} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// ===== Page =====
|
||||
const CampaignsPage = ({ density = 'comfortable' }) => {
|
||||
const D = EWOOOC_DATA.campaigns;
|
||||
const [active, setActive] = React.useState('flash');
|
||||
const [catIdx, setCatIdx] = React.useState(null);
|
||||
const c = D[active];
|
||||
|
||||
React.useEffect(() => { setCatIdx(null); }, [active]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* 活動切換 */}
|
||||
<CampaignSwitcher active={active} setActive={setActive} campaigns={D} />
|
||||
|
||||
{/* Hero 雙欄 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 16 }}>
|
||||
<CampaignHero c={c} activeId={active} />
|
||||
<CampaignKPIs stats={c.stats} schedule={c.schedule} />
|
||||
</div>
|
||||
|
||||
{/* 時段時間軸(僅限時搶購) */}
|
||||
{c.timeSlots && <TimeSlotTimeline slots={c.timeSlots} />}
|
||||
|
||||
{/* 分類 chips */}
|
||||
{c.categories && <CategoryChips cats={c.categories} activeIdx={catIdx} setActive={setCatIdx} />}
|
||||
|
||||
{/* 商品列表 */}
|
||||
<CampaignProductTable products={c.products} total={c.total} isFlash={active === 'flash'} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.CampaignsPage = CampaignsPage;
|
||||
384
MOMO Pro/app/page-dashboard.jsx
Normal file
@@ -0,0 +1,384 @@
|
||||
// EwoooC - 商品看板(Nothing × Claude 美學:安靜、結構化、Mono 為主)
|
||||
|
||||
// ===== 編號標籤(呼應 sidebar 的 01/02/03) =====
|
||||
const SectionLabel = ({ num, children, sub }) => (
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 12 }}>
|
||||
<span className="momo-mono" style={{
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--momo-text-tertiary)',
|
||||
letterSpacing: '0.08em',
|
||||
}}>{num}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--momo-text-primary)', letterSpacing: '0.02em' }}>
|
||||
{children}
|
||||
</span>
|
||||
{sub && (
|
||||
<span className="momo-mono" style={{ fontSize: 10, color: 'var(--momo-text-tertiary)', marginLeft: 'auto' }}>
|
||||
{sub}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ===== KPI 大數字(4 顆並排,扁平、靠線分隔) =====
|
||||
const KPIRow = ({ stats, dynamics }) => {
|
||||
const items = [
|
||||
{ label: '監控總數', value: stats.total.toLocaleString(), sub: `本週 +${stats.weekGrowth}` },
|
||||
{ label: '今日變動', value: dynamics.activeCount, sub: `活躍度 ${dynamics.activity}%`, accent: true },
|
||||
{ label: '漲價', value: dynamics.priceUp, sub: `平均 +$${dynamics.avgUp}`, tone: 'danger' },
|
||||
{ label: '降價', value: dynamics.priceDown, sub: `平均 -$${Math.abs(dynamics.avgDown)}`, tone: 'success' },
|
||||
];
|
||||
const toneColor = (t) => t === 'danger' ? 'var(--momo-danger)' : t === 'success' ? 'var(--momo-success)' : 'var(--momo-text-primary)';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
background: 'var(--momo-bg-surface)',
|
||||
border: '1px solid var(--momo-border-light)',
|
||||
borderRadius: 8, overflow: 'hidden',
|
||||
}}>
|
||||
{items.map((it, i) => (
|
||||
<div key={i} style={{
|
||||
padding: '20px 24px',
|
||||
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',
|
||||
}}>
|
||||
<div className="momo-mono" style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: '0.1em',
|
||||
color: it.accent ? 'rgba(250,247,240,0.6)' : 'var(--momo-text-tertiary)',
|
||||
textTransform: 'uppercase', marginBottom: 10,
|
||||
}}>
|
||||
{it.label}
|
||||
</div>
|
||||
<div className="momo-mono" style={{
|
||||
fontSize: 44, fontWeight: 700,
|
||||
color: it.accent ? '#faf7f0' : toneColor(it.tone),
|
||||
letterSpacing: '-0.04em', lineHeight: 1, marginBottom: 8,
|
||||
}}>
|
||||
{typeof it.value === 'number' ? it.value.toLocaleString() : it.value}
|
||||
</div>
|
||||
<div className="momo-mono" style={{
|
||||
fontSize: 11,
|
||||
color: it.accent ? 'rgba(250,247,240,0.7)' : 'var(--momo-text-secondary)',
|
||||
}}>
|
||||
{it.sub}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== 焦點 + 排程(雙欄,安靜版) =====
|
||||
const FocusRow = ({ dynamics, schedule, stats }) => (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||
{/* 最活躍分類 */}
|
||||
<div style={{
|
||||
padding: 18, background: 'var(--momo-bg-surface)',
|
||||
border: '1px solid var(--momo-border-light)', borderRadius: 8,
|
||||
}}>
|
||||
<div className="momo-mono" style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: '0.1em',
|
||||
color: 'var(--momo-text-tertiary)', textTransform: 'uppercase', marginBottom: 8,
|
||||
}}>最活躍分類</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--momo-text-primary)', marginBottom: 4, lineHeight: 1.3 }}>
|
||||
{dynamics.hottestCategory}
|
||||
</div>
|
||||
<div className="momo-mono" style={{ fontSize: 11, color: 'var(--momo-text-secondary)' }}>
|
||||
{dynamics.hottestCount} 件商品變動
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最大變動 */}
|
||||
<div style={{
|
||||
padding: 18, background: 'var(--momo-bg-surface)',
|
||||
border: '1px solid var(--momo-border-light)', borderRadius: 8,
|
||||
}}>
|
||||
<div className="momo-mono" style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: '0.1em',
|
||||
color: 'var(--momo-text-tertiary)', textTransform: 'uppercase', marginBottom: 8,
|
||||
}}>最大變動</div>
|
||||
<div className="momo-mono" style={{
|
||||
fontSize: 24, fontWeight: 700, color: 'var(--momo-danger)',
|
||||
letterSpacing: '-0.02em', lineHeight: 1, marginBottom: 6,
|
||||
}}>
|
||||
+${dynamics.biggestChange.amount.toLocaleString()}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11, color: 'var(--momo-text-secondary)',
|
||||
display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||
}}>{dynamics.biggestChange.product}</div>
|
||||
</div>
|
||||
|
||||
{/* 爬蟲排程 */}
|
||||
<div style={{
|
||||
padding: 18, 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: 10, fontWeight: 700, letterSpacing: '0.1em',
|
||||
color: 'var(--momo-text-tertiary)', textTransform: 'uppercase',
|
||||
}}>爬蟲排程</span>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10, 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: 24, fontWeight: 700, color: 'var(--momo-text-primary)',
|
||||
letterSpacing: '-0.02em', lineHeight: 1, marginBottom: 6,
|
||||
}}>{schedule.lastRun}</div>
|
||||
<div className="momo-mono" style={{ fontSize: 11, 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: '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>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== 商品列表(黑色表頭、Mono、安靜) =====
|
||||
const ProductTable = ({ products, total, schedule, onRowClick }) => (
|
||||
<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: 11, fontWeight: 700, color: 'var(--momo-text-tertiary)', 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-tertiary)' }}>
|
||||
{total.toLocaleString()} 筆
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ width: 1, height: 14, background: 'var(--momo-border-light)' }} />
|
||||
<span className="momo-mono" style={{ fontSize: 11, 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: 130 },
|
||||
{ label: '商品名稱' },
|
||||
{ label: '當天價格', w: 120, align: 'right' },
|
||||
{ label: '昨日漲跌', w: 110, align: 'right' },
|
||||
{ label: '週漲跌', w: 110, align: 'right' },
|
||||
{ label: '更新時間', w: 120, align: 'right' },
|
||||
{ label: '上架時間', w: 110, align: 'right' },
|
||||
].map((h, i) => (
|
||||
<th key={i} style={{
|
||||
padding: '10px 16px', textAlign: h.align || 'left', width: h.w,
|
||||
fontSize: 10, fontWeight: 700, whiteSpace: 'nowrap',
|
||||
color: 'var(--momo-text-tertiary)',
|
||||
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>
|
||||
{products.map((p, idx) => (
|
||||
<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)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--momo-bg-paper)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={{ padding: '14px 16px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '2px 8px', fontSize: 11,
|
||||
background: 'var(--momo-bg-paper)',
|
||||
border: '1px solid var(--momo-border-light)',
|
||||
color: 'var(--momo-text-secondary)',
|
||||
borderRadius: 3, whiteSpace: 'nowrap',
|
||||
fontFamily: 'var(--momo-font-family-base)',
|
||||
}}>{p.category}</span>
|
||||
</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: 13, fontWeight: 500, color: 'var(--momo-text-primary)',
|
||||
lineHeight: 1.4, marginBottom: 2,
|
||||
display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||
}}>{p.name}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--momo-text-tertiary)' }}>
|
||||
<span className="momo-mono">ID · {p.id}</span>
|
||||
<span style={{ width: 12, height: 12, color: 'var(--momo-text-tertiary)', display: 'inline-flex' }}>
|
||||
<Icon name="copy" size={10} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '14px 16px', textAlign: 'right' }}>
|
||||
<span className="momo-mono" style={{ fontSize: 14, fontWeight: 700, color: 'var(--momo-text-primary)' }}>
|
||||
${p.price.toLocaleString()}
|
||||
</span>
|
||||
</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: 11, color: 'var(--momo-text-secondary)' }}>{p.updatedAt}</td>
|
||||
<td style={{ padding: '14px 16px', textAlign: 'right', fontFamily: 'var(--momo-font-family-mono)', fontSize: 11, color: 'var(--momo-text-tertiary)' }}>{p.listedAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ===== Page =====
|
||||
const DashboardPage = ({ density = 'comfortable' }) => {
|
||||
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 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;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{/* 區塊 1:KPI 一排 */}
|
||||
<section>
|
||||
<SectionLabel num="01" sub="LIVE · 更新於 12:54">監控總覽</SectionLabel>
|
||||
<KPIRow stats={m} dynamics={p} />
|
||||
</section>
|
||||
|
||||
{/* 區塊 2:焦點數據 */}
|
||||
<section>
|
||||
<SectionLabel num="02" sub="今日">焦點數據</SectionLabel>
|
||||
<FocusRow dynamics={p} schedule={D.schedule} stats={m} />
|
||||
</section>
|
||||
|
||||
{/* 區塊 3:篩選 + 列表 */}
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SectionLabel num="03" 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} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.DashboardPage = DashboardPage;
|
||||
290
MOMO Pro/app/page-orders.jsx
Normal file
@@ -0,0 +1,290 @@
|
||||
// MOMO Pro - 訂單管理頁
|
||||
|
||||
const OrdersPage = ({ density = 'comfortable', cardStyle = 'shadow', buttonStyle = 'gradient' }) => {
|
||||
const [selected, setSelected] = React.useState(new Set());
|
||||
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||
const [search, setSearch] = React.useState('');
|
||||
|
||||
const filtered = MOMO_DATA.orders.filter(o => {
|
||||
if (statusFilter !== 'all' && o.status !== statusFilter) return false;
|
||||
if (search && !o.id.toLowerCase().includes(search.toLowerCase()) && !o.customer.includes(search)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selected.size === filtered.length) setSelected(new Set());
|
||||
else setSelected(new Set(filtered.map(o => o.id)));
|
||||
};
|
||||
const toggleOne = (id) => {
|
||||
const s = new Set(selected);
|
||||
if (s.has(id)) s.delete(id); else s.add(id);
|
||||
setSelected(s);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: '全部', count: MOMO_DATA.orders.length },
|
||||
{ id: 'pending', label: '待處理', count: MOMO_DATA.orders.filter(o => o.status === 'pending').length },
|
||||
{ id: 'processing', label: '處理中', count: MOMO_DATA.orders.filter(o => o.status === 'processing').length },
|
||||
{ id: 'shipped', label: '已出貨', count: MOMO_DATA.orders.filter(o => o.status === 'shipped').length },
|
||||
{ id: 'completed', label: '已完成', count: MOMO_DATA.orders.filter(o => o.status === 'completed').length },
|
||||
{ id: 'cancelled', label: '已取消', count: MOMO_DATA.orders.filter(o => o.status === 'cancelled').length },
|
||||
];
|
||||
|
||||
const rowPad = density === 'compact' ? '8px 16px' : '14px 16px';
|
||||
const headPad = density === 'compact' ? '8px 16px' : '12px 16px';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="訂單管理"
|
||||
subtitle={`共 ${MOMO_DATA.orders.length} 筆訂單 · 24 筆待處理`}
|
||||
breadcrumbs={['首頁', '訂單', '訂單列表']}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" size="md" icon="download">匯出 CSV</Button>
|
||||
<Button variant={buttonStyle} size="md" icon="plus">建立訂單</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
borderBottom: '1px solid var(--momo-border-light)',
|
||||
marginBottom: 'var(--momo-space-4)',
|
||||
overflowX: 'auto',
|
||||
}} className="momo-scroll">
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} onClick={() => { setStatusFilter(t.id); setSelected(new Set()); }}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
fontSize: 'var(--momo-font-size-sm)',
|
||||
fontWeight: 'var(--momo-font-weight-medium)',
|
||||
color: statusFilter === t.id ? 'var(--momo-primary-700)' : 'var(--momo-text-secondary)',
|
||||
borderBottom: `2px solid ${statusFilter === t.id ? 'var(--momo-primary)' : 'transparent'}`,
|
||||
marginBottom: -1,
|
||||
transition: 'var(--momo-transition-base)',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{t.label}
|
||||
<span style={{
|
||||
padding: '1px 7px',
|
||||
fontSize: 10, fontWeight: 700,
|
||||
borderRadius: 'var(--momo-radius-pill)',
|
||||
background: statusFilter === t.id ? 'var(--momo-primary-100)' : 'var(--momo-bg-muted)',
|
||||
color: statusFilter === t.id ? 'var(--momo-primary-700)' : 'var(--momo-text-tertiary)',
|
||||
}}>{t.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card cardStyle={cardStyle} padding={false}>
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
padding: '14px 20px',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
borderBottom: '1px solid var(--momo-border-light)',
|
||||
}}>
|
||||
<div style={{ flex: 1, maxWidth: 320 }}>
|
||||
<Input icon="search" placeholder="搜尋訂單編號、會員姓名…" value={search} onChange={e => setSearch(e.target.value)} size="sm" />
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" icon="filter">進階篩選</Button>
|
||||
<Button variant="secondary" size="sm" icon="calendar">日期區間</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button variant="ghost" size="sm" icon="refresh" />
|
||||
<Button variant="ghost" size="sm" icon="settings" />
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selected.size > 0 && (
|
||||
<div style={{
|
||||
padding: '10px 20px',
|
||||
background: 'var(--momo-primary-50)',
|
||||
borderBottom: '1px solid var(--momo-primary-100)',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
fontSize: 'var(--momo-font-size-sm)',
|
||||
animation: 'momo-fade-in 0.2s var(--momo-ease-out)',
|
||||
}}>
|
||||
<span style={{ color: 'var(--momo-primary-700)', fontWeight: 600 }}>
|
||||
已選取 {selected.size} 筆訂單
|
||||
</span>
|
||||
<div style={{ width: 1, height: 16, background: 'var(--momo-primary-200)' }} />
|
||||
<button style={{ fontSize: 13, color: 'var(--momo-primary-700)', fontWeight: 500 }}>標記為已處理</button>
|
||||
<button style={{ fontSize: 13, color: 'var(--momo-primary-700)', fontWeight: 500 }}>列印出貨單</button>
|
||||
<button style={{ fontSize: 13, color: 'var(--momo-primary-700)', fontWeight: 500 }}>批次匯出</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => setSelected(new Set())}
|
||||
style={{ fontSize: 13, color: 'var(--momo-text-secondary)' }}>取消選取</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--momo-font-size-sm)' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--momo-bg-subtle)' }}>
|
||||
<th style={{ padding: headPad, width: 40 }}>
|
||||
<Checkbox
|
||||
checked={selected.size === filtered.length && filtered.length > 0}
|
||||
indeterminate={selected.size > 0 && selected.size < filtered.length}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</th>
|
||||
{[
|
||||
{ label: '訂單編號', sort: true },
|
||||
{ label: '會員', sort: false },
|
||||
{ label: '金額', sort: true, align: 'right' },
|
||||
{ label: '商品', sort: false, align: 'right' },
|
||||
{ label: '狀態', sort: false },
|
||||
{ label: '付款', sort: false },
|
||||
{ label: '通路', sort: false },
|
||||
{ label: '建立時間', sort: true },
|
||||
{ label: '', sort: false, align: 'right' },
|
||||
].map((h, i) => (
|
||||
<th key={i} style={{
|
||||
padding: headPad,
|
||||
textAlign: h.align || 'left',
|
||||
fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--momo-text-secondary)',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{h.sort ? (
|
||||
<button style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'inherit', fontSize: 'inherit', fontWeight: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit' }}>
|
||||
{h.label}
|
||||
<Icon name="chevronDown" size={11} />
|
||||
</button>
|
||||
) : h.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(order => {
|
||||
const isSelected = selected.has(order.id);
|
||||
return (
|
||||
<tr key={order.id} style={{
|
||||
borderTop: '1px solid var(--momo-border-light)',
|
||||
background: isSelected ? 'var(--momo-primary-50)' : 'transparent',
|
||||
transition: 'background var(--momo-duration-fast)',
|
||||
}}
|
||||
onMouseEnter={e => !isSelected && (e.currentTarget.style.background = 'var(--momo-bg-subtle)')}
|
||||
onMouseLeave={e => !isSelected && (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<td style={{ padding: rowPad }}>
|
||||
<Checkbox checked={isSelected} onChange={() => toggleOne(order.id)} />
|
||||
</td>
|
||||
<td style={{ padding: rowPad }}>
|
||||
<a href="#" style={{
|
||||
color: 'var(--momo-text-link)',
|
||||
fontFamily: 'var(--momo-font-family-mono)',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
}}>{order.id}</a>
|
||||
</td>
|
||||
<td style={{ padding: rowPad }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Avatar name={order.customer} size={28} />
|
||||
<div style={{ lineHeight: 1.3 }}>
|
||||
<div style={{ fontWeight: 500, color: 'var(--momo-text-primary)' }}>{order.customer}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--momo-text-tertiary)' }}>{order.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: rowPad, textAlign: 'right', fontFamily: 'var(--momo-font-family-mono)', fontWeight: 600, color: 'var(--momo-text-primary)' }}>
|
||||
NT$ {order.total.toLocaleString()}
|
||||
</td>
|
||||
<td style={{ padding: rowPad, textAlign: 'right', color: 'var(--momo-text-secondary)' }}>
|
||||
{order.items} 項
|
||||
</td>
|
||||
<td style={{ padding: rowPad }}>
|
||||
<Badge tone={STATUS_MAP[order.status].tone} dot>{STATUS_MAP[order.status].label}</Badge>
|
||||
</td>
|
||||
<td style={{ padding: rowPad }}>
|
||||
<Badge tone={STATUS_MAP[order.payment].tone}>{STATUS_MAP[order.payment].label}</Badge>
|
||||
</td>
|
||||
<td style={{ padding: rowPad }}>
|
||||
<span style={{
|
||||
fontSize: 11, color: 'var(--momo-text-secondary)',
|
||||
background: 'var(--momo-bg-subtle)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--momo-radius-sm)',
|
||||
}}>{order.channel}</span>
|
||||
</td>
|
||||
<td style={{ padding: rowPad, color: 'var(--momo-text-secondary)', fontSize: 12, fontFamily: 'var(--momo-font-family-mono)', whiteSpace: 'nowrap' }}>
|
||||
{order.date}
|
||||
</td>
|
||||
<td style={{ padding: rowPad, textAlign: 'right' }}>
|
||||
<div style={{ display: 'inline-flex', gap: 2 }}>
|
||||
<button title="檢視" style={{
|
||||
width: 28, height: 28, borderRadius: 6,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--momo-text-secondary)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--momo-bg-muted)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<Icon name="eye" size={15} />
|
||||
</button>
|
||||
<button title="編輯" style={{
|
||||
width: 28, height: 28, borderRadius: 6,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--momo-text-secondary)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--momo-bg-muted)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<Icon name="edit" size={15} />
|
||||
</button>
|
||||
<button title="更多" style={{
|
||||
width: 28, height: 28, borderRadius: 6,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--momo-text-secondary)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--momo-bg-muted)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<Icon name="moreHorizontal" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div style={{
|
||||
padding: '14px 20px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
borderTop: '1px solid var(--momo-border-light)',
|
||||
fontSize: 'var(--momo-font-size-sm)',
|
||||
color: 'var(--momo-text-secondary)',
|
||||
}}>
|
||||
<div>顯示 1-{filtered.length} 筆,共 {filtered.length} 筆結果</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Button variant="ghost" size="sm" icon="chevronLeft" disabled />
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button key={n} style={{
|
||||
width: 30, height: 30,
|
||||
borderRadius: 'var(--momo-radius-md)',
|
||||
fontSize: 13, fontWeight: 500,
|
||||
background: n === 1 ? 'var(--momo-primary-100)' : 'transparent',
|
||||
color: n === 1 ? 'var(--momo-primary-700)' : 'var(--momo-text-secondary)',
|
||||
}}>{n}</button>
|
||||
))}
|
||||
<span style={{ padding: '0 4px', color: 'var(--momo-text-tertiary)' }}>…</span>
|
||||
<button style={{
|
||||
width: 30, height: 30, borderRadius: 'var(--momo-radius-md)',
|
||||
fontSize: 13, color: 'var(--momo-text-secondary)',
|
||||
}}>32</button>
|
||||
<Button variant="ghost" size="sm" icon="chevronRight" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.OrdersPage = OrdersPage;
|
||||
200
MOMO Pro/app/page-products.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
// EwoooC - 商品列表(對應截圖:價格 / 昨日漲跌 / 週漲跌 / 上次更新 / 上架時間 / 點擊彈窗)
|
||||
|
||||
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: 4,
|
||||
color: up ? 'var(--momo-danger)' : 'var(--momo-success)',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
<span style={{ fontSize: 9 }}>{up ? '▲' : '▼'}</span>
|
||||
{up ? '+' : ''}{value.toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const ProductsPage = ({ density = 'comfortable', cardStyle = 'flat', buttonStyle = 'primary', onEditProduct }) => {
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [category, setCategory] = React.useState('all');
|
||||
|
||||
const D = EWOOOC_DATA;
|
||||
const cats = ['all', ...new Set(D.products.map(p => p.category))];
|
||||
const filtered = D.products.filter(p => {
|
||||
if (category !== 'all' && p.category !== category) return false;
|
||||
if (search && !p.name.includes(search) && !String(p.id).toLowerCase().includes(search.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const rowPad = density === 'compact' ? '10px 16px' : '14px 16px';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={<>商品列表 <span className="momo-mono" style={{ fontSize: 14, color: 'var(--momo-text-tertiary)', marginLeft: 12, letterSpacing: '0.04em' }}>商品監控</span></>}
|
||||
subtitle={`監控 ${D.monitorStats.total.toLocaleString()} 件商品 · 點擊任一列查看 30 天價格走勢`}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" size="md" icon="upload">批次匯入</Button>
|
||||
<Button variant="primary" size="md" icon="plus">新增監控</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card cardStyle="flat" padding={false} style={{ background: 'var(--momo-bg-surface)', border: '1px solid var(--momo-border-light)', borderRadius: 4 }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
padding: '14px 20px',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
borderBottom: '1px solid var(--momo-border-light)',
|
||||
flexWrap: 'wrap',
|
||||
background: 'var(--momo-bg-paper)',
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 200, maxWidth: 320 }}>
|
||||
<Input icon="search" placeholder="搜尋商品名稱、編號…" value={search} onChange={e => setSearch(e.target.value)} size="sm" />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{cats.slice(0, 6).map(c => (
|
||||
<button key={c} onClick={() => setCategory(c)} style={{
|
||||
padding: '6px 12px',
|
||||
fontSize: 12, fontWeight: 500,
|
||||
fontFamily: 'var(--momo-font-family-mono)',
|
||||
borderRadius: 2,
|
||||
background: category === c ? 'var(--momo-accent)' : 'transparent',
|
||||
color: category === c ? '#faf7f0' : 'var(--momo-text-secondary)',
|
||||
border: `1px solid ${category === c ? 'var(--momo-accent)' : 'var(--momo-border-light)'}`,
|
||||
transition: 'var(--momo-transition-base)',
|
||||
}}>{c === 'all' ? '全部' : c}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<span className="momo-mono" style={{ fontSize: 11, color: 'var(--momo-text-tertiary)' }}>
|
||||
{filtered.length} / {D.products.length} 筆
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<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: '編號', sub: '商品編號', align: 'left', w: 130 },
|
||||
{ label: '分類', sub: '商品分類', align: 'left', w: 100 },
|
||||
{ label: '商品', sub: '商品名稱', align: 'left' },
|
||||
{ label: '售價', sub: '目前價格', align: 'right', w: 110 },
|
||||
{ label: '昨日', sub: '昨日漲跌', align: 'right', w: 100 },
|
||||
{ label: '本週', sub: '週漲跌', align: 'right', w: 100 },
|
||||
{ label: '更新', sub: '上次更新', align: 'right', w: 110 },
|
||||
{ label: '上架', sub: '上架時間', align: 'right', w: 110 },
|
||||
{ label: '', sub: '', align: 'right', w: 50 },
|
||||
].map((h, i) => (
|
||||
<th key={i} style={{
|
||||
padding: '10px 16px',
|
||||
textAlign: h.align,
|
||||
width: h.w,
|
||||
fontSize: 10, fontWeight: 600,
|
||||
fontFamily: 'var(--momo-font-family-mono)',
|
||||
color: 'var(--momo-text-tertiary)',
|
||||
letterSpacing: '0.06em',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<div>{h.label}</div>
|
||||
{h.sub && <div style={{ fontFamily: 'var(--momo-font-family-base)', fontSize: 11, color: 'var(--momo-text-secondary)', fontWeight: 500, letterSpacing: 0, marginTop: 1 }}>{h.sub}</div>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((p, idx) => (
|
||||
<tr key={p.id}
|
||||
onClick={() => onEditProduct && onEditProduct(p)}
|
||||
style={{
|
||||
borderTop: idx === 0 ? 'none' : '1px solid var(--momo-border-light)',
|
||||
cursor: 'pointer',
|
||||
transition: 'var(--momo-transition-base)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--momo-bg-paper)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={{ padding: rowPad, fontFamily: 'var(--momo-font-family-mono)', fontSize: 12, color: 'var(--momo-text-link)', fontWeight: 600 }}>
|
||||
#{p.id}
|
||||
</td>
|
||||
<td style={{ padding: rowPad }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
fontFamily: 'var(--momo-font-family-mono)',
|
||||
background: 'var(--momo-bg-subtle)',
|
||||
color: 'var(--momo-text-secondary)',
|
||||
borderRadius: 2,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{p.category}</span>
|
||||
</td>
|
||||
<td style={{ padding: rowPad }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: 'var(--momo-bg-subtle)', border: '1px solid var(--momo-border-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, flexShrink: 0,
|
||||
}}>{p.emoji}</div>
|
||||
<span style={{ fontWeight: 500, color: 'var(--momo-text-primary)', display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{p.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: rowPad, textAlign: 'right', fontFamily: 'var(--momo-font-family-mono)', fontWeight: 700, color: 'var(--momo-text-primary)' }}>
|
||||
${p.price.toLocaleString()}
|
||||
</td>
|
||||
<td style={{ padding: rowPad, textAlign: 'right' }}>
|
||||
<ChangeCell value={p.yesterdayChange} />
|
||||
</td>
|
||||
<td style={{ padding: rowPad, textAlign: 'right' }}>
|
||||
<ChangeCell value={p.weekChange} />
|
||||
</td>
|
||||
<td style={{ padding: rowPad, textAlign: 'right', fontFamily: 'var(--momo-font-family-mono)', fontSize: 11, color: 'var(--momo-text-secondary)' }}>
|
||||
{p.updatedAt}
|
||||
</td>
|
||||
<td style={{ padding: rowPad, textAlign: 'right', fontFamily: 'var(--momo-font-family-mono)', fontSize: 11, color: 'var(--momo-text-tertiary)' }}>
|
||||
{p.listedAt}
|
||||
</td>
|
||||
<td style={{ padding: rowPad, textAlign: 'right' }}>
|
||||
<span style={{ color: 'var(--momo-text-tertiary)' }}>
|
||||
<Icon name="chevronRight" size={14} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: '1px solid var(--momo-border-light)',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
background: 'var(--momo-bg-paper)',
|
||||
}}>
|
||||
<span className="momo-mono" style={{ fontSize: 11, color: 'var(--momo-text-tertiary)' }}>
|
||||
顯示 1–{filtered.length} 筆,共 {D.monitorStats.total.toLocaleString()} 筆
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button style={{ padding: '4px 10px', fontSize: 11, fontFamily: 'var(--momo-font-family-mono)', border: '1px solid var(--momo-border)', borderRadius: 2, color: 'var(--momo-text-secondary)' }}>← 上一頁</button>
|
||||
<button style={{ padding: '4px 10px', fontSize: 11, fontFamily: 'var(--momo-font-family-mono)', border: '1px solid var(--momo-accent)', borderRadius: 2, background: 'var(--momo-accent)', color: '#faf7f0', fontWeight: 600 }}>1</button>
|
||||
<button style={{ padding: '4px 10px', fontSize: 11, fontFamily: 'var(--momo-font-family-mono)', border: '1px solid var(--momo-border)', borderRadius: 2, color: 'var(--momo-text-secondary)' }}>2</button>
|
||||
<button style={{ padding: '4px 10px', fontSize: 11, fontFamily: 'var(--momo-font-family-mono)', border: '1px solid var(--momo-border)', borderRadius: 2, color: 'var(--momo-text-secondary)' }}>3</button>
|
||||
<button style={{ padding: '4px 10px', fontSize: 11, fontFamily: 'var(--momo-font-family-mono)', border: '1px solid var(--momo-border)', borderRadius: 2, color: 'var(--momo-text-secondary)' }}>下一頁 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.ProductsPage = ProductsPage;
|
||||
325
MOMO Pro/app/shell.jsx
Normal file
@@ -0,0 +1,325 @@
|
||||
// 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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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;
|
||||
|
||||
// 淺色側邊欄改用米色 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 isActive = active === item.id;
|
||||
return (
|
||||
<button key={item.id} onClick={() => onNavigate(item.id)}
|
||||
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>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</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 });
|
||||
233
MOMO Pro/app/ui.jsx
Normal file
@@ -0,0 +1,233 @@
|
||||
// MOMO Pro - 共用 UI 元件
|
||||
|
||||
// ===== Badge =====
|
||||
const Badge = ({ tone = 'secondary', children, dot = false, style }) => {
|
||||
const tones = {
|
||||
primary: { bg: 'var(--momo-primary-100)', text: 'var(--momo-primary-700)', dot: 'var(--momo-primary)' },
|
||||
success: { bg: 'var(--momo-success-bg)', text: 'var(--momo-success-text)', dot: 'var(--momo-success)' },
|
||||
danger: { bg: 'var(--momo-danger-bg)', text: 'var(--momo-danger-text)', dot: 'var(--momo-danger)' },
|
||||
warning: { bg: 'var(--momo-warning-bg)', text: 'var(--momo-warning-text)', dot: 'var(--momo-warning)' },
|
||||
info: { bg: 'var(--momo-info-bg)', text: 'var(--momo-info-text)', dot: 'var(--momo-info)' },
|
||||
secondary: { bg: 'var(--momo-bg-muted)', text: 'var(--momo-text-secondary)', dot: 'var(--momo-text-tertiary)' },
|
||||
};
|
||||
const t = tones[tone] || tones.secondary;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '3px 10px',
|
||||
background: t.bg, color: t.text,
|
||||
fontSize: 'var(--momo-font-size-xs)',
|
||||
fontWeight: 'var(--momo-font-weight-medium)',
|
||||
borderRadius: 'var(--momo-radius-pill)',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'nowrap',
|
||||
...style,
|
||||
}}>
|
||||
{dot && <span style={{ width: 6, height: 6, borderRadius: '50%', background: t.dot }} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Button =====
|
||||
const Button = ({ variant = 'gradient', size = 'md', icon, iconRight, children, onClick, style, disabled, type = 'button' }) => {
|
||||
const sizes = {
|
||||
sm: { padding: '6px 12px', fontSize: 'var(--momo-font-size-xs)', height: 30, gap: 6 },
|
||||
md: { padding: '8px 16px', fontSize: 'var(--momo-font-size-sm)', height: 38, gap: 8 },
|
||||
lg: { padding: '10px 20px', fontSize: 'var(--momo-font-size-base)', height: 44, gap: 10 },
|
||||
};
|
||||
const variants = {
|
||||
gradient: {
|
||||
background: 'var(--momo-gradient-primary)',
|
||||
color: 'var(--momo-text-inverse)',
|
||||
boxShadow: 'var(--momo-shadow-sm)',
|
||||
},
|
||||
solid: {
|
||||
background: 'var(--momo-primary)',
|
||||
color: 'var(--momo-text-inverse)',
|
||||
},
|
||||
outline: {
|
||||
background: 'var(--momo-bg-surface)',
|
||||
color: 'var(--momo-primary)',
|
||||
border: '1.5px solid var(--momo-primary)',
|
||||
},
|
||||
ghost: {
|
||||
background: 'transparent',
|
||||
color: 'var(--momo-text-secondary)',
|
||||
},
|
||||
'ghost-hover': {
|
||||
background: 'var(--momo-bg-subtle)',
|
||||
color: 'var(--momo-text-primary)',
|
||||
},
|
||||
secondary: {
|
||||
background: 'var(--momo-bg-surface)',
|
||||
color: 'var(--momo-text-primary)',
|
||||
border: '1px solid var(--momo-border)',
|
||||
},
|
||||
danger: {
|
||||
background: 'var(--momo-danger)',
|
||||
color: 'var(--momo-text-inverse)',
|
||||
},
|
||||
};
|
||||
const s = sizes[size];
|
||||
const v = variants[variant] || variants.gradient;
|
||||
return (
|
||||
<button type={type} onClick={onClick} disabled={disabled}
|
||||
className="momo-btn"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
gap: s.gap, padding: s.padding, fontSize: s.fontSize, height: s.height,
|
||||
fontWeight: 'var(--momo-font-weight-medium)',
|
||||
borderRadius: 'var(--momo-radius-md)',
|
||||
border: 'none',
|
||||
transition: 'var(--momo-transition-base), transform var(--momo-duration-fast)',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
...v,
|
||||
...style,
|
||||
}}>
|
||||
{icon && <Icon name={icon} size={size === 'sm' ? 14 : 16} />}
|
||||
{children}
|
||||
{iconRight && <Icon name={iconRight} size={size === 'sm' ? 14 : 16} />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Avatar =====
|
||||
const Avatar = ({ name = '?', size = 32, gradient = false, style }) => {
|
||||
const initial = (name || '?').trim().charAt(0).toUpperCase();
|
||||
// 從名字 hash 出穩定色相
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
const hue = Math.abs(hash) % 360;
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size,
|
||||
borderRadius: '50%',
|
||||
background: gradient ? 'var(--momo-gradient-primary)' : `hsl(${hue}, 65%, 88%)`,
|
||||
color: gradient ? '#fff' : `hsl(${hue}, 70%, 30%)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
...style,
|
||||
}}>{initial}</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Card =====
|
||||
const Card = ({ children, style, cardStyle = 'shadow', padding = true, hoverable = false }) => {
|
||||
const styles = {
|
||||
shadow: { boxShadow: 'var(--momo-shadow-md)', border: '1px solid transparent' },
|
||||
flat: { boxShadow: 'none', border: '1px solid transparent', background: 'var(--momo-bg-subtle)' },
|
||||
bordered:{ boxShadow: 'none', border: '1px solid var(--momo-border)' },
|
||||
};
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--momo-bg-surface)',
|
||||
borderRadius: 'var(--momo-radius-lg)',
|
||||
padding: padding ? 'var(--momo-space-5)' : 0,
|
||||
transition: 'var(--momo-transition-base), transform var(--momo-duration-fast)',
|
||||
...styles[cardStyle],
|
||||
...(hoverable ? { cursor: 'pointer' } : {}),
|
||||
...style,
|
||||
}}>{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Input =====
|
||||
const Input = ({ icon, value, onChange, placeholder, style, type = 'text', size = 'md' }) => {
|
||||
const heights = { sm: 32, md: 38, lg: 44 };
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
background: 'var(--momo-bg-surface)',
|
||||
border: '1px solid var(--momo-border)',
|
||||
borderRadius: 'var(--momo-radius-md)',
|
||||
padding: '0 12px',
|
||||
height: heights[size],
|
||||
transition: 'var(--momo-transition-base)',
|
||||
...style,
|
||||
}}>
|
||||
{icon && <Icon name={icon} size={16} color="var(--momo-text-tertiary)" style={{ marginRight: 8 }} />}
|
||||
<input type={type} value={value} onChange={onChange} placeholder={placeholder}
|
||||
style={{
|
||||
flex: 1, border: 'none', outline: 'none', background: 'transparent',
|
||||
fontSize: 'var(--momo-font-size-sm)',
|
||||
color: 'var(--momo-text-primary)',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Checkbox =====
|
||||
const Checkbox = ({ checked, onChange, indeterminate = false, style }) => {
|
||||
const ref = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||
}, [indeterminate]);
|
||||
return (
|
||||
<label style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', position: 'relative',
|
||||
width: 18, height: 18,
|
||||
...style,
|
||||
}}>
|
||||
<input ref={ref} type="checkbox" checked={!!checked} onChange={onChange}
|
||||
style={{ position: 'absolute', opacity: 0, width: 0, height: 0 }} />
|
||||
<span style={{
|
||||
width: 18, height: 18,
|
||||
borderRadius: 4,
|
||||
border: `1.5px solid ${checked || indeterminate ? 'var(--momo-primary)' : 'var(--momo-border-dark)'}`,
|
||||
background: checked || indeterminate ? 'var(--momo-gradient-primary)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'var(--momo-transition-base)',
|
||||
}}>
|
||||
{checked && <Icon name="check" size={12} color="#fff" strokeWidth={3} />}
|
||||
{indeterminate && !checked && <span style={{ width: 8, height: 2, background: '#fff', borderRadius: 1 }} />}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Page Header =====
|
||||
const PageHeader = ({ title, subtitle, actions, breadcrumbs }) => (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between',
|
||||
gap: 'var(--momo-space-4)',
|
||||
marginBottom: 'var(--momo-space-5)',
|
||||
}}>
|
||||
<div>
|
||||
{breadcrumbs && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
fontSize: 'var(--momo-font-size-xs)',
|
||||
color: 'var(--momo-text-tertiary)',
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
{breadcrumbs.map((b, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <Icon name="chevronRight" size={12} />}
|
||||
<span style={{ color: i === breadcrumbs.length - 1 ? 'var(--momo-text-secondary)' : 'inherit' }}>{b}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h1 style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--momo-font-size-xl)',
|
||||
fontWeight: 'var(--momo-font-weight-bold)',
|
||||
color: 'var(--momo-text-primary)',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>{title}</h1>
|
||||
{subtitle && <div style={{
|
||||
marginTop: 4,
|
||||
fontSize: 'var(--momo-font-size-sm)',
|
||||
color: 'var(--momo-text-secondary)',
|
||||
}}>{subtitle}</div>}
|
||||
</div>
|
||||
{actions && <div style={{ display: 'flex', gap: 8 }}>{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
Object.assign(window, { Badge, Button, Avatar, Card, Input, Checkbox, PageHeader });
|
||||
73
MOMO Pro/data.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// data.jsx — sample Taiwan e-commerce data
|
||||
|
||||
const ORDERS = [
|
||||
{ id: 'MO20260430-00892', customer: '陳怡君', email: 'chen.yj@gmail.com', items: 3, total: 4280, status: 'paid', payment: '信用卡', shipping: '宅配', date: '2026-04-30 14:32', region: '台北市' },
|
||||
{ id: 'MO20260430-00891', customer: '林志豪', email: 'lin.zh@yahoo.com.tw', items: 1, total: 1290, status: 'shipped', payment: 'LINE Pay', shipping: '超商取貨', date: '2026-04-30 13:18', region: '新北市' },
|
||||
{ id: 'MO20260430-00890', customer: '王雅婷', email: 'wang.yt@hotmail.com', items: 5, total: 8950, status: 'pending', payment: '貨到付款', shipping: '宅配', date: '2026-04-30 12:45', region: '台中市' },
|
||||
{ id: 'MO20260430-00889', customer: '張家豪', email: 'chang.jh@gmail.com', items: 2, total: 2680, status: 'paid', payment: '街口支付', shipping: '超商取貨', date: '2026-04-30 11:50', region: '高雄市' },
|
||||
{ id: 'MO20260430-00888', customer: '黃淑芬', email: 'huang.sf@yahoo.com.tw', items: 1, total: 590, status: 'cancelled', payment: '信用卡', shipping: '宅配', date: '2026-04-30 10:22', region: '桃園市' },
|
||||
{ id: 'MO20260430-00887', customer: '李建宏', email: 'li.jh@gmail.com', items: 4, total: 6320, status: 'shipped', payment: 'Apple Pay', shipping: '宅配', date: '2026-04-30 09:15', region: '台南市' },
|
||||
{ id: 'MO20260430-00886', customer: '吳佩琪', email: 'wu.pq@kimo.com', items: 2, total: 1880, status: 'completed', payment: '信用卡', shipping: '超商取貨', date: '2026-04-30 08:40', region: '台北市' },
|
||||
{ id: 'MO20260429-00885', customer: '劉冠廷', email: 'liu.gt@gmail.com', items: 6, total: 12450, status: 'paid', payment: '信用卡', shipping: '宅配', date: '2026-04-29 22:18', region: '新竹市' },
|
||||
{ id: 'MO20260429-00884', customer: '蔡欣穎', email: 'tsai.xy@hotmail.com', items: 1, total: 780, status: 'completed', payment: 'LINE Pay', shipping: '超商取貨', date: '2026-04-29 20:55', region: '台中市' },
|
||||
{ id: 'MO20260429-00883', customer: '楊俊賢', email: 'yang.jx@gmail.com', items: 3, total: 3450, status: 'pending', payment: '貨到付款', shipping: '宅配', date: '2026-04-29 19:30', region: '彰化縣' },
|
||||
{ id: 'MO20260429-00882', customer: '鄭美玲', email: 'cheng.ml@yahoo.com.tw', items: 2, total: 2190, status: 'shipped', payment: '信用卡', shipping: '超商取貨', date: '2026-04-29 18:12', region: '台北市' },
|
||||
{ id: 'MO20260429-00881', customer: '許文凱', email: 'hsu.wk@gmail.com', items: 1, total: 4990, status: 'paid', payment: '街口支付', shipping: '宅配', date: '2026-04-29 17:00', region: '高雄市' },
|
||||
];
|
||||
|
||||
const PRODUCTS = [
|
||||
{ id: 'P00128', name: '無線藍牙降噪耳機 Pro Max', sku: 'AUD-BT-PRO-001', category: '3C / 耳機', price: 4990, cost: 2200, stock: 142, sold: 1284, status: 'active', image: '🎧' },
|
||||
{ id: 'P00127', name: '日式陶瓷餐具六件組', sku: 'KIT-CER-006', category: '居家 / 餐具', price: 1280, cost: 480, stock: 38, sold: 562, status: 'active', image: '🍽️' },
|
||||
{ id: 'P00126', name: '有機冷壓初榨橄欖油 500ml', sku: 'FOOD-OIL-500', category: '食品 / 油品', price: 680, cost: 280, stock: 8, sold: 928, status: 'low_stock', image: '🫒' },
|
||||
{ id: 'P00125', name: '韓系寬鬆針織毛衣(米白)', sku: 'CLO-KNT-WH-M', category: '服飾 / 上衣', price: 1490, cost: 520, stock: 96, sold: 348, status: 'active', image: '🧥' },
|
||||
{ id: 'P00124', name: '北歐簡約檯燈 LED 護眼', sku: 'HOM-LMP-LED', category: '居家 / 燈飾', price: 2380, cost: 980, stock: 0, sold: 215, status: 'out_of_stock', image: '💡' },
|
||||
{ id: 'P00123', name: '保溫不鏽鋼隨行杯 500ml', sku: 'KIT-CUP-SS500', category: '居家 / 杯具', price: 890, cost: 320, stock: 256, sold: 1842, status: 'active', image: '🥤' },
|
||||
{ id: 'P00122', name: '高蛋白燕麥能量棒 12 入', sku: 'FOOD-BAR-OAT', category: '食品 / 零食', price: 480, cost: 180, stock: 412, sold: 2104, status: 'active', image: '🌾' },
|
||||
{ id: 'P00121', name: '皮革商務後背包 15"', sku: 'BAG-LTH-15', category: '配件 / 包款', price: 3680, cost: 1450, stock: 24, sold: 156, status: 'active', image: '🎒' },
|
||||
{ id: 'P00120', name: '智慧運動手錶 第三代', sku: 'WCH-SPT-G3', category: '3C / 穿戴', price: 5890, cost: 2480, stock: 5, sold: 432, status: 'low_stock', image: '⌚' },
|
||||
{ id: 'P00119', name: '純棉素色短袖 T 恤(藏青)', sku: 'CLO-TEE-NV-L', category: '服飾 / 上衣', price: 590, cost: 180, stock: 320, sold: 1568, status: 'active', image: '👕' },
|
||||
];
|
||||
|
||||
const STATUS_LABELS = {
|
||||
paid: { label: '已付款', tone: 'info' },
|
||||
pending: { label: '待處理', tone: 'warning' },
|
||||
shipped: { label: '已出貨', tone: 'primary' },
|
||||
completed: { label: '已完成', tone: 'success' },
|
||||
cancelled: { label: '已取消', tone: 'neutral' },
|
||||
active: { label: '上架中', tone: 'success' },
|
||||
low_stock: { label: '庫存不足', tone: 'warning' },
|
||||
out_of_stock:{ label: '已售完', tone: 'danger' },
|
||||
};
|
||||
|
||||
// 30 天營收(單位 NT$ 千)
|
||||
const REVENUE_30D = [
|
||||
142, 168, 195, 178, 156, 210, 245, 198, 172, 188,
|
||||
220, 256, 234, 198, 215, 248, 280, 312, 285, 268,
|
||||
295, 332, 358, 340, 318, 295, 348, 392, 425, 408,
|
||||
];
|
||||
|
||||
const HOURLY_TRAFFIC = [
|
||||
120, 95, 68, 42, 35, 48, 92, 168, 245, 312,
|
||||
385, 428, 462, 445, 418, 392, 358, 412, 478, 525,
|
||||
548, 502, 412, 285,
|
||||
];
|
||||
|
||||
const CATEGORY_SHARE = [
|
||||
{ name: '3C 數位', value: 32, color: '#667eea' },
|
||||
{ name: '服飾配件', value: 24, color: '#764ba2' },
|
||||
{ name: '居家生活', value: 18, color: '#5568d3' },
|
||||
{ name: '食品保健', value: 14, color: '#818cf8' },
|
||||
{ name: '美妝個護', value: 8, color: '#a5b4fc' },
|
||||
{ name: '其他', value: 4, color: '#c7d2fe' },
|
||||
];
|
||||
|
||||
const ACTIVITIES = [
|
||||
{ type: 'order', icon: '🛒', text: '新訂單 MO20260430-00892', detail: '陳怡君 · NT$4,280', time: '剛剛', tone: 'info' },
|
||||
{ type: 'stock', icon: '⚠️', text: '低庫存警示:有機橄欖油', detail: '剩餘 8 件 / 安全庫存 20', time: '5 分鐘前', tone: 'warning' },
|
||||
{ type: 'review', icon: '⭐', text: '新評價 5 星', detail: '無線藍牙降噪耳機 Pro Max', time: '12 分鐘前', tone: 'success' },
|
||||
{ type: 'order', icon: '🛒', text: '新訂單 MO20260430-00891', detail: '林志豪 · NT$1,290', time: '18 分鐘前', tone: 'info' },
|
||||
{ type: 'refund', icon: '↩️', text: '退款申請', detail: 'MO20260430-00888 · NT$590', time: '32 分鐘前', tone: 'danger' },
|
||||
{ type: 'campaign', icon: '🎯', text: '行銷活動上線', detail: '五一連假滿千折百', time: '1 小時前', tone: 'primary' },
|
||||
];
|
||||
|
||||
Object.assign(window, { ORDERS, PRODUCTS, STATUS_LABELS, REVENUE_30D, HOURLY_TRAFFIC, CATEGORY_SHARE, ACTIVITIES });
|
||||
621
MOMO Pro/design-canvas.jsx
Normal file
@@ -0,0 +1,621 @@
|
||||
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
|
||||
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
|
||||
// State persists to a .design-canvas.state.json sidecar via the host
|
||||
// bridge. No assets, no deps.
|
||||
//
|
||||
// Usage:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
|
||||
const DC = {
|
||||
bg: '#f0eee9',
|
||||
grid: 'rgba(0,0,0,0.06)',
|
||||
label: 'rgba(60,50,40,0.7)',
|
||||
title: 'rgba(40,30,20,0.85)',
|
||||
subtitle: 'rgba(60,50,40,0.6)',
|
||||
postitBg: '#fef4a8',
|
||||
postitText: '#5a4a2a',
|
||||
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
};
|
||||
|
||||
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||
// the hosted design's own styles).
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dc-styles';
|
||||
s.textContent = [
|
||||
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||
'.dc-card{transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
|
||||
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
|
||||
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||
'.dc-grip:active{cursor:grabbing}',
|
||||
'.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
|
||||
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
|
||||
' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
|
||||
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||
'[data-dc-slot]:hover .dc-expand{opacity:1}',
|
||||
].join('\n');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const DCCtx = React.createContext(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// Owns runtime state (per-section order, renamed titles/labels, focused
|
||||
// artboard). Order/titles/labels persist to a .design-canvas.state.json
|
||||
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||
// Focus is ephemeral.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||
|
||||
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||
// appear on first paint (no source-order flash). didRead gates writes until
|
||||
// the read settles so the empty initial state can't clobber a slow read;
|
||||
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||
// hydration.
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const didRead = React.useRef(false);
|
||||
const skipNextWrite = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let off = false;
|
||||
fetch('./' + DC_STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((saved) => {
|
||||
if (off || !saved || !saved.sections) return;
|
||||
skipNextWrite.current = true;
|
||||
setState((s) => ({ ...s, sections: saved.sections }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||
return () => { off = true; clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!didRead.current) return;
|
||||
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||
const t = setTimeout(() => {
|
||||
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.sections]);
|
||||
|
||||
// Build registries synchronously from children so FocusOverlay can read
|
||||
// them in the same render. Only direct DCSection > DCArtboard children are
|
||||
// walked — wrapping them in other elements opts out of focus/reorder.
|
||||
const registry = {}; // slotId -> { sectionId, artboard }
|
||||
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||
const sectionOrder = [];
|
||||
React.Children.forEach(children, (sec) => {
|
||||
if (!sec || sec.type !== DCSection) return;
|
||||
const sid = sec.props.id ?? sec.props.title;
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
const srcIds = [];
|
||||
React.Children.forEach(sec.props.children, (ab) => {
|
||||
if (!ab || ab.type !== DCArtboard) return;
|
||||
const aid = ab.props.id ?? ab.props.label;
|
||||
if (!aid) return;
|
||||
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||
srcIds.push(aid);
|
||||
});
|
||||
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||
sectionMeta[sid] = {
|
||||
title: persisted.title ?? sec.props.title,
|
||||
subtitle: sec.props.subtitle,
|
||||
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||
};
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => ({
|
||||
state,
|
||||
section: (id) => state.sections[id] || {},
|
||||
patchSection: (id, p) => setState((s) => ({
|
||||
...s,
|
||||
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||
})),
|
||||
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||
}), [state]);
|
||||
|
||||
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||
const onPd = (e) => {
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPd, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPd, true);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCViewport — transform-based pan/zoom (internal)
|
||||
//
|
||||
// Input mapping (Figma-style):
|
||||
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||
// • trackpad scroll → pan (two-finger)
|
||||
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||
// • middle-drag / primary-drag-on-bg → pan
|
||||
//
|
||||
// Transform state lives in a ref and is written straight to the DOM
|
||||
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||
// keeps pans at 60fps on dense canvases.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
const vpRef = React.useRef(null);
|
||||
const worldRef = React.useRef(null);
|
||||
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||
|
||||
const apply = React.useCallback(() => {
|
||||
const { x, y, scale } = tf.current;
|
||||
const el = worldRef.current;
|
||||
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vp = vpRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const zoomAt = (cx, cy, factor) => {
|
||||
const r = vp.getBoundingClientRect();
|
||||
const px = cx - r.left, py = cy - r.top;
|
||||
const t = tf.current;
|
||||
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||
const k = next / t.scale;
|
||||
// keep the world point under the cursor fixed
|
||||
t.x = px - (px - t.x) * k;
|
||||
t.y = py - (py - t.y) * k;
|
||||
t.scale = next;
|
||||
apply();
|
||||
};
|
||||
|
||||
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||
const isMouseWheel = (e) =>
|
||||
e.deltaMode !== 0 ||
|
||||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||
if (e.ctrlKey) {
|
||||
// trackpad pinch (or explicit ctrl+wheel)
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||
} else if (isMouseWheel(e)) {
|
||||
// notched mouse wheel — fixed-ratio step per click
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||
} else {
|
||||
// trackpad two-finger scroll — pan
|
||||
tf.current.x -= e.deltaX;
|
||||
tf.current.y -= e.deltaY;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||
// better feel there. No-ops on other browsers. Safari also fires
|
||||
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||
// onWheel drop those entirely so they neither zoom nor pan.
|
||||
let gsBase = 1;
|
||||
let isGesturing = false;
|
||||
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||
const onGestureChange = (e) => {
|
||||
e.preventDefault();
|
||||
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||
};
|
||||
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||
|
||||
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||
// background (anything that isn't an artboard or an inline editor).
|
||||
let drag = null;
|
||||
const onPointerDown = (e) => {
|
||||
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||
e.preventDefault();
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||
vp.style.cursor = 'grabbing';
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tf.current.x += e.clientX - drag.lx;
|
||||
tf.current.y += e.clientY - drag.ly;
|
||||
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||
apply();
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
vp.releasePointerCapture(e.pointerId);
|
||||
drag = null;
|
||||
vp.style.cursor = '';
|
||||
};
|
||||
|
||||
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||
vp.addEventListener('pointerdown', onPointerDown);
|
||||
vp.addEventListener('pointermove', onPointerMove);
|
||||
vp.addEventListener('pointerup', onPointerUp);
|
||||
vp.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
vp.removeEventListener('wheel', onWheel);
|
||||
vp.removeEventListener('gesturestart', onGestureStart);
|
||||
vp.removeEventListener('gesturechange', onGestureChange);
|
||||
vp.removeEventListener('gestureend', onGestureEnd);
|
||||
vp.removeEventListener('pointerdown', onPointerDown);
|
||||
vp.removeEventListener('pointermove', onPointerMove);
|
||||
vp.removeEventListener('pointerup', onPointerUp);
|
||||
vp.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [apply, minScale, maxScale]);
|
||||
|
||||
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
return (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCSection — editable title + h-row of artboards in persisted order
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const sid = id ?? title;
|
||||
const all = React.Children.toArray(children);
|
||||
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||
const srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
|
||||
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||
|
||||
const order = React.useMemo(() => {
|
||||
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||
}, [sec.order, srcOrder.join('|')]);
|
||||
|
||||
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||
|
||||
return (
|
||||
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px 56px' }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
function DCArtboard() { return null; }
|
||||
|
||||
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
|
||||
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||
const id = rawId ?? rawLabel;
|
||||
const ref = React.useRef(null);
|
||||
|
||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||
// their would-be slots in real time via transforms. DOM order only
|
||||
// changes on drop.
|
||||
const onGripDown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const me = ref.current;
|
||||
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||
const slotXs = homes.map((h) => h.x);
|
||||
const startIdx = order.indexOf(id);
|
||||
const startX = e.clientX;
|
||||
let liveOrder = order.slice();
|
||||
me.classList.add('dc-dragging');
|
||||
|
||||
const layout = () => {
|
||||
for (const h of homes) {
|
||||
if (h.id === id) continue;
|
||||
const slot = liveOrder.indexOf(h.id);
|
||||
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
me.style.transform = `translateX(${dx / scale}px)`;
|
||||
const cur = homes[startIdx].x + dx;
|
||||
let nearest = 0, best = Infinity;
|
||||
for (let i = 0; i < slotXs.length; i++) {
|
||||
const d = Math.abs(slotXs[i] - cur);
|
||||
if (d < best) { best = d; nearest = i; }
|
||||
}
|
||||
if (liveOrder.indexOf(id) !== nearest) {
|
||||
liveOrder = order.filter((k) => k !== id);
|
||||
liveOrder.splice(nearest, 0, id);
|
||||
layout();
|
||||
}
|
||||
};
|
||||
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
const finalSlot = liveOrder.indexOf(id);
|
||||
me.classList.remove('dc-dragging');
|
||||
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||
// After the settle transition, kill transitions + clear transforms +
|
||||
// commit the reorder in the same frame so there's no visual snap-back.
|
||||
setTimeout(() => {
|
||||
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const h of homes) h.el.style.transition = '';
|
||||
}));
|
||||
}, 180);
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
<div className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||
// sections, Esc or backdrop click to exit.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const { sectionId, artboard } = entry;
|
||||
const sec = ctx.section(sectionId);
|
||||
const meta = sectionMeta[sectionId];
|
||||
const peers = meta.slotIds;
|
||||
const aid = artboard.props.id ?? artboard.props.label;
|
||||
const idx = peers.indexOf(aid);
|
||||
const secIdx = sectionOrder.indexOf(sectionId);
|
||||
|
||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||
const goSection = (d) => {
|
||||
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
|
||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||
if (first) ctx.setFocus(`${ns}/${first}`);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const k = (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||
};
|
||||
document.addEventListener('keydown', k);
|
||||
return () => document.removeEventListener('keydown', k);
|
||||
});
|
||||
|
||||
const { width = 260, height = 480, children } = artboard.props;
|
||||
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||
|
||||
const [ddOpen, setDd] = React.useState(false);
|
||||
const Arrow = ({ dir, onClick }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Portal to body so position:fixed is the real viewport regardless of any
|
||||
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||
return ReactDOM.createPortal(
|
||||
<div onClick={() => ctx.setFocus(null)}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||
fontFamily: DC.font, color: '#fff' }}>
|
||||
|
||||
{/* top bar: section dropdown (left) · close (right) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
259
MOMO Pro/design-tokens.css
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* MOMO Pro × Nothing × Claude 設計 Token v2.0
|
||||
* — Nothing 的點陣骨架(黑白、像素、工業)
|
||||
* — Claude 的暖米基底(#f0eee9、焦糖橘 #c96442)
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ===== 1. 色彩 ===== */
|
||||
/* Claude 暖系基底(紙張 / 米色)— 加深暖度 */
|
||||
--momo-bg-body: #ebe6dc; /* Claude 米色頁面底(加深暖度)*/
|
||||
--momo-bg-surface: #faf7f0; /* 卡片改為米白,不純白 */
|
||||
--momo-bg-elevated: #fdfaf3;
|
||||
--momo-bg-subtle: #e2dccf; /* 米色微深 */
|
||||
--momo-bg-muted: #cfc7b5;
|
||||
--momo-bg-paper: #f3eee2; /* 卡片紙張感 */
|
||||
|
||||
/* Nothing 黑(用暖墨色,不純黑)*/
|
||||
--momo-ink: #2a2520; /* 暖墨色 */
|
||||
--momo-ink-strong: #1a1612;
|
||||
--momo-ink-soft: #3d362f;
|
||||
--momo-line: #2a2520;
|
||||
--momo-line-soft: rgba(42,37,32,0.18);
|
||||
--momo-line-faint: rgba(42,37,32,0.10);
|
||||
|
||||
/* Claude 焦糖橘(accent) */
|
||||
--momo-accent: #c96442; /* 主 accent */
|
||||
--momo-accent-50: #fbf2ef;
|
||||
--momo-accent-100: #f5e1d9;
|
||||
--momo-accent-200: #ecc3b3;
|
||||
--momo-accent-500: #c96442;
|
||||
--momo-accent-600: #b1543a;
|
||||
--momo-accent-700: #8f4530;
|
||||
--momo-accent-soft: rgba(201,100,66,0.12);
|
||||
|
||||
/* 沿用 primary 命名以相容(指向 accent) */
|
||||
--momo-primary: var(--momo-accent);
|
||||
--momo-primary-50: var(--momo-accent-50);
|
||||
--momo-primary-100: var(--momo-accent-100);
|
||||
--momo-primary-200: var(--momo-accent-200);
|
||||
--momo-primary-300: var(--momo-accent-200);
|
||||
--momo-primary-400: var(--momo-accent-500);
|
||||
--momo-primary-500: var(--momo-accent-500);
|
||||
--momo-primary-600: var(--momo-accent-600);
|
||||
--momo-primary-700: var(--momo-accent-700);
|
||||
--momo-primary-800: var(--momo-accent-700);
|
||||
--momo-primary-900: #5e2e20;
|
||||
|
||||
/* 導航(Nothing 黑) */
|
||||
--momo-nav-start: #1a1a1a;
|
||||
--momo-nav-end: #000000;
|
||||
--momo-nav-text: #ffffff;
|
||||
--momo-nav-text-muted: rgba(255,255,255,0.55);
|
||||
--momo-nav-hover: rgba(255,255,255,0.08);
|
||||
--momo-nav-active: rgba(255,255,255,0.14);
|
||||
|
||||
/* 漸層 → Nothing 風幾乎不用,保留低調黑灰漸層 */
|
||||
--momo-gradient-primary: #1a1a1a;
|
||||
--momo-gradient-nav: linear-gradient(180deg, #1a1a1a 0%, #000 100%);
|
||||
--momo-gradient-success: #2a7a3f;
|
||||
--momo-gradient-danger: #b5342f;
|
||||
--momo-gradient-warning: #b88416;
|
||||
--momo-gradient-info: #2d5d80;
|
||||
--momo-gradient-subtle: linear-gradient(180deg, #f7f5ef 0%, #ebe8e1 100%);
|
||||
|
||||
/* 狀態色(去飽和化、配合米色底) */
|
||||
--momo-success: #2a7a3f;
|
||||
--momo-success-bg: #e3ebd9;
|
||||
--momo-success-border: #c5d4b0;
|
||||
--momo-success-text: #1f5a2d;
|
||||
--momo-danger: #b5342f;
|
||||
--momo-danger-bg: #f0d8d4;
|
||||
--momo-danger-border: #d9b1ac;
|
||||
--momo-danger-text: #7d2520;
|
||||
--momo-warning: #b88416;
|
||||
--momo-warning-bg: #f3e7c4;
|
||||
--momo-warning-border: #d9c590;
|
||||
--momo-warning-text: #6e500e;
|
||||
--momo-info: #2d5d80;
|
||||
--momo-info-bg: #d8e2ea;
|
||||
--momo-info-border: #b5c5d2;
|
||||
--momo-info-text: #1d3e54;
|
||||
|
||||
/* 文字(暖墨)*/
|
||||
--momo-text-primary: #2a2520;
|
||||
--momo-text-secondary: #645c52;
|
||||
--momo-text-tertiary: #9b9081;
|
||||
--momo-text-disabled: #c4baa8;
|
||||
--momo-text-inverse: #faf7f0;
|
||||
--momo-text-link: #c96442;
|
||||
--momo-text-link-hover: #8f4530;
|
||||
|
||||
/* 邊框(暖色調線)*/
|
||||
--momo-border: #2a2520;
|
||||
--momo-border-light: rgba(42,37,32,0.16);
|
||||
--momo-border-dark: #2a2520;
|
||||
--momo-border-focus: #c96442;
|
||||
--momo-divider: rgba(42,37,32,0.12);
|
||||
|
||||
/* Overlay */
|
||||
--momo-bg-overlay: rgba(26, 26, 26, 0.7);
|
||||
--momo-bg-backdrop: rgba(26, 26, 26, 0.3);
|
||||
|
||||
/* ===== 2. Typography ===== */
|
||||
/* 標題:JetBrains Mono / Space Mono — 帶等寬機械感(替代 Ndot) */
|
||||
--momo-font-display:
|
||||
"JetBrains Mono", "Space Mono", "SF Mono", Menlo, Consolas, monospace;
|
||||
/* 內文:中英混排 */
|
||||
--momo-font-family:
|
||||
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"PingFang TC", "Noto Sans TC", "Microsoft JhengHei",
|
||||
sans-serif;
|
||||
/* 等寬:數據 */
|
||||
--momo-font-family-mono:
|
||||
"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
|
||||
|
||||
--momo-font-size-xs: 0.75rem;
|
||||
--momo-font-size-sm: 0.8125rem;
|
||||
--momo-font-size-base: 0.9375rem;
|
||||
--momo-font-size-lg: 1.0625rem;
|
||||
--momo-font-size-xl: 1.625rem;
|
||||
--momo-font-size-2xl: 2.25rem;
|
||||
|
||||
--momo-font-weight-normal: 400;
|
||||
--momo-font-weight-medium: 500;
|
||||
--momo-font-weight-semibold: 600;
|
||||
--momo-font-weight-bold: 700;
|
||||
|
||||
--momo-line-height-tight: 1.15;
|
||||
--momo-line-height-base: 1.5;
|
||||
--momo-line-height-loose: 1.7;
|
||||
|
||||
/* ===== 3. Spacing ===== */
|
||||
--momo-space-1: 0.25rem;
|
||||
--momo-space-2: 0.5rem;
|
||||
--momo-space-3: 0.75rem;
|
||||
--momo-space-4: 1rem;
|
||||
--momo-space-5: 1.5rem;
|
||||
--momo-space-6: 2rem;
|
||||
--momo-space-7: 3rem;
|
||||
--momo-space-8: 4rem;
|
||||
|
||||
/* ===== 4. Shadow(Nothing 風幾乎不用陰影,改用線條) ===== */
|
||||
--momo-shadow-sm: 0 0 0 1px rgba(26,26,26,0.08);
|
||||
--momo-shadow-md: 0 0 0 1px rgba(26,26,26,0.10);
|
||||
--momo-shadow-lg: 0 12px 40px -8px rgba(26,26,26,0.18), 0 0 0 1px rgba(26,26,26,0.10);
|
||||
--momo-shadow-colored: 0 0 0 2px rgba(201,100,66,0.25);
|
||||
--momo-shadow-inner: inset 0 1px 2px 0 rgba(26,26,26,0.08);
|
||||
|
||||
/* ===== 5. Radius(Nothing 風偏方角,僅輕微圓角) ===== */
|
||||
--momo-radius-sm: 0.125rem; /* 2px */
|
||||
--momo-radius-md: 0.25rem; /* 4px */
|
||||
--momo-radius-lg: 0.375rem; /* 6px */
|
||||
--momo-radius-pill: 50rem;
|
||||
--momo-radius-circle: 50%;
|
||||
|
||||
/* ===== 6. Transition ===== */
|
||||
--momo-duration-fast: 0.12s;
|
||||
--momo-duration-normal: 0.2s;
|
||||
--momo-duration-slow: 0.4s;
|
||||
--momo-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--momo-ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--momo-ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--momo-transition-base:
|
||||
color var(--momo-duration-fast) var(--momo-ease-in-out),
|
||||
background-color var(--momo-duration-fast) var(--momo-ease-in-out),
|
||||
border-color var(--momo-duration-fast) var(--momo-ease-in-out),
|
||||
box-shadow var(--momo-duration-fast) var(--momo-ease-in-out);
|
||||
|
||||
/* ===== 7. z-index ===== */
|
||||
--momo-z-base: 1;
|
||||
--momo-z-dropdown: 1000;
|
||||
--momo-z-sticky: 1020;
|
||||
--momo-z-fixed: 1030;
|
||||
--momo-z-modal-backdrop: 1040;
|
||||
--momo-z-modal: 1050;
|
||||
--momo-z-popover: 1060;
|
||||
--momo-z-tooltip: 1070;
|
||||
--momo-z-toast: 1080;
|
||||
}
|
||||
|
||||
/* 全域 */
|
||||
.momo-app {
|
||||
font-family: var(--momo-font-family);
|
||||
font-size: var(--momo-font-size-base);
|
||||
line-height: var(--momo-line-height-base);
|
||||
color: var(--momo-text-primary);
|
||||
background-color: var(--momo-bg-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.momo-app *, .momo-app *::before, .momo-app *::after { box-sizing: border-box; }
|
||||
.momo-app button {
|
||||
font-family: inherit; cursor: pointer; border: none; background: none; padding: 0; color: inherit;
|
||||
}
|
||||
.momo-app input, .momo-app select, .momo-app textarea {
|
||||
font-family: inherit; font-size: inherit; color: inherit;
|
||||
}
|
||||
|
||||
/* Display class for big numbers / titles in Nothing-mono */
|
||||
.momo-display {
|
||||
font-family: var(--momo-font-display);
|
||||
font-feature-settings: "tnum", "ss01";
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.momo-mono {
|
||||
font-family: var(--momo-font-family-mono);
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
.momo-label {
|
||||
font-family: var(--momo-font-display);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Dot matrix 背景圖案(Nothing 招牌點陣) */
|
||||
.momo-dot-bg {
|
||||
background-image: radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px);
|
||||
background-size: 8px 8px;
|
||||
}
|
||||
.momo-dot-bg-dark {
|
||||
background-image: radial-gradient(circle, rgba(26,26,26,0.12) 1px, transparent 1px);
|
||||
background-size: 8px 8px;
|
||||
}
|
||||
|
||||
/* 滾動條 */
|
||||
.momo-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
.momo-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
.momo-scroll::-webkit-scrollbar-thumb { background: rgba(26,26,26,0.18); border-radius: var(--momo-radius-pill); }
|
||||
.momo-scroll::-webkit-scrollbar-thumb:hover { background: rgba(26,26,26,0.32); }
|
||||
|
||||
/* 動畫 */
|
||||
@keyframes momo-fade-in { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes momo-slide-up { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes momo-pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
/* Topbar responsive:用 container query 因為 topbar 在 artboard 縮放容器內 */
|
||||
.momo-topbar { container-type: inline-size; }
|
||||
@container (max-width: 1024px) {
|
||||
.momo-schedule-pill { display: none !important; }
|
||||
}
|
||||
@container (max-width: 880px) {
|
||||
.momo-user-meta { display: none !important; }
|
||||
}
|
||||
@container (max-width: 720px) {
|
||||
.momo-search-text { display: none !important; }
|
||||
}
|
||||
/* fallback:若瀏覽器不支援 container query,仍用 viewport */
|
||||
@media (max-width: 1024px) {
|
||||
.momo-schedule-pill { display: none !important; }
|
||||
}
|
||||
@media (max-width: 880px) {
|
||||
.momo-user-meta { display: none !important; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.momo-search-text { display: none !important; }
|
||||
}
|
||||
425
MOMO Pro/tweaks-panel.jsx
Normal file
@@ -0,0 +1,425 @@
|
||||
|
||||
// tweaks-panel.jsx
|
||||
// Reusable Tweaks shell + form-control helpers.
|
||||
//
|
||||
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
|
||||
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
|
||||
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
||||
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
||||
//
|
||||
// Usage (in an HTML file that loads React + Babel):
|
||||
//
|
||||
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
// "primaryColor": "#D97757",
|
||||
// "fontSize": 16,
|
||||
// "density": "regular",
|
||||
// "dark": false
|
||||
// }/*EDITMODE-END*/;
|
||||
//
|
||||
// function App() {
|
||||
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||
// return (
|
||||
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||
// Hello
|
||||
// <TweaksPanel>
|
||||
// <TweakSection label="Typography" />
|
||||
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||
// onChange={(v) => setTweak('fontSize', v)} />
|
||||
// <TweakRadio label="Density" value={t.density}
|
||||
// options={['compact', 'regular', 'comfy']}
|
||||
// onChange={(v) => setTweak('density', v)} />
|
||||
// <TweakSection label="Theme" />
|
||||
// <TweakColor label="Primary" value={t.primaryColor}
|
||||
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||
// <TweakToggle label="Dark mode" value={t.dark}
|
||||
// onChange={(v) => setTweak('dark', v)} />
|
||||
// </TweaksPanel>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const __TWEAKS_STYLE = `
|
||||
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
||||
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
||||
background:rgba(250,249,247,.78);color:#29261b;
|
||||
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
||||
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
||||
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
||||
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
||||
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
||||
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
||||
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
||||
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
||||
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
||||
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
||||
overflow-y:auto;overflow-x:hidden;min-height:0;
|
||||
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||
.twk-body::-webkit-scrollbar{width:8px}
|
||||
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
||||
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
||||
border:2px solid transparent;background-clip:content-box}
|
||||
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
||||
border:2px solid transparent;background-clip:content-box}
|
||||
.twk-row{display:flex;flex-direction:column;gap:5px}
|
||||
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
||||
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
||||
color:rgba(41,38,27,.72)}
|
||||
.twk-lbl>span:first-child{font-weight:500}
|
||||
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
||||
|
||||
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
||||
color:rgba(41,38,27,.45);padding:10px 0 0}
|
||||
.twk-sect:first-child{padding-top:0}
|
||||
|
||||
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
|
||||
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
||||
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
||||
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
||||
select.twk-field{padding-right:22px;
|
||||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||
background-repeat:no-repeat;background-position:right 8px center}
|
||||
|
||||
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
||||
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
||||
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
||||
width:14px;height:14px;border-radius:50%;background:#fff;
|
||||
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
||||
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||
|
||||
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
||||
background:rgba(0,0,0,.06);user-select:none}
|
||||
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
||||
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
||||
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
||||
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
||||
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
||||
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
||||
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
|
||||
overflow-wrap:anywhere}
|
||||
|
||||
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
||||
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
||||
.twk-toggle[data-on="1"]{background:#34c759}
|
||||
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
||||
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
||||
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
||||
|
||||
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
|
||||
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
||||
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
||||
user-select:none;padding-right:8px}
|
||||
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
||||
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
||||
outline:none;color:inherit;-moz-appearance:textfield}
|
||||
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
||||
-webkit-appearance:none;margin:0}
|
||||
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
||||
|
||||
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
||||
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
||||
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
||||
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
||||
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
||||
|
||||
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
||||
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
||||
background:transparent;flex-shrink:0}
|
||||
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
||||
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
||||
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
||||
`;
|
||||
|
||||
// ── useTweaks ───────────────────────────────────────────────────────────────
|
||||
// Single source of truth for tweak values. setTweak persists via the host
|
||||
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
|
||||
function useTweaks(defaults) {
|
||||
const [values, setValues] = React.useState(defaults);
|
||||
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
|
||||
// useState-style call doesn't write a "[object Object]" key into the persisted
|
||||
// JSON block.
|
||||
const setTweak = React.useCallback((keyOrEdits, val) => {
|
||||
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
||||
? keyOrEdits : { [keyOrEdits]: val };
|
||||
setValues((prev) => ({ ...prev, ...edits }));
|
||||
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
||||
}, []);
|
||||
return [values, setTweak];
|
||||
}
|
||||
|
||||
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
||||
// Floating shell. Registers the protocol listener BEFORE announcing
|
||||
// availability — if the announce ran first, the host's activate could land
|
||||
// before our handler exists and the toolbar toggle would silently no-op.
|
||||
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
|
||||
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
|
||||
// is what actually hides the panel.
|
||||
function TweaksPanel({ title = 'Tweaks', children }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const dragRef = React.useRef(null);
|
||||
const offsetRef = React.useRef({ x: 16, y: 16 });
|
||||
const PAD = 16;
|
||||
|
||||
const clampToViewport = React.useCallback(() => {
|
||||
const panel = dragRef.current;
|
||||
if (!panel) return;
|
||||
const w = panel.offsetWidth, h = panel.offsetHeight;
|
||||
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
||||
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
||||
offsetRef.current = {
|
||||
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
||||
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
||||
};
|
||||
panel.style.right = offsetRef.current.x + 'px';
|
||||
panel.style.bottom = offsetRef.current.y + 'px';
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
clampToViewport();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', clampToViewport);
|
||||
return () => window.removeEventListener('resize', clampToViewport);
|
||||
}
|
||||
const ro = new ResizeObserver(clampToViewport);
|
||||
ro.observe(document.documentElement);
|
||||
return () => ro.disconnect();
|
||||
}, [open, clampToViewport]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onMsg = (e) => {
|
||||
const t = e?.data?.type;
|
||||
if (t === '__activate_edit_mode') setOpen(true);
|
||||
else if (t === '__deactivate_edit_mode') setOpen(false);
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||
return () => window.removeEventListener('message', onMsg);
|
||||
}, []);
|
||||
|
||||
const dismiss = () => {
|
||||
setOpen(false);
|
||||
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
||||
};
|
||||
|
||||
const onDragStart = (e) => {
|
||||
const panel = dragRef.current;
|
||||
if (!panel) return;
|
||||
const r = panel.getBoundingClientRect();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const startRight = window.innerWidth - r.right;
|
||||
const startBottom = window.innerHeight - r.bottom;
|
||||
const move = (ev) => {
|
||||
offsetRef.current = {
|
||||
x: startRight - (ev.clientX - sx),
|
||||
y: startBottom - (ev.clientY - sy),
|
||||
};
|
||||
clampToViewport();
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
};
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<>
|
||||
<style>{__TWEAKS_STYLE}</style>
|
||||
<div ref={dragRef} className="twk-panel" data-noncommentable=""
|
||||
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||
<b>{title}</b>
|
||||
<button className="twk-x" aria-label="Close tweaks"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={dismiss}>✕</button>
|
||||
</div>
|
||||
<div className="twk-body">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function TweakSection({ label, children }) {
|
||||
return (
|
||||
<>
|
||||
<div className="twk-sect">{label}</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakRow({ label, value, children, inline = false }) {
|
||||
return (
|
||||
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||
<div className="twk-lbl">
|
||||
<span>{label}</span>
|
||||
{value != null && <span className="twk-val">{value}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Controls ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||
return (
|
||||
<TweakRow label={label} value={`${value}${unit}`}>
|
||||
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakToggle({ label, value, onChange }) {
|
||||
return (
|
||||
<div className="twk-row twk-row-h">
|
||||
<div className="twk-lbl"><span>{label}</span></div>
|
||||
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||
role="switch" aria-checked={!!value}
|
||||
onClick={() => onChange(!value)}><i /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakRadio({ label, value, options, onChange }) {
|
||||
const trackRef = React.useRef(null);
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
||||
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
||||
const n = opts.length;
|
||||
|
||||
// The active value is read by pointer-move handlers attached for the lifetime
|
||||
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
||||
const valueRef = React.useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
const segAt = (clientX) => {
|
||||
const r = trackRef.current.getBoundingClientRect();
|
||||
const inner = r.width - 4;
|
||||
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
||||
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||||
};
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
setDragging(true);
|
||||
const v0 = segAt(e.clientX);
|
||||
if (v0 !== valueRef.current) onChange(v0);
|
||||
const move = (ev) => {
|
||||
if (!trackRef.current) return;
|
||||
const v = segAt(ev.clientX);
|
||||
if (v !== valueRef.current) onChange(v);
|
||||
};
|
||||
const up = () => {
|
||||
setDragging(false);
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||
<div className="twk-seg-thumb"
|
||||
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||
width: `calc((100% - 4px) / ${n})` }} />
|
||||
{opts.map((o) => (
|
||||
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakSelect({ label, value, options, onChange }) {
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((o) => {
|
||||
const v = typeof o === 'object' ? o.value : o;
|
||||
const l = typeof o === 'object' ? o.label : o;
|
||||
return <option key={v} value={v}>{l}</option>;
|
||||
})}
|
||||
</select>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakText({ label, value, placeholder, onChange }) {
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
||||
const clamp = (n) => {
|
||||
if (min != null && n < min) return min;
|
||||
if (max != null && n > max) return max;
|
||||
return n;
|
||||
};
|
||||
const startRef = React.useRef({ x: 0, val: 0 });
|
||||
const onScrubStart = (e) => {
|
||||
e.preventDefault();
|
||||
startRef.current = { x: e.clientX, val: value };
|
||||
const decimals = (String(step).split('.')[1] || '').length;
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startRef.current.x;
|
||||
const raw = startRef.current.val + dx * step;
|
||||
const snapped = Math.round(raw / step) * step;
|
||||
onChange(clamp(Number(snapped.toFixed(decimals))));
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
};
|
||||
return (
|
||||
<div className="twk-num">
|
||||
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||
<input type="number" value={value} min={min} max={max} step={step}
|
||||
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakColor({ label, value, onChange }) {
|
||||
return (
|
||||
<div className="twk-row twk-row-h">
|
||||
<div className="twk-lbl"><span>{label}</span></div>
|
||||
<input type="color" className="twk-swatch" value={value}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakButton({ label, onClick, secondary = false }) {
|
||||
return (
|
||||
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||
onClick={onClick}>{label}</button>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||
});
|
||||
BIN
MOMO Pro/uploads/pasted-1777533471613-0.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
MOMO Pro/uploads/pasted-1777533701114-0.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
MOMO Pro/uploads/pasted-1777534333912-0.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
MOMO Pro/uploads/pasted-1777535279346-0.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
MOMO Pro/uploads/pasted-1777535411172-0.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
MOMO Pro/uploads/pasted-1777535532728-0.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
MOMO Pro/uploads/pasted-1777535914075-0.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
MOMO Pro/uploads/pasted-1777535935450-0.png
Normal file
|
After Width: | Height: | Size: 329 KiB |
BIN
MOMO Pro/uploads/pasted-1777535952073-0.png
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
MOMO Pro/uploads/pasted-1777535993732-0.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
MOMO Pro/uploads/pasted-1777536699716-0.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
MOMO Pro/uploads/pasted-1777560629235-0.png
Normal file
|
After Width: | Height: | Size: 307 KiB |
BIN
MOMO Pro/uploads/pasted-1777560718396-0.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
MOMO Pro/uploads/pasted-1777560743638-0.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
MOMO Pro/uploads/pasted-1777561178495-0.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
MOMO Pro/uploads/pasted-1777561213204-0.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
4
app.py
@@ -95,8 +95,8 @@ except Exception as e:
|
||||
sys_log.error(f"無法檢測磁碟空間: {e}")
|
||||
|
||||
# 🚩 系統版本定義 (備份與顯示用)
|
||||
# 🚩 2026-04-30 V10.31: CD sync app/config mount drift guard
|
||||
SYSTEM_VERSION = "V10.31"
|
||||
# 🚩 2026-04-30 V10.32: Frontend v2 visual baseline documented
|
||||
SYSTEM_VERSION = "V10.32"
|
||||
|
||||
# ==========================================
|
||||
# 🔒 SQL Injection 防護函數
|
||||
|
||||
@@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.31"
|
||||
SYSTEM_VERSION = "V10.32"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
220
docs/guides/frontend_upgrade_roadmap.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# EwoooC 前端更版推進路線圖
|
||||
|
||||
> 建立日期:2026-04-30
|
||||
> 依據:`MOMO Pro/HANDOFF.md`、`MOMO Pro/design-tokens.css`、`MOMO Pro/app/*.jsx`、現有 `templates/` 與 `routes/`
|
||||
> 使用者確認:後續前端視覺呈現風格與 UI/UX,以 `MOMO Pro/` 新版本為主要依據。
|
||||
|
||||
## 1. 目標
|
||||
|
||||
將 `MOMO Pro/` 內的 EwoooC 後台原型落地到正式 Flask 系統,先完成使用者可感知的新版後台殼層與核心頁,再逐步整理 API 與模板結構。
|
||||
|
||||
本次更版的視覺方向以原型為準:
|
||||
|
||||
- 米色工作台背景、暖墨文字、焦糖橘 accent。
|
||||
- 側邊欄 + 頂部工具列取代目前頂部橫向 navbar。
|
||||
- 數字、ID、時間、價格使用等寬字體。
|
||||
- Dashboard 採「監控總覽 / 焦點數據 / 商品列表」工作台結構。
|
||||
- 表格改為安靜米色表頭,不延續舊紫藍漸層表頭。
|
||||
- 新增或改版頁面不得再以舊紫藍 Bootstrap 後台作為主要視覺基準。
|
||||
|
||||
## 2. 現況判斷
|
||||
|
||||
目前正式系統不是 React / Next.js,而是 Flask + Jinja + Bootstrap:
|
||||
|
||||
- 主入口:`app.py`
|
||||
- 核心路由:`routes/dashboard_routes.py`、`routes/edm_routes.py`、`routes/vendor_routes.py`
|
||||
- 主要模板:`templates/dashboard.html`、`templates/edm_dashboard.html`、`templates/base.html`、`templates/components/_navbar.html`
|
||||
- 廠商缺貨模板另在:`web/templates/vendor_stockout/`
|
||||
|
||||
重要限制:
|
||||
|
||||
- 多數頁面不是繼承 `base.html`,而是各自包含完整 HTML、CSS 與 navbar include。
|
||||
- `templates/dashboard.html`、`templates/edm_dashboard.html`、`templates/sales_analysis.html`、`templates/daily_sales.html` 都是大型獨立頁。
|
||||
- 因此不適合一開始就全站硬切 Next.js,也不適合只改 `base.html` 期待全站換版。
|
||||
|
||||
## 3. 技術路線
|
||||
|
||||
採「Flask 先落地、API 漸進抽離、React/Next.js 延後決策」:
|
||||
|
||||
1. 第一階段先在現有 Flask/Jinja 內實作新版 design tokens、shell 與 Dashboard。
|
||||
2. 第二階段將活動看板、商品列表、廠商缺貨等核心頁搬到同一套 shell 與元件樣式。
|
||||
3. 第三階段把頁面需要的資料整理成 JSON API,讓日後要切 React/Next.js 時已有清楚資料邊界。
|
||||
4. 若正式引入 Next.js、TanStack Query、Tailwind 等新前端建置系統,需另立 ADR。
|
||||
|
||||
這樣做的好處是可以快速讓正式系統出現新版前端,同時避免一次改動部署架構、認證、API、模板與容器。
|
||||
|
||||
## 4. 分階段工作
|
||||
|
||||
### Phase 0:設計系統落地
|
||||
|
||||
目標:讓 Flask 模板可使用原型視覺語言。
|
||||
|
||||
建議檔案:
|
||||
|
||||
- 新增 `static/css/ewoooc-tokens.css`
|
||||
- 新增 `static/css/ewoooc-shell.css`
|
||||
- 新增 `templates/components/_ewoooc_shell.html`
|
||||
- 保留 `templates/components/_navbar.html` 給尚未改版頁面使用
|
||||
|
||||
工作內容:
|
||||
|
||||
- 從 `MOMO Pro/design-tokens.css` 搬移 CSS variables。
|
||||
- 定義 `.momo-app`、`.momo-mono`、`.momo-label`、button、badge、card、table、responsive 工具 class。
|
||||
- 建立新版 sidebar/topbar Jinja 組件,對應現有路由:
|
||||
- 商品看板 `/`
|
||||
- 活動看板 `/edm`
|
||||
- 分析報表 `/sales_analysis`、`/daily_sales`、`/growth_analysis`、`/monthly_summary_analysis`
|
||||
- 廠商缺貨 `/vendor-stockout`
|
||||
- AI 助手 `/ai_recommend`、`/ai_history`
|
||||
- 雲端匯入 `/auto_import`
|
||||
- 系統管理相關頁
|
||||
|
||||
驗收:
|
||||
|
||||
- 桌面寬度 sidebar 固定,內容區可滾動。
|
||||
- 手機寬度可折疊或轉為 drawer,不遮擋主要內容。
|
||||
- 未改版頁面仍可正常載入。
|
||||
|
||||
### Phase 1:Dashboard 第一版更版
|
||||
|
||||
目標:先替換使用頻率最高的商品看板。
|
||||
|
||||
建議檔案:
|
||||
|
||||
- 改造 `templates/dashboard.html`
|
||||
- 視需要新增 `templates/components/_product_table.html`
|
||||
- 視需要在 `routes/dashboard_routes.py` 補齊 template 欄位,不改既有價格邏輯
|
||||
|
||||
工作內容:
|
||||
|
||||
- 將原型 `page-dashboard.jsx` 轉成 Jinja + CSS:
|
||||
- 區塊 01:監控總覽 KPI
|
||||
- 區塊 02:焦點數據
|
||||
- 區塊 03:商品列表與篩選列
|
||||
- 沿用現有後端篩選、排序、分頁參數:
|
||||
- `q`
|
||||
- `category`
|
||||
- `filter`
|
||||
- `sort_by`
|
||||
- `order`
|
||||
- `page`
|
||||
- 商品圖片優先使用憲章規範的 CDN URL:
|
||||
- `https://m.momoshop.com.tw/moscdn/goods/{i_code}_m.webp`
|
||||
- 保留既有通知、匯出、價格歷史 modal 或 API 行為。
|
||||
|
||||
驗收:
|
||||
|
||||
- `/` 在桌面與手機都可操作。
|
||||
- 搜尋、分類、漲價、降價、新上架、下架篩選結果與舊版一致。
|
||||
- 排序與分頁不退化。
|
||||
- 價格漲跌邏輯不變,不碰 `product.momo_id`。
|
||||
|
||||
### Phase 2:活動看板更版
|
||||
|
||||
目標:把促銷活動頁改為原型的 Campaigns 結構。
|
||||
|
||||
建議檔案:
|
||||
|
||||
- 改造 `templates/edm_dashboard.html`
|
||||
- 視需要整理 `routes/edm_routes.py` 的頁面資料 shape
|
||||
|
||||
工作內容:
|
||||
|
||||
- 將五個活動頁整合成同一視覺:
|
||||
- 限時搶購 `/edm`
|
||||
- 1.1 狂歡 `/festival`
|
||||
- 母親節 `/mothers_day`
|
||||
- 520 情人節 `/valentine_520`
|
||||
- 勞動節 `/labor_day`
|
||||
- 實作活動 segmented tabs、活動 hero、活動 KPI、限時搶購 timeline、分類 chips、商品列表。
|
||||
- 手動更新、發送通知沿用既有 API,不改排程邏輯。
|
||||
|
||||
驗收:
|
||||
|
||||
- 五個活動入口都可進入。
|
||||
- 現有 sort query 行為保留。
|
||||
- 手動更新與通知按鈕不破壞 CSRF 與既有 API。
|
||||
|
||||
### Phase 3:核心營運頁統一殼層
|
||||
|
||||
目標:讓常用營運頁進入新版 shell,但先不重寫全部細節。
|
||||
|
||||
建議順序:
|
||||
|
||||
1. `web/templates/vendor_stockout/index.html`
|
||||
2. `web/templates/vendor_stockout/list.html`
|
||||
3. `templates/auto_import_index.html`
|
||||
4. `templates/ai_recommend.html`
|
||||
5. `templates/settings.html` / `templates/crawler_management.html`
|
||||
|
||||
工作內容:
|
||||
|
||||
- 先移除各頁重複 navbar 與 body 背景,接入新版 shell。
|
||||
- 表格、按鈕、badge 改用 tokens。
|
||||
- 大型功能頁不要在同一 PR 同時重構 JS 行為。
|
||||
|
||||
驗收:
|
||||
|
||||
- 原本表單、匯入、寄信、批次操作可以正常執行。
|
||||
- 手機版不出現文字重疊或表格爆版。
|
||||
|
||||
### Phase 4:API 邊界整理
|
||||
|
||||
目標:為未來 React/Next.js 或更完整前端分離鋪路。
|
||||
|
||||
建議 API:
|
||||
|
||||
- `GET /api/dashboard/summary`
|
||||
- `GET /api/dashboard/focus`
|
||||
- `GET /api/products`
|
||||
- `GET /api/products/<i_code>`
|
||||
- `GET /api/products/<i_code>/price-history`
|
||||
- `GET /api/campaigns`
|
||||
- `GET /api/campaigns/<campaign_id>`
|
||||
- `GET /api/vendors/out-of-stock`
|
||||
- `GET /api/schedule/status`
|
||||
|
||||
注意:
|
||||
|
||||
- API 計算邏輯必須與 dashboard 現有 `get_full_dashboard_data()` / `get_consolidated_data()` 一致。
|
||||
- 商品唯一識別使用 `product.i_code`。
|
||||
- 不為了前端重寫而引入 N+1 查詢。
|
||||
|
||||
### Phase 5:是否引入 React/Next.js 的決策
|
||||
|
||||
只有在以下條件成立時才啟動:
|
||||
|
||||
- Dashboard / Campaigns 已有穩定 JSON API。
|
||||
- 認證與 CSRF 邊界已確認。
|
||||
- Docker / CD pipeline 可承接 Node build。
|
||||
- 已立 ADR,明確記錄 Flask-only、Hybrid、Next.js 分離三種方案取捨。
|
||||
|
||||
## 5. 首批建議任務
|
||||
|
||||
第一個實作批次建議控制在小而完整:
|
||||
|
||||
1. 新增 tokens 與 shell CSS。
|
||||
2. 新增 `_ewoooc_shell.html`,但不替換舊 navbar。
|
||||
3. 新增一份新版 dashboard template 草稿,先掛在內部測試路由或 feature flag。
|
||||
4. 用同一份後端資料渲染新版 Dashboard。
|
||||
5. 本機以瀏覽器檢查桌面與手機 viewport。
|
||||
|
||||
完成後再決定是否將 `/` 切到新版。
|
||||
|
||||
## 6. 驗收清單
|
||||
|
||||
- 全 UI 文字使用繁體中文。
|
||||
- 桌面、平板、手機三種寬度都能操作。
|
||||
- 表格可橫向滾動,文字不重疊。
|
||||
- 漲價、降價、下架、新品數字與舊版一致。
|
||||
- 通知、手動更新、匯出、排序、分頁等既有行為不退化。
|
||||
- 修改後檢查 `logs/system.log` 無新增錯誤。
|
||||
- 重大更新前依憲章執行備份;正式部署遵守 ADR-011 與部署 SOP。
|
||||
|
||||
## 7. 不做事項
|
||||
|
||||
- 不在第一批直接引入 Next.js。
|
||||
- 不在第一批重寫所有模板。
|
||||
- 不在 UI 更版時改價格計算、爬蟲邏輯或資料庫 schema。
|
||||
- 不使用 `docker compose ... --remove-orphans`。
|
||||
- 不影響 `momo-db` 容器生命週期。
|
||||
@@ -54,6 +54,7 @@
|
||||
- **OpenClaw Bot 第二刀拆分**: Inline Keyboard builders 移到 `services/openclaw_bot/menu_keyboards.py`,透過 `configure_menu_keyboards()` 注入 `latest_date/_GOALS/TAIPEI_TZ`,route 檔下降到 5,240 行並補選單回歸測試。
|
||||
- **CD sync mount drift guard**: 發現舊 app 容器未掛載 `app.py/config.py` 時,rsync 後服務檔已更新但 `/health` 版本仍卡 image 內舊檔;CD sync 會檢查 mount,僅 drift 時一次性 recreate momo-app,其餘維持 HUP 熱重載。
|
||||
- **CD 單檔 bind mount inode 修復**: `app.py/config.py` 單檔 bind mount 會被 rsync/tar 的 inode replacement 卡住舊檔,CD rsync 改用 `--inplace`,避免 HUP reload 後仍讀到舊版本。
|
||||
- **Frontend V2 視覺基準立案**: `MOMO Pro/` prototype 與 `docs/guides/frontend_upgrade_roadmap.md` 成為前端更版依據,AGENTS/CONSTITUTION 改以米色工作台、暖墨文字、焦糖橘 accent 與新版 shell 規範作為後續 UI 基準。
|
||||
|
||||
### 2026-04-28~29:Phase 3e 重構大戰 + daily_sales cache 隱形 bug 根除
|
||||
- **app.py 縮減 -10.8%**: 7,386 → 6,590 行,11 commits 全綠零 502。
|
||||
|
||||