From dc99babdc6afb01ed1fe1840022b5b8f44939ca4 Mon Sep 17 00:00:00 2001 From: OoO Date: Wed, 13 May 2026 11:23:35 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E5=87=BA=E8=AA=A4=E5=85=A5=E7=9A=84?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E8=AE=8A=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 2 +- routes/sales_routes.py | 365 +++++++---------------------- web/static/css/page-growth-bem.css | 186 +++++++-------- web/static/css/page-growth.css | 46 ++-- 4 files changed, 181 insertions(+), 418 deletions(-) diff --git a/config.py b/config.py index aad6fc6..ddad0d9 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.104" +SYSTEM_VERSION = "V10.103" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/sales_routes.py b/routes/sales_routes.py index 93acaae..3106e17 100644 --- a/routes/sales_routes.py +++ b/routes/sales_routes.py @@ -52,283 +52,6 @@ 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 進行篩選 @@ -1556,10 +1279,27 @@ 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, empty_kpi = _growth_empty_payload(now_taipei) + 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 + } return render_template('growth_analysis.html', chart_data=empty_chart_data, kpi=empty_kpi, @@ -1595,18 +1335,71 @@ def growth_analysis(): table_name = 'realtime_sales_monthly' inspector = inspect(db.engine) - if not inspector.has_table(table_name): + if table_name not in inspector.get_table_names(): return _render_growth_empty(f"尚未匯入業績資料 ({table_name})") - 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) + req_cols = ['日期', '總業績', '訂單編號', '總成本'] + df = safe_read_sql(table_name, columns=req_cols, engine=db.engine) - if not payload: + if df.empty: return _render_growth_empty(f"資料表 {table_name} 為空") - chart_data, kpi = payload + + 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() + } # 儲存快取 set_growth_cache(chart_data, kpi) diff --git a/web/static/css/page-growth-bem.css b/web/static/css/page-growth-bem.css index 74fb739..8f43b71 100644 --- a/web/static/css/page-growth-bem.css +++ b/web/static/css/page-growth-bem.css @@ -9,82 +9,72 @@ display: flex; align-items: center; justify-content: space-between; - 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); + 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); border: 1px solid var(--momo-border-subtle); - border-radius: var(--momo-radius-md, 4px); - box-shadow: var(--momo-shadow-md); + border-radius: var(--momo-radius-md, 8px); + box-shadow: var(--momo-shadow-soft); } .growth-analysis-page .ga-page-head__title { display: flex; align-items: center; - gap: var(--momo-space-2, 8px); - min-width: 0; + gap: var(--momo-space-2, 12px); } .growth-analysis-page .ga-page-head__icon { - color: var(--momo-page-accent); - font-size: var(--momo-text-title); + font-size: 1.25rem; + color: var(--momo-warm-olive, #6f7a4a); } .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 { - 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; + font-size: var(--momo-text-sm, 0.85rem); + color: var(--momo-text-tertiary); } .growth-analysis-page .ga-page-head__meta strong { - color: var(--momo-text-primary); - font-family: var(--momo-font-mono); - font-weight: var(--momo-font-weight-bold); - font-feature-settings: "tnum"; + color: var(--momo-text-strong); + font-weight: 700; } .growth-analysis-page .ga-empty-state { display: grid; gap: var(--momo-space-2, 8px); - 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); + 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); border: 1px solid var(--momo-border-light); - border-radius: var(--momo-radius-md, 4px); + border-radius: var(--momo-radius-md, 8px); text-align: center; } .growth-analysis-page .ga-empty-state i { - color: var(--momo-page-accent-dark); - font-size: var(--momo-text-headline); + color: var(--momo-warm-honey-deep); + font-size: var(--momo-text-2xl, 1.5rem); } .growth-analysis-page .ga-empty-state h2 { margin: 0; - 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); + color: var(--momo-ink-primary); + font-size: var(--momo-text-lg, 1.125rem); + font-weight: var(--momo-font-bold, 700); } .growth-analysis-page .ga-empty-state p { margin: 0; - color: var(--momo-text-secondary); + color: var(--momo-ink-tertiary); } /* ── KPI Row ────────────────────────────────────────── */ .growth-analysis-page .ga-kpi-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--momo-space-3, 12px); + gap: var(--momo-space-3, 16px); } @media (max-width: 992px) { .growth-analysis-page .ga-kpi-row { grid-template-columns: 1fr; } @@ -93,46 +83,45 @@ .growth-analysis-page .ga-kpi { position: relative; overflow: hidden; - min-height: 132px; - padding: var(--momo-space-5, 24px); - color: var(--momo-text-primary); - background: var(--momo-bg-elevated); + padding: var(--momo-space-4, 24px); + border-radius: var(--momo-radius-md, 8px); + color: var(--momo-text-strong); + background: var(--momo-surface-raised); border: 1px solid var(--momo-border-strong); border-left: 4px solid var(--ga-kpi-accent, var(--momo-page-accent)); - border-radius: var(--momo-radius-md, 4px); - box-shadow: var(--momo-shadow-md); + box-shadow: var(--momo-shadow-soft); + min-height: 132px; } .growth-analysis-page .ga-kpi--revenue { --ga-kpi-accent: var(--momo-page-accent); } .growth-analysis-page .ga-kpi--aov { - --ga-kpi-accent: var(--momo-warning-text); + --ga-kpi-accent: var(--momo-tag-honey); } .growth-analysis-page .ga-kpi--orders { - --ga-kpi-accent: var(--momo-success-text); + --ga-kpi-accent: var(--momo-tag-olive); } .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; - margin-bottom: var(--momo-space-1, 4px); - 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); + color: var(--momo-text-strong); + font-family: var(--momo-font-mono, ui-monospace, monospace); + font-size: 2rem; + font-weight: 800; letter-spacing: 0; - line-height: var(--momo-line-height-tight); + margin-bottom: var(--momo-space-1, 4px); + line-height: 1.08; } .growth-analysis-page .ga-kpi__delta { position: relative; @@ -140,43 +129,39 @@ display: flex; flex-wrap: wrap; align-items: center; - gap: var(--momo-space-1, 4px); + gap: var(--momo-space-1, 6px); margin-top: var(--momo-space-2, 8px); } .growth-analysis-page .ga-kpi__chip { display: inline-flex; align-items: center; - 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; + 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; } .growth-analysis-page .ga-kpi__yoy { - font-family: var(--momo-font-mono); - font-feature-settings: "tnum"; - font-size: var(--momo-text-body-sm); - font-weight: var(--momo-font-weight-bold); + font-weight: 700; + font-size: 0.95rem; } -.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__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__hint { position: relative; z-index: 1; - margin-top: var(--momo-space-1, 4px); - color: var(--momo-text-secondary); - font-size: var(--momo-text-meta); + font-size: var(--momo-text-xs, 0.78rem); + color: var(--momo-text-muted); + margin-top: 2px; } .growth-analysis-page .ga-kpi__icon-bg { position: absolute; - right: calc(var(--momo-space-4, 16px) * -1); - bottom: calc(var(--momo-space-4, 16px) * -1); + right: -15px; + bottom: -15px; + font-size: 6rem; color: var(--ga-kpi-accent); - font-size: 88px; opacity: 0.1; transform: rotate(-15deg); pointer-events: none; @@ -185,7 +170,7 @@ /* ── Chart Row 變體 ─────────────────────────────────── */ .growth-analysis-page .ga-chart-row { display: grid; - gap: var(--momo-space-3, 12px); + gap: var(--momo-space-3, 16px); } .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; } @@ -198,32 +183,29 @@ .growth-analysis-page .ga-chart-card { display: flex; flex-direction: column; - background: var(--momo-bg-surface); + background: var(--momo-surface-1); border: 1px solid var(--momo-border-strong); - border-radius: var(--momo-radius-md, 4px); - box-shadow: var(--momo-shadow-md); + border-radius: var(--momo-radius-md, 8px); + box-shadow: var(--momo-shadow-soft); overflow: hidden; } .growth-analysis-page .ga-chart-card__head { - padding: var(--momo-space-4, 16px) var(--momo-space-5, 24px); - background: var(--momo-bg-elevated); + padding: 1rem 1.25rem; + background: var(--momo-surface-2); 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-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; + gap: var(--momo-space-1, 8px); + font-weight: 800; + color: var(--momo-text-strong); } .growth-analysis-page .ga-chart-card__title i { - color: var(--momo-page-accent); + color: var(--momo-warm-olive, #6f7a4a); } .growth-analysis-page .ga-chart-card__body { - padding: var(--momo-space-4, 16px); + padding: var(--momo-space-3, 16px); height: var(--ga-chart-h, 320px); } .growth-analysis-page .ga-chart-card__body canvas { @@ -235,29 +217,19 @@ .growth-analysis-page .ga-page-head { align-items: flex-start; flex-direction: column; - 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; + padding: 14px 16px; } .growth-analysis-page .ga-kpi { min-height: 112px; - padding: var(--momo-space-4, 16px); + padding: 16px; } .growth-analysis-page .ga-kpi__value { - font-size: var(--momo-text-headline); + font-size: 1.45rem; } .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 389d7af..3e66f2b 100644 --- a/web/static/css/page-growth.css +++ b/web/static/css/page-growth.css @@ -7,24 +7,23 @@ .growth-analysis-page { display: flex; flex-direction: column; - gap: var(--momo-space-4, 16px); + gap: var(--momo-space-3, 18px); } .growth-analysis-page .card { border: 1px solid var(--momo-border-strong); - 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); + 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)); } .growth-analysis-page .card-header { - background: var(--momo-bg-elevated); + background: var(--momo-surface-2, rgba(250, 247, 240, 0.9)); border-bottom: 1px solid var(--momo-border-subtle); - 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); + font-weight: 800; + color: var(--momo-text-strong); + padding: 1rem 1.25rem; } /* ── KPI 卡片 ────────────────────────────────────────── */ @@ -35,26 +34,25 @@ } .growth-analysis-page .kpi-card .icon-bg { position: absolute; - right: calc(var(--momo-space-4, 16px) * -1); - bottom: calc(var(--momo-space-4, 16px) * -1); - font-size: 88px; + right: -15px; + bottom: -15px; + font-size: 6rem; opacity: 0.15; transform: rotate(-15deg); pointer-events: none; } .growth-analysis-page .kpi-value { - font-family: var(--momo-font-mono); - font-feature-settings: "tnum"; - font-size: var(--momo-text-display); - font-weight: var(--momo-font-weight-black); + font-size: 2rem; + font-weight: 800; letter-spacing: 0; - margin-bottom: var(--momo-space-1, 4px); + margin-bottom: 0.2rem; } .growth-analysis-page .kpi-label { - font-size: var(--momo-text-body-sm); - font-weight: var(--momo-font-weight-semibold); - letter-spacing: 0; - color: var(--momo-text-secondary); + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.9; } /* ── Bootstrap 覆寫:用 page palette 漸層取代原色 ─── */ @@ -71,8 +69,8 @@ /* ── 趨勢色 ─────────────────────────────────────────── */ .growth-analysis-page .text-success, .growth-analysis-page .trend-up { - color: var(--momo-warning-text) !important; + color: var(--momo-warm-honey, #c89043) !important; } .growth-analysis-page .trend-down { - color: var(--momo-danger-text) !important; + color: var(--momo-warm-rust, #b5342f) !important; }