Files
shanghai-residences/src/components/guide/GuideSteps.tsx
T
zhongyidian a365964f54 feat: 添加全局键盘快捷键和更新故居数据,提升用户体验
- 在 main.tsx 中添加全局键盘快捷键功能,允许用户通过 Ctrl+Shift+R 重置所有缓存。
- 更新多个故居的拼图数据,增加黄炎培和鲁迅的相关信息,丰富学习内容。
- 调整 ResidenceLayout 组件以支持新的故居,确保数据一致性。
- 清理不再使用的拼图布局配置文件,提升代码整洁性。
2026-01-28 15:08:28 +08:00

277 lines
9.1 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;
}
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 };