From 4cc937a144ba3dca35cc406a9d26a16ee2fee4c9 Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 5 Sep 2025 14:58:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(Claude=20Console):=20=E6=B7=BB=E5=8A=A0Cla?= =?UTF-8?q?ude=20Console=E8=B4=A6=E5=8F=B7=E6=AF=8F=E6=97=A5=E9=85=8D?= =?UTF-8?q?=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 额度检查优先级更高:即使不启用限流机制,超额仍会禁用账户 2. 状态会被覆盖:quota_exceeded 会覆盖 rate_limited 3. 两种恢复时间: - 限流恢复:分钟级(如60分钟) - 额度恢复:天级(第二天重置) 4. 独立控制: - rateLimitDuration = 0:只管理额度,忽略429 - rateLimitDuration > 0:同时管理限流和额度 --- src/routes/admin.js | 58 ++- src/services/claudeConsoleAccountService.js | 339 +++++++++++++++++- src/services/claudeConsoleRelayService.js | 13 + src/services/unifiedClaudeScheduler.js | 39 +- .../src/components/accounts/AccountForm.vue | 168 ++++++++- web/admin-spa/src/views/AccountsView.vue | 61 ++++ 6 files changed, 656 insertions(+), 22 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index f29a43b9..62540a8c 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -2292,7 +2292,9 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => { rateLimitDuration, proxy, accountType, - groupId + groupId, + dailyQuota, + quotaResetTime } = req.body if (!name || !apiUrl || !apiKey) { @@ -2327,7 +2329,9 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => { rateLimitDuration: rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60, proxy, - accountType: accountType || 'shared' + accountType: accountType || 'shared', + dailyQuota: dailyQuota || 0, + quotaResetTime: quotaResetTime || '00:00' }) // 如果是分组类型,将账户添加到分组 @@ -2506,6 +2510,56 @@ router.put( } ) +// 获取Claude Console账户的使用统计 +router.get('/claude-console-accounts/:accountId/usage', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const usageStats = await claudeConsoleAccountService.getAccountUsageStats(accountId) + + if (!usageStats) { + return res.status(404).json({ error: 'Account not found' }) + } + + return res.json(usageStats) + } catch (error) { + logger.error('❌ Failed to get Claude Console account usage stats:', error) + return res.status(500).json({ error: 'Failed to get usage stats', message: error.message }) + } +}) + +// 手动重置Claude Console账户的每日使用量 +router.post( + '/claude-console-accounts/:accountId/reset-usage', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + await claudeConsoleAccountService.resetDailyUsage(accountId) + + logger.success(`✅ Admin manually reset daily usage for Claude Console account: ${accountId}`) + return res.json({ success: true, message: 'Daily usage reset successfully' }) + } catch (error) { + logger.error('❌ Failed to reset Claude Console account daily usage:', error) + return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message }) + } + } +) + +// 手动重置所有Claude Console账户的每日使用量 +router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => { + try { + await claudeConsoleAccountService.resetAllDailyUsage() + + logger.success('✅ Admin manually reset daily usage for all Claude Console accounts') + return res.json({ success: true, message: 'All daily usage reset successfully' }) + } catch (error) { + logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error) + return res + .status(500) + .json({ error: 'Failed to reset all daily usage', message: error.message }) + } +}) + // ☁️ Bedrock 账户管理 // 获取所有Bedrock账户 diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index 28be976d..34c9a5c7 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -50,7 +50,9 @@ class ClaudeConsoleAccountService { proxy = null, isActive = true, accountType = 'shared', // 'dedicated' or 'shared' - schedulable = true // 是否可被调度 + schedulable = true, // 是否可被调度 + dailyQuota = 0, // 每日额度限制(美元),0表示不限制 + quotaResetTime = '00:00' // 额度重置时间(HH:mm格式) } = options // 验证必填字段 @@ -85,7 +87,14 @@ class ClaudeConsoleAccountService { rateLimitedAt: '', rateLimitStatus: '', // 调度控制 - schedulable: schedulable.toString() + schedulable: schedulable.toString(), + // 额度管理相关 + dailyQuota: dailyQuota.toString(), // 每日额度限制(美元) + dailyUsage: '0', // 当日使用金额(美元) + // 使用与统计一致的时区日期,避免边界问题 + lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区) + quotaResetTime, // 额度重置时间 + quotaStoppedAt: '' // 因额度停用的时间 } const client = redis.getClientSafe() @@ -116,7 +125,12 @@ class ClaudeConsoleAccountService { proxy, accountType, status: 'active', - createdAt: accountData.createdAt + createdAt: accountData.createdAt, + dailyQuota, + dailyUsage: 0, + lastResetDate: accountData.lastResetDate, + quotaResetTime, + quotaStoppedAt: null } } @@ -148,12 +162,18 @@ class ClaudeConsoleAccountService { isActive: accountData.isActive === 'true', proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null, accountType: accountData.accountType || 'shared', - status: accountData.status, - errorMessage: accountData.errorMessage, createdAt: accountData.createdAt, lastUsedAt: accountData.lastUsedAt, - rateLimitStatus: rateLimitInfo, - schedulable: accountData.schedulable !== 'false' // 默认为true,只有明确设置为false才不可调度 + status: accountData.status || 'active', + errorMessage: accountData.errorMessage, + rateLimitInfo, + schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度 + // 额度管理相关 + dailyQuota: parseFloat(accountData.dailyQuota || '0'), + dailyUsage: parseFloat(accountData.dailyUsage || '0'), + lastResetDate: accountData.lastResetDate || '', + quotaResetTime: accountData.quotaResetTime || '00:00', + quotaStoppedAt: accountData.quotaStoppedAt || null }) } } @@ -267,6 +287,23 @@ class ClaudeConsoleAccountService { updatedData.schedulable = updates.schedulable.toString() } + // 额度管理相关字段 + if (updates.dailyQuota !== undefined) { + updatedData.dailyQuota = updates.dailyQuota.toString() + } + if (updates.quotaResetTime !== undefined) { + updatedData.quotaResetTime = updates.quotaResetTime + } + if (updates.dailyUsage !== undefined) { + updatedData.dailyUsage = updates.dailyUsage.toString() + } + if (updates.lastResetDate !== undefined) { + updatedData.lastResetDate = updates.lastResetDate + } + if (updates.quotaStoppedAt !== undefined) { + updatedData.quotaStoppedAt = updates.quotaStoppedAt + } + // 处理账户类型变更 if (updates.accountType && updates.accountType !== existingAccount.accountType) { updatedData.accountType = updates.accountType @@ -361,7 +398,16 @@ class ClaudeConsoleAccountService { const updates = { rateLimitedAt: new Date().toISOString(), - rateLimitStatus: 'limited' + rateLimitStatus: 'limited', + isActive: 'false', // 禁用账户 + errorMessage: `Rate limited at ${new Date().toISOString()}` + } + + // 只有当前状态不是quota_exceeded时才设置为rate_limited + // 避免覆盖更重要的配额超限状态 + const currentStatus = await client.hget(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'status') + if (currentStatus !== 'quota_exceeded') { + updates.status = 'rate_limited' } await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) @@ -376,7 +422,7 @@ class ClaudeConsoleAccountService { platform: 'claude-console', status: 'error', errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED', - reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`, + reason: `Account rate limited (429 error) and has been disabled. ${account.rateLimitDuration ? `Will be automatically re-enabled after ${account.rateLimitDuration} minutes` : 'Manual intervention required to re-enable'}`, timestamp: getISOStringWithTimezone(new Date()) }) } catch (webhookError) { @@ -397,14 +443,40 @@ class ClaudeConsoleAccountService { async removeAccountRateLimit(accountId) { try { const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` - await client.hdel( - `${this.ACCOUNT_KEY_PREFIX}${accountId}`, - 'rateLimitedAt', - 'rateLimitStatus' + // 获取账户当前状态和额度信息 + const [currentStatus, quotaStoppedAt] = await client.hmget( + accountKey, + 'status', + 'quotaStoppedAt' ) - logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`) + // 删除限流相关字段 + await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus') + + // 根据不同情况决定是否恢复账户 + if (currentStatus === 'rate_limited') { + if (quotaStoppedAt) { + // 还有额度限制,改为quota_exceeded状态 + await client.hset(accountKey, { + status: 'quota_exceeded' + // isActive保持false + }) + logger.info(`⚠️ Rate limit removed but quota exceeded remains for account: ${accountId}`) + } else { + // 没有额度限制,完全恢复 + await client.hset(accountKey, { + isActive: 'true', + status: 'active', + errorMessage: '' + }) + logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`) + } + } else { + logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`) + } + return { success: true } } catch (error) { logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error) @@ -454,6 +526,64 @@ class ClaudeConsoleAccountService { } } + // 🔍 检查账号是否因额度超限而被停用(懒惰检查) + async isAccountQuotaExceeded(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + // 如果没有设置额度限制,不会超额 + const dailyQuota = parseFloat(account.dailyQuota || '0') + if (isNaN(dailyQuota) || dailyQuota <= 0) { + return false + } + + // 如果账户没有被额度停用,检查当前使用情况 + if (!account.quotaStoppedAt) { + return false + } + + // 检查是否应该重置额度(到了新的重置时间点) + if (this._shouldResetQuota(account)) { + await this.resetDailyUsage(accountId) + return false + } + + // 仍在额度超限状态 + return true + } catch (error) { + logger.error( + `❌ Failed to check quota exceeded status for Claude Console account: ${accountId}`, + error + ) + return false + } + } + + // 🔍 判断是否应该重置账户额度 + _shouldResetQuota(account) { + // 与 Redis 统计一致:按配置时区判断“今天”与时间点 + const tzNow = redis.getDateInTimezone(new Date()) + const today = redis.getDateStringInTimezone(tzNow) + + // 如果已经是今天重置过的,不需要重置 + if (account.lastResetDate === today) { + return false + } + + // 检查是否到了重置时间点(按配置时区的小时/分钟) + const resetTime = account.quotaResetTime || '00:00' + const [resetHour, resetMinute] = resetTime.split(':').map((n) => parseInt(n)) + + const currentHour = tzNow.getUTCHours() + const currentMinute = tzNow.getUTCMinutes() + + // 如果当前时间已过重置时间且不是同一天重置的,应该重置 + return currentHour > resetHour || (currentHour === resetHour && currentMinute >= resetMinute) + } + // 🚫 标记账号为未授权状态(401错误) async markAccountUnauthorized(accountId) { try { @@ -820,6 +950,187 @@ class ClaudeConsoleAccountService { // 返回映射后的模型,如果不存在则返回原模型 return modelMapping[requestedModel] || requestedModel } + + // 💰 检查账户使用额度(基于实时统计数据) + async checkQuotaUsage(accountId) { + try { + // 获取实时的使用统计(包含费用) + const usageStats = await redis.getAccountUsageStats(accountId) + const currentDailyCost = usageStats.daily.cost || 0 + + // 获取账户配置 + const accountData = await this.getAccount(accountId) + if (!accountData) { + logger.warn(`Account not found: ${accountId}`) + return + } + + // 解析额度配置,确保数值有效 + const dailyQuota = parseFloat(accountData.dailyQuota || '0') + if (isNaN(dailyQuota) || dailyQuota <= 0) { + // 没有设置有效额度,无需检查 + return + } + + // 检查是否已经因额度停用(避免重复操作) + if (!accountData.isActive && accountData.quotaStoppedAt) { + return + } + + // 检查是否超过额度限制 + if (currentDailyCost >= dailyQuota) { + // 使用原子操作避免竞态条件 - 再次检查是否已设置quotaStoppedAt + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // double-check locking pattern - 检查quotaStoppedAt而不是status + const existingQuotaStop = await client.hget(accountKey, 'quotaStoppedAt') + if (existingQuotaStop) { + return // 已经被其他进程处理 + } + + // 超过额度,停用账户 + const updates = { + isActive: false, + quotaStoppedAt: new Date().toISOString(), + errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` + } + + // 只有当前状态是active时才改为quota_exceeded + // 如果是rate_limited等其他状态,保持原状态不变 + const currentStatus = await client.hget(accountKey, 'status') + if (currentStatus === 'active') { + updates.status = 'quota_exceeded' + } + + await this.updateAccount(accountId, updates) + + logger.warn( + `💰 Account ${accountId} exceeded daily quota: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` + ) + + // 发送webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Unknown Account', + platform: 'claude-console', + status: 'quota_exceeded', + errorCode: 'CLAUDE_CONSOLE_QUOTA_EXCEEDED', + reason: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification for quota exceeded:', webhookError) + } + } + + logger.debug( + `💰 Quota check for account ${accountId}: $${currentDailyCost.toFixed(4)} / $${dailyQuota.toFixed(2)}` + ) + } catch (error) { + logger.error('Failed to check quota usage:', error) + } + } + + // 🔄 重置账户每日使用量(恢复因额度停用的账户) + async resetDailyUsage(accountId) { + try { + const accountData = await this.getAccount(accountId) + if (!accountData) { + return + } + + const today = redis.getDateStringInTimezone() + const updates = { + lastResetDate: today + } + + // 如果账户是因为超额被停用的,恢复账户 + // 注意:状态可能是 quota_exceeded 或 rate_limited(如果429错误时也超额了) + if ( + accountData.quotaStoppedAt && + accountData.isActive === false && + (accountData.status === 'quota_exceeded' || accountData.status === 'rate_limited') + ) { + updates.isActive = true + updates.status = 'active' + updates.errorMessage = '' + updates.quotaStoppedAt = '' + + // 如果是rate_limited状态,也清除限流相关字段 + if (accountData.status === 'rate_limited') { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus') + } + + logger.info( + `✅ Restored account ${accountId} after daily reset (was ${accountData.status})` + ) + } + + await this.updateAccount(accountId, updates) + + logger.debug(`🔄 Reset daily usage for account ${accountId}`) + } catch (error) { + logger.error('Failed to reset daily usage:', error) + } + } + + // 🔄 重置所有账户的每日使用量 + async resetAllDailyUsage() { + try { + const accounts = await this.getAllAccounts() + // 与统计一致使用配置时区日期 + const today = redis.getDateStringInTimezone() + let resetCount = 0 + + for (const account of accounts) { + // 只重置需要重置的账户 + if (account.lastResetDate !== today) { + await this.resetDailyUsage(account.id) + resetCount += 1 + } + } + + logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`) + } catch (error) { + logger.error('Failed to reset all daily usage:', error) + } + } + + // 📊 获取账户使用统计(基于实时数据) + async getAccountUsageStats(accountId) { + try { + // 获取实时的使用统计(包含费用) + const usageStats = await redis.getAccountUsageStats(accountId) + const currentDailyCost = usageStats.daily.cost || 0 + + // 获取账户配置 + const accountData = await this.getAccount(accountId) + if (!accountData) { + return null + } + + const dailyQuota = parseFloat(accountData.dailyQuota || '0') + + return { + dailyQuota, + dailyUsage: currentDailyCost, // 使用实时计算的费用 + remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null, + usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0, + lastResetDate: accountData.lastResetDate, + quotaStoppedAt: accountData.quotaStoppedAt, + isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota, + // 额外返回完整的使用统计 + fullUsageStats: usageStats + } + } catch (error) { + logger.error('Failed to get account usage stats:', error) + return null + } + } } module.exports = new ClaudeConsoleAccountService() diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index 27920a47..9785bdd0 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -181,6 +181,11 @@ class ClaudeConsoleRelayService { await claudeConsoleAccountService.markAccountUnauthorized(accountId) } else if (response.status === 429) { logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`) + // 收到429先检查是否因为超过了手动配置的每日额度 + await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + await claudeConsoleAccountService.markAccountRateLimited(accountId) } else if (response.status === 529) { logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`) @@ -377,6 +382,10 @@ class ClaudeConsoleRelayService { claudeConsoleAccountService.markAccountUnauthorized(accountId) } else if (response.status === 429) { claudeConsoleAccountService.markAccountRateLimited(accountId) + // 检查是否因为超过每日额度 + claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) } else if (response.status === 529) { claudeConsoleAccountService.markAccountOverloaded(accountId) } @@ -589,6 +598,10 @@ class ClaudeConsoleRelayService { claudeConsoleAccountService.markAccountUnauthorized(accountId) } else if (error.response.status === 429) { claudeConsoleAccountService.markAccountRateLimited(accountId) + // 检查是否因为超过每日额度 + claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) } else if (error.response.status === 529) { claudeConsoleAccountService.markAccountOverloaded(accountId) } diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 34e61a81..8b247d97 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -209,10 +209,20 @@ class UnifiedClaudeScheduler { boundConsoleAccount.isActive === true && boundConsoleAccount.status === 'active' ) { + // 主动触发一次额度检查 + try { + await claudeConsoleAccountService.checkQuotaUsage(boundConsoleAccount.id) + } catch (e) {} + + // 检查限流状态和额度状态 const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited( boundConsoleAccount.id ) - if (!isRateLimited) { + const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded( + boundConsoleAccount.id + ) + + if (!isRateLimited && !isQuotaExceeded) { logger.info( `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})` ) @@ -358,9 +368,16 @@ class UnifiedClaudeScheduler { } } - // 检查是否被限流 + // 主动触发一次额度检查,确保状态即时生效 + try { + await claudeConsoleAccountService.checkQuotaUsage(account.id) + } catch (e) {} + + // 检查是否被限流或额度超限 const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id) - if (!isRateLimited) { + const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(account.id) + + if (!isRateLimited && !isQuotaExceeded) { availableAccounts.push({ ...account, accountId: account.id, @@ -372,7 +389,12 @@ class UnifiedClaudeScheduler { `✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})` ) } else { - logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`) + if (isRateLimited) { + logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`) + } + if (isQuotaExceeded) { + logger.warn(`💰 Claude Console account ${account.name} quota exceeded`) + } } } else { logger.info( @@ -475,10 +497,17 @@ class UnifiedClaudeScheduler { logger.info(`🚫 Claude Console account ${accountId} is not schedulable`) return false } - // 检查是否被限流 + // 主动触发一次额度检查 + try { + await claudeConsoleAccountService.checkQuotaUsage(accountId) + } catch (e) {} + // 检查是否被限流或额度超限 if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) { return false } + if (await claudeConsoleAccountService.isAccountQuotaExceeded(accountId)) { + return false + } // 检查是否未授权(401错误) if (account.status === 'unauthorized') { return false diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 19182c8b..0d4c3226 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -658,6 +658,41 @@

