diff --git a/index.html b/index.html index 14347a4..14f2013 100644 --- a/index.html +++ b/index.html @@ -890,81 +890,91 @@
-
- -
-
-
0
-
总请求数
-
-
- -
-
- -
-
-
0
-
成功请求
-
-
- -
-
- -
-
-
0
-
失败请求
-
-
- -
-
- -
-
-
0
-
总Token数
-
- 缓存 Token 数: - 0 +
+
+
总请求数
+
0
+
+ 成功请求 0 + + 失败请求 0 +
-
- 思考 Token 数: - 0 +
+
-
- -
-
- -
-
-
0
-
RPM(近30分钟)
+
+
-
- +
+
+
总Token数
+
0
+
+ 缓存 Token 数: + 0 +
+
+ 思考 Token 数: + 0 +
+
+
+ +
-
-
0
-
TPM(近30分钟)
+
+ +
+
+ +
+
+
+
RPM(近30分钟)
+
0
+
+
+ +
+
+
+ +
+
+ +
+
+
+
TPM(近30分钟)
+
0
+
+
+ +
+
+
+
-
- +
+
+
总花费
+
--
+
基于已设置的模型单价
+
+
+ +
-
-
--
-
总花费
-
基于已设置的模型单价
+
+
diff --git a/src/modules/usage.js b/src/modules/usage.js index 9ffa69c..5cbd5f3 100644 --- a/src/modules/usage.js +++ b/src/modules/usage.js @@ -140,6 +140,7 @@ export async function loadUsageStats(usageData = null) { // 更新概览卡片 this.updateUsageOverview(usage); + this.renderOverviewSparklines(usage); this.updateChartLineSelectors(usage); this.renderModelPriceOptions(usage); this.renderSavedModelPrices(); @@ -168,6 +169,7 @@ export async function loadUsageStats(usageData = null) { this.renderModelPriceOptions(null); this.renderSavedModelPrices(); this.updateCostSummaryAndChart(null); + this.destroySparklineCharts(); // 清空概览数据 ['total-requests', 'success-requests', 'failed-requests', 'total-tokens', 'cached-tokens', 'reasoning-tokens', 'rpm-30m', 'tpm-30m'].forEach(id => { @@ -242,6 +244,36 @@ export function formatPerMinuteValue(value) { return num.toFixed(2); } +export function formatCompactNumber(value) { + const num = Number(value); + if (!Number.isFinite(num)) { + return '0'; + } + const abs = Math.abs(num); + if (abs >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M`; + } + if (abs >= 1_000) { + return `${(num / 1_000).toFixed(1)}K`; + } + return abs >= 1 ? num.toFixed(0) : num.toFixed(2); +} + +export function formatCompactNumber(value) { + const num = Number(value); + if (!Number.isFinite(num)) { + return '0'; + } + const abs = Math.abs(num); + if (abs >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M`; + } + if (abs >= 1_000) { + return `${(num / 1_000).toFixed(1)}K`; + } + return abs >= 1 ? num.toFixed(0) : num.toFixed(2); +} + export function getModelNamesFromUsage(usage) { if (!usage) { return []; @@ -751,6 +783,7 @@ export function handleModelPriceSubmit() { this.persistModelPrices(next); this.renderSavedModelPrices(); this.updateCostSummaryAndChart(this.currentUsageData, this.getCostChartPeriod()); + this.renderOverviewSparklines(this.currentUsageData); this.showNotification(i18n.t('usage_stats.model_price_saved'), 'success'); } @@ -768,6 +801,7 @@ export function handleModelPriceReset() { this.renderSavedModelPrices(); this.prefillModelPriceInputs(); this.updateCostSummaryAndChart(this.currentUsageData, this.getCostChartPeriod()); + this.renderOverviewSparklines(this.currentUsageData); } export function calculateTokenBreakdown(usage = null) { @@ -826,6 +860,191 @@ export function calculateRecentPerMinuteRates(windowMinutes = 30, usage = null) }; } +export function buildRecentWindowSeries(windowMinutes = 30, usage = null, prices = null) { + const usagePayload = usage || this.currentUsageData; + const effectiveWindow = Number.isFinite(windowMinutes) && windowMinutes > 0 + ? Math.min(windowMinutes, 720) + : 30; + const bucketMs = 60 * 1000; + const bucketCount = Math.max(1, Math.floor(effectiveWindow)); + const now = Date.now(); + const windowStart = now - bucketCount * bucketMs; + const labels = Array.from({ length: bucketCount }, (_, index) => + this.formatMinuteLabel(new Date(windowStart + index * bucketMs)) + ); + + const requestSeries = new Array(bucketCount).fill(0); + const tokenSeries = new Array(bucketCount).fill(0); + const costSeries = new Array(bucketCount).fill(0); + const priceTable = prices || this.modelPrices || {}; + const hasPrices = Object.keys(priceTable).length > 0; + + if (!usagePayload) { + return { + labels, + requests: requestSeries, + tokens: tokenSeries, + rpm: requestSeries, + tpm: tokenSeries, + cost: costSeries, + hasPrices + }; + } + + const details = this.collectUsageDetailsFromUsage(usagePayload); + const calculateDetailCost = (detail) => { + if (!hasPrices) { + return 0; + } + const modelName = detail.__modelName || ''; + const price = priceTable[modelName]; + if (!price) { + return 0; + } + const tokens = detail?.tokens || {}; + const promptTokens = Number(tokens.input_tokens) || 0; + const completionTokens = Number(tokens.output_tokens) || 0; + const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0); + const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0); + const total = promptCost + completionCost; + return Number.isFinite(total) && total > 0 ? total : 0; + }; + + details.forEach(detail => { + const timestamp = Date.parse(detail.timestamp); + if (Number.isNaN(timestamp) || timestamp < windowStart) { + return; + } + const bucketIndex = Math.min(bucketCount - 1, Math.floor((timestamp - windowStart) / bucketMs)); + if (bucketIndex < 0 || bucketIndex >= bucketCount) { + return; + } + + requestSeries[bucketIndex] += 1; + tokenSeries[bucketIndex] += this.extractTotalTokens(detail); + costSeries[bucketIndex] += calculateDetailCost(detail); + }); + + return { + labels, + requests: requestSeries, + tokens: tokenSeries, + rpm: requestSeries, + tpm: tokenSeries, + cost: costSeries, + hasPrices + }; +} + +export function destroySparklineCharts(targetIds = null) { + if (!this.sparklineCharts) { + this.sparklineCharts = {}; + } + const ids = targetIds && targetIds.length ? targetIds : Object.keys(this.sparklineCharts); + ids.forEach(id => { + const chart = this.sparklineCharts[id]; + if (chart && typeof chart.destroy === 'function') { + chart.destroy(); + } + delete this.sparklineCharts[id]; + + const canvas = document.getElementById(id); + if (canvas && typeof canvas.getContext === 'function') { + const ctx = canvas.getContext('2d'); + if (ctx) { + const width = canvas.width || canvas.clientWidth || 300; + const height = canvas.height || canvas.clientHeight || 80; + ctx.clearRect(0, 0, width, height); + } + } + }); +} + +export function renderOverviewSparklines(usage = null) { + const series = this.buildRecentWindowSeries(30, usage, this.modelPrices); + const labels = series.labels || []; + const styleFor = (index = 0) => { + const fallback = { borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.15)' }; + if (!Array.isArray(this.chartLineStyles) || !this.chartLineStyles.length) { + return fallback; + } + return this.chartLineStyles[index % this.chartLineStyles.length] || fallback; + }; + + const createSparkline = ({ id, data, styleIndex, requirePrices = false }) => { + if (requirePrices && !series.hasPrices) { + this.destroySparklineCharts([id]); + return; + } + const canvas = document.getElementById(id); + if (!canvas) { + return; + } + + const style = styleFor(styleIndex); + const values = Array.isArray(data) && data.length ? data : [0]; + const maxValue = values.reduce((max, value) => Math.max(max, Number(value) || 0), 0); + const suggestedMax = maxValue > 0 ? maxValue * 1.2 : 1; + + this.destroySparklineCharts([id]); + if (!this.sparklineCharts) { + this.sparklineCharts = {}; + } + + this.sparklineCharts[id] = new Chart(canvas, { + type: 'line', + data: { + labels, + datasets: [{ + data: values, + borderColor: style.borderColor, + backgroundColor: style.backgroundColor, + fill: true, + tension: 0.35, + pointRadius: 0, + pointHoverRadius: 3, + borderWidth: 2, + spanGaps: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { intersect: false, mode: 'index' }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: (ctx) => this.formatCompactNumber(ctx.parsed.y || 0) + } + } + }, + layout: { padding: { left: 2, right: 2, top: 6, bottom: 6 } }, + scales: { + x: { display: false }, + y: { + display: false, + beginAtZero: true, + suggestedMin: 0, + suggestedMax + } + }, + elements: { + line: { borderWidth: 2, tension: 0.35 }, + point: { radius: 0, hitRadius: 3 } + } + } + }); + }; + + createSparkline({ id: 'requests-sparkline', data: series.requests, styleIndex: 0 }); + createSparkline({ id: 'tokens-sparkline', data: series.tokens, styleIndex: 1 }); + createSparkline({ id: 'rpm-sparkline', data: series.rpm, styleIndex: 2 }); + createSparkline({ id: 'tpm-sparkline', data: series.tpm, styleIndex: 3 }); + createSparkline({ id: 'cost-sparkline', data: series.cost, styleIndex: 7, requirePrices: true }); +} + export function createHourlyBucketMeta() { const hourMs = 60 * 60 * 1000; const now = new Date(); @@ -1000,6 +1219,15 @@ export function formatHourLabel(date) { return `${month}-${day} ${hour}:00`; } +export function formatMinuteLabel(date) { + if (!(date instanceof Date)) { + return ''; + } + const hour = date.getHours().toString().padStart(2, '0'); + const minute = date.getMinutes().toString().padStart(2, '0'); + return `${hour}:${minute}`; +} + export function formatDayLabel(date) { if (!(date instanceof Date)) { return ''; @@ -1602,13 +1830,16 @@ export const usageModule = { handleModelPriceReset, calculateTokenBreakdown, calculateRecentPerMinuteRates, + buildRecentWindowSeries, createHourlyBucketMeta, buildHourlySeriesByModel, buildDailySeriesByModel, buildChartDataForMetric, formatHourLabel, + formatMinuteLabel, formatTokensInMillions, formatPerMinuteValue, + formatCompactNumber, formatDayLabel, extractTotalTokens, formatUsd, @@ -1616,6 +1847,8 @@ export const usageModule = { getCostChartPeriod, setCostChartPlaceholder, destroyCostChart, + destroySparklineCharts, + renderOverviewSparklines, initializeCostChart, updateCostSummaryAndChart, initializeCharts, diff --git a/styles.css b/styles.css index 02d364c..0fb511d 100644 --- a/styles.css +++ b/styles.css @@ -3386,14 +3386,14 @@ input:checked+.slider:before { /* 使用统计样式 */ .stats-overview { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 20px; margin-bottom: 30px; } @media (max-width: 1200px) { .stats-overview { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } } @@ -3486,10 +3486,11 @@ input:checked+.slider:before { background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 12px; - padding: 24px; + padding: 18px; display: flex; - align-items: center; - gap: 16px; + flex-direction: column; + gap: 12px; + min-height: 220px; transition: all 0.2s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } @@ -3500,16 +3501,31 @@ input:checked+.slider:before { transform: translateY(-2px); } +.stat-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + min-height: 110px; +} + +.stat-meta { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + .stat-icon { - width: 50px; - height: 50px; + width: 44px; + height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; background: var(--primary-color); color: white; - font-size: 20px; + font-size: 18px; flex-shrink: 0; } @@ -3521,32 +3537,47 @@ input:checked+.slider:before { background: #ef4444; } -.stat-content { - flex: 1; -} - .stat-number { - font-size: 28px; + font-size: 30px; font-weight: 700; color: var(--text-primary); - line-height: 1; - margin-bottom: 4px; + line-height: 1.1; } .stat-label { - font-size: 14px; + font-size: 13px; + letter-spacing: 0.2px; color: var(--text-secondary); - font-weight: 500; + text-transform: uppercase; + font-weight: 600; } .stat-subtext { font-size: 12px; color: var(--text-tertiary); - line-height: 1.4; + line-height: 1.5; } -.stat-subtext:first-of-type { - margin-top: 6px; +.stat-subtext-inline { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.dot-divider { + color: var(--text-tertiary); + font-weight: 700; +} + +.stat-sparkline { + height: 90px; + margin-top: auto; +} + +.stat-sparkline canvas { + width: 100% !important; + height: 100% !important; } .cost-summary-card .stat-icon {