1081 lines
50 KiB
JavaScript
1081 lines
50 KiB
JavaScript
/* 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// 顯示/隱藏載入
|
||
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/2:AI 深度分析中...';
|
||
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();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
})();
|