diff --git a/VERSION b/VERSION index ba7b2f76..73a29c94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.32 +1.1.34 diff --git a/src/app.js b/src/app.js index 73670861..c26665ce 100644 --- a/src/app.js +++ b/src/app.js @@ -16,6 +16,7 @@ const pricingService = require('./services/pricingService'); const apiRoutes = require('./routes/api'); const adminRoutes = require('./routes/admin'); const webRoutes = require('./routes/web'); +const apiStatsRoutes = require('./routes/apiStats'); const geminiRoutes = require('./routes/geminiRoutes'); const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes'); const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes'); @@ -120,13 +121,14 @@ class Application { this.app.use('/claude', apiRoutes); // /claude 路由别名,与 /api 功能相同 this.app.use('/admin', adminRoutes); this.app.use('/web', webRoutes); + this.app.use('/apiStats', apiStatsRoutes); this.app.use('/gemini', geminiRoutes); this.app.use('/openai/gemini', openaiGeminiRoutes); this.app.use('/openai/claude', openaiClaudeRoutes); - // 🏠 根路径重定向到管理界面 + // 🏠 根路径重定向到API统计页面 this.app.get('/', (req, res) => { - res.redirect('/web'); + res.redirect('/apiStats'); }); // 🏥 增强的健康检查端点 diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js new file mode 100644 index 00000000..d144162e --- /dev/null +++ b/src/routes/apiStats.js @@ -0,0 +1,365 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const redis = require('../models/redis'); +const logger = require('../utils/logger'); +const apiKeyService = require('../services/apiKeyService'); +const CostCalculator = require('../utils/costCalculator'); + +const router = express.Router(); + +// 🛡️ 安全文件服务函数 +function serveStaticFile(req, res, filename, contentType) { + const filePath = path.join(__dirname, '../../web/apiStats', filename); + + try { + // 检查文件是否存在 + if (!fs.existsSync(filePath)) { + logger.error(`❌ API Stats file not found: ${filePath}`); + return res.status(404).json({ error: 'File not found' }); + } + + // 读取并返回文件内容 + const content = fs.readFileSync(filePath, 'utf8'); + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.send(content); + + logger.info(`📄 Served API Stats file: ${filename}`); + } catch (error) { + logger.error(`❌ Error serving API Stats file ${filename}:`, error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +// 🏠 API Stats 主页面 +router.get('/', (req, res) => { + serveStaticFile(req, res, 'index.html', 'text/html; charset=utf-8'); +}); + +// 📱 JavaScript 文件 +router.get('/app.js', (req, res) => { + serveStaticFile(req, res, 'app.js', 'application/javascript; charset=utf-8'); +}); + +// 🎨 CSS 文件 +router.get('/style.css', (req, res) => { + serveStaticFile(req, res, 'style.css', 'text/css; charset=utf-8'); +}); + +// 📊 用户API Key统计查询接口 - 安全的自查询接口 +router.post('/api/user-stats', async (req, res) => { + try { + const { apiKey } = req.body; + + if (!apiKey) { + logger.security(`🔒 Missing API key in user stats query from ${req.ip || 'unknown'}`); + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }); + } + + // 基本API Key格式验证 + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`); + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }); + } + + // 验证API Key(重用现有的验证逻辑) + const validation = await apiKeyService.validateApiKey(apiKey); + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; + logger.security(`🔒 Invalid API key in user stats query: ${validation.error} from ${clientIP}`); + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }); + } + + const keyData = validation.keyData; + + // 记录合法查询 + logger.api(`📊 User stats query from key: ${keyData.name} (${keyData.id}) from ${req.ip || 'unknown'}`); + + // 获取验证结果中的完整keyData(包含isActive状态和cost信息) + const fullKeyData = validation.keyData; + + // 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算) + let totalCost = 0; + let formattedCost = '$0.000000'; + + try { + const client = redis.getClientSafe(); + + // 获取所有月度模型统计(与model-stats接口相同的逻辑) + const allModelKeys = await client.keys(`usage:${fullKeyData.id}:model:monthly:*:*`); + const modelUsageMap = new Map(); + + for (const key of allModelKeys) { + const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/); + if (!modelMatch) continue; + + const model = modelMatch[1]; + const data = await client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + if (!modelUsageMap.has(model)) { + modelUsageMap.set(model, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }); + } + + const modelUsage = modelUsageMap.get(model); + modelUsage.inputTokens += parseInt(data.inputTokens) || 0; + modelUsage.outputTokens += parseInt(data.outputTokens) || 0; + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0; + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0; + } + } + + // 按模型计算费用并汇总 + for (const [model, usage] of modelUsageMap) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + }; + + const costResult = CostCalculator.calculateCost(usageData, model); + totalCost += costResult.costs.total; + } + + // 如果没有模型级别的详细数据,回退到总体数据计算 + if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) { + const usage = fullKeyData.usage.total; + const costUsage = { + input_tokens: usage.inputTokens || 0, + output_tokens: usage.outputTokens || 0, + cache_creation_input_tokens: usage.cacheCreateTokens || 0, + cache_read_input_tokens: usage.cacheReadTokens || 0 + }; + + const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022'); + totalCost = costResult.costs.total; + } + + formattedCost = CostCalculator.formatCost(totalCost); + + } catch (error) { + logger.warn(`Failed to calculate detailed cost for key ${fullKeyData.id}:`, error); + // 回退到简单计算 + if (fullKeyData.usage?.total?.allTokens > 0) { + const usage = fullKeyData.usage.total; + const costUsage = { + input_tokens: usage.inputTokens || 0, + output_tokens: usage.outputTokens || 0, + cache_creation_input_tokens: usage.cacheCreateTokens || 0, + cache_read_input_tokens: usage.cacheReadTokens || 0 + }; + + const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022'); + totalCost = costResult.costs.total; + formattedCost = costResult.formatted.total; + } + } + + // 构建响应数据(只返回该API Key自己的信息,确保不泄露其他信息) + const responseData = { + id: fullKeyData.id, + name: fullKeyData.name, + description: keyData.description || '', + isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的 + createdAt: keyData.createdAt, + expiresAt: keyData.expiresAt, + permissions: fullKeyData.permissions, + + // 使用统计(使用验证结果中的完整数据) + usage: { + total: { + ...(fullKeyData.usage?.total || { + requests: 0, + tokens: 0, + allTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }), + cost: totalCost, + formattedCost: formattedCost + } + }, + + // 限制信息(只显示配置,不显示当前使用量) + limits: { + tokenLimit: fullKeyData.tokenLimit || 0, + concurrencyLimit: fullKeyData.concurrencyLimit || 0, + rateLimitWindow: fullKeyData.rateLimitWindow || 0, + rateLimitRequests: fullKeyData.rateLimitRequests || 0, + dailyCostLimit: fullKeyData.dailyCostLimit || 0 + }, + + // 绑定的账户信息(只显示ID,不显示敏感信息) + accounts: { + claudeAccountId: fullKeyData.claudeAccountId && fullKeyData.claudeAccountId !== '' ? fullKeyData.claudeAccountId : null, + geminiAccountId: fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== '' ? fullKeyData.geminiAccountId : null + }, + + // 模型和客户端限制信息 + restrictions: { + enableModelRestriction: fullKeyData.enableModelRestriction || false, + restrictedModels: fullKeyData.restrictedModels || [], + enableClientRestriction: fullKeyData.enableClientRestriction || false, + allowedClients: fullKeyData.allowedClients || [] + } + }; + + res.json({ + success: true, + data: responseData + }); + + } catch (error) { + logger.error('❌ Failed to process user stats query:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve API key statistics' + }); + } +}); + +// 📊 用户模型统计查询接口 - 安全的自查询接口 +router.post('/api/user-model-stats', async (req, res) => { + try { + const { apiKey, period = 'monthly' } = req.body; + + if (!apiKey) { + logger.security(`🔒 Missing API key in user model stats query from ${req.ip || 'unknown'}`); + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }); + } + + // 验证API Key + const validation = await apiKeyService.validateApiKey(apiKey); + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; + logger.security(`🔒 Invalid API key in user model stats query: ${validation.error} from ${clientIP}`); + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }); + } + + const keyData = validation.keyData; + logger.api(`📊 User model stats query from key: ${keyData.name} (${keyData.id}) for period: ${period}`); + + // 重用管理后台的模型统计逻辑,但只返回该API Key的数据 + const client = redis.getClientSafe(); + const today = new Date().toISOString().split('T')[0]; + const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; + + const pattern = period === 'daily' ? + `usage:${keyData.id}:model:daily:*:${today}` : + `usage:${keyData.id}:model:monthly:*:${currentMonth}`; + + const keys = await client.keys(pattern); + const modelStats = []; + + for (const key of keys) { + const match = key.match(period === 'daily' ? + /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ : + /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ + ); + + if (!match) continue; + + const model = match[1]; + const data = await client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + const usage = { + input_tokens: parseInt(data.inputTokens) || 0, + output_tokens: parseInt(data.outputTokens) || 0, + cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0 + }; + + const costData = CostCalculator.calculateCost(usage, model); + + modelStats.push({ + model, + requests: parseInt(data.requests) || 0, + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + allTokens: parseInt(data.allTokens) || 0, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing + }); + } + } + + // 如果没有详细的模型数据,尝试从总体usage中生成 + if (modelStats.length === 0 && keyData.usage?.total) { + const usageData = keyData.usage.total; + + if (usageData.allTokens > 0) { + const usage = { + input_tokens: usageData.inputTokens || 0, + output_tokens: usageData.outputTokens || 0, + cache_creation_input_tokens: usageData.cacheCreateTokens || 0, + cache_read_input_tokens: usageData.cacheReadTokens || 0 + }; + + const costData = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022'); + + modelStats.push({ + model: '总体使用 (历史数据)', + requests: usageData.requests || 0, + inputTokens: usageData.inputTokens || 0, + outputTokens: usageData.outputTokens || 0, + cacheCreateTokens: usageData.cacheCreateTokens || 0, + cacheReadTokens: usageData.cacheReadTokens || 0, + allTokens: usageData.allTokens || 0, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing + }); + } + } + + // 按总token数降序排列 + modelStats.sort((a, b) => b.allTokens - a.allTokens); + + res.json({ + success: true, + data: modelStats, + period: period + }); + + } catch (error) { + logger.error('❌ Failed to process user model stats query:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve model statistics' + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/web.js b/src/routes/web.js index 8e229363..7aacae26 100644 --- a/src/routes/web.js +++ b/src/routes/web.js @@ -25,7 +25,7 @@ const ALLOWED_FILES = { 'style.css': { path: path.join(__dirname, '../../web/admin/style.css'), contentType: 'text/css; charset=utf-8' - } + }, }; // 🛡️ 安全文件服务函数 @@ -400,6 +400,9 @@ router.get('/style.css', (req, res) => { serveWhitelistedFile(req, res, 'style.css'); }); + + + // 🔑 Gemini OAuth 回调页面 module.exports = router; \ No newline at end of file diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index ae78cd99..f7a1797b 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -147,6 +147,9 @@ class ApiKeyService { keyData: { id: keyData.id, name: keyData.name, + description: keyData.description, + createdAt: keyData.createdAt, + expiresAt: keyData.expiresAt, claudeAccountId: keyData.claudeAccountId, geminiAccountId: keyData.geminiAccountId, permissions: keyData.permissions || 'all', diff --git a/web/admin/app.js b/web/admin/app.js index b4370ab9..76ae9994 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -2065,11 +2065,11 @@ const app = createApp({ method: 'POST', body: JSON.stringify({ name: this.apiKeyForm.name, - tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null, + tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.toString().trim() ? parseInt(this.apiKeyForm.tokenLimit) : null, description: this.apiKeyForm.description || '', - concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0, - rateLimitWindow: this.apiKeyForm.rateLimitWindow && this.apiKeyForm.rateLimitWindow.trim() ? parseInt(this.apiKeyForm.rateLimitWindow) : null, - rateLimitRequests: this.apiKeyForm.rateLimitRequests && this.apiKeyForm.rateLimitRequests.trim() ? parseInt(this.apiKeyForm.rateLimitRequests) : null, + concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.toString().trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0, + rateLimitWindow: this.apiKeyForm.rateLimitWindow && this.apiKeyForm.rateLimitWindow.toString().trim() ? parseInt(this.apiKeyForm.rateLimitWindow) : null, + rateLimitRequests: this.apiKeyForm.rateLimitRequests && this.apiKeyForm.rateLimitRequests.toString().trim() ? parseInt(this.apiKeyForm.rateLimitRequests) : null, claudeAccountId: this.apiKeyForm.claudeAccountId || null, geminiAccountId: this.apiKeyForm.geminiAccountId || null, permissions: this.apiKeyForm.permissions || 'all', @@ -2078,7 +2078,7 @@ const app = createApp({ enableClientRestriction: this.apiKeyForm.enableClientRestriction, allowedClients: this.apiKeyForm.allowedClients, expiresAt: this.apiKeyForm.expiresAt, - dailyCostLimit: this.apiKeyForm.dailyCostLimit && this.apiKeyForm.dailyCostLimit.trim() ? parseFloat(this.apiKeyForm.dailyCostLimit) : 0 + dailyCostLimit: this.apiKeyForm.dailyCostLimit && this.apiKeyForm.dailyCostLimit.toString().trim() ? parseFloat(this.apiKeyForm.dailyCostLimit) : 0 }) }); diff --git a/web/apiStats/app.js b/web/apiStats/app.js new file mode 100644 index 00000000..b61de143 --- /dev/null +++ b/web/apiStats/app.js @@ -0,0 +1,525 @@ +// 初始化 dayjs 插件 +dayjs.extend(dayjs_plugin_relativeTime); +dayjs.extend(dayjs_plugin_timezone); +dayjs.extend(dayjs_plugin_utc); + +const { createApp } = Vue; + +const app = createApp({ + data() { + return { + // 用户输入 + apiKey: '', + + // 状态控制 + loading: false, + modelStatsLoading: false, + error: '', + + // 时间范围控制 + statsPeriod: 'daily', // 默认今日 + + // 数据 + statsData: null, + modelStats: [], + + // 分时间段的统计数据 + dailyStats: null, + monthlyStats: null + }; + }, + + methods: { + // 🔍 查询统计数据 + async queryStats() { + if (!this.apiKey.trim()) { + this.error = '请输入 API Key'; + return; + } + + this.loading = true; + this.error = ''; + this.statsData = null; + this.modelStats = []; + + try { + const response = await fetch('/apiStats/api/user-stats', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + apiKey: this.apiKey + }) + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.message || '查询失败'); + } + + if (result.success) { + this.statsData = result.data; + + // 同时加载今日和本月的统计数据 + await this.loadAllPeriodStats(); + + // 清除错误信息 + this.error = ''; + } else { + throw new Error(result.message || '查询失败'); + } + + } catch (error) { + console.error('Query stats error:', error); + this.error = error.message || '查询统计数据失败,请检查您的 API Key 是否正确'; + this.statsData = null; + this.modelStats = []; + } finally { + this.loading = false; + } + }, + + // 📊 加载所有时间段的统计数据 + async loadAllPeriodStats() { + if (!this.apiKey.trim()) { + return; + } + + // 并行加载今日和本月的数据 + await Promise.all([ + this.loadPeriodStats('daily'), + this.loadPeriodStats('monthly') + ]); + + // 加载当前选择时间段的模型统计 + await this.loadModelStats(this.statsPeriod); + }, + + // 📊 加载指定时间段的统计数据 + async loadPeriodStats(period) { + try { + const response = await fetch('/apiStats/api/user-model-stats', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + apiKey: this.apiKey, + period: period + }) + }); + + const result = await response.json(); + + if (response.ok && result.success) { + // 计算汇总数据 + const modelData = result.data || []; + const summary = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + }; + + modelData.forEach(model => { + summary.requests += model.requests || 0; + summary.inputTokens += model.inputTokens || 0; + summary.outputTokens += model.outputTokens || 0; + summary.cacheCreateTokens += model.cacheCreateTokens || 0; + summary.cacheReadTokens += model.cacheReadTokens || 0; + summary.allTokens += model.allTokens || 0; + summary.cost += model.costs?.total || 0; + }); + + summary.formattedCost = this.formatCost(summary.cost); + + // 存储到对应的时间段数据 + if (period === 'daily') { + this.dailyStats = summary; + } else { + this.monthlyStats = summary; + } + } else { + console.warn(`Failed to load ${period} stats:`, result.message); + } + + } catch (error) { + console.error(`Load ${period} stats error:`, error); + } + }, + + // 📊 加载模型统计数据 + async loadModelStats(period = 'daily') { + if (!this.apiKey.trim()) { + return; + } + + this.modelStatsLoading = true; + + try { + const response = await fetch('/apiStats/api/user-model-stats', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + apiKey: this.apiKey, + period: period + }) + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.message || '加载模型统计失败'); + } + + if (result.success) { + this.modelStats = result.data || []; + } else { + throw new Error(result.message || '加载模型统计失败'); + } + + } catch (error) { + console.error('Load model stats error:', error); + this.modelStats = []; + // 不显示错误,因为模型统计是可选的 + } finally { + this.modelStatsLoading = false; + } + }, + + // 🔄 切换时间范围 + async switchPeriod(period) { + if (this.statsPeriod === period || this.modelStatsLoading) { + return; + } + + this.statsPeriod = period; + + // 如果对应时间段的数据还没有加载,则加载它 + if ((period === 'daily' && !this.dailyStats) || + (period === 'monthly' && !this.monthlyStats)) { + await this.loadPeriodStats(period); + } + + // 加载对应的模型统计 + await this.loadModelStats(period); + }, + + // 📅 格式化日期 + formatDate(dateString) { + if (!dateString) return '无'; + + try { + // 使用 dayjs 格式化日期 + const date = dayjs(dateString); + return date.format('YYYY年MM月DD日 HH:mm'); + } catch (error) { + return '格式错误'; + } + }, + + // 📅 格式化过期日期 + formatExpireDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }, + + // 🔍 检查 API Key 是否已过期 + isApiKeyExpired(expiresAt) { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); + }, + + // ⏰ 检查 API Key 是否即将过期(7天内) + isApiKeyExpiringSoon(expiresAt) { + if (!expiresAt) return false; + const expireDate = new Date(expiresAt); + const now = new Date(); + const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24); + return daysUntilExpire > 0 && daysUntilExpire <= 7; + }, + + // 🔢 格式化数字 + formatNumber(num) { + if (typeof num !== 'number') { + num = parseInt(num) || 0; + } + + if (num === 0) return '0'; + + // 大数字使用简化格式 + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K'; + } else { + return num.toLocaleString(); + } + }, + + // 💰 格式化费用 + formatCost(cost) { + if (typeof cost !== 'number' || cost === 0) { + return '$0.000000'; + } + + // 根据数值大小选择精度 + if (cost >= 1) { + return '$' + cost.toFixed(2); + } else if (cost >= 0.01) { + return '$' + cost.toFixed(4); + } else { + return '$' + cost.toFixed(6); + } + }, + + // 🔐 格式化权限 + formatPermissions(permissions) { + const permissionMap = { + 'claude': 'Claude', + 'gemini': 'Gemini', + 'all': '全部模型' + }; + + return permissionMap[permissions] || permissions || '未知'; + }, + + // 💾 处理错误 + handleError(error, defaultMessage = '操作失败') { + console.error('Error:', error); + + let errorMessage = defaultMessage; + + if (error.response) { + // HTTP 错误响应 + if (error.response.data && error.response.data.message) { + errorMessage = error.response.data.message; + } else if (error.response.status === 401) { + errorMessage = 'API Key 无效或已过期'; + } else if (error.response.status === 403) { + errorMessage = '没有权限访问该数据'; + } else if (error.response.status === 429) { + errorMessage = '请求过于频繁,请稍后再试'; + } else if (error.response.status >= 500) { + errorMessage = '服务器内部错误,请稍后再试'; + } + } else if (error.message) { + errorMessage = error.message; + } + + this.error = errorMessage; + }, + + // 📋 复制到剪贴板 + async copyToClipboard(text) { + try { + await navigator.clipboard.writeText(text); + this.showToast('已复制到剪贴板', 'success'); + } catch (error) { + console.error('Copy failed:', error); + this.showToast('复制失败', 'error'); + } + }, + + // 🍞 显示 Toast 通知 + showToast(message, type = 'info') { + // 简单的 toast 实现 + const toast = document.createElement('div'); + toast.className = `fixed top-4 right-4 z-50 px-6 py-3 rounded-lg shadow-lg text-white transform transition-all duration-300 ${ + type === 'success' ? 'bg-green-500' : + type === 'error' ? 'bg-red-500' : + 'bg-blue-500' + }`; + toast.textContent = message; + + document.body.appendChild(toast); + + // 显示动画 + setTimeout(() => { + toast.style.transform = 'translateX(0)'; + toast.style.opacity = '1'; + }, 100); + + // 自动隐藏 + setTimeout(() => { + toast.style.transform = 'translateX(100%)'; + toast.style.opacity = '0'; + setTimeout(() => { + document.body.removeChild(toast); + }, 300); + }, 3000); + }, + + // 🧹 清除数据 + clearData() { + this.statsData = null; + this.modelStats = []; + this.dailyStats = null; + this.monthlyStats = null; + this.error = ''; + this.statsPeriod = 'daily'; // 重置为默认值 + }, + + // 🔄 刷新数据 + async refreshData() { + if (this.statsData && this.apiKey) { + await this.queryStats(); + } + }, + + // 📊 刷新当前时间段数据 + async refreshCurrentPeriod() { + if (this.apiKey) { + await this.loadPeriodStats(this.statsPeriod); + await this.loadModelStats(this.statsPeriod); + } + } + }, + + computed: { + // 📊 当前时间段的数据 + currentPeriodData() { + if (this.statsPeriod === 'daily') { + return this.dailyStats || { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + }; + } else { + return this.monthlyStats || { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + }; + } + }, + + // 📊 使用率计算(基于当前时间段) + usagePercentages() { + if (!this.statsData || !this.currentPeriodData) { + return { + tokenUsage: 0, + costUsage: 0, + requestUsage: 0 + }; + } + + const current = this.currentPeriodData; + const limits = this.statsData.limits; + + return { + tokenUsage: limits.tokenLimit > 0 ? Math.min((current.allTokens / limits.tokenLimit) * 100, 100) : 0, + costUsage: limits.dailyCostLimit > 0 ? Math.min((current.cost / limits.dailyCostLimit) * 100, 100) : 0, + requestUsage: limits.rateLimitRequests > 0 ? Math.min((current.requests / limits.rateLimitRequests) * 100, 100) : 0 + }; + }, + + // 📈 统计摘要(基于当前时间段) + statsSummary() { + if (!this.statsData || !this.currentPeriodData) return null; + + const current = this.currentPeriodData; + + return { + totalRequests: current.requests || 0, + totalTokens: current.allTokens || 0, + totalCost: current.cost || 0, + formattedCost: current.formattedCost || '$0.000000', + inputTokens: current.inputTokens || 0, + outputTokens: current.outputTokens || 0, + cacheCreateTokens: current.cacheCreateTokens || 0, + cacheReadTokens: current.cacheReadTokens || 0 + }; + } + }, + + watch: { + // 监听 API Key 变化 + apiKey(newValue) { + if (!newValue) { + this.clearData(); + } + // 清除之前的错误 + if (this.error) { + this.error = ''; + } + } + }, + + mounted() { + // 页面加载完成后的初始化 + console.log('User Stats Page loaded'); + + // 检查 URL 参数是否有预填的 API Key(用于开发测试) + const urlParams = new URLSearchParams(window.location.search); + const presetApiKey = urlParams.get('apiKey'); + if (presetApiKey && presetApiKey.length > 10) { + this.apiKey = presetApiKey; + } + + // 添加键盘快捷键 + document.addEventListener('keydown', (event) => { + // Ctrl/Cmd + Enter 查询 + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { + if (!this.loading && this.apiKey.trim()) { + this.queryStats(); + } + event.preventDefault(); + } + + // ESC 清除数据 + if (event.key === 'Escape') { + this.clearData(); + this.apiKey = ''; + } + }); + + // 定期清理无效的 toast 元素 + setInterval(() => { + const toasts = document.querySelectorAll('[class*="fixed top-4 right-4"]'); + toasts.forEach(toast => { + if (toast.style.opacity === '0') { + try { + document.body.removeChild(toast); + } catch (e) { + // 忽略已经被移除的元素 + } + } + }); + }, 5000); + }, + + // 组件销毁前清理 + beforeUnmount() { + // 清理事件监听器 + document.removeEventListener('keydown', this.handleKeyDown); + } +}); + +// 挂载应用 +app.mount('#app'); \ No newline at end of file diff --git a/web/apiStats/index.html b/web/apiStats/index.html new file mode 100644 index 00000000..c238effc --- /dev/null +++ b/web/apiStats/index.html @@ -0,0 +1,450 @@ + + +
+ + +API Key 使用统计
+查询您的 API Key 使用情况和统计数据
++ + 此 API Key 不能访问以上列出的模型 +
++ + 此 API Key 只能被以上列出的客户端使用 +
+加载模型统计数据中...
+{{ model.requests }} 次请求
+暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据
+