chore: 更新素材和组件优化

- 更新展品图片(鲁迅/毛泽东/孙中山/黄炎培/聂耳/张乐平故居)
- 新增点击查看图标、报纸素材
- 优化视频组件、拼图组件、展品组件
- 更新陈云音频资源
- 新增输入验证文档和工具函数
- 组件布局和动画优化

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.8 committed 2026-06-21 21:50:40 +08:00
1 parent 73d02eb62d
commit 73f6df92ce
130 files changed
+2345 -759

No files matched your search

+1
View File
@@ -1 +1,2 @@
VITE_DEBUG_ENTRY=true
VITE_DISABLE_FIRST_WATCH_VIDEO_GATING=true
+128
View File
@@ -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. 所有标点符号(包括中文标点)都会被过滤
Binary file not shown.
+16 -32
View File
@@ -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<string, number[]> }
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() {
}}
>
<Suspense fallback={<div>Loading...</div>}>
<RouterProvider router={router} />
{bootstrapDone && <RouterProvider router={router} />}
<HintHost />
<SyncPromptHost />
<SyncLoadingHost />
</Suspense>
<SyncLoadingHost visible={!bootstrapDone} />
</div>
</div>
<RotateHint />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1003 KiB

After

Width:  |  Height:  |  Size: 907 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 KiB

After

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 KiB

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 841 KiB

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 686 KiB

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 445 KiB

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 513 KiB

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1011 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 KiB

After

Width:  |  Height:  |  Size: 1023 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 736 KiB

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 KiB

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 KiB

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 661 KiB

After

Width:  |  Height:  |  Size: 797 KiB

