mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
fix: 修复账户管理和API Key管理的多个问题
- 修复 Claude Console 账号 supportedModels 导致的 be.join 错误 - 添加专属账号选择器组件,支持搜索和按创建时间倒序排序 - 修复搜索框图标与placeholder文字重叠的问题 - 修复选择共享账号池后显示文字不正确的问题 - 优化下拉框定位逻辑,解决超出视窗无法滚动的问题 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -870,7 +870,19 @@ const form = ref({
|
||||
apiUrl: props.account?.apiUrl || '',
|
||||
apiKey: props.account?.apiKey || '',
|
||||
priority: props.account?.priority || 50,
|
||||
supportedModels: props.account?.supportedModels?.join('\n') || '',
|
||||
supportedModels: (() => {
|
||||
const models = props.account?.supportedModels;
|
||||
if (!models) return '';
|
||||
// 处理对象格式(Claude Console 的新格式)
|
||||
if (typeof models === 'object' && !Array.isArray(models)) {
|
||||
return Object.keys(models).join('\n');
|
||||
}
|
||||
// 处理数组格式(向后兼容)
|
||||
if (Array.isArray(models)) {
|
||||
return models.join('\n');
|
||||
}
|
||||
return '';
|
||||
})(),
|
||||
userAgent: props.account?.userAgent || '',
|
||||
rateLimitDuration: props.account?.rateLimitDuration || 60
|
||||
})
|
||||
@@ -1362,7 +1374,19 @@ watch(() => props.account, (newAccount) => {
|
||||
apiUrl: newAccount.apiUrl || '',
|
||||
apiKey: '', // 编辑模式不显示现有的 API Key
|
||||
priority: newAccount.priority || 50,
|
||||
supportedModels: newAccount.supportedModels?.join('\n') || '',
|
||||
supportedModels: (() => {
|
||||
const models = newAccount.supportedModels;
|
||||
if (!models) return '';
|
||||
// 处理对象格式(Claude Console 的新格式)
|
||||
if (typeof models === 'object' && !Array.isArray(models)) {
|
||||
return Object.keys(models).join('\n');
|
||||
}
|
||||
// 处理数组格式(向后兼容)
|
||||
if (Array.isArray(models)) {
|
||||
return models.join('\n');
|
||||
}
|
||||
return '';
|
||||
})(),
|
||||
userAgent: newAccount.userAgent || '',
|
||||
rateLimitDuration: newAccount.rateLimitDuration || 60
|
||||
}
|
||||
|
||||
@@ -425,87 +425,27 @@
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
||||
<select
|
||||
v-model="form.claudeAccountId"
|
||||
class="form-input w-full"
|
||||
<AccountSelector
|
||||
v-model="form.claudeAccountId"
|
||||
platform="claude"
|
||||
:accounts="localAccounts.claude"
|
||||
:groups="localAccounts.claudeGroups"
|
||||
:disabled="form.permissions === 'gemini'"
|
||||
>
|
||||
<option value="">
|
||||
使用共享账号池
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.claudeGroups && localAccounts.claudeGroups.length > 0"
|
||||
label="调度分组"
|
||||
>
|
||||
<option
|
||||
v-for="group in localAccounts.claudeGroups"
|
||||
:key="`group:${group.id}`"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-oauth').length > 0"
|
||||
label="Claude OAuth 专属账号"
|
||||
>
|
||||
<option
|
||||
v-for="account in localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-oauth')"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-console').length > 0"
|
||||
label="Claude Console 专属账号"
|
||||
>
|
||||
<option
|
||||
v-for="account in localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-console')"
|
||||
:key="account.id"
|
||||
:value="`console:${account.id}`"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
placeholder="请选择Claude账号"
|
||||
default-option-text="使用共享账号池"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
|
||||
<select
|
||||
v-model="form.geminiAccountId"
|
||||
class="form-input w-full"
|
||||
<AccountSelector
|
||||
v-model="form.geminiAccountId"
|
||||
platform="gemini"
|
||||
:accounts="localAccounts.gemini"
|
||||
:groups="localAccounts.geminiGroups"
|
||||
:disabled="form.permissions === 'claude'"
|
||||
>
|
||||
<option value="">
|
||||
使用共享账号池
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.geminiGroups && localAccounts.geminiGroups.length > 0"
|
||||
label="调度分组"
|
||||
>
|
||||
<option
|
||||
v-for="group in localAccounts.geminiGroups"
|
||||
:key="`group:${group.id}`"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.gemini.filter(a => a.accountType === 'dedicated').length > 0"
|
||||
label="Gemini 专属账号"
|
||||
>
|
||||
<option
|
||||
v-for="account in localAccounts.gemini.filter(a => a.accountType === 'dedicated')"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
placeholder="请选择Gemini账号"
|
||||
default-option-text="使用共享账号池"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
@@ -665,6 +605,7 @@ import { showToast } from '@/utils/toast'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
import { apiClient } from '@/config/api'
|
||||
import AccountSelector from '@/components/common/AccountSelector.vue'
|
||||
|
||||
const props = defineProps({
|
||||
accounts: {
|
||||
|
||||
@@ -294,87 +294,27 @@
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
||||
<select
|
||||
v-model="form.claudeAccountId"
|
||||
class="form-input w-full"
|
||||
<AccountSelector
|
||||
v-model="form.claudeAccountId"
|
||||
platform="claude"
|
||||
:accounts="localAccounts.claude"
|
||||
:groups="localAccounts.claudeGroups"
|
||||
:disabled="form.permissions === 'gemini'"
|
||||
>
|
||||
<option value="">
|
||||
使用共享账号池
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.claudeGroups && localAccounts.claudeGroups.length > 0"
|
||||
label="调度分组"
|
||||
>
|
||||
<option
|
||||
v-for="group in localAccounts.claudeGroups"
|
||||
:key="`group:${group.id}`"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-oauth').length > 0"
|
||||
label="Claude OAuth 专属账号"
|
||||
>
|
||||
<option
|
||||
v-for="account in localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-oauth')"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-console').length > 0"
|
||||
label="Claude Console 专属账号"
|
||||
>
|
||||
<option
|
||||
v-for="account in localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-console')"
|
||||
:key="account.id"
|
||||
:value="`console:${account.id}`"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
placeholder="请选择Claude账号"
|
||||
default-option-text="使用共享账号池"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
|
||||
<select
|
||||
v-model="form.geminiAccountId"
|
||||
class="form-input w-full"
|
||||
<AccountSelector
|
||||
v-model="form.geminiAccountId"
|
||||
platform="gemini"
|
||||
:accounts="localAccounts.gemini"
|
||||
:groups="localAccounts.geminiGroups"
|
||||
:disabled="form.permissions === 'claude'"
|
||||
>
|
||||
<option value="">
|
||||
使用共享账号池
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.geminiGroups && localAccounts.geminiGroups.length > 0"
|
||||
label="调度分组"
|
||||
>
|
||||
<option
|
||||
v-for="group in localAccounts.geminiGroups"
|
||||
:key="`group:${group.id}`"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.gemini.filter(a => a.accountType === 'dedicated').length > 0"
|
||||
label="Gemini 专属账号"
|
||||
>
|
||||
<option
|
||||
v-for="account in localAccounts.gemini.filter(a => a.accountType === 'dedicated')"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
placeholder="请选择Gemini账号"
|
||||
default-option-text="使用共享账号池"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
@@ -538,6 +478,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
import { apiClient } from '@/config/api'
|
||||
import AccountSelector from '@/components/common/AccountSelector.vue'
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
@@ -638,11 +579,32 @@ const updateApiKey = async () => {
|
||||
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
|
||||
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
|
||||
permissions: form.permissions,
|
||||
claudeAccountId: form.claudeAccountId || null,
|
||||
geminiAccountId: form.geminiAccountId || null,
|
||||
tags: form.tags
|
||||
}
|
||||
|
||||
// 处理Claude账户绑定(区分OAuth和Console)
|
||||
if (form.claudeAccountId) {
|
||||
if (form.claudeAccountId.startsWith('console:')) {
|
||||
// Claude Console账户
|
||||
data.claudeConsoleAccountId = form.claudeAccountId.substring(8);
|
||||
} else if (!form.claudeAccountId.startsWith('group:')) {
|
||||
// Claude OAuth账户(非分组)
|
||||
data.claudeAccountId = form.claudeAccountId;
|
||||
} else {
|
||||
// 分组
|
||||
data.claudeAccountId = form.claudeAccountId;
|
||||
}
|
||||
} else {
|
||||
data.claudeAccountId = null;
|
||||
}
|
||||
|
||||
// Gemini账户绑定
|
||||
if (form.geminiAccountId) {
|
||||
data.geminiAccountId = form.geminiAccountId;
|
||||
} else {
|
||||
data.geminiAccountId = null;
|
||||
}
|
||||
|
||||
// 模型限制 - 始终提交这些字段
|
||||
data.enableModelRestriction = form.enableModelRestriction
|
||||
data.restrictedModels = form.restrictedModels
|
||||
@@ -747,7 +709,12 @@ onMounted(async () => {
|
||||
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
|
||||
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
||||
form.permissions = props.apiKey.permissions || 'all'
|
||||
form.claudeAccountId = props.apiKey.claudeAccountId || ''
|
||||
// 处理 Claude 账号(区分 OAuth 和 Console)
|
||||
if (props.apiKey.claudeConsoleAccountId) {
|
||||
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`
|
||||
} else {
|
||||
form.claudeAccountId = props.apiKey.claudeAccountId || ''
|
||||
}
|
||||
form.geminiAccountId = props.apiKey.geminiAccountId || ''
|
||||
form.restrictedModels = props.apiKey.restrictedModels || []
|
||||
form.allowedClients = props.apiKey.allowedClients || []
|
||||
|
||||
468
web/admin-spa/src/components/common/AccountSelector.vue
Normal file
468
web/admin-spa/src/components/common/AccountSelector.vue
Normal file
@@ -0,0 +1,468 @@
|
||||
<template>
|
||||
<div ref="triggerRef" class="relative">
|
||||
<!-- 选择器主体 -->
|
||||
<div
|
||||
class="form-input w-full cursor-pointer flex items-center justify-between"
|
||||
:class="{ 'opacity-50': disabled }"
|
||||
@click="!disabled && toggleDropdown()"
|
||||
>
|
||||
<span :class="modelValue ? 'text-gray-900' : 'text-gray-500'">{{ selectedLabel }}</span>
|
||||
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-200" :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 bg-white rounded-lg shadow-lg border border-gray-200 flex flex-col"
|
||||
:style="dropdownStyle"
|
||||
>
|
||||
<!-- 搜索框 -->
|
||||
<div class="p-3 border-b border-gray-200 flex-shrink-0">
|
||||
<div class="relative">
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索账号名称..."
|
||||
class="form-input w-full text-sm"
|
||||
style="padding-left: 40px; padding-right: 36px;"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none" />
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<i class="fas fa-times text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选项列表 -->
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<!-- 默认选项 -->
|
||||
<div
|
||||
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
:class="{ 'bg-blue-50': !modelValue }"
|
||||
@click="selectAccount(null)"
|
||||
>
|
||||
<span class="text-gray-700">{{ defaultOptionText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 分组选项 -->
|
||||
<div v-if="filteredGroups.length > 0">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-gray-500 bg-gray-50">
|
||||
调度分组
|
||||
</div>
|
||||
<div
|
||||
v-for="group in filteredGroups"
|
||||
:key="`group:${group.id}`"
|
||||
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
:class="{ 'bg-blue-50': modelValue === `group:${group.id}` }"
|
||||
@click="selectAccount(`group:${group.id}`)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-700">{{ group.name }}</span>
|
||||
<span class="text-xs text-gray-500">{{ group.memberCount || 0 }} 个成员</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OAuth 账号 -->
|
||||
<div v-if="filteredOAuthAccounts.length > 0">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-gray-500 bg-gray-50">
|
||||
{{ platform === 'claude' ? 'Claude OAuth 专属账号' : 'OAuth 专属账号' }}
|
||||
</div>
|
||||
<div
|
||||
v-for="account in filteredOAuthAccounts"
|
||||
:key="account.id"
|
||||
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
:class="{ 'bg-blue-50': modelValue === account.id }"
|
||||
@click="selectAccount(account.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-gray-700">{{ account.name }}</span>
|
||||
<span
|
||||
class="ml-2 text-xs px-2 py-0.5 rounded-full"
|
||||
:class="account.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ account.status === 'active' ? '正常' : '异常' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ formatDate(account.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Console 账号(仅 Claude) -->
|
||||
<div v-if="platform === 'claude' && filteredConsoleAccounts.length > 0">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-gray-500 bg-gray-50">
|
||||
Claude Console 专属账号
|
||||
</div>
|
||||
<div
|
||||
v-for="account in filteredConsoleAccounts"
|
||||
:key="account.id"
|
||||
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
:class="{ 'bg-blue-50': modelValue === `console:${account.id}` }"
|
||||
@click="selectAccount(`console:${account.id}`)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-gray-700">{{ account.name }}</span>
|
||||
<span
|
||||
class="ml-2 text-xs px-2 py-0.5 rounded-full"
|
||||
:class="account.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ account.status === 'active' ? '正常' : '异常' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ formatDate(account.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无搜索结果 -->
|
||||
<div v-if="searchQuery && !hasResults" class="px-4 py-8 text-center text-gray-500">
|
||||
<i class="fas fa-search text-2xl mb-2" />
|
||||
<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'].includes(value)
|
||||
},
|
||||
accounts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请选择账号'
|
||||
},
|
||||
defaultOptionText: {
|
||||
type: String,
|
||||
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 selectedLabel = computed(() => {
|
||||
// 如果没有选中值,显示默认选项文本
|
||||
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} (${account.status === 'active' ? '正常' : '异常'})` : ''
|
||||
}
|
||||
|
||||
// OAuth 账号
|
||||
const account = props.accounts.find(a => a.id === props.modelValue)
|
||||
return account ? `${account.name} (${account.status === 'active' ? '正常' : '异常'})` : ''
|
||||
})
|
||||
|
||||
// 按创建时间倒序排序账号
|
||||
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(() => {
|
||||
if (!searchQuery.value) return props.groups
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.groups.filter(group =>
|
||||
group.name.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
// 过滤的 OAuth 账号
|
||||
const filteredOAuthAccounts = computed(() => {
|
||||
let accounts = sortedAccounts.value.filter(a =>
|
||||
a.accountType === 'dedicated' &&
|
||||
(props.platform === 'claude' ? a.platform === 'claude-oauth' : a.platform !== 'claude-console')
|
||||
)
|
||||
|
||||
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.accountType === 'dedicated' && a.platform === 'claude-console'
|
||||
)
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
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>
|
||||
Reference in New Issue
Block a user