feat(frontend): 新增 V2 dashboard feature flag

This commit is contained in:
OoO
2026-04-30 23:45:49 +08:00
parent 13f17d62e7
commit 0e18ff304c
13 changed files with 1713 additions and 8 deletions

2
.gitignore vendored
View File

@@ -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/

View File

@@ -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 冒充已完成。
- ❌ **禁止**: 為了符合原型畫面而改寫或捏造業務數字。
---
## 第五章:系統架構規範

4
app.py
View File

@@ -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 防護函數

View File

@@ -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 # 用於模板顯示

View File

@@ -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。
完成後再決定是否將 `/` 切到新版。

View File

@@ -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~29Phase 3e 重構大戰 + daily_sales cache 隱形 bug 根除
- **app.py 縮減 -10.8%**: 7,386 → 6,590 行11 commits 全綠零 502。

View File

@@ -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,

View File

@@ -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 %}
<aside class="momo-sidebar" aria-label="主選單">
<a class="momo-sidebar-logo" href="/">
<span class="momo-logo-mark" aria-hidden="true">
<span></span><span></span><span></span>
<span></span><span></span><span></span>
<span></span><span></span><span></span>
</span>
<span class="momo-brand-word">
<span class="momo-brand-name momo-display">EwoooC</span>
<span class="momo-brand-subtitle momo-label">價格監控 V2</span>
</span>
</a>
<nav class="momo-nav momo-scroll">
<div class="momo-nav-group">
<div class="momo-nav-group-title momo-label">監控</div>
<a class="momo-nav-link {% if _active_page == 'dashboard' %}is-active{% endif %}" href="/">
<span class="momo-nav-icon"><i class="fas fa-border-all"></i></span>
<span class="momo-nav-label">商品看板</span>
<span class="momo-nav-code momo-mono">01</span>
</a>
<a class="momo-nav-link {% if _active_page in ['edm', 'campaigns'] %}is-active{% endif %}" href="/edm">
<span class="momo-nav-icon"><i class="fas fa-bullhorn"></i></span>
<span class="momo-nav-label">活動看板</span>
<span class="momo-nav-code momo-mono">02</span>
</a>
<a class="momo-nav-link {% if _active_page in ['sales', 'daily_sales', 'monthly', 'growth'] %}is-active{% endif %}" href="/sales_analysis">
<span class="momo-nav-icon"><i class="fas fa-chart-line"></i></span>
<span class="momo-nav-label">分析報表</span>
<span class="momo-nav-code momo-mono">03</span>
</a>
</div>
<div class="momo-nav-group">
<div class="momo-nav-group-title momo-label">營運</div>
<a class="momo-nav-link {% if _active_page == 'vendor_stockout' %}is-active{% endif %}" href="/vendor-stockout">
<span class="momo-nav-icon"><i class="fas fa-box-open"></i></span>
<span class="momo-nav-label">廠商缺貨</span>
<span class="momo-nav-code momo-mono">04</span>
</a>
<a class="momo-nav-link {% if _active_page in ['ai_recommend', 'ai_history', 'ai_intelligence'] %}is-active{% endif %}" href="/ai_recommend">
<span class="momo-nav-icon"><i class="fas fa-wand-magic-sparkles"></i></span>
<span class="momo-nav-label">AI 助手</span>
<span class="momo-nav-code momo-mono">05</span>
</a>
<a class="momo-nav-link {% if _active_page == 'auto_import' %}is-active{% endif %}" href="/auto_import">
<span class="momo-nav-icon"><i class="fas fa-download"></i></span>
<span class="momo-nav-label">雲端匯入</span>
<span class="momo-nav-code momo-mono">06</span>
</a>
</div>
<div class="momo-nav-group">
<div class="momo-nav-group-title momo-label">系統</div>
<a class="momo-nav-link {% if _active_page in ['settings', 'system_settings', 'logs', 'crawler', 'user_management', 'ai_automation_smoke'] %}is-active{% endif %}" href="/settings">
<span class="momo-nav-icon"><i class="fas fa-gear"></i></span>
<span class="momo-nav-label">系統管理</span>
<span class="momo-nav-code momo-mono">07</span>
</a>
</div>
</nav>
<div class="momo-status-card">
<div class="momo-status-title momo-label">爬蟲狀態</div>
<div class="momo-status-active momo-mono">
<span class="momo-live-dot"></span>
<span>{{ _status_label }}</span>
</div>
<div class="momo-status-meta momo-mono">
上次執行 {{ _last_run }}<br>
掃描筆數 {{ _scanned }}<br>
新增筆數 {% if _added == '--' %}--{% else %}+{{ _added }}{% endif %}
</div>
</div>
</aside>
<div class="momo-shell-backdrop" data-momo-sidebar-close></div>

