feat:修改英文双引号
This commit is contained in:
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
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user