diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 67aefde..b0d4783 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,45 @@ "Bash(done)", "Bash(grep -E \"\\\\.tsx$\")", "Bash(grep -r \"navigate\" /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/src/pages/chenyun/place/place*.tsx)", - "Bash(bun run:*)" + "Bash(bun run:*)", + "Bash(npm run *)", + "Bash(npx prettier *)", + "Bash(grep -Ei \"nieer|badge|mp3_nieer|/video/|\\\\.mp3$|\\\\.mp4$|\\\\.mov$|\\\\.webm$\")", + "Bash(npm exec *)", + "Bash(/\\\\$\\\\{moduleName\\\\}/\\\\$\\\\{starName\\\\}\" \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a48dafb4/src/pages\" \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a48dafb4/src/router\")", + "Bash(grep -RIn \"moduleName as ModuleName\\\\|starName = \\\\\"\\\\|navigate\\\\\\(`/\\\\$\\\\{moduleName\\\\}/\\\\$\\\\{starName\\\\}\" \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a48dafb4/src/pages\" \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a48dafb4/src/router\")", + "Bash(bunx eslint *)", + "Bash(npx eslint *)", + "Bash(npx tsc *)", + "Bash(python3 -)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a5e97b1c status --short)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a5e97b1c branch --show-current)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a5e97b1c log --oneline --all --decorate --grep='张乐平\\\\|三毛\\\\|Sanmao\\\\|draw\\\\|draw1\\\\|ExhibitFlipOverlay' -n 50)", + "Bash(grep -E '\\(/image/|/button/|ExhibitDescriptionNarration|ExhibitOverlay|router/modules|reflection/scene|home.tsx|place[0-9].tsx$|utils/index.ts\\)')", + "Bash(grep -E \"\\(tsx|ts\\)$\")", + "Bash(git -C \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a5bf04dd\" status --short)", + "Bash(git -C \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a5bf04dd\" branch --show-current)", + "Bash(git -C \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a5bf04dd\" ls-files)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 status --short)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 branch --show-current)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 log --oneline -5)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n -i \"zhangleping\\\\|drawArtworkDataUrl\\\\|hasSubmittedArtwork\\\\|三毛\\\\|张乐平\\\\|创作了三毛\" -- .)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 branch -a --list '*zhang*' '*main*' '*master*')", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 log --all --oneline --grep='张乐平\\\\|三毛\\\\|zhangleping\\\\|drawArtwork' -50)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n -i \"zhangleping\\\\|drawArtworkDataUrl\\\\|hasSubmittedArtwork\\\\|三毛\\\\|张乐平\\\\|创作了三毛\" main origin/main master origin/master -- .)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 ls-tree -r --name-only main src/pages/zhangleping)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/reflection/scene5.tsx)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/store/zhanglepingStore.ts)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/draw/index.tsx)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/draw/draw1.tsx)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/draw/draw1.config.ts)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/router/modules/zhangleping.tsx)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/reflection/index.tsx)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n \"getUserDefine\\\\|setUserDefine\\\\|saveZhanglepingStore\\\\|ZHANGLEPING_LESSON_ID\\\\|ZHANGLEPING_KEY\\\\|drawArtworkDataUrl\\\\|hasDrawArtwork\" main -- src)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/home.tsx)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/store/storeNames.ts)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n \"getUserDefine\" main -- src/pages/zhangleping src/store/zhanglepingStore.ts src/App.tsx)", + "Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n \"setDrawArtwork\\\\|clearDrawArtwork\\\\|drawArtworkDataUrl\" main -- src)" ], "deny": [ "Read(public/**)", diff --git a/.claude/worktrees/agent-a1669fc8 b/.claude/worktrees/agent-a1669fc8 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a1669fc8 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a1b9a5a9 b/.claude/worktrees/agent-a1b9a5a9 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a1b9a5a9 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a2ac2892 b/.claude/worktrees/agent-a2ac2892 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a2ac2892 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a41031e2 b/.claude/worktrees/agent-a41031e2 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a41031e2 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a4306bae b/.claude/worktrees/agent-a4306bae new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a4306bae @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a48dafb4 b/.claude/worktrees/agent-a48dafb4 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a48dafb4 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a503052f b/.claude/worktrees/agent-a503052f new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a503052f @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a5220993 b/.claude/worktrees/agent-a5220993 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a5220993 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a56d14e6 b/.claude/worktrees/agent-a56d14e6 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a56d14e6 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a5bf04dd b/.claude/worktrees/agent-a5bf04dd new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a5bf04dd @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a5e57f2f b/.claude/worktrees/agent-a5e57f2f new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a5e57f2f @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a5e97b1c b/.claude/worktrees/agent-a5e97b1c new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a5e97b1c @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a6a005c1 b/.claude/worktrees/agent-a6a005c1 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a6a005c1 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a729cc8b b/.claude/worktrees/agent-a729cc8b new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a729cc8b @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a77c923f b/.claude/worktrees/agent-a77c923f new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a77c923f @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a7ac1f76 b/.claude/worktrees/agent-a7ac1f76 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a7ac1f76 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a907386f b/.claude/worktrees/agent-a907386f new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a907386f @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-a9420406 b/.claude/worktrees/agent-a9420406 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-a9420406 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-ab099292 b/.claude/worktrees/agent-ab099292 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-ab099292 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-ace17dec b/.claude/worktrees/agent-ace17dec new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-ace17dec @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-ad5d8ae2 b/.claude/worktrees/agent-ad5d8ae2 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-ad5d8ae2 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-aee3dc15 b/.claude/worktrees/agent-aee3dc15 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-aee3dc15 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-aef2787f b/.claude/worktrees/agent-aef2787f new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-aef2787f @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-af322a60 b/.claude/worktrees/agent-af322a60 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-af322a60 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/.claude/worktrees/agent-af9038e9 b/.claude/worktrees/agent-af9038e9 new file mode 160000 index 0000000..57edd05 --- /dev/null +++ b/.claude/worktrees/agent-af9038e9 @@ -0,0 +1 @@ +Subproject commit 57edd05c5a839989b4214ebf255cc530f2158f31 diff --git a/eslint.config.ts b/eslint.config.ts index f3e8d65..2c3ac86 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,3 +1,5 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' @@ -5,8 +7,12 @@ import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' +const tsconfigRootDir = path.dirname(fileURLToPath(import.meta.url)) + export default tseslint.config( - { ignores: ['dist', 'node_modules', 'vite.config.ts'] }, + { + ignores: ['dist', 'node_modules', 'vite.config.ts', '.claude/worktrees/**', 'public/**'], + }, { extends: [ js.configs.recommended, @@ -17,6 +23,9 @@ export default tseslint.config( languageOptions: { ecmaVersion: 2020, globals: globals.browser, + parserOptions: { + tsconfigRootDir, + }, }, plugins: { 'react-hooks': reactHooks, diff --git a/src/assets/images/menu/学习地图.png b/src/assets/images/menu/学习地图.png new file mode 100644 index 0000000..48726b8 Binary files /dev/null and b/src/assets/images/menu/学习地图.png differ diff --git a/src/assets/images/menu/导航地图.png b/src/assets/images/menu/导航地图.png new file mode 100644 index 0000000..828b381 Binary files /dev/null and b/src/assets/images/menu/导航地图.png differ diff --git a/src/assets/images/puzzle/home/底图.png b/src/assets/images/puzzle/home/底图.png index a96615d..b3083cc 100644 Binary files a/src/assets/images/puzzle/home/底图.png and b/src/assets/images/puzzle/home/底图.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/1.png b/src/assets/images/puzzle/zhangleping/details/1.png index e3ce21b..65024a2 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/1.png and b/src/assets/images/puzzle/zhangleping/details/1.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/10.png b/src/assets/images/puzzle/zhangleping/details/10.png index b276617..bf00afe 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/10.png and b/src/assets/images/puzzle/zhangleping/details/10.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/11.png b/src/assets/images/puzzle/zhangleping/details/11.png index e6bf455..b3f289c 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/11.png and b/src/assets/images/puzzle/zhangleping/details/11.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/12.png b/src/assets/images/puzzle/zhangleping/details/12.png index 2cd71e2..30877e9 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/12.png and b/src/assets/images/puzzle/zhangleping/details/12.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/13.png b/src/assets/images/puzzle/zhangleping/details/13.png index ec31ea7..82dd733 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/13.png and b/src/assets/images/puzzle/zhangleping/details/13.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/14.png b/src/assets/images/puzzle/zhangleping/details/14.png index 0a3d5e0..34332d7 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/14.png and b/src/assets/images/puzzle/zhangleping/details/14.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/15.png b/src/assets/images/puzzle/zhangleping/details/15.png index d333f4e..7479447 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/15.png and b/src/assets/images/puzzle/zhangleping/details/15.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/16.png b/src/assets/images/puzzle/zhangleping/details/16.png index 75e254b..24d8f3d 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/16.png and b/src/assets/images/puzzle/zhangleping/details/16.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/2.png b/src/assets/images/puzzle/zhangleping/details/2.png index 5492682..5915d37 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/2.png and b/src/assets/images/puzzle/zhangleping/details/2.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/3.png b/src/assets/images/puzzle/zhangleping/details/3.png index c4a55cc..d449612 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/3.png and b/src/assets/images/puzzle/zhangleping/details/3.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/4.png b/src/assets/images/puzzle/zhangleping/details/4.png index f9f771f..1e58246 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/4.png and b/src/assets/images/puzzle/zhangleping/details/4.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/5.png b/src/assets/images/puzzle/zhangleping/details/5.png index f4e6c4c..f4917c3 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/5.png and b/src/assets/images/puzzle/zhangleping/details/5.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/6.png b/src/assets/images/puzzle/zhangleping/details/6.png index 065b01b..5e5d164 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/6.png and b/src/assets/images/puzzle/zhangleping/details/6.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/7.png b/src/assets/images/puzzle/zhangleping/details/7.png index 1895d23..24f6fd4 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/7.png and b/src/assets/images/puzzle/zhangleping/details/7.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/8.png b/src/assets/images/puzzle/zhangleping/details/8.png index a0bfb85..0120ab9 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/8.png and b/src/assets/images/puzzle/zhangleping/details/8.png differ diff --git a/src/assets/images/puzzle/zhangleping/details/9.png b/src/assets/images/puzzle/zhangleping/details/9.png index 433caac..49a11ea 100644 Binary files a/src/assets/images/puzzle/zhangleping/details/9.png and b/src/assets/images/puzzle/zhangleping/details/9.png differ diff --git a/src/assets/images/puzzle/未选中.png b/src/assets/images/puzzle/未选中.png new file mode 100644 index 0000000..87cff36 Binary files /dev/null and b/src/assets/images/puzzle/未选中.png differ diff --git a/src/assets/images/puzzle/选中.png b/src/assets/images/puzzle/选中.png new file mode 100644 index 0000000..a5ea1f6 Binary files /dev/null and b/src/assets/images/puzzle/选中.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene1/线索1-详情.png b/src/assets/images/sunzhongshan/reflection/scene1/线索1-详情.png index a8e3bfc..1973b1c 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/scene1/线索1-详情的详情.png b/src/assets/images/sunzhongshan/reflection/scene1/线索1-详情的详情.png new file mode 100644 index 0000000..b2b9823 Binary files /dev/null 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 051be74..d54a8b1 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/zhangleping/place1/张乐平场景1展品1.png b/src/assets/images/zhangleping/place1/张乐平场景1展品1.png index 2ee41c8..557f3ef 100644 Binary files a/src/assets/images/zhangleping/place1/张乐平场景1展品1.png and b/src/assets/images/zhangleping/place1/张乐平场景1展品1.png differ diff --git a/src/assets/images/zhangleping/place1/张乐平场景1展品2.png b/src/assets/images/zhangleping/place1/张乐平场景1展品2.png index 5d192da..1e5b486 100644 Binary files a/src/assets/images/zhangleping/place1/张乐平场景1展品2.png and b/src/assets/images/zhangleping/place1/张乐平场景1展品2.png differ diff --git a/src/assets/images/zhangleping/place2/张乐平场景2展品1.png b/src/assets/images/zhangleping/place2/张乐平场景2展品1.png index 7779e83..723bb8a 100644 Binary files a/src/assets/images/zhangleping/place2/张乐平场景2展品1.png and b/src/assets/images/zhangleping/place2/张乐平场景2展品1.png differ diff --git a/src/assets/images/zhangleping/place2/张乐平场景2展品2.png b/src/assets/images/zhangleping/place2/张乐平场景2展品2.png index 03d7074..781dbbe 100644 Binary files a/src/assets/images/zhangleping/place2/张乐平场景2展品2.png and b/src/assets/images/zhangleping/place2/张乐平场景2展品2.png differ diff --git a/src/assets/images/zhangleping/place2/张乐平场景2展品3.png b/src/assets/images/zhangleping/place2/张乐平场景2展品3.png index 93de0e6..ec7ad60 100644 Binary files a/src/assets/images/zhangleping/place2/张乐平场景2展品3.png and b/src/assets/images/zhangleping/place2/张乐平场景2展品3.png differ diff --git a/src/assets/images/zhangleping/place3/张乐平场景3展品1.png b/src/assets/images/zhangleping/place3/张乐平场景3展品1.png index be7b2d3..fb1bfc1 100644 Binary files a/src/assets/images/zhangleping/place3/张乐平场景3展品1.png and b/src/assets/images/zhangleping/place3/张乐平场景3展品1.png differ diff --git a/src/assets/images/zhangleping/place3/张乐平场景3展品2.png b/src/assets/images/zhangleping/place3/张乐平场景3展品2.png index 91b5cc5..7fc5a19 100644 Binary files a/src/assets/images/zhangleping/place3/张乐平场景3展品2.png and b/src/assets/images/zhangleping/place3/张乐平场景3展品2.png differ diff --git a/src/assets/images/zhangleping/place3/张乐平场景3展品3.png b/src/assets/images/zhangleping/place3/张乐平场景3展品3.png index 421cc82..6d6d2db 100644 Binary files a/src/assets/images/zhangleping/place3/张乐平场景3展品3.png and b/src/assets/images/zhangleping/place3/张乐平场景3展品3.png differ diff --git a/src/assets/images/zhangleping/place4/张乐平场景4展品1.png b/src/assets/images/zhangleping/place4/张乐平场景4展品1.png index f5ec0c0..029e381 100644 Binary files a/src/assets/images/zhangleping/place4/张乐平场景4展品1.png and b/src/assets/images/zhangleping/place4/张乐平场景4展品1.png differ diff --git a/src/assets/images/zhangleping/place5/张乐平场景5展品1.png b/src/assets/images/zhangleping/place5/张乐平场景5展品1.png index 92b3791..3cac6a9 100644 Binary files a/src/assets/images/zhangleping/place5/张乐平场景5展品1.png and b/src/assets/images/zhangleping/place5/张乐平场景5展品1.png differ diff --git a/src/assets/images/zhangleping/place5/张乐平场景5展品2.png b/src/assets/images/zhangleping/place5/张乐平场景5展品2.png index 21e200b..6baede9 100644 Binary files a/src/assets/images/zhangleping/place5/张乐平场景5展品2.png and b/src/assets/images/zhangleping/place5/张乐平场景5展品2.png differ diff --git a/src/assets/images/zhangleping/place5/张乐平场景5展品3.png b/src/assets/images/zhangleping/place5/张乐平场景5展品3.png index 9ca2e09..7a513db 100644 Binary files a/src/assets/images/zhangleping/place5/张乐平场景5展品3.png and b/src/assets/images/zhangleping/place5/张乐平场景5展品3.png differ diff --git a/src/components/exhibit/ExhibitDescriptionNarration.tsx b/src/components/exhibit/ExhibitDescriptionNarration.tsx index a013057..7b50318 100644 --- a/src/components/exhibit/ExhibitDescriptionNarration.tsx +++ b/src/components/exhibit/ExhibitDescriptionNarration.tsx @@ -29,7 +29,7 @@ const ExhibitDescriptionNarration = ({ visible = true, autoPlay = true, audioSrc, - audioChannelId, + audioChannelId = 'exhibit-description-narration', className, }: ExhibitDescriptionNarrationProps) => { // 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥 @@ -89,7 +89,7 @@ const ExhibitDescriptionNarration = ({ />
{/* 文本内容 */} -
+
{text}
diff --git a/src/components/exhibit/ExhibitFlipOverlay.tsx b/src/components/exhibit/ExhibitFlipOverlay.tsx index d0ade02..ab59ebe 100644 --- a/src/components/exhibit/ExhibitFlipOverlay.tsx +++ b/src/components/exhibit/ExhibitFlipOverlay.tsx @@ -1,5 +1,5 @@ import { cn, getStaticImage } from '@/lib/utils' -import { useEffect, useMemo, useState } from 'react' +import { type ReactNode, useEffect, useMemo, useState } from 'react' import { FlipActionButton, Image } from '@/components' import { motion } from 'framer-motion' @@ -10,6 +10,14 @@ const OVERLAY_STYLE: React.CSSProperties = { pointerEvents: 'none', } +const isDirectImageSrc = (src: string) => + src.startsWith('http://') || + src.startsWith('https://') || + src.startsWith('/') || + src.startsWith('data:image/') + +const resolveImageSrc = (src: string) => (isDirectImageSrc(src) ? src : getStaticImage(src)) + export interface ExhibitFlipOverlayProps { /** 展品正面图片地址,可以是静态资源路径或完整 URL */ src: string @@ -29,6 +37,10 @@ export interface ExhibitFlipOverlayProps { visible?: boolean /** 额外的自定义类 */ className?: string + /** 底部操作区 */ + footer?: ReactNode + /** 底部操作区额外类名 */ + footerClassName?: string } /** @@ -45,6 +57,8 @@ const ExhibitFlipOverlay = ({ onClose, visible = true, className, + footer, + footerClassName, }: ExhibitFlipOverlayProps) => { const backSrc = getStaticImage('components/close') @@ -63,18 +77,11 @@ const ExhibitFlipOverlay = ({ } }, [visible, src, flipSrc]) - const frontImageSrc = - src.startsWith('http://') || src.startsWith('https://') || src.startsWith('/') - ? src - : getStaticImage(src) + const frontImageSrc = resolveImageSrc(src) const backImageSrc = useMemo(() => { if (!flipSrc) return null - return flipSrc.startsWith('http://') || - flipSrc.startsWith('https://') || - flipSrc.startsWith('/') - ? flipSrc - : getStaticImage(flipSrc) + return resolveImageSrc(flipSrc) }, [flipSrc]) const canFlip = Boolean(backImageSrc && backImageSrc !== frontImageSrc) @@ -200,6 +207,20 @@ const ExhibitFlipOverlay = ({ )}
+ + {showChrome && footer && ( + + {footer} + + )}
) diff --git a/src/components/guide/GuideSteps.tsx b/src/components/guide/GuideSteps.tsx index 0062953..e974cb6 100644 --- a/src/components/guide/GuideSteps.tsx +++ b/src/components/guide/GuideSteps.tsx @@ -55,7 +55,7 @@ const GuideSteps = ({ highlightSelectors, showMask = true, zIndex = 50, - audioChannelId, + audioChannelId = 'guide-steps-default', }: GuideStepsProps) => { const [internalStep, setInternalStep] = useState(0) const [internalVisible, setInternalVisible] = useState(true) diff --git a/src/components/guide/StudyGuideModal.tsx b/src/components/guide/StudyGuideModal.tsx index 448b6af..efe2ca3 100644 --- a/src/components/guide/StudyGuideModal.tsx +++ b/src/components/guide/StudyGuideModal.tsx @@ -24,6 +24,8 @@ export interface StudyGuideModalProps { ailgn?: 'center' | 'left' | 'right' // 垂直对齐方式,默认 center verticalAlign?: 'center' | 'top' | 'bottom' + /** 正文首行缩进,如 '2em'、'32px' 或 32 */ + firstLineIndent?: string | number /** 引导语音地址(可选) */ audioSrc?: string /** 弹窗出现时是否自动播放引导语音(默认 true) */ @@ -55,6 +57,7 @@ export const StudyGuideModal = ({ zIndex = 120, ailgn = 'center', verticalAlign = 'top', + firstLineIndent, audioSrc, autoPlayAudio = true, audioChannelId, @@ -152,6 +155,12 @@ export const StudyGuideModal = ({ )} style={{ marginTop: verticalAlign === 'center' ? '85px' : '', + textIndent: + firstLineIndent === undefined + ? undefined + : typeof firstLineIndent === 'number' + ? `${firstLineIndent}px` + : firstLineIndent, }} > {children} diff --git a/src/components/layout/ResidenceLayout.tsx b/src/components/layout/ResidenceLayout.tsx index c39c626..b245226 100644 --- a/src/components/layout/ResidenceLayout.tsx +++ b/src/components/layout/ResidenceLayout.tsx @@ -1,5 +1,5 @@ import { Outlet, useMatches, useLocation } from 'react-router-dom' -import { useState, useEffect, useRef, useMemo } from 'react' +import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import { createPortal } from 'react-dom' import { getPublicPath, getStaticImage } from '@/lib/utils' import type { VideoRouteHandle } from '@/router/types' @@ -21,6 +21,8 @@ const RESIDENCES_WITH_PUZZLE = [ 'sunzhongshan', 'luxun', 'maozedong', + 'nieer', + 'zhangleping', ] as const /** @@ -48,6 +50,8 @@ const ResidenceLayoutContent = () => { const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行 const puzzlePieceRef = useRef(null) const animationInProgressRef = useRef(false) // 防止重复执行动画 + const animationCompletionRef = useRef<(() => void) | null>(null) + const animationCompletionCallbackRef = useRef<(() => void) | null>(null) // 从路径中提取故居名称(starName),如 'chenyun', 'sunzhongshan' 等 // 路径格式:/:moduleName/:starName/:page @@ -77,106 +81,142 @@ const ResidenceLayoutContent = () => { OPACITY: [1, 1, 0.6, 0] as number[], // 透明度变化(更平滑的淡出) } + const resolveAnimation = useCallback(() => { + const callback = animationCompletionCallbackRef.current + const resolve = animationCompletionRef.current + + animationCompletionCallbackRef.current = null + animationCompletionRef.current = null + + callback?.() + resolve?.() + }, []) + // 清理动画状态 - const cleanupAnimation = () => { + const cleanupAnimation = useCallback(() => { setIsAnimationPlaying(false) setShowPuzzlePiece(false) setPuzzlePieceId(null) setIsFlying(false) animationInProgressRef.current = false - } + resolveAnimation() + }, [resolveAnimation]) // 执行飞行动画 - const executeFlyAnimation = (pieceId: number) => { - if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) { - return false - } + const executeFlyAnimation = useCallback( + (pieceId: number) => { + if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) { + return false + } - setIsFlying(true) + setIsFlying(true) - // 使用 requestAnimationFrame 确保元素已完全渲染 - requestAnimationFrame(() => { - if (!puzzlePieceRef.current || !residence) { + // 使用 requestAnimationFrame 确保元素已完全渲染 + requestAnimationFrame(() => { + if (!puzzlePieceRef.current || !residence) { + return + } + + flyToAnimation({ + fromRef: puzzlePieceRef, + toRef: puzzleBoxRef, + targetPosition: 'center', + duration: ANIMATION_CONFIG.FLY_DURATION, + scale: ANIMATION_CONFIG.SCALE, + opacity: ANIMATION_CONFIG.OPACITY, + ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速 + onComplete: () => { + if (residence) { + unlockPuzzle(residence, pieceId) + } + cleanupAnimation() + }, + }) + }) + + return true + }, + [ANIMATION_CONFIG.FLY_DURATION, ANIMATION_CONFIG.OPACITY, ANIMATION_CONFIG.SCALE, cleanupAnimation, puzzleBoxRef, residence, unlockPuzzle] + ) + + + // 等待元素准备就绪并执行动画 + const waitForElementsAndAnimate = useCallback( + async (pieceId: number, retryCount = 0): Promise => { + if (!residence) { + cleanupAnimation() return } - flyToAnimation({ - fromRef: puzzlePieceRef, - toRef: puzzleBoxRef, - targetPosition: 'center', - duration: ANIMATION_CONFIG.FLY_DURATION, - scale: ANIMATION_CONFIG.SCALE, - opacity: ANIMATION_CONFIG.OPACITY, - ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速 - onComplete: () => { - if (residence) { - unlockPuzzle(residence, pieceId) - } - cleanupAnimation() - }, - }) - }) + // 检查元素是否已准备好 + if (puzzleBoxRef.current && puzzlePieceRef.current) { + const success = executeFlyAnimation(pieceId) + if (success) return + } - return true - } + // 如果元素未准备好且未超过最大重试次数,则重试 + if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) { + await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY)) + return waitForElementsAndAnimate(pieceId, retryCount + 1) + } - // 等待元素准备就绪并执行动画 - const waitForElementsAndAnimate = async (pieceId: number, retryCount = 0): Promise => { - if (!residence) { + // 超过最大重试次数,直接解锁并清理 + console.warn('Puzzle animation: 元素未准备好,直接解锁') + unlockPuzzle(residence, pieceId) cleanupAnimation() - return - } - - // 检查元素是否已准备好 - if (puzzleBoxRef.current && puzzlePieceRef.current) { - const success = executeFlyAnimation(pieceId) - if (success) return - } - - // 如果元素未准备好且未超过最大重试次数,则重试 - if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) { - await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY)) - return waitForElementsAndAnimate(pieceId, retryCount + 1) - } - - // 超过最大重试次数,直接解锁并清理 - console.warn('Puzzle animation: 元素未准备好,直接解锁') - unlockPuzzle(residence, pieceId) - cleanupAnimation() - } + }, + [ANIMATION_CONFIG.MAX_RETRIES, ANIMATION_CONFIG.RETRY_DELAY, cleanupAnimation, executeFlyAnimation, puzzleBoxRef, residence, unlockPuzzle] + ) // 处理碎片动画触发(仅支持拼图功能的故居) - const handleTriggerPuzzleAnimation = (pieceId: number) => { - if (!residence || !hasPuzzleFeature) return + const handleTriggerPuzzleAnimation = useCallback( + (pieceId: number, options?: { onComplete?: () => void }) => { + if (!residence || !hasPuzzleFeature) { + options?.onComplete?.() + return Promise.resolve() + } - // 防止重复执行动画 - if (animationInProgressRef.current) return + // 防止重复执行动画 + if (animationInProgressRef.current) { + options?.onComplete?.() + return Promise.resolve() + } - // 检查碎片是否已解锁 - if (isPuzzleUnlocked(residence, pieceId)) return + // 检查碎片是否已解锁 + if (isPuzzleUnlocked(residence, pieceId)) { + options?.onComplete?.() + return Promise.resolve() + } - // 标记动画正在进行 - animationInProgressRef.current = true + // 标记动画正在进行 + animationInProgressRef.current = true - // 开始动画流程:禁用所有操作 - setIsAnimationPlaying(true) - setPuzzlePieceId(pieceId) - setShowPuzzlePiece(true) + return new Promise((resolve) => { + animationCompletionRef.current = resolve + animationCompletionCallbackRef.current = options?.onComplete ?? null - // 使用双重 requestAnimationFrame 确保 Portal 渲染完成 - requestAnimationFrame(() => { - requestAnimationFrame(async () => { - // 等待初始出现动画(旋转+放大)完成 - await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION)) + // 开始动画流程:禁用所有操作 + setIsAnimationPlaying(true) + setPuzzlePieceId(pieceId) + setShowPuzzlePiece(true) - // 等待发光动画显示一段时间,让用户看到碎片 - await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION)) + // 使用双重 requestAnimationFrame 确保 Portal 渲染完成 + requestAnimationFrame(() => { + requestAnimationFrame(async () => { + // 等待初始出现动画(旋转+放大)完成 + await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION)) - // 等待元素准备就绪并执行飞行动画 - await waitForElementsAndAnimate(pieceId) + // 等待发光动画显示一段时间,让用户看到碎片 + await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION)) + + // 等待元素准备就绪并执行飞行动画 + await waitForElementsAndAnimate(pieceId) + }) + }) }) - }) - } + }, + [ANIMATION_CONFIG.APPEAR_DURATION, ANIMATION_CONFIG.GLOW_DURATION, hasPuzzleFeature, isPuzzleUnlocked, residence, waitForElementsAndAnimate] + ) // 获取当前路由的视频路径 const videoSrc = useMemo(() => { diff --git a/src/components/layout/alert/success-alert/index.tsx b/src/components/layout/alert/success-alert/index.tsx index 4a549c2..4525238 100644 --- a/src/components/layout/alert/success-alert/index.tsx +++ b/src/components/layout/alert/success-alert/index.tsx @@ -1,6 +1,6 @@ import { getStaticImage, getBackgroundImage } from '@/lib/utils' import type { MouseEvent } from 'react' -import { useEffect } from 'react' +import { useEffect, useRef, useState } from 'react' import { useAudioManager } from '@/hooks/useAudioManager' export interface SuccessAlertProps { @@ -42,6 +42,46 @@ const SuccessAlert = ({ const { currentSrc, play, stop } = useAudioManager( audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' } ) + const contentRef = useRef(null) + const [shouldIndent, setShouldIndent] = useState(false) + + useEffect(() => { + const element = contentRef.current + if (!element || !content) { + setShouldIndent(false) + return + } + + const lineHeight = Number.parseFloat(window.getComputedStyle(element).lineHeight) + if (!lineHeight) { + setShouldIndent(false) + return + } + + setShouldIndent(element.scrollHeight - lineHeight > 1) + }, [content, visible]) + + useEffect(() => { + if (!content) return + + const handleResize = () => { + const element = contentRef.current + if (!element) return + + const lineHeight = Number.parseFloat(window.getComputedStyle(element).lineHeight) + if (!lineHeight) { + setShouldIndent(false) + return + } + + setShouldIndent(element.scrollHeight - lineHeight > 1) + } + + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, [content]) + + const contentStyle = shouldIndent ? { textIndent: '2em' } : undefined // 弹窗显示时自动播放音频 useEffect(() => { @@ -105,7 +145,11 @@ const SuccessAlert = ({ {/* 内容文本 */} {content && ( -
+
{content}
)} diff --git a/src/components/layout/quiz/MatchQuizPanel.tsx b/src/components/layout/quiz/MatchQuizPanel.tsx index 96dd36e..c99fc9d 100644 --- a/src/components/layout/quiz/MatchQuizPanel.tsx +++ b/src/components/layout/quiz/MatchQuizPanel.tsx @@ -1,34 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from '@/lib/utils' -import type { MatchQuizPanelProps } from './types' +import { useScale } from '@/contexts/ScaleContext' +import type { MatchQuizItem, MatchQuizPanelProps } from './types' -/** - * 连线题面板组件 - * 支持移动端触摸操作 - * - * @example - * ```tsx - * { - * console.log("匹配结果:", matches, "是否正确:", isCorrect); - * }} - * /> - * ``` - */ const MatchQuizPanel = ({ visible, zIndex = 10000, @@ -41,23 +15,26 @@ const MatchQuizPanel = ({ onError, disabled = false, }: MatchQuizPanelProps) => { + const { scale } = useScale() const [matches, setMatches] = useState>([]) const [selectedLeftId, setSelectedLeftId] = useState(null) const [error, setError] = useState('') const containerRef = useRef(null) - const middleRef = useRef(null) - const leftColRef = useRef(null) - const rightColRef = useRef(null) + const quizAreaRef = useRef(null) const leftItemRefs = useRef>({}) const rightItemRefs = useRef>({}) - // 当前错误的连线对,用于做红色高亮+抖动反馈 + // ✓/✕ 圆点的 ref + const leftDotRefs = useRef>({}) + const rightDotRefs = useRef>({}) const [errorPairs, setErrorPairs] = useState>([]) - const [leftCenters, setLeftCenters] = useState>({}) - const [rightCenters, setRightCenters] = useState>({}) - const [middleHeight, setMiddleHeight] = useState(300) + // 连线端点坐标(相对于 quizArea) + const [lineAnchors, setLineAnchors] = useState< + Record + >({}) + const [svgSize, setSvgSize] = useState({ width: 0, height: 300 }) const rafIdRef = useRef(null) + const useRowAlignedDesktopLayout = leftItems.length === rightItems.length - // 当窗口关闭时,重置状态 useEffect(() => { if (!visible) { setMatches([]) @@ -67,11 +44,9 @@ const MatchQuizPanel = ({ } }, [visible]) - // 处理左列项点击 const handleLeftItemClick = (leftId: string) => { if (disabled) return - // 如果已经匹配过,先取消匹配 const existingMatch = matches.find((m) => m.leftId === leftId) if (existingMatch) { setMatches(matches.filter((m) => m.leftId !== leftId)) @@ -80,41 +55,35 @@ const MatchQuizPanel = ({ return } - // 如果已经选中了另一个左项,取消之前的选中 if (selectedLeftId && selectedLeftId !== leftId) { setSelectedLeftId(leftId) setError('') return } - // 切换选中状态 setSelectedLeftId(selectedLeftId === leftId ? null : leftId) setError('') } - // 处理右列项点击 const handleRightItemClick = (rightId: string) => { if (disabled) return - - // 如果没有选中左项,直接返回 if (!selectedLeftId) return - // 如果已经匹配过,先取消匹配 const existingMatch = matches.find((m) => m.rightId === rightId) if (existingMatch) { - setMatches(matches.filter((m) => m.rightId !== rightId)) - setSelectedLeftId(null) + if (existingMatch.leftId === selectedLeftId) { + setMatches(matches.filter((m) => m.rightId !== rightId)) + setSelectedLeftId(null) + } setError('') return } - // 创建新的匹配 const newMatch = { leftId: selectedLeftId, rightId } const newMatches = [...matches, newMatch] - // 每次连线后立即校验当前所有连线是否存在错误 const wrongMatches = - correctMatches?.filter(() => true) && // 保证 correctMatches 存在时才校验 + correctMatches?.filter(() => true) && newMatches.filter( (match) => !correctMatches?.some((cm) => cm.leftId === match.leftId && cm.rightId === match.rightId) @@ -123,7 +92,6 @@ const MatchQuizPanel = ({ const hasError = !!wrongMatches && wrongMatches.length > 0 if (hasError && wrongMatches) { - // 记录错误连线对,用于红色高亮+抖动反馈 setErrorPairs(wrongMatches) setMatches(newMatches) setSelectedLeftId(null) @@ -137,9 +105,9 @@ const MatchQuizPanel = ({ } else { errorMsg = '连线不正确,请重新尝试!' } + setError(errorMsg) onError?.(errorMsg) - // 短暂展示错误状态(变红+抖动),随后只清空错误连线,保留已答对的连线 setTimeout(() => { setMatches((prev) => prev.filter( @@ -151,93 +119,124 @@ const MatchQuizPanel = ({ return } - // 没有错误时,正常记录匹配 setMatches(newMatches) setSelectedLeftId(null) setError('') - // 如果没有错误且所有项都已连完,则认为答题成功 if (newMatches.length === leftItems.length && newMatches.length === rightItems.length) { onSubmit?.(newMatches, true) } } - // 获取左项是否已匹配 - const isLeftItemMatched = (leftId: string) => { - return matches.some((m) => m.leftId === leftId) - } + const isLeftItemMatched = (leftId: string) => matches.some((m) => m.leftId === leftId) + const isRightItemMatched = (rightId: string) => matches.some((m) => m.rightId === rightId) - // 获取右项是否已匹配 - const isRightItemMatched = (rightId: string) => { - return matches.some((m) => m.rightId === rightId) - } + const getAnchorForMatch = useCallback( + (match: { leftId: string; rightId: string }) => { + const quizAreaEl = quizAreaRef.current + if (!quizAreaEl) return null - const measureCenters = useCallback(() => { - const middleEl = middleRef.current - if (!middleEl) return + const leftCard = leftItemRefs.current[match.leftId] + const rightCard = rightItemRefs.current[match.rightId] + const leftDot = leftDotRefs.current[match.leftId] + const rightDot = rightDotRefs.current[match.rightId] + if (!leftCard || !rightCard || !leftDot || !rightDot) return null - const middleRect = middleEl.getBoundingClientRect() - const nextLeft: Record = {} - const nextRight: Record = {} + const areaRect = quizAreaEl.getBoundingClientRect() + const leftAnchorRect = leftDot.getBoundingClientRect() + const rightAnchorRect = rightDot.getBoundingClientRect() - // 显式同步中间连线层高度,避免因 flex/stretch/内容换行导致坐标系基准漂移 - const leftRect = leftColRef.current?.getBoundingClientRect() - const rightRect = rightColRef.current?.getBoundingClientRect() - const nextMiddleHeight = Math.max(leftRect?.height ?? 0, rightRect?.height ?? 0, 300) - setMiddleHeight(nextMiddleHeight) + return { + startX: (leftAnchorRect.left + leftAnchorRect.width / 2 - areaRect.left) / scale, + startY: (leftAnchorRect.top + leftAnchorRect.height / 2 - areaRect.top) / scale, + endX: (rightAnchorRect.left + rightAnchorRect.width / 2 - areaRect.left) / scale, + endY: (rightAnchorRect.top + rightAnchorRect.height / 2 - areaRect.top) / scale, + } + }, + [scale] + ) - for (const item of leftItems) { - const el = leftItemRefs.current[item.id] - if (!el) continue - const rect = el.getBoundingClientRect() - nextLeft[item.id] = rect.top + rect.height / 2 - middleRect.top - } + const measureAnchors = useCallback(() => { + const quizAreaEl = quizAreaRef.current + if (!quizAreaEl) return - for (const item of rightItems) { - const el = rightItemRefs.current[item.id] - if (!el) continue - const rect = el.getBoundingClientRect() - nextRight[item.id] = rect.top + rect.height / 2 - middleRect.top - } - - setLeftCenters(nextLeft) - setRightCenters(nextRight) - }, [leftItems, rightItems]) - - const scheduleMeasure = useCallback(() => { - if (rafIdRef.current !== null) { - cancelAnimationFrame(rafIdRef.current) - rafIdRef.current = null - } - // 双 RAF:等待浏览器完成一帧布局/绘制(含 transform/transition/pulse 的本帧生效) - rafIdRef.current = requestAnimationFrame(() => { - rafIdRef.current = requestAnimationFrame(() => { - rafIdRef.current = null - measureCenters() - }) + const areaRect = quizAreaEl.getBoundingClientRect() + setSvgSize({ + width: areaRect.width / scale, + height: areaRect.height / scale, }) - }, [measureCenters]) - // 首次展示、匹配变化、选中/错误状态变化都会影响位置(含动画/缩放),延后一帧再测 + const next: Record = {} + + for (const match of matches) { + const anchor = getAnchorForMatch(match) + if (!anchor) continue + next[`${match.leftId}-${match.rightId}`] = anchor + } + + setLineAnchors(next) + }, [getAnchorForMatch, matches, scale]) + + const scheduleMeasure = useCallback( + (mode: 'all' | 'new-only' = 'all') => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = null + } + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = null + + if (mode === 'all') { + measureAnchors() + return + } + + const quizAreaEl = quizAreaRef.current + if (!quizAreaEl) return + + const areaRect = quizAreaEl.getBoundingClientRect() + setSvgSize({ + width: areaRect.width / scale, + height: areaRect.height / scale, + }) + + setLineAnchors((prev) => { + const next: Record< + string, + { startX: number; startY: number; endX: number; endY: number } + > = {} + + for (const match of matches) { + const key = `${match.leftId}-${match.rightId}` + next[key] = prev[key] ?? + getAnchorForMatch(match) ?? { startX: 0, startY: 0, endX: 0, endY: 0 } + } + + return next + }) + }) + }) + }, + [getAnchorForMatch, matches, measureAnchors, scale] + ) + useEffect(() => { if (!visible) return - scheduleMeasure() - }, [visible, matches.length, errorPairs.length, selectedLeftId, scheduleMeasure]) + scheduleMeasure('new-only') + }, [visible, matches, scheduleMeasure]) - // 监听尺寸变化(窗口 resize / 容器尺寸变化 / 文本换行) useEffect(() => { if (!visible) return - const middleEl = middleRef.current - if (!middleEl) return + const quizAreaEl = quizAreaRef.current + if (!quizAreaEl) return const handleResize = () => scheduleMeasure() window.addEventListener('resize', handleResize) const ro = new ResizeObserver(() => scheduleMeasure()) - ro.observe(middleEl) + ro.observe(quizAreaEl) if (containerRef.current) ro.observe(containerRef.current) - if (leftColRef.current) ro.observe(leftColRef.current) - if (rightColRef.current) ro.observe(rightColRef.current) return () => { window.removeEventListener('resize', handleResize) @@ -246,6 +245,125 @@ const MatchQuizPanel = ({ } }, [visible, scheduleMeasure]) + const renderLeftItem = (item: MatchQuizItem) => { + const isMatched = isLeftItemMatched(item.id) + const isSelected = selectedLeftId === item.id + const isError = errorPairs.some((p) => p.leftId === item.id) + + return ( +
{ + 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', + 'touch-manipulation', + disabled || isMatched ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer', + isError + ? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse' + : isSelected + ? 'bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105' + : isMatched + ? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]' + : 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]' + )} + onClick={() => handleLeftItemClick(item.id)} + onTouchStart={(e) => { + e.currentTarget.classList.add('scale-105') + }} + onTouchEnd={(e) => { + e.currentTarget.classList.remove('scale-105') + }} + > + {item.label} + {isError && ( +
{ + 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)]" + > + ✕ +
+ )} + {!isError && isMatched && ( +
{ + 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" + > + ✓ +
+ )} +
+ ) + } + + const renderRightItem = (item: MatchQuizItem) => { + const isMatched = isRightItemMatched(item.id) + const isError = errorPairs.some((p) => p.rightId === item.id) + + return ( +
{ + 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', + 'touch-manipulation', + disabled || isMatched + ? 'opacity-60 cursor-not-allowed' + : selectedLeftId + ? 'cursor-pointer' + : 'cursor-default', + isError + ? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse' + : isMatched + ? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]' + : selectedLeftId + ? 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]' + : 'bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60' + )} + onClick={() => handleRightItemClick(item.id)} + onTouchStart={(e) => { + if (selectedLeftId) { + e.currentTarget.classList.add('scale-105') + } + }} + onTouchEnd={(e) => { + e.currentTarget.classList.remove('scale-105') + }} + > + {item.label} + {isError && ( +
{ + 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)]" + > + ✕ +
+ )} + {!isError && isMatched && ( +
{ + 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" + > + ✓ +
+ )} +
+ ) + } + if (!visible) return null return ( @@ -255,13 +373,9 @@ const MatchQuizPanel = ({ ref={containerRef} >
- {/* 题干卡片 */}
-
- {title} -
+
{title}
- {/* 答案反馈 */} {error && (
{error} @@ -269,163 +383,78 @@ const MatchQuizPanel = ({ )}
- {/* 连线区域 */} -
- {/* 左列:日期 */} -
- {leftItems.map((item) => { - const isMatched = isLeftItemMatched(item.id) - const isSelected = selectedLeftId === item.id - const isError = errorPairs.some((p) => p.leftId === item.id) - - return ( -
{ - 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', - 'touch-manipulation', // 优化移动端触摸 - disabled || isMatched ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer', - isError - ? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse' - : isSelected - ? 'bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105' - : isMatched - ? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]' - : 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]' - )} - onClick={() => handleLeftItemClick(item.id)} - onTouchStart={(e) => { - // 移动端触摸优化 - e.currentTarget.classList.add('scale-105') - }} - onTouchEnd={(e) => { - e.currentTarget.classList.remove('scale-105') - }} - > - {item.label} - {isError && ( -
- ✕ -
- )} - {!isError && isMatched && ( -
- ✓ -
- )} -
- ) - })} -
- - {/* 中间连线区域(移动端隐藏,桌面端显示) */} -
- {/* 绘制连线 */} - - {matches.map((match, index) => { - const leftItem = leftItems.find((item) => item.id === match.leftId) - const rightItem = rightItems.find((item) => item.id === match.rightId) - if (!leftItem || !rightItem) return null - - const startY = leftCenters[match.leftId] - const endY = rightCenters[match.rightId] - if (startY === undefined || endY === undefined) return null - - return ( - - ) - })} - {/* 箭头标记定义 */} - - - - - - -
- - {/* 右列:事件 */} -
- {rightItems.map((item) => { - const isMatched = isRightItemMatched(item.id) - const isError = errorPairs.some((p) => p.rightId === item.id) - - return ( -
{ - 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', - 'touch-manipulation', // 优化移动端触摸 - disabled || isMatched - ? 'opacity-60 cursor-not-allowed' - : selectedLeftId - ? 'cursor-pointer' - : 'cursor-default', - isError - ? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse' - : isMatched - ? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]' - : selectedLeftId - ? 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]' - : 'bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60' - )} - onClick={() => handleRightItemClick(item.id)} - onTouchStart={(e) => { - if (selectedLeftId) { - e.currentTarget.classList.add('scale-105') - } - }} - onTouchEnd={(e) => { - e.currentTarget.classList.remove('scale-105') - }} - > - {item.label} - {isError && ( -
- ✕ -
- )} - {!isError && isMatched && ( -
- ✓ -
- )} -
- ) - })} -
+ {/* 移动端 */} +
+
{leftItems.map(renderLeftItem)}
+
{rightItems.map(renderRightItem)}
+
+ + {/* 桌面端 */} +
+ {/* SVG 覆盖整个题目区域,用绝对像素坐标画线 */} + + {matches.map((match, index) => { + const key = `${match.leftId}-${match.rightId}` + const anchor = lineAnchors[key] + if (!anchor) return null + + return ( + + ) + })} + + + + + + + + {useRowAlignedDesktopLayout ? ( +
+ {leftItems.map((leftItem, index) => { + const rightItem = rightItems[index] + if (!rightItem) return null + + return ( +
+
{renderLeftItem(leftItem)}
+
+
{renderRightItem(rightItem)}
+
+ ) + })} +
+ ) : ( +
+
{leftItems.map(renderLeftItem)}
+
+
{rightItems.map(renderRightItem)}
+
+ )}
- {/* 移动端连线提示 */}
请先点击左侧日期,再点击右侧对应的事件进行连线
diff --git a/src/components/letter/LetterTemplate.tsx b/src/components/letter/LetterTemplate.tsx index ebbbfad..52c7fd9 100644 --- a/src/components/letter/LetterTemplate.tsx +++ b/src/components/letter/LetterTemplate.tsx @@ -5,6 +5,14 @@ import { filterInput } from '@/lib/utils/sensitiveWords' import { Image, Select, Video, type VideoRef } from '@/components' import { showHint } from '@/components/hint' +const formatCurrentDate = () => { + const now = new Date() + const year = now.getFullYear() + const month = `${now.getMonth() + 1}`.padStart(2, '0') + const day = `${now.getDate()}`.padStart(2, '0') + return `${year}.${month}.${day}` +} + export type LetterFieldType = 'text' | 'input' | 'select' export interface LetterFieldOption { @@ -47,6 +55,7 @@ export interface LetterTemplateProps { audioSrc?: string showVideo?: boolean videoSrc?: string + replyVideoSrc?: string sceneImageSrc?: string replyButtonImageSrc?: string replyImageSrc?: string @@ -74,19 +83,26 @@ const LetterTemplate = ({ audioSrc, showVideo = true, videoSrc = 'video/letter/neihua_jixin.mp4', + replyVideoSrc, sceneImageSrc = 'components/letter/动画结束后场景', replyButtonImageSrc = 'components/letter/点击敬读', replyImageSrc, }: LetterTemplateProps) => { const [blankValues, setBlankValues] = useState([]) const [blankErrors, setBlankErrors] = useState([]) + const [signatureName, setSignatureName] = useState('') + const [signatureError, setSignatureError] = useState(false) const [submitted, setSubmitted] = useState(false) const [showSendVideo, setShowSendVideo] = useState(false) + const [showReplyVideo, setShowReplyVideo] = useState(false) const [showScene, setShowScene] = useState(false) const [showReplyImage, setShowReplyImage] = useState(false) const sendVideoRef = useRef(null) + const replyVideoRef = useRef(null) const audioRef = useRef(null) + const currentDate = useMemo(() => formatCurrentDate(), []) + const resolvedFields = useMemo( () => (typeof fields === 'function' ? fields(blankValues) : fields), [fields, blankValues] @@ -99,6 +115,13 @@ const LetterTemplate = ({ (_, index) => blankValues[index] ?? '' ) + const trimmedSignatureName = signatureName.trim() + if (!trimmedSignatureName) { + showHint('请填写落款姓名后再提交哦!') + setSignatureError(true) + return false + } + const nextErrors = validate ? validate(nextValues, letterContent) : nextValues.map((value, index) => { @@ -146,7 +169,10 @@ const LetterTemplate = ({ setSubmitted(false) setBlankValues([]) setBlankErrors([]) + setSignatureName('') + setSignatureError(false) setShowSendVideo(false) + setShowReplyVideo(false) setShowScene(false) setShowReplyImage(false) } @@ -157,14 +183,14 @@ const LetterTemplate = ({ const audio = audioRef.current if (!audio || !audioSrc) return - if (visible && !showSendVideo) { + if (visible && !showSendVideo && !showScene && !showReplyImage && !showReplyVideo) { audio.currentTime = 0 audio.play().catch(() => {}) } else { audio.pause() audio.currentTime = 0 } - }, [visible, showSendVideo, audioSrc]) + }, [visible, showSendVideo, showScene, showReplyImage, showReplyVideo, audioSrc]) useEffect(() => { const audio = audioRef.current @@ -252,6 +278,48 @@ const LetterTemplate = ({ } } + const handleReplyVideoCanPlay = () => { + if (showReplyVideo && replyVideoRef.current) { + replyVideoRef.current.play().catch(() => {}) + } + } + + const handleSignatureNameChange = (value: string) => { + const nextValue = filterInput(value) + setSignatureName(nextValue) + + const hasError = !nextValue.trim() + if (signatureError !== hasError) { + setSignatureError(hasError) + } + } + + const renderSignature = (readonly: boolean) => { + if (readonly) { + return ( +
+ {signatureName.trim() || '  '} + {currentDate} +
+ ) + } + + return ( +
+ handleSignatureNameChange(e.target.value)} + /> + {currentDate} +
+ ) + } + const renderLine = (readonly: boolean) => { let placeholderIndex = 0 let bodyStarted = false @@ -354,7 +422,7 @@ const LetterTemplate = ({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }} - onClick={onClose} + // onClick={onClose} /> )} @@ -416,6 +484,8 @@ const LetterTemplate = ({
+ {renderSignature(false)} +
-
+
可提交状态
-
+ {renderSignature(true)} + +
寄出 { setShowReplyImage(false) setShowScene(false) + if (replyVideoSrc) { + setShowReplyVideo(true) + return + } onClose?.() onAnimationEnd?.() }} /> )} + + + {showReplyVideo && replyVideoSrc && ( + + + )} + )} diff --git a/src/components/letter/LetterToChenyun.tsx b/src/components/letter/LetterToChenyun.tsx index e1a2ffd..63f20ba 100644 --- a/src/components/letter/LetterToChenyun.tsx +++ b/src/components/letter/LetterToChenyun.tsx @@ -9,6 +9,7 @@ export interface LetterToChenyunProps { showVideo?: boolean videoSrc?: string replyImageSrc?: string + replyVideoSrc?: string } const letterContent = `敬爱的陈云爷爷: @@ -31,7 +32,11 @@ const fields: Record = { type: 'input', maxLength: 12, widthClassName: 'w-[120px]', - readOnlyFallback: '勤学钻研', + correctValue: '实', + readOnlyFallback: '实', + hints: { + error: '“不唯上、不唯书、只唯实”是陈云提出的著名方法论原则。', + }, }, 2: { type: 'input', @@ -62,6 +67,7 @@ const LetterToChenyun = ({ showVideo = true, videoSrc = 'video/letter/neihua_jixin.mp4', replyImageSrc = 'components/letter/点击信详情-陈云', + replyVideoSrc = 'video/letter/chenyun_gaizhang.mp4', }: LetterToChenyunProps) => { return ( ) diff --git a/src/components/letter/LetterToHuangyanpei.tsx b/src/components/letter/LetterToHuangyanpei.tsx index 9018db7..8ba58e9 100644 --- a/src/components/letter/LetterToHuangyanpei.tsx +++ b/src/components/letter/LetterToHuangyanpei.tsx @@ -9,6 +9,7 @@ export interface LetterToHuangyanpeiProps { showVideo?: boolean videoSrc?: string replyImageSrc?: string + replyVideoSrc?: string } const commonSelectField: LetterFieldConfig = { @@ -73,6 +74,7 @@ const LetterToHuangyanpei = ({ showVideo = true, videoSrc = 'video/letter/neihua_jixin.mp4', replyImageSrc = 'components/letter/点击信详情-黄炎培', + replyVideoSrc = 'video/letter/huangyanpei_gaizhang.mp4', }: LetterToHuangyanpeiProps) => { return ( ) diff --git a/src/components/letter/LetterToLuxun.tsx b/src/components/letter/LetterToLuxun.tsx index 7ed421e..2622df0 100644 --- a/src/components/letter/LetterToLuxun.tsx +++ b/src/components/letter/LetterToLuxun.tsx @@ -9,6 +9,7 @@ export interface LetterToLuxunProps { showVideo?: boolean videoSrc?: string replyImageSrc?: string + replyVideoSrc?: string } const commonSelectField: LetterFieldConfig = { @@ -17,7 +18,7 @@ const commonSelectField: LetterFieldConfig = { fitContent: false, className: '!w-[120px] !min-w-[120px] shrink-0 px-0', options: [ - { value: '旨在立人', label: '旨在立人' }, + { value: '首在立人', label: '首在立人' }, { value: '批判求真', label: '批判求真' }, ], } @@ -28,18 +29,18 @@ const letterContent = `敬爱的鲁迅先生: 今天,我怀着崇敬的心情走进大陆新村 9 号,看着您亲手撰写的《呐喊》《且介亭杂文》《故事新编》等作品,您编辑的《萌芽》《前哨》等杂志,您翻译的《艺术论》《死魂灵》等译文,仿佛能看到您“吾将上下而求索”的身影。在这场跨越时空的对话中,我终于明白您坚守的“____”,不是抽象的理念,而是我们安身立命的根本;您秉持的“____”,不是无谓的锋芒,而是我们直面现实的勇气。 -我是____专业的学生,今后,我会以您“____于夫指”的骨气,在是非面前坚守立场,不随波逐流;我会以您“俯首甘为____”的热忱,参与____活动,用微光温暖他人;我更会记住您“____,走的人多了,也便成了路”的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。 +我是____专业的学生,今后,我会以您“____千夫指”的骨气,在是非面前坚守立场,不随波逐流;我会以您“俯首甘为____”的热忱,参与____活动,用微光温暖他人;我更会记住您“____,走的人多了,也便成了路”的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。 请您放心,您播下的“立人”火种,我们会让它在新时代燎原;您毕生追求的“人国”愿景,我们会用青春和奋斗去守护!` const fields: Record = { 0: { ...commonSelectField, - correctValue: '旨在立人', + correctValue: '首在立人', hints: { error: '请你结合上下文和所学在想想吧!', }, - readOnlyFallback: '旨在立人', + readOnlyFallback: '首在立人', }, 1: { ...commonSelectField, @@ -85,8 +86,8 @@ const fields: Record = { type: 'input', maxLength: 16, widthClassName: 'w-[120px]', - correctValue: '世上本没有路', - readOnlyFallback: '世上本没有路', + correctValue: '地上本没有路', + readOnlyFallback: '地上本没有路', hints: { error: '鲁迅在小说《故乡》中写道:“地上本没有路,走的人多了,也便成了路。”', }, @@ -108,6 +109,7 @@ const LetterToLuxun = ({ showVideo = true, videoSrc = 'video/letter/neihua_jixin.mp4', replyImageSrc = 'components/letter/点击信详情-鲁迅', + replyVideoSrc = 'video/letter/luxun_gaizhang.mp4', }: LetterToLuxunProps) => { return ( ) diff --git a/src/components/letter/LetterToMaozedong.tsx b/src/components/letter/LetterToMaozedong.tsx index 4b27c4e..90a3356 100644 --- a/src/components/letter/LetterToMaozedong.tsx +++ b/src/components/letter/LetterToMaozedong.tsx @@ -9,6 +9,7 @@ export interface LetterToMaozedongProps { showVideo?: boolean videoSrc?: string replyImageSrc?: string + replyVideoSrc?: string } const commonSelectField: LetterFieldConfig = { @@ -44,7 +45,7 @@ const fields: Record = { ...commonSelectField, correctValue: '实事求是', hints: { - error: '再想想哦,这里强调的是毛泽东思想活的灵魂之一“实事求是”。', + error: '请你结合上下文和所学在想想吧!', }, readOnlyFallback: '实事求是', }, @@ -52,7 +53,7 @@ const fields: Record = { ...commonSelectField, correctValue: '群众路线', hints: { - error: '再想想哦,这里对应的是毛泽东思想活的灵魂之一“群众路线”。', + error: '请你结合上下文和所学在想想吧!', }, readOnlyFallback: '群众路线', }, @@ -60,7 +61,7 @@ const fields: Record = { ...commonSelectField, correctValue: '独立自主', hints: { - error: '再想想哦,这里对应的是毛泽东思想活的灵魂之一“独立自主”。', + error: '请你结合上下文和所学在想想吧!', }, readOnlyFallback: '独立自主', }, @@ -75,7 +76,7 @@ const fields: Record = { ], correctValue: '实事求是', hints: { - error: '再想想哦,这里强调的是在专业学习中脚踏实地、掌握扎实技能。', + error: '请你结合上下文和所学在想想吧!', }, readOnlyFallback: '实事求是', }, @@ -90,7 +91,7 @@ const fields: Record = { ], correctValue: '群众路线', hints: { - error: '再想想哦,这里对应的是服务社会、贡献他人的“群众路线”精神。', + error: '请你结合上下文和所学在想想吧!', }, readOnlyFallback: '群众路线', }, @@ -128,20 +129,24 @@ const LetterToMaozedong = ({ audioSrc = 'audio/letter/1东方红.m4a', showVideo = true, videoSrc = 'video/letter/neihua_jixin.mp4', + replyVideoSrc = 'video/letter/mzd_gaizhang.mp4', replyImageSrc = 'components/letter/点击信详情-毛泽东', }: LetterToMaozedongProps) => { + // const stampedVideoSrc = videoSrc.replace('neihua_jixin.mp4', 'maozedong_gaizhang.mp4') + return ( ) diff --git a/src/components/letter/LetterToNieer.tsx b/src/components/letter/LetterToNieer.tsx index e89f4bb..daa6071 100644 --- a/src/components/letter/LetterToNieer.tsx +++ b/src/components/letter/LetterToNieer.tsx @@ -9,6 +9,7 @@ export interface LetterToNieerProps { showVideo?: boolean videoSrc?: string replyImageSrc?: string + replyVideoSrc?: string } const commonSelectField: LetterFieldConfig = { @@ -87,6 +88,7 @@ const LetterToNieer = ({ showVideo = true, videoSrc = 'video/letter/neihua_jixin.mp4', replyImageSrc = 'components/letter/点击信详情-聂耳', + replyVideoSrc = 'video/letter/nieer_gaizhang.mp4', }: LetterToNieerProps) => { return ( ) diff --git a/src/components/letter/LetterToRongdesheng.tsx b/src/components/letter/LetterToRongdesheng.tsx index 0920c5a..1887f38 100644 --- a/src/components/letter/LetterToRongdesheng.tsx +++ b/src/components/letter/LetterToRongdesheng.tsx @@ -9,6 +9,7 @@ export interface LetterToRongdeshengProps { showVideo?: boolean videoSrc?: string replyImageSrc?: string + replyVideoSrc?: string } const commonSelectField: LetterFieldConfig = { @@ -79,6 +80,7 @@ const LetterToRongdesheng = ({ showVideo = true, videoSrc = 'video/letter/neihua_jixin.mp4', replyImageSrc = 'components/letter/点击信详情-荣德生', + replyVideoSrc = 'video/letter/rongdesheng_gaizhang.mp4', }: LetterToRongdeshengProps) => { return ( ) diff --git a/src/components/letter/LetterToSunzhongshan.tsx b/src/components/letter/LetterToSunzhongshan.tsx index 57ee0a2..77ad6d6 100644 --- a/src/components/letter/LetterToSunzhongshan.tsx +++ b/src/components/letter/LetterToSunzhongshan.tsx @@ -9,6 +9,7 @@ export interface LetterToSunzhongshanProps { showVideo?: boolean videoSrc?: string replyImageSrc?: string + replyVideoSrc?: string } const commonSelectField: LetterFieldConfig = { @@ -92,6 +93,7 @@ const LetterToSunzhongshan = ({ showVideo = true, videoSrc = 'video/letter/neihua_jixin.mp4', replyImageSrc = 'components/letter/点击信详情-孙中山', + replyVideoSrc = 'video/letter/sunzhongshan_gaizhang.mp4', }: LetterToSunzhongshanProps) => { return ( ) diff --git a/src/components/letter/LetterToZhangleping.tsx b/src/components/letter/LetterToZhangleping.tsx index 8afb71d..e324a07 100644 --- a/src/components/letter/LetterToZhangleping.tsx +++ b/src/components/letter/LetterToZhangleping.tsx @@ -9,6 +9,7 @@ export interface LetterToZhanglepingProps { showVideo?: boolean videoSrc?: string replyImageSrc?: string + replyVideoSrc?: string } const letterContent = `敬爱的张乐平先生: @@ -102,6 +103,7 @@ const LetterToZhangleping = ({ showVideo = true, videoSrc = 'video/letter/neihua_jixin.mp4', replyImageSrc = 'components/letter/点击信详情-张乐平', + replyVideoSrc = 'video/letter/zhangleping_gaizhang.mp4', }: LetterToZhanglepingProps) => { return ( ) diff --git a/src/components/narration/Narration.tsx b/src/components/narration/Narration.tsx index eb7cb7f..9620cc3 100644 --- a/src/components/narration/Narration.tsx +++ b/src/components/narration/Narration.tsx @@ -129,7 +129,7 @@ const Narration = ({
- 已知晓 + 继续
)} diff --git a/src/components/overlay/EndingOverlay.tsx b/src/components/overlay/EndingOverlay.tsx index 836dcd1..aaa67bc 100644 --- a/src/components/overlay/EndingOverlay.tsx +++ b/src/components/overlay/EndingOverlay.tsx @@ -114,7 +114,7 @@ export const EndingOverlay = ({ if (!visible) return null return ( -
+
{/* 文本内容层 */}
-
+
{displayedText}
diff --git a/src/components/puzzle/MenuPuzzleCenter.tsx b/src/components/puzzle/MenuPuzzleCenter.tsx index f367b2e..cd9a4bf 100644 --- a/src/components/puzzle/MenuPuzzleCenter.tsx +++ b/src/components/puzzle/MenuPuzzleCenter.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { usePuzzleStore } from '@/store/puzzleStore' import { Image } from '@/components' +import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences' /** * 六边形进度条组件 @@ -104,119 +105,22 @@ export interface MenuPuzzleCenterProps { onClose: () => void /** 选择某个故居后的回调(进入对应的碎片系统) */ onSelectResidence: (residence: string) => void + /** 是否允许从管理中心进入具体拼图 */ + allowPuzzleEntry?: boolean } -interface ResidenceConfig { - /** 拼图存储用的英文 key(如 chenyun) */ - residence: string - /** 显示名称 */ - displayName: string - /** 在大地图上的水平位置(百分比字符串,如 "20%") */ - left: string - /** 在大地图上的垂直位置(百分比字符串,如 "40%") */ - top: string - /** 显示用图片路径(相对于 puzzle/home/ 目录) */ - image: string - /** 是否已经开放拼图(未开放时只展示,不可点击) */ - hasPuzzle: boolean - /** 是否有导图资源 */ - hasMap?: boolean - /** 导图资源文件名(位于 puzzle/home/map 下) */ - mapImage?: string -} - -/** - * 顶层碎片管理中心中展示的故居列表(8 个故居) - * left / top 为大致位置,后续可以根据视觉效果微调 - * image 资源都位于 puzzle/home 目录下,命名可按实际资源调整 - */ -const RESIDENCE_LIST: ResidenceConfig[] = [ - { - residence: 'maozedong', - displayName: '毛泽东旧居', - left: '30%', - top: '35%', - image: '毛泽东', - hasPuzzle: true, - hasMap: true, - mapImage: '毛泽东导图', - }, - { - residence: 'sunzhongshan', - displayName: '孙中山故居', - left: '12%', - top: '51%', - image: '孙中山', - hasPuzzle: true, - hasMap: true, - mapImage: '孙中山导图', - }, - { - residence: 'luxun', - displayName: '鲁迅故居', - left: '26.3%', - top: '74%', - image: '鲁迅', - hasPuzzle: true, - hasMap: true, - mapImage: '鲁迅导图', - }, - { - residence: 'huangyanpei', - displayName: '黄炎培故居', - left: '44%', - top: '67%', - image: '黄炎培', - hasPuzzle: true, - hasMap: true, - mapImage: '黄炎培导图', - }, - { - residence: 'nieer', - displayName: '聂耳旧居', - left: '62%', - top: '60%', - image: '聂耳', - hasPuzzle: true, - hasMap: false, - }, - { - residence: 'zhangleping', - displayName: '张乐平故居', - left: '73%', - top: '33%', - image: '张乐平', - hasPuzzle: true, - hasMap: false, - }, - { - residence: 'rongdesheng', - displayName: '荣德生旧居', - left: '90%', - top: '45%', - image: '荣德生', - hasPuzzle: true, - hasMap: false, - }, - { - residence: 'chenyun', - displayName: '陈云故居', - left: '80%', - top: '76%', - image: '陈云', - hasPuzzle: true, - hasMap: true, - mapImage: '陈云导图', - }, -] - -const MenuPuzzleCenter = ({ visible, onClose, onSelectResidence }: MenuPuzzleCenterProps) => { +const MenuPuzzleCenter = ({ + visible, + onClose, + onSelectResidence, + allowPuzzleEntry = true, +}: MenuPuzzleCenterProps) => { const { getPuzzleCollectProgress } = usePuzzleStore() const [activeMapImage, setActiveMapImage] = useState(null) const residenceProgress = useMemo( () => - RESIDENCE_LIST.map((item) => ({ + PUZZLE_RESIDENCES.map((item) => ({ ...item, // 只有已开放拼图的故居才计算收集进度 progress: item.hasPuzzle ? getPuzzleCollectProgress(item.residence) : 0, @@ -287,16 +191,18 @@ const MenuPuzzleCenter = ({ visible, onClose, onSelectResidence }: MenuPuzzleCen {/* 图标 */}
- 拼图 { - if (!hasPuzzle) return - e.stopPropagation() - onSelectResidence(residence) - }} - /> + {allowPuzzleEntry && ( + 拼图 { + if (!hasPuzzle) return + e.stopPropagation() + onSelectResidence(residence) + }} + /> + )} 导图 { luxun: '鲁迅', sunzhongshan: '孙中山', maozedong: '毛泽东', + zhangleping: '张乐平', + nieer: '聂耳', + rongdesheng: '荣德生', } return nameMap[residence] || residence @@ -28,6 +32,10 @@ export interface PuzzleSystemProps { onClose: () => void /** 故居名称,如 'chenyun' */ residence: string + /** 是否显示菜单入口的故居导航 */ + showResidenceNav?: boolean + /** 切换故居 */ + onResidenceChange?: (residence: string) => void } /** @@ -143,7 +151,13 @@ const PuzzlePieceSvg = ({ * - 已解锁显示正常图片,未解锁显示遮罩 * - 点击碎片可跳转到对应页面 */ -const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => { +const PuzzleSystem = ({ + visible, + onClose, + residence, + showResidenceNav = false, + onResidenceChange, +}: PuzzleSystemProps) => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() const { isPuzzleUnlocked, getPuzzleCollectProgress } = usePuzzleStore() @@ -154,9 +168,15 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => { useEffect(() => { if (!visible) { setSelectedPiece(null) + setHoveredPiece(null) } }, [visible]) + useEffect(() => { + setSelectedPiece(null) + setHoveredPiece(null) + }, [residence]) + if (!visible) return null // 背景图片 @@ -177,13 +197,13 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => { // 处理前往按钮点击 const handleGoToPage = () => { - if (moduleName && selectedPiece) { + if (activeModuleName && selectedPiece) { // console.log(residence, selectedPiece, moduleName); // const route = getPuzzleRoute(residence, selectedPiece, moduleName); // console.log(route); const route = selectedPieceData?.route if (route) { - navigate(`/${moduleName}/${residence}/${route}`) + navigate(`/${activeModuleName}/${residence}/${route}`) onClose() } } @@ -200,6 +220,12 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => { // 获取收集进度 const collectProgress = getPuzzleCollectProgress(residence) + const activeResidenceConfig = PUZZLE_RESIDENCES.find((item) => item.residence === residence) + const activeModuleName = moduleName || activeResidenceConfig?.moduleName + const residenceNavItems = PUZZLE_RESIDENCES.filter((item) => item.hasPuzzle).map((item) => ({ + ...item, + progress: getPuzzleCollectProgress(item.residence), + })) return (
{ backgroundImage: `url(${backgroundImage})`, }} > + {showResidenceNav && ( +
+ {residenceNavItems.map((item) => { + const isActive = item.residence === residence + const progress = Math.round(item.progress) + + return ( + + ) + })} +
+ )} + {/* 关闭按钮 */} {
{/* 完整拼图底图 - 作为背景显示所有碎片拼好的样子 */} -
+
{/* SVG拼图容器 - 使用3D变换和缩放 */} diff --git a/src/components/quiz/drag-fill/drag-fill.stories.tsx b/src/components/quiz/drag-fill/drag-fill.stories.tsx index e1abd32..71e53ed 100644 --- a/src/components/quiz/drag-fill/drag-fill.stories.tsx +++ b/src/components/quiz/drag-fill/drag-fill.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react' import { useState } from 'react' import DragFill from './drag-fill' -import type { DragFillProps } from './drag-fill.types' import { cn } from '@/lib/utils' const meta: Meta = { @@ -47,6 +46,25 @@ const meta: Meta = { export default meta type Story = StoryObj +function ControlledDragFillStory() { + const [value, setValue] = useState([]) + + return ( +
+ { + alert(`提交的结果: ${JSON.stringify(results)}`) + }} + /> +
当前填充: {JSON.stringify(value)}
+
+ ) +} + // 基础示例 export const Default: Story = { args: { @@ -80,23 +98,7 @@ export const WithObjects: Story = { // 受控模式 export const Controlled: Story = { - render: () => { - const [value, setValue] = useState([]) - return ( -
- { - alert(`提交的结果: ${JSON.stringify(results)}`) - }} - /> -
当前填充: {JSON.stringify(value)}
-
- ) - }, + render: () => , } // 允许重复选择 diff --git a/src/components/quiz/multiple-choice/multiple-choice.stories.tsx b/src/components/quiz/multiple-choice/multiple-choice.stories.tsx index 9b8569f..e7f65a9 100644 --- a/src/components/quiz/multiple-choice/multiple-choice.stories.tsx +++ b/src/components/quiz/multiple-choice/multiple-choice.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react' import { useState } from 'react' import MultipleChoice from './multiple-choice' -import type { MultipleChoiceProps } from './multiple-choice.types' import { cn } from '@/lib/utils' const meta: Meta = { @@ -47,6 +46,26 @@ const meta: Meta = { export default meta type Story = StoryObj +function ControlledMultipleChoiceStory() { + const [values, setValues] = useState([]) + + return ( +
+ { + alert(`提交的值: ${vals.join(', ')}`) + }} + /> +
+ 当前选中: {values.length > 0 ? values.join(', ') : '未选择'} +
+
+ ) +} + // 基础示例 export const Default: Story = { args: { @@ -75,24 +94,7 @@ export const WithOptionObjects: Story = { // 受控模式 export const Controlled: Story = { - render: () => { - const [values, setValues] = useState([]) - return ( -
- { - alert(`提交的值: ${vals.join(', ')}`) - }} - /> -
- 当前选中: {values.length > 0 ? values.join(', ') : '未选择'} -
-
- ) - }, + render: () => , } // 限制最大选择数量 diff --git a/src/components/quiz/single-choice/single-choice.stories.tsx b/src/components/quiz/single-choice/single-choice.stories.tsx index c892671..4a1c31d 100644 --- a/src/components/quiz/single-choice/single-choice.stories.tsx +++ b/src/components/quiz/single-choice/single-choice.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react' import { useState } from 'react' import SingleChoice from './single-choice' -import type { SingleChoiceProps } from './single-choice.types' import { cn } from '@/lib/utils' const meta: Meta = { @@ -39,6 +38,24 @@ const meta: Meta = { export default meta type Story = StoryObj +function ControlledSingleChoiceStory() { + const [value, setValue] = useState('') + + return ( +
+ { + alert(`提交的值: ${val}`) + }} + /> +
当前选中: {value || '未选择'}
+
+ ) +} + // 基础示例 export const Default: Story = { args: { @@ -66,22 +83,7 @@ export const WithOptionObjects: Story = { // 受控模式 export const Controlled: Story = { - render: () => { - const [value, setValue] = useState('') - return ( -
- { - alert(`提交的值: ${val}`) - }} - /> -
当前选中: {value || '未选择'}
-
- ) - }, + render: () => , } // 禁用状态 diff --git a/src/config/puzzleData.ts b/src/config/puzzleData.ts index 02e2b66..f3eb617 100644 --- a/src/config/puzzleData.ts +++ b/src/config/puzzleData.ts @@ -20,7 +20,7 @@ export const puzzleDataConfig: Record> = chenyun: { // 碎片1 - 示例数据,请根据实际情况修改 1: { - title: '水乡少年·艰率成长', + title: '水乡少年·艰辛成长', detailImage: 'chenyun/details/1', route: 'place1', }, @@ -208,7 +208,7 @@ export const puzzleDataConfig: Record> = }, // 碎片16 16: { - title: '黄炎培的”实业救国“情怀”', + title: '黄炎培的“实业救国”情怀', detailImage: 'huangyanpei/details/16', route: 'internalization', }, @@ -220,7 +220,7 @@ export const puzzleDataConfig: Record> = route: 'place1', }, 2: { - title: '郑观应的推荐信郑观应的推荐信', + title: '郑观应的推荐信', detailImage: 'sunzhongshan/details/2', route: 'place1', }, @@ -260,7 +260,7 @@ export const puzzleDataConfig: Record> = route: 'place3', }, 10: { - title: '《中国国民党第一次全国代表大会宣言》', + title: '《中国国民党第一次 全国代表大会宣言》', detailImage: 'sunzhongshan/details/10', route: 'place3', }, @@ -285,7 +285,7 @@ export const puzzleDataConfig: Record> = route: 'reflection3', }, 15: { - title: '振兴中华对新时代青年的意义', + title: '振兴中华对新时代 青年的意义', detailImage: 'sunzhongshan/details/15', route: 'internalization', }, @@ -297,7 +297,7 @@ export const puzzleDataConfig: Record> = }, luxun: { 1: { - title: '鲁迅1927年南下广州时的照片', + title: '鲁迅1927年南下 广州时的照片', detailImage: 'luxun/details/1', route: 'place1', }, @@ -364,7 +364,7 @@ export const puzzleDataConfig: Record> = 14: { title: '鲁迅创作历程', detailImage: 'luxun/details/14', - route: 'reflection1', + route: 'reflection', }, 15: { title: '鲁迅精神的总结', @@ -372,7 +372,7 @@ export const puzzleDataConfig: Record> = route: 'reflection3', }, 16: { - title: '鲁迅在木刻上对青年的培养', + title: '鲁迅对青年的培养', detailImage: 'luxun/details/16', route: 'internalization', }, @@ -391,73 +391,319 @@ export const puzzleDataConfig: Record> = 3: { title: '中共二大旧址', detailImage: 'maozedong/details/3', - route: 'place1', + route: 'place2', }, 4: { title: '《向导》周刊三篇文章', detailImage: 'maozedong/details/4', - route: 'place1', + route: 'place2', }, 5: { title: '中共三大会议召开', detailImage: 'maozedong/details/5', - route: 'place1', + route: 'place3', }, 6: { title: '《孙中山手书名单》', detailImage: 'maozedong/details/6', - route: 'place1', + route: 'place3', }, 7: { title: '《孙文越飞联合宣言》', detailImage: 'maozedong/details/7', - route: 'place1', + route: 'place3', }, 8: { title: '毛泽东签发的两份中共四大通知', detailImage: 'maozedong/details/8', - route: 'place1', + route: 'place4', }, 9: { title: '中共四大立碑处', detailImage: 'maozedong/details/9', - route: 'place1', + route: 'place4', }, 10: { title: '毛泽东中国社会分析', detailImage: 'maozedong/details/10', - route: 'place1', + route: 'place4', }, 11: { title: '湖南农民运动考察报告', detailImage: 'maozedong/details/11', - route: 'place1', + route: 'place4', }, 12: { title: '少一人的全家福', detailImage: 'maozedong/details/12', - route: 'place1', + route: 'place4', }, 13: { title: '筹备方向的定夺', detailImage: 'maozedong/details/13', - route: 'place1', + route: 'reflection', }, 14: { title: '基层声音的汇聚', detailImage: 'maozedong/details/14', - route: 'place1', + route: 'reflection', }, 15: { title: '毛泽东思想的确立', detailImage: 'maozedong/details/15', - route: 'place1', + route: 'internalization', }, 16: { title: '毛泽东思想活的灵魂', detailImage: 'maozedong/details/16', + route: 'internalization', + }, + }, + nieer: { + 1: { + title: '九·一八事变', + detailImage: 'nieer/details/1', route: 'place1', }, + 2: { + title: '聂耳发表了《中国歌舞短论》', + detailImage: 'nieer/details/2', + route: 'place1', + }, + 3: { + title: '聂耳加入左翼文艺团体', + detailImage: 'nieer/details/3', + route: 'place1', + }, + 4: { + title: '英雄儿女胶片', + detailImage: 'nieer/details/4', + route: 'place2', + }, + 5: { + title: '志同道合', + detailImage: 'nieer/details/5', + route: 'place2', + }, + 6: { + title: '聂耳手写《义勇军进行曲》', + detailImage: 'nieer/details/6', + route: 'place3', + }, + 7: { + title: '聂耳在海外定稿曲谱', + detailImage: 'nieer/details/7', + route: 'place3', + }, + 8: { + title: '风云儿女上映', + detailImage: 'nieer/details/8', + route: 'place4', + }, + 9: { + title: '刘良模指挥数千人齐唱', + detailImage: 'nieer/details/9', + route: 'place4', + }, + 10: { + title: '国歌选定', + detailImage: 'nieer/details/10', + route: 'place5', + }, + 11: { + title: '国歌入宪', + detailImage: 'nieer/details/11', + route: 'place5', + }, + 12: { + title: '国歌立法', + detailImage: 'nieer/details/12', + route: 'place5', + }, + 13: { + title: '新中国奏响国歌的场景(上)', + detailImage: 'nieer/details/13', + route: 'place6', + }, + 14: { + title: '新中国奏响国歌的场景(下)', + detailImage: 'nieer/details/14', + route: 'place6', + }, + 15: { + title: '聂耳「人民音乐家」', + detailImage: 'nieer/details/15', + route: 'internalization', + }, + 16: { + title: '国歌精神的当代传承', + detailImage: 'nieer/details/16', + route: 'internalization', + }, + }, + zhangleping: { + 1: { + title: '童年的回忆 (张乐平,1983)', + detailImage: 'zhangleping/details/1', + route: 'place1', + }, + 2: { + title: '张母的绣品', + detailImage: 'zhangleping/details/2', + route: 'place1', + }, + 3: { + title: '穷凶极恶的结果 (张乐平,1937)', + detailImage: 'zhangleping/details/3', + route: 'place2', + }, + 4: { + title: '长沙大火前夕 张乐平在街头墙上挥笔作画', + detailImage: 'zhangleping/details/4', + route: 'place2', + }, + 5: { + title: '侵略者的坟墓 (张乐平,1938)', + detailImage: 'zhangleping/details/5', + route: 'place2', + }, + 6: { + title: '早期三毛', + detailImage: 'zhangleping/details/6', + route: 'place3', + }, + 7: { + title: '三毛成长(三毛流浪记)', + detailImage: 'zhangleping/details/7', + route: 'place3', + }, + 8: { + title: '三毛新生(《向日葵》张乐平,1950-1965)', + detailImage: 'zhangleping/details/8', + route: 'place3', + }, + 9: { + title: '张乐平和儿童互通书信', + detailImage: 'zhangleping/details/9', + route: 'place4', + }, + 10: { + title: '儿童园丁', + detailImage: 'zhangleping/details/10', + route: 'place4', + }, + 11: { + title: '朋友画我的瓷板画(右)', + detailImage: 'zhangleping/details/11', + route: 'place5', + }, + 12: { + title: '朋友画我的瓷板画(左)', + detailImage: 'zhangleping/details/12', + route: 'place5', + }, + 13: { + title: '名人留言板', + detailImage: 'zhangleping/details/13', + route: 'place5', + }, + 14: { + title: '画自己', + detailImage: 'zhangleping/details/14', + route: 'place5', + }, + 15: { + title: '平民立场', + detailImage: 'zhangleping/details/15', + route: 'internalization', + }, + 16: { + title: '笔耕不辍', + detailImage: 'zhangleping/details/16', + route: 'internalization', + }, + }, + rongdesheng: { + 1: { + title: '荣德生像', + detailImage: 'rongdesheng/details/1', + route: 'place1', + }, + 2: { + title: '民国时期上海的钱庄', + detailImage: 'rongdesheng/details/2', + route: 'place1', + }, + 3: { + title: '福新面粉厂', + detailImage: 'rongdesheng/details/3', + route: 'place2', + }, + 4: { + title: '“兵船”牌面粉商标', + detailImage: 'rongdesheng/details/4', + route: 'place2', + }, + 5: { + title: '上海申新第九纺织厂门楼(1933年旧照)', + detailImage: 'rongdesheng/details/5', + route: 'place2', + }, + 6: { + title: '“人钟牌”棉纱商标', + detailImage: 'rongdesheng/details/6', + route: 'place2', + }, + 7: { + title: '国际劳工局赞美申新三纺织厂劳工自治区', + detailImage: 'rongdesheng/details/7', + route: 'place2', + }, + 8: { + title: '上海申新一厂和八厂于1937年被日本飞机炸毁', + detailImage: 'rongdesheng/details/8', + route: 'place3', + }, + 9: { + title: '《人报》报道 乐农氏再度捐助面粉一万包', + detailImage: 'rongdesheng/details/9', + route: 'place3', + }, + 10: { + title: '心正思无邪 意诚言必中题字', + detailImage: 'rongdesheng/details/10', + route: 'place3', + }, + 11: { + title: '荣德生被绑架新闻报道', + detailImage: 'rongdesheng/details/11', + route: 'place4', + }, + 12: { + title: '荣德生拒绝荣氏工业结构外迁的报道', + detailImage: 'rongdesheng/details/12', + route: 'place4', + }, + 13: { + title: '毛泽东主席、陈毅副总理在荣毅仁陪同下,参观公私合营上海申新第九纺织厂', + detailImage: 'rongdesheng/details/13', + route: 'place4', + }, + 14: { + title: '荣德生与张 一九四九年九月,荣德生致函第一届全国政协謇的合影', + detailImage: 'rongdesheng/details/14', + route: 'place4', + }, + 15: { + title: '坚韧不拔', + detailImage: 'rongdesheng/details/15', + route: 'internalization', + }, + 16: { + title: '社会责任担当', + detailImage: 'rongdesheng/details/16', + route: 'internalization', + }, }, // 后续可以添加其他故居的数据 // sunzhongshan: { ... }, diff --git a/src/config/puzzleResidences.ts b/src/config/puzzleResidences.ts new file mode 100644 index 0000000..9252b79 --- /dev/null +++ b/src/config/puzzleResidences.ts @@ -0,0 +1,102 @@ +export interface PuzzleResidenceConfig { + residence: string + displayName: string + moduleName: string + left: string + top: string + image: string + hasPuzzle: boolean + hasMap?: boolean + mapImage?: string +} + +export const PUZZLE_RESIDENCES: PuzzleResidenceConfig[] = [ + { + residence: 'maozedong', + displayName: '毛泽东旧居', + moduleName: 'revolutionary', + left: '30%', + top: '35%', + image: '毛泽东', + hasPuzzle: true, + hasMap: true, + mapImage: '毛泽东导图', + }, + { + residence: 'sunzhongshan', + displayName: '孙中山故居', + moduleName: 'revolutionary', + left: '12%', + top: '51%', + image: '孙中山', + hasPuzzle: true, + hasMap: true, + mapImage: '孙中山导图', + }, + { + residence: 'luxun', + displayName: '鲁迅故居', + moduleName: 'cultural', + left: '26.3%', + top: '74%', + image: '鲁迅', + hasPuzzle: true, + hasMap: true, + mapImage: '鲁迅导图', + }, + { + residence: 'huangyanpei', + displayName: '黄炎培故居', + moduleName: 'cultural', + left: '44%', + top: '67%', + image: '黄炎培', + hasPuzzle: true, + hasMap: true, + mapImage: '黄炎培导图', + }, + { + residence: 'nieer', + displayName: '聂耳旧居', + moduleName: 'artistic', + left: '62%', + top: '60%', + image: '聂耳', + hasPuzzle: true, + hasMap: false, + }, + { + residence: 'zhangleping', + displayName: '张乐平故居', + moduleName: 'artistic', + left: '73%', + top: '33%', + image: '张乐平', + hasPuzzle: true, + hasMap: false, + }, + { + residence: 'rongdesheng', + displayName: '荣德生旧居', + moduleName: 'economic', + left: '90%', + top: '45%', + image: '荣德生', + hasPuzzle: true, + hasMap: false, + }, + { + residence: 'chenyun', + displayName: '陈云故居', + moduleName: 'economic', + left: '80%', + top: '76%', + image: '陈云', + hasPuzzle: true, + hasMap: true, + mapImage: '陈云导图', + }, +] + +export const DEFAULT_PUZZLE_RESIDENCE = + PUZZLE_RESIDENCES.find((item) => item.hasPuzzle)?.residence || 'maozedong' diff --git a/src/contexts/PuzzleAnimationContext.tsx b/src/contexts/PuzzleAnimationContext.tsx index d352c33..237668d 100644 --- a/src/contexts/PuzzleAnimationContext.tsx +++ b/src/contexts/PuzzleAnimationContext.tsx @@ -1,8 +1,15 @@ import { createContext, useContext, ReactNode } from 'react' +interface TriggerPuzzleAnimationOptions { + onComplete?: () => void +} + interface PuzzleAnimationContextType { - /** 触发碎片动画,传递碎片ID */ - triggerPuzzleAnimation: (pieceId: number) => void + /** 触发碎片动画,传递碎片ID,并在动画结束后 resolve */ + triggerPuzzleAnimation: ( + pieceId: number, + options?: TriggerPuzzleAnimationOptions + ) => Promise } const PuzzleAnimationContext = createContext(null) @@ -12,7 +19,10 @@ export const PuzzleAnimationProvider = ({ triggerPuzzleAnimation, }: { children: ReactNode - triggerPuzzleAnimation: (pieceId: number) => void + triggerPuzzleAnimation: ( + pieceId: number, + options?: TriggerPuzzleAnimationOptions + ) => Promise }) => { return ( diff --git a/src/lib/utils/sensitiveWords.ts b/src/lib/utils/sensitiveWords.ts index b27de7a..c9d4b54 100644 --- a/src/lib/utils/sensitiveWords.ts +++ b/src/lib/utils/sensitiveWords.ts @@ -12,6 +12,63 @@ const SENSITIVE_WORDS = [ // 可以根据实际需求添加更多敏感词 ] +type FileSaveHandle = { + createWritable: () => Promise<{ + write: (data: Blob) => Promise + close: () => Promise + }> +} + +type WindowWithFilePicker = Window & { + showSaveFilePicker?: (options?: { + suggestedName?: string + types?: Array<{ + accept: Record + }> + }) => Promise +} + +const isAbortError = (error: unknown) => + error instanceof DOMException && error.name === 'AbortError' + +const triggerAnchorDownload = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.rel = 'noopener' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +export async function downloadDataUrlImage(dataUrl: string, filename: string): Promise { + if (!dataUrl) return + + const response = await fetch(dataUrl) + const blob = await response.blob() + const fileType = blob.type || 'image/png' + + const picker = (window as WindowWithFilePicker).showSaveFilePicker + if (picker) { + try { + const handle = await picker({ + suggestedName: filename, + types: [{ accept: { [fileType]: ['.png'] } }], + }) + const writable = await handle.createWritable() + await writable.write(blob) + await writable.close() + return + } catch (error) { + if (isAbortError(error)) return + } + } + + triggerAnchorDownload(blob, filename) +} + /** * 检查文本中是否包含敏感词 * @param text 待检查的文本 diff --git a/src/pages/Menu.tsx b/src/pages/Menu.tsx index 65f6aaa..defe434 100644 --- a/src/pages/Menu.tsx +++ b/src/pages/Menu.tsx @@ -3,6 +3,7 @@ import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { Image, PuzzleSystem } from '@/components' import { useNavigate } from 'react-router-dom' import MenuPuzzleCenter from '@/components/puzzle/MenuPuzzleCenter' +import { DEFAULT_PUZZLE_RESIDENCE } from '@/config/puzzleResidences' const Menu = () => { const navigate = useNavigate() @@ -51,15 +52,37 @@ const Menu = () => { onClick={() => navigate('/')} /> - {/* 左下角碎片盒按钮 - 进入碎片管理中心 */} + {/* 左下角碎片盒按钮 - 进入拼图系统 */}
setIsPuzzleCenterVisible(true)} + onClick={() => setActiveResidence(DEFAULT_PUZZLE_RESIDENCE)} />
+ {/* 顶层碎片管理中心:查看故居导图与进度 */} + setIsPuzzleCenterVisible(false)} + onSelectResidence={(residence) => { + setActiveResidence(residence) + setIsPuzzleCenterVisible(false) + }} + allowPuzzleEntry={false} + /> + + {/* 进入具体故居的拼图系统(与故居页面一致的 PuzzleSystem) */} + {activeResidence && ( + setActiveResidence(null)} + residence={activeResidence} + showResidenceNav + onResidenceChange={setActiveResidence} + /> + )} +
{Category.map((item) => (
{ ))}
- {/* 顶层碎片管理中心:选择要查看的故居拼图 */} - setIsPuzzleCenterVisible(false)} - onSelectResidence={(residence) => { - setActiveResidence(residence) - setIsPuzzleCenterVisible(false) - }} - /> - - {/* 进入具体故居的拼图系统(与故居页面一致的 PuzzleSystem) */} - {activeResidence && ( - { - setActiveResidence(null) - // 关闭 PuzzleSystem 后,重新打开碎片管理中心 - setIsPuzzleCenterVisible(true) - }} - residence={activeResidence} - /> - )} + {/* 学习地图 */} + +
+
+ setIsPuzzleCenterVisible(true)} + /> +
+
) } diff --git a/src/pages/chenyun/internalization.tsx b/src/pages/chenyun/internalization.tsx index b9130c1..7edf43b 100644 --- a/src/pages/chenyun/internalization.tsx +++ b/src/pages/chenyun/internalization.tsx @@ -8,10 +8,11 @@ import { type DragFillResult, LetterToChenyun, EndingOverlay, + Narration, } from '@/components' import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { SuccessAlert, DragQuizPanel } from '@/components/layout' -import { MultipleChoiceQuizPanel } from '@/components/layout/quiz' +import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz' import { GuideSteps, GuideStepsItem } from '@/components' import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' import { setCourseTimeProgress } from '@/lib/utils/api-common' @@ -44,9 +45,9 @@ const MULTIPLE_DEFAULT_ERROR = '你的选择还不够全面哦~请你再想想 const QUIZ_ANCHORS: QuizAnchor[] = [ { id: 'quiz-1', - time: 60 * 5 + 52, //5.52分钟 + time: 60 * 5 + 53, //5.52分钟 title: '题目1', - prompt: '陈云经济思想的核心内容指的是(\xa0\xa0\xa0\xa0)。', + prompt: '陈云经济思想的核心内容包括:', type: 'multiple', options: [ { value: 'A', label: '综合平衡', isCorrect: true }, @@ -61,7 +62,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: 'quiz-2', - time: 60 * 10 + 1, //10.01分钟 + time: 60 * 10 + 2, //10.01分钟 title: '题目2', prompt: '陈云说过以下哪些话能着重体现出他的经济思想?', type: 'multiple', @@ -83,7 +84,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: 'quiz-3', - time: 16 * 60 + 26, //16:26 + time: 16 * 60 + 31, //16:26 title: '题目3', prompt: '新时代的青年可以从陈云身上学习到', type: 'drag', @@ -103,6 +104,21 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ id: 'quiz-4', time: 60 * 5 + 21, // 5:21 title: '题目4', + prompt: '陈云讲:“经济建设最终目的,是为了改善____的生活水平。”', + type: 'single', + options: [ + { + value: 'A', + label: '为官者', + explanation: '这不是陈云提倡的经济建设的最终目的。请你再想想吧!', + }, + { value: 'B', label: '老百姓', isCorrect: true }, + ], + }, + { + id: 'quiz-5', + time: 60 * 16 + 23, // 16:23 + title: '题目5', prompt: '以下哪些陈云的生活小事值得我们学习?', type: 'multiple', options: [ @@ -113,25 +129,6 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ ], explanation: MULTIPLE_DEFAULT_ERROR, }, - { - id: 'quiz-5', - time: 60 * 16 + 23, // 16:23 - title: '题目5', - prompt: '新时代的青年要从陈云身上学习哪些精神品质?', - type: 'multiple', - options: [ - { value: 'A', label: '谦虚谨慎,实事求是的工作作风', isCorrect: true }, - { value: 'B', label: '勤俭朴素,艰苦奋斗的优良作风', isCorrect: true }, - { value: 'C', label: '密切联系群众,关心群众生活', isCorrect: true }, - { - value: 'D', - label: '只唯上的工作作风', - isCorrect: false, - explanation: '"只唯上"的工作作风与陈云同志实事求是的精神相悖。请你再想想吧!', - }, - ], - explanation: MULTIPLE_DEFAULT_ERROR, - }, ] const ChenyunInternalization = () => { @@ -144,6 +141,7 @@ const ChenyunInternalization = () => { // 拖拽题状态 const [dragQuizValues, setDragQuizValues] = useState>({}) const [dragQuizErrors, setDragQuizErrors] = useState>({}) + const [narrationVisible, setNarrationVisible] = useState(false) const [visible, setVisible] = useState(false) const [showVideo, setShowVideo] = useState(false) const [postGuideVisible, setPostGuideVisible] = useState(false) @@ -152,7 +150,7 @@ const ChenyunInternalization = () => { const videoRef = useRef(null) useEffect(() => { - setVisible(true) + setNarrationVisible(true) }, []) // 视频加载完成后自动播放 @@ -307,7 +305,48 @@ const ChenyunInternalization = () => { } } - // 单选题处理(保留原有逻辑,以防万一) + if (item.type === 'single') { + return { + id: item.id, + time: item.time, + once: true, + afterResume: 'remove', + render: ({ resume }) => ( + <> + ({ + value: opt.value, + label: `${opt.value}. ${opt.label}`, + }))} + correctAnswer={correctAnswers[0]} + onWrongSelect={(value) => + item.options.find((opt) => opt.value === value)?.explanation + } + onSubmit={(_, isCorrect) => { + if (isCorrect) { + setActiveSuccess(item.id) + setResumeCallback(() => resume) + } + }} + /> + { + setActiveSuccess(null) + const cb = resumeCallback + setResumeCallback(null) + cb?.() + }} + /> + + ), + } + } + return { id: item.id, time: item.time, @@ -336,6 +375,17 @@ const ChenyunInternalization = () => { onClick={() => navigate(`/${moduleName}/${starName}/home`)} /> + { + setNarrationVisible(false) + setVisible(true) + }} + text={`从练塘水乡的少年时光,到商务印书馆的革命启航;从苏区与延安的经济破局,到新中国成立之初打赢“银元之战”“米棉之战”,再到建设与改革岁月里擘画实业、奠基兴国,我们已循着陈云同志的足迹,走过一段波澜壮阔的奋斗征程。那些“全国一盘棋”的统筹智慧、“不唯上、不唯书、只唯实”的务实品格、“为老百姓做事”的为民初心,穿越历史烟云,依旧闪耀着时代光芒。历史的回响已在耳畔,精神的传承更需躬身践行。新时代的青年,应该从历史感悟走向思想内化,读懂陈云经济思想的深刻内涵,汲取指引青春成长、担当时代使命的精神力量,让伟人风范在新时代薪火相传。`} + animationSpeed={200} + /> + {showVideo && (
)} + { + if (isCorrect) { + setQuizVisible(false) + setSuccessAlertVisible(true) + } + }} + onError={(_error: string) => {}} + submitText="确定" + zIndex={10000} + /> + + { + setSuccessAlertVisible(false) + markExhibitVisited(place, 2) + triggerPuzzleAnimation(5) + }} + /> + {/* 遮罩层 */} = { - 1: 'chenyun/place5/场景5展品1', -} +// ─── 资源路径 ─── +const BACKGROUND_KEY = `${STAR_NAME}/${PLACE}/场景7` +const CONTINUE_IMAGE_KEY = `${STAR_NAME}/${PLACE}/继续前进` +const VIDEO_1_SRC = getPublicPath(`video/${STAR_NAME}/chenyun_10.mp4`) +const VIDEO_2_SRC = getPublicPath(`video/${STAR_NAME}/chenyun_11.mp4`) +const AUDIO_PREFIX = `audio/${STAR_NAME}` -// 题目1选项 +// ─── 题目1:单选 ─── const QUIZ1_OPTIONS: SingleChoiceOption[] = [ - { - value: 'A', - label: 'A.推动了淮海战役的胜利', - }, - { - value: 'B', - label: 'B.有利于稳定人民币的发行和地位', - }, - { - value: 'C', - label: 'C.巩固了新中国人民政权', - }, + { value: 'A', label: 'A.推动了淮海战役的胜利' }, + { value: 'B', label: 'B.有利于稳定人民币的发行和地位' }, + { value: 'C', label: 'C.巩固了新中国人民政权' }, ] -// 题目1各选项对应的错误提示(原来写在选项括号中的内容) +const QUIZ1_CORRECT: string = 'B' + +const QUIZ1_TITLE = + '【单选】1949年6月,中共中央指示打击银元投机活动,金条、银元一律由人民银行挂牌收兑,铁路交通事业,税收一律收人民币。同时,上海军管会查封了证券大楼。这次"银元之战":' + const QUIZ1_ERROR_HINTS: Record = { - A: '淮海战役于1949年1月胜利结束,发生在这次“银元之战”前,不存在因果关系。请你再想想吧!', + A: '淮海战役于1949年1月胜利结束,发生在这次"银元之战"前,不存在因果关系。请你再想想吧!', C: '1949年6月新中国政权尚未建立。请你再想想吧', } +const QUIZ1_FALLBACK_HINT = '再想一想,注意题干中的时间和提示信息哦~' + +const QUIZ1_SUCCESS_CONTENT = + '"银元之战"属于当时人民政府整顿金融的重要举措,黄金、银元投机不利于人民币币值稳定和流通,因此"银元之战"打击贵金属投机活动,有利于稳定人民币的发行和地位。' + +// ─── 题目2:拖拽 ─── +const QUIZ2_BLANKS = [ + { id: 'blank-1', label: '____' }, + { id: 'blank-2', label: '____' }, + { id: 'blank-3', label: '____' }, +] + +const QUIZ2_OPTIONS = [ + { value: '棉纱', label: '棉纱' }, + { value: '大米', label: '大米' }, + { value: '煤炭', label: '煤炭' }, + { value: '石油', label: '石油' }, +] + +const QUIZ2_CORRECT_SET = new Set(['棉纱', '大米', '煤炭']) + +const QUIZ2_PREFIX = + '【拖拽】新中国成立后,为了稳定物价,中央政府组织了"两白一黑"的集中调运,"两白一黑"指的是' + +const QUIZ2_ERROR = '石油当时不是主要调控对象。请你再想想吧!' + +const isQuiz2Correct = (answers: DragFillResult[]): boolean => { + if (answers.length !== QUIZ2_CORRECT_SET.size) return false + const filled = new Set(answers.map((a) => a.optionValue)) + if (filled.size !== QUIZ2_CORRECT_SET.size) return false + for (const value of QUIZ2_CORRECT_SET) { + if (!filled.has(value)) return false + } + return true +} + const ChenyunPlace7 = () => { const navigate = useNavigate() - const { moduleName } = useParams<{ - moduleName: string - }>() - - // 获取碎片动画触发方法 + const { moduleName } = useParams<{ moduleName: string }>() const { triggerPuzzleAnimation } = usePuzzleAnimation() - - const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null) - - // 使用持久化的访问状态 - const place = 'place7' const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore() - // 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码 - const visitedExhibits = getVisitedExhibits(place) - const visited = new Set(visitedExhibits) - const [videoVisible1, setVideoVisible1] = useState(false) - const [videoPlaying, setVideoPlaying] = useState(false) - const videoRef = useRef(null) - // 单选题 - const [quizVisible, setQuizVisible] = useState(false) - const [quizAnswer, setQuizAnswer] = useState('') - const [quizError, setQuizError] = useState('') - // 展品1引导对话 - const [guideVisible, setGuideVisible] = useState(false) - const [guideStep, setGuideStep] = useState(0) - // 第二阶段:对话2 + 视频2 + 题目2 - const [videoVisible2, setVideoVisible2] = useState(false) - const [videoPlaying2, setVideoPlaying2] = useState(false) + const visited = new Set(getVisitedExhibits(PLACE)) + const completed = visited.size >= TOTAL_EXHIBITS + + // ─── 第一阶段:视频1 + 对话1 + 单选题 ─── + const videoRef1 = useRef(null) + const [video1Visible, setVideo1Visible] = useState(false) + const [video1Playing, setVideo1Playing] = useState(false) + const [guide1Visible, setGuide1Visible] = useState(false) + const [guide1Step, setGuide1Step] = useState(0) + const [quiz1Visible, setQuiz1Visible] = useState(false) + const [quiz1Completed, setQuiz1Completed] = useState(false) + + // ─── 第二阶段:视频2 + 对话2 + 拖拽题 ─── const videoRef2 = useRef(null) - const [guideVisible2, setGuideVisible2] = useState(false) - const [guideStep2, setGuideStep2] = useState(0) + const [video2Visible, setVideo2Visible] = useState(false) + const [video2Playing, setVideo2Playing] = useState(false) + const [guide2Visible, setGuide2Visible] = useState(false) + const [guide2Step, setGuide2Step] = useState(0) const [quiz2Visible, setQuiz2Visible] = useState(false) - const [quiz2Error, setQuiz2Error] = useState('') const [quiz2Value, setQuiz2Value] = useState([]) - // 成功提示 - const [successAlertVisible, setSuccessAlertVisible] = useState(false) - const [successAlertContent, setSuccessAlertContent] = useState('') - // 标记拖拽题是否已完成 + const [quiz2Error, setQuiz2Error] = useState('') const [quiz2Completed, setQuiz2Completed] = useState(false) - const handleOpenExhibit = (_index: 1) => { - // 如果本场景学习已完成(拖拽题已答对),不再响应点击,避免重复播放 - if (quiz2Completed) return + // ─── 成功提示 ─── + const [successVisible, setSuccessVisible] = useState(false) + const [successContent, setSuccessContent] = useState('') - // 展品1:点击时显示视频1场景和引导对话1 - setVideoVisible1(true) - setVideoPlaying(false) // 视频不自动播放 - setGuideVisible(true) - setGuideStep(0) - } + // ─── 流程辅助 ─── + const resetFlow = useCallback(() => { + setVideo1Visible(false) + setVideo1Playing(false) + setGuide1Visible(false) + setGuide1Step(0) + setQuiz1Visible(false) + setQuiz1Completed(false) - // 监听GuideSteps步骤变化 - const handleGuideStepChange = (step: number) => { - setGuideStep(step) - } + setVideo2Visible(false) + setVideo2Playing(false) + setGuide2Visible(false) + setGuide2Step(0) + setQuiz2Visible(false) + setQuiz2Value([]) + setQuiz2Error('') + setQuiz2Completed(false) - // 监听对话引导完成 - const handleGuideVisibleChange = (visible: boolean) => { - setGuideVisible(visible) - // 对话引导完成后,开始播放视频 - if (!visible && videoVisible1) { - setVideoPlaying(true) - videoRef.current?.play() + setSuccessVisible(false) + setSuccessContent('') + }, []) + + const enterPhase1 = useCallback(() => { + setVideo1Visible(true) + setVideo1Playing(false) + setGuide1Visible(true) + setGuide1Step(0) + }, []) + + const enterPhase2 = useCallback(() => { + setVideo2Visible(true) + setVideo2Playing(false) + setGuide2Visible(true) + setGuide2Step(0) + }, []) + + // 点击展品光标:每次都从头走完整流程 + const handleOpenExhibit = useCallback(() => { + resetFlow() + enterPhase1() + }, [resetFlow, enterPhase1]) + + // 对话1 结束 → 播放视频1 + const handleGuide1VisibleChange = (visible: boolean) => { + setGuide1Visible(visible) + if (!visible && video1Visible) { + setVideo1Playing(true) + videoRef1.current?.play() } } - // 对话2步骤变化 - const handleGuideStepChange2 = (step: number) => { - setGuideStep2(step) - } - - // 对话2完成 → 播放视频2 - const handleGuideVisibleChange2 = (visible: boolean) => { - setGuideVisible2(visible) - if (!visible && videoVisible2) { - setVideoPlaying2(true) + // 对话2 结束 → 播放视频2 + const handleGuide2VisibleChange = (visible: boolean) => { + setGuide2Visible(visible) + if (!visible && video2Visible) { + setVideo2Playing(true) videoRef2.current?.play() } } - // 拖拽题配置 - const QUIZ2_BLANKS = [ - { id: 'blank-1', label: '____' }, - { id: 'blank-2', label: '____' }, - { id: 'blank-3', label: '____' }, - ] - const QUIZ2_OPTIONS = [ - { value: '棉纱', label: '棉纱' }, - { value: '大米', label: '大米' }, - { value: '煤炭', label: '煤炭' }, - { value: '石油', label: '石油' }, - ] - const QUIZ2_ANSWER = ['棉纱', '大米', '煤炭'] + // 视频1 结束:标记访问 + 触发拼图 + 出题1 + const handleVideo1Ended = () => { + setVideo1Playing(false) + markExhibitVisited(PLACE, EXHIBIT_ID) + triggerPuzzleAnimation(PUZZLE_PIECE_ID) + setQuiz1Visible(true) + } + // 题1 答对:弹成功提示,关闭后进入第二阶段 + const handleQuiz1Submit = () => { + setQuiz1Visible(false) + setQuiz1Completed(true) + setSuccessContent(QUIZ1_SUCCESS_CONTENT) + setSuccessVisible(true) + } + + // 视频2 结束:出题2 + const handleVideo2Ended = () => { + setVideo2Playing(false) + setQuiz2Visible(true) + } + + // 题2 实时校验:填齐后判定 const handleQuiz2Change = (val: DragFillResult[]) => { setQuiz2Value(val) setQuiz2Error('') - if (val.length < QUIZ2_ANSWER.length) return - // 获取所有已填入的答案(不要求顺序) - const filledAnswers = val.map((v) => v.optionValue) - // 检查是否包含了所有正确答案(使用 Set 比较,不要求顺序) - const answerSet = new Set(QUIZ2_ANSWER) - const filledSet = new Set(filledAnswers) - const isCorrect = - answerSet.size === filledSet.size && [...answerSet].every((answer) => filledSet.has(answer)) - if (isCorrect) { - setQuiz2Visible(false) - setVideoVisible1(false) - setVideoPlaying(false) - setVideoVisible2(false) - setVideoPlaying2(false) - // 标记拖拽题已完成 - setQuiz2Completed(true) - // 显示成功提示 - setSuccessAlertVisible(true) - } else { - setQuiz2Error('再想一想,石油当时不是主要调控对象。请你再想想吧!') + if (val.length < QUIZ2_CORRECT_SET.size) return + + if (!isQuiz2Correct(val)) { + setQuiz2Error(QUIZ2_ERROR) + return + } + + setQuiz2Visible(false) + setVideo1Visible(false) + setVideo2Visible(false) + setQuiz2Completed(true) + setSuccessContent('') + setSuccessVisible(true) + } + + // 成功提示关闭:题1答对后才进入第二阶段;题2答对后则停留在场景 + const handleSuccessDismiss = () => { + setSuccessVisible(false) + if (quiz1Completed && !quiz2Completed && !quiz2Visible) { + enterPhase2() } } - const progressCount = visited.size - const total = 1 - const completed = progressCount >= total - const subtitle = completed ? '已完成' : `${progressCount}/${total}` + if (!moduleName) return null + + const subtitle = completed ? '已完成' : `${visited.size}/${TOTAL_EXHIBITS}` const subtitleClassName = completed ? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]' : undefined + return (
{/* 标题 */} { className="absolute right-5 top-5" src={getStaticImage('components/离开')} button - onClick={() => { - if (moduleName && starName) { - navigate(`/${moduleName}/${starName}/place6`) - } - }} + onClick={() => navigate(`/${moduleName}/${STAR_NAME}/place6`)} /> - {/* 展品1 */} + {/* 展品1 光标 */} <Cursor className="absolute left-230 top-88" - onClick={() => handleOpenExhibit(1)} - read={visited.has(1)} + onClick={handleOpenExhibit} + read={visited.has(EXHIBIT_ID)} /> {/* 继续前进 - 仅在完成所有展品后显示 */} {completed && ( <Image className="absolute bottom-0 right-0" - src={getStaticImage('chenyun/place7/继续前进')} + src={getStaticImage(CONTINUE_IMAGE_KEY)} button onClick={() => { setLastVisitedPlace('place8') saveChenyunStore() - navigate(`/${moduleName}/${starName}/place8`) + navigate(`/${moduleName}/${STAR_NAME}/place8`) }} /> )} - {/* 展品1视频播放(全屏) */} - {videoVisible1 && ( - <div className="fixed inset-0 z-[9999] bg-black"> + {/* 视频1(全屏) */} + {video1Visible && ( + <div className="fixed inset-0 z-9999 bg-black"> <Video - ref={videoRef} - src={getPublicPath('video/chenyun/chenyun_10.mp4')} + ref={videoRef1} + src={VIDEO_1_SRC} className="w-full h-full" - autoPlay={videoPlaying} + autoPlay={video1Playing} playsInline preload="auto" - muted={!videoPlaying} + muted={!video1Playing} showSkipButton - onEnded={() => { - setVideoPlaying(false) - markExhibitVisited(place, 1) - triggerPuzzleAnimation(10) - // 视频播放完成后,出现单选题 - setQuizVisible(true) - }} + onEnded={handleVideo1Ended} /> </div> )} - {/* 展品1的引导对话 - 在视频上方显示 */} + {/* 对话1(叠加在视频1之上) */} <GuideSteps - visible={guideVisible} - onVisibleChange={handleGuideVisibleChange} - currentStep={guideStep} - onStepChange={handleGuideStepChange} + visible={guide1Visible} + onVisibleChange={handleGuide1VisibleChange} + currentStep={guide1Step} + onStepChange={setGuide1Step} showMask={false} highlightSelectors={[]} zIndex={10001} > <GuideStepsItem speaker="student" - audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_19.mp3')} + audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_19.mp3`)} > 那"银元之战"是如何取得胜利的呢? </GuideStepsItem> <GuideStepsItem speaker="guide" - audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_20.mp3')} + audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20.mp3`)} > 由于通货膨胀,国民党发行的法币、金圆券变成了一堆废纸。当时坊间流传"大街过三道,物价跳三跳"。 </GuideStepsItem> @@ -279,80 +321,64 @@ const ChenyunPlace7 = () => { </GuideStepsItem> </GuideSteps> - {/* 单选题1:在视频1结束后出现(点击即判定) */} + {/* 题目1:单选 */} <SingleChoiceQuizPanel - visible={quizVisible} + visible={quiz1Visible} zIndex={10002} - title={`【单选】1949年6月,中共中央指示打击银元投机活动,金条、银元一律由人民银行挂牌收兑,铁路交通事业,税收一律收人民币。同时,上海军管会查封了证券大楼。这次“银元之战”:`} + title={QUIZ1_TITLE} options={QUIZ1_OPTIONS} - correctAnswer={'B'} - errorMessage={quizError} - onSubmit={(val) => { - setQuizAnswer(val) - setQuizVisible(false) - setQuizError('') - // 显示成功提示 - setSuccessAlertVisible(true) - setSuccessAlertContent( - '“银元之战”属于当时人民政府整顿金融的重要举措,黄金、银元投机不利于人民币币值稳定和流通,因此“银元之战”打击贵金属投机活动,有利于稳定人民币的发行和地位。' - ) - }} - onWrongSelect={(val) => { - return QUIZ1_ERROR_HINTS[val] || '再想一想,注意题干中的时间和提示信息哦~' - }} + correctAnswer={QUIZ1_CORRECT} + onSubmit={handleQuiz1Submit} + onWrongSelect={(val) => QUIZ1_ERROR_HINTS[val] || QUIZ1_FALLBACK_HINT} /> - {/* 视频2播放(全屏) */} - {videoVisible2 && ( - <div className="fixed inset-0 z-[10010] bg-black"> + {/* 视频2(全屏) */} + {video2Visible && ( + <div className="fixed inset-0 z-10010 bg-black"> <Video ref={videoRef2} - src={getPublicPath('video/chenyun/chenyun_11.mp4')} + src={VIDEO_2_SRC} className="w-full h-full" - autoPlay={videoPlaying2} + autoPlay={video2Playing} playsInline preload="auto" - muted={!videoPlaying2} + muted={!video2Playing} showSkipButton skipText="跳过" - onEnded={() => { - setVideoPlaying2(false) - // 视频2结束后,停在最后一帧并出现题目2 - setQuiz2Visible(true) - }} + onEnded={handleVideo2Ended} /> </div> )} - {/* 对话2:在视频2背景上显示 */} + {/* 对话2(叠加在视频2之上) */} <GuideSteps - visible={guideVisible2} - onVisibleChange={handleGuideVisibleChange2} - currentStep={guideStep2} - onStepChange={handleGuideStepChange2} + visible={guide2Visible} + onVisibleChange={handleGuide2VisibleChange} + currentStep={guide2Step} + onStepChange={setGuide2Step} showMask={false} highlightSelectors={[]} zIndex={10011} > <GuideStepsItem speaker="student" - audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_21.mp3')} + audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_21.mp3`)} > - 那“两白一黑”战役是如何打赢的呢? + 那"两白一黑"战役是如何打赢的呢? </GuideStepsItem> <GuideStepsItem speaker="guide" - audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_22.mp3')} + audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_22.mp3`)} > - “银元之战”的胜利并没有遏制住投机者的嚣张气焰,物价稳定仅十几天后,投机者就由银元投机转向“两白一黑”的投机,即:棉纱、大米和煤炭,引发了又一次全国性涨价狂潮。 + "银元之战"的胜利并没有遏制住投机者的嚣张气焰,物价稳定仅十几天后,投机者就由银元投机转向"两白一黑"的投机,即:棉纱、大米和煤炭,引发了又一次全国性涨价狂潮。 </GuideStepsItem> </GuideSteps> - {/* 拖拽题2:在视频2结束后出现 */} + {/* 题目2:拖拽 */} <DragQuizPanel visible={quiz2Visible} zIndex={10012} - questionPrefix='【拖拽】新中国成立后,为了稳定物价,中央政府组织了"两白一黑"的集中调运,"两白一黑"指的是' + questionPrefix={QUIZ2_PREFIX} questionSuffix="。" options={QUIZ2_OPTIONS} blanks={QUIZ2_BLANKS} @@ -361,45 +387,15 @@ const ChenyunPlace7 = () => { onChange={handleQuiz2Change} /> - {/* 遮罩层 */} - <ExhibitOverlay - visible={activeExhibitId !== null} - src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} - onClose={() => { - if (activeExhibitId !== null) { - markExhibitVisited(place, activeExhibitId) - } - setActiveExhibitId(null) - }} - /> - - {/* 成功提示 */} - {successAlertVisible && ( + {/* 成功提示(盖在视频/题目之上) */} + {successVisible && ( <div className="fixed inset-0" style={{ zIndex: 20000 }}> <SuccessAlert - visible={successAlertVisible} + visible title="恭喜你, 选择正确!" - content={successAlertContent} - onClose={() => { - setSuccessAlertVisible(false) - // 如果是单选题答对且拖拽题还没开始,继续进入第二阶段 - if (quizAnswer === 'B' && !quiz2Visible && !quiz2Completed) { - setVideoVisible2(true) - setVideoPlaying2(false) - setGuideVisible2(true) - setGuideStep2(0) - } - }} - onContinue={() => { - setSuccessAlertVisible(false) - // 如果是单选题答对且拖拽题还没开始,继续进入第二阶段 - if (quizAnswer === 'B' && !quiz2Visible && !quiz2Completed) { - setVideoVisible2(true) - setVideoPlaying2(false) - setGuideVisible2(true) - setGuideStep2(0) - } - }} + content={successContent} + onClose={handleSuccessDismiss} + onContinue={handleSuccessDismiss} /> </div> )} diff --git a/src/pages/chenyun/place/place8.tsx b/src/pages/chenyun/place/place8.tsx index 2ba76e3..41fb3d8 100644 --- a/src/pages/chenyun/place/place8.tsx +++ b/src/pages/chenyun/place/place8.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState } from 'react' import { ExhibitOverlay, Title, @@ -191,7 +191,7 @@ const ChenyunPlace8 = () => { speaker="guide" audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_26.mp3')} > - 不错,随着大规模经济建设的展开,全国粮、棉、油等主要农产品严重紧缺,供求矛盾尖锐。陈云经过调查研究,反复比较,权衡利弊得失,终于下决心在农村征购,在城市配售的解决粮食问题的办法,也就是后来所说的"统购统销"。 + 不错,随着大规模经济建设的展开,全国粮、棉、油等主要农产品严重紧缺,供求矛盾尖锐。陈云经过调查研究,反复比较,权衡利弊得失,终于下决心采用在农村征购,在城市配售的解决粮食问题的办法,也就是后来所说的"统购统销"。 </GuideStepsItem> <GuideStepsItem speaker="guide"> 陈云说:"只有采用这种办法,才能保证我国人民生活日益增长的需要,才能制止投机活动,保证市场物价的稳定,才能使发展国民经济第一个五年计划得以顺利地进行。" diff --git a/src/pages/chenyun/reflection/index.tsx b/src/pages/chenyun/reflection/index.tsx index 1e69c8f..ff40d01 100644 --- a/src/pages/chenyun/reflection/index.tsx +++ b/src/pages/chenyun/reflection/index.tsx @@ -272,6 +272,7 @@ const ChenyunReflection = () => { setShowWelcomeGuide(false) }} audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_28.mp3')} + firstLineIndent="2em" > {WELCOME_TEXT} </StudyGuideModal> diff --git a/src/pages/chenyun/reflection/mimian/MimianQuizzes.tsx b/src/pages/chenyun/reflection/mimian/MimianQuizzes.tsx index 7514175..50e5cfb 100644 --- a/src/pages/chenyun/reflection/mimian/MimianQuizzes.tsx +++ b/src/pages/chenyun/reflection/mimian/MimianQuizzes.tsx @@ -98,7 +98,7 @@ export const MimianQuizzes = ({ {/* 米棉之战 - 单选题1(图1) */} <SingleChoiceQuizPanel visible={showQuiz1} - title={`【单选】投机商集中冲击"两白一黑"的核心目的是(\xa0\xa0\xa0\xa0)。`} + title={`【单选】投机商集中冲击"两白一黑"的核心目的是:`} options={[ { value: 'A', label: 'A. 满足市场需求' }, { value: 'B', label: 'B. 扰乱经济秩序、动摇政权' }, @@ -127,7 +127,7 @@ export const MimianQuizzes = ({ {/* 米棉之战 - 单选题2(图3) */} <SingleChoiceQuizPanel visible={showQuiz2} - title={`【单选】你觉得此时解决上海和全国面临的严峻经济形势问题的对策应该是(\xa0\xa0\xa0\xa0)。`} + title={`【单选】你觉得此时解决上海和全国面临的严峻经济形势问题的对策应该是:`} options={[ { value: 'A', label: 'A. 局部限购' }, { value: 'B', label: 'B. 增发货币' }, diff --git a/src/pages/huangyanpei/internalization.tsx b/src/pages/huangyanpei/internalization.tsx index 04f3caa..688f3a5 100644 --- a/src/pages/huangyanpei/internalization.tsx +++ b/src/pages/huangyanpei/internalization.tsx @@ -75,7 +75,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: 'quiz-2b', - time: 176, // 2:56 + time: 177, // 2:56 title: '', prompt: '【拖拽】黄炎培先生认为,职业学校的办学宗旨是“', promptSuffix: '”。', @@ -89,13 +89,13 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ { id: 'blank-1', label: '___' }, { id: 'blank-2', label: '___' }, ], - blankPrefixes: ['使无业者', ',使有业者'], - blankSeparators: [''], + blankPrefixes: ['使无业者', '使有业者'], + blankSeparators: [','], explanation: '正确答案为“使无业者有业,使有业者乐业”。', }, { id: 'quiz-4', - time: 260, // 4:20 + time: 261, // 4:20 title: '', prompt: '【多选】黄炎培先生认为职业教育的目的是___,简称“四个准备”。', type: 'multiple', @@ -447,6 +447,7 @@ const HuangyanpeiInternalization = () => { <Narration visible={narrationVisible} + audioChannelId="huangyanpei-internalization-narration" onClose={() => { setNarrationVisible(false) setVisible(true) @@ -475,6 +476,7 @@ const HuangyanpeiInternalization = () => { {/* 引导 */} <GuideSteps visible={visible} + audioChannelId="huangyanpei-internalization-guide-pre" onVisibleChange={(visible) => { setVisible(visible) if (!visible) { @@ -504,6 +506,7 @@ const HuangyanpeiInternalization = () => { {/* 视频结束后的引导 */} <GuideSteps visible={postGuideVisible} + audioChannelId="huangyanpei-internalization-guide-post" onVisibleChange={(visible) => { setPostGuideVisible(visible) if (!visible) { @@ -540,7 +543,7 @@ const HuangyanpeiInternalization = () => { onContinue={() => { setShowStudyGuide(false) setCourseTimeProgress(12) - navigate(`/${moduleName}/${starName}/home`) + navigate('/menu') }} audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_31.mp3')} /> diff --git a/src/pages/huangyanpei/place/place1.tsx b/src/pages/huangyanpei/place/place1.tsx index 0859558..3cc5bf8 100644 --- a/src/pages/huangyanpei/place/place1.tsx +++ b/src/pages/huangyanpei/place/place1.tsx @@ -174,6 +174,7 @@ const HuangyanpeiPlace1 = () => { visible={showAfterDialogGuide} onContinue={() => setShowAfterDialogGuide(false)} audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_01.mp3')} + firstLineIndent="2em" > 同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'} 此刻你正站在黄炎培先生故居门前,接下来就跟着时光的脉络,拆解黄先生人生里五段滚烫的旅程:思想启蒙、教育实践、创立职教、延安对话、职教传薪。准备好一起走进这段炽热的岁月了吗? diff --git a/src/pages/huangyanpei/place/place5.tsx b/src/pages/huangyanpei/place/place5.tsx index 7a8d0ce..055d347 100644 --- a/src/pages/huangyanpei/place/place5.tsx +++ b/src/pages/huangyanpei/place/place5.tsx @@ -310,7 +310,7 @@ const HuangyanpeiPlace5 = () => { {/* 题目 */} <SingleChoiceQuizPanel visible={showQuiz} - title={`【单选】《延安归来》中"破解历史周期率"的"初版答案"一夜畅销十万册,其提出的核心对策是(\xa0\xa0\xa0\xa0)。`} + title={`【单选】《延安归来》中"破解历史周期率"的"初版答案"一夜畅销十万册,其提出的核心对策是:`} options={[ { value: 'A', label: 'A. 实行民主' }, { value: 'B', label: 'B. 发展经济' }, diff --git a/src/pages/huangyanpei/reflection/scene1.tsx b/src/pages/huangyanpei/reflection/scene1.tsx index b79f1c2..1f34e47 100644 --- a/src/pages/huangyanpei/reflection/scene1.tsx +++ b/src/pages/huangyanpei/reflection/scene1.tsx @@ -1,10 +1,10 @@ -import { useState, useCallback, useEffect, useMemo } from 'react' +import { useState, useCallback, useMemo } from 'react' import { Title, Image, Cursor, StudyGuideModal } from '@/components' import { BambooPuzzleGame, QuestionModal } from './components' -import { SuccessAlert } from '@/components/layout' import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { useNavigate, useParams } from 'react-router-dom' import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore' +import { useCursorController } from '@/hooks/useCursorController' const starName = 'huangyanpei' @@ -17,7 +17,6 @@ const HuangyanpeiReflectionScene1 = () => { const [showGuideModal, setShowGuideModal] = useState(true) const [showQuestionModal, setShowQuestionModal] = useState(false) const [showGame, setShowGame] = useState(false) - const [showSuccessAlert, setShowSuccessAlert] = useState(false) const [showNextGuide, setShowNextGuide] = useState(false) // 使用持久化的reflection cursor状态 @@ -26,7 +25,6 @@ const HuangyanpeiReflectionScene1 = () => { markReflectionCursorVisited, getVisitedReflectionCursors, setReflectionRoute, - hasShownReflectionCompletionGuide, markReflectionCompletionGuideShown, } = useHuangyanpeiStore() @@ -34,6 +32,12 @@ const HuangyanpeiReflectionScene1 = () => { const visitedCursors = getVisitedReflectionCursors(scene) const visited = useMemo(() => new Set(visitedCursors), [visitedCursors]) + const { isEnabled } = useCursorController({ + initialEnabled: [1], + dependencies: { 2: [1] }, + visited: visitedCursors, + }) + const progressCount = visited.size const total = 2 const completed = progressCount >= total @@ -42,17 +46,12 @@ const HuangyanpeiReflectionScene1 = () => { ? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]' : undefined - // 两个任务都完成后 500ms 弹出下一站提示(仅一次) - useEffect(() => { - if (visited.has(1) && visited.has(2) && !hasShownReflectionCompletionGuide(scene)) { - const timer = setTimeout(() => { - setShowNextGuide(true) - markReflectionCompletionGuideShown(scene) - saveHuangyanpeiStore() - }, 500) - return () => clearTimeout(timer) - } - }, [visited, scene, hasShownReflectionCompletionGuide, markReflectionCompletionGuideShown]) + const handleGoNext = useCallback(() => { + setShowNextGuide(false) + setReflectionRoute('reflection2') + saveHuangyanpeiStore() + navigate(`/${moduleName}/${starName}/reflection2`) + }, [moduleName, navigate, setReflectionRoute]) return ( <div @@ -85,30 +84,28 @@ const HuangyanpeiReflectionScene1 = () => { setShowGuideModal(false) }} audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_23.mp3')} + firstLineIndent="2em" > 同学你好,欢迎来到“跨越时空·体悟”学习模块!{'\n'} - 请登陆“时空回溯・亲历时代”体验仓,下一站:1903,川沙小学堂。{'\n'} + 请登陆“时空回溯・亲历时代”体验仓,下一站:1903年,川沙小学堂。{'\n'} 你将在“任之”校长所在的学校学习,根据所知和所学,努力完成校长给你的考验,完成全部任务后,可进入到下一阶段的学习。时空门已开,请做好准备迎接考验吧! </StudyGuideModal> <Cursor className="absolute left-68 top-55" onClick={() => { - if (!visited.has(1)) { - setShowQuestionModal(true) - } + setShowQuestionModal(true) }} read={visited.has(1)} + disabled={!isEnabled(1)} /> <Cursor className="absolute left-160 bottom-50" onClick={() => { - // 如果游戏已完成,不再打开 - if (!visited.has(2)) { - setShowGame(true) - } + setShowGame(true) }} read={visited.has(2)} + disabled={!isEnabled(2)} /> <QuestionModal @@ -120,18 +117,14 @@ const HuangyanpeiReflectionScene1 = () => { onClose={() => setShowQuestionModal(false)} /> - {/* {completed && ( + {completed && ( <Image - className="absolute bottom-0 right-0" - src={getStaticImage("huangyanpei/reflection/scene1/9btn")} + className="absolute bottom-0 right-0 z-20" + src={getStaticImage('components/place/继续前进')} button - onClick={() => { - if (moduleName && starName) { - navigate(`/${moduleName}/${starName}/reflection2`); - } - }} + onClick={handleGoNext} /> - )} */} + )} <BambooPuzzleGame visible={showGame} @@ -139,32 +132,15 @@ const HuangyanpeiReflectionScene1 = () => { setShowGame(false) }} onComplete={() => { - // 游戏完成后,显示成功提示 setShowGame(false) - setShowSuccessAlert(true) markReflectionCursorVisited(scene, 2) - }} - /> - - <SuccessAlert - visible={showSuccessAlert} - title="拼接毛竹单杠成功,同学们可以在课间之余强身健体!" - onContinue={useCallback(() => { - setShowSuccessAlert(false) - markReflectionCursorVisited(scene, 2) - }, [markReflectionCursorVisited, scene])} - /> - - <StudyGuideModal - visible={showNextGuide} - onContinue={() => { - setShowNextGuide(false) - setReflectionRoute('reflection2') + markReflectionCompletionGuideShown(scene) saveHuangyanpeiStore() - navigate(`/${moduleName}/${starName}/reflection2`) + setShowNextGuide(true) }} - verticalAlign="center" - > + /> + + <StudyGuideModal visible={showNextGuide} onContinue={handleGoNext} verticalAlign="center"> 第一关完成!下一站,浦东中学深造去。 </StudyGuideModal> </div> diff --git a/src/pages/huangyanpei/reflection/scene2.tsx b/src/pages/huangyanpei/reflection/scene2.tsx index 559b689..0905443 100644 --- a/src/pages/huangyanpei/reflection/scene2.tsx +++ b/src/pages/huangyanpei/reflection/scene2.tsx @@ -11,6 +11,7 @@ import { saveHuangyanpeiStore, loadHuangyanpeiStore, } from '@/store/huangyanpeiStore' +import { useCursorController } from '@/hooks/useCursorController' const starName = 'huangyanpei' @@ -46,6 +47,12 @@ const HuangyanpeiReflectionScene2 = () => { const visitedCursors = getVisitedReflectionCursors(scene) const visited = new Set(visitedCursors) + const { isEnabled } = useCursorController({ + initialEnabled: [1], + dependencies: { 2: [1] }, + visited: visitedCursors, + }) + const progressCount = visited.size const total = 2 const completed = progressCount >= total @@ -176,6 +183,7 @@ const HuangyanpeiReflectionScene2 = () => { setShowDialog1(true) }} read={visited.has(1)} + disabled={!isEnabled(1)} /> {/* 互动展品光标2:显示第二个选择弹窗 */} @@ -185,6 +193,7 @@ const HuangyanpeiReflectionScene2 = () => { setShowDialog2(true) }} read={visited.has(2)} + disabled={!isEnabled(2)} /> {/* 第一个选择弹窗 */} diff --git a/src/pages/huangyanpei/reflection/scene2/QuizDialog.tsx b/src/pages/huangyanpei/reflection/scene2/QuizDialog.tsx index 6b0b3c3..2e93e35 100644 --- a/src/pages/huangyanpei/reflection/scene2/QuizDialog.tsx +++ b/src/pages/huangyanpei/reflection/scene2/QuizDialog.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import { AnimatePresence, motion } from 'framer-motion' import { cn, getStaticImage } from '@/lib/utils' import { Image } from '@/components' -import { SuccessAlert } from '@/components/layout' +import { ErrorAlert } from '@/components/layout' export interface Scene2QuizDialogProps { visible?: boolean @@ -148,11 +148,9 @@ const Scene2QuizDialog = ({ visible = true, onClose, onComplete }: Scene2QuizDia </div> </div> - <SuccessAlert + <ErrorAlert visible={errorVisible} - title="" - content="是这个数目吗?你要不要再算一下!" - onContinue={handleErrorContinue} + text="是这个数目吗?你要不要再算一下!" onClose={handleErrorContinue} /> </div> diff --git a/src/pages/huangyanpei/reflection/scene3.tsx b/src/pages/huangyanpei/reflection/scene3.tsx index 9d3fc24..d9e648d 100644 --- a/src/pages/huangyanpei/reflection/scene3.tsx +++ b/src/pages/huangyanpei/reflection/scene3.tsx @@ -9,6 +9,7 @@ import type { VideoRef } from '@/components/video' import { QuizDialog } from './components' import { useHuangyanpeiStore } from '@/store/huangyanpeiStore' import { setCourseTimeProgress } from '@/lib/utils/api-common' +import { useCursorController } from '@/hooks/useCursorController' const starName = 'huangyanpei' @@ -39,6 +40,12 @@ const HuangyanpeiReflectionScene3 = () => { const visitedCursors = getVisitedReflectionCursors(scene) const visited = new Set(visitedCursors) + const { isEnabled } = useCursorController({ + initialEnabled: [2], + dependencies: { 1: [2], 3: [1, 2] }, + visited: visitedCursors, + }) + if (!moduleName) { return null } @@ -79,13 +86,13 @@ const HuangyanpeiReflectionScene3 = () => { src={getStaticImage('huangyanpei/reflection/components/喇叭1')} button onClick={() => { - // 检查是否完成前两个任务(visited.has(1) && visited.has(2)) - if (visited.has(1) && visited.has(2)) { + if (isEnabled(3)) { setShowQuizDialog(true) } else { setShowErrorAlertForQuiz(true) } }} + aria-disabled={!isEnabled(3)} /> {/* 第一段引导 */} @@ -95,23 +102,21 @@ const HuangyanpeiReflectionScene3 = () => { setShowGuide1(false) }} audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_25.mp3')} + firstLineIndent="2em" > 终于到中华职业学校啦!这里是中国近代职教的摇篮,"免学费、包分配",还能学真手艺,快来练就一技之长。 {'\n'} - 你选了学技术,那就来挑战制作中华职校的"明星产品"——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢! +         你选了学技术,那就来挑战制作中华职校的"明星产品"——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢! </StudyGuideModal> {/* 互动展品光标:组装台钳互动 */} <Cursor className="absolute right-100 bottom-55" onClick={() => { - if (visited.has(2)) { - setShowDialog(true) - } else { - setShowErrorAlert(true) - } + setShowDialog(true) }} read={visited.has(1)} + disabled={!isEnabled(1)} /> {/* 答题光标 */} @@ -121,6 +126,7 @@ const HuangyanpeiReflectionScene3 = () => { setShowQuiz(true) }} read={visited.has(2)} + disabled={!isEnabled(2)} /> {/* 组装台钳互动弹窗 */} diff --git a/src/pages/huangyanpei/reflection/scene4.tsx b/src/pages/huangyanpei/reflection/scene4.tsx index 82b6c58..d4d2d35 100644 --- a/src/pages/huangyanpei/reflection/scene4.tsx +++ b/src/pages/huangyanpei/reflection/scene4.tsx @@ -10,6 +10,7 @@ import { QuizDialog } from './components' import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' import { useHuangyanpeiStore } from '@/store/huangyanpeiStore' import { setCourseTimeProgress } from '@/lib/utils/api-common' +import { useCursorController } from '@/hooks/useCursorController' const starName = 'huangyanpei' @@ -29,6 +30,7 @@ const HuangyanpeiReflectionScene4 = () => { const videoRef = useRef<VideoRef>(null) const [showGuide1, setShowGuide1] = useState(true) const [showErrorAlertForQuiz, setShowErrorAlertForQuiz] = useState(false) + const [showSpeakerGuide, setShowSpeakerGuide] = useState(false) // 使用持久化的reflection cursor状态 const scene = 'scene4' @@ -38,6 +40,12 @@ const HuangyanpeiReflectionScene4 = () => { const visitedCursors = getVisitedReflectionCursors(scene) const visited = new Set(visitedCursors) + const { isEnabled } = useCursorController({ + initialEnabled: [1], + dependencies: { 2: [1], 3: [1, 2] }, + visited: visitedCursors, + }) + //喇叭考题弹窗 const [showQuizDialog, setShowQuizDialog] = useState(false) @@ -85,13 +93,13 @@ const HuangyanpeiReflectionScene4 = () => { src={getStaticImage('huangyanpei/reflection/components/喇叭2')} button onClick={() => { - // 检查是否完成前两个任务(visited.has(1) && visited.has(2)) - if (visited.has(1) && visited.has(2)) { + if (isEnabled(3)) { setShowQuizDialog(true) } else { setShowErrorAlertForQuiz(true) } }} + aria-disabled={!isEnabled(3)} /> <Image @@ -110,6 +118,7 @@ const HuangyanpeiReflectionScene4 = () => { setShowQuiz1(true) }} read={visited.has(1)} + disabled={!isEnabled(1)} /> {/* 互动展品光标2 */} @@ -119,12 +128,13 @@ const HuangyanpeiReflectionScene4 = () => { setShowTowelDialog(true) }} read={visited.has(2)} + disabled={!isEnabled(2)} /> {/* 选择题1:中华职校商科的核心教学目标 */} <MultipleChoiceQuizPanel visible={showQuiz1} - title={`【选题】中华职校商科的核心教学目标是什么? `} + title={`【多选】中华职校商科的核心教学目标是什么? `} options={[ { value: 'A', @@ -171,6 +181,7 @@ const HuangyanpeiReflectionScene4 = () => { onComplete={() => { // 完成拖拽后更新visited状态 markReflectionCursorVisited(scene, 2) + setShowSpeakerGuide(true) triggerPuzzleAnimation(15) }} @@ -194,10 +205,11 @@ const HuangyanpeiReflectionScene4 = () => { setShowGuide1(false) }} audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_26.mp3')} + firstLineIndent="2em" > 终于到中华职业学校啦!这里是中国近代职教的摇篮,“免学费、包分配”,还能学真手艺,快来练就一技之长。 {'\n'} - 现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢! +         现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢! </StudyGuideModal> {showVideo && ( @@ -225,6 +237,14 @@ const HuangyanpeiReflectionScene4 = () => { }} /> + <StudyGuideModal + visible={showSpeakerGuide} + onContinue={() => setShowSpeakerGuide(false)} + verticalAlign="center" + > + 请点击左上角的喇叭。 + </StudyGuideModal> + {/* 错误提示:请先完成前两个任务 */} <ErrorAlert visible={showErrorAlertForQuiz} diff --git a/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx b/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx index 15f41a8..ee3dc75 100644 --- a/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx +++ b/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx @@ -146,6 +146,14 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia [value] ) + const getTowelText = useCallback( + (towelId: string) => { + const result = value.find((item) => item.blankId === towelId) + return options.find((option) => option.value === result?.optionValue)?.label + }, + [options, value] + ) + // 样式 const overlayStyle = useMemo<React.CSSProperties>( () => ({ @@ -221,6 +229,11 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia alt="毛巾1" className="w-full h-full object-contain" /> + {getTowelText('towel1') && ( + <div className="absolute inset-0 flex items-center justify-center text-center text-3xl font-bold leading-snug text-[#432800] pointer-events-none"> + {getTowelText('towel1')} + </div> + )} </div> {/* 毛巾2 */} @@ -234,6 +247,11 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia alt="毛巾2" className="w-full h-full object-contain" /> + {getTowelText('towel2') && ( + <div className="absolute inset-0 flex items-center justify-center text-center text-3xl font-bold leading-snug text-[#432800] pointer-events-none"> + {getTowelText('towel2')} + </div> + )} </div> {/* 毛巾3 */} @@ -247,6 +265,11 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia alt="毛巾3" className="w-full h-full object-contain" /> + {getTowelText('towel3') && ( + <div className="absolute inset-0 flex items-center justify-center text-center text-3xl font-bold leading-snug text-[#432800] pointer-events-none"> + {getTowelText('towel3')} + </div> + )} </div> </div> </div> diff --git a/src/pages/luxun/internalization.tsx b/src/pages/luxun/internalization.tsx index 0c2366c..1cd52d1 100644 --- a/src/pages/luxun/internalization.tsx +++ b/src/pages/luxun/internalization.tsx @@ -112,17 +112,17 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ { value: 'B', label: '我自横刀向天笑,去留肝胆两昆仑', - explanation: '本句出自谭嗣同的《狱中题壁》,请再想想吧!', + explanation: '本句出自谭嗣同的《狱中题壁》,请你再想想吧!', }, { value: 'C', label: '此去泉台招旧部,旌旗十万斩阎罗', - explanation: '本句出自陈毅的《梅岭三章》,请再想想吧!', + explanation: '本句出自陈毅的《梅岭三章》,请你再想想吧!', }, { value: 'D', label: '雄关漫道真如铁,而今迈步从头越', - explanation: '本句出自毛泽东的《忆秦娥・娄山关》,请再想想吧!', + explanation: '本句出自毛泽东的《忆秦娥・娄山关》,请你再想想吧!', }, ], }, @@ -153,7 +153,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: 'quiz-5', - time: 60 * 9 + 53, // 9:53 + time: 60 * 9 + 53.5, // 9:53 title: '', prompt: '【多选】我们能从鲁迅身上学习到:', type: 'multiple', @@ -197,10 +197,8 @@ const LuxunInternalization = () => { const { triggerPuzzleAnimation } = usePuzzleAnimation() // 旁白关闭后显示对话引导 useEffect(() => { - if (!showNarration) { - setVisible(true) - } - }, [showNarration]) + setShowNarration(true) + }, []) const handleVideoCanPlay = () => { if (showVideo && videoRef.current) { @@ -474,9 +472,12 @@ const LuxunInternalization = () => { {/* 旁白 */} <Narration visible={showNarration} - text={`走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。那些针砭时弊的杂文、借古讽今的篇章,还有藏在6平方米客房里的红色坚守,都在诉说着先生的热忱。1907年,鲁迅先生在《文化偏至论》里写到:"是故将生存两间,角逐列国是务,其首在立人,人立而后凡事举;若其道术,乃必尊个性而张精神。"先生认为,中华民族要屹立于世界民族之林,"首在立人","立人"思想从此贯穿在鲁迅先生的人生和作品中。`} + audioSrc={getPublicPath('audio/luxun/mp3_luxun_35.mp3')} + text={`走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。那些针砭时弊的杂文、借古讽今的篇章,还有藏在6平方米客房里的红色坚守,都在诉说着先生的热忱。1907年,鲁迅先生在《文化偏至论》里写到:"是故将生存两间,角逐列国是务,其首在立人,人立而后凡事举;若其道术,乃必尊个性而张精神。"`} onClose={() => { setShowNarration(false) + setShowVideo(true) + // handleVideoCanPlay() }} /> @@ -492,7 +493,7 @@ const LuxunInternalization = () => { }} showMask={false} > - <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_35.mp3')}> + {/* <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_35.mp3')}> 走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。 </GuideStepsItem> <GuideStepsItem speaker="guide"> @@ -503,7 +504,7 @@ const LuxunInternalization = () => { </GuideStepsItem> <GuideStepsItem speaker="guide"> 先生认为,中华民族要屹立于世界民族之林,“首在立人”,“立人”思想从此贯穿在鲁迅先生的人生和作品中。”先生认为,中华民族要屹立于世界民族之林,“首在立人”,“立人”思想从此贯穿在鲁迅先生的人生和作品中。 - </GuideStepsItem> + </GuideStepsItem> */} <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_36.mp3')}> 小沪,关于"首在立人"这四个字我还是有些不理解,立人为什么这么重要? </GuideStepsItem> @@ -548,7 +549,7 @@ const LuxunInternalization = () => { onContinue={() => { setShowStudyGuide(false) setCourseTimeProgress(9) - navigate(`/${moduleName}/${starName}/home`) + navigate('/menu') }} audioSrc={getPublicPath('audio/luxun/mp3_luxun_38.mp3')} /> diff --git a/src/pages/luxun/place/place1.tsx b/src/pages/luxun/place/place1.tsx index f0680d1..0c1cc3b 100644 --- a/src/pages/luxun/place/place1.tsx +++ b/src/pages/luxun/place/place1.tsx @@ -7,7 +7,7 @@ import { Cursor, StudyGuideModal, } from '@/components' -import { MatchQuizPanel } from '@/components/layout/quiz' +import { MatchQuizPanel, MultipleChoiceQuizPanel } from '@/components/layout/quiz' import { SuccessAlert } from '@/components/layout' import { useNavigate, useParams } from 'react-router-dom' import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' @@ -59,6 +59,8 @@ const LuxunPlace1 = () => { const [showMatchQuiz, setShowMatchQuiz] = useState(false) // 连线题成功提示状态 const [showMatchQuizSuccess, setShowMatchQuizSuccess] = useState(false) + const [showExhibit2Quiz, setShowExhibit2Quiz] = useState(false) + const [showExhibit2QuizSuccess, setShowExhibit2QuizSuccess] = useState(false) // Cursor 顺序控制:1 -> 2 const { isEnabled } = useCursorController({ @@ -176,8 +178,7 @@ const LuxunPlace1 = () => { if (activeExhibitId === 1) { setShowMatchQuiz(true) } else { - // 展品2正常标记为已访问 - markExhibitVisited(place, activeExhibitId) + setShowExhibit2Quiz(true) } } setActiveExhibitId(null) @@ -301,6 +302,7 @@ const LuxunPlace1 = () => { {/* 连线题成功提示 */} <SuccessAlert + title="恭喜你,匹配正确!" visible={showMatchQuizSuccess} onContinue={() => { setShowMatchQuizSuccess(false) @@ -313,6 +315,49 @@ const LuxunPlace1 = () => { markExhibitVisited(place, 1) }} /> + + <MultipleChoiceQuizPanel + visible={showExhibit2Quiz} + title="【多选】鲁迅先生居住在上海景云里的两年半,写下的作品包括:" + options={[ + { value: 'A', label: 'A. 阿 Q 正传' }, + { value: 'B', label: 'B. 三闲集' }, + { value: 'C', label: 'C. 二心集' }, + { value: 'D', label: 'D. 朝花夕拾' }, + ]} + correctAnswer={['B', 'C']} + errorMessage={(selectedValues) => { + const picked = new Set(selectedValues) + + if (picked.has('A')) { + return '《阿 Q 正传》创作于 1921—1922 年,最初发表于《晨报副刊》,早于鲁迅居住在上海景云里时期。请你再想想吧!' + } + if (picked.has('D')) { + return '《朝花夕拾》创作于 1926 年,最初以《旧事重提》为总题发表,早于鲁迅居住在上海景云里时期。请你再想想吧!' + } + return '请你再想想吧!' + }} + onSubmit={(_values, isCorrect) => { + if (isCorrect) { + setShowExhibit2Quiz(false) + setShowExhibit2QuizSuccess(true) + } + }} + zIndex={10001} + /> + + <SuccessAlert + title="恭喜你,选择正确!" + visible={showExhibit2QuizSuccess} + onContinue={() => { + setShowExhibit2QuizSuccess(false) + markExhibitVisited(place, 2) + }} + onClose={() => { + setShowExhibit2QuizSuccess(false) + markExhibitVisited(place, 2) + }} + /> </div> ) } diff --git a/src/pages/luxun/place/place3.tsx b/src/pages/luxun/place/place3.tsx index fa4bcd2..7c31703 100644 --- a/src/pages/luxun/place/place3.tsx +++ b/src/pages/luxun/place/place3.tsx @@ -245,16 +245,17 @@ const LuxunPlace3 = () => { audioSrc={activeExhibitId === 5 ? getPublicPath('audio/luxun/mp3_luxun_23.mp3') : undefined} onClose={() => { if (activeExhibitId === 2) { - // 光标2:关闭展品框后显示题目 setActiveExhibitId(null) - setQuiz2Visible(true) } else if (activeExhibitId === 5) { // 光标5:关闭展品框后显示题目 setActiveExhibitId(null) setQuiz5Visible(true) - } else if (activeExhibitId === 1 || activeExhibitId === 3) { - // 光标1和光标3:只关闭展品,不标记为已读(等对话关闭时再标记) + } else if (activeExhibitId === 1) { + // 光标1:只关闭展品,不标记为已读(等对话关闭时再标记) setActiveExhibitId(null) + } else if (activeExhibitId === 3) { + setActiveExhibitId(null) + setQuiz2Visible(true) } else if (activeExhibitId === 4) { // 光标4:只关闭展品,不标记为已读(等对话关闭时再标记) setActiveExhibitId(null) @@ -295,7 +296,12 @@ const LuxunPlace3 = () => { {/* 光标2的引导对话 */} <GuideSteps visible={guide2Visible} - onVisibleChange={setGuide2Visible} + onVisibleChange={(visible) => { + setGuide2Visible(visible) + if (!visible) { + markExhibitVisited(place, 2) + } + }} currentStep={guide2Step} onStepChange={setGuide2Step} showMask={false} @@ -313,7 +319,7 @@ const LuxunPlace3 = () => { </GuideStepsItem> </GuideSteps> - {/* 光标2的题目 */} + {/* 光标3的题目 */} <SingleChoiceQuizPanel visible={quiz2Visible} title="【单选】鲁迅在上海大陆新村9号居住期间,翻译外国作品的核心目的不包括:" @@ -356,7 +362,7 @@ const LuxunPlace3 = () => { zIndex={10001} /> - {/* 光标2的成功提示 */} + {/* 光标3的成功提示 */} <SuccessAlert visible={successAlert2Visible} content={ @@ -364,8 +370,7 @@ const LuxunPlace3 = () => { } onContinue={() => { setSuccessAlert2Visible(false) - // 标记光标2为已读 - markExhibitVisited(place, 2) + markExhibitVisited(place, 3) }} /> @@ -374,10 +379,6 @@ const LuxunPlace3 = () => { visible={guide3Visible} onVisibleChange={(visible) => { setGuide3Visible(visible) - if (!visible) { - // 对话关闭时,标记光标3为已读 - markExhibitVisited(place, 3) - } }} currentStep={guide3Step} onStepChange={setGuide3Step} @@ -413,7 +414,7 @@ const LuxunPlace3 = () => { 小沪,这两本书看起来格外厚重,它和鲁迅在这儿的经历有什么关联呢? </GuideStepsItem> <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_22.mp3')}> - 这不是鲁迅的著作,却是他在大陆新村9号倾注了全部心血的作品——为亡友瞿秋白编辑的译文集《海上述林》。1935年6月,瞿秋白被国民党反动派杀害,鲁迅悲痛万分,当即决定要为挚友出版译文集,让他的思想和精神流传下去。 + 这不是鲁迅的著作,却是他在大陆新村9号倾注了全部心血的作品——为亡友瞿秋白编校的译文集《海上述林》。1935年6月,瞿秋白被国民党反动派杀害,鲁迅悲痛万分,当即决定要为挚友出版译文集,让他的思想和精神流传下去。 </GuideStepsItem> </GuideSteps> diff --git a/src/pages/luxun/place/place4.tsx b/src/pages/luxun/place/place4.tsx index 28e1e9d..2c03d9b 100644 --- a/src/pages/luxun/place/place4.tsx +++ b/src/pages/luxun/place/place4.tsx @@ -105,7 +105,7 @@ const LuxunPlace5 = () => { () => [ { id: 'quiz-anchor', - time: 5, + time: 41, once: true, afterResume: 'remove', render: ({ resume }) => ( @@ -244,7 +244,7 @@ const LuxunPlace5 = () => { activeExhibitId === 1 ? '瞿秋白同志曾说,"鲁迅是莱谟斯,是野兽的奶汁所喂养大的,是封建宗法社会的逆子,是绅士阶级的贰臣,而同时也是一些浪漫谛克的革命家的诤友!"' : activeExhibitId === 2 - ? '鲁迅先生曾经抄录清朝诗人何瓦琴的对联赠给瞿秋白,"人生得一知己足矣,斯世当以同怀视之"' + ? '鲁迅先生曾经抄录清朝诗人何瓦琴的对联赠给瞿秋白,"人生得一知己足矣,斯世当以同怀视之。"' : undefined } onClose={() => { @@ -358,7 +358,7 @@ const LuxunPlace5 = () => { <Video ref={videoRef} src={getPublicPath('video/luxun/luxun_06.mp4')} - className="fixed inset-0 z-[20000]" + className="fixed inset-0 z-20000" controls autoPlay playsInline diff --git a/src/pages/luxun/reflection/scene1.tsx b/src/pages/luxun/reflection/scene1.tsx index 2c4b8ab..f308bd1 100644 --- a/src/pages/luxun/reflection/scene1.tsx +++ b/src/pages/luxun/reflection/scene1.tsx @@ -294,7 +294,7 @@ const LuxunReflectionScene1 = () => { visible={true} title="" // title={`你找到了${currentCity.location}!`} - content={`${currentCity.time}\n${currentCity.description}`} + content={`${currentCity.time},\n${currentCity.description}`} onClose={handleCityAlertClose} onContinue={handleCityAlertClose} /> @@ -328,6 +328,7 @@ const LuxunReflectionScene1 = () => { setShowGuideModal(false) }} audioSrc={getPublicPath('audio/luxun/mp3_luxun_31.mp3')} + firstLineIndent="2em" > {`同学你好,欢迎来到“跨越时空·体悟”学习模块! 请登陆“时空回溯・亲历时代”体验仓,下一站:1892年,绍兴三味书屋。 diff --git a/src/pages/luxun/reflection/scene2.tsx b/src/pages/luxun/reflection/scene2.tsx index 31ec64a..a2576c6 100644 --- a/src/pages/luxun/reflection/scene2.tsx +++ b/src/pages/luxun/reflection/scene2.tsx @@ -16,31 +16,31 @@ const dragItems = [ name: '闰土', imagePath: 'luxun/reflection/scene2/拖拽-润土', targetBlankId: 'city-1-label-0', // 绍兴-人物 - errorHint: '❌闰土的故事和鲁迅的少年绍兴时光有关哦', - successMessage: '✅答对啦! 闰土是鲁迅绍兴时期笔下的经典人物', + errorHint: '❌闰土的故事和鲁迅的少年绍兴时光有关哦!', + successMessage: '✅答对啦! 闰土是鲁迅绍兴时期笔下的经典人物。', }, { id: 'baicaoyuan', name: '《从百草园到三味书屋》', imagePath: 'luxun/reflection/scene2/拖拽-百草园到三味书屋', targetBlankId: 'city-1-label-1', // 绍兴-作品 - errorHint: '❌这个作品写的是鲁迅在绍兴的故事哦', - successMessage: '✅真棒! 这篇散文记录了鲁迅在绍兴的求学时光', + errorHint: '❌这个作品写的是鲁迅在绍兴的故事哦!', + successMessage: '✅真棒! 这篇散文记录了鲁迅在绍兴的求学时光。', }, { id: 'aqzhengzhuan', name: '《阿Q正传》', imagePath: 'luxun/reflection/scene2/拖拽-阿Q正传', targetBlankId: 'city-2-label-0', // 北京-作品 - errorHint: '❌这部经典小说和鲁迅在北京的创作有关哦', - successMessage: '✅答对啦! 《阿Q正传》是鲁迅小说的代表作, 诞生于北京', + errorHint: '❌这部经典小说和鲁迅在北京的创作有关哦!', + successMessage: '✅答对啦! 《阿Q正传》是鲁迅小说的代表作, 诞生于北京。', }, { id: 'jingshenshengli', name: '"精神胜利法"', imagePath: 'luxun/reflection/scene2/拖拽-精神胜利法', targetBlankId: 'city-2-label-1', // 北京-核心主题 - errorHint: '❌这个主题是《阿Q正传》的核心, 和北京创作背景相关哦', + errorHint: '❌这个主题是《阿Q正传》的核心, 和北京创作背景相关哦!', successMessage: '✅没错! 《阿Q正传》画出了国人的灵魂, 以便疗救。', }, { @@ -48,32 +48,32 @@ const dragItems = [ name: '"关注底层"', imagePath: 'luxun/reflection/scene2/关注底层', targetBlankId: 'city-3-label-0', // 厦门-创作方向 - errorHint: '❌这个创作方向是鲁迅在厦门的核心追求哦', - successMessage: '✅答对啦!鲁迅在厦门任教时,依旧关注底层民众的命运', + errorHint: '❌这个创作方向是鲁迅在厦门的核心追求哦!', + successMessage: '✅答对啦!鲁迅在厦门任教时,依旧关注底层民众的命运。', }, { id: 'zhaohuaxishi', name: '《朝花夕拾》', imagePath: 'luxun/reflection/scene2/拖拽-朝花夕拾', targetBlankId: 'city-4-label-0', // 广州-作品集 - errorHint: '❌这个作品集和鲁迅在广州的旅居时光有关哦', - successMessage: '✅真棒!《朝花夕拾》部分篇章在广州白云楼定稿', + errorHint: '❌这个作品集和鲁迅在广州的旅居时光有关哦!', + successMessage: '✅真棒!《朝花夕拾》部分篇章在广州白云楼定稿。', }, { id: 'gushixinbian', name: '《故事新编》', imagePath: 'luxun/reflection/scene2/拖拽-故事新编', targetBlankId: 'city-5-label-0', // 上海-作品 - errorHint: '❌再试试~这个作品和上海的创作故事更相关哦', - successMessage: '✅答对啦!《故事新编》是鲁迅在上海故居完成的 经典作品集', + errorHint: '❌再试试~这个作品和上海的创作故事更相关哦!', + successMessage: '✅答对啦!《故事新编》是鲁迅在上海故居完成的 经典作品集。', }, { id: 'biweidaoqiang', name: '"笔为刀枪"', imagePath: 'luxun/reflection/scene2/笔为刀枪', targetBlankId: 'city-5-label-1', // 上海-创作中心 - errorHint: '❌这个初心是鲁迅在上海创作的核心哦', - successMessage: '✅没错!鲁迅在上海以笔为刃,批判黑暗的社会现实', + errorHint: '❌这个初心是鲁迅在上海创作的核心哦!', + successMessage: '✅没错!鲁迅在上海以笔为刃,批判黑暗的社会现实。', }, ] @@ -554,6 +554,7 @@ const LuxunReflectionScene2 = () => { showMask={false} highlightSelectors={[]} zIndex={10000} + audioChannelId="luxun-reflection-scene2-guide" > <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_32.mp3')}> 这些线索藏在先生的文字里,长按卡片拖到对应地标旁,还原创作故事! @@ -567,6 +568,7 @@ const LuxunReflectionScene2 = () => { showMask={false} highlightSelectors={[]} zIndex={10000} + audioChannelId="luxun-reflection-scene2-completion-guide" > <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_32_02.mp3')}> 你读懂了鲁迅文字与土地的联结,再深入看看文字里的初心! diff --git a/src/pages/luxun/reflection/scene3.tsx b/src/pages/luxun/reflection/scene3.tsx index 4f6d8d1..b8cbb82 100644 --- a/src/pages/luxun/reflection/scene3.tsx +++ b/src/pages/luxun/reflection/scene3.tsx @@ -362,7 +362,7 @@ const LuxunReflectionScene3 = () => { {/* 题目2:批判现实 */} <SingleChoiceQuizPanel visible={showQuiz2} - title="【单选】先生的作品《狂人日记》《阿Q正传》都在揭露的是:" + title="【单选】先生的作品《狂人日记》《阿Q正传》都在揭露的是:" options={[ { value: 'A', label: 'A. 旧时代的黑暗与愚昧' }, { value: 'B', label: 'B. 生活中的小美好' }, @@ -375,7 +375,7 @@ const LuxunReflectionScene3 = () => { {/* 题目3:家国情怀 */} <SingleChoiceQuizPanel visible={showQuiz3} - title={'【单选】先生辗转多地创作,核心追求是(\xa0\xa0\xa0\xa0)'} + title={'【单选】先生辗转多地创作,核心追求是:'} options={[ { value: 'A', label: 'A. 唤醒国人,改变社会' }, { value: 'B', label: 'B. 只为个人成名' }, @@ -436,6 +436,7 @@ const LuxunReflectionScene3 = () => { }} verticalAlign="center" audioSrc={getPublicPath('audio/luxun/mp3_luxun_33.mp3')} + firstLineIndent="2em" > 先生的每一步足迹、每一篇文字,都藏着不变的精神底色——拼出这份精神脉络,读懂先生的初心! </StudyGuideModal> diff --git a/src/pages/maozedong/internalization.tsx b/src/pages/maozedong/internalization.tsx index 08ea5e1..94ab715 100644 --- a/src/pages/maozedong/internalization.tsx +++ b/src/pages/maozedong/internalization.tsx @@ -9,10 +9,12 @@ import { SingleChoiceQuizPanel, LetterToMaozedong, EndingOverlay, + Narration, } from '@/components' import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { GuideSteps, GuideStepsItem } from '@/components' import { BasketQuizPanel, DragQuizPanel, SuccessAlert } from '@/components/layout' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' const starName = 'maozedong' @@ -20,6 +22,9 @@ const MaozedongInternalization = () => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation() + // 进页面:先播“引入视频” -> 引入对话(图1) -> 主视频(视频11) const [showIntroVideo, setShowIntroVideo] = useState(true) const [introQuizVisible, setIntroQuizVisible] = useState(false) @@ -39,6 +44,7 @@ const MaozedongInternalization = () => { // 写信、寄信动画与完成提示 const [showLetter, setShowLetter] = useState(false) const [showCompleted, setShowCompleted] = useState(false) + const [showTransition, setShowTransition] = useState(false) const videoRef = useRef<VideoRef>(null) @@ -105,7 +111,7 @@ const MaozedongInternalization = () => { }, { id: 'quiz-3', - time: 60 * 4 + 31, + time: 60 * 4 + 32, title: '', prompt: '请将你认为符合新时代践行“群众路线”思想的做法拖入篮子吧!', correct: ['A', 'C', 'D'], @@ -138,7 +144,7 @@ const MaozedongInternalization = () => { }, { id: 'quiz-4', - time: 60 * 6 + 58, + time: 60 * 6 + 59, title: '', prompt: '请将你认为符合新时代践行“独立自主”思想的做法拖入篮子吧!', correct: ['B', 'C', 'D'], @@ -174,7 +180,7 @@ const MaozedongInternalization = () => { }, { id: 'quiz-5', - time: 60 * 7 + 15, + time: 60 * 7 + 16, title: '', prompt: '毛泽东思想活的灵魂包括', correct: ['实事求是', '群众路线', '独立自主'], @@ -237,6 +243,7 @@ const MaozedongInternalization = () => { if (isCorrect) { setActiveSuccess(q.id) + triggerPuzzleAnimation(16) setResumeCallback(() => resume) } return @@ -281,6 +288,7 @@ const MaozedongInternalization = () => { if (isCorrect) { setActiveSuccess(q.id) + if (q.id === 'quiz-2') triggerPuzzleAnimation(15) setResumeCallback(() => resume) } } @@ -358,6 +366,7 @@ const MaozedongInternalization = () => { dragQuizFeedbackTypes, dragQuizValues, resumeCallback, + triggerPuzzleAnimation, ]) if (!moduleName) return null @@ -374,13 +383,24 @@ const MaozedongInternalization = () => { onClick={() => navigate(`/${moduleName}/${starName}/home`)} /> + {/* 过渡语 */} + <Narration + visible={showTransition} + onClose={() => { + setShowTransition(false) + setIntroDialogVisible(true) + }} + text="毛泽东思想是一个完整的科学思想体系,它的活的灵魂的三个基本方面——实事求是、群众路线、独立自主,是贯穿于毛泽东思想各个组成部分的立场、观点、方法,这三个方面在新时代被赋予了更加丰富的内涵和实践形式,成为习近平新时代中国特色社会主义思想的方法论基础和行动指南。" + animationSpeed={200} + /> + {/* 引入视频:进入页面自动播放(暂用其他视频代替) */} {showIntroVideo && ( <Video src={getPublicPath('video/maozedong/mzd_10.mp4')} className="h-full w-full z-20" playsInline - controls + showSkipButton autoPlay onEnded={() => { setShowIntroVideo(false) @@ -392,7 +412,7 @@ const MaozedongInternalization = () => { <SingleChoiceQuizPanel visible={introQuizVisible} zIndex={10001} - title="【单选】毛泽东思想被确立为中国共产党的指导思想是在哪次会议上( )。" + title="【单选】毛泽东思想被确立为中国共产党的指导思想是在哪次会议上?" options={[ { value: 'A', label: 'A.中共一大' }, { value: 'B', label: 'B.遵义会议' }, @@ -421,7 +441,7 @@ const MaozedongInternalization = () => { content="1945年,中共七大正式将毛泽东思想确立为党的指导思想并写入党章。" onContinue={() => { setIntroQuizSuccessVisible(false) - setIntroDialogVisible(true) + setShowTransition(true) }} /> @@ -491,7 +511,7 @@ const MaozedongInternalization = () => { animationSpeed={200} onContinue={() => { setShowCompleted(false) - navigate(`/${moduleName}/${starName}/home`) + navigate('/menu') }} /> </div> diff --git a/src/pages/maozedong/place/place1.tsx b/src/pages/maozedong/place/place1.tsx index d11845a..db396b0 100644 --- a/src/pages/maozedong/place/place1.tsx +++ b/src/pages/maozedong/place/place1.tsx @@ -304,6 +304,7 @@ const MaozedongPlace1 = () => { visible={showAfterDialogGuide} audioSrc={getPublicPath('audio/maozedong/mp3_mzd_01.mp3')} onContinue={() => setShowAfterDialogGuide(false)} + firstLineIndent="2em" > {`同学你好,欢迎来到“走进故居·对话”学习模块! 此刻你正处在毛泽东旧居陈列馆里,接下来请跟着时光的脚步,一同回望毛泽东主席人生中4段风云激荡的岁月:摸索方向 确立信仰、播撒红色火种、吹响合作号角、唤起工农群众。准备好推开这扇门,走进那段滚烫的峥嵘岁月了吗?`} diff --git a/src/pages/maozedong/reflection.tsx b/src/pages/maozedong/reflection.tsx index c6e15d3..c2886d8 100644 --- a/src/pages/maozedong/reflection.tsx +++ b/src/pages/maozedong/reflection.tsx @@ -7,9 +7,10 @@ import { StudyGuideModal, } from '@/components' import { SuccessAlert } from '@/components/layout' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' import { cn, getStaticImage } from '@/lib/utils' import { getPublicPath } from '@/lib/utils' -import { useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' // 光标组件 - 带旋转动画 @@ -34,7 +35,190 @@ const Cursor = ({ className, onClick }: { className?: string; onClick: () => voi /> ) +const firstQuizOptions = [ + { + value: 'A', + label: 'A. 基层情况对决策至关重要,关乎无产阶级领导权、工农联盟议题能否贴合实际', + }, + { + value: 'B', + label: 'B. 深入基层,收集各地党组织、工农运动实情,再推进筹备等备', + }, +] + +const secondQuizOptions = [ + { + value: 'A', + label: 'A. 以强硬指令,要求各地限时上报意见', + }, + { + value: 'B', + label: 'B. 讲清意见对四大讨论无产阶级领导权、工农联盟的重要性,以革命大义动员党员', + }, +] + +const endingQuizOptions = [ + { + value: 'A', + label: 'A. 仅安心调养', + }, + { + value: 'B', + label: 'B. 在调养修整后调研农民运动', + }, +] + +type QuizCursorProps = { + resume: () => void + unmute: () => void + onCorrect: () => void +} + +const FirstQuizCursor = ({ resume, unmute, onCorrect }: QuizCursorProps) => { + const [visible, setVisible] = useState(false) + const [showSuccessAlert, setShowSuccessAlert] = useState(false) + const hasResumedRef = useRef(false) + + return ( + <> + <Cursor + className="absolute left-320 bottom-70" + onClick={() => { + unmute() + setVisible(true) + }} + /> + <SingleChoiceQuizPanel + visible={visible} + title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?" + options={firstQuizOptions} + correctAnswer="B" + errorMessage="很抱歉,你选错了!高层快速定框架,筹备脱离基层实际,因缺乏实战支撑,在推动工农运动、明确领导权时,难以契合各地实情,革命力量动员只会受阻。请你重新做出选择吧!" + onSubmit={(_value, isCorrect) => { + if (!isCorrect) return + setVisible(false) + setShowSuccessAlert(true) + onCorrect() + }} + /> + <SuccessAlert + visible={showSuccessAlert} + content="基于对史实的理解,毛主席的选择应当是深入基层、收集真实情况,再推进各项筹备与行动。" + onContinue={() => { + if (hasResumedRef.current) return + hasResumedRef.current = true + setShowSuccessAlert(false) + resume() + }} + onClose={() => { + if (hasResumedRef.current) return + hasResumedRef.current = true + setShowSuccessAlert(false) + resume() + }} + /> + </> + ) +} + +const SecondQuizCursor = ({ resume, unmute, onCorrect }: QuizCursorProps) => { + const [visible, setVisible] = useState(false) + const [showSuccessAlert, setShowSuccessAlert] = useState(false) + const hasResumedRef = useRef(false) + + return ( + <> + <Cursor + className="absolute left-110 bottom-65" + onClick={() => { + unmute() + setVisible(true) + }} + /> + <SingleChoiceQuizPanel + visible={visible} + title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?" + options={secondQuizOptions} + correctAnswer="B" + errorMessage="很抱歉,你选错了!下达强硬指令,基层反而误陷内容空洞、应付,四大筹备缺乏真实基层素材,决策难以解决实际问题,工农联盟等议题讨论会浮于表面。请你重新做出选择吧!" + onSubmit={(_value, isCorrect) => { + if (!isCorrect) return + setVisible(false) + setShowSuccessAlert(true) + onCorrect() + }} + /> + <SuccessAlert + visible={showSuccessAlert} + title="恭喜你,选择正确!" + content="当时毛泽东采用动员方式,以“钟英”为代号发出《关于召开四大致各地党组织的信》和《关于召开四大的通知》,在通知中阐明意义,各地党组织积极响应,为四大议题积累丰富实践依据,推动筹备深入。" + onContinue={() => { + if (hasResumedRef.current) return + hasResumedRef.current = true + setShowSuccessAlert(false) + resume() + }} + onClose={() => { + if (hasResumedRef.current) return + hasResumedRef.current = true + setShowSuccessAlert(false) + resume() + }} + /> + </> + ) +} + +const EndingQuizCursor = ({ resume, unmute }: Omit<QuizCursorProps, 'onCorrect'>) => { + const [questionVisible, setQuestionVisible] = useState(false) + const [showSuccessAlert, setShowSuccessAlert] = useState(false) + const hasResumedRef = useRef(false) + + return ( + <> + <Cursor + className="absolute left-110 bottom-140" + onClick={() => { + unmute() + setQuestionVisible(true) + }} + /> + <SingleChoiceQuizPanel + visible={questionVisible} + title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?" + options={endingQuizOptions} + correctAnswer="B" + errorMessage="很抱歉,你选错了!当时如果错过调研契机,农民运动在四大后推进缺乏针对性实践参考,工农联盟思想深化缓慢,革命力量整合会受到影响。请你重新做出选择吧!" + onSubmit={(_value, isCorrect) => { + if (!isCorrect) return + setQuestionVisible(false) + setShowSuccessAlert(true) + }} + /> + <SuccessAlert + visible={showSuccessAlert} + title="恭喜你,选择正确!" + content="毛泽东回湘后,深入农村,了解农民疾苦与诉求,为后续《湖南农民运动考察报告》奠基,让四大工农联盟思想在实践中持续深化,开始了领导工农运动的新征程。" + onContinue={() => { + if (hasResumedRef.current) return + hasResumedRef.current = true + setShowSuccessAlert(false) + resume() + }} + onClose={() => { + if (hasResumedRef.current) return + hasResumedRef.current = true + setShowSuccessAlert(false) + resume() + }} + /> + </> + ) +} + const Home = () => { + const { triggerPuzzleAnimation } = usePuzzleAnimation() + const bgmRef = useRef<HTMLAudioElement>(null) const videoRef = useRef<VideoRef>(null) const navigate = useNavigate() @@ -43,400 +227,245 @@ const Home = () => { const moduleName = 'maozedong' - // 播放 BGM const playBGM = () => { bgmRef.current?.play().catch(() => {}) } - const QuizCursor = ({ resume, unmute }: { resume: () => void; unmute: () => void }) => { - const [visible, setVisible] = useState(false) - const [showSuccessAlert, setShowSuccessAlert] = useState(false) - const hasResumedRef = useRef(false) - - const options = [ + const anchors: VideoAnchor[] = useMemo( + () => [ { - value: 'A', - label: 'A. 基层情况对决策至关重要,关乎无产阶级领导权、工农联盟议题能否贴合实际', + id: 'anchor-0', + time: 3, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute left-223 top-108" + onClick={() => { + unmute() + resume() + }} + /> + ), }, { - value: 'B', - label: 'B. 深入基层,收集各地党组织、工农运动实情,再推进筹备等备', - }, - ] - - return ( - <> - <Cursor - className="absolute left-320 bottom-70" - onClick={() => { - unmute() - setVisible(true) - }} - /> - <SingleChoiceQuizPanel - visible={visible} - title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?" - options={options} - correctAnswer="B" - errorMessage="很抱歉,你选错了!高层快速定框架,筹备脱离基层实际,因缺乏实战支撑,在推动工农运动、明确领导权时,难以契合各地实情,革命力量动员只会受阻。请你重新做出选择吧!" - onSubmit={(_value, isCorrect) => { - if (!isCorrect) return - setVisible(false) - setShowSuccessAlert(true) - }} - /> - <SuccessAlert - visible={showSuccessAlert} - content="基于对史实的理解,毛主席的选择应当是深入基层、收集真实情况,再推进各项筹备与行动。" - onContinue={() => { - if (hasResumedRef.current) return - hasResumedRef.current = true - setShowSuccessAlert(false) - resume() - }} - onClose={() => { - if (hasResumedRef.current) return - hasResumedRef.current = true - setShowSuccessAlert(false) - resume() - }} - /> - </> - ) - } - - const QuizCursor2 = ({ resume, unmute }: { resume: () => void; unmute: () => void }) => { - const [visible, setVisible] = useState(false) - const [showSuccessAlert, setShowSuccessAlert] = useState(false) - const hasResumedRef = useRef(false) - - const options = [ - { - value: 'A', - label: 'A. 以强硬指令,要求各地限时上报意见', + id: 'anchor-1', + time: 8, + afterResume: 'hide', + onTrigger: () => console.log('埋点:锚点 1 触发'), + render: ({ resume, unmute }) => ( + <Cursor + className="absolute left-150 top-80" + onClick={() => { + unmute() + resume() + }} + /> + ), }, { - value: 'B', - label: 'B. 讲清意见对四大讨论无产阶级领导权、工农联盟的重要性,以革命大义动员党员', - }, - ] - - return ( - <> - <Cursor - className="absolute left-110 bottom-65" - onClick={() => { - unmute() - setVisible(true) - }} - /> - <SingleChoiceQuizPanel - visible={visible} - title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?" - options={options} - correctAnswer="B" - errorMessage="很抱歉,你选错了!下达强硬指令,基层反而误陷内容空洞、应付,四大筹备缺乏真实基层素材,决策难以解决实际问题,工农联盟等议题讨论会浮于表面。请你重新做出选择吧!" - onSubmit={(_value, isCorrect) => { - if (!isCorrect) return - setVisible(false) - setShowSuccessAlert(true) - }} - /> - <SuccessAlert - visible={showSuccessAlert} - title="恭喜你,选择正确!" - content="当前毛泽东采用动员党员方式,以“钟毯”为代号发出《关于召开四大致各地党组织的信》和《关于召开四大的通知》,在通知中阐明意见,各地党组织积极响应,为四大会议积累丰富实质依据,推动筹备深入。" - onContinue={() => { - if (hasResumedRef.current) return - hasResumedRef.current = true - setShowSuccessAlert(false) - resume() - }} - onClose={() => { - if (hasResumedRef.current) return - hasResumedRef.current = true - setShowSuccessAlert(false) - resume() - }} - /> - </> - ) - } - - const EndingCursor = ({ resume, unmute }: { resume: () => void; unmute: () => void }) => { - const [questionVisible, setQuestionVisible] = useState(false) - // const [endingVisible, setEndingVisible] = useState(false) - // const hasResumedRef = useRef(false) - - const options = [ - { - value: 'A', - label: 'A. 仅安心调养', + id: 'anchor-2', + time: 22, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute right-42 bottom-95" + onClick={() => { + unmute() + resume() + }} + /> + ), }, { - value: 'B', - label: 'B. 在调养修整后调研农民运动', + id: 'anchor-3', + time: 32, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute left-50 bottom-100" + onClick={() => { + unmute() + resume() + }} + /> + ), }, - ] - - return ( - <> - <Cursor - className="absolute left-110 bottom-140" - onClick={() => { - unmute() - setQuestionVisible(true) - }} - /> - <SingleChoiceQuizPanel - visible={questionVisible} - title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?" - options={options} - correctAnswer="B" - errorMessage="当你如果错过调研契机,农民运动在四大后推进缺乏针对性实践参考,工农联盟思想深化缓慢,革命力量整合会受影响。请你重新做出选择吧!" - onSubmit={(_value, isCorrect) => { - if (!isCorrect) return - setQuestionVisible(false) - setEndingVisible(true) - }} - /> - {/* <EndingOverlay - visible={endingVisible} - animationSpeed={200} - text="恭喜你已经顺利完成了毛泽东故居的学习,请继续前往下一个故居吧!" - onContinue={() => { - if (hasResumedRef.current) return - hasResumedRef.current = true - setEndingVisible(false) - resume() - navigate(`/${moduleName}/maozedong/home`) - }} - /> */} - </> - ) - } - - const anchors: VideoAnchor[] = [ - { - id: 'anchor-0', - time: 3, - afterResume: 'hide', // 恢复后隐藏,seek 回来可重新触发 - render: ({ resume, unmute }) => ( - <Cursor - className="absolute left-223 top-108" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-1', - time: 8, - afterResume: 'hide', // 恢复后隐藏,seek 回来可重新触发 - onTrigger: () => console.log('埋点:锚点 1 触发'), - render: ({ resume, unmute }) => ( - <Cursor - className="absolute left-150 top-80" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-2', - time: 22, - afterResume: 'hide', - render: ({ resume, unmute }) => ( - <Cursor - className="absolute right-42 bottom-95" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-3', - time: 32, - afterResume: 'hide', - render: ({ resume, unmute }) => ( - <Cursor - className="absolute left-50 bottom-100" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-4', - time: 40, - afterResume: 'hide', - render: ({ resume, unmute }) => ( - <Cursor - className="absolute right-158 bottom-105" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-4-1', - time: 52, - afterResume: 'hide', - render: ({ resume, unmute }) => ( - <Cursor - className="absolute left-223 top-135" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-5', - time: 56, - afterResume: 'hide', - render: ({ resume, unmute }) => ( - <Cursor - className="absolute right-58 bottom-115" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-6', - time: 74, - afterResume: 'hide', - render: ({ resume, unmute }) => ( - <Cursor - className="absolute left-80 bottom-110" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-6-1', - time: 83, - afterResume: 'hide', - render: ({ resume, unmute }) => <QuizCursor resume={resume} unmute={unmute} />, - }, - { - id: 'anchor-7', - time: 88, - afterResume: 'hide', - render: ({ resume, unmute }) => ( - <Cursor - className="absolute left-220 bottom-100" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-8', - time: 95, - afterResume: 'hide', - render: ({ resume, unmute }) => ( - <Cursor - className="absolute left-243 bottom-100" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-9', - time: 107, - afterResume: 'hide', - render: ({ resume, unmute }) => ( - <Cursor - className="absolute left-375 bottom-100" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-10', - time: 116, - afterResume: 'hide', - render: ({ resume, unmute }) => <QuizCursor2 resume={resume} unmute={unmute} />, - }, - { - id: 'anchor-11', - time: 120, - afterResume: 'hide', - render: ({ resume, unmute }) => ( - <Cursor - className="absolute left-220 bottom-100" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-12', - time: 127, - afterResume: 'hide', - render: ({ resume, unmute }) => ( - <Cursor - className="absolute left-320 bottom-120" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-13', - time: 141, - afterResume: 'hide', - render: ({ resume, unmute }) => ( - <Cursor - className="absolute left-248 bottom-130" - onClick={() => { - unmute() - resume() - }} - /> - ), - }, - { - id: 'anchor-14', - time: 153, - afterResume: 'hide', - render: ({ resume, unmute }) => <EndingCursor resume={resume} unmute={unmute} />, - }, - ] + { + id: 'anchor-4', + time: 40, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute right-158 bottom-105" + onClick={() => { + unmute() + resume() + }} + /> + ), + }, + { + id: 'anchor-4-1', + time: 52, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute left-223 top-135" + onClick={() => { + unmute() + resume() + }} + /> + ), + }, + { + id: 'anchor-5', + time: 60 + 8, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute right-58 bottom-115" + onClick={() => { + unmute() + resume() + }} + /> + ), + }, + { + id: 'anchor-6', + time: 60 + 25, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute left-80 bottom-110" + onClick={() => { + unmute() + resume() + }} + /> + ), + }, + { + id: 'anchor-6-1', + time: 60 + 34, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <FirstQuizCursor + resume={resume} + unmute={unmute} + onCorrect={() => triggerPuzzleAnimation(13)} + /> + ), + }, + { + id: 'anchor-7', + time: 60 + 38, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute left-220 bottom-100" + onClick={() => { + unmute() + resume() + }} + /> + ), + }, + { + id: 'anchor-8', + time: 60 * 2, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute left-243 bottom-100" + onClick={() => { + unmute() + resume() + }} + /> + ), + }, + { + id: 'anchor-9', + time: 60 * 2 + 11.5, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute left-375 bottom-100" + onClick={() => { + unmute() + resume() + }} + /> + ), + }, + { + id: 'anchor-10', + time: 60 * 2 + 21, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <SecondQuizCursor + resume={resume} + unmute={unmute} + onCorrect={() => triggerPuzzleAnimation(14)} + /> + ), + }, + { + id: 'anchor-11', + time: 60 * 2 + 24, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute left-220 bottom-100" + onClick={() => { + unmute() + resume() + }} + /> + ), + }, + { + id: 'anchor-12', + time: 60 * 2 + 43, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute left-320 bottom-120" + onClick={() => { + unmute() + resume() + }} + /> + ), + }, + { + id: 'anchor-13', + time: 60 * 2 + 57, + afterResume: 'hide', + render: ({ resume, unmute }) => ( + <Cursor + className="absolute left-248 bottom-130" + onClick={() => { + unmute() + resume() + }} + /> + ), + }, + { + id: 'anchor-14', + time: 60 * 3 + 9, + afterResume: 'hide', + render: ({ resume, unmute }) => <EndingQuizCursor resume={resume} unmute={unmute} />, + }, + ], + [triggerPuzzleAnimation] + ) return ( <div> - {/* BGM 循环播放 */} <audio ref={bgmRef} src={getPublicPath('audio/BGM.mp3')} loop autoPlay /> <Video ref={videoRef} @@ -459,6 +488,7 @@ const Home = () => { playBGM() videoRef.current?.play() }} + firstLineIndent="2em" > {`同学你好,欢迎来到“跨越时空·体悟”学习模块! 请登陆“时空回溯・亲历时代”体验仓,下一站:1924年,上海。 diff --git a/src/pages/nieer/internalization.tsx b/src/pages/nieer/internalization.tsx index f80f00c..06910e8 100644 --- a/src/pages/nieer/internalization.tsx +++ b/src/pages/nieer/internalization.tsx @@ -13,6 +13,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { GuideSteps, GuideStepsItem } from '@/components' import { SuccessAlert } from '@/components/layout' import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' const starName = 'nieer' @@ -36,7 +37,7 @@ type QuizAnchor = { const QUIZ_ANCHORS: QuizAnchor[] = [ { id: 'quiz-1', - time: 60 * 2, // 2:00 + time: 60 * 3, type: 'single', title: '【单选】《义勇军进行曲》法定管弦乐配器版的创作者是:', options: [ @@ -79,7 +80,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: 'quiz-3', - time: 60 * 6 + 31, // 6:31 + time: 60 * 6 + 31.5, // 6:31 type: 'multiple', title: '【多选】聂耳的音乐作品具有鲜明的“人民性”,主要体现在:', options: [ @@ -97,7 +98,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: 'quiz-4', - time: 60 * 7 + 11, // 7:11 + time: 60 * 7 + 11.5, // 7:11 type: 'single', title: '【判断】聂耳在创作中注重挖掘中华优秀传统音乐文化元素,《翠湖春晓》《金蛇狂舞》就是他改编传统音乐的经典作品。', @@ -119,7 +120,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: 'quiz-5', - time: 60 * 9 + 14, // 9:14 + time: 60 * 9 + 14.5, // 9:14 type: 'multiple', title: '【多选】聂耳能创作出众多兼具传唱力与思想性的经典作品,源于他身上具有以下宝贵闪光点:', options: [ @@ -140,9 +141,12 @@ const NieerInternalization = () => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() - const [narrationVisible, setNarrationVisible] = useState(true) + const { triggerPuzzleAnimation } = usePuzzleAnimation() + + const [narrationVisible, setNarrationVisible] = useState(false) const [visible, setVisible] = useState(false) const [showVideo, setShowVideo] = useState(false) + const [showPostNarration, setShowPostNarration] = useState(false) const [showLetter, setShowLetter] = useState(false) const [showStudyGuide, setShowStudyGuide] = useState(false) @@ -153,10 +157,8 @@ const NieerInternalization = () => { const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null) useEffect(() => { - if (!narrationVisible) { - setVisible(true) - } - }, [narrationVisible]) + setNarrationVisible(true) + }, []) const handleVideoCanPlay = () => { if (showVideo && videoRef.current) { @@ -196,6 +198,7 @@ const NieerInternalization = () => { /> <SuccessAlert visible={activeSuccess === item.id} + title={item.id === 'quiz-4' ? '恭喜你, 判断正确!' : '恭喜你, 选择正确!'} content={successContent} onContinue={() => { setActiveSuccess(null) @@ -233,6 +236,12 @@ const NieerInternalization = () => { onSubmit={(values: string[], isCorrect: boolean) => { if (isCorrect) { setActiveSuccess(item.id) + if (item.id === 'quiz-2') { + triggerPuzzleAnimation(15) + } + if (item.id === 'quiz-5') { + triggerPuzzleAnimation(16) + } setSuccessContent('') setResumeCallback(() => resume) } @@ -253,7 +262,7 @@ const NieerInternalization = () => { ), } }) - }, [activeSuccess, resumeCallback, successContent]) + }, [activeSuccess, resumeCallback, successContent, triggerPuzzleAnimation]) if (!moduleName) return null @@ -273,8 +282,12 @@ const NieerInternalization = () => { {/* 旁白 */} <Narration visible={narrationVisible} + audioChannelId="nieer-internalization-narration" + audioSrc={getPublicPath('audio/nieer/mp3_nieer_36.mp3')} + animationSpeed={200} onClose={() => { setNarrationVisible(false) + setVisible(true) }} text="从旧居亭子间的不眠之夜到国歌展示馆的激昂旋律,聂耳以青春为火,以音符为刃,在民族危亡的黑暗中奏响了救亡图存的强音。《义勇军进行曲》从电影主题曲到中华人民共和国国歌,从抗战年代的冲锋号到新时代的复兴曲,跨越近百年时光,依然是凝聚民族力量的精神纽带。今天,站在新时代的舞台上,我们更要读懂聂耳的作品与精神内核,让这份旋律中的力量内化于心、外化于行。" /> @@ -282,6 +295,7 @@ const NieerInternalization = () => { {/* 视频前引导 */} <GuideSteps visible={visible} + audioChannelId="nieer-internalization-guide-pre" onVisibleChange={(v) => { setVisible(v) if (!v) { @@ -291,10 +305,10 @@ const NieerInternalization = () => { }} showMask={false} > - <GuideStepsItem speaker="student"> + <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_37.mp3')}> 小沪,前两个模块中,我循着聂耳的足迹读懂了旋律背后的家国情怀,亲历了国歌跨越时空的动人故事。我还想从音乐这一艺术创作角度出发更多了解聂耳,你能给我讲讲吗? </GuideStepsItem> - <GuideStepsItem speaker="guide"> + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_38.mp3')}> 讲到音乐,可是很专业的领域了!今天我们有幸邀请到了上海音乐学院党委宣传部副部长,东方乐器博物馆馆长,研究员,肖阳。肖老师会从上海音乐学院对聂耳的研究、与《义勇军进行曲》的渊源的角度告诉你聂耳的音乐作品与时代价值。 </GuideStepsItem> <GuideStepsItem speaker="guide"> @@ -314,11 +328,23 @@ const NieerInternalization = () => { onCanPlay={handleVideoCanPlay} onEnded={() => { setShowVideo(false) - setTimeout(() => setShowLetter(true), 300) + setTimeout(() => setShowPostNarration(true), 300) }} /> )} + <Narration + visible={showPostNarration} + audioChannelId="nieer-internalization-narration-post" + audioSrc={getPublicPath('audio/nieer/mp3_nieer_39.mp3')} + animationSpeed={200} + onClose={() => { + setShowPostNarration(false) + setShowLetter(true) + }} + text="的确如戏!这份精神,不仅是音乐创作的指引,更是广大青年学子成长路上的精神灯塔,指引大家在专业学习中精益求精,在青春岁月里奋勇前行。此刻,我们仿佛依然站在聂耳旧居的亭子间里,仿佛还能看到先生熬夜打磨旋律的身影,感受到他心中滚烫的家国情怀。现在,就请你静下心来,把对聂耳的崇敬、对国歌精神的体悟和对自己未来的期许,一字一句写成一封信,完成与先生跨越时空的对话吧!" + /> + {/* 写信组件 */} <LetterToNieer visible={showLetter} @@ -334,11 +360,12 @@ const NieerInternalization = () => { {/* 内化结束 */} <EndingOverlay visible={showStudyGuide} + audioSrc={getPublicPath('audio/nieer/mp3_nieer_40.mp3')} text="恭喜你圆满完成聂耳旧居的学习之旅,请带着研学感悟,前往张乐平故居继续探访吧~" animationSpeed={200} onContinue={() => { setShowStudyGuide(false) - navigate(`/${moduleName}/${starName}/home`) + navigate('/menu') }} /> </div> diff --git a/src/pages/nieer/place/place1.tsx b/src/pages/nieer/place/place1.tsx index 79c1a8b..ef0c9b3 100644 --- a/src/pages/nieer/place/place1.tsx +++ b/src/pages/nieer/place/place1.tsx @@ -17,6 +17,7 @@ import { useRef, useState } from 'react' import Cursor from '@/components/cursor' import { useNieerStore, saveNieerStore } from '@/store/nieerStore' import { useCursorController } from '@/hooks/useCursorController' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' // starName 固定为 nieer const starName = 'nieer' @@ -32,6 +33,9 @@ const NieerPlace1 = () => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation() + const { markExhibitVisited, getVisitedExhibits } = useNieerStore() const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace) @@ -50,9 +54,7 @@ const NieerPlace1 = () => { const [guide2Step, setGuide2Step] = useState(0) const [quiz2Visible, setQuiz2Visible] = useState(false) const [successAlert2Visible, setSuccessAlert2Visible] = useState(false) - const [afterQuizGuide2Visible, setAfterQuizGuide2Visible] = useState(false) - const [afterQuizGuide2Step, setAfterQuizGuide2Step] = useState(0) - const [showVideo2, setShowVideo2] = useState(false) + const [video2Step, setVideo2Step] = useState<1 | 2 | null>(null) const video2Ref = useRef<VideoRef>(null) // 光标3的对话状态 @@ -163,6 +165,17 @@ const NieerPlace1 = () => { <ExhibitOverlay visible={activeExhibitId !== null} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} + onOpen={() => { + if (activeExhibitId === 3) { + triggerPuzzleAnimation(1) + } + if (activeExhibitId === 1) { + triggerPuzzleAnimation(2) + } + if (activeExhibitId === 2) { + triggerPuzzleAnimation(3) + } + }} onClose={() => { const closingId = activeExhibitId setActiveExhibitId(null) @@ -199,28 +212,16 @@ const NieerPlace1 = () => { highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="student" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_11.mp3')} - > + <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_05.mp3')}> 这里提到聂耳发表了《中国歌舞短论》,批评脱离民众的音乐,这是为什么呢? </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_12.mp3')} - > + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_06.mp3')}> 你观察得很细致!这时的聂耳在明月歌舞剧社做练习生,社长李锦晖看中了他的潜力,虽然他当时小提琴水平不算专业,还是录取了他,还安排首席小提琴手专门辅导他。聂耳特别刻苦,每天至少练7个小时小提琴,很快就成长为首席小提琴手。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_03.mp3')} - > - 但好景不长,1931年九一八事变后,全国抗日救亡浪潮高涨,可还存在很多脱离现实的靡靡之音,这让满怀爱国热忱的聂耳特别愤懑。 + <GuideStepsItem speaker="guide"> + 但好景不长,1931年九·一八事变后,全国抗日救亡浪潮高涨,可还存在很多脱离现实的靡靡之音,这让满怀爱国热忱的聂耳特别愤懑。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_04.mp3')} - > + <GuideStepsItem speaker="guide"> 1932年,他以“黑天使”为笔名在《电影艺术》杂志刊登《中国歌舞短论》,猛烈评击“软豆腐”式的音乐,提出我们需要“真刀真枪的硬功夫”,主张音乐要贴近民众、为抗日救亡服务。后来他离开了歌舞剧社。 </GuideStepsItem> </GuideSteps> @@ -235,16 +236,10 @@ const NieerPlace1 = () => { highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="student" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_21.mp3')} - > + <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_07.mp3')}> 离开歌舞剧社后,聂耳就加入左翼文艺团体了吗? </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_22.mp3')} - > + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_08.mp3')}> 没错!离开歌舞剧社后,聂耳结识了进步文艺家田汉,两人成为忘年交。1933年,在田汉的介绍下,聂耳秘密加入了中国共产党,之后就成为左翼剧联“音乐小组”的核心成员,还发起组织了“中国新兴音乐研究会”。 </GuideStepsItem> </GuideSteps> @@ -259,34 +254,19 @@ const NieerPlace1 = () => { highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="student" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_01.mp3')} - > - 小沪,聂耳是在九一八事变爆发后来到的上海吗?他为什么来上海呢? + <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_03.mp3')}> + 小沪,聂耳是在九·一八事变爆发后来到的上海吗?他为什么来上海呢? </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_02.mp3')} - > - 九一八事变爆发前,聂耳就已经来到上海了,核心原因是躲避迫害。聂耳中学就读的云南省立第一师范学校是昆明学生运动的中心,在那里他接触到了马克思主义进步思想,还秘密加入了中国共产主义青年团。 + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_04.mp3')}> + 九·一八事变爆发前,聂耳就已经来到上海了,核心原因是躲避迫害。聂耳中学就读的云南省立第一师范学校是昆明学生运动的中心,在那里他接触到了马克思主义进步思想,还秘密加入了中国共产主义青年团。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_03.mp3')} - > + <GuideStepsItem speaker="guide"> 也因为他积极参加学生运动,1930年被国民党当局列入了抓捕黑名单,在家人帮助下,他顶替哥哥的身份逃离昆明来到上海。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_04.mp3')} - > - 来到上海后,1931年九一八事变爆发,继而1932年一·二八事变爆发,聂耳亲眼看见了日军进犯上海的暴行,也目睹了上海民众踊跃投身抗日的热烈场景。 + <GuideStepsItem speaker="guide"> + 来到上海后,1931年九·一八事变爆发,继而1932年一·二八事变爆发,聂耳亲眼看见了日军进犯上海的暴行,也目睹了上海民众踊跃投身抗日的热烈场景。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_04.mp3')} - > + <GuideStepsItem speaker="guide"> 这些亲身经历让他深刻意识到,音乐不能只是娱乐,更能成为唤醒民众、凝聚抗战士气的“武器”。正是这份在上海的所见所感,让他坚定了“音乐救国”的方向,也为《义勇军进行曲》注入了源于现实的痛感与抗争决心。 </GuideStepsItem> </GuideSteps> @@ -321,60 +301,43 @@ const NieerPlace1 = () => { title="恭喜你, 选择正确!" onContinue={() => { setSuccessAlert2Visible(false) - markExhibitVisited(place, 2) - saveNieerStore() - setAfterQuizGuide2Step(0) - setAfterQuizGuide2Visible(true) + setVideo2Step(1) }} onClose={() => { setSuccessAlert2Visible(false) - markExhibitVisited(place, 2) - saveNieerStore() - setAfterQuizGuide2Step(0) - setAfterQuizGuide2Visible(true) + setVideo2Step(1) }} /> - {/* 光标2答题结束后的对话(图3) */} - <GuideSteps - visible={afterQuizGuide2Visible} - currentStep={afterQuizGuide2Step} - onStepChange={setAfterQuizGuide2Step} - onVisibleChange={(nextVisible) => { - setAfterQuizGuide2Visible(nextVisible) - if (!nextVisible) { - setShowVideo2(true) - } - }} - showMask={false} - highlightSelectors={[]} - zIndex={10001} - > - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_23.mp3')} - > - 这一时期他深入工厂、码头,了解底层民众的疾苦,创作了一大批进步歌曲。比如《开矿歌》,他专门去矿山采风,把工人的劳动场景融入旋律,还唱给工人听,直到他们满意为止; - </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_24.mp3')} - > - 《卖报歌》则是他看到卖报小女孩“小毛头”的艰辛生活后创作的,还根据小女孩的建议修改了歌词,特别贴近生活。这些创作都为他后来写《义勇军进行曲》积累了深厚的功底。 - </GuideStepsItem> - </GuideSteps> - - {/* 光标2对话结束后的视频(占位视频地址) */} - {showVideo2 && ( + {/* 光标2连续视频(占位视频地址) */} + {video2Step && ( <Video ref={video2Ref} className="fixed inset-0 z-20000" - src={getPublicPath('video/chenyun/chenyun_01.mp4')} + src={getPublicPath( + video2Step === 1 ? 'video/chenyun/chenyun_01.mp4' : 'video/nieer/nieer_2.mp4' + )} autoPlay playsInline showSkipButton - onEnded={() => setShowVideo2(false)} - onSkip={() => setShowVideo2(false)} + onEnded={() => { + if (video2Step === 1) { + setVideo2Step(2) + return + } + markExhibitVisited(place, 2) + saveNieerStore() + setVideo2Step(null) + }} + onSkip={() => { + if (video2Step === 1) { + setVideo2Step(2) + return + } + markExhibitVisited(place, 2) + saveNieerStore() + setVideo2Step(null) + }} /> )} @@ -427,6 +390,7 @@ const NieerPlace1 = () => { setShowAfterDialogGuide(true) }} ailgn="left" + audioSrc={getPublicPath('audio/nieer/mp3_nieer_02.mp3')} > 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。 {'\n'} @@ -439,6 +403,7 @@ const NieerPlace1 = () => { <StudyGuideModal visible={showAfterDialogGuide} onContinue={() => setShowAfterDialogGuide(false)} + audioSrc={getPublicPath('audio/nieer/mp3_nieer_01.mp3')} > 同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'} 此刻你正位立于国歌展示馆的入口,请随时光的脚步,在聂耳作品的“时光声浪”中聆听五段炽热的时代回响:民族危亡的怒吼声、时代呼唤的最强音、民族解放的冲锋号、强我中华的进行曲、伟大复兴的主旋律。你准备好跟随聂耳的音符,踏上这段炽热征程了吗? diff --git a/src/pages/nieer/place/place2.tsx b/src/pages/nieer/place/place2.tsx index 42eba70..31a76eb 100644 --- a/src/pages/nieer/place/place2.tsx +++ b/src/pages/nieer/place/place2.tsx @@ -5,11 +5,15 @@ import { useMemo, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useNieerStore, saveNieerStore } from '@/store/nieerStore' import { useCursorController } from '@/hooks/useCursorController' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' const NieerPlace2 = () => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation() + const { markExhibitVisited, getVisitedExhibits } = useNieerStore() const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace) @@ -22,9 +26,8 @@ const NieerPlace2 = () => { const [guide2Visible, setGuide2Visible] = useState(false) const [guide2Step, setGuide2Step] = useState(0) - // 完成所有展品与对话后自动播放视频,视频结束后再显示“继续前进” - const [showCompleteVideo, setShowCompleteVideo] = useState(false) - const [completeVideoPlayed, setCompleteVideoPlayed] = useState(false) + // 展品2关闭后播放视频,视频结束后再修改光标状态 + const [showVideo2, setShowVideo2] = useState(false) const starName = 'nieer' const place = 'place2' @@ -116,19 +119,27 @@ const NieerPlace2 = () => { <ExhibitOverlay visible={activeExhibitId !== null} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} + onOpen={() => { + if (activeExhibitId === 1) { + triggerPuzzleAnimation(4) + } + if (activeExhibitId === 2) { + triggerPuzzleAnimation(5) + } + }} onClose={() => { const closingId = activeExhibitId setActiveExhibitId(null) setGuide1Visible(false) setGuide2Visible(false) + if (closingId === 2) { + setShowVideo2(true) + return + } if (closingId !== null) { markExhibitVisited(place, closingId) saveNieerStore() } - // 直接在展品2窗口关闭后播放视频(受 useCursorController 控制顺序保障) - if (closingId === 2 && !completeVideoPlayed) { - setShowCompleteVideo(true) - } }} /> @@ -142,22 +153,13 @@ const NieerPlace2 = () => { highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="student" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_01.mp3')} - > + <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_09.mp3')}> 这盒《风云儿女》的胶片太有年代感了!聂耳是怎么和《风云儿女》这部电影结缘,并给这部电影谱主题歌《义勇军进行曲》的啊? </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_02.mp3')} - > + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_10.mp3')}> 1935年的时候,日本帝国主义已经把侵略的魔爪伸向华北,中华民族到了生死存亡的关键时刻,全国人民都渴望有能凝聚斗志的精神力量。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_03.mp3')} - > + <GuideStepsItem speaker="guide"> 左翼文艺界想通过拍摄《风云儿女》这部抗日电影,让更多人看清侵略者的野心,唤醒大家的抗争意识,而电影的主题歌必须得有“振聋发聩”的力量。 </GuideStepsItem> </GuideSteps> @@ -172,31 +174,39 @@ const NieerPlace2 = () => { highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_03.mp3')} - > + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_11.mp3')}> 1935年2月,田汉创作了《风云儿女》剧本梗概,写下《义勇军进行曲》歌词后不久被捕,田汉的战友辗转把歌词转交给左翼剧联,聂耳得知后第一时间就主动申请完成曲创作。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_04.mp3')} - > + <GuideStepsItem speaker="guide"> 他之所以这么积极,一方面是和田汉的革命情谊——两人都是左翼文艺运动的核心力量,早就约定要一起用文艺为抗日救亡服务; </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_05.mp3')} - > + <GuideStepsItem speaker="guide"> 更重要的是他对时代的感知,之前创作《开矿歌》《卖报歌》时,他就深入底层了解民众的苦难,清楚地知道大家需要什么样的声音。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_06.mp3')} - > + <GuideStepsItem speaker="guide"> 他当时说“这歌词太有力量了,我一定要把它谱成能让全中国人民都唱起来的歌”,这份信心来自他对民众心声的把握,也来自对救国使命的坚守。 </GuideStepsItem> </GuideSteps> + + {showVideo2 && ( + <Video + className="fixed inset-0 z-20000" + src={getPublicPath('video/nieer/nieer_4.mp4')} + autoPlay + playsInline + showSkipButton + onEnded={() => { + markExhibitVisited(place, 2) + saveNieerStore() + setShowVideo2(false) + }} + onSkip={() => { + markExhibitVisited(place, 2) + saveNieerStore() + setShowVideo2(false) + }} + /> + )} </div> ) } diff --git a/src/pages/nieer/place/place3.tsx b/src/pages/nieer/place/place3.tsx index 4138be3..697accf 100644 --- a/src/pages/nieer/place/place3.tsx +++ b/src/pages/nieer/place/place3.tsx @@ -15,11 +15,15 @@ import { useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useNieerStore, saveNieerStore } from '@/store/nieerStore' import { useCursorController } from '@/hooks/useCursorController' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' const NieerPlace3 = () => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation() + const { markExhibitVisited, getVisitedExhibits } = useNieerStore() const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace) @@ -145,6 +149,14 @@ const NieerPlace3 = () => { <ExhibitOverlay visible={activeExhibitId !== null} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} + onOpen={() => { + if (activeExhibitId === 1) { + triggerPuzzleAnimation(6) + } + if (activeExhibitId === 3) { + triggerPuzzleAnimation(7) + } + }} onClose={() => { const closingId = activeExhibitId setActiveExhibitId(null) @@ -179,37 +191,20 @@ const NieerPlace3 = () => { highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="student" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_01.mp3')} - > + <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_12.mp3')}> 这份聂耳手写的《义勇军进行曲》的曲谱太珍贵了,在当时那么动乱的年代,他是怎么创作出这么激昂的旋律的? </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_02.mp3')} - > + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_13.mp3')}> 1935年3月,聂耳在上海霞飞路(现淮海中路1258号聂耳旧居)完成了《义勇军进行曲》的曲谱初稿的创作。 </GuideStepsItem> - - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_03.mp3')} - > + <GuideStepsItem speaker="guide"> 当时他住的是一个亭子间,那里又小又暗,连一张像样的桌子都没有,为了找到最贴合歌词的旋律,他每天天不亮就起来哼唱,用筷子敲着桌子打节拍,有时候灵感来了,半夜还会起来修改,邻居都能听到他的歌声。 </GuideStepsItem> - - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_04.mp3')} - > + <GuideStepsItem speaker="guide"> 草稿上的修改痕迹,藏着他的用心——比如他反复调整音符的节奏,把原本偏舒缓的旋律改得更急促、更有冲击力,就是想让旋律能跟上战士冲锋的步伐;还优化了歌词和旋律的搭配,让每一句都能唱得铿锵有力。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_05.mp3')} - > - 他还专门去电通公司唱给同事听,把工人、学生、战士的反馈都记下来,一点点打磨。他还专门去电通公司唱给同事听,把工人、学生、战士的反馈都记下来,一点点打磨。 + <GuideStepsItem speaker="guide"> + 他还专门去电通公司唱给同事听,把工人、学生、战士的反馈都记下来,一点点打磨。 </GuideStepsItem> </GuideSteps> @@ -223,28 +218,16 @@ const NieerPlace3 = () => { highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="student" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_03.mp3')} - > + <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_14.mp3')}> 原来初稿就倾注了这么多心血!他当时就是用这把小提琴创作的吗? </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_04.mp3')} - > + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_15.mp3')}> 这把小提琴其实是演员赵丹在电影《聂耳》中饰演聂耳时用的道具,不是聂耳本人的。你看到的抱着小提琴的聂耳的照片的拍摄地点已经是在海外了。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_05.mp3')} - > + <GuideStepsItem speaker="guide"> 当时他因为创作了太多进步歌曲,被国民党当局列入了黑名单,继续留在上海会有生命危险,因此他受党的指示前往海外暂避。但他心里一直惦记着谱曲任务,到海外后没敢耽误,立刻投入修改。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_06.mp3')} - > + <GuideStepsItem speaker="guide"> 虽然身在海外,但他通过海外华人社团、抗日刊物,一直关注着国内的抗日浪潮,还收集了海外华人传唱抗日歌曲的反馈,这些都成了他修改的重要参考。 </GuideStepsItem> </GuideSteps> @@ -257,7 +240,7 @@ const NieerPlace3 = () => { src={ videoForExhibitId === 1 ? getPublicPath('video/nieer/nieer_5.mp4') - : getPublicPath('video/chenyun/chenyun_01.mp4') + : getPublicPath('video/chenyun/chenyun_01.mp4') //正确地址nieer_02目前没有,暂时使用其他代替 } autoPlay playsInline diff --git a/src/pages/nieer/place/place4.tsx b/src/pages/nieer/place/place4.tsx index e881302..181a7cb 100644 --- a/src/pages/nieer/place/place4.tsx +++ b/src/pages/nieer/place/place4.tsx @@ -5,11 +5,15 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useNieerStore, saveNieerStore } from '@/store/nieerStore' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' const NieerPlace4 = () => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation() + const starName = 'nieer' const place = 'place4' const { markExhibitVisited, getVisitedExhibits } = useNieerStore() @@ -109,6 +113,14 @@ const NieerPlace4 = () => { <ExhibitOverlay visible={activeExhibitId !== null} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} + onOpen={() => { + if (activeExhibitId === 2) { + triggerPuzzleAnimation(8) + } + if (activeExhibitId === 1) { + triggerPuzzleAnimation(9) + } + }} onClose={() => { const closingId = activeExhibitId setActiveExhibitId(null) @@ -131,22 +143,13 @@ const NieerPlace4 = () => { highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="student" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place4_01.mp3')} - > + <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_16.mp3')}> 随着《风云儿女》的上映,田汉作词、聂耳谱曲的《义勇军进行曲》真如聂耳所期待的一样,传遍了全国吧! </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place4_02.mp3')} - > + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_17.mp3')}> 1935年《风云儿女》公映后,这首歌就跟着影片走进了全国各地的影院,看完电影的观众都忍不住跟着哼唱。当时的百代唱片公司还专门录制了这首歌的唱片,在街头巷尾的唱片店、收音机里循环播放。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place4_03.mp3')} - > + <GuideStepsItem speaker="guide"> 因为它朗朗上口,哪怕是不识字的老百姓,教几遍就能学会,很快就成了街头巷尾、田间地头都能听到的旋律,真正做到了家喻户晓。 </GuideStepsItem> </GuideSteps> @@ -161,22 +164,13 @@ const NieerPlace4 = () => { highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="student" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place4_03.mp3')} - > + <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_18.mp3')}> 这张指挥千人大合唱的照片好震撼!除了日常传唱,这首歌在抗战时期还发挥了哪些特别的作用呢? </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place4_04.mp3')} - > + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_19.mp3')}> 它的作用可太大了!你看这张1936年的照片,刘良模先生在上海南市公共体育场指挥数千人齐唱,就连前来镇压的警察都被感染,跟着一起唱。抗战期间,抗日军队的行军路上、根据地的文艺宣传队里、城市的救亡集会中,都能听到这首歌的声音。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place4_04.mp3')} - > + <GuideStepsItem speaker="guide"> 它就像冲锋号一样,每次响起都能凝聚起大家的斗志,让战士们更勇敢地奔赴战场,让民众们更坚定地支持抗战,成为整个民族团结抗敌的精神纽带,而它也不再只是文艺作品,更是凝聚了中国人民的抗战士气,成了抗日救亡运动的精神旗帜。 </GuideStepsItem> </GuideSteps> diff --git a/src/pages/nieer/place/place5.tsx b/src/pages/nieer/place/place5.tsx index 68254f7..da38068 100644 --- a/src/pages/nieer/place/place5.tsx +++ b/src/pages/nieer/place/place5.tsx @@ -15,11 +15,15 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useNieerStore, saveNieerStore } from '@/store/nieerStore' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' const NieerPlace5 = () => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation() + const starName = 'nieer' const place = 'place5' const { markExhibitVisited, getVisitedExhibits } = useNieerStore() @@ -66,10 +70,10 @@ const NieerPlace5 = () => { const subtitle = completed ? '已完成' : `${progressCount}/${total}` const { isEnabled } = useCursorController({ - initialEnabled: [1], + initialEnabled: [2], dependencies: { - 2: [1], - 3: [2], + 1: [2], + 3: [1], }, visited: visitedExhibits, }) @@ -85,8 +89,8 @@ const NieerPlace5 = () => { const exhibitSrcMap: Record<1 | 2 | 3, string> = { 1: 'nieer/place5/场景5展品1', - 2: 'nieer/place5/场景5展品2', - 3: 'nieer/place5/场景5展品3', + 2: 'nieer/place5/场景5展品3', + 3: 'nieer/place5/场景5展品2', } const handleOpenExhibit = (id: 1 | 2 | 3) => { @@ -163,6 +167,17 @@ const NieerPlace5 = () => { <ExhibitOverlay visible={activeExhibitId !== null} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} + onOpen={() => { + if (activeExhibitId === 1) { + triggerPuzzleAnimation(10) + } + if (activeExhibitId === 2) { + triggerPuzzleAnimation(11) + } + if (activeExhibitId === 3) { + triggerPuzzleAnimation(12) + } + }} onClose={() => { const closingId = activeExhibitId setActiveExhibitId(null) @@ -170,10 +185,16 @@ const NieerPlace5 = () => { setGuide2Visible(false) setGuide3Visible(false) - // 光标1:只需要对话,关窗即完成 + if (closingId == 2) { + markExhibitVisited(place, 2) + } + // 展品1关闭后完成光标1,并触发光标2的两道题 if (closingId === 1) { - markExhibitVisited(place, 1) saveNieerStore() + setQuiz2_1Visible(true) + } + if (closingId === 3) { + setQuiz3_1Visible(true) } }} /> @@ -188,57 +209,34 @@ const NieerPlace5 = () => { highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="student" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_01.mp3')} - > + <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_22.mp3')}> 那2004年的宪法修正案又对国歌做了什么重要规定呢?为什么要把它写入宪法呀? </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_02.mp3')} - > + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_23.mp3')}> 2004年3月14日,第十届全国人民代表大会二次会议通过宪法修正案,明确规定“中华人民共和国国歌是《义勇军进行曲》”,这让国歌的地位有了根本大法的保障。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_03.mp3')} - > + <GuideStepsItem speaker="guide"> 把它写入宪法,是因为这首歌不仅记录了民族救亡的历史,更凝聚了中华民族的精神气节。从抗战时期的冲锋号到新时代的强国音,它早已成为国家尊严的象征,写入宪法能更好地培育全民的爱国意识和国家观念。 </GuideStepsItem> </GuideSteps> - {/* 光标2对话(图2) - 对话结束后依次出现两道题 */} + {/* 光标2对话(图2) */} <GuideSteps visible={guide2Visible} currentStep={guide2Step} onStepChange={setGuide2Step} - onVisibleChange={(nextVisible) => { - setGuide2Visible(nextVisible) - if (!nextVisible) { - setQuiz2_1Visible(true) - } - }} + onVisibleChange={setGuide2Visible} showMask={false} highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="student" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_03.mp3')} - > + <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_20.mp3')}> 小沪,从抗日战歌到正式被确立为国歌,中间经历了多久呀?《义勇军进行曲》第一次作为国歌奏响是在什么时候呢? </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_04.mp3')} - > + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_21.mp3')}> 时间跨度还真不短呢!1949年9月27日,中国人民政治协商会议第一届全体会议决定,在国歌未正式制定前,以《义勇军进行曲》作为代国歌。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_05.mp3')} - > + <GuideStepsItem speaker="guide"> 同年10月1日开国大典上,它第一次在天安门广场作为国歌庄严奏响,见证了新中国的诞生。直到1982年12月4日,第五届全国人民代表大会第五次会议才正式将它定为中华人民共和国国歌,完成了从代国歌到正式国歌的跨越。 </GuideStepsItem> </GuideSteps> @@ -319,35 +317,27 @@ const NieerPlace5 = () => { visible={success2_2Visible} onContinue={() => { setSuccess2_2Visible(false) - markExhibitVisited(place, 2) + markExhibitVisited(place, 1) saveNieerStore() }} onClose={() => { setSuccess2_2Visible(false) - markExhibitVisited(place, 2) + markExhibitVisited(place, 1) saveNieerStore() }} /> - {/* 光标3对话(图5) - 对话结束后依次出现两道题 */} + {/* 光标3对话(图5) - 关闭展品3后依次出现两道题 */} <GuideSteps visible={guide3Visible} currentStep={guide3Step} onStepChange={setGuide3Step} - onVisibleChange={(nextVisible) => { - setGuide3Visible(nextVisible) - if (!nextVisible) { - setQuiz3_1Visible(true) - } - }} + onVisibleChange={setGuide3Visible} showMask={false} highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_05.mp3')} - > + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_24.mp3')}> 根据宪法,2017年9月1日第十二届全国人民代表大会常务委员会第二十九次会议通过《中华人民共和国国歌法》,通过国家立法对国歌的奏唱场合、奏唱礼仪和宣传教育进行规范。 </GuideStepsItem> </GuideSteps> @@ -356,7 +346,7 @@ const NieerPlace5 = () => { <MatchQuizPanel visible={quiz3_1Visible} zIndex={10001} - title="【配对】请将左侧《义勇军进行曲》的关键事件与右侧时间进行匹配" + title="【连线】请将左侧《义勇军进行曲》的关键事件与右侧时间进行匹配" leftItems={[ { id: 'e1982', @@ -454,6 +444,7 @@ const NieerPlace5 = () => { /> <SuccessAlert + title="恭喜你,判断正确!" visible={success3_2Visible} onContinue={() => { setSuccess3_2Visible(false) diff --git a/src/pages/nieer/place/place6.tsx b/src/pages/nieer/place/place6.tsx index 43a0a91..dc2d210 100644 --- a/src/pages/nieer/place/place6.tsx +++ b/src/pages/nieer/place/place6.tsx @@ -12,11 +12,13 @@ import { useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useNieerStore, saveNieerStore } from '@/store/nieerStore' import { useCursorController } from '@/hooks/useCursorController' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' const NieerPlace6 = () => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() + const { triggerPuzzleAnimation } = usePuzzleAnimation() const starName = 'nieer' const place = 'place6' const { markExhibitVisited, getVisitedExhibits } = useNieerStore() @@ -39,9 +41,9 @@ const NieerPlace6 = () => { const subtitle = completed ? '已完成' : `${progressCount}/${total}` const { isEnabled } = useCursorController({ - initialEnabled: [1], + initialEnabled: [2], dependencies: { - 2: [1], + 1: [2], }, visited: visitedExhibits, }) @@ -107,6 +109,14 @@ const NieerPlace6 = () => { <ExhibitOverlay visible={activeExhibitId !== null} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} + onOpen={() => { + if (activeExhibitId === 1) { + triggerPuzzleAnimation(14) + } + if (activeExhibitId === 2) { + triggerPuzzleAnimation(13) + } + }} onClose={() => { const closingId = activeExhibitId setActiveExhibitId(null) @@ -138,29 +148,21 @@ const NieerPlace6 = () => { highlightSelectors={[]} zIndex={10001} > - <GuideStepsItem - speaker="student" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place6_01.mp3')} - > + <GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_25.mp3')}> 小沪,这些不同场景下奏响国歌的画面太让人感动了!在和平年代,这首歌还有什么特别的意义呢? </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place6_02.mp3')} - > - 和平年代里,它的精神力量依然不减。你看这张许海峰奥运夺金的照片,国歌奏响时,是民族自豪的宣告;神舟飞船升空时的国歌,是航天人攻坚克难的礼赞; + <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_26.mp3')}> + 和平年代里,它的精神力量依然不减。你看这张中国女排奥运夺金的照片,国歌奏响时,是民族自豪的宣告;神舟飞船升空时的国歌,是航天人攻坚克难的礼赞。 </GuideStepsItem> - <GuideStepsItem - speaker="guide" - audioSrc={getPublicPath('audio/nieer/mp3_nieer_place6_03.mp3')} - > - 抗震救灾现场的国歌,是全国人民众志成城的誓言。它就像一根精神纽带,无论在高光时刻还是艰难关头,都能凝聚起全国人民的力量,提醒我们不忘历史、砥砺前行。 + <GuideStepsItem speaker="guide"> + 它就像一根精神纽带,无论在高光时刻还是艰难关头,都能凝聚起全国人民的力量,提醒我们不忘历史、砥砺前行。 </GuideStepsItem> </GuideSteps> {/* completed 后的结束语:统一使用 EndingOverlay */} <EndingOverlay visible={endingVisible} + audioSrc={getPublicPath('audio/nieer/mp3_nieer_27.mp3')} text={ '从民族危机中觉醒的热血青年,到《义勇军进行曲》的谱曲者,聂耳用音符奏响了“救亡图存”的时代强音。' + '而《义勇军进行曲》的影响远不止于此,接下来,请你化身国歌时空旅行者,踏入6个散落于岁月中的故事现场,' + diff --git a/src/pages/nieer/reflection/ContentDialog.tsx b/src/pages/nieer/reflection/ContentDialog.tsx index ff766c4..68f4f83 100644 --- a/src/pages/nieer/reflection/ContentDialog.tsx +++ b/src/pages/nieer/reflection/ContentDialog.tsx @@ -11,19 +11,19 @@ type ContentPage = // 每个场景的多页内容配置(按 scene.id 映射) const SCENE_PAGES: Record<number, ContentPage[]> = { 1: [ - { type: 'image', src: '场景1内容', audioSrc: 'audio/test.mp3' }, + { type: 'image', src: '场景1内容', audioSrc: 'audio/nieer/mp3_nieer_29.mp3' }, { type: 'video', src: 'video/nieer/nieer_2_1.mp4' }, ], - 2: [{ type: 'image', src: '场景2内容', audioSrc: 'audio/test.mp3' }], - 3: [{ type: 'image', src: '场景3内容', audioSrc: 'audio/test.mp3' }], - 4: [{ type: 'image', src: '场景4内容', audioSrc: 'audio/test.mp3' }], + 2: [{ type: 'image', src: '场景2内容', audioSrc: 'audio/nieer/mp3_nieer_30.mp3' }], + 3: [{ type: 'image', src: '场景3内容', audioSrc: 'audio/nieer/mp3_nieer_31.mp3' }], + 4: [{ type: 'image', src: '场景4内容', audioSrc: 'audio/nieer/mp3_nieer_32.mp3' }], 5: [ - { type: 'image', src: '场景5内容', audioSrc: 'audio/test.mp3' }, + { type: 'image', src: '场景5内容', audioSrc: 'audio/nieer/mp3_nieer_33.mp3' }, { type: 'video', src: 'video/nieer/nieer_2_4.mp4' }, ], 6: [ - { type: 'image', src: '场景6内容', audioSrc: 'audio/test.mp3' }, - { type: 'video', src: 'video/nieer/nieer_2_4.mp4' }, + { type: 'image', src: '场景6内容', audioSrc: 'audio/nieer/mp3_nieer_34.mp3' }, + { type: 'video', src: 'video/nieer/nieer_2_5.mp4' }, ], } diff --git a/src/pages/nieer/reflection/DialogLayer.tsx b/src/pages/nieer/reflection/DialogLayer.tsx index c7c334b..6da91e2 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 } from '@/components' +import { Image, Video } from '@/components' import { BeijingUnlockInteraction, InputBg, @@ -14,7 +14,43 @@ import ContentDialog from './ContentDialog' import UnlockHintDialog from './UnlockHintDialog' import type { DialogState } from './useDialogReducer' +interface SharePayload { + sceneId?: number + [key: string]: unknown +} + +interface DialogLayerProps { + state: DialogState + onEnterUnlock: () => void + onShare: (sceneId: number, payload?: SharePayload) => void + onFinishBadgeVideo: () => void + onClose: () => void +} + +interface DialogLocalState { + selectedLyric?: string + guizhouInput: string + pianoFinished: boolean + selectedQuote?: string + postcardFinished: boolean + beijingFinished: boolean + postcardState: { + hasPlane: boolean + hasText: boolean + postcardImagePath: string + } + beijingSelection: { + paradeId?: string + paradeName?: string + paradeImagePath?: string + sloganId?: string + sloganImagePath?: string + previewImageDataUrl?: string + } +} + const starName = 'nieer' +const DEFAULT_POSTCARD_IMAGE_PATH = 'nieer/reflection/lobby/content/场景5待交互' const ANTHEM_LYRICS = [ { value: '1', label: '起来!不愿做奴隶的人们!' }, @@ -33,41 +69,208 @@ const SMEDLEY_QUOTES = [ { value: '3', label: '我的心灵在这个世界上除了中国的任何地方都未能找到安宁' }, ] -interface DialogLayerProps { - state: DialogState - onEnterUnlock: () => void - onShare: (payload?: Record<string, unknown>) => void - onClose: () => void +const getCanShare = (sceneId: number, localState: DialogLocalState): boolean => { + switch (sceneId) { + case 1: + return !!localState.selectedLyric + case 2: + return localState.guizhouInput.trim().length > 0 + case 3: + return localState.pianoFinished + case 4: + return !!localState.selectedQuote + case 5: + return localState.postcardFinished + case 6: + return ( + localState.beijingFinished && + typeof localState.beijingSelection.previewImageDataUrl === 'string' + ) + default: + return true + } } -const DialogLayer = ({ state, onEnterUnlock, onShare, onClose }: DialogLayerProps) => { +const buildSharePayload = ( + sceneId: number, + localState: DialogLocalState +): SharePayload | undefined => { + switch (sceneId) { + case 1: { + const selected = ANTHEM_LYRICS.find((item) => item.value === localState.selectedLyric) + return { + sceneId: 1, + lyricValue: localState.selectedLyric ?? '', + lyricLabel: selected?.label ?? '', + } + } + case 2: + return { + sceneId: 2, + reflectionText: localState.guizhouInput.trim(), + } + case 3: + return { + sceneId: 3, + pianoCompleted: localState.pianoFinished, + pianoImagePath: 'nieer/reflection/lobby/box/音符', + } + case 4: { + const selected = SMEDLEY_QUOTES.find((item) => item.value === localState.selectedQuote) + return { + sceneId: 4, + quoteValue: localState.selectedQuote ?? '', + quoteLabel: selected?.label ?? '', + } + } + case 5: + return { + sceneId: 5, + ...localState.postcardState, + } + case 6: + return { + sceneId: 6, + ...localState.beijingSelection, + } + default: + return undefined + } +} + +interface UnlockContentHandlers { + onSelectLyric: (value: string) => void + onChangeGuizhouInput: (value: string) => void + onFinishPiano: () => void + onSelectQuote: (value: string) => void + onFinishPostcard: () => void + onChangePostcardState: (payload: DialogLocalState['postcardState']) => void + onFinishBeijing: () => void + onChangeBeijingSelection: (payload: DialogLocalState['beijingSelection']) => void +} + +const renderUnlockContent = ( + sceneId: number, + localState: DialogLocalState, + handlers: UnlockContentHandlers +) => { + switch (sceneId) { + case 1: + return ( + <div className="flex flex-col items-center justify-center mt-12"> + <Image + className="mb-20" + src={getStaticImage(`${starName}/reflection/lobby/content/场景1交互`)} + alt="场景1交互" + /> + <SelectBg + options={ANTHEM_LYRICS} + value={localState.selectedLyric || ''} + onChange={handlers.onSelectLyric} + placeholder="请选择一句国歌歌词" + /> + </div> + ) + case 2: + return ( + <div className="flex flex-col items-center justify-center mt-12"> + <Image + className="mb-20" + src={getStaticImage(`${starName}/reflection/lobby/content/场景2交互`)} + alt="场景2交互" + /> + <InputBg + className="w-[875px]" + value={localState.guizhouInput} + onChange={handlers.onChangeGuizhouInput} + placeholder="请输入你的爱国感悟(10字以内)" + maxLength={10} + /> + </div> + ) + case 3: + return ( + <div className="flex flex-col items-center justify-center mt-12"> + <PianoUnlockInteraction onFinished={handlers.onFinishPiano} /> + </div> + ) + case 4: + return ( + <div className="flex flex-col items-center justify-center mt-12"> + <Image + className="mb-20" + src={getStaticImage(`${starName}/reflection/lobby/content/场景4交互`)} + alt="场景4交互" + /> + <SelectBg + options={SMEDLEY_QUOTES} + value={localState.selectedQuote || ''} + onChange={handlers.onSelectQuote} + placeholder="请选择一句最触动你的遗言" + /> + </div> + ) + case 5: + return ( + <PostcardUnlockInteraction + onFinished={handlers.onFinishPostcard} + onStateChange={handlers.onChangePostcardState} + /> + ) + case 6: + return ( + <BeijingUnlockInteraction + onFinished={handlers.onFinishBeijing} + onSelectionChange={handlers.onChangeBeijingSelection} + /> + ) + default: + return null + } +} + +const createUnlockHandlers = ( + setSelectedLyric: (value: string) => void, + setGuizhouInput: (value: string) => void, + setPianoFinished: (value: boolean) => void, + setSelectedQuote: (value: string) => void, + setPostcardFinished: (value: boolean) => void, + setPostcardState: (payload: DialogLocalState['postcardState']) => void, + setBeijingFinished: (value: boolean) => void, + setBeijingSelection: (payload: DialogLocalState['beijingSelection']) => void +): UnlockContentHandlers => ({ + onSelectLyric: setSelectedLyric, + onChangeGuizhouInput: (value) => setGuizhouInput(value.slice(0, 10)), + onFinishPiano: () => setPianoFinished(true), + onSelectQuote: setSelectedQuote, + onFinishPostcard: () => setPostcardFinished(true), + onChangePostcardState: setPostcardState, + onFinishBeijing: () => setBeijingFinished(true), + onChangeBeijingSelection: setBeijingSelection, +}) + +const DialogLayer = ({ + state, + onEnterUnlock, + onShare, + onFinishBadgeVideo, + onClose, +}: DialogLayerProps) => { const [selectedLyric, setSelectedLyric] = useState<string>() - const [guizhouInput, setGuizhouInput] = useState<string>('') + const [guizhouInput, setGuizhouInput] = useState('') const [pianoFinished, setPianoFinished] = useState(false) const [selectedQuote, setSelectedQuote] = useState<string>() const [postcardFinished, setPostcardFinished] = useState(false) const [beijingFinished, setBeijingFinished] = useState(false) - const [postcardState, setPostcardState] = useState<{ - hasPlane: boolean - hasText: boolean - postcardImagePath: string - }>({ + const [postcardState, setPostcardState] = useState({ hasPlane: false, hasText: false, - postcardImagePath: 'nieer/reflection/lobby/content/场景5待交互', + postcardImagePath: DEFAULT_POSTCARD_IMAGE_PATH, }) - const [beijingSelection, setBeijingSelection] = useState<{ - paradeId?: string - paradeName?: string - paradeImagePath?: string - sloganId?: string - sloganImagePath?: string - previewImageDataUrl?: string - }>({}) + const [beijingSelection, setBeijingSelection] = useState<DialogLocalState['beijingSelection']>({}) const dialogNonce = state.mode === 'idle' ? 0 : state.nonce - // 每次点击卡片(nonce 变化)时,重置 unlock 交互状态 useEffect(() => { setSelectedLyric(undefined) setGuizhouInput('') @@ -78,84 +281,42 @@ const DialogLayer = ({ state, onEnterUnlock, onShare, onClose }: DialogLayerProp setPostcardState({ hasPlane: false, hasText: false, - postcardImagePath: 'nieer/reflection/lobby/content/场景5待交互', + postcardImagePath: DEFAULT_POSTCARD_IMAGE_PATH, }) setBeijingSelection({}) }, [dialogNonce]) if (state.mode === 'idle') return <AnimatePresence mode="sync" /> - const scene = LOBBY_COLUMNS.find((s) => s.id === state.sceneId) + const scene = LOBBY_COLUMNS.find((item) => item.id === state.sceneId) if (!scene) return null - let canShare = true - if (state.sceneId === 1) { - canShare = !!selectedLyric - } else if (state.sceneId === 2) { - canShare = guizhouInput.trim().length > 0 - } else if (state.sceneId === 3) { - // 欧洲·亚洲:钢琴交互播放完成后才允许分享 - canShare = pianoFinished - } else if (state.sceneId === 4) { - canShare = !!selectedQuote - } else if (state.sceneId === 5) { - canShare = postcardFinished - } else if (state.sceneId === 6) { - canShare = beijingFinished && typeof beijingSelection.previewImageDataUrl === 'string' + const localState: DialogLocalState = { + selectedLyric, + guizhouInput, + pianoFinished, + selectedQuote, + postcardFinished, + beijingFinished, + postcardState, + beijingSelection, } + const canShare = getCanShare(state.sceneId, localState) + const unlockHandlers = createUnlockHandlers( + setSelectedLyric, + setGuizhouInput, + setPianoFinished, + setSelectedQuote, + setPostcardFinished, + setPostcardState, + setBeijingFinished, + setBeijingSelection + ) + const handleShareClick = () => { if (state.mode !== 'unlock') return - - if (state.sceneId === 1) { - const selected = ANTHEM_LYRICS.find((item) => item.value === selectedLyric) - onShare({ - sceneId: 1, - lyricValue: selectedLyric ?? '', - lyricLabel: selected?.label ?? '', - }) - return - } - if (state.sceneId === 2) { - onShare({ - sceneId: 2, - reflectionText: guizhouInput.trim(), - }) - return - } - if (state.sceneId === 3) { - onShare({ - sceneId: 3, - pianoCompleted: pianoFinished, - pianoImagePath: 'nieer/reflection/lobby/box/音符', - }) - return - } - if (state.sceneId === 4) { - const selected = SMEDLEY_QUOTES.find((item) => item.value === selectedQuote) - onShare({ - sceneId: 4, - quoteValue: selectedQuote ?? '', - quoteLabel: selected?.label ?? '', - }) - return - } - if (state.sceneId === 5) { - onShare({ - sceneId: 5, - ...postcardState, - }) - return - } - if (state.sceneId === 6) { - onShare({ - sceneId: 6, - ...beijingSelection, - }) - return - } - - onShare() + onShare(state.sceneId, buildSharePayload(state.sceneId, localState)) } return ( @@ -176,6 +337,19 @@ const DialogLayer = ({ state, onEnterUnlock, onShare, onClose }: DialogLayerProp )} alt="结果页" /> + {state.videoSrc && ( + <div className="absolute inset-0 z-20 bg-black"> + <Video + key={state.videoSrc} + src={state.videoSrc} + autoPlay + playsInline + className="w-full h-full" + onSkip={onFinishBadgeVideo} + onEnded={onFinishBadgeVideo} + /> + </div> + )} <Image button src={getStaticImage('components/关闭')} @@ -203,82 +377,7 @@ const DialogLayer = ({ state, onEnterUnlock, onShare, onClose }: DialogLayerProp onClose={onClose} onShare={state.hasShare && canShare ? handleShareClick : undefined} > - {state.sceneId === 1 && ( - <div className="flex flex-col items-center justify-center mt-12"> - <Image - className="mb-20" - src={getStaticImage(`${starName}/reflection/lobby/content/场景1交互`)} - alt="场景1交互" - /> - <SelectBg - options={ANTHEM_LYRICS} - value={selectedLyric || ''} - onChange={setSelectedLyric} - placeholder="请选择一句国歌歌词" - /> - </div> - )} - - {state.sceneId === 2 && ( - <div className="flex flex-col items-center justify-center mt-12"> - <Image - className="mb-20" - src={getStaticImage(`${starName}/reflection/lobby/content/场景2交互`)} - alt="场景2交互" - /> - <InputBg - className="w-[875px]" - value={guizhouInput} - onChange={(value) => setGuizhouInput(value.slice(0, 10))} - placeholder="请输入你的爱国感悟(10字以内)" - maxLength={10} - /> - </div> - )} - - {state.sceneId === 3 && ( - <div className="flex flex-col items-center justify-center mt-12"> - <PianoUnlockInteraction - onFinished={() => { - setPianoFinished(true) - }} - /> - </div> - )} - - {state.sceneId === 4 && ( - <div className="flex flex-col items-center justify-center mt-12"> - <Image - className="mb-20" - src={getStaticImage(`${starName}/reflection/lobby/content/场景4交互`)} - alt="场景4交互" - /> - <SelectBg - options={SMEDLEY_QUOTES} - value={selectedQuote || ''} - onChange={setSelectedQuote} - placeholder="请选择一句最触动你的遗言" - /> - </div> - )} - - {state.sceneId === 5 && ( - <PostcardUnlockInteraction - onFinished={() => { - setPostcardFinished(true) - }} - onStateChange={setPostcardState} - /> - )} - - {state.sceneId === 6 && ( - <BeijingUnlockInteraction - onFinished={() => { - setBeijingFinished(true) - }} - onSelectionChange={setBeijingSelection} - /> - )} + {renderUnlockContent(state.sceneId, localState, unlockHandlers)} </UnlockHintDialog> </motion.div> )} diff --git a/src/pages/nieer/reflection/index.tsx b/src/pages/nieer/reflection/index.tsx index 9b81406..6a8ebd9 100644 --- a/src/pages/nieer/reflection/index.tsx +++ b/src/pages/nieer/reflection/index.tsx @@ -1,11 +1,20 @@ -import { Image } from '@/components' -import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils' +import { Image, StudyGuideModal } from '@/components' +import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { useMemo } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { LOBBY_COLUMNS, LOBBY_LAYOUT, LOBBY_TIMELINE, PLACE_REQUIRED_COUNTS } from './lobby.config' +import { LOBBY_COLUMNS, LOBBY_LAYOUT, LOBBY_TIMELINE } from './lobby.config' import { useNieerStore } from '@/store/nieerStore' import { useDialogReducer } from './useDialogReducer' import DialogLayer from './DialogLayer' +import { + BADGE_CONFIG, + getAllPlacesCompleted, + getBadgeUnlockedMap, + getGuideSceneId, + getPlaceCompletionMap, + getUnlockVideoSrcForScene, + getUnlockedByIdMap, +} from './reflectionProgress' const starName = 'nieer' @@ -16,61 +25,38 @@ const NieerReflection = () => { const visitedReflectionCursors = useNieerStore((state) => state.visitedReflectionCursors) const markReflectionCursorVisited = useNieerStore((state) => state.markReflectionCursorVisited) const setReflectionWork = useNieerStore((state) => state.setReflectionWork) + const hasSeenReflectionGuide = useNieerStore((state) => state.hasSeenReflectionGuide) + const markReflectionGuideSeen = useNieerStore((state) => state.markReflectionGuideSeen) + const showGuideModal = !hasSeenReflectionGuide const { state: dialogState, openScene, enterUnlock, handleShare, + finishBadgeVideo, close, } = useDialogReducer(markReflectionCursorVisited, setReflectionWork) - const placeCompletion = useMemo(() => { - const getVisitedCount = (placeName: string): number => - visitedReflectionCursors[placeName]?.size ?? 0 - return { - 1: getVisitedCount('place1') >= (PLACE_REQUIRED_COUNTS[1] ?? 0), - 2: getVisitedCount('place2') >= (PLACE_REQUIRED_COUNTS[2] ?? 0), - 3: getVisitedCount('place3') >= (PLACE_REQUIRED_COUNTS[3] ?? 0), - 4: getVisitedCount('place4') >= (PLACE_REQUIRED_COUNTS[4] ?? 0), - 5: getVisitedCount('place5') >= (PLACE_REQUIRED_COUNTS[5] ?? 0), - 6: getVisitedCount('place6') >= (PLACE_REQUIRED_COUNTS[6] ?? 0), - } as const - }, [visitedReflectionCursors]) - - const unlockedById = useMemo( - () => - ({ - 1: true, - 2: placeCompletion[1], - 3: placeCompletion[2], - 4: placeCompletion[3], - 5: placeCompletion[4], - 6: placeCompletion[5], - }) as const, - [placeCompletion] + const placeCompletion = useMemo( + () => getPlaceCompletionMap(visitedReflectionCursors), + [visitedReflectionCursors] ) - - const badgeUnlocked = useMemo( - () => ({ - 1: placeCompletion[1] && placeCompletion[2], - 2: placeCompletion[3] && placeCompletion[4], - 3: placeCompletion[5] && placeCompletion[6], - }), - [placeCompletion] - ) - - const guideSceneId = useMemo(() => { - const nextScene = LOBBY_COLUMNS.find( - (scene) => !placeCompletion[scene.id as keyof typeof placeCompletion] - ) - return nextScene?.id ?? 6 - }, [placeCompletion]) + const unlockedById = useMemo(() => getUnlockedByIdMap(placeCompletion), [placeCompletion]) + const badgeUnlocked = useMemo(() => getBadgeUnlockedMap(placeCompletion), [placeCompletion]) + const guideSceneId = useMemo(() => getGuideSceneId(placeCompletion), [placeCompletion]) const allPlacesCompleted = useMemo( - () => LOBBY_COLUMNS.every((scene) => placeCompletion[scene.id as keyof typeof placeCompletion]), + () => getAllPlacesCompleted(placeCompletion), [placeCompletion] ) + const handleResultShare = (sceneId: number, payload?: Record<string, unknown>) => { + handleShare({ + payload, + unlockVideoSrc: getUnlockVideoSrcForScene(sceneId, visitedReflectionCursors), + }) + } + if (!moduleName) return null return ( @@ -78,7 +64,6 @@ const NieerReflection = () => { className="w-full h-full relative bg-cover bg-center bg-no-repeat overflow-hidden" style={{ backgroundImage: getBackgroundImage(`${starName}/reflection/lobby/场景首页底图`) }} > - {/* 返回按钮 */} <Image className="absolute right-6 top-5 z-30" src={getStaticImage('components/返回')} @@ -86,7 +71,6 @@ const NieerReflection = () => { onClick={() => navigate(`/${moduleName}/${starName}/home`)} /> - {/* 六列时间轴内容 */} <div className={cn( 'absolute h-full grid z-1', @@ -98,7 +82,6 @@ const NieerReflection = () => { gridTemplateColumns: `repeat(${LOBBY_LAYOUT.columnCount}, minmax(0, 1fr))`, }} > - {/* 琴弦层:在背景图之上、在顶部图标之下 */} <img className="absolute -left-65 top-0 z-2 w-[1651px] h-auto pointer-events-none" src={getStaticImage(`${starName}/reflection/lobby/menu/琴弦`)} @@ -106,7 +89,6 @@ const NieerReflection = () => { /> {LOBBY_COLUMNS.map((scene) => { const isUnlocked = unlockedById[scene.id as keyof typeof unlockedById] - // 顶部图标只在“该模块已完成学习任务”时亮起;未完成(即当前进度)保持黑白 const isCompleted = placeCompletion[scene.id as keyof typeof placeCompletion] const iconSrc = isCompleted ? scene.icon : scene.iconBw const isClickable = isUnlocked || scene.id === guideSceneId @@ -131,7 +113,6 @@ const NieerReflection = () => { src={getStaticImage(`${starName}/reflection/lobby/${scene.cityImage}`)} alt={scene.city} /> - {/* 顶部图标 */} <div className={cn( 'relative z-3', @@ -156,22 +137,18 @@ const NieerReflection = () => { </div> </div> - {/* 点击前往(当前引导场景) */} {showGuideState && ( - <> - <Image - className="animate-finger-bounce absolute bottom-[calc(50%-200px)] translate-y-1/2" - src={getStaticImage(`${starName}/reflection/lobby/icon/点击前往`)} - alt="点击前往" - /> - </> + <Image + className="animate-finger-bounce absolute bottom-[calc(50%-200px)] translate-y-1/2" + src={getStaticImage(`${starName}/reflection/lobby/icon/点击前往`)} + alt="点击前往" + /> )} </button> ) })} </div> - {/* 横跨年份层:只负责 1937 跨列展示 */} <div className={cn('absolute z-10 grid', LOBBY_LAYOUT.timelineTop, LOBBY_LAYOUT.columnsLeft)} style={{ @@ -206,7 +183,6 @@ const NieerReflection = () => { })} </div> - {/* 底部 footer + 徽章区 */} <div className="absolute left-0 bottom-0 w-full h-[201px] z-20 flex justify-center items-center pt-20 gap-10" style={{ @@ -214,23 +190,10 @@ const NieerReflection = () => { }} > <div className="relative w-340 h-full"> - {[ - { - id: 1, - className: 'top-0 left-36', - }, - { - id: 2, - className: 'top-0 left-146', - }, - { - id: 3, - className: 'top-0 right-36', - }, - ].map((i) => { - const isLit = badgeUnlocked[i.id as keyof typeof badgeUnlocked] + {BADGE_CONFIG.map((badge) => { + const isLit = badgeUnlocked[badge.id as keyof typeof badgeUnlocked] return ( - <div key={i.id} className={cn('absolute w-[193px]', i.className)}> + <div key={badge.id} className={cn('absolute w-[193px]', badge.className)}> <Image width={193} src={getStaticImage(`${starName}/reflection/lobby/icon/徽章底板`)} @@ -240,8 +203,8 @@ const NieerReflection = () => { <Image className="absolute inset-0" width={193} - src={getStaticImage(`${starName}/reflection/lobby/icon/徽章${i.id}`)} - alt={`徽章${i.id}`} + src={getStaticImage(`${starName}/reflection/lobby/icon/徽章${badge.id}`)} + alt={`徽章${badge.id}`} /> )} </div> @@ -250,7 +213,6 @@ const NieerReflection = () => { </div> </div> - {/* 右下角:旅行箱 */} <Image button src={getStaticImage(`${starName}/reflection/lobby/icon/旅行箱`)} @@ -259,7 +221,6 @@ const NieerReflection = () => { onClick={() => navigate(`/${moduleName}/${starName}/reflection/box`)} /> - {/* debug 参考线 */} {debugLobby && ( <div className="absolute inset-0 z-40 pointer-events-none" @@ -274,9 +235,21 @@ const NieerReflection = () => { <DialogLayer state={dialogState} onEnterUnlock={enterUnlock} - onShare={handleShare} + onShare={handleResultShare} + onFinishBadgeVideo={finishBadgeVideo} onClose={close} /> + + <StudyGuideModal + visible={showGuideModal} + firstLineIndent="2em" + onContinue={markReflectionGuideSeen} + audioSrc={getPublicPath('audio/nieer/mp3_nieer_28.mp3')} + > + {`同学你好,欢迎来到“跨越时空·体悟”学习模块! + 请登陆“时空回溯・亲历时代”体验仓,下一站:1936年,上海。 + 请带上你的旅行包,穿越时空解锁6个国歌故事,集齐精神徽章,绘制属于你的国歌精神图谱吧~时空门已开,请出发吧!`} + </StudyGuideModal> </div> ) } diff --git a/src/pages/nieer/reflection/lobby.config.ts b/src/pages/nieer/reflection/lobby.config.ts index ed65303..be4075a 100644 --- a/src/pages/nieer/reflection/lobby.config.ts +++ b/src/pages/nieer/reflection/lobby.config.ts @@ -65,10 +65,10 @@ export const PLACE_REQUIRED_COUNTS: Record<number, number> = { } export const UNLOCK_TITLES: Record<number, string> = { - 1: '在输入框中写下1句你想教给他人的国歌歌词,把爱国之声传递出去吧~输入完后,点击分享按钮,文字自动进入\u201C旅行箱\u201D', - 2: '了解了蓝运瑜老人的故事,请你在下方输入框中,写下1句10字以内的你的爱国感悟吧!输入完后,点击分享按钮,文字自动进入\u201C旅行箱\u201D', + 1: '在输入框中写下1句你想教给他人的国歌歌词,把爱国之声传递出去吧~', + 2: '了解了蓝运瑜老人的故事,请你在下方输入框中,写下1句10字以内的你的爱国感悟吧!', 3: '请跟随光点在钢琴上弹奏出国歌的片段,为任光的演出伴奏吧~', - 4: '点击选择史沫特莱女士临终遗言最触动你的一句话。选择完成后,点击分享按钮,文字自动进入\u201C旅行箱\u201D', + 4: '点击选择史沫特莱女士临终遗言最触动你的一句话。', 5: '请拖曳配齐图片要素制作接同胞回家的明信片!', 6: '选一个你最喜欢的阅兵方阵,搭配一句词条,点击分享,定格那个瞬间。', } diff --git a/src/pages/nieer/reflection/reflectionProgress.ts b/src/pages/nieer/reflection/reflectionProgress.ts new file mode 100644 index 0000000..c56c1b7 --- /dev/null +++ b/src/pages/nieer/reflection/reflectionProgress.ts @@ -0,0 +1,115 @@ +import { getPublicPath } from '@/lib/utils' +import { PLACE_REQUIRED_COUNTS } from './lobby.config' + +export const BADGE_SCENE_GROUPS = { + 1: [1, 2], + 2: [3, 4], + 3: [5, 6], +} as const + +export const BADGE_UNLOCK_VIDEO_MAP = { + 1: getPublicPath('video/nieer/nieer_2_2.mp4'), + 2: getPublicPath('video/nieer/nieer_2_3.mp4'), + 3: getPublicPath('video/nieer/nieer_2_6.mp4'), +} as const + +export const BADGE_CONFIG = [ + { + id: 1, + className: 'top-0 left-36', + }, + { + id: 2, + className: 'top-0 left-146', + }, + { + id: 3, + className: 'top-0 right-36', + }, +] as const + +export const PLACE_SEQUENCE = [1, 2, 3, 4, 5, 6] as const + +export type PlaceCompletionMap = Record<(typeof PLACE_SEQUENCE)[number], boolean> + +export const getPlaceCompletionMap = ( + visitedReflectionCursors: Record<string, Set<number>> +): PlaceCompletionMap => { + const getVisitedCount = (placeName: string): number => + visitedReflectionCursors[placeName]?.size ?? 0 + + return { + 1: getVisitedCount('place1') >= (PLACE_REQUIRED_COUNTS[1] ?? 0), + 2: getVisitedCount('place2') >= (PLACE_REQUIRED_COUNTS[2] ?? 0), + 3: getVisitedCount('place3') >= (PLACE_REQUIRED_COUNTS[3] ?? 0), + 4: getVisitedCount('place4') >= (PLACE_REQUIRED_COUNTS[4] ?? 0), + 5: getVisitedCount('place5') >= (PLACE_REQUIRED_COUNTS[5] ?? 0), + 6: getVisitedCount('place6') >= (PLACE_REQUIRED_COUNTS[6] ?? 0), + } +} + +export const getUnlockedBadgeIds = ( + placeCompletion: PlaceCompletionMap +): Array<keyof typeof BADGE_SCENE_GROUPS> => { + return ( + Object.entries(BADGE_SCENE_GROUPS) as unknown as Array< + [keyof typeof BADGE_SCENE_GROUPS, readonly number[]] + > + ) + .filter(([, sceneIds]) => + sceneIds.every((sceneId) => placeCompletion[sceneId as keyof PlaceCompletionMap]) + ) + .map(([badgeId]) => badgeId) +} + +export const getBadgeUnlockedMap = (placeCompletion: PlaceCompletionMap) => ({ + 1: placeCompletion[1] && placeCompletion[2], + 2: placeCompletion[3] && placeCompletion[4], + 3: placeCompletion[5] && placeCompletion[6], +}) + +export const getUnlockedByIdMap = (placeCompletion: PlaceCompletionMap) => + ({ + 1: true, + 2: placeCompletion[1], + 3: placeCompletion[2], + 4: placeCompletion[3], + 5: placeCompletion[4], + 6: placeCompletion[5], + }) as const + +export const getGuideSceneId = (placeCompletion: PlaceCompletionMap) => { + const nextScene = PLACE_SEQUENCE.find((sceneId) => !placeCompletion[sceneId]) + return nextScene ?? 6 +} + +export const getAllPlacesCompleted = (placeCompletion: PlaceCompletionMap) => + PLACE_SEQUENCE.every((sceneId) => placeCompletion[sceneId]) + +export const getUnlockVideoSrcForScene = ( + sceneId: number, + visitedReflectionCursors: Record<string, Set<number>> +): string | undefined => { + const previousPlaceCompletion = getPlaceCompletionMap(visitedReflectionCursors) + const previousBadges = new Set(getUnlockedBadgeIds(previousPlaceCompletion)) + + const nextVisitedReflectionCursors: Record<string, Set<number>> = Object.fromEntries( + Object.entries(visitedReflectionCursors).map(([placeName, cursorIds]) => [ + placeName, + new Set(cursorIds), + ]) + ) + + const currentPlaceName = `place${sceneId}` + const requiredCount = PLACE_REQUIRED_COUNTS[sceneId] ?? 0 + nextVisitedReflectionCursors[currentPlaceName] = new Set( + Array.from({ length: requiredCount }, (_, index) => index + 1) + ) + + const nextPlaceCompletion = getPlaceCompletionMap(nextVisitedReflectionCursors) + const unlockedBadgeId = getUnlockedBadgeIds(nextPlaceCompletion).find( + (badgeId) => !previousBadges.has(badgeId) + ) + + return unlockedBadgeId ? BADGE_UNLOCK_VIDEO_MAP[unlockedBadgeId] : undefined +} diff --git a/src/pages/nieer/reflection/useDialogReducer.ts b/src/pages/nieer/reflection/useDialogReducer.ts index a14e817..7167654 100644 --- a/src/pages/nieer/reflection/useDialogReducer.ts +++ b/src/pages/nieer/reflection/useDialogReducer.ts @@ -7,16 +7,22 @@ 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 } + | { mode: 'result'; sceneId: number; nonce: number; videoSrc?: string } // ── Actions ──────────────────────────────────────────── type DialogAction = | { type: 'OPEN_SCENE'; sceneId: number; unlocked: boolean; nonce: number } | { type: 'ENTER_UNLOCK' } - | { type: 'SHOW_RESULT' } + | { type: 'SHOW_RESULT'; videoSrc?: string } + | { type: 'FINISH_BADGE_VIDEO' } | { type: 'CLOSE' } +interface ShareOptions { + payload?: Record<string, unknown> + unlockVideoSrc?: string +} + // ── Reducer ──────────────────────────────────────────── function dialogReducer(state: DialogState, action: DialogAction): DialogState { @@ -30,9 +36,18 @@ 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 } + return { + mode: 'result', + sceneId: state.sceneId, + nonce: state.nonce, + videoSrc: action.videoSrc, + } case 'CLOSE': return { mode: 'idle' } @@ -63,7 +78,7 @@ export function useDialogReducer( }, []) const handleShare = useCallback( - (payload?: Record<string, unknown>) => { + ({ payload, unlockVideoSrc }: ShareOptions = {}) => { if (shareGuardRef.current) return if (state.mode !== 'unlock') return shareGuardRef.current = true @@ -79,15 +94,19 @@ export function useDialogReducer( setReflectionWork(sceneId, payload) } - dispatch({ type: 'SHOW_RESULT' }) + dispatch({ type: 'SHOW_RESULT', videoSrc: unlockVideoSrc }) }, [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, close } + return { state, openScene, enterUnlock, handleShare, finishBadgeVideo, close } } diff --git a/src/pages/rongdesheng/internalization.tsx b/src/pages/rongdesheng/internalization.tsx index 7606c19..f9d8588 100644 --- a/src/pages/rongdesheng/internalization.tsx +++ b/src/pages/rongdesheng/internalization.tsx @@ -389,6 +389,7 @@ const RongdeshengInternalization = () => { <Narration visible={narrationVisible} + audioChannelId="rongdesheng-internalization-narration" audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_32.mp3')} onClose={() => { setNarrationVisible(false) @@ -398,6 +399,7 @@ const RongdeshengInternalization = () => { <GuideSteps visible={preGuideVisible} + audioChannelId="rongdesheng-internalization-guide-pre" onVisibleChange={(visible) => { setPreGuideVisible(visible) if (!visible) { @@ -442,6 +444,7 @@ const RongdeshengInternalization = () => { <GuideSteps visible={postGuideVisible} + audioChannelId="rongdesheng-internalization-guide-post" onVisibleChange={(visible) => { setPostGuideVisible(visible) if (!visible) { @@ -479,7 +482,7 @@ const RongdeshengInternalization = () => { animationSpeed={200} onContinue={() => { setShowEnding(false) - navigate(`/${moduleName}/${starName}/home`) + navigate('/menu') }} /> </div> diff --git a/src/pages/rongdesheng/place/place1.tsx b/src/pages/rongdesheng/place/place1.tsx index 269375b..6fbc616 100644 --- a/src/pages/rongdesheng/place/place1.tsx +++ b/src/pages/rongdesheng/place/place1.tsx @@ -164,7 +164,7 @@ const RongdeshengPlace1 = () => { speaker="student" audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_03.mp3')} > - 小沪,快看荣德生的照片吧,作为一个无锡人,当时是有什么机缘让他离开家乡,到上海闯荡的呢? + 小沪,这就是荣德生先生的照片吧,作为一个无锡人,当时是有什么机缘让他离开家乡,到上海闯荡的呢? </GuideStepsItem> <GuideStepsItem speaker="guide" diff --git a/src/pages/rongdesheng/place/place4.tsx b/src/pages/rongdesheng/place/place4.tsx index 5489eea..3f49e80 100644 --- a/src/pages/rongdesheng/place/place4.tsx +++ b/src/pages/rongdesheng/place/place4.tsx @@ -1,4 +1,12 @@ -import { ExhibitOverlay, GuideSteps, GuideStepsItem, Image, Title, Video } from '@/components' +import { + EndingOverlay, + ExhibitOverlay, + GuideSteps, + GuideStepsItem, + Image, + Title, + Video, +} from '@/components' import Cursor from '@/components/cursor' import { useCursorController } from '@/hooks/useCursorController' import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' @@ -50,6 +58,7 @@ const RongdeshengPlace4 = () => { const [guide3Step, setGuide3Step] = useState(0) const [guide4Visible, setGuide4Visible] = useState(false) const [guide4Step, setGuide4Step] = useState(0) + const [endingGuideVisible, setEndingGuideVisible] = useState(false) if (!moduleName) return null @@ -163,6 +172,10 @@ const RongdeshengPlace4 = () => { saveRongdeshengStore() return next }) + + if (closingId === 4) { + setEndingGuideVisible(true) + } } if (closingId === 1) { @@ -301,6 +314,14 @@ const RongdeshengPlace4 = () => { 晚年的荣先生依然关注民族工业的发展,积极建言献策,他创办的企业后来成为新中国轻工业的重要骨干,为国家的经济建设贡献了力量。 </GuideStepsItem> </GuideSteps> + + <EndingOverlay + visible={endingGuideVisible} + audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_30.mp3')} + text="接下来,请你跨越时空,走进1915年的申新纺织厂,参与工厂的创办与运营中,在模拟决策中感受民族工业在风雨中崛起的力量。让我们带着这份感动与启发,传承爱国情怀,续写新时代的实业篇章。" + animationSpeed={200} + onContinue={() => setEndingGuideVisible(false)} + /> </div> ) } diff --git a/src/pages/rongdesheng/reflection/index.tsx b/src/pages/rongdesheng/reflection/index.tsx index 134bc45..ae8e82e 100644 --- a/src/pages/rongdesheng/reflection/index.tsx +++ b/src/pages/rongdesheng/reflection/index.tsx @@ -1,7 +1,7 @@ import { Image, StudyGuideModal, Video } from '@/components' import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { getBackgroundImage, getStaticImage } from '@/lib/utils' +import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { useRongdeshengStore } from '@/store/rongdeshengStore' import { FinalScene } from './components/FinalScene' import { HintPanel } from './components/HintPanel' @@ -350,6 +350,8 @@ const RongdeshengReflection = () => { setShowGuide(false) markReflectionGuideShown() }} + firstLineIndent="2em" + audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_31.mp3')} > 同学你好,欢迎来到“跨越时空·体悟”学习模块!{'\n'} 请登陆“时空回溯・亲历时代”体验仓,下一站:1915年,上海。{'\n'} diff --git a/src/pages/sunzhongshan/internalization.tsx b/src/pages/sunzhongshan/internalization.tsx index 04b251f..d4a5ff1 100644 --- a/src/pages/sunzhongshan/internalization.tsx +++ b/src/pages/sunzhongshan/internalization.tsx @@ -49,7 +49,7 @@ const starName = 'sunzhongshan' const QUIZ_ANCHORS: QuizAnchor[] = [ { id: 'quiz-1', - time: 60 + 13, //5.52分钟 + time: 60 + 14, //5.52分钟 title: '题目1', prompt: '【拖拽】习近平总书记在纪念孙中山先生诞辰150周年大会上指出: "中国共产党人是孙中山先生革命事业最坚定的', @@ -81,7 +81,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ id: 'quiz-2', time: 60 * 2, // 2分钟 title: '题目2', - prompt: '【多选】新时代青年传承“振兴中华”的精神,能够具体落实为( )。', + prompt: '【多选】新时代青年传承“振兴中华”的精神,能够具体落实为:', type: 'multiple', options: [ { @@ -152,7 +152,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: 'quiz-4', - time: 60 * 5 + 40, + time: 60 * 5 + 40.5, title: '题目4', prompt: '【多选】新时代青年学习“与时俱进”的智慧能够具体落实为:', type: 'multiple', @@ -182,7 +182,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: 'quiz-5', - time: 60 * 5 + 40, + time: 60 * 5 + 40.5, title: '题目5', prompt: '【多选】新时代的青年要从孙中山先生身上学习哪些精神?', type: 'multiple', @@ -477,6 +477,7 @@ const SunzhongshanInternalization = () => { {/* 旁白 */} <Narration visible={showNarration} + audioChannelId="sunzhongshan-internalization-narration" text="从翠亨村的农家院落到影响中国的革命征程,孙中山先生以'亟拯斯民于水火,切扶大厦之将倾'的担当,在近代中国的黑暗中点燃了民主共和的火种。他留下的不仅是推翻帝制、建立共和的历史功绩,更有'振兴中华'的初心、'天下为公'的情怀、'与时俱进'的智慧。今天,我们站在故居的青砖黛瓦间,不仅要回望他为民族独立、人民幸福奋斗的一生,更要让这份精神穿越时空,成为新时代青年砥砺前行的力量。" audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_55.mp3')} onClose={() => { @@ -487,6 +488,7 @@ const SunzhongshanInternalization = () => { {/* 对话引导 */} <GuideSteps visible={visible} + audioChannelId="sunzhongshan-internalization-guide-pre" onVisibleChange={(visible) => { setVisible(visible) if (!visible) { @@ -513,6 +515,7 @@ const SunzhongshanInternalization = () => { {/* 视频结束后的引导 */} <GuideSteps visible={postGuideVisible} + audioChannelId="sunzhongshan-internalization-guide-post" onVisibleChange={(visible) => { setPostGuideVisible(visible) // 引导结束后显示信件 @@ -551,7 +554,7 @@ const SunzhongshanInternalization = () => { onContinue={() => { setShowStudyGuide(false) setCourseTimeProgress(6) - navigate(`/${moduleName}/${starName}/home`) + navigate('/menu') }} /> </div> diff --git a/src/pages/sunzhongshan/place/place2.tsx b/src/pages/sunzhongshan/place/place2.tsx index c69612e..71cacce 100644 --- a/src/pages/sunzhongshan/place/place2.tsx +++ b/src/pages/sunzhongshan/place/place2.tsx @@ -7,9 +7,12 @@ import { GuideSteps, GuideStepsItem, Video, + type DragFillResult, type VideoRef, } from '@/components' import { useCursorController } from '@/hooks/useCursorController' +import { SuccessAlert, DragQuizPanel } from '@/components/layout' +import { SingleChoiceQuizPanel } from '@/components/layout/quiz' import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { useNavigate, useParams } from 'react-router-dom' import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' @@ -60,6 +63,13 @@ const SunzhongshanPlace2 = () => { // 光标1的视频状态 const [showVideo1, setShowVideo1] = useState(false) const video1Ref = useRef<VideoRef>(null) + const [singleQuiz1Visible, setSingleQuiz1Visible] = useState(false) + const [dragQuiz1Visible, setDragQuiz1Visible] = useState(false) + const [dragQuiz1Value, setDragQuiz1Value] = useState<DragFillResult[]>([]) + const [dragQuiz1Error, setDragQuiz1Error] = useState('') + const [successAlert1Visible, setSuccessAlert1Visible] = useState(false) + const [successAlert1Content, setSuccessAlert1Content] = useState('') + const [pendingQuiz1, setPendingQuiz1] = useState<'drag' | 'complete' | null>(null) // 光标4的对话状态 const [guide4Visible, setGuide4Visible] = useState(false) @@ -68,6 +78,9 @@ const SunzhongshanPlace2 = () => { // 光标2的视频状态 const [showVideo2, setShowVideo2] = useState(false) const video2Ref = useRef<VideoRef>(null) + const [singleQuiz2Visible, setSingleQuiz2Visible] = useState(false) + const [successAlert2Visible, setSuccessAlert2Visible] = useState(false) + const [successAlert2Content, setSuccessAlert2Content] = useState('') const handleOpenExhibit = (index: 1 | 2 | 3 | 4) => { setActiveExhibitId(index) @@ -81,6 +94,24 @@ const SunzhongshanPlace2 = () => { } } + const handleSuccessAlert1Close = () => { + setSuccessAlert1Visible(false) + setSuccessAlert1Content('') + if (pendingQuiz1 === 'drag') { + setPendingQuiz1(null) + setDragQuiz1Visible(true) + } else if (pendingQuiz1 === 'complete') { + setPendingQuiz1(null) + markExhibitVisited(place, 1) + } + } + + const handleSuccessAlert2Close = () => { + setSuccessAlert2Visible(false) + setSuccessAlert2Content('') + markExhibitVisited(place, 2) + } + const progressCount = visited.size const total = 4 const completed = progressCount >= total @@ -233,18 +264,102 @@ const SunzhongshanPlace2 = () => { <Video ref={video1Ref} src={getPublicPath('video/sunzhongshan/sunzhongshan_05.mp4')} - className="fixed inset-0 z-[20000]" + className="fixed inset-0 z-20000" autoPlay playsInline showSkipButton onEnded={() => { setShowVideo1(false) - // 修改cursor状态 - markExhibitVisited(place, 1) + setSingleQuiz1Visible(true) }} /> )} + <SingleChoiceQuizPanel + visible={singleQuiz1Visible} + zIndex={10000} + title="【单选】1917年2月—1920年7月,孙中山在多年勘读舆图、多方考察的基础上,撰写:" + options={[ + { value: 'A', label: 'A. 实业计划' }, + { value: 'B', label: 'B. 建国方略' }, + { value: 'C', label: 'C. 建国大纲' }, + { value: 'D', label: 'D. 三民主义' }, + ]} + correctAnswer="A" + onWrongSelect={(value) => { + if (value === 'B') { + return '《建国方略》是孙中山的著作集,包含《孙文学说》《实业计划》《民权初步》三部分,并非1917—1920年这一单独时期内撰写的单一作品。请你再想想吧!' + } + if (value === 'C') { + return '《建国大纲》是孙中山在1924年发表的,明确了“军政、训政、宪政”三阶段的建国步骤。请你再想想吧!' + } + if (value === 'D') { + return '“三民主义”是孙中山在1905年提出的核心思想体系,后续虽有发展完善,但并非1917—1920年这一时期专门撰写的著作。请你再想想吧!' + } + return '请你再想想吧!' + }} + onSubmit={(_value, isCorrect) => { + if (isCorrect) { + setSingleQuiz1Visible(false) + setSuccessAlert1Content( + '《实业计划》是孙中山在1917年2月—1920年7月间撰写的重要著作,这是他为中国规划的宏大工业化蓝图。' + ) + setSuccessAlert1Visible(true) + setPendingQuiz1('drag') + } + }} + /> + + <DragQuizPanel + visible={dragQuiz1Visible} + zIndex={10000} + title="" + questionPrefix="【拖拽】《实业计划》全书共二十余万字,孙中山在其中强调了现代" + questionSuffix="的建设蓝图。三者相辅相成,前业筑舍,阶级收合,这三个问题的方法。" + options={[ + { value: '工业', label: '工业' }, + { value: '交通', label: '交通' }, + { value: '农业', label: '农业' }, + { value: '航天', label: '航天' }, + ]} + blanks={[ + { id: 'blank1', label: '____' }, + { id: 'blank2', label: '____' }, + { id: 'blank3', label: '____' }, + ]} + value={dragQuiz1Value} + error={dragQuiz1Error} + onChange={(value) => { + setDragQuiz1Value(value) + setDragQuiz1Error('') + if (value.length < 3) return + const correctAnswers = ['工业', '交通', '农业'] + const filledAnswers = value.map((v) => v.optionValue) + const answerSet = new Set(correctAnswers) + const filledSet = new Set(filledAnswers) + const isCorrect = + answerSet.size === filledSet.size && + [...answerSet].every((answer) => filledSet.has(answer)) + if (isCorrect) { + setDragQuiz1Visible(false) + setSuccessAlert1Content('恭喜你,选择正确!') + setSuccessAlert1Visible(true) + setPendingQuiz1('complete') + } else { + setDragQuiz1Error( + '《实业计划》是孙中山在为中国规划的工业化蓝图,其内容主要围绕工业、交通、农业等当时亟需发展的传统实践领域,而航天是20世纪中后期才兴起的科技领域,不在这部著作的构想范围内。请你再想想吧!' + ) + } + }} + /> + + <SuccessAlert + visible={successAlert1Visible} + content={successAlert1Content} + onContinue={handleSuccessAlert1Close} + onClose={handleSuccessAlert1Close} + /> + {/* 光标4的对话 */} <GuideSteps visible={guide4Visible} @@ -279,17 +394,46 @@ const SunzhongshanPlace2 = () => { <Video ref={video2Ref} src={getPublicPath('video/sunzhongshan/sunzhongshan_06.mp4')} - className="fixed inset-0 z-[20000]" + className="fixed inset-0 z-20000" autoPlay playsInline showSkipButton onEnded={() => { setShowVideo2(false) - // 修改cursor状态 - markExhibitVisited(place, 2) + setSingleQuiz2Visible(true) }} /> )} + + <SingleChoiceQuizPanel + visible={singleQuiz2Visible} + zIndex={10000} + title="【单选】护法运动,又称护法战争。第一次护法战争,1917年7月至1918年5月21日孙中山发动的维护《中华民国临时约法》和国会的军事行动。" + options={[ + { value: 'A', label: 'A. 中华民国临时政府组织大纲' }, + { value: 'B', label: 'B. 中华民国临时约法' }, + ]} + correctAnswer="B" + onWrongSelect={() => + '《中华民国临时政府组织大纲》是辛亥革命后为组建临时政府而制定的法律文件,主要规定了政府的组织架构,其效力在《中华民国临时约法》颁布后被取代,并非护法运动的核心诉求。请你再想想吧!' + } + onSubmit={(_value, isCorrect) => { + if (isCorrect) { + setSingleQuiz2Visible(false) + setSuccessAlert2Content( + '《中华民国临时约法》是辛亥革命的重要成果,体现了民主共和的精神。1917年段祺瑞拒绝恢复《临时约法》和国会,孙中山正是为了维护这部体现民主宪政的法律,才发起了护法运动。' + ) + setSuccessAlert2Visible(true) + } + }} + /> + + <SuccessAlert + visible={successAlert2Visible} + content={successAlert2Content} + onContinue={handleSuccessAlert2Close} + onClose={handleSuccessAlert2Close} + /> </div> ) } diff --git a/src/pages/sunzhongshan/place/place3.tsx b/src/pages/sunzhongshan/place/place3.tsx index f90c7ce..023a872 100644 --- a/src/pages/sunzhongshan/place/place3.tsx +++ b/src/pages/sunzhongshan/place/place3.tsx @@ -265,7 +265,7 @@ const SunzhongshanPlace3 = () => { speaker="student" audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_40.mp3')} > - 我记得国共第一次合作正式形成的标志就是国民党一大,这就是当时的大会宣言吗? + 我记得在毛泽东旧居陈列馆中学到,国共第一次合作正式形成的标志就是国民党一大,这就是当时的大会宣言吗? </GuideStepsItem> <GuideStepsItem speaker="guide" diff --git a/src/pages/sunzhongshan/reflection/index.tsx b/src/pages/sunzhongshan/reflection/index.tsx index 7fa7496..a467b2c 100644 --- a/src/pages/sunzhongshan/reflection/index.tsx +++ b/src/pages/sunzhongshan/reflection/index.tsx @@ -181,6 +181,7 @@ const SunzhongshanReflection = () => { <StudyGuideModal visible={showGuide} onContinue={handleGuideClose} + firstLineIndent="2em" audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_47.mp3')} > 同学你好,欢迎来到“跨越时空·体悟”学习模块!{'\n'} diff --git a/src/pages/sunzhongshan/reflection/scene1.tsx b/src/pages/sunzhongshan/reflection/scene1.tsx index 01a8605..c3389f7 100644 --- a/src/pages/sunzhongshan/reflection/scene1.tsx +++ b/src/pages/sunzhongshan/reflection/scene1.tsx @@ -256,6 +256,12 @@ const SunzhongshanReflectionScene1 = () => { return getStaticImage(`${sceneId}/线索${clueNumber}-详情`) } + // 获取线索提示图片 + const getClueHintImage = (clueId: string): string | null => { + const clueNumber = clueId.split('clue')[1] + return getStaticImage(`${sceneId}/线索${clueNumber}-详情的详情`) + } + // 初始化线索(如果还没有) useEffect(() => { clueConfigs.forEach((clue) => { @@ -386,6 +392,7 @@ const SunzhongshanReflectionScene1 = () => { onClose={handleClueBookClose} getClueImage={getClueImage} getClueDetailImage={getClueDetailImage} + getClueHintImage={getClueHintImage} /> {/* 成功提示弹窗 */} @@ -435,6 +442,7 @@ const SunzhongshanReflectionScene1 = () => { <StudyGuideModal visible={showGuide} onContinue={handleGuideClose} + firstLineIndent="2em" zIndex={10000} audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_48.mp3')} > diff --git a/src/pages/zhangleping/draw/draw1.tsx b/src/pages/zhangleping/draw/draw1.tsx index 08b3da1..e579514 100644 --- a/src/pages/zhangleping/draw/draw1.tsx +++ b/src/pages/zhangleping/draw/draw1.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { Image } from '@/components' +import { ExhibitFlipOverlay, FlipActionButton, Image } from '@/components' import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils' import { useNavigate, useParams } from 'react-router-dom' import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore' @@ -17,6 +17,10 @@ import { sidebarBlocks, } from './draw1.config' import { TopMenuPanel } from './TopMenuPanel' +import { SanmaoDialogueItem, SanmaoDialogueSteps } from '@/pages/zhangleping/components' + +const COMPLETION_DIALOGUE = + '太好啦!我的记忆都回来啦,还拥有了新时代的模样!爷爷画的我,藏着善良、勇敢与热爱;你画的我,让这些美好品质在今天继续发光!谢谢你,让三毛精神一直传承下去!' const ZhanglepingDraw1 = () => { const navigate = useNavigate() @@ -30,6 +34,9 @@ const ZhanglepingDraw1 = () => { const [showCaptionInput, setShowCaptionInput] = useState(false) const [captionText, setCaptionText] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) + const [createdArtworkSrc, setCreatedArtworkSrc] = useState('') + const [showCreatedOverlay, setShowCreatedOverlay] = useState(false) + const [showCompletionDialogue, setShowCompletionDialogue] = useState(false) const captionInputRef = useRef<HTMLInputElement>(null) const compositionRef = useRef<HTMLDivElement>(null) @@ -75,14 +82,15 @@ const ZhanglepingDraw1 = () => { setShowCaptionInput(true) return } - if (isSubmitting) return + if (isSubmitting || !compositionRef.current) return setIsSubmitting(true) try { - const dataUrl = await buildCompositeDataUrl(compositionRef.current!) + const dataUrl = await buildCompositeDataUrl(compositionRef.current) setDrawArtwork(dataUrl, captionText.trim()) saveZhanglepingStore() - navigate(`/${moduleName}/${STAR_NAME}/reflection5`) + setCreatedArtworkSrc(dataUrl) + setShowCreatedOverlay(true) } catch (error) { console.error('Failed to submit draw artwork', error) } finally { @@ -90,6 +98,10 @@ const ZhanglepingDraw1 = () => { } } + const handleCollectArtwork = () => { + navigate(`/${moduleName}/${STAR_NAME}/reflection5`) + } + const isPreviewVisible = activeBlock !== '任务提示' if (!moduleName) return null @@ -226,6 +238,30 @@ const ZhanglepingDraw1 = () => { onClick={handleSubmitArtwork} /> </div> + + {createdArtworkSrc && ( + <ExhibitFlipOverlay + src={createdArtworkSrc} + visible={showCreatedOverlay} + // footerClassName="bottom-120" + onEnterComplete={() => { + setShowCompletionDialogue(true) + }} + onClose={() => { + setShowCreatedOverlay(false) + }} + footer={<FlipActionButton onClick={handleCollectArtwork}>收藏</FlipActionButton>} + /> + )} + + <SanmaoDialogueSteps + visible={showCompletionDialogue} + onVisibleChange={setShowCompletionDialogue} + showMask={false} + zIndex={10020} + > + <SanmaoDialogueItem>{COMPLETION_DIALOGUE}</SanmaoDialogueItem> + </SanmaoDialogueSteps> </div> ) } diff --git a/src/pages/zhangleping/home.tsx b/src/pages/zhangleping/home.tsx index 8a2bfe5..f0132e4 100644 --- a/src/pages/zhangleping/home.tsx +++ b/src/pages/zhangleping/home.tsx @@ -1,6 +1,7 @@ import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { useNavigate, useParams } from 'react-router-dom' import { Image } from '@/components' +import { useZhanglepingStore } from '@/store/zhanglepingStore' const ZhanglepingHome = () => { const navigate = useNavigate() @@ -11,6 +12,7 @@ const ZhanglepingHome = () => { // starName 固定为 zhangleping const starName = 'zhangleping' + const lastVisitedPlace = useZhanglepingStore((state) => state.lastVisitedPlace) if (!moduleName) return null return ( @@ -32,7 +34,7 @@ const ZhanglepingHome = () => { <Image button src={getStaticImage('star/一-解锁')} - onClick={() => navigate(`/${moduleName}/${starName}/place1`)} + onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)} /> {/* 体悟 / 内化 预留 */} <Image diff --git a/src/pages/zhangleping/internalization.tsx b/src/pages/zhangleping/internalization.tsx index 55f7387..11284c4 100644 --- a/src/pages/zhangleping/internalization.tsx +++ b/src/pages/zhangleping/internalization.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Image, @@ -13,13 +13,131 @@ import { SuccessAlert } from '@/components/layout' import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz' import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { GuideSteps, GuideStepsItem } from '@/components' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' const starName = 'zhangleping' +type QuizOption = { + value: string + label: string + isCorrect?: boolean + explanation?: string +} + +type QuizAnchor = { + id: string + time: number + type: 'single' | 'multiple' + title: string + options: QuizOption[] + correctAnswer: string | string[] +} + +const QUIZ_ANCHORS: QuizAnchor[] = [ + { + id: 'quiz-1', + time: 60 * 1 + 49, + type: 'single', + title: '【单选】张乐平的漫画能够比直白的道理说教更吸引人、更易传递美好品质的核心原因是:', + options: [ + { + value: 'A', + label: 'A. 绘画技巧高超,色彩搭配丰富', + explanation: '张乐平的漫画魅力不在于复杂的技巧和色彩,而在于内容的真实性哦!请你再想想吧!', + }, + { + value: 'B', + label: 'B. 源于生活实际,以贴近现实的“小切片”传递情感与品质', + explanation: + '艺术源于生活,张乐平的漫画捕捉生活中的真实场景与人物,没有口号式宣传,做到了潜移默化、寓教于乐。', + }, + { + value: 'C', + label: 'C. 故事情节离奇,充满奇幻色彩', + explanation: '张乐平的漫画聚焦普通民众的生活与苦难,并非离奇的奇幻故事。请你再想想吧!', + }, + ], + correctAnswer: 'B', + }, + { + id: 'quiz-2', + time: 60 * 3 + 23, + type: 'multiple', + title: '【多选】张乐平“化繁为简、以简为上”的艺术风格主要体现在以下哪些方面?', + options: [ + { + value: 'A', + label: 'A. 抓住人物表情、动作等细节刻画人物,不事无巨细描摹服装', + }, + { + value: 'B', + label: 'B. 场景刻画化繁为简,聚焦核心道具与场景,寥寥数笔呈现完整画面', + }, + { + value: 'C', + label: 'C. 刻意简化故事逻辑,降低读者理解难度', + explanation: + '张乐平的漫画是“形式简”而非“逻辑简”,核心是通过简洁形式传递深刻内涵。请你再想想吧!', + }, + ], + correctAnswer: ['A', 'B'], + }, + { + id: 'quiz-3', + time: 60 * 5 + 2, + type: 'single', + title: + '【判断】张乐平创作的“无字漫画”完全依靠人物的动态表情和场景刻画完成叙事,没有任何说明性文字和对话框。', + options: [ + { + value: 'A', + label: 'A. 正确', + explanation: '无字漫画的特点是无说明性文字、无对话框,仅靠画面完成叙事。', + }, + { + value: 'B', + label: 'B. 错误', + explanation: + '请回顾汪教授对“无字漫画”的解读,张乐平从1935年塑造三毛起就坚持这种创作形式哦!请你再想想吧!', + }, + ], + correctAnswer: 'A', + }, + { + id: 'quiz-4', + time: 60 * 5 + 2, + type: 'multiple', + title: '【多选】以下哪些属于张乐平先生的创作理念与艺术追求?', + options: [ + { + value: 'A', + label: 'A. 艺术源于生活,注重观察日常细节', + }, + { + value: 'B', + label: 'B. 化繁为简,追求“以少胜多”的艺术境界', + }, + { + value: 'C', + label: 'C. 用作品传递善良、正义与真善美', + }, + { + value: 'D', + label: 'D. 仅专注于个人艺术表达,不关心社会现实', + explanation: + '张乐平始终以画笔担当社会责任,关注社会现实与民众疾苦,并非只专注个人表达。请你再想想吧!', + }, + ], + correctAnswer: ['A', 'B', 'C'], + }, +] + const ZhanglepingInternalization = () => { const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string }>() + const { triggerPuzzleAnimation } = usePuzzleAnimation() + const [narrationVisible, setNarrationVisible] = useState(true) const [visible, setVisible] = useState(false) // 对话引导面板是否可见 const [showVideo, setShowVideo] = useState(false) // 内化主视频是否展示 @@ -50,126 +168,59 @@ const ZhanglepingInternalization = () => { } } - type QuizOption = { - value: string - label: string - isCorrect?: boolean - explanation?: string - } + const anchors: VideoAnchor[] = useMemo(() => { + return QUIZ_ANCHORS.map((item) => { + if (item.type === 'single') { + const correct = item.correctAnswer as string - type QuizAnchor = { - id: string - time: number - type: 'single' | 'multiple' - title: string - options: QuizOption[] - correctAnswer: string | string[] - } + return { + id: item.id, + time: item.time, + once: true, + afterResume: 'remove', + render: ({ resume }) => ( + <> + <SingleChoiceQuizPanel + visible={activeSuccess !== item.id} + zIndex={10000} + title={item.title} + options={item.options.map((opt) => ({ + value: opt.value, + label: opt.label, + }))} + correctAnswer={correct} + onWrongSelect={(value: string) => { + const opt = item.options.find((o) => o.value === value) + return opt?.explanation || '再思考一下,选择正确答案。' + }} + onSubmit={(value: string, isCorrect: boolean) => { + if (isCorrect) { + const opt = item.options.find((o) => o.value === value) + setActiveSuccess(item.id) + setSuccessContent(opt?.explanation || '恭喜你,选择正确!') + setResumeCallback(() => resume) + } + }} + /> + <SuccessAlert + visible={activeSuccess === item.id} + title={item.id === 'quiz-3' ? '恭喜你,判断正确!' : '恭喜你,选择正确!'} + content={successContent} + onContinue={() => { + setActiveSuccess(null) + setSuccessContent('') + const cb = resumeCallback + setResumeCallback(null) + cb?.() + }} + /> + </> + ), + } + } - // 视频内题目锚点(时间单位:秒) - const QUIZ_ANCHORS: QuizAnchor[] = [ - { - id: 'quiz-1', - time: 60 * 1 + 49, // 1:49 - type: 'multiple', - title: '【多选】张乐平的漫画能够比直白的道理说教更吸引人、更易传递美好品质的核心原因是:', - options: [ - { - value: 'A', - label: 'A. 绘画技巧高超,色彩搭配丰富', - explanation: - '张乐平的漫画魅力不在于复杂的技巧和色彩,而在于内容的真实性哦!请你再想想吧!', - }, - { - value: 'B', - label: 'B. 源于生活实际,以贴近现实的“小切片”传递情感与品质', - explanation: - '艺术源于生活,张乐平的漫画捕捉生活中的真实场景与人物,没有口号式宣传,做到了潜移默化、寓教于乐。', - }, - { - value: 'C', - label: 'C. 故事情节离奇,充满奇幻色彩', - explanation: '张乐平的漫画聚焦普通民众的生活与苦难,并非离奇的奇幻故事。请你再想想吧!', - }, - ], - correctAnswer: ['A', 'B', 'C'], - }, - { - id: 'quiz-2', - time: 60 * 3 + 22, // 3:22 - type: 'multiple', - title: '【多选】张乐平“化繁为简、以简为上”的艺术风格主要体现在以下哪些方面?', - options: [ - { - value: 'A', - label: 'A. 抓住人物表情、动作等细节刻画人物,不事无巨细描摹服装', - }, - { - value: 'B', - label: 'B. 场景刻画化繁为简,聚焦核心道具与场景,寥寥数笔呈现完整画面', - }, - { - value: 'C', - label: 'C. 刻意简化故事逻辑,降低读者理解难度', - explanation: - '张乐平的漫画是“形式简”而非“逻辑简”,核心是通过简洁形式传递深刻内涵。请你再想想吧!', - }, - ], - correctAnswer: ['A', 'B'], - }, - { - id: 'quiz-3', - time: 60 * 5 + 1, // 5:01 - type: 'single', - title: - '【判断】张乐平创作的“无字漫画”完全依靠人物的动态表情和场景刻画完成叙事,没有任何说明性文字和对话框。', - options: [ - { - value: 'A', - label: 'A. 正确', - explanation: '无字漫画的特点是无说明性文字、无对话框,仅靠画面完成叙事。', - }, - { - value: 'B', - label: 'B. 错误', - explanation: - '请回顾汪教授对“无字漫画”的解读,张乐平从1935年塑造三毛起就坚持这种创作形式哦!请你再想想吧!', - }, - ], - correctAnswer: 'A', - }, - { - id: 'quiz-4', - time: 60 * 5 + 1, // 5:01,同一时间出现(对应图3、4) - type: 'multiple', - title: '【多选】以下哪些属于张乐平先生的创作理念与艺术追求?', - options: [ - { - value: 'A', - label: 'A. 艺术源于生活,注重观察日常细节', - }, - { - value: 'B', - label: 'B. 化繁为简,追求“以少胜多”的艺术境界', - }, - { - value: 'C', - label: 'C. 用作品传递善良、正义与真善美', - }, - { - value: 'D', - label: 'D. 仅专注于个人艺术表达,不关心社会现实', - explanation: - '张乐平始终以画笔担当社会责任,关注社会现实与民众疾苦,并非只专注个人表达。请你再想想吧!', - }, - ], - correctAnswer: ['A', 'B', 'C'], - }, - ] - - const anchors: VideoAnchor[] = QUIZ_ANCHORS.map((item) => { - if (item.type === 'single') { - const correct = item.correctAnswer as string + // 多选题 + const correctAnswers = item.correctAnswer as string[] return { id: item.id, @@ -178,7 +229,7 @@ const ZhanglepingInternalization = () => { afterResume: 'remove', render: ({ resume }) => ( <> - <SingleChoiceQuizPanel + <MultipleChoiceQuizPanel visible={activeSuccess !== item.id} zIndex={10000} title={item.title} @@ -186,23 +237,77 @@ const ZhanglepingInternalization = () => { value: opt.value, label: opt.label, }))} - correctAnswer={correct} - onWrongSelect={(value: string) => { - const opt = item.options.find((o) => o.value === value) - return opt?.explanation || '再思考一下,选择正确答案。' + correctAnswer={correctAnswers} + errorMessage={(selectedValues: string[]) => { + const picked = new Set(selectedValues) + + if (item.id === 'quiz-1') { + if (picked.has('A')) { + const optA = item.options.find((o) => o.value === 'A') + return ( + optA?.explanation || + '张乐平的漫画魅力不在于复杂的技巧和色彩,而在于内容的真实性哦!请你再想想吧!' + ) + } + if (picked.has('C')) { + const optC = item.options.find((o) => o.value === 'C') + return ( + optC?.explanation || + '张乐平的漫画聚焦普通民众的生活与苦难,并非离奇的奇幻故事。请你再想想吧!' + ) + } + return '你的选择还不够全面哦~请你再想想吧!' + } + + if (item.id === 'quiz-2') { + if (picked.has('C')) { + const optC = item.options.find((o) => o.value === 'C') + return ( + optC?.explanation || + '张乐平的漫画是“形式简”而非“逻辑简”,核心是通过简洁形式传递深刻内涵。请你再想想吧!' + ) + } + return '你的选择还不够全面哦~请你再想想吧!' + } + + if (item.id === 'quiz-4') { + if (picked.has('D')) { + const optD = item.options.find((o) => o.value === 'D') + return ( + optD?.explanation || + '张乐平始终以画笔担当社会责任,关注社会现实与民众疾苦,并非只专注个人表达。请你再想想吧!' + ) + } + return '你的选择还不够全面哦~请你再想想吧!' + } + + return '你的选择还不够全面哦~请你再想想吧!' }} - onSubmit={(value: string, isCorrect: boolean) => { + onSubmit={(values: string[], isCorrect: boolean) => { if (isCorrect) { - const opt = item.options.find((o) => o.value === value) setActiveSuccess(item.id) - setSuccessContent(opt?.explanation || '恭喜你,选择正确!') + if (item.id === 'quiz-1') { + setSuccessContent( + '艺术源于生活,张乐平的漫画捕捉生活中的真实场景与人物,没有口号式宣传,做到了潜移默化、寓教于乐。' + ) + } else if (item.id === 'quiz-2') { + setSuccessContent( + '先生善于捕捉核心细节展现人物特质,化繁为简的场景刻画让漫画更具感染力,这正是“以少胜多”艺术境界的体现。' + ) + } else if (item.id === 'quiz-4') { + triggerPuzzleAnimation(15) + setSuccessContent( + '张乐平先生坚持艺术扎根生活,以简洁笔墨刻画时代,用作品传递温暖与正义,这些都是他的核心创作理念。' + ) + } else { + setSuccessContent('') + } setResumeCallback(() => resume) } }} /> <SuccessAlert visible={activeSuccess === item.id} - title={item.id === 'quiz-3' ? '恭喜你,判断正确!' : '恭喜你,选择正确!'} content={successContent} onContinue={() => { setActiveSuccess(null) @@ -215,109 +320,8 @@ const ZhanglepingInternalization = () => { </> ), } - } - - // 多选题 - const correctAnswers = item.correctAnswer as string[] - - return { - id: item.id, - time: item.time, - once: true, - afterResume: 'remove', - render: ({ resume }) => ( - <> - <MultipleChoiceQuizPanel - visible={activeSuccess !== item.id} - zIndex={10000} - title={item.title} - options={item.options.map((opt) => ({ - value: opt.value, - label: opt.label, - }))} - correctAnswer={correctAnswers} - errorMessage={(selectedValues: string[]) => { - const picked = new Set(selectedValues) - - if (item.id === 'quiz-1') { - if (picked.has('A')) { - const optA = item.options.find((o) => o.value === 'A') - return ( - optA?.explanation || - '张乐平的漫画魅力不在于复杂的技巧和色彩,而在于内容的真实性哦!请你再想想吧!' - ) - } - if (picked.has('C')) { - const optC = item.options.find((o) => o.value === 'C') - return ( - optC?.explanation || - '张乐平的漫画聚焦普通民众的生活与苦难,并非离奇的奇幻故事。请你再想想吧!' - ) - } - return '你的选择还不够全面哦~请你再想想吧!' - } - - if (item.id === 'quiz-2') { - if (picked.has('C')) { - const optC = item.options.find((o) => o.value === 'C') - return ( - optC?.explanation || - '张乐平的漫画是“形式简”而非“逻辑简”,核心是通过简洁形式传递深刻内涵。请你再想想吧!' - ) - } - return '你的选择还不够全面哦~请你再想想吧!' - } - - if (item.id === 'quiz-4') { - if (picked.has('D')) { - const optD = item.options.find((o) => o.value === 'D') - return ( - optD?.explanation || - '张乐平始终以画笔担当社会责任,关注社会现实与民众疾苦,并非只专注个人表达。请你再想想吧!' - ) - } - return '你的选择还不够全面哦~请你再想想吧!' - } - - return '你的选择还不够全面哦~请你再想想吧!' - }} - onSubmit={(values: string[], isCorrect: boolean) => { - if (isCorrect) { - setActiveSuccess(item.id) - if (item.id === 'quiz-1') { - setSuccessContent( - '艺术源于生活,张乐平的漫画捕捉生活中的真实场景与人物,没有口号式宣传,做到了潜移默化、寓教于乐。' - ) - } else if (item.id === 'quiz-2') { - setSuccessContent( - '先生善于捕捉核心细节展现人物特质,化繁为简的场景刻画让漫画更具感染力,这正是“以少胜多”艺术境界的体现。' - ) - } else if (item.id === 'quiz-4') { - setSuccessContent( - '张乐平先生坚持艺术扎根生活,以简洁笔墨刻画时代,用作品传递温暖与正义,这些都是他的核心创作理念。' - ) - } else { - setSuccessContent('') - } - setResumeCallback(() => resume) - } - }} - /> - <SuccessAlert - visible={activeSuccess === item.id} - content={successContent} - onContinue={() => { - setActiveSuccess(null) - setSuccessContent('') - const cb = resumeCallback - setResumeCallback(null) - cb?.() - }} - /> - </> - ), - } - }) + }) + }, [activeSuccess, resumeCallback, successContent, triggerPuzzleAnimation]) if (!moduleName) return null @@ -345,7 +349,7 @@ const ZhanglepingInternalization = () => { onClose={() => { setNarrationVisible(false) }} - text="从弄堂里的三毛记忆到画室中的笔墨传承,张乐平先生以一支画笔为刃,划破黑暗现实的迷雾;以一颗赤诚之心,守护万千儿童的童真。他笔下的三毛,从流浪儿到新中国少年,是一个民族的成长缩影;他坚持的艺术,源于生活、归于真诚,是跨越时空的精神纽带。今天,我们站在张乐平故居的画桌前,不仅要回望先生以笔为业、以爱为怀的一生,更要让这份艺术精神内化于心、外化于行,成为新时代中职学子践行初心、传递美好的力量。" + text="从弄堂里的三毛记忆到画室中的笔墨传承,张乐平先生以一支画笔为刃,划破黑暗现实的迷雾;以一颗赤诚之心,守护万千儿童的童真。他笔下的三毛,从流浪儿到新中国少年,是一个民族的成长缩影;他坚持的艺术,源于生活、归于真诚,是跨越时空的精神纽带。今天,我们站在张乐平故居的画桌前,不仅要回望先生以笔为业、以爱为怀的一生,更要让这份艺术精神内化于心、外化于行,成为新时代青年学子践行初心、传递美好的力量。" /> {/* 题目弹窗(旁白结束后出现) */} @@ -436,6 +440,7 @@ const ZhanglepingInternalization = () => { onVisibleChange={(v) => { setPostGuideVisible(v) if (!v) { + triggerPuzzleAnimation(16) setShowLetter(true) } }} @@ -475,7 +480,7 @@ const ZhanglepingInternalization = () => { animationSpeed={200} onContinue={() => { setShowStudyGuide(false) - navigate(`/${moduleName}/${starName}/home`) + navigate('/menu') }} /> </div> diff --git a/src/pages/zhangleping/place/place1.tsx b/src/pages/zhangleping/place/place1.tsx index 0c61ac1..a6d81a7 100644 --- a/src/pages/zhangleping/place/place1.tsx +++ b/src/pages/zhangleping/place/place1.tsx @@ -8,10 +8,11 @@ import { } from '@/components' import { useNavigate, useParams } from 'react-router-dom' import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' -import { useMemo, useState } from 'react' +import { useState } from 'react' import Cursor from '@/components/cursor' import { useCursorController } from '@/hooks/useCursorController' import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' // starName 固定为 zhangleping const starName = 'zhangleping' @@ -29,16 +30,14 @@ const ZhanglepingPlace1 = () => { moduleName: string }>() - // 接入持久化:place1 展品访问与断点 - const markVisited = useZhanglepingStore((s) => s.markExhibitVisited) - const getVisited = useZhanglepingStore((s) => s.getVisitedExhibits) - const setLastVisitedPlace = useZhanglepingStore((s) => s.setLastVisitedPlace) - const persistedVisited = useMemo(() => new Set(getVisited('place1') as (1 | 2)[]), [getVisited]) + const { triggerPuzzleAnimation } = usePuzzleAnimation() + const place = 'place1' + const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useZhanglepingStore() + + const visitedExhibits = getVisitedExhibits(place) as (1 | 2)[] + const visited = new Set<1 | 2>(visitedExhibits) - // 当前激活的展品 ID const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null) - // 已访问的展品(本页内状态;如需持久化可接入 store) - const [visited, setVisited] = useState<Set<1 | 2>>(() => new Set(persistedVisited)) // 展品1的对话引导状态 const [guideVisible1, setGuideVisible1] = useState(false) @@ -157,7 +156,7 @@ const ZhanglepingPlace1 = () => { speaker="guide" audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_04.mp3')} > - 是的。张乐平先生1910年生于浙江农村,母亲是他绘画的启蒙人。你看这里的绣品,就源自张先生的母亲。 + 是的。张乐平先生1910年生于浙江农村,母亲是他绘画的启蒙人。 </GuideStepsItem> </GuideSteps> @@ -175,7 +174,10 @@ const ZhanglepingPlace1 = () => { speaker="guide" audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_05.mp3')} > - 她能字画、工女红,尤擅剪纸、刺绣。张先生自幼耳濡目染,受到启蒙,在小学校师陆寅生指导下,表现出不凡的绘画才能。因为有绘画的陪伴,张先生的童年还是过得非常愉快的,正如你看到的《童年的回忆》这幅画。 + 你看这里的绣品,就源自张先生的母亲。她能字画、工女红,尤擅剪纸、刺绣。张先生自幼耳濡目染,受到启蒙,在小学老师陆寅生指导下,表现出不凡的绘画才能。 + </GuideStepsItem> + <GuideStepsItem speaker="guide"> + 因为有绘画的陪伴,张先生的童年还是过得非常愉快的,正如你看到的《童年的回忆》这幅画。 </GuideStepsItem> <GuideStepsItem speaker="guide"> 但是因为家境贫寒,张先生15岁就只身来到南汇万祥镇,在木料行当学徒,其间他在老板的纸堆上偷偷画画,哪怕被责打也不放弃,还用芦条在沙滩上画巨龙,巨龙里蕴藏的就是他“以画报国”的初心。 @@ -189,14 +191,17 @@ const ZhanglepingPlace1 = () => { <ExhibitOverlay visible={activeExhibitId !== null} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} + onOpen={() => { + if (activeExhibitId === 1) { + triggerPuzzleAnimation(1) + } + if (activeExhibitId === 2) { + triggerPuzzleAnimation(2) + } + }} onClose={() => { if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev) - next.add(activeExhibitId) - markVisited('place1', activeExhibitId) - return next - }) + markExhibitVisited(place, activeExhibitId) } setActiveExhibitId(null) }} @@ -224,6 +229,7 @@ const ZhanglepingPlace1 = () => { visible={showAfterDialogGuide} audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_01.mp3')} onContinue={() => setShowAfterDialogGuide(false)} + firstLineIndent="2em" > 同学你好,欢迎来到“走进故居·对话”学习模块! {'\n'} diff --git a/src/pages/zhangleping/place/place2.tsx b/src/pages/zhangleping/place/place2.tsx index e0a972f..ff35079 100644 --- a/src/pages/zhangleping/place/place2.tsx +++ b/src/pages/zhangleping/place/place2.tsx @@ -5,26 +5,28 @@ import { Image, Title, MatchQuizPanel, + SingleChoiceQuizPanel, Video, type VideoRef, } from '@/components' import { useNavigate, useParams } from 'react-router-dom' import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils' -import { useMemo, useRef, useState } from 'react' +import { useRef, useState } from 'react' import Cursor from '@/components/cursor' import { useCursorController } from '@/hooks/useCursorController' import SuccessAlert from '@/components/layout/alert/success-alert' import ScaledPortal from '@/components/layout/ScaledPortal' import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' // starName 固定为 zhangleping const starName = 'zhangleping' // 展品 ID 与资源路径映射 const exhibitSrcMap: Record<1 | 2 | 3, string> = { - 1: 'zhangleping/place2/张乐平场景2展品1', + 1: 'zhangleping/place2/张乐平场景2展品3', 2: 'zhangleping/place2/张乐平场景2展品2', - 3: 'zhangleping/place2/张乐平场景2展品3', + 3: 'zhangleping/place2/张乐平场景2展品1', } // 阶段二:抗战画家(3 个光标) @@ -34,16 +36,13 @@ const ZhanglepingPlace2 = () => { moduleName: string }>() + const { triggerPuzzleAnimation } = usePuzzleAnimation() + const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null) - // 持久化接入 - const markVisited = useZhanglepingStore((s) => s.markExhibitVisited) - const getVisited = useZhanglepingStore((s) => s.getVisitedExhibits) - const setLastVisitedPlace = useZhanglepingStore((s) => s.setLastVisitedPlace) - const persistedVisited = useMemo( - () => new Set(getVisited('place2') as (1 | 2 | 3)[]), - [getVisited] - ) - const [visited, setVisited] = useState<Set<1 | 2 | 3>>(() => new Set(persistedVisited)) + const place = 'place2' + const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useZhanglepingStore() + const visitedExhibits = getVisitedExhibits(place) as (1 | 2 | 3)[] + const visited = new Set<1 | 2 | 3>(visitedExhibits) const [guideVisible1, setGuideVisible1] = useState(false) const [guideStep1, setGuideStep1] = useState(0) @@ -63,6 +62,8 @@ const ZhanglepingPlace2 = () => { // 展品1:关闭后播放的视频 const [showVideo1, setShowVideo1] = useState(false) const video1Ref = useRef<VideoRef>(null) + const [quizVisible1, setQuizVisible1] = useState(false) + const [successAlert1Visible, setSuccessAlert1Visible] = useState(false) // 展品2:关闭后播放的视频 const [showVideo2, setShowVideo2] = useState(false) @@ -121,7 +122,7 @@ const ZhanglepingPlace2 = () => { button onClick={() => { if (moduleName && starName) { - navigate(`/${moduleName}/${starName}/home`) + navigate(`/${moduleName}/${starName}/place1`) } }} /> @@ -228,6 +229,17 @@ const ZhanglepingPlace2 = () => { <ExhibitOverlay visible={activeExhibitId !== null} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} + onOpen={() => { + if (activeExhibitId === 3) { + triggerPuzzleAnimation(3) + } + if (activeExhibitId === 2) { + triggerPuzzleAnimation(4) + } + if (activeExhibitId === 1) { + triggerPuzzleAnimation(5) + } + }} onClose={() => { if (activeExhibitId !== null) { // 展品1:关闭弹窗时(对话可能还在进行),先不关闭弹窗,等待对话完全结束 @@ -251,22 +263,9 @@ const ZhanglepingPlace2 = () => { } // 展品3:需在完成连线题后才标记为已访问 - if (activeExhibitId === 3) { - if (quizPassed3) { - setVisited((prev) => { - const next = new Set(prev) - next.add(3) - markVisited('place2', 3) - return next - }) - } + if (activeExhibitId === 3 && quizPassed3) { + markExhibitVisited(place, 3) } - // setVisited((prev) => { - // const next = new Set(prev) - // next.add(activeExhibitId) - // markVisited('place2', activeExhibitId) - // return next - // }) } setActiveExhibitId(null) }} @@ -283,36 +282,84 @@ const ZhanglepingPlace2 = () => { showSkipButton onEnded={() => { setShowVideo1(false) - setVisited((prev) => { - const next = new Set(prev) - next.add(1) - markVisited('place2', 1) - return next - }) + setQuizVisible1(true) + markExhibitVisited(place, 1) }} onSkip={() => { setShowVideo1(false) + setQuizVisible1(true) }} /> )} + <ScaledPortal enabled={quizVisible1 || successAlert1Visible} zIndex={10050}> + <> + <SingleChoiceQuizPanel + visible={quizVisible1} + zIndex={10000} + title="【单选】抗战时期,张乐平先生的画笔是“刀枪”,他的作品主要传达了以下哪种精神?" + options={[ + { + value: 'A', + label: 'A.逃避现实,只描绘美好的幻想世界,让大家暂时忘却痛苦', + }, + { + value: 'B', + label: 'B.用幽默搞笑的方式逗大家开心,没有更深层次的意义', + }, + { + value: 'C', + label: 'C.坚定地揭露日寇暴行,激发人们的爱国热情和抗争精神', + }, + { + value: 'D', + label: 'D.只关注个人的艺术创作,不关心社会和国家的命运', + }, + ]} + correctAnswer="C" + onWrongSelect={(value) => { + if (value === 'A') { + return '逃避现实与他的实际行动不符。请你再想想吧!' + } + if (value === 'B') { + return '只强调幽默搞笑,忽略了作品的深刻意义。请你再想想吧!' + } + if (value === 'D') { + return '不关心社会和国家命运与张乐平先生的抗战宣传行为相悖。请你再想想吧!' + } + return '请你再想想吧!' + }} + onSubmit={(_value, isCorrect) => { + if (!isCorrect) return + setQuizVisible1(false) + setSuccessAlert1Visible(true) + }} + /> + + <SuccessAlert + visible={successAlert1Visible} + title="恭喜你,选择正确!" + content="在抗战时期,张乐平先生以抗日救亡漫画宣传队副领队身份北上宣传抗日,带领宣传队足迹遍及多地,他的漫画被译成多国文字,印成传单,空投敌占区,宣传正义,呼唤良知。这充分体现出他用画笔坚定地揭露日寇暴行,激发人们的爱国热情和抗争精神。" + onContinue={() => { + setSuccessAlert1Visible(false) + markExhibitVisited(place, 1) + }} + /> + </> + </ScaledPortal> + {/* 展品2关闭后的讲解视频 */} {showVideo2 && ( <Video ref={video2Ref} - src={getPublicPath('video/sunzhongshan/sunzhongshan_03.mp4')} + src={getPublicPath('video/zhangleping/zhangleping_00.mp4')} className="fixed inset-0 z-20000" autoPlay playsInline showSkipButton onEnded={() => { setShowVideo2(false) - setVisited((prev) => { - const next = new Set(prev) - next.add(2) - markVisited('place2', 2) - return next - }) + markExhibitVisited(place, 2) }} onSkip={() => { setShowVideo2(false) @@ -365,12 +412,7 @@ const ZhanglepingPlace2 = () => { setQuizVisible3(false) setQuizPassed3(true) setSuccessAlert3Visible(true) - // 答题成功后标记展品3为已访问 - setVisited((prev) => { - const next = new Set(prev) - next.add(3) - return next - }) + markExhibitVisited(place, 3) } }} zIndex={10000} @@ -378,6 +420,7 @@ const ZhanglepingPlace2 = () => { <SuccessAlert visible={successAlert3Visible} + title="恭喜你,匹配正确!" onContinue={() => { setSuccessAlert3Visible(false) }} diff --git a/src/pages/zhangleping/place/place3.tsx b/src/pages/zhangleping/place/place3.tsx index 572018a..e1f5499 100644 --- a/src/pages/zhangleping/place/place3.tsx +++ b/src/pages/zhangleping/place/place3.tsx @@ -10,21 +10,22 @@ import { } from '@/components' import { useNavigate, useParams } from 'react-router-dom' import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils' -import { useMemo, useRef, useState } from 'react' +import { useRef, useState } from 'react' import Cursor from '@/components/cursor' import { useCursorController } from '@/hooks/useCursorController' import SuccessAlert from '@/components/layout/alert/success-alert' import ScaledPortal from '@/components/layout/ScaledPortal' import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' // starName 固定为 zhangleping const starName = 'zhangleping' // 展品 ID 与资源路径映射 const exhibitSrcMap: Record<1 | 2 | 3, string> = { - 1: 'zhangleping/place3/张乐平场景3展品1', + 1: 'zhangleping/place3/张乐平场景3展品3', 2: 'zhangleping/place3/张乐平场景3展品2', - 3: 'zhangleping/place3/张乐平场景3展品3', + 3: 'zhangleping/place3/张乐平场景3展品1', } // 阶段三:三毛之父(3 个光标) @@ -34,16 +35,13 @@ const ZhanglepingPlace3 = () => { moduleName: string }>() + const { triggerPuzzleAnimation } = usePuzzleAnimation() const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null) - // 持久化接入 - const markVisited = useZhanglepingStore((s) => s.markExhibitVisited) - const getVisited = useZhanglepingStore((s) => s.getVisitedExhibits) - const setLastVisitedPlace = useZhanglepingStore((s) => s.setLastVisitedPlace) - const persistedVisited = useMemo( - () => new Set(getVisited('place3') as (1 | 2 | 3)[]), - [getVisited] - ) - const [visited, setVisited] = useState<Set<1 | 2 | 3>>(() => new Set(persistedVisited)) + const place = 'place3' + const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useZhanglepingStore() + + const visitedExhibits = getVisitedExhibits(place) as (1 | 2 | 3)[] + const visited = new Set<1 | 2 | 3>(visitedExhibits) const [guideVisible1, setGuideVisible1] = useState(false) const [guideStep1, setGuideStep1] = useState(0) @@ -121,7 +119,7 @@ const ZhanglepingPlace3 = () => { button onClick={() => { if (moduleName && starName) { - navigate(`/${moduleName}/${starName}/home`) + navigate(`/${moduleName}/${starName}/place2`) } }} /> @@ -231,6 +229,17 @@ const ZhanglepingPlace3 = () => { <ExhibitOverlay visible={activeExhibitId !== null} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} + onOpen={() => { + if (activeExhibitId === 3) { + triggerPuzzleAnimation(6) + } + if (activeExhibitId === 2) { + triggerPuzzleAnimation(7) + } + if (activeExhibitId === 1) { + triggerPuzzleAnimation(8) + } + }} onClose={() => { if (activeExhibitId !== null) { // 展品2:关闭后先播放一段视频,视频结束再进入题目 @@ -243,20 +252,10 @@ const ZhanglepingPlace3 = () => { // 展品3:需完成两道题目后才标记为已访问 if (activeExhibitId === 3) { if (quiz3Finished) { - setVisited((prev) => { - const next = new Set(prev) - next.add(3) - markVisited('place3', 3) - return next - }) + markExhibitVisited(place, 3) } } else { - setVisited((prev) => { - const next = new Set(prev) - next.add(activeExhibitId) - markVisited('place3', activeExhibitId) - return next - }) + markExhibitVisited(place, activeExhibitId) } } setActiveExhibitId(null) @@ -320,23 +319,17 @@ const ZhanglepingPlace3 = () => { content={successAlert2Content} onContinue={() => { setSuccessAlert2Visible(false) - // 答题成功后标记展品2为已访问 - setVisited((prev) => { - const next = new Set(prev) - next.add(2) - return next - }) + markExhibitVisited(place, 2) }} /> </> </ScaledPortal> - {/* 展品2对话结束后的讲解视频 */} {showVideo3 && ( <ScaledPortal enabled={true} zIndex={10050}> <Video ref={video3Ref} - src={getPublicPath('video/zhangleping/zhangleping_5.mp4')} + src={getPublicPath('video/zhangleping/zhangleping_01.mp4')} className="fixed inset-0 z-20000" autoPlay playsInline @@ -409,6 +402,7 @@ const ZhanglepingPlace3 = () => { {/* 每题答对后的统一提示框 */} <SuccessAlert visible={successAlert3Visible} + title={quiz3Step === 2 ? '恭喜你,判断正确!' : '恭喜你,选择正确!'} content={successAlert3Content} onContinue={() => { setSuccessAlert3Visible(false) @@ -419,14 +413,8 @@ const ZhanglepingPlace3 = () => { setQuiz3Visible(true) return 2 } - // 两题全部完成 setQuiz3Finished(true) - // 标记展品3为已访问 - setVisited((prevVisited) => { - const nextVisited = new Set(prevVisited) - nextVisited.add(3) - return nextVisited - }) + markExhibitVisited(place, 3) return prev }) }} diff --git a/src/pages/zhangleping/place/place4.tsx b/src/pages/zhangleping/place/place4.tsx index 76d3653..938cda8 100644 --- a/src/pages/zhangleping/place/place4.tsx +++ b/src/pages/zhangleping/place/place4.tsx @@ -8,12 +8,13 @@ import { } from '@/components' import { useNavigate, useParams } from 'react-router-dom' import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' -import { useMemo, useState } from 'react' +import { useState } from 'react' import Cursor from '@/components/cursor' import { useCursorController } from '@/hooks/useCursorController' import SuccessAlert from '@/components/layout/alert/success-alert' import ScaledPortal from '@/components/layout/ScaledPortal' import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' // starName 固定为 zhangleping const starName = 'zhangleping' @@ -30,13 +31,13 @@ const ZhanglepingPlace4 = () => { moduleName: string }>() + const { triggerPuzzleAnimation } = usePuzzleAnimation() + const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null) - // 持久化接入 - const markVisited = useZhanglepingStore((s) => s.markExhibitVisited) - const getVisited = useZhanglepingStore((s) => s.getVisitedExhibits) - const setLastVisitedPlace = useZhanglepingStore((s) => s.setLastVisitedPlace) - const persistedVisited = useMemo(() => new Set(getVisited('place4') as 1[]), [getVisited]) - const [visited, setVisited] = useState<Set<1>>(() => new Set(persistedVisited)) + const place = 'place4' + const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useZhanglepingStore() + const visitedExhibits = getVisitedExhibits(place) as 1[] + const visited = new Set<1>(visitedExhibits) const [guideVisible1, setGuideVisible1] = useState(false) const [guideStep1, setGuideStep1] = useState(0) @@ -44,7 +45,6 @@ const ZhanglepingPlace4 = () => { // 对话结束后的两道单选题 const [quizVisible, setQuizVisible] = useState(false) const [quizStep, setQuizStep] = useState<1 | 2>(1) - const [quizFinished, setQuizFinished] = useState(false) const [successAlertVisible, setSuccessAlertVisible] = useState(false) const [successAlertContent, setSuccessAlertContent] = useState('') @@ -121,7 +121,7 @@ const ZhanglepingPlace4 = () => { onVisibleChange={(visible) => { setGuideVisible1(visible) // 对话结束后依次进入两道题目(仅在未完成时触发) - if (!visible && !quizFinished) { + if (!visible) { setQuizStep(1) setQuizVisible(true) } @@ -147,23 +147,20 @@ const ZhanglepingPlace4 = () => { <GuideStepsItem speaker="guide"> 他说:“一看到孩子,我心里就乐。和他们在一起,我忘乎所以。我别无他技,只有把为孩子服务,当作报国的手段。”张先生为儿童作画,与孩子交流,从中汲取灵感与素材,不断创作出儿童喜爱的漫画作品,为万千儿童种下真善美的种子。 </GuideStepsItem> - <GuideStepsItem speaker="guide"> - 张乐平先生还为儿童写过很多诗,在创作之余,他经常带着小三毛到公园、街头写生,将所见所闻用文字和画笔记录下来。 - </GuideStepsItem> </GuideSteps> <ExhibitOverlay visible={activeExhibitId !== null} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} + onOpen={() => { + if (activeExhibitId === 1) { + triggerPuzzleAnimation(9) + } + }} onClose={() => { - // 需完成两道题目后才标记为已访问 - if (activeExhibitId !== null && quizFinished) { - setVisited((prev) => { - const next = new Set(prev) - next.add(activeExhibitId) - markVisited('place4', activeExhibitId) - return next - }) + if (activeExhibitId !== null) { + triggerPuzzleAnimation(10) + markExhibitVisited(place, activeExhibitId) } setActiveExhibitId(null) }} @@ -270,13 +267,6 @@ const ZhanglepingPlace4 = () => { setQuizVisible(true) return 2 } - // 两题全部完成:标记完成并更新 cursor 状态 - setQuizFinished(true) - setVisited((prevVisited) => { - const nextVisited = new Set(prevVisited) - nextVisited.add(1) - return nextVisited - }) return prev }) }} diff --git a/src/pages/zhangleping/place/place5.tsx b/src/pages/zhangleping/place/place5.tsx index ad8dbef..8df727e 100644 --- a/src/pages/zhangleping/place/place5.tsx +++ b/src/pages/zhangleping/place/place5.tsx @@ -8,10 +8,11 @@ import { } from '@/components' import { useNavigate, useParams } from 'react-router-dom' import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import Cursor from '@/components/cursor' import { useCursorController } from '@/hooks/useCursorController' import { useZhanglepingStore } from '@/store/zhanglepingStore' +import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext' // starName 固定为 zhangleping const starName = 'zhangleping' @@ -32,18 +33,16 @@ const ZhanglepingPlace5 = () => { const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null) + const { triggerPuzzleAnimation } = usePuzzleAnimation() + // 展品3:打开展品窗口时自动播放话外音 const [exhibit3AudioKey, setExhibit3AudioKey] = useState(0) const exhibit3AudioSrc = getPublicPath('audio/zhangleping/mp3_zhangleping_18.mp3') - // 持久化接入 - const markVisited = useZhanglepingStore((s) => s.markExhibitVisited) - const getVisited = useZhanglepingStore((s) => s.getVisitedExhibits) - const persistedVisited = useMemo( - () => new Set(getVisited('place5') as (1 | 2 | 3)[]), - [getVisited] - ) - const [visited, setVisited] = useState<Set<1 | 2 | 3>>(() => new Set(persistedVisited)) + const place = 'place5' + const { markExhibitVisited, getVisitedExhibits } = useZhanglepingStore() + const visitedExhibits = getVisitedExhibits(place) as (1 | 2 | 3)[] + const visited = new Set<1 | 2 | 3>(visitedExhibits) // 光标1+光标2均查看后的总结对话 const [guide12Visible, setGuide12Visible] = useState(false) @@ -99,11 +98,22 @@ const ZhanglepingPlace5 = () => { > {/* 标题 */} <Title - title="朋友笔下" + title="朋友笔下的张乐平" subtitle={subtitle} className="absolute top-10 left-1/2 -translate-x-1/2" /> + <Image + className="absolute right-5 top-5" + src={getStaticImage('components/离开')} + button + onClick={() => { + if (moduleName && starName) { + navigate(`/${moduleName}/${starName}/place4`) + } + }} + /> + {/* 完成全部展品且结束语关闭后,出现离开按钮 */} {completed && !endingVisible && ( <Image @@ -162,7 +172,7 @@ const ZhanglepingPlace5 = () => { speaker="student" audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_16.mp3')} > - 小沪,三毛系列漫画真的太深入人心了!张乐平先生还会和“小三毛迷”们互通书信,张先生真的是非常喜欢儿童呀! + 朋友画我!这一整面墙的朋友笔下的张乐平先生和三毛真是各有特色啊! </GuideStepsItem> <GuideStepsItem speaker="guide" @@ -184,19 +194,31 @@ const ZhanglepingPlace5 = () => { <ExhibitOverlay visible={activeExhibitId !== null} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''} + onOpen={() => { + if (activeExhibitId === 1) { + triggerPuzzleAnimation(12) + } + if (activeExhibitId === 2) { + triggerPuzzleAnimation(11) + } + if (activeExhibitId === 3) { + triggerPuzzleAnimation(13) + } + }} onClose={() => { if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev) - next.add(activeExhibitId) - markVisited('place5', activeExhibitId) - return next - }) + markExhibitVisited(place, activeExhibitId) } - // 关闭展品3后触发结束语(仅触发一次) - if (activeExhibitId === 3 && !endingShown) { - setEndingVisible(true) - setEndingShown(true) + if (activeExhibitId === 3) { + triggerPuzzleAnimation(14).then(() => { + setEndingVisible(true) + // setEndingShown(true) + }) + + // setTimeout(() => { + // setEndingVisible(true) + // setEndingShown(true) + // }, 1000) } setActiveExhibitId(null) diff --git a/src/pages/zhangleping/reflection/index.tsx b/src/pages/zhangleping/reflection/index.tsx index f86b3e3..7da905c 100644 --- a/src/pages/zhangleping/reflection/index.tsx +++ b/src/pages/zhangleping/reflection/index.tsx @@ -116,6 +116,7 @@ const ZhanglepingReflection = () => { }} ailgn="center" zIndex={10000} + firstLineIndent="2em" > 同学你好,欢迎来到“跨越时空·体悟”学习模块! 请登陆“时空回溯・亲历时代”体验仓,下一站:1930年,上海。 diff --git a/src/pages/zhangleping/reflection/scene1.tsx b/src/pages/zhangleping/reflection/scene1.tsx index 17a549d..efcc717 100644 --- a/src/pages/zhangleping/reflection/scene1.tsx +++ b/src/pages/zhangleping/reflection/scene1.tsx @@ -147,7 +147,7 @@ const ZhanglepingReflectionScene1 = () => { // 正确:弹出展品与对话(碎片在关闭碎片展品窗口时才解锁) hint.clearAndReset() setShowFragmentOverlay(true) - setShowFragmentDialogue(true) + // setShowFragmentDialogue(true) }, [hint, isDragPhase] ) diff --git a/src/pages/zhangleping/reflection/scene4.tsx b/src/pages/zhangleping/reflection/scene4.tsx index 76f0c76..c5a7041 100644 --- a/src/pages/zhangleping/reflection/scene4.tsx +++ b/src/pages/zhangleping/reflection/scene4.tsx @@ -75,6 +75,7 @@ const ZhanglepingReflectionScene4 = () => { // 第一阶段 const [activeExhibit, setActiveExhibit] = useState<ExhibitId | null>(null) const [showDialogue, setShowDialogue] = useState(false) + const [drawingCompleted, setDrawingCompleted] = useState(false) const visitedExhibits = getVisitedReflectionCursors(SCENE) const visited = useMemo(() => new Set(visitedExhibits), [visitedExhibits]) @@ -148,9 +149,11 @@ const ZhanglepingReflectionScene4 = () => { return } - // 正确:弹出展品与对话(碎片在关闭碎片展品窗口时才解锁) + // 正确:立即完成画稿,再弹出展品与对话 + setDrawingCompleted(true) + setDragFillValue([]) hint.clearAndReset() - setShowItemExhibit(true) + setTimeout(() => setShowItemExhibit(true), 300) }, [hint, isDragPhase] ) @@ -243,7 +246,7 @@ const ZhanglepingReflectionScene4 = () => { <Image className="absolute left-205 bottom-10" src={getStaticImage( - `zhangleping/reflection/scene4/${fragmentCompleted ? '画稿完成' : '画稿未完成'}` + `zhangleping/reflection/scene4/${drawingCompleted ? '画稿完成' : '画稿未完成'}` )} /> diff --git a/src/pages/zhangleping/reflection/scene5.tsx b/src/pages/zhangleping/reflection/scene5.tsx index f5e81c1..1606a84 100644 --- a/src/pages/zhangleping/reflection/scene5.tsx +++ b/src/pages/zhangleping/reflection/scene5.tsx @@ -1,9 +1,17 @@ import { useCallback, useMemo, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { motion } from 'framer-motion' -import { Image, Title, SceneHint, DragFill, ExhibitFlipOverlay } from '@/components' +import { + Image, + Title, + SceneHint, + DragFill, + ExhibitFlipOverlay, + FlipActionButton, +} from '@/components' import type { DragFillResult } from '@/components' import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils' +import { downloadDataUrlImage } from '@/lib/utils/sensitiveWords' import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore' import { useSceneHintController } from './useSceneHintController' @@ -65,8 +73,12 @@ const ZhanglepingReflectionScene5 = () => { const [haloHovered, setHaloHovered] = useState(false) const scene = 'scene5' const total = 1 - const { markReflectionCursorVisited, getVisitedReflectionCursors, drawArtworkDataUrl } = - useZhanglepingStore() + const { + markReflectionCursorVisited, + getVisitedReflectionCursors, + drawArtworkDataUrl, + hasDrawArtwork, + } = useZhanglepingStore() // 根据 store 中碎片是否已解锁决定初始子场景 const fragmentAlreadyUnlocked = getVisitedReflectionCursors(scene).includes(1) @@ -79,8 +91,9 @@ const ZhanglepingReflectionScene5 = () => { const completed = progressCount >= total const subtitle = completed ? '已完成' : `${Math.min(progressCount, total)}/${total}` const hasSubmittedArtwork = - typeof drawArtworkDataUrl === 'string' && - /^data:image\/[a-zA-Z]+;base64,/.test(drawArtworkDataUrl) + hasDrawArtwork || + (typeof drawArtworkDataUrl === 'string' && + /^data:image\/[a-zA-Z]+;base64,/.test(drawArtworkDataUrl)) // 子场景2:拖拽状态 const [dragFillValue, setDragFillValue] = useState<DragFillResult[]>([]) @@ -155,6 +168,16 @@ const ZhanglepingReflectionScene5 = () => { setTimeout(() => setSubScene(3), 800) }, []) + const handleDownloadArtwork = useCallback(async () => { + if (!hasSubmittedArtwork) return + + try { + await downloadDataUrlImage(drawArtworkDataUrl, '三毛新时代图景.png') + } catch (error) { + console.error('Download artwork failed', error) + } + }, [drawArtworkDataUrl, hasSubmittedArtwork]) + if (!moduleName) return null const subSceneBg = { 1: '场景5', 2: '邮箱', 3: '画板' }[subScene] @@ -372,15 +395,26 @@ const ZhanglepingReflectionScene5 = () => { {/* 画板模块提交的画作(无画作时不显示空白画板) */} {hasSubmittedArtwork && ( - <div - className={cn( - 'absolute left-40 top-178 w-[205px] h-[180px] transform-3d', - 'bg-white flex justify-center items-center overflow-hidden' - )} - style={{ transform: 'rotateX(-15deg) rotateY(22deg) rotateZ(3deg)' }} - > - <Image objectFit="cover" src={drawArtworkDataUrl} /> - </div> + <> + <div + className={cn( + 'absolute left-40 top-178 w-[205px] h-[180px] transform-3d', + 'bg-white flex justify-center items-center overflow-hidden' + )} + style={{ transform: 'rotateX(-15deg) rotateY(22deg) rotateZ(3deg)' }} + > + <Image objectFit="cover" src={drawArtworkDataUrl} /> + </div> + + <div + className="absolute left-62 top-[900px] z-20" + style={{ transform: 'rotateX(-15deg) rotateY(22deg) rotateZ(3deg)' }} + > + <FlipActionButton className="text-2xl px-5 py-2" onClick={handleDownloadArtwork}> + 下载 + </FlipActionButton> + </div> + </> )} </> )} diff --git a/src/store/nieerStore.ts b/src/store/nieerStore.ts index b8f1868..8e005a9 100644 --- a/src/store/nieerStore.ts +++ b/src/store/nieerStore.ts @@ -51,6 +51,10 @@ interface NieerStore { reflectionWorks: Record<number, Record<string, unknown>> setReflectionWork: (sceneId: number, payload: Record<string, unknown>) => void resetReflectionWorks: () => void + + // 是否已经看过体悟首页学习引导 + hasSeenReflectionGuide: boolean + markReflectionGuideSeen: () => void } // 将 Set 转换为数组以便序列化 @@ -129,6 +133,7 @@ export const useNieerStore = create<NieerStore>()( // ========== 体悟模块状态 ========== visitedReflectionCursors: {}, reflectionWorks: {}, + hasSeenReflectionGuide: false, markReflectionCursorVisited: (scene: string, cursorId: number) => { set((state) => { @@ -182,6 +187,10 @@ export const useNieerStore = create<NieerStore>()( resetReflectionWorks: () => { set({ reflectionWorks: {} }) }, + + markReflectionGuideSeen: () => { + set({ hasSeenReflectionGuide: true }) + }, }), { name: STORE_NAMES.NIEER, @@ -190,6 +199,7 @@ export const useNieerStore = create<NieerStore>()( visitedReflectionCursors: serializeVisitedExhibits(state.visitedReflectionCursors), lastVisitedPlace: state.lastVisitedPlace, reflectionWorks: state.reflectionWorks, + hasSeenReflectionGuide: state.hasSeenReflectionGuide, }), onRehydrateStorage: () => (state) => { if (state) { @@ -203,6 +213,9 @@ export const useNieerStore = create<NieerStore>()( if (!state.reflectionWorks || typeof state.reflectionWorks !== 'object') { state.reflectionWorks = {} } + if ((state as any).hasSeenReflectionGuide === undefined) { + ;(state as any).hasSeenReflectionGuide = false + } } }, } diff --git a/src/store/zhanglepingStore.ts b/src/store/zhanglepingStore.ts index 699047c..992b765 100644 --- a/src/store/zhanglepingStore.ts +++ b/src/store/zhanglepingStore.ts @@ -31,6 +31,7 @@ interface ZhanglepingStore { // 绘制模块提交结果(用于在 scene5 画板处展示) drawArtworkDataUrl: string drawArtworkCaption: string + hasDrawArtwork: boolean setDrawArtwork: (dataUrl: string, caption: string) => void clearDrawArtwork: () => void @@ -69,6 +70,7 @@ export const useZhanglepingStore = create<ZhanglepingStore>()( visitedReflectionCursors: {}, drawArtworkDataUrl: '', drawArtworkCaption: '', + hasDrawArtwork: false, hasSeenStudyGuide: false, markExhibitVisited: (place: string, exhibitId: number) => { @@ -125,12 +127,14 @@ export const useZhanglepingStore = create<ZhanglepingStore>()( set({ drawArtworkDataUrl: dataUrl, drawArtworkCaption: caption, + hasDrawArtwork: true, }), clearDrawArtwork: () => set({ drawArtworkDataUrl: '', drawArtworkCaption: '', + hasDrawArtwork: false, }), markStudyGuideSeen: () => set({ hasSeenStudyGuide: true }), @@ -144,6 +148,7 @@ export const useZhanglepingStore = create<ZhanglepingStore>()( visitedReflectionCursors: serializeVisited(state.visitedReflectionCursors), drawArtworkDataUrl: state.drawArtworkDataUrl, drawArtworkCaption: state.drawArtworkCaption, + hasDrawArtwork: state.hasDrawArtwork, hasSeenStudyGuide: state.hasSeenStudyGuide, }), onRehydrateStorage: () => (state) => { @@ -162,6 +167,9 @@ export const useZhanglepingStore = create<ZhanglepingStore>()( if ((state as any).drawArtworkCaption === undefined) { ;(state as any).drawArtworkCaption = '' } + if ((state as any).hasDrawArtwork === undefined) { + ;(state as any).hasDrawArtwork = Boolean((state as any).drawArtworkDataUrl) + } if ((state as any).hasSeenStudyGuide === undefined) { ;(state as any).hasSeenStudyGuide = false } @@ -188,7 +196,7 @@ export const saveZhanglepingStore = (): void => { visitedReflectionCursors: serializeVisited(state.visitedReflectionCursors), drawArtworkDataUrl: remoteArtworkDataUrl, drawArtworkCaption: state.drawArtworkCaption, - hasDrawArtwork: Boolean(state.drawArtworkDataUrl), + hasDrawArtwork: state.hasDrawArtwork, } try {