mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 20:31:59 +00:00
729 lines
25 KiB
Vue
729 lines
25 KiB
Vue
<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>
|