// API Endpoint Config (相对路径, 同源部署无需跨域) const BASE_URL = '/api'; // 数据截止时间缓存 let dataCutoffCache = {}; // ECharts Instances let trendChart; let tagChart; // Initialize when DOM is loaded document.addEventListener('DOMContentLoaded', () => { initCharts(); setupNavigation(); setupRepurchasePeriod(); // Fetch cutoff dates + initial page data fetchDataCutoff(); fetchData(); }); // Setup SPA Navigation function setupNavigation() { const navItems = document.querySelectorAll('.nav-item'); const sections = { '大盘总览': 'page-overview', '商品洞察': 'page-product', '会员画像': 'page-customer', '订货建议': 'page-advice', '运维管理': 'page-ops' }; // 页面名 → 标题文本 映射 const pageTitles = { '大盘总览': '经营数据大盘概览', '商品洞察': '商品洞察分析', '会员画像': '会员画像分析', '订货建议': '智能订货建议', '运维管理': '运维管理' }; // 页面名 → 截止日期 key 映射 const cutoffKeys = { '大盘总览': 'overview', '商品洞察': 'product', '会员画像': 'customer', '订货建议': 'advice' }; navItems.forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); navItems.forEach(nav => nav.classList.remove('active')); item.classList.add('active'); const pageTitle = item.textContent.trim(); document.getElementById('page-title').textContent = pageTitles[pageTitle] || pageTitle; // 更新数据截止日期 const badge = document.getElementById('data-cutoff-badge'); const cutoffKey = cutoffKeys[pageTitle]; if (cutoffKey && dataCutoffCache[cutoffKey]) { badge.textContent = `计算数据截止至 ${dataCutoffCache[cutoffKey]}`; badge.style.display = ''; } else if (pageTitle === '运维管理') { badge.style.display = 'none'; } else { badge.textContent = '计算数据截止至 —'; badge.style.display = ''; } document.querySelectorAll('.page-section').forEach(sec => sec.style.display = 'none'); const targetId = sections[pageTitle]; if(targetId) { document.getElementById(targetId).style.display = 'block'; } fetchDataForPage(pageTitle); }); }); } // Initialize ECharts with premium minimalist styling function initCharts() { trendChart = echarts.init(document.getElementById('trendChart'), null, { renderer: 'svg' }); tagChart = echarts.init(document.getElementById('tagChart'), null, { renderer: 'svg' }); // Resize observer to keep chart responsive window.addEventListener('resize', () => { trendChart.resize(); tagChart.resize(); }); } // Format currency function formatCurrency(val) { if (val === undefined || val === null) return '¥0.00'; return '¥' + parseFloat(val).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } // Format percentages function formatPercent(val) { if (val === undefined || val === null) return '0%'; return (parseFloat(val) * 100).toFixed(1) + '%'; } // Fetch all data for current active page async function fetchData() { const activeNav = document.querySelector('.nav-item.active'); if (activeNav) { await fetchDataForPage(activeNav.textContent.trim()); } } async function fetchDataForPage(pageName) { const btn = document.querySelector('.btn-refresh'); btn.textContent = '加载中...'; btn.classList.add('loading'); try { if (pageName === '大盘总览') { await Promise.all([fetchOverview(), fetchTrend()]); } else if (pageName === '商品洞察') { await Promise.all([fetchRepurchase(), fetchBasket()]); } else if (pageName === '会员画像') { await Promise.all([fetchRfm(), fetchTags()]); // echarts requires explicit resize when un-hidden setTimeout(() => tagChart.resize(), 100); } else if (pageName === '订货建议') { await fetchAdvice(); } } catch (error) { console.error("Failed to fetch data:", error); } finally { setTimeout(() => { btn.textContent = '刷新数据'; btn.classList.remove('loading'); }, 500); } } // ============== DATA CUTOFF ============== async function fetchDataCutoff() { try { const response = await fetch(`${BASE_URL}/dashboard/data-cutoff`); if (!response.ok) return; dataCutoffCache = await response.json(); // 更新当前页面的截止日期 const badge = document.getElementById('data-cutoff-badge'); if (dataCutoffCache.overview) { badge.textContent = `计算数据截止至 ${dataCutoffCache.overview}`; } } catch (e) { console.error('fetchDataCutoff error:', e); } } // ============== PAGE 1: OVERVIEW ============== async function fetchOverview() { try { const response = await fetch(`${BASE_URL}/dashboard/overview`); if (!response.ok) return; const data = await response.json(); animateValue(document.getElementById('revenue-val'), data.past_14d_revenue || 0, true); animateValue(document.getElementById('orders-val'), data.past_14d_orders || 0, false); animateValue(document.getElementById('atv-val'), data.past_14d_atv || 0, true); animateValue(document.getElementById('refund-val'), data.past_14d_refund || 0, true); } catch (e) { console.error(e); } } async function fetchTrend() { try { const response = await fetch(`${BASE_URL}/dashboard/trend`); if (!response.ok) return; const data = await response.json(); const dates = data.map(item => item.stat_date); const revenues = data.map(item => parseFloat(item.revenue)); const orders = data.map(item => parseInt(item.order_count)); updateTrendChart(dates, revenues, orders); } catch (e) { console.error(e); } } // ============== PAGE 2: PRODUCT INSIGHTS ============== // 当前选中的复购率统计周期 let repurchaseDays = 30; async function fetchRepurchase(days) { if (days !== undefined) repurchaseDays = days; try { const response = await fetch(`${BASE_URL}/product/repurchase?days=${repurchaseDays}`); if (!response.ok) return; const data = await response.json(); const tbody = document.querySelector('#repurchase-table tbody'); tbody.innerHTML = ''; if(data.length === 0) { tbody.innerHTML = '暂无数据'; return; } data.forEach(item => { const tr = document.createElement('tr'); // Badge color logic let badgeClass = 'primary'; if(item.matrix_tag === '核心引流爆款') badgeClass = 'success'; if(item.matrix_tag === '体验差需淘汰') badgeClass = 'danger'; if(item.matrix_tag === '小众潜力款') badgeClass = 'warning'; tr.innerHTML = ` ${item.item_name || ('商品 '+item.item_id)} ${item.purchaser_count_30d} 人 ${item.repurchaser_count_30d} 人 ${formatPercent(item.repurchase_rate_30d)} ${item.matrix_tag || '-'} `; tbody.appendChild(tr); }); } catch (e) {} } // 初始化复购率周期切换控件 function setupRepurchasePeriod() { const container = document.getElementById('repurchase-period'); if (!container) return; container.addEventListener('click', (e) => { const btn = e.target.closest('.segment-btn'); if (!btn) return; container.querySelectorAll('.segment-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const days = parseInt(btn.dataset.days); fetchRepurchase(days); }); } async function fetchBasket() { try { const response = await fetch(`${BASE_URL}/product/basket`); if (!response.ok) return; const data = await response.json(); const tbody = document.querySelector('#basket-table tbody'); tbody.innerHTML = ''; if(data.length === 0) { tbody.innerHTML = '暂无数据'; return; } data.forEach(item => { const tr = document.createElement('tr'); tr.innerHTML = ` ${item.item_name_a} ${item.item_name_b} ${item.pair_order_count} 笔 ${formatPercent(item.confidence)} `; tbody.appendChild(tr); }); } catch (e) {} } // ============== PAGE 3: CUSTOMER INSIGHTS ============== async function fetchRfm() { try { const response = await fetch(`${BASE_URL}/customer/rfm/distribution`); if (!response.ok) return; const data = await response.json(); const container = document.getElementById('rfm-cards'); container.innerHTML = ''; // RFM 分组 → 计算逻辑说明 const rfmTooltips = { '重要价值客户': 'R(最近消费)高 + F(消费频次)高 + M(消费金额)高, 核心忠实客户', '重要发展客户': 'R高 + F低 + M高, 消费金额大但频次不够, 需提升复购', '重要保持客户': 'R低 + F高 + M高, 消费频次和金额都高但最近未消费, 需唤醒', '重要挽留客户': 'R低 + F低 + M高, 曾经的高价值客户但已流失, 需重点挽回', '一般价值客户': 'R高 + F高 + M低, 活跃但消费金额偏低', '一般发展客户': 'R高 + F低 + M低, 新客户/偶发客户, 有发展潜力', '一般保持客户': 'R低 + F高 + M低, 活跃但金额低且近期未消费', '一般挽留客户': 'R低 + F低 + M低, 低价值流失客户', }; data.forEach(item => { let trendClass = 'neutral'; if(item.rfm_group.includes('重要')) trendClass = 'positive'; if(item.rfm_group.includes('沉睡')) trendClass = 'negative'; const tooltip = rfmTooltips[item.rfm_group] || 'RFM综合打分分群'; const card = document.createElement('div'); card.className = 'glass-card metric-card'; card.innerHTML = `

