feat:黄炎培对话对接中

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

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 907 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 735 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

+34
View File
@@ -154,3 +154,37 @@ export type { ComponentNameProps, ComponentNameRef } from "./component-name";
| `*.hooks.ts` | 组件专用 hooks | 有可复用的状态逻辑 |
| `*.utils.ts` | 工具函数 | 有纯函数逻辑需要提取 |
| `*.constants.ts` | 常量定义 | 有多个常量需要共享 |
<!-- 1. common / base (基础组件)
存放与业务逻辑解耦的原子级组件。这些组件通常不包含 API 请求,只负责展示和交互。
示例:MyButton, MyInput, Modal, Icon。
用途:如果项目没有使用 Ant Design 或 Element Plus 等 UI 库,或者在其基础上二次封装,则放在此处 [1, 2]。
2. business / biz (业务组件)
存放与特定业务逻辑耦合、但在多个页面中复用的组件。
示例:UserSelect(包含用户列表接口请求)、AddressPicker、OrderCard。
用途:这些组件包含业务状态或特定的数据处理逻辑 [1, 3]。
3. layout (布局组件)
存放页面的整体结构组件。
示例:Header, Footer, Sidebar, NavBar。
用途:定义应用的外壳结构,通常在全局路由配置中使用 [2, 4]。
4. hoc / render-props (高阶/逻辑组件)
存放不直接渲染 UI,而是通过高阶组件(HOC)或 Render Props 模式提供逻辑复用的组件。
示例:withAuth(权限校验)、ErrorBoundary(错误边界)。
5. svg / icons (图标组件)
专门用于存放封装后的 SVG 图标或字体图标组件。
用途:方便通过组件形式调用图标,如 <IconName />。
标准组件内部结构
为了保持清晰,每个具体的组件通常建议以文件夹的形式存在,而不是单个文件:
text
components/
└── MyComponent/
├── index.ts # 组件对外暴露的入口
├── MyComponent.tsx # 组件核心代码
├── MyComponent.scss # 组件私有样式
├── types.ts # TypeScript 类型定义
├── hooks.ts # 组件内部抽离的逻辑 (可选)
└── __tests__/ # 单元测试文件 (可选)
请谨慎使用此类代码。
注意事项:页面级组件
请注意,仅在单个页面中使用的私有组件,通常不建议放在 src/components 下,而应放在对应页面的文件夹内(例如 src/pages/Home/components),以避免全局组件目录过于臃肿 -->
+29 -41
View File
@@ -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<HTMLDivElement>) => {
// 如果禁用了拖拽,直接返回,不处理拖拽事件
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 = ({
{/* 展品内容 - 在剩余空间中居中 */}
<div
className="absolute top-1/2 -translate-y-1/2"
className="absolute top-1/2 -translate-y-1/2 pointer-events-none"
style={{
// 有描述时:从描述右侧30px开始,到右边界,在这个区域内居中
left: description ? "520px" : "0", // 30 + 450 + 30
@@ -510,17 +514,22 @@ const ExhibitOverlay = ({
maxHeight: containerSize,
width: "100%",
height: "100%",
cursor: disableDrag
? "default"
: isDragging
? "grabbing"
: "grab",
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onWheel={handleWheel}
onDoubleClick={handleDoubleClick}
onPointerDown={disableDrag ? undefined : handlePointerDown}
onPointerMove={disableDrag ? undefined : handlePointerMove}
onPointerUp={disableDrag ? undefined : handlePointerUp}
onPointerCancel={disableDrag ? undefined : handlePointerUp}
onWheel={disableDrag ? undefined : handleWheel}
onDoubleClick={disableDrag ? undefined : handleDoubleClick}
>
<div
ref={viewElRef}
className="max-w-full max-h-full select-none relative"
className="max-w-full max-h-full select-none relative pointer-events-auto"
style={{
transform: "translate3d(0px, 0px, 0) scale(1)",
transformOrigin: "0 0",
@@ -533,34 +542,13 @@ const ExhibitOverlay = ({
WebkitPerspective: "1000",
perspective: "1000",
}}
onClick={onClick}
>
<Image
src={exhibitSrc}
alt="展品"
className="max-w-full max-h-full object-contain drop-shadow-2xl"
/>
{/* 可点击区域 */}
{clickableAreas &&
clickableAreas.map((area, index) => (
<div
key={index}
className="absolute cursor-pointer transition-all duration-200 hover:bg-white/10"
style={{
left: `${area.position.left}%`,
top: `${area.position.top}%`,
width: `${area.position.width}%`,
height: `${area.position.height}%`,
}}
onClick={(e) => {
e.stopPropagation();
area.onClick();
}}
onPointerDown={(e) => {
// 阻止拖拽事件
e.stopPropagation();
}}
/>
))}
</div>
</div>
</div>
+139
View File
@@ -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 = (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ zIndex, pointerEvents: "auto" }}
>
{/* 背景遮罩 */}
<div className="absolute inset-0 bg-black/60" onClick={handleMaskClick} />
{/* 引导内容 */}
<div className="relative w-[1700px] h-[760px] pointer-events-none pt-65">
{/* 引导框背景 */}
<img
src={getStaticImage("components/guide/引导框")}
alt="学习引导背景"
className="absolute inset-0 w-full h-full object-contain"
/>
{/* 文本内容 */}
<div
className={cn(
"relative z-10 w-[1400px] flex items-center justify-center mx-auto text-white pointer-events-none",
ailgn === "left" && "justify-start",
ailgn === "right" && "justify-end"
)}
>
{/* {title && (
<div className="mt-40 text-5xl font-bold text-[#ffe7b2] tracking-[12px]">
{title}
</div>
)} */}
{children && (
<div
className={cn(
"text-4xl leading-relaxed whitespace-pre-line",
"[text-shadow:_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4)]"
)}
style={{
marginTop: verticalAlign === "center" ? "100px" : "",
}}
>
{children}
</div>
)}
</div>
{/* 继续按钮 */}
<div className="absolute right-15 bottom-8 z-20 pointer-events-auto">
<Image
src={getStaticImage("components/guide/继续")}
alt="继续"
button
onClick={onContinue}
/>
</div>
</div>
</div>
);
// 当 zIndex >= 10000 时,使用 Portal 渲染到 document.body,并应用缩放
if (zIndex >= 10000) {
const scaledContent = (
<div
style={{
position: "fixed",
inset: 0,
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`,
top: `${offsetY}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
pointerEvents: "auto",
zIndex,
}}
>
{content}
</div>
);
return createPortal(scaledContent, document.body);
}
return content;
};
+1
View File
@@ -1 +1,2 @@
export { GuideSteps, GuideStepsItem } from "./GuideSteps";
export { StudyGuideModal } from "./StudyGuideModal";
+15 -5
View File
@@ -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";
@@ -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 (
<div
className="absolute inset-0 z-50 flex items-center justify-center"
style={{ zIndex }}
>
{/* 蒙版背景 */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url(${getStaticImage("components/蒙版遮罩")})`,
}}
onClick={onMaskClick || onClose}
/>
{/* 内容 */}
<div className="relative w-full z-10 flex flex-col items-center justify-center gap-10">
{/* 标题和副标题容器 - 带深色背景条 */}
<div className="w-full bg-black/70 px-20 py-20 flex flex-col items-center gap-4">
{/* 副标题 */}
<div className="text-white text-5xl font-medium tracking-widest">
{text}
</div>
</div>
{/* 继续按钮 */}
<div
className="w-[414px] h-[109px] bg-cover bg-center bg-no-repeat cursor-pointer hover:scale-105 hover:brightness-110 transition-all duration-300"
style={{
backgroundImage: `
${getBackgroundImage("components/继续前进")},
${getBackgroundImage("components/按钮")}
`,
}}
onClick={onClose}
onMouseDown={(e) => e.stopPropagation()}
></div>
</div>
</div>
);
};
export default ErrorAlert;
+6 -4
View File
@@ -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 {
+2 -5
View File
@@ -24,7 +24,7 @@ const DragQuizPanel = ({
>
<div className="relative w-[1728px] h-[972px] flex flex-col items-center pt-12 pb-14 gap-10">
{/* 题干卡片 */}
<div className="w-[1600px] min-h-[220px] rounded-[32px] bg-black/90 px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
<div className="w-[1600px] rounded-[32px] bg-black/90 px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
<div className="text-[32px] leading-relaxed text-white">
{title && <div className="mb-4">{title}</div>}
<div>
@@ -116,9 +116,7 @@ const DragQuizPanel = ({
</div>
{error && (
<div className="mt-6 text-center text-[26px] text-[#ff4d4f]">
{error}
</div>
<div className="text-center text-[26px] text-[#ff4d4f]">{error}</div>
)}
</div>
</div>
@@ -126,4 +124,3 @@ const DragQuizPanel = ({
};
export default DragQuizPanel;
+165
View File
@@ -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<string[]>([]);
// 信件内容模板(根据设计稿)
const letterContent = `敬爱的陈云爷爷:
您好!
我是____专业的学生。今天,我怀着崇敬又激动的心情,参观了位于上海市青浦区练塘镇的陈云纪念馆,学习了您“不唯上、不唯书、只唯____”的做事准则,还有“综合平衡”“人民至上”的经济思想。我深刻体会到,这些精神不仅是建设年代的智慧结晶,更是新时代指引我们前行的思想灯塔。
作为新时代大学生,我会在专业学习中____,像您当年练珠算、搞调查那样精益求精;未来无论从事什么工作,都要把服务群众____放在首位;面对困难时不退缩,像您当年稳定物价、推进实业那样,用____本领(结合所学专业)为国家发展贡献自己的力量!
站在新时代的起点,我一定以____为志向,勤学苦练,传承精神,让您的务实担当在青春里扎根,让奋斗在建设祖国的实践中绽放光彩!`;
return (
<AnimatePresence>
{visible && (
<>
{/* 遮罩层 */}
<motion.div
className="fixed inset-0 bg-black/50 z-[100]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={onClose}
/>
{/* 信件容器 */}
<motion.div
className="fixed inset-0 z-[101] flex items-center justify-center pointer-events-none"
initial={{ opacity: 0, scale: 0.5, y: 100 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.5, y: 100 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.6,
}}
>
<div
className="relative w-[1200px] h-full pointer-events-auto"
onClick={(e) => e.stopPropagation()}
>
{/* 信纸背景 */}
<img
src={getStaticImage("components/letter/信底")}
alt="信纸"
className="absolute inset-0 w-full h-full object-contain"
/>
{/* 标题 - 垂直显示在左侧 */}
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
<div
className="text-white text-6xl font-bold tracking-[20px]"
style={{
writingMode: "vertical-rl",
textOrientation: "upright",
}}
>
寄给陈云爷爷的信
</div>
</div>
{/* 信件内容区域 */}
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
<div className="text-[#4e402e] text-[26px] leading-relaxed">
{(() => {
let placeholderIndex = 0;
return letterContent.split("\n").map((line, lineIndex) => {
const segments = line.split("____");
return (
<div key={lineIndex} className="mb-6">
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1;
if (isLast) {
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
</span>
);
}
const currentIndex = placeholderIndex++;
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
<input
className="inline-block w-[100px] h-[36px] mx-2 align-middle bg-transparent border-b-2 border-dashed border-[#4e402e] text-[24px] text-center outline-none"
value={blankValues[currentIndex] ?? ""}
onChange={(e) => {
const next = [...blankValues];
next[currentIndex] = e.target.value;
setBlankValues(next);
}}
/>
</span>
);
})}
</div>
);
});
})()}
</div>
</div>
{/* 笔的图片 */}
<div className="absolute bottom-[150px] -right-30">
<img
src={getStaticImage("components/letter/笔")}
alt="笔"
className="w-full h-full object-contain"
/>
</div>
{/* 提交按钮 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image
src={getStaticImage("components/letter/可提交状态")}
alt="可提交状态"
onClick={() => onSubmit?.(blankValues)}
button
/>
</div>
{/* 寄信图标(可选,如果需要的话) */}
<div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
<img
src={getStaticImage("components/letter/寄信图标")}
alt="寄信"
className="w-full h-full object-contain"
/>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
};
export default LetterToChenyun;
@@ -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<string[]>([]);
// 信件内容模板(根据设计稿)
const letterContent = `敬爱的黄炎培先生:
您好!
今天,我循着您的足迹,走进了川沙小学堂的新式课堂,亲历了中华职业学校"手脑并用"的实训时光。那些您为破除旧教育桎梏的奔走、为推广职业教育的坚守、为支援国家危亡的热忱,不再是书本上的文字,而是可感可触的精神力量,深深烙印在我心中。
我终于明白,您喊出的"双手万能、____",不是一句口号,而是我们的立身之本;您践行的"教育救国、实业报国",不是一段历史,而是我们这代人接续的使命;您坚守的"因时求变",不是一种选择,而是我们面对未来的底气。
我是____专业的学生,今后,我会在实训课堂上精益求精,像当年中华职校的学长学姐那样,把技能练到极致;我会在志愿服务中践行担当,用所学知识为身边人、为社会做实事;我更会紧跟时代步伐,努力成为____领域的技能人才,为中国制造升级、为民族产业振兴添砖加瓦!
请您放心,您播下的职教火种,我们会让它在新时代燃烧得更旺;您毕生追求的"无业者有业、____"的愿景,我们会用青春和汗水去实现!`;
return (
<AnimatePresence>
{visible && (
<>
{/* 遮罩层 */}
<motion.div
className="fixed inset-0 bg-black/50 z-[100]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={onClose}
/>
{/* 信件容器 */}
<motion.div
className="fixed inset-0 z-[101] flex items-center justify-center pointer-events-none"
initial={{ opacity: 0, scale: 0.5, y: 100 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.5, y: 100 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.6,
}}
>
<div
className="relative w-[1200px] h-full pointer-events-auto"
onClick={(e) => e.stopPropagation()}
>
{/* 信纸背景 */}
<img
src={getStaticImage("components/letter/信底")}
alt="信纸"
className="absolute inset-0 w-full h-full object-contain"
/>
{/* 标题 - 垂直显示在左侧 */}
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
<div
className="text-white text-6xl font-bold tracking-[20px]"
style={{
writingMode: "vertical-rl",
textOrientation: "upright",
}}
>
写给黄炎培先生的信
</div>
</div>
{/* 信件内容区域 */}
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
<div className="text-[#4e402e] text-[26px] leading-relaxed">
{(() => {
let placeholderIndex = 0;
return letterContent.split("\n").map((line, lineIndex) => {
const segments = line.split("____");
return (
<div key={lineIndex} className="mb-0">
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1;
if (isLast) {
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
</span>
);
}
const currentIndex = placeholderIndex++;
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
<input
className="inline-block w-[100px] h-[36px] mx-2 align-middle bg-transparent border-b-2 border-dashed border-[#4e402e] text-[24px] text-center outline-none"
value={blankValues[currentIndex] ?? ""}
onChange={(e) => {
const next = [...blankValues];
next[currentIndex] = e.target.value;
setBlankValues(next);
}}
/>
</span>
);
})}
</div>
);
});
})()}
</div>
</div>
{/* 笔的图片 */}
<div className="absolute bottom-[150px] -right-30">
<img
src={getStaticImage("components/letter/笔")}
alt="笔"
className="w-full h-full object-contain"
/>
</div>
{/* 提交按钮 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image
src={getStaticImage("components/letter/可提交状态")}
alt="可提交状态"
onClick={() => onSubmit?.(blankValues)}
button
/>
</div>
{/* 寄信图标(可选,如果需要的话) */}
<div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
<img
src={getStaticImage("components/letter/寄信图标")}
alt="寄信"
className="w-full h-full object-contain"
/>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
};
export default LetterToHuangyanpei;
+166
View File
@@ -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<string[]>([]);
// 信件内容模板(根据设计稿)
const letterContent = `敬爱的鲁迅先生:
您好!
今天,我怀着崇敬的心情走进大陆新村9号,看着您亲手撰写的《呐喊》《且介亭杂文》《故事新编》等作品,您编辑的《萌芽》《前哨》等杂志,您翻译的《艺术论》《死魂灵》等译文,仿佛能看到您"舌将上下而求索"的身影。在这场跨越时空的对话中,我终于明白您坚守的"____",不是抽象的理念,而是我们安身立命的根本;您秉持的"____",不是无谓的锋芒,而是我们直面现实的勇气。
我是____专业的学生,今后,我会以您"____干夫指"的骨气,在是非面前坚守立场,不随波逐流;我会以您"俯首甘为____"的热忱,参与____活动,用微光温暖他人;我更会记住您"____,走的人多了,便成了路"的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
请您放心,您播下的"立人"火种,我们会让它在新时代燎原;您毕生追求的"人国"愿景,我们会用青春和奋斗去守护!`;
return (
<AnimatePresence>
{visible && (
<>
{/* 遮罩层 */}
<motion.div
className="fixed inset-0 bg-black/50 z-[100]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={onClose}
/>
{/* 信件容器 */}
<motion.div
className="fixed inset-0 z-[101] flex items-center justify-center pointer-events-none"
initial={{ opacity: 0, scale: 0.5, y: 100 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.5, y: 100 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.6,
}}
>
<div
className="relative w-[1200px] h-full pointer-events-auto"
onClick={(e) => e.stopPropagation()}
>
{/* 信纸背景 */}
<img
src={getStaticImage("components/letter/信底")}
alt="信纸"
className="absolute inset-0 w-full h-full object-contain"
/>
{/* 标题 - 垂直显示在左侧 */}
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
<div
className="text-white text-6xl font-bold tracking-[20px]"
style={{
writingMode: "vertical-rl",
textOrientation: "upright",
}}
>
写给鲁迅先生的信
</div>
</div>
{/* 信件内容区域 */}
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
<div className="text-[#4e402e] text-[26px] leading-relaxed">
{(() => {
let placeholderIndex = 0;
return letterContent.split("\n").map((line, lineIndex) => {
const segments = line.split("____");
return (
<div key={lineIndex} className="mb-2">
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1;
if (isLast) {
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
</span>
);
}
const currentIndex = placeholderIndex++;
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
<input
className="inline-block w-[100px] h-[36px] mx-2 align-middle bg-transparent border-b-2 border-dashed border-[#4e402e] text-[24px] text-center outline-none"
value={blankValues[currentIndex] ?? ""}
onChange={(e) => {
const next = [...blankValues];
next[currentIndex] = e.target.value;
setBlankValues(next);
}}
/>
</span>
);
})}
</div>
);
});
})()}
</div>
</div>
{/* 笔的图片 */}
<div className="absolute bottom-[150px] -right-30">
<img
src={getStaticImage("components/letter/笔")}
alt="笔"
className="w-full h-full object-contain"
/>
</div>
{/* 提交按钮 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image
src={getStaticImage("components/letter/可提交状态")}
alt="可提交状态"
onClick={() => onSubmit?.(blankValues)}
button
/>
</div>
{/* 寄信图标(可选,如果需要的话) */}
<div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
<img
src={getStaticImage("components/letter/寄信图标")}
alt="寄信"
className="w-full h-full object-contain"
/>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
};
export default LetterToLuxun;
@@ -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<string[]>([]);
// 信件内容模板(根据设计稿)
const letterContent = `敬爱的孙中山先生:
您好!
今天,我怀着崇敬的心情走进香山路7号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为"振兴中华"奔走的身影。在李教授的解读中,我终于明白:您喊出的"____",不是遥远的历史,而是我们这代人正在接续的接力;您追求的"____(空里填天下为公)",不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的"____(空里填与时俱进)",不是过去的选择,而是面对未来时必须有的勇气。
我是____专业的学生,今后,我会以您"知难行易"的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您"心系民众"的情怀,参与____,帮身边人解决实际困难;我更会记住您"适乎世界之潮流"的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。
请您放心,"革命尚未成功,同志仍须努力"的嘱托,我们从未忘记;"振兴中华"的初心,我们会用一生践行!`;
return (
<AnimatePresence>
{visible && (
<>
{/* 遮罩层 */}
<motion.div
className="fixed inset-0 bg-black/50 z-[100]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={onClose}
/>
{/* 信件容器 */}
<motion.div
className="fixed inset-0 z-[101] flex items-center justify-center pointer-events-none"
initial={{ opacity: 0, scale: 0.5, y: 100 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.5, y: 100 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.6,
}}
>
<div
className="relative w-[1200px] h-full pointer-events-auto"
onClick={(e) => e.stopPropagation()}
>
{/* 信纸背景 */}
<img
src={getStaticImage("components/letter/信底")}
alt="信纸"
className="absolute inset-0 w-full h-full object-contain"
/>
{/* 标题 - 垂直显示在左侧 */}
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
<div
className="text-white text-6xl font-bold tracking-[20px]"
style={{
writingMode: "vertical-rl",
textOrientation: "upright",
}}
>
写给孙中山爷爷的信
</div>
</div>
{/* 信件内容区域 */}
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
<div className="text-[#4e402e] text-[26px] leading-relaxed">
{(() => {
let placeholderIndex = 0;
return letterContent.split("\n").map((line, lineIndex) => {
const segments = line.split("____");
return (
<div key={lineIndex} className="mb-2">
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1;
if (isLast) {
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
</span>
);
}
const currentIndex = placeholderIndex++;
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
<input
className="inline-block w-[100px] h-[36px] mx-2 align-middle bg-transparent border-b-2 border-dashed border-[#4e402e] text-[24px] text-center outline-none"
value={blankValues[currentIndex] ?? ""}
onChange={(e) => {
const next = [...blankValues];
next[currentIndex] = e.target.value;
setBlankValues(next);
}}
/>
</span>
);
})}
</div>
);
});
})()}
</div>
</div>
{/* 笔的图片 */}
<div className="absolute bottom-[150px] -right-30">
<img
src={getStaticImage("components/letter/笔")}
alt="笔"
className="w-full h-full object-contain"
/>
</div>
{/* 提交按钮 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image
src={getStaticImage("components/letter/可提交状态")}
alt="可提交状态"
onClick={() => onSubmit?.(blankValues)}
button
/>
</div>
{/* 寄信图标(可选,如果需要的话) */}
<div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
<img
src={getStaticImage("components/letter/寄信图标")}
alt="寄信"
className="w-full h-full object-contain"
/>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
};
export default LetterToSunzhongshan;
+11
View File
@@ -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";
+1 -1
View File
@@ -113,7 +113,7 @@ const Narration = ({
if (!visible) return null;
return (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-50">
<div className="fixed inset-0 flex items-center justify-center bg-black/80 z-50">
<div
className={cn(
"w-[1600px] bg-black/90 rounded-[32px] px-20 py-16",
+1 -1
View File
@@ -464,7 +464,7 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
{!hideLabels && (
<h3 className="text-lg font-semibold text-gray-700">填空</h3>
)}
<div className="flex flex-col gap-3">
<div className="flex flex-col">
{normalizedBlanks.map((blank) => {
const filledValue = getBlankFill(blank.id);
const isHovered = hoveredBlank === blank.id && !!draggingOption;
+1 -1
View File
@@ -31,7 +31,7 @@ const Title = ({
>
<div
className={cn(
"absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold",
"absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold whitespace-nowrap",
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]"
)}
>
+18
View File
@@ -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;
}
+118 -8
View File
@@ -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<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) => {
@@ -259,7 +281,12 @@ const ChenyunInternalization = () => {
if (!moduleName) return null;
return (
<div className="relative h-full w-full bg-black">
<div
className="relative h-full w-full bg-black"
style={{
backgroundImage: getBackgroundImage("components/internalization/bg"),
}}
>
{/* 离开按钮 */}
<Image
className="absolute right-8 top-8 z-10"
@@ -268,15 +295,98 @@ const ChenyunInternalization = () => {
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath("video/chenyun/chenyun_neihua.mp4")}
className="h-full w-full"
anchors={anchors}
autoPlay
muted
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false);
setPostGuideVisible(true);
}}
/>
)}
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={(visible) => {
setVisible(visible);
if (!visible) {
setShowVideo(true);
handleVideoCanPlay();
}
}}
showMask={false}
>
<GuideStepsItem speaker="student">
小沪,跟着你参观陈云纪念馆真是收获满满!看到馆里展示的“米棉之战”史料、全国财经统筹的实物展品,我真切感受到陈云爷爷在建国初期平抑物价、稳定经济的魄力。
</GuideStepsItem>
<GuideStepsItem speaker="student">
他用“全国一盘棋”思路化解危机的案例太震撼了,那你能再跟我讲讲,支撑这些成就的核心经济思想是什么吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
针对你的这个问题,今天我们有幸邀请到了中国中共党史学会理事,全国陈云思想生平研究会常务理事兼副秘书长,中共一大纪念馆陈列研究部主任,研究馆员房中老师。关于你的问题,我想房老师会给你指点迷津!...
</GuideStepsItem>
</GuideSteps>
{/* 视频结束后的引导 */}
<GuideSteps
visible={postGuideVisible}
onVisibleChange={(visible) => {
setPostGuideVisible(visible);
// 引导结束后显示信件
if (!visible) {
setShowLetter(true);
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem speaker="student">
听完房主任的讲解,从陈云爷爷童年成长的水乡故居,到他主持"银元之战""米棉之战"的史料,再到那台称量粮食的天平、握不住的铅笔头,我真切感受到他"不唯上、不唯书、只唯实"的精神,也懂了他"为老百姓做事"的初心...
</GuideStepsItem>
<GuideStepsItem speaker="student">
这些都让我特别受触动!
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你的感悟很深刻!陈云同志用一生诠释了"务实"和"为人民服务"——从破解沿海地区经济困境,到掌管国家工业化蓝图,再到改革开放时力挺轻纺、钢铁建设支援,每一步都扎根实际、心系百姓。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你不妨把这份触动凝练成几句心里话,既总结参观的收获,也想想未来该如何践行这份精神,用自己的行动传承这份初心吧!
</GuideStepsItem>
</GuideSteps>
{/* 信件组件:视频引导结束后弹出,带飞入和放大动画 */}
<LetterToChenyun
visible={showLetter}
onSubmit={(values) => {
// 提交信件的逻辑(values 为多个填空的内容)
console.log("提交信件", values);
setShowLetter(false);
setShowStudyGuide(true);
}}
/>
<StudyGuideModal
visible={showStudyGuide}
onContinue={() => {
setShowStudyGuide(false);
navigate(`/${moduleName}/${starName}/home`);
}}
>
恭喜你已经顺利完成了陈云纪念馆的学习,至此,你已经完成了本课程所有故居的学习,
希望你能有所收获,学习八位名人的优秀品质和精神,在新时代为中华民族伟大复兴发光发热!
{/* 同学你好,欢迎来到走进故居-对话学习模块!{"\n"}
进入陈云故居后,我们一起来了解陈云同志经历的7个阶段{"\n"}
“水乡少年 艰辛成长”、“商务启航 投身革命”、“苏区淬炼 崭露头角”、“延安担当
经济破局”、{"\n"}
“恢复时期 经济奠基”、“建设时期 务实兴邦”、“改革浪潮 誉画实业”; */}
</StudyGuideModal>
</div>
);
};
+54 -16
View File
@@ -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<Set<number>>(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 */}
<Cursor
{/* <Cursor
className="absolute right-170 bottom-60"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
/>
/> */}
{/* 展品4 */}
<Cursor
{/* <Cursor
className="absolute right-98 top-130 cursor"
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
/>
/> */}
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
@@ -136,6 +140,21 @@ const ChenyunPlace1 = () => {
}}
/>
{/* 学习引导词 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false);
setVisible(true);
}}
>
同学你好,欢迎来到走进故居-对话学习模块!{"\n"}
进入陈云故居后,我们一起来了解陈云同志经历的7个阶段:“水乡少年
艰辛成长”、“商务启航 投身革命”、“苏区淬炼 崭露头角”、“延安担当
经济破局”、“恢复时期 经济奠基”、“建设时期 务实兴邦”、“改革浪潮
擘画实业”;
</StudyGuideModal>
{/* 学习说明对话框 */}
{showIntroDialog && (
<Overlay
@@ -151,7 +170,13 @@ const ChenyunPlace1 = () => {
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={setVisible}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible);
if (!nextVisible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true);
setShowAfterDialogGuide(true);
}
}}
showMask={true}
highlightSelectors={[]}
>
@@ -168,6 +193,19 @@ const ChenyunPlace1 = () => {
父母去世后,由外婆抚养。陈云从小跟舅父廖文光和舅母廖倪氏一起生活。家里靠种田和做小生意勉强维持生计。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+16
View File
@@ -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 = () => {
陈云在党内以“铁算盘”著称,他在经济工作中能熟练使用算盘,就得益于少年时在青浦县立乙种商业学校的学习。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+16
View File
@@ -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书局)当店员。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+16
View File
@@ -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 = () => {
陈云提议建立合作社,广泛吸收失业工人加入,多听取工人建议、增强企业管理的民主性,从而更好地建设苏区经济。这些做法为他日后主持全国财经工作积累了宝贵经验。
</GuideStepsItem>
</GuideSteps>
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+16
View File
@@ -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);
}}
/>
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+16
View File
@@ -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 = () => {
陈云挂帅后的首仗就是打击投机、平抑物价。通过“银元之战”“两白一黑”战役等几次严峻斗争,只用不到一年时间,就迅速稳定了金融物价。
</GuideStepsItem>
</GuideSteps>
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+33 -7
View File
@@ -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<string, string> = {
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 = () => {
/>
</div>
)}
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+16
View File
@@ -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 = () => {
陈云说:"只有采用这种办法,才能保证我国人民生活日益增长的需要,才能制止投机活动,保证市场物价的稳定,才能使发展国民经济第一个五年计划得以顺利地进行。"
</GuideStepsItem>
</GuideSteps>
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+20 -1
View File
@@ -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 = () => {
我们已经沿着陈云的足迹,从练塘水乡走到改革开放的前沿,读懂了他"适合老百姓的要求,做了一点事"的初心。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
接下来,请你跨越时空,走进1949年刚解放时期的上海,参与到当时那场轰轰烈烈的"银元之战"和"米棉之战"之中去,在实践决策中体会,陈云"多谋善断""敢闯务实"的卓越智慧与战略魄力,感受我国集中力量办大事的制度优势如何在经济战场中彰显威力。
接下来,请你跨越时空,走进1949年刚解放时期的上海,参与到当时那场轰轰烈烈的"银元之战"和"米棉之战"之中去,在实践决策中体会,陈云"多谋善断""敢闯务实"的卓越智慧与战略魄力,
</GuideStepsItem>
<GuideStepsItem speaker="guide">
感受我国集中力量办大事的制度优势如何在经济战场中彰显威力。
</GuideStepsItem>
</GuideSteps>
@@ -170,6 +176,19 @@ const ChenyunPlace9 = () => {
setActiveExhibitId(null);
}}
/>
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
File diff suppressed because it is too large. Load diff
@@ -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<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: (
<div className="flex flex-col items-start text-left gap-2 px-14">
<div className="mb-8">
南京路的先施、永安、大新、新新等四大公司率先拒收人民币,甚至用银元标价。
</div>
<div>其他商户纷纷仿效,人民币在市场交易中地位下降。</div>
</div>
),
},
// 米棉之战 - 投机商情报
"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/录音",
},
};
@@ -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;
@@ -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: "钱庄都在囤媒,冬天没法取暖",
},
],
},
};
@@ -0,0 +1,13 @@
/**
* 共享配置
* 包含欢迎语、starName 等通用配置
*/
// starName 固定为 chenyun
export const starName = "chenyun";
// 欢迎语文案(使用 StudyGuideModal 展示)
export const WELCOME_TEXT = `同学你好,欢迎来到跨越时空-体悟学习模块!
我们即将跨越时空,穿越到"银元之战"和"米棉之战"两个沉浸式场景,来感受陈云同志在1949年的上海是如何应对当时混乱的经济秩序的。`;
@@ -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: (
<div className="flex flex-col items-start text-left gap-2 px-14">
<div className="mb-8">
南京路的先施、永安、大新、新新等四大公司率先拒收人民币,甚至用银元标价。
</div>
<div>其他商户纷纷仿效,人民币在市场交易中地位下降。</div>
</div>
),
},
// 银元之战 - 电话线索
"yinyuan-phone": {
type: "audio",
text: `人民币进不了上海市场,问题很严重。必须让人民币站稳脚跟。`,
audioImage: "chenyun/reflection/录音",
},
};
+315
View File
@@ -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<FileType | null>(null);
/** 当前播放的视频 */
const [playingVideo, setPlayingVideo] = useState<FileType | null>(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<ClueData | null>(null);
/** 当前线索键名 */
const [currentClueKey, setCurrentClueKey] = useState<string | null>(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 (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`chenyun/reflection/底图`),
}}
>
{/* 离开按钮 */}
{expandedFile == null && (
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
}
}}
/>
)}
{/* 桌子位置 */}
<div className="relative w-full top-145 flex">
{/* 银元之战模块 */}
<YinyuanBattle
expandedFile={expandedFile}
isAllViewed={isYinyuanAllViewed}
clueStates={clueStates}
onFileClick={() => handleFileClick("yinyuan")}
onClueClick={handleClueClick}
onPlayVideo={(fileType) => {
setPlayingVideo(fileType);
}}
onSolvedChange={setIsYinyuanSolved}
/>
{/* 米棉之战模块 */}
<MimianBattle
expandedFile={expandedFile}
clueStates={clueStates}
onFileClick={() => handleFileClick("mimian")}
onClueClick={handleClueClick}
isAllViewed={isMimianAllViewed}
onCollapseFile={() => setExpandedFile(null)}
onEnd={handleEnd}
/>
</div>
{/* 拼图盒 */}
<Image
className="absolute left-4 bottom-4"
src={getStaticImage("components/拼图盒")}
/>
{/* 欢迎语层 - 使用 StudyGuideModal,仅承担学习引导/欢迎语 */}
<StudyGuideModal
visible={showWelcomeGuide}
onContinue={() => {
setShowWelcomeGuide(false);
}}
>
{WELCOME_TEXT}
</StudyGuideModal>
{/* 第一幕银元之战 - 学习引导弹窗 */}
<StudyGuideModal
visible={showYinyuanGuide}
onContinue={() => {
setShowYinyuanGuide(false);
// 学习引导结束后再展开银元之战线索
setExpandedFile("yinyuan");
}}
verticalAlign="center"
>
请你分别查阅一份电话录音和两份文件,并根据情报内容做出正确决策。
</StudyGuideModal>
{/* 第二幕米棉之战 - 学习引导弹窗 */}
<StudyGuideModal
visible={showMimianGuide}
onContinue={() => {
setShowMimianGuide(false);
// 学习引导结束后再展开米棉之战线索
setExpandedFile("mimian");
}}
verticalAlign="center"
>
请你分别查阅一个消息记录和两份文件,并根据情报内容做出正确决策。
</StudyGuideModal>
{/* 米棉之战锁定错误提示 - 使用 ErrorAlert 组件 */}
<ErrorAlert
visible={showMimianLockError}
text="同学你好,需要先完成银元之战任务!"
onClose={() => setShowMimianLockError(false)}
onMaskClick={() => setShowMimianLockError(false)}
/>
{/* 视频处理组件 */}
<VideoHandler
playingVideo={playingVideo}
narrationText={narrationText}
showNarration={showNarration}
showAlert={showAlert}
alertConfig={alertConfig}
onVideoEnd={handleVideoEnd}
onNarrationClose={handleNarrationClose}
onAlertClose={handleAlertClose}
/>
{/* 展示线索 */}
{currentClue && (
<ClueOverlay
visible={showClueOverlay}
clue={currentClue}
onClose={handleClueOverlayClose}
/>
)}
</div>
);
};
export default ChenyunReflection;
@@ -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<string, boolean>;
/** 文件点击回调 */
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 (
<>
{/* 文件组件 */}
<MimianFile
expandedFile={expandedFile}
isSolved={state.isMimianSolved}
onFileClick={onFileClick}
/>
{/* 线索组件 */}
<MimianClues
expandedFile={expandedFile}
clueStates={clueStates}
onClueClick={onClueClick}
/>
{/* 题目组件 */}
<MimianQuizzes
showQuiz1={state.showMimianQuiz1}
showNarration1={state.showMimianNarration1}
showQuiz2={state.showMimianQuiz2}
showNarration2={state.showMimianNarration2}
showQuiz3={state.showMimianQuiz3}
showQuiz4={state.showMimianQuiz4}
showNarration3={state.showMimianNarration3}
showQuiz5={state.showMimianQuiz5}
showNarration5={state.showMimianNarration5}
showSuccessAlert={state.showMimianSuccessAlert}
successAlertContent={state.mimianSuccessAlertContent}
onQuiz1Submit={handleQuiz1Submit}
onSuccessAlert1Continue={handleSuccessAlert1Continue}
onNarration1Close={handleNarration1Close}
onQuiz2Submit={handleQuiz2Submit}
onSuccessAlert2Continue={handleSuccessAlert2Continue}
onErrorAlertContinue={handleErrorAlertContinue}
onNarration2Close={handleNarration2Close}
onQuiz3Submit={handleQuiz3Submit}
onQuiz3AlertContinue={handleQuiz3AlertContinue}
onQuiz4Submit={handleQuiz4Submit}
onQuiz4AlertContinue={handleQuiz4AlertContinue}
onNarration3Close={handleNarration3Close}
onQuiz5Submit={handleQuiz5Submit}
onQuiz5AlertContinue={handleQuiz5AlertContinue}
onNarration5Continue={handleNarration5Continue}
/>
</>
);
};
@@ -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<string, boolean>;
/** 线索点击回调 */
onClueClick: (clue: ClueData, clueKey: string) => void;
}
/**
* 米棉之战线索区域组件
* 显示投机商情报、全国物资分布、对话
*/
export const MimianClues = ({
expandedFile,
clueStates,
onClueClick,
}: MimianCluesProps) => {
const isExpanded = expandedFile === "mimian";
return (
<>
{/* 米棉之战:线索-投机商情报 */}
<div
className={cn([
"absolute left-70 bottom-8 transition-all duration-300 delay-100",
"hover:scale-105 hover:brightness-115",
isExpanded
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none",
])}
>
<ClueItem
clueKey="mimian-intelligence"
imageSrc="chenyun/reflection/投机商情报桌上"
isViewed={clueStates["mimian-intelligence"] || false}
onClick={() => {
const clue = MIMIAN_CLUE_DATA["mimian-intelligence"];
if (clue) {
onClueClick(clue, "mimian-intelligence");
}
}}
viewedMarkOffset={{ left: "4px" }}
/>
</div>
{/* 米棉之战:线索-全国物资分布 */}
<div
className={cn([
"absolute left-135 bottom-8 transition-all duration-300 delay-150",
"hover:scale-105 hover:brightness-115",
isExpanded
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none",
])}
>
<ClueItem
clueKey="mimian-distribution"
imageSrc="chenyun/reflection/全国物资分布桌上"
isViewed={clueStates["mimian-distribution"] || false}
onClick={() => {
const clue = MIMIAN_CLUE_DATA["mimian-distribution"];
if (clue) {
onClueClick(clue, "mimian-distribution");
}
}}
viewedMarkOffset={{ left: "12px" }}
/>
</div>
{/* 米棉之战:消息(对话线索) */}
<div
className={cn([
"absolute left-155 -top-118 transition-all duration-300 delay-200",
"hover:scale-105 hover:brightness-115",
isExpanded
? "opacity-100 translate-y-0"
: "opacity-0 -translate-y-10 pointer-events-none",
])}
>
<div
className="cursor-pointer"
onClick={() => {
const clue = MIMIAN_CLUE_DATA["mimian-dialogue"];
if (clue) {
onClueClick(clue, "mimian-dialogue");
}
}}
>
<Image src={getStaticImage("chenyun/reflection/对话气泡")} button />
{/* 未倾听时显示三个点动画 */}
{!clueStates["mimian-dialogue"] && (
<div className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 flex items-center justify-center">
<div className="animate-dots flex gap-2">
<span className="w-3 h-3 bg-gray-500 rounded-full inline-block" />
<span className="w-3 h-3 bg-gray-500 rounded-full inline-block" />
<span className="w-3 h-3 bg-gray-500 rounded-full inline-block" />
</div>
</div>
)}
{/* 已倾听标记 */}
{clueStates["mimian-dialogue"] && (
<div className="absolute left-1/2 -translate-x-1/2 bottom-16">
<span
className={cn([
"text-[#ae6400] text-3xl font-bold whitespace-nowrap",
"[text-shadow:_-4px_-4px_0_#ffffff,_4px_-4px_0_#ffffff,_-4px_4px_0_#ffffff,_4px_4px_0_#ffffff,_-5px_0_0_#ffffff,_5px_0_0_#ffffff,_0_-5px_0_#ffffff,_0_5px_0_#ffffff]",
])}
>
已倾听
</span>
</div>
)}
</div>
</div>
</>
);
};
@@ -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 (
<div
className={cn([
"relative left-90 cursor-pointer transition-all duration-500",
expandedFile === null || expandedFile === "mimian"
? "opacity-100 scale-100"
: "opacity-0 scale-75 pointer-events-none",
expandedFile !== "mimian" && "hover:scale-105 hover:brightness-115",
])}
onClick={onFileClick}
>
<Image src={getStaticImage("chenyun/reflection/文件2")} />
<span
className={cn([
"absolute left-0 bottom-1/2",
"w-full bg-black/60 px-4 py-2 rounded-2xl text-center",
"text-white text-3xl font-bold tracking-widest",
])}
>
{isSolved ? "02米棉之战(已解决)" : "02米棉之战(未解决)"}
</span>
</div>
);
};
@@ -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) */}
<SingleChoiceQuizPanel
visible={showQuiz1}
title='(单选题)投机商集中冲击"两白一黑"的核心目的是( )。'
options={[
{ value: "A", label: "A. 满足市场需求" },
{ value: "B", label: "B. 扰乱经济秩序、动摇政权" },
]}
correctAnswer="B"
errorMessage="很抱歉,你选错了!请你再思考一下吧!"
onSubmit={onQuiz1Submit}
/>
{/* 米棉之战 - 成功提示框1 */}
<SuccessAlert
visible={
showSuccessAlert && successAlertContent === "恭喜你,选择正确!"
}
title="恭喜你,选择正确!"
onContinue={onSuccessAlert1Continue}
/>
{/* 米棉之战 - 旁白1(图2) */}
<Narration
text="为了打击投机资本势力,面对上海米价、纱价等上涨,又同时影响到整个华东和华北、中南等地的情况。陈云于1949年7月17日率领中财委抵达上海,开展了一系列的举措,7月27日至8月15日,陈云主持召开了华东、华北、华中、东北、西北5个大区的财经会议,商讨解决上海和全国面临的严峻经济形势问题和对策。"
visible={showNarration1}
onClose={onNarration1Close}
enableAnimation={false}
/>
{/* 米棉之战 - 单选题2(图3) */}
<SingleChoiceQuizPanel
visible={showQuiz2}
title="(单选题)你觉得此时解决上海和全国面临的严峻经济形势问题的对策应该是( )。"
options={[
{ value: "A", label: "A. 局部限购" },
{ value: "B", label: "B. 增发货币" },
{ value: "C", label: "C. 全国调运+集中抛售" },
]}
correctAnswer="C"
errorMessage="很抱歉,你选错了!请你再思考一下吧!"
onSubmit={onQuiz2Submit}
/>
{/* 米棉之战 - 成功提示框2(单选题2答对后) */}
<SuccessAlert
visible={
showSuccessAlert && successAlertContent.includes("全国一盘棋")
}
title="恭喜你,选择正确!"
content={successAlertContent}
onContinue={onSuccessAlert2Continue}
/>
{/* 米棉之战 - 错误提示框(单选题2答错后) */}
<SuccessAlert
visible={
showSuccessAlert &&
successAlertContent.includes("很抱歉") &&
!successAlertContent.includes("全国一盘棋")
}
title="很抱歉,你选错了!"
content={successAlertContent}
onContinue={onErrorAlertContinue}
/>
{/* 米棉之战 - 旁白2(图4) */}
<Narration
text={`根据"全国一盘棋"的指示,中央政府通过全国范围内的物资调配体系,从多个区域调运米、棉、煤等关键物资支援上海。可是地方在落实推进过程中碰到了现实问题,作为陈云同志的特派员,你应该如何与地方沟通。`}
visible={showNarration2}
onClose={onNarration2Close}
enableAnimation={false}
/>
{/* 米棉之战 - 单选题3(图5) */}
<SingleChoiceQuizPanel
visible={showQuiz3}
title='(单选题)某地方粮站反馈"本地冬储粮不足,不愿外运"。'
options={[
{ value: "A", label: "A. 强制调运,否则追责" },
{
value: "B",
label: "B. 中央已协调东北补充常州冬储粮,先保障全国物价稳定",
},
]}
correctAnswer="B"
errorMessage="地方抵触,调运延误多天"
onSubmit={onQuiz3Submit}
/>
{/* 米棉之战 - 单选题3成功/错误提示 */}
<SuccessAlert
visible={
showSuccessAlert &&
(successAlertContent.includes("地方配合") ||
successAlertContent.includes("地方抵触"))
}
title={
successAlertContent.includes("恭喜")
? "恭喜你,选择正确!"
: "很抱歉,你选错了!"
}
content={successAlertContent}
onContinue={() => {
const isCorrect = successAlertContent.includes("地方配合");
onQuiz3AlertContinue(isCorrect);
}}
/>
{/* 米棉之战 - 单选题4(图6) */}
<SingleChoiceQuizPanel
visible={showQuiz4}
title="(单选题)某地方棉农担心外运后本地纱厂缺原料。"
options={[
{ value: "A", label: "A. 全国优先上海,本地暂时忍耐" },
{
value: "B",
label: "B. 上海纱厂稳定后,会优先供应本地布匹,实现全国共赢",
},
]}
correctAnswer="B"
errorMessage="棉农抵触,调运量减少"
onSubmit={onQuiz4Submit}
/>
{/* 米棉之战 - 单选题4成功/错误提示 */}
<SuccessAlert
visible={
showSuccessAlert &&
(successAlertContent.includes("棉农支持") ||
successAlertContent.includes("棉农抵触"))
}
title={
successAlertContent.includes("恭喜")
? "恭喜你,选择正确!"
: "很抱歉,你选错了!"
}
content={successAlertContent}
onContinue={() => {
const isCorrect = successAlertContent.includes("棉农支持");
onQuiz4AlertContinue(isCorrect);
}}
/>
{/* 米棉之战 - 旁白3(图7) */}
<Narration
text="在以陈云为首的中财委的统一部署下,人民政府在全国范围内调配粮食、棉布和煤炭等商品,在准备充分后,政府开始行动。11月25日,市场物价处在高峰,进入总攻阶段。"
visible={showNarration3}
onClose={onNarration3Close}
enableAnimation={false}
/>
{/* 米棉之战 - 单选题5(图8) */}
<SingleChoiceQuizPanel
visible={showQuiz5}
title="(单选题)此时,以下哪种举措能够一举获得胜利?"
options={[
{
value: "A",
label:
"A:突然性、连续性大量抛售。在全国各地同时行动,上海、武汉、西安等地国营公司连续降价抛售粮棉,不给投机资本喘息之机",
},
{
value: "B",
label: `B:逐步降价,配合舆论引导,每日小幅降价,并通过报纸宣传政府物资充足,试图"劝退"投机商`,
},
]}
correctAnswer="A"
errorMessage="很抱歉,你选错了!投机商此前曾吃下政府平价抛售物资获利。逐步降价加舆论引导难让其信服,他们会持续囤货,政府储备会被消耗,反而让投机商掌控节奏,无法遏制物价上涨。请你再思考一下吧!"
onSubmit={onQuiz5Submit}
/>
{/* 米棉之战 - 单选题5成功/错误提示 */}
<SuccessAlert
visible={
showSuccessAlert &&
(successAlertContent.includes("这契合米棉之战史实") ||
successAlertContent.includes("投机商此前曾吃下"))
}
title={
successAlertContent.includes("恭喜")
? "恭喜你,选择正确!"
: "很抱歉,你选错了!"
}
content={successAlertContent}
onContinue={() => {
const isEndSuccess =
successAlertContent.includes("这契合米棉之战史实");
onQuiz5AlertContinue(isEndSuccess);
}}
/>
{/* 米棉之战 - 旁白5(结束语)改用 StudyGuideModal 展示 */}
<StudyGuideModal
visible={showNarration5}
onContinue={onNarration5Continue}
>
从"银元之战"查封投机枢纽、稳固人民币根基,到"米棉之战"全国统筹调运、集中抛售击溃投机势力,这场经济战线的"淮海战役",不仅平定了物价、安定了民心,更印证了"全国一盘棋"的战略智慧与制度优势。当市场秩序重归稳定,新生政权在经济领域站稳脚跟,这份由陈云同志等老一辈革命家铸就的治理经验,也为新时代经济建设埋下了思想伏笔,等待我们在传承中探索新的实践路径。
</StudyGuideModal>
</>
);
};
@@ -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,
};
};
@@ -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 (
<div className={cn("cursor-pointer", className)} onClick={onClick}>
{/* 线索图片 */}
<Image src={getStaticImage(imageSrc)} />
{/* 搜索图标 - 未浏览时显示 */}
{!isViewed && (
<Image
className={cn(
"absolute pointer-events-none animate-search",
searchIconPosition,
"-translate-x-1/2 -translate-y-1/2"
)}
src={getStaticImage("chenyun/reflection/探索")}
/>
)}
{/* 已浏览标记 */}
{isViewed && (
<div
className="absolute left-1/2 -translate-x-1/2 bottom-0"
style={{
marginLeft: viewedMarkOffset.left || (clueKey === "yinyuan-clue1" || clueKey === "yinyuan-clue2" ? "20px" : clueKey === "mimian-intelligence" ? "4px" : clueKey === "mimian-distribution" ? "12px" : "0"),
bottom: viewedMarkOffset.bottom || (type === "phone" ? "0" : "0"),
}}
>
<span
className={cn([
"text-[#ae6400] text-3xl font-bold",
type === "phone" ? "" : "whitespace-nowrap",
"[text-shadow:_-4px_-4px_0_#ffffff,_4px_-4px_0_#ffffff,_-4px_4px_0_#ffffff,_4px_4px_0_#ffffff,_-5px_0_0_#ffffff,_5px_0_0_#ffffff,_0_-5px_0_#ffffff,_0_5px_0_#ffffff]",
])}
>
{viewedText}
</span>
</div>
)}
</div>
);
};
@@ -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 && (
<div className="absolute inset-0 z-40">
<Video
src={videoConfig.src}
autoPlay
showSkipButton
onEnded={onVideoEnd}
className="w-full h-full"
/>
</div>
)}
{/* 旁白层(仅用于视频后的旁白,不再包含欢迎语) */}
<Narration
text={narrationText}
visible={showNarration}
onClose={onNarrationClose}
enableAnimation={playingVideo === "yinyuan"}
/>
{/* 提示框 */}
{showAlert && (
<Alert
visible={showAlert}
title={alertConfig.title}
subtitle={alertConfig.subtitle}
onClose={onAlertClose}
/>
)}
</>
);
};
@@ -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<ClueKey, boolean>;
@@ -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<ClueStates>(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<ClueStates>) => {
setClueStates((prev) => ({
...prev,
...updates,
}));
};
return {
clueStates,
setClueStates,
isYinyuanAllViewed,
isMimianAllViewed,
markClueAsViewed,
updateClueStates,
};
};
@@ -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<string, boolean>;
/** 文件点击回调 */
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 (
<>
{/* 文件组件 */}
<YinyuanFile
expandedFile={expandedFile}
isSolved={state.isYinyuanSolved}
showTrendChartIcon={state.showTrendChartIcon}
isAllViewed={isAllViewed}
showQuiz1={state.showQuiz1}
showNarration2={state.showNarration2}
showQuiz2={state.showQuiz2}
showSuccessAlert={state.showSuccessAlert}
onFileClick={onFileClick}
onTrendChartClick={() => {
state.setShowGuide2(false);
state.setShowTrendChartDetail(true);
}}
onSolveClick={() => {
state.setShowGuide1(false);
state.setShowQuiz1(true);
}}
/>
{/* 线索组件 */}
<YinyuanClues
expandedFile={expandedFile}
clueStates={clueStates}
onClueClick={onClueClick}
/>
{/* 题目组件 */}
<YinyuanQuizzes
showQuiz1={state.showQuiz1}
showNarration2={state.showNarration2}
narration2Text={state.narration2Text}
showQuiz2={state.showQuiz2}
showSuccessAlert={state.showSuccessAlert}
onQuiz1Submit={handleQuiz1Submit}
onNarration2Close={handleNarration2Close}
onQuiz2Submit={handleQuiz2Submit}
onSuccessAlertContinue={handleSuccessAlertContinue}
/>
{/* 引导提示组件 */}
<YinyuanGuides
showGuide1={state.showGuide1}
showGuide2={state.showGuide2}
showGuide3={state.showGuide3}
showTrendChartDetail={state.showTrendChartDetail}
onGuide1Close={() => state.setShowGuide1(false)}
onGuide2Close={() => state.setShowGuide2(false)}
onGuide3Close={() => state.setShowGuide3(false)}
onTrendChartDetailClose={handleTrendChartDetailClose}
onTrendChartClick={handleTrendChartClick}
/>
</>
);
};
@@ -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<string, boolean>;
/** 线索点击回调 */
onClueClick: (clue: ClueData, clueKey: string) => void;
}
/**
* 银元之战线索区域组件
* 显示线索1、线索2和电话
*/
export const YinyuanClues = ({
expandedFile,
clueStates,
onClueClick,
}: YinyuanCluesProps) => {
const isExpanded = expandedFile === "yinyuan";
return (
<>
{/* 银元之战:线索1 */}
<div
className={cn([
"absolute left-190 bottom-8 transition-all duration-300 delay-100",
"hover:scale-105 hover:brightness-115",
isExpanded
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none",
])}
>
<ClueItem
clueKey="yinyuan-clue1"
imageSrc="chenyun/reflection/线索1"
isViewed={clueStates["yinyuan-clue1"] || false}
onClick={() => {
const clue = YINYUAN_CLUE_DATA["yinyuan-clue1"];
if (clue) {
onClueClick(clue, "yinyuan-clue1");
}
}}
/>
</div>
{/* 银元之战:线索2 */}
<div
className={cn([
"absolute left-265 bottom-8 transition-all duration-300 delay-150",
"hover:scale-105 hover:brightness-115",
isExpanded
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none",
])}
>
<ClueItem
clueKey="yinyuan-clue2"
imageSrc="chenyun/reflection/线索2"
isViewed={clueStates["yinyuan-clue2"] || false}
onClick={() => {
const clue = YINYUAN_CLUE_DATA["yinyuan-clue2"];
if (clue) {
onClueClick(clue, "yinyuan-clue2");
}
}}
/>
</div>
{/* 电话 - 只有银元之战有 */}
<div className="absolute right-50.5 -top-41">
<div className="relative">
{/* 响铃图标 */}
{!clueStates["yinyuan-phone"] && isExpanded && (
<Image
className="absolute left-[45%] -translate-x-1/2 -top-32 animate-bounce"
src={getStaticImage("chenyun/reflection/电话响")}
/>
)}
{/* 电话 */}
<div
className={cn([
isExpanded ? "cursor-pointer" : "pointer-events-none",
])}
onClick={() => {
if (!isExpanded) return;
const clue = YINYUAN_CLUE_DATA["yinyuan-phone"];
if (clue) {
onClueClick(clue, "yinyuan-phone");
}
}}
>
<Image src={getStaticImage("chenyun/reflection/电话")} button />
</div>
{/* 已接听标记 */}
{clueStates["yinyuan-phone"] && isExpanded && (
<div className="absolute left-[40%] -translate-x-1/2 bottom-0">
<span
className={cn([
"text-[#ae6400] text-3xl font-bold",
"[text-shadow:_-4px_-4px_0_#ffffff,_4px_-4px_0_#ffffff,_-4px_4px_0_#ffffff,_4px_4px_0_#ffffff,_-5px_0_0_#ffffff,_5px_0_0_#ffffff,_0_-5px_0_#ffffff,_0_5px_0_#ffffff]",
])}
>
已接听
</span>
</div>
)}
</div>
</div>
</>
);
};
@@ -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 (
<div
id="yinyuan-file"
className={cn([
"relative left-60 cursor-pointer transition-all duration-500",
expandedFile === null || expandedFile === "yinyuan"
? "opacity-100 scale-100"
: "opacity-0 scale-75 pointer-events-none",
expandedFile !== "yinyuan" && "hover:scale-105 hover:brightness-115",
])}
onClick={onFileClick}
>
<Image src={getStaticImage("chenyun/reflection/文件1")} />
<span
className={cn([
"absolute left-0 bottom-1/2 z-10",
"w-full bg-black/60 px-4 py-2 rounded-2xl text-center",
"text-white text-3xl font-bold tracking-widest",
])}
>
{isSolved ? "01银元之战(已解决)" : "01银元之战(未解决)"}
</span>
{/* 走势图 - 银元之战解决后显示,点击后隐藏 */}
{isSolved && showTrendChartIcon && (
<div
className="absolute -top-40 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105 z-2"
onClick={(e) => {
e.stopPropagation();
onTrendChartClick();
}}
>
<Image src={getStaticImage("chenyun/reflection/走势图")} />
</div>
)}
{/* 引导标志 - 只在所有线索查看完后显示-前往解决按钮-去做题 */}
{isAllViewed &&
!isSolved &&
!showQuiz1 &&
!showNarration2 &&
!showQuiz2 &&
!showSuccessAlert && (
<div
className="absolute top-0 w-full h-full"
onClick={(e) => {
e.stopPropagation();
onSolveClick();
}}
>
<Image
className="translate-x-26 -translate-y-2"
src={getStaticImage("chenyun/reflection/点击前往解决")}
button
/>
</div>
)}
</div>
);
};
@@ -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:前往解决按钮出现后 */}
<StudyGuideModal
visible={showGuide1}
onContinue={onGuide1Close}
verticalAlign="center"
zIndex={130}
>
请跟随指示点击前往解决按钮
</StudyGuideModal>
{/* 引导提示2:走势图出现后 */}
<StudyGuideModal
visible={showGuide2}
onContinue={onGuide2Close}
verticalAlign="center"
zIndex={130}
>
请点击走势图
</StudyGuideModal>
{/* 引导提示3:走势图大图打开后 */}
<StudyGuideModal
visible={showGuide3}
onContinue={onGuide3Close}
verticalAlign="center"
zIndex={10000}
>
请点击银元价格走势图
</StudyGuideModal>
{/* 走势图大图展示 - 使用 ExhibitOverlay */}
<ExhibitOverlay
visible={showTrendChartDetail}
src="chenyun/reflection/银元价格走势图"
description={`以上海为中心,代表"人民币信用"的绿色区域迅速覆盖全市,并向外辐射。`}
disableDrag={true}
onClick={onTrendChartClick}
onClose={onTrendChartDetailClose}
/>
</>
);
};
@@ -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 - 点击引导图标后显示 */}
<MultipleChoiceQuizPanel
visible={showQuiz1}
title="(多选题)面对银元投机狂潮,你认为首要突破口是? ()"
options={[
{ value: "A", label: "A. 政治攻势(警告、约谈)" },
{ value: "B", label: "B. 经济手段(抛售银元,以银元打银元)" },
{ value: "C", label: "C. 舆论动员(宣传人民币信用,发动群众)" },
]}
correctAnswer={["A", "B", "C"]}
errorMessage="单一措施效果有限,请尝试综合施策。"
minSelections={1}
maxSelections={3}
minSelectionsErrorMessage="单一措施效果有限,请尝试综合施策。"
submitText="提交"
onSubmit={onQuiz1Submit}
onError={(error) => {
console.log("错误信息:", error);
}}
/>
{/* 旁白文字2(图2)- 多选题1答对后显示 */}
<Narration
text={narration2Text}
visible={showNarration2}
onClose={onNarration2Close}
enableAnimation={false}
/>
{/* 多选题2(图3)- 关闭旁白后显示 */}
<MultipleChoiceQuizPanel
visible={showQuiz2}
title="(多选题)面对投机商的猖狂反扑,下一步应采取何种决定性措施?"
options={[
{ value: "A", label: "A. 加大银元抛售量,耗尽投机资本。" },
{ value: "B", label: "B. 查封投机核心场所,逮捕首要分子。" },
{ value: "C", label: "C. 颁布法令,严禁银元计价流通。" },
]}
correctAnswer={["A", "B", "C"]}
errorMessage="单一措施效果有限,请尝试综合施策。"
minSelections={1}
maxSelections={3}
minSelectionsErrorMessage="单一措施效果有限,请尝试综合施策。"
submitText="提交"
onSubmit={onQuiz2Submit}
onError={(error) => {
console.log("错误信息:", error);
}}
/>
{/* 成功提示框 - 多选题2答对后显示 */}
<SuccessAlert
visible={showSuccessAlert}
title="恭喜你, 选择正确!"
content="陈云同志的意见是:三管齐下,但核心是查封投机核心场所,逮捕首要分子。擒贼先擒王,打掉指挥部。"
onContinue={onSuccessAlertContinue}
/>
</>
);
};
@@ -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,
};
};
+412 -21
View File
@@ -1,38 +1,429 @@
import { useEffect, useMemo, useState, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Image } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
type DragFillResult,
LetterToHuangyanpei,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
import { GuideSteps, GuideStepsItem } from "@/components";
type QuizOption = {
value: string;
label: string;
isCorrect?: boolean;
explanation?: string;
};
type QuizAnchor = {
id: string;
time: number;
title: string;
prompt: string;
type: "single" | "multiple" | "drag";
options: QuizOption[];
blanks?: { id: string; label: string }[];
};
const starName = "huangyanpei";
// 锚点数据:根据视频时长调整 time
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: "quiz-1",
time: 96, // 1:36
title: "题目1",
prompt:
"当年黄炎培先生创办中华职业学校,开设的专业都是当时中国最需要的实业领域,分别是( )。",
type: "multiple",
options: [
{ value: "A", label: "铁工", isCorrect: true },
{
value: "B",
label: "自动控制",
explanation:
"这一工程理论框架成形于20世纪中叶,70年代实现集中控制,新中国成立前,没有相关学科基础、设备与技术条件",
},
{ value: "C", label: "木工", isCorrect: true },
{ value: "D", label: "纽扣科", isCorrect: true },
],
},
{
id: "quiz-2",
time: 221, // 3:41
title: "题目2",
prompt:
"黄炎培先生主导设计的课程与现在中职学校强调的理实一体这一理念相通是( )。",
type: "multiple",
options: [
{
value: "A",
label: "上午学机械原理,下午就去车间打铁、造台钳",
isCorrect: true,
},
{
value: "B",
label: "课堂上学记账、推销,课后就去街头卖国货",
isCorrect: true,
},
{
value: "C",
label: "学习四书五经、八股文",
explanation: "黄炎培认为教育不能只谈书本、脱离实际",
},
{
value: "D",
label: '在川沙小学堂采取"半天读书、半天做手工"的学习模式',
isCorrect: true,
},
],
},
{
id: "quiz-3",
time: 370, // 6:10
title: "题目3",
prompt:
'当年黄炎培先生有推广国货的爱国情怀,如今我们的"国货"已经自强自立,请将其中的国产企业/品牌放置到空格处',
type: "drag",
options: [
{ value: "特斯拉", label: "特斯拉" },
{ value: "比亚迪", label: "比亚迪" },
{ value: "华为", label: "华为" },
{ value: "上海电气", label: "上海电气" },
{ value: "光明食品", label: "光明食品" },
{ value: "拼多多", label: "拼多多" },
],
blanks: [
{ id: "blank-1", label: "国产企业1" },
{ id: "blank-2", label: "国产企业2" },
{ id: "blank-3", label: "国产企业3" },
{ id: "blank-4", label: "国产企业4" },
{ id: "blank-5", label: "国产企业5" },
],
},
{
id: "quiz-4",
time: 467, // 7:47
title: "题目4",
prompt:
'当年现在的中职教育一直在"因时求变",许多专业的新增和关联正确的是( )。',
type: "multiple",
options: [
{ value: "A", label: "汽修专业→学新能源汽车检测与维修", isCorrect: true },
{
value: "B",
label: "会计专业→要掌握大数据财务和智能记账",
isCorrect: true,
},
{ value: "C", label: "国货推销→跨境电商、直播带货", isCorrect: true },
{
value: "D",
label: "美术设计与制作→影像与影视技术",
explanation:
"两个专业为不同技能方向的学习,前者为平面绘画等,后者以视频为主",
},
],
},
];
const HuangyanpeiInternalization = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const { moduleName } = useParams<{ moduleName: string }>();
const [activeSuccess, setActiveSuccess] = useState<string | null>(null);
const [successContent, setSuccessContent] = useState<string>("");
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(
null
);
const [dragQuizValues, setDragQuizValues] = useState<
Record<string, DragFillResult[]>
>({});
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>(
{}
);
const [visible, setVisible] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const [postGuideVisible, setPostGuideVisible] = useState(false);
const [showLetter, setShowLetter] = useState(false);
const [showStudyGuide, setShowStudyGuide] = useState(false);
const videoRef = useRef<VideoRef>(null);
useEffect(() => {
setVisible(true);
}, []);
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
videoRef.current.play().catch((error) => {
console.warn("视频播放失败:", error);
});
}
};
const anchors: VideoAnchor[] = useMemo(
() =>
QUIZ_ANCHORS.map((item) => {
const correctAnswers = item.options
.filter((opt) => opt.isCorrect)
.map((opt) => opt.value);
// 拖拽题处理
if (item.type === "drag" && item.blanks) {
return {
id: item.id,
time: item.time,
once: true,
afterResume: "remove",
render: ({ resume }) => {
const handleDragChange = (val: DragFillResult[]) => {
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }));
setDragQuizErrors((prev) => ({ ...prev, [item.id]: "" }));
// 特斯拉标签无法拖入答案框
const hasInvalidOption = val.some(
(v) => v.optionValue === "特斯拉"
);
if (hasInvalidOption) {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: "特斯拉不是中国国产企业哦~",
}));
return;
}
if (val.length < item.blanks!.length) return;
// 检查是否都是正确答案(排除特斯拉)
const validAnswers = [
"比亚迪",
"华为",
"上海电气",
"光明食品",
"拼多多",
];
const filledAnswers = val.map((v) => v.optionValue);
const isCorrect = filledAnswers.every((answer) =>
validAnswers.includes(answer)
);
if (isCorrect) {
setActiveSuccess(item.id);
setSuccessContent("回答正确,请继续探索。");
setResumeCallback(() => resume);
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: "再想一想,确保选择的都是中国国产企业/品牌~",
}));
}
};
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/internalization/底图`),
<>
<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?.();
}}
>
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
/>
</>
);
},
};
}
// 多选题处理
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);
}
}}
/>
<div className="flex items-center justify-center h-full">
<div className="text-white text-4xl">黄炎培故居-内化模块(待完善)</div>
</div>
<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="relative h-full w-full bg-black"
style={{
backgroundImage: getBackgroundImage("components/internalization/bg"),
}}
>
{/* 离开按钮 */}
<Image
className="absolute right-8 top-8 z-10"
src={getStaticImage("components/离开")}
button
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath("video/huangyanpei/huangyanpei_neihua.mp4")}
className="h-full w-full"
anchors={anchors}
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false);
setPostGuideVisible(true);
}}
/>
)}
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={(visible) => {
setVisible(visible);
if (!visible) {
setShowVideo(true);
handleVideoCanPlay();
}
}}
showMask={false}
>
<GuideStepsItem speaker="student">
小沪,刚刚结束的"任之成长记"太有意思了!我变成百年前的川沙少年选课程、做手工、设计国货推销方案,好像真的走进了黄炎培先生的职教世界。
</GuideStepsItem>
<GuideStepsItem speaker="student">
但我还有一个疑问:先生当年倡导的"手脑并用、工学结合",放在我们现在的中职学习中,到底有什么样的意义呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这个问题问到点子上啦!黄炎培先生的职教思想,从来不是尘封的历史,而是活在当下的行动指南。今天我们特别邀请到中华职业教育社第十三届理事会常务理事、中华职业学校党总支书记、校长黄玉璟,让她带我们解码先生精神的时代密码!
</GuideStepsItem>
</GuideSteps>
{/* 视频结束后的引导 */}
<GuideSteps
visible={postGuideVisible}
onVisibleChange={(visible) => {
setPostGuideVisible(visible);
if (!visible) {
setShowLetter(true);
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem speaker="guide">
听完黄校长的讲解,相信你对黄炎培先生的职教精神有了更深的理解。现在,就请你把这份感悟写下来,既总结参观的收获,也给自己未来许下一个承诺吧!
</GuideStepsItem>
</GuideSteps>
{/* 信件组件 */}
<LetterToHuangyanpei
visible={showLetter}
onSubmit={(values: string[]) => {
console.log("提交信件", values);
setShowLetter(false);
setShowStudyGuide(true);
}}
/>
<StudyGuideModal
visible={showStudyGuide}
onContinue={() => {
setShowStudyGuide(false);
navigate(`/${moduleName}/${starName}/home`);
}}
>
恭喜你已经顺利完成了黄炎培故居的学习,至此,你已经完成了本课程所有故居的学习,
希望你能有所收获,学习八位名人的优秀品质和精神,在新时代为中华民族伟大复兴发光发热!
</StudyGuideModal>
</div>
);
};
export default HuangyanpeiInternalization;
+48 -6
View File
@@ -4,6 +4,7 @@ import {
GuideStepsItem,
Image,
Title,
StudyGuideModal,
} from "@/components";
import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
@@ -24,8 +25,13 @@ const HuangyanpeiPlace1 = () => {
moduleName: string;
}>();
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true);
// 对话结束后的学习引导弹窗
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false);
const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false);
// 引导对话显示状态
const [visible, setVisible] = useState(true);
const [visible, setVisible] = useState(false);
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null);
// 已浏览展品的 ID 集合
@@ -81,8 +87,8 @@ const HuangyanpeiPlace1 = () => {
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-[calc(50%-140px)] right-80"
src={getStaticImage("huangyanpei/place1/3_005")}
className="absolute bottom-0 right-0"
src={getStaticImage("/components/place/继续前进")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/place2`);
@@ -106,20 +112,56 @@ const HuangyanpeiPlace1 = () => {
}}
/>
{/* 学习引导词 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false);
setVisible(true);
}}
>
同学你好,欢迎来到走进故居-对话学习模块!{"\n"}
{
"进入黄炎培故居后,我们一起来了解黄炎培先生经历的5个阶段:““思想启蒙”-> “教育实践”-> “创立职教”-> “延安对话”-> 职教传薪;"
}
</StudyGuideModal>
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={setVisible}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible);
if (!nextVisible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true);
setShowAfterDialogGuide(true);
}
}}
showMask={true}
highlightSelectors={[]}
>
<GuideStepsItem speaker="student">
小沪!这就是黄炎培先生曾经生活过的地方吗?
小沪,这栋古色古香的江南大院,就是黄炎培先生从小生活、读书的地方吗
</GuideStepsItem>
<GuideStepsItem speaker="guide">
是的,这里就是黄炎培故居。让我们一起来探索黄炎培先生的思想启蒙之路...
没错,1878年10月1日,黄炎培先生就出生在当时江苏省川沙县城关镇(现上海市浦东新区川沙镇)的这处“内史第”,宅子原是他姑祖父沈树镛家,也是他后来所说的“生于斯、读于斯、长于斯”之地。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1890年,川沙创办“观澜书院”,12岁的黄炎培以第一名的成绩考入,每天提着母亲亲手缝制的布书包,往返十里路求学,一点都不叫苦。1893年他去上海应县考,松江府复试名列前茅;1899年,在松江府试中更是以第一名的成绩中了秀才。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+66 -1
View File
@@ -1,5 +1,13 @@
import { useState } from "react";
import { ExhibitOverlay, Title, Image, Cursor } from "@/components";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
StudyGuideModal,
GuideStepsItem,
GuideSteps,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -16,11 +24,31 @@ const HuangyanpeiPlace2 = () => {
moduleName: string;
}>();
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
// GuideSteps 当前步骤
const [guideStep, setGuideStep] = useState(0);
// GuideSteps 显示状态
const [guideVisible, setGuideVisible] = useState(false);
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 1) {
// 展品1:点击时同时打开展品和引导对话,从第1步开始
setActiveExhibitId(1);
setGuideVisible(true);
setGuideStep(0);
} else {
// 展品2:直接打开
setActiveExhibitId(index);
}
};
// 监听GuideSteps步骤变化,仅同步当前步骤
const handleGuideStepChange = (step: number) => {
setGuideStep(step);
};
const progressCount = visited.size;
@@ -94,6 +122,43 @@ const HuangyanpeiPlace2 = () => {
setActiveExhibitId(null);
}}
/>
{/* 展品1的引导对话 */}
<GuideSteps
visible={guideVisible}
onVisibleChange={setGuideVisible}
currentStep={guideStep}
onStepChange={handleGuideStepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
哇,这就是南洋公学!是不是现在上海交通大学的前身呀?黄炎培先生中了秀才之后,就来这里读书了吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你说得特别对!1901年,黄炎培先生考入南洋公学(今上海交通大学)特班,当时的老师正是蔡元培先生。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
蔡先生一句“中国教育之病,在于学与术分离”,让他瞬间醍醐灌顶,明白了教育不能只谈书本、脱离实际。1902年,黄炎培去南京参加江南乡试,成功中了举人;
</GuideStepsItem>
<GuideStepsItem speaker="guide">
可就在这一年,南洋公学发生了著名的“墨水瓶风潮”,他毅然跟着蔡元培先生退学,转入蔡先生创办的中国教育会,正式开启了自己“教育救国”的实践。
</GuideStepsItem>
</GuideSteps>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+148 -3
View File
@@ -1,5 +1,14 @@
import { useState } from "react";
import { ExhibitOverlay, Title, Image, Cursor } from "@/components";
import { useState, useEffect } from "react";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
StudyGuideModal,
GuideSteps,
GuideStepsItem,
SingleChoiceQuizPanel,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -17,15 +26,64 @@ const HuangyanpeiPlace3 = () => {
moduleName: string;
}>();
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(
null
);
const [visited, setVisited] = useState<Set<number>>(new Set());
// 光标3的引导对话状态
const [guide3Visible, setGuide3Visible] = useState(false);
const [guide3Step, setGuide3Step] = useState(0);
// 光标3的题目显示状态
const [showQuiz3, setShowQuiz3] = useState(false);
// 光标1、2的引导对话状态(在两者都浏览后显示)
const [guide12Visible, setGuide12Visible] = useState(false);
const [guide12Shown, setGuide12Shown] = useState(false);
const handleOpenExhibit = (index: 1 | 2 | 3) => {
if (index === 3) {
// 光标3:点击时同时打开展品和引导对话
setActiveExhibitId(3);
setGuide3Visible(true);
setGuide3Step(0);
} else {
// 光标1、2:直接打开
setActiveExhibitId(index);
}
};
// 监听光标3的引导对话步骤变化
const handleGuide3StepChange = (step: number) => {
setGuide3Step(step);
};
// 监听光标3的引导对话关闭
const handleGuide3VisibleChange = (visible: boolean) => {
setGuide3Visible(visible);
if (!visible) {
// 对话结束后显示题目
setShowQuiz3(true);
}
};
// 处理光标3题目提交
const handleQuiz3Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setShowQuiz3(false);
}
};
// 监听visited变化,当光标1、2都浏览后显示引导对话
useEffect(() => {
if (visited.has(1) && visited.has(2) && !guide12Shown) {
setGuide12Shown(true);
setGuide12Visible(true);
}
}, [visited, guide12Shown]);
const progressCount = visited.size;
const total = 3;
const completed = progressCount >= total;
@@ -61,16 +119,19 @@ const HuangyanpeiPlace3 = () => {
}}
/>
{/* 光标1 */}
<Cursor
className="absolute left-32 top-105"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute left-172 top-90"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute right-92 top-108"
onClick={() => handleOpenExhibit(3)}
@@ -98,10 +159,94 @@ const HuangyanpeiPlace3 = () => {
next.add(activeExhibitId);
return next;
});
}
setActiveExhibitId(null);
} else {
setActiveExhibitId(null);
}
}}
/>
{/* 光标3的引导对话 */}
<GuideSteps
visible={guide3Visible}
onVisibleChange={handleGuide3VisibleChange}
currentStep={guide3Step}
onStepChange={handleGuide3StepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
川沙小学堂是不是黄炎培先生"教育救国"的第一个试验田啊?还有黄炎培先生是不是就像现在教育这样,也有专业课程、实用课程,也很注重动手能力啊?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你说得特别对!1903年,年仅24岁的黄炎培就和好友张志鹤等人一起,把原来的"观澜书院"改制成"川沙小学堂",黄炎培出任校长。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
学校的课程设置非常大胆创新,首次引入算术、"格致"(也就是现在说的自然科学)、体操、乡土调查等新学科。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
学校采用"半日读书、半日手工"的模式,特别注重实际应用,这也是中国最早的"半日授课、半日实习"的雏形。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
办学资金紧张,没有钱买课本,黄炎培就亲自带领大家刻蜡板来印刷;没有体育器材,他就带着学生用竹子做成双杠...整个学校完全是凭着满腔热情办起来的!
</GuideStepsItem>
</GuideSteps>
{/* 光标3的题目 */}
<SingleChoiceQuizPanel
visible={showQuiz3}
title='黄炎培1903年把川沙"观澜书院"改成"川沙小学堂"后,课程表首次出现下列哪组学科?'
options={[
{ value: "A", label: "A. 经学、策论、八股文" },
{
value: "B",
label: "B. 算术、格致、体操、乡土调查",
},
{ value: "C", label: "C. 外语、逻辑、演讲、社交" },
{ value: "D", label: "D. 绘画、声乐、舞蹈、戏剧" },
]}
correctAnswer="B"
errorMessage='1903年,黄炎培将"观澜书院"改制为"川沙小学堂",自任校长,率先打破传统经学模式,在课程表中首次列入算术、格致(近代自然科学)、体操和乡土调查四门新学科,并实行"半日授课、半日实习"的教学方式,开创了近代中国小学融入科学、体育与实践教育的新风,因此选项B正确。'
onSubmit={handleQuiz3Submit}
zIndex={10001}
/>
{/* 光标1、2的引导对话 */}
<GuideSteps
visible={guide12Visible}
onVisibleChange={setGuide12Visible}
showMask={true}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student">
原来当年还有专门的女子学校呀?在那个年代,女孩子能上学是不是特别不容易?后来的浦东中学也是黄炎培先生办的吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你观察得特别仔细!
川沙小学堂开学没多久,黄炎培就和堂兄黄洪培、堂嫂陆开群一起,策划创建了"开群女校"——这所学校专门面向社会招收女学生,还用全力支持办学、腾出自家住房的陆开群名字命名,直接开创了浦东地区女子教育的先河,让很多女孩子有了读书的机会。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
到了1906年,上海营造业宗师杨斯盛捐资30万两白银,在浦东六里桥购地40多亩创办浦东中学,特意邀请黄炎培担任首任校长。校舍是黄炎培亲自设计的,杨斯盛先生亲自监工,建成后有能容纳千人的大礼堂和两座教学楼,是近代上海第一所完全中学呢!
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1912年,黄炎培担任江苏省署教育司司长,任职期间一直倡导实用主义教育,强调教育要和生产劳动结合起来,这也为后来的职业教育打下了坚实基础。
</GuideStepsItem>
</GuideSteps>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+34 -7
View File
@@ -1,15 +1,22 @@
import { useState } from "react";
import { ExhibitOverlay, Title, Image, Cursor } from "@/components";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
const starName = "huangyanpei";
const exhibitSrcMap: Record<1 | 2 | 3 | 4, string> = {
const exhibitSrcMap: Record<1 | 2 | 3 | 4 | 5, string> = {
1: "huangyanpei/place4/6_006",
2: "huangyanpei/place4/6_007",
3: "huangyanpei/place4/6_008",
4: "huangyanpei/place4/6_009",
5: "huangyanpei/place4/6_010",
};
const HuangyanpeiPlace4 = () => {
@@ -18,17 +25,19 @@ const HuangyanpeiPlace4 = () => {
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | null>(
null
);
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const [activeExhibitId, setActiveExhibitId] = useState<
1 | 2 | 3 | 4 | 5 | null
>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
const handleOpenExhibit = (index: 1 | 2 | 3 | 4) => {
const handleOpenExhibit = (index: 1 | 2 | 3 | 4 | 5) => {
setActiveExhibitId(index);
};
const progressCount = visited.size;
const total = 4;
const total = 5;
const completed = progressCount >= total;
const subtitle = completed
? "探索黄炎培创立职教(已完成)"
@@ -82,6 +91,11 @@ const HuangyanpeiPlace4 = () => {
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
/>
<Cursor
className="absolute right-205 top-110"
onClick={() => handleOpenExhibit(5)}
read={visited.has(5)}
/>
{completed && (
<Image
@@ -108,6 +122,19 @@ const HuangyanpeiPlace4 = () => {
setActiveExhibitId(null);
}}
/>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+31 -4
View File
@@ -1,5 +1,12 @@
import { useState, useRef } from "react";
import { ExhibitOverlay, Title, Image, Cursor, Video } from "@/components";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
Video,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
import type { VideoRef } from "@/components/video";
@@ -21,6 +28,8 @@ const HuangyanpeiPlace5 = () => {
const [visited, setVisited] = useState<Set<number>>(new Set());
const [videoVisible, setVideoVisible] = useState(false);
const videoRef = useRef<VideoRef>(null);
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 2) {
@@ -49,6 +58,12 @@ const HuangyanpeiPlace5 = () => {
backgroundImage: getBackgroundImage(`huangyanpei/place5/7_001`),
}}
>
<img
src={getStaticImage("huangyanpei/place5/7_003")}
alt=""
className="absolute top-72 right-37"
/>
<Title
title="延安对话"
subtitle={subtitle}
@@ -94,10 +109,9 @@ const HuangyanpeiPlace5 = () => {
<div className="fixed inset-0 z-[9999] bg-black">
<Video
ref={videoRef}
src={getPublicPath(
"video/chenyun/8陈云纪念馆16大米价格暴涨_x264.mp4"
)}
src={getPublicPath("video/huangyanpei/huangyanpei_07.mp4")}
className="w-full h-full"
controls
autoPlay
playsInline
preload="auto"
@@ -129,6 +143,19 @@ const HuangyanpeiPlace5 = () => {
setActiveExhibitId(null);
}}
/>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
+22 -1
View File
@@ -1,5 +1,11 @@
import { useState } from "react";
import { ExhibitOverlay, Title, Image, Cursor } from "@/components";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
StudyGuideModal,
} from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
@@ -17,6 +23,8 @@ const HuangyanpeiPlace6 = () => {
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
// 学习引导弹窗(进入场景即显示)
const [showGuideModal, setShowGuideModal] = useState(true);
const handleOpenExhibit = (index: 1) => {
setActiveExhibitId(index);
@@ -88,6 +96,19 @@ const HuangyanpeiPlace6 = () => {
setActiveExhibitId(null);
}}
/>
{/* 学习引导(进入场景即显示,与 place1 保持一致) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
ailgn="left"
>
1、场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态;
{"\n"}
2、在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习;
{"\n"}
3、所有动态光点的展品全部学习后才能进入下一个场景学习;
</StudyGuideModal>
</div>
);
};
-155
View File
@@ -1,155 +0,0 @@
import { useState, useCallback } from "react";
import {
ExhibitOverlay,
Title,
Image,
Cursor,
BambooPuzzleGame,
} from "@/components";
import { SuccessAlert } from "@/components/layout";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom";
const starName = "huangyanpei";
const exhibitSrcMap: Record<1 | 2, string> = {
1: "huangyanpei/place7/9_004",
2: "huangyanpei/place7/场景7展品2",
};
const HuangyanpeiPlace7 = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
const [visited, setVisited] = useState<Set<number>>(new Set());
const [showGame, setShowGame] = useState(false);
const [gameCompleted, setGameCompleted] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index);
};
const progressCount = visited.size;
const total = 2;
const completed = progressCount >= total;
const subtitle = completed
? "探索川沙小学堂·启蒙之始(已完成)"
: `探索川沙小学堂·启蒙之始(${progressCount}/${total})`;
const subtitleClassName = completed
? "text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]"
: undefined;
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`huangyanpei/place7/9_001`),
}}
>
<Title
title="跨越时空"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place6`);
}
}}
/>
<Cursor
className="absolute left-68 top-55"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
/>
<Cursor
className="absolute left-160 bottom-50"
onClick={() => {
// 如果游戏已完成,不再打开
if (!gameCompleted) {
setShowGame(true);
}
}}
read={gameCompleted}
/>
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage("huangyanpei/place7/9btn")}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/home`);
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onClose={() => {
if (activeExhibitId !== null) {
setVisited((prev) => {
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
}
setActiveExhibitId(null);
}}
/>
<BambooPuzzleGame
visible={showGame}
onClose={() => {
setShowGame(false);
}}
onComplete={() => {
// 游戏完成后,显示成功提示
setShowGame(false);
setShowSuccessAlert(true);
}}
/>
<SuccessAlert
visible={showSuccessAlert}
title="拼单杠成功!"
onContinue={useCallback(() => {
console.log("onContinue called, showSuccessAlert:", showSuccessAlert);
// 直接设置为 false,不使用函数式更新
setShowSuccessAlert(false);
setGameCompleted(true);
setVisited((prev) => {
const next = new Set(prev);
next.add(2);
return next;
});
}, [showSuccessAlert])}
onClose={useCallback(() => {
console.log("onClose called, showSuccessAlert:", showSuccessAlert);
// 直接设置为 false,不使用函数式更新
setShowSuccessAlert(false);
setGameCompleted(true);
setVisited((prev) => {
const next = new Set(prev);
next.add(2);
return next;
});
}, [showSuccessAlert])}
/>
</div>
);
};
export default HuangyanpeiPlace7;
-37
View File
@@ -1,37 +0,0 @@
import { useNavigate, useParams } from "react-router-dom";
import { Image } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
const starName = "huangyanpei";
const HuangyanpeiReflection = () => {
const navigate = useNavigate();
const { moduleName } = useParams<{
moduleName: string;
}>();
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/reflection/底图`),
}}
>
<Image
className="absolute right-5 top-5"
src={getStaticImage("components/离开")}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`);
}
}}
/>
<div className="flex items-center justify-center h-full">
<div className="text-white text-4xl">黄炎培故居-体悟模块(待完善)</div>
</div>
</div>
);
};
export default HuangyanpeiReflection;
@@ -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
@@ -0,0 +1,4 @@
export { default as QuestionModal } from "./question-modal";
export type { QuestionModalProps } from "./question-modal";
export { BambooPuzzleGame } from "./game";
@@ -0,0 +1,4 @@
export { default } from "./question-modal";
export { default as QuestionModal } from "./question-modal";
export type { QuestionModalProps } from "./question-modal";
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more