推送给招商
This commit is contained in:
@@ -0,0 +1,602 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user