feat:黄炎培对话对接中

This commit is contained in:
zhongyidian committed 2026-01-07 21:08:38 +08:00
1 parent 3611a0857a
commit 6ab184b328
125 files changed
+6617 -2391

No files matched your search

+124 -14
View File
@@ -1,14 +1,18 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
type DragFillResult,
LetterToChenyun,
StudyGuideModal,
} from "@/components";
import { getPublicPath, getStaticImage } from "@/lib/utils";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
import { GuideSteps, GuideStepsItem } from "@/components";
type QuizOption = {
value: string;
@@ -35,7 +39,7 @@ const starName = "chenyun";
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: "quiz-1",
time: 12,
time: Math.ceil(5.52 * 60), //5.52分钟
title: "题目1",
prompt: "陈云经济思想的核心内容指的是( )。",
type: "multiple",
@@ -52,7 +56,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
id: "quiz-2",
time: 30,
time: Math.ceil(10.01 * 60),
title: "题目2",
prompt: "陈云说过以下哪些话能着重体现出他的经济思想。",
type: "multiple",
@@ -73,7 +77,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
id: "quiz-3",
time: 48,
time: Math.ceil(16.04 * 60),
title: "题目3",
prompt: "我们新时代的青年可以从陈云同志身上学习到哪些精神",
type: "drag",
@@ -107,7 +111,25 @@ const ChenyunInternalization = () => {
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>(
{}
);
const [visible, setVisible] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const [postGuideVisible, setPostGuideVisible] = useState(false);
const [showLetter, setShowLetter] = useState(false);
const [showStudyGuide, setShowStudyGuide] = useState(false);
const videoRef = useRef<VideoRef>(null);
useEffect(() => {
setVisible(true);
}, []);
// 视频加载完成后自动播放
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
videoRef.current.play().catch((error) => {
console.warn("视频播放失败:", error);
});
}
};
const anchors: VideoAnchor[] = useMemo(
() =>
QUIZ_ANCHORS.map((item) => {
@@ -259,7 +281,12 @@ const ChenyunInternalization = () => {
if (!moduleName) return null;
return (
<div className="relative h-full w-full bg-black">
<div
className="relative h-full w-full bg-black"
style={{
backgroundImage: getBackgroundImage("components/internalization/bg"),
}}
>
{/* 离开按钮 */}
<Image
className="absolute right-8 top-8 z-10"
@@ -268,15 +295,98 @@ const ChenyunInternalization = () => {
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
<Video
src={getPublicPath("video/chenyun/chenyun_neihua.mp4")}
className="h-full w-full"
anchors={anchors}
autoPlay
muted
playsInline
controls
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath("video/chenyun/chenyun_neihua.mp4")}
className="h-full w-full"
anchors={anchors}
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false);
setPostGuideVisible(true);
}}
/>
)}
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={(visible) => {
setVisible(visible);
if (!visible) {
setShowVideo(true);
handleVideoCanPlay();
}
}}
showMask={false}
>
<GuideStepsItem speaker="student">
小沪,跟着你参观陈云纪念馆真是收获满满!看到馆里展示的“米棉之战”史料、全国财经统筹的实物展品,我真切感受到陈云爷爷在建国初期平抑物价、稳定经济的魄力。
</GuideStepsItem>
<GuideStepsItem speaker="student">
他用“全国一盘棋”思路化解危机的案例太震撼了,那你能再跟我讲讲,支撑这些成就的核心经济思想是什么吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
针对你的这个问题,今天我们有幸邀请到了中国中共党史学会理事,全国陈云思想生平研究会常务理事兼副秘书长,中共一大纪念馆陈列研究部主任,研究馆员房中老师。关于你的问题,我想房老师会给你指点迷津!...
</GuideStepsItem>
</GuideSteps>
{/* 视频结束后的引导 */}
<GuideSteps
visible={postGuideVisible}
onVisibleChange={(visible) => {
setPostGuideVisible(visible);
// 引导结束后显示信件
if (!visible) {
setShowLetter(true);
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem speaker="student">
听完房主任的讲解,从陈云爷爷童年成长的水乡故居,到他主持"银元之战""米棉之战"的史料,再到那台称量粮食的天平、握不住的铅笔头,我真切感受到他"不唯上、不唯书、只唯实"的精神,也懂了他"为老百姓做事"的初心...
</GuideStepsItem>
<GuideStepsItem speaker="student">
这些都让我特别受触动!
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你的感悟很深刻!陈云同志用一生诠释了"务实"和"为人民服务"——从破解沿海地区经济困境,到掌管国家工业化蓝图,再到改革开放时力挺轻纺、钢铁建设支援,每一步都扎根实际、心系百姓。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你不妨把这份触动凝练成几句心里话,既总结参观的收获,也想想未来该如何践行这份精神,用自己的行动传承这份初心吧!
</GuideStepsItem>
</GuideSteps>
{/* 信件组件:视频引导结束后弹出,带飞入和放大动画 */}
<LetterToChenyun
visible={showLetter}
onSubmit={(values) => {
// 提交信件的逻辑(values 为多个填空的内容)
console.log("提交信件", values);
setShowLetter(false);
setShowStudyGuide(true);
}}
/>
<StudyGuideModal
visible={showStudyGuide}
onContinue={() => {
setShowStudyGuide(false);
navigate(`/${moduleName}/${starName}/home`);
}}
>
恭喜你已经顺利完成了陈云纪念馆的学习,至此,你已经完成了本课程所有故居的学习,
希望你能有所收获,学习八位名人的优秀品质和精神,在新时代为中华民族伟大复兴发光发热!
{/* 同学你好,欢迎来到走进故居-对话学习模块!{"\n"}
进入陈云故居后,我们一起来了解陈云同志经历的7个阶段{"\n"}
“水乡少年 艰辛成长”、“商务启航 投身革命”、“苏区淬炼 崭露头角”、“延安担当
经济破局”、{"\n"}
“恢复时期 经济奠基”、“建设时期 务实兴邦”、“改革浪潮 誉画实业”; */}
</StudyGuideModal>
</div>
);
};
+54 -16
View File
@@ -5,6 +5,7 @@ import {
Image,
Title,
Overlay,
StudyGuideModal,
} from "@/components";
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
@@ -15,11 +16,11 @@ import Cursor from "@/components/cursor";
const starName = "chenyun";
// 展品 ID 与资源路径映射,ID 用来做唯一标识
const exhibitSrcMap: Record<1 | 2 | 3 | 4, string> = {
const exhibitSrcMap: Record<1 | 2, string> = {
1: "chenyun/place1/场景1展品1",
2: "chenyun/place1/场景1展品2",
3: "chenyun/place1/场景1展品3",
4: "chenyun/place1/场景1展品4",
// 3: "chenyun/place1/场景1展品3",
// 4: "chenyun/place1/场景1展品4",
};
const ChenyunPlace1 = () => {
const navigate = useNavigate();
@@ -27,18 +28,21 @@ const ChenyunPlace1 = () => {
moduleName: string;
}>();
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true);
// 对话结束后的学习引导弹窗
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false);
const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false);
// 学习说明对话框显示状态
const [showIntroDialog, setShowIntroDialog] = useState(false);
// 引导对话显示状态
const [visible, setVisible] = useState(true);
const [visible, setVisible] = useState(false);
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | null>(
null
);
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
// 已浏览展品的 ID 集合
const [visited, setVisited] = useState<Set<number>>(new Set());
const handleOpenExhibit = (index: 1 | 2 | 3 | 4) => {
const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index);
};
@@ -48,11 +52,11 @@ const ChenyunPlace1 = () => {
};
const progressCount = visited.size;
const total = 4;
const total = 2;
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;
@@ -96,17 +100,17 @@ const ChenyunPlace1 = () => {
read={visited.has(2)}
/>
{/* 展品3 */}
<Cursor
{/* <Cursor
className="absolute right-170 bottom-60"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
/>
/> */}
{/* 展品4 */}
<Cursor
{/* <Cursor
className="absolute right-98 top-130 cursor"
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
/>
/> */}
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
@@ -136,6 +140,21 @@ const ChenyunPlace1 = () => {
}}
/>
{/* 学习引导词 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false);
setVisible(true);
}}
>
同学你好,欢迎来到走进故居-对话学习模块!{"\n"}
进入陈云故居后,我们一起来了解陈云同志经历的7个阶段:“水乡少年
艰辛成长”、“商务启航 投身革命”、“苏区淬炼 崭露头角”、“延安担当
经济破局”、“恢复时期 经济奠基”、“建设时期 务实兴邦”、“改革浪潮
擘画实业”;
</StudyGuideModal>
{/* 学习说明对话框 */}
{showIntroDialog && (
<Overlay
@@ -151,7 +170,13 @@ const ChenyunPlace1 = () => {
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={setVisible}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible);
if (!nextVisible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true);
setShowAfterDialogGuide(true);
}
}}
showMask={true}
highlightSelectors={[]}
>
@@ -168,6 +193,19 @@ const ChenyunPlace1 = () => {
父母去世后,由外婆抚养。陈云从小跟舅父廖文光和舅母廖倪氏一起生活。家里靠种田和做小生意勉强维持生计。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+16
View File
@@ -6,6 +6,7 @@ import {
Cursor,
GuideSteps,
GuideStepsItem,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -38,6 +39,8 @@ const ChenyunPlace2 = () => {
const [guideVisible, setGuideVisible] = useState(false);
// GuideSteps 当前步骤
const [guideStep, setGuideStep] = useState(0);
// 学习引导弹窗显示状态
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 1) {
@@ -157,6 +160,19 @@ const ChenyunPlace2 = () => {
陈云在党内以“铁算盘”著称,他在经济工作中能熟练使用算盘,就得益于少年时在青浦县立乙种商业学校的学习。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+16
View File
@@ -7,6 +7,7 @@ import {
GuideSteps,
GuideStepsItem,
Video,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -32,6 +33,8 @@ const ChenyunPlace3 = () => {
const [guideVisible, setGuideVisible] = useState(false);
// GuideSteps 当前步骤
const [guideStep, setGuideStep] = useState(0);
// 学习引导弹窗显示状态
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 1) {
@@ -164,6 +167,19 @@ const ChenyunPlace3 = () => {
陈云的珠算能力在商务印书馆工作期间有了很大提高。由于工作认真负责,三年的学徒期,陈云提前两年就破格到商务印书馆虹口分店(现为1925书局)当店员。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+16
View File
@@ -6,6 +6,7 @@ import {
Cursor,
GuideSteps,
GuideStepsItem,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -32,6 +33,8 @@ const ChenyunPlace4 = () => {
// 展品2的引导对话
const [guideVisible2, setGuideVisible2] = useState(false);
const [guideStep2, setGuideStep2] = useState(0);
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 1) {
@@ -180,6 +183,19 @@ const ChenyunPlace4 = () => {
陈云提议建立合作社,广泛吸收失业工人加入,多听取工人建议、增强企业管理的民主性,从而更好地建设苏区经济。这些做法为他日后主持全国财经工作积累了宝贵经验。
</GuideStepsItem>
</GuideSteps>
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+16
View File
@@ -6,6 +6,7 @@ import {
Cursor,
Video,
Overlay,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -36,6 +37,8 @@ const ChenyunPlace5 = () => {
null
);
const [videoVisible3, setVideoVisible3] = useState(false);
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (index: 1 | 2 | 3) => {
if (index === 3) {
@@ -152,6 +155,19 @@ const ChenyunPlace5 = () => {
setActiveExhibitId(null);
}}
/>
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+16
View File
@@ -6,6 +6,7 @@ import {
Cursor,
GuideSteps,
GuideStepsItem,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -27,6 +28,8 @@ const ChenyunPlace6 = () => {
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null);
const [guideVisible, setGuideVisible] = useState(false);
const [guideStep, setGuideStep] = useState(0);
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = () => {
// 展品1:点击时同时打开展品和引导对话,从第1步开始
@@ -130,6 +133,19 @@ const ChenyunPlace6 = () => {
陈云挂帅后的首仗就是打击投机、平抑物价。通过“银元之战”“两白一黑”战役等几次严峻斗争,只用不到一年时间,就迅速稳定了金融物价。
</GuideStepsItem>
</GuideSteps>
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+33 -7
View File
@@ -10,6 +10,7 @@ import {
type SingleChoiceOption,
type DragFillResult,
QuizPanel,
StudyGuideModal,
} from "@/components";
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
@@ -28,21 +29,24 @@ const exhibitSrcMap: Record<1, string> = {
const QUIZ1_OPTIONS: SingleChoiceOption[] = [
{
value: "A",
label:
"A. 推动了淮海战役的胜利(错误选项,选后提示:淮海战役于1949年1月胜利结束,发生在这次“银元之战”前,不存在因果关系)",
label: "A. 推动了淮海战役的胜利",
},
{
value: "B",
label:
"B. 有利于稳定人民的发行和地位(正确选项,选后提示:“银元之战”属于当时人民政府整顿金融的重要举措,黄金、银元投机不利于人民币币值稳定和流通,因此“银元之战”打击贵属投机活动,有利于稳定人民币的发行和地位)",
label: "B. 有利于稳定人民的发行和地位",
},
{
value: "C",
label:
"C. 巩固了新中国人民政权(错误选项,选后提示:1949年6月新中国政权尚未建立)",
label: "C. 巩固了新中国人民政权",
},
];
// 题目1各选项对应的错误提示(原来写在选项括号中的内容)
const QUIZ1_ERROR_HINTS: Record<string, string> = {
A: "淮海战役于1949年1月胜利结束,发生在这次“银元之战”前,不存在因果关系。",
C: "1949年6月新中国政权尚未建立。",
};
const ChenyunPlace7 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
@@ -74,8 +78,13 @@ const ChenyunPlace7 = () => {
const [successAlertVisible, setSuccessAlertVisible] = useState(false);
// 标记拖拽题是否已完成
const [quiz2Completed, setQuiz2Completed] = useState(false);
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (_index: 1) => {
// 如果本场景学习已完成(拖拽题已答对),不再响应点击,避免重复播放
if (quiz2Completed) return;
// 展品1:点击时显示视频1场景和引导对话1
setVideoVisible1(true);
setVideoPlaying(false); // 视频不自动播放
@@ -271,7 +280,11 @@ const ChenyunPlace7 = () => {
// 显示成功提示
setSuccessAlertVisible(true);
} else {
setQuizError("再想一想,注意题干中的时间和提示信息哦~");
// 使用对应选项的错误提示,如果不存在则使用通用提示
setQuizError(
QUIZ1_ERROR_HINTS[val] ||
"再想一想,注意题干中的时间和提示信息哦~"
);
}
}}
/>
@@ -375,6 +388,19 @@ const ChenyunPlace7 = () => {
/>
</div>
)}
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+16
View File
@@ -6,6 +6,7 @@ import {
Cursor,
GuideSteps,
GuideStepsItem,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -29,6 +30,8 @@ const ChenyunPlace8 = () => {
// 展品2的对话引导
const [guideVisible, setGuideVisible] = useState(false);
const [guideStep, setGuideStep] = useState(0);
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index);
@@ -151,6 +154,19 @@ const ChenyunPlace8 = () => {
陈云说:"只有采用这种办法,才能保证我国人民生活日益增长的需要,才能制止投机活动,保证市场物价的稳定,才能使发展国民经济第一个五年计划得以顺利地进行。"
</GuideStepsItem>
</GuideSteps>
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+20 -1
View File
@@ -7,6 +7,7 @@ import {
Video,
GuideSteps,
GuideStepsItem,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -34,6 +35,8 @@ const ChenyunPlace9 = () => {
// 对话引导状态
const [guideVisible, setGuideVisible] = useState(false);
const [guideStep, setGuideStep] = useState(0);
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (_index: 1) => {
// 展品1:播放视频
@@ -151,7 +154,10 @@ const ChenyunPlace9 = () => {
我们已经沿着陈云的足迹,从练塘水乡走到改革开放的前沿,读懂了他"适合老百姓的要求,做了一点事"的初心。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
接下来,请你跨越时空,走进1949年刚解放时期的上海,参与到当时那场轰轰烈烈的"银元之战"和"米棉之战"之中去,在实践决策中体会,陈云"多谋善断""敢闯务实"的卓越智慧与战略魄力,感受我国集中力量办大事的制度优势如何在经济战场中彰显威力。
接下来,请你跨越时空,走进1949年刚解放时期的上海,参与到当时那场轰轰烈烈的"银元之战"和"米棉之战"之中去,在实践决策中体会,陈云"多谋善断""敢闯务实"的卓越智慧与战略魄力,
</GuideStepsItem>
<GuideStepsItem speaker="guide">
感受我国集中力量办大事的制度优势如何在经济战场中彰显威力。
</GuideStepsItem>
</GuideSteps>
@@ -170,6 +176,19 @@ const ChenyunPlace9 = () => {
setActiveExhibitId(null);
}}
/>
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,91 @@
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/录音",
},
};
@@ -0,0 +1,28 @@
/**
* 配置统一导出
* 保持向后兼容,导出所有配置
*/
import { starName, WELCOME_TEXT } from "./shared";
import { YINYUAN_VIDEO_CONFIG, YINYUAN_CLUE_DATA } from "./yinyuan";
import { MIMIAN_VIDEO_CONFIG, MIMIAN_CLUE_DATA } from "./mimian";
// 导出共享配置
export { starName, WELCOME_TEXT };
// 导出银元之战配置
export { YINYUAN_VIDEO_CONFIG, YINYUAN_CLUE_DATA };
// 导出米棉之战配置
export { MIMIAN_VIDEO_CONFIG, MIMIAN_CLUE_DATA };
// 向后兼容:导出旧的 VIDEO_CONFIG 格式
export const VIDEO_CONFIG = {
yinyuan: YINYUAN_VIDEO_CONFIG,
mimian: MIMIAN_VIDEO_CONFIG,
} as const;
// 向后兼容:导出旧的 CLUE_DATA 格式(合并所有线索)
export const CLUE_DATA = {
...YINYUAN_CLUE_DATA,
...MIMIAN_CLUE_DATA,
} as const;
@@ -0,0 +1,52 @@
/**
* 米棉之战配置
* 包含米棉之战的视频配置和线索数据
*/
import type { ReactNode } from "react";
import { getPublicPath } from "@/lib/utils";
import type { ClueData } from "@/components/layout";
// 米棉之战视频配置
export const MIMIAN_VIDEO_CONFIG = {
src: getPublicPath("video/chenyun/chenyun_16.mp4"),
narration: "", // 没有旁白
alert: {
title: '第二幕:"米棉之战"--全国一盘棋的战略调度',
subtitle: "上海目前出现米棉物资短缺的情况,请你先了解情况!",
},
} as const;
// 米棉之战线索数据配置
export const MIMIAN_CLUE_DATA: Record<
string,
ClueData & { content?: ReactNode }
> = {
// 米棉之战 - 投机商情报
"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: "钱庄都在囤媒,冬天没法取暖",
},
],
},
};
@@ -0,0 +1,13 @@
/**
* 共享配置
* 包含欢迎语、starName 等通用配置
*/
// starName 固定为 chenyun
export const starName = "chenyun";
// 欢迎语文案(使用 StudyGuideModal 展示)
export const WELCOME_TEXT = `同学你好,欢迎来到跨越时空-体悟学习模块!
我们即将跨越时空,穿越到"银元之战"和"米棉之战"两个沉浸式场景,来感受陈云同志在1949年的上海是如何应对当时混乱的经济秩序的。`;
@@ -0,0 +1,52 @@
/**
* 银元之战配置
* 包含银元之战的视频配置和线索数据
*/
import { type ReactNode } from "react";
import { getPublicPath } from "@/lib/utils";
import type { ClueData } from "@/components/layout";
// 银元之战视频配置
export const YINYUAN_VIDEO_CONFIG = {
src: getPublicPath("video/chenyun/chenyun_15.mp4"),
narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说"共产党是军事一百分,政治八十分,财经打零分。"上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的"银元之战。你将化身陈云同志的特派员,直面这场'银元之战'。"`,
alert: {
title: '第一幕:"银元之战"--人民币的阵地争夺战',
subtitle: "上海目前出现大肆贩卖银元的情况,请你了解情况!",
},
} as const;
// 银元之战线索数据配置
export const YINYUAN_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>
),
},
// 银元之战 - 电话线索
"yinyuan-phone": {
type: "audio",
text: `人民币进不了上海市场,问题很严重。必须让人民币站稳脚跟。`,
audioImage: "chenyun/reflection/录音",
},
};
+315
View File
@@ -0,0 +1,315 @@
/**
* 陈云反思页面主入口
* 整合银元之战和米棉之战两个模块
*/
import { Image, StudyGuideModal } from "@/components";
import ErrorAlert from "@/components/layout/alert/error-alert";
import ClueOverlay from "@/components/layout/clue";
import type { ClueData } from "@/components/layout";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { WELCOME_TEXT, starName } 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";
const ChenyunReflection = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
// ========== 顶层状态管理 ==========
/** 当前展开的文件 */
const [expandedFile, setExpandedFile] = useState<FileType | null>(null);
/** 当前播放的视频 */
const [playingVideo, setPlayingVideo] = useState<FileType | null>(null);
/** 视频结束后的旁白文本 */
const [narrationText, setNarrationText] = useState("");
/** 是否显示旁白 */
const [showNarration, setShowNarration] = useState(false);
/** 是否显示提示框 */
const [showAlert, setShowAlert] = useState(false);
/** 提示框配置 */
const [alertConfig, setAlertConfig] = useState({ title: "", subtitle: "" });
/** 当前线索 */
const [currentClue, setCurrentClue] = useState<ClueData | null>(null);
/** 当前线索键名 */
const [currentClueKey, setCurrentClueKey] = useState<string | null>(null);
/** 是否显示线索弹窗 */
const [showClueOverlay, setShowClueOverlay] = useState(false);
/** 欢迎语弹窗(使用 StudyGuideModal 展示入口引导) */
const [showWelcomeGuide, setShowWelcomeGuide] = useState(true);
/** 第一幕银元之战 - 学习引导弹窗 */
const [showYinyuanGuide, setShowYinyuanGuide] = useState(false);
/** 第二幕米棉之战 - 学习引导弹窗 */
const [showMimianGuide, setShowMimianGuide] = useState(false);
/** 米棉之战解锁错误提示 */
const [showMimianLockError, setShowMimianLockError] = useState(false);
// ========== 线索状态管理 ==========
const {
clueStates,
isYinyuanAllViewed,
isMimianAllViewed,
markClueAsViewed,
} = useClueStates();
// ========== 银元之战状态(从 YinyuanBattle 获取) ==========
// 注意:这些状态现在由 YinyuanBattle 内部管理,这里只保留必要的共享状态
const [isYinyuanSolved, setIsYinyuanSolved] = useState(false);
// ========== 事件处理函数 ==========
/**
* 处理文件点击
* @param fileType 文件类型
*/
const handleFileClick = (fileType: FileType) => {
// 如果已经展开,则收起
if (expandedFile === fileType) {
setExpandedFile(null);
return;
}
// 必须先完成银元之战任务,才能解锁米棉之战
if (fileType === "mimian" && !isYinyuanSolved) {
setShowMimianLockError(true);
return;
}
// 播放对应视频
setPlayingVideo(fileType);
};
/**
* 处理视频结束
*/
const handleVideoEnd = () => {
if (!playingVideo) return;
// 银元之战:视频结束后显示旁白
if (playingVideo === "yinyuan") {
setNarrationText(YINYUAN_VIDEO_CONFIG.narration);
setShowNarration(true);
}
// 米棉之战:视频结束后直接显示提示框
else if (playingVideo === "mimian") {
setPlayingVideo(null);
setAlertConfig(MIMIAN_VIDEO_CONFIG.alert);
setShowAlert(true);
}
};
/**
* 处理旁白关闭
*/
const handleNarrationClose = () => {
// 关闭旁白和视频后显示提示框
setShowNarration(false);
// 保存当前的文件类型用于后续展开
const currentFile = playingVideo;
setPlayingVideo(null);
if (currentFile) {
if (currentFile === "yinyuan") {
setAlertConfig(YINYUAN_VIDEO_CONFIG.alert);
} else {
setAlertConfig(MIMIAN_VIDEO_CONFIG.alert);
}
setShowAlert(true);
}
};
/**
* 处理提示框关闭
*/
const handleAlertClose = () => {
// 关闭提示框后展开线索 / 显示学习引导
setShowAlert(false);
// 从 alertConfig 的 title 判断是哪个文件
if (alertConfig.title.includes("银元之战")) {
// 第一幕:"银元之战" 提示结束后,先展示学习引导弹窗
setShowYinyuanGuide(true);
} else if (alertConfig.title.includes("米棉之战")) {
// 第二幕:"米棉之战" 提示结束后,同样先展示学习引导弹窗
setShowMimianGuide(true);
}
};
/**
* 处理线索点击
* @param clue 线索数据
* @param clueKey 线索键名
*/
const handleClueClick = (clue: ClueData, clueKey: string) => {
setCurrentClue(clue);
setCurrentClueKey(clueKey);
setShowClueOverlay(true);
};
/**
* 处理线索弹窗关闭
*/
const handleClueOverlayClose = () => {
setShowClueOverlay(false);
// 标记当前线索为已查看
if (currentClueKey) {
markClueAsViewed(currentClueKey as any);
}
setCurrentClue(null);
setCurrentClueKey(null);
};
/**
* 处理结束(返回首页)
*/
const handleEnd = () => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
}
};
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`chenyun/reflection/底图`),
}}
>
{/* 离开按钮 */}
{expandedFile == null && (
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
}
}}
/>
)}
{/* 桌子位置 */}
<div className="relative w-full top-145 flex">
{/* 银元之战模块 */}
<YinyuanBattle
expandedFile={expandedFile}
isAllViewed={isYinyuanAllViewed}
clueStates={clueStates}
onFileClick={() => handleFileClick("yinyuan")}
onClueClick={handleClueClick}
onPlayVideo={(fileType) => {
setPlayingVideo(fileType);
}}
onSolvedChange={setIsYinyuanSolved}
/>
{/* 米棉之战模块 */}
<MimianBattle
expandedFile={expandedFile}
clueStates={clueStates}
onFileClick={() => handleFileClick("mimian")}
onClueClick={handleClueClick}
isAllViewed={isMimianAllViewed}
onCollapseFile={() => setExpandedFile(null)}
onEnd={handleEnd}
/>
</div>
{/* 拼图盒 */}
<Image
className="absolute left-4 bottom-4"
src={getStaticImage("components/拼图盒")}
/>
{/* 欢迎语层 - 使用 StudyGuideModal,仅承担学习引导/欢迎语 */}
<StudyGuideModal
visible={showWelcomeGuide}
onContinue={() => {
setShowWelcomeGuide(false);
}}
>
{WELCOME_TEXT}
</StudyGuideModal>
{/* 第一幕银元之战 - 学习引导弹窗 */}
<StudyGuideModal
visible={showYinyuanGuide}
onContinue={() => {
setShowYinyuanGuide(false);
// 学习引导结束后再展开银元之战线索
setExpandedFile("yinyuan");
}}
verticalAlign="center"
>
请你分别查阅一份电话录音和两份文件,并根据情报内容做出正确决策。
</StudyGuideModal>
{/* 第二幕米棉之战 - 学习引导弹窗 */}
<StudyGuideModal
visible={showMimianGuide}
onContinue={() => {
setShowMimianGuide(false);
// 学习引导结束后再展开米棉之战线索
setExpandedFile("mimian");
}}
verticalAlign="center"
>
请你分别查阅一个消息记录和两份文件,并根据情报内容做出正确决策。
</StudyGuideModal>
{/* 米棉之战锁定错误提示 - 使用 ErrorAlert 组件 */}
<ErrorAlert
visible={showMimianLockError}
text="同学你好,需要先完成银元之战任务!"
onClose={() => setShowMimianLockError(false)}
onMaskClick={() => setShowMimianLockError(false)}
/>
{/* 视频处理组件 */}
<VideoHandler
playingVideo={playingVideo}
narrationText={narrationText}
showNarration={showNarration}
showAlert={showAlert}
alertConfig={alertConfig}
onVideoEnd={handleVideoEnd}
onNarrationClose={handleNarrationClose}
onAlertClose={handleAlertClose}
/>
{/* 展示线索 */}
{currentClue && (
<ClueOverlay
visible={showClueOverlay}
clue={currentClue}
onClose={handleClueOverlayClose}
/>
)}
</div>
);
};
export default ChenyunReflection;
@@ -0,0 +1,281 @@
/**
* 米棉之战主组件
* 整合米棉之战的所有子组件:文件、线索、题目
*/
import { useEffect } from "react";
import { MimianFile } from "./MimianFile";
import { MimianClues } from "./MimianClues";
import { MimianQuizzes } from "./MimianQuizzes";
import { useMimianState } from "./useMimianState";
import type { FileType } from "../shared/types";
import type { ClueData } from "@/components/layout";
export interface MimianBattleProps {
/** 当前展开的文件 */
expandedFile: FileType | null;
/** 线索状态 */
clueStates: Record<string, boolean>;
/** 文件点击回调 */
onFileClick: () => void;
/** 线索点击回调 */
onClueClick: (clue: ClueData, clueKey: string) => void;
/** 是否所有线索都已查看 */
isAllViewed: boolean;
/** 收起文件回调 */
onCollapseFile: () => void;
/** 结束回调(返回首页) */
onEnd: () => void;
}
/**
* 米棉之战主组件
* 管理米棉之战的所有状态和逻辑
*/
export const MimianBattle = ({
expandedFile,
clueStates,
onFileClick,
onClueClick,
isAllViewed,
onCollapseFile,
onEnd,
}: MimianBattleProps) => {
const state = useMimianState();
// 如果所有米棉之战线索都已查看,自动显示单选题1
useEffect(() => {
if (isAllViewed && !state.showMimianQuiz1) {
state.setShowMimianQuiz1(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAllViewed]);
// 处理单选题1提交
const handleQuiz1Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
state.setShowMimianQuiz1(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent("恭喜你,选择正确!");
}
};
// 处理成功提示框1继续
const handleSuccessAlert1Continue = () => {
state.setShowMimianSuccessAlert(false);
state.setMimianSuccessAlertContent("");
// 显示旁白1(图2)
state.setShowMimianNarration1(true);
};
// 处理旁白1关闭
const handleNarration1Close = () => {
state.setShowMimianNarration1(false);
// 显示单选题2
state.setShowMimianQuiz2(true);
};
// 处理单选题2提交
const handleQuiz2Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
state.setShowMimianQuiz2(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
`恭喜你,选择正确!结合当时市场情况,陈云同志在会上做出了"全国一盘棋"的指示。全国统筹调运粮食、棉花、煤炭填补缺口,筑牢物资基础。关键节点集中抛售,让跟风囤货的投机商无力承接,因亏本和高利贷纷纷破产,快速平抑物价、稳固人民币地位。`
);
} else {
// 根据选项显示不同的错误信息
if (value === "A") {
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"很抱歉,你选错了!当时物价上涨已波及多地区,局部限购无法阻止投机商转移物资炒作。上海米、棉依赖外部供应,本地物资匮乏,限购既不解决缺口,还会加剧民众恐慌,扰乱市场秩序。请你再思考一下吧!"
);
} else if (value === "B") {
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"很抱歉,你选错了!当时国民党滥发货币导致的贬值阴影未消,民众对新货币缺乏信任。此时增发人民币,会使货币供应量远超物资总量,重蹈贬值覆辙,削弱人民币信用,让投机气焰更盛。请你再思考一下吧!"
);
}
}
};
// 处理成功提示框2继续
const handleSuccessAlert2Continue = () => {
state.setShowMimianSuccessAlert(false);
state.setMimianSuccessAlertContent("");
// 显示旁白2(图4)
state.setShowMimianNarration2(true);
};
// 处理错误提示框继续
const handleErrorAlertContinue = () => {
state.setShowMimianSuccessAlert(false);
state.setMimianSuccessAlertContent("");
// 重新显示单选题2
state.setShowMimianQuiz2(true);
};
// 处理旁白2关闭
const handleNarration2Close = () => {
state.setShowMimianNarration2(false);
// 显示单选题3
state.setShowMimianQuiz3(true);
};
// 处理单选题3提交
const handleQuiz3Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
state.setShowMimianQuiz3(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"恭喜你,选择正确!地方配合,调运按时推进。"
);
} else {
state.setShowMimianQuiz3(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"很抱歉,你选错了!地方抵触,调运延误多天。请你再思考一下吧!"
);
}
};
// 处理单选题3成功/错误提示继续
const handleQuiz3AlertContinue = (isCorrect: boolean) => {
state.setShowMimianSuccessAlert(false);
state.setMimianSuccessAlertContent("");
if (isCorrect) {
// 答对后显示单选题4
state.setShowMimianQuiz4(true);
} else {
// 答错后重新显示单选题3
state.setShowMimianQuiz3(true);
}
};
// 处理单选题4提交
const handleQuiz4Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
state.setShowMimianQuiz4(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"恭喜你,选择正确!棉农支持,调运量足额完成。"
);
} else {
state.setShowMimianQuiz4(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"很抱歉,你选错了!棉农抵触,调运量减少。请你再思考一下吧!"
);
}
};
// 处理单选题4成功/错误提示继续
const handleQuiz4AlertContinue = (isCorrect: boolean) => {
state.setShowMimianSuccessAlert(false);
state.setMimianSuccessAlertContent("");
if (isCorrect) {
// 答对后显示旁白3
state.setShowMimianNarration3(true);
} else {
// 答错后重新显示单选题4
state.setShowMimianQuiz4(true);
}
};
// 处理旁白3关闭
const handleNarration3Close = () => {
state.setShowMimianNarration3(false);
// 显示单选题5
state.setShowMimianQuiz5(true);
};
// 处理单选题5提交
const handleQuiz5Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
// 标记米棉之战为已解决,并收起文件
state.setIsMimianSolved(true);
onCollapseFile();
state.setShowMimianQuiz5(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"恭喜你,选择正确!这契合米棉之战史实,多地同步大量抛售,让借高利贷囤货的投机商措手不及。连续抛售使其无力承接,只得亏本抛售,最终纷纷破产,快速稳住物价。"
);
} else {
state.setShowMimianQuiz5(false);
state.setShowMimianSuccessAlert(true);
state.setMimianSuccessAlertContent(
"很抱歉,你选错了!投机商此前曾吃下政府平价抛售物资获利。逐步降价加舆论引导难让其信服,他们会持续囤货,政府储备会被消耗,反而让投机商掌控节奏,无法遏制物价上涨。请你再思考一下吧!"
);
}
};
// 处理单选题5成功/错误提示继续
const handleQuiz5AlertContinue = (isEndSuccess: boolean) => {
state.setShowMimianSuccessAlert(false);
state.setMimianSuccessAlertContent("");
if (isEndSuccess) {
// 成功结束:保持 expandedFile 为 null,1 秒后弹出结束语 StudyGuideModal
setTimeout(() => {
state.setShowMimianNarration5(true);
}, 1000);
} else {
// 答错后重新显示单选题5
state.setShowMimianQuiz5(true);
}
};
// 处理结束语继续
const handleNarration5Continue = () => {
state.setShowMimianNarration5(false);
onEnd();
};
return (
<>
{/* 文件组件 */}
<MimianFile
expandedFile={expandedFile}
isSolved={state.isMimianSolved}
onFileClick={onFileClick}
/>
{/* 线索组件 */}
<MimianClues
expandedFile={expandedFile}
clueStates={clueStates}
onClueClick={onClueClick}
/>
{/* 题目组件 */}
<MimianQuizzes
showQuiz1={state.showMimianQuiz1}
showNarration1={state.showMimianNarration1}
showQuiz2={state.showMimianQuiz2}
showNarration2={state.showMimianNarration2}
showQuiz3={state.showMimianQuiz3}
showQuiz4={state.showMimianQuiz4}
showNarration3={state.showMimianNarration3}
showQuiz5={state.showMimianQuiz5}
showNarration5={state.showMimianNarration5}
showSuccessAlert={state.showMimianSuccessAlert}
successAlertContent={state.mimianSuccessAlertContent}
onQuiz1Submit={handleQuiz1Submit}
onSuccessAlert1Continue={handleSuccessAlert1Continue}
onNarration1Close={handleNarration1Close}
onQuiz2Submit={handleQuiz2Submit}
onSuccessAlert2Continue={handleSuccessAlert2Continue}
onErrorAlertContinue={handleErrorAlertContinue}
onNarration2Close={handleNarration2Close}
onQuiz3Submit={handleQuiz3Submit}
onQuiz3AlertContinue={handleQuiz3AlertContinue}
onQuiz4Submit={handleQuiz4Submit}
onQuiz4AlertContinue={handleQuiz4AlertContinue}
onNarration3Close={handleNarration3Close}
onQuiz5Submit={handleQuiz5Submit}
onQuiz5AlertContinue={handleQuiz5AlertContinue}
onNarration5Continue={handleNarration5Continue}
/>
</>
);
};
@@ -0,0 +1,130 @@
/**
* 米棉之战线索区域组件
* 包含投机商情报、全国物资分布、对话的展示
*/
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 { MIMIAN_CLUE_DATA } from "../constants/mimian";
export interface MimianCluesProps {
/** 当前展开的文件 */
expandedFile: FileType | null;
/** 线索状态 */
clueStates: Record<string, boolean>;
/** 线索点击回调 */
onClueClick: (clue: ClueData, clueKey: string) => void;
}
/**
* 米棉之战线索区域组件
* 显示投机商情报、全国物资分布、对话
*/
export const MimianClues = ({
expandedFile,
clueStates,
onClueClick,
}: MimianCluesProps) => {
const isExpanded = expandedFile === "mimian";
return (
<>
{/* 米棉之战:线索-投机商情报 */}
<div
className={cn([
"absolute left-70 bottom-8 transition-all duration-300 delay-100",
"hover:scale-105 hover:brightness-115",
isExpanded
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none",
])}
>
<ClueItem
clueKey="mimian-intelligence"
imageSrc="chenyun/reflection/投机商情报桌上"
isViewed={clueStates["mimian-intelligence"] || false}
onClick={() => {
const clue = MIMIAN_CLUE_DATA["mimian-intelligence"];
if (clue) {
onClueClick(clue, "mimian-intelligence");
}
}}
viewedMarkOffset={{ left: "4px" }}
/>
</div>
{/* 米棉之战:线索-全国物资分布 */}
<div
className={cn([
"absolute left-135 bottom-8 transition-all duration-300 delay-150",
"hover:scale-105 hover:brightness-115",
isExpanded
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none",
])}
>
<ClueItem
clueKey="mimian-distribution"
imageSrc="chenyun/reflection/全国物资分布桌上"
isViewed={clueStates["mimian-distribution"] || false}
onClick={() => {
const clue = MIMIAN_CLUE_DATA["mimian-distribution"];
if (clue) {
onClueClick(clue, "mimian-distribution");
}
}}
viewedMarkOffset={{ left: "12px" }}
/>
</div>
{/* 米棉之战:消息(对话线索) */}
<div
className={cn([
"absolute left-155 -top-118 transition-all duration-300 delay-200",
"hover:scale-105 hover:brightness-115",
isExpanded
? "opacity-100 translate-y-0"
: "opacity-0 -translate-y-10 pointer-events-none",
])}
>
<div
className="cursor-pointer"
onClick={() => {
const clue = MIMIAN_CLUE_DATA["mimian-dialogue"];
if (clue) {
onClueClick(clue, "mimian-dialogue");
}
}}
>
<Image src={getStaticImage("chenyun/reflection/对话气泡")} button />
{/* 未倾听时显示三个点动画 */}
{!clueStates["mimian-dialogue"] && (
<div className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 flex items-center justify-center">
<div className="animate-dots flex gap-2">
<span className="w-3 h-3 bg-gray-500 rounded-full inline-block" />
<span className="w-3 h-3 bg-gray-500 rounded-full inline-block" />
<span className="w-3 h-3 bg-gray-500 rounded-full inline-block" />
</div>
</div>
)}
{/* 已倾听标记 */}
{clueStates["mimian-dialogue"] && (
<div className="absolute left-1/2 -translate-x-1/2 bottom-16">
<span
className={cn([
"text-[#ae6400] text-3xl font-bold whitespace-nowrap",
"[text-shadow:_-4px_-4px_0_#ffffff,_4px_-4px_0_#ffffff,_-4px_4px_0_#ffffff,_4px_4px_0_#ffffff,_-5px_0_0_#ffffff,_5px_0_0_#ffffff,_0_-5px_0_#ffffff,_0_5px_0_#ffffff]",
])}
>
已倾听
</span>
</div>
)}
</div>
</div>
</>
);
};
@@ -0,0 +1,51 @@
/**
* 米棉之战文件组件
* 包含文件展示
*/
import { Image } from "@/components";
import { cn, getStaticImage } from "@/lib/utils";
import type { FileType } from "../shared/types";
export interface MimianFileProps {
/** 当前展开的文件 */
expandedFile: FileType | null;
/** 是否已解决 */
isSolved: boolean;
/** 文件点击回调 */
onFileClick: () => void;
}
/**
* 米棉之战文件组件
* 显示文件2,包含解决状态
*/
export const MimianFile = ({
expandedFile,
isSolved,
onFileClick,
}: MimianFileProps) => {
return (
<div
className={cn([
"relative left-90 cursor-pointer transition-all duration-500",
expandedFile === null || expandedFile === "mimian"
? "opacity-100 scale-100"
: "opacity-0 scale-75 pointer-events-none",
expandedFile !== "mimian" && "hover:scale-105 hover:brightness-115",
])}
onClick={onFileClick}
>
<Image src={getStaticImage("chenyun/reflection/文件2")} />
<span
className={cn([
"absolute left-0 bottom-1/2",
"w-full bg-black/60 px-4 py-2 rounded-2xl text-center",
"text-white text-3xl font-bold tracking-widest",
])}
>
{isSolved ? "02米棉之战(已解决)" : "02米棉之战(未解决)"}
</span>
</div>
);
};
@@ -0,0 +1,299 @@
/**
* 米棉之战题目组件
* 包含单选题1-5、旁白1-3、成功提示框、结束语
*/
import { Narration, SingleChoiceQuizPanel, StudyGuideModal } from "@/components";
import SuccessAlert from "@/components/layout/alert/success-alert";
export interface MimianQuizzesProps {
/** 是否显示单选题1 */
showQuiz1: boolean;
/** 是否显示旁白1 */
showNarration1: boolean;
/** 是否显示单选题2 */
showQuiz2: boolean;
/** 是否显示旁白2 */
showNarration2: boolean;
/** 是否显示单选题3 */
showQuiz3: boolean;
/** 是否显示单选题4 */
showQuiz4: boolean;
/** 是否显示旁白3 */
showNarration3: boolean;
/** 是否显示单选题5 */
showQuiz5: boolean;
/** 是否显示旁白5(结束语) */
showNarration5: boolean;
/** 是否显示成功提示框 */
showSuccessAlert: boolean;
/** 成功提示框内容 */
successAlertContent: string;
/** 单选题1提交回调 */
onQuiz1Submit: (value: string, isCorrect: boolean) => void;
/** 成功提示框1继续回调 */
onSuccessAlert1Continue: () => void;
/** 旁白1关闭回调 */
onNarration1Close: () => void;
/** 单选题2提交回调 */
onQuiz2Submit: (value: string, isCorrect: boolean) => void;
/** 成功提示框2继续回调 */
onSuccessAlert2Continue: () => void;
/** 错误提示框继续回调 */
onErrorAlertContinue: () => void;
/** 旁白2关闭回调 */
onNarration2Close: () => void;
/** 单选题3提交回调 */
onQuiz3Submit: (value: string, isCorrect: boolean) => void;
/** 单选题3成功/错误提示继续回调 */
onQuiz3AlertContinue: (isCorrect: boolean) => void;
/** 单选题4提交回调 */
onQuiz4Submit: (value: string, isCorrect: boolean) => void;
/** 单选题4成功/错误提示继续回调 */
onQuiz4AlertContinue: (isCorrect: boolean) => void;
/** 旁白3关闭回调 */
onNarration3Close: () => void;
/** 单选题5提交回调 */
onQuiz5Submit: (value: string, isCorrect: boolean) => void;
/** 单选题5成功/错误提示继续回调 */
onQuiz5AlertContinue: (isEndSuccess: boolean) => void;
/** 结束语继续回调 */
onNarration5Continue: () => void;
}
/**
* 米棉之战题目组件
* 显示所有题目、旁白和提示框
*/
export const MimianQuizzes = ({
showQuiz1,
showNarration1,
showQuiz2,
showNarration2,
showQuiz3,
showQuiz4,
showNarration3,
showQuiz5,
showNarration5,
showSuccessAlert,
successAlertContent,
onQuiz1Submit,
onSuccessAlert1Continue,
onNarration1Close,
onQuiz2Submit,
onSuccessAlert2Continue,
onErrorAlertContinue,
onNarration2Close,
onQuiz3Submit,
onQuiz3AlertContinue,
onQuiz4Submit,
onQuiz4AlertContinue,
onNarration3Close,
onQuiz5Submit,
onQuiz5AlertContinue,
onNarration5Continue,
}: MimianQuizzesProps) => {
return (
<>
{/* 米棉之战 - 单选题1(图1) */}
<SingleChoiceQuizPanel
visible={showQuiz1}
title='(单选题)投机商集中冲击"两白一黑"的核心目的是( )。'
options={[
{ value: "A", label: "A. 满足市场需求" },
{ value: "B", label: "B. 扰乱经济秩序、动摇政权" },
]}
correctAnswer="B"
errorMessage="很抱歉,你选错了!请你再思考一下吧!"
onSubmit={onQuiz1Submit}
/>
{/* 米棉之战 - 成功提示框1 */}
<SuccessAlert
visible={
showSuccessAlert && successAlertContent === "恭喜你,选择正确!"
}
title="恭喜你,选择正确!"
onContinue={onSuccessAlert1Continue}
/>
{/* 米棉之战 - 旁白1(图2) */}
<Narration
text="为了打击投机资本势力,面对上海米价、纱价等上涨,又同时影响到整个华东和华北、中南等地的情况。陈云于1949年7月17日率领中财委抵达上海,开展了一系列的举措,7月27日至8月15日,陈云主持召开了华东、华北、华中、东北、西北5个大区的财经会议,商讨解决上海和全国面临的严峻经济形势问题和对策。"
visible={showNarration1}
onClose={onNarration1Close}
enableAnimation={false}
/>
{/* 米棉之战 - 单选题2(图3) */}
<SingleChoiceQuizPanel
visible={showQuiz2}
title="(单选题)你觉得此时解决上海和全国面临的严峻经济形势问题的对策应该是( )。"
options={[
{ value: "A", label: "A. 局部限购" },
{ value: "B", label: "B. 增发货币" },
{ value: "C", label: "C. 全国调运+集中抛售" },
]}
correctAnswer="C"
errorMessage="很抱歉,你选错了!请你再思考一下吧!"
onSubmit={onQuiz2Submit}
/>
{/* 米棉之战 - 成功提示框2(单选题2答对后) */}
<SuccessAlert
visible={
showSuccessAlert && successAlertContent.includes("全国一盘棋")
}
title="恭喜你,选择正确!"
content={successAlertContent}
onContinue={onSuccessAlert2Continue}
/>
{/* 米棉之战 - 错误提示框(单选题2答错后) */}
<SuccessAlert
visible={
showSuccessAlert &&
successAlertContent.includes("很抱歉") &&
!successAlertContent.includes("全国一盘棋")
}
title="很抱歉,你选错了!"
content={successAlertContent}
onContinue={onErrorAlertContinue}
/>
{/* 米棉之战 - 旁白2(图4) */}
<Narration
text={`根据"全国一盘棋"的指示,中央政府通过全国范围内的物资调配体系,从多个区域调运米、棉、煤等关键物资支援上海。可是地方在落实推进过程中碰到了现实问题,作为陈云同志的特派员,你应该如何与地方沟通。`}
visible={showNarration2}
onClose={onNarration2Close}
enableAnimation={false}
/>
{/* 米棉之战 - 单选题3(图5) */}
<SingleChoiceQuizPanel
visible={showQuiz3}
title='(单选题)某地方粮站反馈"本地冬储粮不足,不愿外运"。'
options={[
{ value: "A", label: "A. 强制调运,否则追责" },
{
value: "B",
label: "B. 中央已协调东北补充常州冬储粮,先保障全国物价稳定",
},
]}
correctAnswer="B"
errorMessage="地方抵触,调运延误多天"
onSubmit={onQuiz3Submit}
/>
{/* 米棉之战 - 单选题3成功/错误提示 */}
<SuccessAlert
visible={
showSuccessAlert &&
(successAlertContent.includes("地方配合") ||
successAlertContent.includes("地方抵触"))
}
title={
successAlertContent.includes("恭喜")
? "恭喜你,选择正确!"
: "很抱歉,你选错了!"
}
content={successAlertContent}
onContinue={() => {
const isCorrect = successAlertContent.includes("地方配合");
onQuiz3AlertContinue(isCorrect);
}}
/>
{/* 米棉之战 - 单选题4(图6) */}
<SingleChoiceQuizPanel
visible={showQuiz4}
title="(单选题)某地方棉农担心外运后本地纱厂缺原料。"
options={[
{ value: "A", label: "A. 全国优先上海,本地暂时忍耐" },
{
value: "B",
label: "B. 上海纱厂稳定后,会优先供应本地布匹,实现全国共赢",
},
]}
correctAnswer="B"
errorMessage="棉农抵触,调运量减少"
onSubmit={onQuiz4Submit}
/>
{/* 米棉之战 - 单选题4成功/错误提示 */}
<SuccessAlert
visible={
showSuccessAlert &&
(successAlertContent.includes("棉农支持") ||
successAlertContent.includes("棉农抵触"))
}
title={
successAlertContent.includes("恭喜")
? "恭喜你,选择正确!"
: "很抱歉,你选错了!"
}
content={successAlertContent}
onContinue={() => {
const isCorrect = successAlertContent.includes("棉农支持");
onQuiz4AlertContinue(isCorrect);
}}
/>
{/* 米棉之战 - 旁白3(图7) */}
<Narration
text="在以陈云为首的中财委的统一部署下,人民政府在全国范围内调配粮食、棉布和煤炭等商品,在准备充分后,政府开始行动。11月25日,市场物价处在高峰,进入总攻阶段。"
visible={showNarration3}
onClose={onNarration3Close}
enableAnimation={false}
/>
{/* 米棉之战 - 单选题5(图8) */}
<SingleChoiceQuizPanel
visible={showQuiz5}
title="(单选题)此时,以下哪种举措能够一举获得胜利?"
options={[
{
value: "A",
label:
"A:突然性、连续性大量抛售。在全国各地同时行动,上海、武汉、西安等地国营公司连续降价抛售粮棉,不给投机资本喘息之机",
},
{
value: "B",
label: `B:逐步降价,配合舆论引导,每日小幅降价,并通过报纸宣传政府物资充足,试图"劝退"投机商`,
},
]}
correctAnswer="A"
errorMessage="很抱歉,你选错了!投机商此前曾吃下政府平价抛售物资获利。逐步降价加舆论引导难让其信服,他们会持续囤货,政府储备会被消耗,反而让投机商掌控节奏,无法遏制物价上涨。请你再思考一下吧!"
onSubmit={onQuiz5Submit}
/>
{/* 米棉之战 - 单选题5成功/错误提示 */}
<SuccessAlert
visible={
showSuccessAlert &&
(successAlertContent.includes("这契合米棉之战史实") ||
successAlertContent.includes("投机商此前曾吃下"))
}
title={
successAlertContent.includes("恭喜")
? "恭喜你,选择正确!"
: "很抱歉,你选错了!"
}
content={successAlertContent}
onContinue={() => {
const isEndSuccess =
successAlertContent.includes("这契合米棉之战史实");
onQuiz5AlertContinue(isEndSuccess);
}}
/>
{/* 米棉之战 - 旁白5(结束语)改用 StudyGuideModal 展示 */}
<StudyGuideModal
visible={showNarration5}
onContinue={onNarration5Continue}
>
从"银元之战"查封投机枢纽、稳固人民币根基,到"米棉之战"全国统筹调运、集中抛售击溃投机势力,这场经济战线的"淮海战役",不仅平定了物价、安定了民心,更印证了"全国一盘棋"的战略智慧与制度优势。当市场秩序重归稳定,新生政权在经济领域站稳脚跟,这份由陈云同志等老一辈革命家铸就的治理经验,也为新时代经济建设埋下了思想伏笔,等待我们在传承中探索新的实践路径。
</StudyGuideModal>
</>
);
};
@@ -0,0 +1,81 @@
/**
* 米棉之战状态管理 Hook
* 管理米棉之战相关的所有状态
*/
import { useState } from "react";
/**
* 米棉之战状态管理 Hook
* @returns 米棉之战的所有状态和更新函数
*/
export const useMimianState = () => {
// 单选题1相关状态
const [showMimianQuiz1, setShowMimianQuiz1] = useState(false);
// 旁白1相关状态
const [showMimianNarration1, setShowMimianNarration1] = useState(false);
// 单选题2相关状态
const [showMimianQuiz2, setShowMimianQuiz2] = useState(false);
// 旁白2相关状态
const [showMimianNarration2, setShowMimianNarration2] = useState(false);
// 单选题3相关状态
const [showMimianQuiz3, setShowMimianQuiz3] = useState(false);
// 单选题4相关状态
const [showMimianQuiz4, setShowMimianQuiz4] = useState(false);
// 旁白3相关状态
const [showMimianNarration3, setShowMimianNarration3] = useState(false);
// 单选题5相关状态
const [showMimianQuiz5, setShowMimianQuiz5] = useState(false);
// 旁白5相关状态(结束语)
const [showMimianNarration5, setShowMimianNarration5] = useState(false);
// 米棉之战解决状态
const [isMimianSolved, setIsMimianSolved] = useState(false);
// 米棉之战成功提示框状态
const [showMimianSuccessAlert, setShowMimianSuccessAlert] = useState(false);
const [mimianSuccessAlertContent, setMimianSuccessAlertContent] =
useState("");
return {
// 题目相关状态
showMimianQuiz1,
setShowMimianQuiz1,
showMimianQuiz2,
setShowMimianQuiz2,
showMimianQuiz3,
setShowMimianQuiz3,
showMimianQuiz4,
setShowMimianQuiz4,
showMimianQuiz5,
setShowMimianQuiz5,
// 旁白相关状态
showMimianNarration1,
setShowMimianNarration1,
showMimianNarration2,
setShowMimianNarration2,
showMimianNarration3,
setShowMimianNarration3,
showMimianNarration5,
setShowMimianNarration5,
// 成功提示框状态
showMimianSuccessAlert,
setShowMimianSuccessAlert,
mimianSuccessAlertContent,
setMimianSuccessAlertContent,
// 解决状态
isMimianSolved,
setIsMimianSolved,
};
};
@@ -0,0 +1,86 @@
/**
* 线索项组件
* 可复用的线索展示组件,处理线索的点击、已浏览标记等
*/
import { Image } from "@/components";
import { cn, getStaticImage } from "@/lib/utils";
import type { ClueKey } from "./types";
export interface ClueItemProps {
/** 线索键名 */
clueKey: ClueKey;
/** 线索图片路径 */
imageSrc: string;
/** 是否已浏览 */
isViewed: boolean;
/** 点击回调 */
onClick: () => void;
/** 自定义类名 */
className?: string;
/** 已浏览标记的位置偏移 */
viewedMarkOffset?: { left?: string; top?: string; bottom?: string };
/** 已浏览标记文本,默认为"已浏览" */
viewedText?: string;
/** 线索类型:普通线索或电话线索 */
type?: "normal" | "phone";
}
/**
* 线索项组件
* 显示线索图片、搜索动画(未浏览时)、已浏览标记(已浏览时)
*/
export const ClueItem = ({
clueKey,
imageSrc,
isViewed,
onClick,
className,
viewedMarkOffset = {},
viewedText = "已浏览",
type = "normal",
}: ClueItemProps) => {
// 根据线索类型确定搜索图标的位置
const searchIconPosition =
type === "phone" ? "left-[45%] top-[45%]" : "left-1/2 top-[45%]";
return (
<div className={cn("cursor-pointer", className)} onClick={onClick}>
{/* 线索图片 */}
<Image src={getStaticImage(imageSrc)} />
{/* 搜索图标 - 未浏览时显示 */}
{!isViewed && (
<Image
className={cn(
"absolute pointer-events-none animate-search",
searchIconPosition,
"-translate-x-1/2 -translate-y-1/2"
)}
src={getStaticImage("chenyun/reflection/探索")}
/>
)}
{/* 已浏览标记 */}
{isViewed && (
<div
className="absolute left-1/2 -translate-x-1/2 bottom-0"
style={{
marginLeft: viewedMarkOffset.left || (clueKey === "yinyuan-clue1" || clueKey === "yinyuan-clue2" ? "20px" : clueKey === "mimian-intelligence" ? "4px" : clueKey === "mimian-distribution" ? "12px" : "0"),
bottom: viewedMarkOffset.bottom || (type === "phone" ? "0" : "0"),
}}
>
<span
className={cn([
"text-[#ae6400] text-3xl font-bold",
type === "phone" ? "" : "whitespace-nowrap",
"[text-shadow:_-4px_-4px_0_#ffffff,_4px_-4px_0_#ffffff,_-4px_4px_0_#ffffff,_4px_4px_0_#ffffff,_-5px_0_0_#ffffff,_5px_0_0_#ffffff,_0_-5px_0_#ffffff,_0_5px_0_#ffffff]",
])}
>
{viewedText}
</span>
</div>
)}
</div>
);
};
@@ -0,0 +1,88 @@
/**
* 视频处理组件
* 处理视频播放、旁白、提示框的展示逻辑
*/
import { Video, Narration } from "@/components";
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";
export interface VideoHandlerProps {
/** 当前播放的视频类型 */
playingVideo: FileType | null;
/** 旁白文本 */
narrationText: string;
/** 是否显示旁白 */
showNarration: boolean;
/** 是否显示提示框 */
showAlert: boolean;
/** 提示框配置 */
alertConfig: { title: string; subtitle: string };
/** 视频结束回调 */
onVideoEnd: () => void;
/** 旁白关闭回调 */
onNarrationClose: () => void;
/** 提示框关闭回调 */
onAlertClose: () => void;
}
/**
* 视频处理组件
* 根据 playingVideo 显示对应的视频,处理视频结束后的旁白和提示框
*/
export const VideoHandler = ({
playingVideo,
narrationText,
showNarration,
showAlert,
alertConfig,
onVideoEnd,
onNarrationClose,
onAlertClose,
}: VideoHandlerProps) => {
// 获取当前视频配置
const getVideoConfig = () => {
if (playingVideo === "yinyuan") return YINYUAN_VIDEO_CONFIG;
if (playingVideo === "mimian") return MIMIAN_VIDEO_CONFIG;
return null;
};
const videoConfig = getVideoConfig();
return (
<>
{/* 视频播放层 */}
{playingVideo && videoConfig && (
<div className="absolute inset-0 z-40">
<Video
src={videoConfig.src}
autoPlay
showSkipButton
onEnded={onVideoEnd}
className="w-full h-full"
/>
</div>
)}
{/* 旁白层(仅用于视频后的旁白,不再包含欢迎语) */}
<Narration
text={narrationText}
visible={showNarration}
onClose={onNarrationClose}
enableAnimation={playingVideo === "yinyuan"}
/>
{/* 提示框 */}
{showAlert && (
<Alert
visible={showAlert}
title={alertConfig.title}
subtitle={alertConfig.subtitle}
onClose={onAlertClose}
/>
)}
</>
);
};
@@ -0,0 +1,20 @@
/**
* 共享类型定义
* 定义 reflection 模块中使用的通用类型
*/
/** 文件类型:银元之战或米棉之战 */
export type FileType = "yinyuan" | "mimian";
/** 线索键名类型 */
export type ClueKey =
| "yinyuan-clue1"
| "yinyuan-clue2"
| "yinyuan-phone"
| "mimian-intelligence"
| "mimian-distribution"
| "mimian-dialogue";
/** 线索状态记录 */
export type ClueStates = Record<ClueKey, boolean>;
@@ -0,0 +1,83 @@
/**
* 线索状态管理 Hook
* 管理所有线索的查看状态,并提供计算属性
*/
import { useState, useMemo } from "react";
import type { ClueStates, ClueKey } from "./types";
/**
* 初始化线索状态
*/
const initialClueStates: ClueStates = {
"yinyuan-clue1": false,
"yinyuan-clue2": false,
"yinyuan-phone": false,
"mimian-intelligence": false,
"mimian-distribution": false,
"mimian-dialogue": false,
};
/**
* 线索状态管理 Hook
* @returns 线索状态和计算属性
*/
export const useClueStates = () => {
// 记录每个线索的查看状态
const [clueStates, setClueStates] = useState<ClueStates>(initialClueStates);
/**
* 判断银元之战的所有线索是否都已查看
* 需要查看:线索1、线索2、电话
*/
const isYinyuanAllViewed = useMemo(() => {
return (
clueStates["yinyuan-clue1"] &&
clueStates["yinyuan-clue2"] &&
clueStates["yinyuan-phone"]
);
}, [clueStates]);
/**
* 判断米棉之战的所有线索是否都已查看
* 需要查看:投机商情报、全国物资分布、对话
*/
const isMimianAllViewed = useMemo(() => {
return (
clueStates["mimian-intelligence"] &&
clueStates["mimian-distribution"] &&
clueStates["mimian-dialogue"]
);
}, [clueStates]);
/**
* 标记线索为已查看
* @param clueKey 线索键名
*/
const markClueAsViewed = (clueKey: ClueKey) => {
setClueStates((prev) => ({
...prev,
[clueKey]: true,
}));
};
/**
* 批量更新线索状态
* @param updates 要更新的线索状态
*/
const updateClueStates = (updates: Partial<ClueStates>) => {
setClueStates((prev) => ({
...prev,
...updates,
}));
};
return {
clueStates,
setClueStates,
isYinyuanAllViewed,
isMimianAllViewed,
markClueAsViewed,
updateClueStates,
};
};
@@ -0,0 +1,238 @@
/**
* 银元之战主组件
* 整合银元之战的所有子组件:文件、线索、题目、引导提示
*/
import { useEffect } from "react";
import { YinyuanFile } from "./YinyuanFile";
import { YinyuanClues } from "./YinyuanClues";
import { YinyuanQuizzes } from "./YinyuanQuizzes";
import { YinyuanGuides } from "./YinyuanGuides";
import { useYinyuanState } from "./useYinyuanState";
import type { FileType } from "../shared/types";
import type { ClueData } from "@/components/layout";
export interface YinyuanBattleProps {
/** 当前展开的文件 */
expandedFile: FileType | null;
/** 是否所有线索都已查看 */
isAllViewed: boolean;
/** 线索状态 */
clueStates: Record<string, boolean>;
/** 文件点击回调 */
onFileClick: () => void;
/** 线索点击回调 */
onClueClick: (clue: ClueData, clueKey: string) => void;
/** 播放视频回调 */
onPlayVideo: (fileType: FileType) => void;
/** 银元之战解决状态变化回调 */
onSolvedChange?: (solved: boolean) => void;
}
/**
* 银元之战主组件
* 管理银元之战的所有状态和逻辑
*/
export const YinyuanBattle = ({
expandedFile,
isAllViewed,
clueStates,
onFileClick,
onClueClick,
onPlayVideo,
onSolvedChange,
}: YinyuanBattleProps) => {
const state = useYinyuanState();
// 当银元之战解决状态变化时,通知父组件
useEffect(() => {
if (onSolvedChange) {
onSolvedChange(state.isYinyuanSolved);
}
}, [state.isYinyuanSolved, onSolvedChange]);
// 监听"前往解决"按钮出现的条件,显示第一个提示
useEffect(() => {
const shouldShowGuide1 =
isAllViewed &&
!state.isYinyuanSolved &&
!state.showQuiz1 &&
!state.showNarration2 &&
!state.showQuiz2 &&
!state.showSuccessAlert &&
!state.hasShownGuide1;
if (shouldShowGuide1) {
// 延迟一下,确保按钮已经渲染
const timer = setTimeout(() => {
state.setShowGuide1(true);
state.setHasShownGuide1(true);
}, 300);
return () => clearTimeout(timer);
}
}, [
isAllViewed,
state.isYinyuanSolved,
state.showQuiz1,
state.showNarration2,
state.showQuiz2,
state.showSuccessAlert,
state.hasShownGuide1,
state.setShowGuide1,
state.setHasShownGuide1,
]);
// 监听走势图图标出现的条件,显示第二个提示
useEffect(() => {
const shouldShowGuide2 =
state.isYinyuanSolved &&
state.showTrendChartIcon &&
!state.hasShownGuide2;
if (shouldShowGuide2) {
// 延迟一下,确保走势图已经渲染
const timer = setTimeout(() => {
state.setShowGuide2(true);
state.setHasShownGuide2(true);
}, 300);
return () => clearTimeout(timer);
}
}, [
state.isYinyuanSolved,
state.showTrendChartIcon,
state.hasShownGuide2,
state.setShowGuide2,
state.setHasShownGuide2,
]);
// 监听走势图大图打开的条件,显示第三个提示
useEffect(() => {
const shouldShowGuide3 =
state.showTrendChartDetail && !state.hasShownGuide3;
if (shouldShowGuide3) {
// 延迟一下,确保大图已经打开
const timer = setTimeout(() => {
state.setShowGuide3(true);
state.setHasShownGuide3(true);
}, 300);
return () => clearTimeout(timer);
}
}, [
state.showTrendChartDetail,
state.hasShownGuide3,
state.setShowGuide3,
state.setHasShownGuide3,
]);
// 处理多选题1提交
const handleQuiz1Submit = (values: string[], isCorrect: boolean) => {
if (isCorrect) {
// 关闭多选题1,显示旁白文字(图2)
state.setShowQuiz1(false);
state.setNarration2Text(
`六月五日,上海政府抛出十万银元,力图以银元制服银元,使价格回跌。全市举行了"反对银元投机,保障人民生活"的游行和宣传。上海市军委会主任陈毅在全市集会上警告投机奸商"赶快洗手不干,否则勿谓言之不预"。但这两手收效仍微。十万银元被投机商人一吸而空,六月七日银元每元的价格涨到一千八百元人民币。陈云同志判断:'单纯的经济手段已不足以震慑敌人,必须辅以坚定的行政与法律手段。`
);
state.setShowNarration2(true);
}
};
// 处理旁白2关闭
const handleNarration2Close = () => {
state.setShowNarration2(false);
// 关闭旁白后显示多选题2
state.setShowQuiz2(true);
};
// 处理多选题2提交
const handleQuiz2Submit = (values: string[], isCorrect: boolean) => {
if (isCorrect) {
// 关闭多选题2,显示成功提示框
state.setShowQuiz2(false);
state.setShowSuccessAlert(true);
}
};
// 处理成功提示框继续
const handleSuccessAlertContinue = () => {
// 关闭聚光灯、标记银元之战为已解决、显示走势图
state.setIsYinyuanSolved(true);
state.setShowSuccessAlert(false);
};
// 处理走势图点击
const handleTrendChartClick = () => {
// 关闭第三个提示框
state.setShowGuide3(false);
// 点击走势图:隐藏文件上的走势图图标,播放米棉之战视频
state.setShowTrendChartIcon(false);
state.setShowTrendChartDetail(false);
// 播放米棉之战视频
onPlayVideo("mimian");
};
// 处理走势图大图关闭
const handleTrendChartDetailClose = () => {
// 关闭第三个提示框
state.setShowGuide3(false);
state.setShowTrendChartDetail(false);
};
return (
<>
{/* 文件组件 */}
<YinyuanFile
expandedFile={expandedFile}
isSolved={state.isYinyuanSolved}
showTrendChartIcon={state.showTrendChartIcon}
isAllViewed={isAllViewed}
showQuiz1={state.showQuiz1}
showNarration2={state.showNarration2}
showQuiz2={state.showQuiz2}
showSuccessAlert={state.showSuccessAlert}
onFileClick={onFileClick}
onTrendChartClick={() => {
state.setShowGuide2(false);
state.setShowTrendChartDetail(true);
}}
onSolveClick={() => {
state.setShowGuide1(false);
state.setShowQuiz1(true);
}}
/>
{/* 线索组件 */}
<YinyuanClues
expandedFile={expandedFile}
clueStates={clueStates}
onClueClick={onClueClick}
/>
{/* 题目组件 */}
<YinyuanQuizzes
showQuiz1={state.showQuiz1}
showNarration2={state.showNarration2}
narration2Text={state.narration2Text}
showQuiz2={state.showQuiz2}
showSuccessAlert={state.showSuccessAlert}
onQuiz1Submit={handleQuiz1Submit}
onNarration2Close={handleNarration2Close}
onQuiz2Submit={handleQuiz2Submit}
onSuccessAlertContinue={handleSuccessAlertContinue}
/>
{/* 引导提示组件 */}
<YinyuanGuides
showGuide1={state.showGuide1}
showGuide2={state.showGuide2}
showGuide3={state.showGuide3}
showTrendChartDetail={state.showTrendChartDetail}
onGuide1Close={() => state.setShowGuide1(false)}
onGuide2Close={() => state.setShowGuide2(false)}
onGuide3Close={() => state.setShowGuide3(false)}
onTrendChartDetailClose={handleTrendChartDetailClose}
onTrendChartClick={handleTrendChartClick}
/>
</>
);
};
@@ -0,0 +1,123 @@
/**
* 银元之战线索区域组件
* 包含线索1、线索2、电话的展示
*/
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 { YINYUAN_CLUE_DATA } from "../constants/yinyuan";
export interface YinyuanCluesProps {
/** 当前展开的文件 */
expandedFile: FileType | null;
/** 线索状态 */
clueStates: Record<string, boolean>;
/** 线索点击回调 */
onClueClick: (clue: ClueData, clueKey: string) => void;
}
/**
* 银元之战线索区域组件
* 显示线索1、线索2和电话
*/
export const YinyuanClues = ({
expandedFile,
clueStates,
onClueClick,
}: YinyuanCluesProps) => {
const isExpanded = expandedFile === "yinyuan";
return (
<>
{/* 银元之战:线索1 */}
<div
className={cn([
"absolute left-190 bottom-8 transition-all duration-300 delay-100",
"hover:scale-105 hover:brightness-115",
isExpanded
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none",
])}
>
<ClueItem
clueKey="yinyuan-clue1"
imageSrc="chenyun/reflection/线索1"
isViewed={clueStates["yinyuan-clue1"] || false}
onClick={() => {
const clue = YINYUAN_CLUE_DATA["yinyuan-clue1"];
if (clue) {
onClueClick(clue, "yinyuan-clue1");
}
}}
/>
</div>
{/* 银元之战:线索2 */}
<div
className={cn([
"absolute left-265 bottom-8 transition-all duration-300 delay-150",
"hover:scale-105 hover:brightness-115",
isExpanded
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none",
])}
>
<ClueItem
clueKey="yinyuan-clue2"
imageSrc="chenyun/reflection/线索2"
isViewed={clueStates["yinyuan-clue2"] || false}
onClick={() => {
const clue = YINYUAN_CLUE_DATA["yinyuan-clue2"];
if (clue) {
onClueClick(clue, "yinyuan-clue2");
}
}}
/>
</div>
{/* 电话 - 只有银元之战有 */}
<div className="absolute right-50.5 -top-41">
<div className="relative">
{/* 响铃图标 */}
{!clueStates["yinyuan-phone"] && isExpanded && (
<Image
className="absolute left-[45%] -translate-x-1/2 -top-32 animate-bounce"
src={getStaticImage("chenyun/reflection/电话响")}
/>
)}
{/* 电话 */}
<div
className={cn([
isExpanded ? "cursor-pointer" : "pointer-events-none",
])}
onClick={() => {
if (!isExpanded) return;
const clue = YINYUAN_CLUE_DATA["yinyuan-phone"];
if (clue) {
onClueClick(clue, "yinyuan-phone");
}
}}
>
<Image src={getStaticImage("chenyun/reflection/电话")} button />
</div>
{/* 已接听标记 */}
{clueStates["yinyuan-phone"] && isExpanded && (
<div className="absolute left-[40%] -translate-x-1/2 bottom-0">
<span
className={cn([
"text-[#ae6400] text-3xl font-bold",
"[text-shadow:_-4px_-4px_0_#ffffff,_4px_-4px_0_#ffffff,_-4px_4px_0_#ffffff,_4px_4px_0_#ffffff,_-5px_0_0_#ffffff,_5px_0_0_#ffffff,_0_-5px_0_#ffffff,_0_5px_0_#ffffff]",
])}
>
已接听
</span>
</div>
)}
</div>
</div>
</>
);
};
@@ -0,0 +1,111 @@
/**
* 银元之战文件组件
* 包含文件展示、走势图、前往解决按钮
*/
import { Image } from "@/components";
import { cn, getStaticImage } from "@/lib/utils";
import type { FileType } from "../shared/types";
export interface YinyuanFileProps {
/** 当前展开的文件 */
expandedFile: FileType | null;
/** 是否已解决 */
isSolved: boolean;
/** 是否显示走势图图标 */
showTrendChartIcon: boolean;
/** 是否所有线索都已查看 */
isAllViewed: boolean;
/** 是否显示题目1 */
showQuiz1: boolean;
/** 是否显示旁白2 */
showNarration2: boolean;
/** 是否显示题目2 */
showQuiz2: boolean;
/** 是否显示成功提示框 */
showSuccessAlert: boolean;
/** 文件点击回调 */
onFileClick: () => void;
/** 走势图点击回调 */
onTrendChartClick: () => void;
/** 前往解决按钮点击回调 */
onSolveClick: () => void;
}
/**
* 银元之战文件组件
* 显示文件1,包含解决状态、走势图、前往解决按钮
*/
export const YinyuanFile = ({
expandedFile,
isSolved,
showTrendChartIcon,
isAllViewed,
showQuiz1,
showNarration2,
showQuiz2,
showSuccessAlert,
onFileClick,
onTrendChartClick,
onSolveClick,
}: YinyuanFileProps) => {
return (
<div
id="yinyuan-file"
className={cn([
"relative left-60 cursor-pointer transition-all duration-500",
expandedFile === null || expandedFile === "yinyuan"
? "opacity-100 scale-100"
: "opacity-0 scale-75 pointer-events-none",
expandedFile !== "yinyuan" && "hover:scale-105 hover:brightness-115",
])}
onClick={onFileClick}
>
<Image src={getStaticImage("chenyun/reflection/文件1")} />
<span
className={cn([
"absolute left-0 bottom-1/2 z-10",
"w-full bg-black/60 px-4 py-2 rounded-2xl text-center",
"text-white text-3xl font-bold tracking-widest",
])}
>
{isSolved ? "01银元之战(已解决)" : "01银元之战(未解决)"}
</span>
{/* 走势图 - 银元之战解决后显示,点击后隐藏 */}
{isSolved && showTrendChartIcon && (
<div
className="absolute -top-40 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105 z-2"
onClick={(e) => {
e.stopPropagation();
onTrendChartClick();
}}
>
<Image src={getStaticImage("chenyun/reflection/走势图")} />
</div>
)}
{/* 引导标志 - 只在所有线索查看完后显示-前往解决按钮-去做题 */}
{isAllViewed &&
!isSolved &&
!showQuiz1 &&
!showNarration2 &&
!showQuiz2 &&
!showSuccessAlert && (
<div
className="absolute top-0 w-full h-full"
onClick={(e) => {
e.stopPropagation();
onSolveClick();
}}
>
<Image
className="translate-x-26 -translate-y-2"
src={getStaticImage("chenyun/reflection/点击前往解决")}
button
/>
</div>
)}
</div>
);
};
@@ -0,0 +1,88 @@
/**
* 银元之战引导提示组件
* 包含三个引导提示:前往解决按钮、走势图、走势图大图
*/
import { StudyGuideModal } from "@/components";
import { ExhibitOverlay } from "@/components";
export interface YinyuanGuidesProps {
/** 是否显示引导提示1(前往解决按钮) */
showGuide1: boolean;
/** 是否显示引导提示2(走势图) */
showGuide2: boolean;
/** 是否显示引导提示3(走势图大图) */
showGuide3: boolean;
/** 是否显示走势图大图 */
showTrendChartDetail: boolean;
/** 引导提示1关闭回调 */
onGuide1Close: () => void;
/** 引导提示2关闭回调 */
onGuide2Close: () => void;
/** 引导提示3关闭回调 */
onGuide3Close: () => void;
/** 走势图大图关闭回调 */
onTrendChartDetailClose: () => void;
/** 走势图点击回调 */
onTrendChartClick: () => void;
}
/**
* 银元之战引导提示组件
* 显示三个引导提示和走势图大图
*/
export const YinyuanGuides = ({
showGuide1,
showGuide2,
showGuide3,
showTrendChartDetail,
onGuide1Close,
onGuide2Close,
onGuide3Close,
onTrendChartDetailClose,
onTrendChartClick,
}: YinyuanGuidesProps) => {
return (
<>
{/* 引导提示1:前往解决按钮出现后 */}
<StudyGuideModal
visible={showGuide1}
onContinue={onGuide1Close}
verticalAlign="center"
zIndex={130}
>
请跟随指示点击前往解决按钮
</StudyGuideModal>
{/* 引导提示2:走势图出现后 */}
<StudyGuideModal
visible={showGuide2}
onContinue={onGuide2Close}
verticalAlign="center"
zIndex={130}
>
请点击走势图
</StudyGuideModal>
{/* 引导提示3:走势图大图打开后 */}
<StudyGuideModal
visible={showGuide3}
onContinue={onGuide3Close}
verticalAlign="center"
zIndex={10000}
>
请点击银元价格走势图
</StudyGuideModal>
{/* 走势图大图展示 - 使用 ExhibitOverlay */}
<ExhibitOverlay
visible={showTrendChartDetail}
src="chenyun/reflection/银元价格走势图"
description={`以上海为中心,代表"人民币信用"的绿色区域迅速覆盖全市,并向外辐射。`}
disableDrag={true}
onClick={onTrendChartClick}
onClose={onTrendChartDetailClose}
/>
</>
);
};
@@ -0,0 +1,106 @@
/**
* 银元之战题目组件
* 包含多选题1、多选题2、旁白2
*/
import { Narration, MultipleChoiceQuizPanel } from "@/components";
import SuccessAlert from "@/components/layout/alert/success-alert";
export interface YinyuanQuizzesProps {
/** 是否显示多选题1 */
showQuiz1: boolean;
/** 是否显示旁白2 */
showNarration2: boolean;
/** 旁白2文本 */
narration2Text: string;
/** 是否显示多选题2 */
showQuiz2: boolean;
/** 是否显示成功提示框 */
showSuccessAlert: boolean;
/** 多选题1提交回调 */
onQuiz1Submit: (values: string[], isCorrect: boolean) => void;
/** 旁白2关闭回调 */
onNarration2Close: () => void;
/** 多选题2提交回调 */
onQuiz2Submit: (values: string[], isCorrect: boolean) => void;
/** 成功提示框继续回调 */
onSuccessAlertContinue: () => void;
}
/**
* 银元之战题目组件
* 显示多选题1、多选题2、旁白2和成功提示框
*/
export const YinyuanQuizzes = ({
showQuiz1,
showNarration2,
narration2Text,
showQuiz2,
showSuccessAlert,
onQuiz1Submit,
onNarration2Close,
onQuiz2Submit,
onSuccessAlertContinue,
}: YinyuanQuizzesProps) => {
return (
<>
{/* 多选题1 - 点击引导图标后显示 */}
<MultipleChoiceQuizPanel
visible={showQuiz1}
title="(多选题)面对银元投机狂潮,你认为首要突破口是? ()"
options={[
{ value: "A", label: "A. 政治攻势(警告、约谈)" },
{ value: "B", label: "B. 经济手段(抛售银元,以银元打银元)" },
{ value: "C", label: "C. 舆论动员(宣传人民币信用,发动群众)" },
]}
correctAnswer={["A", "B", "C"]}
errorMessage="单一措施效果有限,请尝试综合施策。"
minSelections={1}
maxSelections={3}
minSelectionsErrorMessage="单一措施效果有限,请尝试综合施策。"
submitText="提交"
onSubmit={onQuiz1Submit}
onError={(error) => {
console.log("错误信息:", error);
}}
/>
{/* 旁白文字2(图2)- 多选题1答对后显示 */}
<Narration
text={narration2Text}
visible={showNarration2}
onClose={onNarration2Close}
enableAnimation={false}
/>
{/* 多选题2(图3)- 关闭旁白后显示 */}
<MultipleChoiceQuizPanel
visible={showQuiz2}
title="(多选题)面对投机商的猖狂反扑,下一步应采取何种决定性措施?"
options={[
{ value: "A", label: "A. 加大银元抛售量,耗尽投机资本。" },
{ value: "B", label: "B. 查封投机核心场所,逮捕首要分子。" },
{ value: "C", label: "C. 颁布法令,严禁银元计价流通。" },
]}
correctAnswer={["A", "B", "C"]}
errorMessage="单一措施效果有限,请尝试综合施策。"
minSelections={1}
maxSelections={3}
minSelectionsErrorMessage="单一措施效果有限,请尝试综合施策。"
submitText="提交"
onSubmit={onQuiz2Submit}
onError={(error) => {
console.log("错误信息:", error);
}}
/>
{/* 成功提示框 - 多选题2答对后显示 */}
<SuccessAlert
visible={showSuccessAlert}
title="恭喜你, 选择正确!"
content="陈云同志的意见是:三管齐下,但核心是查封投机核心场所,逮捕首要分子。擒贼先擒王,打掉指挥部。"
onContinue={onSuccessAlertContinue}
/>
</>
);
};
@@ -0,0 +1,86 @@
/**
* 银元之战状态管理 Hook
* 管理银元之战相关的所有状态
*/
import { useState } from "react";
/**
* 银元之战状态管理 Hook
* @returns 银元之战的所有状态和更新函数
*/
export const useYinyuanState = () => {
// 多选题1相关状态
const [showQuiz1, setShowQuiz1] = useState(false);
// 旁白文字状态(图2)
const [showNarration2, setShowNarration2] = useState(false);
const [narration2Text, setNarration2Text] = useState("");
// 多选题2相关状态
const [showQuiz2, setShowQuiz2] = useState(false);
// 成功提示框状态
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
// 银元之战解决状态
const [isYinyuanSolved, setIsYinyuanSolved] = useState(false);
// 走势图图标显示状态(控制文件上的走势图图标是否显示)
const [showTrendChartIcon, setShowTrendChartIcon] = useState(true);
// 走势图大图显示状态
const [showTrendChartDetail, setShowTrendChartDetail] = useState(false);
// 三个引导提示框状态
const [showGuide1, setShowGuide1] = useState(false); // 前往解决按钮出现后的提示
const [showGuide2, setShowGuide2] = useState(false); // 走势图出现后的提示
const [showGuide3, setShowGuide3] = useState(false); // 走势图大图打开后的提示
// 记录是否已经显示过这些提示(避免重复显示)
const [hasShownGuide1, setHasShownGuide1] = useState(false);
const [hasShownGuide2, setHasShownGuide2] = useState(false);
const [hasShownGuide3, setHasShownGuide3] = useState(false);
return {
// 题目相关状态
showQuiz1,
setShowQuiz1,
showQuiz2,
setShowQuiz2,
// 旁白相关状态
showNarration2,
setShowNarration2,
narration2Text,
setNarration2Text,
// 成功提示框状态
showSuccessAlert,
setShowSuccessAlert,
// 解决状态
isYinyuanSolved,
setIsYinyuanSolved,
// 走势图相关状态
showTrendChartIcon,
setShowTrendChartIcon,
showTrendChartDetail,
setShowTrendChartDetail,
// 引导提示状态
showGuide1,
setShowGuide1,
showGuide2,
setShowGuide2,
showGuide3,
setShowGuide3,
hasShownGuide1,
setHasShownGuide1,
hasShownGuide2,
setHasShownGuide2,
hasShownGuide3,
setHasShownGuide3,
};
};
+406 -15
View File
@@ -1,38 +1,429 @@
import { useEffect, useMemo, useState, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Image } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
type DragFillResult,
LetterToHuangyanpei,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
import { GuideSteps, GuideStepsItem } from "@/components";
type QuizOption = {
value: string;
label: string;
isCorrect?: boolean;
explanation?: string;
};
type QuizAnchor = {
id: string;
time: number;
title: string;
prompt: string;
type: "single" | "multiple" | "drag";
options: QuizOption[];
blanks?: { id: string; label: string }[];
};
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: "自动控制",
explanation:
"这一工程理论框架成形于20世纪中叶,70年代实现集中控制,新中国成立前,没有相关学科基础、设备与技术条件",
},
{ value: "C", label: "木工", isCorrect: true },
{ value: "D", label: "纽扣科", isCorrect: true },
],
},
{
id: "quiz-2",
time: 221, // 3:41
title: "题目2",
prompt:
"黄炎培先生主导设计的课程与现在中职学校强调的理实一体这一理念相通是( )。",
type: "multiple",
options: [
{
value: "A",
label: "上午学机械原理,下午就去车间打铁、造台钳",
isCorrect: true,
},
{
value: "B",
label: "课堂上学记账、推销,课后就去街头卖国货",
isCorrect: true,
},
{
value: "C",
label: "学习四书五经、八股文",
explanation: "黄炎培认为教育不能只谈书本、脱离实际",
},
{
value: "D",
label: '在川沙小学堂采取"半天读书、半天做手工"的学习模式',
isCorrect: true,
},
],
},
{
id: "quiz-3",
time: 370, // 6:10
title: "题目3",
prompt:
'当年黄炎培先生有推广国货的爱国情怀,如今我们的"国货"已经自强自立,请将其中的国产企业/品牌放置到空格处',
type: "drag",
options: [
{ value: "特斯拉", label: "特斯拉" },
{ value: "比亚迪", label: "比亚迪" },
{ value: "华为", label: "华为" },
{ value: "上海电气", label: "上海电气" },
{ value: "光明食品", label: "光明食品" },
{ value: "拼多多", label: "拼多多" },
],
blanks: [
{ id: "blank-1", label: "国产企业1" },
{ id: "blank-2", label: "国产企业2" },
{ id: "blank-3", label: "国产企业3" },
{ id: "blank-4", label: "国产企业4" },
{ 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:
"两个专业为不同技能方向的学习,前者为平面绘画等,后者以视频为主",
},
],
},
];
const HuangyanpeiInternalization = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const { moduleName } = useParams<{ moduleName: string }>();
const [activeSuccess, setActiveSuccess] = useState<string | null>(null);
const [successContent, setSuccessContent] = useState<string>("");
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(
null
);
const [dragQuizValues, setDragQuizValues] = useState<
Record<string, DragFillResult[]>
>({});
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>(
{}
);
const [visible, setVisible] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const [postGuideVisible, setPostGuideVisible] = useState(false);
const [showLetter, setShowLetter] = useState(false);
const [showStudyGuide, setShowStudyGuide] = useState(false);
const videoRef = useRef<VideoRef>(null);
useEffect(() => {
setVisible(true);
}, []);
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
videoRef.current.play().catch((error) => {
console.warn("视频播放失败:", error);
});
}
};
const anchors: VideoAnchor[] = useMemo(
() =>
QUIZ_ANCHORS.map((item) => {
const correctAnswers = item.options
.filter((opt) => opt.isCorrect)
.map((opt) => opt.value);
// 拖拽题处理
if (item.type === "drag" && item.blanks) {
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: ({ resume }) => {
const handleDragChange = (val: DragFillResult[]) => {
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }));
setDragQuizErrors((prev) => ({ ...prev, [item.id]: "" }));
// 特斯拉标签无法拖入答案框
const hasInvalidOption = val.some(
(v) => v.optionValue === "特斯拉"
);
if (hasInvalidOption) {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: "特斯拉不是中国国产企业哦~",
}));
return;
}
if (val.length < item.blanks!.length) return;
// 检查是否都是正确答案(排除特斯拉)
const validAnswers = [
"比亚迪",
"华为",
"上海电气",
"光明食品",
"拼多多",
];
const filledAnswers = val.map((v) => v.optionValue);
const isCorrect = filledAnswers.every((answer) =>
validAnswers.includes(answer)
);
if (isCorrect) {
setActiveSuccess(item.id);
setSuccessContent("回答正确,请继续探索。");
setResumeCallback(() => resume);
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: "再想一想,确保选择的都是中国国产企业/品牌~",
}));
}
};
return (
<>
<DragQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.title}(拖拽)`}
questionPrefix={item.prompt}
questionSuffix=""
options={item.options.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
blanks={item.blanks || []}
value={dragQuizValues[item.id] || []}
error={dragQuizErrors[item.id]}
onChange={handleDragChange}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null);
setSuccessContent("");
setDragQuizValues((prev) => {
const next = { ...prev };
delete next[item.id];
return next;
});
setDragQuizErrors((prev) => {
const next = { ...prev };
delete next[item.id];
return next;
});
const cb = resumeCallback;
setResumeCallback(null);
cb?.();
}}
/>
</>
);
},
};
}
// 多选题处理
if (item.type === "multiple") {
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: ({ resume }) => (
<>
<MultipleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.title}(多选题):${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers}
errorMessage="再思考一下,注意选择所有正确答案。"
onSubmit={(values: string[], isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id);
setSuccessContent("回答正确,请继续探索。");
setResumeCallback(() => resume);
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null);
setSuccessContent("");
const cb = resumeCallback;
setResumeCallback(null);
cb?.();
}}
/>
</>
),
};
}
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: () => null,
};
}),
[
activeSuccess,
resumeCallback,
successContent,
dragQuizValues,
dragQuizErrors,
]
);
if (!moduleName) return null;
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
className="relative h-full w-full bg-black"
style={{
backgroundImage: getBackgroundImage(`${starName}/internalization/底图`),
backgroundImage: getBackgroundImage("components/internalization/bg"),
}}
>
{/* 离开按钮 */}
<Image
className="absolute right-5 top-5"
className="absolute right-8 top-8 z-10"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath("video/huangyanpei/huangyanpei_neihua.mp4")}
className="h-full w-full"
anchors={anchors}
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false);
setPostGuideVisible(true);
}}
/>
)}
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={(visible) => {
setVisible(visible);
if (!visible) {
setShowVideo(true);
handleVideoCanPlay();
}
}}
showMask={false}
>
<GuideStepsItem speaker="student">
小沪,刚刚结束的"任之成长记"太有意思了!我变成百年前的川沙少年选课程、做手工、设计国货推销方案,好像真的走进了黄炎培先生的职教世界。
</GuideStepsItem>
<GuideStepsItem speaker="student">
但我还有一个疑问:先生当年倡导的"手脑并用、工学结合",放在我们现在的中职学习中,到底有什么样的意义呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这个问题问到点子上啦!黄炎培先生的职教思想,从来不是尘封的历史,而是活在当下的行动指南。今天我们特别邀请到中华职业教育社第十三届理事会常务理事、中华职业学校党总支书记、校长黄玉璟,让她带我们解码先生精神的时代密码!
</GuideStepsItem>
</GuideSteps>
{/* 视频结束后的引导 */}
<GuideSteps
visible={postGuideVisible}
onVisibleChange={(visible) => {
setPostGuideVisible(visible);
if (!visible) {
setShowLetter(true);
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem speaker="guide">
听完黄校长的讲解,相信你对黄炎培先生的职教精神有了更深的理解。现在,就请你把这份感悟写下来,既总结参观的收获,也给自己未来许下一个承诺吧!
</GuideStepsItem>
</GuideSteps>
{/* 信件组件 */}
<LetterToHuangyanpei
visible={showLetter}
onSubmit={(values: string[]) => {
console.log("提交信件", values);
setShowLetter(false);
setShowStudyGuide(true);
}}
/>
<div className="flex items-center justify-center h-full">
<div className="text-white text-4xl">黄炎培故居-内化模块(待完善)</div>
</div>
<StudyGuideModal
visible={showStudyGuide}
onContinue={() => {
setShowStudyGuide(false);
navigate(`/${moduleName}/${starName}/home`);
}}
>
恭喜你已经顺利完成了黄炎培故居的学习,至此,你已经完成了本课程所有故居的学习,
希望你能有所收获,学习八位名人的优秀品质和精神,在新时代为中华民族伟大复兴发光发热!
</StudyGuideModal>
</div>
);
};
export default HuangyanpeiInternalization;
+48 -6
View File
@@ -4,6 +4,7 @@ import {
GuideStepsItem,
Image,
Title,
StudyGuideModal,
} from "@/components";
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
@@ -24,8 +25,13 @@ const HuangyanpeiPlace1 = () => {
moduleName: string;
}>();
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true);
// 对话结束后的学习引导弹窗
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false);
const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false);
// 引导对话显示状态
const [visible, setVisible] = useState(true);
const [visible, setVisible] = useState(false);
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null);
// 已浏览展品的 ID 集合
@@ -81,8 +87,8 @@ const HuangyanpeiPlace1 = () => {
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-[calc(50%-140px)] right-80"
src={getStaticImage("huangyanpei/place1/3_005")}
className="absolute bottom-0 right-0"
src={getStaticImage("/components/place/继续前进")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/place2`);
@@ -106,20 +112,56 @@ const HuangyanpeiPlace1 = () => {
}}
/>
{/* 学习引导词 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false);
setVisible(true);
}}
>
同学你好,欢迎来到走进故居-对话学习模块!{"\n"}
{
"进入黄炎培故居后,我们一起来了解黄炎培先生经历的5个阶段:““思想启蒙”-> “教育实践”-> “创立职教”-> “延安对话”-> 职教传薪;"
}
</StudyGuideModal>
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={setVisible}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible);
if (!nextVisible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true);
setShowAfterDialogGuide(true);
}
}}
showMask={true}
highlightSelectors={[]}
>
<GuideStepsItem speaker="student">
小沪!这就是黄炎培先生曾经生活过的地方吗?
小沪,这栋古色古香的江南大院,就是黄炎培先生从小生活、读书的地方吗
</GuideStepsItem>
<GuideStepsItem speaker="guide">
是的,这里就是黄炎培故居。让我们一起来探索黄炎培先生的思想启蒙之路...
没错,1878年10月1日,黄炎培先生就出生在当时江苏省川沙县城关镇(现上海市浦东新区川沙镇)的这处“内史第”,宅子原是他姑祖父沈树镛家,也是他后来所说的“生于斯、读于斯、长于斯”之地。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1890年,川沙创办“观澜书院”,12岁的黄炎培以第一名的成绩考入,每天提着母亲亲手缝制的布书包,往返十里路求学,一点都不叫苦。1893年他去上海应县考,松江府复试名列前茅;1899年,在松江府试中更是以第一名的成绩中了秀才。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+67 -2
View File
@@ -1,5 +1,13 @@
import { useState } from "react";
import { ExhibitOverlay, Title, Image, Cursor } from "@/components";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
StudyGuideModal,
GuideStepsItem,
GuideSteps,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -16,11 +24,31 @@ const HuangyanpeiPlace2 = () => {
moduleName: string;
}>();
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
// GuideSteps 当前步骤
const [guideStep, setGuideStep] = useState(0);
// GuideSteps 显示状态
const [guideVisible, setGuideVisible] = useState(false);
const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index);
if (index === 1) {
// 展品1:点击时同时打开展品和引导对话,从第1步开始
setActiveExhibitId(1);
setGuideVisible(true);
setGuideStep(0);
} else {
// 展品2:直接打开
setActiveExhibitId(index);
}
};
// 监听GuideSteps步骤变化,仅同步当前步骤
const handleGuideStepChange = (step: number) => {
setGuideStep(step);
};
const progressCount = visited.size;
@@ -94,6 +122,43 @@ const HuangyanpeiPlace2 = () => {
setActiveExhibitId(null);
}}
/>
{/* 展品1的引导对话 */}
<GuideSteps
visible={guideVisible}
onVisibleChange={setGuideVisible}
currentStep={guideStep}
onStepChange={handleGuideStepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
哇,这就是南洋公学!是不是现在上海交通大学的前身呀?黄炎培先生中了秀才之后,就来这里读书了吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你说得特别对!1901年,黄炎培先生考入南洋公学(今上海交通大学)特班,当时的老师正是蔡元培先生。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
蔡先生一句“中国教育之病,在于学与术分离”,让他瞬间醍醐灌顶,明白了教育不能只谈书本、脱离实际。1902年,黄炎培去南京参加江南乡试,成功中了举人;
</GuideStepsItem>
<GuideStepsItem speaker="guide">
可就在这一年,南洋公学发生了著名的“墨水瓶风潮”,他毅然跟着蔡元培先生退学,转入蔡先生创办的中国教育会,正式开启了自己“教育救国”的实践。
</GuideStepsItem>
</GuideSteps>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+149 -4
View File
@@ -1,5 +1,14 @@
import { useState } from "react";
import { ExhibitOverlay, Title, Image, Cursor } from "@/components";
import { useState, useEffect } from "react";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
StudyGuideModal,
GuideSteps,
GuideStepsItem,
SingleChoiceQuizPanel,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -17,15 +26,64 @@ const HuangyanpeiPlace3 = () => {
moduleName: string;
}>();
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(
null
);
const [visited, setVisited] = useState<Set<number>>(new Set());
// 光标3的引导对话状态
const [guide3Visible, setGuide3Visible] = useState(false);
const [guide3Step, setGuide3Step] = useState(0);
// 光标3的题目显示状态
const [showQuiz3, setShowQuiz3] = useState(false);
// 光标1、2的引导对话状态(在两者都浏览后显示)
const [guide12Visible, setGuide12Visible] = useState(false);
const [guide12Shown, setGuide12Shown] = useState(false);
const handleOpenExhibit = (index: 1 | 2 | 3) => {
setActiveExhibitId(index);
if (index === 3) {
// 光标3:点击时同时打开展品和引导对话
setActiveExhibitId(3);
setGuide3Visible(true);
setGuide3Step(0);
} else {
// 光标1、2:直接打开
setActiveExhibitId(index);
}
};
// 监听光标3的引导对话步骤变化
const handleGuide3StepChange = (step: number) => {
setGuide3Step(step);
};
// 监听光标3的引导对话关闭
const handleGuide3VisibleChange = (visible: boolean) => {
setGuide3Visible(visible);
if (!visible) {
// 对话结束后显示题目
setShowQuiz3(true);
}
};
// 处理光标3题目提交
const handleQuiz3Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setShowQuiz3(false);
}
};
// 监听visited变化,当光标1、2都浏览后显示引导对话
useEffect(() => {
if (visited.has(1) && visited.has(2) && !guide12Shown) {
setGuide12Shown(true);
setGuide12Visible(true);
}
}, [visited, guide12Shown]);
const progressCount = visited.size;
const total = 3;
const completed = progressCount >= total;
@@ -61,16 +119,19 @@ const HuangyanpeiPlace3 = () => {
}}
/>
{/* 光标1 */}
<Cursor
className="absolute left-32 top-105"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute left-172 top-90"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute right-92 top-108"
onClick={() => handleOpenExhibit(3)}
@@ -98,10 +159,94 @@ const HuangyanpeiPlace3 = () => {
next.add(activeExhibitId);
return next;
});
setActiveExhibitId(null);
} else {
setActiveExhibitId(null);
}
setActiveExhibitId(null);
}}
/>
{/* 光标3的引导对话 */}
<GuideSteps
visible={guide3Visible}
onVisibleChange={handleGuide3VisibleChange}
currentStep={guide3Step}
onStepChange={handleGuide3StepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
川沙小学堂是不是黄炎培先生"教育救国"的第一个试验田啊?还有黄炎培先生是不是就像现在教育这样,也有专业课程、实用课程,也很注重动手能力啊?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你说得特别对!1903年,年仅24岁的黄炎培就和好友张志鹤等人一起,把原来的"观澜书院"改制成"川沙小学堂",黄炎培出任校长。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
学校的课程设置非常大胆创新,首次引入算术、"格致"(也就是现在说的自然科学)、体操、乡土调查等新学科。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
学校采用"半日读书、半日手工"的模式,特别注重实际应用,这也是中国最早的"半日授课、半日实习"的雏形。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
办学资金紧张,没有钱买课本,黄炎培就亲自带领大家刻蜡板来印刷;没有体育器材,他就带着学生用竹子做成双杠...整个学校完全是凭着满腔热情办起来的!
</GuideStepsItem>
</GuideSteps>
{/* 光标3的题目 */}
<SingleChoiceQuizPanel
visible={showQuiz3}
title='黄炎培1903年把川沙"观澜书院"改成"川沙小学堂"后,课程表首次出现下列哪组学科?'
options={[
{ value: "A", label: "A. 经学、策论、八股文" },
{
value: "B",
label: "B. 算术、格致、体操、乡土调查",
},
{ value: "C", label: "C. 外语、逻辑、演讲、社交" },
{ value: "D", label: "D. 绘画、声乐、舞蹈、戏剧" },
]}
correctAnswer="B"
errorMessage='1903年,黄炎培将"观澜书院"改制为"川沙小学堂",自任校长,率先打破传统经学模式,在课程表中首次列入算术、格致(近代自然科学)、体操和乡土调查四门新学科,并实行"半日授课、半日实习"的教学方式,开创了近代中国小学融入科学、体育与实践教育的新风,因此选项B正确。'
onSubmit={handleQuiz3Submit}
zIndex={10001}
/>
{/* 光标1、2的引导对话 */}
<GuideSteps
visible={guide12Visible}
onVisibleChange={setGuide12Visible}
showMask={true}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
原来当年还有专门的女子学校呀?在那个年代,女孩子能上学是不是特别不容易?后来的浦东中学也是黄炎培先生办的吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你观察得特别仔细!
川沙小学堂开学没多久,黄炎培就和堂兄黄洪培、堂嫂陆开群一起,策划创建了"开群女校"——这所学校专门面向社会招收女学生,还用全力支持办学、腾出自家住房的陆开群名字命名,直接开创了浦东地区女子教育的先河,让很多女孩子有了读书的机会。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
到了1906年,上海营造业宗师杨斯盛捐资30万两白银,在浦东六里桥购地40多亩创办浦东中学,特意邀请黄炎培担任首任校长。校舍是黄炎培亲自设计的,杨斯盛先生亲自监工,建成后有能容纳千人的大礼堂和两座教学楼,是近代上海第一所完全中学呢!
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1912年,黄炎培担任江苏省署教育司司长,任职期间一直倡导实用主义教育,强调教育要和生产劳动结合起来,这也为后来的职业教育打下了坚实基础。
</GuideStepsItem>
</GuideSteps>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+34 -7
View File
@@ -1,15 +1,22 @@
import { useState } from "react";
import { ExhibitOverlay, Title, Image, Cursor } from "@/components";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
const starName = "huangyanpei";
const exhibitSrcMap: Record<1 | 2 | 3 | 4, string> = {
const exhibitSrcMap: Record<1 | 2 | 3 | 4 | 5, string> = {
1: "huangyanpei/place4/6_006",
2: "huangyanpei/place4/6_007",
3: "huangyanpei/place4/6_008",
4: "huangyanpei/place4/6_009",
5: "huangyanpei/place4/6_010",
};
const HuangyanpeiPlace4 = () => {
@@ -18,17 +25,19 @@ const HuangyanpeiPlace4 = () => {
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | null>(
null
);
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const [activeExhibitId, setActiveExhibitId] = useState<
1 | 2 | 3 | 4 | 5 | null
>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
const handleOpenExhibit = (index: 1 | 2 | 3 | 4) => {
const handleOpenExhibit = (index: 1 | 2 | 3 | 4 | 5) => {
setActiveExhibitId(index);
};
const progressCount = visited.size;
const total = 4;
const total = 5;
const completed = progressCount >= total;
const subtitle = completed
? "探索黄炎培创立职教(已完成)"
@@ -82,6 +91,11 @@ const HuangyanpeiPlace4 = () => {
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
/>
<Cursor
className="absolute right-205 top-110"
onClick={() => handleOpenExhibit(5)}
read={visited.has(5)}
/>
{completed && (
<Image
@@ -108,6 +122,19 @@ const HuangyanpeiPlace4 = () => {
setActiveExhibitId(null);
}}
/>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+31 -4
View File
@@ -1,5 +1,12 @@
import { useState, useRef } from "react";
import { ExhibitOverlay, Title, Image, Cursor, Video } from "@/components";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
Video,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import type { VideoRef } from "@/components/video";
@@ -21,6 +28,8 @@ const HuangyanpeiPlace5 = () => {
const [visited, setVisited] = useState<Set<number>>(new Set());
const [videoVisible, setVideoVisible] = useState(false);
const videoRef = useRef<VideoRef>(null);
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 2) {
@@ -49,6 +58,12 @@ const HuangyanpeiPlace5 = () => {
backgroundImage: getBackgroundImage(`huangyanpei/place5/7_001`),
}}
>
<img
src={getStaticImage("huangyanpei/place5/7_003")}
alt=""
className="absolute top-72 right-37"
/>
<Title
title="延安对话"
subtitle={subtitle}
@@ -94,10 +109,9 @@ const HuangyanpeiPlace5 = () => {
<div className="fixed inset-0 z-[9999] bg-black">
<Video
ref={videoRef}
src={getPublicPath(
"video/chenyun/8陈云纪念馆16大米价格暴涨_x264.mp4"
)}
src={getPublicPath("video/huangyanpei/huangyanpei_07.mp4")}
className="w-full h-full"
controls
autoPlay
playsInline
preload="auto"
@@ -129,6 +143,19 @@ const HuangyanpeiPlace5 = () => {
setActiveExhibitId(null);
}}
/>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+22 -1
View File
@@ -1,5 +1,11 @@
import { useState } from "react";
import { ExhibitOverlay, Title, Image, Cursor } from "@/components";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -17,6 +23,8 @@ const HuangyanpeiPlace6 = () => {
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (index: 1) => {
setActiveExhibitId(index);
@@ -88,6 +96,19 @@ const HuangyanpeiPlace6 = () => {
setActiveExhibitId(null);
}}
/>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
-155
View File
@@ -1,155 +0,0 @@
import { useState, useCallback } from "react";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
BambooPuzzleGame,
} from "@/components";
import { SuccessAlert } from "@/components/layout";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
const starName = "huangyanpei";
const exhibitSrcMap: Record<1 | 2, string> = {
1: "huangyanpei/place7/9_004",
2: "huangyanpei/place7/场景7展品2",
};
const HuangyanpeiPlace7 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
const [showGame, setShowGame] = useState(false);
const [gameCompleted, setGameCompleted] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index);
};
const progressCount = visited.size;
const total = 2;
const completed = progressCount >= total;
const subtitle = completed
? "探索川沙小学堂·启蒙之始(已完成)"
: `探索川沙小学堂·启蒙之始(${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;
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`huangyanpei/place7/9_001`),
}}
>
<Title
title="跨越时空"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place6`);
}
}}
/>
<Cursor
className="absolute left-68 top-55"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
/>
<Cursor
className="absolute left-160 bottom-50"
onClick={() => {
// 如果游戏已完成,不再打开
if (!gameCompleted) {
setShowGame(true);
}
}}
read={gameCompleted}
/>
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage("huangyanpei/place7/9btn")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/home`);
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onClose={() => {
if (activeExhibitId !== null) {
setVisited((prev) => {
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
}
setActiveExhibitId(null);
}}
/>
<BambooPuzzleGame
visible={showGame}
onClose={() => {
setShowGame(false);
}}
onComplete={() => {
// 游戏完成后,显示成功提示
setShowGame(false);
setShowSuccessAlert(true);
}}
/>
<SuccessAlert
visible={showSuccessAlert}
title="拼单杠成功!"
onContinue={useCallback(() => {
console.log("onContinue called, showSuccessAlert:", showSuccessAlert);
// 直接设置为 false,不使用函数式更新
setShowSuccessAlert(false);
setGameCompleted(true);
setVisited((prev) => {
const next = new Set(prev);
next.add(2);
return next;
});
}, [showSuccessAlert])}
onClose={useCallback(() => {
console.log("onClose called, showSuccessAlert:", showSuccessAlert);
// 直接设置为 false,不使用函数式更新
setShowSuccessAlert(false);
setGameCompleted(true);
setVisited((prev) => {
const next = new Set(prev);
next.add(2);
return next;
});
}, [showSuccessAlert])}
/>
</div>
);
};
export default HuangyanpeiPlace7;
-37
View File
@@ -1,37 +0,0 @@
import { useNavigate, useParams } from "react-router-dom";
import { Image } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
const starName = "huangyanpei";
const HuangyanpeiReflection = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/reflection/底图`),
}}
>
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
}
}}
/>
<div className="flex items-center justify-center h-full">
<div className="text-white text-4xl">黄炎培故居-体悟模块(待完善)</div>
</div>
</div>
);
};
export default HuangyanpeiReflection;
@@ -0,0 +1,538 @@
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { cn, getStaticImage } from "@/lib/utils";
import { Image, DragFill, StudyGuideModal } from "@/components";
import type { DragFillResult } from "@/components";
import { useScale } from "@/contexts/ScaleContext";
export interface BambooPuzzleGameProps {
/** 是否可见 */
visible?: boolean;
/** 关闭回调 */
onClose?: () => void;
/** 完成回调 */
onComplete?: () => void;
}
type PieceId = "A" | "B" | "C" | "D" | "E" | "F";
type ContainerType = "horizontal" | "leftPillar" | "rightPillar";
interface Piece {
id: PieceId;
src: string;
correctContainer: ContainerType;
}
// 根据效果图:A和B是立柱(可以放在左右任意一边),C是横杆(必须放在横杆容器)
const pieces: Piece[] = [
{
id: "A",
src: "huangyanpei/reflection/scene1/9_008",
correctContainer: "leftPillar",
},
{
id: "B",
src: "huangyanpei/reflection/scene1/9_0013",
correctContainer: "horizontal",
},
{
id: "C",
src: "huangyanpei/reflection/scene1/9_009",
correctContainer: "rightPillar",
},
{
id: "D",
src: "huangyanpei/reflection/scene1/9_0010",
correctContainer: "horizontal",
},
{
id: "E",
src: "huangyanpei/reflection/scene1/9_0011",
correctContainer: "horizontal",
},
{
id: "F",
src: "huangyanpei/reflection/scene1/9_0012",
correctContainer: "horizontal",
},
];
/**
* 毛竹拼单杠游戏组件
* - 使用蒙版遮罩作为背景
* - 使用 DragFill 组件实现拖拽功能
*/
const BambooPuzzleGame = ({
visible = true,
onClose,
onComplete,
}: BambooPuzzleGameProps) => {
const maskSrc = getStaticImage("components/蒙版遮罩");
const backSrc = getStaticImage("components/关闭");
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
const [mounted, setMounted] = useState(false);
const [value, setValue] = useState<DragFillResult[]>([]);
const [errorContainers, setErrorContainers] = useState<Set<ContainerType>>(
new Set()
);
const [shakeKey, setShakeKey] = useState(0);
const completedRef = useRef(false);
const [showStartGuide, setShowStartGuide] = useState(false);
// SSR 兼容
useEffect(() => {
setMounted(true);
}, []);
// 重置游戏状态
useEffect(() => {
if (visible) {
setValue([]);
setErrorContainers(new Set());
setShakeKey(0);
completedRef.current = false;
setShowStartGuide(true);
}
}, [visible]);
// 检查是否完成 - 三个都拼了才验证
useEffect(() => {
if (value.length === 3) {
const leftPillar = value.find((r) => r.blankId === "leftPillar");
const rightPillar = value.find((r) => r.blankId === "rightPillar");
const horizontal = value.find((r) => r.blankId === "horizontal");
// 验证规则:
// 1. C 必须在横杆容器
// 2. A 和 B 可以在左右任意立柱(但必须各占一个)
const isCorrect =
horizontal?.optionValue === "C" &&
leftPillar &&
rightPillar &&
(leftPillar.optionValue === "A" || leftPillar.optionValue === "B") &&
(rightPillar.optionValue === "A" || rightPillar.optionValue === "B") &&
leftPillar.optionValue !== rightPillar.optionValue;
if (isCorrect && !completedRef.current) {
// 拼对了,调用完成回调(只调用一次)
completedRef.current = true;
onComplete?.();
} else {
// 拼错了,找出错误的容器
const errors = new Set<ContainerType>();
// 检查横杆容器:必须是 C
if (horizontal?.optionValue !== "C") {
errors.add("horizontal");
}
// 检查立柱容器:必须是 A 或 B,且不能相同
if (
!leftPillar ||
(leftPillar.optionValue !== "A" && leftPillar.optionValue !== "B")
) {
errors.add("leftPillar");
}
if (
!rightPillar ||
(rightPillar.optionValue !== "A" && rightPillar.optionValue !== "B")
) {
errors.add("rightPillar");
}
// 如果两个立柱相同,都标记为错误
if (
leftPillar &&
rightPillar &&
leftPillar.optionValue === rightPillar.optionValue
) {
errors.add("leftPillar");
errors.add("rightPillar");
}
// 显示错误效果:变红并抖动
setErrorContainers(errors);
setShakeKey((prev) => prev + 1); // 触发动画重新播放
// 1.5秒后清除错误状态和错误的填充
setTimeout(() => {
setErrorContainers(new Set());
// 清除错误的填充
setValue((prev) =>
prev.filter((r) => !errors.has(r.blankId as ContainerType))
);
}, 1500);
}
}
}, [value, onComplete]);
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation();
onClose?.();
},
[onClose]
);
// 转换为 DragFill 的 options
const options = useMemo(
() =>
pieces.map((piece) => ({
value: piece.id,
label: piece.id,
})),
[]
);
// 转换为 DragFill 的 blanks
const blanks = useMemo(
() => [
{ id: "horizontal", label: "横杆容器" },
{ id: "leftPillar", label: "立柱容器" },
{ id: "rightPillar", label: "立柱容器" },
],
[]
);
// 样式
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
position: "fixed",
inset: 0,
zIndex: 9999,
pointerEvents: "none",
}),
[]
);
const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({
position: "absolute",
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`,
top: `${offsetY}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
overflow: "hidden",
}),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
);
if (!mounted) return null;
const content = (
<AnimatePresence>
{visible && (
<motion.div
key="bamboo-puzzle-game"
style={overlayStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div style={scaledContainerStyle}>
<div
className={cn(
"absolute inset-0 flex flex-col items-center",
"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
/>
)}
<StudyGuideModal
visible={showStartGuide}
onContinue={() => setShowStartGuide(false)}
verticalAlign="center"
>
当年办学经费超紧张,没课本、没器材,黄先生就带着学生自己动手!
{"\n"}
快来一起做器具吧。
</StudyGuideModal>
{/* 返回按钮 */}
{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">
{/* 标题 */}
<div className="mt-8 mb-4 text-center">
<h1
className={cn([
"text-5xl font-bold mb-2 text-white",
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]",
])}
>
毛竹拼单杠
</h1>
<p className="text-4xl text-white mt-10">
请将下方的竹子拖到正确位置,拼出单杠!
</p>
</div>
{/* 拼图区域 - 空白容器 */}
<div
className="flex-1 w-full relative"
style={{ minHeight: "400px" }}
>
{/* 横杆容器 - 上方居中 */}
<motion.div
key={`horizontal-${shakeKey}`}
data-blank-id="horizontal"
className={cn(
"absolute border-2 border-dashed rounded-4xl",
"flex flex-col items-center justify-center transition-all duration-200",
errorContainers.has("horizontal")
? "border-red-500 bg-red-500/20"
: "border-white/60 bg-white/5"
)}
animate={
errorContainers.has("horizontal")
? {
x: [0, -15, 15, -15, 15, -10, 10, 0],
transition: {
duration: 0.6,
ease: "easeInOut",
},
}
: { x: 0 }
}
style={{
left: `${DESIGN_WIDTH * 0.15}px`,
top: `${DESIGN_HEIGHT * 0.02}px`,
width: `${DESIGN_WIDTH * 0.7}px`,
height: `${DESIGN_HEIGHT * 0.2}px`,
}}
>
{(() => {
const filled = value.find(
(r) => r.blankId === "horizontal"
);
const piece = filled
? pieces.find((p) => p.id === filled.optionValue)
: null;
return piece ? (
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain p-2"
objectFit="contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="text-white/30 text-4xl">
拖拽横杆到这里
</div>
</div>
);
})()}
</motion.div>
{/* 左立柱容器 */}
<motion.div
key={`leftPillar-${shakeKey}`}
data-blank-id="leftPillar"
className={cn(
"absolute border-2 border-dashed rounded-4xl",
"flex flex-col items-center justify-center transition-all duration-200",
errorContainers.has("leftPillar")
? "border-red-500 bg-red-500/20"
: "border-white/60 bg-white/5"
)}
animate={
errorContainers.has("leftPillar")
? {
x: [0, -15, 15, -15, 15, -10, 10, 0],
transition: {
duration: 0.6,
ease: "easeInOut",
},
}
: { x: 0 }
}
style={{
left: `${DESIGN_WIDTH * 0.15}px`,
top: `${DESIGN_HEIGHT * 0.14}px`,
width: `${DESIGN_WIDTH * 0.12}px`,
height: `${DESIGN_HEIGHT * 0.32}px`,
}}
>
{(() => {
const filled = value.find(
(r) => r.blankId === "leftPillar"
);
const piece = filled
? pieces.find((p) => p.id === filled.optionValue)
: null;
return piece ? (
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain p-2"
objectFit="contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="text-white/30 text-4xl">
拖拽立柱到这里
</div>
</div>
);
})()}
</motion.div>
{/* 右立柱容器 */}
<motion.div
key={`rightPillar-${shakeKey}`}
data-blank-id="rightPillar"
className={cn(
"absolute border-2 border-dashed rounded-4xl",
"flex flex-col items-center justify-center transition-all duration-200",
errorContainers.has("rightPillar")
? "border-red-500 bg-red-500/20"
: "border-white/60 bg-white/5"
)}
animate={
errorContainers.has("rightPillar")
? {
x: [0, -15, 15, -15, 15, -10, 10, 0],
transition: {
duration: 0.6,
ease: "easeInOut",
},
}
: { x: 0 }
}
style={{
left: `${DESIGN_WIDTH * 0.74}px`,
top: `${DESIGN_HEIGHT * 0.14}px`,
width: `${DESIGN_WIDTH * 0.12}px`,
height: `${DESIGN_HEIGHT * 0.32}px`,
}}
>
{(() => {
const filled = value.find(
(r) => r.blankId === "rightPillar"
);
const piece = filled
? pieces.find((p) => p.id === filled.optionValue)
: null;
return piece ? (
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain p-2"
objectFit="contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="text-white/30 text-4xl">
拖拽立柱到这里
</div>
</div>
);
})()}
</motion.div>
</div>
{/* 道具区域 - 使用 DragFill 组件 */}
<div
className="absolute bottom-0 left-0 w-full"
style={{
paddingLeft: `${DESIGN_WIDTH * 0.05}px`,
paddingBottom: `${DESIGN_HEIGHT * 0.05}px`,
width: `${DESIGN_WIDTH * 0.9}px`,
}}
>
<div className="text-[#243204] mb-20 text-4xl font-blod">
你的毛竹道具
</div>
<DragFill
options={options}
blanks={blanks}
value={value}
onChange={setValue}
allowDuplicate={false}
layout="horizontal-options"
hideLabels={true}
showSubmitButton={false}
className="w-full"
renderOption={(option, isUsed) => {
const piece = pieces.find((p) => p.id === option.value);
if (!piece) return null;
return (
<div
className={cn(
"relative flex flex-col items-center justify-center",
" h-24 rounded-lg transition-all duration-200",
"cursor-move select-none",
isUsed
? "opacity-50 cursor-not-allowed"
: "hover:scale-110"
)}
>
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain pointer-events-none"
draggable={false}
objectFit="contain"
/>
<span className="text-white text-base mt-1 font-medium">
{piece.id}
</span>
</div>
);
}}
renderDragging={(option) => {
const piece = pieces.find((p) => p.id === option.value);
if (!piece) return null;
return (
<div className="w-24 h-24">
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain"
draggable={false}
objectFit="contain"
/>
</div>
);
}}
renderBlank={() => {
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
return <div className="hidden" />;
}}
/>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
return createPortal(content, document.body);
};
export default BambooPuzzleGame;
@@ -0,0 +1,3 @@
export { default as BambooPuzzleGame } from "./BambooPuzzleGame";
export type { BambooPuzzleGameProps } from "./BambooPuzzleGame";
@@ -0,0 +1,4 @@
export { default as QuestionModal } from "./question-modal";
export type { QuestionModalProps } from "./question-modal";
export { BambooPuzzleGame } from "./game";
@@ -0,0 +1,4 @@
export { default } from "./question-modal";
export { default as QuestionModal } from "./question-modal";
export type { QuestionModalProps } from "./question-modal";
@@ -0,0 +1,152 @@
import { useCallback, useState, type ReactNode } from "react";
import { Image } from "@/components";
import { getStaticImage } from "@/lib/utils";
import { ErrorAlert, SuccessAlert } from "@/components/layout";
export interface QuestionModalProps {
/** 是否可见 */
visible?: boolean;
/** 标题文案 */
title?: string;
/** 主体内容(支持换行、富文本) */
children?: ReactNode;
/** 点击关闭按钮回调 */
onClose?: () => void;
/** 点击遮罩是否关闭,默认 false */
maskClosable?: boolean;
/** 自定义 zIndex,默认 120 */
zIndex?: number;
/** 自定义类名 */
className?: string;
/** 自定义样式 */
style?: React.CSSProperties;
/** 成功回调 */
onSuccess?: () => void;
/** 错误回调 */
onError?: () => void;
}
export const QuestionModal = ({
visible = false,
title: _title,
children: _children,
onClose,
onSuccess,
onError,
maskClosable: _maskClosable = false,
zIndex = 120,
className: _className,
style: _style,
}: QuestionModalProps) => {
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [showErrorAlert, setShowErrorAlert] = useState(false);
// 答案
const correctAnswer = 2;
// 选项
const questionOptions = [
{
id: 1,
src: getStaticImage("huangyanpei/reflection/scene1/9_004"),
},
{
id: 2,
src: getStaticImage("huangyanpei/reflection/scene1/9_005"),
},
];
const handleAnswer = useCallback(
(id: number) => {
if (id === correctAnswer) {
// 选对了,显示成功提示
setShowSuccessAlert(true);
} else {
// 选错了,显示错误提示
setShowErrorAlert(true);
}
},
[correctAnswer]
);
const handleSuccessAlertClose = useCallback(() => {
setShowSuccessAlert(false);
onSuccess?.();
}, [onSuccess]);
const handleErrorAlertClose = useCallback(() => {
setShowErrorAlert(false);
onError?.();
}, [onError]);
const maskSrc = getStaticImage("components/蒙版遮罩");
const backSrc = getStaticImage("components/关闭");
if (!visible) return null;
return (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ zIndex }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
{/* 返回按钮 */}
<Image
src={backSrc}
onClick={() => onClose?.()}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
{/* 游戏内容 */}
<div className="relative z-10 w-full h-full flex flex-col ">
{/* 标题 */}
<div className="mt-28 mb-4 text-center px-80">
<p className="text-5xl text-white mt-10 leading-tight">
黄先生说:“中国教育之病,在于学与术分离”!你看,这里有两套课程表,选一套开启学习吧。
</p>
</div>
{/* 选项 */}
<div className="flex justify-center gap-30 mt-20">
{questionOptions.map((option) => (
<Image
className="w-80"
key={option.id}
src={option.src}
button
onClick={() => handleAnswer(option.id)}
/>
))}
</div>
</div>
{/* 成功提示弹窗 */}
<SuccessAlert
visible={showSuccessAlert}
title="选对啦!"
content="这就是黄先生打破旧教育的创新,学这些实用知识,才能真正帮到家乡。"
onContinue={handleSuccessAlertClose}
onClose={handleSuccessAlertClose}
/>
{/* 错误提示弹窗 */}
<ErrorAlert
visible={showErrorAlert}
text="哎呀选错啦!黄先生最反对只啃书本脱离实际的教育,旧课程学不到真本事,快试试另一边的新式课程吧。"
onClose={handleErrorAlertClose}
/>
</div>
);
};
QuestionModal.displayName = "QuestionModal";
export default QuestionModal;
+62 -48
View File
@@ -1,37 +1,27 @@
import { useState, useCallback } from "react";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
BambooPuzzleGame,
} from "@/components";
import { useState, useCallback, useEffect } from "react";
import { Title, Image, Cursor, StudyGuideModal } from "@/components";
import { BambooPuzzleGame, QuestionModal } from "./components";
import { SuccessAlert } from "@/components/layout";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
const starName = "huangyanpei";
const exhibitSrcMap: Record<1 | 2, string> = {
1: "huangyanpei/reflection/scene1/9_004",
2: "huangyanpei/reflection/scene1/场景7展品2",
};
const HuangyanpeiReflectionScene1 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
const [showGuideModal, setShowGuideModal] = useState(true);
const [showQuestionModal, setShowQuestionModal] = useState(false);
const [questionCompleted, setQuestionCompleted] = useState(false);
const [showGame, setShowGame] = useState(false);
const [gameCompleted, setGameCompleted] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index);
};
const [showNextGuide, setShowNextGuide] = useState(false);
const [nextGuideShown, setNextGuideShown] = useState(false);
const progressCount = visited.size;
const total = 2;
@@ -43,6 +33,17 @@ const HuangyanpeiReflectionScene1 = () => {
? "text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]"
: undefined;
// 两个任务都完成后 500ms 弹出下一站提示(仅一次)
useEffect(() => {
if (questionCompleted && gameCompleted && !nextGuideShown) {
const timer = setTimeout(() => {
setShowNextGuide(true);
setNextGuideShown(true);
}, 500);
return () => clearTimeout(timer);
}
}, [questionCompleted, gameCompleted, nextGuideShown]);
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
@@ -64,16 +65,31 @@ const HuangyanpeiReflectionScene1 = () => {
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
if (moduleName && starName && !showGuideModal) {
navigate(`/${moduleName}/${starName}/home`);
}
}}
/>
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false);
}}
>
你好呀!现在是1903年,欢迎你进入川沙小学堂,请开启你的求学第一课吧。
{"\n"}
接下来你将在“任之”校长所在的学校学习,根据所知和所学,努力完成校长给你的考验,完成全部任务后,可进入到下一阶段的学习。
</StudyGuideModal>
<Cursor
className="absolute left-68 top-55"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
onClick={() => {
if (!questionCompleted) {
setShowQuestionModal(true);
}
}}
read={questionCompleted}
/>
<Cursor
className="absolute left-160 bottom-50"
@@ -86,7 +102,17 @@ const HuangyanpeiReflectionScene1 = () => {
read={gameCompleted}
/>
{completed && (
<QuestionModal
visible={showQuestionModal}
onSuccess={() => {
setShowQuestionModal(false);
setQuestionCompleted(true);
setVisited((prev) => new Set(prev).add(1));
}}
onClose={() => setShowQuestionModal(false)}
/>
{/* {completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage("huangyanpei/reflection/scene1/9btn")}
@@ -97,22 +123,7 @@ const HuangyanpeiReflectionScene1 = () => {
}
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onClose={() => {
if (activeExhibitId !== null) {
setVisited((prev) => {
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
}
setActiveExhibitId(null);
}}
/>
)} */}
<BambooPuzzleGame
visible={showGame}
@@ -123,12 +134,13 @@ const HuangyanpeiReflectionScene1 = () => {
// 游戏完成后,显示成功提示
setShowGame(false);
setShowSuccessAlert(true);
setVisited((prev) => new Set(prev).add(2));
}}
/>
<SuccessAlert
visible={showSuccessAlert}
title="拼单杠成功!"
title="拼接毛竹成功!"
onContinue={useCallback(() => {
setShowSuccessAlert(false);
setGameCompleted(true);
@@ -138,16 +150,18 @@ const HuangyanpeiReflectionScene1 = () => {
return next;
});
}, [])}
onClose={useCallback(() => {
setShowSuccessAlert(false);
setGameCompleted(true);
setVisited((prev) => {
const next = new Set(prev);
next.add(2);
return next;
});
}, [])}
/>
<StudyGuideModal
visible={showNextGuide}
onContinue={() => {
setShowNextGuide(false);
navigate(`/${moduleName}/${starName}/reflection2`);
}}
verticalAlign="center"
>
第一关完成!下一站,浦东中学深造去。
</StudyGuideModal>
</div>
);
};
+48 -22
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Title, Image, Cursor } from "@/components";
import { useState, useEffect } from "react";
import { Title, Image, Cursor, StudyGuideModal } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import ChoiceDialog1 from "./scene2/ChoiceDialog1";
@@ -13,6 +13,7 @@ const HuangyanpeiReflectionScene2 = () => {
moduleName: string;
}>();
const [showGuideModal, setShowGuideModal] = useState(true);
const [showDialog1, setShowDialog1] = useState(false);
const [showDialog2, setShowDialog2] = useState(false);
const [visited, setVisited] = useState<Set<number>>(new Set());
@@ -20,10 +21,8 @@ const HuangyanpeiReflectionScene2 = () => {
"读书" | "没出息" | "女子不能上学" | null
>(null);
const [choice2, setChoice2] = useState<"铁工科" | "商科" | null>(null);
if (!moduleName) {
return null;
}
const [showCompleteGuide, setShowCompleteGuide] = useState(false);
const [completeGuideShown, setCompleteGuideShown] = useState(false);
const progressCount = visited.size;
const total = 2;
@@ -35,6 +34,32 @@ const HuangyanpeiReflectionScene2 = () => {
? "text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]"
: undefined;
// 两个任务都完成后,显示完成提示(仅一次)
useEffect(() => {
if (completed && choice2 && !completeGuideShown) {
const timer = setTimeout(() => {
setShowCompleteGuide(true);
setCompleteGuideShown(true);
}, 500);
return () => clearTimeout(timer);
}
}, [completed, choice2, completeGuideShown]);
// 处理完成提示的继续按钮点击
const handleCompleteGuideContinue = () => {
setShowCompleteGuide(false);
// 根据选择决定导航目标
if (choice2 === "铁工科") {
navigate(`/${moduleName}/${starName}/reflection3`);
} else if (choice2 === "商科") {
navigate(`/${moduleName}/${starName}/reflection4`);
}
};
if (!moduleName) {
return null;
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
@@ -56,10 +81,18 @@ const HuangyanpeiReflectionScene2 = () => {
src={getStaticImage("components/离开")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/home`);
navigate(`/${moduleName}/${starName}/reflection`);
}}
/>
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
>
欢迎来到浦东中学!{"\n"}
这是近代上海第一所完全中学,黄炎培先生当首任校长,在这里能学到更多实用知识,还能树立成才的理想哦。
</StudyGuideModal>
{choice2 && (
<img
className="absolute right-[234px] bottom-12.5"
@@ -110,21 +143,14 @@ const HuangyanpeiReflectionScene2 = () => {
}}
/>
{completed && choice2 && (
<Image
className="absolute bottom-0 right-4"
src={getStaticImage("components/place/继续前进")}
button
onClick={() => {
// 根据选择决定导航目标
if (choice2 === "铁工科") {
navigate(`/${moduleName}/${starName}/reflection3`);
} else if (choice2 === "商科") {
navigate(`/${moduleName}/${starName}/reflection4`);
}
}}
/>
)}
{/* 完成提示 */}
<StudyGuideModal
visible={showCompleteGuide}
onContinue={handleCompleteGuideContinue}
verticalAlign="center"
>
第二关完成!"职业方向锁定!"去新学校了解你的专业吧。
</StudyGuideModal>
</div>
);
};
@@ -1,9 +1,8 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { cn, getStaticImage } from "@/lib/utils";
import { Image } from "@/components";
import { useScale } from "@/contexts/ScaleContext";
import { Image, StudyGuideModal } from "@/components";
import { SuccessAlert, ErrorAlert } from "@/components/layout";
export interface ChoiceDialog1Props {
/** 是否可见 */
@@ -31,14 +30,26 @@ const ChoiceDialog1 = ({
const choice2Src = getStaticImage("huangyanpei/reflection/scene2/10_006");
const choice3Src = getStaticImage("huangyanpei/reflection/scene2/10_007");
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
const [mounted, setMounted] = useState(false);
const [showGuide, setShowGuide] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [showErrorAlert, setShowErrorAlert] = useState(false);
// SSR 兼容
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (visible) {
setShowGuide(true);
setShowSuccessAlert(false);
setShowErrorAlert(false);
} else {
setShowGuide(false);
}
}, [visible]);
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation();
@@ -49,10 +60,15 @@ const ChoiceDialog1 = ({
const handleChoiceClick = useCallback(
(choice: "读书" | "没出息" | "女子不能上学") => {
onSelect?.(choice);
onClose?.();
if (choice === "读书") {
setShowGuide(false);
setShowSuccessAlert(true);
onSelect?.(choice);
} else {
setShowErrorAlert(true);
}
},
[onSelect, onClose]
[onSelect]
);
// 样式
@@ -66,20 +82,6 @@ const ChoiceDialog1 = ({
[]
);
const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({
position: "absolute",
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`,
top: `${offsetY}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
overflow: "hidden",
}),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
);
if (!mounted) return null;
const content = (
@@ -93,109 +95,154 @@ const ChoiceDialog1 = ({
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div style={scaledContainerStyle}>
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center",
"bg-black/70"
)}
style={{ pointerEvents: "auto" }}
<div
className={cn(
"fixed inset-0 flex flex-col items-center justify-center",
"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
/>
)}
<StudyGuideModal
visible={showGuide}
onContinue={() => setShowGuide(false)}
verticalAlign="center"
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
黄校长来考你啦!他设计这所学校,就是为了让学生摆脱旧观念,你知道是哪种吗?
</StudyGuideModal>
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 返回按钮 */}
{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 items-center justify-between px-20">
{/* 人物 - 左侧 */}
<div className="flex-shrink-0">
<img src={characterSrc} alt="人物" />
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex items-center justify-between px-20">
{/* 人物 - 左侧 */}
<div className="flex-shrink-0">
<img src={characterSrc} alt="人物" />
</div>
{/* 三个选择选项 - 右侧水平排列 */}
<div className="flex items-center justify-center gap-16 flex-1">
{/* 选择A:读书 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("读书")}
>
{/* 标签A */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
A
</div>
{/* 图标 */}
<img src={choice1Src} alt="读书" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
读书只为
<br />
升官发财
</div>
</div>
{/* 三个选择选项 - 右侧水平排列 */}
<div className="flex items-center justify-center gap-16 flex-1">
{/* 选择A:读书 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("读书")}
>
{/* 标签A */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
A
</div>
{/* 图标 */}
<img src={choice1Src} alt="读书" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
读书只为
<br />
升官发财
</div>
{/* 选择B:没出息 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("没出息")}
>
{/* 标签B */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
B
</div>
{/* 选择B:没出息 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("没出息")}
>
{/* 标签B */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
B
</div>
{/* 图标 */}
<img src={choice2Src} alt="没出息" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
手工劳作
<br />
没出息
</div>
{/* 图标 */}
<img src={choice2Src} alt="没出息" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
手工劳作
<br />
没出息
</div>
</div>
{/* 选择C:女子不能上学 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("女子不能上学")}
>
{/* 标签C */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
C
</div>
{/* 图标 */}
<img src={choice3Src} alt="女子不能上学" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
女子
<br />
不能上学
</div>
{/* 选择C:女子不能上学 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("女子不能上学")}
>
{/* 标签C */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
C
</div>
{/* 图标 */}
<img src={choice3Src} alt="女子不能上学" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
女子
<br />
不能上学
</div>
</div>
</div>
</div>
<SuccessAlert
visible={showSuccessAlert}
title="任务1 正确反馈"
content="答对啦!黄先生说“教育要为国家培养实干人才”,浦东中学就是要教大家学真本事,不是只为当官。"
onContinue={() => {
setShowSuccessAlert(false);
onClose?.();
}}
onClose={() => {
setShowSuccessAlert(false);
onClose?.();
}}
/>
<ErrorAlert
visible={showErrorAlert}
text="不对哦~黄先生虽然反对歧视女子、轻视手工,但创办浦东中学的核心是打破“读书只为当官”的旧思想,再选一次吧!"
onClose={() => setShowErrorAlert(false)}
/>
<SuccessAlert
visible={showSuccessAlert}
content="答对啦!黄先生说“教育要为国家培养实干人才”,浦东中学就是要教大家学真本事,不是只为当官。"
onContinue={() => {
setShowSuccessAlert(false);
onClose?.();
}}
onClose={() => {
setShowSuccessAlert(false);
onClose?.();
}}
/>
<ErrorAlert
visible={showErrorAlert}
text="不对哦~黄先生虽然反对歧视女子、轻视手工,但创办浦东中学的核心是打破“读书只为当官”的旧思想,再选一次吧!"
onClose={() => setShowErrorAlert(false)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
);
return createPortal(content, document.body);
return content;
};
export default ChoiceDialog1;
@@ -1,9 +1,8 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { cn, getStaticImage } from "@/lib/utils";
import { Image } from "@/components";
import { useScale } from "@/contexts/ScaleContext";
import { Image, StudyGuideModal } from "@/components";
import { SuccessAlert } from "@/components/layout";
export interface ChoiceDialog2Props {
/** 是否可见 */
@@ -28,14 +27,28 @@ const ChoiceDialog2 = ({
const backSrc = getStaticImage("components/关闭");
const choiceSrc = getStaticImage("huangyanpei/reflection/scene2/10_008");
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
const [mounted, setMounted] = useState(false);
const [showGuide, setShowGuide] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [selectedChoice, setSelectedChoice] = useState<
"铁工科" | "商科" | null
>(null);
// SSR 兼容
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (visible) {
setShowGuide(true);
setShowSuccessAlert(false);
setSelectedChoice(null);
} else {
setShowGuide(false);
}
}, [visible]);
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation();
@@ -44,13 +57,18 @@ const ChoiceDialog2 = ({
[onClose]
);
const handleChoiceClick = useCallback(
(choice: "铁工科" | "商科") => {
onSelect?.(choice);
onClose?.();
},
[onSelect, onClose]
);
const handleChoiceClick = useCallback((choice: "铁工科" | "商科") => {
setSelectedChoice(choice);
setShowSuccessAlert(true);
}, []);
const handleSuccessAlertContinue = useCallback(() => {
setShowSuccessAlert(false);
if (selectedChoice) {
onSelect?.(selectedChoice);
}
onClose?.();
}, [selectedChoice, onSelect, onClose]);
// 样式
const overlayStyle = useMemo<React.CSSProperties>(
@@ -63,20 +81,6 @@ const ChoiceDialog2 = ({
[]
);
const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({
position: "absolute",
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`,
top: `${offsetY}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
overflow: "hidden",
}),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
);
if (!mounted) return null;
const content = (
@@ -90,102 +94,113 @@ const ChoiceDialog2 = ({
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div style={scaledContainerStyle}>
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center",
"bg-black/70"
)}
style={{ pointerEvents: "auto" }}
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center",
"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
/>
)}
<StudyGuideModal
visible={showGuide}
onContinue={() => setShowGuide(false)}
verticalAlign="center"
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
在浦东中学,你可以选未来的方向啦!不管选哪个,黄先生都会支持,因为“三百六十行,行行出状元”
</StudyGuideModal>
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 返回按钮 */}
{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 items-center justify-center">
<div className="flex flex-col items-center gap-12">
{/* 选择A:铁工科(箭头指向左边) */}
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-12">
{/* 选择A:铁工科(箭头指向左边) */}
<div
className="flex items-center gap-6 cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("铁工科")}
>
{/* 标签A */}
<div className="text-7xl font-bold text-[#ffa500] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
A
</div>
{/* 箭头按钮 - 需要旋转180度使其指向左边 */}
<div
className="flex items-center gap-6 cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("铁工科")}
className="relative"
style={{ transform: "rotate(180deg)" }}
>
{/* 标签A */}
<div className="text-7xl font-bold text-[#ffa500] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
A
</div>
{/* 箭头按钮 - 需要旋转180度使其指向左边 */}
<div
className="relative"
style={{ transform: "rotate(180deg)" }}
>
<img
src={choiceSrc}
alt="铁工科"
className="max-w-[800px] object-contain"
/>
{/* 文字覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center">
<div
className="text-white text-7xl font-bold"
style={{ transform: "rotate(180deg)" }}
>
铁工科
</div>
<img
src={choiceSrc}
alt="铁工科"
className="max-w-[800px] object-contain"
/>
{/* 文字覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center">
<div
className="text-white text-7xl font-bold"
style={{ transform: "rotate(180deg)" }}
>
铁工科
</div>
</div>
</div>
</div>
{/* 选择B:商科(箭头指向右边) */}
<div
className="flex items-center gap-6 cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("商科")}
>
{/* 标签B */}
<div className="text-7xl font-bold text-[#ffa500] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
B
</div>
{/* 箭头按钮 */}
<div className="relative">
<img
src={choiceSrc}
alt="商科"
className="max-w-[800px] object-contain"
/>
{/* 文字覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-white text-7xl font-bold">
商科
</div>
</div>
{/* 选择B:商科(箭头指向右边) */}
<div
className="flex items-center gap-6 cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("商科")}
>
{/* 标签B */}
<div className="text-7xl font-bold text-[#ffa500] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
B
</div>
{/* 箭头按钮 */}
<div className="relative">
<img
src={choiceSrc}
alt="商科"
className="max-w-[800px] object-contain"
/>
{/* 文字覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-white text-7xl font-bold">商科</div>
</div>
</div>
</div>
</div>
</div>
<SuccessAlert
visible={showSuccessAlert}
content="选得好!不管是做工匠还是做买卖,学好本事就能报效国家,这个选择会影响你在中华职业学校的专业哦"
onContinue={handleSuccessAlertContinue}
onClose={handleSuccessAlertContinue}
/>
</div>
</motion.div>
)}
</AnimatePresence>
);
return createPortal(content, document.body);
return content;
};
export default ChoiceDialog2;
+117 -7
View File
@@ -1,8 +1,11 @@
import { useState } from "react";
import { Title, Image, Cursor } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useState, useRef } from "react";
import { Title, Image, Cursor, StudyGuideModal, Video } from "@/components";
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
import { SuccessAlert, ErrorAlert } from "@/components/layout";
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";
const starName = "huangyanpei";
@@ -12,15 +15,22 @@ const HuangyanpeiReflectionScene3 = () => {
moduleName: string;
}>();
const [showGuide1, setShowGuide1] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [showQuiz, setShowQuiz] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [showErrorAlert, setShowErrorAlert] = useState(false);
const [showCompleteGuide, setShowCompleteGuide] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const [visited, setVisited] = useState<Set<number>>(new Set());
const videoRef = useRef<VideoRef>(null);
if (!moduleName) {
return null;
}
const progressCount = visited.size;
const total = 1;
const total = 2;
const completed = progressCount >= total;
const subtitle = completed
? "探索中华职业学院·立业之本·铁工科(已完成)"
@@ -50,22 +60,55 @@ const HuangyanpeiReflectionScene3 = () => {
src={getStaticImage("components/离开")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/home`);
navigate(`/${moduleName}/${starName}/reflection2`);
}}
/>
{/* 第一段引导 */}
<StudyGuideModal
visible={showGuide1}
onContinue={() => {
setShowGuide1(false);
}}
>
终于到中华职业学校啦!这里是中国近代职教的摇篮,"免学费、包分配",还能学真手艺,快来练就一技之长。
{"\n"}
你选了学技术,那就来挑战制作中华职校的"明星产品"——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢
</StudyGuideModal>
{/* 互动展品光标:组装台钳互动 */}
<Cursor
className="absolute right-100 bottom-55"
onClick={() => setShowDialog(true)}
onClick={() => {
if (!showGuide1) {
if (visited.has(2)) {
setShowDialog(true);
} else {
setShowErrorAlert(true);
}
}
}}
read={visited.has(1)}
/>
{/* 答题光标 */}
<Cursor
className="absolute left-145 top-68"
onClick={() => {
if (!showGuide1) {
setShowQuiz(true);
}
}}
read={visited.has(2)}
/>
{/* 组装台钳互动弹窗 */}
<ViseAssemblyDialog
visible={showDialog}
onClose={() => setShowDialog(false)}
onComplete={() => {
setShowDialog(false);
setShowCompleteGuide(true);
setVisited((prev) => {
const next = new Set(prev);
next.add(1);
@@ -74,13 +117,80 @@ const HuangyanpeiReflectionScene3 = () => {
}}
/>
{/* 多选题答题框 */}
<MultipleChoiceQuizPanel
visible={showQuiz}
title="题目1 (多选题) 制作台钳需要哪些工具?(多选)"
options={[
{ value: "A", label: "A. 锯子" },
{ value: "B", label: "B. 毛笔" },
{ value: "C", label: "C. 锉刀" },
{ value: "D", label: "D. 铁锤" },
]}
correctAnswer={["A", "C", "D"]}
errorMessage="毛笔是写字的哦! 台钳是铁制品,得用锯子、锉刀、铁锤这些工具,再选一次。"
minSelections={1}
onSubmit={(values, isCorrect) => {
if (isCorrect) {
setShowQuiz(false);
setShowSuccessAlert(true);
}
}}
zIndex={10001}
/>
{/* 成功提示弹窗 */}
<SuccessAlert
visible={showSuccessAlert}
title="满分!"
content='黄先生说"手脑并用",懂了理论,也要动手实践。'
onContinue={() => {
setShowSuccessAlert(false);
setVisited((prev) => new Set(prev).add(2));
}}
onClose={() => {
setShowSuccessAlert(false);
}}
/>
{/* 错误提示:请先去学习制作台钳 */}
<ErrorAlert
visible={showErrorAlert}
text="请先去学习台钳制作知识吧!"
onClose={() => setShowErrorAlert(false)}
/>
{/* 组装台钳完成提示 */}
<StudyGuideModal
visible={showCompleteGuide}
onContinue={() => setShowCompleteGuide(false)}
verticalAlign="center"
>
太厉害啦!你的台钳制作成功,和当年拿金奖的同款哦。
</StudyGuideModal>
{completed && (
<Image
className="absolute bottom-0 right-4"
src={getStaticImage("components/place/继续前进")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/reflection4`);
setShowVideo(true);
}}
/>
)}
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath("video/huangyanpei/huangyanpei_09.mp4")}
className="h-full w-full"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo(false);
navigate(`/${moduleName}/${starName}/home`);
}}
/>
)}
@@ -59,36 +59,10 @@ const ViseAssemblyDialog = ({
if (visible) {
setValue([]);
completedRef.current = false;
setVideoVisible(false);
}
}, [visible]);
// 检查是否完成
useEffect(() => {
if (value.length === 3 && !completedRef.current) {
const slot0 = value.find((r) => r.blankId === "slot0");
const slot1 = value.find((r) => r.blankId === "slot1");
const slot2 = value.find((r) => r.blankId === "slot2");
// 验证:A->slot0, B->slot1, C->slot2
const isCorrect =
slot0?.optionValue === "A" &&
slot1?.optionValue === "B" &&
slot2?.optionValue === "C";
if (isCorrect) {
completedRef.current = true;
// 答题成功,播放视频
setVideoVisible(true);
} else {
// 答题失败,清除错误的填充
setTimeout(() => {
setValue([]);
}, 1500);
alert("组装顺序不正确,请重新尝试!");
}
}
}, [value]);
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation();
@@ -134,6 +108,35 @@ const ViseAssemblyDialog = ({
setValue(filteredValue);
}, []);
// 处理"拼接台钳"按钮点击
const handleAssemble = useCallback(() => {
if (value.length < 3) {
return;
}
const slot0 = value.find((r) => r.blankId === "slot0");
const slot1 = value.find((r) => r.blankId === "slot1");
const slot2 = value.find((r) => r.blankId === "slot2");
// 验证:A->slot0, B->slot1, C->slot2
const isCorrect =
slot0?.optionValue === "A" &&
slot1?.optionValue === "B" &&
slot2?.optionValue === "C";
if (isCorrect) {
completedRef.current = true;
// 答题成功,播放视频
setVideoVisible(true);
} else {
// 答题失败,清除错误的填充
setTimeout(() => {
setValue([]);
}, 1500);
alert("组装顺序不正确,请重新尝试!");
}
}, [value]);
const handleVideoEnd = useCallback(() => {
setVideoVisible(false);
onComplete?.();
@@ -296,6 +299,7 @@ const ViseAssemblyDialog = ({
{/* 中间的"拼接台钳"按钮 */}
<button
onClick={handleAssemble}
disabled={value.length < 3}
className="mt-8 px-20 py-3 bg-yellow-400 border-8 border-white rounded-xl text-[#432800] text-5xl font-bold cursor-pointer transition-all duration-200 hover:bg-yellow-500 hover:scale-105 active:scale-95 disabled:bg-white/50 disabled:cursor-not-allowed"
>
+35 -4
View File
@@ -1,10 +1,11 @@
import { useState, useCallback } from "react";
import { Title, Image, Cursor } from "@/components";
import { useState, useCallback, useRef } from "react";
import { Title, Image, Cursor, Video, StudyGuideModal } from "@/components";
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
import { SuccessAlert } from "@/components/layout";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
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";
const starName = "huangyanpei";
@@ -19,6 +20,9 @@ const HuangyanpeiReflectionScene4 = () => {
const [showQuiz1, setShowQuiz1] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [showTowelDialog, setShowTowelDialog] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const videoRef = useRef<VideoRef>(null);
const [showGuide1, setShowGuide1] = useState(true);
// 处理成功提示的继续和关闭
const handleSuccessAlertContinue = useCallback(() => {
@@ -76,7 +80,7 @@ const HuangyanpeiReflectionScene4 = () => {
src={getStaticImage("components/离开")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/home`);
navigate(`/${moduleName}/${starName}/reflection2`);
}}
/>
@@ -160,6 +164,33 @@ const HuangyanpeiReflectionScene4 = () => {
src={getStaticImage("components/place/继续前进")}
button
onClick={() => {
setShowVideo(true);
}}
/>
)}
{/* 第一段引导 */}
<StudyGuideModal
visible={showGuide1}
onContinue={() => {
setShowGuide1(false);
}}
>
终于到中华职业学校啦!这里是中国近代职教的摇篮,“免学费、包分配”,还能学真手艺,快来练就一技之长。
{"\n"}
现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢
</StudyGuideModal>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath("video/huangyanpei/huangyanpei_09.mp4")}
className="h-full w-full"
autoPlay
showSkipButton
playsInline
onEnded={() => {
setShowVideo(false);
navigate(`/${moduleName}/${starName}/home`);
}}
/>
@@ -1,10 +1,9 @@
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { cn, getStaticImage } from "@/lib/utils";
import { Image } from "@/components";
import { Image, DragFill } from "@/components";
import type { DragFillResult } from "@/components";
import { useScale } from "@/contexts/ScaleContext";
import { ErrorAlert, SuccessAlert } from "@/components/layout";
export interface TowelSalesDialogProps {
/** 是否可见 */
@@ -40,9 +39,10 @@ const TowelSalesDialog = ({
const towel2Lit = getStaticImage("huangyanpei/reflection/scene4/12_003_02");
const towel3Lit = getStaticImage("huangyanpei/reflection/scene4/12_004_03");
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
const [mounted, setMounted] = useState(false);
const [value, setValue] = useState<DragFillResult[]>([]);
const [errorVisible, setErrorVisible] = useState(false);
const [successVisible, setSuccessVisible] = useState(false);
const completedRef = useRef(false);
// SSR 兼容
@@ -55,6 +55,8 @@ const TowelSalesDialog = ({
if (visible) {
setValue([]);
completedRef.current = false;
setErrorVisible(false);
setSuccessVisible(false);
}
}, [visible]);
@@ -62,13 +64,10 @@ const TowelSalesDialog = ({
useEffect(() => {
if (value.length === 3 && !completedRef.current) {
completedRef.current = true;
// 延迟一点时间让用户看到效果,然后完成
setTimeout(() => {
onComplete?.();
onClose?.();
}, 500);
// 显示成功提示
setSuccessVisible(true);
}
}, [value, onComplete, onClose]);
}, [value]);
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
@@ -78,6 +77,19 @@ const TowelSalesDialog = ({
[onClose]
);
// 关闭错误提示
const handleErrorClose = useCallback(() => {
setErrorVisible(false);
}, []);
// 关闭成功提示
const handleSuccessContinue = useCallback(() => {
setSuccessVisible(false);
// 关闭提示后,完成并关闭弹窗
onComplete?.();
onClose?.();
}, [onComplete, onClose]);
// 可选话术选项
const options = useMemo(
() => [
@@ -101,11 +113,36 @@ const TowelSalesDialog = ({
[]
);
// 转换为 DragFill 的 blanks
const blanks = useMemo(
() => [
{ id: "towel1", label: "毛巾1" },
{ id: "towel2", label: "毛巾2" },
{ id: "towel3", label: "毛巾3" },
],
[]
);
// 处理拖拽结果变化
const handleValueChange = useCallback((newValue: DragFillResult[]) => {
// 限制最多3个
if (newValue.length <= 3) {
setValue(newValue);
// 检查是否选择了错误的选项(option4)
const hasOption4 = newValue.some(
(result) => result.optionValue === "option4"
);
if (hasOption4) {
// 显示错误提示
setErrorVisible(true);
// 移除 option4,保留其他选项
const filteredValue = newValue.filter(
(result) => result.optionValue !== "option4"
);
setValue(filteredValue);
} else {
// 限制最多3个
if (newValue.length <= 3) {
setValue(newValue);
}
}
}, []);
@@ -117,9 +154,6 @@ const TowelSalesDialog = ({
[value]
);
// 拖拽悬停状态
const [hoveredTowel, setHoveredTowel] = useState<string | null>(null);
// 样式
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
@@ -131,20 +165,6 @@ const TowelSalesDialog = ({
[]
);
const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({
position: "absolute",
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`,
top: `${offsetY}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
overflow: "hidden",
}),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
);
if (!mounted) return null;
const content = (
@@ -158,229 +178,159 @@ const TowelSalesDialog = ({
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div style={scaledContainerStyle}>
<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
/>
)}
<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
/>
)}
{/* 返回按钮 */}
{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 items-center justify-center px-30 py-16 pt-30 text-[#432800]">
<div className="w-full h-full">
{/* 左侧:题目和可选话术 */}
<div className="flex justify-between gap-10">
{/* 题目文本 */}
<div className="mb-8">
<div className="text-5xl font-bold mb-6">题目文本</div>
<div className="text-4xl leading-relaxed">
从以下话术里,挑选3句组成你的推销语 (拖拽至对话框)
</div>
</div>
{/* 右侧:毛巾区域 */}
<div className="w-180 flex flex-col items-center">
{/* 答题区标签 */}
{/* <div className="text-4xl font-bold text-white mb-8">
答题区
</div> */}
{/* 毛巾堆叠区域 */}
<div className="relative w-full flex flex-col ">
{/* 毛巾1 */}
<div
className={cn(
"relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
// isTowelLit("towel1")
// ? "opacity-100 border-[#f0be75]"
// : hoveredTowel === "towel1"
// ? "opacity-100 border-[#f0be75] bg-[#f0be75]/20"
// : "opacity-100 border-transparent"
)}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!isTowelLit("towel1")) {
setHoveredTowel("towel1");
}
}}
onDragLeave={() => {
setHoveredTowel(null);
}}
onDrop={(e) => {
e.preventDefault();
setHoveredTowel(null);
const optionValue =
e.dataTransfer.getData("text/plain");
if (optionValue && !isTowelLit("towel1")) {
// 检查是否已经使用过这个选项
const alreadyUsed = value.some(
(r) => r.optionValue === optionValue
);
if (!alreadyUsed) {
handleValueChange([
...value,
{ blankId: "towel1", optionValue },
]);
}
}
}}
>
<img
src={isTowelLit("towel1") ? towel1Lit : towel1}
alt="毛巾1"
className="w-full h-full object-contain"
/>
</div>
{/* 毛巾2 */}
<div
className={cn(
"relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
)}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!isTowelLit("towel2")) {
setHoveredTowel("towel2");
}
}}
onDragLeave={() => {
setHoveredTowel(null);
}}
onDrop={(e) => {
e.preventDefault();
setHoveredTowel(null);
const optionValue =
e.dataTransfer.getData("text/plain");
if (optionValue && !isTowelLit("towel2")) {
// 检查是否已经使用过这个选项
const alreadyUsed = value.some(
(r) => r.optionValue === optionValue
);
if (!alreadyUsed) {
handleValueChange([
...value,
{ blankId: "towel2", optionValue },
]);
}
}
}}
>
<img
src={isTowelLit("towel2") ? towel2Lit : towel2}
alt="毛巾2"
className="w-full h-full object-contain"
/>
</div>
{/* 毛巾3 */}
<div
className={cn(
"relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
)}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!isTowelLit("towel3")) {
setHoveredTowel("towel3");
}
}}
onDragLeave={() => {
setHoveredTowel(null);
}}
onDrop={(e) => {
e.preventDefault();
setHoveredTowel(null);
const optionValue =
e.dataTransfer.getData("text/plain");
if (optionValue && !isTowelLit("towel3")) {
// 检查是否已经使用过这个选项
const alreadyUsed = value.some(
(r) => r.optionValue === optionValue
);
if (!alreadyUsed) {
handleValueChange([
...value,
{ blankId: "towel3", optionValue },
]);
}
}
}}
>
<img
src={isTowelLit("towel3") ? towel3Lit : towel3}
alt="毛巾3"
className="w-full h-full object-contain"
/>
</div>
</div>
{/* 内容区域 */}
<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">题目文本</div>
<div className="text-4xl leading-relaxed">
从以下话术里,挑选3句组成你的推销语 (拖拽至对话框)
</div>
</div>
{/* 可选话术区域 */}
<div className="flex-1">
<div className="text-5xl font-bold mb-6">答题区</div>
<div className="grid grid-cols-2 gap-y-8 gap-x-26 mt-12">
{options.map((option, index) => {
const isUsed = value.some(
(r) => r.optionValue === option.value
);
return (
<div
key={option.value}
className={cn(
" px-10 py-10 rounded-2xl border-3 text-3xl font-medium transition-all",
isUsed
? "border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50"
: "border-[#79471f] bg-black/20 to-[#3a2f25] text-white cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)]"
)}
draggable={!isUsed}
onDragStart={(e) => {
if (isUsed) {
e.preventDefault();
return;
}
e.dataTransfer.setData(
"text/plain",
option.value
);
}}
>
{index + 1}. {option.label}
</div>
);
})}
{/* 右侧:毛巾区域 */}
<div className="w-180 flex flex-col items-center">
{/* 毛巾堆叠区域 */}
<div className="relative w-full flex flex-col">
{/* 毛巾1 */}
<div
data-blank-id="towel1"
className="relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
>
<img
src={isTowelLit("towel1") ? towel1Lit : towel1}
draggable={false}
alt="毛巾1"
className="w-full h-full object-contain"
/>
</div>
{/* 毛巾2 */}
<div
data-blank-id="towel2"
className="relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
>
<img
src={isTowelLit("towel2") ? towel2Lit : towel2}
draggable={false}
alt="毛巾2"
className="w-full h-full object-contain"
/>
</div>
{/* 毛巾3 */}
<div
data-blank-id="towel3"
className="relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
>
<img
src={isTowelLit("towel3") ? towel3Lit : towel3}
draggable={false}
alt="毛巾3"
className="w-full h-full object-contain"
/>
</div>
</div>
</div>
</div>
{/* 底部:可选话术区域 - 使用 DragFill */}
<div className="flex-1">
<div className="text-5xl font-bold mb-6">答题区</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>
{/* 错误提示 */}
<ErrorAlert
text="哎呀!经商要讲诚信,这句话不符合黄先生倡导的商科精神。"
visible={errorVisible}
onClose={handleErrorClose}
onMaskClick={handleErrorClose}
zIndex={10001}
/>
{/* 成功提示 */}
<SuccessAlert
title="恭喜你, 推销语设计成功!"
content="既突出了国货优势,又传递了爱国情怀,当年的商科学生也是这么做的"
visible={successVisible}
onContinue={handleSuccessContinue}
/>
</div>
</motion.div>
)}
</AnimatePresence>
);
return createPortal(content, document.body);
return content;
};
export default TowelSalesDialog;
+312 -15
View File
@@ -1,38 +1,335 @@
import { useEffect, useMemo, useState, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Image } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
LetterToLuxun,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { SuccessAlert } from "@/components/layout";
import {
MultipleChoiceQuizPanel,
SingleChoiceQuizPanel,
} from "@/components/layout/quiz";
import { GuideSteps, GuideStepsItem } from "@/components";
type QuizOption = {
value: string;
label: string;
isCorrect?: boolean;
explanation?: string;
};
type QuizAnchor = {
id: string;
time: number;
title: string;
prompt: string;
type: "single" | "multiple";
options: QuizOption[];
};
const starName = "luxun";
// 锚点数据:根据视频时长调整 time
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: "quiz-1",
time: 155, // 2:35
title: "题目1",
prompt: "以下属于鲁迅先生作品的是( )。",
type: "multiple",
options: [
{ value: "A", label: "孔乙己", isCorrect: true },
{ value: "B", label: "药", isCorrect: true },
{ value: "C", label: "狂人日记", isCorrect: true },
{ value: "D", label: "阿Q正传", isCorrect: true },
],
},
{
id: "quiz-2",
time: 355, // 5:55
title: "题目2",
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-3",
time: 420, // 7:00
title: "题目3",
prompt: "以下那句诗出自鲁迅的作品( )。",
type: "single",
options: [
{ value: "A", label: "寄意寒星荃不察,我以我血荐轩辕", isCorrect: true },
{
value: "B",
label: "我自横刀向天笑,去留肝胆两昆仑",
explanation: "出自谭嗣同《狱中题壁》",
},
{
value: "C",
label: "此去泉台招旧部,旌旗十万斩阎罗",
explanation: "出自陈毅《梅岭三章》",
},
{
value: "D",
label: "雄关漫道真如铁,而今迈步从头越",
explanation: "出自毛泽东《忆秦娥·娄山关》",
},
],
},
];
const LuxunInternalization = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const { moduleName } = useParams<{ moduleName: string }>();
const [activeSuccess, setActiveSuccess] = useState<string | null>(null);
const [successContent, setSuccessContent] = useState<string>("");
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(
null
);
const [visible, setVisible] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const [postGuideVisible, setPostGuideVisible] = useState(false);
const [showLetter, setShowLetter] = useState(false);
const [showStudyGuide, setShowStudyGuide] = useState(false);
const videoRef = useRef<VideoRef>(null);
useEffect(() => {
setVisible(true);
}, []);
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
videoRef.current.play().catch((error) => {
console.warn("视频播放失败:", error);
});
}
};
const anchors: VideoAnchor[] = useMemo(
() =>
QUIZ_ANCHORS.map((item) => {
const correctAnswers = item.options
.filter((opt) => opt.isCorrect)
.map((opt) => opt.value);
// 单选题处理
if (item.type === "single") {
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: ({ resume }) => (
<>
<SingleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.title}(单选题):${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers[0]}
errorMessage="再思考一下,选择正确答案。"
onSubmit={(value: string, isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id);
setSuccessContent("回答正确,请继续探索。");
setResumeCallback(() => resume);
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null);
setSuccessContent("");
const cb = resumeCallback;
setResumeCallback(null);
cb?.();
}}
/>
</>
),
};
}
// 多选题处理
if (item.type === "multiple") {
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: ({ resume }) => (
<>
<MultipleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.title}(多选题):${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers}
errorMessage="再思考一下,注意选择所有正确答案。"
onSubmit={(values: string[], isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id);
setSuccessContent("回答正确,请继续探索。");
setResumeCallback(() => resume);
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null);
setSuccessContent("");
const cb = resumeCallback;
setResumeCallback(null);
cb?.();
}}
/>
</>
),
};
}
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: () => null,
};
}),
[activeSuccess, resumeCallback, successContent]
);
if (!moduleName) return null;
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
className="relative h-full w-full bg-black"
style={{
backgroundImage: getBackgroundImage(`${starName}/internalization/底图`),
backgroundImage: getBackgroundImage("components/internalization/bg"),
}}
>
{/* 离开按钮 */}
<Image
className="absolute right-5 top-5"
className="absolute right-8 top-8 z-10"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath("video/luxun/luxun_neihua.mp4")}
className="h-full w-full"
anchors={anchors}
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false);
setPostGuideVisible(true);
}}
/>
)}
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={(visible) => {
setVisible(visible);
if (!visible) {
setShowVideo(true);
handleVideoCanPlay();
}
}}
showMask={false}
>
<GuideStepsItem speaker="guide">
小沪,关于"首在立人"走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
那些针砭时弊的杂文、借古讽今的篇章,还有藏在6平方米客房里的红色坚守,都在诉说着先生的热忱。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1907年,鲁迅先生在《文化偏至论》里写到:“是故将生存两间,角逐列国是务,其首在立人,人立而后凡事举;若其道术,乃必尊个性而张精神。”
</GuideStepsItem>
<GuideStepsItem speaker="guide">
先生认为,中华民族要屹立于世界民族之林,“首在立人”,“立人”思想从此贯穿在鲁迅先生的人生和作品中。”先生认为,中华民族要屹立于世界民族之林,“首在立人”,“立人”思想从此贯穿在鲁迅先生的人生和作品中。
</GuideStepsItem>
<GuideStepsItem speaker="student">
小沪,关于"首在立人"这四个字我还是有些不理解,立人为什么这么重要?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
"首在立人"这四个字,可以说是鲁迅先生一生的价值观。今天我们有幸邀请到了上海鲁迅纪念馆研究馆员施晓燕。关于你的问题,我想施老师会给你指点迷津!
</GuideStepsItem>
</GuideSteps>
{/* 视频结束后的引导 */}
<GuideSteps
visible={postGuideVisible}
onVisibleChange={(visible) => {
setPostGuideVisible(visible);
if (!visible) {
setShowLetter(true);
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem speaker="guide">
听完施老师的讲解,相信你对鲁迅先生的"立人"精神有了更深的理解。现在,就请你把这份感悟写下来,既总结参观的收获,也给自己未来许下一个承诺吧!
</GuideStepsItem>
</GuideSteps>
{/* 信件组件 */}
<LetterToLuxun
visible={showLetter}
onSubmit={(values: string[]) => {
console.log("提交信件", values);
setShowLetter(false);
setShowStudyGuide(true);
}}
/>
<div className="flex items-center justify-center h-full">
<div className="text-white text-4xl">鲁迅故居-内化模块(待完善)</div>
</div>
<StudyGuideModal
visible={showStudyGuide}
onContinue={() => {
setShowStudyGuide(false);
navigate(`/${moduleName}/${starName}/home`);
}}
>
恭喜你已经顺利完成了鲁迅故居的学习,至此,你已经完成了本课程所有故居的学习,
希望你能有所收获,学习八位名人的优秀品质和精神,在新时代为中华民族伟大复兴发光发热!
</StudyGuideModal>
</div>
);
};
export default LuxunInternalization;
+2 -2
View File
@@ -88,8 +88,8 @@ const LuxunPlace1 = () => {
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 left-130"
src={getStaticImage("luxun/place1/前进")}
className="absolute bottom-0 right-0"
src={getStaticImage("components/place/继续前进")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/place2`);
+34 -20
View File
@@ -7,10 +7,12 @@ import { useState } from "react";
const starName = "luxun";
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: "luxun/place3/展品8",
2: "luxun/place3/展品7",
3: "luxun/place3/展品6",
const exhibitSrcMap: Record<1 | 2 | 3 | 4 | 5, string> = {
1: "luxun/place3/6",
2: "luxun/place3/7",
3: "luxun/place3/8",
4: "luxun/place3/9",
5: "luxun/place3/10",
};
const LuxunPlace3 = () => {
@@ -19,21 +21,21 @@ const LuxunPlace3 = () => {
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(
null
);
const [activeExhibitId, setActiveExhibitId] = useState<
1 | 2 | 3 | 4 | 5 | null
>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
const handleOpenExhibit = (index: 1 | 2 | 3) => {
const handleOpenExhibit = (index: 1 | 2 | 3 | 4 | 5) => {
setActiveExhibitId(index);
};
const progressCount = visited.size;
const total = 3;
const total = 5;
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;
@@ -46,7 +48,7 @@ const LuxunPlace3 = () => {
}}
>
<Title
title="历史新编中的现实呐喊"
title="历史新编中的现实呐喊&为亡友编纂的精神丰碑"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
@@ -65,28 +67,40 @@ const LuxunPlace3 = () => {
{/* 展品 */}
<Cursor
className="absolute left-90 bottom-140"
className="absolute right-114 bottom-130"
onClick={() => handleOpenExhibit(1)}
size={0.4}
size={0.6}
read={visited.has(1)}
/>
<Cursor
className="absolute right-225 bottom-120"
className="absolute right-130 bottom-115"
onClick={() => handleOpenExhibit(2)}
size={0.4}
size={0.6}
read={visited.has(2)}
/>
<Cursor
className="absolute right-214 bottom-123"
className="absolute left-76 bottom-140"
onClick={() => handleOpenExhibit(3)}
size={0.4}
size={0.6}
read={visited.has(3)}
/>
<Cursor
className="absolute right-238 bottom-125"
onClick={() => handleOpenExhibit(4)}
size={0.6}
read={visited.has(4)}
/>
<Cursor
className="absolute right-258 bottom-118"
onClick={() => handleOpenExhibit(5)}
size={0.6}
read={visited.has(5)}
/>
{completed && (
<Image
className="absolute bottom-25 right-[calc(50%-270px)]"
src={getStaticImage("luxun/place3/前进")}
className="absolute bottom-0 right-130"
src={getStaticImage("luxun/place3/前进按钮")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/place4`);
+24 -18
View File
@@ -7,30 +7,33 @@ import { useState } from "react";
const starName = "luxun";
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2, string> = {
1: "luxun/place4/展品9_t",
2: "luxun/place4/展品9_b",
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: "luxun/place4/展品11",
2: "luxun/place4/展品12",
3: "luxun/place4/展品13",
};
const LuxunPlace4 = () => {
const LuxunPlace5 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(
null
);
const [visited, setVisited] = useState<Set<number>>(new Set());
const handleOpenExhibit = (index: 1 | 2) => {
const handleOpenExhibit = (index: 1 | 2 | 3) => {
setActiveExhibitId(index);
};
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;
@@ -43,7 +46,7 @@ const LuxunPlace4 = () => {
}}
>
<Title
title="为亡友编纂的精神丰碑"
title="6平方米的红色庇护所"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
@@ -62,25 +65,28 @@ const LuxunPlace4 = () => {
{/* 展品 */}
<Cursor
className="absolute left-170 bottom-115"
size={0.4}
className="absolute left-167 bottom-30"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
/>
<Cursor
className="absolute left-183 bottom-115"
size={0.4}
className="absolute left-228 bottom-30"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
/>
<Cursor
className="absolute right-115 bottom-18 z-20"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
/>
{completed && (
<Image
className="absolute bottom-0 right-120"
src={getStaticImage("luxun/place4/前进")}
className="absolute bottom-0 right-0"
src={getStaticImage("luxun/place4/离开")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/place5`);
navigate(`/${moduleName}/${starName}/home`);
}}
/>
)}
@@ -103,4 +109,4 @@ const LuxunPlace4 = () => {
);
};
export default LuxunPlace4;
export default LuxunPlace5;
-112
View File
@@ -1,112 +0,0 @@
import { ExhibitOverlay, Image, Title, Cursor } from "@/components";
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useState } from "react";
// starName 固定为 luxun
const starName = "luxun";
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: "luxun/place5/展品11",
2: "luxun/place5/展品12",
3: "luxun/place5/展品13",
};
const LuxunPlace5 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(
null
);
const [visited, setVisited] = useState<Set<number>>(new Set());
const handleOpenExhibit = (index: 1 | 2 | 3) => {
setActiveExhibitId(index);
};
const progressCount = visited.size;
const total = 3;
const completed = progressCount >= total;
const subtitle = completed
? "探索鲁迅故居的客房(已完成)"
: `探索鲁迅故居的客房(${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;
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`luxun/place5/背景`),
}}
>
<Title
title="6平方米的红色庇护所"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place4`);
}
}}
/>
{/* 展品 */}
<Cursor
className="absolute left-167 bottom-30"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
/>
<Cursor
className="absolute left-228 bottom-30"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
/>
<Cursor
className="absolute right-115 bottom-18 z-20"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
/>
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage("luxun/place5/离开")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/home`);
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onClose={() => {
if (activeExhibitId !== null) {
setVisited((prev) => {
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
}
setActiveExhibitId(null);
}}
/>
</div>
);
};
export default LuxunPlace5;
+42 -10
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Image } from "@/components";
import SuccessAlert from "@/components/layout/success-alert";
import SuccessAlert from "@/components/layout/alert/success-alert";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
const starName = "luxun";
@@ -14,7 +14,7 @@ const cityConfig = [
name: "绍兴",
location: "绍兴三味书屋",
imagePath: "luxun/reflection/scene1/绍兴",
position: { top: "58%", left: "60%" }, // 中间
position: { top: "65%", left: "65%" }, // 中间
iconPosition: { top: "0%", left: "0%" },
time: "1892年-1898年",
description: "少年鲁迅在此求学,为《从百草园到三味书屋》埋下创作种子。",
@@ -24,7 +24,7 @@ const cityConfig = [
name: "北京",
location: "北京八道湾胡同",
imagePath: "luxun/reflection/scene1/北京",
position: { top: "14%", left: "50%" }, // 顶部
position: { top: "14%", left: "62%" }, // 顶部
iconPosition: { top: "43%", left: "0%" },
time: "1919-1923年",
description:
@@ -35,7 +35,7 @@ const cityConfig = [
name: "厦门",
location: "厦门大学",
imagePath: "luxun/reflection/scene1/厦门",
position: { top: "80%", left: "70%" }, // 右下
position: { top: "83%", left: "78%" }, // 右下
iconPosition: { top: "16%", left: "40%" },
time: "1926年9-12月",
description:
@@ -46,7 +46,7 @@ const cityConfig = [
name: "广州",
location: "广州白云楼",
imagePath: "luxun/reflection/scene1/广州",
position: { top: "80%", left: "30%" }, // 左下
position: { top: "88%", left: "35%" }, // 左下
iconPosition: { top: "16%", left: "30%" },
time: "1927年3-9月",
description:
@@ -57,7 +57,7 @@ const cityConfig = [
name: "上海",
location: "上海鲁迅故居",
imagePath: "luxun/reflection/scene1/上海",
position: { top: "40%", left: "60%" }, // 中上
position: { top: "48%", left: "70%" }, // 中上
iconPosition: { top: "0%", left: "0%" },
time: "1933-1936年",
description:
@@ -190,6 +190,36 @@ const LuxunReflectionScene1 = () => {
</div>
</div>
{/* 导航栏 */}
<div className="absolute top-5 left-[37%] w-80 space-y-4 z-99">
{/* 任务1点亮图标 */}
<div className="text-4xl font-bold h-16 cursor-pointer"></div>
{/* 任务2漫集美学 */}
<div
className="text-4xl font-bold h-16 cursor-pointer relative"
onClick={() => {
if (moduleName && allCompleted) {
navigate(`/${moduleName}/${starName}/reflection2`);
}
}}
>
{/* 任务完成后显示点一点图标 */}
{allCompleted && (
<div className="absolute -right-20 top-1/2 flex items-center gap-2 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
src={getStaticImage(
`${starName}/reflection/scene1/鲁迅体悟点一点图标`
)}
alt="点一点图标"
/>
</div>
)}
</div>
{/* 任务3学习精神 */}
<div className="text-4xl font-bold h-16 cursor-pointer"></div>
</div>
{/* 右侧区域 */}
<div className="flex-1 relative">
{/* 任务进度 */}
@@ -201,14 +231,16 @@ const LuxunReflectionScene1 = () => {
</span>
</div>
{/* 任务展示窗口 */}
<div className="absolute bottom-0 right-7 w-[995px] h-[728px] font-bold text-7xl space-y-16 px-10 py-10 overflow-y-auto luxun-scrollbar">
<div className="absolute bottom-0 right-7 w-[995px] h-[728px] font-bold text-6xl space-y-16 px-10 py-10 overflow-y-auto custom-scrollbar">
{allCompleted
? // 全部完成后显示详细信息
cityConfig.map((city) => (
<div key={city.id} className="space-y-4">
<div className="text-red-600 text-6xl">{city.location}</div>
<div className="text-gray-600 text-4xl font-normal">
{city.time}
<div className="text-red-600 text-5xl flex items-end gap-6">
<div>{city.location}</div>
<div className="text-gray-600 text-4xl font-normal">
{city.time}
</div>
</div>
<div className="text-black text-4xl font-normal leading-relaxed">
{city.description}
+134 -39
View File
@@ -2,7 +2,8 @@ import { useState, useEffect, useMemo, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Image, DragFill } from "@/components";
import type { DragFillResult } from "@/components";
import SuccessAlert from "@/components/layout/success-alert";
import SuccessAlert from "@/components/layout/alert/success-alert";
import ErrorAlert from "@/components/layout/alert/error-alert";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
const starName = "luxun";
@@ -14,48 +15,56 @@ const dragItems = [
name: "闰土",
imagePath: "luxun/reflection/scene2/拖拽-润土",
targetBlankId: "city-1-label-0", // 绍兴-人物
errorHint: "闰土的故事和鲁迅的少年绍兴时光有关哦",
},
{
id: "baicaoyuan",
name: "《从百草园到三味书屋》",
imagePath: "luxun/reflection/scene2/拖拽-百草园到三味书屋",
targetBlankId: "city-1-label-1", // 绍兴-作品
errorHint: "这个作品写的是鲁迅在绍兴的故事哦",
},
{
id: "aqzhengzhuan",
name: "《阿Q正传》",
imagePath: "luxun/reflection/scene2/拖拽-阿Q正传",
targetBlankId: "city-2-label-0", // 北京-作品
errorHint: "这部经典小说和鲁迅在北京的创作有关哦",
},
{
id: "jingshenshengli",
name: "“精神胜利法”",
name: '"精神胜利法"',
imagePath: "luxun/reflection/scene2/拖拽-精神胜利法",
targetBlankId: "city-2-label-1", // 北京-核心主题
errorHint: "这个主题是《阿Q正传》的核心,和北京创作",
},
{
id: "guanzhudiceng",
name: "“关注底层”",
name: '"关注底层"',
imagePath: "luxun/reflection/scene2/关注底层",
targetBlankId: "city-3-label-0", // 厦门-创作方向
errorHint: "这个创作方向是鲁迅在厦门的核心追求哦",
},
{
id: "zhaohuaxishi",
name: "《朝花夕拾》",
imagePath: "luxun/reflection/scene2/拖拽-朝花夕拾",
targetBlankId: "city-4-label-0", // 广州-作品集
errorHint: "这个作品集和鲁迅在广州的旅居时光有关哦",
},
{
id: "gushixinbian",
name: "《故事新编》",
imagePath: "luxun/reflection/scene2/拖拽-故事新编",
targetBlankId: "city-5-label-0", // 上海-作品
errorHint: "再试试~这个作品和上海的创作故事更相关哦",
},
{
id: "biweidaoqiang",
name: "“笔为刀枪”",
name: '"笔为刀枪"',
imagePath: "luxun/reflection/scene2/笔为刀枪",
targetBlankId: "city-5-label-1", // 上海-创作中心
errorHint: "这个初心是鲁迅在上海创作的核心哦",
},
];
@@ -67,10 +76,10 @@ const cityConfig = [
name: "绍兴",
location: "绍兴三味书屋",
imagePath: "luxun/reflection/scene2/绍兴",
position: { top: "58%", left: "60%" }, // 中间
position: { top: "65%", left: "65%" }, // 中间
labels: [
{ text: "人物", containerPosition: { top: "0%", left: "-20%" } },
{ text: "作品", containerPosition: { top: "0%", left: "20%" } },
{ text: "人物", containerPosition: { top: "50%", left: "20%" } },
{ text: "作品", containerPosition: { top: "50%", left: "50%" } },
],
time: "1892年-1898年",
description: "少年鲁迅在此求学,为《从百草园到三味书屋》埋下创作种子。",
@@ -80,10 +89,10 @@ const cityConfig = [
name: "北京",
location: "北京八道湾胡同",
imagePath: "luxun/reflection/scene2/北京",
position: { top: "14%", left: "50%" }, // 顶部
position: { top: "14%", left: "62%" }, // 顶部
labels: [
{ text: "作品", containerPosition: { top: "0%", left: "-20%" } },
{ text: "核心主题", containerPosition: { top: "0%", left: "20%" } },
{ text: "作品", containerPosition: { top: "72%", left: "30%" } },
{ text: "核心主题", containerPosition: { top: "72%", left: "70%" } },
],
time: "1919-1923年",
description:
@@ -94,9 +103,9 @@ const cityConfig = [
name: "厦门",
location: "厦门大学",
imagePath: "luxun/reflection/scene2/厦门",
position: { top: "80%", left: "70%" }, // 右下
position: { top: "83%", left: "78%" }, // 右下
labels: [
{ text: "创作方向", containerPosition: { top: "0%", left: "0%" } },
{ text: "创作方向", containerPosition: { top: "52%", left: "60%" } },
],
time: "1926年9-12月",
description:
@@ -107,8 +116,10 @@ const cityConfig = [
name: "广州",
location: "广州白云楼",
imagePath: "luxun/reflection/scene2/广州",
position: { top: "80%", left: "30%" }, // 左下
labels: [{ text: "作品集", containerPosition: { top: "0%", left: "0%" } }],
position: { top: "88%", left: "35%" }, // 左下
labels: [
{ text: "作品集", containerPosition: { top: "38%", left: "50%" } },
],
time: "1927年3-9月",
description:
"先生在此整理旧作,思考民族觉醒之路,《朝花夕拾》部分篇章定稿于此。",
@@ -118,10 +129,10 @@ const cityConfig = [
name: "上海",
location: "上海鲁迅故居",
imagePath: "luxun/reflection/scene2/上海",
position: { top: "40%", left: "60%" }, // 中上
position: { top: "48%", left: "70%" }, // 中上
labels: [
{ text: "作品", containerPosition: { top: "0%", left: "-20%" } },
{ text: "创作中心", containerPosition: { top: "0%", left: "20%" } },
{ text: "作品", containerPosition: { top: "50%", left: "20%" } },
{ text: "创作中心", containerPosition: { top: "50%", left: "50%" } },
],
time: "1933-1936年",
description:
@@ -142,6 +153,9 @@ const LuxunReflectionScene2 = () => {
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
// 是否全部完成
const [allCompleted, setAllCompleted] = useState(false);
// 错误提示相关状态
const [showErrorAlert, setShowErrorAlert] = useState(false);
const [errorHint, setErrorHint] = useState("");
// 转换为 DragFill 的 options
const options = useMemo(
@@ -169,18 +183,58 @@ const LuxunReflectionScene2 = () => {
}, []);
// 处理拖拽结果变化 - 验证是否正确放置
const handleValueChange = useCallback((newValue: DragFillResult[]) => {
// 验证每个元素是否放置到正确的标签区域
const filteredValue = newValue.filter((result) => {
const itemId = result.optionValue;
const blankId = result.blankId;
const handleValueChange = useCallback(
(newValue: DragFillResult[]) => {
// 找出新添加或位置改变的拖拽结果(与当前值比较)
const previousValue = dragFillValue;
const changedResults = newValue.filter(
(newResult) =>
!previousValue.some(
(prevResult) =>
prevResult.optionValue === newResult.optionValue &&
prevResult.blankId === newResult.blankId
)
);
const item = dragItems.find((item) => item.id === itemId);
return item && item.targetBlankId === blankId;
});
// 检查改变的结果是否有错误
let hasError = false;
let errorMessage = "";
setDragFillValue(filteredValue);
}, []);
for (const result of changedResults) {
const itemId = result.optionValue;
const blankId = result.blankId;
const item = dragItems.find((item) => item.id === itemId);
// 如果放置位置不正确,显示错误提示
if (item && item.targetBlankId !== blankId) {
hasError = true;
errorMessage = item.errorHint || "放置位置不正确,请再试试~";
break; // 只显示第一个错误的提示
}
}
if (hasError) {
// 显示错误提示
setErrorHint(errorMessage);
setShowErrorAlert(true);
// 不更新拖拽值,保持之前正确的状态
// DragFill 是受控组件,不更新 value 会保持之前的状态
return;
}
// 验证每个元素是否放置到正确的标签区域,只保留正确的放置
const filteredValue = newValue.filter((result) => {
const itemId = result.optionValue;
const blankId = result.blankId;
const item = dragItems.find((item) => item.id === itemId);
return item && item.targetBlankId === blankId;
});
setDragFillValue(filteredValue);
},
[dragFillValue]
);
const progress = dragFillValue.length;
const totalTasks = dragItems.length; // 8个拖拽元素
@@ -197,6 +251,11 @@ const LuxunReflectionScene2 = () => {
setShowSuccessAlert(false);
};
const handleErrorAlertClose = () => {
setShowErrorAlert(false);
setErrorHint("");
};
// 获取每个标签区域已放置的元素
const getLabelPlacedItem = (cityId: number, labelIndex: number) => {
const blankId = `city-${cityId}-label-${labelIndex}`;
@@ -207,7 +266,7 @@ const LuxunReflectionScene2 = () => {
return (
<div
className="flex justify-between w-full h-full bg-cover bg-center bg-no-repeat gap-10 relative"
className="select-none flex justify-between w-full h-full bg-cover bg-center bg-no-repeat gap-10 relative"
style={{
backgroundImage: getBackgroundImage(
`${starName}/reflection/scene2/底图`
@@ -221,7 +280,7 @@ const LuxunReflectionScene2 = () => {
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
navigate(`/${moduleName}/${starName}/reflection`);
}
}}
/>
@@ -262,10 +321,10 @@ const LuxunReflectionScene2 = () => {
<div
key={index}
data-blank-id={blankId}
className={`absolute border-2 border-dashed rounded-lg transition-all min-w-[120px] min-h-[120px] flex flex-col items-center justify-center ${
className={`absolute border-2 border-dashed rounded-lg transition-all min-w-[80px] min-h-[80px] flex flex-col items-center justify-center ${
isFilled
? "border-green-500 bg-green-500/20"
: "border-white/60 bg-white/10 hover:border-blue-400 hover:bg-blue-400/20"
: "border-white/60 bg-black/60 hover:border-blue-400 hover:bg-blue-400/20"
}`}
style={{
top: containerPosition.top,
@@ -281,7 +340,7 @@ const LuxunReflectionScene2 = () => {
className="w-20 h-20 object-contain"
/>
) : (
<div className="text-white/30 text-sm text-center">
<div className="text-white text-sm text-center">
{labelText}
</div>
)}
@@ -296,6 +355,34 @@ const LuxunReflectionScene2 = () => {
</div>
</div>
{/* 导航栏 */}
<div className="absolute top-20 left-[37%] w-80 space-y-4 z-99">
{/* 任务2漫集美学 */}
<div className="text-4xl font-bold h-16 cursor-pointer relative"></div>
{/* 任务3学习精神 */}
<div
className="text-4xl font-bold h-16 cursor-pointer relative"
onClick={() => {
if (moduleName && allCompleted) {
navigate(`/${moduleName}/${starName}/reflection3`);
}
}}
>
{/* 任务完成后显示点一点图标 */}
{allCompleted && (
<div className="absolute -right-20 top-1/2 flex items-center gap-2 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
src={getStaticImage(
`${starName}/reflection/scene1/鲁迅体悟点一点图标`
)}
alt="点一点图标"
/>
</div>
)}
</div>
</div>
{/* 右侧区域 */}
<div className="flex-1 relative">
{/* 任务进度 */}
@@ -309,7 +396,7 @@ const LuxunReflectionScene2 = () => {
{/* 鲁迅画廊 */}
<div className="absolute top-[calc(2.4rem+204px)] left-0 w-[995px] h-[467px] pt-16 pl-70 pr-10">
{/* 提示图标 */}
<div className="absolute left-10 top-30 flex items-center z-10 pointer-events-none">
<div className="absolute left-14 top-35 flex items-center z-10 pointer-events-none">
<img
src={getStaticImage(
`${starName}/reflection/scene2/鲁迅体悟拖一拖`
@@ -364,7 +451,7 @@ const LuxunReflectionScene2 = () => {
<img
src={getStaticImage(item.imagePath)}
alt={item.name}
className="w-20 h-20 object-contain shadow-lg"
className="w-16"
draggable={false}
/>
);
@@ -390,11 +477,11 @@ const LuxunReflectionScene2 = () => {
<div className="space-y-8">
{cityConfig.map((city) => (
<div key={city.id} className="space-y-4">
<div className="text-red-600 text-6xl font-bold">
{city.location}
</div>
<div className="text-gray-600 text-4xl font-normal">
{city.time}
<div className="text-red-600 text-5xl font-bold flex items-end gap-6">
<div>{city.location}</div>
<div className="text-gray-600 text-4xl font-normal">
{city.time}
</div>
</div>
<div className="text-black text-4xl font-normal leading-relaxed">
{city.description}
@@ -438,6 +525,14 @@ const LuxunReflectionScene2 = () => {
onClose={handleSuccessAlertClose}
onContinue={handleSuccessAlertClose}
/>
{/* 错误提示弹窗 */}
<ErrorAlert
visible={showErrorAlert}
text={errorHint}
onClose={handleErrorAlertClose}
onMaskClick={handleErrorAlertClose}
/>
</div>
);
};
+245 -103
View File
@@ -1,139 +1,281 @@
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Title, Image, Cursor, ExhibitOverlay } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { Image } from "@/components";
import { SingleChoiceQuizPanel } from "@/components/layout/quiz";
const starName = "luxun";
const routeMap: Record<1 | 2 | 3 | 4, string> = {
1: "luxun/reflection/scene3/绍兴到北京",
2: "luxun/reflection/scene3/北京到厦门",
3: "luxun/reflection/scene3/厦门广州",
4: "luxun/reflection/scene3/广州上海",
};
const themeMap: Record<5 | 6, string> = {
5: "luxun/reflection/scene3/家国情怀",
6: "luxun/reflection/scene3/批判现实",
};
// 提示图标位置类型
type HintPosition = "luxun1" | "jiaguo" | "piping" | "luxun2" | "none";
const LuxunReflectionScene3 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [showTopics, setShowTopics] = useState(false); // 是否显示"家国情怀"和"批判现实"
const [hintPosition, setHintPosition] = useState<HintPosition>("luxun1"); // 提示图标位置
const [showQuiz1, setShowQuiz1] = useState(false); // 是否显示题目1
const [showQuiz2, setShowQuiz2] = useState(false); // 是否显示题目2
const [completedQuiz1, setCompletedQuiz1] = useState(false); // 题目1是否完成
const [completedQuiz2, setCompletedQuiz2] = useState(false); // 题目2是否完成
const [showSummary, setShowSummary] = useState(false); // 是否显示总结
const [activeRouteId, setActiveRouteId] = useState<1 | 2 | 3 | 4 | null>(null);
const [activeThemeId, setActiveThemeId] = useState<5 | 6 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
const allCompleted = completedQuiz1 && completedQuiz2;
const progress = (completedQuiz1 ? 1 : 0) + (completedQuiz2 ? 1 : 0);
const totalTasks = 2;
const handleOpenRoute = (routeId: 1 | 2 | 3 | 4) => {
setActiveRouteId(routeId);
// 点击"鲁迅1"的处理
const handleLuxun1Click = () => {
setShowTopics(true);
setHintPosition("jiaguo"); // 提示图标移动到"家国情怀"左侧
};
const handleOpenTheme = (themeId: 5 | 6) => {
setActiveThemeId(themeId);
// 点击"家国情怀"的处理
const handleJiaguoClick = () => {
if (!showTopics) return; // 必须先点击"鲁迅1"
setShowQuiz1(true);
};
const progressCount = visited.size;
const total = 6;
const completed = progressCount >= total;
const subtitle = completed
? "探索精神传承·思想之光(已完成)"
: `探索精神传承·思想之光(${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;
// 点击"批判现实"的处理
const handlePipingClick = () => {
if (!completedQuiz1) return; // 必须先完成题目1
setShowQuiz2(true);
};
// 题目1提交处理
const handleQuiz1Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setCompletedQuiz1(true);
setShowQuiz1(false);
setHintPosition("piping"); // 提示图标移动到"批判现实"旁边
}
};
// 题目2提交处理
const handleQuiz2Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setCompletedQuiz2(true);
setShowQuiz2(false);
setHintPosition("luxun2"); // 提示图标移动到"鲁迅2"旁边
}
};
// 点击"鲁迅2"的处理
const handleLuxun2Click = () => {
if (allCompleted) {
setShowSummary(true);
setHintPosition("none"); // 隐藏提示图标
}
};
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
className="select-none flex justify-between w-full h-full bg-cover bg-center bg-no-repeat gap-10 relative"
style={{
backgroundImage: getBackgroundImage(`${starName}/reflection/scene3/底图`),
backgroundImage: getBackgroundImage(
`${starName}/reflection/scene3/底图`
),
}}
>
<Title
title="跨越时空"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
{/* 离开按钮 */}
<Image
className="absolute right-5 top-5"
src={getStaticImage(`${starName}/reflection/scene3/离开`)}
className="absolute right-5 top-5 z-10"
src={getStaticImage(`${starName}/reflection/scene1/离开`)}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
navigate(`/${moduleName}/${starName}/reflection2`);
}
}}
/>
{/* 路线点击区域 - 根据实际位置调整 */}
<Cursor
className="absolute left-20 top-30"
onClick={() => handleOpenRoute(1)}
read={visited.has(1)}
/>
<Cursor
className="absolute left-40 top-35"
onClick={() => handleOpenRoute(2)}
read={visited.has(2)}
/>
<Cursor
className="absolute left-60 top-40"
onClick={() => handleOpenRoute(3)}
read={visited.has(3)}
/>
<Cursor
className="absolute left-50 top-50"
onClick={() => handleOpenRoute(4)}
read={visited.has(4)}
{/* 左侧:鲁迅的文学足迹 - 地图区域 */}
<div className="w-[44.5%] h-full relative">
{/* 家国情怀 - 根据showTopics控制显示 */}
{showTopics && (
<img
className={`absolute top-75 left-70 cursor-pointer transition-opacity ${
completedQuiz1 ? "opacity-50" : ""
}`}
src={getStaticImage(`${starName}/reflection/scene3/家国情怀`)}
alt="家国情怀"
onClick={handleJiaguoClick}
/>
)}
{/* 批判现实 - 根据showTopics和completedQuiz1控制显示 */}
{showTopics && (
<img
className={`absolute bottom-15 left-120 cursor-pointer transition-opacity ${
completedQuiz2 ? "opacity-50" : ""
}`}
src={getStaticImage(`${starName}/reflection/scene3/批判现实`)}
alt="批判现实"
onClick={handlePipingClick}
/>
)}
{/* 提示图标 - 家国情怀左侧 */}
{hintPosition === "jiaguo" && showTopics && (
<div className="absolute top-100 left-85 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
draggable={false}
src={getStaticImage(
`${starName}/reflection/scene1/鲁迅体悟点一点图标`
)}
alt="点一点图标"
/>
<img
draggable={false}
src={getStaticImage(
`${starName}/reflection/scene1/鲁迅体悟改版点一点`
)}
alt="点一点"
/>
</div>
)}
{/* 提示图标 - 批判现实旁边 */}
{hintPosition === "piping" && showTopics && completedQuiz1 && (
<div className="absolute bottom-0 left-180 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
draggable={false}
src={getStaticImage(
`${starName}/reflection/scene1/鲁迅体悟点一点图标`
)}
alt="点一点图标"
/>
<img
draggable={false}
src={getStaticImage(
`${starName}/reflection/scene1/鲁迅体悟改版点一点`
)}
alt="点一点"
/>
</div>
)}
</div>
{/* 右侧区域 */}
<div className="flex-1 relative">
{/* 任务进度 */}
<div className="absolute top-6 left-[23.8%] w-[430px] h-51 flex justify-center items-center">
<span className="text-white text-[54px] font-bold [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
{allCompleted
? "任务进度已完成"
: `任务进度:(${progress}/${totalTasks})`}
</span>
</div>
{/* 鲁迅精神内核 */}
<div className="absolute top-[calc(2.4rem+204px)] left-0 w-[995px] h-[525px] pt-16 px-10">
<div className="flex items-center justify-between">
{/* 提示图标 - 鲁迅1左侧 */}
{hintPosition === "luxun1" && (
<div className="absolute left-80 top-60 flex items-center z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
draggable={false}
src={getStaticImage(
`${starName}/reflection/scene1/鲁迅体悟点一点图标`
)}
alt="点一点图标"
/>
<img
draggable={false}
src={getStaticImage(
`${starName}/reflection/scene1/鲁迅体悟改版点一点`
)}
alt="点一点"
/>
</div>
)}
<img
className="cursor-pointer"
src={getStaticImage(`${starName}/reflection/scene3/鲁迅1`)}
alt="鲁迅精神内核"
onClick={handleLuxun1Click}
/>
<img
className="cursor-pointer"
src={getStaticImage(`${starName}/reflection/scene3/鲁迅2`)}
alt="鲁迅精神内核"
onClick={handleLuxun2Click}
/>
{/* 提示图标 - 鲁迅2旁边 */}
{hintPosition === "luxun2" && allCompleted && (
<div className="absolute right-0 top-70 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
draggable={false}
src={getStaticImage(
`${starName}/reflection/scene1/鲁迅体悟点一点图标`
)}
alt="点一点图标"
/>
<img
draggable={false}
src={getStaticImage(
`${starName}/reflection/scene1/鲁迅体悟改版点一点`
)}
alt="点一点"
/>
</div>
)}
</div>
</div>
{/* 精神总结 */}
<div className="absolute bottom-2 right-8 w-[995px] h-[285px] px-2 py-12">
{showSummary ? (
// 点击鲁迅2后显示总结内容
<div className="h-full px-8 overflow-y-auto custom-scrollbar">
<div className="space-y-0">
<div className="text-red-600 text-4xl font-bold leading-relaxed">
1、批判现实 (他以犀利的文笔批判封建传统);
</div>
<div className="text-red-600 text-4xl font-bold leading-relaxed">
2、关注底层 (他笔下的主角多是社会底层);
</div>
<div className="text-red-600 text-4xl font-bold leading-relaxed">
3、家国情怀 (他投身新文化与左翼运动);
</div>
</div>
</div>
) : (
// 未完成时显示提示内容
<div className="text-center text-black text-4xl font-bold">
请跟随小手指点一点,完成操作和题目以查看总结
</div>
)}
</div>
</div>
{/* 题目1:家国情怀 */}
<SingleChoiceQuizPanel
visible={showQuiz1}
title="【单选题】先生的作品《狂人日记》、《阿Q正传》都在揭露的是()"
options={[
{ value: "A", label: "A. 旧时代的黑暗与愚昧" },
{ value: "B", label: "B. 生活中的小美好" },
]}
correctAnswer="A"
errorMessage='不是哦,先生的小说核心是批判而非歌颂哦!"请结合阿Q的碎片回忆一下吧!"'
onSubmit={handleQuiz1Submit}
/>
{/* 主题点击区域 */}
<Cursor
className="absolute left-30 top-60"
onClick={() => handleOpenTheme(5)}
read={visited.has(5)}
/>
<Cursor
className="absolute left-70 top-55"
onClick={() => handleOpenTheme(6)}
read={visited.has(6)}
/>
{/* 路线展品弹窗 */}
<ExhibitOverlay
visible={activeRouteId !== null}
src={activeRouteId ? routeMap[activeRouteId] : ""}
onClose={() => {
if (activeRouteId !== null) {
setVisited((prev) => {
const next = new Set(prev);
next.add(activeRouteId);
return next;
});
}
setActiveRouteId(null);
}}
/>
{/* 主题展品弹窗 */}
<ExhibitOverlay
visible={activeThemeId !== null}
src={activeThemeId ? themeMap[activeThemeId] : ""}
onClose={() => {
if (activeThemeId !== null) {
setVisited((prev) => {
const next = new Set(prev);
next.add(activeThemeId);
return next;
});
}
setActiveThemeId(null);
}}
{/* 题目2:批判现实 */}
<SingleChoiceQuizPanel
visible={showQuiz2}
title="【单选题】先生辗转多地创作,核心追求是()"
options={[
{ value: "A", label: "A. 唤醒国人,改变社会" },
{ value: "B", label: "B. 只为个人成名" },
]}
correctAnswer="A"
errorMessage='不是哦,先生的创作从不是为了自己哦!"请回忆先生"弃医从文"的初心。"'
onSubmit={handleQuiz2Submit}
/>
</div>
);
+361 -17
View File
@@ -1,38 +1,382 @@
import { useEffect, useMemo, useState, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Image } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
type DragFillResult,
LetterToSunzhongshan,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
import { GuideSteps, GuideStepsItem } from "@/components";
type QuizOption = {
value: string;
label: string;
isCorrect?: boolean;
explanation?: string; // 错误选项的解释
};
type QuizAnchor = {
id: string;
time: number;
title: string;
prompt: string;
type: "single" | "multiple" | "drag";
options: QuizOption[];
// 拖拽题专用
blanks?: { id: string; label: string }[];
};
// 星名固定为 sunzhongshan
const starName = "sunzhongshan";
const SunzhongshanInternalization = () => {
// 锚点数据:可根据实际视频时长调整 time
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: "quiz-1",
time: 11, //5.52分钟
title: "题目1",
prompt: '"振兴中华"对我们新时代青年还有意义吗?',
type: "multiple",
options: [
{
value: "A",
label: "在民族复兴的征程上,解决发展中的新问题、实现更高水平的发展",
isCorrect: true,
},
{
value: "B",
label:
"当时的中国正遭受帝国主义列强的野蛮侵略和封建专制制度的腐朽统治,对于新时代的意义不大",
explanation:
'"振兴中华"需要每一代人的努力,先生当年在上海撰写的《实业计划》里如今已经实现的那些宏伟计划就是我国国家代代相传的结果',
},
{
value: "C",
label:
"我们要专注学习,努力提升自身技能和素养,用精益求精的精神服务于中国智造",
isCorrect: true,
},
{
value: "D",
label: "中华民族的伟大复兴离不开我们每一位新时代青年的奋斗",
isCorrect: true,
},
],
},
{
id: "quiz-2",
time: 42,
title: "题目2",
prompt: "我们新时代青年从孙中山先生身上能学到哪些?",
type: "multiple",
options: [
{
value: "A",
label: '"心系民众,天下为公"的家国情怀',
isCorrect: true,
},
{
value: "B",
label: "与时俱进,主动学习新知识,适应新形势",
isCorrect: true,
},
{ value: "C", label: "为中华民族伟大复兴而读书", isCorrect: true },
{
value: "D",
label: "将振兴中国之责任,置之于自身之肩上",
isCorrect: true,
},
],
},
];
const ChenyunInternalization = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const { moduleName } = useParams<{ moduleName: string }>();
const [activeSuccess, setActiveSuccess] = useState<string | null>(null);
const [successContent, setSuccessContent] = useState<string>("");
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(
null
);
// 拖拽题状态
const [dragQuizValues, setDragQuizValues] = useState<
Record<string, DragFillResult[]>
>({});
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>(
{}
);
const [visible, setVisible] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const [postGuideVisible, setPostGuideVisible] = useState(false);
const [showLetter, setShowLetter] = useState(false);
const [showStudyGuide, setShowStudyGuide] = useState(false);
const videoRef = useRef<VideoRef>(null);
useEffect(() => {
setVisible(true);
}, []);
// 视频加载完成后自动播放
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
videoRef.current.play().catch((error) => {
console.warn("视频播放失败:", error);
});
}
};
const anchors: VideoAnchor[] = useMemo(
() =>
QUIZ_ANCHORS.map((item) => {
const correctAnswers = item.options
.filter((opt) => opt.isCorrect)
.map((opt) => opt.value);
// 拖拽题处理
if (item.type === "drag" && item.blanks) {
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: ({ resume }) => {
const handleDragChange = (val: DragFillResult[]) => {
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }));
setDragQuizErrors((prev) => ({ ...prev, [item.id]: "" }));
if (val.length < correctAnswers.length) return;
// 检查是否包含了所有正确答案(不要求顺序)
const filledAnswers = val.map((v) => v.optionValue);
const answerSet = new Set(correctAnswers);
const filledSet = new Set(filledAnswers);
const isCorrect =
answerSet.size === filledSet.size &&
[...answerSet].every((answer) => filledSet.has(answer));
if (isCorrect) {
setActiveSuccess(item.id);
setSuccessContent("回答正确,请继续探索。");
setResumeCallback(() => resume);
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: "再想一想,确保选择了所有正确答案~",
}));
}
};
return (
<>
<DragQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.title}(拖拽)`}
questionPrefix={`${item.prompt}(`}
questionSuffix=")?"
options={item.options.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
blanks={item.blanks || []}
value={dragQuizValues[item.id] || []}
error={dragQuizErrors[item.id]}
onChange={handleDragChange}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null);
setSuccessContent("");
setDragQuizValues((prev) => {
const next = { ...prev };
delete next[item.id];
return next;
});
setDragQuizErrors((prev) => {
const next = { ...prev };
delete next[item.id];
return next;
});
const cb = resumeCallback;
setResumeCallback(null);
cb?.();
}}
/>
</>
);
},
};
}
// 多选题处理
if (item.type === "multiple") {
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: ({ resume }) => (
<>
<MultipleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.title}(多选题):${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers}
errorMessage="再想一想,确保选择了所有正确答案~"
onSubmit={(values: string[], isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id);
setSuccessContent("回答正确,请继续探索。");
setResumeCallback(() => resume);
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null);
setSuccessContent("");
const cb = resumeCallback;
setResumeCallback(null);
cb?.();
}}
/>
</>
),
};
}
// 单选题处理(保留原有逻辑,以防万一)
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: () => null,
};
}),
[
activeSuccess,
resumeCallback,
successContent,
dragQuizValues,
dragQuizErrors,
]
);
if (!moduleName) return null;
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
className="relative h-full w-full bg-black"
style={{
backgroundImage: getBackgroundImage(`${starName}/internalization/底图`),
backgroundImage: getBackgroundImage("components/internalization/bg"),
}}
>
{/* 离开按钮 */}
<Image
className="absolute right-5 top-5"
className="absolute right-8 top-8 z-10"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath("video/sunzhongshan/sunzhongshan_neihua.mp4")}
className="h-full w-full"
anchors={anchors}
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false);
setPostGuideVisible(true);
}}
/>
)}
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={(visible) => {
setVisible(visible);
if (!visible) {
setShowVideo(true);
handleVideoCanPlay();
}
}}
showMask={false}
>
<GuideStepsItem speaker="student">
小沪,参观完故居,我对孙中山先生从“医人”到“医国”的转变、《实业计划》里的宏大构想有了很多感触,但总觉得这些历史和精神离我们的生活还有些远,不知道该怎么真正理解和传承?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你的感受特别真实!孙中山先生的精神不是停留在书本里的文字,而是能指导我们当下行动的思想财富。今天我们特别邀请到了上海孙中山故居纪念馆研究部主任孙旻老师,让她带我们一起解读先生精神的时代意义,把这份传承落到实处。
</GuideStepsItem>
</GuideSteps>
{/* 视频结束后的引导 */}
<GuideSteps
visible={postGuideVisible}
onVisibleChange={(visible) => {
setPostGuideVisible(visible);
// 引导结束后显示信件
if (!visible) {
setShowLetter(true);
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem speaker="guide">
再看香山路7号这座小楼,相信你有很多心里话想对孙中山先生说。现在,就请你把这份感悟浓缩成几句话,既呼应先生的精神,也给自己未来许下一个承诺吧!
</GuideStepsItem>
</GuideSteps>
{/* 信件组件:视频引导结束后弹出,带飞入和放大动画 */}
<LetterToSunzhongshan
visible={showLetter}
onSubmit={(values: string[]) => {
// 提交信件的逻辑(values 为多个填空的内容)
console.log("提交信件", values);
setShowLetter(false);
setShowStudyGuide(true);
}}
/>
<div className="flex items-center justify-center h-full">
<div className="text-white text-4xl">孙中山故居-内化模块(待完善)</div>
</div>
<StudyGuideModal
visible={showStudyGuide}
onContinue={() => {
setShowStudyGuide(false);
navigate(`/${moduleName}/${starName}/home`);
}}
>
恭喜你已经顺利完成了孙中山故居纪念馆的学习,至此,你已经完成了本课程所有故居的学习,
希望你能有所收获,学习八位名人的优秀品质和精神,在新时代为中华民族伟大复兴发光发热!
{/* 同学你好,欢迎来到走进故居-对话学习模块!{"\n"}
进入陈云故居后,我们一起来了解陈云同志经历的7个阶段{"\n"}
“水乡少年 艰辛成长”、“商务启航 投身革命”、“苏区淬炼 崭露头角”、“延安担当
经济破局”、{"\n"}
“恢复时期 经济奠基”、“建设时期 务实兴邦”、“改革浪潮 誉画实业”; */}
</StudyGuideModal>
</div>
);
};
export default SunzhongshanInternalization;
export default ChenyunInternalization;
+2 -2
View File
@@ -53,8 +53,8 @@ const SunzhongshanPlace1 = () => {
const total = 5;
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;
+2 -2
View File
@@ -50,8 +50,8 @@ const SunzhongshanPlace2 = () => {
const total = 4;
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;
+2 -2
View File
@@ -31,8 +31,8 @@ const SunzhongshanPlace3 = () => {
const total = 4;
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;
+2 -2
View File
@@ -27,8 +27,8 @@ const SunzhongshanPlace4 = () => {
const total = 2;
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;