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

494 lines
20 KiB
JavaScript
Raw 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.
// 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);
}
}