feat: add growth dashboard drilldown details
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s

This commit is contained in:
ogt
2026-06-24 15:28:53 +08:00
parent e6deaa4711
commit 873e0ce902
4 changed files with 318 additions and 17 deletions

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.641"
SYSTEM_VERSION = "V10.642"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -77,6 +77,7 @@
- V10.639 起待確認候選排序必須容忍缺少單位數量;沒有 `momo_total_quantity` / `competitor_total_quantity` 時仍可保存為 `needs_review`,不得中斷 PChome 導向 MOMO 回填。
- V10.640 起 `/ai_intelligence` 必須提供 MOMO 待確認候選操作佇列;使用者可直接確認同款或排除候選。確認後 `external_offers` 會轉為 `verified/verified` 並進入作戰清單,排除後轉為 `rejected/rejected`,兩者都必須清掉 PChome 成長作戰清單快取。
- V10.641 起 `/ai_intelligence` 的摘要數字不可只是靜態文字;第一屏 KPI、商品處理進度、待確認數字都必須可點擊並導向對應明細。今日清單若已有 MOMO 待確認候選,下一步必須顯示「確認候選」並跳到候選面板,不得再只顯示「補齊比價」。
- V10.642 起 `/ai_intelligence` 的摘要卡與商品處理數字不可只跳到大區塊;點擊後必須開啟商品明細面板,列出商品名稱、分類、近 7 天業績、業績變化、MOMO 比價狀態與下一步按鈕。明細需至少支援全部、價格壓力、價格優勢、待確認、缺比價與有外部價切換;外部價格風險分佈也必須能一鍵篩選下方表格。
## 零之一、12 Agent 決策信封2026-05-24

View File

