diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e8d5858..4901e57 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -58,7 +58,8 @@ "Bash(git config *)", "WebSearch", "Bash(grep -v \"^--$\")", - "Bash(grep -E \"^\\\\S+.*>.*$\")" + "Bash(grep -E \"^\\\\S+.*>.*$\")", + "Bash(git -C \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-ab7540fc239364906\" status --short)" ], "deny": [ "Read(public/**)", diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b62b6fe --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_DEBUG_ENTRY=false diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..b62b6fe --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +VITE_DEBUG_ENTRY=false diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..2a682cd --- /dev/null +++ b/.env.test @@ -0,0 +1 @@ +VITE_DEBUG_ENTRY=true diff --git a/public/audio/chenyun/mp3_chenyun_05.mp3 b/public/audio/chenyun/mp3_chenyun_05.mp3 index 5675ebb..4663c26 100644 Binary files a/public/audio/chenyun/mp3_chenyun_05.mp3 and b/public/audio/chenyun/mp3_chenyun_05.mp3 differ diff --git a/public/audio/chenyun/mp3_chenyun_17.mp3 b/public/audio/chenyun/mp3_chenyun_17.mp3 index a7138e0..3f90ef5 100644 Binary files a/public/audio/chenyun/mp3_chenyun_17.mp3 and b/public/audio/chenyun/mp3_chenyun_17.mp3 differ diff --git a/public/audio/huangyanpei/mp3_huangyanpei_07.mp3 b/public/audio/huangyanpei/mp3_huangyanpei_07.mp3 index 5942d5f..8800b2d 100644 Binary files a/public/audio/huangyanpei/mp3_huangyanpei_07.mp3 and b/public/audio/huangyanpei/mp3_huangyanpei_07.mp3 differ diff --git a/public/audio/huangyanpei/mp3_huangyanpei_15.mp3 b/public/audio/huangyanpei/mp3_huangyanpei_15.mp3 index fd69e5e..576c8e4 100644 Binary files a/public/audio/huangyanpei/mp3_huangyanpei_15.mp3 and b/public/audio/huangyanpei/mp3_huangyanpei_15.mp3 differ diff --git a/public/audio/huangyanpei/mp3_huangyanpei_18.mp3 b/public/audio/huangyanpei/mp3_huangyanpei_18.mp3 index 691470a..00be52a 100644 Binary files a/public/audio/huangyanpei/mp3_huangyanpei_18.mp3 and b/public/audio/huangyanpei/mp3_huangyanpei_18.mp3 differ diff --git a/public/audio/huangyanpei/mp3_huangyanpei_26.mp3 b/public/audio/huangyanpei/mp3_huangyanpei_26.mp3 index 12ce040..2243c32 100644 Binary files a/public/audio/huangyanpei/mp3_huangyanpei_26.mp3 and b/public/audio/huangyanpei/mp3_huangyanpei_26.mp3 differ diff --git a/public/audio/luxun/mp3_luxun_03.mp3 b/public/audio/luxun/mp3_luxun_03.mp3 index d232d14..3551b43 100644 Binary files a/public/audio/luxun/mp3_luxun_03.mp3 and b/public/audio/luxun/mp3_luxun_03.mp3 differ diff --git a/public/audio/luxun/mp3_luxun_14.mp3 b/public/audio/luxun/mp3_luxun_14.mp3 index 56e9606..bd1df16 100644 Binary files a/public/audio/luxun/mp3_luxun_14.mp3 and b/public/audio/luxun/mp3_luxun_14.mp3 differ diff --git a/public/audio/luxun/mp3_luxun_20.mp3 b/public/audio/luxun/mp3_luxun_20.mp3 index 6553697..d89fe9a 100644 Binary files a/public/audio/luxun/mp3_luxun_20.mp3 and b/public/audio/luxun/mp3_luxun_20.mp3 differ diff --git a/public/audio/luxun/mp3_luxun_22.mp3 b/public/audio/luxun/mp3_luxun_22.mp3 index d17c76d..29e7c3b 100644 Binary files a/public/audio/luxun/mp3_luxun_22.mp3 and b/public/audio/luxun/mp3_luxun_22.mp3 differ diff --git a/public/audio/luxun/mp3_luxun_23.mp3 b/public/audio/luxun/mp3_luxun_23.mp3 index 804daef..4f274c9 100644 Binary files a/public/audio/luxun/mp3_luxun_23.mp3 and b/public/audio/luxun/mp3_luxun_23.mp3 differ diff --git a/public/audio/sunzhongshan/mp3_sunzhongshan_13.mp3 b/public/audio/sunzhongshan/mp3_sunzhongshan_13.mp3 index 8278ae6..68e1edb 100644 Binary files a/public/audio/sunzhongshan/mp3_sunzhongshan_13.mp3 and b/public/audio/sunzhongshan/mp3_sunzhongshan_13.mp3 differ diff --git a/public/audio/sunzhongshan/mp3_sunzhongshan_14.mp3 b/public/audio/sunzhongshan/mp3_sunzhongshan_14.mp3 index 56a1553..1c79087 100644 Binary files a/public/audio/sunzhongshan/mp3_sunzhongshan_14.mp3 and b/public/audio/sunzhongshan/mp3_sunzhongshan_14.mp3 differ diff --git a/public/audio/sunzhongshan/mp3_sunzhongshan_20.mp3 b/public/audio/sunzhongshan/mp3_sunzhongshan_20.mp3 index 30fc64f..da12448 100644 Binary files a/public/audio/sunzhongshan/mp3_sunzhongshan_20.mp3 and b/public/audio/sunzhongshan/mp3_sunzhongshan_20.mp3 differ diff --git a/public/audio/sunzhongshan/mp3_sunzhongshan_40.mp3 b/public/audio/sunzhongshan/mp3_sunzhongshan_40.mp3 index 655fc4a..2bc7633 100644 Binary files a/public/audio/sunzhongshan/mp3_sunzhongshan_40.mp3 and b/public/audio/sunzhongshan/mp3_sunzhongshan_40.mp3 differ diff --git a/src/App.tsx b/src/App.tsx index 7759780..ba6f734 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,15 @@ import { ScaleProvider, useScale } from './contexts/ScaleContext' import { RotateHint } from './components/RotateHint' import { HintHost } from './components' import { useUserStore } from './store' -import { getUserDefine } from '@/lib/utils/api-common' +import { getUserDefineResult } from '@/lib/utils/api-common' +import { + SyncLoadingHost, + SyncPromptHost, + hideSyncLoading, + showSyncLoading, + showYibanSyncErrorPrompt, + showYibanUnauthenticatedPrompt, +} from '@/components' import { usePuzzleStore, deserializePuzzles, @@ -13,6 +21,13 @@ import { PUZZLE_KEY, } from '@/store/puzzleStore' +const applyStoredProgressionDebugMode = async () => { + if (import.meta.env.VITE_DEBUG_ENTRY !== 'true') return false + + const debug = await import('@/debug/progressionDebug') + return debug.applyStoredProgressionDebugMode() +} + /** * 应用内容组件 * 使用 useScale 获取缩放样式 @@ -36,20 +51,39 @@ function AppContent() { useEffect(() => { ;(async () => { + showSyncLoading() try { - const raw = await getUserDefine(PUZZLE_LESSON_ID, PUZZLE_KEY) - if (raw) { - const data = JSON.parse(raw) as { puzzles?: Record } - if (data.puzzles) { - usePuzzleStore.setState((prev) => ({ - ...prev, - puzzles: deserializePuzzles(data.puzzles), - })) - } + const result = await getUserDefineResult(PUZZLE_LESSON_ID, PUZZLE_KEY) + + if (result.status === 'success') { + const data = JSON.parse(result.content) as { puzzles?: Record } + usePuzzleStore.setState((prev) => ({ + ...prev, + puzzles: deserializePuzzles(data.puzzles), + })) + return + } + + usePuzzleStore.setState((prev) => ({ + ...prev, + puzzles: {}, + })) + + if (result.status === 'unauthenticated') { + showYibanUnauthenticatedPrompt() + } else if (result.status === 'error') { + showYibanSyncErrorPrompt() } } catch (e) { + usePuzzleStore.setState((prev) => ({ + ...prev, + puzzles: {}, + })) + showYibanSyncErrorPrompt() console.error('Failed to load puzzle store', e) } finally { + await applyStoredProgressionDebugMode() + hideSyncLoading() usePuzzleStore.getState().setLoaded(true) } })() @@ -67,6 +101,8 @@ function AppContent() { Loading...}> + + diff --git a/src/assets/images/chenyun/陈云故居底图.png b/src/assets/images/chenyun/陈云故居底图.png index 2978024..e218b19 100644 Binary files a/src/assets/images/chenyun/陈云故居底图.png and b/src/assets/images/chenyun/陈云故居底图.png differ diff --git a/src/assets/images/components/guide/border.png b/src/assets/images/components/guide/border.png new file mode 100644 index 0000000..f14a40f Binary files /dev/null and b/src/assets/images/components/guide/border.png differ diff --git a/src/assets/images/components/guide/frame-bottom.png b/src/assets/images/components/guide/frame-bottom.png new file mode 100644 index 0000000..ce6988c Binary files /dev/null and b/src/assets/images/components/guide/frame-bottom.png differ diff --git a/src/assets/images/components/guide/frame-middle.png b/src/assets/images/components/guide/frame-middle.png new file mode 100644 index 0000000..4e1743b Binary files /dev/null and b/src/assets/images/components/guide/frame-middle.png differ diff --git a/src/assets/images/components/guide/frame-top.png b/src/assets/images/components/guide/frame-top.png new file mode 100644 index 0000000..5102c9b Binary files /dev/null and b/src/assets/images/components/guide/frame-top.png differ diff --git a/src/assets/images/components/guide/star.png b/src/assets/images/components/guide/star.png new file mode 100644 index 0000000..5acb2ad Binary files /dev/null and b/src/assets/images/components/guide/star.png differ diff --git a/src/assets/images/components/guide/title.png b/src/assets/images/components/guide/title.png new file mode 100644 index 0000000..84ef3b7 Binary files /dev/null and b/src/assets/images/components/guide/title.png differ diff --git a/src/assets/images/components/guide/引导框big.png b/src/assets/images/components/guide/引导框big.png new file mode 100644 index 0000000..9446412 Binary files /dev/null and b/src/assets/images/components/guide/引导框big.png differ diff --git a/src/assets/images/components/guide/效果.png b/src/assets/images/components/guide/效果.png new file mode 100644 index 0000000..fadad83 Binary files /dev/null and b/src/assets/images/components/guide/效果.png differ diff --git a/src/assets/images/components/guide/竹叶.png b/src/assets/images/components/guide/竹叶.png new file mode 100644 index 0000000..b2cbf68 Binary files /dev/null and b/src/assets/images/components/guide/竹叶.png differ diff --git a/src/assets/images/entrance/经济巨擎.png b/src/assets/images/entrance/经济巨擎.png index 190995a..5569cbb 100644 Binary files a/src/assets/images/entrance/经济巨擎.png and b/src/assets/images/entrance/经济巨擎.png differ diff --git a/src/assets/images/home/点击关闭.png b/src/assets/images/home/点击关闭.png new file mode 100644 index 0000000..a03ea50 Binary files /dev/null and b/src/assets/images/home/点击关闭.png differ diff --git a/src/assets/images/home/课程简介.png b/src/assets/images/home/课程简介.png new file mode 100644 index 0000000..2174a43 Binary files /dev/null and b/src/assets/images/home/课程简介.png differ diff --git a/src/assets/images/huangyanpei/背景.png b/src/assets/images/huangyanpei/背景.png index 4f0ee9b..5ba1c4a 100644 Binary files a/src/assets/images/huangyanpei/背景.png and b/src/assets/images/huangyanpei/背景.png differ diff --git a/src/assets/images/learning-map/spotlight/孙中山详情.png b/src/assets/images/learning-map/spotlight/孙中山详情.png index b7f845a..49eb9a0 100644 Binary files a/src/assets/images/learning-map/spotlight/孙中山详情.png and b/src/assets/images/learning-map/spotlight/孙中山详情.png differ diff --git a/src/assets/images/learning-map/spotlight/陈云详情.png b/src/assets/images/learning-map/spotlight/陈云详情.png index 055c626..cb6ab32 100644 Binary files a/src/assets/images/learning-map/spotlight/陈云详情.png and b/src/assets/images/learning-map/spotlight/陈云详情.png differ diff --git a/src/assets/images/learning-map/spotlight/黄炎培详情.png b/src/assets/images/learning-map/spotlight/黄炎培详情.png index 79e87ba..072b67f 100644 Binary files a/src/assets/images/learning-map/spotlight/黄炎培详情.png and b/src/assets/images/learning-map/spotlight/黄炎培详情.png differ diff --git a/src/assets/images/maozedong/毛泽东故居底图.png b/src/assets/images/maozedong/毛泽东故居底图.png index 87fac7d..161b45d 100644 Binary files a/src/assets/images/maozedong/毛泽东故居底图.png and b/src/assets/images/maozedong/毛泽东故居底图.png differ diff --git a/src/assets/images/nieer/聂耳旧居底图.png b/src/assets/images/nieer/聂耳旧居底图.png index f77b896..06cc906 100644 Binary files a/src/assets/images/nieer/聂耳旧居底图.png and b/src/assets/images/nieer/聂耳旧居底图.png differ diff --git a/src/assets/images/puzzle/huangyanpei/details/15.png b/src/assets/images/puzzle/huangyanpei/details/15.png index 85bb180..8988c54 100644 Binary files a/src/assets/images/puzzle/huangyanpei/details/15.png and b/src/assets/images/puzzle/huangyanpei/details/15.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/1.png b/src/assets/images/puzzle/rongdesheng/details/1.png index 6138b7b..b049585 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/1.png and b/src/assets/images/puzzle/rongdesheng/details/1.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/10.png b/src/assets/images/puzzle/rongdesheng/details/10.png index 39f04bc..4d01edd 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/10.png and b/src/assets/images/puzzle/rongdesheng/details/10.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/11.png b/src/assets/images/puzzle/rongdesheng/details/11.png index b59f005..0b27a5a 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/11.png and b/src/assets/images/puzzle/rongdesheng/details/11.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/12.png b/src/assets/images/puzzle/rongdesheng/details/12.png index c8856d9..c967d3f 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/12.png and b/src/assets/images/puzzle/rongdesheng/details/12.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/13.png b/src/assets/images/puzzle/rongdesheng/details/13.png index 6bee422..f0ec954 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/13.png and b/src/assets/images/puzzle/rongdesheng/details/13.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/14.png b/src/assets/images/puzzle/rongdesheng/details/14.png index a246cc4..c83756a 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/14.png and b/src/assets/images/puzzle/rongdesheng/details/14.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/15.png b/src/assets/images/puzzle/rongdesheng/details/15.png index f598cdc..e6ba34d 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/15.png and b/src/assets/images/puzzle/rongdesheng/details/15.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/16.png b/src/assets/images/puzzle/rongdesheng/details/16.png index d308ff9..711a1ae 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/16.png and b/src/assets/images/puzzle/rongdesheng/details/16.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/2.png b/src/assets/images/puzzle/rongdesheng/details/2.png index 0f047e3..228bbb9 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/2.png and b/src/assets/images/puzzle/rongdesheng/details/2.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/3.png b/src/assets/images/puzzle/rongdesheng/details/3.png index e645840..695f7c1 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/3.png and b/src/assets/images/puzzle/rongdesheng/details/3.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/4.png b/src/assets/images/puzzle/rongdesheng/details/4.png index 4ef7a86..9c83ef6 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/4.png and b/src/assets/images/puzzle/rongdesheng/details/4.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/5.png b/src/assets/images/puzzle/rongdesheng/details/5.png index ca63cf2..9373b96 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/5.png and b/src/assets/images/puzzle/rongdesheng/details/5.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/6.png b/src/assets/images/puzzle/rongdesheng/details/6.png index 5592266..1050c43 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/6.png and b/src/assets/images/puzzle/rongdesheng/details/6.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/7.png b/src/assets/images/puzzle/rongdesheng/details/7.png index 88cfbf9..dd5f359 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/7.png and b/src/assets/images/puzzle/rongdesheng/details/7.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/8.png b/src/assets/images/puzzle/rongdesheng/details/8.png index 593f558..e78068f 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/8.png and b/src/assets/images/puzzle/rongdesheng/details/8.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details/9.png b/src/assets/images/puzzle/rongdesheng/details/9.png index 91d7cb0..d335f53 100644 Binary files a/src/assets/images/puzzle/rongdesheng/details/9.png and b/src/assets/images/puzzle/rongdesheng/details/9.png differ diff --git a/src/assets/images/rongdesheng/底图.png b/src/assets/images/rongdesheng/底图.png index f474bd5..5c8aca5 100644 Binary files a/src/assets/images/rongdesheng/底图.png and b/src/assets/images/rongdesheng/底图.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene1/线索1-详情.png b/src/assets/images/sunzhongshan/reflection/scene1/线索1-详情.png index 1973b1c..f18016d 100644 Binary files a/src/assets/images/sunzhongshan/reflection/scene1/线索1-详情.png and b/src/assets/images/sunzhongshan/reflection/scene1/线索1-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/线索1-详情.png b/src/assets/images/sunzhongshan/reflection/scene2/线索1-详情.png index d54a8b1..ae8e826 100644 Binary files a/src/assets/images/sunzhongshan/reflection/scene2/线索1-详情.png and b/src/assets/images/sunzhongshan/reflection/scene2/线索1-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/窃窃私语.png b/src/assets/images/sunzhongshan/reflection/scene3/窃窃私语.png index 049c90b..559786e 100644 Binary files a/src/assets/images/sunzhongshan/reflection/scene3/窃窃私语.png and b/src/assets/images/sunzhongshan/reflection/scene3/窃窃私语.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/线索1-详情.png b/src/assets/images/sunzhongshan/reflection/scene3/线索1-详情.png index 1c61cd6..b7fa1f0 100644 Binary files a/src/assets/images/sunzhongshan/reflection/scene3/线索1-详情.png and b/src/assets/images/sunzhongshan/reflection/scene3/线索1-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/线索1.png b/src/assets/images/sunzhongshan/reflection/scene3/线索1.png index 0e7602c..85bd8a6 100644 Binary files a/src/assets/images/sunzhongshan/reflection/scene3/线索1.png and b/src/assets/images/sunzhongshan/reflection/scene3/线索1.png differ diff --git a/src/assets/images/sunzhongshan/孙中山-首页.png b/src/assets/images/sunzhongshan/孙中山-首页.png index c34c071..e8fdc55 100644 Binary files a/src/assets/images/sunzhongshan/孙中山-首页.png and b/src/assets/images/sunzhongshan/孙中山-首页.png differ diff --git a/src/assets/images/zhangleping/张乐平故居底图.png b/src/assets/images/zhangleping/张乐平故居底图.png index fa5bea5..8cc040c 100644 Binary files a/src/assets/images/zhangleping/张乐平故居底图.png and b/src/assets/images/zhangleping/张乐平故居底图.png differ diff --git a/src/assets/svg/fillbox.svg b/src/assets/svg/fillbox.svg new file mode 100644 index 0000000..50e5b8b --- /dev/null +++ b/src/assets/svg/fillbox.svg @@ -0,0 +1 @@ + diff --git a/src/components/guide/StudyGuideModal.tsx b/src/components/guide/StudyGuideModal.tsx index 7dbee45..20f7526 100644 --- a/src/components/guide/StudyGuideModal.tsx +++ b/src/components/guide/StudyGuideModal.tsx @@ -1,5 +1,5 @@ import type { CSSProperties, ReactNode } from 'react' -import { Children, useEffect, useState } from 'react' +import { Children, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Image } from '@/components' import { cn, getStaticImage } from '@/lib/utils' @@ -95,6 +95,8 @@ export const StudyGuideModal = ({ audioChannelId, }: StudyGuideModalProps) => { const [mounted, setMounted] = useState(false) + const frameRef = useRef(null) + const [frameHeight, setFrameHeight] = useState(0) const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale() const videoContext = useVideoContextSafe() const { currentSrc, play, stop } = useAudioManager( @@ -141,6 +143,18 @@ export const StudyGuideModal = ({ } }, [mounted, canPlayAudio, audioSrc, currentSrc, stop]) + useEffect(() => { + if (!mounted || !visible || !frameRef.current) return + + const updateFrameHeight = () => setFrameHeight(frameRef.current?.offsetHeight ?? 0) + updateFrameHeight() + + const observer = new ResizeObserver(updateFrameHeight) + observer.observe(frameRef.current) + + return () => observer.disconnect() + }, [mounted, visible, children]) + if (!visible || !mounted) return null const handleMaskClick = () => { @@ -158,55 +172,144 @@ export const StudyGuideModal = ({ const contentChildren = renderIndentedContent(children, firstLineIndent) + const fillHeight = Math.max(467.43, frameHeight - 74) + const fillPath = [ + 'M76.22,0', + 'L1658,0.3', + 'C1658,0.3 1638.88,37.38 1669.5,69.87', + 'C1700.12,102.36 1733.89,91.14 1733.89,91.14', + `L1733.54,${fillHeight - 91.14}`, + `C1733.54,${fillHeight - 91.14} 1699.76,${fillHeight - 102.35} 1669.15,${fillHeight - 69.87}`, + `C1638.54,${fillHeight - 37.38} 1657.65,${fillHeight - 0.3} 1657.65,${fillHeight - 0.3}`, + `L75.89,${fillHeight}`, + `C75.89,${fillHeight} 95,${fillHeight - 37.08} 64.39,${fillHeight - 69.57}`, + `C33.78,${fillHeight - 102.06} 0,${fillHeight - 90.84} 0,${fillHeight - 90.84}`, + 'L0.33,90.84', + 'C0.33,90.84 34.11,102.05 64.72,69.57', + 'C95.34,37.08 76.22,0 76.22,0', + 'Z', + ].join(' ') + const content = ( -
+
{/* 背景遮罩 */}
{/* 引导内容 */} -
- {/* 引导框背景 */} +
+ {/* 标题 */} + {_title && + (_title === '温馨提示' ? ( + {_title} + ) : ( +
+ {_title} +
+ ))} + + {/* 叶子 */} 学习引导背景 - {/* 文本内容 */} -
- {/* {title && ( -
- {title} -
- )} */} - {children && ( -
+ + {/* 内容 */} +
+ {/* 中间不规则渐变填充 */} +
+
- )} + + + + + + + + +
+ + {/* 边框层 */} +
+ + + +
+ + {/* 文本内容 */} +
+ {children && ( +
+ {contentChildren} +
+ )} +
{/* 继续按钮 */} -
+
继续 void +} + +const SYNC_ERROR_PROMPT_EVENT = 'sync-error-prompt' +const shouldShowSyncPrompt = import.meta.env.MODE === 'production' + +const dispatchSyncPrompt = (options: SyncErrorPromptOptions) => { + if (!shouldShowSyncPrompt) return + + window.dispatchEvent( + new CustomEvent(SYNC_ERROR_PROMPT_EVENT, { detail: options }) + ) +} + +export const showSyncErrorPrompt = (options: SyncErrorPromptOptions) => { + dispatchSyncPrompt(options) +} + +export const showYibanUnauthenticatedPrompt = () => { + showSyncErrorPrompt({ message: '易班登录状态异常,请先登录易班平台后继续学习。' }) +} + +export const showYibanSyncErrorPrompt = () => { + showSyncErrorPrompt({ message: '学习进度获取失败,请稍后刷新重试。' }) +} + +export const SyncPromptHost = () => { + const [prompt, setPrompt] = useState(null) + + useEffect(() => { + const handlePrompt = (event: Event) => { + const customEvent = event as CustomEvent + setPrompt(customEvent.detail) + } + + window.addEventListener(SYNC_ERROR_PROMPT_EVENT, handlePrompt) + return () => window.removeEventListener(SYNC_ERROR_PROMPT_EVENT, handlePrompt) + }, []) + + useEffect(() => { + if (!prompt) return + + const timer = window.setTimeout(() => { + const onConfirm = prompt.onConfirm + setPrompt(null) + onConfirm?.() + }, 4500) + + return () => window.clearTimeout(timer) + }, [prompt]) + + const handleClose = () => { + const onConfirm = prompt?.onConfirm + setPrompt(null) + onConfirm?.() + } + + return ( +
+ + {prompt && ( + +
+
+ +
+
+ {prompt.title ?? '温馨提示'} +
+
+ {prompt.message} +
+
+
+ + )} + +
+ ) +} diff --git a/src/components/guide/SyncLoadingOverlay.tsx b/src/components/guide/SyncLoadingOverlay.tsx new file mode 100644 index 0000000..66f3beb --- /dev/null +++ b/src/components/guide/SyncLoadingOverlay.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react' +import { AnimatePresence, motion } from 'framer-motion' +import { + SYNC_LOADING_EVENT, + SYNC_LOADING_HIDE_EVENT, + type SyncLoadingOptions, +} from './syncLoadingApi' + +export const SyncLoadingHost = () => { + const [text, setText] = useState('正在同步学习进度...') + const [visible, setVisible] = useState(false) + + useEffect(() => { + const handleShow = (event: Event) => { + const customEvent = event as CustomEvent + setText(customEvent.detail.text ?? '正在同步学习进度...') + setVisible(true) + } + + const handleHide = () => { + setVisible(false) + } + + window.addEventListener(SYNC_LOADING_EVENT, handleShow) + window.addEventListener(SYNC_LOADING_HIDE_EVENT, handleHide) + return () => { + window.removeEventListener(SYNC_LOADING_EVENT, handleShow) + window.removeEventListener(SYNC_LOADING_HIDE_EVENT, handleHide) + } + }, []) + + return ( + + {visible && ( + + + + + + +
+ {text} +
+
+
+ )} +
+ ) +} diff --git a/src/components/guide/index.ts b/src/components/guide/index.ts index 6cf425c..8c2283f 100644 --- a/src/components/guide/index.ts +++ b/src/components/guide/index.ts @@ -1,2 +1,11 @@ export { GuideSteps, GuideStepsItem } from './GuideSteps' export { StudyGuideModal } from './StudyGuideModal' +export { SyncLoadingHost } from './SyncLoadingOverlay' +export { showSyncLoading, hideSyncLoading } from './syncLoadingApi' +export type { SyncLoadingOptions } from './syncLoadingApi' +export { + SyncPromptHost, + showSyncErrorPrompt, + showYibanUnauthenticatedPrompt, + showYibanSyncErrorPrompt, +} from './SyncErrorPrompt' diff --git a/src/components/guide/syncLoadingApi.ts b/src/components/guide/syncLoadingApi.ts new file mode 100644 index 0000000..fe691a6 --- /dev/null +++ b/src/components/guide/syncLoadingApi.ts @@ -0,0 +1,18 @@ +export interface SyncLoadingOptions { + text?: string +} + +export const SYNC_LOADING_EVENT = 'sync-loading' +export const SYNC_LOADING_HIDE_EVENT = 'sync-loading-hide' + +const dispatchSyncLoading = (detail: SyncLoadingOptions) => { + window.dispatchEvent(new CustomEvent(SYNC_LOADING_EVENT, { detail })) +} + +export const showSyncLoading = (options?: SyncLoadingOptions) => { + dispatchSyncLoading(options ?? {}) +} + +export const hideSyncLoading = () => { + window.dispatchEvent(new Event(SYNC_LOADING_HIDE_EVENT)) +} diff --git a/src/components/hint/HintHost.tsx b/src/components/hint/HintHost.tsx index 1f03933..8bc2358 100644 --- a/src/components/hint/HintHost.tsx +++ b/src/components/hint/HintHost.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { AnimatePresence, motion } from 'framer-motion' import { cn, getBackgroundImage } from '@/lib/utils' @@ -16,7 +16,7 @@ export interface ShowHintOptions { persistent?: boolean /** 相对中心点微调位置 */ offset?: HintPositionOffset - /** 自定义层级(默认 60) */ + /** 自定义层级(默认 9999) */ zIndex?: number } @@ -30,7 +30,7 @@ const DEFAULT_OPTIONS: HintState['options'] = { durationMs: 2000, persistent: false, offset: { x: 0, y: 0 }, - zIndex: 60, + zIndex: 9999, } let current: HintState = { diff --git a/src/components/index.ts b/src/components/index.ts index 01dd5e4..f693eef 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -14,7 +14,18 @@ export type { ImageProps } from './image' export { Title } from './title' // Guide components -export { GuideSteps, GuideStepsItem, StudyGuideModal } from './guide' +export { + GuideSteps, + GuideStepsItem, + StudyGuideModal, + SyncLoadingHost, + SyncPromptHost, + showSyncLoading, + hideSyncLoading, + showSyncErrorPrompt, + showYibanUnauthenticatedPrompt, + showYibanSyncErrorPrompt, +} from './guide' // Exhibit overlay export { ExhibitOverlay } from './exhibit' diff --git a/src/components/layout/alert/title-alert.tsx b/src/components/layout/alert/title-alert.tsx index 684f414..ebf1801 100644 --- a/src/components/layout/alert/title-alert.tsx +++ b/src/components/layout/alert/title-alert.tsx @@ -21,6 +21,10 @@ export interface AlertProps { const Alert = ({ title, subtitle, visible = true, onMaskClick, onClose }: AlertProps) => { if (!visible) return null + const titleMatch = title.match(/^(第[一二三四五六七八九十]+幕)[::]["“]?(.+?)["”]?(?:--|——)(.+)$/) + const titlePrefix = titleMatch ? `${titleMatch[1]} ${titleMatch[2]}` : undefined + const titleContent = titleMatch?.[3] ?? title + return (
{/* 蒙版背景 */} @@ -39,12 +43,22 @@ const Alert = ({ title, subtitle, visible = true, onMaskClick, onClose }: AlertP {/* 标题 */}
- {title} + {titlePrefix && ( + + {titlePrefix} + + )} + {titleContent}
{/* 副标题 */}
{subtitle}
diff --git a/src/components/layout/quiz/MatchQuizPanel.tsx b/src/components/layout/quiz/MatchQuizPanel.tsx index c99fc9d..6c54245 100644 --- a/src/components/layout/quiz/MatchQuizPanel.tsx +++ b/src/components/layout/quiz/MatchQuizPanel.tsx @@ -257,8 +257,8 @@ const MatchQuizPanel = ({ leftItemRefs.current[item.id] = el }} className={cn( - 'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200', - 'text-[20px] md:text-[28px] leading-relaxed cursor-pointer', + 'relative px-6 py-4 rounded-xl border-2 transition-all duration-200', + 'text-[28px] leading-relaxed cursor-pointer', 'touch-manipulation', disabled || isMatched ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer', isError @@ -283,9 +283,9 @@ const MatchQuizPanel = ({ ref={(el) => { leftDotRefs.current[item.id] = el }} - className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]" + className="absolute -right-4 top-1/2 -translate-y-1/2 w-6 h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]" > - ✕ + ✕
)} {!isError && isMatched && ( @@ -293,9 +293,9 @@ const MatchQuizPanel = ({ ref={(el) => { leftDotRefs.current[item.id] = el }} - className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center" + className="absolute -right-4 top-1/2 -translate-y-1/2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center" > - ✓ + ✓
)}
@@ -313,8 +313,8 @@ const MatchQuizPanel = ({ rightItemRefs.current[item.id] = el }} className={cn( - 'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200', - 'text-[20px] md:text-[28px] leading-relaxed', + 'relative px-6 py-4 rounded-xl border-2 transition-all duration-200', + 'text-[28px] leading-relaxed', 'touch-manipulation', disabled || isMatched ? 'opacity-60 cursor-not-allowed' @@ -345,9 +345,9 @@ const MatchQuizPanel = ({ ref={(el) => { rightDotRefs.current[item.id] = el }} - className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]" + className="absolute -left-4 top-1/2 -translate-y-1/2 w-6 h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]" > - ✕ + ✕
)} {!isError && isMatched && ( @@ -355,9 +355,9 @@ const MatchQuizPanel = ({ ref={(el) => { rightDotRefs.current[item.id] = el }} - className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center" + className="absolute -left-4 top-1/2 -translate-y-1/2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center" > - ✓ + ✓
)}
@@ -372,25 +372,14 @@ const MatchQuizPanel = ({ style={{ zIndex }} ref={containerRef} > -
-
-
{title}
+
+
+
{title}
- {error && ( -
- {error} -
- )} + {error &&
{error}
}
- {/* 移动端 */} -
-
{leftItems.map(renderLeftItem)}
-
{rightItems.map(renderRightItem)}
-
- - {/* 桌面端 */} -
+
{/* SVG 覆盖整个题目区域,用绝对像素坐标画线 */} )}
- -
- 请先点击左侧日期,再点击右侧对应的事件进行连线 -
) diff --git a/src/components/letter/LetterTemplate.tsx b/src/components/letter/LetterTemplate.tsx index 19f502e..32de149 100644 --- a/src/components/letter/LetterTemplate.tsx +++ b/src/components/letter/LetterTemplate.tsx @@ -93,6 +93,7 @@ const LetterTemplate = ({ const [signatureName, setSignatureName] = useState('') const [signatureError, setSignatureError] = useState(false) const [submitted, setSubmitted] = useState(false) + const [letterSent, setLetterSent] = useState(false) const [showSendVideo, setShowSendVideo] = useState(false) const [showReplyVideo, setShowReplyVideo] = useState(false) const [showScene, setShowScene] = useState(false) @@ -167,6 +168,7 @@ const LetterTemplate = ({ useEffect(() => { if (!visible) { setSubmitted(false) + setLetterSent(false) setBlankValues([]) setBlankErrors([]) setSignatureName('') @@ -417,7 +419,7 @@ const LetterTemplate = ({
)} diff --git a/src/config/progression.ts b/src/config/progression.ts index d7234e7..ba95b58 100644 --- a/src/config/progression.ts +++ b/src/config/progression.ts @@ -3,15 +3,6 @@ import { PUZZLE_RESIDENCES } from './puzzleResidences' export type PuzzleProgress = Record> -const BUILD_UNLOCK_ALL = import.meta.env.VITE_PROGRESS_UNLOCK_ALL === 'true' -const UNLOCK_ALL_STORAGE_KEY = 'debug:progression-unlock-all' - -const isUnlockAllEnabled = () => { - if (!BUILD_UNLOCK_ALL) return false - if (typeof window === 'undefined') return true - return window.sessionStorage.getItem(UNLOCK_ALL_STORAGE_KEY) !== 'false' -} - const getResidenceIndex = (residence: string) => PUZZLE_RESIDENCES.findIndex((item) => item.residence === residence) @@ -33,13 +24,10 @@ export const isResidenceStarted = (residence: string, puzzles: PuzzleProgress) = } export const isResidenceComplete = (residence: string, puzzles: PuzzleProgress) => { - if (isUnlockAllEnabled()) return true return arePiecesUnlocked(residence, puzzles, getConfiguredPieceIds(residence)) } export const isResidenceUnlocked = (residence: string, puzzles: PuzzleProgress) => { - if (isUnlockAllEnabled()) return true - const index = getResidenceIndex(residence) if (index === 0) return true if (index < 0) return false @@ -62,8 +50,6 @@ export const isMenuModuleStarted = (moduleName: string, puzzles: PuzzleProgress) } export const isMenuModuleComplete = (moduleName: string, puzzles: PuzzleProgress) => { - if (isUnlockAllEnabled()) return true - const residences = PUZZLE_RESIDENCES.filter((item) => item.moduleName === moduleName) if (residences.length === 0) return false return residences.every((item) => isResidenceComplete(item.residence, puzzles)) @@ -79,14 +65,6 @@ const getPieceIdsByRoute = (residence: string, routePrefix: string) => { } export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgress) => { - if (isUnlockAllEnabled()) { - return { - dialogueUnlocked: true, - reflectionUnlocked: true, - internalizationUnlocked: true, - } - } - const residenceUnlocked = isResidenceUnlocked(residence, puzzles) return { diff --git a/src/config/puzzleData.ts b/src/config/puzzleData.ts index 651bfee..af1860d 100644 --- a/src/config/puzzleData.ts +++ b/src/config/puzzleData.ts @@ -26,7 +26,7 @@ export const puzzleDataConfig: Record> = }, // 碎片2 2: { - title: '陈云就读 青浦县立乙种商业学校', + title: '陈云就读\n青浦县立乙种商业学校', detailImage: 'chenyun/details/2', route: 'place2', }, @@ -38,7 +38,7 @@ export const puzzleDataConfig: Record> = }, // 碎片4 4: { - title: '陈云在 上海商务印书馆当学徒', + title: '陈云在\n上海商务印书馆当学徒', detailImage: 'chenyun/details/4', route: 'place3', }, @@ -50,19 +50,19 @@ export const puzzleDataConfig: Record> = }, // 碎片6 6: { - title: '陈云被选为 中央政治局委员、常委', + title: '陈云被选为\n中央政治局委员、常委', detailImage: 'chenyun/details/6', route: 'place4', }, // 碎片7 7: { - title: '陈云任 西北财经办事处副主任', + title: '陈云任\n西北财经办事处副主任', detailImage: 'chenyun/details/7', route: 'place5', }, // 碎片8 8: { - title: '陈云主持 陕甘宁边区财政工作', + title: '陈云主持\n陕甘宁边区财政工作', detailImage: 'chenyun/details/8', route: 'place5', }, @@ -80,7 +80,7 @@ export const puzzleDataConfig: Record> = }, // 碎片11 11: { - title: '陈云起草的关于发展国民 经济“一五”计划报告的手稿', + title: '陈云起草的关于发展\n国民经济“一五”计划\n报告的手稿', detailImage: 'chenyun/details/11', route: 'place8', }, @@ -110,7 +110,7 @@ export const puzzleDataConfig: Record> = }, // 碎片16 16: { - title: '陈云精神', + title: '陈云给青年的精神指引', detailImage: 'chenyun/details/16', route: 'internalization', }, @@ -148,7 +148,7 @@ export const puzzleDataConfig: Record> = }, // 碎片6 6: { - title: '职业教育 和中华职业学校', + title: '职业教育\n和中华职业学校', detailImage: 'huangyanpei/details/6', route: 'place4', }, @@ -172,7 +172,7 @@ export const puzzleDataConfig: Record> = }, // 碎片10 10: { - title: '黄炎培与荣氏兄弟 合作工人教育', + title: '黄炎培与荣氏兄弟\n合作工人教育', detailImage: 'huangyanpei/details/10', route: 'place4', }, @@ -196,7 +196,7 @@ export const puzzleDataConfig: Record> = }, // 碎片14 14: { - title: '黄炎培对浦东中学 新生的教导', + title: '黄炎培对浦东中学\n新生的教导', detailImage: 'huangyanpei/details/14', route: 'reflection2', }, @@ -225,7 +225,7 @@ export const puzzleDataConfig: Record> = route: 'place1', }, 3: { - title: '《申报》刊登孙中山就职 临时大总统消息', + title: '《申报》刊登孙中山就职\n临时大总统消息', detailImage: 'sunzhongshan/details/3', route: 'place1', }, @@ -260,7 +260,7 @@ export const puzzleDataConfig: Record> = route: 'place3', }, 10: { - title: '中国国民党第一次 全国代表大会宣言', + title: '中国国民党第一次\n全国代表大会宣言', detailImage: 'sunzhongshan/details/10', route: 'place3', }, @@ -285,7 +285,7 @@ export const puzzleDataConfig: Record> = route: 'reflection3', }, 15: { - title: '振兴中华对新时代 青年的意义', + title: '振兴中华对新时代\n青年的意义', detailImage: 'sunzhongshan/details/15', route: 'internalization', }, @@ -297,7 +297,7 @@ export const puzzleDataConfig: Record> = }, luxun: { 1: { - title: '鲁迅1927年南下 广州时的照片', + title: '鲁迅1927年南下\n广州时的照片', detailImage: 'luxun/details/1', route: 'place1', }, @@ -414,7 +414,7 @@ export const puzzleDataConfig: Record> = route: 'place3', }, 8: { - title: '毛泽东签发的两份 中共四大通知', + title: '毛泽东签发的两份\n中共四大通知', detailImage: 'maozedong/details/8', route: 'place4', }, @@ -526,7 +526,7 @@ export const puzzleDataConfig: Record> = route: 'place6', }, 14: { - title: '唱国家一定要站起来', + title: '唱国歌一定要站起来', detailImage: 'nieer/details/14', route: 'reflection', }, @@ -543,7 +543,7 @@ export const puzzleDataConfig: Record> = }, zhangleping: { 1: { - title: '童年的回忆 (张乐平,1983)', + title: '童年的回忆\n(张乐平,1983)', detailImage: 'zhangleping/details/1', route: 'place1', }, @@ -553,17 +553,17 @@ export const puzzleDataConfig: Record> = route: 'place1', }, 3: { - title: '穷凶极恶的结果 (张乐平,1937)', + title: '穷凶极恶的结果\n(张乐平,1937)', detailImage: 'zhangleping/details/3', route: 'place2', }, 4: { - title: '长沙大火前夕 张乐平在街头墙上挥笔作画', + title: '长沙大火前夕\n张乐平在街头墙上挥笔作画', detailImage: 'zhangleping/details/4', route: 'place2', }, 5: { - title: '侵略者的坟墓 (张乐平,1938)', + title: '侵略者的坟墓\n(张乐平,1938)', detailImage: 'zhangleping/details/5', route: 'place2', }, @@ -645,7 +645,7 @@ export const puzzleDataConfig: Record> = route: 'place2', }, 5: { - title: '上海申新第九纺织厂门楼(1933年旧照)', + title: '上海申新第九纺织厂门楼\n(1933年旧照)', detailImage: 'rongdesheng/details/5', route: 'place2', }, @@ -655,22 +655,22 @@ export const puzzleDataConfig: Record> = route: 'place2', }, 7: { - title: '国际劳工局赞美申新三纺织厂劳工自治区', + title: '国际劳工局赞美\n申新三纺织厂劳工自治区', detailImage: 'rongdesheng/details/7', route: 'place2', }, 8: { - title: '上海申新一厂和八厂于1937年被日本飞机炸毁', + title: '上海申新一厂和八厂于\n1937年被日本飞机炸毁', detailImage: 'rongdesheng/details/8', route: 'place3', }, 9: { - title: '《人报》报道 乐农氏再度捐助面粉一万包', + title: '《人报》报道\n乐农氏再度捐助面粉一万包', detailImage: 'rongdesheng/details/9', route: 'place3', }, 10: { - title: '心正思无邪 意诚言必中题字', + title: '心正思无邪\n意诚言必中 题字', detailImage: 'rongdesheng/details/10', route: 'place3', }, @@ -680,19 +680,19 @@ export const puzzleDataConfig: Record> = route: 'place4', }, 12: { - title: '荣德生拒绝荣氏工业结构外迁的报道', + title: '荣德生拒绝荣氏工业结构\n外迁的报道', detailImage: 'rongdesheng/details/12', route: 'place4', }, 13: { - title: '毛泽东主席、陈毅副总理在荣毅仁陪同下,参观公私合营上海申新第九纺织厂', + title: '毛泽东主席、陈毅副总理在\n荣毅仁陪同下,参观公私合\n营上海申新第九纺织厂', detailImage: 'rongdesheng/details/13', route: 'place4', }, 14: { - title: '荣德生与张 一九四九年九月,荣德生致函第一届全国政协謇的合影', + title: '民生坚守', detailImage: 'rongdesheng/details/14', - route: 'place4', + route: 'reflection', }, 15: { title: '坚韧不拔', diff --git a/src/debug/progressionDebug.ts b/src/debug/progressionDebug.ts index c63e04c..36d0f6d 100644 --- a/src/debug/progressionDebug.ts +++ b/src/debug/progressionDebug.ts @@ -1,42 +1,238 @@ -const STORAGE_KEY = 'debug:progression-unlock-all' -const BADGE_ID = 'progression-debug-badge' +import { puzzleDataConfig } from '@/config/puzzleData' +import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences' +import { usePuzzleStore } from '@/store/puzzleStore' -const isUnlockAllEnabled = () => { - return window.sessionStorage.getItem(STORAGE_KEY) !== 'false' +const MODE_KEY = 'debug:progression-mode' +const BACKUP_KEY = 'debug:progression-real-puzzles-backup' +const BADGE_ID = 'progression-debug-badge' +const PANEL_ID = 'progression-debug-panel' + +type DebugMode = + | { type: 'real' } + | { type: 'arrived'; residence: string; label: string } + | { type: 'allCompleted' } + +const serializePuzzles = (puzzles: Record>) => { + return Object.fromEntries( + Object.entries(puzzles).map(([residence, pieces]) => [residence, [...pieces]]) + ) } -const getBadgeText = () => { - return isUnlockAllEnabled() ? '测试包:全解锁' : '测试包:真实进度' +const deserializePuzzles = (puzzles: Record | undefined) => { + if (!puzzles) return {} + return Object.fromEntries( + Object.entries(puzzles).map(([residence, pieces]) => [residence, new Set(pieces)]) + ) +} + +const getAllPieceIds = (residence: string) => { + return Object.keys(puzzleDataConfig[residence] ?? {}).map(Number) +} + +const buildArrivedPuzzles = (targetResidence: string) => { + const targetIndex = PUZZLE_RESIDENCES.findIndex((item) => item.residence === targetResidence) + const puzzles: Record> = {} + + if (targetIndex <= 0) return puzzles + + for (let index = 0; index < targetIndex; index++) { + const residence = PUZZLE_RESIDENCES[index]?.residence + if (!residence) continue + puzzles[residence] = new Set(getAllPieceIds(residence)) + } + + return puzzles +} + +const buildAllCompletedPuzzles = () => { + const puzzles: Record> = {} + + for (const { residence } of PUZZLE_RESIDENCES) { + puzzles[residence] = new Set(getAllPieceIds(residence)) + } + + return puzzles +} + +const getStoredMode = (): DebugMode => { + const raw = window.sessionStorage.getItem(MODE_KEY) + if (!raw) return { type: 'real' } + + try { + const mode = JSON.parse(raw) as DebugMode + if (mode.type === 'arrived' || mode.type === 'allCompleted') return mode + } catch { + return { type: 'real' } + } + + return { type: 'real' } +} + +const setStoredMode = (mode: DebugMode) => { + if (mode.type === 'real') { + window.sessionStorage.removeItem(MODE_KEY) + return + } + + window.sessionStorage.setItem(MODE_KEY, JSON.stringify(mode)) +} + +const getModeLabel = (mode = getStoredMode()) => { + if (mode.type === 'arrived') return `到达:${mode.label}` + if (mode.type === 'allCompleted') return '全流程完成' + return '真实进度' +} + +const backupRealPuzzles = () => { + if (window.sessionStorage.getItem(BACKUP_KEY)) return + + const puzzles = usePuzzleStore.getState().puzzles + window.sessionStorage.setItem(BACKUP_KEY, JSON.stringify(serializePuzzles(puzzles))) +} + +const restoreRealPuzzles = () => { + const raw = window.sessionStorage.getItem(BACKUP_KEY) + if (!raw) return + + try { + const data = JSON.parse(raw) as Record + usePuzzleStore.setState({ puzzles: deserializePuzzles(data) }) + } catch { + window.sessionStorage.removeItem(BACKUP_KEY) + } +} + +const applyDebugModePuzzles = (mode: Exclude) => { + const puzzles = + mode.type === 'allCompleted' ? buildAllCompletedPuzzles() : buildArrivedPuzzles(mode.residence) + + usePuzzleStore.setState((prev) => ({ ...prev, puzzles })) +} + +export const applyStoredProgressionDebugMode = () => { + const mode = getStoredMode() + if (mode.type === 'real') return false + + applyDebugModePuzzles(mode) + return true +} + +const applyMode = (mode: DebugMode) => { + if (mode.type === 'real') { + restoreRealPuzzles() + setStoredMode(mode) + window.sessionStorage.removeItem(BACKUP_KEY) + window.location.reload() + return + } + + backupRealPuzzles() + applyDebugModePuzzles(mode) + setStoredMode(mode) + window.location.reload() +} + +const createButton = (label: string, onClick: () => void) => { + const button = document.createElement('button') + button.type = 'button' + button.textContent = label + button.style.width = '100%' + button.style.border = '1px solid rgba(255,255,255,0.2)' + button.style.borderRadius = '6px' + button.style.background = 'rgba(255,255,255,0.1)' + button.style.color = '#fff' + button.style.padding = '6px 8px' + button.style.textAlign = 'left' + button.style.cursor = 'pointer' + button.addEventListener('click', onClick) + return button +} + +const removePanel = () => { + document.getElementById(PANEL_ID)?.remove() +} + +const togglePanel = () => { + const existingPanel = document.getElementById(PANEL_ID) + if (existingPanel) { + existingPanel.remove() + return + } + + const panel = document.createElement('div') + panel.id = PANEL_ID + panel.style.position = 'fixed' + panel.style.right = '12px' + panel.style.top = '72px' + panel.style.zIndex = '99999' + panel.style.width = '260px' + panel.style.maxHeight = '70vh' + panel.style.overflowY = 'auto' + panel.style.padding = '10px' + panel.style.borderRadius = '10px' + panel.style.background = 'rgba(0, 0, 0, 0.84)' + panel.style.color = '#fff' + panel.style.fontSize = '12px' + panel.style.boxShadow = '0 8px 24px rgba(0,0,0,0.35)' + + const title = document.createElement('div') + title.textContent = '进度模拟' + title.style.fontWeight = 'bold' + title.style.marginBottom = '8px' + panel.appendChild(title) + + const description = document.createElement('div') + description.textContent = '只写本地 puzzleStore,不调用易班保存。' + description.style.opacity = '0.75' + description.style.marginBottom = '8px' + panel.appendChild(description) + + const list = document.createElement('div') + list.style.display = 'flex' + list.style.flexDirection = 'column' + list.style.gap = '6px' + + list.appendChild(createButton('真实进度', () => applyMode({ type: 'real' }))) + + for (const residence of PUZZLE_RESIDENCES) { + list.appendChild( + createButton(`到达:${residence.displayName}`, () => + applyMode({ type: 'arrived', residence: residence.residence, label: residence.displayName }) + ) + ) + } + + list.appendChild(createButton('全流程完成', () => applyMode({ type: 'allCompleted' }))) + + panel.appendChild(list) + document.body.appendChild(panel) } const updateBadge = () => { let badge = document.getElementById(BADGE_ID) if (!badge) { - badge = document.createElement('div') + badge = document.createElement('button') badge.id = BADGE_ID badge.style.position = 'fixed' badge.style.right = '12px' badge.style.top = '12px' badge.style.zIndex = '99999' badge.style.padding = '8px 10px' + badge.style.border = '0' badge.style.borderRadius = '8px' badge.style.background = 'rgba(0, 0, 0, 0.72)' badge.style.color = '#fff' badge.style.fontSize = '12px' badge.style.lineHeight = '1.5' - badge.style.pointerEvents = 'none' + badge.style.cursor = 'pointer' badge.style.whiteSpace = 'pre-line' + badge.addEventListener('click', togglePanel) document.body.appendChild(badge) } - badge.textContent = `${getBadgeText()}\nCtrl+Shift+U 切换` -} - -const toggleUnlockAll = () => { - const nextValue = isUnlockAllEnabled() ? 'false' : 'true' - window.sessionStorage.setItem(STORAGE_KEY, nextValue) - updateBadge() - window.location.reload() + const mode = getStoredMode() + const title = mode.type === 'real' ? '测试包:真实进度' : '测试包:模拟进度' + badge.textContent = `${title}\n当前:${getModeLabel(mode)}\nCtrl+Shift+U 打开面板` } export const setupProgressionDebug = () => { @@ -46,6 +242,14 @@ export const setupProgressionDebug = () => { if (!event.ctrlKey || !event.shiftKey || event.key.toLowerCase() !== 'u') return event.preventDefault() - toggleUnlockAll() + togglePanel() + }) + + window.addEventListener('click', (event) => { + const target = event.target as Node | null + if (!target) return + if (document.getElementById(PANEL_ID)?.contains(target)) return + if (document.getElementById(BADGE_ID)?.contains(target)) return + removePanel() }) } diff --git a/src/lib/utils/api-common.ts b/src/lib/utils/api-common.ts index d471aab..c79667a 100644 --- a/src/lib/utils/api-common.ts +++ b/src/lib/utils/api-common.ts @@ -1,5 +1,8 @@ import { useUserStore } from '@/store/userStore' +//超时时间 +const TIMEOUT_MS = 1000 * 10 + // 声明外部依赖(由运行环境提供) declare const $: any declare const iscallyb: boolean @@ -9,11 +12,6 @@ declare function getFutureDa(days: number): string declare function getCurrentDa(str: string): void declare function formatDateFromMilliseconds(date: Date): string -// 本地或非易班环境下,外部全局变量可能未注入;统一用安全访问 -const isYibanRuntime = (): boolean => { - return typeof iscallyb !== 'undefined' && iscallyb === true -} - interface UserDefineParam { lessonId: string key?: string | number @@ -27,11 +25,21 @@ interface VideoAjaxResponseItem { interface VideoAjaxResponse { code: number + msg?: string data: { value?: VideoAjaxResponseItem[] | string } } +export type UserDefineFetchStatus = 'success' | 'empty' | 'unauthenticated' | 'error' + +export interface UserDefineFetchResult { + status: UserDefineFetchStatus + content: string + code?: number + message?: string +} + interface VideoAjax { setHost(host: string): void addUserDefine(param: UserDefineParam): void @@ -77,17 +85,12 @@ $.extend({ const LESSON_PREFIX = 'hsgrl' -const videoajax: VideoAjax | null = typeof videoAjax !== 'undefined' ? videoAjax() : null -videoajax?.setHost('/') - -function normalizeLessonId(lessonId: string | number): string { - const raw = String(lessonId) - return raw.startsWith(`${LESSON_PREFIX}_`) ? raw : `${LESSON_PREFIX}_${raw}` -} +const videoajax: VideoAjax = videoAjax() +videoajax.setHost('/') // 设置我的自定义数据(所有的自定义数据都设置在第一个课时ID上,以key来区分不同的类型) function setLtDefine(key: string | number, value: string): void { - if (isYibanRuntime()) { + if (iscallyb) { setUserDefine(1, key, value) } else { sessionStorage.setItem(`${LESSON_PREFIX}_${key}`, value) @@ -98,7 +101,7 @@ let mycstr9 = '' // 获取定义数据(由于底层是异步接口,这里返回 Promise) async function getLtDefine(key: string | number): Promise { - if (isYibanRuntime()) { + if (iscallyb) { if (mycstr9 === '') { const all = await getUsersDefine(1, emykey) if (all === undefined || all === null || all === 'null' || all === '') { @@ -142,53 +145,105 @@ export function setUserDefine( key: string | number, value: string ): void { - if (!isYibanRuntime() || !videoajax) { - sessionStorage.setItem(`${normalizeLessonId(lessonId)}_${key}`, value) - return - } - const param: UserDefineParam = { - lessonId: normalizeLessonId(lessonId), + lessonId: `${LESSON_PREFIX}_${lessonId}`, key, content: value, } + console.log(param) videoajax.addUserDefine(param) } // 内部:将回调形式封装为 Promise -function getUserDefineAsyncInternal( +function getUserDefineResultAsyncInternal( lessonId: string | number, key: string | number -): Promise { - if (!isYibanRuntime() || !videoajax) { - const value = sessionStorage.getItem(`${normalizeLessonId(lessonId)}_${key}`) - return Promise.resolve(value === null || value === 'null' ? '' : value) +): Promise { + if (!iscallyb) { + const itemValue = sessionStorage.getItem(`${LESSON_PREFIX}_${lessonId}_${key}`) + return Promise.resolve( + itemValue === undefined || itemValue === null || itemValue === 'null' || itemValue === '' + ? { status: 'empty', content: '' } + : { status: 'success', content: itemValue } + ) } const param: UserDefineParam = { - lessonId: normalizeLessonId(lessonId), + lessonId: `${LESSON_PREFIX}_${lessonId}`, order: key, } - const ajax = videoajax return new Promise((resolve) => { - let content = '' - ajax.getUserDefineInfoByUser(param, (res) => { - // 保留原有 console 行为 + let settled = false + const done = (result: UserDefineFetchResult) => { + if (settled) return + settled = true + resolve(result) + } + + const timeout = setTimeout(() => { + console.warn(`getUserDefine timeout: lessonId=${lessonId}, key=${key}`) + done({ status: 'error', content: '', message: '学习进度获取超时' }) + }, TIMEOUT_MS) + + videoajax.getUserDefineInfoByUser(param, (res) => { + clearTimeout(timeout) console.log(res) - if (res && res.code === 200) { - const json = (res.data.value || []) as VideoAjaxResponseItem[] - const contents = json[0]?.content || '' - content = contents - } else { - content = '' + + if (res === undefined || res === null || res === ('null' as unknown)) { + done({ status: 'empty', content: '' }) + return } - resolve(content) + + if (res.code === 202) { + done({ status: 'unauthenticated', content: '', code: res.code, message: res.msg }) + return + } + + if (res.code !== 200) { + done({ + status: 'error', + content: '', + code: res.code, + message: res.msg || '学习进度获取失败', + }) + return + } + + const value = res.data?.value + if (value === undefined || value === null || value === 'null' || value === '') { + done({ status: 'empty', content: '' }) + return + } + + const json = Array.isArray(value) ? value : [] + const content = json[0]?.content || '' + done( + content === '' || content === 'null' + ? { status: 'empty', content: '' } + : { status: 'success', content } + ) }) }) } +function getUserDefineAsyncInternal( + lessonId: string | number, + key: string | number +): Promise { + return getUserDefineResultAsyncInternal(lessonId, key).then((result) => + result.status === 'success' ? result.content : '' + ) +} + // Promise 版本:获取用户自定义数据 +export async function getUserDefineResult( + lessonId: string | number, + key: string | number +): Promise { + return getUserDefineResultAsyncInternal(lessonId, key) +} + export async function getUserDefine( lessonId: string | number, key: string | number @@ -201,19 +256,27 @@ function getUsersDefineAsyncInternal( lessonId: string | number, key: string | number ): Promise { - if (!isYibanRuntime() || !videoajax) { - return Promise.resolve('') - } - const param: UserDefineParam = { - lessonId: normalizeLessonId(lessonId), + lessonId: `${LESSON_PREFIX}_${lessonId}`, order: key, } - const ajax = videoajax return new Promise((resolve) => { - let content: string | VideoAjaxResponseItem[] = '' - ajax.getUserDefineInfo(param, (data) => { + let settled = false + const done = (content: string | VideoAjaxResponseItem[]) => { + if (settled) return + settled = true + resolve(content) + } + + const timeout = setTimeout(() => { + console.warn(`getUsersDefine timeout: lessonId=${lessonId}, key=${key}`) + done('') + }, TIMEOUT_MS) + + videoajax.getUserDefineInfo(param, (data) => { + clearTimeout(timeout) + let content: string | VideoAjaxResponseItem[] = '' if (data && data.code === 200) { const json = (data.data.value || []) as VideoAjaxResponseItem[] if (json.length > 1) { @@ -231,10 +294,8 @@ function getUsersDefineAsyncInternal( } else { content = json[0]?.content || '' } - } else { - content = '' } - resolve(content) + done(content) }) }) } @@ -253,13 +314,7 @@ export async function setCourseTimeProgress(order: number): Promise { // var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID // const courseid = await getLtDefine('10000') console.info(`courseid:${courseid} order:${order}`) - if ( - isYibanRuntime() && - videoajax && - courseid !== undefined && - courseid !== null && - courseid !== '' - ) { + if (iscallyb && courseid !== undefined && courseid !== null && courseid !== '') { if (order > 0) { const param2 = { order, co: courseid, process: 100 } videoajax.finishCourse(param2) diff --git a/src/lib/utils/sensitiveWords.ts b/src/lib/utils/sensitiveWords.ts index c9d4b54..59487fc 100644 --- a/src/lib/utils/sensitiveWords.ts +++ b/src/lib/utils/sensitiveWords.ts @@ -5,11 +5,20 @@ // 敏感词库(可根据实际需求扩展) const SENSITIVE_WORDS = [ - '敏感词1', - '敏感词2', - '测试', '垃圾', - // 可以根据实际需求添加更多敏感词 + '脏话', + '骂人', + '暴力', + '恐怖', + '色情', + '赌博', + '诈骗', + '毒品', + '邪教', + '反动', + '辱骂', + '仇恨', + 'sb', ] type FileSaveHandle = { diff --git a/src/main.tsx b/src/main.tsx index 9413ac0..146da3f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,7 +2,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App' -import { useChenyunStore, useHuangyanpeiStore, usePuzzleStore, useSunzhongshanStore } from './store' if (import.meta.env.VITE_DEBUG_ENTRY === 'true' && typeof window !== 'undefined') { import('@/debug/progressionDebug').then(({ setupProgressionDebug }) => { @@ -29,43 +28,6 @@ if (import.meta.env.DEV && typeof window !== 'undefined') { } } -// 全局键盘快捷键:Ctrl+Shift+R 重置所有缓存 -const setupGlobalKeyboardShortcuts = () => { - if (typeof window !== 'undefined') { - document.addEventListener('keydown', (event) => { - // Ctrl+Shift+R: 重置所有缓存 - if (event.ctrlKey && event.shiftKey && event.key === 'R') { - event.preventDefault() // 阻止默认的页面刷新行为 - - // 显示确认对话框 - const confirmed = window.confirm( - '确定要重置所有缓存吗?这将清除所有已收集的拼图碎片、已完成的展品和线索。' - ) - - if (confirmed) { - try { - // 重置所有store - useChenyunStore.getState().resetAll() - useHuangyanpeiStore.getState().resetAll() - usePuzzleStore.getState().resetAll() - useSunzhongshanStore.getState().resetAll() - - // 显示成功消息 - alert('所有缓存已重置!页面将刷新。') - window.location.reload() - } catch (error) { - console.error('重置缓存时出错:', error) - alert('重置缓存失败,请查看控制台错误信息。') - } - } - } - }) - } -} - -// 设置全局键盘快捷键 -setupGlobalKeyboardShortcuts() - const rootElement = document.getElementById('root') if (!rootElement) { throw new Error('Root element not found') diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 3f630ef..aec2ff4 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 (
+ {/* 课程简介 */} + + {!showIntroVideo && ( + + setShowIntroVideo(true)} + /> + + )} + + { navigate('/menu') @@ -17,6 +40,46 @@ const Home = () => { button src={getStaticImage('home/首页-进入')} /> + + + {showIntroVideo && ( + + + + + )} +
) } diff --git a/src/pages/chenyun/home.tsx b/src/pages/chenyun/home.tsx index a8a2658..a334a1d 100644 --- a/src/pages/chenyun/home.tsx +++ b/src/pages/chenyun/home.tsx @@ -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 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) } })() diff --git a/src/pages/chenyun/internalization.tsx b/src/pages/chenyun/internalization.tsx index 544ed9a..2659f52 100644 --- a/src/pages/chenyun/internalization.tsx +++ b/src/pages/chenyun/internalization.tsx @@ -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 = () => { /> { setActiveSuccess(null) @@ -291,7 +291,7 @@ const ChenyunInternalization = () => { /> { 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')} /> diff --git a/src/pages/chenyun/reflection/constants/yinyuan.tsx b/src/pages/chenyun/reflection/constants/yinyuan.tsx index eeac51e..2800ecd 100644 --- a/src/pages/chenyun/reflection/constants/yinyuan.tsx +++ b/src/pages/chenyun/reflection/constants/yinyuan.tsx @@ -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: '上海目前出现大肆贩卖银元的情况,请你了解情况!', diff --git a/src/pages/chenyun/reflection/index.tsx b/src/pages/chenyun/reflection/index.tsx index e8da0ed..ee0f48e 100644 --- a/src/pages/chenyun/reflection/index.tsx +++ b/src/pages/chenyun/reflection/index.tsx @@ -35,9 +35,10 @@ const ChenyunReflection = () => { // ========== 顶层状态管理 ========== /** 当前展开的文件 */ - const [expandedFile, setExpandedFile] = useState(() => - useChenyunStore.getState().isYinyuanSolved ? 'mimian' : null - ) + const [expandedFile, setExpandedFile] = useState(() => { + const { isYinyuanSolved, isMimianSolved } = useChenyunStore.getState() + return isYinyuanSolved && !isMimianSolved ? 'mimian' : null + }) /** 当前播放的视频 */ const [playingVideo, setPlayingVideo] = useState(null) diff --git a/src/pages/chenyun/reflection/mimian/MimianBattle.tsx b/src/pages/chenyun/reflection/mimian/MimianBattle.tsx index 34296f8..14f31bd 100644 --- a/src/pages/chenyun/reflection/mimian/MimianBattle.tsx +++ b/src/pages/chenyun/reflection/mimian/MimianBattle.tsx @@ -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); diff --git a/src/pages/chenyun/reflection/mimian/MimianQuizzes.tsx b/src/pages/chenyun/reflection/mimian/MimianQuizzes.tsx index db16816..6520ce6 100644 --- a/src/pages/chenyun/reflection/mimian/MimianQuizzes.tsx +++ b/src/pages/chenyun/reflection/mimian/MimianQuizzes.tsx @@ -176,16 +176,16 @@ export const MimianQuizzes = ({ {/* 米棉之战 - 单选题3(图5) */} @@ -245,7 +245,7 @@ export const MimianQuizzes = ({ {/* 米棉之战 - 单选题5(图8) */} { 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 lastVisitedPlace?: string | null reflectionRoute?: string + shownReflectionCompletionGuides?: Record + 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) } })() diff --git a/src/pages/huangyanpei/internalization.tsx b/src/pages/huangyanpei/internalization.tsx index 9f554f4..c2f1f9e 100644 --- a/src/pages/huangyanpei/internalization.tsx +++ b/src/pages/huangyanpei/internalization.tsx @@ -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')} /> diff --git a/src/pages/huangyanpei/place/place3.tsx b/src/pages/huangyanpei/place/place3.tsx index 5edd2f5..b2aa946 100644 --- a/src/pages/huangyanpei/place/place3.tsx +++ b/src/pages/huangyanpei/place/place3.tsx @@ -345,7 +345,7 @@ const HuangyanpeiPlace3 = () => { speaker="student" audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_07.mp3')} > - 这所川沙小学堂就是黄炎培先生“教育救国”的第一个试验田吧?我们现在学习的课程有专业课、实习实训课都提倡技能培养,当年他是不是也很注重动手能力呀? + 这所川沙小学堂就是黄炎培先生“教育救国”的第一个试验田吧?我们现在的专业课、实训课都提倡技能培养,当年他是不是也很注重动手能力呀? { {/* 题目 */} { speaker="guide" audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_21_2.mp3')} > - 第一次把”中等专业学校”正式纳入国家学制,让职业教育有了明确的国家定位。1952年,他又推动建立了上海机械学校、南京航空学校等五所中专,这些学校为国家”一五”计划输送了6.3万名技术工人,为工业发展提供了坚实的人才支撑。 + 第一次把“中等专业学校”正式纳入国家学制,让职业教育有了明确的国家定位。1952年,他又推动建立了上海机械学校、南京航空学校等五所中专,这些学校为国家“一五”计划输送了6.3万名技术工人,为工业发展提供了坚实的人才支撑。 - 黄炎培先生曾说过:”我要把最后一滴血,滴在职业教育的讲台上。”他用一生践行了这句话,而我们现在中职教育强调的”工学结合、德技并修”,正是对他”手脑并用、注重实践”理念的传承和发展。 + 黄炎培先生曾说过:“我要把最后一滴血,滴在职业教育的讲台上。”他用一生践行了这句话,而我们现在中职教育强调的“工学结合、德技并修”,正是对他“手脑并用、注重实践”理念的传承和发展。 diff --git a/src/pages/huangyanpei/reflection/scene2.tsx b/src/pages/huangyanpei/reflection/scene2.tsx index 054aef3..2d80b2d 100644 --- a/src/pages/huangyanpei/reflection/scene2.tsx +++ b/src/pages/huangyanpei/reflection/scene2.tsx @@ -234,7 +234,7 @@ const HuangyanpeiReflectionScene2 = () => { { setShowQuizSuccess(false) }} @@ -246,7 +246,7 @@ const HuangyanpeiReflectionScene2 = () => { onContinue={handleCompleteGuideContinue} verticalAlign="center" > - 第二关完成!“职业方向锁定!”去新学校了解你的专业吧。 + 第二关完成!职业方向锁定!去新学校了解你的专业吧。
) diff --git a/src/pages/huangyanpei/reflection/scene2/ChoiceDialog2.tsx b/src/pages/huangyanpei/reflection/scene2/ChoiceDialog2.tsx index 22a7ee9..d7d8132 100644 --- a/src/pages/huangyanpei/reflection/scene2/ChoiceDialog2.tsx +++ b/src/pages/huangyanpei/reflection/scene2/ChoiceDialog2.tsx @@ -175,7 +175,7 @@ const ChoiceDialog2 = ({ visible = true, onClose, onSelect }: ChoiceDialog2Props
diff --git a/src/pages/huangyanpei/reflection/scene2/QuizDialog.tsx b/src/pages/huangyanpei/reflection/scene2/QuizDialog.tsx index 6f1ebd5..21e1288 100644 --- a/src/pages/huangyanpei/reflection/scene2/QuizDialog.tsx +++ b/src/pages/huangyanpei/reflection/scene2/QuizDialog.tsx @@ -114,7 +114,7 @@ const Scene2QuizDialog = ({ visible = true, onClose, onComplete }: Scene2QuizDia
【单选】
- 杨斯盛先生近期监工浦东中学建设,有一份上月的工人工钱支出表需要核对,你来告诉我答案吧? + 杨斯盛先生近期监工浦东中学建设,有一份上月的工人工钱支出表需要核对,你来告诉我答案吧!
diff --git a/src/pages/huangyanpei/reflection/scene3.tsx b/src/pages/huangyanpei/reflection/scene3.tsx index f87d540..3c8d10f 100644 --- a/src/pages/huangyanpei/reflection/scene3.tsx +++ b/src/pages/huangyanpei/reflection/scene3.tsx @@ -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) }} />
diff --git a/src/pages/huangyanpei/reflection/scene4.tsx b/src/pages/huangyanpei/reflection/scene4.tsx index 29e322f..2eef4dc 100644 --- a/src/pages/huangyanpei/reflection/scene4.tsx +++ b/src/pages/huangyanpei/reflection/scene4.tsx @@ -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) }} /> diff --git a/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx b/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx index c88556d..02f6104 100644 --- a/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx +++ b/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx @@ -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.我只管赚钱,东西好不好不管哦。', }, ], [] diff --git a/src/pages/learning-map/index.tsx b/src/pages/learning-map/index.tsx index 908577b..b60ab08 100644 --- a/src/pages/learning-map/index.tsx +++ b/src/pages/learning-map/index.tsx @@ -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(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 (
{ />
- {residences.map(({ key, name, left, top }) => ( -
- {name} + {PUZZLE_RESIDENCES.map(({ residence, image, moduleName, left, top }) => { + const isUnlocked = unlockedMap[residence] + const isNewlyUnlocked = residence === newlyUnlockedResidence + const showSpotlightPrompt = residence === spotlightPromptResidence -
- -
+ return ( + + + {image} handleResidenceClick(moduleName, residence, !!isUnlocked)} + /> -
- 追光 navigate(`/map/spotlight/${key}`)} - /> -
-
- ))} +
+ +
+ +
+ {showSpotlightPrompt && ( + + )} + 追光 handleSpotlightClick(residence, !!isUnlocked)} + /> +
+ + + ) + })}
) diff --git a/src/pages/learning-map/spotlight.tsx b/src/pages/learning-map/spotlight.tsx index 86b045f..8d47cf2 100644 --- a/src/pages/learning-map/spotlight.tsx +++ b/src/pages/learning-map/spotlight.tsx @@ -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 = { - 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 } + if (!isLoaded) { + return
+ } + + if (!isResidenceUnlocked(residenceConfig.residence, puzzles)) { + return + } + + const residenceName = residenceConfig.image + return (
{ onClick={() => setShowDetail(true)} >
详情 diff --git a/src/pages/luxun/home.tsx b/src/pages/luxun/home.tsx index 35d8cdf..d672e0e 100644 --- a/src/pages/luxun/home.tsx +++ b/src/pages/luxun/home.tsx @@ -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 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) } })() diff --git a/src/pages/luxun/internalization.tsx b/src/pages/luxun/internalization.tsx index 14e2033..9ed76f7 100644 --- a/src/pages/luxun/internalization.tsx +++ b/src/pages/luxun/internalization.tsx @@ -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')} />
) diff --git a/src/pages/luxun/place/place3.tsx b/src/pages/luxun/place/place3.tsx index 255cff0..54c9cb6 100644 --- a/src/pages/luxun/place/place3.tsx +++ b/src/pages/luxun/place/place3.tsx @@ -322,7 +322,7 @@ const LuxunPlace3 = () => { {/* 光标3的题目 */} { zIndex={10000} > - 除了瞿秋白,还有其他共产党人来过这里吗 + 除了瞿秋白,还有其他共产党人来过这里吗? 当然!1936年4月,中共中央特派员冯雪峰到上海开展工作,也住进了这间客房。冯雪峰把红军长征胜利、遵义会议召开的消息告诉鲁迅,还带来了毛泽东同志提出的抗日民族统一战线主张。 diff --git a/src/pages/maozedong/home.tsx b/src/pages/maozedong/home.tsx index d6ab4a3..da3b055 100644 --- a/src/pages/maozedong/home.tsx +++ b/src/pages/maozedong/home.tsx @@ -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 | undefined +): Record> => { + if (!data || typeof data !== 'object') return {} + const visitedExhibits: Record> = {} + 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 + 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 ( diff --git a/src/pages/maozedong/internalization.tsx b/src/pages/maozedong/internalization.tsx index d0fa478..4cf7380 100644 --- a/src/pages/maozedong/internalization.tsx +++ b/src/pages/maozedong/internalization.tsx @@ -514,7 +514,7 @@ const MaozedongInternalization = () => { onContinue={() => { setShowCompleted(false) setCourseTimeProgress(3) - navigate('/menu') + navigate('/learning-map', { state: { completedResidence: 'maozedong' } }) }} />
diff --git a/src/pages/maozedong/place/place1.tsx b/src/pages/maozedong/place/place1.tsx index ec8706a..aef64ce 100644 --- a/src/pages/maozedong/place/place1.tsx +++ b/src/pages/maozedong/place/place1.tsx @@ -171,7 +171,8 @@ const MaozedongPlace1 = () => { 那参加中共一大时,毛主席对未来有什么想法吗? - 当时全国只有50多个党员,毛主席所在的湖南也才几个。但他觉得,重要的是把马克思主义和中国的实际情况结合起来。就像种田,种子再好,也得看土壤——中国的”土壤”就是农民占大多数,这是和苏联不一样的地方。 + 当时全国只有50多个党员,毛主席所在的湖南也才几个。但他觉得,重要的是把马克思主义和中国的实际情况结合起来。就像种田,种子再好,也得看土壤——中国的 + “土壤”就是农民占大多数,这是和苏联不一样的地方。 diff --git a/src/pages/maozedong/place/place4.tsx b/src/pages/maozedong/place/place4.tsx index c99e5ad..289397f 100644 --- a/src/pages/maozedong/place/place4.tsx +++ b/src/pages/maozedong/place/place4.tsx @@ -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) }} /> diff --git a/src/pages/maozedong/reflection.tsx b/src/pages/maozedong/reflection.tsx index c7f2864..89a1ef9 100644 --- a/src/pages/maozedong/reflection.tsx +++ b/src/pages/maozedong/reflection.tsx @@ -66,7 +66,7 @@ const endingQuizOptions = [ }, { value: 'B', - label: 'B. 在调养修整后调研农民运动', + label: 'B. 在调养休整后调研农民运动', }, ] @@ -232,7 +232,7 @@ const Home = () => { > {`同学你好,欢迎来到“跨越时空·体悟”学习模块! 请登陆“时空回溯・亲历时代”体验仓,下一站:1924年,上海。 -1924年的上海,革命浪潮暗涌,中共四大筹备工作关乎革命前途。无产阶级领导权、工农联盟等核心议题亟待明确,毛泽东同志在筹备关键节点面临诸多重要抉择。请你化身历史见证者,站在毛主席的视角做出选择,解锁四大筹备的关键密码,感受革命先辈的坚定与智慧!完成三次选择后,可进入下一阶段的学习。时空门已开,请做好准备迎接考验吧!`} +1924年的上海,革命浪潮暗涌,中共四大筹备工作关乎革命前途。无产阶级领导权、工农联盟等核心议题亟待明确,毛泽东同志在会议筹备的关键节点面临诸多重要抉择。请你化身历史见证者,站在毛主席的视角做出选择,解锁中共四大筹备的关键密码,感受革命先辈的坚定与智慧!完成三次选择后,可进入下一阶段的学习。时空门已开,请做好准备迎接考验吧!`} ), }, @@ -431,7 +431,7 @@ const Home = () => { }, { id: 'anchor-12', - time: 60 * 2 + 43, + time: 60 * 2 + 44, afterResume: 'hide', render: ({ resume, unmute }) => ( { }, { id: 'anchor-13', - time: 60 * 2 + 57, + time: 60 * 2 + 58, afterResume: 'hide', render: ({ resume, unmute }) => ( { }, { id: 'anchor-14', - time: 60 * 3 + 9, + time: 60 * 3 + 10, afterResume: 'hide', render: ({ resume, 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() diff --git a/src/pages/nieer/components/SelectBg.tsx b/src/pages/nieer/components/SelectBg.tsx index b6f7430..18a7588 100644 --- a/src/pages/nieer/components/SelectBg.tsx +++ b/src/pages/nieer/components/SelectBg.tsx @@ -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(null) - const [dropdownPosition, setDropdownPosition] = useState<{ - top: number - left: number - width: number - } | null>(null) - const triggerRef = useRef(null) const dropdownRef = useRef(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 (
@@ -157,56 +132,50 @@ export function SelectBg({
- {isOpen && - dropdownPosition && - createPortal( -
-
- {options.map((opt) => { - const isHovered = opt.value === hoveredValue - return ( - - ) - })} -
-
, - document.body - )} + {isOpen && ( +
+
+ {options.map((opt) => { + const isHovered = opt.value === hoveredValue + return ( + + ) + })} +
+
+ )}
) } diff --git a/src/pages/nieer/home.tsx b/src/pages/nieer/home.tsx index c4caf22..3ed6b25 100644 --- a/src/pages/nieer/home.tsx +++ b/src/pages/nieer/home.tsx @@ -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 | undefined +): Record> => { + if (!data || typeof data !== 'object') return {} + const visitedExhibits: Record> = {} + 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 + 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 ( diff --git a/src/pages/nieer/internalization.tsx b/src/pages/nieer/internalization.tsx index e454b4b..089fed1 100644 --- a/src/pages/nieer/internalization.tsx +++ b/src/pages/nieer/internalization.tsx @@ -309,10 +309,10 @@ const NieerInternalization = () => { 小沪,前两个模块中,我循着聂耳的足迹读懂了旋律背后的家国情怀,亲历了国歌跨越时空的动人故事。我还想从音乐这一艺术创作角度出发更多了解聂耳,你能给我讲讲吗? - + 讲到音乐,可是很专业的领域了!今天我们有幸邀请到了上海音乐学院党委宣传部副部长,东方乐器博物馆馆长,研究员,肖阳。肖老师会从上海音乐学院对聂耳的研究、与《义勇军进行曲》的渊源的角度告诉你聂耳的音乐作品与时代价值。 - + 接下来,就让我们跟随肖老师一起,探寻聂耳音乐作品和艺术精神的当代意义吧! @@ -378,7 +378,7 @@ const NieerInternalization = () => { onContinue={() => { setShowStudyGuide(false) setCourseTimeProgress(15) - navigate('/menu') + navigate('/learning-map', { state: { completedResidence: 'nieer' } }) }} /> diff --git a/src/pages/nieer/place/place1.tsx b/src/pages/nieer/place/place1.tsx index 10b1f5d..484aadd 100644 --- a/src/pages/nieer/place/place1.tsx +++ b/src/pages/nieer/place/place1.tsx @@ -393,7 +393,7 @@ const NieerPlace1 = () => { firstLineIndent="2em" > 同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'} - 此刻你正位立于国歌展示馆的入口,请随时光的脚步,在聂耳作品的“时光声浪”中聆听5段炽热的时代回响:民族危亡的怒吼声、时代呼唤的最强音、民族解放的冲锋号、强我中华的进行曲、伟大复兴的主旋律。你准备好跟随聂耳的音符,踏上这段炽热征程了吗? + 此刻你正伫立于国歌展示馆的入口,请随时光的脚步,在聂耳作品的“时光声浪”中聆听5段炽热的时代回响:民族危亡的怒吼声、时代呼唤的最强音、民族解放的冲锋号、强我中华的进行曲、伟大复兴的主旋律。你准备好跟随聂耳的音符,踏上这段炽热征程了吗? ) diff --git a/src/pages/nieer/reflection/DialogLayer.tsx b/src/pages/nieer/reflection/DialogLayer.tsx index d5efff3..304c910 100644 --- a/src/pages/nieer/reflection/DialogLayer.tsx +++ b/src/pages/nieer/reflection/DialogLayer.tsx @@ -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() const [guizhouInput, setGuizhouInput] = useState('') const [pianoFinished, setPianoFinished] = useState(false) @@ -337,19 +330,6 @@ const DialogLayer = ({ )} alt="结果页" /> - {state.videoSrc && ( -
-
- )} { openScene, enterUnlock, handleShare, - finishBadgeVideo, close, } = useDialogReducer(markReflectionCursorVisited, setReflectionWork) @@ -63,25 +62,42 @@ const NieerReflection = () => { ) const [endingVisible, setEndingVisible] = useState(false) + const [pendingBadgeVideoSrc, setPendingBadgeVideoSrc] = useState() + const [playingBadgeVideoSrc, setPlayingBadgeVideoSrc] = useState() + 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) => { - 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 = () => { navigate(`/${moduleName}/${starName}/home`)} /> @@ -295,18 +311,33 @@ const NieerReflection = () => { state={dialogState} onEnterUnlock={enterUnlock} onShare={handleResultShare} - onFinishBadgeVideo={finishBadgeVideo} onClose={close} /> + {playingBadgeVideoSrc && ( +
+
+ )} + - {`同学你好,欢迎来到”跨越时空·体悟”学习模块! - 请登陆”时空回溯・亲历时代”体验仓,下一站:1936年,上海。 + {`同学你好,欢迎来到“跨越时空·体悟”学习模块! + 请登陆“时空回溯・亲历时代”体验仓,下一站:1936年,上海。 请带上你的旅行包,穿越时空解锁6个国歌故事,集齐精神徽章,绘制属于你的国歌精神图谱吧~ 别忘了右下角的旅行箱哦,它会记录你每段旅行的成果!时空门已开,请出发吧!`} diff --git a/src/pages/nieer/reflection/lobby.config.ts b/src/pages/nieer/reflection/lobby.config.ts index be4075a..99a9fec 100644 --- a/src/pages/nieer/reflection/lobby.config.ts +++ b/src/pages/nieer/reflection/lobby.config.ts @@ -70,5 +70,5 @@ export const UNLOCK_TITLES: Record = { 3: '请跟随光点在钢琴上弹奏出国歌的片段,为任光的演出伴奏吧~', 4: '点击选择史沫特莱女士临终遗言最触动你的一句话。', 5: '请拖曳配齐图片要素制作接同胞回家的明信片!', - 6: '选一个你最喜欢的阅兵方阵,搭配一句词条,点击分享,定格那个瞬间。', + 6: '选一个你最喜欢的阅兵方阵,搭配一句词条,点击分享,定格那个瞬间。', } diff --git a/src/pages/nieer/reflection/useDialogReducer.ts b/src/pages/nieer/reflection/useDialogReducer.ts index 7167654..4b88efe 100644 --- a/src/pages/nieer/reflection/useDialogReducer.ts +++ b/src/pages/nieer/reflection/useDialogReducer.ts @@ -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 - 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 } } diff --git a/src/pages/rongdesheng/home.tsx b/src/pages/rongdesheng/home.tsx index aec6c6a..2b75170 100644 --- a/src/pages/rongdesheng/home.tsx +++ b/src/pages/rongdesheng/home.tsx @@ -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 | undefined +): Record> => { + if (!data || typeof data !== 'object') return {} + const visitedExhibits: Record> = {} + 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 + lastVisitedPlace?: string | null + reflectionCompleted?: Record + reflectionVideoViewed?: Record> + 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 ( diff --git a/src/pages/rongdesheng/internalization.tsx b/src/pages/rongdesheng/internalization.tsx index 11e3f4b..739998a 100644 --- a/src/pages/rongdesheng/internalization.tsx +++ b/src/pages/rongdesheng/internalization.tsx @@ -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' } }) }} /> diff --git a/src/pages/rongdesheng/place/place2.tsx b/src/pages/rongdesheng/place/place2.tsx index 737eb3c..3c5eed4 100644 --- a/src/pages/rongdesheng/place/place2.tsx +++ b/src/pages/rongdesheng/place/place2.tsx @@ -262,7 +262,7 @@ const RongdeshengPlace2 = () => { speaker="student" audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_07.mp3')} > - 哇,这是福新面粉公司的老照片!荣德生先生为什么选择办面粉,面粉厂又为什么在上海创办?福新面粉厂后来发展得怎么样? + 哇,这是福新面粉公司的老照片!荣德生先生为什么选择办面粉厂,面粉厂又为什么在上海创办?福新面粉厂后来发展得怎么样?
{ // 触发碎片获取动画(展品3 -> 碎片13,展品4 -> 碎片14) if (closingId === 3) { triggerPuzzleAnimation(13) - } else if (closingId === 4) { - triggerPuzzleAnimation(14) } if (closingId === 4) { @@ -377,7 +375,7 @@ const RongdeshengPlace4 = () => { setEndingGuideVisible(false)} /> diff --git a/src/pages/rongdesheng/reflection/components/FinalScene.tsx b/src/pages/rongdesheng/reflection/components/FinalScene.tsx index 87d1fe3..f44c24c 100644 --- a/src/pages/rongdesheng/reflection/components/FinalScene.tsx +++ b/src/pages/rongdesheng/reflection/components/FinalScene.tsx @@ -32,12 +32,17 @@ export const FinalScene = ({ {finalVideoStage !== 'idle' && (
+ {/* 返回按钮 */} +
+ +
+
+ 0} triggerKey={submitErrorHintKey} durationMs={3200}> + 作品生成失败,请稍后再试 + + {createdArtworkSrc && ( {
①为三毛换装
②搭配1个新时代场景
③融入1-3个新时代元素
-
④配1句简短正能量文字
+
④配1句简短正能量文字(不超过15个字)
diff --git a/src/pages/zhangleping/home.tsx b/src/pages/zhangleping/home.tsx index c111147..be458e8 100644 --- a/src/pages/zhangleping/home.tsx +++ b/src/pages/zhangleping/home.tsx @@ -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 | undefined +): Record> => { + if (!data || typeof data !== 'object') return {} + const visitedExhibits: Record> = {} + 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 + lastVisitedPlace?: string | null + reflectionRoute?: string + visitedReflectionCursors?: Record + 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 ( diff --git a/src/pages/zhangleping/internalization.tsx b/src/pages/zhangleping/internalization.tsx index 94eb925..ccefd8e 100644 --- a/src/pages/zhangleping/internalization.tsx +++ b/src/pages/zhangleping/internalization.tsx @@ -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 = () => { > 小沪,刚刚完成“三毛寻忆”的时空任务太有收获了!从童年学艺的老画桌到新中国的画架,我跟着三毛的记忆碎片,真切感受到张乐平先生用画笔记录时代、守护童心的坚守。 - + 尤其是先生笔下三毛的形象从流浪儿到少先队员的转变,还有不同年代里“画笔如刀枪、如镜子、如园丁”的力量,都让我特别触动。但我还想从美术这一艺术创作角度出发更多了解张乐平先生,你能给我讲讲吗? 你的感悟特别深刻!关于你想了解的美术创作,今天我们有幸邀请到了上海大学美术学院设计系副主任、副教授汪宁,汪教授对张乐平漫画艺术开展过专门研究,对先生的创作理念与时代价值有着深入的理解。 - + 接下来,就让我们跟随汪教授一起,探寻张乐平艺术作品和艺术精神的当代意义吧! @@ -485,7 +492,8 @@ const ZhanglepingInternalization = () => { animationSpeed={200} onContinue={() => { setShowStudyGuide(false) - navigate('/menu') + setCourseTimeProgress(18) + navigate('/learning-map', { state: { completedResidence: 'zhangleping' } }) }} /> diff --git a/src/pages/zhangleping/place/place1.tsx b/src/pages/zhangleping/place/place1.tsx index 61d3564..91bccdf 100644 --- a/src/pages/zhangleping/place/place1.tsx +++ b/src/pages/zhangleping/place/place1.tsx @@ -186,7 +186,7 @@ const ZhanglepingPlace1 = () => { speaker="guide" audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_05_3.mp3')} > - 但是因为家境贫寒,张先生15岁就只身来到南汇万祥镇,在木料行当学徒,其间他在老板的纸堆上偷偷画画,哪怕被责打也不放弃,还用芦条在沙滩上画巨龙,巨龙里蕴藏的就是他“以画报国”的初心。 + 但是因为家境贫寒,张先生15岁就只身来到南汇万祥镇,在木料行当学徒,其间他在老板的纸媒上偷偷画画,哪怕被责打也不放弃,还用芦条在沙滩上画巨龙,巨龙里藏着的就是他“以画报国”的初心。 { 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')} > - 小沪,三毛系列漫画真的太深入人心了!张乐平先生还会和“小三毛迷”们互通书信,张先生真的是非常喜欢儿童呀! + 朋友画我!这一整面墙的朋友笔下的张乐平先生和三毛真是各有特色啊! { speaker="guide" audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_17_4.mp3')} > - 这些画作中流淌的是承载着跨越半个世纪的深厚情谊与中国漫画事业的代际传承,更是用漫画传递真善美的中国漫画精神的重要见证。 + 在这些画作中流淌的是承载着跨越半个世纪的深厚情谊与中国漫画事业的代际传承,更是用漫画传递真善美的中国漫画精神的重要见证。 diff --git a/src/pages/zhangleping/reflection/index.tsx b/src/pages/zhangleping/reflection/index.tsx index b28a180..84f2404 100644 --- a/src/pages/zhangleping/reflection/index.tsx +++ b/src/pages/zhangleping/reflection/index.tsx @@ -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 && ( navigate(`/${moduleName}/${starName}/home`)} /> - )} + )} */} {/* 碎片收集 */}
@@ -250,6 +259,13 @@ const ZhanglepingReflection = () => { /> ) })} + {allFragmentsCompleted && ( + 敲章 + )}
diff --git a/src/pages/zhangleping/reflection/scene3.tsx b/src/pages/zhangleping/reflection/scene3.tsx index 457a09d..3522f35 100644 --- a/src/pages/zhangleping/reflection/scene3.tsx +++ b/src/pages/zhangleping/reflection/scene3.tsx @@ -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 = { - 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> = { }, } -// 第二阶段:点击常量 -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 = + DRAG_ITEMS.reduce( + (acc, item) => { + acc[item.id] = { className: item.className, src: item.src } + return acc + }, + {} as Record + ) + +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([]) 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 = () => { 苦难善良·记忆碎片已解锁 )} - {/* 场景提示:点击阶段常驻/错误提示 */} + {/* 场景提示:拖拽阶段常驻/错误提示 */} {!showItemExhibit && !showFragmentOverlay && !fragmentCompleted && - isClickPhase && + isDragPhase && hint.hintMode && ( { )} - {/* 静态物品(第一阶段纯展示,第二阶段可点击带发光效果) */} - {CLICK_ITEMS.map((item) => ( -
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) => ( + + ))} + + {/* 第二阶段:拖拽物品到记忆碎片区域 */} + {isDragPhase && ( + { + const cfg = DRAG_OPTION_ASSET_MAP[option.value as DragOptionValue] + if (!cfg) return null + return ( +
+ + {!isUsed && !isDragging && ( +
+ 可拖拽 +
+ )} +
+ ) }} - onMouseLeave={(e) => { - if (isClickPhase) { - e.currentTarget.style.filter = 'none' - } + renderBlank={(blank, _filledValue, isHovered) => ( +
+ +
+ )} + renderDragging={(option) => { + const src = DRAG_OPTION_ASSET_MAP[option.value as DragOptionValue]?.src + if (!src) return null + return }} - 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))' - } - }} - > - -
- ))} + /> + )} {/* 第一阶段:两个光标,按顺序解锁 */} {!resourcesCompleted && ( <> { )} - {/* 记忆碎片区域(第二阶段及之后显示) */} - {(isClickPhase || fragmentCompleted) && ( + {/* 记忆碎片区域(解锁后显示) */} + {fragmentCompleted && (
{ )} style={{ backgroundImage: getBackgroundImage('zhangleping/reflection/clue/泡泡') }} > - +
)} @@ -322,9 +367,9 @@ const ZhanglepingReflectionScene3 = () => { )} - {/* 第二阶段:点击物品2后的展品弹层(场景3展品2) */} + {/* 第二阶段:拖拽半块饼后的展品弹层(场景3展品3) */} setShowItemDialogue(true)} onClose={handleCloseItemExhibit} diff --git a/src/pages/zhangleping/reflection/scene5.tsx b/src/pages/zhangleping/reflection/scene5.tsx index 8f8eae7..7e142a6 100644 --- a/src/pages/zhangleping/reflection/scene5.tsx +++ b/src/pages/zhangleping/reflection/scene5.tsx @@ -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) } diff --git a/src/store/huangyanpeiStore.ts b/src/store/huangyanpeiStore.ts index 9426113..5ae1292 100644 --- a/src/store/huangyanpeiStore.ts +++ b/src/store/huangyanpeiStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { STORE_NAMES } from './storeNames' -import { setUserDefine, getUserDefine } from '@/lib/utils/api-common' +import { setUserDefine, getUserDefineResult } from '@/lib/utils/api-common' interface HuangyanpeiStore { // 存储每个场景(place)的已访问展品ID集合 @@ -251,12 +251,19 @@ export const saveHuangyanpeiStore = (): void => { export const loadHuangyanpeiStore = async (): Promise => { try { - const raw = await getUserDefine(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY) - if (!raw) { + const result = await getUserDefineResult(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY) + if (result.status !== 'success') { + useHuangyanpeiStore.setState({ + visitedExhibits: {}, + lastVisitedPlace: null, + reflectionRoute: 'reflection', + shownReflectionCompletionGuides: {}, + reflectionScene2Choice: null, + }) return } - const payload = JSON.parse(raw) as { + const payload = JSON.parse(result.content) as { visitedExhibits?: Record lastVisitedPlace?: string | null reflectionRoute?: string @@ -264,17 +271,21 @@ export const loadHuangyanpeiStore = async (): Promise => { reflectionScene2Choice?: '铁工科' | '商科' | null } - useHuangyanpeiStore.setState((state) => ({ - visitedExhibits: payload.visitedExhibits - ? deserializeVisitedExhibits(payload.visitedExhibits) - : state.visitedExhibits, - lastVisitedPlace: payload.lastVisitedPlace ?? state.lastVisitedPlace, - reflectionRoute: payload.reflectionRoute ?? state.reflectionRoute, - shownReflectionCompletionGuides: - payload.shownReflectionCompletionGuides ?? state.shownReflectionCompletionGuides, - reflectionScene2Choice: payload.reflectionScene2Choice ?? state.reflectionScene2Choice, - })) + useHuangyanpeiStore.setState({ + visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits), + lastVisitedPlace: payload.lastVisitedPlace ?? null, + reflectionRoute: payload.reflectionRoute ?? 'reflection', + shownReflectionCompletionGuides: payload.shownReflectionCompletionGuides ?? {}, + reflectionScene2Choice: payload.reflectionScene2Choice ?? null, + }) } catch (error) { + useHuangyanpeiStore.setState({ + visitedExhibits: {}, + lastVisitedPlace: null, + reflectionRoute: 'reflection', + shownReflectionCompletionGuides: {}, + reflectionScene2Choice: null, + }) console.error('Failed to load Huangyanpei store', error) } } diff --git a/src/store/zhanglepingStore.ts b/src/store/zhanglepingStore.ts index 992b765..41c3938 100644 --- a/src/store/zhanglepingStore.ts +++ b/src/store/zhanglepingStore.ts @@ -192,6 +192,8 @@ export const saveZhanglepingStore = (): void => { state.drawArtworkDataUrl.length <= REMOTE_MAX_DATA_URL_LENGTH ? state.drawArtworkDataUrl : '' const payload = { + visitedExhibits: serializeVisited(state.visitedExhibits), + lastVisitedPlace: state.lastVisitedPlace, reflectionRoute: state.reflectionRoute, visitedReflectionCursors: serializeVisited(state.visitedReflectionCursors), drawArtworkDataUrl: remoteArtworkDataUrl, diff --git a/src/types/index.d.ts b/src/types/index.d.ts index c4b33a0..bca3a23 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -14,7 +14,6 @@ interface ImportMeta { DEV: boolean PROD: boolean SSR: boolean - VITE_PROGRESS_UNLOCK_ALL?: string VITE_DEBUG_ENTRY?: string } } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 6cc82b1..3c864c1 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,7 +1,6 @@ /// interface ImportMetaEnv { - readonly VITE_PROGRESS_UNLOCK_ALL?: string readonly VITE_DEBUG_ENTRY?: string } diff --git a/types/api-common.d.ts b/types/api-common.d.ts index 186a070..3c51678 100644 --- a/types/api-common.d.ts +++ b/types/api-common.d.ts @@ -1,10 +1,24 @@ declare module '@/lib/utils/api-common' { + export type UserDefineFetchStatus = 'success' | 'empty' | 'unauthenticated' | 'error' + + export interface UserDefineFetchResult { + status: UserDefineFetchStatus + content: string + code?: number + message?: string + } + export function setUserDefine( lessonId: string | number, key: string | number, value: string ): void + export function getUserDefineResult( + lessonId: string | number, + key: string | number + ): Promise + export function getUserDefine(lessonId: string | number, key: string | number): Promise export function getUsersDefine(