import { ReactNode, useState, Children, isValidElement, useEffect } from 'react' import { AnimatePresence, motion } from 'framer-motion' import { cn, getBackgroundImage } from '@/lib/utils' import { Overlay } from '@/components/overlay' import { useVideoContextSafe } from '@/contexts/VideoContext' import { useAudioManager } from '@/hooks/useAudioManager' /** * 使用示例: * * * 第一步内容... * * */ interface GuideStepsProps { children: ReactNode className?: string visible?: boolean onVisibleChange?: (visible: boolean) => void currentStep?: number onStepChange?: (step: number) => void /** 每个步骤对应的高亮选择器数组,索引对应步骤序号 */ highlightSelectors?: (string | string[])[] /** 控制是否显示遮罩层(包括背景蒙版和 Overlay 遮罩,默认:true,保持向后兼容) */ showMask?: boolean /** 自定义 z-index,用于控制层级(默认:50) */ zIndex?: number /** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */ audioChannelId?: string } interface GuideStepsItemProps { children: ReactNode speaker?: 'guide' | 'student' /** 当前步骤对应的音频地址 */ audioSrc?: string /** 进入该步骤时是否自动播放音频(有 audioSrc 且未显式设置时,默认自动播放) */ autoPlayOnEnter?: boolean } /** * GuideStepsItem 文案长度建议: * - 不要一句一页,也不要超长导致一页放不下 * - 推荐每段 1~2 句,建议不超过 70 个中文字符 */ const GuideSteps = ({ children, className, visible, onVisibleChange, currentStep: controlledStep, onStepChange, highlightSelectors, showMask = true, zIndex = 50, audioChannelId, }: GuideStepsProps) => { const [internalStep, setInternalStep] = useState(0) const [internalVisible, setInternalVisible] = useState(true) // 安全地获取视频上下文(如果不在 VideoProvider 中,返回 null) const videoContext = useVideoContextSafe() // 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥 const { play, stop } = useAudioManager( audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' } ) // 收集每个步骤的内容和 speaker 信息 const stepsData = Children.toArray(children).map((child) => { if (isValidElement(child) && child.type === GuideStepsItem) { const hasAudio = !!child.props.audioSrc return { content: child.props.children, speaker: child.props.speaker || 'guide', // 默认为 "guide" 保持向后兼容 audioSrc: child.props.audioSrc, // 如果没有显式设置 autoPlayOnEnter,则在有 audioSrc 时默认自动播放 autoPlayOnEnter: child.props.autoPlayOnEnter !== undefined ? child.props.autoPlayOnEnter : hasAudio, } } // 如果不是 GuideStepsItem,保持原有行为 return { content: child, speaker: 'guide' as const, audioSrc: undefined, autoPlayOnEnter: false, } }) const steps = stepsData.map((step) => step.content) const totalSteps = steps.length // 使用受控或非受控模式 const isControlled = controlledStep !== undefined const currentStep = isControlled ? controlledStep : internalStep const baseVisible = visible !== undefined ? visible : internalVisible // 如果视频正在播放或内容不可见,则不显示 GuideSteps // 如果不在 VideoProvider 中(videoContext 为 null),则正常显示 const isVisible = baseVisible && (!videoContext || (!videoContext.isVideoPlaying && videoContext.isContentVisible)) // 获取当前步骤的 speaker const currentSpeaker = stepsData[currentStep]?.speaker || 'guide' const currentStepData = stepsData[currentStep] const currentAudioSrc = currentStepData?.audioSrc const currentAutoPlayOnEnter = currentStepData?.autoPlayOnEnter ?? false // 步骤变化时,按需自动播放音频(依赖稳定值,避免循环触发) useEffect(() => { if (!isVisible) return if (currentAutoPlayOnEnter && currentAudioSrc) { play(currentAudioSrc) } }, [isVisible, currentStep, currentAutoPlayOnEnter, currentAudioSrc, play]) const updateStep = (newStep: number) => { if (!isControlled) { setInternalStep(newStep) } onStepChange?.(newStep) } const updateVisible = (newVisible: boolean) => { if (visible === undefined) { setInternalVisible(newVisible) } // 隐藏引导时,停止当前 channel 的音频 if (!newVisible) { stop() } onVisibleChange?.(newVisible) } // 受控 visible 场景:当外部把 visible 置为 false 时,也需要停止音频 useEffect(() => { if (!isVisible) { stop() } }, [isVisible, stop]) const handleNext = (e: React.MouseEvent) => { e.stopPropagation() // 阻止事件冒泡 if (currentStep < totalSteps - 1) { updateStep(currentStep + 1) } } const handleTextClick = () => { if (currentStep < totalSteps - 1) { updateStep(currentStep + 1) } else { // 最后一步,隐藏组件 updateVisible(false) } } // 获取当前步骤的高亮选择器 const currentHighlightSelectors = highlightSelectors && highlightSelectors[currentStep] ? highlightSelectors[currentStep] : undefined // 根据 speaker 选择背景图片 const currentBackgroundPath = currentSpeaker === 'student' ? 'components/引导板2' : 'components/引导板' // GuideSteps 内容 const guideStepsContent = ( {isVisible && ( {/* 引导板容器和背景 - 作为整体切换,确保 padding 和背景同步 */} {/* 只在 isVisible 为 true 时才渲染,避免阻塞 exit 动画 */} {isVisible && ( {/* 背景层 */}
{/* 引导文本区域 - 只在步骤变化时动画 */}
{steps[currentStep]}
{/* 下一步提示 - 最后一页不显示 */} {currentStep < totalSteps - 1 && (
继续 →
)}
)} )} ) // 始终使用 Overlay 包裹,showMask 由 Overlay 处理 return ( {guideStepsContent} ) } const GuideStepsItem = ({ children }: GuideStepsItemProps) => { return <>{children} } export { GuideSteps, GuideStepsItem }