From a04dd06be959745deec741bc139e861d8edc57c5 Mon Sep 17 00:00:00 2001 From: KevinLiao Date: Sun, 27 Jul 2025 17:38:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EApiKey=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/web.js | 342 +++++++++++++++++++++++++++ web/userStats/app.js | 497 +++++++++++++++++++++++++++++++++++++++ web/userStats/index.html | 428 +++++++++++++++++++++++++++++++++ web/userStats/style.css | 439 ++++++++++++++++++++++++++++++++++ 4 files changed, 1706 insertions(+) create mode 100644 web/userStats/app.js create mode 100644 web/userStats/index.html create mode 100644 web/userStats/style.css diff --git a/src/routes/web.js b/src/routes/web.js index 8e229363..be7d31ba 100644 --- a/src/routes/web.js +++ b/src/routes/web.js @@ -6,6 +6,8 @@ const fs = require('fs'); const redis = require('../models/redis'); const logger = require('../utils/logger'); const config = require('../../config/config'); +const apiKeyService = require('../services/apiKeyService'); +const CostCalculator = require('../utils/costCalculator'); const router = express.Router(); @@ -25,6 +27,18 @@ const ALLOWED_FILES = { 'style.css': { path: path.join(__dirname, '../../web/admin/style.css'), contentType: 'text/css; charset=utf-8' + }, + 'userStats.html': { + path: path.join(__dirname, '../../web/userStats/index.html'), + contentType: 'text/html; charset=utf-8' + }, + 'userStats.js': { + path: path.join(__dirname, '../../web/userStats/app.js'), + contentType: 'application/javascript; charset=utf-8' + }, + 'userStats.css': { + path: path.join(__dirname, '../../web/userStats/style.css'), + contentType: 'text/css; charset=utf-8' } }; @@ -400,6 +414,334 @@ router.get('/style.css', (req, res) => { serveWhitelistedFile(req, res, 'style.css'); }); +// 📊 用户统计页面路由 +router.get('/userStats', (req, res) => { + serveWhitelistedFile(req, res, 'userStats.html'); +}); + +router.get('/userStats.js', (req, res) => { + serveWhitelistedFile(req, res, 'userStats.js'); +}); + +router.get('/userStats.css', (req, res) => { + serveWhitelistedFile(req, res, 'userStats.css'); +}); + +// 📊 用户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(); + const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; + + // 获取所有月度模型统计(与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' + }); + } +}); + + // 🔑 Gemini OAuth 回调页面 module.exports = router; \ No newline at end of file diff --git a/web/userStats/app.js b/web/userStats/app.js new file mode 100644 index 00000000..1898c706 --- /dev/null +++ b/web/userStats/app.js @@ -0,0 +1,497 @@ +// 初始化 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('/web/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('/web/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('/web/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 '格式错误'; + } + }, + + // 🔢 格式化数字 + 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/userStats/index.html b/web/userStats/index.html new file mode 100644 index 00000000..83e99700 --- /dev/null +++ b/web/userStats/index.html @@ -0,0 +1,428 @@ + + + + + + API Key 使用统计 - Claude Relay Service + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+

+ + API Key 使用统计 +

+

查询您的 API Key 使用情况和统计数据

+
+ + +
+
+
+ +
+ + +
+
+ + +
+ + 您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途 +
+
+
+ + +
+
+
+
+ + 统计时间范围 +
+
+ + +
+
+
+
+ + +
+
+ + {{ error }} +
+
+ + +
+ +
+ +
+

+ + API Key 信息 +

+
+
+ 名称 + {{ statsData.name }} +
+
+ 状态 + + + {{ statsData.isActive ? '活跃' : '已停用' }} + +
+
+ 权限 + {{ formatPermissions(statsData.permissions) }} +
+
+ 创建时间 + {{ formatDate(statsData.createdAt) }} +
+
+ 过期时间 + {{ formatDate(statsData.expiresAt) }} +
+
+
+ + +
+

+ + 使用统计概览 ({{ statsPeriod === 'daily' ? '今日' : '本月' }}) +

