Files
ewoooc/templates/ai_intelligence.html
OoO 9260cc1740
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s
V10.607 建立外部市場來源正規化層
2026-06-15 16:19:03 +08:00

1125 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends 'ewoooc_base.html' %}
{% block title %}PChome 業績成長自動化作戰系統 · EwoooC{% endblock %}
{% block extra_css %}
<style>
.ai-intel-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.ai-intel-hero {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
padding: 22px;
border: 1px solid var(--momo-border-strong);
border-radius: 8px;
background:
radial-gradient(circle at 18px 18px, rgba(42, 37, 32, 0.12) 1px, transparent 1px),
linear-gradient(135deg, rgba(242, 178, 90, 0.28), rgba(255, 255, 255, 0.92) 42%, rgba(172, 92, 58, 0.12));
background-size: 18px 18px, auto;
box-shadow: var(--momo-shadow-soft);
}
.ai-intel-hero::after {
content: "";
position: absolute;
inset: auto 20px 18px auto;
width: 132px;
height: 132px;
border: 1px solid rgba(42, 37, 32, 0.12);
border-radius: 50%;
background: repeating-linear-gradient(
90deg,
rgba(42, 37, 32, 0.08) 0,
rgba(42, 37, 32, 0.08) 1px,
transparent 1px,
transparent 8px
);
opacity: 0.72;
pointer-events: none;
}
.ai-intel-title {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin: 0;
color: var(--momo-text-strong);
font-family: var(--momo-font-display);
font-size: clamp(1.45rem, 2vw, 2.15rem);
font-weight: 800;
letter-spacing: 0;
}
.ai-intel-title i {
color: var(--momo-warm-rust);
}
.ai-intel-badge,
.ai-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid rgba(42, 37, 32, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.68);
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 0.78rem;
font-weight: 800;
padding: 5px 10px;
}
.ai-status-badge.is-success {
border-color: rgba(40, 128, 80, 0.24);
background: rgba(232, 247, 238, 0.88);
color: #216542;
}
.ai-status-badge.is-error {
border-color: rgba(188, 75, 49, 0.26);
background: rgba(255, 241, 237, 0.9);
color: #9b3d2b;
}
.ai-intel-subtitle {
position: relative;
z-index: 1;
margin: 8px 0 0;
color: var(--momo-text-muted);
font-size: 0.93rem;
}
.ai-intel-actions {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
max-width: 620px;
}
.ai-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 38px;
border-radius: 8px;
font-weight: 800;
white-space: nowrap;
}
.ai-action-btn.btn-outline-danger {
color: var(--momo-warm-rust);
border-color: rgba(172, 92, 58, 0.44);
}
.ai-action-btn.btn-outline-primary {
color: var(--momo-accent-strong);
border-color: rgba(42, 37, 32, 0.24);
}
.ai-action-btn.btn-outline-warning {
color: #805313;
border-color: rgba(242, 178, 90, 0.66);
}
.ai-intel-page #kpiRow .card,
.ai-panel {
border: 1px solid var(--momo-border-subtle) !important;
border-radius: 8px;
background: rgba(255, 255, 255, 0.84);
box-shadow: var(--momo-shadow-soft);
}
.ai-intel-page #kpiRow .card-body {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 116px;
}
.ai-intel-page #kpiRow .fs-2 {
color: var(--momo-text-strong) !important;
font-family: var(--momo-font-mono);
font-size: 2rem !important;
line-height: 1.05;
}
.ai-intel-page #kpiRow .small {
color: var(--momo-text-muted) !important;
font-weight: 700;
}
.ai-intel-page #kpiHighRiskCard.border-danger {
background: linear-gradient(160deg, rgba(255, 245, 240, 0.98), rgba(255, 255, 255, 0.9));
border-color: rgba(188, 75, 49, 0.48) !important;
}
.ai-panel .card-header,
.ai-panel .card-footer {
border-color: var(--momo-border-subtle) !important;
background: rgba(255, 255, 255, 0.78) !important;
}
.ai-panel-title {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--momo-text-strong);
font-family: var(--momo-font-display);
font-size: 0.95rem;
font-weight: 800;
}
.ai-panel-title i {
color: var(--momo-warm-caramel) !important;
}
.ai-panel .form-select,
.ai-panel .form-control {
border-color: var(--momo-border-subtle);
border-radius: 8px;
color: var(--momo-text-strong);
font-size: 0.82rem;
}
.ai-legend {
border-bottom: 1px solid var(--momo-border-subtle);
}
.ai-table-scroll {
overflow-x: auto;
overflow-y: auto;
max-height: 520px;
}
.ai-intel-page .table {
--bs-table-hover-bg: rgba(242, 178, 90, 0.12);
color: var(--momo-text-strong);
}
.ai-intel-page .table thead th {
border-bottom: 1px solid var(--momo-border-strong);
background: rgba(250, 247, 240, 0.96) !important;
color: var(--momo-text-muted);
font-size: 0.76rem;
font-weight: 800;
}
.ai-intel-page .table tbody td {
border-color: rgba(42, 37, 32, 0.08);
}
.ai-recs-scroll {
overflow-y: auto;
max-height: 568px;
}
.ai-intel-page #aiRecsList > .border {
border-color: var(--momo-border-subtle) !important;
border-radius: 8px !important;
background: rgba(250, 247, 240, 0.54);
}
.growth-ops-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.7fr);
gap: 14px;
}
.growth-metric-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.growth-metric {
border: 1px solid var(--momo-border-subtle);
border-radius: 8px;
background: rgba(250, 247, 240, 0.62);
padding: 10px;
}
.growth-metric strong {
display: block;
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 1.35rem;
line-height: 1.1;
}
.growth-metric span {
color: var(--momo-text-muted);
font-size: 0.75rem;
font-weight: 800;
}
.growth-source-note {
margin-top: 10px;
color: var(--momo-text-muted);
font-size: 0.82rem;
line-height: 1.55;
}
.growth-source-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.growth-source-chip {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
padding: 8px 10px;
}
.growth-source-chip.is-active {
border-color: rgba(42, 134, 96, 0.24);
background: rgba(235, 248, 241, 0.78);
}
.growth-source-name {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--momo-text-strong);
font-size: 0.8rem;
font-weight: 900;
}
.growth-source-status {
border-radius: 999px;
background: rgba(42, 37, 32, 0.08);
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 900;
padding: 3px 7px;
white-space: nowrap;
}
.growth-source-chip.is-active .growth-source-status {
background: rgba(42, 134, 96, 0.14);
color: #1f6d4c;
}
.growth-source-detail {
margin: 5px 0 0;
color: var(--momo-text-muted);
font-size: 0.74rem;
line-height: 1.4;
}
.growth-list {
display: grid;
gap: 8px;
max-height: 292px;
overflow-y: auto;
padding-right: 4px;
}
.growth-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.78);
padding: 10px 12px;
}
.growth-item-title {
margin: 0;
color: var(--momo-text-strong);
font-size: 0.9rem;
font-weight: 800;
line-height: 1.35;
}
.growth-item-meta,
.growth-item-reason {
margin: 4px 0 0;
color: var(--momo-text-muted);
font-size: 0.78rem;
line-height: 1.45;
}
.growth-action-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 92px;
border: 1px solid rgba(42, 37, 32, 0.12);
border-radius: 999px;
background: rgba(242, 178, 90, 0.2);
color: var(--momo-text-strong);
font-size: 0.74rem;
font-weight: 900;
padding: 5px 8px;
text-align: center;
white-space: nowrap;
}
@media (max-width: 992px) {
.ai-intel-hero {
grid-template-columns: 1fr;
}
.ai-intel-actions {
justify-content: flex-start;
max-width: none;
}
}
@media (max-width: 576px) {
.ai-intel-hero {
padding: 18px;
}
.ai-action-btn {
width: 100%;
}
}
@media (max-width: 768px) {
.ai-panel .card-header {
align-items: flex-start !important;
flex-direction: column;
gap: 10px;
}
.ai-panel .card-header > .d-flex {
display: grid !important;
grid-template-columns: 1fr;
width: 100%;
}
.ai-panel .card-header .form-select,
.ai-panel .card-header .form-control {
width: 100% !important;
}
.ai-table-scroll {
max-height: none;
overflow: visible;
}
#competitorTable,
#competitorTable tbody,
#competitorTable tr,
#competitorTable td {
display: block;
width: 100% !important;
}
#competitorTable thead {
display: none;
}
#competitorTable tr {
padding: 0.85rem 0.95rem;
border-top: 1px solid rgba(42, 37, 32, 0.08);
}
#competitorTable tr:first-child {
border-top: 0;
}
#competitorTable td {
display: grid;
grid-template-columns: 5.8rem minmax(0, 1fr);
gap: 0.65rem;
align-items: start;
padding: 0.36rem 0 !important;
border: 0 !important;
text-align: left !important;
overflow-wrap: anywhere;
white-space: normal;
}
#competitorTable td::before {
color: var(--momo-text-muted);
font-family: var(--momo-font-mono);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
#competitorTable td[colspan] {
display: block;
}
#competitorTable td[colspan]::before {
content: none;
display: none;
}
#competitorTable td:nth-child(1)::before { content: "商品"; }
#competitorTable td:nth-child(2)::before { content: "MOMO"; }
#competitorTable td:nth-child(3)::before { content: "PChome"; }
#competitorTable td:nth-child(4)::before { content: "價差"; }
#competitorTable td:nth-child(5)::before { content: "狀態"; }
#competitorTable td:nth-child(6)::before { content: "可信度"; }
#competitorTable td:nth-child(7)::before { content: "更新"; }
.ai-panel .card-footer {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.growth-ops-grid,
.growth-metric-row {
grid-template-columns: 1fr;
}
.growth-item {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block ewooo_content %}
<div class="ai-intel-page">
<!-- ── 頁首 ── -->
<section class="ai-intel-hero">
<div>
<h1 class="ai-intel-title">
<i class="fas fa-brain"></i>
PChome 業績成長自動化作戰系統
<span class="ai-intel-badge">AI 競情中樞</span>
</h1>
<p class="ai-intel-subtitle">把 PChome 後台業績、MOMO 外部價格參考與商品對應狀態整理成每天可處理的作戰清單。</p>
</div>
<div class="ai-intel-actions">
<span id="lastUpdateBadge" class="ai-status-badge">
<i class="fas fa-sync me-1"></i>載入中...
</span>
<button class="btn btn-outline-danger btn-sm ai-action-btn" id="btnTrigger" onclick="triggerAnalysis()">
<i class="fas fa-bolt me-1"></i>整理建議
</button>
<button class="btn btn-outline-primary btn-sm ai-action-btn" id="btnPickList" onclick="generatePickList()">
<i class="fas fa-wand-magic-sparkles me-1"></i>產生作戰商品
</button>
<button class="btn btn-outline-warning btn-sm ai-action-btn" id="btnBackfill" onclick="backfillPchomeMatches()">
<i class="fas fa-magnifying-glass-chart me-1"></i>補商品對應
</button>
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadDashboard()">
<i class="fas fa-redo me-1"></i>重新整理
</button>
</div>
</section>
<!-- ── PChome 成長作戰清單 ── -->
<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">
<span class="ai-panel-title">
<i class="fas fa-compass"></i>PChome 成長作戰
<small class="text-muted fw-normal ms-2">先處理最可能影響業績的商品</small>
</span>
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadGrowthOps(true)">
<i class="fas fa-redo me-1"></i>更新清單
</button>
</div>
<div class="card-body">
<div class="growth-ops-grid">
<div>
<div class="growth-metric-row">
<div class="growth-metric">
<strong id="growthCandidateCount"></strong>
<span>作戰商品</span>
</div>
<div class="growth-metric">
<strong id="growthMappedCount"></strong>
<span>可直接比價</span>
</div>
<div class="growth-metric">
<strong id="growthNeedsMapping"></strong>
<span>待補對應</span>
</div>
</div>
<p class="growth-source-note" id="growthSourceNote">來源整理中...</p>
<div class="growth-source-list" id="growthSourceReadiness">
<div class="growth-source-chip">
<div class="growth-source-name">
<span>外部資料來源</span>
<span class="growth-source-status">整理中</span>
</div>
<p class="growth-source-detail">正在確認哪些來源可進作戰清單。</p>
</div>
</div>
</div>
<div>
<div class="growth-list" id="growthOpsList">
<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>整理作戰清單中...
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ── KPI 卡片 ── -->
<div class="row g-3 mb-4" id="kpiRow">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-2 fw-bold text-primary" id="kpiSkus"></div>
<div class="small text-muted mt-1"><i class="fas fa-box me-1"></i>監控商品數</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-2 fw-bold text-success" id="kpiCompetitors"></div>
<div class="small text-muted mt-1">
<i class="fas fa-store me-1"></i>可直接比價
<span id="kpiMatchRate" class="text-muted" style="font-size:0.7rem"></span>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<!-- 高風險卡 — 數值來自全量 CTE非前端截斷的 200 筆 -->
<div class="card border-0 shadow-sm h-100" id="kpiHighRiskCard">
<div class="card-body text-center py-3">
<div class="fs-2 fw-bold text-danger" id="kpiHighRisk"></div>
<div class="small text-muted mt-1">
<i class="fas fa-exclamation-triangle me-1"></i>需檢查價格
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-2 fw-bold text-info" id="kpiAiRecs"></div>
<div class="small text-muted mt-1"><i class="fas fa-robot me-1"></i>作戰建議紀錄</div>
</div>
</div>
</div>
</div>
<!-- ── 主體分兩欄(競品比價 + AI 決策) ── -->
<div class="row g-3">
<!-- ── 左:外部價格參考 ── -->
<div class="col-xl-7">
<div class="card shadow-sm h-100 ai-panel">
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
<span class="ai-panel-title">
<i class="fas fa-balance-scale text-warning me-2"></i>MOMO 外部價格參考
</span>
<div class="d-flex gap-2 align-items-center">
<select class="form-select form-select-sm" id="riskFilter" onchange="filterTable()" style="width:100px">
<option value="all">全部</option>
<option value="HIGH">高風險</option>
<option value="MED">中風險</option>
<option value="LOW">低風險</option>
</select>
<input type="text" class="form-control form-control-sm" id="searchInput"
placeholder="搜尋商品..." oninput="filterTable()" style="width:130px">
</div>
</div>
<!-- 熱力圖圖例 -->
<div class="px-3 pt-2 pb-1 d-flex gap-3 small text-muted ai-legend" style="font-size:0.73rem">
<span><span style="display:inline-block;width:12px;height:12px;background:#fee2e2;border-radius:2px" class="me-1"></span>&gt;20%</span>
<span><span style="display:inline-block;width:12px;height:12px;background:#fef9c3;border-radius:2px" class="me-1"></span>貴 10~20%</span>
<span><span style="display:inline-block;width:12px;height:12px;background:#dcfce7;border-radius:2px" class="me-1"></span>我便宜</span>
</div>
<div class="card-body p-0 ai-table-scroll">
<table class="table table-sm table-hover mb-0 align-middle" id="competitorTable">
<thead class="table-light sticky-top" style="font-size:0.78rem;">
<tr>
<th class="ps-3" style="min-width:200px">商品</th>
<th class="text-end" style="min-width:75px">MOMO</th>
<th class="text-end" style="min-width:75px">PChome</th>
<th class="text-end" style="min-width:70px">價差</th>
<th style="min-width:90px">比價狀態</th>
<th class="text-center" style="min-width:55px">可信度</th>
<th class="text-muted" style="min-width:80px">更新</th>
</tr>
</thead>
<tbody id="competitorTbody">
<tr><td colspan="7" class="text-center py-5 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>載入中...
</td></tr>
</tbody>
</table>
</div>
<div class="card-footer bg-white py-2 d-flex justify-content-between small text-muted">
<span id="compCount"></span>
<span>僅顯示已確認同款的商品</span>
</div>
</div>
</div>
<!-- ── 右:作戰建議紀錄 ── -->
<div class="col-xl-5">
<div class="card shadow-sm h-100 ai-panel">
<div class="card-header py-2 bg-white border-bottom">
<span class="ai-panel-title">
<i class="fas fa-robot text-danger me-2"></i>作戰建議紀錄
<small class="text-muted fw-normal ms-2">挑品、比價與人工覆核</small>
</span>
</div>
<div class="card-body p-0 ai-recs-scroll">
<div id="aiRecsList" class="p-2">
<div class="text-center py-5 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>載入中...
</div>
</div>
</div>
<div class="card-footer bg-white py-2 d-flex justify-content-between small text-muted">
<span id="aiRecsCount"></span>
<span>可手動產生作戰商品</span>
</div>
</div>
</div>
</div><!-- /row -->
<!-- ── Trigger 進度 Toast ── -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:9999">
<div id="triggerToast" class="toast align-items-center text-white border-0" role="alert">
<div class="d-flex">
<div class="toast-body" id="triggerToastMsg">
<i class="fas fa-bolt me-1"></i>分析已啟動...
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
</div><!-- /ai-intel-page -->
<script>
// ── 全域資料 ────────────────────────────────────────
let allCompetitors = [];
// ── 頁面載入 ────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadDashboard();
loadGrowthOps();
});
async function loadDashboard() {
try {
const res = await fetch('/api/ai/icaim/dashboard');
const data = await res.json();
if (!data.success) throw new Error(data.error || '載入失敗');
renderKPIs(data.stats);
allCompetitors = data.competitors;
renderCompetitorTable(allCompetitors);
renderAiRecs(data.ai_recs);
document.getElementById('lastUpdateBadge').innerHTML =
'<i class="fas fa-check-circle me-1"></i>上次更新 ' + new Date().toLocaleTimeString('zh-TW');
document.getElementById('lastUpdateBadge').className = 'ai-status-badge is-success';
} catch (e) {
document.getElementById('lastUpdateBadge').innerHTML =
'<i class="fas fa-exclamation-circle me-1"></i>載入失敗';
document.getElementById('lastUpdateBadge').className = 'ai-status-badge is-error';
console.error(e);
}
}
// ── KPIhigh_risk_count 來自後端全量 CTE─────────
function renderKPIs(stats) {
document.getElementById('kpiSkus').textContent = (stats.total_skus || 0).toLocaleString();
document.getElementById('kpiCompetitors').textContent = (stats.valid_competitor_prices || 0).toLocaleString();
document.getElementById('kpiAiRecs').textContent = (stats.total_ai_recs || 0).toLocaleString();
document.getElementById('kpiMatchRate').textContent = stats.match_rate ? `(${stats.match_rate}%)` : '';
const hr = stats.high_risk_count || 0;
document.getElementById('kpiHighRisk').textContent = hr;
// 高風險卡:數值 > 0 加紅底強調
document.getElementById('kpiHighRiskCard').className =
hr > 0
? 'card border-2 border-danger shadow-sm h-100'
: 'card border-0 shadow-sm h-100';
}
function formatMoney(value) {
const num = Number(value || 0);
return 'NT$ ' + Math.round(num).toLocaleString();
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ch]));
}
async function loadGrowthOps(forceRefresh = false) {
const list = document.getElementById('growthOpsList');
if (forceRefresh) {
list.innerHTML = `<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>更新作戰清單中...
</div>`;
}
try {
const url = '/api/ai/pchome-growth/opportunities?limit=8' + (forceRefresh ? '&refresh=1' : '');
const res = await fetch(url);
const data = await res.json();
if (!data.success) throw new Error(data.error || '讀取失敗');
const stats = data.stats || {};
document.getElementById('growthCandidateCount').textContent = (stats.candidate_count || 0).toLocaleString();
document.getElementById('growthMappedCount').textContent = (stats.mapped_count || 0).toLocaleString();
document.getElementById('growthNeedsMapping').textContent = (stats.needs_mapping_count || 0).toLocaleString();
const scope = data.source_scope || {};
const active = (scope.active_external_sources || []).join('、') || '尚未接入';
const paused = (scope.paused_external_sources || []).join('、') || '無';
document.getElementById('growthSourceNote').textContent =
`業績來源:${scope.primary_sales_source || 'PChome 後台業績'}。外部價格先看:${active}。暫停來源:${paused}`;
renderGrowthSourceReadiness((scope.source_readiness || {}).sources || []);
renderGrowthOps(data.opportunities || []);
} catch (error) {
console.error(error);
list.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-exclamation d-block mb-2"></i>
PChome 成長作戰清單暫時無法讀取,請稍後再試。
</div>`;
}
}
function renderGrowthSourceReadiness(sources) {
const box = document.getElementById('growthSourceReadiness');
if (!box) return;
if (!sources.length) {
box.innerHTML = `<div class="growth-source-chip">
<div class="growth-source-name">
<span>外部資料來源</span>
<span class="growth-source-status">未確認</span>
</div>
<p class="growth-source-detail">目前還沒有可顯示的來源狀態。</p>
</div>`;
return;
}
box.innerHTML = sources.slice(0, 3).map((source) => {
const usable = Number(source.usable_offer_count || 0);
const detail = usable > 0
? `${source.data_quality_label || '資料可用'},目前有 ${usable.toLocaleString()} 筆可用資料。`
: `${source.plain_state || source.plain_note || '等待資料接入。'}`;
const activeClass = source.status_code === 'active' ? ' is-active' : '';
return `<div class="growth-source-chip${activeClass}">
<div class="growth-source-name">
<span>${escapeHtml(source.display_name || '未命名來源')}</span>
<span class="growth-source-status">${escapeHtml(source.status_label || '待確認')}</span>
</div>
<p class="growth-source-detail">${escapeHtml(detail)}</p>
</div>`;
}).join('');
}
function renderGrowthOps(rows) {
const list = document.getElementById('growthOpsList');
if (!rows.length) {
list.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-info d-block mb-2"></i>
目前沒有足夠資料,請先確認 PChome 業績檔已匯入。
</div>`;
return;
}
list.innerHTML = rows.map((row) => {
const action = row.recommended_action || {};
const reason = (row.reason_lines || []).slice(0, 2).join(' ');
const price = row.external_price;
const priceText = price && price.gap_pct !== null && price.gap_pct !== undefined
? `外部價差 ${price.gap_pct > 0 ? '+' : ''}${price.gap_pct}%`
: '尚未可比價';
return `<article class="growth-item">
<div>
<h3 class="growth-item-title">${escapeHtml(row.product_name)}</h3>
<p class="growth-item-meta">
${formatMoney(row.sales_7d)} · 近 7 天業績 · ${escapeHtml(priceText)}
</p>
<p class="growth-item-reason">${escapeHtml(reason)}</p>
</div>
<span class="growth-action-pill">${escapeHtml(action.label || '待判斷')}</span>
</article>`;
}).join('');
}
// ── 競品比價表格(熱力圖底色)──────────────────────
function renderCompetitorTable(rows) {
const tbody = document.getElementById('competitorTbody');
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="7" class="text-center py-5 text-muted">
<i class="fas fa-info-circle me-2"></i>暫無競品比價資料
</td></tr>`;
document.getElementById('compCount').textContent = '0 筆';
return;
}
tbody.innerHTML = rows.map(r => {
// 行底色
let rowBg = '';
if (r.gap_pct > 20) rowBg = 'background:#fee2e2'; // 淺紅 — 嚴重貴
else if (r.gap_pct > 10) rowBg = 'background:#fef9c3'; // 淺黃 — 有風險
else if (r.gap_pct < 0) rowBg = 'background:#f0fdf4'; // 淺綠 — 我便宜
// 價差文字顏色
const gapClass = r.gap_pct > 15 ? 'text-danger fw-bold'
: r.gap_pct > 5 ? 'text-warning fw-bold'
: r.gap_pct < 0 ? 'text-success fw-bold'
: 'text-muted';
const gapSign = r.gap_pct > 0 ? '+' : '';
const tagHtml = (r.tags || []).map(t => {
const tagMap = {
'on_sale': ['bg-info text-dark', '促銷中'],
'discount_30pct': ['bg-danger text-white', '折30%+'],
'discount_20pct': ['bg-warning text-dark', '折20%+'],
'discount_10pct': ['bg-secondary text-white','折10%+'],
'low_stock': ['bg-dark text-white', '低庫存'],
'high_rating': ['bg-primary text-white', '高評分'],
'identity_v2': ['bg-success text-white', '同款確認'],
'match_type_exact':['bg-success text-white', '同款確認'],
'price_alert_exact':['bg-danger text-white', '價差告警'],
'evidence_brand': ['bg-light text-dark', '品牌一致'],
'evidence_identity':['bg-light text-dark', '同款證據'],
'match_shared_model_token':['bg-light text-dark','型號一致'],
'match_product_line':['bg-light text-dark', '品線一致'],
'alert_tier_price':['bg-warning text-dark', '優先追蹤'],
};
const mapped = tagMap[t];
if (!mapped) return '';
const [cls, label] = mapped;
return `<span class="badge ${cls} me-1" style="font-size:0.68rem">${label}</span>`;
}).filter(Boolean).slice(0, 3).join('');
const scoreColor = r.match_score >= 0.7 ? 'text-success'
: r.match_score >= 0.55 ? 'text-warning'
: 'text-danger';
return `<tr data-risk="${r.risk}" data-name="${r.name.toLowerCase()}" style="${rowBg}">
<td class="ps-3">
<div style="font-size:0.82rem;font-weight:500" title="${r.name}">${r.name}</div>
<small class="text-muted">${r.category} · ${r.sku}</small>
</td>
<td class="text-end text-dark fw-bold">$${r.momo_price.toLocaleString()}</td>
<td class="text-end text-secondary">$${r.pchome_price.toLocaleString()}</td>
<td class="text-end ${gapClass}">${gapSign}${r.gap_pct}%</td>
<td>${tagHtml || '<span class="text-muted small">—</span>'}</td>
<td class="text-center ${scoreColor}" style="font-size:0.8rem">${r.match_score.toFixed(2)}</td>
<td class="text-muted" style="font-size:0.75rem">${r.crawled_at}</td>
</tr>`;
}).join('');
document.getElementById('compCount').textContent = `${rows.length}`;
}
// ── 篩選 ─────────────────────────────────────────────
function filterTable() {
const risk = document.getElementById('riskFilter').value;
const search = document.getElementById('searchInput').value.toLowerCase().trim();
const filtered = allCompetitors.filter(r => {
const riskOk = risk === 'all' || r.risk === risk;
const searchOk = !search || r.name.toLowerCase().includes(search) || r.sku.includes(search);
return riskOk && searchOk;
});
renderCompetitorTable(filtered);
}
// ── 作戰建議紀錄 ────────────────────────
function renderAiRecs(recs) {
const container = document.getElementById('aiRecsList');
document.getElementById('aiRecsCount').textContent =
recs.length ? `${recs.length} 筆建議` : '尚無建議';
if (!recs.length) {
container.innerHTML = `
<div class="text-center py-5">
<i class="fas fa-brain fa-3x text-muted mb-3 d-block"></i>
<p class="text-muted mb-1">目前還沒有作戰建議</p>
<p class="small text-muted mb-3">
系統會定期整理,也可以手動更新。
</p>
<button class="btn btn-sm btn-outline-danger" onclick="triggerAnalysis()">
<i class="fas fa-bolt me-1"></i>整理建議
</button>
</div>`;
return;
}
const strategyMap = {
'price_cut': ['bg-danger', '降價'],
'promote': ['bg-primary', '主推'],
'product_pick':['bg-success', 'AI挑品'],
'monitor': ['bg-secondary', '觀察'],
'flag': ['bg-warning text-dark', '覆核'],
};
container.innerHTML = recs.map(r => {
const [sBg, sLabel] = strategyMap[r.strategy] || ['bg-secondary', r.strategy];
const confPct = Math.round(r.confidence * 100);
const confColor = confPct >= 80 ? 'bg-success' : confPct >= 60 ? 'bg-warning' : 'bg-danger';
const gapSign = r.gap_pct > 0 ? '+' : '';
return `<div class="border rounded mb-2 p-2" style="font-size:0.83rem">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="fw-bold text-truncate me-2" style="max-width:200px" title="${r.name}">${r.name}</span>
<span class="badge ${sBg} flex-shrink-0">${sLabel}</span>
</div>
<div class="d-flex gap-3 mb-1 text-muted small">
<span>MOMO <strong class="text-dark">$${r.momo_price.toLocaleString()}</strong></span>
<span>PChome <strong class="text-secondary">$${r.pchome_price.toLocaleString()}</strong></span>
<span class="${r.gap_pct > 10 ? 'text-danger fw-bold' : 'text-muted'}">${gapSign}${r.gap_pct}%</span>
</div>
<div class="mb-1 text-muted" style="font-size:0.78rem;line-height:1.4">
${r.reason ? r.reason.substring(0, 90) + (r.reason.length > 90 ? '…' : '') : ''}
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-1">
<div class="progress" style="width:60px;height:6px">
<div class="progress-bar ${confColor}" style="width:${confPct}%"></div>
</div>
<small class="text-muted">${confPct}%</small>
</div>
<small class="text-muted">
${r.created_at}
</small>
</div>
</div>`;
}).join('');
}
// ── 產生 AI 建議挑品清單 ───────────────────────────
async function generatePickList() {
const btn = document.getElementById('btnPickList');
const toast = document.getElementById('triggerToast');
const msg = document.getElementById('triggerToastMsg');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
try {
const res = await fetch('/api/ai/product-picks/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ limit: 50 })
});
const data = await res.json();
msg.innerHTML = data.success
? `<i class="fas fa-check-circle me-1"></i>${data.message}`
: `<i class="fas fa-times-circle me-1"></i>${data.error || '產生失敗'}`;
toast.className = 'toast align-items-center text-white border-0 ' +
(data.success ? 'bg-success' : 'bg-danger');
new bootstrap.Toast(toast, { delay: 6000 }).show();
if (data.success) loadDashboard();
} catch (e) {
msg.innerHTML = '<i class="fas fa-times-circle me-1"></i>整理失敗:' + e.message;
toast.className = 'toast align-items-center text-white border-0 bg-danger';
new bootstrap.Toast(toast, { delay: 4000 }).show();
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-wand-magic-sparkles me-1"></i>產生作戰商品';
}
}
// ── 補抓 PChome 尚未搜尋商品 ───────────────────────
async function backfillPchomeMatches() {
const btn = document.getElementById('btnBackfill');
const toast = document.getElementById('triggerToast');
const msg = document.getElementById('triggerToastMsg');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
try {
const res = await fetch('/api/ai/pchome-match/backfill', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ limit: 60 })
});
const data = await res.json();
msg.innerHTML = data.success
? `<i class="fas fa-check-circle me-1"></i>${data.message}`
: `<i class="fas fa-times-circle me-1"></i>${data.error || '商品對應啟動失敗'}`;
toast.className = 'toast align-items-center text-white border-0 ' +
(data.success ? 'bg-success' : 'bg-danger');
new bootstrap.Toast(toast, { delay: 6000 }).show();
if (data.success) setTimeout(loadDashboard, 90000);
} catch (e) {
msg.innerHTML = '<i class="fas fa-times-circle me-1"></i>商品對應失敗:' + e.message;
toast.className = 'toast align-items-center text-white border-0 bg-danger';
new bootstrap.Toast(toast, { delay: 4000 }).show();
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-magnifying-glass-chart me-1"></i>補商品對應';
}
}
// ── 手動觸發分析 ────────────────────────────────────
async function triggerAnalysis() {
const btn = document.getElementById('btnTrigger');
const toast = document.getElementById('triggerToast');
const msg = document.getElementById('triggerToastMsg');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
try {
const res = await fetch('/api/ai/icaim/trigger', { method: 'POST' });
const data = await res.json();
msg.innerHTML = data.success
? `<i class="fas fa-check-circle me-1"></i>${data.message}`
: `<i class="fas fa-times-circle me-1"></i>${data.error}`;
toast.className = 'toast align-items-center text-white border-0 ' +
(data.success ? 'bg-success' : 'bg-danger');
new bootstrap.Toast(toast, { delay: 6000 }).show();
if (data.success) {
// 60 秒後自動重新整理儀表板
setTimeout(loadDashboard, 60000);
}
} catch (e) {
msg.innerHTML = '<i class="fas fa-times-circle me-1"></i>整理失敗:' + e.message;
toast.className = 'toast align-items-center text-white border-0 bg-danger';
new bootstrap.Toast(toast, { delay: 4000 }).show();
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-bolt me-1"></i>整理建議';
}
}
</script>
{% endblock %}