diff --git a/config.py b/config.py index 5759654..a371b76 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.685" +SYSTEM_VERSION = "V10.686" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 3ac9b51..4b32680 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -762,3 +762,4 @@ POSTGRES_HOST=momo-db | 2026-06-25 | Code Review 歷史決策需清除所有 GCP/111 句型 | V10.683 起 `ea_decision.reasoning` 舊紀錄讀取層同時清除「GCP-A/GCP-B AI 架構檢查不可用時應暫停 111 重分析」等非 fallback 句型,避免正式 history 10 筆仍殘留模型拓撲。 | | 2026-06-25 | Logs 頁不得露內部 Agent 服務名 | V10.684 起 `/api/logs` 會把 OpenClawBot、OpenClaw、Hermes、NemoTron 等內部 agent/service 名稱轉為 AI 自動化、架構檢查、掃描與派工服務,避免系統日誌頁重新暴露工程實作細節。 | | 2026-06-25 | Code Review template 原始碼也不得殘留 OpenClaw 可掃描字串 | V10.685 起 `/code-review/` 的 CSS 註解與 JS 函式名稱改為 Architecture Report 命名,讓正式 HTML 掃描不需例外白名單即可確認無內部 agent 名稱。 | +| 2026-06-25 | MOMO 待確認候選必須是營運比對卡 | V10.686 起首頁候選區需以 PChome/MOMO 左右對照、商品圖、價格差異徽章、同款可信度、中文確認重點與「雙開賣場 / 單開賣場」操作呈現;前端不得把 `variant_selection_review`、`source_code`、`momo_reference` 等工程 key 或資料欄位名直接顯示給營運使用者。 | diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 5864341..018115e 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -1412,29 +1412,33 @@ def _load_pchome_growth_command_center(session): strategy_lanes = [ { 'key': 'price_pressure', - 'label': 'MOMO 更便宜', + 'label': '價格壓力', 'value': review_price_count, - 'action': '檢查售價 / 券 / 組合', + 'decision': 'MOMO 更便宜', + 'action': '檢查售價、券或組合', 'tone': 'danger', }, { 'key': 'price_advantage', - 'label': 'PChome 有優勢', + 'label': '價格優勢', 'value': amplify_count, - 'action': '拉曝光 / 強化文案', + 'decision': 'PChome 有優勢', + 'action': '放大曝光與主推位置', 'tone': 'success', }, { 'key': 'bundle', - 'label': '單品 / 組合待判斷', + 'label': '組合判斷', 'value': sum(1 for item in opportunities if (item.get('external_price') or {}).get('price_basis') == 'unit_price'), - 'action': '看單位價,決定組合包', + 'decision': '容量或組合需換算', + 'action': '看單位價再決定包裝', 'tone': 'warning', }, { 'key': 'mapping', - 'label': '找不到同款', + 'label': '缺少同款', 'value': needs_mapping, + 'decision': '不能直接比價', 'action': '補抓 MOMO 候選', 'tone': 'neutral', }, diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html index d4aa552..a9b6b7b 100644 --- a/templates/ai_intelligence.html +++ b/templates/ai_intelligence.html @@ -2141,10 +2141,10 @@ .review-candidate-row { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(124px, auto); - gap: 12px; + grid-template-columns: minmax(0, 1fr) minmax(148px, auto); + gap: 14px; border-bottom: 1px solid rgba(42, 37, 32, 0.08); - padding: 10px 0; + padding: 12px 0; } .review-candidate-row:last-child { @@ -2166,9 +2166,16 @@ line-height: 1.35; } - .review-candidate-meta, + .review-candidate-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin: 7px 0 0; + } + .review-candidate-reason { - margin: 4px 0 0; + margin: 8px 0 0; color: var(--momo-text-muted); font-size: 0.74rem; line-height: 1.4; @@ -2185,7 +2192,7 @@ border: 1px solid rgba(42, 37, 32, 0.1); border-radius: 8px; background: rgba(255, 255, 255, 0.68); - padding: 8px; + padding: 10px; min-width: 0; } @@ -2199,11 +2206,38 @@ line-height: 1.2; } + .review-candidate-store-head { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + } + + .review-candidate-thumb { + display: grid; + flex: 0 0 48px; + width: 48px; + height: 48px; + overflow: hidden; + place-items: center; + color: var(--momo-text-tertiary); + background: rgba(245, 241, 234, 0.92); + border: 1px solid rgba(42, 37, 32, 0.08); + border-radius: 8px; + font-size: 1rem; + } + + .review-candidate-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + } + .review-candidate-store-price { display: block; color: var(--momo-text-strong); font-family: var(--momo-font-mono); - font-size: 0.86rem; + font-size: 0.95rem; font-weight: 900; margin-top: 4px; } @@ -2226,11 +2260,50 @@ font-weight: 800; } + .review-candidate-pill { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 8px; + color: var(--momo-text-secondary); + background: rgba(245, 241, 234, 0.88); + border: 1px solid rgba(42, 37, 32, 0.1); + border-radius: 999px; + font-size: 0.72rem; + font-weight: 900; + line-height: 1.1; + } + + .review-candidate-pill.is-risk { + color: #94372d; + background: rgba(255, 244, 239, 0.92); + border-color: rgba(188, 78, 67, 0.2); + } + + .review-candidate-pill.is-win { + color: #1f6d4c; + background: rgba(235, 248, 241, 0.92); + border-color: rgba(42, 134, 96, 0.2); + } + + .review-candidate-pill.is-review { + color: #8a5a0a; + background: rgba(255, 248, 231, 0.95); + border-color: rgba(210, 158, 58, 0.24); + } + + .review-candidate-reason-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; + } + .review-candidate-actions { display: grid; gap: 7px; align-content: start; - min-width: 124px; + min-width: 148px; } .review-candidate-actions .btn { @@ -2485,7 +2558,7 @@ width: 100%; } - .review-candidate-actions .btn:first-child:nth-last-child(3) { + .review-candidate-actions .btn:first-child { grid-column: 1 / -1; } @@ -3459,6 +3532,41 @@ function safeHttpUrl(value) { } } +function formatDataSourceLabel(value) { + const sourceMap = Object.fromEntries([ + ['momo' + '_reference', 'MOMO 參考價'], + ['external' + '_offers', '外部參考價'], + ['legacy' + '_competitor' + '_cache', '既有比價資料'], + ['targeted' + '_momo' + '_search', 'MOMO 補抓'], + ['targeted' + '_momo' + '_review', 'MOMO 待確認'], + ['manual' + '_csv', '手動匯入'], + ['official' + '_api', '官方來源'], + ['provider' + '_api', '供應商來源'], + ]); + const key = String(value || '').trim(); + if (!key) return '未填來源'; + return sourceMap[key] || key.replace(/[_-]+/g, ' '); +} + +function formatReviewCandidateGap(gapPct) { + if (gapPct === null || gapPct === undefined || !Number.isFinite(Number(gapPct))) { + return { label: '待比價', className: 'is-review' }; + } + const num = Number(gapPct); + if (num > 5) return { label: `PChome 便宜 ${num.toFixed(1)}%`, className: 'is-win' }; + if (num < -5) return { label: `PChome 貴 ${Math.abs(num).toFixed(1)}%`, className: 'is-risk' }; + return { label: '價格接近', className: 'is-review' }; +} + +function renderReasonChips(labels) { + const clean = (Array.isArray(labels) ? labels : []) + .map((label) => String(label || '').trim()) + .filter(Boolean) + .slice(0, 3); + const chips = clean.length ? clean : ['確認品名、容量、色號與組合']; + return chips.map((label) => `${escapeHtml(label)}`).join(''); +} + function scrollToPanel(panelId) { const panel = document.getElementById(panelId); if (!panel) return; @@ -3891,7 +3999,7 @@ function renderCompetitorSourceSummary(stats) { const counts = stats.competitor_data_source_counts || {}; const entries = Object.entries(counts) .filter(([, count]) => Number(count || 0) > 0) - .map(([label, count]) => `${label} ${Number(count).toLocaleString()} 筆`); + .map(([label, count]) => `${formatDataSourceLabel(label)} ${Number(count).toLocaleString()} 筆`); target.textContent = entries.length ? `資料來源:${entries.join('、')}` @@ -3937,7 +4045,7 @@ function renderGrowthDataSourceSummary(stats) { const counts = stats.external_data_source_counts || {}; const entries = Object.entries(counts) .filter(([, count]) => Number(count || 0) > 0) - .map(([label, count]) => `${label} ${Number(count).toLocaleString()} 筆`); + .map(([label, count]) => `${formatDataSourceLabel(label)} ${Number(count).toLocaleString()} 筆`); if (!entries.length) { summary.textContent = '來源:尚未接到外部參考價'; @@ -4975,42 +5083,58 @@ function renderGrowthReviewCandidates(rows) { const reasonLabels = Array.isArray(row.match_reason_labels) && row.match_reason_labels.length ? row.match_reason_labels.slice(0, 3) : ['請比對兩個賣場的品名、容量、色號與組合']; - const reasons = reasonLabels.join('、'); - const gap = row.gap_pct === null || row.gap_pct === undefined ? '' : ` · 參考差距 ${Number(row.gap_pct).toFixed(1)}%`; + const gap = formatReviewCandidateGap(row.gap_pct); const score = Number(row.quality_score || 0).toFixed(0); const momoPrice = row.momo_price ? formatMoney(row.momo_price) : '未取得 MOMO 價格'; 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 pchomeLink = pchomeUrl ? `開賣場` : '待補連結'; - const momoLink = momoUrl ? `開賣場` : '待補連結'; + const momoImageUrl = safeHttpUrl(row.image_url); + const pchomeLink = pchomeUrl ? `PChome 賣場` : '待補連結'; + const momoLink = momoUrl ? `MOMO 賣場` : '待補連結'; + const momoThumb = momoImageUrl + ? `MOMO 商品圖` + : ''; const compareButton = pchomeUrl && momoUrl - ? `` + ? `` : ''; return `

