diff --git a/src/assets/images/components/guide/引导框.png b/src/assets/images/components/guide/引导框.png new file mode 100644 index 0000000..f0c78ff Binary files /dev/null and b/src/assets/images/components/guide/引导框.png differ diff --git a/src/assets/images/components/guide/继续.png b/src/assets/images/components/guide/继续.png new file mode 100644 index 0000000..9cb14d0 Binary files /dev/null and b/src/assets/images/components/guide/继续.png differ diff --git a/src/assets/images/components/internalization/bg.png b/src/assets/images/components/internalization/bg.png new file mode 100644 index 0000000..42d8335 Binary files /dev/null and b/src/assets/images/components/internalization/bg.png differ diff --git a/src/assets/images/components/letter/不可提交状态.png b/src/assets/images/components/letter/不可提交状态.png new file mode 100644 index 0000000..9949691 Binary files /dev/null and b/src/assets/images/components/letter/不可提交状态.png differ diff --git a/src/assets/images/components/letter/信底.png b/src/assets/images/components/letter/信底.png new file mode 100644 index 0000000..8e0c666 Binary files /dev/null and b/src/assets/images/components/letter/信底.png differ diff --git a/src/assets/images/components/letter/可提交状态.png b/src/assets/images/components/letter/可提交状态.png new file mode 100644 index 0000000..99867bb Binary files /dev/null and b/src/assets/images/components/letter/可提交状态.png differ diff --git a/src/assets/images/components/letter/寄信图标.png b/src/assets/images/components/letter/寄信图标.png new file mode 100644 index 0000000..93857a6 Binary files /dev/null and b/src/assets/images/components/letter/寄信图标.png differ diff --git a/src/assets/images/components/letter/笔.png b/src/assets/images/components/letter/笔.png new file mode 100644 index 0000000..f2ea261 Binary files /dev/null and b/src/assets/images/components/letter/笔.png differ diff --git a/src/assets/images/huangyanpei/place1/3_003.png b/src/assets/images/huangyanpei/place1/3_003.png index 9566351..04c1060 100644 Binary files a/src/assets/images/huangyanpei/place1/3_003.png and b/src/assets/images/huangyanpei/place1/3_003.png differ diff --git a/src/assets/images/huangyanpei/place1/3_003_.png b/src/assets/images/huangyanpei/place1/3_003_.png new file mode 100644 index 0000000..9566351 Binary files /dev/null and b/src/assets/images/huangyanpei/place1/3_003_.png differ diff --git a/src/assets/images/huangyanpei/place4/6_010.png b/src/assets/images/huangyanpei/place4/6_010.png new file mode 100644 index 0000000..a391e74 Binary files /dev/null and b/src/assets/images/huangyanpei/place4/6_010.png differ diff --git a/src/assets/images/huangyanpei/place5/7_003.png b/src/assets/images/huangyanpei/place5/7_003.png index 065403a..41b924a 100644 Binary files a/src/assets/images/huangyanpei/place5/7_003.png and b/src/assets/images/huangyanpei/place5/7_003.png differ diff --git a/src/assets/images/huangyanpei/reflection/scene1/9_004.png b/src/assets/images/huangyanpei/reflection/scene1/9_004.png index b2eb1e9..70ff64a 100644 Binary files a/src/assets/images/huangyanpei/reflection/scene1/9_004.png and b/src/assets/images/huangyanpei/reflection/scene1/9_004.png differ diff --git a/src/assets/images/huangyanpei/reflection/scene1/9_004_.png b/src/assets/images/huangyanpei/reflection/scene1/9_004_.png new file mode 100644 index 0000000..b2eb1e9 Binary files /dev/null and b/src/assets/images/huangyanpei/reflection/scene1/9_004_.png differ diff --git a/src/assets/images/huangyanpei/reflection/scene1/9_005.png b/src/assets/images/huangyanpei/reflection/scene1/9_005.png new file mode 100644 index 0000000..a69ab1e Binary files /dev/null and b/src/assets/images/huangyanpei/reflection/scene1/9_005.png differ diff --git a/src/assets/images/luxun/place4/展品10.png b/src/assets/images/luxun/place3/10.png similarity index 100% rename from src/assets/images/luxun/place4/展品10.png rename to src/assets/images/luxun/place3/10.png diff --git a/src/assets/images/luxun/place3/展品6.png b/src/assets/images/luxun/place3/6.png similarity index 100% rename from src/assets/images/luxun/place3/展品6.png rename to src/assets/images/luxun/place3/6.png diff --git a/src/assets/images/luxun/place3/展品7.png b/src/assets/images/luxun/place3/7.png similarity index 100% rename from src/assets/images/luxun/place3/展品7.png rename to src/assets/images/luxun/place3/7.png diff --git a/src/assets/images/luxun/place3/8.png b/src/assets/images/luxun/place3/8.png new file mode 100644 index 0000000..ee3aa6d Binary files /dev/null and b/src/assets/images/luxun/place3/8.png differ diff --git a/src/assets/images/luxun/place3/9.png b/src/assets/images/luxun/place3/9.png new file mode 100644 index 0000000..f5135db Binary files /dev/null and b/src/assets/images/luxun/place3/9.png differ diff --git a/src/assets/images/luxun/place4/展品9_t.png b/src/assets/images/luxun/place3/9_1.png similarity index 100% rename from src/assets/images/luxun/place4/展品9_t.png rename to src/assets/images/luxun/place3/9_1.png diff --git a/src/assets/images/luxun/place4/展品9_b.png b/src/assets/images/luxun/place3/9_2.png similarity index 100% rename from src/assets/images/luxun/place4/展品9_b.png rename to src/assets/images/luxun/place3/9_2.png diff --git a/src/assets/images/luxun/place3/前进.png b/src/assets/images/luxun/place3/前进.png deleted file mode 100644 index 3596e1c..0000000 Binary files a/src/assets/images/luxun/place3/前进.png and /dev/null differ diff --git a/src/assets/images/luxun/place3/前进按钮.png b/src/assets/images/luxun/place3/前进按钮.png new file mode 100644 index 0000000..6fd4305 Binary files /dev/null and b/src/assets/images/luxun/place3/前进按钮.png differ diff --git a/src/assets/images/luxun/place3/完整参考.png b/src/assets/images/luxun/place3/完整参考.png deleted file mode 100644 index 9afee0d..0000000 Binary files a/src/assets/images/luxun/place3/完整参考.png and /dev/null differ diff --git a/src/assets/images/luxun/place3/展品8.png b/src/assets/images/luxun/place3/展品8.png deleted file mode 100644 index e2fc048..0000000 Binary files a/src/assets/images/luxun/place3/展品8.png and /dev/null differ diff --git a/src/assets/images/luxun/place3/背景.png b/src/assets/images/luxun/place3/背景.png index 0fe5998..58e3749 100644 Binary files a/src/assets/images/luxun/place3/背景.png and b/src/assets/images/luxun/place3/背景.png differ diff --git a/src/assets/images/luxun/place4/前进.png b/src/assets/images/luxun/place4/前进.png deleted file mode 100644 index f3e5e01..0000000 Binary files a/src/assets/images/luxun/place4/前进.png and /dev/null differ diff --git a/src/assets/images/luxun/place4/完整参考.png b/src/assets/images/luxun/place4/完整参考.png index 6cf6ab7..9a7e4a4 100644 Binary files a/src/assets/images/luxun/place4/完整参考.png and b/src/assets/images/luxun/place4/完整参考.png differ diff --git a/src/assets/images/luxun/place5/展品11.png b/src/assets/images/luxun/place4/展品11.png similarity index 100% rename from src/assets/images/luxun/place5/展品11.png rename to src/assets/images/luxun/place4/展品11.png diff --git a/src/assets/images/luxun/place5/展品12.png b/src/assets/images/luxun/place4/展品12.png similarity index 100% rename from src/assets/images/luxun/place5/展品12.png rename to src/assets/images/luxun/place4/展品12.png diff --git a/src/assets/images/luxun/place5/展品13.png b/src/assets/images/luxun/place4/展品13.png similarity index 100% rename from src/assets/images/luxun/place5/展品13.png rename to src/assets/images/luxun/place4/展品13.png diff --git a/src/assets/images/luxun/place5/离开.png b/src/assets/images/luxun/place4/离开.png similarity index 100% rename from src/assets/images/luxun/place5/离开.png rename to src/assets/images/luxun/place4/离开.png diff --git a/src/assets/images/luxun/place4/背景.png b/src/assets/images/luxun/place4/背景.png index 0fe5998..9bf1405 100644 Binary files a/src/assets/images/luxun/place4/背景.png and b/src/assets/images/luxun/place4/背景.png differ diff --git a/src/assets/images/luxun/place5/完整参考.png b/src/assets/images/luxun/place5/完整参考.png deleted file mode 100644 index 9a7e4a4..0000000 Binary files a/src/assets/images/luxun/place5/完整参考.png and /dev/null differ diff --git a/src/assets/images/luxun/place5/背景.png b/src/assets/images/luxun/place5/背景.png deleted file mode 100644 index 9bf1405..0000000 Binary files a/src/assets/images/luxun/place5/背景.png and /dev/null differ diff --git a/src/components/COMPONENT_GUIDELINES.md b/src/components/COMPONENT_GUIDELINES.md index eeca180..f7676f8 100644 --- a/src/components/COMPONENT_GUIDELINES.md +++ b/src/components/COMPONENT_GUIDELINES.md @@ -154,3 +154,37 @@ export type { ComponentNameProps, ComponentNameRef } from "./component-name"; | `*.hooks.ts` | 组件专用 hooks | 有可复用的状态逻辑 | | `*.utils.ts` | 工具函数 | 有纯函数逻辑需要提取 | | `*.constants.ts` | 常量定义 | 有多个常量需要共享 | + + diff --git a/src/components/exhibit/ExhibitOverlay.tsx b/src/components/exhibit/ExhibitOverlay.tsx index cec388b..c65fcd9 100644 --- a/src/components/exhibit/ExhibitOverlay.tsx +++ b/src/components/exhibit/ExhibitOverlay.tsx @@ -26,15 +26,10 @@ export interface ExhibitOverlayProps { description?: string; /** 是否自动播放描述语音,默认 false */ autoPlayDescription?: boolean; - /** 自定义点击区域配置 */ - clickableAreas?: Array<{ - /** 区域名称 */ - name: string; - /** 相对于图片的位置和大小(百分比) */ - position: { left: number; top: number; width: number; height: number }; - /** 点击回调 */ - onClick: () => void; - }>; + /** 点击展示 */ + onClick?: () => void; + /** 是否禁用拖拽,默认 false。当为 true 时,禁用拖拽和缩放功能,确保 onClick 事件能正常触发 */ + disableDrag?: boolean; } type Point = { x: number; y: number }; @@ -54,7 +49,8 @@ const ExhibitOverlay = ({ className, description, autoPlayDescription = false, - clickableAreas, + onClick, + disableDrag = false, }: ExhibitOverlayProps) => { const maskSrc = getStaticImage("components/蒙版遮罩"); const backSrc = getStaticImage("components/关闭"); @@ -62,6 +58,7 @@ const ExhibitOverlay = ({ // 获取缩放参数,用于视觉对齐 const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale(); const [mounted, setMounted] = useState(false); + const [isDragging, setIsDragging] = useState(false); // SSR 兼容:只在客户端挂载 useEffect(() => { @@ -128,6 +125,7 @@ const ExhibitOverlay = ({ pointers.current.clear(); lastDistance.current = null; dragStartRef.current = null; + setIsDragging(false); // 释放所有指针捕获 if (containerRef.current) { pointers.current.forEach((_, pointerId) => { @@ -244,6 +242,9 @@ const ExhibitOverlay = ({ // 指针按下:记录初始状态 const handlePointerDown = useCallback( (e: React.PointerEvent) => { + // 如果禁用了拖拽,直接返回,不处理拖拽事件 + if (disableDrag) return; + if (!containerRef.current || !viewElRef.current) return; try { containerRef.current.setPointerCapture(e.pointerId); @@ -266,6 +267,7 @@ const ExhibitOverlay = ({ startY: e.clientY, startTransform: { ...transformRef.current }, }; + setIsDragging(true); } else if (pointers.current.size === 2) { // 双指缩放:记录初始距离 const values = Array.from(pointers.current.values()); @@ -278,7 +280,7 @@ const ExhibitOverlay = ({ } } }, - [getDistance] + [getDistance, disableDrag] ); /** @@ -397,6 +399,8 @@ const ExhibitOverlay = ({ } else { dragStartRef.current = null; } + + setIsDragging(pointers.current.size > 0); }, [] ); @@ -492,7 +496,7 @@ const ExhibitOverlay = ({ {/* 展品内容 - 在剩余空间中居中 */}
展品 - {/* 可点击区域 */} - {clickableAreas && - clickableAreas.map((area, index) => ( -
{ - e.stopPropagation(); - area.onClick(); - }} - onPointerDown={(e) => { - // 阻止拖拽事件 - e.stopPropagation(); - }} - /> - ))}
diff --git a/src/components/guide/StudyGuideModal.tsx b/src/components/guide/StudyGuideModal.tsx new file mode 100644 index 0000000..0cdd651 --- /dev/null +++ b/src/components/guide/StudyGuideModal.tsx @@ -0,0 +1,139 @@ +import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { Image } from "@/components"; +import { cn, getStaticImage } from "@/lib/utils"; +import { useScale } from "@/contexts/ScaleContext"; + +export interface StudyGuideModalProps { + /** 是否可见 */ + visible?: boolean; + /** 标题文案,默认:温馨提示 */ + title?: string; + /** 主体内容(支持换行、富文本) */ + children?: ReactNode; + /** 点击继续按钮 */ + onContinue?: () => void; + /** 点击遮罩是否关闭,默认 true */ + maskClosable?: boolean; + /** 自定义 zIndex,默认 120 */ + zIndex?: number; + /** 对齐方式,默认 center */ + ailgn?: "center" | "left" | "right"; + // 垂直对齐方式,默认 center + verticalAlign?: "center" | "top" | "bottom"; +} + +/** + * 通用学习引导弹窗 + * 使用 images/components/guide 下的引导框 + 继续按钮资源 + */ +export const StudyGuideModal = ({ + visible = false, + title: _title = "温馨提示", + children, + onContinue, + maskClosable: _maskClosable = true, + zIndex = 120, + ailgn = "center", + verticalAlign = "top", +}: StudyGuideModalProps) => { + const [mounted, setMounted] = useState(false); + const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale(); + + // SSR 兼容:只在客户端挂载 + useEffect(() => { + setMounted(true); + }, []); + + if (!visible || !mounted) return null; + + const handleMaskClick = () => { + // if (maskClosable) { + // onContinue?.(); + // } + }; + + const content = ( +
+ {/* 背景遮罩 */} +
+ + {/* 引导内容 */} +
+ {/* 引导框背景 */} + 学习引导背景 + + {/* 文本内容 */} +
+ {/* {title && ( +
+ {title} +
+ )} */} + {children && ( +
+ {children} +
+ )} +
+ + {/* 继续按钮 */} +
+ 继续 +
+
+
+ ); + + // 当 zIndex >= 10000 时,使用 Portal 渲染到 document.body,并应用缩放 + if (zIndex >= 10000) { + const scaledContent = ( +
+ {content} +
+ ); + return createPortal(scaledContent, document.body); + } + + return content; +}; diff --git a/src/components/guide/index.ts b/src/components/guide/index.ts index 5fa63ab..3e528ce 100644 --- a/src/components/guide/index.ts +++ b/src/components/guide/index.ts @@ -1 +1,2 @@ export { GuideSteps, GuideStepsItem } from "./GuideSteps"; +export { StudyGuideModal } from "./StudyGuideModal"; diff --git a/src/components/index.ts b/src/components/index.ts index 5276156..dadf930 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -14,7 +14,7 @@ export type { ImageProps } from "./image"; export { Title } from "./title"; // Guide components -export { GuideSteps, GuideStepsItem } from "./guide"; +export { GuideSteps, GuideStepsItem, StudyGuideModal } from "./guide"; // Exhibit overlay export { ExhibitOverlay } from "./exhibit"; @@ -68,10 +68,6 @@ export type { ToastInstance, } from "./toast"; -// Game components -export { BambooPuzzleGame } from "./game"; -export type { BambooPuzzleGameProps } from "./game"; - // Clue components export { ClueHintModal, ClueBookModal } from "./clue"; export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from "./clue"; @@ -79,3 +75,17 @@ export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from "./clue"; // Animation components export { flyToAnimation } from "./animation"; export type { FlyToAnimationOptions, FlyToPosition } from "./animation"; + +// Letter components +export { + LetterToChenyun, + LetterToSunzhongshan, + LetterToHuangyanpei, + LetterToLuxun, +} from "./letter"; +export type { + LetterToChenyunProps, + LetterToSunzhongshanProps, + LetterToHuangyanpeiProps, + LetterToLuxunProps, +} from "./letter"; diff --git a/src/components/layout/alert/error-alert.tsx b/src/components/layout/alert/error-alert.tsx new file mode 100644 index 0000000..33f3753 --- /dev/null +++ b/src/components/layout/alert/error-alert.tsx @@ -0,0 +1,70 @@ +import { getBackgroundImage, getStaticImage } from "@/lib/utils"; + +export interface ErrorAlertProps { + /** 文本 */ + text: string; + /** 是否可见 */ + visible?: boolean; + /** 点击遮罩层回调 */ + onMaskClick?: () => void; + /** 关闭回调 */ + onClose?: () => void; + /** 自定义 zIndex */ + zIndex?: number; +} + +/** + * Alert 组件 - 提示弹窗 + * @returns + */ +const ErrorAlert = ({ + text, + visible = true, + onMaskClick, + onClose, + zIndex = 10000, +}: ErrorAlertProps) => { + if (!visible) return null; + + return ( +
+ {/* 蒙版背景 */} +
+ + {/* 内容 */} +
+ {/* 标题和副标题容器 - 带深色背景条 */} +
+ {/* 副标题 */} +
+ {text} +
+
+ + {/* 继续按钮 */} +
e.stopPropagation()} + >
+
+
+ ); +}; + +export default ErrorAlert; diff --git a/src/components/layout/success-alert/index.tsx b/src/components/layout/alert/success-alert/index.tsx similarity index 100% rename from src/components/layout/success-alert/index.tsx rename to src/components/layout/alert/success-alert/index.tsx diff --git a/src/components/layout/alert/index.tsx b/src/components/layout/alert/title-alert.tsx similarity index 100% rename from src/components/layout/alert/index.tsx rename to src/components/layout/alert/title-alert.tsx diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts index 4402201..e62ee80 100644 --- a/src/components/layout/index.ts +++ b/src/components/layout/index.ts @@ -1,10 +1,12 @@ export { default as ResidenceLayout } from "./ResidenceLayout"; export { default as ClueOverlay } from "./clue"; export type { ClueOverlayProps } from "./clue"; -export { default as Alert } from "./alert"; -export type { AlertProps } from "./alert"; -export { default as SuccessAlert } from "./success-alert"; -export type { SuccessAlertProps } from "./success-alert"; +export { default as Alert } from "./alert/title-alert"; +export type { AlertProps } from "./alert/title-alert"; +export { default as SuccessAlert } from "./alert/success-alert"; +export type { SuccessAlertProps } from "./alert/success-alert"; +export { default as ErrorAlert } from "./alert/error-alert"; +export type { ErrorAlertProps } from "./alert/error-alert"; export { DragQuizPanel } from "./quiz"; export type { DragQuizPanelProps } from "./quiz"; export type { diff --git a/src/components/layout/quiz/DragQuizPanel.tsx b/src/components/layout/quiz/DragQuizPanel.tsx index be35e3e..2ed0842 100644 --- a/src/components/layout/quiz/DragQuizPanel.tsx +++ b/src/components/layout/quiz/DragQuizPanel.tsx @@ -24,7 +24,7 @@ const DragQuizPanel = ({ >
{/* 题干卡片 */} -
+
{title &&
{title}
}
@@ -116,9 +116,7 @@ const DragQuizPanel = ({
{error && ( -
- {error} -
+
{error}
)}
@@ -126,4 +124,3 @@ const DragQuizPanel = ({ }; export default DragQuizPanel; - diff --git a/src/components/letter/LetterToChenyun.tsx b/src/components/letter/LetterToChenyun.tsx new file mode 100644 index 0000000..0d4c77b --- /dev/null +++ b/src/components/letter/LetterToChenyun.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { getStaticImage } from "@/lib/utils"; +import { Image } from "@/components"; + +export interface LetterToChenyunProps { + /** 是否可见 */ + visible?: boolean; + /** 关闭回调 */ + onClose?: () => void; + /** 提交回调 */ + onSubmit?: (values: string[]) => void; +} + +/** + * LetterToChenyun 组件 - 寄给陈云爷爷的信 + * 包含飞入和放大动画 + */ +const LetterToChenyun = ({ + visible = false, + onClose, + onSubmit, +}: LetterToChenyunProps) => { + // 下划线可填写内容 + const [blankValues, setBlankValues] = useState([]); + + // 信件内容模板(根据设计稿) + const letterContent = `敬爱的陈云爷爷: +您好! + +我是____专业的学生。今天,我怀着崇敬又激动的心情,参观了位于上海市青浦区练塘镇的陈云纪念馆,学习了您“不唯上、不唯书、只唯____”的做事准则,还有“综合平衡”“人民至上”的经济思想。我深刻体会到,这些精神不仅是建设年代的智慧结晶,更是新时代指引我们前行的思想灯塔。 + +作为新时代大学生,我会在专业学习中____,像您当年练珠算、搞调查那样精益求精;未来无论从事什么工作,都要把服务群众____放在首位;面对困难时不退缩,像您当年稳定物价、推进实业那样,用____本领(结合所学专业)为国家发展贡献自己的力量! + +站在新时代的起点,我一定以____为志向,勤学苦练,传承精神,让您的务实担当在青春里扎根,让奋斗在建设祖国的实践中绽放光彩!`; + + return ( + + {visible && ( + <> + {/* 遮罩层 */} + + + {/* 信件容器 */} + +
e.stopPropagation()} + > + {/* 信纸背景 */} + 信纸 + + {/* 标题 - 垂直显示在左侧 */} +
+
+ 寄给陈云爷爷的信 +
+
+ + {/* 信件内容区域 */} +
+
+ {(() => { + let placeholderIndex = 0; + return letterContent.split("\n").map((line, lineIndex) => { + const segments = line.split("____"); + return ( +
+ {segments.map((part, partIndex) => { + const isLast = partIndex === segments.length - 1; + if (isLast) { + return ( + + {part} + + ); + } + + const currentIndex = placeholderIndex++; + return ( + + {part} + { + const next = [...blankValues]; + next[currentIndex] = e.target.value; + setBlankValues(next); + }} + /> + + ); + })} +
+ ); + }); + })()} +
+
+ + {/* 笔的图片 */} +
+ 笔 +
+ + {/* 提交按钮 */} +
+ 可提交状态 onSubmit?.(blankValues)} + button + /> +
+ + {/* 寄信图标(可选,如果需要的话) */} +
+ 寄信 +
+
+
+ + )} +
+ ); +}; + +export default LetterToChenyun; diff --git a/src/components/letter/LetterToHuangyanpei.tsx b/src/components/letter/LetterToHuangyanpei.tsx new file mode 100644 index 0000000..61d0436 --- /dev/null +++ b/src/components/letter/LetterToHuangyanpei.tsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { getStaticImage } from "@/lib/utils"; +import { Image } from "@/components"; + +export interface LetterToHuangyanpeiProps { + /** 是否可见 */ + visible?: boolean; + /** 关闭回调 */ + onClose?: () => void; + /** 提交回调 */ + onSubmit?: (values: string[]) => void; +} + +/** + * LetterToHuangyanpei 组件 - 写给黄炎培先生的信 + * 包含飞入和放大动画 + */ +const LetterToHuangyanpei = ({ + visible = false, + onClose, + onSubmit, +}: LetterToHuangyanpeiProps) => { + // 下划线可填写内容 + const [blankValues, setBlankValues] = useState([]); + + // 信件内容模板(根据设计稿) + const letterContent = `敬爱的黄炎培先生: + +您好! + +今天,我循着您的足迹,走进了川沙小学堂的新式课堂,亲历了中华职业学校"手脑并用"的实训时光。那些您为破除旧教育桎梏的奔走、为推广职业教育的坚守、为支援国家危亡的热忱,不再是书本上的文字,而是可感可触的精神力量,深深烙印在我心中。 + +我终于明白,您喊出的"双手万能、____",不是一句口号,而是我们的立身之本;您践行的"教育救国、实业报国",不是一段历史,而是我们这代人接续的使命;您坚守的"因时求变",不是一种选择,而是我们面对未来的底气。 + +我是____专业的学生,今后,我会在实训课堂上精益求精,像当年中华职校的学长学姐那样,把技能练到极致;我会在志愿服务中践行担当,用所学知识为身边人、为社会做实事;我更会紧跟时代步伐,努力成为____领域的技能人才,为中国制造升级、为民族产业振兴添砖加瓦! + +请您放心,您播下的职教火种,我们会让它在新时代燃烧得更旺;您毕生追求的"无业者有业、____"的愿景,我们会用青春和汗水去实现!`; + + return ( + + {visible && ( + <> + {/* 遮罩层 */} + + + {/* 信件容器 */} + +
e.stopPropagation()} + > + {/* 信纸背景 */} + 信纸 + + {/* 标题 - 垂直显示在左侧 */} +
+
+ 写给黄炎培先生的信 +
+
+ + {/* 信件内容区域 */} +
+
+ {(() => { + let placeholderIndex = 0; + return letterContent.split("\n").map((line, lineIndex) => { + const segments = line.split("____"); + return ( +
+ {segments.map((part, partIndex) => { + const isLast = partIndex === segments.length - 1; + if (isLast) { + return ( + + {part} + + ); + } + + const currentIndex = placeholderIndex++; + return ( + + {part} + { + const next = [...blankValues]; + next[currentIndex] = e.target.value; + setBlankValues(next); + }} + /> + + ); + })} +
+ ); + }); + })()} +
+
+ + {/* 笔的图片 */} +
+ 笔 +
+ + {/* 提交按钮 */} +
+ 可提交状态 onSubmit?.(blankValues)} + button + /> +
+ + {/* 寄信图标(可选,如果需要的话) */} +
+ 寄信 +
+
+
+ + )} +
+ ); +}; + +export default LetterToHuangyanpei; diff --git a/src/components/letter/LetterToLuxun.tsx b/src/components/letter/LetterToLuxun.tsx new file mode 100644 index 0000000..0a3e81f --- /dev/null +++ b/src/components/letter/LetterToLuxun.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { getStaticImage } from "@/lib/utils"; +import { Image } from "@/components"; + +export interface LetterToLuxunProps { + /** 是否可见 */ + visible?: boolean; + /** 关闭回调 */ + onClose?: () => void; + /** 提交回调 */ + onSubmit?: (values: string[]) => void; +} + +/** + * LetterToLuxun 组件 - 写给鲁迅先生的信 + * 包含飞入和放大动画 + */ +const LetterToLuxun = ({ + visible = false, + onClose, + onSubmit, +}: LetterToLuxunProps) => { + // 下划线可填写内容 + const [blankValues, setBlankValues] = useState([]); + + // 信件内容模板(根据设计稿) + const letterContent = `敬爱的鲁迅先生: + +您好! + +今天,我怀着崇敬的心情走进大陆新村9号,看着您亲手撰写的《呐喊》《且介亭杂文》《故事新编》等作品,您编辑的《萌芽》《前哨》等杂志,您翻译的《艺术论》《死魂灵》等译文,仿佛能看到您"舌将上下而求索"的身影。在这场跨越时空的对话中,我终于明白您坚守的"____",不是抽象的理念,而是我们安身立命的根本;您秉持的"____",不是无谓的锋芒,而是我们直面现实的勇气。 + +我是____专业的学生,今后,我会以您"____干夫指"的骨气,在是非面前坚守立场,不随波逐流;我会以您"俯首甘为____"的热忱,参与____活动,用微光温暖他人;我更会记住您"____,走的人多了,便成了路"的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。 + +请您放心,您播下的"立人"火种,我们会让它在新时代燎原;您毕生追求的"人国"愿景,我们会用青春和奋斗去守护!`; + + return ( + + {visible && ( + <> + {/* 遮罩层 */} + + + {/* 信件容器 */} + +
e.stopPropagation()} + > + {/* 信纸背景 */} + 信纸 + + {/* 标题 - 垂直显示在左侧 */} +
+
+ 写给鲁迅先生的信 +
+
+ + {/* 信件内容区域 */} +
+
+ {(() => { + let placeholderIndex = 0; + return letterContent.split("\n").map((line, lineIndex) => { + const segments = line.split("____"); + return ( +
+ {segments.map((part, partIndex) => { + const isLast = partIndex === segments.length - 1; + if (isLast) { + return ( + + {part} + + ); + } + + const currentIndex = placeholderIndex++; + return ( + + {part} + { + const next = [...blankValues]; + next[currentIndex] = e.target.value; + setBlankValues(next); + }} + /> + + ); + })} +
+ ); + }); + })()} +
+
+ + {/* 笔的图片 */} +
+ 笔 +
+ + {/* 提交按钮 */} +
+ 可提交状态 onSubmit?.(blankValues)} + button + /> +
+ + {/* 寄信图标(可选,如果需要的话) */} +
+ 寄信 +
+
+
+ + )} +
+ ); +}; + +export default LetterToLuxun; diff --git a/src/components/letter/LetterToSunzhongshan.tsx b/src/components/letter/LetterToSunzhongshan.tsx new file mode 100644 index 0000000..970b836 --- /dev/null +++ b/src/components/letter/LetterToSunzhongshan.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { getStaticImage } from "@/lib/utils"; +import { Image } from "@/components"; + +export interface LetterToSunzhongshanProps { + /** 是否可见 */ + visible?: boolean; + /** 关闭回调 */ + onClose?: () => void; + /** 提交回调 */ + onSubmit?: (values: string[]) => void; +} + +/** + * LetterToSunzhongshan 组件 - 写给孙中山爷爷的信 + * 包含飞入和放大动画 + */ +const LetterToSunzhongshan = ({ + visible = false, + onClose, + onSubmit, +}: LetterToSunzhongshanProps) => { + // 下划线可填写内容 + const [blankValues, setBlankValues] = useState([]); + + // 信件内容模板(根据设计稿) + const letterContent = `敬爱的孙中山先生: + +您好! + +今天,我怀着崇敬的心情走进香山路7号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为"振兴中华"奔走的身影。在李教授的解读中,我终于明白:您喊出的"____",不是遥远的历史,而是我们这代人正在接续的接力;您追求的"____(空里填天下为公)",不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的"____(空里填与时俱进)",不是过去的选择,而是面对未来时必须有的勇气。 + +我是____专业的学生,今后,我会以您"知难行易"的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您"心系民众"的情怀,参与____,帮身边人解决实际困难;我更会记住您"适乎世界之潮流"的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。 + +请您放心,"革命尚未成功,同志仍须努力"的嘱托,我们从未忘记;"振兴中华"的初心,我们会用一生践行!`; + + return ( + + {visible && ( + <> + {/* 遮罩层 */} + + + {/* 信件容器 */} + +
e.stopPropagation()} + > + {/* 信纸背景 */} + 信纸 + + {/* 标题 - 垂直显示在左侧 */} +
+
+ 写给孙中山爷爷的信 +
+
+ + {/* 信件内容区域 */} +
+
+ {(() => { + let placeholderIndex = 0; + return letterContent.split("\n").map((line, lineIndex) => { + const segments = line.split("____"); + return ( +
+ {segments.map((part, partIndex) => { + const isLast = partIndex === segments.length - 1; + if (isLast) { + return ( + + {part} + + ); + } + + const currentIndex = placeholderIndex++; + return ( + + {part} + { + const next = [...blankValues]; + next[currentIndex] = e.target.value; + setBlankValues(next); + }} + /> + + ); + })} +
+ ); + }); + })()} +
+
+ + {/* 笔的图片 */} +
+ 笔 +
+ + {/* 提交按钮 */} +
+ 可提交状态 onSubmit?.(blankValues)} + button + /> +
+ + {/* 寄信图标(可选,如果需要的话) */} +
+ 寄信 +
+
+
+ + )} +
+ ); +}; + +export default LetterToSunzhongshan; diff --git a/src/components/letter/index.ts b/src/components/letter/index.ts new file mode 100644 index 0000000..578e435 --- /dev/null +++ b/src/components/letter/index.ts @@ -0,0 +1,11 @@ +export { default as LetterToChenyun } from "./LetterToChenyun"; +export type { LetterToChenyunProps } from "./LetterToChenyun"; + +export { default as LetterToSunzhongshan } from "./LetterToSunzhongshan"; +export type { LetterToSunzhongshanProps } from "./LetterToSunzhongshan"; + +export { default as LetterToHuangyanpei } from "./LetterToHuangyanpei"; +export type { LetterToHuangyanpeiProps } from "./LetterToHuangyanpei"; + +export { default as LetterToLuxun } from "./LetterToLuxun"; +export type { LetterToLuxunProps } from "./LetterToLuxun"; diff --git a/src/components/narration/Narration.tsx b/src/components/narration/Narration.tsx index 9a57131..7dca629 100644 --- a/src/components/narration/Narration.tsx +++ b/src/components/narration/Narration.tsx @@ -113,7 +113,7 @@ const Narration = ({ if (!visible) return null; return ( -
+
( {!hideLabels && (

填空

)} -
+
{normalizedBlanks.map((blank) => { const filledValue = getBlankFill(blank.id); const isHovered = hoveredBlank === blank.id && !!draggingOption; diff --git a/src/components/title/index.tsx b/src/components/title/index.tsx index 752992b..0e30725 100644 --- a/src/components/title/index.tsx +++ b/src/components/title/index.tsx @@ -31,7 +31,7 @@ const Title = ({ >
diff --git a/src/index.css b/src/index.css index 5d740e1..c5eb27c 100644 --- a/src/index.css +++ b/src/index.css @@ -33,6 +33,10 @@ button:not([disabled]){ cursor: pointer; } +img, video{ + max-width: unset; +} + /* 自定义滚动条样式 */ .custom-scrollbar { scrollbar-width: thin; @@ -224,3 +228,17 @@ button:not([disabled]){ border-color: rgba(250, 245, 235, 0.2); } +/* 点一点图标动画 - 手指点击动画(上下移动模拟点击) */ +@keyframes finger-bounce { + 0%, 100% { + transform: translateY(0) translateX(0); + } + 50% { + transform: translateY(-10px) translateX(5px); + } +} + +.animate-finger-bounce { + animation: finger-bounce 1.2s ease-in-out infinite; +} + diff --git a/src/pages/chenyun/internalization.tsx b/src/pages/chenyun/internalization.tsx index c5db681..aecf87d 100644 --- a/src/pages/chenyun/internalization.tsx +++ b/src/pages/chenyun/internalization.tsx @@ -1,14 +1,18 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useRef } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Image, Video, type VideoAnchor, + type VideoRef, type DragFillResult, + LetterToChenyun, + StudyGuideModal, } from "@/components"; -import { getPublicPath, getStaticImage } from "@/lib/utils"; +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; @@ -35,7 +39,7 @@ const starName = "chenyun"; const QUIZ_ANCHORS: QuizAnchor[] = [ { id: "quiz-1", - time: 12, + time: Math.ceil(5.52 * 60), //5.52分钟 title: "题目1", prompt: "陈云经济思想的核心内容指的是( )。", type: "multiple", @@ -52,7 +56,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: "quiz-2", - time: 30, + time: Math.ceil(10.01 * 60), title: "题目2", prompt: "陈云说过以下哪些话能着重体现出他的经济思想。", type: "multiple", @@ -73,7 +77,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: "quiz-3", - time: 48, + time: Math.ceil(16.04 * 60), title: "题目3", prompt: "我们新时代的青年可以从陈云同志身上学习到哪些精神", type: "drag", @@ -107,7 +111,25 @@ const ChenyunInternalization = () => { const [dragQuizErrors, setDragQuizErrors] = useState>( {} ); + 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(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) => { @@ -259,7 +281,12 @@ const ChenyunInternalization = () => { if (!moduleName) return null; return ( -
+
{/* 离开按钮 */} { onClick={() => navigate(`/${moduleName}/${starName}/home`)} /> -
); }; diff --git a/src/pages/chenyun/place/place1.tsx b/src/pages/chenyun/place/place1.tsx index 067555e..aaff584 100644 --- a/src/pages/chenyun/place/place1.tsx +++ b/src/pages/chenyun/place/place1.tsx @@ -5,6 +5,7 @@ import { Image, Title, Overlay, + StudyGuideModal, } from "@/components"; import { useNavigate, useParams } from "react-router-dom"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; @@ -15,11 +16,11 @@ import Cursor from "@/components/cursor"; const starName = "chenyun"; // 展品 ID 与资源路径映射,ID 用来做唯一标识 -const exhibitSrcMap: Record<1 | 2 | 3 | 4, string> = { +const exhibitSrcMap: Record<1 | 2, string> = { 1: "chenyun/place1/场景1展品1", 2: "chenyun/place1/场景1展品2", - 3: "chenyun/place1/场景1展品3", - 4: "chenyun/place1/场景1展品4", + // 3: "chenyun/place1/场景1展品3", + // 4: "chenyun/place1/场景1展品4", }; const ChenyunPlace1 = () => { const navigate = useNavigate(); @@ -27,18 +28,21 @@ const ChenyunPlace1 = () => { moduleName: string; }>(); + // 学习引导弹窗 + const [showGuideModal, setShowGuideModal] = useState(true); + // 对话结束后的学习引导弹窗 + const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false); + const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false); // 学习说明对话框显示状态 const [showIntroDialog, setShowIntroDialog] = useState(false); // 引导对话显示状态 - const [visible, setVisible] = useState(true); + const [visible, setVisible] = useState(false); // 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识) - const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | null>( - null - ); + const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); // 已浏览展品的 ID 集合 const [visited, setVisited] = useState>(new Set()); - const handleOpenExhibit = (index: 1 | 2 | 3 | 4) => { + const handleOpenExhibit = (index: 1 | 2) => { setActiveExhibitId(index); }; @@ -48,11 +52,11 @@ const ChenyunPlace1 = () => { }; const progressCount = visited.size; - const total = 4; + const total = 2; const completed = progressCount >= total; const subtitle = completed - ? "探索陈云故居老宅堂屋(已完成)" - : `探索陈云故居老宅堂屋(${progressCount}/${total})`; + ? "探索陈云纪念馆老宅堂屋(已完成)" + : `探索陈云纪念馆老宅堂屋(${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; @@ -96,17 +100,17 @@ const ChenyunPlace1 = () => { read={visited.has(2)} /> {/* 展品3 */} - handleOpenExhibit(3)} read={visited.has(3)} - /> + /> */} {/* 展品4 */} - handleOpenExhibit(4)} read={visited.has(4)} - /> + /> */} {/* 继续前进 - 仅在完成所有展品后显示 */} {completed && ( @@ -136,6 +140,21 @@ const ChenyunPlace1 = () => { }} /> + {/* 学习引导词 */} + { + setShowGuideModal(false); + setVisible(true); + }} + > + 同学你好,欢迎来到走进故居-对话学习模块!{"\n"} + 进入陈云故居后,我们一起来了解陈云同志经历的7个阶段:“水乡少年 + 艰辛成长”、“商务启航 投身革命”、“苏区淬炼 崭露头角”、“延安担当 + 经济破局”、“恢复时期 经济奠基”、“建设时期 务实兴邦”、“改革浪潮 + 擘画实业”; + + {/* 学习说明对话框 */} {showIntroDialog && ( { {/* 引导 */} { + setVisible(nextVisible); + if (!nextVisible && !afterDialogGuideShown) { + setAfterDialogGuideShown(true); + setShowAfterDialogGuide(true); + } + }} showMask={true} highlightSelectors={[]} > @@ -168,6 +193,19 @@ const ChenyunPlace1 = () => { 父母去世后,由外婆抚养。陈云从小跟舅父廖文光和舅母廖倪氏一起生活。家里靠种田和做小生意勉强维持生计。 + + {/* 对话结束后的学习引导 */} + setShowAfterDialogGuide(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; +
); }; diff --git a/src/pages/chenyun/place/place2.tsx b/src/pages/chenyun/place/place2.tsx index c1fbc7e..d0cd910 100644 --- a/src/pages/chenyun/place/place2.tsx +++ b/src/pages/chenyun/place/place2.tsx @@ -6,6 +6,7 @@ import { Cursor, GuideSteps, GuideStepsItem, + StudyGuideModal, } from "@/components"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; @@ -38,6 +39,8 @@ const ChenyunPlace2 = () => { const [guideVisible, setGuideVisible] = useState(false); // GuideSteps 当前步骤 const [guideStep, setGuideStep] = useState(0); + // 学习引导弹窗显示状态 + const [showGuideModal, setShowGuideModal] = useState(true); const handleOpenExhibit = (index: 1 | 2) => { if (index === 1) { @@ -157,6 +160,19 @@ const ChenyunPlace2 = () => { 陈云在党内以“铁算盘”著称,他在经济工作中能熟练使用算盘,就得益于少年时在青浦县立乙种商业学校的学习。 + + {/* 对话结束后的学习引导 */} + setShowGuideModal(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; +
); }; diff --git a/src/pages/chenyun/place/place3.tsx b/src/pages/chenyun/place/place3.tsx index e5b0081..37d5433 100644 --- a/src/pages/chenyun/place/place3.tsx +++ b/src/pages/chenyun/place/place3.tsx @@ -7,6 +7,7 @@ import { GuideSteps, GuideStepsItem, Video, + StudyGuideModal, } from "@/components"; import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; @@ -32,6 +33,8 @@ const ChenyunPlace3 = () => { const [guideVisible, setGuideVisible] = useState(false); // GuideSteps 当前步骤 const [guideStep, setGuideStep] = useState(0); + // 学习引导弹窗显示状态 + const [showGuideModal, setShowGuideModal] = useState(true); const handleOpenExhibit = (index: 1 | 2) => { if (index === 1) { @@ -164,6 +167,19 @@ const ChenyunPlace3 = () => { 陈云的珠算能力在商务印书馆工作期间有了很大提高。由于工作认真负责,三年的学徒期,陈云提前两年就破格到商务印书馆虹口分店(现为1925书局)当店员。 + + {/* 对话结束后的学习引导 */} + setShowGuideModal(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; +
); }; diff --git a/src/pages/chenyun/place/place4.tsx b/src/pages/chenyun/place/place4.tsx index 1dec898..6bd9166 100644 --- a/src/pages/chenyun/place/place4.tsx +++ b/src/pages/chenyun/place/place4.tsx @@ -6,6 +6,7 @@ import { Cursor, GuideSteps, GuideStepsItem, + StudyGuideModal, } from "@/components"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; @@ -32,6 +33,8 @@ const ChenyunPlace4 = () => { // 展品2的引导对话 const [guideVisible2, setGuideVisible2] = useState(false); const [guideStep2, setGuideStep2] = useState(0); + // 学习引导弹窗 + const [showGuideModal, setShowGuideModal] = useState(true); const handleOpenExhibit = (index: 1 | 2) => { if (index === 1) { @@ -180,6 +183,19 @@ const ChenyunPlace4 = () => { 陈云提议建立合作社,广泛吸收失业工人加入,多听取工人建议、增强企业管理的民主性,从而更好地建设苏区经济。这些做法为他日后主持全国财经工作积累了宝贵经验。 + + {/* 学习引导 */} + setShowGuideModal(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; +
); }; diff --git a/src/pages/chenyun/place/place5.tsx b/src/pages/chenyun/place/place5.tsx index b02a9ad..56474d8 100644 --- a/src/pages/chenyun/place/place5.tsx +++ b/src/pages/chenyun/place/place5.tsx @@ -6,6 +6,7 @@ import { Cursor, Video, Overlay, + StudyGuideModal, } from "@/components"; import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; @@ -36,6 +37,8 @@ const ChenyunPlace5 = () => { null ); const [videoVisible3, setVideoVisible3] = useState(false); + // 学习引导弹窗 + const [showGuideModal, setShowGuideModal] = useState(true); const handleOpenExhibit = (index: 1 | 2 | 3) => { if (index === 3) { @@ -152,6 +155,19 @@ const ChenyunPlace5 = () => { setActiveExhibitId(null); }} /> + + {/* 学习引导 */} + setShowGuideModal(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; +
); }; diff --git a/src/pages/chenyun/place/place6.tsx b/src/pages/chenyun/place/place6.tsx index 78ba912..24b414c 100644 --- a/src/pages/chenyun/place/place6.tsx +++ b/src/pages/chenyun/place/place6.tsx @@ -6,6 +6,7 @@ import { Cursor, GuideSteps, GuideStepsItem, + StudyGuideModal, } from "@/components"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; @@ -27,6 +28,8 @@ const ChenyunPlace6 = () => { const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null); const [guideVisible, setGuideVisible] = useState(false); const [guideStep, setGuideStep] = useState(0); + // 学习引导弹窗 + const [showGuideModal, setShowGuideModal] = useState(true); const handleOpenExhibit = () => { // 展品1:点击时同时打开展品和引导对话,从第1步开始 @@ -130,6 +133,19 @@ const ChenyunPlace6 = () => { 陈云挂帅后的首仗就是打击投机、平抑物价。通过“银元之战”“两白一黑”战役等几次严峻斗争,只用不到一年时间,就迅速稳定了金融物价。 + + {/* 学习引导 */} + setShowGuideModal(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; +
); }; diff --git a/src/pages/chenyun/place/place7.tsx b/src/pages/chenyun/place/place7.tsx index 13f17f2..db6357f 100644 --- a/src/pages/chenyun/place/place7.tsx +++ b/src/pages/chenyun/place/place7.tsx @@ -10,6 +10,7 @@ import { type SingleChoiceOption, type DragFillResult, QuizPanel, + StudyGuideModal, } from "@/components"; import { SuccessAlert, DragQuizPanel } from "@/components/layout"; import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils"; @@ -28,21 +29,24 @@ const exhibitSrcMap: Record<1, string> = { const QUIZ1_OPTIONS: SingleChoiceOption[] = [ { value: "A", - label: - "A. 推动了淮海战役的胜利(错误选项,选后提示:淮海战役于1949年1月胜利结束,发生在这次“银元之战”前,不存在因果关系)", + label: "A. 推动了淮海战役的胜利", }, { value: "B", - label: - "B. 有利于稳定人民的发行和地位(正确选项,选后提示:“银元之战”属于当时人民政府整顿金融的重要举措,黄金、银元投机不利于人民币币值稳定和流通,因此“银元之战”打击贵属投机活动,有利于稳定人民币的发行和地位)", + label: "B. 有利于稳定人民的发行和地位", }, { value: "C", - label: - "C. 巩固了新中国人民政权(错误选项,选后提示:1949年6月新中国政权尚未建立)", + label: "C. 巩固了新中国人民政权", }, ]; +// 题目1各选项对应的错误提示(原来写在选项括号中的内容) +const QUIZ1_ERROR_HINTS: Record = { + A: "淮海战役于1949年1月胜利结束,发生在这次“银元之战”前,不存在因果关系。", + C: "1949年6月新中国政权尚未建立。", +}; + const ChenyunPlace7 = () => { const navigate = useNavigate(); const { moduleName } = useParams<{ @@ -74,8 +78,13 @@ const ChenyunPlace7 = () => { const [successAlertVisible, setSuccessAlertVisible] = useState(false); // 标记拖拽题是否已完成 const [quiz2Completed, setQuiz2Completed] = useState(false); + // 学习引导弹窗 + const [showGuideModal, setShowGuideModal] = useState(true); const handleOpenExhibit = (_index: 1) => { + // 如果本场景学习已完成(拖拽题已答对),不再响应点击,避免重复播放 + if (quiz2Completed) return; + // 展品1:点击时显示视频1场景和引导对话1 setVideoVisible1(true); setVideoPlaying(false); // 视频不自动播放 @@ -271,7 +280,11 @@ const ChenyunPlace7 = () => { // 显示成功提示 setSuccessAlertVisible(true); } else { - setQuizError("再想一想,注意题干中的时间和提示信息哦~"); + // 使用对应选项的错误提示,如果不存在则使用通用提示 + setQuizError( + QUIZ1_ERROR_HINTS[val] || + "再想一想,注意题干中的时间和提示信息哦~" + ); } }} /> @@ -375,6 +388,19 @@ const ChenyunPlace7 = () => { />
)} + + {/* 学习引导 */} + setShowGuideModal(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; +
); }; diff --git a/src/pages/chenyun/place/place8.tsx b/src/pages/chenyun/place/place8.tsx index 9f30906..5d4639e 100644 --- a/src/pages/chenyun/place/place8.tsx +++ b/src/pages/chenyun/place/place8.tsx @@ -6,6 +6,7 @@ import { Cursor, GuideSteps, GuideStepsItem, + StudyGuideModal, } from "@/components"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; @@ -29,6 +30,8 @@ const ChenyunPlace8 = () => { // 展品2的对话引导 const [guideVisible, setGuideVisible] = useState(false); const [guideStep, setGuideStep] = useState(0); + // 学习引导弹窗 + const [showGuideModal, setShowGuideModal] = useState(true); const handleOpenExhibit = (index: 1 | 2) => { setActiveExhibitId(index); @@ -151,6 +154,19 @@ const ChenyunPlace8 = () => { 陈云说:"只有采用这种办法,才能保证我国人民生活日益增长的需要,才能制止投机活动,保证市场物价的稳定,才能使发展国民经济第一个五年计划得以顺利地进行。" + + {/* 学习引导 */} + setShowGuideModal(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; +
); }; diff --git a/src/pages/chenyun/place/place9.tsx b/src/pages/chenyun/place/place9.tsx index c578bf7..994487b 100644 --- a/src/pages/chenyun/place/place9.tsx +++ b/src/pages/chenyun/place/place9.tsx @@ -7,6 +7,7 @@ import { Video, GuideSteps, GuideStepsItem, + StudyGuideModal, } from "@/components"; import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; @@ -34,6 +35,8 @@ const ChenyunPlace9 = () => { // 对话引导状态 const [guideVisible, setGuideVisible] = useState(false); const [guideStep, setGuideStep] = useState(0); + // 学习引导弹窗 + const [showGuideModal, setShowGuideModal] = useState(true); const handleOpenExhibit = (_index: 1) => { // 展品1:播放视频 @@ -151,7 +154,10 @@ const ChenyunPlace9 = () => { 我们已经沿着陈云的足迹,从练塘水乡走到改革开放的前沿,读懂了他"适合老百姓的要求,做了一点事"的初心。 - 接下来,请你跨越时空,走进1949年刚解放时期的上海,参与到当时那场轰轰烈烈的"银元之战"和"米棉之战"之中去,在实践决策中体会,陈云"多谋善断""敢闯务实"的卓越智慧与战略魄力,感受我国集中力量办大事的制度优势如何在经济战场中彰显威力。 + 接下来,请你跨越时空,走进1949年刚解放时期的上海,参与到当时那场轰轰烈烈的"银元之战"和"米棉之战"之中去,在实践决策中体会,陈云"多谋善断""敢闯务实"的卓越智慧与战略魄力, + + + 感受我国集中力量办大事的制度优势如何在经济战场中彰显威力。 @@ -170,6 +176,19 @@ const ChenyunPlace9 = () => { setActiveExhibitId(null); }} /> + + {/* 学习引导 */} + setShowGuideModal(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; +
); }; diff --git a/src/pages/chenyun/reflection.tsx b/src/pages/chenyun/reflection.tsx deleted file mode 100644 index 49df818..0000000 --- a/src/pages/chenyun/reflection.tsx +++ /dev/null @@ -1,1131 +0,0 @@ -import { - Image, - Video, - Narration, - Spotlight, - MultipleChoiceQuizPanel, - SingleChoiceQuizPanel, - ExhibitOverlay, -} from "@/components"; -import Alert from "@/components/layout/alert"; -import SuccessAlert from "@/components/layout/success-alert"; -import ClueOverlay from "@/components/layout/clue"; -import { ClueData } from "@/components/layout"; -import { - cn, - getBackgroundImage, - getPublicPath, - getStaticImage, -} from "@/lib/utils"; -import { useState, useMemo } from "react"; -import { useNavigate, useParams } from "react-router-dom"; - -// starName 固定为 chenyun -const starName = "chenyun"; - -// 视频配置 -const VIDEO_CONFIG = { - yinyuan: { - src: getPublicPath("video/chenyun/chenyun_15.mp4"), - narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说"共产党是军事一百分,政治八十分,财经打零分。"上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的"银元之战。你将化身陈云同志的特派员,直面这场'银元之战'。"`, - alert: { - title: '第一幕:"银元之战"--人民币的阵地争夺战', - subtitle: "上海目前出现大肆贩卖银元的情况,请你了解情况!", - }, - }, - mimian: { - src: getPublicPath("video/chenyun/chenyun_16.mp4"), - narration: "", // 没有旁白 - alert: { - title: '第二幕:"米棉之战"--全国一盘棋的战略调度', - subtitle: "上海目前出现米棉物资短缺的情况,请你先了解情况!", - }, - }, -}; - -// 线索数据配置 -const CLUE_DATA: Record = { - // 银元之战 - 线索1 - "yinyuan-clue1": { - type: "text", - title: "线索一", - content: `沪市银元价格连日暴涨, - 5月27日一块银元值100人民币, - 6月3日涨到720元, - 6月4日涨到1100元。`, - }, - // 银元之战 - 线索2 - "yinyuan-clue2": { - type: "text", - title: "线索二", - content: ( -
-
- 南京路的先施、永安、大新、新新等四大公司率先拒收人民币,甚至用银元标价。 -
-
其他商户纷纷仿效,人民币在市场交易中地位下降。
-
- ), - }, - // 米棉之战 - 投机商情报 - "mimian-intelligence": { - type: "text", - title: "投机商情报", - content: `申新财团囤积棉纱3万件, - 扬言"控制纱布就能控制上海"。`, - }, - // 米棉之战 - 全国物资分布 - "mimian-distribution": { - type: "map", - mapImage: "chenyun/reflection/全国物资分布", - }, - // 米棉之战 - 对话线索 - "mimian-dialogue": { - type: "dialogue", - dialogues: [ - { - character: "群众1", - avatar: "chenyun/reflection/群众1", - text: "米价一天涨三次,家里快断粮了", - }, - { - character: "群众2", - avatar: "chenyun/reflection/群众2", - text: "钱庄都在囤媒,冬天没法取暖", - }, - ], - }, - // 银元之战 - 电话线索 - "yinyuan-phone": { - type: "audio", - text: `人民币进不了上海市场,问题很严重。必须让人民币站稳脚跟。`, - audioImage: "chenyun/reflection/录音", - }, -}; - -const ChenyunReflection = () => { - const navigate = useNavigate(); - const [expandedFile, setExpandedFile] = useState<"yinyuan" | "mimian" | null>( - null - ); - const [playingVideo, setPlayingVideo] = useState<"yinyuan" | "mimian" | null>( - null - ); - const [narrationText, setNarrationText] = useState( - `同学你好,欢迎来到跨越时空-体悟学习模块! - -我们即将跨越时空,穿越到"银元之战"和"米棉之战"两个沉浸式场景,来感受陈云同志在1949年的上海是如何应对当时混乱的经济秩序的。` - ); - const [showNarration, setShowNarration] = useState(true); - const [showAlert, setShowAlert] = useState(false); - const [alertConfig, setAlertConfig] = useState({ title: "", subtitle: "" }); - const [currentClue, setCurrentClue] = useState(null); - const [currentClueKey, setCurrentClueKey] = useState(null); - const [showClueOverlay, setShowClueOverlay] = useState(false); - // 记录每个线索的查看状态 - const [clueStates, setClueStates] = useState>({ - "yinyuan-clue1": false, - "yinyuan-clue2": false, - "mimian-intelligence": false, - "mimian-distribution": false, - "mimian-dialogue": false, - "yinyuan-phone": false, - }); - - // 判断银元之战的所有线索是否都已查看 - const isYinyuanAllViewed = useMemo(() => { - return ( - clueStates["yinyuan-clue1"] && - clueStates["yinyuan-clue2"] && - clueStates["yinyuan-phone"] - ); - }, [clueStates]); - - // 判断米棉之战的所有线索是否都已查看(用于自动显示题目) - const _isMimianAllViewed = useMemo(() => { - return ( - clueStates["mimian-intelligence"] && - clueStates["mimian-distribution"] && - clueStates["mimian-dialogue"] - ); - }, [clueStates]); - - // 多选题1相关状态 - const [showQuiz1, setShowQuiz1] = useState(false); - // 旁白文字状态(图2) - const [showNarration2, setShowNarration2] = useState(false); - const [narration2Text, setNarration2Text] = useState(""); - // 多选题2相关状态 - const [showQuiz2, setShowQuiz2] = useState(false); - // 成功提示框状态 - const [showSuccessAlert, setShowSuccessAlert] = useState(false); - // 银元之战解决状态 - const [isYinyuanSolved, setIsYinyuanSolved] = useState(false); - // 走势图图标显示状态(控制文件上的走势图图标是否显示) - const [showTrendChartIcon, setShowTrendChartIcon] = useState(true); - // 走势图大图显示状态 - const [showTrendChartDetail, setShowTrendChartDetail] = useState(false); - - // 米棉之战相关状态 - const [showMimianQuiz1, setShowMimianQuiz1] = useState(false); - const [showMimianNarration1, setShowMimianNarration1] = useState(false); - const [showMimianQuiz2, setShowMimianQuiz2] = useState(false); - const [showMimianNarration2, setShowMimianNarration2] = useState(false); - const [showMimianQuiz3, setShowMimianQuiz3] = useState(false); - const [showMimianQuiz4, setShowMimianQuiz4] = useState(false); - const [showMimianNarration3, setShowMimianNarration3] = useState(false); - const [showMimianQuiz5, setShowMimianQuiz5] = useState(false); - const [showMimianNarration4, setShowMimianNarration4] = useState(false); - const [showMimianNarration5, setShowMimianNarration5] = useState(false); - const [isMimianSolved, setIsMimianSolved] = useState(false); - // 米棉之战成功提示框状态 - const [showMimianSuccessAlert, setShowMimianSuccessAlert] = useState(false); - const [mimianSuccessAlertContent, setMimianSuccessAlertContent] = - useState(""); - - const { moduleName } = useParams<{ - moduleName: string; - }>(); - - const handleFileClick = (fileType: "yinyuan" | "mimian") => { - // 如果已经展开,则收起 - if (expandedFile === fileType) { - setExpandedFile(null); - return; - } - - // 播放视频 - setPlayingVideo(fileType); - }; - - const handleVideoEnd = () => { - if (!playingVideo) return; - - const config = VIDEO_CONFIG[playingVideo]; - - // 银元之战:视频结束后显示旁白 - if (playingVideo === "yinyuan") { - setNarrationText(config.narration); - setShowNarration(true); - } - // 米棉之战:视频结束后直接显示提示框 - else if (playingVideo === "mimian") { - setPlayingVideo(null); - setAlertConfig(config.alert); - setShowAlert(true); - } - }; - - const handleNarrationClose = () => { - // 关闭旁白和视频后显示提示框 - setShowNarration(false); - - // 保存当前的文件类型用于后续展开 - const currentFile = playingVideo; - setPlayingVideo(null); - - if (currentFile) { - setAlertConfig(VIDEO_CONFIG[currentFile].alert); - setShowAlert(true); - } - }; - - const handleAlertClose = () => { - // 关闭提示框后展开线索 - setShowAlert(false); - - // 从 alertConfig 的 title 判断是哪个文件 - if (alertConfig.title.includes("银元之战")) { - setExpandedFile("yinyuan"); - } else if (alertConfig.title.includes("米棉之战")) { - setExpandedFile("mimian"); - } - }; - - return ( -
- {/* 离开按钮 */} - {expandedFile == null && ( - { - if (moduleName && starName) { - navigate(`/${moduleName}/${starName}/home`); - } - }} - /> - )} - - {/* 桌子位置 */} -
- {/* 文件1:银元之战 */} -
handleFileClick("yinyuan")} - > - - - {isYinyuanSolved ? "01银元之战(已解决)" : "01银元之战(未解决)"} - - - {/* 走势图 - 银元之战解决后显示,点击后隐藏 */} - {isYinyuanSolved && showTrendChartIcon && ( -
{ - e.stopPropagation(); - setShowTrendChartDetail(true); - }} - > - -
- )} - - {/* 引导标志 - 只在所有线索查看完后显示-前往解决按钮-去做题 */} - {isYinyuanAllViewed && - !isYinyuanSolved && - !showQuiz1 && - !showNarration2 && - !showQuiz2 && - !showSuccessAlert && ( -
{ - e.stopPropagation(); - // 显示多选题1 - setShowQuiz1(true); - }} - > - -
- )} -
- - {/* 银元之战:线索1 */} -
{ - const clue = CLUE_DATA["yinyuan-clue1"]; - if (clue) { - setCurrentClue(clue); - setCurrentClueKey("yinyuan-clue1"); - setShowClueOverlay(true); - } - }} - > - {/* 线索图片 */} - - {/* 搜索图标 */} - {!clueStates["yinyuan-clue1"] && ( - - )} - {/* 已浏览标记 */} - {clueStates["yinyuan-clue1"] && ( -
- - 已浏览 - -
- )} -
- {/* 银元之战:线索2 */} -
{ - const clue = CLUE_DATA["yinyuan-clue2"]; - if (clue) { - setCurrentClue(clue); - setCurrentClueKey("yinyuan-clue2"); - setShowClueOverlay(true); - } - }} - > - {/* 线索图片 */} - - {/* 搜索图标 */} - {!clueStates["yinyuan-clue2"] && ( - - )} - {/* 已浏览标记 */} - {clueStates["yinyuan-clue2"] && ( -
- - 已浏览 - -
- )} -
- - {/* 文件2:米棉之战 */} -
handleFileClick("mimian")} - > - - - {isMimianSolved ? "02米棉之战(已解决)" : "02米棉之战(未解决)"} - -
- - {/* 米棉之战:线索-投机商情报 */} -
{ - const clue = CLUE_DATA["mimian-intelligence"]; - if (clue) { - setCurrentClue(clue); - setCurrentClueKey("mimian-intelligence"); - setShowClueOverlay(true); - } - }} - > - {/* 线索图片 */} - - {/* 搜索图标 */} - {!clueStates["mimian-intelligence"] && ( - - )} - {/* 已浏览标记 */} - {clueStates["mimian-intelligence"] && ( -
- - 已浏览 - -
- )} -
- {/* 米棉之战:线索-全国物资分布 */} -
{ - const clue = CLUE_DATA["mimian-distribution"]; - if (clue) { - setCurrentClue(clue); - setCurrentClueKey("mimian-distribution"); - setShowClueOverlay(true); - } - }} - > - {/* 线索图片 */} - - {/* 搜索图标 */} - {!clueStates["mimian-distribution"] && ( - - )} - {/* 已浏览标记 */} - {clueStates["mimian-distribution"] && ( -
- - 已浏览 - -
- )} -
- - {/* 米棉之战:消息(对话线索) */} -
{ - const clue = CLUE_DATA["mimian-dialogue"]; - if (clue) { - setCurrentClue(clue); - setCurrentClueKey("mimian-dialogue"); - setShowClueOverlay(true); - } - }} - > - - {/* 未倾听时显示三个点动画 */} - {!clueStates["mimian-dialogue"] && ( -
-
- - - -
-
- )} - {/* 已倾听标记 */} - {clueStates["mimian-dialogue"] && ( -
- - 已倾听 - -
- )} -
- - {/* 电话 - 只有银元之战有 */} -
-
- {/* 响铃图标 */} - {!clueStates["yinyuan-phone"] && expandedFile === "yinyuan" && ( - - )} - {/* 电话 */} - { - if (expandedFile !== "yinyuan") return; - const clue = CLUE_DATA["yinyuan-phone"]; - if (clue) { - setCurrentClue(clue); - setCurrentClueKey("yinyuan-phone"); - setShowClueOverlay(true); - } - }} - /> - {/* 已接听标记 */} - {clueStates["yinyuan-phone"] && expandedFile === "yinyuan" && ( -
- - 已接听 - -
- )} -
-
-
- - {/* 拼图盒 */} - - - {/* 视频播放层 */} - {playingVideo && ( -
-
- )} - - {/* 旁白层(包含欢迎语和视频后的旁白) */} - - - {/* 提示框 */} - {showAlert && ( - - )} - - {/* 展示线索 */} - {currentClue && ( - { - setShowClueOverlay(false); - // 标记当前线索为已查看 - if (currentClueKey) { - const newClueStates = { - ...clueStates, - [currentClueKey]: true, - }; - setClueStates(newClueStates); - - // 检查米棉之战所有线索是否都已查看 - const allMimianViewed = - newClueStates["mimian-intelligence"] && - newClueStates["mimian-distribution"] && - newClueStates["mimian-dialogue"]; - - // 如果所有米棉之战线索都已查看,自动显示单选题1 - if (allMimianViewed && !showMimianQuiz1) { - setShowMimianQuiz1(true); - } - } - setCurrentClue(null); - setCurrentClueKey(null); - }} - /> - )} - - {/* 遮罩层 - 当银元之战所有线索都已查看时显示 */} - - - {/* 成功提示框 - 多选题2答对后显示 */} - { - // 关闭聚光灯、标记银元之战为已解决、显示走势图 - setIsYinyuanSolved(true); - setShowSuccessAlert(false); - }} - /> - - {/* 走势图大图展示 - 使用 ExhibitOverlay */} - { - // 点击走势图:隐藏文件上的走势图图标,播放米棉之战视频 - setShowTrendChartIcon(false); - setShowTrendChartDetail(false); - // 播放米棉之战视频 - setPlayingVideo("mimian"); - }, - }, - ]} - onClose={() => { - setShowTrendChartDetail(false); - // 普通关闭:进入米棉之战(展开米棉之战文件) - // setExpandedFile("mimian"); - }} - /> - - {/* 多选题1 - 点击引导图标后显示 */} - { - if (isCorrect) { - // 关闭多选题1,显示旁白文字(图2) - setShowQuiz1(false); - setNarration2Text( - `六月五日,上海政府抛出十万银元,力图以银元制服银元,使价格回跌。全市举行了"反对银元投机,保障人民生活"的游行和宣传。上海市军委会主任陈毅在全市集会上警告投机奸商"赶快洗手不干,否则勿谓言之不预"。但这两手收效仍微。十万银元被投机商人一吸而空,六月七日银元每元的价格涨到一千八百元人民币。陈云同志判断:'单纯的经济手段已不足以震慑敌人,必须辅以坚定的行政与法律手段。` - ); - setShowNarration2(true); - } - }} - onError={(error) => { - console.log("错误信息:", error); - }} - /> - - {/* 旁白文字2(图2)- 多选题1答对后显示 */} - { - setShowNarration2(false); - // 关闭旁白后显示多选题2 - setShowQuiz2(true); - }} - enableAnimation={false} - /> - - {/* 多选题2(图3)- 关闭旁白后显示 */} - { - if (isCorrect) { - // 关闭多选题2,显示成功提示框 - setShowQuiz2(false); - setShowSuccessAlert(true); - } - }} - onError={(error) => { - console.log("错误信息:", error); - }} - /> - - {/* 米棉之战 - 单选题1(图1) */} - { - if (isCorrect) { - setShowMimianQuiz1(false); - setShowMimianSuccessAlert(true); - setMimianSuccessAlertContent("恭喜你,选择正确!"); - } - }} - /> - - {/* 米棉之战 - 成功提示框1 */} - { - setShowMimianSuccessAlert(false); - setMimianSuccessAlertContent(""); - // 显示旁白1(图2) - setShowMimianNarration1(true); - }} - /> - - {/* 米棉之战 - 旁白1(图2) */} - { - setShowMimianNarration1(false); - // 显示单选题2 - setShowMimianQuiz2(true); - }} - enableAnimation={false} - /> - - {/* 米棉之战 - 单选题2(图3) */} - { - if (isCorrect) { - setShowMimianQuiz2(false); - setShowMimianSuccessAlert(true); - setMimianSuccessAlertContent( - `恭喜你,选择正确!结合当时市场情况,陈云同志在会上做出了"全国一盘棋"的指示。全国统筹调运粮食、棉花、煤炭填补缺口,筑牢物资基础。关键节点集中抛售,让跟风囤货的投机商无力承接,因亏本和高利贷纷纷破产,快速平抑物价、稳固人民币地位。` - ); - } else { - // 根据选项显示不同的错误信息 - if (value === "A") { - setShowMimianSuccessAlert(true); - setMimianSuccessAlertContent( - "很抱歉,你选错了!当时物价上涨已波及多地区,局部限购无法阻止投机商转移物资炒作。上海米、棉依赖外部供应,本地物资匮乏,限购既不解决缺口,还会加剧民众恐慌,扰乱市场秩序。请你再思考一下吧!" - ); - } else if (value === "B") { - setShowMimianSuccessAlert(true); - setMimianSuccessAlertContent( - "很抱歉,你选错了!当时国民党滥发货币导致的贬值阴影未消,民众对新货币缺乏信任。此时增发人民币,会使货币供应量远超物资总量,重蹈贬值覆辙,削弱人民币信用,让投机气焰更盛。请你再思考一下吧!" - ); - } - } - }} - /> - - {/* 米棉之战 - 成功提示框2(单选题2答对后) */} - { - setShowMimianSuccessAlert(false); - setMimianSuccessAlertContent(""); - // 显示旁白2(图4) - setShowMimianNarration2(true); - }} - /> - - {/* 米棉之战 - 错误提示框(单选题2答错后) */} - { - setShowMimianSuccessAlert(false); - setMimianSuccessAlertContent(""); - // 重新显示单选题2 - setShowMimianQuiz2(true); - }} - /> - - {/* 米棉之战 - 旁白2(图4) */} - { - setShowMimianNarration2(false); - // 显示单选题3 - setShowMimianQuiz3(true); - }} - enableAnimation={false} - /> - - {/* 米棉之战 - 单选题3(图5) */} - { - if (isCorrect) { - setShowMimianQuiz3(false); - setShowMimianSuccessAlert(true); - setMimianSuccessAlertContent( - "恭喜你,选择正确!地方配合,调运按时推进。" - ); - } else { - setShowMimianQuiz3(false); - setShowMimianSuccessAlert(true); - setMimianSuccessAlertContent( - "很抱歉,你选错了!地方抵触,调运延误多天。请你再思考一下吧!" - ); - } - }} - /> - - {/* 米棉之战 - 单选题3成功/错误提示 */} - { - setShowMimianSuccessAlert(false); - setMimianSuccessAlertContent(""); - if (mimianSuccessAlertContent.includes("地方配合")) { - // 答对后显示单选题4 - setShowMimianQuiz4(true); - } else { - // 答错后重新显示单选题3 - setShowMimianQuiz3(true); - } - }} - /> - - {/* 米棉之战 - 单选题4(图6) */} - { - if (isCorrect) { - setShowMimianQuiz4(false); - setShowMimianSuccessAlert(true); - setMimianSuccessAlertContent( - "恭喜你,选择正确!棉农支持,调运量足额完成。" - ); - } else { - setShowMimianQuiz4(false); - setShowMimianSuccessAlert(true); - setMimianSuccessAlertContent( - "很抱歉,你选错了!棉农抵触,调运量减少。请你再思考一下吧!" - ); - } - }} - /> - - {/* 米棉之战 - 单选题4成功/错误提示 */} - { - setShowMimianSuccessAlert(false); - setMimianSuccessAlertContent(""); - if (mimianSuccessAlertContent.includes("棉农支持")) { - // 答对后显示旁白3 - setShowMimianNarration3(true); - } else { - // 答错后重新显示单选题4 - setShowMimianQuiz4(true); - } - }} - /> - - {/* 米棉之战 - 旁白3(图7) */} - { - setShowMimianNarration3(false); - // 显示单选题5 - setShowMimianQuiz5(true); - }} - enableAnimation={false} - /> - - {/* 米棉之战 - 单选题5(图8) */} - { - if (isCorrect) { - setShowMimianQuiz5(false); - setShowMimianSuccessAlert(true); - setMimianSuccessAlertContent( - "恭喜你,选择正确!这契合米棉之战史实,多地同步大量抛售,让借高利贷囤货的投机商措手不及。连续抛售使其无力承接,只得亏本抛售,最终纷纷破产,快速稳住物价。" - ); - } else { - setShowMimianQuiz5(false); - setShowMimianSuccessAlert(true); - setMimianSuccessAlertContent( - "很抱歉,你选错了!投机商此前曾吃下政府平价抛售物资获利。逐步降价加舆论引导难让其信服,他们会持续囤货,政府储备会被消耗,反而让投机商掌控节奏,无法遏制物价上涨。请你再思考一下吧!" - ); - } - }} - /> - - {/* 米棉之战 - 单选题5成功/错误提示 */} - { - setShowMimianSuccessAlert(false); - setMimianSuccessAlertContent(""); - if (mimianSuccessAlertContent.includes("这契合米棉之战史实")) { - // 答对后显示旁白4 - setShowMimianNarration4(true); - } else { - // 答错后重新显示单选题5 - setShowMimianQuiz5(true); - } - }} - /> - - {/* 米棉之战 - 旁白4(图9) */} - { - setShowMimianNarration4(false); - // 显示旁白5(结束语) - setShowMimianNarration5(true); - }} - enableAnimation={false} - /> - - {/* 米棉之战 - 旁白5(结束语) */} - { - setShowMimianNarration5(false); - // 标记米棉之战为已解决 - setIsMimianSolved(true); - }} - enableAnimation={false} - /> -
- ); -}; - -export default ChenyunReflection; diff --git a/src/pages/chenyun/reflection/constants.tsx b/src/pages/chenyun/reflection/constants.tsx new file mode 100644 index 0000000..39b61b2 --- /dev/null +++ b/src/pages/chenyun/reflection/constants.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from "react"; +import { getPublicPath } from "@/lib/utils"; +import type { ClueData } from "@/components/layout"; + +// starName 固定为 chenyun +export const starName = "chenyun"; + +// 欢迎语文案(使用 StudyGuideModal 展示) +export const WELCOME_TEXT = `同学你好,欢迎来到跨越时空-体悟学习模块! + +我们即将跨越时空,穿越到"银元之战"和"米棉之战"两个沉浸式场景,来感受陈云同志在1949年的上海是如何应对当时混乱的经济秩序的。`; + +// 视频配置 +export const VIDEO_CONFIG = { + yinyuan: { + src: getPublicPath("video/chenyun/chenyun_15.mp4"), + narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说"共产党是军事一百分,政治八十分,财经打零分。"上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的"银元之战。你将化身陈云同志的特派员,直面这场'银元之战'。"`, + alert: { + title: '第一幕:"银元之战"--人民币的阵地争夺战', + subtitle: "上海目前出现大肆贩卖银元的情况,请你了解情况!", + }, + }, + mimian: { + src: getPublicPath("video/chenyun/chenyun_16.mp4"), + narration: "", // 没有旁白 + alert: { + title: '第二幕:"米棉之战"--全国一盘棋的战略调度', + subtitle: "上海目前出现米棉物资短缺的情况,请你先了解情况!", + }, + }, +} as const; + +// 线索数据配置 +export const CLUE_DATA: Record = { + // 银元之战 - 线索1 + "yinyuan-clue1": { + type: "text", + title: "线索一", + content: `沪市银元价格连日暴涨, + 5月27日一块银元值100人民币, + 6月3日涨到720元, + 6月4日涨到1100元。`, + }, + // 银元之战 - 线索2 + "yinyuan-clue2": { + type: "text", + title: "线索二", + content: ( +
+
+ 南京路的先施、永安、大新、新新等四大公司率先拒收人民币,甚至用银元标价。 +
+
其他商户纷纷仿效,人民币在市场交易中地位下降。
+
+ ), + }, + // 米棉之战 - 投机商情报 + "mimian-intelligence": { + type: "text", + title: "投机商情报", + content: `申新财团囤积棉纱3万件, + 扬言"控制纱布就能控制上海"。`, + }, + // 米棉之战 - 全国物资分布 + "mimian-distribution": { + type: "map", + mapImage: "chenyun/reflection/全国物资分布", + }, + // 米棉之战 - 对话线索 + "mimian-dialogue": { + type: "dialogue", + dialogues: [ + { + character: "群众1", + avatar: "chenyun/reflection/群众1", + text: "米价一天涨三次,家里快断粮了", + }, + { + character: "群众2", + avatar: "chenyun/reflection/群众2", + text: "钱庄都在囤媒,冬天没法取暖", + }, + ], + }, + // 银元之战 - 电话线索 + "yinyuan-phone": { + type: "audio", + text: `人民币进不了上海市场,问题很严重。必须让人民币站稳脚跟。`, + audioImage: "chenyun/reflection/录音", + }, +}; diff --git a/src/pages/chenyun/reflection/constants/index.ts b/src/pages/chenyun/reflection/constants/index.ts new file mode 100644 index 0000000..0d844e4 --- /dev/null +++ b/src/pages/chenyun/reflection/constants/index.ts @@ -0,0 +1,28 @@ +/** + * 配置统一导出 + * 保持向后兼容,导出所有配置 + */ +import { starName, WELCOME_TEXT } from "./shared"; +import { YINYUAN_VIDEO_CONFIG, YINYUAN_CLUE_DATA } from "./yinyuan"; +import { MIMIAN_VIDEO_CONFIG, MIMIAN_CLUE_DATA } from "./mimian"; + +// 导出共享配置 +export { starName, WELCOME_TEXT }; + +// 导出银元之战配置 +export { YINYUAN_VIDEO_CONFIG, YINYUAN_CLUE_DATA }; + +// 导出米棉之战配置 +export { MIMIAN_VIDEO_CONFIG, MIMIAN_CLUE_DATA }; + +// 向后兼容:导出旧的 VIDEO_CONFIG 格式 +export const VIDEO_CONFIG = { + yinyuan: YINYUAN_VIDEO_CONFIG, + mimian: MIMIAN_VIDEO_CONFIG, +} as const; + +// 向后兼容:导出旧的 CLUE_DATA 格式(合并所有线索) +export const CLUE_DATA = { + ...YINYUAN_CLUE_DATA, + ...MIMIAN_CLUE_DATA, +} as const; diff --git a/src/pages/chenyun/reflection/constants/mimian.ts b/src/pages/chenyun/reflection/constants/mimian.ts new file mode 100644 index 0000000..60e27a4 --- /dev/null +++ b/src/pages/chenyun/reflection/constants/mimian.ts @@ -0,0 +1,52 @@ +/** + * 米棉之战配置 + * 包含米棉之战的视频配置和线索数据 + */ +import type { ReactNode } from "react"; +import { getPublicPath } from "@/lib/utils"; +import type { ClueData } from "@/components/layout"; + +// 米棉之战视频配置 +export const MIMIAN_VIDEO_CONFIG = { + src: getPublicPath("video/chenyun/chenyun_16.mp4"), + narration: "", // 没有旁白 + alert: { + title: '第二幕:"米棉之战"--全国一盘棋的战略调度', + subtitle: "上海目前出现米棉物资短缺的情况,请你先了解情况!", + }, +} as const; + +// 米棉之战线索数据配置 +export const MIMIAN_CLUE_DATA: Record< + string, + ClueData & { content?: ReactNode } +> = { + // 米棉之战 - 投机商情报 + "mimian-intelligence": { + type: "text", + title: "投机商情报", + content: `申新财团囤积棉纱3万件, + 扬言"控制纱布就能控制上海"。`, + }, + // 米棉之战 - 全国物资分布 + "mimian-distribution": { + type: "map", + mapImage: "chenyun/reflection/全国物资分布", + }, + // 米棉之战 - 对话线索 + "mimian-dialogue": { + type: "dialogue", + dialogues: [ + { + character: "群众1", + avatar: "chenyun/reflection/群众1", + text: "米价一天涨三次,家里快断粮了", + }, + { + character: "群众2", + avatar: "chenyun/reflection/群众2", + text: "钱庄都在囤媒,冬天没法取暖", + }, + ], + }, +}; diff --git a/src/pages/chenyun/reflection/constants/shared.ts b/src/pages/chenyun/reflection/constants/shared.ts new file mode 100644 index 0000000..ab01f3f --- /dev/null +++ b/src/pages/chenyun/reflection/constants/shared.ts @@ -0,0 +1,13 @@ +/** + * 共享配置 + * 包含欢迎语、starName 等通用配置 + */ + +// starName 固定为 chenyun +export const starName = "chenyun"; + +// 欢迎语文案(使用 StudyGuideModal 展示) +export const WELCOME_TEXT = `同学你好,欢迎来到跨越时空-体悟学习模块! + +我们即将跨越时空,穿越到"银元之战"和"米棉之战"两个沉浸式场景,来感受陈云同志在1949年的上海是如何应对当时混乱的经济秩序的。`; + diff --git a/src/pages/chenyun/reflection/constants/yinyuan.tsx b/src/pages/chenyun/reflection/constants/yinyuan.tsx new file mode 100644 index 0000000..48b8a19 --- /dev/null +++ b/src/pages/chenyun/reflection/constants/yinyuan.tsx @@ -0,0 +1,52 @@ +/** + * 银元之战配置 + * 包含银元之战的视频配置和线索数据 + */ +import { type ReactNode } from "react"; +import { getPublicPath } from "@/lib/utils"; +import type { ClueData } from "@/components/layout"; + +// 银元之战视频配置 +export const YINYUAN_VIDEO_CONFIG = { + src: getPublicPath("video/chenyun/chenyun_15.mp4"), + narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说"共产党是军事一百分,政治八十分,财经打零分。"上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的"银元之战。你将化身陈云同志的特派员,直面这场'银元之战'。"`, + alert: { + title: '第一幕:"银元之战"--人民币的阵地争夺战', + subtitle: "上海目前出现大肆贩卖银元的情况,请你了解情况!", + }, +} as const; + +// 银元之战线索数据配置 +export const YINYUAN_CLUE_DATA: Record< + string, + ClueData & { content?: ReactNode } +> = { + // 银元之战 - 线索1 + "yinyuan-clue1": { + type: "text", + title: "线索一", + content: `沪市银元价格连日暴涨, + 5月27日一块银元值100人民币, + 6月3日涨到720元, + 6月4日涨到1100元。`, + }, + // 银元之战 - 线索2 + "yinyuan-clue2": { + type: "text", + title: "线索二", + content: ( +
+
+ 南京路的先施、永安、大新、新新等四大公司率先拒收人民币,甚至用银元标价。 +
+
其他商户纷纷仿效,人民币在市场交易中地位下降。
+
+ ), + }, + // 银元之战 - 电话线索 + "yinyuan-phone": { + type: "audio", + text: `人民币进不了上海市场,问题很严重。必须让人民币站稳脚跟。`, + audioImage: "chenyun/reflection/录音", + }, +}; diff --git a/src/pages/chenyun/reflection/index.tsx b/src/pages/chenyun/reflection/index.tsx new file mode 100644 index 0000000..7964374 --- /dev/null +++ b/src/pages/chenyun/reflection/index.tsx @@ -0,0 +1,315 @@ +/** + * 陈云反思页面主入口 + * 整合银元之战和米棉之战两个模块 + */ +import { Image, StudyGuideModal } from "@/components"; +import ErrorAlert from "@/components/layout/alert/error-alert"; +import ClueOverlay from "@/components/layout/clue"; +import type { ClueData } from "@/components/layout"; +import { getBackgroundImage, getStaticImage } from "@/lib/utils"; +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { WELCOME_TEXT, starName } from "./constants"; +import { useClueStates } from "./shared/useClueStates"; +import { VideoHandler } from "./shared/VideoHandler"; +import { YinyuanBattle } from "./yinyuan/YinyuanBattle"; +import { MimianBattle } from "./mimian/MimianBattle"; +import type { FileType } from "./shared/types"; +import { YINYUAN_VIDEO_CONFIG } from "./constants/yinyuan"; +import { MIMIAN_VIDEO_CONFIG } from "./constants/mimian"; + +const ChenyunReflection = () => { + const navigate = useNavigate(); + const { moduleName } = useParams<{ + moduleName: string; + }>(); + + // ========== 顶层状态管理 ========== + + /** 当前展开的文件 */ + const [expandedFile, setExpandedFile] = useState(null); + + /** 当前播放的视频 */ + const [playingVideo, setPlayingVideo] = useState(null); + + /** 视频结束后的旁白文本 */ + const [narrationText, setNarrationText] = useState(""); + + /** 是否显示旁白 */ + const [showNarration, setShowNarration] = useState(false); + + /** 是否显示提示框 */ + const [showAlert, setShowAlert] = useState(false); + + /** 提示框配置 */ + const [alertConfig, setAlertConfig] = useState({ title: "", subtitle: "" }); + + /** 当前线索 */ + const [currentClue, setCurrentClue] = useState(null); + + /** 当前线索键名 */ + const [currentClueKey, setCurrentClueKey] = useState(null); + + /** 是否显示线索弹窗 */ + const [showClueOverlay, setShowClueOverlay] = useState(false); + + /** 欢迎语弹窗(使用 StudyGuideModal 展示入口引导) */ + const [showWelcomeGuide, setShowWelcomeGuide] = useState(true); + + /** 第一幕银元之战 - 学习引导弹窗 */ + const [showYinyuanGuide, setShowYinyuanGuide] = useState(false); + + /** 第二幕米棉之战 - 学习引导弹窗 */ + const [showMimianGuide, setShowMimianGuide] = useState(false); + + /** 米棉之战解锁错误提示 */ + const [showMimianLockError, setShowMimianLockError] = useState(false); + + // ========== 线索状态管理 ========== + const { + clueStates, + isYinyuanAllViewed, + isMimianAllViewed, + markClueAsViewed, + } = useClueStates(); + + // ========== 银元之战状态(从 YinyuanBattle 获取) ========== + // 注意:这些状态现在由 YinyuanBattle 内部管理,这里只保留必要的共享状态 + const [isYinyuanSolved, setIsYinyuanSolved] = useState(false); + + // ========== 事件处理函数 ========== + + /** + * 处理文件点击 + * @param fileType 文件类型 + */ + const handleFileClick = (fileType: FileType) => { + // 如果已经展开,则收起 + if (expandedFile === fileType) { + setExpandedFile(null); + return; + } + + // 必须先完成银元之战任务,才能解锁米棉之战 + if (fileType === "mimian" && !isYinyuanSolved) { + setShowMimianLockError(true); + return; + } + + // 播放对应视频 + setPlayingVideo(fileType); + }; + + /** + * 处理视频结束 + */ + const handleVideoEnd = () => { + if (!playingVideo) return; + + // 银元之战:视频结束后显示旁白 + if (playingVideo === "yinyuan") { + setNarrationText(YINYUAN_VIDEO_CONFIG.narration); + setShowNarration(true); + } + // 米棉之战:视频结束后直接显示提示框 + else if (playingVideo === "mimian") { + setPlayingVideo(null); + setAlertConfig(MIMIAN_VIDEO_CONFIG.alert); + setShowAlert(true); + } + }; + + /** + * 处理旁白关闭 + */ + const handleNarrationClose = () => { + // 关闭旁白和视频后显示提示框 + setShowNarration(false); + + // 保存当前的文件类型用于后续展开 + const currentFile = playingVideo; + setPlayingVideo(null); + + if (currentFile) { + if (currentFile === "yinyuan") { + setAlertConfig(YINYUAN_VIDEO_CONFIG.alert); + } else { + setAlertConfig(MIMIAN_VIDEO_CONFIG.alert); + } + setShowAlert(true); + } + }; + + /** + * 处理提示框关闭 + */ + const handleAlertClose = () => { + // 关闭提示框后展开线索 / 显示学习引导 + setShowAlert(false); + + // 从 alertConfig 的 title 判断是哪个文件 + if (alertConfig.title.includes("银元之战")) { + // 第一幕:"银元之战" 提示结束后,先展示学习引导弹窗 + setShowYinyuanGuide(true); + } else if (alertConfig.title.includes("米棉之战")) { + // 第二幕:"米棉之战" 提示结束后,同样先展示学习引导弹窗 + setShowMimianGuide(true); + } + }; + + /** + * 处理线索点击 + * @param clue 线索数据 + * @param clueKey 线索键名 + */ + const handleClueClick = (clue: ClueData, clueKey: string) => { + setCurrentClue(clue); + setCurrentClueKey(clueKey); + setShowClueOverlay(true); + }; + + /** + * 处理线索弹窗关闭 + */ + const handleClueOverlayClose = () => { + setShowClueOverlay(false); + // 标记当前线索为已查看 + if (currentClueKey) { + markClueAsViewed(currentClueKey as any); + } + setCurrentClue(null); + setCurrentClueKey(null); + }; + + /** + * 处理结束(返回首页) + */ + const handleEnd = () => { + if (moduleName && starName) { + navigate(`/${moduleName}/${starName}/home`); + } + }; + + return ( +
+ {/* 离开按钮 */} + {expandedFile == null && ( + { + if (moduleName && starName) { + navigate(`/${moduleName}/${starName}/home`); + } + }} + /> + )} + + {/* 桌子位置 */} +
+ {/* 银元之战模块 */} + handleFileClick("yinyuan")} + onClueClick={handleClueClick} + onPlayVideo={(fileType) => { + setPlayingVideo(fileType); + }} + onSolvedChange={setIsYinyuanSolved} + /> + + {/* 米棉之战模块 */} + handleFileClick("mimian")} + onClueClick={handleClueClick} + isAllViewed={isMimianAllViewed} + onCollapseFile={() => setExpandedFile(null)} + onEnd={handleEnd} + /> +
+ + {/* 拼图盒 */} + + + {/* 欢迎语层 - 使用 StudyGuideModal,仅承担学习引导/欢迎语 */} + { + setShowWelcomeGuide(false); + }} + > + {WELCOME_TEXT} + + + {/* 第一幕银元之战 - 学习引导弹窗 */} + { + setShowYinyuanGuide(false); + // 学习引导结束后再展开银元之战线索 + setExpandedFile("yinyuan"); + }} + verticalAlign="center" + > + 请你分别查阅一份电话录音和两份文件,并根据情报内容做出正确决策。 + + + {/* 第二幕米棉之战 - 学习引导弹窗 */} + { + setShowMimianGuide(false); + // 学习引导结束后再展开米棉之战线索 + setExpandedFile("mimian"); + }} + verticalAlign="center" + > + 请你分别查阅一个消息记录和两份文件,并根据情报内容做出正确决策。 + + + {/* 米棉之战锁定错误提示 - 使用 ErrorAlert 组件 */} + setShowMimianLockError(false)} + onMaskClick={() => setShowMimianLockError(false)} + /> + + {/* 视频处理组件 */} + + + {/* 展示线索 */} + {currentClue && ( + + )} +
+ ); +}; + +export default ChenyunReflection; diff --git a/src/pages/chenyun/reflection/mimian/MimianBattle.tsx b/src/pages/chenyun/reflection/mimian/MimianBattle.tsx new file mode 100644 index 0000000..c4178e1 --- /dev/null +++ b/src/pages/chenyun/reflection/mimian/MimianBattle.tsx @@ -0,0 +1,281 @@ +/** + * 米棉之战主组件 + * 整合米棉之战的所有子组件:文件、线索、题目 + */ +import { useEffect } from "react"; +import { MimianFile } from "./MimianFile"; +import { MimianClues } from "./MimianClues"; +import { MimianQuizzes } from "./MimianQuizzes"; +import { useMimianState } from "./useMimianState"; +import type { FileType } from "../shared/types"; +import type { ClueData } from "@/components/layout"; + +export interface MimianBattleProps { + /** 当前展开的文件 */ + expandedFile: FileType | null; + /** 线索状态 */ + clueStates: Record; + /** 文件点击回调 */ + onFileClick: () => void; + /** 线索点击回调 */ + onClueClick: (clue: ClueData, clueKey: string) => void; + /** 是否所有线索都已查看 */ + isAllViewed: boolean; + /** 收起文件回调 */ + onCollapseFile: () => void; + /** 结束回调(返回首页) */ + onEnd: () => void; +} + +/** + * 米棉之战主组件 + * 管理米棉之战的所有状态和逻辑 + */ +export const MimianBattle = ({ + expandedFile, + clueStates, + onFileClick, + onClueClick, + isAllViewed, + onCollapseFile, + onEnd, +}: MimianBattleProps) => { + const state = useMimianState(); + + // 如果所有米棉之战线索都已查看,自动显示单选题1 + useEffect(() => { + if (isAllViewed && !state.showMimianQuiz1) { + state.setShowMimianQuiz1(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAllViewed]); + + // 处理单选题1提交 + const handleQuiz1Submit = (value: string, isCorrect: boolean) => { + if (isCorrect) { + state.setShowMimianQuiz1(false); + state.setShowMimianSuccessAlert(true); + state.setMimianSuccessAlertContent("恭喜你,选择正确!"); + } + }; + + // 处理成功提示框1继续 + const handleSuccessAlert1Continue = () => { + state.setShowMimianSuccessAlert(false); + state.setMimianSuccessAlertContent(""); + // 显示旁白1(图2) + state.setShowMimianNarration1(true); + }; + + // 处理旁白1关闭 + const handleNarration1Close = () => { + state.setShowMimianNarration1(false); + // 显示单选题2 + state.setShowMimianQuiz2(true); + }; + + // 处理单选题2提交 + const handleQuiz2Submit = (value: string, isCorrect: boolean) => { + if (isCorrect) { + state.setShowMimianQuiz2(false); + state.setShowMimianSuccessAlert(true); + state.setMimianSuccessAlertContent( + `恭喜你,选择正确!结合当时市场情况,陈云同志在会上做出了"全国一盘棋"的指示。全国统筹调运粮食、棉花、煤炭填补缺口,筑牢物资基础。关键节点集中抛售,让跟风囤货的投机商无力承接,因亏本和高利贷纷纷破产,快速平抑物价、稳固人民币地位。` + ); + } else { + // 根据选项显示不同的错误信息 + if (value === "A") { + state.setShowMimianSuccessAlert(true); + state.setMimianSuccessAlertContent( + "很抱歉,你选错了!当时物价上涨已波及多地区,局部限购无法阻止投机商转移物资炒作。上海米、棉依赖外部供应,本地物资匮乏,限购既不解决缺口,还会加剧民众恐慌,扰乱市场秩序。请你再思考一下吧!" + ); + } else if (value === "B") { + state.setShowMimianSuccessAlert(true); + state.setMimianSuccessAlertContent( + "很抱歉,你选错了!当时国民党滥发货币导致的贬值阴影未消,民众对新货币缺乏信任。此时增发人民币,会使货币供应量远超物资总量,重蹈贬值覆辙,削弱人民币信用,让投机气焰更盛。请你再思考一下吧!" + ); + } + } + }; + + // 处理成功提示框2继续 + const handleSuccessAlert2Continue = () => { + state.setShowMimianSuccessAlert(false); + state.setMimianSuccessAlertContent(""); + // 显示旁白2(图4) + state.setShowMimianNarration2(true); + }; + + // 处理错误提示框继续 + const handleErrorAlertContinue = () => { + state.setShowMimianSuccessAlert(false); + state.setMimianSuccessAlertContent(""); + // 重新显示单选题2 + state.setShowMimianQuiz2(true); + }; + + // 处理旁白2关闭 + const handleNarration2Close = () => { + state.setShowMimianNarration2(false); + // 显示单选题3 + state.setShowMimianQuiz3(true); + }; + + // 处理单选题3提交 + const handleQuiz3Submit = (value: string, isCorrect: boolean) => { + if (isCorrect) { + state.setShowMimianQuiz3(false); + state.setShowMimianSuccessAlert(true); + state.setMimianSuccessAlertContent( + "恭喜你,选择正确!地方配合,调运按时推进。" + ); + } else { + state.setShowMimianQuiz3(false); + state.setShowMimianSuccessAlert(true); + state.setMimianSuccessAlertContent( + "很抱歉,你选错了!地方抵触,调运延误多天。请你再思考一下吧!" + ); + } + }; + + // 处理单选题3成功/错误提示继续 + const handleQuiz3AlertContinue = (isCorrect: boolean) => { + state.setShowMimianSuccessAlert(false); + state.setMimianSuccessAlertContent(""); + if (isCorrect) { + // 答对后显示单选题4 + state.setShowMimianQuiz4(true); + } else { + // 答错后重新显示单选题3 + state.setShowMimianQuiz3(true); + } + }; + + // 处理单选题4提交 + const handleQuiz4Submit = (value: string, isCorrect: boolean) => { + if (isCorrect) { + state.setShowMimianQuiz4(false); + state.setShowMimianSuccessAlert(true); + state.setMimianSuccessAlertContent( + "恭喜你,选择正确!棉农支持,调运量足额完成。" + ); + } else { + state.setShowMimianQuiz4(false); + state.setShowMimianSuccessAlert(true); + state.setMimianSuccessAlertContent( + "很抱歉,你选错了!棉农抵触,调运量减少。请你再思考一下吧!" + ); + } + }; + + // 处理单选题4成功/错误提示继续 + const handleQuiz4AlertContinue = (isCorrect: boolean) => { + state.setShowMimianSuccessAlert(false); + state.setMimianSuccessAlertContent(""); + if (isCorrect) { + // 答对后显示旁白3 + state.setShowMimianNarration3(true); + } else { + // 答错后重新显示单选题4 + state.setShowMimianQuiz4(true); + } + }; + + // 处理旁白3关闭 + const handleNarration3Close = () => { + state.setShowMimianNarration3(false); + // 显示单选题5 + state.setShowMimianQuiz5(true); + }; + + // 处理单选题5提交 + const handleQuiz5Submit = (value: string, isCorrect: boolean) => { + if (isCorrect) { + // 标记米棉之战为已解决,并收起文件 + state.setIsMimianSolved(true); + onCollapseFile(); + + state.setShowMimianQuiz5(false); + state.setShowMimianSuccessAlert(true); + state.setMimianSuccessAlertContent( + "恭喜你,选择正确!这契合米棉之战史实,多地同步大量抛售,让借高利贷囤货的投机商措手不及。连续抛售使其无力承接,只得亏本抛售,最终纷纷破产,快速稳住物价。" + ); + } else { + state.setShowMimianQuiz5(false); + state.setShowMimianSuccessAlert(true); + state.setMimianSuccessAlertContent( + "很抱歉,你选错了!投机商此前曾吃下政府平价抛售物资获利。逐步降价加舆论引导难让其信服,他们会持续囤货,政府储备会被消耗,反而让投机商掌控节奏,无法遏制物价上涨。请你再思考一下吧!" + ); + } + }; + + // 处理单选题5成功/错误提示继续 + const handleQuiz5AlertContinue = (isEndSuccess: boolean) => { + state.setShowMimianSuccessAlert(false); + state.setMimianSuccessAlertContent(""); + + if (isEndSuccess) { + // 成功结束:保持 expandedFile 为 null,1 秒后弹出结束语 StudyGuideModal + setTimeout(() => { + state.setShowMimianNarration5(true); + }, 1000); + } else { + // 答错后重新显示单选题5 + state.setShowMimianQuiz5(true); + } + }; + + // 处理结束语继续 + const handleNarration5Continue = () => { + state.setShowMimianNarration5(false); + onEnd(); + }; + + return ( + <> + {/* 文件组件 */} + + + {/* 线索组件 */} + + + {/* 题目组件 */} + + + ); +}; diff --git a/src/pages/chenyun/reflection/mimian/MimianClues.tsx b/src/pages/chenyun/reflection/mimian/MimianClues.tsx new file mode 100644 index 0000000..85a13af --- /dev/null +++ b/src/pages/chenyun/reflection/mimian/MimianClues.tsx @@ -0,0 +1,130 @@ +/** + * 米棉之战线索区域组件 + * 包含投机商情报、全国物资分布、对话的展示 + */ +import { Image } from "@/components"; +import { cn, getStaticImage } from "@/lib/utils"; +import { ClueItem } from "../shared/ClueItem"; +import type { FileType } from "../shared/types"; +import type { ClueData } from "@/components/layout"; +import { MIMIAN_CLUE_DATA } from "../constants/mimian"; + +export interface MimianCluesProps { + /** 当前展开的文件 */ + expandedFile: FileType | null; + /** 线索状态 */ + clueStates: Record; + /** 线索点击回调 */ + onClueClick: (clue: ClueData, clueKey: string) => void; +} + +/** + * 米棉之战线索区域组件 + * 显示投机商情报、全国物资分布、对话 + */ +export const MimianClues = ({ + expandedFile, + clueStates, + onClueClick, +}: MimianCluesProps) => { + const isExpanded = expandedFile === "mimian"; + + return ( + <> + {/* 米棉之战:线索-投机商情报 */} +
+ { + const clue = MIMIAN_CLUE_DATA["mimian-intelligence"]; + if (clue) { + onClueClick(clue, "mimian-intelligence"); + } + }} + viewedMarkOffset={{ left: "4px" }} + /> +
+ + {/* 米棉之战:线索-全国物资分布 */} +
+ { + const clue = MIMIAN_CLUE_DATA["mimian-distribution"]; + if (clue) { + onClueClick(clue, "mimian-distribution"); + } + }} + viewedMarkOffset={{ left: "12px" }} + /> +
+ + {/* 米棉之战:消息(对话线索) */} +
+
{ + const clue = MIMIAN_CLUE_DATA["mimian-dialogue"]; + if (clue) { + onClueClick(clue, "mimian-dialogue"); + } + }} + > + + {/* 未倾听时显示三个点动画 */} + {!clueStates["mimian-dialogue"] && ( +
+
+ + + +
+
+ )} + {/* 已倾听标记 */} + {clueStates["mimian-dialogue"] && ( +
+ + 已倾听 + +
+ )} +
+
+ + ); +}; + diff --git a/src/pages/chenyun/reflection/mimian/MimianFile.tsx b/src/pages/chenyun/reflection/mimian/MimianFile.tsx new file mode 100644 index 0000000..6a37245 --- /dev/null +++ b/src/pages/chenyun/reflection/mimian/MimianFile.tsx @@ -0,0 +1,51 @@ +/** + * 米棉之战文件组件 + * 包含文件展示 + */ +import { Image } from "@/components"; +import { cn, getStaticImage } from "@/lib/utils"; +import type { FileType } from "../shared/types"; + +export interface MimianFileProps { + /** 当前展开的文件 */ + expandedFile: FileType | null; + /** 是否已解决 */ + isSolved: boolean; + /** 文件点击回调 */ + onFileClick: () => void; +} + +/** + * 米棉之战文件组件 + * 显示文件2,包含解决状态 + */ +export const MimianFile = ({ + expandedFile, + isSolved, + onFileClick, +}: MimianFileProps) => { + return ( +
+ + + {isSolved ? "02米棉之战(已解决)" : "02米棉之战(未解决)"} + +
+ ); +}; + diff --git a/src/pages/chenyun/reflection/mimian/MimianQuizzes.tsx b/src/pages/chenyun/reflection/mimian/MimianQuizzes.tsx new file mode 100644 index 0000000..7f4a284 --- /dev/null +++ b/src/pages/chenyun/reflection/mimian/MimianQuizzes.tsx @@ -0,0 +1,299 @@ +/** + * 米棉之战题目组件 + * 包含单选题1-5、旁白1-3、成功提示框、结束语 + */ +import { Narration, SingleChoiceQuizPanel, StudyGuideModal } from "@/components"; +import SuccessAlert from "@/components/layout/alert/success-alert"; + +export interface MimianQuizzesProps { + /** 是否显示单选题1 */ + showQuiz1: boolean; + /** 是否显示旁白1 */ + showNarration1: boolean; + /** 是否显示单选题2 */ + showQuiz2: boolean; + /** 是否显示旁白2 */ + showNarration2: boolean; + /** 是否显示单选题3 */ + showQuiz3: boolean; + /** 是否显示单选题4 */ + showQuiz4: boolean; + /** 是否显示旁白3 */ + showNarration3: boolean; + /** 是否显示单选题5 */ + showQuiz5: boolean; + /** 是否显示旁白5(结束语) */ + showNarration5: boolean; + /** 是否显示成功提示框 */ + showSuccessAlert: boolean; + /** 成功提示框内容 */ + successAlertContent: string; + /** 单选题1提交回调 */ + onQuiz1Submit: (value: string, isCorrect: boolean) => void; + /** 成功提示框1继续回调 */ + onSuccessAlert1Continue: () => void; + /** 旁白1关闭回调 */ + onNarration1Close: () => void; + /** 单选题2提交回调 */ + onQuiz2Submit: (value: string, isCorrect: boolean) => void; + /** 成功提示框2继续回调 */ + onSuccessAlert2Continue: () => void; + /** 错误提示框继续回调 */ + onErrorAlertContinue: () => void; + /** 旁白2关闭回调 */ + onNarration2Close: () => void; + /** 单选题3提交回调 */ + onQuiz3Submit: (value: string, isCorrect: boolean) => void; + /** 单选题3成功/错误提示继续回调 */ + onQuiz3AlertContinue: (isCorrect: boolean) => void; + /** 单选题4提交回调 */ + onQuiz4Submit: (value: string, isCorrect: boolean) => void; + /** 单选题4成功/错误提示继续回调 */ + onQuiz4AlertContinue: (isCorrect: boolean) => void; + /** 旁白3关闭回调 */ + onNarration3Close: () => void; + /** 单选题5提交回调 */ + onQuiz5Submit: (value: string, isCorrect: boolean) => void; + /** 单选题5成功/错误提示继续回调 */ + onQuiz5AlertContinue: (isEndSuccess: boolean) => void; + /** 结束语继续回调 */ + onNarration5Continue: () => void; +} + +/** + * 米棉之战题目组件 + * 显示所有题目、旁白和提示框 + */ +export const MimianQuizzes = ({ + showQuiz1, + showNarration1, + showQuiz2, + showNarration2, + showQuiz3, + showQuiz4, + showNarration3, + showQuiz5, + showNarration5, + showSuccessAlert, + successAlertContent, + onQuiz1Submit, + onSuccessAlert1Continue, + onNarration1Close, + onQuiz2Submit, + onSuccessAlert2Continue, + onErrorAlertContinue, + onNarration2Close, + onQuiz3Submit, + onQuiz3AlertContinue, + onQuiz4Submit, + onQuiz4AlertContinue, + onNarration3Close, + onQuiz5Submit, + onQuiz5AlertContinue, + onNarration5Continue, +}: MimianQuizzesProps) => { + return ( + <> + {/* 米棉之战 - 单选题1(图1) */} + + + {/* 米棉之战 - 成功提示框1 */} + + + {/* 米棉之战 - 旁白1(图2) */} + + + {/* 米棉之战 - 单选题2(图3) */} + + + {/* 米棉之战 - 成功提示框2(单选题2答对后) */} + + + {/* 米棉之战 - 错误提示框(单选题2答错后) */} + + + {/* 米棉之战 - 旁白2(图4) */} + + + {/* 米棉之战 - 单选题3(图5) */} + + + {/* 米棉之战 - 单选题3成功/错误提示 */} + { + const isCorrect = successAlertContent.includes("地方配合"); + onQuiz3AlertContinue(isCorrect); + }} + /> + + {/* 米棉之战 - 单选题4(图6) */} + + + {/* 米棉之战 - 单选题4成功/错误提示 */} + { + const isCorrect = successAlertContent.includes("棉农支持"); + onQuiz4AlertContinue(isCorrect); + }} + /> + + {/* 米棉之战 - 旁白3(图7) */} + + + {/* 米棉之战 - 单选题5(图8) */} + + + {/* 米棉之战 - 单选题5成功/错误提示 */} + { + const isEndSuccess = + successAlertContent.includes("这契合米棉之战史实"); + onQuiz5AlertContinue(isEndSuccess); + }} + /> + + {/* 米棉之战 - 旁白5(结束语)改用 StudyGuideModal 展示 */} + + 从"银元之战"查封投机枢纽、稳固人民币根基,到"米棉之战"全国统筹调运、集中抛售击溃投机势力,这场经济战线的"淮海战役",不仅平定了物价、安定了民心,更印证了"全国一盘棋"的战略智慧与制度优势。当市场秩序重归稳定,新生政权在经济领域站稳脚跟,这份由陈云同志等老一辈革命家铸就的治理经验,也为新时代经济建设埋下了思想伏笔,等待我们在传承中探索新的实践路径。 + + + ); +}; + diff --git a/src/pages/chenyun/reflection/mimian/useMimianState.ts b/src/pages/chenyun/reflection/mimian/useMimianState.ts new file mode 100644 index 0000000..dcfdc68 --- /dev/null +++ b/src/pages/chenyun/reflection/mimian/useMimianState.ts @@ -0,0 +1,81 @@ +/** + * 米棉之战状态管理 Hook + * 管理米棉之战相关的所有状态 + */ +import { useState } from "react"; + +/** + * 米棉之战状态管理 Hook + * @returns 米棉之战的所有状态和更新函数 + */ +export const useMimianState = () => { + // 单选题1相关状态 + const [showMimianQuiz1, setShowMimianQuiz1] = useState(false); + + // 旁白1相关状态 + const [showMimianNarration1, setShowMimianNarration1] = useState(false); + + // 单选题2相关状态 + const [showMimianQuiz2, setShowMimianQuiz2] = useState(false); + + // 旁白2相关状态 + const [showMimianNarration2, setShowMimianNarration2] = useState(false); + + // 单选题3相关状态 + const [showMimianQuiz3, setShowMimianQuiz3] = useState(false); + + // 单选题4相关状态 + const [showMimianQuiz4, setShowMimianQuiz4] = useState(false); + + // 旁白3相关状态 + const [showMimianNarration3, setShowMimianNarration3] = useState(false); + + // 单选题5相关状态 + const [showMimianQuiz5, setShowMimianQuiz5] = useState(false); + + // 旁白5相关状态(结束语) + const [showMimianNarration5, setShowMimianNarration5] = useState(false); + + // 米棉之战解决状态 + const [isMimianSolved, setIsMimianSolved] = useState(false); + + // 米棉之战成功提示框状态 + const [showMimianSuccessAlert, setShowMimianSuccessAlert] = useState(false); + const [mimianSuccessAlertContent, setMimianSuccessAlertContent] = + useState(""); + + return { + // 题目相关状态 + showMimianQuiz1, + setShowMimianQuiz1, + showMimianQuiz2, + setShowMimianQuiz2, + showMimianQuiz3, + setShowMimianQuiz3, + showMimianQuiz4, + setShowMimianQuiz4, + showMimianQuiz5, + setShowMimianQuiz5, + + // 旁白相关状态 + showMimianNarration1, + setShowMimianNarration1, + showMimianNarration2, + setShowMimianNarration2, + showMimianNarration3, + setShowMimianNarration3, + showMimianNarration5, + setShowMimianNarration5, + + // 成功提示框状态 + showMimianSuccessAlert, + setShowMimianSuccessAlert, + mimianSuccessAlertContent, + setMimianSuccessAlertContent, + + // 解决状态 + isMimianSolved, + setIsMimianSolved, + }; +}; + diff --git a/src/pages/chenyun/reflection/shared/ClueItem.tsx b/src/pages/chenyun/reflection/shared/ClueItem.tsx new file mode 100644 index 0000000..3e6eed1 --- /dev/null +++ b/src/pages/chenyun/reflection/shared/ClueItem.tsx @@ -0,0 +1,86 @@ +/** + * 线索项组件 + * 可复用的线索展示组件,处理线索的点击、已浏览标记等 + */ +import { Image } from "@/components"; +import { cn, getStaticImage } from "@/lib/utils"; +import type { ClueKey } from "./types"; + +export interface ClueItemProps { + /** 线索键名 */ + clueKey: ClueKey; + /** 线索图片路径 */ + imageSrc: string; + /** 是否已浏览 */ + isViewed: boolean; + /** 点击回调 */ + onClick: () => void; + /** 自定义类名 */ + className?: string; + /** 已浏览标记的位置偏移 */ + viewedMarkOffset?: { left?: string; top?: string; bottom?: string }; + /** 已浏览标记文本,默认为"已浏览" */ + viewedText?: string; + /** 线索类型:普通线索或电话线索 */ + type?: "normal" | "phone"; +} + +/** + * 线索项组件 + * 显示线索图片、搜索动画(未浏览时)、已浏览标记(已浏览时) + */ +export const ClueItem = ({ + clueKey, + imageSrc, + isViewed, + onClick, + className, + viewedMarkOffset = {}, + viewedText = "已浏览", + type = "normal", +}: ClueItemProps) => { + // 根据线索类型确定搜索图标的位置 + const searchIconPosition = + type === "phone" ? "left-[45%] top-[45%]" : "left-1/2 top-[45%]"; + + return ( +
+ {/* 线索图片 */} + + + {/* 搜索图标 - 未浏览时显示 */} + {!isViewed && ( + + )} + + {/* 已浏览标记 */} + {isViewed && ( +
+ + {viewedText} + +
+ )} +
+ ); +}; + diff --git a/src/pages/chenyun/reflection/shared/VideoHandler.tsx b/src/pages/chenyun/reflection/shared/VideoHandler.tsx new file mode 100644 index 0000000..5388ce4 --- /dev/null +++ b/src/pages/chenyun/reflection/shared/VideoHandler.tsx @@ -0,0 +1,88 @@ +/** + * 视频处理组件 + * 处理视频播放、旁白、提示框的展示逻辑 + */ +import { Video, Narration } from "@/components"; +import Alert from "@/components/layout/alert/title-alert"; +import type { FileType } from "./types"; +import { YINYUAN_VIDEO_CONFIG } from "../constants/yinyuan"; +import { MIMIAN_VIDEO_CONFIG } from "../constants/mimian"; + +export interface VideoHandlerProps { + /** 当前播放的视频类型 */ + playingVideo: FileType | null; + /** 旁白文本 */ + narrationText: string; + /** 是否显示旁白 */ + showNarration: boolean; + /** 是否显示提示框 */ + showAlert: boolean; + /** 提示框配置 */ + alertConfig: { title: string; subtitle: string }; + /** 视频结束回调 */ + onVideoEnd: () => void; + /** 旁白关闭回调 */ + onNarrationClose: () => void; + /** 提示框关闭回调 */ + onAlertClose: () => void; +} + +/** + * 视频处理组件 + * 根据 playingVideo 显示对应的视频,处理视频结束后的旁白和提示框 + */ +export const VideoHandler = ({ + playingVideo, + narrationText, + showNarration, + showAlert, + alertConfig, + onVideoEnd, + onNarrationClose, + onAlertClose, +}: VideoHandlerProps) => { + // 获取当前视频配置 + const getVideoConfig = () => { + if (playingVideo === "yinyuan") return YINYUAN_VIDEO_CONFIG; + if (playingVideo === "mimian") return MIMIAN_VIDEO_CONFIG; + return null; + }; + + const videoConfig = getVideoConfig(); + + return ( + <> + {/* 视频播放层 */} + {playingVideo && videoConfig && ( +
+
+ )} + + {/* 旁白层(仅用于视频后的旁白,不再包含欢迎语) */} + + + {/* 提示框 */} + {showAlert && ( + + )} + + ); +}; + diff --git a/src/pages/chenyun/reflection/shared/types.ts b/src/pages/chenyun/reflection/shared/types.ts new file mode 100644 index 0000000..549edb6 --- /dev/null +++ b/src/pages/chenyun/reflection/shared/types.ts @@ -0,0 +1,20 @@ +/** + * 共享类型定义 + * 定义 reflection 模块中使用的通用类型 + */ + +/** 文件类型:银元之战或米棉之战 */ +export type FileType = "yinyuan" | "mimian"; + +/** 线索键名类型 */ +export type ClueKey = + | "yinyuan-clue1" + | "yinyuan-clue2" + | "yinyuan-phone" + | "mimian-intelligence" + | "mimian-distribution" + | "mimian-dialogue"; + +/** 线索状态记录 */ +export type ClueStates = Record; + diff --git a/src/pages/chenyun/reflection/shared/useClueStates.ts b/src/pages/chenyun/reflection/shared/useClueStates.ts new file mode 100644 index 0000000..87bf3c2 --- /dev/null +++ b/src/pages/chenyun/reflection/shared/useClueStates.ts @@ -0,0 +1,83 @@ +/** + * 线索状态管理 Hook + * 管理所有线索的查看状态,并提供计算属性 + */ +import { useState, useMemo } from "react"; +import type { ClueStates, ClueKey } from "./types"; + +/** + * 初始化线索状态 + */ +const initialClueStates: ClueStates = { + "yinyuan-clue1": false, + "yinyuan-clue2": false, + "yinyuan-phone": false, + "mimian-intelligence": false, + "mimian-distribution": false, + "mimian-dialogue": false, +}; + +/** + * 线索状态管理 Hook + * @returns 线索状态和计算属性 + */ +export const useClueStates = () => { + // 记录每个线索的查看状态 + const [clueStates, setClueStates] = useState(initialClueStates); + + /** + * 判断银元之战的所有线索是否都已查看 + * 需要查看:线索1、线索2、电话 + */ + const isYinyuanAllViewed = useMemo(() => { + return ( + clueStates["yinyuan-clue1"] && + clueStates["yinyuan-clue2"] && + clueStates["yinyuan-phone"] + ); + }, [clueStates]); + + /** + * 判断米棉之战的所有线索是否都已查看 + * 需要查看:投机商情报、全国物资分布、对话 + */ + const isMimianAllViewed = useMemo(() => { + return ( + clueStates["mimian-intelligence"] && + clueStates["mimian-distribution"] && + clueStates["mimian-dialogue"] + ); + }, [clueStates]); + + /** + * 标记线索为已查看 + * @param clueKey 线索键名 + */ + const markClueAsViewed = (clueKey: ClueKey) => { + setClueStates((prev) => ({ + ...prev, + [clueKey]: true, + })); + }; + + /** + * 批量更新线索状态 + * @param updates 要更新的线索状态 + */ + const updateClueStates = (updates: Partial) => { + setClueStates((prev) => ({ + ...prev, + ...updates, + })); + }; + + return { + clueStates, + setClueStates, + isYinyuanAllViewed, + isMimianAllViewed, + markClueAsViewed, + updateClueStates, + }; +}; + diff --git a/src/pages/chenyun/reflection/yinyuan/YinyuanBattle.tsx b/src/pages/chenyun/reflection/yinyuan/YinyuanBattle.tsx new file mode 100644 index 0000000..340bd63 --- /dev/null +++ b/src/pages/chenyun/reflection/yinyuan/YinyuanBattle.tsx @@ -0,0 +1,238 @@ +/** + * 银元之战主组件 + * 整合银元之战的所有子组件:文件、线索、题目、引导提示 + */ +import { useEffect } from "react"; +import { YinyuanFile } from "./YinyuanFile"; +import { YinyuanClues } from "./YinyuanClues"; +import { YinyuanQuizzes } from "./YinyuanQuizzes"; +import { YinyuanGuides } from "./YinyuanGuides"; +import { useYinyuanState } from "./useYinyuanState"; +import type { FileType } from "../shared/types"; +import type { ClueData } from "@/components/layout"; + +export interface YinyuanBattleProps { + /** 当前展开的文件 */ + expandedFile: FileType | null; + /** 是否所有线索都已查看 */ + isAllViewed: boolean; + /** 线索状态 */ + clueStates: Record; + /** 文件点击回调 */ + onFileClick: () => void; + /** 线索点击回调 */ + onClueClick: (clue: ClueData, clueKey: string) => void; + /** 播放视频回调 */ + onPlayVideo: (fileType: FileType) => void; + /** 银元之战解决状态变化回调 */ + onSolvedChange?: (solved: boolean) => void; +} + +/** + * 银元之战主组件 + * 管理银元之战的所有状态和逻辑 + */ +export const YinyuanBattle = ({ + expandedFile, + isAllViewed, + clueStates, + onFileClick, + onClueClick, + onPlayVideo, + onSolvedChange, +}: YinyuanBattleProps) => { + const state = useYinyuanState(); + + // 当银元之战解决状态变化时,通知父组件 + useEffect(() => { + if (onSolvedChange) { + onSolvedChange(state.isYinyuanSolved); + } + }, [state.isYinyuanSolved, onSolvedChange]); + + // 监听"前往解决"按钮出现的条件,显示第一个提示 + useEffect(() => { + const shouldShowGuide1 = + isAllViewed && + !state.isYinyuanSolved && + !state.showQuiz1 && + !state.showNarration2 && + !state.showQuiz2 && + !state.showSuccessAlert && + !state.hasShownGuide1; + + if (shouldShowGuide1) { + // 延迟一下,确保按钮已经渲染 + const timer = setTimeout(() => { + state.setShowGuide1(true); + state.setHasShownGuide1(true); + }, 300); + return () => clearTimeout(timer); + } + }, [ + isAllViewed, + state.isYinyuanSolved, + state.showQuiz1, + state.showNarration2, + state.showQuiz2, + state.showSuccessAlert, + state.hasShownGuide1, + state.setShowGuide1, + state.setHasShownGuide1, + ]); + + // 监听走势图图标出现的条件,显示第二个提示 + useEffect(() => { + const shouldShowGuide2 = + state.isYinyuanSolved && + state.showTrendChartIcon && + !state.hasShownGuide2; + + if (shouldShowGuide2) { + // 延迟一下,确保走势图已经渲染 + const timer = setTimeout(() => { + state.setShowGuide2(true); + state.setHasShownGuide2(true); + }, 300); + return () => clearTimeout(timer); + } + }, [ + state.isYinyuanSolved, + state.showTrendChartIcon, + state.hasShownGuide2, + state.setShowGuide2, + state.setHasShownGuide2, + ]); + + // 监听走势图大图打开的条件,显示第三个提示 + useEffect(() => { + const shouldShowGuide3 = + state.showTrendChartDetail && !state.hasShownGuide3; + + if (shouldShowGuide3) { + // 延迟一下,确保大图已经打开 + const timer = setTimeout(() => { + state.setShowGuide3(true); + state.setHasShownGuide3(true); + }, 300); + return () => clearTimeout(timer); + } + }, [ + state.showTrendChartDetail, + state.hasShownGuide3, + state.setShowGuide3, + state.setHasShownGuide3, + ]); + + // 处理多选题1提交 + const handleQuiz1Submit = (values: string[], isCorrect: boolean) => { + if (isCorrect) { + // 关闭多选题1,显示旁白文字(图2) + state.setShowQuiz1(false); + state.setNarration2Text( + `六月五日,上海政府抛出十万银元,力图以银元制服银元,使价格回跌。全市举行了"反对银元投机,保障人民生活"的游行和宣传。上海市军委会主任陈毅在全市集会上警告投机奸商"赶快洗手不干,否则勿谓言之不预"。但这两手收效仍微。十万银元被投机商人一吸而空,六月七日银元每元的价格涨到一千八百元人民币。陈云同志判断:'单纯的经济手段已不足以震慑敌人,必须辅以坚定的行政与法律手段。` + ); + state.setShowNarration2(true); + } + }; + + // 处理旁白2关闭 + const handleNarration2Close = () => { + state.setShowNarration2(false); + // 关闭旁白后显示多选题2 + state.setShowQuiz2(true); + }; + + // 处理多选题2提交 + const handleQuiz2Submit = (values: string[], isCorrect: boolean) => { + if (isCorrect) { + // 关闭多选题2,显示成功提示框 + state.setShowQuiz2(false); + state.setShowSuccessAlert(true); + } + }; + + // 处理成功提示框继续 + const handleSuccessAlertContinue = () => { + // 关闭聚光灯、标记银元之战为已解决、显示走势图 + state.setIsYinyuanSolved(true); + state.setShowSuccessAlert(false); + }; + + // 处理走势图点击 + const handleTrendChartClick = () => { + // 关闭第三个提示框 + state.setShowGuide3(false); + // 点击走势图:隐藏文件上的走势图图标,播放米棉之战视频 + state.setShowTrendChartIcon(false); + state.setShowTrendChartDetail(false); + // 播放米棉之战视频 + onPlayVideo("mimian"); + }; + + // 处理走势图大图关闭 + const handleTrendChartDetailClose = () => { + // 关闭第三个提示框 + state.setShowGuide3(false); + state.setShowTrendChartDetail(false); + }; + + return ( + <> + {/* 文件组件 */} + { + state.setShowGuide2(false); + state.setShowTrendChartDetail(true); + }} + onSolveClick={() => { + state.setShowGuide1(false); + state.setShowQuiz1(true); + }} + /> + + {/* 线索组件 */} + + + {/* 题目组件 */} + + + {/* 引导提示组件 */} + state.setShowGuide1(false)} + onGuide2Close={() => state.setShowGuide2(false)} + onGuide3Close={() => state.setShowGuide3(false)} + onTrendChartDetailClose={handleTrendChartDetailClose} + onTrendChartClick={handleTrendChartClick} + /> + + ); +}; + diff --git a/src/pages/chenyun/reflection/yinyuan/YinyuanClues.tsx b/src/pages/chenyun/reflection/yinyuan/YinyuanClues.tsx new file mode 100644 index 0000000..e1db459 --- /dev/null +++ b/src/pages/chenyun/reflection/yinyuan/YinyuanClues.tsx @@ -0,0 +1,123 @@ +/** + * 银元之战线索区域组件 + * 包含线索1、线索2、电话的展示 + */ +import { Image } from "@/components"; +import { cn, getStaticImage } from "@/lib/utils"; +import { ClueItem } from "../shared/ClueItem"; +import type { FileType } from "../shared/types"; +import type { ClueData } from "@/components/layout"; +import { YINYUAN_CLUE_DATA } from "../constants/yinyuan"; + +export interface YinyuanCluesProps { + /** 当前展开的文件 */ + expandedFile: FileType | null; + /** 线索状态 */ + clueStates: Record; + /** 线索点击回调 */ + onClueClick: (clue: ClueData, clueKey: string) => void; +} + +/** + * 银元之战线索区域组件 + * 显示线索1、线索2和电话 + */ +export const YinyuanClues = ({ + expandedFile, + clueStates, + onClueClick, +}: YinyuanCluesProps) => { + const isExpanded = expandedFile === "yinyuan"; + + return ( + <> + {/* 银元之战:线索1 */} +
+ { + const clue = YINYUAN_CLUE_DATA["yinyuan-clue1"]; + if (clue) { + onClueClick(clue, "yinyuan-clue1"); + } + }} + /> +
+ + {/* 银元之战:线索2 */} +
+ { + const clue = YINYUAN_CLUE_DATA["yinyuan-clue2"]; + if (clue) { + onClueClick(clue, "yinyuan-clue2"); + } + }} + /> +
+ + {/* 电话 - 只有银元之战有 */} +
+
+ {/* 响铃图标 */} + {!clueStates["yinyuan-phone"] && isExpanded && ( + + )} + {/* 电话 */} +
{ + if (!isExpanded) return; + const clue = YINYUAN_CLUE_DATA["yinyuan-phone"]; + if (clue) { + onClueClick(clue, "yinyuan-phone"); + } + }} + > + +
+ {/* 已接听标记 */} + {clueStates["yinyuan-phone"] && isExpanded && ( +
+ + 已接听 + +
+ )} +
+
+ + ); +}; + diff --git a/src/pages/chenyun/reflection/yinyuan/YinyuanFile.tsx b/src/pages/chenyun/reflection/yinyuan/YinyuanFile.tsx new file mode 100644 index 0000000..bb7bbaa --- /dev/null +++ b/src/pages/chenyun/reflection/yinyuan/YinyuanFile.tsx @@ -0,0 +1,111 @@ +/** + * 银元之战文件组件 + * 包含文件展示、走势图、前往解决按钮 + */ +import { Image } from "@/components"; +import { cn, getStaticImage } from "@/lib/utils"; +import type { FileType } from "../shared/types"; + +export interface YinyuanFileProps { + /** 当前展开的文件 */ + expandedFile: FileType | null; + /** 是否已解决 */ + isSolved: boolean; + /** 是否显示走势图图标 */ + showTrendChartIcon: boolean; + /** 是否所有线索都已查看 */ + isAllViewed: boolean; + /** 是否显示题目1 */ + showQuiz1: boolean; + /** 是否显示旁白2 */ + showNarration2: boolean; + /** 是否显示题目2 */ + showQuiz2: boolean; + /** 是否显示成功提示框 */ + showSuccessAlert: boolean; + /** 文件点击回调 */ + onFileClick: () => void; + /** 走势图点击回调 */ + onTrendChartClick: () => void; + /** 前往解决按钮点击回调 */ + onSolveClick: () => void; +} + +/** + * 银元之战文件组件 + * 显示文件1,包含解决状态、走势图、前往解决按钮 + */ +export const YinyuanFile = ({ + expandedFile, + isSolved, + showTrendChartIcon, + isAllViewed, + showQuiz1, + showNarration2, + showQuiz2, + showSuccessAlert, + onFileClick, + onTrendChartClick, + onSolveClick, +}: YinyuanFileProps) => { + return ( +
+ + + {isSolved ? "01银元之战(已解决)" : "01银元之战(未解决)"} + + + {/* 走势图 - 银元之战解决后显示,点击后隐藏 */} + {isSolved && showTrendChartIcon && ( +
{ + e.stopPropagation(); + onTrendChartClick(); + }} + > + +
+ )} + + {/* 引导标志 - 只在所有线索查看完后显示-前往解决按钮-去做题 */} + {isAllViewed && + !isSolved && + !showQuiz1 && + !showNarration2 && + !showQuiz2 && + !showSuccessAlert && ( +
{ + e.stopPropagation(); + onSolveClick(); + }} + > + +
+ )} +
+ ); +}; + diff --git a/src/pages/chenyun/reflection/yinyuan/YinyuanGuides.tsx b/src/pages/chenyun/reflection/yinyuan/YinyuanGuides.tsx new file mode 100644 index 0000000..b76b0b0 --- /dev/null +++ b/src/pages/chenyun/reflection/yinyuan/YinyuanGuides.tsx @@ -0,0 +1,88 @@ +/** + * 银元之战引导提示组件 + * 包含三个引导提示:前往解决按钮、走势图、走势图大图 + */ +import { StudyGuideModal } from "@/components"; +import { ExhibitOverlay } from "@/components"; + +export interface YinyuanGuidesProps { + /** 是否显示引导提示1(前往解决按钮) */ + showGuide1: boolean; + /** 是否显示引导提示2(走势图) */ + showGuide2: boolean; + /** 是否显示引导提示3(走势图大图) */ + showGuide3: boolean; + /** 是否显示走势图大图 */ + showTrendChartDetail: boolean; + /** 引导提示1关闭回调 */ + onGuide1Close: () => void; + /** 引导提示2关闭回调 */ + onGuide2Close: () => void; + /** 引导提示3关闭回调 */ + onGuide3Close: () => void; + /** 走势图大图关闭回调 */ + onTrendChartDetailClose: () => void; + /** 走势图点击回调 */ + onTrendChartClick: () => void; +} + +/** + * 银元之战引导提示组件 + * 显示三个引导提示和走势图大图 + */ +export const YinyuanGuides = ({ + showGuide1, + showGuide2, + showGuide3, + showTrendChartDetail, + onGuide1Close, + onGuide2Close, + onGuide3Close, + onTrendChartDetailClose, + onTrendChartClick, +}: YinyuanGuidesProps) => { + return ( + <> + {/* 引导提示1:前往解决按钮出现后 */} + + 请跟随指示点击前往解决按钮 + + + {/* 引导提示2:走势图出现后 */} + + 请点击走势图 + + + {/* 引导提示3:走势图大图打开后 */} + + 请点击银元价格走势图 + + + {/* 走势图大图展示 - 使用 ExhibitOverlay */} + + + ); +}; + diff --git a/src/pages/chenyun/reflection/yinyuan/YinyuanQuizzes.tsx b/src/pages/chenyun/reflection/yinyuan/YinyuanQuizzes.tsx new file mode 100644 index 0000000..08164ea --- /dev/null +++ b/src/pages/chenyun/reflection/yinyuan/YinyuanQuizzes.tsx @@ -0,0 +1,106 @@ +/** + * 银元之战题目组件 + * 包含多选题1、多选题2、旁白2 + */ +import { Narration, MultipleChoiceQuizPanel } from "@/components"; +import SuccessAlert from "@/components/layout/alert/success-alert"; + +export interface YinyuanQuizzesProps { + /** 是否显示多选题1 */ + showQuiz1: boolean; + /** 是否显示旁白2 */ + showNarration2: boolean; + /** 旁白2文本 */ + narration2Text: string; + /** 是否显示多选题2 */ + showQuiz2: boolean; + /** 是否显示成功提示框 */ + showSuccessAlert: boolean; + /** 多选题1提交回调 */ + onQuiz1Submit: (values: string[], isCorrect: boolean) => void; + /** 旁白2关闭回调 */ + onNarration2Close: () => void; + /** 多选题2提交回调 */ + onQuiz2Submit: (values: string[], isCorrect: boolean) => void; + /** 成功提示框继续回调 */ + onSuccessAlertContinue: () => void; +} + +/** + * 银元之战题目组件 + * 显示多选题1、多选题2、旁白2和成功提示框 + */ +export const YinyuanQuizzes = ({ + showQuiz1, + showNarration2, + narration2Text, + showQuiz2, + showSuccessAlert, + onQuiz1Submit, + onNarration2Close, + onQuiz2Submit, + onSuccessAlertContinue, +}: YinyuanQuizzesProps) => { + return ( + <> + {/* 多选题1 - 点击引导图标后显示 */} + { + console.log("错误信息:", error); + }} + /> + + {/* 旁白文字2(图2)- 多选题1答对后显示 */} + + + {/* 多选题2(图3)- 关闭旁白后显示 */} + { + console.log("错误信息:", error); + }} + /> + + {/* 成功提示框 - 多选题2答对后显示 */} + + + ); +}; + diff --git a/src/pages/chenyun/reflection/yinyuan/useYinyuanState.ts b/src/pages/chenyun/reflection/yinyuan/useYinyuanState.ts new file mode 100644 index 0000000..785f037 --- /dev/null +++ b/src/pages/chenyun/reflection/yinyuan/useYinyuanState.ts @@ -0,0 +1,86 @@ +/** + * 银元之战状态管理 Hook + * 管理银元之战相关的所有状态 + */ +import { useState } from "react"; + +/** + * 银元之战状态管理 Hook + * @returns 银元之战的所有状态和更新函数 + */ +export const useYinyuanState = () => { + // 多选题1相关状态 + const [showQuiz1, setShowQuiz1] = useState(false); + + // 旁白文字状态(图2) + const [showNarration2, setShowNarration2] = useState(false); + const [narration2Text, setNarration2Text] = useState(""); + + // 多选题2相关状态 + const [showQuiz2, setShowQuiz2] = useState(false); + + // 成功提示框状态 + const [showSuccessAlert, setShowSuccessAlert] = useState(false); + + // 银元之战解决状态 + const [isYinyuanSolved, setIsYinyuanSolved] = useState(false); + + // 走势图图标显示状态(控制文件上的走势图图标是否显示) + const [showTrendChartIcon, setShowTrendChartIcon] = useState(true); + + // 走势图大图显示状态 + const [showTrendChartDetail, setShowTrendChartDetail] = useState(false); + + // 三个引导提示框状态 + const [showGuide1, setShowGuide1] = useState(false); // 前往解决按钮出现后的提示 + const [showGuide2, setShowGuide2] = useState(false); // 走势图出现后的提示 + const [showGuide3, setShowGuide3] = useState(false); // 走势图大图打开后的提示 + + // 记录是否已经显示过这些提示(避免重复显示) + const [hasShownGuide1, setHasShownGuide1] = useState(false); + const [hasShownGuide2, setHasShownGuide2] = useState(false); + const [hasShownGuide3, setHasShownGuide3] = useState(false); + + return { + // 题目相关状态 + showQuiz1, + setShowQuiz1, + showQuiz2, + setShowQuiz2, + + // 旁白相关状态 + showNarration2, + setShowNarration2, + narration2Text, + setNarration2Text, + + // 成功提示框状态 + showSuccessAlert, + setShowSuccessAlert, + + // 解决状态 + isYinyuanSolved, + setIsYinyuanSolved, + + // 走势图相关状态 + showTrendChartIcon, + setShowTrendChartIcon, + showTrendChartDetail, + setShowTrendChartDetail, + + // 引导提示状态 + showGuide1, + setShowGuide1, + showGuide2, + setShowGuide2, + showGuide3, + setShowGuide3, + hasShownGuide1, + setHasShownGuide1, + hasShownGuide2, + setHasShownGuide2, + hasShownGuide3, + setHasShownGuide3, + }; +}; + diff --git a/src/pages/huangyanpei/internalization.tsx b/src/pages/huangyanpei/internalization.tsx index 304dc0d..76f526e 100644 --- a/src/pages/huangyanpei/internalization.tsx +++ b/src/pages/huangyanpei/internalization.tsx @@ -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(null); + const [successContent, setSuccessContent] = useState(""); + const [resumeCallback, setResumeCallback] = useState<(() => void) | null>( + null + ); + const [dragQuizValues, setDragQuizValues] = useState< + Record + >({}); + const [dragQuizErrors, setDragQuizErrors] = useState>( + {} + ); + 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(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 ( + <> + ({ + value: opt.value, + label: opt.label, + }))} + blanks={item.blanks || []} + value={dragQuizValues[item.id] || []} + error={dragQuizErrors[item.id]} + onChange={handleDragChange} + /> + { + setActiveSuccess(null); + setSuccessContent(""); + setDragQuizValues((prev) => { + const next = { ...prev }; + delete next[item.id]; + return next; + }); + setDragQuizErrors((prev) => { + const next = { ...prev }; + delete next[item.id]; + return next; + }); + const cb = resumeCallback; + setResumeCallback(null); + cb?.(); + }} + /> + + ); + }, + }; + } + + // 多选题处理 + if (item.type === "multiple") { + return { + id: item.id, + time: item.time, + once: true, + afterResume: "remove", + render: ({ resume }) => ( + <> + ({ + value: opt.value, + label: `${opt.value}. ${opt.label}`, + }))} + correctAnswer={correctAnswers} + errorMessage="再思考一下,注意选择所有正确答案。" + onSubmit={(values: string[], isCorrect: boolean) => { + if (isCorrect) { + setActiveSuccess(item.id); + setSuccessContent("回答正确,请继续探索。"); + setResumeCallback(() => resume); + } + }} + /> + { + 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 (
+ {/* 离开按钮 */} { - if (moduleName && starName) { - navigate(`/${moduleName}/${starName}/home`); + onClick={() => navigate(`/${moduleName}/${starName}/home`)} + /> + + {showVideo && ( +
); }; export default HuangyanpeiInternalization; - diff --git a/src/pages/huangyanpei/place/place1.tsx b/src/pages/huangyanpei/place/place1.tsx index baa7083..baa50b6 100644 --- a/src/pages/huangyanpei/place/place1.tsx +++ b/src/pages/huangyanpei/place/place1.tsx @@ -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 && ( { navigate(`/${moduleName}/${starName}/place2`); @@ -106,20 +112,56 @@ const HuangyanpeiPlace1 = () => { }} /> + {/* 学习引导词 */} + { + setShowGuideModal(false); + setVisible(true); + }} + > + 同学你好,欢迎来到走进故居-对话学习模块!{"\n"} + { + "进入黄炎培故居后,我们一起来了解黄炎培先生经历的5个阶段:““思想启蒙”-> “教育实践”-> “创立职教”-> “延安对话”-> 职教传薪;" + } + + {/* 引导 */} { + setVisible(nextVisible); + if (!nextVisible && !afterDialogGuideShown) { + setAfterDialogGuideShown(true); + setShowAfterDialogGuide(true); + } + }} showMask={true} highlightSelectors={[]} > - 小沪!这就是黄炎培先生曾经生活过的地方吗? + 小沪,这栋古色古香的江南大院,就是黄炎培先生从小生活、读书的地方吗 - 是的,这里就是黄炎培故居。让我们一起来探索黄炎培先生的思想启蒙之路... + 没错,1878年10月1日,黄炎培先生就出生在当时江苏省川沙县城关镇(现上海市浦东新区川沙镇)的这处“内史第”,宅子原是他姑祖父沈树镛家,也是他后来所说的“生于斯、读于斯、长于斯”之地。 + + + 1890年,川沙创办“观澜书院”,12岁的黄炎培以第一名的成绩考入,每天提着母亲亲手缝制的布书包,往返十里路求学,一点都不叫苦。1893年他去上海应县考,松江府复试名列前茅;1899年,在松江府试中更是以第一名的成绩中了秀才。 + + {/* 对话结束后的学习引导 */} + setShowAfterDialogGuide(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; +
); }; diff --git a/src/pages/huangyanpei/place/place2.tsx b/src/pages/huangyanpei/place/place2.tsx index bcd9951..aa0a982 100644 --- a/src/pages/huangyanpei/place/place2.tsx +++ b/src/pages/huangyanpei/place/place2.tsx @@ -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>(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的引导对话 */} + + + 哇,这就是南洋公学!是不是现在上海交通大学的前身呀?黄炎培先生中了秀才之后,就来这里读书了吗? + + + 你说得特别对!1901年,黄炎培先生考入南洋公学(今上海交通大学)特班,当时的老师正是蔡元培先生。 + + + 蔡先生一句“中国教育之病,在于学与术分离”,让他瞬间醍醐灌顶,明白了教育不能只谈书本、脱离实际。1902年,黄炎培去南京参加江南乡试,成功中了举人; + + + 可就在这一年,南洋公学发生了著名的“墨水瓶风潮”,他毅然跟着蔡元培先生退学,转入蔡先生创办的中国教育会,正式开启了自己“教育救国”的实践。 + + + + {/* 学习引导(进入场景即显示,与 place1 保持一致) */} + setShowGuideModal(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; + ); }; diff --git a/src/pages/huangyanpei/place/place3.tsx b/src/pages/huangyanpei/place/place3.tsx index e9b33bb..d4bd21b 100644 --- a/src/pages/huangyanpei/place/place3.tsx +++ b/src/pages/huangyanpei/place/place3.tsx @@ -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>(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 */} handleOpenExhibit(1)} read={visited.has(1)} /> + {/* 光标2 */} handleOpenExhibit(2)} read={visited.has(2)} /> + {/* 光标3 */} handleOpenExhibit(3)} @@ -98,10 +159,94 @@ const HuangyanpeiPlace3 = () => { next.add(activeExhibitId); return next; }); + setActiveExhibitId(null); + } else { + setActiveExhibitId(null); } - setActiveExhibitId(null); }} /> + + {/* 光标3的引导对话 */} + + + 川沙小学堂是不是黄炎培先生"教育救国"的第一个试验田啊?还有黄炎培先生是不是就像现在教育这样,也有专业课程、实用课程,也很注重动手能力啊? + + + 你说得特别对!1903年,年仅24岁的黄炎培就和好友张志鹤等人一起,把原来的"观澜书院"改制成"川沙小学堂",黄炎培出任校长。 + + + 学校的课程设置非常大胆创新,首次引入算术、"格致"(也就是现在说的自然科学)、体操、乡土调查等新学科。 + + + 学校采用"半日读书、半日手工"的模式,特别注重实际应用,这也是中国最早的"半日授课、半日实习"的雏形。 + + + 办学资金紧张,没有钱买课本,黄炎培就亲自带领大家刻蜡板来印刷;没有体育器材,他就带着学生用竹子做成双杠...整个学校完全是凭着满腔热情办起来的! + + + + {/* 光标3的题目 */} + + + {/* 光标1、2的引导对话 */} + + + 原来当年还有专门的女子学校呀?在那个年代,女孩子能上学是不是特别不容易?后来的浦东中学也是黄炎培先生办的吗? + + + 你观察得特别仔细! + 川沙小学堂开学没多久,黄炎培就和堂兄黄洪培、堂嫂陆开群一起,策划创建了"开群女校"——这所学校专门面向社会招收女学生,还用全力支持办学、腾出自家住房的陆开群名字命名,直接开创了浦东地区女子教育的先河,让很多女孩子有了读书的机会。 + + + 到了1906年,上海营造业宗师杨斯盛捐资30万两白银,在浦东六里桥购地40多亩创办浦东中学,特意邀请黄炎培担任首任校长。校舍是黄炎培亲自设计的,杨斯盛先生亲自监工,建成后有能容纳千人的大礼堂和两座教学楼,是近代上海第一所完全中学呢! + + + 1912年,黄炎培担任江苏省署教育司司长,任职期间一直倡导实用主义教育,强调教育要和生产劳动结合起来,这也为后来的职业教育打下了坚实基础。 + + + + {/* 学习引导(进入场景即显示,与 place1 保持一致) */} + setShowGuideModal(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; + ); }; diff --git a/src/pages/huangyanpei/place/place4.tsx b/src/pages/huangyanpei/place/place4.tsx index 452c2ca..44f2c9f 100644 --- a/src/pages/huangyanpei/place/place4.tsx +++ b/src/pages/huangyanpei/place/place4.tsx @@ -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>(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)} /> + handleOpenExhibit(5)} + read={visited.has(5)} + /> {completed && ( { setActiveExhibitId(null); }} /> + + {/* 学习引导(进入场景即显示,与 place1 保持一致) */} + setShowGuideModal(false)} + ailgn="left" + > + 1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态; + {"\n"} + 2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习; + {"\n"} + 3、所有动态光点的展品全部学习后才能进入下一个场景学习; + ); }; diff --git a/src/pages/huangyanpei/place/place5.tsx b/src/pages/huangyanpei/place/place5.tsx index 40ab12e..de89728 100644 --- a/src/pages/huangyanpei/place/place5.tsx +++ b/src/pages/huangyanpei/place/place5.tsx @@ -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>(new Set()); const [videoVisible, setVideoVisible] = useState(false); const videoRef = useRef(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`), }} > + + { <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> ); }; diff --git a/src/pages/huangyanpei/place/place6.tsx b/src/pages/huangyanpei/place/place6.tsx index 164e7f5..27fb468 100644 --- a/src/pages/huangyanpei/place/place6.tsx +++ b/src/pages/huangyanpei/place/place6.tsx @@ -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> ); }; diff --git a/src/pages/huangyanpei/place/place7.tsx b/src/pages/huangyanpei/place/place7.tsx deleted file mode 100644 index 98d8b82..0000000 --- a/src/pages/huangyanpei/place/place7.tsx +++ /dev/null @@ -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; diff --git a/src/pages/huangyanpei/reflection.tsx b/src/pages/huangyanpei/reflection.tsx deleted file mode 100644 index e2cc800..0000000 --- a/src/pages/huangyanpei/reflection.tsx +++ /dev/null @@ -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; diff --git a/src/components/game/BambooPuzzleGame.tsx b/src/pages/huangyanpei/reflection/components/game/BambooPuzzleGame.tsx similarity index 97% rename from src/components/game/BambooPuzzleGame.tsx rename to src/pages/huangyanpei/reflection/components/game/BambooPuzzleGame.tsx index 6af5e41..9cd23d1 100644 --- a/src/components/game/BambooPuzzleGame.tsx +++ b/src/pages/huangyanpei/reflection/components/game/BambooPuzzleGame.tsx @@ -2,7 +2,7 @@ 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 } from "@/components"; +import { Image, DragFill, StudyGuideModal } from "@/components"; import type { DragFillResult } from "@/components"; import { useScale } from "@/contexts/ScaleContext"; @@ -79,6 +79,7 @@ const BambooPuzzleGame = ({ ); const [shakeKey, setShakeKey] = useState(0); const completedRef = useRef(false); + const [showStartGuide, setShowStartGuide] = useState(false); // SSR 兼容 useEffect(() => { @@ -92,6 +93,7 @@ const BambooPuzzleGame = ({ setErrorContainers(new Set()); setShakeKey(0); completedRef.current = false; + setShowStartGuide(true); } }, [visible]); @@ -248,6 +250,16 @@ const BambooPuzzleGame = ({ /> )} + <StudyGuideModal + visible={showStartGuide} + onContinue={() => setShowStartGuide(false)} + verticalAlign="center" + > + 当年办学经费超紧张,没课本、没器材,黄先生就带着学生自己动手! + {"\n"} + 快来一起做器具吧。 + </StudyGuideModal> + {/* 返回按钮 */} {onClose && ( <Image diff --git a/src/components/game/index.ts b/src/pages/huangyanpei/reflection/components/game/index.ts similarity index 100% rename from src/components/game/index.ts rename to src/pages/huangyanpei/reflection/components/game/index.ts diff --git a/src/pages/huangyanpei/reflection/components/index.ts b/src/pages/huangyanpei/reflection/components/index.ts new file mode 100644 index 0000000..48068ca --- /dev/null +++ b/src/pages/huangyanpei/reflection/components/index.ts @@ -0,0 +1,4 @@ +export { default as QuestionModal } from "./question-modal"; +export type { QuestionModalProps } from "./question-modal"; + +export { BambooPuzzleGame } from "./game"; diff --git a/src/pages/huangyanpei/reflection/components/question-modal/index.ts b/src/pages/huangyanpei/reflection/components/question-modal/index.ts new file mode 100644 index 0000000..9021617 --- /dev/null +++ b/src/pages/huangyanpei/reflection/components/question-modal/index.ts @@ -0,0 +1,4 @@ +export { default } from "./question-modal"; +export { default as QuestionModal } from "./question-modal"; +export type { QuestionModalProps } from "./question-modal"; + diff --git a/src/pages/huangyanpei/reflection/components/question-modal/question-modal.tsx b/src/pages/huangyanpei/reflection/components/question-modal/question-modal.tsx new file mode 100644 index 0000000..8c04539 --- /dev/null +++ b/src/pages/huangyanpei/reflection/components/question-modal/question-modal.tsx @@ -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; diff --git a/src/pages/huangyanpei/reflection/scene1.tsx b/src/pages/huangyanpei/reflection/scene1.tsx index 49c9ac0..9f70818 100644 --- a/src/pages/huangyanpei/reflection/scene1.tsx +++ b/src/pages/huangyanpei/reflection/scene1.tsx @@ -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> ); }; diff --git a/src/pages/huangyanpei/reflection/scene2.tsx b/src/pages/huangyanpei/reflection/scene2.tsx index e034c39..8f3378c 100644 --- a/src/pages/huangyanpei/reflection/scene2.tsx +++ b/src/pages/huangyanpei/reflection/scene2.tsx @@ -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> ); }; diff --git a/src/pages/huangyanpei/reflection/scene2/ChoiceDialog1.tsx b/src/pages/huangyanpei/reflection/scene2/ChoiceDialog1.tsx index 585fc2b..f37c7ae 100644 --- a/src/pages/huangyanpei/reflection/scene2/ChoiceDialog1.tsx +++ b/src/pages/huangyanpei/reflection/scene2/ChoiceDialog1.tsx @@ -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; diff --git a/src/pages/huangyanpei/reflection/scene2/ChoiceDialog2.tsx b/src/pages/huangyanpei/reflection/scene2/ChoiceDialog2.tsx index b8cbb11..66efd60 100644 --- a/src/pages/huangyanpei/reflection/scene2/ChoiceDialog2.tsx +++ b/src/pages/huangyanpei/reflection/scene2/ChoiceDialog2.tsx @@ -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; diff --git a/src/pages/huangyanpei/reflection/scene3.tsx b/src/pages/huangyanpei/reflection/scene3.tsx index 71c9817..2fba948 100644 --- a/src/pages/huangyanpei/reflection/scene3.tsx +++ b/src/pages/huangyanpei/reflection/scene3.tsx @@ -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`); }} /> )} diff --git a/src/pages/huangyanpei/reflection/scene3/ViseAssemblyDialog.tsx b/src/pages/huangyanpei/reflection/scene3/ViseAssemblyDialog.tsx index c930a93..da7ecef 100644 --- a/src/pages/huangyanpei/reflection/scene3/ViseAssemblyDialog.tsx +++ b/src/pages/huangyanpei/reflection/scene3/ViseAssemblyDialog.tsx @@ -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" > diff --git a/src/pages/huangyanpei/reflection/scene4.tsx b/src/pages/huangyanpei/reflection/scene4.tsx index 3062243..757b6a0 100644 --- a/src/pages/huangyanpei/reflection/scene4.tsx +++ b/src/pages/huangyanpei/reflection/scene4.tsx @@ -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`); }} /> diff --git a/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx b/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx index 8eb1562..325a59f 100644 --- a/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx +++ b/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx @@ -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; diff --git a/src/pages/luxun/internalization.tsx b/src/pages/luxun/internalization.tsx index 01c2668..9035c60 100644 --- a/src/pages/luxun/internalization.tsx +++ b/src/pages/luxun/internalization.tsx @@ -1,38 +1,335 @@ +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, + LetterToLuxun, + StudyGuideModal, +} from "@/components"; +import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils"; +import { SuccessAlert } from "@/components/layout"; +import { + MultipleChoiceQuizPanel, + SingleChoiceQuizPanel, +} 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"; + options: QuizOption[]; +}; const starName = "luxun"; +// 锚点数据:根据视频时长调整 time +const QUIZ_ANCHORS: QuizAnchor[] = [ + { + id: "quiz-1", + time: 155, // 2:35 + title: "题目1", + prompt: "以下属于鲁迅先生作品的是( )。", + type: "multiple", + options: [ + { value: "A", label: "孔乙己", isCorrect: true }, + { value: "B", label: "药", isCorrect: true }, + { value: "C", label: "狂人日记", isCorrect: true }, + { value: "D", label: "阿Q正传", isCorrect: true }, + ], + }, + { + id: "quiz-2", + time: 355, // 5:55 + title: "题目2", + prompt: "对于鼓励和培养青年一代,鲁迅先生的做法是( )。", + type: "multiple", + options: [ + { value: "A", label: "组织文学社团", isCorrect: true }, + { value: "B", label: "木刻运动", isCorrect: true }, + { value: "C", label: "写纪念烈士文章", isCorrect: true }, + { + value: "D", + label: "让青年都去参军", + explanation: "鲁迅先生还是在文学和思想领域培养青年一代,传递思想的火苗", + }, + ], + }, + { + id: "quiz-3", + time: 420, // 7:00 + title: "题目3", + prompt: "以下那句诗出自鲁迅的作品( )。", + type: "single", + options: [ + { value: "A", label: "寄意寒星荃不察,我以我血荐轩辕", isCorrect: true }, + { + value: "B", + label: "我自横刀向天笑,去留肝胆两昆仑", + explanation: "出自谭嗣同《狱中题壁》", + }, + { + value: "C", + label: "此去泉台招旧部,旌旗十万斩阎罗", + explanation: "出自陈毅《梅岭三章》", + }, + { + value: "D", + label: "雄关漫道真如铁,而今迈步从头越", + explanation: "出自毛泽东《忆秦娥·娄山关》", + }, + ], + }, +]; + const LuxunInternalization = () => { 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 [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 === "single") { + return { + id: item.id, + time: item.time, + once: true, + afterResume: "remove", + render: ({ resume }) => ( + <> + <SingleChoiceQuizPanel + 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[0]} + errorMessage="再思考一下,选择正确答案。" + onSubmit={(value: 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?.(); + }} + /> + </> + ), + }; + } + + // 多选题处理 + 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] + ); + + 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/luxun/luxun_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="guide"> + 小沪,关于"首在立人"走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。 + </GuideStepsItem> + <GuideStepsItem speaker="guide"> + 那些针砭时弊的杂文、借古讽今的篇章,还有藏在6平方米客房里的红色坚守,都在诉说着先生的热忱。 + </GuideStepsItem> + <GuideStepsItem speaker="guide"> + 1907年,鲁迅先生在《文化偏至论》里写到:“是故将生存两间,角逐列国是务,其首在立人,人立而后凡事举;若其道术,乃必尊个性而张精神。” + </GuideStepsItem> + <GuideStepsItem speaker="guide"> + 先生认为,中华民族要屹立于世界民族之林,“首在立人”,“立人”思想从此贯穿在鲁迅先生的人生和作品中。”先生认为,中华民族要屹立于世界民族之林,“首在立人”,“立人”思想从此贯穿在鲁迅先生的人生和作品中。 + </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> + + {/* 信件组件 */} + <LetterToLuxun + 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 LuxunInternalization; - diff --git a/src/pages/luxun/place/place1.tsx b/src/pages/luxun/place/place1.tsx index 2b88412..f54c76f 100644 --- a/src/pages/luxun/place/place1.tsx +++ b/src/pages/luxun/place/place1.tsx @@ -88,8 +88,8 @@ const LuxunPlace1 = () => { {/* 继续前进 - 仅在完成所有展品后显示 */} {completed && ( <Image - className="absolute bottom-0 left-130" - src={getStaticImage("luxun/place1/前进")} + className="absolute bottom-0 right-0" + src={getStaticImage("components/place/继续前进")} button onClick={() => { navigate(`/${moduleName}/${starName}/place2`); diff --git a/src/pages/luxun/place/place3.tsx b/src/pages/luxun/place/place3.tsx index 19fee44..39ba363 100644 --- a/src/pages/luxun/place/place3.tsx +++ b/src/pages/luxun/place/place3.tsx @@ -7,10 +7,12 @@ import { useState } from "react"; const starName = "luxun"; // 展品 ID 与资源路径映射 -const exhibitSrcMap: Record<1 | 2 | 3, string> = { - 1: "luxun/place3/展品8", - 2: "luxun/place3/展品7", - 3: "luxun/place3/展品6", +const exhibitSrcMap: Record<1 | 2 | 3 | 4 | 5, string> = { + 1: "luxun/place3/6", + 2: "luxun/place3/7", + 3: "luxun/place3/8", + 4: "luxun/place3/9", + 5: "luxun/place3/10", }; const LuxunPlace3 = () => { @@ -19,21 +21,21 @@ const LuxunPlace3 = () => { moduleName: string; }>(); - const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>( - null - ); + 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) => { + const handleOpenExhibit = (index: 1 | 2 | 3 | 4 | 5) => { setActiveExhibitId(index); }; const progressCount = visited.size; - const total = 3; + const total = 5; const completed = progressCount >= total; const subtitle = completed - ? "探索二楼卧室兼书房(已完成)" - : `探索二楼卧室兼书房(${progressCount}/${total})`; + ? "探索二楼卧室兼书房的书桌(已完成)" + : `探索二楼卧室兼书房的书桌(${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; @@ -46,7 +48,7 @@ const LuxunPlace3 = () => { }} > <Title - title="历史新编中的现实呐喊" + title="历史新编中的现实呐喊&为亡友编纂的精神丰碑" subtitle={subtitle} subtitleClassName={subtitleClassName} className="absolute top-10 left-1/2 -translate-x-1/2" @@ -65,28 +67,40 @@ const LuxunPlace3 = () => { {/* 展品 */} <Cursor - className="absolute left-90 bottom-140" + className="absolute right-114 bottom-130" onClick={() => handleOpenExhibit(1)} - size={0.4} + size={0.6} read={visited.has(1)} /> <Cursor - className="absolute right-225 bottom-120" + className="absolute right-130 bottom-115" onClick={() => handleOpenExhibit(2)} - size={0.4} + size={0.6} read={visited.has(2)} /> <Cursor - className="absolute right-214 bottom-123" + className="absolute left-76 bottom-140" onClick={() => handleOpenExhibit(3)} - size={0.4} + size={0.6} read={visited.has(3)} /> + <Cursor + className="absolute right-238 bottom-125" + onClick={() => handleOpenExhibit(4)} + size={0.6} + read={visited.has(4)} + /> + <Cursor + className="absolute right-258 bottom-118" + onClick={() => handleOpenExhibit(5)} + size={0.6} + read={visited.has(5)} + /> {completed && ( <Image - className="absolute bottom-25 right-[calc(50%-270px)]" - src={getStaticImage("luxun/place3/前进")} + className="absolute bottom-0 right-130" + src={getStaticImage("luxun/place3/前进按钮")} button onClick={() => { navigate(`/${moduleName}/${starName}/place4`); diff --git a/src/pages/luxun/place/place4.tsx b/src/pages/luxun/place/place4.tsx index 33e1c36..406ad8b 100644 --- a/src/pages/luxun/place/place4.tsx +++ b/src/pages/luxun/place/place4.tsx @@ -7,30 +7,33 @@ import { useState } from "react"; const starName = "luxun"; // 展品 ID 与资源路径映射 -const exhibitSrcMap: Record<1 | 2, string> = { - 1: "luxun/place4/展品9_t", - 2: "luxun/place4/展品9_b", +const exhibitSrcMap: Record<1 | 2 | 3, string> = { + 1: "luxun/place4/展品11", + 2: "luxun/place4/展品12", + 3: "luxun/place4/展品13", }; -const LuxunPlace4 = () => { +const LuxunPlace5 = () => { const navigate = useNavigate(); const { moduleName } = useParams<{ moduleName: string; }>(); - const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); + const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>( + null + ); const [visited, setVisited] = useState<Set<number>>(new Set()); - const handleOpenExhibit = (index: 1 | 2) => { + const handleOpenExhibit = (index: 1 | 2 | 3) => { setActiveExhibitId(index); }; const progressCount = visited.size; - const total = 2; + const total = 3; const completed = progressCount >= total; const subtitle = completed - ? "探索二楼卧室兼书房的书桌(已完成)" - : `探索二楼卧室兼书房的书桌(${progressCount}/${total})`; + ? "探索鲁迅故居的客房(已完成)" + : `探索鲁迅故居的客房(${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; @@ -43,7 +46,7 @@ const LuxunPlace4 = () => { }} > <Title - title="为亡友编纂的精神丰碑" + title="6平方米的红色庇护所" subtitle={subtitle} subtitleClassName={subtitleClassName} className="absolute top-10 left-1/2 -translate-x-1/2" @@ -62,25 +65,28 @@ const LuxunPlace4 = () => { {/* 展品 */} <Cursor - className="absolute left-170 bottom-115" - size={0.4} + className="absolute left-167 bottom-30" onClick={() => handleOpenExhibit(1)} read={visited.has(1)} /> <Cursor - className="absolute left-183 bottom-115" - size={0.4} + className="absolute left-228 bottom-30" onClick={() => handleOpenExhibit(2)} read={visited.has(2)} /> + <Cursor + className="absolute right-115 bottom-18 z-20" + onClick={() => handleOpenExhibit(3)} + read={visited.has(3)} + /> {completed && ( <Image - className="absolute bottom-0 right-120" - src={getStaticImage("luxun/place4/前进")} + className="absolute bottom-0 right-0" + src={getStaticImage("luxun/place4/离开")} button onClick={() => { - navigate(`/${moduleName}/${starName}/place5`); + navigate(`/${moduleName}/${starName}/home`); }} /> )} @@ -103,4 +109,4 @@ const LuxunPlace4 = () => { ); }; -export default LuxunPlace4; +export default LuxunPlace5; diff --git a/src/pages/luxun/place/place5.tsx b/src/pages/luxun/place/place5.tsx deleted file mode 100644 index 553b0fc..0000000 --- a/src/pages/luxun/place/place5.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { ExhibitOverlay, Image, Title, Cursor } from "@/components"; -import { useNavigate, useParams } from "react-router-dom"; -import { getBackgroundImage, getStaticImage } from "@/lib/utils"; -import { useState } from "react"; - -// starName 固定为 luxun -const starName = "luxun"; - -// 展品 ID 与资源路径映射 -const exhibitSrcMap: Record<1 | 2 | 3, string> = { - 1: "luxun/place5/展品11", - 2: "luxun/place5/展品12", - 3: "luxun/place5/展品13", -}; - -const LuxunPlace5 = () => { - const navigate = useNavigate(); - const { moduleName } = useParams<{ - moduleName: string; - }>(); - - const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>( - null - ); - const [visited, setVisited] = useState<Set<number>>(new Set()); - - const handleOpenExhibit = (index: 1 | 2 | 3) => { - setActiveExhibitId(index); - }; - - const progressCount = visited.size; - const total = 3; - 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(`luxun/place5/背景`), - }} - > - <Title - title="6平方米的红色庇护所" - 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}/place4`); - } - }} - /> - - {/* 展品 */} - <Cursor - className="absolute left-167 bottom-30" - onClick={() => handleOpenExhibit(1)} - read={visited.has(1)} - /> - <Cursor - className="absolute left-228 bottom-30" - onClick={() => handleOpenExhibit(2)} - read={visited.has(2)} - /> - <Cursor - className="absolute right-115 bottom-18 z-20" - onClick={() => handleOpenExhibit(3)} - read={visited.has(3)} - /> - - {completed && ( - <Image - className="absolute bottom-0 right-0" - src={getStaticImage("luxun/place5/离开")} - 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); - }} - /> - </div> - ); -}; - -export default LuxunPlace5; diff --git a/src/pages/luxun/reflection/scene1.tsx b/src/pages/luxun/reflection/scene1.tsx index 3b5da4b..82cae27 100644 --- a/src/pages/luxun/reflection/scene1.tsx +++ b/src/pages/luxun/reflection/scene1.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Image } from "@/components"; -import SuccessAlert from "@/components/layout/success-alert"; +import SuccessAlert from "@/components/layout/alert/success-alert"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; const starName = "luxun"; @@ -14,7 +14,7 @@ const cityConfig = [ name: "绍兴", location: "绍兴三味书屋", imagePath: "luxun/reflection/scene1/绍兴", - position: { top: "58%", left: "60%" }, // 中间 + position: { top: "65%", left: "65%" }, // 中间 iconPosition: { top: "0%", left: "0%" }, time: "1892年-1898年", description: "少年鲁迅在此求学,为《从百草园到三味书屋》埋下创作种子。", @@ -24,7 +24,7 @@ const cityConfig = [ name: "北京", location: "北京八道湾胡同", imagePath: "luxun/reflection/scene1/北京", - position: { top: "14%", left: "50%" }, // 顶部 + position: { top: "14%", left: "62%" }, // 顶部 iconPosition: { top: "43%", left: "0%" }, time: "1919-1923年", description: @@ -35,7 +35,7 @@ const cityConfig = [ name: "厦门", location: "厦门大学", imagePath: "luxun/reflection/scene1/厦门", - position: { top: "80%", left: "70%" }, // 右下 + position: { top: "83%", left: "78%" }, // 右下 iconPosition: { top: "16%", left: "40%" }, time: "1926年9-12月", description: @@ -46,7 +46,7 @@ const cityConfig = [ name: "广州", location: "广州白云楼", imagePath: "luxun/reflection/scene1/广州", - position: { top: "80%", left: "30%" }, // 左下 + position: { top: "88%", left: "35%" }, // 左下 iconPosition: { top: "16%", left: "30%" }, time: "1927年3-9月", description: @@ -57,7 +57,7 @@ const cityConfig = [ name: "上海", location: "上海鲁迅故居", imagePath: "luxun/reflection/scene1/上海", - position: { top: "40%", left: "60%" }, // 中上 + position: { top: "48%", left: "70%" }, // 中上 iconPosition: { top: "0%", left: "0%" }, time: "1933-1936年", description: @@ -190,6 +190,36 @@ const LuxunReflectionScene1 = () => { </div> </div> + {/* 导航栏 */} + <div className="absolute top-5 left-[37%] w-80 space-y-4 z-99"> + {/* 任务1点亮图标 */} + <div className="text-4xl font-bold h-16 cursor-pointer"></div> + {/* 任务2漫集美学 */} + <div + className="text-4xl font-bold h-16 cursor-pointer relative" + onClick={() => { + if (moduleName && allCompleted) { + navigate(`/${moduleName}/${starName}/reflection2`); + } + }} + > + {/* 任务完成后显示点一点图标 */} + {allCompleted && ( + <div className="absolute -right-20 top-1/2 flex items-center gap-2 z-10 pointer-events-none"> + <img + className="animate-finger-bounce scale-x-[-1]" + src={getStaticImage( + `${starName}/reflection/scene1/鲁迅体悟点一点图标` + )} + alt="点一点图标" + /> + </div> + )} + </div> + {/* 任务3学习精神 */} + <div className="text-4xl font-bold h-16 cursor-pointer"></div> + </div> + {/* 右侧区域 */} <div className="flex-1 relative"> {/* 任务进度 */} @@ -201,14 +231,16 @@ const LuxunReflectionScene1 = () => { </span> </div> {/* 任务展示窗口 */} - <div className="absolute bottom-0 right-7 w-[995px] h-[728px] font-bold text-7xl space-y-16 px-10 py-10 overflow-y-auto luxun-scrollbar"> + <div className="absolute bottom-0 right-7 w-[995px] h-[728px] font-bold text-6xl space-y-16 px-10 py-10 overflow-y-auto custom-scrollbar"> {allCompleted ? // 全部完成后显示详细信息 cityConfig.map((city) => ( <div key={city.id} className="space-y-4"> - <div className="text-red-600 text-6xl">{city.location}</div> - <div className="text-gray-600 text-4xl font-normal"> - {city.time} + <div className="text-red-600 text-5xl flex items-end gap-6"> + <div>{city.location}</div> + <div className="text-gray-600 text-4xl font-normal"> + {city.time} + </div> </div> <div className="text-black text-4xl font-normal leading-relaxed"> {city.description} diff --git a/src/pages/luxun/reflection/scene2.tsx b/src/pages/luxun/reflection/scene2.tsx index c106a5e..274615d 100644 --- a/src/pages/luxun/reflection/scene2.tsx +++ b/src/pages/luxun/reflection/scene2.tsx @@ -2,7 +2,8 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Image, DragFill } from "@/components"; import type { DragFillResult } from "@/components"; -import SuccessAlert from "@/components/layout/success-alert"; +import SuccessAlert from "@/components/layout/alert/success-alert"; +import ErrorAlert from "@/components/layout/alert/error-alert"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; const starName = "luxun"; @@ -14,48 +15,56 @@ const dragItems = [ name: "闰土", imagePath: "luxun/reflection/scene2/拖拽-润土", targetBlankId: "city-1-label-0", // 绍兴-人物 + errorHint: "闰土的故事和鲁迅的少年绍兴时光有关哦", }, { id: "baicaoyuan", name: "《从百草园到三味书屋》", imagePath: "luxun/reflection/scene2/拖拽-百草园到三味书屋", targetBlankId: "city-1-label-1", // 绍兴-作品 + errorHint: "这个作品写的是鲁迅在绍兴的故事哦", }, { id: "aqzhengzhuan", name: "《阿Q正传》", imagePath: "luxun/reflection/scene2/拖拽-阿Q正传", targetBlankId: "city-2-label-0", // 北京-作品 + errorHint: "这部经典小说和鲁迅在北京的创作有关哦", }, { id: "jingshenshengli", - name: "“精神胜利法”", + name: '"精神胜利法"', imagePath: "luxun/reflection/scene2/拖拽-精神胜利法", targetBlankId: "city-2-label-1", // 北京-核心主题 + errorHint: "这个主题是《阿Q正传》的核心,和北京创作", }, { id: "guanzhudiceng", - name: "“关注底层”", + name: '"关注底层"', imagePath: "luxun/reflection/scene2/关注底层", targetBlankId: "city-3-label-0", // 厦门-创作方向 + errorHint: "这个创作方向是鲁迅在厦门的核心追求哦", }, { id: "zhaohuaxishi", name: "《朝花夕拾》", imagePath: "luxun/reflection/scene2/拖拽-朝花夕拾", targetBlankId: "city-4-label-0", // 广州-作品集 + errorHint: "这个作品集和鲁迅在广州的旅居时光有关哦", }, { id: "gushixinbian", name: "《故事新编》", imagePath: "luxun/reflection/scene2/拖拽-故事新编", targetBlankId: "city-5-label-0", // 上海-作品 + errorHint: "再试试~这个作品和上海的创作故事更相关哦", }, { id: "biweidaoqiang", - name: "“笔为刀枪”", + name: '"笔为刀枪"', imagePath: "luxun/reflection/scene2/笔为刀枪", targetBlankId: "city-5-label-1", // 上海-创作中心 + errorHint: "这个初心是鲁迅在上海创作的核心哦", }, ]; @@ -67,10 +76,10 @@ const cityConfig = [ name: "绍兴", location: "绍兴三味书屋", imagePath: "luxun/reflection/scene2/绍兴", - position: { top: "58%", left: "60%" }, // 中间 + position: { top: "65%", left: "65%" }, // 中间 labels: [ - { text: "人物", containerPosition: { top: "0%", left: "-20%" } }, - { text: "作品", containerPosition: { top: "0%", left: "20%" } }, + { text: "人物", containerPosition: { top: "50%", left: "20%" } }, + { text: "作品", containerPosition: { top: "50%", left: "50%" } }, ], time: "1892年-1898年", description: "少年鲁迅在此求学,为《从百草园到三味书屋》埋下创作种子。", @@ -80,10 +89,10 @@ const cityConfig = [ name: "北京", location: "北京八道湾胡同", imagePath: "luxun/reflection/scene2/北京", - position: { top: "14%", left: "50%" }, // 顶部 + position: { top: "14%", left: "62%" }, // 顶部 labels: [ - { text: "作品", containerPosition: { top: "0%", left: "-20%" } }, - { text: "核心主题", containerPosition: { top: "0%", left: "20%" } }, + { text: "作品", containerPosition: { top: "72%", left: "30%" } }, + { text: "核心主题", containerPosition: { top: "72%", left: "70%" } }, ], time: "1919-1923年", description: @@ -94,9 +103,9 @@ const cityConfig = [ name: "厦门", location: "厦门大学", imagePath: "luxun/reflection/scene2/厦门", - position: { top: "80%", left: "70%" }, // 右下 + position: { top: "83%", left: "78%" }, // 右下 labels: [ - { text: "创作方向", containerPosition: { top: "0%", left: "0%" } }, + { text: "创作方向", containerPosition: { top: "52%", left: "60%" } }, ], time: "1926年9-12月", description: @@ -107,8 +116,10 @@ const cityConfig = [ name: "广州", location: "广州白云楼", imagePath: "luxun/reflection/scene2/广州", - position: { top: "80%", left: "30%" }, // 左下 - labels: [{ text: "作品集", containerPosition: { top: "0%", left: "0%" } }], + position: { top: "88%", left: "35%" }, // 左下 + labels: [ + { text: "作品集", containerPosition: { top: "38%", left: "50%" } }, + ], time: "1927年3-9月", description: "先生在此整理旧作,思考民族觉醒之路,《朝花夕拾》部分篇章定稿于此。", @@ -118,10 +129,10 @@ const cityConfig = [ name: "上海", location: "上海鲁迅故居", imagePath: "luxun/reflection/scene2/上海", - position: { top: "40%", left: "60%" }, // 中上 + position: { top: "48%", left: "70%" }, // 中上 labels: [ - { text: "作品", containerPosition: { top: "0%", left: "-20%" } }, - { text: "创作中心", containerPosition: { top: "0%", left: "20%" } }, + { text: "作品", containerPosition: { top: "50%", left: "20%" } }, + { text: "创作中心", containerPosition: { top: "50%", left: "50%" } }, ], time: "1933-1936年", description: @@ -142,6 +153,9 @@ const LuxunReflectionScene2 = () => { const [showSuccessAlert, setShowSuccessAlert] = useState(false); // 是否全部完成 const [allCompleted, setAllCompleted] = useState(false); + // 错误提示相关状态 + const [showErrorAlert, setShowErrorAlert] = useState(false); + const [errorHint, setErrorHint] = useState(""); // 转换为 DragFill 的 options const options = useMemo( @@ -169,18 +183,58 @@ const LuxunReflectionScene2 = () => { }, []); // 处理拖拽结果变化 - 验证是否正确放置 - const handleValueChange = useCallback((newValue: DragFillResult[]) => { - // 验证每个元素是否放置到正确的标签区域 - const filteredValue = newValue.filter((result) => { - const itemId = result.optionValue; - const blankId = result.blankId; + const handleValueChange = useCallback( + (newValue: DragFillResult[]) => { + // 找出新添加或位置改变的拖拽结果(与当前值比较) + const previousValue = dragFillValue; + const changedResults = newValue.filter( + (newResult) => + !previousValue.some( + (prevResult) => + prevResult.optionValue === newResult.optionValue && + prevResult.blankId === newResult.blankId + ) + ); - const item = dragItems.find((item) => item.id === itemId); - return item && item.targetBlankId === blankId; - }); + // 检查改变的结果是否有错误 + let hasError = false; + let errorMessage = ""; - setDragFillValue(filteredValue); - }, []); + for (const result of changedResults) { + const itemId = result.optionValue; + const blankId = result.blankId; + const item = dragItems.find((item) => item.id === itemId); + + // 如果放置位置不正确,显示错误提示 + if (item && item.targetBlankId !== blankId) { + hasError = true; + errorMessage = item.errorHint || "放置位置不正确,请再试试~"; + break; // 只显示第一个错误的提示 + } + } + + if (hasError) { + // 显示错误提示 + setErrorHint(errorMessage); + setShowErrorAlert(true); + // 不更新拖拽值,保持之前正确的状态 + // DragFill 是受控组件,不更新 value 会保持之前的状态 + return; + } + + // 验证每个元素是否放置到正确的标签区域,只保留正确的放置 + const filteredValue = newValue.filter((result) => { + const itemId = result.optionValue; + const blankId = result.blankId; + + const item = dragItems.find((item) => item.id === itemId); + return item && item.targetBlankId === blankId; + }); + + setDragFillValue(filteredValue); + }, + [dragFillValue] + ); const progress = dragFillValue.length; const totalTasks = dragItems.length; // 8个拖拽元素 @@ -197,6 +251,11 @@ const LuxunReflectionScene2 = () => { setShowSuccessAlert(false); }; + const handleErrorAlertClose = () => { + setShowErrorAlert(false); + setErrorHint(""); + }; + // 获取每个标签区域已放置的元素 const getLabelPlacedItem = (cityId: number, labelIndex: number) => { const blankId = `city-${cityId}-label-${labelIndex}`; @@ -207,7 +266,7 @@ const LuxunReflectionScene2 = () => { return ( <div - className="flex justify-between w-full h-full bg-cover bg-center bg-no-repeat gap-10 relative" + className="select-none flex justify-between w-full h-full bg-cover bg-center bg-no-repeat gap-10 relative" style={{ backgroundImage: getBackgroundImage( `${starName}/reflection/scene2/底图` @@ -221,7 +280,7 @@ const LuxunReflectionScene2 = () => { button onClick={() => { if (moduleName && starName) { - navigate(`/${moduleName}/${starName}/home`); + navigate(`/${moduleName}/${starName}/reflection`); } }} /> @@ -262,10 +321,10 @@ const LuxunReflectionScene2 = () => { <div key={index} data-blank-id={blankId} - className={`absolute border-2 border-dashed rounded-lg transition-all min-w-[120px] min-h-[120px] flex flex-col items-center justify-center ${ + className={`absolute border-2 border-dashed rounded-lg transition-all min-w-[80px] min-h-[80px] flex flex-col items-center justify-center ${ isFilled ? "border-green-500 bg-green-500/20" - : "border-white/60 bg-white/10 hover:border-blue-400 hover:bg-blue-400/20" + : "border-white/60 bg-black/60 hover:border-blue-400 hover:bg-blue-400/20" }`} style={{ top: containerPosition.top, @@ -281,7 +340,7 @@ const LuxunReflectionScene2 = () => { className="w-20 h-20 object-contain" /> ) : ( - <div className="text-white/30 text-sm text-center"> + <div className="text-white text-sm text-center"> {labelText} </div> )} @@ -296,6 +355,34 @@ const LuxunReflectionScene2 = () => { </div> </div> + {/* 导航栏 */} + <div className="absolute top-20 left-[37%] w-80 space-y-4 z-99"> + {/* 任务2漫集美学 */} + <div className="text-4xl font-bold h-16 cursor-pointer relative"></div> + {/* 任务3学习精神 */} + <div + className="text-4xl font-bold h-16 cursor-pointer relative" + onClick={() => { + if (moduleName && allCompleted) { + navigate(`/${moduleName}/${starName}/reflection3`); + } + }} + > + {/* 任务完成后显示点一点图标 */} + {allCompleted && ( + <div className="absolute -right-20 top-1/2 flex items-center gap-2 z-10 pointer-events-none"> + <img + className="animate-finger-bounce scale-x-[-1]" + src={getStaticImage( + `${starName}/reflection/scene1/鲁迅体悟点一点图标` + )} + alt="点一点图标" + /> + </div> + )} + </div> + </div> + {/* 右侧区域 */} <div className="flex-1 relative"> {/* 任务进度 */} @@ -309,7 +396,7 @@ const LuxunReflectionScene2 = () => { {/* 鲁迅画廊 */} <div className="absolute top-[calc(2.4rem+204px)] left-0 w-[995px] h-[467px] pt-16 pl-70 pr-10"> {/* 提示图标 */} - <div className="absolute left-10 top-30 flex items-center z-10 pointer-events-none"> + <div className="absolute left-14 top-35 flex items-center z-10 pointer-events-none"> <img src={getStaticImage( `${starName}/reflection/scene2/鲁迅体悟拖一拖` @@ -364,7 +451,7 @@ const LuxunReflectionScene2 = () => { <img src={getStaticImage(item.imagePath)} alt={item.name} - className="w-20 h-20 object-contain shadow-lg" + className="w-16" draggable={false} /> ); @@ -390,11 +477,11 @@ const LuxunReflectionScene2 = () => { <div className="space-y-8"> {cityConfig.map((city) => ( <div key={city.id} className="space-y-4"> - <div className="text-red-600 text-6xl font-bold"> - {city.location} - </div> - <div className="text-gray-600 text-4xl font-normal"> - {city.time} + <div className="text-red-600 text-5xl font-bold flex items-end gap-6"> + <div>{city.location}</div> + <div className="text-gray-600 text-4xl font-normal"> + {city.time} + </div> </div> <div className="text-black text-4xl font-normal leading-relaxed"> {city.description} @@ -438,6 +525,14 @@ const LuxunReflectionScene2 = () => { onClose={handleSuccessAlertClose} onContinue={handleSuccessAlertClose} /> + + {/* 错误提示弹窗 */} + <ErrorAlert + visible={showErrorAlert} + text={errorHint} + onClose={handleErrorAlertClose} + onMaskClick={handleErrorAlertClose} + /> </div> ); }; diff --git a/src/pages/luxun/reflection/scene3.tsx b/src/pages/luxun/reflection/scene3.tsx index f28c805..5b9394d 100644 --- a/src/pages/luxun/reflection/scene3.tsx +++ b/src/pages/luxun/reflection/scene3.tsx @@ -1,139 +1,281 @@ +import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { Title, Image, Cursor, ExhibitOverlay } from "@/components"; -import { getBackgroundImage, getStaticImage } from "@/lib/utils"; +import { Image } from "@/components"; +import { SingleChoiceQuizPanel } from "@/components/layout/quiz"; const starName = "luxun"; -const routeMap: Record<1 | 2 | 3 | 4, string> = { - 1: "luxun/reflection/scene3/绍兴到北京", - 2: "luxun/reflection/scene3/北京到厦门", - 3: "luxun/reflection/scene3/厦门广州", - 4: "luxun/reflection/scene3/广州上海", -}; - -const themeMap: Record<5 | 6, string> = { - 5: "luxun/reflection/scene3/家国情怀", - 6: "luxun/reflection/scene3/批判现实", -}; +// 提示图标位置类型 +type HintPosition = "luxun1" | "jiaguo" | "piping" | "luxun2" | "none"; const LuxunReflectionScene3 = () => { const navigate = useNavigate(); const { moduleName } = useParams<{ moduleName: string; }>(); + const [showTopics, setShowTopics] = useState(false); // 是否显示"家国情怀"和"批判现实" + const [hintPosition, setHintPosition] = useState<HintPosition>("luxun1"); // 提示图标位置 + const [showQuiz1, setShowQuiz1] = useState(false); // 是否显示题目1 + const [showQuiz2, setShowQuiz2] = useState(false); // 是否显示题目2 + const [completedQuiz1, setCompletedQuiz1] = useState(false); // 题目1是否完成 + const [completedQuiz2, setCompletedQuiz2] = useState(false); // 题目2是否完成 + const [showSummary, setShowSummary] = useState(false); // 是否显示总结 - const [activeRouteId, setActiveRouteId] = useState<1 | 2 | 3 | 4 | null>(null); - const [activeThemeId, setActiveThemeId] = useState<5 | 6 | null>(null); - const [visited, setVisited] = useState<Set<number>>(new Set()); + const allCompleted = completedQuiz1 && completedQuiz2; + const progress = (completedQuiz1 ? 1 : 0) + (completedQuiz2 ? 1 : 0); + const totalTasks = 2; - const handleOpenRoute = (routeId: 1 | 2 | 3 | 4) => { - setActiveRouteId(routeId); + // 点击"鲁迅1"的处理 + const handleLuxun1Click = () => { + setShowTopics(true); + setHintPosition("jiaguo"); // 提示图标移动到"家国情怀"左侧 }; - const handleOpenTheme = (themeId: 5 | 6) => { - setActiveThemeId(themeId); + // 点击"家国情怀"的处理 + const handleJiaguoClick = () => { + if (!showTopics) return; // 必须先点击"鲁迅1" + setShowQuiz1(true); }; - const progressCount = visited.size; - const total = 6; - 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; + // 点击"批判现实"的处理 + const handlePipingClick = () => { + if (!completedQuiz1) return; // 必须先完成题目1 + setShowQuiz2(true); + }; + + // 题目1提交处理 + const handleQuiz1Submit = (value: string, isCorrect: boolean) => { + if (isCorrect) { + setCompletedQuiz1(true); + setShowQuiz1(false); + setHintPosition("piping"); // 提示图标移动到"批判现实"旁边 + } + }; + + // 题目2提交处理 + const handleQuiz2Submit = (value: string, isCorrect: boolean) => { + if (isCorrect) { + setCompletedQuiz2(true); + setShowQuiz2(false); + setHintPosition("luxun2"); // 提示图标移动到"鲁迅2"旁边 + } + }; + + // 点击"鲁迅2"的处理 + const handleLuxun2Click = () => { + if (allCompleted) { + setShowSummary(true); + setHintPosition("none"); // 隐藏提示图标 + } + }; return ( <div - className="w-full h-full bg-cover bg-center bg-no-repeat" + className="select-none flex justify-between w-full h-full bg-cover bg-center bg-no-repeat gap-10 relative" style={{ - backgroundImage: getBackgroundImage(`${starName}/reflection/scene3/底图`), + backgroundImage: getBackgroundImage( + `${starName}/reflection/scene3/底图` + ), }} > - <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(`${starName}/reflection/scene3/离开`)} + className="absolute right-5 top-5 z-10" + src={getStaticImage(`${starName}/reflection/scene1/离开`)} button onClick={() => { if (moduleName && starName) { - navigate(`/${moduleName}/${starName}/home`); + navigate(`/${moduleName}/${starName}/reflection2`); } }} /> - {/* 路线点击区域 - 根据实际位置调整 */} - <Cursor - className="absolute left-20 top-30" - onClick={() => handleOpenRoute(1)} - read={visited.has(1)} - /> - <Cursor - className="absolute left-40 top-35" - onClick={() => handleOpenRoute(2)} - read={visited.has(2)} - /> - <Cursor - className="absolute left-60 top-40" - onClick={() => handleOpenRoute(3)} - read={visited.has(3)} - /> - <Cursor - className="absolute left-50 top-50" - onClick={() => handleOpenRoute(4)} - read={visited.has(4)} + {/* 左侧:鲁迅的文学足迹 - 地图区域 */} + <div className="w-[44.5%] h-full relative"> + {/* 家国情怀 - 根据showTopics控制显示 */} + {showTopics && ( + <img + className={`absolute top-75 left-70 cursor-pointer transition-opacity ${ + completedQuiz1 ? "opacity-50" : "" + }`} + src={getStaticImage(`${starName}/reflection/scene3/家国情怀`)} + alt="家国情怀" + onClick={handleJiaguoClick} + /> + )} + {/* 批判现实 - 根据showTopics和completedQuiz1控制显示 */} + {showTopics && ( + <img + className={`absolute bottom-15 left-120 cursor-pointer transition-opacity ${ + completedQuiz2 ? "opacity-50" : "" + }`} + src={getStaticImage(`${starName}/reflection/scene3/批判现实`)} + alt="批判现实" + onClick={handlePipingClick} + /> + )} + + {/* 提示图标 - 家国情怀左侧 */} + {hintPosition === "jiaguo" && showTopics && ( + <div className="absolute top-100 left-85 z-10 pointer-events-none"> + <img + className="animate-finger-bounce scale-x-[-1]" + draggable={false} + src={getStaticImage( + `${starName}/reflection/scene1/鲁迅体悟点一点图标` + )} + alt="点一点图标" + /> + <img + draggable={false} + src={getStaticImage( + `${starName}/reflection/scene1/鲁迅体悟改版点一点` + )} + alt="点一点" + /> + </div> + )} + + {/* 提示图标 - 批判现实旁边 */} + {hintPosition === "piping" && showTopics && completedQuiz1 && ( + <div className="absolute bottom-0 left-180 z-10 pointer-events-none"> + <img + className="animate-finger-bounce scale-x-[-1]" + draggable={false} + src={getStaticImage( + `${starName}/reflection/scene1/鲁迅体悟点一点图标` + )} + alt="点一点图标" + /> + <img + draggable={false} + src={getStaticImage( + `${starName}/reflection/scene1/鲁迅体悟改版点一点` + )} + alt="点一点" + /> + </div> + )} + </div> + + {/* 右侧区域 */} + <div className="flex-1 relative"> + {/* 任务进度 */} + <div className="absolute top-6 left-[23.8%] w-[430px] h-51 flex justify-center items-center"> + <span className="text-white text-[54px] font-bold [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]"> + {allCompleted + ? "任务进度已完成" + : `任务进度:(${progress}/${totalTasks})`} + </span> + </div> + {/* 鲁迅精神内核 */} + <div className="absolute top-[calc(2.4rem+204px)] left-0 w-[995px] h-[525px] pt-16 px-10"> + <div className="flex items-center justify-between"> + {/* 提示图标 - 鲁迅1左侧 */} + {hintPosition === "luxun1" && ( + <div className="absolute left-80 top-60 flex items-center z-10 pointer-events-none"> + <img + className="animate-finger-bounce scale-x-[-1]" + draggable={false} + src={getStaticImage( + `${starName}/reflection/scene1/鲁迅体悟点一点图标` + )} + alt="点一点图标" + /> + <img + draggable={false} + src={getStaticImage( + `${starName}/reflection/scene1/鲁迅体悟改版点一点` + )} + alt="点一点" + /> + </div> + )} + <img + className="cursor-pointer" + src={getStaticImage(`${starName}/reflection/scene3/鲁迅1`)} + alt="鲁迅精神内核" + onClick={handleLuxun1Click} + /> + <img + className="cursor-pointer" + src={getStaticImage(`${starName}/reflection/scene3/鲁迅2`)} + alt="鲁迅精神内核" + onClick={handleLuxun2Click} + /> + {/* 提示图标 - 鲁迅2旁边 */} + {hintPosition === "luxun2" && allCompleted && ( + <div className="absolute right-0 top-70 z-10 pointer-events-none"> + <img + className="animate-finger-bounce scale-x-[-1]" + draggable={false} + src={getStaticImage( + `${starName}/reflection/scene1/鲁迅体悟点一点图标` + )} + alt="点一点图标" + /> + <img + draggable={false} + src={getStaticImage( + `${starName}/reflection/scene1/鲁迅体悟改版点一点` + )} + alt="点一点" + /> + </div> + )} + </div> + </div> + {/* 精神总结 */} + <div className="absolute bottom-2 right-8 w-[995px] h-[285px] px-2 py-12"> + {showSummary ? ( + // 点击鲁迅2后显示总结内容 + <div className="h-full px-8 overflow-y-auto custom-scrollbar"> + <div className="space-y-0"> + <div className="text-red-600 text-4xl font-bold leading-relaxed"> + 1、批判现实 (他以犀利的文笔批判封建传统); + </div> + <div className="text-red-600 text-4xl font-bold leading-relaxed"> + 2、关注底层 (他笔下的主角多是社会底层); + </div> + <div className="text-red-600 text-4xl font-bold leading-relaxed"> + 3、家国情怀 (他投身新文化与左翼运动); + </div> + </div> + </div> + ) : ( + // 未完成时显示提示内容 + <div className="text-center text-black text-4xl font-bold"> + 请跟随小手指点一点,完成操作和题目以查看总结 + </div> + )} + </div> + </div> + + {/* 题目1:家国情怀 */} + <SingleChoiceQuizPanel + visible={showQuiz1} + title="【单选题】先生的作品《狂人日记》、《阿Q正传》都在揭露的是()" + options={[ + { value: "A", label: "A. 旧时代的黑暗与愚昧" }, + { value: "B", label: "B. 生活中的小美好" }, + ]} + correctAnswer="A" + errorMessage='不是哦,先生的小说核心是批判而非歌颂哦!"请结合阿Q的碎片回忆一下吧!"' + onSubmit={handleQuiz1Submit} /> - {/* 主题点击区域 */} - <Cursor - className="absolute left-30 top-60" - onClick={() => handleOpenTheme(5)} - read={visited.has(5)} - /> - <Cursor - className="absolute left-70 top-55" - onClick={() => handleOpenTheme(6)} - read={visited.has(6)} - /> - - {/* 路线展品弹窗 */} - <ExhibitOverlay - visible={activeRouteId !== null} - src={activeRouteId ? routeMap[activeRouteId] : ""} - onClose={() => { - if (activeRouteId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeRouteId); - return next; - }); - } - setActiveRouteId(null); - }} - /> - - {/* 主题展品弹窗 */} - <ExhibitOverlay - visible={activeThemeId !== null} - src={activeThemeId ? themeMap[activeThemeId] : ""} - onClose={() => { - if (activeThemeId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeThemeId); - return next; - }); - } - setActiveThemeId(null); - }} + {/* 题目2:批判现实 */} + <SingleChoiceQuizPanel + visible={showQuiz2} + title="【单选题】先生辗转多地创作,核心追求是()" + options={[ + { value: "A", label: "A. 唤醒国人,改变社会" }, + { value: "B", label: "B. 只为个人成名" }, + ]} + correctAnswer="A" + errorMessage='不是哦,先生的创作从不是为了自己哦!"请回忆先生"弃医从文"的初心。"' + onSubmit={handleQuiz2Submit} /> </div> ); diff --git a/src/pages/sunzhongshan/internalization.tsx b/src/pages/sunzhongshan/internalization.tsx index 2b38911..54b7ee3 100644 --- a/src/pages/sunzhongshan/internalization.tsx +++ b/src/pages/sunzhongshan/internalization.tsx @@ -1,38 +1,382 @@ +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, + LetterToSunzhongshan, + 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 }[]; +}; + +// 星名固定为 sunzhongshan const starName = "sunzhongshan"; -const SunzhongshanInternalization = () => { +// 锚点数据:可根据实际视频时长调整 time +const QUIZ_ANCHORS: QuizAnchor[] = [ + { + id: "quiz-1", + time: 11, //5.52分钟 + title: "题目1", + prompt: '"振兴中华"对我们新时代青年还有意义吗?', + type: "multiple", + options: [ + { + value: "A", + label: "在民族复兴的征程上,解决发展中的新问题、实现更高水平的发展", + isCorrect: true, + }, + { + value: "B", + label: + "当时的中国正遭受帝国主义列强的野蛮侵略和封建专制制度的腐朽统治,对于新时代的意义不大", + explanation: + '"振兴中华"需要每一代人的努力,先生当年在上海撰写的《实业计划》里如今已经实现的那些宏伟计划就是我国国家代代相传的结果', + }, + { + value: "C", + label: + "我们要专注学习,努力提升自身技能和素养,用精益求精的精神服务于中国智造", + isCorrect: true, + }, + { + value: "D", + label: "中华民族的伟大复兴离不开我们每一位新时代青年的奋斗", + isCorrect: true, + }, + ], + }, + { + id: "quiz-2", + time: 42, + title: "题目2", + prompt: "我们新时代青年从孙中山先生身上能学到哪些?", + type: "multiple", + options: [ + { + value: "A", + label: '"心系民众,天下为公"的家国情怀', + isCorrect: true, + }, + { + value: "B", + label: "与时俱进,主动学习新知识,适应新形势", + isCorrect: true, + }, + { value: "C", label: "为中华民族伟大复兴而读书", isCorrect: true }, + { + value: "D", + label: "将振兴中国之责任,置之于自身之肩上", + isCorrect: true, + }, + ], + }, +]; + +const ChenyunInternalization = () => { 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]: "" })); + + if (val.length < correctAnswers.length) return; + + // 检查是否包含了所有正确答案(不要求顺序) + const filledAnswers = val.map((v) => v.optionValue); + const answerSet = new Set(correctAnswers); + const filledSet = new Set(filledAnswers); + const isCorrect = + answerSet.size === filledSet.size && + [...answerSet].every((answer) => filledSet.has(answer)); + + if (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/sunzhongshan/sunzhongshan_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="guide"> + 你的感受特别真实!孙中山先生的精神不是停留在书本里的文字,而是能指导我们当下行动的思想财富。今天我们特别邀请到了上海孙中山故居纪念馆研究部主任孙旻老师,让她带我们一起解读先生精神的时代意义,把这份传承落到实处。 + </GuideStepsItem> + </GuideSteps> + + {/* 视频结束后的引导 */} + <GuideSteps + visible={postGuideVisible} + onVisibleChange={(visible) => { + setPostGuideVisible(visible); + // 引导结束后显示信件 + if (!visible) { + setShowLetter(true); + } + }} + showMask={false} + zIndex={60} + > + <GuideStepsItem speaker="guide"> + 再看香山路7号这座小楼,相信你有很多心里话想对孙中山先生说。现在,就请你把这份感悟浓缩成几句话,既呼应先生的精神,也给自己未来许下一个承诺吧! + </GuideStepsItem> + </GuideSteps> + + {/* 信件组件:视频引导结束后弹出,带飞入和放大动画 */} + <LetterToSunzhongshan + visible={showLetter} + onSubmit={(values: string[]) => { + // 提交信件的逻辑(values 为多个填空的内容) + 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`); + }} + > + 恭喜你已经顺利完成了孙中山故居纪念馆的学习,至此,你已经完成了本课程所有故居的学习, + 希望你能有所收获,学习八位名人的优秀品质和精神,在新时代为中华民族伟大复兴发光发热! + {/* 同学你好,欢迎来到走进故居-对话学习模块!{"\n"} + 进入陈云故居后,我们一起来了解陈云同志经历的7个阶段{"\n"} + “水乡少年 艰辛成长”、“商务启航 投身革命”、“苏区淬炼 崭露头角”、“延安担当 + 经济破局”、{"\n"} + “恢复时期 经济奠基”、“建设时期 务实兴邦”、“改革浪潮 誉画实业”; */} + </StudyGuideModal> </div> ); }; -export default SunzhongshanInternalization; - +export default ChenyunInternalization; diff --git a/src/pages/sunzhongshan/place/place1.tsx b/src/pages/sunzhongshan/place/place1.tsx index 71f8405..0213192 100644 --- a/src/pages/sunzhongshan/place/place1.tsx +++ b/src/pages/sunzhongshan/place/place1.tsx @@ -53,8 +53,8 @@ const SunzhongshanPlace1 = () => { const total = 5; const completed = progressCount >= total; const subtitle = completed - ? "探索孙中山故居老宅堂屋(已完成)" - : `探索孙中山故居老宅堂屋(${progressCount}/${total})`; + ? "探索孙中山故居纪念馆老宅堂屋(已完成)" + : `探索孙中山故居纪念馆老宅堂屋(${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; diff --git a/src/pages/sunzhongshan/place/place2.tsx b/src/pages/sunzhongshan/place/place2.tsx index fbc6424..1cd7120 100644 --- a/src/pages/sunzhongshan/place/place2.tsx +++ b/src/pages/sunzhongshan/place/place2.tsx @@ -50,8 +50,8 @@ const SunzhongshanPlace2 = () => { const total = 4; const completed = progressCount >= total; const subtitle = completed - ? "探索孙中山故居老宅堂屋(已完成)" - : `探索孙中山故居老宅堂屋(${progressCount}/${total})`; + ? "探索孙中山故居纪念馆老宅堂屋(已完成)" + : `探索孙中山故居纪念馆老宅堂屋(${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; diff --git a/src/pages/sunzhongshan/place/place3.tsx b/src/pages/sunzhongshan/place/place3.tsx index 564b834..e1961e9 100644 --- a/src/pages/sunzhongshan/place/place3.tsx +++ b/src/pages/sunzhongshan/place/place3.tsx @@ -31,8 +31,8 @@ const SunzhongshanPlace3 = () => { const total = 4; const completed = progressCount >= total; const subtitle = completed - ? "探索孙中山故居老宅堂屋(已完成)" - : `探索孙中山故居老宅堂屋(${progressCount}/${total})`; + ? "探索孙中山故居纪念馆老宅堂屋(已完成)" + : `探索孙中山故居纪念馆老宅堂屋(${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; diff --git a/src/pages/sunzhongshan/place/place4.tsx b/src/pages/sunzhongshan/place/place4.tsx index 496a95e..2447ad7 100644 --- a/src/pages/sunzhongshan/place/place4.tsx +++ b/src/pages/sunzhongshan/place/place4.tsx @@ -27,8 +27,8 @@ const SunzhongshanPlace4 = () => { const total = 2; const completed = progressCount >= total; const subtitle = completed - ? "探索孙中山故居老宅堂屋(已完成)" - : `探索孙中山故居老宅堂屋(${progressCount}/${total})`; + ? "探索孙中山故居纪念馆老宅堂屋(已完成)" + : `探索孙中山故居纪念馆老宅堂屋(${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; diff --git a/src/router/modules/huangyanpei.tsx b/src/router/modules/huangyanpei.tsx index c028a2c..28913cd 100644 --- a/src/router/modules/huangyanpei.tsx +++ b/src/router/modules/huangyanpei.tsx @@ -3,12 +3,24 @@ import { RouteObject } from "react-router-dom"; import { ResidenceLayout } from "@/components/layout"; const HomePage = lazy(() => import("@/pages/huangyanpei/home")); -const HuangyanpeiPlace1 = lazy(() => import("@/pages/huangyanpei/place/place1")); -const HuangyanpeiPlace2 = lazy(() => import("@/pages/huangyanpei/place/place2")); -const HuangyanpeiPlace3 = lazy(() => import("@/pages/huangyanpei/place/place3")); -const HuangyanpeiPlace4 = lazy(() => import("@/pages/huangyanpei/place/place4")); -const HuangyanpeiPlace5 = lazy(() => import("@/pages/huangyanpei/place/place5")); -const HuangyanpeiPlace6 = lazy(() => import("@/pages/huangyanpei/place/place6")); +const HuangyanpeiPlace1 = lazy( + () => import("@/pages/huangyanpei/place/place1") +); +const HuangyanpeiPlace2 = lazy( + () => import("@/pages/huangyanpei/place/place2") +); +const HuangyanpeiPlace3 = lazy( + () => import("@/pages/huangyanpei/place/place3") +); +const HuangyanpeiPlace4 = lazy( + () => import("@/pages/huangyanpei/place/place4") +); +const HuangyanpeiPlace5 = lazy( + () => import("@/pages/huangyanpei/place/place5") +); +const HuangyanpeiPlace6 = lazy( + () => import("@/pages/huangyanpei/place/place6") +); const HuangyanpeiReflection = lazy( () => import("@/pages/huangyanpei/reflection/scene1") ); @@ -50,42 +62,42 @@ export const HuangyanpeiRoutes: RouteObject[] = [ path: "place1", element: <HuangyanpeiPlace1 />, handle: { - videoSrc: "video/huangyanpei/place1.mp4", + videoSrc: "video/huangyanpei/huangyanpei_01.mp4", }, }, { path: "place2", element: <HuangyanpeiPlace2 />, handle: { - videoSrc: "video/huangyanpei/place2.mp4", + videoSrc: "video/huangyanpei/huangyanpei_02.mp4", }, }, { path: "place3", element: <HuangyanpeiPlace3 />, handle: { - videoSrc: "video/huangyanpei/place3.mp4", + videoSrc: "video/huangyanpei/huangyanpei_03.mp4", }, }, { path: "place4", element: <HuangyanpeiPlace4 />, handle: { - videoSrc: "video/huangyanpei/place4.mp4", + videoSrc: "video/huangyanpei/huangyanpei_04.mp4", }, }, { path: "place5", element: <HuangyanpeiPlace5 />, handle: { - videoSrc: "video/huangyanpei/place5.mp4", + videoSrc: "video/huangyanpei/huangyanpei_06.mp4", }, }, { path: "place6", element: <HuangyanpeiPlace6 />, handle: { - videoSrc: "video/huangyanpei/place6.mp4", + videoSrc: "video/huangyanpei/huangyanpei_08.mp4", }, }, { @@ -114,4 +126,3 @@ export const HuangyanpeiRoutes: RouteObject[] = [ ], }, ]; - diff --git a/src/router/modules/luxun.tsx b/src/router/modules/luxun.tsx index f11763a..3fd7e76 100644 --- a/src/router/modules/luxun.tsx +++ b/src/router/modules/luxun.tsx @@ -7,14 +7,9 @@ const LuxunPlace1 = lazy(() => import("@/pages/luxun/place/place1")); const LuxunPlace2 = lazy(() => import("@/pages/luxun/place/place2")); const LuxunPlace3 = lazy(() => import("@/pages/luxun/place/place3")); const LuxunPlace4 = lazy(() => import("@/pages/luxun/place/place4")); -const LuxunPlace5 = lazy(() => import("@/pages/luxun/place/place5")); const LuxunReflection = lazy(() => import("@/pages/luxun/reflection/scene1")); -const LuxunReflection2 = lazy( - () => import("@/pages/luxun/reflection/scene2") -); -const LuxunReflection3 = lazy( - () => import("@/pages/luxun/reflection/scene3") -); +const LuxunReflection2 = lazy(() => import("@/pages/luxun/reflection/scene2")); +const LuxunReflection3 = lazy(() => import("@/pages/luxun/reflection/scene3")); const LuxunInternalization = lazy( () => import("@/pages/luxun/internalization") ); @@ -44,35 +39,28 @@ export const LuxunRoutes: RouteObject[] = [ path: "place1", element: <LuxunPlace1 />, handle: { - videoSrc: "video/luxun/place1.mp4", + videoSrc: "video/luxun/luxun_01.mp4", }, }, { path: "place2", element: <LuxunPlace2 />, handle: { - videoSrc: "video/luxun/place2.mp4", + videoSrc: "video/luxun/luxun_02.mp4", }, }, { path: "place3", element: <LuxunPlace3 />, handle: { - videoSrc: "video/luxun/place3.mp4", + videoSrc: "video/luxun/luxun_03.mp4", }, }, { path: "place4", element: <LuxunPlace4 />, handle: { - videoSrc: "video/luxun/place4.mp4", - }, - }, - { - path: "place5", - element: <LuxunPlace5 />, - handle: { - videoSrc: "video/luxun/place5.mp4", + videoSrc: "video/luxun/luxun_05.mp4", }, }, { @@ -97,4 +85,3 @@ export const LuxunRoutes: RouteObject[] = [ ], }, ]; - diff --git a/src/router/modules/sunzhongshan.tsx b/src/router/modules/sunzhongshan.tsx index 7d90760..c7a371f 100644 --- a/src/router/modules/sunzhongshan.tsx +++ b/src/router/modules/sunzhongshan.tsx @@ -56,28 +56,28 @@ export const SunzhongshanRoutes: RouteObject[] = [ path: "place1", element: <SunzhongshanPlace1 />, handle: { - videoSrc: "video/sunzhongshan/place1.mp4", + videoSrc: "video/sunzhongshan/sunzhongshan_01.mp4", }, }, { path: "place2", element: <SunzhongshanPlace2 />, handle: { - videoSrc: "video/sunzhongshan/place2.mp4", + videoSrc: "video/sunzhongshan/sunzhongshan_04.mp4", }, }, { path: "place3", element: <SunzhongshanPlace3 />, handle: { - videoSrc: "video/sunzhongshan/place3.mp4", + videoSrc: "video/sunzhongshan/sunzhongshan_07.mp4", }, }, { path: "place4", element: <SunzhongshanPlace4 />, handle: { - videoSrc: "video/sunzhongshan/place4.mp4", + videoSrc: "video/sunzhongshan/sunzhongshan_09.mp4", }, }, {