feat: 实现Claude账户专属绑定功能

- 添加账户类型(dedicated/shared)支持
- API Key可绑定专属账户,优先使用绑定账户
- 未绑定的API Key继续使用共享池和粘性会话
- 修复专属账户下拉框显示问题(isActive类型不匹配)
- 修复getBoundAccountName方法未定义错误
- 添加删除账户前的API Key绑定检查
- 完全保留原有粘性会话机制

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-17 08:50:12 +08:00
parent a64ced0e36
commit ee9bd4aea4
5 changed files with 293 additions and 28 deletions

View File

@@ -103,7 +103,8 @@ const app = createApp({
name: '',
tokenLimit: '',
description: '',
concurrencyLimit: ''
concurrencyLimit: '',
claudeAccountId: ''
},
apiKeyModelStats: {}, // 存储每个key的模型统计数据
expandedApiKeys: {}, // 跟踪展开的API Keys
@@ -140,7 +141,8 @@ const app = createApp({
id: '',
name: '',
tokenLimit: '',
concurrencyLimit: ''
concurrencyLimit: '',
claudeAccountId: ''
},
// 账户
@@ -152,6 +154,7 @@ const app = createApp({
name: '',
description: '',
addType: 'oauth', // 'oauth' 或 'manual'
accountType: 'shared', // 'shared' 或 'dedicated'
accessToken: '',
refreshToken: '',
proxyType: '',
@@ -168,6 +171,8 @@ const app = createApp({
id: '',
name: '',
description: '',
accountType: 'shared',
originalAccountType: 'shared',
accessToken: '',
refreshToken: '',
proxyType: '',
@@ -207,6 +212,13 @@ const app = createApp({
// 动态计算BASE_URL
currentBaseUrl() {
return `${window.location.protocol}//${window.location.host}/api/`;
},
// 获取专属账号列表
dedicatedAccounts() {
return this.accounts.filter(account =>
account.accountType === 'dedicated' && account.isActive === true
);
}
},
@@ -269,6 +281,17 @@ const app = createApp({
},
methods: {
// 获取绑定账号名称
getBoundAccountName(accountId) {
const account = this.accounts.find(acc => acc.id === accountId);
return account ? account.name : '未知账号';
},
// 获取绑定到特定账号的API Key数量
getBoundApiKeysCount(accountId) {
return this.apiKeys.filter(key => key.claudeAccountId === accountId).length;
},
// Toast 通知方法
showToast(message, type = 'info', title = null, duration = 5000) {
const id = ++this.toastIdCounter;
@@ -356,6 +379,8 @@ const app = createApp({
id: account.id,
name: account.name,
description: account.description || '',
accountType: account.accountType || 'shared',
originalAccountType: account.accountType || 'shared',
accessToken: '',
refreshToken: '',
proxyType: account.proxy ? account.proxy.type : '',
@@ -374,6 +399,8 @@ const app = createApp({
id: '',
name: '',
description: '',
accountType: 'shared',
originalAccountType: 'shared',
accessToken: '',
refreshToken: '',
proxyType: '',
@@ -388,10 +415,21 @@ const app = createApp({
async updateAccount() {
this.editAccountLoading = true;
try {
// 验证账户类型切换
if (this.editAccountForm.accountType === 'shared' &&
this.editAccountForm.originalAccountType === 'dedicated') {
const boundKeysCount = this.getBoundApiKeysCount(this.editAccountForm.id);
if (boundKeysCount > 0) {
this.showToast(`无法切换到共享账户,该账户绑定了 ${boundKeysCount} 个API Key请先解绑所有API Key`, 'error', '切换失败');
return;
}
}
// 构建更新数据
let updateData = {
name: this.editAccountForm.name,
description: this.editAccountForm.description
description: this.editAccountForm.description,
accountType: this.editAccountForm.accountType
};
// 只在有值时才更新 token
@@ -465,6 +503,7 @@ const app = createApp({
name: '',
description: '',
addType: 'oauth',
accountType: 'shared',
accessToken: '',
refreshToken: '',
proxyType: '',
@@ -593,7 +632,8 @@ const app = createApp({
name: this.accountForm.name,
description: this.accountForm.description,
claudeAiOauth: exchangeData.data.claudeAiOauth,
proxy: proxy
proxy: proxy,
accountType: this.accountForm.accountType
})
});
@@ -665,7 +705,8 @@ const app = createApp({
name: this.accountForm.name,
description: this.accountForm.description,
claudeAiOauth: manualOauthData,
proxy: proxy
proxy: proxy,
accountType: this.accountForm.accountType
})
});
@@ -1054,6 +1095,10 @@ const app = createApp({
if (data.success) {
this.accounts = data.data || [];
// 为每个账号计算绑定的API Key数量
this.accounts.forEach(account => {
account.boundApiKeysCount = this.apiKeys.filter(key => key.claudeAccountId === account.id).length;
});
}
} catch (error) {
console.error('Failed to load accounts:', error);
@@ -1097,7 +1142,8 @@ const app = createApp({
name: this.apiKeyForm.name,
tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null,
description: this.apiKeyForm.description || '',
concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0
concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0,
claudeAccountId: this.apiKeyForm.claudeAccountId || null
})
});
@@ -1115,7 +1161,7 @@ const app = createApp({
// 关闭创建弹窗并清理表单
this.showCreateApiKeyModal = false;
this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '' };
this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '', claudeAccountId: '' };
// 重新加载API Keys列表
await this.loadApiKeys();
@@ -1158,7 +1204,8 @@ const app = createApp({
id: key.id,
name: key.name,
tokenLimit: key.tokenLimit || '',
concurrencyLimit: key.concurrencyLimit || ''
concurrencyLimit: key.concurrencyLimit || '',
claudeAccountId: key.claudeAccountId || ''
};
this.showEditApiKeyModal = true;
},
@@ -1169,7 +1216,8 @@ const app = createApp({
id: '',
name: '',
tokenLimit: '',
concurrencyLimit: ''
concurrencyLimit: '',
claudeAccountId: ''
};
},
@@ -1184,7 +1232,8 @@ const app = createApp({
},
body: JSON.stringify({
tokenLimit: this.editApiKeyForm.tokenLimit && this.editApiKeyForm.tokenLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.tokenLimit) : 0,
concurrencyLimit: this.editApiKeyForm.concurrencyLimit && this.editApiKeyForm.concurrencyLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.concurrencyLimit) : 0
concurrencyLimit: this.editApiKeyForm.concurrencyLimit && this.editApiKeyForm.concurrencyLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.concurrencyLimit) : 0,
claudeAccountId: this.editApiKeyForm.claudeAccountId || null
})
});
@@ -1206,6 +1255,13 @@ const app = createApp({
},
async deleteAccount(accountId) {
// 检查是否有API Key绑定到此账号
const boundKeysCount = this.getBoundApiKeysCount(accountId);
if (boundKeysCount > 0) {
this.showToast(`无法删除此账号,有 ${boundKeysCount} 个API Key绑定到此账号请先解绑所有API Key`, 'error', '删除失败');
return;
}
if (!confirm('确定要删除这个 Claude 账户吗?')) return;
try {

View File

@@ -423,6 +423,16 @@
<div>
<div class="text-sm font-semibold text-gray-900">{{ key.name }}</div>
<div class="text-xs text-gray-500">{{ key.id }}</div>
<div class="text-xs text-gray-500 mt-1">
<span v-if="key.claudeAccountId">
<i class="fas fa-link mr-1"></i>
绑定: {{ getBoundAccountName(key.claudeAccountId) }}
</span>
<span v-else>
<i class="fas fa-share-alt mr-1"></i>
使用共享池
</span>
</div>
</div>
</div>
</td>
@@ -739,7 +749,17 @@
<i class="fas fa-user-circle text-white text-xs"></i>
</div>
<div>
<div class="text-sm font-semibold text-gray-900">{{ account.name }}</div>
<div class="flex items-center gap-2">
<div class="text-sm font-semibold text-gray-900">{{ account.name }}</div>
<span v-if="account.accountType === 'dedicated'"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<i class="fas fa-lock mr-1"></i>专属
</span>
<span v-else
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<i class="fas fa-share-alt mr-1"></i>共享
</span>
</div>
<div class="text-xs text-gray-500">{{ account.id }}</div>
</div>
</div>
@@ -755,12 +775,18 @@
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
account.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
<div :class="['w-2 h-2 rounded-full mr-2',
account.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
{{ account.isActive ? '正常' : '异常' }}
</span>
<div class="flex flex-col gap-1">
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
account.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
<div :class="['w-2 h-2 rounded-full mr-2',
account.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
{{ account.isActive ? '正常' : '异常' }}
</span>
<span v-if="account.accountType === 'dedicated'"
class="text-xs text-gray-500">
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<div v-if="account.proxy" class="text-xs bg-blue-50 px-2 py-1 rounded">
@@ -1738,6 +1764,24 @@
></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定 (可选)</label>
<select
v-model="apiKeyForm.claudeAccountId"
class="form-input w-full"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
<p class="text-xs text-gray-500 mt-2">选择专属账号后此API Key将只使用该账号不选择则使用共享账号池</p>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
@@ -1814,6 +1858,24 @@
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数0 或留空表示无限制</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定</label>
<select
v-model="editApiKeyForm.claudeAccountId"
class="form-input w-full"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
<p class="text-xs text-gray-500 mt-2">修改绑定账号将影响此API Key的请求路由</p>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
@@ -2017,6 +2079,33 @@
></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">账户类型</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="accountForm.accountType"
value="shared"
class="mr-2"
>
<span class="text-sm text-gray-700">共享账户</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="accountForm.accountType"
value="dedicated"
class="mr-2"
>
<span class="text-sm text-gray-700">专属账户</span>
</label>
</div>
<p class="text-xs text-gray-500 mt-2">
共享账户供所有API Key使用专属账户仅供特定API Key使用
</p>
</div>
<!-- 手动输入 Token 字段 -->
<div v-if="accountForm.addType === 'manual'" class="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-200">
<div class="flex items-start gap-3 mb-4">
@@ -2299,6 +2388,43 @@
></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">账户类型</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="editAccountForm.accountType"
value="shared"
class="mr-2"
>
<span class="text-sm text-gray-700">共享账户</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="editAccountForm.accountType"
value="dedicated"
class="mr-2"
>
<span class="text-sm text-gray-700">专属账户</span>
</label>
</div>
<div v-if="editAccountForm.accountType === 'shared' && editAccountForm.originalAccountType === 'dedicated'"
class="mt-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-start gap-2">
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5"></i>
<div>
<p class="text-sm text-yellow-800 font-medium">切换到共享账户需要验证</p>
<p class="text-xs text-yellow-700 mt-1">当前账户绑定了 {{ getBoundApiKeysCount(editAccountForm.id) }} 个API Key需要先解绑所有API Key才能切换到共享账户。</p>
</div>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
共享账户供所有API Key使用专属账户仅供特定API Key使用
</p>
</div>
<!-- Token 更新区域 -->
<div class="bg-amber-50 p-4 rounded-lg border border-amber-200">
<div class="flex items-start gap-3 mb-4">