chore: 更新素材和组件优化

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

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

No files matched your search

@@ -30,6 +30,7 @@ interface AnimationInstance extends FlyToAnimationOptions {
startPos: { x: number; y: number }
targetPos: { x: number; y: number }
elementHtml: string
size: { width: number; height: number }
}
class FlyToAnimationManagerClass {
@@ -142,7 +143,7 @@ class FlyToAnimationManagerClass {
clonedElement.style.margin = '0'
clonedElement.style.padding = '0'
// 获取克隆后的 HTML(只获取内部内容,不包含外层元素)
// 获取克隆后的内部内容;尺寸由飞行包装层显式固定,避免 outerHTML 的 absolute 坐标造成二次偏移
const elementHtml = clonedElement.innerHTML
// 创建动画实例
@@ -153,6 +154,7 @@ class FlyToAnimationManagerClass {
startPos: { x: startX, y: startY },
targetPos: { x: targetX, y: targetY },
elementHtml,
size: { width: fromRect.width, height: fromRect.height },
}
this.animations.set(id, instance)
@@ -183,6 +185,7 @@ const FlyToAnimationItem = ({ animation }: { animation: AnimationInstance }) =>
startPos,
targetPos,
elementHtml,
size,
duration = 1200,
ease = 'easeInOut',
className = '',
@@ -223,16 +226,21 @@ const FlyToAnimationItem = ({ animation }: { animation: AnimationInstance }) =>
duration: duration / 1000,
},
}}
className={`fixed pointer-events-none z-[99999] ${className}`}
className={`fixed pointer-events-none z-100002 ${className}`}
style={{
left: 0,
top: 0,
width: size.width,
height: size.height,
}}
transformTemplate={({ x, y, scale }) =>
`translate(-50%, -50%) translateX(${x}) translateY(${y}) scale(${scale})`
}
>
<div dangerouslySetInnerHTML={{ __html: elementHtml }} style={{ pointerEvents: 'none' }} />
<div
dangerouslySetInnerHTML={{ __html: elementHtml }}
style={{ pointerEvents: 'none', width: '100%', height: '100%', position: 'relative' }}
/>
</motion.div>
)
}
+101 -103
View File
@@ -89,139 +89,137 @@ const ExhibitFlipOverlay = ({
if (!mounted || !visible) return null
return (
<div style={OVERLAY_STYLE}>
<div
className={cn('absolute inset-0 flex items-center justify-center', className)}
style={{ pointerEvents: 'auto' }}
>
{/* 第二段:背景与按钮在展品入场后再出现 */}
{showChrome && (
<>
<div
className={cn('absolute inset-0 flex items-center justify-center z-9999', className)}
style={{ pointerEvents: 'auto' }}
>
{/* 第二段:背景与按钮在展品入场后再出现 */}
{showChrome && (
<>
<motion.div
className="absolute inset-0 bg-black/70"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
/>
{onClose && (
<motion.div
className="absolute inset-0 bg-black/70"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute top-5 right-5 z-20"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
/>
>
<Image
src={backSrc}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
alt="返回"
button
/>
</motion.div>
)}
</>
)}
{onClose && (
<motion.div
className="absolute top-5 right-5 z-20"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
<Image
src={backSrc}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
alt="返回"
button
/>
</motion.div>
)}
</>
)}
<div
className="relative z-10 w-[900px] h-[900px] max-w-[90%] max-h-[90%] flex items-center justify-center select-none"
<div
className="relative z-10 w-[900px] h-[900px] max-w-[90%] max-h-[90%] flex items-center justify-center select-none"
style={{
perspective: 1200,
WebkitPerspective: '1200px',
}}
>
{/* 第一段:像“获得装备”一样先把展品弹出 */}
<motion.div
className={cn(
'relative w-full h-full',
'transition-transform duration-500 ease-[cubic-bezier(0.2,0.8,0.2,1)]'
)}
initial={{ scale: 0.25, opacity: 0, rotateY: 0 }}
animate={{ scale: displayScale, opacity: 1, rotateY: flipped ? 180 : 0 }}
transition={{
type: 'spring',
stiffness: 520,
damping: 26,
mass: 0.8,
}}
onAnimationComplete={() => {
if (!showChrome) {
setShowChrome(true)
onEnterComplete?.()
}
}}
style={{
perspective: 1200,
WebkitPerspective: '1200px',
transformStyle: 'preserve-3d',
WebkitTransformStyle: 'preserve-3d',
}}
>
{/* 第一段:像“获得装备”一样先把展品弹出 */}
<motion.div
className={cn(
'relative w-full h-full',
'transition-transform duration-500 ease-[cubic-bezier(0.2,0.8,0.2,1)]'
)}
initial={{ scale: 0.25, opacity: 0, rotateY: 0 }}
animate={{ scale: displayScale, opacity: 1, rotateY: flipped ? 180 : 0 }}
transition={{
type: 'spring',
stiffness: 520,
damping: 26,
mass: 0.8,
}}
onAnimationComplete={() => {
if (!showChrome) {
setShowChrome(true)
onEnterComplete?.()
}
}}
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transformStyle: 'preserve-3d',
WebkitTransformStyle: 'preserve-3d',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
}}
>
<Image
src={frontImageSrc}
alt="展品"
className="max-w-full max-h-full object-contain drop-shadow-2xl"
/>
</div>
{canFlip && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'rotateY(180deg)',
}}
>
<Image
src={frontImageSrc}
alt="展品"
src={backImageSrc!}
alt="展品背面"
className="max-w-full max-h-full object-contain drop-shadow-2xl"
/>
</div>
{canFlip && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'rotateY(180deg)',
}}
>
<Image
src={backImageSrc!}
alt="展品背面"
className="max-w-full max-h-full object-contain drop-shadow-2xl"
/>
</div>
)}
</motion.div>
{showChrome && canFlip && (
<motion.div
className="absolute bottom-50 right-0 z-20"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
<FlipActionButton
onClick={(e) => {
e.stopPropagation()
setFlipped((v) => !v)
}}
>
翻转一下
</FlipActionButton>
</motion.div>
)}
</div>
</motion.div>
{showChrome && footer && (
{showChrome && canFlip && (
<motion.div
className={cn(
'absolute left-1/2 bottom-38 z-30 -translate-x-1/2 pointer-events-auto',
footerClassName
)}
className="absolute bottom-50 right-0 z-20"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
{footer}
<FlipActionButton
onClick={(e) => {
e.stopPropagation()
setFlipped((v) => !v)
}}
>
翻转一下
</FlipActionButton>
</motion.div>
)}
</div>
{showChrome && footer && (
<motion.div
className={cn(
'absolute left-1/2 bottom-38 z-30 -translate-x-1/2 pointer-events-auto',
footerClassName
)}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
{footer}
</motion.div>
)}
</div>
)
}
+1 -1
View File
@@ -191,7 +191,7 @@ const GuideSteps = ({
key={`container-${currentSpeaker}`}
className={cn(
'absolute bottom-0 w-[1764px] h-[656px] pt-75 pb-10',
currentSpeaker === 'student' ? 'pl-50 pr-40' : 'pr-80'
currentSpeaker === 'student' ? 'pl-50 pr-40' : 'pr-70'
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
+13 -3
View File
@@ -6,9 +6,19 @@ import {
type SyncLoadingOptions,
} from './syncLoadingApi'
export const SyncLoadingHost = () => {
interface SyncLoadingHostProps {
visible?: boolean
text?: string
}
export const SyncLoadingHost = ({
visible: controlledVisible,
text: controlledText,
}: SyncLoadingHostProps) => {
const [text, setText] = useState('正在同步学习进度...')
const [visible, setVisible] = useState(false)
const actualVisible = controlledVisible ?? visible
const actualText = controlledText ?? text
useEffect(() => {
const handleShow = (event: Event) => {
@@ -31,7 +41,7 @@ export const SyncLoadingHost = () => {
return (
<AnimatePresence>
{visible && (
{actualVisible && (
<motion.div
key="sync-loading"
className="fixed inset-0 z-10000 flex items-center justify-center bg-black/60"
@@ -63,7 +73,7 @@ export const SyncLoadingHost = () => {
/>
</svg>
<div className="text-3xl font-semibold tracking-[6px] text-[#f4e4bd] drop-shadow-[0_2px_8px_rgba(0,0,0,0.7)]">
{text}
{actualText}
</div>
</motion.div>
</motion.div>
+67 -25
View File
@@ -2,6 +2,7 @@ import { Outlet, useMatches, useLocation } from 'react-router-dom'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { getPublicPath, getStaticImage } from '@/lib/utils'
import { isVideoCompletedByResidence } from '@/lib/utils/videoCompletion'
import type { VideoRouteHandle } from '@/router/types'
import Video, { type VideoRef } from '@/components/video'
import { VideoProvider } from '@/contexts/VideoContext'
@@ -9,6 +10,7 @@ import { PuzzleBoxProvider, usePuzzleBox } from '@/contexts/PuzzleBoxContext'
import { PuzzleAnimationProvider } from '@/contexts/PuzzleAnimationContext'
import { Image, PuzzleSystem, flyToAnimation } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getPuzzlePieceData } from '@/config/puzzleData'
import PuzzlePiecePortal from '@/components/puzzle/PuzzlePiecePortal'
/**
@@ -36,7 +38,7 @@ const ResidenceLayoutContent = () => {
const matches = useMatches()
const location = useLocation()
const { puzzleBoxRef } = usePuzzleBox()
const { unlockPuzzle, isPuzzleUnlocked } = usePuzzleStore()
const { unlockPuzzle, isPuzzleUnlocked, getPuzzleCollectProgress, isLoaded } = usePuzzleStore()
const [isVideoPlaying, setIsVideoPlaying] = useState(false)
const [isContentVisible, setIsContentVisible] = useState(false)
const [isVideoFadingOut, setIsVideoFadingOut] = useState(false)
@@ -49,6 +51,8 @@ const ResidenceLayoutContent = () => {
const [puzzlePieceId, setPuzzlePieceId] = useState<number | null>(null)
const [isAnimationPlaying, setIsAnimationPlaying] = useState(false)
const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行
const [isAwaitingCollect, setIsAwaitingCollect] = useState(false)
const [isCollectConfirming, setIsCollectConfirming] = useState(false)
const puzzlePieceRef = useRef<HTMLDivElement>(null)
const animationInProgressRef = useRef<boolean>(false) // 防止重复执行动画
const animationCompletionRef = useRef<(() => void) | null>(null)
@@ -65,21 +69,35 @@ const ResidenceLayoutContent = () => {
return ''
}, [location.pathname])
// 判断当前路由是否是 place1(讲解视频,首次不可跳过)
const isPlace1Route = useMemo(() => /place1/.test(location.pathname), [location.pathname])
// place1 讲解视频是否已完成(用于 initiallyCompleted)
const place1VideoCompleted = useMemo(
() => (residence ? isVideoCompletedByResidence(residence, `${residence}_place1_entry`) : false),
[residence]
)
// 判断当前故居是否支持拼图盒功能
const hasPuzzleFeature = useMemo(
() => RESIDENCES_WITH_PUZZLE.includes(residence as any),
[residence]
)
// 当前故居的拼图收集进度(百分比)
// 不使用 useMemo:组件已订阅整个 puzzleStore,收集碎片后会重新渲染并实时计算
const residenceProgress =
isLoaded && residence ? Math.round(getPuzzleCollectProgress(residence)) : 0
// 动画配置常量
const ANIMATION_CONFIG = {
APPEAR_DURATION: 800, // 初始出现动画持续时间(3D旋转+放大,毫秒)
GLOW_DURATION: 800, // 发光动画持续时间(毫秒)
FLY_DURATION: 1400, // 飞行动画持续时间(毫秒)
COLLECT_REVEAL_DELAY: 620, // 标题和按钮跟随碎片出现的延迟(毫秒)
COLLECT_CONFIRM_DURATION: 180, // 点击收下后的确认反馈时长(毫秒)
FLY_DURATION: 1650, // 飞行动画持续时间(毫秒)
RETRY_DELAY: 100, // 重试延迟(毫秒)
MAX_RETRIES: 3, // 最大重试次数
SCALE: { from: 1, to: 0.25 }, // 缩放比例(更小的目标尺寸,效果更明显)
OPACITY: [1, 1, 0.6, 0] as number[], // 透明度变化(更平滑的淡出)
SCALE: { from: 1, to: 0.16 }, // 缩放比例(更小的目标尺寸,效果更明显)
OPACITY: [1, 1, 0.95, 0.2, 0] as number[], // 透明度变化(更平滑的淡出)
}
const resolveAnimation = useCallback(() => {
@@ -99,6 +117,8 @@ const ResidenceLayoutContent = () => {
setShowPuzzlePiece(false)
setPuzzlePieceId(null)
setIsFlying(false)
setIsAwaitingCollect(false)
setIsCollectConfirming(false)
animationInProgressRef.current = false
resolveAnimation()
}, [resolveAnimation])
@@ -110,8 +130,6 @@ const ResidenceLayoutContent = () => {
return false
}
setIsFlying(true)
// 使用 requestAnimationFrame 确保元素已完全渲染
requestAnimationFrame(() => {
if (!puzzlePieceRef.current || !residence) {
@@ -125,7 +143,7 @@ const ResidenceLayoutContent = () => {
duration: ANIMATION_CONFIG.FLY_DURATION,
scale: ANIMATION_CONFIG.SCALE,
opacity: ANIMATION_CONFIG.OPACITY,
ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速
ease: [0.22, 0.72, 0.16, 1],
onComplete: () => {
if (residence) {
unlockPuzzle(residence, pieceId)
@@ -133,6 +151,10 @@ const ResidenceLayoutContent = () => {
cleanupAnimation()
},
})
requestAnimationFrame(() => {
setIsFlying(true)
})
})
return true
@@ -184,6 +206,26 @@ const ResidenceLayoutContent = () => {
]
)
const currentPuzzlePieceData = useMemo(() => {
if (!residence || !puzzlePieceId) return null
return getPuzzlePieceData(residence, puzzlePieceId)
}, [puzzlePieceId, residence])
const handleCollectPuzzlePiece = useCallback(async () => {
if (!puzzlePieceId || isCollectConfirming || isFlying) return
setIsCollectConfirming(true)
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.COLLECT_CONFIRM_DURATION))
setIsAwaitingCollect(false)
await waitForElementsAndAnimate(puzzlePieceId)
}, [
ANIMATION_CONFIG.COLLECT_CONFIRM_DURATION,
isCollectConfirming,
isFlying,
puzzlePieceId,
waitForElementsAndAnimate,
])
// 处理碎片动画触发(仅支持拼图功能的故居)
const handleTriggerPuzzleAnimation = useCallback(
(pieceId: number, options?: { onComplete?: () => void }) => {
@@ -222,27 +264,17 @@ const ResidenceLayoutContent = () => {
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
// 等待初始出现动画(旋转+放大)完成
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION))
await new Promise((resolve) =>
setTimeout(resolve, ANIMATION_CONFIG.COLLECT_REVEAL_DELAY)
)
// 等待发光动画显示一段时间,让用户看到碎片
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION))
// 等待元素准备就绪并执行飞行动画
await waitForElementsAndAnimate(pieceId)
setIsAwaitingCollect(true)
})
})
}, 0)
})
},
[
ANIMATION_CONFIG.APPEAR_DURATION,
ANIMATION_CONFIG.GLOW_DURATION,
hasPuzzleFeature,
isPuzzleUnlocked,
residence,
waitForElementsAndAnimate,
]
[ANIMATION_CONFIG.COLLECT_REVEAL_DELAY, hasPuzzleFeature, isPuzzleUnlocked, residence]
)
// 获取当前路由的视频路径
@@ -359,6 +391,9 @@ const ResidenceLayoutContent = () => {
}}
>
<Image src={getStaticImage('components/拼图盒')} button />
<div className="absolute inset-0 flex items-center justify-center pb-8 pointer-events-none text-white text-[36px] font-bold leading-none drop-shadow-[0_2px_2px_rgba(0,0,0,0.8)]">
{residenceProgress}%
</div>
</div>
)}
</div>
@@ -382,6 +417,9 @@ const ResidenceLayoutContent = () => {
showSkipButton={true}
skipText="跳过"
onSkip={handleSkip}
skipKey={isPlace1Route && residence ? `${residence}_place1_entry` : undefined}
showReadonlyProgress={isPlace1Route}
initiallyCompleted={isPlace1Route ? place1VideoCompleted : undefined}
/>
</div>
)}
@@ -402,10 +440,14 @@ const ResidenceLayoutContent = () => {
residence={residence}
pieceId={puzzlePieceId}
hidden={isFlying}
awaitingCollect={isAwaitingCollect}
confirming={isCollectConfirming}
puzzlePieceTitle={currentPuzzlePieceData?.title ?? null}
onCollect={handleCollectPuzzlePiece}
/>
)}
{/* 动画遮罩层 - 动画播放时盖住所有元素,防止操作 */}
{/* 动画遮罩层 - 动画播放时盖住所有元素,防止操作(按钮在更高层级的 Portal 中可点击) */}
{isAnimationPlaying &&
createPortal(
<div
@@ -160,7 +160,7 @@ const SelectFillQuizPanel = ({
}}
>
{value || '____'}
{value && !disabled && (
{/* {value && !disabled && (
<span
className="ml-3 text-[22px] text-[#f0be75]/80"
onClick={(e) => {
@@ -170,7 +170,7 @@ const SelectFillQuizPanel = ({
>
×
</span>
)}
)} */}
</button>
{blank.suffix && <span>{blank.suffix}</span>}
</span>
+75 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { getPublicPath, getStaticImage } from '@/lib/utils'
import { filterInput } from '@/lib/utils/sensitiveWords'
import { filterInput, validateInput, containsSensitiveWord } from '@/lib/utils/sensitiveWords'
import { Image, Select, Video, type VideoRef } from '@/components'
import { showHint } from '@/components/hint'
@@ -98,6 +98,7 @@ const LetterTemplate = ({
const [showReplyVideo, setShowReplyVideo] = useState(false)
const [showScene, setShowScene] = useState(false)
const [showReplyImage, setShowReplyImage] = useState(false)
const [isComposing, setIsComposing] = useState(false) // 追踪中文输入法状态
const sendVideoRef = useRef<VideoRef>(null)
const replyVideoRef = useRef<VideoRef>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
@@ -123,6 +124,14 @@ const LetterTemplate = ({
return false
}
// 验证落款姓名
const signatureValidation = validateInput(trimmedSignatureName)
if (!signatureValidation.valid) {
showHint(`落款姓名${signatureValidation.message}`)
setSignatureError(true)
return false
}
const nextErrors = validate
? validate(nextValues, letterContent)
: nextValues.map((value, index) => {
@@ -139,6 +148,15 @@ const LetterTemplate = ({
return true
}
// 验证输入内容(禁止英文、数字、特殊符号、敏感词)
if (field.sanitize !== false) {
const validation = validateInput(trimmedValue)
if (!validation.valid) {
showHint(validation.message)
return true
}
}
if (field.correctValue && trimmedValue !== field.correctValue) {
if (field.hints?.error) {
showHint(field.hints.error)
@@ -206,7 +224,23 @@ const LetterTemplate = ({
const updateBlank = (index: number, value: string) => {
const field = resolvedFields[index] ?? DEFAULT_TEXT_FIELD
const nextValue = field.sanitize === false ? value : filterInput(value)
// 中文输入法输入过程中不过滤,等输入完成后再过滤
const shouldFilter = field.sanitize !== false && !isComposing
const nextValue = shouldFilter ? filterInput(value) : value
// 检测是否有非法字符被过滤掉(提示用户)
if (shouldFilter && value !== nextValue && value.length > 0) {
// 有字符被过滤了,检查是哪种类型
if (/[a-zA-Z]/.test(value)) {
showHint('不能包含英文字母')
} else if (/\d/.test(value)) {
showHint('不能包含数字')
} else if (/[\p{P}\p{S}]/u.test(value)) {
showHint('不能包含标点符号')
}
}
const nextValues = [...blankValues]
nextValues[index] = nextValue
setBlankValues(nextValues)
@@ -287,7 +321,20 @@ const LetterTemplate = ({
}
const handleSignatureNameChange = (value: string) => {
const nextValue = filterInput(value)
// 中文输入法输入过程中不过滤,等输入完成后再过滤
const nextValue = isComposing ? value : filterInput(value)
// 检测是否有非法字符被过滤掉(提示用户)
if (!isComposing && value !== nextValue && value.length > 0) {
if (/[a-zA-Z]/.test(value)) {
showHint('不能包含英文字母')
} else if (/\d/.test(value)) {
showHint('不能包含数字')
} else if (/[\p{P}\p{S}]/u.test(value)) {
showHint('不能包含标点符号')
}
}
setSignatureName(nextValue)
const hasError = !nextValue.trim()
@@ -318,6 +365,12 @@ const LetterTemplate = ({
value={signatureName}
maxLength={12}
onChange={(e) => handleSignatureNameChange(e.target.value)}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={(e) => {
setIsComposing(false)
// 输入法完成后立即过滤
handleSignatureNameChange(filterInput(e.currentTarget.value))
}}
/>
<span className="text-[26px] leading-[1.8]">{currentDate}</span>
</div>
@@ -393,15 +446,33 @@ const LetterTemplate = ({
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{partIndex === 0 ? `${linePrefix}${part}` : part}
<style>{`.blank-input::placeholder { color: #999999; font-size: 26px; }`}</style>
<input
name={`blank-${currentIndex}`}
className={`inline-block h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-center outline-none ${field.widthClassName || 'w-[100px]'} ${field.className || ''} ${blankErrors[currentIndex] ? 'border-red-500' : 'border-[#4e402e]'}`}
className={`blank-input inline-block h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-center outline-none ${field.widthClassName || 'w-[100px]'} ${field.className || ''} ${blankErrors[currentIndex] ? 'border-red-500' : 'border-[#4e402e]'}`}
style={{ fontSize: field.fontSize ? `${field.fontSize}px` : '26px' }}
placeholder={field.placeholder || ''}
autoComplete="off"
value={fieldValue}
maxLength={field.maxLength ?? 10}
onChange={(e) => updateBlank(currentIndex, e.target.value)}
onBlur={() => handleInputBlur(currentIndex)}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={(e) => {
setIsComposing(false)
// 输入法完成后立即过滤
const field = resolvedFields[currentIndex] ?? DEFAULT_TEXT_FIELD
if (field.sanitize !== false) {
const originalValue = e.currentTarget.value
const filteredValue = filterInput(originalValue)
if (containsSensitiveWord(originalValue)) {
showHint('内容包含敏感词,已替换为星号')
}
updateBlank(currentIndex, filteredValue)
}
}}
/>
</span>
)
+3 -2
View File
@@ -17,7 +17,7 @@ const letterContent = `敬爱的陈云爷爷:
我是____专业的学生。今天,我怀着崇敬又激动的心情,参观了位于上海市青浦区练塘镇的陈云纪念馆,学习了您“不唯上、不唯书、只唯____”的做事准则,还有“综合平衡”“人民至上”的经济思想。我深刻体会到,这些精神不仅是建设年代的智慧结晶,更是新时代指引我们前行的思想灯塔。
作为新时代学生,我会在专业学习中____,像您当年练珠算、搞调查那样精益求精;未来无论从事什么工作,都要把服务群众、____放在首位;面对困难时不退缩,像您当年稳定物价、推进实业那样,用____本领(结合所学专业)为国家发展贡献自己的力量!
作为新时代学生,我会在专业学习中____,像您当年练珠算、搞调查那样精益求精;未来无论从事什么工作,都要把服务群众、____放在首位;面对困难时不退缩,像您当年稳定物价、推进实业那样,用____本领为国家发展贡献自己的力量!
站在新时代的起点,我一定以____为志向,勤学苦练,传承精神,让您的务实担当在青春里扎根,让奋斗在建设祖国的实践中绽放光彩!`
@@ -47,7 +47,8 @@ const fields: Record<number, LetterFieldConfig> = {
3: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
widthClassName: 'w-[180px]',
placeholder: '结合所学专业',
readOnlyFallback: '专业',
},
4: {
+3 -2
View File
@@ -30,7 +30,7 @@ const letterContent = `敬爱的毛泽东主席:
我是____专业的学生。今天,我怀着崇敬又激动的心情,参观了位于茂名北路120弄甲秀里的上海毛泽东旧居陈列馆,学习了毛泽东思想活的灵魂——____、____、____。我深刻体会到,这十二个字不仅是革命年代的智慧结晶,更是新时代指引我们前行的思想灯塔。
作为中职生,我深知“____”要求我们在专业学习中脚踏实地,掌握扎实技能;“____”提醒我们,未来无论从事什么职业,都要以服务社会、贡献他人为己任;而“独立自主”则激励我们,在技术革新中____,像C919大飞机团队那样,用____本领(结合所学专业)为推进中国式现代化贡献自己的力量!
作为中职生,我深知“____”要求我们在专业学习中脚踏实地,掌握扎实技能;“____”提醒我们,未来无论从事什么职业,都要以服务社会、贡献他人为己任;而“独立自主”则激励我们,在技术革新中____,像C919大飞机团队那样,用____本领为推进中国式现代化贡献自己的力量!
站在新时代的起点,我一定以____为志向,勤学苦练,精益求精,让____精神在手中传承,让青春在建设祖国的实践中绽放光彩!`
@@ -98,7 +98,8 @@ const fields: Record<number, LetterFieldConfig> = {
6: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
widthClassName: 'w-[180px]',
placeholder: '结合所学专业',
readOnlyFallback: '勇于突破',
},
7: {
+9 -3
View File
@@ -8,6 +8,8 @@ export interface PuzzlePieceDisplayProps {
pieceId: number
/** 是否显示发光动画 */
showGlow?: boolean
/** 在容器内按真实比例等比贴边展示 */
fitContain?: boolean
/** 自定义类名 */
className?: string
}
@@ -19,14 +21,14 @@ export interface PuzzlePieceDisplayProps {
* - 可以被 ref 引用,用于飞行动画
*/
const PuzzlePieceDisplay = forwardRef<HTMLDivElement, PuzzlePieceDisplayProps>(
({ residence, pieceId, showGlow = true, className = '' }, ref) => {
({ residence, pieceId, showGlow = true, fitContain = false, className = '' }, ref) => {
// 碎片图片路径
const pieceImage = getStaticImage(`puzzle/${residence}/${pieceId}`)
return (
<div
ref={ref}
className={`relative inline-block ${className}`}
className={`relative ${fitContain ? 'h-full w-full overflow-visible' : 'inline-block'} ${className}`}
style={{
animation: showGlow ? 'puzzleGlow 1s ease-in-out infinite' : 'none',
}}
@@ -41,7 +43,11 @@ const PuzzlePieceDisplay = forwardRef<HTMLDivElement, PuzzlePieceDisplayProps>(
}
}
`}</style>
<img src={pieceImage} alt={`碎片 ${pieceId}`} className="w-full" />
<img
src={pieceImage}
alt={`碎片 ${pieceId}`}
className={fitContain ? 'absolute inset-0 block h-full w-full object-contain' : 'w-full'}
/>
</div>
)
}
+143 -67
View File
@@ -1,6 +1,7 @@
import { forwardRef, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { motion } from 'framer-motion'
import FlipActionButton from '@/components/button/FlipActionButton'
import PuzzlePieceDisplay from './PuzzlePieceDisplay'
interface PuzzlePiecePortalProps {
@@ -8,26 +9,34 @@ interface PuzzlePiecePortalProps {
pieceId: number
/** 是否隐藏(飞行时隐藏原始碎片) */
hidden?: boolean
awaitingCollect?: boolean
confirming?: boolean
puzzlePieceTitle?: string | null
onCollect?: () => void
}
/**
* 碎片 Portal 组件
* 将碎片渲染到 document.body,确保在最上层显示
* 支持旋转+放大的初始出现动画
*/
const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
({ residence, pieceId, hidden = false }, ref) => {
(
{
residence,
pieceId,
hidden = false,
awaitingCollect = false,
confirming = false,
puzzlePieceTitle,
onCollect,
},
ref
) => {
const [isAnimating, setIsAnimating] = useState(true)
// 当组件首次渲染时,标记动画开始
useEffect(() => {
setIsAnimating(true)
// 动画完成后,重置状态以便下次出现时重新播放
const timer = setTimeout(() => {
setIsAnimating(false)
}, 800) // 与动画时长一致
}, 800)
return () => clearTimeout(timer)
}, [pieceId]) // 当 pieceId 变化时重新播放动画
}, [pieceId])
return createPortal(
<div
@@ -35,69 +44,136 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
position: 'fixed',
left: '50%',
top: '50%',
width: '120px',
height: '120px',
transform: 'translate(-50%, -50%)',
perspective: '1000px', // 添加透视,让3D效果更明显
zIndex: 99999,
width: 'min(520px, 86vw)',
height: 'min(420px, 72vh)',
transform: 'translate(-50%, -50%) translateZ(0)',
perspective: '1000px',
zIndex: 100001,
pointerEvents: 'none',
willChange: 'transform, opacity',
}}
>
<motion.div
ref={ref}
{awaitingCollect && !hidden && !confirming && (
<motion.div
className="absolute left-1/2 top-0 -translate-x-1/2 -z-10"
style={{
width: 'min(440px, 76vw)',
height: '320px',
background:
'radial-gradient(ellipse 58% 46% at 50% 38%, rgba(255,214,133,0.16) 0%, rgba(255,185,94,0.07) 45%, transparent 76%)',
willChange: 'opacity, transform',
}}
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.42, ease: [0.22, 1, 0.36, 1] }}
/>
)}
<div
className="absolute left-1/2 top-0 -translate-x-1/2"
style={{
width: '100%',
height: '100%',
transformStyle: 'preserve-3d', // 保持3D变换
}}
initial={
isAnimating
? {
scale: 0,
rotateY: -180,
opacity: 0,
}
: false
}
animate={
hidden
? { opacity: 0, visibility: 'hidden' as const }
: isAnimating
? {
scale: 1,
rotateY: 0,
opacity: 1,
visibility: 'visible' as const,
}
: {
opacity: 1,
visibility: 'visible' as const,
}
}
transition={{
duration: 0.8, // 稍微延长动画时间,让效果更舒缓
ease: [0.25, 0.46, 0.45, 0.94], // 平滑的缓动函数,缓缓出现
scale: {
duration: 0.8,
ease: [0.25, 0.46, 0.45, 0.94],
},
rotateY: {
duration: 0.8,
ease: [0.25, 0.46, 0.45, 0.94],
},
opacity: {
duration: 0.6,
ease: 'easeOut',
},
width: 'clamp(240px, 24vw, 300px)',
height: 'clamp(190px, 20vw, 240px)',
}}
>
<PuzzlePieceDisplay
residence={residence}
pieceId={pieceId}
showGlow={true}
className="w-full h-full"
/>
</motion.div>
<motion.div
ref={ref}
style={{
width: '100%',
height: '100%',
transformStyle: 'preserve-3d',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
willChange: 'transform, opacity',
}}
initial={
isAnimating
? {
scale: 0,
rotateY: -180,
opacity: 0,
y: 0,
}
: false
}
animate={
hidden
? { opacity: 0, visibility: 'hidden' as const }
: confirming
? {
scale: 1.035,
rotateY: 0,
opacity: 1,
y: 0,
visibility: 'visible' as const,
}
: {
scale: 1,
rotateY: 0,
opacity: 1,
y: 0,
visibility: 'visible' as const,
}
}
transition={
confirming
? {
duration: 0.18,
ease: [0.22, 1, 0.36, 1],
}
: {
duration: 0.82,
ease: [0.2, 0.72, 0.18, 1],
scale: {
duration: 0.82,
ease: [0.2, 0.72, 0.18, 1],
},
rotateY: {
duration: 0.82,
ease: [0.25, 0.46, 0.45, 0.94],
},
opacity: {
duration: 0.55,
ease: 'easeOut',
},
}
}
>
<PuzzlePieceDisplay
residence={residence}
pieceId={pieceId}
showGlow={isAnimating || awaitingCollect}
fitContain={true}
className="w-full h-full"
/>
</motion.div>
</div>
{awaitingCollect && !hidden && (
<motion.div
className="absolute left-0 right-0 top-[clamp(218px,25vw,276px)] flex flex-col items-center gap-3 pointer-events-none"
initial={{ opacity: 0, y: 10 }}
animate={confirming ? { opacity: 0, y: 6 } : { opacity: 1, y: 0 }}
transition={{ duration: confirming ? 0.16 : 0.36, ease: [0.22, 1, 0.36, 1] }}
style={{ willChange: 'transform, opacity' }}
>
{puzzlePieceTitle && (
<div className="max-w-[480px] rounded-2xl border border-[#f6d58b]/45 bg-[#2a1608]/70 px-6 py-2.5 text-center text-2xl font-bold leading-snug text-[#fff4d0] shadow-[0_6px_20px_rgba(0,0,0,0.22)] [text-shadow:0_2px_0_#5a2a00,0_0_14px_rgba(255,224,150,0.75)]">
{puzzlePieceTitle.split('\n').map((line) => (
<div key={line}>{line}</div>
))}
</div>
)}
<FlipActionButton
className="pointer-events-auto border border-[#ffe6a6]/55 bg-linear-to-b from-[#bd8b50] to-[#84512b] px-4 py-1.5 text-base shadow-[0_3px_10px_rgba(0,0,0,0.2)] hover:brightness-105 active:scale-[0.97]"
disabled={confirming}
onClick={onCollect}
>
收下
</FlipActionButton>
</motion.div>
)}
</div>,
document.body
)
+1 -1
View File
@@ -26,7 +26,7 @@ const Title = ({ title, subtitle, className, subtitleClassName }: TitleProps) =>
>
<div
className={cn(
'absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold whitespace-nowrap',
'absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold whitespace-pre',
'[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]'
)}
>
+95 -3
View File
@@ -7,6 +7,11 @@ import React, {
useEffect,
} from 'react'
import { cn } from '@/lib/utils'
import { showSyncErrorPrompt } from '@/components/guide/SyncErrorPrompt'
import {
isVideoCompletedByResidence,
markVideoCompletedByResidence,
} from '@/lib/utils/videoCompletion'
import type { VideoProps, VideoRef, VideoAnchor, AnchorContext } from './video.types'
// Re-export types for convenience
@@ -51,6 +56,11 @@ const Video = forwardRef<VideoRef, VideoProps>(
skipText = '跳过',
onSkip,
skipButtonClassName,
skipKey,
initiallyCompleted,
onVideoCompleted,
controlsOnReplay,
showReadonlyProgress,
controls = false, // 默认不使用原生控制条
...props
},
@@ -59,6 +69,11 @@ const Video = forwardRef<VideoRef, VideoProps>(
const videoRef = useRef<HTMLVideoElement>(null)
const controlsRef = useRef<HTMLDivElement>(null)
const progressRef = useRef<HTMLDivElement>(null)
const readonlyProgressRef = useRef<HTMLDivElement>(null)
const readonlyProgressRafRef = useRef<number | null>(null)
const routePath = window.location.hash.replace(/^#/, '') || window.location.pathname
const residence = routePath.split('/').filter(Boolean)[1] ?? ''
const storeCompleted = skipKey ? isVideoCompletedByResidence(residence, skipKey) : false
const [showControls, setShowControls] = useState(true)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
@@ -83,6 +98,12 @@ const Video = forwardRef<VideoRef, VideoProps>(
const [isVideoReady, setIsVideoReady] = useState(false)
// 跟踪是否应该停在锚点处(用于 seek 时强制暂停)
const shouldStopAtAnchorRef = useRef<{ time: number; anchorId: string } | null>(null)
// 视频是否已完整播放完成(用于 skipKey / controlsOnReplay 逻辑)
const [isVideoCompleted, setIsVideoCompleted] = useState(initiallyCompleted ?? storeCompleted)
useEffect(() => {
setIsVideoCompleted(initiallyCompleted ?? storeCompleted)
}, [initiallyCompleted, storeCompleted, skipKey])
useImperativeHandle(ref, () => ({
video: videoRef.current,
@@ -106,6 +127,16 @@ const Video = forwardRef<VideoRef, VideoProps>(
})
}, [])
// 计算只读进度百分比(rAF 驱动,直接操作 DOM 避免 React 重渲染卡顿)
const syncReadonlyProgress = useCallback(() => {
const video = videoRef.current
const progressEl = readonlyProgressRef.current
if (!video || !progressEl) return
const nextDuration = video.duration || duration
const percent = nextDuration > 0 ? Math.min(video.currentTime / nextDuration, 1) : 0
progressEl.style.transform = `scaleX(${percent})`
}, [duration])
/** 锚点是否可被触发(用于 timeUpdate / seek 触发) */
const isAnchorTriggerable = useCallback(
(anchor: VideoAnchor): boolean => {
@@ -387,6 +418,11 @@ const Video = forwardRef<VideoRef, VideoProps>(
const handleEnded: React.VideoHTMLAttributes<HTMLVideoElement>['onEnded'] = (e) => {
setIsPlaying(false)
if (skipKey) {
setIsVideoCompleted(true)
markVideoCompletedByResidence(residence, skipKey)
onVideoCompleted?.()
}
props.onEnded?.(e)
}
@@ -400,6 +436,16 @@ const Video = forwardRef<VideoRef, VideoProps>(
props.onPause?.(e)
}
const handleError: React.VideoHTMLAttributes<HTMLVideoElement>['onError'] = (e) => {
showSyncErrorPrompt({ message: '视频加载失败,请检查网络后刷新重试。' })
setIsPlaying(false)
if (props.onError) {
props.onError(e)
return
}
props.onEnded?.({} as any)
}
const handleSkipClick = (): void => {
if (!videoRef.current) return
@@ -529,6 +575,40 @@ const Video = forwardRef<VideoRef, VideoProps>(
// 计算进度百分比
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0
const disableFirstWatchVideoGating =
import.meta.env.VITE_DISABLE_FIRST_WATCH_VIDEO_GATING === 'true'
const canShowSkipButton = !skipKey || disableFirstWatchVideoGating || isVideoCompleted
const canShowControls = !controlsOnReplay || disableFirstWatchVideoGating || isVideoCompleted
const shouldShowReadonlyProgress =
!!showReadonlyProgress && isVideoReady && duration > 0 && !(controls && canShowControls)
useEffect(() => {
if (!shouldShowReadonlyProgress) {
if (readonlyProgressRafRef.current) {
cancelAnimationFrame(readonlyProgressRafRef.current)
readonlyProgressRafRef.current = null
}
return
}
syncReadonlyProgress()
const update = () => {
syncReadonlyProgress()
readonlyProgressRafRef.current = requestAnimationFrame(update)
}
if (isPlaying) {
readonlyProgressRafRef.current = requestAnimationFrame(update)
}
return () => {
if (readonlyProgressRafRef.current) {
cancelAnimationFrame(readonlyProgressRafRef.current)
readonlyProgressRafRef.current = null
}
}
}, [isPlaying, shouldShowReadonlyProgress, syncReadonlyProgress])
return (
<div
@@ -544,16 +624,28 @@ const Video = forwardRef<VideoRef, VideoProps>(
ref={videoRef}
className="w-full h-full object-cover"
controls={false}
{...props}
onTimeUpdate={handleTimeUpdate}
onSeeking={handleSeeking}
onSeeked={handleSeeked}
onEnded={handleEnded}
onPlay={handlePlay}
onPause={handlePause}
{...props}
onError={handleError}
/>
{shouldShowReadonlyProgress && (
<div className="absolute inset-x-0 bottom-0 z-10 pointer-events-none">
<div className="h-1 bg-black/25">
<div
ref={readonlyProgressRef}
className="h-full origin-left bg-white/90"
style={{ transform: `scaleX(${progressPercent / 100})` }}
/>
</div>
</div>
)}
{/* 跳过按钮 */}
{showSkipButton && isPlaying && (
{showSkipButton && isPlaying && canShowSkipButton && (
<button
type="button"
onClick={handleSkipClick}
@@ -568,7 +660,7 @@ const Video = forwardRef<VideoRef, VideoProps>(
</button>
)}
{/* 自定义控制条 */}
{isVideoReady && controls && (
{isVideoReady && controls && canShowControls && (
<div
ref={controlsRef}
className={cn(
+10
View File
@@ -40,6 +40,16 @@ export interface VideoProps extends React.VideoHTMLAttributes<HTMLVideoElement>
onSkip?: () => void
/** 自定义跳过按钮样式 */
skipButtonClassName?: string
/** 跳过按钮的唯一标识;传入时,首次播放完成前不显示跳过按钮 */
skipKey?: string
/** 从 store 读取的初始完成状态,用于初始化 isVideoCompleted */
initiallyCompleted?: boolean
/** 视频首次完整播放完成通知(仅在 skipKey 传入时触发) */
onVideoCompleted?: () => void
/** 二次学习时才显示 controls 进度条(首次学习不显示进度条) */
controlsOnReplay?: boolean
/** 是否显示只读简化进度条(不可拖拽、不可点击,仅展示播放进度) */
showReadonlyProgress?: boolean
}
/** Video 组件 Ref 暴露的方法 */