@@ -473,6 +473,20 @@
gap: 5px;
}
.price-risk-row[role="button"] {
border-radius: 8px;
cursor: pointer;
padding: 6px;
transition: background-color 0.16s ease, transform 0.16s ease;
}
.price-risk-row[role="button"]:hover,
.price-risk-row[role="button"]:focus {
background: rgba(255, 255, 255, 0.82);
outline: 0;
transform: translateY(-1px);
}
.ops-funnel-meta,
.ops-source-meta,
.price-risk-meta {
@@ -624,6 +638,98 @@
padding: 8px 10px;
}
.growth-detail-panel {
margin-top: 12px;
}
.growth-detail-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.growth-detail-title {
margin: 0;
color: var(--momo-text-strong);
font-size: 0.95rem;
font-weight: 900;
line-height: 1.3;
}
.growth-detail-meta {
color: var(--momo-text-muted);
font-size: 0.76rem;
font-weight: 800;
}
.growth-detail-tabs {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.growth-detail-tab {
border: 1px solid rgba(42, 37, 32, 0.12);
border-radius: 999px;
background: rgba(250, 247, 240, 0.66);
color: var(--momo-text-muted);
font-size: 0.74rem;
font-weight: 900;
padding: 6px 10px;
}
.growth-detail-tab.is-active {
border-color: rgba(172, 92, 58, 0.34);
background: rgba(242, 178, 90, 0.18);
color: var(--momo-text-strong);
}
.growth-detail-result {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.76);
min-height: 190px;
max-height: 390px;
overflow: auto;
}
.growth-detail-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(118px, auto);
gap: 12px;
border-bottom: 1px solid rgba(42, 37, 32, 0.08);
padding: 11px 12px;
}
.growth-detail-row:last-child {
border-bottom: 0;
}
.growth-detail-name {
margin: 0;
color: var(--momo-text-strong);
font-size: 0.86rem;
font-weight: 900;
line-height: 1.35;
}
.growth-detail-line {
margin: 4px 0 0;
color: var(--momo-text-muted);
font-size: 0.74rem;
line-height: 1.4;
}
.growth-detail-action {
display: grid;
gap: 6px;
align-content: start;
justify-items: end;
}
.growth-source-list {
display: grid;
gap: 8px;
@@ -1111,9 +1217,14 @@
}
.growth-item,
.growth-detail-row,
.offer-dryrun-row {
grid-template-columns: 1fr;
}
.growth-detail-action {
justify-items: stretch;
}
}
</style>
{% endblock %}
@@ -1151,7 +1262,7 @@
</section>
<section class="growth-executive-strip" aria-label="今日任務摘要">
<article class="growth-exec-card is-primary is-clickable" id="growthExecTaskCard" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<article class="growth-exec-card is-primary is-clickable" id="growthExecTaskCard" role="button" tabindex="0" onclick="showGrowthDetail('task')" onkeydown="handleGrowthDetailKey(event, 'task')">
<div class="growth-exec-label">
<span>今日任務</span>
<i class="fas fa-location-arrow"></i>
@@ -1159,7 +1270,7 @@
<div class="growth-exec-value" id="growthExecTask">整理中</div>
<div class="growth-exec-detail"><span id="growthExecTaskDetail">正在讀取 PChome 業績與 MOMO 外部價格。</span><span class="drilldown-hint">看明細</span></div>
</article>
<article class="growth-exec-card is-ready is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<article class="growth-exec-card is-ready is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('ready')" onkeydown="handleGrowthDetailKey(event, 'ready')">
<div class="growth-exec-label">
<span>可立即處理</span>
<i class="fas fa-circle-check"></i>
@@ -1167,7 +1278,7 @@
<div class="growth-exec-value" id="growthExecReady"></div>
<div class="growth-exec-detail">已有可用比價資料<span class="drilldown-hint">看清單</span></div>
</article>
<article class="growth-exec-card is-gap is-clickable" id="growthExecGapCard" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<article class="growth-exec-card is-gap is-clickable" id="growthExecGapCard" role="button" tabindex="0" onclick="showGrowthDetail('needs')" onkeydown="handleGrowthDetailKey(event, 'needs')">
<div class="growth-exec-label">
<span>待補比價</span>
<i class="fas fa-link-slash"></i>
@@ -1175,7 +1286,7 @@
<div class="growth-exec-value" id="growthExecGap"></div>
<div class="growth-exec-detail">有業績但缺外部參考<span class="drilldown-hint">看商品</span></div>
</article>
<article class="growth-exec-card is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<article class="growth-exec-card is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('all')" onkeydown="handleGrowthDetailKey(event, 'all')">
<div class="growth-exec-label">
<span>最新業績日</span>
<i class="fas fa-calendar-day"></i>
@@ -1204,7 +1315,7 @@
</button>
</div>
<div class="ops-flow-grid">
<div class="ops-dashboard-tile is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="ops-dashboard-tile is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('all')" onkeydown="handleGrowthDetailKey(event, 'all')">
<div class="ops-dashboard-title">
<span>商品處理進度</span>
<strong class="ops-dashboard-value" id="opsReadyRate">—%</strong>
@@ -1224,7 +1335,7 @@
</div>
</div>
</div>
<div class="ops-dashboard-tile is-clickable" role="button" tabindex="0" onclick="scrollToPanel('externalPricePanel')" onkeydown="handleDrilldownKey(event, 'externalPricePanel')">
<div class="ops-dashboard-tile is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('source')" onkeydown="handleGrowthDetailKey(event, 'source')">
<div class="ops-dashboard-title">
<span>外部價格來源</span>
<strong class="ops-dashboard-value" id="opsSourceTotal"></strong>
@@ -1263,6 +1374,31 @@
</div>
</section>
<!-- ── 點擊摘要後的商品明細 ── -->
<section class="card shadow-sm ai-panel growth-detail-panel" id="growthDrilldownPanel">
<div class="card-body">
<div class="growth-detail-toolbar">
<div>
<h2 class="growth-detail-title" id="growthDrilldownTitle">今日商品明細</h2>
<div class="growth-detail-meta" id="growthDrilldownMeta">整理中</div>
</div>
<div class="growth-detail-tabs" aria-label="明細切換">
<button type="button" class="growth-detail-tab is-active" data-detail-kind="all" onclick="showGrowthDetail('all')">全部</button>
<button type="button" class="growth-detail-tab" data-detail-kind="risk" onclick="showGrowthDetail('risk')">價格壓力</button>
<button type="button" class="growth-detail-tab" data-detail-kind="advantage" onclick="showGrowthDetail('advantage')">價格優勢</button>
<button type="button" class="growth-detail-tab" data-detail-kind="review" onclick="showGrowthDetail('review')">待確認</button>
<button type="button" class="growth-detail-tab" data-detail-kind="needs" onclick="showGrowthDetail('needs')">缺比價</button>
<button type="button" class="growth-detail-tab" data-detail-kind="source" onclick="showGrowthDetail('source')">有外部價</button>
</div>
</div>
<div class="growth-detail-result" id="growthDrilldownResult">
<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>整理商品明細中...
</div>
</div>
</div>
</section>
<!-- ── 今日處理清單 ── -->
<section class="card shadow-sm ai-panel" id="growthOpsPanel">
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
@@ -1278,19 +1414,19 @@
<div class="growth-ops-grid">
<div>
<div class="growth-metric-row">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('all')" onkeydown="handleGrowthDetailKey(event, 'all')">
<strong id="growthCandidateCount"></strong>
<span>追蹤商品</span>
</div>
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('ready')" onkeydown="handleGrowthDetailKey(event, 'ready')">
<strong id="growthMappedCount"></strong>
<span>可立即處理</span>
</div>
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('needs')" onkeydown="handleGrowthDetailKey(event, 'needs')">
<strong id="growthNeedsMapping"></strong>
<span>無法比價</span>
</div>
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthReviewPanel')" onkeydown="handleDrilldownKey(event, 'growthReviewPanel')">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('review')" onkeydown="handleGrowthDetailKey(event, 'review')">
<strong id="growthReviewCandidateCount"></strong>
<span>待確認</span>
</div>
@@ -1418,15 +1554,15 @@
</div>
</div>
<div class="price-risk-board" id="priceRiskBoard" aria-label="價格風險分佈">
<div class="price-risk-row">
<div class="price-risk-row" role="button" tabindex="0" onclick="showPriceRiskDetail('HIGH')" onkeydown="handlePriceRiskKey(event, 'HIGH')">
<div class="price-risk-meta"><span>需檢查價格</span><strong id="priceRiskHighText"></strong></div>
<div class="price-risk-track"><span class="price-risk-bar is-high" id="priceRiskHighBar"></span></div>
</div>
<div class="price-risk-row">
<div class="price-risk-row" role="button" tabindex="0" onclick="showPriceRiskDetail('MED')" onkeydown="handlePriceRiskKey(event, 'MED')">
<div class="price-risk-meta"><span>留意價差</span><strong id="priceRiskMediumText"></strong></div>
<div class="price-risk-track"><span class="price-risk-bar is-medium" id="priceRiskMediumBar"></span></div>
</div>
<div class="price-risk-row">
<div class="price-risk-row" role="button" tabindex="0" onclick="showPriceRiskDetail('LOW')" onkeydown="handlePriceRiskKey(event, 'LOW')">
<div class="price-risk-meta"><span>價格有利</span><strong id="priceRiskLowText"></strong></div>
<div class="price-risk-track"><span class="price-risk-bar is-low" id="priceRiskLowBar"></span></div>
</div>
@@ -1557,6 +1693,8 @@
// ── 全域資料 ────────────────────────────────────────
let allCompetitors = [];
let latestGrowthStats = {};
let latestGrowthRows = [];
let activeGrowthDetailKind = 'all';
// ── 頁面載入 ────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
@@ -1690,6 +1828,18 @@ function handleDrilldownKey(event, panelId) {
scrollToPanel(panelId);
}
function handleGrowthDetailKey(event, kind) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
showGrowthDetail(kind);
}
function handlePriceRiskKey(event, risk) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
showPriceRiskDetail(risk);
}
function clampPercent(value) {
return Math.max(0, Math.min(100, Number(value || 0)));
}
@@ -1912,7 +2062,7 @@ async function loadGrowthOps(forceRefresh = false) {
}
try {
const url = '/api/ai/pchome-growth/opportunities?limit=8' + (forceRefresh ? '&refresh=1' : '');
const url = '/api/ai/pchome-growth/opportunities?limit=50' + (forceRefresh ? '&refresh=1' : '');
const res = await fetch(url);
const data = await readJsonResponse(res);
if (!data.success) throw new Error(data.error || '讀取失敗');
@@ -1933,13 +2083,17 @@ async function loadGrowthOps(forceRefresh = false) {
`業績:${scope.primary_sales_source || 'PChome 後台業績'} · 外部:${active} · 暫停:${paused}`;
renderGrowthSourceReadiness((scope.source_readiness || {}).sources || []);
renderGrowthOps(data.opportunities || []);
latestGrowthRows = data.opportunities || [];
renderGrowthOps(latestGrowthRows);
renderGrowthDetail(activeGrowthDetailKind);
loadGrowthReviewCandidates();
} catch (error) {
console.error(error);
latestGrowthRows = [];
renderOpsCommandDashboard({}, {});
renderGrowthActionHint({ candidate_count: 0, mapped_count: 0, needs_mapping_count: 0 });
renderGrowthDataSourceSummary({});
renderGrowthDetail('all');
renderGrowthReviewCandidates([]);
list.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-exclamation d-block mb-2"></i>
@@ -2041,6 +2195,131 @@ function renderGrowthSourceReadiness(sources) {
}).join('');
}
function resolveGrowthDetailKind(kind) {
if (kind !== 'task') return kind || 'all';
const reviewCandidateCount = Number(latestGrowthStats.review_candidate_count || 0);
const needsMapping = Number(latestGrowthStats.needs_mapping_count || 0);
const mappedCount = Number(latestGrowthStats.mapped_count || 0);
if (reviewCandidateCount > 0) return 'review';
if (needsMapping > mappedCount) return 'needs';
return 'risk';
}
function growthDetailConfig(kind) {
const topCategory = latestGrowthStats.top_category || '';
const configs = {
all: ['今日商品明細', '依優先級排序,先看高業績、下滑或價格有壓力的商品。'],
ready: ['可立即處理商品', '已有 MOMO 參考價,可以直接檢查售價、活動或曝光。'],
needs: ['缺比價商品', '有 PChome 業績,但還沒有可用 MOMO 參考。'],
review: ['MOMO 候選待確認', '候選已找到,確認同款後才會進入價格判斷。'],
risk: ['MOMO 更便宜商品', 'MOMO 參考價較低,優先檢查 PChome 售價、券或組合。'],
advantage: ['PChome 價格優勢商品', 'PChome 目前較有價格優勢,適合檢查曝光與主推位置。'],
source: ['外部價格來源明細', '只列出已接到 MOMO 外部參考價的商品。'],
decline: ['業績下滑商品', '近 7 天比前 7 天下滑的商品。'],
category: [topCategory ? `${topCategory} 商品明細` : '最大業績分類明細', '目前最大業績分類內的商品。'],
};
return configs[kind] || configs.all;
}
function growthDetailRows(kind) {
const rows = Array.isArray(latestGrowthRows) ? [...latestGrowthRows] : [];
const topCategory = latestGrowthStats.top_category || '';
const filtered = rows.filter((row) => {
const actionCode = row.recommended_action?.code || '';
const price = row.external_price || null;
const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
if (kind === 'ready') return Boolean(price);
if (kind === 'needs') return !price && !row.review_candidate;
if (kind === 'review') return Boolean(row.review_candidate) || actionCode === 'review_external_candidate';
if (kind === 'risk') return Boolean(price) && (actionCode === 'review_price_or_promo' || (gap !== null && gap < -5));
if (kind === 'advantage') return Boolean(price) && (actionCode === 'amplify_price_advantage' || (gap !== null && gap > 5));
if (kind === 'source') return Boolean(price);
if (kind === 'decline') return Number(row.sales_delta_pct || 0) < 0;
if (kind === 'category') return topCategory && row.category === topCategory;
return true;
});
return filtered.sort((a, b) => Number(b.priority_score || 0) - Number(a.priority_score || 0));
}
function showGrowthDetail(kind, shouldScroll = true) {
activeGrowthDetailKind = resolveGrowthDetailKind(kind);
renderGrowthDetail(activeGrowthDetailKind);
if (shouldScroll) scrollToPanel('growthDrilldownPanel');
}
function renderGrowthDetail(kind = activeGrowthDetailKind) {
const titleBox = document.getElementById('growthDrilldownTitle');
const metaBox = document.getElementById('growthDrilldownMeta');
const resultBox = document.getElementById('growthDrilldownResult');
if (!titleBox || !metaBox || !resultBox) return;
activeGrowthDetailKind = resolveGrowthDetailKind(kind);
document.querySelectorAll('.growth-detail-tab').forEach((tab) => {
tab.classList.toggle('is-active', tab.dataset.detailKind === activeGrowthDetailKind);
});
const [title, subtitle] = growthDetailConfig(activeGrowthDetailKind);
const rows = growthDetailRows(activeGrowthDetailKind);
const visibleRows = rows.slice(0, 50);
const salesTotal = rows.reduce((sum, row) => sum + Number(row.sales_7d || 0), 0);
titleBox.textContent = title;
metaBox.textContent = `${rows.length.toLocaleString()} 件 · 近 7 天業績 ${formatMoney(salesTotal)} · ${subtitle} · 先列 ${visibleRows.length.toLocaleString()}`;
if (!rows.length) {
resultBox.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-info d-block mb-2"></i>
目前沒有符合這個條件的商品。
</div>`;
return;
}
resultBox.innerHTML = visibleRows.map((row) => {
const action = row.recommended_action || {};
const price = row.external_price || null;
const reviewCandidate = row.review_candidate || null;
const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
const gapText = reviewCandidate
? '候選待確認'
: gap === null
? '缺 MOMO 參考'
: gap < 0
? `PChome 貴 ${Math.abs(gap).toFixed(1)}%`
: gap > 0
? `PChome 便宜 ${gap.toFixed(1)}%`
: '價格差不多';
const delta = row.sales_delta_pct === null || row.sales_delta_pct === undefined
? '前期不足'
: `${Number(row.sales_delta_pct).toFixed(1)}%`;
const productKey = escapeHtml(row.pchome_product_id || row.product_name || '');
const nextAction = action.code === 'review_external_candidate'
? 'review-candidate'
: action.code === 'map_external_product'
? 'backfill'
: 'focus-price';
const nextLabel = action.code === 'review_external_candidate'
? '確認候選'
: action.code === 'map_external_product'
? '補齊比價'
: '看價格';
return `<article class="growth-detail-row">
<div>
<h3 class="growth-detail-name">${escapeHtml(row.product_name || '未命名商品')}</h3>
<p class="growth-detail-line">
${escapeHtml(row.category || '未分類')} · 近 7 天 ${escapeHtml(formatMoney(row.sales_7d))} · 變化 ${escapeHtml(delta)}
</p>
<p class="growth-detail-line">
${escapeHtml(action.label || '待判斷')} · ${escapeHtml(gapText)}
</p>
</div>
<div class="growth-detail-action">
<span class="growth-action-pill">${escapeHtml(action.label || '待判斷')}</span>
<button type="button" class="btn btn-sm btn-outline-primary table-row-action" data-growth-action="${nextAction}" data-product-key="${productKey}">${nextLabel}</button>
</div>
</article>`;
}).join('');
}
function renderGrowthOps(rows) {
const list = document.getElementById('growthOpsList');
if (!rows.length) {
@@ -2051,7 +2330,8 @@ function renderGrowthOps(rows) {
return;
}
const body = rows.map((row, index) => {
const visibleRows = rows.slice(0, 12);
const body = visibleRows.map((row, index) => {
const action = row.recommended_action || {};
const reason = (row.reason_lines || []).slice(0, 2).join(' ');
const price = row.external_price;
@@ -2351,6 +2631,15 @@ function renderPriceRiskBoard(rows) {
setWidth('priceRiskLowBar', (counts.low / total) * 100);
}
function showPriceRiskDetail(risk) {
const select = document.getElementById('riskFilter');
if (select) {
select.value = risk || 'all';
filterTable();
}
scrollToPanel('externalPricePanel');
}
function renderCompetitorTable(rows) {
rows = Array.isArray(rows) ? rows : [];
const tbody = document.getElementById('competitorTbody');

View File

@@ -441,6 +441,17 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
assert "growthDataSourceSummary" in template
assert "external_data_source_counts" in template
assert "compSourceSummary" in template
assert "growthDrilldownPanel" in template
assert "showGrowthDetail" in template
assert "growthDetailRows" in template
assert "growth-detail-tab" in template
assert "今日商品明細" in template
assert "價格壓力" in template
assert "價格優勢" in template
assert "有外部價" in template
assert "showPriceRiskDetail" in template
assert "handlePriceRiskKey" in template
assert "opportunities?limit=50" in template
assert "scrollToPanel('externalPricePanel')" in template
assert "備援資料檢查" in template
assert "外部報價預檢" not in template