Files
Henry_Du 8a82cb77a7 fix(ui): 适配歌曲卡片、日期选择器、平台按钮及搜索占位图的浅色主题
- 修复 SongList/ScheduleList 投稿人、热度、投稿时间文字对比度,统一引用 --text-secondary 主题变量
- 重构 CustomSelect 通用组件为浅色底 + indigo 主色系,适配前台投稿页日期选择
- 调整 RequestForm 平台按钮三态配色,激活态使用 indigo 渐变保持主题统一
- 重写 search.svg 全部 19 个 path fill 为深灰系列,解决浅色背景下占位图不可见问题
2026-09-18 22:48:14 +08:00

226 lines
6.5 KiB
Vue

<template>
<div ref="containerRef" class="relative" :class="className">
<div
class="flex items-center gap-2 px-3 py-2 bg-white border rounded-xl transition-all cursor-pointer select-none"
:class="[
isOpen
? 'border-indigo-400/60 bg-indigo-50/60 shadow-sm'
: 'border-zinc-200 hover:border-zinc-300'
]"
@click="toggleDropdown"
>
<div class="flex flex-col flex-1 min-w-0">
<span
v-if="label"
class="text-[8px] font-black uppercase tracking-widest leading-none mb-0.5 transition-colors"
:class="isOpen ? 'text-indigo-500' : 'text-zinc-500'"
>
{{ label }}
</span>
<span class="text-[12px] font-bold text-zinc-800 truncate">{{ displayLabel }}</span>
</div>
<div class="transition-transform duration-200" :class="{ 'rotate-180': isOpen }">
<ChevronDown :size="12" :class="isOpen ? 'text-indigo-500' : 'text-zinc-500'" />
</div>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-1"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-1"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="isOpen"
ref="dropdownRef"
:style="dropdownStyle"
class="fixed z-[9999] p-1.5 bg-white border border-zinc-200 rounded-xl shadow-lg backdrop-blur-xl origin-top"
>
<div class="max-h-[220px] overflow-y-auto custom-scrollbar">
<button
v-for="option in normalizedOptions"
:key="option.value"
class="w-full flex items-center justify-between px-3 py-2 rounded-lg text-[12px] font-bold transition-all"
:class="[
isSelected(option)
? 'bg-indigo-50 text-indigo-600'
: 'text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100'
]"
@click="selectOption(option)"
>
<span class="truncate">{{ option.label }}</span>
<Icon v-if="isSelected(option)" name="check" :size="12" class="shrink-0" />
</button>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
import Icon from '~/components/UI/Icon.vue'
const props = defineProps({
label: String,
modelValue: [String, Number, Object],
value: [String, Number, Object],
options: {
type: Array,
required: true
},
className: String,
labelKey: {
type: String,
default: 'label'
},
valueKey: {
type: String,
default: 'value'
},
placeholder: {
type: String,
default: '请选择'
}
})
const emit = defineEmits(['update:modelValue', 'update:value', 'change'])
const isOpen = ref(false)
const containerRef = ref(null)
const dropdownRef = ref(null)
const dropdownStyle = ref({})
// 统一获取当前值
const currentValue = computed(() => {
if (props.modelValue !== undefined) return props.modelValue
return props.value
})
// 规范化选项为 { label, value } 格式
const normalizedOptions = computed(() => {
return props.options.map((option) => {
if (typeof option === 'object' && option !== null) {
return {
label: option[props.labelKey] || option.label,
value: option[props.valueKey] !== undefined ? option[props.valueKey] : option.value,
original: option
}
}
return { label: option, value: option, original: option }
})
})
// 获取当前显示标签
const displayLabel = computed(() => {
const selected = normalizedOptions.value.find((opt) => opt.value === currentValue.value)
return selected
? selected.label
: currentValue.value && typeof currentValue.value !== 'object'
? currentValue.value
: props.placeholder
})
const isSelected = (option) => {
return option.value === currentValue.value
}
const updatePosition = () => {
if (!isOpen.value || !containerRef.value) return
const rect = containerRef.value.getBoundingClientRect()
// 简单的位置计算,默认向下弹出
// 检查底部空间
const windowHeight = window.innerHeight
const spaceBelow = windowHeight - rect.bottom
const top = rect.bottom + 4
// 如果底部空间不足且顶部空间充足,则向上弹出
if (spaceBelow < 220 && rect.top > 220) {
// 暂时保持向下,或者使用 fixed 定位让它尽量可见
}
dropdownStyle.value = {
top: `${top}px`,
left: `${rect.left}px`,
minWidth: `${Math.max(160, rect.width)}px`
}
}
const toggleDropdown = async () => {
if (isOpen.value) {
isOpen.value = false
} else {
isOpen.value = true
await nextTick()
updatePosition()
}
}
const selectOption = (option) => {
emit('update:modelValue', option.value)
emit('update:value', option.value)
emit('change', option.value)
isOpen.value = false
}
const handleClickOutside = (event) => {
// 检查点击是否在容器内部或者下拉框内部
const isClickInContainer = containerRef.value && containerRef.value.contains(event.target)
const isClickInDropdown = dropdownRef.value && dropdownRef.value.contains(event.target)
if (!isClickInContainer && !isClickInDropdown) {
isOpen.value = false
}
}
const handleScrollOrResize = () => {
if (isOpen.value) {
updatePosition()
}
}
// 监听 isOpen 变化来添加/移除事件监听
watch(isOpen, (val) => {
if (val) {
window.addEventListener('scroll', handleScrollOrResize, true) // capture=true 以捕获子元素的滚动
window.addEventListener('resize', handleScrollOrResize)
} else {
window.removeEventListener('scroll', handleScrollOrResize, true)
window.removeEventListener('resize', handleScrollOrResize)
}
})
onMounted(() => {
document.addEventListener('mousedown', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('mousedown', handleClickOutside)
window.removeEventListener('scroll', handleScrollOrResize, true)
window.removeEventListener('resize', handleScrollOrResize)
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--border-secondary, #e4e4e7);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--border-primary, #d4d4d8);
}
</style>