feat: add puzzle residence configuration and reflection progress logic

- Introduced `puzzleResidences.ts` to define puzzle residence configurations including properties like display name, position, and puzzle availability.
- Added `reflectionProgress.ts` to manage badge unlocking logic based on place completion, including functions to determine unlocked badges and video sources for scenes.
This commit is contained in:
zhongyidian committed 2026-04-27 11:49:30 +08:00
1 parent e0fa47d21a
commit f5f36f75b5
153 files changed
+3846 -2352

No files matched your search

+39 -1
View File
@@ -7,7 +7,45 @@
"Bash(done)",
"Bash(grep -E \"\\\\.tsx$\")",
"Bash(grep -r \"navigate\" /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/src/pages/chenyun/place/place*.tsx)",
"Bash(bun run:*)"
"Bash(bun run:*)",
"Bash(npm run *)",
"Bash(npx prettier *)",
"Bash(grep -Ei \"nieer|badge|mp3_nieer|/video/|\\\\.mp3$|\\\\.mp4$|\\\\.mov$|\\\\.webm$\")",
"Bash(npm exec *)",
"Bash(/\\\\$\\\\{moduleName\\\\}/\\\\$\\\\{starName\\\\}\" \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a48dafb4/src/pages\" \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a48dafb4/src/router\")",
"Bash(grep -RIn \"moduleName as ModuleName\\\\|starName = \\\\\"\\\\|navigate\\\\\\(`/\\\\$\\\\{moduleName\\\\}/\\\\$\\\\{starName\\\\}\" \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a48dafb4/src/pages\" \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a48dafb4/src/router\")",
"Bash(bunx eslint *)",
"Bash(npx eslint *)",
"Bash(npx tsc *)",
"Bash(python3 -)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a5e97b1c status --short)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a5e97b1c branch --show-current)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a5e97b1c log --oneline --all --decorate --grep='张乐平\\\\|三毛\\\\|Sanmao\\\\|draw\\\\|draw1\\\\|ExhibitFlipOverlay' -n 50)",
"Bash(grep -E '\\(/image/|/button/|ExhibitDescriptionNarration|ExhibitOverlay|router/modules|reflection/scene|home.tsx|place[0-9].tsx$|utils/index.ts\\)')",
"Bash(grep -E \"\\(tsx|ts\\)$\")",
"Bash(git -C \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a5bf04dd\" status --short)",
"Bash(git -C \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a5bf04dd\" branch --show-current)",
"Bash(git -C \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a5bf04dd\" ls-files)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 status --short)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 branch --show-current)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 log --oneline -5)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n -i \"zhangleping\\\\|drawArtworkDataUrl\\\\|hasSubmittedArtwork\\\\|三毛\\\\|张乐平\\\\|创作了三毛\" -- .)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 branch -a --list '*zhang*' '*main*' '*master*')",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 log --all --oneline --grep='张乐平\\\\|三毛\\\\|zhangleping\\\\|drawArtwork' -50)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n -i \"zhangleping\\\\|drawArtworkDataUrl\\\\|hasSubmittedArtwork\\\\|三毛\\\\|张乐平\\\\|创作了三毛\" main origin/main master origin/master -- .)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 ls-tree -r --name-only main src/pages/zhangleping)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/reflection/scene5.tsx)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/store/zhanglepingStore.ts)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/draw/index.tsx)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/draw/draw1.tsx)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/draw/draw1.config.ts)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/router/modules/zhangleping.tsx)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/reflection/index.tsx)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n \"getUserDefine\\\\|setUserDefine\\\\|saveZhanglepingStore\\\\|ZHANGLEPING_LESSON_ID\\\\|ZHANGLEPING_KEY\\\\|drawArtworkDataUrl\\\\|hasDrawArtwork\" main -- src)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/pages/zhangleping/home.tsx)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 show main:src/store/storeNames.ts)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n \"getUserDefine\" main -- src/pages/zhangleping src/store/zhanglepingStore.ts src/App.tsx)",
"Bash(git -C /Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-a41031e2 grep -n \"setDrawArtwork\\\\|clearDrawArtwork\\\\|drawArtworkDataUrl\" main -- src)"
],
"deny": [
"Read(public/**)",
Submodule .claude/worktrees/agent-a1669fc8 added at 57edd05c5a.
Submodule .claude/worktrees/agent-a1b9a5a9 added at 57edd05c5a.
Submodule .claude/worktrees/agent-a2ac2892 added at 57edd05c5a.
Submodule .claude/worktrees/agent-a41031e2 added at 57edd05c5a.
Submodule .claude/worktrees/agent-a4306bae added at 57edd05c5a.
Submodule .claude/worktrees/agent-a48dafb4 added at 57edd05c5a.
Submodule .claude/worktrees/agent-a503052f added at 57edd05c5a.
Submodule .claude/worktrees/agent-a5220993 added at 57edd05c5a.
Submodule .claude/worktrees/agent-a56d14e6 added at 57edd05c5a.
Submodule .claude/worktrees/agent-a5bf04dd added at 57edd05c5a.
Submodule .claude/worktrees/agent-a5e57f2f added at 57edd05c5a.
Submodule .claude/worktrees/agent-a5e97b1c added at 57edd05c5a.
Submodule .claude/worktrees/agent-a6a005c1 added at 57edd05c5a.
Submodule .claude/worktrees/agent-a729cc8b added at 57edd05c5a.
Submodule .claude/worktrees/agent-a77c923f added at 57edd05c5a.
Submodule .claude/worktrees/agent-a7ac1f76 added at 57edd05c5a.
Submodule .claude/worktrees/agent-a907386f added at 57edd05c5a.
Submodule .claude/worktrees/agent-a9420406 added at 57edd05c5a.
Submodule .claude/worktrees/agent-ab099292 added at 57edd05c5a.
Submodule .claude/worktrees/agent-ace17dec added at 57edd05c5a.
Submodule .claude/worktrees/agent-ad5d8ae2 added at 57edd05c5a.
Submodule .claude/worktrees/agent-aee3dc15 added at 57edd05c5a.
Submodule .claude/worktrees/agent-aef2787f added at 57edd05c5a.
Submodule .claude/worktrees/agent-af322a60 added at 57edd05c5a.
Submodule .claude/worktrees/agent-af9038e9 added at 57edd05c5a.
+10 -1
View File
@@ -1,3 +1,5 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
@@ -5,8 +7,12 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
const tsconfigRootDir = path.dirname(fileURLToPath(import.meta.url))
export default tseslint.config(
{ ignores: ['dist', 'node_modules', 'vite.config.ts'] },
{
ignores: ['dist', 'node_modules', 'vite.config.ts', '.claude/worktrees/**', 'public/**'],
},
{
extends: [
js.configs.recommended,
@@ -17,6 +23,9 @@ export default tseslint.config(
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
tsconfigRootDir,
},
},
plugins: {
'react-hooks': reactHooks,
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 KiB

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 KiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 549 KiB

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 788 KiB

After

Width:  |  Height:  |  Size: 777 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 KiB

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 KiB

After

Width:  |  Height:  |  Size: 661 KiB

@@ -29,7 +29,7 @@ const ExhibitDescriptionNarration = ({
visible = true,
autoPlay = true,
audioSrc,
audioChannelId,
audioChannelId = 'exhibit-description-narration',
className,
}: ExhibitDescriptionNarrationProps) => {
// 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥
@@ -89,7 +89,7 @@ const ExhibitDescriptionNarration = ({
/>
<div className="relative z-10 flex flex-col gap-4">
{/* 文本内容 */}
<div className="flex-1 text-white text-2xl leading-12 tracking-wide font-medium">
<div className="flex-1 text-white text-2xl leading-12 tracking-wide font-medium indent-12">
{text}
</div>
+31 -10
View File
@@ -1,5 +1,5 @@
import { cn, getStaticImage } from '@/lib/utils'
import { useEffect, useMemo, useState } from 'react'
import { type ReactNode, useEffect, useMemo, useState } from 'react'
import { FlipActionButton, Image } from '@/components'
import { motion } from 'framer-motion'
@@ -10,6 +10,14 @@ const OVERLAY_STYLE: React.CSSProperties = {
pointerEvents: 'none',
}
const isDirectImageSrc = (src: string) =>
src.startsWith('http://') ||
src.startsWith('https://') ||
src.startsWith('/') ||
src.startsWith('data:image/')
const resolveImageSrc = (src: string) => (isDirectImageSrc(src) ? src : getStaticImage(src))
export interface ExhibitFlipOverlayProps {
/** 展品正面图片地址,可以是静态资源路径或完整 URL */
src: string
@@ -29,6 +37,10 @@ export interface ExhibitFlipOverlayProps {
visible?: boolean
/** 额外的自定义类 */
className?: string
/** 底部操作区 */
footer?: ReactNode
/** 底部操作区额外类名 */
footerClassName?: string
}
/**
@@ -45,6 +57,8 @@ const ExhibitFlipOverlay = ({
onClose,
visible = true,
className,
footer,
footerClassName,
}: ExhibitFlipOverlayProps) => {
const backSrc = getStaticImage('components/close')
@@ -63,18 +77,11 @@ const ExhibitFlipOverlay = ({
}
}, [visible, src, flipSrc])
const frontImageSrc =
src.startsWith('http://') || src.startsWith('https://') || src.startsWith('/')
? src
: getStaticImage(src)
const frontImageSrc = resolveImageSrc(src)
const backImageSrc = useMemo(() => {
if (!flipSrc) return null
return flipSrc.startsWith('http://') ||
flipSrc.startsWith('https://') ||
flipSrc.startsWith('/')
? flipSrc
: getStaticImage(flipSrc)
return resolveImageSrc(flipSrc)
}, [flipSrc])
const canFlip = Boolean(backImageSrc && backImageSrc !== frontImageSrc)
@@ -200,6 +207,20 @@ const ExhibitFlipOverlay = ({
</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>
</div>
)
+1 -1
View File
@@ -55,7 +55,7 @@ const GuideSteps = ({
highlightSelectors,
showMask = true,
zIndex = 50,
audioChannelId,
audioChannelId = 'guide-steps-default',
}: GuideStepsProps) => {
const [internalStep, setInternalStep] = useState(0)
const [internalVisible, setInternalVisible] = useState(true)
+9
View File
@@ -24,6 +24,8 @@ export interface StudyGuideModalProps {
ailgn?: 'center' | 'left' | 'right'
// 垂直对齐方式,默认 center
verticalAlign?: 'center' | 'top' | 'bottom'
/** 正文首行缩进,如 '2em'、'32px' 或 32 */
firstLineIndent?: string | number
/** 引导语音地址(可选) */
audioSrc?: string
/** 弹窗出现时是否自动播放引导语音(默认 true) */
@@ -55,6 +57,7 @@ export const StudyGuideModal = ({
zIndex = 120,
ailgn = 'center',
verticalAlign = 'top',
firstLineIndent,
audioSrc,
autoPlayAudio = true,
audioChannelId,
@@ -152,6 +155,12 @@ export const StudyGuideModal = ({
)}
style={{
marginTop: verticalAlign === 'center' ? '85px' : '',
textIndent:
firstLineIndent === undefined
? undefined
: typeof firstLineIndent === 'number'
? `${firstLineIndent}px`
: firstLineIndent,
}}
>
{children}
+115 -75
View File
@@ -1,5 +1,5 @@
import { Outlet, useMatches, useLocation } from 'react-router-dom'
import { useState, useEffect, useRef, useMemo } from 'react'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { getPublicPath, getStaticImage } from '@/lib/utils'
import type { VideoRouteHandle } from '@/router/types'
@@ -21,6 +21,8 @@ const RESIDENCES_WITH_PUZZLE = [
'sunzhongshan',
'luxun',
'maozedong',
'nieer',
'zhangleping',
] as const
/**
@@ -48,6 +50,8 @@ const ResidenceLayoutContent = () => {
const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行
const puzzlePieceRef = useRef<HTMLDivElement>(null)
const animationInProgressRef = useRef<boolean>(false) // 防止重复执行动画
const animationCompletionRef = useRef<(() => void) | null>(null)
const animationCompletionCallbackRef = useRef<(() => void) | null>(null)
// 从路径中提取故居名称(starName),如 'chenyun', 'sunzhongshan' 等
// 路径格式:/:moduleName/:starName/:page
@@ -77,106 +81,142 @@ const ResidenceLayoutContent = () => {
OPACITY: [1, 1, 0.6, 0] as number[], // 透明度变化(更平滑的淡出)
}
const resolveAnimation = useCallback(() => {
const callback = animationCompletionCallbackRef.current
const resolve = animationCompletionRef.current
animationCompletionCallbackRef.current = null
animationCompletionRef.current = null
callback?.()
resolve?.()
}, [])
// 清理动画状态
const cleanupAnimation = () => {
const cleanupAnimation = useCallback(() => {
setIsAnimationPlaying(false)
setShowPuzzlePiece(false)
setPuzzlePieceId(null)
setIsFlying(false)
animationInProgressRef.current = false
}
resolveAnimation()
}, [resolveAnimation])
// 执行飞行动画
const executeFlyAnimation = (pieceId: number) => {
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
return false
}
const executeFlyAnimation = useCallback(
(pieceId: number) => {
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
return false
}
setIsFlying(true)
setIsFlying(true)
// 使用 requestAnimationFrame 确保元素已完全渲染
requestAnimationFrame(() => {
if (!puzzlePieceRef.current || !residence) {
// 使用 requestAnimationFrame 确保元素已完全渲染
requestAnimationFrame(() => {
if (!puzzlePieceRef.current || !residence) {
return
}
flyToAnimation({
fromRef: puzzlePieceRef,
toRef: puzzleBoxRef,
targetPosition: 'center',
duration: ANIMATION_CONFIG.FLY_DURATION,
scale: ANIMATION_CONFIG.SCALE,
opacity: ANIMATION_CONFIG.OPACITY,
ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速
onComplete: () => {
if (residence) {
unlockPuzzle(residence, pieceId)
}
cleanupAnimation()
},
})
})
return true
},
[ANIMATION_CONFIG.FLY_DURATION, ANIMATION_CONFIG.OPACITY, ANIMATION_CONFIG.SCALE, cleanupAnimation, puzzleBoxRef, residence, unlockPuzzle]
)
// 等待元素准备就绪并执行动画
const waitForElementsAndAnimate = useCallback(
async (pieceId: number, retryCount = 0): Promise<void> => {
if (!residence) {
cleanupAnimation()
return
}
flyToAnimation({
fromRef: puzzlePieceRef,
toRef: puzzleBoxRef,
targetPosition: 'center',
duration: ANIMATION_CONFIG.FLY_DURATION,
scale: ANIMATION_CONFIG.SCALE,
opacity: ANIMATION_CONFIG.OPACITY,
ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速
onComplete: () => {
if (residence) {
unlockPuzzle(residence, pieceId)
}
cleanupAnimation()
},
})
})
// 检查元素是否已准备好
if (puzzleBoxRef.current && puzzlePieceRef.current) {
const success = executeFlyAnimation(pieceId)
if (success) return
}
return true
}
// 如果元素未准备好且未超过最大重试次数,则重试
if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY))
return waitForElementsAndAnimate(pieceId, retryCount + 1)
}
// 等待元素准备就绪并执行动画
const waitForElementsAndAnimate = async (pieceId: number, retryCount = 0): Promise<void> => {
if (!residence) {
// 超过最大重试次数,直接解锁并清理
console.warn('Puzzle animation: 元素未准备好,直接解锁')
unlockPuzzle(residence, pieceId)
cleanupAnimation()
return
}
// 检查元素是否已准备好
if (puzzleBoxRef.current && puzzlePieceRef.current) {
const success = executeFlyAnimation(pieceId)
if (success) return
}
// 如果元素未准备好且未超过最大重试次数,则重试
if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY))
return waitForElementsAndAnimate(pieceId, retryCount + 1)
}
// 超过最大重试次数,直接解锁并清理
console.warn('Puzzle animation: 元素未准备好,直接解锁')
unlockPuzzle(residence, pieceId)
cleanupAnimation()
}
},
[ANIMATION_CONFIG.MAX_RETRIES, ANIMATION_CONFIG.RETRY_DELAY, cleanupAnimation, executeFlyAnimation, puzzleBoxRef, residence, unlockPuzzle]
)
// 处理碎片动画触发(仅支持拼图功能的故居)
const handleTriggerPuzzleAnimation = (pieceId: number) => {
if (!residence || !hasPuzzleFeature) return
const handleTriggerPuzzleAnimation = useCallback(
(pieceId: number, options?: { onComplete?: () => void }) => {
if (!residence || !hasPuzzleFeature) {
options?.onComplete?.()
return Promise.resolve()
}
// 防止重复执行动画
if (animationInProgressRef.current) return
// 防止重复执行动画
if (animationInProgressRef.current) {
options?.onComplete?.()
return Promise.resolve()
}
// 检查碎片是否已解锁
if (isPuzzleUnlocked(residence, pieceId)) return
// 检查碎片是否已解锁
if (isPuzzleUnlocked(residence, pieceId)) {
options?.onComplete?.()
return Promise.resolve()
}
// 标记动画正在进行
animationInProgressRef.current = true
// 标记动画正在进行
animationInProgressRef.current = true
// 开始动画流程:禁用所有操作
setIsAnimationPlaying(true)
setPuzzlePieceId(pieceId)
setShowPuzzlePiece(true)
return new Promise<void>((resolve) => {
animationCompletionRef.current = resolve
animationCompletionCallbackRef.current = options?.onComplete ?? null
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
// 等待初始出现动画(旋转+放大)完成
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION))
// 开始动画流程:禁用所有操作
setIsAnimationPlaying(true)
setPuzzlePieceId(pieceId)
setShowPuzzlePiece(true)
// 等待发光动画显示一段时间,让用户看到碎片
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION))
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
// 等待初始出现动画(旋转+放大)完成
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION))
// 等待元素准备就绪并执行飞行动画
await waitForElementsAndAnimate(pieceId)
// 等待发光动画显示一段时间,让用户看到碎片
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION))
// 等待元素准备就绪并执行飞行动画
await waitForElementsAndAnimate(pieceId)
})
})
})
})
}
},
[ANIMATION_CONFIG.APPEAR_DURATION, ANIMATION_CONFIG.GLOW_DURATION, hasPuzzleFeature, isPuzzleUnlocked, residence, waitForElementsAndAnimate]
)
// 获取当前路由的视频路径
const videoSrc = useMemo(() => {
@@ -1,6 +1,6 @@
import { getStaticImage, getBackgroundImage } from '@/lib/utils'
import type { MouseEvent } from 'react'
import { useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useAudioManager } from '@/hooks/useAudioManager'
export interface SuccessAlertProps {
@@ -42,6 +42,46 @@ const SuccessAlert = ({
const { currentSrc, play, stop } = useAudioManager(
audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' }
)
const contentRef = useRef<HTMLDivElement>(null)
const [shouldIndent, setShouldIndent] = useState(false)
useEffect(() => {
const element = contentRef.current
if (!element || !content) {
setShouldIndent(false)
return
}
const lineHeight = Number.parseFloat(window.getComputedStyle(element).lineHeight)
if (!lineHeight) {
setShouldIndent(false)
return
}
setShouldIndent(element.scrollHeight - lineHeight > 1)
}, [content, visible])
useEffect(() => {
if (!content) return
const handleResize = () => {
const element = contentRef.current
if (!element) return
const lineHeight = Number.parseFloat(window.getComputedStyle(element).lineHeight)
if (!lineHeight) {
setShouldIndent(false)
return
}
setShouldIndent(element.scrollHeight - lineHeight > 1)
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [content])
const contentStyle = shouldIndent ? { textIndent: '2em' } : undefined
// 弹窗显示时自动播放音频
useEffect(() => {
@@ -105,7 +145,11 @@ const SuccessAlert = ({
{/* 内容文本 */}
{content && (
<div className="text-white text-4xl font-medium max-w-[1600px] leading-relaxed px-20">
<div
ref={contentRef}
className="text-white text-4xl font-medium max-w-[1600px] leading-relaxed px-20"
style={contentStyle}
>
{content}
</div>
)}
+299 -270
View File
@@ -1,34 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import type { MatchQuizPanelProps } from './types'
import { useScale } from '@/contexts/ScaleContext'
import type { MatchQuizItem, MatchQuizPanelProps } from './types'
/**
* 连线题面板组件
* 支持移动端触摸操作
*
* @example
* ```tsx
* <MatchQuizPanel
* visible={true}
* title="请将日期与对应的事件连线"
* leftItems={[
* { id: "1", label: "1892-1898年" },
* { id: "2", label: "1919-1923年" },
* ]}
* rightItems={[
* { id: "a", label: "在绍兴三味书屋求学积淀" },
* { id: "b", label: "于北京八道湾参加新文化运动" },
* ]}
* correctMatches={[
* { leftId: "1", rightId: "a" },
* { leftId: "2", rightId: "b" },
* ]}
* onSubmit={(matches, isCorrect) => {
* console.log("匹配结果:", matches, "是否正确:", isCorrect);
* }}
* />
* ```
*/
const MatchQuizPanel = ({
visible,
zIndex = 10000,
@@ -41,23 +15,26 @@ const MatchQuizPanel = ({
onError,
disabled = false,
}: MatchQuizPanelProps) => {
const { scale } = useScale()
const [matches, setMatches] = useState<Array<{ leftId: string; rightId: string }>>([])
const [selectedLeftId, setSelectedLeftId] = useState<string | null>(null)
const [error, setError] = useState<string>('')
const containerRef = useRef<HTMLDivElement>(null)
const middleRef = useRef<HTMLDivElement>(null)
const leftColRef = useRef<HTMLDivElement>(null)
const rightColRef = useRef<HTMLDivElement>(null)
const quizAreaRef = useRef<HTMLDivElement>(null)
const leftItemRefs = useRef<Record<string, HTMLDivElement | null>>({})
const rightItemRefs = useRef<Record<string, HTMLDivElement | null>>({})
// 当前错误的连线对,用于做红色高亮+抖动反馈
// ✓/✕ 圆点的 ref
const leftDotRefs = useRef<Record<string, HTMLDivElement | null>>({})
const rightDotRefs = useRef<Record<string, HTMLDivElement | null>>({})
const [errorPairs, setErrorPairs] = useState<Array<{ leftId: string; rightId: string }>>([])
const [leftCenters, setLeftCenters] = useState<Record<string, number>>({})
const [rightCenters, setRightCenters] = useState<Record<string, number>>({})
const [middleHeight, setMiddleHeight] = useState<number>(300)
// 连线端点坐标(相对于 quizArea)
const [lineAnchors, setLineAnchors] = useState<
Record<string, { startX: number; startY: number; endX: number; endY: number }>
>({})
const [svgSize, setSvgSize] = useState({ width: 0, height: 300 })
const rafIdRef = useRef<number | null>(null)
const useRowAlignedDesktopLayout = leftItems.length === rightItems.length
// 当窗口关闭时,重置状态
useEffect(() => {
if (!visible) {
setMatches([])
@@ -67,11 +44,9 @@ const MatchQuizPanel = ({
}
}, [visible])
// 处理左列项点击
const handleLeftItemClick = (leftId: string) => {
if (disabled) return
// 如果已经匹配过,先取消匹配
const existingMatch = matches.find((m) => m.leftId === leftId)
if (existingMatch) {
setMatches(matches.filter((m) => m.leftId !== leftId))
@@ -80,41 +55,35 @@ const MatchQuizPanel = ({
return
}
// 如果已经选中了另一个左项,取消之前的选中
if (selectedLeftId && selectedLeftId !== leftId) {
setSelectedLeftId(leftId)
setError('')
return
}
// 切换选中状态
setSelectedLeftId(selectedLeftId === leftId ? null : leftId)
setError('')
}
// 处理右列项点击
const handleRightItemClick = (rightId: string) => {
if (disabled) return
// 如果没有选中左项,直接返回
if (!selectedLeftId) return
// 如果已经匹配过,先取消匹配
const existingMatch = matches.find((m) => m.rightId === rightId)
if (existingMatch) {
setMatches(matches.filter((m) => m.rightId !== rightId))
setSelectedLeftId(null)
if (existingMatch.leftId === selectedLeftId) {
setMatches(matches.filter((m) => m.rightId !== rightId))
setSelectedLeftId(null)
}
setError('')
return
}
// 创建新的匹配
const newMatch = { leftId: selectedLeftId, rightId }
const newMatches = [...matches, newMatch]
// 每次连线后立即校验当前所有连线是否存在错误
const wrongMatches =
correctMatches?.filter(() => true) && // 保证 correctMatches 存在时才校验
correctMatches?.filter(() => true) &&
newMatches.filter(
(match) =>
!correctMatches?.some((cm) => cm.leftId === match.leftId && cm.rightId === match.rightId)
@@ -123,7 +92,6 @@ const MatchQuizPanel = ({
const hasError = !!wrongMatches && wrongMatches.length > 0
if (hasError && wrongMatches) {
// 记录错误连线对,用于红色高亮+抖动反馈
setErrorPairs(wrongMatches)
setMatches(newMatches)
setSelectedLeftId(null)
@@ -137,9 +105,9 @@ const MatchQuizPanel = ({
} else {
errorMsg = '连线不正确,请重新尝试!'
}
setError(errorMsg)
onError?.(errorMsg)
// 短暂展示错误状态(变红+抖动),随后只清空错误连线,保留已答对的连线
setTimeout(() => {
setMatches((prev) =>
prev.filter(
@@ -151,93 +119,124 @@ const MatchQuizPanel = ({
return
}
// 没有错误时,正常记录匹配
setMatches(newMatches)
setSelectedLeftId(null)
setError('')
// 如果没有错误且所有项都已连完,则认为答题成功
if (newMatches.length === leftItems.length && newMatches.length === rightItems.length) {
onSubmit?.(newMatches, true)
}
}
// 获取左项是否已匹配
const isLeftItemMatched = (leftId: string) => {
return matches.some((m) => m.leftId === leftId)
}
const isLeftItemMatched = (leftId: string) => matches.some((m) => m.leftId === leftId)
const isRightItemMatched = (rightId: string) => matches.some((m) => m.rightId === rightId)
// 获取右项是否已匹配
const isRightItemMatched = (rightId: string) => {
return matches.some((m) => m.rightId === rightId)
}
const getAnchorForMatch = useCallback(
(match: { leftId: string; rightId: string }) => {
const quizAreaEl = quizAreaRef.current
if (!quizAreaEl) return null
const measureCenters = useCallback(() => {
const middleEl = middleRef.current
if (!middleEl) return
const leftCard = leftItemRefs.current[match.leftId]
const rightCard = rightItemRefs.current[match.rightId]
const leftDot = leftDotRefs.current[match.leftId]
const rightDot = rightDotRefs.current[match.rightId]
if (!leftCard || !rightCard || !leftDot || !rightDot) return null
const middleRect = middleEl.getBoundingClientRect()
const nextLeft: Record<string, number> = {}
const nextRight: Record<string, number> = {}
const areaRect = quizAreaEl.getBoundingClientRect()
const leftAnchorRect = leftDot.getBoundingClientRect()
const rightAnchorRect = rightDot.getBoundingClientRect()
// 显式同步中间连线层高度,避免因 flex/stretch/内容换行导致坐标系基准漂移
const leftRect = leftColRef.current?.getBoundingClientRect()
const rightRect = rightColRef.current?.getBoundingClientRect()
const nextMiddleHeight = Math.max(leftRect?.height ?? 0, rightRect?.height ?? 0, 300)
setMiddleHeight(nextMiddleHeight)
return {
startX: (leftAnchorRect.left + leftAnchorRect.width / 2 - areaRect.left) / scale,
startY: (leftAnchorRect.top + leftAnchorRect.height / 2 - areaRect.top) / scale,
endX: (rightAnchorRect.left + rightAnchorRect.width / 2 - areaRect.left) / scale,
endY: (rightAnchorRect.top + rightAnchorRect.height / 2 - areaRect.top) / scale,
}
},
[scale]
)
for (const item of leftItems) {
const el = leftItemRefs.current[item.id]
if (!el) continue
const rect = el.getBoundingClientRect()
nextLeft[item.id] = rect.top + rect.height / 2 - middleRect.top
}
const measureAnchors = useCallback(() => {
const quizAreaEl = quizAreaRef.current
if (!quizAreaEl) return
for (const item of rightItems) {
const el = rightItemRefs.current[item.id]
if (!el) continue
const rect = el.getBoundingClientRect()
nextRight[item.id] = rect.top + rect.height / 2 - middleRect.top
}
setLeftCenters(nextLeft)
setRightCenters(nextRight)
}, [leftItems, rightItems])
const scheduleMeasure = useCallback(() => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}
// 双 RAF:等待浏览器完成一帧布局/绘制(含 transform/transition/pulse 的本帧生效)
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null
measureCenters()
})
const areaRect = quizAreaEl.getBoundingClientRect()
setSvgSize({
width: areaRect.width / scale,
height: areaRect.height / scale,
})
}, [measureCenters])
// 首次展示、匹配变化、选中/错误状态变化都会影响位置(含动画/缩放),延后一帧再测
const next: Record<string, { startX: number; startY: number; endX: number; endY: number }> = {}
for (const match of matches) {
const anchor = getAnchorForMatch(match)
if (!anchor) continue
next[`${match.leftId}-${match.rightId}`] = anchor
}
setLineAnchors(next)
}, [getAnchorForMatch, matches, scale])
const scheduleMeasure = useCallback(
(mode: 'all' | 'new-only' = 'all') => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null
if (mode === 'all') {
measureAnchors()
return
}
const quizAreaEl = quizAreaRef.current
if (!quizAreaEl) return
const areaRect = quizAreaEl.getBoundingClientRect()
setSvgSize({
width: areaRect.width / scale,
height: areaRect.height / scale,
})
setLineAnchors((prev) => {
const next: Record<
string,
{ startX: number; startY: number; endX: number; endY: number }
> = {}
for (const match of matches) {
const key = `${match.leftId}-${match.rightId}`
next[key] = prev[key] ??
getAnchorForMatch(match) ?? { startX: 0, startY: 0, endX: 0, endY: 0 }
}
return next
})
})
})
},
[getAnchorForMatch, matches, measureAnchors, scale]
)
useEffect(() => {
if (!visible) return
scheduleMeasure()
}, [visible, matches.length, errorPairs.length, selectedLeftId, scheduleMeasure])
scheduleMeasure('new-only')
}, [visible, matches, scheduleMeasure])
// 监听尺寸变化(窗口 resize / 容器尺寸变化 / 文本换行)
useEffect(() => {
if (!visible) return
const middleEl = middleRef.current
if (!middleEl) return
const quizAreaEl = quizAreaRef.current
if (!quizAreaEl) return
const handleResize = () => scheduleMeasure()
window.addEventListener('resize', handleResize)
const ro = new ResizeObserver(() => scheduleMeasure())
ro.observe(middleEl)
ro.observe(quizAreaEl)
if (containerRef.current) ro.observe(containerRef.current)
if (leftColRef.current) ro.observe(leftColRef.current)
if (rightColRef.current) ro.observe(rightColRef.current)
return () => {
window.removeEventListener('resize', handleResize)
@@ -246,6 +245,125 @@ const MatchQuizPanel = ({
}
}, [visible, scheduleMeasure])
const renderLeftItem = (item: MatchQuizItem) => {
const isMatched = isLeftItemMatched(item.id)
const isSelected = selectedLeftId === item.id
const isError = errorPairs.some((p) => p.leftId === item.id)
return (
<div
key={item.id}
ref={(el) => {
leftItemRefs.current[item.id] = el
}}
className={cn(
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
'text-[20px] md:text-[28px] leading-relaxed cursor-pointer',
'touch-manipulation',
disabled || isMatched ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
isError
? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
: isSelected
? 'bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105'
: isMatched
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
: 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
)}
onClick={() => handleLeftItemClick(item.id)}
onTouchStart={(e) => {
e.currentTarget.classList.add('scale-105')
}}
onTouchEnd={(e) => {
e.currentTarget.classList.remove('scale-105')
}}
>
{item.label}
{isError && (
<div
ref={(el) => {
leftDotRefs.current[item.id] = el
}}
className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]"
>
<span className="text-white text-[10px] md:text-xs">✕</span>
</div>
)}
{!isError && isMatched && (
<div
ref={(el) => {
leftDotRefs.current[item.id] = el
}}
className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center"
>
<span className="text-white text-xs md:text-sm">✓</span>
</div>
)}
</div>
)
}
const renderRightItem = (item: MatchQuizItem) => {
const isMatched = isRightItemMatched(item.id)
const isError = errorPairs.some((p) => p.rightId === item.id)
return (
<div
key={item.id}
ref={(el) => {
rightItemRefs.current[item.id] = el
}}
className={cn(
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
'text-[20px] md:text-[28px] leading-relaxed',
'touch-manipulation',
disabled || isMatched
? 'opacity-60 cursor-not-allowed'
: selectedLeftId
? 'cursor-pointer'
: 'cursor-default',
isError
? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
: isMatched
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
: selectedLeftId
? 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
: 'bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60'
)}
onClick={() => handleRightItemClick(item.id)}
onTouchStart={(e) => {
if (selectedLeftId) {
e.currentTarget.classList.add('scale-105')
}
}}
onTouchEnd={(e) => {
e.currentTarget.classList.remove('scale-105')
}}
>
{item.label}
{isError && (
<div
ref={(el) => {
rightDotRefs.current[item.id] = el
}}
className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]"
>
<span className="text-white text-[10px] md:text-xs">✕</span>
</div>
)}
{!isError && isMatched && (
<div
ref={(el) => {
rightDotRefs.current[item.id] = el
}}
className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center"
>
<span className="text-white text-xs md:text-sm">✓</span>
</div>
)}
</div>
)
}
if (!visible) return null
return (
@@ -255,13 +373,9 @@ const MatchQuizPanel = ({
ref={containerRef}
>
<div className="relative w-full max-w-[1728px] h-full max-h-[972px] flex flex-col items-center justify-center p-4 md:p-8">
{/* 题干卡片 */}
<div className="w-full max-w-[1700px] rounded-[32px] bg-black/90 px-6 md:px-20 py-6 md:py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] mb-4 md:mb-8">
<div className="text-[24px] md:text-[34px] leading-relaxed text-white text-center">
{title}
</div>
<div className="text-[24px] md:text-[34px] leading-relaxed text-white">{title}</div>
{/* 答案反馈 */}
{error && (
<div className="mt-4 md:mt-5 text-center text-[20px] md:text-[26px] text-[#fcbe53]">
{error}
@@ -269,163 +383,78 @@ const MatchQuizPanel = ({
)}
</div>
{/* 连线区域 */}
<div className="w-full max-w-[1600px] flex flex-col md:flex-row gap-4 md:gap-8 items-start md:items-stretch justify-center">
{/* 左列:日期 */}
<div ref={leftColRef} className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
{leftItems.map((item) => {
const isMatched = isLeftItemMatched(item.id)
const isSelected = selectedLeftId === item.id
const isError = errorPairs.some((p) => p.leftId === item.id)
return (
<div
key={item.id}
ref={(el) => {
leftItemRefs.current[item.id] = el
}}
className={cn(
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
'text-[20px] md:text-[28px] leading-relaxed cursor-pointer',
'touch-manipulation', // 优化移动端触摸
disabled || isMatched ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
isError
? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
: isSelected
? 'bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105'
: isMatched
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
: 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
)}
onClick={() => handleLeftItemClick(item.id)}
onTouchStart={(e) => {
// 移动端触摸优化
e.currentTarget.classList.add('scale-105')
}}
onTouchEnd={(e) => {
e.currentTarget.classList.remove('scale-105')
}}
>
{item.label}
{isError && (
<div className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]">
<span className="text-white text-[10px] md:text-xs">✕</span>
</div>
)}
{!isError && isMatched && (
<div className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs md:text-sm">✓</span>
</div>
)}
</div>
)
})}
</div>
{/* 中间连线区域(移动端隐藏,桌面端显示) */}
<div
ref={middleRef}
className="hidden md:block w-16 relative shrink-0"
style={{ height: middleHeight }}
>
{/* 绘制连线 */}
<svg className="absolute inset-0 w-full h-full pointer-events-none overflow-visible">
{matches.map((match, index) => {
const leftItem = leftItems.find((item) => item.id === match.leftId)
const rightItem = rightItems.find((item) => item.id === match.rightId)
if (!leftItem || !rightItem) return null
const startY = leftCenters[match.leftId]
const endY = rightCenters[match.rightId]
if (startY === undefined || endY === undefined) return null
return (
<line
key={`${match.leftId}-${match.rightId}-${index}`}
x1="0"
y1={startY}
x2="100%"
y2={endY}
stroke="#f0be75"
strokeWidth="3"
strokeDasharray="5,5"
markerEnd="url(#arrowhead)"
/>
)
})}
{/* 箭头标记定义 */}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="10"
refX="9"
refY="3"
orient="auto"
>
<polygon points="0 0, 10 3, 0 6" fill="#f0be75" />
</marker>
</defs>
</svg>
</div>
{/* 右列:事件 */}
<div ref={rightColRef} className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
{rightItems.map((item) => {
const isMatched = isRightItemMatched(item.id)
const isError = errorPairs.some((p) => p.rightId === item.id)
return (
<div
key={item.id}
ref={(el) => {
rightItemRefs.current[item.id] = el
}}
className={cn(
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
'text-[20px] md:text-[28px] leading-relaxed',
'touch-manipulation', // 优化移动端触摸
disabled || isMatched
? 'opacity-60 cursor-not-allowed'
: selectedLeftId
? 'cursor-pointer'
: 'cursor-default',
isError
? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
: isMatched
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
: selectedLeftId
? 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
: 'bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60'
)}
onClick={() => handleRightItemClick(item.id)}
onTouchStart={(e) => {
if (selectedLeftId) {
e.currentTarget.classList.add('scale-105')
}
}}
onTouchEnd={(e) => {
e.currentTarget.classList.remove('scale-105')
}}
>
{item.label}
{isError && (
<div className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]">
<span className="text-white text-[10px] md:text-xs">✕</span>
</div>
)}
{!isError && isMatched && (
<div className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs md:text-sm">✓</span>
</div>
)}
</div>
)
})}
</div>
{/* 移动端 */}
<div className="w-full max-w-[1600px] md:hidden flex flex-col gap-4 items-start justify-center">
<div className="w-full flex flex-col gap-3">{leftItems.map(renderLeftItem)}</div>
<div className="w-full flex flex-col gap-3">{rightItems.map(renderRightItem)}</div>
</div>
{/* 桌面端 */}
<div ref={quizAreaRef} className="hidden md:block w-full max-w-[1600px] relative">
{/* SVG 覆盖整个题目区域,用绝对像素坐标画线 */}
<svg
className="absolute inset-0 pointer-events-none overflow-visible z-10"
width={svgSize.width}
height={svgSize.height}
viewBox={`0 0 ${Math.max(svgSize.width, 1)} ${Math.max(svgSize.height, 1)}`}
>
{matches.map((match, index) => {
const key = `${match.leftId}-${match.rightId}`
const anchor = lineAnchors[key]
if (!anchor) return null
return (
<line
key={`${key}-${index}`}
x1={anchor.startX}
y1={anchor.startY}
x2={anchor.endX}
y2={anchor.endY}
stroke="#f0be75"
strokeWidth="3"
strokeDasharray="5,5"
markerEnd="url(#arrowhead)"
/>
)
})}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="10"
refX="9"
refY="3"
orient="auto"
>
<polygon points="0 0, 10 3, 0 6" fill="#f0be75" />
</marker>
</defs>
</svg>
{useRowAlignedDesktopLayout ? (
<div className="grid grid-cols-[minmax(0,1fr)_64px_minmax(0,1fr)] gap-x-8 gap-y-4 items-stretch">
{leftItems.map((leftItem, index) => {
const rightItem = rightItems[index]
if (!rightItem) return null
return (
<div key={`row-${leftItem.id}-${rightItem.id}`} className="contents">
<div className="min-h-full">{renderLeftItem(leftItem)}</div>
<div className="w-16" />
<div className="min-h-full">{renderRightItem(rightItem)}</div>
</div>
)
})}
</div>
) : (
<div className="flex gap-8 items-stretch justify-center">
<div className="w-1/2 flex flex-col gap-4">{leftItems.map(renderLeftItem)}</div>
<div className="w-16 shrink-0" />
<div className="w-1/2 flex flex-col gap-4">{rightItems.map(renderRightItem)}</div>
</div>
)}
</div>
{/* 移动端连线提示 */}
<div className="md:hidden mt-4 text-center text-[18px] text-white/80">
请先点击左侧日期,再点击右侧对应的事件进行连线
</div>
+107 -5
View File
@@ -5,6 +5,14 @@ import { filterInput } from '@/lib/utils/sensitiveWords'
import { Image, Select, Video, type VideoRef } from '@/components'
import { showHint } from '@/components/hint'
const formatCurrentDate = () => {
const now = new Date()
const year = now.getFullYear()
const month = `${now.getMonth() + 1}`.padStart(2, '0')
const day = `${now.getDate()}`.padStart(2, '0')
return `${year}.${month}.${day}`
}
export type LetterFieldType = 'text' | 'input' | 'select'
export interface LetterFieldOption {
@@ -47,6 +55,7 @@ export interface LetterTemplateProps {
audioSrc?: string
showVideo?: boolean
videoSrc?: string
replyVideoSrc?: string
sceneImageSrc?: string
replyButtonImageSrc?: string
replyImageSrc?: string
@@ -74,19 +83,26 @@ const LetterTemplate = ({
audioSrc,
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyVideoSrc,
sceneImageSrc = 'components/letter/动画结束后场景',
replyButtonImageSrc = 'components/letter/点击敬读',
replyImageSrc,
}: LetterTemplateProps) => {
const [blankValues, setBlankValues] = useState<string[]>([])
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
const [signatureName, setSignatureName] = useState('')
const [signatureError, setSignatureError] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [showSendVideo, setShowSendVideo] = useState(false)
const [showReplyVideo, setShowReplyVideo] = useState(false)
const [showScene, setShowScene] = useState(false)
const [showReplyImage, setShowReplyImage] = useState(false)
const sendVideoRef = useRef<VideoRef>(null)
const replyVideoRef = useRef<VideoRef>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const currentDate = useMemo(() => formatCurrentDate(), [])
const resolvedFields = useMemo(
() => (typeof fields === 'function' ? fields(blankValues) : fields),
[fields, blankValues]
@@ -99,6 +115,13 @@ const LetterTemplate = ({
(_, index) => blankValues[index] ?? ''
)
const trimmedSignatureName = signatureName.trim()
if (!trimmedSignatureName) {
showHint('请填写落款姓名后再提交哦!')
setSignatureError(true)
return false
}
const nextErrors = validate
? validate(nextValues, letterContent)
: nextValues.map((value, index) => {
@@ -146,7 +169,10 @@ const LetterTemplate = ({
setSubmitted(false)
setBlankValues([])
setBlankErrors([])
setSignatureName('')
setSignatureError(false)
setShowSendVideo(false)
setShowReplyVideo(false)
setShowScene(false)
setShowReplyImage(false)
}
@@ -157,14 +183,14 @@ const LetterTemplate = ({
const audio = audioRef.current
if (!audio || !audioSrc) return
if (visible && !showSendVideo) {
if (visible && !showSendVideo && !showScene && !showReplyImage && !showReplyVideo) {
audio.currentTime = 0
audio.play().catch(() => {})
} else {
audio.pause()
audio.currentTime = 0
}
}, [visible, showSendVideo, audioSrc])
}, [visible, showSendVideo, showScene, showReplyImage, showReplyVideo, audioSrc])
useEffect(() => {
const audio = audioRef.current
@@ -252,6 +278,48 @@ const LetterTemplate = ({
}
}
const handleReplyVideoCanPlay = () => {
if (showReplyVideo && replyVideoRef.current) {
replyVideoRef.current.play().catch(() => {})
}
}
const handleSignatureNameChange = (value: string) => {
const nextValue = filterInput(value)
setSignatureName(nextValue)
const hasError = !nextValue.trim()
if (signatureError !== hasError) {
setSignatureError(hasError)
}
}
const renderSignature = (readonly: boolean) => {
if (readonly) {
return (
<div className="absolute right-[240px] bottom-[110px] z-99 flex flex-col items-end text-[26px] leading-[1.8] text-[#4e402e]">
<span>{signatureName.trim() || '  '}</span>
<span>{currentDate}</span>
</div>
)
}
return (
<div className="absolute right-[190px] bottom-[120px] z-99 flex flex-col items-end gap-2 text-[#4e402e]">
<input
name="signature-name"
className={`h-[36px] bg-transparent border-b-2 border-dashed text-center outline-none w-[120px] ${signatureError ? 'border-red-500' : 'border-[#4e402e]'}`}
style={{ fontSize: '26px' }}
autoComplete="off"
value={signatureName}
maxLength={12}
onChange={(e) => handleSignatureNameChange(e.target.value)}
/>
<span className="text-[26px] leading-[1.8]">{currentDate}</span>
</div>
)
}
const renderLine = (readonly: boolean) => {
let placeholderIndex = 0
let bodyStarted = false
@@ -354,7 +422,7 @@ const LetterTemplate = ({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={onClose}
// onClick={onClose}
/>
)}
@@ -416,6 +484,8 @@ const LetterTemplate = ({
</div>
</div>
{renderSignature(false)}
<div className="absolute bottom-[150px] -right-30">
<img
src={getStaticImage('components/letter/笔')}
@@ -424,7 +494,7 @@ const LetterTemplate = ({
/>
</div>
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<div className="absolute bottom-[80px] left-62 z-99">
<Image
src={getStaticImage('components/letter/可提交状态')}
alt="可提交状态"
@@ -468,7 +538,9 @@ const LetterTemplate = ({
</div>
</div>
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
{renderSignature(true)}
<div className="absolute bottom-[80px] left-50 z-99">
<Image
src={getStaticImage('components/letter/寄出')}
alt="寄出"
@@ -577,12 +649,42 @@ const LetterTemplate = ({
onClick={() => {
setShowReplyImage(false)
setShowScene(false)
if (replyVideoSrc) {
setShowReplyVideo(true)
return
}
onClose?.()
onAnimationEnd?.()
}}
/>
</motion.div>
)}
<AnimatePresence>
{showReplyVideo && replyVideoSrc && (
<motion.div
key="reply-video"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="fixed inset-0 z-104 h-full w-full bg-black"
>
<Video
ref={replyVideoRef}
src={getPublicPath(replyVideoSrc)}
className="h-full w-full"
playsInline
onCanPlay={handleReplyVideoCanPlay}
onEnded={() => {
setShowReplyVideo(false)
onClose?.()
onAnimationEnd?.()
}}
/>
</motion.div>
)}
</AnimatePresence>
</>
)}
</AnimatePresence>
+9 -2
View File
@@ -9,6 +9,7 @@ export interface LetterToChenyunProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const letterContent = `敬爱的陈云爷爷:
@@ -31,7 +32,11 @@ const fields: Record<number, LetterFieldConfig> = {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '勤学钻研',
correctValue: '实',
readOnlyFallback: '实',
hints: {
error: '“不唯上、不唯书、只唯实”是陈云提出的著名方法论原则。',
},
},
2: {
type: 'input',
@@ -62,6 +67,7 @@ const LetterToChenyun = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-陈云',
replyVideoSrc = 'video/letter/chenyun_gaizhang.mp4',
}: LetterToChenyunProps) => {
return (
<LetterTemplate
@@ -69,12 +75,13 @@ const LetterToChenyun = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给陈云的信"
title="写给陈云爷爷的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
@@ -9,6 +9,7 @@ export interface LetterToHuangyanpeiProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const commonSelectField: LetterFieldConfig = {
@@ -73,6 +74,7 @@ const LetterToHuangyanpei = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-黄炎培',
replyVideoSrc = 'video/letter/huangyanpei_gaizhang.mp4',
}: LetterToHuangyanpeiProps) => {
return (
<LetterTemplate
@@ -87,6 +89,7 @@ const LetterToHuangyanpei = ({
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
+10 -7
View File
@@ -9,6 +9,7 @@ export interface LetterToLuxunProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const commonSelectField: LetterFieldConfig = {
@@ -17,7 +18,7 @@ const commonSelectField: LetterFieldConfig = {
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '旨在立人', label: '旨在立人' },
{ value: '首在立人', label: '首在立人' },
{ value: '批判求真', label: '批判求真' },
],
}
@@ -28,18 +29,18 @@ const letterContent = `敬爱的鲁迅先生:
今天,我怀着崇敬的心情走进大陆新村 9 号,看着您亲手撰写的《呐喊》《且介亭杂文》《故事新编》等作品,您编辑的《萌芽》《前哨》等杂志,您翻译的《艺术论》《死魂灵》等译文,仿佛能看到您“吾将上下而求索”的身影。在这场跨越时空的对话中,我终于明白您坚守的“____”,不是抽象的理念,而是我们安身立命的根本;您秉持的“____”,不是无谓的锋芒,而是我们直面现实的勇气。
我是____专业的学生,今后,我会以您“____于夫指”的骨气,在是非面前坚守立场,不随波逐流;我会以您“俯首甘为____”的热忱,参与____活动,用微光温暖他人;我更会记住您“____,走的人多了,也便成了路”的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
我是____专业的学生,今后,我会以您“____千夫指”的骨气,在是非面前坚守立场,不随波逐流;我会以您“俯首甘为____”的热忱,参与____活动,用微光温暖他人;我更会记住您“____,走的人多了,也便成了路”的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
请您放心,您播下的“立人”火种,我们会让它在新时代燎原;您毕生追求的“人国”愿景,我们会用青春和奋斗去守护!`
const fields: Record<number, LetterFieldConfig> = {
0: {
...commonSelectField,
correctValue: '旨在立人',
correctValue: '首在立人',
hints: {
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '旨在立人',
readOnlyFallback: '首在立人',
},
1: {
...commonSelectField,
@@ -85,8 +86,8 @@ const fields: Record<number, LetterFieldConfig> = {
type: 'input',
maxLength: 16,
widthClassName: 'w-[120px]',
correctValue: '世上本没有路',
readOnlyFallback: '世上本没有路',
correctValue: '地上本没有路',
readOnlyFallback: '地上本没有路',
hints: {
error: '鲁迅在小说《故乡》中写道:“地上本没有路,走的人多了,也便成了路。”',
},
@@ -108,6 +109,7 @@ const LetterToLuxun = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-鲁迅',
replyVideoSrc = 'video/letter/luxun_gaizhang.mp4',
}: LetterToLuxunProps) => {
return (
<LetterTemplate
@@ -115,12 +117,13 @@ const LetterToLuxun = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给鲁迅的信"
title="写给鲁迅先生的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
+11 -6
View File
@@ -9,6 +9,7 @@ export interface LetterToMaozedongProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const commonSelectField: LetterFieldConfig = {
@@ -44,7 +45,7 @@ const fields: Record<number, LetterFieldConfig> = {
...commonSelectField,
correctValue: '实事求是',
hints: {
error: '再想想哦,这里强调的是毛泽东思想活的灵魂之一“实事求是”。',
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '实事求是',
},
@@ -52,7 +53,7 @@ const fields: Record<number, LetterFieldConfig> = {
...commonSelectField,
correctValue: '群众路线',
hints: {
error: '再想想哦,这里对应的是毛泽东思想活的灵魂之一“群众路线”。',
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '群众路线',
},
@@ -60,7 +61,7 @@ const fields: Record<number, LetterFieldConfig> = {
...commonSelectField,
correctValue: '独立自主',
hints: {
error: '再想想哦,这里对应的是毛泽东思想活的灵魂之一“独立自主”。',
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '独立自主',
},
@@ -75,7 +76,7 @@ const fields: Record<number, LetterFieldConfig> = {
],
correctValue: '实事求是',
hints: {
error: '再想想哦,这里强调的是在专业学习中脚踏实地、掌握扎实技能。',
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '实事求是',
},
@@ -90,7 +91,7 @@ const fields: Record<number, LetterFieldConfig> = {
],
correctValue: '群众路线',
hints: {
error: '再想想哦,这里对应的是服务社会、贡献他人的“群众路线”精神。',
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '群众路线',
},
@@ -128,20 +129,24 @@ const LetterToMaozedong = ({
audioSrc = 'audio/letter/1东方红.m4a',
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyVideoSrc = 'video/letter/mzd_gaizhang.mp4',
replyImageSrc = 'components/letter/点击信详情-毛泽东',
}: LetterToMaozedongProps) => {
// const stampedVideoSrc = videoSrc.replace('neihua_jixin.mp4', 'maozedong_gaizhang.mp4')
return (
<LetterTemplate
visible={visible}
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给毛泽东的信"
title="写给毛泽东主席的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
+4 -1
View File
@@ -9,6 +9,7 @@ export interface LetterToNieerProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const commonSelectField: LetterFieldConfig = {
@@ -87,6 +88,7 @@ const LetterToNieer = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-聂耳',
replyVideoSrc = 'video/letter/nieer_gaizhang.mp4',
}: LetterToNieerProps) => {
return (
<LetterTemplate
@@ -94,13 +96,14 @@ const LetterToNieer = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给聂耳的信"
title="写给聂耳先生的信"
letterContent={letterContent}
contentClassName="!text-[25px]"
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
@@ -9,6 +9,7 @@ export interface LetterToRongdeshengProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const commonSelectField: LetterFieldConfig = {
@@ -79,6 +80,7 @@ const LetterToRongdesheng = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-荣德生',
replyVideoSrc = 'video/letter/rongdesheng_gaizhang.mp4',
}: LetterToRongdeshengProps) => {
return (
<LetterTemplate
@@ -86,12 +88,13 @@ const LetterToRongdesheng = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给荣德生的信"
title="写给荣德生先生的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
@@ -9,6 +9,7 @@ export interface LetterToSunzhongshanProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const commonSelectField: LetterFieldConfig = {
@@ -92,6 +93,7 @@ const LetterToSunzhongshan = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-孙中山',
replyVideoSrc = 'video/letter/sunzhongshan_gaizhang.mp4',
}: LetterToSunzhongshanProps) => {
return (
<LetterTemplate
@@ -99,12 +101,13 @@ const LetterToSunzhongshan = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给孙中山的信"
title="写给孙中山先生的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
@@ -9,6 +9,7 @@ export interface LetterToZhanglepingProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const letterContent = `敬爱的张乐平先生:
@@ -102,6 +103,7 @@ const LetterToZhangleping = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-张乐平',
replyVideoSrc = 'video/letter/zhangleping_gaizhang.mp4',
}: LetterToZhanglepingProps) => {
return (
<LetterTemplate
@@ -109,12 +111,13 @@ const LetterToZhangleping = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给张乐平的信"
title="写给张乐平先生的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
+2 -2
View File
@@ -129,7 +129,7 @@ const Narration = ({
<div
className={cn(
'w-[1600px] bg-black/90 rounded-[32px] px-20 py-16',
'shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]',
'shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] indent-14',
'relative',
className
)}
@@ -155,7 +155,7 @@ const Narration = ({
onClick={handleClose}
className="px-12 py-2 bg-gradient-to-br from-[#d4c5a9] to-[#b8a88a] text-[#4e402e] text-3xl font-bold rounded-full border-3 border-[#8B7355] shadow-lg hover:scale-105 transition-transform"
>
已知晓
继续
</button>
</div>
)}
+2 -2
View File
@@ -114,7 +114,7 @@ export const EndingOverlay = ({
if (!visible) return null
return (
<div className="fixed inset-0 z-10000 flex items-center justify-center bg-transparent">
<div className="fixed inset-0 z-10000 flex items-center justify-center bg-black/40">
{/* 文本内容层 */}
<div
className="relative z-10 flex items-center w-full transition-all py-50 px-24 h-[825px] bg-cover bg-center bg-no-repeat"
@@ -122,7 +122,7 @@ export const EndingOverlay = ({
backgroundImage: `url(${getStaticImage('components/overlay/bg')})`,
}}
>
<div className="text-4xl leading-relaxed text-[#7b3f00] font-semibold tracking-wide">
<div className="text-4xl leading-relaxed text-[#7b3f00] font-semibold tracking-wide indent-18">
{displayedText}
</div>
+22 -116
View File
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { usePuzzleStore } from '@/store/puzzleStore'
import { Image } from '@/components'
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
/**
* 六边形进度条组件
@@ -104,119 +105,22 @@ export interface MenuPuzzleCenterProps {
onClose: () => void
/** 选择某个故居后的回调(进入对应的碎片系统) */
onSelectResidence: (residence: string) => void
/** 是否允许从管理中心进入具体拼图 */
allowPuzzleEntry?: boolean
}
interface ResidenceConfig {
/** 拼图存储用的英文 key(如 chenyun) */
residence: string
/** 显示名称 */
displayName: string
/** 在大地图上的水平位置(百分比字符串,如 "20%") */
left: string
/** 在大地图上的垂直位置(百分比字符串,如 "40%") */
top: string
/** 显示用图片路径(相对于 puzzle/home/ 目录) */
image: string
/** 是否已经开放拼图(未开放时只展示,不可点击) */
hasPuzzle: boolean
/** 是否有导图资源 */
hasMap?: boolean
/** 导图资源文件名(位于 puzzle/home/map 下) */
mapImage?: string
}
/**
* 顶层碎片管理中心中展示的故居列表(8 个故居)
* left / top 为大致位置,后续可以根据视觉效果微调
* image 资源都位于 puzzle/home 目录下,命名可按实际资源调整
*/
const RESIDENCE_LIST: ResidenceConfig[] = [
{
residence: 'maozedong',
displayName: '毛泽东旧居',
left: '30%',
top: '35%',
image: '毛泽东',
hasPuzzle: true,
hasMap: true,
mapImage: '毛泽东导图',
},
{
residence: 'sunzhongshan',
displayName: '孙中山故居',
left: '12%',
top: '51%',
image: '孙中山',
hasPuzzle: true,
hasMap: true,
mapImage: '孙中山导图',
},
{
residence: 'luxun',
displayName: '鲁迅故居',
left: '26.3%',
top: '74%',
image: '鲁迅',
hasPuzzle: true,
hasMap: true,
mapImage: '鲁迅导图',
},
{
residence: 'huangyanpei',
displayName: '黄炎培故居',
left: '44%',
top: '67%',
image: '黄炎培',
hasPuzzle: true,
hasMap: true,
mapImage: '黄炎培导图',
},
{
residence: 'nieer',
displayName: '聂耳旧居',
left: '62%',
top: '60%',
image: '聂耳',
hasPuzzle: true,
hasMap: false,
},
{
residence: 'zhangleping',
displayName: '张乐平故居',
left: '73%',
top: '33%',
image: '张乐平',
hasPuzzle: true,
hasMap: false,
},
{
residence: 'rongdesheng',
displayName: '荣德生旧居',
left: '90%',
top: '45%',
image: '荣德生',
hasPuzzle: true,
hasMap: false,
},
{
residence: 'chenyun',
displayName: '陈云故居',
left: '80%',
top: '76%',
image: '陈云',
hasPuzzle: true,
hasMap: true,
mapImage: '陈云导图',
},
]
const MenuPuzzleCenter = ({ visible, onClose, onSelectResidence }: MenuPuzzleCenterProps) => {
const MenuPuzzleCenter = ({
visible,
onClose,
onSelectResidence,
allowPuzzleEntry = true,
}: MenuPuzzleCenterProps) => {
const { getPuzzleCollectProgress } = usePuzzleStore()
const [activeMapImage, setActiveMapImage] = useState<string | null>(null)
const residenceProgress = useMemo(
() =>
RESIDENCE_LIST.map((item) => ({
PUZZLE_RESIDENCES.map((item) => ({
...item,
// 只有已开放拼图的故居才计算收集进度
progress: item.hasPuzzle ? getPuzzleCollectProgress(item.residence) : 0,
@@ -287,16 +191,18 @@ const MenuPuzzleCenter = ({ visible, onClose, onSelectResidence }: MenuPuzzleCen
{/* 图标 */}
<div className="flex justify-center gap-6">
<Image
src={getStaticImage(`puzzle/home/拼图`)}
alt="拼图"
button
onClick={(e) => {
if (!hasPuzzle) return
e.stopPropagation()
onSelectResidence(residence)
}}
/>
{allowPuzzleEntry && (
<Image
src={getStaticImage(`puzzle/home/拼图`)}
alt="拼图"
button
onClick={(e) => {
if (!hasPuzzle) return
e.stopPropagation()
onSelectResidence(residence)
}}
/>
)}
<Image
src={getStaticImage(`puzzle/home/导图`)}
alt="导图"
+63 -4
View File
@@ -4,6 +4,7 @@ import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getPuzzlePieceData } from '@/config/puzzleData'
import { getPuzzlePiecePath, getPuzzleViewBox } from '@/config/puzzlePaths'
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
import { Image } from '@/components'
/**
@@ -16,6 +17,9 @@ const getResidenceDisplayName = (residence: string): string => {
luxun: '鲁迅',
sunzhongshan: '孙中山',
maozedong: '毛泽东',
zhangleping: '张乐平',
nieer: '聂耳',
rongdesheng: '荣德生',
}
return nameMap[residence] || residence
@@ -28,6 +32,10 @@ export interface PuzzleSystemProps {
onClose: () => void
/** 故居名称,如 'chenyun' */
residence: string
/** 是否显示菜单入口的故居导航 */
showResidenceNav?: boolean
/** 切换故居 */
onResidenceChange?: (residence: string) => void
}
/**
@@ -143,7 +151,13 @@ const PuzzlePieceSvg = ({
* - 已解锁显示正常图片,未解锁显示遮罩
* - 点击碎片可跳转到对应页面
*/
const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
const PuzzleSystem = ({
visible,
onClose,
residence,
showResidenceNav = false,
onResidenceChange,
}: PuzzleSystemProps) => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const { isPuzzleUnlocked, getPuzzleCollectProgress } = usePuzzleStore()
@@ -154,9 +168,15 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
useEffect(() => {
if (!visible) {
setSelectedPiece(null)
setHoveredPiece(null)
}
}, [visible])
useEffect(() => {
setSelectedPiece(null)
setHoveredPiece(null)
}, [residence])
if (!visible) return null
// 背景图片
@@ -177,13 +197,13 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
// 处理前往按钮点击
const handleGoToPage = () => {
if (moduleName && selectedPiece) {
if (activeModuleName && selectedPiece) {
// console.log(residence, selectedPiece, moduleName);
// const route = getPuzzleRoute(residence, selectedPiece, moduleName);
// console.log(route);
const route = selectedPieceData?.route
if (route) {
navigate(`/${moduleName}/${residence}/${route}`)
navigate(`/${activeModuleName}/${residence}/${route}`)
onClose()
}
}
@@ -200,6 +220,12 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
// 获取收集进度
const collectProgress = getPuzzleCollectProgress(residence)
const activeResidenceConfig = PUZZLE_RESIDENCES.find((item) => item.residence === residence)
const activeModuleName = moduleName || activeResidenceConfig?.moduleName
const residenceNavItems = PUZZLE_RESIDENCES.filter((item) => item.hasPuzzle).map((item) => ({
...item,
progress: getPuzzleCollectProgress(item.residence),
}))
return (
<div
@@ -221,6 +247,37 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
backgroundImage: `url(${backgroundImage})`,
}}
>
{showResidenceNav && (
<div className="absolute left-0 top-0 z-10 h-full w-[360px] pt-10 flex flex-col gap-3 pointer-events-auto">
{residenceNavItems.map((item) => {
const isActive = item.residence === residence
const progress = Math.round(item.progress)
return (
<button
key={item.residence}
type="button"
className={cn(
'relative w-[341px] h-[112px] bg-contain bg-center bg-no-repeat text-left transition-all hover:brightness-110',
isActive ? 'translate-x-3 scale-105' : ''
)}
style={{
backgroundImage: `url(${getStaticImage(
isActive ? 'puzzle/选中' : 'puzzle/未选中'
)})`,
}}
onClick={() => onResidenceChange?.(item.residence)}
>
<div className="relative h-full pl-14 pr-5 flex items-center justify-between text-white font-bold">
<span className="text-2xl drop-shadow">{item.displayName}</span>
<span className="text-xl drop-shadow">{progress}%</span>
</div>
</button>
)
})}
</div>
)}
{/* 关闭按钮 */}
<Image
className="absolute right-8 top-8 z-10"
@@ -259,7 +316,9 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
</div>
{/* 完整拼图底图 - 作为背景显示所有碎片拼好的样子 */}
<div className="absolute inset-0 flex justify-end">
<div
className={cn('absolute inset-0 flex justify-end', showResidenceNav ? 'pl-[360px]' : '')}
>
<div className="flex items-center gap-4 px-6 pt-30">
<div className="w-200 h-200 perspective-[1000px]">
{/* SVG拼图容器 - 使用3D变换和缩放 */}
@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import DragFill from './drag-fill'
import type { DragFillProps } from './drag-fill.types'
import { cn } from '@/lib/utils'
const meta: Meta<typeof DragFill> = {
@@ -47,6 +46,25 @@ const meta: Meta<typeof DragFill> = {
export default meta
type Story = StoryObj<typeof DragFill>
function ControlledDragFillStory() {
const [value, setValue] = useState<any[]>([])
return (
<div className="space-y-4">
<DragFill
options={['选项A', '选项B', '选项C']}
blanks={['空白1', '空白2']}
value={value}
onChange={setValue}
onSubmit={(results) => {
alert(`提交的结果: ${JSON.stringify(results)}`)
}}
/>
<div className="text-sm text-gray-600">当前填充: {JSON.stringify(value)}</div>
</div>
)
}
// 基础示例
export const Default: Story = {
args: {
@@ -80,23 +98,7 @@ export const WithObjects: Story = {
// 受控模式
export const Controlled: Story = {
render: () => {
const [value, setValue] = useState<any[]>([])
return (
<div className="space-y-4">
<DragFill
options={['选项A', '选项B', '选项C']}
blanks={['空白1', '空白2']}
value={value}
onChange={setValue}
onSubmit={(results) => {
alert(`提交的结果: ${JSON.stringify(results)}`)
}}
/>
<div className="text-sm text-gray-600">当前填充: {JSON.stringify(value)}</div>
</div>
)
},
render: () => <ControlledDragFillStory />,
}
// 允许重复选择
@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import MultipleChoice from './multiple-choice'
import type { MultipleChoiceProps } from './multiple-choice.types'
import { cn } from '@/lib/utils'
const meta: Meta<typeof MultipleChoice> = {
@@ -47,6 +46,26 @@ const meta: Meta<typeof MultipleChoice> = {
export default meta
type Story = StoryObj<typeof MultipleChoice>
function ControlledMultipleChoiceStory() {
const [values, setValues] = useState<string[]>([])
return (
<div className="space-y-4">
<MultipleChoice
options={['选项A', '选项B', '选项C', '选项D']}
value={values}
onChange={setValues}
onSubmit={(vals) => {
alert(`提交的值: ${vals.join(', ')}`)
}}
/>
<div className="text-sm text-gray-600">
当前选中: {values.length > 0 ? values.join(', ') : '未选择'}
</div>
</div>
)
}
// 基础示例
export const Default: Story = {
args: {
@@ -75,24 +94,7 @@ export const WithOptionObjects: Story = {
// 受控模式
export const Controlled: Story = {
render: () => {
const [values, setValues] = useState<string[]>([])
return (
<div className="space-y-4">
<MultipleChoice
options={['选项A', '选项B', '选项C', '选项D']}
value={values}
onChange={setValues}
onSubmit={(vals) => {
alert(`提交的值: ${vals.join(', ')}`)
}}
/>
<div className="text-sm text-gray-600">
当前选中: {values.length > 0 ? values.join(', ') : '未选择'}
</div>
</div>
)
},
render: () => <ControlledMultipleChoiceStory />,
}
// 限制最大选择数量
@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import SingleChoice from './single-choice'
import type { SingleChoiceProps } from './single-choice.types'
import { cn } from '@/lib/utils'
const meta: Meta<typeof SingleChoice> = {
@@ -39,6 +38,24 @@ const meta: Meta<typeof SingleChoice> = {
export default meta
type Story = StoryObj<typeof SingleChoice>
function ControlledSingleChoiceStory() {
const [value, setValue] = useState<string>('')
return (
<div className="space-y-4">
<SingleChoice
options={['选项A', '选项B', '选项C']}
value={value}
onChange={setValue}
onSubmit={(val) => {
alert(`提交的值: ${val}`)
}}
/>
<div className="text-sm text-gray-600">当前选中: {value || '未选择'}</div>
</div>
)
}
// 基础示例
export const Default: Story = {
args: {
@@ -66,22 +83,7 @@ export const WithOptionObjects: Story = {
// 受控模式
export const Controlled: Story = {
render: () => {
const [value, setValue] = useState<string>('')
return (
<div className="space-y-4">
<SingleChoice
options={['选项A', '选项B', '选项C']}
value={value}
onChange={setValue}
onSubmit={(val) => {
alert(`提交的值: ${val}`)
}}
/>
<div className="text-sm text-gray-600">当前选中: {value || '未选择'}</div>
</div>
)
},
render: () => <ControlledSingleChoiceStory />,
}
// 禁用状态
+267 -21
View File
@@ -20,7 +20,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
chenyun: {
// 碎片1 - 示例数据,请根据实际情况修改
1: {
title: '水乡少年·艰率成长',
title: '水乡少年·艰辛成长',
detailImage: 'chenyun/details/1',
route: 'place1',
},
@@ -208,7 +208,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片16
16: {
title: '黄炎培的”实业救国“情怀”',
title: '黄炎培的“实业救国”情怀',
detailImage: 'huangyanpei/details/16',
route: 'internalization',
},
@@ -220,7 +220,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place1',
},
2: {
title: '郑观应的推荐信郑观应的推荐信',
title: '郑观应的推荐信',
detailImage: 'sunzhongshan/details/2',
route: 'place1',
},
@@ -260,7 +260,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place3',
},
10: {
title: '《中国国民党第一次全国代表大会宣言》',
title: '《中国国民党第一次 全国代表大会宣言》',
detailImage: 'sunzhongshan/details/10',
route: 'place3',
},
@@ -285,7 +285,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'reflection3',
},
15: {
title: '振兴中华对新时代青年的意义',
title: '振兴中华对新时代 青年的意义',
detailImage: 'sunzhongshan/details/15',
route: 'internalization',
},
@@ -297,7 +297,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
luxun: {
1: {
title: '鲁迅1927年南下广州时的照片',
title: '鲁迅1927年南下 广州时的照片',
detailImage: 'luxun/details/1',
route: 'place1',
},
@@ -364,7 +364,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
14: {
title: '鲁迅创作历程',
detailImage: 'luxun/details/14',
route: 'reflection1',
route: 'reflection',
},
15: {
title: '鲁迅精神的总结',
@@ -372,7 +372,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'reflection3',
},
16: {
title: '鲁迅在木刻上对青年的培养',
title: '鲁迅对青年的培养',
detailImage: 'luxun/details/16',
route: 'internalization',
},
@@ -391,73 +391,319 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
3: {
title: '中共二大旧址',
detailImage: 'maozedong/details/3',
route: 'place1',
route: 'place2',
},
4: {
title: '《向导》周刊三篇文章',
detailImage: 'maozedong/details/4',
route: 'place1',
route: 'place2',
},
5: {
title: '中共三大会议召开',
detailImage: 'maozedong/details/5',
route: 'place1',
route: 'place3',
},
6: {
title: '《孙中山手书名单》',
detailImage: 'maozedong/details/6',
route: 'place1',
route: 'place3',
},
7: {
title: '《孙文越飞联合宣言》',
detailImage: 'maozedong/details/7',
route: 'place1',
route: 'place3',
},
8: {
title: '毛泽东签发的两份中共四大通知',
detailImage: 'maozedong/details/8',
route: 'place1',
route: 'place4',
},
9: {
title: '中共四大立碑处',
detailImage: 'maozedong/details/9',
route: 'place1',
route: 'place4',
},
10: {
title: '毛泽东中国社会分析',
detailImage: 'maozedong/details/10',
route: 'place1',
route: 'place4',
},
11: {
title: '湖南农民运动考察报告',
detailImage: 'maozedong/details/11',
route: 'place1',
route: 'place4',
},
12: {
title: '少一人的全家福',
detailImage: 'maozedong/details/12',
route: 'place1',
route: 'place4',
},
13: {
title: '筹备方向的定夺',
detailImage: 'maozedong/details/13',
route: 'place1',
route: 'reflection',
},
14: {
title: '基层声音的汇聚',
detailImage: 'maozedong/details/14',
route: 'place1',
route: 'reflection',
},
15: {
title: '毛泽东思想的确立',
detailImage: 'maozedong/details/15',
route: 'place1',
route: 'internalization',
},
16: {
title: '毛泽东思想活的灵魂',
detailImage: 'maozedong/details/16',
route: 'internalization',
},
},
nieer: {
1: {
title: '九·一八事变',
detailImage: 'nieer/details/1',
route: 'place1',
},
2: {
title: '聂耳发表了《中国歌舞短论》',
detailImage: 'nieer/details/2',
route: 'place1',
},
3: {
title: '聂耳加入左翼文艺团体',
detailImage: 'nieer/details/3',
route: 'place1',
},
4: {
title: '英雄儿女胶片',
detailImage: 'nieer/details/4',
route: 'place2',
},
5: {
title: '志同道合',
detailImage: 'nieer/details/5',
route: 'place2',
},
6: {
title: '聂耳手写《义勇军进行曲》',
detailImage: 'nieer/details/6',
route: 'place3',
},
7: {
title: '聂耳在海外定稿曲谱',
detailImage: 'nieer/details/7',
route: 'place3',
},
8: {
title: '风云儿女上映',
detailImage: 'nieer/details/8',
route: 'place4',
},
9: {
title: '刘良模指挥数千人齐唱',
detailImage: 'nieer/details/9',
route: 'place4',
},
10: {
title: '国歌选定',
detailImage: 'nieer/details/10',
route: 'place5',
},
11: {
title: '国歌入宪',
detailImage: 'nieer/details/11',
route: 'place5',
},
12: {
title: '国歌立法',
detailImage: 'nieer/details/12',
route: 'place5',
},
13: {
title: '新中国奏响国歌的场景(上)',
detailImage: 'nieer/details/13',
route: 'place6',
},
14: {
title: '新中国奏响国歌的场景(下)',
detailImage: 'nieer/details/14',
route: 'place6',
},
15: {
title: '聂耳「人民音乐家」',
detailImage: 'nieer/details/15',
route: 'internalization',
},
16: {
title: '国歌精神的当代传承',
detailImage: 'nieer/details/16',
route: 'internalization',
},
},
zhangleping: {
1: {
title: '童年的回忆 (张乐平,1983)',
detailImage: 'zhangleping/details/1',
route: 'place1',
},
2: {
title: '张母的绣品',
detailImage: 'zhangleping/details/2',
route: 'place1',
},
3: {
title: '穷凶极恶的结果 (张乐平,1937)',
detailImage: 'zhangleping/details/3',
route: 'place2',
},
4: {
title: '长沙大火前夕 张乐平在街头墙上挥笔作画',
detailImage: 'zhangleping/details/4',
route: 'place2',
},
5: {
title: '侵略者的坟墓 (张乐平,1938)',
detailImage: 'zhangleping/details/5',
route: 'place2',
},
6: {
title: '早期三毛',
detailImage: 'zhangleping/details/6',
route: 'place3',
},
7: {
title: '三毛成长(三毛流浪记)',
detailImage: 'zhangleping/details/7',
route: 'place3',
},
8: {
title: '三毛新生(《向日葵》张乐平,1950-1965)',
detailImage: 'zhangleping/details/8',
route: 'place3',
},
9: {
title: '张乐平和儿童互通书信',
detailImage: 'zhangleping/details/9',
route: 'place4',
},
10: {
title: '儿童园丁',
detailImage: 'zhangleping/details/10',
route: 'place4',
},
11: {
title: '朋友画我的瓷板画(右)',
detailImage: 'zhangleping/details/11',
route: 'place5',
},
12: {
title: '朋友画我的瓷板画(左)',
detailImage: 'zhangleping/details/12',
route: 'place5',
},
13: {
title: '名人留言板',
detailImage: 'zhangleping/details/13',
route: 'place5',
},
14: {
title: '画自己',
detailImage: 'zhangleping/details/14',
route: 'place5',
},
15: {
title: '平民立场',
detailImage: 'zhangleping/details/15',
route: 'internalization',
},
16: {
title: '笔耕不辍',
detailImage: 'zhangleping/details/16',
route: 'internalization',
},
},
rongdesheng: {
1: {
title: '荣德生像',
detailImage: 'rongdesheng/details/1',
route: 'place1',
},
2: {
title: '民国时期上海的钱庄',
detailImage: 'rongdesheng/details/2',
route: 'place1',
},
3: {
title: '福新面粉厂',
detailImage: 'rongdesheng/details/3',
route: 'place2',
},
4: {
title: '“兵船”牌面粉商标',
detailImage: 'rongdesheng/details/4',
route: 'place2',
},
5: {
title: '上海申新第九纺织厂门楼(1933年旧照)',
detailImage: 'rongdesheng/details/5',
route: 'place2',
},
6: {
title: '“人钟牌”棉纱商标',
detailImage: 'rongdesheng/details/6',
route: 'place2',
},
7: {
title: '国际劳工局赞美申新三纺织厂劳工自治区',
detailImage: 'rongdesheng/details/7',
route: 'place2',
},
8: {
title: '上海申新一厂和八厂于1937年被日本飞机炸毁',
detailImage: 'rongdesheng/details/8',
route: 'place3',
},
9: {
title: '《人报》报道 乐农氏再度捐助面粉一万包',
detailImage: 'rongdesheng/details/9',
route: 'place3',
},
10: {
title: '心正思无邪 意诚言必中题字',
detailImage: 'rongdesheng/details/10',
route: 'place3',
},
11: {
title: '荣德生被绑架新闻报道',
detailImage: 'rongdesheng/details/11',
route: 'place4',
},
12: {
title: '荣德生拒绝荣氏工业结构外迁的报道',
detailImage: 'rongdesheng/details/12',
route: 'place4',
},
13: {
title: '毛泽东主席、陈毅副总理在荣毅仁陪同下,参观公私合营上海申新第九纺织厂',
detailImage: 'rongdesheng/details/13',
route: 'place4',
},
14: {
title: '荣德生与张 一九四九年九月,荣德生致函第一届全国政协謇的合影',
detailImage: 'rongdesheng/details/14',
route: 'place4',
},
15: {
title: '坚韧不拔',
detailImage: 'rongdesheng/details/15',
route: 'internalization',
},
16: {
title: '社会责任担当',
detailImage: 'rongdesheng/details/16',
route: 'internalization',
},
},
// 后续可以添加其他故居的数据
// sunzhongshan: { ... },
+102
View File
@@ -0,0 +1,102 @@
export interface PuzzleResidenceConfig {
residence: string
displayName: string
moduleName: string
left: string
top: string
image: string
hasPuzzle: boolean
hasMap?: boolean
mapImage?: string
}
export const PUZZLE_RESIDENCES: PuzzleResidenceConfig[] = [
{
residence: 'maozedong',
displayName: '毛泽东旧居',
moduleName: 'revolutionary',
left: '30%',
top: '35%',
image: '毛泽东',
hasPuzzle: true,
hasMap: true,
mapImage: '毛泽东导图',
},
{
residence: 'sunzhongshan',
displayName: '孙中山故居',
moduleName: 'revolutionary',
left: '12%',
top: '51%',
image: '孙中山',
hasPuzzle: true,
hasMap: true,
mapImage: '孙中山导图',
},
{
residence: 'luxun',
displayName: '鲁迅故居',
moduleName: 'cultural',
left: '26.3%',
top: '74%',
image: '鲁迅',
hasPuzzle: true,
hasMap: true,
mapImage: '鲁迅导图',
},
{
residence: 'huangyanpei',
displayName: '黄炎培故居',
moduleName: 'cultural',
left: '44%',
top: '67%',
image: '黄炎培',
hasPuzzle: true,
hasMap: true,
mapImage: '黄炎培导图',
},
{
residence: 'nieer',
displayName: '聂耳旧居',
moduleName: 'artistic',
left: '62%',
top: '60%',
image: '聂耳',
hasPuzzle: true,
hasMap: false,
},
{
residence: 'zhangleping',
displayName: '张乐平故居',
moduleName: 'artistic',
left: '73%',
top: '33%',
image: '张乐平',
hasPuzzle: true,
hasMap: false,
},
{
residence: 'rongdesheng',
displayName: '荣德生旧居',
moduleName: 'economic',
left: '90%',
top: '45%',
image: '荣德生',
hasPuzzle: true,
hasMap: false,
},
{
residence: 'chenyun',
displayName: '陈云故居',
moduleName: 'economic',
left: '80%',
top: '76%',
image: '陈云',
hasPuzzle: true,
hasMap: true,
mapImage: '陈云导图',
},
]
export const DEFAULT_PUZZLE_RESIDENCE =
PUZZLE_RESIDENCES.find((item) => item.hasPuzzle)?.residence || 'maozedong'
+13 -3
View File
@@ -1,8 +1,15 @@
import { createContext, useContext, ReactNode } from 'react'
interface TriggerPuzzleAnimationOptions {
onComplete?: () => void
}
interface PuzzleAnimationContextType {
/** 触发碎片动画,传递碎片ID */
triggerPuzzleAnimation: (pieceId: number) => void
/** 触发碎片动画,传递碎片ID,并在动画结束后 resolve */
triggerPuzzleAnimation: (
pieceId: number,
options?: TriggerPuzzleAnimationOptions
) => Promise<void>
}
const PuzzleAnimationContext = createContext<PuzzleAnimationContextType | null>(null)
@@ -12,7 +19,10 @@ export const PuzzleAnimationProvider = ({
triggerPuzzleAnimation,
}: {
children: ReactNode
triggerPuzzleAnimation: (pieceId: number) => void
triggerPuzzleAnimation: (
pieceId: number,
options?: TriggerPuzzleAnimationOptions
) => Promise<void>
}) => {
return (
<PuzzleAnimationContext.Provider value={{ triggerPuzzleAnimation }}>
+57
View File
@@ -12,6 +12,63 @@ const SENSITIVE_WORDS = [
// 可以根据实际需求添加更多敏感词
]
type FileSaveHandle = {
createWritable: () => Promise<{
write: (data: Blob) => Promise<void>
close: () => Promise<void>
}>
}
type WindowWithFilePicker = Window & {
showSaveFilePicker?: (options?: {
suggestedName?: string
types?: Array<{
accept: Record<string, string[]>
}>
}) => Promise<FileSaveHandle>
}
const isAbortError = (error: unknown) =>
error instanceof DOMException && error.name === 'AbortError'
const triggerAnchorDownload = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.rel = 'noopener'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
export async function downloadDataUrlImage(dataUrl: string, filename: string): Promise<void> {
if (!dataUrl) return
const response = await fetch(dataUrl)
const blob = await response.blob()
const fileType = blob.type || 'image/png'
const picker = (window as WindowWithFilePicker).showSaveFilePicker
if (picker) {
try {
const handle = await picker({
suggestedName: filename,
types: [{ accept: { [fileType]: ['.png'] } }],
})
const writable = await handle.createWritable()
await writable.write(blob)
await writable.close()
return
} catch (error) {
if (isAbortError(error)) return
}
}
triggerAnchorDownload(blob, filename)
}
/**
* 检查文本中是否包含敏感词
* @param text 待检查的文本
+36 -24
View File
@@ -3,6 +3,7 @@ import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image, PuzzleSystem } from '@/components'
import { useNavigate } from 'react-router-dom'
import MenuPuzzleCenter from '@/components/puzzle/MenuPuzzleCenter'
import { DEFAULT_PUZZLE_RESIDENCE } from '@/config/puzzleResidences'
const Menu = () => {
const navigate = useNavigate()
@@ -51,15 +52,37 @@ const Menu = () => {
onClick={() => navigate('/')}
/>
{/* 左下角碎片盒按钮 - 进入碎片管理中心 */}
{/* 左下角碎片盒按钮 - 进入拼图系统 */}
<div className="absolute left-4 bottom-4 z-10 cursor-pointer">
<Image
src={getStaticImage('components/拼图盒')}
button
onClick={() => setIsPuzzleCenterVisible(true)}
onClick={() => setActiveResidence(DEFAULT_PUZZLE_RESIDENCE)}
/>
</div>
{/* 顶层碎片管理中心:查看故居导图与进度 */}
<MenuPuzzleCenter
visible={isPuzzleCenterVisible}
onClose={() => setIsPuzzleCenterVisible(false)}
onSelectResidence={(residence) => {
setActiveResidence(residence)
setIsPuzzleCenterVisible(false)
}}
allowPuzzleEntry={false}
/>
{/* 进入具体故居的拼图系统(与故居页面一致的 PuzzleSystem) */}
{activeResidence && (
<PuzzleSystem
visible={!!activeResidence}
onClose={() => setActiveResidence(null)}
residence={activeResidence}
showResidenceNav
onResidenceChange={setActiveResidence}
/>
)}
<div className="absolute left-40 top-70 inline-grid grid-cols-2 gap-x-20 gap-y-10">
{Category.map((item) => (
<div
@@ -80,28 +103,17 @@ const Menu = () => {
))}
</div>
{/* 顶层碎片管理中心:选择要查看的故居拼图 */}
<MenuPuzzleCenter
visible={isPuzzleCenterVisible}
onClose={() => setIsPuzzleCenterVisible(false)}
onSelectResidence={(residence) => {
setActiveResidence(residence)
setIsPuzzleCenterVisible(false)
}}
/>
{/* 进入具体故居的拼图系统(与故居页面一致的 PuzzleSystem) */}
{activeResidence && (
<PuzzleSystem
visible={!!activeResidence}
onClose={() => {
setActiveResidence(null)
// 关闭 PuzzleSystem 后,重新打开碎片管理中心
setIsPuzzleCenterVisible(true)
}}
residence={activeResidence}
/>
)}
{/* 学习地图 */}
<Image className="absolute bottom-100 right-60" src={getStaticImage('menu/学习地图')} />
<div className="absolute bottom-50 right-57">
<div className="relative">
<Image
src={getStaticImage('menu/导航地图')}
button
onClick={() => setIsPuzzleCenterVisible(true)}
/>
</div>
</div>
</div>
)
}
+77 -27
View File
@@ -8,10 +8,11 @@ import {
type DragFillResult,
LetterToChenyun,
EndingOverlay,
Narration,
} from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
import { GuideSteps, GuideStepsItem } from '@/components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
@@ -44,9 +45,9 @@ const MULTIPLE_DEFAULT_ERROR = '你的选择还不够全面哦~请你再想想
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: 'quiz-1',
time: 60 * 5 + 52, //5.52分钟
time: 60 * 5 + 53, //5.52分钟
title: '题目1',
prompt: '陈云经济思想的核心内容指的是(\xa0\xa0\xa0\xa0)。',
prompt: '陈云经济思想的核心内容包括:',
type: 'multiple',
options: [
{ value: 'A', label: '综合平衡', isCorrect: true },
@@ -61,7 +62,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
id: 'quiz-2',
time: 60 * 10 + 1, //10.01分钟
time: 60 * 10 + 2, //10.01分钟
title: '题目2',
prompt: '陈云说过以下哪些话能着重体现出他的经济思想?',
type: 'multiple',
@@ -83,7 +84,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
id: 'quiz-3',
time: 16 * 60 + 26, //16:26
time: 16 * 60 + 31, //16:26
title: '题目3',
prompt: '新时代的青年可以从陈云身上学习到',
type: 'drag',
@@ -103,6 +104,21 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
id: 'quiz-4',
time: 60 * 5 + 21, // 5:21
title: '题目4',
prompt: '陈云讲:“经济建设最终目的,是为了改善____的生活水平。”',
type: 'single',
options: [
{
value: 'A',
label: '为官者',
explanation: '这不是陈云提倡的经济建设的最终目的。请你再想想吧!',
},
{ value: 'B', label: '老百姓', isCorrect: true },
],
},
{
id: 'quiz-5',
time: 60 * 16 + 23, // 16:23
title: '题目5',
prompt: '以下哪些陈云的生活小事值得我们学习?',
type: 'multiple',
options: [
@@ -113,25 +129,6 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
],
explanation: MULTIPLE_DEFAULT_ERROR,
},
{
id: 'quiz-5',
time: 60 * 16 + 23, // 16:23
title: '题目5',
prompt: '新时代的青年要从陈云身上学习哪些精神品质?',
type: 'multiple',
options: [
{ value: 'A', label: '谦虚谨慎,实事求是的工作作风', isCorrect: true },
{ value: 'B', label: '勤俭朴素,艰苦奋斗的优良作风', isCorrect: true },
{ value: 'C', label: '密切联系群众,关心群众生活', isCorrect: true },
{
value: 'D',
label: '只唯上的工作作风',
isCorrect: false,
explanation: '"只唯上"的工作作风与陈云同志实事求是的精神相悖。请你再想想吧!',
},
],
explanation: MULTIPLE_DEFAULT_ERROR,
},
]
const ChenyunInternalization = () => {
@@ -144,6 +141,7 @@ const ChenyunInternalization = () => {
// 拖拽题状态
const [dragQuizValues, setDragQuizValues] = useState<Record<string, DragFillResult[]>>({})
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>({})
const [narrationVisible, setNarrationVisible] = useState(false)
const [visible, setVisible] = useState(false)
const [showVideo, setShowVideo] = useState(false)
const [postGuideVisible, setPostGuideVisible] = useState(false)
@@ -152,7 +150,7 @@ const ChenyunInternalization = () => {
const videoRef = useRef<VideoRef>(null)
useEffect(() => {
setVisible(true)
setNarrationVisible(true)
}, [])
// 视频加载完成后自动播放
@@ -307,7 +305,48 @@ const ChenyunInternalization = () => {
}
}
// 单选题处理(保留原有逻辑,以防万一)
if (item.type === 'single') {
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => (
<>
<SingleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`【单选】${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers[0]}
onWrongSelect={(value) =>
item.options.find((opt) => opt.value === value)?.explanation
}
onSubmit={(_, isCorrect) => {
if (isCorrect) {
setActiveSuccess(item.id)
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你,选择正确!"
onContinue={() => {
setActiveSuccess(null)
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
),
}
}
return {
id: item.id,
time: item.time,
@@ -336,6 +375,17 @@ const ChenyunInternalization = () => {
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
<Narration
visible={narrationVisible}
audioChannelId="chenyun-internalization-narration"
onClose={() => {
setNarrationVisible(false)
setVisible(true)
}}
text={`从练塘水乡的少年时光,到商务印书馆的革命启航;从苏区与延安的经济破局,到新中国成立之初打赢“银元之战”“米棉之战”,再到建设与改革岁月里擘画实业、奠基兴国,我们已循着陈云同志的足迹,走过一段波澜壮阔的奋斗征程。那些“全国一盘棋”的统筹智慧、“不唯上、不唯书、只唯实”的务实品格、“为老百姓做事”的为民初心,穿越历史烟云,依旧闪耀着时代光芒。历史的回响已在耳畔,精神的传承更需躬身践行。新时代的青年,应该从历史感悟走向思想内化,读懂陈云经济思想的深刻内涵,汲取指引青春成长、担当时代使命的精神力量,让伟人风范在新时代薪火相传。`}
animationSpeed={200}
/>
{showVideo && (
<Video
ref={videoRef}
@@ -434,7 +484,7 @@ const ChenyunInternalization = () => {
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(24)
navigate(`/${moduleName}/${starName}/home`)
navigate('/menu')
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_40.mp3')}
/>
+1
View File
@@ -235,6 +235,7 @@ const ChenyunPlace1 = () => {
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_01.mp3')}
firstLineIndent="2em"
>
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
此刻你正站在陈云同志的故居堂屋里,接下来请跟着时光的脚步,
+54 -2
View File
@@ -7,7 +7,10 @@ import {
GuideSteps,
GuideStepsItem,
Video,
SelectFillQuizPanel,
} from '@/components'
import { SuccessAlert } from '@/components/layout'
import type { SelectFillResult } from '@/components/layout/quiz'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
@@ -46,6 +49,8 @@ const ChenyunPlace3 = () => {
visited,
})
const [videoVisible, setVideoVisible] = useState(false)
const [quizVisible, setQuizVisible] = useState(false)
const [successAlertVisible, setSuccessAlertVisible] = useState(false)
// GuideSteps 显示状态
const [guideVisible, setGuideVisible] = useState(false)
// GuideSteps 当前步骤
@@ -142,13 +147,60 @@ const ChenyunPlace3 = () => {
controls={false}
onEnded={() => {
setVideoVisible(false)
markExhibitVisited(place, 2)
triggerPuzzleAnimation(5)
setQuizVisible(true)
}}
onSkip={() => {
setVideoVisible(false)
setQuizVisible(true)
}}
/>
</div>
)}
<SelectFillQuizPanel
visible={quizVisible}
titlePrefix="【填空】"
titleSuffix="年八九月间,陈云由董亦湘、恽雨棠两人介绍,加入中国共产党。"
blanks={[{ id: 'd1' }, { id: 'd2' }, { id: 'd3' }, { id: 'd4' }]}
options={[
{ value: '0', label: '0' },
{ value: '1', label: '1' },
{ value: '2', label: '2' },
{ value: '3', label: '3' },
{ value: '4', label: '4' },
{ value: '5', label: '5' },
{ value: '6', label: '6' },
{ value: '7', label: '7' },
{ value: '8', label: '8' },
{ value: '9', label: '9' },
]}
correctAnswers={{
d1: '1',
d2: '9',
d3: '2',
d4: '5',
}}
errorMessage="一九二五年八九月间,陈云由董亦湘、恽雨棠两人介绍,加入中国共产党。这是陈云思想上的重大转变,是他新的政治生命的开始。请你再想想吧!"
onSubmit={(_results: SelectFillResult[], isCorrect: boolean) => {
if (isCorrect) {
setQuizVisible(false)
setSuccessAlertVisible(true)
}
}}
onError={(_error: string) => {}}
submitText="确定"
zIndex={10000}
/>
<SuccessAlert
visible={successAlertVisible}
onContinue={() => {
setSuccessAlertVisible(false)
markExhibitVisited(place, 2)
triggerPuzzleAnimation(5)
}}
/>
{/* 遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
+229 -233
View File
@@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { useCallback, useRef, useState } from 'react'
import {
ExhibitOverlay,
Title,
Image,
Cursor,
@@ -18,172 +17,225 @@ import type { VideoRef } from '@/components/video'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
// starName 固定为 chenyun
const starName = 'chenyun'
const STAR_NAME = 'chenyun'
const PLACE = 'place7'
const EXHIBIT_ID = 1
const PUZZLE_PIECE_ID = 10
const TOTAL_EXHIBITS = 1
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1, string> = {
1: 'chenyun/place5/场景5展品1',
}
// ─── 资源路径 ───
const BACKGROUND_KEY = `${STAR_NAME}/${PLACE}/场景7`
const CONTINUE_IMAGE_KEY = `${STAR_NAME}/${PLACE}/继续前进`
const VIDEO_1_SRC = getPublicPath(`video/${STAR_NAME}/chenyun_10.mp4`)
const VIDEO_2_SRC = getPublicPath(`video/${STAR_NAME}/chenyun_11.mp4`)
const AUDIO_PREFIX = `audio/${STAR_NAME}`
// 题目1选项
// ─── 题目1:单选 ───
const QUIZ1_OPTIONS: SingleChoiceOption[] = [
{
value: 'A',
label: 'A.推动了淮海战役的胜利',
},
{
value: 'B',
label: 'B.有利于稳定人民币的发行和地位',
},
{
value: 'C',
label: 'C.巩固了新中国人民政权',
},
{ value: 'A', label: 'A.推动了淮海战役的胜利' },
{ value: 'B', label: 'B.有利于稳定人民币的发行和地位' },
{ value: 'C', label: 'C.巩固了新中国人民政权' },
]
// 题目1各选项对应的错误提示(原来写在选项括号中的内容)
const QUIZ1_CORRECT: string = 'B'
const QUIZ1_TITLE =
'【单选】1949年6月,中共中央指示打击银元投机活动,金条、银元一律由人民银行挂牌收兑,铁路交通事业,税收一律收人民币。同时,上海军管会查封了证券大楼。这次"银元之战":'
const QUIZ1_ERROR_HINTS: Record<string, string> = {
A: '淮海战役于1949年1月胜利结束,发生在这次“银元之战”前,不存在因果关系。请你再想想吧!',
A: '淮海战役于1949年1月胜利结束,发生在这次"银元之战"前,不存在因果关系。请你再想想吧!',
C: '1949年6月新中国政权尚未建立。请你再想想吧',
}
const QUIZ1_FALLBACK_HINT = '再想一想,注意题干中的时间和提示信息哦~'
const QUIZ1_SUCCESS_CONTENT =
'"银元之战"属于当时人民政府整顿金融的重要举措,黄金、银元投机不利于人民币币值稳定和流通,因此"银元之战"打击贵金属投机活动,有利于稳定人民币的发行和地位。'
// ─── 题目2:拖拽 ───
const QUIZ2_BLANKS = [
{ id: 'blank-1', label: '____' },
{ id: 'blank-2', label: '____' },
{ id: 'blank-3', label: '____' },
]
const QUIZ2_OPTIONS = [
{ value: '棉纱', label: '棉纱' },
{ value: '大米', label: '大米' },
{ value: '煤炭', label: '煤炭' },
{ value: '石油', label: '石油' },
]
const QUIZ2_CORRECT_SET = new Set(['棉纱', '大米', '煤炭'])
const QUIZ2_PREFIX =
'【拖拽】新中国成立后,为了稳定物价,中央政府组织了"两白一黑"的集中调运,"两白一黑"指的是'
const QUIZ2_ERROR = '石油当时不是主要调控对象。请你再想想吧!'
const isQuiz2Correct = (answers: DragFillResult[]): boolean => {
if (answers.length !== QUIZ2_CORRECT_SET.size) return false
const filled = new Set(answers.map((a) => a.optionValue))
if (filled.size !== QUIZ2_CORRECT_SET.size) return false
for (const value of QUIZ2_CORRECT_SET) {
if (!filled.has(value)) return false
}
return true
}
const ChenyunPlace7 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
moduleName: string
}>()
// 获取碎片动画触发方法
const { moduleName } = useParams<{ moduleName: string }>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null)
// 使用持久化的访问状态
const place = 'place7'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
const [videoVisible1, setVideoVisible1] = useState(false)
const [videoPlaying, setVideoPlaying] = useState(false)
const videoRef = useRef<VideoRef>(null)
// 单选题
const [quizVisible, setQuizVisible] = useState(false)
const [quizAnswer, setQuizAnswer] = useState<string>('')
const [quizError, setQuizError] = useState<string>('')
// 展品1引导对话
const [guideVisible, setGuideVisible] = useState(false)
const [guideStep, setGuideStep] = useState(0)
// 第二阶段:对话2 + 视频2 + 题目2
const [videoVisible2, setVideoVisible2] = useState(false)
const [videoPlaying2, setVideoPlaying2] = useState(false)
const visited = new Set(getVisitedExhibits(PLACE))
const completed = visited.size >= TOTAL_EXHIBITS
// ─── 第一阶段:视频1 + 对话1 + 单选题 ───
const videoRef1 = useRef<VideoRef>(null)
const [video1Visible, setVideo1Visible] = useState(false)
const [video1Playing, setVideo1Playing] = useState(false)
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
const [quiz1Visible, setQuiz1Visible] = useState(false)
const [quiz1Completed, setQuiz1Completed] = useState(false)
// ─── 第二阶段:视频2 + 对话2 + 拖拽题 ───
const videoRef2 = useRef<VideoRef>(null)
const [guideVisible2, setGuideVisible2] = useState(false)
const [guideStep2, setGuideStep2] = useState(0)
const [video2Visible, setVideo2Visible] = useState(false)
const [video2Playing, setVideo2Playing] = useState(false)
const [guide2Visible, setGuide2Visible] = useState(false)
const [guide2Step, setGuide2Step] = useState(0)
const [quiz2Visible, setQuiz2Visible] = useState(false)
const [quiz2Error, setQuiz2Error] = useState<string>('')
const [quiz2Value, setQuiz2Value] = useState<DragFillResult[]>([])
// 成功提示
const [successAlertVisible, setSuccessAlertVisible] = useState(false)
const [successAlertContent, setSuccessAlertContent] = useState<string>('')
// 标记拖拽题是否已完成
const [quiz2Error, setQuiz2Error] = useState('')
const [quiz2Completed, setQuiz2Completed] = useState(false)
const handleOpenExhibit = (_index: 1) => {
// 如果本场景学习已完成(拖拽题已答对),不再响应点击,避免重复播放
if (quiz2Completed) return
// ─── 成功提示 ───
const [successVisible, setSuccessVisible] = useState(false)
const [successContent, setSuccessContent] = useState('')
// 展品1:点击时显示视频1场景和引导对话1
setVideoVisible1(true)
setVideoPlaying(false) // 视频不自动播放
setGuideVisible(true)
setGuideStep(0)
}
// ─── 流程辅助 ───
const resetFlow = useCallback(() => {
setVideo1Visible(false)
setVideo1Playing(false)
setGuide1Visible(false)
setGuide1Step(0)
setQuiz1Visible(false)
setQuiz1Completed(false)
// 监听GuideSteps步骤变化
const handleGuideStepChange = (step: number) => {
setGuideStep(step)
}
setVideo2Visible(false)
setVideo2Playing(false)
setGuide2Visible(false)
setGuide2Step(0)
setQuiz2Visible(false)
setQuiz2Value([])
setQuiz2Error('')
setQuiz2Completed(false)
// 监听对话引导完成
const handleGuideVisibleChange = (visible: boolean) => {
setGuideVisible(visible)
// 对话引导完成后,开始播放视频
if (!visible && videoVisible1) {
setVideoPlaying(true)
videoRef.current?.play()
setSuccessVisible(false)
setSuccessContent('')
}, [])
const enterPhase1 = useCallback(() => {
setVideo1Visible(true)
setVideo1Playing(false)
setGuide1Visible(true)
setGuide1Step(0)
}, [])
const enterPhase2 = useCallback(() => {
setVideo2Visible(true)
setVideo2Playing(false)
setGuide2Visible(true)
setGuide2Step(0)
}, [])
// 点击展品光标:每次都从头走完整流程
const handleOpenExhibit = useCallback(() => {
resetFlow()
enterPhase1()
}, [resetFlow, enterPhase1])
// 对话1 结束 → 播放视频1
const handleGuide1VisibleChange = (visible: boolean) => {
setGuide1Visible(visible)
if (!visible && video1Visible) {
setVideo1Playing(true)
videoRef1.current?.play()
}
}
// 对话2步骤变化
const handleGuideStepChange2 = (step: number) => {
setGuideStep2(step)
}
// 对话2完成 → 播放视频2
const handleGuideVisibleChange2 = (visible: boolean) => {
setGuideVisible2(visible)
if (!visible && videoVisible2) {
setVideoPlaying2(true)
// 对话2 结束 → 播放视频2
const handleGuide2VisibleChange = (visible: boolean) => {
setGuide2Visible(visible)
if (!visible && video2Visible) {
setVideo2Playing(true)
videoRef2.current?.play()
}
}
// 拖拽题配置
const QUIZ2_BLANKS = [
{ id: 'blank-1', label: '____' },
{ id: 'blank-2', label: '____' },
{ id: 'blank-3', label: '____' },
]
const QUIZ2_OPTIONS = [
{ value: '棉纱', label: '棉纱' },
{ value: '大米', label: '大米' },
{ value: '煤炭', label: '煤炭' },
{ value: '石油', label: '石油' },
]
const QUIZ2_ANSWER = ['棉纱', '大米', '煤炭']
// 视频1 结束:标记访问 + 触发拼图 + 出题1
const handleVideo1Ended = () => {
setVideo1Playing(false)
markExhibitVisited(PLACE, EXHIBIT_ID)
triggerPuzzleAnimation(PUZZLE_PIECE_ID)
setQuiz1Visible(true)
}
// 题1 答对:弹成功提示,关闭后进入第二阶段
const handleQuiz1Submit = () => {
setQuiz1Visible(false)
setQuiz1Completed(true)
setSuccessContent(QUIZ1_SUCCESS_CONTENT)
setSuccessVisible(true)
}
// 视频2 结束:出题2
const handleVideo2Ended = () => {
setVideo2Playing(false)
setQuiz2Visible(true)
}
// 题2 实时校验:填齐后判定
const handleQuiz2Change = (val: DragFillResult[]) => {
setQuiz2Value(val)
setQuiz2Error('')
if (val.length < QUIZ2_ANSWER.length) return
// 获取所有已填入的答案(不要求顺序)
const filledAnswers = val.map((v) => v.optionValue)
// 检查是否包含了所有正确答案(使用 Set 比较,不要求顺序)
const answerSet = new Set(QUIZ2_ANSWER)
const filledSet = new Set(filledAnswers)
const isCorrect =
answerSet.size === filledSet.size && [...answerSet].every((answer) => filledSet.has(answer))
if (isCorrect) {
setQuiz2Visible(false)
setVideoVisible1(false)
setVideoPlaying(false)
setVideoVisible2(false)
setVideoPlaying2(false)
// 标记拖拽题已完成
setQuiz2Completed(true)
// 显示成功提示
setSuccessAlertVisible(true)
} else {
setQuiz2Error('再想一想,石油当时不是主要调控对象。请你再想想吧!')
if (val.length < QUIZ2_CORRECT_SET.size) return
if (!isQuiz2Correct(val)) {
setQuiz2Error(QUIZ2_ERROR)
return
}
setQuiz2Visible(false)
setVideo1Visible(false)
setVideo2Visible(false)
setQuiz2Completed(true)
setSuccessContent('')
setSuccessVisible(true)
}
// 成功提示关闭:题1答对后才进入第二阶段;题2答对后则停留在场景
const handleSuccessDismiss = () => {
setSuccessVisible(false)
if (quiz1Completed && !quiz2Completed && !quiz2Visible) {
enterPhase2()
}
}
const progressCount = visited.size
const total = 1
const completed = progressCount >= total
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
if (!moduleName) return null
const subtitle = completed ? '已完成' : `${visited.size}/${TOTAL_EXHIBITS}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: getBackgroundImage(`chenyun/place7/场景7`),
}}
style={{ backgroundImage: getBackgroundImage(BACKGROUND_KEY) }}
>
{/* 标题 */}
<Title
@@ -198,76 +250,66 @@ const ChenyunPlace7 = () => {
className="absolute right-5 top-5"
src={getStaticImage('components/离开')}
button
onClick={() => {
if (moduleName && starName) {
navigate(`/${moduleName}/${starName}/place6`)
}
}}
onClick={() => navigate(`/${moduleName}/${STAR_NAME}/place6`)}
/>
{/* 展品1 */}
{/* 展品1 光标 */}
<Cursor
className="absolute left-230 top-88"
onClick={() => handleOpenExhibit(1)}
read={visited.has(1)}
onClick={handleOpenExhibit}
read={visited.has(EXHIBIT_ID)}
/>
{/* 继续前进 - 仅在完成所有展品后显示 */}
{completed && (
<Image
className="absolute bottom-0 right-0"
src={getStaticImage('chenyun/place7/继续前进')}
src={getStaticImage(CONTINUE_IMAGE_KEY)}
button
onClick={() => {
setLastVisitedPlace('place8')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place8`)
navigate(`/${moduleName}/${STAR_NAME}/place8`)
}}
/>
)}
{/* 展品1视频播放(全屏) */}
{videoVisible1 && (
<div className="fixed inset-0 z-[9999] bg-black">
{/* 视频1(全屏) */}
{video1Visible && (
<div className="fixed inset-0 z-9999 bg-black">
<Video
ref={videoRef}
src={getPublicPath('video/chenyun/chenyun_10.mp4')}
ref={videoRef1}
src={VIDEO_1_SRC}
className="w-full h-full"
autoPlay={videoPlaying}
autoPlay={video1Playing}
playsInline
preload="auto"
muted={!videoPlaying}
muted={!video1Playing}
showSkipButton
onEnded={() => {
setVideoPlaying(false)
markExhibitVisited(place, 1)
triggerPuzzleAnimation(10)
// 视频播放完成后,出现单选题
setQuizVisible(true)
}}
onEnded={handleVideo1Ended}
/>
</div>
)}
{/* 展品1的引导对话 - 在视频上方显示 */}
{/* 对话1(叠加在视频1之上) */}
<GuideSteps
visible={guideVisible}
onVisibleChange={handleGuideVisibleChange}
currentStep={guideStep}
onStepChange={handleGuideStepChange}
visible={guide1Visible}
onVisibleChange={handleGuide1VisibleChange}
currentStep={guide1Step}
onStepChange={setGuide1Step}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_19.mp3')}
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_19.mp3`)}
>
那"银元之战"是如何取得胜利的呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_20.mp3')}
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20.mp3`)}
>
由于通货膨胀,国民党发行的法币、金圆券变成了一堆废纸。当时坊间流传"大街过三道,物价跳三跳"。
</GuideStepsItem>
@@ -279,80 +321,64 @@ const ChenyunPlace7 = () => {
</GuideStepsItem>
</GuideSteps>
{/* 单选题1:在视频1结束后出现(点击即判定) */}
{/* 题目1:单选 */}
<SingleChoiceQuizPanel
visible={quizVisible}
visible={quiz1Visible}
zIndex={10002}
title={`【单选】1949年6月,中共中央指示打击银元投机活动,金条、银元一律由人民银行挂牌收兑,铁路交通事业,税收一律收人民币。同时,上海军管会查封了证券大楼。这次“银元之战”:`}
title={QUIZ1_TITLE}
options={QUIZ1_OPTIONS}
correctAnswer={'B'}
errorMessage={quizError}
onSubmit={(val) => {
setQuizAnswer(val)
setQuizVisible(false)
setQuizError('')
// 显示成功提示
setSuccessAlertVisible(true)
setSuccessAlertContent(
'“银元之战”属于当时人民政府整顿金融的重要举措,黄金、银元投机不利于人民币币值稳定和流通,因此“银元之战”打击贵金属投机活动,有利于稳定人民币的发行和地位。'
)
}}
onWrongSelect={(val) => {
return QUIZ1_ERROR_HINTS[val] || '再想一想,注意题干中的时间和提示信息哦~'
}}
correctAnswer={QUIZ1_CORRECT}
onSubmit={handleQuiz1Submit}
onWrongSelect={(val) => QUIZ1_ERROR_HINTS[val] || QUIZ1_FALLBACK_HINT}
/>
{/* 视频2播放(全屏) */}
{videoVisible2 && (
<div className="fixed inset-0 z-[10010] bg-black">
{/* 视频2(全屏) */}
{video2Visible && (
<div className="fixed inset-0 z-10010 bg-black">
<Video
ref={videoRef2}
src={getPublicPath('video/chenyun/chenyun_11.mp4')}
src={VIDEO_2_SRC}
className="w-full h-full"
autoPlay={videoPlaying2}
autoPlay={video2Playing}
playsInline
preload="auto"
muted={!videoPlaying2}
muted={!video2Playing}
showSkipButton
skipText="跳过"
onEnded={() => {
setVideoPlaying2(false)
// 视频2结束后,停在最后一帧并出现题目2
setQuiz2Visible(true)
}}
onEnded={handleVideo2Ended}
/>
</div>
)}
{/* 对话2:在视频2背景上显示 */}
{/* 对话2(叠加在视频2之上) */}
<GuideSteps
visible={guideVisible2}
onVisibleChange={handleGuideVisibleChange2}
currentStep={guideStep2}
onStepChange={handleGuideStepChange2}
visible={guide2Visible}
onVisibleChange={handleGuide2VisibleChange}
currentStep={guide2Step}
onStepChange={setGuide2Step}
showMask={false}
highlightSelectors={[]}
zIndex={10011}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_21.mp3')}
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_21.mp3`)}
>
那“两白一黑”战役是如何打赢的呢?
那"两白一黑"战役是如何打赢的呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_22.mp3')}
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_22.mp3`)}
>
“银元之战”的胜利并没有遏制住投机者的嚣张气焰,物价稳定仅十几天后,投机者就由银元投机转向“两白一黑”的投机,即:棉纱、大米和煤炭,引发了又一次全国性涨价狂潮。
"银元之战"的胜利并没有遏制住投机者的嚣张气焰,物价稳定仅十几天后,投机者就由银元投机转向"两白一黑"的投机,即:棉纱、大米和煤炭,引发了又一次全国性涨价狂潮。
</GuideStepsItem>
</GuideSteps>
{/* 拖拽题2:在视频2结束后出现 */}
{/* 题目2:拖拽 */}
<DragQuizPanel
visible={quiz2Visible}
zIndex={10012}
questionPrefix='【拖拽】新中国成立后,为了稳定物价,中央政府组织了"两白一黑"的集中调运,"两白一黑"指的是'
questionPrefix={QUIZ2_PREFIX}
questionSuffix="。"
options={QUIZ2_OPTIONS}
blanks={QUIZ2_BLANKS}
@@ -361,45 +387,15 @@ const ChenyunPlace7 = () => {
onChange={handleQuiz2Change}
/>
{/* 遮罩层 */}
<ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onClose={() => {
if (activeExhibitId !== null) {
markExhibitVisited(place, activeExhibitId)
}
setActiveExhibitId(null)
}}
/>
{/* 成功提示 */}
{successAlertVisible && (
{/* 成功提示(盖在视频/题目之上) */}
{successVisible && (
<div className="fixed inset-0" style={{ zIndex: 20000 }}>
<SuccessAlert
visible={successAlertVisible}
visible
title="恭喜你, 选择正确!"
content={successAlertContent}
onClose={() => {
setSuccessAlertVisible(false)
// 如果是单选题答对且拖拽题还没开始,继续进入第二阶段
if (quizAnswer === 'B' && !quiz2Visible && !quiz2Completed) {
setVideoVisible2(true)
setVideoPlaying2(false)
setGuideVisible2(true)
setGuideStep2(0)
}
}}
onContinue={() => {
setSuccessAlertVisible(false)
// 如果是单选题答对且拖拽题还没开始,继续进入第二阶段
if (quizAnswer === 'B' && !quiz2Visible && !quiz2Completed) {
setVideoVisible2(true)
setVideoPlaying2(false)
setGuideVisible2(true)
setGuideStep2(0)
}
}}
content={successContent}
onClose={handleSuccessDismiss}
onContinue={handleSuccessDismiss}
/>
</div>
)}
+2 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState } from 'react'
import {
ExhibitOverlay,
Title,
@@ -191,7 +191,7 @@ const ChenyunPlace8 = () => {
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_26.mp3')}
>
不错,随着大规模经济建设的展开,全国粮、棉、油等主要农产品严重紧缺,供求矛盾尖锐。陈云经过调查研究,反复比较,权衡利弊得失,终于下决心在农村征购,在城市配售的解决粮食问题的办法,也就是后来所说的"统购统销"。
不错,随着大规模经济建设的展开,全国粮、棉、油等主要农产品严重紧缺,供求矛盾尖锐。陈云经过调查研究,反复比较,权衡利弊得失,终于下决心采用在农村征购,在城市配售的解决粮食问题的办法,也就是后来所说的"统购统销"。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
陈云说:"只有采用这种办法,才能保证我国人民生活日益增长的需要,才能制止投机活动,保证市场物价的稳定,才能使发展国民经济第一个五年计划得以顺利地进行。"
+1
View File
@@ -272,6 +272,7 @@ const ChenyunReflection = () => {
setShowWelcomeGuide(false)
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_28.mp3')}
firstLineIndent="2em"
>
{WELCOME_TEXT}
</StudyGuideModal>
@@ -98,7 +98,7 @@ export const MimianQuizzes = ({
{/* 米棉之战 - 单选题1(图1) */}
<SingleChoiceQuizPanel
visible={showQuiz1}
title={`【单选】投机商集中冲击"两白一黑"的核心目的是(\xa0\xa0\xa0\xa0)。`}
title={`【单选】投机商集中冲击"两白一黑"的核心目的是:`}
options={[
{ value: 'A', label: 'A. 满足市场需求' },
{ value: 'B', label: 'B. 扰乱经济秩序、动摇政权' },
@@ -127,7 +127,7 @@ export const MimianQuizzes = ({
{/* 米棉之战 - 单选题2(图3) */}
<SingleChoiceQuizPanel
visible={showQuiz2}
title={`【单选】你觉得此时解决上海和全国面临的严峻经济形势问题的对策应该是(\xa0\xa0\xa0\xa0)。`}
title={`【单选】你觉得此时解决上海和全国面临的严峻经济形势问题的对策应该是:`}
options={[
{ value: 'A', label: 'A. 局部限购' },
{ value: 'B', label: 'B. 增发货币' },
+8 -5
View File
@@ -75,7 +75,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
id: 'quiz-2b',
time: 176, // 2:56
time: 177, // 2:56
title: '',
prompt: '【拖拽】黄炎培先生认为,职业学校的办学宗旨是“',
promptSuffix: '”。',
@@ -89,13 +89,13 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
{ id: 'blank-1', label: '___' },
{ id: 'blank-2', label: '___' },
],
blankPrefixes: ['使无业者', ',使有业者'],
blankSeparators: [''],
blankPrefixes: ['使无业者', '使有业者'],
blankSeparators: [','],
explanation: '正确答案为“使无业者有业,使有业者乐业”。',
},
{
id: 'quiz-4',
time: 260, // 4:20
time: 261, // 4:20
title: '',
prompt: '【多选】黄炎培先生认为职业教育的目的是___,简称“四个准备”。',
type: 'multiple',
@@ -447,6 +447,7 @@ const HuangyanpeiInternalization = () => {
<Narration
visible={narrationVisible}
audioChannelId="huangyanpei-internalization-narration"
onClose={() => {
setNarrationVisible(false)
setVisible(true)
@@ -475,6 +476,7 @@ const HuangyanpeiInternalization = () => {
{/* 引导 */}
<GuideSteps
visible={visible}
audioChannelId="huangyanpei-internalization-guide-pre"
onVisibleChange={(visible) => {
setVisible(visible)
if (!visible) {
@@ -504,6 +506,7 @@ const HuangyanpeiInternalization = () => {
{/* 视频结束后的引导 */}
<GuideSteps
visible={postGuideVisible}
audioChannelId="huangyanpei-internalization-guide-post"
onVisibleChange={(visible) => {
setPostGuideVisible(visible)
if (!visible) {
@@ -540,7 +543,7 @@ const HuangyanpeiInternalization = () => {
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(12)
navigate(`/${moduleName}/${starName}/home`)
navigate('/menu')
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_31.mp3')}
/>
+1
View File
@@ -174,6 +174,7 @@ const HuangyanpeiPlace1 = () => {
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_01.mp3')}
firstLineIndent="2em"
>
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
此刻你正站在黄炎培先生故居门前,接下来就跟着时光的脉络,拆解黄先生人生里五段滚烫的旅程:思想启蒙、教育实践、创立职教、延安对话、职教传薪。准备好一起走进这段炽热的岁月了吗?
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more