添加独立的前端页面目录 dashboard-ui 到代码仓库

This commit is contained in:
Peter
2026-03-23 16:21:00 +08:00
parent 41ca66866c
commit 6f962d67a2
4 changed files with 1461 additions and 0 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@
!/README.md !/README.md
!/server/ !/server/
!/sql/ !/sql/
!/dashboard-ui/
# 3. 针对 docs 目录:先豁免目录本身,忽略里面所有内容,然后再单一豁免目标子目录 # 3. 针对 docs 目录:先豁免目录本身,忽略里面所有内容,然后再单一豁免目标子目录
!/docs/ !/docs/

493
dashboard-ui/app.js Normal file
View File

@@ -0,0 +1,493 @@
// 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);
}
}

244
dashboard-ui/index.html Normal file
View File

@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>餐饮零售数据中台 | 核心引擎</title>
<!-- Google Fonts for Apple-like Typography -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
<!-- ECharts -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
</head>
<body>
<div class="app-container">
<!-- Sidebar Navigation -->
<aside class="sidebar">
<div class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
<polyline points="2 17 12 22 22 17"></polyline>
<polyline points="2 12 12 17 22 12"></polyline>
</svg>
<span>DataHub Core</span>
</div>
<nav class="nav-menu">
<a href="#" class="nav-item active">大盘总览</a>
<a href="#" class="nav-item">商品洞察</a>
<a href="#" class="nav-item">会员画像</a>
<a href="#" class="nav-item">订货建议</a>
<a href="#" class="nav-item">运维管理</a>
</nav>
</aside>
<!-- Main Content -->
<main class="main-content">
<header class="top-nav">
<div class="page-title-row">
<h1 id="page-title">经营数据大盘概览</h1>
<span id="data-cutoff-badge" class="data-cutoff-badge">计算数据截止至 —</span>
</div>
<div class="header-actions">
<button class="btn-refresh" onclick="fetchData()">刷新数据</button>
<div class="user-profile">
<img src="https://ui-avatars.com/api/?name=Admin&background=000&color=fff" alt="Admin">
</div>
</div>
</header>
<!-- Page 1: 大盘总览 -->
<div id="page-overview" class="page-section active">
<div class="dashboard-grid">
<!-- Overview Cards -->
<div class="metrics-row">
<div class="glass-card metric-card">
<h3>过去14天总营收 <span class="info-icon" data-tooltip="SUM(实付金额), 仅统计 TRADE_SUCCESS 状态的订单, 时间范围: 近14天按 pay_time 筛选"></span></h3>
<div class="value" id="revenue-val">¥0.00</div>
<div class="trend positive"><span></span> 实时</div>
</div>
<div class="glass-card metric-card">
<h3>过去14天订单数 <span class="info-icon" data-tooltip="COUNT(DISTINCT tid), 近14天内有 pay_time 的不重复订单号数量"></span></h3>
<div class="value" id="orders-val">0</div>
<div class="trend positive"><span></span> 实时</div>
</div>
<div class="glass-card metric-card">
<h3>平均客单价 (ATV) <span class="info-icon" data-tooltip="= 过去14天总营收 ÷ 过去14天订单数, 每笔订单的平均消费金额"></span></h3>
<div class="value" id="atv-val">¥0.00</div>
<div class="trend neutral">稳定</div>
</div>
<div class="glass-card metric-card">
<h3>过去14天退款额 <span class="info-icon" data-tooltip="SUM(refund_fee), 近14天内创建的退款单退款金额总计"></span></h3>
<div class="value" id="refund-val">¥0.00</div>
<div class="trend negative"><span></span> 风控监测中</div>
</div>
</div>
<!-- Charts Area -->
<div class="charts-row">
<div class="glass-card chart-container">
<div class="chart-header">
<h2>14天营收曲线 (Sales Trend) <span class="info-icon" data-tooltip="X轴=日期, 蓝线=当日SUM(payment), 红虚线=当日COUNT(DISTINCT tid), 按 pay_time 分组"></span></h2>
</div>
<div id="trendChart" style="width: 100%; height: 350px;"></div>
</div>
</div>
</div>
</div>
<!-- Page 2: 商品洞察 -->
<div id="page-product" class="page-section" style="display: none;">
<div class="dashboard-grid">
<div class="glass-card chart-container">
<div class="chart-header">
<h2>商品30天复购率与波士顿矩阵 <span class="info-icon" data-tooltip="每日洞察计算任务生成, 统计近30天各商品的购买/复购人数"></span></h2>
</div>
<div class="table-responsive">
<table class="data-table" id="repurchase-table">
<thead>
<tr>
<th>商品名称</th>
<th>30天购买人数 <span class="info-icon info-icon-sm" data-tooltip="COUNT(DISTINCT yz_open_id), 近30天购买过该商品的不重复客户数"></span></th>
<th>30天复购人数 <span class="info-icon info-icon-sm" data-tooltip="近30天内购买该商品≥2次的客户数"></span></th>
<th>复购率 <span class="info-icon info-icon-sm" data-tooltip="= 复购人数 ÷ 购买人数 × 100%"></span></th>
<th>波士顿矩阵打标 <span class="info-icon info-icon-sm" data-tooltip="购买人数高+复购率高=核心引流爆款; 购买人数高+复购率低=体验差需淘汰; 购买人数低+复购率高=小众潜力款; 其他=长尾观测品"></span></th>
</tr>
</thead>
<tbody>
<tr><td colspan="5" class="text-center">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="glass-card chart-container">
<div class="chart-header">
<h2>购物篮热门连带趋势 (Apriori Top20) <span class="info-icon" data-tooltip="Apriori关联规则挖掘, 基于近30天同一订单(tid)内共同出现的商品对"></span></h2>
</div>
<div class="table-responsive">
<table class="data-table" id="basket-table">
<thead>
<tr>
<th>核心商品 A</th>
<th>连带商品 B</th>
<th>共同出现订单数 <span class="info-icon info-icon-sm" data-tooltip="在同一笔订单中同时购买A和B的订单数"></span></th>
<th>搭配置信度 <span class="info-icon info-icon-sm" data-tooltip="= 同时购买A和B的订单数 ÷ 购买A的订单数 × 100%, 即买了A的人有多大概率也买B"></span></th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="text-center">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Page 3: 会员画像 -->
<div id="page-customer" class="page-section" style="display: none;">
<div class="dashboard-grid">
<div class="metrics-row" id="rfm-cards">
<!-- RFM Cards rendered via JS -->
</div>
<div class="glass-card chart-container">
<div class="chart-header">
<h2>全域会员标签分布 <span class="info-icon" data-tooltip="基于 RFM 模型对客户分群打标后, 各标签的人数分布饼图"></span></h2>
</div>
<div id="tagChart" style="width: 100%; height: 350px;"></div>
</div>
</div>
</div>
<!-- Page 4: 订货建议 -->
<div id="page-advice" class="page-section" style="display: none;">
<div class="glass-card chart-container">
<div class="chart-header">
<h2>次日智能安全备货单 (过去14天流速推断) <span class="info-icon" data-tooltip="基于近14天每日平均销量(日均流速), 计算3天安全备货量"></span></h2>
</div>
<div class="table-responsive">
<table class="data-table" id="advice-table">
<thead>
<tr>
<th>商品名称</th>
<th>14天总销量 <span class="info-icon info-icon-sm" data-tooltip="近14天该商品的订单明细数量合计 (件数)"></span></th>
<th>日均流速 <span class="info-icon info-icon-sm" data-tooltip="= 14天总销量 ÷ 14, 平均每天销售几件"></span></th>
<th>建议安全补货量 (3天量) <span class="info-icon info-icon-sm" data-tooltip="= CEIL(日均流速 × 3), 向上取整确保安全库存"></span></th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="text-center">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Page 5: 运维管理 -->
<div id="page-ops" class="page-section" style="display: none;">
<div class="dashboard-grid">
<div class="glass-card chart-container">
<div class="chart-header">
<h2>数据同步 & 洞察计算 手动触发</h2>
</div>
<p style="color: #86868b; margin: 0 0 1.5rem 0; font-size: 0.9rem;">
以下操作与每日定时任务相同,点击后将实时执行并返回结果。执行耗时视数据量而定,请耐心等待。
</p>
<!-- 日期选择器: 订单/退款同步可选起始日期 -->
<div class="ops-date-row">
<label for="ops-from-date" class="ops-date-label">指定起始日期</label>
<input type="date" id="ops-from-date" class="ops-date-input" />
<span class="ops-date-hint">留空 = 自动检测缺口 (智能模式)</span>
</div>
<div class="ops-grid">
<button class="ops-btn" onclick="triggerOps('sync-orders', this)">
<span class="ops-icon">📦</span>
<span class="ops-label">订单同步</span>
<span class="ops-desc">智能增量 / 指定日期</span>
</button>
<button class="ops-btn" onclick="triggerOps('sync-refunds', this)">
<span class="ops-icon">💰</span>
<span class="ops-label">退款同步</span>
<span class="ops-desc">智能增量 / 指定日期</span>
</button>
<button class="ops-btn" onclick="triggerOps('sync-items', this)">
<span class="ops-icon">🍱</span>
<span class="ops-label">商品同步</span>
<span class="ops-desc">全量商品目录</span>
</button>
<button class="ops-btn" onclick="triggerOps('sync-stores', this)">
<span class="ops-icon">🏪</span>
<span class="ops-label">门店同步</span>
<span class="ops-desc">门店与网点</span>
</button>
<button class="ops-btn" onclick="triggerOps('sync-inventory', this)">
<span class="ops-icon">📊</span>
<span class="ops-label">库存快照</span>
<span class="ops-desc">各门店库存</span>
</button>
<button class="ops-btn" onclick="triggerOps('sync-customers', this)">
<span class="ops-icon">👥</span>
<span class="ops-label">客户聚合</span>
<span class="ops-desc">订单驱动聚合</span>
</button>
<button class="ops-btn ops-btn-accent" onclick="triggerOps('compute-insights', this)">
<span class="ops-icon">🧠</span>
<span class="ops-label">洞察计算</span>
<span class="ops-desc">RFM+复购+购物篮</span>
</button>
</div>
<div class="ops-log-container">
<h3 style="margin: 0 0 0.5rem 0;">执行日志</h3>
<div id="ops-log" class="ops-log"><span class="log-entry log-placeholder"><span class="log-time"></span>等待操作...</span></div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="app.js"></script>
</body>
</html>

