Files
super_video/src/components/Guide/GuideSteps.vue
T
zhongyidianandClaude Opus 4.7 6f289959be feat: 完善视频学习进度缓存
完善新手导航和三大创作模块的视频完成缓存、未完成提示与学习进度计算,确保进度按实际学习资源完成情况更新。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:05:29 +08:00

395 lines
11 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
:show-skip="currentStep < totalSteps"
:current-step="currentStep"
:total-steps="totalSteps"
:position-offset-y="currentPositionOffsetY"
@next="handleNext"
@skip="handleSkip"
@complete="handleComplete"
>
<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, reactive } from 'vue'
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: 'skip'): 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)
// 合并外部 visible 和内部 show 状态
// 只有当外部 visible 为 true 且内部 internalShow 也为 true 时才显示
const show = computed(() => {
return props.visible && internalShow.value
})
const alignStyle = reactive<{
left: string
top: string
transform: string
width: string
height: string
}>({
left: '0px',
top: '0px',
transform: 'scale(1)',
width: '1920px',
height: '1080px',
})
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 handleSkip = () => {
handleComplete()
}
const handleComplete = () => {
internalShow.value = false
emit('complete')
}
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
case 'Escape':
event.preventDefault()
handleSkip()
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()
}
// 同步到 .app-content 的缩放与偏移,使引导面板与页面对齐
const syncAlign = () => {
const el = document.querySelector('.app-content') as HTMLElement | null
if (!el) return
const style = window.getComputedStyle(el)
const transform = style.transform
let scale = 1
if (transform && transform !== 'none') {
const match = transform.match(/matrix\(([^)]+)\)/)
if (match) {
const parts = match[1].split(',').map((s) => parseFloat(s.trim()))
if (!Number.isNaN(parts[0])) scale = parts[0]
}
}
alignStyle.transform = `scale(${scale})`
alignStyle.left = style.left || '0px'
alignStyle.top = style.top || '0px'
alignStyle.width = style.width || '1920px'
alignStyle.height = style.height || '1080px'
}
syncAlign()
window.addEventListener('resize', syncAlign)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
stopAutoPlay()
window.removeEventListener('resize', () => {})
})
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>