chore: 更新素材和组件优化
- 更新展品图片(鲁迅/毛泽东/孙中山/黄炎培/聂耳/张乐平故居) - 新增点击查看图标、报纸素材 - 优化视频组件、拼图组件、展品组件 - 更新陈云音频资源 - 新增输入验证文档和工具函数 - 组件布局和动画优化 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
No files matched your search
@@ -1 +1,2 @@
|
||||
VITE_DEBUG_ENTRY=true
|
||||
VITE_DISABLE_FIRST_WATCH_VIDEO_GATING=true
|
||||
@@ -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. 所有标点符号(包括中文标点)都会被过滤
|
||||
@@ -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 />
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1003 KiB After Width: | Height: | Size: 907 KiB |
|
Before Width: | Height: | Size: 933 KiB After Width: | Height: | Size: 933 KiB |
|
Before Width: | Height: | Size: 544 KiB After Width: | Height: | Size: 556 KiB |
|
Before Width: | Height: | Size: 841 KiB After Width: | Height: | Size: 789 KiB |
|
Before Width: | Height: | Size: 395 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 686 KiB After Width: | Height: | Size: 692 KiB |
|
Before Width: | Height: | Size: 445 KiB After Width: | Height: | Size: 380 KiB |
|
Before Width: | Height: | Size: 513 KiB After Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1011 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 612 KiB After Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 778 KiB After Width: | Height: | Size: 1023 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 736 KiB After Width: | Height: | Size: 762 KiB |
|
Before Width: | Height: | Size: 673 KiB After Width: | Height: | Size: 647 KiB |
|
Before Width: | Height: | Size: 785 KiB After Width: | Height: | Size: 754 KiB |
|
Before Width: | Height: | Size: 656 KiB After Width: | Height: | Size: 622 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)]'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 暴露的方法 */
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: '' }
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -144,6 +144,8 @@ const ChenyunPlace3 = () => {
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
skipKey="chenyun_place3_video"
|
||||
showReadonlyProgress
|
||||
controls={false}
|
||||
onEnded={() => {
|
||||
setVideoVisible(false)
|
||||
|
||||
@@ -185,6 +185,8 @@ const ChenyunPlace5 = () => {
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
skipKey="chenyun_place5_video"
|
||||
showReadonlyProgress
|
||||
onEnded={() => {
|
||||
setVideoVisible3(false)
|
||||
setQuiz3Visible(true)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -332,6 +332,8 @@ const HuangyanpeiPlace4 = () => {
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
skipKey="huangyanpei_place4_video"
|
||||
showReadonlyProgress
|
||||
onEnded={() => {
|
||||
setShowVideo(false)
|
||||
setShowQuiz(true)
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 拼图区域 - 空白容器 */}
|
||||
|
||||
@@ -135,7 +135,7 @@ const HuangyanpeiReflectionScene2 = () => {
|
||||
}}
|
||||
>
|
||||
<Title
|
||||
title="川沙小学堂"
|
||||
title="浦东中学"
|
||||
subtitle={subtitle}
|
||||
subtitleClassName={subtitleClassName}
|
||||
className="absolute top-10 left-1/2 -translate-x-1/2"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -363,6 +363,8 @@ const LuxunPlace5 = () => {
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
skipKey="luxun_place4_video"
|
||||
showReadonlyProgress
|
||||
anchors={videoAnchors}
|
||||
onEnded={() => {
|
||||
setEndingGuideVisible(true)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -217,6 +217,8 @@ const MaozedongPlace1 = () => {
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
skipKey="maozedong_place1_video"
|
||||
showReadonlyProgress
|
||||
onEnded={() => {
|
||||
setShowVideo2(false)
|
||||
setQuizVisible(true)
|
||||
|
||||
@@ -213,6 +213,8 @@ const MaozedongPlace2 = () => {
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
skipKey="maozedong_place2_video"
|
||||
showReadonlyProgress
|
||||
onEnded={() => {
|
||||
setShowVideo(false)
|
||||
setQuizVisible(true)
|
||||
|
||||
@@ -245,6 +245,8 @@ const MaozedongPlace3 = () => {
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
skipKey="maozedong_place3_video"
|
||||
showReadonlyProgress
|
||||
onEnded={() => {
|
||||
setShowVideo3(false)
|
||||
setQuiz3Visible(true)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -194,6 +194,8 @@ const NieerPlace2 = () => {
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
skipKey="nieer_place2_video"
|
||||
showReadonlyProgress
|
||||
onEnded={() => {
|
||||
markExhibitVisited(place, 2)
|
||||
setShowVideo2(false)
|
||||
|
||||
@@ -245,6 +245,8 @@ const NieerPlace3 = () => {
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
skipKey="nieer_place3_video"
|
||||
showReadonlyProgress
|
||||
onEnded={() => {
|
||||
const id = videoForExhibitId
|
||||
setShowVideo(false)
|
||||
|
||||
@@ -403,7 +403,7 @@ const NieerPlace5 = () => {
|
||||
|
||||
<SuccessAlert
|
||||
visible={success3_1Visible}
|
||||
title="恭喜你, 匹配正确!"
|
||||
title="恭喜你,匹配正确!"
|
||||
onClose={() => {
|
||||
setSuccess3_1Visible(false)
|
||||
setQuiz3_2Visible(true)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -186,6 +186,8 @@ const RongdeshengPlace3 = () => {
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
skipKey="rongdesheng_place3_video"
|
||||
showReadonlyProgress
|
||||
onEnded={handleVideoEnded}
|
||||
onSkip={handleVideoEnded}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||