mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 批量编辑API Keys功能优化和Bug修复
- 添加批量编辑API Keys路由和验证逻辑 - 修复标签操作(添加、移除、替换)功能 - 新增批量编辑前端组件,支持共享账号池选择 - 完善Bedrock账户显示和绑定功能 - 优化账户选择UI,统一所有平台选项 - 修复前端构建错误和代码格式问题 - 添加comprehensive测试场景文档 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
65
web/admin-spa/package-lock.json
generated
65
web/admin-spa/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
726
web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue
Normal file
726
web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue
Normal file
@@ -0,0 +1,726 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div
|
||||
class="modal-content mx-auto flex max-h-[90vh] w-full max-w-4xl flex-col p-4 sm:p-6 md:p-8"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 sm:h-10 sm:w-10 sm:rounded-xl"
|
||||
>
|
||||
<i class="fas fa-edit text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">
|
||||
批量编辑 API Keys ({{ selectedCount }} 个)
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times text-lg sm:text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="modal-scroll-content custom-scrollbar flex-1 space-y-4 sm:space-y-6"
|
||||
@submit.prevent="batchUpdateApiKeys"
|
||||
>
|
||||
<!-- 说明文本 -->
|
||||
<div class="rounded-lg bg-blue-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fas fa-info-circle mt-1 text-blue-500" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800">批量编辑说明</p>
|
||||
<p class="mt-1 text-sm text-blue-700">
|
||||
以下设置将应用到所选的 {{ selectedCount }} 个 API
|
||||
Key。只有填写或修改的字段才会被更新,空白字段将保持原值不变。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签编辑 -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-semibold text-gray-700 sm:mb-3 sm:text-sm">
|
||||
标签 (批量操作)
|
||||
</label>
|
||||
<div class="space-y-4">
|
||||
<!-- 标签操作模式选择 -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="tagOperation" class="mr-2" type="radio" value="replace" />
|
||||
<span class="text-sm text-gray-700">替换标签</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="tagOperation" class="mr-2" type="radio" value="add" />
|
||||
<span class="text-sm text-gray-700">添加标签</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="tagOperation" class="mr-2" type="radio" value="remove" />
|
||||
<span class="text-sm text-gray-700">移除标签</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="tagOperation" class="mr-2" type="radio" value="none" />
|
||||
<span class="text-sm text-gray-700">不修改标签</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 标签编辑区域 -->
|
||||
<div v-if="tagOperation !== 'none'" class="space-y-3">
|
||||
<!-- 已选择的标签 -->
|
||||
<div v-if="form.tags.length > 0">
|
||||
<div class="mb-2 text-xs font-medium text-gray-600">
|
||||
{{
|
||||
tagOperation === 'replace'
|
||||
? '新标签列表:'
|
||||
: tagOperation === 'add'
|
||||
? '要添加的标签:'
|
||||
: '要移除的标签:'
|
||||
}}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(tag, index) in form.tags"
|
||||
:key="'selected-' + index"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-1 text-sm text-blue-800"
|
||||
>
|
||||
{{ tag }}
|
||||
<button
|
||||
class="ml-1 hover:text-blue-900"
|
||||
type="button"
|
||||
@click="removeTag(index)"
|
||||
>
|
||||
<i class="fas fa-times text-xs" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 可选择的已有标签 -->
|
||||
<div v-if="unselectedTags.length > 0">
|
||||
<div class="mb-2 text-xs font-medium text-gray-600">点击选择已有标签:</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="tag in unselectedTags"
|
||||
:key="'available-' + tag"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-blue-100 hover:text-blue-700"
|
||||
type="button"
|
||||
@click="selectTag(tag)"
|
||||
>
|
||||
<i class="fas fa-tag text-xs text-gray-500" />
|
||||
{{ tag }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建新标签 -->
|
||||
<div>
|
||||
<div class="mb-2 text-xs font-medium text-gray-600">创建新标签:</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="form-input flex-1"
|
||||
placeholder="输入新标签名称"
|
||||
type="text"
|
||||
@keypress.enter.prevent="addTag"
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-green-500 px-4 py-2 text-white transition-colors hover:bg-green-600"
|
||||
type="button"
|
||||
@click="addTag"
|
||||
>
|
||||
<i class="fas fa-plus" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 速率限制设置 -->
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-blue-500"
|
||||
>
|
||||
<i class="fas fa-tachometer-alt text-xs text-white" />
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-gray-800">速率限制设置</h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700">
|
||||
时间窗口 (分钟)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
class="form-input w-full text-sm"
|
||||
min="1"
|
||||
placeholder="不修改"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700">请求次数限制</label>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
class="form-input w-full text-sm"
|
||||
min="1"
|
||||
placeholder="不修改"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700">Token 限制</label>
|
||||
<input
|
||||
v-model="form.tokenLimit"
|
||||
class="form-input w-full text-sm"
|
||||
placeholder="不修改"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 每日费用限制 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">
|
||||
每日费用限制 (美元)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
class="form-input w-full"
|
||||
min="0"
|
||||
placeholder="不修改 (0 表示无限制)"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 并发限制 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">并发限制</label>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
class="form-input w-full"
|
||||
min="0"
|
||||
placeholder="不修改 (0 表示无限制)"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 激活状态 -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center gap-4">
|
||||
<label class="text-sm font-semibold text-gray-700">激活状态</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.isActive" class="mr-2" type="radio" :value="true" />
|
||||
<span class="text-sm text-gray-700">激活</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.isActive" class="mr-2" type="radio" :value="false" />
|
||||
<span class="text-sm text-gray-700">禁用</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.isActive" class="mr-2" type="radio" :value="null" />
|
||||
<span class="text-sm text-gray-700">不修改</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务权限 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">服务权限</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="" />
|
||||
<span class="text-sm text-gray-700">不修改</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="all" />
|
||||
<span class="text-sm text-gray-700">全部服务</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="claude" />
|
||||
<span class="text-sm text-gray-700">仅 Claude</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="gemini" />
|
||||
<span class="text-sm text-gray-700">仅 Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="openai" />
|
||||
<span class="text-sm text-gray-700">仅 OpenAI</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 专属账号绑定 -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="text-sm font-semibold text-gray-700">专属账号绑定</label>
|
||||
<button
|
||||
class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="accountsLoading"
|
||||
title="刷新账号列表"
|
||||
type="button"
|
||||
@click="refreshAccounts"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'fas',
|
||||
accountsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt',
|
||||
'text-xs'
|
||||
]"
|
||||
/>
|
||||
<span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600">Claude 专属账号</label>
|
||||
<select
|
||||
v-model="form.claudeAccountId"
|
||||
class="form-input w-full"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
<option value="SHARED_POOL">使用共享账号池</option>
|
||||
<optgroup v-if="localAccounts.claudeGroups.length > 0" label="账号分组">
|
||||
<option
|
||||
v-for="group in localAccounts.claudeGroups"
|
||||
:key="group.id"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
分组 - {{ group.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="localAccounts.claude.length > 0" label="专属账号">
|
||||
<option
|
||||
v-for="account in localAccounts.claude"
|
||||
:key="account.id"
|
||||
:value="
|
||||
account.platform === 'claude-console' ? `console:${account.id}` : account.id
|
||||
"
|
||||
>
|
||||
{{ account.name }} ({{
|
||||
account.platform === 'claude-console' ? 'Console' : 'OAuth'
|
||||
}})
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600">Gemini 专属账号</label>
|
||||
<select
|
||||
v-model="form.geminiAccountId"
|
||||
class="form-input w-full"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
<option value="SHARED_POOL">使用共享账号池</option>
|
||||
<optgroup v-if="localAccounts.geminiGroups.length > 0" label="账号分组">
|
||||
<option
|
||||
v-for="group in localAccounts.geminiGroups"
|
||||
:key="group.id"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
分组 - {{ group.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="localAccounts.gemini.length > 0" label="专属账号">
|
||||
<option
|
||||
v-for="account in localAccounts.gemini"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600">OpenAI 专属账号</label>
|
||||
<select
|
||||
v-model="form.openaiAccountId"
|
||||
class="form-input w-full"
|
||||
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
<option value="SHARED_POOL">使用共享账号池</option>
|
||||
<optgroup v-if="localAccounts.openaiGroups.length > 0" label="账号分组">
|
||||
<option
|
||||
v-for="group in localAccounts.openaiGroups"
|
||||
:key="group.id"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
分组 - {{ group.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="localAccounts.openai.length > 0" label="专属账号">
|
||||
<option
|
||||
v-for="account in localAccounts.openai"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600">Bedrock 专属账号</label>
|
||||
<select
|
||||
v-model="form.bedrockAccountId"
|
||||
class="form-input w-full"
|
||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
||||
>
|
||||
<option value="">不修改</option>
|
||||
<option value="SHARED_POOL">使用共享账号池</option>
|
||||
<optgroup v-if="localAccounts.bedrock.length > 0" label="专属账号">
|
||||
<option
|
||||
v-for="account in localAccounts.bedrock"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
:disabled="loading"
|
||||
type="submit"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{ loading ? '保存中...' : '批量保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const props = defineProps({
|
||||
selectedKeys: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
accounts: {
|
||||
type: Object,
|
||||
default: () => ({ claude: [], gemini: [], openai: [], bedrock: [] })
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const apiKeysStore = useApiKeysStore()
|
||||
const loading = ref(false)
|
||||
const accountsLoading = ref(false)
|
||||
const localAccounts = ref({
|
||||
claude: [],
|
||||
gemini: [],
|
||||
openai: [],
|
||||
bedrock: [],
|
||||
claudeGroups: [],
|
||||
geminiGroups: [],
|
||||
openaiGroups: []
|
||||
})
|
||||
|
||||
// 标签相关
|
||||
const newTag = ref('')
|
||||
const availableTags = ref([])
|
||||
const tagOperation = ref('none') // 'replace', 'add', 'remove', 'none'
|
||||
|
||||
const selectedCount = computed(() => props.selectedKeys.length)
|
||||
|
||||
// 计算未选择的标签
|
||||
const unselectedTags = computed(() => {
|
||||
return availableTags.value.filter((tag) => !form.tags.includes(tag))
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
tokenLimit: '',
|
||||
rateLimitWindow: '',
|
||||
rateLimitRequests: '',
|
||||
concurrencyLimit: '',
|
||||
dailyCostLimit: '',
|
||||
permissions: '', // 空字符串表示不修改
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
openaiAccountId: '',
|
||||
bedrockAccountId: '',
|
||||
tags: [],
|
||||
isActive: null // null表示不修改
|
||||
})
|
||||
|
||||
// 标签管理方法
|
||||
const addTag = () => {
|
||||
if (newTag.value && newTag.value.trim()) {
|
||||
const tag = newTag.value.trim()
|
||||
if (!form.tags.includes(tag)) {
|
||||
form.tags.push(tag)
|
||||
}
|
||||
newTag.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const selectTag = (tag) => {
|
||||
if (!form.tags.includes(tag)) {
|
||||
form.tags.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (index) => {
|
||||
form.tags.splice(index, 1)
|
||||
}
|
||||
|
||||
// 刷新账号列表
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
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) {
|
||||
claudeData.data?.forEach((account) => {
|
||||
claudeAccounts.push({
|
||||
...account,
|
||||
platform: 'claude-oauth',
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (claudeConsoleData.success) {
|
||||
claudeConsoleData.data?.forEach((account) => {
|
||||
claudeAccounts.push({
|
||||
...account,
|
||||
platform: 'claude-console',
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value.claude = claudeAccounts
|
||||
|
||||
if (geminiData.success) {
|
||||
localAccounts.value.gemini = (geminiData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
}))
|
||||
}
|
||||
|
||||
if (openaiData.success) {
|
||||
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
}))
|
||||
}
|
||||
|
||||
if (bedrockData.success) {
|
||||
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
}))
|
||||
}
|
||||
|
||||
// 处理分组数据
|
||||
if (groupsData.success) {
|
||||
const allGroups = groupsData.data || []
|
||||
localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
|
||||
localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
|
||||
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
||||
}
|
||||
|
||||
showToast('账号列表已刷新', 'success')
|
||||
} catch (error) {
|
||||
showToast('刷新账号列表失败', 'error')
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 批量更新API Keys
|
||||
const batchUpdateApiKeys = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 准备提交的数据
|
||||
const updates = {}
|
||||
|
||||
// 只有非空值才添加到更新对象中
|
||||
if (form.tokenLimit !== '' && form.tokenLimit !== null) {
|
||||
updates.tokenLimit = parseInt(form.tokenLimit)
|
||||
}
|
||||
if (form.rateLimitWindow !== '' && form.rateLimitWindow !== null) {
|
||||
updates.rateLimitWindow = parseInt(form.rateLimitWindow)
|
||||
}
|
||||
if (form.rateLimitRequests !== '' && form.rateLimitRequests !== null) {
|
||||
updates.rateLimitRequests = parseInt(form.rateLimitRequests)
|
||||
}
|
||||
if (form.concurrencyLimit !== '' && form.concurrencyLimit !== null) {
|
||||
updates.concurrencyLimit = parseInt(form.concurrencyLimit)
|
||||
}
|
||||
if (form.dailyCostLimit !== '' && form.dailyCostLimit !== null) {
|
||||
updates.dailyCostLimit = parseFloat(form.dailyCostLimit)
|
||||
}
|
||||
|
||||
// 权限设置
|
||||
if (form.permissions !== '') {
|
||||
updates.permissions = form.permissions
|
||||
}
|
||||
|
||||
// 账户绑定
|
||||
if (form.claudeAccountId !== '') {
|
||||
if (form.claudeAccountId === 'SHARED_POOL') {
|
||||
updates.claudeAccountId = null
|
||||
updates.claudeConsoleAccountId = null
|
||||
} else if (form.claudeAccountId.startsWith('console:')) {
|
||||
updates.claudeConsoleAccountId = form.claudeAccountId.substring(8)
|
||||
updates.claudeAccountId = null
|
||||
} else if (!form.claudeAccountId.startsWith('group:')) {
|
||||
updates.claudeAccountId = form.claudeAccountId
|
||||
updates.claudeConsoleAccountId = null
|
||||
} else {
|
||||
updates.claudeAccountId = form.claudeAccountId
|
||||
updates.claudeConsoleAccountId = null
|
||||
}
|
||||
}
|
||||
|
||||
if (form.geminiAccountId !== '') {
|
||||
if (form.geminiAccountId === 'SHARED_POOL') {
|
||||
updates.geminiAccountId = null
|
||||
} else {
|
||||
updates.geminiAccountId = form.geminiAccountId
|
||||
}
|
||||
}
|
||||
|
||||
if (form.openaiAccountId !== '') {
|
||||
if (form.openaiAccountId === 'SHARED_POOL') {
|
||||
updates.openaiAccountId = null
|
||||
} else {
|
||||
updates.openaiAccountId = form.openaiAccountId
|
||||
}
|
||||
}
|
||||
|
||||
if (form.bedrockAccountId !== '') {
|
||||
if (form.bedrockAccountId === 'SHARED_POOL') {
|
||||
updates.bedrockAccountId = null
|
||||
} else {
|
||||
updates.bedrockAccountId = form.bedrockAccountId
|
||||
}
|
||||
}
|
||||
|
||||
// 激活状态
|
||||
if (form.isActive !== null) {
|
||||
updates.isActive = form.isActive
|
||||
}
|
||||
|
||||
// 标签处理
|
||||
if (tagOperation.value !== 'none') {
|
||||
// 这里需要在后端实现标签操作逻辑,目前简化为直接替换
|
||||
if (tagOperation.value === 'replace') {
|
||||
updates.tags = form.tags
|
||||
}
|
||||
// TODO: 实现添加和移除标签的逻辑
|
||||
else if (tagOperation.value === 'add' || tagOperation.value === 'remove') {
|
||||
updates.tags = form.tags
|
||||
updates.tagOperation = tagOperation.value
|
||||
}
|
||||
}
|
||||
|
||||
const result = await apiClient.put('/admin/api-keys/batch', {
|
||||
keyIds: props.selectedKeys,
|
||||
updates
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
const { successCount, failedCount, errors } = result.data
|
||||
|
||||
if (successCount > 0) {
|
||||
showToast(`成功批量编辑 ${successCount} 个 API Keys`, 'success')
|
||||
|
||||
if (failedCount > 0) {
|
||||
const errorMessages = errors.map((e) => `${e.keyId}: ${e.error}`).join('\n')
|
||||
showToast(`${failedCount} 个编辑失败:\n${errorMessages}`, 'warning')
|
||||
}
|
||||
} else {
|
||||
showToast('所有 API Keys 编辑失败', 'error')
|
||||
}
|
||||
|
||||
emit('success')
|
||||
emit('close')
|
||||
} else {
|
||||
showToast(result.message || '批量编辑失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('批量编辑失败', 'error')
|
||||
console.error('批量编辑 API Keys 失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 加载已存在的标签
|
||||
availableTags.value = await apiKeysStore.fetchTags()
|
||||
|
||||
// 初始化账号数据
|
||||
if (props.accounts) {
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || [],
|
||||
openai: props.accounts.openai || [],
|
||||
bedrock: props.accounts.bedrock || [],
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || [],
|
||||
openaiGroups: props.accounts.openaiGroups || []
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 表单样式由全局样式提供 */
|
||||
</style>
|
||||
@@ -89,6 +89,19 @@
|
||||
<span class="relative">刷新</span>
|
||||
</button>
|
||||
|
||||
<!-- 批量编辑按钮 -->
|
||||
<button
|
||||
v-if="selectedApiKeys.length > 0"
|
||||
class="group relative flex items-center justify-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-medium text-blue-700 shadow-sm transition-all duration-200 hover:border-blue-300 hover:bg-blue-100 hover:shadow-md sm:w-auto"
|
||||
@click="openBatchEditModal()"
|
||||
>
|
||||
<div
|
||||
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||
></div>
|
||||
<i class="fas fa-edit relative text-blue-600" />
|
||||
<span class="relative">编辑选中 ({{ selectedApiKeys.length }})</span>
|
||||
</button>
|
||||
|
||||
<!-- 批量删除按钮 -->
|
||||
<button
|
||||
v-if="selectedApiKeys.length > 0"
|
||||
@@ -306,13 +319,26 @@
|
||||
{{ getOpenAIBindingInfo(key) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Bedrock 绑定 -->
|
||||
<div v-if="key.bedrockAccountId" class="flex items-center gap-1 text-xs">
|
||||
<span
|
||||
class="inline-flex items-center rounded bg-orange-100 px-1.5 py-0.5 text-orange-700"
|
||||
>
|
||||
<i class="fas fa-cloud mr-1 text-[10px]" />
|
||||
Bedrock
|
||||
</span>
|
||||
<span class="truncate text-gray-600">
|
||||
{{ getBedrockBindingInfo(key) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 无绑定时显示共享池 -->
|
||||
<div
|
||||
v-if="
|
||||
!key.claudeAccountId &&
|
||||
!key.claudeConsoleAccountId &&
|
||||
!key.geminiAccountId &&
|
||||
!key.openaiAccountId
|
||||
!key.openaiAccountId &&
|
||||
!key.bedrockAccountId
|
||||
"
|
||||
class="text-xs text-gray-500"
|
||||
>
|
||||
@@ -859,13 +885,26 @@
|
||||
{{ getOpenAIBindingInfo(key) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Bedrock 绑定 -->
|
||||
<div v-if="key.bedrockAccountId" class="flex flex-wrap items-center gap-1 text-xs">
|
||||
<span
|
||||
class="inline-flex items-center rounded bg-orange-100 px-2 py-0.5 text-orange-700"
|
||||
>
|
||||
<i class="fas fa-cloud mr-1" />
|
||||
Bedrock
|
||||
</span>
|
||||
<span class="text-gray-600">
|
||||
{{ getBedrockBindingInfo(key) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 无绑定时显示共享池 -->
|
||||
<div
|
||||
v-if="
|
||||
!key.claudeAccountId &&
|
||||
!key.claudeConsoleAccountId &&
|
||||
!key.geminiAccountId &&
|
||||
!key.openaiAccountId
|
||||
!key.openaiAccountId &&
|
||||
!key.bedrockAccountId
|
||||
"
|
||||
class="text-xs text-gray-500"
|
||||
>
|
||||
@@ -1158,6 +1197,14 @@
|
||||
@close="showBatchApiKeyModal = false"
|
||||
/>
|
||||
|
||||
<BatchEditApiKeyModal
|
||||
v-if="showBatchEditModal"
|
||||
:accounts="accounts"
|
||||
:selected-keys="selectedApiKeys"
|
||||
@close="showBatchEditModal = false"
|
||||
@success="handleBatchEditSuccess"
|
||||
/>
|
||||
|
||||
<!-- 过期时间编辑弹窗 -->
|
||||
<ExpiryEditModal
|
||||
ref="expiryEditModalRef"
|
||||
@@ -1186,6 +1233,7 @@ import EditApiKeyModal from '@/components/apikeys/EditApiKeyModal.vue'
|
||||
import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
|
||||
import NewApiKeyModal from '@/components/apikeys/NewApiKeyModal.vue'
|
||||
import BatchApiKeyModal from '@/components/apikeys/BatchApiKeyModal.vue'
|
||||
import BatchEditApiKeyModal from '@/components/apikeys/BatchEditApiKeyModal.vue'
|
||||
import ExpiryEditModal from '@/components/apikeys/ExpiryEditModal.vue'
|
||||
import UsageDetailModal from '@/components/apikeys/UsageDetailModal.vue'
|
||||
import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
|
||||
@@ -1211,6 +1259,7 @@ const accounts = ref({
|
||||
claude: [],
|
||||
gemini: [],
|
||||
openai: [],
|
||||
bedrock: [],
|
||||
claudeGroups: [],
|
||||
geminiGroups: [],
|
||||
openaiGroups: []
|
||||
@@ -1260,6 +1309,7 @@ const showEditApiKeyModal = ref(false)
|
||||
const showRenewApiKeyModal = ref(false)
|
||||
const showNewApiKeyModal = ref(false)
|
||||
const showBatchApiKeyModal = ref(false)
|
||||
const showBatchEditModal = ref(false)
|
||||
const editingApiKey = ref(null)
|
||||
const renewingApiKey = ref(null)
|
||||
const newApiKeyData = ref(null)
|
||||
@@ -1358,33 +1408,60 @@ const paginatedApiKeys = computed(() => {
|
||||
// 加载账户列表
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, groupsData] = await Promise.all([
|
||||
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) {
|
||||
@@ -1494,6 +1571,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)}`
|
||||
}
|
||||
@@ -1567,6 +1650,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
|
||||
@@ -1799,6 +1902,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
|
||||
|
||||
Reference in New Issue
Block a user