fix(frontend): 修复新手引导中Select下拉框无法点击的问题
- 使用 Teleport 将 Select 下拉菜单渲染到 body,避免 driver.js 遮罩层阻挡 - 添加 pointer-events 和 @click.stop 确保下拉选项可点击 - 移除 useOnboardingTour 中无效的 Select 组件处理代码 - 清理未使用的 CSS 样式和 console 调试语句 - 简化 Select 组件在引导期间的交互逻辑
This commit is contained in:
@@ -47,7 +47,7 @@ export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false):
|
||||
description: t('onboarding.admin.groupName.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -57,7 +57,7 @@ export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false):
|
||||
description: t('onboarding.admin.groupPlatform.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -67,7 +67,7 @@ export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false):
|
||||
description: t('onboarding.admin.groupMultiplier.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@ export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false):
|
||||
description: t('onboarding.admin.groupExclusive.description'),
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -119,7 +119,7 @@ export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false):
|
||||
description: t('onboarding.admin.accountName.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -129,7 +129,7 @@ export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false):
|
||||
description: t('onboarding.admin.accountPlatform.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -139,7 +139,7 @@ export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false):
|
||||
description: t('onboarding.admin.accountType.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -149,7 +149,7 @@ export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false):
|
||||
description: t('onboarding.admin.accountPriority.description'),
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -159,7 +159,7 @@ export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false):
|
||||
description: t('onboarding.admin.accountGroups.description'),
|
||||
side: 'top',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -201,7 +201,7 @@ export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false):
|
||||
description: t('onboarding.admin.keyName.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -211,7 +211,7 @@ export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false):
|
||||
description: t('onboarding.admin.keyGroup.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -283,7 +283,7 @@ export const getUserSteps = (t: (key: string) => string): DriveStep[] => [
|
||||
description: t('onboarding.user.keyName.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -293,7 +293,7 @@ export const getUserSteps = (t: (key: string) => string): DriveStep[] => [
|
||||
description: t('onboarding.user.keyGroup.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -29,67 +29,73 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition name="select-dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="dropdownRef"
|
||||
:class="['select-dropdown', dropdownPosition === 'top' && 'select-dropdown-top']"
|
||||
>
|
||||
<!-- 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"
|
||||
<!-- 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
|
||||
/>
|
||||
</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="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 }}
|
||||
<!-- 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>
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -147,6 +153,28 @@ 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>
|
||||
@@ -193,14 +221,17 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
|
||||
const calculateDropdownPosition = () => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
// Update trigger rect for positioning
|
||||
triggerRect.value = containerRef.value.getBoundingClientRect()
|
||||
|
||||
nextTick(() => {
|
||||
if (!containerRef.value || !dropdownRef.value) return
|
||||
|
||||
const triggerRect = containerRef.value.getBoundingClientRect()
|
||||
const rect = triggerRect.value!
|
||||
const dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
|
||||
const viewportHeight = window.innerHeight
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom
|
||||
const spaceAbove = triggerRect.top
|
||||
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) {
|
||||
@@ -233,10 +264,21 @@ const selectOption = (option: SelectOption | Record<string, unknown>) => {
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
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) => {
|
||||
@@ -295,54 +337,57 @@ onUnmounted(() => {
|
||||
.select-icon {
|
||||
@apply flex-shrink-0 text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
.select-dropdown {
|
||||
@apply absolute left-0 z-[100] mt-2 min-w-full w-max max-w-[300px];
|
||||
<!-- 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-top {
|
||||
@apply bottom-full mb-2 mt-0;
|
||||
}
|
||||
|
||||
.select-search {
|
||||
.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-search-input {
|
||||
.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-options {
|
||||
.select-dropdown-portal .select-options {
|
||||
@apply max-h-60 overflow-y-auto py-1;
|
||||
}
|
||||
|
||||
.select-option {
|
||||
.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-option-selected {
|
||||
.select-dropdown-portal .select-option-selected {
|
||||
@apply bg-primary-50 dark:bg-primary-900/20;
|
||||
@apply text-primary-700 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.select-option-label {
|
||||
.select-dropdown-portal .select-option-label {
|
||||
@apply flex-1 min-w-0 truncate text-left;
|
||||
}
|
||||
|
||||
.select-empty {
|
||||
.select-dropdown-portal .select-empty {
|
||||
@apply px-4 py-8 text-center text-sm;
|
||||
@apply text-gray-500 dark:text-dark-400;
|
||||
}
|
||||
@@ -356,17 +401,6 @@ onUnmounted(() => {
|
||||
.select-dropdown-enter-from,
|
||||
.select-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Animation for dropdown opening downward (default) */
|
||||
.select-dropdown:not(.select-dropdown-top).select-dropdown-enter-from,
|
||||
.select-dropdown:not(.select-dropdown-top).select-dropdown-leave-to {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
/* Animation for dropdown opening upward */
|
||||
.select-dropdown-top.select-dropdown-enter-from,
|
||||
.select-dropdown-top.select-dropdown-leave-to {
|
||||
transform: translateY(8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,23 +25,19 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/onboarding.css'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useOnboardingTour } from '@/composables/useOnboardingTour'
|
||||
import { getAdminSteps, getUserSteps } from '@/components/Guide/steps'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const { t } = useI18n()
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||
|
||||
const { replayTour } = useOnboardingTour({
|
||||
steps: isAdmin.value ? getAdminSteps(t) : getUserSteps(t),
|
||||
storageKey: isAdmin.value ? 'admin_guide' : 'user_guide',
|
||||
autoStart: true
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user