Files
ewoooc/web/static/js/page-ai-recommend.js
OoO eb521fd6d8
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
V10.615 AI 推薦頁 Ollama 主路徑文案
2026-06-16 10:16:23 +08:00

1081 lines
50 KiB
JavaScript
Raw Permalink 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.
/* page-ai-recommend.js — Turn C (extracted from inline) */
(function () {
'use strict';
// 台灣節日資料
const taiwanHolidays = [
{ name: '農曆新年', month: 1, day: 29, keywords: ['過年', '春節', '紅包'] },
{ name: '元宵節', month: 2, day: 12, keywords: ['元宵', '湯圓'] },
{ name: '228連假', month: 2, day: 28, keywords: ['連假', '旅遊'] },
{ name: '婦女節', month: 3, day: 8, keywords: ['女神節', '寵愛'] },
{ name: '白色情人節', month: 3, day: 14, keywords: ['情人節', '送禮'] },
{ name: '兒童節', month: 4, day: 4, keywords: ['兒童', '親子'] },
{ name: '母親節', month: 5, day: 11, keywords: ['母親節', '感恩'] },
{ name: '端午節', month: 5, day: 31, keywords: ['端午', '粽子'] },
{ name: '七夕', month: 8, day: 4, keywords: ['七夕', '浪漫'] },
{ name: '父親節', month: 8, day: 8, keywords: ['父親節', '88節'] },
{ name: '中秋節', month: 9, day: 17, keywords: ['中秋', '月餅'] },
{ name: '雙11', month: 11, day: 11, keywords: ['雙11', '購物節'] },
{ name: '聖誕節', month: 12, day: 25, keywords: ['聖誕', '禮物'] }
];
// 計算即將到來的假期
function getUpcomingHolidays() {
const today = new Date();
const upcoming = [];
taiwanHolidays.forEach(h => {
let date = new Date(today.getFullYear(), h.month - 1, h.day);
if (date < today) date = new Date(today.getFullYear() + 1, h.month - 1, h.day);
const days = Math.ceil((date - today) / (1000 * 60 * 60 * 24));
if (days <= 45) upcoming.push({ ...h, date, daysUntil: days });
});
return upcoming.sort((a, b) => a.daysUntil - b.daysUntil).slice(0, 3);
}
// 渲染近期節日
function renderUpcomingHolidays() {
const holidays = getUpcomingHolidays();
const container = document.getElementById('upcomingHolidays');
if (!holidays.length) {
container.innerHTML = '<span class="text-muted">近期無重大節日</span>';
return;
}
container.innerHTML = holidays.map(h => {
const cls = h.daysUntil <= 7 ? 'bg-danger' : h.daysUntil <= 14 ? 'bg-warning text-dark' : 'bg-info';
return `<span class="badge ${cls} me-2">${h.name} (${h.daysUntil}天後)</span>`;
}).join('');
}
// 載入熱銷商品
function loadBestsellers() {
const category = document.getElementById('bestsellersCategory').value;
const platform = document.querySelector('input[name="platform"]:checked')?.value || 'momo';
const container = document.getElementById('bestsellersCard');
container.innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm"></div></div>';
fetch(`/api/ai/bestsellers?category=${encodeURIComponent(category)}&limit=5&platform=${platform}`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
if (data.success && data.data.products?.length) {
container.innerHTML = data.data.products.map((p, i) => `
<div class="d-flex align-items-center px-3 py-2 border-bottom" style="cursor: pointer;" onclick="setProduct('${escapeHtml(p.name)}')">
<span class="badge bg-secondary me-2">${i + 1}</span>
<div class="flex-grow-1 overflow-hidden">
<small class="text-truncate d-block">${p.name}</small>
<small class="text-muted">$${p.price?.toLocaleString() || 'N/A'}</small>
</div>
<a href="${p.url}" target="_blank" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()" title="前往電商網站查看商品"><i class="fas fa-external-link-alt"></i></a>
</div>
`).join('') + `<div class="text-center text-muted small py-1">${data.data.source}</div>`;
} else {
container.innerHTML = '<p class="text-muted text-center py-3 mb-0">無法載入熱銷商品</p>';
}
})
.catch(e => container.innerHTML = '<p class="text-danger text-center py-3 mb-0">載入失敗</p>');
}
// 載入 COSME 排行榜
function loadCosmeRankings() {
const category = document.getElementById('cosmeCategory').value;
const container = document.getElementById('cosmeCard');
container.innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm"></div></div>';
fetch(`/api/ai/cosme_rankings?category=${encodeURIComponent(category)}&limit=5`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
if (data.success && data.data.products?.length) {
container.innerHTML = data.data.products.map((p, i) => `
<div class="d-flex align-items-center px-2 py-1 border-bottom" style="cursor: pointer;" onclick="setProduct('${escapeHtml(p.name)}')">
<span class="badge ${i < 3 ? 'bg-warning text-dark' : 'bg-secondary'} me-2" style="font-size: 0.7rem;">${p.rank}</span>
<div class="flex-grow-1 overflow-hidden">
<small class="text-truncate d-block" style="font-size: 0.8rem;">${p.brand} ${p.name}</small>
<small class="text-muted" style="font-size: 0.7rem;">評分: ${p.rating || 'N/A'}</small>
</div>
</div>
`).join('') + `<div class="text-center text-muted small py-1">${data.data.source}</div>`;
} else {
container.innerHTML = '<p class="text-muted text-center py-2 mb-0 small">暫無資料</p>';
}
})
.catch(e => container.innerHTML = '<p class="text-danger text-center py-2 mb-0 small">載入失敗</p>');
}
// 載入 mybest 推薦文章
function loadMybestArticles() {
const category = document.getElementById('mybestCategory').value;
const container = document.getElementById('mybestCard');
container.innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm"></div></div>';
fetch(`/api/ai/mybest_articles?category=${encodeURIComponent(category)}&limit=5`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
if (data.success && data.data.articles?.length) {
container.innerHTML = data.data.articles.map(a => `
<div class="d-flex align-items-center px-2 py-1 border-bottom" style="cursor: pointer;" onclick="setProduct('${escapeHtml(a.title)}')">
<i class="fas fa-star text-success me-2" style="font-size: 0.7rem;"></i>
<div class="flex-grow-1 overflow-hidden">
<small class="text-truncate d-block" style="font-size: 0.8rem;">${a.title}</small>
<small class="text-muted" style="font-size: 0.7rem;">${a.product_count ? a.product_count + '款推薦' : ''}</small>
</div>
${a.article_url ? `<a href="${a.article_url}" target="_blank" class="btn btn-sm btn-link p-0" onclick="event.stopPropagation()" title="查看完整文章"><i class="fas fa-external-link-alt" style="font-size: 0.7rem;"></i></a>` : ''}
</div>
`).join('') + `<div class="text-center text-muted small py-1">${data.data.source}</div>`;
} else {
container.innerHTML = '<p class="text-muted text-center py-2 mb-0 small">暫無資料</p>';
}
})
.catch(e => container.innerHTML = '<p class="text-danger text-center py-2 mb-0 small">載入失敗</p>');
}
// 趨勢資料快取
let trendDataCache = null;
let currentTrendType = 'news';
// 載入趨勢新聞
function loadTrends() {
const container = document.getElementById('newsCard');
container.innerHTML = '<div class="text-center py-4"><div class="spinner-border"></div></div>';
fetch('/api/ai/trends?time_range=week')
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
if (data.success) {
trendDataCache = data.data;
renderTrendContent(currentTrendType);
} else {
container.innerHTML = '<p class="text-muted text-center py-4">無法載入趨勢資料</p>';
}
})
.catch(e => container.innerHTML = '<p class="text-danger text-center py-4">載入失敗</p>');
}
// 切換趨勢分類頁籤
function switchTrendTab(el, type) {
event.preventDefault();
currentTrendType = type;
// 更新頁籤狀態
document.querySelectorAll('#trendTabs .nav-link').forEach(tab => tab.classList.remove('active'));
el.classList.add('active');
// 重新渲染內容
renderTrendContent(type);
}
// 渲染趨勢內容
function renderTrendContent(type) {
const container = document.getElementById('newsCard');
if (!trendDataCache) {
container.innerHTML = '<p class="text-muted text-center py-4">請先載入趨勢資料</p>';
return;
}
let items = [];
let icon = 'fa-newspaper';
let iconClass = 'text-info';
switch(type) {
case 'news':
items = trendDataCache.news || [];
icon = 'fa-newspaper';
iconClass = 'text-info';
break;
case 'social':
items = trendDataCache.social || [];
icon = 'fa-hashtag';
iconClass = 'text-primary';
break;
case 'search':
// 搜尋趨勢使用社群資料或關鍵字作為備用
items = trendDataCache.search || trendDataCache.social || [];
icon = 'fa-search';
iconClass = 'text-success';
break;
}
if (items.length === 0) {
container.innerHTML = `<p class="text-muted text-center py-4">暫無${type === 'news' ? '新聞' : type === 'social' ? '社群' : '搜尋'}資料</p>`;
return;
}
container.innerHTML = items.slice(0, 20).map(n => `
<div class="d-flex align-items-center px-3 py-2 border-bottom news-item" style="cursor: pointer;" onclick="setProduct('${escapeHtml(n.title || n.query || '')}')">
<i class="fas ${icon} ${iconClass} me-2"></i>
<div class="flex-grow-1 overflow-hidden">
<small class="text-truncate d-block fw-medium">${n.title || n.query || ''}</small>
<small class="text-muted">${n.source || ''} ${formatDate(n.published)}</small>
</div>
</div>
`).join('');
}
// 格式化日期
function formatDate(dateStr) {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
const hours = Math.floor((new Date() - date) / 3600000);
if (hours < 1) return '剛剛';
if (hours < 24) return `${hours}小時前`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}天前`;
return `${date.getMonth() + 1}/${date.getDate()}`;
} catch { return ''; }
}
// ====== AI 引擎切換相關 ======
// AI 引擎切換處理
function onProviderChange() {
const provider = document.getElementById('aiProvider').value;
const isGemini = provider === 'gemini';
// 切換模型選擇器顯示
document.getElementById('ollamaModelSelect').style.display = isGemini ? 'none' : 'block';
document.getElementById('geminiModelSelect').style.display = isGemini ? 'block' : 'none';
// 顯示/隱藏 Gemini 使用量面板
document.getElementById('geminiUsagePanel').style.display = isGemini ? 'block' : 'none';
// 如果選擇 Gemini載入使用量
if (isGemini) {
loadGeminiUsage();
}
}
// 載入 Gemini 使用量統計
function loadGeminiUsage() {
fetch('/api/ai/gemini_usage?days=30')
.then(r => r.json())
.then(data => {
if (data.success) {
const summary = data.data.summary;
document.getElementById('geminiMonthlyCost').textContent = '$' + summary.total_cost_usd.toFixed(4);
document.getElementById('geminiRequestCount').textContent = summary.total_requests.toLocaleString();
document.getElementById('geminiTokenUsage').textContent = summary.total_tokens.toLocaleString();
}
})
.catch(e => {
console.error('載入 Gemini 使用量失敗:', e);
});
}
// 首屏先渲染AI 狀態載入後再更新,避免健康檢查阻塞頁面 TTFB
function refreshAIStatus() {
fetch('/api/ai/status')
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
if (!data.success || !data.data) return;
const status = data.data;
updateAIStatusBadge('ollamaStatus', 'fas fa-server', 'Ollama 主路徑', status.ollama?.connected, 'ar-status--ok');
updateAIStatusBadge('geminiStatus', 'fab fa-google', 'Gemini 備援', status.gemini?.connected, 'ar-status--info');
updateOllamaModels(status.ollama?.available_models || []);
})
.catch(e => console.warn('AI 狀態刷新失敗:', e));
}
function updateAIStatusBadge(id, iconClass, label, connected, okClass) {
const el = document.getElementById(id);
if (!el) return;
el.classList.remove('ar-status--ok', 'ar-status--info', 'ar-status--off');
el.classList.add(connected ? okClass : 'ar-status--off');
el.innerHTML = `<i class="${iconClass}"></i> ${label} ${connected ? '✓' : '✗'}`;
}
function updateOllamaModels(models) {
const select = document.getElementById('ollamaModelSelect');
if (!select || !models.length) return;
const current = select.value;
select.innerHTML = models.map(model => {
const selected = model === current || (!current && model.includes('gemma3:4b')) ? ' selected' : '';
return `<option value="${escapeHtml(model)}"${selected}>${escapeHtml(model)}</option>`;
}).join('');
}
// 初始化 AI 引擎選擇(頁面載入時)
function initAIProvider() {
// 確保 Ollama 為預設
const provider = document.getElementById('aiProvider').value;
onProviderChange();
}
// ====== 其他功能 ======
// 設定商品名稱
function setProduct(name) {
document.getElementById('productName').value = name.substring(0, 100);
}
// 切換關鍵字
function toggleKeyword(el) {
el.classList.toggle('is-selected');
updateKeywordCount();
}
// 更新已選關鍵字計數
function updateKeywordCount() {
const count = document.querySelectorAll('.keyword-badge.is-selected').length;
const countEl = document.getElementById('selectedKeywordCount');
if (countEl) {
countEl.textContent = count + ' 個已選';
countEl.className = count > 0 ? 'badge ar-selected-count is-active ms-1' : 'badge ar-selected-count ms-1';
}
}
// 取得已選關鍵字
function getSelectedKeywords() {
return Array.from(document.querySelectorAll('.keyword-badge.is-selected')).map(el => el.textContent);
}
// 取得節日資訊(用於 API
function getHolidaysForAPI() {
return getUpcomingHolidays().map(h => ({ name: h.name, days_until: h.daysUntil }));
}
// 取得熱銷商品資訊
function getBestsellersForAPI() {
const items = document.querySelectorAll('#bestsellersCard > div.d-flex');
return Array.from(items).slice(0, 3).map(el => {
const name = el.querySelector('small.text-truncate')?.textContent || '';
const priceText = el.querySelector('small.text-muted')?.textContent || '';
const price = parseInt(priceText.replace(/[^0-9]/g, '')) || 0;
return { name, price };
});
}
// 生成文案
function generateCopy() {
const productName = document.getElementById('productName').value.trim();
if (!productName) {
document.getElementById('productName').classList.add('is-invalid');
document.getElementById('productName').focus();
return;
}
document.getElementById('productName').classList.remove('is-invalid');
// 立即更新按鈕狀態為載入中
const btn = document.getElementById('generateBtn');
const originalBtnText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>AI 正在生成中...';
btn.classList.add('btn-secondary');
btn.classList.remove('btn-primary');
// 顯示全螢幕載入動畫
showLoading('AI 正在生成完整文案套組...');
// 取得 AI 設定
const provider = document.getElementById('aiProvider').value;
const model = provider === 'gemini'
? document.getElementById('geminiModelSelect').value
: document.getElementById('ollamaModelSelect').value;
fetch('/api/ai/generate_copy', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({
product_name: productName,
trend_keywords: getSelectedKeywords(),
style: document.getElementById('copyStyle').value,
provider: provider,
model: model,
upcoming_holidays: getHolidaysForAPI(),
bestseller_products: getBestsellersForAPI()
})
})
.then(r => {
if (!r.ok) {
return r.text().then(text => {
throw new Error(`伺服器錯誤 (${r.status}): ${text.substring(0, 100)}`);
});
}
return r.json();
})
.then(data => {
hideLoading();
// 恢復按鈕狀態
btn.disabled = false;
btn.innerHTML = originalBtnText;
btn.classList.remove('btn-secondary');
btn.classList.add('btn-primary');
if (data.success) {
// 格式化顯示文案內容
const formattedCopy = formatCopyResult(data.data.copy);
document.getElementById('generatedCopy').innerHTML = formattedCopy;
// 組合元資料顯示
let metaHtml = `<i class="fas fa-robot me-1"></i>${data.data.provider === 'gemini' ? 'Gemini 備援' : 'Ollama 主路徑'}${data.data.model}`;
metaHtml += ` | <i class="fas fa-clock me-1"></i>耗時:${data.data.duration}`;
// 如果是 Gemini顯示費用
if (data.data.provider === 'gemini' && data.data.cost) {
metaHtml += ` | <i class="fas fa-coins me-1"></i>費用:$${data.data.cost.total.toFixed(4)}`;
metaHtml += ` | 權杖:${data.data.tokens.total}`;
// 刷新使用量面板
loadGeminiUsage();
}
document.getElementById('copyMeta').innerHTML = metaHtml;
document.getElementById('resultArea').style.display = 'block';
document.getElementById('resultArea').scrollIntoView({ behavior: 'smooth' });
} else {
alert('生成失敗:' + data.error);
}
})
.catch(e => {
hideLoading();
// 恢復按鈕狀態
btn.disabled = false;
btn.innerHTML = originalBtnText;
btn.classList.remove('btn-secondary');
btn.classList.add('btn-primary');
alert('發生錯誤:' + e.message);
});
}
// 格式化文案結果
function formatCopyResult(copy) {
if (!copy) return '';
// 將【標題】格式化為漂亮的樣式
let formatted = escapeHtml(copy)
.replace(/【大標題】/g, '<div class="mt-2 mb-1"><span class="badge ar-copy-badge ar-copy-badge--danger me-2">大標題</span></div>')
.replace(/【中標題】/g, '<div class="mt-3 mb-1"><span class="badge ar-copy-badge ar-copy-badge--warn me-2">中標題</span></div>')
.replace(/【小標題】/g, '<div class="mt-3 mb-1"><span class="badge ar-copy-badge ar-copy-badge--info me-2">小標題</span></div>')
.replace(/【詳細文案】/g, '<div class="mt-3 mb-1"><span class="badge ar-copy-badge ar-copy-badge--ok me-2">詳細文案</span></div>')
.replace(/【推廣建議】/g, '<div class="mt-3 mb-1"><span class="badge ar-copy-badge ar-copy-badge--primary me-2">推廣建議</span></div>')
.replace(/• 社群推廣:/g, '<div class="ms-3 mt-2"><i class="fab fa-facebook text-primary me-1"></i><strong>社群推廣:</strong>')
.replace(/• 影音內容:/g, '</div><div class="ms-3 mt-2"><i class="fab fa-youtube text-danger me-1"></i><strong>影音內容:</strong>')
.replace(/• 其他建議:/g, '</div><div class="ms-3 mt-2"><i class="fas fa-lightbulb text-warning me-1"></i><strong>其他建議:</strong></div><div class="ms-3">')
.replace(/\n/g, '<br>');
// 確保最後一個 div 閉合
if (formatted.includes('其他建議')) {
formatted += '</div>';
}
return formatted;
}
// 複製文案(純文字版本)
function copyCopyText() {
// 取得原始文案文字(不含 HTML 格式)
const copyEl = document.getElementById('generatedCopy');
const text = copyEl.innerText || copyEl.textContent;
navigator.clipboard.writeText(text)
.then(() => {
// 顯示成功提示
const btn = event.target.closest('button');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check me-1"></i>已複製';
btn.classList.add('btn-success');
btn.classList.remove('btn-light');
setTimeout(() => {
btn.innerHTML = originalText;
btn.classList.remove('btn-success');
btn.classList.add('btn-light');
}, 2000);
});
}
// 轉義 HTML
function escapeHtml(text) {
if (!text) return '';
return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
// 顯示/隱藏載入
function showLoading(text) {
document.getElementById('loadingText').textContent = text;
document.getElementById('loadingOverlay').classList.remove('d-none');
}
function hideLoading() {
document.getElementById('loadingOverlay').classList.add('d-none');
}
// ===== 網路搜尋功能 =====
// 快速搜尋
function quickWebSearch(query) {
document.getElementById('webSearchQuery').value = query;
doWebSearch();
}
// AI 網路搜尋
function doWebSearch() {
const query = document.getElementById('webSearchQuery').value.trim();
if (!query) {
document.getElementById('webSearchQuery').classList.add('is-invalid');
return;
}
document.getElementById('webSearchQuery').classList.remove('is-invalid');
const searchType = document.getElementById('webSearchType').value;
const btn = document.getElementById('webSearchBtn');
const resultArea = document.getElementById('webSearchResult');
const contentArea = document.getElementById('webSearchContent');
// 更新按鈕狀態
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>搜尋中...';
// 顯示結果區
resultArea.style.display = 'block';
contentArea.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary"></div><br><small class="text-muted mt-2 d-block">AI 正在分析市場資訊...</small><small class="text-muted">(可能需要 30-60 秒)</small></div>';
// 設定前端超時 (3 分鐘)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 180000);
fetch('/api/ai/web_search', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({
query: query,
search_type: searchType,
num_results: 5
}),
signal: controller.signal
})
.then(r => {
clearTimeout(timeoutId);
if (r.redirected || r.url.includes('/login')) {
window.location.href = '/login';
throw new Error('登入已過期,請重新登入');
}
if (!r.ok) {
throw new Error(`伺服器錯誤 (${r.status})`);
}
return r.json();
})
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-brain me-1"></i>AI 搜尋';
if (data.success) {
renderWebSearchResult(data.data);
} else {
contentArea.innerHTML = `<div class="alert alert-danger py-2 mb-0"><i class="fas fa-exclamation-triangle me-1"></i>${data.error}</div>`;
}
})
.catch(e => {
clearTimeout(timeoutId);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-brain me-1"></i>AI 搜尋';
if (e.name === 'AbortError') {
contentArea.innerHTML = `<div class="alert alert-warning py-2 mb-0"><i class="fas fa-clock me-1"></i>AI 伺服器回應較慢,請稍後再試。</div>`;
} else {
contentArea.innerHTML = `<div class="alert alert-danger py-2 mb-0">搜尋失敗:${e.message}</div>`;
}
});
}
// 渲染網路搜尋結果 - 卡片式顯示
function renderWebSearchResult(data) {
const contentArea = document.getElementById('webSearchContent');
let html = '';
// 如果有解析後的 JSON 結果
if (data.parsed) {
const p = data.parsed;
// 摘要卡片
if (p.summary) {
html += `
<div class="card border-primary mb-2">
<div class="card-header ar-card__head ar-card__head--soft py-2">
<h6 class="mb-0 small"><i class="fas fa-quote-left text-primary me-1"></i>AI 分析摘要</h6>
</div>
<div class="card-body py-2">
<p class="mb-0 small">${escapeHtml(p.summary)}</p>
</div>
</div>`;
}
// 分析結果卡片
if (p.results && p.results.length > 0) {
html += `
<div class="card border-success mb-2">
<div class="card-header bg-success bg-opacity-10 py-2">
<h6 class="mb-0 small"><i class="fas fa-list-check text-success me-1"></i>分析結果 (${p.results.length})</h6>
</div>
<div class="card-body py-2">
<div class="list-group list-group-flush">`;
p.results.forEach((r, idx) => {
html += `
<div class="list-group-item px-0 py-1 border-0 bg-transparent">
<div class="d-flex">
<span class="badge bg-success me-2">${idx + 1}</span>
<div>
<strong class="small">${escapeHtml(r.title || '')}</strong>
<p class="mb-0 text-muted small">${escapeHtml(r.description || '')}</p>
</div>
</div>
</div>`;
});
html += `
</div>
</div>
</div>`;
}
// 洞察與建議並排顯示
if ((p.insights && p.insights.length > 0) || (p.recommended_actions && p.recommended_actions.length > 0)) {
html += '<div class="row g-2">';
// 洞察卡片
if (p.insights && p.insights.length > 0) {
const colClass = (p.recommended_actions && p.recommended_actions.length > 0) ? 'col-md-6' : 'col-12';
html += `
<div class="${colClass}">
<div class="card border-warning h-100">
<div class="card-header bg-warning bg-opacity-10 py-2">
<h6 class="mb-0 small"><i class="fas fa-lightbulb text-warning me-1"></i>關鍵洞察</h6>
</div>
<div class="card-body py-2">
<div class="d-flex flex-wrap gap-1">
${p.insights.map(i => `<span class="badge bg-warning text-dark">${escapeHtml(i)}</span>`).join('')}
</div>
</div>
</div>
</div>`;
}
// 建議行動卡片
if (p.recommended_actions && p.recommended_actions.length > 0) {
const colClass = (p.insights && p.insights.length > 0) ? 'col-md-6' : 'col-12';
html += `
<div class="${colClass}">
<div class="card border-info h-100">
<div class="card-header bg-info bg-opacity-10 py-2">
<h6 class="mb-0 small"><i class="fas fa-tasks text-info me-1"></i>建議行動</h6>
</div>
<div class="card-body py-2">
<div class="d-flex flex-wrap gap-1">
${p.recommended_actions.map(a => `<span class="badge bg-info">${escapeHtml(a)}</span>`).join('')}
</div>
</div>
</div>
</div>`;
}
html += '</div>';
}
} else {
// 顯示原始內容
html = `
<div class="card border-secondary">
<div class="card-header bg-secondary bg-opacity-10 py-2">
<h6 class="mb-0 small"><i class="fas fa-file-alt text-secondary me-1"></i>搜尋結果</h6>
</div>
<div class="card-body py-2">
<pre class="mb-0 small" style="white-space: pre-wrap; word-break: break-word;">${escapeHtml(data.raw_content)}</pre>
</div>
</div>`;
}
// 底部資訊
html += `
<div class="text-end mt-2">
<small class="text-muted">
<i class="fas fa-robot me-1"></i>${data.model || 'AI'} |
<i class="fas fa-clock me-1"></i>${data.duration || '?'}
</small>
</div>`;
contentArea.innerHTML = html;
}
// 商品洞察分析 - 整合網路搜尋
function doProductInsights() {
const productName = document.getElementById('productName').value.trim();
if (!productName) {
alert('請先輸入商品名稱');
document.getElementById('productName').focus();
return;
}
const btn = document.getElementById('insightsBtn');
const resultArea = document.getElementById('productInsightsResult');
const placeholder = document.getElementById('productInsightsPlaceholder');
// 更新按鈕狀態
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>搜尋中...';
// 顯示結果區
placeholder.style.display = 'none';
resultArea.style.display = 'block';
resultArea.innerHTML = `
<div class="text-center py-4">
<div class="spinner-border text-warning mb-2"></div>
<div class="progress mb-2" style="height: 4px;">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-warning" id="insightProgress" style="width: 20%"></div>
</div>
<p class="text-muted mb-1" id="insightStatus">步驟 1/2搜尋網路最新資訊...</p>
<small class="text-muted">(整體約需 60-90 秒)</small>
</div>`;
// 設定前端超時 (4 分鐘,因為需要兩步)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 240000);
// 步驟 1: 先進行網路搜尋取得最新資訊
fetch('/api/ai/web_search', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({
query: `${productName} 市場分析 競品比較 價格 評價 2024`,
search_type: 'shopping',
num_results: 5
}),
signal: controller.signal
})
.then(r => {
if (r.redirected || r.url.includes('/login')) {
window.location.href = '/login';
throw new Error('登入已過期,請重新登入');
}
if (!r.ok) throw new Error(`搜尋失敗 (${r.status})`);
return r.json();
})
.then(searchData => {
// 更新進度
document.getElementById('insightProgress').style.width = '60%';
document.getElementById('insightStatus').textContent = '步驟 2/2AI 深度分析中...';
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>分析中...';
// 準備搜尋結果摘要
let webContext = '';
if (searchData.success && searchData.data) {
const parsed = searchData.data.parsed;
if (parsed) {
webContext = `\n\n【網路搜尋結果摘要】\n${parsed.summary || ''}\n`;
if (parsed.results && parsed.results.length > 0) {
webContext += '\n相關資訊\n';
parsed.results.slice(0, 3).forEach((r, i) => {
webContext += `${i+1}. ${r.title}: ${r.description}\n`;
});
}
} else if (searchData.data.raw_content) {
webContext = `\n\n【網路搜尋結果】\n${searchData.data.raw_content.substring(0, 500)}`;
}
}
// 步驟 2: 進行商品洞察分析,傳入網路搜尋結果
return fetch('/api/ai/product_insights', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({
product_name: productName,
include_competitors: true,
include_trends: true,
web_context: webContext // 傳入網路搜尋結果
}),
signal: controller.signal
});
})
.then(r => {
clearTimeout(timeoutId);
if (!r.ok) throw new Error(`分析失敗 (${r.status})`);
return r.json();
})
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-search-dollar me-1"></i>分析商品';
if (data.success) {
renderProductInsights(data.data, productName);
} else {
resultArea.innerHTML = `<div class="alert alert-danger py-2 mb-0">${data.error}</div>`;
}
})
.catch(e => {
clearTimeout(timeoutId);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-search-dollar me-1"></i>分析商品';
if (e.name === 'AbortError') {
resultArea.innerHTML = `<div class="alert alert-warning py-2 mb-0"><i class="fas fa-clock me-1"></i>AI 伺服器回應較慢,請稍後再試。</div>`;
} else {
resultArea.innerHTML = `<div class="alert alert-danger py-2 mb-0">分析失敗:${e.message}</div>`;
}
});
}
// 渲染商品洞察結果 - 完整顯示版本
function renderProductInsights(data, productName) {
const resultArea = document.getElementById('productInsightsResult');
let html = '';
// 標題
html += `<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0"><i class="fas fa-chart-pie text-warning me-2"></i>${escapeHtml(productName || '商品')} 市場分析</h6>
<small class="text-muted"><i class="fas fa-globe me-1"></i>含網路即時資訊</small>
</div>`;
if (data.insights) {
const ins = data.insights;
// 市場定位 - 卡片樣式
if (ins.market_position) {
const mp = ins.market_position;
html += `<div class="card mb-3 border-primary">
<div class="card-body py-2">
<div class="d-flex align-items-center mb-2">
<span class="badge ar-semantic-badge ar-semantic-badge--primary me-2"><i class="fas fa-crosshairs me-1"></i>市場定位</span>
${mp.price_range ? `<span class="badge bg-success">${escapeHtml(mp.price_range)}</span>` : ''}
</div>
<p class="mb-0 small">${escapeHtml(mp.positioning || mp.target_audience || mp.description || '')}</p>
</div>
</div>`;
}
// 競品分析 - 表格樣式
if (ins.competitors && ins.competitors.length > 0) {
html += `<div class="card mb-3 border-warning">
<div class="card-header py-2 bg-warning bg-opacity-10">
<span class="badge bg-warning text-dark"><i class="fas fa-users me-1"></i>競品分析</span>
</div>
<div class="card-body py-2">
<div class="table-responsive">
<table class="table table-sm table-borderless mb-0 small">
<thead><tr><th>競品</th><th class="text-success">優勢</th><th class="text-danger">劣勢</th></tr></thead>
<tbody>`;
ins.competitors.slice(0, 5).forEach(c => {
html += `<tr>
<td class="fw-bold">${escapeHtml(c.name || c.brand || '')}</td>
<td class="text-success">${escapeHtml(c.strength || c.advantage || '-')}</td>
<td class="text-danger">${escapeHtml(c.weakness || c.disadvantage || '-')}</td>
</tr>`;
});
html += `</tbody></table></div></div></div>`;
}
// 市場趨勢
if (ins.trends) {
html += `<div class="card mb-3 border-info">
<div class="card-body py-2">
<span class="badge bg-info mb-2"><i class="fas fa-chart-line me-1"></i>市場趨勢</span>
<p class="mb-0 small">${escapeHtml(ins.trends.current || ins.trends.description || '')}</p>
${ins.trends.forecast ? `<p class="mb-0 small text-info"><i class="fas fa-arrow-right me-1"></i>預測:${escapeHtml(ins.trends.forecast)}</p>` : ''}
</div>
</div>`;
}
// 銷售建議
if (ins.recommendations && ins.recommendations.length > 0) {
html += `<div class="card mb-3 border-success">
<div class="card-body py-2">
<span class="badge bg-success mb-2"><i class="fas fa-lightbulb me-1"></i>銷售建議</span>
<ul class="mb-0 ps-3 small">`;
ins.recommendations.forEach(r => {
html += `<li>${escapeHtml(r)}</li>`;
});
html += `</ul></div></div>`;
}
// 行銷關鍵字 - 可點擊加入
if (ins.keywords && ins.keywords.length > 0) {
html += `<div class="mb-2">
<span class="badge bg-secondary me-2"><i class="fas fa-tags me-1"></i>行銷關鍵字</span>
<small class="text-muted">點擊可加入文案</small>
<div class="mt-2">`;
ins.keywords.forEach(k => {
html += `<span class="badge bg-light text-dark border me-1 mb-1" style="cursor:pointer;" onclick="addKeywordFromInsight('${escapeHtml(k)}')" title="點擊加入關鍵字">${escapeHtml(k)}</span>`;
});
html += `</div></div>`;
}
} else if (data.raw_content) {
// 原始內容顯示
html += `<div class="card">
<div class="card-body py-2">
<div class="small" style="white-space: pre-wrap; line-height: 1.6;">${escapeHtml(data.raw_content)}</div>
</div>
</div>`;
}
// 底部資訊
html += `<div class="d-flex justify-content-between align-items-center mt-3 pt-2 border-top">
<small class="text-muted">
<i class="fas fa-robot me-1"></i>${data.model || 'AI'} |
<i class="fas fa-clock me-1"></i>${data.duration || '?'}
</small>
<button class="btn btn-sm btn-outline-secondary" onclick="doProductInsights()">
<i class="fas fa-redo me-1"></i>重新分析
</button>
</div>`;
resultArea.innerHTML = html;
}
// 從洞察結果添加關鍵字
function addKeywordFromInsight(keyword) {
// 檢查是否已存在此關鍵字
const existingBadges = document.querySelectorAll('#keywordsArea .keyword-badge');
for (let badge of existingBadges) {
if (badge.textContent === keyword) {
// 如果已存在,選中它
if (!badge.classList.contains('is-selected')) {
toggleKeyword(badge);
}
return;
}
}
// 如果不存在,新增一個選中的關鍵字
const newBadge = document.createElement('span');
newBadge.className = 'badge ar-keyword-badge is-selected border me-1 mb-1 keyword-badge';
newBadge.style.cursor = 'pointer';
newBadge.textContent = keyword;
newBadge.onclick = function() { toggleKeyword(this); };
newBadge.title = '點選移除此關鍵字';
document.getElementById('keywordsArea').appendChild(newBadge);
}
// ===== 即時趨勢洞察功能 =====
// 刷新趨勢資料
function refreshTrends() {
const source = document.getElementById('trendSource').value;
const category = document.getElementById('trendCategory').value;
// 並行載入關鍵字和趨勢記錄
Promise.all([
fetch(`/api/trends/keywords?source=${source}&category=${category}&days=7&limit=15`).then(r => r.json()),
fetch(`/api/trends/records?source=${source}&category=${category}&days=7&limit=10`).then(r => r.json())
])
.then(([keywordsData, recordsData]) => {
// 渲染關鍵字標籤雲
if (keywordsData.success && keywordsData.data.length > 0) {
renderTrendKeywordCloud(keywordsData.data);
} else {
document.getElementById('trendKeywordCloud').innerHTML =
'<span class="badge bg-light text-muted border">暫無關鍵字資料</span>';
}
// 渲染趨勢列表
if (recordsData.success && recordsData.data.length > 0) {
renderTrendList(recordsData.data);
} else {
document.getElementById('trendListArea').innerHTML =
'<p class="text-muted text-center py-3 mb-0">暫無趨勢資料,請先觸發爬取</p>';
}
})
.catch(e => {
console.error('載入趨勢資料失敗:', e);
document.getElementById('trendKeywordCloud').innerHTML =
'<span class="badge bg-light text-muted border">載入失敗</span>';
document.getElementById('trendListArea').innerHTML =
'<p class="text-danger text-center py-3 mb-0">載入失敗</p>';
});
}
// 渲染趨勢關鍵字標籤雲
function renderTrendKeywordCloud(keywords) {
const container = document.getElementById('trendKeywordCloud');
const html = keywords.map(kw => {
const size = Math.min(Math.max(10 + kw.total_mentions, 12), 16);
return `<span class="badge bg-success bg-opacity-75 me-1 mb-1"
style="font-size: ${size}px; cursor: pointer;"
onclick="useTrendKeyword('${escapeHtml(kw.keyword)}')"
title="提及 ${kw.total_mentions} 次">${escapeHtml(kw.keyword)}</span>`;
}).join('');
container.innerHTML = html;
}
// 渲染趨勢列表
function renderTrendList(records) {
const container = document.getElementById('trendListArea');
const sourceIcons = {
'ptt': '<span class="badge ar-source-badge ar-source-badge--ptt me-1">PTT</span>',
'dcard': '<span class="badge ar-source-badge ar-source-badge--dcard me-1">Dcard</span>',
'google_news': '<span class="badge ar-source-badge ar-source-badge--google-news me-1">新聞</span>',
'youtube': '<span class="badge ar-source-badge ar-source-badge--youtube me-1">YT</span>',
'ollama_web_search': '<span class="badge ar-source-badge ar-source-badge--ai me-1">AI</span>'
};
const html = records.map(r => `
<div class="d-flex align-items-start py-1 border-bottom" style="cursor: pointer;"
onclick="useTrendForProduct('${escapeHtml(r.title)}')">
${sourceIcons[r.source] || '<span class="badge bg-secondary me-1">其他</span>'}
<div class="flex-grow-1 overflow-hidden">
<small class="text-truncate d-block">${escapeHtml(r.title.substring(0, 40))}${r.title.length > 40 ? '...' : ''}</small>
<small class="text-muted">${r.category || ''} · 熱度 ${r.popularity_score || 0}</small>
</div>
</div>
`).join('');
container.innerHTML = html;
}
// 使用趨勢關鍵字
function useTrendKeyword(keyword) {
addKeywordFromInsight(keyword);
}
// 使用趨勢作為商品名稱
function useTrendForProduct(title) {
document.getElementById('productName').value = title;
}
// 初始化
document.addEventListener('DOMContentLoaded', function() {
initAIProvider(); // 初始化 AI 引擎選擇
refreshAIStatus();
renderUpcomingHolidays();
refreshTrends(); // 載入即時趨勢(預設頁籤)
updateKeywordCount();
// 監聽平台切換
document.querySelectorAll('input[name="platform"]').forEach(r => r.addEventListener('change', loadBestsellers));
// 監聽 Enter 鍵搜尋
document.getElementById('webSearchQuery').addEventListener('keypress', function(e) {
if (e.key === 'Enter') doWebSearch();
});
// 頁籤切換時延遲載入數據
const tabEl = document.querySelectorAll('#marketInfoTabs button[data-bs-toggle="pill"]');
tabEl.forEach(tab => {
tab.addEventListener('shown.bs.tab', function(event) {
const targetId = event.target.getAttribute('data-bs-target');
if (targetId === '#bestsellers-panel') {
// 檢查是否已載入
const container = document.getElementById('bestsellersCard');
if (container.querySelector('.spinner-border')) {
loadBestsellers();
}
} else if (targetId === '#rankings-panel') {
const cosmeContainer = document.getElementById('cosmeCard');
const mybestContainer = document.getElementById('mybestCard');
if (cosmeContainer.querySelector('.spinner-border')) {
loadCosmeRankings();
}
if (mybestContainer.querySelector('.spinner-border')) {
loadMybestArticles();
}
} else if (targetId === '#news-panel') {
const newsContainer = document.getElementById('newsCard');
if (newsContainer.querySelector('.spinner-border')) {
loadTrends();
}
}
});
});
});
})();