@@ -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})`
}
>
<div dangerouslySetInnerHTML={{ __html: elementHtml }} style={{ pointerEvents: 'none' }} />
<div
dangerouslySetInnerHTML={{ __html: elementHtml }}
style={{ pointerEvents: 'none', width: '100%', height: '100%', position: 'relative' }}
/>
</motion.div>
)
}
+101 -103
View File
@@ -89,139 +89,137 @@ const ExhibitFlipOverlay = ({
if (!mounted || !visible) return null
return (
<div style={OVERLAY_STYLE}>
<div
className={cn('absolute inset-0 flex items-center justify-center', className)}
style={{ pointerEvents: 'auto' }}
>
{/* 第二段:背景与按钮在展品入场后再出现 */}
{showChrome && (
<>
<div
className={cn('absolute inset-0 flex items-center justify-center z-9999', className)}
style={{ pointerEvents: 'auto' }}
>
{/* 第二段:背景与按钮在展品入场后再出现 */}
{showChrome && (
<>
<motion.div
className="absolute inset-0 bg-black/70"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
/>
{onClose && (
<motion.div
className="absolute inset-0 bg-black/70"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute top-5 right-5 z-20"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
/>
>
<Image
src={backSrc}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
alt="返回"
button
/>
</motion.div>
)}
</>
)}
{onClose && (
<motion.div
className="absolute top-5 right-5 z-20"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
<Image
src={backSrc}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
alt="返回"
button
/>
</motion.div>
)}
</>
)}
<div
className="relative z-10 w-[900px] h-[900px] max-w-[90%] max-h-[90%] flex items-center justify-center select-none"
<div
className="relative z-10 w-[900px] h-[900px] max-w-[90%] max-h-[90%] flex items-center justify-center select-none"
style={{
perspective: 1200,
WebkitPerspective: '1200px',
}}
>
{/* 第一段:像“获得装备”一样先把展品弹出 */}
<motion.div
className={cn(
'relative w-full h-full',
'transition-transform duration-500 ease-[cubic-bezier(0.2,0.8,0.2,1)]'
)}
initial={{ scale: 0.25, opacity: 0, rotateY: 0 }}
animate={{ scale: displayScale, opacity: 1, rotateY: flipped ? 180 : 0 }}
transition={{
type: 'spring',
stiffness: 520,
damping: 26,
mass: 0.8,
}}
onAnimationComplete={() => {
if (!showChrome) {
setShowChrome(true)
onEnterComplete?.()
}
}}
style={{
perspective: 1200,
WebkitPerspective: '1200px',
transformStyle: 'preserve-3d',
WebkitTransformStyle: 'preserve-3d',
}}
>
{/* 第一段:像“获得装备”一样先把展品弹出 */}
<motion.div
className={cn(
'relative w-full h-full',
'transition-transform duration-500 ease-[cubic-bezier(0.2,0.8,0.2,1)]'
)}
initial={{ scale: 0.25, opacity: 0, rotateY: 0 }}
animate={{ scale: displayScale, opacity: 1, rotateY: flipped ? 180 : 0 }}
transition={{
type: 'spring',
stiffness: 520,
damping: 26,
mass: 0.8,
}}
onAnimationComplete={() => {
if (!showChrome) {
setShowChrome(true)
onEnterComplete?.()
}
}}
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transformStyle: 'preserve-3d',
WebkitTransformStyle: 'preserve-3d',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
}}
>
<Image
src={frontImageSrc}
alt="展品"
className="max-w-full max-h-full object-contain drop-shadow-2xl"
/>
</div>
{canFlip && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'rotateY(180deg)',
}}
>
<Image
src={frontImageSrc}
alt="展品"
src={backImageSrc!}
alt="展品背面"
className="max-w-full max-h-full object-contain drop-shadow-2xl"
/>
</div>
{canFlip && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'rotateY(180deg)',
}}
>
<Image
src={backImageSrc!}
alt="展品背面"
className="max-w-full max-h-full object-contain drop-shadow-2xl"
/>
</div>
)}
</motion.div>
{showChrome && canFlip && (
<motion.div
className="absolute bottom-50 right-0 z-20"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
<FlipActionButton
onClick={(e) => {
e.stopPropagation()
setFlipped((v) => !v)
}}
>
翻转一下
</FlipActionButton>
</motion.div>
)}
</div>
</motion.div>
{showChrome && footer && (
{showChrome && canFlip && (
<motion.div
className={cn(
'absolute left-1/2 bottom-38 z-30 -translate-x-1/2 pointer-events-auto',
footerClassName
)}
className="absolute bottom-50 right-0 z-20"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
{footer}
<FlipActionButton
onClick={(e) => {
e.stopPropagation()
setFlipped((v) => !v)
}}
>
翻转一下
</FlipActionButton>
</motion.div>
)}
</div>
{showChrome && footer && (
<motion.div
className={cn(
'absolute left-1/2 bottom-38 z-30 -translate-x-1/2 pointer-events-auto',
footerClassName
)}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
{footer}
</motion.div>
)}
</div>
)
}
+1 -1
View File
@@ -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 }}
+13 -3
View File
@@ -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 (
<AnimatePresence>
{visible && (
{actualVisible && (
<motion.div
key="sync-loading"
className="fixed inset-0 z-10000 flex items-center justify-center bg-black/60"
@@ -63,7 +73,7 @@ export const SyncLoadingHost = () => {
/>
</svg>
<div className="text-3xl font-semibold tracking-[6px] text-[#f4e4bd] drop-shadow-[0_2px_8px_rgba(0,0,0,0.7)]">
{text}
{actualText}
</div>
</motion.div>
</motion.div>
+67 -25
View File
@@ -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<number | null>(null)
const [isAnimationPlaying, setIsAnimationPlaying] = useState(false)
const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行
const [isAwaitingCollect, setIsAwaitingCollect] = useState(false)
const [isCollectConfirming, setIsCollectConfirming] = useState(false)
const puzzlePieceRef = useRef<HTMLDivElement>(null)
const animationInProgressRef = useRef<boolean>(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 = () => {
}}
>
<Image src={getStaticImage('components/拼图盒')} button />
<div className="absolute inset-0 flex items-center justify-center pb-8 pointer-events-none text-white text-[36px] font-bold leading-none drop-shadow-[0_2px_2px_rgba(0,0,0,0.8)]">
{residenceProgress}%
</div>
</div>
)}
</div>
@@ -382,6 +417,9 @@ const ResidenceLayoutContent = () => {
showSkipButton={true}
skipText="跳过"
onSkip={handleSkip}
skipKey={isPlace1Route && residence ? `${residence}_place1_entry` : undefined}
showReadonlyProgress={isPlace1Route}
initiallyCompleted={isPlace1Route ? place1VideoCompleted : undefined}
/>
</div>
)}
@@ -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(
<div
@@ -160,7 +160,7 @@ const SelectFillQuizPanel = ({
}}
>
{value || '____'}
{value && !disabled && (
{/* {value && !disabled && (
<span
className="ml-3 text-[22px] text-[#f0be75]/80"
onClick={(e) => {
@@ -170,7 +170,7 @@ const SelectFillQuizPanel = ({
>
×
</span>
)}
)} */}
</button>
{blank.suffix && <span>{blank.suffix}</span>}
</span>
+75 -4
View File
@@ -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<VideoRef>(null)
const replyVideoRef = useRef<VideoRef>(null)
const audioRef = useRef<HTMLAudioElement | null>(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))
}}
/>
<span className="text-[26px] leading-[1.8]">{currentDate}</span>
</div>
@@ -393,15 +446,33 @@ const LetterTemplate = ({
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{partIndex === 0 ? `${linePrefix}${part}` : part}
<style>{`.blank-input::placeholder { color: #999999; font-size: 26px; }`}</style>
<input
name={`blank-${currentIndex}`}
className={`inline-block h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-center outline-none ${field.widthClassName || 'w-[100px]'} ${field.className || ''} ${blankErrors[currentIndex] ? 'border-red-500' : 'border-[#4e402e]'}`}
className={`blank-input inline-block h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-center outline-none ${field.widthClassName || 'w-[100px]'} ${field.className || ''} ${blankErrors[currentIndex] ? 'border-red-500' : 'border-[#4e402e]'}`}
style={{ fontSize: field.fontSize ? `${field.fontSize}px` : '26px' }}
placeholder={field.placeholder || ''}
autoComplete="off"
value={fieldValue}
maxLength={field.maxLength ?? 10}
onChange={(e) => 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)
}
}}
/>
</span>
)
+3 -2
View File
@@ -17,7 +17,7 @@ const letterContent = `敬爱的陈云爷爷:
我是____专业的学生。今天,我怀着崇敬又激动的心情,参观了位于上海市青浦区练塘镇的陈云纪念馆,学习了您“不唯上、不唯书、只唯____”的做事准则,还有“综合平衡”“人民至上”的经济思想。我深刻体会到,这些精神不仅是建设年代的智慧结晶,更是新时代指引我们前行的思想灯塔。
作为新时代学生,我会在专业学习中____,像您当年练珠算、搞调查那样精益求精;未来无论从事什么工作,都要把服务群众、____放在首位;面对困难时不退缩,像您当年稳定物价、推进实业那样,用____本领(结合所学专业)为国家发展贡献自己的力量!
作为新时代学生,我会在专业学习中____,像您当年练珠算、搞调查那样精益求精;未来无论从事什么工作,都要把服务群众、____放在首位;面对困难时不退缩,像您当年稳定物价、推进实业那样,用____本领为国家发展贡献自己的力量!
站在新时代的起点,我一定以____为志向,勤学苦练,传承精神,让您的务实担当在青春里扎根,让奋斗在建设祖国的实践中绽放光彩!`
@@ -47,7 +47,8 @@ const fields: Record<number, LetterFieldConfig> = {
3: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
widthClassName: 'w-[180px]',
placeholder: '结合所学专业',
readOnlyFallback: '专业',
},
4: {
+3 -2
View File
@@ -30,7 +30,7 @@ const letterContent = `敬爱的毛泽东主席:
我是____专业的学生。今天,我怀着崇敬又激动的心情,参观了位于茂名北路120弄甲秀里的上海毛泽东旧居陈列馆,学习了毛泽东思想活的灵魂——____、____、____。我深刻体会到,这十二个字不仅是革命年代的智慧结晶,更是新时代指引我们前行的思想灯塔。
作为中职生,我深知“____”要求我们在专业学习中脚踏实地,掌握扎实技能;“____”提醒我们,未来无论从事什么职业,都要以服务社会、贡献他人为己任;而“独立自主”则激励我们,在技术革新中____,像C919大飞机团队那样,用____本领(结合所学专业)为推进中国式现代化贡献自己的力量!
作为中职生,我深知“____”要求我们在专业学习中脚踏实地,掌握扎实技能;“____”提醒我们,未来无论从事什么职业,都要以服务社会、贡献他人为己任;而“独立自主”则激励我们,在技术革新中____,像C919大飞机团队那样,用____本领为推进中国式现代化贡献自己的力量!
站在新时代的起点,我一定以____为志向,勤学苦练,精益求精,让____精神在手中传承,让青春在建设祖国的实践中绽放光彩!`
@@ -98,7 +98,8 @@ const fields: Record<number, LetterFieldConfig> = {
6: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
widthClassName: 'w-[180px]',
placeholder: '结合所学专业',
readOnlyFallback: '勇于突破',
},
7: {
+9 -3
View File
@@ -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<HTMLDivElement, PuzzlePieceDisplayProps>(
({ residence, pieceId, showGlow = true, className = '' }, ref) => {
({ residence, pieceId, showGlow = true, fitContain = false, className = '' }, ref) => {
// 碎片图片路径
const pieceImage = getStaticImage(`puzzle/${residence}/${pieceId}`)
return (
<div
ref={ref}
className={`relative inline-block ${className}`}
className={`relative ${fitContain ? 'h-full w-full overflow-visible' : 'inline-block'} ${className}`}
style={{
animation: showGlow ? 'puzzleGlow 1s ease-in-out infinite' : 'none',
}}
@@ -41,7 +43,11 @@ const PuzzlePieceDisplay = forwardRef<HTMLDivElement, PuzzlePieceDisplayProps>(
}
}
`}</style>
<img src={pieceImage} alt={`碎片 ${pieceId}`} className="w-full" />
<img
src={pieceImage}
alt={`碎片 ${pieceId}`}
className={fitContain ? 'absolute inset-0 block h-full w-full object-contain' : 'w-full'}
/>
</div>
)
}
+143 -67
View File
@@ -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<HTMLDivElement, PuzzlePiecePortalProps>(
({ 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(
<div
@@ -35,69 +44,136 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
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',
}}
>
<motion.div
ref={ref}
{awaitingCollect && !hidden && !confirming && (
<motion.div
className="absolute left-1/2 top-0 -translate-x-1/2 -z-10"
style={{
width: 'min(440px, 76vw)',
height: '320px',
background:
'radial-gradient(ellipse 58% 46% at 50% 38%, rgba(255,214,133,0.16) 0%, rgba(255,185,94,0.07) 45%, transparent 76%)',
willChange: 'opacity, transform',
}}
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.42, ease: [0.22, 1, 0.36, 1] }}
/>
)}
<div
className="absolute left-1/2 top-0 -translate-x-1/2"
style={{
width: '100%',
height: '100%',
transformStyle: 'preserve-3d', // 保持3D变换
}}
initial={
isAnimating
? {
scale: 0,
rotateY: -180,
opacity: 0,
}
: false
}
animate={
hidden
? { opacity: 0, visibility: 'hidden' as const }
: isAnimating
? {
scale: 1,
rotateY: 0,
opacity: 1,
visibility: 'visible' as const,
}
: {
opacity: 1,
visibility: 'visible' as const,
}
}
transition={{
duration: 0.8, // 稍微延长动画时间,让效果更舒缓
ease: [0.25, 0.46, 0.45, 0.94], // 平滑的缓动函数,缓缓出现
scale: {
duration: 0.8,
ease: [0.25, 0.46, 0.45, 0.94],
},
rotateY: {
duration: 0.8,
ease: [0.25, 0.46, 0.45, 0.94],
},
opacity: {
duration: 0.6,
ease: 'easeOut',
},
width: 'clamp(240px, 24vw, 300px)',
height: 'clamp(190px, 20vw, 240px)',
}}
>
<PuzzlePieceDisplay
residence={residence}
pieceId={pieceId}
showGlow={true}
className="w-full h-full"
/>
</motion.div>
<motion.div
ref={ref}
style={{
width: '100%',
height: '100%',
transformStyle: 'preserve-3d',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
willChange: 'transform, opacity',
}}
initial={
isAnimating
? {
scale: 0,
rotateY: -180,
opacity: 0,
y: 0,
}
: false
}
animate={
hidden
? { opacity: 0, visibility: 'hidden' as const }
: confirming
? {
scale: 1.035,
rotateY: 0,
opacity: 1,
y: 0,
visibility: 'visible' as const,
}
: {
scale: 1,
rotateY: 0,
opacity: 1,
y: 0,
visibility: 'visible' as const,
}
}
transition={
confirming
? {
duration: 0.18,
ease: [0.22, 1, 0.36, 1],
}
: {
duration: 0.82,
ease: [0.2, 0.72, 0.18, 1],
scale: {
duration: 0.82,
ease: [0.2, 0.72, 0.18, 1],
},
rotateY: {
duration: 0.82,
ease: [0.25, 0.46, 0.45, 0.94],
},
opacity: {
duration: 0.55,
ease: 'easeOut',
},
}
}
>
<PuzzlePieceDisplay
residence={residence}
pieceId={pieceId}
showGlow={isAnimating || awaitingCollect}
fitContain={true}
className="w-full h-full"
/>
</motion.div>
</div>
{awaitingCollect && !hidden && (
<motion.div
className="absolute left-0 right-0 top-[clamp(218px,25vw,276px)] flex flex-col items-center gap-3 pointer-events-none"
initial={{ opacity: 0, y: 10 }}
animate={confirming ? { opacity: 0, y: 6 } : { opacity: 1, y: 0 }}
transition={{ duration: confirming ? 0.16 : 0.36, ease: [0.22, 1, 0.36, 1] }}
style={{ willChange: 'transform, opacity' }}
>
{puzzlePieceTitle && (
<div className="max-w-[480px] rounded-2xl border border-[#f6d58b]/45 bg-[#2a1608]/70 px-6 py-2.5 text-center text-2xl font-bold leading-snug text-[#fff4d0] shadow-[0_6px_20px_rgba(0,0,0,0.22)] [text-shadow:0_2px_0_#5a2a00,0_0_14px_rgba(255,224,150,0.75)]">
{puzzlePieceTitle.split('\n').map((line) => (
<div key={line}>{line}</div>
))}
</div>
)}
<FlipActionButton
className="pointer-events-auto border border-[#ffe6a6]/55 bg-linear-to-b from-[#bd8b50] to-[#84512b] px-4 py-1.5 text-base shadow-[0_3px_10px_rgba(0,0,0,0.2)] hover:brightness-105 active:scale-[0.97]"
disabled={confirming}
onClick={onCollect}
>
收下
</FlipActionButton>
</motion.div>
)}
</div>,
document.body
)
+1 -1
View File
@@ -26,7 +26,7 @@ const Title = ({ title, subtitle, className, subtitleClassName }: TitleProps) =>
>
<div
className={cn(
'absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold whitespace-nowrap',
'absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold whitespace-pre',
'[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]'
)}
>
+95 -3
View File
@@ -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<VideoRef, VideoProps>(
skipText = '跳过',
onSkip,
skipButtonClassName,
skipKey,
initiallyCompleted,
onVideoCompleted,
controlsOnReplay,
showReadonlyProgress,
controls = false, // 默认不使用原生控制条
...props
},
@@ -59,6 +69,11 @@ const Video = forwardRef<VideoRef, VideoProps>(
const videoRef = useRef<HTMLVideoElement>(null)
const controlsRef = useRef<HTMLDivElement>(null)
const progressRef = useRef<HTMLDivElement>(null)
const readonlyProgressRef = useRef<HTMLDivElement>(null)
const readonlyProgressRafRef = useRef<number | null>(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<VideoRef, VideoProps>(
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<VideoRef, VideoProps>(
})
}, [])
// 计算只读进度百分比(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<VideoRef, VideoProps>(
const handleEnded: React.VideoHTMLAttributes<HTMLVideoElement>['onEnded'] = (e) => {
setIsPlaying(false)
if (skipKey) {
setIsVideoCompleted(true)
markVideoCompletedByResidence(residence, skipKey)
onVideoCompleted?.()
}
props.onEnded?.(e)
}
@@ -400,6 +436,16 @@ const Video = forwardRef<VideoRef, VideoProps>(
props.onPause?.(e)
}
const handleError: React.VideoHTMLAttributes<HTMLVideoElement>['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<VideoRef, VideoProps>(
// 计算进度百分比
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 (
<div
@@ -544,16 +624,28 @@ const Video = forwardRef<VideoRef, VideoProps>(
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 && (
<div className="absolute inset-x-0 bottom-0 z-10 pointer-events-none">
<div className="h-1 bg-black/25">
<div
ref={readonlyProgressRef}
className="h-full origin-left bg-white/90"
style={{ transform: `scaleX(${progressPercent / 100})` }}
/>
</div>
</div>
)}
{/* 跳过按钮 */}
{showSkipButton && isPlaying && (
{showSkipButton && isPlaying && canShowSkipButton && (
<button
type="button"
onClick={handleSkipClick}
@@ -568,7 +660,7 @@ const Video = forwardRef<VideoRef, VideoProps>(
</button>
)}
{/* 自定义控制条 */}
{isVideoReady && controls && (
{isVideoReady && controls && canShowControls && (
<div
ref={controlsRef}
className={cn(
+10
View File
@@ -40,6 +40,16 @@ export interface VideoProps extends React.VideoHTMLAttributes<HTMLVideoElement>
onSkip?: () => void
/** 自定义跳过按钮样式 */
skipButtonClassName?: string
/** 跳过按钮的唯一标识;传入时,首次播放完成前不显示跳过按钮 */
skipKey?: string
/** 从 store 读取的初始完成状态,用于初始化 isVideoCompleted */
initiallyCompleted?: boolean
/** 视频首次完整播放完成通知(仅在 skipKey 传入时触发) */
onVideoCompleted?: () => void
/** 二次学习时才显示 controls 进度条(首次学习不显示进度条) */
controlsOnReplay?: boolean
/** 是否显示只读简化进度条(不可拖拽、不可点击,仅展示播放进度) */
showReadonlyProgress?: boolean
}
/** Video 组件 Ref 暴露的方法 */
+169 -3
View File
@@ -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<string, Set<number>>) => {
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 = () => {
+15
View File
@@ -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;
}
+101 -212
View File
@@ -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<string, string | undefined> {
const vars: any[] & Record<string, string | undefined> = [] 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<string> {
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<string[]> {
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<UserDefineFetchResult> {
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<string> {
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<UserDefineFetchResult> {
return getUserDefineResultAsyncInternal(lessonId, key)
}
export async function getUserDefine(
lessonId: string | number,
key: string | number
): Promise<string> {
return getUserDefineAsyncInternal(lessonId, key)
}
// 内部:获取【所有】用户自定义数据(Promise 封装)
function getUsersDefineAsyncInternal(
lessonId: string | number,
key: string | number
): Promise<string | VideoAjaxResponseItem[]> {
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<string | VideoAjaxResponseItem[]> {
return getUsersDefineAsyncInternal(lessonId, key)
return getUserDefineResultAsyncInternal(lessonId, key, options)
}
// 根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度(异步)
export async function setCourseTimeProgress(order: number): Promise<void> {
export async function setCourseTimeProgress(order: number): Promise<boolean> {
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
}
+105 -5
View File
@@ -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: '' }
}
+40
View File
@@ -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<string, { store: any; save: () => 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),
}
}
+22 -5
View File
@@ -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 (
<div
@@ -23,11 +36,15 @@ const Home = () => {
exit={{ opacity: 0, scale: 0.92, x: 24 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
<Image
src={getStaticImage('home/课程简介')}
button
onClick={() => setShowIntroVideo(true)}
/>
<div className="relative">
<Image src={getStaticImage('home/课程简介')} button onClick={handleIntroClick} />
{!introViewed && (
<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>
</motion.div>
)}
</AnimatePresence>
+18 -2
View File
@@ -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)}
/>
<div className="absolute inset-0 flex items-center justify-center pb-8 pointer-events-none text-white text-[36px] font-bold leading-none drop-shadow-[0_2px_2px_rgba(0,0,0,0.8)]">
{courseProgress}%
</div>
</div>
{/* 进入具体故居的拼图系统(与故居页面一致的 PuzzleSystem) */}
+3 -2
View File
@@ -219,7 +219,6 @@ const ChenyunInternalization = () => {
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content=""
onContinue={() => {
setActiveSuccess(null)
@@ -334,7 +333,6 @@ const ChenyunInternalization = () => {
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你,选择正确!"
onContinue={() => {
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)
+2
View File
@@ -144,6 +144,8 @@ const ChenyunPlace3 = () => {
autoPlay
playsInline
showSkipButton
skipKey="chenyun_place3_video"
showReadonlyProgress
controls={false}
onEnded={() => {
setVideoVisible(false)
+2
View File
@@ -185,6 +185,8 @@ const ChenyunPlace5 = () => {
autoPlay
playsInline
showSkipButton
skipKey="chenyun_place5_video"
showReadonlyProgress
onEnded={() => {
setVideoVisible3(false)
setQuiz3Visible(true)
+4
View File
@@ -286,6 +286,8 @@ const ChenyunPlace7 = () => {
preload="auto"
muted={!video1Playing}
showSkipButton
skipKey="chenyun_place7_video1"
showReadonlyProgress
onEnded={handleVideo1Ended}
/>
</div>
@@ -350,6 +352,8 @@ const ChenyunPlace7 = () => {
preload="auto"
muted={!video2Playing}
showSkipButton
skipKey="chenyun_place7_video2"
showReadonlyProgress
skipText="跳过"
onEnded={handleVideo2Ended}
/>
+2
View File
@@ -122,6 +122,8 @@ const ChenyunPlace9 = () => {
preload="auto"
muted={!videoPlaying}
showSkipButton
skipKey="chenyun_place9_video"
showReadonlyProgress
skipText="跳过"
onEnded={() => {
setVideoPlaying(false)
@@ -60,6 +60,8 @@ export const VideoHandler = ({
src={videoConfig.src}
autoPlay
showSkipButton
skipKey={`chenyun_reflection_${playingVideo}`}
showReadonlyProgress
onEnded={onVideoEnd}
className="w-full h-full"
/>
+4 -2
View File
@@ -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 = () => {
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null)
@@ -465,6 +464,9 @@ const HuangyanpeiInternalization = () => {
anchors={anchors}
playsInline
controls
controlsOnReplay
skipKey="huangyanpei_internalization_video"
showReadonlyProgress
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
+2
View File
@@ -332,6 +332,8 @@ const HuangyanpeiPlace4 = () => {
autoPlay
playsInline
showSkipButton
skipKey="huangyanpei_place4_video"
showReadonlyProgress
onEnded={() => {
setShowVideo(false)
setShowQuiz(true)
+2 -1
View File
@@ -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)
@@ -262,7 +262,9 @@ const BambooPuzzleGame = ({ visible = true, onClose, onComplete }: BambooPuzzleG
>
毛竹拼单杠
</h1>
<p className="text-4xl text-white mt-10">请将下方的竹子拖到正确位置,拼出单杠!</p>
<p className="text-4xl text-white mt-10">
请将下方的竹子拖到正确位置,拼出单杠!
</p>
</div>
{/* 拼图区域 - 空白容器 */}
+1 -1
View File
@@ -135,7 +135,7 @@ const HuangyanpeiReflectionScene2 = () => {
}}
>
<Title
title="川沙小学堂"
title="浦东中学"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
className="absolute top-10 left-1/2 -translate-x-1/2"
+3 -1
View File
@@ -70,7 +70,7 @@ const HuangyanpeiReflectionScene3 = () => {
}}
>
<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()
@@ -210,6 +210,8 @@ const ViseAssemblyDialog = ({ visible = true, onClose, onComplete }: ViseAssembl
playsInline
preload="auto"
showSkipButton
skipKey="huangyanpei_reflection_vise_video"
showReadonlyProgress
skipText="跳过"
onEnded={handleVideoEnd}
/>
+3 -1
View File
@@ -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)
@@ -331,7 +331,7 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia
{/* 成功提示 */}
<SuccessAlert
title="恭喜你, 推销语设计成功!"
title="恭喜你,推销语设计成功!"
content="既突出了国货优势,又传递了爱国情怀,当年的商科学生也是这么做的。"
visible={successVisible}
onContinue={handleSuccessContinue}
+69 -18
View File
@@ -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>
+2 -2
View File
@@ -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 />
}
+4 -1
View File
@@ -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)
+1 -1
View File
@@ -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"
+2
View File
@@ -363,6 +363,8 @@ const LuxunPlace5 = () => {
autoPlay
playsInline
showSkipButton
skipKey="luxun_place4_video"
showReadonlyProgress
anchors={videoAnchors}
onEnded={() => {
setEndingGuideVisible(true)
+118 -78
View File
@@ -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}
+11 -7
View File
@@ -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}
/>
+5
View File
@@ -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={() => {
+2
View File
@@ -217,6 +217,8 @@ const MaozedongPlace1 = () => {
autoPlay
playsInline
showSkipButton
skipKey="maozedong_place1_video"
showReadonlyProgress
onEnded={() => {
setShowVideo2(false)
setQuizVisible(true)
+2
View File
@@ -213,6 +213,8 @@ const MaozedongPlace2 = () => {
autoPlay
playsInline
showSkipButton
skipKey="maozedong_place2_video"
showReadonlyProgress
onEnded={() => {
setShowVideo(false)
setQuizVisible(true)
+2
View File
@@ -245,6 +245,8 @@ const MaozedongPlace3 = () => {
autoPlay
playsInline
showSkipButton
skipKey="maozedong_place3_video"
showReadonlyProgress
onEnded={() => {
setShowVideo3(false)
setQuiz3Visible(true)
+6 -2
View File
@@ -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)
+3 -3
View File
@@ -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()
+4 -1
View File
@@ -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)
+5 -3
View File
@@ -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)
+2
View File
@@ -194,6 +194,8 @@ const NieerPlace2 = () => {
autoPlay
playsInline
showSkipButton
skipKey="nieer_place2_video"
showReadonlyProgress
onEnded={() => {
markExhibitVisited(place, 2)
setShowVideo2(false)
+2
View File
@@ -245,6 +245,8 @@ const NieerPlace3 = () => {
autoPlay
playsInline
showSkipButton
skipKey="nieer_place3_video"
showReadonlyProgress
onEnded={() => {
const id = videoForExhibitId
setShowVideo(false)
+1 -1
View File
@@ -403,7 +403,7 @@ const NieerPlace5 = () => {
<SuccessAlert
visible={success3_1Visible}
title="恭喜你, 匹配正确!"
title="恭喜你,匹配正确!"
onClose={() => {
setSuccess3_1Visible(false)
setQuiz3_2Visible(true)
+4 -1
View File
@@ -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}
@@ -453,6 +453,9 @@ const RongdeshengInternalization = () => {
anchors={anchors}
playsInline
controls
controlsOnReplay
skipKey="rongdesheng_internalization_video"
showReadonlyProgress
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
+8
View File
@@ -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}
/>
+2
View File
@@ -186,6 +186,8 @@ const RongdeshengPlace3 = () => {
autoPlay
playsInline
showSkipButton
skipKey="rongdesheng_place3_video"
showReadonlyProgress
onEnded={handleVideoEnded}
onSkip={handleVideoEnded}
/>
+4
View File
@@ -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}
/>
+14 -2
View File
@@ -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"
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more