723
dashboard-ui/styles.css Normal file
View File

@@ -0,0 +1,723 @@
/* Modern Apple-like Tech Aesthetic Variables */
:root {
--bg-base: #fbfbfd;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--accent-blue: #0071e3;
--accent-green: #34c759;
--accent-red: #ff3b30;
--glass-bg: rgba(255, 255, 255, 0.7);
--glass-border: rgba(255, 255, 255, 0.5);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.04);
--transition-smooth: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
}
/* Dark Mode Query Support */
@media (prefers-color-scheme: dark) {
:root {
--bg-base: #000000;
--text-primary: #f5f5f7;
--text-secondary: #86868b;
--accent-blue: #2997ff;
--glass-bg: rgba(28, 28, 30, 0.6);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
body {
background: radial-gradient(circle at top right, #1d1d27, #000000) !important;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
background: var(--bg-base);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
/* Soft mesh gradient background for premium feel */
background: radial-gradient(circle at top right, #e2eafc, #fbfbfd 60%);
}
.app-container {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 240px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border);
padding: 2rem 0;
display: flex;
flex-direction: column;
z-index: 10;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
font-weight: 600;
font-size: 1.1rem;
letter-spacing: -0.5px;
margin-bottom: 2.5rem;
color: var(--text-primary);
}
.nav-menu {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 12px;
}
.nav-item {
text-decoration: none;
color: var(--text-secondary);
padding: 10px 16px;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
transition: var(--transition-smooth);
}
.nav-item:hover {
background: rgba(134, 134, 139, 0.1);
color: var(--text-primary);
transform: translateX(4px);
}
.nav-item.active {
background: var(--text-primary);
color: var(--bg-base);
}
/* Main Content */
.main-content {
flex: 1;
overflow-y: auto;
padding: 2rem 3rem;
}
.top-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
/* Page title row: title + cutoff badge inline */
.page-title-row {
display: flex;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
}
.page-title-row h1 {
margin: 0;
}
/* Data cutoff date badge */
.data-cutoff-badge {
font-size: 0.78rem;
font-weight: 500;
color: var(--text-secondary);
background: rgba(0, 113, 227, 0.06);
border: 1px solid rgba(0, 113, 227, 0.12);
border-radius: 20px;
padding: 4px 14px;
white-space: nowrap;
line-height: 1.4;
}
/* ============== Info Icon Tooltip ============== */
.info-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 0.72rem;
font-style: normal;
color: var(--text-secondary);
background: rgba(134, 134, 139, 0.08);
border: 1px solid rgba(134, 134, 139, 0.15);
border-radius: 50%;
cursor: help;
position: relative;
vertical-align: middle;
margin-left: 4px;
transition: all 0.2s;
}
.info-icon:hover {
color: var(--accent-blue);
background: rgba(0, 113, 227, 0.08);
border-color: rgba(0, 113, 227, 0.3);
}
/* Smaller variant for table headers */
.info-icon-sm {
width: 15px;
height: 15px;
font-size: 0.65rem;
}
/* Tooltip popup */
.info-icon::after {
content: attr(data-tooltip);
position: absolute;
bottom: 130%;
left: 50%;
transform: translateX(-50%);
background: rgba(28, 28, 30, 0.92);
color: #f5f5f7;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.5;
padding: 8px 12px;
border-radius: 10px;
width: max-content;
max-width: 320px;
white-space: normal;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 100;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(12px);
}
/* Tooltip arrow */
.info-icon::before {
content: '';
position: absolute;
bottom: 120%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: rgba(28, 28, 30, 0.92);
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 101;
}
.info-icon:hover::after,
.info-icon:hover::before {
opacity: 1;
visibility: visible;
}
@media (prefers-color-scheme: dark) {
.data-cutoff-badge {
background: rgba(0, 113, 227, 0.1);
border-color: rgba(0, 113, 227, 0.2);
}
.info-icon {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
}
.info-icon::after {
background: rgba(44, 44, 46, 0.95);
}
.info-icon::before {
border-top-color: rgba(44, 44, 46, 0.95);
}
}
}
.top-nav h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: -1px;
}
.header-actions {
display: flex;
align-items: center;
gap: 20px;
}
.btn-refresh {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--text-primary);
padding: 8px 16px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
backdrop-filter: blur(10px);
transition: var(--transition-smooth);
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.btn-refresh:hover {
background: var(--text-primary);
color: var(--bg-base);
transform: scale(1.05);
}
.user-profile img {
border-radius: 50%;
width: 36px;
height: 36px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
/* Glassmorphism Cards */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(25px);
-webkit-backdrop-filter: blur(25px);
border: 1px solid var(--glass-border);
border-radius: 24px;
box-shadow: var(--glass-shadow);
padding: 1.5rem;
transition: var(--transition-smooth);
}
.glass-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
}
/* Dashboard Grid */
.dashboard-grid {
display: flex;
flex-direction: column;
gap: 2rem;
}
.metrics-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
}
.metric-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.metric-card h3 {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-secondary);
}
.metric-card .value {
font-size: 2.2rem;
font-weight: 600;
letter-spacing: -1px;
}
.trend {
font-size: 0.8rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
}
.trend.positive { color: var(--accent-green); }
.trend.negative { color: var(--accent-red); }
.trend.neutral { color: var(--text-secondary); }
/* Chart Area */
.charts-row {
display: grid;
grid-template-columns: 1fr;
}
.chart-container {
padding: 2rem;
}
.chart-header h2 {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 20px;
}
/* Animations loading state */
.loading {
opacity: 0.5;
pointer-events: none;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 0.5; }
50% { opacity: 0.8; }
100% { opacity: 0.5; }
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.metrics-row .metric-card {
animation: fadeInUp 0.8s cubic-bezier(0.25, 1, 0.5, 1) forwards;
opacity: 0;
}
.metrics-row .metric-card:nth-child(1) { animation-delay: 0.1s; }
.metrics-row .metric-card:nth-child(2) { animation-delay: 0.2s; }
.metrics-row .metric-card:nth-child(3) { animation-delay: 0.3s; }
.metrics-row .metric-card:nth-child(4) { animation-delay: 0.4s; }
.chart-container {
animation: fadeInUp 0.8s cubic-bezier(0.25, 1, 0.5, 1) forwards;
animation-delay: 0.5s;
opacity: 0;
}
/* Tables for Data Grids */
.table-responsive {
width: 100%;
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin-top: 10px;
}
.data-table th, .data-table td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid var(--glass-border);
font-size: 0.9rem;
}
.data-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table tbody tr {
transition: background-color 0.2s;
}
.data-table tbody tr:hover {
background-color: rgba(134, 134, 139, 0.05); /* very subtle hover */
}
.text-center {
text-align: center !important;
}
/* Rank Badge styling for Repurchase / Matrix Tag */
.badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
display: inline-block;
}
.badge.primary { background: rgba(0, 113, 227, 0.1); color: var(--accent-blue); }
.badge.danger { background: rgba(255, 59, 48, 0.1); color: var(--accent-red); }
.badge.success { background: rgba(52, 199, 89, 0.1); color: var(--accent-green); }
.badge.warning { background: rgba(255, 149, 0, 0.1); color: #ff9500; }
/* ============== Ops Date Picker ============== */
.ops-date-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 1.25rem;
padding: 12px 16px;
border-radius: 12px;
background: rgba(0, 113, 227, 0.04);
border: 1px solid rgba(0, 113, 227, 0.1);
}
.ops-date-label {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.ops-date-input {
font-family: inherit;
font-size: 0.88rem;
padding: 6px 12px;
border: 1px solid var(--glass-border);
border-radius: 8px;
background: var(--glass-bg);
color: var(--text-primary);
outline: none;
transition: border-color 0.3s;
min-width: 160px;
}
.ops-date-input:focus {
border-color: var(--accent-blue);
box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.1);
}
.ops-date-hint {
font-size: 0.78rem;
color: var(--text-secondary);
}
@media (prefers-color-scheme: dark) {
.ops-date-row {
background: rgba(0, 113, 227, 0.06);
border-color: rgba(0, 113, 227, 0.15);
}
.ops-date-input {
background: rgba(28, 28, 30, 0.6);
border-color: rgba(255, 255, 255, 0.1);
}
}
/* ============== Ops Management Page ============== */
.ops-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.25rem;
margin-bottom: 2rem;
}
@media (max-width: 1200px) {
.ops-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 800px) {
.ops-grid { grid-template-columns: repeat(2, 1fr); }
}
.ops-btn {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 2rem 1.25rem;
border: none;
border-radius: 20px;
background: linear-gradient(135deg, rgba(255,255,255,0.85) 0%, rgba(240,240,245,0.7) 100%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
cursor: pointer;
transition: all 0.4s cubic-bezier(0.25, 1, 0.5, 1);
color: var(--text-primary);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.04),
0 8px 24px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
overflow: hidden;
}
.ops-btn::before {
content: '';
position: absolute;
inset: 0;
border-radius: 20px;
padding: 1px;
background: linear-gradient(135deg, rgba(255,255,255,0.6), rgba(255,255,255,0.1));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.ops-btn:hover {
transform: translateY(-6px) scale(1.02);
box-shadow:
0 8px 20px rgba(0, 0, 0, 0.08),
0 20px 40px rgba(0, 113, 227, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.ops-btn:active {
transform: translateY(-2px) scale(0.98);
transition-duration: 0.1s;
}
.ops-btn-accent {
background: linear-gradient(135deg, rgba(0, 113, 227, 0.08) 0%, rgba(88, 86, 214, 0.06) 100%);
box-shadow:
0 2px 8px rgba(0, 113, 227, 0.08),
0 8px 24px rgba(0, 113, 227, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.ops-btn-accent:hover {
box-shadow:
0 8px 20px rgba(0, 113, 227, 0.15),
0 20px 40px rgba(88, 86, 214, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
@media (prefers-color-scheme: dark) {
.ops-btn {
background: linear-gradient(135deg, rgba(44, 44, 46, 0.8) 0%, rgba(28, 28, 30, 0.7) 100%);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.2),
0 8px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.ops-btn::before {
background: linear-gradient(135deg, rgba(255,255,255,0.1), rgba(255,255,255,0.02));
}
.ops-btn:hover {
box-shadow:
0 8px 20px rgba(0, 0, 0, 0.3),
0 20px 40px rgba(0, 113, 227, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.ops-btn-accent {
background: linear-gradient(135deg, rgba(0, 113, 227, 0.15) 0%, rgba(88, 86, 214, 0.1) 100%);
}
}
.ops-icon {
font-size: 2.5rem;
line-height: 1;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
}
.ops-label {
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.3px;
}
.ops-desc {
font-size: 0.78rem;
color: var(--text-secondary);
font-weight: 400;
}
/* Running state - pulsing glow */
.ops-running {
pointer-events: none;
animation: opsPulse 1.5s ease-in-out infinite;
}
@keyframes opsPulse {
0%, 100% {
opacity: 0.6;
box-shadow: 0 0 0 0 rgba(0, 113, 227, 0);
}
50% {
opacity: 1;
box-shadow: 0 0 30px rgba(0, 113, 227, 0.2);
}
}
/* Success state - green glow */
.ops-done {
background: linear-gradient(135deg, rgba(52, 199, 89, 0.1) 0%, rgba(48, 209, 88, 0.06) 100%) !important;
box-shadow: 0 0 0 2px rgba(52, 199, 89, 0.4), 0 8px 24px rgba(52, 199, 89, 0.15) !important;
}
/* Error state - red glow */
.ops-error {
background: linear-gradient(135deg, rgba(255, 59, 48, 0.1) 0%, rgba(255, 69, 58, 0.06) 100%) !important;
box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.4), 0 8px 24px rgba(255, 59, 48, 0.15) !important;
}
/* Log panel — terminal aesthetic */
.ops-log-container {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--glass-border);
}
.ops-log-container h3 {
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.3px;
}
.ops-log {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Menlo', 'Consolas', monospace;
font-size: 0.82rem;
line-height: 2;
background: rgba(0, 0, 0, 0.04);
border-radius: 16px;
padding: 1.25rem 1.5rem;
min-height: 80px;
max-height: 300px;
overflow-y: auto;
color: var(--text-secondary);
border: 1px solid var(--glass-border);
}
@media (prefers-color-scheme: dark) {
.ops-log {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
}
}
.ops-log .log-entry {
display: block;
padding: 4px 0;
border-bottom: 1px solid rgba(134, 134, 139, 0.08);
animation: logFadeIn 0.3s ease;
}
.ops-log .log-entry:last-child {
border-bottom: none;
}
.ops-log .log-entry .log-time {
color: var(--text-secondary);
opacity: 0.6;
margin-right: 8px;
}
.ops-log .log-entry.log-success {
color: var(--accent-green);
}
.ops-log .log-entry.log-error {
color: var(--accent-red);
}
.ops-log .log-entry.log-pending {
color: var(--accent-blue);
}
@keyframes logFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}