feat(frontend): 新增 V2 dashboard feature flag
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
4
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 防護函數
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
完成後再決定是否將 `/` 切到新版。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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,
|
||||
|
||||
101
templates/components/_ewoooc_shell.html
Normal file
101
templates/components/_ewoooc_shell.html
Normal 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
700
templates/dashboard_v2.html
Normal 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
109
templates/ewoooc_base.html
Normal 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>
|
||||
52
tests/test_frontend_v2_assets.py
Normal file
52
tests/test_frontend_v2_assets.py
Normal 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
|
||||
537
web/static/css/ewoooc-shell.css
Normal file
537
web/static/css/ewoooc-shell.css
Normal 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;
|
||||
}
|
||||
}
|
||||
193
web/static/css/ewoooc-tokens.css
Normal file
193
web/static/css/ewoooc-tokens.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user