Commit non-media application and asset changes while leaving audio, video, and environment files out of version control. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
508 lines
19 KiB
TypeScript
508 lines
19 KiB
TypeScript
import { useMemo, useState, useRef, useEffect } from 'react'
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|
import {
|
|
Image,
|
|
Video,
|
|
type VideoAnchor,
|
|
type VideoRef,
|
|
type DragFillResult,
|
|
LetterToChenyun,
|
|
EndingOverlay,
|
|
Narration,
|
|
} from '@/components'
|
|
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
|
import { SuccessAlert, DragQuizPanel } from '@/components/layout'
|
|
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
|
import { GuideSteps, GuideStepsItem } from '@/components'
|
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
|
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
type QuizOption = {
|
|
value: string
|
|
label: string
|
|
isCorrect?: boolean
|
|
explanation?: string // 错误选项的解释
|
|
}
|
|
|
|
type QuizAnchor = {
|
|
id: string
|
|
time: number
|
|
title: string
|
|
prompt: string
|
|
type: 'single' | 'multiple' | 'drag'
|
|
options: QuizOption[]
|
|
// 拖拽题专用
|
|
blanks?: { id: string; label: string }[]
|
|
explanation?: string
|
|
}
|
|
|
|
// 星名固定为 chenyun
|
|
const starName = 'chenyun'
|
|
|
|
// 通用提示文案
|
|
const MULTIPLE_DEFAULT_ERROR = '你的选择还不够全面哦~请你再想想吧!'
|
|
|
|
// 锚点数据:可根据实际视频时长调整 time
|
|
const QUIZ_ANCHORS: QuizAnchor[] = [
|
|
{
|
|
id: 'quiz-1',
|
|
time: 60 * 5 + 53, //5.52分钟
|
|
title: '题目1',
|
|
prompt: '陈云经济思想的核心内容包括:',
|
|
type: 'multiple',
|
|
options: [
|
|
{ value: 'A', label: '综合平衡', isCorrect: true },
|
|
{
|
|
value: 'B',
|
|
label: '鸟笼比喻',
|
|
},
|
|
{ value: 'C', label: '正确地处理好计划与市场的关系', isCorrect: true },
|
|
{ value: 'D', label: '经济建设的目的论', isCorrect: true },
|
|
],
|
|
explanation: '鸟笼比喻是陈云提出的一个比喻。请你再想想吧!',
|
|
},
|
|
{
|
|
id: 'quiz-2',
|
|
time: 60 * 10 + 2, //10.01分钟
|
|
title: '题目2',
|
|
prompt: '陈云说过以下哪些话能着重体现出他的经济思想?',
|
|
type: 'multiple',
|
|
options: [
|
|
{
|
|
value: 'A',
|
|
label: '不唯上、不唯书、只唯实,交换、比较、反复',
|
|
isCorrect: true,
|
|
},
|
|
{ value: 'B', label: '无农不稳,无粮则乱', isCorrect: true },
|
|
{ value: 'C', label: '个人名利淡如水,党的事业重如山', isCorrect: true },
|
|
{
|
|
value: 'D',
|
|
label: '领导机关制定政策,要用百分之九十以上的时间作调查研究工作',
|
|
isCorrect: true,
|
|
},
|
|
],
|
|
explanation: '你的选择还不够全面哦~请你再想想吧!',
|
|
},
|
|
{
|
|
id: 'quiz-3',
|
|
time: 16 * 60 + 31, //16:26
|
|
title: '题目3',
|
|
prompt: '新时代的青年可以从陈云身上学习到',
|
|
type: 'drag',
|
|
options: [
|
|
{ value: '积极的人生态度', label: 'A.积极的人生态度', isCorrect: true },
|
|
{ value: '刻苦的学习精神', label: 'B.刻苦的学习精神', isCorrect: true },
|
|
{ value: '朴素的节俭精神', label: 'C.朴素的节俭精神', isCorrect: true },
|
|
{ value: '只唯上的工作作风', label: 'D.只唯上的工作作风' },
|
|
],
|
|
blanks: [
|
|
{ id: 'blank-1', label: '____' },
|
|
{ id: 'blank-2', label: '____' },
|
|
{ id: 'blank-3', label: '____' },
|
|
],
|
|
},
|
|
{
|
|
id: 'quiz-4',
|
|
time: 60 * 5 + 21, // 5:21
|
|
title: '题目4',
|
|
prompt: '陈云讲:“经济建设最终目的,是为了改善____的生活水平。”',
|
|
type: 'single',
|
|
options: [
|
|
{
|
|
value: 'A',
|
|
label: '为官者',
|
|
explanation: '这不是陈云提倡的经济建设的最终目的。请你再想想吧!',
|
|
},
|
|
{ value: 'B', label: '老百姓', isCorrect: true },
|
|
],
|
|
},
|
|
{
|
|
id: 'quiz-5',
|
|
time: 60 * 16 + 23, // 16:23
|
|
title: '题目5',
|
|
prompt: '以下哪些陈云的生活小事值得我们学习?',
|
|
type: 'multiple',
|
|
options: [
|
|
{ value: 'A', label: '光盘行动,不浪费粮食', isCorrect: true },
|
|
{ value: 'B', label: '打了 32 个补丁的棉坎肩他穿了 38 年', isCorrect: true },
|
|
{ value: 'C', label: '2-3 厘米长的铅笔头仍然留用', isCorrect: true },
|
|
{ value: 'D', label: '搓肥皂时关闭水龙头,节约用水', isCorrect: true },
|
|
],
|
|
explanation: MULTIPLE_DEFAULT_ERROR,
|
|
},
|
|
]
|
|
|
|
const ChenyunInternalization = () => {
|
|
const navigate = useNavigate()
|
|
const { moduleName } = useParams<{ moduleName: string }>()
|
|
// 获取碎片动画触发方法
|
|
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
|
const [activeSuccess, setActiveSuccess] = useState<string | null>(null)
|
|
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
|
|
// 拖拽题状态
|
|
const [dragQuizValues, setDragQuizValues] = useState<Record<string, DragFillResult[]>>({})
|
|
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>({})
|
|
const [narrationVisible, setNarrationVisible] = useState(false)
|
|
const [visible, setVisible] = useState(false)
|
|
const [showVideo, setShowVideo] = useState(false)
|
|
const [postGuideVisible, setPostGuideVisible] = useState(false)
|
|
const [showLetter, setShowLetter] = useState(false)
|
|
const [showStudyGuide, setShowStudyGuide] = useState(false)
|
|
const videoRef = useRef<VideoRef>(null)
|
|
|
|
useEffect(() => {
|
|
setNarrationVisible(true)
|
|
}, [])
|
|
|
|
// 视频加载完成后自动播放
|
|
const handleVideoCanPlay = () => {
|
|
if (showVideo && videoRef.current) {
|
|
videoRef.current.play().catch((error) => {
|
|
console.warn('视频播放失败:', error)
|
|
})
|
|
}
|
|
}
|
|
|
|
const anchors: VideoAnchor[] = useMemo(
|
|
() =>
|
|
QUIZ_ANCHORS.map((item) => {
|
|
const correctAnswers = item.options.filter((opt) => opt.isCorrect).map((opt) => opt.value)
|
|
|
|
// 拖拽题处理
|
|
if (item.type === 'drag' && item.blanks) {
|
|
return {
|
|
id: item.id,
|
|
time: item.time,
|
|
once: true,
|
|
afterResume: 'remove',
|
|
render: ({ resume }) => {
|
|
// 拖拽题:处理选项拖拽与校验
|
|
const handleDragChange = (val: DragFillResult[]) => {
|
|
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }))
|
|
setDragQuizErrors((prev) => ({ ...prev, [item.id]: '' }))
|
|
|
|
if (val.length < correctAnswers.length) return
|
|
|
|
// 检查是否包含了所有正确答案(不要求顺序)
|
|
const filledAnswers = val.map((v) => v.optionValue)
|
|
const answerSet = new Set(correctAnswers)
|
|
const filledSet = new Set(filledAnswers)
|
|
const isCorrect =
|
|
answerSet.size === filledSet.size &&
|
|
[...answerSet].every((answer) => filledSet.has(answer))
|
|
|
|
if (isCorrect) {
|
|
setActiveSuccess(item.id)
|
|
setResumeCallback(() => resume)
|
|
} else {
|
|
setDragQuizErrors((prev) => ({
|
|
...prev,
|
|
[item.id]: '这不是陈云的工作作风。请你再想想吧!',
|
|
}))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<DragQuizPanel
|
|
visible={activeSuccess !== item.id}
|
|
zIndex={10000}
|
|
questionPrefix={`【拖拽】${item.prompt}`}
|
|
questionSuffix="等精神。"
|
|
options={item.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: opt.label,
|
|
}))}
|
|
blanks={item.blanks || []}
|
|
value={dragQuizValues[item.id] || []}
|
|
error={dragQuizErrors[item.id]}
|
|
onChange={handleDragChange}
|
|
/>
|
|
<SuccessAlert
|
|
visible={activeSuccess === item.id}
|
|
title="恭喜你, 选择正确!"
|
|
content=""
|
|
onContinue={() => {
|
|
setActiveSuccess(null)
|
|
triggerPuzzleAnimation(16)
|
|
setDragQuizValues((prev) => {
|
|
const next = { ...prev }
|
|
delete next[item.id]
|
|
return next
|
|
})
|
|
setDragQuizErrors((prev) => {
|
|
const next = { ...prev }
|
|
delete next[item.id]
|
|
return next
|
|
})
|
|
const cb = resumeCallback
|
|
setResumeCallback(null)
|
|
cb?.()
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
},
|
|
}
|
|
}
|
|
|
|
// 多选题处理
|
|
if (item.type === 'multiple') {
|
|
return {
|
|
id: item.id,
|
|
time: item.time,
|
|
once: true,
|
|
afterResume: 'remove',
|
|
render: ({ resume }) => (
|
|
<>
|
|
<MultipleChoiceQuizPanel
|
|
visible={activeSuccess !== item.id}
|
|
zIndex={10000}
|
|
title={`【多选】${item.prompt}`}
|
|
options={item.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: `${opt.value}. ${opt.label}`,
|
|
}))}
|
|
correctAnswer={correctAnswers}
|
|
errorMessage={(selectedValues) => {
|
|
// 题目1:选到“鸟笼比喻(B)”给专属提示;否则未选全给“还不够全面”提示
|
|
if (item.id === 'quiz-1') {
|
|
if (selectedValues.includes('B')) {
|
|
return item.explanation || '鸟笼比喻是陈云提出的一个比喻。请你再想想吧!'
|
|
}
|
|
return MULTIPLE_DEFAULT_ERROR
|
|
}
|
|
// 题目5:选到“D”给专属提示;否则未选全给“还不够全面”提示
|
|
if (item.id === 'quiz-5') {
|
|
if (selectedValues.includes('D')) {
|
|
const pickedD = item.options.find((o) => o.value === 'D')
|
|
return pickedD?.explanation || MULTIPLE_DEFAULT_ERROR
|
|
}
|
|
return MULTIPLE_DEFAULT_ERROR
|
|
}
|
|
return item.explanation || MULTIPLE_DEFAULT_ERROR
|
|
}}
|
|
onSubmit={(values: string[], isCorrect: boolean) => {
|
|
if (isCorrect) {
|
|
setActiveSuccess(item.id)
|
|
if (item.id === 'quiz-1') triggerPuzzleAnimation(15)
|
|
setResumeCallback(() => resume)
|
|
}
|
|
}}
|
|
/>
|
|
<SuccessAlert
|
|
visible={activeSuccess === item.id}
|
|
// title="恭喜你, 选择正确!"
|
|
// content="回答正确,请继续探索。"
|
|
onContinue={() => {
|
|
setActiveSuccess(null)
|
|
const cb = resumeCallback
|
|
setResumeCallback(null)
|
|
cb?.()
|
|
}}
|
|
/>
|
|
</>
|
|
),
|
|
}
|
|
}
|
|
|
|
if (item.type === 'single') {
|
|
return {
|
|
id: item.id,
|
|
time: item.time,
|
|
once: true,
|
|
afterResume: 'remove',
|
|
render: ({ resume }) => (
|
|
<>
|
|
<SingleChoiceQuizPanel
|
|
visible={activeSuccess !== item.id}
|
|
zIndex={10000}
|
|
title={`【单选】${item.prompt}`}
|
|
options={item.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: `${opt.value}. ${opt.label}`,
|
|
}))}
|
|
correctAnswer={correctAnswers[0]}
|
|
onWrongSelect={(value) =>
|
|
item.options.find((opt) => opt.value === value)?.explanation
|
|
}
|
|
onSubmit={(_, isCorrect) => {
|
|
if (isCorrect) {
|
|
setActiveSuccess(item.id)
|
|
setResumeCallback(() => resume)
|
|
}
|
|
}}
|
|
/>
|
|
<SuccessAlert
|
|
visible={activeSuccess === item.id}
|
|
title="恭喜你,选择正确!"
|
|
onContinue={() => {
|
|
setActiveSuccess(null)
|
|
const cb = resumeCallback
|
|
setResumeCallback(null)
|
|
cb?.()
|
|
}}
|
|
/>
|
|
</>
|
|
),
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: item.id,
|
|
time: item.time,
|
|
once: true,
|
|
afterResume: 'remove',
|
|
render: () => null,
|
|
}
|
|
}),
|
|
[activeSuccess, resumeCallback, dragQuizValues, dragQuizErrors, triggerPuzzleAnimation]
|
|
)
|
|
|
|
if (!moduleName) return null
|
|
|
|
return (
|
|
<div
|
|
className="relative h-full w-full bg-black"
|
|
style={{
|
|
backgroundImage: getBackgroundImage('components/internalization/bg'),
|
|
}}
|
|
>
|
|
{/* 离开按钮 */}
|
|
<Image
|
|
className="absolute right-8 top-8 z-10"
|
|
src={getStaticImage('components/离开')}
|
|
button
|
|
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
|
|
/>
|
|
|
|
<Narration
|
|
visible={narrationVisible}
|
|
audioChannelId="chenyun-internalization-narration"
|
|
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_35_1.mp3')}
|
|
onClose={() => {
|
|
setNarrationVisible(false)
|
|
setVisible(true)
|
|
}}
|
|
text={`从练塘水乡的少年时光,到商务印书馆的革命启航;从苏区与延安的经济破局,到新中国成立之初打赢“银元之战”“米棉之战”,再到建设与改革岁月里擘画实业、奠基兴国,我们已循着陈云同志的足迹,走过一段波澜壮阔的奋斗征程。那些“全国一盘棋”的统筹智慧、“不唯上、不唯书、只唯实”的务实品格、“为老百姓做事”的为民初心,穿越历史烟云,依旧闪耀着时代光芒。历史的回响已在耳畔,精神的传承更需躬身践行。新时代的青年,应该从历史感悟走向思想内化,读懂陈云经济思想的深刻内涵,汲取指引青春成长、担当时代使命的精神力量,让伟人风范在新时代薪火相传。`}
|
|
animationSpeed={200}
|
|
/>
|
|
|
|
{showVideo && (
|
|
<Video
|
|
ref={videoRef}
|
|
src={getPublicPath('video/chenyun/chenyun_neihua.mp4')}
|
|
className="h-full w-full"
|
|
anchors={anchors}
|
|
playsInline
|
|
controls
|
|
onCanPlay={handleVideoCanPlay}
|
|
onEnded={() => {
|
|
setShowVideo(false)
|
|
setPostGuideVisible(true)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 引导 */}
|
|
<GuideSteps
|
|
visible={visible}
|
|
onVisibleChange={(visible) => {
|
|
setVisible(visible)
|
|
if (!visible) {
|
|
setShowVideo(true)
|
|
handleVideoCanPlay()
|
|
}
|
|
}}
|
|
showMask={false}
|
|
>
|
|
<GuideStepsItem
|
|
speaker="student"
|
|
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36_1.mp3')}
|
|
>
|
|
小沪,跟着你参观陈云纪念馆真是收获满满!看到馆里展示的“米棉之战”史料、全国财经统筹的实物展品,我真切感受到陈云爷爷在建国初期平抑物价、稳定经济的魄力。
|
|
</GuideStepsItem>
|
|
<GuideStepsItem
|
|
speaker="student"
|
|
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36_2.mp3')}
|
|
>
|
|
他用“全国一盘棋”思路化解危机的案例太震撼了,那你能再跟我讲讲,支撑这些成就的核心经济思想是什么吗?
|
|
</GuideStepsItem>
|
|
<GuideStepsItem
|
|
speaker="guide"
|
|
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_37.mp3')}
|
|
>
|
|
针对你的这个问题,今天我们有幸邀请到了中国中共党史学会理事,全国陈云思想生平研究会常务理事兼副秘书长,中共一大纪念馆陈列研究部主任,研究馆员房中老师。关于你的问题,我想房老师会给你指点迷津!
|
|
</GuideStepsItem>
|
|
</GuideSteps>
|
|
|
|
{/* 视频结束后的引导 */}
|
|
<GuideSteps
|
|
visible={postGuideVisible}
|
|
onVisibleChange={(visible) => {
|
|
setPostGuideVisible(visible)
|
|
// 引导结束后显示信件
|
|
if (!visible) {
|
|
setShowLetter(true)
|
|
}
|
|
}}
|
|
showMask={false}
|
|
zIndex={60}
|
|
>
|
|
<GuideStepsItem
|
|
speaker="student"
|
|
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_38_1.mp3')}
|
|
>
|
|
感谢房老师的讲解!从陈云童年成长的水乡故居,到他主持“银元之战”“米棉之战”的史料,再到那台称量粮食的天平、握不住的铅笔头,我真切感受到他“不唯上、不唯书、只唯实”的精神,也懂了他“为老百姓做事”的初心。
|
|
</GuideStepsItem>
|
|
<GuideStepsItem
|
|
speaker="student"
|
|
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_38_2.mp3')}
|
|
>
|
|
这些都让我特别受触动!
|
|
</GuideStepsItem>
|
|
<GuideStepsItem
|
|
speaker="guide"
|
|
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39_1.mp3')}
|
|
>
|
|
你的感悟很深刻!陈云同志用一生诠释了“务实”与“为民”——从破解边区经济困境,到擘画国家工业化蓝图,再到改革开放时力挺宝钢建设,每一步都扎根实际、心系百姓。
|
|
</GuideStepsItem>
|
|
<GuideStepsItem
|
|
speaker="guide"
|
|
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39_2.mp3')}
|
|
>
|
|
你不妨把这份触动凝练成几句心里话,既总结参观的收获,也想想未来该如何践行这份精神,用自己的行动传承这份初心吧!
|
|
</GuideStepsItem>
|
|
</GuideSteps>
|
|
|
|
{/* 信件组件:视频引导结束后弹出,带飞入和放大动画 */}
|
|
<LetterToChenyun
|
|
visible={showLetter}
|
|
onClose={() => {
|
|
setShowLetter(false)
|
|
}}
|
|
onAnimationEnd={() => {
|
|
setShowStudyGuide(true)
|
|
}}
|
|
showVideo={true}
|
|
/>
|
|
|
|
<EndingOverlay
|
|
visible={showStudyGuide}
|
|
text="亲爱的同学,恭喜你圆满完成了陈云纪念馆的学习,也顺利走完了本课程所有名人故居的探访之旅。
|
|
在这段旅程里,你与八位伟大的灵魂跨越时空对话,触摸他们的理想与热忱,感受他们的坚韧与担当。这些宝贵的精神财富,早已融入民族的血脉,此刻也正成为你心中的光。
|
|
新时代的征程已在脚下,愿你带着这份收获与感动,将先辈的精神化作前行的力量,在属于我们的时代里,勇敢发光发热,为中华民族的伟大复兴贡献自己的一份力量。
|
|
"
|
|
animationSpeed={200}
|
|
onContinue={() => {
|
|
setShowStudyGuide(false)
|
|
setCourseTimeProgress(24)
|
|
navigate('/menu')
|
|
}}
|
|
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_40.mp3')}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ChenyunInternalization
|