- 更新 package.json 和 package-lock.json 中的依赖项 - 新增/更新陈云、黄炎培、鲁迅、孙中山的音频文件 - 新增场景和组件的图片资源 - 清理未使用的音频和图片文件 - 优化多个组件和页面的实现逻辑
264 lines
9.0 KiB
TypeScript
264 lines
9.0 KiB
TypeScript
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'
|
||
|
||
/**
|
||
* 使用示例:
|
||
* <GuideSteps audioChannelId="guide-chenyun" visible>
|
||
* <GuideStepsItem audioSrc="/audios/step1.mp3" autoPlayOnEnter>
|
||
* 第一步内容...
|
||
* </GuideStepsItem>
|
||
* </GuideSteps>
|
||
*/
|
||
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<GuideStepsItemProps>(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 = (
|
||
<AnimatePresence>
|
||
{isVisible && (
|
||
<motion.div
|
||
key="guide-steps-container"
|
||
className={cn('fixed inset-0 flex items-center justify-center', className)}
|
||
style={{ zIndex: zIndex + 1 }}
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||
onClick={handleTextClick}
|
||
>
|
||
{/* 引导板容器和背景 - 作为整体切换,确保 padding 和背景同步 */}
|
||
{/* 只在 isVisible 为 true 时才渲染,避免阻塞 exit 动画 */}
|
||
{isVisible && (
|
||
<AnimatePresence mode="wait">
|
||
<motion.div
|
||
key={`container-${currentSpeaker}`}
|
||
className={cn(
|
||
'absolute bottom-0 w-[1764px] h-[656px] pt-75 pb-10',
|
||
currentSpeaker === 'student' ? 'pl-50 pr-40' : 'pr-80'
|
||
)}
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||
>
|
||
{/* 背景层 */}
|
||
<div
|
||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||
style={{
|
||
backgroundImage: getBackgroundImage(currentBackgroundPath),
|
||
}}
|
||
/>
|
||
|
||
{/* 引导文本区域 - 只在步骤变化时动画 */}
|
||
<AnimatePresence mode="wait">
|
||
<motion.div
|
||
key={`text-${currentStep}`}
|
||
className="relative h-full py-20 pl-25 cursor-pointer select-none z-10"
|
||
onClick={handleTextClick}
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||
>
|
||
<div className="text-[#4e402e] text-4xl font-bold leading-15">
|
||
{steps[currentStep]}
|
||
</div>
|
||
|
||
{/* 下一步提示 - 最后一页不显示 */}
|
||
{currentStep < totalSteps - 1 && (
|
||
<div className="absolute bottom-15 right-4 flex items-center gap-2">
|
||
<span
|
||
onClick={handleNext}
|
||
className="text-[#8B7355] text-2xl font-medium cursor-pointer hover:text-[#6d5a43] transition-colors select-none"
|
||
>
|
||
继续
|
||
</span>
|
||
<span className="text-[#8B7355] text-2xl animate-pulse">→</span>
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
)
|
||
|
||
// 始终使用 Overlay 包裹,showMask 由 Overlay 处理
|
||
return (
|
||
<Overlay
|
||
selectors={currentHighlightSelectors || []}
|
||
visible={isVisible}
|
||
zIndex={zIndex}
|
||
showMask={showMask}
|
||
>
|
||
{guideStepsContent}
|
||
</Overlay>
|
||
)
|
||
}
|
||
|
||
const GuideStepsItem = ({ children }: GuideStepsItemProps) => {
|
||
return <>{children}</>
|
||
}
|
||
|
||
export { GuideSteps, GuideStepsItem }
|