Files
ewoooc/web/static/js/page-ai-recommend.js
ogt c268b5cc02
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s
feat: show product identity in ai recommendations
2026-06-26 18:33:11 +08:00

1121 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 || 'pchome';
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) => renderBestsellerCard(p, i, data.data.platform)).join('')
+ `<div class="text-center text-muted small py-1">${escapeHtml(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>');
}
function normalizeBestsellerProduct(product, platform) {
const p = product || {};
return {
name: String(p.name || '').trim(),
productId: String(p.product_id || p.id || p.i_code || p.goodsCode || '').trim(),
platform: String(p.platform || platform || '').toLowerCase(),
price: Number(p.price || 0),
url: String(p.product_url || p.url || '').trim(),
imageUrl: String(p.image_url || p.image || '').trim()
};
}
function platformLabel(platform) {
return String(platform || '').toLowerCase() === 'momo' ? 'MOMO' : 'PChome';
}
function renderProductThumb(product) {
const image = product.imageUrl;
if (image && /^https?:\/\//i.test(image)) {
return `<img class="ar-product-card__img" src="${escapeHtml(image)}" alt="${escapeHtml(product.name)}" loading="lazy" referrerpolicy="no-referrer">`;
}
return `<div class="ar-product-card__missing-img"><i class="fas fa-image"></i><span>待補圖片</span></div>`;
}
function renderBestsellerCard(product, index, platform) {
const p = normalizeBestsellerProduct(product, platform);
const label = platformLabel(p.platform);
const price = p.price > 0 ? `NT$ ${p.price.toLocaleString()}` : '待補價格';
const productId = p.productId || '待補';
const storeAction = p.url
? `<a href="${escapeHtml(p.url)}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-primary ar-product-card__link" onclick="event.stopPropagation()" title="開啟賣場"><i class="fas fa-up-right-from-square me-1"></i>開賣場</a>`
: '<span class="badge bg-light text-muted border ar-product-card__pending">待補連結</span>';
return `
<div class="ar-product-card" data-product-name="${escapeHtml(p.name)}" data-product-id="${escapeHtml(p.productId)}" data-product-url="${escapeHtml(p.url)}" data-platform="${escapeHtml(p.platform)}" data-price="${p.price}" onclick="setProductFromCard(this)">
<div class="ar-product-card__rank">${index + 1}</div>
<div class="ar-product-card__media">${renderProductThumb(p)}</div>
<div class="ar-product-card__body">
<div class="ar-product-card__meta">
<span class="badge bg-light text-dark border">${label}</span>
<span class="ar-product-card__id">商品 ID ${escapeHtml(productId)}</span>
</div>
<div class="ar-product-card__name">${escapeHtml(p.name || '待補商品名稱')}</div>
<div class="ar-product-card__price">${price}</div>
</div>
<div class="ar-product-card__actions">${storeAction}</div>
</div>`;
}
// 載入 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;" data-product-name="${escapeHtml(p.name)}" onclick="setProductFromCard(this)">
<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;" data-product-name="${escapeHtml(a.title)}" onclick="setProductFromCard(this)">
<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;" data-product-name="${escapeHtml(n.title || n.query || '')}" onclick="setProductFromCard(this)">
<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 ''; }
}
// ====== 建議引擎切換相關 ======
// 建議引擎切換處理
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';
document.getElementById('geminiUsagePanel').style.display = isGemini ? 'block' : 'none';
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('fallbackMonthlySpend').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);
});
}
// 首屏先渲染,建議引擎狀態載入後再更新,避免健康檢查阻塞頁面 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-wand-magic-sparkles', '建議引擎', status.ollama?.connected, 'ar-status--ok');
updateAIStatusBadge('geminiStatus', 'fas fa-shield-alt', '備援守門', status.gemini?.connected, 'ar-status--info');
updateOllamaModels(status.ollama?.available_models || []);
})
.catch(e => console.warn('建議引擎狀態刷新未完成:', 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('');
}
// 初始化建議引擎選擇(頁面載入時)
function initAIProvider() {
const provider = document.getElementById('aiProvider').value;
onProviderChange();
}
// ====== 其他功能 ======
// 設定商品名稱
function setProduct(name) {
document.getElementById('productName').value = name.substring(0, 100);
}
function setProductFromCard(card) {
const name = card?.dataset?.productName || '';
if (name) setProduct(name);
}
// 切換關鍵字
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 .ar-product-card');
return Array.from(items).slice(0, 3).map(el => {
const price = parseInt(el.dataset.price || '0', 10) || 0;
return {
name: el.dataset.productName || '',
price,
product_id: el.dataset.productId || '',
platform: el.dataset.platform || '',
url: el.dataset.productUrl || ''
};
});
}
// 生成文案
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>正在整理建議...';
btn.classList.add('btn-secondary');
btn.classList.remove('btn-primary');
// 顯示全螢幕載入動畫
showLoading('正在產生銷售建議...');
// 取得幕後建議設定
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-check-circle me-1"></i>建議已完成`;
metaHtml += ` | <i class="fas fa-clock me-1"></i>分析耗時:${data.data.duration}`;
if (data.data.provider === 'gemini' && data.data.cost) {
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();
}
// 市場訊號搜尋
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">正在整理市場訊號...</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>整理訊號';
if (data.success) {
renderWebSearchResult(data.data);
} else {
contentArea.innerHTML = `<div class="alert alert-warning py-2 mb-0"><i class="fas fa-exclamation-triangle me-1"></i>市場訊號暫時不可用,請稍後重試。</div>`;
}
})
.catch(e => {
clearTimeout(timeoutId);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-brain me-1"></i>整理訊號';
if (e.name === 'AbortError') {
contentArea.innerHTML = `<div class="alert alert-warning py-2 mb-0"><i class="fas fa-clock me-1"></i>市場訊號整理較慢,請稍後再試。</div>`;
} else {
contentArea.innerHTML = `<div class="alert alert-warning py-2 mb-0">市場訊號暫時不可用,請稍後重試。</div>`;
}
});
}
// 渲染市場訊號結果
function renderWebSearchResult(data) {
const contentArea = document.getElementById('webSearchContent');
let html = '';
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>市場摘要</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="alert alert-warning py-2 mb-0">
<i class="fas fa-triangle-exclamation me-1"></i>
外部訊號已取得,但尚未整理成可直接判斷的摘要;請重新整理訊號後再產生下一步。
</div>`;
}
html += `
<div class="text-end mt-2">
<small class="text-muted">
<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} 市場分析 競品比較 價格 評價 ${new Date().getFullYear()}`,
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產生下一步...';
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-warning py-2 mb-0">商品判斷暫時不可用,請稍後重試。</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>商品判斷較慢,請稍後再試。</div>`;
} else {
resultArea.innerHTML = `<div class="alert alert-warning py-2 mb-0">商品判斷暫時不可用,請稍後重試。</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="alert alert-warning py-2 mb-0">
<i class="fas fa-triangle-exclamation me-1"></i>
商品判斷尚未整理成可執行摘要;請重新判斷或先補商品名稱與賣場線索。
</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-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">搜尋</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;
}
Object.assign(window, {
addKeywordFromInsight,
copyCopyText,
doProductInsights,
doWebSearch,
generateCopy,
loadBestsellers,
loadCosmeRankings,
loadMybestArticles,
loadTrends,
onProviderChange,
quickWebSearch,
refreshTrends,
setProduct,
setProductFromCard,
switchTrendTab,
toggleKeyword,
useTrendForProduct,
useTrendKeyword
});
// 初始化
document.addEventListener('DOMContentLoaded', function() {
initAIProvider();
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();
}
}
});
});
});
})();