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 @@ + + + + + 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 @@ 刷新 + + +