diff --git a/config.py b/config.py
index 2891aed..4c3657b 100644
--- a/config.py
+++ b/config.py
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.631"
+SYSTEM_VERSION = "V10.632"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示
diff --git a/routes/ai_routes.py b/routes/ai_routes.py
index 07ec296..e3cb3db 100644
--- a/routes/ai_routes.py
+++ b/routes/ai_routes.py
@@ -1,5 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+from __future__ import annotations
+
"""
AI 推薦路由模組
提供時事熱點商品推薦與文案生成功能
diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py
index 6081a02..176730a 100644
--- a/routes/dashboard_routes.py
+++ b/routes/dashboard_routes.py
@@ -1292,6 +1292,197 @@ def _load_competitor_decision_overview(session, latest_items=None):
return default
+def _load_pchome_growth_command_center(session):
+ """Build the first-screen PChome revenue command center from real sales and comparison data."""
+ cache_key = 'pchome_growth_command_center'
+ cache_ts_key = 'pchome_growth_command_center_timestamp'
+ cached = _DASHBOARD_DATA_CACHE.get(cache_key)
+ cached_ts = _DASHBOARD_DATA_CACHE.get(cache_ts_key)
+ if cached and cached_ts and time.time() - cached_ts < 120:
+ return cached
+
+ default = {
+ 'success': False,
+ 'latest_sales_date': None,
+ 'sales_7d': 0,
+ 'sales_prev_7d': 0,
+ 'sales_delta_pct': None,
+ 'sales_delta_label': '待匯入',
+ 'sales_delta_tone': 'neutral',
+ 'sales_current_width': 0,
+ 'sales_prev_width': 0,
+ 'qty_7d': 0,
+ 'active_product_count': 0,
+ 'declining_product_count': 0,
+ 'top_category': '',
+ 'top_category_sales_7d': 0,
+ 'candidate_count': 0,
+ 'mapped_count': 0,
+ 'mapping_rate': 0,
+ 'mapping_rate_width': 0,
+ 'needs_mapping_count': 0,
+ 'opportunity_sales_7d': 0,
+ 'action_code_counts': {},
+ 'action_counts': {},
+ 'priority_tasks': [],
+ 'strategy_lanes': [],
+ 'top_opportunities': [],
+ 'message': 'PChome 業績作戰資料整理中',
+ }
+
+ try:
+ from services.pchome_revenue_growth_service import build_pchome_growth_opportunities
+
+ engine = session.get_bind()
+ payload = build_pchome_growth_opportunities(engine, limit=16)
+ stats = payload.get('stats') or {}
+ opportunities = payload.get('opportunities') or []
+ sales_7d = _to_float(stats.get('overall_sales_7d')) or 0
+ sales_prev_7d = _to_float(stats.get('overall_sales_prev_7d')) or 0
+ sales_delta_pct = stats.get('overall_sales_delta_pct')
+ sales_delta_value = _to_float(sales_delta_pct)
+ max_sales = max(sales_7d, sales_prev_7d, 1)
+ mapping_rate = _to_float(stats.get('mapping_rate')) or 0
+ action_code_counts = stats.get('action_code_counts') or {}
+
+ if sales_delta_value is None:
+ sales_delta_label = '前期不足'
+ sales_delta_tone = 'neutral'
+ elif sales_delta_value < -10:
+ sales_delta_label = f'較前 7 天 {sales_delta_value:.1f}%'
+ sales_delta_tone = 'danger'
+ elif sales_delta_value < 0:
+ sales_delta_label = f'較前 7 天 {sales_delta_value:.1f}%'
+ sales_delta_tone = 'warning'
+ else:
+ sales_delta_label = f'較前 7 天 +{sales_delta_value:.1f}%'
+ sales_delta_tone = 'success'
+
+ priority_tasks = []
+ needs_mapping = int(stats.get('needs_mapping_count') or 0)
+ if needs_mapping:
+ priority_tasks.append({
+ 'rank': 1,
+ 'tone': 'danger' if mapping_rate < 25 else 'warning',
+ 'title': f'先補 {needs_mapping} 個高業績商品對應',
+ 'metric': f'比價覆蓋 {mapping_rate:.1f}%',
+ 'action': 'backfill',
+ 'button': '啟動補抓',
+ })
+ review_price_count = int(action_code_counts.get('review_price_or_promo') or 0)
+ if review_price_count:
+ priority_tasks.append({
+ 'rank': len(priority_tasks) + 1,
+ 'tone': 'danger',
+ 'title': f'檢查 {review_price_count} 個 MOMO 低價壓力商品',
+ 'metric': '調價 / 券 / 組合',
+ 'action': 'price_review',
+ 'button': '看價格壓力',
+ })
+ amplify_count = int(action_code_counts.get('amplify_price_advantage') or 0)
+ if amplify_count:
+ priority_tasks.append({
+ 'rank': len(priority_tasks) + 1,
+ 'tone': 'success',
+ 'title': f'放大 {amplify_count} 個 PChome 價格優勢',
+ 'metric': '曝光 / 文案 / 主推',
+ 'action': 'ai_picks',
+ 'button': '看主推清單',
+ })
+ recover_count = int(action_code_counts.get('recover_sales_momentum') or 0)
+ if recover_count:
+ priority_tasks.append({
+ 'rank': len(priority_tasks) + 1,
+ 'tone': 'warning',
+ 'title': f'找回 {recover_count} 個下滑商品動能',
+ 'metric': '庫存 / 頁面 / 活動',
+ 'action': 'daily_sales',
+ 'button': '看業績',
+ })
+ if not priority_tasks:
+ priority_tasks.append({
+ 'rank': 1,
+ 'tone': 'neutral',
+ 'title': '先看今日高業績商品',
+ 'metric': '業績穩定,持續監控',
+ 'action': 'daily_sales',
+ 'button': '看業績',
+ })
+
+ strategy_lanes = [
+ {
+ 'key': 'price_pressure',
+ 'label': 'MOMO 更便宜',
+ 'value': review_price_count,
+ 'action': '檢查售價 / 券 / 組合',
+ 'tone': 'danger',
+ },
+ {
+ 'key': 'price_advantage',
+ 'label': 'PChome 有優勢',
+ 'value': amplify_count,
+ 'action': '拉曝光 / 強化文案',
+ 'tone': 'success',
+ },
+ {
+ 'key': 'bundle',
+ 'label': '單品 / 組合待判斷',
+ 'value': sum(1 for item in opportunities if (item.get('external_price') or {}).get('price_basis') == 'unit_price'),
+ 'action': '看單位價,決定組合包',
+ 'tone': 'warning',
+ },
+ {
+ 'key': 'mapping',
+ 'label': '找不到同款',
+ 'value': needs_mapping,
+ 'action': '補抓 MOMO 候選',
+ 'tone': 'neutral',
+ },
+ ]
+
+ command_center = dict(default)
+ command_center.update({
+ 'success': bool(payload.get('success', True)),
+ 'latest_sales_date': stats.get('overall_latest_sales_date') or stats.get('latest_sales_date'),
+ 'sales_7d': sales_7d,
+ 'sales_prev_7d': sales_prev_7d,
+ 'sales_delta_pct': sales_delta_value,
+ 'sales_delta_label': sales_delta_label,
+ 'sales_delta_tone': sales_delta_tone,
+ 'sales_current_width': round(sales_7d / max_sales * 100, 1),
+ 'sales_prev_width': round(sales_prev_7d / max_sales * 100, 1),
+ 'qty_7d': _to_float(stats.get('overall_qty_7d')) or 0,
+ 'active_product_count': int(stats.get('active_product_count') or 0),
+ 'declining_product_count': int(stats.get('declining_product_count') or 0),
+ 'top_category': stats.get('top_category') or '',
+ 'top_category_sales_7d': _to_float(stats.get('top_category_sales_7d')) or 0,
+ 'candidate_count': int(stats.get('candidate_count') or 0),
+ 'mapped_count': int(stats.get('mapped_count') or 0),
+ 'mapping_rate': round(mapping_rate, 1),
+ 'mapping_rate_width': round(max(0, min(100, mapping_rate)), 1),
+ 'needs_mapping_count': needs_mapping,
+ 'opportunity_sales_7d': _to_float(stats.get('opportunity_sales_7d') or stats.get('total_sales_7d')) or 0,
+ 'action_code_counts': action_code_counts,
+ 'action_counts': stats.get('action_counts') or {},
+ 'priority_tasks': priority_tasks[:4],
+ 'strategy_lanes': strategy_lanes,
+ 'top_opportunities': opportunities[:6],
+ 'message': payload.get('message') or default['message'],
+ })
+ _DASHBOARD_DATA_CACHE[cache_key] = command_center
+ _DASHBOARD_DATA_CACHE[cache_ts_key] = time.time()
+ return command_center
+ except Exception as exc:
+ sys_log.warning(f"[Dashboard] PChome 業績作戰台讀取略過: {exc}")
+ try:
+ session.rollback()
+ except Exception:
+ pass
+ _DASHBOARD_DATA_CACHE[cache_key] = default
+ _DASHBOARD_DATA_CACHE[cache_ts_key] = time.time()
+ return default
+
+
def _load_ai_pick_selection(session, limit=PRODUCT_PICK_LIST_LIMIT):
"""讀取商品看板 AI 挑品清單排序,供列表篩選使用。"""
sql = text("""
@@ -1913,6 +2104,7 @@ def _render_pchome_review_dashboard(
review_status_options = _build_review_status_options(competitor_overview)
total_pages = math.ceil(review_queue_total / per_page) if review_queue_total else 0
total_products_history = int(competitor_overview.get('total_active') or 0)
+ pchome_growth_command_center = _load_pchome_growth_command_center(session)
return render_template(
'dashboard_v2.html',
@@ -1953,6 +2145,7 @@ def _render_pchome_review_dashboard(
most_active_category=None,
most_active_count=0,
competitor_overview=competitor_overview,
+ pchome_growth_command_center=pchome_growth_command_center,
ai_pick_list_limit=PRODUCT_PICK_LIST_LIMIT,
build_momo_product_url=_build_momo_product_url,
active_page='dashboard',
@@ -2689,6 +2882,7 @@ def index():
_DASHBOARD_DATA_CACHE['full_data'] = data
_write_shared_full_dashboard_cache(data)
review_status_options = _build_review_status_options(competitor_overview)
+ pchome_growth_command_center = _load_pchome_growth_command_center(session)
template_name = 'dashboard_v2.html'
return render_template(template_name,
@@ -2728,6 +2922,7 @@ def index():
most_active_category=most_active_category,
most_active_count=most_active_count,
competitor_overview=competitor_overview,
+ pchome_growth_command_center=pchome_growth_command_center,
ai_pick_list_limit=PRODUCT_PICK_LIST_LIMIT,
build_momo_product_url=_build_momo_product_url,
active_page='dashboard')
diff --git a/services/momo_crawler.py b/services/momo_crawler.py
index 3baa54a..d919add 100644
--- a/services/momo_crawler.py
+++ b/services/momo_crawler.py
@@ -10,6 +10,8 @@ API 參考:
- 商品 API: https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=XXX
"""
+from __future__ import annotations
+
import re
import json
import time
diff --git a/services/pchome_revenue_growth_service.py b/services/pchome_revenue_growth_service.py
index bfb23f0..3949c72 100644
--- a/services/pchome_revenue_growth_service.py
+++ b/services/pchome_revenue_growth_service.py
@@ -183,6 +183,114 @@ def _fetch_sales_rows(conn, limit: int) -> tuple[list[dict[str, Any]], str | Non
return mapped_rows, latest_date
+def _fetch_sales_summary(conn) -> dict[str, Any]:
+ cols = _daily_sales_columns(conn)
+ missing = [key for key in ["sku", "date", "revenue"] if not cols.get(key)]
+ if missing:
+ return {}
+
+ dialect = conn.dialect.name
+ sku_col = _quote_identifier(cols["sku"])
+ date_col = _quote_identifier(cols["date"])
+ revenue_expr = _numeric_expr(cols["revenue"], dialect)
+ qty_expr = _numeric_expr(cols["qty"], dialect) if cols.get("qty") else "0"
+ category_text = _as_text_expr(cols["category"], dialect) if cols.get("category") else "NULL"
+ sku_text = _as_text_expr(cols["sku"], dialect)
+
+ if dialect == "postgresql":
+ sale_date_expr = f"NULLIF({date_col}::text, '')::date"
+ curr_window = "lw.latest_date - INTERVAL '6 days'"
+ prev_window_start = "lw.latest_date - INTERVAL '13 days'"
+ prev_window_end = "lw.latest_date - INTERVAL '6 days'"
+ else:
+ sale_date_expr = f"date({date_col})"
+ curr_window = "date(lw.latest_date, '-6 days')"
+ prev_window_start = "date(lw.latest_date, '-13 days')"
+ prev_window_end = "date(lw.latest_date, '-6 days')"
+
+ row = conn.execute(text(f"""
+ WITH sales_rows AS (
+ SELECT
+ NULLIF(TRIM({sku_text}), '') AS pchome_product_id,
+ NULLIF(TRIM({_as_text_expr(category_text, dialect, raw=True)}), '') AS category,
+ {sale_date_expr} AS sale_date,
+ {revenue_expr} AS revenue,
+ {qty_expr} AS qty
+ FROM daily_sales_snapshot
+ WHERE {sku_col} IS NOT NULL
+ ),
+ latest_window AS (
+ SELECT MAX(sale_date) AS latest_date
+ FROM sales_rows
+ WHERE sale_date IS NOT NULL
+ ),
+ per_product AS (
+ SELECT
+ sr.pchome_product_id,
+ MAX(sr.category) AS category,
+ SUM(CASE WHEN sr.sale_date >= {curr_window}
+ THEN sr.revenue ELSE 0 END) AS sales_7d,
+ SUM(CASE WHEN sr.sale_date >= {prev_window_start}
+ AND sr.sale_date < {prev_window_end}
+ THEN sr.revenue ELSE 0 END) AS sales_prev_7d,
+ SUM(CASE WHEN sr.sale_date >= {curr_window}
+ THEN sr.qty ELSE 0 END) AS qty_7d,
+ MAX(lw.latest_date) AS latest_sales_date
+ FROM sales_rows sr
+ CROSS JOIN latest_window lw
+ WHERE sr.pchome_product_id IS NOT NULL
+ GROUP BY sr.pchome_product_id
+ ),
+ category_sales AS (
+ SELECT
+ COALESCE(NULLIF(category, ''), '未分類') AS category,
+ SUM(sales_7d) AS sales_7d
+ FROM per_product
+ GROUP BY COALESCE(NULLIF(category, ''), '未分類')
+ )
+ SELECT
+ MAX(latest_sales_date) AS latest_sales_date,
+ COALESCE(SUM(sales_7d), 0) AS overall_sales_7d,
+ COALESCE(SUM(sales_prev_7d), 0) AS overall_sales_prev_7d,
+ COALESCE(SUM(qty_7d), 0) AS overall_qty_7d,
+ SUM(CASE WHEN sales_7d > 0 THEN 1 ELSE 0 END) AS active_product_count,
+ SUM(CASE WHEN sales_prev_7d > 0 AND sales_7d < sales_prev_7d THEN 1 ELSE 0 END) AS declining_product_count,
+ (
+ SELECT category
+ FROM category_sales
+ WHERE sales_7d > 0
+ ORDER BY sales_7d DESC
+ LIMIT 1
+ ) AS top_category,
+ (
+ SELECT COALESCE(sales_7d, 0)
+ FROM category_sales
+ WHERE sales_7d > 0
+ ORDER BY sales_7d DESC
+ LIMIT 1
+ ) AS top_category_sales_7d
+ FROM per_product
+ """)).mappings().first()
+
+ if not row:
+ return {}
+ current = _to_float(row.get("overall_sales_7d"))
+ previous = _to_float(row.get("overall_sales_prev_7d"))
+ delta_pct = ((current - previous) / previous * 100) if previous else None
+ latest_date = row.get("latest_sales_date")
+ return {
+ "overall_latest_sales_date": latest_date.isoformat() if hasattr(latest_date, "isoformat") else str(latest_date or ""),
+ "overall_sales_7d": round(current, 2),
+ "overall_sales_prev_7d": round(previous, 2),
+ "overall_sales_delta_pct": round(delta_pct, 1) if delta_pct is not None else None,
+ "overall_qty_7d": round(_to_float(row.get("overall_qty_7d")), 2),
+ "active_product_count": int(row.get("active_product_count") or 0),
+ "declining_product_count": int(row.get("declining_product_count") or 0),
+ "top_category": row.get("top_category") or "",
+ "top_category_sales_7d": round(_to_float(row.get("top_category_sales_7d")), 2),
+ }
+
+
def _json_dict(value: Any) -> dict[str, Any]:
if not value:
return {}
@@ -610,7 +718,31 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
}
with engine.connect() as conn:
- sales_rows, latest_sales_date = _fetch_sales_rows(conn, limit=limit)
+ sales_summary = _fetch_sales_summary(conn)
+ try:
+ sales_rows, latest_sales_date = _fetch_sales_rows(conn, limit=limit)
+ except RuntimeError as exc:
+ return {
+ "success": True,
+ "system_name": SYSTEM_DISPLAY_NAME,
+ "generated_at": generated_at,
+ "source_scope": source_scope,
+ "stats": {
+ "latest_sales_date": sales_summary.get("overall_latest_sales_date"),
+ "candidate_count": 0,
+ "mapped_count": 0,
+ "mapping_rate": 0,
+ "needs_mapping_count": 0,
+ "total_sales_7d": 0,
+ "opportunity_sales_7d": 0,
+ "action_counts": {},
+ "action_code_counts": {},
+ "external_data_source_counts": {},
+ **sales_summary,
+ },
+ "opportunities": [],
+ "message": str(exc),
+ }
sales_ids = [str(row.get("pchome_product_id") or "") for row in sales_rows]
external_map = _fetch_external_price_map(conn, sales_ids)
@@ -625,10 +757,13 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
mapped_count = len(opportunities) - needs_mapping_count
mapping_rate = round(mapped_count / max(len(opportunities), 1) * 100, 1)
action_counts: dict[str, int] = {}
+ action_code_counts: dict[str, int] = {}
external_data_source_counts: dict[str, int] = {}
for item in opportunities:
label = item["recommended_action"]["label"]
+ code = item["recommended_action"]["code"]
action_counts[label] = action_counts.get(label, 0) + 1
+ action_code_counts[code] = action_code_counts.get(code, 0) + 1
external_price = item.get("external_price") or {}
data_source_label = external_price.get("data_source_label")
if data_source_label:
@@ -642,14 +777,17 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
"generated_at": generated_at,
"source_scope": source_scope,
"stats": {
- "latest_sales_date": latest_sales_date,
+ "latest_sales_date": latest_sales_date or sales_summary.get("overall_latest_sales_date"),
"candidate_count": len(opportunities),
"mapped_count": mapped_count,
"mapping_rate": mapping_rate,
"needs_mapping_count": needs_mapping_count,
"total_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2),
+ "opportunity_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2),
"action_counts": action_counts,
+ "action_code_counts": action_code_counts,
"external_data_source_counts": external_data_source_counts,
+ **sales_summary,
},
"opportunities": opportunities,
"message": "已整理今日 PChome 業績成長作戰清單。",
diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html
index cf2d626..e4ef66d 100644
--- a/templates/dashboard_v2.html
+++ b/templates/dashboard_v2.html
@@ -9,6 +9,171 @@
{% block ewooo_content %}
{% set overview = competitor_overview | default({}) %}
+ {% set growth = pchome_growth_command_center | default({}) %}
+
+
+
+
+ 00
+ PChome 業績成長作戰台
+ 業績日 {{ growth.latest_sales_date or '待匯入' }}
+
+
先看業績,再決定調價、曝光與組合
+
+
+ 近 7 天
+ {{ growth.sales_delta_label | default('待匯入') }}
+
+
+
+
+
+
PChome 近 7 天業績
+
NT$ {{ growth.sales_7d | default(0) | int | number_format }}
+
+
+ 近 7 天
+
+
+
+ 前 7 天
+
+
+
+
+
+ 下滑商品
+ {{ growth.declining_product_count | default(0) | number_format }}
+ {{ growth.active_product_count | default(0) | number_format }} 個有銷售商品
+
+
+
比價可用率
+
{{ growth.mapping_rate | default(0) }}%
+
+
{{ growth.mapped_count | default(0) }} / {{ growth.candidate_count | default(0) }} 個高業績商品已對應
+
+
+ 最大業績分類
+ {{ growth.top_category or '待分類' }}
+ NT$ {{ growth.top_category_sales_7d | default(0) | int | number_format }}
+
+
+
+
+
+
+
+
+ MOMO 比價後怎麼做
+ 直接轉成銷售策略
+
+
+ {% for lane in growth.strategy_lanes | default([]) %}
+
+ {{ lane.label }}
+ {{ lane.value | default(0) | number_format }}
+ {{ lane.action }}
+
+ {% endfor %}
+
+
+
+
+
+
+ 高業績商品作戰清單
+ 業績 × MOMO 價格 × 下一步
+
+
+
+
+
+ | 商品 |
+ 近 7 天業績 |
+ 趨勢 |
+ MOMO 比價 |
+ 下一步 |
+
+
+
+ {% for item in growth.top_opportunities | default([]) %}
+ {% set external = item.external_price or {} %}
+
+ |
+ {{ item.product_name }}
+ {{ item.pchome_product_id }}
+ |
+ NT$ {{ item.sales_7d | default(0) | int | number_format }} |
+
+ {% if item.sales_delta_pct is not none %}
+
+ {{ '%+.1f'|format(item.sales_delta_pct) }}%
+
+ {% else %}
+ 新基準
+ {% endif %}
+ |
+
+ {% if external %}
+
+ {{ external.price_basis_label or '商品總價' }}
+ {% if external.gap_pct is not none %} {{ '%+.1f'|format(external.gap_pct) }}%{% endif %}
+
+ {% else %}
+ 未配對
+ {% endif %}
+ |
+ {{ item.recommended_action.label }} |
+
+ {% else %}
+
+ | 尚未產生成長作戰清單,請先完成 PChome 業績匯入。 |
+
+ {% endfor %}
+
+
+
+
+
+
01
diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py
index 3c115ff..aed2d59 100644
--- a/tests/test_frontend_v2_assets.py
+++ b/tests/test_frontend_v2_assets.py
@@ -244,6 +244,9 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
assert "warm_full_dashboard_cache" in route_source
assert "force_rebuild=False" in route_source
assert "def _load_competitor_decision_overview(session, latest_items=None)" in route_source
+ assert "def _load_pchome_growth_command_center(session)" in route_source
+ assert "build_pchome_growth_opportunities(engine, limit=16)" in route_source
+ assert "pchome_growth_command_center=pchome_growth_command_center" in route_source
assert "fetch_competitor_review_queue" in route_source
assert "fetch_competitor_review_queue_page" in route_source
assert "_load_competitor_review_page(" in route_source
@@ -295,6 +298,14 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
assert "多款任選待確認" in route_source
assert "MockRecord" not in route_source
assert "{% for item in items %}" in dashboard
+ assert "PChome 業績成長作戰台" in dashboard
+ assert "先看業績,再決定調價、曝光與組合" in dashboard
+ assert "growth-command-kpis" in dashboard
+ assert "MOMO 比價後怎麼做" in dashboard
+ assert "高業績商品作戰清單" in dashboard
+ assert "業績 × MOMO 價格 × 下一步" in dashboard
+ assert "growth.mapping_rate" in dashboard
+ assert "growth.top_opportunities" in dashboard
assert "比價監控總覽" in dashboard
assert "決策支援覆蓋率" in dashboard
assert "overview.decision_support_rate" in dashboard
@@ -313,6 +324,9 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
assert "review_status='catalog_identity_review'" in dashboard
assert "身份採用待核" in dashboard
assert "grid-template-columns: repeat(5, minmax(0, 1fr))" in dashboard_css
+ assert ".growth-command-center" in dashboard_css
+ assert ".growth-strategy-grid" in dashboard_css
+ assert ".growth-opportunity-table" in dashboard_css
assert "{% if review_total_is_estimated %}約 {% endif %}" in dashboard
assert "filter='ai_picks'" in dashboard
assert "filter='pchome_review'" in dashboard
diff --git a/web/static/css/page-dashboard-v2.css b/web/static/css/page-dashboard-v2.css
index af83c1a..354a40a 100644
--- a/web/static/css/page-dashboard-v2.css
+++ b/web/static/css/page-dashboard-v2.css
@@ -6,6 +6,400 @@
max-width: 100%;
}
+ .growth-command-center {
+ display: grid;
+ gap: 14px;
+ min-width: 0;
+ }
+
+ .growth-command-head {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 16px;
+ min-width: 0;
+ padding: 18px 20px;
+ background: var(--momo-bg-surface);
+ border: 1px solid var(--momo-border-light);
+ border-radius: 8px;
+ }
+
+ .growth-command-title {
+ margin: 6px 0 0;
+ color: var(--momo-text-primary);
+ font-family: var(--momo-font-display);
+ font-size: clamp(1.35rem, 2vw, 2rem);
+ font-weight: 900;
+ letter-spacing: 0;
+ line-height: 1.16;
+ }
+
+ .growth-command-status {
+ display: grid;
+ gap: 4px;
+ min-width: 168px;
+ padding: 12px 14px;
+ background: color-mix(in srgb, var(--momo-bg-paper) 88%, transparent);
+ border: 1px solid var(--momo-border-light);
+ border-radius: 8px;
+ text-align: right;
+ }
+
+ .growth-command-status span,
+ .growth-kpi-label,
+ .growth-panel-head span,
+ .growth-product-id {
+ color: var(--momo-text-tertiary);
+ font-size: 10px;
+ font-weight: 900;
+ letter-spacing: 0.06em;
+ }
+
+ .growth-command-status strong {
+ color: var(--momo-text-primary);
+ font-family: var(--momo-font-mono);
+ font-size: 1rem;
+ font-weight: 900;
+ }
+
+ .growth-command-status.is-danger strong {
+ color: var(--momo-danger);
+ }
+
+ .growth-command-status.is-warning strong {
+ color: var(--momo-warning-text);
+ }
+
+ .growth-command-status.is-success strong {
+ color: var(--momo-success);
+ }
+
+ .growth-command-kpis {
+ display: grid;
+ grid-template-columns: 1.6fr repeat(3, minmax(0, 1fr));
+ gap: 10px;
+ min-width: 0;
+ }
+
+ .growth-command-kpi,
+ .growth-priority-panel,
+ .growth-strategy-panel,
+ .growth-opportunity-panel {
+ min-width: 0;
+ background: var(--momo-bg-surface);
+ border: 1px solid var(--momo-border-light);
+ border-radius: 8px;
+ box-shadow: var(--momo-shadow-soft);
+ }
+
+ .growth-command-kpi {
+ display: grid;
+ align-content: start;
+ gap: 8px;
+ min-height: 132px;
+ padding: 15px;
+ }
+
+ .growth-kpi-value {
+ overflow-wrap: anywhere;
+ color: var(--momo-text-primary);
+ font-size: 1.82rem;
+ font-weight: 900;
+ letter-spacing: 0;
+ line-height: 1.05;
+ }
+
+ .growth-kpi-value.is-text {
+ font-family: var(--momo-font-display);
+ font-size: 1.1rem;
+ line-height: 1.25;
+ }
+
+ .growth-command-kpi em,
+ .growth-task em,
+ .growth-strategy-lane em {
+ color: var(--momo-text-secondary);
+ font-size: 11px;
+ font-style: normal;
+ font-weight: 800;
+ line-height: 1.35;
+ }
+
+ .growth-bar-stack {
+ display: grid;
+ gap: 7px;
+ margin-top: 4px;
+ }
+
+ .growth-bar-row {
+ display: grid;
+ grid-template-columns: 58px minmax(0, 1fr);
+ gap: 8px;
+ align-items: center;
+ color: var(--momo-text-secondary);
+ font-size: 11px;
+ font-weight: 800;
+ }
+
+ .growth-bar-row i,
+ .growth-progress {
+ display: block;
+ overflow: hidden;
+ height: 9px;
+ background: color-mix(in srgb, var(--momo-border-light) 70%, transparent);
+ border-radius: 999px;
+ }
+
+ .growth-bar-row b,
+ .growth-progress span {
+ display: block;
+ height: 100%;
+ min-width: 2px;
+ background: var(--momo-warm-caramel);
+ border-radius: inherit;
+ }
+
+ .growth-bar-row.is-muted b {
+ background: color-mix(in srgb, var(--momo-ink) 42%, var(--momo-border-light));
+ }
+
+ .growth-command-grid {
+ display: grid;
+ grid-template-columns: minmax(320px, 0.92fr) minmax(0, 1.35fr);
+ gap: 12px;
+ min-width: 0;
+ }
+
+ .growth-priority-panel,
+ .growth-strategy-panel,
+ .growth-opportunity-panel {
+ padding: 14px;
+ }
+
+ .growth-panel-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ align-items: center;
+ margin-bottom: 10px;
+ }
+
+ .growth-panel-head strong {
+ color: var(--momo-text-primary);
+ font-size: 14px;
+ font-weight: 900;
+ }
+
+ .growth-task-list {
+ display: grid;
+ gap: 8px;
+ }
+
+ .growth-task {
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr) auto;
+ gap: 8px 10px;
+ align-items: center;
+ min-height: 66px;
+ width: 100%;
+ padding: 10px 12px;
+ color: var(--momo-text-primary);
+ background: color-mix(in srgb, var(--momo-bg-paper) 78%, transparent);
+ border: 1px solid var(--momo-border-light);
+ border-radius: 8px;
+ text-align: left;
+ text-decoration: none;
+ }
+
+ button.growth-task {
+ cursor: pointer;
+ }
+
+ .growth-task:hover,
+ .growth-task:focus {
+ color: var(--momo-text-primary);
+ border-color: color-mix(in srgb, var(--momo-warm-rust) 34%, var(--momo-border-light));
+ background: var(--momo-bg-surface);
+ }
+
+ .growth-task span {
+ grid-row: span 2;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ color: var(--momo-warm-rust);
+ background: rgba(172, 92, 58, 0.12);
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 900;
+ }
+
+ .growth-task strong {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--momo-text-primary);
+ font-size: 13px;
+ font-weight: 900;
+ line-height: 1.25;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .growth-task b {
+ grid-row: span 2;
+ align-self: center;
+ padding: 7px 10px;
+ color: var(--momo-text-primary);
+ background: var(--momo-bg-surface);
+ border: 1px solid var(--momo-border-light);
+ border-radius: 7px;
+ font-size: 11px;
+ font-weight: 900;
+ white-space: nowrap;
+ }
+
+ .growth-task.is-danger {
+ box-shadow: inset 3px 0 0 var(--momo-danger);
+ }
+
+ .growth-task.is-warning {
+ box-shadow: inset 3px 0 0 var(--momo-warning);
+ }
+
+ .growth-task.is-success {
+ box-shadow: inset 3px 0 0 var(--momo-success);
+ }
+
+ .growth-strategy-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+ }
+
+ .growth-strategy-lane {
+ display: grid;
+ gap: 7px;
+ min-height: 118px;
+ padding: 12px;
+ background: color-mix(in srgb, var(--momo-bg-paper) 76%, transparent);
+ border: 1px solid var(--momo-border-light);
+ border-radius: 8px;
+ }
+
+ .growth-strategy-lane span {
+ color: var(--momo-text-secondary);
+ font-size: 12px;
+ font-weight: 900;
+ }
+
+ .growth-strategy-lane strong {
+ color: var(--momo-text-primary);
+ font-size: 1.72rem;
+ font-weight: 900;
+ line-height: 1;
+ }
+
+ .growth-strategy-lane.is-danger {
+ border-color: rgba(188, 75, 49, 0.32);
+ background: rgba(255, 244, 239, 0.72);
+ }
+
+ .growth-strategy-lane.is-success {
+ border-color: rgba(48, 133, 94, 0.24);
+ background: rgba(235, 248, 241, 0.72);
+ }
+
+ .growth-strategy-lane.is-warning {
+ border-color: rgba(210, 158, 58, 0.34);
+ background: rgba(255, 248, 231, 0.72);
+ }
+
+ .growth-opportunity-table-wrap {
+ overflow-x: auto;
+ }
+
+ .growth-opportunity-table {
+ width: 100%;
+ min-width: 820px;
+ border-collapse: collapse;
+ }
+
+ .growth-opportunity-table th,
+ .growth-opportunity-table td {
+ padding: 11px 10px;
+ border-bottom: 1px solid var(--momo-border-light);
+ vertical-align: middle;
+ }
+
+ .growth-opportunity-table th {
+ color: var(--momo-text-tertiary);
+ font-size: 11px;
+ font-weight: 900;
+ text-align: left;
+ }
+
+ .growth-product-name {
+ display: block;
+ max-width: 420px;
+ overflow: hidden;
+ color: var(--momo-text-primary);
+ font-size: 13px;
+ font-weight: 900;
+ text-decoration: none;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .growth-product-id {
+ display: block;
+ margin-top: 3px;
+ }
+
+ .growth-delta,
+ .growth-compare-pill,
+ .growth-action-chip {
+ display: inline-flex;
+ align-items: center;
+ min-height: 26px;
+ padding: 5px 8px;
+ border: 1px solid var(--momo-border-light);
+ border-radius: 999px;
+ background: var(--momo-bg-paper);
+ color: var(--momo-text-primary);
+ font-size: 11px;
+ font-weight: 900;
+ white-space: nowrap;
+ }
+
+ .growth-delta.is-down,
+ .growth-compare-pill.is-risk {
+ border-color: rgba(188, 75, 49, 0.28);
+ background: rgba(255, 244, 239, 0.86);
+ color: var(--momo-danger);
+ }
+
+ .growth-delta.is-up,
+ .growth-compare-pill.is-win {
+ border-color: rgba(48, 133, 94, 0.24);
+ background: rgba(235, 248, 241, 0.86);
+ color: var(--momo-success);
+ }
+
+ .growth-compare-pill.is-missing {
+ border-color: rgba(210, 158, 58, 0.32);
+ background: rgba(255, 248, 231, 0.9);
+ color: var(--momo-warning-text);
+ }
+
+ .growth-empty {
+ color: var(--momo-text-secondary);
+ font-size: 13px;
+ font-weight: 800;
+ text-align: center;
+ }
+
.dashboard-v2-stack > section,
.dashboard-filter-card,
.dashboard-table-card,
@@ -1505,6 +1899,28 @@
}
@media (max-width: 980px) {
+ .growth-command-head {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .growth-command-status {
+ min-width: 0;
+ text-align: left;
+ }
+
+ .growth-command-kpis {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .growth-command-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .growth-strategy-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
.dashboard-kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -1536,9 +1952,36 @@
}
@media (max-width: 640px) {
+ .growth-command-kpis,
+ .growth-strategy-grid,
.dashboard-kpi-grid,
.dashboard-ai-summary-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-columns: 1fr;
+ }
+
+ .growth-command-head,
+ .growth-priority-panel,
+ .growth-strategy-panel,
+ .growth-opportunity-panel {
+ padding: 12px;
+ }
+
+ .growth-command-title {
+ font-size: 1.28rem;
+ }
+
+ .growth-task {
+ grid-template-columns: 32px minmax(0, 1fr);
+ }
+
+ .growth-task b {
+ grid-column: 2;
+ grid-row: auto;
+ width: fit-content;
+ }
+
+ .growth-task strong {
+ white-space: normal;
}
.dashboard-focus-grid {
diff --git a/web/static/js/page-dashboard-v2.js b/web/static/js/page-dashboard-v2.js
index 389a564..00ae54e 100644
--- a/web/static/js/page-dashboard-v2.js
+++ b/web/static/js/page-dashboard-v2.js
@@ -281,13 +281,19 @@ let priceChartInstance = null;
});
let pchomeBackfillPollTimer = null;
+ const DEFAULT_PCHOME_BACKFILL_LABEL = '補強 60 筆';
+ const DEFAULT_PCHOME_REFRESH_STALE_LABEL = '刷新過期 120 筆';
function getPchomeBackfillElements() {
const card = document.querySelector('[data-pchome-backfill-card]');
+ const triggers = Array.from(document.querySelectorAll('[data-pchome-backfill-trigger]'));
+ const refreshStaleTriggers = Array.from(document.querySelectorAll('[data-pchome-refresh-stale-trigger]'));
return {
card,
- trigger: document.querySelector('[data-pchome-backfill-trigger]'),
- refreshStaleTrigger: document.querySelector('[data-pchome-refresh-stale-trigger]'),
+ trigger: triggers[0],
+ triggers,
+ refreshStaleTrigger: refreshStaleTriggers[0],
+ refreshStaleTriggers,
status: document.querySelector('[data-pchome-backfill-status]'),
result: document.querySelector('[data-pchome-backfill-result]'),
progress: document.querySelector('[data-pchome-backfill-progress]'),
@@ -407,20 +413,22 @@ let priceChartInstance = null;
: (coverageSummary || '尚無最近結果');
}
}
- if (elements.trigger) {
- elements.trigger.disabled = running;
- elements.trigger.classList.toggle('is-loading', running);
- elements.trigger.innerHTML = running
+ elements.triggers.forEach(trigger => {
+ const limit = Number(trigger.dataset.limit || 60);
+ trigger.disabled = running;
+ trigger.classList.toggle('is-loading', running);
+ trigger.innerHTML = running
? ' 執行中'
- : ' 補強 60 筆';
- }
- if (elements.refreshStaleTrigger) {
- elements.refreshStaleTrigger.disabled = running;
- elements.refreshStaleTrigger.classList.toggle('is-loading', running);
- elements.refreshStaleTrigger.innerHTML = running
+ : ` 補強 ${limit} 筆`;
+ });
+ elements.refreshStaleTriggers.forEach(trigger => {
+ const limit = Number(trigger.dataset.limit || 120);
+ trigger.disabled = running;
+ trigger.classList.toggle('is-loading', running);
+ trigger.innerHTML = running
? ' 執行中'
- : ' 刷新過期 120 筆';
- }
+ : ` 刷新過期 ${limit} 筆`;
+ });
if (running) {
schedulePchomeBackfillPoll();
@@ -446,13 +454,14 @@ let priceChartInstance = null;
});
}
- function backfillPchomeMatches() {
+ function backfillPchomeMatches(activeTrigger) {
const elements = getPchomeBackfillElements();
if (!elements.card || !elements.trigger) return;
- const limit = Number(elements.trigger.dataset.limit || 60);
+ const trigger = activeTrigger && activeTrigger.dataset ? activeTrigger : elements.trigger;
+ const limit = Number(trigger.dataset.limit || 60);
if (!confirm(`啟動 PChome 比價補強 ${limit} 筆?會先刷新舊 identity,再重評近門檻與補抓未配對商品。`)) return;
- elements.trigger.disabled = true;
+ trigger.disabled = true;
if (elements.status) {
elements.status.textContent = '正在送出比價補強任務';
}
@@ -476,19 +485,18 @@ let priceChartInstance = null;
if (elements.status) {
elements.status.textContent = error.message || 'PChome 比價補強啟動失敗';
}
- if (elements.trigger) {
- elements.trigger.disabled = false;
- }
+ trigger.disabled = false;
});
}
- function refreshStalePchomeMatches() {
+ function refreshStalePchomeMatches(activeTrigger) {
const elements = getPchomeBackfillElements();
if (!elements.card || !elements.refreshStaleTrigger) return;
- const limit = Number(elements.refreshStaleTrigger.dataset.limit || 120);
+ const trigger = activeTrigger && activeTrigger.dataset ? activeTrigger : elements.refreshStaleTrigger;
+ const limit = Number(trigger.dataset.limit || 120);
if (!confirm(`啟動 PChome 過期價格刷新 ${limit} 筆?`)) return;
- elements.refreshStaleTrigger.disabled = true;
+ trigger.disabled = true;
if (elements.status) {
elements.status.textContent = '正在送出過期價格刷新任務';
}
@@ -512,19 +520,17 @@ let priceChartInstance = null;
if (elements.status) {
elements.status.textContent = error.message || 'PChome 過期價格刷新啟動失敗';
}
- if (elements.refreshStaleTrigger) {
- elements.refreshStaleTrigger.disabled = false;
- }
+ trigger.disabled = false;
});
}
window.backfillPchomeMatches = backfillPchomeMatches;
window.refreshStalePchomeMatches = refreshStalePchomeMatches;
document.querySelectorAll('[data-pchome-backfill-trigger]').forEach(button => {
- button.addEventListener('click', backfillPchomeMatches);
+ button.addEventListener('click', () => backfillPchomeMatches(button));
});
document.querySelectorAll('[data-pchome-refresh-stale-trigger]').forEach(button => {
- button.addEventListener('click', refreshStalePchomeMatches);
+ button.addEventListener('click', () => refreshStalePchomeMatches(button));
});
loadPchomeBackfillStatus();