shaw 4bbf71b7da fix(frontend): 修复新手引导中Select下拉框无法点击的问题
- 使用 Teleport 将 Select 下拉菜单渲染到 body,避免 driver.js 遮罩层阻挡
- 添加 pointer-events 和 @click.stop 确保下拉选项可点击
- 移除 useOnboardingTour 中无效的 Select 组件处理代码
- 清理未使用的 CSS 样式和 console 调试语句
- 简化 Select 组件在引导期间的交互逻辑
2025-12-29 19:38:33 +08:00

407 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="relative" ref="containerRef">
<button
type="button"
@click="toggle"
:disabled="disabled"
:class="[
'select-trigger',
isOpen && 'select-trigger-open',
error && 'select-trigger-error',
disabled && 'select-trigger-disabled'
]"
>
<span class="select-value">
<slot name="selected" :option="selectedOption">
{{ selectedLabel }}
</slot>
</span>
<span class="select-icon">
<svg
:class="['h-5 w-5 transition-transform duration-200', isOpen && 'rotate-180']"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</span>
</button>
<!-- Teleport dropdown to body to escape stacking context (for driver.js overlay compatibility) -->
<Teleport to="body">
<Transition name="select-dropdown">
<div
v-if="isOpen"
ref="dropdownRef"
class="select-dropdown-portal"
:style="dropdownStyle"
@click.stop
@mousedown.stop
>
<!-- Search input -->
<div v-if="searchable" class="select-search">
<svg
class="h-4 w-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
:placeholder="searchPlaceholderText"
class="select-search-input"
@click.stop
/>
</div>
<!-- Options list -->
<div class="select-options">
<div
v-for="option in filteredOptions"
:key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
@click.stop="selectOption(option)"
:class="['select-option', isSelected(option) && 'select-option-selected']"
>
<slot name="option" :option="option" :selected="isSelected(option)">
<span class="select-option-label">{{ getOptionLabel(option) }}</span>
<svg
v-if="isSelected(option)"
class="h-4 w-4 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</slot>
</div>
<!-- Empty state -->
<div v-if="filteredOptions.length === 0" class="select-empty">
{{ emptyTextDisplay }}
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
export interface SelectOption {
value: string | number | boolean | null
label: string
disabled?: boolean
[key: string]: unknown
}
interface Props {
modelValue: string | number | boolean | null | undefined
options: SelectOption[] | Array<Record<string, unknown>>
placeholder?: string
disabled?: boolean
error?: boolean
searchable?: boolean
searchPlaceholder?: string
emptyText?: string
valueKey?: string
labelKey?: string
}
interface Emits {
(e: 'update:modelValue', value: string | number | boolean | null): void
(e: 'change', value: string | number | boolean | null, option: SelectOption | null): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
error: false,
searchable: false,
valueKey: 'value',
labelKey: 'label'
})
// Use computed for i18n default values
const placeholderText = computed(() => props.placeholder ?? t('common.selectOption'))
const searchPlaceholderText = computed(
() => props.searchPlaceholder ?? t('common.searchPlaceholder')
)
const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
const emit = defineEmits<Emits>()
const isOpen = ref(false)
const searchQuery = ref('')
const containerRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<'bottom' | 'top'>('bottom')
const triggerRect = ref<DOMRect | null>(null)
// Computed style for teleported dropdown
const dropdownStyle = computed(() => {
if (!triggerRect.value) return {}
const rect = triggerRect.value
const style: Record<string, string> = {
position: 'fixed',
left: `${rect.left}px`,
minWidth: `${rect.width}px`,
zIndex: '100000020' // Higher than driver.js overlay (99999998)
}
if (dropdownPosition.value === 'top') {
style.bottom = `${window.innerHeight - rect.top + 8}px`
} else {
style.top = `${rect.bottom + 8}px`
}
return style
})
const getOptionValue = (
option: SelectOption | Record<string, unknown>
): string | number | boolean | null | undefined => {
if (typeof option === 'object' && option !== null) {
return option[props.valueKey] as string | number | boolean | null | undefined
}
return option as string | number | boolean | null
}
const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => {
if (typeof option === 'object' && option !== null) {
return String(option[props.labelKey] ?? '')
}
return String(option ?? '')
}
const selectedOption = computed(() => {
return props.options.find((opt) => getOptionValue(opt) === props.modelValue) || null
})
const selectedLabel = computed(() => {
if (selectedOption.value) {
return getOptionLabel(selectedOption.value)
}
return placeholderText.value
})
const filteredOptions = computed(() => {
if (!props.searchable || !searchQuery.value) {
return props.options
}
const query = searchQuery.value.toLowerCase()
return props.options.filter((opt) => {
const label = getOptionLabel(opt).toLowerCase()
return label.includes(query)
})
})
const isSelected = (option: SelectOption | Record<string, unknown>): boolean => {
return getOptionValue(option) === props.modelValue
}
const calculateDropdownPosition = () => {
if (!containerRef.value) return
// Update trigger rect for positioning
triggerRect.value = containerRef.value.getBoundingClientRect()
nextTick(() => {
if (!containerRef.value || !dropdownRef.value) return
const rect = triggerRect.value!
const dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - rect.bottom
const spaceAbove = rect.top
// If not enough space below but enough space above, show dropdown on top
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
dropdownPosition.value = 'top'
} else {
dropdownPosition.value = 'bottom'
}
})
}
const toggle = () => {
if (props.disabled) return
isOpen.value = !isOpen.value
if (isOpen.value) {
calculateDropdownPosition()
if (props.searchable) {
nextTick(() => {
searchInputRef.value?.focus()
})
}
}
}
const selectOption = (option: SelectOption | Record<string, unknown>) => {
const value = getOptionValue(option) ?? null
emit('update:modelValue', value)
emit('change', value, option as SelectOption)
isOpen.value = false
searchQuery.value = ''
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
// 使用 closest 检查点击是否在下拉菜单内部(更可靠,不依赖 ref
if (target.closest('.select-dropdown-portal')) {
return // 点击在下拉菜单内,不关闭
}
// 检查是否点击在触发器内
if (containerRef.value && containerRef.value.contains(target)) {
return // 点击在触发器内,让 toggle 处理
}
// 点击在外部,关闭下拉菜单
isOpen.value = false
searchQuery.value = ''
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen.value) {
isOpen.value = false
searchQuery.value = ''
}
}
watch(isOpen, (open) => {
if (!open) {
searchQuery.value = ''
}
})
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
})
</script>
<style scoped>
.select-trigger {
@apply flex w-full items-center justify-between gap-2;
@apply rounded-xl px-4 py-2.5 text-sm;
@apply bg-white dark:bg-dark-800;
@apply border border-gray-200 dark:border-dark-600;
@apply text-gray-900 dark:text-gray-100;
@apply transition-all duration-200;
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
@apply hover:border-gray-300 dark:hover:border-dark-500;
@apply cursor-pointer;
}
.select-trigger-open {
@apply border-primary-500 ring-2 ring-primary-500/30;
}
.select-trigger-error {
@apply border-red-500 focus:border-red-500 focus:ring-red-500/30;
}
.select-trigger-disabled {
@apply cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900;
}
.select-value {
@apply flex-1 truncate text-left;
}
.select-icon {
@apply flex-shrink-0 text-gray-400 dark:text-dark-400;
}
</style>
<!-- Global styles for teleported dropdown -->
<style>
.select-dropdown-portal {
@apply w-max max-w-[300px];
@apply bg-white dark:bg-dark-800;
@apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700;
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
@apply overflow-hidden;
/* 确保下拉菜单在引导期间可点击(覆盖 driver.js 的 pointer-events 影响) */
pointer-events: auto !important;
}
.select-dropdown-portal .select-search {
@apply flex items-center gap-2 px-3 py-2;
@apply border-b border-gray-100 dark:border-dark-700;
}
.select-dropdown-portal .select-search-input {
@apply flex-1 bg-transparent text-sm;
@apply text-gray-900 dark:text-gray-100;
@apply placeholder:text-gray-400 dark:placeholder:text-dark-400;
@apply focus:outline-none;
}
.select-dropdown-portal .select-options {
@apply max-h-60 overflow-y-auto py-1;
}
.select-dropdown-portal .select-option {
@apply flex items-center justify-between gap-2;
@apply px-4 py-2.5 text-sm;
@apply text-gray-700 dark:text-gray-300;
@apply cursor-pointer transition-colors duration-150;
@apply hover:bg-gray-50 dark:hover:bg-dark-700;
/* 确保选项在引导期间可点击 */
pointer-events: auto !important;
}
.select-dropdown-portal .select-option-selected {
@apply bg-primary-50 dark:bg-primary-900/20;
@apply text-primary-700 dark:text-primary-300;
}
.select-dropdown-portal .select-option-label {
@apply flex-1 min-w-0 truncate text-left;
}
.select-dropdown-portal .select-empty {
@apply px-4 py-8 text-center text-sm;
@apply text-gray-500 dark:text-dark-400;
}
/* Dropdown animation */
.select-dropdown-enter-active,
.select-dropdown-leave-active {
transition: all 0.2s ease;
}
.select-dropdown-enter-from,
.select-dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>