- 在 main.tsx 中添加全局键盘快捷键功能,允许用户通过 Ctrl+Shift+R 重置所有缓存。 - 更新多个故居的拼图数据,增加黄炎培和鲁迅的相关信息,丰富学习内容。 - 调整 ResidenceLayout 组件以支持新的故居,确保数据一致性。 - 清理不再使用的拼图布局配置文件,提升代码整洁性。
515 lines
17 KiB
TypeScript
515 lines
17 KiB
TypeScript
import { useEffect, useMemo, useState, useRef } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import {
|
|
Image,
|
|
Video,
|
|
type VideoAnchor,
|
|
type VideoRef,
|
|
type DragFillResult,
|
|
LetterToHuangyanpei,
|
|
EndingOverlay,
|
|
Narration,
|
|
} 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";
|
|
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
|
|
|
|
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 }[];
|
|
explanation?: 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: "自动控制",
|
|
// },
|
|
// { value: "C", label: "木工", isCorrect: true },
|
|
// { value: "D", label: "纽扣科", isCorrect: true },
|
|
// ],
|
|
// explanation:
|
|
// "自动控制”这一工程理论框架成形于20世纪中叶,70年代实现集中控制,在民国时期,没有相关学科基础、设备与技术条件",
|
|
// },
|
|
{
|
|
id: "quiz-2",
|
|
time: 221, // 3:41
|
|
title: "题目2",
|
|
prompt:
|
|
"黄炎培先生主导设计的课程与现在中职学校强调的理实一体这一理念相通是:",
|
|
type: "multiple",
|
|
options: [
|
|
{
|
|
value: "A",
|
|
label: "上午学机械原理,下午就去车间打铁、造台钳",
|
|
isCorrect: true,
|
|
},
|
|
{
|
|
value: "B",
|
|
label: "课堂上学记账、推销,课后就去街头卖国货",
|
|
isCorrect: true,
|
|
},
|
|
{
|
|
value: "C",
|
|
label: "学习四书五经、八股文",
|
|
},
|
|
{
|
|
value: "D",
|
|
label: '在川沙小学堂采取"半天读书、半天做手工"的学习模式',
|
|
isCorrect: true,
|
|
},
|
|
],
|
|
explanation: "黄炎培认为教育不能只谈书本、脱离实际",
|
|
},
|
|
{
|
|
id: "quiz-3",
|
|
time: 360, // 6:10
|
|
title: "题目3",
|
|
prompt:
|
|
'当年黄炎培先生有推广国货的爱国情怀,如今我们的"国货"已经自强自立,请将其中的国产企业/品牌放置到空格处',
|
|
type: "drag",
|
|
options: [
|
|
{ value: "特斯拉", label: "特斯拉" },
|
|
{ value: "比亚迪", label: "比亚迪" },
|
|
{ value: "华为", label: "华为" },
|
|
{ value: "江南造船", label: "江南造船" },
|
|
{ value: "光明食品", label: "光明食品" },
|
|
{ value: "C919飞机", label: "C919飞机" },
|
|
],
|
|
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 { triggerPuzzleAnimation } = usePuzzleAnimation();
|
|
|
|
const [narrationVisible, setNarrationVisible] = useState(false);
|
|
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 [showLetterVideo, setShowLetterVideo] = useState(false);
|
|
const [showStudyGuide, setShowStudyGuide] = useState(false);
|
|
const videoRef = useRef<VideoRef>(null);
|
|
const letterVideoRef = useRef<VideoRef>(null);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
setNarrationVisible(true);
|
|
}, []);
|
|
|
|
// 组件卸载时清理背景音乐
|
|
useEffect(() => {
|
|
return () => {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current.currentTime = 0;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const handleVideoCanPlay = () => {
|
|
if (showVideo && videoRef.current) {
|
|
videoRef.current.play().catch((error) => {
|
|
console.warn("视频播放失败:", error);
|
|
});
|
|
}
|
|
};
|
|
|
|
// 信件视频加载完成后自动播放
|
|
const handleLetterVideoCanPlay = () => {
|
|
if (showLetterVideo && letterVideoRef.current) {
|
|
letterVideoRef.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 = [
|
|
"比亚迪",
|
|
"华为",
|
|
"江南造船",
|
|
"光明食品",
|
|
"C919飞机",
|
|
];
|
|
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={`【拖拽】`}
|
|
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?.();
|
|
triggerPuzzleAnimation(16);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
// 多选题处理
|
|
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.prompt}`}
|
|
options={item.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: `${opt.value}. ${opt.label}`,
|
|
}))}
|
|
correctAnswer={correctAnswers}
|
|
errorMessage={
|
|
item.explanation || "再思考一下,注意选择所有正确答案。"
|
|
}
|
|
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,
|
|
triggerPuzzleAnimation,
|
|
]
|
|
);
|
|
|
|
if (!moduleName) return null;
|
|
|
|
return (
|
|
<div
|
|
className="relative h-full w-full bg-black"
|
|
style={{
|
|
backgroundImage: getBackgroundImage("components/internalization/bg"),
|
|
}}
|
|
>
|
|
{/* 离开按钮 */}
|
|
<Image
|
|
className="absolute right-8 top-8 z-10"
|
|
src={getStaticImage("components/离开")}
|
|
button
|
|
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
|
|
/>
|
|
|
|
<Narration
|
|
visible={narrationVisible}
|
|
onClose={() => {
|
|
setNarrationVisible(false);
|
|
setVisible(true);
|
|
}}
|
|
audioSrc={getPublicPath("audio/huangyanpei/mp3_huangyanpei_27.mp3")}
|
|
text={`从川沙内史第的少年求学路,到中华职业教育社的拓荒征程,黄炎培先生用一生践行“教育救国”的初心,以“手脑并用”的理念为中国职业教育埋下火种。百年后的今天,这颗火种已长成燎原之势,照亮无数中职学子的成才之路。站在新时代的门槛上,我们回望先生的足迹,不仅是缅怀一段历史,更是要让“职教兴国”的精神,成为当代青年成长的指引。`}
|
|
animationSpeed={200}
|
|
/>
|
|
|
|
{/* 内化结束后的背景音乐 */}
|
|
<audio
|
|
ref={audioRef}
|
|
src={getPublicPath("audio/letter/4不忘初心.m4a")}
|
|
loop
|
|
/>
|
|
|
|
{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);
|
|
if (audioRef.current) {
|
|
audioRef.current.currentTime = 0;
|
|
audioRef.current.play().catch((error) => {
|
|
console.warn("背景音乐播放失败:", error);
|
|
});
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 引导 */}
|
|
<GuideSteps
|
|
visible={visible}
|
|
onVisibleChange={(visible) => {
|
|
setVisible(visible);
|
|
if (!visible) {
|
|
setShowVideo(true);
|
|
handleVideoCanPlay();
|
|
}
|
|
}}
|
|
showMask={false}
|
|
>
|
|
<GuideStepsItem
|
|
speaker="student"
|
|
audioSrc={getPublicPath("audio/huangyanpei/mp3_huangyanpei_28.mp3")}
|
|
>
|
|
小沪,刚刚结束的"小职成长记"太有意思了!我变成百年前的川沙少年选课程、做手工、设计国货推销方案,好像真的走进了黄炎培先生的职教世界。
|
|
</GuideStepsItem>
|
|
<GuideStepsItem speaker="student">
|
|
但我还有一个疑问:先生当年倡导的"手脑并用、工学结合",放在我们现在的中职学习中,到底有什么样的意义呢?
|
|
</GuideStepsItem>
|
|
<GuideStepsItem
|
|
speaker="guide"
|
|
audioSrc={getPublicPath("audio/huangyanpei/mp3_huangyanpei_29.mp3")}
|
|
>
|
|
这个问题问到点子上啦!黄炎培先生的职教思想,从来不是尘封的历史,而是活在当下的行动指南。今天我们特别邀请到中华职业教育社第十三届理事会常务理事、中华职业学校党总支副书记、校长黄玉璟,让她带我们解码先生精神的时代密码!
|
|
</GuideStepsItem>
|
|
</GuideSteps>
|
|
|
|
{/* 视频结束后的引导 */}
|
|
<GuideSteps
|
|
visible={postGuideVisible}
|
|
onVisibleChange={(visible) => {
|
|
setPostGuideVisible(visible);
|
|
if (!visible) {
|
|
setShowLetter(true);
|
|
}
|
|
}}
|
|
showMask={false}
|
|
zIndex={60}
|
|
>
|
|
<GuideStepsItem
|
|
speaker="guide"
|
|
audioSrc={getPublicPath("audio/huangyanpei/mp3_huangyanpei_30.mp3")}
|
|
>
|
|
听完黄校长的讲解,相信你对黄炎培先生的职教精神有了更深的理解。现在,就请你把这份感悟写下来,既总结参观的收获,也给自己未来许下一个承诺吧!
|
|
</GuideStepsItem>
|
|
</GuideSteps>
|
|
|
|
{/* 信件组件 */}
|
|
<LetterToHuangyanpei
|
|
visible={showLetter}
|
|
onSubmit={(values: string[]) => {
|
|
console.log("提交信件", values);
|
|
setShowLetter(false);
|
|
setShowLetterVideo(true);
|
|
}}
|
|
/>
|
|
|
|
{/* 信件关闭后的视频 */}
|
|
{showLetterVideo && (
|
|
<Video
|
|
ref={letterVideoRef}
|
|
src={getPublicPath("video/letter/neihua_jixin.mp4")}
|
|
className="h-full w-full"
|
|
playsInline
|
|
onCanPlay={handleLetterVideoCanPlay}
|
|
onEnded={() => {
|
|
setShowLetterVideo(false);
|
|
setShowStudyGuide(true);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<EndingOverlay
|
|
visible={showStudyGuide}
|
|
text="恭喜你已经顺利完成了黄炎培故居的学习,请继续前往下一个故居吧!"
|
|
animationSpeed={200}
|
|
onContinue={() => {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current.currentTime = 0;
|
|
}
|
|
setShowStudyGuide(false);
|
|
navigate(`/${moduleName}/${starName}/home`);
|
|
}}
|
|
audioSrc={getPublicPath("audio/huangyanpei/mp3_huangyanpei_31.mp3")}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default HuangyanpeiInternalization;
|