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.
No files matched your search
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 404 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 487 KiB After Width: | Height: | Size: 477 KiB |
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 425 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 449 KiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 549 KiB After Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 788 KiB After Width: | Height: | Size: 777 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 810 KiB After Width: | Height: | Size: 798 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.2 MiB |
|
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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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="导图"
|
||||
|
||||
@@ -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 />,
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
|
||||
@@ -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: { ... },
|
||||
|
||||
@@ -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'
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 待检查的文本
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -235,6 +235,7 @@ const ChenyunPlace1 = () => {
|
||||
visible={showAfterDialogGuide}
|
||||
onContinue={() => setShowAfterDialogGuide(false)}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_01.mp3')}
|
||||
firstLineIndent="2em"
|
||||
>
|
||||
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
|
||||
此刻你正站在陈云同志的故居堂屋里,接下来请跟着时光的脚步,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
陈云说:"只有采用这种办法,才能保证我国人民生活日益增长的需要,才能制止投机活动,保证市场物价的稳定,才能使发展国民经济第一个五年计划得以顺利地进行。"
|
||||
|
||||
@@ -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. 增发货币' },
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -174,6 +174,7 @@ const HuangyanpeiPlace1 = () => {
|
||||
visible={showAfterDialogGuide}
|
||||
onContinue={() => setShowAfterDialogGuide(false)}
|
||||
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_01.mp3')}
|
||||
firstLineIndent="2em"
|
||||
>
|
||||
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
|
||||
此刻你正站在黄炎培先生故居门前,接下来就跟着时光的脉络,拆解黄先生人生里五段滚烫的旅程:思想启蒙、教育实践、创立职教、延安对话、职教传薪。准备好一起走进这段炽热的岁月了吗?
|
||||
|
||||
@@ -310,7 +310,7 @@ const HuangyanpeiPlace5 = () => {
|
||||
{/* 题目 */}
|
||||
<SingleChoiceQuizPanel
|
||||
visible={showQuiz}
|
||||
title={`【单选】《延安归来》中"破解历史周期率"的"初版答案"一夜畅销十万册,其提出的核心对策是(\xa0\xa0\xa0\xa0)。`}
|
||||
title={`【单选】《延安归来》中"破解历史周期率"的"初版答案"一夜畅销十万册,其提出的核心对策是:`}
|
||||
options={[
|
||||
{ value: 'A', label: 'A. 实行民主' },
|
||||
{ value: 'B', label: 'B. 发展经济' },
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { Title, Image, Cursor, StudyGuideModal } from '@/components'
|
||||
import { BambooPuzzleGame, QuestionModal } from './components'
|
||||
import { SuccessAlert } from '@/components/layout'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
|
||||
const starName = 'huangyanpei'
|
||||
|
||||
@@ -17,7 +17,6 @@ const HuangyanpeiReflectionScene1 = () => {
|
||||
const [showGuideModal, setShowGuideModal] = useState(true)
|
||||
const [showQuestionModal, setShowQuestionModal] = useState(false)
|
||||
const [showGame, setShowGame] = useState(false)
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
|
||||
const [showNextGuide, setShowNextGuide] = useState(false)
|
||||
|
||||
// 使用持久化的reflection cursor状态
|
||||
@@ -26,7 +25,6 @@ const HuangyanpeiReflectionScene1 = () => {
|
||||
markReflectionCursorVisited,
|
||||
getVisitedReflectionCursors,
|
||||
setReflectionRoute,
|
||||
hasShownReflectionCompletionGuide,
|
||||
markReflectionCompletionGuideShown,
|
||||
} = useHuangyanpeiStore()
|
||||
|
||||
@@ -34,6 +32,12 @@ const HuangyanpeiReflectionScene1 = () => {
|
||||
const visitedCursors = getVisitedReflectionCursors(scene)
|
||||
const visited = useMemo(() => new Set(visitedCursors), [visitedCursors])
|
||||
|
||||
const { isEnabled } = useCursorController({
|
||||
initialEnabled: [1],
|
||||
dependencies: { 2: [1] },
|
||||
visited: visitedCursors,
|
||||
})
|
||||
|
||||
const progressCount = visited.size
|
||||
const total = 2
|
||||
const completed = progressCount >= total
|
||||
@@ -42,17 +46,12 @@ const HuangyanpeiReflectionScene1 = () => {
|
||||
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
|
||||
: undefined
|
||||
|
||||
// 两个任务都完成后 500ms 弹出下一站提示(仅一次)
|
||||
useEffect(() => {
|
||||
if (visited.has(1) && visited.has(2) && !hasShownReflectionCompletionGuide(scene)) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowNextGuide(true)
|
||||
markReflectionCompletionGuideShown(scene)
|
||||
saveHuangyanpeiStore()
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [visited, scene, hasShownReflectionCompletionGuide, markReflectionCompletionGuideShown])
|
||||
const handleGoNext = useCallback(() => {
|
||||
setShowNextGuide(false)
|
||||
setReflectionRoute('reflection2')
|
||||
saveHuangyanpeiStore()
|
||||
navigate(`/${moduleName}/${starName}/reflection2`)
|
||||
}, [moduleName, navigate, setReflectionRoute])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -85,30 +84,28 @@ const HuangyanpeiReflectionScene1 = () => {
|
||||
setShowGuideModal(false)
|
||||
}}
|
||||
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_23.mp3')}
|
||||
firstLineIndent="2em"
|
||||
>
|
||||
同学你好,欢迎来到“跨越时空·体悟”学习模块!{'\n'}
|
||||
请登陆“时空回溯・亲历时代”体验仓,下一站:1903,川沙小学堂。{'\n'}
|
||||
请登陆“时空回溯・亲历时代”体验仓,下一站:1903年,川沙小学堂。{'\n'}
|
||||
你将在“任之”校长所在的学校学习,根据所知和所学,努力完成校长给你的考验,完成全部任务后,可进入到下一阶段的学习。时空门已开,请做好准备迎接考验吧!
|
||||
</StudyGuideModal>
|
||||
|
||||
<Cursor
|
||||
className="absolute left-68 top-55"
|
||||
onClick={() => {
|
||||
if (!visited.has(1)) {
|
||||
setShowQuestionModal(true)
|
||||
}
|
||||
setShowQuestionModal(true)
|
||||
}}
|
||||
read={visited.has(1)}
|
||||
disabled={!isEnabled(1)}
|
||||
/>
|
||||
<Cursor
|
||||
className="absolute left-160 bottom-50"
|
||||
onClick={() => {
|
||||
// 如果游戏已完成,不再打开
|
||||
if (!visited.has(2)) {
|
||||
setShowGame(true)
|
||||
}
|
||||
setShowGame(true)
|
||||
}}
|
||||
read={visited.has(2)}
|
||||
disabled={!isEnabled(2)}
|
||||
/>
|
||||
|
||||
<QuestionModal
|
||||
@@ -120,18 +117,14 @@ const HuangyanpeiReflectionScene1 = () => {
|
||||
onClose={() => setShowQuestionModal(false)}
|
||||
/>
|
||||
|
||||
{/* {completed && (
|
||||
{completed && (
|
||||
<Image
|
||||
className="absolute bottom-0 right-0"
|
||||
src={getStaticImage("huangyanpei/reflection/scene1/9btn")}
|
||||
className="absolute bottom-0 right-0 z-20"
|
||||
src={getStaticImage('components/place/继续前进')}
|
||||
button
|
||||
onClick={() => {
|
||||
if (moduleName && starName) {
|
||||
navigate(`/${moduleName}/${starName}/reflection2`);
|
||||
}
|
||||
}}
|
||||
onClick={handleGoNext}
|
||||
/>
|
||||
)} */}
|
||||
)}
|
||||
|
||||
<BambooPuzzleGame
|
||||
visible={showGame}
|
||||
@@ -139,32 +132,15 @@ const HuangyanpeiReflectionScene1 = () => {
|
||||
setShowGame(false)
|
||||
}}
|
||||
onComplete={() => {
|
||||
// 游戏完成后,显示成功提示
|
||||
setShowGame(false)
|
||||
setShowSuccessAlert(true)
|
||||
markReflectionCursorVisited(scene, 2)
|
||||
}}
|
||||
/>
|
||||
|
||||
<SuccessAlert
|
||||
visible={showSuccessAlert}
|
||||
title="拼接毛竹单杠成功,同学们可以在课间之余强身健体!"
|
||||
onContinue={useCallback(() => {
|
||||
setShowSuccessAlert(false)
|
||||
markReflectionCursorVisited(scene, 2)
|
||||
}, [markReflectionCursorVisited, scene])}
|
||||
/>
|
||||
|
||||
<StudyGuideModal
|
||||
visible={showNextGuide}
|
||||
onContinue={() => {
|
||||
setShowNextGuide(false)
|
||||
setReflectionRoute('reflection2')
|
||||
markReflectionCompletionGuideShown(scene)
|
||||
saveHuangyanpeiStore()
|
||||
navigate(`/${moduleName}/${starName}/reflection2`)
|
||||
setShowNextGuide(true)
|
||||
}}
|
||||
verticalAlign="center"
|
||||
>
|
||||
/>
|
||||
|
||||
<StudyGuideModal visible={showNextGuide} onContinue={handleGoNext} verticalAlign="center">
|
||||
第一关完成!下一站,浦东中学深造去。
|
||||
</StudyGuideModal>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
saveHuangyanpeiStore,
|
||||
loadHuangyanpeiStore,
|
||||
} from '@/store/huangyanpeiStore'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
|
||||
const starName = 'huangyanpei'
|
||||
|
||||
@@ -46,6 +47,12 @@ const HuangyanpeiReflectionScene2 = () => {
|
||||
const visitedCursors = getVisitedReflectionCursors(scene)
|
||||
const visited = new Set(visitedCursors)
|
||||
|
||||
const { isEnabled } = useCursorController({
|
||||
initialEnabled: [1],
|
||||
dependencies: { 2: [1] },
|
||||
visited: visitedCursors,
|
||||
})
|
||||
|
||||
const progressCount = visited.size
|
||||
const total = 2
|
||||
const completed = progressCount >= total
|
||||
@@ -176,6 +183,7 @@ const HuangyanpeiReflectionScene2 = () => {
|
||||
setShowDialog1(true)
|
||||
}}
|
||||
read={visited.has(1)}
|
||||
disabled={!isEnabled(1)}
|
||||
/>
|
||||
|
||||
{/* 互动展品光标2:显示第二个选择弹窗 */}
|
||||
@@ -185,6 +193,7 @@ const HuangyanpeiReflectionScene2 = () => {
|
||||
setShowDialog2(true)
|
||||
}}
|
||||
read={visited.has(2)}
|
||||
disabled={!isEnabled(2)}
|
||||
/>
|
||||
|
||||
{/* 第一个选择弹窗 */}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { cn, getStaticImage } from '@/lib/utils'
|
||||
import { Image } from '@/components'
|
||||
import { SuccessAlert } from '@/components/layout'
|
||||
import { ErrorAlert } from '@/components/layout'
|
||||
|
||||
export interface Scene2QuizDialogProps {
|
||||
visible?: boolean
|
||||
@@ -148,11 +148,9 @@ const Scene2QuizDialog = ({ visible = true, onClose, onComplete }: Scene2QuizDia
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SuccessAlert
|
||||
<ErrorAlert
|
||||
visible={errorVisible}
|
||||
title=""
|
||||
content="是这个数目吗?你要不要再算一下!"
|
||||
onContinue={handleErrorContinue}
|
||||
text="是这个数目吗?你要不要再算一下!"
|
||||
onClose={handleErrorContinue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { VideoRef } from '@/components/video'
|
||||
import { QuizDialog } from './components'
|
||||
import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
|
||||
const starName = 'huangyanpei'
|
||||
|
||||
@@ -39,6 +40,12 @@ const HuangyanpeiReflectionScene3 = () => {
|
||||
const visitedCursors = getVisitedReflectionCursors(scene)
|
||||
const visited = new Set(visitedCursors)
|
||||
|
||||
const { isEnabled } = useCursorController({
|
||||
initialEnabled: [2],
|
||||
dependencies: { 1: [2], 3: [1, 2] },
|
||||
visited: visitedCursors,
|
||||
})
|
||||
|
||||
if (!moduleName) {
|
||||
return null
|
||||
}
|
||||
@@ -79,13 +86,13 @@ const HuangyanpeiReflectionScene3 = () => {
|
||||
src={getStaticImage('huangyanpei/reflection/components/喇叭1')}
|
||||
button
|
||||
onClick={() => {
|
||||
// 检查是否完成前两个任务(visited.has(1) && visited.has(2))
|
||||
if (visited.has(1) && visited.has(2)) {
|
||||
if (isEnabled(3)) {
|
||||
setShowQuizDialog(true)
|
||||
} else {
|
||||
setShowErrorAlertForQuiz(true)
|
||||
}
|
||||
}}
|
||||
aria-disabled={!isEnabled(3)}
|
||||
/>
|
||||
|
||||
{/* 第一段引导 */}
|
||||
@@ -95,23 +102,21 @@ const HuangyanpeiReflectionScene3 = () => {
|
||||
setShowGuide1(false)
|
||||
}}
|
||||
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_25.mp3')}
|
||||
firstLineIndent="2em"
|
||||
>
|
||||
终于到中华职业学校啦!这里是中国近代职教的摇篮,"免学费、包分配",还能学真手艺,快来练就一技之长。
|
||||
{'\n'}
|
||||
你选了学技术,那就来挑战制作中华职校的"明星产品"——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢!
|
||||
你选了学技术,那就来挑战制作中华职校的"明星产品"——台钳吧!当年这台钳还拿过巴拿马万国博览会金奖呢!
|
||||
</StudyGuideModal>
|
||||
|
||||
{/* 互动展品光标:组装台钳互动 */}
|
||||
<Cursor
|
||||
className="absolute right-100 bottom-55"
|
||||
onClick={() => {
|
||||
if (visited.has(2)) {
|
||||
setShowDialog(true)
|
||||
} else {
|
||||
setShowErrorAlert(true)
|
||||
}
|
||||
setShowDialog(true)
|
||||
}}
|
||||
read={visited.has(1)}
|
||||
disabled={!isEnabled(1)}
|
||||
/>
|
||||
|
||||
{/* 答题光标 */}
|
||||
@@ -121,6 +126,7 @@ const HuangyanpeiReflectionScene3 = () => {
|
||||
setShowQuiz(true)
|
||||
}}
|
||||
read={visited.has(2)}
|
||||
disabled={!isEnabled(2)}
|
||||
/>
|
||||
|
||||
{/* 组装台钳互动弹窗 */}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { QuizDialog } from './components'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
|
||||
const starName = 'huangyanpei'
|
||||
|
||||
@@ -29,6 +30,7 @@ const HuangyanpeiReflectionScene4 = () => {
|
||||
const videoRef = useRef<VideoRef>(null)
|
||||
const [showGuide1, setShowGuide1] = useState(true)
|
||||
const [showErrorAlertForQuiz, setShowErrorAlertForQuiz] = useState(false)
|
||||
const [showSpeakerGuide, setShowSpeakerGuide] = useState(false)
|
||||
|
||||
// 使用持久化的reflection cursor状态
|
||||
const scene = 'scene4'
|
||||
@@ -38,6 +40,12 @@ const HuangyanpeiReflectionScene4 = () => {
|
||||
const visitedCursors = getVisitedReflectionCursors(scene)
|
||||
const visited = new Set(visitedCursors)
|
||||
|
||||
const { isEnabled } = useCursorController({
|
||||
initialEnabled: [1],
|
||||
dependencies: { 2: [1], 3: [1, 2] },
|
||||
visited: visitedCursors,
|
||||
})
|
||||
|
||||
//喇叭考题弹窗
|
||||
const [showQuizDialog, setShowQuizDialog] = useState(false)
|
||||
|
||||
@@ -85,13 +93,13 @@ const HuangyanpeiReflectionScene4 = () => {
|
||||
src={getStaticImage('huangyanpei/reflection/components/喇叭2')}
|
||||
button
|
||||
onClick={() => {
|
||||
// 检查是否完成前两个任务(visited.has(1) && visited.has(2))
|
||||
if (visited.has(1) && visited.has(2)) {
|
||||
if (isEnabled(3)) {
|
||||
setShowQuizDialog(true)
|
||||
} else {
|
||||
setShowErrorAlertForQuiz(true)
|
||||
}
|
||||
}}
|
||||
aria-disabled={!isEnabled(3)}
|
||||
/>
|
||||
|
||||
<Image
|
||||
@@ -110,6 +118,7 @@ const HuangyanpeiReflectionScene4 = () => {
|
||||
setShowQuiz1(true)
|
||||
}}
|
||||
read={visited.has(1)}
|
||||
disabled={!isEnabled(1)}
|
||||
/>
|
||||
|
||||
{/* 互动展品光标2 */}
|
||||
@@ -119,12 +128,13 @@ const HuangyanpeiReflectionScene4 = () => {
|
||||
setShowTowelDialog(true)
|
||||
}}
|
||||
read={visited.has(2)}
|
||||
disabled={!isEnabled(2)}
|
||||
/>
|
||||
|
||||
{/* 选择题1:中华职校商科的核心教学目标 */}
|
||||
<MultipleChoiceQuizPanel
|
||||
visible={showQuiz1}
|
||||
title={`【选题】中华职校商科的核心教学目标是什么? `}
|
||||
title={`【多选】中华职校商科的核心教学目标是什么? `}
|
||||
options={[
|
||||
{
|
||||
value: 'A',
|
||||
@@ -171,6 +181,7 @@ const HuangyanpeiReflectionScene4 = () => {
|
||||
onComplete={() => {
|
||||
// 完成拖拽后更新visited状态
|
||||
markReflectionCursorVisited(scene, 2)
|
||||
setShowSpeakerGuide(true)
|
||||
|
||||
triggerPuzzleAnimation(15)
|
||||
}}
|
||||
@@ -194,10 +205,11 @@ const HuangyanpeiReflectionScene4 = () => {
|
||||
setShowGuide1(false)
|
||||
}}
|
||||
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_26.mp3')}
|
||||
firstLineIndent="2em"
|
||||
>
|
||||
终于到中华职业学校啦!这里是中国近代职教的摇篮,“免学费、包分配”,还能学真手艺,快来练就一技之长。
|
||||
{'\n'}
|
||||
现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢!
|
||||
现在要完成你的第一个任务:推销中华职校生产的“国货精品”——手工纽扣和纯棉毛巾!当年学生们就是带着这些国货,走街串巷做推销呢!
|
||||
</StudyGuideModal>
|
||||
|
||||
{showVideo && (
|
||||
@@ -225,6 +237,14 @@ const HuangyanpeiReflectionScene4 = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<StudyGuideModal
|
||||
visible={showSpeakerGuide}
|
||||
onContinue={() => setShowSpeakerGuide(false)}
|
||||
verticalAlign="center"
|
||||
>
|
||||
请点击左上角的喇叭。
|
||||
</StudyGuideModal>
|
||||
|
||||
{/* 错误提示:请先完成前两个任务 */}
|
||||
<ErrorAlert
|
||||
visible={showErrorAlertForQuiz}
|
||||
|
||||
@@ -146,6 +146,14 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia
|
||||
[value]
|
||||
)
|
||||
|
||||
const getTowelText = useCallback(
|
||||
(towelId: string) => {
|
||||
const result = value.find((item) => item.blankId === towelId)
|
||||
return options.find((option) => option.value === result?.optionValue)?.label
|
||||
},
|
||||
[options, value]
|
||||
)
|
||||
|
||||
// 样式
|
||||
const overlayStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
@@ -221,6 +229,11 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia
|
||||
alt="毛巾1"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
{getTowelText('towel1') && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-center text-3xl font-bold leading-snug text-[#432800] pointer-events-none">
|
||||
{getTowelText('towel1')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 毛巾2 */}
|
||||
@@ -234,6 +247,11 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia
|
||||
alt="毛巾2"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
{getTowelText('towel2') && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-center text-3xl font-bold leading-snug text-[#432800] pointer-events-none">
|
||||
{getTowelText('towel2')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 毛巾3 */}
|
||||
@@ -247,6 +265,11 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia
|
||||
alt="毛巾3"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
{getTowelText('towel3') && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-center text-3xl font-bold leading-snug text-[#432800] pointer-events-none">
|
||||
{getTowelText('towel3')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,17 +112,17 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
|
||||
{
|
||||
value: 'B',
|
||||
label: '我自横刀向天笑,去留肝胆两昆仑',
|
||||
explanation: '本句出自谭嗣同的《狱中题壁》,请再想想吧!',
|
||||
explanation: '本句出自谭嗣同的《狱中题壁》,请你再想想吧!',
|
||||
},
|
||||
{
|
||||
value: 'C',
|
||||
label: '此去泉台招旧部,旌旗十万斩阎罗',
|
||||
explanation: '本句出自陈毅的《梅岭三章》,请再想想吧!',
|
||||
explanation: '本句出自陈毅的《梅岭三章》,请你再想想吧!',
|
||||
},
|
||||
{
|
||||
value: 'D',
|
||||
label: '雄关漫道真如铁,而今迈步从头越',
|
||||
explanation: '本句出自毛泽东的《忆秦娥・娄山关》,请再想想吧!',
|
||||
explanation: '本句出自毛泽东的《忆秦娥・娄山关》,请你再想想吧!',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -153,7 +153,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
|
||||
},
|
||||
{
|
||||
id: 'quiz-5',
|
||||
time: 60 * 9 + 53, // 9:53
|
||||
time: 60 * 9 + 53.5, // 9:53
|
||||
title: '',
|
||||
prompt: '【多选】我们能从鲁迅身上学习到:',
|
||||
type: 'multiple',
|
||||
@@ -197,10 +197,8 @@ const LuxunInternalization = () => {
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
// 旁白关闭后显示对话引导
|
||||
useEffect(() => {
|
||||
if (!showNarration) {
|
||||
setVisible(true)
|
||||
}
|
||||
}, [showNarration])
|
||||
setShowNarration(true)
|
||||
}, [])
|
||||
|
||||
const handleVideoCanPlay = () => {
|
||||
if (showVideo && videoRef.current) {
|
||||
@@ -474,9 +472,12 @@ const LuxunInternalization = () => {
|
||||
{/* 旁白 */}
|
||||
<Narration
|
||||
visible={showNarration}
|
||||
text={`走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。那些针砭时弊的杂文、借古讽今的篇章,还有藏在6平方米客房里的红色坚守,都在诉说着先生的热忱。1907年,鲁迅先生在《文化偏至论》里写到:"是故将生存两间,角逐列国是务,其首在立人,人立而后凡事举;若其道术,乃必尊个性而张精神。"先生认为,中华民族要屹立于世界民族之林,"首在立人","立人"思想从此贯穿在鲁迅先生的人生和作品中。`}
|
||||
audioSrc={getPublicPath('audio/luxun/mp3_luxun_35.mp3')}
|
||||
text={`走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。那些针砭时弊的杂文、借古讽今的篇章,还有藏在6平方米客房里的红色坚守,都在诉说着先生的热忱。1907年,鲁迅先生在《文化偏至论》里写到:"是故将生存两间,角逐列国是务,其首在立人,人立而后凡事举;若其道术,乃必尊个性而张精神。"`}
|
||||
onClose={() => {
|
||||
setShowNarration(false)
|
||||
setShowVideo(true)
|
||||
// handleVideoCanPlay()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -492,7 +493,7 @@ const LuxunInternalization = () => {
|
||||
}}
|
||||
showMask={false}
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_35.mp3')}>
|
||||
{/* <GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_35.mp3')}>
|
||||
走进上海鲁迅故居的红砖小楼,解锁了先生从绍兴到上海的文学足迹,我们读懂了他以笔为刃的战斗姿态,也见证了他与进步人士的革命情谊。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem speaker="guide">
|
||||
@@ -503,7 +504,7 @@ const LuxunInternalization = () => {
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem speaker="guide">
|
||||
先生认为,中华民族要屹立于世界民族之林,“首在立人”,“立人”思想从此贯穿在鲁迅先生的人生和作品中。”先生认为,中华民族要屹立于世界民族之林,“首在立人”,“立人”思想从此贯穿在鲁迅先生的人生和作品中。
|
||||
</GuideStepsItem>
|
||||
</GuideStepsItem> */}
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_36.mp3')}>
|
||||
小沪,关于"首在立人"这四个字我还是有些不理解,立人为什么这么重要?
|
||||
</GuideStepsItem>
|
||||
@@ -548,7 +549,7 @@ const LuxunInternalization = () => {
|
||||
onContinue={() => {
|
||||
setShowStudyGuide(false)
|
||||
setCourseTimeProgress(9)
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
navigate('/menu')
|
||||
}}
|
||||
audioSrc={getPublicPath('audio/luxun/mp3_luxun_38.mp3')}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Cursor,
|
||||
StudyGuideModal,
|
||||
} from '@/components'
|
||||
import { MatchQuizPanel } from '@/components/layout/quiz'
|
||||
import { MatchQuizPanel, MultipleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||
import { SuccessAlert } from '@/components/layout'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
@@ -59,6 +59,8 @@ const LuxunPlace1 = () => {
|
||||
const [showMatchQuiz, setShowMatchQuiz] = useState(false)
|
||||
// 连线题成功提示状态
|
||||
const [showMatchQuizSuccess, setShowMatchQuizSuccess] = useState(false)
|
||||
const [showExhibit2Quiz, setShowExhibit2Quiz] = useState(false)
|
||||
const [showExhibit2QuizSuccess, setShowExhibit2QuizSuccess] = useState(false)
|
||||
|
||||
// Cursor 顺序控制:1 -> 2
|
||||
const { isEnabled } = useCursorController({
|
||||
@@ -176,8 +178,7 @@ const LuxunPlace1 = () => {
|
||||
if (activeExhibitId === 1) {
|
||||
setShowMatchQuiz(true)
|
||||
} else {
|
||||
// 展品2正常标记为已访问
|
||||
markExhibitVisited(place, activeExhibitId)
|
||||
setShowExhibit2Quiz(true)
|
||||
}
|
||||
}
|
||||
setActiveExhibitId(null)
|
||||
@@ -301,6 +302,7 @@ const LuxunPlace1 = () => {
|
||||
|
||||
{/* 连线题成功提示 */}
|
||||
<SuccessAlert
|
||||
title="恭喜你,匹配正确!"
|
||||
visible={showMatchQuizSuccess}
|
||||
onContinue={() => {
|
||||
setShowMatchQuizSuccess(false)
|
||||
@@ -313,6 +315,49 @@ const LuxunPlace1 = () => {
|
||||
markExhibitVisited(place, 1)
|
||||
}}
|
||||
/>
|
||||
|
||||
<MultipleChoiceQuizPanel
|
||||
visible={showExhibit2Quiz}
|
||||
title="【多选】鲁迅先生居住在上海景云里的两年半,写下的作品包括:"
|
||||
options={[
|
||||
{ value: 'A', label: 'A. 阿 Q 正传' },
|
||||
{ value: 'B', label: 'B. 三闲集' },
|
||||
{ value: 'C', label: 'C. 二心集' },
|
||||
{ value: 'D', label: 'D. 朝花夕拾' },
|
||||
]}
|
||||
correctAnswer={['B', 'C']}
|
||||
errorMessage={(selectedValues) => {
|
||||
const picked = new Set(selectedValues)
|
||||
|
||||
if (picked.has('A')) {
|
||||
return '《阿 Q 正传》创作于 1921—1922 年,最初发表于《晨报副刊》,早于鲁迅居住在上海景云里时期。请你再想想吧!'
|
||||
}
|
||||
if (picked.has('D')) {
|
||||
return '《朝花夕拾》创作于 1926 年,最初以《旧事重提》为总题发表,早于鲁迅居住在上海景云里时期。请你再想想吧!'
|
||||
}
|
||||
return '请你再想想吧!'
|
||||
}}
|
||||
onSubmit={(_values, isCorrect) => {
|
||||
if (isCorrect) {
|
||||
setShowExhibit2Quiz(false)
|
||||
setShowExhibit2QuizSuccess(true)
|
||||
}
|
||||
}}
|
||||
zIndex={10001}
|
||||
/>
|
||||
|
||||
<SuccessAlert
|
||||
title="恭喜你,选择正确!"
|
||||
visible={showExhibit2QuizSuccess}
|
||||
onContinue={() => {
|
||||
setShowExhibit2QuizSuccess(false)
|
||||
markExhibitVisited(place, 2)
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowExhibit2QuizSuccess(false)
|
||||
markExhibitVisited(place, 2)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,16 +245,17 @@ const LuxunPlace3 = () => {
|
||||
audioSrc={activeExhibitId === 5 ? getPublicPath('audio/luxun/mp3_luxun_23.mp3') : undefined}
|
||||
onClose={() => {
|
||||
if (activeExhibitId === 2) {
|
||||
// 光标2:关闭展品框后显示题目
|
||||
setActiveExhibitId(null)
|
||||
setQuiz2Visible(true)
|
||||
} else if (activeExhibitId === 5) {
|
||||
// 光标5:关闭展品框后显示题目
|
||||
setActiveExhibitId(null)
|
||||
setQuiz5Visible(true)
|
||||
} else if (activeExhibitId === 1 || activeExhibitId === 3) {
|
||||
// 光标1和光标3:只关闭展品,不标记为已读(等对话关闭时再标记)
|
||||
} else if (activeExhibitId === 1) {
|
||||
// 光标1:只关闭展品,不标记为已读(等对话关闭时再标记)
|
||||
setActiveExhibitId(null)
|
||||
} else if (activeExhibitId === 3) {
|
||||
setActiveExhibitId(null)
|
||||
setQuiz2Visible(true)
|
||||
} else if (activeExhibitId === 4) {
|
||||
// 光标4:只关闭展品,不标记为已读(等对话关闭时再标记)
|
||||
setActiveExhibitId(null)
|
||||
@@ -295,7 +296,12 @@ const LuxunPlace3 = () => {
|
||||
{/* 光标2的引导对话 */}
|
||||
<GuideSteps
|
||||
visible={guide2Visible}
|
||||
onVisibleChange={setGuide2Visible}
|
||||
onVisibleChange={(visible) => {
|
||||
setGuide2Visible(visible)
|
||||
if (!visible) {
|
||||
markExhibitVisited(place, 2)
|
||||
}
|
||||
}}
|
||||
currentStep={guide2Step}
|
||||
onStepChange={setGuide2Step}
|
||||
showMask={false}
|
||||
@@ -313,7 +319,7 @@ const LuxunPlace3 = () => {
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
{/* 光标2的题目 */}
|
||||
{/* 光标3的题目 */}
|
||||
<SingleChoiceQuizPanel
|
||||
visible={quiz2Visible}
|
||||
title="【单选】鲁迅在上海大陆新村9号居住期间,翻译外国作品的核心目的不包括:"
|
||||
@@ -356,7 +362,7 @@ const LuxunPlace3 = () => {
|
||||
zIndex={10001}
|
||||
/>
|
||||
|
||||
{/* 光标2的成功提示 */}
|
||||
{/* 光标3的成功提示 */}
|
||||
<SuccessAlert
|
||||
visible={successAlert2Visible}
|
||||
content={
|
||||
@@ -364,8 +370,7 @@ const LuxunPlace3 = () => {
|
||||
}
|
||||
onContinue={() => {
|
||||
setSuccessAlert2Visible(false)
|
||||
// 标记光标2为已读
|
||||
markExhibitVisited(place, 2)
|
||||
markExhibitVisited(place, 3)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -374,10 +379,6 @@ const LuxunPlace3 = () => {
|
||||
visible={guide3Visible}
|
||||
onVisibleChange={(visible) => {
|
||||
setGuide3Visible(visible)
|
||||
if (!visible) {
|
||||
// 对话关闭时,标记光标3为已读
|
||||
markExhibitVisited(place, 3)
|
||||
}
|
||||
}}
|
||||
currentStep={guide3Step}
|
||||
onStepChange={setGuide3Step}
|
||||
@@ -413,7 +414,7 @@ const LuxunPlace3 = () => {
|
||||
小沪,这两本书看起来格外厚重,它和鲁迅在这儿的经历有什么关联呢?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_22.mp3')}>
|
||||
这不是鲁迅的著作,却是他在大陆新村9号倾注了全部心血的作品——为亡友瞿秋白编辑的译文集《海上述林》。1935年6月,瞿秋白被国民党反动派杀害,鲁迅悲痛万分,当即决定要为挚友出版译文集,让他的思想和精神流传下去。
|
||||
这不是鲁迅的著作,却是他在大陆新村9号倾注了全部心血的作品——为亡友瞿秋白编校的译文集《海上述林》。1935年6月,瞿秋白被国民党反动派杀害,鲁迅悲痛万分,当即决定要为挚友出版译文集,让他的思想和精神流传下去。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ const LuxunPlace5 = () => {
|
||||
() => [
|
||||
{
|
||||
id: 'quiz-anchor',
|
||||
time: 5,
|
||||
time: 41,
|
||||
once: true,
|
||||
afterResume: 'remove',
|
||||
render: ({ resume }) => (
|
||||
@@ -244,7 +244,7 @@ const LuxunPlace5 = () => {
|
||||
activeExhibitId === 1
|
||||
? '瞿秋白同志曾说,"鲁迅是莱谟斯,是野兽的奶汁所喂养大的,是封建宗法社会的逆子,是绅士阶级的贰臣,而同时也是一些浪漫谛克的革命家的诤友!"'
|
||||
: activeExhibitId === 2
|
||||
? '鲁迅先生曾经抄录清朝诗人何瓦琴的对联赠给瞿秋白,"人生得一知己足矣,斯世当以同怀视之"'
|
||||
? '鲁迅先生曾经抄录清朝诗人何瓦琴的对联赠给瞿秋白,"人生得一知己足矣,斯世当以同怀视之。"'
|
||||
: undefined
|
||||
}
|
||||
onClose={() => {
|
||||
@@ -358,7 +358,7 @@ const LuxunPlace5 = () => {
|
||||
<Video
|
||||
ref={videoRef}
|
||||
src={getPublicPath('video/luxun/luxun_06.mp4')}
|
||||
className="fixed inset-0 z-[20000]"
|
||||
className="fixed inset-0 z-20000"
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
|
||||
@@ -294,7 +294,7 @@ const LuxunReflectionScene1 = () => {
|
||||
visible={true}
|
||||
title=""
|
||||
// title={`你找到了${currentCity.location}!`}
|
||||
content={`${currentCity.time}\n${currentCity.description}`}
|
||||
content={`${currentCity.time},\n${currentCity.description}`}
|
||||
onClose={handleCityAlertClose}
|
||||
onContinue={handleCityAlertClose}
|
||||
/>
|
||||
@@ -328,6 +328,7 @@ const LuxunReflectionScene1 = () => {
|
||||
setShowGuideModal(false)
|
||||
}}
|
||||
audioSrc={getPublicPath('audio/luxun/mp3_luxun_31.mp3')}
|
||||
firstLineIndent="2em"
|
||||
>
|
||||
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
|
||||
请登陆“时空回溯・亲历时代”体验仓,下一站:1892年,绍兴三味书屋。
|
||||
|
||||
@@ -16,31 +16,31 @@ const dragItems = [
|
||||
name: '闰土',
|
||||
imagePath: 'luxun/reflection/scene2/拖拽-润土',
|
||||
targetBlankId: 'city-1-label-0', // 绍兴-人物
|
||||
errorHint: '❌闰土的故事和鲁迅的少年绍兴时光有关哦',
|
||||
successMessage: '✅答对啦! 闰土是鲁迅绍兴时期笔下的经典人物',
|
||||
errorHint: '❌闰土的故事和鲁迅的少年绍兴时光有关哦!',
|
||||
successMessage: '✅答对啦! 闰土是鲁迅绍兴时期笔下的经典人物。',
|
||||
},
|
||||
{
|
||||
id: 'baicaoyuan',
|
||||
name: '《从百草园到三味书屋》',
|
||||
imagePath: 'luxun/reflection/scene2/拖拽-百草园到三味书屋',
|
||||
targetBlankId: 'city-1-label-1', // 绍兴-作品
|
||||
errorHint: '❌这个作品写的是鲁迅在绍兴的故事哦',
|
||||
successMessage: '✅真棒! 这篇散文记录了鲁迅在绍兴的求学时光',
|
||||
errorHint: '❌这个作品写的是鲁迅在绍兴的故事哦!',
|
||||
successMessage: '✅真棒! 这篇散文记录了鲁迅在绍兴的求学时光。',
|
||||
},
|
||||
{
|
||||
id: 'aqzhengzhuan',
|
||||
name: '《阿Q正传》',
|
||||
imagePath: 'luxun/reflection/scene2/拖拽-阿Q正传',
|
||||
targetBlankId: 'city-2-label-0', // 北京-作品
|
||||
errorHint: '❌这部经典小说和鲁迅在北京的创作有关哦',
|
||||
successMessage: '✅答对啦! 《阿Q正传》是鲁迅小说的代表作, 诞生于北京',
|
||||
errorHint: '❌这部经典小说和鲁迅在北京的创作有关哦!',
|
||||
successMessage: '✅答对啦! 《阿Q正传》是鲁迅小说的代表作, 诞生于北京。',
|
||||
},
|
||||
{
|
||||
id: 'jingshenshengli',
|
||||
name: '"精神胜利法"',
|
||||
imagePath: 'luxun/reflection/scene2/拖拽-精神胜利法',
|
||||
targetBlankId: 'city-2-label-1', // 北京-核心主题
|
||||
errorHint: '❌这个主题是《阿Q正传》的核心, 和北京创作背景相关哦',
|
||||
errorHint: '❌这个主题是《阿Q正传》的核心, 和北京创作背景相关哦!',
|
||||
successMessage: '✅没错! 《阿Q正传》画出了国人的灵魂, 以便疗救。',
|
||||
},
|
||||
{
|
||||
@@ -48,32 +48,32 @@ const dragItems = [
|
||||
name: '"关注底层"',
|
||||
imagePath: 'luxun/reflection/scene2/关注底层',
|
||||
targetBlankId: 'city-3-label-0', // 厦门-创作方向
|
||||
errorHint: '❌这个创作方向是鲁迅在厦门的核心追求哦',
|
||||
successMessage: '✅答对啦!鲁迅在厦门任教时,依旧关注底层民众的命运',
|
||||
errorHint: '❌这个创作方向是鲁迅在厦门的核心追求哦!',
|
||||
successMessage: '✅答对啦!鲁迅在厦门任教时,依旧关注底层民众的命运。',
|
||||
},
|
||||
{
|
||||
id: 'zhaohuaxishi',
|
||||
name: '《朝花夕拾》',
|
||||
imagePath: 'luxun/reflection/scene2/拖拽-朝花夕拾',
|
||||
targetBlankId: 'city-4-label-0', // 广州-作品集
|
||||
errorHint: '❌这个作品集和鲁迅在广州的旅居时光有关哦',
|
||||
successMessage: '✅真棒!《朝花夕拾》部分篇章在广州白云楼定稿',
|
||||
errorHint: '❌这个作品集和鲁迅在广州的旅居时光有关哦!',
|
||||
successMessage: '✅真棒!《朝花夕拾》部分篇章在广州白云楼定稿。',
|
||||
},
|
||||
{
|
||||
id: 'gushixinbian',
|
||||
name: '《故事新编》',
|
||||
imagePath: 'luxun/reflection/scene2/拖拽-故事新编',
|
||||
targetBlankId: 'city-5-label-0', // 上海-作品
|
||||
errorHint: '❌再试试~这个作品和上海的创作故事更相关哦',
|
||||
successMessage: '✅答对啦!《故事新编》是鲁迅在上海故居完成的 经典作品集',
|
||||
errorHint: '❌再试试~这个作品和上海的创作故事更相关哦!',
|
||||
successMessage: '✅答对啦!《故事新编》是鲁迅在上海故居完成的 经典作品集。',
|
||||
},
|
||||
{
|
||||
id: 'biweidaoqiang',
|
||||
name: '"笔为刀枪"',
|
||||
imagePath: 'luxun/reflection/scene2/笔为刀枪',
|
||||
targetBlankId: 'city-5-label-1', // 上海-创作中心
|
||||
errorHint: '❌这个初心是鲁迅在上海创作的核心哦',
|
||||
successMessage: '✅没错!鲁迅在上海以笔为刃,批判黑暗的社会现实',
|
||||
errorHint: '❌这个初心是鲁迅在上海创作的核心哦!',
|
||||
successMessage: '✅没错!鲁迅在上海以笔为刃,批判黑暗的社会现实。',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -554,6 +554,7 @@ const LuxunReflectionScene2 = () => {
|
||||
showMask={false}
|
||||
highlightSelectors={[]}
|
||||
zIndex={10000}
|
||||
audioChannelId="luxun-reflection-scene2-guide"
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_32.mp3')}>
|
||||
这些线索藏在先生的文字里,长按卡片拖到对应地标旁,还原创作故事!
|
||||
@@ -567,6 +568,7 @@ const LuxunReflectionScene2 = () => {
|
||||
showMask={false}
|
||||
highlightSelectors={[]}
|
||||
zIndex={10000}
|
||||
audioChannelId="luxun-reflection-scene2-completion-guide"
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_32_02.mp3')}>
|
||||
你读懂了鲁迅文字与土地的联结,再深入看看文字里的初心!
|
||||
|
||||
@@ -362,7 +362,7 @@ const LuxunReflectionScene3 = () => {
|
||||
{/* 题目2:批判现实 */}
|
||||
<SingleChoiceQuizPanel
|
||||
visible={showQuiz2}
|
||||
title="【单选】先生的作品《狂人日记》《阿Q正传》都在揭露的是:"
|
||||
title="【单选】先生的作品《狂人日记》《阿Q正传》都在揭露的是:"
|
||||
options={[
|
||||
{ value: 'A', label: 'A. 旧时代的黑暗与愚昧' },
|
||||
{ value: 'B', label: 'B. 生活中的小美好' },
|
||||
@@ -375,7 +375,7 @@ const LuxunReflectionScene3 = () => {
|
||||
{/* 题目3:家国情怀 */}
|
||||
<SingleChoiceQuizPanel
|
||||
visible={showQuiz3}
|
||||
title={'【单选】先生辗转多地创作,核心追求是(\xa0\xa0\xa0\xa0)'}
|
||||
title={'【单选】先生辗转多地创作,核心追求是:'}
|
||||
options={[
|
||||
{ value: 'A', label: 'A. 唤醒国人,改变社会' },
|
||||
{ value: 'B', label: 'B. 只为个人成名' },
|
||||
@@ -436,6 +436,7 @@ const LuxunReflectionScene3 = () => {
|
||||
}}
|
||||
verticalAlign="center"
|
||||
audioSrc={getPublicPath('audio/luxun/mp3_luxun_33.mp3')}
|
||||
firstLineIndent="2em"
|
||||
>
|
||||
先生的每一步足迹、每一篇文字,都藏着不变的精神底色——拼出这份精神脉络,读懂先生的初心!
|
||||
</StudyGuideModal>
|
||||
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
SingleChoiceQuizPanel,
|
||||
LetterToMaozedong,
|
||||
EndingOverlay,
|
||||
Narration,
|
||||
} from '@/components'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { BasketQuizPanel, DragQuizPanel, SuccessAlert } from '@/components/layout'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
|
||||
const starName = 'maozedong'
|
||||
|
||||
@@ -20,6 +22,9 @@ const MaozedongInternalization = () => {
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{ moduleName: string }>()
|
||||
|
||||
// 获取碎片动画触发方法
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
|
||||
// 进页面:先播“引入视频” -> 引入对话(图1) -> 主视频(视频11)
|
||||
const [showIntroVideo, setShowIntroVideo] = useState(true)
|
||||
const [introQuizVisible, setIntroQuizVisible] = useState(false)
|
||||
@@ -39,6 +44,7 @@ const MaozedongInternalization = () => {
|
||||
// 写信、寄信动画与完成提示
|
||||
const [showLetter, setShowLetter] = useState(false)
|
||||
const [showCompleted, setShowCompleted] = useState(false)
|
||||
const [showTransition, setShowTransition] = useState(false)
|
||||
|
||||
const videoRef = useRef<VideoRef>(null)
|
||||
|
||||
@@ -105,7 +111,7 @@ const MaozedongInternalization = () => {
|
||||
},
|
||||
{
|
||||
id: 'quiz-3',
|
||||
time: 60 * 4 + 31,
|
||||
time: 60 * 4 + 32,
|
||||
title: '',
|
||||
prompt: '请将你认为符合新时代践行“群众路线”思想的做法拖入篮子吧!',
|
||||
correct: ['A', 'C', 'D'],
|
||||
@@ -138,7 +144,7 @@ const MaozedongInternalization = () => {
|
||||
},
|
||||
{
|
||||
id: 'quiz-4',
|
||||
time: 60 * 6 + 58,
|
||||
time: 60 * 6 + 59,
|
||||
title: '',
|
||||
prompt: '请将你认为符合新时代践行“独立自主”思想的做法拖入篮子吧!',
|
||||
correct: ['B', 'C', 'D'],
|
||||
@@ -174,7 +180,7 @@ const MaozedongInternalization = () => {
|
||||
},
|
||||
{
|
||||
id: 'quiz-5',
|
||||
time: 60 * 7 + 15,
|
||||
time: 60 * 7 + 16,
|
||||
title: '',
|
||||
prompt: '毛泽东思想活的灵魂包括',
|
||||
correct: ['实事求是', '群众路线', '独立自主'],
|
||||
@@ -237,6 +243,7 @@ const MaozedongInternalization = () => {
|
||||
|
||||
if (isCorrect) {
|
||||
setActiveSuccess(q.id)
|
||||
triggerPuzzleAnimation(16)
|
||||
setResumeCallback(() => resume)
|
||||
}
|
||||
return
|
||||
@@ -281,6 +288,7 @@ const MaozedongInternalization = () => {
|
||||
|
||||
if (isCorrect) {
|
||||
setActiveSuccess(q.id)
|
||||
if (q.id === 'quiz-2') triggerPuzzleAnimation(15)
|
||||
setResumeCallback(() => resume)
|
||||
}
|
||||
}
|
||||
@@ -358,6 +366,7 @@ const MaozedongInternalization = () => {
|
||||
dragQuizFeedbackTypes,
|
||||
dragQuizValues,
|
||||
resumeCallback,
|
||||
triggerPuzzleAnimation,
|
||||
])
|
||||
|
||||
if (!moduleName) return null
|
||||
@@ -374,13 +383,24 @@ const MaozedongInternalization = () => {
|
||||
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
|
||||
/>
|
||||
|
||||
{/* 过渡语 */}
|
||||
<Narration
|
||||
visible={showTransition}
|
||||
onClose={() => {
|
||||
setShowTransition(false)
|
||||
setIntroDialogVisible(true)
|
||||
}}
|
||||
text="毛泽东思想是一个完整的科学思想体系,它的活的灵魂的三个基本方面——实事求是、群众路线、独立自主,是贯穿于毛泽东思想各个组成部分的立场、观点、方法,这三个方面在新时代被赋予了更加丰富的内涵和实践形式,成为习近平新时代中国特色社会主义思想的方法论基础和行动指南。"
|
||||
animationSpeed={200}
|
||||
/>
|
||||
|
||||
{/* 引入视频:进入页面自动播放(暂用其他视频代替) */}
|
||||
{showIntroVideo && (
|
||||
<Video
|
||||
src={getPublicPath('video/maozedong/mzd_10.mp4')}
|
||||
className="h-full w-full z-20"
|
||||
playsInline
|
||||
controls
|
||||
showSkipButton
|
||||
autoPlay
|
||||
onEnded={() => {
|
||||
setShowIntroVideo(false)
|
||||
@@ -392,7 +412,7 @@ const MaozedongInternalization = () => {
|
||||
<SingleChoiceQuizPanel
|
||||
visible={introQuizVisible}
|
||||
zIndex={10001}
|
||||
title="【单选】毛泽东思想被确立为中国共产党的指导思想是在哪次会议上( )。"
|
||||
title="【单选】毛泽东思想被确立为中国共产党的指导思想是在哪次会议上?"
|
||||
options={[
|
||||
{ value: 'A', label: 'A.中共一大' },
|
||||
{ value: 'B', label: 'B.遵义会议' },
|
||||
@@ -421,7 +441,7 @@ const MaozedongInternalization = () => {
|
||||
content="1945年,中共七大正式将毛泽东思想确立为党的指导思想并写入党章。"
|
||||
onContinue={() => {
|
||||
setIntroQuizSuccessVisible(false)
|
||||
setIntroDialogVisible(true)
|
||||
setShowTransition(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -491,7 +511,7 @@ const MaozedongInternalization = () => {
|
||||
animationSpeed={200}
|
||||
onContinue={() => {
|
||||
setShowCompleted(false)
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
navigate('/menu')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -304,6 +304,7 @@ const MaozedongPlace1 = () => {
|
||||
visible={showAfterDialogGuide}
|
||||
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_01.mp3')}
|
||||
onContinue={() => setShowAfterDialogGuide(false)}
|
||||
firstLineIndent="2em"
|
||||
>
|
||||
{`同学你好,欢迎来到“走进故居·对话”学习模块!
|
||||
此刻你正处在毛泽东旧居陈列馆里,接下来请跟着时光的脚步,一同回望毛泽东主席人生中4段风云激荡的岁月:摸索方向 确立信仰、播撒红色火种、吹响合作号角、唤起工农群众。准备好推开这扇门,走进那段滚烫的峥嵘岁月了吗?`}
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
StudyGuideModal,
|
||||
} from '@/components'
|
||||
import { SuccessAlert } from '@/components/layout'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { cn, getStaticImage } from '@/lib/utils'
|
||||
import { getPublicPath } from '@/lib/utils'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
// 光标组件 - 带旋转动画
|
||||
@@ -34,7 +35,190 @@ const Cursor = ({ className, onClick }: { className?: string; onClick: () => voi
|
||||
/>
|
||||
)
|
||||
|
||||
const firstQuizOptions = [
|
||||
{
|
||||
value: 'A',
|
||||
label: 'A. 基层情况对决策至关重要,关乎无产阶级领导权、工农联盟议题能否贴合实际',
|
||||
},
|
||||
{
|
||||
value: 'B',
|
||||
label: 'B. 深入基层,收集各地党组织、工农运动实情,再推进筹备等备',
|
||||
},
|
||||
]
|
||||
|
||||
const secondQuizOptions = [
|
||||
{
|
||||
value: 'A',
|
||||
label: 'A. 以强硬指令,要求各地限时上报意见',
|
||||
},
|
||||
{
|
||||
value: 'B',
|
||||
label: 'B. 讲清意见对四大讨论无产阶级领导权、工农联盟的重要性,以革命大义动员党员',
|
||||
},
|
||||
]
|
||||
|
||||
const endingQuizOptions = [
|
||||
{
|
||||
value: 'A',
|
||||
label: 'A. 仅安心调养',
|
||||
},
|
||||
{
|
||||
value: 'B',
|
||||
label: 'B. 在调养修整后调研农民运动',
|
||||
},
|
||||
]
|
||||
|
||||
type QuizCursorProps = {
|
||||
resume: () => void
|
||||
unmute: () => void
|
||||
onCorrect: () => void
|
||||
}
|
||||
|
||||
const FirstQuizCursor = ({ resume, unmute, onCorrect }: QuizCursorProps) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
|
||||
const hasResumedRef = useRef(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Cursor
|
||||
className="absolute left-320 bottom-70"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
setVisible(true)
|
||||
}}
|
||||
/>
|
||||
<SingleChoiceQuizPanel
|
||||
visible={visible}
|
||||
title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?"
|
||||
options={firstQuizOptions}
|
||||
correctAnswer="B"
|
||||
errorMessage="很抱歉,你选错了!高层快速定框架,筹备脱离基层实际,因缺乏实战支撑,在推动工农运动、明确领导权时,难以契合各地实情,革命力量动员只会受阻。请你重新做出选择吧!"
|
||||
onSubmit={(_value, isCorrect) => {
|
||||
if (!isCorrect) return
|
||||
setVisible(false)
|
||||
setShowSuccessAlert(true)
|
||||
onCorrect()
|
||||
}}
|
||||
/>
|
||||
<SuccessAlert
|
||||
visible={showSuccessAlert}
|
||||
content="基于对史实的理解,毛主席的选择应当是深入基层、收集真实情况,再推进各项筹备与行动。"
|
||||
onContinue={() => {
|
||||
if (hasResumedRef.current) return
|
||||
hasResumedRef.current = true
|
||||
setShowSuccessAlert(false)
|
||||
resume()
|
||||
}}
|
||||
onClose={() => {
|
||||
if (hasResumedRef.current) return
|
||||
hasResumedRef.current = true
|
||||
setShowSuccessAlert(false)
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SecondQuizCursor = ({ resume, unmute, onCorrect }: QuizCursorProps) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
|
||||
const hasResumedRef = useRef(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Cursor
|
||||
className="absolute left-110 bottom-65"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
setVisible(true)
|
||||
}}
|
||||
/>
|
||||
<SingleChoiceQuizPanel
|
||||
visible={visible}
|
||||
title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?"
|
||||
options={secondQuizOptions}
|
||||
correctAnswer="B"
|
||||
errorMessage="很抱歉,你选错了!下达强硬指令,基层反而误陷内容空洞、应付,四大筹备缺乏真实基层素材,决策难以解决实际问题,工农联盟等议题讨论会浮于表面。请你重新做出选择吧!"
|
||||
onSubmit={(_value, isCorrect) => {
|
||||
if (!isCorrect) return
|
||||
setVisible(false)
|
||||
setShowSuccessAlert(true)
|
||||
onCorrect()
|
||||
}}
|
||||
/>
|
||||
<SuccessAlert
|
||||
visible={showSuccessAlert}
|
||||
title="恭喜你,选择正确!"
|
||||
content="当时毛泽东采用动员方式,以“钟英”为代号发出《关于召开四大致各地党组织的信》和《关于召开四大的通知》,在通知中阐明意义,各地党组织积极响应,为四大议题积累丰富实践依据,推动筹备深入。"
|
||||
onContinue={() => {
|
||||
if (hasResumedRef.current) return
|
||||
hasResumedRef.current = true
|
||||
setShowSuccessAlert(false)
|
||||
resume()
|
||||
}}
|
||||
onClose={() => {
|
||||
if (hasResumedRef.current) return
|
||||
hasResumedRef.current = true
|
||||
setShowSuccessAlert(false)
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const EndingQuizCursor = ({ resume, unmute }: Omit<QuizCursorProps, 'onCorrect'>) => {
|
||||
const [questionVisible, setQuestionVisible] = useState(false)
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
|
||||
const hasResumedRef = useRef(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Cursor
|
||||
className="absolute left-110 bottom-140"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
setQuestionVisible(true)
|
||||
}}
|
||||
/>
|
||||
<SingleChoiceQuizPanel
|
||||
visible={questionVisible}
|
||||
title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?"
|
||||
options={endingQuizOptions}
|
||||
correctAnswer="B"
|
||||
errorMessage="很抱歉,你选错了!当时如果错过调研契机,农民运动在四大后推进缺乏针对性实践参考,工农联盟思想深化缓慢,革命力量整合会受到影响。请你重新做出选择吧!"
|
||||
onSubmit={(_value, isCorrect) => {
|
||||
if (!isCorrect) return
|
||||
setQuestionVisible(false)
|
||||
setShowSuccessAlert(true)
|
||||
}}
|
||||
/>
|
||||
<SuccessAlert
|
||||
visible={showSuccessAlert}
|
||||
title="恭喜你,选择正确!"
|
||||
content="毛泽东回湘后,深入农村,了解农民疾苦与诉求,为后续《湖南农民运动考察报告》奠基,让四大工农联盟思想在实践中持续深化,开始了领导工农运动的新征程。"
|
||||
onContinue={() => {
|
||||
if (hasResumedRef.current) return
|
||||
hasResumedRef.current = true
|
||||
setShowSuccessAlert(false)
|
||||
resume()
|
||||
}}
|
||||
onClose={() => {
|
||||
if (hasResumedRef.current) return
|
||||
hasResumedRef.current = true
|
||||
setShowSuccessAlert(false)
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Home = () => {
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
|
||||
const bgmRef = useRef<HTMLAudioElement>(null)
|
||||
const videoRef = useRef<VideoRef>(null)
|
||||
const navigate = useNavigate()
|
||||
@@ -43,400 +227,245 @@ const Home = () => {
|
||||
|
||||
const moduleName = 'maozedong'
|
||||
|
||||
// 播放 BGM
|
||||
const playBGM = () => {
|
||||
bgmRef.current?.play().catch(() => {})
|
||||
}
|
||||
|
||||
const QuizCursor = ({ resume, unmute }: { resume: () => void; unmute: () => void }) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
|
||||
const hasResumedRef = useRef(false)
|
||||
|
||||
const options = [
|
||||
const anchors: VideoAnchor[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: 'A',
|
||||
label: 'A. 基层情况对决策至关重要,关乎无产阶级领导权、工农联盟议题能否贴合实际',
|
||||
id: 'anchor-0',
|
||||
time: 3,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-223 top-108"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'B',
|
||||
label: 'B. 深入基层,收集各地党组织、工农运动实情,再推进筹备等备',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Cursor
|
||||
className="absolute left-320 bottom-70"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
setVisible(true)
|
||||
}}
|
||||
/>
|
||||
<SingleChoiceQuizPanel
|
||||
visible={visible}
|
||||
title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?"
|
||||
options={options}
|
||||
correctAnswer="B"
|
||||
errorMessage="很抱歉,你选错了!高层快速定框架,筹备脱离基层实际,因缺乏实战支撑,在推动工农运动、明确领导权时,难以契合各地实情,革命力量动员只会受阻。请你重新做出选择吧!"
|
||||
onSubmit={(_value, isCorrect) => {
|
||||
if (!isCorrect) return
|
||||
setVisible(false)
|
||||
setShowSuccessAlert(true)
|
||||
}}
|
||||
/>
|
||||
<SuccessAlert
|
||||
visible={showSuccessAlert}
|
||||
content="基于对史实的理解,毛主席的选择应当是深入基层、收集真实情况,再推进各项筹备与行动。"
|
||||
onContinue={() => {
|
||||
if (hasResumedRef.current) return
|
||||
hasResumedRef.current = true
|
||||
setShowSuccessAlert(false)
|
||||
resume()
|
||||
}}
|
||||
onClose={() => {
|
||||
if (hasResumedRef.current) return
|
||||
hasResumedRef.current = true
|
||||
setShowSuccessAlert(false)
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const QuizCursor2 = ({ resume, unmute }: { resume: () => void; unmute: () => void }) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
|
||||
const hasResumedRef = useRef(false)
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'A',
|
||||
label: 'A. 以强硬指令,要求各地限时上报意见',
|
||||
id: 'anchor-1',
|
||||
time: 8,
|
||||
afterResume: 'hide',
|
||||
onTrigger: () => console.log('埋点:锚点 1 触发'),
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-150 top-80"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'B',
|
||||
label: 'B. 讲清意见对四大讨论无产阶级领导权、工农联盟的重要性,以革命大义动员党员',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Cursor
|
||||
className="absolute left-110 bottom-65"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
setVisible(true)
|
||||
}}
|
||||
/>
|
||||
<SingleChoiceQuizPanel
|
||||
visible={visible}
|
||||
title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?"
|
||||
options={options}
|
||||
correctAnswer="B"
|
||||
errorMessage="很抱歉,你选错了!下达强硬指令,基层反而误陷内容空洞、应付,四大筹备缺乏真实基层素材,决策难以解决实际问题,工农联盟等议题讨论会浮于表面。请你重新做出选择吧!"
|
||||
onSubmit={(_value, isCorrect) => {
|
||||
if (!isCorrect) return
|
||||
setVisible(false)
|
||||
setShowSuccessAlert(true)
|
||||
}}
|
||||
/>
|
||||
<SuccessAlert
|
||||
visible={showSuccessAlert}
|
||||
title="恭喜你,选择正确!"
|
||||
content="当前毛泽东采用动员党员方式,以“钟毯”为代号发出《关于召开四大致各地党组织的信》和《关于召开四大的通知》,在通知中阐明意见,各地党组织积极响应,为四大会议积累丰富实质依据,推动筹备深入。"
|
||||
onContinue={() => {
|
||||
if (hasResumedRef.current) return
|
||||
hasResumedRef.current = true
|
||||
setShowSuccessAlert(false)
|
||||
resume()
|
||||
}}
|
||||
onClose={() => {
|
||||
if (hasResumedRef.current) return
|
||||
hasResumedRef.current = true
|
||||
setShowSuccessAlert(false)
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const EndingCursor = ({ resume, unmute }: { resume: () => void; unmute: () => void }) => {
|
||||
const [questionVisible, setQuestionVisible] = useState(false)
|
||||
// const [endingVisible, setEndingVisible] = useState(false)
|
||||
// const hasResumedRef = useRef(false)
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'A',
|
||||
label: 'A. 仅安心调养',
|
||||
id: 'anchor-2',
|
||||
time: 22,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute right-42 bottom-95"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'B',
|
||||
label: 'B. 在调养修整后调研农民运动',
|
||||
id: 'anchor-3',
|
||||
time: 32,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-50 bottom-100"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Cursor
|
||||
className="absolute left-110 bottom-140"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
setQuestionVisible(true)
|
||||
}}
|
||||
/>
|
||||
<SingleChoiceQuizPanel
|
||||
visible={questionVisible}
|
||||
title="【单选】基于对史实的了解,你觉得毛主席会做出何种选择?"
|
||||
options={options}
|
||||
correctAnswer="B"
|
||||
errorMessage="当你如果错过调研契机,农民运动在四大后推进缺乏针对性实践参考,工农联盟思想深化缓慢,革命力量整合会受影响。请你重新做出选择吧!"
|
||||
onSubmit={(_value, isCorrect) => {
|
||||
if (!isCorrect) return
|
||||
setQuestionVisible(false)
|
||||
setEndingVisible(true)
|
||||
}}
|
||||
/>
|
||||
{/* <EndingOverlay
|
||||
visible={endingVisible}
|
||||
animationSpeed={200}
|
||||
text="恭喜你已经顺利完成了毛泽东故居的学习,请继续前往下一个故居吧!"
|
||||
onContinue={() => {
|
||||
if (hasResumedRef.current) return
|
||||
hasResumedRef.current = true
|
||||
setEndingVisible(false)
|
||||
resume()
|
||||
navigate(`/${moduleName}/maozedong/home`)
|
||||
}}
|
||||
/> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const anchors: VideoAnchor[] = [
|
||||
{
|
||||
id: 'anchor-0',
|
||||
time: 3,
|
||||
afterResume: 'hide', // 恢复后隐藏,seek 回来可重新触发
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-223 top-108"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-1',
|
||||
time: 8,
|
||||
afterResume: 'hide', // 恢复后隐藏,seek 回来可重新触发
|
||||
onTrigger: () => console.log('埋点:锚点 1 触发'),
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-150 top-80"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-2',
|
||||
time: 22,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute right-42 bottom-95"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-3',
|
||||
time: 32,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-50 bottom-100"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-4',
|
||||
time: 40,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute right-158 bottom-105"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-4-1',
|
||||
time: 52,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-223 top-135"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-5',
|
||||
time: 56,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute right-58 bottom-115"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-6',
|
||||
time: 74,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-80 bottom-110"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-6-1',
|
||||
time: 83,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => <QuizCursor resume={resume} unmute={unmute} />,
|
||||
},
|
||||
{
|
||||
id: 'anchor-7',
|
||||
time: 88,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-220 bottom-100"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-8',
|
||||
time: 95,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-243 bottom-100"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-9',
|
||||
time: 107,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-375 bottom-100"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-10',
|
||||
time: 116,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => <QuizCursor2 resume={resume} unmute={unmute} />,
|
||||
},
|
||||
{
|
||||
id: 'anchor-11',
|
||||
time: 120,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-220 bottom-100"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-12',
|
||||
time: 127,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-320 bottom-120"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-13',
|
||||
time: 141,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-248 bottom-130"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-14',
|
||||
time: 153,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => <EndingCursor resume={resume} unmute={unmute} />,
|
||||
},
|
||||
]
|
||||
{
|
||||
id: 'anchor-4',
|
||||
time: 40,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute right-158 bottom-105"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-4-1',
|
||||
time: 52,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-223 top-135"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-5',
|
||||
time: 60 + 8,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute right-58 bottom-115"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-6',
|
||||
time: 60 + 25,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-80 bottom-110"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-6-1',
|
||||
time: 60 + 34,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<FirstQuizCursor
|
||||
resume={resume}
|
||||
unmute={unmute}
|
||||
onCorrect={() => triggerPuzzleAnimation(13)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-7',
|
||||
time: 60 + 38,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-220 bottom-100"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-8',
|
||||
time: 60 * 2,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-243 bottom-100"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-9',
|
||||
time: 60 * 2 + 11.5,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-375 bottom-100"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-10',
|
||||
time: 60 * 2 + 21,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<SecondQuizCursor
|
||||
resume={resume}
|
||||
unmute={unmute}
|
||||
onCorrect={() => triggerPuzzleAnimation(14)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-11',
|
||||
time: 60 * 2 + 24,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-220 bottom-100"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-12',
|
||||
time: 60 * 2 + 43,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-320 bottom-120"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-13',
|
||||
time: 60 * 2 + 57,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => (
|
||||
<Cursor
|
||||
className="absolute left-248 bottom-130"
|
||||
onClick={() => {
|
||||
unmute()
|
||||
resume()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'anchor-14',
|
||||
time: 60 * 3 + 9,
|
||||
afterResume: 'hide',
|
||||
render: ({ resume, unmute }) => <EndingQuizCursor resume={resume} unmute={unmute} />,
|
||||
},
|
||||
],
|
||||
[triggerPuzzleAnimation]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* BGM 循环播放 */}
|
||||
<audio ref={bgmRef} src={getPublicPath('audio/BGM.mp3')} loop autoPlay />
|
||||
<Video
|
||||
ref={videoRef}
|
||||
@@ -459,6 +488,7 @@ const Home = () => {
|
||||
playBGM()
|
||||
videoRef.current?.play()
|
||||
}}
|
||||
firstLineIndent="2em"
|
||||
>
|
||||
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
|
||||
请登陆“时空回溯・亲历时代”体验仓,下一站:1924年,上海。
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { SuccessAlert } from '@/components/layout'
|
||||
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
|
||||
const starName = 'nieer'
|
||||
|
||||
@@ -36,7 +37,7 @@ type QuizAnchor = {
|
||||
const QUIZ_ANCHORS: QuizAnchor[] = [
|
||||
{
|
||||
id: 'quiz-1',
|
||||
time: 60 * 2, // 2:00
|
||||
time: 60 * 3,
|
||||
type: 'single',
|
||||
title: '【单选】《义勇军进行曲》法定管弦乐配器版的创作者是:',
|
||||
options: [
|
||||
@@ -79,7 +80,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
|
||||
},
|
||||
{
|
||||
id: 'quiz-3',
|
||||
time: 60 * 6 + 31, // 6:31
|
||||
time: 60 * 6 + 31.5, // 6:31
|
||||
type: 'multiple',
|
||||
title: '【多选】聂耳的音乐作品具有鲜明的“人民性”,主要体现在:',
|
||||
options: [
|
||||
@@ -97,7 +98,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
|
||||
},
|
||||
{
|
||||
id: 'quiz-4',
|
||||
time: 60 * 7 + 11, // 7:11
|
||||
time: 60 * 7 + 11.5, // 7:11
|
||||
type: 'single',
|
||||
title:
|
||||
'【判断】聂耳在创作中注重挖掘中华优秀传统音乐文化元素,《翠湖春晓》《金蛇狂舞》就是他改编传统音乐的经典作品。',
|
||||
@@ -119,7 +120,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
|
||||
},
|
||||
{
|
||||
id: 'quiz-5',
|
||||
time: 60 * 9 + 14, // 9:14
|
||||
time: 60 * 9 + 14.5, // 9:14
|
||||
type: 'multiple',
|
||||
title: '【多选】聂耳能创作出众多兼具传唱力与思想性的经典作品,源于他身上具有以下宝贵闪光点:',
|
||||
options: [
|
||||
@@ -140,9 +141,12 @@ const NieerInternalization = () => {
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{ moduleName: string }>()
|
||||
|
||||
const [narrationVisible, setNarrationVisible] = useState(true)
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
|
||||
const [narrationVisible, setNarrationVisible] = useState(false)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [showVideo, setShowVideo] = useState(false)
|
||||
const [showPostNarration, setShowPostNarration] = useState(false)
|
||||
const [showLetter, setShowLetter] = useState(false)
|
||||
const [showStudyGuide, setShowStudyGuide] = useState(false)
|
||||
|
||||
@@ -153,10 +157,8 @@ const NieerInternalization = () => {
|
||||
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!narrationVisible) {
|
||||
setVisible(true)
|
||||
}
|
||||
}, [narrationVisible])
|
||||
setNarrationVisible(true)
|
||||
}, [])
|
||||
|
||||
const handleVideoCanPlay = () => {
|
||||
if (showVideo && videoRef.current) {
|
||||
@@ -196,6 +198,7 @@ const NieerInternalization = () => {
|
||||
/>
|
||||
<SuccessAlert
|
||||
visible={activeSuccess === item.id}
|
||||
title={item.id === 'quiz-4' ? '恭喜你, 判断正确!' : '恭喜你, 选择正确!'}
|
||||
content={successContent}
|
||||
onContinue={() => {
|
||||
setActiveSuccess(null)
|
||||
@@ -233,6 +236,12 @@ const NieerInternalization = () => {
|
||||
onSubmit={(values: string[], isCorrect: boolean) => {
|
||||
if (isCorrect) {
|
||||
setActiveSuccess(item.id)
|
||||
if (item.id === 'quiz-2') {
|
||||
triggerPuzzleAnimation(15)
|
||||
}
|
||||
if (item.id === 'quiz-5') {
|
||||
triggerPuzzleAnimation(16)
|
||||
}
|
||||
setSuccessContent('')
|
||||
setResumeCallback(() => resume)
|
||||
}
|
||||
@@ -253,7 +262,7 @@ const NieerInternalization = () => {
|
||||
),
|
||||
}
|
||||
})
|
||||
}, [activeSuccess, resumeCallback, successContent])
|
||||
}, [activeSuccess, resumeCallback, successContent, triggerPuzzleAnimation])
|
||||
|
||||
if (!moduleName) return null
|
||||
|
||||
@@ -273,8 +282,12 @@ const NieerInternalization = () => {
|
||||
{/* 旁白 */}
|
||||
<Narration
|
||||
visible={narrationVisible}
|
||||
audioChannelId="nieer-internalization-narration"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_36.mp3')}
|
||||
animationSpeed={200}
|
||||
onClose={() => {
|
||||
setNarrationVisible(false)
|
||||
setVisible(true)
|
||||
}}
|
||||
text="从旧居亭子间的不眠之夜到国歌展示馆的激昂旋律,聂耳以青春为火,以音符为刃,在民族危亡的黑暗中奏响了救亡图存的强音。《义勇军进行曲》从电影主题曲到中华人民共和国国歌,从抗战年代的冲锋号到新时代的复兴曲,跨越近百年时光,依然是凝聚民族力量的精神纽带。今天,站在新时代的舞台上,我们更要读懂聂耳的作品与精神内核,让这份旋律中的力量内化于心、外化于行。"
|
||||
/>
|
||||
@@ -282,6 +295,7 @@ const NieerInternalization = () => {
|
||||
{/* 视频前引导 */}
|
||||
<GuideSteps
|
||||
visible={visible}
|
||||
audioChannelId="nieer-internalization-guide-pre"
|
||||
onVisibleChange={(v) => {
|
||||
setVisible(v)
|
||||
if (!v) {
|
||||
@@ -291,10 +305,10 @@ const NieerInternalization = () => {
|
||||
}}
|
||||
showMask={false}
|
||||
>
|
||||
<GuideStepsItem speaker="student">
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_37.mp3')}>
|
||||
小沪,前两个模块中,我循着聂耳的足迹读懂了旋律背后的家国情怀,亲历了国歌跨越时空的动人故事。我还想从音乐这一艺术创作角度出发更多了解聂耳,你能给我讲讲吗?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem speaker="guide">
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_38.mp3')}>
|
||||
讲到音乐,可是很专业的领域了!今天我们有幸邀请到了上海音乐学院党委宣传部副部长,东方乐器博物馆馆长,研究员,肖阳。肖老师会从上海音乐学院对聂耳的研究、与《义勇军进行曲》的渊源的角度告诉你聂耳的音乐作品与时代价值。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem speaker="guide">
|
||||
@@ -314,11 +328,23 @@ const NieerInternalization = () => {
|
||||
onCanPlay={handleVideoCanPlay}
|
||||
onEnded={() => {
|
||||
setShowVideo(false)
|
||||
setTimeout(() => setShowLetter(true), 300)
|
||||
setTimeout(() => setShowPostNarration(true), 300)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Narration
|
||||
visible={showPostNarration}
|
||||
audioChannelId="nieer-internalization-narration-post"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_39.mp3')}
|
||||
animationSpeed={200}
|
||||
onClose={() => {
|
||||
setShowPostNarration(false)
|
||||
setShowLetter(true)
|
||||
}}
|
||||
text="的确如戏!这份精神,不仅是音乐创作的指引,更是广大青年学子成长路上的精神灯塔,指引大家在专业学习中精益求精,在青春岁月里奋勇前行。此刻,我们仿佛依然站在聂耳旧居的亭子间里,仿佛还能看到先生熬夜打磨旋律的身影,感受到他心中滚烫的家国情怀。现在,就请你静下心来,把对聂耳的崇敬、对国歌精神的体悟和对自己未来的期许,一字一句写成一封信,完成与先生跨越时空的对话吧!"
|
||||
/>
|
||||
|
||||
{/* 写信组件 */}
|
||||
<LetterToNieer
|
||||
visible={showLetter}
|
||||
@@ -334,11 +360,12 @@ const NieerInternalization = () => {
|
||||
{/* 内化结束 */}
|
||||
<EndingOverlay
|
||||
visible={showStudyGuide}
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_40.mp3')}
|
||||
text="恭喜你圆满完成聂耳旧居的学习之旅,请带着研学感悟,前往张乐平故居继续探访吧~"
|
||||
animationSpeed={200}
|
||||
onContinue={() => {
|
||||
setShowStudyGuide(false)
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
navigate('/menu')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useRef, useState } from 'react'
|
||||
import Cursor from '@/components/cursor'
|
||||
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
|
||||
// starName 固定为 nieer
|
||||
const starName = 'nieer'
|
||||
@@ -32,6 +33,9 @@ const NieerPlace1 = () => {
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{ moduleName: string }>()
|
||||
|
||||
// 获取碎片动画触发方法
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
|
||||
const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
|
||||
const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
|
||||
|
||||
@@ -50,9 +54,7 @@ const NieerPlace1 = () => {
|
||||
const [guide2Step, setGuide2Step] = useState(0)
|
||||
const [quiz2Visible, setQuiz2Visible] = useState(false)
|
||||
const [successAlert2Visible, setSuccessAlert2Visible] = useState(false)
|
||||
const [afterQuizGuide2Visible, setAfterQuizGuide2Visible] = useState(false)
|
||||
const [afterQuizGuide2Step, setAfterQuizGuide2Step] = useState(0)
|
||||
const [showVideo2, setShowVideo2] = useState(false)
|
||||
const [video2Step, setVideo2Step] = useState<1 | 2 | null>(null)
|
||||
const video2Ref = useRef<VideoRef>(null)
|
||||
|
||||
// 光标3的对话状态
|
||||
@@ -163,6 +165,17 @@ const NieerPlace1 = () => {
|
||||
<ExhibitOverlay
|
||||
visible={activeExhibitId !== null}
|
||||
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
|
||||
onOpen={() => {
|
||||
if (activeExhibitId === 3) {
|
||||
triggerPuzzleAnimation(1)
|
||||
}
|
||||
if (activeExhibitId === 1) {
|
||||
triggerPuzzleAnimation(2)
|
||||
}
|
||||
if (activeExhibitId === 2) {
|
||||
triggerPuzzleAnimation(3)
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
const closingId = activeExhibitId
|
||||
setActiveExhibitId(null)
|
||||
@@ -199,28 +212,16 @@ const NieerPlace1 = () => {
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_11.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_05.mp3')}>
|
||||
这里提到聂耳发表了《中国歌舞短论》,批评脱离民众的音乐,这是为什么呢?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_12.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_06.mp3')}>
|
||||
你观察得很细致!这时的聂耳在明月歌舞剧社做练习生,社长李锦晖看中了他的潜力,虽然他当时小提琴水平不算专业,还是录取了他,还安排首席小提琴手专门辅导他。聂耳特别刻苦,每天至少练7个小时小提琴,很快就成长为首席小提琴手。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_03.mp3')}
|
||||
>
|
||||
但好景不长,1931年九一八事变后,全国抗日救亡浪潮高涨,可还存在很多脱离现实的靡靡之音,这让满怀爱国热忱的聂耳特别愤懑。
|
||||
<GuideStepsItem speaker="guide">
|
||||
但好景不长,1931年九·一八事变后,全国抗日救亡浪潮高涨,可还存在很多脱离现实的靡靡之音,这让满怀爱国热忱的聂耳特别愤懑。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_04.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
1932年,他以“黑天使”为笔名在《电影艺术》杂志刊登《中国歌舞短论》,猛烈评击“软豆腐”式的音乐,提出我们需要“真刀真枪的硬功夫”,主张音乐要贴近民众、为抗日救亡服务。后来他离开了歌舞剧社。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
@@ -235,16 +236,10 @@ const NieerPlace1 = () => {
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_21.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_07.mp3')}>
|
||||
离开歌舞剧社后,聂耳就加入左翼文艺团体了吗?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_22.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_08.mp3')}>
|
||||
没错!离开歌舞剧社后,聂耳结识了进步文艺家田汉,两人成为忘年交。1933年,在田汉的介绍下,聂耳秘密加入了中国共产党,之后就成为左翼剧联“音乐小组”的核心成员,还发起组织了“中国新兴音乐研究会”。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
@@ -259,34 +254,19 @@ const NieerPlace1 = () => {
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_01.mp3')}
|
||||
>
|
||||
小沪,聂耳是在九一八事变爆发后来到的上海吗?他为什么来上海呢?
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_03.mp3')}>
|
||||
小沪,聂耳是在九·一八事变爆发后来到的上海吗?他为什么来上海呢?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_02.mp3')}
|
||||
>
|
||||
九一八事变爆发前,聂耳就已经来到上海了,核心原因是躲避迫害。聂耳中学就读的云南省立第一师范学校是昆明学生运动的中心,在那里他接触到了马克思主义进步思想,还秘密加入了中国共产主义青年团。
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_04.mp3')}>
|
||||
九·一八事变爆发前,聂耳就已经来到上海了,核心原因是躲避迫害。聂耳中学就读的云南省立第一师范学校是昆明学生运动的中心,在那里他接触到了马克思主义进步思想,还秘密加入了中国共产主义青年团。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_03.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
也因为他积极参加学生运动,1930年被国民党当局列入了抓捕黑名单,在家人帮助下,他顶替哥哥的身份逃离昆明来到上海。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_04.mp3')}
|
||||
>
|
||||
来到上海后,1931年九一八事变爆发,继而1932年一·二八事变爆发,聂耳亲眼看见了日军进犯上海的暴行,也目睹了上海民众踊跃投身抗日的热烈场景。
|
||||
<GuideStepsItem speaker="guide">
|
||||
来到上海后,1931年九·一八事变爆发,继而1932年一·二八事变爆发,聂耳亲眼看见了日军进犯上海的暴行,也目睹了上海民众踊跃投身抗日的热烈场景。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_04.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
这些亲身经历让他深刻意识到,音乐不能只是娱乐,更能成为唤醒民众、凝聚抗战士气的“武器”。正是这份在上海的所见所感,让他坚定了“音乐救国”的方向,也为《义勇军进行曲》注入了源于现实的痛感与抗争决心。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
@@ -321,60 +301,43 @@ const NieerPlace1 = () => {
|
||||
title="恭喜你, 选择正确!"
|
||||
onContinue={() => {
|
||||
setSuccessAlert2Visible(false)
|
||||
markExhibitVisited(place, 2)
|
||||
saveNieerStore()
|
||||
setAfterQuizGuide2Step(0)
|
||||
setAfterQuizGuide2Visible(true)
|
||||
setVideo2Step(1)
|
||||
}}
|
||||
onClose={() => {
|
||||
setSuccessAlert2Visible(false)
|
||||
markExhibitVisited(place, 2)
|
||||
saveNieerStore()
|
||||
setAfterQuizGuide2Step(0)
|
||||
setAfterQuizGuide2Visible(true)
|
||||
setVideo2Step(1)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 光标2答题结束后的对话(图3) */}
|
||||
<GuideSteps
|
||||
visible={afterQuizGuide2Visible}
|
||||
currentStep={afterQuizGuide2Step}
|
||||
onStepChange={setAfterQuizGuide2Step}
|
||||
onVisibleChange={(nextVisible) => {
|
||||
setAfterQuizGuide2Visible(nextVisible)
|
||||
if (!nextVisible) {
|
||||
setShowVideo2(true)
|
||||
}
|
||||
}}
|
||||
showMask={false}
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_23.mp3')}
|
||||
>
|
||||
这一时期他深入工厂、码头,了解底层民众的疾苦,创作了一大批进步歌曲。比如《开矿歌》,他专门去矿山采风,把工人的劳动场景融入旋律,还唱给工人听,直到他们满意为止;
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place1_24.mp3')}
|
||||
>
|
||||
《卖报歌》则是他看到卖报小女孩“小毛头”的艰辛生活后创作的,还根据小女孩的建议修改了歌词,特别贴近生活。这些创作都为他后来写《义勇军进行曲》积累了深厚的功底。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
{/* 光标2对话结束后的视频(占位视频地址) */}
|
||||
{showVideo2 && (
|
||||
{/* 光标2连续视频(占位视频地址) */}
|
||||
{video2Step && (
|
||||
<Video
|
||||
ref={video2Ref}
|
||||
className="fixed inset-0 z-20000"
|
||||
src={getPublicPath('video/chenyun/chenyun_01.mp4')}
|
||||
src={getPublicPath(
|
||||
video2Step === 1 ? 'video/chenyun/chenyun_01.mp4' : 'video/nieer/nieer_2.mp4'
|
||||
)}
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
onEnded={() => setShowVideo2(false)}
|
||||
onSkip={() => setShowVideo2(false)}
|
||||
onEnded={() => {
|
||||
if (video2Step === 1) {
|
||||
setVideo2Step(2)
|
||||
return
|
||||
}
|
||||
markExhibitVisited(place, 2)
|
||||
saveNieerStore()
|
||||
setVideo2Step(null)
|
||||
}}
|
||||
onSkip={() => {
|
||||
if (video2Step === 1) {
|
||||
setVideo2Step(2)
|
||||
return
|
||||
}
|
||||
markExhibitVisited(place, 2)
|
||||
saveNieerStore()
|
||||
setVideo2Step(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -427,6 +390,7 @@ const NieerPlace1 = () => {
|
||||
setShowAfterDialogGuide(true)
|
||||
}}
|
||||
ailgn="left"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_02.mp3')}
|
||||
>
|
||||
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
|
||||
{'\n'}
|
||||
@@ -439,6 +403,7 @@ const NieerPlace1 = () => {
|
||||
<StudyGuideModal
|
||||
visible={showAfterDialogGuide}
|
||||
onContinue={() => setShowAfterDialogGuide(false)}
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_01.mp3')}
|
||||
>
|
||||
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
|
||||
此刻你正位立于国歌展示馆的入口,请随时光的脚步,在聂耳作品的“时光声浪”中聆听五段炽热的时代回响:民族危亡的怒吼声、时代呼唤的最强音、民族解放的冲锋号、强我中华的进行曲、伟大复兴的主旋律。你准备好跟随聂耳的音符,踏上这段炽热征程了吗?
|
||||
|
||||
@@ -5,11 +5,15 @@ import { useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
|
||||
const NieerPlace2 = () => {
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{ moduleName: string }>()
|
||||
|
||||
// 获取碎片动画触发方法
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
|
||||
const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
|
||||
const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
|
||||
|
||||
@@ -22,9 +26,8 @@ const NieerPlace2 = () => {
|
||||
const [guide2Visible, setGuide2Visible] = useState(false)
|
||||
const [guide2Step, setGuide2Step] = useState(0)
|
||||
|
||||
// 完成所有展品与对话后自动播放视频,视频结束后再显示“继续前进”
|
||||
const [showCompleteVideo, setShowCompleteVideo] = useState(false)
|
||||
const [completeVideoPlayed, setCompleteVideoPlayed] = useState(false)
|
||||
// 展品2关闭后播放视频,视频结束后再修改光标状态
|
||||
const [showVideo2, setShowVideo2] = useState(false)
|
||||
|
||||
const starName = 'nieer'
|
||||
const place = 'place2'
|
||||
@@ -116,19 +119,27 @@ const NieerPlace2 = () => {
|
||||
<ExhibitOverlay
|
||||
visible={activeExhibitId !== null}
|
||||
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
|
||||
onOpen={() => {
|
||||
if (activeExhibitId === 1) {
|
||||
triggerPuzzleAnimation(4)
|
||||
}
|
||||
if (activeExhibitId === 2) {
|
||||
triggerPuzzleAnimation(5)
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
const closingId = activeExhibitId
|
||||
setActiveExhibitId(null)
|
||||
setGuide1Visible(false)
|
||||
setGuide2Visible(false)
|
||||
if (closingId === 2) {
|
||||
setShowVideo2(true)
|
||||
return
|
||||
}
|
||||
if (closingId !== null) {
|
||||
markExhibitVisited(place, closingId)
|
||||
saveNieerStore()
|
||||
}
|
||||
// 直接在展品2窗口关闭后播放视频(受 useCursorController 控制顺序保障)
|
||||
if (closingId === 2 && !completeVideoPlayed) {
|
||||
setShowCompleteVideo(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -142,22 +153,13 @@ const NieerPlace2 = () => {
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_01.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_09.mp3')}>
|
||||
这盒《风云儿女》的胶片太有年代感了!聂耳是怎么和《风云儿女》这部电影结缘,并给这部电影谱主题歌《义勇军进行曲》的啊?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_02.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_10.mp3')}>
|
||||
1935年的时候,日本帝国主义已经把侵略的魔爪伸向华北,中华民族到了生死存亡的关键时刻,全国人民都渴望有能凝聚斗志的精神力量。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_03.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
左翼文艺界想通过拍摄《风云儿女》这部抗日电影,让更多人看清侵略者的野心,唤醒大家的抗争意识,而电影的主题歌必须得有“振聋发聩”的力量。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
@@ -172,31 +174,39 @@ const NieerPlace2 = () => {
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_03.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_11.mp3')}>
|
||||
1935年2月,田汉创作了《风云儿女》剧本梗概,写下《义勇军进行曲》歌词后不久被捕,田汉的战友辗转把歌词转交给左翼剧联,聂耳得知后第一时间就主动申请完成曲创作。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_04.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
他之所以这么积极,一方面是和田汉的革命情谊——两人都是左翼文艺运动的核心力量,早就约定要一起用文艺为抗日救亡服务;
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_05.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
更重要的是他对时代的感知,之前创作《开矿歌》《卖报歌》时,他就深入底层了解民众的苦难,清楚地知道大家需要什么样的声音。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place2_06.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
他当时说“这歌词太有力量了,我一定要把它谱成能让全中国人民都唱起来的歌”,这份信心来自他对民众心声的把握,也来自对救国使命的坚守。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
{showVideo2 && (
|
||||
<Video
|
||||
className="fixed inset-0 z-20000"
|
||||
src={getPublicPath('video/nieer/nieer_4.mp4')}
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
onEnded={() => {
|
||||
markExhibitVisited(place, 2)
|
||||
saveNieerStore()
|
||||
setShowVideo2(false)
|
||||
}}
|
||||
onSkip={() => {
|
||||
markExhibitVisited(place, 2)
|
||||
saveNieerStore()
|
||||
setShowVideo2(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,11 +15,15 @@ import { useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
|
||||
const NieerPlace3 = () => {
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{ moduleName: string }>()
|
||||
|
||||
// 获取碎片动画触发方法
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
|
||||
const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
|
||||
const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
|
||||
|
||||
@@ -145,6 +149,14 @@ const NieerPlace3 = () => {
|
||||
<ExhibitOverlay
|
||||
visible={activeExhibitId !== null}
|
||||
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
|
||||
onOpen={() => {
|
||||
if (activeExhibitId === 1) {
|
||||
triggerPuzzleAnimation(6)
|
||||
}
|
||||
if (activeExhibitId === 3) {
|
||||
triggerPuzzleAnimation(7)
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
const closingId = activeExhibitId
|
||||
setActiveExhibitId(null)
|
||||
@@ -179,37 +191,20 @@ const NieerPlace3 = () => {
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_01.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_12.mp3')}>
|
||||
这份聂耳手写的《义勇军进行曲》的曲谱太珍贵了,在当时那么动乱的年代,他是怎么创作出这么激昂的旋律的?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_02.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_13.mp3')}>
|
||||
1935年3月,聂耳在上海霞飞路(现淮海中路1258号聂耳旧居)完成了《义勇军进行曲》的曲谱初稿的创作。
|
||||
</GuideStepsItem>
|
||||
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_03.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
当时他住的是一个亭子间,那里又小又暗,连一张像样的桌子都没有,为了找到最贴合歌词的旋律,他每天天不亮就起来哼唱,用筷子敲着桌子打节拍,有时候灵感来了,半夜还会起来修改,邻居都能听到他的歌声。
|
||||
</GuideStepsItem>
|
||||
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_04.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
草稿上的修改痕迹,藏着他的用心——比如他反复调整音符的节奏,把原本偏舒缓的旋律改得更急促、更有冲击力,就是想让旋律能跟上战士冲锋的步伐;还优化了歌词和旋律的搭配,让每一句都能唱得铿锵有力。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_05.mp3')}
|
||||
>
|
||||
他还专门去电通公司唱给同事听,把工人、学生、战士的反馈都记下来,一点点打磨。他还专门去电通公司唱给同事听,把工人、学生、战士的反馈都记下来,一点点打磨。
|
||||
<GuideStepsItem speaker="guide">
|
||||
他还专门去电通公司唱给同事听,把工人、学生、战士的反馈都记下来,一点点打磨。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
@@ -223,28 +218,16 @@ const NieerPlace3 = () => {
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_03.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_14.mp3')}>
|
||||
原来初稿就倾注了这么多心血!他当时就是用这把小提琴创作的吗?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_04.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_15.mp3')}>
|
||||
这把小提琴其实是演员赵丹在电影《聂耳》中饰演聂耳时用的道具,不是聂耳本人的。你看到的抱着小提琴的聂耳的照片的拍摄地点已经是在海外了。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_05.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
当时他因为创作了太多进步歌曲,被国民党当局列入了黑名单,继续留在上海会有生命危险,因此他受党的指示前往海外暂避。但他心里一直惦记着谱曲任务,到海外后没敢耽误,立刻投入修改。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place3_06.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
虽然身在海外,但他通过海外华人社团、抗日刊物,一直关注着国内的抗日浪潮,还收集了海外华人传唱抗日歌曲的反馈,这些都成了他修改的重要参考。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
@@ -257,7 +240,7 @@ const NieerPlace3 = () => {
|
||||
src={
|
||||
videoForExhibitId === 1
|
||||
? getPublicPath('video/nieer/nieer_5.mp4')
|
||||
: getPublicPath('video/chenyun/chenyun_01.mp4')
|
||||
: getPublicPath('video/chenyun/chenyun_01.mp4') //正确地址nieer_02目前没有,暂时使用其他代替
|
||||
}
|
||||
autoPlay
|
||||
playsInline
|
||||
|
||||
@@ -5,11 +5,15 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
|
||||
const NieerPlace4 = () => {
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{ moduleName: string }>()
|
||||
|
||||
// 获取碎片动画触发方法
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
|
||||
const starName = 'nieer'
|
||||
const place = 'place4'
|
||||
const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
|
||||
@@ -109,6 +113,14 @@ const NieerPlace4 = () => {
|
||||
<ExhibitOverlay
|
||||
visible={activeExhibitId !== null}
|
||||
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
|
||||
onOpen={() => {
|
||||
if (activeExhibitId === 2) {
|
||||
triggerPuzzleAnimation(8)
|
||||
}
|
||||
if (activeExhibitId === 1) {
|
||||
triggerPuzzleAnimation(9)
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
const closingId = activeExhibitId
|
||||
setActiveExhibitId(null)
|
||||
@@ -131,22 +143,13 @@ const NieerPlace4 = () => {
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place4_01.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_16.mp3')}>
|
||||
随着《风云儿女》的上映,田汉作词、聂耳谱曲的《义勇军进行曲》真如聂耳所期待的一样,传遍了全国吧!
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place4_02.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_17.mp3')}>
|
||||
1935年《风云儿女》公映后,这首歌就跟着影片走进了全国各地的影院,看完电影的观众都忍不住跟着哼唱。当时的百代唱片公司还专门录制了这首歌的唱片,在街头巷尾的唱片店、收音机里循环播放。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place4_03.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
因为它朗朗上口,哪怕是不识字的老百姓,教几遍就能学会,很快就成了街头巷尾、田间地头都能听到的旋律,真正做到了家喻户晓。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
@@ -161,22 +164,13 @@ const NieerPlace4 = () => {
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place4_03.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_18.mp3')}>
|
||||
这张指挥千人大合唱的照片好震撼!除了日常传唱,这首歌在抗战时期还发挥了哪些特别的作用呢?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place4_04.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_19.mp3')}>
|
||||
它的作用可太大了!你看这张1936年的照片,刘良模先生在上海南市公共体育场指挥数千人齐唱,就连前来镇压的警察都被感染,跟着一起唱。抗战期间,抗日军队的行军路上、根据地的文艺宣传队里、城市的救亡集会中,都能听到这首歌的声音。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place4_04.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
它就像冲锋号一样,每次响起都能凝聚起大家的斗志,让战士们更勇敢地奔赴战场,让民众们更坚定地支持抗战,成为整个民族团结抗敌的精神纽带,而它也不再只是文艺作品,更是凝聚了中国人民的抗战士气,成了抗日救亡运动的精神旗帜。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
@@ -15,11 +15,15 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
|
||||
const NieerPlace5 = () => {
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{ moduleName: string }>()
|
||||
|
||||
// 获取碎片动画触发方法
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
|
||||
const starName = 'nieer'
|
||||
const place = 'place5'
|
||||
const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
|
||||
@@ -66,10 +70,10 @@ const NieerPlace5 = () => {
|
||||
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
|
||||
|
||||
const { isEnabled } = useCursorController({
|
||||
initialEnabled: [1],
|
||||
initialEnabled: [2],
|
||||
dependencies: {
|
||||
2: [1],
|
||||
3: [2],
|
||||
1: [2],
|
||||
3: [1],
|
||||
},
|
||||
visited: visitedExhibits,
|
||||
})
|
||||
@@ -85,8 +89,8 @@ const NieerPlace5 = () => {
|
||||
|
||||
const exhibitSrcMap: Record<1 | 2 | 3, string> = {
|
||||
1: 'nieer/place5/场景5展品1',
|
||||
2: 'nieer/place5/场景5展品2',
|
||||
3: 'nieer/place5/场景5展品3',
|
||||
2: 'nieer/place5/场景5展品3',
|
||||
3: 'nieer/place5/场景5展品2',
|
||||
}
|
||||
|
||||
const handleOpenExhibit = (id: 1 | 2 | 3) => {
|
||||
@@ -163,6 +167,17 @@ const NieerPlace5 = () => {
|
||||
<ExhibitOverlay
|
||||
visible={activeExhibitId !== null}
|
||||
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
|
||||
onOpen={() => {
|
||||
if (activeExhibitId === 1) {
|
||||
triggerPuzzleAnimation(10)
|
||||
}
|
||||
if (activeExhibitId === 2) {
|
||||
triggerPuzzleAnimation(11)
|
||||
}
|
||||
if (activeExhibitId === 3) {
|
||||
triggerPuzzleAnimation(12)
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
const closingId = activeExhibitId
|
||||
setActiveExhibitId(null)
|
||||
@@ -170,10 +185,16 @@ const NieerPlace5 = () => {
|
||||
setGuide2Visible(false)
|
||||
setGuide3Visible(false)
|
||||
|
||||
// 光标1:只需要对话,关窗即完成
|
||||
if (closingId == 2) {
|
||||
markExhibitVisited(place, 2)
|
||||
}
|
||||
// 展品1关闭后完成光标1,并触发光标2的两道题
|
||||
if (closingId === 1) {
|
||||
markExhibitVisited(place, 1)
|
||||
saveNieerStore()
|
||||
setQuiz2_1Visible(true)
|
||||
}
|
||||
if (closingId === 3) {
|
||||
setQuiz3_1Visible(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -188,57 +209,34 @@ const NieerPlace5 = () => {
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_01.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_22.mp3')}>
|
||||
那2004年的宪法修正案又对国歌做了什么重要规定呢?为什么要把它写入宪法呀?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_02.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_23.mp3')}>
|
||||
2004年3月14日,第十届全国人民代表大会二次会议通过宪法修正案,明确规定“中华人民共和国国歌是《义勇军进行曲》”,这让国歌的地位有了根本大法的保障。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_03.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
把它写入宪法,是因为这首歌不仅记录了民族救亡的历史,更凝聚了中华民族的精神气节。从抗战时期的冲锋号到新时代的强国音,它早已成为国家尊严的象征,写入宪法能更好地培育全民的爱国意识和国家观念。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
{/* 光标2对话(图2) - 对话结束后依次出现两道题 */}
|
||||
{/* 光标2对话(图2) */}
|
||||
<GuideSteps
|
||||
visible={guide2Visible}
|
||||
currentStep={guide2Step}
|
||||
onStepChange={setGuide2Step}
|
||||
onVisibleChange={(nextVisible) => {
|
||||
setGuide2Visible(nextVisible)
|
||||
if (!nextVisible) {
|
||||
setQuiz2_1Visible(true)
|
||||
}
|
||||
}}
|
||||
onVisibleChange={setGuide2Visible}
|
||||
showMask={false}
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_03.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_20.mp3')}>
|
||||
小沪,从抗日战歌到正式被确立为国歌,中间经历了多久呀?《义勇军进行曲》第一次作为国歌奏响是在什么时候呢?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_04.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_21.mp3')}>
|
||||
时间跨度还真不短呢!1949年9月27日,中国人民政治协商会议第一届全体会议决定,在国歌未正式制定前,以《义勇军进行曲》作为代国歌。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_05.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide">
|
||||
同年10月1日开国大典上,它第一次在天安门广场作为国歌庄严奏响,见证了新中国的诞生。直到1982年12月4日,第五届全国人民代表大会第五次会议才正式将它定为中华人民共和国国歌,完成了从代国歌到正式国歌的跨越。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
@@ -319,35 +317,27 @@ const NieerPlace5 = () => {
|
||||
visible={success2_2Visible}
|
||||
onContinue={() => {
|
||||
setSuccess2_2Visible(false)
|
||||
markExhibitVisited(place, 2)
|
||||
markExhibitVisited(place, 1)
|
||||
saveNieerStore()
|
||||
}}
|
||||
onClose={() => {
|
||||
setSuccess2_2Visible(false)
|
||||
markExhibitVisited(place, 2)
|
||||
markExhibitVisited(place, 1)
|
||||
saveNieerStore()
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 光标3对话(图5) - 对话结束后依次出现两道题 */}
|
||||
{/* 光标3对话(图5) - 关闭展品3后依次出现两道题 */}
|
||||
<GuideSteps
|
||||
visible={guide3Visible}
|
||||
currentStep={guide3Step}
|
||||
onStepChange={setGuide3Step}
|
||||
onVisibleChange={(nextVisible) => {
|
||||
setGuide3Visible(nextVisible)
|
||||
if (!nextVisible) {
|
||||
setQuiz3_1Visible(true)
|
||||
}
|
||||
}}
|
||||
onVisibleChange={setGuide3Visible}
|
||||
showMask={false}
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place5_05.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_24.mp3')}>
|
||||
根据宪法,2017年9月1日第十二届全国人民代表大会常务委员会第二十九次会议通过《中华人民共和国国歌法》,通过国家立法对国歌的奏唱场合、奏唱礼仪和宣传教育进行规范。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
@@ -356,7 +346,7 @@ const NieerPlace5 = () => {
|
||||
<MatchQuizPanel
|
||||
visible={quiz3_1Visible}
|
||||
zIndex={10001}
|
||||
title="【配对】请将左侧《义勇军进行曲》的关键事件与右侧时间进行匹配"
|
||||
title="【连线】请将左侧《义勇军进行曲》的关键事件与右侧时间进行匹配"
|
||||
leftItems={[
|
||||
{
|
||||
id: 'e1982',
|
||||
@@ -454,6 +444,7 @@ const NieerPlace5 = () => {
|
||||
/>
|
||||
|
||||
<SuccessAlert
|
||||
title="恭喜你,判断正确!"
|
||||
visible={success3_2Visible}
|
||||
onContinue={() => {
|
||||
setSuccess3_2Visible(false)
|
||||
|
||||
@@ -12,11 +12,13 @@ import { useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
|
||||
const NieerPlace6 = () => {
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{ moduleName: string }>()
|
||||
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
const starName = 'nieer'
|
||||
const place = 'place6'
|
||||
const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
|
||||
@@ -39,9 +41,9 @@ const NieerPlace6 = () => {
|
||||
const subtitle = completed ? '已完成' : `${progressCount}/${total}`
|
||||
|
||||
const { isEnabled } = useCursorController({
|
||||
initialEnabled: [1],
|
||||
initialEnabled: [2],
|
||||
dependencies: {
|
||||
2: [1],
|
||||
1: [2],
|
||||
},
|
||||
visited: visitedExhibits,
|
||||
})
|
||||
@@ -107,6 +109,14 @@ const NieerPlace6 = () => {
|
||||
<ExhibitOverlay
|
||||
visible={activeExhibitId !== null}
|
||||
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
|
||||
onOpen={() => {
|
||||
if (activeExhibitId === 1) {
|
||||
triggerPuzzleAnimation(14)
|
||||
}
|
||||
if (activeExhibitId === 2) {
|
||||
triggerPuzzleAnimation(13)
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
const closingId = activeExhibitId
|
||||
setActiveExhibitId(null)
|
||||
@@ -138,29 +148,21 @@ const NieerPlace6 = () => {
|
||||
highlightSelectors={[]}
|
||||
zIndex={10001}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place6_01.mp3')}
|
||||
>
|
||||
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_25.mp3')}>
|
||||
小沪,这些不同场景下奏响国歌的画面太让人感动了!在和平年代,这首歌还有什么特别的意义呢?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place6_02.mp3')}
|
||||
>
|
||||
和平年代里,它的精神力量依然不减。你看这张许海峰奥运夺金的照片,国歌奏响时,是民族自豪的宣告;神舟飞船升空时的国歌,是航天人攻坚克难的礼赞;
|
||||
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_26.mp3')}>
|
||||
和平年代里,它的精神力量依然不减。你看这张中国女排奥运夺金的照片,国歌奏响时,是民族自豪的宣告;神舟飞船升空时的国歌,是航天人攻坚克难的礼赞。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_place6_03.mp3')}
|
||||
>
|
||||
抗震救灾现场的国歌,是全国人民众志成城的誓言。它就像一根精神纽带,无论在高光时刻还是艰难关头,都能凝聚起全国人民的力量,提醒我们不忘历史、砥砺前行。
|
||||
<GuideStepsItem speaker="guide">
|
||||
它就像一根精神纽带,无论在高光时刻还是艰难关头,都能凝聚起全国人民的力量,提醒我们不忘历史、砥砺前行。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
{/* completed 后的结束语:统一使用 EndingOverlay */}
|
||||
<EndingOverlay
|
||||
visible={endingVisible}
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_27.mp3')}
|
||||
text={
|
||||
'从民族危机中觉醒的热血青年,到《义勇军进行曲》的谱曲者,聂耳用音符奏响了“救亡图存”的时代强音。' +
|
||||
'而《义勇军进行曲》的影响远不止于此,接下来,请你化身国歌时空旅行者,踏入6个散落于岁月中的故事现场,' +
|
||||
|
||||
@@ -11,19 +11,19 @@ type ContentPage =
|
||||
// 每个场景的多页内容配置(按 scene.id 映射)
|
||||
const SCENE_PAGES: Record<number, ContentPage[]> = {
|
||||
1: [
|
||||
{ type: 'image', src: '场景1内容', audioSrc: 'audio/test.mp3' },
|
||||
{ type: 'image', src: '场景1内容', audioSrc: 'audio/nieer/mp3_nieer_29.mp3' },
|
||||
{ type: 'video', src: 'video/nieer/nieer_2_1.mp4' },
|
||||
],
|
||||
2: [{ type: 'image', src: '场景2内容', audioSrc: 'audio/test.mp3' }],
|
||||
3: [{ type: 'image', src: '场景3内容', audioSrc: 'audio/test.mp3' }],
|
||||
4: [{ type: 'image', src: '场景4内容', audioSrc: 'audio/test.mp3' }],
|
||||
2: [{ type: 'image', src: '场景2内容', audioSrc: 'audio/nieer/mp3_nieer_30.mp3' }],
|
||||
3: [{ type: 'image', src: '场景3内容', audioSrc: 'audio/nieer/mp3_nieer_31.mp3' }],
|
||||
4: [{ type: 'image', src: '场景4内容', audioSrc: 'audio/nieer/mp3_nieer_32.mp3' }],
|
||||
5: [
|
||||
{ type: 'image', src: '场景5内容', audioSrc: 'audio/test.mp3' },
|
||||
{ type: 'image', src: '场景5内容', audioSrc: 'audio/nieer/mp3_nieer_33.mp3' },
|
||||
{ type: 'video', src: 'video/nieer/nieer_2_4.mp4' },
|
||||
],
|
||||
6: [
|
||||
{ type: 'image', src: '场景6内容', audioSrc: 'audio/test.mp3' },
|
||||
{ type: 'video', src: 'video/nieer/nieer_2_4.mp4' },
|
||||
{ type: 'image', src: '场景6内容', audioSrc: 'audio/nieer/mp3_nieer_34.mp3' },
|
||||
{ type: 'video', src: 'video/nieer/nieer_2_5.mp4' },
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Image } from '@/components'
|
||||
import { Image, Video } from '@/components'
|
||||
import {
|
||||
BeijingUnlockInteraction,
|
||||
InputBg,
|
||||
@@ -14,7 +14,43 @@ import ContentDialog from './ContentDialog'
|
||||
import UnlockHintDialog from './UnlockHintDialog'
|
||||
import type { DialogState } from './useDialogReducer'
|
||||
|
||||
interface SharePayload {
|
||||
sceneId?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface DialogLayerProps {
|
||||
state: DialogState
|
||||
onEnterUnlock: () => void
|
||||
onShare: (sceneId: number, payload?: SharePayload) => void
|
||||
onFinishBadgeVideo: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface DialogLocalState {
|
||||
selectedLyric?: string
|
||||
guizhouInput: string
|
||||
pianoFinished: boolean
|
||||
selectedQuote?: string
|
||||
postcardFinished: boolean
|
||||
beijingFinished: boolean
|
||||
postcardState: {
|
||||
hasPlane: boolean
|
||||
hasText: boolean
|
||||
postcardImagePath: string
|
||||
}
|
||||
beijingSelection: {
|
||||
paradeId?: string
|
||||
paradeName?: string
|
||||
paradeImagePath?: string
|
||||
sloganId?: string
|
||||
sloganImagePath?: string
|
||||
previewImageDataUrl?: string
|
||||
}
|
||||
}
|
||||
|
||||
const starName = 'nieer'
|
||||
const DEFAULT_POSTCARD_IMAGE_PATH = 'nieer/reflection/lobby/content/场景5待交互'
|
||||
|
||||
const ANTHEM_LYRICS = [
|
||||
{ value: '1', label: '起来!不愿做奴隶的人们!' },
|
||||
@@ -33,41 +69,208 @@ const SMEDLEY_QUOTES = [
|
||||
{ value: '3', label: '我的心灵在这个世界上除了中国的任何地方都未能找到安宁' },
|
||||
]
|
||||
|
||||
interface DialogLayerProps {
|
||||
state: DialogState
|
||||
onEnterUnlock: () => void
|
||||
onShare: (payload?: Record<string, unknown>) => void
|
||||
onClose: () => void
|
||||
const getCanShare = (sceneId: number, localState: DialogLocalState): boolean => {
|
||||
switch (sceneId) {
|
||||
case 1:
|
||||
return !!localState.selectedLyric
|
||||
case 2:
|
||||
return localState.guizhouInput.trim().length > 0
|
||||
case 3:
|
||||
return localState.pianoFinished
|
||||
case 4:
|
||||
return !!localState.selectedQuote
|
||||
case 5:
|
||||
return localState.postcardFinished
|
||||
case 6:
|
||||
return (
|
||||
localState.beijingFinished &&
|
||||
typeof localState.beijingSelection.previewImageDataUrl === 'string'
|
||||
)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const DialogLayer = ({ state, onEnterUnlock, onShare, onClose }: DialogLayerProps) => {
|
||||
const buildSharePayload = (
|
||||
sceneId: number,
|
||||
localState: DialogLocalState
|
||||
): SharePayload | undefined => {
|
||||
switch (sceneId) {
|
||||
case 1: {
|
||||
const selected = ANTHEM_LYRICS.find((item) => item.value === localState.selectedLyric)
|
||||
return {
|
||||
sceneId: 1,
|
||||
lyricValue: localState.selectedLyric ?? '',
|
||||
lyricLabel: selected?.label ?? '',
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
return {
|
||||
sceneId: 2,
|
||||
reflectionText: localState.guizhouInput.trim(),
|
||||
}
|
||||
case 3:
|
||||
return {
|
||||
sceneId: 3,
|
||||
pianoCompleted: localState.pianoFinished,
|
||||
pianoImagePath: 'nieer/reflection/lobby/box/音符',
|
||||
}
|
||||
case 4: {
|
||||
const selected = SMEDLEY_QUOTES.find((item) => item.value === localState.selectedQuote)
|
||||
return {
|
||||
sceneId: 4,
|
||||
quoteValue: localState.selectedQuote ?? '',
|
||||
quoteLabel: selected?.label ?? '',
|
||||
}
|
||||
}
|
||||
case 5:
|
||||
return {
|
||||
sceneId: 5,
|
||||
...localState.postcardState,
|
||||
}
|
||||
case 6:
|
||||
return {
|
||||
sceneId: 6,
|
||||
...localState.beijingSelection,
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface UnlockContentHandlers {
|
||||
onSelectLyric: (value: string) => void
|
||||
onChangeGuizhouInput: (value: string) => void
|
||||
onFinishPiano: () => void
|
||||
onSelectQuote: (value: string) => void
|
||||
onFinishPostcard: () => void
|
||||
onChangePostcardState: (payload: DialogLocalState['postcardState']) => void
|
||||
onFinishBeijing: () => void
|
||||
onChangeBeijingSelection: (payload: DialogLocalState['beijingSelection']) => void
|
||||
}
|
||||
|
||||
const renderUnlockContent = (
|
||||
sceneId: number,
|
||||
localState: DialogLocalState,
|
||||
handlers: UnlockContentHandlers
|
||||
) => {
|
||||
switch (sceneId) {
|
||||
case 1:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center mt-12">
|
||||
<Image
|
||||
className="mb-20"
|
||||
src={getStaticImage(`${starName}/reflection/lobby/content/场景1交互`)}
|
||||
alt="场景1交互"
|
||||
/>
|
||||
<SelectBg
|
||||
options={ANTHEM_LYRICS}
|
||||
value={localState.selectedLyric || ''}
|
||||
onChange={handlers.onSelectLyric}
|
||||
placeholder="请选择一句国歌歌词"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center mt-12">
|
||||
<Image
|
||||
className="mb-20"
|
||||
src={getStaticImage(`${starName}/reflection/lobby/content/场景2交互`)}
|
||||
alt="场景2交互"
|
||||
/>
|
||||
<InputBg
|
||||
className="w-[875px]"
|
||||
value={localState.guizhouInput}
|
||||
onChange={handlers.onChangeGuizhouInput}
|
||||
placeholder="请输入你的爱国感悟(10字以内)"
|
||||
maxLength={10}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center mt-12">
|
||||
<PianoUnlockInteraction onFinished={handlers.onFinishPiano} />
|
||||
</div>
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center mt-12">
|
||||
<Image
|
||||
className="mb-20"
|
||||
src={getStaticImage(`${starName}/reflection/lobby/content/场景4交互`)}
|
||||
alt="场景4交互"
|
||||
/>
|
||||
<SelectBg
|
||||
options={SMEDLEY_QUOTES}
|
||||
value={localState.selectedQuote || ''}
|
||||
onChange={handlers.onSelectQuote}
|
||||
placeholder="请选择一句最触动你的遗言"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<PostcardUnlockInteraction
|
||||
onFinished={handlers.onFinishPostcard}
|
||||
onStateChange={handlers.onChangePostcardState}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
return (
|
||||
<BeijingUnlockInteraction
|
||||
onFinished={handlers.onFinishBeijing}
|
||||
onSelectionChange={handlers.onChangeBeijingSelection}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const createUnlockHandlers = (
|
||||
setSelectedLyric: (value: string) => void,
|
||||
setGuizhouInput: (value: string) => void,
|
||||
setPianoFinished: (value: boolean) => void,
|
||||
setSelectedQuote: (value: string) => void,
|
||||
setPostcardFinished: (value: boolean) => void,
|
||||
setPostcardState: (payload: DialogLocalState['postcardState']) => void,
|
||||
setBeijingFinished: (value: boolean) => void,
|
||||
setBeijingSelection: (payload: DialogLocalState['beijingSelection']) => void
|
||||
): UnlockContentHandlers => ({
|
||||
onSelectLyric: setSelectedLyric,
|
||||
onChangeGuizhouInput: (value) => setGuizhouInput(value.slice(0, 10)),
|
||||
onFinishPiano: () => setPianoFinished(true),
|
||||
onSelectQuote: setSelectedQuote,
|
||||
onFinishPostcard: () => setPostcardFinished(true),
|
||||
onChangePostcardState: setPostcardState,
|
||||
onFinishBeijing: () => setBeijingFinished(true),
|
||||
onChangeBeijingSelection: setBeijingSelection,
|
||||
})
|
||||
|
||||
const DialogLayer = ({
|
||||
state,
|
||||
onEnterUnlock,
|
||||
onShare,
|
||||
onFinishBadgeVideo,
|
||||
onClose,
|
||||
}: DialogLayerProps) => {
|
||||
const [selectedLyric, setSelectedLyric] = useState<string>()
|
||||
const [guizhouInput, setGuizhouInput] = useState<string>('')
|
||||
const [guizhouInput, setGuizhouInput] = useState('')
|
||||
const [pianoFinished, setPianoFinished] = useState(false)
|
||||
const [selectedQuote, setSelectedQuote] = useState<string>()
|
||||
const [postcardFinished, setPostcardFinished] = useState(false)
|
||||
const [beijingFinished, setBeijingFinished] = useState(false)
|
||||
const [postcardState, setPostcardState] = useState<{
|
||||
hasPlane: boolean
|
||||
hasText: boolean
|
||||
postcardImagePath: string
|
||||
}>({
|
||||
const [postcardState, setPostcardState] = useState({
|
||||
hasPlane: false,
|
||||
hasText: false,
|
||||
postcardImagePath: 'nieer/reflection/lobby/content/场景5待交互',
|
||||
postcardImagePath: DEFAULT_POSTCARD_IMAGE_PATH,
|
||||
})
|
||||
const [beijingSelection, setBeijingSelection] = useState<{
|
||||
paradeId?: string
|
||||
paradeName?: string
|
||||
paradeImagePath?: string
|
||||
sloganId?: string
|
||||
sloganImagePath?: string
|
||||
previewImageDataUrl?: string
|
||||
}>({})
|
||||
const [beijingSelection, setBeijingSelection] = useState<DialogLocalState['beijingSelection']>({})
|
||||
|
||||
const dialogNonce = state.mode === 'idle' ? 0 : state.nonce
|
||||
|
||||
// 每次点击卡片(nonce 变化)时,重置 unlock 交互状态
|
||||
useEffect(() => {
|
||||
setSelectedLyric(undefined)
|
||||
setGuizhouInput('')
|
||||
@@ -78,84 +281,42 @@ const DialogLayer = ({ state, onEnterUnlock, onShare, onClose }: DialogLayerProp
|
||||
setPostcardState({
|
||||
hasPlane: false,
|
||||
hasText: false,
|
||||
postcardImagePath: 'nieer/reflection/lobby/content/场景5待交互',
|
||||
postcardImagePath: DEFAULT_POSTCARD_IMAGE_PATH,
|
||||
})
|
||||
setBeijingSelection({})
|
||||
}, [dialogNonce])
|
||||
|
||||
if (state.mode === 'idle') return <AnimatePresence mode="sync" />
|
||||
|
||||
const scene = LOBBY_COLUMNS.find((s) => s.id === state.sceneId)
|
||||
const scene = LOBBY_COLUMNS.find((item) => item.id === state.sceneId)
|
||||
if (!scene) return null
|
||||
|
||||
let canShare = true
|
||||
if (state.sceneId === 1) {
|
||||
canShare = !!selectedLyric
|
||||
} else if (state.sceneId === 2) {
|
||||
canShare = guizhouInput.trim().length > 0
|
||||
} else if (state.sceneId === 3) {
|
||||
// 欧洲·亚洲:钢琴交互播放完成后才允许分享
|
||||
canShare = pianoFinished
|
||||
} else if (state.sceneId === 4) {
|
||||
canShare = !!selectedQuote
|
||||
} else if (state.sceneId === 5) {
|
||||
canShare = postcardFinished
|
||||
} else if (state.sceneId === 6) {
|
||||
canShare = beijingFinished && typeof beijingSelection.previewImageDataUrl === 'string'
|
||||
const localState: DialogLocalState = {
|
||||
selectedLyric,
|
||||
guizhouInput,
|
||||
pianoFinished,
|
||||
selectedQuote,
|
||||
postcardFinished,
|
||||
beijingFinished,
|
||||
postcardState,
|
||||
beijingSelection,
|
||||
}
|
||||
|
||||
const canShare = getCanShare(state.sceneId, localState)
|
||||
const unlockHandlers = createUnlockHandlers(
|
||||
setSelectedLyric,
|
||||
setGuizhouInput,
|
||||
setPianoFinished,
|
||||
setSelectedQuote,
|
||||
setPostcardFinished,
|
||||
setPostcardState,
|
||||
setBeijingFinished,
|
||||
setBeijingSelection
|
||||
)
|
||||
|
||||
const handleShareClick = () => {
|
||||
if (state.mode !== 'unlock') return
|
||||
|
||||
if (state.sceneId === 1) {
|
||||
const selected = ANTHEM_LYRICS.find((item) => item.value === selectedLyric)
|
||||
onShare({
|
||||
sceneId: 1,
|
||||
lyricValue: selectedLyric ?? '',
|
||||
lyricLabel: selected?.label ?? '',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (state.sceneId === 2) {
|
||||
onShare({
|
||||
sceneId: 2,
|
||||
reflectionText: guizhouInput.trim(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (state.sceneId === 3) {
|
||||
onShare({
|
||||
sceneId: 3,
|
||||
pianoCompleted: pianoFinished,
|
||||
pianoImagePath: 'nieer/reflection/lobby/box/音符',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (state.sceneId === 4) {
|
||||
const selected = SMEDLEY_QUOTES.find((item) => item.value === selectedQuote)
|
||||
onShare({
|
||||
sceneId: 4,
|
||||
quoteValue: selectedQuote ?? '',
|
||||
quoteLabel: selected?.label ?? '',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (state.sceneId === 5) {
|
||||
onShare({
|
||||
sceneId: 5,
|
||||
...postcardState,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (state.sceneId === 6) {
|
||||
onShare({
|
||||
sceneId: 6,
|
||||
...beijingSelection,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onShare()
|
||||
onShare(state.sceneId, buildSharePayload(state.sceneId, localState))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -176,6 +337,19 @@ const DialogLayer = ({ state, onEnterUnlock, onShare, onClose }: DialogLayerProp
|
||||
)}
|
||||
alt="结果页"
|
||||
/>
|
||||
{state.videoSrc && (
|
||||
<div className="absolute inset-0 z-20 bg-black">
|
||||
<Video
|
||||
key={state.videoSrc}
|
||||
src={state.videoSrc}
|
||||
autoPlay
|
||||
playsInline
|
||||
className="w-full h-full"
|
||||
onSkip={onFinishBadgeVideo}
|
||||
onEnded={onFinishBadgeVideo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Image
|
||||
button
|
||||
src={getStaticImage('components/关闭')}
|
||||
@@ -203,82 +377,7 @@ const DialogLayer = ({ state, onEnterUnlock, onShare, onClose }: DialogLayerProp
|
||||
onClose={onClose}
|
||||
onShare={state.hasShare && canShare ? handleShareClick : undefined}
|
||||
>
|
||||
{state.sceneId === 1 && (
|
||||
<div className="flex flex-col items-center justify-center mt-12">
|
||||
<Image
|
||||
className="mb-20"
|
||||
src={getStaticImage(`${starName}/reflection/lobby/content/场景1交互`)}
|
||||
alt="场景1交互"
|
||||
/>
|
||||
<SelectBg
|
||||
options={ANTHEM_LYRICS}
|
||||
value={selectedLyric || ''}
|
||||
onChange={setSelectedLyric}
|
||||
placeholder="请选择一句国歌歌词"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.sceneId === 2 && (
|
||||
<div className="flex flex-col items-center justify-center mt-12">
|
||||
<Image
|
||||
className="mb-20"
|
||||
src={getStaticImage(`${starName}/reflection/lobby/content/场景2交互`)}
|
||||
alt="场景2交互"
|
||||
/>
|
||||
<InputBg
|
||||
className="w-[875px]"
|
||||
value={guizhouInput}
|
||||
onChange={(value) => setGuizhouInput(value.slice(0, 10))}
|
||||
placeholder="请输入你的爱国感悟(10字以内)"
|
||||
maxLength={10}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.sceneId === 3 && (
|
||||
<div className="flex flex-col items-center justify-center mt-12">
|
||||
<PianoUnlockInteraction
|
||||
onFinished={() => {
|
||||
setPianoFinished(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.sceneId === 4 && (
|
||||
<div className="flex flex-col items-center justify-center mt-12">
|
||||
<Image
|
||||
className="mb-20"
|
||||
src={getStaticImage(`${starName}/reflection/lobby/content/场景4交互`)}
|
||||
alt="场景4交互"
|
||||
/>
|
||||
<SelectBg
|
||||
options={SMEDLEY_QUOTES}
|
||||
value={selectedQuote || ''}
|
||||
onChange={setSelectedQuote}
|
||||
placeholder="请选择一句最触动你的遗言"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.sceneId === 5 && (
|
||||
<PostcardUnlockInteraction
|
||||
onFinished={() => {
|
||||
setPostcardFinished(true)
|
||||
}}
|
||||
onStateChange={setPostcardState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.sceneId === 6 && (
|
||||
<BeijingUnlockInteraction
|
||||
onFinished={() => {
|
||||
setBeijingFinished(true)
|
||||
}}
|
||||
onSelectionChange={setBeijingSelection}
|
||||
/>
|
||||
)}
|
||||
{renderUnlockContent(state.sceneId, localState, unlockHandlers)}
|
||||
</UnlockHintDialog>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { Image } from '@/components'
|
||||
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
import { Image, StudyGuideModal } from '@/components'
|
||||
import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { LOBBY_COLUMNS, LOBBY_LAYOUT, LOBBY_TIMELINE, PLACE_REQUIRED_COUNTS } from './lobby.config'
|
||||
import { LOBBY_COLUMNS, LOBBY_LAYOUT, LOBBY_TIMELINE } from './lobby.config'
|
||||
import { useNieerStore } from '@/store/nieerStore'
|
||||
import { useDialogReducer } from './useDialogReducer'
|
||||
import DialogLayer from './DialogLayer'
|
||||
import {
|
||||
BADGE_CONFIG,
|
||||
getAllPlacesCompleted,
|
||||
getBadgeUnlockedMap,
|
||||
getGuideSceneId,
|
||||
getPlaceCompletionMap,
|
||||
getUnlockVideoSrcForScene,
|
||||
getUnlockedByIdMap,
|
||||
} from './reflectionProgress'
|
||||
|
||||
const starName = 'nieer'
|
||||
|
||||
@@ -16,61 +25,38 @@ const NieerReflection = () => {
|
||||
const visitedReflectionCursors = useNieerStore((state) => state.visitedReflectionCursors)
|
||||
const markReflectionCursorVisited = useNieerStore((state) => state.markReflectionCursorVisited)
|
||||
const setReflectionWork = useNieerStore((state) => state.setReflectionWork)
|
||||
const hasSeenReflectionGuide = useNieerStore((state) => state.hasSeenReflectionGuide)
|
||||
const markReflectionGuideSeen = useNieerStore((state) => state.markReflectionGuideSeen)
|
||||
const showGuideModal = !hasSeenReflectionGuide
|
||||
|
||||
const {
|
||||
state: dialogState,
|
||||
openScene,
|
||||
enterUnlock,
|
||||
handleShare,
|
||||
finishBadgeVideo,
|
||||
close,
|
||||
} = useDialogReducer(markReflectionCursorVisited, setReflectionWork)
|
||||
|
||||
const placeCompletion = useMemo(() => {
|
||||
const getVisitedCount = (placeName: string): number =>
|
||||
visitedReflectionCursors[placeName]?.size ?? 0
|
||||
return {
|
||||
1: getVisitedCount('place1') >= (PLACE_REQUIRED_COUNTS[1] ?? 0),
|
||||
2: getVisitedCount('place2') >= (PLACE_REQUIRED_COUNTS[2] ?? 0),
|
||||
3: getVisitedCount('place3') >= (PLACE_REQUIRED_COUNTS[3] ?? 0),
|
||||
4: getVisitedCount('place4') >= (PLACE_REQUIRED_COUNTS[4] ?? 0),
|
||||
5: getVisitedCount('place5') >= (PLACE_REQUIRED_COUNTS[5] ?? 0),
|
||||
6: getVisitedCount('place6') >= (PLACE_REQUIRED_COUNTS[6] ?? 0),
|
||||
} as const
|
||||
}, [visitedReflectionCursors])
|
||||
|
||||
const unlockedById = useMemo(
|
||||
() =>
|
||||
({
|
||||
1: true,
|
||||
2: placeCompletion[1],
|
||||
3: placeCompletion[2],
|
||||
4: placeCompletion[3],
|
||||
5: placeCompletion[4],
|
||||
6: placeCompletion[5],
|
||||
}) as const,
|
||||
[placeCompletion]
|
||||
const placeCompletion = useMemo(
|
||||
() => getPlaceCompletionMap(visitedReflectionCursors),
|
||||
[visitedReflectionCursors]
|
||||
)
|
||||
|
||||
const badgeUnlocked = useMemo(
|
||||
() => ({
|
||||
1: placeCompletion[1] && placeCompletion[2],
|
||||
2: placeCompletion[3] && placeCompletion[4],
|
||||
3: placeCompletion[5] && placeCompletion[6],
|
||||
}),
|
||||
[placeCompletion]
|
||||
)
|
||||
|
||||
const guideSceneId = useMemo(() => {
|
||||
const nextScene = LOBBY_COLUMNS.find(
|
||||
(scene) => !placeCompletion[scene.id as keyof typeof placeCompletion]
|
||||
)
|
||||
return nextScene?.id ?? 6
|
||||
}, [placeCompletion])
|
||||
const unlockedById = useMemo(() => getUnlockedByIdMap(placeCompletion), [placeCompletion])
|
||||
const badgeUnlocked = useMemo(() => getBadgeUnlockedMap(placeCompletion), [placeCompletion])
|
||||
const guideSceneId = useMemo(() => getGuideSceneId(placeCompletion), [placeCompletion])
|
||||
const allPlacesCompleted = useMemo(
|
||||
() => LOBBY_COLUMNS.every((scene) => placeCompletion[scene.id as keyof typeof placeCompletion]),
|
||||
() => getAllPlacesCompleted(placeCompletion),
|
||||
[placeCompletion]
|
||||
)
|
||||
|
||||
const handleResultShare = (sceneId: number, payload?: Record<string, unknown>) => {
|
||||
handleShare({
|
||||
payload,
|
||||
unlockVideoSrc: getUnlockVideoSrcForScene(sceneId, visitedReflectionCursors),
|
||||
})
|
||||
}
|
||||
|
||||
if (!moduleName) return null
|
||||
|
||||
return (
|
||||
@@ -78,7 +64,6 @@ const NieerReflection = () => {
|
||||
className="w-full h-full relative bg-cover bg-center bg-no-repeat overflow-hidden"
|
||||
style={{ backgroundImage: getBackgroundImage(`${starName}/reflection/lobby/场景首页底图`) }}
|
||||
>
|
||||
{/* 返回按钮 */}
|
||||
<Image
|
||||
className="absolute right-6 top-5 z-30"
|
||||
src={getStaticImage('components/返回')}
|
||||
@@ -86,7 +71,6 @@ const NieerReflection = () => {
|
||||
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
|
||||
/>
|
||||
|
||||
{/* 六列时间轴内容 */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute h-full grid z-1',
|
||||
@@ -98,7 +82,6 @@ const NieerReflection = () => {
|
||||
gridTemplateColumns: `repeat(${LOBBY_LAYOUT.columnCount}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{/* 琴弦层:在背景图之上、在顶部图标之下 */}
|
||||
<img
|
||||
className="absolute -left-65 top-0 z-2 w-[1651px] h-auto pointer-events-none"
|
||||
src={getStaticImage(`${starName}/reflection/lobby/menu/琴弦`)}
|
||||
@@ -106,7 +89,6 @@ const NieerReflection = () => {
|
||||
/>
|
||||
{LOBBY_COLUMNS.map((scene) => {
|
||||
const isUnlocked = unlockedById[scene.id as keyof typeof unlockedById]
|
||||
// 顶部图标只在“该模块已完成学习任务”时亮起;未完成(即当前进度)保持黑白
|
||||
const isCompleted = placeCompletion[scene.id as keyof typeof placeCompletion]
|
||||
const iconSrc = isCompleted ? scene.icon : scene.iconBw
|
||||
const isClickable = isUnlocked || scene.id === guideSceneId
|
||||
@@ -131,7 +113,6 @@ const NieerReflection = () => {
|
||||
src={getStaticImage(`${starName}/reflection/lobby/${scene.cityImage}`)}
|
||||
alt={scene.city}
|
||||
/>
|
||||
{/* 顶部图标 */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-3',
|
||||
@@ -156,22 +137,18 @@ const NieerReflection = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 点击前往(当前引导场景) */}
|
||||
{showGuideState && (
|
||||
<>
|
||||
<Image
|
||||
className="animate-finger-bounce absolute bottom-[calc(50%-200px)] translate-y-1/2"
|
||||
src={getStaticImage(`${starName}/reflection/lobby/icon/点击前往`)}
|
||||
alt="点击前往"
|
||||
/>
|
||||
</>
|
||||
<Image
|
||||
className="animate-finger-bounce absolute bottom-[calc(50%-200px)] translate-y-1/2"
|
||||
src={getStaticImage(`${starName}/reflection/lobby/icon/点击前往`)}
|
||||
alt="点击前往"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 横跨年份层:只负责 1937 跨列展示 */}
|
||||
<div
|
||||
className={cn('absolute z-10 grid', LOBBY_LAYOUT.timelineTop, LOBBY_LAYOUT.columnsLeft)}
|
||||
style={{
|
||||
@@ -206,7 +183,6 @@ const NieerReflection = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 底部 footer + 徽章区 */}
|
||||
<div
|
||||
className="absolute left-0 bottom-0 w-full h-[201px] z-20 flex justify-center items-center pt-20 gap-10"
|
||||
style={{
|
||||
@@ -214,23 +190,10 @@ const NieerReflection = () => {
|
||||
}}
|
||||
>
|
||||
<div className="relative w-340 h-full">
|
||||
{[
|
||||
{
|
||||
id: 1,
|
||||
className: 'top-0 left-36',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
className: 'top-0 left-146',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
className: 'top-0 right-36',
|
||||
},
|
||||
].map((i) => {
|
||||
const isLit = badgeUnlocked[i.id as keyof typeof badgeUnlocked]
|
||||
{BADGE_CONFIG.map((badge) => {
|
||||
const isLit = badgeUnlocked[badge.id as keyof typeof badgeUnlocked]
|
||||
return (
|
||||
<div key={i.id} className={cn('absolute w-[193px]', i.className)}>
|
||||
<div key={badge.id} className={cn('absolute w-[193px]', badge.className)}>
|
||||
<Image
|
||||
width={193}
|
||||
src={getStaticImage(`${starName}/reflection/lobby/icon/徽章底板`)}
|
||||
@@ -240,8 +203,8 @@ const NieerReflection = () => {
|
||||
<Image
|
||||
className="absolute inset-0"
|
||||
width={193}
|
||||
src={getStaticImage(`${starName}/reflection/lobby/icon/徽章${i.id}`)}
|
||||
alt={`徽章${i.id}`}
|
||||
src={getStaticImage(`${starName}/reflection/lobby/icon/徽章${badge.id}`)}
|
||||
alt={`徽章${badge.id}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -250,7 +213,6 @@ const NieerReflection = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右下角:旅行箱 */}
|
||||
<Image
|
||||
button
|
||||
src={getStaticImage(`${starName}/reflection/lobby/icon/旅行箱`)}
|
||||
@@ -259,7 +221,6 @@ const NieerReflection = () => {
|
||||
onClick={() => navigate(`/${moduleName}/${starName}/reflection/box`)}
|
||||
/>
|
||||
|
||||
{/* debug 参考线 */}
|
||||
{debugLobby && (
|
||||
<div
|
||||
className="absolute inset-0 z-40 pointer-events-none"
|
||||
@@ -274,9 +235,21 @@ const NieerReflection = () => {
|
||||
<DialogLayer
|
||||
state={dialogState}
|
||||
onEnterUnlock={enterUnlock}
|
||||
onShare={handleShare}
|
||||
onShare={handleResultShare}
|
||||
onFinishBadgeVideo={finishBadgeVideo}
|
||||
onClose={close}
|
||||
/>
|
||||
|
||||
<StudyGuideModal
|
||||
visible={showGuideModal}
|
||||
firstLineIndent="2em"
|
||||
onContinue={markReflectionGuideSeen}
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_28.mp3')}
|
||||
>
|
||||
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
|
||||
请登陆“时空回溯・亲历时代”体验仓,下一站:1936年,上海。
|
||||
请带上你的旅行包,穿越时空解锁6个国歌故事,集齐精神徽章,绘制属于你的国歌精神图谱吧~时空门已开,请出发吧!`}
|
||||
</StudyGuideModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||