Spaces:
Runtime error
Runtime error
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Banana Pro - 站点统计</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">站点调用统计</h1> | |
| <div class="stats-meta"> | |
| <span id="stat-updated">更新时间: --</span> | |
| <span id="stat-uptime">运行时长: --</span> | |
| </div> | |
| </div> | |
| <div class="stats-actions"> | |
| <a class="prompt-link" href="index.html">返回创意画板</a> | |
| <button class="icon-btn" id="refresh-stats">刷新</button> | |
| </div> | |
| </div> | |
| <section class="stats-grid"> | |
| <div class="stats-card"> | |
| <div class="stats-label">总调用</div> | |
| <div class="stats-value" id="stat-total">--</div> | |
| <div class="stats-sub"> | |
| <span>成功: <span id="stat-success">--</span></span> | |
| <span>失败: <span id="stat-failure">--</span></span> | |
| </div> | |
| </div> | |
| <div class="stats-card"> | |
| <div class="stats-label">成功率</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">平均耗时</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">进行中</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">公共画廊发布</div> | |
| <div class="stats-value" id="stat-share-total">--</div> | |
| <div class="stats-sub"> | |
| <span>成功: <span id="stat-share-success">--</span></span> | |
| <span>失败: <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">最近 7 天调用量</div> | |
| <div class="stats-legend"> | |
| <span class="legend-item"><span class="legend-dot legend-total"></span>调用</span> | |
| <span class="legend-item"><span class="legend-dot legend-share"></span>发布</span> | |
| </div> | |
| <div class="bars" id="stats-bars"></div> | |
| </section> | |
| <section class="stats-panel"> | |
| <div class="panel-title">最近错误</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> | |