Files
shanghai-residences/src/pages/zhangleping/place/place2.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

441 lines
16 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 {
ExhibitOverlay,
GuideSteps,
GuideStepsItem,
Image,
Title,
MatchQuizPanel,
SingleChoiceQuizPanel,
Video,
type VideoRef,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useRef, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
// starName 固定为 zhangleping
const starName = 'zhangleping'
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
1: 'zhangleping/place2/张乐平场景2展品3',
2: 'zhangleping/place2/张乐平场景2展品2',
3: 'zhangleping/place2/张乐平场景2展品1',
}
// 阶段二:抗战画家(3 个光标)
const ZhanglepingPlace2 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
const place = 'place2'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useZhanglepingStore()
const visitedExhibits = getVisitedExhibits(place) as (1 | 2 | 3)[]
const visited = new Set<1 | 2 | 3>(visitedExhibits)
const [guideVisible1, setGuideVisible1] = useState(false)
const [guideStep1, setGuideStep1] = useState(0)
// 展品2目前无引导对话(保留 setter 以便未来扩展)
const [_guideVisible2, _setGuideVisible2] = useState(false)
const [_guideStep2, _setGuideStep2] = useState(0)
const [guideVisible3, setGuideVisible3] = useState(false)
const [guideStep3, setGuideStep3] = useState(0)
// 展品3对话结束后的连线题
const [quizVisible3, setQuizVisible3] = useState(false)
const [quizPassed3, setQuizPassed3] = useState(false)
const [successAlert3Visible, setSuccessAlert3Visible] = useState(false)
// 展品1:关闭后播放的视频
const [showVideo1, setShowVideo1] = useState(false)
const video1Ref = useRef<VideoRef>(null)
const [quizVisible1, setQuizVisible1] = useState(false)
const [successAlert1Visible, setSuccessAlert1Visible] = useState(false)
// 展品2:关闭后播放的视频
const [showVideo2, setShowVideo2] = useState(false)
const video2Ref = useRef<VideoRef>(null)
// Cursor 顺序控制:先学习展品3,再依次解锁展品2、展品1
const { isEnabled } = useCursorController({
initialEnabled: [3],
dependencies: {
2: [3],
1: [2],
},
visited,
})
if (!moduleName) return null
const total = 3
const progressCount = visited.size
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id)
if (id === 1) {
// 展品1:打开弹窗时显示引导对话
setGuideVisible1(true)
setGuideStep1(0)
} else if (id === 2) {
// 展品2:无引导对话
} else {
setGuideVisible3(true)
setGuideStep3(0)
// 重新进入展品3时重置题目完成状态
setQuizPassed3(false)
}
}
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`${starName}/place2/张乐平场景2`),
}}
>
{/* 标题 */}
<Title
title="抗战画家"
subtitle={subtitle}
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
<Image
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place1`)
}
}}
/>
{/* 光标1 */}
<Cursor
className="absolute left-135 bottom-90"
size={0.6}
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
disabled={!isEnabled(1)}
/>
{/* 光标2 */}
<Cursor
className="absolute right-177 bottom-170"
size={0.6}
onClick={() => handleOpenExhibit(2)}
read={visited.has(2)}
disabled={!isEnabled(2)}
/>
{/* 光标3 */}
<Cursor
className="absolute right-135 bottom-168"
size={0.6}
onClick={() => handleOpenExhibit(3)}
read={visited.has(3)}
disabled={!isEnabled(3)}
/>
{/* 继续前进 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
setLastVisitedPlace('place3')
saveZhanglepingStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
/>
)}
{/* 展品1:代表作品与历史背景(打开展品1弹窗时显示对话) */}
<GuideSteps
visible={guideVisible1}
onVisibleChange={setGuideVisible1}
currentStep={guideStep1}
onStepChange={setGuideStep1}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_08.mp3')}
>
这幅发表在《抗战漫画》第12期上的《侵略者的坟墓》与刚才那幅《穷凶极恶的结果》一样,当时的国人看起来肯定特别提振精神!
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_09_1.mp3')}
>
这就是张乐平先生画作在当时发挥的最重要的作用!他的幅幅作品犹如刺刀钢枪,揭露日军暴行,抨击汉奸敌伪的卖国嘴脸,直至抗战的最后胜利。那时他常说“漫画是流动的宣传队”。
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_09_2.mp3')}
>
他的漫画还被译成多国文字,印成传单,空投敌占区,宣传正义,呼唤良知,这正是“笔铸刀枪”的力量!
</GuideStepsItem>
</GuideSteps>
{/* 展品3:张乐平画笔在不同历史时期的力量 */}
<GuideSteps
visible={guideVisible3}
onVisibleChange={(visible) => {
setGuideVisible3(visible)
// 对话结束后,弹出连线题(如未通过)
if (!visible && !quizPassed3) {
setQuizVisible3(true)
}
}}
currentStep={guideStep3}
onStepChange={setGuideStep3}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_06.mp3')}
>
这幅“穷凶极恶的结果”的漫画太形象了,小沪,聂耳先生用音乐唤醒大家的爱国情怀,那张乐平先生的画笔在不同年代里,是不是也为大家带来了不一样的力量呀?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_07_1.mp3')}
>
这是个很好的问题!张乐平先生的画笔在不同的历史时期发挥着不同作用,比如抗日战争时是“刀枪”,揭露日寇暴行;解放战争时是“镜子”,映照社会现实;新中国成立后是“园丁”,为万千儿童种下真善美的种子。
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_07_2.mp3')}
>
六十年笔墨不辍,他笔下不只是漫画故事,更是陪着一代代中国人长大的心灵成长书。张先生用画笔为我们带来了对社会深刻的洞察和对普通民众深切的关怀。
</GuideStepsItem>
</GuideSteps>
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onOpen={() => {
if (activeExhibitId === 3) {
triggerPuzzleAnimation(3)
}
if (activeExhibitId === 2) {
triggerPuzzleAnimation(4)
}
if (activeExhibitId === 1) {
triggerPuzzleAnimation(5)
}
}}
onClose={() => {
if (activeExhibitId !== null) {
// 展品1:关闭弹窗时(对话可能还在进行),先不关闭弹窗,等待对话完全结束
if (activeExhibitId === 1) {
// 如果对话还在显示,先关闭对话
if (guideVisible1) {
setGuideVisible1(false)
} else {
// 对话已经结束,关闭弹窗并播放视频
setActiveExhibitId(null)
setShowVideo1(true)
}
return
}
// 展品2:关闭后播放一段视频,视频结束再标记已访问
if (activeExhibitId === 2) {
setActiveExhibitId(null)
setShowVideo2(true)
return
}
// 展品3:需在完成连线题后才标记为已访问
if (activeExhibitId === 3 && quizPassed3) {
markExhibitVisited(place, 3)
}
}
setActiveExhibitId(null)
}}
/>
{/* 展品1关闭后的讲解视频 */}
{showVideo1 && (
<Video
ref={video1Ref}
src={getPublicPath('video/zhangleping/zhangleping_3.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo1(false)
setQuizVisible1(true)
markExhibitVisited(place, 1)
}}
onSkip={() => {
setShowVideo1(false)
setQuizVisible1(true)
}}
/>
)}
<ScaledPortal enabled={quizVisible1 || successAlert1Visible} zIndex={10050}>
<>
<SingleChoiceQuizPanel
visible={quizVisible1}
zIndex={10000}
title="【单选】抗战时期,张乐平先生的画笔是“刀枪”,他的作品主要传达了以下哪种精神?"
options={[
{
value: 'A',
label: 'A.逃避现实,只描绘美好的幻想世界,让大家暂时忘却痛苦',
},
{
value: 'B',
label: 'B.用幽默搞笑的方式逗大家开心,没有更深层次的意义',
},
{
value: 'C',
label: 'C.坚定地揭露日寇暴行,激发人们的爱国热情和抗争精神',
},
{
value: 'D',
label: 'D.只关注个人的艺术创作,不关心社会和国家的命运',
},
]}
correctAnswer="C"
onWrongSelect={(value) => {
if (value === 'A') {
return '逃避现实与他的实际行动不符。请你再想想吧!'
}
if (value === 'B') {
return '只强调幽默搞笑,忽略了作品的深刻意义。请你再想想吧!'
}
if (value === 'D') {
return '不关心社会和国家命运与张乐平先生的抗战宣传行为相悖。请你再想想吧!'
}
return '请你再想想吧!'
}}
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setQuizVisible1(false)
setSuccessAlert1Visible(true)
}}
/>
<SuccessAlert
visible={successAlert1Visible}
title="恭喜你,选择正确!"
content="在抗战时期,张乐平先生以抗日救亡漫画宣传队副领队身份北上宣传抗日,带领宣传队足迹遍及多地,他的漫画被译成多国文字,印成传单,空投敌占区,宣传正义,呼唤良知。这充分体现出他用画笔坚定地揭露日寇暴行,激发人们的爱国热情和抗争精神。"
onContinue={() => {
setSuccessAlert1Visible(false)
markExhibitVisited(place, 1)
}}
/>
</>
</ScaledPortal>
{/* 展品2关闭后的讲解视频 */}
{showVideo2 && (
<Video
ref={video2Ref}
src={getPublicPath('video/zhangleping/zhangleping_00.mp4')}
className="fixed inset-0 z-20000"
autoPlay
playsInline
showSkipButton
onEnded={() => {
setShowVideo2(false)
markExhibitVisited(place, 2)
}}
onSkip={() => {
setShowVideo2(false)
}}
/>
)}
{/* 展品3对话结束后的连线题 + 成功提示(使用 ScaledPortal) */}
<ScaledPortal enabled={quizVisible3 || successAlert3Visible} zIndex={10050}>
<>
<MatchQuizPanel
visible={quizVisible3}
title="【连线】张乐平先生的画笔在不同历史时期发挥着不同作用,请你把对应的历史时期与当时先生画笔发挥的主要作用匹配起来吧!"
leftItems={[
{ id: 'period1', label: '抗日战争' },
{ id: 'period2', label: '解放战争' },
{ id: 'period3', label: '新中国成立后' },
]}
rightItems={[
{ id: 'role2', label: '镜子' },
{ id: 'role3', label: '园丁' },
{ id: 'role1', label: '刀枪' },
]}
correctMatches={[
{ leftId: 'period1', rightId: 'role1' },
{ leftId: 'period2', rightId: 'role2' },
{ leftId: 'period3', rightId: 'role3' },
]}
errorMessage={(matches) => {
const getMatchByLeft = (leftId: string) => matches.find((m) => m.leftId === leftId)
const match1 = getMatchByLeft('period1')
if (match1 && match1.rightId !== 'role1') {
return '抗日战争时期,张乐平先生的画笔主要是揭露日寇暴行,似“刀枪”。请你再想想吧!'
}
const match2 = getMatchByLeft('period2')
if (match2 && match2.rightId !== 'role2') {
return '解放战争时期,张乐平先生的画笔主要是映照社会现实,似“镜子”。请你再想想吧!'
}
const match3 = getMatchByLeft('period3')
if (match3 && match3.rightId !== 'role3') {
return '新中国成立后,张乐平先生的画笔主要为万千儿童种下真善美的种子,似“园丁”。请你再想想吧!'
}
return '连线不太对,请你再仔细想一想吧!'
}}
onSubmit={(_matches, isCorrect) => {
if (isCorrect) {
setQuizVisible3(false)
setQuizPassed3(true)
setSuccessAlert3Visible(true)
markExhibitVisited(place, 3)
}
}}
zIndex={10000}
/>
<SuccessAlert
visible={successAlert3Visible}
title="恭喜你,匹配正确!"
onContinue={() => {
setSuccessAlert3Visible(false)
}}
/>
</>
</ScaledPortal>
</div>
)
}
export default ZhanglepingPlace2