feat: 添加Droid账户API Key管理功能

(cherry picked from commit 0cf3ca6c7eafcf28a2da7e8bfd6814b4883bb752)
This commit is contained in:
AAEE86
2025-10-13 18:19:17 +08:00
parent 268f041588
commit 1f9afc788b
6 changed files with 716 additions and 41 deletions

View File

@@ -9058,6 +9058,109 @@ router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (r
}
})
// 获取单个 Droid 账户详细信息
router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
// 获取账户基本信息
const account = await droidAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
error: 'Not Found',
message: 'Droid account not found'
})
}
// 获取使用统计信息
let usageStats
try {
usageStats = await redis.getAccountUsageStats(account.id, 'droid')
} catch (error) {
logger.debug(`Failed to get usage stats for Droid account ${account.id}:`, error)
usageStats = {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
// 获取分组信息
let groupInfos = []
try {
groupInfos = await accountGroupService.getAccountGroups(account.id)
} catch (error) {
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, error)
groupInfos = []
}
// 获取绑定的 API Key 数量
const allApiKeys = await redis.getAllApiKeys()
const groupIds = groupInfos.map((group) => group.id)
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
const binding = key.droidAccountId
if (!binding) {
return count
}
if (binding === account.id) {
return count + 1
}
if (binding.startsWith('group:')) {
const groupId = binding.substring('group:'.length)
if (groupIds.includes(groupId)) {
return count + 1
}
}
return count
}, 0)
// 获取解密的 API Keys用于管理界面
let decryptedApiKeys = []
try {
decryptedApiKeys = await droidAccountService.getDecryptedApiKeyEntries(id)
} catch (error) {
logger.debug(`Failed to get decrypted API keys for Droid account ${account.id}:`, error)
decryptedApiKeys = []
}
// 返回完整的账户信息,包含实际的 API Keys
const accountDetails = {
...account,
// 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
expiresAt: account.subscriptionExpiresAt || null,
schedulable: account.schedulable === 'true',
boundApiKeysCount,
groupInfos,
// 包含实际的 API Keys用于管理界面
apiKeys: decryptedApiKeys.map((entry) => ({
key: entry.key,
id: entry.id,
usageCount: entry.usageCount || 0,
lastUsedAt: entry.lastUsedAt || null,
status: entry.status || 'active', // 使用实际的状态,默认为 active
errorMessage: entry.errorMessage || '', // 包含错误信息
createdAt: entry.createdAt || null
})),
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages
}
}
return res.json({
success: true,
data: accountDetails
})
} catch (error) {
logger.error(`Failed to get Droid account ${req.params.id}:`, error)
return res.status(500).json({
error: 'Failed to get Droid account',
message: error.message
})
}
})
// 删除 Droid 账户
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
try {

View File

@@ -183,7 +183,10 @@ class DroidAccountService {
? []
: normalizedExisting
.filter((entry) => entry && entry.id && entry.encryptedKey)
.map((entry) => ({ ...entry }))
.map((entry) => ({
...entry,
status: entry.status || 'active' // 确保有默认状态
}))
const hashSet = new Set(entries.map((entry) => entry.hash).filter(Boolean))
@@ -214,7 +217,9 @@ class DroidAccountService {
encryptedKey: this._encryptSensitiveData(trimmed),
createdAt: now,
lastUsedAt: '',
usageCount: '0'
usageCount: '0',
status: 'active', // 新增状态字段
errorMessage: '' // 新增错误信息字段
})
}
@@ -230,7 +235,9 @@ class DroidAccountService {
id: entry.id,
createdAt: entry.createdAt || '',
lastUsedAt: entry.lastUsedAt || '',
usageCount: entry.usageCount || '0'
usageCount: entry.usageCount || '0',
status: entry.status || 'active', // 新增状态字段
errorMessage: entry.errorMessage || '' // 新增错误信息字段
}))
}
@@ -252,7 +259,9 @@ class DroidAccountService {
hash: entry.hash || '',
createdAt: entry.createdAt || '',
lastUsedAt: entry.lastUsedAt || '',
usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0
usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0,
status: entry.status || 'active', // 新增状态字段
errorMessage: entry.errorMessage || '' // 新增错误信息字段
}
}
@@ -348,6 +357,56 @@ class DroidAccountService {
}
}
/**
* 标记指定的 Droid API Key 条目为异常状态
*/
async markApiKeyAsError(accountId, keyId, errorMessage = '') {
if (!accountId || !keyId) {
return { marked: false, error: '参数无效' }
}
try {
const accountData = await redis.getDroidAccount(accountId)
if (!accountData) {
return { marked: false, error: '账户不存在' }
}
const entries = this._parseApiKeyEntries(accountData.apiKeys)
if (!entries || entries.length === 0) {
return { marked: false, error: '无API Key条目' }
}
let marked = false
const updatedEntries = entries.map((entry) => {
if (entry && entry.id === keyId) {
marked = true
return {
...entry,
status: 'error',
errorMessage: errorMessage || 'API Key异常'
}
}
return entry
})
if (!marked) {
return { marked: false, error: '未找到指定的API Key' }
}
accountData.apiKeys = JSON.stringify(updatedEntries)
await redis.setDroidAccount(accountId, accountData)
logger.warn(
`⚠️ 已标记 Droid API Key ${keyId} 为异常状态Account: ${accountId}${errorMessage}`
)
return { marked: true }
} catch (error) {
logger.error(`❌ 标记 Droid API Key 异常状态失败:${keyId}Account: ${accountId}`, error)
return { marked: false, error: error.message }
}
}
/**
* 使用 WorkOS Refresh Token 刷新并验证凭证
*/
@@ -979,7 +1038,7 @@ class DroidAccountService {
? updates.apiKeyUpdateMode.trim().toLowerCase()
: ''
let apiKeyUpdateMode = ['append', 'replace', 'delete'].includes(rawApiKeyMode)
let apiKeyUpdateMode = ['append', 'replace', 'delete', 'update'].includes(rawApiKeyMode)
? rawApiKeyMode
: ''
@@ -1041,6 +1100,53 @@ class DroidAccountService {
} else if (removeApiKeysInput.length > 0) {
logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`)
}
} else if (apiKeyUpdateMode === 'update') {
// 更新模式:根据提供的 key 匹配现有条目并更新状态
mergedApiKeys = [...existingApiKeyEntries]
const updatedHashes = new Set()
for (const updateItem of newApiKeysInput) {
if (!updateItem || typeof updateItem !== 'object') {
continue
}
const key = updateItem.key || updateItem.apiKey || ''
if (!key || typeof key !== 'string') {
continue
}
const trimmed = key.trim()
if (!trimmed) {
continue
}
const hash = crypto.createHash('sha256').update(trimmed).digest('hex')
updatedHashes.add(hash)
// 查找现有条目
const existingIndex = mergedApiKeys.findIndex(
(entry) => entry && entry.hash === hash
)
if (existingIndex !== -1) {
// 更新现有条目的状态信息
const existingEntry = mergedApiKeys[existingIndex]
mergedApiKeys[existingIndex] = {
...existingEntry,
status: updateItem.status || existingEntry.status || 'active',
errorMessage: updateItem.errorMessage !== undefined ? updateItem.errorMessage : existingEntry.errorMessage || '',
lastUsedAt: updateItem.lastUsedAt !== undefined ? updateItem.lastUsedAt : existingEntry.lastUsedAt || '',
usageCount: updateItem.usageCount !== undefined ? String(updateItem.usageCount) : existingEntry.usageCount || '0'
}
apiKeysUpdated = true
}
}
if (!apiKeysUpdated) {
logger.warn(
`⚠️ 更新模式未匹配任何 Droid API Key: ${accountId} (提供 ${updatedHashes.size} 个哈希)`
)
}
} else {
const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys
const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length
@@ -1063,6 +1169,10 @@ class DroidAccountService {
logger.info(
`🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}`
)
} else if (apiKeyUpdateMode === 'update') {
logger.info(
`🔑 更新模式更新 Droid API keys for ${accountId}: 更新了 ${newApiKeysInput.length} 个 API Key 的状态信息`
)
} else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) {
logger.info(
`🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}`

View File

@@ -121,12 +121,18 @@ class DroidRelayService {
throw new Error(`Droid account ${account.id} 未配置任何 API Key`)
}
// 过滤掉异常状态的API Key
const activeEntries = entries.filter((entry) => entry.status !== 'error')
if (!activeEntries || activeEntries.length === 0) {
throw new Error(`Droid account ${account.id} 没有可用的 API Key所有API Key均已异常`)
}
const stickyKey = this._composeApiKeyStickyKey(account.id, endpointType, sessionHash)
if (stickyKey) {
const mappedKeyId = await redis.getSessionAccountMapping(stickyKey)
if (mappedKeyId) {
const mappedEntry = entries.find((entry) => entry.id === mappedKeyId)
const mappedEntry = activeEntries.find((entry) => entry.id === mappedKeyId)
if (mappedEntry) {
await redis.extendSessionAccountMappingTTL(stickyKey)
await droidAccountService.touchApiKeyUsage(account.id, mappedEntry.id)
@@ -138,7 +144,7 @@ class DroidRelayService {
}
}
const selectedEntry = entries[Math.floor(Math.random() * entries.length)]
const selectedEntry = activeEntries[Math.floor(Math.random() * activeEntries.length)]
if (!selectedEntry) {
throw new Error(`Droid account ${account.id} 没有可用的 API Key`)
}
@@ -150,7 +156,7 @@ class DroidRelayService {
await droidAccountService.touchApiKeyUsage(account.id, selectedEntry.id)
logger.info(
`🔐 随机选取 Droid API Key ${selectedEntry.id}Account: ${account.id}, Keys: ${entries.length}`
`🔐 随机选取 Droid API Key ${selectedEntry.id}Account: ${account.id}, Active Keys: ${activeEntries.length}/${entries.length}`
)
return selectedEntry
@@ -1144,39 +1150,52 @@ class DroidRelayService {
if (authMethod === 'api_key') {
if (selectedAccountApiKey?.id) {
let removalResult = null
let markResult = null
const errorMessage = `上游返回 ${statusCode} 错误`
try {
removalResult = await droidAccountService.removeApiKeyEntry(
// 标记API Key为异常状态而不是删除
markResult = await droidAccountService.markApiKeyAsError(
accountId,
selectedAccountApiKey.id
selectedAccountApiKey.id,
errorMessage
)
} catch (error) {
logger.error(
`移除 Droid API Key ${selectedAccountApiKey.id}Account: ${accountId})失败:`,
`标记 Droid API Key ${selectedAccountApiKey.id} 异常状态Account: ${accountId})失败:`,
error
)
}
await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash)
if (removalResult?.removed) {
if (markResult?.marked) {
logger.warn(
`🚫 上游返回 ${statusCode},已移除 Droid API Key ${selectedAccountApiKey.id}Account: ${accountId}`
`⚠️ 上游返回 ${statusCode},已标记 Droid API Key ${selectedAccountApiKey.id} 为异常状态Account: ${accountId}`
)
} else {
logger.warn(
`⚠️ 上游返回 ${statusCode},但未能移除 Droid API Key ${selectedAccountApiKey.id}Account: ${accountId}`
`⚠️ 上游返回 ${statusCode},但未能标记 Droid API Key ${selectedAccountApiKey.id} 异常状态Account: ${accountId}${markResult?.error || '未知错误'}`
)
}
if (!removalResult || removalResult.remainingCount === 0) {
await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key 已全部失效')
// 检查是否还有可用的API Key
try {
const availableEntries = await droidAccountService.getDecryptedApiKeyEntries(accountId)
const activeEntries = availableEntries.filter(entry => entry.status !== 'error')
if (activeEntries.length === 0) {
await this._stopDroidAccountScheduling(accountId, statusCode, '所有API Key均已异常')
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
} else {
logger.info(
` Droid 账号 ${accountId} 仍有 ${activeEntries.length} 个可用 API Key`
)
}
} catch (error) {
logger.error(`❌ 检查可用API Key失败Account: ${accountId}`, error)
await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key检查失败')
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
} else {
logger.info(
` Droid 账号 ${accountId} 仍有 ${removalResult.remainingCount} 个 API Key 可用`
)
}
return

View File

@@ -1817,7 +1817,7 @@
<li>新会话将随机命中一个 Key并在会话有效期内保持粘性。</li>
<li>若某 Key 失效,会自动切换到剩余可用 Key最大化成功率。</li>
<li>
若上游返回 4xx 错误码,该 Key 会被自动移除;全部 Key 清空后账号将暂停调度。
若上游返回 4xx 错误码,该 Key 会被自动标记为异常;全部 Key 异常后账号将暂停调度。
</li>
</ul>
</div>
@@ -3011,10 +3011,18 @@
>
<i class="fas fa-retweet text-sm text-white" />
</div>
<div>
<h5 class="mb-2 font-semibold text-purple-900 dark:text-purple-200">
更新 API Key
</h5>
<div class="flex-1">
<div class="mb-2 flex items-center justify-between">
<h5 class="font-semibold text-purple-900 dark:text-purple-200">更新 API Key</h5>
<button
type="button"
class="flex items-center gap-1.5 rounded-lg bg-purple-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600"
@click="showApiKeyManagement = true"
>
<i class="fas fa-list-ul" />
<span>管理 API Key</span>
</button>
</div>
<p class="mb-1 text-sm text-purple-800 dark:text-purple-200">
当前已保存 <strong>{{ existingApiKeyCount }}</strong> 条 API Key。您可以追加新的
Key或通过下方模式快速覆盖、删除指定 Key。
@@ -3187,6 +3195,15 @@
@close="showGroupManagement = false"
@refresh="handleGroupRefresh"
/>
<!-- API Key 管理模态框 -->
<ApiKeyManagementModal
v-if="showApiKeyManagement"
:account-id="props.account?.id"
:account-name="props.account?.name"
@close="showApiKeyManagement = false"
@refresh="handleApiKeyRefresh"
/>
</Teleport>
</template>
@@ -3200,6 +3217,7 @@ import ProxyConfig from './ProxyConfig.vue'
import OAuthFlow from './OAuthFlow.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
import GroupManagementModal from './GroupManagementModal.vue'
import ApiKeyManagementModal from './ApiKeyManagementModal.vue'
const props = defineProps({
account: {
@@ -3239,6 +3257,9 @@ const clearingCache = ref(false)
// 平台分组状态
const platformGroup = ref('')
// API Key 管理模态框
const showApiKeyManagement = ref(false)
// 根据现有平台确定分组
const determinePlatformGroup = (platform) => {
if (['claude', 'claude-console', 'ccr', 'bedrock'].includes(platform)) {
@@ -4816,6 +4837,18 @@ const handleGroupRefresh = async () => {
await loadGroups()
}
// 处理 API Key 管理模态框刷新
const handleApiKeyRefresh = async () => {
// 刷新账户信息以更新 API Key 数量
if (props.account?.id) {
try {
await accountsStore.fetchAccounts()
} catch (error) {
console.error('Failed to refresh account data:', error)
}
}
}
// 监听平台变化,重置表单
watch(
() => form.value.platform,

View File

@@ -0,0 +1,406 @@
<template>
<Teleport to="body">
<div v-if="show" class="modal fixed inset-0 z-[60] flex items-center justify-center p-3 sm:p-4">
<div
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-4xl overflow-y-auto 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-purple-500 to-purple-600 sm:h-10 sm:w-10 sm:rounded-xl"
>
<i class="fas fa-key text-sm text-white sm:text-base" />
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
API Key 管理
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 sm:text-sm">
{{ accountName }}
</p>
</div>
</div>
<button
class="p-1 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
@click="$emit('close')"
>
<i class="fas fa-times text-lg sm:text-xl" />
</button>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="py-8 text-center">
<div class="loading-spinner-lg mx-auto mb-4" />
<p class="text-gray-500 dark:text-gray-400">加载中...</p>
</div>
<!-- API Key 列表 -->
<div
v-else-if="apiKeys.length === 0"
class="rounded-lg bg-gray-50 py-8 text-center dark:bg-gray-800"
>
<i class="fas fa-key mb-4 text-4xl text-gray-300 dark:text-gray-600" />
<p class="text-gray-500 dark:text-gray-400">暂无 API Key</p>
</div>
<div v-else>
<!-- API Key 网格布局 -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="(apiKey, index) in paginatedApiKeys"
:key="index"
class="rounded-lg border border-gray-200 bg-white p-4 transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex flex-col gap-3">
<!-- API Key 信息 -->
<div class="flex items-start justify-between gap-2 mb-2">
<span
class="font-mono text-xs font-medium text-gray-900 dark:text-gray-100 break-all flex-1"
:title="apiKey.key"
>
{{ maskApiKey(apiKey.key) }}
</span>
<div class="flex items-center gap-1">
<button
class="text-xs text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
@click="copyApiKey(apiKey.key)"
>
<i class="fas fa-copy" />
</button>
<button
class="text-xs text-red-500 transition-colors hover:text-red-700 dark:text-red-400 dark:hover:text-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="deleting === getOriginalIndex(index)"
@click="deleteApiKey(apiKey, getOriginalIndex(index))"
>
<div v-if="deleting === getOriginalIndex(index)" class="loading-spinner-sm" />
<i v-else class="fas fa-trash" />
</button>
</div>
</div>
<!-- 统计信息一行显示 -->
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
<div>
<span
:class="[
apiKey.status === 'active'
? 'text-green-600 dark:text-green-400'
: apiKey.status === 'error'
? 'text-red-600 dark:text-red-400'
: 'text-yellow-600 dark:text-yellow-400'
]"
>
<i
:class="[
apiKey.status === 'active'
? 'fas fa-check-circle'
: apiKey.status === 'error'
? 'fas fa-exclamation-triangle'
: 'fas fa-exclamation-circle'
]"
class="mr-1"
/>
{{
apiKey.status === 'active'
? '正常'
: apiKey.status === 'error'
? '异常'
: apiKey.status === 'disabled'
? '禁用'
: apiKey.status || '未知'
}}
</span>
</div>
<div>
<span>使用: <strong>{{ apiKey.usageCount || 0 }}</strong></span>
</div>
<div v-if="apiKey.lastUsedAt">
<span>{{ formatTime(apiKey.lastUsedAt) }}</span>
</div>
</div>
<!-- 错误信息显示 -->
<div v-if="(apiKey.status === 'error' || apiKey.status === 'disabled') && apiKey.errorMessage" class="mt-2">
<div
class="text-xs p-2 rounded flex items-start justify-between gap-2"
:class="[
apiKey.status === 'error'
? 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20'
: 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20'
]"
>
<div class="flex items-center flex-1 min-w-0">
<i
:class="[
apiKey.status === 'error'
? 'fas fa-exclamation-triangle'
: 'fas fa-exclamation-circle'
]"
class="mr-1 flex-shrink-0"
></i>
<span class="break-words">{{ apiKey.errorMessage }}</span>
</div>
<button
class="text-xs px-2 py-1 rounded bg-white/80 dark:bg-gray-700/80 hover:bg-white dark:hover:bg-gray-600 transition-colors flex-shrink-0"
:class="[
apiKey.status === 'error'
? 'text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300'
: 'text-yellow-600 dark:text-yellow-400 hover:text-yellow-700 dark:hover:text-yellow-300'
]"
@click="resetApiKeyStatus(apiKey, getOriginalIndex(index))"
:disabled="resetting === getOriginalIndex(index)"
title="重置状态"
>
<div v-if="resetting === getOriginalIndex(index)" class="loading-spinner-sm" />
<i v-else class="fas fa-redo"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 分页控制底部 -->
<div v-if="totalPages > 1" class="mt-4 flex items-center justify-between">
<div class="text-sm text-gray-600 dark:text-gray-400">
显示 {{ (currentPage - 1) * pageSize + 1 }}-{{ Math.min(currentPage * pageSize, totalItems) }} {{ totalItems }}
</div>
<div class="flex items-center gap-2">
<button
class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="currentPage === 1"
@click="currentPage = 1"
>
<i class="fas fa-angle-double-left" />
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="currentPage === 1"
@click="currentPage--"
>
<i class="fas fa-angle-left" />
</button>
<span class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
<i class="fas fa-angle-right" />
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="currentPage === totalPages"
@click="currentPage = totalPages"
>
<i class="fas fa-angle-double-right" />
</button>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api'
const props = defineProps({
accountId: {
type: String,
required: true
},
accountName: {
type: String,
default: ''
}
})
const emit = defineEmits(['close', 'refresh'])
const show = ref(true)
const loading = ref(false)
const deleting = ref(null)
const resetting = ref(null)
const apiKeys = ref([])
const currentPage = ref(1)
const pageSize = ref(18)
// 计算属性
const totalItems = computed(() => apiKeys.value.length)
const totalPages = computed(() => Math.ceil(totalItems.value / pageSize.value))
const paginatedApiKeys = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return apiKeys.value.slice(start, end)
})
// 获取原始索引的方法
const getOriginalIndex = (paginatedIndex) => {
return (currentPage.value - 1) * pageSize.value + paginatedIndex
}
// 加载 API Keys
const loadApiKeys = async () => {
loading.value = true
try {
const response = await apiClient.get(`/admin/droid-accounts/${props.accountId}`)
const account = response.data
// 解析 apiKeys
let parsedKeys = []
if (Array.isArray(account.apiKeys)) {
parsedKeys = account.apiKeys
} else if (typeof account.apiKeys === 'string') {
try {
parsedKeys = JSON.parse(account.apiKeys)
} catch (error) {
console.error('Failed to parse apiKeys:', error)
}
}
// 转换为统一格式
apiKeys.value = parsedKeys.map((item) => {
if (typeof item === 'string') {
// 对于字符串类型的API Key保持默认状态为active
return {
key: item,
usageCount: 0,
status: 'active',
lastUsedAt: null,
errorMessage: ''
}
} else if (typeof item === 'object' && item !== null) {
// 对于对象类型的API Key保留所有状态信息
return {
key: item.key || item.apiKey || '',
usageCount: item.usageCount || item.count || 0,
status: item.status || 'active', // 保留后端返回的状态
lastUsedAt: item.lastUsedAt || item.lastUsed || null,
errorMessage: item.errorMessage || '' // 保留后端返回的错误信息
}
}
// 其他情况默认为active状态
return {
key: String(item),
usageCount: 0,
status: 'active',
lastUsedAt: null,
errorMessage: ''
}
})
} catch (error) {
console.error('Failed to load API keys:', error)
showToast('加载 API Key 失败', 'error')
} finally {
loading.value = false
// 重置到第一页
currentPage.value = 1
}
}
// 删除 API Key
const deleteApiKey = async (apiKey, index) => {
if (!confirm(`确定要删除 API Key "${maskApiKey(apiKey.key)}" 吗?`)) {
return
}
deleting.value = index
try {
// 准备更新数据:删除指定的 key
const updateData = {
apiKeys: [apiKey.key],
apiKeyUpdateMode: 'delete'
}
await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
showToast('API Key 已删除', 'success')
await loadApiKeys()
emit('refresh')
} catch (error) {
console.error('Failed to delete API key:', error)
showToast(error.response?.data?.error || '删除 API Key 失败', 'error')
} finally {
deleting.value = null
}
}
// 重置 API Key 状态
const resetApiKeyStatus = async (apiKey, index) => {
if (!confirm(`确定要重置 API Key "${maskApiKey(apiKey.key)}" 的状态吗?这将清除错误信息并恢复为正常状态。`)) {
return
}
resetting.value = index
try {
// 准备更新数据:重置指定 key 的状态
const updateData = {
apiKeys: [
{
key: apiKey.key,
status: 'active',
errorMessage: ''
}
],
apiKeyUpdateMode: 'update'
}
await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
showToast('API Key 状态已重置', 'success')
await loadApiKeys()
emit('refresh')
} catch (error) {
console.error('Failed to reset API key status:', error)
showToast(error.response?.data?.error || '重置 API Key 状态失败', 'error')
} finally {
resetting.value = null
}
}
// 掩码显示 API Key
const maskApiKey = (key) => {
if (!key || key.length < 12) {
return key
}
return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`
}
// 复制 API Key
const copyApiKey = async (key) => {
try {
await navigator.clipboard.writeText(key)
showToast('API Key 已复制', 'success')
} catch (error) {
console.error('Failed to copy:', error)
showToast('复制失败', 'error')
}
}
// 格式化时间
const formatTime = (timestamp) => {
if (!timestamp) return '-'
try {
const date = new Date(timestamp)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
} catch (error) {
return '-'
}
}
onMounted(() => {
loadApiKeys()
})
</script>

