mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 添加Droid账户API Key管理功能
(cherry picked from commit 0cf3ca6c7eafcf28a2da7e8bfd6814b4883bb752)
This commit is contained in:
@@ -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,
|
||||
|
||||
406
web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue
Normal file
406
web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user