- 在 DragQuizPanel 组件中添加空白分隔符功能,增强用户交互性。 - 更新多个场景中的图像资源,确保视觉内容的准确性和一致性。 - 调整引导对话文本,提升信息传达的清晰度。 - 引入新的拖拽题逻辑,优化用户填写体验。
554 lines
20 KiB
TypeScript
554 lines
20 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,
|
|
LetterToLuxun,
|
|
StudyGuideModal,
|
|
Narration,
|
|
} from "@/components";
|
|
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
|
|
import { SuccessAlert, DragQuizPanel } 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;
|
|
// 拖拽题专用:题干后缀(空白区域之后的文本)
|
|
promptSuffix?: string;
|
|
type: "single" | "multiple" | "drag";
|
|
options: QuizOption[];
|
|
// 拖拽题专用
|
|
blanks?: { id: string; label: string }[];
|
|
// 拖拽题专用:空白之间的分隔符数组
|
|
blankSeparators?: string[];
|
|
// 拖拽题正确答案映射:blankId -> optionValue
|
|
correctAnswersMap?: Record<string, string>;
|
|
explanation?: string;
|
|
};
|
|
|
|
const starName = "luxun";
|
|
|
|
// 锚点数据:根据视频时长调整 time
|
|
const QUIZ_ANCHORS: QuizAnchor[] = [
|
|
{
|
|
id: "quiz-1",
|
|
time: 60 + 41, // 1:41
|
|
title: "",
|
|
prompt:
|
|
"【拖拽】有学者认为鲁迅有一个宏大的知识大众化计划,对于文盲,鲁迅推荐的是",
|
|
promptSuffix: "。",
|
|
type: "drag",
|
|
blanks: [
|
|
{ id: "blank-1", label: "___" },
|
|
{ id: "blank-2", label: "___" },
|
|
{ id: "blank-3", label: "___" },
|
|
],
|
|
options: [
|
|
{ value: "电影、讲演、戏剧演出", label: "电影、讲演、戏剧演出" },
|
|
{ value: "木刻艺术", label: "木刻艺术" },
|
|
{
|
|
value: "努力推行新文字和文学通俗化",
|
|
label: "努力推行新文字和文学通俗化",
|
|
},
|
|
],
|
|
blankSeparators: [
|
|
";在介于文盲和和识字者之间,鲁迅运用的是",
|
|
";鲁迅对作家们的要求是",
|
|
],
|
|
correctAnswersMap: {
|
|
"blank-1": "电影、讲演、戏剧演出",
|
|
"blank-2": "木刻艺术",
|
|
"blank-3": "努力推行新文字和文学通俗化",
|
|
},
|
|
explanation:
|
|
"有学者认为鲁迅有一个宏大的知识大众化计划,对于文盲,鲁迅推荐的是电影、讲演、戏剧演出;在介于文盲和识字者之间,鲁迅运用的是木刻艺术;鲁迅对作家们的要求是努力推行新文字和文学通俗化。",
|
|
},
|
|
{
|
|
id: "quiz-2",
|
|
time: 60 * 5 + 2, // 5:02
|
|
title: "",
|
|
prompt: "【多选】对于鼓励和培养青年一代,鲁迅先生的做法是( )。",
|
|
type: "multiple",
|
|
options: [
|
|
{ value: "A", label: "组织文学社团", isCorrect: true },
|
|
{ value: "B", label: "木刻运动", isCorrect: true },
|
|
{ value: "C", label: "写纪念烈士文章", isCorrect: true },
|
|
// {
|
|
// value: "D",
|
|
// label: "让青年都去参军",
|
|
// },
|
|
],
|
|
// explanation:
|
|
// "虽然鲁迅热爱革命,但他主要通过文学社团、木刻艺术和纪念文章来教育和启发青年,而不是通过参军来培养青年。",
|
|
explanation: "请你再想想吧!",
|
|
},
|
|
{
|
|
id: "quiz-3",
|
|
time: 60 * 6 + 39, // 6:39
|
|
title: "",
|
|
prompt: "【单选】以下那句诗出自鲁迅的作品( )。",
|
|
type: "single",
|
|
options: [
|
|
{
|
|
value: "A",
|
|
label: "寄意寒星荃不察,我以我血荐轩辕",
|
|
isCorrect: true,
|
|
explanation: "本句出自鲁迅的《自题小像》",
|
|
},
|
|
{
|
|
value: "B",
|
|
label: "我自横刀向天笑,去留肝胆两昆仑",
|
|
explanation: "本句出自谭嗣同的《狱中题壁》,请再想想吧!",
|
|
},
|
|
{
|
|
value: "C",
|
|
label: "此去泉台招旧部,旌旗十万斩阎罗",
|
|
explanation: "本句出自陈毅的《梅岭三章》,请再想想吧!",
|
|
},
|
|
{
|
|
value: "D",
|
|
label: "雄关漫道真如铁,而今迈步从头越",
|
|
explanation: "本句出自毛泽东的《忆秦娥・娄山关》,请再想想吧!",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const LuxunInternalization = () => {
|
|
const navigate = useNavigate();
|
|
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 [showNarration, setShowNarration] = useState(false); // 是否显示旁白
|
|
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);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
// 旁白关闭后显示对话引导
|
|
useEffect(() => {
|
|
if (!showNarration) {
|
|
setVisible(true);
|
|
}
|
|
}, [showNarration]);
|
|
|
|
// 组件卸载时清理背景音乐
|
|
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 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 (!item.blanks || val.length < item.blanks.length) return;
|
|
|
|
// 如果有位置特定的答案映射,使用它进行验证
|
|
if (item.correctAnswersMap) {
|
|
const isCorrect = item.blanks.every((blank) => {
|
|
const filledResult = val.find(
|
|
(v) => v.blankId === blank.id
|
|
);
|
|
const correctAnswer = item.correctAnswersMap![blank.id];
|
|
return filledResult?.optionValue === correctAnswer;
|
|
});
|
|
|
|
if (isCorrect) {
|
|
setActiveSuccess(item.id);
|
|
setSuccessContent("恭喜你,匹配正确!");
|
|
setResumeCallback(() => resume);
|
|
} else {
|
|
setDragQuizErrors((prev) => ({
|
|
...prev,
|
|
[item.id]: item.explanation || "再想一想,选择正确答案~",
|
|
}));
|
|
}
|
|
} else {
|
|
// 如果没有位置特定的映射,使用原来的逻辑(检查是否包含了所有正确答案)
|
|
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]: item.explanation || "再想一想,选择正确答案~",
|
|
}));
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<DragQuizPanel
|
|
visible={activeSuccess !== item.id}
|
|
zIndex={10000}
|
|
title=""
|
|
questionPrefix={item.prompt}
|
|
questionSuffix={item.promptSuffix || ""}
|
|
options={item.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: opt.label,
|
|
}))}
|
|
blanks={item.blanks || []}
|
|
blankSeparators={item.blankSeparators}
|
|
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 === "single") {
|
|
return {
|
|
id: item.id,
|
|
time: item.time,
|
|
once: true,
|
|
afterResume: "remove",
|
|
render: ({ resume }) => (
|
|
<>
|
|
<SingleChoiceQuizPanel
|
|
visible={activeSuccess !== item.id}
|
|
zIndex={10000}
|
|
title={`${item.prompt}`}
|
|
options={item.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: `${opt.value}. ${opt.label}`,
|
|
}))}
|
|
correctAnswer={correctAnswers[0]}
|
|
errorMessage="再思考一下,选择正确答案。"
|
|
onWrongSelect={(value: string) => {
|
|
// 为题目3提供详细的选项解释
|
|
if (item.id === "quiz-3") {
|
|
const selectedOption = item.options.find(
|
|
(opt) => opt.value === value
|
|
);
|
|
return (
|
|
selectedOption?.explanation ||
|
|
"再思考一下,选择正确答案。"
|
|
);
|
|
}
|
|
return undefined;
|
|
}}
|
|
onSubmit={(value: string, isCorrect: boolean) => {
|
|
if (isCorrect) {
|
|
setActiveSuccess(item.id);
|
|
// 为题目3提供正确答案的详细解释
|
|
if (item.id === "quiz-3") {
|
|
const correctOption = item.options.find(
|
|
(opt) => opt.isCorrect
|
|
);
|
|
setSuccessContent(
|
|
correctOption?.explanation || "回答正确,请继续探索。"
|
|
);
|
|
} else {
|
|
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.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,
|
|
]
|
|
);
|
|
|
|
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`)}
|
|
/>
|
|
|
|
{/* 内化结束后的背景音乐 */}
|
|
<audio
|
|
ref={audioRef}
|
|
src={getPublicPath("audio/letter/3我和我的祖国.m4a")}
|
|
loop
|
|
/>
|
|
|
|
{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);
|
|
if (audioRef.current) {
|
|
audioRef.current.currentTime = 0;
|
|
audioRef.current.play().catch((error) => {
|
|
console.warn("背景音乐播放失败:", error);
|
|
});
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 旁白 */}
|
|
<Narration
|
|
visible={showNarration}
|
|
text={`走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。那些针砭时弊的杂文、借古讽今的篇章,还有藏在6平方米客房里的红色坚守,都在诉说着先生的热忱。1907年,鲁迅先生在《文化偏至论》里写到:"是故将生存两间,角逐列国是务,其首在立人,人立而后凡事举;若其道术,乃必尊个性而张精神。"先生认为,中华民族要屹立于世界民族之林,"首在立人","立人"思想从此贯穿在鲁迅先生的人生和作品中。`}
|
|
onClose={() => {
|
|
setShowNarration(false);
|
|
}}
|
|
/>
|
|
|
|
{/* 对话引导 */}
|
|
<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);
|
|
}}
|
|
/>
|
|
|
|
<StudyGuideModal
|
|
visible={showStudyGuide}
|
|
onContinue={() => {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current.currentTime = 0;
|
|
}
|
|
setShowStudyGuide(false);
|
|
navigate(`/${moduleName}/${starName}/home`);
|
|
}}
|
|
>
|
|
恭喜你已经顺利完成了鲁迅故居的学习,请继续前往下一个故居吧!
|
|
</StudyGuideModal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LuxunInternalization;
|