Files
youzan-datahub/dashboard-ui/app.js

514 lines
20 KiB
JavaScript
Raw Normal View History

// 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 = '<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) {}
}
// 初始化复购率周期切换控件
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 = '<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);
}
}