feat:修改英文双引号
This commit is contained in:
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'
|
||||
@@ -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] || ''}`
|
||||
@@ -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'
|
||||
Reference in new issue
Block a user