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

+56
View File
@@ -0,0 +1,56 @@
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image } from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { useRongdeshengStore } from '@/store/rongdeshengStore'
const RongdeshengHome = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
// starName 固定为 rongdesheng
const starName = 'rongdesheng'
const lastVisitedPlace = useRongdeshengStore((s) => s.lastVisitedPlace)
if (!moduleName) return null
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/底图`),
}}
>
<Image
className="absolute right-37 top-22"
src={getStaticImage('components/革命先驱故居-返回')}
button
onClick={() => navigate(`/entrance/${moduleName}`)}
/>
<div className="absolute right-20 top-75 flex flex-col space-y-10">
{/* 对话 */}
<Image
button
src={getStaticImage('star/一-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)}
/>
{/* 体悟(占位) */}
<Image
button
src={getStaticImage('star/二-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/reflection`)}
/>
{/* 内化(占位) */}
<Image
button
src={getStaticImage('star/三-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)}
/>
</div>
</div>
)
}
export default RongdeshengHome
+492
View File
@@ -0,0 +1,492 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
DragQuizPanel,
EndingOverlay,
GuideSteps,
GuideStepsItem,
Image,
LetterToRongdesheng,
Narration,
Video,
type DragFillResult,
type VideoAnchor,
type VideoRef,
} from '@/components'
import { SuccessAlert } from '@/components/layout'
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
const starName = 'rongdesheng'
type QuizOption = {
value: string
label: string
isCorrect?: boolean
explanation?: string
}
type QuizAnchor = {
id: string
time: number
type: 'single' | 'multiple' | 'drag'
title: string
prompt?: string
promptSuffix?: string
options: QuizOption[]
blanks?: { id: string; label: string }[]
blankSeparators?: string[]
correctAnswer?: string | string[]
correctAnswersMap?: Record<string, string>
successContent?: string
explanation?: string
}
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: 'quiz-1',
time: 60,
type: 'drag',
title: '',
prompt: '【拖拽】荣德生大力发展与国计民生密切相关的',
promptSuffix: '产业。',
blanks: [
{ id: 'blank-1', label: '____' },
{ id: 'blank-2', label: '____' },
],
blankSeparators: ['产业和'],
options: [
{ value: '面粉', label: 'A. 面粉', isCorrect: true },
{ value: '纺织', label: 'B. 纺织', isCorrect: true },
{
value: '采矿',
label: 'C. 采矿',
explanation: '荣德生的实业布局并未涉及采矿业。请你再想想吧!',
},
],
correctAnswersMap: {
'blank-1': '面粉',
'blank-2': '纺织',
},
successContent: '',
explanation: '荣德生的实业布局并未涉及采矿业。请你再想想吧!',
},
{
id: 'quiz-2',
time: 60 + 51,
type: 'single',
title: '【单选】荣德生的企业虽多次遭战火摧毁,但始终浴火重生,这体现了他哪种宝贵品质?',
options: [
{
value: 'A',
label: 'A. 坚韧不拔、锲而不舍',
isCorrect: true,
explanation:
'荣德生面对战火摧残,始终坚守实业,70岁高龄仍重建工厂,这份坚韧是民族实业家的宝贵品格。',
},
{
value: 'B',
label: 'B. 急流勇退、明哲保身',
explanation: '荣德生始终坚守实业救国初心,从未退缩,这并非他的品质。请你再想想吧!',
},
],
correctAnswer: 'A',
},
{
id: 'quiz-3',
time: 3 * 60 + 29,
type: 'multiple',
title: '【多选】荣德生用实际行动诠释了个人与国家的紧密联系,下列哪些事迹体现了他的爱国情怀?',
options: [
{ value: 'A', label: 'A. 淞沪抗战中捐钱捐物,一次捐助一万包面粉支援前线', isCorrect: true },
{ value: 'B', label: 'B. 拒绝日军、汪伪政权的威逼利诱,不转让工厂产权', isCorrect: true },
{ value: 'C', label: 'C. 1948年坚守大陆,将荣氏企业完整留给新中国', isCorrect: true },
{
value: 'D',
label: 'D. 专注企业盈利,不参与任何社会公益与爱国活动',
explanation:
'很遗憾选择有误,荣德生是爱国民族实业家,始终心系国家,不仅仅专注企业盈利,上述多项事迹均体现了他的爱国情怀。请你再想想吧!',
},
],
correctAnswer: ['A', 'B', 'C'],
successContent: '荣德生始终将个人实业与国家命运绑定,用实际行动践行了实业救国的爱国初心。',
},
{
id: 'quiz-4',
time: 4 * 60 + 33,
type: 'single',
title: '【单选】抗战时期,荣德生为助力民族工业人才培养,做出了哪项举措?',
options: [
{
value: 'A',
label: 'A. 开办纺织专科学校',
isCorrect: true,
explanation: '荣德生重视实业人才培养,抗战时期开办纺织专科学校,为行业发展储备力量。',
},
{
value: 'B',
label: 'B. 设立全国性实业奖学金',
explanation: '荣德生彼时的举措是开办纺织专科学校,并非设立全国性实业奖学金。请你再想想吧!',
},
],
correctAnswer: 'A',
},
{
id: 'quiz-5',
time: 5 * 60 + 22,
type: 'single',
title:
'【单选】荣德生先生提出的“崇俭立国”主张,以及造桥铺路、创办学校等善举,体现了他怎样的财富观?',
options: [
{
value: 'A',
label: 'A. 创造财富后回馈社会,勇担社会责任',
isCorrect: true,
explanation:
'荣德生不将财富用于个人享乐,而是投身公益、建设家乡,让财富成为推动社会发展的力量。',
},
{
value: 'B',
label: 'B. 将财富完全用于企业扩张,追求利益最大化',
explanation:
'荣德生重视企业发展,但更注重财富的社会价值,并非单纯追求利益最大化。请你再想想吧!',
},
],
correctAnswer: 'A',
},
]
const RongdeshengInternalization = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const [dragQuizValues, setDragQuizValues] = useState<Record<string, DragFillResult[]>>({})
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>({})
const [narrationVisible, setNarrationVisible] = useState(true)
const [preGuideVisible, setPreGuideVisible] = useState(false)
const [showVideo, setShowVideo] = useState(false)
const [postGuideVisible, setPostGuideVisible] = useState(false)
const [showLetter, setShowLetter] = useState(false)
const [showEnding, setShowEnding] = useState(false)
const [activeSuccess, setActiveSuccess] = useState<string | null>(null)
const [successContent, setSuccessContent] = useState('')
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
const videoRef = useRef<VideoRef>(null)
useEffect(() => {
if (!narrationVisible) {
setPreGuideVisible(true)
}
}, [narrationVisible])
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
videoRef.current.play().catch(() => {})
}
}
const anchors: VideoAnchor[] = useMemo(() => {
return QUIZ_ANCHORS.map((item) => {
if (item.type === 'drag' && item.blanks) {
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => {
const handleDragChange = (value: DragFillResult[]) => {
setDragQuizValues((prev) => ({ ...prev, [item.id]: value }))
setDragQuizErrors((prev) => ({ ...prev, [item.id]: '' }))
if (value.length < item.blanks!.length) return
const hasWrongOption = value.some((result) => {
const option = item.options.find((current) => current.value === result.optionValue)
return option && !option.isCorrect
})
if (hasWrongOption) {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: item.explanation || '请你再想想吧!',
}))
return
}
const isCorrect = item.blanks!.every((blank) => {
const filled = value.find((result) => result.blankId === blank.id)
return filled?.optionValue === item.correctAnswersMap?.[blank.id]
})
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent(item.successContent || '')
setResumeCallback(() => resume)
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: item.explanation || '请你再想想吧!',
}))
}
}
return (
<>
<DragQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
questionPrefix={item.prompt}
questionSuffix={item.promptSuffix}
blanks={item.blanks ?? []}
blankSeparators={item.blankSeparators}
options={item.options.map((option) => ({
value: option.value,
label: option.label,
}))}
value={dragQuizValues[item.id] || []}
error={dragQuizErrors[item.id]}
onChange={handleDragChange}
/>
<SuccessAlert
visible={activeSuccess === item.id}
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
setDragQuizValues((prev) => {
const next = { ...prev }
delete next[item.id]
return next
})
setDragQuizErrors((prev) => {
const next = { ...prev }
delete next[item.id]
return next
})
const callback = resumeCallback
setResumeCallback(null)
callback?.()
}}
/>
</>
)
},
}
}
if (item.type === 'single') {
const correctAnswer = item.correctAnswer as string
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => (
<>
<SingleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={item.title}
options={item.options.map((option) => ({
value: option.value,
label: option.label,
}))}
correctAnswer={correctAnswer}
onWrongSelect={(value: string) => {
const option = item.options.find((current) => current.value === value)
return option?.explanation || '再思考一下,选择正确答案。'
}}
onSubmit={(value: string, isCorrect: boolean) => {
if (isCorrect) {
const option = item.options.find((current) => current.value === value)
setActiveSuccess(item.id)
setSuccessContent(option?.explanation || item.successContent || '')
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
const callback = resumeCallback
setResumeCallback(null)
callback?.()
}}
/>
</>
),
}
}
const correctAnswer = item.correctAnswer as string[]
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => (
<>
<MultipleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={item.title}
options={item.options.map((option) => ({
value: option.value,
label: option.label,
}))}
correctAnswer={correctAnswer}
errorMessage={(selectedValues: string[]) => {
const selectedSet = new Set(selectedValues)
const wrongOption = item.options.find(
(option) => selectedSet.has(option.value) && option.explanation
)
return wrongOption?.explanation || '你的选择还不够全面哦~请你再想想吧!'
}}
onSubmit={(_values: string[], isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent(item.successContent || '')
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
const callback = resumeCallback
setResumeCallback(null)
callback?.()
}}
/>
</>
),
}
})
}, [activeSuccess, dragQuizErrors, dragQuizValues, resumeCallback, successContent])
if (!moduleName) return null
return (
<div
className="relative h-full w-full bg-black bg-cover bg-center bg-no-repeat"
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="rongdesheng-internalization-narration"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_32.mp3')}
onClose={() => {
setNarrationVisible(false)
}}
text="百年风云,实业兴邦;旧居留痕,精神永续。走过荣德生先生的旧居,我们读懂了一位民族实业家的赤诚与坚守,见证了“面粉大王”“纺织巨子”实业救国的初心与担当。历史的尘埃落定,但先生的精神从未远去。生活在新时代的青年,应该在先生精神的指引下,传承实业魂、续写报国志。"
/>
<GuideSteps
visible={preGuideVisible}
audioChannelId="rongdesheng-internalization-guide-pre"
onVisibleChange={(visible) => {
setPreGuideVisible(visible)
if (!visible) {
setShowVideo(true)
handleVideoCanPlay()
}
}}
showMask={false}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_33.mp3')}
>
小沪,跟着你走完荣德生旧居的学习之旅,我真的被这位民族实业家的一生打动了!
</GuideStepsItem>
<GuideStepsItem speaker="student">
从钱庄学徒到“面粉大王”“纺织巨子”,从抗战守节到坚守大陆留存工业火种,他用一生践行实业救国的初心,那在新时代,我们该如何理解和传承荣先生的这份实业精神呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_34.mp3')}
>
针对你的这个问题,今天我们有幸邀请到了上海社会科学院经济研究所博士后,经济史研究室研究人员徐晶老师。关于荣德生先生的实业精神与新时代的传承,我想徐老师会给我们带来深刻的解读!
</GuideStepsItem>
</GuideSteps>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath('video/rongdesheng/rongdesheng_neihua.mp4')}
className="h-full w-full"
anchors={anchors}
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
setPostGuideVisible(true)
}}
/>
)}
<GuideSteps
visible={postGuideVisible}
audioChannelId="rongdesheng-internalization-guide-post"
onVisibleChange={(visible) => {
setPostGuideVisible(visible)
if (!visible) {
setShowLetter(true)
}
}}
showMask={false}
>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_35.mp3')}
>
荣德生先生用一生诠释了“实业救国、经济报国”的初心——从钱庄学徒到实业巨子,他扎根民族大地,以面粉和棉纱筑牢国家经济根基;从战火守节到坚守大陆,他始终与国家同呼吸、共命运;
</GuideStepsItem>
<GuideStepsItem speaker="guide">
从创办学校到造桥铺路,他用责任与担当诠释了企业家的社会价值。你不妨把这份触动凝练成几句心里话,既总结学习的收获,也想想未来该如何践行这份实业精神,用自己的行动传承这份初心吧!
</GuideStepsItem>
</GuideSteps>
<LetterToRongdesheng
visible={showLetter}
onClose={() => {
setShowLetter(false)
}}
onAnimationEnd={() => {
setShowEnding(true)
}}
showVideo={true}
/>
<EndingOverlay
visible={showEnding}
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_36.mp3')}
text="恭喜你圆满完成荣德生旧居的学习之旅,请带着研学感悟,前往陈云纪念馆继续探访吧~"
animationSpeed={200}
onContinue={() => {
setShowEnding(false)
navigate('/menu')
}}
/>
</div>
)
}
export default RongdeshengInternalization
+283
View File
@@ -0,0 +1,283 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
StudyGuideModal,
SingleChoiceQuizPanel,
Title,
} from '@/components'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useRongdeshengStore, saveRongdeshengStore } from '@/store/rongdeshengStore'
const starName = 'rongdesheng'
const place = 'place1'
const RongdeshengPlace1 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useRongdeshengStore()
const persistedVisited = useMemo(
() => new Set(getVisitedExhibits(place) as (1 | 2)[]),
[getVisitedExhibits]
)
const [visited, setVisited] = useState<Set<1 | 2>>(() => new Set(persistedVisited))
const total = 2
const progressCount = visited.size
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
},
visited,
})
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 学习引导弹窗(开场)
const [showGuideModal, setShowGuideModal] = useState(true)
// 对话结束后的学习引导(说明框)
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false)
// 展品2对话结束后的题目
const [quizVisible, setQuizVisible] = useState(false)
// 分开展品1、展品2对话状态,避免 fragment 条件渲染导致 speaker 识别异常
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
const [guide2Visible, setGuide2Visible] = useState(false)
const [guide2Step, setGuide2Step] = useState(0)
if (!moduleName) return null
const exhibitSrcMap: Record<1 | 2, string> = {
1: 'rongdesheng/place1/场景1展品1',
2: 'rongdesheng/place1/场景1展品2',
}
const handleOpenExhibit = (id: 1 | 2) => {
setActiveExhibitId(id)
setQuizVisible(false)
if (id === 1) {
setGuide1Step(0)
setGuide1Visible(true)
setGuide2Visible(false)
return
}
setGuide2Step(0)
setGuide2Visible(true)
setGuide1Visible(false)
}
const markVisited = (id: 1 | 2) => {
setVisited((prev) => {
const next = new Set(prev)
next.add(id)
markExhibitVisited(place, id)
saveRongdeshengStore()
return next
})
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/${place}/场景1`),
}}
>
<Title
title="金融启蒙·资本助基"
subtitle={subtitle}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
{/* cursor 1 */}
<Cursor
className="absolute right-149 bottom-152"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* cursor 2 */}
<Cursor
className="absolute right-36 bottom-130"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place2')
saveRongdeshengStore()
navigate(`/${moduleName}/${starName}/place2`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onClose={() => {
setActiveExhibitId(null)
}}
/>
{/* 展品1对话(图1) */}
<GuideSteps
visible={guide1Visible}
onVisibleChange={(nextVisible) => {
setGuide1Visible(nextVisible)
if (!nextVisible) {
markVisited(1)
}
}}
currentStep={guide1Step}
onStepChange={setGuide1Step}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_03.mp3')}
>
小沪,这就是荣德生先生的照片吧,作为一个无锡人,当时是有什么机缘让他离开家乡,到上海闯荡的呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_04.mp3')}
>
如你所言,1875年,荣德生先生出生于无锡的商贾之家。凭借着殷实的家境,荣先生从小便由老先生教学,九岁的时候被送到私塾念书,毕业以后,就正式开始学习经商的道路。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
想要做生意,第一要走南闯北,第二要胆识过人。所以1889年,十几岁的荣先生离开无锡老家,来到上海通顺钱庄学做生意。当时的钱庄学徒要从打扫、记账、珠算等基础活干起,每天天不亮就起床,深夜才休息。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
荣先生格外刻苦,不仅熟练掌握了珠算、记账、汇兑等核心技能,还学会了察言观色、与人打交道的本事,也从繁重琐碎的钱业学习中逐渐培养起了稳健踏实的办事风格,为他后来创办自己的广生钱庄打下了坚实基础。
</GuideStepsItem>
</GuideSteps>
{/* 展品2对话(图2) */}
<GuideSteps
visible={guide2Visible}
onVisibleChange={(nextVisible) => {
setGuide2Visible(nextVisible)
if (!nextVisible) {
setQuizVisible(true)
}
}}
currentStep={guide2Step}
onStepChange={setGuide2Step}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_05.mp3')}
>
原来荣德生先生后来自己开了钱庄!广生钱庄在他的创业路上起到了什么作用?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_06.mp3')}
>
1896年,荣德生先生与兄长荣宗敬和父亲与人合伙在上海开设广生钱庄,后在无锡设立分庄,这是荣氏兄弟携手创业的起点。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
广生钱庄开展的“收茧款项”就是他们的创新之举——利用钱庄的汇兑优势,在上海与无锡之间开展蚕茧收购业务,把无锡的蚕茧运到上海销售,再将货款通过钱庄汇回,赚取了可观的利润。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
广生钱庄后来转为荣家独资经营,成为荣家资本原始积累的重要来源,就像一座桥梁,连接起他后来的实业梦想。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
不过可惜的是,因为关联商号卷入投机活动失败,广生钱庄最终歇业了,但这段金融从业经历让荣德生摸清了市场规律,积累了宝贵的管理经验,为之后创办面粉厂、纺织厂埋下了伏笔。
</GuideStepsItem>
</GuideSteps>
{/* 图3:对话结束后弹出的单选题 */}
<SingleChoiceQuizPanel
visible={quizVisible}
zIndex={10001}
title="【单选】荣氏兄弟携手创业的起点是创办了哪家钱庄?"
options={[
{
value: 'A',
label: 'A. 广生钱庄',
},
{
value: 'B',
label: 'B. 通顺钱庄',
},
]}
correctAnswer="A"
errorMessage="通顺钱庄是荣德生早年做学徒的钱庄,并非其创业起点。请你再想想吧!"
successAlertProps={{
title: '恭喜你,选择正确!',
content:
'1896年广生钱庄创办,荣宗敬任上海总庄经理,荣德生任无锡分庄经理,这是荣氏兄弟携手创业的起点',
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
// 交由成功弹窗继续按钮统一收口
}}
onSuccessContinue={() => {
setQuizVisible(false)
markVisited(2)
}}
/>
{/* 学习引导词(开场) */}
<StudyGuideModal
visible={showGuideModal}
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_02.mp3')}
onContinue={() => {
setShowGuideModal(false)
setShowAfterDialogGuide(true)
}}
ailgn="left"
>
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
{'\n'}
2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
{'\n'}
3.所有动态光点的展品全部学习后才能进入下一个场景学习。
</StudyGuideModal>
{/* 学习引导词(说明框) */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_01.mp3')}
>
{` 同学你好,欢迎来到“走进故居·对话”学习模块!
此刻你正伫立在荣德生旧居的石库门廊下,接下来请循着实业的足迹,一同探寻先生传奇人生里的4段壮阔征程:金融启蒙·资本筑基、实业拓疆·双业兴邦、山河危难·气节如钢、风雨坚守·初心不改。准备好共赴这场面粉与棉纱交织的民族工业史诗了吗?`}
</StudyGuideModal>
</div>
)
}
export default RongdeshengPlace1
+457
View File
@@ -0,0 +1,457 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
SingleChoiceQuizPanel,
Title,
Video,
} from '@/components'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { saveRongdeshengStore, useRongdeshengStore } from '@/store/rongdeshengStore'
const starName = 'rongdesheng'
const place = 'place2'
const RongdeshengPlace2 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useRongdeshengStore()
const persistedVisited = useMemo(
() => new Set(getVisitedExhibits(place) as (1 | 2 | 3 | 4 | 5)[]),
[getVisitedExhibits]
)
const [visited, setVisited] = useState<Set<1 | 2 | 3 | 4 | 5>>(() => new Set(persistedVisited))
const total = 5
const progressCount = visited.size
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
3: [2],
4: [3],
5: [4],
},
visited,
})
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | 5 | null>(null)
const [showVideo, setShowVideo] = useState(false)
const [videoForExhibitId, setVideoForExhibitId] = useState<1 | 2 | 3 | 4 | 5 | null>(null)
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
const [guide1AfterVideoVisible, setGuide1AfterVideoVisible] = useState(false)
const [guide1AfterVideoStep, setGuide1AfterVideoStep] = useState(0)
const [quiz1Visible, setQuiz1Visible] = useState(false)
const [guide3Visible, setGuide3Visible] = useState(false)
const [guide3Step, setGuide3Step] = useState(0)
const [quiz4Visible, setQuiz4Visible] = useState(false)
const [guide5BeforeVisible, setGuide5BeforeVisible] = useState(false)
const [guide5BeforeStep, setGuide5BeforeStep] = useState(0)
const [guide5AfterVisible, setGuide5AfterVisible] = useState(false)
const [guide5AfterStep, setGuide5AfterStep] = useState(0)
if (!moduleName) return null
const exhibitSrcMap: Record<1 | 2 | 3 | 4 | 5, string> = {
1: 'rongdesheng/place2/场景2展品1',
2: 'rongdesheng/place2/场景2展品2',
3: 'rongdesheng/place2/场景2展品3',
4: 'rongdesheng/place2/场景2展品4',
5: 'rongdesheng/place2/场景2展品5',
}
const handleOpenExhibit = (id: 1 | 2 | 3 | 4 | 5) => {
setActiveExhibitId(id)
if (id === 1) {
setGuide1Step(0)
setGuide1Visible(true)
}
if (id === 3) {
setGuide3Step(0)
setGuide3Visible(true)
}
if (id === 5) {
setGuide5BeforeStep(0)
setGuide5BeforeVisible(true)
}
}
const handleVideoEnded = () => {
const id = videoForExhibitId
setShowVideo(false)
setVideoForExhibitId(null)
if (id === 1) {
setGuide1AfterVideoStep(0)
setGuide1AfterVideoVisible(true)
}
if (id === 2) {
setVisited((prev) => {
const next = new Set(prev)
if (!next.has(2)) {
next.add(2)
markExhibitVisited(place, 2)
saveRongdeshengStore()
}
return next
})
}
if (id === 4) {
setQuiz4Visible(true)
}
if (id === 5) {
setGuide5AfterStep(0)
setGuide5AfterVisible(true)
}
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/${place}/场景2`),
}}
>
<Title
title="实业拓疆·双业兴邦"
subtitle={subtitle}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => navigate(`/${moduleName}/${starName}/place1`)}
/>
{/* cursor order: 1 -> 2 -> 3 -> 4 -> 5 */}
<Cursor
className="absolute left-132 bottom-90"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute left-172 bottom-95"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
<Cursor
className="absolute left-249 bottom-118"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
<Cursor
className="absolute left-250 bottom-70"
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
disabled={!isEnabled(4)}
/>
<Cursor
className="absolute right-125 bottom-115"
onClick={() => handleOpenExhibit(5)}
read={visited.has(5)}
disabled={!isEnabled(5)}
/>
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place3')
saveRongdeshengStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onClose={() => {
if (activeExhibitId === null) return
const closingId = activeExhibitId
setActiveExhibitId(null)
if (closingId === 1 || closingId === 2 || closingId === 4 || closingId === 5) {
setVideoForExhibitId(closingId)
setShowVideo(true)
return
}
// 其余展品先保持关闭即完成
setVisited((prev) => {
const next = new Set(prev)
if (!next.has(closingId)) {
next.add(closingId)
markExhibitVisited(place, closingId)
saveRongdeshengStore()
}
return next
})
}}
/>
{showVideo && videoForExhibitId === 1 && (
<Video
src={getPublicPath('video/rongdesheng/rongdesheng_3.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
)}
{showVideo && videoForExhibitId === 2 && (
<Video
src={getPublicPath('video/rongdesheng/rongdesheng_3.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
)}
{showVideo && videoForExhibitId === 4 && (
<Video
src={getPublicPath('video/rongdesheng/rongdesheng_3.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
)}
{showVideo && videoForExhibitId === 5 && (
<Video
src={getPublicPath('video/rongdesheng/rongdesheng_6.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
)}
<GuideSteps
visible={guide1Visible}
onVisibleChange={setGuide1Visible}
currentStep={guide1Step}
onStepChange={setGuide1Step}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_07.mp3')}
>
哇,这是福新面粉公司的老照片!荣德生先生为什么选择办面粉,面粉厂又为什么在上海创办?福新面粉厂后来发展得怎么样?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_08.mp3')}
>
办中国自己的面粉厂,这可以说是荣德生先生实业救国的一个初心。
</GuideStepsItem>
</GuideSteps>
<GuideSteps
visible={guide1AfterVideoVisible}
onVisibleChange={(v) => {
setGuide1AfterVideoVisible(v)
if (!v) {
setQuiz1Visible(true)
}
}}
currentStep={guide1AfterVideoStep}
onStepChange={setGuide1AfterVideoStep}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_09.mp3')}
>
目睹洋面粉倾销的荣先生与兄长一起在无锡创办了保兴面粉厂,1912年,荣氏兄弟又看准了上海的区位优势——交通便利、人口密集、市场需求大,于是在上海创办了福新面粉厂,引进了当时最先进的生产设备。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
之后他们趁热打铁,又在上海接连创办了福新二厂、三厂,还收买或租办了中兴面粉厂等,改为福新四厂、六厂,形成了规模化的面粉生产体系。短短几年间,福新面粉厂的产量就占据了上海面粉市场的半壁江山。
</GuideStepsItem>
</GuideSteps>
{/* 展品3:打开即对话 */}
<GuideSteps
visible={guide3Visible}
onVisibleChange={setGuide3Visible}
currentStep={guide3Step}
onStepChange={setGuide3Step}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_10.mp3')}
>
荣德生先生在面粉业取得成功后,又进军纺织业了吗?申新纺织厂是怎么创办起来的呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_11.mp3')}
>
是的!1915年,他与兄长在上海招股创办了申新纺织无限公司,并开办申新一厂。他们之所以选择纺织业,是因为当时中国的棉纱市场被外资垄断,老百姓的棉布多依赖进口。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
申新纺织公司的创办,就是要打破这种垄断局面,为国人生产品质优良的棉纱、棉布。
</GuideStepsItem>
</GuideSteps>
{/* 展品4:视频后题目 */}
<SingleChoiceQuizPanel
visible={quiz4Visible}
zIndex={10001}
title="【单选】荣氏企业的“兵船”牌面粉和“人钟牌”棉纱分别成为上海相关交易所的标准产品,这体现了:"
options={[
{ value: 'A', label: 'A. 产品质量过硬' },
{ value: 'B', label: 'B. 市场环境良好' },
{ value: 'C', label: 'C. 外资大力支持' },
]}
correctAnswer="A"
errorMessage="请结合当时中国的发展情况,再想想吧!"
successAlertProps={{
content: '“标准产品”意味着其质量、规格成为行业标杆,这是荣氏企业注重品质的直接体现。',
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
}}
onSuccessContinue={() => {
setQuiz4Visible(false)
setVisited((prev) => {
const next = new Set(prev)
if (!next.has(4)) {
next.add(4)
markExhibitVisited(place, 4)
saveRongdeshengStore()
}
return next
})
}}
/>
<SingleChoiceQuizPanel
visible={quiz1Visible}
zIndex={10001}
title="【单选】荣氏兄弟在上海创办的第一家面粉厂是:"
options={[
{ value: 'A', label: 'A. 福新面粉厂' },
{ value: 'B', label: 'B. 保兴面粉厂' },
]}
correctAnswer="A"
errorMessage="保兴面粉厂创办于无锡。请你再想想吧!"
successAlertProps={{
content: '1912年,荣氏兄弟看准上海的区位优势,在上海创办了福新面粉厂。',
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
}}
onSuccessContinue={() => {
setQuiz1Visible(false)
setVisited((prev) => {
const next = new Set(prev)
if (!next.has(1)) {
next.add(1)
markExhibitVisited(place, 1)
saveRongdeshengStore()
}
return next
})
}}
/>
{/* 展品5:打开即对话 + 视频后对话并标记完成 */}
<GuideSteps
visible={guide5BeforeVisible}
onVisibleChange={setGuide5BeforeVisible}
currentStep={guide5BeforeStep}
onStepChange={setGuide5BeforeStep}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_12.mp3')}
>
劳工自治区!荣德生先生的工厂能在市场上竞争过外资企业,除了优质产品,是不是还有特别的管理秘诀呀?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_13.mp3')}
>
你观察得特别仔细!荣氏企业能站稳脚跟,秘诀之一就是这套超前的“劳工自治区”管理模式。
</GuideStepsItem>
</GuideSteps>
<GuideSteps
visible={guide5AfterVisible}
onVisibleChange={(v) => {
setGuide5AfterVisible(v)
if (!v) {
setVisited((prev) => {
const next = new Set(prev)
if (!next.has(5)) {
next.add(5)
markExhibitVisited(place, 5)
saveRongdeshengStore()
}
return next
})
}
}}
currentStep={guide5AfterStep}
onStepChange={setGuide5AfterStep}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_14.mp3')}
>
在荣德生先生推行的以“劳资双方利益要兼顾”为出发点的劳工自治区管理模式下,工人在企业里得到尊重和关怀,干事的积极性自然高涨。他们不仅严格把控生产环节,还主动参与技术改进。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
再加上荣先生坚持每天清晨用前一天的面粉做疙瘩汤亲自检验品质的严谨态度,形成了“人文管理+品质把控”的双重保障。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这种立足创新、聚焦工人福利的全方位关照,让荣氏产品质量远超同行,成功抵御了洋货倾销,也让“兵船”牌面粉和“人钟牌”棉纱成为行业标准。
</GuideStepsItem>
</GuideSteps>
</div>
)
}
export default RongdeshengPlace2
+350
View File
@@ -0,0 +1,350 @@
import {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
SingleChoiceQuizPanel,
Title,
Video,
} from '@/components'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { saveRongdeshengStore, useRongdeshengStore } from '@/store/rongdeshengStore'
const starName = 'rongdesheng'
const place = 'place3'
const RongdeshengPlace3 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useRongdeshengStore()
const persistedVisited = useMemo(
() => new Set(getVisitedExhibits(place) as (1 | 2 | 3)[]),
[getVisitedExhibits]
)
const [visited, setVisited] = useState<Set<1 | 2 | 3>>(() => new Set(persistedVisited))
const total = 3
const progressCount = visited.size
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
// cursor:1 -> 2 -> 3
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
3: [2],
},
visited,
})
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
const [showVideo, setShowVideo] = useState(false)
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
const [guide2Visible, setGuide2Visible] = useState(false)
const [guide2Step, setGuide2Step] = useState(0)
const [guide3BeforeVisible, setGuide3BeforeVisible] = useState(false)
const [guide3BeforeStep, setGuide3BeforeStep] = useState(0)
const [guide3AfterVisible, setGuide3AfterVisible] = useState(false)
const [guide3AfterStep, setGuide3AfterStep] = useState(0)
const [quiz1Visible, setQuiz1Visible] = useState(false)
const [quiz3Visible, setQuiz3Visible] = useState(false)
if (!moduleName) return null
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: 'rongdesheng/place3/场景3展品1',
2: 'rongdesheng/place3/场景3展品2',
3: 'rongdesheng/place3/场景3展品3',
}
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id)
if (id === 1) {
setGuide1Step(0)
setGuide1Visible(true)
}
if (id === 2) {
setGuide2Step(0)
setGuide2Visible(true)
}
if (id === 3) {
setGuide3BeforeStep(0)
setGuide3BeforeVisible(true)
}
}
const handleVideoEnded = () => {
setShowVideo(false)
setGuide3AfterStep(0)
setGuide3AfterVisible(true)
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/${place}/场景3`),
}}
>
<Title
title="山河危难·气节如钢"
subtitle={subtitle}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => navigate(`/${moduleName}/${starName}/place2`)}
/>
<Cursor
className="absolute left-80 bottom-165"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute left-170 bottom-165"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
<Cursor
className="absolute right-198 bottom-155"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place4')
saveRongdeshengStore()
navigate(`/${moduleName}/${starName}/place4`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onClose={() => {
if (activeExhibitId === null) return
const closingId = activeExhibitId
setActiveExhibitId(null)
// 展品1、2:关闭展品后直接完成
if (closingId === 1 || closingId === 2) {
setVisited((prev) => {
const next = new Set(prev)
next.add(closingId)
markExhibitVisited(place, closingId)
saveRongdeshengStore()
return next
})
}
if (closingId === 1) {
setQuiz1Visible(true)
}
if (closingId === 3) {
setShowVideo(true)
}
}}
/>
{showVideo && (
<Video
src={getPublicPath('video/rongdesheng/rongdesheng_8.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
)}
<GuideSteps
visible={guide1Visible}
onVisibleChange={(v) => {
setGuide1Visible(v)
}}
currentStep={guide1Step}
onStepChange={setGuide1Step}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_15.mp3')}
>
上海申新一厂和八厂于1937年10月27日被日本飞机炸毁了,在战乱中荣氏又是如何发展工业的呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_16.mp3')}
>
1937年抗日战争全面爆发,日军的炮火轰炸了荣氏在上海、无锡的多家工厂,设备被毁,生产停滞。为了保住民族工业的火种,支援抗战,荣德生做出了艰难的决定——将工厂内迁。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
迁厂要从上海、无锡出发,穿越日军的封锁线,沿着崎岖的道路前往重庆、成都等地,路途遥远且危险重重。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
很多设备在运输过程中遭到损坏,不少工人也付出了生命的代价,但他们始终没有放弃,因为荣德生知道,后方的生产关乎前线的军需和民众的生计。
</GuideStepsItem>
</GuideSteps>
<GuideSteps
visible={guide2Visible}
onVisibleChange={setGuide2Visible}
currentStep={guide2Step}
onStepChange={setGuide2Step}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_17.mp3')}
>
内迁后的工厂在那么艰苦的条件下还能坚持生产吗?他们主要生产什么?为抗战做出了哪些贡献?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_18.mp3')}
>
是的!内迁后的重庆申新纱厂、茂新面粉厂等,在简陋的条件下迅速恢复生产。虽然产量不及战前,但从未中断。生产的面粉、棉纱,一部分供应给前线的抗日军队,制作军装、被褥、急救包;另一部分供应给后方民众,保障基本生活需求。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
这篇报道,就见证了荣氏企业用实业支援抗战的壮举,为抗日战争的物资供应提供了有力支持,成为民族救亡运动中不可或缺的力量。
</GuideStepsItem>
</GuideSteps>
<GuideSteps
visible={guide3BeforeVisible}
onVisibleChange={setGuide3BeforeVisible}
currentStep={guide3BeforeStep}
onStepChange={setGuide3BeforeStep}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_19.mp3')}
>
“心正思无邪 意诚言必中”,这是荣德生先生的题字吗?他为什么写这两句话?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_20.mp3')}
>
这发生于1941年太平洋战争爆发、上海公共租界沦陷之后。日军以经济提携为名,通过行政命令强迫中国企业交由日本公司或个人管理,企图将上海的民族工业纳入其战时经济体系。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
日商丰田纱厂多次上门威逼利诱,想收购上海申新一、八厂;汪伪政权的外交部长也出面邀谈,企图逼迫他妥协。但荣德生始终坚守民族底线,不合作、不妥协。
</GuideStepsItem>
</GuideSteps>
<GuideSteps
visible={guide3AfterVisible}
onVisibleChange={(v) => {
setGuide3AfterVisible(v)
if (!v) {
setQuiz3Visible(true)
}
}}
currentStep={guide3AfterStep}
onStepChange={setGuide3AfterStep}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_21.mp3')}
>
生死存亡之际,他用行动诠释了“民族气节”四个字。即便工厂被占据,也绝不与侵略者合作。
</GuideStepsItem>
</GuideSteps>
{/* 展品1:对话结束后的单选题(图2) */}
<SingleChoiceQuizPanel
visible={quiz1Visible}
zIndex={10001}
title="【单选】1937年抗日战争全面爆发后,荣德生为保住民族工业火种做出的重要决定是:"
options={[
{
value: 'A',
label: 'A. 将工厂内迁到重庆、成都等地,穿越封锁线内迁,为后方抗战保留工业生产能力',
},
{ value: 'B', label: 'B. 将工厂出售给华商以保全资产' },
]}
correctAnswer="A"
errorMessage="荣德生选择内迁工厂而非出售,目的是继续生产支援抗战,并非单纯保全资产。请你再想想吧!"
successAlertProps={{
content: '荣氏工厂穿越日军封锁线内迁,为后方抗战保留了珍贵的工业生产能力。',
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
}}
onSuccessContinue={() => {
setQuiz1Visible(false)
}}
/>
{/* 展品3:视频后对话结束的单选题 */}
<SingleChoiceQuizPanel
visible={quiz3Visible}
zIndex={10001}
title="【单选】面对威逼利诱与占领压力,荣德生坚持的原则最能体现为:"
options={[
{ value: 'A', label: 'A. 民族气节,不合作、不妥协' },
{ value: 'B', label: 'B. 权宜之计,暂时让渡控制权' },
]}
correctAnswer="A"
errorMessage="这句题字是荣德生在民族危亡之际,表达坚守民族底线的心声,并非单纯针对企业管理。请你再想想吧!"
successAlertProps={{
content:
'太平洋战争爆发后,荣德生以这句话彰显本心,坚决拒绝与日军、汪伪政权合作,守住了民族实业家的底线。',
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
}}
onSuccessContinue={() => {
setQuiz3Visible(false)
// 展品3:答题成功后才算完成,更新 cursor 状态
setVisited((prev) => {
const next = new Set(prev)
if (!next.has(3)) {
next.add(3)
markExhibitVisited(place, 3)
saveRongdeshengStore()
}
return next
})
}}
/>
</div>
)
}
export default RongdeshengPlace3
+329
View File
@@ -0,0 +1,329 @@
import {
EndingOverlay,
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
Video,
} from '@/components'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { saveRongdeshengStore, useRongdeshengStore } from '@/store/rongdeshengStore'
const starName = 'rongdesheng'
const place = 'place4'
const RongdeshengPlace4 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useRongdeshengStore()
const persistedVisited = useMemo(
() => new Set(getVisitedExhibits(place) as (1 | 2 | 3 | 4)[]),
[getVisitedExhibits]
)
const [visited, setVisited] = useState<Set<1 | 2 | 3 | 4>>(() => new Set(persistedVisited))
const total = 4
const progressCount = visited.size
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
// cursor:1 -> 2 -> 3 -> 4
const { isEnabled } = useCursorController({
initialEnabled: [1],
dependencies: {
2: [1],
3: [2],
4: [3],
},
visited,
})
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | null>(null)
const [showVideo, setShowVideo] = useState(false)
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
const [guide1AfterVideoVisible, setGuide1AfterVideoVisible] = useState(false)
const [guide1AfterVideoStep, setGuide1AfterVideoStep] = useState(0)
const [guide2Visible, setGuide2Visible] = useState(false)
const [guide2Step, setGuide2Step] = useState(0)
const [guide3Visible, setGuide3Visible] = useState(false)
const [guide3Step, setGuide3Step] = useState(0)
const [guide4Visible, setGuide4Visible] = useState(false)
const [guide4Step, setGuide4Step] = useState(0)
const [endingGuideVisible, setEndingGuideVisible] = useState(false)
if (!moduleName) return null
const exhibitSrcMap: Record<1 | 2 | 3 | 4, string> = {
1: 'rongdesheng/place4/场景4展品1',
2: 'rongdesheng/place4/场景4展品2',
3: 'rongdesheng/place4/场景4展品3',
4: 'rongdesheng/place4/场景4展品4',
}
const handleOpenExhibit = (id: 1 | 2 | 3 | 4) => {
setActiveExhibitId(id)
if (id === 1) {
setGuide1Step(0)
setGuide1Visible(true)
}
if (id === 2) {
setGuide2Step(0)
setGuide2Visible(true)
}
if (id === 3) {
setGuide3Step(0)
setGuide3Visible(true)
}
if (id === 4) {
setGuide4Step(0)
setGuide4Visible(true)
}
}
const handleVideoEnded = () => {
setShowVideo(false)
setGuide1AfterVideoStep(0)
setGuide1AfterVideoVisible(true)
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/${place}/场景4`),
}}
>
<Title
title="风雨坚守·初心不改"
subtitle={subtitle}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => navigate(`/${moduleName}/${starName}/place3`)}
/>
<Cursor
className="absolute left-73 bottom-145"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
<Cursor
className="absolute left-120 bottom-145"
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
<Cursor
className="absolute left-165 bottom-145"
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 展品 */}
<Cursor
className="absolute left-175 bottom-70"
onClick={() => handleOpenExhibit(4)}
read={visited.has(4)}
disabled={!isEnabled(4)}
/>
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => {
setLastVisitedPlace('place4')
saveRongdeshengStore()
navigate(`/${moduleName}/${starName}/home`)
}}
/>
)}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onClose={() => {
if (activeExhibitId === null) return
const closingId = activeExhibitId
setActiveExhibitId(null)
// 展品1在视频后对话结束再改状态;2/3/4关闭展品即改状态
if (closingId === 2 || closingId === 3 || closingId === 4) {
setVisited((prev) => {
const next = new Set(prev)
next.add(closingId)
markExhibitVisited(place, closingId)
saveRongdeshengStore()
return next
})
if (closingId === 4) {
setEndingGuideVisible(true)
}
}
if (closingId === 1) {
setShowVideo(true)
}
}}
/>
{showVideo && (
<Video
src={getPublicPath('video/rongdesheng/rongdesheng_10.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
)}
<GuideSteps
visible={guide1Visible}
onVisibleChange={setGuide1Visible}
currentStep={guide1Step}
onStepChange={setGuide1Step}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_22.mp3')}
>
荣德生先生竟然遭遇过绑架?这是怎么回事?他是怎么度过这次危机的?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_23.mp3')}
>
1946年,荣德生先生在上海高恩路(现高安路)住所门前遭到绑架,绑匪勒索巨额款项,将他囚禁了35天。这是当时的新闻报道。面对突如其来的变故,荣氏企业内部沉着应对,在保障人身安全的前提下,积极与绑匪谈判,最终筹集款项将他赎回。
</GuideStepsItem>
</GuideSteps>
<GuideSteps
visible={guide1AfterVideoVisible}
onVisibleChange={(v) => {
setGuide1AfterVideoVisible(v)
if (!v) {
setVisited((prev) => {
const next = new Set(prev)
if (!next.has(1)) {
next.add(1)
markExhibitVisited(place, 1)
saveRongdeshengStore()
}
return next
})
}
}}
currentStep={guide1AfterVideoStep}
onStepChange={setGuide1AfterVideoStep}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_24.mp3')}
>
这次绑架事件让荣先生身心受到巨大创伤,但也让他更加看清了当时社会的动荡不安,坚定了他期盼国家安定、民族工业能平稳发展的愿望。
</GuideStepsItem>
</GuideSteps>
<GuideSteps
visible={guide2Visible}
onVisibleChange={setGuide2Visible}
currentStep={guide2Step}
onStepChange={setGuide2Step}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_25.mp3')}
>
“荣氏工业机构,绝不迁赴国外”,当时荣德生先生为什么阻止企业迁往国外啊?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_26.mp3')}
>
1948年,解放战争接近尾声,很多实业家纷纷撤离大陆,前往台湾或海外。但荣德生先生专程从无锡来到上海,发表声明,明确表态绝不离开大陆。他在给海外亲友的信中写道:“民族工业的根基在大陆,离开这片土地,实业救国便成空谈。”
</GuideStepsItem>
<GuideStepsItem speaker="guide">
他不仅自己留下,还力排众议,阻止了荣氏企业迁台的计划,保住了大量的工厂设备、技术人才和生产资料。这份坚守,为新中国成立后的民族工业留存了重要力量,也体现了一位民族实业家的家国情怀。
</GuideStepsItem>
</GuideSteps>
<GuideSteps
visible={guide3Visible}
onVisibleChange={setGuide3Visible}
currentStep={guide3Step}
onStepChange={setGuide3Step}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_27.mp3')}
>
新中国成立后,荣氏的企业很快就复工了吗?政府对他的贡献给了肯定吗?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_28.mp3')}
>
是的!新中国成立后,在政府的支持下,荣氏企业迅速恢复生产。荣德生先生一生致力于实业救国,为民族工业的发展作出了巨大贡献,政府也对他给予了高度肯定。
</GuideStepsItem>
</GuideSteps>
<GuideSteps
visible={guide4Visible}
onVisibleChange={setGuide4Visible}
currentStep={guide4Step}
onStepChange={setGuide4Step}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_29.mp3')}
>
晚年的荣先生依然关注民族工业的发展,积极建言献策,他创办的企业后来成为新中国轻工业的重要骨干,为国家的经济建设贡献了力量。
</GuideStepsItem>
</GuideSteps>
<EndingOverlay
visible={endingGuideVisible}
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_30.mp3')}
text="接下来,请你跨越时空,走进1915年的申新纺织厂,参与工厂的创办与运营中,在模拟决策中感受民族工业在风雨中崛起的力量。让我们带着这份感动与启发,传承爱国情怀,续写新时代的实业篇章。"
animationSpeed={200}
onContinue={() => setEndingGuideVisible(false)}
/>
</div>
)
}
export default RongdeshengPlace4
@@ -0,0 +1,62 @@
import { Image, Video } from '@/components'
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
import { FINAL_VIDEO_SRC } from '../modules.data'
import { HINT_TEXTS } from '../constants'
import type { FinalVideoStage } from '../types'
interface FinalSceneProps {
visible: boolean
finalVideoStage: FinalVideoStage
onStart: () => void
onVideoEnded: () => void
onExit: () => void
}
export const FinalScene = ({
visible,
finalVideoStage,
onStart,
onVideoEnded,
onExit,
}: FinalSceneProps) => {
if (!visible) return null
return (
<>
<Image
className="absolute bottom-90 right-4 cursor-pointer"
src={getStaticImage('rongdesheng/reflection/合成坚守图谱')}
button
onClick={onStart}
/>
{finalVideoStage !== 'idle' && (
<div className="fixed inset-0 z-50 w-full h-full bg-black">
<Video src={FINAL_VIDEO_SRC} autoPlay onEnded={onVideoEnded} className="w-full h-full" />
{finalVideoStage === 'ended' && (
<div
className="absolute w-[844px] h-[188px] bottom-5 right-160 bg-cover bg-center bg-no-repeat cursor-pointer"
style={{ backgroundImage: getBackgroundImage('rongdesheng/reflection/提示版') }}
onClick={onExit}
>
<div className="absolute inset-0 flex flex-col items-center justify-center text-2xl font-normal p-4">
<div
className={cn(
'absolute left-1/2 -translate-x-1/2 -top-8 w-[270px] h-[62px] bg-cover bg-center bg-no-repeat',
'text-white text-3xl font-bold flex items-center justify-center tracking-widest'
)}
style={{ backgroundImage: getBackgroundImage('rongdesheng/reflection/抬头') }}
>
小提示
</div>
<div className="text-center text-[#5C5542] font-semibold">
{HINT_TEXTS.finalEnded}
</div>
</div>
</div>
)}
</div>
)}
</>
)
}
@@ -0,0 +1,49 @@
import type { ReactNode } from 'react'
import { useEffect } from 'react'
import { cn, getBackgroundImage } from '@/lib/utils'
import { useAudioManager } from '@/hooks/useAudioManager'
import { HINT_AUDIO_CHANNEL } from '../constants'
interface HintPanelProps {
visible: boolean
text: ReactNode
audioSrc?: string
}
export const HintPanel = ({ visible, text, audioSrc }: HintPanelProps) => {
const { play, stop, currentSrc } = useAudioManager({
mode: 'channel',
channelId: HINT_AUDIO_CHANNEL,
})
useEffect(() => {
if (visible && audioSrc) {
play(audioSrc)
return
}
// 不可见或无音频时,若当前正在播放的是上一条 hint 音频则停掉
if (currentSrc) stop()
}, [visible, audioSrc, play, stop, currentSrc])
if (!visible) return null
return (
<div
className="absolute w-[844px] h-[188px] bottom-5 right-160 bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: getBackgroundImage('rongdesheng/reflection/提示版') }}
>
<div className="absolute inset-0 flex flex-col items-center justify-center text-2xl font-normal p-4">
<div
className={cn(
'absolute left-1/2 -translate-x-1/2 -top-8 w-[270px] h-[62px] bg-cover bg-center bg-no-repeat',
'text-white text-3xl font-bold flex items-center justify-center tracking-widest'
)}
style={{ backgroundImage: getBackgroundImage('rongdesheng/reflection/抬头') }}
>
小提示
</div>
<div className="text-center">{text}</div>
</div>
</div>
)
}
@@ -0,0 +1,69 @@
import { cn, getBackgroundImage } from '@/lib/utils'
import { MODULES } from '../modules.data'
import type { ModuleId, ModuleState } from '../types'
interface ModuleNavigationProps {
currentModuleId: ModuleId
showFinalScene: boolean
persistedCompleted: Record<string, boolean>
modulesState: Record<ModuleId, ModuleState>
onSelect: (id: ModuleId) => void
}
const getVideoWatchedCount = (state: ModuleState | undefined): number => {
if (!state) return 0
return Object.values(state.videoViewed).filter(Boolean).length
}
export const ModuleNavigation = ({
currentModuleId,
showFinalScene,
persistedCompleted,
modulesState,
onSelect,
}: ModuleNavigationProps) => {
return (
<div className="absolute w-full top-5 left-16 flex gap-26">
{MODULES.map((module, index) => {
const prev = MODULES[index - 1]
const isActive = !showFinalScene && currentModuleId === module.id
const isUnlocked =
index === 0 ||
(!!prev && (!!persistedCompleted[prev.id] || modulesState[prev.id].completed)) ||
showFinalScene
const statusText = (() => {
if (!isUnlocked) return '未解锁'
const watched = getVideoWatchedCount(modulesState[module.id])
return watched >= 2 ? '已完成' : `${watched}/2`
})()
return (
<div
key={module.id}
className={cn(
'relative w-[225px] h-[139px] bg-cover bg-center bg-no-repeat transition-all duration-300',
isActive && 'scale-110',
!isActive && isUnlocked && 'opacity-80 hover:opacity-100 cursor-pointer',
!isUnlocked && 'opacity-40 cursor-not-allowed'
)}
style={{
backgroundImage: getBackgroundImage(
isActive ? 'rongdesheng/reflection/选中模块' : 'rongdesheng/reflection/未选中模块'
),
}}
onClick={() => {
if (!isUnlocked) return
onSelect(module.id)
}}
>
<div className="absolute top-4 -left-6 text-right text-white text-3xl font-bold">
<div>{module.title}</div>
<div>{statusText}</div>
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,121 @@
import { Image } from '@/components'
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
import type { Module, ModuleState } from '../types'
interface QuestionPanelProps {
module: Module
sceneTitle: string
currentState: ModuleState
allSelectedVideosViewed: boolean
onOptionSelect: (optionId: string) => void
onRetry: () => void
onSubmit: () => void
}
export const QuestionPanel = ({
module,
sceneTitle,
currentState,
allSelectedVideosViewed,
onOptionSelect,
onRetry,
onSubmit,
}: QuestionPanelProps) => {
return (
<>
<SceneHeader sceneTitle={sceneTitle} moduleTitle={module.title} />
<div className="text-2xl leading-12 mt-6 mb-10 font-semibold text-[#5C5542] indent-10">
{module.question}
</div>
<div className="space-y-3">
{module.options.map((option) => {
const isSelected = currentState.selectedOptions.includes(option.id)
return (
<div
key={option.id}
className={cn(
'cursor-pointer p-3 rounded-lg transition-all',
'h-[81px] bg-cover bg-center bg-no-repeat'
)}
style={{
backgroundImage: getBackgroundImage(
isSelected
? 'rongdesheng/reflection/选项底板-选中'
: 'rongdesheng/reflection/选项底板'
),
}}
onClick={() => onOptionSelect(option.id)}
>
<div className="flex justify-center items-center h-full relative">
<span className="text-[#6D6552] text-2xl">{option.text}</span>
<div className="absolute -left-8 -top-6">
{isSelected && option.isCorrect && (
<Image src={getStaticImage('rongdesheng/reflection/正确')} />
)}
{isSelected && !option.isCorrect && (
<Image src={getStaticImage('rongdesheng/reflection/错误')} />
)}
</div>
</div>
</div>
)
})}
</div>
{currentState.showRetryButton && (
<ActionButton label="重做" onClick={onRetry} tracking={false} />
)}
{currentState.hasAnswered && allSelectedVideosViewed && (
<ActionButton label="提交" onClick={onSubmit} />
)}
</>
)
}
interface SceneHeaderProps {
sceneTitle: string
moduleTitle: string
}
export const SceneHeader = ({ sceneTitle, moduleTitle }: SceneHeaderProps) => (
<div
className={cn(
'text-3xl font-bold text-center text-white tracking-widest',
'w-full h-[75px] bg-cover bg-center bg-no-repeat',
'flex items-center justify-center relative mb-4'
)}
style={{ backgroundImage: getBackgroundImage('rongdesheng/reflection/标题') }}
>
<div
className={cn(
'absolute -left-10 -top-8 w-[270px] h-[62px] bg-cover bg-center bg-no-repeat',
'text-white text-3xl font-bold flex items-center justify-center tracking-widest'
)}
style={{ backgroundImage: getBackgroundImage('rongdesheng/reflection/抬头') }}
>
{sceneTitle}
</div>
{moduleTitle}
</div>
)
interface ActionButtonProps {
label: string
onClick: () => void
tracking?: boolean
}
const ActionButton = ({ label, onClick, tracking = true }: ActionButtonProps) => (
<div
className="cursor-pointer mt-6 mx-auto w-[249px] h-[65px] bg-cover bg-center bg-no-repeat flex items-center justify-center"
style={{ backgroundImage: getBackgroundImage('rongdesheng/reflection/按钮-亮') }}
onClick={onClick}
>
<span className={cn('text-white text-2xl font-bold', tracking && 'tracking-widest')}>
{label}
</span>
</div>
)
@@ -0,0 +1,36 @@
import { getBackgroundImage } from '@/lib/utils'
import { SceneHeader } from './QuestionPanel'
import type { Module } from '../types'
interface ResultPanelProps {
module: Module
sceneTitle: string
onNext: () => void
}
export const ResultPanel = ({ module, sceneTitle, onNext }: ResultPanelProps) => {
return (
<>
<SceneHeader sceneTitle={sceneTitle} moduleTitle={module.title} />
<div className="flex flex-col items-center h-full">
<div
className="w-[500px] h-[370px] bg-contain bg-center bg-no-repeat mt-4"
style={{ backgroundImage: getBackgroundImage(module.resultImage) }}
/>
<div className="text-3xl leading-14 mt-4 mb-10 font-semibold text-[#8F7145] px-4 indent-10">
{module.resultContent}
</div>
<div
className="cursor-pointer mb-4 w-[249px] h-[65px] bg-cover bg-center bg-no-repeat flex items-center justify-center"
style={{ backgroundImage: getBackgroundImage('rongdesheng/reflection/按钮-亮') }}
onClick={onNext}
>
<span className="text-white text-2xl font-bold tracking-widest">下一场景</span>
</div>
</div>
</>
)
}
@@ -0,0 +1,91 @@
import { Image } from '@/components'
import { cn, getStaticImage } from '@/lib/utils'
import type { Module } from '../types'
interface VideoBubblesProps {
module: Module
selectedOptions: string[]
videoViewed: Record<string, boolean>
onWatch: (videoId: string, videoSrc: string) => void
}
export const VideoBubbles = ({
module,
selectedOptions,
videoViewed,
onWatch,
}: VideoBubblesProps) => {
return (
<>
{module.videoResources.map((video) => {
const shouldShow = module.options.some(
(option) =>
option.videoResourceId === video.id &&
option.isCorrect &&
selectedOptions.includes(option.id)
)
if (!shouldShow) return null
const isViewed = videoViewed[video.id] || false
return (
<div
key={video.id}
className={cn(
'absolute cursor-pointer transition-all duration-300',
isViewed ? 'opacity-100' : 'hover:scale-105 hover:brightness-115'
)}
style={{
top: video.position.top,
left: video.position.left,
right: video.position.right,
bottom: video.position.bottom,
transform: video.flipY ? 'rotateY(180deg)' : undefined,
}}
onClick={() => onWatch(video.id, video.videoSrc)}
>
<Image src={getStaticImage('chenyun/reflection/对话气泡')} />
{!isViewed && (
<div className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 flex items-center justify-center pb-2">
<div className="animate-dots flex gap-2">
<span className="w-3 h-3 bg-gray-500 rounded-full inline-block" />
<span className="w-3 h-3 bg-gray-500 rounded-full inline-block" />
<span className="w-3 h-3 bg-gray-500 rounded-full inline-block" />
</div>
</div>
)}
{isViewed && (
<>
<div
className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 flex items-center justify-center"
style={{ transform: 'rotateY(180deg)' }}
>
<div className="flex gap-2">
<span className="w-3 h-3 bg-gray-500 rounded-full inline-block" />
<span className="w-3 h-3 bg-gray-500 rounded-full inline-block" />
<span className="w-3 h-3 bg-gray-500 rounded-full inline-block" />
</div>
</div>
<div
className="absolute top-4 left-2"
style={{ transform: video.flipY ? 'rotateY(180deg)' : undefined }}
>
<span
className={cn([
'text-[#ae6400] text-3xl font-bold whitespace-nowrap',
'[text-shadow:_-4px_-4px_0_#ffffff,_4px_-4px_0_#ffffff,_-4px_4px_0_#ffffff,_4px_4px_0_#ffffff,_-5px_0_0_#ffffff,_5px_0_0_#ffffff,_0_-5px_0_#ffffff,_0_5px_0_#ffffff]',
])}
>
已浏览
</span>
</div>
</>
)}
</div>
)
})}
</>
)
}
@@ -0,0 +1,13 @@
export const HINT_TEXTS = {
answeredPending: '选择成功,在场景中点击对应气泡查看决策影响',
answeredAllViewed: '已了解所有决策影响,请点击提交查看结果',
partialCorrect: '还有合适的决策未选择,请继续补充。',
finalScene: '恭喜你成功闯过所有难关!点击合成按钮,解锁申新纺织厂从初创到自强的完整红色故事吧~',
finalEnded: '恭喜你完成推演!保存勋章,和同学分享申新纺织厂从初创到自强的红色实业故事吧~',
} as const
export type HintKey = keyof typeof HINT_TEXTS
export const HINT_AUDIOS: Partial<Record<HintKey, string>> = {}
export const HINT_AUDIO_CHANNEL = 'rongdesheng-reflection-hint'
+420
View File
@@ -0,0 +1,420 @@
import { Image, StudyGuideModal, Video } from '@/components'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useRongdeshengStore } from '@/store/rongdeshengStore'
import { FinalScene } from './components/FinalScene'
import { HintPanel } from './components/HintPanel'
import { ModuleNavigation } from './components/ModuleNavigation'
import { QuestionPanel } from './components/QuestionPanel'
import { ResultPanel } from './components/ResultPanel'
import { VideoBubbles } from './components/VideoBubbles'
import { HINT_AUDIOS, HINT_TEXTS } from './constants'
import { MODULES, getSceneTitle } from './modules.data'
import type { ModuleId, ModuleState, VideoPlayingState } from './types'
const RongdeshengReflection = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const starName = 'rongdesheng'
// 持久化 store
const persistedCompleted = useRongdeshengStore((s) => s.reflectionCompleted)
const persistedVideoViewed = useRongdeshengStore((s) => s.reflectionVideoViewed)
const persistedFinalCompleted = useRongdeshengStore((s) => s.reflectionFinalCompleted)
const persistedGuideShown = useRongdeshengStore((s) => s.reflectionGuideShown)
const markReflectionVideoViewed = useRongdeshengStore((s) => s.markReflectionVideoViewed)
const markReflectionCompleted = useRongdeshengStore((s) => s.markReflectionCompleted)
const markReflectionFinalCompleted = useRongdeshengStore((s) => s.markReflectionFinalCompleted)
const markReflectionGuideShown = useRongdeshengStore((s) => s.markReflectionGuideShown)
// 学习引导弹窗:第一次进入时显示
const [showGuide, setShowGuide] = useState(!persistedGuideShown)
// 当前选中的模块ID:默认选中第一个未完成的模块(断点续学)
const [currentModuleId, setCurrentModuleId] = useState<ModuleId>(() => {
const firstUncompleted = MODULES.find((m) => !persistedCompleted[m.id])
return (firstUncompleted?.id ?? MODULES[MODULES.length - 1]!.id) as ModuleId
})
// 是否进入最终场景(场景6 合成坚守图谱)
const [showFinalScene, setShowFinalScene] = useState(persistedFinalCompleted)
// 最终合成视频:'idle' 未播放 | 'playing' 播放中 | 'ended' 播放结束停在最后一帧
const [finalVideoStage, setFinalVideoStage] = useState<'idle' | 'playing' | 'ended'>('idle')
// 视频播放状态
const [videoPlayingState, setVideoPlayingState] = useState<VideoPlayingState>({
isPlaying: false,
currentVideoId: null,
currentVideoSrc: null,
})
// 所有模块的状态
const [modulesState, setModulesState] = useState<Record<ModuleId, ModuleState>>(() => {
const initial = {} as Record<ModuleId, ModuleState>
MODULES.forEach((module) => {
initial[module.id] = {
selectedOptions: [],
videoViewed: { ...(persistedVideoViewed[module.id] || {}) },
completed: !!persistedCompleted[module.id],
hasAnswered: false,
hasSelectedWrong: false,
showRetryButton: false,
}
})
return initial
})
const currentModule = MODULES.find((m) => m.id === currentModuleId)!
const currentState = modulesState[currentModuleId]
// 答对后,当前选中的正确选项所关联的视频是否都已观看(用于控制是否允许提交)
const allSelectedVideosViewed = currentState.selectedOptions.every((optionId) => {
const option = currentModule.options.find((o) => o.id === optionId)
if (!option?.videoResourceId) return true
return !!currentState.videoViewed[option.videoResourceId]
})
if (!moduleName) return null
// 处理模块切换:已完成模块支持回看再学习(重置交互状态,保留 videoViewed)
const handleModuleSelect = (moduleId: ModuleId) => {
if (showFinalScene) setShowFinalScene(false)
setCurrentModuleId(moduleId)
setModulesState((prev) => {
const target = prev[moduleId]
if (!target.completed) return prev
return {
...prev,
[moduleId]: {
...target,
selectedOptions: [],
completed: false,
hasAnswered: false,
hasSelectedWrong: false,
showRetryButton: false,
},
}
})
}
// 处理选项选择
const handleOptionSelect = (optionId: string) => {
const state = currentState
if (state.completed || state.hasAnswered) return
const option = currentModule.options.find((opt) => opt.id === optionId)
if (!option) return
// 已选中则取消
if (state.selectedOptions.includes(optionId)) {
setModulesState((prev) => ({
...prev,
[currentModuleId]: {
...prev[currentModuleId],
selectedOptions: prev[currentModuleId].selectedOptions.filter((id) => id !== optionId),
hasSelectedWrong: false,
showRetryButton: false,
},
}))
return
}
if (state.selectedOptions.length >= 2) return
const newSelectedOptions = [...state.selectedOptions, optionId]
setModulesState((prev) => ({
...prev,
[currentModuleId]: { ...prev[currentModuleId], selectedOptions: newSelectedOptions },
}))
// 选择了错误选项
if (!option.isCorrect) {
setModulesState((prev) => ({
...prev,
[currentModuleId]: {
...prev[currentModuleId],
hasSelectedWrong: true,
showRetryButton: true,
},
}))
return
}
// 选满两个选项后,判断是否全对
if (newSelectedOptions.length === 2) {
const [o1, o2] = newSelectedOptions.map((id) =>
currentModule.options.find((opt) => opt.id === id)
)
const allCorrect = !!o1?.isCorrect && !!o2?.isCorrect
setModulesState((prev) => ({
...prev,
[currentModuleId]: {
...prev[currentModuleId],
hasAnswered: allCorrect,
hasSelectedWrong: !allCorrect,
showRetryButton: !allCorrect,
},
}))
} else {
// 只选了一个正确选项,等继续补充
setModulesState((prev) => ({
...prev,
[currentModuleId]: {
...prev[currentModuleId],
hasAnswered: false,
hasSelectedWrong: false,
showRetryButton: false,
},
}))
}
}
// 重做:只清空答错的选项,保留已答对的
const handleRetry = () => {
setModulesState((prev) => {
const keptCorrectOptions = prev[currentModuleId].selectedOptions.filter((optionId) => {
const option = currentModule.options.find((opt) => opt.id === optionId)
return option?.isCorrect
})
return {
...prev,
[currentModuleId]: {
...prev[currentModuleId],
selectedOptions: keptCorrectOptions,
hasAnswered: false,
hasSelectedWrong: false,
showRetryButton: false,
},
}
})
}
// 开始播放气泡视频
const handleVideoWatch = (videoId: string, videoSrc: string) => {
setVideoPlayingState({ isPlaying: true, currentVideoId: videoId, currentVideoSrc: videoSrc })
}
// 气泡视频播放结束 -> 标记已观看
const handleVideoEnded = () => {
const videoId = videoPlayingState.currentVideoId
if (!videoId) return
setModulesState((prev) => ({
...prev,
[currentModuleId]: {
...prev[currentModuleId],
videoViewed: { ...prev[currentModuleId].videoViewed, [videoId]: true },
},
}))
markReflectionVideoViewed(currentModuleId, videoId)
setVideoPlayingState({ isPlaying: false, currentVideoId: null, currentVideoSrc: null })
}
// 提交答题 -> 进入当前场景结算页
const handleSubmit = () => {
setModulesState((prev) => ({
...prev,
[currentModuleId]: { ...prev[currentModuleId], completed: true },
}))
markReflectionCompleted(currentModuleId)
}
// 结算页"下一场景":切到下一场景或进入最终合成场景
const handleNextScene = () => {
const currentIndex = MODULES.findIndex((m) => m.id === currentModuleId)
const nextModule = MODULES[currentIndex + 1]
if (nextModule) {
setModulesState((prev) => ({
...prev,
[currentModuleId]: {
...prev[currentModuleId],
selectedOptions: [],
hasAnswered: false,
hasSelectedWrong: false,
showRetryButton: false,
},
}))
setCurrentModuleId(nextModule.id)
} else {
setShowFinalScene(true)
}
}
// 计算小提示板显示内容与音频
const hint = resolveHint({
showFinalScene,
currentState,
currentModule,
allSelectedVideosViewed,
})
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(
showFinalScene ? 'rongdesheng/reflection/场景6' : currentModule.backgroundImage
),
}}
>
{/* 返回按钮 */}
<Image
className="absolute right-5 top-5 z-10"
src={getStaticImage('components/返回')}
button
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
{/* 视频气泡(仅非最终场景展示) */}
{!showFinalScene && (
<VideoBubbles
module={currentModule}
selectedOptions={currentState.selectedOptions}
videoViewed={currentState.videoViewed}
onWatch={handleVideoWatch}
/>
)}
{/* 气泡视频播放器 */}
{videoPlayingState.isPlaying && videoPlayingState.currentVideoSrc && (
<Video
src={videoPlayingState.currentVideoSrc}
autoPlay
showSkipButton
onEnded={handleVideoEnded}
className="fixed inset-0 z-50 w-full h-full bg-black"
/>
)}
{/* 顶部模块目录 */}
<ModuleNavigation
currentModuleId={currentModuleId}
showFinalScene={showFinalScene}
persistedCompleted={persistedCompleted}
modulesState={modulesState}
onSelect={handleModuleSelect}
/>
{/* 底部小提示板(含音频) */}
<HintPanel visible={hint.visible} text={hint.text} audioSrc={hint.audioSrc} />
{/* 右侧题目或结果面板(最终场景时隐藏) */}
<div
className={
'absolute right-11 -translate-x-1/2 bottom-2 w-96' + (showFinalScene ? ' hidden' : '')
}
>
<div
className="relative w-[621px] h-[931px] bg-cover bg-center bg-no-repeat py-14 px-8 flex flex-col"
style={{ backgroundImage: getBackgroundImage('rongdesheng/reflection/交互底板') }}
>
{!currentState.completed ? (
<QuestionPanel
module={currentModule}
sceneTitle={getSceneTitle(currentModuleId)}
currentState={currentState}
allSelectedVideosViewed={allSelectedVideosViewed}
onOptionSelect={handleOptionSelect}
onRetry={handleRetry}
onSubmit={handleSubmit}
/>
) : (
<ResultPanel
module={currentModule}
sceneTitle={getSceneTitle(currentModuleId)}
onNext={handleNextScene}
/>
)}
</div>
</div>
{/* 最终合成场景 */}
<FinalScene
visible={showFinalScene}
finalVideoStage={finalVideoStage}
onStart={() => setFinalVideoStage('playing')}
onVideoEnded={() => {
setFinalVideoStage('ended')
markReflectionFinalCompleted()
}}
onExit={() => navigate(`/${moduleName}/${starName}/home`)}
/>
{/* 学习引导弹窗:首次进入时显示 */}
<StudyGuideModal
visible={showGuide}
onContinue={() => {
setShowGuide(false)
markReflectionGuideShown()
}}
firstLineIndent="2em"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_31.mp3')}
>
同学你好,欢迎来到“跨越时空·体悟”学习模块!{'\n'}
请登陆“时空回溯・亲历时代”体验仓,下一站:1915年,上海。{'\n'}
欢迎来到申新纺织厂!荣氏兄弟创办的申新纺织厂,从无到有、历经磨难,却始终坚守爱国初心。
接下来请你化身决策助手,和他们一起闯过重重难关,守护民族实业的未来!{'\n'}
温馨提示:每个难关的突破都需要叠加多重因素助力哦!
</StudyGuideModal>
</div>
)
}
// ---- helpers ----
interface ResolveHintArgs {
showFinalScene: boolean
currentState: ModuleState
currentModule: (typeof MODULES)[number]
allSelectedVideosViewed: boolean
}
interface HintResult {
visible: boolean
text: string
audioSrc?: string
}
function resolveHint({
showFinalScene,
currentState,
currentModule,
allSelectedVideosViewed,
}: ResolveHintArgs): HintResult {
if (showFinalScene) {
return { visible: true, text: HINT_TEXTS.finalScene, audioSrc: HINT_AUDIOS.finalScene }
}
if (currentState.hasSelectedWrong) {
return {
visible: true,
text: currentModule.wrongHint,
audioSrc: currentModule.audio?.wrongHint,
}
}
if (currentState.hasAnswered) {
return allSelectedVideosViewed
? {
visible: true,
text: HINT_TEXTS.answeredAllViewed,
audioSrc: HINT_AUDIOS.answeredAllViewed,
}
: {
visible: true,
text: HINT_TEXTS.answeredPending,
audioSrc: HINT_AUDIOS.answeredPending,
}
}
const selectedCorrectCount = currentState.selectedOptions.filter((optionId) => {
const option = currentModule.options.find((opt) => opt.id === optionId)
return option?.isCorrect
}).length
if (selectedCorrectCount === 1) {
return { visible: true, text: HINT_TEXTS.partialCorrect, audioSrc: HINT_AUDIOS.partialCorrect }
}
return { visible: false, text: '' }
}
export default RongdeshengReflection
@@ -0,0 +1,219 @@
import type { Module } from './types'
export const MODULES: Module[] = [
{
id: 'scene1',
title: '建厂拓业',
backgroundImage: 'rongdesheng/reflection/场景1选中',
question:
'1915年,荣氏兄弟计划在上海创办申新纱厂,却面临三大难题:租界建厂受列强限制、资金短缺、原料运输不便。上海作为远东商贸中心,机遇与挑战并存,如何在这里建起属于中国人的纱厂?',
options: [
{ id: 'opt1-1', text: '创办申新纱厂', isCorrect: true, videoResourceId: 'video1-1' },
{ id: 'opt1-2', text: '引进国外先进设备', isCorrect: true, videoResourceId: 'video1-2' },
{ id: 'opt1-3', text: '建立工会组织', isCorrect: false },
],
videoResources: [
{
id: 'video1-1',
thumbnail: 'rongdesheng/reflection/视频1-1',
videoSrc: '/video/rongdesheng/rongdesheng_2_1.mp4',
position: { top: '16%', left: '8%' },
flipY: false,
},
{
id: 'video1-2',
thumbnail: 'rongdesheng/reflection/视频1-2',
videoSrc: '/video/rongdesheng/rongdesheng_2_2.mp4',
position: { top: '25%', left: '29%' },
flipY: true,
},
],
resultContent:
'择址临港、凝聚华商力量,申新在上海扎根生长,成为民族工业冲破列强封锁的一面旗帜!',
resultImage: 'rongdesheng/reflection/场景1结算',
wrongHint:
'荣氏兄弟坚守"实业救国"初心,拒绝洋行干涉企业经营,不愿民族产业沦为外资附庸!试试其他方案吧~',
audio: { wrongHint: '' },
},
{
id: 'scene2',
title: '护厂抗争',
backgroundImage: 'rongdesheng/reflection/场景2选中',
question:
'1934年,汇丰银行以申新七厂借款到期为由,欲低价拍卖给日商。申新发展遭遇重创,该如何守护来之不易的民族产业?',
options: [
{ id: 'opt2-1', text: 'A.妥协退让,出售部分厂房抵债', isCorrect: false },
{
id: 'opt2-2',
text: 'B.联合全厂职工护厂,抵制拍卖',
isCorrect: true,
videoResourceId: 'video2-1',
},
{
id: 'opt2-3',
text: 'C.联络华商群体与总工会,寻求社会声援',
isCorrect: true,
videoResourceId: 'video2-2',
},
],
videoResources: [
{
id: 'video2-1',
thumbnail: 'rongdesheng/reflection/视频2-1',
videoSrc: '/video/rongdesheng/rongdesheng_2_3.mp4',
position: { top: '14%', left: '11%' },
},
{
id: 'video2-2',
thumbnail: 'rongdesheng/reflection/视频2-2',
videoSrc: '/video/rongdesheng/rongdesheng_2_4.mp4',
position: { top: '21%', left: '22.2%' },
flipY: true,
},
],
resultContent:
'护厂抗争的胜利,是民族工业坚守爱国初心的体现,也是国人联合“反抗压迫、团结抗争”的印证!',
resultImage: 'rongdesheng/reflection/场景2结算',
wrongHint:
'荣氏兄弟坚守"实业救国"初心,拒绝洋行干涉企业经营,不愿民族产业沦为外资附庸!试试其他方案吧~',
audio: { wrongHint: '' },
},
{
id: 'scene3',
title: '民生坚守',
backgroundImage: 'rongdesheng/reflection/场景3选中',
question:
'时局动荡,棉花原料涨价,工厂盈利下滑,职工生活面临困难。荣德生面对既要维持生产,又要保障职工生活,该怎么做?',
options: [
{ id: 'opt3-1', text: 'A.削减职工工资,降低运营成本', isCorrect: false },
{
id: 'opt3-2',
text: 'B.开设职工福利社,提供平价粮油',
isCorrect: true,
videoResourceId: 'video3-1',
},
{
id: 'opt3-3',
text: 'C.坚持生产平价棉布,保障市场民生供应',
isCorrect: true,
videoResourceId: 'video3-2',
},
],
videoResources: [
{
id: 'video3-1',
thumbnail: 'rongdesheng/reflection/视频3-1',
videoSrc: '/video/rongdesheng/rongdesheng_2_5.mp4',
position: { top: '15%', left: '44%' },
},
{
id: 'video3-2',
thumbnail: 'rongdesheng/reflection/视频3-2',
videoSrc: '/video/rongdesheng/rongdesheng_2_6.mp4',
position: { bottom: '50%', left: '55%' },
flipY: true,
},
],
resultContent: '在艰难时局中坚守民生初心,让民族工业成为稳定社会的重要力量!',
resultImage: 'rongdesheng/reflection/场景3结算',
wrongHint: '荣德生始终重视职工权益,认为“职工是工厂的根基”,不会削减工资哦!换个方案试试~',
audio: { wrongHint: '' },
},
{
id: 'scene4',
title: '抗战支援',
backgroundImage: 'rongdesheng/reflection/场景4选中',
question:
'抗战爆发后,棉花原料运输受阻,前线急需大量棉布、军毯支援,如何在保生产的同时,践行民族企业的爱国担当?',
options: [
{ id: 'opt4-1', text: 'A.停工避战,将设备转移至安全地区', isCorrect: false },
{
id: 'opt4-2',
text: 'B.寻找江南本地棉农,保障原料供应',
isCorrect: true,
videoResourceId: 'video4-1',
},
{
id: 'opt4-3',
text: 'C.调整生产结构,优先生产棉布、军毯支援前线',
isCorrect: true,
videoResourceId: 'video4-2',
},
],
videoResources: [
{
id: 'video4-1',
thumbnail: 'rongdesheng/reflection/视频4-1',
videoSrc: '/video/rongdesheng/rongdesheng_2_7.mp4',
position: { top: '42%', left: '41%' },
flipY: true,
},
{
id: 'video4-2',
thumbnail: 'rongdesheng/reflection/视频4-2',
videoSrc: '/video/rongdesheng/rongdesheng_2_8.mp4',
position: { bottom: '26%', right: '44%' },
},
],
resultContent: '战乱中坚守生产、支援前线,申新用实际行动诠释了民族工业的红色担当!',
resultImage: 'rongdesheng/reflection/场景4结算',
wrongHint: '荣氏兄弟秉持爱国担当,在抗战期间坚持生产支援前线,不会轻易停工!试试其他方案吧~',
audio: { wrongHint: '' },
},
{
id: 'scene5',
title: '革新突围',
backgroundImage: 'rongdesheng/reflection/场景5选中',
question:
'抗战胜利后,洋纱、洋布卷土重来,申新设备陈旧、技术落后,面临产品竞争力不足的困境,荣德生如何通过技术革新让民族纱厂站稳脚跟?',
options: [
{
id: 'opt5-1',
text: 'A.仿制外国先进设备,改良生产工艺',
isCorrect: false,
videoResourceId: 'video5-1',
},
{
id: 'opt5-2',
text: 'B.设立职员养成所,培养本土技术人才',
isCorrect: true,
videoResourceId: 'video5-2',
},
{ id: 'opt5-3', text: 'C.降价促销,以低价抢占市场', isCorrect: true },
],
videoResources: [
{
id: 'video5-1',
thumbnail: 'rongdesheng/reflection/视频5-1',
videoSrc: '/video/rongdesheng/rongdesheng_2_9.mp4',
position: { top: '25%', left: '42%' },
},
{
id: 'video5-2',
thumbnail: 'rongdesheng/reflection/视频5-2',
videoSrc: '/video/rongdesheng/rongdesheng_2_10.mp4',
position: { bottom: '50%', left: '55%' },
flipY: true,
},
],
resultContent:
'自主革新、培育人才,申新以技术突破打破洋货垄断,体现了红色实业“自立自强”的奋斗精神!',
resultImage: 'rongdesheng/reflection/场景5结算',
wrongHint:
'单纯降价会挤压利润空间,无法从根本上解决技术短板,荣氏兄弟更注重以技术提质增效!换个方案试试~',
audio: { wrongHint: '' },
},
]
export const FINAL_VIDEO_SRC = '/video/rongdesheng/rongdesheng_2_11.mp4'
const SCENE_NUMBER_MAP: Record<Module['id'], string> = {
scene1: '一',
scene2: '二',
scene3: '三',
scene4: '四',
scene5: '五',
}
export const getSceneTitle = (moduleId: Module['id']): string =>
`推演场景${SCENE_NUMBER_MAP[moduleId] || ''}`
+53
View File
@@ -0,0 +1,53 @@
export type ModuleId = 'scene1' | 'scene2' | 'scene3' | 'scene4' | 'scene5'
export interface VideoResource {
id: string
thumbnail: string
videoSrc: string
position: {
top?: string
left?: string
right?: string
bottom?: string
}
flipY?: boolean
}
export interface ModuleOption {
id: string
text: string
isCorrect: boolean
videoResourceId?: string
}
export interface Module {
id: ModuleId
title: string
backgroundImage: string
question: string
options: ModuleOption[]
videoResources: VideoResource[]
resultContent: string
resultImage: string
wrongHint: string
audio?: {
wrongHint?: string
}
}
export interface ModuleState {
selectedOptions: string[]
videoViewed: Record<string, boolean>
completed: boolean
hasAnswered: boolean
hasSelectedWrong: boolean
showRetryButton: boolean
}
export interface VideoPlayingState {
isPlaying: boolean
currentVideoId: string | null
currentVideoSrc: string | null
}
export type FinalVideoStage = 'idle' | 'playing' | 'ended'