Files
claude-relay-service/web/admin-spa/src/components/common/AccountSelector.vue
2025-11-23 22:00:13 +08:00

729 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div ref="triggerRef" class="relative">
<!-- 选择器主体 -->
<div
class="form-input flex w-full cursor-pointer items-center justify-between border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:class="{ 'opacity-50': disabled }"
@click="!disabled && toggleDropdown()"
>
<span
:class="
modelValue ? 'text-gray-900 dark:text-gray-200' : 'text-gray-500 dark:text-gray-400'
"
>{{ selectedLabel }}</span
>
<i
class="fas fa-chevron-down text-gray-400 transition-transform duration-200 dark:text-gray-500"
:class="{ 'rotate-180': showDropdown }"
/>
</div>
<!-- 下拉菜单 -->
<Teleport to="body">
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="showDropdown"
ref="dropdownRef"
class="absolute z-50 flex flex-col rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
:style="dropdownStyle"
>
<!-- 搜索框 -->
<div class="flex-shrink-0 border-b border-gray-200 p-3 dark:border-gray-600">
<div class="relative">
<input
ref="searchInput"
v-model="searchQuery"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="搜索账号名称..."
style="padding-left: 40px; padding-right: 36px"
type="text"
@input="handleSearch"
/>
<i
class="fas fa-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-gray-400 dark:text-gray-500"
/>
<button
v-if="searchQuery"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
type="button"
@click="clearSearch"
>
<i class="fas fa-times text-sm" />
</button>
</div>
</div>
<!-- 选项列表 -->
<div class="custom-scrollbar flex-1 overflow-y-auto">
<!-- 特殊选项 -->
<div
v-if="specialOptionsList.length > 0"
class="border-b border-gray-200 dark:border-gray-600"
>
<div
v-for="option in specialOptionsList"
:key="`special-${option.value}`"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{ 'bg-blue-50 dark:bg-blue-900/20': modelValue === option.value }"
@click="selectAccount(option.value)"
>
<span class="text-gray-700 dark:text-gray-300">{{ option.label }}</span>
<span
v-if="option.description"
class="ml-2 text-xs text-gray-400 dark:text-gray-500"
>
{{ option.description }}
</span>
</div>
</div>
<!-- 默认选项 -->
<div
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{ 'bg-blue-50 dark:bg-blue-900/20': !modelValue }"
@click="selectAccount(null)"
>
<span class="text-gray-700 dark:text-gray-300">{{ defaultOptionText }}</span>
</div>
<!-- 分组选项 -->
<div v-if="filteredGroups.length > 0">
<div
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
调度分组
</div>
<div
v-for="group in filteredGroups"
:key="`group:${group.id}`"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{ 'bg-blue-50 dark:bg-blue-900/20': modelValue === `group:${group.id}` }"
@click="selectAccount(`group:${group.id}`)"
>
<div class="flex items-center justify-between">
<span class="text-gray-700 dark:text-gray-300">{{ group.name }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400"
>{{ group.memberCount || 0 }} 个成员</span
>
</div>
</div>
</div>
<!-- OAuth 账号 -->
<div v-if="filteredOAuthAccounts.length > 0">
<div
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
{{
platform === 'claude'
? 'Claude OAuth 专属账号'
: platform === 'openai'
? 'OpenAI 专属账号'
: platform === 'droid'
? 'Droid 专属账号'
: platform === 'gemini'
? 'Gemini OAuth 专属账号'
: 'OAuth 专属账号'
}}
</div>
<div
v-for="account in filteredOAuthAccounts"
:key="account.id"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{ 'bg-blue-50 dark:bg-blue-900/20': modelValue === account.id }"
@click="selectAccount(account.id)"
>
<div class="flex items-center justify-between">
<div>
<span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
<span
class="ml-2 rounded-full px-2 py-0.5 text-xs"
:class="
account.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: account.status === 'unauthorized'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
"
>
{{ getAccountStatusText(account) }}
</span>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ formatDate(account.createdAt) }}
</span>
</div>
</div>
</div>
<!-- Console 账号 Claude -->
<div v-if="platform === 'claude' && filteredConsoleAccounts.length > 0">
<div
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
Claude Console 专属账号
</div>
<div
v-for="account in filteredConsoleAccounts"
:key="account.id"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{
'bg-blue-50 dark:bg-blue-900/20': modelValue === `console:${account.id}`
}"
@click="selectAccount(`console:${account.id}`)"
>
<div class="flex items-center justify-between">
<div>
<span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
<span
class="ml-2 rounded-full px-2 py-0.5 text-xs"
:class="
account.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: account.status === 'unauthorized'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
"
>
{{ getAccountStatusText(account) }}
</span>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ formatDate(account.createdAt) }}
</span>
</div>
</div>
</div>
<!-- OpenAI-Responses 账号 OpenAI -->
<div v-if="platform === 'openai' && filteredOpenAIResponsesAccounts.length > 0">
<div
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
OpenAI-Responses 专属账号
</div>
<div
v-for="account in filteredOpenAIResponsesAccounts"
:key="account.id"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{
'bg-blue-50 dark:bg-blue-900/20': modelValue === `responses:${account.id}`
}"
@click="selectAccount(`responses:${account.id}`)"
>
<div class="flex items-center justify-between">
<div>
<span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
<span
class="ml-2 rounded-full px-2 py-0.5 text-xs"
:class="
account.isActive === 'true' || account.isActive === true
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: account.status === 'rate_limited'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
"
>
{{ getAccountStatusText(account) }}
</span>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ formatDate(account.createdAt) }}
</span>
</div>
</div>
</div>
<!-- Gemini-API 账号 Gemini -->
<div v-if="platform === 'gemini' && filteredGeminiApiAccounts.length > 0">
<div
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
Gemini-API 专属账号
</div>
<div
v-for="account in filteredGeminiApiAccounts"
:key="account.id"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{
'bg-blue-50 dark:bg-blue-900/20': modelValue === `api:${account.id}`
}"
@click="selectAccount(`api:${account.id}`)"
>
<div class="flex items-center justify-between">
<div>
<span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
<span
class="ml-2 rounded-full px-2 py-0.5 text-xs"
:class="
account.isActive === 'true' || account.isActive === true
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: account.status === 'rate_limited'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
"
>
{{ getAccountStatusText(account) }}
</span>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ formatDate(account.createdAt) }}
</span>
</div>
</div>
</div>
<!-- 无搜索结果 -->
<div
v-if="searchQuery && !hasResults"
class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"
>
<i class="fas fa-search mb-2 text-2xl" />
<p class="text-sm">没有找到匹配的账号</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
platform: {
type: String,
required: true,
validator: (value) => ['claude', 'gemini', 'openai', 'bedrock', 'droid'].includes(value)
},
accounts: {
type: Array,
default: () => []
},
groups: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: '请选择账号'
},
defaultOptionText: {
type: String,
default: '使用共享账号池'
},
specialOptions: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue'])
const showDropdown = ref(false)
const searchQuery = ref('')
const searchInput = ref(null)
const dropdownRef = ref(null)
const dropdownStyle = ref({})
const triggerRef = ref(null)
const lastDirection = ref('') // 记住上次的显示方向
const specialOptionsList = computed(() => props.specialOptions || [])
// 获取选中的标签
const selectedLabel = computed(() => {
const matchedSpecial = specialOptionsList.value.find(
(option) => option.value === props.modelValue
)
if (matchedSpecial) {
return matchedSpecial.label
}
// 如果没有选中值,显示默认选项文本
if (!props.modelValue) return props.defaultOptionText
// 分组
if (props.modelValue.startsWith('group:')) {
const groupId = props.modelValue.substring(6)
const group = props.groups.find((g) => g.id === groupId)
return group ? `${group.name} (${group.memberCount || 0} 个成员)` : ''
}
// Console 账号
if (props.modelValue.startsWith('console:')) {
const accountId = props.modelValue.substring(8)
const account = props.accounts.find(
(a) => a.id === accountId && a.platform === 'claude-console'
)
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
}
// OpenAI-Responses 账号
if (props.modelValue.startsWith('responses:')) {
const accountId = props.modelValue.substring(10)
const account = props.accounts.find(
(a) => a.id === accountId && a.platform === 'openai-responses'
)
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
}
// Gemini-API 账号
if (props.modelValue.startsWith('api:')) {
const accountId = props.modelValue.substring(4)
const account = props.accounts.find((a) => a.id === accountId && a.platform === 'gemini-api')
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
}
// OAuth 账号
const account = props.accounts.find((a) => a.id === props.modelValue)
return account ? `${account.name} (${getAccountStatusText(account)})` : ''
})
// 获取账户状态文本
const getAccountStatusText = (account) => {
if (!account) return '未知'
// 处理 OpenAI-Responses 账号isActive 可能是字符串)
const isActive = account.isActive === 'true' || account.isActive === true
// 优先使用 isActive 判断
if (!isActive) {
// 根据 status 提供更详细的状态信息
switch (account.status) {
case 'unauthorized':
return '未授权'
case 'error':
return 'Token错误'
case 'created':
return '待验证'
case 'rate_limited':
return '限流中'
case 'quota_exceeded':
return '额度超限'
default:
return '异常'
}
}
// 对于激活的账号,如果是限流状态也要显示
if (account.status === 'rate_limited') {
return '限流中'
}
return '正常'
}
// 按创建时间倒序排序账号
const sortedAccounts = computed(() => {
return [...props.accounts].sort((a, b) => {
const dateA = new Date(a.createdAt || 0)
const dateB = new Date(b.createdAt || 0)
return dateB - dateA // 倒序排序
})
})
// 过滤的分组(根据平台类型过滤)
const filteredGroups = computed(() => {
// 只显示与当前平台匹配的分组
let groups = props.groups.filter((group) => {
// 如果分组有platform属性则必须匹配当前平台
// 如果没有platform属性则认为是旧数据根据平台判断
if (group.platform) {
return group.platform === props.platform
}
// 向后兼容如果没有platform字段通过其他方式判断
return true
})
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
groups = groups.filter((group) => group.name.toLowerCase().includes(query))
}
return groups
})
// 过滤的 OAuth 账号
const filteredOAuthAccounts = computed(() => {
let accounts = []
if (props.platform === 'claude') {
accounts = sortedAccounts.value.filter((a) => a.platform === 'claude-oauth')
} else if (props.platform === 'openai') {
// 对于 OpenAI只显示 openai 类型的账号
accounts = sortedAccounts.value.filter((a) => a.platform === 'openai')
} else if (props.platform === 'droid') {
accounts = sortedAccounts.value.filter((a) => a.platform === 'droid')
} else if (props.platform === 'gemini') {
// 对于 Gemini只显示 OAuth 类型的账号(排除 gemini-api
accounts = sortedAccounts.value.filter((a) => a.platform === 'gemini')
} else {
// 其他平台显示所有非特殊类型的账号
accounts = sortedAccounts.value.filter(
(a) =>
!['claude-oauth', 'claude-console', 'openai-responses', 'gemini-api'].includes(a.platform)
)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
}
return accounts
})
// 过滤的 Console 账号
const filteredConsoleAccounts = computed(() => {
if (props.platform !== 'claude') return []
let accounts = sortedAccounts.value.filter((a) => a.platform === 'claude-console')
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
}
return accounts
})
// 过滤的 OpenAI-Responses 账号
const filteredOpenAIResponsesAccounts = computed(() => {
if (props.platform !== 'openai') return []
let accounts = sortedAccounts.value.filter((a) => a.platform === 'openai-responses')
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
}
return accounts
})
// 过滤的 Gemini-API 账号
const filteredGeminiApiAccounts = computed(() => {
if (props.platform !== 'gemini') return []
let accounts = sortedAccounts.value.filter((a) => a.platform === 'gemini-api')
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
}
return accounts
})
// 是否有搜索结果
const hasResults = computed(() => {
return (
filteredGroups.value.length > 0 ||
filteredOAuthAccounts.value.length > 0 ||
filteredConsoleAccounts.value.length > 0 ||
filteredOpenAIResponsesAccounts.value.length > 0 ||
filteredGeminiApiAccounts.value.length > 0
)
})
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
const now = new Date()
const diffInHours = (now - date) / (1000 * 60 * 60)
if (diffInHours < 24) {
return '今天创建'
} else if (diffInHours < 48) {
return '昨天创建'
} else if (diffInHours < 168) {
// 7天内
return `${Math.floor(diffInHours / 24)} 天前`
} else {
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
}
}
// 更新下拉菜单位置
const updateDropdownPosition = () => {
if (!showDropdown.value || !dropdownRef.value || !triggerRef.value) return
const trigger = triggerRef.value
if (!trigger) return
const rect = trigger.getBoundingClientRect()
const windowHeight = window.innerHeight
const windowWidth = window.innerWidth
const spaceBelow = windowHeight - rect.bottom
const spaceAbove = rect.top
const margin = 8 // 边距
// 获取下拉框的高度
// const dropdownHeight = dropdownRef.value.offsetHeight
// 计算最大可用高度
const maxHeightBelow = spaceBelow - margin
const maxHeightAbove = spaceAbove - margin
// 决定显示方向和最大高度
let showAbove = false
let maxHeight = maxHeightBelow
// 优先使用上次的方向,除非空间不足
if (lastDirection.value === 'above' && maxHeightAbove >= 150) {
showAbove = true
maxHeight = maxHeightAbove
} else if (lastDirection.value === 'below' && maxHeightBelow >= 150) {
showAbove = false
maxHeight = maxHeightBelow
} else {
// 如果没有历史方向或空间不足,选择空间更大的方向
if (maxHeightAbove > maxHeightBelow && maxHeightBelow < 200) {
showAbove = true
maxHeight = maxHeightAbove
}
}
// 记住这次的方向
lastDirection.value = showAbove ? 'above' : 'below'
// 确保下拉框不超出视窗左右边界
let left = rect.left
const dropdownWidth = rect.width
if (left + dropdownWidth > windowWidth - margin) {
left = windowWidth - dropdownWidth - margin
}
if (left < margin) {
left = margin
}
dropdownStyle.value = {
position: 'fixed',
left: `${left}px`,
width: `${rect.width}px`,
maxHeight: `${Math.min(maxHeight, 400)}px`, // 限制最大高度为400px
...(showAbove ? { bottom: `${windowHeight - rect.top}px` } : { top: `${rect.bottom}px` })
}
}
// 切换下拉菜单
const toggleDropdown = () => {
if (!showDropdown.value && triggerRef.value) {
// 在显示前就设置初始样式,避免闪烁
const rect = triggerRef.value.getBoundingClientRect()
const windowHeight = window.innerHeight
const spaceBelow = windowHeight - rect.bottom
const margin = 8
// 预先设置一个合理的初始位置
dropdownStyle.value = {
position: 'fixed',
left: `${rect.left}px`,
width: `${rect.width}px`,
maxHeight: `${Math.min(spaceBelow - margin, 400)}px`,
top: `${rect.bottom}px`
}
}
showDropdown.value = !showDropdown.value
if (showDropdown.value) {
nextTick(() => {
updateDropdownPosition()
searchInput.value?.focus()
})
}
}
// 选择账号
const selectAccount = (value) => {
emit('update:modelValue', value || '')
showDropdown.value = false
searchQuery.value = ''
}
// 处理搜索
const handleSearch = () => {
// 搜索时自动触发
}
// 清除搜索
const clearSearch = () => {
searchQuery.value = ''
searchInput.value?.focus()
}
// 点击外部关闭
const handleClickOutside = (event) => {
if (!triggerRef.value?.contains(event.target) && !dropdownRef.value?.contains(event.target)) {
showDropdown.value = false
}
}
// 监听滚动更新位置
const handleScroll = () => {
if (showDropdown.value) {
updateDropdownPosition()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
window.addEventListener('scroll', handleScroll, true)
window.addEventListener('resize', updateDropdownPosition)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('scroll', handleScroll, true)
window.removeEventListener('resize', updateDropdownPosition)
})
// 监听下拉菜单状态变化
watch(showDropdown, (newVal) => {
if (!newVal) {
searchQuery.value = ''
// 关闭时重置方向,下次打开重新计算
lastDirection.value = ''
}
})
</script>
<style scoped>
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #cbd5e0 #f7fafc;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f7fafc;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #cbd5e0;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: #a0aec0;
}
</style>