235 lines
12 KiB
JavaScript
235 lines
12 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════
|
||
* 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 = '<tr><td colspan="8" class="text-center py-4"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">載入中...</span></div></td></tr>';
|
||
|
||
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, '"').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 += `<tr>
|
||
<td><img src="${escapeHtml(p.image_url)}" onerror="this.src='/static/placeholder.png'" style="width:60px;height:60px;object-fit:cover;border-radius:8px;"></td>
|
||
<td><a href="${u}" target="_blank" class="momo-tracked-link" data-track-platform="momo" data-track-source="dashboard-legacy-modal" data-track-product-id="${pid}" data-track-icode="${pid}" data-track-product-name="${nm}" data-momo-original-url="${u}">${pid}</a></td>
|
||
<td><a href="${u}" target="_blank" class="momo-tracked-link" data-track-platform="momo" data-track-source="dashboard-legacy-modal" data-track-product-id="${pid}" data-track-icode="${pid}" data-track-product-name="${nm}" title="${nm}" data-momo-original-url="${u}">${nm}</a></td>
|
||
<td><span class="badge bg-secondary">${escapeHtml(p.category) || '未分類'}</span></td>
|
||
<td>$${(p.old_price || 0).toLocaleString()}</td>
|
||
<td><strong>$${(p.current_price || 0).toLocaleString()}</strong></td>
|
||
<td><strong class="${cls}">${icon} ${txt}</strong></td>
|
||
<td class="small text-muted">${escapeHtml(p.update_time)}</td>
|
||
</tr>`;
|
||
});
|
||
tbody.innerHTML = html;
|
||
} else {
|
||
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4 text-muted">無資料</td></tr>';
|
||
}
|
||
}).catch(e => {
|
||
console.error('載入資料失敗', e);
|
||
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4 text-danger">載入失敗,請重試</td></tr>';
|
||
});
|
||
}
|
||
|
||
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);
|