` | 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 面板觀察可調整的設計變數。
diff --git a/MOMO Pro/app/data.jsx b/MOMO Pro/app/data.jsx
new file mode 100644
index 0000000..a01b1ca
--- /dev/null
+++ b/MOMO Pro/app/data.jsx
@@ -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;
diff --git a/MOMO Pro/app/icons.jsx b/MOMO Pro/app/icons.jsx
new file mode 100644
index 0000000..b15788f
--- /dev/null
+++ b/MOMO Pro/app/icons.jsx
@@ -0,0 +1,65 @@
+// MOMO Pro - Icons (簡單 SVG 圖示)
+const Icon = ({ name, size = 16, color = 'currentColor', strokeWidth = 2, style }) => {
+ const paths = {
+ dashboard: <>>,
+ orders: <>>,
+ products: <>>,
+ inventory: <>>,
+ members: <>>,
+ marketing: <>>,
+ analytics: <>>,
+ settings: <>>,
+ search: <>>,
+ bell: <>>,
+ plus: <>>,
+ chevronDown: <>>,
+ chevronRight: <>>,
+ chevronLeft: <>>,
+ chevronUp: <>>,
+ arrowUp: <>>,
+ arrowDown: <>>,
+ trendUp: <>>,
+ trendDown: <>>,
+ moreHorizontal: <>>,
+ moreVertical: <>>,
+ edit: <>>,
+ trash: <>>,
+ eye: <>>,
+ filter: <>>,
+ download: <>>,
+ upload: <>>,
+ calendar: <>>,
+ check: <>>,
+ checkCircle: <>>,
+ x: <>>,
+ xCircle: <>>,
+ clock: <>>,
+ truck: <>>,
+ package: <>>,
+ user: <>>,
+ dollar: <>>,
+ shoppingBag: <>>,
+ refresh: <>>,
+ copy: <>>,
+ menu: <>>,
+ helpCircle: <>>,
+ star: <>>,
+ tag: <>>,
+ creditCard: <>>,
+ mail: <>>,
+ logout: <>>,
+ grid: <>>,
+ list: <>>,
+ command: <>>,
+ sparkle: <>>,
+ };
+ return (
+
+ );
+};
+
+window.Icon = Icon;
diff --git a/MOMO Pro/app/main.jsx b/MOMO Pro/app/main.jsx
new file mode 100644
index 0000000..7e64d69
--- /dev/null
+++ b/MOMO Pro/app/main.jsx
@@ -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 (
+
+
setTweak && setTweak('page', id)}
+ collapsed={tweaks.sidebarCollapsed}
+ sidebarTheme={tweaks.sidebarTheme}
+ />
+
+
setTweak && setTweak('sidebarCollapsed', !tweaks.sidebarCollapsed)}
+ onOpenCmd={() => setCmdOpen(true)}
+ />
+ {label && (
+ {label}
+ )}
+
+ {page === 'dashboard' && (
+
+ )}
+ {page === 'campaigns' && (
+
+ )}
+ {page === 'orders' && (
+
+ )}
+ {page === 'products' && (
+
+ )}
+ {!['dashboard','orders','products','campaigns'].includes(page) && (
+
+ )}
+
+
+
+ setCmdOpen(false)}
+ onNavigate={(id) => setTweak && setTweak('page', id)}
+ />
+ setEditProduct(null)}
+ buttonStyle={tweaks.buttonStyle}
+ />
+
+ );
+};
+
+const EmptyPage = ({ page }) => {
+ const labels = {
+ inventory: '庫存管理', members: '會員管理', marketing: '行銷活動',
+ analytics: '數據分析', settings: '系統設定',
+ };
+ return (
+
+
+
+
+
+
+
+ {labels[page]} 即將推出
+
+
+ 此區尚未建置示意內容,歡迎在 Tweaks 切換到其他頁面預覽。
+
+
+
+
+ );
+};
+
+window.MomoApp = MomoApp;
+window.TWEAK_DEFAULTS = TWEAK_DEFAULTS;
diff --git a/MOMO Pro/app/modals.jsx b/MOMO Pro/app/modals.jsx
new file mode 100644
index 0000000..bdf7262
--- /dev/null
+++ b/MOMO Pro/app/modals.jsx
@@ -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 (
+
+
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',
+ }}>
+
+
{title}
+
+
+
+ {children}
+
+ {footer && (
+
{footer}
+ )}
+
+
+ );
+};
+
+// ===== 編輯商品 Modal =====
+const ProductEditModal = ({ open, product, onClose, buttonStyle = 'gradient' }) => {
+ if (!product) return null;
+ const Field = ({ label, children, hint }) => (
+
+
+ {children}
+ {hint &&
{hint}
}
+
+ );
+ 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 (
+
+
+
+
+ >
+ }>
+
+
+
+
+
+ {product.emoji}
+
+ {[1,2,3].map(i => (
+
{product.emoji}
+ ))}
+
+
+
+
+
+
+
+
+
+ SEO 提示
+
+ 標題建議 30 字內,描述含 3-5 個關鍵字,可提升搜尋曝光。
+
+
+
+
+ );
+};
+
+// ===== 命令面板 =====
+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 (
+
+
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',
+ }}>
+
+
+ setQuery(e.target.value)}
+ placeholder="搜尋指令、訂單、商品、會員…"
+ style={{
+ flex: 1, border: 'none', outline: 'none',
+ fontSize: 15, color: 'var(--momo-text-primary)',
+ background: 'transparent',
+ }} />
+ ESC
+
+
+ {Object.keys(groups).length === 0 && (
+
+ 找不到相符的結果
+
+ )}
+ {Object.entries(groups).map(([kind, items]) => (
+
+
{kind}
+ {items.map((c, i) => (
+
+ ))}
+
+ ))}
+
+
+
+ ↑↓
+ 選擇
+
+
+ ↵
+ 執行
+
+
+
+
+ );
+};
+
+Object.assign(window, { Modal, ProductEditModal, CommandPalette });
diff --git a/MOMO Pro/app/page-campaigns.jsx b/MOMO Pro/app/page-campaigns.jsx
new file mode 100644
index 0000000..1fab64e
--- /dev/null
+++ b/MOMO Pro/app/page-campaigns.jsx
@@ -0,0 +1,465 @@
+// EwoooC - 活動看板(新視覺語言:與 Dashboard 一致的設計語彙)
+
+// ===== 活動切換 — 精緻 segmented tabs =====
+const CampaignSwitcher = ({ active, setActive, campaigns }) => {
+ const ids = Object.keys(campaigns);
+ return (
+
+ {ids.map(id => {
+ const c = campaigns[id];
+ const isActive = id === active;
+ return (
+
+ );
+ })}
+
+ );
+};
+
+// ===== 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 (
+
+ {/* 點陣裝飾 */}
+
+ {/* 大圖示 */}
+ {c.icon}
+
+
+ {/* 標籤列 */}
+
+ CAMPAIGN
+
+ ID · {activeId.toUpperCase()}
+
+
+
+ {/* 標題 */}
+
+ {c.name}
+
+
+ {/* meta 列 */}
+
+
+
+
最後更新
+
{c.lastUpdate}
+
+
+
商品總數
+
+ {c.total.toLocaleString()}
+
+
+
+
+ {/* 操作 */}
+
+
+ {activeId === 'flash' && (
+
+ )}
+
+
+
+ );
+};
+
+// ===== 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 (
+
+
+
+ 活動數據
+
+
+ {kpis.map(k => (
+
+
{k.label}
+
+ {k.value.toLocaleString()}
+
+
+ ))}
+
+ {/* 排程列 */}
+
+
+ 排程 · {schedule.lastRun}
+ |
+ 異動 {schedule.anomalies} 筆
+
+
+ );
+};
+
+// ===== 時段切片改為時間軸(限時搶購用) =====
+const TimeSlotTimeline = ({ slots }) => {
+ const max = Math.max(...slots.map(s => s.count), 1);
+ return (
+
+
+
+
+ 時段排程
+
+
+ 24H · {slots.reduce((a, b) => a + b.count, 0)} 件
+
+
+
+
+ {slots.map((s, i) => {
+ const heightPct = max > 0 ? (s.count / max) * 100 : 0;
+ return (
+
+ {/* bar 區,高度固定 80px */}
+
+
+ {s.active && (
+
NOW
+ )}
+
+
+
{s.time}
+
{s.count}
+
+ );
+ })}
+
+
+ );
+};
+
+// ===== 分類 chip 列(橫向 scroll) =====
+const CategoryChips = ({ cats, activeIdx, setActive }) => (
+
+
+
+
+ 分類 {cats.length}
+
+
+
+ {cats.map((cat, i) => {
+ const isActive = activeIdx == null ? cat.active : i === activeIdx;
+ return (
+
+ );
+ })}
+
+
+);
+
+// ===== 商品列表(與 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 (
+ {children}
+ );
+};
+
+const CampaignProductRow = ({ p, isFlash, idx }) => (
+ e.currentTarget.style.background = 'var(--momo-bg-paper)'}
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
+ |
+ {p.cat}
+ |
+
+
+ {p.emoji}
+
+ {p.name}
+
+ ID: {p.id}
+
+
+
+ {p.new && NEW}
+ {p.delisted && 下架}
+ {p.up && 漲價}
+
+
+
+ |
+
+ {p.up && p.change ? (
+
+
+ ▲ +${p.change} ({p.changePct}%)
+
+
+
+ ${p.oldPrice.toLocaleString()}
+
+
+ ${p.price.toLocaleString()}
+
+
+
+ ) : (
+
+ ${p.price.toLocaleString()}
+
+ )}
+ |
+
+ {isFlash && p.limit ? (
+
+ 🔥 {p.limit}組
+
+ ) : p.off ? (
+ {p.off}
+ ) : (
+ {p.status || '活動中'}
+ )}
+ |
+
+);
+
+const CampaignProductTable = ({ products, total, isFlash }) => (
+
+
+
+ 商品列表 ({total.toLocaleString()} 筆)
+
+
+
+
+
+
+
+
+ {[
+ { label: '分類', w: 140 },
+ { label: '商品資訊' },
+ { label: '價格', w: 150, align: 'right' },
+ { label: isFlash ? '倒數組數' : '狀態', w: 130, align: 'right' },
+ ].map((h, i) => (
+ |
+ {h.label} ↕
+ |
+ ))}
+
+
+
+ {products.map((p, i) => )}
+
+
+
+
+);
+
+// ===== 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 (
+
+ {/* 活動切換 */}
+
+
+ {/* Hero 雙欄 */}
+
+
+
+
+
+ {/* 時段時間軸(僅限時搶購) */}
+ {c.timeSlots &&
}
+
+ {/* 分類 chips */}
+ {c.categories &&
}
+
+ {/* 商品列表 */}
+
+
+ );
+};
+
+window.CampaignsPage = CampaignsPage;
diff --git a/MOMO Pro/app/page-dashboard.jsx b/MOMO Pro/app/page-dashboard.jsx
new file mode 100644
index 0000000..9a1fa28
--- /dev/null
+++ b/MOMO Pro/app/page-dashboard.jsx
@@ -0,0 +1,384 @@
+// EwoooC - 商品看板(Nothing × Claude 美學:安靜、結構化、Mono 為主)
+
+// ===== 編號標籤(呼應 sidebar 的 01/02/03) =====
+const SectionLabel = ({ num, children, sub }) => (
+
+ {num}
+
+ {children}
+
+ {sub && (
+
+ {sub}
+
+ )}
+
+);
+
+// ===== 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 (
+
+ {items.map((it, i) => (
+
+
+ {it.label}
+
+
+ {typeof it.value === 'number' ? it.value.toLocaleString() : it.value}
+
+
+ {it.sub}
+
+
+ ))}
+
+ );
+};
+
+// ===== 焦點 + 排程(雙欄,安靜版) =====
+const FocusRow = ({ dynamics, schedule, stats }) => (
+
+ {/* 最活躍分類 */}
+
+
最活躍分類
+
+ {dynamics.hottestCategory}
+
+
+ {dynamics.hottestCount} 件商品變動
+
+
+
+ {/* 最大變動 */}
+
+
最大變動
+
+ +${dynamics.biggestChange.amount.toLocaleString()}
+
+
{dynamics.biggestChange.product}
+
+
+ {/* 爬蟲排程 */}
+
+
+ 爬蟲排程
+
+
+ ACTIVE
+
+
+
{schedule.lastRun}
+
+ 掃描 {schedule.scanned.toLocaleString()} 筆 · 新增 +{schedule.added}
+
+
+
+);
+
+// ===== 篩選列(簡潔版) =====
+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 (
+
+
+ setSearch(e.target.value)} size="sm" />
+
+
+
+
+ {/* segmented tabs */}
+
+ {tabs.map(t => {
+ const active = tab === t.id;
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// ===== 漲跌格子 =====
+const ChangeCell = ({ value }) => {
+ if (value == null) return —;
+ if (value === 0) return 0;
+ const up = value > 0;
+ return (
+
+ {up ? '▲' : '▼'}
+ {up ? '+' : ''}{value.toLocaleString()}
+
+ );
+};
+
+// ===== 商品列表(黑色表頭、Mono、安靜) =====
+const ProductTable = ({ products, total, schedule, onRowClick }) => (
+
+
+
+ 04
+ 商品列表
+
+ {total.toLocaleString()} 筆
+
+
+
+
+
+ 排程 {schedule.lastRun} · 掃描 {schedule.scanned.toLocaleString()} · 新增 +{schedule.added}
+
+
+
+
+
+
+
+
+
+ {[
+ { 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) => (
+ |
+ {h.label}
+ ↕
+ |
+ ))}
+
+
+
+ {products.map((p, idx) => (
+ 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'}>
+ |
+ {p.category}
+ |
+
+
+ {p.emoji}
+
+ {p.name}
+
+ ID · {p.id}
+
+
+
+
+
+
+ |
+
+
+ ${p.price.toLocaleString()}
+
+ |
+ |
+ |
+ {p.updatedAt} |
+ {p.listedAt} |
+
+ ))}
+
+
+
+
+);
+
+// ===== 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 (
+
+ {/* 區塊 1:KPI 一排 */}
+
+
+ {/* 區塊 2:焦點數據 */}
+
+
+ {/* 區塊 3:篩選 + 列表 */}
+
+
+ );
+};
+
+window.DashboardPage = DashboardPage;
diff --git a/MOMO Pro/app/page-orders.jsx b/MOMO Pro/app/page-orders.jsx
new file mode 100644
index 0000000..bcc3461
--- /dev/null
+++ b/MOMO Pro/app/page-orders.jsx
@@ -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 (
+
+
+
+
+ >
+ }
+ />
+
+ {/* Tabs */}
+
+ {tabs.map(t => (
+
+ ))}
+
+
+
+ {/* Toolbar */}
+
+
+ setSearch(e.target.value)} size="sm" />
+
+
+
+
+
+
+
+
+ {/* Bulk action bar */}
+ {selected.size > 0 && (
+
+
+ 已選取 {selected.size} 筆訂單
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Table */}
+
+
+
+
+ |
+ 0}
+ indeterminate={selected.size > 0 && selected.size < filtered.length}
+ onChange={toggleAll}
+ />
+ |
+ {[
+ { 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) => (
+
+ {h.sort ? (
+
+ ) : h.label}
+ |
+ ))}
+
+
+
+ {filtered.map(order => {
+ const isSelected = selected.has(order.id);
+ return (
+ !isSelected && (e.currentTarget.style.background = 'var(--momo-bg-subtle)')}
+ onMouseLeave={e => !isSelected && (e.currentTarget.style.background = 'transparent')}
+ >
+ |
+ toggleOne(order.id)} />
+ |
+
+ {order.id}
+ |
+
+
+
+
+ {order.customer}
+ {order.email}
+
+
+ |
+
+ NT$ {order.total.toLocaleString()}
+ |
+
+ {order.items} 項
+ |
+
+ {STATUS_MAP[order.status].label}
+ |
+
+ {STATUS_MAP[order.payment].label}
+ |
+
+ {order.channel}
+ |
+
+ {order.date}
+ |
+
+
+
+
+
+
+ |
+
+ );
+ })}
+
+
+
+
+ {/* Pagination */}
+
+
顯示 1-{filtered.length} 筆,共 {filtered.length} 筆結果
+
+
+ {[1, 2, 3, 4, 5].map(n => (
+
+ ))}
+ …
+
+
+
+
+
+
+ );
+};
+
+window.OrdersPage = OrdersPage;
diff --git a/MOMO Pro/app/page-products.jsx b/MOMO Pro/app/page-products.jsx
new file mode 100644
index 0000000..ec0e86e
--- /dev/null
+++ b/MOMO Pro/app/page-products.jsx
@@ -0,0 +1,200 @@
+// EwoooC - 商品列表(對應截圖:價格 / 昨日漲跌 / 週漲跌 / 上次更新 / 上架時間 / 點擊彈窗)
+
+const ChangeCell = ({ value }) => {
+ if (value == null) return —;
+ if (value === 0) return 0;
+ const up = value > 0;
+ return (
+
+ {up ? '▲' : '▼'}
+ {up ? '+' : ''}{value.toLocaleString()}
+
+ );
+};
+
+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 (
+
+
商品列表 商品監控>}
+ subtitle={`監控 ${D.monitorStats.total.toLocaleString()} 件商品 · 點擊任一列查看 30 天價格走勢`}
+ actions={
+ <>
+
+
+ >
+ }
+ />
+
+
+ {/* Toolbar */}
+
+
+ setSearch(e.target.value)} size="sm" />
+
+
+
+ {cats.slice(0, 6).map(c => (
+
+ ))}
+
+
+
+
+
+ {filtered.length} / {D.products.length} 筆
+
+
+
+ {/* Table */}
+
+
+
+
+ {[
+ { 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) => (
+ |
+ {h.label}
+ {h.sub && {h.sub} }
+ |
+ ))}
+
+
+
+ {filtered.map((p, idx) => (
+ 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'}>
+ |
+ #{p.id}
+ |
+
+ {p.category}
+ |
+
+
+ {p.emoji}
+
+ {p.name}
+
+
+ |
+
+ ${p.price.toLocaleString()}
+ |
+
+
+ |
+
+
+ |
+
+ {p.updatedAt}
+ |
+
+ {p.listedAt}
+ |
+
+
+
+
+ |
+
+ ))}
+
+
+
+
+
+
+ 顯示 1–{filtered.length} 筆,共 {D.monitorStats.total.toLocaleString()} 筆
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+window.ProductsPage = ProductsPage;
diff --git a/MOMO Pro/app/shell.jsx b/MOMO Pro/app/shell.jsx
new file mode 100644
index 0000000..86a9eeb
--- /dev/null
+++ b/MOMO Pro/app/shell.jsx
@@ -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 (
+
+ );
+};
+
+// ===== Topbar =====
+const Topbar = ({ onToggleSidebar, onOpenCmd }) => (
+
+
+
+
+
+
+
+
+ {/* 排程徽章 */}
+
+
+ 下次排程
+ 13:00
+
+
+
+
+
+
+
+
+
+
+
+);
+
+Object.assign(window, { Sidebar, Topbar, NAV_GROUPS });
diff --git a/MOMO Pro/app/ui.jsx b/MOMO Pro/app/ui.jsx
new file mode 100644
index 0000000..aa5d0da
--- /dev/null
+++ b/MOMO Pro/app/ui.jsx
@@ -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 (
+
+ {dot && }
+ {children}
+
+ );
+};
+
+// ===== 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 (
+
+ );
+};
+
+// ===== 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 (
+ {initial}
+ );
+};
+
+// ===== 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 (
+ {children}
+ );
+};
+
+// ===== Input =====
+const Input = ({ icon, value, onChange, placeholder, style, type = 'text', size = 'md' }) => {
+ const heights = { sm: 32, md: 38, lg: 44 };
+ return (
+
+ {icon && }
+
+
+ );
+};
+
+// ===== Checkbox =====
+const Checkbox = ({ checked, onChange, indeterminate = false, style }) => {
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ if (ref.current) ref.current.indeterminate = indeterminate;
+ }, [indeterminate]);
+ return (
+
+ );
+};
+
+// ===== Page Header =====
+const PageHeader = ({ title, subtitle, actions, breadcrumbs }) => (
+
+
+ {breadcrumbs && (
+
+ {breadcrumbs.map((b, i) => (
+
+ {i > 0 && }
+ {b}
+
+ ))}
+
+ )}
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {actions &&
{actions}
}
+
+);
+
+Object.assign(window, { Badge, Button, Avatar, Card, Input, Checkbox, PageHeader });
diff --git a/MOMO Pro/data.jsx b/MOMO Pro/data.jsx
new file mode 100644
index 0000000..bdb3dbc
--- /dev/null
+++ b/MOMO Pro/data.jsx
@@ -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 });
diff --git a/MOMO Pro/design-canvas.jsx b/MOMO Pro/design-canvas.jsx
new file mode 100644
index 0000000..a5968a7
--- /dev/null
+++ b/MOMO Pro/design-canvas.jsx
@@ -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:
+//
+//
+// …
+// …
+//
+//
+
+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 (
+
+ {ready && children}
+ {state.focus && registry[state.focus] && (
+
+ )}
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// 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 (
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// 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 (
+
+
+
ctx && sid && ctx.patchSection(sid, { title: v })}
+ style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
+ {subtitle && {subtitle}
}
+
+
+ {order.map((k) => (
+ ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
+ onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
+ onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
+ ))}
+
+ {rest}
+
+ );
+}
+
+// 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 (
+
+
+
+
+
+
+ e.stopPropagation()}
+ style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
+
+
+
+
+
+ );
+}
+
+// Inline rename — commits on blur or Enter.
+function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
+ const T = tag;
+ return (
+ e.stopPropagation()}
+ onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
+ style={style}>{value}
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// 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 }) => (
+
+ );
+
+ // 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(
+ 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) */}
+
e.stopPropagation()}
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
+
+
+ {ddOpen && (
+
+ {sectionOrder.map((sid) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ {/* card centered, label + index below — only the card itself stops
+ propagation so any backdrop click (including the margins around
+ the card) exits focus */}
+
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
+
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
+ {(sec.labels || {})[aid] ?? artboard.props.label}
+ {idx + 1} / {peers.length}
+
+
+
+
go(-1)} />
+ go(1)} />
+
+ {/* dots */}
+ e.stopPropagation()}
+ style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
+ {peers.map((p, i) => (
+
+ ,
+ document.body,
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// Post-it — absolute-positioned sticky note
+// ─────────────────────────────────────────────────────────────
+function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
+ return (
+ {children}
+ );
+}
+
+Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
diff --git a/MOMO Pro/design-tokens.css b/MOMO Pro/design-tokens.css
new file mode 100644
index 0000000..74bcbee
--- /dev/null
+++ b/MOMO Pro/design-tokens.css
@@ -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; }
+}
diff --git a/MOMO Pro/tweaks-panel.jsx b/MOMO Pro/tweaks-panel.jsx
new file mode 100644
index 0000000..5f8f95a
--- /dev/null
+++ b/MOMO Pro/tweaks-panel.jsx
@@ -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 , 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 (
+//
+// Hello
+//
+//
+// setTweak('fontSize', v)} />
+// setTweak('density', v)} />
+//
+// setTweak('primaryColor', v)} />
+// setTweak('dark', v)} />
+//
+//
+// );
+// }
+//
+// ─────────────────────────────────────────────────────────────────────────────
+
+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,");
+ 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 (
+ <>
+
+
+
+ {title}
+
+
+
{children}
+
+ >
+ );
+}
+
+// ── Layout helpers ──────────────────────────────────────────────────────────
+
+function TweakSection({ label, children }) {
+ return (
+ <>
+ {label}
+ {children}
+ >
+ );
+}
+
+function TweakRow({ label, value, children, inline = false }) {
+ return (
+
+
+ {label}
+ {value != null && {value}}
+
+ {children}
+
+ );
+}
+
+// ── Controls ────────────────────────────────────────────────────────────────
+
+function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
+ return (
+
+ onChange(Number(e.target.value))} />
+
+ );
+}
+
+function TweakToggle({ label, value, onChange }) {
+ return (
+
+
{label}
+
+
+ );
+}
+
+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 (
+
+
+
+ {opts.map((o) => (
+
+ ))}
+
+
+ );
+}
+
+function TweakSelect({ label, value, options, onChange }) {
+ return (
+
+
+
+ );
+}
+
+function TweakText({ label, value, placeholder, onChange }) {
+ return (
+
+ onChange(e.target.value)} />
+
+ );
+}
+
+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 (
+
+ {label}
+ onChange(clamp(Number(e.target.value)))} />
+ {unit && {unit}}
+
+ );
+}
+
+function TweakColor({ label, value, onChange }) {
+ return (
+
+
{label}
+
onChange(e.target.value)} />
+
+ );
+}
+
+function TweakButton({ label, onClick, secondary = false }) {
+ return (
+
+ );
+}
+
+Object.assign(window, {
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
+ TweakText, TweakNumber, TweakColor, TweakButton,
+});
diff --git a/MOMO Pro/uploads/pasted-1777533471613-0.png b/MOMO Pro/uploads/pasted-1777533471613-0.png
new file mode 100644
index 0000000..fbcd5b9
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777533471613-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777533701114-0.png b/MOMO Pro/uploads/pasted-1777533701114-0.png
new file mode 100644
index 0000000..ad8fcf5
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777533701114-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777534333912-0.png b/MOMO Pro/uploads/pasted-1777534333912-0.png
new file mode 100644
index 0000000..485b63b
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777534333912-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777535279346-0.png b/MOMO Pro/uploads/pasted-1777535279346-0.png
new file mode 100644
index 0000000..677dbf6
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777535279346-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777535411172-0.png b/MOMO Pro/uploads/pasted-1777535411172-0.png
new file mode 100644
index 0000000..e808d81
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777535411172-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777535532728-0.png b/MOMO Pro/uploads/pasted-1777535532728-0.png
new file mode 100644
index 0000000..793696a
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777535532728-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777535914075-0.png b/MOMO Pro/uploads/pasted-1777535914075-0.png
new file mode 100644
index 0000000..ccf3c67
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777535914075-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777535935450-0.png b/MOMO Pro/uploads/pasted-1777535935450-0.png
new file mode 100644
index 0000000..96ae313
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777535935450-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777535952073-0.png b/MOMO Pro/uploads/pasted-1777535952073-0.png
new file mode 100644
index 0000000..a023aff
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777535952073-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777535993732-0.png b/MOMO Pro/uploads/pasted-1777535993732-0.png
new file mode 100644
index 0000000..773d888
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777535993732-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777536699716-0.png b/MOMO Pro/uploads/pasted-1777536699716-0.png
new file mode 100644
index 0000000..257f1ae
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777536699716-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777560629235-0.png b/MOMO Pro/uploads/pasted-1777560629235-0.png
new file mode 100644
index 0000000..f59161e
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777560629235-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777560718396-0.png b/MOMO Pro/uploads/pasted-1777560718396-0.png
new file mode 100644
index 0000000..8811c9d
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777560718396-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777560743638-0.png b/MOMO Pro/uploads/pasted-1777560743638-0.png
new file mode 100644
index 0000000..8adbfb6
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777560743638-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777561178495-0.png b/MOMO Pro/uploads/pasted-1777561178495-0.png
new file mode 100644
index 0000000..b5410de
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777561178495-0.png differ
diff --git a/MOMO Pro/uploads/pasted-1777561213204-0.png b/MOMO Pro/uploads/pasted-1777561213204-0.png
new file mode 100644
index 0000000..2316a65
Binary files /dev/null and b/MOMO Pro/uploads/pasted-1777561213204-0.png differ
diff --git a/app.py b/app.py
index 3aa6560..45fab0f 100644
--- a/app.py
+++ b/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 防護函數
diff --git a/config.py b/config.py
index c08ffde..19cf40c 100644
--- a/config.py
+++ b/config.py
@@ -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 # 用於模板顯示
diff --git a/docs/guides/frontend_upgrade_roadmap.md b/docs/guides/frontend_upgrade_roadmap.md
new file mode 100644
index 0000000..53a97e1
--- /dev/null
+++ b/docs/guides/frontend_upgrade_roadmap.md
@@ -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/`
+- `GET /api/products//price-history`
+- `GET /api/campaigns`
+- `GET /api/campaigns/`
+- `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` 容器生命週期。
diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md
index 3d9b6e2..b72f66e 100644
--- a/docs/memory/history_logs.md
+++ b/docs/memory/history_logs.md
@@ -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。