diff --git a/.gitignore b/.gitignore
index cf17557..a282919 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@
!/README.md
!/server/
!/sql/
+!/dashboard-ui/
# 3. 针对 docs 目录:先豁免目录本身,忽略里面所有内容,然后再单一豁免目标子目录
!/docs/
diff --git a/dashboard-ui/app.js b/dashboard-ui/app.js
new file mode 100644
index 0000000..6ec92bd
--- /dev/null
+++ b/dashboard-ui/app.js
@@ -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 = '
| 暂无数据 |
';
+ 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 = `
+ ${item.item_name || ('商品 '+item.item_id)} |
+ ${item.purchaser_count_30d} 人 |
+ ${item.repurchaser_count_30d} 人 |
+ ${formatPercent(item.repurchase_rate_30d)} |
+ ${item.matrix_tag || '-'} |
+ `;
+ 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 = '| 暂无数据 |
';
+ return;
+ }
+
+ data.forEach(item => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `
+ ${item.item_name_a} |
+ ${item.item_name_b} |
+ ${item.pair_order_count} 笔 |
+ ${formatPercent(item.confidence)} |
+ `;
+ 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 = `
+ ${item.rfm_group} ⓘ
+ ${item.customer_count} 人
+ 营收贡献: ${formatCurrency(item.total_revenue_contribution)}
+ `;
+ 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 = '| 近14天数据不足,无法生成建议 |
';
+ return;
+ }
+
+ data.forEach(item => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `
+ ${item.item_name || item.item_id} |
+ ${item.sum_14d} 件 |
+ ${parseFloat(item.avg_daily_speed).toFixed(2)} 件/天 |
+ 备货 ${item.advice_stock_qty} 件 |
+ `;
+ 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 = `${params.name}
`;
+ html += `人数: ${params.value} 人 (${params.percent}%)`;
+ if (def) {
+ html += `
定义: ${def}`;
+ }
+ 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 = `${time}${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);
+ }
+}
diff --git a/dashboard-ui/index.html b/dashboard-ui/index.html
new file mode 100644
index 0000000..1348c8a
--- /dev/null
+++ b/dashboard-ui/index.html
@@ -0,0 +1,244 @@
+
+
+
+
+
+ 餐饮零售数据中台 | 核心引擎
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
经营数据大盘概览
+ 计算数据截止至 —
+
+
+
+
+
+
+
+
+
+
+
过去14天总营收 ⓘ
+
¥0.00
+
↑ 实时
+
+
+
过去14天订单数 ⓘ
+
0
+
↑ 实时
+
+
+
平均客单价 (ATV) ⓘ
+
¥0.00
+
稳定
+
+
+
过去14天退款额 ⓘ
+
¥0.00
+
↓ 风控监测中
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 商品名称 |
+ 30天购买人数 ⓘ |
+ 30天复购人数 ⓘ |
+ 复购率 ⓘ |
+ 波士顿矩阵打标 ⓘ |
+
+
+
+ | 加载中... |
+
+
+
+
+
+
+
+
+
+
+
+ | 核心商品 A |
+ 连带商品 B |
+ 共同出现订单数 ⓘ |
+ 搭配置信度 ⓘ |
+
+
+
+ | 加载中... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 商品名称 |
+ 14天总销量 ⓘ |
+ 日均流速 ⓘ |
+ 建议安全补货量 (3天量) ⓘ |
+
+
+
+ | 加载中... |
+
+
+
+
+
+
+
+
+
+
+
+
+ 以下操作与每日定时任务相同,点击后将实时执行并返回结果。执行耗时视数据量而定,请耐心等待。
+
+
+
+
+
+ 留空 = 自动检测缺口 (智能模式)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard-ui/styles.css b/dashboard-ui/styles.css
new file mode 100644
index 0000000..d365fb6
--- /dev/null
+++ b/dashboard-ui/styles.css
@@ -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); }
+}