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);
|
|||
|
|
}
|
|||
|
|
}
|