Files
shanghai-residences/src/pages/maozedong/internalization.tsx
T
zhongyidianandClaude Opus 4.7 6d40e19346 feat: update residence learning flows
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>
2026-05-13 10:15:26 +08:00

525 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
type DragFillResult,
SingleChoiceQuizPanel,
LetterToMaozedong,
EndingOverlay,
Narration,
} from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { GuideSteps, GuideStepsItem } from '@/components'
import { BasketQuizPanel, DragQuizPanel, SuccessAlert } from '@/components/layout'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'maozedong'
const MaozedongInternalization = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
// 进页面:先播“引入视频” -> 引入对话(图1) -> 主视频(视频11)
const [showIntroVideo, setShowIntroVideo] = useState(true)
const [introQuizVisible, setIntroQuizVisible] = useState(false)
const [introQuizSuccessVisible, setIntroQuizSuccessVisible] = useState(false)
const [introDialogVisible, setIntroDialogVisible] = useState(false)
const [showMainVideo, setShowMainVideo] = useState(false)
// 题目弹层状态(视频锚点 render 中使用)
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 [dragQuizFeedbackTypes, setDragQuizFeedbackTypes] = useState<
Record<string, 'success' | 'error'>
>({})
// 写信、寄信动画与完成提示
const [showLetter, setShowLetter] = useState(false)
const [showCompleted, setShowCompleted] = useState(false)
const [showTransition, setShowTransition] = useState(false)
const videoRef = useRef<VideoRef>(null)
useEffect(() => {}, [])
const handleVideoCanPlay = () => {
if (showMainVideo && videoRef.current) {
videoRef.current.play().catch(() => {})
}
}
// —— 题目配置:题目2(1:59)、题目3(4:31)、题目4(6:58)、题目5(7:15)——
type DragQuizConfig = {
id: string
time: number
title: string
prompt: string
correct: string[]
wrongValue: string
wrongHint: string
optionHints: Partial<Record<string, string>>
options: { value: string; label: string }[]
blanks: { id: string; label: string }[]
separators?: string[]
}
const DRAG_QUIZZES: DragQuizConfig[] = useMemo(
() => [
{
id: 'quiz-2',
time: 60 * 1 + 59,
title: '',
prompt: '请将你觉得属于新时代坚持“实事求是”思想的做法拖入篮子吧!',
correct: ['A', 'B', 'C'],
wrongValue: 'D',
wrongHint:
'这种做法脱离实际,违背了“实事求是”的思想路线,容易导致发展走弯路。请你再想想吧!',
optionHints: {
A: '恭喜你,选择正确!这是党在精准把握发展阶段特征的基础上作出的科学判断,体现了“实事求是”的思想精髓。',
B: '恭喜你,选择正确!这是从上海实际出发,立足自身优势精准定位发展方向,是“实事求是”在区域发展中的生动体现。',
C: '恭喜你,选择正确!这是立足实际、聚焦问题、务求实效的实践路径,是“实事求是”在改革发展中的具体践行。',
D: '这种做法脱离实际,违背了“实事求是”的思想路线,容易导致发展走弯路。请你再想想吧!',
},
options: [
{
value: 'A',
label: '党的十八大以来,根据世情国情变化作出“社会主要矛盾转化”的新论断',
},
{
value: 'B',
label: '基于上海的区位优势和发展基础,党中央赋予上海建设“五个中心”的使命',
},
{
value: 'C',
label: '上海以问题为导向,推动“五个中心”联动发展,将改革开放“施工图”转化为“实景画”',
},
{ value: 'D', label: '不顾本地资源禀赋,盲目照搬其他地区的产业发展模式' },
],
blanks: [
{ id: 'basket-1', label: '' },
{ id: 'basket-2', label: '' },
{ id: 'basket-3', label: '' },
],
},
{
id: 'quiz-3',
time: 60 * 4 + 32,
title: '',
prompt: '请将你认为符合新时代践行“群众路线”思想的做法拖入篮子吧!',
correct: ['A', 'C', 'D'],
wrongValue: 'B',
wrongHint:
'这种做法违背了以人民为中心的发展思想,不符合新时代群众路线的要求。请你再想想吧!',
optionHints: {
A: '恭喜你,选择正确!这一理念是新时代群众路线在城市治理中的生动表达,体现了以人民为中心的发展思想。',
B: '这种做法违背了以人民为中心的发展思想,不符合新时代群众路线的要求。请你再想想吧!',
C: '恭喜你,选择正确!这是群众路线在民主政治领域的创新实践,保障了人民群众的参与权、表达权,让人民真正成为城市的主人。',
D: '恭喜你,选择正确!这是群众路线在新时代的深化与拓展,充分调动了人民群众的积极性、主动性、创造性。',
},
options: [
{ value: 'A', label: '提出“人民城市人民建,人民城市为人民”的重要理念' },
{ value: 'B', label: '城市在经济发展中只关注企业效益,忽视普通市民的民生需求' },
{
value: 'C',
label: '上海作为全过程人民民主首提地,搭建多元民主平台让群众声音直达决策层面',
},
{
value: 'D',
label: '上海通过推进“全过程人民民主”,汇聚各方智慧共同推进中国式现代化',
},
],
blanks: [
{ id: 'basket-1', label: '' },
{ id: 'basket-2', label: '' },
{ id: 'basket-3', label: '' },
],
},
{
id: 'quiz-4',
time: 60 * 6 + 59,
title: '',
prompt: '请将你认为符合新时代践行“独立自主”思想的做法拖入篮子吧!',
correct: ['B', 'C', 'D'],
wrongValue: 'A',
wrongHint:
'这种做法违背了“独立自主”的原则,会导致发展受制于他人,不符合中国式现代化的本质要求。请你再想想吧!',
optionHints: {
A: '这种做法违背了“独立自主”的原则,会导致发展受制于他人,不符合中国式现代化的本质要求。请你再想想吧!',
B: '恭喜你,选择正确!这是我国践行独立自主思想的典范,彰显了我国在高端制造领域依靠自身力量突破核心技术的决心与能力。',
C: '恭喜你,选择正确!这是上海在改革开放中坚持独立自主、探索制度创新的生动实践,体现了在开放中掌握发展主动权的思路。',
D: '恭喜你,选择正确!这是上海坚持把发展放在自己力量基点上的具体体现,通过打造自主产业集群,在开放合作中牢牢掌握了发展主动权。',
},
options: [
{ value: 'A', label: '部分地区在产业发展中完全依赖国外技术,放弃自主研发投入' },
{
value: 'B',
label: 'C919大飞机研制成功,实现百余项关键技术突破,60%的自主配套率',
},
{
value: 'C',
label: '上海自贸试验区率先试点“负面清单管理”,临港新片区打造全产业链集群',
},
{
value: 'D',
label: '临港新片区布局“东方芯港”“生命蓝湾”等特色产业园区,构建自主可控产业生态',
},
],
blanks: [
{ id: 'basket-1', label: '' },
{ id: 'basket-2', label: '' },
{ id: 'basket-3', label: '' },
],
},
{
id: 'quiz-5',
time: 60 * 7 + 16,
title: '',
prompt: '毛泽东思想活的灵魂包括',
correct: ['实事求是', '群众路线', '独立自主'],
wrongValue: '党的建设',
wrongHint: '党的建设是新民主主义革命胜利的“三大法宝”之一。请你再想想吧!',
optionHints: {
实事求是: '',
群众路线: '',
独立自主: '',
党的建设: '党的建设是新民主主义革命胜利的“三大法宝”之一。请你再想想吧!',
},
options: [
{ value: '实事求是', label: 'A.实事求是' },
{ value: '群众路线', label: 'B.群众路线' },
{ value: '独立自主', label: 'C.独立自主' },
{ value: '党的建设', label: 'D.党的建设' },
],
blanks: [
{ id: 'blank-1', label: '' },
{ id: 'blank-2', label: '' },
{ id: 'blank-3', label: '' },
],
separators: ['、', '、'],
},
],
[]
)
const anchors: VideoAnchor[] = useMemo(() => {
return DRAG_QUIZZES.map((q) => ({
id: q.id,
time: q.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => {
const handleDragChange = (val: DragFillResult[]) => {
// quiz-5:参考黄炎培 DragQuizPanel 的用法,不做“每次拖拽都提示”,仅在填满后校验并提示
if (q.id === 'quiz-5') {
const valid = val.filter((v) => q.blanks.some((b) => b.id === v.blankId))
if (valid.some((v) => v.optionValue === q.wrongValue)) {
setDragQuizErrors((prevErr) => ({ ...prevErr, [q.id]: q.wrongHint }))
// 错误项拖拽后回到原来的选项框位(从结果中移除)
setDragQuizValues((prev) => ({
...prev,
[q.id]: valid.filter((v) => v.optionValue !== q.wrongValue),
}))
return
}
setDragQuizErrors((prevErr) => ({ ...prevErr, [q.id]: '' }))
setDragQuizValues((prev) => ({ ...prev, [q.id]: valid }))
if (valid.length < q.blanks.length) return
const filledSet = new Set(valid.map((v) => v.optionValue))
const answerSet = new Set(q.correct)
const isCorrect =
filledSet.size >= answerSet.size && [...answerSet].every((a) => filledSet.has(a))
if (isCorrect) {
setActiveSuccess(q.id)
triggerPuzzleAnimation(16)
setResumeCallback(() => resume)
}
return
}
const prev = dragQuizValues[q.id] || []
const valid = val.filter((v) => q.blanks.some((b) => b.id === v.blankId))
const prevOptionSet = new Set(prev.map((v) => v.optionValue))
const newlyAdded = valid.find((v) => !prevOptionSet.has(v.optionValue))
const newlyAddedValue = newlyAdded?.optionValue
const isNewlyWrong = newlyAddedValue ? newlyAddedValue === q.wrongValue : false
const isNewlyCorrect = newlyAddedValue ? newlyAddedValue !== q.wrongValue : false
if (valid.some((v) => v.optionValue === q.wrongValue)) {
const wrongMsg = newlyAdded?.optionValue
? q.optionHints[newlyAdded.optionValue] || q.wrongHint
: q.wrongHint
setDragQuizErrors((prevErr) => ({ ...prevErr, [q.id]: wrongMsg }))
setDragQuizFeedbackTypes((prevType) => ({ ...prevType, [q.id]: 'error' }))
setDragQuizValues((prev) => ({
...prev,
[q.id]: valid.filter((v) => v.optionValue !== q.wrongValue),
}))
return
}
const defaultTip = isNewlyCorrect ? '恭喜你,选择正确!' : ''
const tip = newlyAddedValue ? q.optionHints[newlyAddedValue] || defaultTip : ''
setDragQuizErrors((prevErr) => ({ ...prevErr, [q.id]: tip }))
if (newlyAddedValue) {
setDragQuizFeedbackTypes((prevType) => ({
...prevType,
[q.id]: isNewlyWrong ? 'error' : 'success',
}))
}
setDragQuizValues((prev) => ({ ...prev, [q.id]: valid }))
const filledSet = new Set(valid.map((v) => v.optionValue))
const answerSet = new Set(q.correct)
const isCorrect =
filledSet.size >= answerSet.size && [...answerSet].every((a) => filledSet.has(a))
if (isCorrect) {
setActiveSuccess(q.id)
if (q.id === 'quiz-2') triggerPuzzleAnimation(15)
setResumeCallback(() => resume)
}
}
return (
<>
{q.id === 'quiz-5' ? (
<DragQuizPanel
visible={activeSuccess !== q.id}
zIndex={10000}
title=""
questionPrefix={`【拖拽】${q.prompt}`}
questionSuffix="。"
options={q.options}
blanks={q.blanks}
blankSeparators={q.separators}
value={dragQuizValues[q.id] || []}
error={dragQuizErrors[q.id]}
onChange={handleDragChange}
/>
) : (
<BasketQuizPanel
visible={activeSuccess !== q.id}
zIndex={10000}
title={q.title}
questionPrefix={`【拖拽】${q.prompt}`}
options={q.options}
blanks={q.blanks}
blankSeparators={q.separators}
value={dragQuizValues[q.id] || []}
error={dragQuizErrors[q.id]}
feedbackType={dragQuizFeedbackTypes[q.id] || 'error'}
onChange={handleDragChange}
/>
)}
<SuccessAlert
visible={activeSuccess === q.id}
title="恭喜你,选择正确!"
content=""
onContinue={() => {
setActiveSuccess(null)
setDragQuizValues((prev) => {
const next = { ...prev }
delete next[q.id]
return next
})
setDragQuizErrors((prev) => {
const next = { ...prev }
delete next[q.id]
return next
})
setDragQuizFeedbackTypes((prev) => {
const next = { ...prev }
delete next[q.id]
return next
})
const cb = resumeCallback
setResumeCallback(null)
if (q.id === 'quiz-5') {
cb?.()
return
}
cb?.()
}}
/>
</>
)
},
}))
}, [
DRAG_QUIZZES,
activeSuccess,
dragQuizErrors,
dragQuizFeedbackTypes,
dragQuizValues,
resumeCallback,
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={showTransition}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_28.mp3')}
onClose={() => {
setShowTransition(false)
setIntroDialogVisible(true)
}}
text="毛泽东思想是一个完整的科学思想体系,它的活的灵魂的三个基本方面——实事求是、群众路线、独立自主,是贯穿于毛泽东思想各个组成部分的立场、观点、方法,这三个方面在新时代被赋予了更加丰富的内涵和实践形式,成为习近平新时代中国特色社会主义思想的方法论基础和行动指南。"
animationSpeed={200}
/>
{/* 引入视频:进入页面自动播放(暂用其他视频代替) */}
{showIntroVideo && (
<Video
src={getPublicPath('video/maozedong/mzd_10.mp4')}
className="h-full w-full z-20"
playsInline
showSkipButton
autoPlay
onEnded={() => {
setShowIntroVideo(false)
setIntroQuizVisible(true)
}}
/>
)}
<SingleChoiceQuizPanel
visible={introQuizVisible}
zIndex={10001}
title="【单选】毛泽东思想被确立为中国共产党的指导思想是在哪次会议上?"
options={[
{ value: 'A', label: 'A.中共一大' },
{ value: 'B', label: 'B.遵义会议' },
{ value: 'C', label: 'C.中共七大' },
]}
correctAnswer="C"
onWrongSelect={(value) => {
if (value === 'A') {
return '中共一大标志着党的成立。请你再想想吧!'
}
if (value === 'B') {
return '遵义会议确立了毛泽东在党内的领导地位。请你再想想吧!'
}
return '请结合党史时间线再想想吧!'
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setIntroQuizVisible(false)
setIntroQuizSuccessVisible(true)
}}
/>
<SuccessAlert
visible={introQuizSuccessVisible}
title="恭喜你,选择正确!"
content="1945年,中共七大正式将毛泽东思想确立为党的指导思想并写入党章。"
onContinue={() => {
setIntroQuizSuccessVisible(false)
setShowTransition(true)
}}
/>
{/* 页面进入后直接播放引入视频,不再展示额外引导 */}
{/* 引入视频结束后的对话(图1) */}
<GuideSteps
visible={introDialogVisible}
onVisibleChange={(v) => {
setIntroDialogVisible(v)
if (!v) {
setShowMainVideo(true)
handleVideoCanPlay()
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_24.mp3')}
>
小沪,关于“实事求是、群众路线、独立自主”这十二个字我还是有些不理解,总感觉这十二个字离我们很遥远。
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_25.mp3')}>
“实事求是、群众路线、独立自主”这十二个字,可以说是中国共产党人认识世界、改造世界的根本方法与价值遵循。今天我们有幸邀请到了华东师范大学马克思主义学院马立政副教授。关于你的问题,我想马教授会给你指点迷津!
</GuideStepsItem>
</GuideSteps>
{/* 主视频(视频11):对话结束自动播放,并插入题目锚点 */}
{showMainVideo && (
<Video
ref={videoRef}
src={getPublicPath('video/maozedong/mzd_neihua.mp4')}
className="h-full w-full"
anchors={anchors}
playsInline
controls
autoPlay
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowMainVideo(false)
// 等视频组件卸载后再弹出信件
setTimeout(() => {
setShowLetter(true)
}, 300)
}}
/>
)}
{/* 写给毛主席的信:视频播完后出现 */}
<LetterToMaozedong
visible={showLetter}
onClose={() => {
setShowLetter(false)
}}
onAnimationEnd={() => {
setShowCompleted(true)
}}
showVideo={true}
/>
<EndingOverlay
visible={showCompleted}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_26.mp3')}
text="恭喜你圆满完成毛泽东旧居陈列馆的学习之旅,请带着研学感悟,前往孙中山故居纪念馆继续探访吧~"
animationSpeed={200}
onContinue={() => {
setShowCompleted(false)
setCourseTimeProgress(3)
navigate('/menu')
}}
/>
</div>
)
}
export default MaozedongInternalization