View File

@@ -3108,6 +3108,25 @@ const getDroidApiKeyCount = (account) => {
return 0
}
// 优先使用 apiKeys 数组来计算正常状态的 API Keys
if (Array.isArray(account.apiKeys)) {
// 只计算状态不是 'error' 的 API Keys
return account.apiKeys.filter(apiKey => apiKey.status !== 'error').length
}
// 如果是字符串格式的 apiKeys尝试解析
if (typeof account.apiKeys === 'string' && account.apiKeys.trim()) {
try {
const parsed = JSON.parse(account.apiKeys)
if (Array.isArray(parsed)) {
// 只计算状态不是 'error' 的 API Keys
return parsed.filter(apiKey => apiKey.status !== 'error').length
}
} catch (error) {
// 忽略解析错误,继续使用其他字段
}
}
const candidates = [
account.apiKeyCount,
account.api_key_count,
@@ -3122,21 +3141,6 @@ const getDroidApiKeyCount = (account) => {
}
}
if (Array.isArray(account.apiKeys)) {
return account.apiKeys.length
}
if (typeof account.apiKeys === 'string' && account.apiKeys.trim()) {
try {
const parsed = JSON.parse(account.apiKeys)
if (Array.isArray(parsed)) {
return parsed.length
}
} catch (error) {
// 忽略解析错误,维持默认值
}
}
return 0
}