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