Files
shanghai-residences/src/components/guide/GuideSteps.tsx
T
zhongyidian e0fa47d21a 更新依赖项及历史人物相关内容资源
- 更新 package.json 和 package-lock.json 中的依赖项
- 新增/更新陈云、黄炎培、鲁迅、孙中山的音频文件
- 新增场景和组件的图片资源
- 清理未使用的音频和图片文件
- 优化多个组件和页面的实现逻辑
2026-04-20 13:27:18 +08:00

264 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }