diff --git a/README.md b/README.md index 17c7b7fe..95a47a55 100644 --- a/README.md +++ b/README.md @@ -411,11 +411,10 @@ export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" **Gemini CLI 设置环境变量:** ```bash -export CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名 -export GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥" # 使用相同的API密钥即可 -export GOOGLE_GENAI_USE_GCA="true" +GEMINI_MODEL="gemini-2.5-pro" +GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名 +GEMINI_API_KEY="后台创建的API密钥" # 使用相同的API密钥即可 ``` - **使用 Claude Code:** ```bash diff --git a/VERSION b/VERSION index 5b7cfb35..01793f68 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.172 +1.1.175 diff --git a/src/middleware/auth.js b/src/middleware/auth.js index b18b5322..378f1565 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -65,6 +65,44 @@ const TOKEN_COUNT_PATHS = new Set([ '/droid/claude/v1/messages/count_tokens' ]) +function extractApiKey(req) { + const candidates = [ + req.headers['x-api-key'], + req.headers['x-goog-api-key'], + req.headers['authorization'], + req.headers['api-key'], + req.query?.key + ] + + for (const candidate of candidates) { + let value = candidate + + if (Array.isArray(value)) { + value = value.find((item) => typeof item === 'string' && item.trim()) + } + + if (typeof value !== 'string') { + continue + } + + let trimmed = value.trim() + if (!trimmed) { + continue + } + + if (/^Bearer\s+/i.test(trimmed)) { + trimmed = trimmed.replace(/^Bearer\s+/i, '').trim() + if (!trimmed) { + continue + } + } + + return trimmed + } + + return '' +} + function normalizeRequestPath(value) { if (!value) { return '/' @@ -95,18 +133,18 @@ const authenticateApiKey = async (req, res, next) => { try { // 安全提取API Key,支持多种格式(包括Gemini CLI支持) - const apiKey = - req.headers['x-api-key'] || - req.headers['x-goog-api-key'] || - req.headers['authorization']?.replace(/^Bearer\s+/i, '') || - req.headers['api-key'] || - req.query.key + const apiKey = extractApiKey(req) + + if (apiKey) { + req.headers['x-api-key'] = apiKey + } if (!apiKey) { logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`) return res.status(401).json({ error: 'Missing API key', - message: 'Please provide an API key in the x-api-key header or Authorization header' + message: + 'Please provide an API key in the x-api-key, x-goog-api-key, or Authorization header' }) } @@ -950,6 +988,7 @@ const corsMiddleware = (req, res, next) => { 'Accept', 'Authorization', 'x-api-key', + 'x-goog-api-key', 'api-key', 'x-admin-token', 'anthropic-version', diff --git a/src/middleware/browserFallback.js b/src/middleware/browserFallback.js index df81ae38..ed82532c 100644 --- a/src/middleware/browserFallback.js +++ b/src/middleware/browserFallback.js @@ -7,12 +7,38 @@ const logger = require('../utils/logger') const browserFallbackMiddleware = (req, res, next) => { const userAgent = req.headers['user-agent'] || '' const origin = req.headers['origin'] || '' - const authHeader = req.headers['authorization'] || req.headers['x-api-key'] || '' + + const extractHeader = (value) => { + let candidate = value + + if (Array.isArray(candidate)) { + candidate = candidate.find((item) => typeof item === 'string' && item.trim()) + } + + if (typeof candidate !== 'string') { + return '' + } + + let trimmed = candidate.trim() + if (!trimmed) { + return '' + } + + if (/^Bearer\s+/i.test(trimmed)) { + trimmed = trimmed.replace(/^Bearer\s+/i, '').trim() + } + + return trimmed + } + + const apiKeyHeader = + extractHeader(req.headers['x-api-key']) || extractHeader(req.headers['x-goog-api-key']) + const normalizedKey = extractHeader(req.headers['authorization']) || apiKeyHeader // 检查是否为Chrome插件或浏览器请求 const isChromeExtension = origin.startsWith('chrome-extension://') const isBrowserRequest = userAgent.includes('Mozilla/') && userAgent.includes('Chrome/') - const hasApiKey = authHeader.startsWith('cr_') // 我们的API Key格式 + const hasApiKey = normalizedKey.startsWith('cr_') // 我们的API Key格式 if ((isChromeExtension || isBrowserRequest) && hasApiKey) { // 为Chrome插件请求添加特殊标记 @@ -23,8 +49,8 @@ const browserFallbackMiddleware = (req, res, next) => { req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)' // 确保设置正确的认证头 - if (!req.headers['authorization'] && req.headers['x-api-key']) { - req.headers['authorization'] = `Bearer ${req.headers['x-api-key']}` + if (!req.headers['authorization'] && apiKeyHeader) { + req.headers['authorization'] = `Bearer ${apiKeyHeader}` } // 添加必要的Anthropic头 diff --git a/src/routes/admin.js b/src/routes/admin.js index fc212c87..fe49e72e 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -9058,6 +9058,109 @@ router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (r } }) +// 获取单个 Droid 账户详细信息 +router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + // 获取账户基本信息 + const account = await droidAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + error: 'Not Found', + message: 'Droid account not found' + }) + } + + // 获取使用统计信息 + let usageStats + try { + usageStats = await redis.getAccountUsageStats(account.id, 'droid') + } catch (error) { + logger.debug(`Failed to get usage stats for Droid account ${account.id}:`, error) + usageStats = { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + + // 获取分组信息 + let groupInfos = [] + try { + groupInfos = await accountGroupService.getAccountGroups(account.id) + } catch (error) { + logger.debug(`Failed to get group infos for Droid account ${account.id}:`, error) + groupInfos = [] + } + + // 获取绑定的 API Key 数量 + const allApiKeys = await redis.getAllApiKeys() + const groupIds = groupInfos.map((group) => group.id) + const boundApiKeysCount = allApiKeys.reduce((count, key) => { + const binding = key.droidAccountId + if (!binding) { + return count + } + if (binding === account.id) { + return count + 1 + } + if (binding.startsWith('group:')) { + const groupId = binding.substring('group:'.length) + if (groupIds.includes(groupId)) { + return count + 1 + } + } + return count + }, 0) + + // 获取解密的 API Keys(用于管理界面) + let decryptedApiKeys = [] + try { + decryptedApiKeys = await droidAccountService.getDecryptedApiKeyEntries(id) + } catch (error) { + logger.debug(`Failed to get decrypted API keys for Droid account ${account.id}:`, error) + decryptedApiKeys = [] + } + + // 返回完整的账户信息,包含实际的 API Keys + const accountDetails = { + ...account, + // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt + expiresAt: account.subscriptionExpiresAt || null, + schedulable: account.schedulable === 'true', + boundApiKeysCount, + groupInfos, + // 包含实际的 API Keys(用于管理界面) + apiKeys: decryptedApiKeys.map((entry) => ({ + key: entry.key, + id: entry.id, + usageCount: entry.usageCount || 0, + lastUsedAt: entry.lastUsedAt || null, + status: entry.status || 'active', // 使用实际的状态,默认为 active + errorMessage: entry.errorMessage || '', // 包含错误信息 + createdAt: entry.createdAt || null + })), + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + + return res.json({ + success: true, + data: accountDetails + }) + } catch (error) { + logger.error(`Failed to get Droid account ${req.params.id}:`, error) + return res.status(500).json({ + error: 'Failed to get Droid account', + message: error.message + }) + } +}) + // 删除 Droid 账户 router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => { try { diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index 532979cf..8ece60fd 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -13,13 +13,10 @@ const { updateRateLimitCounters } = require('../utils/rateLimitHelper') // 生成会话哈希 function generateSessionHash(req) { - const sessionData = [ - req.headers['user-agent'], - req.ip, - req.headers['x-api-key']?.substring(0, 10) - ] - .filter(Boolean) - .join(':') + const apiKeyPrefix = + req.headers['x-api-key']?.substring(0, 10) || req.headers['x-goog-api-key']?.substring(0, 10) + + const sessionData = [req.headers['user-agent'], req.ip, apiKeyPrefix].filter(Boolean).join(':') return crypto.createHash('sha256').update(sessionData).digest('hex') } diff --git a/src/routes/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js index 54305401..a718aad2 100644 --- a/src/routes/openaiGeminiRoutes.js +++ b/src/routes/openaiGeminiRoutes.js @@ -9,11 +9,10 @@ const crypto = require('crypto') // 生成会话哈希 function generateSessionHash(req) { - const sessionData = [ - req.headers['user-agent'], - req.ip, - req.headers['authorization']?.substring(0, 20) - ] + const authSource = + req.headers['authorization'] || req.headers['x-api-key'] || req.headers['x-goog-api-key'] + + const sessionData = [req.headers['user-agent'], req.ip, authSource?.substring(0, 20)] .filter(Boolean) .join(':') diff --git a/src/services/droidAccountService.js b/src/services/droidAccountService.js index c6baecbf..34fc2c59 100644 --- a/src/services/droidAccountService.js +++ b/src/services/droidAccountService.js @@ -183,7 +183,10 @@ class DroidAccountService { ? [] : normalizedExisting .filter((entry) => entry && entry.id && entry.encryptedKey) - .map((entry) => ({ ...entry })) + .map((entry) => ({ + ...entry, + status: entry.status || 'active' // 确保有默认状态 + })) const hashSet = new Set(entries.map((entry) => entry.hash).filter(Boolean)) @@ -214,7 +217,9 @@ class DroidAccountService { encryptedKey: this._encryptSensitiveData(trimmed), createdAt: now, lastUsedAt: '', - usageCount: '0' + usageCount: '0', + status: 'active', // 新增状态字段 + errorMessage: '' // 新增错误信息字段 }) } @@ -230,7 +235,9 @@ class DroidAccountService { id: entry.id, createdAt: entry.createdAt || '', lastUsedAt: entry.lastUsedAt || '', - usageCount: entry.usageCount || '0' + usageCount: entry.usageCount || '0', + status: entry.status || 'active', // 新增状态字段 + errorMessage: entry.errorMessage || '' // 新增错误信息字段 })) } @@ -252,7 +259,9 @@ class DroidAccountService { hash: entry.hash || '', createdAt: entry.createdAt || '', lastUsedAt: entry.lastUsedAt || '', - usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0 + usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0, + status: entry.status || 'active', // 新增状态字段 + errorMessage: entry.errorMessage || '' // 新增错误信息字段 } } @@ -348,6 +357,56 @@ class DroidAccountService { } } + /** + * 标记指定的 Droid API Key 条目为异常状态 + */ + async markApiKeyAsError(accountId, keyId, errorMessage = '') { + if (!accountId || !keyId) { + return { marked: false, error: '参数无效' } + } + + try { + const accountData = await redis.getDroidAccount(accountId) + if (!accountData) { + return { marked: false, error: '账户不存在' } + } + + const entries = this._parseApiKeyEntries(accountData.apiKeys) + if (!entries || entries.length === 0) { + return { marked: false, error: '无API Key条目' } + } + + let marked = false + const updatedEntries = entries.map((entry) => { + if (entry && entry.id === keyId) { + marked = true + return { + ...entry, + status: 'error', + errorMessage: errorMessage || 'API Key异常' + } + } + return entry + }) + + if (!marked) { + return { marked: false, error: '未找到指定的API Key' } + } + + accountData.apiKeys = JSON.stringify(updatedEntries) + await redis.setDroidAccount(accountId, accountData) + + logger.warn( + `⚠️ 已标记 Droid API Key ${keyId} 为异常状态(Account: ${accountId}):${errorMessage}` + ) + + return { marked: true } + } catch (error) { + logger.error(`❌ 标记 Droid API Key 异常状态失败:${keyId}(Account: ${accountId})`, error) + return { marked: false, error: error.message } + } + } + /** * 使用 WorkOS Refresh Token 刷新并验证凭证 */ @@ -979,7 +1038,7 @@ class DroidAccountService { ? updates.apiKeyUpdateMode.trim().toLowerCase() : '' - let apiKeyUpdateMode = ['append', 'replace', 'delete'].includes(rawApiKeyMode) + let apiKeyUpdateMode = ['append', 'replace', 'delete', 'update'].includes(rawApiKeyMode) ? rawApiKeyMode : '' @@ -1041,6 +1100,60 @@ class DroidAccountService { } else if (removeApiKeysInput.length > 0) { logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`) } + } else if (apiKeyUpdateMode === 'update') { + // 更新模式:根据提供的 key 匹配现有条目并更新状态 + mergedApiKeys = [...existingApiKeyEntries] + const updatedHashes = new Set() + + for (const updateItem of newApiKeysInput) { + if (!updateItem || typeof updateItem !== 'object') { + continue + } + + const key = updateItem.key || updateItem.apiKey || '' + if (!key || typeof key !== 'string') { + continue + } + + const trimmed = key.trim() + if (!trimmed) { + continue + } + + const hash = crypto.createHash('sha256').update(trimmed).digest('hex') + updatedHashes.add(hash) + + // 查找现有条目 + const existingIndex = mergedApiKeys.findIndex((entry) => entry && entry.hash === hash) + + if (existingIndex !== -1) { + // 更新现有条目的状态信息 + const existingEntry = mergedApiKeys[existingIndex] + mergedApiKeys[existingIndex] = { + ...existingEntry, + status: updateItem.status || existingEntry.status || 'active', + errorMessage: + updateItem.errorMessage !== undefined + ? updateItem.errorMessage + : existingEntry.errorMessage || '', + lastUsedAt: + updateItem.lastUsedAt !== undefined + ? updateItem.lastUsedAt + : existingEntry.lastUsedAt || '', + usageCount: + updateItem.usageCount !== undefined + ? String(updateItem.usageCount) + : existingEntry.usageCount || '0' + } + apiKeysUpdated = true + } + } + + if (!apiKeysUpdated) { + logger.warn( + `⚠️ 更新模式未匹配任何 Droid API Key: ${accountId} (提供 ${updatedHashes.size} 个哈希)` + ) + } } else { const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length @@ -1063,6 +1176,10 @@ class DroidAccountService { logger.info( `🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}` ) + } else if (apiKeyUpdateMode === 'update') { + logger.info( + `🔑 更新模式更新 Droid API keys for ${accountId}: 更新了 ${newApiKeysInput.length} 个 API Key 的状态信息` + ) } else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) { logger.info( `🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}` diff --git a/src/services/droidRelayService.js b/src/services/droidRelayService.js index 1072b869..38cd9a6b 100644 --- a/src/services/droidRelayService.js +++ b/src/services/droidRelayService.js @@ -121,12 +121,18 @@ class DroidRelayService { throw new Error(`Droid account ${account.id} 未配置任何 API Key`) } + // 过滤掉异常状态的API Key + const activeEntries = entries.filter((entry) => entry.status !== 'error') + if (!activeEntries || activeEntries.length === 0) { + throw new Error(`Droid account ${account.id} 没有可用的 API Key(所有API Key均已异常)`) + } + const stickyKey = this._composeApiKeyStickyKey(account.id, endpointType, sessionHash) if (stickyKey) { const mappedKeyId = await redis.getSessionAccountMapping(stickyKey) if (mappedKeyId) { - const mappedEntry = entries.find((entry) => entry.id === mappedKeyId) + const mappedEntry = activeEntries.find((entry) => entry.id === mappedKeyId) if (mappedEntry) { await redis.extendSessionAccountMappingTTL(stickyKey) await droidAccountService.touchApiKeyUsage(account.id, mappedEntry.id) @@ -138,7 +144,7 @@ class DroidRelayService { } } - const selectedEntry = entries[Math.floor(Math.random() * entries.length)] + const selectedEntry = activeEntries[Math.floor(Math.random() * activeEntries.length)] if (!selectedEntry) { throw new Error(`Droid account ${account.id} 没有可用的 API Key`) } @@ -150,7 +156,7 @@ class DroidRelayService { await droidAccountService.touchApiKeyUsage(account.id, selectedEntry.id) logger.info( - `🔐 随机选取 Droid API Key ${selectedEntry.id}(Account: ${account.id}, Keys: ${entries.length})` + `🔐 随机选取 Droid API Key ${selectedEntry.id}(Account: ${account.id}, Active Keys: ${activeEntries.length}/${entries.length})` ) return selectedEntry @@ -1144,39 +1150,50 @@ class DroidRelayService { if (authMethod === 'api_key') { if (selectedAccountApiKey?.id) { - let removalResult = null + let markResult = null + const errorMessage = `${statusCode}` try { - removalResult = await droidAccountService.removeApiKeyEntry( + // 标记API Key为异常状态而不是删除 + markResult = await droidAccountService.markApiKeyAsError( accountId, - selectedAccountApiKey.id + selectedAccountApiKey.id, + errorMessage ) } catch (error) { logger.error( - `❌ 移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})失败:`, + `❌ 标记 Droid API Key ${selectedAccountApiKey.id} 异常状态(Account: ${accountId})失败:`, error ) } await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash) - if (removalResult?.removed) { + if (markResult?.marked) { logger.warn( - `🚫 上游返回 ${statusCode},已移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})` + `⚠️ 上游返回 ${statusCode},已标记 Droid API Key ${selectedAccountApiKey.id} 为异常状态(Account: ${accountId})` ) } else { logger.warn( - `⚠️ 上游返回 ${statusCode},但未能移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})` + `⚠️ 上游返回 ${statusCode},但未能标记 Droid API Key ${selectedAccountApiKey.id} 异常状态(Account: ${accountId}):${markResult?.error || '未知错误'}` ) } - if (!removalResult || removalResult.remainingCount === 0) { - await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key 已全部失效') + // 检查是否还有可用的API Key + try { + const availableEntries = await droidAccountService.getDecryptedApiKeyEntries(accountId) + const activeEntries = availableEntries.filter((entry) => entry.status !== 'error') + + if (activeEntries.length === 0) { + await this._stopDroidAccountScheduling(accountId, statusCode, '所有API Key均已异常') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + } else { + logger.info(`ℹ️ Droid 账号 ${accountId} 仍有 ${activeEntries.length} 个可用 API Key`) + } + } catch (error) { + logger.error(`❌ 检查可用API Key失败(Account: ${accountId}):`, error) + await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key检查失败') await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) - } else { - logger.info( - `ℹ️ Droid 账号 ${accountId} 仍有 ${removalResult.remainingCount} 个 API Key 可用` - ) } return diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index fc80a3b1..fcfb0453 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -28,8 +28,25 @@ class UnifiedClaudeScheduler { return true // 没有指定模型时,默认支持 } - // Claude OAuth 账户的 Opus 模型检查 + // Claude OAuth 账户的模型检查 if (accountType === 'claude-official') { + // 1. 首先检查是否为 Claude 官方支持的模型 + // Claude Official API 只支持 Anthropic 自己的模型,不支持第三方模型(如 deepseek-chat) + const isClaudeOfficialModel = + requestedModel.startsWith('claude-') || + requestedModel.includes('claude') || + requestedModel.includes('sonnet') || + requestedModel.includes('opus') || + requestedModel.includes('haiku') + + if (!isClaudeOfficialModel) { + logger.info( + `🚫 Claude official account ${account.name} does not support non-Claude model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + + // 2. Opus 模型的订阅级别检查 if (requestedModel.toLowerCase().includes('opus')) { if (account.subscriptionInfo) { try { diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 67ab4390..0bea28a8 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -1817,7 +1817,8 @@
  • 新会话将随机命中一个 Key,并在会话有效期内保持粘性。
  • 若某 Key 失效,会自动切换到剩余可用 Key,最大化成功率。
  • - 若上游返回 4xx 错误码,该 Key 会被自动移除;全部 Key 清空后账号将暂停调度。 + 若上游返回 4xx 错误码,该 Key 会被自动标记为异常;全部 Key + 异常后账号将暂停调度。
  • @@ -3011,10 +3012,18 @@ > -
    -
    - 更新 API Key -
    +
    +
    +
    更新 API Key
    + +

    当前已保存 {{ existingApiKeyCount }} 条 API Key。您可以追加新的 Key,或通过下方模式快速覆盖、删除指定 Key。 @@ -3187,6 +3196,15 @@ @close="showGroupManagement = false" @refresh="handleGroupRefresh" /> + + + @@ -3200,6 +3218,7 @@ import ProxyConfig from './ProxyConfig.vue' import OAuthFlow from './OAuthFlow.vue' import ConfirmModal from '@/components/common/ConfirmModal.vue' import GroupManagementModal from './GroupManagementModal.vue' +import ApiKeyManagementModal from './ApiKeyManagementModal.vue' const props = defineProps({ account: { @@ -3239,6 +3258,9 @@ const clearingCache = ref(false) // 平台分组状态 const platformGroup = ref('') +// API Key 管理模态框 +const showApiKeyManagement = ref(false) + // 根据现有平台确定分组 const determinePlatformGroup = (platform) => { if (['claude', 'claude-console', 'ccr', 'bedrock'].includes(platform)) { @@ -4816,6 +4838,18 @@ const handleGroupRefresh = async () => { await loadGroups() } +// 处理 API Key 管理模态框刷新 +const handleApiKeyRefresh = async () => { + // 刷新账户信息以更新 API Key 数量 + if (props.account?.id) { + try { + await accountsStore.fetchAccounts() + } catch (error) { + console.error('Failed to refresh account data:', error) + } + } +} + // 监听平台变化,重置表单 watch( () => form.value.platform, diff --git a/web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue b/web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue new file mode 100644 index 00000000..ad062150 --- /dev/null +++ b/web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue @@ -0,0 +1,437 @@ + + + diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index dad086e2..a4c8de6e 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -3108,6 +3108,25 @@ const getDroidApiKeyCount = (account) => { return 0 } + // 优先使用 apiKeys 数组来计算正常状态的 API Keys + if (Array.isArray(account.apiKeys)) { + // 只计算状态不是 'error' 的 API Keys + return account.apiKeys.filter((apiKey) => apiKey.status !== 'error').length + } + + // 如果是字符串格式的 apiKeys,尝试解析 + if (typeof account.apiKeys === 'string' && account.apiKeys.trim()) { + try { + const parsed = JSON.parse(account.apiKeys) + if (Array.isArray(parsed)) { + // 只计算状态不是 'error' 的 API Keys + return parsed.filter((apiKey) => apiKey.status !== 'error').length + } + } catch (error) { + // 忽略解析错误,继续使用其他字段 + } + } + const candidates = [ account.apiKeyCount, account.api_key_count, @@ -3122,21 +3141,6 @@ const getDroidApiKeyCount = (account) => { } } - if (Array.isArray(account.apiKeys)) { - return account.apiKeys.length - } - - if (typeof account.apiKeys === 'string' && account.apiKeys.trim()) { - try { - const parsed = JSON.parse(account.apiKeys) - if (Array.isArray(parsed)) { - return parsed.length - } - } catch (error) { - // 忽略解析错误,维持默认值 - } - } - return 0 } diff --git a/web/admin-spa/src/views/TutorialView.vue b/web/admin-spa/src/views/TutorialView.vue index fa976159..373c7019 100644 --- a/web/admin-spa/src/views/TutorialView.vue +++ b/web/admin-spa/src/views/TutorialView.vue @@ -403,13 +403,13 @@ class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" >

    - $env:CODE_ASSIST_ENDPOINT = "{{ geminiBaseUrl }}" + $env:GOOGLE_GEMINI_BASE_URL = "{{ geminiBaseUrl }}"
    - $env:GOOGLE_CLOUD_ACCESS_TOKEN = "你的API密钥" + $env:GEMINI_API_KEY = "你的API密钥"
    - $env:GOOGLE_GENAI_USE_GCA = "true" + $env:GEMINI_MODEL = "gemini-2.5-pro"

    @@ -431,16 +431,16 @@ >

    # 设置用户级环境变量(永久生效)
    - [System.Environment]::SetEnvironmentVariable("CODE_ASSIST_ENDPOINT", "{{ + [System.Environment]::SetEnvironmentVariable("GOOGLE_GEMINI_BASE_URL", "{{ geminiBaseUrl }}", [System.EnvironmentVariableTarget]::User)
    - [System.Environment]::SetEnvironmentVariable("GOOGLE_CLOUD_ACCESS_TOKEN", - "你的API密钥", [System.EnvironmentVariableTarget]::User) + [System.Environment]::SetEnvironmentVariable("GEMINI_API_KEY", "你的API密钥", + [System.EnvironmentVariableTarget]::User)
    - [System.Environment]::SetEnvironmentVariable("GOOGLE_GENAI_USE_GCA", "true", + [System.Environment]::SetEnvironmentVariable("GEMINI_MODEL", "gemini-2.5-pro", [System.EnvironmentVariableTarget]::User)
    @@ -459,11 +459,9 @@
    -
    echo $env:CODE_ASSIST_ENDPOINT
    -
    - echo $env:GOOGLE_CLOUD_ACCESS_TOKEN -
    -
    echo $env:GOOGLE_GENAI_USE_GCA
    +
    echo $env:GOOGLE_GEMINI_BASE_URL
    +
    echo $env:GEMINI_API_KEY
    +
    echo $env:GEMINI_MODEL
    @@ -1047,13 +1045,13 @@ class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" >
    - export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}" + export GOOGLE_GEMINI_BASE_URL="{{ geminiBaseUrl }}"
    - export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥" + export GEMINI_API_KEY="你的API密钥"
    - export GOOGLE_GENAI_USE_GCA="true" + export GEMINI_MODEL="gemini-2.5-pro"

    @@ -1075,13 +1073,13 @@ >

    # 对于 zsh (默认)
    - echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.zshrc + echo 'export GOOGLE_GEMINI_BASE_URL="{{ geminiBaseUrl }}"' >> ~/.zshrc
    - echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.zshrc + echo 'export GEMINI_API_KEY="你的API密钥"' >> ~/.zshrc
    - echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.zshrc + echo 'export GEMINI_MODEL="gemini-2.5-pro"' >> ~/.zshrc
    source ~/.zshrc
    @@ -1090,13 +1088,13 @@ >
    # 对于 bash
    - echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.bash_profile + echo 'export GOOGLE_GEMINI_BASE_URL="{{ geminiBaseUrl }}"' >> ~/.bash_profile
    - echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.bash_profile + echo 'export GEMINI_API_KEY="你的API密钥"' >> ~/.bash_profile
    - echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.bash_profile + echo 'export GEMINI_MODEL="gemini-2.5-pro"' >> ~/.bash_profile
    source ~/.bash_profile
    @@ -1112,9 +1110,9 @@
    -
    echo $CODE_ASSIST_ENDPOINT
    -
    echo $GOOGLE_CLOUD_ACCESS_TOKEN
    -
    echo $GOOGLE_GENAI_USE_GCA
    +
    echo $GOOGLE_GEMINI_BASE_URL
    +
    echo $GEMINI_API_KEY
    +
    echo $GEMINI_MODEL
    @@ -1660,13 +1658,13 @@ class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" >
    - export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}" + export GOOGLE_GEMINI_BASE_URL="{{ geminiBaseUrl }}"
    - export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥" + export GEMINI_API_KEY="你的API密钥"
    - export GOOGLE_GENAI_USE_GCA="true" + export GEMINI_MODEL="gemini-2.5-pro"

    @@ -1688,13 +1686,13 @@ >

    # 对于 bash (默认)
    - echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.bashrc + echo 'export GOOGLE_GEMINI_BASE_URL="{{ geminiBaseUrl }}"' >> ~/.bashrc
    - echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.bashrc + echo 'export GEMINI_API_KEY="你的API密钥"' >> ~/.bashrc
    - echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.bashrc + echo 'export GEMINI_MODEL="gemini-2.5-pro"' >> ~/.bashrc
    source ~/.bashrc
    @@ -1703,13 +1701,13 @@ >
    # 对于 zsh
    - echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.zshrc + echo 'export GOOGLE_GEMINI_BASE_URL="{{ geminiBaseUrl }}"' >> ~/.zshrc
    - echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.zshrc + echo 'export GEMINI_API_KEY="你的API密钥"' >> ~/.zshrc
    - echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.zshrc + echo 'export GEMINI_MODEL="gemini-2.5-pro"' >> ~/.zshrc
    source ~/.zshrc
    @@ -1725,9 +1723,9 @@
    -
    echo $CODE_ASSIST_ENDPOINT
    -
    echo $GOOGLE_CLOUD_ACCESS_TOKEN
    -
    echo $GOOGLE_GENAI_USE_GCA
    +
    echo $GOOGLE_GEMINI_BASE_URL
    +
    echo $GEMINI_API_KEY
    +
    echo $GEMINI_MODEL