feat: link growth dashboard metrics to details
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
ogt
2026-06-24 14:28:17 +08:00
parent 06418878e0
commit e6deaa4711
5 changed files with 258 additions and 26 deletions

View File

@@ -298,6 +298,25 @@
line-height: 1;
}
.growth-exec-card.is-clickable,
.ops-dashboard-tile.is-clickable,
.growth-metric.is-clickable {
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.growth-exec-card.is-clickable:hover,
.growth-exec-card.is-clickable:focus,
.ops-dashboard-tile.is-clickable:hover,
.ops-dashboard-tile.is-clickable:focus,
.growth-metric.is-clickable:hover,
.growth-metric.is-clickable:focus {
border-color: rgba(172, 92, 58, 0.34);
box-shadow: 0 0 0 3px rgba(172, 92, 58, 0.1), var(--momo-shadow-soft);
outline: none;
transform: translateY(-1px);
}
.growth-exec-detail {
margin-top: 7px;
color: var(--momo-text-muted);
@@ -306,6 +325,17 @@
line-height: 1.4;
}
.drilldown-hint {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 6px;
color: var(--momo-warm-rust);
font-size: 0.7rem;
font-weight: 900;
white-space: nowrap;
}
.ops-flow {
border: 1px solid var(--momo-border-subtle);
border-radius: 8px;
@@ -878,6 +908,13 @@
border-bottom: 0;
}
.review-candidate-row.is-highlight {
border-radius: 8px;
background: rgba(242, 178, 90, 0.16);
box-shadow: inset 3px 0 0 var(--momo-warm-caramel);
padding-left: 10px;
}
.review-candidate-title {
margin: 0;
color: var(--momo-text-strong);
@@ -1114,37 +1151,37 @@
</section>
<section class="growth-executive-strip" aria-label="今日任務摘要">
<article class="growth-exec-card is-primary" id="growthExecTaskCard">
<article class="growth-exec-card is-primary is-clickable" id="growthExecTaskCard" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="growth-exec-label">
<span>今日任務</span>
<i class="fas fa-location-arrow"></i>
</div>
<div class="growth-exec-value" id="growthExecTask">整理中</div>
<div class="growth-exec-detail" id="growthExecTaskDetail">正在讀取 PChome 業績與 MOMO 外部價格。</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">
<article class="growth-exec-card is-ready is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="growth-exec-label">
<span>可立即處理</span>
<i class="fas fa-circle-check"></i>
</div>
<div class="growth-exec-value" id="growthExecReady"></div>
<div class="growth-exec-detail">已有可用比價資料</div>
<div class="growth-exec-detail">已有可用比價資料<span class="drilldown-hint">看清單</span></div>
</article>
<article class="growth-exec-card is-gap" id="growthExecGapCard">
<article class="growth-exec-card is-gap is-clickable" id="growthExecGapCard" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="growth-exec-label">
<span>待補比價</span>
<i class="fas fa-link-slash"></i>
</div>
<div class="growth-exec-value" id="growthExecGap"></div>
<div class="growth-exec-detail">有業績但缺外部參考</div>
<div class="growth-exec-detail">有業績但缺外部參考<span class="drilldown-hint">看商品</span></div>
</article>
<article class="growth-exec-card">
<article class="growth-exec-card is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="growth-exec-label">
<span>最新業績日</span>
<i class="fas fa-calendar-day"></i>
</div>
<div class="growth-exec-value" id="growthExecLatestDate"></div>
<div class="growth-exec-detail" id="growthExecLatestDetail">等待資料</div>
<div class="growth-exec-detail"><span id="growthExecLatestDetail">等待資料</span><span class="drilldown-hint">看清單</span></div>
</article>
</section>
@@ -1167,7 +1204,7 @@
</button>
</div>
<div class="ops-flow-grid">
<div class="ops-dashboard-tile">
<div class="ops-dashboard-tile is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="ops-dashboard-title">
<span>商品處理進度</span>
<strong class="ops-dashboard-value" id="opsReadyRate">—%</strong>
@@ -1187,7 +1224,7 @@
</div>
</div>
</div>
<div class="ops-dashboard-tile">
<div class="ops-dashboard-tile is-clickable" role="button" tabindex="0" onclick="scrollToPanel('externalPricePanel')" onkeydown="handleDrilldownKey(event, 'externalPricePanel')">
<div class="ops-dashboard-title">
<span>外部價格來源</span>
<strong class="ops-dashboard-value" id="opsSourceTotal"></strong>
@@ -1241,19 +1278,19 @@
<div class="growth-ops-grid">
<div>
<div class="growth-metric-row">
<div class="growth-metric">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<strong id="growthCandidateCount"></strong>
<span>追蹤商品</span>
</div>
<div class="growth-metric">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<strong id="growthMappedCount"></strong>
<span>可立即處理</span>
</div>
<div class="growth-metric">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<strong id="growthNeedsMapping"></strong>
<span>無法比價</span>
</div>
<div class="growth-metric">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthReviewPanel')" onkeydown="handleDrilldownKey(event, 'growthReviewPanel')">
<strong id="growthReviewCandidateCount"></strong>
<span>待確認</span>
</div>
@@ -1542,6 +1579,10 @@ function bindActionDelegation() {
backfillPchomeMatches();
return;
}
if (growthButton.dataset.growthAction === 'review-candidate') {
focusReviewCandidate(growthButton.dataset.productKey || '');
return;
}
focusPriceTable(growthButton.dataset.productKey || '');
});
}
@@ -1555,6 +1596,22 @@ function focusPriceTable(keyword) {
scrollToPanel('externalPricePanel');
}
function focusReviewCandidate(productKey) {
scrollToPanel('growthReviewPanel');
const key = String(productKey || '').trim();
if (!key) return;
setTimeout(() => {
document.querySelectorAll('.review-candidate-row.is-highlight')
.forEach((row) => row.classList.remove('is-highlight'));
const target = Array.from(document.querySelectorAll('.review-candidate-row'))
.find((row) => row.dataset.pchomeId === key);
if (target) {
target.classList.add('is-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 220);
}
async function loadDashboard() {
try {
const res = await fetch('/api/ai/icaim/dashboard');
@@ -1627,6 +1684,12 @@ function scrollToPanel(panelId) {
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function handleDrilldownKey(event, panelId) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
scrollToPanel(panelId);
}
function clampPercent(value) {
return Math.max(0, Math.min(100, Number(value || 0)));
}
@@ -1680,6 +1743,7 @@ function renderOpsCommandDashboard(stats, scope = {}) {
const candidateCount = Number(stats.candidate_count || 0);
const mappedCount = Number(stats.mapped_count || 0);
const needsMapping = Number(stats.needs_mapping_count || 0);
const reviewCandidateCount = Number(stats.review_candidate_count || 0);
const readyRate = candidateCount ? Math.round((mappedCount / candidateCount) * 100) : 0;
document.getElementById('opsReadyRate').textContent = `${readyRate}%`;
@@ -1689,7 +1753,7 @@ function renderOpsCommandDashboard(stats, scope = {}) {
document.getElementById('opsDashboardStatus').textContent = candidateCount
? `資料就緒率 ${readyRate}%`
: '等待 PChome 業績資料';
renderNextAction(candidateCount, mappedCount, needsMapping);
renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandidateCount);
setWidth('opsFunnelCandidateBar', candidateCount ? 100 : 0);
setWidth('opsFunnelMappedBar', candidateCount ? (mappedCount / candidateCount) * 100 : 0);
@@ -1698,7 +1762,7 @@ function renderOpsCommandDashboard(stats, scope = {}) {
renderGrowthExecutiveSummary(stats);
}
function renderNextAction(candidateCount, mappedCount, needsMapping) {
function renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandidateCount = 0) {
const title = document.getElementById('nextActionTitle');
const reason = document.getElementById('nextActionReason');
const button = document.getElementById('nextActionButton');
@@ -1714,6 +1778,14 @@ function renderNextAction(candidateCount, mappedCount, needsMapping) {
}
if (needsMapping > 0 && mappedCount === 0) {
if (reviewCandidateCount > 0) {
title.textContent = `今天先做:確認 ${formatCount(reviewCandidateCount)} 筆 MOMO 候選`;
reason.textContent = '候選已找到,先確認同款或排除,確認後才會進入價格判斷。';
button.textContent = '確認候選';
delete button.dataset.action;
button.onclick = () => scrollToPanel('growthReviewPanel');
return;
}
title.textContent = `今天先做:補齊 ${formatCount(needsMapping)} 件比價資料`;
reason.textContent = '這些商品有業績,但目前還看不到 MOMO 參考價,請先補齊比價資料。';
button.textContent = '補齊比價資料';
@@ -1723,6 +1795,14 @@ function renderNextAction(candidateCount, mappedCount, needsMapping) {
}
if (needsMapping > mappedCount) {
if (reviewCandidateCount > 0) {
title.textContent = `今天先做:確認 ${formatCount(reviewCandidateCount)} 筆 MOMO 候選`;
reason.textContent = '先把已找到的候選變成可用資料,再補剩下找不到同款的商品。';
button.textContent = '確認候選';
delete button.dataset.action;
button.onclick = () => scrollToPanel('growthReviewPanel');
return;
}
title.textContent = `今天先做:補齊 ${formatCount(needsMapping)} 件比價資料`;
reason.textContent = '目前無法比價的商品比可處理商品多,請先補齊商品對應。';
button.textContent = '補齊比價資料';
@@ -1742,6 +1822,7 @@ function renderGrowthExecutiveSummary(stats = {}) {
const candidateCount = Number(stats.candidate_count || 0);
const mappedCount = Number(stats.mapped_count || 0);
const needsMapping = Number(stats.needs_mapping_count || 0);
const reviewCandidateCount = Number(stats.review_candidate_count || 0);
const latestSalesDate = String(stats.latest_sales_date || '').slice(0, 10);
document.getElementById('growthExecReady').textContent = formatCount(mappedCount);
@@ -1762,6 +1843,12 @@ function renderGrowthExecutiveSummary(stats = {}) {
}
if (needsMapping > 0 && mappedCount === 0) {
if (reviewCandidateCount > 0) {
task.textContent = `先確認 ${formatCount(reviewCandidateCount)} 筆候選`;
detail.textContent = '候選商品已找到,確認同款後才能進入價格判斷。';
gapCard?.classList.add('is-gap');
return;
}
task.textContent = `先補 ${formatCount(needsMapping)} 件 MOMO 參考`;
detail.textContent = '高業績商品還不能比價,先補對應資料才會有可行動建議。';
gapCard?.classList.add('is-gap');
@@ -1769,6 +1856,12 @@ function renderGrowthExecutiveSummary(stats = {}) {
}
if (needsMapping > mappedCount) {
if (reviewCandidateCount > 0) {
task.textContent = `先確認 ${formatCount(reviewCandidateCount)} 筆候選`;
detail.textContent = '先處理候選清單,再補剩下找不到同款的商品。';
gapCard?.classList.add('is-gap');
return;
}
task.textContent = `先補比價,再處理 ${formatCount(mappedCount)}`;
detail.textContent = '待補比價比可處理商品多,先擴大 MOMO 對應覆蓋率。';
gapCard?.classList.add('is-gap');
@@ -1962,9 +2055,12 @@ function renderGrowthOps(rows) {
const action = row.recommended_action || {};
const reason = (row.reason_lines || []).slice(0, 2).join(' ');
const price = row.external_price;
const reviewCandidate = row.review_candidate || null;
const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
const basisLabel = price?.price_basis_label || '商品總價';
const priceText = gap === null
const priceText = reviewCandidate
? '候選待確認'
: gap === null
? '資料不足,先補比價'
: gap < 0
? `${basisLabel} PChome 貴 ${Math.abs(gap).toFixed(1)}%`
@@ -1980,8 +2076,16 @@ function renderGrowthOps(rows) {
const qualityLabel = quality.label || (price ? '可直接參考' : '資料不足');
const qualityIssues = Array.isArray(quality.issues) ? quality.issues.join('、') : '';
const productKey = escapeHtml(row.pchome_product_id || row.product_name || '');
const nextLabel = action.code === 'map_external_product' ? '補齊比價' : '檢查價格';
const nextAction = action.code === 'map_external_product' ? 'backfill' : 'focus-price';
const nextLabel = action.code === 'review_external_candidate'
? '確認候選'
: action.code === 'map_external_product'
? '補齊比價'
: '檢查價格';
const nextAction = action.code === 'review_external_candidate'
? 'review-candidate'
: action.code === 'map_external_product'
? 'backfill'
: 'focus-price';
return `<tr>
<td data-label="優先級">
<span class="badge ${priorityClass}">${priority}</span>
@@ -2073,7 +2177,7 @@ function renderGrowthReviewCandidates(rows) {
const pchomePrice = row.pchome_price ? formatMoney(row.pchome_price) : '未取得 PChome 價格';
const safeUrl = safeHttpUrl(row.product_url);
const url = safeUrl ? `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener">看 MOMO</a>` : '';
return `<article class="review-candidate-row">
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">