feat: add pchome growth command center
All checks were successful
CD Pipeline / deploy (push) Successful in 1m10s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m10s
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
AI 推薦路由模組
|
||||
提供時事熱點商品推薦與文案生成功能
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 業績成長作戰清單。",
|
||||
|
||||
@@ -9,6 +9,171 @@
|
||||
{% block ewooo_content %}
|
||||
<div class="dashboard-v2-stack">
|
||||
{% set overview = competitor_overview | default({}) %}
|
||||
{% set growth = pchome_growth_command_center | default({}) %}
|
||||
<section class="growth-command-center" aria-label="PChome 業績成長作戰台">
|
||||
<div class="growth-command-head">
|
||||
<div>
|
||||
<div class="dashboard-section-label">
|
||||
<span class="num momo-mono">00</span>
|
||||
<span class="title">PChome 業績成長作戰台</span>
|
||||
<span class="meta momo-mono">業績日 {{ growth.latest_sales_date or '待匯入' }}</span>
|
||||
</div>
|
||||
<h1 class="growth-command-title">先看業績,再決定調價、曝光與組合</h1>
|
||||
</div>
|
||||
<div class="growth-command-status is-{{ growth.sales_delta_tone | default('neutral') }}">
|
||||
<span>近 7 天</span>
|
||||
<strong>{{ growth.sales_delta_label | default('待匯入') }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="growth-command-kpis">
|
||||
<div class="growth-command-kpi is-sales">
|
||||
<span class="growth-kpi-label">PChome 近 7 天業績</span>
|
||||
<strong class="growth-kpi-value momo-mono">NT$ {{ growth.sales_7d | default(0) | int | number_format }}</strong>
|
||||
<div class="growth-bar-stack" aria-label="近 7 天與前 7 天業績比較">
|
||||
<div class="growth-bar-row">
|
||||
<span>近 7 天</span>
|
||||
<i><b style="width: {{ growth.sales_current_width | default(0) }}%"></b></i>
|
||||
</div>
|
||||
<div class="growth-bar-row is-muted">
|
||||
<span>前 7 天</span>
|
||||
<i><b style="width: {{ growth.sales_prev_width | default(0) }}%"></b></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="growth-command-kpi">
|
||||
<span class="growth-kpi-label">下滑商品</span>
|
||||
<strong class="growth-kpi-value momo-mono">{{ growth.declining_product_count | default(0) | number_format }}</strong>
|
||||
<em>{{ growth.active_product_count | default(0) | number_format }} 個有銷售商品</em>
|
||||
</div>
|
||||
<div class="growth-command-kpi">
|
||||
<span class="growth-kpi-label">比價可用率</span>
|
||||
<strong class="growth-kpi-value momo-mono">{{ growth.mapping_rate | default(0) }}%</strong>
|
||||
<div class="growth-progress"><span style="width: {{ growth.mapping_rate_width | default(0) }}%"></span></div>
|
||||
<em>{{ growth.mapped_count | default(0) }} / {{ growth.candidate_count | default(0) }} 個高業績商品已對應</em>
|
||||
</div>
|
||||
<div class="growth-command-kpi">
|
||||
<span class="growth-kpi-label">最大業績分類</span>
|
||||
<strong class="growth-kpi-value is-text">{{ growth.top_category or '待分類' }}</strong>
|
||||
<em>NT$ {{ growth.top_category_sales_7d | default(0) | int | number_format }}</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="growth-command-grid">
|
||||
<div class="growth-priority-panel">
|
||||
<div class="growth-panel-head">
|
||||
<strong>今天先做</strong>
|
||||
<span>照順序處理</span>
|
||||
</div>
|
||||
<div class="growth-task-list">
|
||||
{% for task in growth.priority_tasks | default([]) %}
|
||||
{% if task.action == 'backfill' %}
|
||||
<button class="growth-task is-{{ task.tone | default('neutral') }}" type="button" data-pchome-backfill-trigger data-limit="80">
|
||||
<span class="momo-mono">{{ '%02d'|format(task.rank) }}</span>
|
||||
<strong>{{ task.title }}</strong>
|
||||
<em>{{ task.metric }}</em>
|
||||
<b>{{ task.button }}</b>
|
||||
</button>
|
||||
{% elif task.action == 'price_review' %}
|
||||
<a class="growth-task is-{{ task.tone | default('neutral') }}" href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, sort_by='pchome_review', order='desc') }}">
|
||||
<span class="momo-mono">{{ '%02d'|format(task.rank) }}</span>
|
||||
<strong>{{ task.title }}</strong>
|
||||
<em>{{ task.metric }}</em>
|
||||
<b>{{ task.button }}</b>
|
||||
</a>
|
||||
{% elif task.action == 'ai_picks' %}
|
||||
<a class="growth-task is-{{ task.tone | default('neutral') }}" href="{{ url_for('dashboard.index', filter='ai_picks', category=current_category, q=search_query, sort_by='timestamp', order='desc') }}">
|
||||
<span class="momo-mono">{{ '%02d'|format(task.rank) }}</span>
|
||||
<strong>{{ task.title }}</strong>
|
||||
<em>{{ task.metric }}</em>
|
||||
<b>{{ task.button }}</b>
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="growth-task is-{{ task.tone | default('neutral') }}" href="{{ url_for('daily_sales.daily_sales') }}">
|
||||
<span class="momo-mono">{{ '%02d'|format(task.rank) }}</span>
|
||||
<strong>{{ task.title }}</strong>
|
||||
<em>{{ task.metric }}</em>
|
||||
<b>{{ task.button }}</b>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="growth-strategy-panel">
|
||||
<div class="growth-panel-head">
|
||||
<strong>MOMO 比價後怎麼做</strong>
|
||||
<span>直接轉成銷售策略</span>
|
||||
</div>
|
||||
<div class="growth-strategy-grid">
|
||||
{% for lane in growth.strategy_lanes | default([]) %}
|
||||
<div class="growth-strategy-lane is-{{ lane.tone | default('neutral') }}">
|
||||
<span>{{ lane.label }}</span>
|
||||
<strong class="momo-mono">{{ lane.value | default(0) | number_format }}</strong>
|
||||
<em>{{ lane.action }}</em>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="growth-opportunity-panel">
|
||||
<div class="growth-panel-head">
|
||||
<strong>高業績商品作戰清單</strong>
|
||||
<span>業績 × MOMO 價格 × 下一步</span>
|
||||
</div>
|
||||
<div class="growth-opportunity-table-wrap">
|
||||
<table class="growth-opportunity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>商品</th>
|
||||
<th class="text-end">近 7 天業績</th>
|
||||
<th>趨勢</th>
|
||||
<th>MOMO 比價</th>
|
||||
<th>下一步</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in growth.top_opportunities | default([]) %}
|
||||
{% set external = item.external_price or {} %}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="growth-product-name" href="https://24h.pchome.com.tw/prod/{{ item.pchome_product_id }}" target="_blank" rel="noopener noreferrer">{{ item.product_name }}</a>
|
||||
<span class="growth-product-id momo-mono">{{ item.pchome_product_id }}</span>
|
||||
</td>
|
||||
<td class="text-end momo-mono">NT$ {{ item.sales_7d | default(0) | int | number_format }}</td>
|
||||
<td>
|
||||
{% if item.sales_delta_pct is not none %}
|
||||
<span class="growth-delta {% if item.sales_delta_pct < 0 %}is-down{% else %}is-up{% endif %}">
|
||||
{{ '%+.1f'|format(item.sales_delta_pct) }}%
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="growth-delta is-flat">新基準</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if external %}
|
||||
<span class="growth-compare-pill {% if external.gap_pct is not none and external.gap_pct < -5 %}is-risk{% elif external.gap_pct is not none and external.gap_pct > 5 %}is-win{% else %}is-even{% endif %}">
|
||||
{{ external.price_basis_label or '商品總價' }}
|
||||
{% if external.gap_pct is not none %} {{ '%+.1f'|format(external.gap_pct) }}%{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="growth-compare-pill is-missing">未配對</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="growth-action-chip">{{ item.recommended_action.label }}</span></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="growth-empty">尚未產生成長作戰清單,請先完成 PChome 業績匯入。</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="dashboard-section-label">
|
||||
<span class="num momo-mono">01</span>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
? '<i class="fas fa-spinner fa-spin"></i> 執行中'
|
||||
: '<i class="fas fa-search"></i> 補強 60 筆';
|
||||
}
|
||||
if (elements.refreshStaleTrigger) {
|
||||
elements.refreshStaleTrigger.disabled = running;
|
||||
elements.refreshStaleTrigger.classList.toggle('is-loading', running);
|
||||
elements.refreshStaleTrigger.innerHTML = running
|
||||
: `<i class="fas fa-search"></i> 補強 ${limit} 筆`;
|
||||
});
|
||||
elements.refreshStaleTriggers.forEach(trigger => {
|
||||
const limit = Number(trigger.dataset.limit || 120);
|
||||
trigger.disabled = running;
|
||||
trigger.classList.toggle('is-loading', running);
|
||||
trigger.innerHTML = running
|
||||
? '<i class="fas fa-spinner fa-spin"></i> 執行中'
|
||||
: '<i class="fas fa-rotate"></i> 刷新過期 120 筆';
|
||||
}
|
||||
: `<i class="fas fa-rotate"></i> 刷新過期 ${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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user