mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge branch 'dev'
This commit is contained in:
@@ -78,9 +78,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { keyId } = req.params;
|
const { keyId } = req.params;
|
||||||
const { tokenLimit, concurrencyLimit } = req.body;
|
const { tokenLimit, concurrencyLimit, claudeAccountId } = req.body;
|
||||||
|
|
||||||
// 只允许更新tokenLimit和concurrencyLimit
|
// 只允许更新tokenLimit、concurrencyLimit和claudeAccountId
|
||||||
const updates = {};
|
const updates = {};
|
||||||
|
|
||||||
if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') {
|
if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') {
|
||||||
@@ -97,6 +97,11 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
updates.concurrencyLimit = Number(concurrencyLimit);
|
updates.concurrencyLimit = Number(concurrencyLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (claudeAccountId !== undefined) {
|
||||||
|
// 空字符串表示解绑,null或空字符串都设置为空字符串
|
||||||
|
updates.claudeAccountId = claudeAccountId || '';
|
||||||
|
}
|
||||||
|
|
||||||
await apiKeyService.updateApiKey(keyId, updates);
|
await apiKeyService.updateApiKey(keyId, updates);
|
||||||
|
|
||||||
logger.success(`📝 Admin updated API key: ${keyId}`);
|
logger.success(`📝 Admin updated API key: ${keyId}`);
|
||||||
@@ -244,13 +249,19 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
password,
|
password,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
claudeAiOauth,
|
claudeAiOauth,
|
||||||
proxy
|
proxy,
|
||||||
|
accountType
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({ error: 'Name is required' });
|
return res.status(400).json({ error: 'Name is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (accountType && !['shared', 'dedicated'].includes(accountType)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' });
|
||||||
|
}
|
||||||
|
|
||||||
const newAccount = await claudeAccountService.createAccount({
|
const newAccount = await claudeAccountService.createAccount({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -258,10 +269,11 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
password,
|
password,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
claudeAiOauth,
|
claudeAiOauth,
|
||||||
proxy
|
proxy,
|
||||||
|
accountType: accountType || 'shared' // 默认为共享类型
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.success(`🏢 Admin created new Claude account: ${name}`);
|
logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`);
|
||||||
res.json({ success: true, data: newAccount });
|
res.json({ success: true, data: newAccount });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to create Claude account:', error);
|
logger.error('❌ Failed to create Claude account:', error);
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ class ClaudeAccountService {
|
|||||||
refreshToken = '',
|
refreshToken = '',
|
||||||
claudeAiOauth = null, // Claude标准格式的OAuth数据
|
claudeAiOauth = null, // Claude标准格式的OAuth数据
|
||||||
proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' }
|
proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' }
|
||||||
isActive = true
|
isActive = true,
|
||||||
|
accountType = 'shared' // 'dedicated' or 'shared'
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const accountId = uuidv4();
|
const accountId = uuidv4();
|
||||||
@@ -49,6 +50,7 @@ class ClaudeAccountService {
|
|||||||
scopes: claudeAiOauth.scopes.join(' '),
|
scopes: claudeAiOauth.scopes.join(' '),
|
||||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||||
isActive: isActive.toString(),
|
isActive: isActive.toString(),
|
||||||
|
accountType: accountType, // 账号类型:'dedicated' 或 'shared'
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
lastUsedAt: '',
|
lastUsedAt: '',
|
||||||
lastRefreshAt: '',
|
lastRefreshAt: '',
|
||||||
@@ -69,6 +71,7 @@ class ClaudeAccountService {
|
|||||||
scopes: '',
|
scopes: '',
|
||||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||||
isActive: isActive.toString(),
|
isActive: isActive.toString(),
|
||||||
|
accountType: accountType, // 账号类型:'dedicated' 或 'shared'
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
lastUsedAt: '',
|
lastUsedAt: '',
|
||||||
lastRefreshAt: '',
|
lastRefreshAt: '',
|
||||||
@@ -88,6 +91,7 @@ class ClaudeAccountService {
|
|||||||
email,
|
email,
|
||||||
isActive,
|
isActive,
|
||||||
proxy,
|
proxy,
|
||||||
|
accountType,
|
||||||
status: accountData.status,
|
status: accountData.status,
|
||||||
createdAt: accountData.createdAt,
|
createdAt: accountData.createdAt,
|
||||||
expiresAt: accountData.expiresAt,
|
expiresAt: accountData.expiresAt,
|
||||||
@@ -234,6 +238,7 @@ class ClaudeAccountService {
|
|||||||
proxy: account.proxy ? JSON.parse(account.proxy) : null,
|
proxy: account.proxy ? JSON.parse(account.proxy) : null,
|
||||||
status: account.status,
|
status: account.status,
|
||||||
errorMessage: account.errorMessage,
|
errorMessage: account.errorMessage,
|
||||||
|
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
|
||||||
createdAt: account.createdAt,
|
createdAt: account.createdAt,
|
||||||
lastUsedAt: account.lastUsedAt,
|
lastUsedAt: account.lastUsedAt,
|
||||||
lastRefreshAt: account.lastRefreshAt,
|
lastRefreshAt: account.lastRefreshAt,
|
||||||
@@ -254,7 +259,7 @@ class ClaudeAccountService {
|
|||||||
throw new Error('Account not found');
|
throw new Error('Account not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth'];
|
const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth', 'accountType'];
|
||||||
const updatedData = { ...accountData };
|
const updatedData = { ...accountData };
|
||||||
|
|
||||||
for (const [field, value] of Object.entries(updates)) {
|
for (const [field, value] of Object.entries(updates)) {
|
||||||
@@ -366,6 +371,72 @@ class ClaudeAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🎯 基于API Key选择账户(支持专属绑定和共享池)
|
||||||
|
async selectAccountForApiKey(apiKeyData, sessionHash = null) {
|
||||||
|
try {
|
||||||
|
// 如果API Key绑定了专属账户,优先使用
|
||||||
|
if (apiKeyData.claudeAccountId) {
|
||||||
|
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId);
|
||||||
|
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||||
|
logger.info(`🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`);
|
||||||
|
return apiKeyData.claudeAccountId;
|
||||||
|
} else {
|
||||||
|
logger.warn(`⚠️ Bound account ${apiKeyData.claudeAccountId} is not available, falling back to shared pool`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有绑定账户或绑定账户不可用,从共享池选择
|
||||||
|
const accounts = await redis.getAllClaudeAccounts();
|
||||||
|
|
||||||
|
const sharedAccounts = accounts.filter(account =>
|
||||||
|
account.isActive === 'true' &&
|
||||||
|
account.status !== 'error' &&
|
||||||
|
(account.accountType === 'shared' || !account.accountType) // 兼容旧数据
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sharedAccounts.length === 0) {
|
||||||
|
throw new Error('No active shared Claude accounts available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有会话哈希,检查是否有已映射的账户
|
||||||
|
if (sessionHash) {
|
||||||
|
const mappedAccountId = await redis.getSessionAccountMapping(sessionHash);
|
||||||
|
if (mappedAccountId) {
|
||||||
|
// 验证映射的账户是否仍然在共享池中且可用
|
||||||
|
const mappedAccount = sharedAccounts.find(acc => acc.id === mappedAccountId);
|
||||||
|
if (mappedAccount) {
|
||||||
|
logger.info(`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`);
|
||||||
|
return mappedAccountId;
|
||||||
|
} else {
|
||||||
|
logger.warn(`⚠️ Mapped shared account ${mappedAccountId} is no longer available, selecting new account`);
|
||||||
|
// 清理无效的映射
|
||||||
|
await redis.deleteSessionAccountMapping(sessionHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从共享池选择账户(负载均衡)
|
||||||
|
const sortedAccounts = sharedAccounts.sort((a, b) => {
|
||||||
|
const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime();
|
||||||
|
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime();
|
||||||
|
return bLastRefresh - aLastRefresh;
|
||||||
|
});
|
||||||
|
const selectedAccountId = sortedAccounts[0].id;
|
||||||
|
|
||||||
|
// 如果有会话哈希,建立新的映射
|
||||||
|
if (sessionHash) {
|
||||||
|
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600); // 1小时过期
|
||||||
|
logger.info(`🎯 Created new sticky session mapping for shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🎯 Selected shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`);
|
||||||
|
return selectedAccountId;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to select account for API key:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🌐 创建代理agent
|
// 🌐 创建代理agent
|
||||||
_createProxyAgent(proxyConfig) {
|
_createProxyAgent(proxyConfig) {
|
||||||
if (!proxyConfig) {
|
if (!proxyConfig) {
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ class ClaudeRelayService {
|
|||||||
// 生成会话哈希用于sticky会话
|
// 生成会话哈希用于sticky会话
|
||||||
const sessionHash = sessionHelper.generateSessionHash(requestBody);
|
const sessionHash = sessionHelper.generateSessionHash(requestBody);
|
||||||
|
|
||||||
// 选择可用的Claude账户(支持sticky会话)
|
// 选择可用的Claude账户(支持专属绑定和sticky会话)
|
||||||
const accountId = apiKeyData.claudeAccountId || await claudeAccountService.selectAvailableAccount(sessionHash);
|
const accountId = await claudeAccountService.selectAccountForApiKey(apiKeyData, sessionHash);
|
||||||
|
|
||||||
logger.info(`📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId}${sessionHash ? `, session: ${sessionHash}` : ''}`);
|
logger.info(`📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId}${sessionHash ? `, session: ${sessionHash}` : ''}`);
|
||||||
|
|
||||||
@@ -393,8 +393,8 @@ class ClaudeRelayService {
|
|||||||
// 生成会话哈希用于sticky会话
|
// 生成会话哈希用于sticky会话
|
||||||
const sessionHash = sessionHelper.generateSessionHash(requestBody);
|
const sessionHash = sessionHelper.generateSessionHash(requestBody);
|
||||||
|
|
||||||
// 选择可用的Claude账户(支持sticky会话)
|
// 选择可用的Claude账户(支持专属绑定和sticky会话)
|
||||||
const accountId = apiKeyData.claudeAccountId || await claudeAccountService.selectAvailableAccount(sessionHash);
|
const accountId = await claudeAccountService.selectAccountForApiKey(apiKeyData, sessionHash);
|
||||||
|
|
||||||
logger.info(`📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId}${sessionHash ? `, session: ${sessionHash}` : ''}`);
|
logger.info(`📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId}${sessionHash ? `, session: ${sessionHash}` : ''}`);
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ const app = createApp({
|
|||||||
name: '',
|
name: '',
|
||||||
tokenLimit: '',
|
tokenLimit: '',
|
||||||
description: '',
|
description: '',
|
||||||
concurrencyLimit: ''
|
concurrencyLimit: '',
|
||||||
|
claudeAccountId: ''
|
||||||
},
|
},
|
||||||
apiKeyModelStats: {}, // 存储每个key的模型统计数据
|
apiKeyModelStats: {}, // 存储每个key的模型统计数据
|
||||||
expandedApiKeys: {}, // 跟踪展开的API Keys
|
expandedApiKeys: {}, // 跟踪展开的API Keys
|
||||||
@@ -140,7 +141,8 @@ const app = createApp({
|
|||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
tokenLimit: '',
|
tokenLimit: '',
|
||||||
concurrencyLimit: ''
|
concurrencyLimit: '',
|
||||||
|
claudeAccountId: ''
|
||||||
},
|
},
|
||||||
|
|
||||||
// 账户
|
// 账户
|
||||||
@@ -152,6 +154,7 @@ const app = createApp({
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
addType: 'oauth', // 'oauth' 或 'manual'
|
addType: 'oauth', // 'oauth' 或 'manual'
|
||||||
|
accountType: 'shared', // 'shared' 或 'dedicated'
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
refreshToken: '',
|
refreshToken: '',
|
||||||
proxyType: '',
|
proxyType: '',
|
||||||
@@ -168,6 +171,8 @@ const app = createApp({
|
|||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
accountType: 'shared',
|
||||||
|
originalAccountType: 'shared',
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
refreshToken: '',
|
refreshToken: '',
|
||||||
proxyType: '',
|
proxyType: '',
|
||||||
@@ -207,6 +212,13 @@ const app = createApp({
|
|||||||
// 动态计算BASE_URL
|
// 动态计算BASE_URL
|
||||||
currentBaseUrl() {
|
currentBaseUrl() {
|
||||||
return `${window.location.protocol}//${window.location.host}/api/`;
|
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: {
|
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 通知方法
|
// Toast 通知方法
|
||||||
showToast(message, type = 'info', title = null, duration = 5000) {
|
showToast(message, type = 'info', title = null, duration = 5000) {
|
||||||
const id = ++this.toastIdCounter;
|
const id = ++this.toastIdCounter;
|
||||||
@@ -356,6 +379,8 @@ const app = createApp({
|
|||||||
id: account.id,
|
id: account.id,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
description: account.description || '',
|
description: account.description || '',
|
||||||
|
accountType: account.accountType || 'shared',
|
||||||
|
originalAccountType: account.accountType || 'shared',
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
refreshToken: '',
|
refreshToken: '',
|
||||||
proxyType: account.proxy ? account.proxy.type : '',
|
proxyType: account.proxy ? account.proxy.type : '',
|
||||||
@@ -374,6 +399,8 @@ const app = createApp({
|
|||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
accountType: 'shared',
|
||||||
|
originalAccountType: 'shared',
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
refreshToken: '',
|
refreshToken: '',
|
||||||
proxyType: '',
|
proxyType: '',
|
||||||
@@ -388,10 +415,21 @@ const app = createApp({
|
|||||||
async updateAccount() {
|
async updateAccount() {
|
||||||
this.editAccountLoading = true;
|
this.editAccountLoading = true;
|
||||||
try {
|
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 = {
|
let updateData = {
|
||||||
name: this.editAccountForm.name,
|
name: this.editAccountForm.name,
|
||||||
description: this.editAccountForm.description
|
description: this.editAccountForm.description,
|
||||||
|
accountType: this.editAccountForm.accountType
|
||||||
};
|
};
|
||||||
|
|
||||||
// 只在有值时才更新 token
|
// 只在有值时才更新 token
|
||||||
@@ -465,6 +503,7 @@ const app = createApp({
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
addType: 'oauth',
|
addType: 'oauth',
|
||||||
|
accountType: 'shared',
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
refreshToken: '',
|
refreshToken: '',
|
||||||
proxyType: '',
|
proxyType: '',
|
||||||
@@ -593,7 +632,8 @@ const app = createApp({
|
|||||||
name: this.accountForm.name,
|
name: this.accountForm.name,
|
||||||
description: this.accountForm.description,
|
description: this.accountForm.description,
|
||||||
claudeAiOauth: exchangeData.data.claudeAiOauth,
|
claudeAiOauth: exchangeData.data.claudeAiOauth,
|
||||||
proxy: proxy
|
proxy: proxy,
|
||||||
|
accountType: this.accountForm.accountType
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -665,7 +705,8 @@ const app = createApp({
|
|||||||
name: this.accountForm.name,
|
name: this.accountForm.name,
|
||||||
description: this.accountForm.description,
|
description: this.accountForm.description,
|
||||||
claudeAiOauth: manualOauthData,
|
claudeAiOauth: manualOauthData,
|
||||||
proxy: proxy
|
proxy: proxy,
|
||||||
|
accountType: this.accountForm.accountType
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1054,6 +1095,10 @@ const app = createApp({
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.accounts = data.data || [];
|
this.accounts = data.data || [];
|
||||||
|
// 为每个账号计算绑定的API Key数量
|
||||||
|
this.accounts.forEach(account => {
|
||||||
|
account.boundApiKeysCount = this.apiKeys.filter(key => key.claudeAccountId === account.id).length;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load accounts:', error);
|
console.error('Failed to load accounts:', error);
|
||||||
@@ -1097,7 +1142,8 @@ const app = createApp({
|
|||||||
name: this.apiKeyForm.name,
|
name: this.apiKeyForm.name,
|
||||||
tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null,
|
tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null,
|
||||||
description: this.apiKeyForm.description || '',
|
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.showCreateApiKeyModal = false;
|
||||||
this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '' };
|
this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '', claudeAccountId: '' };
|
||||||
|
|
||||||
// 重新加载API Keys列表
|
// 重新加载API Keys列表
|
||||||
await this.loadApiKeys();
|
await this.loadApiKeys();
|
||||||
@@ -1158,7 +1204,8 @@ const app = createApp({
|
|||||||
id: key.id,
|
id: key.id,
|
||||||
name: key.name,
|
name: key.name,
|
||||||
tokenLimit: key.tokenLimit || '',
|
tokenLimit: key.tokenLimit || '',
|
||||||
concurrencyLimit: key.concurrencyLimit || ''
|
concurrencyLimit: key.concurrencyLimit || '',
|
||||||
|
claudeAccountId: key.claudeAccountId || ''
|
||||||
};
|
};
|
||||||
this.showEditApiKeyModal = true;
|
this.showEditApiKeyModal = true;
|
||||||
},
|
},
|
||||||
@@ -1169,7 +1216,8 @@ const app = createApp({
|
|||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
tokenLimit: '',
|
tokenLimit: '',
|
||||||
concurrencyLimit: ''
|
concurrencyLimit: '',
|
||||||
|
claudeAccountId: ''
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1184,7 +1232,8 @@ const app = createApp({
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tokenLimit: this.editApiKeyForm.tokenLimit && this.editApiKeyForm.tokenLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.tokenLimit) : 0,
|
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) {
|
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;
|
if (!confirm('确定要删除这个 Claude 账户吗?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -423,6 +423,16 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-semibold text-gray-900">{{ key.name }}</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">{{ 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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -739,7 +749,17 @@
|
|||||||
<i class="fas fa-user-circle text-white text-xs"></i>
|
<i class="fas fa-user-circle text-white text-xs"></i>
|
||||||
</div>
|
</div>
|
||||||
<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 class="text-xs text-gray-500">{{ account.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -755,12 +775,18 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
|
<div class="flex flex-col gap-1">
|
||||||
account.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
|
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
|
||||||
<div :class="['w-2 h-2 rounded-full mr-2',
|
account.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
|
||||||
account.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
|
<div :class="['w-2 h-2 rounded-full mr-2',
|
||||||
{{ account.isActive ? '正常' : '异常' }}
|
account.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
|
||||||
</span>
|
{{ account.isActive ? '正常' : '异常' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="account.accountType === 'dedicated'"
|
||||||
|
class="text-xs text-gray-500">
|
||||||
|
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
<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">
|
<div v-if="account.proxy" class="text-xs bg-blue-50 px-2 py-1 rounded">
|
||||||
@@ -1738,6 +1764,24 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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">
|
<div class="flex gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1814,6 +1858,24 @@
|
|||||||
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制</p>
|
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制</p>
|
||||||
</div>
|
</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">
|
<div class="flex gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2017,6 +2079,33 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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 字段 -->
|
<!-- 手动输入 Token 字段 -->
|
||||||
<div v-if="accountForm.addType === 'manual'" class="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-200">
|
<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">
|
<div class="flex items-start gap-3 mb-4">
|
||||||
@@ -2299,6 +2388,43 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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 更新区域 -->
|
<!-- Token 更新区域 -->
|
||||||
<div class="bg-amber-50 p-4 rounded-lg border border-amber-200">
|
<div class="bg-amber-50 p-4 rounded-lg border border-amber-200">
|
||||||
<div class="flex items-start gap-3 mb-4">
|
<div class="flex items-start gap-3 mb-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user