fix: 修复开发/测试包模拟进度被易班同步覆盖的问题

进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖;
现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度,
确保测试包的"到达某故居/全流程完成"调试入口生效。

同时纳入近期未提交的页面、组件、资源与环境变量改动。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-23 10:19:36 +08:00
1 parent 6d40e19346
commit 49ecabbf3a
155 files changed
+1956 -724

No files matched your search

+65 -2
View File
@@ -1,14 +1,37 @@
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image } from '@/components'
import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { Image, Video } from '@/components'
import { useNavigate } from 'react-router-dom'
const Home = () => {
const navigate = useNavigate()
const [showIntroVideo, setShowIntroVideo] = useState(false)
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: getBackgroundImage('home/首页底图') }}
>
{/* 课程简介 */}
<AnimatePresence>
{!showIntroVideo && (
<motion.div
className="absolute right-0 top-0"
initial={{ opacity: 0, scale: 0.92, x: 24 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.92, x: 24 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
<Image
src={getStaticImage('home/课程简介')}
button
onClick={() => setShowIntroVideo(true)}
/>
</motion.div>
)}
</AnimatePresence>
<Image
onClick={() => {
navigate('/menu')
@@ -17,6 +40,46 @@ const Home = () => {
button
src={getStaticImage('home/首页-进入')}
/>
<AnimatePresence>
{showIntroVideo && (
<motion.div
className="absolute inset-0 z-50 flex items-center justify-center bg-black/65"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.28, ease: 'easeInOut' }}
>
<motion.div
className="relative w-full h-full overflow-hidden bg-black"
initial={{ opacity: 0, scale: 1.04 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02 }}
transition={{ duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] }}
>
<Video
className="w-full h-full"
src={getPublicPath('video/intro.mp4')}
autoPlay
controls
/>
<motion.div
className="absolute right-0 top-0 z-10"
initial={{ opacity: 0, scale: 0.92, x: 24 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.92, x: 24 }}
transition={{ duration: 0.22, ease: 'easeOut', delay: 0.08 }}
>
<Image
src={getStaticImage('home/点击关闭')}
button
onClick={() => setShowIntroVideo(false)}
/>
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
+32 -10
View File
@@ -4,7 +4,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefine } from '@/lib/utils/api-common'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { useChenyunStore, CHENYUN_LESSON_ID, CHENYUN_KEY } from '@/store/chenyunStore'
const deserializeVisitedExhibits = (
@@ -43,10 +44,26 @@ const Chenyun = () => {
useEffect(() => {
;(async () => {
try {
const raw = await getUserDefine(CHENYUN_LESSON_ID, CHENYUN_KEY)
if (!raw) return
const result = await getUserDefineResult(CHENYUN_LESSON_ID, CHENYUN_KEY)
const data = JSON.parse(raw) as {
if (result.status !== 'success') {
useChenyunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
isYinyuanSolved: false,
isMimianSolved: false,
lastVisitedPlace: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
isYinyuanSolved?: boolean
isMimianSolved?: boolean
@@ -56,14 +73,19 @@ const Chenyun = () => {
useChenyunStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
isYinyuanSolved:
data.isYinyuanSolved !== undefined ? data.isYinyuanSolved : prev.isYinyuanSolved,
isMimianSolved:
data.isMimianSolved !== undefined ? data.isMimianSolved : prev.isMimianSolved,
lastVisitedPlace:
data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
isYinyuanSolved: data.isYinyuanSolved ?? false,
isMimianSolved: data.isMimianSolved ?? false,
lastVisitedPlace: data.lastVisitedPlace ?? null,
}))
} catch (error) {
useChenyunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
isYinyuanSolved: false,
isMimianSolved: false,
lastVisitedPlace: null,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Chenyun store', error)
}
})()
+5 -5
View File
@@ -84,7 +84,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
id: 'quiz-3',
time: 16 * 60 + 31, //16:26
time: 15 * 60 + 56, //15:56
title: '题目3',
prompt: '新时代的青年可以从陈云身上学习到',
type: 'drag',
@@ -117,7 +117,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
id: 'quiz-5',
time: 60 * 16 + 23, // 16:23
time: 60 * 15 + 48, // 15:48
title: '题目5',
prompt: '以下哪些陈云的生活小事值得我们学习?',
type: 'multiple',
@@ -219,7 +219,7 @@ const ChenyunInternalization = () => {
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
title="恭喜你, 选择正确!"
content=""
onContinue={() => {
setActiveSuccess(null)
@@ -291,7 +291,7 @@ const ChenyunInternalization = () => {
/>
<SuccessAlert
visible={activeSuccess === item.id}
// title="恭喜你, 选择正确!"
// title="恭喜你, 选择正确!"
// content="回答正确,请继续探索。"
onContinue={() => {
setActiveSuccess(null)
@@ -496,7 +496,7 @@ const ChenyunInternalization = () => {
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(24)
navigate('/menu')
navigate('/learning-map', { state: { completedResidence: 'chenyun' } })
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_40.mp3')}
/>
@@ -9,7 +9,7 @@ import type { ClueData } from '../clue/types'
// 银元之战视频配置
export const YINYUAN_VIDEO_CONFIG = {
src: getPublicPath('video/chenyun/chenyun_15.mp4'),
narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说“共产党是军事一百分,政治八十分,财经打零分。“上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的“银元之战”。”你将化身陈云同志的特派员,直面这场“银元之战”。`,
narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说“共产党是军事一百分,政治八十分,财经打零分。”上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的“银元之战”。你将化身陈云同志的特派员,直面这场“银元之战”。`,
alert: {
title: '第一幕:“银元之战”--人民币的阵地争夺战',
subtitle: '上海目前出现大肆贩卖银元的情况,请你了解情况!',
+4 -3
View File
@@ -35,9 +35,10 @@ const ChenyunReflection = () => {
// ========== 顶层状态管理 ==========
/** 当前展开的文件 */
const [expandedFile, setExpandedFile] = useState<FileType | null>(() =>
useChenyunStore.getState().isYinyuanSolved ? 'mimian' : null
)
const [expandedFile, setExpandedFile] = useState<FileType | null>(() => {
const { isYinyuanSolved, isMimianSolved } = useChenyunStore.getState()
return isYinyuanSolved && !isMimianSolved ? 'mimian' : null
})
/** 当前播放的视频 */
const [playingVideo, setPlayingVideo] = useState<FileType | null>(null)
@@ -85,7 +85,7 @@ export const MimianBattle = ({
state.setShowMimianQuiz2(false)
state.setShowMimianSuccessAlert(true)
state.setMimianSuccessAlertContent(
`结合当时市场情况,陈云同志在会上做出了"全国一盘棋"的指示。全国统筹调运粮食、棉花、煤炭填补缺口,筑牢物资基础。关键节点集中抛售,让跟风囤货的投机商无力承接,因亏本和高利贷纷纷破产,快速平抑物价、稳固人民币地位。`
`结合当时市场情况,陈云同志在会上做出了“全国一盘棋”的指示。全国统筹调运粮食、棉花、煤炭填补缺口,筑牢物资基础。关键节点集中抛售,让跟风囤货的投机商无力承接,因亏本和高利贷纷纷破产,快速平抑物价、稳固人民币地位。`
)
}
// 选错时,错误信息已经在题目面板中通过 onWrongSelect 显示,不需要额外的 SuccessAlert
@@ -125,7 +125,7 @@ export const MimianBattle = ({
if (isCorrect) {
state.setShowMimianQuiz3(false)
state.setShowMimianSuccessAlert(true)
state.setMimianSuccessAlertContent('地方配合,调运按时推进。')
state.setMimianSuccessAlertContent('地方配合,调运按时推进。')
}
}
@@ -147,7 +147,7 @@ export const MimianBattle = ({
if (isCorrect) {
state.setShowMimianQuiz4(false)
state.setShowMimianSuccessAlert(true)
state.setMimianSuccessAlertContent('棉农支持,调运量足额完成。')
state.setMimianSuccessAlertContent('棉农支持,调运量足额完成。')
} else {
// state.setShowMimianQuiz4(false);
// state.setShowMimianSuccessAlert(true);
@@ -176,16 +176,16 @@ export const MimianQuizzes = ({
{/* 米棉之战 - 单选题3(图5) */}
<SingleChoiceQuizPanel
visible={showQuiz3}
title="【单选】某地方粮站反馈“本地冬储粮不足,不愿外运”。"
title="【单选】华东某地方粮站反馈“本地冬储粮不足,不愿外运”。"
options={[
{ value: 'A', label: 'A. 强制调运,否则追责' },
{
value: 'B',
label: 'B. 中央已协调东北补充常州冬储粮,先保障全国物价稳定',
label: 'B. 中央已协调东北补充华东地方冬储粮,先保障全国物价稳定',
},
]}
correctAnswer="B"
errorMessage="地方抵触,调运延误多天。请你再思考一下吧!"
errorMessage="地方抵触,调运延误多天。请你再思考一下吧!"
onSubmit={onQuiz3Submit}
/>
@@ -245,7 +245,7 @@ export const MimianQuizzes = ({
{/* 米棉之战 - 单选题5(图8) */}
<SingleChoiceQuizPanel
visible={showQuiz5}
title="【单选】此时,以下哪种举措能够一举获得胜利?"
title="【单选】此时,以下哪种举措能够一举获得胜利?"
options={[
{
value: 'A',
@@ -254,7 +254,7 @@ export const MimianQuizzes = ({
},
{
value: 'B',
label: `B.逐步降价,配合舆论引导,每日小幅降价,并通过报纸宣传政府物资充足,试图"劝退"投机商`,
label: `B.逐步降价,配合舆论引导,每日小幅降价,并通过报纸宣传政府物资充足,试图“劝退”投机商`,
},
]}
correctAnswer="A"
+2 -2
View File
@@ -32,7 +32,7 @@ const entranceActionConfig: Record<
locked: {
image: 'entrance/暂未解锁',
position: {
left: 'absolute left-95 bottom-58',
left: 'absolute left-108 bottom-58',
right: 'absolute right-106 bottom-58',
},
},
@@ -47,7 +47,7 @@ const entranceActionConfig: Record<
image: 'entrance/该故居已访问',
position: {
left: 'absolute left-95 bottom-58',
right: 'absolute right-102 bottom-58',
right: 'absolute right-98 bottom-58',
},
},
}
+49 -13
View File
@@ -4,7 +4,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefine } from '@/lib/utils/api-common'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import {
useHuangyanpeiStore,
HUANGYANPEI_LESSON_ID,
@@ -37,28 +38,63 @@ const Huangyanpei = () => {
useEffect(() => {
;(async () => {
try {
const raw = await getUserDefine(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY)
if (!raw) return
const result = await getUserDefineResult(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY)
const data = JSON.parse(raw) as {
if (result.status !== 'success') {
useHuangyanpeiStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
shownReflectionCompletionGuides: {},
reflectionScene2Choice: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
reflectionRoute?: string
shownReflectionCompletionGuides?: Record<string, boolean>
reflectionScene2Choice?: '铁工科' | '商科' | null
}
const validReflectionRoute =
data.reflectionRoute &&
['reflection', 'reflection2', 'reflection3', 'reflection4'].includes(data.reflectionRoute)
? data.reflectionRoute
: 'reflection'
useHuangyanpeiStore.setState((prev) => ({
...prev,
lastVisitedPlace:
data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
reflectionRoute:
data.reflectionRoute &&
['reflection', 'reflection2', 'reflection3', 'reflection4'].includes(
data.reflectionRoute
)
? data.reflectionRoute
: (prev.reflectionRoute ?? 'reflection'),
visitedExhibits: Object.fromEntries(
Object.entries(data.visitedExhibits ?? {}).map(([place, exhibits]) => [
place,
new Set(Array.isArray(exhibits) ? exhibits : []),
])
),
lastVisitedPlace: data.lastVisitedPlace ?? null,
reflectionRoute: validReflectionRoute,
shownReflectionCompletionGuides: data.shownReflectionCompletionGuides ?? {},
reflectionScene2Choice: data.reflectionScene2Choice ?? null,
}))
} catch (error) {
useHuangyanpeiStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
shownReflectionCompletionGuides: {},
reflectionScene2Choice: null,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Huangyanpei store', error)
}
})()
+1 -1
View File
@@ -546,7 +546,7 @@ const HuangyanpeiInternalization = () => {
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(12)
navigate('/menu')
navigate('/learning-map', { state: { completedResidence: 'huangyanpei' } })
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_31.mp3')}
/>
+1 -1
View File
@@ -345,7 +345,7 @@ const HuangyanpeiPlace3 = () => {
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_07.mp3')}
>
这所川沙小学堂就是黄炎培先生“教育救国”的第一个试验田吧?我们现在学习的课程有专业课、实习实训课都提倡技能培养,当年他是不是也很注重动手能力呀?
这所川沙小学堂就是黄炎培先生“教育救国”的第一个试验田吧?我们现在的专业课、实训课都提倡技能培养,当年他是不是也很注重动手能力呀?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
+1 -1
View File
@@ -342,7 +342,7 @@ const HuangyanpeiPlace4 = () => {
{/* 题目 */}
<SingleChoiceQuizPanel
visible={showQuiz}
title={`【单选】中华职业学校首批招生78人,凭借____口号和“免学费、包分配”的优待,开创了中国近代职业教育的先河。`}
title={`【单选】中华职业学校首批招生78人,凭借____口号和“免学费、包分配”的优待,开创了中国近代职业教育的先河。`}
options={[
{ value: 'A', label: 'A. 学而优则仕' },
{ value: 'B', label: 'B. 双手万能、手脑并用' },
+2 -2
View File
@@ -150,13 +150,13 @@ const HuangyanpeiPlace6 = () => {
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_21_2.mp3')}
>
第一次把”中等专业学校”正式纳入国家学制,让职业教育有了明确的国家定位。1952年,他又推动建立了上海机械学校、南京航空学校等五所中专,这些学校为国家”一五”计划输送了6.3万名技术工人,为工业发展提供了坚实的人才支撑。
第一次把“中等专业学校”正式纳入国家学制,让职业教育有了明确的国家定位。1952年,他又推动建立了上海机械学校、南京航空学校等五所中专,这些学校为国家“一五”计划输送了6.3万名技术工人,为工业发展提供了坚实的人才支撑。
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_21_3.mp3')}
>
黄炎培先生曾说过:”我要把最后一滴血,滴在职业教育的讲台上。”他用一生践行了这句话,而我们现在中职教育强调的”工学结合、德技并修”,正是对他”手脑并用、注重实践”理念的传承和发展。
黄炎培先生曾说过:“我要把最后一滴血,滴在职业教育的讲台上。”他用一生践行了这句话,而我们现在中职教育强调的“工学结合、德技并修”,正是对他“手脑并用、注重实践”理念的传承和发展。
</GuideStepsItem>
</GuideSteps>
+2 -2
View File
@@ -234,7 +234,7 @@ const HuangyanpeiReflectionScene2 = () => {
<SuccessAlert
visible={showQuizSuccess}
title=""
content="恭喜你!选择正确。不愧是从川沙小学堂走出来!"
content="恭喜你!选择正确。不愧是从川沙小学堂走出来!"
onClose={() => {
setShowQuizSuccess(false)
}}
@@ -246,7 +246,7 @@ const HuangyanpeiReflectionScene2 = () => {
onContinue={handleCompleteGuideContinue}
verticalAlign="center"
>
第二关完成!“职业方向锁定!”去新学校了解你的专业吧。
第二关完成!职业方向锁定!去新学校了解你的专业吧。
</StudyGuideModal>
</div>
)
@@ -175,7 +175,7 @@ const ChoiceDialog2 = ({ visible = true, onClose, onSelect }: ChoiceDialog2Props
<SuccessAlert
visible={showSuccessAlert}
title="选得好!"
content="不管是做工匠还是做买卖,学好本事就能报效国家,这个选择会影响你在中华职业学校的专业哦!"
content="不管是做工匠还是做买卖,学好本事就能报效国家,这个选择会影响你在中华职业学校的专业哦!"
onClose={handleSuccessAlertContinue}
/>
</div>
@@ -114,7 +114,7 @@ const Scene2QuizDialog = ({ visible = true, onClose, onComplete }: Scene2QuizDia
<div className="flex-1">
<div className="text-5xl font-bold mb-6 -ml-[26px]">【单选】</div>
<div className="text-4xl leading-relaxed">
杨斯盛先生近期监工浦东中学建设,有一份上月的工人工钱支出表需要核对,你来告诉我答案吧?
杨斯盛先生近期监工浦东中学建设,有一份上月的工人工钱支出表需要核对,你来告诉我答案吧!
</div>
</div>
@@ -11,6 +11,7 @@ import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { useCursorController } from '@/hooks/useCursorController'
import { saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const starName = 'huangyanpei'
@@ -20,6 +21,8 @@ const HuangyanpeiReflectionScene3 = () => {
moduleName: string
}>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
//喇叭考题弹窗
const [showQuizDialog, setShowQuizDialog] = useState(false)
@@ -252,6 +255,7 @@ const HuangyanpeiReflectionScene3 = () => {
onComplete={() => {
setShowQuizDialog(false)
markReflectionCursorVisited(scene, 3)
triggerPuzzleAnimation(15)
}}
/>
</div>
+2 -3
View File
@@ -8,10 +8,9 @@ import TowelSalesDialog from './scene4/TowelSalesDialog'
import type { VideoRef } from '@/components/video'
import { QuizDialog } from './components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { useCursorController } from '@/hooks/useCursorController'
import { saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
const starName = 'huangyanpei'
@@ -186,7 +185,6 @@ const HuangyanpeiReflectionScene4 = () => {
onComplete={() => {
// 完成拖拽后更新visited状态
markReflectionCursorVisited(scene, 2)
triggerPuzzleAnimation(15)
}}
/>
@@ -238,6 +236,7 @@ const HuangyanpeiReflectionScene4 = () => {
onComplete={() => {
setShowQuizDialog(false)
markReflectionCursorVisited(scene, 3)
triggerPuzzleAnimation(15)
}}
/>
@@ -91,19 +91,19 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia
() => [
{
value: 'option1',
label: 'A.这是中华职校生产的国货,质量比洋货还好!',
label: 'A.这是中华职校生产的国货,质量比洋货还好!',
},
{
value: 'option2',
label: 'B.价格便宜,量大从优,诚信经营绝不掺假',
label: 'B.价格便宜,量大从优,诚信经营绝不掺假。',
},
{
value: 'option3',
label: 'C.买国货就是支持实业救国,为国家出一份力!',
label: 'C.买国货就是支持实业救国,为国家出一份力!',
},
{
value: 'option4',
label: 'D.我只管赚钱,东西好不好不管哦',
label: 'D.我只管赚钱,东西好不好不管哦。',
},
],
[]
+149 -35
View File
@@ -1,24 +1,22 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image } from '@/components'
import { isResidenceComplete, isResidenceUnlocked } from '@/config/progression'
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
import { usePuzzleStore } from '@/store/puzzleStore'
const residences = [
{ key: 'maozedong', name: '毛泽东', left: '30%', top: '35%' },
{ key: 'sunzhongshan', name: '孙中山', left: '12%', top: '51%' },
{ key: 'luxun', name: '鲁迅', left: '26.3%', top: '74%' },
{ key: 'huangyanpei', name: '黄炎培', left: '44%', top: '67%' },
{ key: 'nieer', name: '聂耳', left: '62%', top: '60%' },
{ key: 'zhangleping', name: '张乐平', left: '73%', top: '33%' },
{ key: 'rongdesheng', name: '荣德生', left: '90%', top: '45%' },
{ key: 'chenyun', name: '陈云', left: '80%', top: '76%' },
]
const SPOTLIGHT_PROMPT_STORAGE_PREFIX = 'learning-map:spotlight-clicked:'
interface HexagonProgressBarProps {
progress: number
}
interface LearningMapLocationState {
completedResidence?: string
}
const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
const percent = Math.min(100, Math.max(0, progress))
const text = percent >= 100 ? '进度100%' : `进度${Math.round(percent)}%`
@@ -51,13 +49,82 @@ const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
const LearningMap = () => {
const navigate = useNavigate()
const location = useLocation()
const puzzles = usePuzzleStore((s) => s.puzzles)
const isLoaded = usePuzzleStore((s) => s.isLoaded)
const getPuzzleCollectProgress = usePuzzleStore((s) => s.getPuzzleCollectProgress)
const completedResidence = (location.state as LearningMapLocationState | null)?.completedResidence
const [spotlightPromptResidence, setSpotlightPromptResidence] = useState<string | null>(null)
const progressMap = useMemo(
() => Object.fromEntries(residences.map((r) => [r.key, getPuzzleCollectProgress(r.key)])),
[getPuzzleCollectProgress]
const progressMap = Object.fromEntries(
PUZZLE_RESIDENCES.map(({ residence }) => [
residence,
isLoaded ? getPuzzleCollectProgress(residence) : 0,
])
)
const unlockedMap = useMemo(
() =>
Object.fromEntries(
PUZZLE_RESIDENCES.map(({ residence }) => [
residence,
isLoaded && isResidenceUnlocked(residence, puzzles),
])
),
[isLoaded, puzzles]
)
const completeMap = useMemo(
() =>
Object.fromEntries(
PUZZLE_RESIDENCES.map(({ residence }) => [
residence,
isLoaded && isResidenceComplete(residence, puzzles),
])
),
[isLoaded, puzzles]
)
const newlyUnlockedResidence = useMemo(() => {
if (!completedResidence) return null
const completedIndex = PUZZLE_RESIDENCES.findIndex(
(item) => item.residence === completedResidence
)
const nextResidence = PUZZLE_RESIDENCES[completedIndex + 1]?.residence
if (!nextResidence || !unlockedMap[nextResidence]) return null
return nextResidence
}, [completedResidence, unlockedMap])
useEffect(() => {
if (!completedResidence || !isLoaded || !completeMap[completedResidence]) {
setSpotlightPromptResidence(null)
return
}
const isKnownResidence = PUZZLE_RESIDENCES.some((item) => item.residence === completedResidence)
const isPromptClicked =
window.sessionStorage.getItem(`${SPOTLIGHT_PROMPT_STORAGE_PREFIX}${completedResidence}`) ===
'true'
setSpotlightPromptResidence(isKnownResidence && !isPromptClicked ? completedResidence : null)
}, [completeMap, completedResidence, isLoaded])
const handleSpotlightClick = (residence: string, isUnlocked: boolean) => {
if (!isUnlocked) return
if (residence === spotlightPromptResidence) {
window.sessionStorage.setItem(`${SPOTLIGHT_PROMPT_STORAGE_PREFIX}${residence}`, 'true')
setSpotlightPromptResidence(null)
}
navigate(`/map/spotlight/${residence}`)
}
const handleResidenceClick = (moduleName: string, residence: string, isUnlocked: boolean) => {
if (!isUnlocked) return
navigate(`/${moduleName}/${residence}/home`)
}
return (
<div
className="relative w-full h-full bg-cover bg-center bg-no-repeat"
@@ -78,28 +145,75 @@ const LearningMap = () => {
/>
<div className="absolute inset-0">
{residences.map(({ key, name, left, top }) => (
<div
key={key}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left, top }}
>
<Image src={getStaticImage(`learning-map/${name}`)} alt={name} />
{PUZZLE_RESIDENCES.map(({ residence, image, moduleName, left, top }) => {
const isUnlocked = unlockedMap[residence]
const isNewlyUnlocked = residence === newlyUnlockedResidence
const showSpotlightPrompt = residence === spotlightPromptResidence
<div className="mb-4 flex justify-center">
<HexagonProgressBar progress={progressMap[key] ?? 0} />
</div>
return (
<motion.div
key={residence}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left, top }}
initial={
isNewlyUnlocked
? { opacity: 0.4, filter: 'grayscale(1) brightness(0.8)' }
: undefined
}
animate={{
opacity: isUnlocked ? 1 : 0.4,
filter: isUnlocked ? 'grayscale(0) brightness(1)' : 'grayscale(1) brightness(0.8)',
}}
transition={
isNewlyUnlocked
? { duration: 1.2, ease: 'easeOut' }
: { duration: 0.25, ease: 'easeOut' }
}
>
<motion.div
animate={
isNewlyUnlocked
? { filter: ['brightness(1)', 'brightness(1.25)', 'brightness(1)'] }
: undefined
}
transition={
isNewlyUnlocked ? { delay: 1.2, duration: 1, ease: 'easeInOut' } : undefined
}
>
<Image
className={isUnlocked ? undefined : 'cursor-not-allowed'}
src={getStaticImage(`learning-map/${image}`)}
alt={image}
button={isUnlocked}
onClick={() => handleResidenceClick(moduleName, residence, !!isUnlocked)}
/>
<div className="flex justify-center gap-6">
<Image
src={getStaticImage('learning-map/追光入口')}
alt="追光"
button
onClick={() => navigate(`/map/spotlight/${key}`)}
/>
</div>
</div>
))}
<div className="mb-4 flex justify-center">
<HexagonProgressBar progress={progressMap[residence] ?? 0} />
</div>
<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' }}
/>
)}
<Image
className={isUnlocked ? undefined : 'cursor-not-allowed'}
src={getStaticImage('learning-map/追光入口')}
alt="追光"
button={isUnlocked}
onClick={() => handleSpotlightClick(residence, !!isUnlocked)}
/>
</div>
</motion.div>
</motion.div>
)
})}
</div>
</div>
)
+18 -14
View File
@@ -1,30 +1,34 @@
import { useState } from 'react'
import { Navigate, useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { isResidenceUnlocked } from '@/config/progression'
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
import { getStaticImage } from '@/lib/utils'
const residenceNameMap: Record<string, string> = {
maozedong: '毛泽东',
sunzhongshan: '孙中山',
luxun: '鲁迅',
huangyanpei: '黄炎培',
nieer: '聂耳',
zhangleping: '张乐平',
rongdesheng: '荣德生',
chenyun: '陈云',
}
import { usePuzzleStore } from '@/store/puzzleStore'
const SpotlightMap = () => {
const navigate = useNavigate()
const { residence } = useParams()
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
const [showDetail, setShowDetail] = useState(false)
const residenceName = residence ? residenceNameMap[residence] : undefined
const residenceConfig = PUZZLE_RESIDENCES.find((item) => item.residence === residence)
if (!residenceName) {
if (!residenceConfig) {
return <Navigate to="/learning-map" replace />
}
if (!isLoaded) {
return <div className="relative w-full h-full bg-black" />
}
if (!isResidenceUnlocked(residenceConfig.residence, puzzles)) {
return <Navigate to="/learning-map" replace />
}
const residenceName = residenceConfig.image
return (
<div className="relative w-full h-full bg-black">
<Image
@@ -51,7 +55,7 @@ const SpotlightMap = () => {
onClick={() => setShowDetail(true)}
></div>
<Image
className="pointer-events-none transition-transform duration-200 group-hover:scale-110"
className="mt-58 pointer-events-none transition-transform duration-200 group-hover:scale-110"
src={getStaticImage('learning-map/spotlight/点击')}
alt="详情"
/>
+35 -11
View File
@@ -4,7 +4,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefine } from '@/lib/utils/api-common'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { useLuxunStore, LUXUN_LESSON_ID, LUXUN_KEY } from '@/store/luxunStore'
const deserializeVisitedExhibits = (
@@ -44,27 +45,50 @@ const Luxun = () => {
useEffect(() => {
;(async () => {
try {
const raw = await getUserDefine(LUXUN_LESSON_ID, LUXUN_KEY)
if (!raw) return
const result = await getUserDefineResult(LUXUN_LESSON_ID, LUXUN_KEY)
const data = JSON.parse(raw) as {
if (result.status !== 'success') {
useLuxunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
reflectionRoute?: string
}
const validReflectionRoute =
data.reflectionRoute &&
['reflection', 'reflection2', 'reflection3'].includes(data.reflectionRoute)
? data.reflectionRoute
: 'reflection'
useLuxunStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace:
data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
reflectionRoute:
data.reflectionRoute &&
['reflection', 'reflection2', 'reflection3'].includes(data.reflectionRoute)
? data.reflectionRoute
: (prev.reflectionRoute ?? 'reflection'),
lastVisitedPlace: data.lastVisitedPlace ?? null,
reflectionRoute: validReflectionRoute,
}))
} catch (error) {
useLuxunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Luxun store', error)
}
})()
+2 -2
View File
@@ -551,9 +551,9 @@ const LuxunInternalization = () => {
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(9)
navigate('/menu')
navigate('/learning-map', { state: { completedResidence: 'luxun' } })
}}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_38.mp3')}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_33_02_new.mp3')}
/>
</div>
)
+1 -1
View File
@@ -322,7 +322,7 @@ const LuxunPlace3 = () => {
{/* 光标3的题目 */}
<SingleChoiceQuizPanel
visible={quiz2Visible}
title="【单选】鲁迅在上海大陆新村9号居住期间,翻译外国作品的核心目的不包括:"
title="【单选】鲁迅在上海大陆新村9号居住期间,翻译外国作品的核心目的不包括:"
options={[
{
value: 'A',
+1 -1
View File
@@ -343,7 +343,7 @@ const LuxunPlace5 = () => {
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_28.mp3')}>
除了瞿秋白,还有其他共产党人来过这里吗
除了瞿秋白,还有其他共产党人来过这里吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_29_1.mp3')}>
当然!1936年4月,中共中央特派员冯雪峰到上海开展工作,也住进了这间客房。冯雪峰把红军长征胜利、遵义会议召开的消息告诉鲁迅,还带来了毛泽东同志提出的抗日民族统一战线主张。
+56 -1
View File
@@ -4,7 +4,20 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { useMaozedongStore } from '@/store/maozedongStore'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { MAOZEDONG_KEY, MAOZEDONG_LESSON_ID, useMaozedongStore } from '@/store/maozedongStore'
const deserializeVisitedExhibits = (
data: Record<string, number[]> | undefined
): Record<string, Set<number>> => {
if (!data || typeof data !== 'object') return {}
const visitedExhibits: Record<string, Set<number>> = {}
for (const [place, exhibits] of Object.entries(data)) {
visitedExhibits[place] = new Set(Array.isArray(exhibits) ? exhibits : [])
}
return visitedExhibits
}
const Maozedong = () => {
const navigate = useNavigate()
@@ -27,6 +40,48 @@ const Maozedong = () => {
}
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
;(async () => {
try {
const result = await getUserDefineResult(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY)
if (result.status !== 'success') {
useMaozedongStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
}
useMaozedongStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
}))
} catch (error) {
useMaozedongStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Maozedong store', error)
}
})()
}, [])
if (!moduleName) return null
return (
+1 -1
View File
@@ -514,7 +514,7 @@ const MaozedongInternalization = () => {
onContinue={() => {
setShowCompleted(false)
setCourseTimeProgress(3)
navigate('/menu')
navigate('/learning-map', { state: { completedResidence: 'maozedong' } })
}}
/>
</div>
+2 -1
View File
@@ -171,7 +171,8 @@ const MaozedongPlace1 = () => {
那参加中共一大时,毛主席对未来有什么想法吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_04.mp3')}>
当时全国只有50多个党员,毛主席所在的湖南也才几个。但他觉得,重要的是把马克思主义和中国的实际情况结合起来。就像种田,种子再好,也得看土壤——中国的”土壤”就是农民占大多数,这是和苏联不一样的地方。
当时全国只有50多个党员,毛主席所在的湖南也才几个。但他觉得,重要的是把马克思主义和中国的实际情况结合起来。就像种田,种子再好,也得看土壤——中国的
“土壤”就是农民占大多数,这是和苏联不一样的地方。
</GuideStepsItem>
</GuideSteps>
+1 -1
View File
@@ -494,7 +494,7 @@ const MaozedongPlace4 = () => {
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setQuizVisible(false)
setSuccessAlertContent('中共四大召开于1925年1月11日至22日,在上海。')
setSuccessAlertContent('中共四大于1925年1月11日至22日在上海召开。')
setSuccessAlertVisible(true)
}}
/>
+6 -6
View File
@@ -66,7 +66,7 @@ const endingQuizOptions = [
},
{
value: 'B',
label: 'B. 在调养修整后调研农民运动',
label: 'B. 在调养休整后调研农民运动',
},
]
@@ -232,7 +232,7 @@ const Home = () => {
>
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1924年,上海。
1924年的上海,革命浪潮暗涌,中共四大筹备工作关乎革命前途。无产阶级领导权、工农联盟等核心议题亟待明确,毛泽东同志在筹备关键节点面临诸多重要抉择。请你化身历史见证者,站在毛主席的视角做出选择,解锁四大筹备的关键密码,感受革命先辈的坚定与智慧!完成三次选择后,可进入下一阶段的学习。时空门已开,请做好准备迎接考验吧!`}
1924年的上海,革命浪潮暗涌,中共四大筹备工作关乎革命前途。无产阶级领导权、工农联盟等核心议题亟待明确,毛泽东同志在会议筹备的关键节点面临诸多重要抉择。请你化身历史见证者,站在毛主席的视角做出选择,解锁中共四大筹备的关键密码,感受革命先辈的坚定与智慧!完成三次选择后,可进入下一阶段的学习。时空门已开,请做好准备迎接考验吧!`}
</StudyGuideModal>
),
},
@@ -431,7 +431,7 @@ const Home = () => {
},
{
id: 'anchor-12',
time: 60 * 2 + 43,
time: 60 * 2 + 44,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
@@ -445,7 +445,7 @@ const Home = () => {
},
{
id: 'anchor-13',
time: 60 * 2 + 57,
time: 60 * 2 + 58,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
@@ -459,7 +459,7 @@ const Home = () => {
},
{
id: 'anchor-14',
time: 60 * 3 + 9,
time: 60 * 3 + 10,
afterResume: 'hide',
render: ({ resume, unmute }) => <EndingQuizCursor resume={resume} unmute={unmute} />,
},
@@ -487,7 +487,7 @@ const Home = () => {
visible={endingVisible}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_23.mp3')}
animationSpeed={200}
text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因健康未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。"
text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因身体健康原因未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。"
onContinue={() => {
setEndingVisible(false)
saveMaozedongStore()
+46 -77
View File
@@ -1,5 +1,4 @@
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
import { createPortal } from 'react-dom'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
export type SelectBgOption = {
@@ -54,36 +53,17 @@ export function SelectBg({
}: SelectBgProps) {
const [isOpen, setIsOpen] = useState(false)
const [hoveredValue, setHoveredValue] = useState<string | null>(null)
const [dropdownPosition, setDropdownPosition] = useState<{
top: number
left: number
width: number
} | null>(null)
const triggerRef = useRef<HTMLDivElement | null>(null)
const dropdownRef = useRef<HTMLDivElement | null>(null)
const selectedOption = useMemo(() => options.find((opt) => opt.value === value), [options, value])
const updateDropdownPosition = useCallback(() => {
const el = triggerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
setDropdownPosition({
top: rect.top + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
})
}, [])
const close = useCallback(() => setIsOpen(false), [])
const open = useCallback(() => {
if (disabled) return
setIsOpen(true)
// 等到下一帧确保布局稳定再计算位置
setTimeout(() => updateDropdownPosition(), 0)
}, [disabled, updateDropdownPosition])
}, [disabled])
const handleSelect = useCallback(
(nextValue: string) => {
@@ -110,17 +90,12 @@ export function SelectBg({
}
}
const onScrollOrResize = () => updateDropdownPosition()
document.addEventListener('mousedown', onMouseDown)
window.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
return () => {
document.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
}
}, [close, isOpen, updateDropdownPosition])
}, [close, isOpen])
return (
<div className={cn('relative inline-block w-[875px]', className)}>
@@ -157,56 +132,50 @@ export function SelectBg({
</span>
</div>
{isOpen &&
dropdownPosition &&
createPortal(
<div
ref={dropdownRef}
className={cn('fixed bg-transparent', dropdownClassName)}
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
zIndex,
transform: 'translateY(-100%)',
}}
role="listbox"
>
<div className="flex flex-col">
{options.map((opt) => {
const isHovered = opt.value === hoveredValue
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={opt.value === value}
disabled={!!opt.disabled}
onClick={() => handleSelect(opt.value)}
onMouseEnter={() => setHoveredValue(opt.value)}
onMouseLeave={() => setHoveredValue(null)}
className={cn(
'h-[30px] bg-cover bg-no-repeat flex items-center justify-center',
opt.disabled && 'cursor-not-allowed opacity-60 pointer-events-none',
optionClassName
)}
style={{
backgroundImage: getBackgroundImage(
isHovered ? selectedBgPath : unselectedBgPath
),
backgroundSize: '100% 100%',
}}
>
<span className="pointer-events-none text-[#756D5C] text-lg leading-none font-medium">
{opt.label}
</span>
</button>
)
})}
</div>
</div>,
document.body
)}
{isOpen && (
<div
ref={dropdownRef}
className={cn(
'absolute bottom-full left-0 z-10 w-full bg-transparent',
dropdownClassName
)}
style={{ zIndex }}
role="listbox"
>
<div className="flex flex-col">
{options.map((opt) => {
const isHovered = opt.value === hoveredValue
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={opt.value === value}
disabled={!!opt.disabled}
onClick={() => handleSelect(opt.value)}
onMouseEnter={() => setHoveredValue(opt.value)}
onMouseLeave={() => setHoveredValue(null)}
className={cn(
'h-[56px] bg-cover bg-no-repeat flex items-center justify-center',
opt.disabled && 'cursor-not-allowed opacity-60 pointer-events-none',
optionClassName
)}
style={{
backgroundImage: getBackgroundImage(
isHovered ? selectedBgPath : unselectedBgPath
),
backgroundSize: '100% 100%',
}}
>
<span className="pointer-events-none max-w-full px-4 text-[26px] text-[#756D5C] text-lg leading-none font-medium whitespace-nowrap overflow-hidden text-ellipsis">
{opt.label}
</span>
</button>
)
})}
</div>
</div>
)}
</div>
)
}
+56 -1
View File
@@ -4,7 +4,20 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { useNieerStore } from '@/store/nieerStore'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { NIEER_KEY, NIEER_LESSON_ID, useNieerStore } from '@/store/nieerStore'
const deserializeVisitedExhibits = (
data: Record<string, number[]> | undefined
): Record<string, Set<number>> => {
if (!data || typeof data !== 'object') return {}
const visitedExhibits: Record<string, Set<number>> = {}
for (const [place, exhibits] of Object.entries(data)) {
visitedExhibits[place] = new Set(Array.isArray(exhibits) ? exhibits : [])
}
return visitedExhibits
}
const NieerHome = () => {
const navigate = useNavigate()
@@ -25,6 +38,48 @@ const NieerHome = () => {
}
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
;(async () => {
try {
const result = await getUserDefineResult(NIEER_LESSON_ID, NIEER_KEY)
if (result.status !== 'success') {
useNieerStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
}
useNieerStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
}))
} catch (error) {
useNieerStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Nieer store', error)
}
})()
}, [])
if (!moduleName) return null
return (
+3 -3
View File
@@ -309,10 +309,10 @@ const NieerInternalization = () => {
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_37.mp3')}>
小沪,前两个模块中,我循着聂耳的足迹读懂了旋律背后的家国情怀,亲历了国歌跨越时空的动人故事。我还想从音乐这一艺术创作角度出发更多了解聂耳,你能给我讲讲吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_38.mp3')}>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_38_1.mp3')}>
讲到音乐,可是很专业的领域了!今天我们有幸邀请到了上海音乐学院党委宣传部副部长,东方乐器博物馆馆长,研究员,肖阳。肖老师会从上海音乐学院对聂耳的研究、与《义勇军进行曲》的渊源的角度告诉你聂耳的音乐作品与时代价值。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_38_2.mp3')}>
接下来,就让我们跟随肖老师一起,探寻聂耳音乐作品和艺术精神的当代意义吧!
</GuideStepsItem>
</GuideSteps>
@@ -378,7 +378,7 @@ const NieerInternalization = () => {
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(15)
navigate('/menu')
navigate('/learning-map', { state: { completedResidence: 'nieer' } })
}}
/>
</div>
+1 -1
View File
@@ -393,7 +393,7 @@ const NieerPlace1 = () => {
firstLineIndent="2em"
>
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
此刻你正位立于国歌展示馆的入口,请随时光的脚步,在聂耳作品的“时光声浪”中聆听5段炽热的时代回响:民族危亡的怒吼声、时代呼唤的最强音、民族解放的冲锋号、强我中华的进行曲、伟大复兴的主旋律。你准备好跟随聂耳的音符,踏上这段炽热征程了吗?
此刻你正伫立于国歌展示馆的入口,请随时光的脚步,在聂耳作品的“时光声浪”中聆听5段炽热的时代回响:民族危亡的怒吼声、时代呼唤的最强音、民族解放的冲锋号、强我中华的进行曲、伟大复兴的主旋律。你准备好跟随聂耳的音符,踏上这段炽热征程了吗?
</StudyGuideModal>
</div>
)
+2 -22
View File
@@ -1,6 +1,6 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { Image, Video } from '@/components'
import { Image } from '@/components'
import {
BeijingUnlockInteraction,
InputBg,
@@ -23,7 +23,6 @@ interface DialogLayerProps {
state: DialogState
onEnterUnlock: () => void
onShare: (sceneId: number, payload?: SharePayload) => void
onFinishBadgeVideo: () => void
onClose: () => void
}
@@ -249,13 +248,7 @@ const createUnlockHandlers = (
onChangeBeijingSelection: setBeijingSelection,
})
const DialogLayer = ({
state,
onEnterUnlock,
onShare,
onFinishBadgeVideo,
onClose,
}: DialogLayerProps) => {
const DialogLayer = ({ state, onEnterUnlock, onShare, onClose }: DialogLayerProps) => {
const [selectedLyric, setSelectedLyric] = useState<string>()
const [guizhouInput, setGuizhouInput] = useState('')
const [pianoFinished, setPianoFinished] = useState(false)
@@ -337,19 +330,6 @@ const DialogLayer = ({
)}
alt="结果页"
/>
{state.videoSrc && (
<div className="absolute inset-0 z-20 bg-black">
<Video
key={state.videoSrc}
src={state.videoSrc}
autoPlay
playsInline
className="w-full h-full"
onSkip={onFinishBadgeVideo}
onEnded={onFinishBadgeVideo}
/>
</div>
)}
<Image
button
src={getStaticImage('components/关闭')}
+48 -17
View File
@@ -46,7 +46,6 @@ const NieerReflection = () => {
openScene,
enterUnlock,
handleShare,
finishBadgeVideo,
close,
} = useDialogReducer(markReflectionCursorVisited, setReflectionWork)
@@ -63,25 +62,42 @@ const NieerReflection = () => {
)
const [endingVisible, setEndingVisible] = useState(false)
const [pendingBadgeVideoSrc, setPendingBadgeVideoSrc] = useState<string>()
const [playingBadgeVideoSrc, setPlayingBadgeVideoSrc] = useState<string>()
const [pendingEndingVisible, setPendingEndingVisible] = useState(false)
const prevDialogModeRef = useRef(dialogState.mode)
useEffect(() => {
if (
prevDialogModeRef.current === 'result' &&
dialogState.mode === 'idle' &&
allPlacesCompleted
) {
triggerPuzzleAnimation(14)
setEndingVisible(true)
if (prevDialogModeRef.current === 'result' && dialogState.mode === 'idle') {
if (pendingBadgeVideoSrc) {
setPlayingBadgeVideoSrc(pendingBadgeVideoSrc)
setPendingBadgeVideoSrc(undefined)
}
if (allPlacesCompleted) {
triggerPuzzleAnimation(14)
if (pendingBadgeVideoSrc) {
setPendingEndingVisible(true)
} else {
setEndingVisible(true)
}
}
}
prevDialogModeRef.current = dialogState.mode
}, [dialogState.mode, allPlacesCompleted, triggerPuzzleAnimation])
}, [dialogState.mode, allPlacesCompleted, pendingBadgeVideoSrc, triggerPuzzleAnimation])
const handleResultShare = (sceneId: number, payload?: Record<string, unknown>) => {
handleShare({
payload,
unlockVideoSrc: getUnlockVideoSrcForScene(sceneId, visitedReflectionCursors),
})
const unlockVideoSrc = getUnlockVideoSrcForScene(sceneId, visitedReflectionCursors)
setPendingBadgeVideoSrc(unlockVideoSrc)
handleShare({ payload })
}
const finishBadgeVideo = () => {
setPlayingBadgeVideoSrc(undefined)
if (pendingEndingVisible) {
setPendingEndingVisible(false)
setEndingVisible(true)
}
}
if (!moduleName) return null
@@ -108,7 +124,7 @@ const NieerReflection = () => {
<Image
className="absolute right-6 top-5 z-30"
src={getStaticImage('components/返回')}
src={getStaticImage('components/离开')}
button
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
@@ -295,18 +311,33 @@ const NieerReflection = () => {
state={dialogState}
onEnterUnlock={enterUnlock}
onShare={handleResultShare}
onFinishBadgeVideo={finishBadgeVideo}
onClose={close}
/>
{playingBadgeVideoSrc && (
<div className="fixed inset-0 z-10000 w-full h-full bg-black">
<Video
key={playingBadgeVideoSrc}
src={playingBadgeVideoSrc}
autoPlay
playsInline
showSkipButton
skipText="跳过"
onEnded={finishBadgeVideo}
onSkip={finishBadgeVideo}
onError={finishBadgeVideo}
/>
</div>
)}
<StudyGuideModal
visible={showGuideModal}
onContinue={markReflectionGuideSeen}
audioSrc={getPublicPath('audio/nieer/mp3_nieer_28.mp3')}
firstLineIndent="2em"
>
{`同学你好,欢迎来到”跨越时空·体悟”学习模块!
请登陆”时空回溯・亲历时代”体验仓,下一站:1936年,上海。
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1936年,上海。
请带上你的旅行包,穿越时空解锁6个国歌故事,集齐精神徽章,绘制属于你的国歌精神图谱吧~
别忘了右下角的旅行箱哦,它会记录你每段旅行的成果!时空门已开,请出发吧!`}
</StudyGuideModal>
+1 -1
View File
@@ -70,5 +70,5 @@ export const UNLOCK_TITLES: Record<number, string> = {
3: '请跟随光点在钢琴上弹奏出国歌的片段,为任光的演出伴奏吧~',
4: '点击选择史沫特莱女士临终遗言最触动你的一句话。',
5: '请拖曳配齐图片要素制作接同胞回家的明信片!',
6: '选一个你最喜欢的阅兵方阵,搭配一句词条,点击分享,定格那个瞬间。',
6: '选一个你最喜欢的阅兵方阵,搭配一句词条,点击分享,定格那个瞬间。',
}
+5 -16
View File
@@ -7,20 +7,18 @@ export type DialogState =
| { mode: 'idle' }
| { mode: 'content'; sceneId: number; nonce: number }
| { mode: 'unlock'; sceneId: number; hasShare: boolean; nonce: number }
| { mode: 'result'; sceneId: number; nonce: number; videoSrc?: string }
| { mode: 'result'; sceneId: number; nonce: number }
// ── Actions ────────────────────────────────────────────
type DialogAction =
| { type: 'OPEN_SCENE'; sceneId: number; unlocked: boolean; nonce: number }
| { type: 'ENTER_UNLOCK' }
| { type: 'SHOW_RESULT'; videoSrc?: string }
| { type: 'FINISH_BADGE_VIDEO' }
| { type: 'SHOW_RESULT' }
| { type: 'CLOSE' }
interface ShareOptions {
payload?: Record<string, unknown>
unlockVideoSrc?: string
}
// ── Reducer ────────────────────────────────────────────
@@ -36,17 +34,12 @@ function dialogReducer(state: DialogState, action: DialogAction): DialogState {
if (state.mode !== 'content') return state
return { mode: 'unlock', sceneId: state.sceneId, hasShare: true, nonce: state.nonce }
case 'FINISH_BADGE_VIDEO':
if (state.mode !== 'result') return state
return { ...state, videoSrc: undefined }
case 'SHOW_RESULT':
if (state.mode !== 'unlock') return state
return {
mode: 'result',
sceneId: state.sceneId,
nonce: state.nonce,
videoSrc: action.videoSrc,
}
case 'CLOSE':
@@ -78,7 +71,7 @@ export function useDialogReducer(
}, [])
const handleShare = useCallback(
({ payload, unlockVideoSrc }: ShareOptions = {}) => {
({ payload }: ShareOptions = {}) => {
if (shareGuardRef.current) return
if (state.mode !== 'unlock') return
shareGuardRef.current = true
@@ -94,19 +87,15 @@ export function useDialogReducer(
setReflectionWork(sceneId, payload)
}
dispatch({ type: 'SHOW_RESULT', videoSrc: unlockVideoSrc })
dispatch({ type: 'SHOW_RESULT' })
},
[state, markExhibitVisited, setReflectionWork]
)
const finishBadgeVideo = useCallback(() => {
dispatch({ type: 'FINISH_BADGE_VIDEO' })
}, [])
const close = useCallback(() => {
shareGuardRef.current = false
dispatch({ type: 'CLOSE' })
}, [])
return { state, openScene, enterUnlock, handleShare, finishBadgeVideo, close }
return { state, openScene, enterUnlock, handleShare, close }
}
+76 -1
View File
@@ -4,7 +4,24 @@ import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { useNavigate, useParams } from 'react-router-dom'
import { useRongdeshengStore } from '@/store/rongdeshengStore'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import {
RONGDESHENG_KEY,
RONGDESHENG_LESSON_ID,
useRongdeshengStore,
} from '@/store/rongdeshengStore'
const deserializeVisitedExhibits = (
data: Record<string, number[]> | undefined
): Record<string, Set<number>> => {
if (!data || typeof data !== 'object') return {}
const visitedExhibits: Record<string, Set<number>> = {}
for (const [place, exhibits] of Object.entries(data)) {
visitedExhibits[place] = new Set(Array.isArray(exhibits) ? exhibits : [])
}
return visitedExhibits
}
const RongdeshengHome = () => {
const navigate = useNavigate()
@@ -25,6 +42,64 @@ const RongdeshengHome = () => {
}
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
;(async () => {
try {
const result = await getUserDefineResult(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY)
if (result.status !== 'success') {
useRongdeshengStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionCompleted: {},
reflectionVideoViewed: {},
reflectionFinalCompleted: false,
reflectionGuideShown: false,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
reflectionCompleted?: Record<string, boolean>
reflectionVideoViewed?: Record<string, Record<string, boolean>>
reflectionFinalCompleted?: boolean
reflectionGuideShown?: boolean
}
useRongdeshengStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
reflectionCompleted: data.reflectionCompleted ?? {},
reflectionVideoViewed: data.reflectionVideoViewed ?? {},
reflectionFinalCompleted: data.reflectionFinalCompleted ?? false,
reflectionGuideShown: data.reflectionGuideShown ?? false,
}))
} catch (error) {
useRongdeshengStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionCompleted: {},
reflectionVideoViewed: {},
reflectionFinalCompleted: false,
reflectionGuideShown: false,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Rongdesheng store', error)
}
})()
}, [])
if (!moduleName) return null
return (
+2 -2
View File
@@ -47,7 +47,7 @@ type QuizAnchor = {
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: 'quiz-1',
time: 60,
time: 60.5,
type: 'drag',
title: '',
prompt: '【拖拽】荣德生大力发展与国计民生密切相关的',
@@ -505,7 +505,7 @@ const RongdeshengInternalization = () => {
onContinue={() => {
setShowEnding(false)
setCourseTimeProgress(21)
navigate('/menu')
navigate('/learning-map', { state: { completedResidence: 'rongdesheng' } })
}}
/>
</div>
+1 -1
View File
@@ -262,7 +262,7 @@ const RongdeshengPlace2 = () => {
speaker="student"
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_07.mp3')}
>
哇,这是福新面粉公司的老照片!荣德生先生为什么选择办面粉,面粉厂又为什么在上海创办?福新面粉厂后来发展得怎么样?
哇,这是福新面粉公司的老照片!荣德生先生为什么选择办面粉厂,面粉厂又为什么在上海创办?福新面粉厂后来发展得怎么样?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
+1 -3
View File
@@ -186,8 +186,6 @@ const RongdeshengPlace4 = () => {
// 触发碎片获取动画(展品3 -> 碎片13,展品4 -> 碎片14)
if (closingId === 3) {
triggerPuzzleAnimation(13)
} else if (closingId === 4) {
triggerPuzzleAnimation(14)
}
if (closingId === 4) {
@@ -377,7 +375,7 @@ const RongdeshengPlace4 = () => {
<EndingOverlay
visible={endingGuideVisible}
audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_30.mp3')}
text="接下来,请你跨越时空,走进1915年的申新纺织厂,参与工厂的创办与运营中,在模拟决策中感受民族工业在风雨中崛起的力量。让我们带着这份感动与启发,传承爱国情怀,续写新时代的实业篇章。"
text="接下来,请你跨越时空,走进1915年的申新纺织厂,参与工厂的创办与运营,在模拟决策中感受民族工业在风雨中崛起的力量。让我们带着这份感动与启发,传承爱国情怀,续写新时代的实业篇章。"
animationSpeed={200}
onContinue={() => setEndingGuideVisible(false)}
/>
@@ -32,12 +32,17 @@ export const FinalScene = ({
{finalVideoStage !== 'idle' && (
<div className="fixed inset-0 z-50 w-full h-full bg-black">
{/* 返回按钮 */}
<div className="bg-[#5C524B] absolute right-6 top-6 z-10">
<Image src={getStaticImage('components/离开')} button onClick={onExit} />
</div>
<Video src={FINAL_VIDEO_SRC} autoPlay onEnded={onVideoEnded} className="w-full h-full" />
{finalVideoStage === 'ended' && (
<div
className="absolute w-[844px] h-[188px] bottom-5 right-160 bg-cover bg-center bg-no-repeat cursor-pointer"
className="absolute w-[844px] h-[188px] bottom-5 right-160 bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: getBackgroundImage('rongdesheng/reflection/提示版') }}
onClick={onExit}
// onClick={onExit}
>
<div className="absolute inset-0 flex flex-col items-center justify-center text-2xl font-normal p-4">
<div
@@ -48,7 +48,7 @@ export const QuestionPanel = ({
}}
onClick={() => onOptionSelect(option.id)}
>
<div className="flex justify-center items-center h-full relative">
<div className="flex justify-start items-center h-full relative pl-5">
<span className="text-[#6D6552] text-2xl">{option.text}</span>
<div className="absolute -left-8 -top-6">
{isSelected && option.isCorrect && (
@@ -1,9 +1,10 @@
export const HINT_TEXTS = {
answeredPending: '选择成功,在场景中点击对应气泡查看决策影响',
answeredAllViewed: '已了解所有决策影响,请点击提交查看结果',
answeredPending: '选择成功,在场景中点击对应气泡查看决策影响。',
answeredAllViewed: '已了解所有决策影响,请点击提交查看结果。',
partialCorrect: '还有合适的决策未选择,请继续补充。',
finalScene: '恭喜你成功闯过所有难关!点击合成按钮,解锁申新纺织厂从初创到自强的完整红色故事吧~',
finalEnded: '恭喜你完成推演!保存勋章,和同学分享申新纺织厂从初创到自强的红色实业故事吧~',
finalScene:
'恭喜你成功闯过所有难关!点击“合成坚守图谱”按钮,解锁申新纺织厂从初创到自强的完整红色故事吧~',
finalEnded: '恭喜你完成推演!和同学分享申新纺织厂从初创到自强的红色实业故事吧~',
} as const
export type HintKey = keyof typeof HINT_TEXTS
+6 -1
View File
@@ -13,11 +13,13 @@ import { HINT_AUDIOS, HINT_TEXTS } from './constants'
import { MODULES, getSceneTitle } from './modules.data'
import type { ModuleId, ModuleState, VideoPlayingState } from './types'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const RongdeshengReflection = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const starName = 'rongdesheng'
const { triggerPuzzleAnimation } = usePuzzleAnimation()
// 持久化 store
const persistedCompleted = useRongdeshengStore((s) => s.reflectionCompleted)
@@ -223,6 +225,9 @@ const RongdeshengReflection = () => {
[currentModuleId]: { ...prev[currentModuleId], completed: true },
}))
markReflectionCompleted(currentModuleId)
if (currentModuleId === 'scene3') {
triggerPuzzleAnimation(14)
}
}
// 结算页"下一场景":切到下一场景或进入最终合成场景
@@ -296,7 +301,7 @@ const RongdeshengReflection = () => {
{/* 返回按钮 */}
<Image
className="absolute right-5 top-5 z-10"
src={getStaticImage('components/返回')}
src={getStaticImage('components/离开')}
button
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
@@ -11,17 +11,17 @@ export const MODULES: Module[] = [
options: [
{
id: 'opt1-1',
text: '选址上海杨树浦(临黄浦江,便于原料运输)',
text: 'A.选址上海杨树浦(临黄浦江,便于原料运输)',
isCorrect: true,
videoResourceId: 'video1-1',
},
{
id: 'opt1-2',
text: '联合无锡帮华商与实业同仁招股筹款',
text: 'B.联合无锡帮华商与实业同仁招股筹款',
isCorrect: true,
videoResourceId: 'video1-2',
},
{ id: 'opt1-3', text: '依附租界洋行,换取建厂便利与资金支持', isCorrect: false },
{ id: 'opt1-3', text: 'C.依附租界洋行,换取建厂便利与资金支持', isCorrect: false },
],
videoResources: [
{
@@ -86,7 +86,7 @@ export const MODULES: Module[] = [
'护厂抗争的胜利,是民族工业坚守爱国初心的体现,也是国人联合“反抗压迫、团结抗争”的印证!',
resultImage: 'rongdesheng/reflection/场景2结算',
wrongHint:
'荣氏兄弟坚守"实业救国"初心,拒绝洋行干涉企业经营,不愿民族产业沦为外资附庸!试试其他方案吧~',
'荣氏兄弟坚守“实业救国”初心,拒绝洋行干涉企业经营,不愿民族产业沦为外资附庸!试试其他方案吧~',
audio: { wrongHint: '' },
},
{
@@ -181,7 +181,8 @@ export const MODULES: Module[] = [
{
id: 'opt5-1',
text: 'A.仿制外国先进设备,改良生产工艺',
isCorrect: false,
videoResourceId: 'video5-2',
isCorrect: true,
},
{
id: 'opt5-2',
@@ -192,8 +193,7 @@ export const MODULES: Module[] = [
{
id: 'opt5-3',
text: 'C.降价促销,以低价抢占市场',
videoResourceId: 'video5-2',
isCorrect: true,
isCorrect: false,
},
],
videoResources: [
+35 -13
View File
@@ -4,7 +4,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefine } from '@/lib/utils/api-common'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import {
useSunzhongshanStore,
SUNZHONGSHAN_LESSON_ID,
@@ -48,29 +49,50 @@ const Sunzhongshan = () => {
useEffect(() => {
;(async () => {
try {
const raw = await getUserDefine(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY)
if (!raw) return
const result = await getUserDefineResult(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY)
const data = JSON.parse(raw) as {
if (result.status !== 'success') {
useSunzhongshanStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
reflectionRoute?: string
}
const validReflectionRoute =
data.reflectionRoute &&
['reflection', 'reflection1', 'reflection2', 'reflection3'].includes(data.reflectionRoute)
? data.reflectionRoute
: 'reflection'
useSunzhongshanStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace:
data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
reflectionRoute:
data.reflectionRoute &&
['reflection', 'reflection1', 'reflection2', 'reflection3'].includes(
data.reflectionRoute
)
? data.reflectionRoute
: (prev.reflectionRoute ?? 'reflection'),
lastVisitedPlace: data.lastVisitedPlace ?? null,
reflectionRoute: validReflectionRoute,
}))
} catch (error) {
useSunzhongshanStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Sunzhongshan store', error)
}
})()
+1 -1
View File
@@ -555,7 +555,7 @@ const SunzhongshanInternalization = () => {
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(6)
navigate('/menu')
navigate('/learning-map', { state: { completedResidence: 'sunzhongshan' } })
}}
/>
</div>
+3 -3
View File
@@ -422,12 +422,12 @@ const SunzhongshanPlace1 = () => {
哇!这张《申报》好震撼,“中华民国万岁”“孙大总统万岁”,就连发行日期写的都是“中华民国元年元月二日”!
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={audio('14')}>
{`没错!资产阶级民主革命的尝试走过了风雨兼程的十余年,这一过程中孙中山先生一直在探寻救国道路,1905年孙先生在亲自撰写的《<民报>发刊词》中明确提出了三民主义,即民族、民权、民生这一资产阶级民主革命的政纲。`}
{`没错!资产阶级民主革命的尝试走过了风雨兼程的十余年,这一过程中孙中山先生一直在探寻救国道路。1905年孙先生在亲自撰写的《<民报>发刊词》中明确提出了“三民主义”这一资产阶级民主革命政纲。`}
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={audio('15')}>
<GuideStepsItem speaker="guide" audioSrc={audio('15_1')}>
这些尝试终于在武昌起义过后迎来了回响。1911年年末,湖南、广东等14个省和上海纷纷宣布脱离清政府独立。
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={audio('16')}>
<GuideStepsItem speaker="guide" audioSrc={audio('15_2')}>
1912年1月1日上午,上海有一万多人聚集在沪宁车站,欢送孙先生前去南京就任临时大总统。当天夜里11点,孙先生在原清朝两江总督府宣誓就职,新的共和政体就此诞生。彼时仍属农历辛亥年,因此历史上将这一系列事件称为“辛亥革命”。
</GuideStepsItem>
</GuideSteps>
+4 -4
View File
@@ -258,7 +258,7 @@ const SunzhongshanPlace2 = () => {
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_23_2.mp3')}
>
旁边这本《实业计划》中译稿手稿,就是他对中国实业发展的整体构想,里面不仅有铁路,还有港口、工厂等规划。这个计划也是孙先生在上海居住期间完成的。
而《实业计划》这本著作,就是他对中国实业发展的整体构想,里面不仅有铁路,还有港口、工厂等规划。这个计划也是孙先生在上海居住期间完成的。
</GuideStepsItem>
</GuideSteps>
@@ -384,13 +384,13 @@ const SunzhongshanPlace2 = () => {
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_25_1.mp3')}
>
是的,1913年3月“宋教仁案”将国民党和袁世凯方面的矛盾推向高峰。不愿坐视袁世凯窃取辛亥革命果实后独揽大权,革命党人发动了“二次革命”。可惜革命失败,孙中山先生被迫流亡海外。
是的,1913年3月“宋教仁案”将国民党和袁世凯方面的矛盾推向高峰。不愿坐视袁世凯窃取辛亥革命果实后独揽大权,革命党人发动了“二次革命”。
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_25_2.mp3')}
>
袁世凯趁机撤销了他“筹办全国铁路全权”的职务,把铁路公司的事权交给了北京政府交通部,公司就这样解散了。
可惜革命失败,孙中山先生被迫流亡海外,袁世凯趁机撤销了他“筹办全国铁路全权”的职务,把铁路公司的事权交给了北京政府交通部,公司就这样解散了。
</GuideStepsItem>
</GuideSteps>
@@ -413,7 +413,7 @@ const SunzhongshanPlace2 = () => {
<SingleChoiceQuizPanel
visible={singleQuiz2Visible}
zIndex={10000}
title="【单选】护法运动,又称护法战争、第一次护法战争,1917年7月至1918年5月21日孙中山发动的维护、反对段祺瑞独裁统治的斗争。"
title="【单选】护法运动又称护法战争、第一次护法战争,是孙中山先生于1917年7月至次年5月发动的维护___、反对段祺瑞独裁统治的斗争。"
options={[
{ value: 'A', label: 'A. 中华民国临时政府组织大纲' },
{ value: 'B', label: 'B. 中华民国临时约法' },
+1 -1
View File
@@ -230,7 +230,7 @@ const SunzhongshanPlace3 = () => {
speaker="guide"
audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_37.mp3')}
>
这封信的收件人是胡适。信里提到,孙中山先生经历了陈炯明叛变事件回到上海后,态度变得很冷静,不再坚持之前的护法主张,反而提出”收军权于中央””结合民主的联合战线,与反动派决裂”等。
这封信的收件人是胡适。信里提到,孙中山先生经历了陈炯明叛变事件回到上海后,态度变得很冷静,不再坚持之前的护法主张,反而提出“收军权于中央”“结合民主的联合战线,与反动派决裂”等。
</GuideStepsItem>
<GuideStepsItem
speaker="student"
+5 -5
View File
@@ -424,11 +424,11 @@ const SunzhongshanReflectionScene1 = () => {
visible={showClueSuccessAlert}
title="恭喜你!"
content={clueSuccessAlertContent}
audioSrc={
clueSuccessAlertContent === '获得1条线索,已获得线索可在线索簿中查看!'
? getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_49.mp3')
: undefined
}
// audioSrc={
// clueSuccessAlertContent === '获得1条线索,已获得线索可在线索簿中查看!'
// ? getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_49.mp3')
// : undefined
// }
onClose={handleClueSuccessAlertClose}
/>
+5 -5
View File
@@ -438,11 +438,11 @@ const SunzhongshanReflectionScene2 = () => {
visible={showClueSuccessAlert}
title="恭喜你!"
content={clueSuccessAlertContent}
audioSrc={
clueSuccessAlertContent === '获得1条线索,已获得线索可在线索簿中查看!'
? getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_49.mp3')
: undefined
}
// audioSrc={
// clueSuccessAlertContent === '获得1条线索,已获得线索可在线索簿中查看!'
// ? getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_49.mp3')
// : undefined
// }
onClose={handleClueSuccessAlertClose}
/>
+5 -5
View File
@@ -484,11 +484,11 @@ const SunzhongshanReflectionScene3 = () => {
visible={showClueSuccessAlert}
title="恭喜你!"
content={clueSuccessAlertContent}
audioSrc={
clueSuccessAlertContent === '获得1条线索,已获得线索可在线索簿中查看!'
? getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_49.mp3')
: undefined
}
// audioSrc={
// clueSuccessAlertContent === '获得1条线索,已获得线索可在线索簿中查看!'
// ? getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_49.mp3')
// : undefined
// }
onClose={handleClueSuccessAlertClose}
/>
+7 -2
View File
@@ -1,4 +1,4 @@
import { toPng } from 'html-to-image'
import { toJpeg } from 'html-to-image'
export const STAR_NAME = 'zhangleping'
@@ -224,11 +224,16 @@ export async function buildCompositeDataUrl(compositionEl: HTMLElement): Promise
})
try {
return await toPng(compositionEl, {
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 {
// 恢复原始样式
+22 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ExhibitFlipOverlay, FlipActionButton, Image } from '@/components'
import { ExhibitFlipOverlay, FlipActionButton, Image, SceneHint } from '@/components'
import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { filterInput } from '@/lib/utils/sensitiveWords'
import { useNavigate, useParams } from 'react-router-dom'
import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
import {
@@ -37,6 +38,7 @@ const ZhanglepingDraw1 = () => {
const [createdArtworkSrc, setCreatedArtworkSrc] = useState('')
const [showCreatedOverlay, setShowCreatedOverlay] = useState(false)
const [showCompletionDialogue, setShowCompletionDialogue] = useState(false)
const [submitErrorHintKey, setSubmitErrorHintKey] = useState(0)
const captionInputRef = useRef<HTMLInputElement>(null)
const compositionRef = useRef<HTMLDivElement>(null)
@@ -84,15 +86,23 @@ const ZhanglepingDraw1 = () => {
}
if (isSubmitting || !compositionRef.current) return
const filteredCaptionText = filterInput(captionText.trim())
if (Array.from(filteredCaptionText).length > 15) {
setCaptionText(Array.from(filteredCaptionText).slice(0, 15).join(''))
setSubmitErrorHintKey((key) => key + 1)
return
}
setIsSubmitting(true)
try {
const dataUrl = await buildCompositeDataUrl(compositionRef.current)
setDrawArtwork(dataUrl, captionText.trim())
setDrawArtwork(dataUrl, filteredCaptionText)
saveZhanglepingStore()
setCreatedArtworkSrc(dataUrl)
setShowCreatedOverlay(true)
} catch (error) {
console.error('Failed to submit draw artwork', error)
setSubmitErrorHintKey((key) => key + 1)
} finally {
setIsSubmitting(false)
}
@@ -174,7 +184,7 @@ const ZhanglepingDraw1 = () => {
<div>①为三毛换装</div>
<div>②搭配1个新时代场景</div>
<div>③融入1-3个新时代元素</div>
<div>④配1句简短正能量文字</div>
<div>④配1句简短正能量文字(不超过15个字)</div>
</div>
</div>
</div>
@@ -210,13 +220,15 @@ const ZhanglepingDraw1 = () => {
name="textInput"
maxLength={15}
value={captionText}
onChange={(e) => setCaptionText(Array.from(e.target.value).slice(0, 15).join(''))}
onChange={(e) =>
setCaptionText(Array.from(filterInput(e.target.value)).slice(0, 15).join(''))
}
className={cn(
'w-200 h-24 bg-transparent',
'w-260 h-24 bg-transparent',
'text-4xl! text-[#8B6914] leading-none placeholder:text-3xl!'
)}
autoComplete="off"
placeholder="提交之前,请给你创作的我配一句简短的正能量文字吧!"
placeholder="提交之前,请给你创作的我配一句简短的正能量文字吧!(不超过15个字)"
/>
</div>
</div>
@@ -239,6 +251,10 @@ const ZhanglepingDraw1 = () => {
/>
</div>
<SceneHint when={submitErrorHintKey > 0} triggerKey={submitErrorHintKey} durationMs={3200}>
作品生成失败,请稍后再试
</SceneHint>
{createdArtworkSrc && (
<ExhibitFlipOverlay
src={createdArtworkSrc}
+1 -1
View File
@@ -36,7 +36,7 @@ const ZhanglepingDraw = () => {
<div>①为三毛换装</div>
<div>②搭配1个新时代场景</div>
<div>③融入1-3个新时代元素</div>
<div>④配1句简短正能量文字</div>
<div>④配1句简短正能量文字(不超过15个字)</div>
</div>
</div>
</div>
+95 -1
View File
@@ -4,7 +4,26 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { useZhanglepingStore } from '@/store/zhanglepingStore'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import {
ZHANGLEPING_KEY,
ZHANGLEPING_LESSON_ID,
useZhanglepingStore,
} from '@/store/zhanglepingStore'
const deserializeVisitedExhibits = (
data: Record<string, number[]> | undefined
): Record<string, Set<number>> => {
if (!data || typeof data !== 'object') return {}
const visitedExhibits: Record<string, Set<number>> = {}
for (const [place, exhibits] of Object.entries(data)) {
visitedExhibits[place] = new Set(Array.isArray(exhibits) ? exhibits : [])
}
return visitedExhibits
}
const deserializeVisitedCursors = deserializeVisitedExhibits
const ZhanglepingHome = () => {
const navigate = useNavigate()
@@ -27,6 +46,81 @@ const ZhanglepingHome = () => {
}
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
;(async () => {
try {
const result = await getUserDefineResult(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY)
if (result.status !== 'success') {
useZhanglepingStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
visitedReflectionCursors: {},
drawArtworkDataUrl: '',
drawArtworkCaption: '',
hasDrawArtwork: false,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
reflectionRoute?: string
visitedReflectionCursors?: Record<string, number[]>
drawArtworkDataUrl?: string
drawArtworkCaption?: string
hasDrawArtwork?: boolean
}
const validReflectionRoute =
data.reflectionRoute &&
[
'reflection',
'reflection1',
'reflection2',
'reflection3',
'reflection4',
'reflection5',
].includes(data.reflectionRoute)
? data.reflectionRoute
: 'reflection'
useZhanglepingStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
reflectionRoute: validReflectionRoute,
visitedReflectionCursors: deserializeVisitedCursors(data.visitedReflectionCursors),
drawArtworkDataUrl: data.drawArtworkDataUrl ?? '',
drawArtworkCaption: data.drawArtworkCaption ?? '',
hasDrawArtwork: data.hasDrawArtwork ?? false,
}))
} catch (error) {
useZhanglepingStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
visitedReflectionCursors: {},
drawArtworkDataUrl: '',
drawArtworkCaption: '',
hasDrawArtwork: false,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Zhangleping store', error)
}
})()
}, [])
if (!moduleName) return null
return (
+13 -5
View File
@@ -14,6 +14,7 @@ import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/lay
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { GuideSteps, GuideStepsItem } from '@/components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'zhangleping'
@@ -397,20 +398,26 @@ const ZhanglepingInternalization = () => {
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_32.mp3')}
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_32_1.mp3')}
>
小沪,刚刚完成“三毛寻忆”的时空任务太有收获了!从童年学艺的老画桌到新中国的画架,我跟着三毛的记忆碎片,真切感受到张乐平先生用画笔记录时代、守护童心的坚守。
</GuideStepsItem>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_32_2.mp3')}
>
尤其是先生笔下三毛的形象从流浪儿到少先队员的转变,还有不同年代里“画笔如刀枪、如镜子、如园丁”的力量,都让我特别触动。但我还想从美术这一艺术创作角度出发更多了解张乐平先生,你能给我讲讲吗?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_33.mp3')}
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_33_1.mp3')}
>
你的感悟特别深刻!关于你想了解的美术创作,今天我们有幸邀请到了上海大学美术学院设计系副主任、副教授汪宁,汪教授对张乐平漫画艺术开展过专门研究,对先生的创作理念与时代价值有着深入的理解。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_33_2.mp3')}
>
接下来,就让我们跟随汪教授一起,探寻张乐平艺术作品和艺术精神的当代意义吧!
</GuideStepsItem>
</GuideSteps>
@@ -485,7 +492,8 @@ const ZhanglepingInternalization = () => {
animationSpeed={200}
onContinue={() => {
setShowStudyGuide(false)
navigate('/menu')
setCourseTimeProgress(18)
navigate('/learning-map', { state: { completedResidence: 'zhangleping' } })
}}
/>
</div>
+1 -1
View File
@@ -186,7 +186,7 @@ const ZhanglepingPlace1 = () => {
speaker="guide"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_05_3.mp3')}
>
但是因为家境贫寒,张先生15岁就只身来到南汇万祥镇,在木料行当学徒,其间他在老板的纸堆上偷偷画画,哪怕被责打也不放弃,还用芦条在沙滩上画巨龙,巨龙里蕴藏的就是他“以画报国”的初心。
但是因为家境贫寒,张先生15岁就只身来到南汇万祥镇,在木料行当学徒,其间他在老板的纸媒上偷偷画画,哪怕被责打也不放弃,还用芦条在沙滩上画巨龙,巨龙里藏着的就是他“以画报国”的初心。
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
+6 -3
View File
@@ -11,8 +11,9 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useEffect, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import { useZhanglepingStore } from '@/store/zhanglepingStore'
import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
// starName 固定为 zhangleping
const starName = 'zhangleping'
@@ -122,6 +123,8 @@ const ZhanglepingPlace5 = () => {
button
onClick={() => {
if (moduleName && starName) {
saveZhanglepingStore()
setCourseTimeProgress(16)
navigate(`/${moduleName}/${starName}/home`)
}
}}
@@ -172,7 +175,7 @@ const ZhanglepingPlace5 = () => {
speaker="student"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_16.mp3')}
>
小沪,三毛系列漫画真的太深入人心了!张乐平先生还会和“小三毛迷”们互通书信,张先生真的是非常喜欢儿童呀!
朋友画我!这一整面墙的朋友笔下的张乐平先生和三毛真是各有特色啊!
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
@@ -196,7 +199,7 @@ const ZhanglepingPlace5 = () => {
speaker="guide"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_17_4.mp3')}
>
这些画作中流淌的是承载着跨越半个世纪的深厚情谊与中国漫画事业的代际传承,更是用漫画传递真善美的中国漫画精神的重要见证。
在这些画作中流淌的是承载着跨越半个世纪的深厚情谊与中国漫画事业的代际传承,更是用漫画传递真善美的中国漫画精神的重要见证。
</GuideStepsItem>
</GuideSteps>
+19 -3
View File
@@ -3,6 +3,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Image, StudyGuideModal, Video } from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'zhangleping'
@@ -93,6 +94,14 @@ const ZhanglepingReflection = () => {
const allFragmentsCompleted = sceneConfig.every((scene) => isSceneCompleted(scene.id))
// 监听allFragmentsCompleted
useEffect(() => {
if (allFragmentsCompleted) {
saveZhanglepingStore()
setCourseTimeProgress(17)
}
}, [allFragmentsCompleted])
// 场景逐步解锁:1 默认解锁;N 需要 N-1 完成
const isSceneUnlocked = (sceneId: SceneId) => {
if (sceneId === 1) return true
@@ -213,14 +222,14 @@ const ZhanglepingReflection = () => {
)
})}
{allFragmentsCompleted && (
{/* {allFragmentsCompleted && (
<Image
className="absolute bottom-0 right-0 z-10"
className="absolute bottom-0 right-0 z-20"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
)}
)} */}
{/* 碎片收集 */}
<div className="absolute right-0 top-[27%] w-[24%] h-[68%]">
@@ -250,6 +259,13 @@ const ZhanglepingReflection = () => {
/>
)
})}
{allFragmentsCompleted && (
<img
src={getStaticImage(`${starName}/reflection/home/敲章`)}
alt="敲章"
className="absolute right-0 -bottom-12 z-10"
/>
)}
</div>
</div>
</>
+120 -75
View File
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Image, Title, Cursor, ExhibitFlipOverlay, SceneHint } from '@/components'
import { Image, Title, Cursor, ExhibitFlipOverlay, DragFill, SceneHint } from '@/components'
import type { DragFillResult } from '@/components'
import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore'
import { SanmaoDialogueItem, SanmaoDialogueSteps } from '@/pages/zhangleping/components'
@@ -20,7 +21,7 @@ type ExhibitId = 1 | 2
type FragmentId = 3
const exhibitAssets: Record<ExhibitId, { src: string }> = {
1: { src: getStaticImage('zhangleping/reflection/scene3/场景3展品3') },
1: { src: getStaticImage('zhangleping/reflection/scene3/场景3展品2') },
2: { src: getStaticImage('zhangleping/reflection/scene3/场景3展品1') },
}
@@ -35,32 +36,48 @@ const exhibitDialogues: Record<ExhibitId, Record<string, string>> = {
},
}
// 第二阶段:点击常量
const CORRECT_CLICK_ITEM = 'click-3'
const CLICK_ERROR_HINT =
// 第二阶段:拖拽常量
const CORRECT_DRAG_OPTION = 'drag-3'
const DRAG_ERROR_HINT =
'这个物品虽然与三毛有关,但并不是解锁记忆碎片的关键。试试找找与“苦难里还有善良”相关的物品吧!'
const PERSISTENT_CLICK_HINT = '点击正确的物品解锁记忆碎片'
const CLICK_ERROR_HINT_DURATION_MS = 3200
const CLICK_ERROR_HINT_RESET_DELAY_MS = 3300
const PERSISTENT_DRAG_HINT = '拖拽正确的物品到这里解锁记忆碎片'
const DRAG_ERROR_HINT_DURATION_MS = 3200
const DRAG_ERROR_HINT_RESET_DELAY_MS = 3300
const CLICK_ITEMS = [
const DRAG_ITEMS = [
{
id: 'click-1',
id: 'drag-1',
className: 'absolute left-[3%] top-[6%]',
src: 'zhangleping/reflection/scene3/点击项1',
},
{
id: 'click-2',
id: 'drag-2',
className: 'absolute left-[36.3%] bottom-[15%]',
src: 'zhangleping/reflection/scene3/点击项2',
},
{
id: 'click-3',
id: 'drag-3',
className: 'absolute left-180 bottom-10',
src: 'zhangleping/reflection/scene3/点击项3',
},
] as const
type DragOptionValue = (typeof DRAG_ITEMS)[number]['id']
const DRAG_OPTION_ASSET_MAP: Record<DragOptionValue, { className: string; src: string }> =
DRAG_ITEMS.reduce(
(acc, item) => {
acc[item.id] = { className: item.className, src: item.src }
return acc
},
{} as Record<DragOptionValue, { className: string; src: string }>
)
const DRAG_OPTIONS = DRAG_ITEMS.map((i) => ({ value: i.id, label: i.id }))
type DragBlankId = 'fragment-drop'
const DRAG_BLANKS = [{ id: 'fragment-drop' as DragBlankId, label: '', required: true }]
const ZhanglepingReflectionScene3 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
@@ -102,33 +119,38 @@ const ZhanglepingReflectionScene3 = () => {
visited,
})
// 第二阶段:点击物品
const isClickPhase = resourcesCompleted && !fragmentCompleted
// 第二阶段:拖拽物品
const isDragPhase = resourcesCompleted && !fragmentCompleted
const [dragFillValue, setDragFillValue] = useState<DragFillResult[]>([])
const [showItemExhibit, setShowItemExhibit] = useState(false)
const [showItemDialogue, setShowItemDialogue] = useState(false)
const [showFragmentOverlay, setShowFragmentOverlay] = useState(false)
const [successHintKey, setSuccessHintKey] = useState(0)
const hint = useSceneHintController(isClickPhase, PERSISTENT_CLICK_HINT)
const hint = useSceneHintController(isDragPhase, PERSISTENT_DRAG_HINT)
const handleClickItem = useCallback(
(itemId: string) => {
if (!isClickPhase) return
const handleDragValueChange = useCallback(
(newValue: DragFillResult[]) => {
setDragFillValue(newValue)
if (!isDragPhase) return
if (itemId !== CORRECT_CLICK_ITEM) {
const answer = newValue.find((v) => v.blankId === 'fragment-drop')
if (!answer) return
if (answer.optionValue !== CORRECT_DRAG_OPTION) {
setDragFillValue([])
hint.showTempHint(
CLICK_ERROR_HINT,
CLICK_ERROR_HINT_DURATION_MS,
CLICK_ERROR_HINT_RESET_DELAY_MS
DRAG_ERROR_HINT,
DRAG_ERROR_HINT_DURATION_MS,
DRAG_ERROR_HINT_RESET_DELAY_MS
)
return
}
// 正确:弹出展品与对话(碎片在关闭碎片展品窗口时才解锁)
hint.clearAndReset()
setShowItemExhibit(true)
},
[hint, isClickPhase]
[hint, isDragPhase]
)
const handleCloseItemExhibit = useCallback(() => {
@@ -166,7 +188,7 @@ const ZhanglepingReflectionScene3 = () => {
className="w-full h-full bg-cover bg-center bg-no-repeat relative"
style={{
backgroundImage: getBackgroundImage(
`${STAR_NAME}/reflection/scene3/${isClickPhase ? '场景-点击' : '场景-进入'}`
`${STAR_NAME}/reflection/scene3/${isDragPhase ? '场景-点击' : '场景-进入'}`
),
}}
>
@@ -192,11 +214,11 @@ const ZhanglepingReflectionScene3 = () => {
苦难善良·记忆碎片已解锁
</SceneHint>
)}
{/* 场景提示:点击阶段常驻/错误提示 */}
{/* 场景提示:拖拽阶段常驻/错误提示 */}
{!showItemExhibit &&
!showFragmentOverlay &&
!fragmentCompleted &&
isClickPhase &&
isDragPhase &&
hint.hintMode && (
<SceneHint
when
@@ -209,46 +231,79 @@ const ZhanglepingReflectionScene3 = () => {
</SceneHint>
)}
{/* 静态物品(第一阶段纯展示,第二阶段可点击带发光效果) */}
{CLICK_ITEMS.map((item) => (
<div
key={item.id}
className={cn(item.className, isClickPhase && 'cursor-pointer')}
onClick={isClickPhase ? () => handleClickItem(item.id) : undefined}
style={isClickPhase ? { transition: 'filter 0.2s ease' } : undefined}
onMouseEnter={(e) => {
if (isClickPhase) {
e.currentTarget.style.filter =
'drop-shadow(0 0 8px rgba(255, 215, 0, 0.8)) drop-shadow(0 0 16px rgba(255, 215, 0, 0.5))'
}
{/* 静态物品(第一阶段纯展示,第二阶段可拖拽) */}
{!isDragPhase &&
DRAG_ITEMS.map((item) => (
<Image key={item.src} className={item.className} src={getStaticImage(item.src)} />
))}
{/* 第二阶段:拖拽物品到记忆碎片区域 */}
{isDragPhase && (
<DragFill
options={DRAG_OPTIONS}
blanks={DRAG_BLANKS}
value={dragFillValue}
onChange={handleDragValueChange}
showSubmitButton={false}
hideLabels
layout="horizontal-options"
className="absolute inset-0"
optionClassName="absolute inset-0"
blankClassName="hidden"
renderOption={(option, isUsed, isDragging) => {
const cfg = DRAG_OPTION_ASSET_MAP[option.value as DragOptionValue]
if (!cfg) return null
return (
<div className={cfg.className} style={{ opacity: isUsed ? 0 : 1 }}>
<Image src={getStaticImage(cfg.src)} />
{!isUsed && !isDragging && (
<div
className={cn(
'pointer-events-none absolute left-1/2 -translate-x-1/2 -bottom-10 z-10',
'bg-[#B08D6A] px-4 py-2 rounded-2xl',
'whitespace-nowrap text-2xl leading-none text-white'
)}
>
可拖拽
</div>
)}
</div>
)
}}
onMouseLeave={(e) => {
if (isClickPhase) {
e.currentTarget.style.filter = 'none'
}
renderBlank={(blank, _filledValue, isHovered) => (
<div
data-blank-id={blank.id}
className={cn(
'absolute left-[42.8%] top-[29%] w-[284px] h-[280px] bg-cover bg-center bg-no-repeat',
'flex items-center justify-center pl-2 transition-all duration-150',
isHovered && 'scale-105 drop-shadow-[0_0_20px_rgba(255,249,152,0.95)]'
)}
style={{ backgroundImage: getBackgroundImage('zhangleping/reflection/clue/泡泡') }}
>
<Image
style={{
filter: isHovered
? 'grayscale(100%) brightness(0) drop-shadow(0 0 12px rgba(255, 249, 152, 0.9))'
: 'grayscale(100%) brightness(0)',
opacity: isHovered ? 0.9 : 0.7,
}}
src={getStaticImage('zhangleping/reflection/clue/碎片3')}
/>
</div>
)}
renderDragging={(option) => {
const src = DRAG_OPTION_ASSET_MAP[option.value as DragOptionValue]?.src
if (!src) return null
return <img src={getStaticImage(src)} alt="" style={{ width: '8vw', height: 'auto' }} />
}}
onPointerDown={(e) => {
if (isClickPhase) {
e.currentTarget.style.filter =
'drop-shadow(0 0 4px rgba(255, 215, 0, 1)) drop-shadow(0 0 10px rgba(255, 215, 0, 0.7))'
}
}}
onPointerUp={(e) => {
if (isClickPhase) {
e.currentTarget.style.filter =
'drop-shadow(0 0 8px rgba(255, 215, 0, 0.8)) drop-shadow(0 0 16px rgba(255, 215, 0, 0.5))'
}
}}
>
<Image src={getStaticImage(item.src)} />
</div>
))}
/>
)}
{/* 第一阶段:两个光标,按顺序解锁 */}
{!resourcesCompleted && (
<>
<Cursor
className="absolute left-195 bottom-15"
className="absolute left-198 bottom-50"
size={0.6}
disabled={!isEnabled(1)}
read={visited.has(1)}
@@ -264,8 +319,8 @@ const ZhanglepingReflectionScene3 = () => {
</>
)}
{/* 记忆碎片区域(第二阶段及之后显示) */}
{(isClickPhase || fragmentCompleted) && (
{/* 记忆碎片区域(解锁后显示) */}
{fragmentCompleted && (
<div
className={cn(
'absolute left-[42.8%] top-[29%] w-[284px] h-[280px] bg-cover bg-center bg-no-repeat',
@@ -273,17 +328,7 @@ const ZhanglepingReflectionScene3 = () => {
)}
style={{ backgroundImage: getBackgroundImage('zhangleping/reflection/clue/泡泡') }}
>
<Image
style={
fragmentCompleted
? undefined
: {
filter: 'grayscale(100%) brightness(0)',
opacity: 0.7,
}
}
src={getStaticImage('zhangleping/reflection/clue/碎片3')}
/>
<Image src={getStaticImage('zhangleping/reflection/clue/碎片3')} />
</div>
)}
@@ -322,9 +367,9 @@ const ZhanglepingReflectionScene3 = () => {
</SanmaoDialogueSteps>
)}
{/* 第二阶段:点击物品2后的展品弹层(场景3展品2) */}
{/* 第二阶段:拖拽半块饼后的展品弹层(场景3展品3) */}
<ExhibitFlipOverlay
src={getStaticImage('zhangleping/reflection/scene3/场景3展品2')}
src={getStaticImage('zhangleping/reflection/scene3/场景3展品3')}
visible={showItemExhibit}
onEnterComplete={() => setShowItemDialogue(true)}
onClose={handleCloseItemExhibit}
+1 -1
View File
@@ -176,7 +176,7 @@ const ZhanglepingReflectionScene5 = () => {
if (!hasSubmittedArtwork) return
try {
await downloadDataUrlImage(drawArtworkDataUrl, '三毛新时代图景.png')
await downloadDataUrlImage(drawArtworkDataUrl, '三毛新时代图景.jpg')
} catch (error) {
console.error('Download artwork failed', error)
}