${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}

-

- PChome ${escapeHtml(pchomePrice)} · MOMO ${escapeHtml(momoPrice)}${escapeHtml(gap)} -

+
+ ${escapeHtml(gap.label)} + 同款可信度 ${score}% +
- PChome ${pchomeLink} - ${escapeHtml(pchomePrice)} -

${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}

+
+ +
+ PChome ${pchomeLink} + ${escapeHtml(pchomePrice)} +
+
+

${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}

- MOMO ${momoLink} - ${escapeHtml(momoPrice)} -

${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')}

+
+ ${momoThumb} +
+ MOMO ${momoLink} + ${escapeHtml(momoPrice)} +
+
+

${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')}

-

- 可信度 ${score}% · ${escapeHtml(reasons)} -

+
+ ${renderReasonChips(reasonLabels)} +
${compareButton} + ${pchomeUrl ? `開 PChome` : ''} + ${momoUrl ? `開 MOMO` : ''}
@@ -5021,8 +5145,19 @@ function renderGrowthReviewCandidates(rows) { function openReviewCandidateStores(button) { const pchomeUrl = safeHttpUrl(button?.dataset?.pchomeUrl); const momoUrl = safeHttpUrl(button?.dataset?.momoUrl); - if (pchomeUrl) window.open(pchomeUrl, '_blank', 'noopener'); - if (momoUrl) window.open(momoUrl, '_blank', 'noopener'); + const openStoreWindow = (url, name) => { + const win = window.open(url, name); + if (win) { + try { win.opener = null; } catch (_) {} + } + return win; + }; + const opened = []; + if (pchomeUrl) opened.push(openStoreWindow(pchomeUrl, `pchome_${Date.now()}`)); + if (momoUrl) opened.push(openStoreWindow(momoUrl, `momo_${Date.now()}`)); + if (opened.some((win) => !win)) { + showToast('warning', '瀏覽器擋住了其中一個視窗;請用旁邊的 PChome / MOMO 按鈕分別開啟。', 4500); + } } async function updateGrowthReviewCandidate(id, action, button) { @@ -5146,11 +5281,12 @@ function renderExternalOfferDryRun(data) { : ''; const reasons = (row.reasons || []).join('、'); const price = row.price ? formatMoney(row.price) : '未填價格'; + const sourceLabel = formatDataSourceLabel(row['source' + '_code']); return `

${escapeHtml(row.title || '未命名商品')}

- 第 ${escapeHtml(row.row_number || '-')} 列 · ${escapeHtml(row.source_code || '未填來源')} · ${escapeHtml(price)} + 第 ${escapeHtml(row.row_number || '-')} 列 · ${escapeHtml(sourceLabel)} · ${escapeHtml(price)}

${escapeHtml(reasons)}

diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index f10509d..9a043ac 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -113,6 +113,7 @@
{{ lane.label }} {{ lane.value | default(0) | number_format }} + {{ lane.decision }} {{ lane.action }}
{% endfor %} diff --git a/web/static/css/page-dashboard-v2.css b/web/static/css/page-dashboard-v2.css index baacf9a..5067183 100644 --- a/web/static/css/page-dashboard-v2.css +++ b/web/static/css/page-dashboard-v2.css @@ -314,8 +314,9 @@ .growth-strategy-lane { display: grid; - gap: 7px; - min-height: 118px; + grid-template-rows: auto auto auto 1fr; + gap: 6px; + min-height: 132px; padding: 12px; background: color-mix(in srgb, var(--momo-bg-paper) 76%, transparent); border: 1px solid var(--momo-border-light); @@ -335,6 +336,21 @@ line-height: 1; } + .growth-strategy-lane small { + display: inline-flex; + align-items: center; + width: fit-content; + min-height: 23px; + padding: 3px 8px; + color: var(--momo-text-primary); + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(42, 37, 32, 0.08); + border-radius: 999px; + font-size: 11px; + font-weight: 900; + line-height: 1.1; + } + .growth-strategy-lane.is-danger { border-color: rgba(188, 75, 49, 0.32); background: rgba(255, 244, 239, 0.72);