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

+24
View File
@@ -0,0 +1,24 @@
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image } from '@/components'
import { useNavigate } from 'react-router-dom'
const Home = () => {
const navigate = useNavigate()
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: getBackgroundImage('home/首页底图') }}
>
<Image
onClick={() => {
navigate('/menu')
}}
className="absolute bottom-38 left-1/2 -translate-1/2"
button
src={getStaticImage('home/首页-进入')}
/>
</div>
)
}
export default Home
+121
View File
@@ -0,0 +1,121 @@
import { useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image, PuzzleSystem } from '@/components'
import { useNavigate } from 'react-router-dom'
import MenuPuzzleCenter from '@/components/puzzle/MenuPuzzleCenter'
import { DEFAULT_PUZZLE_RESIDENCE } from '@/config/puzzleResidences'
const Menu = () => {
const navigate = useNavigate()
const [isPuzzleCenterVisible, setIsPuzzleCenterVisible] = useState(false)
const [activeResidence, setActiveResidence] = useState<string | null>(null)
/**
* 类型:1:革命先驱、文化民人、艺术名家、经济巨擘
*/
const Category = [
{
id: 1,
name: '革命先驱',
moduleName: 'revolutionary',
image: 'menu/模块-革命先驱',
},
{
id: 2,
name: '文教名人',
moduleName: 'cultural',
image: 'menu/模块-文教名人',
},
{
id: 3,
name: '艺术名家',
moduleName: 'artistic',
image: 'menu/模块-艺术名家',
},
{
id: 4,
name: '经济巨擘',
moduleName: 'economic',
image: 'menu/模块-经济巨擘',
},
]
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: getBackgroundImage('menu/故居入口页底图') }}
>
<Image
className="absolute top-10 left-10"
src={getStaticImage('menu/首页-返回')}
button
onClick={() => navigate('/')}
/>
{/* 左下角碎片盒按钮 - 进入拼图系统 */}
<div className="absolute left-4 bottom-4 z-10 cursor-pointer">
<Image
src={getStaticImage('components/拼图盒')}
button
onClick={() => setActiveResidence(DEFAULT_PUZZLE_RESIDENCE)}
/>
</div>
{/* 顶层碎片管理中心:查看故居导图与进度 */}
<MenuPuzzleCenter
visible={isPuzzleCenterVisible}
onClose={() => setIsPuzzleCenterVisible(false)}
onSelectResidence={(residence) => {
setActiveResidence(residence)
setIsPuzzleCenterVisible(false)
}}
allowPuzzleEntry={false}
/>
{/* 进入具体故居的拼图系统(与故居页面一致的 PuzzleSystem) */}
{activeResidence && (
<PuzzleSystem
visible={!!activeResidence}
onClose={() => setActiveResidence(null)}
residence={activeResidence}
showResidenceNav
onResidenceChange={setActiveResidence}
/>
)}
<div className="absolute left-40 top-70 inline-grid grid-cols-2 gap-x-20 gap-y-10">
{Category.map((item) => (
<div
className="relative w-[241px] h-[282px] bg-cover bg-center bg-no-repeat hover:brightness-110 hover:scale-105 transition-all cursor-pointer"
style={{ backgroundImage: getBackgroundImage('menu/模块-通用') }}
key={item.id}
onClick={() => navigate(`/entrance/${encodeURIComponent(item.moduleName)}`)}
>
<div className="absolute top-15 flex flex-col items-center">
<Image key={item.id} src={getStaticImage(item.image)} />
<div className="relative mt-23">
<Image className="absolute bottom-5" src={getStaticImage('menu/未看完')} />
<Image src={getStaticImage('menu/未探索')} />
</div>
</div>
</div>
))}
</div>
{/* 学习地图 */}
<Image className="absolute bottom-100 right-60" src={getStaticImage('menu/学习地图')} />
<div className="absolute bottom-50 right-57">
<div className="relative">
<Image
src={getStaticImage('menu/导航地图')}
button
onClick={() => setIsPuzzleCenterVisible(true)}
/>
</div>
</div>
</div>
)
}
export default Menu
+35
View File
@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom'
import { Home, ArrowLeft } from 'lucide-react'
export default function NotFound() {
return (
<div className="h-full w-full relative bg-white">
<div className="max-w-2xl mx-auto text-center">
<div className="bg-white rounded-xl shadow-lg p-12">
<div className="mb-8">
<h1 className="text-9xl font-bold text-indigo-600 mb-4">404</h1>
<h2 className="text-3xl font-bold text-gray-800 mb-2">页面未找到</h2>
<p className="text-gray-600 text-lg">抱歉,您访问的页面不存在或已被移除。</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
to="/"
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-semibold"
>
<Home size={20} />
返回首页
</Link>
<button
onClick={() => window.history.back()}
className="inline-flex items-center justify-center gap-2 px-6 py-3 border-2 border-indigo-600 text-indigo-600 rounded-lg hover:bg-indigo-50 transition-colors font-semibold"
>
<ArrowLeft size={20} />
返回上一页
</button>
</div>
</div>
</div>
</div>
)
}
+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,
}
}
+143
View File
@@ -0,0 +1,143 @@
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
//英文命名 "革命先驱" | "文化名人" | "艺术名家" | "经济巨擘"
type ModuleName = 'revolutionary' | 'cultural' | 'artistic' | 'economic'
interface Star {
image: string
name: string
starName: string
}
interface EntranceBackground {
backgroundImage: string
//Star1
Star: {
Star1: Star
Star2: Star
}
}
/**
* 故居入口页
* 根据模块名称显示不同的背景图
* 根据模块名称显示不同的进入按钮
* 革命先驱:毛泽东、孙中山
* 文化名人:鲁迅、黄炎培
* 艺术名家:聂耳、刘海栗
* 经济巨擘:荣德生、陈云
*
*/
const EntranceBackgroundMap: Record<ModuleName, EntranceBackground> = {
revolutionary: {
backgroundImage: getBackgroundImage('entrance/革命先驱'),
Star: {
Star1: {
image: getStaticImage('entrance/毛泽东故居入口'),
name: '毛泽东',
starName: 'maozedong',
},
Star2: {
image: getStaticImage('entrance/孙中山故居入口-解锁'),
name: '孙中山',
starName: 'sunzhongshan',
},
},
},
cultural: {
backgroundImage: getBackgroundImage('entrance/文教名人'),
Star: {
Star1: {
image: getStaticImage('entrance/鲁迅故居-解锁'),
name: '鲁迅',
starName: 'luxun',
},
Star2: {
image: getStaticImage('entrance/黄元培-解锁'),
name: '黄炎培',
starName: 'huangyanpei',
},
},
},
artistic: {
backgroundImage: getBackgroundImage('entrance/艺术名家'),
Star: {
Star1: {
image: getStaticImage('entrance/聂耳亮'),
name: '聂耳',
starName: 'nieer',
},
Star2: {
image: getStaticImage('entrance/张乐平亮'),
name: '张乐平',
starName: 'zhangleping',
},
},
},
economic: {
backgroundImage: getBackgroundImage('entrance/经济巨擎'),
Star: {
Star1: {
image: getStaticImage('entrance/荣德生-解锁'),
name: '荣德生',
starName: 'rongdesheng',
},
Star2: {
image: getStaticImage('entrance/陈云故居-解锁'),
name: '陈云',
starName: 'chenyun',
},
},
},
}
const Entrance = () => {
const { moduleName } = useParams()
const navigate = useNavigate()
// const decodedModuleName = decodeURIComponent(moduleName || "");
const { backgroundImage, Star } = EntranceBackgroundMap[moduleName as ModuleName] || {}
if (!Star) {
return null
}
const { Star1, Star2 } = Star
return (
<div className="w-full h-full bg-cover bg-center bg-no-repeat" style={{ backgroundImage }}>
<Image
className="absolute bottom-5 right-5"
src={getStaticImage('entrance/入口-返回')}
button
onClick={() => navigate('/menu')}
/>
{/* 名人1 */}
<Image className="absolute left-73 top-[calc(50%+38px)] -translate-y-1/2" src={Star1.image} />
{Star1.image && (
<Image
className="absolute left-90.5 bottom-40"
src={getStaticImage('entrance/点击前往浏览')}
button
onClick={() => navigate(`/${moduleName}/${Star1.starName}/home`)}
/>
)}
{/* 名人2 */}
<Image
className="absolute right-77.5 top-[calc(50%+39px)] -translate-y-1/2"
src={Star2.image}
/>
{Star2.image && (
<Image
className="absolute right-90.5 bottom-40"
src={getStaticImage('entrance/点击前往浏览')}
button
onClick={() => navigate(`/${moduleName}/${Star2.starName}/home`)}
/>
)}
{/* <Image
className="absolute right-5 top-1/2 -translate-y-1/2"
src={Star2.image}
/> */}
</div>
)
}
export default Entrance
+96
View File
@@ -0,0 +1,96 @@
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 {
useHuangyanpeiStore,
HUANGYANPEI_LESSON_ID,
HUANGYANPEI_KEY,
} from '@/store/huangyanpeiStore'
const Huangyanpei = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// starName 固定为 huangyanpei
const starName = 'huangyanpei'
const lastVisitedPlace = useHuangyanpeiStore((state) => state.lastVisitedPlace)
const reflectionRoute = useHuangyanpeiStore((state) => state.reflectionRoute)
useEffect(() => {
;(async () => {
try {
const raw = await getUserDefine(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY)
if (!raw) return
const data = JSON.parse(raw) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
reflectionRoute?: string
}
useHuangyanpeiStore.setState((prev) => ({
...prev,
lastVisitedPlace:
data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
reflectionRoute:
data.reflectionRoute &&
['reflection', 'reflection2', 'reflection3', 'reflection4'].includes(
data.reflectionRoute
)
? data.reflectionRoute
: (prev.reflectionRoute ?? 'reflection'),
}))
} catch (error) {
console.error('Failed to load Huangyanpei 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}/${reflectionRoute || 'reflection'}`)}
/>
{/* 内化 */}
<Image
button
src={getStaticImage('star/三-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)}
/>
</div>
</div>
)
}
export default Huangyanpei
+554
View File
@@ -0,0 +1,554 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
type DragFillResult,
SingleChoiceQuizPanel,
LetterToHuangyanpei,
EndingOverlay,
Narration,
} from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
import { GuideSteps, GuideStepsItem } from '@/components'
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
promptSuffix?: string
type: 'single' | 'multiple' | 'drag'
options: QuizOption[]
blanks?: { id: string; label: string }[]
blankPrefixes?: string[]
blankSeparators?: string[]
explanation?: string
}
const starName = 'huangyanpei'
// 锚点数据:根据视频时长调整 time
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: 'quiz-1',
time: 96, // 1:36
title: '',
prompt:
'【单选】黄炎培先生提出“双手万能、手脑并用”的职业教育的学习方式、教学原则的主要原因是:',
type: 'single',
options: [
{ value: 'A', label: '解决旧教育脱离生产劳动、人才供需错配的问题', isCorrect: true },
{
value: 'B',
label: '鼓励学生只专注于动手劳动,放弃文化学习',
explanation:
'黄炎培的主张是“要使动的读书,读书的动手”,强调动手与读书相结合,并非让学生放弃文化学习。请你再想想吧!',
},
{
value: 'C',
label: '为了完全照搬西方职业教育模式',
explanation:
'这一原则是针对中国旧教育的弊端和当时民族工业的实际需求提出的本土化理念。请你再想想吧!',
},
{
value: 'D',
label: '迎合传统旧式教育的理念',
explanation: '这一原则是为了解决旧教育严重脱离生产劳动的弊端。请你再想想吧!',
},
],
explanation:
'黄炎培提出“双手万能、手脑并用”是为了让学生既具备文化知识,又掌握实践技能,解决人才供需错配的矛盾。',
},
{
id: 'quiz-2b',
time: 177, // 2:56
title: '',
prompt: '【拖拽】黄炎培先生认为,职业学校的办学宗旨是“',
promptSuffix: '”。',
type: 'drag',
options: [
{ value: '有业', label: '有业' },
{ value: '乐业', label: '乐业' },
{ value: '就业', label: '就业' },
],
blanks: [
{ id: 'blank-1', label: '___' },
{ id: 'blank-2', label: '___' },
],
blankPrefixes: ['使无业者', '使有业者'],
blankSeparators: [','],
explanation: '正确答案为“使无业者有业,使有业者乐业”。',
},
{
id: 'quiz-4',
time: 261, // 4:20
title: '',
prompt: '【多选】黄炎培先生认为职业教育的目的是___,简称“四个准备”。',
type: 'multiple',
options: [
{
value: 'A',
label: '谋个性之发展,为个人谋生之准备',
isCorrect: true,
},
{
value: 'B',
label: '为个人服务社会之准备',
isCorrect: true,
},
{
value: 'C',
label: '为国家及世界增进生产力之准备',
isCorrect: true,
},
],
explanation: '你的选择还不够全面哦~请你再想想吧!',
},
{
id: 'quiz-2',
time: 221, // 3:41
title: '',
prompt: '【多选】黄炎培先生主导设计的课程与现在中职学校强调的理实一体这一理念相通是:',
type: 'multiple',
options: [
{
value: 'A',
label: '上午学机械原理,下午就去车间打铁、造台钳',
isCorrect: true,
},
{
value: 'B',
label: '课堂上学记账、推销,课后就去街头卖国货',
isCorrect: true,
},
{
value: 'C',
label: '学习四书五经、八股文',
},
{
value: 'D',
label: '在川沙小学堂采取"半天读书、半天做手工"的学习模式',
isCorrect: true,
},
],
explanation: '黄炎培认为教育不能只谈书本、脱离实际',
},
{
id: 'quiz-3',
time: 369, // 6:09
title: '',
prompt:
'【拖拽】当年黄炎培先生有推广国货的爱国情怀,如今我们的"国货"已经自强自立,请将其中的国产企业/品牌放置到空格处',
type: 'drag',
options: [
{ value: '特斯拉', label: '特斯拉' },
{ value: '比亚迪', label: '比亚迪' },
{ value: '华为', label: '华为' },
{ value: '江南造船', label: '江南造船' },
{ value: '光明食品', label: '光明食品' },
{ value: 'C919飞机', label: 'C919飞机' },
],
blanks: [
{ id: 'blank-1', label: '国产企业1' },
{ id: 'blank-2', label: '国产企业2' },
{ id: 'blank-3', label: '国产企业3' },
{ id: 'blank-4', label: '国产企业4' },
{ id: 'blank-5', label: '国产企业5' },
],
},
]
const HuangyanpeiInternalization = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [narrationVisible, setNarrationVisible] = useState(false)
const [activeSuccess, setActiveSuccess] = useState<string | null>(null)
const [successContent, setSuccessContent] = useState<string>('')
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
const [dragQuizValues, setDragQuizValues] = useState<Record<string, DragFillResult[]>>({})
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>({})
const [visible, setVisible] = useState(false)
const [showVideo, setShowVideo] = useState(false)
const [postGuideVisible, setPostGuideVisible] = useState(false)
const [showLetter, setShowLetter] = useState(false)
const [showStudyGuide, setShowStudyGuide] = useState(false)
const videoRef = useRef<VideoRef>(null)
useEffect(() => {
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]: '' }))
// 第 2 题:必须按顺序填入“有业”“乐业”
if (item.id === 'quiz-2b') {
const orderedAnswers = ['有业', '乐业']
const isCorrect =
val.length === orderedAnswers.length &&
orderedAnswers.every((answer, index) => val[index]?.optionValue === answer)
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent('')
setResumeCallback(() => resume)
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: item.explanation || '请你再想想吧!',
}))
}
return
}
// 特斯拉标签无法拖入答案框
const hasInvalidOption = val.some((v) => v.optionValue === '特斯拉')
if (hasInvalidOption) {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: '特斯拉为外资企业哦~',
}))
return
}
if (val.length < item.blanks!.length) return
// 检查是否都是正确答案(排除特斯拉)
const validAnswers = ['比亚迪', '华为', '江南造船', '光明食品', 'C919飞机']
const filledAnswers = val.map((v) => v.optionValue)
const isCorrect = filledAnswers.every((answer) => validAnswers.includes(answer))
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent('')
setResumeCallback(() => resume)
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: '特斯拉是美国品牌。请你再想想吧!',
}))
}
}
return (
<>
<DragQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={``}
questionPrefix={item.prompt}
questionSuffix={item.promptSuffix || '。'}
blankSeparators={item.blankSeparators}
options={item.options.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
blanks={item.blanks || []}
blankPrefixes={item.blankPrefixes}
value={dragQuizValues[item.id] || []}
error={dragQuizErrors[item.id]}
onChange={handleDragChange}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
setDragQuizValues((prev) => {
const next = { ...prev }
delete next[item.id]
return next
})
setDragQuizErrors((prev) => {
const next = { ...prev }
delete next[item.id]
return next
})
if (item.id === 'quiz-3') {
triggerPuzzleAnimation(16)
}
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) => {
const selected = item.options.find((opt) => opt.value === value)
return selected?.explanation || item.explanation || '请再想想吧!'
}}
onSubmit={(_value, isCorrect) => {
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent('')
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
),
}
}
// 多选题处理
if (item.type === 'multiple') {
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => (
<>
<MultipleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers}
errorMessage={item.explanation || '再思考一下,注意选择所有正确答案。'}
onSubmit={(_values: string[], isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent('')
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
),
}
}
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: () => null,
}
}),
[
activeSuccess,
resumeCallback,
successContent,
dragQuizValues,
dragQuizErrors,
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="huangyanpei-internalization-narration"
onClose={() => {
setNarrationVisible(false)
setVisible(true)
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_27.mp3')}
text={`从川沙内史第的少年求学路,到中华职业教育社的拓荒征程,黄炎培先生用一生践行“教育救国”的初心,以“手脑并用”的理念为中国职业教育埋下火种。百年后的今天,这颗火种已长成燎原之势,照亮无数中职学子的成才之路。站在新时代的门槛上,我们回望先生的足迹,不仅是缅怀一段历史,更是要让“职教兴国”的精神,成为当代青年成长的指引。`}
animationSpeed={200}
/>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath('video/huangyanpei/huangyanpei_neihua.mp4')}
className="h-full w-full"
anchors={anchors}
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
setPostGuideVisible(true)
}}
/>
)}
{/* 引导 */}
<GuideSteps
visible={visible}
audioChannelId="huangyanpei-internalization-guide-pre"
onVisibleChange={(visible) => {
setVisible(visible)
if (!visible) {
setShowVideo(true)
handleVideoCanPlay()
}
}}
showMask={false}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_28.mp3')}
>
小沪,刚刚结束的“小职成长记”太有意思了!我变成百年前的川沙少年选课程、做手工、设计国货推销方案,好像真的走进了黄炎培先生的职教世界。
</GuideStepsItem>
<GuideStepsItem speaker="student">
但我还有一个疑问:先生当年倡导的“手脑并用、工学结合”,放在我们现在的中职学习中,到底有什么样的意义呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_29.mp3')}
>
这个问题问到点子上啦!黄炎培先生的职教思想,从来不是尘封的历史,而是活在当下的行动指南。今天我们特别邀请到中华职业教育社第十三届理事会常务理事、中华职业学校党总支副书记、校长黄玉璟,让她带我们解码先生精神的时代密码!
</GuideStepsItem>
</GuideSteps>
{/* 视频结束后的引导 */}
<GuideSteps
visible={postGuideVisible}
audioChannelId="huangyanpei-internalization-guide-post"
onVisibleChange={(visible) => {
setPostGuideVisible(visible)
if (!visible) {
setShowLetter(true)
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_30.mp3')}
>
听完黄校长的讲解,相信你对黄炎培先生的职教精神有了更深的理解。现在,就请你把这份感悟写下来,既总结参观的收获,也给自己未来许下一个承诺吧!
</GuideStepsItem>
</GuideSteps>
{/* 信件组件 */}
<LetterToHuangyanpei
visible={showLetter}
onClose={() => {
setShowLetter(false)
}}
onAnimationEnd={() => {
setShowStudyGuide(true)
}}
showVideo={true}
/>
<EndingOverlay
visible={showStudyGuide}
text="恭喜你圆满完成黄炎培故居的学习之旅,请带着研学感悟,前往聂耳旧居继续探访吧~"
animationSpeed={200}
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(12)
navigate('/menu')
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_31.mp3')}
/>
</div>
)
}
export default HuangyanpeiInternalization
+186
View File
@@ -0,0 +1,186 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
StudyGuideModal,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState } from 'react'
import Cursor from '@/components/cursor'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
// starName 固定为 huangyanpei
const starName = 'huangyanpei'
// 展品 ID 与资源路径映射,ID 用来做唯一标识
const exhibitSrcMap: Record<1, string> = {
1: 'huangyanpei/place1/3_003',
}
const HuangyanpeiPlace1 = () => {
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 [visible, setVisible] = useState(false)
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null)
// 使用持久化的访问状态
const place = 'place1'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useHuangyanpeiStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const handleOpenExhibit = (index: 1 | null) => {
setActiveExhibitId(index)
}
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(`huangyanpei/place1/3_001`),
}}
>
{/* 标题 */}
<Title
title="思想启蒙"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`)
}
}}
/>
{/* 展品1 */}
<Cursor
className="absolute left-[calc(50%-8px)] bottom-100"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place2')
saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/place2`)
}}
/>
)}
{/* 遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(1)
}
}}
onClose={() => {
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}}
/>
{/* 学习引导词 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false)
setVisible(true)
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_02.mp3')}
ailgn="left"
>
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
{'\n'}
2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
{'\n'}
3.所有动态光点的展品全部学习后才能进入下一个场景学习。
</StudyGuideModal>
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible)
if (!nextVisible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true)
setShowAfterDialogGuide(true)
}
}}
showMask={false}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_03.mp3')}
>
小沪,这栋古色古香的江南大院,就是黄炎培先生从小生活、读书的地方吗
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_04.mp3')}
>
没错,1878年10月1日,黄炎培先生就出生在当时江苏省川沙县城关镇(现上海市浦东新区川沙镇)的这处“内史第”,宅子原是他外祖父沈树镛家,也是他后来所说的“生于斯、读于斯、长于斯”之地。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1890年,川沙创办“观澜书院”,12岁的黄炎培以第一名的成绩考入,每天提着母亲亲手缝制的布书包,往返十里路求学,一点都不叫苦。1893年他去上海应县考,松江府复试名列前茅;1899年,在松江府试中更是以第一名的成绩中了秀才。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_01.mp3')}
firstLineIndent="2em"
>
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
此刻你正站在黄炎培先生故居门前,接下来就跟着时光的脉络,拆解黄先生人生里五段滚烫的旅程:思想启蒙、教育实践、创立职教、延安对话、职教传薪。准备好一起走进这段炽热的岁月了吗?
</StudyGuideModal>
</div>
)
}
export default HuangyanpeiPlace1
+170
View File
@@ -0,0 +1,170 @@
import { useState } from 'react'
import { ExhibitOverlay, Title, Image, Cursor, GuideStepsItem, GuideSteps } from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
const starName = 'huangyanpei'
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'huangyanpei/place2/4_004',
2: 'huangyanpei/place2/4_005',
}
const HuangyanpeiPlace2 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 使用持久化的访问状态
const place = 'place2'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useHuangyanpeiStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: { 2: [1] },
visited: visitedExhibits, // 传入数组,而不是 Set
})
// GuideSteps 当前步骤
const [guideStep, setGuideStep] = useState(0)
// GuideSteps 显示状态
const [guideVisible, setGuideVisible] = useState(false)
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 1) {
// 展品1:点击时同时打开展品和引导对话,从第1步开始
setActiveExhibitId(1)
setGuideVisible(true)
setGuideStep(0)
} else {
// 展品2:直接打开
setActiveExhibitId(index)
}
}
// 监听GuideSteps步骤变化,仅同步当前步骤
const handleGuideStepChange = (step: number) => {
setGuideStep(step)
}
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`huangyanpei/place2/4_001`),
}}
>
<Title
title="思想启蒙"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place1`)
}
}}
/>
<Cursor
className="absolute left-132 top-132"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute left-240 top-132"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{completed && (
<Image
className="absolute bottom-0 right-4"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place3')
saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(2)
}
}}
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/huangyanpei/mp3_huangyanpei_05.mp3')}
>
哇,这就是南洋公学!是不是现在上海交通大学的前身呀?黄炎培先生中了秀才之后,就来这里读书了吗?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_06.mp3')}
>
你说得特别对!1901年,黄炎培先生考入南洋公学(今上海交通大学)特班,当时的老师正是蔡元培先生。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
蔡先生一句“中国教育之病,在于学与术分离”,让他瞬间醍醐灌顶,明白了教育不能只谈书本、脱离实际。1902年,黄炎培去南京参加江南乡试,成功中了举人;
</GuideStepsItem>
<GuideStepsItem speaker="guide">
可就在这一年,南洋公学发生了著名的“墨水瓶风潮”,他毅然跟着蔡元培先生退学,转入蔡先生创办的中国教育会,正式开启了自己“教育救国”的实践。
</GuideStepsItem>
</GuideSteps>
</div>
)
}
export default HuangyanpeiPlace2
+521
View File
@@ -0,0 +1,521 @@
import { useState, useEffect, useMemo } from 'react'
import {
ExhibitOverlay,
Title,
Image,
Cursor,
GuideSteps,
GuideStepsItem,
SingleChoiceQuizPanel,
type DragFillResult,
} from '@/components'
import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
const starName = 'huangyanpei'
const place = 'place3'
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: 'huangyanpei/place3/5_005',
2: 'huangyanpei/place3/5_006',
3: 'huangyanpei/place3/5_007',
}
// 常量定义
const FIRST_QUIZ_SUCCESS_CONTENT =
'1903年,黄炎培将"观澜书院"改制为"川沙小学堂",自任校长,率先打破传统经学模式,在课程表中首次列入算术、格致(近代自然科学)、体操和乡土调查四门新学科,并实行"半日授课、半日实习"的教学方式,开创了近代中国小学融入科学、体育与实践教育的新风。'
const DRAG_QUIZ_SUCCESS_CONTENT = '恭喜你,选择正确!'
const DRAG_QUIZ_ERROR_MESSAGE = '艺术不是黄炎培先生在川沙小学堂办学期间强调的重点。请你再想想吧!'
const DRAG_QUIZ_OPTIONS = [
{ value: '读书', label: '读书' },
{ value: '做手工', label: '做手工' },
{ value: '学艺术', label: '学艺术' },
]
const DRAG_QUIZ_BLANKS = [
{ id: 'blank-1', label: '' },
{ id: 'blank-2', label: '' },
]
const CORRECT_ANSWERS = {
blank1: '读书',
blank2: '做手工',
} as const
const HuangyanpeiPlace3 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
// 使用持久化的访问状态
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useHuangyanpeiStore()
// 从 store 获取已访问的展品
const visitedExhibits = getVisitedExhibits(place)
// 使用 useMemo 优化 visited Set,避免每次渲染都重新创建
const visited = useMemo(() => new Set(visitedExhibits), [visitedExhibits])
const { isEnabled } = useCursorController({
initialEnabled: [3],
dependencies: { 2: [3], 1: [2] },
visited: visitedExhibits, // 传入数组,而不是 Set
})
// 光标3的引导对话状态
const [guide3Visible, setGuide3Visible] = useState(false)
const [guide3Step, setGuide3Step] = useState(0)
// 光标3的题目显示状态
const [showQuiz3, setShowQuiz3] = useState(false)
// 拖拽题显示状态
const [showDragQuiz, setShowDragQuiz] = useState(false)
// 拖拽题的值
const [dragQuizValue, setDragQuizValue] = useState<DragFillResult[]>([])
// 拖拽题错误提示
const [dragQuizError, setDragQuizError] = useState<string>('')
// 成功提示状态
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
// 成功提示内容
const [successAlertContent, setSuccessAlertContent] = useState<string>('')
// 光标1、2的引导对话状态(在两者都浏览后显示)
const [guide12Visible, setGuide12Visible] = useState(false)
const [guide12Shown, setGuide12Shown] = useState(false)
// 题目3(开群女学)显示状态
const [showQuiz3_12, setShowQuiz3_12] = useState(false)
// 题目3成功提示状态
const [showQuiz3Success, setShowQuiz3Success] = useState(false)
// 题目4(浦东中学)显示状态
const [showQuiz4, setShowQuiz4] = useState(false)
// 题目4成功提示状态
const [showQuiz4Success, setShowQuiz4Success] = useState(false)
const handleOpenExhibit = (index: 1 | 2 | 3) => {
if (index === 3) {
// 光标3:点击时同时打开展品和引导对话
setActiveExhibitId(3)
setGuide3Visible(true)
setGuide3Step(0)
} else {
// 光标1、2:直接打开
setActiveExhibitId(index)
}
}
// 监听光标3的引导对话步骤变化
const handleGuide3StepChange = (step: number) => {
setGuide3Step(step)
}
// 监听光标3的引导对话关闭
const handleGuide3VisibleChange = (visible: boolean) => {
setGuide3Visible(visible)
}
// 处理光标3题目提交
const handleQuiz3Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setShowQuiz3(false)
setShowSuccessAlert(true)
}
}
// 处理第一题成功提示继续
const handleFirstQuizSuccessContinue = () => {
setShowSuccessAlert(false)
// 显示拖拽题
setShowDragQuiz(true)
}
// 处理拖拽题变化
const handleDragQuizChange = (value: DragFillResult[]) => {
setDragQuizValue(value)
setDragQuizError('')
// 检查是否有错误选项(学艺术)- 立即检查并显示错误
const hasInvalidOption = value.some((v) => v.optionValue === '学艺术')
if (hasInvalidOption) {
setDragQuizError(DRAG_QUIZ_ERROR_MESSAGE)
return
}
// 检查是否所有空白都已填充
if (value.length < 2) return
// 验证答案:第一个空白应该是"读书",第二个空白应该是"做手工"
const blank1 = value.find((v) => v.blankId === 'blank-1')
const blank2 = value.find((v) => v.blankId === 'blank-2')
// 验证正确答案
const isCorrect =
blank1?.optionValue === CORRECT_ANSWERS.blank1 &&
blank2?.optionValue === CORRECT_ANSWERS.blank2
if (isCorrect) {
// 答题成功
setShowDragQuiz(false)
setSuccessAlertContent(DRAG_QUIZ_SUCCESS_CONTENT)
setShowSuccessAlert(true)
} else {
// 答题失败
setDragQuizError('请再想想,确保选择正确的答案。')
}
}
// 处理拖拽题成功提示继续
const handleDragQuizSuccessContinue = () => {
setShowSuccessAlert(false)
setSuccessAlertContent('')
setDragQuizValue([])
setDragQuizError('')
// 标记展品已访问
markExhibitVisited(place, 3)
}
// 监听visited变化,当光标1、2都浏览后显示引导对话
useEffect(() => {
if (visited.has(1) && visited.has(2) && !guide12Shown) {
setGuide12Shown(true)
setGuide12Visible(true)
}
}, [visited, guide12Shown])
// 处理guide12Visible变化,对话结束后显示题目3
const handleGuide12VisibleChange = (visible: boolean) => {
setGuide12Visible(visible)
// 当对话关闭时,显示题目3
if (!visible && guide12Shown) {
setShowQuiz3_12(true)
}
}
// 处理题目3提交
const handleQuiz3_12Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setShowQuiz3_12(false)
setShowQuiz3Success(true)
}
}
// 处理题目3成功提示继续
const handleQuiz3SuccessContinue = () => {
setShowQuiz3Success(false)
// 显示题目4
setShowQuiz4(true)
}
// 处理题目4提交
const handleQuiz4Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setShowQuiz4(false)
setShowQuiz4Success(true)
}
}
// 处理题目4成功提示继续
const handleQuiz4SuccessContinue = () => {
setShowQuiz4Success(false)
// 标记展品1和2为已访问,触发继续前进按钮显示
if (!visited.has(1)) {
markExhibitVisited(place, 1)
}
if (!visited.has(2)) {
markExhibitVisited(place, 2)
}
}
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(`huangyanpei/place3/5_001`),
}}
>
<Title
title="教育实践"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place2`)
}
}}
/>
{/* 光标1 */}
<Cursor
className="absolute left-32 top-105"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute left-172 top-90"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute right-92 top-108"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{completed && (
<Image
className="absolute bottom-0 right-4"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place4')
saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/place4`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
const animationMap: Record<1 | 2 | 3, number> = {
3: 3,
2: 4,
1: 5,
}
if (activeExhibitId) {
triggerPuzzleAnimation(animationMap[activeExhibitId])
}
}}
onClose={() => {
if (activeExhibitId !== null) {
setActiveExhibitId(null)
if (activeExhibitId === 3) {
setShowQuiz3(true)
return
}
markExhibitVisited(place, activeExhibitId)
} else {
setActiveExhibitId(null)
}
}}
/>
{/* 光标3的引导对话 */}
<GuideSteps
visible={guide3Visible}
onVisibleChange={handleGuide3VisibleChange}
currentStep={guide3Step}
onStepChange={handleGuide3StepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_07.mp3')}
>
这所川沙小学堂就是黄炎培先生“教育救国”的第一个试验田吧?我们现在学习的课程有专业课、实习实训课都提倡技能培养,当年他是不是也很注重动手能力呀?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_08.mp3')}
>
太有共鸣啦!1903年,24岁的黄炎培先生和好友张志鹤等人一起,把原来的“观澜书院”改成了“川沙小学堂”,他自己当校长。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这所学校的课程表可太有突破性了——破天荒出现了算术、格致(也就是咱们现在说的自然科学)、体操、乡土调查这些新学科,还实行“半天读书、半天做手工”的模式,特别注重实践。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
当时办学经费特别紧张,没钱买课本,他就带头刻蜡版油印;缺体育器材,就带着学生用毛竹做单杠,完全是凭着一腔热血办学呢!这也是中国最早的“半日授课、半日实习”雏形。
</GuideStepsItem>
</GuideSteps>
{/* 光标3的题目 */}
<SingleChoiceQuizPanel
visible={showQuiz3}
title='【单选】黄炎培1903年把川沙"观澜书院"改成"川沙小学堂"后,课程表首次出现下列哪组学科?'
options={[
{ value: 'A', label: 'A. 经学、策论、八股文' },
{
value: 'B',
label: 'B. 算术、格致、体操、乡土调查',
},
{ value: 'C', label: 'C. 外语、逻辑、演讲、社交' },
{ value: 'D', label: 'D. 绘画、声乐、舞蹈、戏剧' },
]}
correctAnswer="B"
errorMessage='1903年,黄炎培将"观澜书院"改制为"川沙小学堂",自任校长,率先打破传统经学模式,在课程表中首次列入算术、格致(近代自然科学)、体操和乡土调查四门新学科,并实行"半日授课、半日实习"的教学方式,开创了近代中国小学融入科学、体育与实践教育的新风。'
onWrongSelect={(value) => {
switch (value) {
case 'A':
return '经学、策论、八股文是传统书院的旧式课程,而川沙小学堂正是要打破这种传统模式。请你再想想吧!'
case 'C':
return '外语、逻辑、演讲、社交这些科目虽然也很重要,但不是川沙小学堂首次引入的突破性学科。请你再想想吧!'
case 'D':
return '绘画、声乐、舞蹈、戏剧属于艺术类课程,不是川沙小学堂在1903年首次开设的突破性学科。请你再想想吧!'
default:
return '这不是川沙小学堂开设的具有突破性的学科。请你再想想吧!'
}
}}
onSubmit={handleQuiz3Submit}
/>
{/* 第一题成功提示 */}
<SuccessAlert
visible={showSuccessAlert && !showDragQuiz && !successAlertContent}
content={FIRST_QUIZ_SUCCESS_CONTENT}
onContinue={handleFirstQuizSuccessContinue}
onClose={handleFirstQuizSuccessContinue}
/>
{/* 拖拽题 */}
<DragQuizPanel
visible={showDragQuiz}
zIndex={10000}
title=""
questionPrefix='【拖拽】黄炎培在"川沙小学堂"实行"半天'
questionSuffix="的模式,特别注重实践。"
options={DRAG_QUIZ_OPTIONS}
blanks={DRAG_QUIZ_BLANKS}
blankSeparators={['、']}
value={dragQuizValue}
error={dragQuizError}
onChange={handleDragQuizChange}
/>
{/* 拖拽题成功提示 */}
<SuccessAlert
visible={showSuccessAlert && !showDragQuiz && !!successAlertContent}
// content={successAlertContent}
onContinue={handleDragQuizSuccessContinue}
onClose={handleDragQuizSuccessContinue}
/>
{/* 题目3(开群女学) */}
<SingleChoiceQuizPanel
visible={showQuiz3_12}
title="【单选】黄炎培和堂兄黄洪培、堂嫂陆开群一起,策划创建的开创了浦东地区女子教育的先河的学校为:"
options={[
{ value: 'A', label: 'A. 开群女学' },
{ value: 'B', label: 'B. 上海女子师范学校' },
{ value: 'C', label: 'C. 浦东中学' },
]}
correctAnswer="A"
onWrongSelect={(value) => {
switch (value) {
case 'B':
return '上海女子师范学校是上海市区的女子教育机构,并非由黄炎培等人在浦东地区创办。请你再想想吧!'
case 'C':
return '浦东中学是一所面向男女学生的综合性中学,并非专门的女子学校。请你再想想吧!'
default:
return '请你再想想吧!'
}
}}
onSubmit={handleQuiz3_12Submit}
/>
{/* 题目3成功提示 */}
<SuccessAlert
visible={showQuiz3Success}
onContinue={handleQuiz3SuccessContinue}
onClose={handleQuiz3SuccessContinue}
/>
{/* 题目4(浦东中学) */}
<SingleChoiceQuizPanel
visible={showQuiz4}
title="【单选】由黄炎培担任首任校长的浦东中学是:"
options={[
{ value: 'A', label: 'A. 近代上海第一所女子中学' },
{ value: 'B', label: 'B. 近代上海第一所职业技术学校' },
{ value: 'C', label: 'C. 近代上海第一所完全中学' },
]}
correctAnswer="C"
onWrongSelect={(value) => {
switch (value) {
case 'A':
return '浦东中学是一所男女同校的综合性中学,并非女子中学。请你再想想吧!'
case 'B':
return '浦东中学以普通中学教育为主,其定位是培养全面发展的人才,并非职业技术学校。请你再想想吧!'
default:
return '请你再想想吧!'
}
}}
onSubmit={handleQuiz4Submit}
/>
{/* 题目4成功提示 */}
<SuccessAlert
visible={showQuiz4Success}
onContinue={handleQuiz4SuccessContinue}
onClose={handleQuiz4SuccessContinue}
/>
{/* 光标1、2的引导对话 */}
<GuideSteps
visible={guide12Visible}
onVisibleChange={handleGuide12VisibleChange}
showMask={false}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_09.mp3')}
>
原来当年还有专门的女子学校呀?在那个年代,女孩子能上学是不是特别不容易?后来的浦东中学也是黄炎培先生办的吗?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_10.mp3')}
>
你观察得特别仔细!川沙小学堂开学没多久,黄炎培就和堂兄黄洪培、堂嫂陆开群一起,策划创建了“开群女学”——这所学校专门面向社会招收女学生,还用全力支持办学、腾出自家住房的陆开群名字命名。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这所学校直接开创了浦东地区女子教育的先河,让很多女孩子有了读书的机会。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
到了1906年,上海营造业宗师杨斯盛捐资30万两白银,在浦东六里桥购地40多亩创办浦东中学,特意邀请黄炎培担任首任校长。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
校舍是黄炎培亲自设计的,杨斯盛先生亲自监工,建成后有能容纳千人的大礼堂和两座教学楼,是近代上海第一所完全中学呢!
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1912年,黄炎培担任江苏省署教育司司长,任职期间一直倡导实用主义教育,强调教育要和生产劳动结合起来,这也为后来的职业教育打下了坚实基础。
</GuideStepsItem>
</GuideSteps>
</div>
)
}
export default HuangyanpeiPlace3
+357
View File
@@ -0,0 +1,357 @@
import { useState, useEffect, useRef } from 'react'
import {
ExhibitOverlay,
Title,
Image,
Cursor,
GuideSteps,
GuideStepsItem,
SingleChoiceQuizPanel,
Video,
} from '@/components'
import { SuccessAlert } from '@/components/layout'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import type { VideoRef } from '@/components/video'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
const starName = 'huangyanpei'
const exhibitSrcMap: Record<1 | 2 | 3 | 4 | 5, string> = {
1: 'huangyanpei/place4/6_6&7',
2: 'huangyanpei/place4/6_007',
3: 'huangyanpei/place4/6_008',
4: 'huangyanpei/place4/6_009',
5: 'huangyanpei/place4/6_010',
}
const HuangyanpeiPlace4 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 3 | 4 | 5 | null>(null)
// 使用持久化的访问状态
const place = 'place4'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useHuangyanpeiStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const { isEnabled } = useCursorController({
initialEnabled: [3, 5],
dependencies: {
1: [3, 5],
4: [1],
// 2 已注释,不计入顺序
},
visited: visitedExhibits, // 传入数组,而不是 Set
})
// 光标1的引导对话状态
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
// 光标3、4的引导对话状态(在两者都浏览后显示)
const [guide34Visible, setGuide34Visible] = useState(false)
const [guide34Shown, setGuide34Shown] = useState(false)
// 光标5的引导对话状态
const [guide5Visible, setGuide5Visible] = useState(false)
const [guide5Step, setGuide5Step] = useState(0)
// 视频显示状态
const [showVideo, setShowVideo] = useState(false)
// 题目显示状态
const [showQuiz, setShowQuiz] = useState(false)
// 成功提示状态
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
const videoRef = useRef<VideoRef>(null)
const handleOpenExhibit = (index: 1 | 3 | 4 | 5) => {
setActiveExhibitId(index)
if (index === 1) {
setGuide1Step(0)
setGuide1Visible(true)
}
if (index === 4) {
setGuide5Step(0)
setGuide5Visible(true)
}
}
// 监听visited变化,当光标3、4都浏览后显示引导对话
useEffect(() => {
if (visited.has(3) && visited.has(5) && !guide34Shown) {
setGuide34Shown(true)
setGuide34Visible(true)
}
}, [visited, guide34Shown])
// 处理题目提交
const handleQuizSubmit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setShowQuiz(false)
setShowSuccessAlert(true)
}
}
const progressCount = visited.size
const total = 4
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`huangyanpei/place4/6_001`),
}}
>
<Title
title="创立职教"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place3`)
}
}}
/>
{/* 光标1 */}
<Cursor
className="absolute left-42 top-102"
onClick={() => handleOpenExhibit(1)}
size={1}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
{/* <Cursor
className="absolute left-40 top-132"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
/> */}
{/* 光标3 */}
<Cursor
className="absolute left-125 top-100"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 光标4 */}
<Cursor
className="absolute right-62 top-102"
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
disabled={!isEnabled(4)}
/>
{/* 光标5 */}
<Cursor
className="absolute right-205 top-110"
onClick={() => handleOpenExhibit(5)}
read={visited.has(5)}
disabled={!isEnabled(5)}
/>
{completed && (
<Image
className="absolute bottom-0 right-4"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place5')
saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/place5`)
}}
/>
)}
{/* 展品1、2、4出现描述 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
// description={
// activeExhibitId === 1 ||
// activeExhibitId === 2 ||
// activeExhibitId === 4
// ? "黄炎培先生一直怀揣着“教育救国”的理想,职业教育从来都和国家命运紧紧连在一起。1932年“一・二八”事变爆发,黄炎培立刻组织了抗日救国研究会,一边支援前线输送军需,一边维持地方秩序、稳定金融市场,还创办了“一・二八”残废院,专门收容受伤战士和无家可归的难民。“八・一三”抗战爆发后,他担任上海抗战后援会主席,组织民众设难民所、医疗队和后方医院,还成立了青年战地服务团,冒着枪林弹雨为前线军队服务。他自己不顾日机轰炸的危险,九次往返南京与上海之间,协助政府募集救国公债。正是这样的全民抗敌,粉碎了日军“一个月灭亡中国”的狂妄企图。这期间,1935年中华职业教育社还与荣氏三兄弟合作,在申新三厂办理劳动工自治区,对工人进行文化教育。"
// : undefined
// }
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 3) {
triggerPuzzleAnimation(7)
}
if (activeExhibitId === 5) {
triggerPuzzleAnimation(6)
}
if (activeExhibitId === 1) {
triggerPuzzleAnimation(8)
}
if (activeExhibitId === 4) {
triggerPuzzleAnimation(10)
}
}}
onClose={() => {
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}}
/>
{/* 光标1的对话 */}
<GuideSteps
visible={guide1Visible}
currentStep={guide1Step}
onStepChange={setGuide1Step}
onVisibleChange={(visible) => {
if (!visible) {
triggerPuzzleAnimation(9)
}
setGuide1Visible(visible)
}}
showMask={false}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_13.mp3')}
>
真没想到中华职校还组织过义勇军!我们现在也会参加国防教育、志愿服务,当年的职业教育不仅教技能,还这么注重爱国主义教育呀?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_14.mp3')}
>
没错!黄炎培先生一直怀揣着“教育救国”的理想,职业教育从来都和国家命运紧紧连在一起。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1932年“一・二八”事变爆发,黄炎培立刻组织了抗日救国研究会,一边支援前线输送军需,一边维持地方秩序、稳定金融市场,还创办了“一・二八”残废院,专门收容受伤战士和无家可归的难民。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
“八・一三”抗战爆发后,他担任上海抗战后援会主席,组织民众设难民所、医疗队和后方医院,还成立了青年战地服务团,冒着枪林弹雨为前线军队服务。他自己不顾日机轰炸的危险,九次往返南京与上海之间,协助政府募集救国公债。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
正是这样的全民抗敌,粉碎了日军“一个月灭亡中国”的狂妄企图。
</GuideStepsItem>
</GuideSteps>
{/* 光标3、5的引导对话 */}
<GuideSteps
visible={guide34Visible}
onVisibleChange={(visible) => {
setGuide34Visible(visible)
// 对话结束后播放视频
if (!visible) {
setShowVideo(true)
}
}}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_11.mp3')}
>
职业教育这个词是黄炎培先生创造的吗?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_12.mp3')}
>
这个词不是他创造的,但体系是他建立的。1917年5月6日,黄炎培联合蔡元培、张謇等48位有识之士,在上海成立了“中华职业教育社”,并发表了《中华职业教育社宣言》。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1918年,他在上海陆家浜创办中华职业学校,开设铁工、木工、珐琅、纽扣四科,招生广告明确写着“免学费、包分配”。首批招收78人,还推出了响亮的口号“双手万能、手脑并用”。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这所学校特别厉害,教室和车间连在一起,学生可以一边学理论,一边练技能。学校铁工厂制作的台钳,后来还成了“国货精品”,甚至在巴拿马万国博览会上拿过金奖呢!
</GuideStepsItem>
</GuideSteps>
{/* 光标4的对话 */}
<GuideSteps
visible={guide5Visible}
currentStep={guide5Step}
onStepChange={setGuide5Step}
onVisibleChange={(visible) => {
setGuide5Visible(visible)
}}
showMask={false}
zIndex={10000}
>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_15.mp3')}
>
这期间,1935年中华职业教育社还与荣氏三兄弟合作,在申新三厂办理劳动工自治区,对工人进行文化教育。
</GuideStepsItem>
</GuideSteps>
{/* 视频播放 */}
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath('video/huangyanpei/huangyanpei_05.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo(false)
setShowQuiz(true)
}}
/>
)}
{/* 题目 */}
<SingleChoiceQuizPanel
visible={showQuiz}
title={`【单选】中华职业学校首批招生78人,凭借(\xa0\xa0\xa0\xa0)口号和“免学费、包分配”的优待,开创了中国近代职业教育的先河。`}
options={[
{ value: 'A', label: 'A. 学而优则仕' },
{ value: 'B', label: 'B. 双手万能、手脑并用' },
{ value: 'C', label: 'C. 实业救国、教育兴国' },
{ value: 'D', label: 'D. 民主科学、救国救民' },
]}
correctAnswer="B"
errorMessage="1918年黄炎培在上海创办中华职业学校,首批招78人,以“免学费、包分配”的实惠和“双手万能、手脑并用”的响亮口号,打破“学而优则仕”的传统观念,设铁工、木工、珐琅、纽扣科,把课堂与车间合一,开创了中国近代职业教育的先河。"
onSubmit={handleQuizSubmit}
zIndex={10001}
/>
{/* 成功提示 */}
<SuccessAlert
visible={showSuccessAlert}
onContinue={() => {
setShowSuccessAlert(false)
}}
onClose={() => {
setShowSuccessAlert(false)
}}
/>
</div>
)
}
export default HuangyanpeiPlace4
+348
View File
@@ -0,0 +1,348 @@
import { useState, useRef } from 'react'
import {
ExhibitOverlay,
Title,
Image,
Cursor,
Video,
GuideSteps,
GuideStepsItem,
SingleChoiceQuizPanel,
} from '@/components'
import { SuccessAlert } from '@/components/layout'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import type { VideoRef } from '@/components/video'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
const starName = 'huangyanpei'
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'huangyanpei/place5/7_004',
2: 'huangyanpei/place5/场景5展品2',
}
const HuangyanpeiPlace5 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
const [videoVisible, setVideoVisible] = useState(false)
const videoRef = useRef<VideoRef>(null)
// 光标1的引导对话状态
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
// 展品2视频后的对话引导状态
const [guideVisible, setGuideVisible] = useState(false)
const [guideStep, setGuideStep] = useState(0)
// 题目1(光标1对话后)显示状态
const [showQuiz1, setShowQuiz1] = useState(false)
// 题目1成功提示状态
const [showQuiz1Success, setShowQuiz1Success] = useState(false)
// 题目显示状态(展品2视频后)
const [showQuiz, setShowQuiz] = useState(false)
// 成功提示状态
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
// 使用持久化的访问状态
const place = 'place5'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useHuangyanpeiStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
// 光标顺序:1 -> 2(需完成1后解锁2)
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: { 2: [1] },
visited: visitedExhibits, // 传入数组,而不是 Set
})
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 2) {
// 展品2:点击时显示对话,再显示视频
setGuideStep(0)
setGuideVisible(true)
} else {
// 展品1:正常展示展品
setGuide1Step(0)
setGuide1Visible(true)
setActiveExhibitId(index)
}
}
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`huangyanpei/place5/7_001`),
}}
>
<img
src={getStaticImage('huangyanpei/place5/7_003')}
alt=""
className="absolute top-72 right-37"
/>
<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-35 top-108"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute right-112 top-108"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{completed && (
<Image
className="absolute bottom-0 right-4"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place6')
saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/place6`)
}}
/>
)}
{/* 对话后显示展品2视频播放(全屏) */}
{videoVisible && (
<div className="fixed inset-0 z-9999 bg-black">
<Video
ref={videoRef}
src={getPublicPath('video/huangyanpei/huangyanpei_07.mp4')}
className="w-full h-full"
controls
autoPlay
playsInline
preload="auto"
showSkipButton
skipText="跳过"
onEnded={() => {
// 对话结束后显示题目
setShowQuiz(true)
}}
/>
</div>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
// description={
// activeExhibitId === 1
// ? '1940年,国民党政府还坚持"攘外必先安内"的政策,让国共两党的关系越来越紧张。黄炎培等有识之士看到这种严峻形势,觉得中间派应该联合起来,为国家寻求一条和平发展的道路。于是他联系了张君励、梁漱溟等爱国同仁,后来又联合了第三党和无党派人士,准备成立统一的同盟。刚好这时发生了震惊中外的"皖南事变",国共矛盾进一步激化,黄炎培心里特别着急,就加紧了筹备工作。1941年3月19日,中国民主政团同盟正式成立,参加成立会的有13人,选出了5位常委,黄炎培当选为第一任主席。1945年,他还创立了民主建国会并担任第一任主委,带领大家积极投入"反内战、保和平"的民主斗争,为国家的和平稳定奔走呼号。'
// : undefined
// }
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(11)
}
}}
onClose={() => {
if (activeExhibitId !== null) {
// 如果是展品1关闭,显示题目1
if (activeExhibitId === 1) {
setShowQuiz1(true)
}
// 注意:展品1的已访问标记在题目1成功后处理,这里不标记
// 展品2正常标记
if (activeExhibitId === 2) {
markExhibitVisited(place, activeExhibitId)
}
}
setActiveExhibitId(null)
}}
/>
{/* 光标1的引导对话 */}
<GuideSteps
visible={guide1Visible}
currentStep={guide1Step}
onStepChange={setGuide1Step}
onVisibleChange={setGuide1Visible}
showMask={false}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_16.mp3')}
>
抗战胜利后,黄炎培先生除了继续推进职业教育,还参与了政党活动呀?他创建民主政团同盟,是想为国家做些什么呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_17.mp3')}
>
这和当时的历史背景分不开。1940年,国民党政府还坚持“攘外必先安内”的政策,让国共两党的关系越来越紧张。黄炎培等有识之士看到这种严峻形势,觉得中间派应该联合起来,为国家寻求一条和平发展的道路。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
于是他联系了张君劢、梁漱溟等爱国同仁,后来又联合了第三党和无党派人士,准备成立统一的同盟。刚好这时发生了震惊中外的“皖南事变”,国共矛盾进一步激化,黄炎培心里特别着急,就加紧了筹备工作。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1941年3月19日,中国民主政团同盟正式成立,参加成立会的有13人,选出了5位常委,黄炎培当选为第一任主席。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1945年,他还创立了民主建国会并担任第一任主委,带领大家积极投入“反内战、保和平”的民主斗争,为国家的和平稳定奔走呼号。
</GuideStepsItem>
</GuideSteps>
{/* 题目1(光标1对话后) */}
<SingleChoiceQuizPanel
visible={showQuiz1}
title="【单选】黄炎培先生创建民主政团同盟等组织是为了:"
options={[
{ value: 'A', label: 'A. 为国家寻求一条和平发展的道路' },
{ value: 'B', label: 'B. 支持国民党"攘外必先安内"的政策' },
{ value: 'C', label: 'C. 扩大个人政治影响力,争夺权力' },
]}
correctAnswer="A"
onWrongSelect={(value) => {
switch (value) {
case 'B':
return '国民党政府坚持"攘外必先安内"的政策导致国共关系紧张,黄炎培正是因为反对这种政策、希望推动和平,才发起组建了民主政团同盟。请你再想想吧!'
case 'C':
return '黄炎培创建民主政团同盟的出发点是民族大义与国家前途,并非为了个人政治利益。请你再想想吧!'
default:
return '请你再想想吧!'
}
}}
onSubmit={(value, isCorrect) => {
if (isCorrect) {
setShowQuiz1(false)
setShowQuiz1Success(true)
}
}}
zIndex={10001}
/>
{/* 题目1成功提示 */}
<SuccessAlert
visible={showQuiz1Success}
onContinue={() => {
setShowQuiz1Success(false)
// 标记展品1为已访问,修改cursor状态
markExhibitVisited(place, 1)
}}
onClose={() => {
setShowQuiz1Success(false)
// 标记展品1为已访问,修改cursor状态
markExhibitVisited(place, 1)
}}
/>
{/* 光标2的引导对话 */}
<GuideSteps
visible={guideVisible}
currentStep={guideStep}
onStepChange={setGuideStep}
onVisibleChange={(visible) => {
setGuideVisible(visible)
// 如果对话结束,显示视频
if (!visible) {
setVideoVisible(true)
}
}}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_18.mp3')}
>
哇!这就是历史课上学的“窑洞对”!黄炎培先生和毛主席的这次谈话,是不是谈到了“历史周期率”呀?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_19.mp3')}
>
完全正确!1945年7月1日,黄炎培以国民参政员的身份,带着考察团一行六人赴延安考察,中共中央领导人毛泽东、朱德、周恩来等都亲自到机场迎接。7月4日,他和毛泽东主席长谈了十几个小时,这场对话能和诸葛亮的“隆中对”相媲美。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
谈话中,黄炎培提出了一个深刻的问题:“其兴也勃焉,其亡也忽焉”,也就是历代王朝逃不开的“历史周期率”。毛泽东主席当场回答:“我们已经找到新路,我们能跳出这周期律,这条新路就是民主。”
</GuideStepsItem>
<GuideStepsItem speaker="guide">
后来,黄炎培把这段对话写进了《延安归来》这本书,一夜之间就畅销十万册,成为当时最热门的书籍,而“实行民主”也被大家称为破解历史周期率的“初版答案”。
</GuideStepsItem>
</GuideSteps>
{/* 题目 */}
<SingleChoiceQuizPanel
visible={showQuiz}
title={`【单选】《延安归来》中"破解历史周期率"的"初版答案"一夜畅销十万册,其提出的核心对策是:`}
options={[
{ value: 'A', label: 'A. 实行民主' },
{ value: 'B', label: 'B. 发展经济' },
{ value: 'C', label: 'C. 加强党建' },
{ value: 'D', label: 'D. 武装斗争' },
]}
correctAnswer="A"
errorMessage="1945年7月,黄炎培在延安向毛泽东提出“其兴也勃焉,其亡也忽焉”的历史周期率之问。毛泽东回答:“我们已经找到新路,我们能跳出这周期律,这条新路就是民主。”黄炎培将这一对话写入《延安归来》,首次公开提出以“民主”破解历代王朝兴衰循环的命题。该书一夜之间畅销十万册,使“实行民主”成为当时社会各界瞩目的“初版答案”。"
onSubmit={(value, isCorrect) => {
if (isCorrect) {
setVideoVisible(false)
setShowQuiz(false)
setShowSuccessAlert(true)
}
}}
zIndex={10001}
/>
{/* 成功提示 */}
<SuccessAlert
visible={showSuccessAlert}
onContinue={() => {
setShowSuccessAlert(false)
markExhibitVisited(place, 2)
triggerPuzzleAnimation(12)
}}
onClose={() => {
setShowSuccessAlert(false)
}}
/>
</div>
)
}
export default HuangyanpeiPlace5
+174
View File
@@ -0,0 +1,174 @@
import { useState } 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 { EndingOverlay } from '@/components/overlay/EndingOverlay'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'huangyanpei'
const exhibitSrcMap: Record<1, string> = {
1: 'huangyanpei/place6/8_003',
}
const HuangyanpeiPlace6 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null)
// 对话引导状态
const [guideVisible, setGuideVisible] = useState(false)
// 结束窗口状态
const [endingVisible, setEndingVisible] = useState(false)
// 是否已经显示过结束窗口(防止重复弹出)
const [hasShownEnding, setHasShownEnding] = useState(false)
// 使用持久化的访问状态
const place = 'place6'
const { markExhibitVisited, getVisitedExhibits } = useHuangyanpeiStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const [Leave, setLeave] = useState(false)
const handleOpenExhibit = (index: 1) => {
// 同时打开展品和对话
setActiveExhibitId(index)
setGuideVisible(true)
}
// 当 cursor 已浏览后,显示结束窗口(只显示一次)
// useEffect(() => {
// if (visited.has(1) && !hasShownEnding && !endingVisible) {
// // 延迟一下显示,确保展品关闭动画完成
// const timer = setTimeout(() => {
// }, 500);
// return () => clearTimeout(timer);
// }
// }, [visited, hasShownEnding, endingVisible]);
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(`huangyanpei/place6/8_001`),
}}
>
<Title
title="职教传薪"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place5`)
}
}}
/>
<Cursor
className="absolute right-27 top-110"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
/>
{Leave && (
<Image
className="absolute bottom-0 right-4"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/home`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(13)
}
}}
onClose={() => {
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
setEndingVisible(true)
setHasShownEnding(true)
}}
/>
{/* 对话引导 */}
<GuideSteps
visible={guideVisible}
onVisibleChange={setGuideVisible}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_20.mp3')}
>
新中国成立后,黄炎培先生还继续从事教育工作吗?我们现在的中职教育体系,是不是也有他当年的理念呀?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_21.mp3')}
>
当然啦!1949年10月19日,71岁的黄炎培出任政务院副总理兼轻工业部部长,依然没有放下他毕生追求的职业教育事业。他把中华职教社的骨干力量全部调到北京,亲自起草《关于改革学制的决定》。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
第一次把“中等专业学校”正式纳入国家学制,让职业教育有了明确的国家定位。1952年,他又推动建立了上海机械学校、南京航空学校等五所中专,这些学校为国家"一五"计划输送了6.3万名技术工人,为工业发展提供了坚实的人才支撑。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
黄炎培先生曾说过:“我要把最后一滴血,滴在职业教育的讲台上。”他用一生践行了这句话,而我们现在中职教育强调的“工学结合、德技并修”,正是对他“手脑并用、注重实践”理念的传承和发展。
</GuideStepsItem>
</GuideSteps>
{/* 结束窗口 */}
<EndingOverlay
visible={endingVisible}
text={`从川沙“内史第”的求学少年,到中华职教社的拓荒者,再到新中国职业教育的奠基人,黄炎培先生用一生诠释了“教育救国”的初心。他曾说,教育不是把篮子装满,而是把灯点亮。想知道百年前的少年们,是怎样从懵懂学子,一步步成长为手握技能、心怀家国的青年吗?接下来,请你化身成为一名川沙少年,踏入1903年刚改制的川沙小学堂,跟着黄先生的脚步一路学习,一路成长,亲身体验那段热血又鲜活的青年成长之路吧!`}
onContinue={() => {
setEndingVisible(false)
// 标记为已显示过,防止再次弹出
setHasShownEnding(true)
setLeave(true)
setCourseTimeProgress(10)
saveHuangyanpeiStore()
}}
animationSpeed={200}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_22.mp3')}
/>
</div>
)
}
export default HuangyanpeiPlace6
@@ -0,0 +1,215 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { cn, getStaticImage } from '@/lib/utils'
import { Image } from '@/components'
import { SuccessAlert } from '@/components/layout'
export interface QuizDialogProps {
/** 是否可见 */
visible?: boolean
/** 关闭回调 */
onClose?: () => void
/** 完成回调 */
onComplete?: () => void
}
/**
* 喇叭考题弹窗
*/
const QuizDialog = ({ visible = true, onClose, onComplete }: QuizDialogProps) => {
const maskSrc = getStaticImage('components/蒙版遮罩')
const backSrc = getStaticImage('components/关闭')
const [mounted, setMounted] = useState(false)
const [successVisible, setSuccessVisible] = useState(false)
// SSR 兼容
useEffect(() => {
setMounted(true)
}, [])
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation()
onClose?.()
},
[onClose]
)
// 关闭成功提示
const handleSuccessContinue = useCallback(() => {
setSuccessVisible(false)
// 关闭提示后,完成并关闭弹窗
onComplete?.()
onClose?.()
}, [onComplete, onClose])
// 可选话术选项
const options = useMemo(
() => [
{
value: 'option1',
label: '加入义勇军(军事训练)',
},
{
value: 'option2',
label: '制作军需物资',
},
{
value: 'option3',
label: '宣传抗日募捐',
},
],
[]
)
// 样式
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
position: 'fixed',
inset: 0,
zIndex: 9999,
pointerEvents: 'none',
}),
[]
)
if (!mounted) return null
const content = (
<AnimatePresence>
{visible && (
<motion.div
key="towel-sales-dialog"
style={overlayStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<div
className={cn('absolute inset-0 flex flex-col', 'bg-black/70')}
style={{ pointerEvents: 'auto' }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex flex-col px-30 py-16 pt-30 text-[#432800]">
<div className="w-full h-full flex flex-col">
{/* 顶部:题目文本和毛巾区域 */}
<div className="flex justify-between gap-10 mb-8">
{/* 题目文本 */}
<div className="flex-1">
<div className="text-5xl font-bold mb-6 -ml-[26px]">【单选】</div>
<div className="text-4xl leading-relaxed">
国难当头,黄先生说“职教救国”,中华职校的师生都行动起来了!你要加入哪支爱国队伍?
</div>
</div>
{/* 右侧:毛巾区域 */}
<div className="w-180 flex flex-col items-center">
<Image src={getStaticImage('huangyanpei/reflection/components/题目图')} />
</div>
</div>
{/* 底部:可选话术区域 - 使用 DragFill */}
<div className="flex-1">
<div className="text-5xl font-bold mb-6">答题区</div>
<div className="grid grid-cols-2 gap-10">
{options.map((option, index) => (
<div
className={cn(
'cursor-pointer px-10 py-10 rounded-2xl border-3 text-3xl font-medium transition-all select-none',
'border-[#79471f] bg-black/20 text-white',
'hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)]'
)}
key={option.value}
onClick={() => {
setSuccessVisible(true)
}}
>
{index + 1}. {option.label}
</div>
))}
</div>
{/* <DragFill
options={options}
blanks={blanks}
value={value}
onChange={handleValueChange}
allowDuplicate={false}
layout="horizontal-options"
optionClassName="grid grid-cols-2 gap-10"
hideLabels={true}
showSubmitButton={false}
className="w-full"
renderOption={(option, isUsed) => {
const index = options.findIndex(
(opt) => opt.value === option.value
);
return (
<div
className={cn(
"px-10 py-10 rounded-2xl border-3 text-3xl font-medium transition-all select-none",
isUsed
? "border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50"
: "border-[#79471f] bg-black/20 text-white cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)]"
)}
>
{index + 1}. {option.label}
</div>
);
}}
renderDragging={(option) => {
return (
<div className="w-[638px] h-[90px] flex items-center justify-center rounded-2xl border-3 text-2xl font-medium border-[#f0be75] bg-black/40 text-white shadow-[0_12px_40px_rgba(240,190,117,0.7)]">
{option.label}
</div>
);
}}
renderBlank={() => {
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
return <div className="hidden" />;
}}
/> */}
</div>
</div>
</div>
{/* 成功提示 */}
<SuccessAlert
content="好样的!不管是上前线还是做后援,都是在用学到的本事报效国家,黄先生一定会为你骄傲!"
visible={successVisible}
onContinue={handleSuccessContinue}
/>
</div>
</motion.div>
)}
</AnimatePresence>
)
return content
}
export default QuizDialog
@@ -0,0 +1,488 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { createPortal } from 'react-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { cn, getStaticImage } from '@/lib/utils'
import { Image, DragFill, StudyGuideModal } from '@/components'
import type { DragFillResult } from '@/components'
import { useScale } from '@/contexts/ScaleContext'
export interface BambooPuzzleGameProps {
/** 是否可见 */
visible?: boolean
/** 关闭回调 */
onClose?: () => void
/** 完成回调 */
onComplete?: () => void
}
type PieceId = 'A' | 'B' | 'C' | 'D' | 'E' | 'F'
type ContainerType = 'horizontal' | 'leftPillar' | 'rightPillar'
interface Piece {
id: PieceId
src: string
correctContainer: ContainerType
}
// 根据效果图:A和B是立柱(可以放在左右任意一边),C是横杆(必须放在横杆容器)
const pieces: Piece[] = [
{
id: 'A',
src: 'huangyanpei/reflection/scene1/9_008',
correctContainer: 'leftPillar',
},
{
id: 'B',
src: 'huangyanpei/reflection/scene1/9_0013',
correctContainer: 'horizontal',
},
{
id: 'C',
src: 'huangyanpei/reflection/scene1/9_009',
correctContainer: 'rightPillar',
},
{
id: 'D',
src: 'huangyanpei/reflection/scene1/9_0010',
correctContainer: 'horizontal',
},
{
id: 'E',
src: 'huangyanpei/reflection/scene1/9_0011',
correctContainer: 'horizontal',
},
{
id: 'F',
src: 'huangyanpei/reflection/scene1/9_0012',
correctContainer: 'horizontal',
},
]
/**
* 毛竹拼单杠游戏组件
* - 使用蒙版遮罩作为背景
* - 使用 DragFill 组件实现拖拽功能
*/
const BambooPuzzleGame = ({ visible = true, onClose, onComplete }: BambooPuzzleGameProps) => {
const maskSrc = getStaticImage('components/蒙版遮罩')
const backSrc = getStaticImage('components/关闭')
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
const [mounted, setMounted] = useState(false)
const [value, setValue] = useState<DragFillResult[]>([])
const [errorContainers, setErrorContainers] = useState<Set<ContainerType>>(new Set())
const [shakeKey, setShakeKey] = useState(0)
const completedRef = useRef(false)
const [showStartGuide, setShowStartGuide] = useState(false)
// SSR 兼容
useEffect(() => {
setMounted(true)
}, [])
// 重置游戏状态
useEffect(() => {
if (visible) {
setValue([])
setErrorContainers(new Set())
setShakeKey(0)
completedRef.current = false
setShowStartGuide(true)
}
}, [visible])
// 检查是否完成 - 三个都拼了才验证
useEffect(() => {
if (value.length === 3) {
const leftPillar = value.find((r) => r.blankId === 'leftPillar')
const rightPillar = value.find((r) => r.blankId === 'rightPillar')
const horizontal = value.find((r) => r.blankId === 'horizontal')
// 验证规则:
// 1. C 必须在横杆容器
// 2. A 和 B 可以在左右任意立柱(但必须各占一个)
const isCorrect =
horizontal?.optionValue === 'C' &&
leftPillar &&
rightPillar &&
(leftPillar.optionValue === 'A' || leftPillar.optionValue === 'B') &&
(rightPillar.optionValue === 'A' || rightPillar.optionValue === 'B') &&
leftPillar.optionValue !== rightPillar.optionValue
if (isCorrect && !completedRef.current) {
// 拼对了,调用完成回调(只调用一次)
completedRef.current = true
onComplete?.()
} else {
// 拼错了,找出错误的容器
const errors = new Set<ContainerType>()
// 检查横杆容器:必须是 C
if (horizontal?.optionValue !== 'C') {
errors.add('horizontal')
}
// 检查立柱容器:必须是 A 或 B,且不能相同
if (!leftPillar || (leftPillar.optionValue !== 'A' && leftPillar.optionValue !== 'B')) {
errors.add('leftPillar')
}
if (!rightPillar || (rightPillar.optionValue !== 'A' && rightPillar.optionValue !== 'B')) {
errors.add('rightPillar')
}
// 如果两个立柱相同,都标记为错误
if (leftPillar && rightPillar && leftPillar.optionValue === rightPillar.optionValue) {
errors.add('leftPillar')
errors.add('rightPillar')
}
// 显示错误效果:变红并抖动
setErrorContainers(errors)
setShakeKey((prev) => prev + 1) // 触发动画重新播放
// 1.5秒后清除错误状态和错误的填充
setTimeout(() => {
setErrorContainers(new Set())
// 清除错误的填充
setValue((prev) => prev.filter((r) => !errors.has(r.blankId as ContainerType)))
}, 1500)
}
}
}, [value, onComplete])
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation()
onClose?.()
},
[onClose]
)
// 转换为 DragFill 的 options
const options = useMemo(
() =>
pieces.map((piece) => ({
value: piece.id,
label: piece.id,
})),
[]
)
// 转换为 DragFill 的 blanks
const blanks = useMemo(
() => [
{ id: 'horizontal', label: '横杆容器' },
{ id: 'leftPillar', label: '立柱容器' },
{ id: 'rightPillar', label: '立柱容器' },
],
[]
)
// 样式
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="bamboo-puzzle-game"
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 flex-col items-center', 'bg-black/70')}
style={{ pointerEvents: 'auto' }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
<StudyGuideModal
visible={showStartGuide}
onContinue={() => setShowStartGuide(false)}
verticalAlign="center"
>
当年办学经费超紧张,没课本、没器材,黄先生就带着学生自己动手!
{'\n'}
快来一起做教具吧。
</StudyGuideModal>
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 游戏内容 */}
<div className="relative z-10 w-full h-full flex flex-col">
{/* 标题 */}
<div className="mt-8 mb-4 text-center">
<h1
className={cn([
'text-5xl font-bold mb-2 text-white',
'[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]',
])}
>
毛竹拼单杠
</h1>
<p className="text-4xl text-white mt-10">请将下方的竹子拖到正确位置,拼出单杠!</p>
</div>
{/* 拼图区域 - 空白容器 */}
<div className="flex-1 w-full relative" style={{ minHeight: '400px' }}>
{/* 横杆容器 - 上方居中 */}
<motion.div
key={`horizontal-${shakeKey}`}
data-blank-id="horizontal"
className={cn(
'absolute border-2 border-dashed rounded-4xl',
'flex flex-col items-center justify-center transition-all duration-200',
errorContainers.has('horizontal')
? 'border-red-500 bg-red-500/20'
: 'border-white/60 bg-white/5'
)}
animate={
errorContainers.has('horizontal')
? {
x: [0, -15, 15, -15, 15, -10, 10, 0],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
}
: { x: 0 }
}
style={{
left: `${DESIGN_WIDTH * 0.15}px`,
top: `${DESIGN_HEIGHT * 0.02}px`,
width: `${DESIGN_WIDTH * 0.7}px`,
height: `${DESIGN_HEIGHT * 0.2}px`,
}}
>
{(() => {
const filled = value.find((r) => r.blankId === 'horizontal')
const piece = filled ? pieces.find((p) => p.id === filled.optionValue) : null
return piece ? (
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain p-2"
objectFit="contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="text-white/30 text-4xl">拖拽横杆到这里</div>
</div>
)
})()}
</motion.div>
{/* 左立柱容器 */}
<motion.div
key={`leftPillar-${shakeKey}`}
data-blank-id="leftPillar"
className={cn(
'absolute border-2 border-dashed rounded-4xl',
'flex flex-col items-center justify-center transition-all duration-200',
errorContainers.has('leftPillar')
? 'border-red-500 bg-red-500/20'
: 'border-white/60 bg-white/5'
)}
animate={
errorContainers.has('leftPillar')
? {
x: [0, -15, 15, -15, 15, -10, 10, 0],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
}
: { x: 0 }
}
style={{
left: `${DESIGN_WIDTH * 0.15}px`,
top: `${DESIGN_HEIGHT * 0.14}px`,
width: `${DESIGN_WIDTH * 0.12}px`,
height: `${DESIGN_HEIGHT * 0.32}px`,
}}
>
{(() => {
const filled = value.find((r) => r.blankId === 'leftPillar')
const piece = filled ? pieces.find((p) => p.id === filled.optionValue) : null
return piece ? (
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain p-2"
objectFit="contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="text-white/30 text-4xl">拖拽立柱到这里</div>
</div>
)
})()}
</motion.div>
{/* 右立柱容器 */}
<motion.div
key={`rightPillar-${shakeKey}`}
data-blank-id="rightPillar"
className={cn(
'absolute border-2 border-dashed rounded-4xl',
'flex flex-col items-center justify-center transition-all duration-200',
errorContainers.has('rightPillar')
? 'border-red-500 bg-red-500/20'
: 'border-white/60 bg-white/5'
)}
animate={
errorContainers.has('rightPillar')
? {
x: [0, -15, 15, -15, 15, -10, 10, 0],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
}
: { x: 0 }
}
style={{
left: `${DESIGN_WIDTH * 0.74}px`,
top: `${DESIGN_HEIGHT * 0.14}px`,
width: `${DESIGN_WIDTH * 0.12}px`,
height: `${DESIGN_HEIGHT * 0.32}px`,
}}
>
{(() => {
const filled = value.find((r) => r.blankId === 'rightPillar')
const piece = filled ? pieces.find((p) => p.id === filled.optionValue) : null
return piece ? (
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain p-2"
objectFit="contain"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="text-white/30 text-4xl">拖拽立柱到这里</div>
</div>
)
})()}
</motion.div>
</div>
{/* 道具区域 - 使用 DragFill 组件 */}
<div
className="absolute bottom-0 left-0 w-full"
style={{
paddingLeft: `${DESIGN_WIDTH * 0.05}px`,
paddingBottom: `${DESIGN_HEIGHT * 0.05}px`,
width: `${DESIGN_WIDTH * 0.9}px`,
}}
>
<div className="text-[#243204] mb-20 text-4xl font-blod">你的毛竹道具</div>
<DragFill
options={options}
blanks={blanks}
value={value}
onChange={setValue}
allowDuplicate={false}
layout="horizontal-options"
hideLabels={true}
showSubmitButton={false}
className="w-full"
renderOption={(option, isUsed) => {
const piece = pieces.find((p) => p.id === option.value)
if (!piece) return null
return (
<div
className={cn(
'relative flex flex-col items-center justify-center',
' h-24 rounded-lg transition-all duration-200',
'cursor-move select-none',
isUsed ? 'opacity-50 cursor-not-allowed' : 'hover:scale-110'
)}
>
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain pointer-events-none"
draggable={false}
objectFit="contain"
/>
<span className="text-white text-base mt-1 font-medium">{piece.id}</span>
</div>
)
}}
renderDragging={(option) => {
const piece = pieces.find((p) => p.id === option.value)
if (!piece) return null
return (
<div className="w-24 h-24">
<Image
src={getStaticImage(piece.src)}
alt={piece.id}
className="w-full h-full object-contain"
draggable={false}
objectFit="contain"
/>
</div>
)
}}
renderBlank={() => {
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
return <div className="hidden" />
}}
/>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
return createPortal(content, document.body)
}
export default BambooPuzzleGame
@@ -0,0 +1,2 @@
export { default as BambooPuzzleGame } from './BambooPuzzleGame'
export type { BambooPuzzleGameProps } from './BambooPuzzleGame'
@@ -0,0 +1,6 @@
export { default as QuestionModal } from './question-modal'
export type { QuestionModalProps } from './question-modal'
export { BambooPuzzleGame } from './game'
export { default as QuizDialog } from './dialog'
@@ -0,0 +1,3 @@
export { default } from './question-modal'
export { default as QuestionModal } from './question-modal'
export type { QuestionModalProps } from './question-modal'
@@ -0,0 +1,149 @@
import { useCallback, useState, type ReactNode } from 'react'
import { Image } from '@/components'
import { getStaticImage } from '@/lib/utils'
import { ErrorAlert, SuccessAlert } from '@/components/layout'
export interface QuestionModalProps {
/** 是否可见 */
visible?: boolean
/** 标题文案 */
title?: string
/** 主体内容(支持换行、富文本) */
children?: ReactNode
/** 点击关闭按钮回调 */
onClose?: () => void
/** 点击遮罩是否关闭,默认 false */
maskClosable?: boolean
/** 自定义 zIndex,默认 120 */
zIndex?: number
/** 自定义类名 */
className?: string
/** 自定义样式 */
style?: React.CSSProperties
/** 成功回调 */
onSuccess?: () => void
/** 错误回调 */
onError?: () => void
}
export const QuestionModal = ({
visible = false,
title: _title,
children: _children,
onClose,
onSuccess,
onError,
maskClosable: _maskClosable = false,
zIndex = 120,
className: _className,
style: _style,
}: QuestionModalProps) => {
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
const [showErrorAlert, setShowErrorAlert] = useState(false)
// 答案
const correctAnswer = 2
// 选项
const questionOptions = [
{
id: 1,
src: getStaticImage('huangyanpei/reflection/scene1/9_004'),
},
{
id: 2,
src: getStaticImage('huangyanpei/reflection/scene1/9_005'),
},
]
const handleAnswer = useCallback(
(id: number) => {
if (id === correctAnswer) {
// 选对了,显示成功提示
setShowSuccessAlert(true)
} else {
// 选错了,显示错误提示
setShowErrorAlert(true)
}
},
[correctAnswer]
)
const handleSuccessAlertClose = useCallback(() => {
setShowSuccessAlert(false)
onSuccess?.()
}, [onSuccess])
const handleErrorAlertClose = useCallback(() => {
setShowErrorAlert(false)
onError?.()
}, [onError])
const maskSrc = getStaticImage('components/蒙版遮罩')
const backSrc = getStaticImage('components/关闭')
if (!visible) return null
return (
<div className="fixed inset-0 flex items-center justify-center" style={{ zIndex }}>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
{/* 返回按钮 */}
<Image
src={backSrc}
onClick={() => onClose?.()}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
{/* 游戏内容 */}
<div className="relative z-10 w-full h-full flex flex-col ">
{/* 标题 */}
<div className="mt-28 mb-4 text-center px-80">
<p className="text-5xl text-white mt-10 leading-tight">
黄先生说:“中国教育之病,在于学与术分离”!你看,这里有两套课程表,选一套开启学习吧。
</p>
</div>
{/* 选项 */}
<div className="flex justify-center gap-30 mt-20">
{questionOptions.map((option) => (
<Image
className="w-80"
key={option.id}
src={option.src}
button
onClick={() => handleAnswer(option.id)}
/>
))}
</div>
</div>
{/* 成功提示弹窗 */}
<SuccessAlert
visible={showSuccessAlert}
title="选对啦!"
content="这就是黄先生打破旧教育的创新,学这些实用知识,才能真正帮到家乡。"
onContinue={handleSuccessAlertClose}
onClose={handleSuccessAlertClose}
/>
{/* 错误提示弹窗 */}
<ErrorAlert
visible={showErrorAlert}
text="哎呀选错啦!黄先生最反对只啃书本脱离实际的教育,旧课程学不到真本事,快试试另一边的新式课程吧。"
onClose={handleErrorAlertClose}
/>
</div>
)
}
QuestionModal.displayName = 'QuestionModal'
export default QuestionModal
+150
View File
@@ -0,0 +1,150 @@
import { useState, useCallback, useMemo } from 'react'
import { Title, Image, Cursor, StudyGuideModal } from '@/components'
import { BambooPuzzleGame, QuestionModal } from './components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { useCursorController } from '@/hooks/useCursorController'
const starName = 'huangyanpei'
const HuangyanpeiReflectionScene1 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
const [showGuideModal, setShowGuideModal] = useState(true)
const [showQuestionModal, setShowQuestionModal] = useState(false)
const [showGame, setShowGame] = useState(false)
const [showNextGuide, setShowNextGuide] = useState(false)
// 使用持久化的reflection cursor状态
const scene = 'scene1'
const {
markReflectionCursorVisited,
getVisitedReflectionCursors,
setReflectionRoute,
markReflectionCompletionGuideShown,
} = useHuangyanpeiStore()
// 从 store 获取已完成的cursor,转换为 Set 用于兼容现有代码
const visitedCursors = getVisitedReflectionCursors(scene)
const visited = useMemo(() => new Set(visitedCursors), [visitedCursors])
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: { 2: [1] },
visited: visitedCursors,
})
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
const handleGoNext = useCallback(() => {
setShowNextGuide(false)
setReflectionRoute('reflection2')
saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/reflection2`)
}, [moduleName, navigate, setReflectionRoute])
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`huangyanpei/reflection/scene1/9_001`),
}}
>
<Title
title="跨越时空"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName && !showGuideModal) {
navigate(`/${moduleName}/${starName}/home`)
}
}}
/>
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false)
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_23.mp3')}
firstLineIndent="2em"
>
同学你好,欢迎来到“跨越时空·体悟”学习模块!{'\n'}
请登陆“时空回溯・亲历时代”体验仓,下一站:1903年,川沙小学堂。{'\n'}
你将在“任之”校长所在的学校学习,根据所知和所学,努力完成校长给你的考验,完成全部任务后,可进入到下一阶段的学习。时空门已开,请做好准备迎接考验吧!
</StudyGuideModal>
<Cursor
className="absolute left-68 top-55"
onClick={() => {
setShowQuestionModal(true)
}}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute left-160 bottom-50"
onClick={() => {
setShowGame(true)
}}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
<QuestionModal
visible={showQuestionModal}
onSuccess={() => {
setShowQuestionModal(false)
markReflectionCursorVisited(scene, 1)
}}
onClose={() => setShowQuestionModal(false)}
/>
{completed && (
<Image
className="absolute bottom-0 right-0 z-20"
src={getStaticImage('components/place/继续前进')}
button
onClick={handleGoNext}
/>
)}
<BambooPuzzleGame
visible={showGame}
onClose={() => {
setShowGame(false)
}}
onComplete={() => {
setShowGame(false)
markReflectionCursorVisited(scene, 2)
markReflectionCompletionGuideShown(scene)
saveHuangyanpeiStore()
setShowNextGuide(true)
}}
/>
<StudyGuideModal visible={showNextGuide} onContinue={handleGoNext} verticalAlign="center">
第一关完成!下一站,浦东中学深造去。
</StudyGuideModal>
</div>
)
}
export default HuangyanpeiReflectionScene1
+257
View File
@@ -0,0 +1,257 @@
import { useState, useEffect } from 'react'
import { Title, Image, Cursor, StudyGuideModal } from '@/components'
import { SuccessAlert } from '@/components/layout'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import ChoiceDialog1 from './scene2/ChoiceDialog1'
import ChoiceDialog2 from './scene2/ChoiceDialog2'
import QuizDialog from './scene2/QuizDialog'
import {
useHuangyanpeiStore,
saveHuangyanpeiStore,
loadHuangyanpeiStore,
} from '@/store/huangyanpeiStore'
import { useCursorController } from '@/hooks/useCursorController'
const starName = 'huangyanpei'
const HuangyanpeiReflectionScene2 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
const [showGuideModal, setShowGuideModal] = useState(true)
const [showDialog1, setShowDialog1] = useState(false)
const [showDialog2, setShowDialog2] = useState(false)
const [choice2, setChoice2] = useState<'铁工科' | '商科' | null>(null)
const [showCompleteGuide, setShowCompleteGuide] = useState(false)
// 题目显示状态(ChoiceDialog1关闭后)
const [showQuiz, setShowQuiz] = useState(false)
// 题目成功提示状态
const [showQuizSuccess, setShowQuizSuccess] = useState(false)
// 使用持久化的reflection cursor状态
const scene = 'scene2'
const {
markReflectionCursorVisited,
getVisitedReflectionCursors,
setReflectionRoute,
reflectionScene2Choice,
setReflectionScene2Choice,
hasShownReflectionCompletionGuide,
markReflectionCompletionGuideShown,
} = useHuangyanpeiStore()
// 从 store 获取已完成的cursor,转换为 Set 用于兼容现有代码
const visitedCursors = getVisitedReflectionCursors(scene)
const visited = new Set(visitedCursors)
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: { 2: [1] },
visited: visitedCursors,
})
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
// 进入页面时读取缓存中的 choice2
useEffect(() => {
let mounted = true
const syncChoice2FromCache = async () => {
await loadHuangyanpeiStore()
if (!mounted) {
return
}
const latestChoice2 = useHuangyanpeiStore.getState().reflectionScene2Choice
if (latestChoice2) {
setChoice2(latestChoice2)
}
}
void syncChoice2FromCache()
return () => {
mounted = false
}
}, [])
// 本地状态与 store 中的 choice2 保持同步
useEffect(() => {
if (reflectionScene2Choice && reflectionScene2Choice !== choice2) {
setChoice2(reflectionScene2Choice)
}
}, [reflectionScene2Choice, choice2])
// 两个任务都完成后,显示完成提示(仅一次)
useEffect(() => {
if (completed && choice2 && !hasShownReflectionCompletionGuide(scene)) {
const timer = setTimeout(() => {
setShowCompleteGuide(true)
markReflectionCompletionGuideShown(scene)
saveHuangyanpeiStore()
}, 500)
return () => clearTimeout(timer)
}
}, [
completed,
choice2,
scene,
hasShownReflectionCompletionGuide,
markReflectionCompletionGuideShown,
])
// 处理完成提示的继续按钮点击
const handleCompleteGuideContinue = () => {
setShowCompleteGuide(false)
// 根据选择决定导航目标
if (choice2 === '铁工科') {
setReflectionRoute('reflection3')
saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/reflection3`)
} else if (choice2 === '商科') {
setReflectionRoute('reflection4')
saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/reflection4`)
}
}
if (!moduleName) {
return null
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`huangyanpei/reflection/scene2/10_001`),
}}
>
<Title
title="跨越时空"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/home`)
}}
/>
{completed && (
<Image
className="absolute bottom-0 right-0 z-20"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => handleCompleteGuideContinue()}
/>
)}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => setShowGuideModal(false)}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_24.mp3')}
>
欢迎来到浦东中学!{'\n'}
这是近代上海第一所完全中学,黄炎培先生当首任校长,在这里能学到更多实用知识,还能树立成才的理想哦。
</StudyGuideModal>
{choice2 && (
<img
className="absolute right-[234px] bottom-12.5"
src={getStaticImage('huangyanpei/reflection/scene2/10_003')}
alt="已解锁指示牌盖板"
/>
)}
{/* 互动展品光标1:显示第一个选择弹窗 */}
<Cursor
className="absolute left-118 bottom-105"
onClick={() => {
setShowDialog1(true)
}}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 互动展品光标2:显示第二个选择弹窗 */}
<Cursor
className="absolute right-74 bottom-68"
onClick={() => {
setShowDialog2(true)
}}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 第一个选择弹窗 */}
<ChoiceDialog1
visible={showDialog1}
onClose={() => {
setShowDialog1(false)
setShowQuiz(true)
}}
onSelect={() => {
markReflectionCursorVisited(scene, 1)
}}
/>
{/* 第二个选择弹窗 */}
<ChoiceDialog2
visible={showDialog2}
onClose={() => setShowDialog2(false)}
onSelect={(choice) => {
setChoice2(choice)
setReflectionScene2Choice(choice)
markReflectionCursorVisited(scene, 2)
saveHuangyanpeiStore()
}}
/>
<QuizDialog
visible={showQuiz}
onClose={() => setShowQuiz(false)}
onComplete={() => {
setShowQuiz(false)
setShowQuizSuccess(true)
}}
/>
{/* 题目成功提示 */}
<SuccessAlert
visible={showQuizSuccess}
title=""
content="恭喜你!选择正确。不愧是从川沙小学堂走出来!"
onContinue={() => {
setShowQuizSuccess(false)
}}
onClose={() => {
setShowQuizSuccess(false)
}}
/>
{/* 完成提示 */}
<StudyGuideModal
visible={showCompleteGuide}
onContinue={handleCompleteGuideContinue}
verticalAlign="center"
>
第二关完成!“职业方向锁定!”去新学校了解你的专业吧。
</StudyGuideModal>
</div>
)
}
export default HuangyanpeiReflectionScene2
@@ -0,0 +1,247 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { cn, getStaticImage } from '@/lib/utils'
import { Image, StudyGuideModal } from '@/components'
import { SuccessAlert, ErrorAlert } from '@/components/layout'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
export interface ChoiceDialog1Props {
/** 是否可见 */
visible?: boolean
/** 关闭回调 */
onClose?: () => void
/** 选择回调 */
onSelect?: (choice: '读书' | '没出息' | '女子不能上学') => void
}
/**
* 第一个选择弹窗:人物和三个选择
* - 使用蒙版遮罩作为背景
* - 显示人物10_004和三个选择选项
*/
const ChoiceDialog1 = ({ visible = true, onClose, onSelect }: ChoiceDialog1Props) => {
const maskSrc = getStaticImage('components/蒙版遮罩')
const backSrc = getStaticImage('components/关闭')
const characterSrc = getStaticImage('huangyanpei/reflection/scene2/10_004')
const choice1Src = getStaticImage('huangyanpei/reflection/scene2/10_005')
const choice2Src = getStaticImage('huangyanpei/reflection/scene2/10_006')
const choice3Src = getStaticImage('huangyanpei/reflection/scene2/10_007')
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [mounted, setMounted] = useState(false)
const [showGuide, setShowGuide] = useState(false)
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
const [showErrorAlert, setShowErrorAlert] = useState(false)
// SSR 兼容
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (visible) {
setShowGuide(true)
setShowSuccessAlert(false)
setShowErrorAlert(false)
} else {
setShowGuide(false)
}
}, [visible])
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation()
onClose?.()
},
[onClose]
)
const handleChoiceClick = useCallback(
(choice: '读书' | '没出息' | '女子不能上学') => {
if (choice === '读书') {
setShowGuide(false)
setShowSuccessAlert(true)
onSelect?.(choice)
} else {
setShowErrorAlert(true)
}
},
[onSelect]
)
// 样式
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
position: 'fixed',
inset: 0,
zIndex: 9999,
pointerEvents: 'none',
}),
[]
)
if (!mounted) return null
const content = (
<AnimatePresence>
{visible && (
<motion.div
key="choice-dialog-1"
style={overlayStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<div
className={cn('fixed inset-0 flex flex-col items-center justify-center', 'bg-black/70')}
style={{ pointerEvents: 'auto' }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
<StudyGuideModal
visible={showGuide}
onContinue={() => {
setShowGuide(false)
triggerPuzzleAnimation(14)
}}
verticalAlign="center"
>
黄校长来考你啦!他设计这所学校,就是为了让学生摆脱旧观念,你知道是哪种吗?
</StudyGuideModal>
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex items-center justify-between px-20">
{/* 人物 - 左侧 */}
<div className="flex-shrink-0">
<img src={characterSrc} alt="人物" />
</div>
{/* 三个选择选项 - 右侧水平排列 */}
<div className="flex items-center justify-center gap-16 flex-1">
{/* 选择A:读书 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick('读书')}
>
{/* 标签A */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
A
</div>
{/* 图标 */}
<img src={choice1Src} alt="读书" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
读书只为
<br />
升官发财
</div>
</div>
{/* 选择B:没出息 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick('没出息')}
>
{/* 标签B */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
B
</div>
{/* 图标 */}
<img src={choice2Src} alt="没出息" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
手工劳作
<br />
没出息
</div>
</div>
{/* 选择C:女子不能上学 */}
<div
className="flex flex-col items-center cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick('女子不能上学')}
>
{/* 标签C */}
<div className="text-7xl font-bold text-[#ffa500] mb-3 [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
C
</div>
{/* 图标 */}
<img src={choice3Src} alt="女子不能上学" className="mb-3" />
{/* 文字说明 */}
<div className="text-black text-4xl text-center leading-relaxed font-bold">
女子
<br />
不能上学
</div>
</div>
</div>
</div>
<SuccessAlert
visible={showSuccessAlert}
title="任务1 正确反馈"
content="答对啦!黄先生说“教育要为国家培养实干人才”,浦东中学就是要教大家学真本事,不是只为当官。"
onContinue={() => {
setShowSuccessAlert(false)
onClose?.()
}}
onClose={() => {
setShowSuccessAlert(false)
onClose?.()
}}
/>
<ErrorAlert
visible={showErrorAlert}
text="不对哦~黄先生虽然反对歧视女子、轻视手工,但创办浦东中学的核心是打破“读书只为当官”的旧思想,再选一次吧!"
onClose={() => setShowErrorAlert(false)}
/>
<SuccessAlert
visible={showSuccessAlert}
content="答对啦!黄先生说“教育要为国家培养实干人才”,浦东中学就是要教大家学真本事,不是只为当官。"
onContinue={() => {
setShowSuccessAlert(false)
onClose?.()
}}
onClose={() => {
setShowSuccessAlert(false)
onClose?.()
}}
/>
{/* <ErrorAlert
visible={showErrorAlert}
text="不对哦~黄先生虽然反对歧视女子、轻视手工,但创办浦东中学的核心是打破“读书只为当官”的旧思想,再选一次吧!"
onClose={() => setShowErrorAlert(false)}
/> */}
</div>
</motion.div>
)}
</AnimatePresence>
)
return content
}
export default ChoiceDialog1
@@ -0,0 +1,190 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { cn, getStaticImage } from '@/lib/utils'
import { Image, StudyGuideModal } from '@/components'
import { SuccessAlert } from '@/components/layout'
export interface ChoiceDialog2Props {
/** 是否可见 */
visible?: boolean
/** 关闭回调 */
onClose?: () => void
/** 选择回调 */
onSelect?: (choice: '铁工科' | '商科') => void
}
/**
* 第二个选择弹窗:背景图选择
* - 使用蒙版遮罩作为背景
* - 显示两个选择:10_008(箭头指向右边)和旋转后的背景图
*/
const ChoiceDialog2 = ({ visible = true, onClose, onSelect }: ChoiceDialog2Props) => {
const maskSrc = getStaticImage('components/蒙版遮罩')
const backSrc = getStaticImage('components/关闭')
const choiceSrc = getStaticImage('huangyanpei/reflection/scene2/10_008')
const [mounted, setMounted] = useState(false)
const [showGuide, setShowGuide] = useState(false)
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
const [selectedChoice, setSelectedChoice] = useState<'铁工科' | '商科' | null>(null)
// SSR 兼容
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (visible) {
setShowGuide(true)
setShowSuccessAlert(false)
setSelectedChoice(null)
} else {
setShowGuide(false)
}
}, [visible])
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation()
onClose?.()
},
[onClose]
)
const handleChoiceClick = useCallback((choice: '铁工科' | '商科') => {
setSelectedChoice(choice)
setShowSuccessAlert(true)
}, [])
const handleSuccessAlertContinue = useCallback(() => {
setShowSuccessAlert(false)
if (selectedChoice) {
onSelect?.(selectedChoice)
}
onClose?.()
}, [selectedChoice, onSelect, onClose])
// 样式
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
position: 'fixed',
inset: 0,
zIndex: 9999,
pointerEvents: 'none',
}),
[]
)
if (!mounted) return null
const content = (
<AnimatePresence>
{visible && (
<motion.div
key="choice-dialog-2"
style={overlayStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<div
className={cn(
'absolute inset-0 flex flex-col items-center justify-center',
'bg-black/70'
)}
style={{ pointerEvents: 'auto' }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
<StudyGuideModal
visible={showGuide}
onContinue={() => setShowGuide(false)}
verticalAlign="center"
>
在浦东中学,你可以选未来的方向啦!不管选哪个,黄先生都会支持,因为“三百六十行,行行出状元”!
</StudyGuideModal>
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-12">
{/* 选择A:铁工科(箭头指向左边) */}
<div
className="flex items-center gap-6 cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick('铁工科')}
>
{/* 标签A */}
<div className="text-7xl font-bold text-[#ffa500] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
A
</div>
{/* 箭头按钮 - 需要旋转180度使其指向左边 */}
<div className="relative" style={{ transform: 'rotate(180deg)' }}>
<img src={choiceSrc} alt="铁工科" className="max-w-[800px] object-contain" />
{/* 文字覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center">
<div
className="text-white text-7xl font-bold"
style={{ transform: 'rotate(180deg)' }}
>
铁工科
</div>
</div>
</div>
</div>
{/* 选择B:商科(箭头指向右边) */}
<div
className="flex items-center gap-6 cursor-pointer transition-transform duration-200 hover:scale-105 active:scale-95"
onClick={() => handleChoiceClick('商科')}
>
{/* 标签B */}
<div className="text-7xl font-bold text-[#ffa500] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
B
</div>
{/* 箭头按钮 */}
<div className="relative">
<img src={choiceSrc} alt="商科" className="max-w-[800px] object-contain" />
{/* 文字覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-white text-7xl font-bold">商科</div>
</div>
</div>
</div>
</div>
</div>
<SuccessAlert
visible={showSuccessAlert}
title="选得好!"
content="不管是做工匠还是做买卖,学好本事就能报效国家,这个选择会影响你在中华职业学校的专业哦!"
onContinue={handleSuccessAlertContinue}
onClose={handleSuccessAlertContinue}
/>
</div>
</motion.div>
)}
</AnimatePresence>
)
return content
}
export default ChoiceDialog2
@@ -0,0 +1,163 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { cn, getStaticImage } from '@/lib/utils'
import { Image } from '@/components'
import { ErrorAlert } from '@/components/layout'
export interface Scene2QuizDialogProps {
visible?: boolean
onClose?: () => void
onComplete?: () => void
}
const Scene2QuizDialog = ({ visible = true, onClose, onComplete }: Scene2QuizDialogProps) => {
const maskSrc = getStaticImage('components/蒙版遮罩')
const backSrc = getStaticImage('components/关闭')
const [mounted, setMounted] = useState(false)
const [errorVisible, setErrorVisible] = useState(false)
const options = useMemo(
() => [
{ value: 'A', label: 'A. 800 两白银' },
{ value: 'B', label: 'B. 815 两白银' },
{ value: 'C', label: 'C. 820 两白银' },
{ value: 'D', label: 'D. 836 两白银' },
],
[]
)
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
position: 'fixed',
inset: 0,
zIndex: 10001,
pointerEvents: 'none',
}),
[]
)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (!visible) {
setErrorVisible(false)
}
}, [visible])
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation()
setErrorVisible(false)
onClose?.()
},
[onClose]
)
const handleOptionClick = useCallback(
(value: string) => {
if (value === 'C') {
setErrorVisible(false)
onComplete?.()
return
}
setErrorVisible(true)
},
[onComplete]
)
const handleErrorContinue = useCallback(() => {
setErrorVisible(false)
}, [])
if (!mounted) return null
return (
<AnimatePresence>
{visible && (
<motion.div
key="scene2-quiz-dialog"
style={overlayStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<div
className={cn('absolute inset-0 flex flex-col', 'bg-black/70')}
style={{ pointerEvents: 'auto' }}
>
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="关闭"
className="absolute top-5 right-5 z-20"
button
/>
)}
<div className="relative z-10 w-full h-full flex flex-col px-30 py-16 pt-30 text-[#432800]">
<div className="w-full h-full flex flex-col">
<div className="flex h-200 justify-between gap-10 mb-8">
<div className="flex-1">
<div className="text-5xl font-bold mb-6 -ml-[26px]">【单选】</div>
<div className="text-4xl leading-relaxed">
杨斯盛先生近期监工浦东中学建设,有一份上月的工人工钱支出表需要核对,你来告诉我答案吧?
</div>
</div>
<div className="w-200">
<Image
width={800}
src={getStaticImage('huangyanpei/reflection/components/compute')}
/>
</div>
</div>
<div className="flex-1">
<div className="text-5xl font-bold mb-6">答题区</div>
<div className="grid grid-cols-2 gap-10">
{options.map((option) => (
<div
className={cn(
'cursor-pointer px-10 py-10 rounded-2xl border-3 text-3xl font-medium transition-all select-none',
'border-[#79471f] bg-black/20 text-white',
'hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)]'
)}
key={option.value}
onClick={() => handleOptionClick(option.value)}
>
{option.label}
</div>
))}
</div>
</div>
</div>
</div>
<ErrorAlert
visible={errorVisible}
text="是这个数目吗?你要不要再算一下!"
onClose={handleErrorContinue}
/>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
export default Scene2QuizDialog
+248
View File
@@ -0,0 +1,248 @@
import { useState, useRef } from 'react'
import { Title, Image, Cursor, StudyGuideModal, Video } from '@/components'
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
import { SuccessAlert, ErrorAlert } from '@/components/layout'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import ViseAssemblyDialog from './scene3/ViseAssemblyDialog'
import type { VideoRef } from '@/components/video'
import { QuizDialog } from './components'
import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { useCursorController } from '@/hooks/useCursorController'
const starName = 'huangyanpei'
const HuangyanpeiReflectionScene3 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
//喇叭考题弹窗
const [showQuizDialog, setShowQuizDialog] = useState(false)
const [showGuide1, setShowGuide1] = useState(true)
const [showDialog, setShowDialog] = useState(false)
const [showQuiz, setShowQuiz] = useState(false)
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
const [showErrorAlert, setShowErrorAlert] = useState(false)
const [showErrorAlertForQuiz, setShowErrorAlertForQuiz] = useState(false)
const [showCompleteGuide, setShowCompleteGuide] = useState(false)
const [showVideo, setShowVideo] = useState(false)
const videoRef = useRef<VideoRef>(null)
// 使用持久化的reflection cursor状态
const scene = 'scene3'
const { markReflectionCursorVisited, getVisitedReflectionCursors } = useHuangyanpeiStore()
// 从 store 获取已完成的cursor,转换为 Set 用于兼容现有代码
const visitedCursors = getVisitedReflectionCursors(scene)
const visited = new Set(visitedCursors)
const { isEnabled } = useCursorController({
initialEnabled: [2],
dependencies: { 1: [2], 3: [1, 2] },
visited: visitedCursors,
})
if (!moduleName) {
return null
}
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(`huangyanpei/reflection/scene3/11_001`),
}}
>
<Title
title="跨越时空"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/reflection2`)
}}
/>
<Image
className="absolute left-5 top-5"
src={getStaticImage('huangyanpei/reflection/components/喇叭1')}
button
onClick={() => {
if (isEnabled(3)) {
setShowQuizDialog(true)
} else {
setShowErrorAlertForQuiz(true)
}
}}
aria-disabled={!isEnabled(3)}
/>
{/* 第一段引导 */}
<StudyGuideModal
visible={showGuide1}
onContinue={() => {
setShowGuide1(false)
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_25.mp3')}
firstLineIndent="2em"
>
终于到中华职业学校啦!这里是中国近代职教的摇篮,“免学费、包分配”,还能学真手艺,快来练就一技之长。
{'\n'}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;你选了学技术,那就来挑战制作中华职校的“明星产品”——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢!
</StudyGuideModal>
{/* 互动展品光标:组装台钳互动 */}
<Cursor
className="absolute right-100 bottom-55"
onClick={() => {
setShowDialog(true)
}}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 答题光标 */}
<Cursor
className="absolute left-145 top-68"
onClick={() => {
setShowQuiz(true)
}}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 组装台钳互动弹窗 */}
<ViseAssemblyDialog
visible={showDialog}
onClose={() => setShowDialog(false)}
onComplete={() => {
setShowDialog(false)
setShowCompleteGuide(true)
markReflectionCursorVisited(scene, 1)
}}
/>
{/* 多选题答题框 */}
<MultipleChoiceQuizPanel
visible={showQuiz}
title="【多选】 制作台钳需要哪些工具?"
options={[
{ value: 'A', label: 'A. 锯子' },
{ value: 'B', label: 'B. 毛笔' },
{ value: 'C', label: 'C. 锉刀' },
{ value: 'D', label: 'D. 铁锤' },
]}
correctAnswer={['A', 'C', 'D']}
errorMessage={(selectedValues) => {
// 如果选择了B,显示包含"毛笔是写字的哦"的提示
if (selectedValues.includes('B')) {
return '毛笔是写字的哦!台钳是铁制品,得用锯子、锉刀、铁锤这些工具,再选一次吧!'
}
// 其他情况(单选A或C或D,或者AC、CD、AD等组合)
return '台钳是铁制品,得用锯子、锉刀、铁锤这些工具,再选一次吧!'
}}
minSelections={1}
onSubmit={(values, isCorrect) => {
if (isCorrect) {
setShowQuiz(false)
setShowSuccessAlert(true)
}
}}
zIndex={10001}
/>
{/* 成功提示弹窗 */}
<SuccessAlert
visible={showSuccessAlert}
title="满分!"
content='黄先生说“手脑并用”,懂了理论,也要动手实践。'
onContinue={() => {
setShowSuccessAlert(false)
markReflectionCursorVisited(scene, 2)
}}
onClose={() => {
setShowSuccessAlert(false)
}}
/>
{/* 错误提示:请先去学习制作台钳 */}
<ErrorAlert
visible={showErrorAlert}
text="请先去学习台钳制作知识吧!"
onClose={() => setShowErrorAlert(false)}
/>
{/* 错误提示:请先完成前两个任务 */}
<ErrorAlert
visible={showErrorAlertForQuiz}
text="请先完成前两个任务吧!"
onClose={() => setShowErrorAlertForQuiz(false)}
/>
{/* 组装台钳完成提示 */}
<StudyGuideModal
visible={showCompleteGuide}
onContinue={() => setShowCompleteGuide(false)}
verticalAlign="center"
>
太厉害啦!你的台钳制作成功,和当年拿金奖的同款哦。
</StudyGuideModal>
{completed && (
<Image
className="absolute bottom-0 right-4"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setShowVideo(true)
}}
/>
)}
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath('video/huangyanpei/huangyanpei_09.mp4')}
className="h-full w-full"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo(false)
setCourseTimeProgress(11)
navigate(`/${moduleName}/${starName}/home`)
}}
/>
)}
<QuizDialog
visible={showQuizDialog}
onClose={() => setShowQuizDialog(false)}
onComplete={() => {
setShowQuizDialog(false)
markReflectionCursorVisited(scene, 3)
}}
/>
</div>
)
}
export default HuangyanpeiReflectionScene3
@@ -0,0 +1,376 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { createPortal } from 'react-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { cn, getStaticImage, getPublicPath } from '@/lib/utils'
import { Image, Video, DragFill } from '@/components'
import type { DragFillResult } from '@/components'
import { useScale } from '@/contexts/ScaleContext'
import type { VideoRef } from '@/components/video'
export interface ViseAssemblyDialogProps {
/** 是否可见 */
visible?: boolean
/** 关闭回调 */
onClose?: () => void
/** 完成回调 */
onComplete?: () => void
}
type PartType = 'A' | 'B' | 'C'
/**
* 组装台钳互动弹窗
* - 顶部三个位置用于放置零件
* - 底部三个零件(A、B、C)可以选择
* - 答题成功后播放视频
*/
const ViseAssemblyDialog = ({ visible = true, onClose, onComplete }: ViseAssemblyDialogProps) => {
const maskSrc = getStaticImage('components/蒙版遮罩')
const backSrc = getStaticImage('components/关闭')
// 零件背景提示素材
const partHintA = getStaticImage('huangyanpei/reflection/scene3/11_005')
const partHintB = getStaticImage('huangyanpei/reflection/scene3/11_006')
const partHintC = getStaticImage('huangyanpei/reflection/scene3/c')
// 零件素材
const partA = getStaticImage('huangyanpei/reflection/scene3/11_010')
const partB = getStaticImage('huangyanpei/reflection/scene3/11_011')
const partC = getStaticImage('huangyanpei/reflection/scene3/11_012')
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
const [mounted, setMounted] = useState(false)
const [value, setValue] = useState<DragFillResult[]>([])
const [videoVisible, setVideoVisible] = useState(false)
const videoRef = useRef<VideoRef>(null)
const completedRef = useRef(false)
// SSR 兼容
useEffect(() => {
setMounted(true)
}, [])
// 重置游戏状态
useEffect(() => {
if (visible) {
setValue([])
completedRef.current = false
setVideoVisible(false)
}
}, [visible])
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation()
if (!videoVisible) {
onClose?.()
}
},
[onClose, videoVisible]
)
// 转换为 DragFill 的 options
const options = useMemo(
() => [
{ value: 'A', label: '丝杠和丝杆' },
{ value: 'B', label: '活动钳口' },
{ value: 'C', label: '固定钳口和基座' },
],
[]
)
// 转换为 DragFill 的 blanks
const blanks = useMemo(
() => [
{ id: 'slot0', label: '位置1' },
{ id: 'slot1', label: '位置2' },
{ id: 'slot2', label: '位置3' },
],
[]
)
// 处理拖拽结果变化
const handleValueChange = useCallback((newValue: DragFillResult[]) => {
// 验证:只允许 A->slot0, B->slot1, C->slot2
const filteredValue = newValue.filter((result) => {
const expectedSlot =
result.optionValue === 'A' ? 'slot0' : result.optionValue === 'B' ? 'slot1' : 'slot2'
return result.blankId === expectedSlot
})
setValue(filteredValue)
}, [])
// 处理"拼接台钳"按钮点击
const handleAssemble = useCallback(() => {
if (value.length < 3) {
return
}
const slot0 = value.find((r) => r.blankId === 'slot0')
const slot1 = value.find((r) => r.blankId === 'slot1')
const slot2 = value.find((r) => r.blankId === 'slot2')
// 验证:A->slot0, B->slot1, C->slot2
const isCorrect =
slot0?.optionValue === 'A' && slot1?.optionValue === 'B' && slot2?.optionValue === 'C'
if (isCorrect) {
completedRef.current = true
// 答题成功,播放视频
setVideoVisible(true)
} else {
// 答题失败,清除错误的填充
setTimeout(() => {
setValue([])
}, 1500)
alert('组装顺序不正确,请重新尝试!')
}
}, [value])
const handleVideoEnd = useCallback(() => {
setVideoVisible(false)
onComplete?.()
onClose?.()
}, [onComplete, onClose])
// 样式
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="vise-assembly-dialog"
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 flex-col items-center justify-center',
'bg-black/70'
)}
style={{ pointerEvents: 'auto' }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
{/* 返回按钮 */}
{onClose && !videoVisible && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 视频播放层 */}
{videoVisible ? (
<div className="fixed inset-0 z-[10000] bg-black">
<Video
ref={videoRef}
src={getPublicPath('video/huangyanpei/huangyanpei_11.mp4')}
className="w-full h-full"
autoPlay
playsInline
preload="auto"
showSkipButton
skipText="跳过"
onEnded={handleVideoEnd}
/>
</div>
) : (
/* 内容区域 */
<div className="relative z-10 w-full h-full flex flex-col items-center px-16 pt-30 pb-8">
{/* 顶部三个位置 - 更大的框 */}
<div className="w-full flex items-center justify-between">
{[0, 1, 2].map((index) => {
const blankId = `slot${index}`
const filled = value.find((r) => r.blankId === blankId)
const partType = filled ? (filled.optionValue as PartType) : null
const hasPart = partType !== null
return (
<div
key={index}
data-blank-id={blankId}
className={cn(
'relative w-140 h-90 border-4 border-white rounded-lg transition-all duration-200',
hasPart ? 'bg-white/30' : 'bg-white/20 hover:bg-white/30'
)}
>
{/* 背景提示 */}
{!hasPart && index === 0 && (
<img
src={partHintA}
alt="位置A提示"
className="absolute inset-0 w-full h-full object-contain opacity-40 p-4"
/>
)}
{!hasPart && index === 1 && (
<img
src={partHintB}
alt="位置B提示"
className="absolute inset-0 w-full h-full object-contain opacity-40 p-4"
/>
)}
{!hasPart && index === 2 && (
<img
src={partHintC}
alt="位置C提示"
className="absolute inset-0 w-full h-full object-contain opacity-40 p-4"
/>
)}
{/* 已放置的零件 */}
{partType === 'A' && (
<img
src={partA}
alt="零件A"
className="absolute inset-0 w-full h-full object-contain p-4"
/>
)}
{partType === 'B' && (
<img
src={partB}
alt="零件B"
className="absolute inset-0 w-full h-full object-contain p-4"
/>
)}
{partType === 'C' && (
<img
src={partC}
alt="零件C"
className="absolute inset-0 w-full h-full object-contain p-4"
/>
)}
</div>
)
})}
</div>
{/* 中间的"拼接台钳"按钮 */}
<button
onClick={handleAssemble}
disabled={value.length < 3}
className="mt-8 px-20 py-3 bg-yellow-400 border-8 border-white rounded-xl text-[#432800] text-5xl font-bold cursor-pointer transition-all duration-200 hover:bg-yellow-500 hover:scale-105 active:scale-95 disabled:bg-white/50 disabled:cursor-not-allowed"
>
拼接台钳
</button>
{/* 底部零件选择区域 - 使用 DragFill */}
<div className="w-full gap-16">
<div className="text-[#432800] text-5xl font-bold mr-4 my-10">零件区</div>
<div className="flex justify-between">
<DragFill
options={options}
blanks={blanks}
value={value}
onChange={handleValueChange}
allowDuplicate={false}
layout="horizontal-options"
hideLabels={true}
showSubmitButton={false}
className="w-full"
renderOption={(option, isUsed) => {
const partSrc =
option.value === 'A' ? partA : option.value === 'B' ? partB : partC
return (
<div
className={cn(
'flex items-center justify-center transition-all duration-200',
'cursor-move select-none',
isUsed
? 'opacity-30 cursor-not-allowed'
: 'hover:scale-110 opacity-100'
)}
>
<div className="flex flex-col gap-y-4">
<div className="flex items-center justify-center">
<div className="mr-16 text-8xl font-bold text-[#cf9e51] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
{option.value}
</div>
<img
src={partSrc}
alt={`零件${option.value}`}
className="w-56 h-56 object-contain pointer-events-none"
draggable={false}
/>
</div>
<div className="flex items-center justify-center">
<div className="mr-16 text-3xl font-bold text-white">
{option.label}
</div>
</div>
</div>
</div>
)
}}
renderDragging={(option) => {
const partSrc =
option.value === 'A' ? partA : option.value === 'B' ? partB : partC
return (
<div className="w-56 h-56">
<img
src={partSrc}
alt={`零件${option.value}`}
className="w-full h-full object-contain"
draggable={false}
/>
</div>
)
}}
renderBlank={() => {
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
return <div className="hidden" />
}}
/>
</div>
</div>
</div>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
return createPortal(content, document.body)
}
export default ViseAssemblyDialog
+258
View File
@@ -0,0 +1,258 @@
import { useState, useCallback, useRef } from 'react'
import { Title, Image, Cursor, Video, StudyGuideModal } from '@/components'
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
import { SuccessAlert, ErrorAlert } from '@/components/layout'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import TowelSalesDialog from './scene4/TowelSalesDialog'
import type { VideoRef } from '@/components/video'
import { QuizDialog } from './components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { useCursorController } from '@/hooks/useCursorController'
const starName = 'huangyanpei'
const HuangyanpeiReflectionScene4 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [_activeExhibitId, _setActiveExhibitId] = useState<1 | 2 | null>(null)
const [showQuiz1, setShowQuiz1] = useState(false)
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
const [showTowelDialog, setShowTowelDialog] = useState(false)
const [showVideo, setShowVideo] = useState(false)
const videoRef = useRef<VideoRef>(null)
const [showGuide1, setShowGuide1] = useState(true)
const [showErrorAlertForQuiz, setShowErrorAlertForQuiz] = useState(false)
const [showSpeakerGuide, setShowSpeakerGuide] = useState(false)
// 使用持久化的reflection cursor状态
const scene = 'scene4'
const { markReflectionCursorVisited, getVisitedReflectionCursors } = useHuangyanpeiStore()
// 从 store 获取已完成的cursor,转换为 Set 用于兼容现有代码
const visitedCursors = getVisitedReflectionCursors(scene)
const visited = new Set(visitedCursors)
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: { 2: [1], 3: [1, 2] },
visited: visitedCursors,
})
//喇叭考题弹窗
const [showQuizDialog, setShowQuizDialog] = useState(false)
// 处理成功提示的继续和关闭
const handleSuccessAlertContinue = useCallback(() => {
setShowSuccessAlert(false)
// 更新visited状态
markReflectionCursorVisited(scene, 1)
}, [markReflectionCursorVisited, scene])
const handleSuccessAlertClose = useCallback(() => {
setShowSuccessAlert(false)
// 更新visited状态
markReflectionCursorVisited(scene, 1)
}, [markReflectionCursorVisited, scene])
if (!moduleName) {
return null
}
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(`huangyanpei/reflection/scene4/12_001`),
}}
>
<Title
title="跨越时空"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute left-5 top-5"
src={getStaticImage('huangyanpei/reflection/components/喇叭2')}
button
onClick={() => {
if (isEnabled(3)) {
setShowQuizDialog(true)
} else {
setShowErrorAlertForQuiz(true)
}
}}
aria-disabled={!isEnabled(3)}
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
navigate(`/${moduleName}/${starName}/reflection2`)
}}
/>
{/* 互动展品光标1 */}
<Cursor
className="absolute left-100 bottom-88"
onClick={() => {
setShowQuiz1(true)
}}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 互动展品光标2 */}
<Cursor
className="absolute right-82 bottom-88"
onClick={() => {
setShowTowelDialog(true)
}}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 选择题1:中华职校商科的核心教学目标 */}
<MultipleChoiceQuizPanel
visible={showQuiz1}
title={`【多选】中华职校商科的核心教学目标是什么? `}
options={[
{
value: 'A',
label: 'A. 教会学生投机倒把',
},
{
value: 'B',
label: 'B. 培养诚信经营人才',
},
{
value: 'C',
label: 'C. 推广国货支援实业',
},
{
value: 'D',
label: 'D. 只为个人发财致富',
},
]}
correctAnswer={['B', 'C']}
errorMessage="不对哦!黄先生倡导“商科要为民族工商业服务”,商科是要培养诚信经营、助力国货发展的人才,可不能想着投机发财呀,再选一次吧!"
minSelections={1}
onSubmit={(values: string[], isCorrect: boolean) => {
if (isCorrect) {
// 答题成功:关闭选择题,显示成功提示
setShowQuiz1(false)
setShowSuccessAlert(true)
}
}}
/>
{/* 答题成功提示 */}
<SuccessAlert
visible={showSuccessAlert}
title="满分!"
content="诚信是经商之本, 推广国货是商科学生的责任, 这正是黄先生的办学初衷。"
onContinue={handleSuccessAlertContinue}
onClose={handleSuccessAlertClose}
/>
{/* 毛巾推销语拖拽对话框 */}
<TowelSalesDialog
visible={showTowelDialog}
onClose={() => setShowTowelDialog(false)}
onComplete={() => {
// 完成拖拽后更新visited状态
markReflectionCursorVisited(scene, 2)
setShowSpeakerGuide(true)
triggerPuzzleAnimation(15)
}}
/>
{completed && (
<Image
className="absolute bottom-0 right-4"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setShowVideo(true)
}}
/>
)}
{/* 第一段引导 */}
<StudyGuideModal
visible={showGuide1}
onContinue={() => {
setShowGuide1(false)
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_26.mp3')}
firstLineIndent="2em"
>
终于到中华职业学校啦!这里是中国近代职教的摇篮,“免学费、包分配”,还能学真手艺,快来练就一技之长。
{'\n'}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢!
</StudyGuideModal>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath('video/huangyanpei/huangyanpei_09.mp4')}
className="h-full w-full"
autoPlay
showSkipButton
playsInline
onEnded={() => {
setShowVideo(false)
setCourseTimeProgress(11)
navigate(`/${moduleName}/${starName}/home`)
}}
/>
)}
<QuizDialog
visible={showQuizDialog}
onClose={() => setShowQuizDialog(false)}
onComplete={() => {
setShowQuizDialog(false)
markReflectionCursorVisited(scene, 3)
}}
/>
<StudyGuideModal
visible={showSpeakerGuide}
onContinue={() => setShowSpeakerGuide(false)}
verticalAlign="center"
>
请点击左上角的喇叭。
</StudyGuideModal>
{/* 错误提示:请先完成前两个任务 */}
<ErrorAlert
visible={showErrorAlertForQuiz}
text="请先完成前两个任务吧!"
onClose={() => setShowErrorAlertForQuiz(false)}
/>
</div>
)
}
export default HuangyanpeiReflectionScene4
@@ -0,0 +1,348 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { cn, getStaticImage } from '@/lib/utils'
import { Image, DragFill } from '@/components'
import type { DragFillResult } from '@/components'
import { ErrorAlert, SuccessAlert } from '@/components/layout'
export interface TowelSalesDialogProps {
/** 是否可见 */
visible?: boolean
/** 关闭回调 */
onClose?: () => void
/** 完成回调 */
onComplete?: () => void
}
/**
* 毛巾推销语拖拽弹窗
* - 左边显示4个可选话术
* - 右边显示3个毛巾(堆叠显示)
* - 拖拽话术到毛巾上,点亮对应的毛巾
* - 3个都点亮后完成
*/
const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDialogProps) => {
const maskSrc = getStaticImage('components/蒙版遮罩')
const backSrc = getStaticImage('components/关闭')
// 毛巾图片资源(未点亮)
const towel1 = getStaticImage('huangyanpei/reflection/scene4/12_002')
const towel2 = getStaticImage('huangyanpei/reflection/scene4/12_003')
const towel3 = getStaticImage('huangyanpei/reflection/scene4/12_004')
// 毛巾图片资源(点亮)
const towel1Lit = getStaticImage('huangyanpei/reflection/scene4/12_002_02')
const towel2Lit = getStaticImage('huangyanpei/reflection/scene4/12_003_02')
const towel3Lit = getStaticImage('huangyanpei/reflection/scene4/12_004_03')
const [mounted, setMounted] = useState(false)
const [value, setValue] = useState<DragFillResult[]>([])
const [errorVisible, setErrorVisible] = useState(false)
const [successVisible, setSuccessVisible] = useState(false)
const completedRef = useRef(false)
// SSR 兼容
useEffect(() => {
setMounted(true)
}, [])
// 重置游戏状态
useEffect(() => {
if (visible) {
setValue([])
completedRef.current = false
setErrorVisible(false)
setSuccessVisible(false)
}
}, [visible])
// 检查是否完成(3个毛巾都点亮)
useEffect(() => {
if (value.length === 3 && !completedRef.current) {
completedRef.current = true
// 显示成功提示
setSuccessVisible(true)
}
}, [value])
const handleClose = useCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
e.stopPropagation()
onClose?.()
},
[onClose]
)
// 关闭错误提示
const handleErrorClose = useCallback(() => {
setErrorVisible(false)
}, [])
// 关闭成功提示
const handleSuccessContinue = useCallback(() => {
setSuccessVisible(false)
// 关闭提示后,完成并关闭弹窗
onComplete?.()
onClose?.()
}, [onComplete, onClose])
// 可选话术选项
const options = useMemo(
() => [
{
value: 'option1',
label: '这是中华职校生产的国货,质量比洋货还好!',
},
{
value: 'option2',
label: '价格便宜,量大从优,诚信经营绝不掺假',
},
{
value: 'option3',
label: '买国货就是支持实业救国,为国家出一份力!',
},
{
value: 'option4',
label: '我只管赚钱,东西好不好不管哦',
},
],
[]
)
// 转换为 DragFill 的 blanks
const blanks = useMemo(
() => [
{ id: 'towel1', label: '毛巾1' },
{ id: 'towel2', label: '毛巾2' },
{ id: 'towel3', label: '毛巾3' },
],
[]
)
// 处理拖拽结果变化
const handleValueChange = useCallback((newValue: DragFillResult[]) => {
// 检查是否选择了错误的选项(option4)
const hasOption4 = newValue.some((result) => result.optionValue === 'option4')
if (hasOption4) {
// 显示错误提示
setErrorVisible(true)
// 移除 option4,保留其他选项
const filteredValue = newValue.filter((result) => result.optionValue !== 'option4')
setValue(filteredValue)
} else {
// 限制最多3个
if (newValue.length <= 3) {
setValue(newValue)
}
}
}, [])
// 检查毛巾是否被点亮
const isTowelLit = useCallback(
(towelId: string) => {
return value.some((result) => result.blankId === towelId)
},
[value]
)
const getTowelText = useCallback(
(towelId: string) => {
const result = value.find((item) => item.blankId === towelId)
return options.find((option) => option.value === result?.optionValue)?.label
},
[options, value]
)
// 样式
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
position: 'fixed',
inset: 0,
zIndex: 9999,
pointerEvents: 'none',
}),
[]
)
if (!mounted) return null
const content = (
<AnimatePresence>
{visible && (
<motion.div
key="towel-sales-dialog"
style={overlayStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<div
className={cn('absolute inset-0 flex flex-col', 'bg-black/70')}
style={{ pointerEvents: 'auto' }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 内容区域 */}
<div className="relative z-10 w-full h-full flex flex-col px-30 py-16 pt-30 text-[#432800]">
<div className="w-full h-full flex flex-col">
{/* 顶部:题目文本和毛巾区域 */}
<div className="flex justify-between gap-10 mb-8">
{/* 题目文本 */}
<div className="flex-1">
<div className="text-4xl leading-relaxed">
【拖拽】从以下话术里,挑选三句组成你的推销语,放置在国货毛巾上。
</div>
</div>
{/* 右侧:毛巾区域 */}
<div className="w-180 flex flex-col items-center">
{/* 毛巾堆叠区域 */}
<div className="relative w-full flex flex-col">
{/* 毛巾1 */}
<div
data-blank-id="towel1"
className="relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
>
<img
src={isTowelLit('towel1') ? towel1Lit : towel1}
draggable={false}
alt="毛巾1"
className="w-full h-full object-contain"
/>
{getTowelText('towel1') && (
<div className="absolute inset-0 flex items-center justify-center text-center text-3xl font-bold leading-snug text-[#432800] pointer-events-none">
{getTowelText('towel1')}
</div>
)}
</div>
{/* 毛巾2 */}
<div
data-blank-id="towel2"
className="relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
>
<img
src={isTowelLit('towel2') ? towel2Lit : towel2}
draggable={false}
alt="毛巾2"
className="w-full h-full object-contain"
/>
{getTowelText('towel2') && (
<div className="absolute inset-0 flex items-center justify-center text-center text-3xl font-bold leading-snug text-[#432800] pointer-events-none">
{getTowelText('towel2')}
</div>
)}
</div>
{/* 毛巾3 */}
<div
data-blank-id="towel3"
className="relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
>
<img
src={isTowelLit('towel3') ? towel3Lit : towel3}
draggable={false}
alt="毛巾3"
className="w-full h-full object-contain"
/>
{getTowelText('towel3') && (
<div className="absolute inset-0 flex items-center justify-center text-center text-3xl font-bold leading-snug text-[#432800] pointer-events-none">
{getTowelText('towel3')}
</div>
)}
</div>
</div>
</div>
</div>
{/* 底部:可选话术区域 - 使用 DragFill */}
<div className="flex-1">
<div className="text-5xl font-bold mb-6">答题区</div>
<DragFill
options={options}
blanks={blanks}
value={value}
onChange={handleValueChange}
allowDuplicate={false}
layout="horizontal-options"
optionClassName="grid grid-cols-2 gap-10"
hideLabels={true}
showSubmitButton={false}
className="w-full"
renderOption={(option, isUsed) => {
const index = options.findIndex((opt) => opt.value === option.value)
return (
<div
className={cn(
'px-10 py-10 rounded-2xl border-3 text-3xl font-medium transition-all select-none',
isUsed
? 'border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50'
: 'border-[#79471f] bg-black/20 text-white cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)]'
)}
>
{index + 1}. {option.label}
</div>
)
}}
renderDragging={(option) => {
return (
<div className="w-[638px] h-[90px] flex items-center justify-center rounded-2xl border-3 text-2xl font-medium border-[#f0be75] bg-black/40 text-white shadow-[0_12px_40px_rgba(240,190,117,0.7)]">
{option.label}
</div>
)
}}
renderBlank={() => {
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
return <div className="hidden" />
}}
/>
</div>
</div>
</div>
{/* 错误提示 */}
<ErrorAlert
text="哎呀!经商要讲诚信,这句话不符合黄先生倡导的商科精神。"
visible={errorVisible}
onClose={handleErrorClose}
onMaskClick={handleErrorClose}
zIndex={10001}
/>
{/* 成功提示 */}
<SuccessAlert
title="恭喜你, 推销语设计成功!"
content="既突出了国货优势,又传递了爱国情怀,当年的商科学生也是这么做的"
visible={successVisible}
onContinue={handleSuccessContinue}
/>
</div>
</motion.div>
)}
</AnimatePresence>
)
return content
}
export default TowelSalesDialog
+102
View File
@@ -0,0 +1,102 @@
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 { useLuxunStore, LUXUN_LESSON_ID, LUXUN_KEY } from '@/store/luxunStore'
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 Luxun = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// starName 固定为 luxun
const starName = 'luxun'
const lastVisitedPlace = useLuxunStore((state) => state.lastVisitedPlace)
const reflectionRoute = useLuxunStore((state) => state.reflectionRoute)
useEffect(() => {
;(async () => {
try {
const raw = await getUserDefine(LUXUN_LESSON_ID, LUXUN_KEY)
if (!raw) return
const data = JSON.parse(raw) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
reflectionRoute?: string
}
useLuxunStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace:
data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
reflectionRoute:
data.reflectionRoute &&
['reflection', 'reflection2', 'reflection3'].includes(data.reflectionRoute)
? data.reflectionRoute
: (prev.reflectionRoute ?? 'reflection'),
}))
} catch (error) {
console.error('Failed to load Luxun 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}/${reflectionRoute || 'reflection'}`)}
/>
{/* 内化 */}
<Image
button
src={getStaticImage('star/三-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)}
/>
</div>
</div>
)
}
export default Luxun
+560
View File
@@ -0,0 +1,560 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
type DragFillResult,
LetterToLuxun,
Narration,
EndingOverlay,
} 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
// 拖拽题专用:题干后缀(空白区域之后的文本)
promptSuffix?: string
type: 'single' | 'multiple' | 'drag'
options: QuizOption[]
// 拖拽题专用
blanks?: { id: string; label: string }[]
// 拖拽题专用:每个空前面的前缀文案
blankPrefixes?: string[]
// 拖拽题专用:空白之间的分隔符数组
blankSeparators?: string[]
// 拖拽题正确答案映射:blankId -> optionValue
correctAnswersMap?: Record<string, string>
explanation?: string
}
const starName = 'luxun'
// 锚点数据:根据视频时长调整 time
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: 'quiz-1',
time: 60 + 41, // 1:41
title: '',
prompt: '【拖拽】有学者认为鲁迅有一个宏大的知识大众化计划,对于文盲,鲁迅推荐的是',
promptSuffix: '。',
type: 'drag',
blanks: [
{ id: 'blank-1', label: '___' },
{ id: 'blank-2', label: '___' },
{ id: 'blank-3', label: '___' },
],
options: [
{ value: '电影、讲演、戏剧演出', label: '电影、讲演、戏剧演出' },
{ value: '木刻艺术', label: '木刻艺术' },
{
value: '努力推行新文字和文学通俗化',
label: '努力推行新文字和文学通俗化',
},
],
blankSeparators: [';在介于文盲和和识字者之间,鲁迅运用的是', ';鲁迅对作家们的要求是'],
correctAnswersMap: {
'blank-1': '电影、讲演、戏剧演出',
'blank-2': '木刻艺术',
'blank-3': '努力推行新文字和文学通俗化',
},
explanation:
'有学者认为鲁迅有一个宏大的知识大众化计划,对于文盲,鲁迅推荐的是电影、讲演、戏剧演出;在介于文盲和识字者之间,鲁迅运用的是木刻艺术;鲁迅对作家们的要求是努力推行新文字和文学通俗化。',
},
{
id: 'quiz-2',
time: 60 * 5 + 2, // 5:02
title: '',
prompt: '【多选】对于鼓励和培养青年一代,鲁迅先生的做法是:',
type: 'multiple',
options: [
{ value: 'A', label: '组织文学社团', isCorrect: true },
{ value: 'B', label: '木刻运动', isCorrect: true },
{ value: 'C', label: '写纪念烈士文章', isCorrect: true },
// {
// value: "D",
// label: "让青年都去参军",
// },
],
// explanation:
// "虽然鲁迅热爱革命,但他主要通过文学社团、木刻艺术和纪念文章来教育和启发青年,而不是通过参军来培养青年。",
explanation: '请你再想想吧!',
},
{
id: 'quiz-3',
time: 60 * 6 + 39, // 6:39
title: '',
prompt: '【单选】以下那句诗出自鲁迅的作品:',
type: 'single',
options: [
{
value: 'A',
label: '寄意寒星荃不察,我以我血荐轩辕',
isCorrect: true,
explanation: '本句出自鲁迅的《自题小像》',
},
{
value: 'B',
label: '我自横刀向天笑,去留肝胆两昆仑',
explanation: '本句出自谭嗣同的《狱中题壁》,请你再想想吧!',
},
{
value: 'C',
label: '此去泉台招旧部,旌旗十万斩阎罗',
explanation: '本句出自陈毅的《梅岭三章》,请你再想想吧!',
},
{
value: 'D',
label: '雄关漫道真如铁,而今迈步从头越',
explanation: '本句出自毛泽东的《忆秦娥・娄山关》,请你再想想吧!',
},
],
},
{
id: 'quiz-4',
time: 60 * 7 + 59, // 7:59
title: '',
prompt: '【拖拽】鲁迅先生一生为国为民,写下了大量抨击黑暗社会的檄文',
promptSuffix:
',他以无所畏惧的姿态和极大的热情,参加反对封建专制、反对帝国主义侵略和国民党反动统治的实际斗争。',
type: 'drag',
blanks: [
{ id: 'blank-1', label: '___' },
{ id: 'blank-2', label: '___' },
],
blankPrefixes: [',如', '如'],
blankSeparators: [''],
options: [
{ value: '七首', label: '七首' },
{ value: '投枪', label: '投枪' },
{ value: '清泉', label: '清泉' },
],
correctAnswersMap: {
'blank-1': '七首',
'blank-2': '投枪',
},
explanation: '“清泉”不是鲁迅文章的意象。请你再想想吧!',
},
{
id: 'quiz-5',
time: 60 * 9 + 53.5, // 9:53
title: '',
prompt: '【多选】我们能从鲁迅身上学习到:',
type: 'multiple',
options: [
{
value: 'A',
label: '至诚爱国,为民族独立与人民幸福奋斗不息的精神',
isCorrect: true,
},
{
value: 'B',
label: '追求光明、追求真理的人生追求',
isCorrect: true,
},
{
value: 'C',
label: '博大的文化胸襟与崇高的人格力量',
isCorrect: true,
},
],
explanation: '请再仔细分析一下题目,做出更全面的选择吧!',
},
]
const LuxunInternalization = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const [activeSuccess, setActiveSuccess] = useState<string | null>(null)
const [successContent, setSuccessContent] = useState<string>('')
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
// 拖拽题状态
const [dragQuizValues, setDragQuizValues] = useState<Record<string, DragFillResult[]>>({})
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>({})
const [showNarration, setShowNarration] = useState(false) // 是否显示旁白
const [visible, setVisible] = useState(false)
const [showVideo, setShowVideo] = useState(false)
const [showLetter, setShowLetter] = useState(false)
const [showStudyGuide, setShowStudyGuide] = useState(false)
const videoRef = useRef<VideoRef>(null)
//碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
// 旁白关闭后显示对话引导
useEffect(() => {
setShowNarration(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 (!item.blanks || val.length < item.blanks.length) return
// 如果有位置特定的答案映射,使用它进行验证
if (item.correctAnswersMap) {
const isCorrect = item.blanks.every((blank) => {
const filledResult = val.find((v) => v.blankId === blank.id)
const correctAnswer = item.correctAnswersMap![blank.id]
return filledResult?.optionValue === correctAnswer
})
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent('')
setResumeCallback(() => resume)
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: item.explanation || '再想一想,选择正确答案~',
}))
}
} else {
// 如果没有位置特定的映射,使用原来的逻辑(检查是否包含了所有正确答案)
const filledAnswers = val.map((v) => v.optionValue)
const answerSet = new Set(correctAnswers)
const filledSet = new Set(filledAnswers)
const isCorrect =
answerSet.size === filledSet.size &&
[...answerSet].every((answer) => filledSet.has(answer))
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent('')
setResumeCallback(() => resume)
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: item.explanation || '再想一想,选择正确答案~',
}))
}
}
}
return (
<>
<DragQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title=""
questionPrefix={item.prompt}
questionSuffix={item.promptSuffix || ''}
options={item.options.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
blanks={item.blanks || []}
blankPrefixes={item.blankPrefixes}
blankSeparators={item.blankSeparators}
value={dragQuizValues[item.id] || []}
error={dragQuizErrors[item.id]}
onChange={handleDragChange}
/>
<SuccessAlert
visible={activeSuccess === item.id}
// title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
setDragQuizValues((prev) => {
const next = { ...prev }
delete next[item.id]
return next
})
setDragQuizErrors((prev) => {
const next = { ...prev }
delete next[item.id]
return next
})
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
)
},
}
}
// 单选题处理
if (item.type === '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]}
errorMessage="再思考一下,选择正确答案。"
onWrongSelect={(value: string) => {
// 为题目3提供详细的选项解释
if (item.id === 'quiz-3') {
const selectedOption = item.options.find((opt) => opt.value === value)
return selectedOption?.explanation || '再思考一下,选择正确答案。'
}
return undefined
}}
onSubmit={(_value: string, isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id)
// 为题目3提供正确答案的详细解释
if (item.id === 'quiz-3') {
const correctOption = item.options.find((opt) => opt.isCorrect)
setSuccessContent(correctOption?.explanation || '')
} else {
setSuccessContent('')
}
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
// title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
),
}
}
// 多选题处理
if (item.type === 'multiple') {
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => (
<>
<MultipleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers}
errorMessage={item.explanation || '再思考一下,注意选择所有正确答案。'}
onSubmit={(_values: string[], isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent('')
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
// title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
const cb = resumeCallback
setResumeCallback(null)
triggerPuzzleAnimation(16)
cb?.()
}}
/>
</>
),
}
}
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: () => null,
}
}),
[
activeSuccess,
resumeCallback,
successContent,
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`)}
/>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath('video/luxun/luxun_neihua.mp4')}
className="h-full w-full"
anchors={anchors}
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
setShowLetter(true)
// setPostGuideVisible(true);
}}
/>
)}
{/* 旁白 */}
<Narration
visible={showNarration}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_35.mp3')}
text={`走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。那些针砭时弊的杂文、借古讽今的篇章,还有藏在6平方米客房里的红色坚守,都在诉说着先生的热忱。1907年,鲁迅先生在《文化偏至论》里写到:“是故将生存两间,角逐列国是务,其首在立人,人立而后凡事举;若其道术,乃必尊个性而张精神。”`}
onClose={() => {
setShowNarration(false)
setShowVideo(true)
// handleVideoCanPlay()
}}
/>
{/* 对话引导 */}
<GuideSteps
visible={visible}
onVisibleChange={(visible) => {
setVisible(visible)
if (!visible) {
setShowVideo(true)
handleVideoCanPlay()
}
}}
showMask={false}
>
{/* <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_35.mp3')}>
走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
那些针砭时弊的杂文、借古讽今的篇章,还有藏在6平方米客房里的红色坚守,都在诉说着先生的热忱。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1907年,鲁迅先生在《文化偏至论》里写到:“是故将生存两间,角逐列国是务,其首在立人,人立而后凡事举;若其道术,乃必尊个性而张精神。”
</GuideStepsItem>
<GuideStepsItem speaker="guide">
先生认为,中华民族要屹立于世界民族之林,“首在立人”,“立人”思想从此贯穿在鲁迅先生的人生和作品中。”先生认为,中华民族要屹立于世界民族之林,“首在立人”,“立人”思想从此贯穿在鲁迅先生的人生和作品中。
</GuideStepsItem> */}
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_36.mp3')}>
小沪,关于“首在立人”这四个字我还是有些不理解,立人为什么这么重要?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_37.mp3')}>
“首在立人”这四个字,可以说是鲁迅先生一生的价值观。今天我们有幸邀请到了上海鲁迅纪念馆研究馆员施晓燕。关于你的问题,我想施老师会给你指点迷津!
</GuideStepsItem>
</GuideSteps>
{/* 视频结束后的引导 */}
{/* <GuideSteps
visible={postGuideVisible}
onVisibleChange={(visible) => {
setPostGuideVisible(visible);
if (!visible) {
setShowLetter(true);
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem speaker="guide">
听完施老师的讲解,相信你对鲁迅先生的"立人"精神有了更深的理解。现在,就请你把这份感悟写下来,既总结参观的收获,也给自己未来许下一个承诺吧!
</GuideStepsItem>
</GuideSteps> */}
{/* 信件组件 */}
<LetterToLuxun
visible={showLetter}
onClose={() => {
setShowLetter(false)
}}
onAnimationEnd={() => {
setShowStudyGuide(true)
}}
showVideo={true}
/>
<EndingOverlay
visible={showStudyGuide}
text="恭喜你圆满完成鲁迅故居的学习之旅,请带着研学感悟,前往黄炎培故居继续探访吧~"
animationSpeed={200}
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(9)
navigate('/menu')
}}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_38.mp3')}
/>
</div>
)
}
export default LuxunInternalization
+365
View File
@@ -0,0 +1,365 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
Cursor,
StudyGuideModal,
} from '@/components'
import { MatchQuizPanel, MultipleChoiceQuizPanel } from '@/components/layout/quiz'
import { SuccessAlert } from '@/components/layout'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState } from 'react'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
// starName 固定为 luxun
const starName = 'luxun'
// 展品 ID 与资源路径映射,ID 用来做唯一标识
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'luxun/place1/展品1',
2: 'luxun/place1/展品2',
}
const LuxunPlace1 = () => {
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 [visible, setVisible] = useState(false)
const [visibleIndex, setVisibleIndex] = useState(0)
// 打开展品2后的对话显示状态
const [showAfterExhibitDialog, setShowAfterExhibitDialog] = useState(false)
const [afterExhibitDialogStep, setAfterExhibitDialogStep] = useState(0)
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 使用持久化的访问状态
const place = 'place1'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useLuxunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
// 连线题显示状态(展品1关闭后)
const [showMatchQuiz, setShowMatchQuiz] = useState(false)
// 连线题成功提示状态
const [showMatchQuizSuccess, setShowMatchQuizSuccess] = useState(false)
const [showExhibit2Quiz, setShowExhibit2Quiz] = useState(false)
const [showExhibit2QuizSuccess, setShowExhibit2QuizSuccess] = useState(false)
// Cursor 顺序控制:1 -> 2
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
},
visited: visited,
})
const handleOpenExhibit = (index: 1 | 2) => {
if (index === 1) {
setVisibleIndex(0)
setVisible(true)
}
if (index === 2) {
setAfterExhibitDialogStep(0)
setShowAfterExhibitDialog(true)
}
setActiveExhibitId(index)
}
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
// 当两个展品都浏览完成后,自动显示对话
// useEffect(() => {
// if (completed && !afterExhibitDialogShown) {
// setShowAfterExhibitDialog(true);
// setAfterExhibitDialogShown(true);
// }
// }, [completed, afterExhibitDialogShown]);
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`luxun/place1/背景`),
}}
>
{/* 标题 */}
<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-[calc(50%-48px)] top-80"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 展品2 */}
<Cursor
className="absolute left-[calc(50%)] bottom-60"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 继续前进 - 仅在完成所有展品且对话结束后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place2')
saveLuxunStore()
navigate(`/${moduleName}/${starName}/place2`)
}}
/>
)}
{/* 遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(1)
}
if (activeExhibitId === 2) {
triggerPuzzleAnimation(2)
}
}}
// description={
// activeExhibitId == 1
// ? `1892-1898年,少年时期在绍兴三味书屋求学积淀;\n1919-1923年,于北京八道湾参加新文化运动、酝酿《阿Q正传》;\n1926年9-12月,赴厦门大学任教期间聚焦底层创作杂文;\n1927年3-9月,广州白云楼整理旧作定稿《朝花夕拾》部分篇章。`
// : undefined
// }
onClose={() => {
if (activeExhibitId !== null) {
// 如果是展品1关闭,显示连线题
if (activeExhibitId === 1) {
setShowMatchQuiz(true)
} else {
setShowExhibit2Quiz(true)
}
}
setActiveExhibitId(null)
}}
/>
{/* 学习引导词 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false)
if (!visible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true)
setShowAfterDialogGuide(true)
}
}}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_02.mp3')}
ailgn="left"
>
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
{'\n'}
2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
{'\n'}
3.所有动态光点的展品全部学习后才能进入下一个场景学习。
</StudyGuideModal>
{/* 引导 */}
<GuideSteps
visible={visible}
currentStep={visibleIndex}
onStepChange={setVisibleIndex}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible)
}}
showMask={false}
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_03.mp3')}>
小沪!我知道鲁迅是1933年搬到大陆新村9号的,那在这之前他为什么会选择来到上海,又有哪些思想转变呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_04.mp3')}>
你问得很关键!从1892-1898年少年时期在绍兴三味书屋求学积淀,到1919-1923年于北京八道湾参加新文化运动、酝酿《阿Q正传》,再到1926年9-12月赴厦门大学任教期间聚焦底层创作杂文。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1927年3-9月广州白云楼整理旧作定稿《朝花夕拾》部分篇章,鲁迅的创作足迹跨越南北,而1927年是鲁迅思想的重要转折点,这一年他在广州亲历了“四一二”反革命政变,亲眼看见进步青年和革命志士倒在血泊中,曾经对“温和改革”的幻想彻底破灭。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
此前他写下《狂人日记》《阿Q正传》批判封建礼教,参加语丝社针砭时弊,但始终在“彷徨”中摸索救国之路。广州的血雨腥风,让他认清了反动派的真面目。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_01.mp3')}
>
{`同学你好,欢迎来到“走进故居·对话”学习模块!
此刻你正立在鲁迅故居的一楼客厅里,接下来请随旧时光的纹路,一同翻开先生思想长卷里的5段深刻轨迹:风雨前夕的思想淬炼、杂文集里的匕首与投枪、历史新编中的现实呐喊、为亡友编纂的精神丰碑、6平方米的红色庇护所。准备好共赴这场笔尖与灵魂的滚烫相遇了吗?`}
</StudyGuideModal>
{/* 浏览完展品后的对话 */}
<GuideSteps
visible={showAfterExhibitDialog}
currentStep={afterExhibitDialogStep}
onStepChange={setAfterExhibitDialogStep}
onVisibleChange={(nextVisible) => {
setShowAfterExhibitDialog(nextVisible)
}}
showMask={false}
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_05.mp3')}>
这张照片也是鲁迅在上海期间的住所吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_06.mp3')}>
不错!1927年10月,鲁迅带着许广平来到上海,先住进了虹口区横浜路景云里。在景云里的两年半,他结识了冯雪峰等共产党员,参与发起中国左翼作家联盟,从"独战的英雄"转变为革命文艺战线的旗手。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这段时间他写下《三闲集》《二心集》,表达他在当时历史条件下针对社会现象、文化问题和论战的看法与社会批判,此间还翻译了大量马列文艺理论著作,为此后的战斗打下了坚实思想基础。
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_07.mp3')}>
鲁迅在发起成立左联后,为躲避国民党当局监视,1930年5月,与许广平、周海婴迁居到拉摩斯公寓。1933年4月,受战火纷扰影响,为安全及健康计,同时也为了更方便开展革命文化工作,鲁迅以"内山书店职员"的名义,搬到了大陆新村9号。
</GuideStepsItem>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_08.mp3')}>
这个过程的确十分曲折。
</GuideStepsItem>
</GuideSteps>
{/* 连线题(展品1关闭后) */}
<MatchQuizPanel
visible={showMatchQuiz}
title="【连线】请将左侧的日期与右侧对应的事件进行连线:"
leftItems={[
{ id: '1', label: '1892-1898年' },
{ id: '2', label: '1919-1923年' },
{ id: '3', label: '1926年9-12月' },
{ id: '4', label: '1927年3-9月' },
]}
rightItems={[
{ id: 'a', label: '在绍兴三味书屋求学积淀' },
{ id: 'b', label: '于北京八道湾参加新文化运动、酝酿《阿Q正传》' },
{ id: 'c', label: '赴厦门大学任教,聚焦底层创作杂文' },
{ id: 'd', label: '在广州白云楼整理旧作定稿《朝花夕拾》部分篇章' },
]}
correctMatches={[
{ leftId: '1', rightId: 'a' },
{ leftId: '2', rightId: 'b' },
{ leftId: '3', rightId: 'c' },
{ leftId: '4', rightId: 'd' },
]}
errorMessage="连线不正确,请重新尝试!"
onSubmit={(matches, isCorrect) => {
if (isCorrect) {
setShowMatchQuiz(false)
setShowMatchQuizSuccess(true)
}
}}
zIndex={10001}
/>
{/* 连线题成功提示 */}
<SuccessAlert
title="恭喜你,匹配正确!"
visible={showMatchQuizSuccess}
onContinue={() => {
setShowMatchQuizSuccess(false)
// 标记展品1为已访问
markExhibitVisited(place, 1)
}}
onClose={() => {
setShowMatchQuizSuccess(false)
// 标记展品1为已访问
markExhibitVisited(place, 1)
}}
/>
<MultipleChoiceQuizPanel
visible={showExhibit2Quiz}
title="【多选】鲁迅先生居住在上海景云里的两年半,写下的作品包括:"
options={[
{ value: 'A', label: 'A. 阿 Q 正传' },
{ value: 'B', label: 'B. 三闲集' },
{ value: 'C', label: 'C. 二心集' },
{ value: 'D', label: 'D. 朝花夕拾' },
]}
correctAnswer={['B', 'C']}
errorMessage={(selectedValues) => {
const picked = new Set(selectedValues)
if (picked.has('A')) {
return '《阿 Q 正传》创作于 1921—1922 年,最初发表于《晨报副刊》,早于鲁迅居住在上海景云里时期。请你再想想吧!'
}
if (picked.has('D')) {
return '《朝花夕拾》创作于 1926 年,最初以《旧事重提》为总题发表,早于鲁迅居住在上海景云里时期。请你再想想吧!'
}
return '请你再想想吧!'
}}
onSubmit={(_values, isCorrect) => {
if (isCorrect) {
setShowExhibit2Quiz(false)
setShowExhibit2QuizSuccess(true)
}
}}
zIndex={10001}
/>
<SuccessAlert
title="恭喜你,选择正确!"
visible={showExhibit2QuizSuccess}
onContinue={() => {
setShowExhibit2QuizSuccess(false)
markExhibitVisited(place, 2)
}}
onClose={() => {
setShowExhibit2QuizSuccess(false)
markExhibitVisited(place, 2)
}}
/>
</div>
)
}
export default LuxunPlace1
+356
View File
@@ -0,0 +1,356 @@
import { ExhibitOverlay, Image, Title, Cursor, GuideSteps, GuideStepsItem } from '@/components'
import { DragQuizPanel } from '@/components/layout/quiz'
import { SuccessAlert } from '@/components/layout'
import ScaledPortal from '@/components/layout/ScaledPortal'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState } from 'react'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import type { DragFillResult } from '@/components'
import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
// starName 固定为 luxun
const starName = 'luxun'
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: 'luxun/place2/展品3',
2: 'luxun/place2/展品4',
3: 'luxun/place2/展品5',
}
const LuxunPlace2 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
//碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
// 使用持久化的访问状态
const place = 'place2'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useLuxunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
// Cursor 顺序控制:1 -> 2 -> 3
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [3],
3: [1],
},
visited: visited,
})
// 光标1的引导对话状态
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
// 光标2的引导对话状态
const [guide2Visible, setGuide2Visible] = useState(false)
const [guide2Step, setGuide2Step] = useState(0)
// 光标3的引导对话状态
const [guide3Visible, setGuide3Visible] = useState(false)
const [guide3Step, setGuide3Step] = useState(0)
// 拖拽题显示状态(包含那句话的步骤结束后)
const [showDragQuiz, setShowDragQuiz] = useState(false)
// 标记是否已经显示过题目(避免重复显示)
const [quizShown, setQuizShown] = useState(false)
// 拖拽题的值
const [dragQuizValue, setDragQuizValue] = useState<DragFillResult[]>([])
// 拖拽题错误提示
const [dragQuizError, setDragQuizError] = useState<string>('')
// 拖拽题成功提示状态
const [showDragQuizSuccess, setShowDragQuizSuccess] = useState(false)
const handleOpenExhibit = (index: 1 | 2 | 3) => {
if (index === 1) {
// 光标1:点击时同时打开展品和引导对话
setActiveExhibitId(1)
setGuide1Visible(true)
setGuide1Step(0)
} else if (index === 2) {
// 光标2:点击时同时打开展品和引导对话
setActiveExhibitId(2)
setGuide2Visible(true)
setGuide2Step(0)
} else if (index === 3) {
setActiveExhibitId(3)
setGuide3Visible(true)
setGuide3Step(0)
setQuizShown(false) // 重置题目显示标记
} else {
// 光标3:直接打开
setActiveExhibitId(index)
}
}
// 监听光标1的引导对话步骤变化
const handleGuide1StepChange = (step: number) => {
setGuide1Step(step)
}
// 监听光标1的引导对话关闭
const handleGuide1VisibleChange = (visible: boolean) => {
setGuide1Visible(visible)
}
// 监听光标2的引导对话步骤变化
const handleGuide2StepChange = (step: number) => {
setGuide2Step(step)
}
// 监听光标2的引导对话关闭
const handleGuide2VisibleChange = (visible: boolean) => {
setGuide2Visible(visible)
}
// 监听光标3的引导对话步骤变化
const handleGuide3StepChange = (step: number) => {
// 在步骤2(包含那句话的步骤)结束后,进入步骤3前先弹题
if (step === 3 && !quizShown) {
setGuide3Step(2) // 保持在步骤2
setShowDragQuiz(true)
setQuizShown(true)
return
}
setGuide3Step(step)
}
const progressCount = visited.size
const total = 3
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`luxun/place2/背景`),
}}
>
<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 right-218 bottom-104"
size={0.4}
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute right-225 bottom-95"
size={0.4}
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute right-232 bottom-84"
size={0.4}
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{completed && (
<Image
className="absolute bottom-10 left-42"
src={getStaticImage('luxun/place2/前进')}
button
onClick={() => {
setLastVisitedPlace('place3')
saveLuxunStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(3)
}
if (activeExhibitId === 3) {
triggerPuzzleAnimation(4)
}
if (activeExhibitId === 2) {
triggerPuzzleAnimation(5)
}
}}
// description={
// activeExhibitId === 3
// ? '这篇《中国人失掉自信力了吗》收录于《且介亭杂文》中,它将"匕首与投枪"的特质发挥到极致。当时国民党当局接连丧权辱国,一些文人却散布"中国人失掉自信力"的悲观论调,迎合反动派的卖国行径。鲁迅直接驳斥这种谬论,一针见血指出:"我们从古以来,就有埋头苦干的人,有拼命硬干的人,有为民请命的人,有舍身求法的人……这就是中国的脊梁。"文字如利刃般戳破虚假论调,揭露了反动派掩盖自身无能、麻痹民众的阴谋,唤醒国人的民族自信,尽显战斗文字的锋芒与力量。'
// : undefined
// }
onClose={() => {
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}}
/>
{/* 光标1的引导对话 */}
<GuideSteps
visible={guide1Visible}
onVisibleChange={handleGuide1VisibleChange}
currentStep={guide1Step}
onStepChange={handleGuide1StepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_09.mp3')}>
小沪,这里的《且介亭杂文》就是鲁迅先生在大陆新村9号创作的吧,有人将先生的杂文评价为"匕首与投枪",《且介亭杂文》中有具体体现吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_10.mp3')}>
当然有!20世纪30年代,国民党"文化围剿"愈烈,进步思想遭严控,鲁迅在大陆新村9号创作出《且介亭杂文》等集子。这些杂文聚焦文化乱象与社会沉疴,篇幅精炼却直击要害,像匕首般刺穿虚伪表象,似投枪般直戳问题核心。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
用锋利文字与黑暗势力对峙,成为那个时代最具战斗力的思想武器,“匕首与投枪”的赞誉恰是其战斗性的生动写照。
</GuideStepsItem>
</GuideSteps>
{/* 光标2的引导对话 */}
<GuideSteps
visible={guide2Visible}
onVisibleChange={handleGuide2VisibleChange}
currentStep={guide2Step}
onStepChange={handleGuide2StepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_13.mp3')}>
那《南腔北调集》这个名字又有什么深意呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_14.mp3')}>
这是鲁迅的自嘲,他曾说“我不会说绵软的苏白,不会打响亮的京腔,不入调,不入流”,所以戏称自己的文章是“南腔北调”。这本书里的《〈守常全集》题记》,赞美了李大钊同志,说“他的遗文却将永驻,因为这是先驱者的遗产,革命史上的丰碑。”
</GuideStepsItem>
</GuideSteps>
{/* 光标3的引导对话 */}
<GuideSteps
visible={guide3Visible}
onVisibleChange={setGuide3Visible}
currentStep={guide3Step}
onStepChange={handleGuide3StepChange}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_11.mp3')}>
我们初中学的《中国人失掉自信力了吗》就是收录于《且介亭杂文》中的!
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_12.mp3')}>
那你应该更有体会!这篇《中国人失掉自信力了吗》是将“匕首与投枪”的特质发挥到极致。当时国民党当局接连丧权辱国,一些文人却散布“中国人失掉自信力”的悲观论调,迎合反动派的卖国行径。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
鲁迅直接驳斥这种谬论,一针见血指出:“我们从古以来,就有埋头苦干的人,有拼命硬干的人,有为民请命的人,有舍身求法的人……这就是中国的脊梁。”
</GuideStepsItem>
<GuideStepsItem speaker="guide">
文字如利刃般戳破虚假论调,揭露了反动派掩盖自身无能、麻痹民众的阴谋,唤醒国人的民族自信,尽显战斗文字的锋芒与力量。
</GuideStepsItem>
</GuideSteps>
{/* 拖拽题和成功提示(使用 ScaledPortal 保证层级与缩放) */}
<ScaledPortal enabled={showDragQuiz || showDragQuizSuccess} zIndex={10050}>
<>
<DragQuizPanel
visible={showDragQuiz}
title=""
questionPrefix="【拖拽】我们从古以来,就有"
questionSuffix="......这就是中国的脊梁。"
options={[
{ value: '埋头苦干的人', label: 'A. 埋头苦干的人' },
{ value: '拼命硬干的人', label: 'B. 拼命硬干的人' },
{ value: '为民请命的人', label: 'C. 为民请命的人' },
{ value: '舍身求法的人', label: 'D. 舍身求法的人' },
]}
blanks={[
{ id: 'blank-1', label: '' },
{ id: 'blank-2', label: '' },
{ id: 'blank-3', label: '' },
{ id: 'blank-4', label: '' },
]}
blankSeparators={[',有', ',有', ',有']}
value={dragQuizValue}
error={dragQuizError}
onChange={(value) => {
setDragQuizValue(value)
setDragQuizError('')
// 检查是否所有空白都已填充
if (value.length < 4) return
// 验证顺序:必须是 ABCD 顺序
const correctOrder = ['埋头苦干的人', '拼命硬干的人', '为民请命的人', '舍身求法的人']
const isCorrect = value.every((v, index) => {
return v.optionValue === correctOrder[index]
})
if (isCorrect) {
// 答题成功
setShowDragQuiz(false)
setShowDragQuizSuccess(true)
} else {
// 答题失败:顺序不对
setDragQuizError('请你按照鲁迅先生《中国人失掉自信力了吗》的原文拖拽哦~')
}
}}
zIndex={10000}
/>
<SuccessAlert
visible={showDragQuizSuccess}
onContinue={() => {
setShowDragQuizSuccess(false)
setDragQuizValue([])
setDragQuizError('')
// 题目完成后,继续对话到步骤3
setGuide3Visible(true)
setGuide3Step(3)
}}
onClose={() => {
setShowDragQuizSuccess(false)
setDragQuizValue([])
setDragQuizError('')
// 题目完成后,继续对话到步骤3
setGuide3Visible(true)
setGuide3Step(3)
}}
/>
</>
</ScaledPortal>
</div>
)
}
export default LuxunPlace2
+488
View File
@@ -0,0 +1,488 @@
import {
ExhibitOverlay,
Image,
Title,
Cursor,
GuideSteps,
GuideStepsItem,
SingleChoiceQuizPanel,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState } from 'react'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
// starName 固定为 luxun
const starName = 'luxun'
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2 | 3 | 4 | 5, string> = {
1: 'luxun/place3/6',
2: 'luxun/place3/7',
3: 'luxun/place3/8',
4: 'luxun/place3/9',
5: 'luxun/place3/10',
}
const quiz2DefaultError = '再想一想,注意题干中的时间和提示信息哦~'
const quiz2Explanations: Record<string, string> = {
A: '鲁迅身处旧中国文艺思惰僵化、缺乏革新活力的时代,他认为“没有拿来的,人不能自成为新人”。翻译外国作品正是为了打破文化封闭,为中国文艺引进新思想、新方法,为沉寂的文坛注入新血液。',
B: '当时中国青年普遍受传统旧思想束缚,接触先进文化的渠道有限。鲁迅希望通过翻译外国进步作品,让青年接触世界范围内的先进文化理念、思想成果,拓宽视野、启发心智,助力青年觉醒。',
C: '鲁迅的核心斗争方式是“以笔为刃”,通过文学创作、翻译、杂文批判等方式开展思想启蒙和文化斗争,从未直接参与武装革命的战略部署。',
D: '在国民党“文化围剿”、进步思想遭严控的背景下,鲁迅将翻译视为重要的斗争武器——通过引进外国进步文学、马克思主义理论等,揭露旧中国的黑暗与愚昧,唤醒民众的思想意识,助力思想启蒙运动。',
}
// 光标5-题目:纪念瞿秋白的译文集
const quiz5DefaultError = '再想一想,结合前面的旁白内容哦~'
const quiz5Explanations: Record<string, string> = {
A: '《仿自由书》是鲁迅在上海创作的杂文集,收录了1933年所作的多篇杂文,核心内容是针砭国民党当局的虚伪统治、批判社会乱象,并非为纪念瞿秋白编校的译文集。',
B: '《南腔北调集》是鲁迅的一部杂文集,收录了1927年至1933年的杂文作品,书名源于鲁迅对自己文章“不入调、不入流”的自嘲,内容涵盖文化批判、社会评论等,与纪念瞿秋白及译文集无关。',
C: '恭喜你,选对啦。',
D: '《故事新编》是鲁迅的历史小说集,酝酿多年后在大陆新村9号完成定稿。作品以神话传说、历史典故为载体借古讽今,批判现实社会的腐朽与愚昧,并非译文集,更与纪念瞿秋白无关。',
}
const LuxunPlace3 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
//碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | 5 | null>(null)
// 使用持久化的访问状态
const place = 'place3'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useLuxunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
// Cursor 顺序控制:1 -> 2 -> 3 -> 4 -> 5
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
3: [2],
4: [3],
5: [4],
},
visited: visited,
})
// 光标1的引导对话状态
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
// 光标2的引导对话状态
const [guide2Visible, setGuide2Visible] = useState(false)
const [guide2Step, setGuide2Step] = useState(0)
// 光标2的题目状态
const [quiz2Visible, setQuiz2Visible] = useState(false)
const [successAlert2Visible, setSuccessAlert2Visible] = useState(false)
const [quiz2Error, setQuiz2Error] = useState(quiz2DefaultError)
// 光标3的引导对话状态
const [guide3Visible, setGuide3Visible] = useState(false)
const [guide3Step, setGuide3Step] = useState(0)
// 光标4的引导对话状态
const [guide4Visible, setGuide4Visible] = useState(false)
const [guide4Step, setGuide4Step] = useState(0)
// 光标5的题目 & 成功提示 & 引导对话状态
const [quiz5Visible, setQuiz5Visible] = useState(false)
const [quiz5Error, setQuiz5Error] = useState(quiz5DefaultError)
const [successAlert5Visible, setSuccessAlert5Visible] = useState(false)
const [guide5Visible, setGuide5Visible] = useState(false)
const [guide5Step, setGuide5Step] = useState(0)
const handleOpenExhibit = (index: 1 | 2 | 3 | 4 | 5) => {
if (index === 1) {
// 光标1:点击时同时打开展品和引导对话
setActiveExhibitId(1)
setGuide1Visible(true)
setGuide1Step(0)
} else if (index === 2) {
// 光标2:只打开展品(带描述)
setActiveExhibitId(2)
setGuide2Visible(true)
setGuide2Step(0)
} else if (index === 3) {
// 光标3:点击时同时打开展品和引导对话
setActiveExhibitId(3)
setGuide3Visible(true)
setGuide3Step(0)
} else if (index === 4) {
// 光标4:点击时同时打开展品和引导对话
setActiveExhibitId(4)
setGuide4Visible(true)
setGuide4Step(0)
} else {
// 光标5:先打开展品,关闭后进入答题
setActiveExhibitId(index)
}
}
const progressCount = visited.size
const total = 5
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`luxun/place3/背景`),
}}
>
<Title
title={`历史新编中的现实呐喊\n为亡友编纂的精神丰碑`}
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-114 bottom-130"
onClick={() => handleOpenExhibit(1)}
size={0.6}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute right-130 bottom-115"
onClick={() => handleOpenExhibit(2)}
size={0.6}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute left-76 bottom-140"
onClick={() => handleOpenExhibit(3)}
size={0.6}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 光标4 */}
<Cursor
className="absolute right-238 bottom-125"
onClick={() => handleOpenExhibit(4)}
size={0.6}
read={visited.has(4)}
disabled={!isEnabled(4)}
/>
{/* 光标5 */}
<Cursor
className="absolute right-258 bottom-118"
onClick={() => handleOpenExhibit(5)}
size={0.6}
read={visited.has(5)}
disabled={!isEnabled(5)}
/>
{completed && (
<Image
className="absolute bottom-0 right-130"
src={getStaticImage('luxun/place3/前进按钮')}
button
onClick={() => {
setLastVisitedPlace('place4')
saveLuxunStore()
navigate(`/${moduleName}/${starName}/place4`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(6)
}
if (activeExhibitId === 2) {
triggerPuzzleAnimation(7)
}
if (activeExhibitId === 3) {
triggerPuzzleAnimation(8)
}
if (activeExhibitId === 4) {
triggerPuzzleAnimation(9)
}
if (activeExhibitId === 5) {
triggerPuzzleAnimation(10)
}
}}
description={
activeExhibitId === 5
? '为了妥善出版亡友遗著,鲁迅亲自选稿、校对、设计封面。为了躲避反动派的审查,他还特意用“诸夏怀霜社”的名义出版,“怀霜”就是为了纪念瞿秋白。1936年《海上述林》上卷出版时,鲁迅的身体已经极度虚弱,但他仍一遍遍翻看样书,说“这是我对秋白最好的纪念”。'
: undefined
}
audioSrc={activeExhibitId === 5 ? getPublicPath('audio/luxun/mp3_luxun_23.mp3') : undefined}
onClose={() => {
if (activeExhibitId === 2) {
setActiveExhibitId(null)
} else if (activeExhibitId === 5) {
// 光标5:关闭展品框后显示题目
setActiveExhibitId(null)
setQuiz5Visible(true)
} else if (activeExhibitId === 1) {
// 光标1:只关闭展品,不标记为已读(等对话关闭时再标记)
setActiveExhibitId(null)
} else if (activeExhibitId === 3) {
setActiveExhibitId(null)
setQuiz2Visible(true)
} else if (activeExhibitId === 4) {
// 光标4:只关闭展品,不标记为已读(等对话关闭时再标记)
setActiveExhibitId(null)
} else {
// 其他光标:正常关闭并标记为已读
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}
}}
/>
{/* 光标1的引导对话 */}
<GuideSteps
visible={guide1Visible}
onVisibleChange={(visible) => {
setGuide1Visible(visible)
if (!visible) {
// 对话关闭时,标记光标1为已读
markExhibitVisited(place, 1)
}
}}
currentStep={guide1Step}
onStepChange={setGuide1Step}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_15.mp3')}>
小沪,书架上还有《故事新编》,这不是历史小说吗?鲁迅为什么要在这个时期写古代故事呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_16.mp3')}>
《故事新编》是鲁迅酝酿十几年的作品,最终在大陆新村9号完成定稿。表面上写的是神话传说和历史典故,实则句句都在影射现实。当时反动派妄图用复古思想麻痹民众,鲁迅就用"故事新编"的方式,借古讽今,唤醒民众。
</GuideStepsItem>
</GuideSteps>
{/* 光标2的引导对话 */}
<GuideSteps
visible={guide2Visible}
onVisibleChange={(visible) => {
setGuide2Visible(visible)
if (!visible) {
markExhibitVisited(place, 2)
}
}}
currentStep={guide2Step}
onStepChange={setGuide2Step}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_17.mp3')}>
这本书里面还收录了《理水》,是大禹治水的故事吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_18.mp3')}>
不错,文章写的是大禹治水“三过家门而不入”的上古传说,实则藏着对现实的尖锐针砭。当时反动派统治腐朽,面对内忧外患只会粉饰太平,还有一群空谈理论、脱离实际的“学者”混淆视听。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
大禹带领民众跋山涉水、实地勘察,摒弃“堵”的旧法改用“疏”的良方,历经千辛万苦终平水患。这哪里是写古代治水?分明是在赞颂脚踏实地、敢破敢立的实干精神,讽刺那些庸碌无为的当权者与空谈家,用远古的正义之光映照现实的黑暗。
</GuideStepsItem>
</GuideSteps>
{/* 光标3的题目 */}
<SingleChoiceQuizPanel
visible={quiz2Visible}
title="【单选】鲁迅在上海大陆新村9号居住期间,翻译外国作品的核心目的不包括:"
options={[
{
value: 'A',
label: 'A.为中国文艺引进新思想、新血液',
},
{
value: 'B',
label: 'B.让中国青年接触更先进的文化理念',
},
{
value: 'C',
label: 'C.直接参与武装革命的战略部署',
},
{
value: 'D',
label: 'D.以翻译为武器助力思想启蒙',
},
]}
correctAnswer="C"
errorMessage={quiz2Error}
onSubmit={(value, isCorrect) => {
if (isCorrect) {
setQuiz2Error(quiz2DefaultError)
setQuiz2Visible(false)
setSuccessAlert2Visible(true)
}
}}
onWrongSelect={(value) => {
const explanation = quiz2Explanations[value]
if (explanation) {
setQuiz2Error(explanation)
return explanation
}
setQuiz2Error(quiz2DefaultError)
return quiz2DefaultError
}}
zIndex={10001}
/>
{/* 光标3的成功提示 */}
<SuccessAlert
visible={successAlert2Visible}
content={
'鲁迅的核心斗争方式是“以笔为刃”,通过文学创作、翻译、杂文批判等方式开展思想启蒙和文化斗争,从未直接参与武装革命的战略部署。'
}
onContinue={() => {
setSuccessAlert2Visible(false)
markExhibitVisited(place, 3)
}}
/>
{/* 光标3的引导对话 */}
<GuideSteps
visible={guide3Visible}
onVisibleChange={(visible) => {
setGuide3Visible(visible)
}}
currentStep={guide3Step}
onStepChange={setGuide3Step}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_19.mp3')}>
我还看到书架上有很多翻译作品,这也是他在这儿完成的吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_20.mp3')}>
没错!鲁迅在这儿翻译了《死魂灵》《表》等大量外国作品。他说“没有拿来的,人不能自成为新人”,就是想通过翻译,为中国文艺引进新思想、新方法,让更多人看到世界的真相,找到救国的道路。
</GuideStepsItem>
</GuideSteps>
{/* 光标4的引导对话 */}
<GuideSteps
visible={guide4Visible}
onVisibleChange={(visible) => {
setGuide4Visible(visible)
if (!visible) {
// 对话关闭时,标记光标4为已读
markExhibitVisited(place, 4)
}
}}
currentStep={guide4Step}
onStepChange={setGuide4Step}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_21.mp3')}>
小沪,这两本书看起来格外厚重,它和鲁迅在这儿的经历有什么关联呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_22.mp3')}>
这不是鲁迅的著作,却是他在大陆新村9号倾注了全部心血的作品——为亡友瞿秋白编校的译文集《海上述林》。1935年6月,瞿秋白被国民党反动派杀害,鲁迅悲痛万分,当即决定要为挚友出版译文集,让他的思想和精神流传下去。
</GuideStepsItem>
</GuideSteps>
{/* 光标5的题目 */}
<SingleChoiceQuizPanel
visible={quiz5Visible}
title="【单选】鲁迅在上海大陆新村9号居住期间,为纪念瞿秋白编校的译文集是:"
options={[
{ value: 'A', label: 'A.《仿自由书》' },
{ value: 'B', label: 'B.《南腔北调集》' },
{ value: 'C', label: 'C.《海上述林》' },
{ value: 'D', label: 'D.《故事新编》' },
]}
correctAnswer="C"
errorMessage={quiz5Error}
onSubmit={(value, isCorrect) => {
if (isCorrect) {
setQuiz5Error(quiz5DefaultError)
setQuiz5Visible(false)
setSuccessAlert5Visible(true)
}
}}
onWrongSelect={(value) => {
const explanation = quiz5Explanations[value]
if (explanation) {
setQuiz5Error(explanation)
return explanation
}
setQuiz5Error(quiz5DefaultError)
return quiz5DefaultError
}}
zIndex={10001}
/>
{/* 光标5的成功提示 */}
<SuccessAlert
visible={successAlert5Visible}
onContinue={() => {
setSuccessAlert5Visible(false)
// 标记光标5为已读
markExhibitVisited(place, 5)
// 打开光标5的引导对话
setGuide5Visible(true)
setGuide5Step(0)
}}
/>
{/* 光标5的引导对话 */}
<GuideSteps
visible={guide5Visible}
onVisibleChange={(visible) => {
setGuide5Visible(visible)
}}
currentStep={guide5Step}
onStepChange={setGuide5Step}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_24.mp3')}>
原来这本书不仅是译文集,更是两人革命友谊和共同信仰的见证!
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_25.mp3')}>
太对了!鲁迅和瞿秋白的故事不仅在于《海上述林》的字里行间,这座小楼的三楼也藏着他们为了革命事业并肩战斗的初心。
</GuideStepsItem>
</GuideSteps>
</div>
)
}
export default LuxunPlace3
+399
View File
@@ -0,0 +1,399 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
Cursor,
SingleChoiceQuizPanel,
Video,
type VideoAnchor,
} from '@/components'
import type { VideoRef } from '@/components/video'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState, useRef, useMemo } from 'react'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
// starName 固定为 luxun
const starName = 'luxun'
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: 'luxun/place4/展品11',
2: 'luxun/place4/展品12',
3: 'luxun/place4/展品13',
}
const quizDefaultError = '请结合刚刚的旁白内容,再思考一下吧!'
const quizExplanations: Record<string, string> = {
// A: "陈独秀与鲁迅有思想交往,但他并未在这间“红色庇护所”中避难、与鲁迅夜谈革命议题。",
// B: "恭喜你,选对啦。",
// C: "李大钊于1927年牺牲,时间上不符合这间客房被用作“红色庇护所”的背景。",
// D: "周恩来虽长期在上海从事革命活动,但并未在这里避难并与鲁迅夜谈。",
A: '请你结合刚刚学习的内容,再思考一下吧!',
B: '恭喜你,选择正确!',
C: '请你结合刚刚学习的内容,再思考一下吧!',
D: '请你结合刚刚学习的内容,再思考一下吧!',
}
const LuxunPlace5 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
//碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [introGuideVisible, setIntroGuideVisible] = useState(true)
const [introGuideStep, setIntroGuideStep] = useState(0)
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
// 使用持久化的访问状态
const place = 'place4'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useLuxunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
// Cursor 顺序控制:1 -> 2 -> 3
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
3: [2],
},
visited: visited,
})
// 光标2的题目状态
const [quizVisible, setQuizVisible] = useState(false)
const [quizError, setQuizError] = useState(quizDefaultError)
const [successAlertVisible, setSuccessAlertVisible] = useState(false)
// 光标3的引导对话状态
const [guide3Visible, setGuide3Visible] = useState(false)
const [guide3Step, setGuide3Step] = useState(0)
// 结尾视频与对话
const [showEndingVideo, setShowEndingVideo] = useState(false)
const [endingGuideVisible, setEndingGuideVisible] = useState(false)
// 视频锚点相关状态
const [activeVideoSuccess, setActiveVideoSuccess] = useState(false)
const [resumeVideoCallback, setResumeVideoCallback] = useState<(() => void) | null>(null)
const videoRef = useRef<VideoRef>(null)
const handleOpenExhibit = (index: 1 | 2 | 3) => {
if (index === 3) {
setActiveExhibitId(3)
setGuide3Visible(true)
setGuide3Step(0)
return
}
setActiveExhibitId(index)
}
// 视频锚点配置
const videoAnchors: VideoAnchor[] = useMemo(
() => [
{
id: 'quiz-anchor',
time: 41,
once: true,
afterResume: 'remove',
render: ({ resume }) => (
<>
<SingleChoiceQuizPanel
visible={!activeVideoSuccess}
title='【单选】毛泽东主席称赞鲁迅先生为“最伟大和最英勇的___”。'
options={[
{ value: 'A', label: 'A. 旗手' },
{ value: 'B', label: 'B. 作家' },
{ value: 'C', label: 'C. 战士' },
]}
correctAnswer="A"
onWrongSelect={(value: string) => {
switch (value) {
case 'B':
return '这不是毛泽东主席的原话。请你再想想吧!'
case 'C':
return '这不是毛泽东主席的原话。请你再想想吧!'
default:
return '请你再想想吧!'
}
}}
onSubmit={(value: string, isCorrect: boolean) => {
if (isCorrect) {
setActiveVideoSuccess(true)
setResumeVideoCallback(() => resume)
}
}}
zIndex={20000}
/>
<SuccessAlert
visible={activeVideoSuccess}
onContinue={() => {
setActiveVideoSuccess(false)
const cb = resumeVideoCallback
setResumeVideoCallback(null)
cb?.()
}}
onClose={() => {
setActiveVideoSuccess(false)
const cb = resumeVideoCallback
setResumeVideoCallback(null)
cb?.()
}}
/>
</>
),
},
],
[activeVideoSuccess, resumeVideoCallback]
)
const progressCount = visited.size
const total = 3
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`luxun/place4/背景`),
}}
>
<Title
title="6平方米的红色庇护所"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place3`)
}
}}
/>
{/* 展品 */}
<Cursor
className="absolute left-167 bottom-30"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute left-228 bottom-30"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
<Cursor
className="absolute right-115 bottom-18 z-20"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
// 完成后先播放视频,不立即跳转页面
saveLuxunStore()
setShowEndingVideo(true)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(11)
}
if (activeExhibitId === 2) {
triggerPuzzleAnimation(12)
}
if (activeExhibitId === 3) {
triggerPuzzleAnimation(13)
}
}}
description={
activeExhibitId === 1
? '瞿秋白同志曾说,“鲁迅是莱谟斯,是野兽的奶汁所喂养大的,是封建宗法社会的逆子,是绅士阶级的贰臣,而同时也是一些浪漫谛克的革命家的诤友!”'
: activeExhibitId === 2
? '鲁迅先生曾经抄录清朝诗人何瓦琴的对联赠给瞿秋白,“人生得一知己足矣,斯世当以同怀视之。”'
: undefined
}
onClose={() => {
if (activeExhibitId === 2) {
setActiveExhibitId(null)
setQuizVisible(true)
return
}
if (activeExhibitId === 3) {
// 对话关闭时再标记已读
setActiveExhibitId(null)
return
}
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}}
/>
{/* 学习引导结束后的对话 */}
<GuideSteps
visible={introGuideVisible}
onVisibleChange={setIntroGuideVisible}
currentStep={introGuideStep}
onStepChange={setIntroGuideStep}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_26.mp3')}>
你说的是这间小小的客房吗,这里为什么会被称为“红色庇护所”呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_27.mp3')}>
你可别小看这间仅6平方米的客房,在白色恐怖笼罩的1933年至1936年,这里是很多共产党人的"安全港湾"。1933年,瞿秋白夫妇第三次到鲁迅家避难,就秘密住进了这里。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
当时反动派到处搜捕共产党人,鲁迅冒着生命危险掩护他们,两人常彻夜长谈。瞿秋白的夫人杨之华回忆道:“秋白和鲁迅在一起,往往感觉夜太短。深刻的友谊的交谈,从政治到文艺,从实际到理论,从希腊到莫斯科,轻松,愉快,活泼。”
</GuideStepsItem>
</GuideSteps>
{/* 光标2的题目 */}
<SingleChoiceQuizPanel
visible={quizVisible}
title="【单选】上海鲁迅故居三楼客房被称为“红色庇护所”,以下哪位共产党人曾在此避难并与鲁迅探讨革命议题?"
options={[
{ value: 'A', label: 'A.陈独秀' },
{ value: 'B', label: 'B.瞿秋白' },
{ value: 'C', label: 'C.李大钊' },
{ value: 'D', label: 'D.周恩来' },
]}
correctAnswer="B"
errorMessage={quizError}
onSubmit={(value, isCorrect) => {
if (isCorrect) {
setQuizError(quizDefaultError)
setQuizVisible(false)
setSuccessAlertVisible(true)
}
}}
onWrongSelect={(value) => {
const explanation = quizExplanations[value]
if (explanation) {
setQuizError(explanation)
return explanation
}
setQuizError(quizDefaultError)
return quizDefaultError
}}
zIndex={10001}
/>
{/* 光标2的成功提示 */}
<SuccessAlert
visible={successAlertVisible}
content="1933年,瞿秋白夫妇第三次到鲁迅家避难,就住进了这间仅6平方米的客房,与鲁迅常常彻夜长谈革命与文学。"
onContinue={() => {
setSuccessAlertVisible(false)
markExhibitVisited(place, 2)
}}
/>
{/* 光标3的引导对话 */}
<GuideSteps
visible={guide3Visible}
onVisibleChange={(visible) => {
setGuide3Visible(visible)
if (!visible) {
markExhibitVisited(place, 3)
}
}}
currentStep={guide3Step}
onStepChange={setGuide3Step}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_28.mp3')}>
除了瞿秋白,还有其他共产党人来过这里吗
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_29.mp3')}>
当然!1936年4月,中共中央特派员冯雪峰到上海开展工作,也先住进了这间客房。冯雪峰把红军长征胜利、遵义会议召开的消息告诉鲁迅,还带来了毛泽东同志提出的抗日民族统一战线主张。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
鲁迅听后格外振奋,激动地说“我做一个小兵还是胜任的,用笔!”,这句话也成了他晚年投身抗日救亡运动的誓言。
</GuideStepsItem>
</GuideSteps>
{/* 结尾视频播放 */}
{showEndingVideo && (
<Video
ref={videoRef}
src={getPublicPath('video/luxun/luxun_06.mp4')}
className="fixed inset-0 z-20000"
controls
autoPlay
playsInline
showSkipButton
anchors={videoAnchors}
onEnded={() => {
setEndingGuideVisible(true)
}}
/>
)}
{/* 视频结束后的结束语对话 */}
<GuideSteps
visible={endingGuideVisible}
onVisibleChange={(visible) => {
setShowEndingVideo(false)
setEndingGuideVisible(visible)
if (!visible && moduleName) {
// 对话结束后返回鲁迅首页
setCourseTimeProgress(7)
navigate(`/${moduleName}/${starName}/home`)
}
}}
showMask={false}
zIndex={20010}
>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_30.mp3')}>
我们已经了解了鲁迅居住在上海大陆新村9号时的重要事件。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
接下来,请你接过先生手中的“笔”,走出上海,跨越时空,追寻他从绍兴三味书屋到上海的创作足迹——解锁每个地标背后的故事,填充文字里的时代线索,在互动中,读懂先生笔墨间的温度与力量。
</GuideStepsItem>
</GuideSteps>
</div>
)
}
export default LuxunPlace5
+342
View File
@@ -0,0 +1,342 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Image, StudyGuideModal } from '@/components'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
const starName = 'luxun'
// 城市配置 - 根据效果图调整位置
// 布局:北京(上) -> 上海(中上) -> 绍兴(中) -> 广州(左下) -> 厦门(右下)
const cityConfig = [
{
id: 1,
name: '绍兴',
location: '绍兴三味书屋',
imagePath: 'luxun/reflection/scene1/绍兴',
position: { top: '65%', left: '65%' }, // 中间
iconPosition: { top: '0%', left: '0%' },
time: '1892年-1898年',
description: '少年鲁迅在此求学,为《从百草园到三味书屋》埋下创作种子。',
},
{
id: 2,
name: '北京',
location: '北京八道湾胡同',
imagePath: 'luxun/reflection/scene1/北京',
position: { top: '14%', left: '62%' }, // 顶部
iconPosition: { top: '43%', left: '0%' },
time: '1919-1923年',
description: '鲁迅参与新文化运动的核心时期,《阿Q正传》等经典作品在此酝酿创作。',
},
{
id: 3,
name: '厦门',
location: '厦门大学',
imagePath: 'luxun/reflection/scene1/厦门',
position: { top: '83%', left: '78%' }, // 右下
iconPosition: { top: '16%', left: '40%' },
time: '1926年9-12月',
description: '鲁迅应邀前往厦门大学任教,期间写下多篇批判现实的杂文,关注底层民众命运。',
},
{
id: 4,
name: '广州',
location: '广州白云楼',
imagePath: 'luxun/reflection/scene1/广州',
position: { top: '88%', left: '35%' }, // 左下
iconPosition: { top: '16%', left: '30%' },
time: '1927年3-9月',
description: '先生在此整理旧作,思考民族觉醒之路,《朝花夕拾》部分篇章定稿于此。',
},
{
id: 5,
name: '上海',
location: '上海鲁迅故居',
imagePath: 'luxun/reflection/scene1/上海',
position: { top: '48%', left: '70%' }, // 中上
iconPosition: { top: '0%', left: '0%' },
time: '1933-1936年',
description:
'鲁迅晚年定居地,先生在此写下《故事新编》等作品,与进步文人并肩,以文字为刃批判黑暗。',
},
]
const LuxunReflectionScene1 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
//碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const { setReflectionRoute } = useLuxunStore()
// 已点击的城市ID集合
const [clickedCities, setClickedCities] = useState<Set<number>>(new Set())
// 是否显示成功提示(全部完成时)
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
// 当前显示的城市提示ID(单个地点点击后)
const [currentCityAlertId, setCurrentCityAlertId] = useState<number | null>(null)
// 是否全部完成
const [allCompleted, setAllCompleted] = useState(false)
// 学习引导对话是否显示
const [showGuideModal, setShowGuideModal] = useState(true)
const progress = clickedCities.size
// 当前应当点亮的城市索引(按照 cityConfig 顺序)
const nextCityIndex = progress
const nextCityId = cityConfig[nextCityIndex]?.id
const totalTasks = cityConfig.length
// 检查是否全部完成
useEffect(() => {
if (progress === totalTasks && !allCompleted) {
setAllCompleted(true)
}
}, [progress, totalTasks, allCompleted])
const handleCityClick = (cityId: number) => {
// 仅允许按照 cityConfig 顺序点击
if (clickedCities.has(cityId)) return // 已点击过的不再处理
if (cityId !== nextCityId) return // 不是当前应点击的城市则不响应
setClickedCities((prev) => {
const next = new Set(prev)
next.add(cityId)
return next
})
// 显示当前城市的成功提示
setCurrentCityAlertId(cityId)
}
const handleSuccessAlertClose = () => {
setShowSuccessAlert(false)
}
const handleCityAlertClose = () => {
const cityId = currentCityAlertId
setCurrentCityAlertId(null)
// 如果关闭的是最后一个城市(上海鲁迅故居,id=5),则显示全部完成的提示
const lastCity = cityConfig[cityConfig.length - 1]
if (lastCity && cityId === lastCity.id) {
triggerPuzzleAnimation(14)
setShowSuccessAlert(true)
}
}
// 获取当前显示的城市信息
const currentCity = currentCityAlertId
? cityConfig.find((city) => city.id === currentCityAlertId)
: null
return (
<div
className="flex justify-between w-full h-full bg-cover bg-center bg-no-repeat gap-8 relative"
style={{
backgroundImage: getBackgroundImage(`${starName}/reflection/scene1/底图`),
}}
>
{/* 离开按钮 */}
<Image
className="absolute right-5 top-5 z-10"
src={getStaticImage(`${starName}/reflection/scene1/离开`)}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`)
}
}}
/>
{/* 左侧:鲁迅的文学足迹 - 地图区域 */}
<div className="w-[45%] relative">
{/* 地图容器 */}
<div className="relative w-full h-full">
{cityConfig.map((city) => {
const isClicked = clickedCities.has(city.id)
const isNext = city.id === nextCityId
return (
<div
key={city.id}
className="absolute flex flex-col items-center cursor-pointer hover:scale-110 transition-all duration-300"
style={{
top: city.position.top,
left: city.position.left,
transform: 'translate(-50%, -50%)',
}}
onClick={() => handleCityClick(city.id)}
>
{/* 城市图片容器 */}
<div className="relative">
<img
src={getStaticImage(city.imagePath)}
alt={city.name}
className={`transition-all duration-300 ${
isClicked ? 'brightness-100' : 'brightness-50 grayscale'
}`}
style={{
filter: isClicked ? 'none' : 'grayscale(100%) brightness(0.5)',
}}
/>
{/* 点击图标和提示 - 覆盖在城市图片上 */}
<div
className="absolute -translate-x-full -translate-y-full flex flex-col items-center z-10 pointer-events-none"
style={{
left: city.iconPosition.left,
top: city.iconPosition.top,
}}
>
{/* 点一点提示 - 始终显示 */}
{/* 仅对当前应点击的城市显示点一点提示 */}
{!isClicked && isNext && (
<div className="flex items-center">
<img
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟改版点一点`)}
alt="点一点"
/>
<img
className="animate-finger-bounce scale-y-[-1]"
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟点一点图标`)}
alt="点一点图标"
/>
</div>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
{/* 导航栏 */}
<div className="absolute top-5 left-[37%] w-80 space-y-4 z-99">
{/* 任务1点亮图标 */}
<div className="text-4xl font-bold h-16 cursor-pointer"></div>
{/* 任务2漫集美学 */}
<div
className="text-4xl font-bold h-16 cursor-pointer relative"
onClick={() => {
if (moduleName && allCompleted) {
setReflectionRoute('reflection2')
saveLuxunStore()
navigate(`/${moduleName}/${starName}/reflection2`)
}
}}
>
{/* 任务完成后显示点一点图标 */}
{allCompleted && (
<div className="absolute -right-20 top-1/2 flex items-center gap-2 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟点一点图标`)}
alt="点一点图标"
/>
</div>
)}
</div>
{/* 任务3学习精神 */}
<div className="text-4xl font-bold h-16 cursor-pointer"></div>
</div>
{/* 右侧区域 */}
<div className="flex-1 relative">
{/* 任务进度 */}
<div className="absolute top-5 left-[24%] w-[445px] h-55 flex justify-center items-center">
<span className="text-white text-[54px] font-bold [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
{allCompleted ? '任务进度已完成' : `任务进度:(${progress}/${totalTasks})`}
</span>
</div>
{/* 任务展示窗口 */}
<div className="absolute bottom-0 right-7 w-[995px] h-[728px] font-bold text-6xl px-10 py-10 ">
{allCompleted ? (
// 全部完成后显示详细信息
<div className="space-y-10 h-full overflow-y-auto custom-scrollbar">
{cityConfig.map((city) => (
<div key={city.id} className="space-y-2">
<div className="text-red-600 text-3xl flex items-end gap-6">
<div>{city.location}</div>
<div className="text-gray-600 text-2xl font-normal">{city.time}</div>
</div>
<div className="text-black text-2xl font-normal leading-relaxed">
{city.description}
</div>
</div>
))}
</div>
) : (
<div className="space-y-16">
{
// 未完成时显示任务列表
cityConfig.map((city, index) => {
const isCompleted = clickedCities.has(city.id)
return (
<div key={city.id} className={isCompleted ? 'text-green-600' : ''}>
{index + 1}.{city.location}
</div>
)
})
}
</div>
)}
</div>
</div>
{/* 单个地点点击成功提示弹窗 */}
{currentCity && (
<SuccessAlert
visible={true}
title=""
// title={`你找到了${currentCity.location}!`}
content={`${currentCity.time},\n${currentCity.description}`}
onClose={handleCityAlertClose}
onContinue={handleCityAlertClose}
/>
)}
{/* 全部完成成功提示弹窗 */}
<SuccessAlert
visible={showSuccessAlert}
title="你找到了鲁迅创作的足迹,接下来,填充文字里的故事吧!"
onClose={handleSuccessAlertClose}
onContinue={handleSuccessAlertClose}
/>
{/* 学习引导对话 */}
{/* <GuideSteps
visible={guideVisible}
onVisibleChange={setGuideVisible}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="guide">
跟着先生的脚步, 解锁文字里的温度与力量!
</GuideStepsItem>
<GuideStepsItem speaker="guide">
请根据年代由远及近的顺序,依“手指提示”点亮先生的创作足迹,解锁更多的故事背景!
</GuideStepsItem>
</GuideSteps> */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false)
}}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_31.mp3')}
contentPaddingLeft="2em"
// firstLineIndent="2em"
>
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1892年,绍兴三味书屋。
请按照年代由远及近的顺序,依“手指提示”,点亮先生的创作足迹,解锁更多故事背景!时空门已开,请做好准备吧!`}
</StudyGuideModal>
</div>
)
}
export default LuxunReflectionScene1
+581
View File
@@ -0,0 +1,581 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Image, DragFill, GuideSteps, GuideStepsItem } from '@/components'
import type { DragFillResult } from '@/components'
import ErrorAlert from '@/components/layout/alert/error-alert'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
const starName = 'luxun'
// 拖拽元素配置 - 每个元素对应一个特定的标签放置区域
const dragItems = [
{
id: 'runtu',
name: '闰土',
imagePath: 'luxun/reflection/scene2/拖拽-润土',
targetBlankId: 'city-1-label-0', // 绍兴-人物
errorHint: '❌闰土的故事和鲁迅的少年绍兴时光有关哦!',
successMessage: '✅答对啦! 闰土是鲁迅绍兴时期笔下的经典人物。',
},
{
id: 'baicaoyuan',
name: '《从百草园到三味书屋》',
imagePath: 'luxun/reflection/scene2/拖拽-百草园到三味书屋',
targetBlankId: 'city-1-label-1', // 绍兴-作品
errorHint: '❌这个作品写的是鲁迅在绍兴的故事哦!',
successMessage: '✅真棒! 这篇散文记录了鲁迅在绍兴的求学时光。',
},
{
id: 'aqzhengzhuan',
name: '《阿Q正传》',
imagePath: 'luxun/reflection/scene2/拖拽-阿Q正传',
targetBlankId: 'city-2-label-0', // 北京-作品
errorHint: '❌这部经典小说和鲁迅在北京的创作有关哦!',
successMessage: '✅答对啦! 《阿Q正传》是鲁迅小说的代表作,诞生于北京。',
},
{
id: 'jingshenshengli',
name: '“精神胜利法”',
imagePath: 'luxun/reflection/scene2/拖拽-精神胜利法',
targetBlankId: 'city-2-label-1', // 北京-核心主题
errorHint: '❌这个主题是《阿Q正传》的核心,和北京创作背景相关哦!',
successMessage: '✅没错! 《阿Q正传》画出了国人的灵魂,以便疗救。',
},
{
id: 'guanzhudiceng',
name: '“关注底层”',
imagePath: 'luxun/reflection/scene2/关注底层',
targetBlankId: 'city-3-label-0', // 厦门-创作方向
errorHint: '❌这个创作方向是鲁迅在厦门的核心追求哦!',
successMessage: '✅答对啦!鲁迅在厦门任教时,依旧关注底层民众的命运。',
},
{
id: 'zhaohuaxishi',
name: '《朝花夕拾》',
imagePath: 'luxun/reflection/scene2/拖拽-朝花夕拾',
targetBlankId: 'city-4-label-0', // 广州-作品集
errorHint: '❌这个作品集和鲁迅在广州的旅居时光有关哦!',
successMessage: '✅真棒!《朝花夕拾》部分篇章在广州白云楼定稿。',
},
{
id: 'gushixinbian',
name: '《故事新编》',
imagePath: 'luxun/reflection/scene2/拖拽-故事新编',
targetBlankId: 'city-5-label-0', // 上海-作品
errorHint: '❌再试试~这个作品和上海的创作故事更相关哦!',
successMessage: '✅答对啦!《故事新编》是鲁迅在上海故居完成的经典作品集。',
},
{
id: 'biweidaoqiang',
name: '“笔为刀枪”',
imagePath: 'luxun/reflection/scene2/笔为刀枪',
targetBlankId: 'city-5-label-1', // 上海-创作中心
errorHint: '❌这个初心是鲁迅在上海创作的核心哦!',
successMessage: '✅没错!鲁迅在上海以笔为刃,批判黑暗的社会现实。',
},
]
// 城市配置 - 根据效果图调整位置
// 布局:北京(上) -> 上海(中上) -> 绍兴(中) -> 广州(左下) -> 厦门(右下)
const cityConfig = [
{
id: 1,
name: '绍兴',
location: '绍兴三味书屋',
imagePath: 'luxun/reflection/scene2/绍兴',
position: { top: '65%', left: '65%' }, // 中间
labels: [
{ text: '人物', containerPosition: { top: '50%', left: '20%' } },
{ text: '作品', containerPosition: { top: '50%', left: '50%' } },
],
time: '1892年-1898年',
description: '少年鲁迅在此求学,为《从百草园到三味书屋》埋下创作种子。',
},
{
id: 2,
name: '北京',
location: '北京八道湾胡同',
imagePath: 'luxun/reflection/scene2/北京',
position: { top: '14%', left: '62%' }, // 顶部
labels: [
{ text: '作品', containerPosition: { top: '72%', left: '30%' } },
{ text: '核心主题', containerPosition: { top: '72%', left: '70%' } },
],
time: '1919-1923年',
description: '鲁迅参与新文化运动的核心时期,《阿Q正传》等经典作品在此酝酿创作。',
},
{
id: 3,
name: '厦门',
location: '厦门大学',
imagePath: 'luxun/reflection/scene2/厦门',
position: { top: '83%', left: '78%' }, // 右下
labels: [{ text: '创作方向', containerPosition: { top: '52%', left: '60%' } }],
time: '1926年9-12月',
description: '鲁迅应邀前往厦门大学任教,期间写下多篇批判现实的杂文,关注底层民众命运。',
},
{
id: 4,
name: '广州',
location: '广州白云楼',
imagePath: 'luxun/reflection/scene2/广州',
position: { top: '88%', left: '35%' }, // 左下
labels: [{ text: '作品集', containerPosition: { top: '38%', left: '50%' } }],
time: '1927年3-9月',
description: '先生在此整理旧作,思考民族觉醒之路,《朝花夕拾》部分篇章定稿于此。',
},
{
id: 5,
name: '上海',
location: '上海鲁迅故居',
imagePath: 'luxun/reflection/scene2/上海',
position: { top: '48%', left: '70%' }, // 中上
labels: [
{ text: '作品', containerPosition: { top: '50%', left: '20%' } },
{ text: '创作中心', containerPosition: { top: '50%', left: '50%' } },
],
time: '1933-1936年',
description:
'鲁迅晚年定居地,先生在此写下《故事新编》等作品,与进步文人并肩,以文字为刃批判黑暗。',
},
]
const LuxunReflectionScene2 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
const { setReflectionRoute } = useLuxunStore()
// DragFill 的值
const [dragFillValue, setDragFillValue] = useState<DragFillResult[]>([])
// 是否全部完成
const [allCompleted, setAllCompleted] = useState(false)
// 错误提示相关状态
const [showErrorAlert, setShowErrorAlert] = useState(false)
const [errorHint, setErrorHint] = useState('')
// 成功提示相关状态
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
const [successMessage, setSuccessMessage] = useState('')
// 引导对话是否显示
const [guideVisible, setGuideVisible] = useState(true)
// 通关提示对话是否显示
const [completionGuideVisible, setCompletionGuideVisible] = useState(false)
// 通关提示是否已经显示过(防止重复显示)
const [completionGuideShown, setCompletionGuideShown] = useState(false)
// 转换为 DragFill 的 options
const options = useMemo(
() =>
dragItems.map((item) => ({
value: item.id,
label: item.name,
})),
[]
)
// 转换为 DragFill 的 blanks - 为每个标签创建独立的空白区域
const blanks = useMemo(() => {
const allBlanks: Array<{ id: string; label: string }> = []
cityConfig.forEach((city) => {
city.labels.forEach((label, index) => {
const labelText = typeof label === 'string' ? label : label.text
allBlanks.push({
id: `city-${city.id}-label-${index}`,
label: labelText,
})
})
})
return allBlanks
}, [])
// 处理拖拽结果变化 - 验证是否正确放置
const handleValueChange = useCallback(
(newValue: DragFillResult[]) => {
// 找出新添加或位置改变的拖拽结果(与当前值比较)
const previousValue = dragFillValue
const changedResults = newValue.filter(
(newResult) =>
!previousValue.some(
(prevResult) =>
prevResult.optionValue === newResult.optionValue &&
prevResult.blankId === newResult.blankId
)
)
// 检查改变的结果是否有错误
let hasError = false
let errorMessage = ''
let successItem = null
for (const result of changedResults) {
const itemId = result.optionValue
const blankId = result.blankId
const item = dragItems.find((item) => item.id === itemId)
// 如果放置位置不正确,显示错误提示
if (item && item.targetBlankId !== blankId) {
hasError = true
errorMessage = item.errorHint || '放置位置不正确,请再试试~'
break // 只显示第一个错误的提示
} else if (item && item.targetBlankId === blankId) {
// 如果放置正确,记录成功项(只记录第一个正确的)
if (!successItem) {
successItem = item
}
}
}
if (hasError) {
// 显示错误提示
setErrorHint(errorMessage)
setShowErrorAlert(true)
// 不更新拖拽值,保持之前正确的状态
// DragFill 是受控组件,不更新 value 会保持之前的状态
return
}
// 验证每个元素是否放置到正确的标签区域,只保留正确的放置
const filteredValue = newValue.filter((result) => {
const itemId = result.optionValue
const blankId = result.blankId
const item = dragItems.find((item) => item.id === itemId)
return item && item.targetBlankId === blankId
})
// 如果拖拽正确,显示成功提示
if (successItem) {
setSuccessMessage(successItem.successMessage)
setShowSuccessAlert(true)
}
setDragFillValue(filteredValue)
},
[dragFillValue]
)
const progress = dragFillValue.length
const totalTasks = dragItems.length // 8个拖拽元素
// 检查是否全部完成
useEffect(() => {
if (progress === totalTasks && !allCompleted) {
setAllCompleted(true)
// 不在这里显示通关提示,等待所有成功提示关闭后再显示
}
}, [progress, totalTasks, allCompleted])
const handleErrorAlertClose = () => {
setShowErrorAlert(false)
setErrorHint('')
}
const handleSuccessAlertClose = () => {
setShowSuccessAlert(false)
setSuccessMessage('')
}
// 当所有任务完成且成功提示关闭后,显示通关提示(只显示一次)
useEffect(() => {
if (allCompleted && !showSuccessAlert && !completionGuideShown) {
setCompletionGuideVisible(true)
setCompletionGuideShown(true)
}
}, [allCompleted, showSuccessAlert, completionGuideShown])
// 获取每个标签区域已放置的元素
const getLabelPlacedItem = (cityId: number, labelIndex: number) => {
const blankId = `city-${cityId}-label-${labelIndex}`
const result = dragFillValue.find((r) => r.blankId === blankId)
if (!result) return null
return dragItems.find((item) => item.id === result.optionValue) || null
}
return (
<div
className="select-none flex justify-between w-full h-full bg-cover bg-center bg-no-repeat gap-10 relative"
style={{
backgroundImage: getBackgroundImage(`${starName}/reflection/scene2/底图`),
}}
>
{/* 离开按钮 */}
<Image
className="absolute right-5 top-5 z-10"
src={getStaticImage(`${starName}/reflection/scene1/离开`)}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/reflection`)
}
}}
/>
{/* 左侧:鲁迅的文学足迹 - 地图区域 */}
<div className="w-[44.5%] relative">
{/* 地图容器 */}
<div className="relative w-full h-full">
{cityConfig.map((city) => {
return (
<div
key={city.id}
className="absolute flex flex-col items-center"
style={{
top: city.position.top,
left: city.position.left,
transform: 'translate(-50%, -50%)',
}}
>
{/* 城市图片容器 */}
<div className="relative">
<img src={getStaticImage(city.imagePath)} alt={city.name} />
{/* 标签区域 - 每个标签是一个独立的容器框 */}
<div className="absolute inset-0">
{city.labels.map((label, index) => {
const blankId = `city-${city.id}-label-${index}`
const placedItem = getLabelPlacedItem(city.id, index)
const isFilled = !!placedItem
const labelText = typeof label === 'string' ? label : label.text
const containerPosition =
typeof label === 'string'
? { top: '0%', left: '0%' }
: label.containerPosition
return (
<div
key={index}
data-blank-id={blankId}
className={`absolute border-2 border-dashed rounded-lg transition-all min-w-[80px] min-h-[80px] flex flex-col items-center justify-center ${
isFilled
? 'border-green-500 bg-green-500/20'
: 'border-white/60 bg-black/60 hover:border-blue-400 hover:bg-blue-400/20'
}`}
style={{
top: containerPosition.top,
left: containerPosition.left,
transform: 'translate(-50%, -50%)',
}}
>
{/* 已放置的元素显示在容器框内 */}
{placedItem ? (
<img
src={getStaticImage(placedItem.imagePath)}
alt={placedItem.name}
className="w-23 h-23 object-contain"
/>
) : (
<div className="text-white text-sm text-center">{labelText}</div>
)}
</div>
)
})}
</div>
</div>
</div>
)
})}
</div>
</div>
{/* 导航栏 */}
<div className="absolute top-20 left-[37%] w-80 space-y-4 z-99">
{/* 任务2漫集美学 */}
<div className="text-4xl font-bold h-16 cursor-pointer relative"></div>
{/* 任务3学习精神 */}
<div
className="text-4xl font-bold h-16 cursor-pointer relative"
onClick={() => {
if (moduleName && allCompleted) {
setReflectionRoute('reflection3')
saveLuxunStore()
navigate(`/${moduleName}/${starName}/reflection3`)
}
}}
>
{/* 任务完成后显示点一点图标 */}
{allCompleted && (
<div className="absolute -right-20 top-1/2 flex items-center gap-2 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟点一点图标`)}
alt="点一点图标"
/>
</div>
)}
</div>
</div>
{/* 右侧区域 */}
<div className="flex-1 relative">
{/* 任务进度 */}
<div className="absolute top-4 left-[27%] w-[430px] h-51 flex justify-center items-center">
<span className="text-white text-[54px] font-bold [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
{allCompleted ? '任务进度已完成' : `任务进度:(${progress}/${totalTasks})`}
</span>
</div>
{/* 鲁迅画廊 */}
<div className="absolute top-[calc(2.4rem+204px)] left-0 w-[995px] h-[467px] pt-16 pl-70 pr-10">
{/* 提示图标 */}
<div className="absolute left-14 top-35 space-y-2 z-10 pointer-events-none">
<div className="flex items-center">
<img
src={getStaticImage(`${starName}/reflection/scene2/鲁迅体悟拖一拖`)}
alt="拖一拖"
/>
<img
src={getStaticImage(`${starName}/reflection/scene2/鲁迅体悟拖一拖图标`)}
alt="拖一拖图标"
/>
</div>
<div className="w-45 leading-relaxed tracking-wider text-black text-3xl font-bold">
请将右侧图标拖到左侧对应位置。
</div>
</div>
{/* 使用 DragFill 组件 */}
<div className="mt-10">
<DragFill
options={options}
blanks={blanks}
value={dragFillValue}
onChange={handleValueChange}
allowDuplicate={false}
showSubmitButton={false}
layout="horizontal-options"
hideLabels={true}
className="w-full"
optionClassName="grid grid-cols-4 gap-10"
blankClassName="hidden" // 隐藏默认的空白区域,我们在地图上自定义显示
renderOption={(option, isUsed) => {
const item = dragItems.find((item) => item.id === option.value)
if (!item) return null
return (
<div
className={`relative transition-all duration-200 cursor-move ${
isUsed ? 'opacity-30' : 'hover:scale-105'
}`}
>
<img
src={getStaticImage(item.imagePath)}
alt={item.name}
className="w-full h-auto"
draggable={false}
/>
</div>
)
}}
renderDragging={(option) => {
const item = dragItems.find((item) => item.id === option.value)
if (!item) return null
return (
<img
src={getStaticImage(item.imagePath)}
alt={item.name}
className="w-12"
draggable={false}
/>
)
}}
renderBlank={(blank, _filledValue, _isHovered) => {
// 空白区域在地图上显示,返回一个隐藏的占位元素
// DragFill 会通过 data-blank-id 属性来识别空白区域
return (
<div data-blank-id={blank.id} className="hidden" style={{ display: 'none' }} />
)
}}
/>
</div>
</div>
{/* 任务展示窗口 */}
<div className="absolute bottom-3.5 right-8 w-[995px] h-[335px] px-10 py-4 overflow-y-auto luxun-scrollbar">
{allCompleted ? (
// 全部完成后显示详细信息
<div className="space-y-8">
{cityConfig.map((city) => (
<div key={city.id} className="space-y-4">
<div className="text-red-600 text-5xl font-bold flex items-end gap-6">
<div>{city.location}</div>
<div className="text-gray-600 text-4xl font-normal">{city.time}</div>
</div>
<div className="text-black text-4xl font-normal leading-relaxed">
{city.description}
</div>
</div>
))}
</div>
) : (
// 未完成时显示提示内容
<div className="space-y-8 text-center">
<div className="text-black text-5xl font-bold mb-10">任务展示窗口</div>
{/* <div className="text-red-600 text-4xl font-bold">
请将以下内容拖拽至正确位置
</div> */}
<div className="grid grid-cols-4 gap-4 text-3xl font-bold">
{dragItems.map((item) => {
const isPlaced = dragFillValue.some((result) => result.optionValue === item.id)
return (
<div key={item.id} className={`${isPlaced ? 'text-green-600' : 'text-black'}`}>
{item.name}
</div>
)
})}
</div>
</div>
)}
</div>
</div>
{/* 错误提示弹窗 */}
<ErrorAlert
visible={showErrorAlert}
text={errorHint}
onClose={handleErrorAlertClose}
onMaskClick={handleErrorAlertClose}
/>
{/* 成功提示弹窗 */}
<SuccessAlert
visible={showSuccessAlert}
title={successMessage}
onClose={handleSuccessAlertClose}
onContinue={handleSuccessAlertClose}
/>
{/* 学习引导对话 */}
<GuideSteps
visible={guideVisible}
onVisibleChange={setGuideVisible}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
audioChannelId="luxun-reflection-scene2-guide"
>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_32.mp3')}>
这些线索藏在先生的文字里,长按卡片拖到对应地标旁,还原创作故事!
</GuideStepsItem>
</GuideSteps>
{/* 通关提示对话 */}
<GuideSteps
visible={completionGuideVisible}
onVisibleChange={setCompletionGuideVisible}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
audioChannelId="luxun-reflection-scene2-completion-guide"
>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_32_02.mp3')}>
你读懂了鲁迅文字与土地的联结,再深入看看文字里的初心!
</GuideStepsItem>
</GuideSteps>
</div>
)
}
export default LuxunReflectionScene2
+447
View File
@@ -0,0 +1,447 @@
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Image, GuideSteps, GuideStepsItem, StudyGuideModal, EndingOverlay } from '@/components'
import { SingleChoiceQuizPanel } from '@/components/layout/quiz'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'luxun'
// 提示图标位置类型
type HintPosition = 'luxun1' | 'guanzhu' | 'piping' | 'jiaguo' | 'luxun2' | 'none'
const LuxunReflectionScene3 = () => {
//碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
const [showGuanzhu, setShowGuanzhu] = useState(false) // 是否显示"关注底层"
const [showPiping, setShowPiping] = useState(false) // 是否显示"批判现实"
const [showJiaguo, setShowJiaguo] = useState(false) // 是否显示"家国情怀"
const [hintPosition, setHintPosition] = useState<HintPosition>('luxun1') // 提示图标位置
const [showQuiz1, setShowQuiz1] = useState(false) // 是否显示题目1(关注底层)
const [showQuiz2, setShowQuiz2] = useState(false) // 是否显示题目2(批判现实)
const [showQuiz3, setShowQuiz3] = useState(false) // 是否显示题目3(家国情怀)
const [completedQuiz1, setCompletedQuiz1] = useState(false) // 题目1是否完成
const [completedQuiz2, setCompletedQuiz2] = useState(false) // 题目2是否完成
const [completedQuiz3, setCompletedQuiz3] = useState(false) // 题目3是否完成
const [showSummary, setShowSummary] = useState(false) // 是否显示总结
const [completionGuideVisible, setCompletionGuideVisible] = useState(false) // 通关提示对话是否显示
const [showGuideModal, setShowGuideModal] = useState(true)
// 成功提示相关状态
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
const [successMessage, setSuccessMessage] = useState('')
// 结束语覆盖层是否已关闭
const [endingOverlayFinished, setEndingOverlayFinished] = useState(false)
const [showEndingOverlay, setShowEndingOverlay] = useState(false)
const allCompleted = completedQuiz1 && completedQuiz2 && completedQuiz3
const progress = (completedQuiz1 ? 1 : 0) + (completedQuiz2 ? 1 : 0) + (completedQuiz3 ? 1 : 0)
const totalTasks = 3
// 当显示总结后,延迟1秒显示通关提示对话
useEffect(() => {
if (showSummary) {
const timer = setTimeout(() => {
setCompletionGuideVisible(true)
triggerPuzzleAnimation(15)
}, 500)
return () => clearTimeout(timer)
}
}, [showSummary])
// 点击"鲁迅1"的处理
const handleLuxun1Click = () => {
// 同时显示三个选项
setShowGuanzhu(true)
setShowPiping(true)
setShowJiaguo(true)
// 提示图标会根据完成状态自动显示在对应的选项上
}
// 点击"关注底层"的处理
const handleGuanzhuClick = () => {
if (!showGuanzhu || completedQuiz1) return // 必须先点击"鲁迅1"且未完成
setShowQuiz1(true)
}
// 点击"批判现实"的处理
const handlePipingClick = () => {
if (!completedQuiz1 || completedQuiz2) return // 必须先完成题目1且未完成题目2
setShowQuiz2(true)
}
// 点击"家国情怀"的处理
const handleJiaguoClick = () => {
if (!completedQuiz2 || completedQuiz3) return // 必须先完成题目2且未完成题目3
setShowQuiz3(true)
}
// 题目1提交处理(关注底层)
const handleQuiz1Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setCompletedQuiz1(true)
setShowQuiz1(false)
// 提示图标会自动移动到"批判现实"旁边(根据完成状态)
// 显示成功提示
setSuccessMessage('鲁迅的文字始终聚焦底层百姓的苦难与希望。')
setShowSuccessAlert(true)
}
}
// 题目2提交处理(批判现实)
const handleQuiz2Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setCompletedQuiz2(true)
setShowQuiz2(false)
// 提示图标会自动移动到"家国情怀"旁边(根据完成状态)
// 显示成功提示
setSuccessMessage('鲁迅以文字批判旧时代的黑暗,唤醒国人的觉醒。')
setShowSuccessAlert(true)
}
}
// 题目3提交处理(家国情怀)
const handleQuiz3Submit = (value: string, isCorrect: boolean) => {
if (isCorrect) {
setCompletedQuiz3(true)
setShowQuiz3(false)
setHintPosition('luxun2') // 提示图标移动到"鲁迅2"旁边
// 显示成功提示
setSuccessMessage('鲁迅一生辗转,只为唤醒国人,守护家国。')
setShowSuccessAlert(true)
}
}
// 点击"鲁迅2"的处理
const handleLuxun2Click = () => {
if (allCompleted) {
setShowSummary(true)
setHintPosition('none') // 隐藏提示图标
}
}
return (
<div
className="select-none flex justify-between w-full h-full bg-cover bg-center bg-no-repeat gap-10 relative"
style={{
backgroundImage: getBackgroundImage(`${starName}/reflection/scene3/底图`),
}}
>
{/* 离开按钮 */}
<Image
className="absolute right-5 top-5 z-10"
src={getStaticImage(`${starName}/reflection/scene1/离开`)}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/reflection2`)
}
}}
/>
{/* 左侧:鲁迅的文学足迹 - 地图区域 */}
<div className="w-[44.5%] h-full relative">
{/* 关注底层 - 同时显示,但按顺序启用 */}
{showGuanzhu && (
<img
className={`absolute top-20 left-40 transition-opacity ${
completedQuiz1 ? 'opacity-50 cursor-default' : 'cursor-pointer opacity-100'
} ${!showGuanzhu ? 'opacity-30 cursor-not-allowed' : ''}`}
src={getStaticImage(`${starName}/reflection/scene3/关注底层`)}
alt="关注底层"
onClick={handleGuanzhuClick}
style={{
pointerEvents: completedQuiz1 ? 'none' : 'auto',
}}
/>
)}
{/* 批判现实 - 同时显示,但按顺序启用 */}
{showPiping && (
<img
className={`absolute top-60 left-40 transition-opacity ${
completedQuiz2
? 'opacity-50 cursor-default'
: completedQuiz1
? 'cursor-pointer opacity-100'
: 'opacity-30 cursor-not-allowed'
}`}
src={getStaticImage(`${starName}/reflection/scene3/批判现实`)}
alt="批判现实"
onClick={handlePipingClick}
style={{
pointerEvents: !completedQuiz1 || completedQuiz2 ? 'none' : 'auto',
}}
/>
)}
{/* 家国情怀 - 同时显示,但按顺序启用 */}
{showJiaguo && (
<img
className={`absolute top-100 left-40 transition-opacity ${
completedQuiz3
? 'opacity-50 cursor-default'
: completedQuiz2
? 'cursor-pointer opacity-100'
: 'opacity-30 cursor-not-allowed'
}`}
src={getStaticImage(`${starName}/reflection/scene3/家国情怀`)}
alt="家国情怀"
onClick={handleJiaguoClick}
style={{
pointerEvents: !completedQuiz2 || completedQuiz3 ? 'none' : 'auto',
}}
/>
)}
{/* 提示图标 - 关注底层左侧(未完成题目1时显示) */}
{showGuanzhu && !completedQuiz1 && (
<div className="absolute top-32 left-86 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
draggable={false}
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟点一点图标`)}
alt="点一点图标"
/>
<img
draggable={false}
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟改版点一点`)}
alt="点一点"
/>
</div>
)}
{/* 提示图标 - 批判现实旁边(完成题目1但未完成题目2时显示) */}
{showPiping && completedQuiz1 && !completedQuiz2 && (
<div className="absolute top-75 left-85 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
draggable={false}
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟点一点图标`)}
alt="点一点图标"
/>
<img
draggable={false}
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟改版点一点`)}
alt="点一点"
/>
</div>
)}
{/* 提示图标 - 家国情怀旁边(完成题目2但未完成题目3时显示) */}
{showJiaguo && completedQuiz2 && !completedQuiz3 && (
<div className="absolute top-112 left-85 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
draggable={false}
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟点一点图标`)}
alt="点一点图标"
/>
<img
draggable={false}
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟改版点一点`)}
alt="点一点"
/>
</div>
)}
</div>
{/* 右侧区域 */}
<div className="flex-1 relative">
{/* 任务进度 */}
<div className="absolute top-6 left-[23.8%] w-[430px] h-51 flex justify-center items-center">
<span className="text-white text-[54px] font-bold [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
{allCompleted ? '任务进度已完成' : `任务进度:(${progress}/${totalTasks})`}
</span>
</div>
{/* 鲁迅精神内核 */}
<div className="absolute top-[calc(2.4rem+204px)] left-0 w-[995px] h-[525px] pt-16 px-10">
<div className="flex items-center justify-between">
{/* 提示图标 - 鲁迅1左侧 */}
{hintPosition === 'luxun1' && !showGuanzhu && (
<div className="absolute left-80 top-60 flex items-center z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
draggable={false}
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟点一点图标`)}
alt="点一点图标"
/>
<img
draggable={false}
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟改版点一点`)}
alt="点一点"
/>
</div>
)}
<img
className="cursor-pointer"
src={getStaticImage(`${starName}/reflection/scene3/鲁迅1`)}
alt="鲁迅精神内核"
onClick={handleLuxun1Click}
/>
<img
className="cursor-pointer"
src={getStaticImage(`${starName}/reflection/scene3/鲁迅2`)}
alt="鲁迅精神内核"
onClick={handleLuxun2Click}
/>
{/* 提示图标 - 鲁迅2旁边 */}
{hintPosition === 'luxun2' && allCompleted && (
<div className="absolute right-0 top-70 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
draggable={false}
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟点一点图标`)}
alt="点一点图标"
/>
<img
draggable={false}
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟改版点一点`)}
alt="点一点"
/>
</div>
)}
</div>
</div>
{/* 精神总结 */}
<div className="absolute bottom-2 right-8 w-[995px] h-[285px] px-2 py-12">
{showSummary ? (
// 点击鲁迅2后显示总结内容
<div className="h-full px-8 overflow-y-auto custom-scrollbar">
<div className="space-y-0">
<div className="text-red-600 text-4xl font-bold leading-relaxed">
1.关注底层:他笔下的主角多是社会底层
</div>
<div className="text-red-600 text-4xl font-bold leading-relaxed">
2.批判现实:他以犀利的文笔批判封建传统
</div>
<div className="text-red-600 text-4xl font-bold leading-relaxed">
3.家国情怀:他致力于唤醒国人、守护家国
</div>
</div>
</div>
) : (
// 未完成时显示提示内容
<div className="text-center text-black text-4xl font-bold">
请跟随小手指点一点,拼接鲁迅的精神脉络!
</div>
)}
</div>
</div>
{/* 继续前进按钮 - 只在结束语覆盖层关闭后显示 */}
{endingOverlayFinished && (
<Image
className="absolute bottom-45 right-0"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => {
setCourseTimeProgress(8)
navigate(`/${moduleName}/${starName}/home`)
}}
/>
)}
{/* 题目1:关注底层 */}
<SingleChoiceQuizPanel
visible={showQuiz1}
title="【单选】从闰土到祥林嫂,先生的文字始终看向:"
options={[
{ value: 'A', label: 'A. 底层普通百姓' },
{ value: 'B', label: 'B. 达官贵人' },
]}
correctAnswer="A"
errorMessage="不是哦,先生的文字从来不是为达官贵人而写哦!回忆闰土、祥林嫂的身份。"
onSubmit={handleQuiz1Submit}
/>
{/* 题目2:批判现实 */}
<SingleChoiceQuizPanel
visible={showQuiz2}
title="【单选】先生的作品《狂人日记》《阿Q正传》都在揭露的是:"
options={[
{ value: 'A', label: 'A. 旧时代的黑暗与愚昧' },
{ value: 'B', label: 'B. 生活中的小美好' },
]}
correctAnswer="A"
errorMessage="不是哦,先生的小说核心是批判而非歌颂哦!请结合阿Q的碎片回忆一下吧!"
onSubmit={handleQuiz2Submit}
/>
{/* 题目3:家国情怀 */}
<SingleChoiceQuizPanel
visible={showQuiz3}
title={'【单选】先生辗转多地创作,核心追求是:'}
options={[
{ value: 'A', label: 'A. 唤醒国人,改变社会' },
{ value: 'B', label: 'B. 只为个人成名' },
]}
correctAnswer="A"
errorMessage="不是哦,先生的创作从不是为了自己哦!请回忆先生“弃医从文”的初心。"
onSubmit={handleQuiz3Submit}
/>
{/* 成功提示弹窗 */}
<SuccessAlert
visible={showSuccessAlert}
title="恭喜你,答对啦!"
content={successMessage}
onClose={() => {
setShowSuccessAlert(false)
setSuccessMessage('')
}}
onContinue={() => {
setShowSuccessAlert(false)
setSuccessMessage('')
}}
/>
{/* 通关提示对话 */}
<GuideSteps
visible={completionGuideVisible}
onVisibleChange={(visible) => {
if (!visible) {
setCompletionGuideVisible(false)
setShowEndingOverlay(true)
}
}}
showMask={false}
highlightSelectors={[]}
zIndex={10000}
>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_33_02.mp3')}>
恭喜你读懂了鲁迅的文字与初心,愿这份精神能伴你前行!
</GuideStepsItem>
</GuideSteps>
<EndingOverlay
visible={showEndingOverlay}
text="愿你走过我的文学足迹,读懂文字的力量,心怀热望,直面生活!"
animationSpeed={200}
onContinue={() => {
setShowEndingOverlay(false)
setEndingOverlayFinished(true)
}}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_34.mp3')}
/>
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false)
}}
verticalAlign="center"
audioSrc={getPublicPath('audio/luxun/mp3_luxun_33.mp3')}
firstLineIndent="2em"
>
先生的每一步足迹、每一篇文字,都藏着不变的精神底色——拼出这份精神脉络,读懂先生的初心!
</StudyGuideModal>
</div>
)
}
export default LuxunReflectionScene3
+53
View File
@@ -0,0 +1,53 @@
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { useMaozedongStore } from '@/store/maozedongStore'
const Maozedong = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// starName 固定为 maozedong
const starName = 'maozedong'
const lastVisitedPlace = useMaozedongStore((state) => state.lastVisitedPlace)
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/revolutionary`)}
/>
<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 Maozedong
+521
View File
@@ -0,0 +1,521 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
type DragFillResult,
SingleChoiceQuizPanel,
LetterToMaozedong,
EndingOverlay,
Narration,
} from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { GuideSteps, GuideStepsItem } from '@/components'
import { BasketQuizPanel, DragQuizPanel, SuccessAlert } from '@/components/layout'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const starName = 'maozedong'
const MaozedongInternalization = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
// 进页面:先播“引入视频” -> 引入对话(图1) -> 主视频(视频11)
const [showIntroVideo, setShowIntroVideo] = useState(true)
const [introQuizVisible, setIntroQuizVisible] = useState(false)
const [introQuizSuccessVisible, setIntroQuizSuccessVisible] = useState(false)
const [introDialogVisible, setIntroDialogVisible] = useState(false)
const [showMainVideo, setShowMainVideo] = useState(false)
// 题目弹层状态(视频锚点 render 中使用)
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 [dragQuizFeedbackTypes, setDragQuizFeedbackTypes] = useState<
Record<string, 'success' | 'error'>
>({})
// 写信、寄信动画与完成提示
const [showLetter, setShowLetter] = useState(false)
const [showCompleted, setShowCompleted] = useState(false)
const [showTransition, setShowTransition] = useState(false)
const videoRef = useRef<VideoRef>(null)
useEffect(() => {}, [])
const handleVideoCanPlay = () => {
if (showMainVideo && videoRef.current) {
videoRef.current.play().catch(() => {})
}
}
// —— 题目配置:题目2(1:59)、题目3(4:31)、题目4(6:58)、题目5(7:15)——
type DragQuizConfig = {
id: string
time: number
title: string
prompt: string
correct: string[]
wrongValue: string
wrongHint: string
optionHints: Partial<Record<string, string>>
options: { value: string; label: string }[]
blanks: { id: string; label: string }[]
separators?: string[]
}
const DRAG_QUIZZES: DragQuizConfig[] = useMemo(
() => [
{
id: 'quiz-2',
time: 60 * 1 + 59,
title: '',
prompt: '请将你觉得属于新时代坚持“实事求是”思想的做法拖入篮子吧!',
correct: ['A', 'B', 'C'],
wrongValue: 'D',
wrongHint:
'这种做法脱离实际,违背了“实事求是”的思想路线,容易导致发展走弯路。请你再想想吧!',
optionHints: {
A: '恭喜你,选择正确!这是党在精准把握发展阶段特征的基础上作出的科学判断,体现了“实事求是”的思想精髓。',
B: '恭喜你,选择正确!这是从上海实际出发,立足自身优势精准定位发展方向,是“实事求是”在区域发展中的生动体现。',
C: '恭喜你,选择正确!这是立足实际、聚焦问题、务求实效的实践路径,是“实事求是”在改革发展中的具体践行。',
D: '这种做法脱离实际,违背了“实事求是”的思想路线,容易导致发展走弯路。请你再想想吧!',
},
options: [
{
value: 'A',
label: '党的十八大以来,根据世情国情变化作出“社会主要矛盾转化”的新论断',
},
{
value: 'B',
label: '基于上海的区位优势和发展基础,党中央赋予上海建设“五个中心”的使命',
},
{
value: 'C',
label: '上海以问题为导向,推动“五个中心”联动发展,将改革开放“施工图”转化为“实景画”',
},
{ value: 'D', label: '不顾本地资源禀赋,盲目照搬其他地区的产业发展模式' },
],
blanks: [
{ id: 'basket-1', label: '' },
{ id: 'basket-2', label: '' },
{ id: 'basket-3', label: '' },
],
},
{
id: 'quiz-3',
time: 60 * 4 + 32,
title: '',
prompt: '请将你认为符合新时代践行“群众路线”思想的做法拖入篮子吧!',
correct: ['A', 'C', 'D'],
wrongValue: 'B',
wrongHint:
'这种做法违背了以人民为中心的发展思想,不符合新时代群众路线的要求。请你再想想吧!',
optionHints: {
A: '恭喜你,选择正确!这一理念是新时代群众路线在城市治理中的生动表达,体现了以人民为中心的发展思想。',
B: '这种做法违背了以人民为中心的发展思想,不符合新时代群众路线的要求。请你再想想吧!',
C: '恭喜你,选择正确!这是群众路线在民主政治领域的创新实践,保障了人民群众的参与权、表达权,让人民真正成为城市的主人。',
D: '恭喜你,选择正确!这是群众路线在新时代的深化与拓展,充分调动了人民群众的积极性、主动性、创造性。',
},
options: [
{ value: 'A', label: '提出“人民城市人民建,人民城市为人民”的重要理念' },
{ value: 'B', label: '城市在经济发展中只关注企业效益,忽视普通市民的民生需求' },
{
value: 'C',
label: '上海作为全过程人民民主首提地,搭建多元民主平台让群众声音直达决策层面',
},
{
value: 'D',
label: '上海通过推进“全过程人民民主”,汇聚各方智慧共同推进中国式现代化',
},
],
blanks: [
{ id: 'basket-1', label: '' },
{ id: 'basket-2', label: '' },
{ id: 'basket-3', label: '' },
],
},
{
id: 'quiz-4',
time: 60 * 6 + 59,
title: '',
prompt: '请将你认为符合新时代践行“独立自主”思想的做法拖入篮子吧!',
correct: ['B', 'C', 'D'],
wrongValue: 'A',
wrongHint:
'这种做法违背了“独立自主”的原则,会导致发展受制于他人,不符合中国式现代化的本质要求。请你再想想吧!',
optionHints: {
A: '这种做法违背了“独立自主”的原则,会导致发展受制于他人,不符合中国式现代化的本质要求。请你再想想吧!',
B: '恭喜你,选择正确!这是我国践行独立自主思想的典范,彰显了我国在高端制造领域依靠自身力量突破核心技术的决心与能力。',
C: '恭喜你,选择正确!这是上海在改革开放中坚持独立自主、探索制度创新的生动实践,体现了在开放中掌握发展主动权的思路。',
D: '恭喜你,选择正确!这是上海坚持把发展放在自己力量基点上的具体体现,通过打造自主产业集群,在开放合作中牢牢掌握了发展主动权。',
},
options: [
{ value: 'A', label: '部分地区在产业发展中完全依赖国外技术,放弃自主研发投入' },
{
value: 'B',
label: 'C919大飞机研制成功,实现百余项关键技术突破,60%的自主配套率',
},
{
value: 'C',
label: '上海自贸试验区率先试点“负面清单管理”,临港新片区打造全产业链集群',
},
{
value: 'D',
label: '临港新片区布局“东方芯港”“生命蓝湾”等特色产业园区,构建自主可控产业生态',
},
],
blanks: [
{ id: 'basket-1', label: '' },
{ id: 'basket-2', label: '' },
{ id: 'basket-3', label: '' },
],
},
{
id: 'quiz-5',
time: 60 * 7 + 16,
title: '',
prompt: '毛泽东思想活的灵魂包括',
correct: ['实事求是', '群众路线', '独立自主'],
wrongValue: '党的建设',
wrongHint: '党的建设是新民主主义革命胜利的“三大法宝”之一。请你再想想吧!',
optionHints: {
实事求是: '',
群众路线: '',
独立自主: '',
党的建设: '党的建设是新民主主义革命胜利的“三大法宝”之一。请你再想想吧!',
},
options: [
{ value: '实事求是', label: 'A.实事求是' },
{ value: '群众路线', label: 'B.群众路线' },
{ value: '独立自主', label: 'C.独立自主' },
{ value: '党的建设', label: 'D.党的建设' },
],
blanks: [
{ id: 'blank-1', label: '' },
{ id: 'blank-2', label: '' },
{ id: 'blank-3', label: '' },
],
separators: ['、', '、'],
},
],
[]
)
const anchors: VideoAnchor[] = useMemo(() => {
return DRAG_QUIZZES.map((q) => ({
id: q.id,
time: q.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => {
const handleDragChange = (val: DragFillResult[]) => {
// quiz-5:参考黄炎培 DragQuizPanel 的用法,不做“每次拖拽都提示”,仅在填满后校验并提示
if (q.id === 'quiz-5') {
const valid = val.filter((v) => q.blanks.some((b) => b.id === v.blankId))
if (valid.some((v) => v.optionValue === q.wrongValue)) {
setDragQuizErrors((prevErr) => ({ ...prevErr, [q.id]: q.wrongHint }))
// 错误项拖拽后回到原来的选项框位(从结果中移除)
setDragQuizValues((prev) => ({
...prev,
[q.id]: valid.filter((v) => v.optionValue !== q.wrongValue),
}))
return
}
setDragQuizErrors((prevErr) => ({ ...prevErr, [q.id]: '' }))
setDragQuizValues((prev) => ({ ...prev, [q.id]: valid }))
if (valid.length < q.blanks.length) return
const filledSet = new Set(valid.map((v) => v.optionValue))
const answerSet = new Set(q.correct)
const isCorrect =
filledSet.size >= answerSet.size && [...answerSet].every((a) => filledSet.has(a))
if (isCorrect) {
setActiveSuccess(q.id)
triggerPuzzleAnimation(16)
setResumeCallback(() => resume)
}
return
}
const prev = dragQuizValues[q.id] || []
const valid = val.filter((v) => q.blanks.some((b) => b.id === v.blankId))
const prevOptionSet = new Set(prev.map((v) => v.optionValue))
const newlyAdded = valid.find((v) => !prevOptionSet.has(v.optionValue))
const newlyAddedValue = newlyAdded?.optionValue
const isNewlyWrong = newlyAddedValue ? newlyAddedValue === q.wrongValue : false
const isNewlyCorrect = newlyAddedValue ? newlyAddedValue !== q.wrongValue : false
if (valid.some((v) => v.optionValue === q.wrongValue)) {
const wrongMsg = newlyAdded?.optionValue
? q.optionHints[newlyAdded.optionValue] || q.wrongHint
: q.wrongHint
setDragQuizErrors((prevErr) => ({ ...prevErr, [q.id]: wrongMsg }))
setDragQuizFeedbackTypes((prevType) => ({ ...prevType, [q.id]: 'error' }))
setDragQuizValues((prev) => ({
...prev,
[q.id]: valid.filter((v) => v.optionValue !== q.wrongValue),
}))
return
}
const defaultTip = isNewlyCorrect ? '恭喜你,选择正确!' : ''
const tip = newlyAddedValue ? q.optionHints[newlyAddedValue] || defaultTip : ''
setDragQuizErrors((prevErr) => ({ ...prevErr, [q.id]: tip }))
if (newlyAddedValue) {
setDragQuizFeedbackTypes((prevType) => ({
...prevType,
[q.id]: isNewlyWrong ? 'error' : 'success',
}))
}
setDragQuizValues((prev) => ({ ...prev, [q.id]: valid }))
const filledSet = new Set(valid.map((v) => v.optionValue))
const answerSet = new Set(q.correct)
const isCorrect =
filledSet.size >= answerSet.size && [...answerSet].every((a) => filledSet.has(a))
if (isCorrect) {
setActiveSuccess(q.id)
if (q.id === 'quiz-2') triggerPuzzleAnimation(15)
setResumeCallback(() => resume)
}
}
return (
<>
{q.id === 'quiz-5' ? (
<DragQuizPanel
visible={activeSuccess !== q.id}
zIndex={10000}
title=""
questionPrefix={`【拖拽】${q.prompt}`}
questionSuffix="。"
options={q.options}
blanks={q.blanks}
blankSeparators={q.separators}
value={dragQuizValues[q.id] || []}
error={dragQuizErrors[q.id]}
onChange={handleDragChange}
/>
) : (
<BasketQuizPanel
visible={activeSuccess !== q.id}
zIndex={10000}
title={q.title}
questionPrefix={`【拖拽】${q.prompt}`}
options={q.options}
blanks={q.blanks}
blankSeparators={q.separators}
value={dragQuizValues[q.id] || []}
error={dragQuizErrors[q.id]}
feedbackType={dragQuizFeedbackTypes[q.id] || 'error'}
onChange={handleDragChange}
/>
)}
<SuccessAlert
visible={activeSuccess === q.id}
title="恭喜你,选择正确!"
content=""
onContinue={() => {
setActiveSuccess(null)
setDragQuizValues((prev) => {
const next = { ...prev }
delete next[q.id]
return next
})
setDragQuizErrors((prev) => {
const next = { ...prev }
delete next[q.id]
return next
})
setDragQuizFeedbackTypes((prev) => {
const next = { ...prev }
delete next[q.id]
return next
})
const cb = resumeCallback
setResumeCallback(null)
if (q.id === 'quiz-5') {
cb?.()
return
}
cb?.()
}}
/>
</>
)
},
}))
}, [
DRAG_QUIZZES,
activeSuccess,
dragQuizErrors,
dragQuizFeedbackTypes,
dragQuizValues,
resumeCallback,
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={showTransition}
onClose={() => {
setShowTransition(false)
setIntroDialogVisible(true)
}}
text="毛泽东思想是一个完整的科学思想体系,它的活的灵魂的三个基本方面——实事求是、群众路线、独立自主,是贯穿于毛泽东思想各个组成部分的立场、观点、方法,这三个方面在新时代被赋予了更加丰富的内涵和实践形式,成为习近平新时代中国特色社会主义思想的方法论基础和行动指南。"
animationSpeed={200}
/>
{/* 引入视频:进入页面自动播放(暂用其他视频代替) */}
{showIntroVideo && (
<Video
src={getPublicPath('video/maozedong/mzd_10.mp4')}
className="h-full w-full z-20"
playsInline
showSkipButton
autoPlay
onEnded={() => {
setShowIntroVideo(false)
setIntroQuizVisible(true)
}}
/>
)}
<SingleChoiceQuizPanel
visible={introQuizVisible}
zIndex={10001}
title="【单选】毛泽东思想被确立为中国共产党的指导思想是在哪次会议上?"
options={[
{ value: 'A', label: 'A.中共一大' },
{ value: 'B', label: 'B.遵义会议' },
{ value: 'C', label: 'C.中共七大' },
]}
correctAnswer="C"
onWrongSelect={(value) => {
if (value === 'A') {
return '中共一大标志着党的成立。请你再想想吧!'
}
if (value === 'B') {
return '遵义会议确立了毛泽东在党内的领导地位。请你再想想吧!'
}
return '请结合党史时间线再想想吧!'
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setIntroQuizVisible(false)
setIntroQuizSuccessVisible(true)
}}
/>
<SuccessAlert
visible={introQuizSuccessVisible}
title="恭喜你,选择正确!"
content="1945年,中共七大正式将毛泽东思想确立为党的指导思想并写入党章。"
onContinue={() => {
setIntroQuizSuccessVisible(false)
setShowTransition(true)
}}
/>
{/* 页面进入后直接播放引入视频,不再展示额外引导 */}
{/* 引入视频结束后的对话(图1) */}
<GuideSteps
visible={introDialogVisible}
onVisibleChange={(v) => {
setIntroDialogVisible(v)
if (!v) {
setShowMainVideo(true)
handleVideoCanPlay()
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_24.mp3')}
>
关于“实事求是、群众路线、独立自主”这十二个字我还是有些不理解,总感觉这十二个字离我们很遥远。
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_25.mp3')}>
“实事求是、群众路线、独立自主”这十二个字,可以说是中国共产党人认识世界、改造世界的根本方法与价值遵循。今天我们有幸邀请到了华东师范大学马克思主义学院马立政副教授。关于你的问题,我想马教授会给你指点迷津!
</GuideStepsItem>
</GuideSteps>
{/* 主视频(视频11):对话结束自动播放,并插入题目锚点 */}
{showMainVideo && (
<Video
ref={videoRef}
src={getPublicPath('video/maozedong/mzd_neihua.mp4')}
className="h-full w-full"
anchors={anchors}
playsInline
controls
autoPlay
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowMainVideo(false)
// 等视频组件卸载后再弹出信件
setTimeout(() => {
setShowLetter(true)
}, 300)
}}
/>
)}
{/* 写给毛主席的信:视频播完后出现 */}
<LetterToMaozedong
visible={showLetter}
onClose={() => {
setShowLetter(false)
}}
onAnimationEnd={() => {
setShowCompleted(true)
}}
showVideo={true}
/>
<EndingOverlay
visible={showCompleted}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_26.mp3')}
text="恭喜你圆满完成毛泽东旧居陈列馆的学习之旅,请带着研学感悟,前往孙中山故居纪念馆继续探访吧~"
animationSpeed={200}
onContinue={() => {
setShowCompleted(false)
navigate('/menu')
}}
/>
</div>
)
}
export default MaozedongInternalization
+316
View File
@@ -0,0 +1,316 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
StudyGuideModal,
Title,
SelectFillQuizPanel,
Video,
type VideoRef,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useMemo, useRef, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
import type { SelectFillResult } from '@/components/layout/quiz'
import { saveMaozedongStore, useMaozedongStore } from '@/store/maozedongStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
// starName 固定为 maozedong
const starName = 'maozedong'
// 展品 ID 与资源路径映射,ID 用来做唯一标识
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'maozedong/place1/场景1展品1',
2: 'maozedong/place1/场景1展品2',
}
const MaozedongPlace1 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
// 接入持久化:记录本场景 place1 的已访问展品与断点
const markVisited = useMaozedongStore((s) => s.markExhibitVisited)
const getVisited = useMaozedongStore((s) => s.getVisitedExhibits)
const setLastVisitedPlace = useMaozedongStore((s) => s.setLastVisitedPlace)
const persistedVisited = useMemo(() => new Set(getVisited('place1') as (1 | 2)[]), [getVisited])
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true)
// 对话结束后的学习引导弹窗
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false)
// 展品2的对话引导状态
const [guideVisible2, setGuideVisible2] = useState(false)
const [guideStep2, setGuideStep2] = useState(0)
// 当前激活的展品 ID
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 已访问的展品(初始化自 store;随后每次关闭展品时写回)
const [visited, setVisited] = useState<Set<1 | 2>>(() => new Set(persistedVisited))
// 展品2关闭后播放的视频 + 选择填空题
const [showVideo2, setShowVideo2] = useState(false)
const video2Ref = useRef<VideoRef>(null)
const [quizVisible, setQuizVisible] = useState(false)
const [successAlertVisible, setSuccessAlertVisible] = useState(false)
// Cursor 顺序控制:1 -> 2
const { isEnabled } = useCursorController({
initialEnabled: [2],
dependencies: {
1: [2],
},
visited,
})
const handleOpenExhibit = (id: 1 | 2) => {
setActiveExhibitId(id)
// 根据展品ID显示对应的对话
if (id === 1) {
setGuideVisible2(true)
setGuideStep2(0)
}
}
// 监听展品2对话引导步骤变化
const handleGuideStepChange2 = (step: number) => {
setGuideStep2(step)
}
// 监听展品2对话引导完成
const handleGuideVisibleChange2 = (visible: boolean) => {
setGuideVisible2(visible)
}
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
if (!moduleName) return null
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/place1/场景1`),
}}
>
{/* 标题 */}
<Title
title="探索方向·确立信仰"
subtitle={subtitle}
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-106 bottom-150"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute left-170 bottom-135"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-140"
src={getStaticImage(`${starName}/place1/场景1继续前进`)}
button
onClick={() => {
setLastVisitedPlace('place2')
saveMaozedongStore()
navigate(`/${moduleName}/${starName}/place2`)
}}
/>
)}
{/* 展品2的对话引导 */}
<GuideSteps
visible={guideVisible2}
onVisibleChange={handleGuideVisibleChange2}
currentStep={guideStep2}
onStepChange={handleGuideStepChange2}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_03.mp3')}
>
那参加中共一大时,毛主席对未来有什么想法吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_04.mp3')}>
当时全国只有50多个党员,毛主席所在的湖南也才几个。但他觉得,重要的是把马克思主义和中国的实际情况结合起来。就像种田,种子再好,也得看土壤——中国的“土壤”就是农民占大多数,这是和苏联不一样的地方。
</GuideStepsItem>
</GuideSteps>
{/* 展品遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 2) {
triggerPuzzleAnimation(1)
}
if (activeExhibitId === 1) {
triggerPuzzleAnimation(2)
}
}}
onClose={() => {
if (activeExhibitId === 2) {
setActiveExhibitId(null)
setShowVideo2(true)
return
}
if (activeExhibitId !== null) {
setVisited((prev) => {
const next = new Set(prev)
next.add(activeExhibitId)
// 写回持久化
markVisited('place1', activeExhibitId)
return next
})
}
setActiveExhibitId(null)
}}
/>
{/* 展品2关闭后播放的视频 */}
{showVideo2 && (
<Video
ref={video2Ref}
src={getPublicPath('video/maozedong/mzd_00.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo2(false)
setQuizVisible(true)
}}
onSkip={() => {
setShowVideo2(false)
setQuizVisible(true)
}}
/>
)}
{/* 展品2对话结束后的选择填空题 + 成功提示(使用 ScaledPortal) */}
<ScaledPortal enabled={quizVisible || successAlertVisible} zIndex={10050}>
<>
<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: '0',
}}
errorMessage="1920年5月5日到7月初,在上海居住的2个月期间,毛主席的思想完成了向马克思主义者的转变。请你再想想吧!"
onSubmit={(_results: SelectFillResult[], isCorrect: boolean) => {
if (isCorrect) {
setQuizVisible(false)
setSuccessAlertVisible(true)
}
}}
onError={(_error: string) => {
// 错误时仅提示,再让学生重做
}}
submitText="确定"
zIndex={10000}
/>
<SuccessAlert
visible={successAlertVisible}
onContinue={() => {
setSuccessAlertVisible(false)
setVisited((prev) => {
const next = new Set(prev)
next.add(2)
markVisited('place1', 2)
return next
})
}}
/>
</>
</ScaledPortal>
{/* 学习引导词 */}
<StudyGuideModal
visible={showGuideModal}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_02.mp3')}
onContinue={() => {
setShowGuideModal(false)
setShowAfterDialogGuide(true)
}}
ailgn="left"
>
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
{'\n'}
2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
{'\n'}
3.所有动态光点的展品全部学习后才能进入下一个场景学习。
</StudyGuideModal>
{/* 对话结束后的学习引导 */}
<StudyGuideModal
visible={showAfterDialogGuide}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_01.mp3')}
onContinue={() => setShowAfterDialogGuide(false)}
firstLineIndent="2em"
>
{`同学你好,欢迎来到“走进故居·对话”学习模块!
此刻你正处在毛泽东旧居陈列馆里,接下来请跟着时光的脚步,一同回望毛泽东主席人生中4段风云激荡的岁月:摸索方向 确立信仰、播撒红色火种、吹响合作号角、唤起工农群众。准备好推开这扇门,走进那段滚烫的峥嵘岁月了吗?`}
</StudyGuideModal>
</div>
)
}
export default MaozedongPlace1
+320
View File
@@ -0,0 +1,320 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
MultipleChoiceQuizPanel,
Video,
type VideoRef,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useMemo, useRef, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
import { useMaozedongStore, saveMaozedongStore } from '@/store/maozedongStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
// starName 固定为 maozedong
const starName = 'maozedong'
// 展品 ID 与资源路径映射,ID 用来做唯一标识
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'maozedong/place2/场景2展品1',
2: 'maozedong/place2/场景2展品2',
}
const MaozedongPlace2 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
// 当前激活的展品 ID
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 持久化接入
const markVisited = useMaozedongStore((s) => s.markExhibitVisited)
const getVisited = useMaozedongStore((s) => s.getVisitedExhibits)
const setLastVisitedPlace = useMaozedongStore((s) => s.setLastVisitedPlace)
const persistedVisited = useMemo(() => new Set(getVisited('place2') as (1 | 2)[]), [getVisited])
const [visited, setVisited] = useState<Set<1 | 2>>(() => new Set(persistedVisited))
// 展品1的对话引导状态
const [guideVisible1, setGuideVisible1] = useState(false)
const [guideStep1, setGuideStep1] = useState(0)
// 展品2的对话引导状态
const [guideVisible2, setGuideVisible2] = useState(false)
const [guideStep2, setGuideStep2] = useState(0)
// 展品1对话结束后播放的视频状态(播完后再出题目)
const [showVideo, setShowVideo] = useState(false)
const videoRef = useRef<VideoRef>(null)
// 展品1对话结束后的多选题
const [quizVisible, setQuizVisible] = useState(false)
const [quizPassed, setQuizPassed] = useState(false)
const [successAlertVisible, setSuccessAlertVisible] = useState(false)
// Cursor 顺序控制:1 -> 2
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
},
visited,
})
const handleOpenExhibit = (id: 1 | 2) => {
setActiveExhibitId(id)
if (id === 1) {
setGuideVisible1(true)
setGuideStep1(0)
// 重新进入时重置题目完成状态
setQuizPassed(false)
} else if (id === 2) {
// 展品2:先看展品,关闭展品后再显示对话
setGuideVisible2(false)
setGuideStep2(0)
}
}
// 展品1对话步骤变化
const handleGuideStepChange1 = (step: number) => {
setGuideStep1(step)
}
// 展品1对话完成
const handleGuideVisibleChange1 = (visible: boolean) => {
setGuideVisible1(visible)
if (!visible && !quizPassed) {
setShowVideo(true)
}
}
// 展品2对话步骤变化
const handleGuideStepChange2 = (step: number) => {
setGuideStep2(step)
}
// 展品2对话完成
const handleGuideVisibleChange2 = (visible: boolean) => {
setGuideVisible2(visible)
}
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
if (!moduleName) return null
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/place2/场景2`),
}}
>
{/* 标题 */}
<Title
title="播撒红色火种"
subtitle={subtitle}
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-217 bottom-145"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute right-92 bottom-105"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place3')
saveMaozedongStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
/>
)}
{/* 展品遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(3)
}
if (activeExhibitId === 2) {
triggerPuzzleAnimation(4)
}
}}
onClose={() => {
const currentId = activeExhibitId
if (currentId !== null) {
setVisited((prev) => {
const next = new Set(prev)
next.add(currentId)
markVisited('place2', currentId)
saveMaozedongStore()
return next
})
}
setActiveExhibitId(null)
// 展品2:关闭后显示对话
if (currentId === 2) {
setGuideStep2(0)
setGuideVisible2(true)
}
}}
/>
{/* 展品1对话结束后播放的视频:播放完毕后出现题目 */}
{showVideo && (
<ScaledPortal zIndex={20000}>
<Video
ref={videoRef}
src={getPublicPath('video/maozedong/mzd_002.mp4')}
className="absolute inset-0 w-full h-full"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo(false)
setQuizVisible(true)
}}
/>
</ScaledPortal>
)}
{/* 展品1的对话引导(文字+语音播报占位) */}
<GuideSteps
visible={guideVisible1}
onVisibleChange={handleGuideVisibleChange1}
currentStep={guideStep1}
onStepChange={handleGuideStepChange1}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_05.mp3')}
>
中共一大定了“推翻资产阶级政权”的目标,但中共二大提
出了“反帝反封建的民主革命纲领”,为什么会有这样的转变呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_06.mp3')}>
中共一大时我们中国共产党刚成立,像刚学走路的孩子,
目标很宏大——“建立无产阶级专政”“消灭阶级”。但毛主席回到湖南后,
做了实际调查发现必须先解决“眼前的敌人”。
</GuideStepsItem>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_07.mp3')}
>
“眼前的敌人”?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_08.mp3')}>
对!想想看1922年长沙的码头工人罢工,安源的路矿工人罢工,他们的敌人是谁?是外国资本家,是军阀。中共二大时毛主席虽然没有出席,但完全赞同“最低纲领”——打倒军阀、推翻帝国主义压迫,实现民族独立和人民民主。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这就像盖房子,得先把地基打好;等打倒了军阀和帝国主义,再谈社会主义也不迟。
</GuideStepsItem>
</GuideSteps>
{/* 展品2的对话引导 */}
<GuideSteps
visible={guideVisible2}
onVisibleChange={handleGuideVisibleChange2}
currentStep={guideStep2}
onStepChange={handleGuideStepChange2}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_09.mp3')}>
后来喊出的“打倒列强,除军阀”的口号,就是从这儿来的。
</GuideStepsItem>
</GuideSteps>
{/* 展品1对话结束后的多选题 + 成功提示(使用 ScaledPortal) */}
<ScaledPortal enabled={quizVisible || successAlertVisible} zIndex={10050}>
<>
<MultipleChoiceQuizPanel
visible={quizVisible}
title="【多选】1922年7月16日至23日,中国共产党第二次全国代表大会在上海南成都路辅德里625号召开。大会制定了党的最低纲领和最高纲领,党的最低纲领,即党在民主革命阶段的纲领是:"
options={[
{ value: 'A', label: 'A.打倒军阀,建立国内和平' },
{
value: 'B',
label: 'B.推翻国际帝国主义的压迫,达到中华民族的完全独立',
},
{ value: 'C', label: 'C.统一中国为真正的民主共和国' },
]}
correctAnswer={['A', 'B', 'C']}
minSelections={1}
maxSelections={3}
errorMessage={(selectedValues) => {
if (selectedValues.length < 3) {
return '你还没有全面了解中共二大提出的党的最低纲领哦。请你再想想吧!'
}
return '还不太对,再仔细对照对话内容想一想吧~'
}}
onSubmit={(_values, isCorrect) => {
if (isCorrect) {
setQuizVisible(false)
setQuizPassed(true)
setSuccessAlertVisible(true)
}
}}
zIndex={10000}
/>
<SuccessAlert
visible={successAlertVisible}
onContinue={() => {
setSuccessAlertVisible(false)
}}
/>
</>
</ScaledPortal>
</div>
)
}
export default MaozedongPlace2
+310
View File
@@ -0,0 +1,310 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
Video,
type VideoRef,
MultipleChoiceQuizPanel,
Narration,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useEffect, useMemo, useRef, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
import { useMaozedongStore, saveMaozedongStore } from '@/store/maozedongStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
// starName 固定为 maozedong
const starName = 'maozedong'
// 展品 ID 与资源路径映射,ID 用来做唯一标识
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: 'maozedong/place3/场景3展品1',
2: 'maozedong/place3/场景3展品2',
3: 'maozedong/place3/场景3展品3',
}
const MaozedongPlace3 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
// 当前激活的展品 ID
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
// 持久化接入
const markVisited = useMaozedongStore((s) => s.markExhibitVisited)
const getVisited = useMaozedongStore((s) => s.getVisitedExhibits)
const setLastVisitedPlace = useMaozedongStore((s) => s.setLastVisitedPlace)
const persistedVisited = useMemo(
() => new Set(getVisited('place3') as (1 | 2 | 3)[]),
[getVisited]
)
const [visited, setVisited] = useState<Set<1 | 2 | 3>>(() => new Set(persistedVisited))
// 入场旁白
const [narrationVisible, setNarrationVisible] = useState(false)
// 光标3对应的对话状态
const [guide3Visible, setGuide3Visible] = useState(false)
const [guide3Step, setGuide3Step] = useState(0)
// 光标3的视频和题目状态
const [showVideo3, setShowVideo3] = useState(false)
const video3Ref = useRef<VideoRef>(null)
const [quiz3Visible, setQuiz3Visible] = useState(false)
const [successAlert3Visible, setSuccessAlert3Visible] = useState(false)
// Cursor
const { isEnabled } = useCursorController({
initialEnabled: [3],
dependencies: {
2: [3],
1: [3],
},
visited,
})
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id)
setLastVisitedPlace('place3')
saveMaozedongStore()
if (id === 3) {
setGuide3Step(0)
setGuide3Visible(true)
}
}
const progressCount = visited.size
const total = 3
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
useEffect(() => {
if (visited.has(1) && visited.has(2)) {
setNarrationVisible(true)
}
}, [visited])
if (!moduleName) return null
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/place3/场景3`),
}}
>
<Narration
visible={narrationVisible}
onClose={() => setNarrationVisible(false)}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_12.mp3')}
text={`本党(国民党)为革命党,凡有利于革命的可采用,有害于革命的即应摒弃。——(毛泽东在国民党“一大”上的发言)`}
/>
{/* 标题 */}
<Title
title="吹响合作号角"
subtitle={subtitle}
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 left-162 bottom-140"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute left-200 bottom-140"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute right-155 bottom-90"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place4')
saveMaozedongStore()
navigate(`/${moduleName}/${starName}/place4`)
}}
/>
)}
{/* 光标3的对话(点击展品3时出现) */}
<GuideSteps
visible={guide3Visible}
currentStep={guide3Step}
onStepChange={setGuide3Step}
onVisibleChange={(nextVisible) => {
setGuide3Visible(nextVisible)
}}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_10.mp3')}
>
中共三大决定和国民党合作,但党内有争论。毛主席当时是什么态度呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_11.mp3')}>
争论确实激烈!有人觉得和国民党合作是“降低身份”,但毛主席认为这是必要的。当时孙中山先生领导的国民党有革命传统,但缺真正的革命力量;我们有马克思主义,但缺群众基础。国共合作就好比“星火借烈焰,共燃燎原势”。
</GuideStepsItem>
</GuideSteps>
{/* 展品遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 3) {
triggerPuzzleAnimation(5)
}
if (activeExhibitId === 1) {
triggerPuzzleAnimation(6)
}
if (activeExhibitId === 2) {
triggerPuzzleAnimation(7)
}
}}
onClose={() => {
const currentId = activeExhibitId
if (currentId === 3) {
// 关闭展品3窗口:结束对话,播放视频
setGuide3Step(0)
setGuide3Visible(false)
setActiveExhibitId(null)
setShowVideo3(true)
return
}
if (currentId !== null) {
setVisited((prev) => {
const next = new Set(prev)
next.add(currentId)
markVisited('place3', currentId)
saveMaozedongStore()
return next
})
}
setActiveExhibitId(null)
}}
/>
{/* 光标3的视频:播放结束后出现多选题 */}
{showVideo3 && (
<Video
ref={video3Ref}
src={getPublicPath('video/maozedong/mzd_01.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo3(false)
setQuiz3Visible(true)
}}
/>
)}
{/* 光标3的视频后的多选题 + 成功提示(使用 ScaledPortal) */}
<ScaledPortal enabled={quiz3Visible || successAlert3Visible} zIndex={10050}>
<>
<MultipleChoiceQuizPanel
visible={quiz3Visible}
zIndex={10000}
title="【多选】1923年6月12日至20日,中国共产党第三次全国代表大会在广州召开。以下符合中共三大核心内容及历史意义的有:"
options={[
{
value: 'A',
label: 'A.大会中心议题是讨论全体共产党员以个人身份加入国民党,实现国共“党内合作”',
},
{
value: 'B',
label: 'B.大会强调共产党党员加入国民党后,需保持政治、思想和组织上的独立性',
},
{
value: 'C',
label: 'C.大会标志着中国共产党以国共合作为基础的统一战线政策正式形成',
},
{
value: 'D',
label: 'D.大会提出了“打倒军阀、推翻帝国主义”的革命目标',
},
]}
correctAnswer={['A', 'B', 'C']}
minSelections={1}
maxSelections={4}
errorMessage={(selectedValues) => {
if (selectedValues.includes('D')) {
return '“打倒军阀、推翻帝国主义”的革命目标是中共二大提出的,中共三大是肯定该目标的正确性,并明确通过国共合作推进这一目标的实现。请你再想想吧!'
}
if (selectedValues.length < 3) {
return '恭喜你,答案已经十分接近!请再结合上下文仔细完善吧。'
}
return '请再仔细分析一下题目,做出更全面的选择吧!'
}}
onSubmit={(_values, isCorrect) => {
if (isCorrect) {
setQuiz3Visible(false)
setSuccessAlert3Visible(true)
// 答题成功后标记展品3已访问
setVisited((prev) => {
const next = new Set(prev)
next.add(3)
return next
})
markVisited('place3', 3)
}
}}
/>
<SuccessAlert
content="你理解了中共三大和国民党合作的核心内容及历史意义"
visible={successAlert3Visible}
onContinue={() => {
setSuccessAlert3Visible(false)
}}
/>
</>
</ScaledPortal>
</div>
)
}
export default MaozedongPlace3
+610
View File
@@ -0,0 +1,610 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
EndingOverlay,
Image,
Title,
Video,
type VideoRef,
SingleChoiceQuizPanel,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useMemo, useRef, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
import { useMaozedongStore, saveMaozedongStore } from '@/store/maozedongStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
// starName 固定为 maozedong
const starName = 'maozedong'
// 展品 ID 与资源路径映射,ID 用来做唯一标识
const exhibitSrcMap: Record<1 | 2 | 3 | 4 | 5, string> = {
1: 'maozedong/place4/场景4展品1',
2: 'maozedong/place4/场景4展品2_',
3: 'maozedong/place4/场景4展品4',
4: 'maozedong/place4/场景4展品5',
5: 'maozedong/place4/场景4展品6',
}
const MaozedongPlace4 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
// 场景级引导对话显示状态(暂未使用,可根据需要开启)
const [visible, setVisible] = useState(false)
// 当前激活的展品 ID
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | 5 | null>(null)
// 持久化接入
const markVisited = useMaozedongStore((s) => s.markExhibitVisited)
const getVisited = useMaozedongStore((s) => s.getVisitedExhibits)
const persistedVisited = useMemo(
() => new Set(getVisited('place4') as (1 | 2 | 3 | 4 | 5)[]),
[getVisited]
)
const [visited, setVisited] = useState<Set<1 | 2 | 3 | 4 | 5>>(() => new Set(persistedVisited))
// 光标1的对话状态
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
// 光标3的对话状态
const [guide3Visible, setGuide3Visible] = useState(false)
const [guide3Step, setGuide3Step] = useState(0)
// 光标4(展品4)对话
// 光标4关闭后的过场视频与总结对话
const [showVideo4, setShowVideo4] = useState(false)
const [guide4SummaryVisible, setGuide4SummaryVisible] = useState(false)
const [guide4SummaryStep, setGuide4SummaryStep] = useState(0)
const [endingVisible, setEndingVisible] = useState(false)
// 光标2的对话状态
const [guide2Visible, setGuide2Visible] = useState(false)
const [guide2Step, setGuide2Step] = useState(0)
// 光标2的视频与题目状态
const [showVideo2, setShowVideo2] = useState(false)
const video2Ref = useRef<VideoRef>(null)
const [quizStep, setQuizStep] = useState(0) // 0 表示未开始,1-4 为四道题
const [quizVisible, setQuizVisible] = useState(false)
const [successAlertVisible, setSuccessAlertVisible] = useState(false)
const [successAlertContent, setSuccessAlertContent] = useState<string>('')
// Cursor 顺序控制:1 -> 2 -> 3 -> 4 -> 5 -> 6
const { isEnabled } = useCursorController({
initialEnabled: [2],
dependencies: {
1: [2],
3: [1],
4: [3, 5],
5: [1],
},
visited,
})
const handleOpenExhibit = (id: 1 | 2 | 3 | 4 | 5) => {
setActiveExhibitId(id)
if (id === 1) {
setGuide1Step(0)
setGuide1Visible(true)
}
if (id === 2) {
setGuide2Step(0)
setGuide2Visible(true)
setQuizStep(0)
}
if (id === 3) {
setGuide3Step(0)
setGuide3Visible(true)
}
}
if (!moduleName) return null
const progressCount = visited.size
const total = 5
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/place4/场景4`),
}}
>
{/* 标题 */}
<Title
title="唤起工农群众"
subtitle={subtitle}
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`)
}
}}
/>
{completed && (
<Image
className="absolute right-0 bottom-0"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/home`)
}
}}
/>
)}
{/* 光标1 */}
<Cursor
className="absolute left-105 bottom-158"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute left-199 bottom-162"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
{/* <Cursor
className="absolute left-215 bottom-163"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/> */}
{/* 光标3 */}
<Cursor
className="absolute left-212 bottom-123"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 光标4 */}
<Cursor
className="absolute right-186 bottom-162"
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
disabled={!isEnabled(4)}
/>
{/* 光标5 */}
<Cursor
className="absolute right-153 bottom-123"
onClick={() => handleOpenExhibit(5)}
read={visited.has(5)}
disabled={!isEnabled(5)}
/>
{/* 场景对话 */}
<GuideSteps
visible={visible}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible)
}}
showMask={false}
>
<GuideStepsItem speaker="student">小沪,毛泽东是如何组织和发动工农群众的?</GuideStepsItem>
<GuideStepsItem speaker="guide">
1925年,毛泽东回到湖南,深入农村调查研究,写出了《湖南农民运动考察报告》。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
他认识到农民是中国革命的主力军,只有发动农民,革命才能成功。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
毛泽东在各地建立农民协会,组织农民开展减租减息、反对封建压迫的斗争。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
同时,他也重视工人运动,在安源、长沙等地组织工人罢工,提高工人的政治觉悟。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
通过这些工作,毛泽东为后来的革命斗争奠定了坚实的群众基础。
</GuideStepsItem>
</GuideSteps>
{/* 光标1的对话:点击展品1时出现 */}
<GuideSteps
visible={guide1Visible}
currentStep={guide1Step}
onStepChange={setGuide1Step}
onVisibleChange={(nextVisible) => {
setGuide1Visible(nextVisible)
}}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_15.mp3')}
>
看来中共四大明确提出了“无产阶级领导权”和“工农联盟”,这和毛主席的实践有关吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_16.mp3')}>
太有关系了!中共三大后毛主席到上海工作了一段时间,看到工人运动轰轰烈烈,
但农民还是“沉默的大多数”。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
后来毛主席回湖南,专门跑了湘潭、湘乡、衡山、
醴陵、长沙五县,发现农民不仅能革命,而且革命性极强——他们被剥削得最狠, 反抗起来也最坚决!
</GuideStepsItem>
</GuideSteps>
{/* 光标2的对话:点击展品2时出现 */}
<GuideSteps
visible={guide2Visible}
currentStep={guide2Step}
onStepChange={setGuide2Step}
onVisibleChange={(nextVisible) => {
setGuide2Visible(nextVisible)
}}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_13.mp3')}
>
那中共四大为什么召开呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_14.mp3')}>
因为此前中共三大虽然确定了国共合作的方针,但未明确解决
“谁领导革命”的问题。我们必须要明确“谁领导革命”“依靠谁革命” 这两个基本问题。
</GuideStepsItem>
</GuideSteps>
{/* 光标3的对话:点击展品3时出现(图1) */}
<GuideSteps
visible={guide3Visible}
currentStep={guide3Step}
onStepChange={setGuide3Step}
onVisibleChange={(nextVisible) => {
setGuide3Visible(nextVisible)
}}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_17.mp3')}
>
所以毛主席觉得,光靠工人不够,得把农民拉进革命队伍?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_18.mp3')}>
对!无产阶级要领导革命,必须联合农民——他们是工人阶级的“天然盟友”。
四大虽然还没有明确提出“农村包围城市”,但已经意识到农民问题的重要性。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
后来毛主席常说:“国民革命的中心问题,就是农民问题,一切都要靠农民问题的解决。”
这句话的种子,就是在四大前后埋下的。
</GuideStepsItem>
</GuideSteps>
{/* 光标4视频结束后出现的总结对话(图1) */}
<GuideSteps
visible={guide4SummaryVisible}
currentStep={guide4SummaryStep}
onStepChange={setGuide4SummaryStep}
onVisibleChange={(nextVisible) => {
setGuide4SummaryVisible(nextVisible)
if (!nextVisible) {
setEndingVisible(true)
}
}}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_19.mp3')}
>
为有牺牲多壮志,敢教日月换新天。小沪,通过您的讲解,
我明白了中共一大到四大是在艰难探索中不断前进的——从建党的理想,
到制定民主革命纲领;从国共合作,到发现农民的力量。这些探索对我们今天有什么启示呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_20.mp3')}>
同学问到了根子上!革命就像爬山,一开始可能看不清山顶的样子,
但只要沿着正确的方向,一步一步扎扎实实地走,总会离目标越来越近。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
你们现在学历史,不能只记年代和事件,更要理解“为什么”——
为什么马克思主义要和中国实际相结合?为什么农民是革命的重要力量?
把这些道理想通了,将来不管做什么事,都能脚踏实地。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
“实事求是”这四个字,是我们党一路走来的法宝,希望你也能记住。
</GuideStepsItem>
</GuideSteps>
{/* 展品遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 2) {
triggerPuzzleAnimation(8)
}
if (activeExhibitId === 1) {
triggerPuzzleAnimation(9)
}
if (activeExhibitId === 3) {
triggerPuzzleAnimation(10)
}
if (activeExhibitId === 4) {
triggerPuzzleAnimation(12)
}
if (activeExhibitId === 5) {
triggerPuzzleAnimation(11)
}
}}
onClose={() => {
const currentId = activeExhibitId
// 展品2:关闭后播放视频和答题,全部正确后再标记已访问
if (currentId === 2) {
setGuide2Step(0)
setGuide2Visible(false)
setActiveExhibitId(null)
setShowVideo2(true)
return
}
// 展品4:关闭后播放视频,视频结束后显示总结对话,并立即标记已访问
if (currentId === 4) {
setActiveExhibitId(null)
setVisited((prev) => {
const next = new Set(prev)
next.add(4)
markVisited('place4', 4)
saveMaozedongStore()
return next
})
setShowVideo4(true)
return
}
if (currentId !== null) {
setVisited((prev) => {
const next = new Set(prev)
next.add(currentId)
markVisited('place4', currentId)
saveMaozedongStore()
return next
})
}
setActiveExhibitId(null)
}}
/>
<EndingOverlay
visible={endingVisible}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_21.mp3')}
text="我们已经了解了毛主席1924年前后居住在上海时所经历的重要事件。接下来,请你跨越时空,走进风云激荡的1924年,通过参与毛主席在筹备中共四大期间做出的几次关键选择,体悟早期共产党人探索革命道路的初心与智慧,体悟理论与实践结合的深刻意义。"
onContinue={() => {
setEndingVisible(false)
}}
animationSpeed={200}
/>
{/* 光标4:展品关闭后播放的视频 */}
{showVideo4 && (
<Video
src={getPublicPath('video/maozedong/mzd_02.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onSkip={() => {
setShowVideo4(false)
setGuide4SummaryStep(0)
setGuide4SummaryVisible(true)
}}
onEnded={() => {
setShowVideo4(false)
setGuide4SummaryStep(0)
setGuide4SummaryVisible(true)
}}
/>
)}
{/* 光标2:展品关闭后播放的视频 */}
{showVideo2 && (
<Video
ref={video2Ref}
src={getPublicPath('video/maozedong/mzd_5.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo2(false)
setQuizStep(1)
setQuizVisible(true)
}}
/>
)}
{/* 光标2:四道单选题 + 每题答对后的提示 */}
<ScaledPortal enabled={quizVisible || successAlertVisible} zIndex={10050}>
<>
{/* 题目1:中共四大召开时间 */}
<SingleChoiceQuizPanel
visible={quizVisible && quizStep === 1}
zIndex={10000}
title="【单选】中共四大召开的时间是( )"
options={[
{ value: 'A', label: 'A.1923年6月' },
{ value: 'B', label: 'B.1924年1月' },
{ value: 'C', label: 'C.1925年1月' },
{ value: 'D', label: 'D.1926年7月' },
]}
correctAnswer="C"
onWrongSelect={(value) => {
if (value === 'A') {
return '1923年6月,中共三大召开,确定了国共合作方针。请你再想想吧!'
}
if (value === 'B') {
return '1924年1月,国民党一大召开,国共合作正式形成。请你再想想吧!'
}
if (value === 'D') {
return '1926年7月,北伐战争开始。请你再想想吧!'
}
return '请结合时间线再仔细思考一下~'
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setQuizVisible(false)
setSuccessAlertContent('中共四大召开于1925年1月11日至22日,在上海。')
setSuccessAlertVisible(true)
}}
/>
{/* 题目2:召开地点 */}
<SingleChoiceQuizPanel
visible={quizVisible && quizStep === 2}
zIndex={10000}
title="【单选】中共四大召开的地点是:"
options={[
{ value: 'A', label: 'A.北京' },
{ value: 'B', label: 'B.上海' },
{ value: 'C', label: 'C.广州' },
{ value: 'D', label: 'D.武汉' },
]}
correctAnswer="B"
onWrongSelect={(value) => {
if (value === 'A') {
return '现在中国共产党全国代表大会多在北京召开,但是中共四大不是哦!请你再想想吧!'
}
if (value === 'C') {
return '中共三大在广州召开,请你再想想吧!'
}
if (value === 'D') {
return '中共五大在武汉召开,请你再想想吧!'
}
return '请再仔细对照题干信息选择~'
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setQuizVisible(false)
setSuccessAlertContent('')
setSuccessAlertVisible(true)
}}
/>
{/* 题目3:首次明确提出的重要问题 */}
<SingleChoiceQuizPanel
visible={quizVisible && quizStep === 3}
zIndex={10000}
title="【单选】中共四大第一次明确提出的重要问题是:"
options={[
{ value: 'A', label: 'A.党的建设问题' },
{ value: 'B', label: 'B.武装斗争问题' },
{ value: 'C', label: 'C.无产阶级领导权和工农联盟问题' },
{ value: 'D', label: 'D.土地革命问题' },
]}
correctAnswer="C"
onWrongSelect={(value) => {
if (value === 'A') {
return '古田会议更系统阐述党的建设问题哦!请你再想想吧!'
}
if (value === 'B') {
return '八七会议更系统阐述武装斗争问题哦!请你再想想吧!'
}
if (value === 'D') {
return '八七会议后提出土地革命问题哦!请你再想想吧!'
}
return '请再仔细比较不同会议的主要议题~'
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setQuizVisible(false)
setSuccessAlertContent('')
setSuccessAlertVisible(true)
}}
/>
{/* 题目4:党的基本组织形式 */}
<SingleChoiceQuizPanel
visible={quizVisible && quizStep === 4}
zIndex={10000}
title="【单选】中共四大确立的党的基本组织形式是:"
options={[
{ value: 'A', label: 'A.组' },
{ value: 'B', label: 'B.支部' },
// { value: "C", label: "C.区委" },
// { value: "D", label: "D.中央局" },
]}
correctAnswer="B"
onWrongSelect={(value) => {
if (value === 'A') {
return '中共四大以前党的基本组织形式是组或者小组,请你再想想吧!'
}
return '请注意中共四大之后对基层组织形式的规定~'
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setQuizVisible(false)
setSuccessAlertContent(
'中共四大明确“支部是党的基本组织”,规定“有三名党员即可成立一个支部”。'
)
setSuccessAlertVisible(true)
// 全部答对后标记展品2为已访问
setVisited((prev) => {
const next = new Set(prev)
next.add(2)
return next
})
}}
/>
{/* 每题答对后的统一提示框 */}
<SuccessAlert
visible={successAlertVisible}
content={successAlertContent}
onContinue={() => {
setSuccessAlertVisible(false)
//答题结束,标记展品4为已查看
markVisited('place4', 2)
// 进入下一题
setQuizStep((prev) => {
const next = prev + 1
if (next >= 1 && next <= 4) {
setQuizVisible(true)
}
return next
})
}}
/>
</>
</ScaledPortal>
</div>
)
}
export default MaozedongPlace4
+512
View File
@@ -0,0 +1,512 @@
import {
SingleChoiceQuizPanel,
Video,
type VideoAnchor,
type VideoRef,
EndingOverlay,
StudyGuideModal,
} from '@/components'
import { SuccessAlert } from '@/components/layout'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { cn, getStaticImage } from '@/lib/utils'
import { getPublicPath } from '@/lib/utils'
import { useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
// 光标组件 - 带旋转动画
// Safari 兼容:使用 onTouchEnd 和 -webkit-tap-highlight-color
const Cursor = ({ className, onClick }: { className?: string; onClick: () => void }) => (
<img
className={cn('w-35 cursor-pointer animate-[spin_2s_linear_infinite] select-none', className)}
onClick={onClick}
onTouchEnd={(e) => {
e.preventDefault() // 防止 Safari 触发两次
onClick()
}}
style={{
WebkitTapHighlightColor: 'transparent',
WebkitTouchCallout: 'none',
WebkitUserSelect: 'none',
touchAction: 'manipulation',
}}
src={getStaticImage('components/光标')}
alt=""
draggable={false}
/>
)
const firstQuizOptions = [
{
value: 'A',
label: 'A. 基层情况对决策至关重要,关乎无产阶级领导权、工农联盟议题能否贴合实际',
},
{
value: 'B',
label: 'B. 深入基层,收集各地党组织、工农运动实情,再推进筹备等备',
},
]
const secondQuizOptions = [
{
value: 'A',
label: 'A. 以强硬指令,要求各地限时上报意见',
},
{
value: 'B',
label: 'B. 讲清意见对四大讨论无产阶级领导权、工农联盟的重要性,以革命大义动员党员',
},
]
const endingQuizOptions = [
{
value: 'A',
label: 'A. 仅安心调养',
},
{
value: 'B',
label: 'B. 在调养修整后调研农民运动',
},
]
type QuizCursorProps = {
resume: () => void
unmute: () => void
onCorrect: () => void
}
const FirstQuizCursor = ({ resume, unmute, onCorrect }: QuizCursorProps) => {
const [visible, setVisible] = useState(false)
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
const hasResumedRef = useRef(false)
return (
<>
<Cursor
className="absolute left-320 bottom-70"
onClick={() => {
unmute()
setVisible(true)
}}
/>
<SingleChoiceQuizPanel
visible={visible}
title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?"
options={firstQuizOptions}
correctAnswer="B"
errorMessage="很抱歉,你选错了!高层快速定框架,筹备脱离基层实际,因缺乏实战支撑,在推动工农运动、明确领导权时,难以契合各地实情,革命力量动员只会受阻。请你重新做出选择吧!"
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setVisible(false)
setShowSuccessAlert(true)
onCorrect()
}}
/>
<SuccessAlert
visible={showSuccessAlert}
content="基于对史实的理解,毛主席的选择应当是深入基层、收集真实情况,再推进各项筹备与行动。"
onContinue={() => {
if (hasResumedRef.current) return
hasResumedRef.current = true
setShowSuccessAlert(false)
resume()
}}
onClose={() => {
if (hasResumedRef.current) return
hasResumedRef.current = true
setShowSuccessAlert(false)
resume()
}}
/>
</>
)
}
const SecondQuizCursor = ({ resume, unmute, onCorrect }: QuizCursorProps) => {
const [visible, setVisible] = useState(false)
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
const hasResumedRef = useRef(false)
return (
<>
<Cursor
className="absolute left-110 bottom-65"
onClick={() => {
unmute()
setVisible(true)
}}
/>
<SingleChoiceQuizPanel
visible={visible}
title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?"
options={secondQuizOptions}
correctAnswer="B"
errorMessage="很抱歉,你选错了!下达强硬指令,基层反而误陷内容空洞、应付,四大筹备缺乏真实基层素材,决策难以解决实际问题,工农联盟等议题讨论会浮于表面。请你重新做出选择吧!"
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setVisible(false)
setShowSuccessAlert(true)
onCorrect()
}}
/>
<SuccessAlert
visible={showSuccessAlert}
title="恭喜你,选择正确!"
content="当时毛泽东采用动员方式,以“钟英”为代号发出《关于召开四大致各地党组织的信》和《关于召开四大的通知》,在通知中阐明意义,各地党组织积极响应,为四大议题积累丰富实践依据,推动筹备深入。"
onContinue={() => {
if (hasResumedRef.current) return
hasResumedRef.current = true
setShowSuccessAlert(false)
resume()
}}
onClose={() => {
if (hasResumedRef.current) return
hasResumedRef.current = true
setShowSuccessAlert(false)
resume()
}}
/>
</>
)
}
const EndingQuizCursor = ({ resume, unmute }: Omit<QuizCursorProps, 'onCorrect'>) => {
const [questionVisible, setQuestionVisible] = useState(false)
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
const hasResumedRef = useRef(false)
return (
<>
<Cursor
className="absolute left-110 bottom-140"
onClick={() => {
unmute()
setQuestionVisible(true)
}}
/>
<SingleChoiceQuizPanel
visible={questionVisible}
title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?"
options={endingQuizOptions}
correctAnswer="B"
errorMessage="很抱歉,你选错了!当时如果错过调研契机,农民运动在四大后推进缺乏针对性实践参考,工农联盟思想深化缓慢,革命力量整合会受到影响。请你重新做出选择吧!"
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setQuestionVisible(false)
setShowSuccessAlert(true)
}}
/>
<SuccessAlert
visible={showSuccessAlert}
title="恭喜你,选择正确!"
content="毛泽东回湘后,深入农村,了解农民疾苦与诉求,为后续《湖南农民运动考察报告》奠基,让四大工农联盟思想在实践中持续深化,开始了领导工农运动的新征程。"
onContinue={() => {
if (hasResumedRef.current) return
hasResumedRef.current = true
setShowSuccessAlert(false)
resume()
}}
onClose={() => {
if (hasResumedRef.current) return
hasResumedRef.current = true
setShowSuccessAlert(false)
resume()
}}
/>
</>
)
}
const Home = () => {
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const bgmRef = useRef<HTMLAudioElement>(null)
const videoRef = useRef<VideoRef>(null)
const navigate = useNavigate()
const [showGuideModal, setShowGuideModal] = useState(true)
const [endingVisible, setEndingVisible] = useState(false)
const moduleName = 'maozedong'
const playBGM = () => {
bgmRef.current?.play().catch(() => {})
}
const anchors: VideoAnchor[] = useMemo(
() => [
{
id: 'anchor-0',
time: 3,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute left-223 top-108"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-1',
time: 8,
afterResume: 'hide',
onTrigger: () => console.log('埋点:锚点 1 触发'),
render: ({ resume, unmute }) => (
<Cursor
className="absolute left-150 top-80"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-2',
time: 22,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute right-42 bottom-95"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-3',
time: 32,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute left-50 bottom-100"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-4',
time: 40,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute right-158 bottom-105"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-4-1',
time: 52,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute left-223 top-135"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-5',
time: 60 + 8,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute right-58 bottom-115"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-6',
time: 60 + 25,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute left-80 bottom-110"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-6-1',
time: 60 + 34,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<FirstQuizCursor
resume={resume}
unmute={unmute}
onCorrect={() => triggerPuzzleAnimation(13)}
/>
),
},
{
id: 'anchor-7',
time: 60 + 38,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute left-220 bottom-100"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-8',
time: 60 * 2,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute left-243 bottom-100"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-9',
time: 60 * 2 + 11.5,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute left-375 bottom-100"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-10',
time: 60 * 2 + 21,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<SecondQuizCursor
resume={resume}
unmute={unmute}
onCorrect={() => triggerPuzzleAnimation(14)}
/>
),
},
{
id: 'anchor-11',
time: 60 * 2 + 24,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute left-220 bottom-100"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-12',
time: 60 * 2 + 43,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute left-320 bottom-120"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-13',
time: 60 * 2 + 57,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
className="absolute left-248 bottom-130"
onClick={() => {
unmute()
resume()
}}
/>
),
},
{
id: 'anchor-14',
time: 60 * 3 + 9,
afterResume: 'hide',
render: ({ resume, unmute }) => <EndingQuizCursor resume={resume} unmute={unmute} />,
},
],
[triggerPuzzleAnimation]
)
return (
<div>
<audio ref={bgmRef} src={getPublicPath('audio/BGM.mp3')} loop autoPlay />
<Video
ref={videoRef}
src={getPublicPath('video/maozedong/mzd_7_8_9_tiwu.mp4')}
className="w-full h-full aspect-video"
anchors={anchors}
autoPlay={false}
muted
playsInline
onEnded={() => {
setEndingVisible(true)
}}
/>
<StudyGuideModal
visible={showGuideModal}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_22.mp3')}
onContinue={() => {
setShowGuideModal(false)
playBGM()
videoRef.current?.play()
}}
firstLineIndent="2em"
>
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1924年,上海。
1924年的上海,革命浪潮暗涌,中共四大筹备工作关乎革命前途。无产阶级领导权、工农联盟等核心议题亟待明确,毛泽东同志在筹备关键节点面临诸多重要抉择。请你化身历史见证者,站在毛主席的视角做出选择,解锁四大筹备的关键密码,感受革命先辈的坚定与智慧!完成三次选择后,可进入下一阶段的学习。时空门已开,请做好准备迎接考验吧!`}
</StudyGuideModal>
<EndingOverlay
visible={endingVisible}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_23.mp3')}
animationSpeed={200}
text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因健康未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。"
onContinue={() => {
setEndingVisible(false)
navigate(`/${moduleName}/maozedong/home`)
}}
/>
</div>
)
}
export default Home
@@ -0,0 +1,335 @@
import { DragFill, Image, type DragFillOption, type DragFillResult } from '@/components'
import { cn, getStaticImage } from '@/lib/utils'
import { useEffect, useMemo, useRef, useState } from 'react'
import { toJpeg } from 'html-to-image'
type ParadeOption = {
id: string
name: string
src: string
}
type SloganOption = {
id: string
src: string
}
const PARADE_OPTIONS: ParadeOption[] = [
{
id: 'amphibious',
name: '两栖突击方队',
src: 'nieer/reflection/lobby/content/场景6两栖突击方队',
},
{
id: 'air-escort',
name: '空中护旗梯队',
src: 'nieer/reflection/lobby/content/场景6空中护旗梯队',
},
{
id: 'df5c',
name: '东风-5C液体洲际战略核导弹',
src: 'nieer/reflection/lobby/content/场景6“东风-5C”液体洲际战略核导弹',
},
{
id: 'sea-unmanned',
name: '海上无人作战方队',
src: 'nieer/reflection/lobby/content/场景6海上无人作战方队',
},
{
id: 'honor-guard',
name: '中国人民解放军仪仗方队',
src: 'nieer/reflection/lobby/content/场景6中国人民解放军仪仗方队',
},
{ id: 'rocket-force', name: '火箭军方队', src: 'nieer/reflection/lobby/content/场景6火箭军方队' },
]
const SLOGAN_OPTIONS: SloganOption[] = [
{ id: 's1', src: 'nieer/reflection/lobby/content/场景6文字1' },
{ id: 's2', src: 'nieer/reflection/lobby/content/场景6文字2' },
{ id: 's3', src: 'nieer/reflection/lobby/content/场景6文字3' },
{ id: 's4', src: 'nieer/reflection/lobby/content/场景6文字4' },
{ id: 's5', src: 'nieer/reflection/lobby/content/场景6文字5' },
]
type BeijingUnlockInteractionProps = {
onFinished: () => void
onSelectionChange?: (selection: {
paradeId?: string
paradeName?: string
paradeImagePath?: string
sloganId?: string
sloganImagePath?: string
previewImageDataUrl?: string
}) => void
}
export default function BeijingUnlockInteraction({
onFinished,
onSelectionChange,
}: BeijingUnlockInteractionProps) {
const [paradeFill, setParadeFill] = useState<DragFillResult[]>([])
const [sloganFill, setSloganFill] = useState<DragFillResult[]>([])
const finishedRef = useRef(false)
const previewRef = useRef<HTMLDivElement>(null)
const paradeValue = paradeFill.find((v) => v.blankId === 'parade')?.optionValue ?? null
const sloganValue = sloganFill.find((v) => v.blankId === 'slogan')?.optionValue ?? null
const selectedParade = useMemo(
() =>
paradeValue
? (PARADE_OPTIONS.find((opt) => opt.id === paradeValue.replace('parade:', '')) ?? null)
: null,
[paradeValue]
)
const selectedSlogan = useMemo(
() =>
sloganValue
? (SLOGAN_OPTIONS.find((opt) => opt.id === sloganValue.replace('slogan:', '')) ?? null)
: null,
[sloganValue]
)
useEffect(() => {
onSelectionChange?.({
paradeId: selectedParade?.id,
paradeName: selectedParade?.name,
paradeImagePath: selectedParade?.src,
sloganId: selectedSlogan?.id,
sloganImagePath: selectedSlogan?.src,
})
}, [onSelectionChange, selectedParade, selectedSlogan])
useEffect(() => {
let cancelled = false
const waitImagesReady = async (root: HTMLElement): Promise<void> => {
const images = Array.from(root.querySelectorAll('img'))
await Promise.all(
images.map(
(img) =>
new Promise<void>((resolve) => {
if (img.complete && img.naturalWidth > 0) {
resolve()
return
}
const done = () => resolve()
img.addEventListener('load', done, { once: true })
img.addEventListener('error', done, { once: true })
})
)
)
}
const capturePreview = async () => {
if (!selectedParade || !selectedSlogan) return
const previewEl = previewRef.current
if (!previewEl) return
try {
await waitImagesReady(previewEl)
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)))
const dataUrl = await toJpeg(previewEl, {
cacheBust: false,
skipFonts: true,
quality: 0.72,
pixelRatio: 1,
canvasWidth: Math.floor(previewEl.offsetWidth * 0.66),
canvasHeight: Math.floor(previewEl.offsetHeight * 0.66),
})
if (cancelled) return
onSelectionChange?.({
paradeId: selectedParade.id,
paradeName: selectedParade.name,
paradeImagePath: selectedParade.src,
sloganId: selectedSlogan.id,
sloganImagePath: selectedSlogan.src,
previewImageDataUrl: dataUrl,
})
if (!finishedRef.current) {
finishedRef.current = true
onFinished()
}
} catch {
// ignore screenshot failure, fallback to image-path based display
}
}
capturePreview()
return () => {
cancelled = true
}
}, [onFinished, onSelectionChange, selectedParade, selectedSlogan])
const paradeDragOptions: DragFillOption[] = PARADE_OPTIONS.map((opt) => ({
value: `parade:${opt.id}`,
label: opt.name,
}))
const sloganDragOptions: DragFillOption[] = SLOGAN_OPTIONS.map((opt) => ({
value: `slogan:${opt.id}`,
label: opt.id,
}))
return (
<div className="relative w-full h-full">
<div className="flex my-4 gap-6 px-8">
<div className="flex-1">
<div className="flex items-center justify-center">
<span className="h-[2px] w-24 bg-[#9f9384]/80" />
<span className="text-3xl text-[#9f9384]">阅兵方阵</span>
<span className="h-[2px] w-24 bg-[#9f9384]/80" />
</div>
<DragFill
options={paradeDragOptions}
blanks={[{ id: 'parade', label: '' }]}
value={paradeFill}
onChange={(next) => {
// 只保留 parade 类型填充值,防止误投
const valid = next.filter(
(v) => v.blankId === 'parade' && v.optionValue.startsWith('parade:')
)
setParadeFill(valid.slice(-1))
}}
showSubmitButton={false}
hideLabels
layout="horizontal-options"
optionClassName="gap-3 grid grid-cols-3 py-4"
blankClassName="hidden"
// 这里不要渲染同名 blank 节点,避免覆盖右侧真实释放区命中
renderBlank={() => null}
renderOption={(option, isUsed, isDragging) => {
const item = PARADE_OPTIONS.find((opt) => `parade:${opt.id}` === option.value)
if (!item) return null
return (
<div
className={cn(
'z-30 cursor-pointer transition-opacity',
isDragging && 'opacity-60',
isUsed && 'opacity-35 pointer-events-none'
)}
onClick={() => {
if (isUsed) return
setParadeFill([{ blankId: 'parade', optionValue: option.value }])
}}
>
<Image
button
src={getStaticImage(item.src)}
alt={item.name}
className="w-full cursor-grab"
/>
<div className="mt-1 text-center text-2xl leading-none text-[#9f9384] font-semibold whitespace-nowrap">
{item.name}
</div>
</div>
)
}}
renderDragging={(option) => {
const item = PARADE_OPTIONS.find((opt) => `parade:${opt.id}` === option.value)
if (!item) return null
return (
<img
src={getStaticImage(item.src)}
alt={item.name}
style={{ width: '220px', height: 'auto', pointerEvents: 'none' }}
/>
)
}}
/>
</div>
{/* 方阵展示区 */}
<div
ref={previewRef}
className="relative w-[576px] h-[569px]"
style={{
backgroundImage: `url(${getStaticImage('nieer/reflection/lobby/content/场景6定格底板')})`,
}}
>
{/* 释放区域 */}
<div className="w-full h-full">
<div data-blank-id="parade" className="absolute inset-0" />
<div data-blank-id="slogan" className="absolute inset-0" />
{selectedParade && (
<>
<div
className={cn(
'absolute left-0 top-9 h-[54px] w-full',
'flex items-center justify-center',
'text-white text-3xl font-bold'
)}
>
{selectedParade.name}
</div>
<div className="absolute left-6 top-32 w-[526px]">
<Image src={getStaticImage(selectedParade.src)} alt={selectedParade.name} />
</div>
</>
)}
{selectedSlogan && (
<Image
src={getStaticImage(selectedSlogan.src)}
alt="词条"
className="absolute right-5 bottom-5"
/>
)}
</div>
</div>
</div>
<div className="flex items-center justify-center">
<div className="text-3xl text-[#9f9384] whitespace-nowrap">词条:</div>
<div className="h-26">
<DragFill
options={sloganDragOptions}
blanks={[{ id: 'slogan', label: '' }]}
value={sloganFill}
onChange={(next) => {
const valid = next.filter(
(v) => v.blankId === 'slogan' && v.optionValue.startsWith('slogan:')
)
setSloganFill(valid.slice(-1))
}}
showSubmitButton={false}
hideLabels
layout="horizontal-options"
optionClassName="flex items-center justify-center gap-0"
blankClassName="hidden"
// 这里不要渲染同名 blank 节点,避免覆盖右侧真实释放区命中
renderBlank={() => null}
renderOption={(option, isUsed, isDragging) => {
const item = SLOGAN_OPTIONS.find((opt) => `slogan:${opt.id}` === option.value)
if (!item) return null
return (
<Image
button
width={268}
src={getStaticImage(item.src)}
alt={`词条${item.id}`}
className={cn(
'z-30 transition-opacity',
isDragging && 'opacity-60',
isUsed && 'opacity-35 pointer-events-none'
)}
onClick={() => {
if (isUsed) return
setSloganFill([{ blankId: 'slogan', optionValue: option.value }])
}}
/>
)
}}
renderDragging={(option) => {
const item = SLOGAN_OPTIONS.find((opt) => `slogan:${opt.id}` === option.value)
if (!item) return null
return (
<img
src={getStaticImage(item.src)}
alt={`词条${item.id}`}
style={{ width: '187px', height: 'auto', pointerEvents: 'none' }}
/>
)
}}
/>
</div>
</div>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
import { type ChangeEvent, type InputHTMLAttributes } from 'react'
export interface InputBgProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
'value' | 'onChange' | 'className'
> {
value: string
onChange: (value: string) => void
className?: string
inputClassName?: string
/**
* 背景图路径(不需要写后缀),例如:`nieer/reflection/lobby/输入`
*/
bgImagePath?: string
}
/**
* 背景图输入框(无边框/无默认样式)。
* 默认背景:`src/assets/images/nieer/reflection/lobby/输入.png`
*/
export function InputBg({
value,
onChange,
className,
inputClassName,
bgImagePath = 'nieer/reflection/lobby/输入',
placeholder,
disabled,
...rest
}: InputBgProps) {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}
return (
<div
className={cn('relative w-full h-[56px] flex justify-center items-center', className)}
style={{
// 避免背景与 input 文本抢点击层级
pointerEvents: 'none',
}}
>
<img className="absolute inset-0 -top-14" src={getStaticImage(bgImagePath)} alt="背景" />
<input
{...rest}
disabled={disabled}
value={value}
placeholder={placeholder}
onChange={handleChange}
className={cn(
'w-full h-full text-center',
'bg-transparent border-0 outline-none',
'p-0 m-0',
'text-inherit',
// 默认文本样式尽量克制,具体可由 inputClassName 覆盖
'text-[#756D5C] text-4xl leading-none',
'placeholder:text-[#756D5C]/70 placeholder:text-3xl',
disabled && 'cursor-not-allowed',
inputClassName
)}
// 确保不会出现默认浏览器样式(尤其是某些 WebKit 下的圆角/阴影)
style={{
WebkitAppearance: 'none',
appearance: 'none',
pointerEvents: 'auto',
}}
/>
</div>
)
}
export default InputBg
@@ -0,0 +1,160 @@
import { Image } from '@/components'
import { getPublicPath, getStaticImage } from '@/lib/utils'
import { useAudioManager } from '@/hooks/useAudioManager'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
type PianoUnlockInteractionProps = {
/**
* 音频全部播完(显示交互 9 后播放 `all.wav` 并结束)时回调
* 用于通知外层显示“分享”按钮。
*/
onFinished: () => void
}
/**
* 欧洲·亚洲(sceneId=3)UnlockHintDialog:钢琴交互
* - 默认显示 交互 1
* - 点击依次播放 1~7.wav(每段结束后切到下一张交互图)
* - 当显示交互 8 时,点击播放 all.wav,并切到交互 9;播放结束后 onFinished
*/
export default function PianoUnlockInteraction({ onFinished }: PianoUnlockInteractionProps) {
const [step, setStep] = useState(1) // 1..9
// 选择一个独立 channel,避免与其它对话/旁白音频互相抢占
const {
play,
stop,
isPlaying: audioIsPlaying,
currentSrc: audioCurrentSrc,
} = useAudioManager({
mode: 'channel',
channelId: 'nieer-unlock-piano',
})
const expectedAudioSrcRef = useRef<string | null>(null)
const nextStepRef = useRef<number | null>(null)
const startedRef = useRef(false)
const allFinishedRef = useRef(false)
const imageSrc = useMemo(() => {
return getStaticImage(`nieer/reflection/lobby/piano/弹琴交互${step}`)
}, [step])
// 卸载时清理音频
useEffect(() => {
return () => {
stop()
}
}, [stop])
// 点击映射:
// - step=1 播放 1 -> 2
// - step=2 播放 2 -> 3
// - step=3 点击后只切到 4(不播放),以满足“点击过3和4才播放3音频”
// - step=4 播放 3 -> 5
// - step=5 播放 4 -> 6
// - step=6 播放 5 -> 7
// - step=7 播放 6 -> 8
// - step=8 播放 7 -> 9(到 9 后自动播放 all.wav 并结束)
const trigger = useCallback(() => {
if (audioIsPlaying) return
if (step >= 9) return
const playSeg = (segIndex: number, after: number) => {
const segSrc = getPublicPath(`audio/nieer/reflection/${segIndex}.wav`)
expectedAudioSrcRef.current = segSrc
nextStepRef.current = after
startedRef.current = false
play(segSrc)
}
if (step === 1) {
playSeg(1, 2)
return
}
if (step === 2) {
playSeg(2, 3)
return
}
if (step === 3) {
// 关键:step3 先不播放,直接推进到 step4
expectedAudioSrcRef.current = null
nextStepRef.current = null
startedRef.current = false
setStep(4)
return
}
if (step === 4) {
playSeg(3, 5)
return
}
if (step === 5) {
playSeg(4, 6)
return
}
if (step === 6) {
playSeg(5, 7)
return
}
if (step === 7) {
playSeg(6, 8)
return
}
if (step === 8) {
playSeg(7, 9)
}
}, [audioIsPlaying, play, setStep, step])
// 播放开始/结束判定(用于“1~7段音频”)
useEffect(() => {
const expected = expectedAudioSrcRef.current
if (!expected) return
// 只有当我们已经“确实开始播放”(isPlaying==true)后,才认为已结束。
if (audioIsPlaying && audioCurrentSrc === expected) {
startedRef.current = true
return
}
if (!audioIsPlaying && startedRef.current && audioCurrentSrc === expected) {
startedRef.current = false
expectedAudioSrcRef.current = null
const nextStep = nextStepRef.current
nextStepRef.current = null
if (nextStep) {
setStep(nextStep)
} else {
// all.wav 播完后标记结束,避免 step=9 effect 重复触发
allFinishedRef.current = true
onFinished()
}
}
}, [audioCurrentSrc, audioIsPlaying, onFinished])
// step=9:自动播放 all.wav 并结束
useEffect(() => {
if (step !== 9) return
if (allFinishedRef.current) return
const allSrc = getPublicPath('audio/nieer/reflection/all.wav')
// 如果已经在播放/已播放到 all,就不重复触发
if (expectedAudioSrcRef.current === allSrc && audioIsPlaying) return
expectedAudioSrcRef.current = allSrc
nextStepRef.current = null
startedRef.current = false
play(allSrc)
}, [audioIsPlaying, play, step])
return (
<Image
button
src={imageSrc}
alt={`弹琴交互${step}`}
className="w-auto h-auto cursor-pointer"
onClick={() => trigger()}
/>
)
}
@@ -0,0 +1,131 @@
import { Image } from '@/components'
import { getStaticImage } from '@/lib/utils'
import { useEffect, useMemo, useRef, useState } from 'react'
type DragItemId = 'item1' | 'item2'
type PostcardUnlockInteractionProps = {
/** 两个素材都完成放置时回调(用于外层开启分享按钮) */
onFinished: () => void
/** 拖拽状态变化回调(用于旅行箱保存成果) */
onStateChange?: (state: {
hasPlane: boolean
hasText: boolean
postcardImagePath: string
}) => void
}
export default function PostcardUnlockInteraction({
onFinished,
onStateChange,
}: PostcardUnlockInteractionProps) {
const [dropped, setDropped] = useState<Record<DragItemId, boolean>>({
item1: false,
item2: false,
})
const finishedRef = useRef(false)
const postcardSrc = useMemo(() => {
const has1 = dropped.item1
const has2 = dropped.item2
if (has1 && has2) return getStaticImage('nieer/reflection/lobby/content/场景5全部反馈')
if (has1) return getStaticImage('nieer/reflection/lobby/content/场景5有飞机反馈')
if (has2) return getStaticImage('nieer/reflection/lobby/content/场景5有文字反馈')
return getStaticImage('nieer/reflection/lobby/content/场景5待交互')
}, [dropped.item1, dropped.item2])
useEffect(() => {
const has1 = dropped.item1
const has2 = dropped.item2
const postcardImagePath =
has1 && has2
? 'nieer/reflection/lobby/content/场景5全部反馈'
: has1
? 'nieer/reflection/lobby/content/场景5有飞机反馈'
: has2
? 'nieer/reflection/lobby/content/场景5有文字反馈'
: 'nieer/reflection/lobby/content/场景5待交互'
onStateChange?.({
hasPlane: has1,
hasText: has2,
postcardImagePath,
})
}, [dropped.item1, dropped.item2, onStateChange])
const placeItem = (id: DragItemId) => {
setDropped((prev) => {
if (prev[id]) return prev
const next = { ...prev, [id]: true }
if (next.item1 && next.item2 && !finishedRef.current) {
finishedRef.current = true
onFinished()
}
return next
})
}
const handleDragStart = (id: DragItemId) => (event: React.DragEvent<HTMLDivElement>) => {
event.dataTransfer.setData('text/plain', id)
event.dataTransfer.effectAllowed = 'move'
}
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault()
const id = event.dataTransfer.getData('text/plain') as DragItemId
if (id === 'item1' || id === 'item2') {
placeItem(id)
}
}
return (
<div className="relative w-full h-full">
<Image
width={933}
src={postcardSrc}
alt="我制作的明信片"
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
/>
{/* 明信片投放区域:拖入任一素材即更新对应反馈图 */}
<div
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[933px] h-[586px] z-10"
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
/>
{!dropped.item1 && (
<Image
button
draggable
src={getStaticImage('nieer/reflection/lobby/content/场景5交互可拖拽1')}
alt="可拖拽素材1"
className="absolute left-0 top-30 z-20 cursor-grab active:cursor-grabbing"
onDragStart={handleDragStart('item1')}
// 触屏/不便拖拽设备兜底:点击直接放入明信片
onClick={() => placeItem('item1')}
/>
)}
{!dropped.item2 && (
<Image
button
draggable
src={getStaticImage('nieer/reflection/lobby/content/场景5交互可拖拽2')}
alt="可拖拽素材2"
className="absolute right-0 bottom-30 z-20 cursor-grab active:cursor-grabbing"
onDragStart={handleDragStart('item2')}
onClick={() => placeItem('item2')}
/>
)}
{/* 完成:两个素材都拖拽完成后显示 */}
{dropped.item1 && dropped.item2 && (
<Image
className="absolute right-40 bottom-12"
src={getStaticImage('nieer/reflection/lobby/icon/完成')}
alt="完成"
/>
)}
</div>
)
}
+214
View File
@@ -0,0 +1,214 @@
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
import { createPortal } from 'react-dom'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
export type SelectBgOption = {
value: string
label: string
disabled?: boolean
}
export interface SelectBgProps {
options: SelectBgOption[]
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
className?: string
triggerClassName?: string
dropdownClassName?: string
optionClassName?: string
zIndex?: number
/**
* 未选中背景(默认从 `assets/images/nieer/reflection/lobby/select/未选中.png` 读取)
* 传入时不需要写后缀名,比如:`nieer/reflection/lobby/select/未选中`
*/
unselectedBgPath?: string
/**
* 选中背景(默认从 `assets/images/nieer/reflection/lobby/select/选中.png` 读取)
* 传入时不需要写后缀名,比如:`nieer/reflection/lobby/select/选中`
*/
selectedBgPath?: string
}
const DEFAULT_TRIGGER_BG_PATH = 'nieer/reflection/lobby/输入'
const DEFAULT_UNSELECTED_BG_PATH = 'nieer/reflection/lobby/select/未选中'
const DEFAULT_SELECTED_BG_PATH = 'nieer/reflection/lobby/select/选中'
/**
* Select - 背景图状态的下拉选择(无默认边框/外观样式)
*/
export function SelectBg({
options,
value,
onChange,
placeholder = '请选择',
disabled = false,
className,
triggerClassName,
dropdownClassName,
optionClassName,
zIndex = 1000,
unselectedBgPath = DEFAULT_UNSELECTED_BG_PATH,
selectedBgPath = DEFAULT_SELECTED_BG_PATH,
}: SelectBgProps) {
const [isOpen, setIsOpen] = useState(false)
const [hoveredValue, setHoveredValue] = useState<string | null>(null)
const [dropdownPosition, setDropdownPosition] = useState<{
top: number
left: number
width: number
} | null>(null)
const triggerRef = useRef<HTMLDivElement | null>(null)
const dropdownRef = useRef<HTMLDivElement | null>(null)
const selectedOption = useMemo(() => options.find((opt) => opt.value === value), [options, value])
const updateDropdownPosition = useCallback(() => {
const el = triggerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
setDropdownPosition({
top: rect.top + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
})
}, [])
const close = useCallback(() => setIsOpen(false), [])
const open = useCallback(() => {
if (disabled) return
setIsOpen(true)
// 等到下一帧确保布局稳定再计算位置
setTimeout(() => updateDropdownPosition(), 0)
}, [disabled, updateDropdownPosition])
const handleSelect = useCallback(
(nextValue: string) => {
const opt = options.find((o) => o.value === nextValue)
if (opt?.disabled) return
onChange(nextValue)
close()
},
[close, onChange, options]
)
useEffect(() => {
if (!isOpen) return
const onMouseDown = (event: MouseEvent) => {
const target = event.target as Node
if (
triggerRef.current &&
!triggerRef.current.contains(target) &&
dropdownRef.current &&
!dropdownRef.current.contains(target)
) {
close()
}
}
const onScrollOrResize = () => updateDropdownPosition()
document.addEventListener('mousedown', onMouseDown)
window.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
return () => {
document.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
}
}, [close, isOpen, updateDropdownPosition])
return (
<div className={cn('relative inline-block w-[875px]', className)}>
<div
ref={triggerRef}
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
aria-expanded={isOpen}
onClick={() => {
if (disabled) return
if (isOpen) close()
else open()
}}
className={cn(
'relative w-full h-[56px] bg-cover bg-center bg-no-repeat flex items-center justify-center select-none',
disabled && 'cursor-not-allowed opacity-60',
triggerClassName
)}
style={{
// 避免背景层抢点击
pointerEvents: 'auto',
outline: 'none',
}}
>
<img
className="absolute inset-0 -top-14"
src={getStaticImage(DEFAULT_TRIGGER_BG_PATH)}
alt="背景"
/>
<span className="pointer-events-none text-[#756D5C] text-[26px] leading-none font-medium">
{selectedOption ? selectedOption.label : placeholder}
</span>
</div>
{isOpen &&
dropdownPosition &&
createPortal(
<div
ref={dropdownRef}
className={cn('fixed bg-transparent', dropdownClassName)}
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
zIndex,
transform: 'translateY(-100%)',
}}
role="listbox"
>
<div className="flex flex-col">
{options.map((opt) => {
const isHovered = opt.value === hoveredValue
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={opt.value === value}
disabled={!!opt.disabled}
onClick={() => handleSelect(opt.value)}
onMouseEnter={() => setHoveredValue(opt.value)}
onMouseLeave={() => setHoveredValue(null)}
className={cn(
'h-[30px] bg-cover bg-no-repeat flex items-center justify-center',
opt.disabled && 'cursor-not-allowed opacity-60 pointer-events-none',
optionClassName
)}
style={{
backgroundImage: getBackgroundImage(
isHovered ? selectedBgPath : unselectedBgPath
),
backgroundSize: '100% 100%',
}}
>
<span className="pointer-events-none text-[#756D5C] text-lg leading-none font-medium">
{opt.label}
</span>
</button>
)
})}
</div>
</div>,
document.body
)}
</div>
)
}
export default SelectBg
+5
View File
@@ -0,0 +1,5 @@
export { InputBg } from './InputBg'
export { SelectBg } from './SelectBg'
export { default as PianoUnlockInteraction } from './PianoUnlockInteraction'
export { default as PostcardUnlockInteraction } from './PostcardUnlockInteraction'
export { default as BeijingUnlockInteraction } from './BeijingUnlockInteraction'
+53
View File
@@ -0,0 +1,53 @@
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { useNieerStore } from '@/store/nieerStore'
const NieerHome = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
// starName 固定为 nieer
const starName = 'nieer'
const lastVisitedPlace = useNieerStore((s) => s.lastVisitedPlace)
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 NieerHome
+375
View File
@@ -0,0 +1,375 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
LetterToNieer,
Narration,
EndingOverlay,
} from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { GuideSteps, GuideStepsItem } from '@/components'
import { SuccessAlert } from '@/components/layout'
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const starName = 'nieer'
type QuizOption = {
value: string
label: string
isCorrect?: boolean
explanation?: string
}
type QuizAnchor = {
id: string
time: number
type: 'single' | 'multiple'
title: string
options: QuizOption[]
correctAnswer: string | string[]
successTitle?: string
}
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: 'quiz-1',
time: 60 * 3,
type: 'single',
title: '【单选】《义勇军进行曲》法定管弦乐配器版的创作者是:',
options: [
{
value: 'A',
label: 'A. 贺绿汀邀请的阿隆·阿甫夏洛穆夫',
explanation: '这是《义勇军进行曲》的第一个管弦乐配器版,并非法定版哦!请你再想想吧!',
},
{ value: 'B', label: 'B. 李焕之', isCorrect: true, explanation: '' },
{
value: 'C',
label: 'C. 聂耳',
explanation: '聂耳是《义勇军进行曲》的作曲者,并非管弦乐配器版的创作者。请你再想想吧!',
},
{
value: 'D',
label: 'D. 林超夏',
explanation: '林超夏完成的管弦乐配器版,并非广为熟知的法定版。请你再想想吧!',
},
],
correctAnswer: 'B',
},
{
id: 'quiz-2',
time: 60 * 4 + 39, // 4:39
type: 'multiple',
title: '【多选】音乐作品要传递时代力量与精神,需具备哪些特质?',
options: [
{ value: 'A', label: 'A. 拥有积极向上的音型与坚定有力的节奏,易于传唱与共鸣' },
{ value: 'B', label: 'B. 贴近民众心声,契合人类共通的真挚情感(如家国情怀、正义坚守)' },
{ value: 'C', label: 'C. 聚焦时代主题,呼应国家与民族的发展需求' },
{
value: 'D',
label: 'D. 追求复杂华丽的编曲技巧,彰显专业门槛',
explanation:
'如《义勇军进行曲》这样的作品,节奏并不复杂,核心在于情感与主题的传递,而非技巧的堆砌。请你再想想吧!',
},
],
correctAnswer: ['A', 'B', 'C'],
},
{
id: 'quiz-3',
time: 60 * 6 + 31.5, // 6:31
type: 'multiple',
title: '【多选】聂耳的音乐作品具有鲜明的“人民性”,主要体现在:',
options: [
{ value: 'A', label: 'A. 关注劳苦大众的生活与困境,如《开矿歌》《大路歌》' },
{ value: 'B', label: 'B. 为少年儿童创作贴合其生活的作品,如《卖报歌》' },
{ value: 'C', label: 'C. 关注女性群体的境遇,如《梅娘曲》《新女性》《铁蹄下的歌女》' },
{
value: 'D',
label: 'D. 仅围绕抗日救亡主题,不涉及其他社会群体',
explanation:
'聂耳的创作既聚焦抗日救亡,也覆盖多个社会群体,体现了广泛的人民性。请你再想想吧!',
},
],
correctAnswer: ['A', 'B', 'C'],
},
{
id: 'quiz-4',
time: 60 * 7 + 11.5, // 7:11
type: 'single',
title:
'【判断】聂耳在创作中注重挖掘中华优秀传统音乐文化元素,《翠湖春晓》《金蛇狂舞》就是他改编传统音乐的经典作品。',
options: [
{
value: 'A',
label: 'A. 正确',
isCorrect: true,
explanation:
'这两部作品是聂耳对传统音乐元素的整理、挖掘与再传播,彰显了鲜明的文化传承意识。',
},
{
value: 'B',
label: 'B. 错误',
explanation: '聂耳的创作中融入了传统音乐元素,这两部作品正是典型代表哦!请你再想想吧!',
},
],
correctAnswer: 'A',
},
{
id: 'quiz-5',
time: 60 * 9 + 14.5, // 9:14
type: 'multiple',
title: '【多选】聂耳能创作出众多兼具传唱力与思想性的经典作品,源于他身上具有以下宝贵闪光点:',
options: [
{ value: 'A', label: 'A. 对新鲜事物的探索精神' },
{ value: 'B', label: 'B. 关注社会时事的批判思维' },
{
value: 'C',
label: 'C. 只为个人名利创作的追求',
explanation: '“只为个人名利创作的追求”与聂耳“音乐救国”的初心相悖哦!请你再想想吧!',
},
{ value: 'D', label: 'D. 对国家民族未来的深刻思考' },
],
correctAnswer: ['A', 'B', 'D'],
},
]
const NieerInternalization = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [narrationVisible, setNarrationVisible] = useState(false)
const [visible, setVisible] = useState(false)
const [showVideo, setShowVideo] = useState(false)
const [showPostNarration, setShowPostNarration] = useState(false)
const [showLetter, setShowLetter] = useState(false)
const [showStudyGuide, setShowStudyGuide] = useState(false)
const videoRef = useRef<VideoRef>(null)
const [activeSuccess, setActiveSuccess] = useState<string | null>(null)
const [successContent, setSuccessContent] = useState<string>('')
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
useEffect(() => {
setNarrationVisible(true)
}, [])
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
videoRef.current.play().catch(() => {})
}
}
const anchors: VideoAnchor[] = useMemo(() => {
return QUIZ_ANCHORS.map((item) => {
if (item.type === 'single') {
const correct = item.correctAnswer as string
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => (
<>
<SingleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={item.title}
options={item.options.map((opt) => ({ value: opt.value, label: opt.label }))}
correctAnswer={correct}
onWrongSelect={(value: string) => {
const opt = item.options.find((o) => o.value === value)
return opt?.explanation || '再思考一下,选择正确答案。'
}}
onSubmit={(value: string, isCorrect: boolean) => {
if (isCorrect) {
const opt = item.options.find((o) => o.value === value)
setActiveSuccess(item.id)
setSuccessContent(opt?.explanation || '')
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title={item.id === 'quiz-4' ? '恭喜你, 判断正确!' : '恭喜你, 选择正确!'}
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
),
}
}
const correctAnswers = item.correctAnswer as string[]
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => (
<>
<MultipleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={item.title}
options={item.options.map((opt) => ({ value: opt.value, label: opt.label }))}
correctAnswer={correctAnswers}
errorMessage={(selectedValues: string[]) => {
const picked = new Set(selectedValues)
const wrongOpt = item.options.find((o) => picked.has(o.value) && o.explanation)
if (wrongOpt?.explanation) return wrongOpt.explanation
return '你的选择还不够全面哦~请你再想想吧!'
}}
onSubmit={(values: string[], isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id)
if (item.id === 'quiz-2') {
triggerPuzzleAnimation(15)
}
if (item.id === 'quiz-5') {
triggerPuzzleAnimation(16)
}
setSuccessContent('')
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
),
}
})
}, [activeSuccess, resumeCallback, successContent, 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="nieer-internalization-narration"
audioSrc={getPublicPath('audio/nieer/mp3_nieer_36.mp3')}
animationSpeed={200}
onClose={() => {
setNarrationVisible(false)
setVisible(true)
}}
text="从旧居亭子间的不眠之夜到国歌展示馆的激昂旋律,聂耳以青春为火,以音符为刃,在民族危亡的黑暗中奏响了救亡图存的强音。《义勇军进行曲》从电影主题曲到中华人民共和国国歌,从抗战年代的冲锋号到新时代的复兴曲,跨越近百年时光,依然是凝聚民族力量的精神纽带。今天,站在新时代的舞台上,我们更要读懂聂耳的作品与精神内核,让这份旋律中的力量内化于心、外化于行。"
/>
{/* 视频前引导 */}
<GuideSteps
visible={visible}
audioChannelId="nieer-internalization-guide-pre"
onVisibleChange={(v) => {
setVisible(v)
if (!v) {
setShowVideo(true)
handleVideoCanPlay()
}
}}
showMask={false}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_37.mp3')}>
小沪,前两个模块中,我循着聂耳的足迹读懂了旋律背后的家国情怀,亲历了国歌跨越时空的动人故事。我还想从音乐这一艺术创作角度出发更多了解聂耳,你能给我讲讲吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_38.mp3')}>
讲到音乐,可是很专业的领域了!今天我们有幸邀请到了上海音乐学院党委宣传部副部长,东方乐器博物馆馆长,研究员,肖阳。肖老师会从上海音乐学院对聂耳的研究、与《义勇军进行曲》的渊源的角度告诉你聂耳的音乐作品与时代价值。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
接下来,就让我们跟随肖老师一起,探寻聂耳音乐作品和艺术精神的当代意义吧!
</GuideStepsItem>
</GuideSteps>
{/* 内化主视频 */}
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath('video/nieer/nieer_neihua.mp4')}
className="h-full w-full"
anchors={anchors}
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
setTimeout(() => setShowPostNarration(true), 300)
}}
/>
)}
<Narration
visible={showPostNarration}
audioChannelId="nieer-internalization-narration-post"
audioSrc={getPublicPath('audio/nieer/mp3_nieer_39.mp3')}
animationSpeed={200}
onClose={() => {
setShowPostNarration(false)
setShowLetter(true)
}}
text="的确如戏!这份精神,不仅是音乐创作的指引,更是广大青年学子成长路上的精神灯塔,指引大家在专业学习中精益求精,在青春岁月里奋勇前行。此刻,我们仿佛依然站在聂耳旧居的亭子间里,仿佛还能看到先生熬夜打磨旋律的身影,感受到他心中滚烫的家国情怀。现在,就请你静下心来,把对聂耳的崇敬、对国歌精神的体悟和对自己未来的期许,一字一句写成一封信,完成与先生跨越时空的对话吧!"
/>
{/* 写信组件 */}
<LetterToNieer
visible={showLetter}
onClose={() => {
setShowLetter(false)
}}
onAnimationEnd={() => {
setShowStudyGuide(true)
}}
showVideo={true}
/>
{/* 内化结束 */}
<EndingOverlay
visible={showStudyGuide}
audioSrc={getPublicPath('audio/nieer/mp3_nieer_40.mp3')}
text="恭喜你圆满完成聂耳旧居的学习之旅,请带着研学感悟,前往张乐平故居继续探访吧~"
animationSpeed={200}
onContinue={() => {
setShowStudyGuide(false)
navigate('/menu')
}}
/>
</div>
)
}
export default NieerInternalization
+415
View File
@@ -0,0 +1,415 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
SelectFillQuizPanel,
SingleChoiceQuizPanel,
Title,
StudyGuideModal,
Video,
type VideoRef,
} from '@/components'
import { SuccessAlert } from '@/components/layout'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useRef, useState } from 'react'
import Cursor from '@/components/cursor'
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
// starName 固定为 nieer
const starName = 'nieer'
// 展品 ID 与资源路径映射,ID 用来做唯一标识
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: 'nieer/place1/场景1展品2',
2: 'nieer/place1/场景1展品3',
3: 'nieer/place1/场景1展品1',
}
const NieerPlace1 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
// 当前激活的展品 ID
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
// 光标1的对话状态
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
// 光标1关闭展品后的题目
const [quiz1Visible, setQuiz1Visible] = useState(false)
const [successAlert1Visible, setSuccessAlert1Visible] = useState(false)
// 光标2:对话 -> 题目 -> 对话 -> 视频
const [guide2Visible, setGuide2Visible] = useState(false)
const [guide2Step, setGuide2Step] = useState(0)
const [quiz2Visible, setQuiz2Visible] = useState(false)
const [successAlert2Visible, setSuccessAlert2Visible] = useState(false)
const [video2Step, setVideo2Step] = useState<1 | 2 | null>(null)
const video2Ref = useRef<VideoRef>(null)
// 光标3的对话状态
const [guide3Visible, setGuide3Visible] = useState(false)
const [guide3Step, setGuide3Step] = useState(0)
// 学习引导弹窗(开场)
const [showGuideModal, setShowGuideModal] = useState(true)
// 说明框(沿用原有说明)
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false)
const place = 'place1'
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits) as Set<1 | 2 | 3>
// Cursor 控制器:使用依赖映射实现自动解锁
// cursor 1 初始启用,cursor 2 依赖展品1,cursor 3 依赖展品2
const { isEnabled } = useCursorController({
initialEnabled: [3],
dependencies: {
2: [1], // cursor 2 依赖展品 1,当展品1被访问后自动解锁
1: [3], // cursor 3 依赖展品 2,当展品2被访问后自动解锁
},
visited: visitedExhibits, // 传入数组,而不是 Set
})
if (!moduleName) return null
const total = 3
const progressCount = visited.size
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id)
if (id === 1) {
setGuide1Step(0)
setGuide1Visible(true)
}
if (id === 2) {
setGuide2Step(0)
setGuide2Visible(true)
}
if (id === 3) {
setGuide3Step(0)
setGuide3Visible(true)
}
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/place1/场景1`),
}}
>
{/* 标题 */}
<Title
title="民族危亡的怒吼声"
subtitle={subtitle}
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}/${starName}/home`)}
/>
{/* 光标1 */}
<Cursor
className="absolute left-188 bottom-108"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute right-175 bottom-115"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute right-20 bottom-130"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-10 right-120"
src={getStaticImage('nieer/place1/继续前进1')}
button
onClick={() => {
setLastVisitedPlace('place2')
saveNieerStore()
navigate(`/${moduleName}/${starName}/place2`)
}}
/>
)}
{/* 展品遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 3) {
triggerPuzzleAnimation(1)
}
if (activeExhibitId === 1) {
triggerPuzzleAnimation(2)
}
if (activeExhibitId === 2) {
triggerPuzzleAnimation(3)
}
}}
onClose={() => {
const closingId = activeExhibitId
setActiveExhibitId(null)
// 关闭展品时一并关闭对话(避免残留在下层)
setGuide1Visible(false)
setGuide2Visible(false)
setGuide3Visible(false)
if (closingId === 1) {
// 光标1:关闭展品后出题,答对才算完成并点亮 cursor
setQuiz1Visible(true)
return
}
if (closingId === 2) {
// 光标2:关闭展品后出题,答对后进入后置对话与视频
setQuiz2Visible(true)
return
}
// 其它展品:关闭即视为完成
if (closingId !== null) {
markExhibitVisited(place, closingId)
}
}}
/>
{/* 光标1的对话(图1文案) */}
<GuideSteps
visible={guide1Visible}
currentStep={guide1Step}
onStepChange={setGuide1Step}
onVisibleChange={setGuide1Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_05.mp3')}>
这里提到聂耳发表了《中国歌舞短论》,批评脱离民众的音乐,这是为什么呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_06.mp3')}>
你观察得很细致!这时的聂耳在明月歌舞剧社做练习生,社长李锦晖看中了他的潜力,虽然他当时小提琴水平不算专业,还是录取了他,还安排首席小提琴手专门辅导他。聂耳特别刻苦,每天至少练7个小时小提琴,很快就成长为首席小提琴手。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
但好景不长,1931年九·一八事变后,全国抗日救亡浪潮高涨,可还存在很多脱离现实的靡靡之音,这让满怀爱国热忱的聂耳特别愤懑。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
1932年,他以“黑天使”为笔名在《电影艺术》杂志刊登《中国歌舞短论》,猛烈评击“软豆腐”式的音乐,提出我们需要“真刀真枪的硬功夫”,主张音乐要贴近民众、为抗日救亡服务。后来他离开了歌舞剧社。
</GuideStepsItem>
</GuideSteps>
{/* 光标2的对话(图1文案) */}
<GuideSteps
visible={guide2Visible}
currentStep={guide2Step}
onStepChange={setGuide2Step}
onVisibleChange={setGuide2Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_07.mp3')}>
离开歌舞剧社后,聂耳就加入左翼文艺团体了吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_08.mp3')}>
没错!离开歌舞剧社后,聂耳结识了进步文艺家田汉,两人成为忘年交。1933年,在田汉的介绍下,聂耳秘密加入了中国共产党,之后就成为左翼剧联“音乐小组”的核心成员,还发起组织了“中国新兴音乐研究会”。
</GuideStepsItem>
</GuideSteps>
{/* 光标3的对话(参考陈云对话模块) */}
<GuideSteps
visible={guide3Visible}
currentStep={guide3Step}
onStepChange={setGuide3Step}
onVisibleChange={setGuide3Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_03.mp3')}>
小沪,聂耳是在九·一八事变爆发后来到的上海吗?他为什么来上海呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_04.mp3')}>
九·一八事变爆发前,聂耳就已经来到上海了,核心原因是躲避迫害。聂耳中学就读的云南省立第一师范学校是昆明学生运动的中心,在那里他接触到了马克思主义进步思想,还秘密加入了中国共产主义青年团。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
也因为他积极参加学生运动,1930年被国民党当局列入了抓捕黑名单,在家人帮助下,他顶替哥哥的身份逃离昆明来到上海。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
来到上海后,1931年九·一八事变爆发,继而1932年一·二八事变爆发,聂耳亲眼看见了日军进犯上海的暴行,也目睹了上海民众踊跃投身抗日的热烈场景。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这些亲身经历让他深刻意识到,音乐不能只是娱乐,更能成为唤醒民众、凝聚抗战士气的“武器”。正是这份在上海的所见所感,让他坚定了“音乐救国”的方向,也为《义勇军进行曲》注入了源于现实的痛感与抗争决心。
</GuideStepsItem>
</GuideSteps>
{/* 光标2关闭展品后的填空题(图2) */}
<SelectFillQuizPanel
visible={quiz2Visible}
zIndex={10001}
titlePrefix="【填空】"
titleSuffix="年,在田汉的介绍下,聂耳秘密加入了中国共产党。"
blanks={[{ id: 'b1' }, { id: 'b2' }, { id: 'b3' }, { id: 'b4' }]}
options={Array.from({ length: 10 }).map((_, i) => ({ value: String(i), label: String(i) }))}
correctAnswers={{
b1: '1',
b2: '9',
b3: '3',
b4: '3',
}}
errorMessage="1933年,在田汉的介绍下,聂耳秘密加入了中国共产党。"
submitText="提交"
onSubmit={(_results, isCorrect) => {
if (isCorrect) {
setQuiz2Visible(false)
setSuccessAlert2Visible(true)
}
}}
/>
{/* 光标2题目成功提示:成功后点亮 cursor2,并进入后置对话 */}
<SuccessAlert
visible={successAlert2Visible}
title="恭喜你, 选择正确!"
onContinue={() => {
setSuccessAlert2Visible(false)
setVideo2Step(1)
}}
onClose={() => {
setSuccessAlert2Visible(false)
setVideo2Step(1)
}}
/>
{/* 光标2连续视频(占位视频地址) */}
{video2Step && (
<Video
ref={video2Ref}
className="fixed inset-0 z-20000"
src={getPublicPath(
video2Step === 1 ? 'video/chenyun/chenyun_01.mp4' : 'video/nieer/nieer_2.mp4'
)}
autoPlay
playsInline
showSkipButton
onEnded={() => {
if (video2Step === 1) {
setVideo2Step(2)
return
}
markExhibitVisited(place, 2)
saveNieerStore()
setVideo2Step(null)
}}
onSkip={() => {
if (video2Step === 1) {
setVideo2Step(2)
return
}
markExhibitVisited(place, 2)
saveNieerStore()
setVideo2Step(null)
}}
/>
)}
{/* 光标1关闭展品后的题目(图2) */}
<SingleChoiceQuizPanel
visible={quiz1Visible}
zIndex={10001}
title="【单选】1932年,聂耳以“黑天使”为笔名在《电影艺术》杂志刊登《中国歌舞短论》,主张音乐要贴近民众、为抗日救亡服务,提出我们需要:"
options={[
{
value: 'A',
label: 'A.“软豆腐”式的音乐',
},
{
value: 'B',
label: 'B.“真刀真枪的硬功夫”',
},
]}
correctAnswer="B"
errorMessage="“软豆腐”式的音乐是聂耳猛烈评击的音乐类型。请你再想想吧!"
onSubmit={(_value, isCorrect) => {
if (isCorrect) {
setQuiz1Visible(false)
setSuccessAlert1Visible(true)
}
}}
/>
{/* 光标1题目成功提示:成功后点亮 cursor1 */}
<SuccessAlert
visible={successAlert1Visible}
title="恭喜你, 选择正确!"
onContinue={() => {
setSuccessAlert1Visible(false)
markExhibitVisited(place, 1)
saveNieerStore()
}}
onClose={() => {
setSuccessAlert1Visible(false)
markExhibitVisited(place, 1)
saveNieerStore()
}}
/>
{/* 场景1学习引导(开场) */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false)
setShowAfterDialogGuide(true)
}}
ailgn="left"
audioSrc={getPublicPath('audio/nieer/mp3_nieer_02.mp3')}
>
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
{'\n'}
2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
{'\n'}
3.所有动态光点的展品全部学习后才能进入下一个场景学习。
</StudyGuideModal>
{/* 说明框(内容沿用原有说明) */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
audioSrc={getPublicPath('audio/nieer/mp3_nieer_01.mp3')}
>
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
此刻你正位立于国歌展示馆的入口,请随时光的脚步,在聂耳作品的“时光声浪”中聆听五段炽热的时代回响:民族危亡的怒吼声、时代呼唤的最强音、民族解放的冲锋号、强我中华的进行曲、伟大复兴的主旋律。你准备好跟随聂耳的音符,踏上这段炽热征程了吗?
</StudyGuideModal>
</div>
)
}
export default NieerPlace1
+214
View File
@@ -0,0 +1,214 @@
import { ExhibitOverlay, GuideSteps, GuideStepsItem, Image, Title, Video } from '@/components'
import Cursor from '@/components/cursor'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const NieerPlace2 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 光标1的对话状态
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
// 光标2的对话状态
const [guide2Visible, setGuide2Visible] = useState(false)
const [guide2Step, setGuide2Step] = useState(0)
// 展品2关闭后播放视频,视频结束后再修改光标状态
const [showVideo2, setShowVideo2] = useState(false)
const starName = 'nieer'
const place = 'place2'
const visitedExhibits = getVisitedExhibits(place)
const visited = useMemo(() => new Set(visitedExhibits), [visitedExhibits])
const groupTotal = 5
const groupProgress = getVisitedExhibits('place2').length + getVisitedExhibits('place3').length
const groupCompleted = groupProgress >= groupTotal
const subtitle = groupCompleted ? '已完成' : `${groupProgress}/${groupTotal}`
const pageProgress = visitedExhibits.length
const pageCompleted = pageProgress >= 2
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
},
visited: visitedExhibits,
})
if (!moduleName) return null
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'nieer/place2/场景2展品1',
2: 'nieer/place2/场景2展品2',
}
const handleOpenExhibit = (id: 1 | 2) => {
setActiveExhibitId(id)
if (id === 1) {
setGuide1Step(0)
setGuide1Visible(true)
}
if (id === 2) {
setGuide2Step(0)
setGuide2Visible(true)
}
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/${place}/场景2`),
}}
>
<Title
title="时代呼唤的最强音"
subtitle={subtitle}
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}/${starName}/place1`)}
/>
<Cursor
className="absolute left-260 bottom-25"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute right-112 bottom-130"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{pageCompleted && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place3')
saveNieerStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(4)
}
if (activeExhibitId === 2) {
triggerPuzzleAnimation(5)
}
}}
onClose={() => {
const closingId = activeExhibitId
setActiveExhibitId(null)
setGuide1Visible(false)
setGuide2Visible(false)
if (closingId === 2) {
setShowVideo2(true)
return
}
if (closingId !== null) {
markExhibitVisited(place, closingId)
saveNieerStore()
}
}}
/>
{/* 光标1对话(图1) */}
<GuideSteps
visible={guide1Visible}
currentStep={guide1Step}
onStepChange={setGuide1Step}
onVisibleChange={setGuide1Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_09.mp3')}>
这盒《风云儿女》的胶片太有年代感了!聂耳是怎么和《风云儿女》这部电影结缘,并给这部电影谱主题歌《义勇军进行曲》的啊?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_10.mp3')}>
1935年的时候,日本帝国主义已经把侵略的魔爪伸向华北,中华民族到了生死存亡的关键时刻,全国人民都渴望有能凝聚斗志的精神力量。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
左翼文艺界想通过拍摄《风云儿女》这部抗日电影,让更多人看清侵略者的野心,唤醒大家的抗争意识,而电影的主题歌必须得有“振聋发聩”的力量。
</GuideStepsItem>
</GuideSteps>
{/* 光标2对话(图2) */}
<GuideSteps
visible={guide2Visible}
currentStep={guide2Step}
onStepChange={setGuide2Step}
onVisibleChange={setGuide2Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_11.mp3')}>
1935年2月,田汉创作了《风云儿女》剧本梗概,写下《义勇军进行曲》歌词后不久被捕,田汉的战友辗转把歌词转交给左翼剧联,聂耳得知后第一时间就主动申请完成曲创作。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
他之所以这么积极,一方面是和田汉的革命情谊——两人都是左翼文艺运动的核心力量,早就约定要一起用文艺为抗日救亡服务;
</GuideStepsItem>
<GuideStepsItem speaker="guide">
更重要的是他对时代的感知,之前创作《开矿歌》《卖报歌》时,他就深入底层了解民众的苦难,清楚地知道大家需要什么样的声音。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
他当时说“这歌词太有力量了,我一定要把它谱成能让全中国人民都唱起来的歌”,这份信心来自他对民众心声的把握,也来自对救国使命的坚守。
</GuideStepsItem>
</GuideSteps>
{showVideo2 && (
<Video
className="fixed inset-0 z-20000"
src={getPublicPath('video/nieer/nieer_4.mp4')}
autoPlay
playsInline
showSkipButton
onEnded={() => {
markExhibitVisited(place, 2)
saveNieerStore()
setShowVideo2(false)
}}
onSkip={() => {
markExhibitVisited(place, 2)
saveNieerStore()
setShowVideo2(false)
}}
/>
)}
</div>
)
}
export default NieerPlace2
+335
View File
@@ -0,0 +1,335 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
SingleChoiceQuizPanel,
Title,
Video,
type VideoRef,
} from '@/components'
import { SuccessAlert } from '@/components/layout'
import Cursor from '@/components/cursor'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const NieerPlace3 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
const starName = 'nieer'
const place = 'place3'
// 展品弹窗
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
// 光标1对话
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
// 光标3对话
const [guide3Visible, setGuide3Visible] = useState(false)
const [guide3Step, setGuide3Step] = useState(0)
// 关窗后视频
const [showVideo, setShowVideo] = useState(false)
const videoRef = useRef<VideoRef>(null)
const [videoForExhibitId, setVideoForExhibitId] = useState<1 | 3 | null>(null)
// 光标3视频结束后的题目
const [quiz3Visible, setQuiz3Visible] = useState(false)
const [successAlert3Visible, setSuccessAlert3Visible] = useState(false)
// place2 + place3 共 5 个展品进度
const groupTotal = 5
const groupProgress = getVisitedExhibits('place2').length + getVisitedExhibits('place3').length
const groupCompleted = groupProgress >= groupTotal
const subtitle = groupCompleted ? '已完成' : `${groupProgress}/${groupTotal}`
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const pageCompleted = visited.size >= 3
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
3: [2],
},
visited: visitedExhibits,
})
if (!moduleName) return null
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: 'nieer/place3/场景3展品1',
2: 'nieer/place3/场景3展品2',
3: 'nieer/place3/场景3展品3',
}
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id)
if (id === 1) {
setGuide1Step(0)
setGuide1Visible(true)
}
if (id === 3) {
setGuide3Step(0)
setGuide3Visible(true)
}
}
const playPlaceholderVideoFor = (id: 1 | 3) => {
setVideoForExhibitId(id)
setShowVideo(true)
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/${place}/场景3`),
}}
>
<Title
title="时代呼唤的最强音"
subtitle={subtitle}
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}/${starName}/place2`)}
/>
<Cursor
className="absolute left-35 bottom-45"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute left-215 bottom-75"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
<Cursor
className="absolute right-64 bottom-75"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{pageCompleted && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place4')
saveNieerStore()
navigate(`/${moduleName}/${starName}/place4`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(6)
}
if (activeExhibitId === 3) {
triggerPuzzleAnimation(7)
}
}}
onClose={() => {
const closingId = activeExhibitId
setActiveExhibitId(null)
setGuide1Visible(false)
setGuide3Visible(false)
if (closingId === 1) {
// 光标1:关窗后播放视频,视频结束才算完成
playPlaceholderVideoFor(1)
return
}
if (closingId === 3) {
// 光标3:关窗后播放视频,视频结束再出题
playPlaceholderVideoFor(3)
return
}
if (closingId !== null) {
markExhibitVisited(place, closingId)
}
}}
/>
{/* 光标1对话(图1) */}
<GuideSteps
visible={guide1Visible}
currentStep={guide1Step}
onStepChange={setGuide1Step}
onVisibleChange={setGuide1Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_12.mp3')}>
这份聂耳手写的《义勇军进行曲》的曲谱太珍贵了,在当时那么动乱的年代,他是怎么创作出这么激昂的旋律的?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_13.mp3')}>
1935年3月,聂耳在上海霞飞路(现淮海中路1258号聂耳旧居)完成了《义勇军进行曲》的曲谱初稿的创作。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
当时他住的是一个亭子间,那里又小又暗,连一张像样的桌子都没有,为了找到最贴合歌词的旋律,他每天天不亮就起来哼唱,用筷子敲着桌子打节拍,有时候灵感来了,半夜还会起来修改,邻居都能听到他的歌声。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
草稿上的修改痕迹,藏着他的用心——比如他反复调整音符的节奏,把原本偏舒缓的旋律改得更急促、更有冲击力,就是想让旋律能跟上战士冲锋的步伐;还优化了歌词和旋律的搭配,让每一句都能唱得铿锵有力。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
他还专门去电通公司唱给同事听,把工人、学生、战士的反馈都记下来,一点点打磨。
</GuideStepsItem>
</GuideSteps>
{/* 光标3对话(图2) */}
<GuideSteps
visible={guide3Visible}
currentStep={guide3Step}
onStepChange={setGuide3Step}
onVisibleChange={setGuide3Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_14.mp3')}>
原来初稿就倾注了这么多心血!他当时就是用这把小提琴创作的吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_15.mp3')}>
这把小提琴其实是演员赵丹在电影《聂耳》中饰演聂耳时用的道具,不是聂耳本人的。你看到的抱着小提琴的聂耳的照片的拍摄地点已经是在海外了。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
当时他因为创作了太多进步歌曲,被国民党当局列入了黑名单,继续留在上海会有生命危险,因此他受党的指示前往海外暂避。但他心里一直惦记着谱曲任务,到海外后没敢耽误,立刻投入修改。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
虽然身在海外,但他通过海外华人社团、抗日刊物,一直关注着国内的抗日浪潮,还收集了海外华人传唱抗日歌曲的反馈,这些都成了他修改的重要参考。
</GuideStepsItem>
</GuideSteps>
{/* 关窗后的视频(占位视频地址) */}
{showVideo && videoForExhibitId !== null && (
<Video
ref={videoRef}
className="fixed inset-0 z-20000"
src={
videoForExhibitId === 1
? getPublicPath('video/nieer/nieer_5.mp4')
: getPublicPath('video/chenyun/chenyun_01.mp4') //正确地址nieer_02目前没有,暂时使用其他代替
}
autoPlay
playsInline
showSkipButton
onEnded={() => {
const id = videoForExhibitId
setShowVideo(false)
setVideoForExhibitId(null)
if (id === 1) {
markExhibitVisited(place, 1)
saveNieerStore()
return
}
if (id === 3) {
setQuiz3Visible(true)
}
}}
onSkip={() => {
const id = videoForExhibitId
setShowVideo(false)
setVideoForExhibitId(null)
if (id === 1) {
markExhibitVisited(place, 1)
saveNieerStore()
return
}
if (id === 3) {
setQuiz3Visible(true)
}
}}
/>
)}
{/* 光标3视频结束后的题目(图3) */}
<SingleChoiceQuizPanel
visible={quiz3Visible}
zIndex={10001}
title="【单选】聂耳是主动请缨为《风云儿女》电影创作的主题曲是:"
options={[
{ value: 'A', label: 'A.义勇军进行曲' },
{
value: 'B',
label: 'B.卖报歌',
},
{
value: 'C',
label: 'C.开矿歌',
},
]}
correctAnswer="A"
errorMessage="回答错误,请你再仔细思考一下吧。"
onWrongSelect={(value) => {
if (value === 'B') {
return '卖报歌是聂耳创作的儿童歌曲,灵感来自街头卖报的小女孩“小毛头”,并非《风云儿女》的主题曲。请你再想想吧!'
}
if (value === 'C') {
return '开矿歌是聂耳为进步影片《母性之光》创作的插曲,展现工人劳动场景,和《风云儿女》无关。请你再想想吧!'
}
return undefined
}}
onSubmit={(_value, isCorrect) => {
if (isCorrect) {
setQuiz3Visible(false)
setSuccessAlert3Visible(true)
}
}}
/>
{/* 光标3题目成功提示:成功后点亮 cursor3 */}
<SuccessAlert
visible={successAlert3Visible}
title="恭喜你, 选择正确!"
onContinue={() => {
setSuccessAlert3Visible(false)
markExhibitVisited(place, 3)
saveNieerStore()
}}
onClose={() => {
setSuccessAlert3Visible(false)
markExhibitVisited(place, 3)
saveNieerStore()
}}
/>
</div>
)
}
export default NieerPlace3
+181
View File
@@ -0,0 +1,181 @@
import { ExhibitOverlay, GuideSteps, GuideStepsItem, Image, Title } from '@/components'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const NieerPlace4 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const starName = 'nieer'
const place = 'place4'
const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 光标2对话(图1)
const [guide2Visible, setGuide2Visible] = useState(false)
const [guide2Step, setGuide2Step] = useState(0)
// 光标1对话(图2)
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const total = 2
const progressCount = visited.size
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const { isEnabled } = useCursorController({
initialEnabled: [2],
dependencies: {
1: [2],
},
visited: visitedExhibits,
})
if (!moduleName) return null
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'nieer/place4/场景4展品1',
2: 'nieer/place4/场景4展品2',
}
const handleOpenExhibit = (id: 1 | 2) => {
setActiveExhibitId(id)
if (id === 2) {
setGuide2Step(0)
setGuide2Visible(true)
}
if (id === 1) {
setGuide1Step(0)
setGuide1Visible(true)
}
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/${place}/场景4`),
}}
>
<Title
title="民族解放的冲锋号"
subtitle={subtitle}
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}/${starName}/place3`)}
/>
<Cursor
className="absolute left-70 bottom-135"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute right-100 bottom-145"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{completed && (
<Image
className="absolute bottom-10 right-120"
src={getStaticImage('nieer/place4/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place5')
saveNieerStore()
navigate(`/${moduleName}/${starName}/place5`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 2) {
triggerPuzzleAnimation(8)
}
if (activeExhibitId === 1) {
triggerPuzzleAnimation(9)
}
}}
onClose={() => {
const closingId = activeExhibitId
setActiveExhibitId(null)
setGuide2Visible(false)
setGuide1Visible(false)
if (closingId !== null) {
markExhibitVisited(place, closingId)
saveNieerStore()
}
}}
/>
{/* 光标2对话(图1) */}
<GuideSteps
visible={guide2Visible}
currentStep={guide2Step}
onStepChange={setGuide2Step}
onVisibleChange={setGuide2Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_16.mp3')}>
随着《风云儿女》的上映,田汉作词、聂耳谱曲的《义勇军进行曲》真如聂耳所期待的一样,传遍了全国吧!
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_17.mp3')}>
1935年《风云儿女》公映后,这首歌就跟着影片走进了全国各地的影院,看完电影的观众都忍不住跟着哼唱。当时的百代唱片公司还专门录制了这首歌的唱片,在街头巷尾的唱片店、收音机里循环播放。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
因为它朗朗上口,哪怕是不识字的老百姓,教几遍就能学会,很快就成了街头巷尾、田间地头都能听到的旋律,真正做到了家喻户晓。
</GuideStepsItem>
</GuideSteps>
{/* 光标1对话(图2) */}
<GuideSteps
visible={guide1Visible}
currentStep={guide1Step}
onStepChange={setGuide1Step}
onVisibleChange={setGuide1Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_18.mp3')}>
这张指挥千人大合唱的照片好震撼!除了日常传唱,这首歌在抗战时期还发挥了哪些特别的作用呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_19.mp3')}>
它的作用可太大了!你看这张1936年的照片,刘良模先生在上海南市公共体育场指挥数千人齐唱,就连前来镇压的警察都被感染,跟着一起唱。抗战期间,抗日军队的行军路上、根据地的文艺宣传队里、城市的救亡集会中,都能听到这首歌的声音。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
它就像冲锋号一样,每次响起都能凝聚起大家的斗志,让战士们更勇敢地奔赴战场,让民众们更坚定地支持抗战,成为整个民族团结抗敌的精神纽带,而它也不再只是文艺作品,更是凝聚了中国人民的抗战士气,成了抗日救亡运动的精神旗帜。
</GuideStepsItem>
</GuideSteps>
</div>
)
}
export default NieerPlace4
+464
View File
@@ -0,0 +1,464 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
MatchQuizPanel,
MultipleChoiceQuizPanel,
SingleChoiceQuizPanel,
Title,
} from '@/components'
import Cursor from '@/components/cursor'
import { ErrorAlert, SuccessAlert } from '@/components/layout'
import { useCursorController } from '@/hooks/useCursorController'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const NieerPlace5 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const starName = 'nieer'
const place = 'place5'
const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
// 光标1对话(图1)
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
// 光标2对话(图2)
const [guide2Visible, setGuide2Visible] = useState(false)
const [guide2Step, setGuide2Step] = useState(0)
// 光标3对话(图5)
const [guide3Visible, setGuide3Visible] = useState(false)
const [guide3Step, setGuide3Step] = useState(0)
// 光标2题目1(图3:填空)
const [quiz2_1Visible, setQuiz2_1Visible] = useState(false)
const quiz2_1WrongCountRef = useRef(0)
const [quiz2_1HintVisible, setQuiz2_1HintVisible] = useState(false)
const [success2_1Visible, setSuccess2_1Visible] = useState(false)
// 光标2题目2(图4:多选)
const [quiz2_2Visible, setQuiz2_2Visible] = useState(false)
const [success2_2Visible, setSuccess2_2Visible] = useState(false)
// 光标3题目1(图6:配对)
const [quiz3_1Visible, setQuiz3_1Visible] = useState(false)
const [success3_1Visible, setSuccess3_1Visible] = useState(false)
// 光标3题目2(图7:判断)
const [quiz3_2Visible, setQuiz3_2Visible] = useState(false)
const [success3_2Visible, setSuccess3_2Visible] = useState(false)
const visitedExhibits = getVisitedExhibits(place)
const visited = useMemo(() => new Set(visitedExhibits), [visitedExhibits])
const total = 3
const progressCount = visited.size
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const { isEnabled } = useCursorController({
initialEnabled: [2],
dependencies: {
1: [2],
3: [1],
},
visited: visitedExhibits,
})
useEffect(() => {
if (!quiz2_1Visible) {
quiz2_1WrongCountRef.current = 0
setQuiz2_1HintVisible(false)
}
}, [quiz2_1Visible])
if (!moduleName) return null
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: 'nieer/place5/场景5展品1',
2: 'nieer/place5/场景5展品3',
3: 'nieer/place5/场景5展品2',
}
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id)
if (id === 1) {
setGuide1Step(0)
setGuide1Visible(true)
}
if (id === 2) {
setGuide2Step(0)
setGuide2Visible(true)
}
if (id === 3) {
setGuide3Step(0)
setGuide3Visible(true)
}
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/${place}/场景5`),
}}
>
<Title
title="强我中华的进行曲"
subtitle={subtitle}
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}/${starName}/place4`)}
/>
<Cursor
className="absolute left-165 bottom-122"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute left-280 bottom-120"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
<Cursor
className="absolute right-46 bottom-120"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place6')
saveNieerStore()
navigate(`/${moduleName}/${starName}/place6`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(10)
}
if (activeExhibitId === 2) {
triggerPuzzleAnimation(11)
}
if (activeExhibitId === 3) {
triggerPuzzleAnimation(12)
}
}}
onClose={() => {
const closingId = activeExhibitId
setActiveExhibitId(null)
setGuide1Visible(false)
setGuide2Visible(false)
setGuide3Visible(false)
if (closingId == 2) {
markExhibitVisited(place, 2)
}
// 展品1关闭后完成光标1,并触发光标2的两道题
if (closingId === 1) {
saveNieerStore()
setQuiz2_1Visible(true)
}
if (closingId === 3) {
setQuiz3_1Visible(true)
}
}}
/>
{/* 光标1对话(图1) */}
<GuideSteps
visible={guide1Visible}
currentStep={guide1Step}
onStepChange={setGuide1Step}
onVisibleChange={setGuide1Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_22.mp3')}>
那2004年的宪法修正案又对国歌做了什么重要规定呢?为什么要把它写入宪法呀?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_23.mp3')}>
2004年3月14日,第十届全国人民代表大会二次会议通过宪法修正案,明确规定“中华人民共和国国歌是《义勇军进行曲》”,这让国歌的地位有了根本大法的保障。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
把它写入宪法,是因为这首歌不仅记录了民族救亡的历史,更凝聚了中华民族的精神气节。从抗战时期的冲锋号到新时代的强国音,它早已成为国家尊严的象征,写入宪法能更好地培育全民的爱国意识和国家观念。
</GuideStepsItem>
</GuideSteps>
{/* 光标2对话(图2) */}
<GuideSteps
visible={guide2Visible}
currentStep={guide2Step}
onStepChange={setGuide2Step}
onVisibleChange={setGuide2Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_20.mp3')}>
小沪,从抗日战歌到正式被确立为国歌,中间经历了多久呀?《义勇军进行曲》第一次作为国歌奏响是在什么时候呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_21.mp3')}>
时间跨度还真不短呢!1949年9月27日,中国人民政治协商会议第一届全体会议决定,在国歌未正式制定前,以《义勇军进行曲》作为代国歌。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
同年10月1日开国大典上,它第一次在天安门广场作为国歌庄严奏响,见证了新中国的诞生。直到1982年12月4日,第五届全国人民代表大会第五次会议才正式将它定为中华人民共和国国歌,完成了从代国歌到正式国歌的跨越。
</GuideStepsItem>
</GuideSteps>
{/* 光标2题目1(图3:选择题) - 错两次弹窗提示 */}
<SingleChoiceQuizPanel
visible={quiz2_1Visible}
zIndex={10001}
title="【单选】2004年3月14日,第十届全国人民代表大会二次会议通过宪法修正案,明确规定“中华人民共和国国歌是《____》”。"
options={[
{ value: 'A', label: 'A.义勇军进行曲' },
{ value: 'B', label: 'B.卖报歌' },
{ value: 'C', label: 'C.开矿歌' },
]}
correctAnswer="A"
errorMessage="回答错误,请你再仔细想想吧。"
onSubmit={(_value, isCorrect) => {
if (isCorrect) {
setQuiz2_1Visible(false)
setSuccess2_1Visible(true)
}
}}
onError={() => {
quiz2_1WrongCountRef.current += 1
if (quiz2_1WrongCountRef.current >= 2) {
setQuiz2_1HintVisible(true)
}
}}
/>
<ErrorAlert
visible={quiz2_1HintVisible}
text="提示:中华人民共和国国歌是田汉作词、聂耳作曲的《义勇军进行曲》。"
zIndex={10002}
onClose={() => setQuiz2_1HintVisible(false)}
/>
<SuccessAlert
visible={success2_1Visible}
title="恭喜你, 选择正确!"
onContinue={() => {
setSuccess2_1Visible(false)
setQuiz2_2Visible(true)
}}
onClose={() => {
setSuccess2_1Visible(false)
setQuiz2_2Visible(true)
}}
/>
{/* 光标2题目2(图4:多选) */}
<MultipleChoiceQuizPanel
visible={quiz2_2Visible}
zIndex={10001}
title="【多选】2004年,《义勇军进行曲》被正式写入《中华人民共和国宪法》。下列关于国歌入宪意义的表述,正确的有:"
options={[
{ value: 'A', label: 'A.让国歌的地位获得了根本大法的坚实保障' },
{ value: 'B', label: 'B.记录了民族救亡的历史,凝聚了中华民族的精神气节' },
{
value: 'C',
label: 'C.确立了国歌作为国家尊严象征的法律地位,更好地培育全民爱国意识和国家观念',
},
]}
correctAnswer={['A', 'B', 'C']}
errorMessage="你的选择还不够全面哦~请你再想想吧!"
minSelections={1}
maxSelections={3}
submitText="提交"
onSubmit={(_values, isCorrect) => {
if (isCorrect) {
setQuiz2_2Visible(false)
setSuccess2_2Visible(true)
}
}}
/>
<SuccessAlert
visible={success2_2Visible}
onContinue={() => {
setSuccess2_2Visible(false)
markExhibitVisited(place, 1)
saveNieerStore()
}}
onClose={() => {
setSuccess2_2Visible(false)
markExhibitVisited(place, 1)
saveNieerStore()
}}
/>
{/* 光标3对话(图5) - 关闭展品3后依次出现两道题 */}
<GuideSteps
visible={guide3Visible}
currentStep={guide3Step}
onStepChange={setGuide3Step}
onVisibleChange={setGuide3Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_24.mp3')}>
根据宪法,2017年9月1日第十二届全国人民代表大会常务委员会第二十九次会议通过《中华人民共和国国歌法》,通过国家立法对国歌的奏唱场合、奏唱礼仪和宣传教育进行规范。
</GuideStepsItem>
</GuideSteps>
{/* 光标3题目1(图6:配对) */}
<MatchQuizPanel
visible={quiz3_1Visible}
zIndex={10001}
title="【连线】请将左侧《义勇军进行曲》的关键事件与右侧时间进行匹配"
leftItems={[
{
id: 'e1982',
label: '第五届全国人民代表大会第五次会议正式将《义勇军进行曲》定为中华人民共和国国歌。',
},
{
id: 'e2017',
label: '第十二届全国人民代表大会常务委员会第二十九次会议通过《中华人民共和国国歌法》。',
},
{
id: 'e1949',
label:
'中国人民政治协商会议第一届全体会议决定,在国歌未正式制定前,以《义勇军进行曲》作为代国歌。',
},
{
id: 'e2004',
label:
'第十届全国人民代表大会二次会议通过宪法修正案,明确规定“中华人民共和国国歌是《义勇军进行曲》”。',
},
]}
rightItems={[
{ id: 't1949', label: '1949年9月27日' },
{ id: 't1982', label: '1982年12月4日' },
{ id: 't2004', label: '2004年3月14日' },
{ id: 't2017', label: '2017年9月1日' },
]}
correctMatches={[
{ leftId: 'e1949', rightId: 't1949' },
{ leftId: 'e1982', rightId: 't1982' },
{ leftId: 'e2004', rightId: 't2004' },
{ leftId: 'e2017', rightId: 't2017' },
]}
errorMessage={(matches) => {
const expectedMap = new Map([
['e1949', 't1949'],
['e1982', 't1982'],
['e2004', 't2004'],
['e2017', 't2017'],
])
const firstWrongLeft = matches.find(
(m) => expectedMap.get(m.leftId) !== m.rightId
)?.leftId
if (!firstWrongLeft) return '请你再想想吧!'
if (firstWrongLeft === 'e1949') {
return '1949年9月27日,中国人民政治协商会议第一届全体会议决定,在国歌未正式制定前,以《义勇军进行曲》作为代国歌。请你再想想吧!'
}
if (firstWrongLeft === 'e1982') {
return '1982年12月4日,第五届全国人民代表大会第五次会议正式将《义勇军进行曲》定为中华人民共和国国歌。请你再想想吧!'
}
if (firstWrongLeft === 'e2004') {
return '2004年3月14日,第十届全国人民代表大会二次会议通过宪法修正案,明确规定“中华人民共和国国歌是《义勇军进行曲》”。请你再想想吧!'
}
return '2017年9月1日第十二届全国人民代表大会常务委员会第二十九次会议通过《中华人民共和国国歌法》。请你再想想吧!'
}}
onSubmit={(_matches, isCorrect) => {
if (isCorrect) {
setQuiz3_1Visible(false)
setSuccess3_1Visible(true)
}
}}
/>
<SuccessAlert
visible={success3_1Visible}
title="恭喜你, 匹配正确!"
onContinue={() => {
setSuccess3_1Visible(false)
setQuiz3_2Visible(true)
}}
onClose={() => {
setSuccess3_1Visible(false)
setQuiz3_2Visible(true)
}}
/>
{/* 光标3题目2(图7:判断) */}
<SingleChoiceQuizPanel
visible={quiz3_2Visible}
zIndex={10001}
title="【判断】《中华人民共和国国歌法》规定中华人民共和国国歌是《义勇军进行曲》。中华人民共和国国歌是中华人民共和国的象征和标志。一切公民和组织都应当尊重国歌,维护国歌的尊严。"
options={[
{ value: 'A', label: 'A.正确' },
{ value: 'B', label: 'B.错误' },
]}
correctAnswer="A"
errorMessage="请你再想想吧!"
onSubmit={(_value, isCorrect) => {
if (isCorrect) {
setQuiz3_2Visible(false)
setSuccess3_2Visible(true)
}
}}
/>
<SuccessAlert
title="恭喜你,判断正确!"
visible={success3_2Visible}
onContinue={() => {
setSuccess3_2Visible(false)
markExhibitVisited(place, 3)
saveNieerStore()
}}
onClose={() => {
setSuccess3_2Visible(false)
markExhibitVisited(place, 3)
saveNieerStore()
}}
/>
</div>
)
}
export default NieerPlace5
+178
View File
@@ -0,0 +1,178 @@
import {
EndingOverlay,
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
} from '@/components'
import Cursor from '@/components/cursor'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const NieerPlace6 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const starName = 'nieer'
const place = 'place6'
const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
const total = 2
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const progressCount = visited.size
const completed = progressCount >= total
const [endingVisible, setEndingVisible] = useState(false)
// 是否已展示过结束语(用于控制“离开场景”按钮出现时机)
// 若用户刷新页面且已完成,则默认视为结束语已展示,避免卡住无法离开
const [endingShown, setEndingShown] = useState(() => completed)
const endingShownRef = useRef(false)
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const { isEnabled } = useCursorController({
initialEnabled: [2],
dependencies: {
1: [2],
},
visited: visitedExhibits,
})
if (!moduleName) return null
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'nieer/place6/场景6展品1',
2: 'nieer/place6/场景6展品2',
}
const handleOpenExhibit = (id: 1 | 2) => {
setActiveExhibitId(id)
if (id === 1) {
setGuide1Step(0)
setGuide1Visible(true)
}
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/${place}/场景6`),
}}
>
<Title
title="伟大复兴的主旋律"
subtitle={subtitle}
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}/${starName}/place5`)}
/>
<Cursor
className="absolute left-90 bottom-130"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute right-240 bottom-135"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 最后一个场景:完成后不显示“前进”按钮 */}
{completed && endingShown && !endingVisible && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(14)
}
if (activeExhibitId === 2) {
triggerPuzzleAnimation(13)
}
}}
onClose={() => {
const closingId = activeExhibitId
setActiveExhibitId(null)
setGuide1Visible(false)
if (closingId !== null) {
markExhibitVisited(place, closingId)
saveNieerStore()
// 仅在“关闭展品”这一刻触发结束语:当本次关闭导致完成时,弹出 EndingOverlay
const nextVisitedSize = visited.has(closingId) ? visited.size : visited.size + 1
const willComplete = nextVisitedSize >= total
if (willComplete && !endingShownRef.current) {
endingShownRef.current = true
setEndingShown(true)
setEndingVisible(true)
}
}
}}
/>
{/* 光标1对话(图1) */}
<GuideSteps
visible={guide1Visible}
currentStep={guide1Step}
onStepChange={setGuide1Step}
onVisibleChange={setGuide1Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_25.mp3')}>
小沪,这些不同场景下奏响国歌的画面太让人感动了!在和平年代,这首歌还有什么特别的意义呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_26.mp3')}>
和平年代里,它的精神力量依然不减。你看这张中国女排奥运夺金的照片,国歌奏响时,是民族自豪的宣告;神舟飞船升空时的国歌,是航天人攻坚克难的礼赞。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
它就像一根精神纽带,无论在高光时刻还是艰难关头,都能凝聚起全国人民的力量,提醒我们不忘历史、砥砺前行。
</GuideStepsItem>
</GuideSteps>
{/* completed 后的结束语:统一使用 EndingOverlay */}
<EndingOverlay
visible={endingVisible}
audioSrc={getPublicPath('audio/nieer/mp3_nieer_27.mp3')}
text={
'从民族危机中觉醒的热血青年,到《义勇军进行曲》的谱曲者,聂耳用音符奏响了“救亡图存”的时代强音。' +
'而《义勇军进行曲》的影响远不止于此,接下来,请你化身国歌时空旅行者,踏入6个散落于岁月中的故事现场,' +
'一路聆听、一路共鸣,亲身体验这首旋律如何跨越时空,成为永远激荡在国人心中的精神号角吧!'
}
animationSpeed={200}
onContinue={() => setEndingVisible(false)}
/>
</div>
)
}
export default NieerPlace6
@@ -0,0 +1,274 @@
import { Image, Video } from '@/components'
import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useAudioManager } from '@/hooks/useAudioManager'
import { motion } from 'framer-motion'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
type ContentPage =
| { type: 'image'; src: string; audioSrc?: string }
| { type: 'video'; src: string }
// 每个场景的多页内容配置(按 scene.id 映射)
const SCENE_PAGES: Record<number, ContentPage[]> = {
1: [
{ type: 'image', src: '场景1内容', audioSrc: 'audio/nieer/mp3_nieer_29.mp3' },
{ type: 'video', src: 'video/nieer/nieer_2_1.mp4' },
],
2: [{ type: 'image', src: '场景2内容', audioSrc: 'audio/nieer/mp3_nieer_30.mp3' }],
3: [{ type: 'image', src: '场景3内容', audioSrc: 'audio/nieer/mp3_nieer_31.mp3' }],
4: [{ type: 'image', src: '场景4内容', audioSrc: 'audio/nieer/mp3_nieer_32.mp3' }],
5: [
{ type: 'image', src: '场景5内容', audioSrc: 'audio/nieer/mp3_nieer_33.mp3' },
{ type: 'video', src: 'video/nieer/nieer_2_4.mp4' },
],
6: [
{ type: 'image', src: '场景6内容', audioSrc: 'audio/nieer/mp3_nieer_34.mp3' },
{ type: 'video', src: 'video/nieer/nieer_2_5.mp4' },
],
}
type LobbyDialogScene = {
id: number
city: string
}
interface ContentDialogProps {
starName: string
title?: string
scene: LobbyDialogScene
onClose: () => void
onEnterScene: (sceneId: number) => void
}
const ContentDialog = ({ starName, title, scene, onClose, onEnterScene }: ContentDialogProps) => {
const [, setContentLoaded] = useState(false)
const pages = useMemo(() => SCENE_PAGES[scene.id] ?? [], [scene.id])
const [pageIndex, setPageIndex] = useState(0)
const [showCaption, setShowCaption] = useState(false)
const currentPage = pages[pageIndex] as ContentPage | undefined
const isLastPage = pageIndex >= pages.length - 1
const {
play: audioPlay,
stop: audioStop,
isPlaying: audioIsPlaying,
currentSrc: audioCurrentSrc,
} = useAudioManager({
mode: 'channel',
channelId: 'nieer-reflection',
})
const audioPlayingRef = useRef(false)
// 预期要播放的音频(仅用于判断“是否真的成功开始播放”)
const expectedAudioSrcRef = useRef<string | null>(null)
// 避免同一页图片重复触发 play(),导致音频被反复重置
const lastPlayedKeyRef = useRef<string | null>(null)
const goNext = useCallback(() => {
audioPlayingRef.current = false
if (isLastPage) {
// 如果最后一页是视频,由 onEnded 触发寄语;否则直接显示
const lastPage = pages[pageIndex]
if (lastPage?.type !== 'video') {
setShowCaption(true)
}
} else {
setPageIndex((i) => i + 1)
setContentLoaded(false)
}
}, [isLastPage, pages, pageIndex])
const handleVideoEnded = useCallback(() => {
setShowCaption(true)
}, [])
// 重置状态
useEffect(() => {
setContentLoaded(false)
setPageIndex(0)
setShowCaption(false)
audioPlayingRef.current = false
expectedAudioSrcRef.current = null
lastPlayedKeyRef.current = null
audioStop()
}, [scene.id, audioStop])
// 图片页自动播放音频
useEffect(() => {
if (!currentPage || currentPage.type !== 'image') return
const key = `${scene.id}-${pageIndex}`
if (lastPlayedKeyRef.current === key) return
if (currentPage.audioSrc) {
const src = getPublicPath(currentPage.audioSrc)
expectedAudioSrcRef.current = src
// 不要在 play() 调用后立刻置为 true;浏览器策略可能拦截,导致 isPlaying=false,从而不该自动翻页。
audioPlayingRef.current = false
audioPlay(src)
lastPlayedKeyRef.current = key
} else {
audioPlayingRef.current = false
expectedAudioSrcRef.current = null
lastPlayedKeyRef.current = key
}
}, [pageIndex, scene.id, currentPage, audioPlay])
// 只有当 audio 实际进入播放状态时才认为“正在播放图片页音频”
useEffect(() => {
if (!expectedAudioSrcRef.current) return
if (audioIsPlaying && audioCurrentSrc === expectedAudioSrcRef.current) {
audioPlayingRef.current = true
}
}, [audioIsPlaying, audioCurrentSrc])
// 音频结束 → 自动翻页
useEffect(() => {
if (!audioPlayingRef.current) return
if (!audioIsPlaying) {
audioPlayingRef.current = false
goNext()
}
}, [audioIsPlaying, goNext])
const boardBg = useMemo(
() => getBackgroundImage(`${starName}/reflection/lobby/dialog/竖底板`),
[starName]
)
// 跳过按钮只用于“查看图片/播放图片对应音频”阶段;
// 播放视频时不显示跳过按钮(由视频播放结束后触发寄语/进入下一步)。
const shouldShowSkip = !showCaption && currentPage?.type === 'image'
return (
<motion.div
className="absolute inset-0 z-50 flex items-center justify-center px-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
style={{ pointerEvents: 'auto' }}
>
{/* 左侧竖标题 */}
<div
className="w-[91px] h-[671px] mr-10 flex items-center justify-center"
style={{
backgroundImage: boardBg,
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
}}
>
{title && (
<div
className="text-5xl font-bold tracking-widest"
style={{
color: '#E1C98D',
writingMode: 'vertical-rl',
textOrientation: 'upright',
textShadow: '0 2px 8px rgba(0,0,0,0.55)',
}}
>
{title}
</div>
)}
</div>
{/* 内容框 */}
<div
className="relative w-[1296px] h-[737px] pt-10 pl-10 pr-13 pb-13"
style={{
backgroundImage: getBackgroundImage(`${starName}/reflection/lobby/dialog/内容框`),
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
}}
>
{currentPage?.type === 'image' && (
<Image
src={getStaticImage(`${starName}/reflection/lobby/content/${currentPage.src}`)}
alt={`${scene.city}内容`}
onLoad={() => setContentLoaded(true)}
/>
)}
{currentPage?.type === 'video' && (
<div className="w-full h-full rounded-tr-4xl rounded-bl-4xl overflow-hidden">
<Video
key={`${scene.id}-${pageIndex}`}
src={getPublicPath(currentPage.src)}
autoPlay
playsInline
controls
className="w-full h-full"
onLoad={() => setContentLoaded(true)}
onEnded={handleVideoEnded}
/>
</div>
)}
{/* 关闭按钮 */}
<Image
button
src={getStaticImage(`${starName}/reflection/lobby/dialog/内容框关闭`)}
className="absolute -right-10 -top-8 z-10"
alt="关闭"
onClick={() => {
audioStop()
onClose()
}}
/>
{/* 跳过按钮 */}
{shouldShowSkip && (
<button
type="button"
className="absolute right-12 top-6 z-10 px-4 py-1.5 rounded bg-black/40 text-white text-sm hover:bg-black/60 transition-colors"
onClick={() => {
audioPlayingRef.current = false
audioStop()
goNext()
}}
>
跳过
</button>
)}
{/* 底部页码指示器 */}
{pages.length > 1 && (
<div className="absolute right-8 bottom-6 z-10 flex items-center gap-3">
{pages.map((_, i) => (
<span
key={i}
className={cn(
'w-3 h-3 rounded-full transition-colors',
i === pageIndex ? 'bg-[#E1C98D]' : 'bg-white/30'
)}
/>
))}
</div>
)}
</div>
{/* 寄语按钮 */}
{showCaption && (
<div className="absolute right-10 bottom-1/2 translate-y-1/2">
<Image
button
src={getStaticImage(`${starName}/reflection/lobby/icon/寄语`)}
alt="寄语"
onClick={() => onEnterScene(scene.id)}
/>
<img
className="absolute -bottom-25 left-7 animate-finger-bounce pointer-events-none"
src={getStaticImage(`${starName}/reflection/lobby/icon/点此寄语留念`)}
alt="点此寄语留念"
/>
</div>
)}
</motion.div>
)
}
export default ContentDialog
+399
View File
@@ -0,0 +1,399 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { Image, Video } from '@/components'
import {
BeijingUnlockInteraction,
InputBg,
PianoUnlockInteraction,
PostcardUnlockInteraction,
SelectBg,
} from '../components'
import { getStaticImage } from '@/lib/utils'
import { LOBBY_COLUMNS, SCENE_TITLES, UNLOCK_TITLES } from './lobby.config'
import ContentDialog from './ContentDialog'
import UnlockHintDialog from './UnlockHintDialog'
import type { DialogState } from './useDialogReducer'
interface SharePayload {
sceneId?: number
[key: string]: unknown
}
interface DialogLayerProps {
state: DialogState
onEnterUnlock: () => void
onShare: (sceneId: number, payload?: SharePayload) => void
onFinishBadgeVideo: () => void
onClose: () => void
}
interface DialogLocalState {
selectedLyric?: string
guizhouInput: string
pianoFinished: boolean
selectedQuote?: string
postcardFinished: boolean
beijingFinished: boolean
postcardState: {
hasPlane: boolean
hasText: boolean
postcardImagePath: string
}
beijingSelection: {
paradeId?: string
paradeName?: string
paradeImagePath?: string
sloganId?: string
sloganImagePath?: string
previewImageDataUrl?: string
}
}
const starName = 'nieer'
const DEFAULT_POSTCARD_IMAGE_PATH = 'nieer/reflection/lobby/content/场景5待交互'
const ANTHEM_LYRICS = [
{ value: '1', label: '起来!不愿做奴隶的人们!' },
{ value: '2', label: '把我们的血肉,筑成我们新的长城!' },
{ value: '3', label: '中华民族到了最危险的时候' },
{ value: '4', label: '每个人被迫着发出最后的吼声' },
{ value: '5', label: '起来!起来!起来!' },
{ value: '6', label: '我们万众一心' },
{ value: '7', label: '冒着敌人的炮火,前进!' },
{ value: '8', label: '前进!前进!进!' },
]
const SMEDLEY_QUOTES = [
{ value: '1', label: '建设一个强大的和自由的中国' },
{ value: '2', label: '为我的遗体唱一首歌,中国的国歌……“起来”' },
{ value: '3', label: '我的心灵在这个世界上除了中国的任何地方都未能找到安宁' },
]
const getCanShare = (sceneId: number, localState: DialogLocalState): boolean => {
switch (sceneId) {
case 1:
return !!localState.selectedLyric
case 2:
return localState.guizhouInput.trim().length > 0
case 3:
return localState.pianoFinished
case 4:
return !!localState.selectedQuote
case 5:
return localState.postcardFinished
case 6:
return (
localState.beijingFinished &&
typeof localState.beijingSelection.previewImageDataUrl === 'string'
)
default:
return true
}
}
const buildSharePayload = (
sceneId: number,
localState: DialogLocalState
): SharePayload | undefined => {
switch (sceneId) {
case 1: {
const selected = ANTHEM_LYRICS.find((item) => item.value === localState.selectedLyric)
return {
sceneId: 1,
lyricValue: localState.selectedLyric ?? '',
lyricLabel: selected?.label ?? '',
}
}
case 2:
return {
sceneId: 2,
reflectionText: localState.guizhouInput.trim(),
}
case 3:
return {
sceneId: 3,
pianoCompleted: localState.pianoFinished,
pianoImagePath: 'nieer/reflection/lobby/box/音符',
}
case 4: {
const selected = SMEDLEY_QUOTES.find((item) => item.value === localState.selectedQuote)
return {
sceneId: 4,
quoteValue: localState.selectedQuote ?? '',
quoteLabel: selected?.label ?? '',
}
}
case 5:
return {
sceneId: 5,
...localState.postcardState,
}
case 6:
return {
sceneId: 6,
...localState.beijingSelection,
}
default:
return undefined
}
}
interface UnlockContentHandlers {
onSelectLyric: (value: string) => void
onChangeGuizhouInput: (value: string) => void
onFinishPiano: () => void
onSelectQuote: (value: string) => void
onFinishPostcard: () => void
onChangePostcardState: (payload: DialogLocalState['postcardState']) => void
onFinishBeijing: () => void
onChangeBeijingSelection: (payload: DialogLocalState['beijingSelection']) => void
}
const renderUnlockContent = (
sceneId: number,
localState: DialogLocalState,
handlers: UnlockContentHandlers
) => {
switch (sceneId) {
case 1:
return (
<div className="flex flex-col items-center justify-center mt-12">
<Image
className="mb-20"
src={getStaticImage(`${starName}/reflection/lobby/content/场景1交互`)}
alt="场景1交互"
/>
<SelectBg
options={ANTHEM_LYRICS}
value={localState.selectedLyric || ''}
onChange={handlers.onSelectLyric}
placeholder="请选择一句国歌歌词"
/>
</div>
)
case 2:
return (
<div className="flex flex-col items-center justify-center mt-12">
<Image
className="mb-20"
src={getStaticImage(`${starName}/reflection/lobby/content/场景2交互`)}
alt="场景2交互"
/>
<InputBg
className="w-[875px]"
value={localState.guizhouInput}
onChange={handlers.onChangeGuizhouInput}
placeholder="请输入你的爱国感悟(10字以内)"
maxLength={10}
/>
</div>
)
case 3:
return (
<div className="flex flex-col items-center justify-center mt-12">
<PianoUnlockInteraction onFinished={handlers.onFinishPiano} />
</div>
)
case 4:
return (
<div className="flex flex-col items-center justify-center mt-12">
<Image
className="mb-20"
src={getStaticImage(`${starName}/reflection/lobby/content/场景4交互`)}
alt="场景4交互"
/>
<SelectBg
options={SMEDLEY_QUOTES}
value={localState.selectedQuote || ''}
onChange={handlers.onSelectQuote}
placeholder="请选择一句最触动你的遗言"
/>
</div>
)
case 5:
return (
<PostcardUnlockInteraction
onFinished={handlers.onFinishPostcard}
onStateChange={handlers.onChangePostcardState}
/>
)
case 6:
return (
<BeijingUnlockInteraction
onFinished={handlers.onFinishBeijing}
onSelectionChange={handlers.onChangeBeijingSelection}
/>
)
default:
return null
}
}
const createUnlockHandlers = (
setSelectedLyric: (value: string) => void,
setGuizhouInput: (value: string) => void,
setPianoFinished: (value: boolean) => void,
setSelectedQuote: (value: string) => void,
setPostcardFinished: (value: boolean) => void,
setPostcardState: (payload: DialogLocalState['postcardState']) => void,
setBeijingFinished: (value: boolean) => void,
setBeijingSelection: (payload: DialogLocalState['beijingSelection']) => void
): UnlockContentHandlers => ({
onSelectLyric: setSelectedLyric,
onChangeGuizhouInput: (value) => setGuizhouInput(value.slice(0, 10)),
onFinishPiano: () => setPianoFinished(true),
onSelectQuote: setSelectedQuote,
onFinishPostcard: () => setPostcardFinished(true),
onChangePostcardState: setPostcardState,
onFinishBeijing: () => setBeijingFinished(true),
onChangeBeijingSelection: setBeijingSelection,
})
const DialogLayer = ({
state,
onEnterUnlock,
onShare,
onFinishBadgeVideo,
onClose,
}: DialogLayerProps) => {
const [selectedLyric, setSelectedLyric] = useState<string>()
const [guizhouInput, setGuizhouInput] = useState('')
const [pianoFinished, setPianoFinished] = useState(false)
const [selectedQuote, setSelectedQuote] = useState<string>()
const [postcardFinished, setPostcardFinished] = useState(false)
const [beijingFinished, setBeijingFinished] = useState(false)
const [postcardState, setPostcardState] = useState({
hasPlane: false,
hasText: false,
postcardImagePath: DEFAULT_POSTCARD_IMAGE_PATH,
})
const [beijingSelection, setBeijingSelection] = useState<DialogLocalState['beijingSelection']>({})
const dialogNonce = state.mode === 'idle' ? 0 : state.nonce
useEffect(() => {
setSelectedLyric(undefined)
setGuizhouInput('')
setPianoFinished(false)
setSelectedQuote(undefined)
setPostcardFinished(false)
setBeijingFinished(false)
setPostcardState({
hasPlane: false,
hasText: false,
postcardImagePath: DEFAULT_POSTCARD_IMAGE_PATH,
})
setBeijingSelection({})
}, [dialogNonce])
if (state.mode === 'idle') return <AnimatePresence mode="sync" />
const scene = LOBBY_COLUMNS.find((item) => item.id === state.sceneId)
if (!scene) return null
const localState: DialogLocalState = {
selectedLyric,
guizhouInput,
pianoFinished,
selectedQuote,
postcardFinished,
beijingFinished,
postcardState,
beijingSelection,
}
const canShare = getCanShare(state.sceneId, localState)
const unlockHandlers = createUnlockHandlers(
setSelectedLyric,
setGuizhouInput,
setPianoFinished,
setSelectedQuote,
setPostcardFinished,
setPostcardState,
setBeijingFinished,
setBeijingSelection
)
const handleShareClick = () => {
if (state.mode !== 'unlock') return
onShare(state.sceneId, buildSharePayload(state.sceneId, localState))
}
return (
<AnimatePresence mode="sync">
{state.mode === 'result' && (
<motion.div
key={`result-${state.sceneId}-${state.nonce}`}
className="absolute w-full h-full z-99"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
>
<div className="relative w-full h-full">
<Image
src={getStaticImage(
`${starName}/reflection/lobby/result/聂耳结束场景${state.sceneId}`
)}
alt="结果页"
/>
{state.videoSrc && (
<div className="absolute inset-0 z-20 bg-black">
<Video
key={state.videoSrc}
src={state.videoSrc}
autoPlay
playsInline
className="w-full h-full"
onSkip={onFinishBadgeVideo}
onEnded={onFinishBadgeVideo}
/>
</div>
)}
<Image
button
src={getStaticImage('components/关闭')}
className="absolute right-6 top-5 z-30"
alt="关闭"
onClick={onClose}
/>
</div>
</motion.div>
)}
{state.mode === 'unlock' && (
<motion.div
key={`unlock-${state.sceneId}-${state.hasShare}-${state.nonce}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className="absolute inset-0 z-40"
>
<UnlockHintDialog
starName={starName}
title={UNLOCK_TITLES[state.sceneId]}
visible
onClose={onClose}
onShare={state.hasShare && canShare ? handleShareClick : undefined}
>
{renderUnlockContent(state.sceneId, localState, unlockHandlers)}
</UnlockHintDialog>
</motion.div>
)}
{state.mode === 'content' && (
<ContentDialog
key={`content-${state.sceneId}-${state.nonce}`}
starName={starName}
title={SCENE_TITLES[state.sceneId]}
scene={scene}
onClose={onClose}
onEnterScene={onEnterUnlock}
/>
)}
</AnimatePresence>
)
}
export default DialogLayer
@@ -0,0 +1,78 @@
import { Image } from '@/components'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { motion } from 'framer-motion'
import type { ReactNode } from 'react'
type UnlockHintDialogProps = {
title?: string
starName: string
visible: boolean
onClose: () => void
onShare?: () => void
children?: ReactNode
}
const UnlockHintDialog = ({
title,
starName,
visible,
onClose,
onShare,
children,
}: UnlockHintDialogProps) => {
return (
<motion.div
className="absolute inset-0 z-50 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: visible ? 1 : 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
style={{
pointerEvents: visible ? 'auto' : 'none',
backgroundColor: 'rgba(132, 120, 100, 0.93)',
}}
>
{visible && (
<div
className="relative w-[1666px] h-[972px] bg-no-repeat bg-cover p-12 pl-4"
style={{
backgroundImage: getBackgroundImage(
`${starName}/reflection/lobby/dialog/场景交互底板-最后一个`
),
}}
>
<Image
button
src={getStaticImage(`${starName}/reflection/lobby/dialog/按钮关闭2`)}
className="absolute -right-10 -top-8 z-10"
alt="关闭"
onClick={onClose}
/>
{title && (
<div className="flex justify-center items-center h-42">
<div className="w-3/4 text-4xl font-bold text-[#E0D2B3] text-center leading-14">
{title}
</div>
</div>
)}
<div className="h-[calc(100%-188px)]">{children}</div>
{onShare && (
<div className="absolute -right-20 bottom-1/2 translate-y-1/2">
<Image
button
src={getStaticImage(`${starName}/reflection/lobby/icon/分享`)}
alt="分享"
onClick={onShare}
/>
</div>
)}
</div>
)}
</motion.div>
)
}
export default UnlockHintDialog
+132
View File
@@ -0,0 +1,132 @@
import { Image } from '@/components'
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { PLACE_REQUIRED_COUNTS } from './lobby.config'
import { useNieerStore } from '@/store/nieerStore'
const starName = 'nieer'
const MODULES = [
{ id: 1, place: 'place1', pos: 'left-140 top-65' },
{ id: 2, place: 'place2', pos: 'left-332 top-65' },
{ id: 3, place: 'place3', pos: 'left-80 top-108' },
{ id: 4, place: 'place4', pos: 'left-275 top-112' },
{ id: 5, place: 'place5', pos: 'left-120 top-162' },
{ id: 6, place: 'place6', pos: 'left-320 top-162' },
] as const
const NieerReflectionBox = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const visitedReflectionCursors = useNieerStore((state) => state.visitedReflectionCursors)
const reflectionWorks = useNieerStore((state) => state.reflectionWorks)
const completionByPlace = useMemo(() => {
const getVisitedCount = (placeName: string): number =>
visitedReflectionCursors[placeName]?.size ?? 0
return {
place1: getVisitedCount('place1') >= (PLACE_REQUIRED_COUNTS[1] ?? 0),
place2: getVisitedCount('place2') >= (PLACE_REQUIRED_COUNTS[2] ?? 0),
place3: getVisitedCount('place3') >= (PLACE_REQUIRED_COUNTS[3] ?? 0),
place4: getVisitedCount('place4') >= (PLACE_REQUIRED_COUNTS[4] ?? 0),
place5: getVisitedCount('place5') >= (PLACE_REQUIRED_COUNTS[5] ?? 0),
place6: getVisitedCount('place6') >= (PLACE_REQUIRED_COUNTS[6] ?? 0),
} as const
}, [visitedReflectionCursors])
if (!moduleName) return null
return (
<div
className="w-full h-full relative"
style={{
backgroundImage: getBackgroundImage(`${starName}/reflection/lobby/box/旅行箱内置`),
}}
>
<Image
button
className="absolute right-6 top-6 z-10"
src={getStaticImage('components/返回')}
alt="返回"
onClick={() => navigate(`/${moduleName}/${starName}/reflection`)}
/>
<div className="absolute inset-0">
{MODULES.map((module) => {
const done = completionByPlace[module.place]
const work = reflectionWorks[module.id]
const imagePathOrDataUrl =
typeof work?.previewImageDataUrl === 'string'
? work.previewImageDataUrl
: typeof work?.postcardImagePath === 'string'
? work.postcardImagePath
: typeof work?.paradeImagePath === 'string'
? work.paradeImagePath
: typeof work?.pianoImagePath === 'string'
? work.pianoImagePath
: null
const imageSrc =
imagePathOrDataUrl && imagePathOrDataUrl.startsWith('data:image')
? imagePathOrDataUrl
: imagePathOrDataUrl
? getStaticImage(imagePathOrDataUrl)
: null
const shouldShowSloganImage =
done &&
typeof work?.sloganImagePath === 'string' &&
!(typeof work?.previewImageDataUrl === 'string')
const sloganImageSrc =
shouldShowSloganImage && typeof work?.sloganImagePath === 'string'
? getStaticImage(work.sloganImagePath)
: null
const textContent =
typeof work?.reflectionText === 'string'
? work.reflectionText
: typeof work?.lyricLabel === 'string'
? work.lyricLabel
: typeof work?.quoteLabel === 'string'
? work.quoteLabel
: null
return (
<div key={module.id} className={cn('absolute', ' text-4xl font-semibold', module.pos)}>
{!done && (
<div className="w-full h-full flex items-center justify-center text-[#9a8b74]">
暂未收集
</div>
)}
{done && imageSrc && (
<Image
width={imageSrc.startsWith('data:image') ? 280 : 440}
src={imageSrc}
alt="成果缩略图"
/>
)}
{shouldShowSloganImage && sloganImageSrc && (
<Image
className="absolute -bottom-15 -right-15 mt-2"
width="100%"
src={sloganImageSrc}
alt="成果词条"
/>
)}
{done && textContent && (
<div className="text-[#6b4d2b] text-4xl w-180 leading-12">{textContent}</div>
)}
{done && (
<Image
className="absolute right-6 top-1/2 -translate-y-1/2"
width={86}
src={getStaticImage(`${starName}/reflection/lobby/box/音符`)}
alt="音符"
/>
)}
</div>
)
})}
</div>
</div>
)
}
export default NieerReflectionBox
+258
View File
@@ -0,0 +1,258 @@
import { Image, StudyGuideModal } from '@/components'
import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { LOBBY_COLUMNS, LOBBY_LAYOUT, LOBBY_TIMELINE } from './lobby.config'
import { useNieerStore } from '@/store/nieerStore'
import { useDialogReducer } from './useDialogReducer'
import DialogLayer from './DialogLayer'
import {
BADGE_CONFIG,
getAllPlacesCompleted,
getBadgeUnlockedMap,
getGuideSceneId,
getPlaceCompletionMap,
getUnlockVideoSrcForScene,
getUnlockedByIdMap,
} from './reflectionProgress'
const starName = 'nieer'
const NieerReflection = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const debugLobby = new URLSearchParams(window.location.search).get('debugLobby') === '1'
const visitedReflectionCursors = useNieerStore((state) => state.visitedReflectionCursors)
const markReflectionCursorVisited = useNieerStore((state) => state.markReflectionCursorVisited)
const setReflectionWork = useNieerStore((state) => state.setReflectionWork)
const hasSeenReflectionGuide = useNieerStore((state) => state.hasSeenReflectionGuide)
const markReflectionGuideSeen = useNieerStore((state) => state.markReflectionGuideSeen)
const showGuideModal = !hasSeenReflectionGuide
const {
state: dialogState,
openScene,
enterUnlock,
handleShare,
finishBadgeVideo,
close,
} = useDialogReducer(markReflectionCursorVisited, setReflectionWork)
const placeCompletion = useMemo(
() => getPlaceCompletionMap(visitedReflectionCursors),
[visitedReflectionCursors]
)
const unlockedById = useMemo(() => getUnlockedByIdMap(placeCompletion), [placeCompletion])
const badgeUnlocked = useMemo(() => getBadgeUnlockedMap(placeCompletion), [placeCompletion])
const guideSceneId = useMemo(() => getGuideSceneId(placeCompletion), [placeCompletion])
const allPlacesCompleted = useMemo(
() => getAllPlacesCompleted(placeCompletion),
[placeCompletion]
)
const handleResultShare = (sceneId: number, payload?: Record<string, unknown>) => {
handleShare({
payload,
unlockVideoSrc: getUnlockVideoSrcForScene(sceneId, visitedReflectionCursors),
})
}
if (!moduleName) return null
return (
<div
className="w-full h-full relative bg-cover bg-center bg-no-repeat overflow-hidden"
style={{ backgroundImage: getBackgroundImage(`${starName}/reflection/lobby/场景首页底图`) }}
>
<Image
className="absolute right-6 top-5 z-30"
src={getStaticImage('components/返回')}
button
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
<div
className={cn(
'absolute h-full grid z-1',
LOBBY_LAYOUT.columnsTop,
LOBBY_LAYOUT.columnsLeft
)}
style={{
width: `${LOBBY_LAYOUT.columnsWidthPx}px`,
gridTemplateColumns: `repeat(${LOBBY_LAYOUT.columnCount}, minmax(0, 1fr))`,
}}
>
<img
className="absolute -left-65 top-0 z-2 w-[1651px] h-auto pointer-events-none"
src={getStaticImage(`${starName}/reflection/lobby/menu/琴弦`)}
alt="琴弦"
/>
{LOBBY_COLUMNS.map((scene) => {
const isUnlocked = unlockedById[scene.id as keyof typeof unlockedById]
const isCompleted = placeCompletion[scene.id as keyof typeof placeCompletion]
const iconSrc = isCompleted ? scene.icon : scene.iconBw
const isClickable = isUnlocked || scene.id === guideSceneId
const showGuideState = !allPlacesCompleted && scene.id === guideSceneId
return (
<button
key={scene.id}
type="button"
className={cn(
'relative w-[220px] h-full flex flex-col items-center',
!isClickable && 'pointer-events-none cursor-not-allowed',
showGuideState && 'border-l-6 border-r-6 border-white'
)}
onClick={() => {
if (!isClickable) return
openScene(scene.id, !!isUnlocked)
}}
>
<Image
className={cn('absolute inset-0 -z-1', !isUnlocked && 'brightness-50')}
src={getStaticImage(`${starName}/reflection/lobby/${scene.cityImage}`)}
alt={scene.city}
/>
<div
className={cn(
'relative z-3',
'w-[233px] h-[233px] flex items-center justify-center -mt-13'
)}
>
<div className="relative w-[112px] h-[111px]">
{showGuideState && (
<span
className={cn(
' absolute -z-3 left-0 inline-flex h-full w-full rounded-full',
'bg-[#fcc419] opacity-100 animate-ping',
'border-2 border-white drop-shadow-2xl'
)}
/>
)}
<img
className="w-full"
src={getStaticImage(`${starName}/reflection/lobby/menu/${iconSrc}`)}
alt={scene.icon}
/>
</div>
</div>
{showGuideState && (
<Image
className="animate-finger-bounce absolute bottom-[calc(50%-200px)] translate-y-1/2"
src={getStaticImage(`${starName}/reflection/lobby/icon/点击前往`)}
alt="点击前往"
/>
)}
</button>
)
})}
</div>
<div
className={cn('absolute z-10 grid', LOBBY_LAYOUT.timelineTop, LOBBY_LAYOUT.columnsLeft)}
style={{
width: `${LOBBY_LAYOUT.columnsWidthPx}px`,
gridTemplateColumns: `repeat(${LOBBY_LAYOUT.columnCount}, minmax(0, 1fr))`,
}}
>
{LOBBY_TIMELINE.map((item) => {
if (item.type === 'single') {
return (
<div
key={`${item.type}-${item.col}-${item.label}`}
className="text-center text-4xl font-semibold text-white [text-shadow:0_0_20px_rgb(247_214_97_/0.8),0_0_20px_rgb(247_214_97/0.8)]"
style={{ gridColumn: `${item.col + 1} / span 1` }}
>
{item.label}
</div>
)
}
return (
<div
key={`${item.type}-${item.colStart}-${item.colEnd}-${item.label}`}
className="flex items-center justify-center gap-3 text-4xl font-semibold text-white [text-shadow:0_0_20px_rgb(247_214_97_/0.8),0_0_20px_rgb(247_214_97/0.8)]"
style={{ gridColumn: `${item.colStart + 1} / ${item.colEnd + 2}` }}
>
{item.withLine && <span className="h-[2px] w-24 bg-[#f5e9c7]/80" />}
<span>{item.label}</span>
{item.withLine && <span className="h-[2px] w-24 bg-[#f5e9c7]/80" />}
</div>
)
})}
</div>
<div
className="absolute left-0 bottom-0 w-full h-[201px] z-20 flex justify-center items-center pt-20 gap-10"
style={{
backgroundImage: getBackgroundImage(`${starName}/reflection/lobby/footer`),
}}
>
<div className="relative w-340 h-full">
{BADGE_CONFIG.map((badge) => {
const isLit = badgeUnlocked[badge.id as keyof typeof badgeUnlocked]
return (
<div key={badge.id} className={cn('absolute w-[193px]', badge.className)}>
<Image
width={193}
src={getStaticImage(`${starName}/reflection/lobby/icon/徽章底板`)}
alt="徽章底板"
/>
{isLit && (
<Image
className="absolute inset-0"
width={193}
src={getStaticImage(`${starName}/reflection/lobby/icon/徽章${badge.id}`)}
alt={`徽章${badge.id}`}
/>
)}
</div>
)
})}
</div>
</div>
<Image
button
src={getStaticImage(`${starName}/reflection/lobby/icon/旅行箱`)}
className="absolute right-0 bottom-0 z-30"
alt="旅行箱"
onClick={() => navigate(`/${moduleName}/${starName}/reflection/box`)}
/>
{debugLobby && (
<div
className="absolute inset-0 z-40 pointer-events-none"
style={{
background:
'linear-gradient(to right, rgba(255,0,0,0.25) 1px, transparent 1px), linear-gradient(to bottom, rgba(0,255,255,0.18) 1px, transparent 1px)',
backgroundSize: '120px 100%, 100% 80px',
}}
/>
)}
<DialogLayer
state={dialogState}
onEnterUnlock={enterUnlock}
onShare={handleResultShare}
onFinishBadgeVideo={finishBadgeVideo}
onClose={close}
/>
<StudyGuideModal
visible={showGuideModal}
onContinue={markReflectionGuideSeen}
audioSrc={getPublicPath('audio/nieer/mp3_nieer_28.mp3')}
contentPaddingLeft="2em"
// firstLineIndent="2em"
>
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1936年,上海。
请带上你的旅行包,穿越时空解锁6个国歌故事,集齐精神徽章,绘制属于你的国歌精神图谱吧~时空门已开,请出发吧!`}
</StudyGuideModal>
</div>
)
}
export default NieerReflection
@@ -0,0 +1,74 @@
export const LOBBY_LAYOUT = {
columnsLeft: 'left-75',
columnsTop: 'top-0',
columnsWidthPx: 1320,
columnCount: 6,
timelineTop: 'top-45',
} as const
export const LOBBY_COLUMNS = [
{ id: 1, city: '上海', cityImage: '上海', icon: '话筒', iconBw: '黑白-话筒', active: true },
{ id: 2, city: '贵州', cityImage: '贵州', icon: '喇叭', iconBw: '黑白-喇叭', active: false },
{
id: 3,
city: '欧洲·亚洲',
cityImage: '欧洲亚洲',
icon: '唱片',
iconBw: '黑白-唱片',
active: false,
},
{ id: 4, city: '美国', cityImage: '美国', icon: '羽毛笔', iconBw: '黑白-羽毛', active: false },
{ id: 5, city: '利比亚', cityImage: '利比亚', icon: '飞机', iconBw: '黑白-飞机', active: false },
{ id: 6, city: '北京', cityImage: '北京', icon: '车', iconBw: '黑白-车', active: false },
] as const
type TimelineSingle = {
type: 'single'
col: number
label: string
}
type TimelineSpan = {
type: 'span'
colStart: number
colEnd: number
label: string
withLine?: boolean
}
export type TimelineItem = TimelineSingle | TimelineSpan
export const LOBBY_TIMELINE: TimelineItem[] = [
{ type: 'single', col: 0, label: '1936' },
{ type: 'span', colStart: 1, colEnd: 2, label: '1937', withLine: true },
{ type: 'single', col: 3, label: '1950' },
{ type: 'single', col: 4, label: '2011' },
{ type: 'single', col: 5, label: '2025' },
]
export const SCENE_TITLES: Record<number, string> = {
1: '抗战诞生·烽火铸歌',
2: '抗战诞生·烽火铸歌',
3: '海外传播·声动世界',
4: '海外传播·声动世界',
5: '当代传承·薪火不息',
6: '当代传承·薪火不息',
}
export const PLACE_REQUIRED_COUNTS: Record<number, number> = {
1: 3,
2: 2,
3: 3,
4: 2,
5: 3,
6: 2,
}
export const UNLOCK_TITLES: Record<number, string> = {
1: '在输入框中写下1句你想教给他人的国歌歌词,把爱国之声传递出去吧~',
2: '了解了蓝运瑜老人的故事,请你在下方输入框中,写下1句10字以内的你的爱国感悟吧!',
3: '请跟随光点在钢琴上弹奏出国歌的片段,为任光的演出伴奏吧~',
4: '点击选择史沫特莱女士临终遗言最触动你的一句话。',
5: '请拖曳配齐图片要素制作接同胞回家的明信片!',
6: '选一个你最喜欢的阅兵方阵,搭配一句词条,点击分享,定格那个瞬间。',
}
@@ -0,0 +1,115 @@
import { getPublicPath } from '@/lib/utils'
import { PLACE_REQUIRED_COUNTS } from './lobby.config'
export const BADGE_SCENE_GROUPS = {
1: [1, 2],
2: [3, 4],
3: [5, 6],
} as const
export const BADGE_UNLOCK_VIDEO_MAP = {
1: getPublicPath('video/nieer/nieer_2_2.mp4'),
2: getPublicPath('video/nieer/nieer_2_3.mp4'),
3: getPublicPath('video/nieer/nieer_2_6.mp4'),
} as const
export const BADGE_CONFIG = [
{
id: 1,
className: 'top-0 left-36',
},
{
id: 2,
className: 'top-0 left-146',
},
{
id: 3,
className: 'top-0 right-36',
},
] as const
export const PLACE_SEQUENCE = [1, 2, 3, 4, 5, 6] as const
export type PlaceCompletionMap = Record<(typeof PLACE_SEQUENCE)[number], boolean>
export const getPlaceCompletionMap = (
visitedReflectionCursors: Record<string, Set<number>>
): PlaceCompletionMap => {
const getVisitedCount = (placeName: string): number =>
visitedReflectionCursors[placeName]?.size ?? 0
return {
1: getVisitedCount('place1') >= (PLACE_REQUIRED_COUNTS[1] ?? 0),
2: getVisitedCount('place2') >= (PLACE_REQUIRED_COUNTS[2] ?? 0),
3: getVisitedCount('place3') >= (PLACE_REQUIRED_COUNTS[3] ?? 0),
4: getVisitedCount('place4') >= (PLACE_REQUIRED_COUNTS[4] ?? 0),
5: getVisitedCount('place5') >= (PLACE_REQUIRED_COUNTS[5] ?? 0),
6: getVisitedCount('place6') >= (PLACE_REQUIRED_COUNTS[6] ?? 0),
}
}
export const getUnlockedBadgeIds = (
placeCompletion: PlaceCompletionMap
): Array<keyof typeof BADGE_SCENE_GROUPS> => {
return (
Object.entries(BADGE_SCENE_GROUPS) as unknown as Array<
[keyof typeof BADGE_SCENE_GROUPS, readonly number[]]
>
)
.filter(([, sceneIds]) =>
sceneIds.every((sceneId) => placeCompletion[sceneId as keyof PlaceCompletionMap])
)
.map(([badgeId]) => badgeId)
}
export const getBadgeUnlockedMap = (placeCompletion: PlaceCompletionMap) => ({
1: placeCompletion[1] && placeCompletion[2],
2: placeCompletion[3] && placeCompletion[4],
3: placeCompletion[5] && placeCompletion[6],
})
export const getUnlockedByIdMap = (placeCompletion: PlaceCompletionMap) =>
({
1: true,
2: placeCompletion[1],
3: placeCompletion[2],
4: placeCompletion[3],
5: placeCompletion[4],
6: placeCompletion[5],
}) as const
export const getGuideSceneId = (placeCompletion: PlaceCompletionMap) => {
const nextScene = PLACE_SEQUENCE.find((sceneId) => !placeCompletion[sceneId])
return nextScene ?? 6
}
export const getAllPlacesCompleted = (placeCompletion: PlaceCompletionMap) =>
PLACE_SEQUENCE.every((sceneId) => placeCompletion[sceneId])
export const getUnlockVideoSrcForScene = (
sceneId: number,
visitedReflectionCursors: Record<string, Set<number>>
): string | undefined => {
const previousPlaceCompletion = getPlaceCompletionMap(visitedReflectionCursors)
const previousBadges = new Set(getUnlockedBadgeIds(previousPlaceCompletion))
const nextVisitedReflectionCursors: Record<string, Set<number>> = Object.fromEntries(
Object.entries(visitedReflectionCursors).map(([placeName, cursorIds]) => [
placeName,
new Set(cursorIds),
])
)
const currentPlaceName = `place${sceneId}`
const requiredCount = PLACE_REQUIRED_COUNTS[sceneId] ?? 0
nextVisitedReflectionCursors[currentPlaceName] = new Set(
Array.from({ length: requiredCount }, (_, index) => index + 1)
)
const nextPlaceCompletion = getPlaceCompletionMap(nextVisitedReflectionCursors)
const unlockedBadgeId = getUnlockedBadgeIds(nextPlaceCompletion).find(
(badgeId) => !previousBadges.has(badgeId)
)
return unlockedBadgeId ? BADGE_UNLOCK_VIDEO_MAP[unlockedBadgeId] : undefined
}
@@ -0,0 +1,112 @@
import { useCallback, useReducer, useRef } from 'react'
import { PLACE_REQUIRED_COUNTS } from './lobby.config'
// ── State ──────────────────────────────────────────────
export type DialogState =
| { mode: 'idle' }
| { mode: 'content'; sceneId: number; nonce: number }
| { mode: 'unlock'; sceneId: number; hasShare: boolean; nonce: number }
| { mode: 'result'; sceneId: number; nonce: number; videoSrc?: string }
// ── Actions ────────────────────────────────────────────
type DialogAction =
| { type: 'OPEN_SCENE'; sceneId: number; unlocked: boolean; nonce: number }
| { type: 'ENTER_UNLOCK' }
| { type: 'SHOW_RESULT'; videoSrc?: string }
| { type: 'FINISH_BADGE_VIDEO' }
| { type: 'CLOSE' }
interface ShareOptions {
payload?: Record<string, unknown>
unlockVideoSrc?: string
}
// ── Reducer ────────────────────────────────────────────
function dialogReducer(state: DialogState, action: DialogAction): DialogState {
switch (action.type) {
case 'OPEN_SCENE':
return action.unlocked
? { mode: 'content', sceneId: action.sceneId, nonce: action.nonce }
: { mode: 'unlock', sceneId: action.sceneId, hasShare: false, nonce: action.nonce }
case 'ENTER_UNLOCK':
if (state.mode !== 'content') return state
return { mode: 'unlock', sceneId: state.sceneId, hasShare: true, nonce: state.nonce }
case 'FINISH_BADGE_VIDEO':
if (state.mode !== 'result') return state
return { ...state, videoSrc: undefined }
case 'SHOW_RESULT':
if (state.mode !== 'unlock') return state
return {
mode: 'result',
sceneId: state.sceneId,
nonce: state.nonce,
videoSrc: action.videoSrc,
}
case 'CLOSE':
return { mode: 'idle' }
default:
return state
}
}
// ── Hook ───────────────────────────────────────────────
export function useDialogReducer(
markExhibitVisited: (place: string, exhibitId: number) => void,
setReflectionWork: (sceneId: number, payload: Record<string, unknown>) => void
) {
const [state, dispatch] = useReducer(dialogReducer, { mode: 'idle' })
const shareGuardRef = useRef(false)
const nonceRef = useRef(0)
const openScene = useCallback((sceneId: number, unlocked: boolean) => {
shareGuardRef.current = false
nonceRef.current += 1
dispatch({ type: 'OPEN_SCENE', sceneId, unlocked, nonce: nonceRef.current })
}, [])
const enterUnlock = useCallback(() => {
dispatch({ type: 'ENTER_UNLOCK' })
}, [])
const handleShare = useCallback(
({ payload, unlockVideoSrc }: ShareOptions = {}) => {
if (shareGuardRef.current) return
if (state.mode !== 'unlock') return
shareGuardRef.current = true
const { sceneId } = state
const placeName = `place${sceneId}`
const needCount = PLACE_REQUIRED_COUNTS[sceneId] ?? 0
for (let i = 1; i <= needCount; i++) {
markExhibitVisited(placeName, i)
}
if (payload) {
setReflectionWork(sceneId, payload)
}
dispatch({ type: 'SHOW_RESULT', videoSrc: unlockVideoSrc })
},
[state, markExhibitVisited, setReflectionWork]
)
const finishBadgeVideo = useCallback(() => {
dispatch({ type: 'FINISH_BADGE_VIDEO' })
}, [])
const close = useCallback(() => {
shareGuardRef.current = false
dispatch({ type: 'CLOSE' })
}, [])
return { state, openScene, enterUnlock, handleShare, finishBadgeVideo, close }
}
Loaded 100 of 1351 files, more files were not shown because too many files have changed in this diff. Show more