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>
566 lines
21 KiB
TypeScript
566 lines
21 KiB
TypeScript
import { useEffect, useMemo, useState, useRef } from 'react'
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|
import {
|
|
Image,
|
|
Video,
|
|
type VideoAnchor,
|
|
type VideoRef,
|
|
type DragFillResult,
|
|
LetterToSunzhongshan,
|
|
Narration,
|
|
EndingOverlay,
|
|
} from '@/components'
|
|
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
|
import { SuccessAlert, DragQuizPanel } from '@/components/layout'
|
|
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
|
|
import { GuideSteps, GuideStepsItem } from '@/components'
|
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
|
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
|
|
type QuizOption = {
|
|
value: string
|
|
label: string
|
|
isCorrect?: boolean
|
|
explanation?: string // 错误选项的解释
|
|
}
|
|
|
|
type QuizAnchor = {
|
|
id: string
|
|
time: number
|
|
title: string
|
|
prompt: string
|
|
// 拖拽题专用:题干后缀(空白区域之后的文本)
|
|
promptSuffix?: string
|
|
type: 'single' | 'multiple' | 'drag'
|
|
options: QuizOption[]
|
|
// 拖拽题专用
|
|
blanks?: { id: string; label: string }[]
|
|
// 拖拽题专用:空白之间的分隔符数组
|
|
blankSeparators?: string[]
|
|
// 拖拽题正确答案映射:blankId -> optionValue
|
|
correctAnswersMap?: Record<string, string>
|
|
explanation?: string
|
|
}
|
|
|
|
// 星名固定为 sunzhongshan
|
|
const starName = 'sunzhongshan'
|
|
|
|
// 锚点数据:可根据实际视频时长调整 time
|
|
const QUIZ_ANCHORS: QuizAnchor[] = [
|
|
{
|
|
id: 'quiz-1',
|
|
time: 60 + 14, //5.52分钟
|
|
title: '题目1',
|
|
prompt:
|
|
'【拖拽】习近平总书记在纪念孙中山先生诞辰150周年大会上指出: “中国共产党人是孙中山先生革命事业最坚定的',
|
|
promptSuffix: '、最忠实的继承者。”',
|
|
type: 'drag',
|
|
blanks: [
|
|
{ id: 'blank-1', label: '最坚定的' },
|
|
{ id: 'blank-2', label: '最忠诚的' },
|
|
],
|
|
options: [
|
|
{
|
|
value: '支持者',
|
|
label: '支持者',
|
|
isCorrect: true,
|
|
},
|
|
{
|
|
value: '合作者',
|
|
label: '合作者',
|
|
isCorrect: true,
|
|
},
|
|
],
|
|
blankSeparators: ['、最忠诚的'],
|
|
correctAnswersMap: {
|
|
'blank-1': '支持者',
|
|
'blank-2': '合作者',
|
|
},
|
|
},
|
|
{
|
|
id: 'quiz-2',
|
|
time: 60 * 2, // 2分钟
|
|
title: '题目2',
|
|
prompt: '【多选】新时代青年传承“振兴中华”的精神,能够具体落实为:',
|
|
type: 'multiple',
|
|
options: [
|
|
{
|
|
value: 'A',
|
|
label: '专注学习,提升自身技能与素养',
|
|
isCorrect: true,
|
|
explanation:
|
|
'恭喜你,选择正确!青年通过专注学习、提升自身能力,用精益求精的精神服务于国家建设,才能成为“振兴中华”的践行者。',
|
|
},
|
|
{
|
|
value: 'B',
|
|
label: '秉持精益求精的精神,在专业领域深耕',
|
|
isCorrect: true,
|
|
explanation:
|
|
'恭喜你,选择正确!青年在自己的专业领域深耕细作,才能以扎实的能力为国家发展贡献力量。',
|
|
},
|
|
{
|
|
value: 'C',
|
|
label: '遇到困难不退缩,不因失败而灰心',
|
|
isCorrect: true,
|
|
explanation:
|
|
'恭喜你,选择正确!不因失败而灰心,也从不因困难而退缩,这种坚韧的态度是践行“振兴中华”过程中不可或缺的品质。',
|
|
},
|
|
{
|
|
value: 'D',
|
|
label: '放弃个人职业规划,直接投身革命事业',
|
|
isCorrect: false,
|
|
explanation:
|
|
'振兴中华要把个人理想融入国家命运,在自己的领域里成为“振兴中华”的践行者。请你再想想吧!',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'quiz-3',
|
|
time: 60 * 4 + 14, // 4分14秒
|
|
title: '题目3',
|
|
prompt: '【多选】新时代青年涵养“天下为公”的情怀能够具体落实为:',
|
|
type: 'multiple',
|
|
options: [
|
|
{
|
|
value: 'A',
|
|
label: '做一名志愿者走进养老院服务老人',
|
|
isCorrect: true,
|
|
explanation: '恭喜你,选择正确!对他人的关爱与奉献是传承“天下为公”情怀的具体行动哦!',
|
|
},
|
|
{
|
|
value: 'B',
|
|
label: '只专注于个人学业与职业发展,不参与公共事务',
|
|
isCorrect: false,
|
|
explanation: '这与“天下为公”的情怀完全相悖。请你再想想吧!',
|
|
},
|
|
{
|
|
value: 'C',
|
|
label: '用自己所学的技能为企业、社区提供服务',
|
|
isCorrect: true,
|
|
explanation:
|
|
'恭喜你,选择正确!用自己所学的技能为企业、社区提供服务是青年将个人能力转化为公共服务、践行“天下为公”的重要方式。',
|
|
},
|
|
{
|
|
value: 'D',
|
|
label: '在日常生活中关心身边有需要的人',
|
|
isCorrect: true,
|
|
explanation:
|
|
'恭喜你,选择正确!“天下为公”的情怀不需要宏大叙事,从身边小事做起就是具体落实。',
|
|
},
|
|
],
|
|
explanation: '请再仔细分析一下题目,做出更全面的选择吧!',
|
|
},
|
|
{
|
|
id: 'quiz-4',
|
|
time: 60 * 5 + 40.5,
|
|
title: '题目4',
|
|
prompt: '【多选】新时代青年学习“与时俱进”的智慧能够具体落实为:',
|
|
type: 'multiple',
|
|
options: [
|
|
{
|
|
value: 'A',
|
|
label: '主动学习新知识,关注科技等前沿领域',
|
|
isCorrect: true,
|
|
explanation:
|
|
'恭喜你,选择正确!主动学习新知识、适应新趋势正是青年与时俱进、紧跟时代步伐的具体表现。',
|
|
},
|
|
{
|
|
value: 'B',
|
|
label: '在工作中突破传统思维,用创新方法解决问题',
|
|
isCorrect: true,
|
|
explanation:
|
|
'恭喜你,选择正确!在工作中突破传统思维,用创新解决问题正是与时俱进精神在职业场景中的落地。',
|
|
},
|
|
{
|
|
value: 'C',
|
|
label: '固执己见,拒绝接受新事物和新变化',
|
|
isCorrect: false,
|
|
explanation: '这与“与时俱进”的智慧完全相悖。请你再想想吧!',
|
|
},
|
|
],
|
|
explanation: '请再仔细分析一下题目,做出更全面的选择吧!',
|
|
},
|
|
{
|
|
id: 'quiz-5',
|
|
time: 60 * 5 + 40.5,
|
|
title: '题目5',
|
|
prompt: '【多选】新时代的青年要从孙中山先生身上学习哪些精神?',
|
|
type: 'multiple',
|
|
options: [
|
|
{
|
|
value: 'A',
|
|
label: '“振兴中华”的初心',
|
|
isCorrect: true,
|
|
},
|
|
{
|
|
value: 'B',
|
|
label: '“天下为公”的情怀',
|
|
isCorrect: true,
|
|
},
|
|
{ value: 'C', label: '“与时俱进”的智慧', isCorrect: true },
|
|
],
|
|
},
|
|
]
|
|
|
|
const SunzhongshanInternalization = () => {
|
|
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 [showNarration, setShowNarration] = useState(true) // 是否显示旁白
|
|
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(() => {
|
|
if (!showNarration) {
|
|
setVisible(true)
|
|
}
|
|
}, [showNarration])
|
|
|
|
// 视频加载完成后自动播放
|
|
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 (!item.blanks || val.length < item.blanks.length) return
|
|
|
|
// 如果有位置特定的答案映射,使用它进行验证
|
|
if (item.correctAnswersMap) {
|
|
const isCorrect = item.blanks.every((blank) => {
|
|
const filledResult = val.find((v) => v.blankId === blank.id)
|
|
const correctAnswer = item.correctAnswersMap![blank.id]
|
|
return filledResult?.optionValue === correctAnswer
|
|
})
|
|
|
|
if (isCorrect) {
|
|
setActiveSuccess(item.id)
|
|
setResumeCallback(() => resume)
|
|
} else {
|
|
setDragQuizErrors((prev) => ({
|
|
...prev,
|
|
[item.id]: '再想一想,选择正确答案~',
|
|
}))
|
|
}
|
|
} else {
|
|
// 如果没有位置特定的映射,使用原来的逻辑(检查是否包含了所有正确答案)
|
|
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}
|
|
title={``}
|
|
questionPrefix={item.prompt}
|
|
questionSuffix={item.promptSuffix || ''}
|
|
options={item.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: opt.label,
|
|
}))}
|
|
blanks={item.blanks || []}
|
|
blankSeparators={item.blankSeparators}
|
|
value={dragQuizValues[item.id] || []}
|
|
error={dragQuizErrors[item.id]}
|
|
onChange={handleDragChange}
|
|
/>
|
|
<SuccessAlert
|
|
visible={activeSuccess === item.id}
|
|
title="恭喜你, 选择正确!"
|
|
content=""
|
|
onContinue={() => {
|
|
setActiveSuccess(null)
|
|
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)
|
|
triggerPuzzleAnimation(15)
|
|
cb?.()
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
},
|
|
}
|
|
}
|
|
|
|
// 多选题处理
|
|
if (item.type === 'multiple') {
|
|
return {
|
|
id: item.id,
|
|
time: item.time,
|
|
once: true,
|
|
afterResume: 'remove',
|
|
render: ({ resume }) => (
|
|
<>
|
|
<MultipleChoiceQuizPanel
|
|
visible={activeSuccess !== item.id}
|
|
zIndex={10000}
|
|
title={`${item.prompt}`}
|
|
options={item.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: `${opt.value}. ${opt.label}`,
|
|
}))}
|
|
correctAnswer={correctAnswers}
|
|
errorMessage={
|
|
item.id === 'quiz-2'
|
|
? (selectedValues: string[]) => {
|
|
const picked = new Set(selectedValues)
|
|
const pickedD = item.options.find((o) => o.value === 'D')
|
|
if (picked.has('D')) return pickedD?.explanation || ''
|
|
return '请再仔细分析一下题目,做出更全面的选择吧!'
|
|
}
|
|
: item.id === 'quiz-3'
|
|
? (selectedValues: string[]) => {
|
|
const picked = new Set(selectedValues)
|
|
const pickedB = item.options.find((o) => o.value === 'B')
|
|
if (picked.has('B')) return pickedB?.explanation || ''
|
|
return item.explanation || '请再仔细分析一下题目,做出更全面的选择吧!'
|
|
}
|
|
: item.id === 'quiz-4'
|
|
? (selectedValues: string[]) => {
|
|
const picked = new Set(selectedValues)
|
|
const pickedC = item.options.find((o) => o.value === 'C')
|
|
if (picked.has('C')) return pickedC?.explanation || ''
|
|
return (
|
|
item.explanation || '请再仔细分析一下题目,做出更全面的选择吧!'
|
|
)
|
|
}
|
|
: item.id === 'quiz-5'
|
|
? (selectedValues: string[]) => {
|
|
const picked = new Set(selectedValues)
|
|
const pickedC = item.options.find((o) => o.value === 'C')
|
|
if (picked.has('C')) return pickedC?.explanation || ''
|
|
return (
|
|
item.explanation || '请再仔细分析一下题目,做出更全面的选择吧!'
|
|
)
|
|
}
|
|
: item.explanation || '再想一想,确保选择了所有正确答案~'
|
|
}
|
|
onSubmit={(values: string[], isCorrect: boolean) => {
|
|
if (isCorrect) {
|
|
setActiveSuccess(item.id)
|
|
// if (item.id === "quiz-2") {
|
|
// const picked = new Set(values);
|
|
// const content = item.options
|
|
// .filter((o) => picked.has(o.value))
|
|
// .map((o) => o.explanation)
|
|
// .filter(Boolean)
|
|
// .join("\n");
|
|
// setSuccessContent(content || "回答正确,请继续探索。");
|
|
// } else {
|
|
// setSuccessContent("回答正确,请继续探索。");
|
|
// }
|
|
setResumeCallback(() => resume)
|
|
}
|
|
}}
|
|
/>
|
|
<SuccessAlert
|
|
visible={activeSuccess === item.id}
|
|
// title="恭喜你, 选择正确!"
|
|
// content="回答正确,请继续探索。"
|
|
onContinue={() => {
|
|
setActiveSuccess(null)
|
|
const cb = resumeCallback
|
|
setResumeCallback(null)
|
|
triggerPuzzleAnimation(16)
|
|
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`)}
|
|
/>
|
|
|
|
{showVideo && (
|
|
<Video
|
|
ref={videoRef}
|
|
src={getPublicPath('video/sunzhongshan/sunzhongshan_neihua.mp4')}
|
|
className="h-full w-full"
|
|
anchors={anchors}
|
|
playsInline
|
|
controls
|
|
onCanPlay={handleVideoCanPlay}
|
|
onEnded={() => {
|
|
setShowVideo(false)
|
|
setPostGuideVisible(true)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 旁白 */}
|
|
<Narration
|
|
visible={showNarration}
|
|
audioChannelId="sunzhongshan-internalization-narration"
|
|
text="从翠亨村的农家院落到影响中国的革命征程,孙中山先生以“亟拯斯民于水火,切扶大厦之将倾”的担当,在近代中国的黑暗中点燃了民主共和的火种。他留下的不仅是推翻帝制、建立共和的历史功绩,更有“振兴中华”的初心、“天下为公”的情怀、“与时俱进”的智慧。今天,我们站在故居的青砖黛瓦间,不仅要回望他为民族独立、人民幸福奋斗的一生,更要让这份精神穿越时空,成为新时代青年砥砺前行的力量。"
|
|
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_55.mp3')}
|
|
animationSpeed={200}
|
|
onClose={() => {
|
|
setShowNarration(false)
|
|
}}
|
|
/>
|
|
|
|
{/* 对话引导 */}
|
|
<GuideSteps
|
|
visible={visible}
|
|
audioChannelId="sunzhongshan-internalization-guide-pre"
|
|
onVisibleChange={(visible) => {
|
|
setVisible(visible)
|
|
if (!visible) {
|
|
setShowVideo(true)
|
|
handleVideoCanPlay()
|
|
}
|
|
}}
|
|
showMask={false}
|
|
>
|
|
<GuideStepsItem
|
|
speaker="student"
|
|
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_56.mp3')}
|
|
>
|
|
小沪,参观完故居,我对孙中山先生从“医人”到“医国”的转变、《实业计划》里的宏大构想有了很多感触,但总觉得这些历史和精神离我们的生活还有些远,不知道该怎么真正理解和传承。
|
|
</GuideStepsItem>
|
|
<GuideStepsItem
|
|
speaker="guide"
|
|
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_57.mp3')}
|
|
>
|
|
你的感受特别真实!孙中山先生的精神不是停留在书本里的文字,而是能指导我们当下行动的思想财富。今天我们特别邀请到了上海孙中山故居纪念馆研究部主任孙旻老师,让她带我们一起解读先生精神的时代意义,把这份传承落到实处。
|
|
</GuideStepsItem>
|
|
</GuideSteps>
|
|
|
|
{/* 视频结束后的引导 */}
|
|
<GuideSteps
|
|
visible={postGuideVisible}
|
|
audioChannelId="sunzhongshan-internalization-guide-post"
|
|
onVisibleChange={(visible) => {
|
|
setPostGuideVisible(visible)
|
|
// 引导结束后显示信件
|
|
if (!visible) {
|
|
setShowLetter(true)
|
|
}
|
|
}}
|
|
showMask={false}
|
|
zIndex={60}
|
|
>
|
|
<GuideStepsItem
|
|
speaker="guide"
|
|
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_58.mp3')}
|
|
>
|
|
再看香山路7号这座小楼,相信你有很多心里话想对孙中山先生说。现在,就请你把这份感悟浓缩成几句话,既呼应先生的精神,也给自己未来许下一个承诺吧!
|
|
</GuideStepsItem>
|
|
</GuideSteps>
|
|
|
|
{/* 信件组件:视频引导结束后弹出,带飞入和放大动画 */}
|
|
<LetterToSunzhongshan
|
|
visible={showLetter}
|
|
onClose={() => {
|
|
setShowLetter(false)
|
|
}}
|
|
onAnimationEnd={() => {
|
|
setShowStudyGuide(true)
|
|
}}
|
|
showVideo={true}
|
|
/>
|
|
|
|
<EndingOverlay
|
|
visible={showStudyGuide}
|
|
text="恭喜你圆满完成孙中山故居纪念馆的学习之旅,请带着研学感悟,前往鲁迅故居继续探访吧~"
|
|
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_59.mp3')}
|
|
animationSpeed={200}
|
|
onContinue={() => {
|
|
setShowStudyGuide(false)
|
|
setCourseTimeProgress(6)
|
|
navigate('/menu')
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default SunzhongshanInternalization
|