fix: improve growth comparison UX
All checks were successful
CD Pipeline / deploy (push) Successful in 1m1s

This commit is contained in:
ogt
2026-06-25 15:39:52 +08:00
parent feabc70b99
commit f8eb7f6a99
6 changed files with 199 additions and 41 deletions

View File

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

View File

@@ -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 或資料欄位名直接顯示給營運使用者。 |

View File

@@ -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',
},

View File

@@ -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) => `<span class="review-candidate-pill is-review">${escapeHtml(label)}</span>`).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 ? `<a href="${escapeHtml(pchomeUrl)}" target="_blank" rel="noopener">開賣場</a>` : '<span class="text-muted">待補連結</span>';
const momoLink = momoUrl ? `<a href="${escapeHtml(momoUrl)}" target="_blank" rel="noopener">開賣場</a>` : '<span class="text-muted">待補連結</span>';
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 compareButton = pchomeUrl && momoUrl
? `<button type="button" class="btn btn-sm btn-outline-primary" data-pchome-url="${escapeHtml(pchomeUrl)}" data-momo-url="${escapeHtml(momoUrl)}" onclick="openReviewCandidateStores(this)">同時開兩個賣場</button>`
? `<button type="button" class="btn btn-sm btn-outline-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>`
: '';
return `<article class="review-candidate-row" data-pchome-id="${escapeHtml(row.pchome_product_id || '')}">
<div>
<h3 class="review-candidate-title">${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}</h3>
<p class="review-candidate-meta">
PChome ${escapeHtml(pchomePrice)} · MOMO ${escapeHtml(momoPrice)}${escapeHtml(gap)}
</p>
<div class="review-candidate-meta">
<span class="review-candidate-pill ${escapeHtml(gap.className)}">${escapeHtml(gap.label)}</span>
<span class="review-candidate-pill">同款可信度 ${score}%</span>
</div>
<div class="review-candidate-compare" aria-label="兩家賣場比對">
<section class="review-candidate-store">
<strong>PChome ${pchomeLink}</strong>
<span class="review-candidate-store-price">${escapeHtml(pchomePrice)}</span>
<p class="review-candidate-store-title">${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}</p>
<div class="review-candidate-store-head">
<span class="review-candidate-thumb"><i class="fas fa-store"></i></span>
<div>
<strong>PChome ${pchomeLink}</strong>
<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>
</section>
<section class="review-candidate-store">
<strong>MOMO ${momoLink}</strong>
<span class="review-candidate-store-price">${escapeHtml(momoPrice)}</span>
<p class="review-candidate-store-title">${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')}</p>
<div class="review-candidate-store-head">
<span class="review-candidate-thumb">${momoThumb}</span>
<div>
<strong>MOMO ${momoLink}</strong>
<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>
</section>
</div>
<p class="review-candidate-reason">
可信度 ${score}% · ${escapeHtml(reasons)}
</p>
<div class="review-candidate-reason-chips" aria-label="需要確認的重點">
${renderReasonChips(reasonLabels)}
</div>
</div>
<div class="review-candidate-actions">
${compareButton}
${pchomeUrl ? `<a class="btn btn-sm btn-outline-secondary" href="${escapeHtml(pchomeUrl)}" target="_blank" rel="noopener noreferrer">開 PChome</a>` : ''}
${momoUrl ? `<a class="btn btn-sm btn-outline-secondary" href="${escapeHtml(momoUrl)}" target="_blank" rel="noopener noreferrer">開 MOMO</a>` : ''}
<button type="button" class="btn btn-sm btn-success" onclick="updateGrowthReviewCandidate(${Number(row.id)}, 'confirm', this)">確認同款</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="updateGrowthReviewCandidate(${Number(row.id)}, 'reject', this)">不是同款</button>
</div>
@@ -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 `<article class="offer-dryrun-row">
<div>
<h3 class="offer-dryrun-title">${escapeHtml(row.title || '未命名商品')}</h3>
<p class="offer-dryrun-meta">
${escapeHtml(row.row_number || '-')} 列 · ${escapeHtml(row.source_code || '未填來源')} · ${escapeHtml(price)}
${escapeHtml(row.row_number || '-')} 列 · ${escapeHtml(sourceLabel)} · ${escapeHtml(price)}
</p>
<p class="offer-dryrun-reason">${escapeHtml(reasons)}</p>
</div>

View File

@@ -113,6 +113,7 @@
<div class="growth-strategy-lane is-{{ lane.tone | default('neutral') }}">
<span>{{ lane.label }}</span>
<strong class="momo-mono">{{ lane.value | default(0) | number_format }}</strong>
<small>{{ lane.decision }}</small>
<em>{{ lane.action }}</em>
</div>
{% endfor %}

View File

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