/* ═══════════════════════════════════════════════════════════ * page-dashboard.js — Dashboard 互動腳本 * 從原 dashboard.html L1118-L1547 抽出 * 變更: * - Chart.js 漸層色從 #667eea/#764ba2 改讀 CSS var(--momo-page-accent) * - tooltip border / pointBackground 使用群組色 * ═══════════════════════════════════════════════════════════ */ // 讀取 CSS var 的小工具 function getMomoVar(name, fallback) { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; } // Bootstrap Tooltips var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); tooltipTriggerList.map(function (el) { return new bootstrap.Tooltip(el); }); function getCSRFToken() { return document.querySelector('meta[name="csrf-token"]').getAttribute('content'); } function triggerTask() { if (confirm('確定要手動執行全站爬蟲嗎?(可能需要一段時間)')) { fetch('/api/run_task', { method: 'POST', headers: { 'X-CSRFToken': getCSRFToken() } }) .then(r => r.json()).then(d => alert(d.message)).catch(e => alert('錯誤: ' + e)); } } function triggerNotification() { if (confirm('確定要發送今日商品異動通知嗎?')) { fetch('/api/trigger_momo_notification', { method: 'POST', headers: { 'X-CSRFToken': getCSRFToken() } }) .then(r => r.json()).then(d => alert(d.message)).catch(e => alert('錯誤: ' + e)); } } let priceChartInstance = null; function handleRowClick(event, productId, row) { if (event.target.closest('a') || event.target.closest('button')) return; showHistory(productId, row.getAttribute('data-name')); } function showHistory(productId, productName) { if (typeof Chart === 'undefined') { alert('圖表元件尚未載入完成'); return; } const modalEl = document.getElementById('historyModal'); let modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl); document.getElementById('historyModalLabel').innerText = productName; modal.show(); if (priceChartInstance) { priceChartInstance.destroy(); priceChartInstance = null; } const accent = getMomoVar('--momo-page-accent', '#D97757'); const accentDark = getMomoVar('--momo-page-accent-dark', '#A85A3F'); const accentSoft = getMomoVar('--momo-page-accent-soft', 'rgba(217,119,87,0.12)'); fetch(`/api/history/${productId}`).then(r => r.json()).then(data => { const ctx = document.getElementById('priceChart').getContext('2d'); priceChartInstance = new Chart(ctx, { type: 'line', data: { labels: data.map(d => d.t), datasets: [{ label: '價格', data: data.map(d => d.p), borderColor: accent, backgroundColor: accentSoft, borderWidth: 2, fill: true, tension: 0.35, pointRadius: 3, pointHoverRadius: 6, pointBackgroundColor: accent, pointBorderColor: '#fff8ef', pointBorderWidth: 2, pointHoverBackgroundColor: accentDark, pointHoverBorderColor: '#fff8ef' }] }, options: { responsive: true, maintainAspectRatio: true, interaction: { mode: 'index', intersect: false }, plugins: { tooltip: { backgroundColor: '#29261b', titleColor: '#fff8ef', bodyColor: '#fff8ef', borderColor: accent, borderWidth: 1, padding: 10, displayColors: false, callbacks: { label: c => ' $' + c.parsed.y.toLocaleString() } }, legend: { display: false } }, scales: { y: { beginAtZero: false, grid: { color: 'rgba(0,0,0,0.05)', drawBorder: false }, ticks: { color: accentDark, font: { weight: '600' }, callback: v => '$' + v.toLocaleString() } }, x: { grid: { display: false }, ticks: { color: '#6c757d', font: { size: 11 } } } }, animation: { duration: 600, easing: 'easeInOutQuart' } } }); }).catch(err => console.error("圖表載入失敗:", err)); } function copyToClipboard(event, text, element) { event.stopPropagation(); const showFeedback = () => { const orig = element.innerHTML; element.innerHTML = '✅ 已複製!'; element.style.transform = 'scale(1.1)'; element.style.transition = 'all 0.3s ease'; setTimeout(() => { element.innerHTML = orig; element.style.transform = 'scale(1)'; }, 1500); }; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(showFeedback); } else { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.focus(); ta.select(); try { document.execCommand('copy'); showFeedback(); } catch (err) { console.error('複製失敗', err); } document.body.removeChild(ta); } } let currentFilterType = ''; let currentFilterCategory = ''; function showPriceChangeModal(type, title, productId = '') { currentFilterType = type; currentFilterCategory = (type === 'category') ? title : ''; const modalEl = document.getElementById('priceChangeModal'); let modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl); document.getElementById('priceChangeModalLabel').innerText = title; modal.show(); const tbody = document.getElementById('modalProductList'); tbody.innerHTML = '
載入中...
'; let apiUrl = `/api/price_change_details?type=${type}&category=${encodeURIComponent(currentFilterCategory)}`; if (productId) apiUrl += `&product_id=${encodeURIComponent(productId)}`; fetch(apiUrl).then(r => r.json()).then(data => { if (data.products && data.products.length > 0) { document.getElementById('modalProductCount').innerText = `${data.products.length} 件商品`; const escapeHtml = s => s == null ? '' : String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); let html = ''; data.products.forEach(p => { const cls = p.change > 0 ? 'price-up' : (p.change < 0 ? 'price-down' : 'text-muted'); const icon = p.change > 0 ? '↑' : (p.change < 0 ? '↓' : ''); const txt = p.change > 0 ? `+$${Math.abs(p.change).toLocaleString()}` : (p.change < 0 ? `-$${Math.abs(p.change).toLocaleString()}` : '$0'); const u = escapeHtml(p.url), pid = escapeHtml(p.product_id), nm = escapeHtml(p.name); html += ` ${pid} ${nm} ${escapeHtml(p.category) || '未分類'} $${(p.old_price || 0).toLocaleString()} $${(p.current_price || 0).toLocaleString()} ${icon} ${txt} ${escapeHtml(p.update_time)} `; }); tbody.innerHTML = html; } else { tbody.innerHTML = '無資料'; } }).catch(e => { console.error('載入資料失敗', e); tbody.innerHTML = '載入失敗,請重試'; }); } function exportToExcel() { if (!currentFilterType) { alert('無法匯出,請先選擇要查看的項目'); return; } window.location.href = `/api/export/price_changes?type=${currentFilterType}&category=${encodeURIComponent(currentFilterCategory)}`; } /* ───────── MOMO link guard(與設計無關,原樣保留) ───────── */ function getSafeMomoFallbackUrl(link) { const iCode = (link.dataset.trackIcode || link.dataset.trackProductId || '').trim(); if (!isLikelyMomoProductCode(iCode)) return '#'; return `https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=${encodeURIComponent(iCode)}`; } function isLikelyMomoProductCode(value) { const c = (value || '').trim(); if (!c) return false; const l = c.toLowerCase(); if (['nan', 'none', 'null', 'undefined'].includes(l)) return false; if (l.startsWith('momo_') || l.startsWith('manual_') || l.startsWith('pchome_')) return false; return /^[A-Za-z0-9_-]{4,}$/.test(c); } function isBlockedMomoUrl(url) { const l = (url || '').toLowerCase(); if (l.includes('ec404')) return true; try { const p = new URL(url, location.origin); if (!(p.pathname || '').toLowerCase().includes('goodsdetail')) return false; const code = (p.searchParams.get('i_code') || '').trim(); if (code) return !isLikelyMomoProductCode(code); return !/\/goodsdetail\/[^/?#]+/i.test(p.pathname); } catch (e) { return false; } } function openMomoUrl(link, url) { if (!url || url === '#') return; const t = (link.getAttribute('target') || '_self').toLowerCase(); if (t === '_blank') window.open(url, '_blank', 'noopener,noreferrer'); else if (t === '_self' || t === '') window.location.href = url; else window.open(url, t); } function trackMomoLinkClick(event) { const link = event.target.closest('.momo-tracked-link'); if (!link) return; const href = link.getAttribute('href') || ''; const originalHref = link.dataset.momoOriginalUrl || href; if (!href || href === '#') return; const payload = { url: originalHref, page: location.pathname, source: link.dataset.trackSource || 'unknown', platform: link.dataset.trackPlatform || 'momo', product_id: link.dataset.trackProductId || '', i_code: link.dataset.trackIcode || '', product_name: link.dataset.trackProductName || '', label: (link.textContent || '').trim(), effective_url: href }; if (isBlockedMomoUrl(href)) { event.preventDefault(); const fb = link.dataset.momoFallbackUrl || getSafeMomoFallbackUrl(link); if (fb && fb !== '#' && fb !== href) { link.dataset.momoFallbackUrl = fb; link.setAttribute('href', fb); payload.effective_url = fb; openMomoUrl(link, fb); } } fetch('/api/track_momo_link', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCSRFToken() }, body: JSON.stringify(payload) }).catch(() => {}); } document.addEventListener('click', trackMomoLinkClick);