feat: 更新音频和引导对话,优化用户体验
- 在多个场景中添加音频资源,增强学习互动性。 - 更新引导对话文本,确保信息准确性和一致性。 - 调整组件结构,清理不必要的代码,提升可维护性。 - 引入结束窗口,鼓励用户深入了解历史背景。
This commit is contained in:
1 parent
d7ff7d3a45
commit
6e106032fd
141 files changed
+1297
-401
No files matched your search
@@ -13,7 +13,6 @@ dist-ssr
|
|||||||
*.local
|
*.local
|
||||||
public/video/*
|
public/video/*
|
||||||
public/words/*
|
public/words/*
|
||||||
public/audio/*
|
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 549 KiB |
@@ -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 { AnimatePresence, motion } from "framer-motion";
|
||||||
import { cn, getBackgroundImage } from "@/lib/utils";
|
import { cn, getBackgroundImage } from "@/lib/utils";
|
||||||
import { Overlay } from "@/components/overlay";
|
import { Overlay } from "@/components/overlay";
|
||||||
import { useVideoContextSafe } from "@/contexts/VideoContext";
|
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 {
|
interface GuideStepsProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -17,11 +32,17 @@ interface GuideStepsProps {
|
|||||||
showMask?: boolean;
|
showMask?: boolean;
|
||||||
/** 自定义 z-index,用于控制层级(默认:50) */
|
/** 自定义 z-index,用于控制层级(默认:50) */
|
||||||
zIndex?: number;
|
zIndex?: number;
|
||||||
|
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||||
|
audioChannelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GuideStepsItemProps {
|
interface GuideStepsItemProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
speaker?: "guide" | "student";
|
speaker?: "guide" | "student";
|
||||||
|
/** 当前步骤对应的音频地址 */
|
||||||
|
audioSrc?: string;
|
||||||
|
/** 进入该步骤时是否自动播放音频(有 audioSrc 且未显式设置时,默认自动播放) */
|
||||||
|
autoPlayOnEnter?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GuideSteps = ({
|
const GuideSteps = ({
|
||||||
@@ -34,6 +55,7 @@ const GuideSteps = ({
|
|||||||
highlightSelectors,
|
highlightSelectors,
|
||||||
showMask = true,
|
showMask = true,
|
||||||
zIndex = 50,
|
zIndex = 50,
|
||||||
|
audioChannelId,
|
||||||
}: GuideStepsProps) => {
|
}: GuideStepsProps) => {
|
||||||
const [internalStep, setInternalStep] = useState(0);
|
const [internalStep, setInternalStep] = useState(0);
|
||||||
const [internalVisible, setInternalVisible] = useState(true);
|
const [internalVisible, setInternalVisible] = useState(true);
|
||||||
@@ -41,21 +63,37 @@ const GuideSteps = ({
|
|||||||
// 安全地获取视频上下文(如果不在 VideoProvider 中,返回 null)
|
// 安全地获取视频上下文(如果不在 VideoProvider 中,返回 null)
|
||||||
const videoContext = useVideoContextSafe();
|
const videoContext = useVideoContextSafe();
|
||||||
|
|
||||||
|
// 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥
|
||||||
|
const { play, stop } = useAudioManager(
|
||||||
|
audioChannelId
|
||||||
|
? { mode: "channel", channelId: audioChannelId }
|
||||||
|
: { mode: "global" }
|
||||||
|
);
|
||||||
|
|
||||||
// 收集每个步骤的内容和 speaker 信息
|
// 收集每个步骤的内容和 speaker 信息
|
||||||
const stepsData = Children.toArray(children).map((child) => {
|
const stepsData = Children.toArray(children).map((child) => {
|
||||||
if (
|
if (
|
||||||
isValidElement<GuideStepsItemProps>(child) &&
|
isValidElement<GuideStepsItemProps>(child) &&
|
||||||
child.type === GuideStepsItem
|
child.type === GuideStepsItem
|
||||||
) {
|
) {
|
||||||
|
const hasAudio = !!child.props.audioSrc;
|
||||||
return {
|
return {
|
||||||
content: child.props.children,
|
content: child.props.children,
|
||||||
speaker: child.props.speaker || "guide", // 默认为 "guide" 保持向后兼容
|
speaker: child.props.speaker || "guide", // 默认为 "guide" 保持向后兼容
|
||||||
|
audioSrc: child.props.audioSrc,
|
||||||
|
// 如果没有显式设置 autoPlayOnEnter,则在有 audioSrc 时默认自动播放
|
||||||
|
autoPlayOnEnter:
|
||||||
|
child.props.autoPlayOnEnter !== undefined
|
||||||
|
? child.props.autoPlayOnEnter
|
||||||
|
: hasAudio,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// 如果不是 GuideStepsItem,保持原有行为
|
// 如果不是 GuideStepsItem,保持原有行为
|
||||||
return {
|
return {
|
||||||
content: child,
|
content: child,
|
||||||
speaker: "guide" as const,
|
speaker: "guide" as const,
|
||||||
|
audioSrc: undefined,
|
||||||
|
autoPlayOnEnter: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,6 +114,17 @@ const GuideSteps = ({
|
|||||||
|
|
||||||
// 获取当前步骤的 speaker
|
// 获取当前步骤的 speaker
|
||||||
const currentSpeaker = stepsData[currentStep]?.speaker || "guide";
|
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) => {
|
const updateStep = (newStep: number) => {
|
||||||
if (!isControlled) {
|
if (!isControlled) {
|
||||||
@@ -88,9 +137,20 @@ const GuideSteps = ({
|
|||||||
if (visible === undefined) {
|
if (visible === undefined) {
|
||||||
setInternalVisible(newVisible);
|
setInternalVisible(newVisible);
|
||||||
}
|
}
|
||||||
|
// 隐藏引导时,停止当前 channel 的音频
|
||||||
|
if (!newVisible) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
onVisibleChange?.(newVisible);
|
onVisibleChange?.(newVisible);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 受控 visible 场景:当外部把 visible 置为 false 时,也需要停止音频
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}, [isVisible, stop]);
|
||||||
|
|
||||||
const handleNext = (e: React.MouseEvent) => {
|
const handleNext = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation(); // 阻止事件冒泡
|
e.stopPropagation(); // 阻止事件冒泡
|
||||||
if (currentStep < totalSteps - 1) {
|
if (currentStep < totalSteps - 1) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { createPortal } from "react-dom";
|
|||||||
import { Image } from "@/components";
|
import { Image } from "@/components";
|
||||||
import { cn, getStaticImage } from "@/lib/utils";
|
import { cn, getStaticImage } from "@/lib/utils";
|
||||||
import { useScale } from "@/contexts/ScaleContext";
|
import { useScale } from "@/contexts/ScaleContext";
|
||||||
|
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||||
|
import { useVideoContextSafe } from "@/contexts/VideoContext";
|
||||||
|
|
||||||
export interface StudyGuideModalProps {
|
export interface StudyGuideModalProps {
|
||||||
/** 是否可见 */
|
/** 是否可见 */
|
||||||
@@ -22,11 +24,27 @@ export interface StudyGuideModalProps {
|
|||||||
ailgn?: "center" | "left" | "right";
|
ailgn?: "center" | "left" | "right";
|
||||||
// 垂直对齐方式,默认 center
|
// 垂直对齐方式,默认 center
|
||||||
verticalAlign?: "center" | "top" | "bottom";
|
verticalAlign?: "center" | "top" | "bottom";
|
||||||
|
/** 引导语音地址(可选) */
|
||||||
|
audioSrc?: string;
|
||||||
|
/** 弹窗出现时是否自动播放引导语音(默认 true) */
|
||||||
|
autoPlayAudio?: boolean;
|
||||||
|
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||||
|
audioChannelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用学习引导弹窗
|
* 通用学习引导弹窗
|
||||||
* 使用 images/components/guide 下的引导框 + 继续按钮资源
|
* 使用 images/components/guide 下的引导框 + 继续按钮资源
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* <StudyGuideModal
|
||||||
|
* visible
|
||||||
|
* audioSrc="/audios/modal-intro.mp3"
|
||||||
|
* autoPlayAudio
|
||||||
|
* audioChannelId="guide-chenyun"
|
||||||
|
* >
|
||||||
|
* 文案内容...
|
||||||
|
* </StudyGuideModal>
|
||||||
*/
|
*/
|
||||||
export const StudyGuideModal = ({
|
export const StudyGuideModal = ({
|
||||||
visible = false,
|
visible = false,
|
||||||
@@ -37,15 +55,58 @@ export const StudyGuideModal = ({
|
|||||||
zIndex = 120,
|
zIndex = 120,
|
||||||
ailgn = "center",
|
ailgn = "center",
|
||||||
verticalAlign = "top",
|
verticalAlign = "top",
|
||||||
|
audioSrc,
|
||||||
|
autoPlayAudio = true,
|
||||||
|
audioChannelId,
|
||||||
}: StudyGuideModalProps) => {
|
}: StudyGuideModalProps) => {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
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 兼容:只在客户端挂载
|
// SSR 兼容:只在客户端挂载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
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;
|
if (!visible || !mounted) return null;
|
||||||
|
|
||||||
const handleMaskClick = () => {
|
const handleMaskClick = () => {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export type { ExhibitOverlayProps } from "./exhibit";
|
|||||||
export { Overlay } from "./overlay";
|
export { Overlay } from "./overlay";
|
||||||
export type { OverlayProps } from "./overlay";
|
export type { OverlayProps } from "./overlay";
|
||||||
|
|
||||||
|
export { EndingOverlay } from "./overlay";
|
||||||
|
export type { EndingOverlayProps } from "./overlay";
|
||||||
|
|
||||||
// Spotlight component
|
// Spotlight component
|
||||||
export { Spotlight } from "./spotlight";
|
export { Spotlight } from "./spotlight";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export { default as ResidenceLayout } from "./ResidenceLayout";
|
export { default as ResidenceLayout } from "./ResidenceLayout";
|
||||||
export { default as ClueOverlay } from "./clue";
|
export { default as ClueOverlay } from "../../pages/chenyun/reflection/clue";
|
||||||
export type { ClueOverlayProps } from "./clue";
|
export type { ClueOverlayProps } from "../../pages/chenyun/reflection/clue";
|
||||||
export { default as Alert } from "./alert/title-alert";
|
export { default as Alert } from "./alert/title-alert";
|
||||||
export type { AlertProps } from "./alert/title-alert";
|
export type { AlertProps } from "./alert/title-alert";
|
||||||
export { default as SuccessAlert } from "./alert/success-alert";
|
export { default as SuccessAlert } from "./alert/success-alert";
|
||||||
@@ -9,12 +9,12 @@ export { default as ErrorAlert } from "./alert/error-alert";
|
|||||||
export type { ErrorAlertProps } from "./alert/error-alert";
|
export type { ErrorAlertProps } from "./alert/error-alert";
|
||||||
export { DragQuizPanel } from "./quiz";
|
export { DragQuizPanel } from "./quiz";
|
||||||
export type { DragQuizPanelProps } from "./quiz";
|
export type { DragQuizPanelProps } from "./quiz";
|
||||||
export type {
|
// export type {
|
||||||
ClueData,
|
// ClueData,
|
||||||
ClueType,
|
// ClueType,
|
||||||
TextClueData,
|
// TextClueData,
|
||||||
MapClueData,
|
// MapClueData,
|
||||||
AudioClueData,
|
// AudioClueData,
|
||||||
DialogueClueData,
|
// DialogueClueData,
|
||||||
CustomClueData,
|
// CustomClueData,
|
||||||
} from "./clue/types";
|
// } from "../../pages/chenyun/reflection/clue/types";
|
||||||
@@ -90,8 +90,12 @@ const MultipleChoiceQuizPanel = ({
|
|||||||
onSubmit?.(submittedValues, true);
|
onSubmit?.(submittedValues, true);
|
||||||
} else {
|
} else {
|
||||||
// 答题失败:清空状态,执行 onError,允许重新选择
|
// 答题失败:清空状态,执行 onError,允许重新选择
|
||||||
|
const submittedValues = [...selectedValues];
|
||||||
setSelectedValues([]);
|
setSelectedValues([]);
|
||||||
const errorMsg = errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
|
const errorMsg =
|
||||||
|
typeof errorMessage === "function"
|
||||||
|
? errorMessage(submittedValues)
|
||||||
|
: errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
onError?.(errorMsg);
|
onError?.(errorMsg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ export interface MultipleChoiceQuizPanelProps {
|
|||||||
options: MultipleChoiceOption[];
|
options: MultipleChoiceOption[];
|
||||||
/** 正确答案数组(可选,用于验证) */
|
/** 正确答案数组(可选,用于验证) */
|
||||||
correctAnswer?: string[];
|
correctAnswer?: string[];
|
||||||
/** 自定义错误提示信息(答案错误时显示) */
|
/** 自定义错误提示信息(答案错误时显示),可以是字符串或函数(根据用户选择返回不同提示) */
|
||||||
errorMessage?: string;
|
errorMessage?: string | ((selectedValues: string[]) => string);
|
||||||
/** 自定义最小选择数量不足时的提示信息 */
|
/** 自定义最小选择数量不足时的提示信息 */
|
||||||
minSelectionsErrorMessage?: string;
|
minSelectionsErrorMessage?: string;
|
||||||
/** 提交回调,返回用户选择和是否正确 */
|
/** 提交回调,返回用户选择和是否正确 */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { getStaticImage } from "@/lib/utils";
|
import { getStaticImage } from "@/lib/utils";
|
||||||
import { Image } from "@/components";
|
import { Image } from "@/components";
|
||||||
@@ -23,6 +23,47 @@ const LetterToHuangyanpei = ({
|
|||||||
}: LetterToHuangyanpeiProps) => {
|
}: LetterToHuangyanpeiProps) => {
|
||||||
// 下划线可填写内容
|
// 下划线可填写内容
|
||||||
const [blankValues, setBlankValues] = useState<string[]>([]);
|
const [blankValues, setBlankValues] = useState<string[]>([]);
|
||||||
|
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
|
||||||
|
const [blankErrors, setBlankErrors] = useState<boolean[]>([]);
|
||||||
|
|
||||||
|
// 是否已提交:提交后隐藏编辑信件,展示只读信件
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
// 校验输入:不能为空、每个不超过 10 个字符
|
||||||
|
const validateBlanks = () => {
|
||||||
|
// 计算占位总数,防止有些没被输入过而是 undefined
|
||||||
|
const placeholderCount = (letterContent.match(/____/g) || []).length;
|
||||||
|
|
||||||
|
const nextErrors: boolean[] = Array(placeholderCount).fill(false);
|
||||||
|
const nextValues: string[] = [...blankValues];
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < placeholderCount; i++) {
|
||||||
|
const raw = blankValues[i] || "";
|
||||||
|
const v = raw.trim();
|
||||||
|
|
||||||
|
// 为空 或 超过 10 个字,都视为不符合要求:清空并标红
|
||||||
|
if (!v || v.length > 10) {
|
||||||
|
nextValues[i] = "";
|
||||||
|
nextErrors[i] = true;
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setBlankValues(nextValues);
|
||||||
|
setBlankErrors(nextErrors);
|
||||||
|
|
||||||
|
return !hasError;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 弹窗关闭后重置内容,方便下次重新填写
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setSubmitted(false);
|
||||||
|
setBlankValues([]);
|
||||||
|
setBlankErrors([]);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
// 信件内容模板(根据设计稿)
|
// 信件内容模板(根据设计稿)
|
||||||
const letterContent = `敬爱的黄炎培先生:
|
const letterContent = `敬爱的黄炎培先生:
|
||||||
@@ -67,8 +108,26 @@ const LetterToHuangyanpei = ({
|
|||||||
<div
|
<div
|
||||||
className="relative w-[1200px] h-full pointer-events-auto"
|
className="relative w-[1200px] h-full pointer-events-auto"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ perspective: "1200px" }}
|
||||||
>
|
>
|
||||||
{/* 信纸背景 */}
|
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
|
||||||
|
<motion.div
|
||||||
|
className="relative w-full h-full"
|
||||||
|
initial={false}
|
||||||
|
animate={{ rotateY: submitted ? 180 : 0 }}
|
||||||
|
transition={{ duration: 0.7, ease: "easeInOut" }}
|
||||||
|
style={{ transformStyle: "preserve-3d" }}
|
||||||
|
>
|
||||||
|
{/* 前面:编辑态 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backfaceVisibility: "hidden",
|
||||||
|
// 编辑态展示时才响应事件,避免与背面点击冲突
|
||||||
|
pointerEvents: submitted ? "none" : "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 编辑态:信纸背景 */}
|
||||||
<img
|
<img
|
||||||
src={getStaticImage("components/letter/信底")}
|
src={getStaticImage("components/letter/信底")}
|
||||||
alt="信纸"
|
alt="信纸"
|
||||||
@@ -88,13 +147,15 @@ const LetterToHuangyanpei = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 信件内容区域 */}
|
{/* 信件内容区域(可编辑) */}
|
||||||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||||
{(() => {
|
{(() => {
|
||||||
let placeholderIndex = 0;
|
let placeholderIndex = 0;
|
||||||
let foundNiHao = false;
|
let foundNiHao = false;
|
||||||
return letterContent.split("\n").map((line, lineIndex) => {
|
return letterContent
|
||||||
|
.split("\n")
|
||||||
|
.map((line, lineIndex) => {
|
||||||
// 检测是否已经找到"您好"行
|
// 检测是否已经找到"您好"行
|
||||||
if (line.includes("您好")) {
|
if (line.includes("您好")) {
|
||||||
foundNiHao = true;
|
foundNiHao = true;
|
||||||
@@ -110,14 +171,16 @@ const LetterToHuangyanpei = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={lineIndex}
|
key={lineIndex}
|
||||||
className="mb-0"
|
|
||||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||||
>
|
>
|
||||||
{segments.map((part, partIndex) => {
|
{segments.map((part, partIndex) => {
|
||||||
const isLast = partIndex === segments.length - 1;
|
const isLast =
|
||||||
|
partIndex === segments.length - 1;
|
||||||
if (isLast) {
|
if (isLast) {
|
||||||
return (
|
return (
|
||||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
<span
|
||||||
|
key={`part-${lineIndex}-${partIndex}`}
|
||||||
|
>
|
||||||
{part}
|
{part}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -125,15 +188,29 @@ const LetterToHuangyanpei = ({
|
|||||||
|
|
||||||
const currentIndex = placeholderIndex++;
|
const currentIndex = placeholderIndex++;
|
||||||
return (
|
return (
|
||||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
<span
|
||||||
|
key={`part-${lineIndex}-${partIndex}`}
|
||||||
|
>
|
||||||
{part}
|
{part}
|
||||||
<input
|
<input
|
||||||
className="inline-block w-[100px] h-[36px] mx-2 align-middle bg-transparent border-b-2 border-dashed border-[#4e402e] text-[24px] text-center outline-none"
|
name={`blank-${currentIndex}`}
|
||||||
|
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||||
|
blankErrors[currentIndex]
|
||||||
|
? "border-red-500"
|
||||||
|
: "border-[#4e402e]"
|
||||||
|
}`}
|
||||||
value={blankValues[currentIndex] ?? ""}
|
value={blankValues[currentIndex] ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...blankValues];
|
const next = [...blankValues];
|
||||||
next[currentIndex] = e.target.value;
|
next[currentIndex] = e.target.value;
|
||||||
setBlankValues(next);
|
setBlankValues(next);
|
||||||
|
|
||||||
|
// 用户再次输入时,清除该空的错误高亮
|
||||||
|
if (blankErrors[currentIndex]) {
|
||||||
|
const nextErrs = [...blankErrors];
|
||||||
|
nextErrs[currentIndex] = false;
|
||||||
|
setBlankErrors(nextErrs);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -155,26 +232,117 @@ const LetterToHuangyanpei = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 提交按钮 */}
|
{/* 提交按钮:切换到只读信件态 */}
|
||||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||||
<Image
|
<Image
|
||||||
src={getStaticImage("components/letter/可提交状态")}
|
src={getStaticImage("components/letter/可提交状态")}
|
||||||
alt="可提交状态"
|
alt="可提交状态"
|
||||||
onClick={() => onSubmit?.(blankValues)}
|
onClick={() => {
|
||||||
|
if (!validateBlanks()) return;
|
||||||
|
setSubmitted(true);
|
||||||
|
}}
|
||||||
button
|
button
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 寄信图标(可选,如果需要的话) */}
|
{/* 背面:只读态 */}
|
||||||
<div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
transform: "rotateY(180deg)",
|
||||||
|
backfaceVisibility: "hidden",
|
||||||
|
// 只读态展示时才响应事件
|
||||||
|
pointerEvents: submitted ? "auto" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 只读态:新背景 */}
|
||||||
<img
|
<img
|
||||||
src={getStaticImage("components/letter/寄信图标")}
|
src={getStaticImage("components/letter/信")}
|
||||||
alt="寄信"
|
alt="信件"
|
||||||
className="w-full h-full object-contain"
|
className="absolute inset-0 w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src={getStaticImage("components/close")}
|
||||||
|
alt="信纸"
|
||||||
|
className="absolute right-38 top-0"
|
||||||
|
button
|
||||||
|
onClick={() => {
|
||||||
|
setSubmitted(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 信件内容区域(只读) */}
|
||||||
|
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||||
|
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||||
|
{(() => {
|
||||||
|
let placeholderIndex = 0;
|
||||||
|
let foundNiHao = false;
|
||||||
|
return letterContent
|
||||||
|
.split("\n")
|
||||||
|
.map((line, lineIndex) => {
|
||||||
|
if (line.includes("您好")) {
|
||||||
|
foundNiHao = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsIndent =
|
||||||
|
foundNiHao &&
|
||||||
|
line.trim() !== "" &&
|
||||||
|
!line.includes("您好");
|
||||||
|
|
||||||
|
const segments = line.split("____");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={lineIndex}
|
||||||
|
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||||
|
>
|
||||||
|
{segments.map((part, partIndex) => {
|
||||||
|
const isLast =
|
||||||
|
partIndex === segments.length - 1;
|
||||||
|
if (isLast) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`part-${lineIndex}-${partIndex}`}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = placeholderIndex++;
|
||||||
|
const value = blankValues[currentIndex] ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`part-${lineIndex}-${partIndex}`}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
{value || " "}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 寄出按钮:关闭窗口 */}
|
||||||
|
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||||
|
<Image
|
||||||
|
src={getStaticImage("components/letter/寄出")}
|
||||||
|
alt="寄出"
|
||||||
|
onClick={() => {
|
||||||
|
onSubmit?.(blankValues);
|
||||||
|
}}
|
||||||
|
button
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||||
|
|
||||||
export interface NarrationProps {
|
export interface NarrationProps {
|
||||||
/** 旁白文本 */
|
/** 旁白文本 */
|
||||||
@@ -16,6 +17,10 @@ export interface NarrationProps {
|
|||||||
enableAnimation?: boolean;
|
enableAnimation?: boolean;
|
||||||
/** 文字动画速度(毫秒/字),默认 100ms */
|
/** 文字动画速度(毫秒/字),默认 100ms */
|
||||||
animationSpeed?: number;
|
animationSpeed?: number;
|
||||||
|
/** 覆盖层出现时是否自动播放音频(默认 true) */
|
||||||
|
autoPlayAudio?: boolean;
|
||||||
|
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||||
|
audioChannelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,18 +39,33 @@ const Narration = ({
|
|||||||
className,
|
className,
|
||||||
enableAnimation = true,
|
enableAnimation = true,
|
||||||
animationSpeed = 100,
|
animationSpeed = 100,
|
||||||
|
autoPlayAudio = true,
|
||||||
|
audioChannelId,
|
||||||
}: NarrationProps) => {
|
}: NarrationProps) => {
|
||||||
const [displayedText, setDisplayedText] = useState("");
|
const [displayedText, setDisplayedText] = useState("");
|
||||||
const [isAnimating, setIsAnimating] = useState(true);
|
const [isAnimating, setIsAnimating] = useState(true);
|
||||||
const [audio, setAudio] = useState<HTMLAudioElement | null>(null);
|
|
||||||
const [isSkipped, setIsSkipped] = useState(false);
|
const [isSkipped, setIsSkipped] = useState(false);
|
||||||
|
|
||||||
|
// 旁白音频:默认自动播放,支持按 channel 分组互斥
|
||||||
|
const { currentSrc, play, stop } = useAudioManager(
|
||||||
|
audioChannelId
|
||||||
|
? {
|
||||||
|
mode: "channel",
|
||||||
|
channelId: audioChannelId,
|
||||||
|
}
|
||||||
|
: { mode: "global" }
|
||||||
|
);
|
||||||
|
|
||||||
// 文字动画效果
|
// 文字动画效果
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
setDisplayedText("");
|
setDisplayedText("");
|
||||||
setIsAnimating(true);
|
setIsAnimating(true);
|
||||||
setIsSkipped(false);
|
setIsSkipped(false);
|
||||||
|
// 旁白关闭时,如果当前正在播放该旁白音频,则停止
|
||||||
|
if (audioSrc && currentSrc === audioSrc) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +96,7 @@ const Narration = ({
|
|||||||
}, animationSpeed);
|
}, animationSpeed);
|
||||||
|
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [text, visible, enableAnimation, animationSpeed, isSkipped]);
|
}, [text, visible, enableAnimation, animationSpeed, isSkipped, audioSrc, currentSrc, stop]);
|
||||||
|
|
||||||
// 跳过动画
|
// 跳过动画
|
||||||
const handleSkip = () => {
|
const handleSkip = () => {
|
||||||
@@ -85,27 +105,19 @@ const Narration = ({
|
|||||||
setIsSkipped(true);
|
setIsSkipped(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 语音播报
|
// 旁白出现时自动播放音频
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !audioSrc) return;
|
if (!visible) return;
|
||||||
|
if (!autoPlayAudio) return;
|
||||||
|
if (!audioSrc) return;
|
||||||
|
|
||||||
const audioElement = new Audio(audioSrc);
|
play(audioSrc);
|
||||||
audioElement.play().catch((error) => {
|
}, [visible, autoPlayAudio, audioSrc, play]);
|
||||||
console.warn("Audio playback failed:", error);
|
|
||||||
});
|
|
||||||
setAudio(audioElement);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
audioElement.pause();
|
|
||||||
audioElement.currentTime = 0;
|
|
||||||
};
|
|
||||||
}, [visible, audioSrc]);
|
|
||||||
|
|
||||||
// 停止音频
|
// 停止音频
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (audio) {
|
if (audioSrc && currentSrc === audioSrc) {
|
||||||
audio.pause();
|
stop();
|
||||||
audio.currentTime = 0;
|
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|||||||
+34
-1
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getStaticImage } from "@/lib/utils";
|
import { getStaticImage } from "@/lib/utils";
|
||||||
|
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||||
|
|
||||||
export interface EndingOverlayProps {
|
export interface EndingOverlayProps {
|
||||||
/** 是否可见 */
|
/** 是否可见 */
|
||||||
@@ -10,6 +11,12 @@ export interface EndingOverlayProps {
|
|||||||
onContinue: () => void;
|
onContinue: () => void;
|
||||||
/** 文字动画速度(毫秒/字),默认 60ms */
|
/** 文字动画速度(毫秒/字),默认 60ms */
|
||||||
animationSpeed?: number;
|
animationSpeed?: number;
|
||||||
|
/** 结语对应的音频地址(可选) */
|
||||||
|
audioSrc?: string;
|
||||||
|
/** 覆盖层出现时是否自动播放音频(默认 true) */
|
||||||
|
autoPlayAudio?: boolean;
|
||||||
|
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||||
|
audioChannelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,15 +28,32 @@ export const EndingOverlay = ({
|
|||||||
text,
|
text,
|
||||||
onContinue,
|
onContinue,
|
||||||
animationSpeed = 60,
|
animationSpeed = 60,
|
||||||
|
audioSrc,
|
||||||
|
autoPlayAudio = true,
|
||||||
|
audioChannelId,
|
||||||
}: EndingOverlayProps) => {
|
}: EndingOverlayProps) => {
|
||||||
const [displayedText, setDisplayedText] = useState("");
|
const [displayedText, setDisplayedText] = useState("");
|
||||||
const [isAnimating, setIsAnimating] = useState(false);
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
|
||||||
|
// 结语音频:默认自动播放,支持按 channel 分组互斥
|
||||||
|
const { currentSrc, play, stop } = useAudioManager(
|
||||||
|
audioChannelId
|
||||||
|
? {
|
||||||
|
mode: "channel",
|
||||||
|
channelId: audioChannelId,
|
||||||
|
}
|
||||||
|
: { mode: "global" }
|
||||||
|
);
|
||||||
|
|
||||||
// 文字逐字动画
|
// 文字逐字动画
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
setDisplayedText("");
|
setDisplayedText("");
|
||||||
setIsAnimating(false);
|
setIsAnimating(false);
|
||||||
|
// 结语关闭时,如果当前正在播放该结语音频,则停止
|
||||||
|
if (audioSrc && currentSrc === audioSrc) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +72,16 @@ export const EndingOverlay = ({
|
|||||||
}, animationSpeed);
|
}, animationSpeed);
|
||||||
|
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [visible, text, animationSpeed]);
|
}, [visible, text, animationSpeed, audioSrc, currentSrc, stop]);
|
||||||
|
|
||||||
|
// 覆盖层显示时自动播放音频
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
if (!autoPlayAudio) return;
|
||||||
|
if (!audioSrc) return;
|
||||||
|
|
||||||
|
play(audioSrc);
|
||||||
|
}, [visible, autoPlayAudio, audioSrc, play]);
|
||||||
|
|
||||||
// 全部文字出现后 1 秒自动关闭
|
// 全部文字出现后 1 秒自动关闭
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
export { default as Overlay } from "./Overlay";
|
export { default as Overlay } from "./Overlay";
|
||||||
export type { OverlayProps } from "./Overlay";
|
export type { OverlayProps } from "./Overlay";
|
||||||
|
|
||||||
|
export { EndingOverlay } from "./EndingOverlay";
|
||||||
|
export type { EndingOverlayProps } from "./EndingOverlay";
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type AudioMode = "global" | "channel";
|
||||||
|
|
||||||
|
interface UseAudioManagerOptions {
|
||||||
|
mode?: AudioMode;
|
||||||
|
channelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioState {
|
||||||
|
key: string | null;
|
||||||
|
currentSrc: string | null;
|
||||||
|
isPlaying: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局音频池:每个 key(全局或某个 channel)对应一个 HTMLAudioElement
|
||||||
|
const audioPool = new Map<string, HTMLAudioElement>();
|
||||||
|
|
||||||
|
// 订阅者列表,用于在任意地方播放/暂停时同步状态
|
||||||
|
const listeners = new Set<(state: AudioState) => void>();
|
||||||
|
|
||||||
|
let globalState: AudioState = {
|
||||||
|
key: null,
|
||||||
|
currentSrc: null,
|
||||||
|
isPlaying: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function emit(state: AudioState) {
|
||||||
|
globalState = state;
|
||||||
|
listeners.forEach((listener) => listener(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKey(options?: UseAudioManagerOptions): string {
|
||||||
|
const mode = options?.mode ?? "global";
|
||||||
|
|
||||||
|
if (mode === "global") {
|
||||||
|
return "__GLOBAL__";
|
||||||
|
}
|
||||||
|
|
||||||
|
// mode === "channel"
|
||||||
|
return `channel:${options?.channelId ?? "__DEFAULT__"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateAudio(key: string): HTMLAudioElement {
|
||||||
|
let audio = audioPool.get(key);
|
||||||
|
if (!audio) {
|
||||||
|
audio = new Audio();
|
||||||
|
audioPool.set(key, audio);
|
||||||
|
|
||||||
|
audio.addEventListener("ended", () => {
|
||||||
|
// 某个 key 下的音频自然播放结束时,仅更新该 key 对应的播放状态
|
||||||
|
if (globalState.key === key) {
|
||||||
|
emit({
|
||||||
|
key,
|
||||||
|
currentSrc: globalState.currentSrc,
|
||||||
|
isPlaying: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分组互斥音频管理:
|
||||||
|
* - mode = "global":全局单例,所有使用方互斥
|
||||||
|
* - mode = "channel" + channelId:同一 channel 内互斥,channel 之间互不影响
|
||||||
|
*/
|
||||||
|
export function useAudioManager(options?: UseAudioManagerOptions) {
|
||||||
|
const [state, setState] = useState<AudioState>(globalState);
|
||||||
|
|
||||||
|
// 将 options 展开为基础类型,避免在 useCallback 中依赖对象引用导致每次 render 都重建函数
|
||||||
|
const resolvedMode: AudioMode = options?.mode ?? "global";
|
||||||
|
const resolvedChannelId: string | undefined = options?.channelId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listeners.add(setState);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(setState);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const play = useCallback(
|
||||||
|
(src: string) => {
|
||||||
|
const key = resolveKey({
|
||||||
|
mode: resolvedMode,
|
||||||
|
channelId: resolvedChannelId,
|
||||||
|
});
|
||||||
|
const audio = getOrCreateAudio(key);
|
||||||
|
|
||||||
|
if (audio.src !== src) {
|
||||||
|
audio.src = src;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.currentTime = 0;
|
||||||
|
// 捕获浏览器因策略或中断导致的 play() 拒绝,避免控制台 Unhandled promise
|
||||||
|
audio.play().catch(() => {
|
||||||
|
// 可以在这里按需上报或忽略
|
||||||
|
});
|
||||||
|
|
||||||
|
emit({
|
||||||
|
key,
|
||||||
|
currentSrc: src,
|
||||||
|
isPlaying: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[resolvedMode, resolvedChannelId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
const key = resolveKey({
|
||||||
|
mode: resolvedMode,
|
||||||
|
channelId: resolvedChannelId,
|
||||||
|
});
|
||||||
|
const audio = audioPool.get(key);
|
||||||
|
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
audio.pause();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
|
||||||
|
if (globalState.key === key) {
|
||||||
|
emit({
|
||||||
|
key,
|
||||||
|
currentSrc: globalState.currentSrc,
|
||||||
|
isPlaying: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [resolvedMode, resolvedChannelId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
play,
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -355,13 +355,19 @@ const ChenyunInternalization = () => {
|
|||||||
}}
|
}}
|
||||||
showMask={false}
|
showMask={false}
|
||||||
>
|
>
|
||||||
<GuideStepsItem speaker="student">
|
<GuideStepsItem
|
||||||
|
speaker="student"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_36.mp3")}
|
||||||
|
>
|
||||||
小沪,跟着你参观陈云纪念馆真是收获满满!看到馆里展示的“米棉之战”史料、全国财经统筹的实物展品,我真切感受到陈云爷爷在建国初期平抑物价、稳定经济的魄力。
|
小沪,跟着你参观陈云纪念馆真是收获满满!看到馆里展示的“米棉之战”史料、全国财经统筹的实物展品,我真切感受到陈云爷爷在建国初期平抑物价、稳定经济的魄力。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="student">
|
<GuideStepsItem speaker="student">
|
||||||
他用“全国一盘棋”思路化解危机的案例太震撼了,那你能再跟我讲讲,支撑这些成就的核心经济思想是什么吗?
|
他用“全国一盘棋”思路化解危机的案例太震撼了,那你能再跟我讲讲,支撑这些成就的核心经济思想是什么吗?
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem
|
||||||
|
speaker="guide"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_37.mp3")}
|
||||||
|
>
|
||||||
针对你的这个问题,今天我们有幸邀请到了中国中共党史学会理事,全国陈云思想生平研究会常务理事兼副秘书长,中共一大纪念馆陈列研究部主任,研究馆员房中老师。关于你的问题,我想房老师会给你指点迷津!
|
针对你的这个问题,今天我们有幸邀请到了中国中共党史学会理事,全国陈云思想生平研究会常务理事兼副秘书长,中共一大纪念馆陈列研究部主任,研究馆员房中老师。关于你的问题,我想房老师会给你指点迷津!
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
</GuideSteps>
|
</GuideSteps>
|
||||||
@@ -379,13 +385,19 @@ const ChenyunInternalization = () => {
|
|||||||
showMask={false}
|
showMask={false}
|
||||||
zIndex={60}
|
zIndex={60}
|
||||||
>
|
>
|
||||||
<GuideStepsItem speaker="student">
|
<GuideStepsItem
|
||||||
|
speaker="student"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_38.mp3")}
|
||||||
|
>
|
||||||
感谢房老师的讲解!从陈云童年成长的水乡故居,到他主持"银元之战""米棉之战"的史料,再到那台称量粮食的天平、握不住的铅笔头,我真切感受到他"不唯上、不唯书、只唯实"的精神,也懂了他"为老百姓做事"的初心。
|
感谢房老师的讲解!从陈云童年成长的水乡故居,到他主持"银元之战""米棉之战"的史料,再到那台称量粮食的天平、握不住的铅笔头,我真切感受到他"不唯上、不唯书、只唯实"的精神,也懂了他"为老百姓做事"的初心。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="student">
|
<GuideStepsItem speaker="student">
|
||||||
这些都让我特别受触动!
|
这些都让我特别受触动!
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem
|
||||||
|
speaker="guide"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_39.mp3")}
|
||||||
|
>
|
||||||
你的感悟很深刻!陈云同志用一生诠释了“务实”与“为民”——从破解边区经济困境,到擘画国家工业化蓝图,再到改革开放时力挺宝钢建设,每一步都扎根实际、心系百姓。
|
你的感悟很深刻!陈云同志用一生诠释了“务实”与“为民”——从破解边区经济困境,到擘画国家工业化蓝图,再到改革开放时力挺宝钢建设,每一步都扎根实际、心系百姓。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem speaker="guide">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
StudyGuideModal,
|
StudyGuideModal,
|
||||||
} from "@/components";
|
} from "@/components";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import Cursor from "@/components/cursor";
|
import Cursor from "@/components/cursor";
|
||||||
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
|
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
|
||||||
@@ -176,6 +176,7 @@ const ChenyunPlace1 = () => {
|
|||||||
setShowGuideModal(false);
|
setShowGuideModal(false);
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
}}
|
}}
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_01.mp3")}
|
||||||
>
|
>
|
||||||
同学你好,欢迎来到“走进故居·对话”学习模块!{"\n"}
|
同学你好,欢迎来到“走进故居·对话”学习模块!{"\n"}
|
||||||
此刻你正站在陈云同志的故居堂屋里,接下来请跟着时光的脚步,
|
此刻你正站在陈云同志的故居堂屋里,接下来请跟着时光的脚步,
|
||||||
@@ -196,7 +197,7 @@ const ChenyunPlace1 = () => {
|
|||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 引导 */}
|
{/* 场景对话 */}
|
||||||
<GuideSteps
|
<GuideSteps
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onVisibleChange={(nextVisible) => {
|
onVisibleChange={(nextVisible) => {
|
||||||
@@ -206,13 +207,18 @@ const ChenyunPlace1 = () => {
|
|||||||
setShowAfterDialogGuide(true);
|
setShowAfterDialogGuide(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
showMask={true}
|
showMask={false}
|
||||||
highlightSelectors={[]}
|
>
|
||||||
|
<GuideStepsItem
|
||||||
|
speaker="student"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_03.mp3")}
|
||||||
>
|
>
|
||||||
<GuideStepsItem speaker="student">
|
|
||||||
小沪!这就是陈云小时候生活的房子吗?看起来和普通农家差不多,他童年过得艰苦吗?
|
小沪!这就是陈云小时候生活的房子吗?看起来和普通农家差不多,他童年过得艰苦吗?
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem
|
||||||
|
speaker="guide"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_04.mp3")}
|
||||||
|
>
|
||||||
这座房子就是陈云故居,位于上海市青浦区练塘镇下塘街95号,是一座坐南朝北、面向河畔的两层楼江南民居。
|
这座房子就是陈云故居,位于上海市青浦区练塘镇下塘街95号,是一座坐南朝北、面向河畔的两层楼江南民居。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem speaker="guide">
|
||||||
@@ -228,6 +234,7 @@ const ChenyunPlace1 = () => {
|
|||||||
visible={showAfterDialogGuide}
|
visible={showAfterDialogGuide}
|
||||||
onContinue={() => setShowAfterDialogGuide(false)}
|
onContinue={() => setShowAfterDialogGuide(false)}
|
||||||
ailgn="left"
|
ailgn="left"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_02.mp3")}
|
||||||
>
|
>
|
||||||
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
|
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
|
||||||
{"\n"}
|
{"\n"}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
GuideSteps,
|
GuideSteps,
|
||||||
GuideStepsItem,
|
GuideStepsItem,
|
||||||
} from "@/components";
|
} from "@/components";
|
||||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useChenyunStore } from "@/store/chenyunStore";
|
import { useChenyunStore } from "@/store/chenyunStore";
|
||||||
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
|
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
|
||||||
@@ -167,10 +167,16 @@ const ChenyunPlace2 = () => {
|
|||||||
highlightSelectors={[]}
|
highlightSelectors={[]}
|
||||||
zIndex={10000}
|
zIndex={10000}
|
||||||
>
|
>
|
||||||
<GuideStepsItem speaker="student">
|
<GuideStepsItem
|
||||||
|
speaker="student"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_05.mp3")}
|
||||||
|
>
|
||||||
那陈云是怎么有机会到图中的青浦县乙种商业学校读书的呀?
|
那陈云是怎么有机会到图中的青浦县乙种商业学校读书的呀?
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem
|
||||||
|
speaker="guide"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_06.mp3")}
|
||||||
|
>
|
||||||
因为家贫,初小读完的陈云便辍学在家。后在舅母亲戚的资助下,陈云才到青浦县乙种商业学校学习珠算和记账的,他在很短时间里就基本掌握了珠算,还初步学会写账,这为陈云日后从事经济工作打下了基础。
|
因为家贫,初小读完的陈云便辍学在家。后在舅母亲戚的资助下,陈云才到青浦县乙种商业学校学习珠算和记账的,他在很短时间里就基本掌握了珠算,还初步学会写账,这为陈云日后从事经济工作打下了基础。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem speaker="guide">
|
||||||
@@ -188,10 +194,16 @@ const ChenyunPlace2 = () => {
|
|||||||
highlightSelectors={[]}
|
highlightSelectors={[]}
|
||||||
zIndex={10000}
|
zIndex={10000}
|
||||||
>
|
>
|
||||||
<GuideStepsItem speaker="student">
|
<GuideStepsItem
|
||||||
|
speaker="student"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_07.mp3")}
|
||||||
|
>
|
||||||
这里有一幅陈云提写的颜安小学,请问陈云在这所学校读过书吗?
|
这里有一幅陈云提写的颜安小学,请问陈云在这所学校读过书吗?
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem
|
||||||
|
speaker="guide"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_08.mp3")}
|
||||||
|
>
|
||||||
是的,陈云在青浦县乙种商业学校学习了一个多月后,又因经济困难不得不辍学回家。多亏当地知识分子、颜安小学校长杜枢,他免费保荐陈云入颜安小学高小部读书。
|
是的,陈云在青浦县乙种商业学校学习了一个多月后,又因经济困难不得不辍学回家。多亏当地知识分子、颜安小学校长杜枢,他免费保荐陈云入颜安小学高小部读书。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem speaker="guide">
|
||||||
|
|||||||
@@ -176,10 +176,16 @@ const ChenyunPlace3 = () => {
|
|||||||
highlightSelectors={[]}
|
highlightSelectors={[]}
|
||||||
zIndex={10000}
|
zIndex={10000}
|
||||||
>
|
>
|
||||||
<GuideStepsItem speaker="student">
|
<GuideStepsItem
|
||||||
|
speaker="student"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_09.mp3")}
|
||||||
|
>
|
||||||
陈云14岁就去了上海商务印书馆当学徒,他主要负责哪些工作呢?
|
陈云14岁就去了上海商务印书馆当学徒,他主要负责哪些工作呢?
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem
|
||||||
|
speaker="guide"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_10.mp3")}
|
||||||
|
>
|
||||||
从颜安小学毕业后,陈云进入了商务印书馆当学徒,开始在柜台接待顾客,打珠算、写信封、练包装、理货物、修文具。
|
从颜安小学毕业后,陈云进入了商务印书馆当学徒,开始在柜台接待顾客,打珠算、写信封、练包装、理货物、修文具。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem speaker="guide">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
GuideSteps,
|
GuideSteps,
|
||||||
GuideStepsItem,
|
GuideStepsItem,
|
||||||
} from "@/components";
|
} from "@/components";
|
||||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useChenyunStore } from "@/store/chenyunStore";
|
import { useChenyunStore } from "@/store/chenyunStore";
|
||||||
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
|
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
|
||||||
@@ -165,10 +165,16 @@ const ChenyunPlace4 = () => {
|
|||||||
highlightSelectors={[]}
|
highlightSelectors={[]}
|
||||||
zIndex={10000}
|
zIndex={10000}
|
||||||
>
|
>
|
||||||
<GuideStepsItem speaker="student">
|
<GuideStepsItem
|
||||||
|
speaker="student"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_11.mp3")}
|
||||||
|
>
|
||||||
这组史料看起来陈云前往中央苏区了,他在中央苏区主要负责哪些工作呢?
|
这组史料看起来陈云前往中央苏区了,他在中央苏区主要负责哪些工作呢?
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem
|
||||||
|
speaker="guide"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_12.mp3")}
|
||||||
|
>
|
||||||
1932年3月,陈云担任中共临时中央政治局常委,兼任全国总工会党团书记。1933年1月,陈云离开上海前往中央苏区首府瑞金,继续参加中共中央和全国总工会的领导工作。
|
1932年3月,陈云担任中共临时中央政治局常委,兼任全国总工会党团书记。1933年1月,陈云离开上海前往中央苏区首府瑞金,继续参加中共中央和全国总工会的领导工作。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem speaker="guide">
|
||||||
@@ -189,10 +195,16 @@ const ChenyunPlace4 = () => {
|
|||||||
highlightSelectors={[]}
|
highlightSelectors={[]}
|
||||||
zIndex={10000}
|
zIndex={10000}
|
||||||
>
|
>
|
||||||
<GuideStepsItem speaker="student">
|
<GuideStepsItem
|
||||||
|
speaker="student"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_13.mp3")}
|
||||||
|
>
|
||||||
苏区的经济工作一定不简单,陈云是如何开展工作的呢?
|
苏区的经济工作一定不简单,陈云是如何开展工作的呢?
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem
|
||||||
|
speaker="guide"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_14.mp3")}
|
||||||
|
>
|
||||||
中央苏区时期的经济斗争,是陈云经济工作能力的“淬炼场”。陈云分析了“左”倾经济政策的表现、危害以及原因。
|
中央苏区时期的经济斗争,是陈云经济工作能力的“淬炼场”。陈云分析了“左”倾经济政策的表现、危害以及原因。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem speaker="guide">
|
||||||
|
|||||||
@@ -155,10 +155,16 @@ const ChenyunPlace5 = () => {
|
|||||||
highlightSelectors={[]}
|
highlightSelectors={[]}
|
||||||
zIndex={10000}
|
zIndex={10000}
|
||||||
>
|
>
|
||||||
<GuideStepsItem speaker="student">
|
<GuideStepsItem
|
||||||
|
speaker="student"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_15.mp3")}
|
||||||
|
>
|
||||||
小沪,延安时期因为国民党的封锁,陕甘宁边区经济困难。陈云转战陕甘宁边区主持财经工作,他一定遇到了很多经济难题吧。
|
小沪,延安时期因为国民党的封锁,陕甘宁边区经济困难。陈云转战陕甘宁边区主持财经工作,他一定遇到了很多经济难题吧。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem
|
||||||
|
speaker="guide"
|
||||||
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_16.mp3")}
|
||||||
|
>
|
||||||
1944年新春伊始,陈云开始担任西北财经办事处副主任,工作转到财经方面,这是陈云以主要精力从事领导经济工作的开始。当时,陕甘宁边区在国民党严密封锁下,财经状况严峻,边区物价猛涨,老百姓日常生活必需的布匹和粮食十分缺乏。
|
1944年新春伊始,陈云开始担任西北财经办事处副主任,工作转到财经方面,这是陈云以主要精力从事领导经济工作的开始。当时,陕甘宁边区在国民党严密封锁下,财经状况严峻,边区物价猛涨,老百姓日常生活必需的布匹和粮食十分缺乏。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem speaker="guide">
|
<GuideStepsItem speaker="guide">
|
||||||
|
|||||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user