fix: improve growth comparison UX
All checks were successful
CD Pipeline / deploy (push) Successful in 1m1s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m1s
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 或資料欄位名直接顯示給營運使用者。 |
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user