+
+
+
{{ formatNumber(currentPeriodData.requests) }}
+
{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数
+
+
+
{{ formatNumber(currentPeriodData.allTokens) }}
+
{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数
+
+
+
{{ currentPeriodData.formattedCost || '$0.000000' }}
+
{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用
+
+
+
{{ formatNumber(currentPeriodData.inputTokens) }}
+
{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token
+
+
+
+
+ + +
+ +
+

+ + Token 使用分布 ({{ statsPeriod === 'daily' ? '今日' : '本月' }}) +

+
+
+ + + 输入 Token + + {{ formatNumber(currentPeriodData.inputTokens) }} +
+
+ + + 输出 Token + + {{ formatNumber(currentPeriodData.outputTokens) }} +
+
+ + + 缓存创建 Token + + {{ formatNumber(currentPeriodData.cacheCreateTokens) }} +
+
+ + + 缓存读取 Token + + {{ formatNumber(currentPeriodData.cacheReadTokens) }} +
+
+
+
+ {{ statsPeriod === 'daily' ? '今日' : '本月' }}总计 + {{ formatNumber(currentPeriodData.allTokens) }} +
+
+
+ + +
+

+ + 限制配置 +

+
+
+ Token 限制 + {{ statsData.limits.tokenLimit > 0 ? formatNumber(statsData.limits.tokenLimit) : '无限制' }} +
+
+ 并发限制 + {{ statsData.limits.concurrencyLimit > 0 ? statsData.limits.concurrencyLimit : '无限制' }} +
+
+ 速率限制 + + {{ statsData.limits.rateLimitRequests > 0 && statsData.limits.rateLimitWindow > 0 + ? `${statsData.limits.rateLimitRequests}次/${statsData.limits.rateLimitWindow}分钟` + : '无限制' }} + +
+
+ 每日费用限制 + {{ statsData.limits.dailyCostLimit > 0 ? '$' + statsData.limits.dailyCostLimit : '无限制' }} +
+
+
+
+ + +
+
+

+ + 模型使用统计 ({{ statsPeriod === 'daily' ? '今日' : '本月' }}) +

+
+ + +
+ +

加载模型统计数据中...

+
+ + +
+
+
+
+

{{ model.model }}

+

{{ model.requests }} 次请求

+
+
+
{{ model.formatted?.total || '$0.000000' }}
+
总费用
+
+
+ +
+
+
输入 Token
+
{{ formatNumber(model.inputTokens) }}
+
+
+
输出 Token
+
{{ formatNumber(model.outputTokens) }}
+
+
+
缓存创建
+
{{ formatNumber(model.cacheCreateTokens) }}
+
+
+
缓存读取
+
{{ formatNumber(model.cacheReadTokens) }}
+
+
+
+
+ + +
+ +

暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据

+
+
+
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/web/userStats/style.css b/web/userStats/style.css new file mode 100644 index 00000000..dc47bbad --- /dev/null +++ b/web/userStats/style.css @@ -0,0 +1,439 @@ +/* 🎨 用户统计页面自定义样式 */ + +/* 📱 响应式布局优化 */ +@media (max-width: 768px) { + .container { + padding-left: 1rem; + padding-right: 1rem; + } + + .card { + margin-bottom: 1rem; + } + + .grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .stat-card { + padding: 0.75rem; + } + + .stat-card .text-2xl { + font-size: 1.5rem; + } + + .model-usage-item .grid { + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + } + + .text-4xl { + font-size: 2rem; + } + + .input-field, .btn-primary { + padding: 0.75rem 1rem; + } +} + +@media (max-width: 480px) { + .container { + padding-left: 0.5rem; + padding-right: 0.5rem; + } + + .text-4xl { + font-size: 1.75rem; + } + + .text-lg { + font-size: 1rem; + } + + .card { + padding: 1rem; + } + + .stat-card { + padding: 0.5rem; + } + + .stat-card .text-2xl { + font-size: 1.25rem; + } + + .stat-card .text-sm { + font-size: 0.75rem; + } + + .model-usage-item .grid { + grid-template-columns: 1fr; + } + + .flex.gap-3 { + flex-direction: column; + gap: 0.75rem; + } + + .btn-primary { + width: 100%; + justify-content: center; + } +} + +/* 🌈 渐变背景增强 */ +.gradient-bg { + background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + background-size: 200% 200%; + animation: gradientShift 15s ease infinite; +} + +@keyframes gradientShift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +/* ✨ 卡片样式增强 */ +.card { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37); +} + +.card:hover { + transform: translateY(-4px) scale(1.02); + box-shadow: 0 20px 40px rgba(31, 38, 135, 0.5); + border-color: rgba(255, 255, 255, 0.3); +} + +/* 🎯 统计卡片样式 */ +.stat-card { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%); + border: 1px solid rgba(255, 255, 255, 0.2); + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.stat-card:hover::before { + left: 100%; +} + +.stat-card:hover { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.25) 0%, rgba(255, 255, 255, 0.15) 100%); + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); +} + +/* 🔍 输入框增强 */ +.input-field { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + transition: all 0.3s ease; + backdrop-filter: blur(10px); +} + +.input-field::placeholder { + color: rgba(255, 255, 255, 0.6); +} + +.input-field:focus { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.5); + outline: none; + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1); + transform: translateY(-1px); +} + +/* 🎨 按钮增强 */ +.btn-primary { + background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 50%, #ec4899 100%); + background-size: 200% 200%; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.btn-primary::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.btn-primary:hover::before { + left: 100%; +} + +.btn-primary:hover { + background-position: 100% 0; + transform: translateY(-2px); + box-shadow: 0 15px 30px rgba(79, 70, 229, 0.4); +} + +.btn-primary:active { + transform: translateY(0); + box-shadow: 0 5px 15px rgba(79, 70, 229, 0.3); +} + +/* 📊 模型使用项增强 */ +.model-usage-item { + transition: all 0.3s ease; + border: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + overflow: hidden; +} + +.model-usage-item::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent); + transition: left 0.6s; +} + +.model-usage-item:hover::before { + left: 100%; +} + +.model-usage-item:hover { + transform: translateX(8px) translateY(-2px); + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); +} + +/* 🔄 加载动画增强 */ +.loading-spinner { + animation: spin 1s linear infinite; + filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5)); +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* 🌟 动画效果 */ +.fade-in { + animation: fadeIn 0.6s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.slide-in { + animation: slideIn 0.4s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* 🎯 焦点样式增强 */ +.input-field:focus-visible, +.btn-primary:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.5); + outline-offset: 2px; +} + +/* 📱 滚动条样式 */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3); + border-radius: 4px; + transition: background 0.3s ease; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.5); +} + +/* 🚨 错误状态样式 */ +.error-border { + border-color: #ef4444 !important; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); +} + +/* 🎉 成功状态样式 */ +.success-border { + border-color: #10b981 !important; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); +} + +/* 🌙 深色模式适配 */ +@media (prefers-color-scheme: dark) { + .card { + background: rgba(0, 0, 0, 0.2); + border-color: rgba(255, 255, 255, 0.15); + } + + .stat-card { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%); + } + + .input-field { + background: rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.3); + } +} + +/* 🔍 高对比度模式支持 */ +@media (prefers-contrast: high) { + .card { + border-width: 2px; + border-color: rgba(255, 255, 255, 0.5); + } + + .input-field { + border-width: 2px; + border-color: rgba(255, 255, 255, 0.6); + } + + .btn-primary { + border: 2px solid rgba(255, 255, 255, 0.5); + } +} + +/* 📊 数据可视化增强 */ +.chart-container { + position: relative; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 20px; + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); +} + +/* 🎨 图标动画 */ +.fas { + transition: transform 0.3s ease; +} + +.card:hover .fas { + transform: scale(1.1); +} + +/* 💫 悬浮效果 */ +.hover-lift { + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.hover-lift:hover { + transform: translateY(-4px); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); +} + +/* 🎯 选中状态 */ +.selected { + background: rgba(255, 255, 255, 0.2) !important; + border-color: rgba(255, 255, 255, 0.4) !important; + transform: scale(1.02); +} + +/* 🌈 彩虹边框效果 */ +.rainbow-border { + position: relative; + background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4, #feca57); + background-size: 400% 400%; + animation: gradientBG 15s ease infinite; + padding: 2px; + border-radius: 12px; +} + +.rainbow-border > * { + background: rgba(0, 0, 0, 0.8); + border-radius: 10px; +} + +@keyframes gradientBG { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +/* 📱 触摸设备优化 */ +@media (hover: none) and (pointer: coarse) { + .card:hover { + transform: none; + box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37); + } + + .btn-primary:hover { + transform: none; + background-position: 0% 0; + } + + .model-usage-item:hover { + transform: none; + background: rgba(255, 255, 255, 0.05); + } +} + +/* 🎯 打印样式 */ +@media print { + .gradient-bg { + background: white !important; + color: black !important; + } + + .card { + border: 1px solid #ccc !important; + background: white !important; + box-shadow: none !important; + } + + .btn-primary { + display: none !important; + } + + .input-field { + border: 1px solid #ccc !important; + background: white !important; + } +} \ No newline at end of file