守住 Observability smoke timeout
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
OoO
2026-05-13 11:22:47 +08:00
parent 4079f1c0ac
commit 81aa424587
6 changed files with 426 additions and 181 deletions

View File

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

View File

@@ -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 全範圍執行

View File

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

View File

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

View File

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

View File

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