Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
351 lines
9.3 KiB
Vue
351 lines
9.3 KiB
Vue
<template>
|
|
<teleport to="body">
|
|
<div
|
|
class="guide-steps-container"
|
|
v-if="show"
|
|
>
|
|
<SpotlightOverlay
|
|
:visible="show"
|
|
:target-selector="currentHighlightSelector"
|
|
:padding="currentHighlightPadding"
|
|
:border-radius="currentHighlightRadius"
|
|
:highlight-mode="currentHighlightMode"
|
|
/>
|
|
<div
|
|
class="guide-align"
|
|
:style="alignStyle"
|
|
>
|
|
<GuideDragon
|
|
:current-step="currentStep"
|
|
:total-steps="totalSteps"
|
|
:position-offset-y="currentPositionOffsetY"
|
|
@next="handleNext"
|
|
>
|
|
<template #default>
|
|
<!-- 支持插槽形式的步骤内容 -->
|
|
<div v-if="useSlotMode">
|
|
<component
|
|
:is="currentStepComponent"
|
|
:key="currentStepIndex"
|
|
@continue="handleNext"
|
|
:is-last-step="currentStep >= totalSteps"
|
|
:current-step="currentStep"
|
|
:total-steps="totalSteps"
|
|
/>
|
|
</div>
|
|
<!-- 兼容原有的配置形式:也包裹 GuideStepsItem,获得继续按钮 -->
|
|
<GuideStepsItem
|
|
v-else
|
|
:key="currentStepIndex"
|
|
:is-last-step="currentStep >= totalSteps"
|
|
:current-step="currentStep"
|
|
:total-steps="totalSteps"
|
|
:audio-src="currentStepData?.audioSrc"
|
|
:audio-mode="currentStepData?.audioMode"
|
|
:audio-channel-id="currentStepData?.audioChannelId"
|
|
:audio-autoplay="currentStepData?.audioAutoplay"
|
|
@continue="handleNext"
|
|
>
|
|
<!-- 支持组件形式的 content -->
|
|
<component
|
|
v-if="isCurrentStepComponent"
|
|
:is="currentStepContentComponent"
|
|
/>
|
|
<!-- 支持字符串形式的 content -->
|
|
<div
|
|
v-else
|
|
class="whitespace-pre-line"
|
|
v-html="currentStepContent"
|
|
></div>
|
|
</GuideStepsItem>
|
|
</template>
|
|
</GuideDragon>
|
|
</div>
|
|
</div>
|
|
</teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, useSlots, onMounted, onUnmounted } from 'vue'
|
|
import { useScale } from '@/composables/useScale'
|
|
import type { GuideStep } from './types'
|
|
import SpotlightOverlay from './SpotlightOverlay.vue'
|
|
import GuideDragon from './GuideDragon.vue'
|
|
import GuideStepsItem from './GuideStepsItem.vue'
|
|
|
|
interface Props {
|
|
/** 引导步骤配置 - 当使用插槽模式时可以为空 */
|
|
steps?: GuideStep[]
|
|
/** 是否自动开始 */
|
|
autoStart?: boolean
|
|
/** 是否启用键盘导航 */
|
|
keyboardNavigation?: boolean
|
|
/** 自动播放间隔时间(毫秒),0表示不自动播放 */
|
|
autoPlayInterval?: number
|
|
/** 当前步骤高亮目标的选择器(CSS选择器),为空则不高亮 */
|
|
highlightSelector?: string | null
|
|
/** 高亮内边距,默认8 */
|
|
highlightPadding?: number
|
|
/** 高亮圆角,默认16 */
|
|
highlightRadius?: number
|
|
/** 高亮模式:'border' 画框效果,'gradient' 渐变灯光效果,默认 'border' */
|
|
highlightMode?: 'border' | 'gradient'
|
|
/** 垂直偏移(仅Y轴),支持 number(px) 或 '10%'/'120px' */
|
|
positionOffsetY?: number | string
|
|
/** 是否可见,用于外部控制显示/隐藏 */
|
|
visible?: boolean
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'complete'): void
|
|
(e: 'close'): void
|
|
(e: 'step-change', step: GuideStep | null, index: number): void
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
steps: () => [],
|
|
autoStart: true,
|
|
keyboardNavigation: true,
|
|
autoPlayInterval: 0,
|
|
highlightSelector: null,
|
|
highlightPadding: 8,
|
|
highlightRadius: 16,
|
|
highlightMode: 'border',
|
|
positionOffsetY: '50%',
|
|
visible: true,
|
|
})
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const slots = useSlots()
|
|
const internalShow = ref(true)
|
|
const currentStepIndex = ref(0)
|
|
const { scaleStyle: alignStyle } = useScale()
|
|
|
|
// 合并外部 visible 和内部 show 状态
|
|
// 只有当外部 visible 为 true 且内部 internalShow 也为 true 时才显示
|
|
const show = computed(() => {
|
|
return props.visible && internalShow.value
|
|
})
|
|
const normalizeSelector = (selector?: string | null) => {
|
|
if (!selector) return null
|
|
const trimmed = selector.trim()
|
|
return trimmed.length > 0 ? trimmed : null
|
|
}
|
|
|
|
const useSlotMode = computed(() => {
|
|
return slots.default && slots.default().length > 0
|
|
})
|
|
|
|
const slotSteps = computed(() => {
|
|
if (!useSlotMode.value) return []
|
|
return slots.default?.() || []
|
|
})
|
|
const currentStep = computed(() => currentStepIndex.value + 1)
|
|
const totalSteps = computed(() => {
|
|
return useSlotMode.value ? slotSteps.value.length : props.steps.length
|
|
})
|
|
|
|
const currentStepData = computed(() => {
|
|
if (useSlotMode.value) {
|
|
return null // 插槽模式不需要step数据
|
|
}
|
|
if (props.steps.length === 0) {
|
|
return null
|
|
}
|
|
return props.steps[currentStepIndex.value] || null
|
|
})
|
|
|
|
const currentStepConfig = computed(
|
|
() => props.steps[currentStepIndex.value] || null,
|
|
)
|
|
const currentHighlightSelector = computed(() => {
|
|
return normalizeSelector(
|
|
currentStepConfig.value?.highlightSelector ?? props.highlightSelector,
|
|
)
|
|
})
|
|
const currentHighlightPadding = computed(
|
|
() => currentStepConfig.value?.highlightPadding ?? props.highlightPadding,
|
|
)
|
|
const currentHighlightRadius = computed(
|
|
() => currentStepConfig.value?.highlightRadius ?? props.highlightRadius,
|
|
)
|
|
const currentHighlightMode = computed(
|
|
() => currentStepConfig.value?.highlightMode ?? props.highlightMode,
|
|
)
|
|
const currentPositionOffsetY = computed(
|
|
() => currentStepConfig.value?.positionOffsetY ?? props.positionOffsetY,
|
|
)
|
|
|
|
const currentStepComponent = computed(() => {
|
|
if (!useSlotMode.value) return null
|
|
return slotSteps.value[currentStepIndex.value] || null
|
|
})
|
|
|
|
// 判断当前步骤的 content 是否为组件
|
|
const isCurrentStepComponent = computed(() => {
|
|
if (useSlotMode.value) return false
|
|
const step = currentStepData.value
|
|
if (!step) return false
|
|
return typeof step.content !== 'string'
|
|
})
|
|
|
|
// 当前步骤的内容(字符串形式)
|
|
const currentStepContent = computed(() => {
|
|
if (useSlotMode.value) return ''
|
|
if (isCurrentStepComponent.value) return ''
|
|
|
|
const step = currentStepData.value
|
|
if (!step) return ''
|
|
|
|
return typeof step.content === 'string' ? step.content : ''
|
|
})
|
|
|
|
// 当前步骤的内容(组件形式)
|
|
const currentStepContentComponent = computed(() => {
|
|
if (useSlotMode.value) return null
|
|
if (!isCurrentStepComponent.value) return null
|
|
|
|
const step = currentStepData.value
|
|
if (!step) return null
|
|
|
|
return typeof step.content !== 'string' ? step.content : null
|
|
})
|
|
|
|
const handleNext = () => {
|
|
const maxSteps = useSlotMode.value
|
|
? slotSteps.value.length
|
|
: props.steps.length
|
|
if (maxSteps === 0) {
|
|
handleComplete()
|
|
return
|
|
}
|
|
|
|
if (currentStepIndex.value < maxSteps - 1) {
|
|
currentStepIndex.value++
|
|
emit('step-change', currentStepData.value, currentStepIndex.value)
|
|
} else {
|
|
handleComplete()
|
|
}
|
|
}
|
|
|
|
const handleComplete = () => {
|
|
internalShow.value = false
|
|
emit('complete')
|
|
emit('close')
|
|
}
|
|
|
|
const goToStep = (index: number) => {
|
|
const maxSteps = useSlotMode.value
|
|
? slotSteps.value.length
|
|
: props.steps.length
|
|
if (maxSteps === 0) {
|
|
console.warn('GuideSteps goToStep: No steps available')
|
|
return
|
|
}
|
|
|
|
if (index >= 0 && index < maxSteps) {
|
|
currentStepIndex.value = index
|
|
emit('step-change', currentStepData.value, currentStepIndex.value)
|
|
} else {
|
|
console.warn(
|
|
'GuideSteps goToStep: Invalid step index',
|
|
index,
|
|
'max:',
|
|
maxSteps - 1,
|
|
)
|
|
}
|
|
}
|
|
|
|
const resetGuide = () => {
|
|
currentStepIndex.value = 0
|
|
internalShow.value = true
|
|
}
|
|
|
|
// 键盘导航
|
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
if (!props.keyboardNavigation || !show.value) return
|
|
|
|
switch (event.key) {
|
|
case 'ArrowRight':
|
|
case ' ':
|
|
event.preventDefault()
|
|
handleNext()
|
|
break
|
|
case 'ArrowLeft':
|
|
event.preventDefault()
|
|
if (currentStepIndex.value > 0) {
|
|
currentStepIndex.value--
|
|
emit('step-change', currentStepData.value, currentStepIndex.value)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// 自动播放
|
|
let autoPlayTimer: number | null = null
|
|
|
|
const startAutoPlay = () => {
|
|
if (props.autoPlayInterval > 0) {
|
|
autoPlayTimer = window.setInterval(() => {
|
|
const maxSteps = useSlotMode.value
|
|
? slotSteps.value.length
|
|
: props.steps.length
|
|
if (currentStepIndex.value < maxSteps - 1) {
|
|
handleNext()
|
|
} else {
|
|
stopAutoPlay()
|
|
handleComplete()
|
|
}
|
|
}, props.autoPlayInterval)
|
|
}
|
|
}
|
|
|
|
const stopAutoPlay = () => {
|
|
if (autoPlayTimer) {
|
|
clearInterval(autoPlayTimer)
|
|
autoPlayTimer = null
|
|
}
|
|
}
|
|
|
|
// 生命周期
|
|
onMounted(() => {
|
|
document.addEventListener('keydown', handleKeydown)
|
|
if (props.autoStart && props.autoPlayInterval > 0) {
|
|
startAutoPlay()
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('keydown', handleKeydown)
|
|
stopAutoPlay()
|
|
})
|
|
|
|
defineExpose({
|
|
goToStep,
|
|
resetGuide,
|
|
currentStep,
|
|
totalSteps,
|
|
startAutoPlay,
|
|
stopAutoPlay,
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.guide-steps-container {
|
|
@apply w-full h-full;
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 10001;
|
|
pointer-events: none;
|
|
font-family: 'DingTalkJinBuTi';
|
|
}
|
|
|
|
.guide-align {
|
|
position: absolute;
|
|
transform-origin: top left;
|
|
pointer-events: none;
|
|
z-index: 100002;
|
|
}
|
|
</style>
|