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

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

+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 = () => {
+3
View File
@@ -24,6 +24,9 @@ export type { ExhibitOverlayProps } from "./exhibit";
export { Overlay } from "./overlay";
export type { OverlayProps } from "./overlay";
export { EndingOverlay } from "./overlay";
export type { EndingOverlayProps } from "./overlay";
// Spotlight component
export { Spotlight } from "./spotlight";
+11 -11
View File
@@ -1,6 +1,6 @@
export { default as ResidenceLayout } from "./ResidenceLayout";
export { default as ClueOverlay } from "./clue";
export type { ClueOverlayProps } from "./clue";
export { default as ClueOverlay } from "../../pages/chenyun/reflection/clue";
export type { ClueOverlayProps } from "../../pages/chenyun/reflection/clue";
export { default as Alert } from "./alert/title-alert";
export type { AlertProps } from "./alert/title-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 { DragQuizPanel } from "./quiz";
export type { DragQuizPanelProps } from "./quiz";
export type {
ClueData,
ClueType,
TextClueData,
MapClueData,
AudioClueData,
DialogueClueData,
CustomClueData,
} from "./clue/types";
// export type {
// ClueData,
// ClueType,
// TextClueData,
// MapClueData,
// AudioClueData,
// DialogueClueData,
// CustomClueData,
// } from "../../pages/chenyun/reflection/clue/types";
@@ -90,8 +90,12 @@ const MultipleChoiceQuizPanel = ({
onSubmit?.(submittedValues, true);
} else {
// 答题失败:清空状态,执行 onError,允许重新选择
const submittedValues = [...selectedValues];
setSelectedValues([]);
const errorMsg = errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
const errorMsg =
typeof errorMessage === "function"
? errorMessage(submittedValues)
: errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
setError(errorMsg);
onError?.(errorMsg);
}
+2 -2
View File
@@ -65,8 +65,8 @@ export interface MultipleChoiceQuizPanelProps {
options: MultipleChoiceOption[];
/** 正确答案数组(可选,用于验证) */
correctAnswer?: string[];
/** 自定义错误提示信息(答案错误时显示) */
errorMessage?: string;
/** 自定义错误提示信息(答案错误时显示),可以是字符串或函数(根据用户选择返回不同提示) */
errorMessage?: string | ((selectedValues: string[]) => string);
/** 自定义最小选择数量不足时的提示信息 */
minSelectionsErrorMessage?: string;
/** 提交回调,返回用户选择和是否正确 */
+260 -92
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { getStaticImage } from "@/lib/utils";
import { Image } from "@/components";
@@ -23,6 +23,47 @@ const LetterToHuangyanpei = ({
}: LetterToHuangyanpeiProps) => {
// 下划线可填写内容
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 = `敬爱的黄炎培先生:
@@ -67,112 +108,239 @@ const LetterToHuangyanpei = ({
<div
className="relative w-[1200px] h-full pointer-events-auto"
onClick={(e) => e.stopPropagation()}
style={{ perspective: "1200px" }}
>
{/* 信纸背景 */}
<img
src={getStaticImage("components/letter/信底")}
alt="信纸"
className="absolute inset-0 w-full h-full object-contain"
/>
{/* 标题 - 垂直显示在左侧 */}
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
{/* 编辑态 / 只读态: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="text-white text-6xl font-bold tracking-[20px]"
className="absolute inset-0"
style={{
writingMode: "vertical-rl",
textOrientation: "upright",
backfaceVisibility: "hidden",
// 编辑态展示时才响应事件,避免与背面点击冲突
pointerEvents: submitted ? "none" : "auto",
}}
>
写给黄炎培先生的信
</div>
</div>
{/* 编辑态:信纸背景 */}
<img
src={getStaticImage("components/letter/信底")}
alt="信纸"
className="absolute inset-0 w-full h-full object-contain"
/>
{/* 信件内容区域 */}
<div className="absolute w-[700px] left-[320px] 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;
}
{/* 标题 - 垂直显示在左侧 */}
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
<div
className="text-white text-6xl font-bold tracking-[20px]"
style={{
writingMode: "vertical-rl",
textOrientation: "upright",
}}
>
写给黄炎培先生的信
</div>
</div>
// "您好"行之后的非空行需要首行缩进
const needsIndent =
foundNiHao &&
line.trim() !== "" &&
!line.includes("您好");
const segments = line.split("____");
return (
<div
key={lineIndex}
className="mb-0"
style={needsIndent ? { textIndent: "2em" } : {}}
>
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1;
if (isLast) {
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
</span>
);
{/* 信件内容区域(可编辑) */}
<div className="absolute w-[700px] left-[320px] 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 currentIndex = placeholderIndex++;
// "您好"行之后的非空行需要首行缩进
const needsIndent =
foundNiHao &&
line.trim() !== "" &&
!line.includes("您好");
const segments = line.split("____");
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
<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"
value={blankValues[currentIndex] ?? ""}
onChange={(e) => {
const next = [...blankValues];
next[currentIndex] = e.target.value;
setBlankValues(next);
}}
/>
</span>
<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++;
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
<input
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] ?? ""}
onChange={(e) => {
const next = [...blankValues];
next[currentIndex] = e.target.value;
setBlankValues(next);
// 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = false;
setBlankErrors(nextErrs);
}
}}
/>
</span>
);
})}
</div>
);
})}
</div>
);
});
})()}
});
})()}
</div>
</div>
{/* 笔的图片 */}
<div className="absolute bottom-[150px] -right-30">
<img
src={getStaticImage("components/letter/笔")}
alt="笔"
className="w-full h-full object-contain"
/>
</div>
{/* 提交按钮:切换到只读信件态 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image
src={getStaticImage("components/letter/可提交状态")}
alt="可提交状态"
onClick={() => {
if (!validateBlanks()) return;
setSubmitted(true);
}}
button
/>
</div>
</div>
</div>
{/* 笔的图片 */}
<div className="absolute bottom-[150px] -right-30">
<img
src={getStaticImage("components/letter/笔")}
alt="笔"
className="w-full h-full object-contain"
/>
</div>
{/* 背面:只读态 */}
<div
className="absolute inset-0"
style={{
transform: "rotateY(180deg)",
backfaceVisibility: "hidden",
// 只读态展示时才响应事件
pointerEvents: submitted ? "auto" : "none",
}}
>
{/* 只读态:新背景 */}
<img
src={getStaticImage("components/letter/信")}
alt="信件"
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 bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image
src={getStaticImage("components/letter/可提交状态")}
alt="可提交状态"
onClick={() => onSubmit?.(blankValues)}
button
/>
</div>
{/* 信件内容区域(只读) */}
<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;
}
{/* 寄信图标(可选,如果需要的话) */}
<div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
<img
src={getStaticImage("components/letter/寄信图标")}
alt="寄信"
className="w-full h-full object-contain"
/>
</div>
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>
</motion.div>
</div>
</motion.div>
</>
+30 -18
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { useAudioManager } from "@/hooks/useAudioManager";
export interface NarrationProps {
/** 旁白文本 */
@@ -16,6 +17,10 @@ export interface NarrationProps {
enableAnimation?: boolean;
/** 文字动画速度(毫秒/字),默认 100ms */
animationSpeed?: number;
/** 覆盖层出现时是否自动播放音频(默认 true) */
autoPlayAudio?: boolean;
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string;
}
/**
@@ -34,18 +39,33 @@ const Narration = ({
className,
enableAnimation = true,
animationSpeed = 100,
autoPlayAudio = true,
audioChannelId,
}: NarrationProps) => {
const [displayedText, setDisplayedText] = useState("");
const [isAnimating, setIsAnimating] = useState(true);
const [audio, setAudio] = useState<HTMLAudioElement | null>(null);
const [isSkipped, setIsSkipped] = useState(false);
// 旁白音频:默认自动播放,支持按 channel 分组互斥
const { currentSrc, play, stop } = useAudioManager(
audioChannelId
? {
mode: "channel",
channelId: audioChannelId,
}
: { mode: "global" }
);
// 文字动画效果
useEffect(() => {
if (!visible) {
setDisplayedText("");
setIsAnimating(true);
setIsSkipped(false);
// 旁白关闭时,如果当前正在播放该旁白音频,则停止
if (audioSrc && currentSrc === audioSrc) {
stop();
}
return;
}
@@ -76,7 +96,7 @@ const Narration = ({
}, animationSpeed);
return () => clearInterval(timer);
}, [text, visible, enableAnimation, animationSpeed, isSkipped]);
}, [text, visible, enableAnimation, animationSpeed, isSkipped, audioSrc, currentSrc, stop]);
// 跳过动画
const handleSkip = () => {
@@ -85,27 +105,19 @@ const Narration = ({
setIsSkipped(true);
};
// 语音播报
// 旁白出现时自动播放音频
useEffect(() => {
if (!visible || !audioSrc) return;
if (!visible) return;
if (!autoPlayAudio) return;
if (!audioSrc) return;
const audioElement = new Audio(audioSrc);
audioElement.play().catch((error) => {
console.warn("Audio playback failed:", error);
});
setAudio(audioElement);
return () => {
audioElement.pause();
audioElement.currentTime = 0;
};
}, [visible, audioSrc]);
play(audioSrc);
}, [visible, autoPlayAudio, audioSrc, play]);
// 停止音频
const handleClose = () => {
if (audio) {
audio.pause();
audio.currentTime = 0;
if (audioSrc && currentSrc === audioSrc) {
stop();
}
onClose();
};
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { getStaticImage } from "@/lib/utils";
import { useAudioManager } from "@/hooks/useAudioManager";
export interface EndingOverlayProps {
/** 是否可见 */
@@ -10,6 +11,12 @@ export interface EndingOverlayProps {
onContinue: () => void;
/** 文字动画速度(毫秒/字),默认 60ms */
animationSpeed?: number;
/** 结语对应的音频地址(可选) */
audioSrc?: string;
/** 覆盖层出现时是否自动播放音频(默认 true) */
autoPlayAudio?: boolean;
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string;
}
/**
@@ -21,15 +28,32 @@ export const EndingOverlay = ({
text,
onContinue,
animationSpeed = 60,
audioSrc,
autoPlayAudio = true,
audioChannelId,
}: EndingOverlayProps) => {
const [displayedText, setDisplayedText] = useState("");
const [isAnimating, setIsAnimating] = useState(false);
// 结语音频:默认自动播放,支持按 channel 分组互斥
const { currentSrc, play, stop } = useAudioManager(
audioChannelId
? {
mode: "channel",
channelId: audioChannelId,
}
: { mode: "global" }
);
// 文字逐字动画
useEffect(() => {
if (!visible) {
setDisplayedText("");
setIsAnimating(false);
// 结语关闭时,如果当前正在播放该结语音频,则停止
if (audioSrc && currentSrc === audioSrc) {
stop();
}
return;
}
@@ -48,7 +72,16 @@ export const EndingOverlay = ({
}, animationSpeed);
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 秒自动关闭
useEffect(() => {
+2
View File
@@ -1,3 +1,5 @@
export { default as Overlay } from "./Overlay";
export type { OverlayProps } from "./Overlay";
export { EndingOverlay } from "./EndingOverlay";
export type { EndingOverlayProps } from "./EndingOverlay";
+136
View File
@@ -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,
};
}
+16 -4
View File
@@ -355,13 +355,19 @@ const ChenyunInternalization = () => {
}}
showMask={false}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_36.mp3")}
>
小沪,跟着你参观陈云纪念馆真是收获满满!看到馆里展示的“米棉之战”史料、全国财经统筹的实物展品,我真切感受到陈云爷爷在建国初期平抑物价、稳定经济的魄力。
</GuideStepsItem>
<GuideStepsItem speaker="student">
他用“全国一盘棋”思路化解危机的案例太震撼了,那你能再跟我讲讲,支撑这些成就的核心经济思想是什么吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_37.mp3")}
>
针对你的这个问题,今天我们有幸邀请到了中国中共党史学会理事,全国陈云思想生平研究会常务理事兼副秘书长,中共一大纪念馆陈列研究部主任,研究馆员房中老师。关于你的问题,我想房老师会给你指点迷津!
</GuideStepsItem>
</GuideSteps>
@@ -379,13 +385,19 @@ const ChenyunInternalization = () => {
showMask={false}
zIndex={60}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_38.mp3")}
>
感谢房老师的讲解!从陈云童年成长的水乡故居,到他主持"银元之战""米棉之战"的史料,再到那台称量粮食的天平、握不住的铅笔头,我真切感受到他"不唯上、不唯书、只唯实"的精神,也懂了他"为老百姓做事"的初心。
</GuideStepsItem>
<GuideStepsItem speaker="student">
这些都让我特别受触动!
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_39.mp3")}
>
你的感悟很深刻!陈云同志用一生诠释了“务实”与“为民”——从破解边区经济困境,到擘画国家工业化蓝图,再到改革开放时力挺宝钢建设,每一步都扎根实际、心系百姓。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
+13 -6
View File
@@ -8,7 +8,7 @@ import {
StudyGuideModal,
} from "@/components";
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 Cursor from "@/components/cursor";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
@@ -176,6 +176,7 @@ const ChenyunPlace1 = () => {
setShowGuideModal(false);
setVisible(true);
}}
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_01.mp3")}
>
同学你好,欢迎来到“走进故居·对话”学习模块!{"\n"}
此刻你正站在陈云同志的故居堂屋里,接下来请跟着时光的脚步,
@@ -196,7 +197,7 @@ const ChenyunPlace1 = () => {
</Overlay>
)}
{/* 引导 */}
{/* 场景对话 */}
<GuideSteps
visible={visible}
onVisibleChange={(nextVisible) => {
@@ -206,13 +207,18 @@ const ChenyunPlace1 = () => {
setShowAfterDialogGuide(true);
}
}}
showMask={true}
highlightSelectors={[]}
showMask={false}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_03.mp3")}
>
小沪!这就是陈云小时候生活的房子吗?看起来和普通农家差不多,他童年过得艰苦吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_04.mp3")}
>
这座房子就是陈云故居,位于上海市青浦区练塘镇下塘街95号,是一座坐南朝北、面向河畔的两层楼江南民居。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
@@ -228,6 +234,7 @@ const ChenyunPlace1 = () => {
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
ailgn="left"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_02.mp3")}
>
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
{"\n"}
+17 -5
View File
@@ -7,7 +7,7 @@ import {
GuideSteps,
GuideStepsItem,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import { useChenyunStore } from "@/store/chenyunStore";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
@@ -167,10 +167,16 @@ const ChenyunPlace2 = () => {
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_05.mp3")}
>
那陈云是怎么有机会到图中的青浦县乙种商业学校读书的呀?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_06.mp3")}
>
因为家贫,初小读完的陈云便辍学在家。后在舅母亲戚的资助下,陈云才到青浦县乙种商业学校学习珠算和记账的,他在很短时间里就基本掌握了珠算,还初步学会写账,这为陈云日后从事经济工作打下了基础。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
@@ -188,10 +194,16 @@ const ChenyunPlace2 = () => {
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_07.mp3")}
>
这里有一幅陈云提写的颜安小学,请问陈云在这所学校读过书吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_08.mp3")}
>
是的,陈云在青浦县乙种商业学校学习了一个多月后,又因经济困难不得不辍学回家。多亏当地知识分子、颜安小学校长杜枢,他免费保荐陈云入颜安小学高小部读书。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
+8 -2
View File
@@ -176,10 +176,16 @@ const ChenyunPlace3 = () => {
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_09.mp3")}
>
陈云14岁就去了上海商务印书馆当学徒,他主要负责哪些工作呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_10.mp3")}
>
从颜安小学毕业后,陈云进入了商务印书馆当学徒,开始在柜台接待顾客,打珠算、写信封、练包装、理货物、修文具。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
+17 -5
View File
@@ -7,7 +7,7 @@ import {
GuideSteps,
GuideStepsItem,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import { useChenyunStore } from "@/store/chenyunStore";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
@@ -165,10 +165,16 @@ const ChenyunPlace4 = () => {
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_11.mp3")}
>
这组史料看起来陈云前往中央苏区了,他在中央苏区主要负责哪些工作呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_12.mp3")}
>
1932年3月,陈云担任中共临时中央政治局常委,兼任全国总工会党团书记。1933年1月,陈云离开上海前往中央苏区首府瑞金,继续参加中共中央和全国总工会的领导工作。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
@@ -189,10 +195,16 @@ const ChenyunPlace4 = () => {
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_13.mp3")}
>
苏区的经济工作一定不简单,陈云是如何开展工作的呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_14.mp3")}
>
中央苏区时期的经济斗争,是陈云经济工作能力的“淬炼场”。陈云分析了“左”倾经济政策的表现、危害以及原因。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
+8 -2
View File
@@ -155,10 +155,16 @@ const ChenyunPlace5 = () => {
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_15.mp3")}
>
小沪,延安时期因为国民党的封锁,陕甘宁边区经济困难。陈云转战陕甘宁边区主持财经工作,他一定遇到了很多经济难题吧。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_16.mp3")}
>
1944年新春伊始,陈云开始担任西北财经办事处副主任,工作转到财经方面,这是陈云以主要精力从事领导经济工作的开始。当时,陕甘宁边区在国民党严密封锁下,财经状况严峻,边区物价猛涨,老百姓日常生活必需的布匹和粮食十分缺乏。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
+9 -3
View File
@@ -7,7 +7,7 @@ import {
GuideSteps,
GuideStepsItem,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import { useChenyunStore } from "@/store/chenyunStore";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
@@ -134,10 +134,16 @@ const ChenyunPlace6 = () => {
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_17.mp3")}
>
这是中央人民政府院财政经济委员会的印章,到这个时候陈云已经从地方到中央工作了吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_18.mp3")}
>
是的,新中国刚成立时,中国共产党接管的是一个民穷财尽、物价飞涨的烂摊子。面对财政经济上的严重困难,陈云在1949年受命担任新成立的中央财政经济委员会(简称中财委)主任,主持领导全国财政经济工作。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
+16 -4
View File
@@ -261,10 +261,16 @@ const ChenyunPlace7 = () => {
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_19.mp3")}
>
那"银元之战"是如何取得胜利的呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_20.mp3")}
>
由于通货膨胀,国民党发行的法币、金圆券变成了一堆废纸。当时坊间流传"大街过三道,物价跳三跳"。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
@@ -332,10 +338,16 @@ const ChenyunPlace7 = () => {
highlightSelectors={[]}
zIndex={10011}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_21.mp3")}
>
那“两白一黑”战役是如何打赢的呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_22.mp3")}
>
“银元之战”的胜利并没有遏制住投机者的嚣张气焰,物价稳定仅十几天后,投机者就由银元投机转向“两白一黑”的投机,即:棉纱、大米和煤炭,引发了又一次全国性涨价狂潮。
</GuideStepsItem>
</GuideSteps>
+17 -5
View File
@@ -7,7 +7,7 @@ import {
GuideSteps,
GuideStepsItem,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import { useChenyunStore } from "@/store/chenyunStore";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
@@ -170,10 +170,16 @@ const ChenyunPlace8 = () => {
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_25.mp3")}
>
您讲的"统购统销"是不是和这里我们看到的粮票有直接关系啊?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_26.mp3")}
>
不错,随着大规模经济建设的展开,全国粮、棉、油等主要农产品严重紧缺,供求矛盾尖锐。陈云经过调查研究,反复比较,权衡利弊得失,终于下决心在农村征购,在城市配售的解决粮食问题的办法,也就是后来所说的"统购统销"。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
@@ -192,10 +198,16 @@ const ChenyunPlace8 = () => {
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_23.mp3")}
>
打赢“银元之战”“两白一黑”战役的过程真的是彰显了中国共产党治理经济的卓越能力!这里有一份“一五”计划报告的手稿,陈云在“一五”计划的编制中做出了什么贡献吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_24.mp3")}
>
一九五四年,陈云的主要工作之一,就是主持编制第一个五年计划纲要。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
+4 -1
View File
@@ -157,7 +157,10 @@ const ChenyunPlace9 = () => {
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_27.mp3")}
>
我们已经沿着陈云的足迹,从练塘水乡走到改革开放的前沿,读懂了他"适合老百姓的要求,做了一点事"的初心。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
@@ -4,6 +4,7 @@ import { AnimatePresence, motion } from "framer-motion";
import { cn, getStaticImage } from "@/lib/utils";
import { Image } from "@/components";
import { useScale } from "@/contexts/ScaleContext";
import { useAudioManager } from "@/hooks/useAudioManager";
import ClueContent from "./ClueContent";
import { ClueData } from "./types";
@@ -18,12 +19,19 @@ export interface ClueOverlayProps {
children?: ReactNode;
/** 额外的自定义类 */
className?: string;
/** 线索对应的音频地址(可选) */
audioSrc?: string;
/** 出现时是否自动播放音频(默认 true) */
autoPlayAudio?: boolean;
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string;
}
/**
* 线索展示框组件
* - 中间内容区域可自定义(文本或其他内容)
* - 使用背景资源:components/layout/纸(地图和音频类型除外)
* - 支持音频自动播放,与全局/分组音频互斥
*/
const ClueOverlay = ({
visible = true,
@@ -31,6 +39,9 @@ const ClueOverlay = ({
clue,
children,
className,
audioSrc,
autoPlayAudio = true,
audioChannelId,
}: ClueOverlayProps) => {
const maskSrc = getStaticImage("components/蒙版遮罩");
const backSrc = getStaticImage("components/关闭");
@@ -40,6 +51,13 @@ const ClueOverlay = ({
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
const [mounted, setMounted] = useState(false);
// 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥
const { currentSrc, play, stop } = useAudioManager(
audioChannelId
? { mode: "channel", channelId: audioChannelId }
: { mode: "global" }
);
// 判断是否需要显示纸张背景(地图、音频和对话类型不显示纸张背景)
const shouldShowPaperBg =
clue?.type !== "map" && clue?.type !== "audio" && clue?.type !== "dialogue";
@@ -49,8 +67,28 @@ const ClueOverlay = ({
setMounted(true);
}, []);
// 线索显示时自动播放音频
useEffect(() => {
if (!mounted) return;
if (visible && autoPlayAudio && audioSrc) {
play(audioSrc);
}
}, [visible, mounted, autoPlayAudio, audioSrc, play]);
// 关闭线索时停止音频(仅当当前播放的是该线索的 audioSrc)
useEffect(() => {
if (!mounted) return;
if (!visible && audioSrc && currentSrc === audioSrc) {
stop();
}
}, [visible, mounted, audioSrc, currentSrc, stop]);
const handleClose = (e: MouseEvent<HTMLImageElement>) => {
e.stopPropagation();
// 关闭时停止音频
if (audioSrc && currentSrc === audioSrc) {
stop();
}
onClose?.();
};
@@ -26,6 +26,10 @@ export interface AudioClueData {
text: string;
/** 录音图片资源路径 */
audioImage: string;
/** 录音音频资源路径 */
audioSrc?: string;
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string;
}
/** 对话线索数据 */
@@ -1,91 +0,0 @@
import type { ReactNode } from "react";
import { getPublicPath } from "@/lib/utils";
import type { ClueData } from "@/components/layout";
// starName 固定为 chenyun
export const starName = "chenyun";
// 欢迎语文案(使用 StudyGuideModal 展示)
export const WELCOME_TEXT = `同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1949,上海。
你将扎进“银元之战”的旋涡,踏过“米棉之战”的浪潮,以陈云同志特派员的身份体验陈云如何以智破局,稳稳托住这座城的经济天平。时空门已开,准备好入戏了吗?`;
// 视频配置
export const VIDEO_CONFIG = {
yinyuan: {
src: getPublicPath("video/chenyun/chenyun_15.mp4"),
narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说"共产党是军事一百分,政治八十分,财经打零分。"上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的"银元之战。你将化身陈云同志的特派员,直面这场'银元之战'。"`,
alert: {
title: '第一幕:"银元之战"--人民币的阵地争夺战',
subtitle: "上海目前出现大肆贩卖银元的情况,请你了解情况!",
},
},
mimian: {
src: getPublicPath("video/chenyun/chenyun_16.mp4"),
narration: "", // 没有旁白
alert: {
title: '第二幕:"米棉之战"--全国一盘棋的战略调度',
subtitle: "上海目前出现米棉物资短缺的情况,请你先了解情况!",
},
},
} as const;
// 线索数据配置
export const CLUE_DATA: Record<string, ClueData & { content?: ReactNode }> = {
// 银元之战 - 线索1
"yinyuan-clue1": {
type: "text",
title: "线索一",
content: `沪市银元价格连日暴涨,
5月27日一块银元值100人民币,
6月3日涨到720元,
6月4日涨到1100元。`,
},
// 银元之战 - 线索2
"yinyuan-clue2": {
type: "text",
title: "线索二",
content: (
<div className="flex flex-col items-start text-left gap-2 px-14">
<div className="mb-8">
南京路的先施、永安、大新、新新等四大公司率先拒收人民币,甚至用银元标价。
</div>
<div>其他商户纷纷仿效,人民币在市场交易中地位下降。</div>
</div>
),
},
// 米棉之战 - 投机商情报
"mimian-intelligence": {
type: "text",
title: "投机商情报",
content: `申新财团囤积棉纱3万件,
扬言"控制纱布就能控制上海"。`,
},
// 米棉之战 - 全国物资分布
"mimian-distribution": {
type: "map",
mapImage: "chenyun/reflection/全国物资分布",
},
// 米棉之战 - 对话线索
"mimian-dialogue": {
type: "dialogue",
dialogues: [
{
character: "群众1",
avatar: "chenyun/reflection/群众1",
text: "米价一天涨三次,家里快断粮了",
},
{
character: "群众2",
avatar: "chenyun/reflection/群众2",
text: "钱庄都在囤媒,冬天没法取暖",
},
],
},
// 银元之战 - 电话线索
"yinyuan-phone": {
type: "audio",
text: `人民币进不了上海市场,问题很严重。必须让人民币站稳脚跟。`,
audioImage: "chenyun/reflection/录音",
},
};
@@ -4,7 +4,7 @@
*/
import type { ReactNode } from "react";
import { getPublicPath } from "@/lib/utils";
import type { ClueData } from "@/components/layout";
import type { ClueData } from "../clue/types";
// 米棉之战视频配置
export const MIMIAN_VIDEO_CONFIG = {
@@ -19,7 +19,7 @@ export const MIMIAN_VIDEO_CONFIG = {
// 米棉之战线索数据配置
export const MIMIAN_CLUE_DATA: Record<
string,
ClueData & { content?: ReactNode }
ClueData & { content?: ReactNode; audioSrc?: string }
> = {
// 米棉之战 - 投机商情报
"mimian-intelligence": {
@@ -7,7 +7,6 @@
export const starName = "chenyun";
// 欢迎语文案(使用 StudyGuideModal 展示)
export const WELCOME_TEXT = `同学你好,欢迎来到跨越时空-体悟学习模块!
我们即将跨越时空,穿越到"银元之战"和"米棉之战"两个沉浸式场景,来感受陈云同志在1949年的上海是如何应对当时混乱的经济秩序的。`;
export const WELCOME_TEXT = `同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1949年,上海。
你将扎进“银元之战”的旋涡,踏过“米棉之战”的浪潮,以陈云同志特派员的身份体验陈云如何以智破局,稳稳托住这座城的经济天平。时空门已开,准备好入戏了吗?`;
@@ -4,7 +4,7 @@
*/
import { type ReactNode } from "react";
import { getPublicPath } from "@/lib/utils";
import type { ClueData } from "@/components/layout";
import type { ClueData } from "../clue/types";
// 银元之战视频配置
export const YINYUAN_VIDEO_CONFIG = {
@@ -19,7 +19,7 @@ export const YINYUAN_VIDEO_CONFIG = {
// 银元之战线索数据配置
export const YINYUAN_CLUE_DATA: Record<
string,
ClueData & { content?: ReactNode }
ClueData & { content?: ReactNode; audioSrc?: string }
> = {
// 银元之战 - 线索1
"yinyuan-clue1": {
@@ -48,5 +48,6 @@ export const YINYUAN_CLUE_DATA: Record<
type: "audio",
text: `人民币进不了上海市场,问题很严重。必须让人民币站稳脚跟。`,
audioImage: "chenyun/reflection/录音",
audioSrc: getPublicPath("audio/chenyun/mp3_chenyun_30.mp3"),
},
};
+22 -6
View File
@@ -5,19 +5,25 @@
import { Image, StudyGuideModal } from "@/components";
import ErrorAlert from "@/components/layout/alert/error-alert";
import TitleAlert from "@/components/layout/alert/title-alert";
import ClueOverlay from "@/components/layout/clue";
import type { ClueData } from "@/components/layout";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import ClueOverlay from "@/pages/chenyun/reflection/clue";
import type { ClueData } from "./clue/types";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { WELCOME_TEXT, starName } from "./constants";
import {
WELCOME_TEXT,
starName,
YINYUAN_VIDEO_CONFIG,
MIMIAN_VIDEO_CONFIG,
CLUE_DATA,
} from "./constants";
import { useClueStates } from "./shared/useClueStates";
import { VideoHandler } from "./shared/VideoHandler";
import { YinyuanBattle } from "./yinyuan/YinyuanBattle";
import { MimianBattle } from "./mimian/MimianBattle";
import type { FileType } from "./shared/types";
import { YINYUAN_VIDEO_CONFIG } from "./constants/yinyuan";
import { MIMIAN_VIDEO_CONFIG } from "./constants/mimian";
// import { } from "./constants/yinyuan";
// import { } from "./constants/mimian";
import { useChenyunStore } from "@/store/chenyunStore";
const ChenyunReflection = () => {
@@ -81,6 +87,9 @@ const ChenyunReflection = () => {
// ========== 银元之战和米棉之战状态(从 store 获取) ==========
const { isYinyuanSolved, isMimianSolved } = useChenyunStore();
//线索音频
const [clueAudioSrc, setClueAudioSrc] = useState("");
// ========== 事件处理函数 ==========
/**
@@ -173,8 +182,13 @@ const ChenyunReflection = () => {
* @param clueKey 线索键名
*/
const handleClueClick = (clue: ClueData, clueKey: string) => {
console.log(clueKey);
setCurrentClue(clue);
setCurrentClueKey(clueKey);
// 从合并后的 CLUE_DATA 中直接查找 audioSrc
const audioSrc =
(CLUE_DATA[clueKey] as { audioSrc?: string })?.audioSrc || "";
setClueAudioSrc(audioSrc);
setShowClueOverlay(true);
};
@@ -259,6 +273,7 @@ const ChenyunReflection = () => {
onContinue={() => {
setShowWelcomeGuide(false);
}}
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_28.mp3")}
>
{WELCOME_TEXT}
</StudyGuideModal>
@@ -326,6 +341,7 @@ const ChenyunReflection = () => {
visible={showClueOverlay}
clue={currentClue}
onClose={handleClueOverlayClose}
audioSrc={clueAudioSrc}
/>
)}
</div>
@@ -11,7 +11,7 @@ import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
import { usePuzzleStore } from "@/store/puzzleStore";
import { starName } from "../constants";
import type { FileType } from "../shared/types";
import type { ClueData } from "@/components/layout";
import type { ClueData } from "../clue/types";
export interface MimianBattleProps {
/** 当前展开的文件 */
@@ -85,7 +85,7 @@ export const MimianBattle = ({
state.setShowMimianQuiz2(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
`恭喜你,选择正确!结合当时市场情况,陈云同志在会上做出了"全国一盘棋"的指示。全国统筹调运粮食、棉花、煤炭填补缺口,筑牢物资基础。关键节点集中抛售,让跟风囤货的投机商无力承接,因亏本和高利贷纷纷破产,快速平抑物价、稳固人民币地位。`
`结合当时市场情况,陈云同志在会上做出了"全国一盘棋"的指示。全国统筹调运粮食、棉花、煤炭填补缺口,筑牢物资基础。关键节点集中抛售,让跟风囤货的投机商无力承接,因亏本和高利贷纷纷破产,快速平抑物价、稳固人民币地位。`
);
}
// 选错时,错误信息已经在题目面板中通过 onWrongSelect 显示,不需要额外的 SuccessAlert
@@ -125,15 +125,7 @@ export const MimianBattle = ({
if (isCorrect) {
state.setShowMimianQuiz3(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"恭喜你,选择正确!地方配合,调运按时推进。"
);
} else {
state.setShowMimianQuiz3(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"很抱歉,你选错了!地方抵触,调运延误多天。请你再思考一下吧!"
);
state.setMimianSuccessAlertContent("地方配合,调运按时推进。");
}
};
@@ -155,15 +147,13 @@ export const MimianBattle = ({
if (isCorrect) {
state.setShowMimianQuiz4(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"恭喜你,选择正确!棉农支持,调运量足额完成。"
);
state.setMimianSuccessAlertContent("棉农支持,调运量足额完成。");
} else {
state.setShowMimianQuiz4(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"很抱歉,你选错了!棉农抵触,调运量减少。请你再思考一下吧!"
);
// state.setShowMimianQuiz4(false);
// state.setShowMimianSuccessAlert(true);
// state.setMimianSuccessAlertContent(
// "很抱歉,你选错了!棉农抵触,调运量减少。请你再思考一下吧!"
// );
}
};
@@ -197,13 +187,7 @@ export const MimianBattle = ({
state.setShowMimianQuiz5(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"恭喜你,选择正确!这契合米棉之战史实,多地同步大量抛售,让借高利贷囤货的投机商措手不及。连续抛售使其无力承接,只得亏本抛售,最终纷纷破产,快速稳住物价。"
);
} else {
state.setShowMimianQuiz5(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"很抱歉,你选错了!投机商此前曾吃下政府平价抛售物资获利。逐步降价加舆论引导难让其信服,他们会持续囤货,政府储备会被消耗,反而让投机商掌控节奏,无法遏制物价上涨。请你再思考一下吧!"
"这契合米棉之战史实,多地同步大量抛售,让借高利贷囤货的投机商措手不及。连续抛售使其无力承接,只得亏本抛售,最终纷纷破产,快速稳住物价。"
);
}
};
@@ -6,7 +6,7 @@ import { Image } from "@/components";
import { cn, getStaticImage } from "@/lib/utils";
import { ClueItem } from "../shared/ClueItem";
import type { FileType } from "../shared/types";
import type { ClueData } from "@/components/layout";
import type { ClueData } from "../clue/types";
import { MIMIAN_CLUE_DATA } from "../constants/mimian";
export interface MimianCluesProps {
@@ -127,4 +127,3 @@ export const MimianClues = ({
</>
);
};
@@ -2,9 +2,9 @@
* 米棉之战题目组件
* 包含单选题1-5、旁白1-3、成功提示框、结束语
*/
import { Narration, SingleChoiceQuizPanel } from "@/components";
import { Narration, SingleChoiceQuizPanel, EndingOverlay } from "@/components";
import SuccessAlert from "@/components/layout/alert/success-alert";
import { EndingOverlay } from "../shared/EndingOverlay";
import { getPublicPath } from "@/lib/utils";
export interface MimianQuizzesProps {
/** 是否显示单选题1 */
@@ -121,6 +121,7 @@ export const MimianQuizzes = ({
visible={showNarration1}
onClose={onNarration1Close}
enableAnimation={false}
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_32.mp3")}
/>
{/* 米棉之战 - 单选题2(图3) */}
@@ -152,7 +153,7 @@ export const MimianQuizzes = ({
/>
{/* 米棉之战 - 错误提示框(单选题2答错后) */}
<SuccessAlert
{/* <SuccessAlert
visible={
showSuccessAlert &&
successAlertContent.includes("很抱歉") &&
@@ -161,7 +162,7 @@ export const MimianQuizzes = ({
title="很抱歉,你选错了!"
content={successAlertContent}
onContinue={onErrorAlertContinue}
/>
/> */}
{/* 米棉之战 - 旁白2(图4) */}
<Narration
@@ -169,6 +170,7 @@ export const MimianQuizzes = ({
visible={showNarration2}
onClose={onNarration2Close}
enableAnimation={false}
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_33.mp3")}
/>
{/* 米棉之战 - 单选题3(图5) */}
@@ -183,7 +185,7 @@ export const MimianQuizzes = ({
},
]}
correctAnswer="B"
errorMessage="地方抵触,调运延误多天"
errorMessage="地方抵触,调运延误多天。请你再思考一下吧!"
onSubmit={onQuiz3Submit}
/>
@@ -194,11 +196,7 @@ export const MimianQuizzes = ({
(successAlertContent.includes("地方配合") ||
successAlertContent.includes("地方抵触"))
}
title={
successAlertContent.includes("恭喜")
? "恭喜你,选择正确!"
: "很抱歉,你选错了!"
}
title={"恭喜你,选择正确!"}
content={successAlertContent}
onContinue={() => {
const isCorrect = successAlertContent.includes("地方配合");
@@ -218,7 +216,7 @@ export const MimianQuizzes = ({
},
]}
correctAnswer="B"
errorMessage="棉农抵触,调运量减少"
errorMessage="棉农抵触,调运量减少。请你再思考一下吧!"
onSubmit={onQuiz4Submit}
/>
@@ -229,11 +227,7 @@ export const MimianQuizzes = ({
(successAlertContent.includes("棉农支持") ||
successAlertContent.includes("棉农抵触"))
}
title={
successAlertContent.includes("恭喜")
? "恭喜你,选择正确!"
: "很抱歉,你选错了!"
}
title={"恭喜你,选择正确!"}
content={successAlertContent}
onContinue={() => {
const isCorrect = successAlertContent.includes("棉农支持");
@@ -247,6 +241,7 @@ export const MimianQuizzes = ({
visible={showNarration3}
onClose={onNarration3Close}
enableAnimation={false}
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_34.mp3")}
/>
{/* 米棉之战 - 单选题5(图8) */}
@@ -276,11 +271,7 @@ export const MimianQuizzes = ({
(successAlertContent.includes("这契合米棉之战史实") ||
successAlertContent.includes("投机商此前曾吃下"))
}
title={
successAlertContent.includes("恭喜")
? "恭喜你,选择正确!"
: "很抱歉,你选错了!"
}
title={"恭喜你,选择正确!"}
content={successAlertContent}
onContinue={() => {
const isEndSuccess =
@@ -296,6 +287,8 @@ export const MimianQuizzes = ({
text={
'从"银元之战"查封投机枢纽、稳固人民币根基,到"米棉之战"全国统筹调运、集中抛售击溃投机势力,这场经济战线的"淮海战役",不仅平定了物价、安定了民心,更印证了"全国一盘棋"的战略智慧与制度优势。当市场秩序重归稳定,新生政权在经济领域站稳脚跟,这份由陈云同志等老一辈革命家铸就的治理经验,也为新时代经济建设埋下了思想伏笔,等待我们在传承中探索新的实践路径。'
}
animationSpeed={200}
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_35.mp3")}
/>
</>
);
@@ -7,6 +7,7 @@ import Alert from "@/components/layout/alert/title-alert";
import type { FileType } from "./types";
import { YINYUAN_VIDEO_CONFIG } from "../constants/yinyuan";
import { MIMIAN_VIDEO_CONFIG } from "../constants/mimian";
import { getPublicPath } from "@/lib/utils";
export interface VideoHandlerProps {
/** 当前播放的视频类型 */
@@ -70,7 +71,9 @@ export const VideoHandler = ({
text={narrationText}
visible={showNarration}
onClose={onNarrationClose}
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_29.mp3")}
enableAnimation={playingVideo === "yinyuan"}
animationSpeed={200}
/>
{/* 提示框 */}
@@ -85,4 +88,3 @@ export const VideoHandler = ({
</>
);
};
@@ -12,7 +12,7 @@ import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
import { usePuzzleStore } from "@/store/puzzleStore";
import { starName } from "../constants";
import type { FileType } from "../shared/types";
import type { ClueData } from "@/components/layout";
import type { ClueData } from "../clue/types";
export interface YinyuanBattleProps {
/** 当前展开的文件 */
@@ -6,7 +6,7 @@ import { Image } from "@/components";
import { cn, getStaticImage } from "@/lib/utils";
import { ClueItem } from "../shared/ClueItem";
import type { FileType } from "../shared/types";
import type { ClueData } from "@/components/layout";
import type { ClueData } from "../clue/types";
import { YINYUAN_CLUE_DATA } from "../constants/yinyuan";
export interface YinyuanCluesProps {
@@ -120,4 +120,3 @@ export const YinyuanClues = ({
</>
);
};
@@ -4,6 +4,7 @@
*/
import { Narration, MultipleChoiceQuizPanel } from "@/components";
import SuccessAlert from "@/components/layout/alert/success-alert";
import { getPublicPath } from "@/lib/utils";
export interface YinyuanQuizzesProps {
/** 是否显示多选题1 */
@@ -70,6 +71,7 @@ export const YinyuanQuizzes = ({
visible={showNarration2}
onClose={onNarration2Close}
enableAnimation={false}
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_31.mp3")}
/>
{/* 多选题2(图3)- 关闭旁白后显示 */}
+57 -60
View File
@@ -36,31 +36,31 @@ const starName = "huangyanpei";
// 锚点数据:根据视频时长调整 time
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: "quiz-1",
time: 96, // 1:36
title: "题目1",
prompt:
"当年黄炎培先生创办中华职业学校,开设的专业都是当时中国最需要的实业领域,分别是( )。",
type: "multiple",
options: [
{ value: "A", label: "铁工", isCorrect: true },
{
value: "B",
label: "自动控制",
},
{ value: "C", label: "木工", isCorrect: true },
{ value: "D", label: "纽扣科", isCorrect: true },
],
explanation:
"自动控制”这一工程理论框架成形于20世纪中叶,70年代实现集中控制,在民国时期,没有相关学科基础、设备与技术条件",
},
// {
// id: "quiz-1",
// time: 96, // 1:36
// title: "题目1",
// prompt:
// "当年黄炎培先生创办中华职业学校,开设的专业都是当时中国最需要的实业领域,分别是( )。",
// type: "multiple",
// options: [
// { value: "A", label: "铁工", isCorrect: true },
// {
// value: "B",
// label: "自动控制",
// },
// { value: "C", label: "木工", isCorrect: true },
// { value: "D", label: "纽扣科", isCorrect: true },
// ],
// explanation:
// "自动控制”这一工程理论框架成形于20世纪中叶,70年代实现集中控制,在民国时期,没有相关学科基础、设备与技术条件",
// },
{
id: "quiz-2",
time: 221, // 3:41
title: "题目2",
prompt:
"黄炎培先生主导设计的课程与现在中职学校强调的理实一体这一理念相通是( )。",
"黄炎培先生主导设计的课程与现在中职学校强调的理实一体这一理念相通是:",
type: "multiple",
options: [
{
@@ -96,9 +96,9 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
{ value: "特斯拉", label: "特斯拉" },
{ value: "比亚迪", label: "比亚迪" },
{ value: "华为", label: "华为" },
{ value: "上海电气", label: "上海电气" },
{ value: "江南造船", label: "江南造船" },
{ value: "光明食品", label: "光明食品" },
{ value: "拼多多", label: "拼多多" },
{ value: "C919飞机", label: "C919飞机" },
],
blanks: [
{ id: "blank-1", label: "国产企业1" },
@@ -108,29 +108,29 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
{ id: "blank-5", label: "国产企业5" },
],
},
{
id: "quiz-4",
time: 467, // 7:47
title: "题目4",
prompt:
'当年现在的中职教育一直在"因时求变",许多专业的新增和关联正确的是( )。',
type: "multiple",
options: [
{ value: "A", label: "汽修专业→学新能源汽车检测与维修", isCorrect: true },
{
value: "B",
label: "会计专业→要掌握大数据财务和智能记账",
isCorrect: true,
},
{ value: "C", label: "国货推销→跨境电商、直播带货", isCorrect: true },
{
value: "D",
label: "美术设计与制作→影像与影视技术",
},
],
explanation:
"美术设计与制作专业侧重于平面设计,而影像与影视技术专业侧重于影视特效后期,两者的专业核心课程涉及的软硬件也有差异",
},
// {
// id: "quiz-4",
// time: 467, // 7:47
// title: "题目4",
// prompt:
// '当年现在的中职教育一直在"因时求变",许多专业的新增和关联正确的是( )。',
// type: "multiple",
// options: [
// { value: "A", label: "汽修专业→学新能源汽车检测与维修", isCorrect: true },
// {
// value: "B",
// label: "会计专业→要掌握大数据财务和智能记账",
// isCorrect: true,
// },
// { value: "C", label: "国货推销→跨境电商、直播带货", isCorrect: true },
// {
// value: "D",
// label: "美术设计与制作→影像与影视技术",
// },
// ],
// explanation:
// "美术设计与制作专业侧重于平面设计,而影像与影视技术专业侧重于影视特效后期,两者的专业核心课程涉及的软硬件也有差异",
// },
];
const HuangyanpeiInternalization = () => {
@@ -203,7 +203,7 @@ const HuangyanpeiInternalization = () => {
if (hasInvalidOption) {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: "特斯拉不是中国国产企业哦~",
[item.id]: "特斯拉为外资企业哦~",
}));
return;
}
@@ -214,9 +214,9 @@ const HuangyanpeiInternalization = () => {
const validAnswers = [
"比亚迪",
"华为",
"上海电气",
"江南造船",
"光明食品",
"拼多多",
"C919飞机",
];
const filledAnswers = val.map((v) => v.optionValue);
const isCorrect = filledAnswers.every((answer) =>
@@ -240,9 +240,9 @@ const HuangyanpeiInternalization = () => {
<DragQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.title}(拖拽)`}
title={`【拖拽】`}
questionPrefix={item.prompt}
questionSuffix=""
questionSuffix="。"
options={item.options.map((opt) => ({
value: opt.value,
label: opt.label,
@@ -292,7 +292,7 @@ const HuangyanpeiInternalization = () => {
<MultipleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.title}(多选题):${item.prompt}`}
title={`【多选】${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
@@ -381,11 +381,9 @@ const HuangyanpeiInternalization = () => {
setPostGuideVisible(true);
if (audioRef.current) {
audioRef.current.currentTime = 0;
audioRef.current
.play()
.catch((error) => {
console.warn("背景音乐播放失败:", error);
});
audioRef.current.play().catch((error) => {
console.warn("背景音乐播放失败:", error);
});
}
}}
/>
@@ -404,13 +402,13 @@ const HuangyanpeiInternalization = () => {
showMask={false}
>
<GuideStepsItem speaker="student">
小沪,刚刚结束的"任之成长记"太有意思了!我变成百年前的川沙少年选课程、做手工、设计国货推销方案,好像真的走进了黄炎培先生的职教世界。
小沪,刚刚结束的"小职成长记"太有意思了!我变成百年前的川沙少年选课程、做手工、设计国货推销方案,好像真的走进了黄炎培先生的职教世界。
</GuideStepsItem>
<GuideStepsItem speaker="student">
但我还有一个疑问:先生当年倡导的"手脑并用、工学结合",放在我们现在的中职学习中,到底有什么样的意义呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这个问题问到点子上啦!黄炎培先生的职教思想,从来不是尘封的历史,而是活在当下的行动指南。今天我们特别邀请到中华职业教育社第十三届理事会常务理事、中华职业学校党总支书记、校长黄玉璟,让她带我们解码先生精神的时代密码!
这个问题问到点子上啦!黄炎培先生的职教思想,从来不是尘封的历史,而是活在当下的行动指南。今天我们特别邀请到中华职业教育社第十三届理事会常务理事、中华职业学校党总支副书记、校长黄玉璟,让她带我们解码先生精神的时代密码!
</GuideStepsItem>
</GuideSteps>
@@ -452,8 +450,7 @@ const HuangyanpeiInternalization = () => {
navigate(`/${moduleName}/${starName}/home`);
}}
>
恭喜你已经顺利完成了黄炎培故居的学习,至此,你已经完成了本课程所有故居的学习,
希望你能有所收获,学习八位名人的优秀品质和精神,在新时代为中华民族伟大复兴发光发热!
恭喜你已经顺利完成了黄炎培故居的学习,请继续前往下一个故居吧!
</StudyGuideModal>
</div>
);
+4 -4
View File
@@ -88,7 +88,7 @@ const HuangyanpeiPlace1 = () => {
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage("/components/place/继续前进")}
src={getStaticImage("components/place/继续前进")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/place2`);
@@ -154,11 +154,11 @@ const HuangyanpeiPlace1 = () => {
onContinue={() => setShowAfterDialogGuide(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
3.所有动态光点的展品全部学习后才能进入下一个场景学习。
</StudyGuideModal>
</div>
);
+2 -2
View File
@@ -32,8 +32,8 @@ const HuangyanpeiPlace3 = () => {
);
const [visited, setVisited] = useState<Set<number>>(new Set());
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: { 2: [1], 3: [1, 2] },
initialEnabled: [3],
dependencies: { 2: [3], 1: [2] },
visited,
});
+7 -8
View File
@@ -31,16 +31,15 @@ const HuangyanpeiPlace4 = () => {
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<
1 | 2 | 3 | 4 | 5 | null
>(null);
const [activeExhibitId, setActiveExhibitId] = useState<1 | 3 | 4 | 5 | null>(
null
);
const [visited, setVisited] = useState<Set<number>>(new Set());
const { isEnabled } = useCursorController({
initialEnabled: [1],
initialEnabled: [3, 5],
dependencies: {
3: [1],
4: [3],
5: [4],
1: [3, 5],
4: [1],
// 2 已注释,不计入顺序
},
visited,
@@ -66,7 +65,7 @@ const HuangyanpeiPlace4 = () => {
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const videoRef = useRef<VideoRef>(null);
const handleOpenExhibit = (index: 1 | 2 | 3 | 4 | 5) => {
const handleOpenExhibit = (index: 1 | 3 | 4 | 5) => {
setActiveExhibitId(index);
if (index === 1) {
setGuide1Step(0);
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import { EndingOverlay } from "@/pages/chenyun/reflection/shared/EndingOverlay";
import { EndingOverlay } from "@/components/overlay/EndingOverlay";
const starName = "huangyanpei";
@@ -0,0 +1,225 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { cn, getStaticImage } from "@/lib/utils";
import { Image } from "@/components";
import { SuccessAlert } from "@/components/layout";
export interface QuizDialogProps {
/** 是否可见 */
visible?: boolean;
/** 关闭回调 */
onClose?: () => void;
/** 完成回调 */
onComplete?: () => void;
}
/**
* 喇叭考题弹窗
*/
const QuizDialog = ({
visible = true,
onClose,
onComplete,
}: QuizDialogProps) => {
const maskSrc = getStaticImage("components/蒙版遮罩");
const backSrc = getStaticImage("components/关闭");
const [mounted, setMounted] = useState(false);
const [successVisible, setSuccessVisible] = useState(false);
// SSR 兼容
useEffect(() => {
setMounted(true);
}, []);
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation();
onClose?.();
},
[onClose]
);
// 关闭成功提示
const handleSuccessContinue = useCallback(() => {
setSuccessVisible(false);
// 关闭提示后,完成并关闭弹窗
onComplete?.();
onClose?.();
}, [onComplete, onClose]);
// 可选话术选项
const options = useMemo(
() => [
{
value: "option1",
label: "加入义勇军(军事训练)",
},
{
value: "option2",
label: "制作军需物资",
},
{
value: "option3",
label: "宣传抗日募捐",
},
],
[]
);
// 样式
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
position: "fixed",
inset: 0,
zIndex: 9999,
pointerEvents: "none",
}),
[]
);
if (!mounted) return null;
const content = (
<AnimatePresence>
{visible && (
<motion.div
key="towel-sales-dialog"
style={overlayStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div
className={cn("absolute inset-0 flex flex-col", "bg-black/70")}
style={{ pointerEvents: "auto" }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex flex-col px-30 py-16 pt-30 text-[#432800]">
<div className="w-full h-full flex flex-col">
{/* 顶部:题目文本和毛巾区域 */}
<div className="flex justify-between gap-10 mb-8">
{/* 题目文本 */}
<div className="flex-1">
<div className="text-5xl font-bold mb-6 -ml-[26px]">
【单选】
</div>
<div className="text-4xl leading-relaxed">
国难当头,黄先生说“职教救国”,中华职校的师生都行动起来了!你要加入哪支爱国队伍?
</div>
</div>
{/* 右侧:毛巾区域 */}
<div className="w-180 flex flex-col items-center">
<Image
src={getStaticImage(
"huangyanpei/reflection/components/题目图"
)}
/>
</div>
</div>
{/* 底部:可选话术区域 - 使用 DragFill */}
<div className="flex-1">
<div className="text-5xl font-bold mb-6">答题区</div>
<div className="grid grid-cols-2 gap-10">
{options.map((option, index) => (
<div
className={cn(
"cursor-pointer px-10 py-10 rounded-2xl border-3 text-3xl font-medium transition-all select-none",
"border-[#79471f] bg-black/20 text-white",
"hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)]"
)}
key={option.value}
onClick={() => {
setSuccessVisible(true);
}}
>
{index + 1}. {option.label}
</div>
))}
</div>
{/* <DragFill
options={options}
blanks={blanks}
value={value}
onChange={handleValueChange}
allowDuplicate={false}
layout="horizontal-options"
optionClassName="grid grid-cols-2 gap-10"
hideLabels={true}
showSubmitButton={false}
className="w-full"
renderOption={(option, isUsed) => {
const index = options.findIndex(
(opt) => opt.value === option.value
);
return (
<div
className={cn(
"px-10 py-10 rounded-2xl border-3 text-3xl font-medium transition-all select-none",
isUsed
? "border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50"
: "border-[#79471f] bg-black/20 text-white cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)]"
)}
>
{index + 1}. {option.label}
</div>
);
}}
renderDragging={(option) => {
return (
<div className="w-[638px] h-[90px] flex items-center justify-center rounded-2xl border-3 text-2xl font-medium border-[#f0be75] bg-black/40 text-white shadow-[0_12px_40px_rgba(240,190,117,0.7)]">
{option.label}
</div>
);
}}
renderBlank={() => {
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
return <div className="hidden" />;
}}
/> */}
</div>
</div>
</div>
{/* 成功提示 */}
<SuccessAlert
content="好样的!不管是上前线还是做后援,都是在用学到的本事报效国家,黄先生一定会为你骄傲!"
visible={successVisible}
onContinue={handleSuccessContinue}
/>
</div>
</motion.div>
)}
</AnimatePresence>
);
return content;
};
export default QuizDialog;
@@ -2,3 +2,5 @@ export { default as QuestionModal } from "./question-modal";
export type { QuestionModalProps } from "./question-modal";
export { BambooPuzzleGame } from "./game";
export { default as QuizDialog } from "./dialog";
+3 -3
View File
@@ -77,9 +77,9 @@ const HuangyanpeiReflectionScene1 = () => {
setShowGuideModal(false);
}}
>
你好呀!现在是1903年,欢迎你进入川沙小学堂,请开启你的求学第一课吧。
{"\n"}
接下来你将在“任之”校长所在的学校学习,根据所知和所学,努力完成校长给你的考验,完成全部任务后,可进入到下一阶段的学习。
同学你好,欢迎来到“跨越时空·体悟”学习模块!{"\n"}
请登陆“时空回溯・亲历时代”体验仓,下一站:1903,川沙小学堂。{"\n"}
你将在“任之”校长所在的学校学习,根据所知和所学,努力完成校长给你的考验,完成全部任务后,可进入到下一阶段的学习。时空门已开,请做好准备迎接考验吧!
</StudyGuideModal>
<Cursor
@@ -231,11 +231,11 @@ const ChoiceDialog1 = ({
}}
/>
<ErrorAlert
{/* <ErrorAlert
visible={showErrorAlert}
text="不对哦~黄先生虽然反对歧视女子、轻视手工,但创办浦东中学的核心是打破“读书只为当官”的旧思想,再选一次吧!"
onClose={() => setShowErrorAlert(false)}
/>
/> */}
</div>
</motion.div>
)}
@@ -115,7 +115,7 @@ const ChoiceDialog2 = ({
onContinue={() => setShowGuide(false)}
verticalAlign="center"
>
在浦东中学,你可以选未来的方向啦!不管选哪个,黄先生都会支持,因为“三百六十行,行行出状元”
在浦东中学,你可以选未来的方向啦!不管选哪个,黄先生都会支持,因为“三百六十行,行行出状元”!
</StudyGuideModal>
{/* 返回按钮 */}
@@ -190,7 +190,8 @@ const ChoiceDialog2 = ({
<SuccessAlert
visible={showSuccessAlert}
content="选得好!不管是做工匠还是做买卖,学好本事就能报效国家,这个选择会影响你在中华职业学校的专业哦"
title="选得好!"
content="不管是做工匠还是做买卖,学好本事就能报效国家,这个选择会影响你在中华职业学校的专业哦!"
onContinue={handleSuccessAlertContinue}
onClose={handleSuccessAlertContinue}
/>
+35 -6
View File
@@ -6,6 +6,7 @@ import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import ViseAssemblyDialog from "./scene3/ViseAssemblyDialog";
import type { VideoRef } from "@/components/video";
import { QuizDialog } from "./components";
const starName = "huangyanpei";
@@ -15,6 +16,9 @@ const HuangyanpeiReflectionScene3 = () => {
moduleName: string;
}>();
//喇叭考题弹窗
const [showQuizDialog, setShowQuizDialog] = useState(false);
const [showGuide1, setShowGuide1] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [showQuiz, setShowQuiz] = useState(false);
@@ -30,11 +34,11 @@ const HuangyanpeiReflectionScene3 = () => {
}
const progressCount = visited.size;
const total = 2;
const total = 3;
const completed = progressCount >= total;
const subtitle = completed
? "探索中华职业学院·立业之本·铁工科(已完成)"
: `探索中华职业学院·立业之本·铁工科(${progressCount}/${total})`;
? "探索中华职业学校·立业之本·铁工科(已完成)"
: `探索中华职业学校·立业之本·铁工科(${progressCount}/${total})`;
const subtitleClassName = completed
? "text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]"
: undefined;
@@ -64,6 +68,15 @@ const HuangyanpeiReflectionScene3 = () => {
}}
/>
<Image
className="absolute left-5 top-5"
src={getStaticImage("huangyanpei/reflection/components/喇叭1")}
button
onClick={() => {
setShowQuizDialog(true);
}}
/>
{/* 第一段引导 */}
<StudyGuideModal
visible={showGuide1}
@@ -73,7 +86,7 @@ const HuangyanpeiReflectionScene3 = () => {
>
终于到中华职业学校啦!这里是中国近代职教的摇篮,"免学费、包分配",还能学真手艺,快来练就一技之长。
{"\n"}
你选了学技术,那就来挑战制作中华职校的"明星产品"——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢
你选了学技术,那就来挑战制作中华职校的"明星产品"——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢!
</StudyGuideModal>
{/* 互动展品光标:组装台钳互动 */}
@@ -120,7 +133,7 @@ const HuangyanpeiReflectionScene3 = () => {
{/* 多选题答题框 */}
<MultipleChoiceQuizPanel
visible={showQuiz}
title="题目1 (多选题) 制作台钳需要哪些工具?(多选)"
title="【多选】 制作台钳需要哪些工具?"
options={[
{ value: "A", label: "A. 锯子" },
{ value: "B", label: "B. 毛笔" },
@@ -128,7 +141,14 @@ const HuangyanpeiReflectionScene3 = () => {
{ value: "D", label: "D. 铁锤" },
]}
correctAnswer={["A", "C", "D"]}
errorMessage="毛笔是写字的哦! 台钳是铁制品,得用锯子、锉刀、铁锤这些工具,再选一次。"
errorMessage={(selectedValues) => {
// 如果选择了B,显示包含"毛笔是写字的哦"的提示
if (selectedValues.includes("B")) {
return "毛笔是写字的哦!台钳是铁制品,得用锯子、锉刀、铁锤这些工具,再选一次吧!";
}
// 其他情况(单选A或C或D,或者AC、CD、AD等组合)
return "台钳是铁制品,得用锯子、锉刀、铁锤这些工具,再选一次吧!";
}}
minSelections={1}
onSubmit={(values, isCorrect) => {
if (isCorrect) {
@@ -194,6 +214,15 @@ const HuangyanpeiReflectionScene3 = () => {
}}
/>
)}
<QuizDialog
visible={showQuizDialog}
onClose={() => setShowQuizDialog(false)}
onComplete={() => {
setShowQuizDialog(false);
setVisited((prev) => new Set(prev).add(3));
}}
/>
</div>
);
};
+26 -7
View File
@@ -6,6 +6,7 @@ import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import TowelSalesDialog from "./scene4/TowelSalesDialog";
import type { VideoRef } from "@/components/video";
import { QuizDialog } from "./components";
const starName = "huangyanpei";
@@ -24,6 +25,9 @@ const HuangyanpeiReflectionScene4 = () => {
const videoRef = useRef<VideoRef>(null);
const [showGuide1, setShowGuide1] = useState(true);
//喇叭考题弹窗
const [showQuizDialog, setShowQuizDialog] = useState(false);
// 处理成功提示的继续和关闭
const handleSuccessAlertContinue = useCallback(() => {
setShowSuccessAlert(false);
@@ -50,7 +54,7 @@ const HuangyanpeiReflectionScene4 = () => {
}
const progressCount = visited.size;
const total = 2;
const total = 3;
const completed = progressCount >= total;
const subtitle = completed
? "探索中华职业学院·立业之本·商科(已完成)"
@@ -75,6 +79,15 @@ const HuangyanpeiReflectionScene4 = () => {
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute left-5 top-5"
src={getStaticImage("huangyanpei/reflection/components/喇叭2")}
button
onClick={() => {
setShowQuizDialog(true);
}}
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
@@ -101,7 +114,7 @@ const HuangyanpeiReflectionScene4 = () => {
{/* 选择题1:中华职校商科的核心教学目标 */}
<MultipleChoiceQuizPanel
visible={showQuiz1}
title={`题目 1 (多选题)\n\n中华职校商科的核心教学目标是什么? (多选)`}
title={`【选题】中华职校商科的核心教学目标是什么? `}
options={[
{
value: "A",
@@ -121,7 +134,7 @@ const HuangyanpeiReflectionScene4 = () => {
},
]}
correctAnswer={["B", "C"]}
errorMessage='不对哦! 黄先生倡导"商科要为民族工商业服务", 商科是要培养诚信经营、助力国货发展的人才, 可不能想着投机发财呀, 再选一次;'
errorMessage="不对哦!黄先生倡导“商科要为民族工商业服务”,商科是要培养诚信经营、助力国货发展的人才,可不能想着投机发财呀,再选一次吧!"
minSelections={1}
onSubmit={(values: string[], isCorrect: boolean) => {
if (isCorrect) {
@@ -130,9 +143,6 @@ const HuangyanpeiReflectionScene4 = () => {
setShowSuccessAlert(true);
}
}}
onError={(_error: string) => {
// 答题错误时的处理(组件内部已显示错误信息)
}}
/>
{/* 答题成功提示 */}
@@ -178,7 +188,7 @@ const HuangyanpeiReflectionScene4 = () => {
>
终于到中华职业学校啦!这里是中国近代职教的摇篮,“免学费、包分配”,还能学真手艺,快来练就一技之长。
{"\n"}
现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢
现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢!
</StudyGuideModal>
{showVideo && (
@@ -195,6 +205,15 @@ const HuangyanpeiReflectionScene4 = () => {
}}
/>
)}
<QuizDialog
visible={showQuizDialog}
onClose={() => setShowQuizDialog(false)}
onComplete={() => {
setShowQuizDialog(false);
setVisited((prev) => new Set(prev).add(3));
}}
/>
</div>
);
};
@@ -209,7 +209,6 @@ const TowelSalesDialog = ({
<div className="flex justify-between gap-10 mb-8">
{/* 题目文本 */}
<div className="flex-1">
<div className="text-5xl font-bold mb-6">题目文本</div>
<div className="text-4xl leading-relaxed">
从以下话术里,挑选3句组成你的推销语 (拖拽至对话框)
</div>
+3 -3
View File
@@ -179,11 +179,11 @@ const LuxunPlace1 = () => {
onContinue={() => setShowAfterDialogGuide(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
3.所有动态光点的展品全部学习后才能进入下一个场景学习。
</StudyGuideModal>
{/* 浏览完展品后的对话 */}
+72 -33
View File
@@ -15,6 +15,7 @@ import { useState, useRef } from "react";
import Cursor from "@/components/cursor";
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
import { useCursorController } from "@/hooks/useCursorController";
// starName 固定为 sunzhongshan
const starName = "sunzhongshan";
@@ -48,6 +49,22 @@ const SunzhongshanPlace1 = () => {
// 已浏览展品的 ID 集合
const [visited, setVisited] = useState<Set<number>>(new Set());
// 展品2窗口关闭的对话状态
const [guide2CloseVisible, setGuide2CloseVisible] = useState(false);
const [guide2CloseStep, setGuide2CloseStep] = useState(0);
// Cursor 顺序控制:1 -> 2 -> 3 -> 4 -> 5
const { isEnabled } = useCursorController({
initialEnabled: [2],
dependencies: {
1: [3],
3: [4],
4: [5],
5: [2],
},
visited,
});
// 光标2的对话状态
const [guide2Visible, setGuide2Visible] = useState(false);
const [guide2Step, setGuide2Step] = useState(0);
@@ -140,30 +157,35 @@ const SunzhongshanPlace1 = () => {
className="absolute left-75 bottom-80"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute left-108 bottom-118"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute left-165 bottom-90"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 光标4 */}
<Cursor
className="absolute right-192 bottom-118"
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
disabled={!isEnabled(4)}
/>
{/* 光标5 */}
<Cursor
className="absolute right-188 bottom-88"
onClick={() => handleOpenExhibit(5)}
read={visited.has(5)}
disabled={!isEnabled(5)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
@@ -188,6 +210,9 @@ const SunzhongshanPlace1 = () => {
if (currentId === 2) {
setGuide2Step(0);
setGuide2Visible(false);
setGuide2CloseStep(0);
setGuide2CloseVisible(true);
} else if (currentId === 5) {
setGuide5Step(0);
setGuide5Visible(false);
@@ -201,6 +226,9 @@ const SunzhongshanPlace1 = () => {
} else if (currentId === 3) {
setGuide3Step(0);
setGuide3Visible(false);
setActiveExhibitId(null);
setShowVideo4Second(true);
return;
} else if (currentId === 1) {
setGuide1Step(0);
setGuide1Visible(false);
@@ -228,8 +256,8 @@ const SunzhongshanPlace1 = () => {
setVisible(true);
}}
>
{`同学你好,欢迎来到走进故居-对话学习模块!
进入孙中山故居纪念馆后,我们一起来了解孙中山同志经历的7个阶段:"立志救国 推翻帝制"、"讨袁护法 捍卫共和"、"与时俱进探索新路"、"抱病北上鞠躬尽瘁"、"革命先驱 永垂不朽"、"思想传承 影响深远"、"精神财富 激励后人";`}
{`同学你好,欢迎来到“走进故居·对话”学习模块!
此刻你正置身于孙中山故居堂屋,接下来请随时光的脚步,一同走进他人生中四段激情燃烧的岁月:立志救国·推翻帝制、讨袁护法·捍卫共和、与时俱进·探索新路、抱病北上·鞠躬尽瘁。做好准备了吗?开始探索这段波澜壮阔的历史征程吧!`}
</StudyGuideModal>
{/* 引导 */}
@@ -242,7 +270,7 @@ const SunzhongshanPlace1 = () => {
setShowAfterDialogGuide(true);
}
}}
showMask={true}
showMask={false}
highlightSelectors={[]}
>
<GuideStepsItem speaker="student">
@@ -259,11 +287,11 @@ const SunzhongshanPlace1 = () => {
onContinue={() => setShowAfterDialogGuide(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,浏览展品后蓝色动态光点会变为已浏览状态;
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
3.所有动态光点的展品全部学习后才能进入下一个场景学习。
</StudyGuideModal>
{/* 光标2的对话 */}
@@ -281,7 +309,7 @@ const SunzhongshanPlace1 = () => {
});
}
}}
showMask={true}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
@@ -291,6 +319,25 @@ const SunzhongshanPlace1 = () => {
</GuideStepsItem>
</GuideSteps>
{/* 展品2窗口关闭的对话 */}
<GuideSteps
visible={guide2CloseVisible}
currentStep={guide2CloseStep}
onStepChange={setGuide2CloseStep}
onVisibleChange={(nextVisible) => {
setGuide2CloseVisible(nextVisible);
}}
showMask={false}
zIndex={10000}
>
<GuideStepsItem speaker="student">
原来孙先生早在青年时期就把国家前途命运放在了心上。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
不仅是关心,他还付诸了实际行动。看看这件展品。
</GuideStepsItem>
</GuideSteps>
{/* 光标5的对话 */}
<GuideSteps
visible={guide5Visible}
@@ -358,9 +405,9 @@ const SunzhongshanPlace1 = () => {
<DragQuizPanel
visible={dragQuiz4Visible}
zIndex={10000}
title="题目 1(拖拽题):"
questionPrefix="三民主义是孙中山所倡导的民主革命纲领,是其民主思想的精髓和高度概括,具体包括("
questionSuffix=")。"
title="【拖拽】"
questionPrefix="三民主义是孙中山所倡导的民主革命纲领,是其民主思想的精髓和高度概括,具体包括"
questionSuffix="。"
options={[
{ value: "民族", label: "民族" },
{ value: "民权", label: "民权" },
@@ -402,11 +449,15 @@ const SunzhongshanPlace1 = () => {
visible={successAlert4Visible}
onContinue={() => {
setSuccessAlert4Visible(false);
setShowVideo4Second(true);
// setShowVideo4Second(true);
setVisited((prev) => {
const next = new Set(prev);
next.add(4);
return next;
});
}}
onClose={() => {
setSuccessAlert4Visible(false);
setShowVideo4Second(true);
}}
/>
@@ -421,12 +472,8 @@ const SunzhongshanPlace1 = () => {
showSkipButton
onEnded={() => {
setShowVideo4Second(false);
// 修改cursor状态
setVisited((prev) => {
const next = new Set(prev);
next.add(4);
return next;
});
setVisited((prev) => new Set([...prev, 3]));
}}
/>
)}
@@ -438,16 +485,8 @@ const SunzhongshanPlace1 = () => {
onStepChange={setGuide3Step}
onVisibleChange={(nextVisible) => {
setGuide3Visible(nextVisible);
if (!nextVisible) {
setVisited((prev) => {
const next = new Set(prev);
next.add(3);
return next;
});
}
}}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="guide">
@@ -460,10 +499,10 @@ const SunzhongshanPlace1 = () => {
孙中山先生领导的辛亥革命推动了中国社会的变革,可是这张《民立报》让我有点不明白,孙先生刚当上临时大总统,为什么又很快宣布"解职"了呀?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
中华民国的建立有个重要背景——"南北议和"。辛亥革命中宣告独立的省份大多集中在中国南方,负隅顽抗的清政府将最后希望寄托在主导北洋军阀势力的袁世凯身上。在列强支持下,袁世凯率军南下...
中华民国的建立有个重要背景——"南北议和"。辛亥革命中宣告独立的省份大多集中在中国南方,负隅顽抗的清政府将最后希望寄托在主导北洋军阀势力的袁世凯身上。在列强支持下,袁世凯率军南下。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
一边以武力威胁革命势力,一边又迎合革命党人急于完成统一的愿望,诱使其开展和议。当时,革命党人力量还不够强。为避免国家陷入前途未卜的内战,孙中山先生发表声明:只要清帝退位,袁世凯赞成共和,就推举他做大总统。
他一边以武力威胁革命势力,一边又迎合革命党人急于完成统一的愿望,诱使其开展和议。当时,革命党人力量还不够强。为避免国家陷入前途未卜的内战,孙中山先生发表声明:只要清帝退位,袁世凯赞成共和,就推举他做大总统。
</GuideStepsItem>
</GuideSteps>
@@ -489,12 +528,12 @@ const SunzhongshanPlace1 = () => {
<MultipleChoiceQuizPanel
visible={quiz1Visible}
zIndex={10000}
title="题目 2(多选题):关于辛亥革命的意义,下列表述正确的有()"
title="【多选】关于辛亥革命的意义,下列表述正确的有:"
options={[
{ value: "1", label: "结束了中国两千多年的君主专制制度" },
{ value: "2", label: "传播了民主共和理念,推动了中华民族思想解放" },
{ value: "3", label: "冲破了封建主义藩篱,打击了帝国主义在华势力" },
{ value: "4", label: "完成了民族独立、人民解放的历史任务" },
{ value: "1", label: "A.结束了中国两千多年的君主专制制度" },
{ value: "2", label: "B.传播了民主共和理念,推动了中华民族思想解放" },
{ value: "3", label: "C.冲破了封建主义藩篱,打击了帝国主义在华势力" },
{ value: "4", label: "D.完成了民族独立、人民解放的历史任务" },
]}
correctAnswer={["1", "2", "3"]}
errorMessage="辛亥革命未能改变旧中国半殖民地半封建的社会性质和中国人民的悲惨境遇,因此也未能完成实现民族独立、人民解放的历史任务。但它拉开了中国完全意义上的近代民族民主革命的序幕。"
+17 -2
View File
@@ -9,6 +9,7 @@ import {
Video,
type VideoRef,
} from "@/components";
import { useCursorController } from "@/hooks/useCursorController";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -32,6 +33,17 @@ const SunzhongshanPlace2 = () => {
);
const [visited, setVisited] = useState<Set<number>>(new Set());
// Cursor 顺序控制:1 -> 2 -> 3 -> 4
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
3: [2],
4: [3],
},
visited: visited,
});
// 光标1的对话状态
const [guide1Visible, setGuide1Visible] = useState(false);
const [guide1Step, setGuide1Step] = useState(0);
@@ -98,24 +110,28 @@ const SunzhongshanPlace2 = () => {
className="absolute left-160 top-114"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2-陆军大元帅时佩带的指挥刀 */}
<Cursor
className="absolute left-235 bottom-95"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3-917.9.10孙中山就任中华民国军政府海陆军大元帅留影 */}
<Cursor
className="absolute left-241.5 top-119"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 光标4-实业计划 */}
<Cursor
className="absolute right-120 bottom-78"
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
disabled={!isEnabled(4)}
/>
{completed && (
@@ -225,7 +241,7 @@ const SunzhongshanPlace2 = () => {
真是一个宏伟的计划,可是在内忧外患的时局下,实现起来一定很艰难吧?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
是的,1913年3月"宋教仁案"将国民党和袁世凯方面的矛盾推向高峰。不愿坐视袁世凯窃取辛亥革命果实后独揽大权,革命党人发动了"二次革命"。可惜革命失败,孙中山先生被迫流亡日本。
是的,1913年3月"宋教仁案"将国民党和袁世凯方面的矛盾推向高峰。不愿坐视袁世凯窃取辛亥革命果实后独揽大权,革命党人发动了"二次革命"。可惜革命失败,孙中山先生被迫流亡海外。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
袁世凯趁机撤销了他"筹办全国铁路全权"的职务,把铁路公司的事权交给了北京政府交通部,公司就这样解散了。
@@ -252,7 +268,6 @@ const SunzhongshanPlace2 = () => {
}}
/>
)}
</div>
);
};
+19 -3
View File
@@ -10,6 +10,7 @@ import {
type VideoRef,
type DragFillResult,
} from "@/components";
import { useCursorController } from "@/hooks/useCursorController";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
@@ -34,6 +35,17 @@ const SunzhongshanPlace3 = () => {
);
const [visited, setVisited] = useState<Set<number>>(new Set());
// Cursor 顺序控制:1 -> 2 -> 3 -> 4
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
3: [2],
4: [3],
},
visited: visited,
});
// 光标1的对话状态
const [guide1Visible, setGuide1Visible] = useState(false);
const [guide1Step, setGuide1Step] = useState(0);
@@ -112,22 +124,26 @@ const SunzhongshanPlace3 = () => {
className="absolute left-198 top-118"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute left-210 top-140"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
<Cursor
className="absolute right-182 bottom-95"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
<Cursor
className="absolute right-158 bottom-95"
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
disabled={!isEnabled(4)}
/>
{completed && (
@@ -246,9 +262,9 @@ const SunzhongshanPlace3 = () => {
<DragQuizPanel
visible={dragQuiz2Visible}
zIndex={10000}
title="题目1 (拖拽题):"
questionPrefix="3. 中国国民党第一次全国代表大会事实上确立了("
questionSuffix=")三大政策。"
title="【拖拽】"
questionPrefix="3. 中国国民党第一次全国代表大会事实上确立了"
questionSuffix="三大政策。"
options={[
{ value: "联俄", label: "联俄" },
{ value: "联共", label: "联共" },
+52 -10
View File
@@ -4,12 +4,13 @@ import {
Title,
Image,
Cursor,
StudyGuideModal,
GuideSteps,
GuideStepsItem,
Video,
type VideoRef,
EndingOverlay,
} from "@/components";
import { useCursorController } from "@/hooks/useCursorController";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -21,8 +22,8 @@ const exhibitSrcMap: Record<1 | 2, string> = {
};
// 光标1的展品描述内容(图2)
const exhibit1Description =
'1924年10月,冯玉祥将军发动"北京政变",推翻了曹锟政府,邀请孙中山先生北上共商国是,希望能实现国家和平统一。孙先生认为"这是一个好机会",于是11月13日从广州出发北上,途经上海、日本、天津等地,一路宣传反帝、反封建主张。孙先生抵达上海时,外滩码头上聚集了前来欢迎的各界代表、群众四千多人。大家关心国家前途命运,拥护孙先生,认同他在《北上宣言》中提出的"废除不平等条约""召开国民会议"等主张,真心希望结束军阀割据,国家实现统一,民族实现独立。';
// const exhibit1Description =
// '1924年10月,冯玉祥将军发动"北京政变",推翻了曹锟政府,邀请孙中山先生北上共商国是,希望能实现国家和平统一。孙先生认为"这是一个好机会",于是11月13日从广州出发北上,途经上海、日本、天津等地,一路宣传反帝、反封建主张。孙先生抵达上海时,外滩码头上聚集了前来欢迎的各界代表、群众四千多人。大家关心国家前途命运,拥护孙先生,认同他在《北上宣言》中提出的"废除不平等条约""召开国民会议"等主张,真心希望结束军阀割据,国家实现统一,民族实现独立。';
const SunzhongshanPlace4 = () => {
const navigate = useNavigate();
@@ -35,14 +36,32 @@ const SunzhongshanPlace4 = () => {
const [initialDialogStep, setInitialDialogStep] = useState(0);
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
// Cursor 顺序控制:1 -> 2
const { isEnabled } = useCursorController({
initialEnabled: [2],
dependencies: {
1: [2],
},
visited: visited,
});
// 光标1的视频状态
const [showVideo1, setShowVideo1] = useState(false);
const video1Ref = useRef<VideoRef>(null);
// 光标1视频播放后的学习引导状态
const [showVideo1Guide, setShowVideo1Guide] = useState(false);
// 光标2的对话状态
const [guide2Visible, setGuide2Visible] = useState(false);
const [guide2Step, setGuide2Step] = useState(0);
const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index);
if (index === 2) {
console.log(index);
setGuide2Step(0);
setGuide2Visible(true);
}
};
const progressCount = visited.size;
@@ -84,11 +103,13 @@ const SunzhongshanPlace4 = () => {
className="absolute right-209 top-128"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute right-150 top-88"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{completed && (
@@ -105,7 +126,7 @@ const SunzhongshanPlace4 = () => {
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
description={activeExhibitId === 2 ? exhibit1Description : undefined}
// description={activeExhibitId === 2 ? exhibit1Description : undefined}
onClose={() => {
const currentId = activeExhibitId;
@@ -144,9 +165,10 @@ const SunzhongshanPlace4 = () => {
/>
)}
{/* 光标1视频播放后的学习引导 */}
<StudyGuideModal
{/* 结束语 */}
<EndingOverlay
visible={showVideo1Guide}
text="通过了解这些展品背后的故事,我们回顾了孙中山先生这位民主革命先驱伟大的一生,不仅看到了孙先生万难不辞、奔波海上的革命足迹,更感受到了他愈挫愈奋、攀登不已的革命精神。下面,让我们跨越时空回到黎明的前夜,与孙先生一起,探寻国家的出路。"
onContinue={() => {
setShowVideo1Guide(false);
// 学习引导关闭后,修改cursor状态
@@ -156,10 +178,7 @@ const SunzhongshanPlace4 = () => {
return next;
});
}}
ailgn="left"
>
通过了解这些展品背后的故事,我们回顾了孙中山先生这位民主革命先驱伟大的一生,不仅看到了孙先生万难不辞、奔波海上的革命足迹,更感受到了他愈挫愈奋、攀登不已的革命精神。下面,让我们跨越时空回到黎明的前夜,与孙先生一起,探寻国家的出路。
</StudyGuideModal>
/>
{/* 关闭学习引导后显示的对话(图1) */}
<GuideSteps
@@ -180,6 +199,29 @@ const SunzhongshanPlace4 = () => {
是的。然而长期操劳国事使得孙中山先生身体抱恙。在生命最后的时间里,他仍在为国奔走,始终未曾停歇。看看这张拍摄于1924年11月17日的照片吧。
</GuideStepsItem>
</GuideSteps>
{/* 光标2的对话 */}
<GuideSteps
visible={guide2Visible}
currentStep={guide2Step}
onStepChange={setGuide2Step}
onVisibleChange={(nextVisible) => {
setGuide2Visible(nextVisible);
}}
showMask={false}
zIndex={10000}
>
<GuideStepsItem speaker="student">这是……外滩?</GuideStepsItem>
<GuideStepsItem speaker="guide">
没错。1924年10月,冯玉祥将军发动“北京政变”,推翻了曹锟政府,邀请孙中山先生北上共商国是,希望能实现国家和平统一。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
孙先生认为“这是一个好机会”,于是11月13日从广州出发北上,途经上海、日本、天津等地,一路宣传反帝、反封建主张。孙先生抵达上海时,外滩码头上聚集了前来欢迎的各界代表、群众四千多人。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
大家关心国家前途命运,拥护孙先生,认同他在《北上宣言》中提出的“废除不平等条约”“召开国民会议”等主张,真心希望结束军阀割据,国家实现统一,民族实现独立。
</GuideStepsItem>
</GuideSteps>
</div>
);
};
+4 -3
View File
@@ -130,9 +130,10 @@ const SunzhongshanReflection = () => {
{/* 学习引导弹窗 */}
<StudyGuideModal visible={showGuide} onContinue={handleGuideClose}>
同学你好,欢迎来到跨越时空 - 体悟学习模块!
{"\n\n"}
本模块包含三个推理小游戏,场景学习顺序不分先后,三个场景全部学习完后方能完成本模块的学习,那么现在就请你开始体验吧。
同学你好,欢迎来到“跨越时空·体悟”学习模块!{"\n"}
请登陆“时空回溯・亲历时代”体验仓,下一站:二十世纪初,上海。{"\n"}
辛亥革命之后,民主共和的观念深入人心。面对中外反动势力,社会各界更广泛地行动起来,以各自的方式争取着民主自由和国家尊严。
请探索周围环境,拨开时局迷雾,与奋进求索的仁人志士们一同探寻中国革命的前路……本模块包含三个推理小游戏,完成三个场景的探索后,可进入到下一阶段的学习。时空门已开,请做好准备迎接考验吧!
</StudyGuideModal>
</div>
);
+3 -3
View File
@@ -433,10 +433,10 @@ const SunzhongshanReflectionScene1 = () => {
onContinue={handleGuideClose}
zIndex={10000}
>
同学你好,欢迎来到二十世纪初的上海。辛亥革命之后,民主共和的观念深入人心。
面对中外反动势力,社会各界更广泛地行动起来,以各自的方式争取着民主自由和国家尊严。
同学你好,欢迎来到二十世纪初的上海。{"\n"}
辛亥革命之后,民主共和的观念深入人心。面对中外反动势力,社会各界更广泛地行动起来,以各自的方式争取着民主自由和国家尊严。
{"\n"}
请探索周围环境,拨开时局迷雾,与奋进求索的仁人志士们一同探寻中国革命的前路。本游戏需要在场景中找出4条线索,并回答正确所有问题后才能完成本游戏探索。
你需要在本场景中找出4条线索,并正确回答所有问题后,才能拨开时局迷雾,进入下一个场景。
</StudyGuideModal>
{/* 题目1:今天的日期是? */}
+4 -4
View File
@@ -474,12 +474,12 @@ const SunzhongshanReflectionScene2 = () => {
{
value: "A",
label:
"A: 回想一年多来,全国已经组织起了大小罢工上百次。虽然取得过一些胜利,但现在工人们的力量毕竟弱小,我们愈是坚定,便愈将面对敌人疯狂的反扑。若为工人们找到其他方面的朋友,或许方可与荷枪实弹的军阀势力相抗衡。",
"A.回想一年多来,全国已经组织起了大小罢工上百次。虽然取得过一些胜利,但现在工人们的力量毕竟弱小,我们愈是坚定,便愈将面对敌人疯狂的反扑。若为工人们找到其他方面的朋友,或许方可与荷枪实弹的军阀势力相抗衡。",
},
{
value: "B",
label:
"B: 回想一年多来,全国已经组织起了大小罢工上百次,多有捷报。此时虽面对一些挫折,但只要工人们始终坚定地团结在一起,敢于牺牲,就一定能战胜内外敌人!",
"B.回想一年多来,全国已经组织起了大小罢工上百次,多有捷报。此时虽面对一些挫折,但只要工人们始终坚定地团结在一起,敢于牺牲,就一定能战胜内外敌人!",
},
]}
correctAnswer="A"
@@ -495,11 +495,11 @@ const SunzhongshanReflectionScene2 = () => {
options={[
{
value: "A",
label: "A: 青帮、洪门等颇有势力的会党",
label: "A.青帮、洪门等颇有势力的会党",
},
{
value: "B",
label: "B: 孙中山先生领导的中国国民党",
label: "B.孙中山先生领导的中国国民党",
},
]}
correctAnswer="B"