banber / stats.html
486CHD's picture
Upload 13 files
db8feb0 verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Banana Pro - &#x7AD9;&#x70B9;&#x7EDF;&#x8BA1;</title>
<link rel="stylesheet" href="style.css">
<style>
body.stats-page {
background:
radial-gradient(700px 360px at 12% -10%, rgba(59, 130, 246, 0.18), transparent 60%),
radial-gradient(700px 420px at 88% -20%, rgba(34, 211, 238, 0.2), transparent 55%),
var(--bg-color);
min-height: 100vh;
}
.stats-shell {
max-width: 1200px;
margin: 0 auto;
padding: 120px 20px 80px;
width: 100%;
position: relative;
z-index: 0;
}
.stats-shell::before {
content: '';
position: absolute;
inset: 60px 0 0;
background:
radial-gradient(480px 280px at 15% 0%, rgba(59, 130, 246, 0.16), transparent 70%),
radial-gradient(520px 320px at 85% 10%, rgba(34, 211, 238, 0.12), transparent 70%);
opacity: 0.75;
pointer-events: none;
z-index: 0;
}
.stats-shell > * {
position: relative;
z-index: 1;
}
.stats-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding-bottom: 12px;
border-bottom: 1px solid var(--panel-border);
}
.stats-title {
margin: 0;
font-size: 1.6rem;
letter-spacing: 0.6px;
background: linear-gradient(135deg, var(--text-main) 0%, var(--text-sub) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stats-meta {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-top: 6px;
color: var(--text-sub);
font-size: 12px;
}
.stats-meta span {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.55);
border: 1px solid rgba(148, 163, 184, 0.2);
}
.stats-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 18px;
margin-top: 24px;
}
.stats-card {
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 18px;
padding: 16px 18px;
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.25);
position: relative;
overflow: hidden;
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
}
.stats-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, rgba(59, 130, 246, 0.9), rgba(34, 211, 238, 0.9));
opacity: 0.75;
}
.stats-card:hover {
transform: translateY(-4px);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.35);
border-color: var(--panel-border-strong);
}
.stats-label {
color: var(--text-sub);
font-size: 11px;
letter-spacing: 1px;
text-transform: uppercase;
}
.stats-value {
font-size: 2rem;
font-weight: 700;
margin-top: 10px;
color: var(--text-main);
letter-spacing: 0.5px;
}
.stats-sub {
margin-top: 8px;
font-size: 12px;
color: var(--text-sub);
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.stats-panel {
margin-top: 20px;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.7), rgba(15, 23, 42, 0.95));
border: 1px solid var(--panel-border);
border-radius: 20px;
padding: 18px 20px;
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.25);
}
.panel-title {
font-size: 0.95rem;
color: var(--text-main);
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.panel-title::before {
content: '';
width: 10px;
height: 10px;
border-radius: 999px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(34, 211, 238, 0.9));
box-shadow: 0 0 10px rgba(34, 211, 238, 0.45);
}
.bars {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 10px;
height: 190px;
align-items: end;
padding: 12px;
border-radius: 16px;
background: rgba(15, 23, 42, 0.35);
border: 1px solid rgba(148, 163, 184, 0.18);
}
.stats-legend {
margin-top: 10px;
display: flex;
gap: 12px;
flex-wrap: wrap;
color: var(--text-sub);
font-size: 12px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: linear-gradient(180deg, var(--accent-color), var(--accent-secondary));
box-shadow: 0 0 6px rgba(59, 130, 246, 0.45);
}
.legend-dot.legend-share {
background: linear-gradient(180deg, #22c55e, #16a34a);
box-shadow: 0 0 6px rgba(34, 197, 94, 0.45);
}
.bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
height: 100%;
}
.bar-group {
width: 100%;
flex: 1;
display: flex;
align-items: flex-end;
gap: 8px;
}
.bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
height: 100%;
}
.bar-fill {
width: 100%;
border-radius: 10px 10px 6px 6px;
background: linear-gradient(180deg, var(--accent-color), var(--accent-secondary));
min-height: 10px;
transition: height 0.4s ease;
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.3);
position: relative;
overflow: hidden;
}
.bar-fill::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.25), transparent 65%);
opacity: 0.55;
}
.bar-fill.share {
background: linear-gradient(180deg, #22c55e, #16a34a);
box-shadow: 0 8px 20px rgba(34, 197, 94, 0.3);
}
.bar-label {
font-size: 11px;
color: var(--text-sub);
}
.bar-series {
font-size: 10px;
color: var(--text-sub);
}
.bar-value {
font-size: 11px;
color: var(--text-main);
}
.panel-body {
margin-top: 12px;
color: var(--text-sub);
font-size: 13px;
line-height: 1.5;
}
.empty-muted {
color: rgba(148, 163, 184, 0.7);
}
@media (max-width: 768px) {
.stats-shell {
padding: 100px 16px 60px;
}
.stats-title {
font-size: 1.35rem;
}
.bars {
height: 140px;
}
}
</style>
</head>
<body class="stats-page">
<div id="login-overlay">
<div class="login-card glass-panel">
<h1 style="font-size: 2rem;">banana绘画网页项目</h1>
<h3 style="margin: 10px 0; color: #cbd5e1;">Banana Pro</h3>
<input type="password" id="pwd" placeholder="Password" onkeypress="if(event.key==='Enter')doLogin()">
<button class="btn-3d" onclick="doLogin()" style="width: 100%; padding: 10px;">Unlock</button>
</div>
</div>
<div class="app-container stats-shell" id="stats-app" style="filter: blur(10px); pointer-events: none;">
<div class="stats-header">
<div>
<h1 class="stats-title">&#x7AD9;&#x70B9;&#x8C03;&#x7528;&#x7EDF;&#x8BA1;</h1>
<div class="stats-meta">
<span id="stat-updated">&#x66F4;&#x65B0;&#x65F6;&#x95F4;: --</span>
<span id="stat-uptime">&#x8FD0;&#x884C;&#x65F6;&#x957F;: --</span>
</div>
</div>
<div class="stats-actions">
<a class="prompt-link" href="index.html">&#x8FD4;&#x56DE;&#x521B;&#x610F;&#x753B;&#x677F;</a>
<button class="icon-btn" id="refresh-stats">&#x5237;&#x65B0;</button>
</div>
</div>
<section class="stats-grid">
<div class="stats-card">
<div class="stats-label">&#x603B;&#x8C03;&#x7528;</div>
<div class="stats-value" id="stat-total">--</div>
<div class="stats-sub">
<span>&#x6210;&#x529F;: <span id="stat-success">--</span></span>
<span>&#x5931;&#x8D25;: <span id="stat-failure">--</span></span>
</div>
</div>
<div class="stats-card">
<div class="stats-label">&#x6210;&#x529F;&#x7387;</div>
<div class="stats-value" id="stat-rate">--</div>
<div class="stats-sub" id="stat-last-success">--</div>
</div>
<div class="stats-card">
<div class="stats-label">&#x5E73;&#x5747;&#x8017;&#x65F6;</div>
<div class="stats-value" id="stat-latency">--</div>
<div class="stats-sub" id="stat-latency-count">--</div>
</div>
<div class="stats-card">
<div class="stats-label">&#x8FDB;&#x884C;&#x4E2D;</div>
<div class="stats-value" id="stat-inflight">--</div>
<div class="stats-sub" id="stat-last">--</div>
</div>
<div class="stats-card">
<div class="stats-label">&#x516C;&#x5171;&#x753B;&#x5ECA;&#x53D1;&#x5E03;</div>
<div class="stats-value" id="stat-share-total">--</div>
<div class="stats-sub">
<span>&#x6210;&#x529F;: <span id="stat-share-success">--</span></span>
<span>&#x5931;&#x8D25;: <span id="stat-share-failure">--</span></span>
</div>
<div class="stats-sub" id="stat-share-last">--</div>
</div>
</section>
<section class="stats-panel">
<div class="panel-title">&#x6700;&#x8FD1; 7 &#x5929;&#x8C03;&#x7528;&#x91CF;</div>
<div class="stats-legend">
<span class="legend-item"><span class="legend-dot legend-total"></span>&#x8C03;&#x7528;</span>
<span class="legend-item"><span class="legend-dot legend-share"></span>&#x53D1;&#x5E03;</span>
</div>
<div class="bars" id="stats-bars"></div>
</section>
<section class="stats-panel">
<div class="panel-title">&#x6700;&#x8FD1;&#x9519;&#x8BEF;</div>
<div class="panel-body empty-muted" id="stat-last-error">--</div>
</section>
</div>
<script>
const API_BASE_STORAGE_KEY = 'BananaPro_ApiBase';
const resolveApiBase = () => {
const params = new URLSearchParams(window.location.search);
const paramBase = params.get('api');
if (paramBase) {
localStorage.setItem(API_BASE_STORAGE_KEY, paramBase);
}
const stored = localStorage.getItem(API_BASE_STORAGE_KEY);
const base = (paramBase || stored || '').trim();
return base.endsWith('/') ? base.slice(0, -1) : base;
};
const API_BASE = resolveApiBase();
const apiFetch = (path, options) => {
if (/^https?:\/\//i.test(path)) {
return fetch(path, options);
}
const base = API_BASE;
const url = base ? `${base}${path}` : path;
return fetch(url, options);
};
const Auth = {
async check() {
try {
const res = await apiFetch('/api/check-auth');
const data = await res.json();
return data.authenticated;
} catch {
return false;
}
},
async login(password) {
try {
const res = await apiFetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
let data = {};
try {
data = await res.json();
} catch {
data = {};
}
if (!res.ok) {
return { success: false, message: data.message || '登录失败' };
}
return { success: Boolean(data.success), message: data.message || '' };
} catch (error) {
return { success: false, message: '无法连接到登录接口,请确认后端服务已启动' };
}
}, unlock() {
document.getElementById('login-overlay').style.display = 'none';
const app = document.getElementById('stats-app');
app.style.filter = 'none';
app.style.pointerEvents = 'all';
}
};
const StatsPage = {
timer: null,
loading: false,
init() {
const refreshBtn = document.getElementById('refresh-stats');
if (refreshBtn) {
refreshBtn.onclick = () => this.fetchStats();
}
},
start() {
this.fetchStats();
this.timer = setInterval(() => this.fetchStats(), 15000);
},
async fetchStats() {
if (this.loading) return;
this.loading = true;
try {
const res = await apiFetch('/api/stats');
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || 'Stats unavailable');
}
this.render(data.stats);
} catch (error) {
this.setError(error.message || 'Stats error');
} finally {
this.loading = false;
}
},
setError(message) {
const errorEl = document.getElementById('stat-last-error');
if (errorEl) {
errorEl.textContent = message;
errorEl.classList.remove('empty-muted');
}
},
formatNumber(value) {
return typeof value === 'number' && Number.isFinite(value)
? value.toLocaleString()
: '--';
},
formatTime(value) {
if (!value) return '--';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '--' : date.toLocaleString();
},
formatUptime(seconds) {
if (!Number.isFinite(seconds)) return '--';
const total = Math.max(0, Math.floor(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
return `${hours}h ${minutes}m`;
},
formatLatency(ms) {
if (!Number.isFinite(ms)) return '--';
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(2)} s`;
},
render(stats) {
const total = stats.totalRequests || 0;
const success = stats.successCount || 0;
const failure = stats.failureCount || 0;
const rate = total > 0 ? Math.round((success / total) * 100) : 0;
const shareTotal = stats.shareRequests || 0;
const shareSuccess = stats.shareSuccess || 0;
const shareFailure = stats.shareFailure || 0;
document.getElementById('stat-total').textContent = this.formatNumber(total);
document.getElementById('stat-success').textContent = this.formatNumber(success);
document.getElementById('stat-failure').textContent = this.formatNumber(failure);
document.getElementById('stat-rate').textContent = `${rate}%`;
document.getElementById('stat-latency').textContent = this.formatLatency(stats.avgLatencyMs);
document.getElementById('stat-latency-count').textContent = `samples: ${this.formatNumber(stats.latencyCount)}`;
document.getElementById('stat-inflight').textContent = this.formatNumber(stats.activeRequests || 0);
document.getElementById('stat-last').textContent = `\u6700\u8fd1\u8c03\u7528: ${this.formatTime(stats.lastRequestAt)}`;
document.getElementById('stat-last-success').textContent = `\u6700\u8fd1\u6210\u529f: ${this.formatTime(stats.lastSuccessAt)}`;
document.getElementById('stat-updated').textContent = `\u66f4\u65b0\u65f6\u95f4: ${this.formatTime(new Date().toISOString())}`;
document.getElementById('stat-uptime').textContent = `\u8fd0\u884c\u65f6\u957f: ${this.formatUptime(stats.uptimeSec)}`;
document.getElementById('stat-share-total').textContent = this.formatNumber(shareTotal);
document.getElementById('stat-share-success').textContent = this.formatNumber(shareSuccess);
document.getElementById('stat-share-failure').textContent = this.formatNumber(shareFailure);
document.getElementById('stat-share-last').textContent = `\u6700\u8fd1\u53d1\u5e03: ${this.formatTime(stats.lastShareAt)}`;
const lastErrorEl = document.getElementById('stat-last-error');
const lastError = stats.lastError || stats.lastShareError;
if (lastError) {
lastErrorEl.textContent = lastError;
lastErrorEl.classList.remove('empty-muted');
} else {
lastErrorEl.textContent = '--';
lastErrorEl.classList.add('empty-muted');
}
this.renderBars(stats.byDay || {});
},
renderBars(byDay) {
const bars = document.getElementById('stats-bars');
if (!bars) return;
bars.innerHTML = '';
const days = [];
const today = new Date();
for (let i = 6; i >= 0; i -= 1) {
const date = new Date(today);
date.setDate(today.getDate() - i);
const key = date.toISOString().slice(0, 10);
const dayStat = byDay[key] || {};
days.push({
key,
label: key.slice(5),
total: dayStat.total || 0,
shareTotal: dayStat.shareTotal || 0
});
}
const maxTotal = Math.max(1, ...days.map((d) => Math.max(d.total, d.shareTotal)));
days.forEach((day) => {
const bar = document.createElement('div');
bar.className = 'bar';
const group = document.createElement('div');
group.className = 'bar-group';
const totalCol = document.createElement('div');
totalCol.className = 'bar-col';
const totalValue = document.createElement('div');
totalValue.className = 'bar-value';
totalValue.textContent = this.formatNumber(day.total);
const totalFill = document.createElement('div');
totalFill.className = 'bar-fill';
totalFill.style.height = `${Math.max(6, Math.round((day.total / maxTotal) * 100))}%`;
const totalLabel = document.createElement('div');
totalLabel.className = 'bar-series';
totalLabel.textContent = '\u8C03\u7528';
totalCol.appendChild(totalValue);
totalCol.appendChild(totalFill);
totalCol.appendChild(totalLabel);
const shareCol = document.createElement('div');
shareCol.className = 'bar-col';
const shareValue = document.createElement('div');
shareValue.className = 'bar-value';
shareValue.textContent = this.formatNumber(day.shareTotal);
const shareFill = document.createElement('div');
shareFill.className = 'bar-fill share';
shareFill.style.height = `${Math.max(6, Math.round((day.shareTotal / maxTotal) * 100))}%`;
const shareLabel = document.createElement('div');
shareLabel.className = 'bar-series';
shareLabel.textContent = '\u53D1\u5E03';
shareCol.appendChild(shareValue);
shareCol.appendChild(shareFill);
shareCol.appendChild(shareLabel);
group.appendChild(totalCol);
group.appendChild(shareCol);
const dateLabel = document.createElement('div');
dateLabel.className = 'bar-label';
dateLabel.textContent = day.label;
bar.appendChild(group);
bar.appendChild(dateLabel);
bars.appendChild(bar);
});
},
};
async function doLogin() {
const pwd = document.getElementById('pwd').value;
if (!pwd) return;
const result = await Auth.login(pwd);
if (result.success) {
Auth.unlock();
StatsPage.start();
} else {
alert(result.message || '登录失败');
}
}
async function initPage() {
StatsPage.init();
const isAuth = await Auth.check();
if (isAuth) {
Auth.unlock();
StatsPage.start();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPage);
} else {
initPage();
}
</script>
</body>
</html>