diff --git a/.gitignore b/.gitignore index b6179027..fbe70338 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ pnpm-debug.log* # MCP configuration (local only) .mcp.json +.spec-workflow/ # Data directory (contains sensitive information) data/ @@ -240,4 +241,4 @@ web/admin/ web/apiStats/ # Admin SPA build files -web/admin-spa/dist/ \ No newline at end of file +web/admin-spa/dist/ diff --git a/src/routes/admin.js b/src/routes/admin.js index 10c1757c..8d1e1390 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -620,6 +620,170 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { } }) +// 批量编辑API Keys +router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { + try { + const { keyIds, updates } = req.body + + if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) { + return res.status(400).json({ + error: 'Invalid input', + message: 'keyIds must be a non-empty array' + }) + } + + if (!updates || typeof updates !== 'object') { + return res.status(400).json({ + error: 'Invalid input', + message: 'updates must be an object' + }) + } + + logger.info( + `🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}` + ) + logger.info(`🔍 Debug: keyIds received: ${JSON.stringify(keyIds)}`) + + const results = { + successCount: 0, + failedCount: 0, + errors: [] + } + + // 处理每个API Key + for (const keyId of keyIds) { + try { + // 获取当前API Key信息 + const currentKey = await redis.getApiKey(keyId) + if (!currentKey || Object.keys(currentKey).length === 0) { + results.failedCount++ + results.errors.push(`API key ${keyId} not found`) + continue + } + + // 构建最终更新数据 + const finalUpdates = {} + + // 处理普通字段 + if (updates.name) { + finalUpdates.name = updates.name + } + if (updates.tokenLimit !== undefined) { + finalUpdates.tokenLimit = updates.tokenLimit + } + if (updates.concurrencyLimit !== undefined) { + finalUpdates.concurrencyLimit = updates.concurrencyLimit + } + if (updates.rateLimitWindow !== undefined) { + finalUpdates.rateLimitWindow = updates.rateLimitWindow + } + if (updates.rateLimitRequests !== undefined) { + finalUpdates.rateLimitRequests = updates.rateLimitRequests + } + if (updates.dailyCostLimit !== undefined) { + finalUpdates.dailyCostLimit = updates.dailyCostLimit + } + if (updates.permissions !== undefined) { + finalUpdates.permissions = updates.permissions + } + if (updates.isActive !== undefined) { + finalUpdates.isActive = updates.isActive + } + if (updates.monthlyLimit !== undefined) { + finalUpdates.monthlyLimit = updates.monthlyLimit + } + if (updates.priority !== undefined) { + finalUpdates.priority = updates.priority + } + if (updates.enabled !== undefined) { + finalUpdates.enabled = updates.enabled + } + + // 处理账户绑定 + if (updates.claudeAccountId !== undefined) { + finalUpdates.claudeAccountId = updates.claudeAccountId + } + if (updates.claudeConsoleAccountId !== undefined) { + finalUpdates.claudeConsoleAccountId = updates.claudeConsoleAccountId + } + if (updates.geminiAccountId !== undefined) { + finalUpdates.geminiAccountId = updates.geminiAccountId + } + if (updates.openaiAccountId !== undefined) { + finalUpdates.openaiAccountId = updates.openaiAccountId + } + if (updates.bedrockAccountId !== undefined) { + finalUpdates.bedrockAccountId = updates.bedrockAccountId + } + + // 处理标签操作 + if (updates.tags !== undefined) { + if (updates.tagOperation) { + const currentTags = currentKey.tags ? JSON.parse(currentKey.tags) : [] + const operationTags = updates.tags + + switch (updates.tagOperation) { + case 'replace': { + finalUpdates.tags = operationTags + break + } + case 'add': { + const newTags = [...currentTags] + operationTags.forEach((tag) => { + if (!newTags.includes(tag)) { + newTags.push(tag) + } + }) + finalUpdates.tags = newTags + break + } + case 'remove': { + finalUpdates.tags = currentTags.filter((tag) => !operationTags.includes(tag)) + break + } + } + } else { + // 如果没有指定操作类型,默认为替换 + finalUpdates.tags = updates.tags + } + } + + // 执行更新 + await apiKeyService.updateApiKey(keyId, finalUpdates) + results.successCount++ + logger.success(`✅ Batch edit: API key ${keyId} updated successfully`) + } catch (error) { + results.failedCount++ + results.errors.push(`Failed to update key ${keyId}: ${error.message}`) + logger.error(`❌ Batch edit failed for key ${keyId}:`, error) + } + } + + // 记录批量编辑结果 + if (results.successCount > 0) { + logger.success( + `🎉 Batch edit completed: ${results.successCount} successful, ${results.failedCount} failed` + ) + } else { + logger.warn( + `⚠️ Batch edit completed with no successful updates: ${results.failedCount} failed` + ) + } + + return res.json({ + success: true, + message: `批量编辑完成`, + data: results + }) + } catch (error) { + logger.error('❌ Failed to batch edit API keys:', error) + return res.status(500).json({ + error: 'Batch edit failed', + message: error.message + }) + } +}) + // 更新API Key router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json index 784105ed..6d04a5f8 100644 --- a/web/admin-spa/package-lock.json +++ b/web/admin-spa/package-lock.json @@ -18,12 +18,14 @@ "vue-router": "^4.2.5" }, "devDependencies": { + "@playwright/test": "^1.55.0", "@vitejs/plugin-vue": "^4.5.2", "@vue/eslint-config-prettier": "^10.2.0", "autoprefixer": "^10.4.16", "eslint": "^8.55.0", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-vue": "^9.19.2", + "playwright": "^1.55.0", "postcss": "^8.4.32", "prettier": "^3.1.1", "prettier-plugin-tailwindcss": "^0.6.14", @@ -806,6 +808,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", + "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "name": "@sxzz/popperjs-es", "version": "2.11.7", @@ -3465,6 +3483,53 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", diff --git a/web/admin-spa/package.json b/web/admin-spa/package.json index 9cb15379..4881e1b3 100644 --- a/web/admin-spa/package.json +++ b/web/admin-spa/package.json @@ -21,12 +21,14 @@ "vue-router": "^4.2.5" }, "devDependencies": { + "@playwright/test": "^1.55.0", "@vitejs/plugin-vue": "^4.5.2", "@vue/eslint-config-prettier": "^10.2.0", "autoprefixer": "^10.4.16", "eslint": "^8.55.0", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-vue": "^9.19.2", + "playwright": "^1.55.0", "postcss": "^8.4.32", "prettier": "^3.1.1", "prettier-plugin-tailwindcss": "^0.6.14", diff --git a/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue new file mode 100644 index 00000000..ab777c56 --- /dev/null +++ b/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue @@ -0,0 +1,719 @@ + + + + + + + + + + + 批量编辑 API Keys ({{ selectedCount }} 个) + + + + + + + + + + + + + + 批量编辑说明 + + 以下设置将应用到所选的 {{ selectedCount }} 个 API + Key。只有填写或修改的字段才会被更新,空白字段将保持原值不变。 + + + + + + + + + 标签 (批量操作) + + + + + + + 替换标签 + + + + 添加标签 + + + + 移除标签 + + + + 不修改标签 + + + + + + + + + {{ + tagOperation === 'replace' + ? '新标签列表:' + : tagOperation === 'add' + ? '要添加的标签:' + : '要移除的标签:' + }} + + + + {{ tag }} + + + + + + + + + + 点击选择已有标签: + + + + {{ tag }} + + + + + + + 创建新标签: + + + + + + + + + + + + + + + + + + 速率限制设置 + + + + + + + 时间窗口 (分钟) + + + + + + 请求次数限制 + + + + + Token 限制 + + + + + + + + + + 每日费用限制 (美元) + + + + + + + 并发限制 + + + + + + + 激活状态 + + + + 激活 + + + + 禁用 + + + + 不修改 + + + + + + + + 服务权限 + + + + 不修改 + + + + 全部服务 + + + + 仅 Claude + + + + 仅 Gemini + + + + 仅 OpenAI + + + + + + + + 专属账号绑定 + + + {{ accountsLoading ? '刷新中...' : '刷新账号' }} + + + + + Claude 专属账号 + + 不修改 + 使用共享账号池 + + + 分组 - {{ group.name }} + + + + + {{ account.name }} ({{ + account.platform === 'claude-console' ? 'Console' : 'OAuth' + }}) + + + + + + Gemini 专属账号 + + 不修改 + 使用共享账号池 + + + 分组 - {{ group.name }} + + + + + {{ account.name }} + + + + + + OpenAI 专属账号 + + 不修改 + 使用共享账号池 + + + 分组 - {{ group.name }} + + + + + {{ account.name }} + + + + + + Bedrock 专属账号 + + 不修改 + 使用共享账号池 + + + {{ account.name }} + + + + + + + + + + 取消 + + + + + {{ loading ? '保存中...' : '批量保存' }} + + + + + + + + + + + diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index 0ebf7049..6b01e835 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -93,6 +93,19 @@ 刷新 + + + + + 编辑选中 ({{ selectedApiKeys.length }}) + + + + + + + Bedrock + + + {{ getBedrockBindingInfo(key) }} + + @@ -879,13 +905,26 @@ {{ getOpenAIBindingInfo(key) }} + + + + + Bedrock + + + {{ getBedrockBindingInfo(key) }} + + @@ -1184,6 +1223,14 @@ @close="showBatchApiKeyModal = false" /> + + { // 加载账户列表 const loadAccounts = async () => { try { - const [claudeData, claudeConsoleData, geminiData, openaiData, groupsData] = await Promise.all([ - apiClient.get('/admin/claude-accounts'), - apiClient.get('/admin/claude-console-accounts'), - apiClient.get('/admin/gemini-accounts'), - apiClient.get('/admin/openai-accounts'), - apiClient.get('/admin/account-groups') - ]) + const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] = + await Promise.all([ + apiClient.get('/admin/claude-accounts'), + apiClient.get('/admin/claude-console-accounts'), + apiClient.get('/admin/gemini-accounts'), + apiClient.get('/admin/openai-accounts'), + apiClient.get('/admin/bedrock-accounts'), + apiClient.get('/admin/account-groups') + ]) + + // 合并Claude OAuth账户和Claude Console账户 + const claudeAccounts = [] if (claudeData.success) { - accounts.value.claude = claudeData.data || [] + claudeData.data?.forEach((account) => { + claudeAccounts.push({ + ...account, + platform: 'claude-oauth', + isDedicated: account.accountType === 'dedicated' + }) + }) } if (claudeConsoleData.success) { - // 将 Claude Console 账号合并到 claude 数组中 - const consoleAccounts = (claudeConsoleData.data || []).map((acc) => ({ - ...acc, - platform: 'claude-console' - })) - accounts.value.claude = [...accounts.value.claude, ...consoleAccounts] + claudeConsoleData.data?.forEach((account) => { + claudeAccounts.push({ + ...account, + platform: 'claude-console', + isDedicated: account.accountType === 'dedicated' + }) + }) } + accounts.value.claude = claudeAccounts + if (geminiData.success) { - accounts.value.gemini = geminiData.data || [] + accounts.value.gemini = (geminiData.data || []).map((account) => ({ + ...account, + isDedicated: account.accountType === 'dedicated' + })) } if (openaiData.success) { - accounts.value.openai = openaiData.data || [] + accounts.value.openai = (openaiData.data || []).map((account) => ({ + ...account, + isDedicated: account.accountType === 'dedicated' + })) + } + + if (bedrockData.success) { + accounts.value.bedrock = (bedrockData.data || []).map((account) => ({ + ...account, + isDedicated: account.accountType === 'dedicated' + })) } if (groupsData.success) { @@ -1520,6 +1597,12 @@ const getBoundAccountName = (accountId) => { return `${openaiAccount.name}` } + // 从Bedrock账户列表中查找 + const bedrockAccount = accounts.value.bedrock.find((acc) => acc.id === accountId) + if (bedrockAccount) { + return `${bedrockAccount.name}` + } + // 如果找不到,返回账户ID的前8位 return `${accountId.substring(0, 8)}` } @@ -1593,6 +1676,26 @@ const getOpenAIBindingInfo = (key) => { return '' } +// 获取Bedrock绑定信息 +const getBedrockBindingInfo = (key) => { + if (key.bedrockAccountId) { + const info = getBoundAccountName(key.bedrockAccountId) + if (key.bedrockAccountId.startsWith('group:')) { + return info + } + // 检查账户是否存在 + const account = accounts.value.bedrock.find((acc) => acc.id === key.bedrockAccountId) + if (!account) { + return `⚠️ ${info} (账户不存在)` + } + if (account.accountType === 'dedicated') { + return `🔒 专属-${info}` + } + return info + } + return '' +} + // 检查API Key是否过期 const isApiKeyExpired = (expiresAt) => { if (!expiresAt) return false @@ -1825,6 +1928,27 @@ const handleBatchCreateSuccess = (data) => { loadApiKeys() } +// 打开批量编辑模态框 +const openBatchEditModal = async () => { + if (selectedApiKeys.value.length === 0) { + showToast('请先选择要编辑的 API Keys', 'warning') + return + } + + // 重新加载账号数据,确保显示最新的专属账号 + await loadAccounts() + showBatchEditModal.value = true +} + +// 处理批量编辑成功 +const handleBatchEditSuccess = () => { + showBatchEditModal.value = false + // 清空选中状态 + selectedApiKeys.value = [] + updateSelectAllState() + loadApiKeys() +} + // 处理编辑成功 const handleEditSuccess = () => { showEditApiKeyModal.value = false
批量编辑说明
+ 以下设置将应用到所选的 {{ selectedCount }} 个 API + Key。只有填写或修改的字段才会被更新,空白字段将保持原值不变。 +