加速成長分析冷啟動
All checks were successful
CD Pipeline / deploy (push) Successful in 56s

This commit is contained in:
OoO
2026-05-13 11:29:49 +08:00
parent 035b88cbf7
commit 0dcebb4798
6 changed files with 517 additions and 200 deletions

View File

@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.103"
SYSTEM_VERSION = "V10.105"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -52,6 +52,356 @@ 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') or row.get('volume'))),
}
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_summary(engine):
"""優先使用月結摘要表,避免成長頁冷 worker 掃 70+ 萬列原始明細。"""
table_name = 'monthly_summary_analysis'
if not inspect(engine).has_table(table_name):
return None
table_ref = f'"{table_name}"'
if engine.dialect.name == 'postgresql':
summary_sql = text(f"""
SELECT
make_date("year"::int, "month"::int, 1) AS month_start,
SUM(COALESCE(sales_amt_curr, 0)) AS amount,
SUM(COALESCE(profit_amt_curr, 0)) AS profit,
SUM(COALESCE(sales_vol_curr, 0)) AS volume
FROM {table_ref}
WHERE "year" IS NOT NULL
AND "month" IS NOT NULL
GROUP BY 1
ORDER BY 1
""")
else:
summary_sql = text(f"""
SELECT
printf('%04d-%02d-01', "year", "month") AS month_start,
SUM(COALESCE(sales_amt_curr, 0)) AS amount,
SUM(COALESCE(profit_amt_curr, 0)) AS profit,
SUM(COALESCE(sales_vol_curr, 0)) AS volume
FROM {table_ref}
WHERE "year" IS NOT NULL
AND "month" IS NOT NULL
GROUP BY 1
ORDER BY 1
""")
with engine.connect() as conn:
rows = conn.execute(summary_sql).mappings().all()
chart_data = _build_growth_chart_data(rows)
if not chart_data or not chart_data['labels']:
return None
latest_label = chart_data['labels'][-1]
current_year = int(latest_label[:4])
latest_month = int(latest_label[5:7])
last_year = current_year - 1
ytd_revenue = 0.0
last_ytd_revenue = 0.0
for label, revenue in zip(chart_data['labels'], chart_data['revenue']):
year = int(label[:4])
month = int(label[5:7])
if year == current_year and month <= latest_month:
ytd_revenue += _growth_number(revenue)
elif year == last_year and month <= latest_month:
last_ytd_revenue += _growth_number(revenue)
latest_revenue = _growth_number(chart_data['revenue'][-1])
latest_volume = int(_growth_number(chart_data['orders'][-1]))
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': latest_revenue / latest_volume if latest_volume > 0 else 0,
'total_orders': int(sum(_growth_number(value) for value in chart_data['orders']))
}
return chart_data, kpi
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':
date_expr = '"日期"::date'
amount_expr = 'COALESCE(NULLIF(regexp_replace("總業績"::text, \'[^0-9.-]\', \'\', \'g\'), \'\')::numeric, 0)'
cost_expr = 'COALESCE(NULLIF(regexp_replace("總成本"::text, \'[^0-9.-]\', \'\', \'g\'), \'\')::numeric, 0)'
monthly_sql = text(f"""
SELECT
date_trunc('month', {date_expr})::date AS month_start,
SUM({amount_expr}) AS amount,
SUM({amount_expr} - {cost_expr}) 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_expr}) 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_expr} >= date_trunc('year', b.max_date)::date
AND {date_expr} <= b.max_date
THEN {amount_expr} ELSE 0 END), 0) AS ytd_revenue,
COALESCE(SUM(CASE
WHEN {date_expr} >= (date_trunc('year', b.max_date) - interval '1 year')::date
AND {date_expr} <= (b.max_date - interval '1 year')::date
THEN {amount_expr} ELSE 0 END), 0) AS last_ytd_revenue,
COALESCE(SUM(CASE
WHEN {date_expr} >= (b.max_date - interval '30 days')::date
AND {date_expr} <= b.max_date
THEN {amount_expr} ELSE 0 END), 0) AS recent_revenue,
COUNT(DISTINCT CASE
WHEN {date_expr} >= (b.max_date - interval '30 days')::date
AND {date_expr} <= 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['volume'] = 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',
'volume': 'sum'
}).rename(columns={'volume': '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, 'volume'].sum())
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 +1629,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 +1668,25 @@ 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_summary(db.engine)
except Exception as summary_error:
sys_log.warning(f"[GrowthAnalysis] 月結摘要聚合失敗,改走明細聚合: {summary_error}")
payload = None
if df.empty:
if not payload:
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 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

@@ -47,16 +47,16 @@
</article>
<article class="ga-kpi ga-kpi--aov">
<div class="ga-kpi__label">近 30 天平均單價 (AOV)</div>
<div class="ga-kpi__label">最新月平均單價</div>
<div class="ga-kpi__value">${{ "{:,.0f}".format(kpi.recent_aov) }}</div>
<div class="ga-kpi__hint">真實訂單基礎 (Unique Order ID)</div>
<div class="ga-kpi__hint">月結銷售額 ÷ 銷量</div>
<i class="fas fa-shopping-cart ga-kpi__icon-bg"></i>
</article>
<article class="ga-kpi ga-kpi--orders">
<div class="ga-kpi__label">訂單數 (Total Orders)</div>
<div class="ga-kpi__label">銷量</div>
<div class="ga-kpi__value">{{ "{:,.0f}".format(kpi.total_orders) }}</div>
<div class="ga-kpi__hint">全時期累計</div>
<div class="ga-kpi__hint">全時期累計件數</div>
<i class="fas fa-receipt ga-kpi__icon-bg"></i>
</article>
</section>
@@ -86,7 +86,7 @@
<section class="ga-chart-row ga-chart-row--6-6">
<article class="ga-chart-card">
<header class="ga-chart-card__head">
<span class="ga-chart-card__title"><i class="fas fa-wallet"></i> 單價趨勢 (AOV Trend)</span>
<span class="ga-chart-card__title"><i class="fas fa-wallet"></i> 平均單價趨勢</span>
</header>
<div class="ga-chart-card__body" style="--ga-chart-h: 300px;">
<canvas id="aovChart"></canvas>

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

View File

@@ -7,22 +7,22 @@
'use strict';
const data = JSON.parse(document.getElementById('chart-data').textContent);
const rootStyle = getComputedStyle(document.documentElement);
const token = (name, fallback) => rootStyle.getPropertyValue(name).trim() || fallback;
// 與 design system page-group=analytics 對齊的暖色 palette
const chartPalette = {
caramel: 'rgba(201, 100, 66, 1)',
caramelSoft: 'rgba(201, 100, 66, 0.58)',
honey: 'rgba(184, 132, 22, 1)',
honeySoft: 'rgba(184, 132, 22, 0.58)',
rust: 'rgba(181, 52, 47, 1)',
rustSoft: 'rgba(181, 52, 47, 0.48)',
mahogany: 'rgba(143, 69, 48, 1)',
mahoganySoft: 'rgba(143, 69, 48, 0.12)'
caramel: token('--momo-page-chart-2', '#c96442'),
caramelSoft: token('--momo-warm-caramel-soft', 'rgba(201, 100, 66, 0.58)'),
honey: token('--momo-page-accent', '#c89043'),
honeySoft: token('--momo-page-accent-soft', 'rgba(200, 144, 67, 0.14)'),
rust: token('--momo-danger-text', '#7a3210'),
rustSoft: token('--momo-danger-bg', '#efd3c4')
};
Chart.defaults.color = '#6f665a';
Chart.defaults.borderColor = 'rgba(126, 111, 92, 0.18)';
Chart.defaults.font.family = "'Noto Sans TC', 'Inter', system-ui, sans-serif";
Chart.defaults.color = token('--momo-text-secondary', '#6b6155');
Chart.defaults.borderColor = token('--momo-border-light', 'rgba(42, 37, 32, 0.10)');
Chart.defaults.font.family = token('--momo-font-family', "'Inter', system-ui, sans-serif");
// 1) Revenue + YoY
new Chart(document.getElementById('revenueChart'), {
@@ -70,10 +70,10 @@
data: {
labels: data.labels,
datasets: [{
label: '平均單價 ($)',
label: '平均單價 ($)',
data: data.aov,
borderColor: chartPalette.caramel,
backgroundColor: 'rgba(201, 100, 66, 0.12)',
backgroundColor: chartPalette.caramelSoft,
fill: true, tension: 0.4
}]
},
@@ -92,7 +92,7 @@
label: '毛利率 (%)',
data: data.margin_rate,
borderColor: chartPalette.honey,
backgroundColor: 'rgba(184, 132, 22, 0.12)',
backgroundColor: chartPalette.honeySoft,
fill: true, tension: 0.4
}]
},