diff --git a/.env.example b/.env.example index 62f7fcfb..4d77eb53 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,12 @@ REDIS_PASSWORD= REDIS_DB=0 REDIS_ENABLE_TLS= +# 🔗 会话管理配置 +# 粘性会话TTL配置(小时),默认1小时 +STICKY_SESSION_TTL_HOURS=1 +# 续期阈值(分钟),默认0分钟(不续期) +STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=0 + # 🎯 Claude API 配置 CLAUDE_API_URL=https://api.anthropic.com/v1/messages CLAUDE_API_VERSION=2023-06-01 diff --git a/VERSION b/VERSION index c4c90b95..64dbc165 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.131 +1.1.133 diff --git a/config/config.example.js b/config/config.example.js index 433ecd1f..1fdcdf7c 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -32,6 +32,14 @@ const config = { enableTLS: process.env.REDIS_ENABLE_TLS === 'true' }, + // 🔗 会话管理配置 + session: { + // 粘性会话TTL配置(小时),默认1小时 + stickyTtlHours: parseFloat(process.env.STICKY_SESSION_TTL_HOURS) || 1, + // 续期阈值(分钟),默认0分钟(不续期) + renewalThresholdMinutes: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES) || 0 + }, + // 🎯 Claude API配置 claude: { apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages', diff --git a/src/models/redis.js b/src/models/redis.js index 145f94cd..7ef64ebf 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1356,9 +1356,12 @@ class RedisClient { } // 🔗 会话sticky映射管理 - async setSessionAccountMapping(sessionHash, accountId, ttl = 3600) { + async setSessionAccountMapping(sessionHash, accountId, ttl = null) { + const appConfig = require('../../config/config') + // 从配置读取TTL(小时),转换为秒,默认1小时 + const defaultTTL = ttl !== null ? ttl : (appConfig.session?.stickyTtlHours || 1) * 60 * 60 const key = `sticky_session:${sessionHash}` - await this.client.set(key, accountId, 'EX', ttl) + await this.client.set(key, accountId, 'EX', defaultTTL) } async getSessionAccountMapping(sessionHash) { @@ -1366,6 +1369,57 @@ class RedisClient { return await this.client.get(key) } + // 🚀 智能会话TTL续期:剩余时间少于阈值时自动续期 + async extendSessionAccountMappingTTL(sessionHash) { + const appConfig = require('../../config/config') + const key = `sticky_session:${sessionHash}` + + // 📊 从配置获取参数 + const ttlHours = appConfig.session?.stickyTtlHours || 1 // 小时,默认1小时 + const thresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 // 分钟,默认0(不续期) + + // 如果阈值为0,不执行续期 + if (thresholdMinutes === 0) { + return true + } + + const fullTTL = ttlHours * 60 * 60 // 转换为秒 + const renewalThreshold = thresholdMinutes * 60 // 转换为秒 + + try { + // 获取当前剩余TTL(秒) + const remainingTTL = await this.client.ttl(key) + + // 键不存在或已过期 + if (remainingTTL === -2) { + return false + } + + // 键存在但没有TTL(永不过期,不需要处理) + if (remainingTTL === -1) { + return true + } + + // 🎯 智能续期策略:仅在剩余时间少于阈值时才续期 + if (remainingTTL < renewalThreshold) { + await this.client.expire(key, fullTTL) + logger.debug( + `🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}min, renewed to ${ttlHours}h)` + ) + return true + } + + // 剩余时间充足,无需续期 + logger.debug( + `✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}min)` + ) + return true + } catch (error) { + logger.error('❌ Failed to extend session TTL:', error) + return false + } + } + async deleteSessionAccountMapping(sessionHash) { const key = `sticky_session:${sessionHash}` return await this.client.del(key) diff --git a/src/routes/admin.js b/src/routes/admin.js index b8db9666..590f20c1 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -122,7 +122,7 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => // 获取所有API Keys router.get('/api-keys', authenticateAdmin, async (req, res) => { try { - const { timeRange = 'all' } = req.query // all, 7days, monthly + const { timeRange = 'all', startDate, endDate } = req.query // all, 7days, monthly, custom const apiKeys = await apiKeyService.getAllApiKeys() // 获取用户服务来补充owner信息 @@ -132,7 +132,32 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { const now = new Date() const searchPatterns = [] - if (timeRange === 'today') { + if (timeRange === 'custom' && startDate && endDate) { + // 自定义日期范围 + const redisClient = require('../models/redis') + const start = new Date(startDate) + const end = new Date(endDate) + + // 确保日期范围有效 + if (start > end) { + return res.status(400).json({ error: 'Start date must be before or equal to end date' }) + } + + // 限制最大范围为365天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 + if (daysDiff > 365) { + return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) + } + + // 生成日期范围内每天的搜索模式 + const currentDate = new Date(start) + while (currentDate <= end) { + const tzDate = redisClient.getDateInTimezone(currentDate) + const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}` + searchPatterns.push(`usage:daily:*:${dateStr}`) + currentDate.setDate(currentDate.getDate() + 1) + } + } else if (timeRange === 'today') { // 今日 - 使用时区日期 const redisClient = require('../models/redis') const tzDate = redisClient.getDateInTimezone(now) @@ -233,7 +258,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost) } } else { - // 7天或本月:重新计算统计数据 + // 7天、本月或自定义日期范围:重新计算统计数据 const tempUsage = { requests: 0, tokens: 0, @@ -274,12 +299,28 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { const tzDate = redisClient.getDateInTimezone(now) const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` - const modelKeys = - timeRange === 'today' - ? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`) - : timeRange === '7days' - ? await client.keys(`usage:${apiKey.id}:model:daily:*:*`) - : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`) + let modelKeys = [] + if (timeRange === 'custom' && startDate && endDate) { + // 自定义日期范围:获取范围内所有日期的模型统计 + const start = new Date(startDate) + const end = new Date(endDate) + const currentDate = new Date(start) + + while (currentDate <= end) { + const tzDateForKey = redisClient.getDateInTimezone(currentDate) + const dateStr = `${tzDateForKey.getUTCFullYear()}-${String(tzDateForKey.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDateForKey.getUTCDate()).padStart(2, '0')}` + const dayKeys = await client.keys(`usage:${apiKey.id}:model:daily:*:${dateStr}`) + modelKeys = modelKeys.concat(dayKeys) + currentDate.setDate(currentDate.getDate() + 1) + } + } else { + modelKeys = + timeRange === 'today' + ? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`) + : timeRange === '7days' + ? await client.keys(`usage:${apiKey.id}:model:daily:*:*`) + : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`) + } const modelStatsMap = new Map() @@ -295,8 +336,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { continue } } - } else if (timeRange === 'today') { - // today选项已经在查询时过滤了,不需要额外处理 + } else if (timeRange === 'today' || timeRange === 'custom') { + // today和custom选项已经在查询时过滤了,不需要额外处理 } const modelMatch = key.match( @@ -619,7 +660,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { weeklyOpusCostLimit, tags, activationDays, - expirationMode + expirationMode, + icon }) logger.success(`🔑 Admin created new API key: ${name}`) @@ -655,7 +697,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { weeklyOpusCostLimit, tags, activationDays, - expirationMode + expirationMode, + icon } = req.body // 输入验证 @@ -701,7 +744,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { weeklyOpusCostLimit, tags, activationDays, - expirationMode + expirationMode, + icon }) // 保留原始 API Key 供返回 @@ -3878,10 +3922,10 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: 'Start date must be before or equal to end date' }) } - // 限制最大范围为31天 + // 限制最大范围为365天 const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 - if (daysDiff > 31) { - return res.status(400).json({ error: 'Date range cannot exceed 31 days' }) + if (daysDiff > 365) { + return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) } // 生成日期范围内所有日期的搜索模式 @@ -4342,10 +4386,10 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) = return res.status(400).json({ error: 'Start date must be before or equal to end date' }) } - // 限制最大范围为31天 + // 限制最大范围为365天 const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 - if (daysDiff > 31) { - return res.status(400).json({ error: 'Date range cannot exceed 31 days' }) + if (daysDiff > 365) { + return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) } // 生成日期范围内所有日期的搜索模式 diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 7e7f5fde..3fc4b715 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -36,7 +36,8 @@ class ApiKeyService { weeklyOpusCostLimit = 0, tags = [], activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能) - expirationMode = 'fixed' // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活) + expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活) + icon = '' // 新增:图标(base64编码) } = options // 生成简单的API Key (64字符十六进制) @@ -78,7 +79,8 @@ class ApiKeyService { expiresAt: expirationMode === 'fixed' ? expiresAt || '' : '', // 固定模式才设置过期时间 createdBy: options.createdBy || 'admin', userId: options.userId || '', - userUsername: options.userUsername || '' + userUsername: options.userUsername || '', + icon: icon || '' // 新增:图标(base64编码) } // 保存API Key数据并建立哈希映射 diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index ee67b0ad..354e5df3 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -3,8 +3,8 @@ const crypto = require('crypto') const ProxyHelper = require('../utils/proxyHelper') const axios = require('axios') const redis = require('../models/redis') -const logger = require('../utils/logger') const config = require('../../config/config') +const logger = require('../utils/logger') const { maskToken } = require('../utils/tokenMask') const { logRefreshStart, @@ -707,6 +707,8 @@ class ClaudeAccountService { // 验证映射的账户是否仍然可用 const mappedAccount = activeAccounts.find((acc) => acc.id === mappedAccountId) if (mappedAccount) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}` ) @@ -733,7 +735,9 @@ class ClaudeAccountService { // 如果有会话哈希,建立新的映射 if (sessionHash) { - await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期 + // 从配置获取TTL(小时),转换为秒 + const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60 + await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds) logger.info( `🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}` ) @@ -827,6 +831,8 @@ class ClaudeAccountService { ) await redis.deleteSessionAccountMapping(sessionHash) } else { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}` ) @@ -885,7 +891,9 @@ class ClaudeAccountService { // 如果有会话哈希,建立新的映射 if (sessionHash) { - await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期 + // 从配置获取TTL(小时),转换为秒 + const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60 + await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds) logger.info( `🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}` ) diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index eed4e365..79123b89 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -211,19 +211,7 @@ class ClaudeRelayService { // 检查是否为5xx状态码 else if (response.statusCode >= 500 && response.statusCode < 600) { logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`) - // 记录5xx错误 - await claudeAccountService.recordServerError(accountId, response.statusCode) - // 检查是否需要标记为临时错误状态(连续3次500) - const errorCount = await claudeAccountService.getServerErrorCount(accountId) - logger.info( - `🔥 Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes` - ) - if (errorCount > 10) { - logger.error( - `❌ Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error` - ) - await claudeAccountService.markAccountTempError(accountId, sessionHash) - } + await this._handleServerError(accountId, response.statusCode, sessionHash) } // 检查是否为429状态码 else if (response.statusCode === 429) { @@ -764,7 +752,7 @@ class ClaudeRelayService { onRequest(req) } - req.on('error', (error) => { + req.on('error', async (error) => { console.error(': ❌ ', error) logger.error('❌ Claude API request error:', error.message, { code: error.code, @@ -784,14 +772,19 @@ class ClaudeRelayService { errorMessage = 'Connection refused by Claude API server' } else if (error.code === 'ETIMEDOUT') { errorMessage = 'Connection timed out to Claude API server' + + await this._handleServerError(accountId, 504, null, 'Network') } reject(new Error(errorMessage)) }) - req.on('timeout', () => { + req.on('timeout', async () => { req.destroy() logger.error('❌ Claude API request timeout') + + await this._handleServerError(accountId, 504, null, 'Request') + reject(new Error('Request timeout')) }) @@ -1013,19 +1006,7 @@ class ClaudeRelayService { logger.warn( `🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}` ) - // 记录5xx错误 - await claudeAccountService.recordServerError(accountId, res.statusCode) - // 检查是否需要标记为临时错误状态(连续3次500) - const errorCount = await claudeAccountService.getServerErrorCount(accountId) - logger.info( - `🔥 [Stream] Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes` - ) - if (errorCount > 10) { - logger.error( - `❌ [Stream] Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error` - ) - await claudeAccountService.markAccountTempError(accountId, sessionHash) - } + await this._handleServerError(accountId, res.statusCode, sessionHash, '[Stream]') } } @@ -1361,7 +1342,7 @@ class ClaudeRelayService { }) }) - req.on('error', (error) => { + req.on('error', async (error) => { logger.error('❌ Claude stream request error:', error.message, { code: error.code, errno: error.errno, @@ -1408,9 +1389,10 @@ class ClaudeRelayService { reject(error) }) - req.on('timeout', () => { + req.on('timeout', async () => { req.destroy() logger.error('❌ Claude stream request timeout') + if (!responseStream.headersSent) { responseStream.writeHead(504, { 'Content-Type': 'text/event-stream', @@ -1510,7 +1492,7 @@ class ClaudeRelayService { }) }) - req.on('error', (error) => { + req.on('error', async (error) => { logger.error('❌ Claude stream request error:', error.message, { code: error.code, errno: error.errno, @@ -1557,9 +1539,10 @@ class ClaudeRelayService { reject(error) }) - req.on('timeout', () => { + req.on('timeout', async () => { req.destroy() logger.error('❌ Claude stream request timeout') + if (!responseStream.headersSent) { responseStream.writeHead(504, { 'Content-Type': 'text/event-stream', @@ -1596,6 +1579,33 @@ class ClaudeRelayService { }) } + // 🛠️ 统一的错误处理方法 + async _handleServerError(accountId, statusCode, sessionHash = null, context = '') { + try { + await claudeAccountService.recordServerError(accountId, statusCode) + const errorCount = await claudeAccountService.getServerErrorCount(accountId) + + // 根据错误类型设置不同的阈值和日志前缀 + const isTimeout = statusCode === 504 + const threshold = 3 // 统一使用3次阈值 + const prefix = context ? `${context} ` : '' + + logger.warn( + `⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}` + ) + + if (errorCount > threshold) { + const errorTypeLabel = isTimeout ? 'timeout' : '5xx' + logger.error( + `❌ ${prefix}Account ${accountId} exceeded ${errorTypeLabel} error threshold (${errorCount} errors), marking as temp_error` + ) + await claudeAccountService.markAccountTempError(accountId, sessionHash) + } + } catch (handlingError) { + logger.error(`❌ Failed to handle ${context} server error:`, handlingError) + } + } + // 🔄 重试逻辑 async _retryRequest(requestFunc, maxRetries = 3) { let lastError diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index c373d1f5..7bceb040 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -177,6 +177,8 @@ class UnifiedClaudeScheduler { requestedModel ) if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) @@ -789,6 +791,8 @@ class UnifiedClaudeScheduler { requestedModel ) if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) diff --git a/src/services/unifiedGeminiScheduler.js b/src/services/unifiedGeminiScheduler.js index face2c81..27c2387f 100644 --- a/src/services/unifiedGeminiScheduler.js +++ b/src/services/unifiedGeminiScheduler.js @@ -61,6 +61,8 @@ class UnifiedGeminiScheduler { mappedAccount.accountType ) if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) @@ -382,6 +384,8 @@ class UnifiedGeminiScheduler { mappedAccount.accountType ) if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) diff --git a/src/services/unifiedOpenAIScheduler.js b/src/services/unifiedOpenAIScheduler.js index cbc7a8ff..99c21f32 100644 --- a/src/services/unifiedOpenAIScheduler.js +++ b/src/services/unifiedOpenAIScheduler.js @@ -90,6 +90,8 @@ class UnifiedOpenAIScheduler { mappedAccount.accountType ) if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) @@ -406,6 +408,8 @@ class UnifiedOpenAIScheduler { mappedAccount.accountType ) if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})` ) diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json index 30b46f1e..c9726bcd 100644 --- a/web/admin-spa/package-lock.json +++ b/web/admin-spa/package-lock.json @@ -15,7 +15,9 @@ "element-plus": "^2.4.4", "pinia": "^2.1.7", "vue": "^3.3.4", - "vue-router": "^4.2.5" + "vue-router": "^4.2.5", + "xlsx": "^0.18.5", + "xlsx-js-style": "^1.2.0" }, "devDependencies": { "@playwright/test": "^1.55.0", @@ -1366,6 +1368,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", @@ -1643,6 +1654,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", @@ -1710,6 +1734,15 @@ "node": ">= 6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", @@ -1766,6 +1799,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2304,6 +2349,15 @@ "node": ">=0.10.0" } }, + "node_modules/exit-on-epipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", + "integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/exsolve": { "version": "1.0.7", "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz", @@ -2379,6 +2433,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz", + "integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2497,6 +2557,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz", @@ -3804,6 +3873,18 @@ } } }, + "node_modules/printj": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz", + "integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==", + "license": "Apache-2.0", + "bin": { + "printj": "bin/printj.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4083,6 +4164,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", @@ -5126,6 +5219,24 @@ "node": ">= 8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5244,6 +5355,95 @@ "dev": true, "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz", + "integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.2.0", + "cfb": "^1.1.4", + "codepage": "~1.14.0", + "commander": "~2.17.1", + "crc-32": "~1.2.0", + "exit-on-epipe": "~1.0.1", + "fflate": "^0.3.8", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/adler-32": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz", + "integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==", + "license": "Apache-2.0", + "dependencies": { + "exit-on-epipe": "~1.0.1", + "printj": "~1.1.0" + }, + "bin": { + "adler32": "bin/adler32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/codepage": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz", + "integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==", + "license": "Apache-2.0", + "dependencies": { + "commander": "~2.14.1", + "exit-on-epipe": "~1.0.1" + }, + "bin": { + "codepage": "bin/codepage.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/codepage/node_modules/commander": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz", + "integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==", + "license": "MIT" + }, + "node_modules/xlsx-js-style/node_modules/commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "license": "MIT" + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz", diff --git a/web/admin-spa/package.json b/web/admin-spa/package.json index 4881e1b3..af353d80 100644 --- a/web/admin-spa/package.json +++ b/web/admin-spa/package.json @@ -18,7 +18,9 @@ "element-plus": "^2.4.4", "pinia": "^2.1.7", "vue": "^3.3.4", - "vue-router": "^4.2.5" + "vue-router": "^4.2.5", + "xlsx": "^0.18.5", + "xlsx-js-style": "^1.2.0" }, "devDependencies": { "@playwright/test": "^1.55.0", diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue index 7c19cdfc..4e78520f 100644 --- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue @@ -110,19 +110,21 @@ class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm" >名称 * - +
{{ errors.name }}
diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 07d9eaa5..fda19ade 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -32,14 +32,16 @@ class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm" >名称 - +用于识别此 API Key 的用途
diff --git a/web/admin-spa/src/components/apikeys/UsageDetailModal.vue b/web/admin-spa/src/components/apikeys/UsageDetailModal.vue index 54593e11..93527223 100644 --- a/web/admin-spa/src/components/apikeys/UsageDetailModal.vue +++ b/web/admin-spa/src/components/apikeys/UsageDetailModal.vue @@ -6,7 +6,7 @@ @@ -961,17 +1039,13 @@