Some checks failed
CD Pipeline / deploy (push) Failing after 59s
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml) - 部署模式: rsync Python 檔案至 188 → docker restart (volume mount) - Dockerfile/requirements 變動時自動重建 Docker image - 部署通知: Telegram (開始/成功/失敗) - 健康檢查: https://mo.wooo.work/health (最多 5 次重試) - 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
386 lines
19 KiB
HTML
386 lines
19 KiB
HTML
{% extends 'base.html' %}
|
||
{% block title %}AI 競情中樞 - WOOO TECH{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid py-3">
|
||
|
||
<!-- ── 頁首 ── -->
|
||
<div class="row mb-3 align-items-center">
|
||
<div class="col">
|
||
<h4 class="mb-0">
|
||
<i class="fas fa-brain text-danger me-2"></i>AI 競情中樞
|
||
<span class="badge bg-danger ms-2 fs-6">ICAIM</span>
|
||
</h4>
|
||
<small class="text-muted">Hermes 3 分析師 × NemoTron 派發器 — 全天候競品監控</small>
|
||
</div>
|
||
<div class="col-auto d-flex gap-2 align-items-center">
|
||
<span id="lastUpdateBadge" class="badge bg-secondary">
|
||
<i class="fas fa-sync me-1"></i>載入中...
|
||
</span>
|
||
<button class="btn btn-outline-danger btn-sm" id="btnTrigger" onclick="triggerAnalysis()">
|
||
<i class="fas fa-bolt me-1"></i>立即分析
|
||
</button>
|
||
<button class="btn btn-outline-secondary btn-sm" onclick="loadDashboard()">
|
||
<i class="fas fa-redo me-1"></i>重新整理
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── 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>有效競品比價</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>高風險商品
|
||
<span class="text-muted" style="font-size:0.7rem">(貴>15%)</span>
|
||
</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>AI 決策記錄</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── 主體分兩欄(競品比價 + AI 決策) ── -->
|
||
<div class="row g-3">
|
||
|
||
<!-- ── 左:PChome 競品比價 ── -->
|
||
<div class="col-xl-7">
|
||
<div class="card shadow-sm h-100">
|
||
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
|
||
<span class="fw-bold">
|
||
<i class="fas fa-balance-scale text-warning me-2"></i>PChome 競品比價監控
|
||
</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" style="font-size:0.73rem">
|
||
<span><span style="display:inline-block;width:12px;height:12px;background:#fee2e2;border-radius:2px" class="me-1"></span>貴 >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" style="overflow-x:auto; max-height:500px; overflow-y:auto;">
|
||
<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>TTL: 6h | 比對門檻: 0.45</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── 右:AI 決策日誌 ── -->
|
||
<div class="col-xl-5">
|
||
<div class="card shadow-sm h-100">
|
||
<div class="card-header py-2 bg-white border-bottom">
|
||
<span class="fw-bold">
|
||
<i class="fas fa-robot text-danger me-2"></i>AI 決策日誌
|
||
<small class="text-muted fw-normal ms-2">Hermes × NemoTron</small>
|
||
</span>
|
||
</div>
|
||
<div class="card-body p-0" style="overflow-y:auto; max-height:548px;">
|
||
<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>每 6h 自動更新</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><!-- /container -->
|
||
|
||
<script>
|
||
// ── 全域資料 ────────────────────────────────────────
|
||
let allCompetitors = [];
|
||
|
||
// ── 頁面載入 ────────────────────────────────────────
|
||
document.addEventListener('DOMContentLoaded', loadDashboard);
|
||
|
||
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 = 'badge bg-success';
|
||
} catch (e) {
|
||
document.getElementById('lastUpdateBadge').innerHTML =
|
||
'<i class="fas fa-exclamation-circle me-1"></i>載入失敗';
|
||
document.getElementById('lastUpdateBadge').className = 'badge bg-danger';
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
// ── KPI(high_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();
|
||
|
||
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 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', '高評分'],
|
||
};
|
||
const [cls, label] = tagMap[t] || ['bg-light text-dark', t];
|
||
return `<span class="badge ${cls} me-1" style="font-size:0.68rem">${label}</span>`;
|
||
}).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);
|
||
}
|
||
|
||
// ── AI 決策日誌(含推理足跡)───────────────────────
|
||
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">AI 決策日誌尚為空</p>
|
||
<p class="small text-muted mb-3">
|
||
排程每 6 小時執行一次 Hermes 分析 + NemoTron 派發<br>
|
||
或點擊「立即分析」手動觸發
|
||
</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', '主推'],
|
||
'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 ? '+' : '';
|
||
|
||
// 推理足跡
|
||
const hermesInfo = r.hermes_duration
|
||
? `${r.analyst} ${r.hermes_duration}s${r.hermes_tokens ? ' / ' + r.hermes_tokens + 'tok' : ''}`
|
||
: r.analyst;
|
||
const nimInfo = r.nim_tokens
|
||
? `${r.dispatcher} ${r.nim_tokens}tok`
|
||
: r.dispatcher;
|
||
|
||
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" title="推理足跡">
|
||
<i class="fas fa-microchip me-1" style="font-size:0.68rem"></i>${hermesInfo} → ${nimInfo}
|
||
· ${r.created_at}
|
||
</small>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ── 手動觸發分析 ────────────────────────────────────
|
||
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 %}
|