feat: 更新音频和引导对话,优化用户体验

- 在多个场景中添加音频资源,增强学习互动性。
- 更新引导对话文本,确保信息准确性和一致性。
- 调整组件结构,清理不必要的代码,提升可维护性。
- 引入结束窗口,鼓励用户深入了解历史背景。
This commit is contained in:
zhongyidian committed 2026-01-15 19:30:17 +08:00
1 parent d7ff7d3a45
commit 6e106032fd
141 files changed
+1375 -479

No files matched your search

+61 -1
View File
@@ -1,9 +1,24 @@
import { ReactNode, useState, Children, isValidElement } from "react";
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;
@@ -17,11 +32,17 @@ interface GuideStepsProps {
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 = ({
@@ -34,6 +55,7 @@ const GuideSteps = ({
highlightSelectors,
showMask = true,
zIndex = 50,
audioChannelId,
}: GuideStepsProps) => {
const [internalStep, setInternalStep] = useState(0);
const [internalVisible, setInternalVisible] = useState(true);
@@ -41,21 +63,37 @@ const GuideSteps = ({
// 安全地获取视频上下文(如果不在 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,
};
});
@@ -76,6 +114,17 @@ const GuideSteps = ({
// 获取当前步骤的 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) {
@@ -88,9 +137,20 @@ const GuideSteps = ({
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) {
+61
View File
@@ -4,6 +4,8 @@ import { createPortal } from "react-dom";
import { Image } from "@/components";
import { cn, getStaticImage } from "@/lib/utils";
import { useScale } from "@/contexts/ScaleContext";
import { useAudioManager } from "@/hooks/useAudioManager";
import { useVideoContextSafe } from "@/contexts/VideoContext";
export interface StudyGuideModalProps {
/** 是否可见 */
@@ -22,11 +24,27 @@ export interface StudyGuideModalProps {
ailgn?: "center" | "left" | "right";
// 垂直对齐方式,默认 center
verticalAlign?: "center" | "top" | "bottom";
/** 引导语音地址(可选) */
audioSrc?: string;
/** 弹窗出现时是否自动播放引导语音(默认 true) */
autoPlayAudio?: boolean;
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string;
}
/**
* 通用学习引导弹窗
* 使用 images/components/guide 下的引导框 + 继续按钮资源
*
* 使用示例:
* <StudyGuideModal
* visible
* audioSrc="/audios/modal-intro.mp3"
* autoPlayAudio
* audioChannelId="guide-chenyun"
* >
* 文案内容...
* </StudyGuideModal>
*/
export const StudyGuideModal = ({
visible = false,
@@ -37,15 +55,58 @@ export const StudyGuideModal = ({
zIndex = 120,
ailgn = "center",
verticalAlign = "top",
audioSrc,
autoPlayAudio = true,
audioChannelId,
}: StudyGuideModalProps) => {
const [mounted, setMounted] = useState(false);
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
const videoContext = useVideoContextSafe();
const { currentSrc, play, stop } = useAudioManager(
audioChannelId
? {
mode: "channel",
channelId: audioChannelId,
}
: { mode: "global" }
);
// SSR 兼容:只在客户端挂载
useEffect(() => {
setMounted(true);
}, []);
// 当视频正在播放或内容未展示时,不应播放引导语音
const canPlayAudio =
!videoContext ||
(!videoContext.isVideoPlaying && videoContext.isContentVisible);
// 弹窗显隐联动音频播放/停止
useEffect(() => {
if (!mounted) return;
if (visible && autoPlayAudio && audioSrc && canPlayAudio) {
play(audioSrc);
}
// 注意:这里不依赖 currentSrc,避免 play() 更新状态后重复触发自动播放
}, [visible, mounted, autoPlayAudio, audioSrc, play, canPlayAudio]);
// 关闭弹窗时停止(仅当当前播放的是该弹窗的 audioSrc)
useEffect(() => {
if (!mounted) return;
if (!visible && audioSrc && currentSrc === audioSrc) {
stop();
}
}, [visible, mounted, audioSrc, currentSrc, stop]);
// 当视频开始播放时,如果当前正在播放该弹窗音频,则停止,避免视频音频与弹窗音频重叠
useEffect(() => {
if (!mounted) return;
if (!canPlayAudio && audioSrc && currentSrc === audioSrc) {
stop();
}
}, [mounted, canPlayAudio, audioSrc, currentSrc, stop]);
if (!visible || !mounted) return null;
const handleMaskClick = () => {