mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +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:
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