feat: 增强连线题功能,添加错误反馈机制

- 在 MatchQuizPanel 组件中引入错误连线对的状态管理,提供红色高亮和抖动反馈。
- 更新匹配逻辑,确保用户在连线错误时能及时获得反馈信息。
- 修改类型定义,支持自定义错误提示信息的函数形式,提升灵活性。
- 优化用户体验,确保在错误状态下的匹配项能正确展示。
This commit is contained in:
zhongyidian committed 2026-03-03 13:27:25 +08:00
1 parent b91f2430c6
commit 5d00adc1a5
43 files changed
+1656 -48

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+73 -34
View File
@@ -45,11 +45,16 @@ const MatchQuizPanel = ({
const [selectedLeftId, setSelectedLeftId] = useState<string | null>(null);
const [error, setError] = useState<string>("");
const containerRef = useRef<HTMLDivElement>(null);
// 当前错误的连线对,用于做红色高亮+抖动反馈
const [errorPairs, setErrorPairs] = useState<
Array<{ leftId: string; rightId: string }>
>([]);
// 当窗口关闭时,重置状态
useEffect(() => {
if (!visible) {
setMatches([]);
setErrorPairs([]);
setSelectedLeftId(null);
setError("");
}
@@ -99,45 +104,65 @@ const MatchQuizPanel = ({
// 创建新的匹配
const newMatch = { leftId: selectedLeftId, rightId };
const newMatches = [...matches, newMatch];
// 每次连线后立即校验当前所有连线是否存在错误
const wrongMatches =
correctMatches?.filter(
() => true
) && // 保证 correctMatches 存在时才校验
newMatches.filter(
(match) =>
!correctMatches?.some(
(cm) => cm.leftId === match.leftId && cm.rightId === match.rightId
)
);
const hasError = !!wrongMatches && wrongMatches.length > 0;
if (hasError && wrongMatches) {
// 记录错误连线对,用于红色高亮+抖动反馈
setErrorPairs(wrongMatches);
setMatches(newMatches);
setSelectedLeftId(null);
setError("");
// 检查是否所有项都已匹配
if (newMatches.length === leftItems.length && newMatches.length === rightItems.length) {
// 验证答案
const isCorrect = validateMatches(newMatches);
if (isCorrect) {
onSubmit?.(newMatches, true);
let errorMsg: string;
if (typeof errorMessage === "function") {
errorMsg = errorMessage(newMatches);
} else if (errorMessage) {
errorMsg = errorMessage;
} else {
const errorMsg = errorMessage || "连线不正确,请重新尝试!";
errorMsg = "连线不正确,请重新尝试!";
}
setError(errorMsg);
onError?.(errorMsg);
// 清空匹配,允许重新尝试
// 短暂展示错误状态(变红+抖动),随后只清空错误连线,保留已答对的连线
setTimeout(() => {
setMatches([]);
}, 2000);
}
}
};
// 验证匹配是否正确
const validateMatches = (matchesToValidate: Array<{ leftId: string; rightId: string }>) => {
if (!correctMatches || matchesToValidate.length !== correctMatches.length) {
return false;
}
// 检查每个匹配是否正确
for (const match of matchesToValidate) {
const isCorrect = correctMatches.some(
(cm) => cm.leftId === match.leftId && cm.rightId === match.rightId
setMatches((prev) =>
prev.filter(
(m) =>
!wrongMatches.some(
(w) => w.leftId === m.leftId && w.rightId === m.rightId
)
)
);
if (!isCorrect) {
return false;
}
setErrorPairs([]);
}, 600);
return;
}
return true;
// 没有错误时,正常记录匹配
setMatches(newMatches);
setSelectedLeftId(null);
setError("");
// 如果没有错误且所有项都已连完,则认为答题成功
if (
newMatches.length === leftItems.length &&
newMatches.length === rightItems.length
) {
onSubmit?.(newMatches, true);
}
};
// 获取左项是否已匹配
@@ -192,7 +217,7 @@ const MatchQuizPanel = ({
{leftItems.map((item) => {
const isMatched = isLeftItemMatched(item.id);
const isSelected = selectedLeftId === item.id;
const matchedRightId = getMatchedRightId(item.id);
const isError = errorPairs.some((p) => p.leftId === item.id);
return (
<div
@@ -204,7 +229,9 @@ const MatchQuizPanel = ({
disabled || isMatched
? "opacity-60 cursor-not-allowed"
: "cursor-pointer",
isSelected
isError
? "bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse"
: isSelected
? "bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105"
: isMatched
? "bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]"
@@ -220,7 +247,12 @@ const MatchQuizPanel = ({
}}
>
{item.label}
{isMatched && (
{isError && (
<div className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]">
<span className="text-white text-[10px] md:text-xs">✕</span>
</div>
)}
{!isError && isMatched && (
<div className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs md:text-sm">✓</span>
</div>
@@ -282,7 +314,7 @@ const MatchQuizPanel = ({
<div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
{rightItems.map((item) => {
const isMatched = isRightItemMatched(item.id);
const matchedLeftId = getMatchedLeftId(item.id);
const isError = errorPairs.some((p) => p.rightId === item.id);
return (
<div
@@ -296,7 +328,9 @@ const MatchQuizPanel = ({
: selectedLeftId
? "cursor-pointer"
: "cursor-default",
isMatched
isError
? "bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse"
: isMatched
? "bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]"
: selectedLeftId
? "bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]"
@@ -313,7 +347,12 @@ const MatchQuizPanel = ({
}}
>
{item.label}
{isMatched && (
{isError && (
<div className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]">
<span className="text-white text-[10px] md:text-xs">✕</span>
</div>
)}
{!isError && isMatched && (
<div className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs md:text-sm">✓</span>
</div>
+5 -2
View File
@@ -115,8 +115,11 @@ export interface MatchQuizPanelProps {
rightItems: MatchQuizItem[];
/** 正确答案匹配列表 */
correctMatches: MatchQuizMatch[];
/** 自定义错误提示信息(答案错误时显示) */
errorMessage?: string;
/**
* 自定义错误提示信息(答案错误时显示)
* 可以是固定文案,或根据当前匹配结果返回不同提示
*/
errorMessage?: string | ((matches: MatchQuizMatch[]) => string);
/** 提交回调,返回用户匹配和是否正确 */
onSubmit?: (matches: MatchQuizMatch[], isCorrect: boolean) => void;
/** 错误回调(可选) */
+2 -2
View File
@@ -284,7 +284,7 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
// 处理鼠标按下开始拖拽
const handleMouseDown = (e: React.MouseEvent, optionValue: string) => {
startDragging(optionValue, e.clientX, e.clientY, e.currentTarget);
startDragging(optionValue, e.clientX, e.clientY, e.currentTarget as HTMLElement);
};
// 处理触摸开始拖拽
@@ -293,7 +293,7 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
if (!touch) return;
// 防止滚动干扰
e.preventDefault();
startDragging(optionValue, touch.clientX, touch.clientY, e.currentTarget);
startDragging(optionValue, touch.clientX, touch.clientY, e.currentTarget as HTMLElement);
};
// 移除填充
+2 -2
View File
@@ -5,8 +5,8 @@ import { Image } from "@/components";
import { getUserDefine } from "@/lib/utils/api-common";
import { useChenyunStore } from "@/store/chenyunStore";
const CHENYUN_LESSON_ID = "chenyunStore";
const CHENYUN_KEY = 1;
const CHENYUN_LESSON_ID = "hsgrl_Cheny_duihua";
const CHENYUN_KEY = 22;
const deserializeVisitedExhibits = (
data: Record<string, number[]> | undefined
+2 -2
View File
@@ -18,8 +18,8 @@ import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
// starName 固定为 chenyun
const starName = "chenyun";
const CHENYUN_LESSON_ID = "chenyunStore";
const CHENYUN_KEY = 1;
const CHENYUN_LESSON_ID = "hsgrl_Cheny_duihua";
const CHENYUN_KEY = 22;
const saveChenyunStore = () => {
const state = useChenyunStore.getState();
+4 -4
View File
@@ -64,14 +64,14 @@ const EntranceBackgroundMap: Record<ModuleName, EntranceBackground> = {
backgroundImage: getBackgroundImage("entrance/艺术名家"),
Star: {
Star1: {
image: "",
image: getStaticImage("entrance/聂耳亮"),
name: "聂耳",
starName: "nieer",
},
Star2: {
image: "",
name: "刘海栗",
starName: "liuhaili",
image: getStaticImage("entrance/张乐平亮"),
name: "张乐平",
starName: "zhangleping",
},
},
},
+47
View File
@@ -0,0 +1,47 @@
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import { Image } from "@/components";
const ZhanglepingHome = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
// starName 固定为 zhangleping
const starName = "zhangleping";
if (!moduleName) return null;
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/张乐平故居底图`),
}}
>
<Image
className="absolute right-37 top-22"
src={getStaticImage("components/革命先驱故居-返回")}
button
onClick={() => navigate(`/entrance/${moduleName}`)}
/>
<div className="absolute right-20 top-75 flex flex-col space-y-10">
{/* 对话 */}
<Image
button
src={getStaticImage("star/一-解锁")}
onClick={() => navigate(`/${moduleName}/${starName}/place1`)}
/>
{/* 体悟 / 内化 预留 */}
<Image button src={getStaticImage("star/二-解锁")} />
<Image button src={getStaticImage("star/三-解锁")} />
</div>
</div>
);
};
export default ZhanglepingHome;
+221
View File
@@ -0,0 +1,221 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
StudyGuideModal,
} from "@/components";
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useState } from "react";
import Cursor from "@/components/cursor";
import { useCursorController } from "@/hooks/useCursorController";
// starName 固定为 zhangleping
const starName = "zhangleping";
// 展品 ID 与资源路径映射,ID 用来做唯一标识
const exhibitSrcMap: Record<1 | 2, string> = {
1: "zhangleping/place1/张乐平场景1展品1",
2: "zhangleping/place1/张乐平场景1展品2",
};
// 阶段一:童年励志(2 个光标)
const ZhanglepingPlace1 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
// 当前激活的展品 ID
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
// 已访问的展品(本页内状态;如需持久化可接入 store)
const [visited, setVisited] = useState<Set<1 | 2>>(() => new Set());
// 展品1的对话引导状态
const [guideVisible1, setGuideVisible1] = useState(false);
const [guideStep1, setGuideStep1] = useState(0);
// 展品2的对话引导状态
const [guideVisible2, setGuideVisible2] = useState(false);
const [guideStep2, setGuideStep2] = useState(0);
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true);
// 对话结束后的学习引导弹窗
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false);
// Cursor 顺序控制:两个展品均可直接访问
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2:[1]
},
visited,
});
if (!moduleName) return null;
const total = 2;
const progressCount = visited.size;
const completed = progressCount >= total;
const subtitle = completed
? "搜索该阶段相关资料(已完成)"
: `搜索该阶段相关资料(${progressCount}/${total})`;
const handleOpenExhibit = (id: 1 | 2) => {
setActiveExhibitId(id);
if (id === 1) {
setGuideVisible1(true);
setGuideStep1(0);
} else {
setGuideVisible2(true);
setGuideStep2(0);
}
};
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/place1/张乐平场景1`),
}}
>
{/* 标题 */}
<Title
title="童年励志"
subtitle={subtitle}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
}
}}
/>
{/* 光标1 */}
<Cursor
className="absolute left-120 bottom-150"
size={0.8}
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute left-159 bottom-123"
size={0.8}
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage("components/place/继续前进")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/place2`);
}}
/>
)}
{/* 展品1的对话引导 */}
<GuideSteps
visible={guideVisible1}
onVisibleChange={setGuideVisible1}
currentStep={guideStep1}
onStepChange={setGuideStep1}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student">
小沪,张乐平先生画的这幅《童年的回忆》漫画真灵动!这是张先生回忆自己在浙江老家的生活场景吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
是的。张乐平先生1910年生于浙江农村,母亲是他绘画的启蒙人。你看这里的绣品,就源自张先生的母亲。
</GuideStepsItem>
</GuideSteps>
{/* 展品2的对话引导 */}
<GuideSteps
visible={guideVisible2}
onVisibleChange={setGuideVisible2}
currentStep={guideStep2}
onStepChange={setGuideStep2}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="guide">
她能字画、工女红,尤擅剪纸、刺绣。张先生自幼耳濡目染,受到启蒙,在小学校师陆寅生指导下,表现出不凡的绘画才能。因为有绘画的陪伴,张先生的童年还是过得非常愉快的,正如你看到的《童年的回忆》这幅画。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
但是因为家境贫寒,张先生15岁就只身来到南汇万祥镇,在木料行当学徒,其间他在老板的纸堆上偷偷画画,哪怕被责打也不放弃,还用芦条在沙滩上画巨龙,巨龙里蕴藏的就是他“以画报国”的初心。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
由此可见,艺术从不是温室里的花朵,而是在生活的土壤中倔强生长的种子。
</GuideStepsItem>
</GuideSteps>
{/* 展品遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onClose={() => {
if (activeExhibitId !== null) {
setVisited((prev) => {
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
}
setActiveExhibitId(null);
}}
/>
{/* 学习引导词 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false);
setShowAfterDialogGuide(true);
}}
>
同学你好,欢迎来到“走进故居·对话”学习模块!
{"\n"}
此刻你正驻足在张乐平故居的温馨院落里,接下来请循着灵动的笔墨,一同探寻先生艺术人生里的5段动人旅程:童年励志、抗战画家、三毛之父、儿童园丁、朋友笔下的张乐平。
{"\n"}
准备好共赴这场画笔与心灵的温柔邂逅了吗?
</StudyGuideModal>
{/* 对话结束后的学习引导(内容沿用原有说明) */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
ailgn="left"
>
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
{"\n"}
2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
{"\n"}
3.所有动态光点的展品全部学习后才能进入下一个场景学习。
</StudyGuideModal>
</div>
);
};
export default ZhanglepingPlace1;
+326
View File
@@ -0,0 +1,326 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
MatchQuizPanel,
Video,
type VideoRef,
} from "@/components";
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useRef, useState } from "react";
import Cursor from "@/components/cursor";
import { useCursorController } from "@/hooks/useCursorController";
import SuccessAlert from "@/components/layout/alert/success-alert";
import ScaledPortal from "@/components/layout/ScaledPortal";
// starName 固定为 zhangleping
const starName = "zhangleping";
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: "zhangleping/place2/张乐平场景2展品1",
2: "zhangleping/place2/张乐平场景2展品2",
3: "zhangleping/place2/张乐平场景2展品3",
};
// 阶段二:抗战画家(3 个光标)
const ZhanglepingPlace2 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null);
const [visited, setVisited] = useState<Set<1 | 2 | 3>>(() => new Set());
const [guideVisible2, setGuideVisible2] = useState(false);
const [guideStep2, setGuideStep2] = useState(0);
const [guideVisible3, setGuideVisible3] = useState(false);
const [guideStep3, setGuideStep3] = useState(0);
// 展品3对话结束后的连线题
const [quizVisible3, setQuizVisible3] = useState(false);
const [quizPassed3, setQuizPassed3] = useState(false);
const [successAlert3Visible, setSuccessAlert3Visible] = useState(false);
// 展品1:关闭后播放的视频
const [showVideo1, setShowVideo1] = useState(false);
const video1Ref = useRef<VideoRef>(null);
// Cursor 顺序控制:先学习展品3,再依次解锁展品2、展品1
const { isEnabled } = useCursorController({
initialEnabled: [3],
dependencies: {
2: [3],
1: [2],
},
visited,
});
if (!moduleName) return null;
const total = 3;
const progressCount = visited.size;
const completed = progressCount >= total;
const subtitle = completed
? "搜索该阶段相关资料(已完成)"
: `搜索该阶段相关资料(${progressCount}/${total})`;
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id);
if (id === 1) {
// 展品1仅展示大图,无引导对话
} else if (id === 2) {
setGuideVisible2(true);
setGuideStep2(0);
} else {
setGuideVisible3(true);
setGuideStep3(0);
// 重新进入展品3时重置题目完成状态
setQuizPassed3(false);
}
};
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/place2/张乐平场景2`),
}}
>
{/* 标题 */}
<Title
title="抗战画家"
subtitle={subtitle}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
}
}}
/>
{/* 光标1 */}
<Cursor
className="absolute left-135 bottom-90"
size={0.6}
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute right-177 bottom-170"
size={0.6}
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute right-135 bottom-168"
size={0.6}
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 继续前进 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage("components/place/继续前进")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/place3`);
}}
/>
)}
{/* 展品1:仅图片,无对话,引导通过关闭后的视频讲解 */}
{/* 展品2:代表作品与历史背景 */}
<GuideSteps
visible={guideVisible2}
onVisibleChange={setGuideVisible2}
currentStep={guideStep2}
onStepChange={setGuideStep2}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student">
张先生还在街头做过画,这样的画作能直面民众,唤起国人的抗战热情,应该就是你说的画笔如“刀枪”吧!
</GuideStepsItem>
<GuideStepsItem speaker="guide">
不错。你看到的正是抗战时期的场景,也是张乐平先生艺术生涯中最具历史使命感和时代感的时期。1937年震惊中外的“七七事变”爆发,日本侵略行径激起了全中国人民的强烈愤慨。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
淞沪会战当月,张先生以抗日救亡漫画宣传队副领队身份北上宣传抗日,后任队长,他带领宣传队足迹遍及沪、苏、皖、鄂、浙、湘、桂、赣、闽、粤等地,配合抗战形势创作了大量的抗日漫画。
</GuideStepsItem>
</GuideSteps>
{/* 展品3:张乐平画笔在不同历史时期的力量 */}
<GuideSteps
visible={guideVisible3}
onVisibleChange={(visible) => {
setGuideVisible3(visible);
// 对话结束后,弹出连线题(如未通过)
if (!visible && !quizPassed3) {
setQuizVisible3(true);
}
}}
currentStep={guideStep3}
onStepChange={setGuideStep3}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student">
这幅“穷凶极恶的结果”的漫画太形象了,小沪,聂耳先生用音乐唤醒大家的爱国情怀,那张乐平先生的画笔在不同年代里,是不是也为大家带来了不一样的力量呀?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这是个很好的问题!张乐平先生的画笔在不同的历史时期发挥着不同作用,比如抗日战争时是“刀枪”,揭露日寇暴行;解放战争时是“镜子”,映照社会现实;新中国成立后是“园丁”,为万千儿童种下真善美的种子。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
六十年笔墨不辍,他笔下不只是漫画故事,更是陪着一代代中国人长大的心灵成长书。张先生用画笔为我们带来了对社会深刻的洞察和对普通民众深切的关怀。
</GuideStepsItem>
</GuideSteps>
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onClose={() => {
if (activeExhibitId !== null) {
// 展品1:关闭后播放一段视频,视频结束再标记已访问
if (activeExhibitId === 1) {
setActiveExhibitId(null);
setShowVideo1(true);
return;
}
// 展品3:需在完成连线题后才标记为已访问
if (activeExhibitId === 3) {
if (quizPassed3) {
setVisited((prev) => {
const next = new Set(prev);
next.add(3);
return next;
});
}
} else {
setVisited((prev) => {
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
}
}
setActiveExhibitId(null);
}}
/>
{/* 展品1关闭后的讲解视频 */}
{showVideo1 && (
<Video
ref={video1Ref}
src={getPublicPath("video/sunzhongshan/sunzhongshan_03.mp4")}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo1(false);
setVisited((prev) => {
const next = new Set(prev);
next.add(1);
return next;
});
}}
onSkip={() => {
setShowVideo1(false);
}}
/>
)}
{/* 展品3对话结束后的连线题 + 成功提示(使用 ScaledPortal) */}
<ScaledPortal enabled={quizVisible3 || successAlert3Visible} zIndex={10050}>
<>
<MatchQuizPanel
visible={quizVisible3}
title="【连线】张乐平先生的画笔在不同历史时期发挥着不同作用,请你把对应的历史时期与当时先生画笔发挥的主要作用匹配起来吧!"
leftItems={[
{ id: "period1", label: "抗日战争" },
{ id: "period2", label: "解放战争" },
{ id: "period3", label: "新中国成立后" },
]}
rightItems={[
{ id: "role2", label: "镜子" },
{ id: "role3", label: "园丁" },
{ id: "role1", label: "刀枪" },
]}
correctMatches={[
{ leftId: "period1", rightId: "role1" },
{ leftId: "period2", rightId: "role2" },
{ leftId: "period3", rightId: "role3" },
]}
errorMessage={(matches) => {
const getMatchByLeft = (leftId: string) =>
matches.find((m) => m.leftId === leftId);
const match1 = getMatchByLeft("period1");
if (match1 && match1.rightId !== "role1") {
return "抗日战争时期,张乐平先生的画笔主要是揭露日寇暴行,似“刀枪”。请你再想想吧!";
}
const match2 = getMatchByLeft("period2");
if (match2 && match2.rightId !== "role2") {
return "解放战争时期,张乐平先生的画笔主要是映照社会现实,似“镜子”。请你再想想吧!";
}
const match3 = getMatchByLeft("period3");
if (match3 && match3.rightId !== "role3") {
return "新中国成立后,张乐平先生的画笔主要为万千儿童种下真善美的种子,似“园丁”。请你再想想吧!";
}
return "连线不太对,请你再仔细想一想吧!";
}}
onSubmit={(_matches, isCorrect) => {
if (isCorrect) {
setQuizVisible3(false);
setQuizPassed3(true);
setSuccessAlert3Visible(true);
// 答题成功后标记展品3为已访问
setVisited((prev) => {
const next = new Set(prev);
next.add(3);
return next;
});
}
}}
zIndex={10000}
/>
<SuccessAlert
visible={successAlert3Visible}
onContinue={() => {
setSuccessAlert3Visible(false);
}}
/>
</>
</ScaledPortal>
</div>
);
};
export default ZhanglepingPlace2;
+422
View File
@@ -0,0 +1,422 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
SingleChoiceQuizPanel,
Video,
type VideoRef,
} from "@/components";
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useRef, useState } from "react";
import Cursor from "@/components/cursor";
import { useCursorController } from "@/hooks/useCursorController";
import SuccessAlert from "@/components/layout/alert/success-alert";
import ScaledPortal from "@/components/layout/ScaledPortal";
// starName 固定为 zhangleping
const starName = "zhangleping";
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: "zhangleping/place3/张乐平场景3展品1",
2: "zhangleping/place3/张乐平场景3展品2",
3: "zhangleping/place3/张乐平场景3展品3",
};
// 阶段三:三毛之父(3 个光标)
const ZhanglepingPlace3 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null);
const [visited, setVisited] = useState<Set<1 | 2 | 3>>(() => new Set());
const [guideVisible1, setGuideVisible1] = useState(false);
const [guideStep1, setGuideStep1] = useState(0);
const [guideVisible2, setGuideVisible2] = useState(false);
const [guideStep2, setGuideStep2] = useState(0);
const [guideVisible3, setGuideVisible3] = useState(false);
const [guideStep3, setGuideStep3] = useState(0);
// 展品2:关闭后播放的视频 + 单选题
const [showVideo2, setShowVideo2] = useState(false);
const video2Ref = useRef<VideoRef>(null);
const [quiz2Visible, setQuiz2Visible] = useState(false);
const [successAlert2Visible, setSuccessAlert2Visible] = useState(false);
const [successAlert2Content, setSuccessAlert2Content] = useState("");
// 展品3对话结束后的两道单选题
const [quiz3Visible, setQuiz3Visible] = useState(false);
const [quiz3Step, setQuiz3Step] = useState<1 | 2>(1);
const [quiz3Finished, setQuiz3Finished] = useState(false);
const [successAlert3Visible, setSuccessAlert3Visible] = useState(false);
const [successAlert3Content, setSuccessAlert3Content] = useState("");
const { isEnabled } = useCursorController({
initialEnabled: [3],
dependencies: {
2: [3],
1: [2],
},
visited,
});
if (!moduleName) return null;
const total = 3;
const progressCount = visited.size;
const completed = progressCount >= total;
const subtitle = completed
? "搜索该阶段相关资料(已完成)"
: `搜索该阶段相关资料(${progressCount}/${total})`;
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id);
if (id === 1) {
setGuideVisible1(true);
setGuideStep1(0);
} else if (id === 2) {
setGuideVisible2(true);
setGuideStep2(0);
} else {
setGuideVisible3(true);
setGuideStep3(0);
}
};
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/place3/张乐平场景3`),
}}
>
{/* 标题 */}
<Title
title="三毛之父"
subtitle={subtitle}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
}
}}
/>
{/* 光标1 */}
<Cursor
className="absolute left-202 bottom-109"
size={0.8}
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute right-178 bottom-62"
size={0.8}
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute right-58 bottom-68"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 继续前进 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage("components/place/继续前进")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/place4`);
}}
/>
)}
{/* 展品1:三毛的诞生 */}
<GuideSteps
visible={guideVisible1}
onVisibleChange={setGuideVisible1}
currentStep={guideStep1}
onStepChange={setGuideStep1}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student">
那我明白了!这个戴上红领巾的三毛体现的应该是已经摆脱苦难的中国人民了。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
确实如此。这幅名叫《向日葵》的画选自于1950—1965年的《三毛日记》。像这样的“新三毛”形象再次发生重要转变是自1949年新中国成立之后,三毛戴上红领巾,成为新中国的少先队员。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这一变化不仅标志着角色自身的成长,更是整个中国社会由战乱走向安定、由贫弱迈向新生的时代缩影。三毛那三根标志性的头发,仿佛穿越时光的见证者,记录下一个民族从山河破碎到国泰民安的巨大变迁。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
他的每一次蜕变,不仅是一个漫画人物的成长,更是一个国家与一代人的共同记忆。
</GuideStepsItem>
</GuideSteps>
{/* 展品2:三毛形象的特点 */}
<GuideSteps
visible={guideVisible2}
onVisibleChange={setGuideVisible2}
currentStep={guideStep2}
onStepChange={setGuideStep2}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="guide">
早期三毛以一个滑稽幽默、调皮活泼的上海弄堂儿童形象出现。抗战胜利后,张先生重返上海开启了新的创作历程,自1950年6月起开始居住在五原路288弄3号,直至1992年9月辞世,张先生在这里度过了他艺术生涯中极为重要的创作时期。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
他先是根据抗战前线的经历见闻创作了《三毛从军记》,后又以自己耳闻目睹的社会现实,创作了《三毛流浪记》,三毛的形象也逐步转变为承载民族苦难与底层儿童命运的象征。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
三毛身上的每一处伤痕、每一次遭遇,可以说都真实折射出了当时社会的困顿与民众的疾苦,也是为什么说这个时期,先生的画作更像是一面“镜子”。
</GuideStepsItem>
</GuideSteps>
{/* 展品3:三毛的社会影响 */}
<GuideSteps
visible={guideVisible3}
onVisibleChange={(visible) => {
setGuideVisible3(visible);
// 对话结束后依次进入两道题目(仅在未完成时触发)
if (!visible) {
setQuiz3Step(1);
setQuiz3Visible(true);
}
}}
currentStep={guideStep3}
onStepChange={setGuideStep3}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student">
小沪,我一直知道张乐平先生被称为“三毛之父”,但是今天才发现张先生笔下成长时期的三毛看起来比早期的三毛多了一些磨难,张先生塑造的这样一个小流浪儿也在跟着时代一起成长吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你的感受很准确。三毛初次亮相于1935年的上海,他的创作者张乐平先生也开创了中国无文字儿童连环漫画之先河。
</GuideStepsItem>
</GuideSteps>
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onClose={() => {
if (activeExhibitId !== null) {
// 展品2:关闭后先播放一段视频,视频结束再进入题目
if (activeExhibitId === 2) {
setActiveExhibitId(null);
setShowVideo2(true);
return;
}
// 展品3:需完成两道题目后才标记为已访问
if (activeExhibitId === 3) {
if (quiz3Finished) {
setVisited((prev) => {
const next = new Set(prev);
next.add(3);
return next;
});
}
} else {
setVisited((prev) => {
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
}
}
setActiveExhibitId(null);
}}
/>
{/* 展品2关闭后的讲解视频 */}
{showVideo2 && (
<Video
ref={video2Ref}
src={getPublicPath("video/sunzhongshan/sunzhongshan_03.mp4")}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo2(false);
setQuiz2Visible(true);
}}
onSkip={() => {
setShowVideo2(false);
setQuiz2Visible(true);
}}
/>
)}
{/* 展品2关闭后的视频 + 单选题(如图1) */}
<ScaledPortal
enabled={quiz2Visible || successAlert2Visible}
zIndex={10040}
>
<>
<SingleChoiceQuizPanel
visible={quiz2Visible}
zIndex={10000}
title="【单选】张乐平先生根据自己抗战前线的经历见闻创作的作品是:"
options={[
{ value: "A", label: "A.三毛流浪记" },
{ value: "B", label: "B.三毛日记" },
{ value: "C", label: "C.三毛从军记" },
]}
correctAnswer="C"
onWrongSelect={(value) => {
if (value === "A") {
return "《三毛流浪记》以旧上海为背景,聚焦三毛在城市底层的流浪生活,主要反映旧社会儿童的苦难遭遇与人性冷暖。请你再想想吧!";
}
if (value === "B") {
return "《三毛日记》以日记体形式记录三毛的日常琐事与童真视角,内容偏向儿童生活的趣味表达。请你再想想吧!";
}
return "请你再想想吧!";
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return;
setQuiz2Visible(false);
setSuccessAlert2Content(
"《三毛从军记》是张乐平根据自己奔赴抗战前线的亲身经历与见闻创作的作品,通过三毛从军的荒诞经历,讽刺了当时的社会乱象,也传递出抗战背景下的民生百态。"
);
setSuccessAlert2Visible(true);
}}
/>
<SuccessAlert
visible={successAlert2Visible}
content={successAlert2Content}
onContinue={() => {
setSuccessAlert2Visible(false);
// 答题成功后标记展品2为已访问
setVisited((prev) => {
const next = new Set(prev);
next.add(2);
return next;
});
}}
/>
</>
</ScaledPortal>
{/* 展品3对话结束后的两道单选题 + 每题答对后的提示(使用 ScaledPortal) */}
<ScaledPortal
enabled={quiz3Visible || successAlert3Visible}
zIndex={10050}
>
<>
{/* 题目1:三毛形象首次亮相年份与地点 */}
<SingleChoiceQuizPanel
visible={quiz3Visible && quiz3Step === 1}
zIndex={10000}
title="【单选】1935年,张乐平先生创作的三毛形象初次亮相于:"
options={[
{ value: "A", label: "A.上海" },
{ value: "B", label: "B.延安" },
{ value: "C", label: "C.浙江" },
]}
correctAnswer="A"
onWrongSelect={(value) => {
if (value === "B") {
return "延安是当时的革命根据地,但张乐平先生的主要创作活动和延安没有关联。请你再想想吧!";
}
if (value === "C") {
return "浙江是张乐平先生的故乡,不是三毛初次亮相的地方。请你再想想吧!";
}
return "请再仔细阅读题干中的时间和地点信息~";
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return;
setQuiz3Visible(false);
setSuccessAlert3Content(
"张乐平先生创作的三毛初次亮相于1935年上海的《晨报》副刊。"
);
setSuccessAlert3Visible(true);
}}
/>
{/* 题目2:三毛与中国无文字儿童连环漫画 */}
<SingleChoiceQuizPanel
visible={quiz3Visible && quiz3Step === 2}
zIndex={10000}
title="【判断】张乐平先生创作的三毛开创了中国无文字儿童连环漫画之先河。"
options={[
{ value: "A", label: "A.正确" },
{ value: "B", label: "B.错误" },
]}
correctAnswer="A"
onWrongSelect={(value) => {
if (value === "B") {
return "再想一想三毛在中国儿童漫画史上的地位吧~";
}
return "请再仔细对照题干信息选择~";
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return;
setQuiz3Visible(false);
setSuccessAlert3Content("");
setSuccessAlert3Visible(true);
}}
/>
{/* 每题答对后的统一提示框 */}
<SuccessAlert
visible={successAlert3Visible}
content={successAlert3Content}
onContinue={() => {
setSuccessAlert3Visible(false);
// 进入下一题或标记完成
setQuiz3Step((prev) => {
const next = (prev + 1) as 1 | 2 | 3;
if (next === 2) {
setQuiz3Visible(true);
return 2;
}
// 两题全部完成
setQuiz3Finished(true);
// 标记展品3为已访问
setVisited((prevVisited) => {
const nextVisited = new Set(prevVisited);
nextVisited.add(3);
return nextVisited;
});
return prev;
});
}}
/>
</>
</ScaledPortal>
</div>
);
};
export default ZhanglepingPlace3;
+282
View File
@@ -0,0 +1,282 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
SingleChoiceQuizPanel,
} from "@/components";
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useState } from "react";
import Cursor from "@/components/cursor";
import { useCursorController } from "@/hooks/useCursorController";
import SuccessAlert from "@/components/layout/alert/success-alert";
import ScaledPortal from "@/components/layout/ScaledPortal";
// starName 固定为 zhangleping
const starName = "zhangleping";
// 展品 ID 与资源路径映射(仅 1 个光标)
const exhibitSrcMap: Record<1, string> = {
1: "zhangleping/place4/张乐平场景4展品1",
};
// 阶段四:儿童园丁(1 个光标)
const ZhanglepingPlace4 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null);
const [visited, setVisited] = useState<Set<1>>(() => new Set());
const [guideVisible1, setGuideVisible1] = useState(false);
const [guideStep1, setGuideStep1] = useState(0);
// 对话结束后的两道单选题
const [quizVisible, setQuizVisible] = useState(false);
const [quizStep, setQuizStep] = useState<1 | 2>(1);
const [quizFinished, setQuizFinished] = useState(false);
const [successAlertVisible, setSuccessAlertVisible] = useState(false);
const [successAlertContent, setSuccessAlertContent] = useState("");
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {},
visited,
});
if (!moduleName) return null;
const total = 1;
const progressCount = visited.size;
const completed = progressCount >= total;
const subtitle = completed
? "搜索该阶段相关资料(已完成)"
: `搜索该阶段相关资料(${progressCount}/${total})`;
const handleOpenExhibit = (id: 1) => {
setActiveExhibitId(id);
setGuideVisible1(true);
setGuideStep1(0);
};
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/place4/张乐平场景4`),
}}
>
{/* 标题 */}
<Title
title="儿童园丁"
subtitle={subtitle}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
}
}}
/>
{/* 光标1 */}
<Cursor
className="absolute left-190 bottom-140"
size={0.6}
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 继续前进:进入最后一幕“朋友画我” */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage("components/place/继续前进")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/place5`);
}}
/>
)}
{/* 展品1:儿童园丁的教育情怀 */}
<GuideSteps
visible={guideVisible1}
onVisibleChange={(visible) => {
setGuideVisible1(visible);
// 对话结束后依次进入两道题目(仅在未完成时触发)
if (!visible && !quizFinished) {
setQuizStep(1);
setQuizVisible(true);
}
}}
currentStep={guideStep1}
onStepChange={setGuideStep1}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student">
小沪,三毛系列漫画真的太深入人心了!张乐平先生还会和“小三毛迷”们互通书信,张先生真的是非常喜欢儿童呀!
</GuideStepsItem>
<GuideStepsItem speaker="guide">
张乐平先生将其一生都献给了中国漫画和儿童事业。他说:“大树是从苗苗长大的,对苗苗我们要注意浇灌。”新中国成立后,张先生像“园丁”一样,经常深入学校、少年宫等辅导孩子们画画。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
他说:“一看到孩子,我心里就乐。和他们在一起,我忘乎所以。我别无他技,只有把为孩子服务,当作报国的手段。”张先生为儿童作画,与孩子交流,从中汲取灵感与素材,不断创作出儿童喜爱的漫画作品,为万千儿童种下真善美的种子。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
张乐平先生还为儿童写过很多诗,在创作之余,他经常带着小三毛到公园、街头写生,将所见所闻用文字和画笔记录下来。
</GuideStepsItem>
</GuideSteps>
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onClose={() => {
// 需完成两道题目后才标记为已访问
if (activeExhibitId !== null && quizFinished) {
setVisited((prev) => {
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
}
setActiveExhibitId(null);
}}
/>
{/* 对话结束后的两道单选题 + 每题答对后的提示(使用 ScaledPortal) */}
<ScaledPortal enabled={quizVisible || successAlertVisible} zIndex={10050}>
<>
{/* 题目1 */}
<SingleChoiceQuizPanel
visible={quizVisible && quizStep === 1}
zIndex={10000}
title="【单选】新中国成立后,张乐平先生的画笔成为“园丁”,他的作品主要起到了以下哪种作用"
options={[
{
value: "A",
label:
"A.让孩子们只专注于玩耍,忘记学习和生活中的道理",
},
{
value: "B",
label:
"B.传递善良、勇敢、乐观等真善美的品质,助力儿童成长",
},
{ value: "C", label: "C.只是画一些漂亮的图画,没有实质的教育意义" },
{ value: "D", label: "D.引导孩子们追求物质享受,忽略精神追求" },
]}
correctAnswer="B"
onWrongSelect={(value) => {
if (value === "A") {
return "此选项与张乐平先生用绘画作品传递积极品质相悖。请你再想想吧!";
}
if (value === "C") {
return "此选项忽略了张乐平先生的作品的教育价值。请你再想想吧!";
}
if (value === "D") {
return "此选项不符合张乐平先生作品的精神内核。请你再想想吧!";
}
return "请你再想想吧!";
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return;
setQuizVisible(false);
setSuccessAlertContent(
"新中国成立后,张乐平先生笔下的“新三毛”戴上红领巾,成为新时代的少先队员,这一变化标志着角色自身的成长,更是整个中国社会由战乱走向安定、由贫弱迈向新生的时代缩影。他的作品用温暖的故事和形象,传递着善良、勇敢、乐观的精神,为儿童种下真善美的种子,助力儿童成长。"
);
setSuccessAlertVisible(true);
}}
/>
{/* 题目2 */}
<SingleChoiceQuizPanel
visible={quizVisible && quizStep === 2}
zIndex={10000}
title="【单选】从抗日战争时的“刀枪”到解放战争时的“镜子”再到新中国成立后的“园丁”,从张乐平先生作品身份的转变中,我们能看到他最核心的精神是:"
options={[
{
value: "A",
label: "A.只追求个人艺术风格的不断变化,不考虑社会影响",
},
{
value: "B",
label:
"B.根据时代需求,用画笔担当社会责任,关怀社会与普通民众",
},
{
value: "C",
label: "C.为了迎合不同群体的喜好,随意改变创作方向",
},
{
value: "D",
label: "D.只关注自己感兴趣的题材,不关心时代的变化",
},
]}
correctAnswer="B"
onWrongSelect={(value) => {
if (value === "A") {
return "此选项与张乐平先生用漫画作品反映社会现实不符。请你再想想吧!";
}
if (value === "C") {
return "此选项不符合张乐平先生根据时代需求进行创作的特点。请你再想想吧!";
}
if (value === "D") {
return "此选项与张乐平先生用不同作品体现时代特征的风格相矛盾。请你再想想吧!";
}
return "请你再想想吧!";
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return;
setQuizVisible(false);
setSuccessAlertContent(
"抗战时他揭露日寇暴行,为抗战贡献力量;内战时映照社会乱象,反映社会现实;新中国成立后为儿童种下真善美的种子,助力儿童成长。这充分说明他根据不同时代的需求,用画笔担当起社会责任,关怀社会与普通民众。"
);
setSuccessAlertVisible(true);
}}
/>
{/* 每题答对后的统一提示框 */}
<SuccessAlert
visible={successAlertVisible}
content={successAlertContent}
onContinue={() => {
setSuccessAlertVisible(false);
setQuizStep((prev) => {
const next = (prev + 1) as 1 | 2 | 3;
if (next === 2) {
setQuizVisible(true);
return 2;
}
// 两题全部完成:标记完成并更新 cursor 状态
setQuizFinished(true);
setVisited((prevVisited) => {
const nextVisited = new Set(prevVisited);
nextVisited.add(1);
return nextVisited;
});
return prev;
});
}}
/>
</>
</ScaledPortal>
</div>
);
};
export default ZhanglepingPlace4;
+197
View File
@@ -0,0 +1,197 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
EndingOverlay,
Image,
Title,
} from "@/components";
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useEffect, useState } from "react";
import Cursor from "@/components/cursor";
import { useCursorController } from "@/hooks/useCursorController";
// starName 固定为 zhangleping
const starName = "zhangleping";
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: "zhangleping/place5/张乐平场景5展品1",
2: "zhangleping/place5/张乐平场景5展品2",
3: "zhangleping/place5/张乐平场景5展品3",
};
// 阶段五:朋友画我(3 个光标)
const ZhanglepingPlace5 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null);
const [visited, setVisited] = useState<Set<1 | 2 | 3>>(() => new Set());
// 光标1+光标2均查看后的总结对话
const [guide12Visible, setGuide12Visible] = useState(false);
const [guide12Step, setGuide12Step] = useState(0);
const [guide12Shown, setGuide12Shown] = useState(false);
// 所有光标已读后的结束语
const [endingVisible, setEndingVisible] = useState(false);
const [endingShown, setEndingShown] = useState(false);
const { isEnabled } = useCursorController({
initialEnabled: [1, 2],
dependencies: {
3: [1, 2],
},
visited,
});
const total = 3;
const progressCount = visited.size;
const completed = progressCount >= total;
const subtitle = completed
? "搜索该阶段相关资料(已完成)"
: `搜索该阶段相关资料(${progressCount}/${total})`;
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id);
};
// 光标1和光标2都已查看后,自动弹出一次总结对话
useEffect(() => {
if (guide12Shown) return;
if (!visited.has(1) || !visited.has(2)) return;
setGuide12Step(0);
setGuide12Visible(true);
setGuide12Shown(true);
}, [visited, guide12Shown]);
// 所有光标已读后,自动弹出一次结束语
useEffect(() => {
if (endingShown) return;
if (!completed) return;
setEndingVisible(true);
setEndingShown(true);
}, [completed, endingShown]);
if (!moduleName) return null;
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/place5/张乐平场景5`),
}}
>
{/* 标题 */}
<Title
title="朋友笔下"
subtitle={subtitle}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
{/* 完成全部展品且结束语关闭后,出现离开按钮 */}
{completed && !endingVisible && (
<Image
className="absolute right-0 bottom-0"
src={getStaticImage("components/place/离开场景")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
}
}}
/>
)}
{/* 光标1 */}
<Cursor
className="absolute left-130 bottom-145"
size={0.6}
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute left-205 bottom-157"
size={0.6}
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute right-75 bottom-160"
size={0.6}
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 展品1、展品2:仅展示大图,无对话(总结对话会在两者均已读后自动出现) */}
{/* 展品3:仅展示大图,无对话 */}
{/* 光标1 + 光标2 都已查看后的对话(自动弹出一次) */}
<GuideSteps
visible={guide12Visible}
currentStep={guide12Step}
onStepChange={setGuide12Step}
onVisibleChange={(nextVisible) => {
setGuide12Visible(nextVisible);
}}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student">
小沪,三毛系列漫画真的太深入人心了!张乐平先生还会和“小三毛迷”们互通书信,张先生真的是非常喜欢儿童呀!
</GuideStepsItem>
<GuideStepsItem speaker="guide">
是呀!这面汇聚了五十二位著名漫画家创作的瓷板画墙,堪称中国漫画界一次意义非凡的“集体致敬”,每一位参与者都以自己独特的艺术语言,重新诠释了三毛与张乐平先生之间的故事。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这些作品风格各异,有的充满诙谐趣味,有的流淌着温暖深情,但共同诉说着一个深刻的主题——张乐平先生作为漫画泰斗的巨大行业影响力。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
他创办的《漫画世界》不仅是一本刊物,更成为培育漫画新人的重要园地,为中国漫画界输送了大量杰出人才。正因如此,他在同行中赢得了“平民大师”的美誉,这不仅是对其艺术成就的肯定,更是对其人格魅力的褒奖。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这些画作中流淌的是承载着跨越半个世纪的深厚情谊与中国漫画事业的代际传承,更是用漫画传递真善美的中国漫画精神的重要见证。
</GuideStepsItem>
</GuideSteps>
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onClose={() => {
if (activeExhibitId !== null) {
setVisited((prev) => {
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
}
setActiveExhibitId(null);
}}
/>
{/* 结束语:所有光标已读后出现 */}
<EndingOverlay
visible={endingVisible}
text="张乐平先生用一生的画笔,为我们塑造了三毛这个灵动的形象,也用作品传递着善良、勇敢、乐观的精神。他始终秉持平民立场,笔腕春秋中,坚守着笔耕不辍的漫画人生;方寸丹青间,传递着深刻隽永的艺术真谛。他的漫画,留给社会的是享之不尽的宝贵财富。希望大家能记住三毛的故事,记住张先生的初心,也能学会用温暖的眼光看待世界,用善良的行动对待他人。时光匆匆,旅程暂歇,但张先生的艺术精神永远不会落幕。愿三毛的善良与勇敢陪伴我们前行,愿艺术的温暖照亮每一段旅程……接下来,请你化身“时光探员”,开启一场“三毛寻忆”之旅吧。"
onContinue={() => {
setEndingVisible(false);
}}
animationSpeed={200}
/>
</div>
);
};
export default ZhanglepingPlace5;
+10 -2
View File
@@ -6,7 +6,14 @@ import Menu from "@/pages/Menu";
import Entrance from "@/pages/entrance";
//各个故居路由
import { ChenyunRoutes, MaozedongRoutes, SunzhongshanRoutes, HuangyanpeiRoutes, LuxunRoutes } from "./modules";
import {
ChenyunRoutes,
MaozedongRoutes,
SunzhongshanRoutes,
HuangyanpeiRoutes,
LuxunRoutes,
ZhanglepingRoutes,
} from "./modules";
const baseRoutes: RouteObject[] = [
{
@@ -23,7 +30,7 @@ const baseRoutes: RouteObject[] = [
* 每个类型下有2个故居
* 革命先驱:毛泽东、孙中山
* 文化名人:鲁迅、黄炎培
* 艺术名家:聂耳、刘海栗
* 艺术名家:聂耳、张乐平
* 经济巨擘:荣德生、陈云
*/
{
@@ -49,6 +56,7 @@ const router = createHashRouter([
...SunzhongshanRoutes,
...HuangyanpeiRoutes,
...LuxunRoutes,
...ZhanglepingRoutes,
{
path: "*",
element: <NotFound />,
+1
View File
@@ -3,3 +3,4 @@ export * from "./maozedong";
export * from "./sunzhongshan";
export * from "./huangyanpei";
export * from "./luxun";
export * from "./zhangleping";
+62
View File
@@ -0,0 +1,62 @@
import { lazy } from "react";
import { RouteObject } from "react-router-dom";
import { ResidenceLayout } from "@/components/layout";
const HomePage = lazy(() => import("@/pages/zhangleping/home"));
const ZhanglepingPlace1 = lazy(
() => import("@/pages/zhangleping/place/place1")
);
const ZhanglepingPlace2 = lazy(
() => import("@/pages/zhangleping/place/place2")
);
const ZhanglepingPlace3 = lazy(
() => import("@/pages/zhangleping/place/place3")
);
const ZhanglepingPlace4 = lazy(
() => import("@/pages/zhangleping/place/place4")
);
const ZhanglepingPlace5 = lazy(
() => import("@/pages/zhangleping/place/place5")
);
// 张乐平 router
// 路径格式:/:moduleName/zhangleping/:page
export const ZhanglepingRoutes: RouteObject[] = [
{
path: "/:moduleName/zhangleping",
element: <ResidenceLayout />,
children: [
{
index: true,
element: <HomePage />,
},
{
path: "home",
element: <HomePage />,
},
// 对话模块
{
path: "place1",
element: <ZhanglepingPlace1 />,
},
{
path: "place2",
element: <ZhanglepingPlace2 />,
},
{
path: "place3",
element: <ZhanglepingPlace3 />,
},
{
path: "place4",
element: <ZhanglepingPlace4 />,
},
{
path: "place5",
element: <ZhanglepingPlace5 />,
},
],
},
];