添加独立的前端页面目录 dashboard-ui 到代码仓库
This commit is contained in:
493
dashboard-ui/app.js
Normal file
493
dashboard-ui/app.js
Normal 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
244
dashboard-ui/index.html
Normal 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
723
dashboard-ui/styles.css
Normal 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); }
|
||||
}
|
||||
Reference in New Issue
Block a user