494 lines
20 KiB
JavaScript
494 lines
20 KiB
JavaScript
// 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();
|
||
|
||
// 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 ==============
|
||
async function fetchRepurchase() {
|
||
try {
|
||
const response = await fetch(`${BASE_URL}/product/repurchase`);
|
||
if (!response.ok) return;
|
||
const data = await response.json();
|
||
|
||
const tbody = document.querySelector('#repurchase-table tbody');
|
||
tbody.innerHTML = '';
|
||
if(data.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="text-center">暂无数据</td></tr>';
|
||
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 = `
|
||
<td><strong>${item.item_name || ('商品 '+item.item_id)}</strong></td>
|
||
<td>${item.purchaser_count_30d} 人</td>
|
||
<td>${item.repurchaser_count_30d} 人</td>
|
||
<td>${formatPercent(item.repurchase_rate_30d)}</td>
|
||
<td><span class="badge ${badgeClass}">${item.matrix_tag || '-'}</span></td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
|
||
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 = '<tr><td colspan="4" class="text-center">暂无数据</td></tr>';
|
||
return;
|
||
}
|
||
|
||
data.forEach(item => {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td><strong>${item.item_name_a}</strong></td>
|
||
<td>${item.item_name_b}</td>
|
||
<td>${item.pair_order_count} 笔</td>
|
||
<td><span class="badge primary">${formatPercent(item.confidence)}</span></td>
|
||
`;
|
||
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 = `
|
||
<h3>${item.rfm_group} <span class="info-icon" data-tooltip="${tooltip}">ⓘ</span></h3>
|
||
<div class="value">${item.customer_count} <span style="font-size:1rem;font-weight:400;">人</span></div>
|
||
<div class="trend ${trendClass}">营收贡献: ${formatCurrency(item.total_revenue_contribution)}</div>
|
||
`;
|
||
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 = '<tr><td colspan="4" class="text-center">近14天数据不足,无法生成建议</td></tr>';
|
||
return;
|
||
}
|
||
|
||
data.forEach(item => {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td><strong>${item.item_name || item.item_id}</strong></td>
|
||
<td>${item.sum_14d} 件</td>
|
||
<td>${parseFloat(item.avg_daily_speed).toFixed(2)} 件/天</td>
|
||
<td><span class="badge success">备货 ${item.advice_stock_qty} 件</span></td>
|
||
`;
|
||
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 = `<strong style="font-size:14px">${params.name}</strong><br/>`;
|
||
html += `人数: <strong>${params.value}</strong> 人 (${params.percent}%)`;
|
||
if (def) {
|
||
html += `<br/><span style="color:#86868b;font-size:12px">定义: ${def}</span>`;
|
||
}
|
||
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 = `<span class="log-time">${time}</span>${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);
|
||
}
|
||
}
|