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

+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