diff --git a/src/assets/images/luxun/reflection/scene1/底图.png b/src/assets/images/luxun/reflection/scene1/底图.png index 255a0bc..3a52f0d 100644 Binary files a/src/assets/images/luxun/reflection/scene1/底图.png and b/src/assets/images/luxun/reflection/scene1/底图.png differ diff --git a/src/assets/images/luxun/reflection/scene2/底图.png b/src/assets/images/luxun/reflection/scene2/底图.png index 4421cd8..b0774a4 100644 Binary files a/src/assets/images/luxun/reflection/scene2/底图.png and b/src/assets/images/luxun/reflection/scene2/底图.png differ diff --git a/src/assets/images/luxun/reflection/scene3/关注底层.png b/src/assets/images/luxun/reflection/scene3/关注底层.png new file mode 100644 index 0000000..4a7bd48 Binary files /dev/null and b/src/assets/images/luxun/reflection/scene3/关注底层.png differ diff --git a/src/assets/images/luxun/reflection/scene3/家国情怀.png b/src/assets/images/luxun/reflection/scene3/家国情怀.png index 2018f93..e358610 100644 Binary files a/src/assets/images/luxun/reflection/scene3/家国情怀.png and b/src/assets/images/luxun/reflection/scene3/家国情怀.png differ diff --git a/src/assets/images/luxun/reflection/scene3/家国情怀_.png b/src/assets/images/luxun/reflection/scene3/家国情怀_.png new file mode 100644 index 0000000..2018f93 Binary files /dev/null and b/src/assets/images/luxun/reflection/scene3/家国情怀_.png differ diff --git a/src/assets/images/luxun/reflection/scene3/底图.png b/src/assets/images/luxun/reflection/scene3/底图.png index 86a570c..05792e6 100644 Binary files a/src/assets/images/luxun/reflection/scene3/底图.png and b/src/assets/images/luxun/reflection/scene3/底图.png differ diff --git a/src/assets/images/luxun/reflection/scene3/批判现实.png b/src/assets/images/luxun/reflection/scene3/批判现实.png index 87bc386..940f555 100644 Binary files a/src/assets/images/luxun/reflection/scene3/批判现实.png and b/src/assets/images/luxun/reflection/scene3/批判现实.png differ diff --git a/src/assets/images/luxun/reflection/scene3/批判现实_.png b/src/assets/images/luxun/reflection/scene3/批判现实_.png new file mode 100644 index 0000000..87bc386 Binary files /dev/null and b/src/assets/images/luxun/reflection/scene3/批判现实_.png differ diff --git a/src/components/layout/quiz/DragQuizPanel.tsx b/src/components/layout/quiz/DragQuizPanel.tsx index 5ca5fe4..b5f740a 100644 --- a/src/components/layout/quiz/DragQuizPanel.tsx +++ b/src/components/layout/quiz/DragQuizPanel.tsx @@ -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 = ({
{title &&
{title}
} +
{questionPrefix && {questionPrefix}} {blanks.map((blank, index) => { @@ -67,12 +83,22 @@ const DragQuizPanel = ({ )} {index < blanks.length - 1 && ( - 、 + + {blankSeparators && blankSeparators[index] + ? blankSeparators[index] + : "、"} + )} ); })} {questionSuffix && {questionSuffix}} + {/* 答案反馈 */} + {error && ( +
+ {error} +
+ )}
@@ -114,10 +140,6 @@ const DragQuizPanel = ({ }} /> - - {error && ( -
{error}
- )} ); diff --git a/src/components/layout/quiz/MultipleChoiceQuizPanel.tsx b/src/components/layout/quiz/MultipleChoiceQuizPanel.tsx index 69edcd0..4c95f7c 100644 --- a/src/components/layout/quiz/MultipleChoiceQuizPanel.tsx +++ b/src/components/layout/quiz/MultipleChoiceQuizPanel.tsx @@ -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([]); const [error, setError] = useState(""); + // 当窗口关闭时,清除选中状态和错误状态 + useEffect(() => { + if (!visible) { + setSelectedValues([]); + setError(""); + } + }, [visible]); + if (!visible) return null; // 处理选项点击 diff --git a/src/components/layout/quiz/types.ts b/src/components/layout/quiz/types.ts index e264315..1ff9287 100644 --- a/src/components/layout/quiz/types.ts +++ b/src/components/layout/quiz/types.ts @@ -21,6 +21,8 @@ export interface DragQuizPanelProps { options: { value: string; label: string }[]; /** 空白区域列表 */ blanks: DragFillBlank[]; + /** 空白之间的分隔符数组(可选,如果不提供则使用默认的"、") */ + blankSeparators?: string[]; /** 当前值 */ value: DragFillResult[]; /** 错误提示信息 */ diff --git a/src/components/letter/LetterToLuxun.tsx b/src/components/letter/LetterToLuxun.tsx index d24085c..72f7087 100644 --- a/src/components/letter/LetterToLuxun.tsx +++ b/src/components/letter/LetterToLuxun.tsx @@ -317,13 +317,13 @@ const LetterToLuxun = ({ {/* 寄信图标(可选,如果需要的话) */} -
+ {/*
寄信 -
+
*/} {/* 背面:只读态 */} diff --git a/src/components/letter/LetterToSunzhongshan.tsx b/src/components/letter/LetterToSunzhongshan.tsx index 6240eba..cdb5762 100644 --- a/src/components/letter/LetterToSunzhongshan.tsx +++ b/src/components/letter/LetterToSunzhongshan.tsx @@ -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 ( @@ -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 ( +
+ {segments.map((part, partIndex) => { + const isLast = + partIndex === segments.length - 1; + if (isLast) { + return ( + + {part} + + ); + } + + const currentIndex = placeholderIndex++; + const isSelectFill = currentIndex < 3; // 前三个空使用 Select - const segments = line.split("____"); - return ( -
- {segments.map((part, partIndex) => { - const isLast = partIndex === segments.length - 1; - if (isLast) { return ( - + {part} + {isSelectFill ? ( + // 前三个空:下拉选择 + { + const next = [...blankValues]; + next[currentIndex] = e.target.value; + setBlankValues(next); + + // 用户再次输入时,清除该空的错误高亮 + if (blankErrors[currentIndex]) { + const nextErrs = [...blankErrors]; + nextErrs[currentIndex] = false; + setBlankErrors(nextErrs); + } + }} + /> + )} ); - } - - const currentIndex = placeholderIndex++; - return ( - - {part} - { - const next = [...blankValues]; - next[currentIndex] = e.target.value; - setBlankValues(next); - - // 用户再次输入时,清除该空的错误高亮 - if (blankErrors[currentIndex]) { - const nextErrs = [...blankErrors]; - nextErrs[currentIndex] = false; - setBlankErrors(nextErrs); - } - }} - /> - - ); - })} -
- ); - }); + })} +
+ ); + }); })()} @@ -238,13 +341,13 @@ const LetterToSunzhongshan = ({ {/* 寄信图标(可选,如果需要的话) */} -
+ {/*
寄信 -
+
*/} {/* 背面:只读态 */} @@ -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 ( +
+ {segments.map((part, partIndex) => { + const isLast = + partIndex === segments.length - 1; + if (isLast) { + return ( + + {part} + + ); + } + + const currentIndex = placeholderIndex++; + const value = blankValues[currentIndex] ?? ""; - const segments = line.split("____"); - return ( -
- {segments.map((part, partIndex) => { - const isLast = partIndex === segments.length - 1; - if (isLast) { return ( - + {part} + {value || "  "} ); - } - - const currentIndex = placeholderIndex++; - const value = blankValues[currentIndex] ?? ""; - - return ( - - {part} - {value || "  "} - - ); - })} -
- ); - }); + })} +
+ ); + }); })()} @@ -338,6 +448,15 @@ const LetterToSunzhongshan = ({ + + {/* 错误提示弹窗 */} + setShowErrorAlert(false)} + onMaskClick={() => setShowErrorAlert(false)} + zIndex={10002} + /> )}
diff --git a/src/pages/chenyun/place/place1.tsx b/src/pages/chenyun/place/place1.tsx index 26d7fb0..ac15b38 100644 --- a/src/pages/chenyun/place/place1.tsx +++ b/src/pages/chenyun/place/place1.tsx @@ -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"; diff --git a/src/pages/luxun/internalization.tsx b/src/pages/luxun/internalization.tsx index d2e3a6d..9b9f6d1 100644 --- a/src/pages/luxun/internalization.tsx +++ b/src/pages/luxun/internalization.tsx @@ -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; 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 + >({}); + const [dragQuizErrors, setDragQuizErrors] = useState>( + {} + ); + 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 ( + <> + ({ + value: opt.value, + label: opt.label, + }))} + blanks={item.blanks || []} + blankSeparators={item.blankSeparators} + value={dragQuizValues[item.id] || []} + error={dragQuizErrors[item.id]} + onChange={handleDragChange} + /> + { + 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} > - 小沪,关于"首在立人"走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。 + 关于"首在立人"走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。 那些针砭时弊的杂文、借古讽今的篇章,还有藏在6平方米客房里的红色坚守,都在诉说着先生的热忱。 diff --git a/src/pages/luxun/place/place1.tsx b/src/pages/luxun/place/place1.tsx index b2b2450..9b5ea09 100644 --- a/src/pages/luxun/place/place1.tsx +++ b/src/pages/luxun/place/place1.tsx @@ -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>(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 */} handleOpenExhibit(2)} read={visited.has(2)} + disabled={!isEnabled(2)} /> {/* 继续前进 - 仅在完成所有展品且对话结束后显示 */} diff --git a/src/pages/luxun/place/place2.tsx b/src/pages/luxun/place/place2.tsx index a0c4a04..0d27944 100644 --- a/src/pages/luxun/place/place2.tsx +++ b/src/pages/luxun/place/place2.tsx @@ -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>(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 */} { size={0.4} onClick={() => handleOpenExhibit(2)} read={visited.has(2)} + disabled={!isEnabled(2)} /> {/* 光标3 */} { size={0.4} onClick={() => handleOpenExhibit(3)} read={visited.has(3)} + disabled={!isEnabled(3)} /> {completed && ( { >(null); const [visited, setVisited] = useState>(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 */} { onClick={() => handleOpenExhibit(2)} size={0.6} read={visited.has(2)} + disabled={!isEnabled(2)} /> {/* 光标3 */} { onClick={() => handleOpenExhibit(3)} size={0.6} read={visited.has(3)} + disabled={!isEnabled(3)} /> {/* 光标4 */} { onClick={() => handleOpenExhibit(4)} size={0.6} read={visited.has(4)} + disabled={!isEnabled(4)} /> {/* 光标5 */} { onClick={() => handleOpenExhibit(5)} size={0.6} read={visited.has(5)} + disabled={!isEnabled(5)} /> {completed && ( diff --git a/src/pages/luxun/place/place4.tsx b/src/pages/luxun/place/place4.tsx index fd55672..8b76006 100644 --- a/src/pages/luxun/place/place4.tsx +++ b/src/pages/luxun/place/place4.tsx @@ -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>(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)} /> handleOpenExhibit(2)} read={visited.has(2)} + disabled={!isEnabled(2)} /> 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} diff --git a/src/pages/luxun/reflection/scene1.tsx b/src/pages/luxun/reflection/scene1.tsx index df80353..881d71d 100644 --- a/src/pages/luxun/reflection/scene1.tsx +++ b/src/pages/luxun/reflection/scene1.tsx @@ -291,7 +291,7 @@ const LuxunReflectionScene1 = () => { key={city.id} className={isCompleted ? "text-green-600" : ""} > - {index + 1}.点击图标{city.location} + {index + 1}.{city.location} ); }) diff --git a/src/pages/luxun/reflection/scene3.tsx b/src/pages/luxun/reflection/scene3.tsx index 8264045..434438c 100644 --- a/src/pages/luxun/reflection/scene3.tsx +++ b/src/pages/luxun/reflection/scene3.tsx @@ -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("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 = () => { {/* 左侧:鲁迅的文学足迹 - 地图区域 */}
- {/* 家国情怀 - 根据showTopics控制显示 */} - {showTopics && ( + {/* 关注底层 - 同时显示,但按顺序启用 */} + {showGuanzhu && ( 家国情怀 )} - {/* 批判现实 - 根据showTopics和completedQuiz1控制显示 */} - {showTopics && ( + {/* 批判现实 - 同时显示,但按顺序启用 */} + {showPiping && ( 批判现实 + )} + {/* 家国情怀 - 同时显示,但按顺序启用 */} + {showJiaguo && ( + 家国情怀 )} - {/* 提示图标 - 家国情怀左侧 */} - {hintPosition === "jiaguo" && showTopics && ( -
+ {/* 提示图标 - 关注底层左侧(未完成题目1时显示) */} + {showGuanzhu && !completedQuiz1 && ( +
{
)} - {/* 提示图标 - 批判现实旁边 */} - {hintPosition === "piping" && showTopics && completedQuiz1 && ( -
+ {/* 提示图标 - 批判现实旁边(完成题目1但未完成题目2时显示) */} + {showPiping && completedQuiz1 && !completedQuiz2 && ( +
+ 点一点图标 + 点一点 +
+ )} + + {/* 提示图标 - 家国情怀旁边(完成题目2但未完成题目3时显示) */} + {showJiaguo && completedQuiz2 && !completedQuiz3 && ( +
{ /> )} - {/* 题目1:家国情怀 */} + {/* 题目1:关注底层 */} @@ -317,6 +404,19 @@ const LuxunReflectionScene3 = () => { onSubmit={handleQuiz2Submit} /> + {/* 题目3:家国情怀 */} + + {/* 成功提示弹窗 */} optionValue + correctAnswersMap?: Record; 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 = () => { ({ 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 = () => { ({ value: opt.value, label: `${opt.value}. ${opt.label}`, @@ -361,7 +387,7 @@ const ChenyunInternalization = () => { showMask={false} > - 小沪,参观完故居,我对孙中山先生从"医人"到"医国"的转变、《实业计划》里的宏大构想有了很多感触,但总觉得这些历史和精神离我们的生活还有些远,不知道该怎么真正理解和传承? + 小沪,参观完故居,我对孙中山先生从"医人"到"医国"的转变、《实业计划》里的宏大构想有了很多感触,但总觉得这些历史和精神离我们的生活还有些远,不知道该怎么真正理解和传承。 你的感受特别真实!孙中山先生的精神不是停留在书本里的文字,而是能指导我们当下行动的思想财富。今天我们特别邀请到了上海孙中山故居纪念馆研究部主任孙旻老师,让她带我们一起解读先生精神的时代意义,把这份传承落到实处。 diff --git a/src/pages/sunzhongshan/place/place1.tsx b/src/pages/sunzhongshan/place/place1.tsx index 7dc8c94..23ab799 100644 --- a/src/pages/sunzhongshan/place/place1.tsx +++ b/src/pages/sunzhongshan/place/place1.tsx @@ -405,8 +405,8 @@ const SunzhongshanPlace1 = () => { { 可以说,这是当时客观条件限制下的一种无奈妥协。因为革命党人的首要目标是推翻帝制,在武装力量不足以对抗北洋军阀的情况下,只能暂时信任袁世凯,希望他能维护共和。你看旁边的"南北议和"纪念章和纪念墨盒,就是当时这段历史的见证。 + + 可后来袁世凯不是复辟当皇帝了吗?那孙中山先生是不是特别后悔呀? + + + 的确,所以辛亥革命未能改变旧中国半殖民地半封建的社会性质和中国人民的悲惨境遇,也未能完成实现民族独立、人民解放的历史任务。孙中山先生十分痛心,但也更坚定了捍卫共和的决心。这也为他后来发起二次革命、护法运动埋下了伏笔。 + {/* 光标1的多选题 */} diff --git a/src/pages/sunzhongshan/place/place2.tsx b/src/pages/sunzhongshan/place/place2.tsx index ce69938..496cc6a 100644 --- a/src/pages/sunzhongshan/place/place2.tsx +++ b/src/pages/sunzhongshan/place/place2.tsx @@ -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(null); + // 光标2的视频状态 + const [showVideo2, setShowVideo2] = useState(false); + const video2Ref = useRef(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 = () => { - {/* 光标3的视频 */} - {showVideo3 && ( + {/* 光标2的视频 */} + {showVideo2 && (