feat:黄炎培对话对接中

This commit is contained in:
zhongyidian committed 2026-01-07 21:08:38 +08:00
1 parent 3611a0857a
commit 6ab184b328
125 files changed
+6617 -2391

No files matched your search

+406 -15
View File
@@ -1,38 +1,429 @@
import { useEffect, useMemo, useState, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Image } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
type DragFillResult,
LetterToHuangyanpei,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
import { GuideSteps, GuideStepsItem } from "@/components";
type QuizOption = {
value: string;
label: string;
isCorrect?: boolean;
explanation?: string;
};
type QuizAnchor = {
id: string;
time: number;
title: string;
prompt: string;
type: "single" | "multiple" | "drag";
options: QuizOption[];
blanks?: { id: string; label: string }[];
};
const starName = "huangyanpei";
// 锚点数据:根据视频时长调整 time
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: "quiz-1",
time: 96, // 1:36
title: "题目1",
prompt:
"当年黄炎培先生创办中华职业学校,开设的专业都是当时中国最需要的实业领域,分别是( )。",
type: "multiple",
options: [
{ value: "A", label: "铁工", isCorrect: true },
{
value: "B",
label: "自动控制",
explanation:
"这一工程理论框架成形于20世纪中叶,70年代实现集中控制,新中国成立前,没有相关学科基础、设备与技术条件",
},
{ value: "C", label: "木工", isCorrect: true },
{ value: "D", label: "纽扣科", isCorrect: true },
],
},
{
id: "quiz-2",
time: 221, // 3:41
title: "题目2",
prompt:
"黄炎培先生主导设计的课程与现在中职学校强调的理实一体这一理念相通是( )。",
type: "multiple",
options: [
{
value: "A",
label: "上午学机械原理,下午就去车间打铁、造台钳",
isCorrect: true,
},
{
value: "B",
label: "课堂上学记账、推销,课后就去街头卖国货",
isCorrect: true,
},
{
value: "C",
label: "学习四书五经、八股文",
explanation: "黄炎培认为教育不能只谈书本、脱离实际",
},
{
value: "D",
label: '在川沙小学堂采取"半天读书、半天做手工"的学习模式',
isCorrect: true,
},
],
},
{
id: "quiz-3",
time: 370, // 6:10
title: "题目3",
prompt:
'当年黄炎培先生有推广国货的爱国情怀,如今我们的"国货"已经自强自立,请将其中的国产企业/品牌放置到空格处',
type: "drag",
options: [
{ value: "特斯拉", label: "特斯拉" },
{ value: "比亚迪", label: "比亚迪" },
{ value: "华为", label: "华为" },
{ value: "上海电气", label: "上海电气" },
{ value: "光明食品", label: "光明食品" },
{ value: "拼多多", label: "拼多多" },
],
blanks: [
{ id: "blank-1", label: "国产企业1" },
{ id: "blank-2", label: "国产企业2" },
{ id: "blank-3", label: "国产企业3" },
{ id: "blank-4", label: "国产企业4" },
{ id: "blank-5", label: "国产企业5" },
],
},
{
id: "quiz-4",
time: 467, // 7:47
title: "题目4",
prompt:
'当年现在的中职教育一直在"因时求变",许多专业的新增和关联正确的是( )。',
type: "multiple",
options: [
{ value: "A", label: "汽修专业→学新能源汽车检测与维修", isCorrect: true },
{
value: "B",
label: "会计专业→要掌握大数据财务和智能记账",
isCorrect: true,
},
{ value: "C", label: "国货推销→跨境电商、直播带货", isCorrect: true },
{
value: "D",
label: "美术设计与制作→影像与影视技术",
explanation:
"两个专业为不同技能方向的学习,前者为平面绘画等,后者以视频为主",
},
],
},
];
const HuangyanpeiInternalization = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const { moduleName } = useParams<{ moduleName: string }>();
const [activeSuccess, setActiveSuccess] = useState<string | null>(null);
const [successContent, setSuccessContent] = useState<string>("");
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(
null
);
const [dragQuizValues, setDragQuizValues] = useState<
Record<string, DragFillResult[]>
>({});
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>(
{}
);
const [visible, setVisible] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const [postGuideVisible, setPostGuideVisible] = useState(false);
const [showLetter, setShowLetter] = useState(false);
const [showStudyGuide, setShowStudyGuide] = useState(false);
const videoRef = useRef<VideoRef>(null);
useEffect(() => {
setVisible(true);
}, []);
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
videoRef.current.play().catch((error) => {
console.warn("视频播放失败:", error);
});
}
};
const anchors: VideoAnchor[] = useMemo(
() =>
QUIZ_ANCHORS.map((item) => {
const correctAnswers = item.options
.filter((opt) => opt.isCorrect)
.map((opt) => opt.value);
// 拖拽题处理
if (item.type === "drag" && item.blanks) {
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: ({ resume }) => {
const handleDragChange = (val: DragFillResult[]) => {
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }));
setDragQuizErrors((prev) => ({ ...prev, [item.id]: "" }));
// 特斯拉标签无法拖入答案框
const hasInvalidOption = val.some(
(v) => v.optionValue === "特斯拉"
);
if (hasInvalidOption) {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: "特斯拉不是中国国产企业哦~",
}));
return;
}
if (val.length < item.blanks!.length) return;
// 检查是否都是正确答案(排除特斯拉)
const validAnswers = [
"比亚迪",
"华为",
"上海电气",
"光明食品",
"拼多多",
];
const filledAnswers = val.map((v) => v.optionValue);
const isCorrect = filledAnswers.every((answer) =>
validAnswers.includes(answer)
);
if (isCorrect) {
setActiveSuccess(item.id);
setSuccessContent("回答正确,请继续探索。");
setResumeCallback(() => resume);
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: "再想一想,确保选择的都是中国国产企业/品牌~",
}));
}
};
return (
<>
<DragQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.title}(拖拽)`}
questionPrefix={item.prompt}
questionSuffix=""
options={item.options.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
blanks={item.blanks || []}
value={dragQuizValues[item.id] || []}
error={dragQuizErrors[item.id]}
onChange={handleDragChange}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null);
setSuccessContent("");
setDragQuizValues((prev) => {
const next = { ...prev };
delete next[item.id];
return next;
});
setDragQuizErrors((prev) => {
const next = { ...prev };
delete next[item.id];
return next;
});
const cb = resumeCallback;
setResumeCallback(null);
cb?.();
}}
/>
</>
);
},
};
}
// 多选题处理
if (item.type === "multiple") {
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: ({ resume }) => (
<>
<MultipleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.title}(多选题):${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers}
errorMessage="再思考一下,注意选择所有正确答案。"
onSubmit={(values: string[], isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id);
setSuccessContent("回答正确,请继续探索。");
setResumeCallback(() => resume);
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null);
setSuccessContent("");
const cb = resumeCallback;
setResumeCallback(null);
cb?.();
}}
/>
</>
),
};
}
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: () => null,
};
}),
[
activeSuccess,
resumeCallback,
successContent,
dragQuizValues,
dragQuizErrors,
]
);
if (!moduleName) return null;
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
className="relative h-full w-full bg-black"
style={{
backgroundImage: getBackgroundImage(`${starName}/internalization/底图`),
backgroundImage: getBackgroundImage("components/internalization/bg"),
}}
>
{/* 离开按钮 */}
<Image
className="absolute right-5 top-5"
className="absolute right-8 top-8 z-10"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath("video/huangyanpei/huangyanpei_neihua.mp4")}
className="h-full w-full"
anchors={anchors}
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false);
setPostGuideVisible(true);
}}
/>
)}
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={(visible) => {
setVisible(visible);
if (!visible) {
setShowVideo(true);
handleVideoCanPlay();
}
}}
showMask={false}
>
<GuideStepsItem speaker="student">
小沪,刚刚结束的"任之成长记"太有意思了!我变成百年前的川沙少年选课程、做手工、设计国货推销方案,好像真的走进了黄炎培先生的职教世界。
</GuideStepsItem>
<GuideStepsItem speaker="student">
但我还有一个疑问:先生当年倡导的"手脑并用、工学结合",放在我们现在的中职学习中,到底有什么样的意义呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这个问题问到点子上啦!黄炎培先生的职教思想,从来不是尘封的历史,而是活在当下的行动指南。今天我们特别邀请到中华职业教育社第十三届理事会常务理事、中华职业学校党总支书记、校长黄玉璟,让她带我们解码先生精神的时代密码!
</GuideStepsItem>
</GuideSteps>
{/* 视频结束后的引导 */}
<GuideSteps
visible={postGuideVisible}
onVisibleChange={(visible) => {
setPostGuideVisible(visible);
if (!visible) {
setShowLetter(true);
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem speaker="guide">
听完黄校长的讲解,相信你对黄炎培先生的职教精神有了更深的理解。现在,就请你把这份感悟写下来,既总结参观的收获,也给自己未来许下一个承诺吧!
</GuideStepsItem>
</GuideSteps>
{/* 信件组件 */}
<LetterToHuangyanpei
visible={showLetter}
onSubmit={(values: string[]) => {
console.log("提交信件", values);
setShowLetter(false);
setShowStudyGuide(true);
}}
/>
<div className="flex items-center justify-center h-full">
<div className="text-white text-4xl">黄炎培故居-内化模块(待完善)</div>
</div>
<StudyGuideModal
visible={showStudyGuide}
onContinue={() => {
setShowStudyGuide(false);
navigate(`/${moduleName}/${starName}/home`);
}}
>
恭喜你已经顺利完成了黄炎培故居的学习,至此,你已经完成了本课程所有故居的学习,
希望你能有所收获,学习八位名人的优秀品质和精神,在新时代为中华民族伟大复兴发光发热!
</StudyGuideModal>
</div>
);
};
export default HuangyanpeiInternalization;
+48 -6
View File
@@ -4,6 +4,7 @@ import {
GuideStepsItem,
Image,
Title,
StudyGuideModal,
} from "@/components";
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
@@ -24,8 +25,13 @@ const HuangyanpeiPlace1 = () => {
moduleName: string;
}>();
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true);
// 对话结束后的学习引导弹窗
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false);
const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false);
// 引导对话显示状态
const [visible, setVisible] = useState(true);
const [visible, setVisible] = useState(false);
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null);
// 已浏览展品的 ID 集合
@@ -81,8 +87,8 @@ const HuangyanpeiPlace1 = () => {
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-[calc(50%-140px)] right-80"
src={getStaticImage("huangyanpei/place1/3_005")}
className="absolute bottom-0 right-0"
src={getStaticImage("/components/place/继续前进")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/place2`);
@@ -106,20 +112,56 @@ const HuangyanpeiPlace1 = () => {
}}
/>
{/* 学习引导词 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false);
setVisible(true);
}}
>
同学你好,欢迎来到走进故居-对话学习模块!{"\n"}
{
"进入黄炎培故居后,我们一起来了解黄炎培先生经历的5个阶段:““思想启蒙”-> “教育实践”-> “创立职教”-> “延安对话”-> 职教传薪;"
}
</StudyGuideModal>
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={setVisible}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible);
if (!nextVisible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true);
setShowAfterDialogGuide(true);
}
}}
showMask={true}
highlightSelectors={[]}
>
<GuideStepsItem speaker="student">
小沪!这就是黄炎培先生曾经生活过的地方吗?
小沪,这栋古色古香的江南大院,就是黄炎培先生从小生活、读书的地方吗
</GuideStepsItem>
<GuideStepsItem speaker="guide">
是的,这里就是黄炎培故居。让我们一起来探索黄炎培先生的思想启蒙之路...
没错,1878年10月1日,黄炎培先生就出生在当时江苏省川沙县城关镇(现上海市浦东新区川沙镇)的这处“内史第”,宅子原是他姑祖父沈树镛家,也是他后来所说的“生于斯、读于斯、长于斯”之地。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1890年,川沙创办“观澜书院”,12岁的黄炎培以第一名的成绩考入,每天提着母亲亲手缝制的布书包,往返十里路求学,一点都不叫苦。1893年他去上海应县考,松江府复试名列前茅;1899年,在松江府试中更是以第一名的成绩中了秀才。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+67 -2
View File
@@ -1,5 +1,13 @@
import { useState } from "react";
import { ExhibitOverlay, Title, Image, Cursor } from "@/components";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
StudyGuideModal,
GuideStepsItem,
GuideSteps,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -16,11 +24,31 @@ const HuangyanpeiPlace2 = () => {
moduleName: string;
}>();
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
// GuideSteps 当前步骤
const [guideStep, setGuideStep] = useState(0);
// GuideSteps 显示状态
const [guideVisible, setGuideVisible] = useState(false);
const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index);
if (index === 1) {
// 展品1:点击时同时打开展品和引导对话,从第1步开始
setActiveExhibitId(1);
setGuideVisible(true);
setGuideStep(0);
} else {
// 展品2:直接打开
setActiveExhibitId(index);
}
};
// 监听GuideSteps步骤变化,仅同步当前步骤
const handleGuideStepChange = (step: number) => {
setGuideStep(step);
};
const progressCount = visited.size;
@@ -94,6 +122,43 @@ const HuangyanpeiPlace2 = () => {
setActiveExhibitId(null);
}}
/>
{/* 展品1的引导对话 */}
<GuideSteps
visible={guideVisible}
onVisibleChange={setGuideVisible}
currentStep={guideStep}
onStepChange={handleGuideStepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
哇,这就是南洋公学!是不是现在上海交通大学的前身呀?黄炎培先生中了秀才之后,就来这里读书了吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你说得特别对!1901年,黄炎培先生考入南洋公学(今上海交通大学)特班,当时的老师正是蔡元培先生。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
蔡先生一句“中国教育之病,在于学与术分离”,让他瞬间醍醐灌顶,明白了教育不能只谈书本、脱离实际。1902年,黄炎培去南京参加江南乡试,成功中了举人;
</GuideStepsItem>
<GuideStepsItem speaker="guide">
可就在这一年,南洋公学发生了著名的“墨水瓶风潮”,他毅然跟着蔡元培先生退学,转入蔡先生创办的中国教育会,正式开启了自己“教育救国”的实践。
</GuideStepsItem>
</GuideSteps>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+149 -4
View File
@@ -1,5 +1,14 @@
import { useState } from "react";
import { ExhibitOverlay, Title, Image, Cursor } from "@/components";
import { useState, useEffect } from "react";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
StudyGuideModal,
GuideSteps,
GuideStepsItem,
SingleChoiceQuizPanel,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -17,15 +26,64 @@ const HuangyanpeiPlace3 = () => {
moduleName: string;
}>();
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(
null
);
const [visited, setVisited] = useState<Set<number>>(new Set());
// 光标3的引导对话状态
const [guide3Visible, setGuide3Visible] = useState(false);
const [guide3Step, setGuide3Step] = useState(0);
// 光标3的题目显示状态
const [showQuiz3, setShowQuiz3] = useState(false);
// 光标1、2的引导对话状态(在两者都浏览后显示)
const [guide12Visible, setGuide12Visible] = useState(false);
const [guide12Shown, setGuide12Shown] = useState(false);
const handleOpenExhibit = (index: 1 | 2 | 3) => {
setActiveExhibitId(index);
if (index === 3) {
// 光标3:点击时同时打开展品和引导对话
setActiveExhibitId(3);
setGuide3Visible(true);
setGuide3Step(0);
} else {
// 光标1、2:直接打开
setActiveExhibitId(index);
}
};
// 监听光标3的引导对话步骤变化
const handleGuide3StepChange = (step: number) => {
setGuide3Step(step);
};
// 监听光标3的引导对话关闭
const handleGuide3VisibleChange = (visible: boolean) => {
setGuide3Visible(visible);
if (!visible) {
// 对话结束后显示题目
setShowQuiz3(true);
}
};
// 处理光标3题目提交
const handleQuiz3Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setShowQuiz3(false);
}
};
// 监听visited变化,当光标1、2都浏览后显示引导对话
useEffect(() => {
if (visited.has(1) && visited.has(2) && !guide12Shown) {
setGuide12Shown(true);
setGuide12Visible(true);
}
}, [visited, guide12Shown]);
const progressCount = visited.size;
const total = 3;
const completed = progressCount >= total;
@@ -61,16 +119,19 @@ const HuangyanpeiPlace3 = () => {
}}
/>
{/* 光标1 */}
<Cursor
className="absolute left-32 top-105"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute left-172 top-90"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute right-92 top-108"
onClick={() => handleOpenExhibit(3)}
@@ -98,10 +159,94 @@ const HuangyanpeiPlace3 = () => {
next.add(activeExhibitId);
return next;
});
setActiveExhibitId(null);
} else {
setActiveExhibitId(null);
}
setActiveExhibitId(null);
}}
/>
{/* 光标3的引导对话 */}
<GuideSteps
visible={guide3Visible}
onVisibleChange={handleGuide3VisibleChange}
currentStep={guide3Step}
onStepChange={handleGuide3StepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
川沙小学堂是不是黄炎培先生"教育救国"的第一个试验田啊?还有黄炎培先生是不是就像现在教育这样,也有专业课程、实用课程,也很注重动手能力啊?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你说得特别对!1903年,年仅24岁的黄炎培就和好友张志鹤等人一起,把原来的"观澜书院"改制成"川沙小学堂",黄炎培出任校长。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
学校的课程设置非常大胆创新,首次引入算术、"格致"(也就是现在说的自然科学)、体操、乡土调查等新学科。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
学校采用"半日读书、半日手工"的模式,特别注重实际应用,这也是中国最早的"半日授课、半日实习"的雏形。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
办学资金紧张,没有钱买课本,黄炎培就亲自带领大家刻蜡板来印刷;没有体育器材,他就带着学生用竹子做成双杠...整个学校完全是凭着满腔热情办起来的!
</GuideStepsItem>
</GuideSteps>
{/* 光标3的题目 */}
<SingleChoiceQuizPanel
visible={showQuiz3}
title='黄炎培1903年把川沙"观澜书院"改成"川沙小学堂"后,课程表首次出现下列哪组学科?'
options={[
{ value: "A", label: "A. 经学、策论、八股文" },
{
value: "B",
label: "B. 算术、格致、体操、乡土调查",
},
{ value: "C", label: "C. 外语、逻辑、演讲、社交" },
{ value: "D", label: "D. 绘画、声乐、舞蹈、戏剧" },
]}
correctAnswer="B"
errorMessage='1903年,黄炎培将"观澜书院"改制为"川沙小学堂",自任校长,率先打破传统经学模式,在课程表中首次列入算术、格致(近代自然科学)、体操和乡土调查四门新学科,并实行"半日授课、半日实习"的教学方式,开创了近代中国小学融入科学、体育与实践教育的新风,因此选项B正确。'
onSubmit={handleQuiz3Submit}
zIndex={10001}
/>
{/* 光标1、2的引导对话 */}
<GuideSteps
visible={guide12Visible}
onVisibleChange={setGuide12Visible}
showMask={true}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
原来当年还有专门的女子学校呀?在那个年代,女孩子能上学是不是特别不容易?后来的浦东中学也是黄炎培先生办的吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你观察得特别仔细!
川沙小学堂开学没多久,黄炎培就和堂兄黄洪培、堂嫂陆开群一起,策划创建了"开群女校"——这所学校专门面向社会招收女学生,还用全力支持办学、腾出自家住房的陆开群名字命名,直接开创了浦东地区女子教育的先河,让很多女孩子有了读书的机会。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
到了1906年,上海营造业宗师杨斯盛捐资30万两白银,在浦东六里桥购地40多亩创办浦东中学,特意邀请黄炎培担任首任校长。校舍是黄炎培亲自设计的,杨斯盛先生亲自监工,建成后有能容纳千人的大礼堂和两座教学楼,是近代上海第一所完全中学呢!
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1912年,黄炎培担任江苏省署教育司司长,任职期间一直倡导实用主义教育,强调教育要和生产劳动结合起来,这也为后来的职业教育打下了坚实基础。
</GuideStepsItem>
</GuideSteps>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+34 -7
View File
@@ -1,15 +1,22 @@
import { useState } from "react";
import { ExhibitOverlay, Title, Image, Cursor } from "@/components";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
const starName = "huangyanpei";
const exhibitSrcMap: Record<1 | 2 | 3 | 4, string> = {
const exhibitSrcMap: Record<1 | 2 | 3 | 4 | 5, string> = {
1: "huangyanpei/place4/6_006",
2: "huangyanpei/place4/6_007",
3: "huangyanpei/place4/6_008",
4: "huangyanpei/place4/6_009",
5: "huangyanpei/place4/6_010",
};
const HuangyanpeiPlace4 = () => {
@@ -18,17 +25,19 @@ const HuangyanpeiPlace4 = () => {
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | null>(
null
);
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const [activeExhibitId, setActiveExhibitId] = useState<
1 | 2 | 3 | 4 | 5 | null
>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
const handleOpenExhibit = (index: 1 | 2 | 3 | 4) => {
const handleOpenExhibit = (index: 1 | 2 | 3 | 4 | 5) => {
setActiveExhibitId(index);
};
const progressCount = visited.size;
const total = 4;
const total = 5;
const completed = progressCount >= total;
const subtitle = completed
? "探索黄炎培创立职教(已完成)"
@@ -82,6 +91,11 @@ const HuangyanpeiPlace4 = () => {
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
/>
<Cursor
className="absolute right-205 top-110"
onClick={() => handleOpenExhibit(5)}
read={visited.has(5)}
/>
{completed && (
<Image
@@ -108,6 +122,19 @@ const HuangyanpeiPlace4 = () => {
setActiveExhibitId(null);
}}
/>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+31 -4
View File
@@ -1,5 +1,12 @@
import { useState, useRef } from "react";
import { ExhibitOverlay, Title, Image, Cursor, Video } from "@/components";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
Video,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import type { VideoRef } from "@/components/video";
@@ -21,6 +28,8 @@ const HuangyanpeiPlace5 = () => {
const [visited, setVisited] = useState<Set<number>>(new Set());
const [videoVisible, setVideoVisible] = useState(false);
const videoRef = useRef<VideoRef>(null);
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 2) {
@@ -49,6 +58,12 @@ const HuangyanpeiPlace5 = () => {
backgroundImage: getBackgroundImage(`huangyanpei/place5/7_001`),
}}
>
<img
src={getStaticImage("huangyanpei/place5/7_003")}
alt=""
className="absolute top-72 right-37"
/>
<Title
title="延安对话"
subtitle={subtitle}
@@ -94,10 +109,9 @@ const HuangyanpeiPlace5 = () => {
<div className="fixed inset-0 z-[9999] bg-black">
<Video
ref={videoRef}
src={getPublicPath(
"video/chenyun/8陈云纪念馆16大米价格暴涨_x264.mp4"
)}
src={getPublicPath("video/huangyanpei/huangyanpei_07.mp4")}
className="w-full h-full"
controls
autoPlay
playsInline
preload="auto"
@@ -129,6 +143,19 @@ const HuangyanpeiPlace5 = () => {
setActiveExhibitId(null);
}}
/>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+22 -1
View File
@@ -1,5 +1,11 @@
import { useState } from "react";
import { ExhibitOverlay, Title, Image, Cursor } from "@/components";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -17,6 +23,8 @@ const HuangyanpeiPlace6 = () => {
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (index: 1) => {
setActiveExhibitId(index);
@@ -88,6 +96,19 @@ const HuangyanpeiPlace6 = () => {
setActiveExhibitId(null);
}}
/>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
-155
View File
@@ -1,155 +0,0 @@
import { useState, useCallback } from "react";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
BambooPuzzleGame,
} from "@/components";
import { SuccessAlert } from "@/components/layout";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
const starName = "huangyanpei";
const exhibitSrcMap: Record<1 | 2, string> = {
1: "huangyanpei/place7/9_004",
2: "huangyanpei/place7/场景7展品2",
};
const HuangyanpeiPlace7 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
const [showGame, setShowGame] = useState(false);
const [gameCompleted, setGameCompleted] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index);
};
const progressCount = visited.size;
const total = 2;
const completed = progressCount >= total;
const subtitle = completed
? "探索川沙小学堂·启蒙之始(已完成)"
: `探索川沙小学堂·启蒙之始(${progressCount}/${total})`;
const subtitleClassName = completed
? "text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]"
: undefined;
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`huangyanpei/place7/9_001`),
}}
>
<Title
title="跨越时空"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
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}/place6`);
}
}}
/>
<Cursor
className="absolute left-68 top-55"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
/>
<Cursor
className="absolute left-160 bottom-50"
onClick={() => {
// 如果游戏已完成,不再打开
if (!gameCompleted) {
setShowGame(true);
}
}}
read={gameCompleted}
/>
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage("huangyanpei/place7/9btn")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/home`);
}}
/>
)}
<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);
}}
/>
<BambooPuzzleGame
visible={showGame}
onClose={() => {
setShowGame(false);
}}
onComplete={() => {
// 游戏完成后,显示成功提示
setShowGame(false);
setShowSuccessAlert(true);
}}
/>
<SuccessAlert
visible={showSuccessAlert}
title="拼单杠成功!"
onContinue={useCallback(() => {
console.log("onContinue called, showSuccessAlert:", showSuccessAlert);
// 直接设置为 false,不使用函数式更新
setShowSuccessAlert(false);
setGameCompleted(true);
setVisited((prev) => {
const next = new Set(prev);
next.add(2);
return next;
});
}, [showSuccessAlert])}
onClose={useCallback(() => {
console.log("onClose called, showSuccessAlert:", showSuccessAlert);
// 直接设置为 false,不使用函数式更新
setShowSuccessAlert(false);
setGameCompleted(true);
setVisited((prev) => {
const next = new Set(prev);
next.add(2);
return next;
});
}, [showSuccessAlert])}
/>
</div>
);
};
export default HuangyanpeiPlace7;
-37
View File
@@ -1,37 +0,0 @@
import { useNavigate, useParams } from "react-router-dom";
import { Image } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
const starName = "huangyanpei";
const HuangyanpeiReflection = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/reflection/底图`),
}}
>
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
}
}}
/>
<div className="flex items-center justify-center h-full">
<div className="text-white text-4xl">黄炎培故居-体悟模块(待完善)</div>
</div>
</div>
);
};
export default HuangyanpeiReflection;
@@ -0,0 +1,538 @@
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { cn, getStaticImage } from "@/lib/utils";
import { Image, DragFill, StudyGuideModal } from "@/components";
import type { DragFillResult } from "@/components";
import { useScale } from "@/contexts/ScaleContext";
export interface BambooPuzzleGameProps {
/** 是否可见 */
visible?: boolean;
/** 关闭回调 */
onClose?: () => void;
/** 完成回调 */
onComplete?: () => void;
}
type PieceId = "A" | "B" | "C" | "D" | "E" | "F";
type ContainerType = "horizontal" | "leftPillar" | "rightPillar";
interface Piece {
id: PieceId;
src: string;
correctContainer: ContainerType;
}
// 根据效果图:A和B是立柱(可以放在左右任意一边),C是横杆(必须放在横杆容器)
const pieces: Piece[] = [
{
id: "A",
src: "huangyanpei/reflection/scene1/9_008",
correctContainer: "leftPillar",
},
{
id: "B",
src: "huangyanpei/reflection/scene1/9_0013",
correctContainer: "horizontal",
},
{
id: "C",
src: "huangyanpei/reflection/scene1/9_009",
correctContainer: "rightPillar",
},
{
id: "D",
src: "huangyanpei/reflection/scene1/9_0010",
correctContainer: "horizontal",
},
{
id: "E",
src: "huangyanpei/reflection/scene1/9_0011",
correctContainer: "horizontal",
},
{
id: "F",
src: "huangyanpei/reflection/scene1/9_0012",
correctContainer: "horizontal",
},
];
/**
* 毛竹拼单杠游戏组件
* - 使用蒙版遮罩作为背景
* - 使用 DragFill 组件实现拖拽功能
*/
const BambooPuzzleGame = ({
visible = true,
onClose,
onComplete,
}: BambooPuzzleGameProps) => {
const maskSrc = getStaticImage("components/蒙版遮罩");
const backSrc = getStaticImage("components/关闭");
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
const [mounted, setMounted] = useState(false);
const [value, setValue] = useState<DragFillResult[]>([]);
const [errorContainers, setErrorContainers] = useState<Set<ContainerType>>(
new Set()
);
const [shakeKey, setShakeKey] = useState(0);
const completedRef = useRef(false);
const [showStartGuide, setShowStartGuide] = useState(false);
// SSR 兼容
useEffect(() => {
setMounted(true);
}, []);
// 重置游戏状态
useEffect(() => {
if (visible) {
setValue([]);
setErrorContainers(new Set());
setShakeKey(0);
completedRef.current = false;
setShowStartGuide(true);
}
}, [visible]);
// 检查是否完成 - 三个都拼了才验证
useEffect(() => {
if (value.length === 3) {
const leftPillar = value.find((r) => r.blankId === "leftPillar");
const rightPillar = value.find((r) => r.blankId === "rightPillar");
const horizontal = value.find((r) => r.blankId === "horizontal");
// 验证规则:
// 1. C 必须在横杆容器
// 2. A 和 B 可以在左右任意立柱(但必须各占一个)
const isCorrect =
horizontal?.optionValue === "C" &&
leftPillar &&
rightPillar &&
(leftPillar.optionValue === "A" || leftPillar.optionValue === "B") &&
(rightPillar.optionValue === "A" || rightPillar.optionValue === "B") &&
leftPillar.optionValue !== rightPillar.optionValue;
if (isCorrect && !completedRef.current) {
// 拼对了,调用完成回调(只调用一次)
completedRef.current = true;
onComplete?.();
} else {
// 拼错了,找出错误的容器
const errors = new Set<ContainerType>();
// 检查横杆容器:必须是 C
if (horizontal?.optionValue !== "C") {
errors.add("horizontal");
}
// 检查立柱容器:必须是 A 或 B,且不能相同
if (
!leftPillar ||
(leftPillar.optionValue !== "A" && leftPillar.optionValue !== "B")
) {
errors.add("leftPillar");
}
if (
!rightPillar ||
(rightPillar.optionValue !== "A" && rightPillar.optionValue !== "B")
) {
errors.add("rightPillar");
}
// 如果两个立柱相同,都标记为错误
if (
leftPillar &&
rightPillar &&
leftPillar.optionValue === rightPillar.optionValue
) {
errors.add("leftPillar");
errors.add("rightPillar");
}
// 显示错误效果:变红并抖动
setErrorContainers(errors);
setShakeKey((prev) => prev + 1); // 触发动画重新播放
// 1.5秒后清除错误状态和错误的填充
setTimeout(() => {
setErrorContainers(new Set());
// 清除错误的填充
setValue((prev) =>
prev.filter((r) => !errors.has(r.blankId as ContainerType))
);
}, 1500);
}
}
}, [value, onComplete]);
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation();
onClose?.();
},
[onClose]
);
// 转换为 DragFill 的 options
const options = useMemo(
() =>
pieces.map((piece) => ({
value: piece.id,
label: piece.id,
})),
[]
);
// 转换为 DragFill 的 blanks
const blanks = useMemo(
() => [
{ id: "horizontal", label: "横杆容器" },
{ id: "leftPillar", label: "立柱容器" },
{ id: "rightPillar", label: "立柱容器" },
],
[]
);
// 样式
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
position: "fixed",
inset: 0,
zIndex: 9999,
pointerEvents: "none",
}),
[]
);
const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({
position: "absolute",
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`,
top: `${offsetY}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
overflow: "hidden",
}),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
);
if (!mounted) return null;
const content = (
<AnimatePresence>
{visible && (
<motion.div
key="bamboo-puzzle-game"
style={overlayStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div style={scaledContainerStyle}>
<div
className={cn(
"absolute inset-0 flex flex-col items-center",
"bg-black/70"
)}
style={{ pointerEvents: "auto" }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
<StudyGuideModal
visible={showStartGuide}
onContinue={() => setShowStartGuide(false)}
verticalAlign="center"
>
当年办学经费超紧张,没课本、没器材,黄先生就带着学生自己动手!
{"\n"}
快来一起做器具吧。
</StudyGuideModal>
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 游戏内容 */}
<div className="relative z-10 w-full h-full flex flex-col">
{/* 标题 */}
<div className="mt-8 mb-4 text-center">
<h1
className={cn([
"text-5xl font-bold mb-2 text-white",
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]",
])}
>
毛竹拼单杠
</h1>
<p className="text-4xl text-white mt-10">
请将下方的竹子拖到正确位置,拼出单杠!
</p>
</div>
{/* 拼图区域 - 空白容器 */}
<div
className="flex-1 w-full relative"
style={{ minHeight: "400px" }}
>
{/* 横杆容器 - 上方居中 */}
<motion.div
key={`horizontal-${shakeKey}`}
data-blank-id="horizontal"
className={cn(
"absolute border-2 border-dashed rounded-4xl",
"flex flex-col items-center justify-center transition-all duration-200",
errorContainers.has("horizontal")
? "border-red-500 bg-red-500/20"
: "border-white/60 bg-white/5"
)}
animate={
errorContainers.has("horizontal")
? {
x: [0, -15, 15, -15, 15, -10, 10, 0],
transition: {
duration: 0.6,
ease: "easeInOut",
},
}
: { x: 0 }
}
style={{
left: `${DESIGN_WIDTH * 0.15}px`,
top: `${DESIGN_HEIGHT * 0.02}px`,
width: `${DESIGN_WIDTH * 0.7}px`,
height: `${DESIGN_HEIGHT * 0.2}px`,
}}
>
{(() => {
const filled = value.find(
(r) => r.blankId === "horizontal"
);
const piece = filled
? pieces.find((p) => p.id === filled.optionValue)
: null;
return piece ? (
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain p-2"
objectFit="contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="text-white/30 text-4xl">
拖拽横杆到这里
</div>
</div>
);
})()}
</motion.div>
{/* 左立柱容器 */}
<motion.div
key={`leftPillar-${shakeKey}`}
data-blank-id="leftPillar"
className={cn(
"absolute border-2 border-dashed rounded-4xl",
"flex flex-col items-center justify-center transition-all duration-200",
errorContainers.has("leftPillar")
? "border-red-500 bg-red-500/20"
: "border-white/60 bg-white/5"
)}
animate={
errorContainers.has("leftPillar")
? {
x: [0, -15, 15, -15, 15, -10, 10, 0],
transition: {
duration: 0.6,
ease: "easeInOut",
},
}
: { x: 0 }
}
style={{
left: `${DESIGN_WIDTH * 0.15}px`,
top: `${DESIGN_HEIGHT * 0.14}px`,
width: `${DESIGN_WIDTH * 0.12}px`,
height: `${DESIGN_HEIGHT * 0.32}px`,
}}
>
{(() => {
const filled = value.find(
(r) => r.blankId === "leftPillar"
);
const piece = filled
? pieces.find((p) => p.id === filled.optionValue)
: null;
return piece ? (
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain p-2"
objectFit="contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="text-white/30 text-4xl">
拖拽立柱到这里
</div>
</div>
);
})()}
</motion.div>
{/* 右立柱容器 */}
<motion.div
key={`rightPillar-${shakeKey}`}
data-blank-id="rightPillar"
className={cn(
"absolute border-2 border-dashed rounded-4xl",
"flex flex-col items-center justify-center transition-all duration-200",
errorContainers.has("rightPillar")
? "border-red-500 bg-red-500/20"
: "border-white/60 bg-white/5"
)}
animate={
errorContainers.has("rightPillar")
? {
x: [0, -15, 15, -15, 15, -10, 10, 0],
transition: {
duration: 0.6,
ease: "easeInOut",
},
}
: { x: 0 }
}
style={{
left: `${DESIGN_WIDTH * 0.74}px`,
top: `${DESIGN_HEIGHT * 0.14}px`,
width: `${DESIGN_WIDTH * 0.12}px`,
height: `${DESIGN_HEIGHT * 0.32}px`,
}}
>
{(() => {
const filled = value.find(
(r) => r.blankId === "rightPillar"
);
const piece = filled
? pieces.find((p) => p.id === filled.optionValue)
: null;
return piece ? (
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain p-2"
objectFit="contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="text-white/30 text-4xl">
拖拽立柱到这里
</div>
</div>
);
})()}
</motion.div>
</div>
{/* 道具区域 - 使用 DragFill 组件 */}
<div
className="absolute bottom-0 left-0 w-full"
style={{
paddingLeft: `${DESIGN_WIDTH * 0.05}px`,
paddingBottom: `${DESIGN_HEIGHT * 0.05}px`,
width: `${DESIGN_WIDTH * 0.9}px`,
}}
>
<div className="text-[#243204] mb-20 text-4xl font-blod">
你的毛竹道具
</div>
<DragFill
options={options}
blanks={blanks}
value={value}
onChange={setValue}
allowDuplicate={false}
layout="horizontal-options"
hideLabels={true}
showSubmitButton={false}
className="w-full"
renderOption={(option, isUsed) => {
const piece = pieces.find((p) => p.id === option.value);
if (!piece) return null;
return (
<div
className={cn(
"relative flex flex-col items-center justify-center",
" h-24 rounded-lg transition-all duration-200",
"cursor-move select-none",
isUsed
? "opacity-50 cursor-not-allowed"
: "hover:scale-110"
)}
>
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain pointer-events-none"
draggable={false}
objectFit="contain"
/>
<span className="text-white text-base mt-1 font-medium">
{piece.id}
</span>
</div>
);
}}
renderDragging={(option) => {
const piece = pieces.find((p) => p.id === option.value);
if (!piece) return null;
return (
<div className="w-24 h-24">
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain"
draggable={false}
objectFit="contain"
/>
</div>
);
}}
renderBlank={() => {
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
return <div className="hidden" />;
}}
/>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
return createPortal(content, document.body);
};
export default BambooPuzzleGame;
@@ -0,0 +1,3 @@
export { default as BambooPuzzleGame } from "./BambooPuzzleGame";
export type { BambooPuzzleGameProps } from "./BambooPuzzleGame";
@@ -0,0 +1,4 @@
export { default as QuestionModal } from "./question-modal";
export type { QuestionModalProps } from "./question-modal";
export { BambooPuzzleGame } from "./game";
@@ -0,0 +1,4 @@
export { default } from "./question-modal";
export { default as QuestionModal } from "./question-modal";
export type { QuestionModalProps } from "./question-modal";
@@ -0,0 +1,152 @@
import { useCallback, useState, type ReactNode } from "react";
import { Image } from "@/components";
import { getStaticImage } from "@/lib/utils";
import { ErrorAlert, SuccessAlert } from "@/components/layout";
export interface QuestionModalProps {
/** 是否可见 */
visible?: boolean;
/** 标题文案 */
title?: string;
/** 主体内容(支持换行、富文本) */
children?: ReactNode;
/** 点击关闭按钮回调 */
onClose?: () => void;
/** 点击遮罩是否关闭,默认 false */
maskClosable?: boolean;
/** 自定义 zIndex,默认 120 */
zIndex?: number;
/** 自定义类名 */
className?: string;
/** 自定义样式 */
style?: React.CSSProperties;
/** 成功回调 */
onSuccess?: () => void;
/** 错误回调 */
onError?: () => void;
}
export const QuestionModal = ({
visible = false,
title: _title,
children: _children,
onClose,
onSuccess,
onError,
maskClosable: _maskClosable = false,
zIndex = 120,
className: _className,
style: _style,
}: QuestionModalProps) => {
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [showErrorAlert, setShowErrorAlert] = useState(false);
// 答案
const correctAnswer = 2;
// 选项
const questionOptions = [
{
id: 1,
src: getStaticImage("huangyanpei/reflection/scene1/9_004"),
},
{
id: 2,
src: getStaticImage("huangyanpei/reflection/scene1/9_005"),
},
];
const handleAnswer = useCallback(
(id: number) => {
if (id === correctAnswer) {
// 选对了,显示成功提示
setShowSuccessAlert(true);
} else {
// 选错了,显示错误提示
setShowErrorAlert(true);
}
},
[correctAnswer]
);
const handleSuccessAlertClose = useCallback(() => {
setShowSuccessAlert(false);
onSuccess?.();
}, [onSuccess]);
const handleErrorAlertClose = useCallback(() => {
setShowErrorAlert(false);
onError?.();
}, [onError]);
const maskSrc = getStaticImage("components/蒙版遮罩");
const backSrc = getStaticImage("components/关闭");
if (!visible) return null;
return (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ zIndex }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
{/* 返回按钮 */}
<Image
src={backSrc}
onClick={() => onClose?.()}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
{/* 游戏内容 */}
<div className="relative z-10 w-full h-full flex flex-col ">
{/* 标题 */}
<div className="mt-28 mb-4 text-center px-80">
<p className="text-5xl text-white mt-10 leading-tight">
黄先生说:“中国教育之病,在于学与术分离”!你看,这里有两套课程表,选一套开启学习吧。
</p>
</div>
{/* 选项 */}
<div className="flex justify-center gap-30 mt-20">
{questionOptions.map((option) => (
<Image
className="w-80"
key={option.id}
src={option.src}
button
onClick={() => handleAnswer(option.id)}
/>
))}
</div>
</div>
{/* 成功提示弹窗 */}
<SuccessAlert
visible={showSuccessAlert}
title="选对啦!"
content="这就是黄先生打破旧教育的创新,学这些实用知识,才能真正帮到家乡。"
onContinue={handleSuccessAlertClose}
onClose={handleSuccessAlertClose}
/>
{/* 错误提示弹窗 */}
<ErrorAlert
visible={showErrorAlert}
text="哎呀选错啦!黄先生最反对只啃书本脱离实际的教育,旧课程学不到真本事,快试试另一边的新式课程吧。"
onClose={handleErrorAlertClose}
/>
</div>
);
};
QuestionModal.displayName = "QuestionModal";
export default QuestionModal;
+62 -48
View File
@@ -1,37 +1,27 @@
import { useState, useCallback } from "react";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
BambooPuzzleGame,
} from "@/components";
import { useState, useCallback, useEffect } from "react";
import { Title, Image, Cursor, StudyGuideModal } from "@/components";
import { BambooPuzzleGame, QuestionModal } from "./components";
import { SuccessAlert } from "@/components/layout";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
const starName = "huangyanpei";
const exhibitSrcMap: Record<1 | 2, string> = {
1: "huangyanpei/reflection/scene1/9_004",
2: "huangyanpei/reflection/scene1/场景7展品2",
};
const HuangyanpeiReflectionScene1 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
const [showGuideModal, setShowGuideModal] = useState(true);
const [showQuestionModal, setShowQuestionModal] = useState(false);
const [questionCompleted, setQuestionCompleted] = useState(false);
const [showGame, setShowGame] = useState(false);
const [gameCompleted, setGameCompleted] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index);
};
const [showNextGuide, setShowNextGuide] = useState(false);
const [nextGuideShown, setNextGuideShown] = useState(false);
const progressCount = visited.size;
const total = 2;
@@ -43,6 +33,17 @@ const HuangyanpeiReflectionScene1 = () => {
? "text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]"
: undefined;
// 两个任务都完成后 500ms 弹出下一站提示(仅一次)
useEffect(() => {
if (questionCompleted && gameCompleted && !nextGuideShown) {
const timer = setTimeout(() => {
setShowNextGuide(true);
setNextGuideShown(true);
}, 500);
return () => clearTimeout(timer);
}
}, [questionCompleted, gameCompleted, nextGuideShown]);
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
@@ -64,16 +65,31 @@ const HuangyanpeiReflectionScene1 = () => {
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
if (moduleName && starName && !showGuideModal) {
navigate(`/${moduleName}/${starName}/home`);
}
}}
/>
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false);
}}
>
你好呀!现在是1903年,欢迎你进入川沙小学堂,请开启你的求学第一课吧。
{"\n"}
接下来你将在“任之”校长所在的学校学习,根据所知和所学,努力完成校长给你的考验,完成全部任务后,可进入到下一阶段的学习。
</StudyGuideModal>
<Cursor
className="absolute left-68 top-55"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
onClick={() => {
if (!questionCompleted) {
setShowQuestionModal(true);
}
}}
read={questionCompleted}
/>
<Cursor
className="absolute left-160 bottom-50"
@@ -86,7 +102,17 @@ const HuangyanpeiReflectionScene1 = () => {
read={gameCompleted}
/>
{completed && (
<QuestionModal
visible={showQuestionModal}
onSuccess={() => {
setShowQuestionModal(false);
setQuestionCompleted(true);
setVisited((prev) => new Set(prev).add(1));
}}
onClose={() => setShowQuestionModal(false)}
/>
{/* {completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage("huangyanpei/reflection/scene1/9btn")}
@@ -97,22 +123,7 @@ const HuangyanpeiReflectionScene1 = () => {
}
}}
/>
)}
<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);
}}
/>
)} */}
<BambooPuzzleGame
visible={showGame}
@@ -123,12 +134,13 @@ const HuangyanpeiReflectionScene1 = () => {
// 游戏完成后,显示成功提示
setShowGame(false);
setShowSuccessAlert(true);
setVisited((prev) => new Set(prev).add(2));
}}
/>
<SuccessAlert
visible={showSuccessAlert}
title="拼单杠成功!"
title="拼接毛竹成功!"
onContinue={useCallback(() => {
setShowSuccessAlert(false);
setGameCompleted(true);
@@ -138,16 +150,18 @@ const HuangyanpeiReflectionScene1 = () => {
return next;
});
}, [])}
onClose={useCallback(() => {
setShowSuccessAlert(false);
setGameCompleted(true);
setVisited((prev) => {
const next = new Set(prev);
next.add(2);
return next;
});
}, [])}
/>
<StudyGuideModal
visible={showNextGuide}
onContinue={() => {
setShowNextGuide(false);
navigate(`/${moduleName}/${starName}/reflection2`);
}}
verticalAlign="center"
>
第一关完成!下一站,浦东中学深造去。
</StudyGuideModal>
</div>
);
};
+48 -22
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Title, Image, Cursor } from "@/components";
import { useState, useEffect } from "react";
import { Title, Image, Cursor, StudyGuideModal } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import ChoiceDialog1 from "./scene2/ChoiceDialog1";
@@ -13,6 +13,7 @@ const HuangyanpeiReflectionScene2 = () => {
moduleName: string;
}>();
const [showGuideModal, setShowGuideModal] = useState(true);
const [showDialog1, setShowDialog1] = useState(false);
const [showDialog2, setShowDialog2] = useState(false);
const [visited, setVisited] = useState<Set<number>>(new Set());
@@ -20,10 +21,8 @@ const HuangyanpeiReflectionScene2 = () => {
"读书" | "没出息" | "女子不能上学" | null
>(null);
const [choice2, setChoice2] = useState<"铁工科" | "商科" | null>(null);
if (!moduleName) {
return null;
}
const [showCompleteGuide, setShowCompleteGuide] = useState(false);
const [completeGuideShown, setCompleteGuideShown] = useState(false);
const progressCount = visited.size;
const total = 2;
@@ -35,6 +34,32 @@ const HuangyanpeiReflectionScene2 = () => {
? "text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]"
: undefined;
// 两个任务都完成后,显示完成提示(仅一次)
useEffect(() => {
if (completed && choice2 && !completeGuideShown) {
const timer = setTimeout(() => {
setShowCompleteGuide(true);
setCompleteGuideShown(true);
}, 500);
return () => clearTimeout(timer);
}
}, [completed, choice2, completeGuideShown]);
// 处理完成提示的继续按钮点击
const handleCompleteGuideContinue = () => {
setShowCompleteGuide(false);
// 根据选择决定导航目标
if (choice2 === "铁工科") {
navigate(`/${moduleName}/${starName}/reflection3`);
} else if (choice2 === "商科") {
navigate(`/${moduleName}/${starName}/reflection4`);
}
};
if (!moduleName) {
return null;
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
@@ -56,10 +81,18 @@ const HuangyanpeiReflectionScene2 = () => {
src={getStaticImage("components/离开")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/home`);
navigate(`/${moduleName}/${starName}/reflection`);
}}
/>
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
>
欢迎来到浦东中学!{"\n"}
这是近代上海第一所完全中学,黄炎培先生当首任校长,在这里能学到更多实用知识,还能树立成才的理想哦。
</StudyGuideModal>
{choice2 && (
<img
className="absolute right-[234px] bottom-12.5"
@@ -110,21 +143,14 @@ const HuangyanpeiReflectionScene2 = () => {
}}
/>
{completed && choice2 && (
<Image
className="absolute bottom-0 right-4"
src={getStaticImage("components/place/继续前进")}
button
onClick={() => {
// 根据选择决定导航目标
if (choice2 === "铁工科") {
navigate(`/${moduleName}/${starName}/reflection3`);
} else if (choice2 === "商科") {
navigate(`/${moduleName}/${starName}/reflection4`);
}
}}
/>
)}
{/* 完成提示 */}
<StudyGuideModal
visible={showCompleteGuide}
onContinue={handleCompleteGuideContinue}
verticalAlign="center"
>
第二关完成!"职业方向锁定!"去新学校了解你的专业吧。
</StudyGuideModal>
</div>
);
};
@@ -1,9 +1,8 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { cn, getStaticImage } from "@/lib/utils";
import { Image } from "@/components";
import { useScale } from "@/contexts/ScaleContext";
import { Image, StudyGuideModal } from "@/components";
import { SuccessAlert, ErrorAlert } from "@/components/layout";
export interface ChoiceDialog1Props {
/** 是否可见 */
@@ -31,14 +30,26 @@ const ChoiceDialog1 = ({
const choice2Src = getStaticImage("huangyanpei/reflection/scene2/10_006");
const choice3Src = getStaticImage("huangyanpei/reflection/scene2/10_007");
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
const [mounted, setMounted] = useState(false);
const [showGuide, setShowGuide] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [showErrorAlert, setShowErrorAlert] = useState(false);
// SSR 兼容
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (visible) {
setShowGuide(true);
setShowSuccessAlert(false);
setShowErrorAlert(false);
} else {
setShowGuide(false);
}
}, [visible]);
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation();
@@ -49,10 +60,15 @@ const ChoiceDialog1 = ({
const handleChoiceClick = useCallback(
(choice: "读书" | "没出息" | "女子不能上学") => {
onSelect?.(choice);
onClose?.();
if (choice === "读书") {
setShowGuide(false);
setShowSuccessAlert(true);
onSelect?.(choice);
} else {
setShowErrorAlert(true);
}
},
[onSelect, onClose]
[onSelect]
);
// 样式
@@ -66,20 +82,6 @@ const ChoiceDialog1 = ({
[]
);
const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({
position: "absolute",
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`,
top: `${offsetY}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
overflow: "hidden",
}),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
);
if (!mounted) return null;
const content = (
@@ -93,109 +95,154 @@ const ChoiceDialog1 = ({
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div style={scaledContainerStyle}>
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center",
"bg-black/70"
)}
style={{ pointerEvents: "auto" }}
<div
className={cn(
"fixed inset-0 flex flex-col items-center justify-center",
"bg-black/70"
)}
style={{ pointerEvents: "auto" }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
<StudyGuideModal
visible={showGuide}
onContinue={() => setShowGuide(false)}
verticalAlign="center"
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
黄校长来考你啦!他设计这所学校,就是为了让学生摆脱旧观念,你知道是哪种吗?
</StudyGuideModal>
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex items-center justify-between px-20">
{/* 人物 - 左侧 */}
<div className="flex-shrink-0">
<img src={characterSrc} alt="人物" />
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex items-center justify-between px-20">
{/* 人物 - 左侧 */}
<div className="flex-shrink-0">
<img src={characterSrc} alt="人物" />
</div>
{/* 三个选择选项 - 右侧水平排列 */}
<div className="flex items-center justify-center gap-16 flex-1">
{/* 选择A:读书 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("读书")}
>
{/* 标签A */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
A
</div>
{/* 图标 */}
<img src={choice1Src} alt="读书" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
读书只为
<br />
升官发财
</div>
</div>
{/* 三个选择选项 - 右侧水平排列 */}
<div className="flex items-center justify-center gap-16 flex-1">
{/* 选择A:读书 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("读书")}
>
{/* 标签A */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
A
</div>
{/* 图标 */}
<img src={choice1Src} alt="读书" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
读书只为
<br />
升官发财
</div>
{/* 选择B:没出息 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("没出息")}
>
{/* 标签B */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
B
</div>
{/* 选择B:没出息 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("没出息")}
>
{/* 标签B */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
B
</div>
{/* 图标 */}
<img src={choice2Src} alt="没出息" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
手工劳作
<br />
没出息
</div>
{/* 图标 */}
<img src={choice2Src} alt="没出息" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
手工劳作
<br />
没出息
</div>
</div>
{/* 选择C:女子不能上学 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("女子不能上学")}
>
{/* 标签C */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
C
</div>
{/* 图标 */}
<img src={choice3Src} alt="女子不能上学" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
女子
<br />
不能上学
</div>
{/* 选择C:女子不能上学 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("女子不能上学")}
>
{/* 标签C */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
C
</div>
{/* 图标 */}
<img src={choice3Src} alt="女子不能上学" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
女子
<br />
不能上学
</div>
</div>
</div>
</div>
<SuccessAlert
visible={showSuccessAlert}
title="任务1 正确反馈"
content="答对啦!黄先生说“教育要为国家培养实干人才”,浦东中学就是要教大家学真本事,不是只为当官。"
onContinue={() => {
setShowSuccessAlert(false);
onClose?.();
}}
onClose={() => {
setShowSuccessAlert(false);
onClose?.();
}}
/>
<ErrorAlert
visible={showErrorAlert}
text="不对哦~黄先生虽然反对歧视女子、轻视手工,但创办浦东中学的核心是打破“读书只为当官”的旧思想,再选一次吧!"
onClose={() => setShowErrorAlert(false)}
/>
<SuccessAlert
visible={showSuccessAlert}
content="答对啦!黄先生说“教育要为国家培养实干人才”,浦东中学就是要教大家学真本事,不是只为当官。"
onContinue={() => {
setShowSuccessAlert(false);
onClose?.();
}}
onClose={() => {
setShowSuccessAlert(false);
onClose?.();
}}
/>
<ErrorAlert
visible={showErrorAlert}
text="不对哦~黄先生虽然反对歧视女子、轻视手工,但创办浦东中学的核心是打破“读书只为当官”的旧思想,再选一次吧!"
onClose={() => setShowErrorAlert(false)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
);
return createPortal(content, document.body);
return content;
};
export default ChoiceDialog1;
@@ -1,9 +1,8 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { cn, getStaticImage } from "@/lib/utils";
import { Image } from "@/components";
import { useScale } from "@/contexts/ScaleContext";
import { Image, StudyGuideModal } from "@/components";
import { SuccessAlert } from "@/components/layout";
export interface ChoiceDialog2Props {
/** 是否可见 */
@@ -28,14 +27,28 @@ const ChoiceDialog2 = ({
const backSrc = getStaticImage("components/关闭");
const choiceSrc = getStaticImage("huangyanpei/reflection/scene2/10_008");
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
const [mounted, setMounted] = useState(false);
const [showGuide, setShowGuide] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [selectedChoice, setSelectedChoice] = useState<
"铁工科" | "商科" | null
>(null);
// SSR 兼容
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (visible) {
setShowGuide(true);
setShowSuccessAlert(false);
setSelectedChoice(null);
} else {
setShowGuide(false);
}
}, [visible]);
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation();
@@ -44,13 +57,18 @@ const ChoiceDialog2 = ({
[onClose]
);
const handleChoiceClick = useCallback(
(choice: "铁工科" | "商科") => {
onSelect?.(choice);
onClose?.();
},
[onSelect, onClose]
);
const handleChoiceClick = useCallback((choice: "铁工科" | "商科") => {
setSelectedChoice(choice);
setShowSuccessAlert(true);
}, []);
const handleSuccessAlertContinue = useCallback(() => {
setShowSuccessAlert(false);
if (selectedChoice) {
onSelect?.(selectedChoice);
}
onClose?.();
}, [selectedChoice, onSelect, onClose]);
// 样式
const overlayStyle = useMemo<React.CSSProperties>(
@@ -63,20 +81,6 @@ const ChoiceDialog2 = ({
[]
);
const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({
position: "absolute",
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`,
top: `${offsetY}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
overflow: "hidden",
}),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
);
if (!mounted) return null;
const content = (
@@ -90,102 +94,113 @@ const ChoiceDialog2 = ({
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div style={scaledContainerStyle}>
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center",
"bg-black/70"
)}
style={{ pointerEvents: "auto" }}
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center",
"bg-black/70"
)}
style={{ pointerEvents: "auto" }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
<StudyGuideModal
visible={showGuide}
onContinue={() => setShowGuide(false)}
verticalAlign="center"
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
在浦东中学,你可以选未来的方向啦!不管选哪个,黄先生都会支持,因为“三百六十行,行行出状元”
</StudyGuideModal>
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-12">
{/* 选择A:铁工科(箭头指向左边) */}
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-12">
{/* 选择A:铁工科(箭头指向左边) */}
<div
className="flex items-center gap-6 cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("铁工科")}
>
{/* 标签A */}
<div className="text-7xl font-bold text-[#ffa500] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
A
</div>
{/* 箭头按钮 - 需要旋转180度使其指向左边 */}
<div
className="flex items-center gap-6 cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("铁工科")}
className="relative"
style={{ transform: "rotate(180deg)" }}
>
{/* 标签A */}
<div className="text-7xl font-bold text-[#ffa500] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
A
</div>
{/* 箭头按钮 - 需要旋转180度使其指向左边 */}
<div
className="relative"
style={{ transform: "rotate(180deg)" }}
>
<img
src={choiceSrc}
alt="铁工科"
className="max-w-[800px] object-contain"
/>
{/* 文字覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center">
<div
className="text-white text-7xl font-bold"
style={{ transform: "rotate(180deg)" }}
>
铁工科
</div>
<img
src={choiceSrc}
alt="铁工科"
className="max-w-[800px] object-contain"
/>
{/* 文字覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center">
<div
className="text-white text-7xl font-bold"
style={{ transform: "rotate(180deg)" }}
>
铁工科
</div>
</div>
</div>
</div>
{/* 选择B:商科(箭头指向右边) */}
<div
className="flex items-center gap-6 cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("商科")}
>
{/* 标签B */}
<div className="text-7xl font-bold text-[#ffa500] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
B
</div>
{/* 箭头按钮 */}
<div className="relative">
<img
src={choiceSrc}
alt="商科"
className="max-w-[800px] object-contain"
/>
{/* 文字覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-white text-7xl font-bold">
商科
</div>
</div>
{/* 选择B:商科(箭头指向右边) */}
<div
className="flex items-center gap-6 cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick("商科")}
>
{/* 标签B */}
<div className="text-7xl font-bold text-[#ffa500] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
B
</div>
{/* 箭头按钮 */}
<div className="relative">
<img
src={choiceSrc}
alt="商科"
className="max-w-[800px] object-contain"
/>
{/* 文字覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-white text-7xl font-bold">商科</div>
</div>
</div>
</div>
</div>
</div>
<SuccessAlert
visible={showSuccessAlert}
content="选得好!不管是做工匠还是做买卖,学好本事就能报效国家,这个选择会影响你在中华职业学校的专业哦"
onContinue={handleSuccessAlertContinue}
onClose={handleSuccessAlertContinue}
/>
</div>
</motion.div>
)}
</AnimatePresence>
);
return createPortal(content, document.body);
return content;
};
export default ChoiceDialog2;
+117 -7
View File
@@ -1,8 +1,11 @@
import { useState } from "react";
import { Title, Image, Cursor } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useState, useRef } from "react";
import { Title, Image, Cursor, StudyGuideModal, Video } from "@/components";
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
import { SuccessAlert, ErrorAlert } from "@/components/layout";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import ViseAssemblyDialog from "./scene3/ViseAssemblyDialog";
import type { VideoRef } from "@/components/video";
const starName = "huangyanpei";
@@ -12,15 +15,22 @@ const HuangyanpeiReflectionScene3 = () => {
moduleName: string;
}>();
const [showGuide1, setShowGuide1] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [showQuiz, setShowQuiz] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [showErrorAlert, setShowErrorAlert] = useState(false);
const [showCompleteGuide, setShowCompleteGuide] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const [visited, setVisited] = useState<Set<number>>(new Set());
const videoRef = useRef<VideoRef>(null);
if (!moduleName) {
return null;
}
const progressCount = visited.size;
const total = 1;
const total = 2;
const completed = progressCount >= total;
const subtitle = completed
? "探索中华职业学院·立业之本·铁工科(已完成)"
@@ -50,22 +60,55 @@ const HuangyanpeiReflectionScene3 = () => {
src={getStaticImage("components/离开")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/home`);
navigate(`/${moduleName}/${starName}/reflection2`);
}}
/>
{/* 第一段引导 */}
<StudyGuideModal
visible={showGuide1}
onContinue={() => {
setShowGuide1(false);
}}
>
终于到中华职业学校啦!这里是中国近代职教的摇篮,"免学费、包分配",还能学真手艺,快来练就一技之长。
{"\n"}
你选了学技术,那就来挑战制作中华职校的"明星产品"——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢
</StudyGuideModal>
{/* 互动展品光标:组装台钳互动 */}
<Cursor
className="absolute right-100 bottom-55"
onClick={() => setShowDialog(true)}
onClick={() => {
if (!showGuide1) {
if (visited.has(2)) {
setShowDialog(true);
} else {
setShowErrorAlert(true);
}
}
}}
read={visited.has(1)}
/>
{/* 答题光标 */}
<Cursor
className="absolute left-145 top-68"
onClick={() => {
if (!showGuide1) {
setShowQuiz(true);
}
}}
read={visited.has(2)}
/>
{/* 组装台钳互动弹窗 */}
<ViseAssemblyDialog
visible={showDialog}
onClose={() => setShowDialog(false)}
onComplete={() => {
setShowDialog(false);
setShowCompleteGuide(true);
setVisited((prev) => {
const next = new Set(prev);
next.add(1);
@@ -74,13 +117,80 @@ const HuangyanpeiReflectionScene3 = () => {
}}
/>
{/* 多选题答题框 */}
<MultipleChoiceQuizPanel
visible={showQuiz}
title="题目1 (多选题) 制作台钳需要哪些工具?(多选)"
options={[
{ value: "A", label: "A. 锯子" },
{ value: "B", label: "B. 毛笔" },
{ value: "C", label: "C. 锉刀" },
{ value: "D", label: "D. 铁锤" },
]}
correctAnswer={["A", "C", "D"]}
errorMessage="毛笔是写字的哦! 台钳是铁制品,得用锯子、锉刀、铁锤这些工具,再选一次。"
minSelections={1}
onSubmit={(values, isCorrect) => {
if (isCorrect) {
setShowQuiz(false);
setShowSuccessAlert(true);
}
}}
zIndex={10001}
/>
{/* 成功提示弹窗 */}
<SuccessAlert
visible={showSuccessAlert}
title="满分!"
content='黄先生说"手脑并用",懂了理论,也要动手实践。'
onContinue={() => {
setShowSuccessAlert(false);
setVisited((prev) => new Set(prev).add(2));
}}
onClose={() => {
setShowSuccessAlert(false);
}}
/>
{/* 错误提示:请先去学习制作台钳 */}
<ErrorAlert
visible={showErrorAlert}
text="请先去学习台钳制作知识吧!"
onClose={() => setShowErrorAlert(false)}
/>
{/* 组装台钳完成提示 */}
<StudyGuideModal
visible={showCompleteGuide}
onContinue={() => setShowCompleteGuide(false)}
verticalAlign="center"
>
太厉害啦!你的台钳制作成功,和当年拿金奖的同款哦。
</StudyGuideModal>
{completed && (
<Image
className="absolute bottom-0 right-4"
src={getStaticImage("components/place/继续前进")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/reflection4`);
setShowVideo(true);
}}
/>
)}
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath("video/huangyanpei/huangyanpei_09.mp4")}
className="h-full w-full"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo(false);
navigate(`/${moduleName}/${starName}/home`);
}}
/>
)}
@@ -59,36 +59,10 @@ const ViseAssemblyDialog = ({
if (visible) {
setValue([]);
completedRef.current = false;
setVideoVisible(false);
}
}, [visible]);
// 检查是否完成
useEffect(() => {
if (value.length === 3 && !completedRef.current) {
const slot0 = value.find((r) => r.blankId === "slot0");
const slot1 = value.find((r) => r.blankId === "slot1");
const slot2 = value.find((r) => r.blankId === "slot2");
// 验证:A->slot0, B->slot1, C->slot2
const isCorrect =
slot0?.optionValue === "A" &&
slot1?.optionValue === "B" &&
slot2?.optionValue === "C";
if (isCorrect) {
completedRef.current = true;
// 答题成功,播放视频
setVideoVisible(true);
} else {
// 答题失败,清除错误的填充
setTimeout(() => {
setValue([]);
}, 1500);
alert("组装顺序不正确,请重新尝试!");
}
}
}, [value]);
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation();
@@ -134,6 +108,35 @@ const ViseAssemblyDialog = ({
setValue(filteredValue);
}, []);
// 处理"拼接台钳"按钮点击
const handleAssemble = useCallback(() => {
if (value.length < 3) {
return;
}
const slot0 = value.find((r) => r.blankId === "slot0");
const slot1 = value.find((r) => r.blankId === "slot1");
const slot2 = value.find((r) => r.blankId === "slot2");
// 验证:A->slot0, B->slot1, C->slot2
const isCorrect =
slot0?.optionValue === "A" &&
slot1?.optionValue === "B" &&
slot2?.optionValue === "C";
if (isCorrect) {
completedRef.current = true;
// 答题成功,播放视频
setVideoVisible(true);
} else {
// 答题失败,清除错误的填充
setTimeout(() => {
setValue([]);
}, 1500);
alert("组装顺序不正确,请重新尝试!");
}
}, [value]);
const handleVideoEnd = useCallback(() => {
setVideoVisible(false);
onComplete?.();
@@ -296,6 +299,7 @@ const ViseAssemblyDialog = ({
{/* 中间的"拼接台钳"按钮 */}
<button
onClick={handleAssemble}
disabled={value.length < 3}
className="mt-8 px-20 py-3 bg-yellow-400 border-8 border-white rounded-xl text-[#432800] text-5xl font-bold cursor-pointer transition-all duration-200 hover:bg-yellow-500 hover:scale-105 active:scale-95 disabled:bg-white/50 disabled:cursor-not-allowed"
>
+35 -4
View File
@@ -1,10 +1,11 @@
import { useState, useCallback } from "react";
import { Title, Image, Cursor } from "@/components";
import { useState, useCallback, useRef } from "react";
import { Title, Image, Cursor, Video, StudyGuideModal } from "@/components";
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
import { SuccessAlert } from "@/components/layout";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import TowelSalesDialog from "./scene4/TowelSalesDialog";
import type { VideoRef } from "@/components/video";
const starName = "huangyanpei";
@@ -19,6 +20,9 @@ const HuangyanpeiReflectionScene4 = () => {
const [showQuiz1, setShowQuiz1] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [showTowelDialog, setShowTowelDialog] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const videoRef = useRef<VideoRef>(null);
const [showGuide1, setShowGuide1] = useState(true);
// 处理成功提示的继续和关闭
const handleSuccessAlertContinue = useCallback(() => {
@@ -76,7 +80,7 @@ const HuangyanpeiReflectionScene4 = () => {
src={getStaticImage("components/离开")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/home`);
navigate(`/${moduleName}/${starName}/reflection2`);
}}
/>
@@ -160,6 +164,33 @@ const HuangyanpeiReflectionScene4 = () => {
src={getStaticImage("components/place/继续前进")}
button
onClick={() => {
setShowVideo(true);
}}
/>
)}
{/* 第一段引导 */}
<StudyGuideModal
visible={showGuide1}
onContinue={() => {
setShowGuide1(false);
}}
>
终于到中华职业学校啦!这里是中国近代职教的摇篮,“免学费、包分配”,还能学真手艺,快来练就一技之长。
{"\n"}
现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢
</StudyGuideModal>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath("video/huangyanpei/huangyanpei_09.mp4")}
className="h-full w-full"
autoPlay
showSkipButton
playsInline
onEnded={() => {
setShowVideo(false);
navigate(`/${moduleName}/${starName}/home`);
}}
/>
@@ -1,10 +1,9 @@
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { cn, getStaticImage } from "@/lib/utils";
import { Image } from "@/components";
import { Image, DragFill } from "@/components";
import type { DragFillResult } from "@/components";
import { useScale } from "@/contexts/ScaleContext";
import { ErrorAlert, SuccessAlert } from "@/components/layout";
export interface TowelSalesDialogProps {
/** 是否可见 */
@@ -40,9 +39,10 @@ const TowelSalesDialog = ({
const towel2Lit = getStaticImage("huangyanpei/reflection/scene4/12_003_02");
const towel3Lit = getStaticImage("huangyanpei/reflection/scene4/12_004_03");
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
const [mounted, setMounted] = useState(false);
const [value, setValue] = useState<DragFillResult[]>([]);
const [errorVisible, setErrorVisible] = useState(false);
const [successVisible, setSuccessVisible] = useState(false);
const completedRef = useRef(false);
// SSR 兼容
@@ -55,6 +55,8 @@ const TowelSalesDialog = ({
if (visible) {
setValue([]);
completedRef.current = false;
setErrorVisible(false);
setSuccessVisible(false);
}
}, [visible]);
@@ -62,13 +64,10 @@ const TowelSalesDialog = ({
useEffect(() => {
if (value.length === 3 && !completedRef.current) {
completedRef.current = true;
// 延迟一点时间让用户看到效果,然后完成
setTimeout(() => {
onComplete?.();
onClose?.();
}, 500);
// 显示成功提示
setSuccessVisible(true);
}
}, [value, onComplete, onClose]);
}, [value]);
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
@@ -78,6 +77,19 @@ const TowelSalesDialog = ({
[onClose]
);
// 关闭错误提示
const handleErrorClose = useCallback(() => {
setErrorVisible(false);
}, []);
// 关闭成功提示
const handleSuccessContinue = useCallback(() => {
setSuccessVisible(false);
// 关闭提示后,完成并关闭弹窗
onComplete?.();
onClose?.();
}, [onComplete, onClose]);
// 可选话术选项
const options = useMemo(
() => [
@@ -101,11 +113,36 @@ const TowelSalesDialog = ({
[]
);
// 转换为 DragFill 的 blanks
const blanks = useMemo(
() => [
{ id: "towel1", label: "毛巾1" },
{ id: "towel2", label: "毛巾2" },
{ id: "towel3", label: "毛巾3" },
],
[]
);
// 处理拖拽结果变化
const handleValueChange = useCallback((newValue: DragFillResult[]) => {
// 限制最多3个
if (newValue.length <= 3) {
setValue(newValue);
// 检查是否选择了错误的选项(option4)
const hasOption4 = newValue.some(
(result) => result.optionValue === "option4"
);
if (hasOption4) {
// 显示错误提示
setErrorVisible(true);
// 移除 option4,保留其他选项
const filteredValue = newValue.filter(
(result) => result.optionValue !== "option4"
);
setValue(filteredValue);
} else {
// 限制最多3个
if (newValue.length <= 3) {
setValue(newValue);
}
}
}, []);
@@ -117,9 +154,6 @@ const TowelSalesDialog = ({
[value]
);
// 拖拽悬停状态
const [hoveredTowel, setHoveredTowel] = useState<string | null>(null);
// 样式
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
@@ -131,20 +165,6 @@ const TowelSalesDialog = ({
[]
);
const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({
position: "absolute",
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`,
top: `${offsetY}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
overflow: "hidden",
}),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
);
if (!mounted) return null;
const content = (
@@ -158,229 +178,159 @@ const TowelSalesDialog = ({
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div style={scaledContainerStyle}>
<div
className={cn("absolute inset-0 flex flex-col", "bg-black/70")}
style={{ pointerEvents: "auto" }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
<div
className={cn("absolute inset-0 flex flex-col", "bg-black/70")}
style={{ pointerEvents: "auto" }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex items-center justify-center px-30 py-16 pt-30 text-[#432800]">
<div className="w-full h-full">
{/* 左侧:题目和可选话术 */}
<div className="flex justify-between gap-10">
{/* 题目文本 */}
<div className="mb-8">
<div className="text-5xl font-bold mb-6">题目文本</div>
<div className="text-4xl leading-relaxed">
从以下话术里,挑选3句组成你的推销语 (拖拽至对话框)
</div>
</div>
{/* 右侧:毛巾区域 */}
<div className="w-180 flex flex-col items-center">
{/* 答题区标签 */}
{/* <div className="text-4xl font-bold text-white mb-8">
答题区
</div> */}
{/* 毛巾堆叠区域 */}
<div className="relative w-full flex flex-col ">
{/* 毛巾1 */}
<div
className={cn(
"relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
// isTowelLit("towel1")
// ? "opacity-100 border-[#f0be75]"
// : hoveredTowel === "towel1"
// ? "opacity-100 border-[#f0be75] bg-[#f0be75]/20"
// : "opacity-100 border-transparent"
)}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!isTowelLit("towel1")) {
setHoveredTowel("towel1");
}
}}
onDragLeave={() => {
setHoveredTowel(null);
}}
onDrop={(e) => {
e.preventDefault();
setHoveredTowel(null);
const optionValue =
e.dataTransfer.getData("text/plain");
if (optionValue && !isTowelLit("towel1")) {
// 检查是否已经使用过这个选项
const alreadyUsed = value.some(
(r) => r.optionValue === optionValue
);
if (!alreadyUsed) {
handleValueChange([
...value,
{ blankId: "towel1", optionValue },
]);
}
}
}}
>
<img
src={isTowelLit("towel1") ? towel1Lit : towel1}
alt="毛巾1"
className="w-full h-full object-contain"
/>
</div>
{/* 毛巾2 */}
<div
className={cn(
"relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
)}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!isTowelLit("towel2")) {
setHoveredTowel("towel2");
}
}}
onDragLeave={() => {
setHoveredTowel(null);
}}
onDrop={(e) => {
e.preventDefault();
setHoveredTowel(null);
const optionValue =
e.dataTransfer.getData("text/plain");
if (optionValue && !isTowelLit("towel2")) {
// 检查是否已经使用过这个选项
const alreadyUsed = value.some(
(r) => r.optionValue === optionValue
);
if (!alreadyUsed) {
handleValueChange([
...value,
{ blankId: "towel2", optionValue },
]);
}
}
}}
>
<img
src={isTowelLit("towel2") ? towel2Lit : towel2}
alt="毛巾2"
className="w-full h-full object-contain"
/>
</div>
{/* 毛巾3 */}
<div
className={cn(
"relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
)}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!isTowelLit("towel3")) {
setHoveredTowel("towel3");
}
}}
onDragLeave={() => {
setHoveredTowel(null);
}}
onDrop={(e) => {
e.preventDefault();
setHoveredTowel(null);
const optionValue =
e.dataTransfer.getData("text/plain");
if (optionValue && !isTowelLit("towel3")) {
// 检查是否已经使用过这个选项
const alreadyUsed = value.some(
(r) => r.optionValue === optionValue
);
if (!alreadyUsed) {
handleValueChange([
...value,
{ blankId: "towel3", optionValue },
]);
}
}
}}
>
<img
src={isTowelLit("towel3") ? towel3Lit : towel3}
alt="毛巾3"
className="w-full h-full object-contain"
/>
</div>
</div>
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex flex-col px-30 py-16 pt-30 text-[#432800]">
<div className="w-full h-full flex flex-col">
{/* 顶部:题目文本和毛巾区域 */}
<div className="flex justify-between gap-10 mb-8">
{/* 题目文本 */}
<div className="flex-1">
<div className="text-5xl font-bold mb-6">题目文本</div>
<div className="text-4xl leading-relaxed">
从以下话术里,挑选3句组成你的推销语 (拖拽至对话框)
</div>
</div>
{/* 可选话术区域 */}
<div className="flex-1">
<div className="text-5xl font-bold mb-6">答题区</div>
<div className="grid grid-cols-2 gap-y-8 gap-x-26 mt-12">
{options.map((option, index) => {
const isUsed = value.some(
(r) => r.optionValue === option.value
);
return (
<div
key={option.value}
className={cn(
" px-10 py-10 rounded-2xl border-3 text-3xl font-medium transition-all",
isUsed
? "border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50"
: "border-[#79471f] bg-black/20 to-[#3a2f25] text-white cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)]"
)}
draggable={!isUsed}
onDragStart={(e) => {
if (isUsed) {
e.preventDefault();
return;
}
e.dataTransfer.setData(
"text/plain",
option.value
);
}}
>
{index + 1}. {option.label}
</div>
);
})}
{/* 右侧:毛巾区域 */}
<div className="w-180 flex flex-col items-center">
{/* 毛巾堆叠区域 */}
<div className="relative w-full flex flex-col">
{/* 毛巾1 */}
<div
data-blank-id="towel1"
className="relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
>
<img
src={isTowelLit("towel1") ? towel1Lit : towel1}
draggable={false}
alt="毛巾1"
className="w-full h-full object-contain"
/>
</div>
{/* 毛巾2 */}
<div
data-blank-id="towel2"
className="relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
>
<img
src={isTowelLit("towel2") ? towel2Lit : towel2}
draggable={false}
alt="毛巾2"
className="w-full h-full object-contain"
/>
</div>
{/* 毛巾3 */}
<div
data-blank-id="towel3"
className="relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
>
<img
src={isTowelLit("towel3") ? towel3Lit : towel3}
draggable={false}
alt="毛巾3"
className="w-full h-full object-contain"
/>
</div>
</div>
</div>
</div>
{/* 底部:可选话术区域 - 使用 DragFill */}
<div className="flex-1">
<div className="text-5xl font-bold mb-6">答题区</div>
<DragFill
options={options}
blanks={blanks}
value={value}
onChange={handleValueChange}
allowDuplicate={false}
layout="horizontal-options"
optionClassName="grid grid-cols-2 gap-10"
hideLabels={true}
showSubmitButton={false}
className="w-full"
renderOption={(option, isUsed) => {
const index = options.findIndex(
(opt) => opt.value === option.value
);
return (
<div
className={cn(
"px-10 py-10 rounded-2xl border-3 text-3xl font-medium transition-all select-none",
isUsed
? "border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50"
: "border-[#79471f] bg-black/20 text-white cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)]"
)}
>
{index + 1}. {option.label}
</div>
);
}}
renderDragging={(option) => {
return (
<div className="w-[638px] h-[90px] flex items-center justify-center rounded-2xl border-3 text-2xl font-medium border-[#f0be75] bg-black/40 text-white shadow-[0_12px_40px_rgba(240,190,117,0.7)]">
{option.label}
</div>
);
}}
renderBlank={() => {
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
return <div className="hidden" />;
}}
/>
</div>
</div>
</div>
{/* 错误提示 */}
<ErrorAlert
text="哎呀!经商要讲诚信,这句话不符合黄先生倡导的商科精神。"
visible={errorVisible}
onClose={handleErrorClose}
onMaskClick={handleErrorClose}
zIndex={10001}
/>
{/* 成功提示 */}
<SuccessAlert
title="恭喜你, 推销语设计成功!"
content="既突出了国货优势,又传递了爱国情怀,当年的商科学生也是这么做的"
visible={successVisible}
onContinue={handleSuccessContinue}
/>
</div>
</motion.div>
)}
</AnimatePresence>
);
return createPortal(content, document.body);
return content;
};
export default TowelSalesDialog;