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 && (
-
- ✓
-
- )}
-
- )
- })}
-
-
- {/* 中间连线区域(移动端隐藏,桌面端显示) */}
-
- {/* 绘制连线 */}
-
-
-
- {/* 右列:事件 */}
-
- {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 覆盖整个题目区域,用绝对像素坐标画线 */}
+
+
+ {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 光标 */}
handleOpenExhibit(1)}
- read={visited.has(1)}
+ onClick={handleOpenExhibit}
+ read={visited.has(EXHIBIT_ID)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
{
setLastVisitedPlace('place8')
saveChenyunStore()
- navigate(`/${moduleName}/${starName}/place8`)
+ navigate(`/${moduleName}/${STAR_NAME}/place8`)
}}
/>
)}
- {/* 展品1视频播放(全屏) */}
- {videoVisible1 && (
-
+ {/* 视频1(全屏) */}
+ {video1Visible && (
+
)}
- {/* 展品1的引导对话 - 在视频上方显示 */}
+ {/* 对话1(叠加在视频1之上) */}
那"银元之战"是如何取得胜利的呢?
由于通货膨胀,国民党发行的法币、金圆券变成了一堆废纸。当时坊间流传"大街过三道,物价跳三跳"。
@@ -279,80 +321,64 @@ const ChenyunPlace7 = () => {
- {/* 单选题1:在视频1结束后出现(点击即判定) */}
+ {/* 题目1:单选 */}
{
- 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 && (
-
+ {/* 视频2(全屏) */}
+ {video2Visible && (
+
)}
- {/* 对话2:在视频2背景上显示 */}
+ {/* 对话2(叠加在视频2之上) */}
- 那“两白一黑”战役是如何打赢的呢?
+ 那"两白一黑"战役是如何打赢的呢?
- “银元之战”的胜利并没有遏制住投机者的嚣张气焰,物价稳定仅十几天后,投机者就由银元投机转向“两白一黑”的投机,即:棉纱、大米和煤炭,引发了又一次全国性涨价狂潮。
+ "银元之战"的胜利并没有遏制住投机者的嚣张气焰,物价稳定仅十几天后,投机者就由银元投机转向"两白一黑"的投机,即:棉纱、大米和煤炭,引发了又一次全国性涨价狂潮。
- {/* 拖拽题2:在视频2结束后出现 */}
+ {/* 题目2:拖拽 */}
{
onChange={handleQuiz2Change}
/>
- {/* 遮罩层 */}
- {
- if (activeExhibitId !== null) {
- markExhibitVisited(place, activeExhibitId)
- }
- setActiveExhibitId(null)
- }}
- />
-
- {/* 成功提示 */}
- {successAlertVisible && (
+ {/* 成功提示(盖在视频/题目之上) */}
+ {successVisible && (
{
- 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}
/>
)}
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')}
>
- 不错,随着大规模经济建设的展开,全国粮、棉、油等主要农产品严重紧缺,供求矛盾尖锐。陈云经过调查研究,反复比较,权衡利弊得失,终于下决心在农村征购,在城市配售的解决粮食问题的办法,也就是后来所说的"统购统销"。
+ 不错,随着大规模经济建设的展开,全国粮、棉、油等主要农产品严重紧缺,供求矛盾尖锐。陈云经过调查研究,反复比较,权衡利弊得失,终于下决心采用在农村征购,在城市配售的解决粮食问题的办法,也就是后来所说的"统购统销"。
陈云说:"只有采用这种办法,才能保证我国人民生活日益增长的需要,才能制止投机活动,保证市场物价的稳定,才能使发展国民经济第一个五年计划得以顺利地进行。"
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}
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) */}
{
{
setNarrationVisible(false)
setVisible(true)
@@ -475,6 +476,7 @@ const HuangyanpeiInternalization = () => {
{/* 引导 */}
{
setVisible(visible)
if (!visible) {
@@ -504,6 +506,7 @@ const HuangyanpeiInternalization = () => {
{/* 视频结束后的引导 */}
{
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 = () => {
{/* 题目 */}
{
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 (
{
setShowGuideModal(false)
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_23.mp3')}
+ firstLineIndent="2em"
>
同学你好,欢迎来到“跨越时空·体悟”学习模块!{'\n'}
- 请登陆“时空回溯・亲历时代”体验仓,下一站:1903,川沙小学堂。{'\n'}
+ 请登陆“时空回溯・亲历时代”体验仓,下一站:1903年,川沙小学堂。{'\n'}
你将在“任之”校长所在的学校学习,根据所知和所学,努力完成校长给你的考验,完成全部任务后,可进入到下一阶段的学习。时空门已开,请做好准备迎接考验吧!
{
- if (!visited.has(1)) {
- setShowQuestionModal(true)
- }
+ setShowQuestionModal(true)
}}
read={visited.has(1)}
+ disabled={!isEnabled(1)}
/>
{
- // 如果游戏已完成,不再打开
- if (!visited.has(2)) {
- setShowGame(true)
- }
+ setShowGame(true)
}}
read={visited.has(2)}
+ disabled={!isEnabled(2)}
/>
{
onClose={() => setShowQuestionModal(false)}
/>
- {/* {completed && (
+ {completed && (
{
- if (moduleName && starName) {
- navigate(`/${moduleName}/${starName}/reflection2`);
- }
- }}
+ onClick={handleGoNext}
/>
- )} */}
+ )}
{
setShowGame(false)
}}
onComplete={() => {
- // 游戏完成后,显示成功提示
setShowGame(false)
- setShowSuccessAlert(true)
markReflectionCursorVisited(scene, 2)
- }}
- />
-
- {
- setShowSuccessAlert(false)
- markReflectionCursorVisited(scene, 2)
- }, [markReflectionCursorVisited, scene])}
- />
-
- {
- setShowNextGuide(false)
- setReflectionRoute('reflection2')
+ markReflectionCompletionGuideShown(scene)
saveHuangyanpeiStore()
- navigate(`/${moduleName}/${starName}/reflection2`)
+ setShowNextGuide(true)
}}
- verticalAlign="center"
- >
+ />
+
+
第一关完成!下一站,浦东中学深造去。
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
-
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'}
- 你选了学技术,那就来挑战制作中华职校的"明星产品"——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢!
+ 你选了学技术,那就来挑战制作中华职校的"明星产品"——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢!
{/* 互动展品光标:组装台钳互动 */}
{
- 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(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)}
/>
{
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:中华职校商科的核心教学目标 */}
{
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'}
- 现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢!
+ 现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢!
{showVideo && (
@@ -225,6 +237,14 @@ const HuangyanpeiReflectionScene4 = () => {
}}
/>
+ setShowSpeakerGuide(false)}
+ verticalAlign="center"
+ >
+ 请点击左上角的喇叭。
+
+
{/* 错误提示:请先完成前两个任务 */}
{
+ const result = value.find((item) => item.blankId === towelId)
+ return options.find((option) => option.value === result?.optionValue)?.label
+ },
+ [options, value]
+ )
+
// 样式
const overlayStyle = useMemo(
() => ({
@@ -221,6 +229,11 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia
alt="毛巾1"
className="w-full h-full object-contain"
/>
+ {getTowelText('towel1') && (
+
+ {getTowelText('towel1')}
+
+ )}
{/* 毛巾2 */}
@@ -234,6 +247,11 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia
alt="毛巾2"
className="w-full h-full object-contain"
/>
+ {getTowelText('towel2') && (
+
+ {getTowelText('towel2')}
+
+ )}
{/* 毛巾3 */}
@@ -247,6 +265,11 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia
alt="毛巾3"
className="w-full h-full object-contain"
/>
+ {getTowelText('towel3') && (
+
+ {getTowelText('towel3')}
+
+ )}
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 = () => {
{/* 旁白 */}
{
setShowNarration(false)
+ setShowVideo(true)
+ // handleVideoCanPlay()
}}
/>
@@ -492,7 +493,7 @@ const LuxunInternalization = () => {
}}
showMask={false}
>
-
+ {/*
走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。
@@ -503,7 +504,7 @@ const LuxunInternalization = () => {
先生认为,中华民族要屹立于世界民族之林,“首在立人”,“立人”思想从此贯穿在鲁迅先生的人生和作品中。”先生认为,中华民族要屹立于世界民族之林,“首在立人”,“立人”思想从此贯穿在鲁迅先生的人生和作品中。
-
+ */}
小沪,关于"首在立人"这四个字我还是有些不理解,立人为什么这么重要?
@@ -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 = () => {
{/* 连线题成功提示 */}
{
setShowMatchQuizSuccess(false)
@@ -313,6 +315,49 @@ const LuxunPlace1 = () => {
markExhibitVisited(place, 1)
}}
/>
+
+ {
+ 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}
+ />
+
+ {
+ setShowExhibit2QuizSuccess(false)
+ markExhibitVisited(place, 2)
+ }}
+ onClose={() => {
+ setShowExhibit2QuizSuccess(false)
+ markExhibitVisited(place, 2)
+ }}
+ />
)
}
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的引导对话 */}
{
+ setGuide2Visible(visible)
+ if (!visible) {
+ markExhibitVisited(place, 2)
+ }
+ }}
currentStep={guide2Step}
onStepChange={setGuide2Step}
showMask={false}
@@ -313,7 +319,7 @@ const LuxunPlace3 = () => {
- {/* 光标2的题目 */}
+ {/* 光标3的题目 */}
{
zIndex={10001}
/>
- {/* 光标2的成功提示 */}
+ {/* 光标3的成功提示 */}
{
}
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 = () => {
小沪,这两本书看起来格外厚重,它和鲁迅在这儿的经历有什么关联呢?
- 这不是鲁迅的著作,却是他在大陆新村9号倾注了全部心血的作品——为亡友瞿秋白编辑的译文集《海上述林》。1935年6月,瞿秋白被国民党反动派杀害,鲁迅悲痛万分,当即决定要为挚友出版译文集,让他的思想和精神流传下去。
+ 这不是鲁迅的著作,却是他在大陆新村9号倾注了全部心血的作品——为亡友瞿秋白编校的译文集《海上述林》。1935年6月,瞿秋白被国民党反动派杀害,鲁迅悲痛万分,当即决定要为挚友出版译文集,让他的思想和精神流传下去。
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 = () => {
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 (
+ <>
+
{
+ unmute()
+ setVisible(true)
+ }}
+ />
+ {
+ if (!isCorrect) return
+ setVisible(false)
+ setShowSuccessAlert(true)
+ onCorrect()
+ }}
+ />
+ {
+ 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 (
+ <>
+ {
+ unmute()
+ setVisible(true)
+ }}
+ />
+ {
+ if (!isCorrect) return
+ setVisible(false)
+ setShowSuccessAlert(true)
+ onCorrect()
+ }}
+ />
+ {
+ 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) => {
+ const [questionVisible, setQuestionVisible] = useState(false)
+ const [showSuccessAlert, setShowSuccessAlert] = useState(false)
+ const hasResumedRef = useRef(false)
+
+ return (
+ <>
+ {
+ unmute()
+ setQuestionVisible(true)
+ }}
+ />
+ {
+ if (!isCorrect) return
+ setQuestionVisible(false)
+ setShowSuccessAlert(true)
+ }}
+ />
+ {
+ 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(null)
const videoRef = useRef(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 }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
},
{
- value: 'B',
- label: 'B. 深入基层,收集各地党组织、工农运动实情,再推进筹备等备',
- },
- ]
-
- return (
- <>
- {
- unmute()
- setVisible(true)
- }}
- />
- {
- if (!isCorrect) return
- setVisible(false)
- setShowSuccessAlert(true)
- }}
- />
- {
- 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 }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
},
{
- value: 'B',
- label: 'B. 讲清意见对四大讨论无产阶级领导权、工农联盟的重要性,以革命大义动员党员',
- },
- ]
-
- return (
- <>
- {
- unmute()
- setVisible(true)
- }}
- />
- {
- if (!isCorrect) return
- setVisible(false)
- setShowSuccessAlert(true)
- }}
- />
- {
- 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 }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
},
{
- value: 'B',
- label: 'B. 在调养修整后调研农民运动',
+ id: 'anchor-3',
+ time: 32,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
},
- ]
-
- return (
- <>
- {
- unmute()
- setQuestionVisible(true)
- }}
- />
- {
- if (!isCorrect) return
- setQuestionVisible(false)
- setEndingVisible(true)
- }}
- />
- {/* {
- 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 }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-1',
- time: 8,
- afterResume: 'hide', // 恢复后隐藏,seek 回来可重新触发
- onTrigger: () => console.log('埋点:锚点 1 触发'),
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-2',
- time: 22,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-3',
- time: 32,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-4',
- time: 40,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-4-1',
- time: 52,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-5',
- time: 56,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-6',
- time: 74,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-6-1',
- time: 83,
- afterResume: 'hide',
- render: ({ resume, unmute }) => ,
- },
- {
- id: 'anchor-7',
- time: 88,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-8',
- time: 95,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-9',
- time: 107,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-10',
- time: 116,
- afterResume: 'hide',
- render: ({ resume, unmute }) => ,
- },
- {
- id: 'anchor-11',
- time: 120,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-12',
- time: 127,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-13',
- time: 141,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-14',
- time: 153,
- afterResume: 'hide',
- render: ({ resume, unmute }) => ,
- },
- ]
+ {
+ id: 'anchor-4',
+ time: 40,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-4-1',
+ time: 52,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-5',
+ time: 60 + 8,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-6',
+ time: 60 + 25,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-6-1',
+ time: 60 + 34,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ triggerPuzzleAnimation(13)}
+ />
+ ),
+ },
+ {
+ id: 'anchor-7',
+ time: 60 + 38,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-8',
+ time: 60 * 2,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-9',
+ time: 60 * 2 + 11.5,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-10',
+ time: 60 * 2 + 21,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ triggerPuzzleAnimation(14)}
+ />
+ ),
+ },
+ {
+ id: 'anchor-11',
+ time: 60 * 2 + 24,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-12',
+ time: 60 * 2 + 43,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-13',
+ time: 60 * 2 + 57,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-14',
+ time: 60 * 3 + 9,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => ,
+ },
+ ],
+ [triggerPuzzleAnimation]
+ )
return (
- {/* BGM 循环播放 */}
{
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 = () => {
/>
{
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 = () => {
{/* 旁白 */}
{
setNarrationVisible(false)
+ setVisible(true)
}}
text="从旧居亭子间的不眠之夜到国歌展示馆的激昂旋律,聂耳以青春为火,以音符为刃,在民族危亡的黑暗中奏响了救亡图存的强音。《义勇军进行曲》从电影主题曲到中华人民共和国国歌,从抗战年代的冲锋号到新时代的复兴曲,跨越近百年时光,依然是凝聚民族力量的精神纽带。今天,站在新时代的舞台上,我们更要读懂聂耳的作品与精神内核,让这份旋律中的力量内化于心、外化于行。"
/>
@@ -282,6 +295,7 @@ const NieerInternalization = () => {
{/* 视频前引导 */}
{
setVisible(v)
if (!v) {
@@ -291,10 +305,10 @@ const NieerInternalization = () => {
}}
showMask={false}
>
-
+
小沪,前两个模块中,我循着聂耳的足迹读懂了旋律背后的家国情怀,亲历了国歌跨越时空的动人故事。我还想从音乐这一艺术创作角度出发更多了解聂耳,你能给我讲讲吗?
-
+
讲到音乐,可是很专业的领域了!今天我们有幸邀请到了上海音乐学院党委宣传部副部长,东方乐器博物馆馆长,研究员,肖阳。肖老师会从上海音乐学院对聂耳的研究、与《义勇军进行曲》的渊源的角度告诉你聂耳的音乐作品与时代价值。
@@ -314,11 +328,23 @@ const NieerInternalization = () => {
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
- setTimeout(() => setShowLetter(true), 300)
+ setTimeout(() => setShowPostNarration(true), 300)
}}
/>
)}
+ {
+ setShowPostNarration(false)
+ setShowLetter(true)
+ }}
+ text="的确如戏!这份精神,不仅是音乐创作的指引,更是广大青年学子成长路上的精神灯塔,指引大家在专业学习中精益求精,在青春岁月里奋勇前行。此刻,我们仿佛依然站在聂耳旧居的亭子间里,仿佛还能看到先生熬夜打磨旋律的身影,感受到他心中滚烫的家国情怀。现在,就请你静下心来,把对聂耳的崇敬、对国歌精神的体悟和对自己未来的期许,一字一句写成一封信,完成与先生跨越时空的对话吧!"
+ />
+
{/* 写信组件 */}
{
{/* 内化结束 */}
{
setShowStudyGuide(false)
- navigate(`/${moduleName}/${starName}/home`)
+ navigate('/menu')
}}
/>
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(null)
// 光标3的对话状态
@@ -163,6 +165,17 @@ const NieerPlace1 = () => {
{
+ 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}
>
-
+
这里提到聂耳发表了《中国歌舞短论》,批评脱离民众的音乐,这是为什么呢?
-
+
你观察得很细致!这时的聂耳在明月歌舞剧社做练习生,社长李锦晖看中了他的潜力,虽然他当时小提琴水平不算专业,还是录取了他,还安排首席小提琴手专门辅导他。聂耳特别刻苦,每天至少练7个小时小提琴,很快就成长为首席小提琴手。
-
- 但好景不长,1931年九一八事变后,全国抗日救亡浪潮高涨,可还存在很多脱离现实的靡靡之音,这让满怀爱国热忱的聂耳特别愤懑。
+
+ 但好景不长,1931年九·一八事变后,全国抗日救亡浪潮高涨,可还存在很多脱离现实的靡靡之音,这让满怀爱国热忱的聂耳特别愤懑。
-
+
1932年,他以“黑天使”为笔名在《电影艺术》杂志刊登《中国歌舞短论》,猛烈评击“软豆腐”式的音乐,提出我们需要“真刀真枪的硬功夫”,主张音乐要贴近民众、为抗日救亡服务。后来他离开了歌舞剧社。
@@ -235,16 +236,10 @@ const NieerPlace1 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
+
离开歌舞剧社后,聂耳就加入左翼文艺团体了吗?
-
+
没错!离开歌舞剧社后,聂耳结识了进步文艺家田汉,两人成为忘年交。1933年,在田汉的介绍下,聂耳秘密加入了中国共产党,之后就成为左翼剧联“音乐小组”的核心成员,还发起组织了“中国新兴音乐研究会”。
@@ -259,34 +254,19 @@ const NieerPlace1 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
- 小沪,聂耳是在九一八事变爆发后来到的上海吗?他为什么来上海呢?
+
+ 小沪,聂耳是在九·一八事变爆发后来到的上海吗?他为什么来上海呢?
-
- 九一八事变爆发前,聂耳就已经来到上海了,核心原因是躲避迫害。聂耳中学就读的云南省立第一师范学校是昆明学生运动的中心,在那里他接触到了马克思主义进步思想,还秘密加入了中国共产主义青年团。
+
+ 九·一八事变爆发前,聂耳就已经来到上海了,核心原因是躲避迫害。聂耳中学就读的云南省立第一师范学校是昆明学生运动的中心,在那里他接触到了马克思主义进步思想,还秘密加入了中国共产主义青年团。
-
+
也因为他积极参加学生运动,1930年被国民党当局列入了抓捕黑名单,在家人帮助下,他顶替哥哥的身份逃离昆明来到上海。
-
- 来到上海后,1931年九一八事变爆发,继而1932年一·二八事变爆发,聂耳亲眼看见了日军进犯上海的暴行,也目睹了上海民众踊跃投身抗日的热烈场景。
+
+ 来到上海后,1931年九·一八事变爆发,继而1932年一·二八事变爆发,聂耳亲眼看见了日军进犯上海的暴行,也目睹了上海民众踊跃投身抗日的热烈场景。
-
+
这些亲身经历让他深刻意识到,音乐不能只是娱乐,更能成为唤醒民众、凝聚抗战士气的“武器”。正是这份在上海的所见所感,让他坚定了“音乐救国”的方向,也为《义勇军进行曲》注入了源于现实的痛感与抗争决心。
@@ -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) */}
- {
- setAfterQuizGuide2Visible(nextVisible)
- if (!nextVisible) {
- setShowVideo2(true)
- }
- }}
- showMask={false}
- highlightSelectors={[]}
- zIndex={10001}
- >
-
- 这一时期他深入工厂、码头,了解底层民众的疾苦,创作了一大批进步歌曲。比如《开矿歌》,他专门去矿山采风,把工人的劳动场景融入旋律,还唱给工人听,直到他们满意为止;
-
-
- 《卖报歌》则是他看到卖报小女孩“小毛头”的艰辛生活后创作的,还根据小女孩的建议修改了歌词,特别贴近生活。这些创作都为他后来写《义勇军进行曲》积累了深厚的功底。
-
-
-
- {/* 光标2对话结束后的视频(占位视频地址) */}
- {showVideo2 && (
+ {/* 光标2连续视频(占位视频地址) */}
+ {video2Step && (
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 = () => {
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 = () => {
{
+ 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}
>
-
+
这盒《风云儿女》的胶片太有年代感了!聂耳是怎么和《风云儿女》这部电影结缘,并给这部电影谱主题歌《义勇军进行曲》的啊?
-
+
1935年的时候,日本帝国主义已经把侵略的魔爪伸向华北,中华民族到了生死存亡的关键时刻,全国人民都渴望有能凝聚斗志的精神力量。
-
+
左翼文艺界想通过拍摄《风云儿女》这部抗日电影,让更多人看清侵略者的野心,唤醒大家的抗争意识,而电影的主题歌必须得有“振聋发聩”的力量。
@@ -172,31 +174,39 @@ const NieerPlace2 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
+
1935年2月,田汉创作了《风云儿女》剧本梗概,写下《义勇军进行曲》歌词后不久被捕,田汉的战友辗转把歌词转交给左翼剧联,聂耳得知后第一时间就主动申请完成曲创作。
-
+
他之所以这么积极,一方面是和田汉的革命情谊——两人都是左翼文艺运动的核心力量,早就约定要一起用文艺为抗日救亡服务;
-
+
更重要的是他对时代的感知,之前创作《开矿歌》《卖报歌》时,他就深入底层了解民众的苦难,清楚地知道大家需要什么样的声音。
-
+
他当时说“这歌词太有力量了,我一定要把它谱成能让全中国人民都唱起来的歌”,这份信心来自他对民众心声的把握,也来自对救国使命的坚守。
+
+ {showVideo2 && (
+ {
+ markExhibitVisited(place, 2)
+ saveNieerStore()
+ setShowVideo2(false)
+ }}
+ onSkip={() => {
+ markExhibitVisited(place, 2)
+ saveNieerStore()
+ setShowVideo2(false)
+ }}
+ />
+ )}
)
}
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 = () => {
{
+ 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}
>
-
+
这份聂耳手写的《义勇军进行曲》的曲谱太珍贵了,在当时那么动乱的年代,他是怎么创作出这么激昂的旋律的?
-
+
1935年3月,聂耳在上海霞飞路(现淮海中路1258号聂耳旧居)完成了《义勇军进行曲》的曲谱初稿的创作。
-
-
+
当时他住的是一个亭子间,那里又小又暗,连一张像样的桌子都没有,为了找到最贴合歌词的旋律,他每天天不亮就起来哼唱,用筷子敲着桌子打节拍,有时候灵感来了,半夜还会起来修改,邻居都能听到他的歌声。
-
-
+
草稿上的修改痕迹,藏着他的用心——比如他反复调整音符的节奏,把原本偏舒缓的旋律改得更急促、更有冲击力,就是想让旋律能跟上战士冲锋的步伐;还优化了歌词和旋律的搭配,让每一句都能唱得铿锵有力。
-
- 他还专门去电通公司唱给同事听,把工人、学生、战士的反馈都记下来,一点点打磨。他还专门去电通公司唱给同事听,把工人、学生、战士的反馈都记下来,一点点打磨。
+
+ 他还专门去电通公司唱给同事听,把工人、学生、战士的反馈都记下来,一点点打磨。
@@ -223,28 +218,16 @@ const NieerPlace3 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
+
原来初稿就倾注了这么多心血!他当时就是用这把小提琴创作的吗?
-
+
这把小提琴其实是演员赵丹在电影《聂耳》中饰演聂耳时用的道具,不是聂耳本人的。你看到的抱着小提琴的聂耳的照片的拍摄地点已经是在海外了。
-
+
当时他因为创作了太多进步歌曲,被国民党当局列入了黑名单,继续留在上海会有生命危险,因此他受党的指示前往海外暂避。但他心里一直惦记着谱曲任务,到海外后没敢耽误,立刻投入修改。
-
+
虽然身在海外,但他通过海外华人社团、抗日刊物,一直关注着国内的抗日浪潮,还收集了海外华人传唱抗日歌曲的反馈,这些都成了他修改的重要参考。
@@ -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 = () => {
{
+ 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}
>
-
+
随着《风云儿女》的上映,田汉作词、聂耳谱曲的《义勇军进行曲》真如聂耳所期待的一样,传遍了全国吧!
-
+
1935年《风云儿女》公映后,这首歌就跟着影片走进了全国各地的影院,看完电影的观众都忍不住跟着哼唱。当时的百代唱片公司还专门录制了这首歌的唱片,在街头巷尾的唱片店、收音机里循环播放。
-
+
因为它朗朗上口,哪怕是不识字的老百姓,教几遍就能学会,很快就成了街头巷尾、田间地头都能听到的旋律,真正做到了家喻户晓。
@@ -161,22 +164,13 @@ const NieerPlace4 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
+
这张指挥千人大合唱的照片好震撼!除了日常传唱,这首歌在抗战时期还发挥了哪些特别的作用呢?
-
+
它的作用可太大了!你看这张1936年的照片,刘良模先生在上海南市公共体育场指挥数千人齐唱,就连前来镇压的警察都被感染,跟着一起唱。抗战期间,抗日军队的行军路上、根据地的文艺宣传队里、城市的救亡集会中,都能听到这首歌的声音。
-
+
它就像冲锋号一样,每次响起都能凝聚起大家的斗志,让战士们更勇敢地奔赴战场,让民众们更坚定地支持抗战,成为整个民族团结抗敌的精神纽带,而它也不再只是文艺作品,更是凝聚了中国人民的抗战士气,成了抗日救亡运动的精神旗帜。
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 = () => {
{
+ 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}
>
-
+
那2004年的宪法修正案又对国歌做了什么重要规定呢?为什么要把它写入宪法呀?
-
+
2004年3月14日,第十届全国人民代表大会二次会议通过宪法修正案,明确规定“中华人民共和国国歌是《义勇军进行曲》”,这让国歌的地位有了根本大法的保障。
-
+
把它写入宪法,是因为这首歌不仅记录了民族救亡的历史,更凝聚了中华民族的精神气节。从抗战时期的冲锋号到新时代的强国音,它早已成为国家尊严的象征,写入宪法能更好地培育全民的爱国意识和国家观念。
- {/* 光标2对话(图2) - 对话结束后依次出现两道题 */}
+ {/* 光标2对话(图2) */}
{
- setGuide2Visible(nextVisible)
- if (!nextVisible) {
- setQuiz2_1Visible(true)
- }
- }}
+ onVisibleChange={setGuide2Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
-
+
小沪,从抗日战歌到正式被确立为国歌,中间经历了多久呀?《义勇军进行曲》第一次作为国歌奏响是在什么时候呢?
-
+
时间跨度还真不短呢!1949年9月27日,中国人民政治协商会议第一届全体会议决定,在国歌未正式制定前,以《义勇军进行曲》作为代国歌。
-
+
同年10月1日开国大典上,它第一次在天安门广场作为国歌庄严奏响,见证了新中国的诞生。直到1982年12月4日,第五届全国人民代表大会第五次会议才正式将它定为中华人民共和国国歌,完成了从代国歌到正式国歌的跨越。
@@ -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后依次出现两道题 */}
{
- setGuide3Visible(nextVisible)
- if (!nextVisible) {
- setQuiz3_1Visible(true)
- }
- }}
+ onVisibleChange={setGuide3Visible}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
-
+
根据宪法,2017年9月1日第十二届全国人民代表大会常务委员会第二十九次会议通过《中华人民共和国国歌法》,通过国家立法对国歌的奏唱场合、奏唱礼仪和宣传教育进行规范。
@@ -356,7 +346,7 @@ const NieerPlace5 = () => {
{
/>
{
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 = () => {
{
+ 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}
>
-
+
小沪,这些不同场景下奏响国歌的画面太让人感动了!在和平年代,这首歌还有什么特别的意义呢?
-
- 和平年代里,它的精神力量依然不减。你看这张许海峰奥运夺金的照片,国歌奏响时,是民族自豪的宣告;神舟飞船升空时的国歌,是航天人攻坚克难的礼赞;
+
+ 和平年代里,它的精神力量依然不减。你看这张中国女排奥运夺金的照片,国歌奏响时,是民族自豪的宣告;神舟飞船升空时的国歌,是航天人攻坚克难的礼赞。
-
- 抗震救灾现场的国歌,是全国人民众志成城的誓言。它就像一根精神纽带,无论在高光时刻还是艰难关头,都能凝聚起全国人民的力量,提醒我们不忘历史、砥砺前行。
+
+ 它就像一根精神纽带,无论在高光时刻还是艰难关头,都能凝聚起全国人民的力量,提醒我们不忘历史、砥砺前行。
{/* completed 后的结束语:统一使用 EndingOverlay */}
= {
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) => 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 (
+
+
+
+
+ )
+ case 2:
+ return (
+
+
+
+
+ )
+ case 3:
+ return (
+
+ )
+ case 4:
+ return (
+
+
+
+
+ )
+ case 5:
+ return (
+
+ )
+ case 6:
+ return (
+
+ )
+ 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()
- const [guizhouInput, setGuizhouInput] = useState('')
+ const [guizhouInput, setGuizhouInput] = useState('')
const [pianoFinished, setPianoFinished] = useState(false)
const [selectedQuote, setSelectedQuote] = useState()
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({})
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
- 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 && (
+
+
+
+ )}
- {state.sceneId === 1 && (
-
-
-
-
- )}
-
- {state.sceneId === 2 && (
-
-
- setGuizhouInput(value.slice(0, 10))}
- placeholder="请输入你的爱国感悟(10字以内)"
- maxLength={10}
- />
-
- )}
-
- {state.sceneId === 3 && (
-
-
{
- setPianoFinished(true)
- }}
- />
-
- )}
-
- {state.sceneId === 4 && (
-
-
-
-
- )}
-
- {state.sceneId === 5 && (
- {
- setPostcardFinished(true)
- }}
- onStateChange={setPostcardState}
- />
- )}
-
- {state.sceneId === 6 && (
- {
- setBeijingFinished(true)
- }}
- onSelectionChange={setBeijingSelection}
- />
- )}
+ {renderUnlockContent(state.sceneId, localState, unlockHandlers)}
)}
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) => {
+ 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/场景首页底图`) }}
>
- {/* 返回按钮 */}
{
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
- {/* 六列时间轴内容 */}
{
gridTemplateColumns: `repeat(${LOBBY_LAYOUT.columnCount}, minmax(0, 1fr))`,
}}
>
- {/* 琴弦层:在背景图之上、在顶部图标之下 */}
})
{
/>
{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}
/>
- {/* 顶部图标 */}
{
- {/* 点击前往(当前引导场景) */}
{showGuideState && (
- <>
-
- >
+
)}
)
})}
- {/* 横跨年份层:只负责 1937 跨列展示 */}
{
})}
- {/* 底部 footer + 徽章区 */}
{
}}
>
- {[
- {
- 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 (
-
+
{
)}
@@ -250,7 +213,6 @@ const NieerReflection = () => {
- {/* 右下角:旅行箱 */}
{
onClick={() => navigate(`/${moduleName}/${starName}/reflection/box`)}
/>
- {/* debug 参考线 */}
{debugLobby && (
{
+
+
+ {`同学你好,欢迎来到“跨越时空·体悟”学习模块!
+ 请登陆“时空回溯・亲历时代”体验仓,下一站:1936年,上海。
+ 请带上你的旅行包,穿越时空解锁6个国歌故事,集齐精神徽章,绘制属于你的国歌精神图谱吧~时空门已开,请出发吧!`}
+
)
}
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 = {
}
export const UNLOCK_TITLES: Record = {
- 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>
+): 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 => {
+ 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 | undefined => {
+ const previousPlaceCompletion = getPlaceCompletionMap(visitedReflectionCursors)
+ const previousBadges = new Set(getUnlockedBadgeIds(previousPlaceCompletion))
+
+ const nextVisitedReflectionCursors: Record> = 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
+ 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) => {
+ ({ 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 = () => {
{
setNarrationVisible(false)
@@ -398,6 +399,7 @@ const RongdeshengInternalization = () => {
{
setPreGuideVisible(visible)
if (!visible) {
@@ -442,6 +444,7 @@ const RongdeshengInternalization = () => {
{
setPostGuideVisible(visible)
if (!visible) {
@@ -479,7 +482,7 @@ const RongdeshengInternalization = () => {
animationSpeed={200}
onContinue={() => {
setShowEnding(false)
- navigate(`/${moduleName}/${starName}/home`)
+ navigate('/menu')
}}
/>
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')}
>
- 小沪,快看荣德生的照片吧,作为一个无锡人,当时是有什么机缘让他离开家乡,到上海闯荡的呢?
+ 小沪,这就是荣德生先生的照片吧,作为一个无锡人,当时是有什么机缘让他离开家乡,到上海闯荡的呢?
{
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 = () => {
晚年的荣先生依然关注民族工业的发展,积极建言献策,他创办的企业后来成为新中国轻工业的重要骨干,为国家的经济建设贡献了力量。
+
+
setEndingGuideVisible(false)}
+ />
)
}
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 = () => {
{/* 旁白 */}
{
@@ -487,6 +488,7 @@ const SunzhongshanInternalization = () => {
{/* 对话引导 */}
{
setVisible(visible)
if (!visible) {
@@ -513,6 +515,7 @@ const SunzhongshanInternalization = () => {
{/* 视频结束后的引导 */}
{
setPostGuideVisible(visible)
// 引导结束后显示信件
@@ -551,7 +554,7 @@ const SunzhongshanInternalization = () => {
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(6)
- navigate(`/${moduleName}/${starName}/home`)
+ navigate('/menu')
}}
/>
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
(null)
+ const [singleQuiz1Visible, setSingleQuiz1Visible] = useState(false)
+ const [dragQuiz1Visible, setDragQuiz1Visible] = useState(false)
+ const [dragQuiz1Value, setDragQuiz1Value] = useState([])
+ 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(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 = () => {
{
setShowVideo1(false)
- // 修改cursor状态
- markExhibitVisited(place, 1)
+ setSingleQuiz1Visible(true)
}}
/>
)}
+ {
+ 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')
+ }
+ }}
+ />
+
+ {
+ 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世纪中后期才兴起的科技领域,不在这部著作的构想范围内。请你再想想吧!'
+ )
+ }
+ }}
+ />
+
+
+
{/* 光标4的对话 */}
{
{
setShowVideo2(false)
- // 修改cursor状态
- markExhibitVisited(place, 2)
+ setSingleQuiz2Visible(true)
}}
/>
)}
+
+
+ '《中华民国临时政府组织大纲》是辛亥革命后为组建临时政府而制定的法律文件,主要规定了政府的组织架构,其效力在《中华民国临时约法》颁布后被取代,并非护法运动的核心诉求。请你再想想吧!'
+ }
+ onSubmit={(_value, isCorrect) => {
+ if (isCorrect) {
+ setSingleQuiz2Visible(false)
+ setSuccessAlert2Content(
+ '《中华民国临时约法》是辛亥革命的重要成果,体现了民主共和的精神。1917年段祺瑞拒绝恢复《临时约法》和国会,孙中山正是为了维护这部体现民主宪政的法律,才发起了护法运动。'
+ )
+ setSuccessAlert2Visible(true)
+ }
+ }}
+ />
+
+
)
}
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')}
>
- 我记得国共第一次合作正式形成的标志就是国民党一大,这就是当时的大会宣言吗?
+ 我记得在毛泽东旧居陈列馆中学到,国共第一次合作正式形成的标志就是国民党一大,这就是当时的大会宣言吗?
{
同学你好,欢迎来到“跨越时空·体悟”学习模块!{'\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 = () => {
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(null)
const compositionRef = useRef(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}
/>
+
+ {createdArtworkSrc && (
+ {
+ setShowCompletionDialogue(true)
+ }}
+ onClose={() => {
+ setShowCreatedOverlay(false)
+ }}
+ footer={收藏}
+ />
+ )}
+
+
+ {COMPLETION_DIALOGUE}
+
)
}
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 = () => {
navigate(`/${moduleName}/${starName}/place1`)}
+ onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)}
/>
{/* 体悟 / 内化 预留 */}
{
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 }) => (
+ <>
+ ({
+ 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)
+ }
+ }}
+ />
+ {
+ 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 }) => (
<>
- {
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)
}
}}
/>
{
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 }) => (
- <>
- ({
- 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)
- }
- }}
- />
- {
- 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')
}}
/>
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>(() => 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年生于浙江农村,母亲是他绘画的启蒙人。
@@ -175,7 +174,10 @@ const ZhanglepingPlace1 = () => {
speaker="guide"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_05.mp3')}
>
- 她能字画、工女红,尤擅剪纸、刺绣。张先生自幼耳濡目染,受到启蒙,在小学校师陆寅生指导下,表现出不凡的绘画才能。因为有绘画的陪伴,张先生的童年还是过得非常愉快的,正如你看到的《童年的回忆》这幅画。
+ 你看这里的绣品,就源自张先生的母亲。她能字画、工女红,尤擅剪纸、刺绣。张先生自幼耳濡目染,受到启蒙,在小学老师陆寅生指导下,表现出不凡的绘画才能。
+
+
+ 因为有绘画的陪伴,张先生的童年还是过得非常愉快的,正如你看到的《童年的回忆》这幅画。
但是因为家境贫寒,张先生15岁就只身来到南汇万祥镇,在木料行当学徒,其间他在老板的纸堆上偷偷画画,哪怕被责打也不放弃,还用芦条在沙滩上画巨龙,巨龙里蕴藏的就是他“以画报国”的初心。
@@ -189,14 +191,17 @@ const ZhanglepingPlace1 = () => {
{
+ 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>(() => 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(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 = () => {
{
+ 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)
}}
/>
)}
+
+ <>
+ {
+ if (value === 'A') {
+ return '逃避现实与他的实际行动不符。请你再想想吧!'
+ }
+ if (value === 'B') {
+ return '只强调幽默搞笑,忽略了作品的深刻意义。请你再想想吧!'
+ }
+ if (value === 'D') {
+ return '不关心社会和国家命运与张乐平先生的抗战宣传行为相悖。请你再想想吧!'
+ }
+ return '请你再想想吧!'
+ }}
+ onSubmit={(_value, isCorrect) => {
+ if (!isCorrect) return
+ setQuizVisible1(false)
+ setSuccessAlert1Visible(true)
+ }}
+ />
+
+ {
+ setSuccessAlert1Visible(false)
+ markExhibitVisited(place, 1)
+ }}
+ />
+ >
+
+
{/* 展品2关闭后的讲解视频 */}
{showVideo2 && (
{
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 = () => {
{
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>(() => 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 = () => {
{
+ 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)
}}
/>
>
- {/* 展品2对话结束后的讲解视频 */}
{showVideo3 && (
{
{/* 每题答对后的统一提示框 */}
{
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>(() => 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 = () => {
他说:“一看到孩子,我心里就乐。和他们在一起,我忘乎所以。我别无他技,只有把为孩子服务,当作报国的手段。”张先生为儿童作画,与孩子交流,从中汲取灵感与素材,不断创作出儿童喜爱的漫画作品,为万千儿童种下真善美的种子。
-
- 张乐平先生还为儿童写过很多诗,在创作之余,他经常带着小三毛到公园、街头写生,将所见所闻用文字和画笔记录下来。
-
{
+ 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>(() => 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 = () => {
>
{/* 标题 */}
+ {
+ if (moduleName && starName) {
+ navigate(`/${moduleName}/${starName}/place4`)
+ }
+ }}
+ />
+
{/* 完成全部展品且结束语关闭后,出现离开按钮 */}
{completed && !endingVisible && (
{
speaker="student"
audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_16.mp3')}
>
- 小沪,三毛系列漫画真的太深入人心了!张乐平先生还会和“小三毛迷”们互通书信,张先生真的是非常喜欢儿童呀!
+ 朋友画我!这一整面墙的朋友笔下的张乐平先生和三毛真是各有特色啊!
{
{
+ 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(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 = () => {
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([])
@@ -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 && (
-
-
-
+ <>
+
+
+
+
+
+
+ 下载
+
+
+ >
)}
>
)}
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>
setReflectionWork: (sceneId: number, payload: Record) => void
resetReflectionWorks: () => void
+
+ // 是否已经看过体悟首页学习引导
+ hasSeenReflectionGuide: boolean
+ markReflectionGuideSeen: () => void
}
// 将 Set 转换为数组以便序列化
@@ -129,6 +133,7 @@ export const useNieerStore = create()(
// ========== 体悟模块状态 ==========
visitedReflectionCursors: {},
reflectionWorks: {},
+ hasSeenReflectionGuide: false,
markReflectionCursorVisited: (scene: string, cursorId: number) => {
set((state) => {
@@ -182,6 +187,10 @@ export const useNieerStore = create()(
resetReflectionWorks: () => {
set({ reflectionWorks: {} })
},
+
+ markReflectionGuideSeen: () => {
+ set({ hasSeenReflectionGuide: true })
+ },
}),
{
name: STORE_NAMES.NIEER,
@@ -190,6 +199,7 @@ export const useNieerStore = create()(
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()(
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()(
visitedReflectionCursors: {},
drawArtworkDataUrl: '',
drawArtworkCaption: '',
+ hasDrawArtwork: false,
hasSeenStudyGuide: false,
markExhibitVisited: (place: string, exhibitId: number) => {
@@ -125,12 +127,14 @@ export const useZhanglepingStore = create()(
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()(
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()(
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 {