PushToZhaoShang/frontend/index.html
2025-12-07 21:04:24 +08:00

603 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>IMC益选便利店营业额数据看板</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-zoom/2.2.0/chartjs-plugin-zoom.min.js"></script>
<style>
:root {
--primary: #22d3ee; /* cyan-400 */
--primary-dim: rgba(34, 211, 238, 0.1);
--bg-dark: #0b1121;
}
body { background-color: var(--bg-dark); color: #e2e8f0; font-family: 'Inter', system-ui, sans-serif; }
/* Tech Background Grid - Toned down */
.bg-tech-grid {
background-size: 40px 40px;
background-image:
linear-gradient(to right, rgba(34, 211, 238, 0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(34, 211, 238, 0.03) 1px, transparent 1px);
mask-image: radial-gradient(circle at center, black 30%, transparent 100%);
}
/* Glass Tech Panel - Toned down */
.glass-panel {
background: rgba(15, 23, 42, 0.85); /* More opaque for readability */
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1); /* Subtle white border */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
/* Simplified Hover */
.glass-panel:hover {
border-color: rgba(34, 211, 238, 0.3);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
}
/* Typography & Effects - Cleaner */
.text-glow { text-shadow: 0 0 5px rgba(34, 211, 238, 0.3); }
.font-mono-tech { font-family: 'JetBrains Mono', 'Consolas', monospace; }
.btn-tech {
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(148, 163, 184, 0.2);
color: #94a3b8;
transition: all 0.2s ease;
}
.btn-tech:hover {
background: rgba(34, 211, 238, 0.1);
border-color: var(--primary);
color: var(--primary);
}
.btn-tech.active {
background: var(--primary);
color: #0f172a;
border-color: var(--primary);
font-weight: 600;
}
.chip-tech {
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(148, 163, 184, 0.1);
color: #94a3b8;
font-family: monospace;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #0b1121; }
::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; border: 1px solid #334155; }
::-webkit-scrollbar-thumb:hover { background: var(--primary); }
</style>
</head>
<body class="antialiased min-h-screen bg-slate-900 selection:bg-cyan-500 selection:text-white overflow-x-hidden">
<!-- Tech Background Layers -->
<div class="fixed inset-0 bg-[url('https://tailwindcss.com/img/beams-home@95.jpg')] bg-cover bg-center opacity-20 pointer-events-none"></div>
<div class="fixed inset-0 bg-tech-grid pointer-events-none z-0"></div>
<div class="fixed inset-0 bg-gradient-to-b from-slate-900/50 via-slate-900/20 to-slate-900/80 pointer-events-none z-0"></div>
<div id="app" class="relative z-10 max-w-5xl mx-auto p-3 sm:p-4">
<header class="sticky top-2 z-50 mb-4 text-center">
<div class="glass-panel rounded-lg px-4 py-3 flex flex-col items-center justify-center gap-2">
<div class="w-full flex flex-col items-center gap-1">
<div class="w-full text-center">
<h1 class="text-xl sm:text-2xl font-bold tracking-tight text-white text-glow">
IMC 益选便利店
</h1>
<div class="inline-flex items-center justify-center gap-2 text-[10px] sm:text-xs text-slate-400 font-mono mt-1 leading-none mx-auto">
<span class="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse"></span>
<span>实时监控中</span>
<span class="text-slate-500">|</span>
<span id="last-updated">LAST UPDATE: -</span>
</div>
</div>
</div>
</div>
</header>
<section class="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 mb-4">
<!-- Card Template -->
<div class="glass-panel p-3 sm:p-4 rounded-lg group hover:bg-slate-800/50 transition-colors" aria-label="今日数据">
<div class="flex justify-between items-center mb-2">
<span class="text-slate-400 text-xs font-bold tracking-wider">今日营业额</span>
<div class="flex gap-1">
<span id="today-date" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
<span id="today-weekday" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
</div>
</div>
<div id="today" class="text-xl sm:text-2xl font-mono-tech font-bold text-cyan-400 text-glow tracking-tight">Loading...</div>
</div>
<div class="glass-panel p-3 sm:p-4 rounded-lg group hover:bg-slate-800/50 transition-colors">
<div class="flex justify-between items-center mb-2">
<span class="text-slate-400 text-xs font-bold tracking-wider">昨日营业额</span>
<div class="flex gap-1">
<span id="yday-date" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
<span id="yday-weekday" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
</div>
</div>
<div id="yday" class="text-xl sm:text-2xl font-mono-tech font-semibold text-slate-300 tracking-tight">-</div>
</div>
<div class="glass-panel p-3 sm:p-4 rounded-lg group hover:bg-slate-800/50 transition-colors">
<div class="flex justify-between items-center mb-2">
<span class="text-slate-400 text-xs font-bold tracking-wider">前日营业额</span>
<div class="flex gap-1">
<span id="day-before-date" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
<span id="day-before-weekday" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
</div>
</div>
<div id="day-before" class="text-xl sm:text-2xl font-mono-tech font-semibold text-slate-400 tracking-tight">-</div>
</div>
<div class="glass-panel p-3 sm:p-4 rounded-lg group hover:bg-slate-800/50 transition-colors">
<div class="flex justify-between items-center mb-2">
<span class="text-slate-400 text-xs font-bold tracking-wider">本周累计</span>
<span id="this-week-range" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
</div>
<div id="this-week" class="text-xl sm:text-2xl font-mono-tech font-bold text-white tracking-tight">-</div>
</div>
<div class="glass-panel p-3 sm:p-4 rounded-lg group hover:bg-slate-800/50 transition-colors">
<div class="flex justify-between items-center mb-2">
<span class="text-slate-400 text-xs font-bold tracking-wider">上周累计</span>
<span id="last-week-range" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
</div>
<div id="last-week" class="text-xl sm:text-2xl font-mono-tech font-semibold text-slate-300 tracking-tight">-</div>
</div>
<div class="glass-panel p-3 sm:p-4 rounded-lg group hover:bg-slate-800/50 transition-colors">
<div class="flex justify-between items-center mb-2">
<span class="text-slate-400 text-xs font-bold tracking-wider">本月累计</span>
<span id="this-month-range" class="chip-tech px-1.5 py-0.5 rounded text-[10px]">-</span>
</div>
<div id="this-month" class="text-xl sm:text-2xl font-mono-tech font-bold text-white tracking-tight">-</div>
</div>
</section>
<section class="glass-panel p-3 sm:p-4 rounded-lg mt-5 relative">
<div class="flex flex-col xl:flex-row items-center justify-between mb-4 gap-3 z-10 relative">
<div class="flex p-0.5 bg-slate-900/50 rounded border border-slate-700/50 backdrop-blur">
<button id="view-7" class="btn-tech px-2.5 py-1 text-xs rounded mr-0.5">最近7天</button>
<button id="view-this-month" class="btn-tech px-2.5 py-1 text-xs rounded mr-0.5">本月</button>
<button id="view-last-month" class="btn-tech px-2.5 py-1 text-xs rounded mr-0.5">上月</button>
<button id="view-90" class="btn-tech px-2.5 py-1 text-xs rounded">最近90天</button>
</div>
<div class="flex items-center gap-2">
<button id="export-img" class="btn-tech px-2.5 py-1 text-xs rounded flex items-center gap-1.5 group">
<span class="group-hover:animate-bounce">📷</span> 图片
</button>
<button id="export-csv" class="btn-tech px-2.5 py-1 text-xs rounded flex items-center gap-1.5 group">
<span class="group-hover:animate-bounce">📄</span> 表格
</button>
</div>
</div>
<div class="w-full relative" style="height:280px">
<canvas id="chart" aria-label="趋势分析图表"></canvas>
</div>
<div id="chart-msg" class="text-xs text-cyan-500/50 mt-2 font-mono text-center tracking-widest uppercase" aria-live="polite"></div>
</section>
</div>
<script>
const api = path => `/api/${path}`;
Chart.register(ChartDataLabels);
// Dark mode default configuration for Chart.js
Chart.defaults.color = '#94a3b8';
Chart.defaults.borderColor = 'rgba(34, 211, 238, 0.1)';
Chart.defaults.font.family = "'JetBrains Mono', 'Fira Code', 'Consolas', monospace";
let shopName = '益选便利店';
let seriesDates = [];
const isMobile = window.matchMedia('(max-width: 639px)').matches;
const maxTicks = isMobile ? 7 : 14;
let targetY = null;
let viewMode = 'recent7';
const weekendBackground = {
id: 'weekendBackground',
beforeDraw(chart, args, opts){
const dates = opts?.dates || [];
const {ctx, chartArea: {left, right, top, bottom}, scales: {x}} = chart;
if (!x || dates.length === 0) return;
ctx.save();
ctx.fillStyle = 'rgba(34, 211, 238, 0.05)'; // Tech cyan low opacity
for (let i = 0; i < dates.length; i++) {
const d = new Date(dates[i] + 'T00:00:00');
const wd = d.getDay();
if (wd === 0 || wd === 6) {
const x1 = x.getPixelForTick ? x.getPixelForTick(i) : x.getPixelForValue(i);
const x2 = (i < dates.length - 1)
? (x.getPixelForTick ? x.getPixelForTick(i+1) : x.getPixelForValue(i+1))
: right;
ctx.fillRect(x1, top, (x2 - x1), bottom - top);
}
}
ctx.restore();
}
};
Chart.register(weekendBackground);
function fmtYuan(n){ return '¥' + Number(n).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2}); }
function fmtRange(a,b){ const sa=a.slice(5), sb=b.slice(5); return `${sa}${sb}`; }
function weekdayStr(ds){
const map = ['周日','周一','周二','周三','周四','周五','周六'];
const d = new Date(ds + 'T00:00:00');
return map[d.getDay()];
}
function parseYuanText(t){
if (!t) return 0;
const s = String(t).replace(/[^0-9.\-]/g,'');
const n = parseFloat(s);
return Number.isFinite(n) ? n : 0;
}
function animateYuan(el, to, ms=600){
const fmt = v => '¥' + Number(v).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2});
const from = parseYuanText(el.textContent);
const start = performance.now();
// Ease out cubic
function step(ts){
const t = Math.min(1, (ts - start) / ms);
const e = 1 - Math.pow(1 - t, 3);
const v = from + (to - from) * e;
el.textContent = fmt(v);
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
async function loadMetrics(){
let m;
try {
document.getElementById('chart-msg').textContent = 'Loading data...';
const res = await fetch(api('metrics'));
m = await res.json();
} catch (e) {
document.getElementById('last-updated').textContent = 'Status: Offline';
return;
}
shopName = m.shop_name || shopName;
document.title = `IMC ${shopName} Dashboard`;
// Update H1 logic removed to preserve custom Tech header structure
if (chart?.options?.plugins?.title) {
chart.options.plugins.title.text = shopName;
chart.update('none');
}
if (m && typeof m.last_week?.total === 'number') {
targetY = m.last_week.total / 7;
}
document.getElementById('last-updated').textContent = `LAST UPDATE: ${m.server_now.slice(11,16)}`;
// Update data with animation
document.getElementById('today-date').textContent = m.today.date.slice(5);
document.getElementById('today-weekday').textContent = m.today.weekday;
if (m.today.amount == null) {
document.getElementById('today').textContent = '暂无数据';
document.getElementById('today').classList.add('text-slate-500');
} else {
document.getElementById('today').classList.remove('text-slate-500');
animateYuan(document.getElementById('today'), m.today.amount);
}
document.getElementById('yday-date').textContent = m.yesterday.date.slice(5);
document.getElementById('yday-weekday').textContent = weekdayStr(m.yesterday.date);
if (m.yesterday.amount == null) {
document.getElementById('yday').textContent = '暂无数据';
document.getElementById('yday').classList.add('text-slate-500');
} else {
document.getElementById('yday').classList.remove('text-slate-500');
animateYuan(document.getElementById('yday'), m.yesterday.amount);
}
document.getElementById('day-before-date').textContent = m.day_before.date.slice(5);
document.getElementById('day-before-weekday').textContent = weekdayStr(m.day_before.date);
if (m.day_before.amount == null) {
document.getElementById('day-before').textContent = '暂无数据';
document.getElementById('day-before').classList.add('text-slate-500');
} else {
document.getElementById('day-before').classList.remove('text-slate-500');
animateYuan(document.getElementById('day-before'), m.day_before.amount);
}
document.getElementById('this-week-range').textContent = fmtRange(m.this_week.start, m.this_week.end);
animateYuan(document.getElementById('this-week'), m.this_week.total);
document.getElementById('last-week-range').textContent = fmtRange(m.last_week.start, m.last_week.end);
animateYuan(document.getElementById('last-week'), m.last_week.total);
document.getElementById('this-month-range').textContent = fmtRange(m.this_month.start, m.this_month.end);
animateYuan(document.getElementById('this-month'), m.this_month.total);
}
document.getElementById('export-csv').addEventListener('click', () => location.href = api('export'));
document.getElementById('export-img').addEventListener('click', () => {
if (!chart) return;
const url = chart.toBase64Image('image/png', 1.0);
const a = document.createElement('a');
a.href = url;
const start = seriesDates[0];
const end = seriesDates[seriesDates.length - 1];
const fallback = viewMode === 'this_month' ? `${shopName}_本月.png` : (viewMode === 'last_month' ? `${shopName}_上月.png` : (viewMode === 'recent90' ? `${shopName}_最近90天.png` : `${shopName}_最近7天.png`));
const fname = start && end ? `${shopName}_${start}_${end}.png` : fallback;
a.download = fname;
a.click();
});
let chart;
async function loadSeries(){
try {
let s;
if (viewMode === 'recent7') {
const res = await fetch(api('series7') + `?days=7&v=${Date.now()}`);
s = await res.json();
} else {
const res = await fetch(api('series7') + `?days=90&v=${Date.now()}`);
const all = await res.json();
if (all.length === 0) {
s = all;
} else {
const endDateStr = all[all.length - 1].date;
const endDate = new Date(endDateStr + 'T00:00:00');
const endYear = endDate.getFullYear();
const endMonth = endDate.getMonth();
let y = endYear, m = endMonth;
if (viewMode === 'recent90') {
s = all;
} else {
if (viewMode === 'last_month') {
m = endMonth - 1; if (m < 0) { m = 11; y = endYear - 1; }
}
const monthStr = `${y}-${String(m + 1).padStart(2, '0')}`;
s = all.filter(d => d.date.startsWith(monthStr));
}
}
}
seriesDates = s.map(d => d.date);
if (!s.length) {
if (chart) { chart.destroy(); chart = null; }
document.getElementById('chart-msg').textContent = 'NO DATA AVAILABLE';
return;
}
const ctx = document.getElementById('chart').getContext('2d');
if (chart) chart.destroy();
const xPadding = isMobile ? 10 : 20;
// Create gradient
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, 'rgba(34, 211, 238, 0.5)'); // Cyan
gradient.addColorStop(1, 'rgba(34, 211, 238, 0.0)');
chart = new Chart(ctx, {
type: 'line',
data: {
labels: s.map(d => [d.date.slice(5), weekdayStr(d.date)]),
datasets: (() => {
const values = s.map(d => d.amount);
const avg = values.length ? (values.reduce((a,b)=>a+b,0) / values.length) : 0;
const maxVal = Math.max(...values);
const minVal = Math.min(...values);
const maxIdx = values.indexOf(maxVal);
const minIdx = values.indexOf(minVal);
const sets = [
{
label: '营业额',
data: values,
borderColor: '#22d3ee', // Cyan-400
borderWidth: 3,
backgroundColor: gradient,
tension: 0.4,
fill: true,
spanGaps: true,
pointBackgroundColor: '#0f172a',
pointBorderColor: '#22d3ee',
pointBorderWidth: 2,
pointRadius: isMobile ? 3 : 5,
pointHoverRadius: isMobile ? 5 : 7,
pointHoverBackgroundColor: '#22d3ee',
pointHoverBorderColor: '#fff'
},
{
label: '平均线',
data: values.map(() => avg),
borderColor: 'rgba(148, 163, 184, 0.5)', // Slate-400
borderDash: [5,5],
borderWidth: 1,
tension: 0,
fill: false,
pointRadius: 0
}
];
if (typeof targetY === 'number') {
sets.push({
label: '目标线',
data: values.map(() => targetY),
borderColor: '#fbbf24', // Amber-400
borderDash: [3,3],
borderWidth: 1,
tension: 0,
fill: false,
pointRadius: 0
});
}
return sets;
})()
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: 'index' },
animation: { duration: 800, easing: 'easeOutQuart' },
plugins: {
legend: { display: false },
title: { display: false },
datalabels: {
display: !isMobile,
align: 'top',
anchor: 'end',
offset: 8,
formatter: (v, ctx) => {
const values = ctx.chart.data.datasets[0].data;
const maxVal = Math.max(...values);
const minVal = Math.min(...values);
const isMax = v === maxVal;
const isMin = v === minVal;
// Only show max, min and every few points to avoid clutter
if (!isMax && !isMin) {
// Simple logic to show some labels but not all if crowded
if (values.length > 10 && ctx.dataIndex % 3 !== 0) return '';
}
const str = '¥' + Number(v).toLocaleString('zh-CN', {minimumFractionDigits: 0, maximumFractionDigits: 0});
return isMax ? '🔥 ' + str : (isMin ? '❄️ ' + str : str);
},
color: (ctx) => {
const v = ctx.dataset.data[ctx.dataIndex];
const values = ctx.chart.data.datasets[0].data;
if (v === Math.max(...values)) return '#fbbf24'; // Amber
if (v === Math.min(...values)) return '#94a3b8'; // Slate
return '#e2e8f0'; // Slate-200
},
font: { weight: 'bold', family: 'monospace' }
},
weekendBackground: { dates: s.map(d => d.date) },
tooltip: {
backgroundColor: 'rgba(15, 23, 42, 0.9)',
titleColor: '#e2e8f0',
bodyColor: '#22d3ee',
borderColor: 'rgba(34, 211, 238, 0.3)',
borderWidth: 1,
padding: 10,
displayColors: false,
callbacks: {
title: (items) => items[0].label,
label: (ctx) => {
const v = Number(ctx.parsed.y);
const vals = ctx.chart.data.datasets[0].data;
const i = ctx.dataIndex;
let base = '¥' + v.toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2});
if (i > 0 && Number.isFinite(vals[i-1])) {
const diff = v - vals[i-1];
const sign = diff >= 0 ? '▲' : '▼';
const diffStr = Number(Math.abs(diff)).toLocaleString('zh-CN', {minimumFractionDigits: 2});
return `${base} (${sign} ${diffStr})`;
}
return base;
}
}
},
zoom: {
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' },
pan: { enabled: true, mode: 'x' }
}
},
scales: {
x: {
grid: { color: 'rgba(148, 163, 184, 0.1)' },
ticks: { color: '#94a3b8', maxRotation: 0, padding: 10, font: {family:'monospace'} }
},
y: {
grid: { color: 'rgba(148, 163, 184, 0.1)' },
min: (() => {
const vals = s.map(d=>d.amount).filter(v => Number.isFinite(v));
if (!vals.length) return 0;
const m = Math.min(...vals);
return Math.max(0, m * 0.95);
})(),
ticks: {
color: '#64748b',
callback: v => '¥' + Number(v).toLocaleString('zh-CN', {compact: 'short'})
}
}
}
}
});
document.getElementById('chart-msg').textContent = '';
} catch (e) {
console.error('Chart Load Failed', e);
document.getElementById('chart-msg').textContent = 'Chart visualization error';
}
}
// SSE logic remains same
let es;
function startSSE(){
try {
es = new EventSource('/api/events');
es.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'tick' && msg.server_now) {
const tm = msg.server_now.slice(11,16);
document.getElementById('last-updated').textContent = `LAST UPDATE: ${tm}`;
} else if (msg.type === 'force_refresh') {
loadMetrics();
loadSeries();
}
} catch(_){}
};
es.onerror = () => {
if (es) es.close();
setInterval(() => { loadMetrics(); }, 60000);
setInterval(() => { loadSeries(); }, 120000);
};
} catch (e) {
setInterval(() => { loadMetrics(); }, 60000);
setInterval(() => { loadSeries(); }, 120000);
}
}
loadMetrics();
loadSeries();
startSSE();
const b7 = document.getElementById('view-7');
const bm = document.getElementById('view-this-month');
const blm = document.getElementById('view-last-month');
const b90 = document.getElementById('view-90');
function updateTabs() {
const allBtns = [b7, bm, blm, b90];
allBtns.forEach(b => {
if(!b) return;
b.classList.remove('active');
b.setAttribute('aria-pressed', 'false');
});
let target;
if(viewMode === 'recent7') target = b7;
else if(viewMode === 'this_month') target = bm;
else if(viewMode === 'last_month') target = blm;
else if(viewMode === 'recent90') target = b90;
if(target) {
target.classList.add('active');
target.setAttribute('aria-pressed', 'true');
}
}
if(b7) b7.addEventListener('click', () => { viewMode='recent7'; updateTabs(); loadSeries(); });
if(bm) bm.addEventListener('click', () => { viewMode='this_month'; updateTabs(); loadSeries(); });
if(blm) blm.addEventListener('click', () => { viewMode='last_month'; updateTabs(); loadSeries(); });
if(b90) b90.addEventListener('click', () => { viewMode='recent90'; updateTabs(); loadSeries(); });
updateTabs();
</script>
</body>
</html>