${item.rfm_group}

${item.customer_count}
营收贡献: ${formatCurrency(item.total_revenue_contribution)}
`; container.appendChild(card); }); } catch (e) {} } async function fetchTags() { try { const response = await fetch(`${BASE_URL}/customer/tags`); if (!response.ok) return; const data = await response.json(); // 标签名 → 定义说明 (鼠标悬浮时显示) const tagDefs = { '工作日常客': '90天内周一~周五消费 ≥ 3 次', '高客单囤货客': '有单笔订单实付 ≥ 200 元', '周末活跃客': '90天内周六日消费 ≥ 3 次', '新客户': '首单在近 30 天内', '沉睡客户': '最近一次消费距今 > 60 天', '高频复购客': '90天内消费 ≥ 10 次', '多品类客户': '90天内购买 ≥ 3 种不同商品', '大额客户': '历史累计消费 ≥ 1000 元', }; const chartData = data.map(item => ({ value: item.tagged_users_count, name: item.tag_name, definition: tagDefs[item.tag_name] || '' })); updateTagChart(chartData); } catch(e) {} } // ============== PAGE 4: ADVICE ============== async function fetchAdvice() { try { const response = await fetch(`${BASE_URL}/product/advice`); if (!response.ok) return; const data = await response.json(); const tbody = document.querySelector('#advice-table tbody'); tbody.innerHTML = ''; if(data.length === 0) { tbody.innerHTML = '近14天数据不足,无法生成建议'; return; } data.forEach(item => { const tr = document.createElement('tr'); tr.innerHTML = ` ${item.item_name || item.item_id} ${item.sum_14d} 件 ${parseFloat(item.avg_daily_speed).toFixed(2)} 件/天 备货 ${item.advice_stock_qty} 件 `; tbody.appendChild(tr); }); } catch (e) {} } // ============== CHART UPDATERS & UTILS ============== function updateTrendChart(dates, revenues, orders) { const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; const textColor = isDarkMode ? '#f5f5f7' : '#1d1d1f'; const gridLineColor = isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.04)'; trendChart.setOption({ tooltip: { trigger: 'axis', backgroundColor: isDarkMode ? 'rgba(28, 28, 30, 0.9)' : 'rgba(255, 255, 255, 0.9)', borderColor: isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)', textStyle: { color: textColor, fontFamily: 'Inter' }, backdropFilter: 'blur(20px)', borderRadius: 12, padding: 12 }, legend: { data: ['销售额', '订单数'], textStyle: { color: '#86868b', fontFamily: 'Inter' }, top: 0, right: 0 }, grid: { left: '3%', right: '4%', bottom: '3%', top: '15%', containLabel: true }, xAxis: { type: 'category', boundaryGap: false, data: dates, axisLabel: { color: '#86868b' }, axisLine: { show: false }, axisTick: { show: false } }, yAxis: [ { type: 'value', name: 'Revenue', splitLine: { lineStyle: { color: gridLineColor, type: 'dashed' } }, axisLabel: { color: '#86868b' } }, { type: 'value', name: 'Orders', position: 'right', splitLine: { show: false }, axisLabel: { color: '#86868b' } } ], series: [ { name: '销售额', type: 'line', smooth: 0.4, symbol: 'none', itemStyle: { color: '#0071e3' }, lineStyle: { width: 3, shadowColor: 'rgba(0, 113, 227, 0.3)', shadowBlur: 10 }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(0, 113, 227, 0.2)' }, { offset: 1, color: 'rgba(0, 113, 227, 0)' }]) }, data: revenues }, { name: '订单数', type: 'line', yAxisIndex: 1, smooth: 0.4, symbol: 'none', itemStyle: { color: '#ff3b30' }, lineStyle: { width: 2, type: 'dashed' }, data: orders } ], animationDuration: 1500 }); } function updateTagChart(chartData) { const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; const textColor = isDarkMode ? '#f5f5f7' : '#1d1d1f'; tagChart.setOption({ tooltip: { trigger: 'item', backgroundColor: isDarkMode ? 'rgba(28, 28, 30, 0.95)' : 'rgba(255, 255, 255, 0.95)', textStyle: { color: textColor, fontFamily: 'Inter', fontSize: 13 }, borderColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', borderRadius: 12, padding: [12, 16], formatter: function(params) { const def = params.data.definition; let html = `${params.name}
`; html += `人数: ${params.value} 人 (${params.percent}%)`; if (def) { html += `
定义: ${def}`; } return html; } }, legend: { top: 'bottom', textStyle: { color: '#86868b', fontFamily: 'Inter' } }, series: [ { name: '画像人数', type: 'pie', radius: ['40%', '70%'], avoidLabelOverlap: false, itemStyle: { borderRadius: 10, borderColor: isDarkMode ? '#1c1c1e' : '#fff', borderWidth: 2 }, label: { show: false, position: 'center' }, emphasis: { label: { show: true, fontSize: '18', fontWeight: 'bold', color: textColor } }, labelLine: { show: false }, data: chartData, color: ['#0071e3', '#34c759', '#ff9500', '#ff3b30', '#5856d6', '#ff2d55', '#30b0c7', '#a2845e'] } ] }); } function animateValue(obj, end, isCurrency) { let startTimestamp = null; const duration = 1000; const target = parseFloat(end); const step = (timestamp) => { if (!startTimestamp) startTimestamp = timestamp; const progress = Math.min((timestamp - startTimestamp) / duration, 1); const ease = 1 - Math.pow(1 - progress, 4); const current = target * ease; obj.innerHTML = isCurrency ? formatCurrency(current) : Math.floor(current); if (progress < 1) window.requestAnimationFrame(step); else obj.innerHTML = isCurrency ? formatCurrency(target) : target; }; window.requestAnimationFrame(step); } // ============== PAGE 5: OPS MANAGEMENT ============== function appendLog(logEl, emoji, text, cssClass) { const entry = document.createElement('span'); entry.className = `log-entry ${cssClass || ''}`; const time = new Date().toLocaleTimeString('zh-CN'); entry.innerHTML = `${time}${emoji} ${text}`; logEl.appendChild(entry); logEl.scrollTop = logEl.scrollHeight; } async function triggerOps(action, btnElement) { const logEl = document.getElementById('ops-log'); const taskName = btnElement.querySelector('.ops-label').textContent; // Clear the initial placeholder text on first use if (logEl.querySelector('.log-placeholder')) { logEl.innerHTML = ''; } // Disable button & show running state btnElement.disabled = true; btnElement.classList.add('ops-running'); const origDesc = btnElement.querySelector('.ops-desc').textContent; btnElement.querySelector('.ops-desc').textContent = '执行中...'; appendLog(logEl, '⏳', `${taskName} 开始执行...`, 'log-pending'); try { // 构建 URL: 订单/退款同步支持可选的 from 日期参数 let url = `${BASE_URL.replace('/api', '')}/api/ops/${action}`; if (action === 'sync-orders' || action === 'sync-refunds') { const fromDate = document.getElementById('ops-from-date')?.value; if (fromDate) { url += `?from=${fromDate}`; appendLog(logEl, '📅', `使用指定日期: ${fromDate}`, 'log-pending'); } } const response = await fetch(url); const data = await response.json(); if (data.status === 'success') { const detail = data.message || (data.count !== undefined ? `处理 ${data.count} 条` : '完成'); appendLog(logEl, '✅', `${taskName} 成功 — ${detail}`, 'log-success'); btnElement.classList.add('ops-done'); } else { appendLog(logEl, '❌', `${taskName} 失败: ${data.message || '未知错误'}`, 'log-error'); btnElement.classList.add('ops-error'); } } catch (e) { appendLog(logEl, '❌', `${taskName} 网络异常: ${e.message}`, 'log-error'); btnElement.classList.add('ops-error'); } finally { btnElement.disabled = false; btnElement.classList.remove('ops-running'); btnElement.querySelector('.ops-desc').textContent = origDesc; setTimeout(() => { btnElement.classList.remove('ops-done', 'ops-error'); }, 3000); } }