Files
shanghai-residences/src/pages/sunzhongshan/internalization.tsx
T
2026-09-21 10:29:54 +08:00

557 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 + 42.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 + 42.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]: '' }))
}
const handleDragSubmit = (val: DragFillResult[]) => {
const isCorrect = item.correctAnswersMap
? item.blanks!.every((blank) => {
const filled = val.find((result) => result.blankId === blank.id)
return filled?.optionValue === item.correctAnswersMap![blank.id]
})
: (() => {
const filledSet = new Set(val.map((result) => result.optionValue))
const answerSet = new Set(correctAnswers)
return (
answerSet.size === filledSet.size &&
[...answerSet].every((answer) => filledSet.has(answer))
)
})()
if (isCorrect) {
setActiveSuccess(item.id)
setResumeCallback(() => resume)
return
}
setDragQuizErrors((prev) => ({ ...prev, [item.id]: '再想一想,选择正确答案~' }))
setDragQuizValues((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}
onSubmit={handleDragSubmit}
/>
<SuccessAlert
visible={activeSuccess === item.id}
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)
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)
// quiz-2 完成后掉落碎片 15,最后一个题目(quiz-5)完成后掉落碎片 16
if (item.id === 'quiz-2') {
triggerPuzzleAnimation(15)
} else if (item.id === 'quiz-5') {
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
controlsOnReplay
skipKey="sunzhongshan_internalization_video"
showReadonlyProgress
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('/learning-map', { state: { completedResidence: 'sunzhongshan' } })
}}
/>
</div>
)
}
export default SunzhongshanInternalization