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

+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
)