From bf4a98bf9f7ec0ba644efcb0f78509e193ebdc1d Mon Sep 17 00:00:00 2001 From: zhongyingmin Date: Wed, 31 Dec 2025 17:48:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E9=BB=84=E7=82=8E?= =?UTF-8?q?=E5=9F=B9=E4=BD=93=E6=82=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reflection/scene3/{11_007.png => c.png} | Bin src/pages/chenyun/place/place2.tsx | 4 +- src/pages/huangyanpei/reflection/scene3.tsx | 39 +- .../reflection/scene3/ViseAssemblyDialog.tsx | 387 ++++++++++++++++++ src/pages/huangyanpei/reflection/scene4.tsx | 121 ++++-- .../reflection/scene4/TowelSalesDialog.tsx | 386 +++++++++++++++++ 6 files changed, 888 insertions(+), 49 deletions(-) rename src/assets/images/huangyanpei/reflection/scene3/{11_007.png => c.png} (100%) create mode 100644 src/pages/huangyanpei/reflection/scene3/ViseAssemblyDialog.tsx create mode 100644 src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx diff --git a/src/assets/images/huangyanpei/reflection/scene3/11_007.png b/src/assets/images/huangyanpei/reflection/scene3/c.png similarity index 100% rename from src/assets/images/huangyanpei/reflection/scene3/11_007.png rename to src/assets/images/huangyanpei/reflection/scene3/c.png diff --git a/src/pages/chenyun/place/place2.tsx b/src/pages/chenyun/place/place2.tsx index d5c4f12..c1fbc7e 100644 --- a/src/pages/chenyun/place/place2.tsx +++ b/src/pages/chenyun/place/place2.tsx @@ -21,8 +21,8 @@ const exhibitSrcMap: Record<1 | 2, string> = { // 展品描述映射 const exhibitDescriptionMap: Record<1 | 2, string> = { - 1: "同学你看,这里有一幅陈云提写的颜安小学。陈云在青浦县乙种商业学校学习了一个多月后,又因经济困难不得不辍学回家。多亏当地知识分子、颜安小学校长杜枢,他免费保荐陈云入颜安小学高小部读书。颜安小学实行的新式教育开阔了陈云的视野,五四运动后,颜安小学师生积极投入反帝爱国运动,为陈云日后加入中国共产党埋下了思想的种子。", - 2: "", + 1: "", + 2: "同学你看,这里有一幅陈云提写的颜安小学。陈云在青浦县乙种商业学校学习了一个多月后,又因经济困难不得不辍学回家。多亏当地知识分子、颜安小学校长杜枢,他免费保荐陈云入颜安小学高小部读书。颜安小学实行的新式教育开阔了陈云的视野,五四运动后,颜安小学师生积极投入反帝爱国运动,为陈云日后加入中国共产党埋下了思想的种子。", }; const ChenyunPlace2 = () => { const navigate = useNavigate(); diff --git a/src/pages/huangyanpei/reflection/scene3.tsx b/src/pages/huangyanpei/reflection/scene3.tsx index fe731f2..71c9817 100644 --- a/src/pages/huangyanpei/reflection/scene3.tsx +++ b/src/pages/huangyanpei/reflection/scene3.tsx @@ -1,22 +1,18 @@ import { useState } from "react"; -import { Title, Image, Cursor, ExhibitOverlay } from "@/components"; +import { Title, Image, Cursor } from "@/components"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; +import ViseAssemblyDialog from "./scene3/ViseAssemblyDialog"; const starName = "huangyanpei"; -const exhibitSrcMap: Record<1, string> = { - // 预留展品图片路径:请在 images 中放置对应资源 - 1: "huangyanpei/reflection/scene3/11_002", -}; - const HuangyanpeiReflectionScene3 = () => { const navigate = useNavigate(); const { moduleName } = useParams<{ moduleName: string; }>(); - const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null); + const [showDialog, setShowDialog] = useState(false); const [visited, setVisited] = useState>(new Set()); if (!moduleName) { @@ -58,26 +54,23 @@ const HuangyanpeiReflectionScene3 = () => { }} /> - {/* 互动展品光标:后续可替换为拖拽/选择类互动 */} + {/* 互动展品光标:组装台钳互动 */} setActiveExhibitId(1)} + className="absolute right-100 bottom-55" + onClick={() => setShowDialog(true)} read={visited.has(1)} /> - {/* 展品遮罩:当前使用单张图片,占位为后续互动内容 */} - { - if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeExhibitId); - return next; - }); - } - setActiveExhibitId(null); + {/* 组装台钳互动弹窗 */} + setShowDialog(false)} + onComplete={() => { + setVisited((prev) => { + const next = new Set(prev); + next.add(1); + return next; + }); }} /> diff --git a/src/pages/huangyanpei/reflection/scene3/ViseAssemblyDialog.tsx b/src/pages/huangyanpei/reflection/scene3/ViseAssemblyDialog.tsx new file mode 100644 index 0000000..c930a93 --- /dev/null +++ b/src/pages/huangyanpei/reflection/scene3/ViseAssemblyDialog.tsx @@ -0,0 +1,387 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { createPortal } from "react-dom"; +import { AnimatePresence, motion } from "framer-motion"; +import { cn, getStaticImage, getPublicPath } from "@/lib/utils"; +import { Image, Video, DragFill } from "@/components"; +import type { DragFillResult } from "@/components"; +import { useScale } from "@/contexts/ScaleContext"; +import type { VideoRef } from "@/components/video"; + +export interface ViseAssemblyDialogProps { + /** 是否可见 */ + visible?: boolean; + /** 关闭回调 */ + onClose?: () => void; + /** 完成回调 */ + onComplete?: () => void; +} + +type PartType = "A" | "B" | "C"; + +/** + * 组装台钳互动弹窗 + * - 顶部三个位置用于放置零件 + * - 底部三个零件(A、B、C)可以选择 + * - 答题成功后播放视频 + */ +const ViseAssemblyDialog = ({ + visible = true, + onClose, + onComplete, +}: ViseAssemblyDialogProps) => { + const maskSrc = getStaticImage("components/蒙版遮罩"); + const backSrc = getStaticImage("components/关闭"); + + // 零件背景提示素材 + const partHintA = getStaticImage("huangyanpei/reflection/scene3/11_005"); + const partHintB = getStaticImage("huangyanpei/reflection/scene3/11_006"); + const partHintC = getStaticImage("huangyanpei/reflection/scene3/c"); + + // 零件素材 + const partA = getStaticImage("huangyanpei/reflection/scene3/11_010"); + const partB = getStaticImage("huangyanpei/reflection/scene3/11_011"); + const partC = getStaticImage("huangyanpei/reflection/scene3/11_012"); + + const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale(); + const [mounted, setMounted] = useState(false); + const [value, setValue] = useState([]); + const [videoVisible, setVideoVisible] = useState(false); + const videoRef = useRef(null); + const completedRef = useRef(false); + + // SSR 兼容 + useEffect(() => { + setMounted(true); + }, []); + + // 重置游戏状态 + useEffect(() => { + if (visible) { + setValue([]); + completedRef.current = 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) => { + e.stopPropagation(); + if (!videoVisible) { + onClose?.(); + } + }, + [onClose, videoVisible] + ); + + // 转换为 DragFill 的 options + const options = useMemo( + () => [ + { value: "A", label: "A" }, + { value: "B", label: "B" }, + { value: "C", label: "C" }, + ], + [] + ); + + // 转换为 DragFill 的 blanks + const blanks = useMemo( + () => [ + { id: "slot0", label: "位置1" }, + { id: "slot1", label: "位置2" }, + { id: "slot2", label: "位置3" }, + ], + [] + ); + + // 处理拖拽结果变化 + const handleValueChange = useCallback((newValue: DragFillResult[]) => { + // 验证:只允许 A->slot0, B->slot1, C->slot2 + const filteredValue = newValue.filter((result) => { + const expectedSlot = + result.optionValue === "A" + ? "slot0" + : result.optionValue === "B" + ? "slot1" + : "slot2"; + return result.blankId === expectedSlot; + }); + setValue(filteredValue); + }, []); + + const handleVideoEnd = useCallback(() => { + setVideoVisible(false); + onComplete?.(); + onClose?.(); + }, [onComplete, onClose]); + + // 样式 + const overlayStyle = useMemo( + () => ({ + position: "fixed", + inset: 0, + zIndex: 9999, + pointerEvents: "none", + }), + [] + ); + + const scaledContainerStyle = useMemo( + () => ({ + 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 = ( + + {visible && ( + +
+
+ {/* 背景蒙版 */} + {maskSrc && ( + + )} + + {/* 返回按钮 */} + {onClose && !videoVisible && ( + 返回 + )} + + {/* 视频播放层 */} + {videoVisible ? ( +
+
+ ) : ( + /* 内容区域 */ +
+ {/* 顶部三个位置 - 更大的框 */} +
+ {[0, 1, 2].map((index) => { + const blankId = `slot${index}`; + const filled = value.find((r) => r.blankId === blankId); + const partType = filled + ? (filled.optionValue as PartType) + : null; + const hasPart = partType !== null; + + return ( +
+ {/* 背景提示 */} + {!hasPart && index === 0 && ( + 位置A提示 + )} + {!hasPart && index === 1 && ( + 位置B提示 + )} + {!hasPart && index === 2 && ( + 位置C提示 + )} + {/* 已放置的零件 */} + {partType === "A" && ( + 零件A + )} + {partType === "B" && ( + 零件B + )} + {partType === "C" && ( + 零件C + )} +
+ ); + })} +
+ + {/* 中间的"拼接台钳"按钮 */} + + + {/* 底部零件选择区域 - 使用 DragFill */} +
+
+ 零件区 +
+
+ { + const partSrc = + option.value === "A" + ? partA + : option.value === "B" + ? partB + : partC; + return ( +
+
+ {option.value} +
+ {`零件${option.value}`} +
+ ); + }} + renderDragging={(option) => { + const partSrc = + option.value === "A" + ? partA + : option.value === "B" + ? partB + : partC; + return ( +
+ {`零件${option.value}`} +
+ ); + }} + renderBlank={() => { + // 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位 + return
; + }} + /> +
+
+
+ )} +
+
+ + )} + + ); + + return createPortal(content, document.body); +}; + +export default ViseAssemblyDialog; diff --git a/src/pages/huangyanpei/reflection/scene4.tsx b/src/pages/huangyanpei/reflection/scene4.tsx index ddeccee..3062243 100644 --- a/src/pages/huangyanpei/reflection/scene4.tsx +++ b/src/pages/huangyanpei/reflection/scene4.tsx @@ -1,30 +1,52 @@ -import { useState } from "react"; -import { Title, Image, Cursor, ExhibitOverlay } from "@/components"; +import { useState, useCallback } from "react"; +import { Title, Image, Cursor } from "@/components"; +import { MultipleChoiceQuizPanel } from "@/components/layout/quiz"; +import { SuccessAlert } from "@/components/layout"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; +import TowelSalesDialog from "./scene4/TowelSalesDialog"; const starName = "huangyanpei"; -const exhibitSrcMap: Record<1, string> = { - // 预留展品图片路径:请在 images 中放置对应资源 - 1: "huangyanpei/reflection/scene4/12_002", -}; - const HuangyanpeiReflectionScene4 = () => { const navigate = useNavigate(); const { moduleName } = useParams<{ moduleName: string; }>(); - const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null); + const [_activeExhibitId, _setActiveExhibitId] = useState<1 | 2 | null>(null); const [visited, setVisited] = useState>(new Set()); + const [showQuiz1, setShowQuiz1] = useState(false); + const [showSuccessAlert, setShowSuccessAlert] = useState(false); + const [showTowelDialog, setShowTowelDialog] = useState(false); + + // 处理成功提示的继续和关闭 + const handleSuccessAlertContinue = useCallback(() => { + setShowSuccessAlert(false); + // 更新visited状态 + setVisited((prev) => { + const next = new Set(prev); + next.add(1); + return next; + }); + }, []); + + const handleSuccessAlertClose = useCallback(() => { + setShowSuccessAlert(false); + // 更新visited状态 + setVisited((prev) => { + const next = new Set(prev); + next.add(1); + return next; + }); + }, []); if (!moduleName) { return null; } const progressCount = visited.size; - const total = 1; + const total = 2; const completed = progressCount >= total; const subtitle = completed ? "探索中华职业学院·立业之本·商科(已完成)" @@ -58,26 +80,77 @@ const HuangyanpeiReflectionScene4 = () => { }} /> - {/* 互动展品光标:后续可替换为拖拽/选择类互动 */} + {/* 互动展品光标1 */} setActiveExhibitId(1)} + className="absolute left-100 bottom-88" + onClick={() => setShowQuiz1(true)} read={visited.has(1)} /> - {/* 展品遮罩:当前使用单张图片,占位为后续互动内容 */} - { - if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeExhibitId); - return next; - }); + {/* 互动展品光标2 */} + setShowTowelDialog(true)} + read={visited.has(2)} + /> + + {/* 选择题1:中华职校商科的核心教学目标 */} + { + if (isCorrect) { + // 答题成功:关闭选择题,显示成功提示 + setShowQuiz1(false); + setShowSuccessAlert(true); } - setActiveExhibitId(null); + }} + onError={(_error: string) => { + // 答题错误时的处理(组件内部已显示错误信息) + }} + /> + + {/* 答题成功提示 */} + + + {/* 毛巾推销语拖拽对话框 */} + setShowTowelDialog(false)} + onComplete={() => { + // 完成拖拽后更新visited状态 + setVisited((prev) => { + const next = new Set(prev); + next.add(2); + return next; + }); }} /> diff --git a/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx b/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx new file mode 100644 index 0000000..8eb1562 --- /dev/null +++ b/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx @@ -0,0 +1,386 @@ +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 type { DragFillResult } from "@/components"; +import { useScale } from "@/contexts/ScaleContext"; + +export interface TowelSalesDialogProps { + /** 是否可见 */ + visible?: boolean; + /** 关闭回调 */ + onClose?: () => void; + /** 完成回调 */ + onComplete?: () => void; +} + +/** + * 毛巾推销语拖拽弹窗 + * - 左边显示4个可选话术 + * - 右边显示3个毛巾(堆叠显示) + * - 拖拽话术到毛巾上,点亮对应的毛巾 + * - 3个都点亮后完成 + */ +const TowelSalesDialog = ({ + visible = true, + onClose, + onComplete, +}: TowelSalesDialogProps) => { + const maskSrc = getStaticImage("components/蒙版遮罩"); + const backSrc = getStaticImage("components/关闭"); + + // 毛巾图片资源(未点亮) + const towel1 = getStaticImage("huangyanpei/reflection/scene4/12_002"); + const towel2 = getStaticImage("huangyanpei/reflection/scene4/12_003"); + const towel3 = getStaticImage("huangyanpei/reflection/scene4/12_004"); + + // 毛巾图片资源(点亮) + const towel1Lit = getStaticImage("huangyanpei/reflection/scene4/12_002_02"); + 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([]); + const completedRef = useRef(false); + + // SSR 兼容 + useEffect(() => { + setMounted(true); + }, []); + + // 重置游戏状态 + useEffect(() => { + if (visible) { + setValue([]); + completedRef.current = false; + } + }, [visible]); + + // 检查是否完成(3个毛巾都点亮) + useEffect(() => { + if (value.length === 3 && !completedRef.current) { + completedRef.current = true; + // 延迟一点时间让用户看到效果,然后完成 + setTimeout(() => { + onComplete?.(); + onClose?.(); + }, 500); + } + }, [value, onComplete, onClose]); + + const handleClose = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onClose?.(); + }, + [onClose] + ); + + // 可选话术选项 + const options = useMemo( + () => [ + { + value: "option1", + label: "这是中华职校生产的国货,质量比洋货还好!", + }, + { + value: "option2", + label: "价格便宜,量大从优,诚信经营绝不掺假", + }, + { + value: "option3", + label: "买国货就是支持实业救国,为国家出一份力!", + }, + { + value: "option4", + label: "我只管赚钱,东西好不好不管哦", + }, + ], + [] + ); + + // 处理拖拽结果变化 + const handleValueChange = useCallback((newValue: DragFillResult[]) => { + // 限制最多3个 + if (newValue.length <= 3) { + setValue(newValue); + } + }, []); + + // 检查毛巾是否被点亮 + const isTowelLit = useCallback( + (towelId: string) => { + return value.some((result) => result.blankId === towelId); + }, + [value] + ); + + // 拖拽悬停状态 + const [hoveredTowel, setHoveredTowel] = useState(null); + + // 样式 + const overlayStyle = useMemo( + () => ({ + position: "fixed", + inset: 0, + zIndex: 9999, + pointerEvents: "none", + }), + [] + ); + + const scaledContainerStyle = useMemo( + () => ({ + 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 = ( + + {visible && ( + +
+
+ {/* 背景蒙版 */} + {maskSrc && ( + + )} + + {/* 返回按钮 */} + {onClose && ( + 返回 + )} + + {/* 内容区域 */} +
+
+ {/* 左侧:题目和可选话术 */} +
+ {/* 题目文本 */} +
+
题目文本
+
+ 从以下话术里,挑选3句组成你的推销语 (拖拽至对话框) +
+
+ + {/* 右侧:毛巾区域 */} +
+ {/* 答题区标签 */} + {/*
+ 答题区 +
*/} + + {/* 毛巾堆叠区域 */} +
+ {/* 毛巾1 */} +
{ + 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 }, + ]); + } + } + }} + > + 毛巾1 +
+ + {/* 毛巾2 */} +
{ + 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 }, + ]); + } + } + }} + > + 毛巾2 +
+ + {/* 毛巾3 */} +
{ + 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 }, + ]); + } + } + }} + > + 毛巾3 +
+
+
+
+ + {/* 可选话术区域 */} +
+
答题区
+
+ {options.map((option, index) => { + const isUsed = value.some( + (r) => r.optionValue === option.value + ); + return ( +
{ + if (isUsed) { + e.preventDefault(); + return; + } + e.dataTransfer.setData( + "text/plain", + option.value + ); + }} + > + {index + 1}. {option.label} +
+ ); + })} +
+
+
+
+
+
+
+ )} +
+ ); + + return createPortal(content, document.body); +}; + +export default TowelSalesDialog;