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

@@ -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'