700
templates/dashboard_v2.html Normal file
View File

@@ -0,0 +1,700 @@
{% extends 'ewoooc_base.html' %}
{% block title %}EwoooC 商品看板{% endblock %}
{% block extra_css %}
<style>
.dashboard-v2-stack {
display: grid;
gap: 24px;
}
.dashboard-section-label {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 12px;
}
.dashboard-section-label .num {
color: var(--momo-text-tertiary);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
}
.dashboard-section-label .title {
color: var(--momo-text-primary);
font-size: 13px;
font-weight: 800;
letter-spacing: 0;
}
.dashboard-section-label .meta {
margin-left: auto;
color: var(--momo-text-tertiary);
font-size: 10px;
}
.dashboard-kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: 8px;
}
.dashboard-kpi {
min-width: 0;
padding: 20px 24px;
border-right: 1px solid var(--momo-border-light);
}
.dashboard-kpi:last-child {
border-right: 0;
}
.dashboard-kpi.is-accent {
color: var(--momo-text-inverse);
background: var(--momo-ink);
}
.dashboard-kpi-label {
margin-bottom: 10px;
color: var(--momo-text-tertiary);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.10em;
text-transform: uppercase;
}
.dashboard-kpi.is-accent .dashboard-kpi-label,
.dashboard-kpi.is-accent .dashboard-kpi-sub {
color: rgba(250, 247, 240, 0.68);
}
.dashboard-kpi-value {
margin-bottom: 8px;
color: var(--momo-text-primary);
font-size: 44px;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1;
}
.dashboard-kpi-value.is-danger {
color: var(--momo-danger);
}
.dashboard-kpi-value.is-success {
color: var(--momo-success);
}
.dashboard-kpi.is-accent .dashboard-kpi-value {
color: var(--momo-text-inverse);
}
.dashboard-kpi-sub {
color: var(--momo-text-secondary);
font-size: 11px;
}
.dashboard-focus-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.dashboard-focus-card,
.dashboard-filter-card,
.dashboard-table-card {
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: 8px;
}
.dashboard-focus-card {
min-width: 0;
padding: 18px;
}
.dashboard-focus-label {
margin-bottom: 8px;
color: var(--momo-text-tertiary);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.10em;
text-transform: uppercase;
}
.dashboard-focus-title {
margin-bottom: 4px;
color: var(--momo-text-primary);
font-size: 16px;
font-weight: 800;
line-height: 1.35;
}
.dashboard-focus-number {
margin-bottom: 6px;
color: var(--momo-danger);
font-size: 24px;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1;
}
.dashboard-focus-sub {
color: var(--momo-text-secondary);
font-size: 11px;
}
.dashboard-filter-card {
padding: 12px 16px;
}
.dashboard-filter-form {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.dashboard-search,
.dashboard-select {
min-height: 34px;
color: var(--momo-text-primary);
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border);
border-radius: 4px;
font-size: 12px;
}
.dashboard-search {
width: min(320px, 100%);
padding: 7px 12px;
}
.dashboard-select {
min-width: 160px;
padding: 7px 12px;
}
.dashboard-segmented {
display: inline-flex;
padding: 2px;
gap: 0;
background: var(--momo-bg-paper);
border: 1px solid var(--momo-border-light);
border-radius: 4px;
}
.dashboard-segmented a {
padding: 5px 12px;
color: var(--momo-text-secondary);
border-radius: 3px;
font-size: 12px;
font-weight: 800;
text-decoration: none;
transition: var(--momo-transition-base);
}
.dashboard-segmented a:hover {
color: var(--momo-text-primary);
background: var(--momo-bg-subtle);
}
.dashboard-segmented a.is-active {
color: var(--momo-text-inverse);
background: var(--momo-ink);
}
.dashboard-action-link,
.dashboard-action-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 30px;
padding: 6px 12px;
color: var(--momo-text-primary);
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border);
border-radius: 4px;
font-size: 12px;
font-weight: 800;
text-decoration: none;
transition: var(--momo-transition-base);
}
.dashboard-action-button.is-primary {
color: var(--momo-text-inverse);
background: var(--momo-ink);
border-color: var(--momo-ink);
}
.dashboard-action-link:hover,
.dashboard-action-button:hover {
color: var(--momo-text-primary);
background: var(--momo-bg-subtle);
}
.dashboard-action-button.is-primary:hover {
color: var(--momo-text-inverse);
background: var(--momo-ink-soft);
}
.dashboard-table-head {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid var(--momo-border-light);
flex-wrap: wrap;
}
.dashboard-table-title {
color: var(--momo-text-primary);
font-size: 14px;
font-weight: 800;
}
.dashboard-table-meta {
color: var(--momo-text-secondary);
font-size: 11px;
}
.dashboard-table-wrap {
overflow-x: auto;
}
.dashboard-table {
width: 100%;
min-width: 980px;
border-collapse: collapse;
font-size: var(--momo-font-size-sm);
}
.dashboard-table th {
padding: 11px 14px;
color: var(--momo-text-tertiary);
background: var(--momo-bg-paper);
border-bottom: 1px solid var(--momo-border-light);
font-family: var(--momo-font-family-mono);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.10em;
text-transform: uppercase;
white-space: nowrap;
}
.dashboard-table th a {
color: inherit;
text-decoration: none;
}
.dashboard-table td {
padding: 14px;
border-bottom: 1px solid var(--momo-border-light);
vertical-align: middle;
}
.dashboard-table tbody tr {
transition: var(--momo-transition-base);
}
.dashboard-table tbody tr:hover {
background: var(--momo-bg-paper);
}
.dashboard-category {
display: inline-flex;
max-width: 120px;
padding: 3px 8px;
overflow: hidden;
color: var(--momo-text-primary);
background: var(--momo-bg-subtle);
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-pill);
font-size: 11px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-product-cell {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.dashboard-product-thumb {
width: 52px;
height: 52px;
flex: 0 0 auto;
object-fit: cover;
background: var(--momo-bg-paper);
border: 1px solid var(--momo-border-light);
border-radius: 6px;
}
.dashboard-product-name {
display: -webkit-box;
overflow: hidden;
color: var(--momo-text-primary);
font-weight: 800;
line-height: 1.35;
text-decoration: none;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.dashboard-product-name:hover {
color: var(--momo-accent);
}
.dashboard-product-id {
margin-top: 4px;
color: var(--momo-text-tertiary);
font-size: 11px;
}
.dashboard-price {
color: var(--momo-text-primary);
font-size: 16px;
font-weight: 800;
}
.dashboard-change-up {
color: var(--momo-danger);
font-weight: 800;
}
.dashboard-change-down {
color: var(--momo-success);
font-weight: 800;
}
.dashboard-empty {
padding: 48px 16px;
color: var(--momo-text-secondary);
text-align: center;
}
.dashboard-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px 20px;
}
@media (max-width: 980px) {
.dashboard-kpi-grid,
.dashboard-focus-grid {
grid-template-columns: 1fr 1fr;
}
.dashboard-kpi:nth-child(2) {
border-right: 0;
}
}
@media (max-width: 640px) {
.dashboard-kpi-grid,
.dashboard-focus-grid {
grid-template-columns: 1fr;
}
.dashboard-kpi {
border-right: 0;
border-bottom: 1px solid var(--momo-border-light);
}
.dashboard-kpi:last-child {
border-bottom: 0;
}
.dashboard-search,
.dashboard-select,
.dashboard-segmented {
width: 100%;
}
.dashboard-segmented {
overflow-x: auto;
}
}
</style>
{% endblock %}
{% block ewooo_content %}
<div class="dashboard-v2-stack">
<section>
<div class="dashboard-section-label">
<span class="num momo-mono">01</span>
<span class="title">監控總覽</span>
<span class="meta momo-mono">LIVE · 更新於 {{ datetime_now }}</span>
</div>
<div class="dashboard-kpi-grid">
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">監控總數</div>
<div class="dashboard-kpi-value momo-mono">{{ total_products | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">本週 +{{ week_new_products }}</div>
</div>
<div class="dashboard-kpi is-accent">
<div class="dashboard-kpi-label momo-mono">今日變動</div>
<div class="dashboard-kpi-value momo-mono">{{ active_count | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">活躍度 {{ activity_rate | round(1) }}%</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">漲價</div>
<div class="dashboard-kpi-value momo-mono is-danger">{{ cnt_increase | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">平均 +${{ avg_increase | abs | int | number_format }}</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">降價</div>
<div class="dashboard-kpi-value momo-mono is-success">{{ cnt_decrease | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">平均 -${{ avg_decrease | abs | int | number_format }}</div>
</div>
</div>
</section>
<section>
<div class="dashboard-section-label">
<span class="num momo-mono">02</span>
<span class="title">焦點數據</span>
<span class="meta momo-mono">{{ today_date }}</span>
</div>
<div class="dashboard-focus-grid">
<div class="dashboard-focus-card">
<div class="dashboard-focus-label momo-mono">最活躍分類</div>
{% if most_active_category %}
<div class="dashboard-focus-title">{{ most_active_category }}</div>
<div class="dashboard-focus-sub momo-mono">{{ most_active_count }} 件商品變動</div>
{% else %}
<div class="dashboard-focus-title">尚無分類變動</div>
<div class="dashboard-focus-sub momo-mono">今日沒有可彙整的分類異動</div>
{% endif %}
</div>
<div class="dashboard-focus-card">
<div class="dashboard-focus-label momo-mono">最大變動</div>
{% if max_change_item %}
<div class="dashboard-focus-number momo-mono">
{% if max_change_value > 0 %}+{% else %}-{% endif %}${{ max_change_value | abs | int | number_format }}
</div>
<div class="dashboard-focus-sub" title="{{ max_change_item.record.product.name }}">
{{ max_change_item.record.product.name }}
</div>
{% else %}
<div class="dashboard-focus-title">尚無最大變動</div>
<div class="dashboard-focus-sub momo-mono">今日沒有價格異動</div>
{% endif %}
</div>
<div class="dashboard-focus-card">
<div class="dashboard-focus-label momo-mono">爬蟲排程</div>
{% set momo_stats_list = scheduler_stats.get('momo_task', []) %}
{% if momo_stats_list %}
{% set latest_run = momo_stats_list[0] %}
<div class="dashboard-focus-title momo-mono">{{ latest_run.last_run }}</div>
<div class="dashboard-focus-sub momo-mono">
狀態 {{ 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 %}
</div>
{% else %}
<div class="dashboard-focus-title">尚無排程紀錄</div>
<div class="dashboard-focus-sub momo-mono">未讀到 scheduler_stats.json 的 momo_task 紀錄</div>
{% endif %}
</div>
</div>
</section>
<section>
<div class="dashboard-section-label">
<span class="num momo-mono">03</span>
<span class="title">篩選</span>
</div>
<div class="dashboard-filter-card">
<form class="dashboard-filter-form" method="GET" action="/">
<input type="hidden" name="ui" value="v2">
<input class="dashboard-search" type="text" name="q" value="{{ search_query }}" placeholder="搜尋商品名稱或品號...">
<select class="dashboard-select" name="category" onchange="this.form.submit()">
<option value="all">所有分類</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if current_category == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
<input type="hidden" name="filter" value="{{ current_filter }}">
<input type="hidden" name="sort_by" value="{{ current_sort }}">
<input type="hidden" name="order" value="{{ current_order }}">
<button class="dashboard-action-button" type="submit">
<i class="fas fa-search"></i> 搜尋
</button>
<div class="dashboard-segmented">
<a class="{% if current_filter == 'all' %}is-active{% endif %}" href="{{ url_for('dashboard.index', ui='v2', filter='all', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">全部</a>
<a class="{% if current_filter == 'new' %}is-active{% endif %}" href="{{ url_for('dashboard.index', ui='v2', filter='new', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">新上架</a>
<a class="{% if current_filter == 'increase' %}is-active{% endif %}" href="{{ url_for('dashboard.index', ui='v2', filter='increase', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">漲價</a>
<a class="{% if current_filter == 'decrease' %}is-active{% endif %}" href="{{ url_for('dashboard.index', ui='v2', filter='decrease', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">降價</a>
<a class="{% if current_filter == 'delisted' %}is-active{% endif %}" href="{{ url_for('dashboard.index', ui='v2', filter='delisted', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">下架</a>
</div>
<button class="dashboard-action-button" type="button" onclick="triggerTask()">
<i class="fas fa-rotate"></i> 更新
</button>
<button class="dashboard-action-button is-primary" type="button" onclick="triggerNotification()">
<i class="fas fa-bell"></i> 發送通知
</button>
</form>
</div>
</section>
<section>
<div class="dashboard-table-card">
<div class="dashboard-table-head">
<span class="momo-mono" style="font-size:11px;font-weight:800;color:var(--momo-text-tertiary);letter-spacing:.08em;">04</span>
<span class="dashboard-table-title">商品列表</span>
<span class="dashboard-table-meta momo-mono">{{ total_items | number_format }} 筆</span>
<div class="momo-topbar-spacer"></div>
<a class="dashboard-action-link" href="/api/export/excel/all">
<i class="fas fa-download"></i> 匯出全部
</a>
<a class="dashboard-action-link" href="/api/export/excel/changes">
<i class="fas fa-arrow-trend-up"></i> 匯出漲跌
</a>
</div>
<div class="dashboard-table-wrap">
<table class="dashboard-table">
<thead>
<tr>
<th>分類</th>
<th>商品名稱</th>
<th class="text-end">
<a href="{{ url_for('dashboard.index', ui='v2', page=1, sort_by='price', order='asc' if current_sort == 'price' and current_order == 'desc' else 'desc', category=current_category, filter=current_filter, q=search_query) }}">當天價格</a>
</th>
<th class="text-end">
<a href="{{ url_for('dashboard.index', ui='v2', page=1, sort_by='yesterday_change', order='asc' if current_sort == 'yesterday_change' and current_order == 'desc' else 'desc', category=current_category, filter=current_filter, q=search_query) }}">昨日漲跌</a>
</th>
<th class="text-end">
<a href="{{ url_for('dashboard.index', ui='v2', page=1, sort_by='week_change', order='asc' if current_sort == 'week_change' and current_order == 'desc' else 'desc', category=current_category, filter=current_filter, q=search_query) }}">週漲跌</a>
</th>
<th class="text-end">
<a href="{{ url_for('dashboard.index', ui='v2', page=1, sort_by='timestamp', order='asc' if current_sort == 'timestamp' and current_order == 'desc' else 'desc', category=current_category, filter=current_filter, q=search_query) }}">更新時間</a>
</th>
<th class="text-end">上架時間</th>
</tr>
</thead>
<tbody>
{% 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') %}
<tr>
<td><span class="dashboard-category">{{ product.category or '未分類' }}</span></td>
<td>
<div class="dashboard-product-cell">
<img class="dashboard-product-thumb" src="{{ image_url }}" alt="{{ product.name }}" loading="lazy" referrerpolicy="no-referrer">
<div>
<a class="dashboard-product-name" href="{{ product.url }}" target="_blank" rel="noopener noreferrer">{{ product.name }}</a>
<div class="dashboard-product-id momo-mono">ID {{ product.i_code }}</div>
</div>
</div>
</td>
<td class="text-end">
<span class="dashboard-price momo-mono">${{ item.record.price | int | number_format }}</span>
</td>
<td class="text-end momo-mono">
{% if item.yesterday_diff > 0 %}
<span class="dashboard-change-up">▲ +{{ item.yesterday_diff | abs | int | number_format }}</span>
{% elif item.yesterday_diff < 0 %}
<span class="dashboard-change-down">▼ -{{ item.yesterday_diff | abs | int | number_format }}</span>
{% else %}
<span style="color:var(--momo-text-tertiary);">--</span>
{% endif %}
</td>
<td class="text-end momo-mono">
{% set week_diff = item.stats.get('7d_diff', 0) %}
{% if week_diff > 0 %}
<span class="dashboard-change-up">+{{ week_diff | int | number_format }}</span>
{% elif week_diff < 0 %}
<span class="dashboard-change-down">-{{ week_diff | abs | int | number_format }}</span>
{% else %}
<span style="color:var(--momo-text-tertiary);">--</span>
{% endif %}
</td>
<td class="text-end momo-mono" style="color:var(--momo-text-secondary);">
{{ item.record.timestamp.strftime('%m-%d %H:%M') if item.record.timestamp else '--' }}
</td>
<td class="text-end momo-mono" style="color:var(--momo-text-secondary);">
{{ item.safe_created_at.strftime('%m-%d %H:%M') if item.safe_created_at else '--' }}
</td>
</tr>
{% else %}
<tr>
<td colspan="7">
<div class="dashboard-empty">
{% if search_query %}
找不到與「{{ search_query }}」相關的商品
{% else %}
目前沒有符合條件的商品
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="dashboard-pagination">
{% if current_page > 1 %}
<a class="dashboard-action-link" href="{{ url_for('dashboard.index', ui='v2', page=current_page - 1, category=current_category, filter=current_filter, q=search_query, sort_by=current_sort, order=current_order) }}">上一頁</a>
{% endif %}
<span class="dashboard-table-meta momo-mono">第 {{ current_page }} / {{ total_pages }} 頁</span>
{% if current_page < total_pages %}
<a class="dashboard-action-link" href="{{ url_for('dashboard.index', ui='v2', page=current_page + 1, category=current_category, filter=current_filter, q=search_query, sort_by=current_sort, order=current_order) }}">下一頁</a>
{% endif %}
</div>
{% endif %}
</div>
</section>
</div>
{% endblock %}
{% block extra_js %}
<script>
function getCSRFToken() {
return document.querySelector('meta[name="csrf-token"]').getAttribute('content');
}
function triggerTask() {
if (confirm('確定要手動執行全站爬蟲嗎?可能需要一段時間。')) {
fetch('/api/run_task', {
method: 'POST',
headers: { 'X-CSRFToken': getCSRFToken() }
})
.then(response => response.json())
.then(data => alert(data.message))
.catch(error => alert('錯誤: ' + error));
}
}
function triggerNotification() {
if (confirm('確定要發送今日商品異動通知嗎?')) {
fetch('/api/trigger_momo_notification', {
method: 'POST',
headers: { 'X-CSRFToken': getCSRFToken() }
})
.then(response => response.json())
.then(data => alert(data.message))
.catch(error => alert('錯誤: ' + error));
}
}
</script>
{% endblock %}

109
templates/ewoooc_base.html Normal file
View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}EwoooC{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700;800&family=Noto+Sans+TC:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ewoooc-tokens.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ewoooc-shell.css') }}">
{% block extra_css %}{% endblock %}
</head>
<body class="momo-v2-body">
<div class="momo-app momo-shell" id="momo-shell">
{% 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 %}
<section class="momo-main-shell">
<header class="momo-topbar">
<button class="momo-mobile-menu-button" type="button" data-momo-sidebar-toggle aria-label="開啟主選單">
<i class="fas fa-bars"></i>
</button>
<form class="momo-search-box" role="search" method="GET" action="/">
<input type="hidden" name="ui" value="v2">
<i class="fas fa-search" aria-hidden="true"></i>
<input class="momo-search-input momo-mono" type="search" name="q" value="{{ search_query|default('') }}" placeholder="搜尋商品名稱、編號、品牌...">
<kbd class="momo-shortcut momo-mono">⌘K</kbd>
</form>
<div class="momo-topbar-spacer"></div>
{% if _next_run %}
<div class="momo-topbar-pill momo-mono">
<span class="momo-live-dot"></span>
<span>下次排程</span>
<strong>{{ _next_run }}</strong>
</div>
{% endif %}
<button class="momo-icon-button" type="button" title="說明">
<i class="fas fa-circle-question"></i>
</button>
<button class="momo-icon-button" type="button" title="通知">
<i class="fas fa-bell"></i>
</button>
{% if _is_logged_in %}
<button class="momo-user-chip" type="button">
<span class="momo-avatar">{{ (_session_username or '已')[:1] }}</span>
<span class="momo-user-meta">
<span class="momo-user-name">{{ _session_username or '已登入' }}</span>
{% if _session_role %}
<span class="momo-user-role">{{ _session_role }}</span>
{% endif %}
</span>
<i class="fas fa-chevron-down" aria-hidden="true"></i>
</button>
{% endif %}
</header>
<main class="momo-content">
{% block ewooo_content %}{% endblock %}
</main>
</section>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const fetchWithCSRF = (url, options = {}) => {
options.headers = {
...options.headers,
'X-CSRFToken': csrfToken
};
return fetch(url, options);
};
(function () {
const shell = document.getElementById('momo-shell');
const toggle = document.querySelector('[data-momo-sidebar-toggle]');
const close = document.querySelector('[data-momo-sidebar-close]');
if (!shell || !toggle) return;
toggle.addEventListener('click', function () {
shell.classList.toggle('is-sidebar-open');
});
if (close) {
close.addEventListener('click', function () {
shell.classList.remove('is-sidebar-open');
});
}
})();
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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;
}
}