chore: 更新素材和组件优化

- 更新展品图片(鲁迅/毛泽东/孙中山/黄炎培/聂耳/张乐平故居)
- 新增点击查看图标、报纸素材
- 优化视频组件、拼图组件、展品组件
- 更新陈云音频资源
- 新增输入验证文档和工具函数
- 组件布局和动画优化

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.8 committed 2026-06-21 21:50:40 +08:00
1 parent 73d02eb62d
commit 73f6df92ce
130 files changed
+2345 -759

No files matched your search

+22 -5
View File
@@ -4,9 +4,22 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { Image, Video } from '@/components'
import { useNavigate } from 'react-router-dom'
const INTRO_VIEWED_KEY = 'home-intro-viewed'
const Home = () => {
const navigate = useNavigate()
const [showIntroVideo, setShowIntroVideo] = useState(false)
const [introViewed, setIntroViewed] = useState(
() => localStorage.getItem(INTRO_VIEWED_KEY) === 'true'
)
const handleIntroClick = () => {
setShowIntroVideo(true)
if (!introViewed) {
setIntroViewed(true)
localStorage.setItem(INTRO_VIEWED_KEY, 'true')
}
}
return (
<div
@@ -23,11 +36,15 @@ const Home = () => {
exit={{ opacity: 0, scale: 0.92, x: 24 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
<Image
src={getStaticImage('home/课程简介')}
button
onClick={() => setShowIntroVideo(true)}
/>
<div className="relative">
<Image src={getStaticImage('home/课程简介')} button onClick={handleIntroClick} />
{!introViewed && (
<Image
className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none"
src={getStaticImage('components/点击查看')}
/>
)}
</div>
</motion.div>
)}
</AnimatePresence>
+18 -2
View File
@@ -1,8 +1,9 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image, PuzzleSystem } from '@/components'
import { useNavigate } from 'react-router-dom'
import { DEFAULT_PUZZLE_RESIDENCE } from '@/config/puzzleResidences'
import { DEFAULT_PUZZLE_RESIDENCE, PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
import { puzzleDataConfig } from '@/config/puzzleData'
import { usePuzzleStore } from '@/store/puzzleStore'
import { isMenuModuleComplete } from '@/config/progression'
@@ -12,6 +13,18 @@ const Menu = () => {
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
const courseProgress = useMemo(() => {
const totalPieces = PUZZLE_RESIDENCES.reduce((total, item) => {
return total + Object.keys(puzzleDataConfig[item.residence] ?? {}).length
}, 0)
const unlockedPieces = PUZZLE_RESIDENCES.reduce((total, item) => {
return total + (puzzles[item.residence]?.size ?? 0)
}, 0)
if (!isLoaded || totalPieces === 0) return 0
return Math.round((unlockedPieces / totalPieces) * 100)
}, [isLoaded, puzzles])
/**
* 类型:1:革命先驱、文化民人、艺术名家、经济巨擘
*/
@@ -61,6 +74,9 @@ const Menu = () => {
button
onClick={() => setActiveResidence(DEFAULT_PUZZLE_RESIDENCE)}
/>
<div className="absolute inset-0 flex items-center justify-center pb-8 pointer-events-none text-white text-[36px] font-bold leading-none drop-shadow-[0_2px_2px_rgba(0,0,0,0.8)]">
{courseProgress}%
</div>
</div>
{/* 进入具体故居的拼图系统(与故居页面一致的 PuzzleSystem) */}
+3 -2
View File
@@ -219,7 +219,6 @@ const ChenyunInternalization = () => {
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content=""
onContinue={() => {
setActiveSuccess(null)
@@ -334,7 +333,6 @@ const ChenyunInternalization = () => {
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你,选择正确!"
onContinue={() => {
setActiveSuccess(null)
const cb = resumeCallback
@@ -395,6 +393,9 @@ const ChenyunInternalization = () => {
anchors={anchors}
playsInline
controls
controlsOnReplay
skipKey="chenyun_internalization_video"
showReadonlyProgress
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
+2
View File
@@ -144,6 +144,8 @@ const ChenyunPlace3 = () => {
autoPlay
playsInline
showSkipButton
skipKey="chenyun_place3_video"
showReadonlyProgress
controls={false}
onEnded={() => {
setVideoVisible(false)
+2
View File
@@ -185,6 +185,8 @@ const ChenyunPlace5 = () => {
autoPlay
playsInline
showSkipButton
skipKey="chenyun_place5_video"
showReadonlyProgress
onEnded={() => {
setVideoVisible3(false)
setQuiz3Visible(true)
+4
View File
@@ -286,6 +286,8 @@ const ChenyunPlace7 = () => {
preload="auto"
muted={!video1Playing}
showSkipButton
skipKey="chenyun_place7_video1"
showReadonlyProgress
onEnded={handleVideo1Ended}
/>
</div>
@@ -350,6 +352,8 @@ const ChenyunPlace7 = () => {
preload="auto"
muted={!video2Playing}
showSkipButton
skipKey="chenyun_place7_video2"
showReadonlyProgress
skipText="跳过"
onEnded={handleVideo2Ended}
/>
+2
View File
@@ -122,6 +122,8 @@ const ChenyunPlace9 = () => {
preload="auto"
muted={!videoPlaying}
showSkipButton
skipKey="chenyun_place9_video"
showReadonlyProgress
skipText="跳过"
onEnded={() => {
setVideoPlaying(false)
@@ -60,6 +60,8 @@ export const VideoHandler = ({
src={videoConfig.src}
autoPlay
showSkipButton
skipKey={`chenyun_reflection_${playingVideo}`}
showReadonlyProgress
onEnded={onVideoEnd}
className="w-full h-full"
/>
+4 -2
View File
@@ -152,7 +152,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
time: 369, // 6:09
title: '',
prompt:
'【拖拽】当年黄炎培先生有推广国货的爱国情怀,如今我们的“国货”已经自强自立,请将其中的国产企业/品牌放置到空格处',
'【拖拽】当年黄炎培先生有推广国货的爱国情怀,如今我们的“国货”已经自强自立,请将以下属于中国的企业、品牌、装备放置到空格处:',
type: 'drag',
options: [
{ value: '特斯拉', label: '特斯拉' },
@@ -395,7 +395,6 @@ const HuangyanpeiInternalization = () => {
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null)
@@ -465,6 +464,9 @@ const HuangyanpeiInternalization = () => {
anchors={anchors}
playsInline
controls
controlsOnReplay
skipKey="huangyanpei_internalization_video"
showReadonlyProgress
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
+2
View File
@@ -332,6 +332,8 @@ const HuangyanpeiPlace4 = () => {
autoPlay
playsInline
showSkipButton
skipKey="huangyanpei_place4_video"
showReadonlyProgress
onEnded={() => {
setShowVideo(false)
setShowQuiz(true)
+2 -1
View File
@@ -152,12 +152,13 @@ const HuangyanpeiPlace5 = () => {
ref={videoRef}
src={getPublicPath('video/huangyanpei/huangyanpei_07.mp4')}
className="w-full h-full"
controls
autoPlay
playsInline
preload="auto"
showSkipButton
skipText="跳过"
skipKey="huangyanpei_place5_video"
showReadonlyProgress
onEnded={() => {
// 对话结束后显示题目
setShowQuiz(true)
@@ -262,7 +262,9 @@ const BambooPuzzleGame = ({ visible = true, onClose, onComplete }: BambooPuzzleG
>
毛竹拼单杠
</h1>
<p className="text-4xl text-white mt-10">请将下方的竹子拖到正确位置,拼出单杠!</p>
<p className="text-4xl text-white mt-10">
请将下方的竹子拖到正确位置,拼出单杠!
</p>
</div>
{/* 拼图区域 - 空白容器 */}
+1 -1
View File
@@ -135,7 +135,7 @@ const HuangyanpeiReflectionScene2 = () => {
}}
>
<Title
title="川沙小学堂"
title="浦东中学"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
+3 -1
View File
@@ -70,7 +70,7 @@ const HuangyanpeiReflectionScene3 = () => {
}}
>
<Title
title="川沙小学堂"
title="中华职业学校"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
@@ -240,6 +240,8 @@ const HuangyanpeiReflectionScene3 = () => {
autoPlay
playsInline
showSkipButton
skipKey="huangyanpei_reflection_scene3_video"
showReadonlyProgress
onEnded={() => {
setShowVideo(false)
saveHuangyanpeiStore()
@@ -210,6 +210,8 @@ const ViseAssemblyDialog = ({ visible = true, onClose, onComplete }: ViseAssembl
playsInline
preload="auto"
showSkipButton
skipKey="huangyanpei_reflection_vise_video"
showReadonlyProgress
skipText="跳过"
onEnded={handleVideoEnd}
/>
+3 -1
View File
@@ -73,7 +73,7 @@ const HuangyanpeiReflectionScene4 = () => {
}}
>
<Title
title="川沙小学堂"
title="中华职业学校"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
@@ -220,6 +220,8 @@ const HuangyanpeiReflectionScene4 = () => {
className="h-full w-full"
autoPlay
showSkipButton
skipKey="huangyanpei_reflection_scene4_video"
showReadonlyProgress
playsInline
onEnded={() => {
setShowVideo(false)
@@ -331,7 +331,7 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia
{/* 成功提示 */}
<SuccessAlert
title="恭喜你, 推销语设计成功!"
title="恭喜你,推销语设计成功!"
content="既突出了国货优势,又传递了爱国情怀,当年的商科学生也是这么做的。"
visible={successVisible}
onContinue={handleSuccessContinue}
+69 -18
View File
@@ -8,7 +8,6 @@ import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
import { usePuzzleStore } from '@/store/puzzleStore'
const SPOTLIGHT_PROMPT_STORAGE_PREFIX = 'learning-map:spotlight-clicked:'
interface HexagonProgressBarProps {
progress: number
}
@@ -96,21 +95,32 @@ const LearningMap = () => {
}, [completedResidence, unlockedMap])
useEffect(() => {
if (!completedResidence || !isLoaded || !completeMap[completedResidence]) {
if (!isLoaded) {
setSpotlightPromptResidence(null)
return
}
let latestCompleted: string | null = null
for (const { residence } of PUZZLE_RESIDENCES) {
if (isResidenceComplete(residence, puzzles)) {
latestCompleted = residence
}
}
if (!latestCompleted) {
setSpotlightPromptResidence(null)
return
}
const isKnownResidence = PUZZLE_RESIDENCES.some((item) => item.residence === completedResidence)
const isPromptClicked =
window.sessionStorage.getItem(`${SPOTLIGHT_PROMPT_STORAGE_PREFIX}${completedResidence}`) ===
window.sessionStorage.getItem(`${SPOTLIGHT_PROMPT_STORAGE_PREFIX}${latestCompleted}`) ===
'true'
setSpotlightPromptResidence(isKnownResidence && !isPromptClicked ? completedResidence : null)
}, [completeMap, completedResidence, isLoaded])
setSpotlightPromptResidence(isPromptClicked ? null : latestCompleted)
}, [isLoaded, puzzles])
const handleSpotlightClick = (residence: string, isUnlocked: boolean) => {
if (!isUnlocked) return
const handleSpotlightClick = (residence: string, isComplete: boolean) => {
if (!isComplete) return
if (residence === spotlightPromptResidence) {
window.sessionStorage.setItem(`${SPOTLIGHT_PROMPT_STORAGE_PREFIX}${residence}`, 'true')
@@ -147,6 +157,7 @@ const LearningMap = () => {
<div className="absolute inset-0">
{PUZZLE_RESIDENCES.map(({ residence, image, moduleName, left, top }) => {
const isUnlocked = unlockedMap[residence]
const isComplete = completeMap[residence]
const isNewlyUnlocked = residence === newlyUnlockedResidence
const showSpotlightPrompt = residence === spotlightPromptResidence
@@ -194,20 +205,60 @@ const LearningMap = () => {
<div className="relative flex justify-center gap-6">
{showSpotlightPrompt && (
<motion.img
className="absolute -right-10 -top-14 z-20 w-20 pointer-events-none"
src={getStaticImage('learning-map/箭头指引')}
alt="点击追光"
animate={{ y: [0, -8, 0], scale: [1, 1.08, 1] }}
transition={{ duration: 1, repeat: Infinity, ease: 'easeInOut' }}
/>
<div
className="absolute left-1/2 top-0 z-30 pointer-events-none"
style={{
transform: 'translate(-40%, 30%)',
willChange: 'transform',
}}
>
<div className="relative flex items-center justify-center">
<motion.span
className="absolute w-14 h-14 rounded-full bg-amber-400"
style={{ willChange: 'transform, opacity' }}
animate={{ scale: [0.55, 1.6], opacity: [0.5, 0] }}
transition={{
duration: 1.4,
repeat: Infinity,
ease: 'easeOut',
}}
/>
<motion.svg
viewBox="0 0 32 32"
className="relative w-10 h-10"
style={{
filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.45))',
willChange: 'transform',
}}
animate={{ scale: [1, 0.88, 1] }}
transition={{
duration: 1.2,
repeat: Infinity,
ease: 'easeInOut',
}}
>
<path
d="M9 5 L9 23 L14 19 L17 28 L20 27 L17 18 L24 18 Z"
fill="white"
stroke="#0f172a"
strokeWidth="2"
strokeLinejoin="round"
/>
</motion.svg>
</div>
</div>
)}
<Image
className={isUnlocked ? undefined : 'cursor-not-allowed'}
className={isComplete ? undefined : 'cursor-not-allowed'}
style={
isComplete
? undefined
: { opacity: 0.4, filter: 'grayscale(1) brightness(0.8)' }
}
src={getStaticImage('learning-map/追光入口')}
alt="追光"
button={isUnlocked}
onClick={() => handleSpotlightClick(residence, !!isUnlocked)}
button={isComplete}
onClick={() => handleSpotlightClick(residence, !!isComplete)}
/>
</div>
</motion.div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { Navigate, useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { isResidenceUnlocked } from '@/config/progression'
import { isResidenceComplete } from '@/config/progression'
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
import { getStaticImage } from '@/lib/utils'
import { usePuzzleStore } from '@/store/puzzleStore'
@@ -23,7 +23,7 @@ const SpotlightMap = () => {
return <div className="relative w-full h-full bg-black" />
}
if (!isResidenceUnlocked(residenceConfig.residence, puzzles)) {
if (!isResidenceComplete(residenceConfig.residence, puzzles)) {
return <Navigate to="/learning-map" replace />
}
+4 -1
View File
@@ -100,7 +100,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
id: 'quiz-3',
time: 60 * 6 + 39, // 6:39
title: '',
prompt: '【单选】以下那句诗出自鲁迅的作品:',
prompt: '【单选】以下哪句诗出自鲁迅的作品:',
type: 'single',
options: [
{
@@ -460,6 +460,9 @@ const LuxunInternalization = () => {
anchors={anchors}
playsInline
controls
controlsOnReplay
skipKey="luxun_internalization_video"
showReadonlyProgress
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
+1 -1
View File
@@ -146,7 +146,7 @@ const LuxunPlace3 = () => {
}}
>
<Title
title={`历史新编中的现实呐喊\n为亡友编纂的精神丰碑`}
title={`历史新编中的现实呐喊 为亡友编纂的精神丰碑`}
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
+2
View File
@@ -363,6 +363,8 @@ const LuxunPlace5 = () => {
autoPlay
playsInline
showSkipButton
skipKey="luxun_place4_video"
showReadonlyProgress
anchors={videoAnchors}
onEnded={() => {
setEndingGuideVisible(true)
+118 -78
View File
@@ -16,64 +16,64 @@ const dragItems = [
name: '闰土',
imagePath: 'luxun/reflection/scene2/拖拽-润土',
targetBlankId: 'city-1-label-0', // 绍兴-人物
errorHint: '❌闰土的故事和鲁迅的少年绍兴时光有关哦!',
successMessage: '✅答对啦! 闰土是鲁迅绍兴时期笔下的经典人物。',
errorHint: '❌闰土的故事和鲁迅的少年绍兴时光有关哦!',
successMessage: '✅答对啦!闰土是鲁迅绍兴时期笔下的经典人物。',
},
{
id: 'baicaoyuan',
name: '《从百草园到三味书屋》',
imagePath: 'luxun/reflection/scene2/拖拽-百草园到三味书屋',
targetBlankId: 'city-1-label-1', // 绍兴-作品
errorHint: '❌这个作品写的是鲁迅在绍兴的故事哦!',
successMessage: '✅真棒! 这篇散文记录了鲁迅在绍兴的求学时光。',
errorHint: '❌这个作品写的是鲁迅在绍兴的故事哦!',
successMessage: '✅真棒!这篇散文记录了鲁迅在绍兴的求学时光。',
},
{
id: 'aqzhengzhuan',
name: '《阿Q正传》',
imagePath: 'luxun/reflection/scene2/拖拽-阿Q正传',
targetBlankId: 'city-2-label-0', // 北京-作品
errorHint: '❌这部经典小说和鲁迅在北京的创作有关哦!',
successMessage: '✅答对啦! 《阿Q正传》是鲁迅小说的代表作,诞生于北京。',
errorHint: '❌这部经典小说和鲁迅在北京的创作有关哦!',
successMessage: '✅答对啦!《阿Q正传》是鲁迅小说的代表作,诞生于北京。',
},
{
id: 'jingshenshengli',
name: '“精神胜利法”',
imagePath: 'luxun/reflection/scene2/拖拽-精神胜利法',
targetBlankId: 'city-2-label-1', // 北京-核心主题
errorHint: '❌这个主题是《阿Q正传》的核心,和北京创作背景相关哦!',
successMessage: '✅没错! 《阿Q正传》画出了国人的灵魂,以便疗救。',
errorHint: '❌这个主题是《阿Q正传》的核心,和北京创作背景相关哦!',
successMessage: '✅没错!《阿Q正传》画出了国人的灵魂,以便疗救。',
},
{
id: 'guanzhudiceng',
name: '“关注底层”',
imagePath: 'luxun/reflection/scene2/关注底层',
targetBlankId: 'city-3-label-0', // 厦门-创作方向
errorHint: '❌这个创作方向是鲁迅在厦门的核心追求哦!',
successMessage: '✅答对啦!鲁迅在厦门任教时,依旧关注底层民众的命运。',
errorHint: '❌这个创作方向是鲁迅在厦门的核心追求哦!',
successMessage: '✅答对啦!鲁迅在厦门任教时,依旧关注底层民众的命运。',
},
{
id: 'zhaohuaxishi',
name: '《朝花夕拾》',
imagePath: 'luxun/reflection/scene2/拖拽-朝花夕拾',
targetBlankId: 'city-4-label-0', // 广州-作品集
errorHint: '❌这个作品集和鲁迅在广州的旅居时光有关哦!',
successMessage: '✅真棒!《朝花夕拾》部分篇章在广州白云楼定稿。',
errorHint: '❌这个作品集和鲁迅在广州的旅居时光有关哦!',
successMessage: '✅真棒!《朝花夕拾》部分篇章在广州白云楼定稿。',
},
{
id: 'gushixinbian',
name: '《故事新编》',
imagePath: 'luxun/reflection/scene2/拖拽-故事新编',
targetBlankId: 'city-5-label-0', // 上海-作品
errorHint: '❌再试试~这个作品和上海的创作故事更相关哦!',
successMessage: '✅答对啦!《故事新编》是鲁迅在上海故居完成的经典作品集。',
errorHint: '❌再试试~这个作品和上海的创作故事更相关哦!',
successMessage: '✅答对啦!《故事新编》是鲁迅在上海故居完成的经典作品集。',
},
{
id: 'biweidaoqiang',
name: '“笔为刀枪”',
imagePath: 'luxun/reflection/scene2/笔为刀枪',
targetBlankId: 'city-5-label-1', // 上海-创作中心
errorHint: '❌这个初心是鲁迅在上海创作的核心哦!',
successMessage: '✅没错!鲁迅在上海以笔为刃,批判黑暗的社会现实。',
errorHint: '❌这个初心是鲁迅在上海创作的核心哦!',
successMessage: '✅没错!鲁迅在上海以笔为刃,批判黑暗的社会现实。',
},
]
@@ -167,6 +167,8 @@ const LuxunReflectionScene2 = () => {
const [completionGuideVisible, setCompletionGuideVisible] = useState(false)
// 通关提示是否已经显示过(防止重复显示)
const [completionGuideShown, setCompletionGuideShown] = useState(false)
// 完成弹窗是否显示
const [showCompletionModal, setShowCompletionModal] = useState(false)
// 转换为 DragFill 的 options
const options = useMemo(
@@ -280,14 +282,20 @@ const LuxunReflectionScene2 = () => {
setSuccessMessage('')
}
// 当所有任务完成且成功提示关闭后,显示通关提示(只显示一次)
// 当所有任务完成且成功提示关闭后,显示完成弹窗(只显示一次)
useEffect(() => {
if (allCompleted && !showSuccessAlert && !completionGuideShown) {
setCompletionGuideVisible(true)
setShowCompletionModal(true)
setCompletionGuideShown(true)
}
}, [allCompleted, showSuccessAlert, completionGuideShown])
// 关闭完成弹窗后显示通关提示
const handleCompletionModalClose = () => {
setShowCompletionModal(false)
setCompletionGuideVisible(true)
}
// 获取每个标签区域已放置的元素
const getLabelPlacedItem = (cityId: number, labelIndex: number) => {
const blankId = `city-${cityId}-label-${labelIndex}`
@@ -401,7 +409,7 @@ const LuxunReflectionScene2 = () => {
>
{/* 任务完成后显示点一点图标 */}
{allCompleted && (
<div className="absolute -right-20 top-1/2 flex items-center gap-2 z-10 pointer-events-none">
<div className="absolute -right-20 top-[calc(50%+26px)] flex items-center gap-2 z-10 pointer-events-none">
<img
className="animate-finger-bounce scale-x-[-1]"
src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟点一点图标`)}
@@ -415,32 +423,20 @@ const LuxunReflectionScene2 = () => {
{/* 右侧区域 */}
<div className="flex-1 relative">
{/* 任务进度 */}
<div className="absolute top-4 left-[27%] w-[430px] h-51 flex justify-center items-center">
<div className="absolute top-6 left-[24.5%] w-[430px] h-51 flex justify-center items-center">
<span className="text-white text-[54px] font-bold [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
{allCompleted ? '任务进度已完成' : `任务进度:(${progress}/${totalTasks})`}
</span>
</div>
{/* 鲁迅画廊 */}
<div className="absolute top-[calc(2.4rem+204px)] left-0 w-[995px] h-[467px] pt-16 pl-70 pr-10">
{/* 提示图标 */}
<div className="absolute left-14 top-35 space-y-2 z-10 pointer-events-none">
<div className="flex items-center">
<img
src={getStaticImage(`${starName}/reflection/scene2/鲁迅体悟拖一拖`)}
alt="拖一拖"
/>
<img
src={getStaticImage(`${starName}/reflection/scene2/鲁迅体悟拖一拖图标`)}
alt="拖一拖图标"
/>
</div>
<div className="w-45 leading-relaxed tracking-wider text-black text-3xl font-bold">
请将右侧图标拖到左侧对应位置。
</div>
{/* 拖拽区域 */}
<div className="absolute top-[calc(2.4rem+214px)] h-206 left-0 w-full px-16">
{/* 提示文字 */}
<div className="my-16 text-yellow-600 text-5xl font-bold text-center">
请将下面图标拖到左侧对应的位置。
</div>
{/* 使用 DragFill 组件 */}
<div className="mt-10">
<div className="w-full max-w-[1000px] mx-auto">
<DragFill
options={options}
blanks={blanks}
@@ -451,7 +447,7 @@ const LuxunReflectionScene2 = () => {
layout="horizontal-options"
hideLabels={true}
className="w-full"
optionClassName="grid grid-cols-4 gap-10"
optionClassName="grid grid-cols-4 gap-x-14 gap-y-14"
blankClassName="hidden" // 隐藏默认的空白区域,我们在地图上自定义显示
renderOption={(option, isUsed) => {
const item = dragItems.find((item) => item.id === option.value)
@@ -459,16 +455,19 @@ const LuxunReflectionScene2 = () => {
return (
<div
className={`relative transition-all duration-200 cursor-move ${
className={`relative transition-all duration-200 cursor-move flex flex-col items-center gap-4 ${
isUsed ? 'opacity-30' : 'hover:scale-105'
}`}
>
<img
src={getStaticImage(item.imagePath)}
alt={item.name}
className="w-full h-auto"
className="w-[150px] h-[150px] object-contain"
draggable={false}
/>
<div className="text-black text-3xl font-bold text-center leading-tight px-2 min-w-[160px] max-w-[200px]">
{item.name}
</div>
</div>
)
}}
@@ -494,43 +493,6 @@ const LuxunReflectionScene2 = () => {
/>
</div>
</div>
{/* 任务展示窗口 */}
<div className="absolute bottom-3.5 right-8 w-[995px] h-[335px] px-10 py-4 overflow-y-auto luxun-scrollbar">
{allCompleted ? (
// 全部完成后显示详细信息
<div className="space-y-8">
{cityConfig.map((city) => (
<div key={city.id} className="space-y-4">
<div className="text-red-600 text-5xl font-bold flex items-end gap-6">
<div>{city.location}</div>
<div className="text-gray-600 text-4xl font-normal">{city.time}</div>
</div>
<div className="text-black text-4xl font-normal leading-relaxed">
{city.description}
</div>
</div>
))}
</div>
) : (
// 未完成时显示提示内容
<div className="space-y-8 text-center">
<div className="text-black text-5xl font-bold mb-10">任务展示窗口</div>
{/* <div className="text-red-600 text-4xl font-bold">
请将以下内容拖拽至正确位置
</div> */}
<div className="grid grid-cols-4 gap-4 text-3xl font-bold">
{dragItems.map((item) => {
const isPlaced = dragFillValue.some((result) => result.optionValue === item.id)
return (
<div key={item.id} className={`${isPlaced ? 'text-green-600' : 'text-black'}`}>
{item.name}
</div>
)
})}
</div>
</div>
)}
</div>
</div>
{/* 错误提示弹窗 */}
@@ -548,6 +510,84 @@ const LuxunReflectionScene2 = () => {
onClose={handleSuccessAlertClose}
/>
{/* 完成弹窗 */}
{showCompletionModal && (
<div className="fixed inset-0 z-[10001] flex items-center justify-center bg-black/60">
<div
className="relative w-[1200px] p-12 bg-cover bg-center bg-no-repeat rounded-2xl shadow-2xl"
style={{
backgroundImage: `linear-gradient(to bottom, rgba(255, 248, 220, 0.95), rgba(255, 248, 220, 0.98)), url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcz48cGF0dGVybiBpZD0icGFwZXIiIHBhdHRlcm5Vbml0cz0idXNlclNwYWNlT25Vc2UiIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2ZmZjhkYyIvPjwvcGF0dGVybj48L2RlZnM+PHJlY3Qgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiIGZpbGw9InVybCgjcGFwZXIpIi8+PC9zdmc+)`,
border: '4px solid #8B4513',
boxShadow: '0 0 30px rgba(139, 69, 19, 0.4), inset 0 0 50px rgba(218, 165, 32, 0.2)',
}}
>
{/* 装饰性边框 */}
<div className="absolute inset-4 border-2 border-yellow-700/40 rounded-xl pointer-events-none" />
{/* 关闭按钮 */}
<button
onClick={handleCompletionModalClose}
className="absolute top-6 right-6 w-12 h-12 flex items-center justify-center text-red-700 hover:text-red-900 text-5xl font-bold bg-yellow-100/80 rounded-full hover:bg-yellow-200/80 transition-colors z-10"
style={{ lineHeight: '1' }}
>
×
</button>
{/* 标题 */}
<div className="text-center mb-8">
<div
className="text-6xl font-bold mb-2"
style={{
background: 'linear-gradient(to bottom, #8B0000, #DC143C)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textShadow: '2px 2px 4px rgba(139, 0, 0, 0.3)',
}}
>
恭喜完成任务!
</div>
<div className="text-3xl text-yellow-800 font-semibold mt-6">
鲁迅的文学足迹已然清晰
</div>
</div>
{/* 两列布局 */}
<div className="grid grid-cols-2 gap-8 px-4">
{cityConfig.map((city, index) => (
<div
key={city.id}
className="bg-gradient-to-r from-yellow-50/80 to-amber-50/80 p-6 rounded-xl border-2 border-yellow-600/30 shadow-md hover:shadow-lg transition-shadow"
style={{
animation: `fadeIn 0.5s ease-out ${index * 0.1}s both`,
}}
>
<div className="flex items-end gap-4 mb-3">
<div className="text-red-700 text-4xl font-bold">{city.location}</div>
<div className="text-amber-700 text-2xl font-medium pb-0.5">{city.time}</div>
</div>
<div className="text-gray-800 text-2xl leading-relaxed font-normal pl-2 border-l-4 border-red-600/40">
{city.description}
</div>
</div>
))}
</div>
</div>
<style>{`
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</div>
)}
{/* 学习引导对话 */}
<GuideSteps
visible={guideVisible}
+11 -7
View File
@@ -1,5 +1,5 @@
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Image, GuideSteps, GuideStepsItem, StudyGuideModal, EndingOverlay } from '@/components'
import { SingleChoiceQuizPanel } from '@/components/layout/quiz'
@@ -16,6 +16,8 @@ type HintPosition = 'luxun1' | 'guanzhu' | 'piping' | 'jiaguo' | 'luxun2' | 'non
const LuxunReflectionScene3 = () => {
//碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const triggerPuzzleAnimationRef = useRef(triggerPuzzleAnimation)
triggerPuzzleAnimationRef.current = triggerPuzzleAnimation
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
@@ -32,6 +34,7 @@ const LuxunReflectionScene3 = () => {
const [completedQuiz3, setCompletedQuiz3] = useState(false) // 题目3是否完成
const [showSummary, setShowSummary] = useState(false) // 是否显示总结
const [completionGuideVisible, setCompletionGuideVisible] = useState(false) // 通关提示对话是否显示
const [completionGuideQueued, setCompletionGuideQueued] = useState(false) // 通关提示是否已排队显示
const [showGuideModal, setShowGuideModal] = useState(true)
// 成功提示相关状态
@@ -47,14 +50,14 @@ const LuxunReflectionScene3 = () => {
// 当显示总结后,延迟1秒显示通关提示对话
useEffect(() => {
if (showSummary) {
if (completionGuideQueued) {
const timer = setTimeout(() => {
setCompletionGuideVisible(true)
triggerPuzzleAnimation(15)
triggerPuzzleAnimationRef.current(15)
}, 500)
return () => clearTimeout(timer)
}
}, [showSummary, triggerPuzzleAnimation])
}, [completionGuideQueued])
// 点击"鲁迅1"的处理
const handleLuxun1Click = () => {
@@ -121,8 +124,9 @@ const LuxunReflectionScene3 = () => {
// 点击"鲁迅2"的处理
const handleLuxun2Click = () => {
if (allCompleted) {
if (allCompleted && !showSummary) {
setShowSummary(true)
setCompletionGuideQueued(true)
setHintPosition('none') // 隐藏提示图标
}
}
@@ -370,7 +374,7 @@ const LuxunReflectionScene3 = () => {
{ value: 'B', label: 'B. 生活中的小美好' },
]}
correctAnswer="A"
errorMessage="不是哦,先生的小说核心是批判而非歌颂哦!请结合阿Q的碎片回忆一下吧!"
errorMessage="不是哦,先生的小说核心是批判而非歌颂哦!请结合阿Q的碎片回忆一下吧!"
onSubmit={handleQuiz2Submit}
/>
@@ -383,7 +387,7 @@ const LuxunReflectionScene3 = () => {
{ value: 'B', label: 'B. 只为个人成名' },
]}
correctAnswer="A"
errorMessage="不是哦,先生的创作从不是为了自己哦!请回忆先生“弃医从文”的初心。"
errorMessage="不是哦,先生的创作从不是为了自己哦!请回忆先生“弃医从文”的初心。"
onSubmit={handleQuiz3Submit}
/>
+5
View File
@@ -403,6 +403,8 @@ const MaozedongInternalization = () => {
className="h-full w-full z-20"
playsInline
showSkipButton
skipKey="maozedong_internalization_intro"
showReadonlyProgress
autoPlay
onEnded={() => {
setShowIntroVideo(false)
@@ -482,6 +484,9 @@ const MaozedongInternalization = () => {
anchors={anchors}
playsInline
controls
controlsOnReplay
skipKey="maozedong_internalization_main"
showReadonlyProgress
autoPlay
onCanPlay={handleVideoCanPlay}
onEnded={() => {
+2
View File
@@ -217,6 +217,8 @@ const MaozedongPlace1 = () => {
autoPlay
playsInline
showSkipButton
skipKey="maozedong_place1_video"
showReadonlyProgress
onEnded={() => {
setShowVideo2(false)
setQuizVisible(true)
+2
View File
@@ -213,6 +213,8 @@ const MaozedongPlace2 = () => {
autoPlay
playsInline
showSkipButton
skipKey="maozedong_place2_video"
showReadonlyProgress
onEnded={() => {
setShowVideo(false)
setQuizVisible(true)
+2
View File
@@ -245,6 +245,8 @@ const MaozedongPlace3 = () => {
autoPlay
playsInline
showSkipButton
skipKey="maozedong_place3_video"
showReadonlyProgress
onEnded={() => {
setShowVideo3(false)
setQuiz3Visible(true)
+6 -2
View File
@@ -305,13 +305,13 @@ const MaozedongPlace4 = () => {
speaker="guide"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_18_01.mp3')}
>
对!无产阶级要领导革命,必须联合农民——他们是工人阶级的“天然盟友”。四大虽然还没有明确提出“农村包围城市”,但已经意识到农民问题的重要性。
对!无产阶级要领导革命,必须联合农民——他们是工人阶级的“天然盟友”。中共四大虽然还没有明确提出“农村包围城市”,但已经意识到农民问题的重要性。
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_18_02.mp3')}
>
后来毛主席常说:“国民革命的中心问题,就是农民问题,一切都要靠农民问题的解决。”这句话的种子,就是在四大前后埋下的。
后来毛主席常说:“国民革命的中心问题,就是农民问题,一切都要靠农民问题的解决。”这句话的种子,就是在中共四大前后埋下的。
</GuideStepsItem>
</GuideSteps>
@@ -434,6 +434,8 @@ const MaozedongPlace4 = () => {
autoPlay
playsInline
showSkipButton
skipKey="maozedong_place4_video1"
showReadonlyProgress
onSkip={() => {
setShowVideo4(false)
setGuide4SummaryStep(0)
@@ -456,6 +458,8 @@ const MaozedongPlace4 = () => {
autoPlay
playsInline
showSkipButton
skipKey="maozedong_place4_video2"
showReadonlyProgress
onEnded={() => {
setShowVideo2(false)
setQuizStep(1)
+3 -3
View File
@@ -44,7 +44,7 @@ const firstQuizOptions = [
},
{
value: 'B',
label: 'B. 深入基层,收集各地党组织、工农运动实情,再推进筹备等备',
label: 'B. 深入基层,收集各地党组织、工农运动实情,再推进筹备',
},
]
@@ -55,7 +55,7 @@ const secondQuizOptions = [
},
{
value: 'B',
label: 'B. 讲清意见对四大讨论无产阶级领导权、工农联盟的重要性,以革命大义动员党员',
label: 'B. 讲清意见对四大讨论无产阶级领导权、工农联盟的重要性,以革命大义动员',
},
]
@@ -487,7 +487,7 @@ const Home = () => {
visible={endingVisible}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_23.mp3')}
animationSpeed={200}
text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因身体健康原因未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。"
text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因身体健康问题未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。"
onContinue={() => {
setEndingVisible(false)
saveMaozedongStore()
+4 -1
View File
@@ -199,7 +199,7 @@ const NieerInternalization = () => {
/>
<SuccessAlert
visible={activeSuccess === item.id}
title={item.id === 'quiz-4' ? '恭喜你, 判断正确!' : '恭喜你, 选择正确!'}
title={item.id === 'quiz-4' ? '恭喜你,判断正确!' : '恭喜你,选择正确!'}
content={successContent}
onContinue={() => {
setActiveSuccess(null)
@@ -326,6 +326,9 @@ const NieerInternalization = () => {
anchors={anchors}
playsInline
controls
controlsOnReplay
skipKey="nieer_internalization_video"
showReadonlyProgress
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
+5 -3
View File
@@ -216,13 +216,13 @@ const NieerPlace1 = () => {
这里提到聂耳发表了《中国歌舞短论》,批评脱离民众的音乐,这是为什么呢?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_06_1.mp3')}>
你观察得很细致!这时的聂耳在明月歌舞剧社做练习生,社长李锦晖看中了他的潜力,虽然他当时小提琴水平不算专业,还是录取了他,还安排首席小提琴手专门辅导他。聂耳特别刻苦,每天至少练7个小时小提琴,很快就成长为首席小提琴手。
你观察得很细致!这时的聂耳在明月歌舞剧社做练习生,社长李锦晖看中了他的潜力,虽然他当时小提琴水平不算专业,但还是录取了他,还安排首席小提琴手专门辅导他。聂耳特别刻苦,每天至少练7个小时小提琴,很快就成长为首席小提琴手。
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_06_2.mp3')}>
但好景不长,1931年九·一八事变后,全国抗日救亡浪潮高涨,可还存在很多脱离现实的靡靡之音,这让满怀爱国热忱的聂耳特别愤懑。
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_06_3.mp3')}>
1932年,他以“黑天使”为笔名在《电影艺术》杂志刊登《中国歌舞短论》,猛烈评击“软豆腐”式的音乐,提出我们需要“真刀真枪的硬功夫”,主张音乐要贴近民众、为抗日救亡服务。后来他离开了歌舞剧社。
1932年,他以“黑天使”为笔名在《电影艺术》杂志刊登《中国歌舞短论》,猛烈抨击“软豆腐”式的音乐,提出我们需要“真刀真枪的硬功夫”,主张音乐要贴近民众、为抗日救亡服务。后来他离开了歌舞剧社。
</GuideStepsItem>
</GuideSteps>
@@ -315,6 +315,8 @@ const NieerPlace1 = () => {
autoPlay
playsInline
showSkipButton
skipKey="nieer_place1_video"
showReadonlyProgress
onEnded={() => {
if (video2Step === 1) {
setVideo2Step(2)
@@ -350,7 +352,7 @@ const NieerPlace1 = () => {
},
]}
correctAnswer="B"
errorMessage="“软豆腐”式的音乐是聂耳猛烈评击的音乐类型。请你再想想吧!"
errorMessage="“软豆腐”式的音乐是聂耳猛烈抨击的音乐类型。请你再想想吧!"
onSubmit={(_value, isCorrect) => {
if (isCorrect) {
setQuiz1Visible(false)
+2
View File
@@ -194,6 +194,8 @@ const NieerPlace2 = () => {
autoPlay
playsInline
showSkipButton
skipKey="nieer_place2_video"
showReadonlyProgress
onEnded={() => {
markExhibitVisited(place, 2)
setShowVideo2(false)
+2
View File
@@ -245,6 +245,8 @@ const NieerPlace3 = () => {
autoPlay
playsInline
showSkipButton
skipKey="nieer_place3_video"
showReadonlyProgress
onEnded={() => {
const id = videoForExhibitId
setShowVideo(false)
+1 -1
View File
@@ -403,7 +403,7 @@ const NieerPlace5 = () => {
<SuccessAlert
visible={success3_1Visible}
title="恭喜你, 匹配正确!"
title="恭喜你,匹配正确!"
onClose={() => {
setSuccess3_1Visible(false)
setQuiz3_2Visible(true)
+4 -1
View File
@@ -200,7 +200,10 @@ const ContentDialog = ({ starName, title, scene, onClose, onEnterScene }: Conten
src={getPublicPath(currentPage.src)}
autoPlay
playsInline
controls
showSkipButton
skipText="跳过"
skipKey={`nieer_reflection_scene${scene.id}_video`}
showReadonlyProgress
className="w-full h-full"
onLoad={() => setContentLoaded(true)}
onEnded={handleVideoEnded}
@@ -453,6 +453,9 @@ const RongdeshengInternalization = () => {
anchors={anchors}
playsInline
controls
controlsOnReplay
skipKey="rongdesheng_internalization_video"
showReadonlyProgress
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
+8
View File
@@ -211,6 +211,8 @@ const RongdeshengPlace2 = () => {
autoPlay
playsInline
showSkipButton
skipKey="rongdesheng_place2_video1"
showReadonlyProgress
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
@@ -222,6 +224,8 @@ const RongdeshengPlace2 = () => {
autoPlay
playsInline
showSkipButton
skipKey="rongdesheng_place2_video2"
showReadonlyProgress
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
@@ -233,6 +237,8 @@ const RongdeshengPlace2 = () => {
autoPlay
playsInline
showSkipButton
skipKey="rongdesheng_place2_video3"
showReadonlyProgress
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
@@ -244,6 +250,8 @@ const RongdeshengPlace2 = () => {
autoPlay
playsInline
showSkipButton
skipKey="rongdesheng_place2_video4"
showReadonlyProgress
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
+2
View File
@@ -186,6 +186,8 @@ const RongdeshengPlace3 = () => {
autoPlay
playsInline
showSkipButton
skipKey="rongdesheng_place3_video"
showReadonlyProgress
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
+4
View File
@@ -206,6 +206,8 @@ const RongdeshengPlace4 = () => {
autoPlay
playsInline
showSkipButton
skipKey="rongdesheng_place4_video1"
showReadonlyProgress
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
@@ -218,6 +220,8 @@ const RongdeshengPlace4 = () => {
autoPlay
playsInline
showSkipButton
skipKey="rongdesheng_place4_video2"
showReadonlyProgress
onEnded={handleEndingVideoEnded}
onSkip={handleEndingVideoEnded}
/>
+14 -2
View File
@@ -2,7 +2,7 @@ import { Image, StudyGuideModal, Video } from '@/components'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useRongdeshengStore } from '@/store/rongdeshengStore'
import { saveRongdeshengStore, useRongdeshengStore } from '@/store/rongdeshengStore'
import { FinalScene } from './components/FinalScene'
import { HintPanel } from './components/HintPanel'
import { ModuleNavigation } from './components/ModuleNavigation'
@@ -214,6 +214,7 @@ const RongdeshengReflection = () => {
},
}))
markReflectionVideoViewed(currentModuleId, videoId)
saveRongdeshengStore()
setVideoPlayingState({ isPlaying: false, currentVideoId: null, currentVideoSrc: null })
}
@@ -225,6 +226,7 @@ const RongdeshengReflection = () => {
[currentModuleId]: { ...prev[currentModuleId], completed: true },
}))
markReflectionCompleted(currentModuleId)
saveRongdeshengStore()
if (currentModuleId === 'scene3') {
triggerPuzzleAnimation(14)
}
@@ -322,6 +324,12 @@ const RongdeshengReflection = () => {
src={videoPlayingState.currentVideoSrc}
autoPlay
showSkipButton
skipKey={
videoPlayingState.currentVideoId
? `rongdesheng_reflection_${videoPlayingState.currentVideoId}`
: undefined
}
showReadonlyProgress
onEnded={handleVideoEnded}
className="fixed inset-0 z-50 w-full h-full bg-black"
/>
@@ -378,8 +386,11 @@ const RongdeshengReflection = () => {
setCourseTimeProgress(20)
setFinalVideoStage('ended')
markReflectionFinalCompleted()
saveRongdeshengStore()
}}
onExit={() => {
navigate(`/${moduleName}/${starName}/home`)
}}
onExit={() => navigate(`/${moduleName}/${starName}/home`)}
/>
{/* 学习引导弹窗:首次进入时显示 */}
@@ -388,6 +399,7 @@ const RongdeshengReflection = () => {
onContinue={() => {
setShowGuide(false)
markReflectionGuideShown()
saveRongdeshengStore()
}}
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_31.mp3')}
firstLineIndent="2em"
@@ -85,8 +85,7 @@ export const MODULES: Module[] = [
resultContent:
'护厂抗争的胜利,是民族工业坚守爱国初心的体现,也是国人联合“反抗压迫、团结抗争”的印证!',
resultImage: 'rongdesheng/reflection/场景2结算',
wrongHint:
'荣氏兄弟坚守“实业救国”初心,拒绝洋行干涉企业经营,不愿民族产业沦为外资附庸!试试其他方案吧~',
wrongHint: '荣氏兄弟坚守“实业救国”初心,绝不会将民族产业拱手让人!试试其他方案吧~',
audio: { wrongHint: '' },
},
{
@@ -94,7 +93,7 @@ export const MODULES: Module[] = [
title: '民生坚守',
backgroundImage: 'rongdesheng/reflection/场景3选中',
question:
'时局动荡,棉花原料涨价,工厂盈利下滑,职工生活面临困难。荣德生面对既要维持生产,又要保障职工生活,该怎么做?',
'时局动荡,棉花原料涨价,工厂盈利下滑,职工生活面临困难。既要维持生产,又要保障职工生活,该怎么做?',
options: [
{ id: 'opt3-1', text: 'A.削减职工工资,降低运营成本', isCorrect: false },
{
@@ -176,7 +175,7 @@ export const MODULES: Module[] = [
title: '革新突围',
backgroundImage: 'rongdesheng/reflection/场景5选中',
question:
'抗战胜利后,洋纱、洋布卷土重来,申新设备陈旧、技术落后,面临产品竞争力不足的困境,荣德生如何通过技术革新让民族纱厂站稳脚跟?',
'抗战胜利后,洋纱、洋布卷土重来,申新设备陈旧、技术落后,面临产品竞争力不足的困境,如何通过技术革新让民族纱厂站稳脚跟?',
options: [
{
id: 'opt5-1',
+3 -1
View File
@@ -315,7 +315,6 @@ const SunzhongshanInternalization = () => {
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content=""
onContinue={() => {
setActiveSuccess(null)
@@ -466,6 +465,9 @@ const SunzhongshanInternalization = () => {
anchors={anchors}
playsInline
controls
controlsOnReplay
skipKey="sunzhongshan_internalization_video"
showReadonlyProgress
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
+5 -1
View File
@@ -441,6 +441,8 @@ const SunzhongshanPlace1 = () => {
autoPlay
playsInline
showSkipButton
skipKey="sunzhongshan_place1_video1"
showReadonlyProgress
onEnded={() => {
setShowVideo4(false)
setDragQuiz4Visible(true)
@@ -513,6 +515,8 @@ const SunzhongshanPlace1 = () => {
autoPlay
playsInline
showSkipButton
skipKey="sunzhongshan_place1_video2"
showReadonlyProgress
onEnded={() => {
setShowVideo4Second(false)
setQuiz3Visible(true)
@@ -569,7 +573,7 @@ const SunzhongshanPlace1 = () => {
中华民国的建立有个重要背景——“南北议和”。辛亥革命中宣告独立的省份大多集中在中国南方,负隅顽抗的清政府将最后希望寄托在主导北洋军阀势力的袁世凯身上。在列强支持下,袁世凯率军南下。
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={audio('17_2')}>
他一边以武力威胁革命势力,一边又迎合革命党人急于完成统一的愿望,诱使其开展和议。当时,革命党人力量还不够强。为避免国家陷入前途未卜的内战,孙中山先生发表声明:只要清帝退位,袁世凯赞成共和,就推举他做大总统。
他一边以武力威胁革命势力,一边又迎合革命党人急于完成统一的愿望,诱使其开展和议。当时,革命党人力量还不够强,为避免国家陷入前途未卜的内战,孙中山先生发表声明:只要清帝退位,袁世凯赞成共和,就推举他做大总统。
</GuideStepsItem>
</GuideSteps>
+4
View File
@@ -271,6 +271,8 @@ const SunzhongshanPlace2 = () => {
autoPlay
playsInline
showSkipButton
skipKey="sunzhongshan_place2_video1"
showReadonlyProgress
onEnded={() => {
setShowVideo1(false)
setSingleQuiz1Visible(true)
@@ -403,6 +405,8 @@ const SunzhongshanPlace2 = () => {
autoPlay
playsInline
showSkipButton
skipKey="sunzhongshan_place2_video2"
showReadonlyProgress
onEnded={() => {
setShowVideo2(false)
setSingleQuiz2Visible(true)
+2
View File
@@ -293,6 +293,8 @@ const SunzhongshanPlace3 = () => {
autoPlay
playsInline
showSkipButton
skipKey="sunzhongshan_place3_video"
showReadonlyProgress
onEnded={() => {
setShowVideo2(false)
setDragQuiz2Visible(true)
+2
View File
@@ -167,6 +167,8 @@ const SunzhongshanPlace4 = () => {
autoPlay
playsInline
showSkipButton
skipKey="sunzhongshan_place4_video"
showReadonlyProgress
onEnded={() => {
setShowVideo1(false)
// 视频播放结束后,显示学习引导
+45 -21
View File
@@ -86,10 +86,13 @@ const SunzhongshanReflectionScene1 = () => {
const [showQuiz3, setShowQuiz3] = useState(false)
const [showCompleteGuide, setShowCompleteGuide] = useState(false)
const [pendingQuiz, setPendingQuiz] = useState<'quiz2' | 'quiz3' | 'complete' | null>(null)
const [hasClickedClueHint, setHasClickedClueHint] = useState(false)
const [hasClickedClueBook, setHasClickedClueBook] = useState(false)
const { collectClue, isClueCollected } = useSunzhongshanStore()
const containerRef = useRef<HTMLDivElement>(null)
const clueBookButtonRef = useRef<HTMLDivElement>(null)
const backpackButtonRef = useRef<HTMLDivElement>(null)
// 为每个线索创建 ref
const clueRefs = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(new Map())
@@ -147,13 +150,10 @@ const SunzhongshanReflectionScene1 = () => {
// 收集线索1
collectClue(clue1Id, clue1Name)
// 获取线索1的 ref
const clue1Ref = getClueRef(clue1Id)
// 执行飞行动画
if (clue1Ref.current && clueBookButtonRef.current) {
// 执行飞行动画 - 从背包按钮开始
if (backpackButtonRef.current && clueBookButtonRef.current) {
flyToAnimation({
fromRef: clue1Ref as React.RefObject<HTMLElement>,
fromRef: backpackButtonRef as React.RefObject<HTMLElement>,
toRef: clueBookButtonRef as React.RefObject<HTMLElement>,
targetPosition: 'center',
duration: 1200,
@@ -168,6 +168,7 @@ const SunzhongshanReflectionScene1 = () => {
// 处理线索提示按钮点击 - 打开线索提示框
const handleClueHintClick = () => {
setHasClickedClueHint(true)
setShowClueHint(true)
}
@@ -341,11 +342,9 @@ const SunzhongshanReflectionScene1 = () => {
style={{
top: clue.position.top,
left: clue.position.left,
width: clue.position.width,
height: clue.position.height,
}}
>
<div className="relative">
<div className="relative inline-block">
<Image
src={getClueImage(clue.id)}
className="w-20 h-20 border-2 border-yellow-400 rounded shadow-lg"
@@ -377,22 +376,47 @@ const SunzhongshanReflectionScene1 = () => {
{/* 右下角-功能处 */}
<div className="absolute right-6 bottom-12 flex flex-col items-center gap-4 z-10">
<Image
src={getStaticImage(`${starName}/reflection/线索提示`)}
button
onClick={handleClueHintClick}
/>
<Image
src={getStaticImage(`${starName}/reflection/背包`)}
button
onClick={handleBackpackClick}
/>
<div className="relative">
<Image
src={getStaticImage(`${starName}/reflection/线索提示`)}
button
onClick={handleClueHintClick}
/>
{!hasClickedClueHint && (
<Image
className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none"
src={getStaticImage('components/点击查看')}
/>
)}
</div>
<div ref={backpackButtonRef} className="relative">
<Image
src={getStaticImage(`${starName}/reflection/背包`)}
button
onClick={handleBackpackClick}
/>
{hasClickedClueHint && !isClueCollected(`${sceneId}/clue1`) && (
<Image
className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none"
src={getStaticImage('components/点击查看')}
/>
)}
</div>
<div ref={clueBookButtonRef} className="relative" data-clue-book-button="true">
<Image
src={getStaticImage(`${starName}/reflection/线索簿`)}
button
onClick={() => setShowClueBook(true)}
onClick={() => {
setHasClickedClueBook(true)
setShowClueBook(true)
}}
/>
{hasClickedClueHint && isClueCollected(`${sceneId}/clue1`) && !hasClickedClueBook && (
<Image
className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none"
src={getStaticImage('components/点击查看')}
/>
)}
</div>
</div>
@@ -452,7 +476,7 @@ const SunzhongshanReflectionScene1 = () => {
{/* 题目1:今天的日期是? */}
<SingleChoiceQuizPanel
visible={showQuiz1}
title="【单选】今天的日期是?"
title="【单选】今天的日期是:"
options={[
{ value: 'A', label: 'A.1919年5月5日' },
{ value: 'B', label: 'B.1921年5月5日' },
+44 -20
View File
@@ -96,10 +96,13 @@ const SunzhongshanReflectionScene2 = () => {
const [showQuiz3, setShowQuiz3] = useState(false)
const [showCompleteGuide, setShowCompleteGuide] = useState(false)
const [pendingQuiz, setPendingQuiz] = useState<'quiz2' | 'quiz3' | 'complete' | null>(null)
const [hasClickedClueHint, setHasClickedClueHint] = useState(false)
const [hasClickedClueBook, setHasClickedClueBook] = useState(false)
const { collectClue, isClueCollected } = useSunzhongshanStore()
const containerRef = useRef<HTMLDivElement>(null)
const clueBookButtonRef = useRef<HTMLDivElement>(null)
const backpackButtonRef = useRef<HTMLDivElement>(null)
// 为每个线索创建 ref
const clueRefs = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(new Map())
@@ -157,13 +160,10 @@ const SunzhongshanReflectionScene2 = () => {
// 收集线索1
collectClue(clue1Id, clue1Name)
// 获取线索1的 ref
const clue1Ref = getClueRef(clue1Id)
// 执行飞行动画
if (clue1Ref.current && clueBookButtonRef.current) {
// 执行飞行动画 - 从背包按钮开始
if (backpackButtonRef.current && clueBookButtonRef.current) {
flyToAnimation({
fromRef: clue1Ref as React.RefObject<HTMLElement>,
fromRef: backpackButtonRef as React.RefObject<HTMLElement>,
toRef: clueBookButtonRef as React.RefObject<HTMLElement>,
targetPosition: 'center',
duration: 1200,
@@ -178,6 +178,7 @@ const SunzhongshanReflectionScene2 = () => {
// 处理线索提示按钮点击 - 打开线索提示框
const handleClueHintClick = () => {
setHasClickedClueHint(true)
setShowClueHint(true)
}
@@ -355,11 +356,9 @@ const SunzhongshanReflectionScene2 = () => {
style={{
top: clue.position.top,
left: clue.position.left,
width: clue.position.width,
height: clue.position.height,
}}
>
<div className="relative">
<div className="relative inline-block">
<Image
src={getClueImage(clue.id)}
className="w-20 h-20 border-2 border-yellow-400 rounded shadow-lg"
@@ -391,22 +390,47 @@ const SunzhongshanReflectionScene2 = () => {
{/* 右下角-功能处 */}
<div className="absolute right-6 bottom-12 flex flex-col items-center gap-4 z-10">
<Image
src={getStaticImage(`${starName}/reflection/线索提示`)}
button
onClick={handleClueHintClick}
/>
<Image
src={getStaticImage(`${starName}/reflection/背包`)}
button
onClick={handleBackpackClick}
/>
<div className="relative">
<Image
src={getStaticImage(`${starName}/reflection/线索提示`)}
button
onClick={handleClueHintClick}
/>
{!hasClickedClueHint && (
<Image
className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none"
src={getStaticImage('components/点击查看')}
/>
)}
</div>
<div ref={backpackButtonRef} className="relative">
<Image
src={getStaticImage(`${starName}/reflection/背包`)}
button
onClick={handleBackpackClick}
/>
{hasClickedClueHint && !isClueCollected(`${sceneId}/clue1`) && (
<Image
className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none"
src={getStaticImage('components/点击查看')}
/>
)}
</div>
<div ref={clueBookButtonRef} className="relative" data-clue-book-button="true">
<Image
src={getStaticImage(`${starName}/reflection/线索簿`)}
button
onClick={() => setShowClueBook(true)}
onClick={() => {
setHasClickedClueBook(true)
setShowClueBook(true)
}}
/>
{hasClickedClueHint && isClueCollected(`${sceneId}/clue1`) && !hasClickedClueBook && (
<Image
className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none"
src={getStaticImage('components/点击查看')}
/>
)}
</div>
</div>
+44 -20
View File
@@ -94,10 +94,13 @@ const SunzhongshanReflectionScene3 = () => {
const [isMainScene, setIsMainScene] = useState(false)
// 控制 Cursor 显示:获取线索1后显示,点击后进入主场景
const [showCursor, setShowCursor] = useState(false)
const [hasClickedClueHint, setHasClickedClueHint] = useState(false)
const [hasClickedClueBook, setHasClickedClueBook] = useState(false)
const { collectClue, isClueCollected } = useSunzhongshanStore()
const containerRef = useRef<HTMLDivElement>(null)
const clueBookButtonRef = useRef<HTMLDivElement>(null)
const backpackButtonRef = useRef<HTMLDivElement>(null)
// 为每个线索创建 ref
const clueRefs = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(new Map())
@@ -169,13 +172,10 @@ const SunzhongshanReflectionScene3 = () => {
// 收集线索4
collectClue(clue4Id, clue4Name)
// 获取线索4的 ref
const clue4Ref = getClueRef(clue4Id)
// 执行飞行动画
if (clue4Ref.current && clueBookButtonRef.current) {
// 执行飞行动画 - 从报纸按钮开始
if (backpackButtonRef.current && clueBookButtonRef.current) {
flyToAnimation({
fromRef: clue4Ref as React.RefObject<HTMLElement>,
fromRef: backpackButtonRef as React.RefObject<HTMLElement>,
toRef: clueBookButtonRef as React.RefObject<HTMLElement>,
targetPosition: 'center',
duration: 1200,
@@ -190,6 +190,7 @@ const SunzhongshanReflectionScene3 = () => {
// 处理线索提示按钮点击 - 打开线索提示框
const handleClueHintClick = () => {
setHasClickedClueHint(true)
setShowClueHint(true)
}
@@ -397,11 +398,9 @@ const SunzhongshanReflectionScene3 = () => {
style={{
top: clue.position.top,
left: clue.position.left,
width: clue.position.width,
height: clue.position.height,
}}
>
<div className="relative">
<div className="relative inline-block">
<Image
src={getClueImage(clue.id)}
className="w-20 h-20 border-2 border-yellow-400 rounded shadow-lg"
@@ -433,22 +432,47 @@ const SunzhongshanReflectionScene3 = () => {
{/* 右下角-功能处 */}
<div className="absolute right-6 bottom-12 flex flex-col items-center gap-4 z-10">
<Image
src={getStaticImage(`${starName}/reflection/线索提示`)}
button
onClick={handleClueHintClick}
/>
<Image
src={getStaticImage(`${starName}/reflection/背包`)}
button
onClick={handleBackpackClick}
/>
<div className="relative">
<Image
src={getStaticImage(`${starName}/reflection/线索提示`)}
button
onClick={handleClueHintClick}
/>
{!isMainScene && !hasClickedClueHint && (
<Image
className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none"
src={getStaticImage('components/点击查看')}
/>
)}
</div>
<div ref={backpackButtonRef} className="relative">
<Image
src={getStaticImage(`${starName}/reflection/scene3/报纸`)}
button
onClick={handleBackpackClick}
/>
{isMainScene && !isClueCollected(`${sceneId}/clue4`) && (
<Image
className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none"
src={getStaticImage('components/点击查看')}
/>
)}
</div>
<div ref={clueBookButtonRef} className="relative" data-clue-book-button="true">
<Image
src={getStaticImage(`${starName}/reflection/线索簿`)}
button
onClick={() => setShowClueBook(true)}
onClick={() => {
setHasClickedClueBook(true)
setShowClueBook(true)
}}
/>
{isMainScene && isClueCollected(`${sceneId}/clue4`) && !hasClickedClueBook && (
<Image
className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none"
src={getStaticImage('components/点击查看')}
/>
)}
</div>
</div>
+102 -27
View File
@@ -1,5 +1,3 @@
import { toJpeg } from 'html-to-image'
export const STAR_NAME = 'zhangleping'
// ─── 类型定义 ───────────────────────────────────────────────
@@ -210,33 +208,110 @@ export function getTopBarSrc(block: SidebarBlockKey): string {
return `${STAR_NAME}/reflection/draw/素材库条`
}
// ─── DOM 截图生成 ─────────────────────────────────────────────
// ─── Canvas 合成生成 ───────────────────────────────────────────
export async function buildCompositeDataUrl(compositionEl: HTMLElement): Promise<string> {
// 临时移除定位偏移,避免截图出现留白/偏移
const { position, left, right, top, bottom } = compositionEl.style
Object.assign(compositionEl.style, {
position: 'fixed',
left: '0',
right: 'auto',
top: '0',
bottom: 'auto',
interface CompositeImageLayer {
src: string
x: number
y: number
width: number
height: number
zIndex: number
}
const EXPORT_WIDTH = 960
const EXPORT_HEIGHT = 540
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new window.Image()
image.decoding = 'async'
image.onload = () => resolve(image)
image.onerror = reject
image.src = src
})
}
try {
return await toJpeg(compositionEl, {
cacheBust: false,
skipFonts: true,
width: compositionEl.offsetWidth,
height: compositionEl.offsetHeight,
canvasWidth: 960,
canvasHeight: 540,
pixelRatio: 1,
quality: 0.82,
backgroundColor: '#fff',
})
} finally {
// 恢复原始样式
Object.assign(compositionEl.style, { position, left, right, top, bottom })
function getBackgroundUrl(element: HTMLElement): string | null {
const backgroundImage = window.getComputedStyle(element).backgroundImage
const match = backgroundImage.match(/^url\(["']?(.+?)["']?\)$/)
return match?.[1] ?? null
}
function getLayerZIndex(element: HTMLElement, root: HTMLElement): number {
let current: HTMLElement | null = element
while (current && current !== root) {
const zIndex = Number(window.getComputedStyle(current).zIndex)
if (Number.isFinite(zIndex)) return zIndex
current = current.parentElement
}
return 0
}
function getImageLayer(
element: HTMLImageElement,
root: HTMLElement,
rootRect: DOMRect
): CompositeImageLayer {
const rect = element.getBoundingClientRect()
return {
src: element.currentSrc || element.src,
x: rect.left - rootRect.left,
y: rect.top - rootRect.top,
width: rect.width,
height: rect.height,
zIndex: getLayerZIndex(element, root),
}
}
async function drawLayer(
context: CanvasRenderingContext2D,
layer: CompositeImageLayer,
scaleX: number,
scaleY: number
) {
const image = await loadImage(layer.src)
context.drawImage(
image,
layer.x * scaleX,
layer.y * scaleY,
layer.width * scaleX,
layer.height * scaleY
)
}
export async function buildCompositeDataUrl(compositionEl: HTMLElement): Promise<string> {
const canvas = document.createElement('canvas')
canvas.width = EXPORT_WIDTH
canvas.height = EXPORT_HEIGHT
const context = canvas.getContext('2d')
if (!context) throw new Error('Canvas is not available')
context.fillStyle = '#fff'
context.fillRect(0, 0, EXPORT_WIDTH, EXPORT_HEIGHT)
const rootRect = compositionEl.getBoundingClientRect()
const scaleX = EXPORT_WIDTH / rootRect.width
const scaleY = EXPORT_HEIGHT / rootRect.height
const backgroundUrl = getBackgroundUrl(compositionEl)
if (backgroundUrl) {
const background = await loadImage(backgroundUrl)
context.drawImage(background, 0, 0, EXPORT_WIDTH, EXPORT_HEIGHT)
}
const layers = Array.from(compositionEl.querySelectorAll<HTMLImageElement>('img'))
.filter((image) => image.complete && image.naturalWidth > 0)
.map((image) => getImageLayer(image, compositionEl, rootRect))
.sort((a, b) => a.zIndex - b.zIndex)
for (const layer of layers) {
await drawLayer(context, layer, scaleX, scaleY)
}
return canvas.toDataURL('image/jpeg', 0.82)
}
+6 -2
View File
@@ -209,7 +209,7 @@ const ZhanglepingDraw1 = () => {
{/* 底部输入框 */}
{showCaptionInput && (
<div
className="absolute left-20 bottom-2.5 bg-cover bg-center bg-no-repeat w-[1724px] h-[100px] z-40"
className="absolute left-20 bottom-0.5 bg-cover bg-center bg-no-repeat w-[1724px] h-[100px] z-40"
style={{ backgroundImage: getBackgroundImage(`${STAR_NAME}/reflection/draw/写字底板`) }}
>
<div className="flex items-center justify-center">
@@ -239,6 +239,7 @@ const ZhanglepingDraw1 = () => {
<Image
src={getStaticImage(`${STAR_NAME}/reflection/draw/返回`)}
button
className="block w-[371px] h-[105px]"
onClick={() => navigate(`/${moduleName}/${STAR_NAME}/draw`)}
/>
</div>
@@ -246,7 +247,10 @@ const ZhanglepingDraw1 = () => {
<Image
src={getStaticImage(`${STAR_NAME}/reflection/draw/提交作品`)}
button
className={isSubmitting ? 'opacity-70 pointer-events-none' : undefined}
className={cn(
'block w-[371px] h-[105px]',
isSubmitting && 'opacity-70 pointer-events-none'
)}
onClick={handleSubmitArtwork}
/>
</div>
@@ -431,6 +431,9 @@ const ZhanglepingInternalization = () => {
anchors={anchors}
playsInline
controls
controlsOnReplay
skipKey="zhangleping_internalization_video"
showReadonlyProgress
onSkip={() => {
setShowVideo(false)
setPostGuideVisible(true)
+4
View File
@@ -286,6 +286,8 @@ const ZhanglepingPlace2 = () => {
autoPlay
playsInline
showSkipButton
skipKey="zhangleping_place2_video1"
showReadonlyProgress
onEnded={() => {
setShowVideo1(false)
setQuizVisible1(true)
@@ -363,6 +365,8 @@ const ZhanglepingPlace2 = () => {
autoPlay
playsInline
showSkipButton
skipKey="zhangleping_place2_video2"
showReadonlyProgress
onEnded={() => {
setShowVideo2(false)
markExhibitVisited(place, 2)
+4
View File
@@ -277,6 +277,8 @@ const ZhanglepingPlace3 = () => {
autoPlay
playsInline
showSkipButton
skipKey="zhangleping_place3_video1"
showReadonlyProgress
onEnded={() => {
setShowVideo2(false)
setQuiz2Visible(true)
@@ -340,6 +342,8 @@ const ZhanglepingPlace3 = () => {
autoPlay
playsInline
showSkipButton
skipKey="zhangleping_place3_video2"
showReadonlyProgress
onEnded={() => {
setShowVideo3(false)
}}
+1 -1
View File
@@ -296,7 +296,7 @@ const ZhanglepingReflectionScene1 = () => {
{fragmentCompleted && !showFragmentOverlay && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/离开场景')}
src={getStaticImage('components/place/继续前进')}
button
onClick={handleLeave}
/>
+1 -1
View File
@@ -473,7 +473,7 @@ const ZhanglepingReflectionScene2 = () => {
{fragmentCompleted && !showRepairOverlay && !showFragmentOverlay && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/离开场景')}
src={getStaticImage('components/place/继续前进')}
button
onClick={handleLeave}
/>
+2 -2
View File
@@ -211,7 +211,7 @@ const ZhanglepingReflectionScene3 = () => {
{/* 场景提示:碎片已解锁 */}
{!showItemExhibit && !showFragmentOverlay && fragmentCompleted && (
<SceneHint when triggerKey={successHintKey} persistent offset={{ x: -20 }}>
苦难善良·记忆碎片已解锁
流浪岁月·记忆碎片已解锁
</SceneHint>
)}
{/* 场景提示:拖拽阶段常驻/错误提示 */}
@@ -336,7 +336,7 @@ const ZhanglepingReflectionScene3 = () => {
{fragmentCompleted && !showItemExhibit && !showFragmentOverlay && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/离开场景')}
src={getStaticImage('components/place/继续前进')}
button
onClick={handleLeave}
/>
+1 -1
View File
@@ -344,7 +344,7 @@ const ZhanglepingReflectionScene4 = () => {
{fragmentCompleted && !showItemExhibit && !showFragmentOverlay && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/离开场景')}
src={getStaticImage('components/place/继续前进')}
button
onClick={handleLeave}
/>