fix: professionalize marketplace product ux
This commit is contained in:
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.705"
|
||||
SYSTEM_VERSION = "V10.708"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# PChome 業績成長自動化作戰系統 — AI 競價情報模組 Single Source of Truth
|
||||
|
||||
> **最後更新**: 2026-06-18 (台北時間)
|
||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口、高可見頁面繁中化守門、比價/作戰 UI 工作台化、GCP embedding 熔斷延後處理、110 proxy rescue 與 direct host health skip 已建立
|
||||
> **適用版本**: V10.627
|
||||
> **最後更新**: 2026-06-26 (台北時間)
|
||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口、高可見頁面繁中化守門、比價/作戰 UI 工作台化、跨平台來源治理與商品身份 UI 契約已建立,GCP embedding 熔斷延後處理、110 proxy rescue 與 direct host health skip 已建立
|
||||
> **適用版本**: V10.708
|
||||
|
||||
---
|
||||
|
||||
@@ -55,7 +55,10 @@
|
||||
- 使用者可見 UI、Telegram 與報表文案必須白話、可行動,優先使用「商品對應」「可直接比價」「待補對應」「放大價格優勢」「檢查售價與活動」等營運語言,避免把 `identity_v2`、`match_score`、`candidate queue` 等工程詞直接丟給使用者。
|
||||
- `services/pchome_revenue_growth_service.py` 是第一版只讀作戰清單:讀 PChome 後台業績與已驗證 MOMO 外部價格參考,輸出 `/api/ai/pchome-growth/opportunities`。此服務不呼叫 LLM、不抓外站、不寫 DB。
|
||||
- 2026-06-15 只讀盤點確認:`daily_sales_snapshot."商品ID"` 與 `competitor_prices.competitor_product_id` 在正式資料中直接重疊為 0。因此第一版作戰清單不得硬接兩邊 ID;若沒有可驗證對應,只能輸出「先補商品對應」任務。
|
||||
- 蝦皮與酷澎暫停接入,不進作戰清單、不發告警;後續只可透過 official API / provider API / manual CSV 進 `external_offers` 類正規化層,並清楚標示資料品質。
|
||||
- 外部主流平台不得只限 PChome / MOMO 視角;MOMO 是已接入參考來源,Shopee、Lazada、Amazon、Google Merchant / Shopping、TikTok Shop、LINE 購物、Rakuten、Yahoo 購物、露天、品牌官網 / Shopify、Meta Commerce 與 Coupang 必須先納入來源治理契約。未取得合法穩定來源前只能顯示「待接入 / 不進告警」,不得假裝 AI 已監控或抓到促銷。
|
||||
- 所有外部平台資料進作戰清單前必須正規化為同一商品報價格式:平台商品 ID、商品名稱、賣場連結、商品圖、售價、促銷/券/活動、庫存、資料時間、取得方式、同款狀態、資料可信度。缺商品 ID、缺賣場連結、缺圖片時,UI 必須明確顯示待補狀態,不得留下空白、破圖或資料庫欄位名稱。
|
||||
- 商品清單、AI 挑品、比價覆核與待確認候選必須採商品身份優先 UI:縮圖、商品 ID、平台、賣場連結、價格/促銷狀態、可信度與下一步動作需在同一商品區塊內可一眼掃描;AI 建議不得只顯示長段理由,必須同時提供可開啟的賣場入口。
|
||||
- 蝦皮與酷澎等未接入來源暫停接入,不進作戰清單、不發告警;後續只可透過 official API / provider API / manual CSV 進 `external_offers` 類正規化層,並清楚標示資料品質。
|
||||
- V10.607 新增 `external_market_sources` / `external_offers` 正規化層與 `/api/ai/pchome-growth/source-contract` 只讀 API。MOMO 先以既有比價快取橋接進來源狀態;蝦皮與酷澎只保留 official API、provider API、manual CSV contract,預設暫停且不進告警。
|
||||
- V10.608 新增 `/api/ai/pchome-growth/external-offers/csv-dry-run` 與 AI 情報頁「外部報價預檢」。CSV 預檢只讀、不寫 DB;逐列回報「可使用」「需人工確認」「不能使用」,並支援中文表頭,避免格式小錯造成整批匯入失敗。
|
||||
- V10.609 明確把外部報價主路徑改為自動化:`run_external_offer_sync_task` 每 4 小時將已確認同款的既有比價快取同步進 `external_offers`。CSV 只保留為 API / crawler / provider 失敗時的備援預檢入口,不是日常營運主流程。
|
||||
@@ -784,3 +787,8 @@ POSTGRES_HOST=momo-db
|
||||
| 2026-06-26 | 全站 UI/UX 工作重點必須文件化並納入入口索引 | V10.704 起新增 `docs/guides/pchome_growth_ui_ux_guardrails.md` 並由 `AGENTS.md` 索引;所有前台頁面以「提升 PChome 業績、快速判斷、直接下一步」為共同目標,避免後續工作再偏回局部文案修補。 |
|
||||
| 2026-06-26 | 待確認商品必須能並排比較兩家賣場 | V10.705 起 `ai_intelligence` 與 `price_comparison` 的 MOMO 待確認候選都要以 PChome/MOMO 兩欄比較卡呈現,並提供「同時開兩家賣場」主要操作;不得只顯示候選摘要或只放單一平台連結。 |
|
||||
| 2026-06-26 | 外部促銷活動要進商業情報與 PChome 解法 | V10.705 起商業情報頁新增外部促銷活動監控,從 24h 外部價格/折扣訊號推導外部低價壓力或促銷訊號,並用守價、組合、曝光、會員四類 PChome 業績提升解法承接。 |
|
||||
| 2026-06-26 | 商品型 UI 必須顯示商品身份與賣場操作 | V10.706 起商品看板、AI 挑品與 MOMO 待確認候選需在主要商品區塊顯示商品圖、商品 ID、平台賣場連結、價格/可信度與下一步;缺圖需顯示「待補圖片」,不得留下破圖、空白或資料庫欄位名稱。 |
|
||||
| 2026-06-26 | 外部主流平台需先納入來源治理 | V10.706 起外部來源契約擴充到 Shopee、Lazada、Amazon、Google Merchant / Shopping、Rakuten、Yahoo 購物、Meta Commerce 與 Coupang;未接合法穩定來源前標示待接入且不進告警,避免假裝 AI 已完成監控。 |
|
||||
| 2026-06-26 | 外部來源視野不可停在少數平台 | V10.707 起外部來源契約再補 TikTok Shop、LINE 購物、露天、品牌官網 / Shopify;所有待接來源必須在 UI 顯示為待接入且不進告警,等官方 API、商品 feed、供應商 API 或人工 CSV 通過品質門檻後才可進作戰清單。 |
|
||||
| 2026-06-26 | 同版 CSS 修正必須跳版本破快取 | V10.707 起 UI 修正若影響 `web/static` 資產,必須同步提升 `SYSTEM_VERSION`,讓正式 HTML 的 `?v=` 參數改變;不得在同一版本號下修改 CSS 後宣稱使用者一定看得到。 |
|
||||
| 2026-06-26 | AI 挑品賣場操作必須固定可見 | V10.708 起 AI 挑品清單在桌面寬度固定「AI 建議 / 賣場操作」欄,橫向查看價格與更新欄時仍能直接開 MOMO / PChome 賣場;手機版維持卡片式堆疊。 |
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
4. 資料不足時不能整段消失,要顯示可理解的空狀態與下一步。
|
||||
5. 不得把工作視窗溝通、部署交接、工程判斷或維護工作摘要搬到前台。
|
||||
6. 外部促銷活動、折扣、價格壓力與平台活動訊號,必須被整理成「PChome 現況對比」與「業績提升解法」,不能只顯示外部事件本身。
|
||||
7. 商品型頁面必須把商品身份放在主要視覺區:商品圖、平台商品 ID、商品名稱、售價、賣場連結、可信度與下一步不得分散到難以掃描的位置。
|
||||
8. 外部主流平台來源治理不得只看 PChome / MOMO;Shopee、Lazada、Amazon、Google Merchant / Shopping、TikTok Shop、LINE 購物、Rakuten、Yahoo 購物、露天、品牌官網 / Shopify、Meta Commerce、Coupang 等來源至少要有待接入契約。未接合法穩定來源前只能標示待接入,不得假裝已監控。
|
||||
|
||||
## 每次 UI/UX 修改的驗收
|
||||
|
||||
@@ -21,7 +23,10 @@
|
||||
- 相關業務測試:`tests/test_pchome_revenue_growth_service.py`
|
||||
- 正式 smoke:檢查 `/health` 版本、核心頁 HTTP 200、可見文案無 raw terms、靜態資源 HTTP 200
|
||||
- 如果頁面有 PChome/MOMO 商品比較,必須能一眼看到兩平台價格與可同時開啟的外部賣場連結
|
||||
- 如果頁面有商品列、AI 挑品或候選卡,必須能一眼看到商品圖、商品 ID 與可點擊賣場;缺圖只能顯示「待補圖片」等可診斷狀態,不得破圖或空白
|
||||
- 如果商品表格需要橫向捲動,關鍵賣場操作欄必須固定或重複在可見區,不能讓使用者為了開兩家賣場而找不到按鈕
|
||||
- 如果頁面有外部促銷或競品活動訊號,必須至少提供守價、組合、曝光或會員回饋等 PChome 可執行解法
|
||||
- 如果 CSS/JS 在同一輪修正後重新部署,必須同步提升 `SYSTEM_VERSION`,確保正式頁的靜態資源版本參數改變,不讓使用者瀏覽器繼續使用舊樣式
|
||||
|
||||
## 判斷標準
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import json
|
||||
import logging
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -21,6 +25,10 @@ from services.pchome_growth_cache_state import mark_pchome_growth_cache_stale
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PCHOME_PUBLIC_PRODUCT_API = "https://ecapi.pchome.com.tw/ecshop/prodapi/v2/prod"
|
||||
PCHOME_IMAGE_CDN_BASE = "https://cs-a.ecimg.tw"
|
||||
PCHOME_PRODUCT_ID_RE = re.compile(r"^[A-Z0-9]{6}-[A-Z0-9]{9}-[0-9]{3}$")
|
||||
|
||||
|
||||
SOURCE_CONTRACTS = [
|
||||
{
|
||||
@@ -45,6 +53,116 @@ SOURCE_CONTRACTS = [
|
||||
"data_quality_label": "暫不進告警",
|
||||
"plain_note": "先保留資料接口,等有穩定合法來源後再啟用,不會影響目前作戰清單。",
|
||||
},
|
||||
{
|
||||
"code": "lazada",
|
||||
"display_name": "Lazada",
|
||||
"platform_code": "lazada",
|
||||
"status_code": "paused",
|
||||
"status_label": "待接入",
|
||||
"source_kind": "connector_contract",
|
||||
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
|
||||
"data_quality_label": "暫不進告警",
|
||||
"plain_note": "先建立跨境平台資料欄位,等合法穩定來源後才啟用價格與活動監控。",
|
||||
},
|
||||
{
|
||||
"code": "amazon",
|
||||
"display_name": "Amazon",
|
||||
"platform_code": "amazon",
|
||||
"status_code": "paused",
|
||||
"status_label": "待接入",
|
||||
"source_kind": "connector_contract",
|
||||
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
|
||||
"data_quality_label": "暫不進告警",
|
||||
"plain_note": "保留 SKU / ASIN / Marketplace 維度,未接正式來源前不參與自動判斷。",
|
||||
},
|
||||
{
|
||||
"code": "google_merchant",
|
||||
"display_name": "Google Merchant / Shopping",
|
||||
"platform_code": "google_merchant",
|
||||
"status_code": "paused",
|
||||
"status_label": "待接入",
|
||||
"source_kind": "connector_contract",
|
||||
"input_methods": ["官方報表", "官方 API", "手動 CSV"],
|
||||
"data_quality_label": "暫不進告警",
|
||||
"plain_note": "用於價格競爭力、商品資料完整度與成效 benchmark,待正式資料源接入。",
|
||||
},
|
||||
{
|
||||
"code": "tiktok_shop",
|
||||
"display_name": "TikTok Shop",
|
||||
"platform_code": "tiktok_shop",
|
||||
"status_code": "paused",
|
||||
"status_label": "待接入",
|
||||
"source_kind": "connector_contract",
|
||||
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
|
||||
"data_quality_label": "暫不進告警",
|
||||
"plain_note": "保留短影音電商的商品、促銷與內容導流欄位;未接合法穩定來源前不參與自動判斷。",
|
||||
},
|
||||
{
|
||||
"code": "line_shopping",
|
||||
"display_name": "LINE 購物",
|
||||
"platform_code": "line_shopping",
|
||||
"status_code": "paused",
|
||||
"status_label": "待接入",
|
||||
"source_kind": "connector_contract",
|
||||
"input_methods": ["官方報表", "供應商 API", "手動 CSV"],
|
||||
"data_quality_label": "暫不進告警",
|
||||
"plain_note": "保留本地導購、點數回饋與活動檔期訊號;接入前只列為待補來源。",
|
||||
},
|
||||
{
|
||||
"code": "rakuten",
|
||||
"display_name": "Rakuten",
|
||||
"platform_code": "rakuten",
|
||||
"status_code": "paused",
|
||||
"status_label": "待接入",
|
||||
"source_kind": "connector_contract",
|
||||
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
|
||||
"data_quality_label": "暫不進告警",
|
||||
"plain_note": "保留日本與海外平台比價欄位,未確認來源前只列入待接入清單。",
|
||||
},
|
||||
{
|
||||
"code": "yahoo_shopping",
|
||||
"display_name": "Yahoo 購物",
|
||||
"platform_code": "yahoo_shopping",
|
||||
"status_code": "paused",
|
||||
"status_label": "待接入",
|
||||
"source_kind": "connector_contract",
|
||||
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
|
||||
"data_quality_label": "暫不進告警",
|
||||
"plain_note": "保留本地平台促銷與價格監控欄位,接入前不影響現有作戰清單。",
|
||||
},
|
||||
{
|
||||
"code": "ruten",
|
||||
"display_name": "露天",
|
||||
"platform_code": "ruten",
|
||||
"status_code": "paused",
|
||||
"status_label": "待接入",
|
||||
"source_kind": "connector_contract",
|
||||
"input_methods": ["官方 API", "供應商 API", "手動 CSV"],
|
||||
"data_quality_label": "暫不進告警",
|
||||
"plain_note": "保留拍賣與長尾賣場價格訊號;正式來源未確認前不進入自動告警。",
|
||||
},
|
||||
{
|
||||
"code": "shopify_brand_store",
|
||||
"display_name": "品牌官網 / Shopify",
|
||||
"platform_code": "shopify_brand_store",
|
||||
"status_code": "paused",
|
||||
"status_label": "待接入",
|
||||
"source_kind": "connector_contract",
|
||||
"input_methods": ["官方 API", "商品 Feed", "手動 CSV"],
|
||||
"data_quality_label": "暫不進告警",
|
||||
"plain_note": "保留品牌官網售價、組合與活動檔期,用來和 PChome 主推品對照。",
|
||||
},
|
||||
{
|
||||
"code": "meta_commerce",
|
||||
"display_name": "Meta Commerce",
|
||||
"platform_code": "meta_commerce",
|
||||
"status_code": "paused",
|
||||
"status_label": "待接入",
|
||||
"source_kind": "connector_contract",
|
||||
"input_methods": ["官方 Catalog API", "手動 CSV"],
|
||||
"data_quality_label": "暫不進告警",
|
||||
"plain_note": "保留廣告商品目錄與商品連結欄位,用於後續曝光與導流成效對照。",
|
||||
},
|
||||
{
|
||||
"code": "coupang",
|
||||
"display_name": "酷澎",
|
||||
@@ -230,6 +348,75 @@ def _load_json_dict(value: Any) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _looks_like_pchome_product_id(product_id: Any) -> bool:
|
||||
return bool(PCHOME_PRODUCT_ID_RE.match(str(product_id or "").strip()))
|
||||
|
||||
|
||||
def _absolute_pchome_image_url(path: Any) -> str:
|
||||
image_path = str(path or "").strip()
|
||||
if not image_path:
|
||||
return ""
|
||||
if image_path.startswith("http://") or image_path.startswith("https://"):
|
||||
return image_path
|
||||
if not image_path.startswith("/"):
|
||||
image_path = f"/{image_path}"
|
||||
return f"{PCHOME_IMAGE_CDN_BASE}{image_path}"
|
||||
|
||||
|
||||
def _pchome_api_timeout_seconds() -> float:
|
||||
try:
|
||||
return max(1.0, min(float(os.getenv("PCHOME_PUBLIC_API_TIMEOUT_SECONDS", "4")), 10.0))
|
||||
except (TypeError, ValueError):
|
||||
return 4.0
|
||||
|
||||
|
||||
def _parse_pchome_jsonp(payload: str) -> dict[str, Any]:
|
||||
match = re.search(r"jsonp\((.*)\);\s*}\s*catch", payload or "", re.DOTALL)
|
||||
if not match:
|
||||
match = re.search(r"jsonp\((.*)\);", payload or "", re.DOTALL)
|
||||
if not match:
|
||||
return {}
|
||||
parsed = json.loads(match.group(1))
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _fetch_pchome_public_image_map(product_ids: list[str]) -> dict[str, str]:
|
||||
"""Fetch PChome product images from the public product API without blocking the list."""
|
||||
ids = [
|
||||
str(product_id or "").strip()
|
||||
for product_id in product_ids
|
||||
if _looks_like_pchome_product_id(product_id)
|
||||
]
|
||||
ids = list(dict.fromkeys(ids))
|
||||
if not ids:
|
||||
return {}
|
||||
|
||||
url = (
|
||||
f"{PCHOME_PUBLIC_PRODUCT_API}"
|
||||
f"?id={quote(','.join(ids), safe=',')}"
|
||||
"&fields=Id,Name,Nick,Pic,Price"
|
||||
"&_callback=jsonp"
|
||||
)
|
||||
try:
|
||||
request = Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urlopen(request, timeout=_pchome_api_timeout_seconds()) as response:
|
||||
raw = response.read(512000).decode("utf-8", "ignore")
|
||||
products = _parse_pchome_jsonp(raw)
|
||||
except Exception as exc:
|
||||
logger.warning("[ExternalOffer] PChome image API failed: %s", exc)
|
||||
return {}
|
||||
|
||||
image_map: dict[str, str] = {}
|
||||
for product_id, product in products.items():
|
||||
if not isinstance(product, dict):
|
||||
continue
|
||||
pic = product.get("Pic") if isinstance(product.get("Pic"), dict) else {}
|
||||
image_url = _absolute_pchome_image_url(pic.get("B") or pic.get("S") or pic.get("W"))
|
||||
if image_url:
|
||||
image_map[str(product_id)] = image_url
|
||||
return image_map
|
||||
|
||||
|
||||
_PCHOME_PRODUCT_URL_BASE = "https://24h.pchome.com.tw/prod/"
|
||||
|
||||
_REVIEW_REASON_LABELS = {
|
||||
@@ -1491,7 +1678,7 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]:
|
||||
"review_offer_count": review_offer_count,
|
||||
"sources": sources,
|
||||
"connector_contract": build_connector_contracts(),
|
||||
"plain_summary": "MOMO 先用;蝦皮與酷澎先保留接口,暫不進告警。",
|
||||
"plain_summary": "MOMO 先用;其他主流平台已列管,未接合法穩定來源前不進告警。",
|
||||
}
|
||||
|
||||
|
||||
@@ -1541,6 +1728,11 @@ def list_momo_review_candidates(engine, *, limit: int = 20) -> dict[str, Any]:
|
||||
LIMIT :limit
|
||||
"""), {"limit": limit * 4}).mappings().all()
|
||||
|
||||
pchome_image_map = _fetch_pchome_public_image_map([
|
||||
str(row.get("pchome_product_id") or "").strip()
|
||||
for row in rows
|
||||
])
|
||||
|
||||
seen: set[tuple[str, str]] = set()
|
||||
items: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
@@ -1567,19 +1759,28 @@ def list_momo_review_candidates(engine, *, limit: int = 20) -> dict[str, Any]:
|
||||
pchome_price = _to_float(raw_payload.get("pchome_public_price"))
|
||||
momo_price = _to_float(row.get("price"))
|
||||
gap_pct = _to_float(raw_payload.get("target_gap_pct"))
|
||||
pchome_image_url = (
|
||||
raw_payload.get("pchome_image_url")
|
||||
or raw_payload.get("pchome_public_image_url")
|
||||
or pchome_image_map.get(str(pchome_product_id or "").strip())
|
||||
or ""
|
||||
)
|
||||
momo_image_url = row.get("image_url")
|
||||
|
||||
items.append({
|
||||
"id": int(row.get("id")),
|
||||
"pchome_product_id": pchome_product_id,
|
||||
"pchome_product_name": raw_payload.get("pchome_public_name") or "",
|
||||
"pchome_url": _build_pchome_product_url(pchome_product_id),
|
||||
"pchome_image_url": pchome_image_url,
|
||||
"pchome_price": pchome_price,
|
||||
"momo_sku": row.get("momo_sku") or row.get("source_product_id"),
|
||||
"momo_title": row.get("title"),
|
||||
"momo_price": momo_price,
|
||||
"momo_url": momo_url,
|
||||
"product_url": momo_url,
|
||||
"image_url": row.get("image_url"),
|
||||
"image_url": momo_image_url,
|
||||
"momo_image_url": momo_image_url,
|
||||
"quality_score": round(_to_float(row.get("quality_score")) or 0.0, 2),
|
||||
"alert_tier": raw_payload.get("alert_tier") or "identity_review",
|
||||
"price_basis": raw_payload.get("price_basis") or "manual_review",
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
max-width: 680px;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.growth-command-status-pill {
|
||||
@@ -75,6 +75,53 @@
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.growth-command-action-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.growth-command-action-group.is-secondary {
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid rgba(42, 37, 32, 0.12);
|
||||
}
|
||||
|
||||
.growth-command-pro .ai-action-btn {
|
||||
min-height: 38px;
|
||||
border-width: 1px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.growth-command-pro .ai-action-btn.btn-primary {
|
||||
background: #2267d5;
|
||||
border-color: #2267d5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.growth-command-pro .ai-action-btn.btn-primary:hover,
|
||||
.growth-command-pro .ai-action-btn.btn-primary:focus {
|
||||
background: #1d58b9;
|
||||
border-color: #1d58b9;
|
||||
}
|
||||
|
||||
.growth-command-pro .ai-action-btn.btn-outline-primary,
|
||||
.growth-command-pro .ai-action-btn.btn-outline-secondary {
|
||||
background: #fff;
|
||||
border-color: rgba(71, 82, 97, 0.45);
|
||||
color: #3e4a59;
|
||||
}
|
||||
|
||||
.growth-command-pro .ai-action-btn.btn-outline-primary:hover,
|
||||
.growth-command-pro .ai-action-btn.btn-outline-primary:focus,
|
||||
.growth-command-pro .ai-action-btn.btn-outline-secondary:hover,
|
||||
.growth-command-pro .ai-action-btn.btn-outline-secondary:focus {
|
||||
background: rgba(34, 103, 213, 0.08);
|
||||
border-color: rgba(34, 103, 213, 0.55);
|
||||
color: #1f4f9f;
|
||||
}
|
||||
|
||||
.growth-command-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.28fr repeat(4, minmax(0, 1fr));
|
||||
@@ -425,6 +472,10 @@
|
||||
box-shadow: var(--momo-shadow-soft);
|
||||
}
|
||||
|
||||
.ai-intel-legacy-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-intel-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -1869,6 +1920,7 @@
|
||||
|
||||
.growth-source-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -2285,6 +2337,21 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.review-candidate-thumb.is-missing {
|
||||
align-content: center;
|
||||
gap: 2px;
|
||||
color: #786f63;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.review-candidate-thumb.is-missing i {
|
||||
display: block;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.review-candidate-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -2312,6 +2379,24 @@
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.review-candidate-store-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.review-candidate-store-meta span {
|
||||
border: 1px solid rgba(42, 37, 32, 0.08);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: var(--momo-text-muted);
|
||||
font-family: var(--momo-font-mono);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 900;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.review-candidate-store a {
|
||||
white-space: nowrap;
|
||||
font-size: 0.7rem;
|
||||
@@ -2605,6 +2690,7 @@
|
||||
}
|
||||
|
||||
.growth-ops-grid,
|
||||
.growth-source-list,
|
||||
.review-candidate-panel,
|
||||
.growth-executive-strip,
|
||||
.offer-dryrun-grid,
|
||||
@@ -2746,25 +2832,29 @@
|
||||
<div class="growth-command-pro-head">
|
||||
<div>
|
||||
<h1 class="growth-command-pro-title">PChome 業績成長系統</h1>
|
||||
<p class="growth-command-pro-subtitle">評估業績、分析價差、決定今天的解法。</p>
|
||||
<p class="growth-command-pro-subtitle">先看今日優先商品,再決定價格、曝光、組合與資料補強。</p>
|
||||
</div>
|
||||
<div class="growth-command-pro-actions">
|
||||
<span id="growthCommandStatus" class="growth-command-status-pill">
|
||||
<i class="fas fa-circle-check"></i>
|
||||
<span>讀取中</span>
|
||||
</span>
|
||||
<button class="btn btn-primary btn-sm ai-action-btn" id="btnTrigger" data-action="trigger-analysis" onclick="triggerAnalysis()">
|
||||
<i class="fas fa-bolt me-1"></i>更新今日建議
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-sm ai-action-btn" id="btnPickList" data-action="generate-picks" onclick="generatePickList()">
|
||||
<i class="fas fa-wand-magic-sparkles me-1"></i>產生今日清單
|
||||
</button>
|
||||
<button class="btn btn-outline-warning btn-sm ai-action-btn" id="btnBackfill" data-action="backfill" onclick="backfillPchomeMatches()">
|
||||
<i class="fas fa-magnifying-glass-chart me-1"></i>補齊比價資料
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadDashboard(); loadGrowthOps(true);">
|
||||
<i class="fas fa-rotate me-1"></i>重新整理
|
||||
</button>
|
||||
<div class="growth-command-action-group" aria-label="今日主要操作">
|
||||
<button class="btn btn-primary btn-sm ai-action-btn" id="btnPickList" data-action="generate-picks" onclick="generatePickList()">
|
||||
<i class="fas fa-list-check me-1"></i>產生今日清單
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm ai-action-btn" id="btnBackfill" data-action="backfill" onclick="backfillPchomeMatches()">
|
||||
<i class="fas fa-link me-1"></i>補齊比價資料
|
||||
</button>
|
||||
</div>
|
||||
<div class="growth-command-action-group is-secondary" aria-label="資料更新操作">
|
||||
<button class="btn btn-outline-primary btn-sm ai-action-btn" id="btnTrigger" data-action="trigger-analysis" onclick="triggerAnalysis()">
|
||||
<i class="fas fa-sparkles me-1"></i>更新建議
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadDashboard(); loadGrowthOps(true);">
|
||||
<i class="fas fa-rotate me-1"></i>重新整理
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2898,7 +2988,7 @@
|
||||
</section>
|
||||
|
||||
<!-- ── 頁首 ── -->
|
||||
<section class="ai-intel-hero">
|
||||
<section class="ai-intel-hero ai-intel-legacy-status" aria-hidden="true">
|
||||
<div>
|
||||
<h1 class="ai-intel-title">
|
||||
<i class="fas fa-brain"></i>
|
||||
@@ -2911,14 +3001,14 @@
|
||||
<span id="lastUpdateBadge" class="ai-status-badge">
|
||||
<i class="fas fa-sync me-1"></i>載入中...
|
||||
</span>
|
||||
<button class="btn btn-outline-danger btn-sm ai-action-btn" id="legacyBtnTrigger" data-action="trigger-analysis" onclick="triggerAnalysis()">
|
||||
<i class="fas fa-bolt me-1"></i>更新今日建議
|
||||
<button class="btn btn-outline-primary btn-sm ai-action-btn" id="legacyBtnTrigger" data-action="trigger-analysis" onclick="triggerAnalysis()">
|
||||
<i class="fas fa-sparkles me-1"></i>更新建議
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-sm ai-action-btn" id="legacyBtnPickList" data-action="generate-picks" onclick="generatePickList()" title="依目前業績與比價資料整理商品處理清單">
|
||||
<i class="fas fa-wand-magic-sparkles me-1"></i>產生今日清單
|
||||
<i class="fas fa-list-check me-1"></i>產生今日清單
|
||||
</button>
|
||||
<button class="btn btn-outline-warning btn-sm ai-action-btn" id="legacyBtnBackfill" data-action="backfill" onclick="backfillPchomeMatches()" title="替還不能比價的 PChome 商品尋找 MOMO 參考">
|
||||
<i class="fas fa-magnifying-glass-chart me-1"></i>補齊比價資料
|
||||
<i class="fas fa-link me-1"></i>補齊比價資料
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadDashboard()" title="重新載入畫面資料">
|
||||
<i class="fas fa-redo me-1"></i>重新整理
|
||||
@@ -3653,6 +3743,14 @@ function renderReasonChips(labels) {
|
||||
return chips.map((label) => `<span class="review-candidate-pill is-review">${escapeHtml(label)}</span>`).join('');
|
||||
}
|
||||
|
||||
function renderProductThumb(imageUrl, altText) {
|
||||
const safeUrl = safeHttpUrl(imageUrl);
|
||||
if (!safeUrl) {
|
||||
return '<span class="review-candidate-thumb is-missing"><i class="fas fa-image"></i><span>待補圖片</span></span>';
|
||||
}
|
||||
return `<span class="review-candidate-thumb"><img src="${escapeHtml(safeUrl)}" alt="${escapeHtml(altText || '商品圖')}" loading="lazy" onerror="this.closest('.review-candidate-thumb').outerHTML='<span class="review-candidate-thumb is-missing"><i class="fas fa-image"></i><span>待補圖片</span></span>'"></span>`;
|
||||
}
|
||||
|
||||
function scrollToPanel(panelId) {
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return;
|
||||
@@ -4155,7 +4253,7 @@ function renderGrowthSourceReadiness(sources) {
|
||||
return;
|
||||
}
|
||||
|
||||
box.innerHTML = sources.slice(0, 3).map((source) => {
|
||||
box.innerHTML = sources.map((source) => {
|
||||
const usable = Number(source.usable_offer_count || 0);
|
||||
const detail = usable > 0
|
||||
? `${source.data_quality_label || '資料可用'} · ${usable.toLocaleString()} 筆`
|
||||
@@ -5175,12 +5273,10 @@ function renderGrowthReviewCandidates(rows) {
|
||||
const pchomePrice = row.pchome_price ? formatMoney(row.pchome_price) : '未取得 PChome 價格';
|
||||
const pchomeUrl = safeHttpUrl(row.pchome_url);
|
||||
const momoUrl = safeHttpUrl(row.momo_url || row.product_url);
|
||||
const momoImageUrl = safeHttpUrl(row.image_url);
|
||||
const pchomeLink = pchomeUrl ? `<a href="${escapeHtml(pchomeUrl)}" target="_blank" rel="noopener noreferrer">PChome 賣場</a>` : '<span class="text-muted">待補連結</span>';
|
||||
const momoLink = momoUrl ? `<a href="${escapeHtml(momoUrl)}" target="_blank" rel="noopener noreferrer">MOMO 賣場</a>` : '<span class="text-muted">待補連結</span>';
|
||||
const momoThumb = momoImageUrl
|
||||
? `<img src="${escapeHtml(momoImageUrl)}" alt="MOMO 商品圖" loading="lazy">`
|
||||
: '<i class="fas fa-store"></i>';
|
||||
const pchomeThumb = renderProductThumb(row.pchome_image_url, `${row.pchome_product_name || 'PChome'} 商品圖`);
|
||||
const momoThumb = renderProductThumb(row.momo_image_url || row.image_url, `${row.momo_title || 'MOMO'} 商品圖`);
|
||||
const compareButton = pchomeUrl && momoUrl
|
||||
? `<button type="button" class="btn btn-sm review-candidate-primary" data-pchome-url="${escapeHtml(pchomeUrl)}" data-momo-url="${escapeHtml(momoUrl)}" onclick="openReviewCandidateStores(this)"><i class="fas fa-up-right-from-square me-1"></i>同時開兩家賣場</button>`
|
||||
: '';
|
||||
@@ -5194,24 +5290,26 @@ function renderGrowthReviewCandidates(rows) {
|
||||
<div class="review-candidate-compare" aria-label="兩家賣場比對">
|
||||
<section class="review-candidate-store is-pchome">
|
||||
<div class="review-candidate-store-head">
|
||||
<span class="review-candidate-thumb"><i class="fas fa-store"></i></span>
|
||||
${pchomeThumb}
|
||||
<div>
|
||||
<span class="review-candidate-store-label"><i class="fas fa-bolt"></i>PChome</span>
|
||||
<span class="review-candidate-store-price">${escapeHtml(pchomePrice)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="review-candidate-store-title" title="${escapeHtml(row.pchome_product_name || '')}">${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}</p>
|
||||
<div class="review-candidate-store-meta"><span>商品ID ${escapeHtml(row.pchome_product_id || '待補')}</span></div>
|
||||
<div class="mt-2">${pchomeLink}</div>
|
||||
</section>
|
||||
<section class="review-candidate-store is-momo">
|
||||
<div class="review-candidate-store-head">
|
||||
<span class="review-candidate-thumb">${momoThumb}</span>
|
||||
${momoThumb}
|
||||
<div>
|
||||
<span class="review-candidate-store-label"><i class="fas fa-store"></i>MOMO</span>
|
||||
<span class="review-candidate-store-price">${escapeHtml(momoPrice)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="review-candidate-store-title" title="${escapeHtml(row.momo_title || '')}">${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')}</p>
|
||||
<div class="review-candidate-store-meta"><span>商品ID ${escapeHtml(row.momo_sku || '待補')}</span></div>
|
||||
<div class="mt-2">${momoLink}</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -5612,9 +5710,9 @@ async function generatePickList() {
|
||||
const btn = document.getElementById('btnPickList');
|
||||
|
||||
if (btn && btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
if (btn) btn.disabled = true;
|
||||
setActionBusy('generate-picks', true);
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
|
||||
if (btn) btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ai/product-picks/generate', {
|
||||
@@ -5634,9 +5732,9 @@ async function generatePickList() {
|
||||
} catch (e) {
|
||||
showToast('error', `整理失敗:${e.message}`, 4000);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
if (btn) btn.disabled = false;
|
||||
setActionBusy('generate-picks', false);
|
||||
btn.innerHTML = '<i class="fas fa-wand-magic-sparkles me-1"></i>產生今日清單';
|
||||
if (btn) btn.innerHTML = '<i class="fas fa-list-check me-1"></i>產生今日清單';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5645,9 +5743,9 @@ async function backfillPchomeMatches() {
|
||||
const btn = document.getElementById('btnBackfill');
|
||||
|
||||
if (btn && btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
if (btn) btn.disabled = true;
|
||||
setActionBusy('backfill', true);
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
|
||||
if (btn) btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ai/pchome-match/backfill', {
|
||||
@@ -5667,9 +5765,9 @@ async function backfillPchomeMatches() {
|
||||
} catch (e) {
|
||||
showToast('error', `商品對應失敗:${e.message}`, 4000);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
if (btn) btn.disabled = false;
|
||||
setActionBusy('backfill', false);
|
||||
btn.innerHTML = '<i class="fas fa-magnifying-glass-chart me-1"></i>補齊比價資料';
|
||||
if (btn) btn.innerHTML = '<i class="fas fa-link me-1"></i>補齊比價資料';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5678,9 +5776,9 @@ async function triggerAnalysis() {
|
||||
const btn = document.getElementById('btnTrigger');
|
||||
|
||||
if (btn && btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
if (btn) btn.disabled = true;
|
||||
setActionBusy('trigger-analysis', true);
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
|
||||
if (btn) btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ai/icaim/trigger', { method: 'POST' });
|
||||
@@ -5699,9 +5797,9 @@ async function triggerAnalysis() {
|
||||
} catch (e) {
|
||||
showToast('error', `整理失敗:${e.message}`, 4000);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
if (btn) btn.disabled = false;
|
||||
setActionBusy('trigger-analysis', false);
|
||||
btn.innerHTML = '<i class="fas fa-bolt me-1"></i>更新今日建議';
|
||||
if (btn) btn.innerHTML = '<i class="fas fa-sparkles me-1"></i>更新建議';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -622,7 +622,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="dashboard-table-wrap {% if current_filter == 'pchome_review' %}is-review-wrap{% endif %}">
|
||||
<div class="dashboard-table-wrap {% if current_filter == 'pchome_review' %}is-review-wrap{% elif current_filter == 'ai_picks' %}is-ai-picks-wrap{% endif %}">
|
||||
<table class="dashboard-table {% if current_filter == 'ai_picks' %}is-ai-picks{% elif current_filter == 'pchome_review' %}is-review{% endif %}">
|
||||
<thead>
|
||||
{% if current_filter == 'pchome_review' %}
|
||||
@@ -676,7 +676,10 @@
|
||||
<td>
|
||||
<div class="dashboard-review-product-stack">
|
||||
<div class="dashboard-product-cell">
|
||||
<img class="dashboard-product-thumb" src="{{ image_url }}" alt="{{ product.name }}" loading="lazy" referrerpolicy="no-referrer">
|
||||
<span class="dashboard-product-thumb-frame">
|
||||
<img class="dashboard-product-thumb" src="{{ image_url }}" alt="{{ product.name }}" loading="lazy" referrerpolicy="no-referrer" onerror="this.hidden=true; this.nextElementSibling.hidden=false;">
|
||||
<span class="dashboard-product-thumb-missing" hidden aria-label="待補商品圖"><i class="fas fa-image" aria-hidden="true"></i></span>
|
||||
</span>
|
||||
<div>
|
||||
<a class="dashboard-product-name momo-tracked-link" href="{{ safe_product_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-momo-original-url="{{ safe_product_url or '#' }}"
|
||||
@@ -689,6 +692,9 @@
|
||||
<span>MOMO {{ product.i_code }}</span>
|
||||
<span>${{ item.record.price | int | number_format }}</span>
|
||||
</div>
|
||||
<div class="dashboard-product-identity" aria-label="商品身份">
|
||||
<span>商品ID {{ product.i_code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -830,16 +836,22 @@
|
||||
<td><span class="dashboard-category">{{ product.category or '未分類' }}</span></td>
|
||||
<td>
|
||||
<div class="dashboard-product-cell">
|
||||
<img class="dashboard-product-thumb" src="{{ image_url }}" alt="{{ product.name }}" loading="lazy" referrerpolicy="no-referrer">
|
||||
<span class="dashboard-product-thumb-frame">
|
||||
<img class="dashboard-product-thumb" src="{{ image_url }}" alt="{{ product.name }}" loading="lazy" referrerpolicy="no-referrer" onerror="this.hidden=true; this.nextElementSibling.hidden=false;">
|
||||
<span class="dashboard-product-thumb-missing" hidden aria-label="待補商品圖"><i class="fas fa-image" aria-hidden="true"></i></span>
|
||||
</span>
|
||||
{% set safe_product_url = item.safe_momo_url or '#' %}
|
||||
<a class="dashboard-product-name momo-tracked-link" href="{{ safe_product_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-momo-original-url="{{ safe_product_url or '#' }}"
|
||||
data-track-platform="momo"
|
||||
data-track-source="dashboard-v2-table-main"
|
||||
data-track-product-id="{{ product.id }}"
|
||||
data-track-icode="{{ product.i_code }}"
|
||||
data-track-product-name="{{ product.name|e }}">{{ product.name }}</a>
|
||||
<div>
|
||||
<div class="dashboard-product-info">
|
||||
<a class="dashboard-product-name momo-tracked-link" href="{{ safe_product_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-momo-original-url="{{ safe_product_url or '#' }}"
|
||||
data-track-platform="momo"
|
||||
data-track-source="dashboard-v2-table-main"
|
||||
data-track-product-id="{{ product.id }}"
|
||||
data-track-icode="{{ product.i_code }}"
|
||||
data-track-product-name="{{ product.name|e }}">{{ product.name }}</a>
|
||||
<div class="dashboard-product-identity" aria-label="商品身份">
|
||||
<span>商品ID {{ product.i_code }}</span>
|
||||
</div>
|
||||
<div class="dashboard-platform-links">
|
||||
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ safe_product_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-momo-original-url="{{ safe_product_url or '#' }}"
|
||||
@@ -950,6 +962,22 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="dashboard-ai-pick-reason">{{ item.ai_pick.reason }}</div>
|
||||
<div class="dashboard-ai-action-row" aria-label="商品賣場連結">
|
||||
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ safe_product_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-momo-original-url="{{ safe_product_url or '#' }}"
|
||||
data-track-platform="momo"
|
||||
data-track-source="dashboard-v2-ai-pick-card"
|
||||
data-track-product-id="{{ product.id }}"
|
||||
data-track-icode="{{ product.i_code }}"
|
||||
data-track-product-name="{{ product.name|e }}">
|
||||
開 MOMO 賣場
|
||||
</a>
|
||||
{% if competitor and competitor.product_url %}
|
||||
<a class="dashboard-platform-link is-pchome" href="{{ competitor.product_url }}" target="_blank" rel="noopener noreferrer">開 PChome 賣場</a>
|
||||
{% elif item.pchome_match_attempt and item.pchome_match_attempt.competitor_product_url %}
|
||||
<a class="dashboard-platform-link is-pchome" href="{{ item.pchome_match_attempt.competitor_product_url }}" target="_blank" rel="noopener noreferrer">開 PChome 候選</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.ai_pick.missing_evidence %}
|
||||
<div class="dashboard-ai-evidence-line" title="{{ item.ai_pick.missing_evidence_text }}">
|
||||
{% for evidence in item.ai_pick.missing_evidence[:3] %}
|
||||
|
||||
@@ -134,11 +134,11 @@
|
||||
{% elif _page_group == 'ops' %}{% set _growth_stage = 'solve' %}
|
||||
{% else %}{% set _growth_stage = 'govern' %}
|
||||
{% endif %}
|
||||
{% if _growth_stage == 'evaluate' %}{% set _growth_stage_brief = '現在:評估缺口' %}
|
||||
{% elif _growth_stage == 'analyze' %}{% set _growth_stage_brief = '現在:分析原因' %}
|
||||
{% elif _growth_stage == 'recommend' %}{% set _growth_stage_brief = '現在:產生建議' %}
|
||||
{% elif _growth_stage == 'solve' %}{% set _growth_stage_brief = '現在:執行解法' %}
|
||||
{% else %}{% set _growth_stage_brief = '現在:守住品質' %}
|
||||
{% if _growth_stage == 'evaluate' %}{% set _growth_stage_brief = '今日重點:優先商品' %}
|
||||
{% elif _growth_stage == 'analyze' %}{% set _growth_stage_brief = '今日重點:價格判斷' %}
|
||||
{% elif _growth_stage == 'recommend' %}{% set _growth_stage_brief = '今日重點:業績建議' %}
|
||||
{% elif _growth_stage == 'solve' %}{% set _growth_stage_brief = '今日重點:執行清單' %}
|
||||
{% else %}{% set _growth_stage_brief = '今日重點:資料品質' %}
|
||||
{% endif %}
|
||||
<nav class="momo-growth-rail" aria-label="PChome 業績提升流程">
|
||||
<span class="momo-growth-goal">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
|
||||
def test_connector_contract_keeps_shopee_and_coupang_paused_with_manual_csv_path():
|
||||
def test_connector_contract_lists_mainstream_sources_without_auto_alerts():
|
||||
from services.external_market_offer_service import build_connector_contracts
|
||||
|
||||
payload = build_connector_contracts()
|
||||
@@ -12,8 +12,31 @@ def test_connector_contract_keeps_shopee_and_coupang_paused_with_manual_csv_path
|
||||
assert sources["momo_reference"]["status_label"] == "正在使用"
|
||||
assert sources["shopee"]["status_label"] == "先暫停"
|
||||
assert sources["coupang"]["status_label"] == "先暫停"
|
||||
assert {
|
||||
"shopee",
|
||||
"lazada",
|
||||
"amazon",
|
||||
"google_merchant",
|
||||
"tiktok_shop",
|
||||
"line_shopping",
|
||||
"rakuten",
|
||||
"yahoo_shopping",
|
||||
"ruten",
|
||||
"shopify_brand_store",
|
||||
"meta_commerce",
|
||||
"coupang",
|
||||
}.issubset(sources)
|
||||
assert all(
|
||||
sources[code]["status_code"] == "paused"
|
||||
for code in sources
|
||||
if code != "momo_reference"
|
||||
)
|
||||
assert "手動 CSV" in sources["shopee"]["input_methods"]
|
||||
assert "官方 API" in sources["coupang"]["input_methods"]
|
||||
assert "官方報表" in sources["google_merchant"]["input_methods"]
|
||||
assert "商品 Feed" in sources["shopify_brand_store"]["input_methods"]
|
||||
assert sources["tiktok_shop"]["plain_note"].startswith("保留短影音電商")
|
||||
assert sources["ruten"]["display_name"] == "露天"
|
||||
assert "price" in payload["manual_csv"]["required_headers"]
|
||||
|
||||
|
||||
@@ -434,6 +457,11 @@ def test_momo_review_candidate_queue_can_confirm_candidate(monkeypatch):
|
||||
|
||||
stale_marks = []
|
||||
monkeypatch.setattr(service, "mark_pchome_growth_cache_stale", lambda: stale_marks.append(True))
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_fetch_pchome_public_image_map",
|
||||
lambda product_ids: {"PCH-CDP": "https://cs-a.ecimg.tw/items/PCH-CDP/000001.jpg"},
|
||||
)
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
_seed_external_offer_sync_tables(engine)
|
||||
@@ -462,6 +490,8 @@ def test_momo_review_candidate_queue_can_confirm_candidate(monkeypatch):
|
||||
assert candidate["pchome_product_name"] == "cle de peau 光采柔焦蜜粉 24g #1"
|
||||
assert candidate["momo_sku"] == "14917079"
|
||||
assert candidate["plain_status"] == "待確認同款或色號"
|
||||
assert candidate["pchome_image_url"] == "https://cs-a.ecimg.tw/items/PCH-CDP/000001.jpg"
|
||||
assert "momo_image_url" in candidate
|
||||
|
||||
updated = service.update_momo_review_candidate(engine, candidate["id"], "confirm", note="同款 #1")
|
||||
|
||||
@@ -575,7 +605,10 @@ def test_external_source_readiness_uses_legacy_momo_reference_cache():
|
||||
assert sources["momo_reference"]["usable_offer_count"] == 1
|
||||
assert sources["momo_reference"]["plain_state"] == "已接入,可進作戰清單"
|
||||
assert sources["shopee"]["plain_state"] == "先保留接口,不進告警"
|
||||
assert payload["plain_summary"] == "MOMO 先用;蝦皮與酷澎先保留接口,暫不進告警。"
|
||||
assert sources["tiktok_shop"]["plain_state"] == "先保留接口,不進告警"
|
||||
assert sources["line_shopping"]["plain_state"] == "先保留接口,不進告警"
|
||||
assert sources["shopify_brand_store"]["plain_state"] == "先保留接口,不進告警"
|
||||
assert payload["plain_summary"] == "MOMO 先用;其他主流平台已列管,未接合法穩定來源前不進告警。"
|
||||
|
||||
|
||||
def test_external_market_migration_creates_source_and_offer_tables():
|
||||
|
||||
@@ -268,6 +268,7 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
|
||||
route_source = (ROOT / "routes/dashboard_routes.py").read_text(encoding="utf-8")
|
||||
dashboard = (ROOT / "templates/dashboard_v2.html").read_text(encoding="utf-8")
|
||||
dashboard_css = (ROOT / "web/static/css/page-dashboard-v2.css").read_text(encoding="utf-8")
|
||||
guard_css = (ROOT / "web/static/css/ewoooc-v3-page-guard.css").read_text(encoding="utf-8")
|
||||
|
||||
assert "template_name = 'dashboard_v2.html'" in route_source
|
||||
assert "template_name = 'dashboard.html' if request.args.get('ui') == 'legacy' else 'dashboard_v2.html'" not in route_source
|
||||
@@ -383,6 +384,17 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
|
||||
assert "AI 挑品清單" in dashboard
|
||||
assert "比價覆核隊列" in dashboard
|
||||
assert "下一步" in dashboard
|
||||
assert "商品ID {{ product.i_code }}" in dashboard
|
||||
assert "開 MOMO 賣場" in dashboard
|
||||
assert "開 PChome 賣場" in dashboard
|
||||
assert "is-ai-picks-wrap" in dashboard
|
||||
assert "dashboard-product-thumb-missing" in dashboard
|
||||
assert "dashboard-product-identity" in dashboard_css
|
||||
assert ".dashboard-table.is-ai-picks td:nth-child(6)" in dashboard_css
|
||||
assert "position: sticky;" in dashboard_css
|
||||
assert "min-width: 230px" in dashboard_css
|
||||
assert ".dashboard-table-wrap.is-ai-picks-wrap" in guard_css
|
||||
assert "min-width: 1660px !important" in guard_css
|
||||
assert "dashboard-ai-summary-grid" in dashboard
|
||||
assert "AI 建議" in dashboard
|
||||
assert "/api/export/excel/ai-picks" in dashboard
|
||||
@@ -1081,6 +1093,7 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action():
|
||||
|
||||
assert "產生今日清單" in template
|
||||
assert "補齊比價資料" in template
|
||||
assert "ai-intel-legacy-status" in template
|
||||
assert "generatePickList" in template
|
||||
assert "backfillPchomeMatches" in template
|
||||
assert "/api/ai/product-picks/generate" in template
|
||||
|
||||
@@ -218,6 +218,8 @@ def test_momo_review_candidates_return_dual_store_links_and_plain_reasons():
|
||||
row = payload["rows"][0]
|
||||
assert row["pchome_url"] == "https://24h.pchome.com.tw/prod/PCH-REVIEW"
|
||||
assert row["momo_url"] == "https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=MOMO-REVIEW"
|
||||
assert "pchome_image_url" in row
|
||||
assert "momo_image_url" in row
|
||||
assert row["match_reason_labels"]
|
||||
assert all("_" not in label for label in row["match_reason_labels"])
|
||||
assert all("_" not in label for label in row["match_reasons"])
|
||||
@@ -489,6 +491,7 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
|
||||
assert "/api/ai/pchome-growth/external-offers/csv-dry-run" in template
|
||||
assert "/api/ai/pchome-growth/review-candidates" in template
|
||||
assert "growthSourceReadiness" in template
|
||||
assert "sources.map((source)" in template
|
||||
assert "MOMO 待確認候選" in template
|
||||
assert "確認同款" in template
|
||||
assert "不是同款" in template
|
||||
@@ -502,6 +505,10 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
|
||||
assert "data-momo-url" in template
|
||||
assert "PChome 賣場" in template
|
||||
assert "MOMO 賣場" in template
|
||||
assert "商品ID ${escapeHtml(row.pchome_product_id" in template
|
||||
assert "商品ID ${escapeHtml(row.momo_sku" in template
|
||||
assert "renderProductThumb" in template
|
||||
assert "待補圖片" in template
|
||||
assert "row.match_reason_labels" in template
|
||||
assert "row.match_reasons" not in template
|
||||
assert "variant_selection_review" not in template
|
||||
@@ -587,11 +594,11 @@ def test_sidebar_uses_growth_command_center_as_primary_entry():
|
||||
assert "{% set _group_monitor = ['ai_intelligence', 'dashboard', 'edm', 'campaigns'] %}" in base
|
||||
assert "momo-growth-rail" in base
|
||||
assert "PChome 業績提升" in base
|
||||
assert "現在:評估缺口" in base
|
||||
assert "現在:分析原因" in base
|
||||
assert "現在:產生建議" in base
|
||||
assert "現在:執行解法" in base
|
||||
assert "現在:守住品質" in base
|
||||
assert "今日重點:優先商品" in base
|
||||
assert "今日重點:價格判斷" in base
|
||||
assert "今日重點:業績建議" in base
|
||||
assert "今日重點:執行清單" in base
|
||||
assert "今日重點:資料品質" in base
|
||||
assert "評估" in base
|
||||
assert "分析" in base
|
||||
assert "建議" in base
|
||||
|
||||
@@ -586,9 +586,11 @@ body.momo-v2-body {
|
||||
}
|
||||
|
||||
.momo-growth-current {
|
||||
padding: 0 9px;
|
||||
border-left: 1px solid var(--momo-border-light);
|
||||
color: var(--momo-page-accent-dark);
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(49, 113, 234, 0.22);
|
||||
border-radius: var(--momo-radius-md);
|
||||
background: rgba(235, 243, 255, 0.82);
|
||||
color: #285b9f;
|
||||
font-family: var(--momo-font-display);
|
||||
font-size: var(--momo-text-label);
|
||||
font-weight: 800;
|
||||
|
||||
@@ -408,6 +408,17 @@
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.momo-app:not(.momo-observability-mode) .dashboard-table-wrap.is-ai-picks-wrap {
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.momo-app:not(.momo-observability-mode) .dashboard-table.is-ai-picks {
|
||||
width: max(100%, 1660px) !important;
|
||||
min-width: 1660px !important;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.momo-app:not(.momo-observability-mode) .dashboard-table.is-review {
|
||||
width: max(100%, 1540px) !important;
|
||||
min-width: 1540px !important;
|
||||
|
||||
@@ -1235,7 +1235,50 @@
|
||||
}
|
||||
|
||||
.dashboard-table.is-ai-picks {
|
||||
min-width: 1460px;
|
||||
min-width: 1660px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.dashboard-table.is-ai-picks th:nth-child(1) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.dashboard-table.is-ai-picks th:nth-child(2) {
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
.dashboard-table.is-ai-picks th:nth-child(3),
|
||||
.dashboard-table.is-ai-picks th:nth-child(4) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.dashboard-table.is-ai-picks th:nth-child(5) {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.dashboard-table.is-ai-picks th:nth-child(6) {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.dashboard-table.is-ai-picks th:nth-child(6),
|
||||
.dashboard-table.is-ai-picks td:nth-child(6) {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
background: rgba(255, 252, 246, 0.98);
|
||||
box-shadow: -12px 0 18px rgba(42, 37, 32, 0.08);
|
||||
}
|
||||
|
||||
.dashboard-table.is-ai-picks th:nth-child(6) {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.dashboard-table.is-ai-picks th:nth-child(7),
|
||||
.dashboard-table.is-ai-picks th:nth-child(8),
|
||||
.dashboard-table.is-ai-picks th:nth-child(9),
|
||||
.dashboard-table.is-ai-picks th:nth-child(10) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.dashboard-table.is-review {
|
||||
@@ -1336,6 +1379,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-product-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-product-thumb {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
@@ -1346,6 +1393,52 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dashboard-product-thumb-frame {
|
||||
display: grid;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.dashboard-product-thumb-frame .dashboard-product-thumb {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.dashboard-product-thumb-missing {
|
||||
display: grid;
|
||||
grid-area: 1 / 1;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: 6px;
|
||||
background: var(--momo-bg-paper);
|
||||
color: var(--momo-text-tertiary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.dashboard-product-identity {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.dashboard-product-identity span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: var(--momo-radius-pill);
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
color: var(--momo-text-secondary);
|
||||
font-family: var(--momo-font-family-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dashboard-product-name {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
@@ -1525,10 +1618,34 @@
|
||||
|
||||
.dashboard-ai-pick-card {
|
||||
display: grid;
|
||||
min-width: 170px;
|
||||
min-width: 230px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-ai-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-ai-action-row .dashboard-platform-link {
|
||||
min-width: 104px;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-table.is-ai-picks th:nth-child(6),
|
||||
.dashboard-table.is-ai-picks td:nth-child(6) {
|
||||
position: static;
|
||||
right: auto;
|
||||
z-index: auto;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-ai-pick-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user