2025-12-29 19:38:33 +08:00
|
|
|
|
import { onMounted, onUnmounted, nextTick } from 'vue'
|
2025-12-29 15:21:05 +08:00
|
|
|
|
import { driver, type Driver, type DriveStep } from 'driver.js'
|
|
|
|
|
|
import 'driver.js/dist/driver.css'
|
|
|
|
|
|
import { useAuthStore as useUserStore } from '@/stores/auth'
|
|
|
|
|
|
import { useOnboardingStore } from '@/stores/onboarding'
|
|
|
|
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
|
|
import { getAdminSteps, getUserSteps } from '@/components/Guide/steps'
|
|
|
|
|
|
|
|
|
|
|
|
export interface OnboardingOptions {
|
|
|
|
|
|
storageKey?: string
|
|
|
|
|
|
autoStart?: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useOnboardingTour(options: OnboardingOptions) {
|
|
|
|
|
|
const { t } = useI18n()
|
|
|
|
|
|
const userStore = useUserStore()
|
|
|
|
|
|
const onboardingStore = useOnboardingStore()
|
|
|
|
|
|
const storageVersion = 'v4_interactive' // Bump version for new tour type
|
|
|
|
|
|
|
|
|
|
|
|
// Timing constants for better maintainability
|
|
|
|
|
|
const TIMING = {
|
|
|
|
|
|
INTERACTIVE_WAIT_MS: 800, // Default wait time for interactive steps
|
|
|
|
|
|
ELEMENT_TIMEOUT_MS: 8000, // Timeout for element detection
|
|
|
|
|
|
AUTO_START_DELAY_MS: 1000 // Delay before auto-starting tour
|
|
|
|
|
|
} as const
|
|
|
|
|
|
|
2025-12-29 19:38:33 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 15:21:05 +08:00
|
|
|
|
// 使用 store 管理的全局 driver 实例
|
|
|
|
|
|
let driverInstance: Driver | null = onboardingStore.getDriverInstance()
|
|
|
|
|
|
let currentClickListener: {
|
|
|
|
|
|
element: HTMLElement
|
|
|
|
|
|
handler: () => void
|
|
|
|
|
|
keyHandler?: (e: KeyboardEvent) => void
|
|
|
|
|
|
originalTabIndex?: string | null
|
|
|
|
|
|
eventTypes?: string[] // Track which event types were added
|
|
|
|
|
|
} | null = null
|
|
|
|
|
|
let autoStartTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
|
let globalKeyboardHandler: ((e: KeyboardEvent) => void) | null = null
|
|
|
|
|
|
|
|
|
|
|
|
const getStorageKey = () => {
|
|
|
|
|
|
const baseKey = options.storageKey ?? 'onboarding_tour'
|
|
|
|
|
|
const userId = userStore.user?.id ?? 'guest'
|
|
|
|
|
|
const role = userStore.user?.role ?? 'user'
|
|
|
|
|
|
return `${baseKey}_${userId}_${role}_${storageVersion}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const hasSeen = () => {
|
|
|
|
|
|
return localStorage.getItem(getStorageKey()) === 'true'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const markAsSeen = () => {
|
|
|
|
|
|
localStorage.setItem(getStorageKey(), 'true')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const clearSeen = () => {
|
|
|
|
|
|
localStorage.removeItem(getStorageKey())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 检查元素是否存在,如果不存在则重试
|
|
|
|
|
|
*/
|
|
|
|
|
|
const ensureElement = async (selector: string, timeout = 5000): Promise<boolean> => {
|
|
|
|
|
|
const startTime = Date.now()
|
|
|
|
|
|
while (Date.now() - startTime < timeout) {
|
|
|
|
|
|
const element = document.querySelector(selector)
|
|
|
|
|
|
if (element && element.getBoundingClientRect().height > 0) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const startTour = async (startIndex = 0) => {
|
|
|
|
|
|
// 动态获取当前用户角色和步骤
|
|
|
|
|
|
const isAdmin = userStore.user?.role === 'admin'
|
2025-12-29 15:36:04 +08:00
|
|
|
|
const isSimpleMode = userStore.isSimpleMode
|
|
|
|
|
|
const steps = isAdmin ? getAdminSteps(t, isSimpleMode) : getUserSteps(t)
|
2025-12-29 15:21:05 +08:00
|
|
|
|
|
|
|
|
|
|
// 确保 DOM 就绪
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
|
|
|
|
|
|
// 如果指定了起始步骤,确保元素可见
|
|
|
|
|
|
const currentStep = steps[startIndex]
|
|
|
|
|
|
if (currentStep?.element && typeof currentStep.element === 'string') {
|
|
|
|
|
|
await ensureElement(currentStep.element, TIMING.ELEMENT_TIMEOUT_MS)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (driverInstance) {
|
|
|
|
|
|
driverInstance.destroy()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建新的 driver 实例并存储到 store
|
|
|
|
|
|
driverInstance = driver({
|
|
|
|
|
|
showProgress: true,
|
|
|
|
|
|
steps,
|
|
|
|
|
|
animate: true,
|
|
|
|
|
|
allowClose: false, // 禁止点击遮罩关闭
|
|
|
|
|
|
stagePadding: 4,
|
|
|
|
|
|
popoverClass: 'theme-tour-popover',
|
|
|
|
|
|
nextBtnText: t('common.next'),
|
|
|
|
|
|
prevBtnText: t('common.back'),
|
|
|
|
|
|
doneBtnText: t('common.confirm'),
|
|
|
|
|
|
|
|
|
|
|
|
// 导航处理
|
|
|
|
|
|
onNextClick: async (_el, _step, { config, state }) => {
|
|
|
|
|
|
// 如果是最后一步,点击则是"完成"
|
|
|
|
|
|
if (state.activeIndex === (config.steps?.length ?? 0) - 1) {
|
|
|
|
|
|
markAsSeen()
|
|
|
|
|
|
driverInstance?.destroy()
|
|
|
|
|
|
onboardingStore.setDriverInstance(null)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 注意:交互式步骤通常隐藏 Next 按钮,此处逻辑为防御性编程
|
|
|
|
|
|
const currentIndex = state.activeIndex ?? 0
|
|
|
|
|
|
const currentStep = steps[currentIndex]
|
|
|
|
|
|
|
2025-12-29 19:38:33 +08:00
|
|
|
|
if (currentStep && isInteractiveStep(currentStep) && currentStep.element) {
|
2025-12-29 15:21:05 +08:00
|
|
|
|
const targetElement = typeof currentStep.element === 'string'
|
|
|
|
|
|
? document.querySelector(currentStep.element) as HTMLElement
|
|
|
|
|
|
: currentStep.element as HTMLElement
|
|
|
|
|
|
|
|
|
|
|
|
if (targetElement) {
|
|
|
|
|
|
const isClickable = !['INPUT', 'TEXTAREA', 'SELECT'].includes(targetElement.tagName)
|
|
|
|
|
|
if (isClickable) {
|
|
|
|
|
|
targetElement.click()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
driverInstance?.moveNext()
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
onPrevClick: () => {
|
|
|
|
|
|
driverInstance?.movePrevious()
|
|
|
|
|
|
},
|
|
|
|
|
|
onCloseClick: () => {
|
2025-12-29 15:50:07 +08:00
|
|
|
|
markAsSeen()
|
|
|
|
|
|
driverInstance?.destroy()
|
|
|
|
|
|
onboardingStore.setDriverInstance(null)
|
2025-12-29 15:21:05 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染时重组 Footer 布局
|
|
|
|
|
|
onPopoverRender: (popover, { config, state }) => {
|
|
|
|
|
|
// Class name constants for easier maintenance
|
|
|
|
|
|
const CLASS_REORGANIZED = 'reorganized'
|
|
|
|
|
|
const CLASS_FOOTER_LEFT = 'footer-left'
|
|
|
|
|
|
const CLASS_FOOTER_RIGHT = 'footer-right'
|
|
|
|
|
|
const CLASS_DONE_BTN = 'driver-popover-done-btn'
|
|
|
|
|
|
const CLASS_PROGRESS_TEXT = 'driver-popover-progress-text'
|
|
|
|
|
|
const CLASS_NEXT_BTN = 'driver-popover-next-btn'
|
|
|
|
|
|
const CLASS_PREV_BTN = 'driver-popover-prev-btn'
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { title: titleEl, footer: footerEl, nextButton, previousButton } = popover
|
|
|
|
|
|
|
|
|
|
|
|
// Defensive check: ensure popover elements exist
|
|
|
|
|
|
if (!titleEl || !footerEl) {
|
|
|
|
|
|
console.warn('Onboarding: Missing popover elements')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1.5 交互式步骤提示
|
|
|
|
|
|
const currentStep = steps[state.activeIndex ?? 0]
|
|
|
|
|
|
|
2025-12-29 19:38:33 +08:00
|
|
|
|
if (currentStep && isInteractiveStep(currentStep) && popover.description) {
|
2025-12-29 15:21:05 +08:00
|
|
|
|
const hintClass = 'driver-popover-description-hint'
|
|
|
|
|
|
if (!popover.description.querySelector(`.${hintClass}`)) {
|
|
|
|
|
|
const hint = document.createElement('div')
|
|
|
|
|
|
hint.className = `${hintClass} mt-2 text-xs text-gray-500 flex items-center gap-1`
|
|
|
|
|
|
|
|
|
|
|
|
const iconSpan = document.createElement('span')
|
|
|
|
|
|
iconSpan.className = 'i-mdi-keyboard-return mr-1'
|
|
|
|
|
|
|
|
|
|
|
|
const textNode = document.createTextNode(
|
|
|
|
|
|
t('onboarding.interactiveHint', 'Press Enter or Click to continue'),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
hint.appendChild(iconSpan)
|
|
|
|
|
|
hint.appendChild(textNode)
|
|
|
|
|
|
popover.description.appendChild(hint)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 底部:DOM 重组
|
|
|
|
|
|
if (!footerEl.classList.contains(CLASS_REORGANIZED)) {
|
|
|
|
|
|
footerEl.classList.add(CLASS_REORGANIZED)
|
|
|
|
|
|
|
|
|
|
|
|
const progressEl = footerEl.querySelector(`.${CLASS_PROGRESS_TEXT}`)
|
|
|
|
|
|
const nextBtnEl = nextButton || footerEl.querySelector(`.${CLASS_NEXT_BTN}`)
|
|
|
|
|
|
const prevBtnEl = previousButton || footerEl.querySelector(`.${CLASS_PREV_BTN}`)
|
|
|
|
|
|
|
|
|
|
|
|
const leftContainer = document.createElement('div')
|
|
|
|
|
|
leftContainer.className = CLASS_FOOTER_LEFT
|
|
|
|
|
|
|
|
|
|
|
|
const rightContainer = document.createElement('div')
|
|
|
|
|
|
rightContainer.className = CLASS_FOOTER_RIGHT
|
|
|
|
|
|
|
|
|
|
|
|
if (progressEl) leftContainer.appendChild(progressEl)
|
|
|
|
|
|
|
|
|
|
|
|
const shortcutsEl = document.createElement('div')
|
|
|
|
|
|
shortcutsEl.className = 'footer-shortcuts'
|
|
|
|
|
|
|
|
|
|
|
|
const shortcut1 = document.createElement('span')
|
|
|
|
|
|
shortcut1.className = 'shortcut-item'
|
|
|
|
|
|
const kbd1 = document.createElement('kbd')
|
|
|
|
|
|
kbd1.textContent = '←'
|
|
|
|
|
|
const kbd2 = document.createElement('kbd')
|
|
|
|
|
|
kbd2.textContent = '→'
|
|
|
|
|
|
shortcut1.appendChild(kbd1)
|
|
|
|
|
|
shortcut1.appendChild(kbd2)
|
|
|
|
|
|
shortcut1.appendChild(
|
|
|
|
|
|
document.createTextNode(` ${t('onboarding.navigation.flipPage')}`),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const shortcut2 = document.createElement('span')
|
|
|
|
|
|
shortcut2.className = 'shortcut-item'
|
|
|
|
|
|
const kbd3 = document.createElement('kbd')
|
|
|
|
|
|
kbd3.textContent = 'ESC'
|
|
|
|
|
|
shortcut2.appendChild(kbd3)
|
|
|
|
|
|
shortcut2.appendChild(
|
|
|
|
|
|
document.createTextNode(` ${t('onboarding.navigation.exit')}`),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
shortcutsEl.appendChild(shortcut1)
|
|
|
|
|
|
shortcutsEl.appendChild(shortcut2)
|
|
|
|
|
|
leftContainer.appendChild(shortcutsEl)
|
|
|
|
|
|
|
|
|
|
|
|
if (prevBtnEl) rightContainer.appendChild(prevBtnEl)
|
|
|
|
|
|
if (nextBtnEl) rightContainer.appendChild(nextBtnEl)
|
|
|
|
|
|
|
|
|
|
|
|
footerEl.innerHTML = ''
|
|
|
|
|
|
footerEl.appendChild(leftContainer)
|
|
|
|
|
|
footerEl.appendChild(rightContainer)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 状态更新
|
|
|
|
|
|
const isLastStep = state.activeIndex === (config.steps?.length ?? 0) - 1
|
|
|
|
|
|
const activeNextBtn = nextButton || footerEl.querySelector(`.${CLASS_NEXT_BTN}`)
|
|
|
|
|
|
|
|
|
|
|
|
if (activeNextBtn) {
|
|
|
|
|
|
if (isLastStep) {
|
|
|
|
|
|
activeNextBtn.classList.add(CLASS_DONE_BTN)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
activeNextBtn.classList.remove(CLASS_DONE_BTN)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('Onboarding Tour Render Error:', e)
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 步骤高亮时触发
|
|
|
|
|
|
onHighlightStarted: async (element, step) => {
|
|
|
|
|
|
// 清理之前的监听器
|
2025-12-29 19:38:33 +08:00
|
|
|
|
cleanupClickListener()
|
2025-12-29 15:21:05 +08:00
|
|
|
|
|
|
|
|
|
|
// 尝试等待元素
|
|
|
|
|
|
if (!element && step.element && typeof step.element === 'string') {
|
|
|
|
|
|
const exists = await ensureElement(step.element, 8000)
|
|
|
|
|
|
if (!exists) {
|
|
|
|
|
|
console.warn(`Tour element not found after 8s: ${step.element}`)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
element = document.querySelector(step.element) as HTMLElement
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 19:38:33 +08:00
|
|
|
|
if (isInteractiveStep(step) && element) {
|
2025-12-29 15:21:05 +08:00
|
|
|
|
const htmlElement = element as HTMLElement
|
|
|
|
|
|
|
|
|
|
|
|
// Check if this is a submit button - if so, don't bind auto-advance listeners
|
|
|
|
|
|
// Let business code (e.g., handleCreateGroup) manually call nextStep after success
|
|
|
|
|
|
const isSubmitButton = htmlElement.getAttribute('type') === 'submit' ||
|
|
|
|
|
|
(htmlElement.tagName === 'BUTTON' && htmlElement.closest('form'))
|
|
|
|
|
|
|
|
|
|
|
|
if (isSubmitButton) {
|
|
|
|
|
|
return // Don't bind any click listeners for submit buttons
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const originalTabIndex = htmlElement.getAttribute('tabindex')
|
|
|
|
|
|
if (!htmlElement.isContentEditable && htmlElement.tabIndex === -1) {
|
|
|
|
|
|
htmlElement.setAttribute('tabindex', '0')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Enhanced Select component detection - check both children and self
|
|
|
|
|
|
const isSelectComponent = htmlElement.querySelector('.select-trigger') !== null ||
|
|
|
|
|
|
htmlElement.classList.contains('select-trigger')
|
|
|
|
|
|
|
2025-12-29 19:38:33 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 15:21:05 +08:00
|
|
|
|
// Single-execution protection flag
|
|
|
|
|
|
let hasExecuted = false
|
|
|
|
|
|
|
2025-12-29 19:38:33 +08:00
|
|
|
|
// Capture the step index when binding the handler
|
|
|
|
|
|
const boundStepIndex = driverInstance?.getActiveIndex() ?? 0
|
|
|
|
|
|
|
2025-12-29 15:21:05 +08:00
|
|
|
|
const clickHandler = async () => {
|
|
|
|
|
|
// Prevent duplicate execution
|
|
|
|
|
|
if (hasExecuted) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
hasExecuted = true
|
|
|
|
|
|
|
2025-12-29 19:38:33 +08:00
|
|
|
|
// Wait before advancing to allow user to see the result of their action
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, TIMING.INTERACTIVE_WAIT_MS))
|
2025-12-29 15:21:05 +08:00
|
|
|
|
|
|
|
|
|
|
// Verify driver is still active and not destroyed
|
|
|
|
|
|
if (!driverInstance || !driverInstance.isActive()) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 19:38:33 +08:00
|
|
|
|
// Check if we're still on the same step - abort if step changed during wait
|
2025-12-29 15:21:05 +08:00
|
|
|
|
const currentIndex = driverInstance.getActiveIndex() ?? 0
|
2025-12-29 19:38:33 +08:00
|
|
|
|
if (currentIndex !== boundStepIndex) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 15:21:05 +08:00
|
|
|
|
const nextStep = steps[currentIndex + 1]
|
|
|
|
|
|
|
|
|
|
|
|
if (nextStep?.element && typeof nextStep.element === 'string') {
|
|
|
|
|
|
const exists = await ensureElement(nextStep.element, TIMING.ELEMENT_TIMEOUT_MS)
|
|
|
|
|
|
if (!exists) {
|
2025-12-29 19:38:33 +08:00
|
|
|
|
console.warn(`Onboarding: Next step element not found: ${nextStep.element}`)
|
2025-12-29 15:21:05 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Final check before moving
|
|
|
|
|
|
if (driverInstance && driverInstance.isActive()) {
|
|
|
|
|
|
driverInstance.moveNext()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For input fields, advance on input/change events instead of click
|
|
|
|
|
|
const isInputField = ['INPUT', 'TEXTAREA', 'SELECT'].includes(htmlElement.tagName)
|
|
|
|
|
|
|
|
|
|
|
|
if (isInputField) {
|
|
|
|
|
|
const inputHandler = () => {
|
|
|
|
|
|
// Remove listener after first input
|
|
|
|
|
|
htmlElement.removeEventListener('input', inputHandler)
|
|
|
|
|
|
htmlElement.removeEventListener('change', inputHandler)
|
|
|
|
|
|
clickHandler()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
htmlElement.addEventListener('input', inputHandler)
|
|
|
|
|
|
htmlElement.addEventListener('change', inputHandler)
|
|
|
|
|
|
|
|
|
|
|
|
currentClickListener = {
|
|
|
|
|
|
element: htmlElement,
|
|
|
|
|
|
handler: inputHandler,
|
|
|
|
|
|
originalTabIndex,
|
|
|
|
|
|
eventTypes: ['input', 'change']
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const keyHandler = (e: KeyboardEvent) => {
|
|
|
|
|
|
if (['Enter', ' '].includes(e.key)) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
clickHandler()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
htmlElement.addEventListener('click', clickHandler, { once: true })
|
|
|
|
|
|
htmlElement.addEventListener('keydown', keyHandler)
|
|
|
|
|
|
|
|
|
|
|
|
currentClickListener = {
|
|
|
|
|
|
element: htmlElement,
|
|
|
|
|
|
handler: clickHandler as () => void,
|
|
|
|
|
|
keyHandler,
|
|
|
|
|
|
originalTabIndex,
|
|
|
|
|
|
eventTypes: ['click']
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
onDestroyed: () => {
|
2025-12-29 19:38:33 +08:00
|
|
|
|
cleanupClickListener()
|
2025-12-29 15:21:05 +08:00
|
|
|
|
// 清理全局监听器 (由此处唯一管理)
|
|
|
|
|
|
if (globalKeyboardHandler) {
|
|
|
|
|
|
document.removeEventListener('keydown', globalKeyboardHandler, { capture: true })
|
|
|
|
|
|
globalKeyboardHandler = null
|
|
|
|
|
|
}
|
|
|
|
|
|
onboardingStore.setDriverInstance(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onboardingStore.setDriverInstance(driverInstance)
|
|
|
|
|
|
|
|
|
|
|
|
// 添加全局键盘监听器
|
|
|
|
|
|
globalKeyboardHandler = (e: KeyboardEvent) => {
|
|
|
|
|
|
if (!driverInstance?.isActive()) return
|
|
|
|
|
|
|
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
e.stopPropagation()
|
2025-12-29 15:50:07 +08:00
|
|
|
|
markAsSeen()
|
|
|
|
|
|
driverInstance.destroy()
|
|
|
|
|
|
onboardingStore.setDriverInstance(null)
|
2025-12-29 15:21:05 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (e.key === 'ArrowRight') {
|
|
|
|
|
|
const target = e.target as HTMLElement
|
|
|
|
|
|
// 允许在输入框中使用方向键
|
|
|
|
|
|
if (['INPUT', 'TEXTAREA'].includes(target?.tagName)) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
|
|
|
|
|
|
// 对于交互式步骤,箭头键应该触发交互而非跳过
|
|
|
|
|
|
const currentIndex = driverInstance!.getActiveIndex() ?? 0
|
|
|
|
|
|
const currentStep = steps[currentIndex]
|
|
|
|
|
|
|
2025-12-29 19:38:33 +08:00
|
|
|
|
if (currentStep && isInteractiveStep(currentStep) && currentStep.element) {
|
2025-12-29 15:21:05 +08:00
|
|
|
|
const targetElement = typeof currentStep.element === 'string'
|
|
|
|
|
|
? document.querySelector(currentStep.element) as HTMLElement
|
|
|
|
|
|
: currentStep.element as HTMLElement
|
|
|
|
|
|
|
|
|
|
|
|
if (targetElement) {
|
|
|
|
|
|
// 对于非输入类元素,提示用户需要点击或按Enter
|
|
|
|
|
|
const isClickable = !['INPUT', 'TEXTAREA', 'SELECT'].includes(targetElement.tagName)
|
|
|
|
|
|
if (isClickable) {
|
|
|
|
|
|
// 不自动触发,只是停留提示
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 非交互式步骤才允许箭头键翻页
|
|
|
|
|
|
driverInstance!.moveNext()
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (e.key === 'Enter') {
|
|
|
|
|
|
const target = e.target as HTMLElement
|
|
|
|
|
|
// 允许在输入框中使用回车
|
|
|
|
|
|
if (['INPUT', 'TEXTAREA'].includes(target?.tagName)) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
|
|
|
|
|
|
// 回车键处理交互式步骤
|
|
|
|
|
|
const currentIndex = driverInstance!.getActiveIndex() ?? 0
|
|
|
|
|
|
const currentStep = steps[currentIndex]
|
|
|
|
|
|
|
2025-12-29 19:38:33 +08:00
|
|
|
|
if (currentStep && isInteractiveStep(currentStep) && currentStep.element) {
|
2025-12-29 15:21:05 +08:00
|
|
|
|
const targetElement = typeof currentStep.element === 'string'
|
|
|
|
|
|
? document.querySelector(currentStep.element) as HTMLElement
|
|
|
|
|
|
: currentStep.element as HTMLElement
|
|
|
|
|
|
|
|
|
|
|
|
if (targetElement) {
|
|
|
|
|
|
const isClickable = !['INPUT', 'TEXTAREA', 'SELECT'].includes(targetElement.tagName)
|
|
|
|
|
|
if (isClickable) {
|
|
|
|
|
|
targetElement.click()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
driverInstance!.moveNext()
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (e.key === 'ArrowLeft') {
|
|
|
|
|
|
const target = e.target as HTMLElement
|
|
|
|
|
|
// 允许在输入框中使用方向键
|
|
|
|
|
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
driverInstance.movePrevious()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('keydown', globalKeyboardHandler, { capture: true })
|
|
|
|
|
|
driverInstance.drive(startIndex)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const nextStep = async (delay = 300) => {
|
|
|
|
|
|
if (!driverInstance?.isActive()) return
|
|
|
|
|
|
if (delay > 0) {
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, delay))
|
|
|
|
|
|
}
|
|
|
|
|
|
driverInstance.moveNext()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isCurrentStep = (elementSelector: string): boolean => {
|
|
|
|
|
|
if (!driverInstance?.isActive()) return false
|
|
|
|
|
|
const activeElement = driverInstance.getActiveElement()
|
|
|
|
|
|
return activeElement?.matches(elementSelector) ?? false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const replayTour = () => {
|
|
|
|
|
|
clearSeen()
|
|
|
|
|
|
void startTour()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
onboardingStore.setControlMethods({
|
|
|
|
|
|
nextStep,
|
|
|
|
|
|
isCurrentStep
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (onboardingStore.isDriverActive()) {
|
|
|
|
|
|
driverInstance = onboardingStore.getDriverInstance()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 15:36:04 +08:00
|
|
|
|
// 简易模式下禁用新手引导
|
|
|
|
|
|
if (userStore.isSimpleMode) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 只在管理员+标准模式下自动启动
|
|
|
|
|
|
const isAdmin = userStore.user?.role === 'admin'
|
|
|
|
|
|
if (!isAdmin) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 15:21:05 +08:00
|
|
|
|
if (!options.autoStart || hasSeen()) return
|
|
|
|
|
|
autoStartTimer = setTimeout(() => {
|
|
|
|
|
|
void startTour()
|
|
|
|
|
|
}, TIMING.AUTO_START_DELAY_MS)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
if (autoStartTimer) {
|
|
|
|
|
|
clearTimeout(autoStartTimer)
|
|
|
|
|
|
autoStartTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
// 关键修复:不再此处清理 globalKeyboardHandler,交由 driver.onDestroyed 管理
|
|
|
|
|
|
onboardingStore.clearControlMethods()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
startTour,
|
|
|
|
|
|
replayTour,
|
|
|
|
|
|
nextStep,
|
|
|
|
|
|
isCurrentStep,
|
|
|
|
|
|
hasSeen,
|
|
|
|
|
|
markAsSeen,
|
|
|
|
|
|
clearSeen
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|