feat:修改英文双引号

This commit is contained in:
kevinzhu committed 2026-04-30 09:17:31 +08:00
commit 4a2be3b4d0
1351 files changed
+56547

No files matched your search

@@ -0,0 +1,67 @@
import { Image } from '@/components'
import { getStaticImage } from '@/lib/utils'
import { ClueData } from './types'
import DialogueContent from './DialogueContent'
export interface ClueContentProps {
/** 线索数据 */
clue: ClueData
}
/**
* 线索内容组件
* 根据线索类型渲染不同的内容
*/
const ClueContent = ({ clue }: ClueContentProps) => {
switch (clue.type) {
case 'text':
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<h2 className="text-4xl font-bold text-[#896a27] mb-4 text-center">{clue.title}</h2>
{typeof clue.content === 'string' ? (
<div className="text-3xl leading-16 text-[#896a27] whitespace-pre-line">
{clue.content}
</div>
) : (
<div className="text-3xl leading-16 text-[#896a27]">{clue.content}</div>
)}
</div>
)
case 'map':
return (
<div className="w-full h-full flex items-center justify-center">
<Image
src={getStaticImage(clue.mapImage)}
className="max-w-full max-h-full object-contain"
alt="地图"
/>
</div>
)
case 'audio':
return (
<div className="w-full h-full flex items-center justify-center">
<div className="text-5xl leading-relaxed text-white whitespace-pre-line text-center ">
{clue.text}
</div>
<Image
className="absolute left-0 bottom-10 w-full "
src={getStaticImage(clue.audioImage)}
alt="录音"
/>
</div>
)
case 'dialogue':
return <DialogueContent clue={clue} />
case 'custom':
return <>{clue.content}</>
default:
return null
}
}
export default ClueContent
@@ -0,0 +1,122 @@
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { Image } from '@/components'
import { getStaticImage } from '@/lib/utils'
import { DialogueClueData } from './types'
export interface DialogueContentProps {
/** 对话线索数据 */
clue: DialogueClueData
}
/**
* 对话内容组件
* 依次显示多条对话,所有对话同时展示,带有动画效果
*/
const DialogueContent = ({ clue }: DialogueContentProps) => {
const [visibleIndices, setVisibleIndices] = useState<number[]>([])
// 依次显示对话:先显示第一条,延迟后显示第二条
useEffect(() => {
if (clue.dialogues.length === 0) return
// 立即显示第一条对话
setVisibleIndices([0])
// 延迟显示后续对话
if (clue.dialogues.length > 1) {
const timer = setTimeout(() => {
setVisibleIndices((prev) => [...prev, 1])
}, 1500) // 第一条显示1.5秒后显示第二条
return () => clearTimeout(timer)
}
}, [clue.dialogues.length])
return (
<div className="w-full h-full flex flex-col items-center gap-30 relative px-10">
{clue.dialogues.map((dialogue, index) => {
const isVisible = visibleIndices.includes(index)
return (
<motion.div
key={index}
initial={{ opacity: 0, y: 30, scale: 0.95 }}
animate={
isVisible ? { opacity: 1, y: 0, scale: 1 } : { opacity: 0, y: 30, scale: 0.95 }
}
transition={{
duration: 0.6,
ease: [0.16, 1, 0.3, 1], // 自定义缓动函数,更流畅
}}
className="flex items-center gap-8 w-full max-w-7xl"
>
{/* 角色头像 */}
{dialogue.avatar && (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={isVisible ? { opacity: 1, x: 0 } : { opacity: 0, x: -20 }}
transition={{
duration: 0.5,
delay: 0.1,
ease: 'easeOut',
}}
className="flex-shrink-0 flex flex-col items-center"
>
<Image
src={getStaticImage(dialogue.avatar)}
className="w-60 h-60 object-contain"
alt={dialogue.character}
/>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 10 }}
transition={{
duration: 0.4,
delay: 0.3,
ease: 'easeOut',
}}
className="mt-3 px-5 py-2 bg-gray-800/90 rounded-lg"
>
<span className="text-white text-base font-medium">{dialogue.character}</span>
</motion.div>
</motion.div>
)}
{/* 对话气泡 */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={isVisible ? { opacity: 1, x: 0 } : { opacity: 0, x: 20 }}
transition={{
duration: 0.5,
delay: 0.2,
ease: 'easeOut',
}}
className="flex-1 relative"
>
<div className="bg-gray-800/95 rounded-3xl px-20 py-8 shadow-2xl">
<motion.div
initial={{ opacity: 0 }}
animate={isVisible ? { opacity: 1 } : { opacity: 0 }}
transition={{
duration: 0.4,
delay: 0.4,
}}
className="text-white text-4xl leading-relaxed"
>
{dialogue.text}
</motion.div>
</div>
{/* 对话气泡箭头 - 指向左侧头像 */}
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3">
<div className="w-0 h-0 border-t-[12px] border-t-transparent border-r-[12px] border-r-gray-800/95 border-b-[12px] border-b-transparent" />
</div>
</motion.div>
</motion.div>
)
})}
</div>
)
}
export default DialogueContent
+200
View File
@@ -0,0 +1,200 @@
import { useEffect, useState, useMemo, MouseEvent, ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { cn, getStaticImage } from '@/lib/utils'
import { Image } from '@/components'
import { useScale } from '@/contexts/ScaleContext'
import { useAudioManager } from '@/hooks/useAudioManager'
import ClueContent from './ClueContent'
import { ClueData } from './types'
export interface ClueOverlayProps {
/** 是否可见 */
visible?: boolean
/** 关闭回调 */
onClose?: () => void
/** 线索数据(如果提供,会优先使用) */
clue?: ClueData
/** 自定义内容(文本或其他内容,当 clue 未提供时使用) */
children?: ReactNode
/** 额外的自定义类 */
className?: string
/** 线索对应的音频地址(可选) */
audioSrc?: string
/** 出现时是否自动播放音频(默认 true) */
autoPlayAudio?: boolean
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string
}
/**
* 线索展示框组件
* - 中间内容区域可自定义(文本或其他内容)
* - 使用背景资源:components/layout/纸(地图和音频类型除外)
* - 支持音频自动播放,与全局/分组音频互斥
*/
const ClueOverlay = ({
visible = true,
onClose,
clue,
children,
className,
audioSrc,
autoPlayAudio = true,
audioChannelId,
}: ClueOverlayProps) => {
const maskSrc = getStaticImage('components/蒙版遮罩')
const backSrc = getStaticImage('components/关闭')
const paperBgSrc = getStaticImage('components/layout/纸')
// 获取缩放参数,用于视觉对齐
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
const [mounted, setMounted] = useState(false)
// 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥
const { currentSrc, play, stop } = useAudioManager(
audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' }
)
// 判断是否需要显示纸张背景(地图、音频和对话类型不显示纸张背景)
const shouldShowPaperBg =
clue?.type !== 'map' && clue?.type !== 'audio' && clue?.type !== 'dialogue'
// SSR 兼容:只在客户端挂载
useEffect(() => {
setMounted(true)
}, [])
// 线索显示时自动播放音频
useEffect(() => {
if (!mounted) return
if (visible && autoPlayAudio && audioSrc) {
play(audioSrc)
}
}, [visible, mounted, autoPlayAudio, audioSrc, play])
// 关闭线索时停止音频(仅当当前播放的是该线索的 audioSrc)
useEffect(() => {
if (!mounted) return
if (!visible && audioSrc && currentSrc === audioSrc) {
stop()
}
}, [visible, mounted, audioSrc, currentSrc, stop])
const handleClose = (e: MouseEvent<HTMLImageElement>) => {
e.stopPropagation()
// 关闭时停止音频
if (audioSrc && currentSrc === audioSrc) {
stop()
}
onClose?.()
}
// 视觉对齐样式:确保与 App 内容对齐
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
position: 'fixed',
inset: 0,
zIndex: 9999,
pointerEvents: 'none',
}),
[]
)
const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({
position: 'absolute',
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`,
top: `${offsetY}px`,
transform: `scale(${scale})`,
transformOrigin: 'top left',
overflow: 'hidden',
}),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
)
if (!mounted) return null
const content = (
<AnimatePresence>
{visible && (
<motion.div
key="clue-overlay"
style={overlayStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<div style={scaledContainerStyle}>
<div
className={cn(
'absolute inset-0 flex items-center justify-center',
'bg-black/70',
className
)}
style={{ pointerEvents: 'auto' }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
{/* 关闭按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="关闭"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 内容区域 */}
<div className="relative z-10 flex items-center justify-center w-full h-full">
{shouldShowPaperBg ? (
// 文本类型:显示纸张背景
<div className="relative" style={{ minWidth: '600px', minHeight: '400px' }}>
{/* 纸张背景图片 */}
<Image
src={paperBgSrc}
className="w-full h-full object-contain pointer-events-none select-none"
alt=""
aria-hidden
/>
{/* 自定义内容 - 绝对定位在纸张上方 */}
<div
className="absolute inset-0 flex items-center justify-center"
style={{ padding: '60px 80px' }}
>
<div className="w-full h-full flex flex-col items-center justify-center">
{clue ? <ClueContent clue={clue} /> : children}
</div>
</div>
</div>
) : (
// 地图和音频类型:直接显示内容,无纸张背景
<div className="max-w-full max-h-full w-full flex items-center justify-center">
{clue ? <ClueContent clue={clue} /> : children}
</div>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
// 直接使用 Portal 渲染到 document.body
return createPortal(content, document.body)
}
export default ClueOverlay
@@ -0,0 +1,62 @@
import { ReactNode } from 'react'
/** 线索类型 */
export type ClueType = 'text' | 'map' | 'audio' | 'dialogue' | 'custom'
/** 文本线索数据 */
export interface TextClueData {
type: 'text'
/** 标题 */
title: string
/** 内容文本(支持字符串或 ReactNode,字符串可使用 \n 换行) */
content: string | ReactNode
}
/** 地图线索数据 */
export interface MapClueData {
type: 'map'
/** 地图图片资源路径 */
mapImage: string
}
/** 录音线索数据 */
export interface AudioClueData {
type: 'audio'
/** 文本内容 */
text: string
/** 录音图片资源路径 */
audioImage: string
/** 录音音频资源路径 */
audioSrc?: string
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string
}
/** 对话线索数据 */
export interface DialogueClueData {
type: 'dialogue'
/** 对话列表 */
dialogues: Array<{
/** 角色名称 */
character: string
/** 角色头像资源路径 */
avatar?: string
/** 对话文本 */
text: string
}>
}
/** 自定义线索数据 */
export interface CustomClueData {
type: 'custom'
/** 自定义内容 */
content: ReactNode
}
/** 线索数据联合类型 */
export type ClueData =
| TextClueData
| MapClueData
| AudioClueData
| DialogueClueData
| CustomClueData
@@ -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 '../clue/types'
// 米棉之战视频配置
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; audioSrc?: string }
> = {
// 米棉之战 - 投机商情报
'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,12 @@
/**
* 共享配置
* 包含欢迎语、starName 等通用配置
*/
// starName 固定为 chenyun
export const starName = 'chenyun'
// 欢迎语文案(使用 StudyGuideModal 展示)
export const WELCOME_TEXT = `同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1949年,上海。
你将扎进“银元之战”的旋涡,踏过“米棉之战”的浪潮,以陈云同志特派员的身份体验陈云如何以智破局,稳稳托住这座城的经济天平。时空门已开,准备好入戏了吗?`
@@ -0,0 +1,53 @@
/**
* 银元之战配置
* 包含银元之战的视频配置和线索数据
*/
import { type ReactNode } from 'react'
import { getPublicPath } from '@/lib/utils'
import type { ClueData } from '../clue/types'
// 银元之战视频配置
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; audioSrc?: string }
> = {
// 银元之战 - 线索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/录音',
audioSrc: getPublicPath('audio/chenyun/mp3_chenyun_30.mp3'),
},
}
+350
View File
@@ -0,0 +1,350 @@
/**
* 陈云反思页面主入口
* 整合银元之战和米棉之战两个模块
*/
import { Image, StudyGuideModal } from '@/components'
import ErrorAlert from '@/components/layout/alert/error-alert'
import TitleAlert from '@/components/layout/alert/title-alert'
import ClueOverlay from '@/pages/chenyun/reflection/clue'
import type { ClueData } from './clue/types'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
WELCOME_TEXT,
starName,
YINYUAN_VIDEO_CONFIG,
MIMIAN_VIDEO_CONFIG,
CLUE_DATA,
} 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 { } from "./constants/yinyuan";
// import { } from "./constants/mimian";
import { useChenyunStore } from '@/store/chenyunStore'
const ChenyunReflection = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// ========== 顶层状态管理 ==========
/** 当前展开的文件 */
const [expandedFile, setExpandedFile] = useState<FileType | null>(() =>
useChenyunStore.getState().isYinyuanSolved ? 'mimian' : 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(
() => !useChenyunStore.getState().isYinyuanSolved
)
/** 第一幕银元之战 - 学习引导弹窗 */
const [showYinyuanGuide, setShowYinyuanGuide] = useState(false)
/** 第二幕米棉之战 - 学习引导弹窗 */
const [showMimianGuide, setShowMimianGuide] = useState(false)
/** 米棉之战解锁错误提示 */
const [showMimianLockError, setShowMimianLockError] = useState(false)
/** 银元之战视频前的引导弹窗 */
const [showPreYinyuanAlert, setShowPreYinyuanAlert] = useState(false)
// ========== 线索状态管理 ==========
const { clueStates, isYinyuanAllViewed, isMimianAllViewed, markClueAsViewed } = useClueStates()
// ========== 银元之战和米棉之战状态(从 store 获取) ==========
const { isYinyuanSolved, isMimianSolved } = useChenyunStore()
//线索音频
const [clueAudioSrc, setClueAudioSrc] = useState('')
// ========== 事件处理函数 ==========
/**
* 处理文件点击
* @param fileType 文件类型
*/
const handleFileClick = (fileType: FileType) => {
// 如果已经展开,则收起
if (expandedFile === fileType) {
setExpandedFile(null)
return
}
// 必须先完成银元之战任务,才能解锁米棉之战
if (fileType === 'mimian' && !isYinyuanSolved) {
setShowMimianLockError(true)
return
}
// 银元之战:先弹出引导,再播放视频
if (fileType === 'yinyuan') {
setShowPreYinyuanAlert(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) => {
console.log(clueKey)
setCurrentClue(clue)
setCurrentClueKey(clueKey)
// 从合并后的 CLUE_DATA 中直接查找 audioSrc
const audioSrc = (CLUE_DATA[clueKey] as { audioSrc?: string })?.audioSrc || ''
setClueAudioSrc(audioSrc)
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}
isMimianSolved={isMimianSolved}
onFileClick={() => handleFileClick('yinyuan')}
onClueClick={handleClueClick}
onPlayVideo={(fileType) => {
// 播放银元之战视频前,先弹出标题提示
if (fileType === 'yinyuan') {
setShowPreYinyuanAlert(true)
return
}
setPlayingVideo(fileType)
}}
/>
{/* 米棉之战模块 */}
<MimianBattle
expandedFile={expandedFile}
clueStates={clueStates}
onFileClick={() => handleFileClick('mimian')}
onClueClick={handleClueClick}
isAllViewed={isMimianAllViewed}
onCollapseFile={() => setExpandedFile(null)}
onEnd={handleEnd}
/>
</div>
{/* 欢迎语层 - 使用 StudyGuideModal,仅承担学习引导/欢迎语 */}
<StudyGuideModal
visible={showWelcomeGuide}
onContinue={() => {
setShowWelcomeGuide(false)
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_28.mp3')}
firstLineIndent="2em"
>
{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)}
/>
{/* 银元之战视频前的标题提示弹窗 */}
<TitleAlert
visible={showPreYinyuanAlert}
title=""
subtitle="1949年5月,上海解放。"
onClose={() => {
setShowPreYinyuanAlert(false)
setPlayingVideo('yinyuan')
}}
/>
{/* 视频处理组件 */}
<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}
audioSrc={clueAudioSrc}
/>
)}
</div>
)
}
export default ChenyunReflection
@@ -0,0 +1,260 @@
/**
* 米棉之战主组件
* 整合米棉之战的所有子组件:文件、线索、题目
*/
import { useEffect } from 'react'
import { MimianFile } from './MimianFile'
import { MimianClues } from './MimianClues'
import { MimianQuizzes } from './MimianQuizzes'
import { useMimianState } from './useMimianState'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { usePuzzleStore } from '@/store/puzzleStore'
import { starName } from '../constants'
import type { FileType } from '../shared/types'
import type { ClueData } from '../clue/types'
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()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const { isPuzzleUnlocked } = usePuzzleStore()
// 如果所有米棉之战线索都已查看,自动显示单选题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(
`结合当时市场情况,陈云同志在会上做出了"全国一盘棋"的指示。全国统筹调运粮食、棉花、煤炭填补缺口,筑牢物资基础。关键节点集中抛售,让跟风囤货的投机商无力承接,因亏本和高利贷纷纷破产,快速平抑物价、稳固人民币地位。`
)
}
// 选错时,错误信息已经在题目面板中通过 onWrongSelect 显示,不需要额外的 SuccessAlert
}
// 处理成功提示框2继续
const handleSuccessAlert2Continue = () => {
state.setShowMimianSuccessAlert(false)
state.setMimianSuccessAlertContent('')
// 显示旁白2(图4)
state.setShowMimianNarration2(true)
// 检查碎片14是否已解锁,如果未解锁则触发动画
const pieceId = 14
if (!isPuzzleUnlocked(starName, pieceId)) {
triggerPuzzleAnimation(pieceId)
}
}
// 处理错误提示框继续
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('地方配合,调运按时推进。')
}
}
// 处理单选题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(
'这契合米棉之战史实,多地同步大量抛售,让借高利贷囤货的投机商措手不及。连续抛售使其无力承接,只得亏本抛售,最终纷纷破产,快速稳住物价。'
)
}
}
// 处理单选题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,121 @@
/**
* 米棉之战线索区域组件
* 包含投机商情报、全国物资分布、对话的展示
*/
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 '../clue/types'
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,53 @@
/**
* 米棉之战文件组件
* 包含文件展示
*/
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
className="transition-all duration-300"
src={
expandedFile
? getStaticImage('chenyun/reflection/文件2打开')
: 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,292 @@
/**
* 米棉之战题目组件
* 包含单选题1-5、旁白1-3、成功提示框、结束语
*/
import { Narration, SingleChoiceQuizPanel, EndingOverlay } from '@/components'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { getPublicPath } from '@/lib/utils'
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}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_32.mp3')}
/>
{/* 米棉之战 - 单选题2(图3) */}
<SingleChoiceQuizPanel
visible={showQuiz2}
title={`【单选】你觉得此时解决上海和全国面临的严峻经济形势问题的对策应该是:`}
options={[
{ value: 'A', label: 'A. 局部限购' },
{ value: 'B', label: 'B. 增发货币' },
{ value: 'C', label: 'C. 全国调运+集中抛售' },
]}
correctAnswer="C"
onWrongSelect={(value) => {
if (value === 'A') {
return '很抱歉,你选错了!当时物价上涨已波及多地区,局部限购无法阻止投机商转移物资炒作。上海米、棉依赖外部供应,本地物资匮乏,限购既不解决缺口,还会加剧民众恐慌,扰乱市场秩序。请你再思考一下吧!'
} else if (value === 'B') {
return '很抱歉,你选错了!当时国民党滥发货币导致的贬值阴影未消,民众对新货币缺乏信任。此时增发人民币,会使货币供应量远超物资总量,重蹈贬值覆辙,削弱人民币信用,让投机气焰更盛。请你再思考一下吧!'
}
}}
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}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_33.mp3')}
/>
{/* 米棉之战 - 单选题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={'恭喜你,选择正确!'}
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={'恭喜你,选择正确!'}
content={successAlertContent}
onContinue={() => {
const isCorrect = successAlertContent.includes('棉农支持')
onQuiz4AlertContinue(isCorrect)
}}
/>
{/* 米棉之战 - 旁白3(图7) */}
<Narration
text="在以陈云为首的中财委的统一部署下,人民政府在全国范围内调配粮食、棉布和煤炭等商品,在准备充分后,政府开始行动。11月25日,市场物价处在高峰,进入总攻阶段。"
visible={showNarration3}
onClose={onNarration3Close}
enableAnimation={false}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_34.mp3')}
/>
{/* 米棉之战 - 单选题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={'恭喜你,选择正确!'}
content={successAlertContent}
onContinue={() => {
const isEndSuccess = successAlertContent.includes('这契合米棉之战史实')
onQuiz5AlertContinue(isEndSuccess)
}}
/>
{/* 米棉之战 - 旁白5(结束语)使用统一结语覆盖层组件 */}
<EndingOverlay
visible={showNarration5}
onContinue={onNarration5Continue}
text={
'从“银元之战”查封投机枢纽、稳固人民币根基,到“米棉之战”全国统筹调运、集中抛售击溃投机势力,这场经济战线的“淮海战役”,不仅平定了物价、安定了民心,更印证了“全国一盘棋” strategic智慧与制度优势。当市场秩序重归稳定,新生政权在经济领域站稳脚跟,这份由陈云同志等老一辈革命家铸就的治理经验,也为新时代经济建设埋下了思想伏笔,等待我们在传承中探索新的实践路径。'
}
animationSpeed={200}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_35.mp3')}
/>
</>
)
}
@@ -0,0 +1,88 @@
/**
* 米棉之战状态管理 Hook
* 管理米棉之战相关的所有状态
*/
import { useState } from 'react'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
/**
* 米棉之战状态管理 Hook
* @returns 米棉之战的所有状态和更新函数
*/
export const useMimianState = () => {
// 直接从 store 获取持久化的解决状态,不需要本地状态
const { isMimianSolved, setMimianSolved } = useChenyunStore()
// 更新解决状态时,直接更新 store
const handleSetIsMimianSolved = (solved: boolean) => {
setMimianSolved(solved)
saveChenyunStore()
setCourseTimeProgress(23)
}
// 单选题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 [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: handleSetIsMimianSolved,
}
}
@@ -0,0 +1,92 @@
/**
* 线索项组件
* 可复用的线索展示组件,处理线索的点击、已浏览标记等
*/
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,90 @@
/**
* 视频处理组件
* 处理视频播放、旁白、提示框的展示逻辑
*/
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'
import { getPublicPath } from '@/lib/utils'
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}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_29.mp3')}
enableAnimation={playingVideo === 'yinyuan'}
animationSpeed={200}
/>
{/* 提示框 */}
{showAlert && (
<Alert
visible={showAlert}
title={alertConfig.title}
subtitle={alertConfig.subtitle}
onClose={onAlertClose}
/>
)}
</>
)
}
@@ -0,0 +1,19 @@
/**
* 共享类型定义
* 定义 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,78 @@
/**
* 线索状态管理 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,248 @@
/**
* 银元之战主组件
* 整合银元之战的所有子组件:文件、线索、题目、引导提示
*/
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 { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { usePuzzleStore } from '@/store/puzzleStore'
import { starName } from '../constants'
import type { FileType } from '../shared/types'
import type { ClueData } from '../clue/types'
export interface YinyuanBattleProps {
/** 当前展开的文件 */
expandedFile: FileType | null
/** 是否所有线索都已查看 */
isAllViewed: boolean
/** 线索状态 */
clueStates: Record<string, boolean>
/** 米棉之战是否已解决 */
isMimianSolved: boolean
/** 文件点击回调 */
onFileClick: () => void
/** 线索点击回调 */
onClueClick: (clue: ClueData, clueKey: string) => void
/** 播放视频回调 */
onPlayVideo: (fileType: FileType) => void
}
/**
* 银元之战主组件
* 管理银元之战的所有状态和逻辑
*/
export const YinyuanBattle = ({
expandedFile,
isAllViewed,
clueStates,
isMimianSolved,
onFileClick,
onClueClick,
onPlayVideo,
}: YinyuanBattleProps) => {
const state = useYinyuanState()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const { isPuzzleUnlocked } = usePuzzleStore()
// 当米棉之战解决后,隐藏银元之战的走势图
useEffect(() => {
if (isMimianSolved) {
state.setShowTrendChartIcon(false)
// 如果 guide2 正在显示,也关闭它
if (state.showGuide2) {
state.setShowGuide2(false)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMimianSolved])
// 监听"前往解决"按钮出现的条件,显示第一个提示
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 && !isMimianSolved // 米棉之战解决后不再显示提示
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,
isMimianSolved,
])
// 监听走势图大图打开的条件,显示第三个提示
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)
// 检查碎片13是否已解锁,如果未解锁则触发动画
const pieceId = 13
if (!isPuzzleUnlocked(starName, pieceId)) {
triggerPuzzleAnimation(pieceId)
}
}
// 处理走势图点击
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,112 @@
/**
* 银元之战线索区域组件
* 包含线索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 '../clue/types'
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,117 @@
/**
* 银元之战文件组件
* 包含文件展示、走势图、前往解决按钮
*/
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
className="transition-all duration-300"
src={
expandedFile
? getStaticImage('chenyun/reflection/文件1打开')
: 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 && (
<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,86 @@
/**
* 银元之战引导提示组件
* 包含三个引导提示:前往解决按钮、走势图、走势图大图
*/
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={`以上海为中心,代表"人民币信用"的绿色区域迅速覆盖全市,并向外辐射。`}
onClick={onTrendChartClick}
onClose={onTrendChartDetailClose}
/>
</>
)
}
@@ -0,0 +1,107 @@
/**
* 银元之战题目组件
* 包含多选题1、多选题2、旁白2
*/
import { Narration, MultipleChoiceQuizPanel } from '@/components'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { getPublicPath } from '@/lib/utils'
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}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_31.mp3')}
/>
{/* 多选题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,94 @@
/**
* 银元之战状态管理 Hook
* 管理银元之战相关的所有状态
*/
import { useState } from 'react'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
/**
* 银元之战状态管理 Hook
* @returns 银元之战的所有状态和更新函数
*/
export const useYinyuanState = () => {
// 直接从 store 获取持久化的解决状态,不需要本地状态
const { isYinyuanSolved, setYinyuanSolved } = useChenyunStore()
// 更新解决状态时,直接更新 store
const handleSetIsYinyuanSolved = (solved: boolean) => {
setYinyuanSolved(solved)
saveChenyunStore()
}
// 多选题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 [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(
() => useChenyunStore.getState().isYinyuanSolved
)
const [hasShownGuide3, setHasShownGuide3] = useState(false)
return {
// 题目相关状态
showQuiz1,
setShowQuiz1,
showQuiz2,
setShowQuiz2,
// 旁白相关状态
showNarration2,
setShowNarration2,
narration2Text,
setNarration2Text,
// 成功提示框状态
showSuccessAlert,
setShowSuccessAlert,
// 解决状态
isYinyuanSolved,
setIsYinyuanSolved: handleSetIsYinyuanSolved,
// 走势图相关状态
showTrendChartIcon,
setShowTrendChartIcon,
showTrendChartDetail,
setShowTrendChartDetail,
// 引导提示状态
showGuide1,
setShowGuide1,
showGuide2,
setShowGuide2,
showGuide3,
setShowGuide3,
hasShownGuide1,
setHasShownGuide1,
hasShownGuide2,
setHasShownGuide2,
hasShownGuide3,
setHasShownGuide3,
}
}