feat: 更新拖拽题功能和图像资源,提升学习体验

- 在 DragQuizPanel 组件中添加空白分隔符功能,增强用户交互性。
- 更新多个场景中的图像资源,确保视觉内容的准确性和一致性。
- 调整引导对话文本,提升信息传达的清晰度。
- 引入新的拖拽题逻辑,优化用户填写体验。
This commit is contained in:
zhongyidian committed 2026-01-18 16:45:06 +08:00
1 parent 9184c41cc7
commit cf64e7ec50
25 files changed
+726 -241

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+28 -6
View File
@@ -1,4 +1,5 @@
import { DragFill, type DragFillResult } from "@/components";
import { useEffect, useRef } from "react";
import { DragFill } from "@/components";
import { cn } from "@/lib/utils";
import type { DragQuizPanelProps } from "./types";
@@ -11,10 +12,24 @@ const DragQuizPanel = ({
questionSuffix,
options,
blanks,
blankSeparators,
value,
error,
onChange,
}: DragQuizPanelProps) => {
// 跟踪之前的 visible 状态
const prevVisibleRef = useRef(visible);
// 当窗口关闭时,清除拖拽状态
useEffect(() => {
// 如果从可见变为不可见,清除拖拽状态
if (prevVisibleRef.current && !visible && value.length > 0) {
onChange([]);
}
prevVisibleRef.current = visible;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
if (!visible) return null;
return (
@@ -27,6 +42,7 @@ const DragQuizPanel = ({
<div className="w-[1700px] rounded-[32px] bg-black/90 px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
<div className="text-[32px] leading-relaxed text-white">
{title && <div className="mb-4">{title}</div>}
<div>
{questionPrefix && <span>{questionPrefix}</span>}
{blanks.map((blank, index) => {
@@ -67,12 +83,22 @@ const DragQuizPanel = ({
)}
</span>
{index < blanks.length - 1 && (
<span className="text-white/80 mx-1">、</span>
<span className="text-white/80 mx-1">
{blankSeparators && blankSeparators[index]
? blankSeparators[index]
: "、"}
</span>
)}
</span>
);
})}
{questionSuffix && <span>{questionSuffix}</span>}
{/* 答案反馈 */}
{error && (
<div className="mt-5 text-center text-[26px] text-[#fcbe53]">
{error}
</div>
)}
</div>
</div>
</div>
@@ -114,10 +140,6 @@ const DragQuizPanel = ({
}}
/>
</div>
{error && (
<div className="text-center text-[26px] text-[#ff4d4f]">{error}</div>
)}
</div>
</div>
);
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { MultipleChoice } from "@/components";
import type { MultipleChoiceQuizPanelProps } from "./types";
@@ -49,6 +49,14 @@ const MultipleChoiceQuizPanel = ({
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const [error, setError] = useState<string>("");
// 当窗口关闭时,清除选中状态和错误状态
useEffect(() => {
if (!visible) {
setSelectedValues([]);
setError("");
}
}, [visible]);
if (!visible) return null;
// 处理选项点击
+2
View File
@@ -21,6 +21,8 @@ export interface DragQuizPanelProps {
options: { value: string; label: string }[];
/** 空白区域列表 */
blanks: DragFillBlank[];
/** 空白之间的分隔符数组(可选,如果不提供则使用默认的"、") */
blankSeparators?: string[];
/** 当前值 */
value: DragFillResult[];
/** 错误提示信息 */
+2 -2
View File
@@ -317,13 +317,13 @@ const LetterToLuxun = ({
</div>
{/* 寄信图标(可选,如果需要的话) */}
<div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
{/* <div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
<img
src={getStaticImage("components/letter/寄信图标")}
alt="寄信"
className="w-full h-full object-contain"
/>
</div>
</div> */}
</div>
{/* 背面:只读态 */}
+219 -100
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { getStaticImage } from "@/lib/utils";
import { Image } from "@/components";
import { Image, Select } from "@/components";
import { ErrorAlert } from "@/components/layout";
export interface LetterToSunzhongshanProps {
/** 是否可见 */
@@ -29,7 +30,25 @@ const LetterToSunzhongshan = ({
// 是否已提交:提交后隐藏编辑信件,展示只读信件
const [submitted, setSubmitted] = useState(false);
// 校验输入:不能为空、每个不超过 10 个字符
// ErrorAlert 相关状态
const [showErrorAlert, setShowErrorAlert] = useState(false);
const [errorAlertText, setErrorAlertText] = useState("");
// 前三个空的正确答案
const BLANK_0_ANSWER = "振兴中华";
const BLANK_1_ANSWER = "天下为公";
const BLANK_2_ANSWER = "与时俱进";
// 最后一个空的正确答案
const LAST_BLANK_ANSWER = "仍须努力";
// 前三个空的选项(所有空都用这三个选项)
const SELECT_OPTIONS = [
{ value: "振兴中华", label: "振兴中华" },
{ value: "天下为公", label: "天下为公" },
{ value: "与时俱进", label: "与时俱进" },
];
// 校验输入:前三个空必须选择正确答案,最后一个空必须是"仍须努力",其他空不能为空且不超过 10 个字符
const validateBlanks = () => {
// 计算占位总数,防止有些没被输入过而是 undefined
const placeholderCount = (letterContent.match(/____/g) || []).length;
@@ -42,11 +61,27 @@ const LetterToSunzhongshan = ({
const raw = blankValues[i] || "";
const v = raw.trim();
// 为空 或 超过 10 个字,都视为不符合要求:清空并标红
if (!v || v.length > 10) {
nextValues[i] = "";
nextErrors[i] = true;
hasError = true;
if (i < 3) {
// 前三个空:必须选择正确答案(已在 onChange 中验证,这里只检查是否为空)
if (!v) {
nextValues[i] = "";
nextErrors[i] = true;
hasError = true;
}
} else if (i === placeholderCount - 1) {
// 最后一个空:必须是"仍须努力"
if (!v || v !== LAST_BLANK_ANSWER) {
nextValues[i] = "";
nextErrors[i] = true;
hasError = true;
}
} else {
// 其他空:不能为空,不超过 10 个字
if (!v || v.length > 10) {
nextValues[i] = "";
nextErrors[i] = true;
hasError = true;
}
}
}
@@ -62,6 +97,8 @@ const LetterToSunzhongshan = ({
setSubmitted(false);
setBlankValues([]);
setBlankErrors([]);
setShowErrorAlert(false);
setErrorAlertText("");
}
}, [visible]);
@@ -70,11 +107,11 @@ const LetterToSunzhongshan = ({
您好!
今天,我怀着崇敬的心情走进香山路7号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为"振兴中华"奔走的身影。在李教授的解读中,我终于明白:您喊出的"____",不是遥远的历史,而是我们这代人正在接续的接力;您追求的"____(空里填天下为公)",不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的"____(空里填与时俱进)",不是过去的选择,而是面对未来时必须有的勇气。
今天,我怀着崇敬的心情走进香山路7号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为“振兴中华”奔走的身影。在这场跨越时空的对话中,我终于明白,您喊出的“____”,不是遥远的历史,而是我们这代人正在接续的接力;您追求的“____”,不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的“____”,不是过去的选择,而是面对未来时必须有的勇气。
我是____专业的学生,今后,我会以您"知难行易"的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您"心系民众"的情怀,参与____,帮身边人解决实际困难;我更会记住您"适乎世界之潮流"的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。
我是____专业的学生,今后,我会以您“知难行易”的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您“心系民众”的情怀,参与____,帮身边人解决实际困难;我更会记住您“适乎世界之潮流”的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。
请您放心,"革命尚未成功,同志仍须努力"的嘱托,我们从未忘记;"振兴中华"的初心,我们会用一生践行!`;
请您放心,“革命尚未成功,同志____”的嘱托,我们从未忘记;“振兴中华”的初心,我们会用一生践行!`;
return (
<AnimatePresence>
@@ -151,66 +188,132 @@ const LetterToSunzhongshan = ({
{(() => {
let placeholderIndex = 0;
let foundNiHao = false;
return letterContent.split("\n").map((line, lineIndex) => {
// 检测是否已经找到"您好"行
if (line.includes("您好")) {
foundNiHao = true;
}
return letterContent
.split("\n")
.map((line, lineIndex) => {
// 检测是否已经找到"您好"行
if (line.includes("您好")) {
foundNiHao = true;
}
// "您好"行之后的非空行需要首行缩进
const needsIndent =
foundNiHao &&
line.trim() !== "" &&
!line.includes("您好");
// "您好"行之后的非空行需要首行缩进
const needsIndent =
foundNiHao &&
line.trim() !== "" &&
!line.includes("您好");
const segments = line.split("____");
return (
<div
key={lineIndex}
className="mb-2"
style={needsIndent ? { textIndent: "2em" } : {}}
>
{segments.map((part, partIndex) => {
const isLast =
partIndex === segments.length - 1;
if (isLast) {
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
}
const currentIndex = placeholderIndex++;
const isSelectFill = currentIndex < 3; // 前三个空使用 Select
const segments = line.split("____");
return (
<div
key={lineIndex}
className="mb-2"
style={needsIndent ? { textIndent: "2em" } : {}}
>
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1;
if (isLast) {
return (
<span key={`part-${lineIndex}-${partIndex}`}>
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
{isSelectFill ? (
// 前三个空:下拉选择
<Select
className="inline"
options={SELECT_OPTIONS}
value={
blankValues[currentIndex] ?? ""
}
onChange={(selectedValue) => {
const next = [...blankValues];
next[currentIndex] = selectedValue;
setBlankValues(next);
// 清除错误状态
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = false;
setBlankErrors(nextErrs);
}
// 立即验证选择是否正确
let correctAnswer = "";
if (currentIndex === 0) {
correctAnswer = BLANK_0_ANSWER;
} else if (currentIndex === 1) {
correctAnswer = BLANK_1_ANSWER;
} else if (currentIndex === 2) {
correctAnswer = BLANK_2_ANSWER;
}
if (
correctAnswer &&
selectedValue !== correctAnswer
) {
// 选择错误,显示错误提示
setErrorAlertText(
"请选择正确答案"
);
setShowErrorAlert(true);
// 清空选择
next[currentIndex] = "";
setBlankValues(next);
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = true;
setBlankErrors(nextErrs);
}
}}
placeholder="请选择"
error={blankErrors[currentIndex]}
zIndex={10000}
fitContent={true}
/>
) : (
// 其他空:文本输入
<input
name={`blank-${currentIndex}`}
className={`inline-block w-[110px] h-[36px] mx-2 mb-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
blankErrors[currentIndex]
? "border-red-500"
: "border-[#4e402e]"
}`}
value={
blankValues[currentIndex] ?? ""
}
onChange={(e) => {
const next = [...blankValues];
next[currentIndex] = e.target.value;
setBlankValues(next);
// 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = false;
setBlankErrors(nextErrs);
}
}}
/>
)}
</span>
);
}
const currentIndex = placeholderIndex++;
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
<input
name={`blank-${currentIndex}`}
className={`inline-block w-[100px] h-[36px] mx-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
blankErrors[currentIndex]
? "border-red-500"
: "border-[#4e402e]"
}`}
value={blankValues[currentIndex] ?? ""}
onChange={(e) => {
const next = [...blankValues];
next[currentIndex] = e.target.value;
setBlankValues(next);
// 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = false;
setBlankErrors(nextErrs);
}
}}
/>
</span>
);
})}
</div>
);
});
})}
</div>
);
});
})()}
</div>
</div>
@@ -238,13 +341,13 @@ const LetterToSunzhongshan = ({
</div>
{/* 寄信图标(可选,如果需要的话) */}
<div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
{/* <div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
<img
src={getStaticImage("components/letter/寄信图标")}
alt="寄信"
className="w-full h-full object-contain"
/>
</div>
</div> */}
</div>
{/* 背面:只读态 */}
@@ -279,46 +382,53 @@ const LetterToSunzhongshan = ({
{(() => {
let placeholderIndex = 0;
let foundNiHao = false;
return letterContent.split("\n").map((line, lineIndex) => {
if (line.includes("您好")) {
foundNiHao = true;
}
return letterContent
.split("\n")
.map((line, lineIndex) => {
if (line.includes("您好")) {
foundNiHao = true;
}
const needsIndent =
foundNiHao &&
line.trim() !== "" &&
!line.includes("您好");
const needsIndent =
foundNiHao &&
line.trim() !== "" &&
!line.includes("您好");
const segments = line.split("____");
return (
<div
key={lineIndex}
className="mb-2"
style={needsIndent ? { textIndent: "2em" } : {}}
>
{segments.map((part, partIndex) => {
const isLast =
partIndex === segments.length - 1;
if (isLast) {
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
}
const currentIndex = placeholderIndex++;
const value = blankValues[currentIndex] ?? "";
const segments = line.split("____");
return (
<div
key={lineIndex}
className="mb-2"
style={needsIndent ? { textIndent: "2em" } : {}}
>
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1;
if (isLast) {
return (
<span key={`part-${lineIndex}-${partIndex}`}>
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
{value || "  "}
</span>
);
}
const currentIndex = placeholderIndex++;
const value = blankValues[currentIndex] ?? "";
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{value || "  "}
</span>
);
})}
</div>
);
});
})}
</div>
);
});
})()}
</div>
</div>
@@ -338,6 +448,15 @@ const LetterToSunzhongshan = ({
</motion.div>
</div>
</motion.div>
{/* 错误提示弹窗 */}
<ErrorAlert
visible={showErrorAlert}
text={errorAlertText}
onClose={() => setShowErrorAlert(false)}
onMaskClick={() => setShowErrorAlert(false)}
zIndex={10002}
/>
</>
)}
</AnimatePresence>
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from "@/components";
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { useState, useMemo } from "react";
import { useState } from "react";
import Cursor from "@/components/cursor";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
import { useChenyunStore } from "@/store/chenyunStore";
+159 -15
View File
@@ -5,12 +5,13 @@ import {
Video,
type VideoAnchor,
type VideoRef,
type DragFillResult,
LetterToLuxun,
StudyGuideModal,
Narration,
} from "@/components";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { SuccessAlert } from "@/components/layout";
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
import {
MultipleChoiceQuizPanel,
SingleChoiceQuizPanel,
@@ -29,8 +30,16 @@ type QuizAnchor = {
time: number;
title: string;
prompt: string;
type: "single" | "multiple";
// 拖拽题专用:题干后缀(空白区域之后的文本)
promptSuffix?: string;
type: "single" | "multiple" | "drag";
options: QuizOption[];
// 拖拽题专用
blanks?: { id: string; label: string }[];
// 拖拽题专用:空白之间的分隔符数组
blankSeparators?: string[];
// 拖拽题正确答案映射:blankId -> optionValue
correctAnswersMap?: Record<string, string>;
explanation?: string;
};
@@ -40,20 +49,40 @@ const starName = "luxun";
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: "quiz-1",
time: 155, // 2:35
time: 60 + 41, // 1:41
title: "",
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 },
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: 355, // 5:55
time: 60 * 5 + 2, // 5:02
title: "",
prompt: "【多选】对于鼓励和培养青年一代,鲁迅先生的做法是( )。",
type: "multiple",
@@ -72,7 +101,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
id: "quiz-3",
time: 420, // 7:00
time: 60 * 6 + 39, // 6:39
title: "",
prompt: "【单选】以下那句诗出自鲁迅的作品( )。",
type: "single",
@@ -110,7 +139,14 @@ const LuxunInternalization = () => {
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(
null
);
const [showNarration, setShowNarration] = useState(true); // 是否显示旁白
// 拖拽题状态
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);
@@ -151,6 +187,108 @@ const LuxunInternalization = () => {
.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 {
@@ -272,7 +410,13 @@ const LuxunInternalization = () => {
render: () => null,
};
}),
[activeSuccess, resumeCallback, successContent]
[
activeSuccess,
resumeCallback,
successContent,
dragQuizValues,
dragQuizErrors,
]
);
if (!moduleName) return null;
@@ -343,7 +487,7 @@ const LuxunInternalization = () => {
showMask={false}
>
<GuideStepsItem speaker="guide">
小沪,关于"首在立人"走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。
关于"首在立人"走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
那些针砭时弊的杂文、借古讽今的篇章,还有藏在6平方米客房里的红色坚守,都在诉说着先生的热忱。
+12
View File
@@ -10,6 +10,7 @@ import {
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useState, useEffect } from "react";
import { useCursorController } from "@/hooks/useCursorController";
// starName 固定为 luxun
const starName = "luxun";
@@ -45,6 +46,15 @@ const LuxunPlace1 = () => {
// 已浏览展品的 ID 集合
const [visited, setVisited] = useState<Set<number>>(new Set());
// Cursor 顺序控制:1 -> 2
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
},
visited: visited,
});
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 1) {
setVisibleIndex(0);
@@ -102,12 +112,14 @@ const LuxunPlace1 = () => {
className="absolute left-[calc(50%-48px)] top-80"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 展品2 */}
<Cursor
className="absolute left-[calc(50%)] bottom-60"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 继续前进 - 仅在完成所有展品且对话结束后显示 */}
+14
View File
@@ -9,6 +9,7 @@ import {
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useState } from "react";
import { useCursorController } from "@/hooks/useCursorController";
// starName 固定为 luxun
const starName = "luxun";
@@ -31,6 +32,16 @@ const LuxunPlace2 = () => {
);
const [visited, setVisited] = useState<Set<number>>(new Set());
// Cursor 顺序控制:1 -> 2 -> 3
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
3: [2],
},
visited: visited,
});
// 光标1的引导对话状态
const [guide1Visible, setGuide1Visible] = useState(false);
const [guide1Step, setGuide1Step] = useState(0);
@@ -123,6 +134,7 @@ const LuxunPlace2 = () => {
size={0.4}
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
@@ -130,6 +142,7 @@ const LuxunPlace2 = () => {
size={0.4}
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
@@ -137,6 +150,7 @@ const LuxunPlace2 = () => {
size={0.4}
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{completed && (
<Image
+18
View File
@@ -11,6 +11,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useState } from "react";
import SuccessAlert from "@/components/layout/alert/success-alert";
import { useCursorController } from "@/hooks/useCursorController";
// starName 固定为 luxun
const starName = "luxun";
@@ -52,6 +53,18 @@ const LuxunPlace3 = () => {
>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
// Cursor 顺序控制:1 -> 2 -> 3 -> 4 -> 5
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
3: [2],
4: [3],
5: [4],
},
visited: visited,
});
// 光标1的引导对话状态
const [guide1Visible, setGuide1Visible] = useState(false);
const [guide1Step, setGuide1Step] = useState(0);
@@ -148,6 +161,7 @@ const LuxunPlace3 = () => {
onClick={() => handleOpenExhibit(1)}
size={0.6}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
@@ -155,6 +169,7 @@ const LuxunPlace3 = () => {
onClick={() => handleOpenExhibit(2)}
size={0.6}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
@@ -162,6 +177,7 @@ const LuxunPlace3 = () => {
onClick={() => handleOpenExhibit(3)}
size={0.6}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 光标4 */}
<Cursor
@@ -169,6 +185,7 @@ const LuxunPlace3 = () => {
onClick={() => handleOpenExhibit(4)}
size={0.6}
read={visited.has(4)}
disabled={!isEnabled(4)}
/>
{/* 光标5 */}
<Cursor
@@ -176,6 +193,7 @@ const LuxunPlace3 = () => {
onClick={() => handleOpenExhibit(5)}
size={0.6}
read={visited.has(5)}
disabled={!isEnabled(5)}
/>
{completed && (
+18 -4
View File
@@ -12,6 +12,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { useState } from "react";
import SuccessAlert from "@/components/layout/alert/success-alert";
import { useCursorController } from "@/hooks/useCursorController";
// starName 固定为 luxun
const starName = "luxun";
@@ -45,6 +46,16 @@ const LuxunPlace5 = () => {
);
const [visited, setVisited] = useState<Set<number>>(new Set());
// Cursor 顺序控制:1 -> 2 -> 3
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
3: [2],
},
visited: visited,
});
// 光标2的题目状态
const [quizVisible, setQuizVisible] = useState(false);
const [quizError, setQuizError] = useState(quizDefaultError);
@@ -108,16 +119,19 @@ const LuxunPlace5 = () => {
className="absolute left-167 bottom-30"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute left-228 bottom-30"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
<Cursor
className="absolute right-115 bottom-18 z-20"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{completed && (
@@ -190,10 +204,10 @@ const LuxunPlace5 = () => {
visible={quizVisible}
title="【单选】上海鲁迅故居三楼客房被称为“红色庇护所”,以下哪位共产党人曾在此避难并与鲁迅探讨革命议题?"
options={[
{ value: "A", label: "陈独秀" },
{ value: "B", label: "瞿秋白" },
{ value: "C", label: "李大钊" },
{ value: "D", label: "周恩来" },
{ value: "A", label: "A.陈独秀" },
{ value: "B", label: "B.瞿秋白" },
{ value: "C", label: "C.李大钊" },
{ value: "D", label: "D.周恩来" },
]}
correctAnswer="B"
errorMessage={quizError}
+1 -1
View File
@@ -291,7 +291,7 @@ const LuxunReflectionScene1 = () => {
key={city.id}
className={isCompleted ? "text-green-600" : ""}
>
{index + 1}.点击图标{city.location}
{index + 1}.{city.location}
</div>
);
})
+142 -42
View File
@@ -8,19 +8,29 @@ import SuccessAlert from "@/components/layout/alert/success-alert";
const starName = "luxun";
// 提示图标位置类型
type HintPosition = "luxun1" | "jiaguo" | "piping" | "luxun2" | "none";
type HintPosition =
| "luxun1"
| "guanzhu"
| "piping"
| "jiaguo"
| "luxun2"
| "none";
const LuxunReflectionScene3 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [showTopics, setShowTopics] = useState(false); // 是否显示"家国情怀"和"批判现实"
const [showGuanzhu, setShowGuanzhu] = useState(false); // 是否显示"关注底层"
const [showPiping, setShowPiping] = useState(false); // 是否显示"批判现实"
const [showJiaguo, setShowJiaguo] = useState(false); // 是否显示"家国情怀"
const [hintPosition, setHintPosition] = useState<HintPosition>("luxun1"); // 提示图标位置
const [showQuiz1, setShowQuiz1] = useState(false); // 是否显示题目1
const [showQuiz2, setShowQuiz2] = useState(false); // 是否显示题目2
const [showQuiz1, setShowQuiz1] = useState(false); // 是否显示题目1(关注底层)
const [showQuiz2, setShowQuiz2] = useState(false); // 是否显示题目2(批判现实)
const [showQuiz3, setShowQuiz3] = useState(false); // 是否显示题目3(家国情怀)
const [completedQuiz1, setCompletedQuiz1] = useState(false); // 题目1是否完成
const [completedQuiz2, setCompletedQuiz2] = useState(false); // 题目2是否完成
const [completedQuiz3, setCompletedQuiz3] = useState(false); // 题目3是否完成
const [showSummary, setShowSummary] = useState(false); // 是否显示总结
const [completionGuideVisible, setCompletionGuideVisible] = useState(false); // 通关提示对话是否显示
const [_completionGuideFinished, setCompletionGuideFinished] =
@@ -33,62 +43,86 @@ const LuxunReflectionScene3 = () => {
// 结束语覆盖层是否已关闭
const [endingOverlayFinished, setEndingOverlayFinished] = useState(false);
const allCompleted = completedQuiz1 && completedQuiz2;
const progress = (completedQuiz1 ? 1 : 0) + (completedQuiz2 ? 1 : 0);
const totalTasks = 2;
const allCompleted = completedQuiz1 && completedQuiz2 && completedQuiz3;
const progress =
(completedQuiz1 ? 1 : 0) +
(completedQuiz2 ? 1 : 0) +
(completedQuiz3 ? 1 : 0);
const totalTasks = 3;
// 当显示总结后,延迟1秒显示通关提示对话
useEffect(() => {
if (showSummary) {
const timer = setTimeout(() => {
setCompletionGuideVisible(true);
}, 1000);
}, 800);
return () => clearTimeout(timer);
}
}, [showSummary]);
// 点击"鲁迅1"的处理
const handleLuxun1Click = () => {
setShowTopics(true);
setHintPosition("jiaguo"); // 提示图标移动到"家国情怀"左侧
// 同时显示三个选项
setShowGuanzhu(true);
setShowPiping(true);
setShowJiaguo(true);
// 提示图标会根据完成状态自动显示在对应的选项上
};
// 点击"家国情怀"的处理
const handleJiaguoClick = () => {
if (!showTopics) return; // 必须先点击"鲁迅1"
// 点击"关注底层"的处理
const handleGuanzhuClick = () => {
if (!showGuanzhu || completedQuiz1) return; // 必须先点击"鲁迅1"且未完成
setShowQuiz1(true);
};
// 点击"批判现实"的处理
const handlePipingClick = () => {
if (!completedQuiz1) return; // 必须先完成题目1
if (!completedQuiz1 || completedQuiz2) return; // 必须先完成题目1且未完成题目2
setShowQuiz2(true);
};
// 题目1提交处理
// 点击"家国情怀"的处理
const handleJiaguoClick = () => {
if (!completedQuiz2 || completedQuiz3) return; // 必须先完成题目2且未完成题目3
setShowQuiz3(true);
};
// 题目1提交处理(关注底层)
const handleQuiz1Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setCompletedQuiz1(true);
setShowQuiz1(false);
setHintPosition("piping"); // 提示图标移动到"批判现实"旁边
// 提示图标会自动移动到"批判现实"旁边(根据完成状态)
// 显示成功提示
setSuccessMessage("鲁迅一生辗转,只为唤醒国人,守护家国。");
setSuccessMessage("鲁迅的文字始终聚焦底层百姓的苦难与希望。");
setShowSuccessAlert(true);
}
};
// 题目2提交处理
// 题目2提交处理(批判现实)
const handleQuiz2Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setCompletedQuiz2(true);
setShowQuiz2(false);
setHintPosition("luxun2"); // 提示图标移动到"鲁迅2"旁边
// 提示图标会自动移动到"家国情怀"旁边(根据完成状态)
// 显示成功提示
setSuccessMessage("鲁迅以文字批判旧时代的黑暗,唤醒国人的觉醒。");
setShowSuccessAlert(true);
}
};
// 题目3提交处理(家国情怀)
const handleQuiz3Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setCompletedQuiz3(true);
setShowQuiz3(false);
setHintPosition("luxun2"); // 提示图标移动到"鲁迅2"旁边
// 显示成功提示
setSuccessMessage("鲁迅一生辗转,只为唤醒国人,守护家国。");
setShowSuccessAlert(true);
}
};
// 点击"鲁迅2"的处理
const handleLuxun2Click = () => {
if (allCompleted) {
@@ -120,32 +154,64 @@ const LuxunReflectionScene3 = () => {
{/* 左侧:鲁迅的文学足迹 - 地图区域 */}
<div className="w-[44.5%] h-full relative">
{/* 家国情怀 - 根据showTopics控制显示 */}
{showTopics && (
{/* 关注底层 - 同时显示,但按顺序启用 */}
{showGuanzhu && (
<img
className={`absolute top-75 left-70 cursor-pointer transition-opacity ${
completedQuiz1 ? "opacity-50" : ""
}`}
src={getStaticImage(`${starName}/reflection/scene3/家国情怀`)}
alt="家国情怀"
onClick={handleJiaguoClick}
className={`absolute top-20 left-40 transition-opacity ${
completedQuiz1
? "opacity-50 cursor-default"
: "cursor-pointer opacity-100"
} ${!showGuanzhu ? "opacity-30 cursor-not-allowed" : ""}`}
src={getStaticImage(`${starName}/reflection/scene3/关注底层`)}
alt="关注底层"
onClick={handleGuanzhuClick}
style={{
pointerEvents: completedQuiz1 ? "none" : "auto",
}}
/>
)}
{/* 批判现实 - 根据showTopics和completedQuiz1控制显示 */}
{showTopics && (
{/* 批判现实 - 同时显示,但按顺序启用 */}
{showPiping && (
<img
className={`absolute bottom-15 left-120 cursor-pointer transition-opacity ${
completedQuiz2 ? "opacity-50" : ""
className={`absolute top-60 left-40 transition-opacity ${
completedQuiz2
? "opacity-50 cursor-default"
: completedQuiz1
? "cursor-pointer opacity-100"
: "opacity-30 cursor-not-allowed"
}`}
src={getStaticImage(`${starName}/reflection/scene3/批判现实`)}
alt="批判现实"
onClick={handlePipingClick}
style={{
pointerEvents:
!completedQuiz1 || completedQuiz2 ? "none" : "auto",
}}
/>
)}
{/* 家国情怀 - 同时显示,但按顺序启用 */}
{showJiaguo && (
<img
className={`absolute top-100 left-40 transition-opacity ${
completedQuiz3
? "opacity-50 cursor-default"
: completedQuiz2
? "cursor-pointer opacity-100"
: "opacity-30 cursor-not-allowed"
}`}
src={getStaticImage(`${starName}/reflection/scene3/家国情怀`)}
alt="家国情怀"
onClick={handleJiaguoClick}
style={{
pointerEvents:
!completedQuiz2 || completedQuiz3 ? "none" : "auto",
}}
/>
)}
{/* 提示图标 - 家国情怀左侧 */}
{hintPosition === "jiaguo" && showTopics && (
<div className="absolute top-100 left-85 z-10 pointer-events-none">
{/* 提示图标 - 关注底层左侧(未完成题目1时显示) */}
{showGuanzhu && !completedQuiz1 && (
<div className="absolute top-32 left-86 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
draggable={false}
@@ -164,9 +230,30 @@ const LuxunReflectionScene3 = () => {
</div>
)}
{/* 提示图标 - 批判现实旁边 */}
{hintPosition === "piping" && showTopics && completedQuiz1 && (
<div className="absolute bottom-0 left-180 z-10 pointer-events-none">
{/* 提示图标 - 批判现实旁边(完成题目1但未完成题目2时显示) */}
{showPiping && completedQuiz1 && !completedQuiz2 && (
<div className="absolute top-75 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>
)}
{/* 提示图标 - 家国情怀旁边(完成题目2但未完成题目3时显示) */}
{showJiaguo && completedQuiz2 && !completedQuiz3 && (
<div className="absolute top-112 left-85 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
draggable={false}
@@ -291,16 +378,16 @@ const LuxunReflectionScene3 = () => {
/>
)}
{/* 题目1:家国情怀 */}
{/* 题目1:关注底层 */}
<SingleChoiceQuizPanel
visible={showQuiz1}
title={"【单选】先生辗转多地创作,核心追求是(\xa0\xa0\xa0\xa0)"}
title="【单选】从闰土到祥林嫂,先生的文字始终看向:"
options={[
{ value: "A", label: "A. 唤醒国人,改变社会" },
{ value: "B", label: "B. 只为个人成名" },
{ value: "A", label: "A. 底层普通百姓" },
{ value: "B", label: "B. 达官贵人" },
]}
correctAnswer="A"
errorMessage='不是哦,先生的创作从不是为了自己哦!请回忆先生"弃医从文"的初心。'
errorMessage="不是哦,先生的文字从来不是为达官贵人而写哦!回忆闰土、祥林嫂的身份。"
onSubmit={handleQuiz1Submit}
/>
@@ -317,6 +404,19 @@ const LuxunReflectionScene3 = () => {
onSubmit={handleQuiz2Submit}
/>
{/* 题目3:家国情怀 */}
<SingleChoiceQuizPanel
visible={showQuiz3}
title={"【单选】先生辗转多地创作,核心追求是(\xa0\xa0\xa0\xa0)"}
options={[
{ value: "A", label: "A. 唤醒国人,改变社会" },
{ value: "B", label: "B. 只为个人成名" },
]}
correctAnswer="A"
errorMessage='不是哦,先生的创作从不是为了自己哦!请回忆先生"弃医从文"的初心。'
onSubmit={handleQuiz3Submit}
/>
{/* 成功提示弹窗 */}
<SuccessAlert
visible={showSuccessAlert}
+72 -46
View File
@@ -7,7 +7,6 @@ import {
type VideoRef,
type DragFillResult,
LetterToSunzhongshan,
StudyGuideModal,
Narration,
EndingOverlay,
} from "@/components";
@@ -28,10 +27,16 @@ type QuizAnchor = {
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;
};
@@ -42,54 +47,52 @@ const starName = "sunzhongshan";
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: "quiz-1",
time: 11, //5.52分钟
time: 60 + 13, //5.52分钟
title: "题目1",
prompt: '"振兴中华"对我们新时代青年还有意义吗?',
type: "multiple",
prompt:
'【拖拽】习近平总书记在纪念孙中山先生诞辰150周年大会上指出: "中国共产党人是孙中山先生革命事业最坚定的',
promptSuffix: '、最忠实的继承者。"',
type: "drag",
blanks: [
{ id: "blank-1", label: "最坚定的" },
{ id: "blank-2", label: "最忠诚的" },
],
options: [
{
value: "A",
label: "在民族复兴的征程上,解决发展中的新问题、实现更高水平的发展",
value: "支持者",
label: "支持者",
isCorrect: true,
},
{
value: "B",
label:
"当时的中国正遭受帝国主义列强的野蛮侵略和封建专制制度的腐朽统治,对于新时代的意义不大",
},
{
value: "C",
label:
"我们要专注学习,努力提升自身技能和素养,用精益求精的精神服务于中国智造",
isCorrect: true,
},
{
value: "D",
label: "中华民族的伟大复兴离不开我们每一位新时代青年的奋斗",
value: "合作者",
label: "合作者",
isCorrect: true,
},
],
explanation:
"“振兴中华”需要每一代人的努力,先生当年在上海撰写的《实业计划》里如今已经实现的那些宏伟计划就是我们国家代代相传的结果。答案B的观点错误",
blankSeparators: ["、最忠诚的"],
correctAnswersMap: {
"blank-1": "支持者",
"blank-2": "合作者",
},
},
{
id: "quiz-2",
time: 42,
time: 60 * 5 + 40,
title: "题目2",
prompt: "【多选】新时代的青年要从孙中山先生身上学习哪些精神?",
type: "multiple",
options: [
{
value: "A",
label: "A.“振兴中华”的初心",
label: "“振兴中华”的初心",
isCorrect: true,
},
{
value: "B",
label: "B.“天下为公”的情怀",
label: "“天下为公”的情怀",
isCorrect: true,
},
{ value: "C", label: "C.“与时俱进”的智慧", isCorrect: true },
{ value: "C", label: "“与时俱进”的智慧", isCorrect: true },
],
},
];
@@ -162,25 +165,47 @@ const ChenyunInternalization = () => {
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }));
setDragQuizErrors((prev) => ({ ...prev, [item.id]: "" }));
if (val.length < correctAnswers.length) return;
if (!item.blanks || val.length < item.blanks.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 (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);
if (isCorrect) {
setActiveSuccess(item.id);
setSuccessContent("回答正确,请继续探索。");
setResumeCallback(() => resume);
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: "再想一想,选择正确答案~",
}));
}
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: "再想一想,确保选择了所有正确答案~",
}));
// 如果没有位置特定的映射,使用原来的逻辑(检查是否包含了所有正确答案)
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]: "再想一想,选择正确答案~",
}));
}
}
};
@@ -189,14 +214,15 @@ const ChenyunInternalization = () => {
<DragQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.title}(拖拽)`}
questionPrefix={`${item.prompt}(`}
questionSuffix=")?"
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}
@@ -241,7 +267,7 @@ const ChenyunInternalization = () => {
<MultipleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.title}(多选题):${item.prompt}`}
title={`${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
@@ -361,7 +387,7 @@ const ChenyunInternalization = () => {
showMask={false}
>
<GuideStepsItem speaker="student">
小沪,参观完故居,我对孙中山先生从"医人"到"医国"的转变、《实业计划》里的宏大构想有了很多感触,但总觉得这些历史和精神离我们的生活还有些远,不知道该怎么真正理解和传承?
小沪,参观完故居,我对孙中山先生从"医人"到"医国"的转变、《实业计划》里的宏大构想有了很多感触,但总觉得这些历史和精神离我们的生活还有些远,不知道该怎么真正理解和传承。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你的感受特别真实!孙中山先生的精神不是停留在书本里的文字,而是能指导我们当下行动的思想财富。今天我们特别邀请到了上海孙中山故居纪念馆研究部主任孙旻老师,让她带我们一起解读先生精神的时代意义,把这份传承落到实处。
+8 -2
View File
@@ -405,8 +405,8 @@ const SunzhongshanPlace1 = () => {
<DragQuizPanel
visible={dragQuiz4Visible}
zIndex={10000}
title="【拖拽】"
questionPrefix="三民主义是孙中山所倡导的民主革命纲领,是其民主思想的精髓和高度概括,具体包括"
title=""
questionPrefix="【拖拽】三民主义是孙中山所倡导的民主革命纲领,是其民主思想的精髓和高度概括,具体包括"
questionSuffix="。"
options={[
{ value: "民族", label: "民族" },
@@ -522,6 +522,12 @@ const SunzhongshanPlace1 = () => {
<GuideStepsItem speaker="guide">
可以说,这是当时客观条件限制下的一种无奈妥协。因为革命党人的首要目标是推翻帝制,在武装力量不足以对抗北洋军阀的情况下,只能暂时信任袁世凯,希望他能维护共和。你看旁边的"南北议和"纪念章和纪念墨盒,就是当时这段历史的见证。
</GuideStepsItem>
<GuideStepsItem speaker="student">
可后来袁世凯不是复辟当皇帝了吗?那孙中山先生是不是特别后悔呀?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
的确,所以辛亥革命未能改变旧中国半殖民地半封建的社会性质和中国人民的悲惨境遇,也未能完成实现民族独立、人民解放的历史任务。孙中山先生十分痛心,但也更坚定了捍卫共和的决心。这也为他后来发起二次革命、护法运动埋下了伏笔。
</GuideStepsItem>
</GuideSteps>
{/* 光标1的多选题 */}
+16 -16
View File
@@ -37,9 +37,9 @@ const SunzhongshanPlace2 = () => {
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
3: [2],
4: [3],
2: [3],
3: [4],
4: [1],
},
visited: visited,
});
@@ -55,9 +55,9 @@ const SunzhongshanPlace2 = () => {
const [guide4Visible, setGuide4Visible] = useState(false);
const [guide4Step, setGuide4Step] = useState(0);
// 光标3的视频状态
const [showVideo3, setShowVideo3] = useState(false);
const video3Ref = useRef<VideoRef>(null);
// 光标2的视频状态
const [showVideo2, setShowVideo2] = useState(false);
const video2Ref = useRef<VideoRef>(null);
const handleOpenExhibit = (index: 1 | 2 | 3 | 4) => {
setActiveExhibitId(index);
@@ -162,13 +162,13 @@ const SunzhongshanPlace2 = () => {
setActiveExhibitId(null);
setShowVideo1(true);
return;
} else if (currentId === 2) {
// 光标2:关闭窗口后播放视频
setActiveExhibitId(null);
setShowVideo2(true);
return;
} else if (currentId === 4) {
// 光标4:正常关闭,更新visited状态
} else if (currentId === 3) {
// 光标3:关闭窗口后播放视频
setActiveExhibitId(null);
setShowVideo3(true);
return;
}
if (currentId !== null) {
setVisited((prev) => {
@@ -248,21 +248,21 @@ const SunzhongshanPlace2 = () => {
</GuideStepsItem>
</GuideSteps>
{/* 光标3的视频 */}
{showVideo3 && (
{/* 光标2的视频 */}
{showVideo2 && (
<Video
ref={video3Ref}
ref={video2Ref}
src={getPublicPath("video/sunzhongshan/sunzhongshan_06.mp4")}
className="fixed inset-0 z-[20000]"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo3(false);
setShowVideo2(false);
// 修改cursor状态
setVisited((prev) => {
const next = new Set(prev);
next.add(3);
next.add(2);
return next;
});
}}
+5 -5
View File
@@ -37,11 +37,11 @@ const SunzhongshanPlace3 = () => {
// Cursor 顺序控制:1 -> 2 -> 3 -> 4
const { isEnabled } = useCursorController({
initialEnabled: [1],
initialEnabled: [4],
dependencies: {
1: [3],
2: [1],
3: [2],
4: [3],
3: [4],
},
visited: visited,
});
@@ -262,8 +262,8 @@ const SunzhongshanPlace3 = () => {
<DragQuizPanel
visible={dragQuiz2Visible}
zIndex={10000}
title="【拖拽】"
questionPrefix="3. 中国国民党第一次全国代表大会事实上确立了"
title=""
questionPrefix="【拖拽】中国国民党第一次全国代表大会事实上确立了:"
questionSuffix="三大政策。"
options={[
{ value: "联俄", label: "联俄" },