This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 全範圍執行
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user