feat(frontend): 实现新手引导功能

- 添加 Guide 组件和引导步骤配置
- 实现 useOnboardingTour 和 useTourStepDescription composables
- 添加 onboarding store 管理引导状态
- 更新多个视图和组件以支持引导功能
- 添加国际化支持(中英文)
- 删除旧的实现指南文档
This commit is contained in:
IanShaw027
2025-12-29 15:21:05 +08:00
parent c01db6b180
commit dd247e55e9
30 changed files with 3968 additions and 36 deletions

View File

@@ -0,0 +1,639 @@
import { onBeforeUnmount, onMounted, onUnmounted, nextTick } from 'vue'
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 {
steps: DriveStep[]
storageKey?: string
autoStart?: boolean
onComplete?: () => void
}
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
SELECT_WAIT_MS: 2500, // Extended wait for Select components
ELEMENT_TIMEOUT_MS: 8000, // Timeout for element detection
AUTO_START_DELAY_MS: 1000 // Delay before auto-starting tour
} as const
// 使用 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'
const steps = isAdmin ? getAdminSteps(t) : getUserSteps(t)
// 确保 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]
const isInteractiveStep = currentStep?.popover?.showButtons?.length === 1 &&
currentStep?.popover.showButtons[0] === 'close'
if (isInteractiveStep && currentStep.element) {
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: () => {
if (confirm(t('onboarding.confirmExit'))) {
driverInstance?.destroy()
onboardingStore.setDriverInstance(null)
}
},
// 渲染时重组 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_SKIP_BTN = 'header-skip-btn'
const CLASS_DONE_BTN = 'driver-popover-done-btn'
const CLASS_TITLE_TEXT = 'driver-popover-title-text'
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. 顶部:添加 "不再提示" 按钮
if (!titleEl.querySelector(`.${CLASS_SKIP_BTN}`)) {
const titleText = titleEl.innerText
if (!titleEl.querySelector(`.${CLASS_TITLE_TEXT}`)) {
const titleSpan = document.createElement('span')
titleSpan.className = CLASS_TITLE_TEXT
titleSpan.textContent = titleText
titleEl.textContent = ''
titleEl.appendChild(titleSpan)
}
const skipBtn = document.createElement('button')
skipBtn.className = CLASS_SKIP_BTN
skipBtn.innerText = t('onboarding.dontShowAgain')
skipBtn.title = t('onboarding.dontShowAgainTitle')
skipBtn.type = 'button'
skipBtn.setAttribute('aria-label', t('onboarding.dontShowAgain'))
skipBtn.onclick = (e) => {
e.stopPropagation()
if (confirm(t('onboarding.confirmDontShow'))) {
markAsSeen()
driverInstance?.destroy()
onboardingStore.setDriverInstance(null)
}
}
titleEl.appendChild(skipBtn)
}
// 1.5 交互式步骤提示
const currentStep = steps[state.activeIndex ?? 0]
const isInteractive = currentStep?.popover?.showButtons?.length === 1 &&
currentStep?.popover?.showButtons[0] === 'close'
if (isInteractive && popover.description) {
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) => {
// 清理之前的监听器
if (currentClickListener) {
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') {
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
}
const isInteractiveStep = step.popover?.showButtons?.length === 1 &&
step.popover.showButtons[0] === 'close'
if (isInteractiveStep && element) {
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) {
console.log('Submit button detected, skipping auto-advance listener')
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')
// Single-execution protection flag
let hasExecuted = false
const clickHandler = async () => {
// Prevent duplicate execution
if (hasExecuted) {
console.warn('Click handler already executed, skipping')
return
}
hasExecuted = true
// For Select components, wait longer to allow user to make a selection
const waitTime = isSelectComponent ? TIMING.SELECT_WAIT_MS : TIMING.INTERACTIVE_WAIT_MS
await new Promise(resolve => setTimeout(resolve, waitTime))
// Verify driver is still active and not destroyed
if (!driverInstance || !driverInstance.isActive()) {
console.warn('Driver instance destroyed or inactive during navigation')
return
}
const currentIndex = driverInstance.getActiveIndex() ?? 0
const nextStep = steps[currentIndex + 1]
if (nextStep?.element && typeof nextStep.element === 'string') {
// 增加超时时间到 8 秒,给路由导航更多时间
const exists = await ensureElement(nextStep.element, TIMING.ELEMENT_TIMEOUT_MS)
if (!exists) {
console.warn('Next step element not found after timeout, aborting auto-advance')
console.warn('Expected element:', nextStep.element)
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 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 {
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: () => {
if (currentClickListener) {
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) {
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()
if (confirm(t('onboarding.confirmExit'))) {
driverInstance.destroy()
onboardingStore.setDriverInstance(null)
}
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]
const isInteractiveStep = currentStep?.popover?.showButtons?.length === 1 &&
currentStep?.popover.showButtons[0] === 'close'
if (isInteractiveStep && currentStep.element) {
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]
const isInteractiveStep = currentStep?.popover?.showButtons?.length === 1 &&
currentStep?.popover.showButtons[0] === 'close'
if (isInteractiveStep && currentStep.element) {
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()) {
console.log('Tour already active, skipping auto-start')
driverInstance = onboardingStore.getDriverInstance()
return
}
if (!options.autoStart || hasSeen()) return
autoStartTimer = setTimeout(() => {
void startTour()
}, TIMING.AUTO_START_DELAY_MS)
})
onBeforeUnmount(() => {
// 保持 driver 实例活跃,支持路由切换
})
onUnmounted(() => {
if (autoStartTimer) {
clearTimeout(autoStartTimer)
autoStartTimer = null
}
// 关键修复:不再此处清理 globalKeyboardHandler交由 driver.onDestroyed 管理
onboardingStore.clearControlMethods()
})
return {
startTour,
replayTour,
nextStep,
isCurrentStep,
hasSeen,
markAsSeen,
clearSeen
}
}

View File

@@ -0,0 +1,79 @@
export const ADMIN_TOUR_STEP_KEYS = [
'admin.welcome',
'admin.groupManage',
'admin.createGroup',
'admin.groupName',
'admin.groupPlatform',
'admin.groupMultiplier',
'admin.groupExclusive',
'admin.groupSubmit',
'admin.accountManage',
'admin.createAccount',
'admin.accountName',
'admin.accountPlatform',
'admin.accountType',
'admin.accountPriority',
'admin.accountGroups',
'admin.accountSubmit',
'admin.keyManage',
'admin.createKey',
'admin.keyName',
'admin.keyGroup',
'admin.keySubmit'
] as const
export const USER_TOUR_STEP_KEYS = [
'user.welcome',
'user.keyManage',
'user.createKey',
'user.keyName',
'user.keyGroup',
'user.keySubmit'
] as const
export const TOUR_STEP_KEYS = [...ADMIN_TOUR_STEP_KEYS, ...USER_TOUR_STEP_KEYS] as const
export type TourStepKey = (typeof TOUR_STEP_KEYS)[number]
export const TOUR_STEP_COMPONENTS: Record<TourStepKey, string> = {
'admin.welcome': 'AdminWelcomeDescription',
'admin.groupManage': 'AdminGroupManageDescription',
'admin.createGroup': 'AdminCreateGroupDescription',
'admin.groupName': 'AdminGroupNameDescription',
'admin.groupPlatform': 'AdminGroupPlatformDescription',
'admin.groupMultiplier': 'AdminGroupMultiplierDescription',
'admin.groupExclusive': 'AdminGroupExclusiveDescription',
'admin.groupSubmit': 'AdminGroupSubmitDescription',
'admin.accountManage': 'AdminAccountManageDescription',
'admin.createAccount': 'AdminCreateAccountDescription',
'admin.accountName': 'AdminAccountNameDescription',
'admin.accountPlatform': 'AdminAccountPlatformDescription',
'admin.accountType': 'AdminAccountTypeDescription',
'admin.accountPriority': 'AdminAccountPriorityDescription',
'admin.accountGroups': 'AdminAccountGroupsDescription',
'admin.accountSubmit': 'AdminAccountSubmitDescription',
'admin.keyManage': 'AdminKeyManageDescription',
'admin.createKey': 'AdminCreateKeyDescription',
'admin.keyName': 'AdminKeyNameDescription',
'admin.keyGroup': 'AdminKeyGroupDescription',
'admin.keySubmit': 'AdminKeySubmitDescription',
'user.welcome': 'UserWelcomeDescription',
'user.keyManage': 'UserKeyManageDescription',
'user.createKey': 'UserCreateKeyDescription',
'user.keyName': 'UserKeyNameDescription',
'user.keyGroup': 'UserKeyGroupDescription',
'user.keySubmit': 'UserKeySubmitDescription'
}
export const useTourStepDescription = () => {
const getComponentName = (stepKey: TourStepKey) => TOUR_STEP_COMPONENTS[stepKey]
const isTourStepKey = (value: string): value is TourStepKey =>
Object.prototype.hasOwnProperty.call(TOUR_STEP_COMPONENTS, value)
return {
getComponentName,
isTourStepKey,
stepKeys: TOUR_STEP_KEYS
}
}