fix: professionalize marketplace product ux

This commit is contained in:
ogt
2026-06-26 12:29:20 +08:00
parent 2888bac597
commit 5327dfda1f
13 changed files with 596 additions and 73 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -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 賣場;手機版維持卡片式堆疊。 |

View File

@@ -12,6 +12,8 @@
4. 資料不足時不能整段消失,要顯示可理解的空狀態與下一步。
5. 不得把工作視窗溝通、部署交接、工程判斷或維護工作摘要搬到前台。
6. 外部促銷活動、折扣、價格壓力與平台活動訊號必須被整理成「PChome 現況對比」與「業績提升解法」,不能只顯示外部事件本身。
7. 商品型頁面必須把商品身份放在主要視覺區:商品圖、平台商品 ID、商品名稱、售價、賣場連結、可信度與下一步不得分散到難以掃描的位置。
8. 外部主流平台來源治理不得只看 PChome / MOMOShopee、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`,確保正式頁的靜態資源版本參數改變,不讓使用者瀏覽器繼續使用舊樣式
## 判斷標準

View File

@@ -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",

View File

@@ -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=&quot;review-candidate-thumb is-missing&quot;><i class=&quot;fas fa-image&quot;></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>

View File

@@ -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] %}

View File

@@ -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">

View File

@@ -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():

View File

@@ -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

View File

@@ -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

View File

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

View File

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

View File

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