| |
| |
| |
|
|
| const $ = (sel) => document.querySelector(sel); |
| const $$ = (sel) => document.querySelectorAll(sel); |
|
|
| |
| const fmtNum = (n) => { |
| if (n == null || isNaN(n)) return '--'; |
| if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'; |
| if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; |
| if (n >= 1e4) return (n / 1e3).toFixed(1) + 'K'; |
| return String(n); |
| }; |
|
|
| const fmtTime = (ts) => { |
| const d = new Date(ts * 1000); |
| return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); |
| }; |
|
|
| |
| function themeColors() { |
| const s = getComputedStyle(document.documentElement); |
| return { |
| primary: s.getPropertyValue('--md-sys-color-primary').trim(), |
| secondary: s.getPropertyValue('--md-sys-color-secondary').trim(), |
| error: s.getPropertyValue('--md-sys-color-error').trim(), |
| surfaceVar: s.getPropertyValue('--md-sys-color-surface-variant').trim(), |
| onSurface: s.getPropertyValue('--md-sys-color-on-surface').trim(), |
| }; |
| } |
|
|
| |
| const COLOR_IN_FLASH = '#1565C0'; |
| const COLOR_OUT_FLASH = '#42A5F5'; |
| const COLOR_IN_PRO = '#C62828'; |
| const COLOR_OUT_PRO = '#EF5350'; |
|
|
| |
| let period = 0; |
| let chartTokens = null; |
| let chartRequests = null; |
|
|
| |
| async function fetchJSON(path) { |
| const resp = await fetch(path); |
| if (!resp.ok) throw new Error(`${path} returned ${resp.status}`); |
| return resp.json(); |
| } |
|
|
| |
| async function updateOverview() { |
| try { |
| const [ov, models] = await Promise.all([ |
| fetchJSON(`/api/stats/overview?days=${period}`), |
| fetchJSON(`/api/stats/models?days=${period}`), |
| ]); |
|
|
| $('#ovRequests').textContent = fmtNum(ov.total_requests); |
| $('#ovTokens').textContent = fmtNum(ov.total_tokens); |
|
|
| |
| const totalIn = models.reduce((s, m) => s + (m.prompt_tokens || 0), 0); |
| const totalOut = models.reduce((s, m) => s + (m.completion_tokens || 0), 0); |
| $('#ovInOut').innerHTML = totalIn + totalOut > 0 |
| ? `<span class="inout-item inout-item--in">入 ${fmtNum(totalIn)}</span><span class="inout-item inout-item--out">出 ${fmtNum(totalOut)}</span>` |
| : ''; |
|
|
| const flash = models.find(m => m.model_display === 'DeepSeek-V4-Flash') || { request_count:0, prompt_tokens:0, completion_tokens:0, total_tokens:0 }; |
| const pro = models.find(m => m.model_display === 'DeepSeek-V4-Pro') || { request_count:0, prompt_tokens:0, completion_tokens:0, total_tokens:0 }; |
|
|
| $('#ovFlashReq').textContent = fmtNum(flash.request_count); |
| $('#ovFlashInOut').innerHTML = (flash.prompt_tokens + flash.completion_tokens) > 0 |
| ? `<span class="inout-item inout-item--in">入 ${fmtNum(flash.prompt_tokens)}</span><span class="inout-item inout-item--out">出 ${fmtNum(flash.completion_tokens)}</span>` |
| : ''; |
|
|
| $('#ovProReq').textContent = fmtNum(pro.request_count); |
| $('#ovProInOut').innerHTML = (pro.prompt_tokens + pro.completion_tokens) > 0 |
| ? `<span class="inout-item inout-item--in">入 ${fmtNum(pro.prompt_tokens)}</span><span class="inout-item inout-item--out">出 ${fmtNum(pro.completion_tokens)}</span>` |
| : ''; |
| } catch (e) { |
| console.error('[updateOverview]', e); |
| } |
| } |
|
|
| |
| async function updateCharts() { |
| try { |
| const dailyData = await fetchJSON('/api/stats/daily?days=30'); |
|
|
| |
| const allDates = []; |
| for (let i = 29; i >= 0; i--) { |
| const d = new Date(); |
| d.setDate(d.getDate() - i); |
| allDates.push(d.toISOString().slice(0, 10)); |
| } |
|
|
| const dateMap = {}; |
| for (const d of allDates) dateMap[d] = {}; |
| for (const row of dailyData) { |
| if (dateMap[row.date]) dateMap[row.date][row.model_display] = row; |
| } |
|
|
| const sortedDates = allDates; |
| const labels = sortedDates.map(d => d.slice(5)); |
|
|
| |
| const flashIn = sortedDates.map(d => (dateMap[d]['DeepSeek-V4-Flash'] || {}).prompt_tokens || 0); |
| const flashOut = sortedDates.map(d => (dateMap[d]['DeepSeek-V4-Flash'] || {}).completion_tokens || 0); |
| const proIn = sortedDates.map(d => (dateMap[d]['DeepSeek-V4-Pro'] || {}).prompt_tokens || 0); |
| const proOut = sortedDates.map(d => (dateMap[d]['DeepSeek-V4-Pro'] || {}).completion_tokens || 0); |
| const flashReqs = sortedDates.map(d => (dateMap[d]['DeepSeek-V4-Flash'] || {}).request_count || 0); |
| const proReqs = sortedDates.map(d => (dateMap[d]['DeepSeek-V4-Pro'] || {}).request_count || 0); |
|
|
| const tc = themeColors(); |
|
|
| const chartOpts = (yLabel) => ({ |
| responsive: true, |
| maintainAspectRatio: false, |
| interaction: { mode: 'index', intersect: false }, |
| scales: { |
| x: { |
| grid: { color: tc.surfaceVar + '80' }, |
| ticks: { color: tc.onSurface, maxTicksLimit: 10, font: { size: 11 } }, |
| }, |
| y: { |
| beginAtZero: true, |
| stacked: true, |
| title: { display: true, text: yLabel, color: tc.onSurface }, |
| grid: { color: tc.surfaceVar + '80' }, |
| ticks: { color: tc.onSurface, callback: v => fmtNum(v), font: { size: 11 } }, |
| }, |
| }, |
| plugins: { |
| legend: { |
| position: 'bottom', |
| labels: { color: tc.onSurface, usePointStyle: true, padding: 16, font: { size: 11 } }, |
| }, |
| tooltip: { |
| callbacks: { |
| label: (ctx) => `${ctx.dataset.label}: ${fmtNum(ctx.raw)}`, |
| }, |
| }, |
| }, |
| }); |
|
|
| |
| if (chartTokens) chartTokens.destroy(); |
| chartTokens = new Chart($('#chartTokens'), { |
| type: 'bar', |
| data: { |
| labels, |
| datasets: [ |
| { label: 'Flash 输入', data: flashIn, backgroundColor: COLOR_IN_FLASH, stack: 'flash', borderWidth: 0, borderRadius: 0 }, |
| { label: 'Flash 输出', data: flashOut, backgroundColor: COLOR_OUT_FLASH, stack: 'flash', borderWidth: 0, borderRadius: 4 }, |
| { label: 'Pro 输入', data: proIn, backgroundColor: COLOR_IN_PRO, stack: 'pro', borderWidth: 0, borderRadius: 0 }, |
| { label: 'Pro 输出', data: proOut, backgroundColor: COLOR_OUT_PRO, stack: 'pro', borderWidth: 0, borderRadius: 4 }, |
| ], |
| }, |
| options: chartOpts('Token'), |
| }); |
|
|
| |
| if (chartRequests) chartRequests.destroy(); |
| chartRequests = new Chart($('#chartRequests'), { |
| type: 'line', |
| data: { |
| labels, |
| datasets: [ |
| { |
| label: 'V4-Flash', |
| data: flashReqs, |
| borderColor: COLOR_IN_FLASH, |
| backgroundColor: COLOR_IN_FLASH + '33', |
| fill: true, |
| tension: 0.3, |
| pointRadius: 2, |
| pointHoverRadius: 5, |
| }, |
| { |
| label: 'V4-Pro', |
| data: proReqs, |
| borderColor: COLOR_IN_PRO, |
| backgroundColor: COLOR_IN_PRO + '33', |
| fill: true, |
| tension: 0.3, |
| pointRadius: 2, |
| pointHoverRadius: 5, |
| }, |
| ], |
| }, |
| options: { ...chartOpts('请求数'), scales: { ...chartOpts('请求数').scales, y: { ...chartOpts('请求数').scales.y, stacked: false } } }, |
| }); |
| } catch (e) { |
| console.error('[updateCharts]', e); |
| } |
| } |
|
|
| |
| function renderAccountTable(tbodyId, modelDisplay, data) { |
| const tbody = $(`#${tbodyId}`); |
| const filtered = data.filter(r => r.model_display === modelDisplay); |
| if (!filtered.length) { |
| tbody.innerHTML = `<tr><td colspan="5" class="empty">暂无数据</td></tr>`; |
| return; |
| } |
| |
| filtered.sort((a, b) => (b.total_tokens || 0) - (a.total_tokens || 0)); |
| const maxTotal = Math.max(...filtered.map(r => r.total_tokens || 0)); |
|
|
| tbody.innerHTML = filtered.map(r => { |
| const bw = maxTotal > 0 ? Math.max(2, (r.total_tokens / maxTotal) * 100) : 0; |
| return `<tr> |
| <td>${r.account_id || '(直接 token)'}</td> |
| <td class="r">${fmtNum(r.request_count)}</td> |
| <td class="r">${fmtNum(r.prompt_tokens)}</td> |
| <td class="r">${fmtNum(r.completion_tokens)}</td> |
| <td class="r"> |
| <span class="token-bar" style="width:${bw}px;background:${modelDisplay === 'DeepSeek-V4-Flash' ? COLOR_IN_FLASH : COLOR_IN_PRO}"></span> |
| ${fmtNum(r.total_tokens)} |
| </td> |
| </tr>`; |
| }).join(''); |
| } |
|
|
| async function updateAccounts() { |
| try { |
| const data = await fetchJSON(`/api/stats/accounts?days=${period}`); |
| renderAccountTable('accountFlashTbody', 'DeepSeek-V4-Flash', data); |
| renderAccountTable('accountProTbody', 'DeepSeek-V4-Pro', data); |
| } catch (e) { |
| console.error('[updateAccounts]', e); |
| } |
| } |
|
|
| |
| async function updateRecent() { |
| try { |
| const data = await fetchJSON('/api/stats/recent?limit=15'); |
| const tbody = $('#recentTbody'); |
| if (!data.length) { |
| tbody.innerHTML = '<tr><td colspan="6" class="empty">暂无数据</td></tr>'; |
| return; |
| } |
| tbody.innerHTML = data.map(r => `<tr> |
| <td>${fmtTime(r.timestamp)}</td> |
| <td>${r.model_display}</td> |
| <td>${r.account_id || '--'}</td> |
| <td class="r">${fmtNum(r.prompt_tokens)}</td> |
| <td class="r">${fmtNum(r.completion_tokens)}</td> |
| <td class="r">${fmtNum(r.total_tokens)}</td> |
| </tr>`).join(''); |
| } catch (e) { |
| console.error('[updateRecent]', e); |
| } |
| } |
|
|
| |
| async function refreshAll() { |
| const btn = $('#refreshBtn'); |
| btn.classList.add('spinning'); |
| try { |
| await Promise.all([ |
| updateOverview(), |
| updateCharts(), |
| updateAccounts(), |
| updateRecent(), |
| updateActive(), |
| ]); |
| } catch (e) { |
| console.error('[refreshAll]', e); |
| } finally { |
| setTimeout(() => btn.classList.remove('spinning'), 400); |
| } |
| } |
|
|
| |
| async function updateActive() { |
| try { |
| const data = await fetchJSON('/api/stats/active'); |
| const count = data.active_connections || 0; |
| const chip = $('#activeChip'); |
| const counter = $('#activeCount'); |
| counter.textContent = count; |
| if (count > 0) { |
| chip.classList.add('has-connections'); |
| } else { |
| chip.classList.remove('has-connections'); |
| } |
| } catch (e) { |
| |
| } |
| } |
|
|
| |
| $$('.segmented-btn').forEach(btn => { |
| btn.addEventListener('click', () => { |
| $$('.segmented-btn').forEach(b => b.classList.remove('active')); |
| btn.classList.add('active'); |
| period = parseInt(btn.dataset.days); |
| refreshAll(); |
| }); |
| }); |
|
|
| |
| $('#refreshBtn').addEventListener('click', refreshAll); |
|
|
| let autoRefreshTimer = null; |
|
|
| function startAutoRefresh() { |
| stopAutoRefresh(); |
| autoRefreshTimer = setInterval(refreshAll, 60000); |
| } |
| function stopAutoRefresh() { |
| if (autoRefreshTimer) clearInterval(autoRefreshTimer); |
| } |
| document.addEventListener('visibilitychange', () => { |
| if (document.hidden) stopAutoRefresh(); |
| else { refreshAll(); startAutoRefresh(); } |
| }); |
|
|
| refreshAll().then(() => startAutoRefresh()); |
|
|