From 81aa4245879f35d1ec8a7edbd836129304555423 Mon Sep 17 00:00:00 2001 From: OoO Date: Wed, 13 May 2026 11:22:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=88=E4=BD=8F=20Observability=20smoke=20ti?= =?UTF-8?q?meout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 2 +- .../claude_inventory_validation_20260513.md | 2 + routes/sales_routes.py | 365 ++++++++++++++---- tests/test_cd_health_check.py | 6 + web/static/css/page-growth-bem.css | 186 +++++---- web/static/css/page-growth.css | 46 +-- 6 files changed, 426 insertions(+), 181 deletions(-) diff --git a/config.py b/config.py index ddad0d9..aad6fc6 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.103" +SYSTEM_VERSION = "V10.104" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/claude_inventory_validation_20260513.md b/docs/memory/claude_inventory_validation_20260513.md index fe9e729..5ec7cbc 100644 --- a/docs/memory/claude_inventory_validation_20260513.md +++ b/docs/memory/claude_inventory_validation_20260513.md @@ -36,6 +36,7 @@ - V2 指出的 9 個 cron 盲區已補回歸守門:8 個 `run_scheduler.py` wrapper 必須呼叫 `_notify_scheduler_failure()`,`scheduler.py::run_promo_event_task` 必須呼叫 `notify_failure()`。 - 09:00 排程衝突已有回歸守門:`daily_report=09:00`、`roi_monthly_report=09:05`、`ai_smoke_daily_summary=09:10` 必須保持錯開。 - CD migration 全範圍冪等已有回歸守門:`.gitea/workflows/cd.yaml` 必須維持 024-099 pattern、`sort | uniq` 與 `for m in $V5_MIGRATIONS` apply loop。 +- CD Observability production smoke 已補 timeout 守門:`quick_review.sh --observability-smoke` 必須帶 `--timeout 12`。 - 0-byte `database/momo*.db` 迷惑檔已不存在;真實 SQLite 僅在 `data/momo_database.db`。 - `.gitignore` 已涵蓋 `.claude/worktrees/`、`.tmp_*`、`MOMO Pro/`、uploads/screenshots。 - `cache_service.py` 已成為 `cache_manager.py` 的相容 shim,`_SALES_CACHE_TTL` 單一來源有測試鎖住。 @@ -102,3 +103,4 @@ - `6c86839` 守住盤點誤判依賴 - `2e2b775` 守住 V2 import 清理狀態 - `58ba95b` 守住月結匯入 append 路徑 +- `4079f1c` 守住 CD migration 全範圍執行 diff --git a/routes/sales_routes.py b/routes/sales_routes.py index 3106e17..93acaae 100644 --- a/routes/sales_routes.py +++ b/routes/sales_routes.py @@ -52,6 +52,283 @@ from utils.df_helpers import find_col # noqa: E402, F401 from utils.security import validate_table_name, safe_read_sql # noqa: E402, F401 +def _growth_empty_payload(now_taipei=None): + now_taipei = now_taipei or datetime.now(TAIPEI_TZ) + return ( + { + 'labels': [], + 'revenue': [], + 'profit': [], + 'orders': [], + 'aov': [], + 'mom': [], + 'yoy': [], + 'margin_rate': [] + }, + { + 'ytd_revenue': 0, + 'ytd_growth': 0, + 'current_year': now_taipei.year, + 'recent_aov': 0, + 'total_orders': 0 + } + ) + + +def _growth_number(value): + try: + number = float(value or 0) + except (TypeError, ValueError): + return 0.0 + return number if math.isfinite(number) else 0.0 + + +def _growth_month_iter(start_month, end_month): + year = start_month.year + month = start_month.month + while (year, month) <= (end_month.year, end_month.month): + yield datetime(year, month, 1).date() + month += 1 + if month > 12: + year += 1 + month = 1 + + +def _build_growth_chart_data(monthly_rows): + monthly_values = {} + for row in monthly_rows: + month_raw = row.get('month_start') + month_dt = pd.to_datetime(month_raw, errors='coerce') + if pd.isna(month_dt): + continue + month_key = month_dt.date().replace(day=1) + monthly_values[month_key] = { + 'amount': _growth_number(row.get('amount')), + 'profit': _growth_number(row.get('profit')), + 'orders': int(_growth_number(row.get('orders'))), + } + + if not monthly_values: + return None + + months = list(_growth_month_iter(min(monthly_values), max(monthly_values))) + revenue = [] + profit = [] + orders = [] + aov = [] + margin_rate = [] + mom = [] + yoy = [] + + for index, month_key in enumerate(months): + item = monthly_values.get(month_key, {'amount': 0, 'profit': 0, 'orders': 0}) + amount = item['amount'] + profit_value = item['profit'] + order_count = item['orders'] + + revenue.append(amount) + profit.append(profit_value) + orders.append(order_count) + aov.append(round(amount / order_count, 0) if order_count > 0 else 0) + margin_rate.append(round((profit_value / amount) * 100, 1) if amount > 0 else 0) + + prev_amount = revenue[index - 1] if index >= 1 else 0 + last_year_amount = revenue[index - 12] if index >= 12 else 0 + mom.append(round(((amount - prev_amount) / prev_amount) * 100, 2) if prev_amount > 0 else 0) + yoy.append(round(((amount - last_year_amount) / last_year_amount) * 100, 2) if last_year_amount > 0 else 0) + + return { + 'labels': [month.strftime('%Y-%m') for month in months], + 'revenue': revenue, + 'profit': profit, + 'orders': orders, + 'aov': aov, + 'mom': mom, + 'yoy': yoy, + 'margin_rate': margin_rate + } + + +def _build_growth_kpi(kpi_row): + if not kpi_row: + return None + + max_dt = pd.to_datetime(kpi_row.get('max_date'), errors='coerce') + current_year = int(kpi_row.get('current_year') or (max_dt.year if not pd.isna(max_dt) else datetime.now(TAIPEI_TZ).year)) + ytd_revenue = _growth_number(kpi_row.get('ytd_revenue')) + last_ytd_revenue = _growth_number(kpi_row.get('last_ytd_revenue')) + recent_revenue = _growth_number(kpi_row.get('recent_revenue')) + recent_orders = int(_growth_number(kpi_row.get('recent_orders'))) + + return { + 'ytd_revenue': ytd_revenue, + 'ytd_growth': ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue) * 100 if last_ytd_revenue > 0 else 0, + 'current_year': current_year, + 'recent_aov': recent_revenue / recent_orders if recent_orders > 0 else 0, + 'total_orders': int(_growth_number(kpi_row.get('total_orders'))) + } + + +def _fetch_growth_payload_sql(engine, table_name): + """用 DB 聚合取代全表 pandas 載入,降低成長分析冷啟動 TTFB。""" + table_name = validate_table_name(table_name) + table_ref = f'"{table_name}"' + + if engine.dialect.name == 'postgresql': + monthly_sql = text(f""" + SELECT + date_trunc('month', "日期")::date AS month_start, + SUM(COALESCE("總業績", 0)) AS amount, + SUM(COALESCE("總業績", 0) - COALESCE("總成本", 0)) AS profit, + COUNT(DISTINCT "訂單編號") AS orders + FROM {table_ref} + WHERE "日期" IS NOT NULL + GROUP BY 1 + ORDER BY 1 + """) + kpi_sql = text(f""" + WITH bounds AS ( + SELECT MAX("日期"::date) AS max_date + FROM {table_ref} + WHERE "日期" IS NOT NULL + ) + SELECT + EXTRACT(YEAR FROM b.max_date)::int AS current_year, + b.max_date::text AS max_date, + COALESCE(SUM(CASE + WHEN "日期"::date >= date_trunc('year', b.max_date)::date + AND "日期"::date <= b.max_date + THEN COALESCE("總業績", 0) ELSE 0 END), 0) AS ytd_revenue, + COALESCE(SUM(CASE + WHEN "日期"::date >= (date_trunc('year', b.max_date) - interval '1 year')::date + AND "日期"::date <= (b.max_date - interval '1 year')::date + THEN COALESCE("總業績", 0) ELSE 0 END), 0) AS last_ytd_revenue, + COALESCE(SUM(CASE + WHEN "日期"::date >= (b.max_date - interval '30 days')::date + AND "日期"::date <= b.max_date + THEN COALESCE("總業績", 0) ELSE 0 END), 0) AS recent_revenue, + COUNT(DISTINCT CASE + WHEN "日期"::date >= (b.max_date - interval '30 days')::date + AND "日期"::date <= b.max_date + THEN "訂單編號" END) AS recent_orders, + COUNT(DISTINCT "訂單編號") AS total_orders + FROM {table_ref} + CROSS JOIN bounds b + WHERE "日期" IS NOT NULL + GROUP BY b.max_date + """) + else: + monthly_sql = text(f""" + SELECT + date("日期", 'start of month') AS month_start, + SUM(COALESCE(CAST("總業績" AS REAL), 0)) AS amount, + SUM(COALESCE(CAST("總業績" AS REAL), 0) - COALESCE(CAST("總成本" AS REAL), 0)) AS profit, + COUNT(DISTINCT "訂單編號") AS orders + FROM {table_ref} + WHERE "日期" IS NOT NULL + GROUP BY 1 + ORDER BY 1 + """) + kpi_sql = text(f""" + WITH bounds AS ( + SELECT date(MAX("日期")) AS max_date + FROM {table_ref} + WHERE "日期" IS NOT NULL + ) + SELECT + CAST(strftime('%Y', b.max_date) AS INTEGER) AS current_year, + b.max_date AS max_date, + COALESCE(SUM(CASE + WHEN date("日期") >= date(b.max_date, 'start of year') + AND date("日期") <= b.max_date + THEN COALESCE(CAST("總業績" AS REAL), 0) ELSE 0 END), 0) AS ytd_revenue, + COALESCE(SUM(CASE + WHEN date("日期") >= date(b.max_date, 'start of year', '-1 year') + AND date("日期") <= date(b.max_date, '-1 year') + THEN COALESCE(CAST("總業績" AS REAL), 0) ELSE 0 END), 0) AS last_ytd_revenue, + COALESCE(SUM(CASE + WHEN date("日期") >= date(b.max_date, '-30 days') + AND date("日期") <= b.max_date + THEN COALESCE(CAST("總業績" AS REAL), 0) ELSE 0 END), 0) AS recent_revenue, + COUNT(DISTINCT CASE + WHEN date("日期") >= date(b.max_date, '-30 days') + AND date("日期") <= b.max_date + THEN "訂單編號" END) AS recent_orders, + COUNT(DISTINCT "訂單編號") AS total_orders + FROM {table_ref} + CROSS JOIN bounds b + WHERE "日期" IS NOT NULL + GROUP BY b.max_date + """) + + with engine.connect() as conn: + monthly_rows = conn.execute(monthly_sql).mappings().all() + kpi_row = conn.execute(kpi_sql).mappings().first() + + chart_data = _build_growth_chart_data(monthly_rows) + kpi = _build_growth_kpi(kpi_row) + if not chart_data or not kpi: + return None + return chart_data, kpi + + +def _fetch_growth_payload_pandas(engine, table_name): + req_cols = ['日期', '總業績', '訂單編號', '總成本'] + df = safe_read_sql(table_name, columns=req_cols, engine=engine) + + if df.empty: + return None + + df['dt'] = pd.to_datetime(df['日期'], errors='coerce') + df = df.dropna(subset=['dt']) + if df.empty: + return None + + df['amount'] = pd.to_numeric(df['總業績'], errors='coerce').fillna(0) + df['cost'] = pd.to_numeric(df['總成本'], errors='coerce').fillna(0) + df['profit'] = df['amount'] - df['cost'] + + monthly_stats = df.set_index('dt').resample('MS').agg({ + 'amount': 'sum', + 'profit': 'sum', + '訂單編號': 'nunique' + }).rename(columns={'訂單編號': 'orders'}) + + monthly_rows = [ + { + 'month_start': month_start, + 'amount': row['amount'], + 'profit': row['profit'], + 'orders': row['orders'], + } + for month_start, row in monthly_stats.iterrows() + ] + chart_data = _build_growth_chart_data(monthly_rows) + + current_year = df['dt'].max().year + last_year = current_year - 1 + ytd_mask = df['dt'].dt.year == current_year + last_ytd_mask = (df['dt'].dt.year == last_year) & (df['dt'].dt.dayofyear <= df['dt'].max().dayofyear) + last_month_mask = df['dt'] >= (df['dt'].max() - pd.Timedelta(days=30)) + + ytd_revenue = float(df.loc[ytd_mask, 'amount'].sum()) + last_ytd_revenue = float(df.loc[last_ytd_mask, 'amount'].sum()) + recent_revenue = float(df.loc[last_month_mask, 'amount'].sum()) + recent_orders = int(df.loc[last_month_mask, '訂單編號'].nunique()) + + kpi = { + 'ytd_revenue': ytd_revenue, + 'ytd_growth': ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue) * 100 if last_ytd_revenue > 0 else 0, + 'current_year': current_year, + 'recent_aov': recent_revenue / recent_orders if recent_orders > 0 else 0, + 'total_orders': int(monthly_stats['orders'].sum()) + } + + if not chart_data: + return None + return chart_data, kpi + + def _get_filtered_sales_data(cache_key): """ 🚩 共用函式:從快取讀取資料並根據 request.args 進行篩選 @@ -1279,27 +1556,10 @@ def growth_analysis(): from services.cache_service import ( get_growth_cache, set_growth_cache, is_growth_cache_valid ) - import time def _render_growth_empty(message=None): now_taipei = datetime.now(TAIPEI_TZ) - empty_chart_data = { - 'labels': [], - 'revenue': [], - 'profit': [], - 'orders': [], - 'aov': [], - 'mom': [], - 'yoy': [], - 'margin_rate': [] - } - empty_kpi = { - 'ytd_revenue': 0, - 'ytd_growth': 0, - 'current_year': now_taipei.year, - 'recent_aov': 0, - 'total_orders': 0 - } + empty_chart_data, empty_kpi = _growth_empty_payload(now_taipei) return render_template('growth_analysis.html', chart_data=empty_chart_data, kpi=empty_kpi, @@ -1335,71 +1595,18 @@ def growth_analysis(): table_name = 'realtime_sales_monthly' inspector = inspect(db.engine) - if table_name not in inspector.get_table_names(): + if not inspector.has_table(table_name): return _render_growth_empty(f"尚未匯入業績資料 ({table_name})") - req_cols = ['日期', '總業績', '訂單編號', '總成本'] - df = safe_read_sql(table_name, columns=req_cols, engine=db.engine) + try: + payload = _fetch_growth_payload_sql(db.engine, table_name) + except Exception as sql_error: + sys_log.warning(f"[GrowthAnalysis] SQL 聚合失敗,回退 pandas 路徑: {sql_error}") + payload = _fetch_growth_payload_pandas(db.engine, table_name) - if df.empty: + if not payload: return _render_growth_empty(f"資料表 {table_name} 為空") - - df['dt'] = pd.to_datetime(df['日期'], errors='coerce') - df = df.dropna(subset=['dt']) - df['amount'] = pd.to_numeric(df['總業績'], errors='coerce').fillna(0) - df['cost'] = pd.to_numeric(df['總成本'], errors='coerce').fillna(0) - df['profit'] = df['amount'] - df['cost'] - - monthly_stats = df.set_index('dt').resample('MS').agg({ - 'amount': 'sum', - 'profit': 'sum', - '訂單編號': 'nunique' - }).rename(columns={'訂單編號': 'orders'}) - - monthly_stats['aov'] = monthly_stats['amount'] / monthly_stats['orders'] - monthly_stats['margin_rate'] = (monthly_stats['profit'] / monthly_stats['amount']) * 100 - monthly_stats['mom'] = monthly_stats['amount'].pct_change() * 100 - monthly_stats['yoy'] = monthly_stats['amount'].pct_change(periods=12) * 100 - monthly_stats = monthly_stats.fillna(0) - - labels = monthly_stats.index.strftime('%Y-%m').tolist() - - chart_data = { - 'labels': labels, - 'revenue': monthly_stats['amount'].tolist(), - 'profit': monthly_stats['profit'].tolist(), - 'orders': monthly_stats['orders'].tolist(), - 'aov': monthly_stats['aov'].round(0).tolist(), - 'mom': monthly_stats['mom'].round(2).tolist(), - 'yoy': monthly_stats['yoy'].round(2).tolist(), - 'margin_rate': monthly_stats['margin_rate'].round(1).tolist() - } - - current_year = df['dt'].max().year - last_year = current_year - 1 - - ytd_mask = df['dt'].dt.year == current_year - last_ytd_mask = (df['dt'].dt.year == last_year) & (df['dt'].dt.dayofyear <= df['dt'].max().dayofyear) - - ytd_revenue = df.loc[ytd_mask, 'amount'].sum() - last_ytd_revenue = df.loc[last_ytd_mask, 'amount'].sum() - - ytd_growth = 0 - if last_ytd_revenue > 0: - ytd_growth = ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue) * 100 - - last_month_mask = df['dt'] >= (df['dt'].max() - pd.Timedelta(days=30)) - recent_revenue = df.loc[last_month_mask, 'amount'].sum() - recent_orders = df.loc[last_month_mask, '訂單編號'].nunique() - recent_aov = recent_revenue / recent_orders if recent_orders > 0 else 0 - - kpi = { - 'ytd_revenue': ytd_revenue, - 'ytd_growth': ytd_growth, - 'current_year': current_year, - 'recent_aov': recent_aov, - 'total_orders': monthly_stats['orders'].sum() - } + chart_data, kpi = payload # 儲存快取 set_growth_cache(chart_data, kpi) diff --git a/tests/test_cd_health_check.py b/tests/test_cd_health_check.py index 48c27a9..db1e156 100644 --- a/tests/test_cd_health_check.py +++ b/tests/test_cd_health_check.py @@ -87,3 +87,9 @@ def test_cd_applies_full_v5_migration_range_idempotently(): assert pattern in workflow assert "sort | uniq" in workflow assert "for m in $V5_MIGRATIONS" in workflow + + +def test_cd_observability_production_smoke_has_timeout(): + workflow = CD_WORKFLOW.read_text(encoding="utf-8") + + assert "bash ./scripts/quick_review.sh --observability-smoke --base-url https://mo.wooo.work --timeout 12" in workflow diff --git a/web/static/css/page-growth-bem.css b/web/static/css/page-growth-bem.css index 8f43b71..74fb739 100644 --- a/web/static/css/page-growth-bem.css +++ b/web/static/css/page-growth-bem.css @@ -9,72 +9,82 @@ display: flex; align-items: center; justify-content: space-between; - gap: var(--momo-space-3, 16px); - padding: var(--momo-space-3, 16px) var(--momo-space-4, 20px); - margin-top: var(--momo-space-3, 16px); - background: var(--momo-surface-1); + gap: var(--momo-space-4, 16px); + padding: var(--momo-space-4, 16px) var(--momo-space-5, 24px); + margin-top: var(--momo-space-3, 12px); + background: var(--momo-bg-surface); border: 1px solid var(--momo-border-subtle); - border-radius: var(--momo-radius-md, 8px); - box-shadow: var(--momo-shadow-soft); + border-radius: var(--momo-radius-md, 4px); + box-shadow: var(--momo-shadow-md); } .growth-analysis-page .ga-page-head__title { display: flex; align-items: center; - gap: var(--momo-space-2, 12px); + gap: var(--momo-space-2, 8px); + min-width: 0; } .growth-analysis-page .ga-page-head__icon { - font-size: 1.25rem; - color: var(--momo-warm-olive, #6f7a4a); + color: var(--momo-page-accent); + font-size: var(--momo-text-title); } .growth-analysis-page .ga-page-head__h1 { - font-size: var(--momo-text-h4, 1.25rem); - font-weight: 800; - color: var(--momo-text-strong); margin: 0; + color: var(--momo-page-ink, var(--momo-text-primary)); + font-family: var(--momo-font-display); + font-size: var(--momo-text-headline); + font-weight: var(--momo-font-weight-black); + line-height: var(--momo-line-height-tight); + letter-spacing: 0; } .growth-analysis-page .ga-page-head__meta { - font-size: var(--momo-text-sm, 0.85rem); - color: var(--momo-text-tertiary); + flex: 0 0 auto; + color: var(--momo-text-secondary); + font-size: var(--momo-text-meta); + line-height: var(--momo-line-height-base); + text-align: right; } .growth-analysis-page .ga-page-head__meta strong { - color: var(--momo-text-strong); - font-weight: 700; + color: var(--momo-text-primary); + font-family: var(--momo-font-mono); + font-weight: var(--momo-font-weight-bold); + font-feature-settings: "tnum"; } .growth-analysis-page .ga-empty-state { display: grid; gap: var(--momo-space-2, 8px); - padding: var(--momo-space-8, 40px) var(--momo-space-5, 24px); - margin-bottom: var(--momo-space-4, 20px); - color: var(--momo-ink-secondary); - background: var(--momo-surface-0); + padding: var(--momo-space-7, 48px) var(--momo-space-5, 24px); + margin-bottom: var(--momo-space-4, 16px); + color: var(--momo-text-tertiary); + background: var(--momo-bg-surface); border: 1px solid var(--momo-border-light); - border-radius: var(--momo-radius-md, 8px); + border-radius: var(--momo-radius-md, 4px); text-align: center; } .growth-analysis-page .ga-empty-state i { - color: var(--momo-warm-honey-deep); - font-size: var(--momo-text-2xl, 1.5rem); + color: var(--momo-page-accent-dark); + font-size: var(--momo-text-headline); } .growth-analysis-page .ga-empty-state h2 { margin: 0; - color: var(--momo-ink-primary); - font-size: var(--momo-text-lg, 1.125rem); - font-weight: var(--momo-font-bold, 700); + color: var(--momo-text-primary); + font-size: var(--momo-text-title); + font-weight: var(--momo-font-weight-bold); + line-height: var(--momo-line-height-tight); } .growth-analysis-page .ga-empty-state p { margin: 0; - color: var(--momo-ink-tertiary); + color: var(--momo-text-secondary); } /* ── KPI Row ────────────────────────────────────────── */ .growth-analysis-page .ga-kpi-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--momo-space-3, 16px); + gap: var(--momo-space-3, 12px); } @media (max-width: 992px) { .growth-analysis-page .ga-kpi-row { grid-template-columns: 1fr; } @@ -83,45 +93,46 @@ .growth-analysis-page .ga-kpi { position: relative; overflow: hidden; - padding: var(--momo-space-4, 24px); - border-radius: var(--momo-radius-md, 8px); - color: var(--momo-text-strong); - background: var(--momo-surface-raised); + min-height: 132px; + padding: var(--momo-space-5, 24px); + color: var(--momo-text-primary); + background: var(--momo-bg-elevated); border: 1px solid var(--momo-border-strong); border-left: 4px solid var(--ga-kpi-accent, var(--momo-page-accent)); - box-shadow: var(--momo-shadow-soft); - min-height: 132px; + border-radius: var(--momo-radius-md, 4px); + box-shadow: var(--momo-shadow-md); } .growth-analysis-page .ga-kpi--revenue { --ga-kpi-accent: var(--momo-page-accent); } .growth-analysis-page .ga-kpi--aov { - --ga-kpi-accent: var(--momo-tag-honey); + --ga-kpi-accent: var(--momo-warning-text); } .growth-analysis-page .ga-kpi--orders { - --ga-kpi-accent: var(--momo-tag-olive); + --ga-kpi-accent: var(--momo-success-text); } .growth-analysis-page .ga-kpi__label { position: relative; z-index: 1; - font-size: var(--momo-text-sm, 0.85rem); - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--momo-text-muted); margin-bottom: var(--momo-space-2, 8px); + color: var(--momo-text-secondary); + font-size: var(--momo-text-body-sm); + font-weight: var(--momo-font-weight-bold); + letter-spacing: 0; + line-height: var(--momo-line-height-tight); } .growth-analysis-page .ga-kpi__value { position: relative; z-index: 1; - color: var(--momo-text-strong); - font-family: var(--momo-font-mono, ui-monospace, monospace); - font-size: 2rem; - font-weight: 800; - letter-spacing: 0; margin-bottom: var(--momo-space-1, 4px); - line-height: 1.08; + color: var(--momo-text-primary); + font-family: var(--momo-font-mono); + font-feature-settings: "tnum"; + font-size: var(--momo-text-display); + font-weight: var(--momo-font-weight-black); + letter-spacing: 0; + line-height: var(--momo-line-height-tight); } .growth-analysis-page .ga-kpi__delta { position: relative; @@ -129,39 +140,43 @@ display: flex; flex-wrap: wrap; align-items: center; - gap: var(--momo-space-1, 6px); + gap: var(--momo-space-1, 4px); margin-top: var(--momo-space-2, 8px); } .growth-analysis-page .ga-kpi__chip { display: inline-flex; align-items: center; - padding: 2px 10px; - background: color-mix(in srgb, var(--ga-kpi-accent) 12%, var(--momo-surface)); - color: var(--ga-kpi-accent); - border: 1px solid color-mix(in srgb, var(--ga-kpi-accent) 28%, var(--momo-border-subtle)); - font-size: 0.72rem; - font-weight: 700; - border-radius: 6px; + padding: var(--momo-space-1, 4px) var(--momo-space-2, 8px); + color: var(--momo-tag-honey-text); + background: var(--momo-tag-honey-bg); + border: 1px solid var(--momo-tag-honey-border); + border-radius: var(--momo-radius-sm, 2px); + font-size: var(--momo-text-label); + font-weight: var(--momo-font-weight-bold); + line-height: 1; + letter-spacing: 0; } .growth-analysis-page .ga-kpi__yoy { - font-weight: 700; - font-size: 0.95rem; + font-family: var(--momo-font-mono); + font-feature-settings: "tnum"; + font-size: var(--momo-text-body-sm); + font-weight: var(--momo-font-weight-bold); } -.growth-analysis-page .ga-kpi__yoy.is-up { color: var(--momo-tag-rust); } -.growth-analysis-page .ga-kpi__yoy.is-down { color: var(--momo-tag-olive); } +.growth-analysis-page .ga-kpi__yoy.is-up { color: var(--momo-danger-text); } +.growth-analysis-page .ga-kpi__yoy.is-down { color: var(--momo-success-text); } .growth-analysis-page .ga-kpi__hint { position: relative; z-index: 1; - font-size: var(--momo-text-xs, 0.78rem); - color: var(--momo-text-muted); - margin-top: 2px; + margin-top: var(--momo-space-1, 4px); + color: var(--momo-text-secondary); + font-size: var(--momo-text-meta); } .growth-analysis-page .ga-kpi__icon-bg { position: absolute; - right: -15px; - bottom: -15px; - font-size: 6rem; + right: calc(var(--momo-space-4, 16px) * -1); + bottom: calc(var(--momo-space-4, 16px) * -1); color: var(--ga-kpi-accent); + font-size: 88px; opacity: 0.1; transform: rotate(-15deg); pointer-events: none; @@ -170,7 +185,7 @@ /* ── Chart Row 變體 ─────────────────────────────────── */ .growth-analysis-page .ga-chart-row { display: grid; - gap: var(--momo-space-3, 16px); + gap: var(--momo-space-3, 12px); } .growth-analysis-page .ga-chart-row--8-4 { grid-template-columns: 2fr 1fr; } .growth-analysis-page .ga-chart-row--6-6 { grid-template-columns: 1fr 1fr; } @@ -183,29 +198,32 @@ .growth-analysis-page .ga-chart-card { display: flex; flex-direction: column; - background: var(--momo-surface-1); + background: var(--momo-bg-surface); border: 1px solid var(--momo-border-strong); - border-radius: var(--momo-radius-md, 8px); - box-shadow: var(--momo-shadow-soft); + border-radius: var(--momo-radius-md, 4px); + box-shadow: var(--momo-shadow-md); overflow: hidden; } .growth-analysis-page .ga-chart-card__head { - padding: 1rem 1.25rem; - background: var(--momo-surface-2); + padding: var(--momo-space-4, 16px) var(--momo-space-5, 24px); + background: var(--momo-bg-elevated); border-bottom: 1px solid var(--momo-border-subtle); } .growth-analysis-page .ga-chart-card__title { display: inline-flex; align-items: center; - gap: var(--momo-space-1, 8px); - font-weight: 800; - color: var(--momo-text-strong); + gap: var(--momo-space-2, 8px); + color: var(--momo-text-primary); + font-size: var(--momo-text-title); + font-weight: var(--momo-font-weight-bold); + line-height: var(--momo-line-height-tight); + letter-spacing: 0; } .growth-analysis-page .ga-chart-card__title i { - color: var(--momo-warm-olive, #6f7a4a); + color: var(--momo-page-accent); } .growth-analysis-page .ga-chart-card__body { - padding: var(--momo-space-3, 16px); + padding: var(--momo-space-4, 16px); height: var(--ga-chart-h, 320px); } .growth-analysis-page .ga-chart-card__body canvas { @@ -217,19 +235,29 @@ .growth-analysis-page .ga-page-head { align-items: flex-start; flex-direction: column; - padding: 14px 16px; + padding: var(--momo-space-3, 12px) var(--momo-space-4, 16px); + } + + .growth-analysis-page .ga-page-head__h1 { + font-size: var(--momo-text-title); + } + + .growth-analysis-page .ga-page-head__meta { + width: 100%; + text-align: left; } .growth-analysis-page .ga-kpi { min-height: 112px; - padding: 16px; + padding: var(--momo-space-4, 16px); } .growth-analysis-page .ga-kpi__value { - font-size: 1.45rem; + font-size: var(--momo-text-headline); } .growth-analysis-page .ga-chart-card__body { height: 260px !important; + padding: var(--momo-space-3, 12px); } } diff --git a/web/static/css/page-growth.css b/web/static/css/page-growth.css index 3e66f2b..389d7af 100644 --- a/web/static/css/page-growth.css +++ b/web/static/css/page-growth.css @@ -7,23 +7,24 @@ .growth-analysis-page { display: flex; flex-direction: column; - gap: var(--momo-space-3, 18px); + gap: var(--momo-space-4, 16px); } .growth-analysis-page .card { border: 1px solid var(--momo-border-strong); - border-radius: var(--momo-radius-md, 8px); - box-shadow: var(--momo-shadow-soft); - margin-bottom: 1.5rem; - background: var(--momo-surface-1, rgba(255, 253, 248, 0.94)); + border-radius: var(--momo-radius-md, 4px); + box-shadow: var(--momo-shadow-md); + margin-bottom: var(--momo-space-5, 24px); + background: var(--momo-bg-surface); } .growth-analysis-page .card-header { - background: var(--momo-surface-2, rgba(250, 247, 240, 0.9)); + background: var(--momo-bg-elevated); border-bottom: 1px solid var(--momo-border-subtle); - font-weight: 800; - color: var(--momo-text-strong); - padding: 1rem 1.25rem; + font-size: var(--momo-text-title); + font-weight: var(--momo-font-weight-bold); + color: var(--momo-text-primary); + padding: var(--momo-space-4, 16px) var(--momo-space-5, 24px); } /* ── KPI 卡片 ────────────────────────────────────────── */ @@ -34,25 +35,26 @@ } .growth-analysis-page .kpi-card .icon-bg { position: absolute; - right: -15px; - bottom: -15px; - font-size: 6rem; + right: calc(var(--momo-space-4, 16px) * -1); + bottom: calc(var(--momo-space-4, 16px) * -1); + font-size: 88px; opacity: 0.15; transform: rotate(-15deg); pointer-events: none; } .growth-analysis-page .kpi-value { - font-size: 2rem; - font-weight: 800; + font-family: var(--momo-font-mono); + font-feature-settings: "tnum"; + font-size: var(--momo-text-display); + font-weight: var(--momo-font-weight-black); letter-spacing: 0; - margin-bottom: 0.2rem; + margin-bottom: var(--momo-space-1, 4px); } .growth-analysis-page .kpi-label { - font-size: 0.85rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - opacity: 0.9; + font-size: var(--momo-text-body-sm); + font-weight: var(--momo-font-weight-semibold); + letter-spacing: 0; + color: var(--momo-text-secondary); } /* ── Bootstrap 覆寫:用 page palette 漸層取代原色 ─── */ @@ -69,8 +71,8 @@ /* ── 趨勢色 ─────────────────────────────────────────── */ .growth-analysis-page .text-success, .growth-analysis-page .trend-up { - color: var(--momo-warm-honey, #c89043) !important; + color: var(--momo-warning-text) !important; } .growth-analysis-page .trend-down { - color: var(--momo-warm-rust, #b5342f) !important; + color: var(--momo-danger-text) !important; }