mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
feat: 完善管理界面功能和用户体验
- 添加 API Key 窗口倒计时组件 (WindowCountdown) - 添加自定义下拉菜单组件 (CustomDropdown) - 优化账户和 API Key 管理界面交互 - 改进教程页面布局和说明文字 - 完善账户状态显示和错误处理 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -89,7 +89,7 @@
|
||||
>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">添加方式</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<label v-if="form.platform === 'claude'" class="flex cursor-pointer items-center">
|
||||
<input v-model="form.addType" class="mr-2" type="radio" value="setup-token" />
|
||||
<span class="text-sm text-gray-700">Setup Token (推荐)</span>
|
||||
</label>
|
||||
@@ -1203,7 +1203,7 @@ const initProxyConfig = () => {
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
platform: props.account?.platform || 'claude',
|
||||
addType: 'setup-token',
|
||||
addType: props.account?.platform === 'gemini' ? 'oauth' : 'setup-token',
|
||||
name: props.account?.name || '',
|
||||
description: props.account?.description || '',
|
||||
accountType: props.account?.accountType || 'shared',
|
||||
|
||||
@@ -442,48 +442,71 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.enableModelRestriction"
|
||||
class="space-y-2 rounded-lg border border-red-200 bg-red-50 p-3"
|
||||
>
|
||||
<div v-if="form.enableModelRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700">限制的模型列表</label>
|
||||
<div class="mb-2 flex min-h-[24px] flex-wrap gap-1">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600">限制的模型列表</label>
|
||||
<div
|
||||
class="mb-3 flex min-h-[32px] flex-wrap gap-2 rounded-lg border border-gray-200 bg-gray-50 p-2"
|
||||
>
|
||||
<span
|
||||
v-for="(model, index) in form.restrictedModels"
|
||||
:key="index"
|
||||
class="inline-flex items-center rounded-full bg-red-100 px-2 py-1 text-xs text-red-800"
|
||||
class="inline-flex items-center rounded-full bg-red-100 px-3 py-1 text-sm text-red-800"
|
||||
>
|
||||
{{ model }}
|
||||
<button
|
||||
class="ml-1 text-red-600 hover:text-red-800"
|
||||
class="ml-2 text-red-600 hover:text-red-800"
|
||||
type="button"
|
||||
@click="removeRestrictedModel(index)"
|
||||
>
|
||||
<i class="fas fa-times text-xs" />
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="form.restrictedModels.length === 0" class="text-xs text-gray-400">
|
||||
<span v-if="form.restrictedModels.length === 0" class="text-sm text-gray-400">
|
||||
暂无限制的模型
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.modelInput"
|
||||
class="form-input flex-1 text-sm"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
type="text"
|
||||
@keydown.enter.prevent="addRestrictedModel"
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-red-500 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-600"
|
||||
type="button"
|
||||
@click="addRestrictedModel"
|
||||
>
|
||||
<i class="fas fa-plus" />
|
||||
</button>
|
||||
<div class="space-y-3">
|
||||
<!-- 快速添加按钮 -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="model in availableQuickModels"
|
||||
:key="model"
|
||||
class="flex-shrink-0 rounded-lg bg-gray-100 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-gray-200 sm:text-sm"
|
||||
type="button"
|
||||
@click="quickAddRestrictedModel(model)"
|
||||
>
|
||||
{{ model }}
|
||||
</button>
|
||||
<span
|
||||
v-if="availableQuickModels.length === 0"
|
||||
class="text-sm italic text-gray-400"
|
||||
>
|
||||
所有常用模型已在限制列表中
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 手动输入 -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.modelInput"
|
||||
class="form-input flex-1"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
type="text"
|
||||
@keydown.enter.prevent="addRestrictedModel"
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600"
|
||||
type="button"
|
||||
@click="addRestrictedModel"
|
||||
>
|
||||
<i class="fas fa-plus" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">例如:claude-opus-4-20250514</p>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
设置此API Key无法访问的模型,例如:claude-opus-4-20250514
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -769,6 +792,21 @@ const removeRestrictedModel = (index) => {
|
||||
form.restrictedModels.splice(index, 1)
|
||||
}
|
||||
|
||||
// 常用模型列表
|
||||
const commonModels = ref(['claude-opus-4-20250514', 'claude-opus-4-1-20250805'])
|
||||
|
||||
// 可用的快捷模型(过滤掉已在限制列表中的)
|
||||
const availableQuickModels = computed(() => {
|
||||
return commonModels.value.filter((model) => !form.restrictedModels.includes(model))
|
||||
})
|
||||
|
||||
// 快速添加限制的模型
|
||||
const quickAddRestrictedModel = (model) => {
|
||||
if (!form.restrictedModels.includes(model)) {
|
||||
form.restrictedModels.push(model)
|
||||
}
|
||||
}
|
||||
|
||||
// 标签管理方法
|
||||
const addTag = () => {
|
||||
if (newTag.value && newTag.value.trim()) {
|
||||
|
||||
@@ -238,6 +238,27 @@
|
||||
<p class="mt-2 text-xs text-gray-500">设置此 API Key 可同时处理的最大请求数</p>
|
||||
</div>
|
||||
|
||||
<!-- 激活账号 -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center">
|
||||
<input
|
||||
id="editIsActive"
|
||||
v-model="form.isActive"
|
||||
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700"
|
||||
for="editIsActive"
|
||||
>
|
||||
激活账号
|
||||
</label>
|
||||
</div>
|
||||
<p class="mb-4 text-xs text-gray-500">
|
||||
取消勾选将禁用此 API Key,暂停所有请求,客户端返回 401 错误
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">服务权限</label>
|
||||
<div class="flex gap-4">
|
||||
@@ -511,7 +532,8 @@ const form = reactive({
|
||||
modelInput: '',
|
||||
enableClientRestriction: false,
|
||||
allowedClients: [],
|
||||
tags: []
|
||||
tags: [],
|
||||
isActive: true
|
||||
})
|
||||
|
||||
// 添加限制的模型
|
||||
@@ -628,6 +650,9 @@ const updateApiKey = async () => {
|
||||
data.enableClientRestriction = form.enableClientRestriction
|
||||
data.allowedClients = form.allowedClients
|
||||
|
||||
// 活跃状态
|
||||
data.isActive = form.isActive
|
||||
|
||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||
|
||||
if (result.success) {
|
||||
@@ -737,6 +762,8 @@ onMounted(async () => {
|
||||
// 从后端数据中获取实际的启用状态,而不是根据数组长度推断
|
||||
form.enableModelRestriction = props.apiKey.enableModelRestriction || false
|
||||
form.enableClientRestriction = props.apiKey.enableClientRestriction || false
|
||||
// 初始化活跃状态,默认为 true
|
||||
form.isActive = props.apiKey.isActive !== undefined ? props.apiKey.isActive : true
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
254
web/admin-spa/src/components/apikeys/WindowCountdown.vue
Normal file
254
web/admin-spa/src/components/apikeys/WindowCountdown.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-500">{{ label }}</span>
|
||||
<span v-if="windowState === 'active'" class="font-medium text-gray-700">
|
||||
<i class="fas fa-clock mr-1 text-blue-500" />
|
||||
{{ formatTime(remainingSeconds) }}
|
||||
</span>
|
||||
<span v-else-if="windowState === 'expired'" class="font-medium text-orange-600">
|
||||
<i class="fas fa-sync-alt mr-1" />
|
||||
窗口已过期
|
||||
</span>
|
||||
<span v-else-if="windowState === 'notStarted'" class="font-medium text-gray-500">
|
||||
<i class="fas fa-pause-circle mr-1" />
|
||||
窗口未激活
|
||||
</span>
|
||||
<span v-else class="font-medium text-gray-400"> {{ rateLimitWindow }} 分钟 </span>
|
||||
</div>
|
||||
|
||||
<!-- 进度条(仅在有限制时显示) -->
|
||||
<div v-if="showProgress" class="space-y-0.5">
|
||||
<div v-if="hasRequestLimit" class="space-y-0.5">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-400">请求</span>
|
||||
<span class="text-gray-600"> {{ currentRequests || 0 }}/{{ requestLimit }} </span>
|
||||
</div>
|
||||
<div class="h-1 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-1 rounded-full transition-all duration-300"
|
||||
:class="getRequestProgressColor()"
|
||||
:style="{ width: getRequestProgress() + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasTokenLimit" class="space-y-0.5">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-400">Token</span>
|
||||
<span class="text-gray-600">
|
||||
{{ formatTokenCount(currentTokens || 0) }}/{{ formatTokenCount(tokenLimit) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-1 rounded-full transition-all duration-300"
|
||||
:class="getTokenProgressColor()"
|
||||
:style="{ width: getTokenProgress() + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 额外提示信息 -->
|
||||
<div v-if="windowState === 'active' && showTooltip" class="text-xs text-gray-500">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
<span v-if="remainingSeconds < 60">即将重置</span>
|
||||
<span v-else-if="remainingSeconds < 300"
|
||||
>{{ Math.ceil(remainingSeconds / 60) }} 分钟后重置</span
|
||||
>
|
||||
<span v-else>{{ formatDetailedTime(remainingSeconds) }}后重置</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '窗口限制'
|
||||
},
|
||||
rateLimitWindow: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
windowStartTime: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
windowEndTime: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
windowRemainingSeconds: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
currentRequests: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
requestLimit: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
currentTokens: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
tokenLimit: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
showProgress: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showTooltip: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// 响应式数据
|
||||
const remainingSeconds = ref(props.windowRemainingSeconds)
|
||||
let intervalId = null
|
||||
|
||||
// 计算属性
|
||||
const windowState = computed(() => {
|
||||
if (props.windowStartTime === null) {
|
||||
return 'notStarted' // 窗口未开始
|
||||
}
|
||||
if (remainingSeconds.value === 0) {
|
||||
return 'expired' // 窗口已过期
|
||||
}
|
||||
if (remainingSeconds.value > 0) {
|
||||
return 'active' // 窗口活跃中
|
||||
}
|
||||
return 'unknown'
|
||||
})
|
||||
|
||||
const hasRequestLimit = computed(() => props.requestLimit > 0)
|
||||
const hasTokenLimit = computed(() => props.tokenLimit > 0)
|
||||
|
||||
// 方法
|
||||
const formatTime = (seconds) => {
|
||||
if (seconds === null || seconds === undefined) return '--:--'
|
||||
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`
|
||||
} else {
|
||||
return `${secs}s`
|
||||
}
|
||||
}
|
||||
|
||||
const formatDetailedTime = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${minutes}分钟`
|
||||
} else {
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
}
|
||||
|
||||
const formatTokenCount = (count) => {
|
||||
if (count >= 1000000) {
|
||||
return (count / 1000000).toFixed(1) + 'M'
|
||||
} else if (count >= 1000) {
|
||||
return (count / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
const getRequestProgress = () => {
|
||||
if (!props.requestLimit || props.requestLimit === 0) return 0
|
||||
const percentage = ((props.currentRequests || 0) / props.requestLimit) * 100
|
||||
return Math.min(percentage, 100)
|
||||
}
|
||||
|
||||
const getRequestProgressColor = () => {
|
||||
const progress = getRequestProgress()
|
||||
if (progress >= 100) return 'bg-red-500'
|
||||
if (progress >= 80) return 'bg-yellow-500'
|
||||
return 'bg-blue-500'
|
||||
}
|
||||
|
||||
const getTokenProgress = () => {
|
||||
if (!props.tokenLimit || props.tokenLimit === 0) return 0
|
||||
const percentage = ((props.currentTokens || 0) / props.tokenLimit) * 100
|
||||
return Math.min(percentage, 100)
|
||||
}
|
||||
|
||||
const getTokenProgressColor = () => {
|
||||
const progress = getTokenProgress()
|
||||
if (progress >= 100) return 'bg-red-500'
|
||||
if (progress >= 80) return 'bg-yellow-500'
|
||||
return 'bg-purple-500'
|
||||
}
|
||||
|
||||
// 更新倒计时
|
||||
const updateCountdown = () => {
|
||||
if (props.windowEndTime && remainingSeconds.value > 0) {
|
||||
const now = Date.now()
|
||||
const remaining = Math.max(0, Math.floor((props.windowEndTime - now) / 1000))
|
||||
remainingSeconds.value = remaining
|
||||
|
||||
if (remaining === 0) {
|
||||
// 窗口已过期,停止倒计时
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听props变化
|
||||
watch(
|
||||
() => props.windowRemainingSeconds,
|
||||
(newVal) => {
|
||||
remainingSeconds.value = newVal
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.windowEndTime,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
// 重新计算剩余时间
|
||||
updateCountdown()
|
||||
|
||||
// 如果窗口活跃且没有定时器,启动定时器
|
||||
if (!intervalId && remainingSeconds.value > 0) {
|
||||
intervalId = setInterval(updateCountdown, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
if (props.windowEndTime && remainingSeconds.value > 0) {
|
||||
// 立即更新一次
|
||||
updateCountdown()
|
||||
// 启动定时器
|
||||
intervalId = setInterval(updateCountdown, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
219
web/admin-spa/src/components/common/CustomDropdown.vue
Normal file
219
web/admin-spa/src/components/common/CustomDropdown.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- 触发器 -->
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="relative flex cursor-pointer items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-sm transition-all duration-200 hover:shadow-md"
|
||||
:class="[isOpen && 'border-blue-400 shadow-md']"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<i v-if="icon" :class="['fas', icon, 'text-sm', iconColor]"></i>
|
||||
<span class="select-none whitespace-nowrap text-sm font-medium text-gray-700">
|
||||
{{ selectedLabel || placeholder }}
|
||||
</span>
|
||||
<i
|
||||
:class="[
|
||||
'fas fa-chevron-down ml-auto text-xs text-gray-400 transition-transform duration-200',
|
||||
isOpen && 'rotate-180'
|
||||
]"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- 下拉选项 - 使用 Teleport 将其移动到 body -->
|
||||
<Teleport to="body">
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="dropdownRef"
|
||||
class="fixed z-[9999] min-w-max overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||
:style="dropdownStyle"
|
||||
>
|
||||
<div class="max-h-60 overflow-y-auto py-1">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="flex cursor-pointer items-center gap-2 whitespace-nowrap px-3 py-2 text-sm transition-colors duration-150"
|
||||
:class="[
|
||||
option.value === modelValue
|
||||
? 'bg-blue-50 font-medium text-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
]"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<i v-if="option.icon" :class="['fas', option.icon, 'text-xs']"></i>
|
||||
<span>{{ option.label }}</span>
|
||||
<i
|
||||
v-if="option.value === modelValue"
|
||||
class="fas fa-check ml-auto pl-3 text-xs text-blue-600"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请选择'
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: 'text-gray-500'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const isOpen = ref(false)
|
||||
const triggerRef = ref(null)
|
||||
const dropdownRef = ref(null)
|
||||
const dropdownStyle = ref({})
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
const selected = props.options.find((opt) => opt.value === props.modelValue)
|
||||
return selected ? selected.label : ''
|
||||
})
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
updateDropdownPosition()
|
||||
}
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const selectOption = (option) => {
|
||||
emit('update:modelValue', option.value)
|
||||
emit('change', option.value)
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
const updateDropdownPosition = () => {
|
||||
if (!triggerRef.value || !isOpen.value) return
|
||||
|
||||
const trigger = triggerRef.value.getBoundingClientRect()
|
||||
const dropdownHeight = 250 // 预估高度
|
||||
const spaceBelow = window.innerHeight - trigger.bottom
|
||||
const spaceAbove = trigger.top
|
||||
|
||||
let top, left
|
||||
|
||||
// 计算垂直位置
|
||||
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
||||
// 显示在下方
|
||||
top = trigger.bottom + 8
|
||||
} else {
|
||||
// 显示在上方
|
||||
top = trigger.top - dropdownHeight - 8
|
||||
}
|
||||
|
||||
// 计算水平位置
|
||||
left = trigger.left
|
||||
|
||||
// 确保不超出右边界
|
||||
const dropdownWidth = 200 // 预估宽度
|
||||
if (left + dropdownWidth > window.innerWidth) {
|
||||
left = window.innerWidth - dropdownWidth - 10
|
||||
}
|
||||
|
||||
// 确保不超出左边界
|
||||
if (left < 10) {
|
||||
left = 10
|
||||
}
|
||||
|
||||
dropdownStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
minWidth: `${trigger.width}px`
|
||||
}
|
||||
}
|
||||
|
||||
// 监听窗口大小变化和滚动
|
||||
const handleScroll = () => {
|
||||
if (isOpen.value) {
|
||||
updateDropdownPosition()
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (isOpen.value) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理点击外部关闭
|
||||
const handleClickOutside = (event) => {
|
||||
if (!triggerRef.value || !isOpen.value) return
|
||||
|
||||
// 如果点击不在触发器内,且下拉框存在时也不在下拉框内,则关闭
|
||||
if (!triggerRef.value.contains(event.target)) {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
|
||||
closeDropdown()
|
||||
} else if (!dropdownRef.value) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
window.addEventListener('resize', handleResize)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义滚动条 */
|
||||
.max-h-60::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.max-h-60::-webkit-scrollbar-track {
|
||||
background: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.max-h-60::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.max-h-60::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user