From 0e18ff304c76d10d9e42c0c9a318c7a3b29b61a3 Mon Sep 17 00:00:00 2001 From: OoO Date: Thu, 30 Apr 2026 23:45:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E6=96=B0=E5=A2=9E=20V2=20das?= =?UTF-8?q?hboard=20feature=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + CONSTITUTION.md | 8 +- app.py | 4 +- config.py | 2 +- docs/guides/frontend_upgrade_roadmap.md | 3 +- docs/memory/history_logs.md | 2 + routes/dashboard_routes.py | 8 +- templates/components/_ewoooc_shell.html | 101 ++++ templates/dashboard_v2.html | 700 ++++++++++++++++++++++++ templates/ewoooc_base.html | 109 ++++ tests/test_frontend_v2_assets.py | 52 ++ web/static/css/ewoooc-shell.css | 537 ++++++++++++++++++ web/static/css/ewoooc-tokens.css | 193 +++++++ 13 files changed, 1713 insertions(+), 8 deletions(-) create mode 100644 templates/components/_ewoooc_shell.html create mode 100644 templates/dashboard_v2.html create mode 100644 templates/ewoooc_base.html create mode 100644 tests/test_frontend_v2_assets.py create mode 100644 web/static/css/ewoooc-shell.css create mode 100644 web/static/css/ewoooc-tokens.css diff --git a/.gitignore b/.gitignore index d0b005b..18f2aa8 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ data/*.db-shm data/*.db-wal data/*.sqlite data/*.sqlite3 +data/*.lock database/*.db database/*.db-journal database/*.db-shm @@ -84,6 +85,7 @@ data/excel_exports/ # 上傳檔案 web/static/uploads/ web/static/screenshots/ +templates/__init__.py # 測試與覆蓋率報告 .pytest_cache/ diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 401ede8..420eee7 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -2,7 +2,7 @@ > 本文件定義專案開發的核心準則與不可違反的規範 > **建立日期**: 2026-01-12 -> **當前版本**: V10.32 (Frontend v2 visual baseline) +> **當前版本**: V10.33 (Frontend v2 dashboard feature flag) > **最後更新**: 2026-04-30 --- @@ -134,6 +134,12 @@ - ❌ **禁止**: 使用純黑色 `#000`(刺眼) - ❌ **禁止**: 使用低對比度顏色組合 +### 第 14.1 條:真實資料與真實頁面(絕對禁止違反) +- ✅ **正確**: 所有新版頁面內容必須接正式後端資料、既有 route/service/API 或明確可追溯的資料來源。 +- ✅ **正確**: 尚未完成串接的功能,應顯示真實空狀態、錯誤狀態或「尚未提供資料來源」的可診斷訊息。 +- ❌ **禁止**: 使用 mock data、假商品、假 KPI、假排程、假使用者、假頁面或純展示用 placeholder 冒充已完成。 +- ❌ **禁止**: 為了符合原型畫面而改寫或捏造業務數字。 + --- ## 第五章:系統架構規範 diff --git a/app.py b/app.py index 45fab0f..6f2955d 100644 --- a/app.py +++ b/app.py @@ -95,8 +95,8 @@ except Exception as e: sys_log.error(f"無法檢測磁碟空間: {e}") # 🚩 系統版本定義 (備份與顯示用) -# 🚩 2026-04-30 V10.32: Frontend v2 visual baseline documented -SYSTEM_VERSION = "V10.32" +# 🚩 2026-04-30 V10.33: Frontend v2 dashboard feature flag +SYSTEM_VERSION = "V10.33" # ========================================== # 🔒 SQL Injection 防護函數 diff --git a/config.py b/config.py index 19cf40c..a02908a 100644 --- a/config.py +++ b/config.py @@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.32" +SYSTEM_VERSION = "V10.33" 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 index 53a97e1..6d78d30 100644 --- a/docs/guides/frontend_upgrade_roadmap.md +++ b/docs/guides/frontend_upgrade_roadmap.md @@ -16,6 +16,7 @@ - Dashboard 採「監控總覽 / 焦點數據 / 商品列表」工作台結構。 - 表格改為安靜米色表頭,不延續舊紫藍漸層表頭。 - 新增或改版頁面不得再以舊紫藍 Bootstrap 後台作為主要視覺基準。 +- 所有頁面內容必須使用真實資料來源;禁止用 mock data、假頁面或展示用 placeholder 冒充完成。 ## 2. 現況判斷 @@ -196,7 +197,7 @@ 1. 新增 tokens 與 shell CSS。 2. 新增 `_ewoooc_shell.html`,但不替換舊 navbar。 3. 新增一份新版 dashboard template 草稿,先掛在內部測試路由或 feature flag。 -4. 用同一份後端資料渲染新版 Dashboard。 +4. 用同一份後端真實資料渲染新版 Dashboard,不建立假商品或假統計。 5. 本機以瀏覽器檢查桌面與手機 viewport。 完成後再決定是否將 `/` 切到新版。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index b72f66e..5a0c867 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -55,6 +55,8 @@ - **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 基準。 +- **Frontend V2 Phase 0 assets**: 新增 `web/static/css/ewoooc-tokens.css`、`web/static/css/ewoooc-shell.css`、`templates/ewoooc_base.html` 與 `_ewoooc_shell.html`,先建立可重用 shell,不替換既有頁面;憲章補「真實資料與真實頁面」紅線,禁止假資料冒充完成。 +- **Dashboard V2 feature flag**: `/` 預設仍走既有 `dashboard.html`,`/?ui=v2` 才渲染 `dashboard_v2.html`;新版頁沿用既有 dashboard 真實資料與篩選/排序參數,不建立 mock 商品或假 KPI。 ### 2026-04-28~29:Phase 3e 重構大戰 + daily_sales cache 隱形 bug 根除 - **app.py 縮減 -10.8%**: 7,386 → 6,590 行,11 commits 全綠零 502。 diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 9bb61a6..2f68bf2 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -555,7 +555,7 @@ def index(): filtered_items = [i for i in base_items if i['record'].product_id in new_product_ids] elif filter_type == 'delisted': for item in today_delisted_items: - class MockRecord: + class DelistedRecord: def __init__(self, p, price): self.product = p self.price = price @@ -563,7 +563,7 @@ def index(): if not search_query or search_query.lower() in item['product'].name.lower(): filtered_items.append({ - 'record': MockRecord(item['product'], item['last_price']), + 'record': DelistedRecord(item['product'], item['last_price']), 'stats': {'1d_diff': 0, '7d_diff': 0, '30d_diff': 0}, 'yesterday_diff': 0, 'today_changes': [], @@ -619,7 +619,9 @@ def index(): category_name = item['record'].product.category item['category_color'] = get_color_for_string(category_name) - return render_template('dashboard.html', + template_name = 'dashboard_v2.html' if request.args.get('ui') == 'v2' else 'dashboard.html' + + return render_template(template_name, total_products=total_products_history, today_new_products=today_new_products, total_price_records=total_price_records, diff --git a/templates/components/_ewoooc_shell.html b/templates/components/_ewoooc_shell.html new file mode 100644 index 0000000..efec0a7 --- /dev/null +++ b/templates/components/_ewoooc_shell.html @@ -0,0 +1,101 @@ +{# + EwoooC Frontend V2 shell. + + 使用方式: + {% include 'components/_ewoooc_shell.html' %} + + 呼叫頁需提供 active_page;未提供時會以空字串處理。 +#} + +{% set _active_page = active_page|default('') %} +{% set _scheduler = scheduler_stats|default({}) %} +{% set _momo_runs = _scheduler.get('momo_task', []) if _scheduler is mapping else [] %} +{% set _latest_run = _momo_runs[0] if _momo_runs else {} %} +{% set _has_scheduler_data = _latest_run is mapping and _latest_run %} +{% set _last_run = _latest_run.get('last_run', '--') if _has_scheduler_data else '--' %} +{% set _scanned = _latest_run.get('scanned_count', _latest_run.get('total_products', '--')) if _latest_run is mapping else '--' %} +{% set _added = _latest_run.get('new_records', _latest_run.get('added', '--')) if _latest_run is mapping else '--' %} +{% set _run_status = _latest_run.get('status', '') if _has_scheduler_data else '' %} +{% set _status_label = '尚無紀錄' if not _has_scheduler_data else ('最近成功' if _run_status in ['Success', 'success', 'SUCCESS'] else (_run_status or '已有紀錄')) %} +{% set _next_run = next_run|default(None) %} +{% set _session_username = session.get('username') if session is defined else None %} +{% set _session_role = session.get('role') if session is defined else None %} +{% set _is_logged_in = session.get('logged_in') if session is defined else false %} + + + +
diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html new file mode 100644 index 0000000..02122d4 --- /dev/null +++ b/templates/dashboard_v2.html @@ -0,0 +1,700 @@ +{% extends 'ewoooc_base.html' %} + +{% block title %}EwoooC 商品看板{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block ewooo_content %} +
+
+ +
+
+
監控總數
+
{{ total_products | number_format }}
+
本週 +{{ week_new_products }}
+
+
+
今日變動
+
{{ active_count | number_format }}
+
活躍度 {{ activity_rate | round(1) }}%
+
+
+
漲價
+
{{ cnt_increase | number_format }}
+
平均 +${{ avg_increase | abs | int | number_format }}
+
+
+
降價
+
{{ cnt_decrease | number_format }}
+
平均 -${{ avg_decrease | abs | int | number_format }}
+
+
+
+ +
+ +
+
+
最活躍分類
+ {% if most_active_category %} +
{{ most_active_category }}
+
{{ most_active_count }} 件商品變動
+ {% else %} +
尚無分類變動
+
今日沒有可彙整的分類異動
+ {% endif %} +
+ +
+
最大變動
+ {% if max_change_item %} +
+ {% if max_change_value > 0 %}+{% else %}-{% endif %}${{ max_change_value | abs | int | number_format }} +
+
+ {{ max_change_item.record.product.name }} +
+ {% else %} +
尚無最大變動
+
今日沒有價格異動
+ {% endif %} +
+ +
+
爬蟲排程
+ {% set momo_stats_list = scheduler_stats.get('momo_task', []) %} + {% if momo_stats_list %} + {% set latest_run = momo_stats_list[0] %} +
{{ latest_run.last_run }}
+
+ 狀態 {{ latest_run.status | default('未標記') }} + {% if latest_run.scraped_count is defined %} · 掃描 {{ latest_run.scraped_count }} 筆{% endif %} + {% if latest_run.new_products is defined %} · 新增 +{{ latest_run.new_products }}{% endif %} +
+ {% else %} +
尚無排程紀錄
+
未讀到 scheduler_stats.json 的 momo_task 紀錄
+ {% endif %} +
+
+
+ +
+ +
+
+ + + + + + + + + + + + +
+
+
+ +
+
+
+ 04 + 商品列表 + {{ total_items | number_format }} 筆 +
+ + 匯出全部 + + + 匯出漲跌 + +
+ +
+ + + + + + + + + + + + + + {% for item in items %} + {% set product = item.record.product %} + {% set image_url = product.image_url or ('https://m.momoshop.com.tw/moscdn/goods/' ~ product.i_code ~ '_m.webp') %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
分類商品名稱 + 當天價格 + + 昨日漲跌 + + 週漲跌 + + 更新時間 + 上架時間
{{ product.category or '未分類' }} +
+ {{ product.name }} +
+ {{ product.name }} +
ID {{ product.i_code }}
+
+
+
+ ${{ item.record.price | int | number_format }} + + {% if item.yesterday_diff > 0 %} + ▲ +{{ item.yesterday_diff | abs | int | number_format }} + {% elif item.yesterday_diff < 0 %} + ▼ -{{ item.yesterday_diff | abs | int | number_format }} + {% else %} + -- + {% endif %} + + {% set week_diff = item.stats.get('7d_diff', 0) %} + {% if week_diff > 0 %} + +{{ week_diff | int | number_format }} + {% elif week_diff < 0 %} + -{{ week_diff | abs | int | number_format }} + {% else %} + -- + {% endif %} + + {{ item.record.timestamp.strftime('%m-%d %H:%M') if item.record.timestamp else '--' }} + + {{ item.safe_created_at.strftime('%m-%d %H:%M') if item.safe_created_at else '--' }} +
+
+ {% if search_query %} + 找不到與「{{ search_query }}」相關的商品 + {% else %} + 目前沒有符合條件的商品 + {% endif %} +
+
+
+ + {% if total_pages > 1 %} +
+ {% if current_page > 1 %} + 上一頁 + {% endif %} + 第 {{ current_page }} / {{ total_pages }} 頁 + {% if current_page < total_pages %} + 下一頁 + {% endif %} +
+ {% endif %} +
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/ewoooc_base.html b/templates/ewoooc_base.html new file mode 100644 index 0000000..8f9ca81 --- /dev/null +++ b/templates/ewoooc_base.html @@ -0,0 +1,109 @@ + + + + + + + {% block title %}EwoooC{% endblock %} + + + + + + + + + {% block extra_css %}{% endblock %} + + +
+ {% include 'components/_ewoooc_shell.html' %} + + {% set _next_run = next_run|default(None) %} + {% set _session_username = session.get('username') if session is defined else None %} + {% set _session_role = session.get('role') if session is defined else None %} + {% set _is_logged_in = session.get('logged_in') if session is defined else false %} + +
+
+ + + + +
+ + {% if _next_run %} +
+ + 下次排程 + {{ _next_run }} +
+ {% endif %} + + + + + {% if _is_logged_in %} + + {% endif %} +
+ +
+ {% block ewooo_content %}{% endblock %} +
+
+
+ + + + {% block extra_js %}{% endblock %} + + diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py new file mode 100644 index 0000000..bebafe0 --- /dev/null +++ b/tests/test_frontend_v2_assets.py @@ -0,0 +1,52 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_frontend_v2_shell_assets_exist_and_are_indexed(): + assert (ROOT / "web/static/css/ewoooc-tokens.css").exists() + assert (ROOT / "web/static/css/ewoooc-shell.css").exists() + assert (ROOT / "templates/ewoooc_base.html").exists() + assert (ROOT / "templates/components/_ewoooc_shell.html").exists() + + agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8") + constitution = (ROOT / "CONSTITUTION.md").read_text(encoding="utf-8") + roadmap = (ROOT / "docs/guides/frontend_upgrade_roadmap.md").read_text(encoding="utf-8") + + assert "docs/guides/frontend_upgrade_roadmap.md" in agents + assert "前端 V2 視覺基準" in constitution + assert "禁止用 mock data" in roadmap + + +def test_frontend_v2_shell_uses_real_runtime_context(): + shell = (ROOT / "templates/components/_ewoooc_shell.html").read_text(encoding="utf-8") + base = (ROOT / "templates/ewoooc_base.html").read_text(encoding="utf-8") + + assert "scheduler_stats" in shell + assert "session.get('username')" in shell + assert "next_run|default(None)" in shell + assert "components/_ewoooc_shell.html" in base + + forbidden_markers = [ + "mockProducts", + "mockData", + "fake", + "假商品", + "假 KPI", + ] + combined = shell + "\n" + base + assert all(marker not in combined for marker in forbidden_markers) + + +def test_dashboard_v2_is_feature_flagged_and_uses_real_dashboard_data(): + route_source = (ROOT / "routes/dashboard_routes.py").read_text(encoding="utf-8") + dashboard = (ROOT / "templates/dashboard_v2.html").read_text(encoding="utf-8") + + assert "request.args.get('ui') == 'v2'" in route_source + assert "template_name = 'dashboard_v2.html'" in route_source + assert "get_full_dashboard_data()" in route_source + assert "MockRecord" not in route_source + assert "{% for item in items %}" in dashboard + assert "mockProducts" not in dashboard + assert "假商品" not in dashboard diff --git a/web/static/css/ewoooc-shell.css b/web/static/css/ewoooc-shell.css new file mode 100644 index 0000000..3e38ab4 --- /dev/null +++ b/web/static/css/ewoooc-shell.css @@ -0,0 +1,537 @@ +body.momo-v2-body { + margin: 0; + min-height: 100vh; + background: var(--momo-bg-body); + color: var(--momo-text-primary); +} + +.momo-shell { + display: grid; + grid-template-columns: var(--momo-sidebar-width) minmax(0, 1fr); + min-height: 100vh; + background: var(--momo-bg-body); +} + +.momo-sidebar { + position: sticky; + top: 0; + height: 100vh; + z-index: 20; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--momo-bg-paper); + border-right: 1px solid var(--momo-border-light); +} + +.momo-sidebar-logo { + display: flex; + align-items: center; + gap: 10px; + height: var(--momo-topbar-height); + padding: 0 20px; + color: var(--momo-text-primary); + text-decoration: none; + border-bottom: 1px solid var(--momo-border-light); +} + +.momo-logo-mark { + display: grid; + flex: 0 0 auto; + width: 32px; + height: 32px; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: 1.5px; + padding: 5px; + color: var(--momo-text-inverse); + background: var(--momo-ink); + border-radius: 2px; +} + +.momo-logo-mark span { + border-radius: 50%; + background: currentColor; +} + +.momo-logo-mark span:nth-child(5) { + background: transparent; +} + +.momo-brand-word { + display: flex; + flex-direction: column; + line-height: 1.05; +} + +.momo-brand-name { + color: var(--momo-text-primary); + font-size: 18px; + font-weight: 800; + letter-spacing: -0.02em; +} + +.momo-brand-subtitle { + margin-top: 3px; + color: var(--momo-text-secondary); +} + +.momo-nav { + flex: 1; + overflow-y: auto; + padding: 12px 8px; +} + +.momo-nav-group { + margin-bottom: 4px; +} + +.momo-nav-group-title { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 12px 8px; + color: var(--momo-text-tertiary); +} + +.momo-nav-group-title::after { + content: ""; + flex: 1; + height: 1px; + background: var(--momo-border-light); +} + +.momo-nav-link { + display: flex; + align-items: center; + gap: 12px; + min-height: 38px; + margin-bottom: 2px; + padding: 9px 12px; + border: 1px solid transparent; + border-radius: var(--momo-radius-md); + color: var(--momo-text-primary); + font-size: var(--momo-font-size-sm); + font-weight: var(--momo-font-weight-medium); + text-decoration: none; + transition: var(--momo-transition-base), transform var(--momo-duration-fast) var(--momo-ease-out); +} + +.momo-nav-link:hover { + color: var(--momo-text-primary); + background: var(--momo-accent-soft); +} + +.momo-nav-link.is-active { + color: var(--momo-text-inverse); + background: var(--momo-accent); + font-weight: var(--momo-font-weight-semibold); +} + +.momo-nav-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + color: inherit; +} + +.momo-nav-label { + flex: 1; + min-width: 0; +} + +.momo-nav-code { + color: currentColor; + font-size: 10px; + font-weight: 700; + opacity: 0.48; +} + +.momo-nav-link.is-active .momo-nav-code { + opacity: 0.8; +} + +.momo-nav-badge { + min-width: 20px; + padding: 1px 6px; + border-radius: 2px; + color: var(--momo-text-inverse); + background: var(--momo-accent); + font-size: 10px; + font-weight: 800; + text-align: center; +} + +.momo-status-card { + position: relative; + margin: 12px; + padding: 14px; + overflow: hidden; + color: var(--momo-text-inverse); + background: var(--momo-ink-strong); + border: 1px solid rgba(201, 100, 66, 0.35); + border-radius: var(--momo-radius-md); +} + +.momo-status-card::before { + position: absolute; + inset: 0; + content: ""; + background-image: radial-gradient(circle, rgba(201, 100, 66, 0.12) 1px, transparent 1px); + background-size: 6px 6px; +} + +.momo-status-card > * { + position: relative; +} + +.momo-status-title { + margin-bottom: 8px; + color: rgba(250, 247, 240, 0.55); +} + +.momo-status-active { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 10px; + font-size: 11px; + font-weight: 700; +} + +.momo-live-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--momo-accent); + box-shadow: 0 0 8px var(--momo-accent); + animation: momo-pulse-dot 2s infinite; +} + +.momo-status-meta { + color: rgba(250, 247, 240, 0.62); + font-size: 10px; + line-height: 1.7; +} + +.momo-main-shell { + min-width: 0; + min-height: 100vh; +} + +.momo-topbar { + position: sticky; + top: 0; + z-index: 15; + display: flex; + align-items: center; + gap: 16px; + height: var(--momo-topbar-height); + padding: 0 24px; + background: var(--momo-bg-surface); + border-bottom: 1px solid var(--momo-border-light); +} + +.momo-mobile-menu-button { + display: none; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 0; + border-radius: var(--momo-radius-md); + color: var(--momo-text-secondary); + background: transparent; + transition: var(--momo-transition-base); +} + +.momo-mobile-menu-button:hover { + background: var(--momo-bg-subtle); +} + +.momo-search-box { + display: flex; + flex: 1 1 320px; + align-items: center; + max-width: 480px; + min-width: 0; + height: 38px; + padding: 0 12px; + overflow: hidden; + color: var(--momo-text-secondary); + background: var(--momo-bg-paper); + border: 1px solid var(--momo-border); + border-radius: var(--momo-radius-md); + font-size: var(--momo-font-size-sm); + transition: var(--momo-transition-base); +} + +.momo-search-box:hover { + background: var(--momo-bg-elevated); +} + +.momo-search-box i { + margin-right: 10px; +} + +.momo-search-input { + flex: 1; + min-width: 0; + padding: 0; + overflow: hidden; + color: var(--momo-text-primary); + background: transparent; + border: 0; + outline: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.momo-search-input::placeholder { + color: var(--momo-text-secondary); + opacity: 1; +} + +.momo-shortcut { + padding: 2px 6px; + color: var(--momo-text-inverse); + background: var(--momo-accent); + border-radius: 2px; + font-size: 10px; + font-weight: 700; +} + +.momo-topbar-spacer { + flex: 1; +} + +.momo-topbar-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + color: var(--momo-text-inverse); + background: var(--momo-ink-strong); + border: 1px solid rgba(201, 100, 66, 0.35); + border-radius: var(--momo-radius-md); + font-size: 11px; +} + +.momo-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 0; + border-radius: var(--momo-radius-md); + color: var(--momo-text-secondary); + background: transparent; + transition: var(--momo-transition-base); +} + +.momo-icon-button:hover { + color: var(--momo-text-primary); + background: var(--momo-bg-subtle); +} + +.momo-user-chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + height: 40px; + padding: 4px 10px 4px 4px; + border: 0; + border-radius: var(--momo-radius-pill); + color: var(--momo-text-primary); + background: transparent; + transition: var(--momo-transition-base); +} + +.momo-user-chip:hover { + background: var(--momo-bg-subtle); +} + +.momo-avatar { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + color: var(--momo-text-inverse); + background: var(--momo-ink); + border-radius: var(--momo-radius-circle); + font-size: 13px; + font-weight: 800; +} + +.momo-user-meta { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; + line-height: 1.2; +} + +.momo-user-name { + max-width: 96px; + overflow: hidden; + color: var(--momo-text-primary); + font-size: 13px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.momo-user-role { + color: var(--momo-text-tertiary); + font-size: 11px; +} + +.momo-content { + min-width: 0; + padding: 28px 32px 40px; +} + +.momo-page-title { + margin: 0; + color: var(--momo-text-primary); + font-size: var(--momo-font-size-xl); + font-weight: 800; + letter-spacing: 0; + line-height: var(--momo-line-height-tight); +} + +.momo-page-subtitle { + margin-top: 6px; + color: var(--momo-text-secondary); + font-size: var(--momo-font-size-sm); +} + +.momo-shell-backdrop { + display: none; +} + +@media (max-width: 1180px) { + .momo-shell { + grid-template-columns: var(--momo-sidebar-collapsed-width) minmax(0, 1fr); + } + + .momo-sidebar-logo { + justify-content: center; + padding: 0; + } + + .momo-brand-word, + .momo-nav-group-title, + .momo-nav-label, + .momo-nav-code, + .momo-status-card { + display: none; + } + + .momo-nav-link { + justify-content: center; + padding: 10px; + } + + .momo-nav-badge { + position: absolute; + width: 8px; + min-width: 8px; + height: 8px; + padding: 0; + overflow: hidden; + color: transparent; + border: 2px solid var(--momo-bg-paper); + border-radius: 50%; + transform: translate(12px, -10px); + } +} + +@media (max-width: 820px) { + .momo-shell { + display: block; + } + + .momo-sidebar { + position: fixed; + left: 0; + top: 0; + width: var(--momo-sidebar-width); + transform: translateX(-100%); + transition: transform var(--momo-duration-normal) var(--momo-ease-in-out); + } + + .momo-shell.is-sidebar-open .momo-sidebar { + transform: translateX(0); + } + + .momo-shell-backdrop { + position: fixed; + inset: 0; + z-index: 18; + display: none; + background: var(--momo-bg-backdrop); + } + + .momo-shell.is-sidebar-open .momo-shell-backdrop { + display: block; + } + + .momo-brand-word, + .momo-nav-group-title, + .momo-nav-label, + .momo-nav-code, + .momo-status-card { + display: flex; + } + + .momo-status-card { + display: block; + } + + .momo-nav-link { + justify-content: flex-start; + padding: 9px 12px; + } + + .momo-nav-badge { + position: static; + width: auto; + min-width: 20px; + height: auto; + padding: 1px 6px; + overflow: visible; + color: var(--momo-text-inverse); + border: 0; + border-radius: 2px; + transform: none; + } + + .momo-topbar { + padding: 0 14px; + } + + .momo-mobile-menu-button { + display: inline-flex; + } + + .momo-search-box { + flex-basis: 160px; + } + + .momo-shortcut, + .momo-topbar-pill, + .momo-user-meta { + display: none; + } + + .momo-content { + padding: 20px 16px 32px; + } +} diff --git a/web/static/css/ewoooc-tokens.css b/web/static/css/ewoooc-tokens.css new file mode 100644 index 0000000..8fa1f98 --- /dev/null +++ b/web/static/css/ewoooc-tokens.css @@ -0,0 +1,193 @@ +/** + * EwoooC Frontend V2 tokens. + * Source of truth: MOMO Pro/design-tokens.css. + */ + +:root { + --momo-bg-body: #ebe6dc; + --momo-bg-surface: #faf7f0; + --momo-bg-elevated: #fdfaf3; + --momo-bg-subtle: #e2dccf; + --momo-bg-muted: #cfc7b5; + --momo-bg-paper: #f3eee2; + + --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); + + --momo-accent: #c96442; + --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); + + --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-500: var(--momo-accent-500); + --momo-primary-600: var(--momo-accent-600); + --momo-primary-700: var(--momo-accent-700); + + --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); + --momo-bg-overlay: rgba(26, 26, 26, 0.70); + --momo-bg-backdrop: rgba(26, 26, 26, 0.30); + + --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; + + --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; + + --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-radius-sm: 0.125rem; + --momo-radius-md: 0.25rem; + --momo-radius-lg: 0.375rem; + --momo-radius-pill: 50rem; + --momo-radius-circle: 50%; + + --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-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); + + --momo-sidebar-width: 240px; + --momo-sidebar-collapsed-width: 72px; + --momo-topbar-height: 64px; +} + +.momo-app, +.momo-app * { + box-sizing: border-box; +} + +.momo-app { + min-height: 100vh; + background: var(--momo-bg-body); + color: var(--momo-text-primary); + font-family: var(--momo-font-family); + font-size: var(--momo-font-size-base); + line-height: var(--momo-line-height-base); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.momo-display { + font-family: var(--momo-font-display); + font-feature-settings: "tnum", "ss01"; +} + +.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: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.momo-dot-bg { + 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-pulse-dot { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.35; + } +}