Files
ewoooc/web/static/js/page-dashboard.js
OoO 605250619c
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
Frontend V3 responsive production update
2026-05-12 18:27:29 +08:00

235 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ═══════════════════════════════════════════════════════════
* page-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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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);