diff --git a/.env.test b/.env.test index 2a682cd..b581e23 100644 --- a/.env.test +++ b/.env.test @@ -1 +1,2 @@ VITE_DEBUG_ENTRY=true +VITE_DISABLE_FIRST_WATCH_VIDEO_GATING=true diff --git a/INPUT_VALIDATION.md b/INPUT_VALIDATION.md new file mode 100644 index 0000000..5ff2691 --- /dev/null +++ b/INPUT_VALIDATION.md @@ -0,0 +1,128 @@ +# 信件输入校验功能说明 + +## 功能概述 + +为信件模板(`LetterTemplate.tsx`)的输入框添加了严格的内容校验,包括: + +1. **禁止英文字母** - 自动过滤并提示 +2. **禁止数字** - 自动过滤并提示 +3. **禁止所有标点符号和特殊符号**(包括中文标点和英文标点)- 自动过滤并提示 +4. **敏感词过滤** - 检测并替换为星号,提交时校验 + +## 实现细节 + +### 1. 核心函数(`src/lib/utils/sensitiveWords.ts`) + +#### `filterInput(input: string): string` +实时过滤输入内容,在用户输入时自动去除: +- 英文字母(a-z, A-Z) +- 数字(0-9) +- 所有标点符号(包括中文标点:,。!?;:等) +- 所有特殊符号(@#$%^&*等) +- 敏感词(替换为星号) + +#### `validateInput(input: string): { valid: boolean, message: string }` +提交时完整校验,返回详细的错误信息: +- 空内容检测 +- 英文字母检测 +- 数字检测 +- 特殊符号检测 +- 敏感词检测 + +#### 辅助函数 +- `containsEnglish()` - 检测是否包含英文 +- `containsNumber()` - 检测是否包含数字 +- `containsSpecialChars()` - 检测是否包含标点符号或特殊符号(包括中文标点) +- `containsSensitiveWord()` - 检测是否包含敏感词 + +### 2. 敏感词库 + +已扩展的敏感词库包括: +- 基础不文明用语 +- 暴力相关词汇 +- 违法违规内容 +- 歧视性用语 + +可根据实际需求在 `SENSITIVE_WORDS` 数组中扩展。 + +### 3. 用户体验 + +#### 实时过滤 +用户输入时: +- **中文输入法支持**:在输入拼音时不会被过滤,等用户选择中文后才进行过滤 +- **英文字母、数字、所有标点符号和特殊符号** 会被立即删除(用户无法输入) +- **敏感词** 会被替换为星号(例如:"垃圾" → "**") +- **中文字符** 可以正常输入 + +示例: +- 输入 "你好abc123世界" → 实时显示 "你好世界" +- 输入 "测试垃圾内容" → 实时显示 "测试**内容" +- 输入 "你好,世界!" → 实时显示 "你好世界"(标点被过滤) +- 使用中文输入法输入 "nihao" → 看到拼音 → 选择"你好" → 显示"你好" ✅ + +#### 提交校验 +当用户点击提交时: +- 如果内容包含被禁止的字符或敏感词,会显示相应提示 +- 错误的输入框会清空并标红 +- 用户需要重新填写 + +#### 允许的内容 +- ✅ 纯中文字符(汉字) +- ✅ 空格 +- ❌ 英文字母(自动删除) +- ❌ 数字(自动删除) +- ❌ 所有标点符号(包括中文标点如,。!?;:等,自动删除) +- ❌ 所有特殊符号(@#$%^&*等,自动删除) +- ⚠️ 敏感词(替换为星号 * ) + +## 测试 + +所有校验函数都已通过测试: +- ✓ 英文字母检测和过滤 +- ✓ 数字检测和过滤 +- ✓ 特殊符号检测和过滤 +- ✓ 敏感词检测和过滤 +- ✓ 中文标点符号保留 +- ✓ 完整的提交校验流程 + +## 使用示例 + +```typescript +import { filterInput, validateInput } from '@/lib/utils/sensitiveWords' + +// 实时过滤 +const handleChange = (value: string) => { + const filtered = filterInput(value) // 自动过滤英文、数字、特殊符号、敏感词 + setValue(filtered) +} + +// 提交前校验 +const handleSubmit = () => { + const result = validateInput(value) + if (!result.valid) { + showHint(result.message) + return + } + // 继续提交逻辑 +} +``` + +## 配置选项 + +在 `LetterFieldConfig` 中可以通过 `sanitize` 字段控制是否启用校验: + +```typescript +const field: LetterFieldConfig = { + type: 'input', + sanitize: true, // 默认启用校验 + // sanitize: false, // 禁用校验(不建议) +} +``` + +## 注意事项 + +1. **落款姓名** 也会进行相同的校验 +2. **中文输入法支持** - 使用 `onCompositionStart` 和 `onCompositionEnd` 事件处理中文输入法,确保拼音输入过程不会被过滤 +3. **敏感词库** 需要定期维护和更新 +4. 如果需要添加更多敏感词,请在 `sensitiveWords.ts` 的 `SENSITIVE_WORDS` 数组中添加 +5. 所有标点符号(包括中文标点)都会被过滤 diff --git a/public/audio/chenyun/mp3_chenyun_23.mp3 b/public/audio/chenyun/mp3_chenyun_23.mp3 index 7a8e22c..26cd67a 100644 Binary files a/public/audio/chenyun/mp3_chenyun_23.mp3 and b/public/audio/chenyun/mp3_chenyun_23.mp3 differ diff --git a/src/App.tsx b/src/App.tsx index ba6f734..398d397 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,25 +1,17 @@ import { RouterProvider } from 'react-router-dom' -import { Suspense, useEffect } from 'react' +import { Suspense, useEffect, useState } from 'react' import router from './router' import { ScaleProvider, useScale } from './contexts/ScaleContext' import { RotateHint } from './components/RotateHint' import { HintHost } from './components' import { useUserStore } from './store' -import { getUserDefineResult } from '@/lib/utils/api-common' import { SyncLoadingHost, SyncPromptHost, - hideSyncLoading, - showSyncLoading, showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt, } from '@/components' -import { - usePuzzleStore, - deserializePuzzles, - PUZZLE_LESSON_ID, - PUZZLE_KEY, -} from '@/store/puzzleStore' +import { usePuzzleStore, loadPuzzleStore } from '@/store/puzzleStore' const applyStoredProgressionDebugMode = async () => { if (import.meta.env.VITE_DEBUG_ENTRY !== 'true') return false @@ -34,11 +26,12 @@ const applyStoredProgressionDebugMode = async () => { */ function AppContent() { const { scaleStyle } = useScale() + const [bootstrapDone, setBootstrapDone] = useState(false) const setCourseInfo = useUserStore((state) => state.setCourseInfo) useEffect(() => { const params = new URLSearchParams(window.location.search) - const courseId = params.get('courseid') + const courseId = params.get('courseId') ?? params.get('courseid') const uid = params.get('uid') if (courseId || uid) { @@ -51,40 +44,31 @@ function AppContent() { useEffect(() => { ;(async () => { - showSyncLoading() try { - const result = await getUserDefineResult(PUZZLE_LESSON_ID, PUZZLE_KEY) + const status = await loadPuzzleStore({ backend: 'yiban' }) - if (result.status === 'success') { - const data = JSON.parse(result.content) as { puzzles?: Record } - usePuzzleStore.setState((prev) => ({ - ...prev, - puzzles: deserializePuzzles(data.puzzles), - })) + if (status === 'success' || status === 'empty') { + useUserStore.getState().setYibanConnectionStatus('connected') return } - usePuzzleStore.setState((prev) => ({ - ...prev, - puzzles: {}, - })) + useUserStore.getState().setYibanConnectionStatus('disconnected') + await loadPuzzleStore({ backend: 'session' }) - if (result.status === 'unauthenticated') { + if (status === 'unauthenticated') { showYibanUnauthenticatedPrompt() - } else if (result.status === 'error') { + } else if (status === 'error') { showYibanSyncErrorPrompt() } } catch (e) { - usePuzzleStore.setState((prev) => ({ - ...prev, - puzzles: {}, - })) + useUserStore.getState().setYibanConnectionStatus('disconnected') + await loadPuzzleStore({ backend: 'session' }) showYibanSyncErrorPrompt() console.error('Failed to load puzzle store', e) } finally { await applyStoredProgressionDebugMode() - hideSyncLoading() usePuzzleStore.getState().setLoaded(true) + setBootstrapDone(true) } })() }, []) @@ -99,11 +83,11 @@ function AppContent() { }} > Loading...}> - + {bootstrapDone && } - + diff --git a/src/assets/images/components/拼图盒.png b/src/assets/images/components/拼图盒.png index e2fdd89..1a087f9 100644 Binary files a/src/assets/images/components/拼图盒.png and b/src/assets/images/components/拼图盒.png differ diff --git a/src/assets/images/components/点击查看.png b/src/assets/images/components/点击查看.png new file mode 100644 index 0000000..231dbf9 Binary files /dev/null and b/src/assets/images/components/点击查看.png differ diff --git a/src/assets/images/huangyanpei/place2/4_004.png b/src/assets/images/huangyanpei/place2/4_004.png index aac465f..e2fe9bb 100644 Binary files a/src/assets/images/huangyanpei/place2/4_004.png and b/src/assets/images/huangyanpei/place2/4_004.png differ diff --git a/src/assets/images/huangyanpei/place2/4_005.png b/src/assets/images/huangyanpei/place2/4_005.png index 3fc2eb3..ebab600 100644 Binary files a/src/assets/images/huangyanpei/place2/4_005.png and b/src/assets/images/huangyanpei/place2/4_005.png differ diff --git a/src/assets/images/huangyanpei/place3/5_006.png b/src/assets/images/huangyanpei/place3/5_006.png index 12d9992..ddb2609 100644 Binary files a/src/assets/images/huangyanpei/place3/5_006.png and b/src/assets/images/huangyanpei/place3/5_006.png differ diff --git a/src/assets/images/luxun/place1/展品2.png b/src/assets/images/luxun/place1/展品2.png index b47767f..8926dd9 100644 Binary files a/src/assets/images/luxun/place1/展品2.png and b/src/assets/images/luxun/place1/展品2.png differ diff --git a/src/assets/images/luxun/place2/展品3.png b/src/assets/images/luxun/place2/展品3.png index 875c22a..11f98a1 100644 Binary files a/src/assets/images/luxun/place2/展品3.png and b/src/assets/images/luxun/place2/展品3.png differ diff --git a/src/assets/images/luxun/place2/展品4.png b/src/assets/images/luxun/place2/展品4.png index 85bcc63..7fd662b 100644 Binary files a/src/assets/images/luxun/place2/展品4.png and b/src/assets/images/luxun/place2/展品4.png differ diff --git a/src/assets/images/luxun/place2/展品5.png b/src/assets/images/luxun/place2/展品5.png index ce5d38d..fc86939 100644 Binary files a/src/assets/images/luxun/place2/展品5.png and b/src/assets/images/luxun/place2/展品5.png differ diff --git a/src/assets/images/luxun/place3/10.png b/src/assets/images/luxun/place3/10.png index 00df72b..9d899fa 100644 Binary files a/src/assets/images/luxun/place3/10.png and b/src/assets/images/luxun/place3/10.png differ diff --git a/src/assets/images/luxun/place3/6.png b/src/assets/images/luxun/place3/6.png index 48a79df..4a08427 100644 Binary files a/src/assets/images/luxun/place3/6.png and b/src/assets/images/luxun/place3/6.png differ diff --git a/src/assets/images/luxun/place3/7.png b/src/assets/images/luxun/place3/7.png index d8c6532..d1cb973 100644 Binary files a/src/assets/images/luxun/place3/7.png and b/src/assets/images/luxun/place3/7.png differ diff --git a/src/assets/images/luxun/place3/8.png b/src/assets/images/luxun/place3/8.png index ee3aa6d..0cea5a1 100644 Binary files a/src/assets/images/luxun/place3/8.png and b/src/assets/images/luxun/place3/8.png differ diff --git a/src/assets/images/luxun/place3/9.png b/src/assets/images/luxun/place3/9.png index f5135db..74febe1 100644 Binary files a/src/assets/images/luxun/place3/9.png and b/src/assets/images/luxun/place3/9.png differ diff --git a/src/assets/images/luxun/place4/展品11.png b/src/assets/images/luxun/place4/展品11.png index b38c10a..cc8dba3 100644 Binary files a/src/assets/images/luxun/place4/展品11.png and b/src/assets/images/luxun/place4/展品11.png differ diff --git a/src/assets/images/luxun/place4/展品12.png b/src/assets/images/luxun/place4/展品12.png index f45f426..c75cd60 100644 Binary files a/src/assets/images/luxun/place4/展品12.png and b/src/assets/images/luxun/place4/展品12.png differ diff --git a/src/assets/images/maozedong/place1/场景1展品2.png b/src/assets/images/maozedong/place1/场景1展品2.png index fbeb335..615674a 100644 Binary files a/src/assets/images/maozedong/place1/场景1展品2.png and b/src/assets/images/maozedong/place1/场景1展品2.png differ diff --git a/src/assets/images/maozedong/place2/场景2展品1.png b/src/assets/images/maozedong/place2/场景2展品1.png index 74cd251..6ccd3b4 100644 Binary files a/src/assets/images/maozedong/place2/场景2展品1.png and b/src/assets/images/maozedong/place2/场景2展品1.png differ diff --git a/src/assets/images/nieer/place3/场景3展品2.png b/src/assets/images/nieer/place3/场景3展品2.png index a71adf2..09c5bec 100644 Binary files a/src/assets/images/nieer/place3/场景3展品2.png and b/src/assets/images/nieer/place3/场景3展品2.png differ diff --git a/src/assets/images/puzzle/rongdesheng/details.rar b/src/assets/images/puzzle/rongdesheng/details.rar new file mode 100644 index 0000000..8fcec8e Binary files /dev/null and b/src/assets/images/puzzle/rongdesheng/details.rar differ diff --git a/src/assets/images/sunzhongshan/place1/“南北议和”纪念章.png b/src/assets/images/sunzhongshan/place1/“南北议和”纪念章.png index 7bcf655..9f94957 100644 Binary files a/src/assets/images/sunzhongshan/place1/“南北议和”纪念章.png and b/src/assets/images/sunzhongshan/place1/“南北议和”纪念章.png differ diff --git a/src/assets/images/sunzhongshan/place1/“四大寇”合影.png b/src/assets/images/sunzhongshan/place1/“四大寇”合影.png index 0431586..70a8ad1 100644 Binary files a/src/assets/images/sunzhongshan/place1/“四大寇”合影.png and b/src/assets/images/sunzhongshan/place1/“四大寇”合影.png differ diff --git a/src/assets/images/sunzhongshan/place1/民立报.png b/src/assets/images/sunzhongshan/place1/民立报.png index 2154390..4ed19c2 100644 Binary files a/src/assets/images/sunzhongshan/place1/民立报.png and b/src/assets/images/sunzhongshan/place1/民立报.png differ diff --git a/src/assets/images/sunzhongshan/place1/申报.png b/src/assets/images/sunzhongshan/place1/申报.png index 5eed704..8cffc8d 100644 Binary files a/src/assets/images/sunzhongshan/place1/申报.png and b/src/assets/images/sunzhongshan/place1/申报.png differ diff --git a/src/assets/images/sunzhongshan/place1/郑观应的推荐信.png b/src/assets/images/sunzhongshan/place1/郑观应的推荐信.png index 36fb877..bfdb610 100644 Binary files a/src/assets/images/sunzhongshan/place1/郑观应的推荐信.png and b/src/assets/images/sunzhongshan/place1/郑观应的推荐信.png differ diff --git a/src/assets/images/sunzhongshan/place2/917.9.10孙中山就任中华民国军政府海陆军大元帅留影.png b/src/assets/images/sunzhongshan/place2/917.9.10孙中山就任中华民国军政府海陆军大元帅留影.png index 835de0b..b7a6a87 100644 Binary files a/src/assets/images/sunzhongshan/place2/917.9.10孙中山就任中华民国军政府海陆军大元帅留影.png and b/src/assets/images/sunzhongshan/place2/917.9.10孙中山就任中华民国军政府海陆军大元帅留影.png differ diff --git a/src/assets/images/sunzhongshan/place2/实业计划.png b/src/assets/images/sunzhongshan/place2/实业计划.png index 5b55c66..72e51f3 100644 Binary files a/src/assets/images/sunzhongshan/place2/实业计划.png and b/src/assets/images/sunzhongshan/place2/实业计划.png differ diff --git a/src/assets/images/sunzhongshan/place2/铁路计划图.png b/src/assets/images/sunzhongshan/place2/铁路计划图.png index 542dc67..b4cf981 100644 Binary files a/src/assets/images/sunzhongshan/place2/铁路计划图.png and b/src/assets/images/sunzhongshan/place2/铁路计划图.png differ diff --git a/src/assets/images/sunzhongshan/place2/陆军大元帅时佩带的指挥刀.png b/src/assets/images/sunzhongshan/place2/陆军大元帅时佩带的指挥刀.png index cfec48d..a583e75 100644 Binary files a/src/assets/images/sunzhongshan/place2/陆军大元帅时佩带的指挥刀.png and b/src/assets/images/sunzhongshan/place2/陆军大元帅时佩带的指挥刀.png differ diff --git a/src/assets/images/sunzhongshan/place3/中国国民党第一次全国代表大会宣言.png b/src/assets/images/sunzhongshan/place3/中国国民党第一次全国代表大会宣言.png index 73649d3..97eae26 100644 Binary files a/src/assets/images/sunzhongshan/place3/中国国民党第一次全国代表大会宣言.png and b/src/assets/images/sunzhongshan/place3/中国国民党第一次全国代表大会宣言.png differ diff --git a/src/assets/images/sunzhongshan/place3/星期评论.png b/src/assets/images/sunzhongshan/place3/星期评论.png index ec8b2a9..31dbfef 100644 Binary files a/src/assets/images/sunzhongshan/place3/星期评论.png and b/src/assets/images/sunzhongshan/place3/星期评论.png differ diff --git a/src/assets/images/sunzhongshan/place3/李大钊给胡适的信.png b/src/assets/images/sunzhongshan/place3/李大钊给胡适的信.png index 7ef4eba..079eb8e 100644 Binary files a/src/assets/images/sunzhongshan/place3/李大钊给胡适的信.png and b/src/assets/images/sunzhongshan/place3/李大钊给胡适的信.png differ diff --git a/src/assets/images/sunzhongshan/place3/社会主义的精义.png b/src/assets/images/sunzhongshan/place3/社会主义的精义.png index c04e63f..d500b3d 100644 Binary files a/src/assets/images/sunzhongshan/place3/社会主义的精义.png and b/src/assets/images/sunzhongshan/place3/社会主义的精义.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/bg.png b/src/assets/images/sunzhongshan/reflection/scene2/bg.png index 4224925..3d19694 100644 Binary files a/src/assets/images/sunzhongshan/reflection/scene2/bg.png and b/src/assets/images/sunzhongshan/reflection/scene2/bg.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/报纸.png b/src/assets/images/sunzhongshan/reflection/scene3/报纸.png new file mode 100644 index 0000000..566d715 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene3/报纸.png differ diff --git a/src/assets/images/zhangleping/place5/张乐平场景5展品3.png b/src/assets/images/zhangleping/place5/张乐平场景5展品3.png index 7a513db..18b7408 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/animation/FlyToAnimationManager.tsx b/src/components/animation/FlyToAnimationManager.tsx index c04d1dc..6cce0e9 100644 --- a/src/components/animation/FlyToAnimationManager.tsx +++ b/src/components/animation/FlyToAnimationManager.tsx @@ -30,6 +30,7 @@ interface AnimationInstance extends FlyToAnimationOptions { startPos: { x: number; y: number } targetPos: { x: number; y: number } elementHtml: string + size: { width: number; height: number } } class FlyToAnimationManagerClass { @@ -142,7 +143,7 @@ class FlyToAnimationManagerClass { clonedElement.style.margin = '0' clonedElement.style.padding = '0' - // 获取克隆后的 HTML(只获取内部内容,不包含外层元素) + // 获取克隆后的内部内容;尺寸由飞行包装层显式固定,避免 outerHTML 的 absolute 坐标造成二次偏移 const elementHtml = clonedElement.innerHTML // 创建动画实例 @@ -153,6 +154,7 @@ class FlyToAnimationManagerClass { startPos: { x: startX, y: startY }, targetPos: { x: targetX, y: targetY }, elementHtml, + size: { width: fromRect.width, height: fromRect.height }, } this.animations.set(id, instance) @@ -183,6 +185,7 @@ const FlyToAnimationItem = ({ animation }: { animation: AnimationInstance }) => startPos, targetPos, elementHtml, + size, duration = 1200, ease = 'easeInOut', className = '', @@ -223,16 +226,21 @@ const FlyToAnimationItem = ({ animation }: { animation: AnimationInstance }) => duration: duration / 1000, }, }} - className={`fixed pointer-events-none z-[99999] ${className}`} + className={`fixed pointer-events-none z-100002 ${className}`} style={{ left: 0, top: 0, + width: size.width, + height: size.height, }} transformTemplate={({ x, y, scale }) => `translate(-50%, -50%) translateX(${x}) translateY(${y}) scale(${scale})` } > -
+
) } diff --git a/src/components/exhibit/ExhibitFlipOverlay.tsx b/src/components/exhibit/ExhibitFlipOverlay.tsx index ab59ebe..7eda586 100644 --- a/src/components/exhibit/ExhibitFlipOverlay.tsx +++ b/src/components/exhibit/ExhibitFlipOverlay.tsx @@ -89,139 +89,137 @@ const ExhibitFlipOverlay = ({ if (!mounted || !visible) return null return ( -
-
- {/* 第二段:背景与按钮在展品入场后再出现 */} - {showChrome && ( - <> +
+ {/* 第二段:背景与按钮在展品入场后再出现 */} + {showChrome && ( + <> + + + {onClose && ( + > + { + e.stopPropagation() + onClose() + }} + alt="返回" + button + /> + + )} + + )} - {onClose && ( - - { - e.stopPropagation() - onClose() - }} - alt="返回" - button - /> - - )} - - )} - -
+ {/* 第一段:像“获得装备”一样先把展品弹出 */} + { + if (!showChrome) { + setShowChrome(true) + onEnterComplete?.() + } + }} style={{ - perspective: 1200, - WebkitPerspective: '1200px', + transformStyle: 'preserve-3d', + WebkitTransformStyle: 'preserve-3d', }} > - {/* 第一段:像“获得装备”一样先把展品弹出 */} - { - if (!showChrome) { - setShowChrome(true) - onEnterComplete?.() - } - }} +
+ 展品 +
+ + {canFlip && (
展品
- - {canFlip && ( -
- 展品背面 -
- )} -
- - {showChrome && canFlip && ( - - { - e.stopPropagation() - setFlipped((v) => !v) - }} - > - 翻转一下 - - )} -
+
- {showChrome && footer && ( + {showChrome && canFlip && ( - {footer} + { + e.stopPropagation() + setFlipped((v) => !v) + }} + > + 翻转一下 + )}
+ + {showChrome && footer && ( + + {footer} + + )}
) } diff --git a/src/components/guide/GuideSteps.tsx b/src/components/guide/GuideSteps.tsx index e974cb6..117d575 100644 --- a/src/components/guide/GuideSteps.tsx +++ b/src/components/guide/GuideSteps.tsx @@ -191,7 +191,7 @@ const GuideSteps = ({ key={`container-${currentSpeaker}`} className={cn( 'absolute bottom-0 w-[1764px] h-[656px] pt-75 pb-10', - currentSpeaker === 'student' ? 'pl-50 pr-40' : 'pr-80' + currentSpeaker === 'student' ? 'pl-50 pr-40' : 'pr-70' )} initial={{ opacity: 0 }} animate={{ opacity: 1 }} diff --git a/src/components/guide/SyncLoadingOverlay.tsx b/src/components/guide/SyncLoadingOverlay.tsx index 66f3beb..80bda9b 100644 --- a/src/components/guide/SyncLoadingOverlay.tsx +++ b/src/components/guide/SyncLoadingOverlay.tsx @@ -6,9 +6,19 @@ import { type SyncLoadingOptions, } from './syncLoadingApi' -export const SyncLoadingHost = () => { +interface SyncLoadingHostProps { + visible?: boolean + text?: string +} + +export const SyncLoadingHost = ({ + visible: controlledVisible, + text: controlledText, +}: SyncLoadingHostProps) => { const [text, setText] = useState('正在同步学习进度...') const [visible, setVisible] = useState(false) + const actualVisible = controlledVisible ?? visible + const actualText = controlledText ?? text useEffect(() => { const handleShow = (event: Event) => { @@ -31,7 +41,7 @@ export const SyncLoadingHost = () => { return ( - {visible && ( + {actualVisible && ( { />
- {text} + {actualText}
diff --git a/src/components/layout/ResidenceLayout.tsx b/src/components/layout/ResidenceLayout.tsx index bd6cd0d..104fe96 100644 --- a/src/components/layout/ResidenceLayout.tsx +++ b/src/components/layout/ResidenceLayout.tsx @@ -2,6 +2,7 @@ import { Outlet, useMatches, useLocation } from 'react-router-dom' import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import { createPortal } from 'react-dom' import { getPublicPath, getStaticImage } from '@/lib/utils' +import { isVideoCompletedByResidence } from '@/lib/utils/videoCompletion' import type { VideoRouteHandle } from '@/router/types' import Video, { type VideoRef } from '@/components/video' import { VideoProvider } from '@/contexts/VideoContext' @@ -9,6 +10,7 @@ import { PuzzleBoxProvider, usePuzzleBox } from '@/contexts/PuzzleBoxContext' import { PuzzleAnimationProvider } from '@/contexts/PuzzleAnimationContext' import { Image, PuzzleSystem, flyToAnimation } from '@/components' import { usePuzzleStore } from '@/store/puzzleStore' +import { getPuzzlePieceData } from '@/config/puzzleData' import PuzzlePiecePortal from '@/components/puzzle/PuzzlePiecePortal' /** @@ -36,7 +38,7 @@ const ResidenceLayoutContent = () => { const matches = useMatches() const location = useLocation() const { puzzleBoxRef } = usePuzzleBox() - const { unlockPuzzle, isPuzzleUnlocked } = usePuzzleStore() + const { unlockPuzzle, isPuzzleUnlocked, getPuzzleCollectProgress, isLoaded } = usePuzzleStore() const [isVideoPlaying, setIsVideoPlaying] = useState(false) const [isContentVisible, setIsContentVisible] = useState(false) const [isVideoFadingOut, setIsVideoFadingOut] = useState(false) @@ -49,6 +51,8 @@ const ResidenceLayoutContent = () => { const [puzzlePieceId, setPuzzlePieceId] = useState(null) const [isAnimationPlaying, setIsAnimationPlaying] = useState(false) const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行 + const [isAwaitingCollect, setIsAwaitingCollect] = useState(false) + const [isCollectConfirming, setIsCollectConfirming] = useState(false) const puzzlePieceRef = useRef(null) const animationInProgressRef = useRef(false) // 防止重复执行动画 const animationCompletionRef = useRef<(() => void) | null>(null) @@ -65,21 +69,35 @@ const ResidenceLayoutContent = () => { return '' }, [location.pathname]) + // 判断当前路由是否是 place1(讲解视频,首次不可跳过) + const isPlace1Route = useMemo(() => /place1/.test(location.pathname), [location.pathname]) + + // place1 讲解视频是否已完成(用于 initiallyCompleted) + const place1VideoCompleted = useMemo( + () => (residence ? isVideoCompletedByResidence(residence, `${residence}_place1_entry`) : false), + [residence] + ) + // 判断当前故居是否支持拼图盒功能 const hasPuzzleFeature = useMemo( () => RESIDENCES_WITH_PUZZLE.includes(residence as any), [residence] ) + // 当前故居的拼图收集进度(百分比) + // 不使用 useMemo:组件已订阅整个 puzzleStore,收集碎片后会重新渲染并实时计算 + const residenceProgress = + isLoaded && residence ? Math.round(getPuzzleCollectProgress(residence)) : 0 + // 动画配置常量 const ANIMATION_CONFIG = { - APPEAR_DURATION: 800, // 初始出现动画持续时间(3D旋转+放大,毫秒) - GLOW_DURATION: 800, // 发光动画持续时间(毫秒) - FLY_DURATION: 1400, // 飞行动画持续时间(毫秒) + COLLECT_REVEAL_DELAY: 620, // 标题和按钮跟随碎片出现的延迟(毫秒) + COLLECT_CONFIRM_DURATION: 180, // 点击收下后的确认反馈时长(毫秒) + FLY_DURATION: 1650, // 飞行动画持续时间(毫秒) RETRY_DELAY: 100, // 重试延迟(毫秒) MAX_RETRIES: 3, // 最大重试次数 - SCALE: { from: 1, to: 0.25 }, // 缩放比例(更小的目标尺寸,效果更明显) - OPACITY: [1, 1, 0.6, 0] as number[], // 透明度变化(更平滑的淡出) + SCALE: { from: 1, to: 0.16 }, // 缩放比例(更小的目标尺寸,效果更明显) + OPACITY: [1, 1, 0.95, 0.2, 0] as number[], // 透明度变化(更平滑的淡出) } const resolveAnimation = useCallback(() => { @@ -99,6 +117,8 @@ const ResidenceLayoutContent = () => { setShowPuzzlePiece(false) setPuzzlePieceId(null) setIsFlying(false) + setIsAwaitingCollect(false) + setIsCollectConfirming(false) animationInProgressRef.current = false resolveAnimation() }, [resolveAnimation]) @@ -110,8 +130,6 @@ const ResidenceLayoutContent = () => { return false } - setIsFlying(true) - // 使用 requestAnimationFrame 确保元素已完全渲染 requestAnimationFrame(() => { if (!puzzlePieceRef.current || !residence) { @@ -125,7 +143,7 @@ const ResidenceLayoutContent = () => { duration: ANIMATION_CONFIG.FLY_DURATION, scale: ANIMATION_CONFIG.SCALE, opacity: ANIMATION_CONFIG.OPACITY, - ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速 + ease: [0.22, 0.72, 0.16, 1], onComplete: () => { if (residence) { unlockPuzzle(residence, pieceId) @@ -133,6 +151,10 @@ const ResidenceLayoutContent = () => { cleanupAnimation() }, }) + + requestAnimationFrame(() => { + setIsFlying(true) + }) }) return true @@ -184,6 +206,26 @@ const ResidenceLayoutContent = () => { ] ) + const currentPuzzlePieceData = useMemo(() => { + if (!residence || !puzzlePieceId) return null + return getPuzzlePieceData(residence, puzzlePieceId) + }, [puzzlePieceId, residence]) + + const handleCollectPuzzlePiece = useCallback(async () => { + if (!puzzlePieceId || isCollectConfirming || isFlying) return + + setIsCollectConfirming(true) + await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.COLLECT_CONFIRM_DURATION)) + setIsAwaitingCollect(false) + await waitForElementsAndAnimate(puzzlePieceId) + }, [ + ANIMATION_CONFIG.COLLECT_CONFIRM_DURATION, + isCollectConfirming, + isFlying, + puzzlePieceId, + waitForElementsAndAnimate, + ]) + // 处理碎片动画触发(仅支持拼图功能的故居) const handleTriggerPuzzleAnimation = useCallback( (pieceId: number, options?: { onComplete?: () => void }) => { @@ -222,27 +264,17 @@ const ResidenceLayoutContent = () => { // 使用双重 requestAnimationFrame 确保 Portal 渲染完成 requestAnimationFrame(() => { requestAnimationFrame(async () => { - // 等待初始出现动画(旋转+放大)完成 - await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION)) + await new Promise((resolve) => + setTimeout(resolve, ANIMATION_CONFIG.COLLECT_REVEAL_DELAY) + ) - // 等待发光动画显示一段时间,让用户看到碎片 - await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION)) - - // 等待元素准备就绪并执行飞行动画 - await waitForElementsAndAnimate(pieceId) + setIsAwaitingCollect(true) }) }) }, 0) }) }, - [ - ANIMATION_CONFIG.APPEAR_DURATION, - ANIMATION_CONFIG.GLOW_DURATION, - hasPuzzleFeature, - isPuzzleUnlocked, - residence, - waitForElementsAndAnimate, - ] + [ANIMATION_CONFIG.COLLECT_REVEAL_DELAY, hasPuzzleFeature, isPuzzleUnlocked, residence] ) // 获取当前路由的视频路径 @@ -359,6 +391,9 @@ const ResidenceLayoutContent = () => { }} > +
+ {residenceProgress}% +
)}
@@ -382,6 +417,9 @@ const ResidenceLayoutContent = () => { showSkipButton={true} skipText="跳过" onSkip={handleSkip} + skipKey={isPlace1Route && residence ? `${residence}_place1_entry` : undefined} + showReadonlyProgress={isPlace1Route} + initiallyCompleted={isPlace1Route ? place1VideoCompleted : undefined} />
)} @@ -402,10 +440,14 @@ const ResidenceLayoutContent = () => { residence={residence} pieceId={puzzlePieceId} hidden={isFlying} + awaitingCollect={isAwaitingCollect} + confirming={isCollectConfirming} + puzzlePieceTitle={currentPuzzlePieceData?.title ?? null} + onCollect={handleCollectPuzzlePiece} /> )} - {/* 动画遮罩层 - 动画播放时盖住所有元素,防止操作 */} + {/* 动画遮罩层 - 动画播放时盖住所有元素,防止操作(按钮在更高层级的 Portal 中可点击) */} {isAnimationPlaying && createPortal(
{value || '____'} - {value && !disabled && ( + {/* {value && !disabled && ( { @@ -170,7 +170,7 @@ const SelectFillQuizPanel = ({ > × - )} + )} */} {blank.suffix && {blank.suffix}} diff --git a/src/components/letter/LetterTemplate.tsx b/src/components/letter/LetterTemplate.tsx index 32de149..412a8d3 100644 --- a/src/components/letter/LetterTemplate.tsx +++ b/src/components/letter/LetterTemplate.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { AnimatePresence, motion } from 'framer-motion' import { getPublicPath, getStaticImage } from '@/lib/utils' -import { filterInput } from '@/lib/utils/sensitiveWords' +import { filterInput, validateInput, containsSensitiveWord } from '@/lib/utils/sensitiveWords' import { Image, Select, Video, type VideoRef } from '@/components' import { showHint } from '@/components/hint' @@ -98,6 +98,7 @@ const LetterTemplate = ({ const [showReplyVideo, setShowReplyVideo] = useState(false) const [showScene, setShowScene] = useState(false) const [showReplyImage, setShowReplyImage] = useState(false) + const [isComposing, setIsComposing] = useState(false) // 追踪中文输入法状态 const sendVideoRef = useRef(null) const replyVideoRef = useRef(null) const audioRef = useRef(null) @@ -123,6 +124,14 @@ const LetterTemplate = ({ return false } + // 验证落款姓名 + const signatureValidation = validateInput(trimmedSignatureName) + if (!signatureValidation.valid) { + showHint(`落款姓名${signatureValidation.message}`) + setSignatureError(true) + return false + } + const nextErrors = validate ? validate(nextValues, letterContent) : nextValues.map((value, index) => { @@ -139,6 +148,15 @@ const LetterTemplate = ({ return true } + // 验证输入内容(禁止英文、数字、特殊符号、敏感词) + if (field.sanitize !== false) { + const validation = validateInput(trimmedValue) + if (!validation.valid) { + showHint(validation.message) + return true + } + } + if (field.correctValue && trimmedValue !== field.correctValue) { if (field.hints?.error) { showHint(field.hints.error) @@ -206,7 +224,23 @@ const LetterTemplate = ({ const updateBlank = (index: number, value: string) => { const field = resolvedFields[index] ?? DEFAULT_TEXT_FIELD - const nextValue = field.sanitize === false ? value : filterInput(value) + + // 中文输入法输入过程中不过滤,等输入完成后再过滤 + const shouldFilter = field.sanitize !== false && !isComposing + const nextValue = shouldFilter ? filterInput(value) : value + + // 检测是否有非法字符被过滤掉(提示用户) + if (shouldFilter && value !== nextValue && value.length > 0) { + // 有字符被过滤了,检查是哪种类型 + if (/[a-zA-Z]/.test(value)) { + showHint('不能包含英文字母') + } else if (/\d/.test(value)) { + showHint('不能包含数字') + } else if (/[\p{P}\p{S}]/u.test(value)) { + showHint('不能包含标点符号') + } + } + const nextValues = [...blankValues] nextValues[index] = nextValue setBlankValues(nextValues) @@ -287,7 +321,20 @@ const LetterTemplate = ({ } const handleSignatureNameChange = (value: string) => { - const nextValue = filterInput(value) + // 中文输入法输入过程中不过滤,等输入完成后再过滤 + const nextValue = isComposing ? value : filterInput(value) + + // 检测是否有非法字符被过滤掉(提示用户) + if (!isComposing && value !== nextValue && value.length > 0) { + if (/[a-zA-Z]/.test(value)) { + showHint('不能包含英文字母') + } else if (/\d/.test(value)) { + showHint('不能包含数字') + } else if (/[\p{P}\p{S}]/u.test(value)) { + showHint('不能包含标点符号') + } + } + setSignatureName(nextValue) const hasError = !nextValue.trim() @@ -318,6 +365,12 @@ const LetterTemplate = ({ value={signatureName} maxLength={12} onChange={(e) => handleSignatureNameChange(e.target.value)} + onCompositionStart={() => setIsComposing(true)} + onCompositionEnd={(e) => { + setIsComposing(false) + // 输入法完成后立即过滤 + handleSignatureNameChange(filterInput(e.currentTarget.value)) + }} /> {currentDate}
@@ -393,15 +446,33 @@ const LetterTemplate = ({ return ( {partIndex === 0 ? `${linePrefix}${part}` : part} + updateBlank(currentIndex, e.target.value)} onBlur={() => handleInputBlur(currentIndex)} + onCompositionStart={() => setIsComposing(true)} + onCompositionEnd={(e) => { + setIsComposing(false) + // 输入法完成后立即过滤 + const field = resolvedFields[currentIndex] ?? DEFAULT_TEXT_FIELD + if (field.sanitize !== false) { + const originalValue = e.currentTarget.value + const filteredValue = filterInput(originalValue) + + if (containsSensitiveWord(originalValue)) { + showHint('内容包含敏感词,已替换为星号') + } + + updateBlank(currentIndex, filteredValue) + } + }} /> ) diff --git a/src/components/letter/LetterToChenyun.tsx b/src/components/letter/LetterToChenyun.tsx index 63f20ba..d7e9d83 100644 --- a/src/components/letter/LetterToChenyun.tsx +++ b/src/components/letter/LetterToChenyun.tsx @@ -17,7 +17,7 @@ const letterContent = `敬爱的陈云爷爷: 我是____专业的学生。今天,我怀着崇敬又激动的心情,参观了位于上海市青浦区练塘镇的陈云纪念馆,学习了您“不唯上、不唯书、只唯____”的做事准则,还有“综合平衡”“人民至上”的经济思想。我深刻体会到,这些精神不仅是建设年代的智慧结晶,更是新时代指引我们前行的思想灯塔。 -作为新时代学生,我会在专业学习中____,像您当年练珠算、搞调查那样精益求精;未来无论从事什么工作,都要把服务群众、____放在首位;面对困难时不退缩,像您当年稳定物价、推进实业那样,用____本领(结合所学专业)为国家发展贡献自己的力量! +作为新时代学生,我会在专业学习中____,像您当年练珠算、搞调查那样精益求精;未来无论从事什么工作,都要把服务群众、____放在首位;面对困难时不退缩,像您当年稳定物价、推进实业那样,用____本领为国家发展贡献自己的力量! 站在新时代的起点,我一定以____为志向,勤学苦练,传承精神,让您的务实担当在青春里扎根,让奋斗在建设祖国的实践中绽放光彩!` @@ -47,7 +47,8 @@ const fields: Record = { 3: { type: 'input', maxLength: 12, - widthClassName: 'w-[120px]', + widthClassName: 'w-[180px]', + placeholder: '结合所学专业', readOnlyFallback: '专业', }, 4: { diff --git a/src/components/letter/LetterToMaozedong.tsx b/src/components/letter/LetterToMaozedong.tsx index 98edfa5..9908585 100644 --- a/src/components/letter/LetterToMaozedong.tsx +++ b/src/components/letter/LetterToMaozedong.tsx @@ -30,7 +30,7 @@ const letterContent = `敬爱的毛泽东主席: 我是____专业的学生。今天,我怀着崇敬又激动的心情,参观了位于茂名北路120弄甲秀里的上海毛泽东旧居陈列馆,学习了毛泽东思想活的灵魂——____、____、____。我深刻体会到,这十二个字不仅是革命年代的智慧结晶,更是新时代指引我们前行的思想灯塔。 -作为中职生,我深知“____”要求我们在专业学习中脚踏实地,掌握扎实技能;“____”提醒我们,未来无论从事什么职业,都要以服务社会、贡献他人为己任;而“独立自主”则激励我们,在技术革新中____,像C919大飞机团队那样,用____本领(结合所学专业)为推进中国式现代化贡献自己的力量! +作为中职生,我深知“____”要求我们在专业学习中脚踏实地,掌握扎实技能;“____”提醒我们,未来无论从事什么职业,都要以服务社会、贡献他人为己任;而“独立自主”则激励我们,在技术革新中____,像C919大飞机团队那样,用____本领为推进中国式现代化贡献自己的力量! 站在新时代的起点,我一定以____为志向,勤学苦练,精益求精,让____精神在手中传承,让青春在建设祖国的实践中绽放光彩!` @@ -98,7 +98,8 @@ const fields: Record = { 6: { type: 'input', maxLength: 12, - widthClassName: 'w-[120px]', + widthClassName: 'w-[180px]', + placeholder: '结合所学专业', readOnlyFallback: '勇于突破', }, 7: { diff --git a/src/components/puzzle/PuzzlePieceDisplay.tsx b/src/components/puzzle/PuzzlePieceDisplay.tsx index 1921c47..807b8e4 100644 --- a/src/components/puzzle/PuzzlePieceDisplay.tsx +++ b/src/components/puzzle/PuzzlePieceDisplay.tsx @@ -8,6 +8,8 @@ export interface PuzzlePieceDisplayProps { pieceId: number /** 是否显示发光动画 */ showGlow?: boolean + /** 在容器内按真实比例等比贴边展示 */ + fitContain?: boolean /** 自定义类名 */ className?: string } @@ -19,14 +21,14 @@ export interface PuzzlePieceDisplayProps { * - 可以被 ref 引用,用于飞行动画 */ const PuzzlePieceDisplay = forwardRef( - ({ residence, pieceId, showGlow = true, className = '' }, ref) => { + ({ residence, pieceId, showGlow = true, fitContain = false, className = '' }, ref) => { // 碎片图片路径 const pieceImage = getStaticImage(`puzzle/${residence}/${pieceId}`) return (
( } } `} - {`碎片 + {`碎片
) } diff --git a/src/components/puzzle/PuzzlePiecePortal.tsx b/src/components/puzzle/PuzzlePiecePortal.tsx index 75f8bc4..483933f 100644 --- a/src/components/puzzle/PuzzlePiecePortal.tsx +++ b/src/components/puzzle/PuzzlePiecePortal.tsx @@ -1,6 +1,7 @@ import { forwardRef, useEffect, useState } from 'react' import { createPortal } from 'react-dom' import { motion } from 'framer-motion' +import FlipActionButton from '@/components/button/FlipActionButton' import PuzzlePieceDisplay from './PuzzlePieceDisplay' interface PuzzlePiecePortalProps { @@ -8,26 +9,34 @@ interface PuzzlePiecePortalProps { pieceId: number /** 是否隐藏(飞行时隐藏原始碎片) */ hidden?: boolean + awaitingCollect?: boolean + confirming?: boolean + puzzlePieceTitle?: string | null + onCollect?: () => void } -/** - * 碎片 Portal 组件 - * 将碎片渲染到 document.body,确保在最上层显示 - * 支持旋转+放大的初始出现动画 - */ const PuzzlePiecePortal = forwardRef( - ({ residence, pieceId, hidden = false }, ref) => { + ( + { + residence, + pieceId, + hidden = false, + awaitingCollect = false, + confirming = false, + puzzlePieceTitle, + onCollect, + }, + ref + ) => { const [isAnimating, setIsAnimating] = useState(true) - // 当组件首次渲染时,标记动画开始 useEffect(() => { setIsAnimating(true) - // 动画完成后,重置状态以便下次出现时重新播放 const timer = setTimeout(() => { setIsAnimating(false) - }, 800) // 与动画时长一致 + }, 800) return () => clearTimeout(timer) - }, [pieceId]) // 当 pieceId 变化时重新播放动画 + }, [pieceId]) return createPortal(
( position: 'fixed', left: '50%', top: '50%', - width: '120px', - height: '120px', - transform: 'translate(-50%, -50%)', - perspective: '1000px', // 添加透视,让3D效果更明显 - zIndex: 99999, + width: 'min(520px, 86vw)', + height: 'min(420px, 72vh)', + transform: 'translate(-50%, -50%) translateZ(0)', + perspective: '1000px', + zIndex: 100001, pointerEvents: 'none', + willChange: 'transform, opacity', }} > - + )} + + + + {awaitingCollect && !hidden && ( + + {puzzlePieceTitle && ( +
+ {puzzlePieceTitle.split('\n').map((line) => ( +
{line}
+ ))} +
+ )} + + + 收下 + +
+ )}
, document.body ) diff --git a/src/components/title/index.tsx b/src/components/title/index.tsx index 742d4e8..a4442d0 100644 --- a/src/components/title/index.tsx +++ b/src/components/title/index.tsx @@ -26,7 +26,7 @@ const Title = ({ title, subtitle, className, subtitleClassName }: TitleProps) => >
diff --git a/src/components/video/video.tsx b/src/components/video/video.tsx index b48fa50..b6329a6 100644 --- a/src/components/video/video.tsx +++ b/src/components/video/video.tsx @@ -7,6 +7,11 @@ import React, { useEffect, } from 'react' import { cn } from '@/lib/utils' +import { showSyncErrorPrompt } from '@/components/guide/SyncErrorPrompt' +import { + isVideoCompletedByResidence, + markVideoCompletedByResidence, +} from '@/lib/utils/videoCompletion' import type { VideoProps, VideoRef, VideoAnchor, AnchorContext } from './video.types' // Re-export types for convenience @@ -51,6 +56,11 @@ const Video = forwardRef( skipText = '跳过', onSkip, skipButtonClassName, + skipKey, + initiallyCompleted, + onVideoCompleted, + controlsOnReplay, + showReadonlyProgress, controls = false, // 默认不使用原生控制条 ...props }, @@ -59,6 +69,11 @@ const Video = forwardRef( const videoRef = useRef(null) const controlsRef = useRef(null) const progressRef = useRef(null) + const readonlyProgressRef = useRef(null) + const readonlyProgressRafRef = useRef(null) + const routePath = window.location.hash.replace(/^#/, '') || window.location.pathname + const residence = routePath.split('/').filter(Boolean)[1] ?? '' + const storeCompleted = skipKey ? isVideoCompletedByResidence(residence, skipKey) : false const [showControls, setShowControls] = useState(true) const [currentTime, setCurrentTime] = useState(0) const [duration, setDuration] = useState(0) @@ -83,6 +98,12 @@ const Video = forwardRef( const [isVideoReady, setIsVideoReady] = useState(false) // 跟踪是否应该停在锚点处(用于 seek 时强制暂停) const shouldStopAtAnchorRef = useRef<{ time: number; anchorId: string } | null>(null) + // 视频是否已完整播放完成(用于 skipKey / controlsOnReplay 逻辑) + const [isVideoCompleted, setIsVideoCompleted] = useState(initiallyCompleted ?? storeCompleted) + + useEffect(() => { + setIsVideoCompleted(initiallyCompleted ?? storeCompleted) + }, [initiallyCompleted, storeCompleted, skipKey]) useImperativeHandle(ref, () => ({ video: videoRef.current, @@ -106,6 +127,16 @@ const Video = forwardRef( }) }, []) + // 计算只读进度百分比(rAF 驱动,直接操作 DOM 避免 React 重渲染卡顿) + const syncReadonlyProgress = useCallback(() => { + const video = videoRef.current + const progressEl = readonlyProgressRef.current + if (!video || !progressEl) return + const nextDuration = video.duration || duration + const percent = nextDuration > 0 ? Math.min(video.currentTime / nextDuration, 1) : 0 + progressEl.style.transform = `scaleX(${percent})` + }, [duration]) + /** 锚点是否可被触发(用于 timeUpdate / seek 触发) */ const isAnchorTriggerable = useCallback( (anchor: VideoAnchor): boolean => { @@ -387,6 +418,11 @@ const Video = forwardRef( const handleEnded: React.VideoHTMLAttributes['onEnded'] = (e) => { setIsPlaying(false) + if (skipKey) { + setIsVideoCompleted(true) + markVideoCompletedByResidence(residence, skipKey) + onVideoCompleted?.() + } props.onEnded?.(e) } @@ -400,6 +436,16 @@ const Video = forwardRef( props.onPause?.(e) } + const handleError: React.VideoHTMLAttributes['onError'] = (e) => { + showSyncErrorPrompt({ message: '视频加载失败,请检查网络后刷新重试。' }) + setIsPlaying(false) + if (props.onError) { + props.onError(e) + return + } + props.onEnded?.({} as any) + } + const handleSkipClick = (): void => { if (!videoRef.current) return @@ -529,6 +575,40 @@ const Video = forwardRef( // 计算进度百分比 const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0 + const disableFirstWatchVideoGating = + import.meta.env.VITE_DISABLE_FIRST_WATCH_VIDEO_GATING === 'true' + const canShowSkipButton = !skipKey || disableFirstWatchVideoGating || isVideoCompleted + const canShowControls = !controlsOnReplay || disableFirstWatchVideoGating || isVideoCompleted + const shouldShowReadonlyProgress = + !!showReadonlyProgress && isVideoReady && duration > 0 && !(controls && canShowControls) + + useEffect(() => { + if (!shouldShowReadonlyProgress) { + if (readonlyProgressRafRef.current) { + cancelAnimationFrame(readonlyProgressRafRef.current) + readonlyProgressRafRef.current = null + } + return + } + + syncReadonlyProgress() + + const update = () => { + syncReadonlyProgress() + readonlyProgressRafRef.current = requestAnimationFrame(update) + } + + if (isPlaying) { + readonlyProgressRafRef.current = requestAnimationFrame(update) + } + + return () => { + if (readonlyProgressRafRef.current) { + cancelAnimationFrame(readonlyProgressRafRef.current) + readonlyProgressRafRef.current = null + } + } + }, [isPlaying, shouldShowReadonlyProgress, syncReadonlyProgress]) return (
( ref={videoRef} className="w-full h-full object-cover" controls={false} + {...props} onTimeUpdate={handleTimeUpdate} onSeeking={handleSeeking} onSeeked={handleSeeked} onEnded={handleEnded} onPlay={handlePlay} onPause={handlePause} - {...props} + onError={handleError} /> + {shouldShowReadonlyProgress && ( +
+
+
+
+
+ )} {/* 跳过按钮 */} - {showSkipButton && isPlaying && ( + {showSkipButton && isPlaying && canShowSkipButton && ( )} {/* 自定义控制条 */} - {isVideoReady && controls && ( + {isVideoReady && controls && canShowControls && (
onSkip?: () => void /** 自定义跳过按钮样式 */ skipButtonClassName?: string + /** 跳过按钮的唯一标识;传入时,首次播放完成前不显示跳过按钮 */ + skipKey?: string + /** 从 store 读取的初始完成状态,用于初始化 isVideoCompleted */ + initiallyCompleted?: boolean + /** 视频首次完整播放完成通知(仅在 skipKey 传入时触发) */ + onVideoCompleted?: () => void + /** 二次学习时才显示 controls 进度条(首次学习不显示进度条) */ + controlsOnReplay?: boolean + /** 是否显示只读简化进度条(不可拖拽、不可点击,仅展示播放进度) */ + showReadonlyProgress?: boolean } /** Video 组件 Ref 暴露的方法 */ diff --git a/src/debug/progressionDebug.ts b/src/debug/progressionDebug.ts index 36d0f6d..ebae40b 100644 --- a/src/debug/progressionDebug.ts +++ b/src/debug/progressionDebug.ts @@ -4,14 +4,22 @@ import { usePuzzleStore } from '@/store/puzzleStore' const MODE_KEY = 'debug:progression-mode' const BACKUP_KEY = 'debug:progression-real-puzzles-backup' +const POSITION_KEY = 'debug:progression-badge-position' const BADGE_ID = 'progression-debug-badge' const PANEL_ID = 'progression-debug-panel' +const EDGE_PADDING = 12 +const DRAG_THRESHOLD = 4 type DebugMode = | { type: 'real' } | { type: 'arrived'; residence: string; label: string } | { type: 'allCompleted' } +type BadgePosition = { + edge: 'right' | 'left' + offset: number +} + const serializePuzzles = (puzzles: Record>) => { return Object.fromEntries( Object.entries(puzzles).map(([residence, pieces]) => [residence, [...pieces]]) @@ -83,6 +91,70 @@ const getModeLabel = (mode = getStoredMode()) => { return '真实进度' } +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max) + +const getStoredBadgePosition = (): BadgePosition => { + const raw = window.sessionStorage.getItem(POSITION_KEY) + if (!raw) return { edge: 'right', offset: EDGE_PADDING } + + try { + const position = JSON.parse(raw) as BadgePosition + if (position.edge === 'right' || position.edge === 'left') return position + } catch { + return { edge: 'right', offset: EDGE_PADDING } + } + + return { edge: 'right', offset: EDGE_PADDING } +} + +const setStoredBadgePosition = (position: BadgePosition) => { + window.sessionStorage.setItem(POSITION_KEY, JSON.stringify(position)) +} + +const applyBadgePosition = (badge: HTMLElement, position = getStoredBadgePosition()) => { + const rect = badge.getBoundingClientRect() + const maxTop = Math.max(EDGE_PADDING, window.innerHeight - rect.height - EDGE_PADDING) + + badge.style.left = '' + badge.style.right = '' + badge.style.top = `${clamp(position.offset, EDGE_PADDING, maxTop)}px` + badge.style.bottom = '' + badge.style[position.edge] = `${EDGE_PADDING}px` +} + +const getSnappedPosition = (badge: HTMLElement): BadgePosition => { + const rect = badge.getBoundingClientRect() + const edge = rect.left < window.innerWidth - rect.right ? 'left' : 'right' + + return { edge, offset: rect.top } +} + +const positionPanelNearBadge = (panel: HTMLElement) => { + const badge = document.getElementById(BADGE_ID) + if (!badge) { + panel.style.right = `${EDGE_PADDING}px` + panel.style.top = '72px' + return + } + + const badgeRect = badge.getBoundingClientRect() + const panelRect = panel.getBoundingClientRect() + const top = clamp( + badgeRect.bottom + 8, + EDGE_PADDING, + window.innerHeight - panelRect.height - EDGE_PADDING + ) + const left = clamp( + badgeRect.left, + EDGE_PADDING, + window.innerWidth - panelRect.width - EDGE_PADDING + ) + + panel.style.left = `${left}px` + panel.style.top = `${top}px` + panel.style.right = '' +} + const backupRealPuzzles = () => { if (window.sessionStorage.getItem(BACKUP_KEY)) return @@ -206,6 +278,81 @@ const togglePanel = () => { panel.appendChild(list) document.body.appendChild(panel) + positionPanelNearBadge(panel) +} + +const setupBadgeDrag = (badge: HTMLElement) => { + let isPointerDown = false + let hasDragged = false + let startX = 0 + let startY = 0 + let startLeft = 0 + let startTop = 0 + let startWidth = 0 + let startHeight = 0 + + badge.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return + + const rect = badge.getBoundingClientRect() + isPointerDown = true + hasDragged = false + startX = event.clientX + startY = event.clientY + startLeft = rect.left + startTop = rect.top + startWidth = rect.width + startHeight = rect.height + badge.setPointerCapture(event.pointerId) + }) + + badge.addEventListener('pointermove', (event) => { + if (!isPointerDown) return + + const deltaX = event.clientX - startX + const deltaY = event.clientY - startY + if (!hasDragged && Math.hypot(deltaX, deltaY) < DRAG_THRESHOLD) return + + hasDragged = true + removePanel() + event.preventDefault() + + badge.style.left = `${clamp( + startLeft + deltaX, + EDGE_PADDING, + window.innerWidth - startWidth - EDGE_PADDING + )}px` + badge.style.top = `${clamp( + startTop + deltaY, + EDGE_PADDING, + window.innerHeight - startHeight - EDGE_PADDING + )}px` + badge.style.right = '' + badge.style.bottom = '' + }) + + badge.addEventListener('pointerup', (event) => { + if (!isPointerDown) return + + isPointerDown = false + badge.releasePointerCapture(event.pointerId) + + if (!hasDragged) return + + const position = getSnappedPosition(badge) + setStoredBadgePosition(position) + applyBadgePosition(badge, position) + badge.dataset.skipClick = 'true' + window.setTimeout(() => { + delete badge.dataset.skipClick + }, 100) + }) + + badge.addEventListener('pointercancel', (event) => { + isPointerDown = false + badge.releasePointerCapture(event.pointerId) + applyBadgePosition(badge) + }) } const updateBadge = () => { @@ -225,14 +372,33 @@ const updateBadge = () => { badge.style.fontSize = '12px' badge.style.lineHeight = '1.5' badge.style.cursor = 'pointer' - badge.style.whiteSpace = 'pre-line' - badge.addEventListener('click', togglePanel) + badge.style.textAlign = 'left' + badge.style.touchAction = 'none' + badge.addEventListener('click', (event) => { + if (badge?.dataset.skipClick) { + event.preventDefault() + return + } + togglePanel() + }) + setupBadgeDrag(badge) document.body.appendChild(badge) } const mode = getStoredMode() const title = mode.type === 'real' ? '测试包:真实进度' : '测试包:模拟进度' - badge.textContent = `${title}\n当前:${getModeLabel(mode)}\nCtrl+Shift+U 打开面板` + badge.replaceChildren() + + const titleLine = document.createElement('span') + titleLine.textContent = title + titleLine.style.display = 'block' + + const detailLine = document.createElement('span') + detailLine.textContent = `${getModeLabel(mode)} / Ctrl+Shift+U` + detailLine.style.display = 'block' + + badge.append(titleLine, detailLine) + applyBadgePosition(badge) } export const setupProgressionDebug = () => { diff --git a/src/index.css b/src/index.css index 92658bb..6b8c5c0 100644 --- a/src/index.css +++ b/src/index.css @@ -236,3 +236,18 @@ video { .animate-finger-bounce { animation: finger-bounce 1.2s ease-in-out infinite; } + +/* 点一点图标动画 - 手指左右摆动(横向引导点击) */ +@keyframes finger-shake { + 0%, + 100% { + transform: translateX(0); + } + 50% { + transform: translateX(-12px); + } +} + +.animate-finger-shake { + animation: finger-shake 1s ease-in-out infinite; +} diff --git a/src/lib/utils/api-common.ts b/src/lib/utils/api-common.ts index c79667a..5e7f6e6 100644 --- a/src/lib/utils/api-common.ts +++ b/src/lib/utils/api-common.ts @@ -1,16 +1,10 @@ import { useUserStore } from '@/store/userStore' //超时时间 -const TIMEOUT_MS = 1000 * 10 +const TIMEOUT_MS = 1000 * 6 // 声明外部依赖(由运行环境提供) -declare const $: any -declare const iscallyb: boolean -declare const emykey: string declare function videoAjax(): VideoAjax -declare function getFutureDa(days: number): string -declare function getCurrentDa(str: string): void -declare function formatDateFromMilliseconds(date: Date): string interface UserDefineParam { lessonId: string @@ -54,113 +48,57 @@ interface VideoAjax { finishCourse(param: { order: number; co: string; process: number }): void } -// 扩展 jQuery,获取 URL 参数 -$.extend({ - getUrlVars(): string[] & Record { - const vars: any[] & Record = [] as any - let hash: string[] - const queryIndex = window.location.href.indexOf('?') - if (queryIndex === -1) return vars - const hashes = window.location.href - .slice(queryIndex + 1) - .split('&') - .filter(Boolean) - for (let i = 0; i < hashes.length; i++) { - const item = hashes[i] - if (!item) continue - hash = item.split('=') - const keyRaw = hash[0] ?? '' - const valueRaw = hash[1] ?? '' - const key = decodeURIComponent(keyRaw) - const value = decodeURIComponent(valueRaw) - vars.push(key) - vars[key] = value - } - return vars - }, - getUrlVar(name: string): string | undefined { - return ($ as any).getUrlVars()[name] - }, -}) - const LESSON_PREFIX = 'hsgrl' +export type StorageBackend = 'auto' | 'yiban' | 'session' + +export interface StorageOptions { + backend?: StorageBackend +} + +const getStorageKey = (lessonId: string | number, key: string | number) => + `${LESSON_PREFIX}_${lessonId}_${key}` + +const resolveStorageBackend = (backend: StorageBackend = 'auto'): 'yiban' | 'session' => { + if (backend === 'yiban' || backend === 'session') return backend + return useUserStore.getState().yibanConnectionStatus === 'connected' ? 'yiban' : 'session' +} + const videoajax: VideoAjax = videoAjax() videoajax.setHost('/') -// 设置我的自定义数据(所有的自定义数据都设置在第一个课时ID上,以key来区分不同的类型) -function setLtDefine(key: string | number, value: string): void { - if (iscallyb) { - setUserDefine(1, key, value) - } else { - sessionStorage.setItem(`${LESSON_PREFIX}_${key}`, value) - } -} - -let mycstr9 = '' - -// 获取定义数据(由于底层是异步接口,这里返回 Promise) -async function getLtDefine(key: string | number): Promise { - if (iscallyb) { - if (mycstr9 === '') { - const all = await getUsersDefine(1, emykey) - if (all === undefined || all === null || all === 'null' || all === '') { - mycstr9 = getFutureDa(10) - setLtDefine(emykey, mycstr9) - } else { - mycstr9 = all as string // 这里是 emykey,对应上面 getUsersDefine 的 string 分支 - } - } - const str = mycstr9 === undefined || mycstr9 === null || mycstr9 === 'null' ? '' : mycstr9 - getCurrentDa(str) - const objitem = await getUserDefine(1, key) - return objitem === undefined || objitem === null || objitem === 'null' ? '' : objitem - } else { - const itemvalue = sessionStorage.getItem(`${LESSON_PREFIX}_${key}`) - return itemvalue === undefined || itemvalue === null || itemvalue === 'null' ? '' : itemvalue - } -} - -// 设置课程笔记10002 -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function setCourseNotepad(collect: string[]): void { - // 保持和原逻辑一致:序列化为 JSON 存储 - setLtDefine(10002, $.toJSON(collect)) -} - -// 获取课程笔记10002(异步) -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function getCourseNotepad(): Promise { - const raw = await getLtDefine(10002) - if (raw === undefined || raw === null || raw === '') { - // 原逻辑:返回长度为 9 的空字符串数组 - return ['', '', '', '', '', '', '', '', ''] - } - return $.parseJSON(raw) as string[] -} - // lessonId(课时唯一ID) export function setUserDefine( lessonId: string | number, key: string | number, - value: string + value: string, + options: StorageOptions = {} ): void { + const backend = resolveStorageBackend(options.backend) + + if (backend === 'session') { + sessionStorage.setItem(getStorageKey(lessonId, key), value) + return + } + const param: UserDefineParam = { lessonId: `${LESSON_PREFIX}_${lessonId}`, key, content: value, } - console.log(param) videoajax.addUserDefine(param) } // 内部:将回调形式封装为 Promise function getUserDefineResultAsyncInternal( lessonId: string | number, - key: string | number + key: string | number, + options: StorageOptions = {} ): Promise { - if (!iscallyb) { - const itemValue = sessionStorage.getItem(`${LESSON_PREFIX}_${lessonId}_${key}`) + const backend = resolveStorageBackend(options.backend) + + if (backend === 'session') { + const itemValue = sessionStorage.getItem(getStorageKey(lessonId, key)) return Promise.resolve( itemValue === undefined || itemValue === null || itemValue === 'null' || itemValue === '' ? { status: 'empty', content: '' } @@ -175,149 +113,100 @@ function getUserDefineResultAsyncInternal( return new Promise((resolve) => { let settled = false - const done = (result: UserDefineFetchResult) => { - if (settled) return - settled = true - resolve(result) - } - const timeout = setTimeout(() => { console.warn(`getUserDefine timeout: lessonId=${lessonId}, key=${key}`) done({ status: 'error', content: '', message: '学习进度获取超时' }) }, TIMEOUT_MS) - videoajax.getUserDefineInfoByUser(param, (res) => { + const done = (result: UserDefineFetchResult) => { + if (settled) return + settled = true clearTimeout(timeout) - console.log(res) + resolve(result) + } - if (res === undefined || res === null || res === ('null' as unknown)) { - done({ status: 'empty', content: '' }) - return - } + try { + videoajax.getUserDefineInfoByUser(param, (res) => { + try { + console.log(res) - if (res.code === 202) { - done({ status: 'unauthenticated', content: '', code: res.code, message: res.msg }) - return - } + if (res === undefined || res === null || res === ('null' as unknown)) { + done({ status: 'empty', content: '' }) + return + } - if (res.code !== 200) { - done({ - status: 'error', - content: '', - code: res.code, - message: res.msg || '学习进度获取失败', - }) - return - } + if (res.code === 202) { + done({ status: 'unauthenticated', content: '', code: res.code, message: res.msg }) + return + } - const value = res.data?.value - if (value === undefined || value === null || value === 'null' || value === '') { - done({ status: 'empty', content: '' }) - return - } + if (res.code !== 200) { + done({ + status: 'error', + content: '', + code: res.code, + message: res.msg || '学习进度获取失败', + }) + return + } - const json = Array.isArray(value) ? value : [] - const content = json[0]?.content || '' - done( - content === '' || content === 'null' - ? { status: 'empty', content: '' } - : { status: 'success', content } - ) - }) + const value = res.data?.value + if (value === undefined || value === null || value === 'null' || value === '') { + done({ status: 'empty', content: '' }) + return + } + + const json = Array.isArray(value) ? value : [] + const content = json[0]?.content || '' + done( + content === '' || content === 'null' + ? { status: 'empty', content: '' } + : { status: 'success', content } + ) + } catch (error) { + console.error('Failed to handle getUserDefine response', error) + done({ status: 'error', content: '', message: '学习进度解析失败' }) + } + }) + } catch (error) { + console.error('Failed to call getUserDefine', error) + done({ status: 'error', content: '', message: '学习进度获取失败' }) + } }) } -function getUserDefineAsyncInternal( - lessonId: string | number, - key: string | number -): Promise { - return getUserDefineResultAsyncInternal(lessonId, key).then((result) => - result.status === 'success' ? result.content : '' - ) -} - // Promise 版本:获取用户自定义数据 export async function getUserDefineResult( lessonId: string | number, - key: string | number + key: string | number, + options: StorageOptions = {} ): Promise { - return getUserDefineResultAsyncInternal(lessonId, key) -} - -export async function getUserDefine( - lessonId: string | number, - key: string | number -): Promise { - return getUserDefineAsyncInternal(lessonId, key) -} - -// 内部:获取【所有】用户自定义数据(Promise 封装) -function getUsersDefineAsyncInternal( - lessonId: string | number, - key: string | number -): Promise { - const param: UserDefineParam = { - lessonId: `${LESSON_PREFIX}_${lessonId}`, - order: key, - } - - return new Promise((resolve) => { - let settled = false - const done = (content: string | VideoAjaxResponseItem[]) => { - if (settled) return - settled = true - resolve(content) - } - - const timeout = setTimeout(() => { - console.warn(`getUsersDefine timeout: lessonId=${lessonId}, key=${key}`) - done('') - }, TIMEOUT_MS) - - videoajax.getUserDefineInfo(param, (data) => { - clearTimeout(timeout) - let content: string | VideoAjaxResponseItem[] = '' - if (data && data.code === 200) { - const json = (data.data.value || []) as VideoAjaxResponseItem[] - if (json.length > 1) { - if (key === emykey) { - const mdate = new Date( - Math.max.apply( - null, - json.map((item) => new Date(item.content || '').getTime()) - ) - ) - content = formatDateFromMilliseconds(mdate) - } else { - content = json - } - } else { - content = json[0]?.content || '' - } - } - done(content) - }) - }) -} - -// Promise 版本:获取【所有】用户自定义数据 -export async function getUsersDefine( - lessonId: string | number, - key: string | number -): Promise { - return getUsersDefineAsyncInternal(lessonId, key) + return getUserDefineResultAsyncInternal(lessonId, key, options) } // 根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度(异步) -export async function setCourseTimeProgress(order: number): Promise { +export async function setCourseTimeProgress(order: number): Promise { + if (useUserStore.getState().yibanConnectionStatus !== 'connected') { + console.info(`[yiban] skip finishCourse: disconnected, order=${order}`) + return false + } + const courseid = useUserStore.getState().courseId // var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID // const courseid = await getLtDefine('10000') - console.info(`courseid:${courseid} order:${order}`) - if (iscallyb && courseid !== undefined && courseid !== null && courseid !== '') { - if (order > 0) { - const param2 = { order, co: courseid, process: 100 } - videoajax.finishCourse(param2) - } + + if (courseid === undefined || courseid === null || courseid === '') { + console.info(`[yiban] skip finishCourse: missing courseId, order=${order}`) + return false } + + if (order <= 0) { + console.info(`[yiban] skip finishCourse: invalid order=${order}`) + return false + } + + const param2 = { order, co: courseid, process: 100 } + console.info('[yiban] finishCourse', param2) + videoajax.finishCourse(param2) + return true } diff --git a/src/lib/utils/sensitiveWords.ts b/src/lib/utils/sensitiveWords.ts index 59487fc..e04983f 100644 --- a/src/lib/utils/sensitiveWords.ts +++ b/src/lib/utils/sensitiveWords.ts @@ -5,20 +5,56 @@ // 敏感词库(可根据实际需求扩展) const SENSITIVE_WORDS = [ + // 基础不文明用语 '垃圾', '脏话', '骂人', + '辱骂', + '仇恨', + '傻逼', + '煞笔', + '傻b', + '傻比', + 'sb', + 'SB', + '草泥马', + '操', + '妈的', + '他妈', + '你妈', + '去死', + '找死', + '该死', + '白痴', + '智障', + '弱智', + '脑残', + '废物', + '贱', + '滚', + // 暴力相关 '暴力', '恐怖', + '杀人', + '杀害', + '血腥', + '虐待', + // 违法违规 '色情', '赌博', '诈骗', '毒品', '邪教', '反动', - '辱骂', - '仇恨', - 'sb', + '法轮功', + '传销', + '洗钱', + '贩毒', + '走私', + // 歧视性用语 + '歧视', + '种族', + '民族歧视', ] type FileSaveHandle = { @@ -78,6 +114,28 @@ export async function downloadDataUrlImage(dataUrl: string, filename: string): P triggerAnchorDownload(blob, filename) } +/** + * 检查文本中是否包含英文字母 + */ +export function containsEnglish(text: string): boolean { + return /[a-zA-Z]/.test(text) +} + +/** + * 检查文本中是否包含数字 + */ +export function containsNumber(text: string): boolean { + return /\d/.test(text) +} + +/** + * 检查文本中是否包含特殊符号或标点符号 + */ +export function containsSpecialChars(text: string): boolean { + // 检测所有标点符号和特殊符号(包括中文标点) + return /[\p{P}\p{S}]/u.test(text) +} + /** * 检查文本中是否包含敏感词 * @param text 待检查的文本 @@ -117,8 +175,50 @@ export function filterSensitiveWords(text: string): string { export function filterInput(input: string): string { if (!input) return input - // 先进行敏感词过滤 - const filtered = filterSensitiveWords(input) + let filtered = input + + // 过滤英文字母 + filtered = filtered.replace(/[a-zA-Z]/g, '') + + // 过滤数字 + filtered = filtered.replace(/\d/g, '') + + // 过滤所有标点符号和特殊符号 + // 保留:中文字符、空格 + // 过滤:所有标点符号(包括中文标点和英文标点)、特殊符号 + filtered = filtered.replace(/[\p{P}\p{S}]/gu, '') + + // 进行敏感词过滤(替换为星号,不直接删除) + filtered = filterSensitiveWords(filtered) return filtered } + +/** + * 验证输入内容的完整性 + * @param input 用户输入的内容 + * @returns 验证结果 { valid: boolean, message: string } + */ +export function validateInput(input: string): { valid: boolean; message: string } { + if (!input || !input.trim()) { + return { valid: false, message: '内容不能为空' } + } + + if (containsEnglish(input)) { + return { valid: false, message: '不能包含英文字母' } + } + + if (containsNumber(input)) { + return { valid: false, message: '不能包含数字' } + } + + if (containsSpecialChars(input)) { + return { valid: false, message: '不能包含特殊符号' } + } + + if (containsSensitiveWord(input)) { + return { valid: false, message: '内容包含敏感词,请修改后重试' } + } + + return { valid: true, message: '' } +} diff --git a/src/lib/utils/videoCompletion.ts b/src/lib/utils/videoCompletion.ts new file mode 100644 index 0000000..3bed30b --- /dev/null +++ b/src/lib/utils/videoCompletion.ts @@ -0,0 +1,40 @@ +import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore' +import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore' +import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore' +import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore' +import { useMaozedongStore, saveMaozedongStore } from '@/store/maozedongStore' +import { useNieerStore, saveNieerStore } from '@/store/nieerStore' +import { useRongdeshengStore, saveRongdeshengStore } from '@/store/rongdeshengStore' +import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore' + +const STORE_MAP: Record void }> = { + chenyun: { store: useChenyunStore, save: saveChenyunStore }, + huangyanpei: { store: useHuangyanpeiStore, save: saveHuangyanpeiStore }, + sunzhongshan: { store: useSunzhongshanStore, save: saveSunzhongshanStore }, + luxun: { store: useLuxunStore, save: saveLuxunStore }, + maozedong: { store: useMaozedongStore, save: saveMaozedongStore }, + nieer: { store: useNieerStore, save: saveNieerStore }, + rongdesheng: { store: useRongdeshengStore, save: saveRongdeshengStore }, + zhangleping: { store: useZhanglepingStore, save: saveZhanglepingStore }, +} + +export function markVideoCompletedByResidence(residence: string, videoKey: string) { + const entry = STORE_MAP[residence] + if (!entry) return + entry.store.getState().markVideoCompleted(videoKey) + entry.save() +} + +export function isVideoCompletedByResidence(residence: string, videoKey: string): boolean { + const entry = STORE_MAP[residence] + if (!entry) return false + return !!entry.store.getState().completedVideos?.[videoKey] +} + +export function getVideoCompletionProps(residence: string, videoKey: string) { + return { + skipKey: videoKey, + initiallyCompleted: isVideoCompletedByResidence(residence, videoKey), + onVideoCompleted: () => markVideoCompletedByResidence(residence, videoKey), + } +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index aec2ff4..4c152d3 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -4,9 +4,22 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' import { Image, Video } from '@/components' import { useNavigate } from 'react-router-dom' +const INTRO_VIEWED_KEY = 'home-intro-viewed' + const Home = () => { const navigate = useNavigate() const [showIntroVideo, setShowIntroVideo] = useState(false) + const [introViewed, setIntroViewed] = useState( + () => localStorage.getItem(INTRO_VIEWED_KEY) === 'true' + ) + + const handleIntroClick = () => { + setShowIntroVideo(true) + if (!introViewed) { + setIntroViewed(true) + localStorage.setItem(INTRO_VIEWED_KEY, 'true') + } + } return (
{ exit={{ opacity: 0, scale: 0.92, x: 24 }} transition={{ duration: 0.22, ease: 'easeOut' }} > - setShowIntroVideo(true)} - /> +
+ + {!introViewed && ( + + )} +
)} diff --git a/src/pages/Menu.tsx b/src/pages/Menu.tsx index 47c57c2..98e12bf 100644 --- a/src/pages/Menu.tsx +++ b/src/pages/Menu.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { Image, PuzzleSystem } from '@/components' import { useNavigate } from 'react-router-dom' -import { DEFAULT_PUZZLE_RESIDENCE } from '@/config/puzzleResidences' +import { DEFAULT_PUZZLE_RESIDENCE, PUZZLE_RESIDENCES } from '@/config/puzzleResidences' +import { puzzleDataConfig } from '@/config/puzzleData' import { usePuzzleStore } from '@/store/puzzleStore' import { isMenuModuleComplete } from '@/config/progression' @@ -12,6 +13,18 @@ const Menu = () => { const puzzles = usePuzzleStore((state) => state.puzzles) const isLoaded = usePuzzleStore((state) => state.isLoaded) + const courseProgress = useMemo(() => { + const totalPieces = PUZZLE_RESIDENCES.reduce((total, item) => { + return total + Object.keys(puzzleDataConfig[item.residence] ?? {}).length + }, 0) + const unlockedPieces = PUZZLE_RESIDENCES.reduce((total, item) => { + return total + (puzzles[item.residence]?.size ?? 0) + }, 0) + + if (!isLoaded || totalPieces === 0) return 0 + return Math.round((unlockedPieces / totalPieces) * 100) + }, [isLoaded, puzzles]) + /** * 类型:1:革命先驱、文化民人、艺术名家、经济巨擘 */ @@ -61,6 +74,9 @@ const Menu = () => { button onClick={() => setActiveResidence(DEFAULT_PUZZLE_RESIDENCE)} /> +
+ {courseProgress}% +
{/* 进入具体故居的拼图系统(与故居页面一致的 PuzzleSystem) */} diff --git a/src/pages/chenyun/internalization.tsx b/src/pages/chenyun/internalization.tsx index 2659f52..d1a20ac 100644 --- a/src/pages/chenyun/internalization.tsx +++ b/src/pages/chenyun/internalization.tsx @@ -219,7 +219,6 @@ const ChenyunInternalization = () => { /> { setActiveSuccess(null) @@ -334,7 +333,6 @@ const ChenyunInternalization = () => { /> { setActiveSuccess(null) const cb = resumeCallback @@ -395,6 +393,9 @@ const ChenyunInternalization = () => { anchors={anchors} playsInline controls + controlsOnReplay + skipKey="chenyun_internalization_video" + showReadonlyProgress onCanPlay={handleVideoCanPlay} onEnded={() => { setShowVideo(false) diff --git a/src/pages/chenyun/place/place3.tsx b/src/pages/chenyun/place/place3.tsx index 2b85409..11b1c15 100644 --- a/src/pages/chenyun/place/place3.tsx +++ b/src/pages/chenyun/place/place3.tsx @@ -144,6 +144,8 @@ const ChenyunPlace3 = () => { autoPlay playsInline showSkipButton + skipKey="chenyun_place3_video" + showReadonlyProgress controls={false} onEnded={() => { setVideoVisible(false) diff --git a/src/pages/chenyun/place/place5.tsx b/src/pages/chenyun/place/place5.tsx index e9a494a..209b36e 100644 --- a/src/pages/chenyun/place/place5.tsx +++ b/src/pages/chenyun/place/place5.tsx @@ -185,6 +185,8 @@ const ChenyunPlace5 = () => { autoPlay playsInline showSkipButton + skipKey="chenyun_place5_video" + showReadonlyProgress onEnded={() => { setVideoVisible3(false) setQuiz3Visible(true) diff --git a/src/pages/chenyun/place/place7.tsx b/src/pages/chenyun/place/place7.tsx index 78525ae..3e19261 100644 --- a/src/pages/chenyun/place/place7.tsx +++ b/src/pages/chenyun/place/place7.tsx @@ -286,6 +286,8 @@ const ChenyunPlace7 = () => { preload="auto" muted={!video1Playing} showSkipButton + skipKey="chenyun_place7_video1" + showReadonlyProgress onEnded={handleVideo1Ended} />
@@ -350,6 +352,8 @@ const ChenyunPlace7 = () => { preload="auto" muted={!video2Playing} showSkipButton + skipKey="chenyun_place7_video2" + showReadonlyProgress skipText="跳过" onEnded={handleVideo2Ended} /> diff --git a/src/pages/chenyun/place/place9.tsx b/src/pages/chenyun/place/place9.tsx index ac6b48e..f5d0f3c 100644 --- a/src/pages/chenyun/place/place9.tsx +++ b/src/pages/chenyun/place/place9.tsx @@ -122,6 +122,8 @@ const ChenyunPlace9 = () => { preload="auto" muted={!videoPlaying} showSkipButton + skipKey="chenyun_place9_video" + showReadonlyProgress skipText="跳过" onEnded={() => { setVideoPlaying(false) diff --git a/src/pages/chenyun/reflection/shared/VideoHandler.tsx b/src/pages/chenyun/reflection/shared/VideoHandler.tsx index 89f465a..6c33eb6 100644 --- a/src/pages/chenyun/reflection/shared/VideoHandler.tsx +++ b/src/pages/chenyun/reflection/shared/VideoHandler.tsx @@ -60,6 +60,8 @@ export const VideoHandler = ({ src={videoConfig.src} autoPlay showSkipButton + skipKey={`chenyun_reflection_${playingVideo}`} + showReadonlyProgress onEnded={onVideoEnd} className="w-full h-full" /> diff --git a/src/pages/huangyanpei/internalization.tsx b/src/pages/huangyanpei/internalization.tsx index c2f1f9e..cf40acd 100644 --- a/src/pages/huangyanpei/internalization.tsx +++ b/src/pages/huangyanpei/internalization.tsx @@ -152,7 +152,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ time: 369, // 6:09 title: '', prompt: - '【拖拽】当年黄炎培先生有推广国货的爱国情怀,如今我们的“国货”已经自强自立,请将其中的国产企业/品牌放置到空格处', + '【拖拽】当年黄炎培先生有推广国货的爱国情怀,如今我们的“国货”已经自强自立,请将以下属于中国的企业、品牌、装备放置到空格处:', type: 'drag', options: [ { value: '特斯拉', label: '特斯拉' }, @@ -395,7 +395,6 @@ const HuangyanpeiInternalization = () => { /> { setActiveSuccess(null) @@ -465,6 +464,9 @@ const HuangyanpeiInternalization = () => { anchors={anchors} playsInline controls + controlsOnReplay + skipKey="huangyanpei_internalization_video" + showReadonlyProgress onCanPlay={handleVideoCanPlay} onEnded={() => { setShowVideo(false) diff --git a/src/pages/huangyanpei/place/place4.tsx b/src/pages/huangyanpei/place/place4.tsx index b6d7fb6..112c57e 100644 --- a/src/pages/huangyanpei/place/place4.tsx +++ b/src/pages/huangyanpei/place/place4.tsx @@ -332,6 +332,8 @@ const HuangyanpeiPlace4 = () => { autoPlay playsInline showSkipButton + skipKey="huangyanpei_place4_video" + showReadonlyProgress onEnded={() => { setShowVideo(false) setShowQuiz(true) diff --git a/src/pages/huangyanpei/place/place5.tsx b/src/pages/huangyanpei/place/place5.tsx index 02986b3..1105e07 100644 --- a/src/pages/huangyanpei/place/place5.tsx +++ b/src/pages/huangyanpei/place/place5.tsx @@ -152,12 +152,13 @@ const HuangyanpeiPlace5 = () => { ref={videoRef} src={getPublicPath('video/huangyanpei/huangyanpei_07.mp4')} className="w-full h-full" - controls autoPlay playsInline preload="auto" showSkipButton skipText="跳过" + skipKey="huangyanpei_place5_video" + showReadonlyProgress onEnded={() => { // 对话结束后显示题目 setShowQuiz(true) diff --git a/src/pages/huangyanpei/reflection/components/game/BambooPuzzleGame.tsx b/src/pages/huangyanpei/reflection/components/game/BambooPuzzleGame.tsx index 312b2c8..d0e7725 100644 --- a/src/pages/huangyanpei/reflection/components/game/BambooPuzzleGame.tsx +++ b/src/pages/huangyanpei/reflection/components/game/BambooPuzzleGame.tsx @@ -262,7 +262,9 @@ const BambooPuzzleGame = ({ visible = true, onClose, onComplete }: BambooPuzzleG > 毛竹拼单杠 -

请将下方的竹子拖到正确位置,拼出单杠!

+

+ 请将下方的竹子拖到正确位置,拼出单杠! +

{/* 拼图区域 - 空白容器 */} diff --git a/src/pages/huangyanpei/reflection/scene2.tsx b/src/pages/huangyanpei/reflection/scene2.tsx index 2d80b2d..c2d9042 100644 --- a/src/pages/huangyanpei/reflection/scene2.tsx +++ b/src/pages/huangyanpei/reflection/scene2.tsx @@ -135,7 +135,7 @@ const HuangyanpeiReflectionScene2 = () => { }} > { }} > <Title - title="川沙小学堂" + title="中华职业学校" subtitle={subtitle} subtitleClassName={subtitleClassName} className="absolute top-10 left-1/2 -translate-x-1/2" @@ -240,6 +240,8 @@ const HuangyanpeiReflectionScene3 = () => { autoPlay playsInline showSkipButton + skipKey="huangyanpei_reflection_scene3_video" + showReadonlyProgress onEnded={() => { setShowVideo(false) saveHuangyanpeiStore() diff --git a/src/pages/huangyanpei/reflection/scene3/ViseAssemblyDialog.tsx b/src/pages/huangyanpei/reflection/scene3/ViseAssemblyDialog.tsx index bd08c9e..ec9730a 100644 --- a/src/pages/huangyanpei/reflection/scene3/ViseAssemblyDialog.tsx +++ b/src/pages/huangyanpei/reflection/scene3/ViseAssemblyDialog.tsx @@ -210,6 +210,8 @@ const ViseAssemblyDialog = ({ visible = true, onClose, onComplete }: ViseAssembl playsInline preload="auto" showSkipButton + skipKey="huangyanpei_reflection_vise_video" + showReadonlyProgress skipText="跳过" onEnded={handleVideoEnd} /> diff --git a/src/pages/huangyanpei/reflection/scene4.tsx b/src/pages/huangyanpei/reflection/scene4.tsx index 2eef4dc..ca63b26 100644 --- a/src/pages/huangyanpei/reflection/scene4.tsx +++ b/src/pages/huangyanpei/reflection/scene4.tsx @@ -73,7 +73,7 @@ const HuangyanpeiReflectionScene4 = () => { }} > <Title - title="川沙小学堂" + title="中华职业学校" subtitle={subtitle} subtitleClassName={subtitleClassName} className="absolute top-10 left-1/2 -translate-x-1/2" @@ -220,6 +220,8 @@ const HuangyanpeiReflectionScene4 = () => { className="h-full w-full" autoPlay showSkipButton + skipKey="huangyanpei_reflection_scene4_video" + showReadonlyProgress playsInline onEnded={() => { setShowVideo(false) diff --git a/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx b/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx index 02f6104..e409f78 100644 --- a/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx +++ b/src/pages/huangyanpei/reflection/scene4/TowelSalesDialog.tsx @@ -331,7 +331,7 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia {/* 成功提示 */} <SuccessAlert - title="恭喜你, 推销语设计成功!" + title="恭喜你,推销语设计成功!" content="既突出了国货优势,又传递了爱国情怀,当年的商科学生也是这么做的。" visible={successVisible} onContinue={handleSuccessContinue} diff --git a/src/pages/learning-map/index.tsx b/src/pages/learning-map/index.tsx index b60ab08..afc64e4 100644 --- a/src/pages/learning-map/index.tsx +++ b/src/pages/learning-map/index.tsx @@ -8,7 +8,6 @@ import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences' import { usePuzzleStore } from '@/store/puzzleStore' const SPOTLIGHT_PROMPT_STORAGE_PREFIX = 'learning-map:spotlight-clicked:' - interface HexagonProgressBarProps { progress: number } @@ -96,21 +95,32 @@ const LearningMap = () => { }, [completedResidence, unlockedMap]) useEffect(() => { - if (!completedResidence || !isLoaded || !completeMap[completedResidence]) { + if (!isLoaded) { + setSpotlightPromptResidence(null) + return + } + + let latestCompleted: string | null = null + for (const { residence } of PUZZLE_RESIDENCES) { + if (isResidenceComplete(residence, puzzles)) { + latestCompleted = residence + } + } + + if (!latestCompleted) { setSpotlightPromptResidence(null) return } - const isKnownResidence = PUZZLE_RESIDENCES.some((item) => item.residence === completedResidence) const isPromptClicked = - window.sessionStorage.getItem(`${SPOTLIGHT_PROMPT_STORAGE_PREFIX}${completedResidence}`) === + window.sessionStorage.getItem(`${SPOTLIGHT_PROMPT_STORAGE_PREFIX}${latestCompleted}`) === 'true' - setSpotlightPromptResidence(isKnownResidence && !isPromptClicked ? completedResidence : null) - }, [completeMap, completedResidence, isLoaded]) + setSpotlightPromptResidence(isPromptClicked ? null : latestCompleted) + }, [isLoaded, puzzles]) - const handleSpotlightClick = (residence: string, isUnlocked: boolean) => { - if (!isUnlocked) return + const handleSpotlightClick = (residence: string, isComplete: boolean) => { + if (!isComplete) return if (residence === spotlightPromptResidence) { window.sessionStorage.setItem(`${SPOTLIGHT_PROMPT_STORAGE_PREFIX}${residence}`, 'true') @@ -147,6 +157,7 @@ const LearningMap = () => { <div className="absolute inset-0"> {PUZZLE_RESIDENCES.map(({ residence, image, moduleName, left, top }) => { const isUnlocked = unlockedMap[residence] + const isComplete = completeMap[residence] const isNewlyUnlocked = residence === newlyUnlockedResidence const showSpotlightPrompt = residence === spotlightPromptResidence @@ -194,20 +205,60 @@ const LearningMap = () => { <div className="relative flex justify-center gap-6"> {showSpotlightPrompt && ( - <motion.img - className="absolute -right-10 -top-14 z-20 w-20 pointer-events-none" - src={getStaticImage('learning-map/箭头指引')} - alt="点击追光" - animate={{ y: [0, -8, 0], scale: [1, 1.08, 1] }} - transition={{ duration: 1, repeat: Infinity, ease: 'easeInOut' }} - /> + <div + className="absolute left-1/2 top-0 z-30 pointer-events-none" + style={{ + transform: 'translate(-40%, 30%)', + willChange: 'transform', + }} + > + <div className="relative flex items-center justify-center"> + <motion.span + className="absolute w-14 h-14 rounded-full bg-amber-400" + style={{ willChange: 'transform, opacity' }} + animate={{ scale: [0.55, 1.6], opacity: [0.5, 0] }} + transition={{ + duration: 1.4, + repeat: Infinity, + ease: 'easeOut', + }} + /> + <motion.svg + viewBox="0 0 32 32" + className="relative w-10 h-10" + style={{ + filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.45))', + willChange: 'transform', + }} + animate={{ scale: [1, 0.88, 1] }} + transition={{ + duration: 1.2, + repeat: Infinity, + ease: 'easeInOut', + }} + > + <path + d="M9 5 L9 23 L14 19 L17 28 L20 27 L17 18 L24 18 Z" + fill="white" + stroke="#0f172a" + strokeWidth="2" + strokeLinejoin="round" + /> + </motion.svg> + </div> + </div> )} <Image - className={isUnlocked ? undefined : 'cursor-not-allowed'} + className={isComplete ? undefined : 'cursor-not-allowed'} + style={ + isComplete + ? undefined + : { opacity: 0.4, filter: 'grayscale(1) brightness(0.8)' } + } src={getStaticImage('learning-map/追光入口')} alt="追光" - button={isUnlocked} - onClick={() => handleSpotlightClick(residence, !!isUnlocked)} + button={isComplete} + onClick={() => handleSpotlightClick(residence, !!isComplete)} /> </div> </motion.div> diff --git a/src/pages/learning-map/spotlight.tsx b/src/pages/learning-map/spotlight.tsx index 8d47cf2..9183a42 100644 --- a/src/pages/learning-map/spotlight.tsx +++ b/src/pages/learning-map/spotlight.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { Navigate, useNavigate, useParams } from 'react-router-dom' import { Image } from '@/components' -import { isResidenceUnlocked } from '@/config/progression' +import { isResidenceComplete } from '@/config/progression' import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences' import { getStaticImage } from '@/lib/utils' import { usePuzzleStore } from '@/store/puzzleStore' @@ -23,7 +23,7 @@ const SpotlightMap = () => { return <div className="relative w-full h-full bg-black" /> } - if (!isResidenceUnlocked(residenceConfig.residence, puzzles)) { + if (!isResidenceComplete(residenceConfig.residence, puzzles)) { return <Navigate to="/learning-map" replace /> } diff --git a/src/pages/luxun/internalization.tsx b/src/pages/luxun/internalization.tsx index 9ed76f7..d5771f8 100644 --- a/src/pages/luxun/internalization.tsx +++ b/src/pages/luxun/internalization.tsx @@ -100,7 +100,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ id: 'quiz-3', time: 60 * 6 + 39, // 6:39 title: '', - prompt: '【单选】以下那句诗出自鲁迅的作品:', + prompt: '【单选】以下哪句诗出自鲁迅的作品:', type: 'single', options: [ { @@ -460,6 +460,9 @@ const LuxunInternalization = () => { anchors={anchors} playsInline controls + controlsOnReplay + skipKey="luxun_internalization_video" + showReadonlyProgress onCanPlay={handleVideoCanPlay} onEnded={() => { setShowVideo(false) diff --git a/src/pages/luxun/place/place3.tsx b/src/pages/luxun/place/place3.tsx index 54c9cb6..7eeb7cd 100644 --- a/src/pages/luxun/place/place3.tsx +++ b/src/pages/luxun/place/place3.tsx @@ -146,7 +146,7 @@ const LuxunPlace3 = () => { }} > <Title - title={`历史新编中的现实呐喊\n为亡友编纂的精神丰碑`} + title={`历史新编中的现实呐喊 为亡友编纂的精神丰碑`} subtitle={subtitle} subtitleClassName={subtitleClassName} className="absolute top-10 left-1/2 -translate-x-1/2" diff --git a/src/pages/luxun/place/place4.tsx b/src/pages/luxun/place/place4.tsx index 3cab7e8..89aa36d 100644 --- a/src/pages/luxun/place/place4.tsx +++ b/src/pages/luxun/place/place4.tsx @@ -363,6 +363,8 @@ const LuxunPlace5 = () => { autoPlay playsInline showSkipButton + skipKey="luxun_place4_video" + showReadonlyProgress anchors={videoAnchors} onEnded={() => { setEndingGuideVisible(true) diff --git a/src/pages/luxun/reflection/scene2.tsx b/src/pages/luxun/reflection/scene2.tsx index 3489ccc..b4d916b 100644 --- a/src/pages/luxun/reflection/scene2.tsx +++ b/src/pages/luxun/reflection/scene2.tsx @@ -16,64 +16,64 @@ const dragItems = [ name: '闰土', imagePath: 'luxun/reflection/scene2/拖拽-润土', targetBlankId: 'city-1-label-0', // 绍兴-人物 - errorHint: '❌闰土的故事和鲁迅的少年绍兴时光有关哦!', - successMessage: '✅答对啦! 闰土是鲁迅绍兴时期笔下的经典人物。', + errorHint: '❌闰土的故事和鲁迅的少年绍兴时光有关哦!', + successMessage: '✅答对啦!闰土是鲁迅绍兴时期笔下的经典人物。', }, { id: 'baicaoyuan', name: '《从百草园到三味书屋》', imagePath: 'luxun/reflection/scene2/拖拽-百草园到三味书屋', targetBlankId: 'city-1-label-1', // 绍兴-作品 - errorHint: '❌这个作品写的是鲁迅在绍兴的故事哦!', - successMessage: '✅真棒! 这篇散文记录了鲁迅在绍兴的求学时光。', + errorHint: '❌这个作品写的是鲁迅在绍兴的故事哦!', + successMessage: '✅真棒!这篇散文记录了鲁迅在绍兴的求学时光。', }, { id: 'aqzhengzhuan', name: '《阿Q正传》', imagePath: 'luxun/reflection/scene2/拖拽-阿Q正传', targetBlankId: 'city-2-label-0', // 北京-作品 - errorHint: '❌这部经典小说和鲁迅在北京的创作有关哦!', - successMessage: '✅答对啦! 《阿Q正传》是鲁迅小说的代表作,诞生于北京。', + errorHint: '❌这部经典小说和鲁迅在北京的创作有关哦!', + successMessage: '✅答对啦!《阿Q正传》是鲁迅小说的代表作,诞生于北京。', }, { id: 'jingshenshengli', name: '“精神胜利法”', imagePath: 'luxun/reflection/scene2/拖拽-精神胜利法', targetBlankId: 'city-2-label-1', // 北京-核心主题 - errorHint: '❌这个主题是《阿Q正传》的核心,和北京创作背景相关哦!', - successMessage: '✅没错! 《阿Q正传》画出了国人的灵魂,以便疗救。', + errorHint: '❌这个主题是《阿Q正传》的核心,和北京创作背景相关哦!', + successMessage: '✅没错!《阿Q正传》画出了国人的灵魂,以便疗救。', }, { id: 'guanzhudiceng', name: '“关注底层”', imagePath: 'luxun/reflection/scene2/关注底层', targetBlankId: 'city-3-label-0', // 厦门-创作方向 - errorHint: '❌这个创作方向是鲁迅在厦门的核心追求哦!', - successMessage: '✅答对啦!鲁迅在厦门任教时,依旧关注底层民众的命运。', + errorHint: '❌这个创作方向是鲁迅在厦门的核心追求哦!', + successMessage: '✅答对啦!鲁迅在厦门任教时,依旧关注底层民众的命运。', }, { id: 'zhaohuaxishi', name: '《朝花夕拾》', imagePath: 'luxun/reflection/scene2/拖拽-朝花夕拾', targetBlankId: 'city-4-label-0', // 广州-作品集 - errorHint: '❌这个作品集和鲁迅在广州的旅居时光有关哦!', - successMessage: '✅真棒!《朝花夕拾》部分篇章在广州白云楼定稿。', + errorHint: '❌这个作品集和鲁迅在广州的旅居时光有关哦!', + successMessage: '✅真棒!《朝花夕拾》部分篇章在广州白云楼定稿。', }, { id: 'gushixinbian', name: '《故事新编》', imagePath: 'luxun/reflection/scene2/拖拽-故事新编', targetBlankId: 'city-5-label-0', // 上海-作品 - errorHint: '❌再试试~这个作品和上海的创作故事更相关哦!', - successMessage: '✅答对啦!《故事新编》是鲁迅在上海故居完成的经典作品集。', + errorHint: '❌再试试~这个作品和上海的创作故事更相关哦!', + successMessage: '✅答对啦!《故事新编》是鲁迅在上海故居完成的经典作品集。', }, { id: 'biweidaoqiang', name: '“笔为刀枪”', imagePath: 'luxun/reflection/scene2/笔为刀枪', targetBlankId: 'city-5-label-1', // 上海-创作中心 - errorHint: '❌这个初心是鲁迅在上海创作的核心哦!', - successMessage: '✅没错!鲁迅在上海以笔为刃,批判黑暗的社会现实。', + errorHint: '❌这个初心是鲁迅在上海创作的核心哦!', + successMessage: '✅没错!鲁迅在上海以笔为刃,批判黑暗的社会现实。', }, ] @@ -167,6 +167,8 @@ const LuxunReflectionScene2 = () => { const [completionGuideVisible, setCompletionGuideVisible] = useState(false) // 通关提示是否已经显示过(防止重复显示) const [completionGuideShown, setCompletionGuideShown] = useState(false) + // 完成弹窗是否显示 + const [showCompletionModal, setShowCompletionModal] = useState(false) // 转换为 DragFill 的 options const options = useMemo( @@ -280,14 +282,20 @@ const LuxunReflectionScene2 = () => { setSuccessMessage('') } - // 当所有任务完成且成功提示关闭后,显示通关提示(只显示一次) + // 当所有任务完成且成功提示关闭后,显示完成弹窗(只显示一次) useEffect(() => { if (allCompleted && !showSuccessAlert && !completionGuideShown) { - setCompletionGuideVisible(true) + setShowCompletionModal(true) setCompletionGuideShown(true) } }, [allCompleted, showSuccessAlert, completionGuideShown]) + // 关闭完成弹窗后显示通关提示 + const handleCompletionModalClose = () => { + setShowCompletionModal(false) + setCompletionGuideVisible(true) + } + // 获取每个标签区域已放置的元素 const getLabelPlacedItem = (cityId: number, labelIndex: number) => { const blankId = `city-${cityId}-label-${labelIndex}` @@ -401,7 +409,7 @@ const LuxunReflectionScene2 = () => { > {/* 任务完成后显示点一点图标 */} {allCompleted && ( - <div className="absolute -right-20 top-1/2 flex items-center gap-2 z-10 pointer-events-none"> + <div className="absolute -right-20 top-[calc(50%+26px)] flex items-center gap-2 z-10 pointer-events-none"> <img className="animate-finger-bounce scale-x-[-1]" src={getStaticImage(`${starName}/reflection/scene1/鲁迅体悟点一点图标`)} @@ -415,32 +423,20 @@ const LuxunReflectionScene2 = () => { {/* 右侧区域 */} <div className="flex-1 relative"> {/* 任务进度 */} - <div className="absolute top-4 left-[27%] w-[430px] h-51 flex justify-center items-center"> + <div className="absolute top-6 left-[24.5%] w-[430px] h-51 flex justify-center items-center"> <span className="text-white text-[54px] font-bold [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]"> {allCompleted ? '任务进度已完成' : `任务进度:(${progress}/${totalTasks})`} </span> </div> - {/* 鲁迅画廊 */} - <div className="absolute top-[calc(2.4rem+204px)] left-0 w-[995px] h-[467px] pt-16 pl-70 pr-10"> - {/* 提示图标 */} - <div className="absolute left-14 top-35 space-y-2 z-10 pointer-events-none"> - <div className="flex items-center"> - <img - src={getStaticImage(`${starName}/reflection/scene2/鲁迅体悟拖一拖`)} - alt="拖一拖" - /> - <img - src={getStaticImage(`${starName}/reflection/scene2/鲁迅体悟拖一拖图标`)} - alt="拖一拖图标" - /> - </div> - <div className="w-45 leading-relaxed tracking-wider text-black text-3xl font-bold"> - 请将右侧图标拖到左侧对应位置。 - </div> + {/* 拖拽区域 */} + <div className="absolute top-[calc(2.4rem+214px)] h-206 left-0 w-full px-16"> + {/* 提示文字 */} + <div className="my-16 text-yellow-600 text-5xl font-bold text-center"> + 请将下面图标拖到左侧对应的位置。 </div> {/* 使用 DragFill 组件 */} - <div className="mt-10"> + <div className="w-full max-w-[1000px] mx-auto"> <DragFill options={options} blanks={blanks} @@ -451,7 +447,7 @@ const LuxunReflectionScene2 = () => { layout="horizontal-options" hideLabels={true} className="w-full" - optionClassName="grid grid-cols-4 gap-10" + optionClassName="grid grid-cols-4 gap-x-14 gap-y-14" blankClassName="hidden" // 隐藏默认的空白区域,我们在地图上自定义显示 renderOption={(option, isUsed) => { const item = dragItems.find((item) => item.id === option.value) @@ -459,16 +455,19 @@ const LuxunReflectionScene2 = () => { return ( <div - className={`relative transition-all duration-200 cursor-move ${ + className={`relative transition-all duration-200 cursor-move flex flex-col items-center gap-4 ${ isUsed ? 'opacity-30' : 'hover:scale-105' }`} > <img src={getStaticImage(item.imagePath)} alt={item.name} - className="w-full h-auto" + className="w-[150px] h-[150px] object-contain" draggable={false} /> + <div className="text-black text-3xl font-bold text-center leading-tight px-2 min-w-[160px] max-w-[200px]"> + {item.name} + </div> </div> ) }} @@ -494,43 +493,6 @@ const LuxunReflectionScene2 = () => { /> </div> </div> - {/* 任务展示窗口 */} - <div className="absolute bottom-3.5 right-8 w-[995px] h-[335px] px-10 py-4 overflow-y-auto luxun-scrollbar"> - {allCompleted ? ( - // 全部完成后显示详细信息 - <div className="space-y-8"> - {cityConfig.map((city) => ( - <div key={city.id} className="space-y-4"> - <div className="text-red-600 text-5xl font-bold flex items-end gap-6"> - <div>{city.location}</div> - <div className="text-gray-600 text-4xl font-normal">{city.time}</div> - </div> - <div className="text-black text-4xl font-normal leading-relaxed"> - {city.description} - </div> - </div> - ))} - </div> - ) : ( - // 未完成时显示提示内容 - <div className="space-y-8 text-center"> - <div className="text-black text-5xl font-bold mb-10">任务展示窗口</div> - {/* <div className="text-red-600 text-4xl font-bold"> - 请将以下内容拖拽至正确位置 - </div> */} - <div className="grid grid-cols-4 gap-4 text-3xl font-bold"> - {dragItems.map((item) => { - const isPlaced = dragFillValue.some((result) => result.optionValue === item.id) - return ( - <div key={item.id} className={`${isPlaced ? 'text-green-600' : 'text-black'}`}> - {item.name} - </div> - ) - })} - </div> - </div> - )} - </div> </div> {/* 错误提示弹窗 */} @@ -548,6 +510,84 @@ const LuxunReflectionScene2 = () => { onClose={handleSuccessAlertClose} /> + {/* 完成弹窗 */} + {showCompletionModal && ( + <div className="fixed inset-0 z-[10001] flex items-center justify-center bg-black/60"> + <div + className="relative w-[1200px] p-12 bg-cover bg-center bg-no-repeat rounded-2xl shadow-2xl" + style={{ + backgroundImage: `linear-gradient(to bottom, rgba(255, 248, 220, 0.95), rgba(255, 248, 220, 0.98)), url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcz48cGF0dGVybiBpZD0icGFwZXIiIHBhdHRlcm5Vbml0cz0idXNlclNwYWNlT25Vc2UiIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2ZmZjhkYyIvPjwvcGF0dGVybj48L2RlZnM+PHJlY3Qgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiIGZpbGw9InVybCgjcGFwZXIpIi8+PC9zdmc+)`, + border: '4px solid #8B4513', + boxShadow: '0 0 30px rgba(139, 69, 19, 0.4), inset 0 0 50px rgba(218, 165, 32, 0.2)', + }} + > + {/* 装饰性边框 */} + <div className="absolute inset-4 border-2 border-yellow-700/40 rounded-xl pointer-events-none" /> + + {/* 关闭按钮 */} + <button + onClick={handleCompletionModalClose} + className="absolute top-6 right-6 w-12 h-12 flex items-center justify-center text-red-700 hover:text-red-900 text-5xl font-bold bg-yellow-100/80 rounded-full hover:bg-yellow-200/80 transition-colors z-10" + style={{ lineHeight: '1' }} + > + × + </button> + + {/* 标题 */} + <div className="text-center mb-8"> + <div + className="text-6xl font-bold mb-2" + style={{ + background: 'linear-gradient(to bottom, #8B0000, #DC143C)', + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + textShadow: '2px 2px 4px rgba(139, 0, 0, 0.3)', + }} + > + 恭喜完成任务! + </div> + <div className="text-3xl text-yellow-800 font-semibold mt-6"> + 鲁迅的文学足迹已然清晰 + </div> + </div> + + {/* 两列布局 */} + <div className="grid grid-cols-2 gap-8 px-4"> + {cityConfig.map((city, index) => ( + <div + key={city.id} + className="bg-gradient-to-r from-yellow-50/80 to-amber-50/80 p-6 rounded-xl border-2 border-yellow-600/30 shadow-md hover:shadow-lg transition-shadow" + style={{ + animation: `fadeIn 0.5s ease-out ${index * 0.1}s both`, + }} + > + <div className="flex items-end gap-4 mb-3"> + <div className="text-red-700 text-4xl font-bold">{city.location}</div> + <div className="text-amber-700 text-2xl font-medium pb-0.5">{city.time}</div> + </div> + <div className="text-gray-800 text-2xl leading-relaxed font-normal pl-2 border-l-4 border-red-600/40"> + {city.description} + </div> + </div> + ))} + </div> + </div> + + <style>{` + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + `}</style> + </div> + )} + {/* 学习引导对话 */} <GuideSteps visible={guideVisible} diff --git a/src/pages/luxun/reflection/scene3.tsx b/src/pages/luxun/reflection/scene3.tsx index f45b1cb..c582f94 100644 --- a/src/pages/luxun/reflection/scene3.tsx +++ b/src/pages/luxun/reflection/scene3.tsx @@ -1,5 +1,5 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Image, GuideSteps, GuideStepsItem, StudyGuideModal, EndingOverlay } from '@/components' import { SingleChoiceQuizPanel } from '@/components/layout/quiz' @@ -16,6 +16,8 @@ type HintPosition = 'luxun1' | 'guanzhu' | 'piping' | 'jiaguo' | 'luxun2' | 'non const LuxunReflectionScene3 = () => { //碎片动画触发方法 const { triggerPuzzleAnimation } = usePuzzleAnimation() + const triggerPuzzleAnimationRef = useRef(triggerPuzzleAnimation) + triggerPuzzleAnimationRef.current = triggerPuzzleAnimation const navigate = useNavigate() const { moduleName } = useParams<{ moduleName: string @@ -32,6 +34,7 @@ const LuxunReflectionScene3 = () => { const [completedQuiz3, setCompletedQuiz3] = useState(false) // 题目3是否完成 const [showSummary, setShowSummary] = useState(false) // 是否显示总结 const [completionGuideVisible, setCompletionGuideVisible] = useState(false) // 通关提示对话是否显示 + const [completionGuideQueued, setCompletionGuideQueued] = useState(false) // 通关提示是否已排队显示 const [showGuideModal, setShowGuideModal] = useState(true) // 成功提示相关状态 @@ -47,14 +50,14 @@ const LuxunReflectionScene3 = () => { // 当显示总结后,延迟1秒显示通关提示对话 useEffect(() => { - if (showSummary) { + if (completionGuideQueued) { const timer = setTimeout(() => { setCompletionGuideVisible(true) - triggerPuzzleAnimation(15) + triggerPuzzleAnimationRef.current(15) }, 500) return () => clearTimeout(timer) } - }, [showSummary, triggerPuzzleAnimation]) + }, [completionGuideQueued]) // 点击"鲁迅1"的处理 const handleLuxun1Click = () => { @@ -121,8 +124,9 @@ const LuxunReflectionScene3 = () => { // 点击"鲁迅2"的处理 const handleLuxun2Click = () => { - if (allCompleted) { + if (allCompleted && !showSummary) { setShowSummary(true) + setCompletionGuideQueued(true) setHintPosition('none') // 隐藏提示图标 } } @@ -370,7 +374,7 @@ const LuxunReflectionScene3 = () => { { value: 'B', label: 'B. 生活中的小美好' }, ]} correctAnswer="A" - errorMessage="不是哦,先生的小说核心是批判而非歌颂哦!请结合阿Q的碎片回忆一下吧!" + errorMessage="不是哦,先生的小说核心是批判而非歌颂哦!请结合阿Q的碎片回忆一下吧!" onSubmit={handleQuiz2Submit} /> @@ -383,7 +387,7 @@ const LuxunReflectionScene3 = () => { { value: 'B', label: 'B. 只为个人成名' }, ]} correctAnswer="A" - errorMessage="不是哦,先生的创作从不是为了自己哦!请回忆先生“弃医从文”的初心。" + errorMessage="不是哦,先生的创作从不是为了自己哦!请回忆先生“弃医从文”的初心。" onSubmit={handleQuiz3Submit} /> diff --git a/src/pages/maozedong/internalization.tsx b/src/pages/maozedong/internalization.tsx index 4cf7380..21af67f 100644 --- a/src/pages/maozedong/internalization.tsx +++ b/src/pages/maozedong/internalization.tsx @@ -403,6 +403,8 @@ const MaozedongInternalization = () => { className="h-full w-full z-20" playsInline showSkipButton + skipKey="maozedong_internalization_intro" + showReadonlyProgress autoPlay onEnded={() => { setShowIntroVideo(false) @@ -482,6 +484,9 @@ const MaozedongInternalization = () => { anchors={anchors} playsInline controls + controlsOnReplay + skipKey="maozedong_internalization_main" + showReadonlyProgress autoPlay onCanPlay={handleVideoCanPlay} onEnded={() => { diff --git a/src/pages/maozedong/place/place1.tsx b/src/pages/maozedong/place/place1.tsx index aef64ce..76f04f7 100644 --- a/src/pages/maozedong/place/place1.tsx +++ b/src/pages/maozedong/place/place1.tsx @@ -217,6 +217,8 @@ const MaozedongPlace1 = () => { autoPlay playsInline showSkipButton + skipKey="maozedong_place1_video" + showReadonlyProgress onEnded={() => { setShowVideo2(false) setQuizVisible(true) diff --git a/src/pages/maozedong/place/place2.tsx b/src/pages/maozedong/place/place2.tsx index 14747a2..854d5ab 100644 --- a/src/pages/maozedong/place/place2.tsx +++ b/src/pages/maozedong/place/place2.tsx @@ -213,6 +213,8 @@ const MaozedongPlace2 = () => { autoPlay playsInline showSkipButton + skipKey="maozedong_place2_video" + showReadonlyProgress onEnded={() => { setShowVideo(false) setQuizVisible(true) diff --git a/src/pages/maozedong/place/place3.tsx b/src/pages/maozedong/place/place3.tsx index a363453..ca069a1 100644 --- a/src/pages/maozedong/place/place3.tsx +++ b/src/pages/maozedong/place/place3.tsx @@ -245,6 +245,8 @@ const MaozedongPlace3 = () => { autoPlay playsInline showSkipButton + skipKey="maozedong_place3_video" + showReadonlyProgress onEnded={() => { setShowVideo3(false) setQuiz3Visible(true) diff --git a/src/pages/maozedong/place/place4.tsx b/src/pages/maozedong/place/place4.tsx index 289397f..fac43c6 100644 --- a/src/pages/maozedong/place/place4.tsx +++ b/src/pages/maozedong/place/place4.tsx @@ -305,13 +305,13 @@ const MaozedongPlace4 = () => { speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_18_01.mp3')} > - 对!无产阶级要领导革命,必须联合农民——他们是工人阶级的“天然盟友”。四大虽然还没有明确提出“农村包围城市”,但已经意识到农民问题的重要性。 + 对!无产阶级要领导革命,必须联合农民——他们是工人阶级的“天然盟友”。中共四大虽然还没有明确提出“农村包围城市”,但已经意识到农民问题的重要性。 </GuideStepsItem> <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_18_02.mp3')} > - 后来毛主席常说:“国民革命的中心问题,就是农民问题,一切都要靠农民问题的解决。”这句话的种子,就是在四大前后埋下的。 + 后来毛主席常说:“国民革命的中心问题,就是农民问题,一切都要靠农民问题的解决。”这句话的种子,就是在中共四大前后埋下的。 </GuideStepsItem> </GuideSteps> @@ -434,6 +434,8 @@ const MaozedongPlace4 = () => { autoPlay playsInline showSkipButton + skipKey="maozedong_place4_video1" + showReadonlyProgress onSkip={() => { setShowVideo4(false) setGuide4SummaryStep(0) @@ -456,6 +458,8 @@ const MaozedongPlace4 = () => { autoPlay playsInline showSkipButton + skipKey="maozedong_place4_video2" + showReadonlyProgress onEnded={() => { setShowVideo2(false) setQuizStep(1) diff --git a/src/pages/maozedong/reflection.tsx b/src/pages/maozedong/reflection.tsx index 89a1ef9..c9e719e 100644 --- a/src/pages/maozedong/reflection.tsx +++ b/src/pages/maozedong/reflection.tsx @@ -44,7 +44,7 @@ const firstQuizOptions = [ }, { value: 'B', - label: 'B. 深入基层,收集各地党组织、工农运动实情,再推进筹备等备', + label: 'B. 深入基层,收集各地党组织、工农运动实情,再推进筹备', }, ] @@ -55,7 +55,7 @@ const secondQuizOptions = [ }, { value: 'B', - label: 'B. 讲清意见对四大讨论无产阶级领导权、工农联盟的重要性,以革命大义动员党员', + label: 'B. 讲清意见对四大讨论无产阶级领导权、工农联盟的重要性,以革命大义动员', }, ] @@ -487,7 +487,7 @@ const Home = () => { visible={endingVisible} audioSrc={getPublicPath('audio/maozedong/mp3_mzd_23.mp3')} animationSpeed={200} - text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因身体健康原因未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。" + text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因身体健康问题未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。" onContinue={() => { setEndingVisible(false) saveMaozedongStore() diff --git a/src/pages/nieer/internalization.tsx b/src/pages/nieer/internalization.tsx index 089fed1..e7c376e 100644 --- a/src/pages/nieer/internalization.tsx +++ b/src/pages/nieer/internalization.tsx @@ -199,7 +199,7 @@ const NieerInternalization = () => { /> <SuccessAlert visible={activeSuccess === item.id} - title={item.id === 'quiz-4' ? '恭喜你, 判断正确!' : '恭喜你, 选择正确!'} + title={item.id === 'quiz-4' ? '恭喜你,判断正确!' : '恭喜你,选择正确!'} content={successContent} onContinue={() => { setActiveSuccess(null) @@ -326,6 +326,9 @@ const NieerInternalization = () => { anchors={anchors} playsInline controls + controlsOnReplay + skipKey="nieer_internalization_video" + showReadonlyProgress onCanPlay={handleVideoCanPlay} onEnded={() => { setShowVideo(false) diff --git a/src/pages/nieer/place/place1.tsx b/src/pages/nieer/place/place1.tsx index 484aadd..471fad5 100644 --- a/src/pages/nieer/place/place1.tsx +++ b/src/pages/nieer/place/place1.tsx @@ -216,13 +216,13 @@ const NieerPlace1 = () => { 这里提到聂耳发表了《中国歌舞短论》,批评脱离民众的音乐,这是为什么呢? </GuideStepsItem> <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_06_1.mp3')}> - 你观察得很细致!这时的聂耳在明月歌舞剧社做练习生,社长李锦晖看中了他的潜力,虽然他当时小提琴水平不算专业,还是录取了他,还安排首席小提琴手专门辅导他。聂耳特别刻苦,每天至少练7个小时小提琴,很快就成长为首席小提琴手。 + 你观察得很细致!这时的聂耳在明月歌舞剧社做练习生,社长李锦晖看中了他的潜力,虽然他当时小提琴水平不算专业,但还是录取了他,还安排首席小提琴手专门辅导他。聂耳特别刻苦,每天至少练7个小时小提琴,很快就成长为首席小提琴手。 </GuideStepsItem> <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_06_2.mp3')}> 但好景不长,1931年九·一八事变后,全国抗日救亡浪潮高涨,可还存在很多脱离现实的靡靡之音,这让满怀爱国热忱的聂耳特别愤懑。 </GuideStepsItem> <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_06_3.mp3')}> - 1932年,他以“黑天使”为笔名在《电影艺术》杂志刊登《中国歌舞短论》,猛烈评击“软豆腐”式的音乐,提出我们需要“真刀真枪的硬功夫”,主张音乐要贴近民众、为抗日救亡服务。后来他离开了歌舞剧社。 + 1932年,他以“黑天使”为笔名在《电影艺术》杂志刊登《中国歌舞短论》,猛烈抨击“软豆腐”式的音乐,提出我们需要“真刀真枪的硬功夫”,主张音乐要贴近民众、为抗日救亡服务。后来他离开了歌舞剧社。 </GuideStepsItem> </GuideSteps> @@ -315,6 +315,8 @@ const NieerPlace1 = () => { autoPlay playsInline showSkipButton + skipKey="nieer_place1_video" + showReadonlyProgress onEnded={() => { if (video2Step === 1) { setVideo2Step(2) @@ -350,7 +352,7 @@ const NieerPlace1 = () => { }, ]} correctAnswer="B" - errorMessage="“软豆腐”式的音乐是聂耳猛烈评击的音乐类型。请你再想想吧!" + errorMessage="“软豆腐”式的音乐是聂耳猛烈抨击的音乐类型。请你再想想吧!" onSubmit={(_value, isCorrect) => { if (isCorrect) { setQuiz1Visible(false) diff --git a/src/pages/nieer/place/place2.tsx b/src/pages/nieer/place/place2.tsx index 9fed7a2..bc729be 100644 --- a/src/pages/nieer/place/place2.tsx +++ b/src/pages/nieer/place/place2.tsx @@ -194,6 +194,8 @@ const NieerPlace2 = () => { autoPlay playsInline showSkipButton + skipKey="nieer_place2_video" + showReadonlyProgress onEnded={() => { markExhibitVisited(place, 2) setShowVideo2(false) diff --git a/src/pages/nieer/place/place3.tsx b/src/pages/nieer/place/place3.tsx index 1d0110a..18d7d87 100644 --- a/src/pages/nieer/place/place3.tsx +++ b/src/pages/nieer/place/place3.tsx @@ -245,6 +245,8 @@ const NieerPlace3 = () => { autoPlay playsInline showSkipButton + skipKey="nieer_place3_video" + showReadonlyProgress onEnded={() => { const id = videoForExhibitId setShowVideo(false) diff --git a/src/pages/nieer/place/place5.tsx b/src/pages/nieer/place/place5.tsx index c4c33dd..6731afd 100644 --- a/src/pages/nieer/place/place5.tsx +++ b/src/pages/nieer/place/place5.tsx @@ -403,7 +403,7 @@ const NieerPlace5 = () => { <SuccessAlert visible={success3_1Visible} - title="恭喜你, 匹配正确!" + title="恭喜你,匹配正确!" onClose={() => { setSuccess3_1Visible(false) setQuiz3_2Visible(true) diff --git a/src/pages/nieer/reflection/ContentDialog.tsx b/src/pages/nieer/reflection/ContentDialog.tsx index 68f4f83..46f6c6c 100644 --- a/src/pages/nieer/reflection/ContentDialog.tsx +++ b/src/pages/nieer/reflection/ContentDialog.tsx @@ -200,7 +200,10 @@ const ContentDialog = ({ starName, title, scene, onClose, onEnterScene }: Conten src={getPublicPath(currentPage.src)} autoPlay playsInline - controls + showSkipButton + skipText="跳过" + skipKey={`nieer_reflection_scene${scene.id}_video`} + showReadonlyProgress className="w-full h-full" onLoad={() => setContentLoaded(true)} onEnded={handleVideoEnded} diff --git a/src/pages/rongdesheng/internalization.tsx b/src/pages/rongdesheng/internalization.tsx index 739998a..1cc2fbc 100644 --- a/src/pages/rongdesheng/internalization.tsx +++ b/src/pages/rongdesheng/internalization.tsx @@ -453,6 +453,9 @@ const RongdeshengInternalization = () => { anchors={anchors} playsInline controls + controlsOnReplay + skipKey="rongdesheng_internalization_video" + showReadonlyProgress onCanPlay={handleVideoCanPlay} onEnded={() => { setShowVideo(false) diff --git a/src/pages/rongdesheng/place/place2.tsx b/src/pages/rongdesheng/place/place2.tsx index 3c5eed4..888693f 100644 --- a/src/pages/rongdesheng/place/place2.tsx +++ b/src/pages/rongdesheng/place/place2.tsx @@ -211,6 +211,8 @@ const RongdeshengPlace2 = () => { autoPlay playsInline showSkipButton + skipKey="rongdesheng_place2_video1" + showReadonlyProgress onEnded={handleVideoEnded} onSkip={handleVideoEnded} /> @@ -222,6 +224,8 @@ const RongdeshengPlace2 = () => { autoPlay playsInline showSkipButton + skipKey="rongdesheng_place2_video2" + showReadonlyProgress onEnded={handleVideoEnded} onSkip={handleVideoEnded} /> @@ -233,6 +237,8 @@ const RongdeshengPlace2 = () => { autoPlay playsInline showSkipButton + skipKey="rongdesheng_place2_video3" + showReadonlyProgress onEnded={handleVideoEnded} onSkip={handleVideoEnded} /> @@ -244,6 +250,8 @@ const RongdeshengPlace2 = () => { autoPlay playsInline showSkipButton + skipKey="rongdesheng_place2_video4" + showReadonlyProgress onEnded={handleVideoEnded} onSkip={handleVideoEnded} /> diff --git a/src/pages/rongdesheng/place/place3.tsx b/src/pages/rongdesheng/place/place3.tsx index deb3f79..9acd48f 100644 --- a/src/pages/rongdesheng/place/place3.tsx +++ b/src/pages/rongdesheng/place/place3.tsx @@ -186,6 +186,8 @@ const RongdeshengPlace3 = () => { autoPlay playsInline showSkipButton + skipKey="rongdesheng_place3_video" + showReadonlyProgress onEnded={handleVideoEnded} onSkip={handleVideoEnded} /> diff --git a/src/pages/rongdesheng/place/place4.tsx b/src/pages/rongdesheng/place/place4.tsx index 74cfb28..fbe8893 100644 --- a/src/pages/rongdesheng/place/place4.tsx +++ b/src/pages/rongdesheng/place/place4.tsx @@ -206,6 +206,8 @@ const RongdeshengPlace4 = () => { autoPlay playsInline showSkipButton + skipKey="rongdesheng_place4_video1" + showReadonlyProgress onEnded={handleVideoEnded} onSkip={handleVideoEnded} /> @@ -218,6 +220,8 @@ const RongdeshengPlace4 = () => { autoPlay playsInline showSkipButton + skipKey="rongdesheng_place4_video2" + showReadonlyProgress onEnded={handleEndingVideoEnded} onSkip={handleEndingVideoEnded} /> diff --git a/src/pages/rongdesheng/reflection/index.tsx b/src/pages/rongdesheng/reflection/index.tsx index aaa0019..ac8faae 100644 --- a/src/pages/rongdesheng/reflection/index.tsx +++ b/src/pages/rongdesheng/reflection/index.tsx @@ -2,7 +2,7 @@ import { Image, StudyGuideModal, Video } from '@/components' import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils' -import { useRongdeshengStore } from '@/store/rongdeshengStore' +import { saveRongdeshengStore, useRongdeshengStore } from '@/store/rongdeshengStore' import { FinalScene } from './components/FinalScene' import { HintPanel } from './components/HintPanel' import { ModuleNavigation } from './components/ModuleNavigation' @@ -214,6 +214,7 @@ const RongdeshengReflection = () => { }, })) markReflectionVideoViewed(currentModuleId, videoId) + saveRongdeshengStore() setVideoPlayingState({ isPlaying: false, currentVideoId: null, currentVideoSrc: null }) } @@ -225,6 +226,7 @@ const RongdeshengReflection = () => { [currentModuleId]: { ...prev[currentModuleId], completed: true }, })) markReflectionCompleted(currentModuleId) + saveRongdeshengStore() if (currentModuleId === 'scene3') { triggerPuzzleAnimation(14) } @@ -322,6 +324,12 @@ const RongdeshengReflection = () => { src={videoPlayingState.currentVideoSrc} autoPlay showSkipButton + skipKey={ + videoPlayingState.currentVideoId + ? `rongdesheng_reflection_${videoPlayingState.currentVideoId}` + : undefined + } + showReadonlyProgress onEnded={handleVideoEnded} className="fixed inset-0 z-50 w-full h-full bg-black" /> @@ -378,8 +386,11 @@ const RongdeshengReflection = () => { setCourseTimeProgress(20) setFinalVideoStage('ended') markReflectionFinalCompleted() + saveRongdeshengStore() + }} + onExit={() => { + navigate(`/${moduleName}/${starName}/home`) }} - onExit={() => navigate(`/${moduleName}/${starName}/home`)} /> {/* 学习引导弹窗:首次进入时显示 */} @@ -388,6 +399,7 @@ const RongdeshengReflection = () => { onContinue={() => { setShowGuide(false) markReflectionGuideShown() + saveRongdeshengStore() }} audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_31.mp3')} firstLineIndent="2em" diff --git a/src/pages/rongdesheng/reflection/modules.data.ts b/src/pages/rongdesheng/reflection/modules.data.ts index 9e63259..547c791 100644 --- a/src/pages/rongdesheng/reflection/modules.data.ts +++ b/src/pages/rongdesheng/reflection/modules.data.ts @@ -85,8 +85,7 @@ export const MODULES: Module[] = [ resultContent: '护厂抗争的胜利,是民族工业坚守爱国初心的体现,也是国人联合“反抗压迫、团结抗争”的印证!', resultImage: 'rongdesheng/reflection/场景2结算', - wrongHint: - '荣氏兄弟坚守“实业救国”初心,拒绝洋行干涉企业经营,不愿民族产业沦为外资附庸!试试其他方案吧~', + wrongHint: '荣氏兄弟坚守“实业救国”初心,绝不会将民族产业拱手让人!试试其他方案吧~', audio: { wrongHint: '' }, }, { @@ -94,7 +93,7 @@ export const MODULES: Module[] = [ title: '民生坚守', backgroundImage: 'rongdesheng/reflection/场景3选中', question: - '时局动荡,棉花原料涨价,工厂盈利下滑,职工生活面临困难。荣德生面对既要维持生产,又要保障职工生活,该怎么做?', + '时局动荡,棉花原料涨价,工厂盈利下滑,职工生活面临困难。既要维持生产,又要保障职工生活,该怎么做?', options: [ { id: 'opt3-1', text: 'A.削减职工工资,降低运营成本', isCorrect: false }, { @@ -176,7 +175,7 @@ export const MODULES: Module[] = [ title: '革新突围', backgroundImage: 'rongdesheng/reflection/场景5选中', question: - '抗战胜利后,洋纱、洋布卷土重来,申新设备陈旧、技术落后,面临产品竞争力不足的困境,荣德生如何通过技术革新让民族纱厂站稳脚跟?', + '抗战胜利后,洋纱、洋布卷土重来,申新设备陈旧、技术落后,面临产品竞争力不足的困境,如何通过技术革新让民族纱厂站稳脚跟?', options: [ { id: 'opt5-1', diff --git a/src/pages/sunzhongshan/internalization.tsx b/src/pages/sunzhongshan/internalization.tsx index 147e033..fe210fc 100644 --- a/src/pages/sunzhongshan/internalization.tsx +++ b/src/pages/sunzhongshan/internalization.tsx @@ -315,7 +315,6 @@ const SunzhongshanInternalization = () => { /> <SuccessAlert visible={activeSuccess === item.id} - title="恭喜你, 选择正确!" content="" onContinue={() => { setActiveSuccess(null) @@ -466,6 +465,9 @@ const SunzhongshanInternalization = () => { anchors={anchors} playsInline controls + controlsOnReplay + skipKey="sunzhongshan_internalization_video" + showReadonlyProgress onCanPlay={handleVideoCanPlay} onEnded={() => { setShowVideo(false) diff --git a/src/pages/sunzhongshan/place/place1.tsx b/src/pages/sunzhongshan/place/place1.tsx index 7dd01f3..e2bc36a 100644 --- a/src/pages/sunzhongshan/place/place1.tsx +++ b/src/pages/sunzhongshan/place/place1.tsx @@ -441,6 +441,8 @@ const SunzhongshanPlace1 = () => { autoPlay playsInline showSkipButton + skipKey="sunzhongshan_place1_video1" + showReadonlyProgress onEnded={() => { setShowVideo4(false) setDragQuiz4Visible(true) @@ -513,6 +515,8 @@ const SunzhongshanPlace1 = () => { autoPlay playsInline showSkipButton + skipKey="sunzhongshan_place1_video2" + showReadonlyProgress onEnded={() => { setShowVideo4Second(false) setQuiz3Visible(true) @@ -569,7 +573,7 @@ const SunzhongshanPlace1 = () => { 中华民国的建立有个重要背景——“南北议和”。辛亥革命中宣告独立的省份大多集中在中国南方,负隅顽抗的清政府将最后希望寄托在主导北洋军阀势力的袁世凯身上。在列强支持下,袁世凯率军南下。 </GuideStepsItem> <GuideStepsItem speaker="guide" audioSrc={audio('17_2')}> - 他一边以武力威胁革命势力,一边又迎合革命党人急于完成统一的愿望,诱使其开展和议。当时,革命党人力量还不够强。为避免国家陷入前途未卜的内战,孙中山先生发表声明:只要清帝退位,袁世凯赞成共和,就推举他做大总统。 + 他一边以武力威胁革命势力,一边又迎合革命党人急于完成统一的愿望,诱使其开展和议。当时,革命党人力量还不够强,为避免国家陷入前途未卜的内战,孙中山先生发表声明:只要清帝退位,袁世凯赞成共和,就推举他做大总统。 </GuideStepsItem> </GuideSteps> diff --git a/src/pages/sunzhongshan/place/place2.tsx b/src/pages/sunzhongshan/place/place2.tsx index 8417b7a..dbb4370 100644 --- a/src/pages/sunzhongshan/place/place2.tsx +++ b/src/pages/sunzhongshan/place/place2.tsx @@ -271,6 +271,8 @@ const SunzhongshanPlace2 = () => { autoPlay playsInline showSkipButton + skipKey="sunzhongshan_place2_video1" + showReadonlyProgress onEnded={() => { setShowVideo1(false) setSingleQuiz1Visible(true) @@ -403,6 +405,8 @@ const SunzhongshanPlace2 = () => { autoPlay playsInline showSkipButton + skipKey="sunzhongshan_place2_video2" + showReadonlyProgress onEnded={() => { setShowVideo2(false) setSingleQuiz2Visible(true) diff --git a/src/pages/sunzhongshan/place/place3.tsx b/src/pages/sunzhongshan/place/place3.tsx index c1915b6..cc71fb2 100644 --- a/src/pages/sunzhongshan/place/place3.tsx +++ b/src/pages/sunzhongshan/place/place3.tsx @@ -293,6 +293,8 @@ const SunzhongshanPlace3 = () => { autoPlay playsInline showSkipButton + skipKey="sunzhongshan_place3_video" + showReadonlyProgress onEnded={() => { setShowVideo2(false) setDragQuiz2Visible(true) diff --git a/src/pages/sunzhongshan/place/place4.tsx b/src/pages/sunzhongshan/place/place4.tsx index 7fed4a3..246d225 100644 --- a/src/pages/sunzhongshan/place/place4.tsx +++ b/src/pages/sunzhongshan/place/place4.tsx @@ -167,6 +167,8 @@ const SunzhongshanPlace4 = () => { autoPlay playsInline showSkipButton + skipKey="sunzhongshan_place4_video" + showReadonlyProgress onEnded={() => { setShowVideo1(false) // 视频播放结束后,显示学习引导 diff --git a/src/pages/sunzhongshan/reflection/scene1.tsx b/src/pages/sunzhongshan/reflection/scene1.tsx index d2d43cd..c2312f1 100644 --- a/src/pages/sunzhongshan/reflection/scene1.tsx +++ b/src/pages/sunzhongshan/reflection/scene1.tsx @@ -86,10 +86,13 @@ const SunzhongshanReflectionScene1 = () => { const [showQuiz3, setShowQuiz3] = useState(false) const [showCompleteGuide, setShowCompleteGuide] = useState(false) const [pendingQuiz, setPendingQuiz] = useState<'quiz2' | 'quiz3' | 'complete' | null>(null) + const [hasClickedClueHint, setHasClickedClueHint] = useState(false) + const [hasClickedClueBook, setHasClickedClueBook] = useState(false) const { collectClue, isClueCollected } = useSunzhongshanStore() const containerRef = useRef<HTMLDivElement>(null) const clueBookButtonRef = useRef<HTMLDivElement>(null) + const backpackButtonRef = useRef<HTMLDivElement>(null) // 为每个线索创建 ref const clueRefs = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(new Map()) @@ -147,13 +150,10 @@ const SunzhongshanReflectionScene1 = () => { // 收集线索1 collectClue(clue1Id, clue1Name) - // 获取线索1的 ref - const clue1Ref = getClueRef(clue1Id) - - // 执行飞行动画 - if (clue1Ref.current && clueBookButtonRef.current) { + // 执行飞行动画 - 从背包按钮开始 + if (backpackButtonRef.current && clueBookButtonRef.current) { flyToAnimation({ - fromRef: clue1Ref as React.RefObject<HTMLElement>, + fromRef: backpackButtonRef as React.RefObject<HTMLElement>, toRef: clueBookButtonRef as React.RefObject<HTMLElement>, targetPosition: 'center', duration: 1200, @@ -168,6 +168,7 @@ const SunzhongshanReflectionScene1 = () => { // 处理线索提示按钮点击 - 打开线索提示框 const handleClueHintClick = () => { + setHasClickedClueHint(true) setShowClueHint(true) } @@ -341,11 +342,9 @@ const SunzhongshanReflectionScene1 = () => { style={{ top: clue.position.top, left: clue.position.left, - width: clue.position.width, - height: clue.position.height, }} > - <div className="relative"> + <div className="relative inline-block"> <Image src={getClueImage(clue.id)} className="w-20 h-20 border-2 border-yellow-400 rounded shadow-lg" @@ -377,22 +376,47 @@ const SunzhongshanReflectionScene1 = () => { {/* 右下角-功能处 */} <div className="absolute right-6 bottom-12 flex flex-col items-center gap-4 z-10"> - <Image - src={getStaticImage(`${starName}/reflection/线索提示`)} - button - onClick={handleClueHintClick} - /> - <Image - src={getStaticImage(`${starName}/reflection/背包`)} - button - onClick={handleBackpackClick} - /> + <div className="relative"> + <Image + src={getStaticImage(`${starName}/reflection/线索提示`)} + button + onClick={handleClueHintClick} + /> + {!hasClickedClueHint && ( + <Image + className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none" + src={getStaticImage('components/点击查看')} + /> + )} + </div> + <div ref={backpackButtonRef} className="relative"> + <Image + src={getStaticImage(`${starName}/reflection/背包`)} + button + onClick={handleBackpackClick} + /> + {hasClickedClueHint && !isClueCollected(`${sceneId}/clue1`) && ( + <Image + className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none" + src={getStaticImage('components/点击查看')} + /> + )} + </div> <div ref={clueBookButtonRef} className="relative" data-clue-book-button="true"> <Image src={getStaticImage(`${starName}/reflection/线索簿`)} button - onClick={() => setShowClueBook(true)} + onClick={() => { + setHasClickedClueBook(true) + setShowClueBook(true) + }} /> + {hasClickedClueHint && isClueCollected(`${sceneId}/clue1`) && !hasClickedClueBook && ( + <Image + className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none" + src={getStaticImage('components/点击查看')} + /> + )} </div> </div> @@ -452,7 +476,7 @@ const SunzhongshanReflectionScene1 = () => { {/* 题目1:今天的日期是? */} <SingleChoiceQuizPanel visible={showQuiz1} - title="【单选】今天的日期是?" + title="【单选】今天的日期是:" options={[ { value: 'A', label: 'A.1919年5月5日' }, { value: 'B', label: 'B.1921年5月5日' }, diff --git a/src/pages/sunzhongshan/reflection/scene2.tsx b/src/pages/sunzhongshan/reflection/scene2.tsx index c840878..9f02531 100644 --- a/src/pages/sunzhongshan/reflection/scene2.tsx +++ b/src/pages/sunzhongshan/reflection/scene2.tsx @@ -96,10 +96,13 @@ const SunzhongshanReflectionScene2 = () => { const [showQuiz3, setShowQuiz3] = useState(false) const [showCompleteGuide, setShowCompleteGuide] = useState(false) const [pendingQuiz, setPendingQuiz] = useState<'quiz2' | 'quiz3' | 'complete' | null>(null) + const [hasClickedClueHint, setHasClickedClueHint] = useState(false) + const [hasClickedClueBook, setHasClickedClueBook] = useState(false) const { collectClue, isClueCollected } = useSunzhongshanStore() const containerRef = useRef<HTMLDivElement>(null) const clueBookButtonRef = useRef<HTMLDivElement>(null) + const backpackButtonRef = useRef<HTMLDivElement>(null) // 为每个线索创建 ref const clueRefs = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(new Map()) @@ -157,13 +160,10 @@ const SunzhongshanReflectionScene2 = () => { // 收集线索1 collectClue(clue1Id, clue1Name) - // 获取线索1的 ref - const clue1Ref = getClueRef(clue1Id) - - // 执行飞行动画 - if (clue1Ref.current && clueBookButtonRef.current) { + // 执行飞行动画 - 从背包按钮开始 + if (backpackButtonRef.current && clueBookButtonRef.current) { flyToAnimation({ - fromRef: clue1Ref as React.RefObject<HTMLElement>, + fromRef: backpackButtonRef as React.RefObject<HTMLElement>, toRef: clueBookButtonRef as React.RefObject<HTMLElement>, targetPosition: 'center', duration: 1200, @@ -178,6 +178,7 @@ const SunzhongshanReflectionScene2 = () => { // 处理线索提示按钮点击 - 打开线索提示框 const handleClueHintClick = () => { + setHasClickedClueHint(true) setShowClueHint(true) } @@ -355,11 +356,9 @@ const SunzhongshanReflectionScene2 = () => { style={{ top: clue.position.top, left: clue.position.left, - width: clue.position.width, - height: clue.position.height, }} > - <div className="relative"> + <div className="relative inline-block"> <Image src={getClueImage(clue.id)} className="w-20 h-20 border-2 border-yellow-400 rounded shadow-lg" @@ -391,22 +390,47 @@ const SunzhongshanReflectionScene2 = () => { {/* 右下角-功能处 */} <div className="absolute right-6 bottom-12 flex flex-col items-center gap-4 z-10"> - <Image - src={getStaticImage(`${starName}/reflection/线索提示`)} - button - onClick={handleClueHintClick} - /> - <Image - src={getStaticImage(`${starName}/reflection/背包`)} - button - onClick={handleBackpackClick} - /> + <div className="relative"> + <Image + src={getStaticImage(`${starName}/reflection/线索提示`)} + button + onClick={handleClueHintClick} + /> + {!hasClickedClueHint && ( + <Image + className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none" + src={getStaticImage('components/点击查看')} + /> + )} + </div> + <div ref={backpackButtonRef} className="relative"> + <Image + src={getStaticImage(`${starName}/reflection/背包`)} + button + onClick={handleBackpackClick} + /> + {hasClickedClueHint && !isClueCollected(`${sceneId}/clue1`) && ( + <Image + className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none" + src={getStaticImage('components/点击查看')} + /> + )} + </div> <div ref={clueBookButtonRef} className="relative" data-clue-book-button="true"> <Image src={getStaticImage(`${starName}/reflection/线索簿`)} button - onClick={() => setShowClueBook(true)} + onClick={() => { + setHasClickedClueBook(true) + setShowClueBook(true) + }} /> + {hasClickedClueHint && isClueCollected(`${sceneId}/clue1`) && !hasClickedClueBook && ( + <Image + className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none" + src={getStaticImage('components/点击查看')} + /> + )} </div> </div> diff --git a/src/pages/sunzhongshan/reflection/scene3.tsx b/src/pages/sunzhongshan/reflection/scene3.tsx index 4a69fa9..73c9e10 100644 --- a/src/pages/sunzhongshan/reflection/scene3.tsx +++ b/src/pages/sunzhongshan/reflection/scene3.tsx @@ -94,10 +94,13 @@ const SunzhongshanReflectionScene3 = () => { const [isMainScene, setIsMainScene] = useState(false) // 控制 Cursor 显示:获取线索1后显示,点击后进入主场景 const [showCursor, setShowCursor] = useState(false) + const [hasClickedClueHint, setHasClickedClueHint] = useState(false) + const [hasClickedClueBook, setHasClickedClueBook] = useState(false) const { collectClue, isClueCollected } = useSunzhongshanStore() const containerRef = useRef<HTMLDivElement>(null) const clueBookButtonRef = useRef<HTMLDivElement>(null) + const backpackButtonRef = useRef<HTMLDivElement>(null) // 为每个线索创建 ref const clueRefs = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(new Map()) @@ -169,13 +172,10 @@ const SunzhongshanReflectionScene3 = () => { // 收集线索4 collectClue(clue4Id, clue4Name) - // 获取线索4的 ref - const clue4Ref = getClueRef(clue4Id) - - // 执行飞行动画 - if (clue4Ref.current && clueBookButtonRef.current) { + // 执行飞行动画 - 从报纸按钮开始 + if (backpackButtonRef.current && clueBookButtonRef.current) { flyToAnimation({ - fromRef: clue4Ref as React.RefObject<HTMLElement>, + fromRef: backpackButtonRef as React.RefObject<HTMLElement>, toRef: clueBookButtonRef as React.RefObject<HTMLElement>, targetPosition: 'center', duration: 1200, @@ -190,6 +190,7 @@ const SunzhongshanReflectionScene3 = () => { // 处理线索提示按钮点击 - 打开线索提示框 const handleClueHintClick = () => { + setHasClickedClueHint(true) setShowClueHint(true) } @@ -397,11 +398,9 @@ const SunzhongshanReflectionScene3 = () => { style={{ top: clue.position.top, left: clue.position.left, - width: clue.position.width, - height: clue.position.height, }} > - <div className="relative"> + <div className="relative inline-block"> <Image src={getClueImage(clue.id)} className="w-20 h-20 border-2 border-yellow-400 rounded shadow-lg" @@ -433,22 +432,47 @@ const SunzhongshanReflectionScene3 = () => { {/* 右下角-功能处 */} <div className="absolute right-6 bottom-12 flex flex-col items-center gap-4 z-10"> - <Image - src={getStaticImage(`${starName}/reflection/线索提示`)} - button - onClick={handleClueHintClick} - /> - <Image - src={getStaticImage(`${starName}/reflection/背包`)} - button - onClick={handleBackpackClick} - /> + <div className="relative"> + <Image + src={getStaticImage(`${starName}/reflection/线索提示`)} + button + onClick={handleClueHintClick} + /> + {!isMainScene && !hasClickedClueHint && ( + <Image + className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none" + src={getStaticImage('components/点击查看')} + /> + )} + </div> + <div ref={backpackButtonRef} className="relative"> + <Image + src={getStaticImage(`${starName}/reflection/scene3/报纸`)} + button + onClick={handleBackpackClick} + /> + {isMainScene && !isClueCollected(`${sceneId}/clue4`) && ( + <Image + className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none" + src={getStaticImage('components/点击查看')} + /> + )} + </div> <div ref={clueBookButtonRef} className="relative" data-clue-book-button="true"> <Image src={getStaticImage(`${starName}/reflection/线索簿`)} button - onClick={() => setShowClueBook(true)} + onClick={() => { + setHasClickedClueBook(true) + setShowClueBook(true) + }} /> + {isMainScene && isClueCollected(`${sceneId}/clue4`) && !hasClickedClueBook && ( + <Image + className="absolute right-full top-1/2 z-20 mr-2 h-28 w-34 -translate-y-1/2 animate-finger-shake pointer-events-none" + src={getStaticImage('components/点击查看')} + /> + )} </div> </div> diff --git a/src/pages/zhangleping/draw/draw1.config.ts b/src/pages/zhangleping/draw/draw1.config.ts index 316a91f..7c72e22 100644 --- a/src/pages/zhangleping/draw/draw1.config.ts +++ b/src/pages/zhangleping/draw/draw1.config.ts @@ -1,5 +1,3 @@ -import { toJpeg } from 'html-to-image' - export const STAR_NAME = 'zhangleping' // ─── 类型定义 ─────────────────────────────────────────────── @@ -210,33 +208,110 @@ export function getTopBarSrc(block: SidebarBlockKey): string { return `${STAR_NAME}/reflection/draw/素材库条` } -// ─── DOM 截图生成 ───────────────────────────────────────────── +// ─── Canvas 合成生成 ─────────────────────────────────────────── -export async function buildCompositeDataUrl(compositionEl: HTMLElement): Promise<string> { - // 临时移除定位偏移,避免截图出现留白/偏移 - const { position, left, right, top, bottom } = compositionEl.style - Object.assign(compositionEl.style, { - position: 'fixed', - left: '0', - right: 'auto', - top: '0', - bottom: 'auto', +interface CompositeImageLayer { + src: string + x: number + y: number + width: number + height: number + zIndex: number +} + +const EXPORT_WIDTH = 960 +const EXPORT_HEIGHT = 540 + +function loadImage(src: string): Promise<HTMLImageElement> { + return new Promise((resolve, reject) => { + const image = new window.Image() + image.decoding = 'async' + image.onload = () => resolve(image) + image.onerror = reject + image.src = src }) +} - try { - return await toJpeg(compositionEl, { - cacheBust: false, - skipFonts: true, - width: compositionEl.offsetWidth, - height: compositionEl.offsetHeight, - canvasWidth: 960, - canvasHeight: 540, - pixelRatio: 1, - quality: 0.82, - backgroundColor: '#fff', - }) - } finally { - // 恢复原始样式 - Object.assign(compositionEl.style, { position, left, right, top, bottom }) +function getBackgroundUrl(element: HTMLElement): string | null { + const backgroundImage = window.getComputedStyle(element).backgroundImage + const match = backgroundImage.match(/^url\(["']?(.+?)["']?\)$/) + return match?.[1] ?? null +} + +function getLayerZIndex(element: HTMLElement, root: HTMLElement): number { + let current: HTMLElement | null = element + + while (current && current !== root) { + const zIndex = Number(window.getComputedStyle(current).zIndex) + if (Number.isFinite(zIndex)) return zIndex + current = current.parentElement + } + + return 0 +} + +function getImageLayer( + element: HTMLImageElement, + root: HTMLElement, + rootRect: DOMRect +): CompositeImageLayer { + const rect = element.getBoundingClientRect() + + return { + src: element.currentSrc || element.src, + x: rect.left - rootRect.left, + y: rect.top - rootRect.top, + width: rect.width, + height: rect.height, + zIndex: getLayerZIndex(element, root), } } + +async function drawLayer( + context: CanvasRenderingContext2D, + layer: CompositeImageLayer, + scaleX: number, + scaleY: number +) { + const image = await loadImage(layer.src) + context.drawImage( + image, + layer.x * scaleX, + layer.y * scaleY, + layer.width * scaleX, + layer.height * scaleY + ) +} + +export async function buildCompositeDataUrl(compositionEl: HTMLElement): Promise<string> { + const canvas = document.createElement('canvas') + canvas.width = EXPORT_WIDTH + canvas.height = EXPORT_HEIGHT + + const context = canvas.getContext('2d') + if (!context) throw new Error('Canvas is not available') + + context.fillStyle = '#fff' + context.fillRect(0, 0, EXPORT_WIDTH, EXPORT_HEIGHT) + + const rootRect = compositionEl.getBoundingClientRect() + const scaleX = EXPORT_WIDTH / rootRect.width + const scaleY = EXPORT_HEIGHT / rootRect.height + const backgroundUrl = getBackgroundUrl(compositionEl) + + if (backgroundUrl) { + const background = await loadImage(backgroundUrl) + context.drawImage(background, 0, 0, EXPORT_WIDTH, EXPORT_HEIGHT) + } + + const layers = Array.from(compositionEl.querySelectorAll<HTMLImageElement>('img')) + .filter((image) => image.complete && image.naturalWidth > 0) + .map((image) => getImageLayer(image, compositionEl, rootRect)) + .sort((a, b) => a.zIndex - b.zIndex) + + for (const layer of layers) { + await drawLayer(context, layer, scaleX, scaleY) + } + + return canvas.toDataURL('image/jpeg', 0.82) +} diff --git a/src/pages/zhangleping/draw/draw1.tsx b/src/pages/zhangleping/draw/draw1.tsx index 9101131..854f8be 100644 --- a/src/pages/zhangleping/draw/draw1.tsx +++ b/src/pages/zhangleping/draw/draw1.tsx @@ -209,7 +209,7 @@ const ZhanglepingDraw1 = () => { {/* 底部输入框 */} {showCaptionInput && ( <div - className="absolute left-20 bottom-2.5 bg-cover bg-center bg-no-repeat w-[1724px] h-[100px] z-40" + className="absolute left-20 bottom-0.5 bg-cover bg-center bg-no-repeat w-[1724px] h-[100px] z-40" style={{ backgroundImage: getBackgroundImage(`${STAR_NAME}/reflection/draw/写字底板`) }} > <div className="flex items-center justify-center"> @@ -239,6 +239,7 @@ const ZhanglepingDraw1 = () => { <Image src={getStaticImage(`${STAR_NAME}/reflection/draw/返回`)} button + className="block w-[371px] h-[105px]" onClick={() => navigate(`/${moduleName}/${STAR_NAME}/draw`)} /> </div> @@ -246,7 +247,10 @@ const ZhanglepingDraw1 = () => { <Image src={getStaticImage(`${STAR_NAME}/reflection/draw/提交作品`)} button - className={isSubmitting ? 'opacity-70 pointer-events-none' : undefined} + className={cn( + 'block w-[371px] h-[105px]', + isSubmitting && 'opacity-70 pointer-events-none' + )} onClick={handleSubmitArtwork} /> </div> diff --git a/src/pages/zhangleping/internalization.tsx b/src/pages/zhangleping/internalization.tsx index ccefd8e..75e8154 100644 --- a/src/pages/zhangleping/internalization.tsx +++ b/src/pages/zhangleping/internalization.tsx @@ -431,6 +431,9 @@ const ZhanglepingInternalization = () => { anchors={anchors} playsInline controls + controlsOnReplay + skipKey="zhangleping_internalization_video" + showReadonlyProgress onSkip={() => { setShowVideo(false) setPostGuideVisible(true) diff --git a/src/pages/zhangleping/place/place2.tsx b/src/pages/zhangleping/place/place2.tsx index 917e809..a06c125 100644 --- a/src/pages/zhangleping/place/place2.tsx +++ b/src/pages/zhangleping/place/place2.tsx @@ -286,6 +286,8 @@ const ZhanglepingPlace2 = () => { autoPlay playsInline showSkipButton + skipKey="zhangleping_place2_video1" + showReadonlyProgress onEnded={() => { setShowVideo1(false) setQuizVisible1(true) @@ -363,6 +365,8 @@ const ZhanglepingPlace2 = () => { autoPlay playsInline showSkipButton + skipKey="zhangleping_place2_video2" + showReadonlyProgress onEnded={() => { setShowVideo2(false) markExhibitVisited(place, 2) diff --git a/src/pages/zhangleping/place/place3.tsx b/src/pages/zhangleping/place/place3.tsx index 5fcda24..7cc4503 100644 --- a/src/pages/zhangleping/place/place3.tsx +++ b/src/pages/zhangleping/place/place3.tsx @@ -277,6 +277,8 @@ const ZhanglepingPlace3 = () => { autoPlay playsInline showSkipButton + skipKey="zhangleping_place3_video1" + showReadonlyProgress onEnded={() => { setShowVideo2(false) setQuiz2Visible(true) @@ -340,6 +342,8 @@ const ZhanglepingPlace3 = () => { autoPlay playsInline showSkipButton + skipKey="zhangleping_place3_video2" + showReadonlyProgress onEnded={() => { setShowVideo3(false) }} diff --git a/src/pages/zhangleping/reflection/scene1.tsx b/src/pages/zhangleping/reflection/scene1.tsx index d4d681a..2ebccef 100644 --- a/src/pages/zhangleping/reflection/scene1.tsx +++ b/src/pages/zhangleping/reflection/scene1.tsx @@ -296,7 +296,7 @@ const ZhanglepingReflectionScene1 = () => { {fragmentCompleted && !showFragmentOverlay && ( <Image className="absolute bottom-0 right-0" - src={getStaticImage('components/place/离开场景')} + src={getStaticImage('components/place/继续前进')} button onClick={handleLeave} /> diff --git a/src/pages/zhangleping/reflection/scene2.tsx b/src/pages/zhangleping/reflection/scene2.tsx index 7a4e448..d0c19d3 100644 --- a/src/pages/zhangleping/reflection/scene2.tsx +++ b/src/pages/zhangleping/reflection/scene2.tsx @@ -473,7 +473,7 @@ const ZhanglepingReflectionScene2 = () => { {fragmentCompleted && !showRepairOverlay && !showFragmentOverlay && ( <Image className="absolute bottom-0 right-0" - src={getStaticImage('components/place/离开场景')} + src={getStaticImage('components/place/继续前进')} button onClick={handleLeave} /> diff --git a/src/pages/zhangleping/reflection/scene3.tsx b/src/pages/zhangleping/reflection/scene3.tsx index 3522f35..878247e 100644 --- a/src/pages/zhangleping/reflection/scene3.tsx +++ b/src/pages/zhangleping/reflection/scene3.tsx @@ -211,7 +211,7 @@ const ZhanglepingReflectionScene3 = () => { {/* 场景提示:碎片已解锁 */} {!showItemExhibit && !showFragmentOverlay && fragmentCompleted && ( <SceneHint when triggerKey={successHintKey} persistent offset={{ x: -20 }}> - 苦难善良·记忆碎片已解锁 + 流浪岁月·记忆碎片已解锁 </SceneHint> )} {/* 场景提示:拖拽阶段常驻/错误提示 */} @@ -336,7 +336,7 @@ const ZhanglepingReflectionScene3 = () => { {fragmentCompleted && !showItemExhibit && !showFragmentOverlay && ( <Image className="absolute bottom-0 right-0" - src={getStaticImage('components/place/离开场景')} + src={getStaticImage('components/place/继续前进')} button onClick={handleLeave} /> diff --git a/src/pages/zhangleping/reflection/scene4.tsx b/src/pages/zhangleping/reflection/scene4.tsx index 1c80545..dec93dc 100644 --- a/src/pages/zhangleping/reflection/scene4.tsx +++ b/src/pages/zhangleping/reflection/scene4.tsx @@ -344,7 +344,7 @@ const ZhanglepingReflectionScene4 = () => { {fragmentCompleted && !showItemExhibit && !showFragmentOverlay && ( <Image className="absolute bottom-0 right-0" - src={getStaticImage('components/place/离开场景')} + src={getStaticImage('components/place/继续前进')} button onClick={handleLeave} /> diff --git a/src/store/chenyunStore.ts b/src/store/chenyunStore.ts index 0a7b640..64d445b 100644 --- a/src/store/chenyunStore.ts +++ b/src/store/chenyunStore.ts @@ -1,7 +1,11 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { STORE_NAMES } from './storeNames' -import { setUserDefine } from '@/lib/utils/api-common' +import { + setUserDefine, + getUserDefineResult, + type UserDefineFetchStatus, +} from '@/lib/utils/api-common' interface ChenyunStore { // 存储每个场景(place)的已访问展品ID集合 @@ -38,6 +42,10 @@ interface ChenyunStore { isMimianSolved: boolean // 设置米棉之战解决状态 setMimianSolved: (solved: boolean) => void + + // ========== 视频完成状态 ========== + completedVideos: Record<string, boolean> + markVideoCompleted: (videoKey: string) => void } // 将 Set 转换为数组以便序列化 @@ -69,6 +77,14 @@ const deserializeVisitedExhibits = ( return visitedExhibits } +interface ChenyunPersistedState { + visitedExhibits?: Record<string, number[]> + isYinyuanSolved?: boolean + isMimianSolved?: boolean + lastVisitedPlace?: string | null + completedVideos?: Record<string, boolean> +} + export const useChenyunStore = create<ChenyunStore>()( persist( (set, get) => ({ @@ -107,7 +123,13 @@ export const useChenyunStore = create<ChenyunStore>()( }, resetAll: () => { - set({ visitedExhibits: {} }) + set({ + visitedExhibits: {}, + lastVisitedPlace: null, + isYinyuanSolved: false, + isMimianSolved: false, + completedVideos: {}, + }) }, // ========== 断点续读 ========== @@ -124,6 +146,13 @@ export const useChenyunStore = create<ChenyunStore>()( setMimianSolved: (solved: boolean) => { set({ isMimianSolved: solved }) }, + + completedVideos: {}, + markVideoCompleted: (videoKey: string) => { + set((state) => ({ + completedVideos: { ...state.completedVideos, [videoKey]: true }, + })) + }, }), { name: STORE_NAMES.CHENYUN, // localStorage key @@ -132,6 +161,7 @@ export const useChenyunStore = create<ChenyunStore>()( isYinyuanSolved: state.isYinyuanSolved, isMimianSolved: state.isMimianSolved, lastVisitedPlace: state.lastVisitedPlace, + completedVideos: state.completedVideos, }), onRehydrateStorage: () => (state) => { if (state) { @@ -147,6 +177,9 @@ export const useChenyunStore = create<ChenyunStore>()( if (state.lastVisitedPlace === undefined) { state.lastVisitedPlace = null } + if (!state.completedVideos || typeof state.completedVideos !== 'object') { + state.completedVideos = {} + } } }, } @@ -165,6 +198,7 @@ export const saveChenyunStore = (): void => { isYinyuanSolved: state.isYinyuanSolved, isMimianSolved: state.isMimianSolved, lastVisitedPlace: state.lastVisitedPlace, + completedVideos: state.completedVideos, } try { @@ -173,3 +207,31 @@ export const saveChenyunStore = (): void => { console.error('Failed to save Chenyun store', error) } } + +export const applyChenyunPersistedState = (payload: ChenyunPersistedState): void => { + useChenyunStore.setState({ + visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits), + isYinyuanSolved: payload.isYinyuanSolved ?? false, + isMimianSolved: payload.isMimianSolved ?? false, + lastVisitedPlace: payload.lastVisitedPlace ?? null, + completedVideos: payload.completedVideos ?? {}, + }) +} + +export const loadChenyunStore = async (): Promise<UserDefineFetchStatus> => { + try { + const result = await getUserDefineResult(CHENYUN_LESSON_ID, CHENYUN_KEY) + if (result.status !== 'success') { + if (result.status === 'empty') { + useChenyunStore.getState().resetAll() + } + return result.status + } + + applyChenyunPersistedState(JSON.parse(result.content) as ChenyunPersistedState) + return result.status + } catch (error) { + console.error('Failed to load Chenyun store', error) + return 'error' + } +} diff --git a/src/store/huangyanpeiStore.ts b/src/store/huangyanpeiStore.ts index 5ae1292..2a6a48b 100644 --- a/src/store/huangyanpeiStore.ts +++ b/src/store/huangyanpeiStore.ts @@ -1,7 +1,11 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { STORE_NAMES } from './storeNames' -import { setUserDefine, getUserDefineResult } from '@/lib/utils/api-common' +import { + setUserDefine, + getUserDefineResult, + type UserDefineFetchStatus, +} from '@/lib/utils/api-common' interface HuangyanpeiStore { // 存储每个场景(place)的已访问展品ID集合 @@ -59,6 +63,10 @@ interface HuangyanpeiStore { // 重置所有reflection cursor记录 resetAllReflectionCursors: () => void + + // ========== 视频完成状态 ========== + completedVideos: Record<string, boolean> + markVideoCompleted: (videoKey: string) => void } // 将 Set 转换为数组以便序列化 @@ -90,6 +98,22 @@ const deserializeVisitedExhibits = ( return visitedExhibits } +const resolveReflectionRoute = (route: unknown): string => + typeof route === 'string' && + ['reflection', 'reflection2', 'reflection3', 'reflection4'].includes(route) + ? route + : 'reflection' + +interface HuangyanpeiPersistedState { + visitedExhibits?: Record<string, number[]> + visitedReflectionCursors?: Record<string, number[]> + shownReflectionCompletionGuides?: Record<string, boolean> + reflectionScene2Choice?: '铁工科' | '商科' | null + lastVisitedPlace?: string | null + reflectionRoute?: string + completedVideos?: Record<string, boolean> +} + export const useHuangyanpeiStore = create<HuangyanpeiStore>()( persist( (set, get) => ({ @@ -132,7 +156,15 @@ export const useHuangyanpeiStore = create<HuangyanpeiStore>()( }, resetAll: () => { - set({ visitedExhibits: {} }) + set({ + visitedExhibits: {}, + visitedReflectionCursors: {}, + shownReflectionCompletionGuides: {}, + reflectionScene2Choice: null, + lastVisitedPlace: null, + reflectionRoute: 'reflection', + completedVideos: {}, + }) }, markReflectionCompletionGuideShown: (scene: string) => { @@ -191,6 +223,13 @@ export const useHuangyanpeiStore = create<HuangyanpeiStore>()( setLastVisitedPlace: (place: string) => set({ lastVisitedPlace: place }), setReflectionRoute: (route: string) => set({ reflectionRoute: route }), setReflectionScene2Choice: (choice) => set({ reflectionScene2Choice: choice }), + + completedVideos: {}, + markVideoCompleted: (videoKey: string) => { + set((state) => ({ + completedVideos: { ...state.completedVideos, [videoKey]: true }, + })) + }, }), { name: STORE_NAMES.HUANGYANPEI, // localStorage key @@ -201,6 +240,7 @@ export const useHuangyanpeiStore = create<HuangyanpeiStore>()( reflectionScene2Choice: state.reflectionScene2Choice, lastVisitedPlace: state.lastVisitedPlace, reflectionRoute: state.reflectionRoute, + completedVideos: state.completedVideos, }), onRehydrateStorage: () => (state) => { if (state) { @@ -221,6 +261,9 @@ export const useHuangyanpeiStore = create<HuangyanpeiStore>()( if ((state as any).reflectionRoute === undefined) { ;(state as any).reflectionRoute = 'reflection' } + if (!state.completedVideos || typeof state.completedVideos !== 'object') { + state.completedVideos = {} + } } }, } @@ -236,6 +279,7 @@ export const saveHuangyanpeiStore = (): void => { const payload = { visitedExhibits: serializeVisitedExhibits(state.visitedExhibits), + visitedReflectionCursors: serializeVisitedExhibits(state.visitedReflectionCursors), lastVisitedPlace: state.lastVisitedPlace, reflectionRoute: state.reflectionRoute, shownReflectionCompletionGuides: state.shownReflectionCompletionGuides, @@ -249,43 +293,32 @@ export const saveHuangyanpeiStore = (): void => { } } -export const loadHuangyanpeiStore = async (): Promise<void> => { +export const applyHuangyanpeiPersistedState = (payload: HuangyanpeiPersistedState): void => { + useHuangyanpeiStore.setState({ + visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits), + visitedReflectionCursors: deserializeVisitedExhibits(payload.visitedReflectionCursors), + shownReflectionCompletionGuides: payload.shownReflectionCompletionGuides ?? {}, + reflectionScene2Choice: payload.reflectionScene2Choice ?? null, + lastVisitedPlace: payload.lastVisitedPlace ?? null, + reflectionRoute: resolveReflectionRoute(payload.reflectionRoute), + completedVideos: payload.completedVideos ?? {}, + }) +} + +export const loadHuangyanpeiStore = async (): Promise<UserDefineFetchStatus> => { try { const result = await getUserDefineResult(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY) if (result.status !== 'success') { - useHuangyanpeiStore.setState({ - visitedExhibits: {}, - lastVisitedPlace: null, - reflectionRoute: 'reflection', - shownReflectionCompletionGuides: {}, - reflectionScene2Choice: null, - }) - return + if (result.status === 'empty') { + useHuangyanpeiStore.getState().resetAll() + } + return result.status } - const payload = JSON.parse(result.content) as { - visitedExhibits?: Record<string, number[]> - lastVisitedPlace?: string | null - reflectionRoute?: string - shownReflectionCompletionGuides?: Record<string, boolean> - reflectionScene2Choice?: '铁工科' | '商科' | null - } - - useHuangyanpeiStore.setState({ - visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits), - lastVisitedPlace: payload.lastVisitedPlace ?? null, - reflectionRoute: payload.reflectionRoute ?? 'reflection', - shownReflectionCompletionGuides: payload.shownReflectionCompletionGuides ?? {}, - reflectionScene2Choice: payload.reflectionScene2Choice ?? null, - }) + applyHuangyanpeiPersistedState(JSON.parse(result.content) as HuangyanpeiPersistedState) + return result.status } catch (error) { - useHuangyanpeiStore.setState({ - visitedExhibits: {}, - lastVisitedPlace: null, - reflectionRoute: 'reflection', - shownReflectionCompletionGuides: {}, - reflectionScene2Choice: null, - }) console.error('Failed to load Huangyanpei store', error) + return 'error' } } diff --git a/src/store/luxunStore.ts b/src/store/luxunStore.ts index a125867..2cbd6b4 100644 --- a/src/store/luxunStore.ts +++ b/src/store/luxunStore.ts @@ -1,7 +1,11 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { STORE_NAMES } from './storeNames' -import { setUserDefine } from '@/lib/utils/api-common' +import { + setUserDefine, + getUserDefineResult, + type UserDefineFetchStatus, +} from '@/lib/utils/api-common' interface LuxunStore { // 存储每个场景(place)的已访问展品ID集合 @@ -30,6 +34,9 @@ interface LuxunStore { // 重置所有访问记录 resetAll: () => void + + completedVideos: Record<string, boolean> + markVideoCompleted: (videoKey: string) => void } // 将 Set 转换为数组以便序列化 @@ -61,12 +68,31 @@ const deserializeVisitedExhibits = ( return visitedExhibits } +const resolveReflectionRoute = (route: unknown): string => + typeof route === 'string' && ['reflection', 'reflection2', 'reflection3'].includes(route) + ? route + : 'reflection' + +interface LuxunPersistedState { + visitedExhibits?: Record<string, number[]> + lastVisitedPlace?: string | null + reflectionRoute?: string + completedVideos?: Record<string, boolean> +} + export const useLuxunStore = create<LuxunStore>()( persist( (set, get) => ({ visitedExhibits: {}, reflectionRoute: 'reflection', lastVisitedPlace: null, + completedVideos: {}, + + markVideoCompleted: (videoKey: string) => { + set((state) => ({ + completedVideos: { ...state.completedVideos, [videoKey]: true }, + })) + }, markExhibitVisited: (place: string, exhibitId: number) => { set((state) => { @@ -101,7 +127,12 @@ export const useLuxunStore = create<LuxunStore>()( }, resetAll: () => { - set({ visitedExhibits: {} }) + set({ + visitedExhibits: {}, + lastVisitedPlace: null, + reflectionRoute: 'reflection', + completedVideos: {}, + }) }, setLastVisitedPlace: (place: string) => set({ lastVisitedPlace: place }), @@ -113,6 +144,7 @@ export const useLuxunStore = create<LuxunStore>()( visitedExhibits: serializeVisitedExhibits(state.visitedExhibits), lastVisitedPlace: state.lastVisitedPlace, reflectionRoute: state.reflectionRoute, + completedVideos: state.completedVideos, }), onRehydrateStorage: () => (state) => { if (state) { @@ -123,6 +155,9 @@ export const useLuxunStore = create<LuxunStore>()( if ((state as any).reflectionRoute === undefined) { ;(state as any).reflectionRoute = 'reflection' } + if (!state.completedVideos || typeof state.completedVideos !== 'object') { + state.completedVideos = {} + } } }, } @@ -140,6 +175,7 @@ export const saveLuxunStore = (): void => { visitedExhibits: serializeVisitedExhibits(state.visitedExhibits), lastVisitedPlace: state.lastVisitedPlace, reflectionRoute: state.reflectionRoute, + completedVideos: state.completedVideos, } try { @@ -148,3 +184,30 @@ export const saveLuxunStore = (): void => { console.error('Failed to save Luxun store', error) } } + +export const applyLuxunPersistedState = (payload: LuxunPersistedState): void => { + useLuxunStore.setState({ + visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits), + lastVisitedPlace: payload.lastVisitedPlace ?? null, + reflectionRoute: resolveReflectionRoute(payload.reflectionRoute), + completedVideos: payload.completedVideos ?? {}, + }) +} + +export const loadLuxunStore = async (): Promise<UserDefineFetchStatus> => { + try { + const result = await getUserDefineResult(LUXUN_LESSON_ID, LUXUN_KEY) + if (result.status !== 'success') { + if (result.status === 'empty') { + useLuxunStore.getState().resetAll() + } + return result.status + } + + applyLuxunPersistedState(JSON.parse(result.content) as LuxunPersistedState) + return result.status + } catch (error) { + console.error('Failed to load Luxun store', error) + return 'error' + } +} diff --git a/src/store/maozedongStore.ts b/src/store/maozedongStore.ts index e4cb11c..94bfbd9 100644 --- a/src/store/maozedongStore.ts +++ b/src/store/maozedongStore.ts @@ -1,7 +1,11 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { STORE_NAMES } from './storeNames' -import { setUserDefine } from '@/lib/utils/api-common' +import { + setUserDefine, + getUserDefineResult, + type UserDefineFetchStatus, +} from '@/lib/utils/api-common' interface MaozedongStore { // 每个场景(placeX)的已访问展品ID集合 @@ -21,6 +25,9 @@ interface MaozedongStore { resetPlace: (place: string) => void // 重置所有 resetAll: () => void + + completedVideos: Record<string, boolean> + markVideoCompleted: (videoKey: string) => void } const serializeVisited = (data: Record<string, Set<number>>): Record<string, number[]> => { @@ -38,14 +45,27 @@ const deserializeVisited = ( return out } +interface MaozedongPersistedState { + visitedExhibits?: Record<string, number[]> + lastVisitedPlace?: string | null + completedVideos?: Record<string, boolean> +} + export const useMaozedongStore = create<MaozedongStore>()( persist( (set, get) => ({ visitedExhibits: {}, lastVisitedPlace: null, + completedVideos: {}, setLastVisitedPlace: (place: string) => set({ lastVisitedPlace: place }), + markVideoCompleted: (videoKey: string) => { + set((state) => ({ + completedVideos: { ...state.completedVideos, [videoKey]: true }, + })) + }, + markExhibitVisited: (place: string, exhibitId: number) => { set((state) => { const current = state.visitedExhibits[place] || new Set<number>() @@ -77,18 +97,21 @@ export const useMaozedongStore = create<MaozedongStore>()( }) }, - resetAll: () => set({ visitedExhibits: {}, lastVisitedPlace: null }), + resetAll: () => set({ visitedExhibits: {}, lastVisitedPlace: null, completedVideos: {} }), }), { name: STORE_NAMES.MAOZEDONG, partialize: (state) => ({ visitedExhibits: serializeVisited(state.visitedExhibits), lastVisitedPlace: state.lastVisitedPlace, + completedVideos: state.completedVideos, }), onRehydrateStorage: () => (state) => { if (state) { state.visitedExhibits = deserializeVisited(state.visitedExhibits as any) if (state.lastVisitedPlace === undefined) state.lastVisitedPlace = null + if (!state.completedVideos || typeof state.completedVideos !== 'object') + state.completedVideos = {} } }, } @@ -104,6 +127,7 @@ export const saveMaozedongStore = (): void => { const payload = { visitedExhibits: serializeVisited(state.visitedExhibits), lastVisitedPlace: state.lastVisitedPlace, + completedVideos: state.completedVideos, } try { setUserDefine(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY, JSON.stringify(payload)) @@ -111,3 +135,29 @@ export const saveMaozedongStore = (): void => { console.error('Failed to save Maozedong store', error) } } + +export const applyMaozedongPersistedState = (payload: MaozedongPersistedState): void => { + useMaozedongStore.setState({ + visitedExhibits: deserializeVisited(payload.visitedExhibits), + lastVisitedPlace: payload.lastVisitedPlace ?? null, + completedVideos: payload.completedVideos ?? {}, + }) +} + +export const loadMaozedongStore = async (): Promise<UserDefineFetchStatus> => { + try { + const result = await getUserDefineResult(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY) + if (result.status !== 'success') { + if (result.status === 'empty') { + useMaozedongStore.getState().resetAll() + } + return result.status + } + + applyMaozedongPersistedState(JSON.parse(result.content) as MaozedongPersistedState) + return result.status + } catch (error) { + console.error('Failed to load Maozedong store', error) + return 'error' + } +} diff --git a/src/store/nieerStore.ts b/src/store/nieerStore.ts index fcf4b37..c094a0d 100644 --- a/src/store/nieerStore.ts +++ b/src/store/nieerStore.ts @@ -1,7 +1,11 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { STORE_NAMES } from './storeNames' -import { setUserDefine } from '@/lib/utils/api-common' +import { + setUserDefine, + getUserDefineResult, + type UserDefineFetchStatus, +} from '@/lib/utils/api-common' interface NieerStore { // 存储每个场景(place)的已访问展品ID集合 @@ -59,6 +63,9 @@ interface NieerStore { // 是否已经看过旅行箱提示 hasSeenBoxHint: boolean markBoxHintSeen: () => void + + completedVideos: Record<string, boolean> + markVideoCompleted: (videoKey: string) => void } // 将 Set 转换为数组以便序列化 @@ -90,10 +97,27 @@ const deserializeVisitedExhibits = ( return visitedExhibits } +interface NieerPersistedState { + visitedExhibits?: Record<string, number[]> + visitedReflectionCursors?: Record<string, number[]> + lastVisitedPlace?: string | null + reflectionWorks?: Record<number, Record<string, unknown>> + hasSeenReflectionGuide?: boolean + hasSeenBoxHint?: boolean + completedVideos?: Record<string, boolean> +} + export const useNieerStore = create<NieerStore>()( persist( (set, get) => ({ visitedExhibits: {}, + completedVideos: {}, + + markVideoCompleted: (videoKey: string) => { + set((state) => ({ + completedVideos: { ...state.completedVideos, [videoKey]: true }, + })) + }, markExhibitVisited: (place: string, exhibitId: number) => { set((state) => { @@ -128,7 +152,15 @@ export const useNieerStore = create<NieerStore>()( }, resetAll: () => { - set({ visitedExhibits: {} }) + set({ + visitedExhibits: {}, + lastVisitedPlace: null, + visitedReflectionCursors: {}, + reflectionWorks: {}, + hasSeenReflectionGuide: false, + hasSeenBoxHint: false, + completedVideos: {}, + }) }, lastVisitedPlace: null, @@ -210,6 +242,7 @@ export const useNieerStore = create<NieerStore>()( reflectionWorks: state.reflectionWorks, hasSeenReflectionGuide: state.hasSeenReflectionGuide, hasSeenBoxHint: state.hasSeenBoxHint, + completedVideos: state.completedVideos, }), onRehydrateStorage: () => (state) => { if (state) { @@ -229,6 +262,9 @@ export const useNieerStore = create<NieerStore>()( if ((state as any).hasSeenBoxHint === undefined) { ;(state as any).hasSeenBoxHint = false } + if (!state.completedVideos || typeof state.completedVideos !== 'object') { + state.completedVideos = {} + } } }, } @@ -243,7 +279,12 @@ export const saveNieerStore = (): void => { const state = useNieerStore.getState() const payload = { visitedExhibits: serializeVisitedExhibits(state.visitedExhibits), + visitedReflectionCursors: serializeVisitedExhibits(state.visitedReflectionCursors), lastVisitedPlace: state.lastVisitedPlace, + reflectionWorks: state.reflectionWorks, + hasSeenReflectionGuide: state.hasSeenReflectionGuide, + hasSeenBoxHint: state.hasSeenBoxHint, + completedVideos: state.completedVideos, } try { setUserDefine(NIEER_LESSON_ID, NIEER_KEY, JSON.stringify(payload)) @@ -251,3 +292,33 @@ export const saveNieerStore = (): void => { console.error('Failed to save Nieer store', error) } } + +export const applyNieerPersistedState = (payload: NieerPersistedState): void => { + useNieerStore.setState({ + visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits), + visitedReflectionCursors: deserializeVisitedExhibits(payload.visitedReflectionCursors), + lastVisitedPlace: payload.lastVisitedPlace ?? null, + reflectionWorks: payload.reflectionWorks ?? {}, + hasSeenReflectionGuide: payload.hasSeenReflectionGuide ?? false, + hasSeenBoxHint: payload.hasSeenBoxHint ?? false, + completedVideos: payload.completedVideos ?? {}, + }) +} + +export const loadNieerStore = async (): Promise<UserDefineFetchStatus> => { + try { + const result = await getUserDefineResult(NIEER_LESSON_ID, NIEER_KEY) + if (result.status !== 'success') { + if (result.status === 'empty') { + useNieerStore.getState().resetAll() + } + return result.status + } + + applyNieerPersistedState(JSON.parse(result.content) as NieerPersistedState) + return result.status + } catch (error) { + console.error('Failed to load Nieer store', error) + return 'error' + } +} diff --git a/src/store/puzzleStore.ts b/src/store/puzzleStore.ts index fc3cdbb..75160d7 100644 --- a/src/store/puzzleStore.ts +++ b/src/store/puzzleStore.ts @@ -1,7 +1,12 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { STORE_NAMES } from './storeNames' -import { setUserDefine } from '@/lib/utils/api-common' +import { + setUserDefine, + getUserDefineResult, + type StorageOptions, + type UserDefineFetchStatus, +} from '@/lib/utils/api-common' interface PuzzleStore { // 每个故居的已解锁碎片集合,格式:{ residence: Set<pieceId> } @@ -25,6 +30,9 @@ interface PuzzleStore { // 重置所有碎片 resetAll: () => void + // 应用持久化的拼图数据 + applyPersistedState: (payload: PuzzlePersistedState) => void + // 获取收集进度 getPuzzleCollectProgress: (residence: string) => number } @@ -56,6 +64,10 @@ export const deserializePuzzles = (serialized: Record<string, number[]> | undefi return puzzles } +interface PuzzlePersistedState { + puzzles?: Record<string, number[]> +} + // 辅助函数:确保 puzzles 中的值都是 Set const ensurePuzzlesAreSets = (puzzles: any): Record<string, Set<number>> => { const fixed: Record<string, Set<number>> = {} @@ -140,6 +152,10 @@ export const usePuzzleStore = create<PuzzleStore>()( resetAll: () => { set({ puzzles: {} }) }, + + applyPersistedState: (payload: PuzzlePersistedState) => { + set({ puzzles: deserializePuzzles(payload.puzzles) }) + }, }), { name: STORE_NAMES.PUZZLE, // localStorage key @@ -180,3 +196,25 @@ export const savePuzzleStore = (puzzles: Record<string, Set<number>>): void => { console.error('Failed to save Chenyun store', error) } } + +export const loadPuzzleStore = async ( + options: StorageOptions = {} +): Promise<UserDefineFetchStatus> => { + try { + const result = await getUserDefineResult(PUZZLE_LESSON_ID, PUZZLE_KEY, options) + if (result.status !== 'success') { + if (result.status === 'empty') { + usePuzzleStore.getState().resetAll() + } + return result.status + } + + usePuzzleStore + .getState() + .applyPersistedState(JSON.parse(result.content) as PuzzlePersistedState) + return result.status + } catch (error) { + console.error('Failed to load puzzle store', error) + return 'error' + } +} diff --git a/src/store/rongdeshengStore.ts b/src/store/rongdeshengStore.ts index 64cba4c..63d4789 100644 --- a/src/store/rongdeshengStore.ts +++ b/src/store/rongdeshengStore.ts @@ -1,7 +1,11 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { STORE_NAMES } from './storeNames' -import { setUserDefine } from '@/lib/utils/api-common' +import { + setUserDefine, + getUserDefineResult, + type UserDefineFetchStatus, +} from '@/lib/utils/api-common' interface RongdeshengStore { // 对话模块:每个 place 的已访问展品 ID 集合 @@ -45,6 +49,9 @@ interface RongdeshengStore { // 重置所有访问记录 resetAll: () => void + + completedVideos: Record<string, boolean> + markVideoCompleted: (videoKey: string) => void } const serializeVisitedExhibits = ( @@ -68,6 +75,16 @@ const deserializeVisitedExhibits = ( return visitedExhibits } +interface RongdeshengPersistedState { + visitedExhibits?: Record<string, number[]> + lastVisitedPlace?: string | null + reflectionCompleted?: Record<string, boolean> + reflectionVideoViewed?: Record<string, Record<string, boolean>> + reflectionFinalCompleted?: boolean + reflectionGuideShown?: boolean + completedVideos?: Record<string, boolean> +} + export const useRongdeshengStore = create<RongdeshengStore>()( persist( (set, get) => ({ @@ -77,9 +94,16 @@ export const useRongdeshengStore = create<RongdeshengStore>()( reflectionVideoViewed: {}, reflectionFinalCompleted: false, reflectionGuideShown: false, + completedVideos: {}, setLastVisitedPlace: (place: string) => set({ lastVisitedPlace: place }), + markVideoCompleted: (videoKey: string) => { + set((state) => ({ + completedVideos: { ...state.completedVideos, [videoKey]: true }, + })) + }, + markReflectionVideoViewed: (sceneId: string, videoId: string) => { set((state) => ({ reflectionVideoViewed: { @@ -155,6 +179,7 @@ export const useRongdeshengStore = create<RongdeshengStore>()( reflectionVideoViewed: {}, reflectionFinalCompleted: false, reflectionGuideShown: false, + completedVideos: {}, }), }), { @@ -166,6 +191,7 @@ export const useRongdeshengStore = create<RongdeshengStore>()( reflectionVideoViewed: state.reflectionVideoViewed, reflectionFinalCompleted: state.reflectionFinalCompleted, reflectionGuideShown: state.reflectionGuideShown, + completedVideos: state.completedVideos, }), onRehydrateStorage: () => (state) => { if (!state) return @@ -183,6 +209,9 @@ export const useRongdeshengStore = create<RongdeshengStore>()( if (typeof state.reflectionGuideShown !== 'boolean') { state.reflectionGuideShown = false } + if (!state.completedVideos || typeof state.completedVideos !== 'object') { + state.completedVideos = {} + } }, } ) @@ -201,6 +230,7 @@ export const saveRongdeshengStore = (): void => { reflectionVideoViewed: state.reflectionVideoViewed, reflectionFinalCompleted: state.reflectionFinalCompleted, reflectionGuideShown: state.reflectionGuideShown, + completedVideos: state.completedVideos, } try { setUserDefine(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY, JSON.stringify(payload)) @@ -208,3 +238,33 @@ export const saveRongdeshengStore = (): void => { console.error('Failed to save Rongdesheng store', error) } } + +export const applyRongdeshengPersistedState = (payload: RongdeshengPersistedState): void => { + useRongdeshengStore.setState({ + visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits), + lastVisitedPlace: payload.lastVisitedPlace ?? null, + reflectionCompleted: payload.reflectionCompleted ?? {}, + reflectionVideoViewed: payload.reflectionVideoViewed ?? {}, + reflectionFinalCompleted: payload.reflectionFinalCompleted ?? false, + reflectionGuideShown: payload.reflectionGuideShown ?? false, + completedVideos: payload.completedVideos ?? {}, + }) +} + +export const loadRongdeshengStore = async (): Promise<UserDefineFetchStatus> => { + try { + const result = await getUserDefineResult(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY) + if (result.status !== 'success') { + if (result.status === 'empty') { + useRongdeshengStore.getState().resetAll() + } + return result.status + } + + applyRongdeshengPersistedState(JSON.parse(result.content) as RongdeshengPersistedState) + return result.status + } catch (error) { + console.error('Failed to load Rongdesheng store', error) + return 'error' + } +} diff --git a/src/store/sunzhongshanStore.ts b/src/store/sunzhongshanStore.ts index 30048cb..a6e5c82 100644 --- a/src/store/sunzhongshanStore.ts +++ b/src/store/sunzhongshanStore.ts @@ -1,7 +1,11 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { STORE_NAMES } from './storeNames' -import { setUserDefine } from '@/lib/utils/api-common' +import { + setUserDefine, + getUserDefineResult, + type UserDefineFetchStatus, +} from '@/lib/utils/api-common' export interface Clue { id: string @@ -53,6 +57,9 @@ interface SunzhongshanStore { // 获取体悟模块布尔状态 getReflectionFlag: (key: string) => boolean + + completedVideos: Record<string, boolean> + markVideoCompleted: (videoKey: string) => void } // 将 Set 转换为数组以便序列化 @@ -84,6 +91,21 @@ const deserializeVisitedExhibits = ( return visitedExhibits } +const resolveReflectionRoute = (route: unknown): string => + typeof route === 'string' && + ['reflection', 'reflection1', 'reflection2', 'reflection3'].includes(route) + ? route + : 'reflection' + +interface SunzhongshanPersistedState { + visitedExhibits?: Record<string, number[]> + reflectionFlags?: Record<string, boolean> + lastVisitedPlace?: string | null + reflectionRoute?: string + clues?: Clue[] + completedVideos?: Record<string, boolean> +} + export const useSunzhongshanStore = create<SunzhongshanStore>()( persist( (set, get) => ({ @@ -92,6 +114,13 @@ export const useSunzhongshanStore = create<SunzhongshanStore>()( lastVisitedPlace: null, reflectionRoute: 'reflection', clues: [], + completedVideos: {}, + + markVideoCompleted: (videoKey: string) => { + set((state) => ({ + completedVideos: { ...state.completedVideos, [videoKey]: true }, + })) + }, markExhibitVisited: (place: string, exhibitId: number) => { set((state) => { @@ -132,6 +161,7 @@ export const useSunzhongshanStore = create<SunzhongshanStore>()( lastVisitedPlace: null, reflectionRoute: 'reflection', clues: [], + completedVideos: {}, }) }, @@ -202,6 +232,7 @@ export const useSunzhongshanStore = create<SunzhongshanStore>()( lastVisitedPlace: state.lastVisitedPlace, reflectionRoute: state.reflectionRoute, clues: state.clues, + completedVideos: state.completedVideos, }), onRehydrateStorage: () => (state) => { if (state) { @@ -218,6 +249,9 @@ export const useSunzhongshanStore = create<SunzhongshanStore>()( if (!(state as any).clues || !Array.isArray((state as any).clues)) { ;(state as any).clues = [] } + if (!state.completedVideos || typeof state.completedVideos !== 'object') { + state.completedVideos = {} + } } }, } @@ -233,8 +267,11 @@ export const saveSunzhongshanStore = (): void => { const payload = { visitedExhibits: serializeVisitedExhibits(state.visitedExhibits), + reflectionFlags: state.reflectionFlags, lastVisitedPlace: state.lastVisitedPlace, reflectionRoute: state.reflectionRoute, + clues: state.clues, + completedVideos: state.completedVideos, } try { @@ -243,3 +280,32 @@ export const saveSunzhongshanStore = (): void => { console.error('Failed to save Sunzhongshan store', error) } } + +export const applySunzhongshanPersistedState = (payload: SunzhongshanPersistedState): void => { + useSunzhongshanStore.setState({ + visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits), + reflectionFlags: payload.reflectionFlags ?? {}, + lastVisitedPlace: payload.lastVisitedPlace ?? null, + reflectionRoute: resolveReflectionRoute(payload.reflectionRoute), + clues: Array.isArray(payload.clues) ? payload.clues : [], + completedVideos: payload.completedVideos ?? {}, + }) +} + +export const loadSunzhongshanStore = async (): Promise<UserDefineFetchStatus> => { + try { + const result = await getUserDefineResult(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY) + if (result.status !== 'success') { + if (result.status === 'empty') { + useSunzhongshanStore.getState().resetAll() + } + return result.status + } + + applySunzhongshanPersistedState(JSON.parse(result.content) as SunzhongshanPersistedState) + return result.status + } catch (error) { + console.error('Failed to load Sunzhongshan store', error) + return 'error' + } +} diff --git a/src/store/userStore.ts b/src/store/userStore.ts index c1f675f..8bf027e 100644 --- a/src/store/userStore.ts +++ b/src/store/userStore.ts @@ -2,11 +2,15 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { STORE_NAMES } from './storeNames' +export type YibanConnectionStatus = 'unknown' | 'connected' | 'disconnected' + interface UserStore { courseId: string | null uid: string | null + yibanConnectionStatus: YibanConnectionStatus setCourseInfo: (info: { courseId?: string | null; uid?: string | null }) => void + setYibanConnectionStatus: (status: YibanConnectionStatus) => void resetCourseInfo: () => void } @@ -15,6 +19,7 @@ export const useUserStore = create<UserStore>()( (set) => ({ courseId: null, uid: null, + yibanConnectionStatus: 'unknown', setCourseInfo: (info) => { set((state) => ({ @@ -23,12 +28,20 @@ export const useUserStore = create<UserStore>()( })) }, + setYibanConnectionStatus: (status) => { + set({ yibanConnectionStatus: status }) + }, + resetCourseInfo: () => { - set({ courseId: null, uid: null }) + set({ courseId: null, uid: null, yibanConnectionStatus: 'unknown' }) }, }), { name: STORE_NAMES.USER, + partialize: (state) => ({ + courseId: state.courseId, + uid: state.uid, + }), } ) ) diff --git a/src/store/zhanglepingStore.ts b/src/store/zhanglepingStore.ts index 41c3938..0bbfb62 100644 --- a/src/store/zhanglepingStore.ts +++ b/src/store/zhanglepingStore.ts @@ -1,7 +1,11 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { STORE_NAMES } from './storeNames' -import { setUserDefine } from '@/lib/utils/api-common' +import { + setUserDefine, + getUserDefineResult, + type UserDefineFetchStatus, +} from '@/lib/utils/api-common' interface ZhanglepingStore { // ========= 对话模块:展品访问进度/断点 ========= @@ -13,6 +17,7 @@ interface ZhanglepingStore { getVisitedExhibits: (place: string) => number[] resetPlace: (place: string) => void resetAllDialog: () => void + resetAll: () => void // 体悟模块当前路由(reflection / reflection1 / ... / reflection5) reflectionRoute: string @@ -38,6 +43,9 @@ interface ZhanglepingStore { // 是否已经看过学习引导窗口 hasSeenStudyGuide: boolean markStudyGuideSeen: () => void + + completedVideos: Record<string, boolean> + markVideoCompleted: (videoKey: string) => void } // 将 Set 转换为数组以便序列化(体悟) @@ -61,6 +69,31 @@ const deserializeVisited = ( return visited } +const resolveReflectionRoute = (route: unknown): string => + typeof route === 'string' && + [ + 'reflection', + 'reflection1', + 'reflection2', + 'reflection3', + 'reflection4', + 'reflection5', + ].includes(route) + ? route + : 'reflection' + +interface ZhanglepingPersistedState { + visitedExhibits?: Record<string, number[]> + lastVisitedPlace?: string | null + reflectionRoute?: string + visitedReflectionCursors?: Record<string, number[]> + drawArtworkDataUrl?: string + drawArtworkCaption?: string + hasDrawArtwork?: boolean + hasSeenStudyGuide?: boolean + completedVideos?: Record<string, boolean> +} + export const useZhanglepingStore = create<ZhanglepingStore>()( persist( (set, get) => ({ @@ -72,6 +105,13 @@ export const useZhanglepingStore = create<ZhanglepingStore>()( drawArtworkCaption: '', hasDrawArtwork: false, hasSeenStudyGuide: false, + completedVideos: {}, + + markVideoCompleted: (videoKey: string) => { + set((state) => ({ + completedVideos: { ...state.completedVideos, [videoKey]: true }, + })) + }, markExhibitVisited: (place: string, exhibitId: number) => { set((state) => { @@ -101,6 +141,18 @@ export const useZhanglepingStore = create<ZhanglepingStore>()( }) }, resetAllDialog: () => set({ visitedExhibits: {}, lastVisitedPlace: null }), + resetAll: () => + set({ + visitedExhibits: {}, + lastVisitedPlace: null, + reflectionRoute: 'reflection', + visitedReflectionCursors: {}, + drawArtworkDataUrl: '', + drawArtworkCaption: '', + hasDrawArtwork: false, + hasSeenStudyGuide: false, + completedVideos: {}, + }), setLastVisitedPlace: (place: string) => set({ lastVisitedPlace: place }), setReflectionRoute: (route: string) => set({ reflectionRoute: route }), @@ -150,6 +202,7 @@ export const useZhanglepingStore = create<ZhanglepingStore>()( drawArtworkCaption: state.drawArtworkCaption, hasDrawArtwork: state.hasDrawArtwork, hasSeenStudyGuide: state.hasSeenStudyGuide, + completedVideos: state.completedVideos, }), onRehydrateStorage: () => (state) => { if (!state) return @@ -173,6 +226,9 @@ export const useZhanglepingStore = create<ZhanglepingStore>()( if ((state as any).hasSeenStudyGuide === undefined) { ;(state as any).hasSeenStudyGuide = false } + if (!state.completedVideos || typeof state.completedVideos !== 'object') { + state.completedVideos = {} + } }, } ) @@ -199,6 +255,8 @@ export const saveZhanglepingStore = (): void => { drawArtworkDataUrl: remoteArtworkDataUrl, drawArtworkCaption: state.drawArtworkCaption, hasDrawArtwork: state.hasDrawArtwork, + hasSeenStudyGuide: state.hasSeenStudyGuide, + completedVideos: state.completedVideos, } try { @@ -207,3 +265,35 @@ export const saveZhanglepingStore = (): void => { console.error('Failed to save Zhangleping store', error) } } + +export const applyZhanglepingPersistedState = (payload: ZhanglepingPersistedState): void => { + useZhanglepingStore.setState({ + visitedExhibits: deserializeVisited(payload.visitedExhibits), + lastVisitedPlace: payload.lastVisitedPlace ?? null, + reflectionRoute: resolveReflectionRoute(payload.reflectionRoute), + visitedReflectionCursors: deserializeVisited(payload.visitedReflectionCursors), + drawArtworkDataUrl: payload.drawArtworkDataUrl ?? '', + drawArtworkCaption: payload.drawArtworkCaption ?? '', + hasDrawArtwork: payload.hasDrawArtwork ?? false, + hasSeenStudyGuide: payload.hasSeenStudyGuide ?? false, + completedVideos: payload.completedVideos ?? {}, + }) +} + +export const loadZhanglepingStore = async (): Promise<UserDefineFetchStatus> => { + try { + const result = await getUserDefineResult(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY) + if (result.status !== 'success') { + if (result.status === 'empty') { + useZhanglepingStore.getState().resetAll() + } + return result.status + } + + applyZhanglepingPersistedState(JSON.parse(result.content) as ZhanglepingPersistedState) + return result.status + } catch (error) { + console.error('Failed to load Zhangleping store', error) + return 'error' + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 3c864c1..c094fbe 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_DEBUG_ENTRY?: string + readonly VITE_DISABLE_FIRST_WATCH_VIDEO_GATING?: string } interface ImportMeta { diff --git a/types/api-common.d.ts b/types/api-common.d.ts index 3c51678..938671d 100644 --- a/types/api-common.d.ts +++ b/types/api-common.d.ts @@ -1,5 +1,10 @@ declare module '@/lib/utils/api-common' { export type UserDefineFetchStatus = 'success' | 'empty' | 'unauthenticated' | 'error' + export type StorageBackend = 'auto' | 'yiban' | 'session' + + export interface StorageOptions { + backend?: StorageBackend + } export interface UserDefineFetchResult { status: UserDefineFetchStatus @@ -11,20 +16,26 @@ declare module '@/lib/utils/api-common' { export function setUserDefine( lessonId: string | number, key: string | number, - value: string + value: string, + options?: StorageOptions ): void export function getUserDefineResult( lessonId: string | number, - key: string | number + key: string | number, + options?: StorageOptions ): Promise<UserDefineFetchResult> - export function getUserDefine(lessonId: string | number, key: string | number): Promise<string> + export function getUserDefine( + lessonId: string | number, + key: string | number, + options?: StorageOptions + ): Promise<string> export function getUsersDefine( lessonId: string | number, key: string | number ): Promise<string | { content?: string }[]> - export function setCourseTimeProgress(order: number): Promise<void> + export function setCourseTimeProgress(order: number): Promise<boolean> }