+ +
+
+ + +

+ 设置每日使用额度,0 表示不限制 +

+
+ +
+ + +

+ 每日自动重置额度的时间 +

+
+
+
留空表示不更新 API Key

+ +
+
+ + +

+ 设置每日使用额度,0 表示不限制 +

+
+ +
+ + +

每日自动重置额度的时间

+
+
+ + +
+
+ + 今日使用情况 + + + ${{ calculateCurrentUsage().toFixed(4) }} / ${{ form.dailyQuota.toFixed(2) }} + +
+
+
+
+
+ + 剩余: ${{ Math.max(0, form.dailyQuota - calculateCurrentUsage()).toFixed(2) }} + + + {{ usagePercentage.toFixed(1) }}% 已使用 + +
+
+
0 : true, rateLimitDuration: props.account?.rateLimitDuration || 60, + // 额度管理字段 + dailyQuota: props.account?.dailyQuota || 0, + dailyUsage: props.account?.dailyUsage || 0, + quotaResetTime: props.account?.quotaResetTime || '00:00', // Bedrock 特定字段 accessKeyId: props.account?.accessKeyId || '', secretAccessKey: props.account?.secretAccessKey || '', @@ -2162,6 +2270,45 @@ const canExchangeSetupToken = computed(() => { return setupTokenAuthUrl.value && setupTokenAuthCode.value.trim() }) +// 获取当前使用量(实时) +const calculateCurrentUsage = () => { + // 如果不是编辑模式或没有账户ID,返回0 + if (!isEdit.value || !props.account?.id) { + return 0 + } + + // 如果已经加载了今日使用数据,直接使用 + if (typeof form.value.dailyUsage === 'number') { + return form.value.dailyUsage + } + + return 0 +} + +// 计算额度使用百分比 +const usagePercentage = computed(() => { + if (!form.value.dailyQuota || form.value.dailyQuota <= 0) { + return 0 + } + const currentUsage = calculateCurrentUsage() + return (currentUsage / form.value.dailyQuota) * 100 +}) + +// 加载账户今日使用情况 +const loadAccountUsage = async () => { + if (!isEdit.value || !props.account?.id) return + + try { + const response = await apiClient.get(`/admin/claude-console-accounts/${props.account.id}/usage`) + if (response) { + // 更新表单中的使用量数据 + form.value.dailyUsage = response.dailyUsage || 0 + } + } catch (error) { + console.warn('Failed to load account usage:', error) + } +} + // // 计算是否可以创建 // const canCreate = computed(() => { // if (form.value.addType === 'manual') { @@ -2601,6 +2748,9 @@ const createAccount = async () => { data.userAgent = form.value.userAgent || null // 如果不启用限流,传递 0 表示不限流 data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 + // 额度管理字段 + data.dailyQuota = form.value.dailyQuota || 0 + data.quotaResetTime = form.value.quotaResetTime || '00:00' } else if (form.value.platform === 'bedrock') { // Bedrock 账户特定数据 - 构造 awsCredentials 对象 data.awsCredentials = { @@ -2798,6 +2948,9 @@ const updateAccount = async () => { data.userAgent = form.value.userAgent || null // 如果不启用限流,传递 0 表示不限流 data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 + // 额度管理字段 + data.dailyQuota = form.value.dailyQuota || 0 + data.quotaResetTime = form.value.quotaResetTime || '00:00' } // Bedrock 特定更新 @@ -3207,7 +3360,16 @@ watch( // Azure OpenAI 特定字段 azureEndpoint: newAccount.azureEndpoint || '', apiVersion: newAccount.apiVersion || '', - deploymentName: newAccount.deploymentName || '' + deploymentName: newAccount.deploymentName || '', + // 额度管理字段 + dailyQuota: newAccount.dailyQuota || 0, + dailyUsage: newAccount.dailyUsage || 0, + quotaResetTime: newAccount.quotaResetTime || '00:00' + } + + // 如果是Claude Console账户,加载实时使用情况 + if (newAccount.platform === 'claude-console') { + loadAccountUsage() } // 如果是分组类型,加载分组ID @@ -3287,6 +3449,10 @@ const clearUnifiedCache = async () => { onMounted(() => { // 获取Claude Code统一User-Agent信息 fetchUnifiedUserAgent() + // 如果是编辑模式且是Claude Console账户,加载使用情况 + if (isEdit.value && props.account?.platform === 'claude-console') { + loadAccountUsage() + } }) // 监听平台变化,当切换到Claude平台时获取统一User-Agent信息 diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 01fae1c5..9c2ccb7e 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -584,6 +584,44 @@
+ +
+
+
+ 额度进度 + + {{ getQuotaUsagePercent(account).toFixed(1) }}% + +
+
+
+
+
+ + ${{ formatCost(account.usage?.daily?.cost || 0) }} / ${{ + Number(account.dailyQuota).toFixed(2) + }} + +
+
+ 剩余 ${{ formatRemainingQuota(account) }} + 重置 {{ account.quotaResetTime || '00:00' }} +
+
+
+ +
+
@@ -1788,6 +1826,29 @@ const formatCost = (cost) => { return cost.toFixed(2) } +// 额度使用百分比(Claude Console) +const getQuotaUsagePercent = (account) => { + const used = Number(account?.usage?.daily?.cost || 0) + const quota = Number(account?.dailyQuota || 0) + if (!quota || quota <= 0) return 0 + return (used / quota) * 100 +} + +// 额度进度条颜色(Claude Console) +const getQuotaBarClass = (percent) => { + if (percent >= 90) return 'bg-red-500' + if (percent >= 70) return 'bg-yellow-500' + return 'bg-green-500' +} + +// 剩余额度(Claude Console) +const formatRemainingQuota = (account) => { + const used = Number(account?.usage?.daily?.cost || 0) + const quota = Number(account?.dailyQuota || 0) + if (!quota || quota <= 0) return '0.00' + return Math.max(0, quota - used).toFixed(2) +} + // 计算每日费用(使用后端返回的精确费用数据) const calculateDailyCost = (account) => { if (!account.usage || !account.usage.daily) return '0.0000'