fix(frontend): 修复新手引导中Select下拉框无法点击的问题

- 使用 Teleport 将 Select 下拉菜单渲染到 body,避免 driver.js 遮罩层阻挡
- 添加 pointer-events 和 @click.stop 确保下拉选项可点击
- 移除 useOnboardingTour 中无效的 Select 组件处理代码
- 清理未使用的 CSS 样式和 console 调试语句
- 简化 Select 组件在引导期间的交互逻辑
This commit is contained in:
shaw
2025-12-29 19:38:33 +08:00
parent ef22d6f628
commit 4bbf71b7da
5 changed files with 191 additions and 237 deletions

View File

@@ -47,7 +47,7 @@ export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false):
description: t('onboarding.admin.groupName.description'), description: t('onboarding.admin.groupName.description'),
side: 'right', side: 'right',
align: 'start', 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'), description: t('onboarding.admin.groupPlatform.description'),
side: 'right', side: 'right',
align: 'start', 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'), description: t('onboarding.admin.groupMultiplier.description'),
side: 'right', side: 'right',
align: 'start', 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'), description: t('onboarding.admin.groupExclusive.description'),
side: 'top', side: 'top',
align: 'start', 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'), description: t('onboarding.admin.accountName.description'),
side: 'right', side: 'right',
align: 'start', 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'), description: t('onboarding.admin.accountPlatform.description'),
side: 'right', side: 'right',
align: 'start', 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'), description: t('onboarding.admin.accountType.description'),
side: 'right', side: 'right',
align: 'start', 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'), description: t('onboarding.admin.accountPriority.description'),
side: 'top', side: 'top',
align: 'start', 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'), description: t('onboarding.admin.accountGroups.description'),
side: 'top', side: 'top',
align: 'center', 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'), description: t('onboarding.admin.keyName.description'),
side: 'right', side: 'right',
align: 'start', 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'), description: t('onboarding.admin.keyGroup.description'),
side: 'right', side: 'right',
align: 'start', 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'), description: t('onboarding.user.keyName.description'),
side: 'right', side: 'right',
align: 'start', 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'), description: t('onboarding.user.keyGroup.description'),
side: 'right', side: 'right',
align: 'start', align: 'start',
showButtons: ['close'] showButtons: ['next', 'previous']
} }
}, },
{ {

View File

@@ -29,67 +29,73 @@
</span> </span>
</button> </button>
<Transition name="select-dropdown"> <!-- Teleport dropdown to body to escape stacking context (for driver.js overlay compatibility) -->
<div <Teleport to="body">
v-if="isOpen" <Transition name="select-dropdown">
ref="dropdownRef" <div
:class="['select-dropdown', dropdownPosition === 'top' && 'select-dropdown-top']" v-if="isOpen"
> ref="dropdownRef"
<!-- Search input --> class="select-dropdown-portal"
<div v-if="searchable" class="select-search"> :style="dropdownStyle"
<svg @click.stop
class="h-4 w-4 text-gray-400" @mousedown.stop
fill="none" >
stroke="currentColor" <!-- Search input -->
viewBox="0 0 24 24" <div v-if="searchable" class="select-search">
stroke-width="1.5" <svg
> class="h-4 w-4 text-gray-400"
<path fill="none"
stroke-linecap="round" stroke="currentColor"
stroke-linejoin="round" viewBox="0 0 24 24"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" 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> </div>
<!-- Empty state --> <!-- Options list -->
<div v-if="filteredOptions.length === 0" class="select-empty"> <div class="select-options">
{{ emptyTextDisplay }} <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> </div>
</div> </Transition>
</Transition> </Teleport>
</div> </div>
</template> </template>
@@ -147,6 +153,28 @@ const containerRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null) const searchInputRef = ref<HTMLInputElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null) const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<'bottom' | 'top'>('bottom') 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 = ( const getOptionValue = (
option: SelectOption | Record<string, unknown> option: SelectOption | Record<string, unknown>
@@ -193,14 +221,17 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
const calculateDropdownPosition = () => { const calculateDropdownPosition = () => {
if (!containerRef.value) return if (!containerRef.value) return
// Update trigger rect for positioning
triggerRect.value = containerRef.value.getBoundingClientRect()
nextTick(() => { nextTick(() => {
if (!containerRef.value || !dropdownRef.value) return 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 dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
const viewportHeight = window.innerHeight const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - triggerRect.bottom const spaceBelow = viewportHeight - rect.bottom
const spaceAbove = triggerRect.top const spaceAbove = rect.top
// If not enough space below but enough space above, show dropdown on top // If not enough space below but enough space above, show dropdown on top
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) { if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
@@ -233,10 +264,21 @@ const selectOption = (option: SelectOption | Record<string, unknown>) => {
} }
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) { const target = event.target as HTMLElement
isOpen.value = false
searchQuery.value = '' // 使用 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) => { const handleEscape = (event: KeyboardEvent) => {
@@ -295,54 +337,57 @@ onUnmounted(() => {
.select-icon { .select-icon {
@apply flex-shrink-0 text-gray-400 dark:text-dark-400; @apply flex-shrink-0 text-gray-400 dark:text-dark-400;
} }
</style>
.select-dropdown { <!-- Global styles for teleported dropdown -->
@apply absolute left-0 z-[100] mt-2 min-w-full w-max max-w-[300px]; <style>
.select-dropdown-portal {
@apply w-max max-w-[300px];
@apply bg-white dark:bg-dark-800; @apply bg-white dark:bg-dark-800;
@apply rounded-xl; @apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700; @apply border border-gray-200 dark:border-dark-700;
@apply shadow-lg shadow-black/10 dark:shadow-black/30; @apply shadow-lg shadow-black/10 dark:shadow-black/30;
@apply overflow-hidden; @apply overflow-hidden;
/* 确保下拉菜单在引导期间可点击(覆盖 driver.js 的 pointer-events 影响) */
pointer-events: auto !important;
} }
.select-dropdown-top { .select-dropdown-portal .select-search {
@apply bottom-full mb-2 mt-0;
}
.select-search {
@apply flex items-center gap-2 px-3 py-2; @apply flex items-center gap-2 px-3 py-2;
@apply border-b border-gray-100 dark:border-dark-700; @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 flex-1 bg-transparent text-sm;
@apply text-gray-900 dark:text-gray-100; @apply text-gray-900 dark:text-gray-100;
@apply placeholder:text-gray-400 dark:placeholder:text-dark-400; @apply placeholder:text-gray-400 dark:placeholder:text-dark-400;
@apply focus:outline-none; @apply focus:outline-none;
} }
.select-options { .select-dropdown-portal .select-options {
@apply max-h-60 overflow-y-auto py-1; @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 flex items-center justify-between gap-2;
@apply px-4 py-2.5 text-sm; @apply px-4 py-2.5 text-sm;
@apply text-gray-700 dark:text-gray-300; @apply text-gray-700 dark:text-gray-300;
@apply cursor-pointer transition-colors duration-150; @apply cursor-pointer transition-colors duration-150;
@apply hover:bg-gray-50 dark:hover:bg-dark-700; @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 bg-primary-50 dark:bg-primary-900/20;
@apply text-primary-700 dark:text-primary-300; @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; @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 px-4 py-8 text-center text-sm;
@apply text-gray-500 dark:text-dark-400; @apply text-gray-500 dark:text-dark-400;
} }
@@ -356,17 +401,6 @@ onUnmounted(() => {
.select-dropdown-enter-from, .select-dropdown-enter-from,
.select-dropdown-leave-to { .select-dropdown-leave-to {
opacity: 0; 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); 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> </style>

View File

@@ -25,23 +25,19 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/onboarding.css' import '@/styles/onboarding.css'
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useOnboardingTour } from '@/composables/useOnboardingTour' import { useOnboardingTour } from '@/composables/useOnboardingTour'
import { getAdminSteps, getUserSteps } from '@/components/Guide/steps'
import { useOnboardingStore } from '@/stores/onboarding' import { useOnboardingStore } from '@/stores/onboarding'
import AppSidebar from './AppSidebar.vue' import AppSidebar from './AppSidebar.vue'
import AppHeader from './AppHeader.vue' import AppHeader from './AppHeader.vue'
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const { t } = useI18n()
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed) const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
const isAdmin = computed(() => authStore.user?.role === 'admin') const isAdmin = computed(() => authStore.user?.role === 'admin')
const { replayTour } = useOnboardingTour({ const { replayTour } = useOnboardingTour({
steps: isAdmin.value ? getAdminSteps(t) : getUserSteps(t),
storageKey: isAdmin.value ? 'admin_guide' : 'user_guide', storageKey: isAdmin.value ? 'admin_guide' : 'user_guide',
autoStart: true autoStart: true
}) })

View File

@@ -1,4 +1,4 @@
import { onBeforeUnmount, onMounted, onUnmounted, nextTick } from 'vue' import { onMounted, onUnmounted, nextTick } from 'vue'
import { driver, type Driver, type DriveStep } from 'driver.js' import { driver, type Driver, type DriveStep } from 'driver.js'
import 'driver.js/dist/driver.css' import 'driver.js/dist/driver.css'
import { useAuthStore as useUserStore } from '@/stores/auth' import { useAuthStore as useUserStore } from '@/stores/auth'
@@ -7,10 +7,8 @@ import { useI18n } from 'vue-i18n'
import { getAdminSteps, getUserSteps } from '@/components/Guide/steps' import { getAdminSteps, getUserSteps } from '@/components/Guide/steps'
export interface OnboardingOptions { export interface OnboardingOptions {
steps: DriveStep[]
storageKey?: string storageKey?: string
autoStart?: boolean autoStart?: boolean
onComplete?: () => void
} }
export function useOnboardingTour(options: OnboardingOptions) { export function useOnboardingTour(options: OnboardingOptions) {
@@ -22,11 +20,31 @@ export function useOnboardingTour(options: OnboardingOptions) {
// Timing constants for better maintainability // Timing constants for better maintainability
const TIMING = { const TIMING = {
INTERACTIVE_WAIT_MS: 800, // Default wait time for interactive steps INTERACTIVE_WAIT_MS: 800, // Default wait time for interactive steps
SELECT_WAIT_MS: 2500, // Extended wait for Select components
ELEMENT_TIMEOUT_MS: 8000, // Timeout for element detection ELEMENT_TIMEOUT_MS: 8000, // Timeout for element detection
AUTO_START_DELAY_MS: 1000 // Delay before auto-starting tour AUTO_START_DELAY_MS: 1000 // Delay before auto-starting tour
} as const } as const
// Helper: Check if a step is interactive (only close button shown)
const isInteractiveStep = (step: DriveStep): boolean => {
return step.popover?.showButtons?.length === 1 &&
step.popover.showButtons[0] === 'close'
}
// Helper: Clean up click listener
const cleanupClickListener = () => {
if (!currentClickListener) return
const { element: el, handler, keyHandler, originalTabIndex, eventTypes } = currentClickListener
if (eventTypes) {
eventTypes.forEach(type => el.removeEventListener(type, handler))
}
if (keyHandler) el.removeEventListener('keydown', keyHandler)
if (originalTabIndex !== undefined) {
if (originalTabIndex === null) el.removeAttribute('tabindex')
else el.setAttribute('tabindex', originalTabIndex)
}
currentClickListener = null
}
// 使用 store 管理的全局 driver 实例 // 使用 store 管理的全局 driver 实例
let driverInstance: Driver | null = onboardingStore.getDriverInstance() let driverInstance: Driver | null = onboardingStore.getDriverInstance()
let currentClickListener: { let currentClickListener: {
@@ -115,10 +133,8 @@ export function useOnboardingTour(options: OnboardingOptions) {
// 注意:交互式步骤通常隐藏 Next 按钮,此处逻辑为防御性编程 // 注意:交互式步骤通常隐藏 Next 按钮,此处逻辑为防御性编程
const currentIndex = state.activeIndex ?? 0 const currentIndex = state.activeIndex ?? 0
const currentStep = steps[currentIndex] const currentStep = steps[currentIndex]
const isInteractiveStep = currentStep?.popover?.showButtons?.length === 1 &&
currentStep?.popover.showButtons[0] === 'close'
if (isInteractiveStep && currentStep.element) { if (currentStep && isInteractiveStep(currentStep) && currentStep.element) {
const targetElement = typeof currentStep.element === 'string' const targetElement = typeof currentStep.element === 'string'
? document.querySelector(currentStep.element) as HTMLElement ? document.querySelector(currentStep.element) as HTMLElement
: currentStep.element as HTMLElement : currentStep.element as HTMLElement
@@ -165,10 +181,8 @@ export function useOnboardingTour(options: OnboardingOptions) {
// 1.5 交互式步骤提示 // 1.5 交互式步骤提示
const currentStep = steps[state.activeIndex ?? 0] const currentStep = steps[state.activeIndex ?? 0]
const isInteractive = currentStep?.popover?.showButtons?.length === 1 &&
currentStep?.popover?.showButtons[0] === 'close'
if (isInteractive && popover.description) { if (currentStep && isInteractiveStep(currentStep) && popover.description) {
const hintClass = 'driver-popover-description-hint' const hintClass = 'driver-popover-description-hint'
if (!popover.description.querySelector(`.${hintClass}`)) { if (!popover.description.querySelector(`.${hintClass}`)) {
const hint = document.createElement('div') const hint = document.createElement('div')
@@ -258,19 +272,7 @@ export function useOnboardingTour(options: OnboardingOptions) {
// 步骤高亮时触发 // 步骤高亮时触发
onHighlightStarted: async (element, step) => { onHighlightStarted: async (element, step) => {
// 清理之前的监听器 // 清理之前的监听器
if (currentClickListener) { cleanupClickListener()
const { element: el, handler, keyHandler, originalTabIndex, eventTypes } = currentClickListener
// Remove all tracked event types
if (eventTypes) {
eventTypes.forEach(type => el.removeEventListener(type, handler))
}
if (keyHandler) el.removeEventListener('keydown', keyHandler)
if (originalTabIndex !== undefined) {
if (originalTabIndex === null) el.removeAttribute('tabindex')
else el.setAttribute('tabindex', originalTabIndex)
}
currentClickListener = null
}
// 尝试等待元素 // 尝试等待元素
if (!element && step.element && typeof step.element === 'string') { if (!element && step.element && typeof step.element === 'string') {
@@ -282,10 +284,7 @@ export function useOnboardingTour(options: OnboardingOptions) {
element = document.querySelector(step.element) as HTMLElement element = document.querySelector(step.element) as HTMLElement
} }
const isInteractiveStep = step.popover?.showButtons?.length === 1 && if (isInteractiveStep(step) && element) {
step.popover.showButtons[0] === 'close'
if (isInteractiveStep && element) {
const htmlElement = element as HTMLElement const htmlElement = element as HTMLElement
// Check if this is a submit button - if so, don't bind auto-advance listeners // Check if this is a submit button - if so, don't bind auto-advance listeners
@@ -294,7 +293,6 @@ export function useOnboardingTour(options: OnboardingOptions) {
(htmlElement.tagName === 'BUTTON' && htmlElement.closest('form')) (htmlElement.tagName === 'BUTTON' && htmlElement.closest('form'))
if (isSubmitButton) { if (isSubmitButton) {
console.log('Submit button detected, skipping auto-advance listener')
return // Don't bind any click listeners for submit buttons return // Don't bind any click listeners for submit buttons
} }
@@ -307,36 +305,46 @@ export function useOnboardingTour(options: OnboardingOptions) {
const isSelectComponent = htmlElement.querySelector('.select-trigger') !== null || const isSelectComponent = htmlElement.querySelector('.select-trigger') !== null ||
htmlElement.classList.contains('select-trigger') htmlElement.classList.contains('select-trigger')
// Select dropdowns are teleported to <body>, so click events on options
// won't bubble through this element. Skip auto-advance for Select components.
// Users navigate using Next/Previous buttons after making their selection.
if (isSelectComponent) {
return
}
// Single-execution protection flag // Single-execution protection flag
let hasExecuted = false let hasExecuted = false
// Capture the step index when binding the handler
const boundStepIndex = driverInstance?.getActiveIndex() ?? 0
const clickHandler = async () => { const clickHandler = async () => {
// Prevent duplicate execution // Prevent duplicate execution
if (hasExecuted) { if (hasExecuted) {
console.warn('Click handler already executed, skipping')
return return
} }
hasExecuted = true hasExecuted = true
// For Select components, wait longer to allow user to make a selection // Wait before advancing to allow user to see the result of their action
const waitTime = isSelectComponent ? TIMING.SELECT_WAIT_MS : TIMING.INTERACTIVE_WAIT_MS await new Promise(resolve => setTimeout(resolve, TIMING.INTERACTIVE_WAIT_MS))
await new Promise(resolve => setTimeout(resolve, waitTime))
// Verify driver is still active and not destroyed // Verify driver is still active and not destroyed
if (!driverInstance || !driverInstance.isActive()) { if (!driverInstance || !driverInstance.isActive()) {
console.warn('Driver instance destroyed or inactive during navigation')
return return
} }
// Check if we're still on the same step - abort if step changed during wait
const currentIndex = driverInstance.getActiveIndex() ?? 0 const currentIndex = driverInstance.getActiveIndex() ?? 0
if (currentIndex !== boundStepIndex) {
return
}
const nextStep = steps[currentIndex + 1] const nextStep = steps[currentIndex + 1]
if (nextStep?.element && typeof nextStep.element === 'string') { if (nextStep?.element && typeof nextStep.element === 'string') {
// 增加超时时间到 8 秒,给路由导航更多时间
const exists = await ensureElement(nextStep.element, TIMING.ELEMENT_TIMEOUT_MS) const exists = await ensureElement(nextStep.element, TIMING.ELEMENT_TIMEOUT_MS)
if (!exists) { if (!exists) {
console.warn('Next step element not found after timeout, aborting auto-advance') console.warn(`Onboarding: Next step element not found: ${nextStep.element}`)
console.warn('Expected element:', nextStep.element)
return return
} }
} }
@@ -367,43 +375,6 @@ export function useOnboardingTour(options: OnboardingOptions) {
originalTabIndex, originalTabIndex,
eventTypes: ['input', 'change'] eventTypes: ['input', 'change']
} }
} else if (isSelectComponent) {
// For Select components, listen for option selection clicks
const selectOptionClickHandler = (e: Event) => {
const target = e.target as HTMLElement
// Type safety: ensure target is an Element before using closest
if (!(target instanceof Element)) {
return
}
// Check if the clicked element is a select option
if (target.closest('.select-option')) {
// User selected an option, proceed to next step
clickHandler()
}
}
const keyHandler = (e: KeyboardEvent) => {
if (['Enter', ' '].includes(e.key)) {
e.preventDefault()
// For select components, Enter/Space should open dropdown, not advance
// Only advance if an option is focused
const focusedOption = htmlElement.querySelector('.select-option:focus')
if (focusedOption) {
clickHandler()
}
}
}
htmlElement.addEventListener('click', selectOptionClickHandler)
htmlElement.addEventListener('keydown', keyHandler)
currentClickListener = {
element: htmlElement,
handler: selectOptionClickHandler as () => void,
keyHandler,
originalTabIndex,
eventTypes: ['click']
}
} else { } else {
const keyHandler = (e: KeyboardEvent) => { const keyHandler = (e: KeyboardEvent) => {
if (['Enter', ' '].includes(e.key)) { if (['Enter', ' '].includes(e.key)) {
@@ -427,19 +398,7 @@ export function useOnboardingTour(options: OnboardingOptions) {
}, },
onDestroyed: () => { onDestroyed: () => {
if (currentClickListener) { cleanupClickListener()
const { element: el, handler, keyHandler, originalTabIndex, eventTypes } = currentClickListener
// Remove all tracked event types
if (eventTypes) {
eventTypes.forEach(type => el.removeEventListener(type, handler))
}
if (keyHandler) el.removeEventListener('keydown', keyHandler)
if (originalTabIndex !== undefined) {
if (originalTabIndex === null) el.removeAttribute('tabindex')
else el.setAttribute('tabindex', originalTabIndex)
}
currentClickListener = null
}
// 清理全局监听器 (由此处唯一管理) // 清理全局监听器 (由此处唯一管理)
if (globalKeyboardHandler) { if (globalKeyboardHandler) {
document.removeEventListener('keydown', globalKeyboardHandler, { capture: true }) document.removeEventListener('keydown', globalKeyboardHandler, { capture: true })
@@ -477,10 +436,8 @@ export function useOnboardingTour(options: OnboardingOptions) {
// 对于交互式步骤,箭头键应该触发交互而非跳过 // 对于交互式步骤,箭头键应该触发交互而非跳过
const currentIndex = driverInstance!.getActiveIndex() ?? 0 const currentIndex = driverInstance!.getActiveIndex() ?? 0
const currentStep = steps[currentIndex] const currentStep = steps[currentIndex]
const isInteractiveStep = currentStep?.popover?.showButtons?.length === 1 &&
currentStep?.popover.showButtons[0] === 'close'
if (isInteractiveStep && currentStep.element) { if (currentStep && isInteractiveStep(currentStep) && currentStep.element) {
const targetElement = typeof currentStep.element === 'string' const targetElement = typeof currentStep.element === 'string'
? document.querySelector(currentStep.element) as HTMLElement ? document.querySelector(currentStep.element) as HTMLElement
: currentStep.element as HTMLElement : currentStep.element as HTMLElement
@@ -511,10 +468,8 @@ export function useOnboardingTour(options: OnboardingOptions) {
// 回车键处理交互式步骤 // 回车键处理交互式步骤
const currentIndex = driverInstance!.getActiveIndex() ?? 0 const currentIndex = driverInstance!.getActiveIndex() ?? 0
const currentStep = steps[currentIndex] const currentStep = steps[currentIndex]
const isInteractiveStep = currentStep?.popover?.showButtons?.length === 1 &&
currentStep?.popover.showButtons[0] === 'close'
if (isInteractiveStep && currentStep.element) { if (currentStep && isInteractiveStep(currentStep) && currentStep.element) {
const targetElement = typeof currentStep.element === 'string' const targetElement = typeof currentStep.element === 'string'
? document.querySelector(currentStep.element) as HTMLElement ? document.querySelector(currentStep.element) as HTMLElement
: currentStep.element as HTMLElement : currentStep.element as HTMLElement
@@ -572,21 +527,18 @@ export function useOnboardingTour(options: OnboardingOptions) {
}) })
if (onboardingStore.isDriverActive()) { if (onboardingStore.isDriverActive()) {
console.log('Tour already active, skipping auto-start')
driverInstance = onboardingStore.getDriverInstance() driverInstance = onboardingStore.getDriverInstance()
return return
} }
// 简易模式下禁用新手引导 // 简易模式下禁用新手引导
if (userStore.isSimpleMode) { if (userStore.isSimpleMode) {
console.log('Simple mode detected, skipping onboarding tour')
return return
} }
// 只在管理员+标准模式下自动启动 // 只在管理员+标准模式下自动启动
const isAdmin = userStore.user?.role === 'admin' const isAdmin = userStore.user?.role === 'admin'
if (!isAdmin) { if (!isAdmin) {
console.log('Non-admin user, skipping auto-start')
return return
} }
@@ -596,10 +548,6 @@ export function useOnboardingTour(options: OnboardingOptions) {
}, TIMING.AUTO_START_DELAY_MS) }, TIMING.AUTO_START_DELAY_MS)
}) })
onBeforeUnmount(() => {
// 保持 driver 实例活跃,支持路由切换
})
onUnmounted(() => { onUnmounted(() => {
if (autoStartTimer) { if (autoStartTimer) {
clearTimeout(autoStartTimer) clearTimeout(autoStartTimer)

View File

@@ -6,6 +6,17 @@
inset: 0 !important; inset: 0 !important;
z-index: 99999998 !important; z-index: 99999998 !important;
background-color: transparent !important; background-color: transparent !important;
/*
* 关键修复:让 overlay 不拦截点击事件
* 因为已设置 allowClose: false用户不能通过点击遮罩关闭引导
* 这样 Select 下拉菜单等脱离高亮区域的元素才能正常交互
* 视觉遮罩效果保持不变SVG 仍然渲染pointer-events 只影响交互不影响渲染)
*/
pointer-events: none !important;
}
.driver-overlay svg {
pointer-events: none !important;
} }
.driver-active-element { .driver-active-element {
@@ -55,36 +66,6 @@
} }
.dark .driver-popover-title-text { color: #ffffff !important; } .dark .driver-popover-title-text { color: #ffffff !important; }
/* Skip Button */
.header-skip-btn {
position: absolute !important;
top: 18px !important;
right: 60px !important;
font-size: 12px !important;
color: #9ca3af !important;
background: transparent !important;
border: none !important;
padding: 4px 8px !important;
cursor: pointer !important;
border-radius: 4px !important;
transition: all 0.2s !important;
white-space: nowrap !important;
display: flex !important;
align-items: center !important;
height: 28px !important;
max-width: 120px !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.header-skip-btn:hover {
background-color: rgba(239, 68, 68, 0.1) !important;
color: #ef4444 !important;
}
.dark .header-skip-btn:hover {
background-color: rgba(248, 113, 113, 0.1) !important;
color: #f87171 !important;
}
/* Close Button */ /* Close Button */
.theme-tour-popover .driver-popover-close-btn { .theme-tour-popover .driver-popover-close-btn {
position: absolute !important; position: absolute !important;
@@ -245,8 +226,3 @@
.dark .driver-popover-arrow-side-right.driver-popover-arrow { border-right-color: #1e293b !important; } .dark .driver-popover-arrow-side-right.driver-popover-arrow { border-right-color: #1e293b !important; }
.dark .driver-popover-arrow-side-top.driver-popover-arrow { border-top-color: #1e293b !important; } .dark .driver-popover-arrow-side-top.driver-popover-arrow { border-top-color: #1e293b !important; }
.dark .driver-popover-arrow-side-bottom.driver-popover-arrow { border-bottom-color: #1e293b !important; } .dark .driver-popover-arrow-side-bottom.driver-popover-arrow { border-bottom-color: #1e293b !important; }
/* 确保被高亮元素的下拉菜单也有足够高的 z-index */
.driver-active-element .select-dropdown {
z-index: 100000001 !important;
}