Files
shanghai-residences/src/components/exhibit/ExhibitFlipOverlay.tsx
T
zhongyidianandClaude Opus 4.8 73f6df92ce chore: 更新素材和组件优化
- 更新展品图片(鲁迅/毛泽东/孙中山/黄炎培/聂耳/张乐平故居)
- 新增点击查看图标、报纸素材
- 优化视频组件、拼图组件、展品组件
- 更新陈云音频资源
- 新增输入验证文档和工具函数
- 组件布局和动画优化

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:50:40 +08:00

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