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

+101
View File
@@ -0,0 +1,101 @@
import { useEffect } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { getUserDefine } from '@/lib/utils/api-common'
import { useChenyunStore, CHENYUN_LESSON_ID, CHENYUN_KEY } from '@/store/chenyunStore'
const deserializeVisitedExhibits = (
data: Record<string, number[]> | undefined
): Record<string, Set<number>> => {
if (!data || typeof data !== 'object') return {}
const visitedExhibits: Record<string, Set<number>> = {}
for (const [place, exhibits] of Object.entries(data)) {
visitedExhibits[place] = new Set(Array.isArray(exhibits) ? exhibits : [])
}
return visitedExhibits
}
const Chenyun = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// starName 固定为 chenyun
const starName = 'chenyun'
const lastVisitedPlace = useChenyunStore((state) => state.lastVisitedPlace)
useEffect(() => {
;(async () => {
try {
const raw = await getUserDefine(CHENYUN_LESSON_ID, CHENYUN_KEY)
if (!raw) return
const data = JSON.parse(raw) as {
visitedExhibits?: Record<string, number[]>
isYinyuanSolved?: boolean
isMimianSolved?: boolean
lastVisitedPlace?: string
}
useChenyunStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
isYinyuanSolved:
data.isYinyuanSolved !== undefined ? data.isYinyuanSolved : prev.isYinyuanSolved,
isMimianSolved:
data.isMimianSolved !== undefined ? data.isMimianSolved : prev.isMimianSolved,
lastVisitedPlace:
data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
}))
} catch (error) {
console.error('Failed to load Chenyun store', error)
}
})()
}, [])
if (!moduleName) {
return null
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/陈云故居底图`),
}}
>
<Image
className="absolute right-37 top-22"
src={getStaticImage('components/革命先驱故居-返回')}
button
onClick={() => navigate(`/entrance/${moduleName}`)}
/>
<div className="absolute right-20 top-75 flex flex-col space-y-10">
{/* 对话 */}
<Image
button
src={getStaticImage('star/一-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)}
/>
{/* 体悟 */}
<Image
button
src={getStaticImage('star/二-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/reflection`)}
/>
{/* 内化 */}
<Image
button
src={getStaticImage('star/三-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)}
/>
</div>
</div>
)
}
export default Chenyun
+495
View File
@@ -0,0 +1,495 @@
import { useMemo, useState, useRef, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
type DragFillResult,
LetterToChenyun,
EndingOverlay,
Narration,
} from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
import { GuideSteps, GuideStepsItem } from '@/components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
type QuizOption = {
value: string
label: string
isCorrect?: boolean
explanation?: string // 错误选项的解释
}
type QuizAnchor = {
id: string
time: number
title: string
prompt: string
type: 'single' | 'multiple' | 'drag'
options: QuizOption[]
// 拖拽题专用
blanks?: { id: string; label: string }[]
explanation?: string
}
// 星名固定为 chenyun
const starName = 'chenyun'
// 通用提示文案
const MULTIPLE_DEFAULT_ERROR = '你的选择还不够全面哦~请你再想想吧!'
// 锚点数据:可根据实际视频时长调整 time
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: 'quiz-1',
time: 60 * 5 + 53, //5.52分钟
title: '题目1',
prompt: '陈云经济思想的核心内容包括:',
type: 'multiple',
options: [
{ value: 'A', label: '综合平衡', isCorrect: true },
{
value: 'B',
label: '鸟笼比喻',
},
{ value: 'C', label: '正确的处理好计划与市场的关系', isCorrect: true },
{ value: 'D', label: '经济建设的目的论', isCorrect: true },
],
explanation: '鸟笼比喻是陈云提出的一个比喻。请你再想想吧!',
},
{
id: 'quiz-2',
time: 60 * 10 + 2, //10.01分钟
title: '题目2',
prompt: '陈云说过以下哪些话能着重体现出他的经济思想?',
type: 'multiple',
options: [
{
value: 'A',
label: '不唯上、不唯书、只唯实,交换、比较、反复',
isCorrect: true,
},
{ value: 'B', label: '无农不稳,无粮则乱', isCorrect: true },
{ value: 'C', label: '个人名利淡如水,党的事业重如山', isCorrect: true },
{
value: 'D',
label: '领导机关制定政策,要用百分之九十以上的时间作调查研究工作',
isCorrect: true,
},
],
explanation: '你的选择还不够全面哦~请你再想想吧!',
},
{
id: 'quiz-3',
time: 16 * 60 + 31, //16:26
title: '题目3',
prompt: '新时代的青年可以从陈云身上学习到',
type: 'drag',
options: [
{ value: '积极的人生态度', label: 'A.积极的人生态度', isCorrect: true },
{ value: '刻苦的学习精神', label: 'B.刻苦的学习精神', isCorrect: true },
{ value: '朴素的节俭精神', label: 'C.朴素的节俭精神', isCorrect: true },
{ value: '只唯上的工作作风', label: 'D.只唯上的工作作风' },
],
blanks: [
{ id: 'blank-1', label: '____' },
{ id: 'blank-2', label: '____' },
{ id: 'blank-3', label: '____' },
],
},
{
id: 'quiz-4',
time: 60 * 5 + 21, // 5:21
title: '题目4',
prompt: '陈云讲:“经济建设最终目的,是为了改善____的生活水平。”',
type: 'single',
options: [
{
value: 'A',
label: '为官者',
explanation: '这不是陈云提倡的经济建设的最终目的。请你再想想吧!',
},
{ value: 'B', label: '老百姓', isCorrect: true },
],
},
{
id: 'quiz-5',
time: 60 * 16 + 23, // 16:23
title: '题目5',
prompt: '以下哪些陈云的生活小事值得我们学习?',
type: 'multiple',
options: [
{ value: 'A', label: '光盘行动,不浪费粮食', isCorrect: true },
{ value: 'B', label: '打了 32 个补丁的棉坎肩他穿了 38 年', isCorrect: true },
{ value: 'C', label: '2-3 厘米长的铅笔头仍然留用', isCorrect: true },
{ value: 'D', label: '搓肥皂时关闭水龙头,节约用水', isCorrect: true },
],
explanation: MULTIPLE_DEFAULT_ERROR,
},
]
const ChenyunInternalization = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeSuccess, setActiveSuccess] = useState<string | null>(null)
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
// 拖拽题状态
const [dragQuizValues, setDragQuizValues] = useState<Record<string, DragFillResult[]>>({})
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>({})
const [narrationVisible, setNarrationVisible] = useState(false)
const [visible, setVisible] = useState(false)
const [showVideo, setShowVideo] = useState(false)
const [postGuideVisible, setPostGuideVisible] = useState(false)
const [showLetter, setShowLetter] = useState(false)
const [showStudyGuide, setShowStudyGuide] = useState(false)
const videoRef = useRef<VideoRef>(null)
useEffect(() => {
setNarrationVisible(true)
}, [])
// 视频加载完成后自动播放
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
videoRef.current.play().catch((error) => {
console.warn('视频播放失败:', error)
})
}
}
const anchors: VideoAnchor[] = useMemo(
() =>
QUIZ_ANCHORS.map((item) => {
const correctAnswers = item.options.filter((opt) => opt.isCorrect).map((opt) => opt.value)
// 拖拽题处理
if (item.type === 'drag' && item.blanks) {
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => {
// 拖拽题:处理选项拖拽与校验
const handleDragChange = (val: DragFillResult[]) => {
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }))
setDragQuizErrors((prev) => ({ ...prev, [item.id]: '' }))
if (val.length < correctAnswers.length) return
// 检查是否包含了所有正确答案(不要求顺序)
const filledAnswers = val.map((v) => v.optionValue)
const answerSet = new Set(correctAnswers)
const filledSet = new Set(filledAnswers)
const isCorrect =
answerSet.size === filledSet.size &&
[...answerSet].every((answer) => filledSet.has(answer))
if (isCorrect) {
setActiveSuccess(item.id)
setResumeCallback(() => resume)
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: '这不是陈云的工作作风。请你再想想吧!',
}))
}
}
return (
<>
<DragQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
questionPrefix={`【拖拽】${item.prompt}`}
questionSuffix="等精神。"
options={item.options.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
blanks={item.blanks || []}
value={dragQuizValues[item.id] || []}
error={dragQuizErrors[item.id]}
onChange={handleDragChange}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content=""
onContinue={() => {
setActiveSuccess(null)
triggerPuzzleAnimation(16)
setDragQuizValues((prev) => {
const next = { ...prev }
delete next[item.id]
return next
})
setDragQuizErrors((prev) => {
const next = { ...prev }
delete next[item.id]
return next
})
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
)
},
}
}
// 多选题处理
if (item.type === 'multiple') {
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => (
<>
<MultipleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`【多选】${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers}
errorMessage={(selectedValues) => {
// 题目1:选到“鸟笼比喻(B)”给专属提示;否则未选全给“还不够全面”提示
if (item.id === 'quiz-1') {
if (selectedValues.includes('B')) {
return item.explanation || '鸟笼比喻是陈云提出的一个比喻。请你再想想吧!'
}
return MULTIPLE_DEFAULT_ERROR
}
// 题目5:选到“D”给专属提示;否则未选全给“还不够全面”提示
if (item.id === 'quiz-5') {
if (selectedValues.includes('D')) {
const pickedD = item.options.find((o) => o.value === 'D')
return pickedD?.explanation || MULTIPLE_DEFAULT_ERROR
}
return MULTIPLE_DEFAULT_ERROR
}
return item.explanation || MULTIPLE_DEFAULT_ERROR
}}
onSubmit={(values: string[], isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id)
if (item.id === 'quiz-1') triggerPuzzleAnimation(15)
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
// title="恭喜你, 选择正确!"
// content="回答正确,请继续探索。"
onContinue={() => {
setActiveSuccess(null)
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
),
}
}
if (item.type === 'single') {
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => (
<>
<SingleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`【单选】${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers[0]}
onWrongSelect={(value) =>
item.options.find((opt) => opt.value === value)?.explanation
}
onSubmit={(_, isCorrect) => {
if (isCorrect) {
setActiveSuccess(item.id)
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你,选择正确!"
onContinue={() => {
setActiveSuccess(null)
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
),
}
}
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: () => null,
}
}),
[activeSuccess, resumeCallback, dragQuizValues, dragQuizErrors, triggerPuzzleAnimation]
)
if (!moduleName) return null
return (
<div
className="relative h-full w-full bg-black"
style={{
backgroundImage: getBackgroundImage('components/internalization/bg'),
}}
>
{/* 离开按钮 */}
<Image
className="absolute right-8 top-8 z-10"
src={getStaticImage('components/离开')}
button
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
<Narration
visible={narrationVisible}
audioChannelId="chenyun-internalization-narration"
onClose={() => {
setNarrationVisible(false)
setVisible(true)
}}
text={`从练塘水乡的少年时光,到商务印书馆的革命启航;从苏区与延安的经济破局,到新中国成立之初打赢“银元之战”“米棉之战”,再到建设与改革岁月里擘画实业、奠基兴国,我们已循着陈云同志的足迹,走过一段波澜壮阔的奋斗征程。那些“全国一盘棋”的统筹智慧、“不唯上、不唯书、只唯实”的务实品格、“为老百姓做事”的为民初心,穿越历史烟云,依旧闪耀着时代光芒。历史的回响已在耳畔,精神的传承更需躬身践行。新时代的青年,应该从历史感悟走向思想内化,读懂陈云经济思想的深刻内涵,汲取指引青春成长、担当时代使命的精神力量,让伟人风范在新时代薪火相传。`}
animationSpeed={200}
/>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath('video/chenyun/chenyun_neihua.mp4')}
className="h-full w-full"
anchors={anchors}
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
setPostGuideVisible(true)
}}
/>
)}
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={(visible) => {
setVisible(visible)
if (!visible) {
setShowVideo(true)
handleVideoCanPlay()
}
}}
showMask={false}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36.mp3')}
>
小沪,跟着你参观陈云纪念馆真是收获满满!看到馆里展示的“米棉之战”史料、全国财经统筹的实物展品,我真切感受到陈云爷爷在建国初期平抑物价、稳定经济的魄力。
</GuideStepsItem>
<GuideStepsItem speaker="student">
他用“全国一盘棋”思路化解危机的案例太震撼了,那你能再跟我讲讲,支撑这些成就的核心经济思想是什么吗?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_37.mp3')}
>
针对你的这个问题,今天我们有幸邀请到了中国中共党史学会理事,全国陈云思想生平研究会常务理事兼副秘书长,中共一大纪念馆陈列研究部主任,研究馆员房中老师。关于你的问题,我想房老师会给你指点迷津!
</GuideStepsItem>
</GuideSteps>
{/* 视频结束后的引导 */}
<GuideSteps
visible={postGuideVisible}
onVisibleChange={(visible) => {
setPostGuideVisible(visible)
// 引导结束后显示信件
if (!visible) {
setShowLetter(true)
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_38.mp3')}
>
感谢房老师的讲解!从陈云童年成长的水乡故居,到他主持"银元之战""米棉之战"的史料,再到那台称量粮食的天平、握不住的铅笔头,我真切感受到他"不唯上、不唯书、只唯实"的精神,也懂了他"为老百姓做事"的初心。
</GuideStepsItem>
<GuideStepsItem speaker="student">这些都让我特别受触动!</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39.mp3')}
>
你的感悟很深刻!陈云同志用一生诠释了“务实”与“为民”——从破解边区经济困境,到擘画国家工业化蓝图,再到改革开放时力挺宝钢建设,每一步都扎根实际、心系百姓。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你不妨把这份触动凝练成几句心里话,既总结参观的收获,也想想未来该如何践行这份精神,用自己的行动传承这份初心吧!
</GuideStepsItem>
</GuideSteps>
{/* 信件组件:视频引导结束后弹出,带飞入和放大动画 */}
<LetterToChenyun
visible={showLetter}
onClose={() => {
setShowLetter(false)
}}
onAnimationEnd={() => {
setShowStudyGuide(true)
}}
showVideo={true}
/>
<EndingOverlay
visible={showStudyGuide}
text="亲爱的同学,恭喜你圆满完成了陈云纪念馆的学习,也顺利走完了本课程所有名人故居的探访之旅。
在这段旅程里,你与八位伟大的灵魂跨越时空对话,触摸他们的理想与热忱,感受他们的坚韧与担当。这些宝贵的精神财富,早已融入民族的血脉,此刻也正成为你心中的光。
新时代的征程已在脚下,愿你带着这份收获与感动,将先辈的精神化作前行的力量,在属于我们的时代里,勇敢发光发热,为中华民族的伟大复兴贡献自己的一份力量。
"
animationSpeed={200}
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(24)
navigate('/menu')
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_40.mp3')}
/>
</div>
)
}
export default ChenyunInternalization
+340
View File
@@ -0,0 +1,340 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
Overlay,
StudyGuideModal,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState, useEffect } from 'react'
import Cursor from '@/components/cursor'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { useCursorController } from '@/hooks/useCursorController'
// starName 固定为 chenyun
const starName = 'chenyun'
// 展品 ID 与资源路径映射,ID 用来做唯一标识
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'chenyun/place1/场景1展品1',
2: 'chenyun/place1/场景1展品2',
// 3: "chenyun/place1/场景1展品3",
// 4: "chenyun/place1/场景1展品4",
}
const ChenyunPlace1 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true)
// 对话结束后的学习引导弹窗
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false)
const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false)
// 学习说明对话框显示状态
const [showIntroDialog, setShowIntroDialog] = useState(false)
// 引导对话显示状态
const [visible, setVisible] = useState(false)
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 使用持久化的访问状态
const place = 'place1'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
// Cursor 控制器:使用依赖映射实现自动解锁
// cursor 1 初始启用,cursor 2 依赖展品1(当展品1被访问后自动解锁)
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1], // cursor 2 依赖展品 1,当展品1被访问后自动解锁
},
visited, // 传入数组,而不是 Set
})
const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index)
// ExhibitOverlay 打开时会自动通过 onOpen 通知 ResidenceLayout 执行动画
}
const handleStartLearning = () => {
setShowIntroDialog(false)
setVisible(true)
}
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`chenyun/place1/场景1`),
}}
>
{/* 标题 */}
<Title
title="水乡少年·艰辛成长"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`)
}
}}
/>
{/* 展品1 */}
<Cursor
className="absolute left-145 top-100"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 展品2 */}
<Cursor
className="absolute left-198 top-100"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 展品3 */}
{/* <Cursor
className="absolute right-170 bottom-60"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
/> */}
{/* 展品4 */}
{/* <Cursor
className="absolute right-98 top-130 cursor"
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
/> */}
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('chenyun/place1/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place2')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place2`)
}}
/>
)}
{/* 遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
// 示例:展品1对应碎片1,展品2对应碎片2
// 根据实际需求调用 triggerPuzzleAnimation
if (activeExhibitId === 1) {
triggerPuzzleAnimation(1)
}
}}
onClose={() => {
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
// cursor 状态会根据 visited 自动更新,无需手动调用 enable
}
setActiveExhibitId(null)
}}
/>
{/* 学习引导词 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false)
setVisible(true)
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_02.mp3')}
ailgn="left"
>
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
{'\n'}
2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
{'\n'}
3.所有动态光点的展品全部学习后才能进入下一个场景学习。
</StudyGuideModal>
{/* 学习说明对话框 */}
{showIntroDialog && (
<Overlay
selectors={[]}
visible={showIntroDialog}
maskColor="rgba(0, 0, 0, 0.6)"
zIndex={50}
>
<LearningIntroduction handleStartLearning={handleStartLearning} />
</Overlay>
)}
{/* 场景对话 */}
<GuideSteps
visible={visible}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible)
if (!nextVisible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true)
setShowAfterDialogGuide(true)
}
}}
showMask={false}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_03.mp3')}
>
小沪!这就是陈云小时候生活的房子吗?看起来和普通农家差不多,他童年过得艰苦吗?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04.mp3')}
>
这座房子就是陈云故居,位于上海市青浦区练塘镇下塘街95号,是一座坐南朝北、面向河畔的两层楼江南民居。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
实际上是陈云舅父母的家,也是少年陈云从4岁至14岁生活的地方。童年的陈云,家庭生活十分不幸。他两岁时父亲病故,四岁时,母亲也悲苦地死去。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
父母去世后,由外婆抚养。六岁时,外婆去世,陈云过继给舅父母当养子。陈云从小跟舅父廖文光和舅母廖倪氏一起生活。家里靠种田和做小生意勉强维持生计。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_01.mp3')}
firstLineIndent="2em"
>
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
此刻你正站在陈云同志的故居堂屋里,接下来请跟着时光的脚步,
一起翻开他人生的7帧珍贵篇章:水乡少年·艰辛成长、商务启航·投身革命、
苏区淬炼·崭露头角、延安担当·经济破局、恢复时期·经济奠基、建设时期·务实兴邦、
改革浪潮·擘画实业。准备好一起走进这段滚烫的岁月了吗?
</StudyGuideModal>
</div>
)
}
// 学习说明内容
const LearningIntroduction = ({ handleStartLearning }: { handleStartLearning: () => void }) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="relative w-full h-full bg-linear-to-b from-[#f5f1e8] via-[#ede8dc] to-[#e8e0d1] shadow-[0_20px_60px_rgba(0,0,0,0.4)] border-4 border-[#d4c5a9] overflow-hidden">
{/* 内容层 */}
<div className="relative h-full w-full p-16 pb-20 overflow-hidden flex flex-col">
{/* 装饰性顶部线条 */}
<div className="absolute top-0 left-0 right-0 h-2 bg-linear-to-r from-transparent via-[#c9b99b] to-transparent"></div>
{/* 标题 */}
<div className="text-center mb-8 shrink-0">
<h1 className="text-6xl font-bold text-[#5c4a37] mb-4 tracking-wide">
走进故居-对话模块学习说明
</h1>
<div className="w-32 h-1 bg-linear-to-r from-transparent via-[#8b7355] to-transparent mx-auto"></div>
</div>
{/* 介绍文字 */}
<div className="mb-8 space-y-4 shrink-0">
<p className="text-3xl text-[#6b5d47] leading-relaxed text-center font-medium">
同学你好,欢迎来到走进故居-对话学习模块!
</p>
<p className="text-3xl text-[#6b5d47] leading-relaxed text-center">
进入陈云故居场景后,我们将通过参观陈云故居和陈云纪念馆的形式,带你游览陈云同志经历的7个阶段:
</p>
</div>
{/* 7个阶段列表 - 卡片式设计 */}
<div className="mb-8 bg-[rgba(255,255,255,0.6)] rounded-2xl p-6 border-2 border-[#d4c5a9] flex-shrink-0">
<div className="flex flex-wrap items-center justify-center gap-4 text-2xl">
{[
'水乡少年 艰辛成长',
'商务启航 投身革命',
'苏区淬炼 崭露头角',
'延安担当 经济破局',
'恢复时期 经济奠基',
'建设时期 务实兴邦',
'改革浪潮 擘画实业',
].map((stage, index, array) => (
<div key={index} className="flex items-center">
<span className="px-4 py-2 bg-[rgba(139,115,85,0.15)] rounded-lg text-[#5c4a37] font-semibold">
{stage}
</span>
{index < array.length - 1 && (
<span className="mx-2 text-[#8b7355] font-bold text-3xl">→</span>
)}
</div>
))}
</div>
</div>
{/* 功能说明列表 */}
<div className="mb-8 space-y-4 flex-1 overflow-hidden">
<div className="h-full overflow-hidden">
{[
'走进故居-对话学习模块会包含多个场景,场景中有蓝色动态光点的展品,表示未浏览,可通过点击光点来学习展品背后的故事,学习展品后蓝色动态光点会变为绿色静态光点,表示已浏览。',
'场景中的展品有可能会包含一个或多个拼图,游览过程中如获得拼图,会自动掉落拼图并收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。',
'所有有蓝色动态光点的展品全部学习后才能进入下一个场景进行学习。',
'每个阶段学习过后,会有学习回顾环节,以导图的形式总结了阶段的学习内容。',
].map((feature, index) => (
<div
key={index}
className="flex items-start gap-6 bg-[rgba(255,255,255,0.4)] rounded-xl p-6 border-l-4 border-[#8b7355] mb-4"
>
<span className="shrink-0 w-12 h-12 bg-linear-to-br from-[#8b7355] to-[#6b5d47] text-white text-3xl font-bold rounded-full flex items-center justify-center shadow-md">
{index + 1}
</span>
<p className="text-2xl text-[#5c4a37] leading-relaxed flex-1 pt-1">{feature}</p>
</div>
))}
</div>
</div>
{/* 开始学习按钮 */}
<div className="flex justify-center shrink-0">
<button
onClick={handleStartLearning}
className="relative cursor-pointer px-20 py-5 bg-linear-to-r from-[#8b7355] to-[#6b5d47] text-white text-4xl font-bold rounded-2xl shadow-[0_8px_20px_rgba(139,115,85,0.4)] hover:shadow-[0_12px_30px_rgba(139,115,85,0.6)] hover:from-[#9b8365] hover:to-[#7b6d57] transition-all duration-300 active:scale-95 border-2 border-[#a08f75]"
>
<span className="relative z-10">开始学习</span>
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white to-transparent opacity-0 hover:opacity-20 rounded-2xl transition-opacity duration-300"></div>
</button>
</div>
</div>
</div>
</div>
)
}
export default ChenyunPlace1
+210
View File
@@ -0,0 +1,210 @@
import { useState, useEffect } from 'react'
import { ExhibitOverlay, Title, Image, Cursor, GuideSteps, GuideStepsItem } from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useCursorController } from '@/hooks/useCursorController'
// starName 固定为 chenyun
const starName = 'chenyun'
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'chenyun/place2/场景2展品1',
2: 'chenyun/place2/场景2展品2',
}
// 展品描述映射(当前未使用,若后续需要可恢复)
const ChenyunPlace2 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
// 当前激活展品 ID,只存 ID 做唯一标识
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 使用持久化的访问状态
const place = 'place2'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: { 2: [1] },
visited,
})
// GuideSteps 显示状态
const [guideVisible, setGuideVisible] = useState(false)
// GuideSteps 当前步骤
const [guideVisible2, setGuideVisible2] = useState(false)
const [guideStep2, setGuideStep2] = useState(0)
const [guideStep, setGuideStep] = useState(0)
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 1) {
// 展品1:点击时同时打开展品和引导对话,从第1步开始
setActiveExhibitId(1)
setGuideVisible(true)
setGuideStep(0)
} else {
// 展品2:点击时同时打开展品和引导对话,从第1步开始
setActiveExhibitId(2)
setGuideVisible2(true)
setGuideStep2(0)
setActiveExhibitId(index)
}
}
// 监听GuideSteps步骤变化,仅同步当前步骤
const handleGuideStepChange = (step: number) => {
setGuideStep(step)
}
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`chenyun/place2/场景2`),
}}
>
{/* 标题 */}
<Title
title="水乡少年·艰辛成长"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
{/* 返回上一章 */}
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place1`)
}
}}
/>
{/* 展品1 */}
<Cursor
className="absolute left-100 top-100"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 展品2 */}
<Cursor
className="absolute left-210 top-100"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-160"
src={getStaticImage('chenyun/place2/继续前进2')}
button
onClick={() => {
setLastVisitedPlace('place3')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
/>
)}
{/* 遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(2)
} else if (activeExhibitId === 2) {
triggerPuzzleAnimation(3)
}
}}
onClose={() => {
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}}
/>
{/* 展品1的引导对话 */}
<GuideSteps
visible={guideVisible}
onVisibleChange={setGuideVisible}
currentStep={guideStep}
onStepChange={handleGuideStepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_05.mp3')}
>
那陈云是怎么有机会到图中的青浦县立乙种商业学校读书的呀?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_06.mp3')}
>
因为家贫,初小读完的陈云便辍学在家。后在舅母亲戚的资助下,陈云才到青浦县立乙种商业学校学习珠算和记账的,他在很短时间里就基本掌握了珠算,还初步学会写账,这为陈云日后从事经济工作打下了基础。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
陈云在党内以“铁算盘”著称,他在经济工作中能熟练使用算盘,就得益于少年时在青浦县立乙种商业学校的学习。
</GuideStepsItem>
</GuideSteps>
{/* 展品2的引导对话 */}
<GuideSteps
visible={guideVisible2}
onVisibleChange={setGuideVisible2}
currentStep={guideStep2}
onStepChange={(step) => setGuideStep2(step)}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_07.mp3')}
>
这里有一幅陈云题写的颜安小学,请问陈云在这所学校读过书吗?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_08.mp3')}
>
是的,陈云在青浦县立乙种商业学校学习了一个多月后,又因经济困难不得不辍学回家。多亏当地知识分子、颜安小学校长杜枢,他免费保荐陈云入颜安小学高小部读书。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
颜安小学实行的新式教育开阔了陈云的视野,五四运动后,颜安小学师生积极投入反帝爱国运动,为陈云日后加入中国共产党埋下了思想的种子。
</GuideStepsItem>
</GuideSteps>
</div>
)
}
export default ChenyunPlace2
+251
View File
@@ -0,0 +1,251 @@
import { useState } from 'react'
import {
ExhibitOverlay,
Title,
Image,
Cursor,
GuideSteps,
GuideStepsItem,
Video,
SelectFillQuizPanel,
} from '@/components'
import { SuccessAlert } from '@/components/layout'
import type { SelectFillResult } from '@/components/layout/quiz'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useCursorController } from '@/hooks/useCursorController'
// starName 固定为 chenyun
const starName = 'chenyun'
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'chenyun/place3/场景3展品1',
2: 'chenyun/place3/场景3展品2',
}
const ChenyunPlace3 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 使用持久化的访问状态
const place = 'place3'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: { 2: [1] },
visited,
})
const [videoVisible, setVideoVisible] = useState(false)
const [quizVisible, setQuizVisible] = useState(false)
const [successAlertVisible, setSuccessAlertVisible] = useState(false)
// GuideSteps 显示状态
const [guideVisible, setGuideVisible] = useState(false)
// GuideSteps 当前步骤
const [guideStep, setGuideStep] = useState(0)
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 1) {
// 展品1:点击时同时打开展品和引导对话,从第1步开始
setActiveExhibitId(1)
setGuideVisible(true)
setGuideStep(0)
} else {
// 展品2:先播放视频,结束后再打开展品
setVideoVisible(true)
}
}
// 监听GuideSteps步骤变化,仅同步当前步骤
const handleGuideStepChange = (step: number) => {
setGuideStep(step)
}
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`chenyun/place3/场景3`),
}}
>
{/* 标题 商务启航·投身革命*/}
<Title
title="商务启航·投身革命"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
{/* 返回上一章 */}
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place2`)
}
}}
/>
{/* 展品1 */}
<Cursor
className="absolute right-200 bottom-75"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 展品2 */}
<Cursor
className="absolute right-130 bottom-75"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('chenyun/place3/继续前进3')}
button
onClick={() => {
setLastVisitedPlace('place4')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place4`)
}}
/>
)}
{/* 展品2视频播放遮罩(全屏) */}
{videoVisible && (
<div className="fixed inset-0 z-11001 bg-black">
<Video
src={getPublicPath('video/chenyun/chenyun_04.mp4')}
autoPlay
playsInline
showSkipButton
controls={false}
onEnded={() => {
setVideoVisible(false)
setQuizVisible(true)
}}
onSkip={() => {
setVideoVisible(false)
setQuizVisible(true)
}}
/>
</div>
)}
<SelectFillQuizPanel
visible={quizVisible}
titlePrefix="【填空】"
titleSuffix="年八九月间,陈云由董亦湘、恽雨棠两人介绍,加入中国共产党。"
blanks={[{ id: 'd1' }, { id: 'd2' }, { id: 'd3' }, { id: 'd4' }]}
options={[
{ value: '0', label: '0' },
{ value: '1', label: '1' },
{ value: '2', label: '2' },
{ value: '3', label: '3' },
{ value: '4', label: '4' },
{ value: '5', label: '5' },
{ value: '6', label: '6' },
{ value: '7', label: '7' },
{ value: '8', label: '8' },
{ value: '9', label: '9' },
]}
correctAnswers={{
d1: '1',
d2: '9',
d3: '2',
d4: '5',
}}
errorMessage="一九二五年八九月间,陈云由董亦湘、恽雨棠两人介绍,加入中国共产党。这是陈云思想上的重大转变,是他新的政治生命的开始。请你再想想吧!"
onSubmit={(_results: SelectFillResult[], isCorrect: boolean) => {
if (isCorrect) {
setQuizVisible(false)
setSuccessAlertVisible(true)
}
}}
onError={(_error: string) => {}}
submitText="确定"
zIndex={10000}
/>
<SuccessAlert
visible={successAlertVisible}
onContinue={() => {
setSuccessAlertVisible(false)
markExhibitVisited(place, 2)
triggerPuzzleAnimation(5)
}}
/>
{/* 遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(4)
}
}}
onClose={() => {
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}}
/>
{/* 展品1的引导对话 */}
<GuideSteps
visible={guideVisible}
onVisibleChange={setGuideVisible}
currentStep={guideStep}
onStepChange={handleGuideStepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_09.mp3')}
>
陈云14岁就去了上海商务印书馆当学徒,他主要负责哪些工作呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_10.mp3')}
>
从颜安小学毕业后,陈云进入了商务印书馆当学徒,开始在柜台接待顾客,打珠算、写信封、练包装、理货物、修文具。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
陈云的珠算能力在商务印书馆工作期间有了很大提高。由于工作认真负责,三年的学徒期,陈云提前两年就破格到商务印书馆虹口分店(现为1925书局)当店员。
</GuideStepsItem>
</GuideSteps>
</div>
)
}
export default ChenyunPlace3
+289
View File
@@ -0,0 +1,289 @@
import { useState, useEffect } from 'react'
import {
ExhibitOverlay,
Title,
Image,
Cursor,
GuideSteps,
GuideStepsItem,
MultipleChoiceQuizPanel,
} from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
// starName 固定为 chenyun
const starName = 'chenyun'
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'chenyun/place4/场景4展品1',
2: 'chenyun/place4/场景4展品2',
}
const ChenyunPlace4 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 使用持久化的访问状态
const place = 'place4'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: { 2: [1] },
visited,
})
// 展品1引导对话
const [guideVisible, setGuideVisible] = useState(false)
const [guideStep, setGuideStep] = useState(0)
// 展品2的引导对话
const [guideVisible2, setGuideVisible2] = useState(false)
const [guideStep2, setGuideStep2] = useState(0)
// 展品2关闭后的题目状态(多选)
const [quiz2Visible, setQuiz2Visible] = useState(false)
const [successAlert2Visible, setSuccessAlert2Visible] = useState(false)
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 1) {
// 展品1:点击时同时打开展品和引导对话
setActiveExhibitId(1)
setGuideVisible(true)
setGuideStep(0)
} else {
// 展品2:点击时同时打开展品和引导对话
setActiveExhibitId(2)
setGuideVisible2(true)
setGuideStep2(0)
}
}
// 监听GuideSteps步骤变化,仅同步当前步骤
const handleGuideStepChange = (step: number) => {
setGuideStep(step)
}
const handleGuideStepChange2 = (step: number) => {
setGuideStep2(step)
}
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`chenyun/place4/场景4`),
}}
>
{/* 标题 */}
<Title
title="苏区淬炼·崭露头角"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
{/* 返回上一章 */}
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place3`)
}
}}
/>
{/* 展品1 */}
<Cursor
className="absolute left-180 top-120"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 展品2 */}
<Cursor
className="absolute right-2 top-100"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('chenyun/place3/继续前进3')}
button
onClick={() => {
setLastVisitedPlace('place5')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place5`)
}}
/>
)}
{/* 遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(6)
}
}}
onClose={() => {
// 展品2:关闭后先进入答题,答对后再标记已读
if (activeExhibitId === 2) {
setActiveExhibitId(null)
setQuiz2Visible(true)
return
}
// 其他展品:关闭即标记已读
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}}
/>
{/* 展品2的题目 + 成功提示(使用 ScaledPortal 保证层级与缩放) */}
<ScaledPortal enabled={quiz2Visible || successAlert2Visible} zIndex={10050}>
<>
<MultipleChoiceQuizPanel
visible={quiz2Visible}
title="【多选】中央苏区时期,陈云开展经济工作的主要做法包括:"
options={[
{
value: 'A',
label: 'A.从实际出发,实事求是地制定符合企业发展需求的劳动政策',
},
{
value: 'B',
label: 'B.提议建立合作社,吸收失业工人并增强企业管理的民主性',
},
{
value: 'C',
label: 'C.照搬“左”倾经济政策,严格推行统一的劳动标准',
},
{
value: 'D',
label: 'D.审查企业、作坊的实际承受能力,避免政策脱离实际',
},
]}
correctAnswer={['A', 'B', 'D']}
minSelections={1}
maxSelections={4}
errorMessage={(selectedValues) => {
// 选了 C:给出截图中的专属提示
if (selectedValues.includes('C')) {
return '陈云反对“左”倾错误。请你再想想吧!'
}
// 没选 C 但仍不正确:通常是“选不全”
return '你的选择还不够全面哦~请你再想想吧!'
}}
onSubmit={(_values, isCorrect) => {
if (isCorrect) {
setQuiz2Visible(false)
setSuccessAlert2Visible(true)
}
}}
zIndex={10000}
/>
<SuccessAlert
visible={successAlert2Visible}
onContinue={() => {
setSuccessAlert2Visible(false)
markExhibitVisited(place, 2)
}}
/>
</>
</ScaledPortal>
{/* 展品1的引导对话 */}
<GuideSteps
visible={guideVisible}
onVisibleChange={setGuideVisible}
currentStep={guideStep}
onStepChange={handleGuideStepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_11.mp3')}
>
这组史料看起来陈云前往中央苏区了,他在中央苏区主要负责哪些工作呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12.mp3')}
>
1932年3月,陈云担任中共临时中央政治局常委,兼任全国总工会党团书记。1933年1月,陈云离开上海前往中央苏区首府瑞金,继续参加中共中央和全国总工会的领导工作。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
虽然陈云在中央苏区的任务主要是领导工人运动,但是陈云总是能针对一些棘手的经济问题提出较出色的建议。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
在中央苏区时期,陈云也关注和积极参与解决了许多经济难题。1934年,在中共六届五中全会上,陈云被选为中央政治局委员、常委,兼任中央白区工作部部长。
</GuideStepsItem>
</GuideSteps>
{/* 展品2的引导对话 */}
<GuideSteps
visible={guideVisible2}
onVisibleChange={setGuideVisible2}
currentStep={guideStep2}
onStepChange={handleGuideStepChange2}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_13.mp3')}
>
苏区的经济工作一定不简单,陈云是如何开展工作的呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14.mp3')}
>
中央苏区时期的经济斗争,是陈云经济工作能力的“淬炼场”。陈云分析了“左”倾经济政策的表现、危害以及原因。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
他强调,当务之急是重新严格审查各个企业、工厂以及商店、大小作坊的实际承受力和经济支付能力。从实际出发,实事求是地制定符合中央苏维埃区域每一个工厂、企业、作坊发展需求的劳动政策。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
陈云提议建立合作社,广泛吸收失业工人加入,多听取工人建议、增强企业管理的民主性,从而更好地建设苏区经济。这些做法为他日后主持全国财经工作积累了宝贵经验。
</GuideStepsItem>
</GuideSteps>
</div>
)
}
export default ChenyunPlace4
+262
View File
@@ -0,0 +1,262 @@
import { useState, useEffect } from 'react'
import {
ExhibitOverlay,
Title,
Image,
Cursor,
Video,
GuideSteps,
GuideStepsItem,
MultipleChoiceQuizPanel,
} from '@/components'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
// starName 固定为 chenyun
const starName = 'chenyun'
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: 'chenyun/place5/场景5展品1',
2: 'chenyun/place5/场景5展品2',
3: 'chenyun/place5/场景5展品3',
}
// 展品描述映射(目前未使用,后续如需在展品弹窗中显示可恢复)
// const exhibitDescriptionMap: Record<1 | 2 | 3, string> = {
// 1: "",
// 2: "延安时期因为国民党的封锁,陕甘宁边区经济困难。陈云转战陕甘宁边区主持财经工作,他遇到了很多经济难题。1944年新春伊始,陈云开始担任西北财经办事处副主任,工作转到财经方面,这是陈云以主要精力从事领导经济工作的开始。当时,陕甘宁边区在国民党严密封锁下,财经状况严峻,边区物价猛涨,老百姓日常生活必需的布匹和粮食十分缺乏。国民党顽固派封锁边区,企图“不让一粒粮、一尺布进入边区”,同时千方百计阻止边区食盐出口。",
// 3: "",
// };
const ChenyunPlace5 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
// 使用持久化的访问状态
const place = 'place5'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const { isEnabled } = useCursorController({
initialEnabled: [1, 2],
dependencies: { 3: [1, 2] },
visited,
})
const [videoVisible3, setVideoVisible3] = useState(false)
const [quiz3Visible, setQuiz3Visible] = useState(false)
const [successAlert3Visible, setSuccessAlert3Visible] = useState(false)
// 展品1引导对话
const [guideVisible, setGuideVisible] = useState(false)
const [guideStep, setGuideStep] = useState(0)
const handleOpenExhibit = (index: 1 | 2 | 3) => {
if (index === 3) {
// 展品3:播放视频,不打开展品
setVideoVisible3(true)
return
}
if (index === 2) {
setGuideVisible(true)
setGuideStep(0)
}
setActiveExhibitId(index)
}
const progressCount = visited.size
const total = 3
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`chenyun/place5/场景5`),
}}
>
{/* 标题 */}
<Title
title="延安担当·经济破局"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
{/* 返回上一章 */}
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place4`)
}
}}
/>
{/* 展品1 */}
<Cursor
className="absolute left-30 top-70"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 展品2 */}
<Cursor
className="absolute left-107 top-72"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 展品3 */}
<Cursor
className="absolute right-170 top-84"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-5 right-20"
src={getStaticImage('chenyun/place5/继续前进4')}
button
onClick={() => {
setLastVisitedPlace('place6')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place6`)
}}
/>
)}
{/* 展品1的引导对话 */}
<GuideSteps
visible={guideVisible}
onVisibleChange={setGuideVisible}
currentStep={guideStep}
onStepChange={(step) => setGuideStep(step)}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_15.mp3')}
>
小沪,延安时期因为国民党的封锁,陕甘宁边区经济困难。陈云转战陕甘宁边区主持财经工作,他一定遇到了很多经济难题吧。
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_16.mp3')}
>
1944年新春伊始,陈云开始担任西北财经办事处副主任,工作转到财经方面,这是陈云以主要精力从事领导经济工作的开始。当时,陕甘宁边区在国民党严密封锁下,财经状况严峻,边区物价猛涨,老百姓日常生活必需的布匹和粮食十分缺乏。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
国民党顽固派封锁边区,企图“不让一粒粮、一尺布进入边区”,同时千方百计阻止边区食盐出口。
</GuideStepsItem>
</GuideSteps>
{/* 展品3视频播放(全屏) */}
{videoVisible3 && (
<Video
src={getPublicPath('video/chenyun/chenyun_07.mp4')}
className="w-full h-full"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setVideoVisible3(false)
setQuiz3Visible(true)
}}
/>
)}
{/* 展品3视频后的题目 + 成功提示(使用 ScaledPortal) */}
<ScaledPortal enabled={quiz3Visible || successAlert3Visible} zIndex={10050}>
<>
<MultipleChoiceQuizPanel
visible={quiz3Visible}
title="【多选】陈云主持陕甘宁边区财经工作期间,提出的解决当时经济困难的应对之策包括:"
options={[
{ value: 'A', label: 'A.实行贸易统一' },
{
value: 'B',
label: 'B.事先准确估计情况,预测市场商情,以争取主动',
},
{
value: 'C',
label: 'C.不断总结封锁状态下对外贸易的经验,摸索实现等价交换的途径',
},
{
value: 'D',
label: 'D.贸易公司对边区内部贸易也要采取市场上做生意的办法',
},
{ value: 'E', label: 'E.做贸易工作的干部要精通业务' },
]}
correctAnswer={['A', 'B', 'C', 'D', 'E']}
minSelections={1}
maxSelections={5}
errorMessage="你的选择还不够全面哦~请你再想想吧!"
onSubmit={(_values, isCorrect) => {
if (isCorrect) {
setQuiz3Visible(false)
setSuccessAlert3Visible(true)
}
}}
zIndex={10000}
/>
<SuccessAlert
visible={successAlert3Visible}
onContinue={() => {
setSuccessAlert3Visible(false)
markExhibitVisited(place, 3)
triggerPuzzleAnimation(8)
}}
/>
</>
</ScaledPortal>
{/* 遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
// description={
// activeExhibitId ? exhibitDescriptionMap[activeExhibitId] : undefined
// }
autoPlayDescription={activeExhibitId === 2}
onOpen={() => {
if (activeExhibitId === 2) {
triggerPuzzleAnimation(7)
}
}}
onClose={() => {
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}}
/>
</div>
)
}
export default ChenyunPlace5
+149
View File
@@ -0,0 +1,149 @@
import { useState, useEffect } from 'react'
import { ExhibitOverlay, Title, Image, Cursor, GuideSteps, GuideStepsItem } from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
// starName 固定为 chenyun
const starName = 'chenyun'
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1, string> = {
1: 'chenyun/place6/场景6展品1',
}
const ChenyunPlace6 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null)
// 使用持久化的访问状态
const place = 'place6'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const [guideVisible, setGuideVisible] = useState(false)
const [guideStep, setGuideStep] = useState(0)
const handleOpenExhibit = () => {
// 展品1:点击时同时打开展品和引导对话,从第1步开始
setActiveExhibitId(1)
setGuideVisible(true)
setGuideStep(0)
}
const handleGuideStepChange = (step: number) => {
setGuideStep(step)
}
const progressCount = visited.size
const total = 1
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`chenyun/place6/场景6`),
}}
>
{/* 标题 */}
<Title
title="恢复时期·经济奠基"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
{/* 返回上一章 */}
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place5`)
}
}}
/>
{/* 展品1 */}
<Cursor
className="absolute left-230 bottom-80"
onClick={handleOpenExhibit}
read={visited.has(1)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('chenyun/place6/继续前进5')}
button
onClick={() => {
setLastVisitedPlace('place7')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place7`)
}}
/>
)}
{/* 遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(9)
}
}}
onClose={() => {
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}}
/>
{/* 展品1的引导对话 */}
<GuideSteps
visible={guideVisible}
onVisibleChange={setGuideVisible}
currentStep={guideStep}
onStepChange={handleGuideStepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_17.mp3')}
>
这是中央人民政府院财政经济委员会的印章,到这个时候陈云已经从地方到中央工作了吗?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_18.mp3')}
>
是的,新中国刚成立时,中国共产党接管的是一个民穷财尽、物价飞涨的烂摊子。面对财政经济上的严重困难,陈云在1949年受命担任新成立的中央财政经济委员会(简称中财委)主任,主持领导全国财政经济工作。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
陈云挂帅后的首仗就是打击投机、平抑物价。通过“银元之战”“两白一黑”战役等几次严峻斗争,只用不到一年时间,就迅速稳定了金融物价。
</GuideStepsItem>
</GuideSteps>
</div>
)
}
export default ChenyunPlace6
+406
View File
@@ -0,0 +1,406 @@
import { useCallback, useRef, useState } from 'react'
import {
Title,
Image,
Cursor,
Video,
GuideSteps,
GuideStepsItem,
type SingleChoiceOption,
type DragFillResult,
SingleChoiceQuizPanel,
} from '@/components'
import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import type { VideoRef } from '@/components/video'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const STAR_NAME = 'chenyun'
const PLACE = 'place7'
const EXHIBIT_ID = 1
const PUZZLE_PIECE_ID = 10
const TOTAL_EXHIBITS = 1
// ─── 资源路径 ───
const BACKGROUND_KEY = `${STAR_NAME}/${PLACE}/场景7`
const CONTINUE_IMAGE_KEY = `${STAR_NAME}/${PLACE}/继续前进`
const VIDEO_1_SRC = getPublicPath(`video/${STAR_NAME}/chenyun_10.mp4`)
const VIDEO_2_SRC = getPublicPath(`video/${STAR_NAME}/chenyun_11.mp4`)
const AUDIO_PREFIX = `audio/${STAR_NAME}`
// ─── 题目1:单选 ───
const QUIZ1_OPTIONS: SingleChoiceOption[] = [
{ value: 'A', label: 'A.推动了淮海战役的胜利' },
{ value: 'B', label: 'B.有利于稳定人民币的发行和地位' },
{ value: 'C', label: 'C.巩固了新中国人民政权' },
]
const QUIZ1_CORRECT: string = 'B'
const QUIZ1_TITLE =
'【单选】1949年6月,中共中央指示打击银元投机活动,金条、银元一律由人民银行挂牌收兑,铁路交通事业,税收一律收人民币。同时,上海军管会查封了证券大楼。这次“银元之战”:'
const QUIZ1_ERROR_HINTS: Record<string, string> = {
A: '淮海战役于1949年1月胜利结束,发生在这次“银元之战”前,不存在因果关系。请你再想想吧!',
C: '1949年6月新中国政权尚未建立。请你再想想吧',
}
const QUIZ1_FALLBACK_HINT = '再想一想,注意题干中的时间和提示信息哦~'
const QUIZ1_SUCCESS_CONTENT =
'“银元之战”属于当时人民政府整顿金融的重要举措,黄金、银元投机不利于人民币币值稳定和流通,因此“银元之战”打击贵金属投机活动,有利于稳定人民币的发行和地位。'
// ─── 题目2:拖拽 ───
const QUIZ2_BLANKS = [
{ id: 'blank-1', label: '____' },
{ id: 'blank-2', label: '____' },
{ id: 'blank-3', label: '____' },
]
const QUIZ2_OPTIONS = [
{ value: '棉纱', label: '棉纱' },
{ value: '大米', label: '大米' },
{ value: '煤炭', label: '煤炭' },
{ value: '石油', label: '石油' },
]
const QUIZ2_CORRECT_SET = new Set(['棉纱', '大米', '煤炭'])
const QUIZ2_PREFIX =
'【拖拽】新中国成立后,为了稳定物价,中央政府组织了“两白一黑”的集中调运,“两白一黑”指的是'
const QUIZ2_ERROR = '石油当时不是主要调控对象。请你再想想吧!'
const isQuiz2Correct = (answers: DragFillResult[]): boolean => {
if (answers.length !== QUIZ2_CORRECT_SET.size) return false
const filled = new Set(answers.map((a) => a.optionValue))
if (filled.size !== QUIZ2_CORRECT_SET.size) return false
for (const value of QUIZ2_CORRECT_SET) {
if (!filled.has(value)) return false
}
return true
}
const ChenyunPlace7 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
const visited = new Set(getVisitedExhibits(PLACE))
const completed = visited.size >= TOTAL_EXHIBITS
// ─── 第一阶段:视频1 + 对话1 + 单选题 ───
const videoRef1 = useRef<VideoRef>(null)
const [video1Visible, setVideo1Visible] = useState(false)
const [video1Playing, setVideo1Playing] = useState(false)
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
const [quiz1Visible, setQuiz1Visible] = useState(false)
const [quiz1Completed, setQuiz1Completed] = useState(false)
// ─── 第二阶段:视频2 + 对话2 + 拖拽题 ───
const videoRef2 = useRef<VideoRef>(null)
const [video2Visible, setVideo2Visible] = useState(false)
const [video2Playing, setVideo2Playing] = useState(false)
const [guide2Visible, setGuide2Visible] = useState(false)
const [guide2Step, setGuide2Step] = useState(0)
const [quiz2Visible, setQuiz2Visible] = useState(false)
const [quiz2Value, setQuiz2Value] = useState<DragFillResult[]>([])
const [quiz2Error, setQuiz2Error] = useState('')
const [quiz2Completed, setQuiz2Completed] = useState(false)
// ─── 成功提示 ───
const [successVisible, setSuccessVisible] = useState(false)
const [successContent, setSuccessContent] = useState('')
// ─── 流程辅助 ───
const resetFlow = useCallback(() => {
setVideo1Visible(false)
setVideo1Playing(false)
setGuide1Visible(false)
setGuide1Step(0)
setQuiz1Visible(false)
setQuiz1Completed(false)
setVideo2Visible(false)
setVideo2Playing(false)
setGuide2Visible(false)
setGuide2Step(0)
setQuiz2Visible(false)
setQuiz2Value([])
setQuiz2Error('')
setQuiz2Completed(false)
setSuccessVisible(false)
setSuccessContent('')
}, [])
const enterPhase1 = useCallback(() => {
setVideo1Visible(true)
setVideo1Playing(false)
setGuide1Visible(true)
setGuide1Step(0)
}, [])
const enterPhase2 = useCallback(() => {
setVideo2Visible(true)
setVideo2Playing(false)
setGuide2Visible(true)
setGuide2Step(0)
}, [])
// 点击展品光标:每次都从头走完整流程
const handleOpenExhibit = useCallback(() => {
resetFlow()
enterPhase1()
}, [resetFlow, enterPhase1])
// 对话1 结束 → 播放视频1
const handleGuide1VisibleChange = (visible: boolean) => {
setGuide1Visible(visible)
if (!visible && video1Visible) {
setVideo1Playing(true)
videoRef1.current?.play()
}
}
// 对话2 结束 → 播放视频2
const handleGuide2VisibleChange = (visible: boolean) => {
setGuide2Visible(visible)
if (!visible && video2Visible) {
setVideo2Playing(true)
videoRef2.current?.play()
}
}
// 视频1 结束:标记访问 + 触发拼图 + 出题1
const handleVideo1Ended = () => {
setVideo1Playing(false)
markExhibitVisited(PLACE, EXHIBIT_ID)
triggerPuzzleAnimation(PUZZLE_PIECE_ID)
setQuiz1Visible(true)
}
// 题1 答对:弹成功提示,关闭后进入第二阶段
const handleQuiz1Submit = () => {
setQuiz1Visible(false)
setQuiz1Completed(true)
setSuccessContent(QUIZ1_SUCCESS_CONTENT)
setSuccessVisible(true)
}
// 视频2 结束:出题2
const handleVideo2Ended = () => {
setVideo2Playing(false)
setQuiz2Visible(true)
}
// 题2 实时校验:填齐后判定
const handleQuiz2Change = (val: DragFillResult[]) => {
setQuiz2Value(val)
setQuiz2Error('')
if (val.length < QUIZ2_CORRECT_SET.size) return
if (!isQuiz2Correct(val)) {
setQuiz2Error(QUIZ2_ERROR)
return
}
setQuiz2Visible(false)
setVideo1Visible(false)
setVideo2Visible(false)
setQuiz2Completed(true)
setSuccessContent('')
setSuccessVisible(true)
}
// 成功提示关闭:题1答对后才进入第二阶段;题2答对后则停留在场景
const handleSuccessDismiss = () => {
setSuccessVisible(false)
if (quiz1Completed && !quiz2Completed && !quiz2Visible) {
enterPhase2()
}
}
if (!moduleName) return null
const subtitle = completed ? '已完成' : `${visited.size}/${TOTAL_EXHIBITS}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: getBackgroundImage(BACKGROUND_KEY) }}
>
{/* 标题 */}
<Title
title="恢复时期·经济奠基"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
{/* 返回上一章 */}
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => navigate(`/${moduleName}/${STAR_NAME}/place6`)}
/>
{/* 展品1 光标 */}
<Cursor
className="absolute left-230 top-88"
onClick={handleOpenExhibit}
read={visited.has(EXHIBIT_ID)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage(CONTINUE_IMAGE_KEY)}
button
onClick={() => {
setLastVisitedPlace('place8')
saveChenyunStore()
navigate(`/${moduleName}/${STAR_NAME}/place8`)
}}
/>
)}
{/* 视频1(全屏) */}
{video1Visible && (
<div className="fixed inset-0 z-9999 bg-black">
<Video
ref={videoRef1}
src={VIDEO_1_SRC}
className="w-full h-full"
autoPlay={video1Playing}
playsInline
preload="auto"
muted={!video1Playing}
showSkipButton
onEnded={handleVideo1Ended}
/>
</div>
)}
{/* 对话1(叠加在视频1之上) */}
<GuideSteps
visible={guide1Visible}
onVisibleChange={handleGuide1VisibleChange}
currentStep={guide1Step}
onStepChange={setGuide1Step}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_19.mp3`)}
>
那"银元之战"是如何取得胜利的呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20.mp3`)}
>
由于通货膨胀,国民党发行的法币、金圆券变成了一堆废纸。当时坊间流传“大街过三道,物价跳三跳”。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
有人计算,一百元法币,在一九三七年可买两头牛,一九四五年只能买两个鸡蛋,一九四七年只能买一个煤球,到一九四九年五月连一粒米也买不到。可见,当时通货膨胀多么严重。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
打击投机资本,稳定金融物价,陈云代中央起草《中共中央关于打击银元使人民币占领阵地的指示》,提出以强硬手段打击投机势力。
</GuideStepsItem>
</GuideSteps>
{/* 题目1:单选 */}
<SingleChoiceQuizPanel
visible={quiz1Visible}
zIndex={10002}
title={QUIZ1_TITLE}
options={QUIZ1_OPTIONS}
correctAnswer={QUIZ1_CORRECT}
onSubmit={handleQuiz1Submit}
onWrongSelect={(val) => QUIZ1_ERROR_HINTS[val] || QUIZ1_FALLBACK_HINT}
/>
{/* 视频2(全屏) */}
{video2Visible && (
<div className="fixed inset-0 z-10010 bg-black">
<Video
ref={videoRef2}
src={VIDEO_2_SRC}
className="w-full h-full"
autoPlay={video2Playing}
playsInline
preload="auto"
muted={!video2Playing}
showSkipButton
skipText="跳过"
onEnded={handleVideo2Ended}
/>
</div>
)}
{/* 对话2(叠加在视频2之上) */}
<GuideSteps
visible={guide2Visible}
onVisibleChange={handleGuide2VisibleChange}
currentStep={guide2Step}
onStepChange={setGuide2Step}
showMask={false}
highlightSelectors={[]}
zIndex={10011}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_21.mp3`)}
>
那"两白一黑"战役是如何打赢的呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_22.mp3`)}
>
“银元之战”的胜利并没有遏制住投机者的嚣张气焰,物价稳定仅十几天后,投机者就由银元投机转向“两白一黑”的投机,即:棉纱、大米和煤炭,引发了又一次全国性涨价狂潮。
</GuideStepsItem>
</GuideSteps>
{/* 题目2:拖拽 */}
<DragQuizPanel
visible={quiz2Visible}
zIndex={10012}
questionPrefix={QUIZ2_PREFIX}
questionSuffix="。"
options={QUIZ2_OPTIONS}
blanks={QUIZ2_BLANKS}
value={quiz2Value}
error={quiz2Error}
onChange={handleQuiz2Change}
/>
{/* 成功提示(盖在视频/题目之上) */}
{successVisible && (
<div className="fixed inset-0" style={{ zIndex: 20000 }}>
<SuccessAlert
visible
title="恭喜你, 选择正确!"
content={successContent}
onClose={handleSuccessDismiss}
onContinue={handleSuccessDismiss}
/>
</div>
)}
</div>
)
}
export default ChenyunPlace7
+317
View File
@@ -0,0 +1,317 @@
import { useState } from 'react'
import {
ExhibitOverlay,
Title,
Image,
Cursor,
GuideSteps,
GuideStepsItem,
MultipleChoiceQuizPanel,
SingleChoiceQuizPanel,
} from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
// starName 固定为 chenyun
const starName = 'chenyun'
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'chenyun/place8/场景8展品1',
2: 'chenyun/place8/场景8展品2',
}
const ChenyunPlace8 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 使用持久化的访问状态
const place = 'place8'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: { 2: [1] },
visited,
})
// 展品2的对话引导
const [guideVisible, setGuideVisible] = useState(false)
const [guideStep, setGuideStep] = useState(0)
const [guideVisible2, setGuideVisible2] = useState(false)
const [guideStep2, setGuideStep2] = useState(0)
const [quizVisible, setQuizVisible] = useState(false)
const [successAlertVisible, setSuccessAlertVisible] = useState(false)
const [quizPassed, setQuizPassed] = useState(false)
// 关闭对话后出现的“统购统销”单选题
const [closeQuizVisible, setCloseQuizVisible] = useState(false)
const [closeSuccessAlertVisible, setCloseSuccessAlertVisible] = useState(false)
const [closeQuizPassed, setCloseQuizPassed] = useState(false)
const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index)
if (index === 1) {
setGuideVisible2(true)
setGuideStep2(0)
setQuizPassed(false)
}
// 如果是展品2,显示对话引导
if (index === 2) {
setGuideVisible(true)
setGuideStep(0)
}
}
// 监听对话引导步骤变化
const handleGuideStepChange = (step: number) => {
setGuideStep(step)
}
// 监听对话引导完成
const handleGuideVisibleChange = (visible: boolean) => {
setGuideVisible(visible)
}
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`chenyun/place8/场景8`),
}}
>
{/* 标题 */}
<Title
title="建设时期·务实兴邦"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
{/* 返回上一章 */}
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place7`)
}
}}
/>
{/* 展品1 */}
<Cursor
className="absolute right-175 top-75"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 展品2 */}
<Cursor
className="absolute right-100 bottom-55"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-150"
src={getStaticImage('chenyun/place8/继续前进6')}
button
onClick={() => {
setLastVisitedPlace('place9')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place9`)
}}
/>
)}
{/* 遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(11)
}
}}
// description={
// activeExhibitId === 1
// ? `打赢“银元之战”“两白一黑”战役的过程彰显了中国共产党治理经济的卓越能力!这里有一份“一五”计划报告的手稿,陈云在“一五”计划的编制中做出了重大贡献。
// 一九五四年,陈云的主要工作之一,就是主持编制第一个五年计划纲要。这次编制工作是在前四次基础上进行的,陈云提出计划编制工作中要注意几个问题:首先是工业投资金额,其次是市场供应和人民生活,第三是农业发展速度,这三个问题对编制计划有指导意义。第一个五年计划纲要草案的重点是优先发展重工业,特别是156项重点工程。当时陈云重点抓重工业建设和粮食保障,通过“统购统销”稳定粮价,为重点工程项目筑牢根基,亲手铺就了我国工业化的起步之路。`
// : undefined
// }
onClose={() => {
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}}
/>
{/* 展品2的对话引导 - 在展品遮罩层上方显示 */}
<GuideSteps
visible={guideVisible}
onVisibleChange={handleGuideVisibleChange}
currentStep={guideStep}
onStepChange={handleGuideStepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_25.mp3')}
>
您讲的“统购统销”是不是和这里我们看到的粮票有直接关系啊?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_26.mp3')}
>
不错,随着大规模经济建设的展开,全国粮、棉、油等主要农产品严重紧缺,供求矛盾尖锐。陈云经过调查研究,反复比较,权衡利弊得失,终于下决心采用在农村征购,在城市配售的解决粮食问题的办法,也就是后来所说的“统购统销”。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
陈云说:“只有采用这种办法,才能保证我国人民生活日益增长的需要,才能制止投机活动,保证市场物价的稳定,才能使发展国民经济第一个五年计划得以顺利地进行。”
</GuideStepsItem>
</GuideSteps>
{/* 打赢“银元之战”“两白一黑”战役的过程真的是彰显了中国共产党治理经济的卓越能力!这里有一份“一五”计划报告的手稿,陈云在“一五”计划的编制中做出了什么贡献吗? */}
<GuideSteps
visible={guideVisible2}
onVisibleChange={(visible) => {
setGuideVisible2(visible)
// 关闭对话后,如果还未完成该题,则弹出题目
if (!visible && !closeQuizPassed) {
setCloseQuizVisible(true)
}
}}
currentStep={guideStep2}
onStepChange={(step) => {
// 在第 3 句(guideStep2 === 2)结束后,进入下一句前先弹题
if (step === 3 && !quizPassed) {
setGuideStep2(2)
setQuizVisible(true)
return
}
setGuideStep2(step)
}}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_23.mp3')}
>
打赢“银元之战”“两白一黑”战役的过程真的是彰显了中国共产党治理经济的卓越能力!这里有一份“一五”计划报告的手稿,陈云在“一五”计划的编制中做出了什么贡献吗?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24.mp3')}
>
一九五四年,陈云的主要工作之一,就是主持编制第一个五年计划纲要。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这次编制工作是在前四次基础上进行的,陈云提出计划编制工作中要注意几个问题:首先是工业投资金额,其次是市场供应和人民生活,第三是农业发展速度,这三个问题对编制计划有指导意义。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
第一个五年计划纲要草案的重点是优先发展重工业,特别是156项重点工程。当时陈云重点抓重工业建设和粮食保障,通过“统购统销”稳定粮价,为重点工程项目筑牢根基,亲手铺就了我国工业化的起步之路。
</GuideStepsItem>
</GuideSteps>
{/* “这三个问题…”结束后的题目 + 成功提示(使用 ScaledPortal) */}
<ScaledPortal enabled={quizVisible || successAlertVisible} zIndex={10050}>
<>
<MultipleChoiceQuizPanel
visible={quizVisible}
title="【多选】陈云主持编制第一个五年计划纲要时提出计划编制工作中要注意的问题包括:"
options={[
{ value: 'A', label: 'A.工业投资金额' },
{ value: 'B', label: 'B.市场供应和人民生活' },
{ value: 'C', label: 'C.农业发展速度' },
]}
correctAnswer={['A', 'B', 'C']}
minSelections={1}
maxSelections={3}
errorMessage="你的选择还不够全面哦~请你再想想吧!"
onSubmit={(_values, isCorrect) => {
if (isCorrect) {
setQuizVisible(false)
setSuccessAlertVisible(true)
}
}}
zIndex={10000}
/>
<SuccessAlert
visible={successAlertVisible}
onContinue={() => {
setSuccessAlertVisible(false)
setQuizPassed(true)
setGuideVisible2(true)
setGuideStep2(3)
}}
/>
</>
</ScaledPortal>
{/* 关闭对话后出现的题目(单选) + 成功提示(使用 ScaledPortal) */}
<ScaledPortal enabled={closeQuizVisible || closeSuccessAlertVisible} zIndex={10050}>
<>
<SingleChoiceQuizPanel
visible={closeQuizVisible}
title="【单选】第一个五年计划纲要草案的重点是优先发展重工业,特别是156项重点工程。当时陈云重点抓重工业建设和粮食保障,通过“____”稳定粮价。"
options={[
{ value: 'A', label: 'A.优先分配' },
{ value: 'B', label: 'B.统购统销' },
{ value: 'C', label: 'C.依靠进口' },
]}
correctAnswer="B"
errorMessage="请你再想想吧!"
onWrongSelect={() => '这不是当时陈云稳定粮价的方法。请你再想想吧!'}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setCloseQuizVisible(false)
setCloseSuccessAlertVisible(true)
}}
zIndex={10000}
/>
<SuccessAlert
visible={closeSuccessAlertVisible}
onContinue={() => {
setCloseSuccessAlertVisible(false)
// setCloseQuizPassed(true);
}}
/>
</>
</ScaledPortal>
</div>
)
}
export default ChenyunPlace8
+177
View File
@@ -0,0 +1,177 @@
import { useState, useRef } from 'react'
import { Title, Image, Cursor, Video, GuideSteps, GuideStepsItem } from '@/components'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import type { VideoRef } from '@/components/video'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
// starName 固定为 chenyun
const starName = 'chenyun'
const ChenyunPlace9 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
// const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null)
// 使用持久化的访问状态
const place = 'place9'
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
// 视频播放状态
const [videoVisible, setVideoVisible] = useState(false)
const [videoPlaying, setVideoPlaying] = useState(false)
const videoRef = useRef<VideoRef>(null)
// 对话引导状态
const [guideVisible, setGuideVisible] = useState(false)
const [guideStep, setGuideStep] = useState(0)
const handleOpenExhibit = (_index: 1) => {
// 展品1:播放视频
setVideoVisible(true)
setVideoPlaying(true)
}
// 监听对话引导步骤变化
const handleGuideStepChange = (step: number) => {
setGuideStep(step)
}
// 监听对话引导完成
const handleGuideVisibleChange = (visible: boolean) => {
setGuideVisible(visible)
if (!visible && videoVisible) {
setVideoVisible(false)
}
}
const progressCount = visited.size
const total = 1
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`chenyun/place9/场景9`),
}}
>
{/* 标题 */}
<Title
title="改革浪潮·擘画实业"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
{/* 返回上一章 */}
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place8`)
}
}}
/>
{/* 展品1 */}
<Cursor
className="absolute right-26 top-88"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
/>
{/* 离开场景 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('chenyun/place9/离开场景')}
button
onClick={() => {
setCourseTimeProgress(22)
saveChenyunStore()
navigate(`/${moduleName}/${starName}/home`)
}}
/>
)}
{/* 展品1视频播放(全屏) */}
{videoVisible && (
<div className="fixed inset-0 z-9999 bg-black">
<Video
ref={videoRef}
src={getPublicPath('video/chenyun/chenyun_14.mp4')}
className="w-full h-full"
autoPlay={videoPlaying}
playsInline
preload="auto"
muted={!videoPlaying}
showSkipButton
skipText="跳过"
onEnded={() => {
setVideoPlaying(false)
markExhibitVisited(place, 1)
// 视频播放完成后,显示对话引导
setGuideVisible(true)
setGuideStep(0)
triggerPuzzleAnimation(12)
}}
/>
</div>
)}
{/* 视频播放完成后的对话引导 */}
<GuideSteps
visible={guideVisible}
onVisibleChange={handleGuideVisibleChange}
currentStep={guideStep}
onStepChange={handleGuideStepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27.mp3')}
>
我们已经沿着陈云的足迹,从练塘水乡走到改革开放的前沿,读懂了他“适合老百姓的要求,做了一点事”的初心。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
接下来,请你跨越时空,走进1949年刚解放时期的上海,参与到当时那场轰轰烈烈的“银元之战”和“米棉之战”之中去。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
在实践决策中体会陈云“多谋善断”“敢闯务实”的卓越智慧与战略魄力,感受我国集中力量办大事的制度优势如何在经济战场中彰显威力。
</GuideStepsItem>
</GuideSteps>
{/* 遮罩层 */}
{/* <ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onClose={() => {
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}}
/> */}
</div>
)
}
export default ChenyunPlace9
@@ -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,
}
}