- 更新展品图片(鲁迅/毛泽东/孙中山/黄炎培/聂耳/张乐平故居) - 新增点击查看图标、报纸素材 - 优化视频组件、拼图组件、展品组件 - 更新陈云音频资源 - 新增输入验证文档和工具函数 - 组件布局和动画优化 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
228 lines
6.4 KiB
TypeScript
228 lines
6.4 KiB
TypeScript
import { cn, getStaticImage } from '@/lib/utils'
|
|
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
|
import { FlipActionButton, Image } from '@/components'
|
|
import { motion } from 'framer-motion'
|
|
|
|
const OVERLAY_STYLE: React.CSSProperties = {
|
|
position: 'fixed',
|
|
inset: 0,
|
|
zIndex: 9999,
|
|
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
|
|
/** 展品翻面(背面)图片地址,可以是静态资源路径或完整 URL */
|
|
flipSrc?: string
|
|
/**
|
|
* 展品默认展示缩放(用于“默认放大”)
|
|
* - 1 表示原尺寸逻辑
|
|
* - > 1 会放大展品展示(仍受 max-w/max-h 约束)
|
|
*/
|
|
displayScale?: number
|
|
/** 展品入场动画播放完成(背景/按钮出现前后) */
|
|
onEnterComplete?: () => void
|
|
/** 关闭回调 */
|
|
onClose?: () => void
|
|
/** 是否可见,默认 true */
|
|
visible?: boolean
|
|
/** 额外的自定义类 */
|
|
className?: string
|
|
/** 底部操作区 */
|
|
footer?: ReactNode
|
|
/** 底部操作区额外类名 */
|
|
footerClassName?: string
|
|
}
|
|
|
|
/**
|
|
* 轻量展品翻转层
|
|
* - 不使用 Portal
|
|
* - 不支持缩放/拖拽
|
|
* - 点击展品区域翻转显示另一张图(带 3D 翻转动画)
|
|
*/
|
|
const ExhibitFlipOverlay = ({
|
|
src,
|
|
flipSrc,
|
|
displayScale = 1,
|
|
onEnterComplete,
|
|
onClose,
|
|
visible = true,
|
|
className,
|
|
footer,
|
|
footerClassName,
|
|
}: ExhibitFlipOverlayProps) => {
|
|
const backSrc = getStaticImage('components/close')
|
|
|
|
const [mounted, setMounted] = useState(false)
|
|
const [flipped, setFlipped] = useState(false)
|
|
const [showChrome, setShowChrome] = useState(false)
|
|
|
|
useEffect(() => {
|
|
setMounted(true)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
setFlipped(false)
|
|
setShowChrome(false)
|
|
}
|
|
}, [visible, src, flipSrc])
|
|
|
|
const frontImageSrc = resolveImageSrc(src)
|
|
|
|
const backImageSrc = useMemo(() => {
|
|
if (!flipSrc) return null
|
|
return resolveImageSrc(flipSrc)
|
|
}, [flipSrc])
|
|
|
|
const canFlip = Boolean(backImageSrc && backImageSrc !== frontImageSrc)
|
|
|
|
if (!mounted || !visible) return null
|
|
|
|
return (
|
|
<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 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"
|
|
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={{
|
|
transformStyle: 'preserve-3d',
|
|
WebkitTransformStyle: 'preserve-3d',
|
|
}}
|
|
>
|
|
<div
|
|
className="absolute inset-0 flex items-center justify-center"
|
|
style={{
|
|
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={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>
|
|
|
|
{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>
|
|
)
|
|
}
|
|
|
|
export default ExhibitFlipOverlay
|