进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖; 现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度, 确保测试包的"到达某故居/全流程完成"调试入口生效。 同时纳入近期未提交的页面、组件、资源与环境变量改动。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
717 lines
24 KiB
TypeScript
717 lines
24 KiB
TypeScript
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 { Image, Select, Video, type VideoRef } from '@/components'
|
||
import { showHint } from '@/components/hint'
|
||
|
||
const formatCurrentDate = () => {
|
||
const now = new Date()
|
||
const year = now.getFullYear()
|
||
const month = now.getMonth() + 1
|
||
const day = now.getDate()
|
||
return `${year}年${month}月${day}日`
|
||
}
|
||
|
||
export type LetterFieldType = 'text' | 'input' | 'select'
|
||
|
||
export interface LetterFieldOption {
|
||
value: string
|
||
label: string
|
||
disabled?: boolean
|
||
}
|
||
|
||
export interface LetterFieldHintConfig {
|
||
error?: string
|
||
}
|
||
|
||
export interface LetterFieldConfig {
|
||
type?: LetterFieldType
|
||
maxLength?: number
|
||
placeholder?: string
|
||
options?: LetterFieldOption[]
|
||
fitContent?: boolean
|
||
widthClassName?: string
|
||
className?: string
|
||
readOnlyFallback?: string
|
||
sanitize?: boolean
|
||
correctValue?: string
|
||
hints?: LetterFieldHintConfig
|
||
fontSize?: number
|
||
}
|
||
|
||
export interface LetterTemplateProps {
|
||
visible?: boolean
|
||
onClose?: () => void
|
||
onSubmit?: (values: string[]) => void
|
||
onAnimationEnd?: () => void
|
||
title: string
|
||
letterContent: string
|
||
fields?:
|
||
| Record<number, LetterFieldConfig>
|
||
| ((values: string[]) => Record<number, LetterFieldConfig>)
|
||
validate?: (values: string[], letterContent: string) => boolean[]
|
||
contentClassName?: string
|
||
audioSrc?: string
|
||
showVideo?: boolean
|
||
videoSrc?: string
|
||
replyVideoSrc?: string
|
||
sceneImageSrc?: string
|
||
replyButtonImageSrc?: string
|
||
replyImageSrc?: string
|
||
}
|
||
|
||
const DEFAULT_TEXT_FIELD: LetterFieldConfig = {
|
||
type: 'text',
|
||
maxLength: 10,
|
||
widthClassName: 'w-[100px]',
|
||
sanitize: true,
|
||
}
|
||
|
||
const getPlaceholderCount = (letterContent: string) => (letterContent.match(/____/g) || []).length
|
||
|
||
const LetterTemplate = ({
|
||
visible = false,
|
||
onClose,
|
||
onSubmit,
|
||
onAnimationEnd,
|
||
title,
|
||
letterContent,
|
||
fields = {},
|
||
validate,
|
||
contentClassName,
|
||
audioSrc,
|
||
showVideo = true,
|
||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||
replyVideoSrc,
|
||
sceneImageSrc = 'components/letter/动画结束后场景',
|
||
replyButtonImageSrc = 'components/letter/点击敬读',
|
||
replyImageSrc,
|
||
}: LetterTemplateProps) => {
|
||
const [blankValues, setBlankValues] = useState<string[]>([])
|
||
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
|
||
const [signatureName, setSignatureName] = useState('')
|
||
const [signatureError, setSignatureError] = useState(false)
|
||
const [submitted, setSubmitted] = useState(false)
|
||
const [letterSent, setLetterSent] = useState(false)
|
||
const [showSendVideo, setShowSendVideo] = useState(false)
|
||
const [showReplyVideo, setShowReplyVideo] = useState(false)
|
||
const [showScene, setShowScene] = useState(false)
|
||
const [showReplyImage, setShowReplyImage] = useState(false)
|
||
const sendVideoRef = useRef<VideoRef>(null)
|
||
const replyVideoRef = useRef<VideoRef>(null)
|
||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||
|
||
const currentDate = useMemo(() => formatCurrentDate(), [])
|
||
|
||
const resolvedFields = useMemo(
|
||
() => (typeof fields === 'function' ? fields(blankValues) : fields),
|
||
[fields, blankValues]
|
||
)
|
||
|
||
const validateBlanks = () => {
|
||
const placeholderCount = getPlaceholderCount(letterContent)
|
||
const nextValues = Array.from(
|
||
{ length: placeholderCount },
|
||
(_, index) => blankValues[index] ?? ''
|
||
)
|
||
|
||
const trimmedSignatureName = signatureName.trim()
|
||
if (!trimmedSignatureName) {
|
||
showHint('请填写落款姓名后再提交哦!')
|
||
setSignatureError(true)
|
||
return false
|
||
}
|
||
|
||
const nextErrors = validate
|
||
? validate(nextValues, letterContent)
|
||
: nextValues.map((value, index) => {
|
||
const field = resolvedFields[index] ?? DEFAULT_TEXT_FIELD
|
||
const trimmedValue = value.trim()
|
||
const maxLength = field.maxLength ?? 10
|
||
|
||
if (!trimmedValue || trimmedValue.length > maxLength) {
|
||
if (!trimmedValue) {
|
||
showHint('请填写完整后再提交哦!')
|
||
} else {
|
||
showHint(`内容过长,请不要超过${maxLength}个字哦!`)
|
||
}
|
||
return true
|
||
}
|
||
|
||
if (field.correctValue && trimmedValue !== field.correctValue) {
|
||
if (field.hints?.error) {
|
||
showHint(field.hints.error)
|
||
}
|
||
return true
|
||
}
|
||
|
||
return false
|
||
})
|
||
|
||
const hasError = nextErrors.some(Boolean)
|
||
if (hasError) {
|
||
// 清空所有错误位置的输入
|
||
const cleanedValues = [...blankValues]
|
||
nextErrors.forEach((isError, index) => {
|
||
if (isError) {
|
||
cleanedValues[index] = ''
|
||
}
|
||
})
|
||
setBlankValues(cleanedValues)
|
||
}
|
||
|
||
setBlankErrors(nextErrors)
|
||
return !hasError
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!visible) {
|
||
setSubmitted(false)
|
||
setLetterSent(false)
|
||
setBlankValues([])
|
||
setBlankErrors([])
|
||
setSignatureName('')
|
||
setSignatureError(false)
|
||
setShowSendVideo(false)
|
||
setShowReplyVideo(false)
|
||
setShowScene(false)
|
||
setShowReplyImage(false)
|
||
}
|
||
}, [visible])
|
||
|
||
// 音频播放逻辑
|
||
useEffect(() => {
|
||
const audio = audioRef.current
|
||
if (!audio || !audioSrc) return
|
||
|
||
if (visible && !showSendVideo && !showScene && !showReplyImage && !showReplyVideo) {
|
||
audio.currentTime = 0
|
||
audio.play().catch(() => {})
|
||
} else {
|
||
audio.pause()
|
||
audio.currentTime = 0
|
||
}
|
||
}, [visible, showSendVideo, showScene, showReplyImage, showReplyVideo, audioSrc])
|
||
|
||
useEffect(() => {
|
||
const audio = audioRef.current
|
||
return () => {
|
||
if (audio) {
|
||
audio.pause()
|
||
audio.currentTime = 0
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
const updateBlank = (index: number, value: string) => {
|
||
const field = resolvedFields[index] ?? DEFAULT_TEXT_FIELD
|
||
const nextValue = field.sanitize === false ? value : filterInput(value)
|
||
const nextValues = [...blankValues]
|
||
nextValues[index] = nextValue
|
||
setBlankValues(nextValues)
|
||
|
||
// correctValue 字段:输入过程中不标红,由 blur / select 触发校验;
|
||
// 若当前已标红且值匹配正确答案,则清除标红
|
||
if (field.correctValue) {
|
||
if (nextValue.trim() === field.correctValue && blankErrors[index]) {
|
||
const nextErrors = [...blankErrors]
|
||
nextErrors[index] = false
|
||
setBlankErrors(nextErrors)
|
||
}
|
||
return
|
||
}
|
||
|
||
// 无 correctValue 字段:实时校验空 / 超长
|
||
const trimmedValue = nextValue.trim()
|
||
const maxLength = field.maxLength ?? 10
|
||
const isError = !trimmedValue || trimmedValue.length > maxLength
|
||
if (blankErrors[index] !== isError) {
|
||
const nextErrors = [...blankErrors]
|
||
nextErrors[index] = isError
|
||
setBlankErrors(nextErrors)
|
||
}
|
||
}
|
||
|
||
const handleSelectChange = (index: number, value: string) => {
|
||
updateBlank(index, value)
|
||
|
||
const field = resolvedFields[index]
|
||
if (!field?.correctValue) return
|
||
|
||
if (value !== field.correctValue) {
|
||
if (field.hints?.error) {
|
||
showHint(field.hints.error)
|
||
}
|
||
|
||
const nextValues = [...blankValues]
|
||
nextValues[index] = ''
|
||
setBlankValues(nextValues)
|
||
|
||
const nextErrors = [...blankErrors]
|
||
nextErrors[index] = true
|
||
setBlankErrors(nextErrors)
|
||
}
|
||
}
|
||
|
||
const handleInputBlur = (index: number) => {
|
||
const field = resolvedFields[index]
|
||
if (!field?.correctValue) return
|
||
|
||
const value = (blankValues[index] ?? '').trim()
|
||
if (value && value !== field.correctValue) {
|
||
if (field.hints?.error) {
|
||
showHint(field.hints.error)
|
||
}
|
||
|
||
const nextValues = [...blankValues]
|
||
nextValues[index] = ''
|
||
setBlankValues(nextValues)
|
||
|
||
const nextErrors = [...blankErrors]
|
||
nextErrors[index] = true
|
||
setBlankErrors(nextErrors)
|
||
}
|
||
}
|
||
|
||
const handleSendVideoCanPlay = () => {
|
||
if (showSendVideo && sendVideoRef.current) {
|
||
sendVideoRef.current.play().catch(() => {})
|
||
}
|
||
}
|
||
|
||
const handleReplyVideoCanPlay = () => {
|
||
if (showReplyVideo && replyVideoRef.current) {
|
||
replyVideoRef.current.play().catch(() => {})
|
||
}
|
||
}
|
||
|
||
const handleSignatureNameChange = (value: string) => {
|
||
const nextValue = filterInput(value)
|
||
setSignatureName(nextValue)
|
||
|
||
const hasError = !nextValue.trim()
|
||
if (signatureError !== hasError) {
|
||
setSignatureError(hasError)
|
||
}
|
||
}
|
||
|
||
const renderSignature = (readonly: boolean) => {
|
||
if (readonly) {
|
||
return (
|
||
<div className="absolute right-[240px] bottom-[110px] z-99 flex flex-col items-end text-[26px] leading-[1.8] text-[#4e402e]">
|
||
<span>{signatureName.trim() || ' '}</span>
|
||
<span>{currentDate}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="absolute right-[190px] bottom-[120px] z-100 flex flex-col items-end gap-2 text-[#4e402e]">
|
||
<style>{`.signature-name-input::placeholder { color: #999999; font-size: 26px; }`}</style>
|
||
<input
|
||
name="signature-name"
|
||
className={`signature-name-input h-[36px] bg-transparent border-b-2 border-dashed text-center outline-none w-[240px] ${signatureError ? 'border-red-500' : 'border-[#4e402e]'}`}
|
||
style={{ fontSize: '26px' }}
|
||
autoComplete="off"
|
||
placeholder="请郑重签下你的姓名"
|
||
value={signatureName}
|
||
maxLength={12}
|
||
onChange={(e) => handleSignatureNameChange(e.target.value)}
|
||
/>
|
||
<span className="text-[26px] leading-[1.8]">{currentDate}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const renderLine = (readonly: boolean) => {
|
||
let placeholderIndex = 0
|
||
let bodyStarted = false
|
||
|
||
return letterContent.split('\n').map((line, lineIndex) => {
|
||
const trimmedLine = line.trim()
|
||
const isSalutationLine = trimmedLine.endsWith(':') || trimmedLine.endsWith(':')
|
||
|
||
if (isSalutationLine) {
|
||
bodyStarted = true
|
||
}
|
||
|
||
const needsIndent = bodyStarted && trimmedLine !== '' && !isSalutationLine
|
||
const segments = line.split('____')
|
||
const linePrefix = needsIndent ? ' ' : ''
|
||
|
||
return (
|
||
<div key={lineIndex} className="">
|
||
{segments.map((part, partIndex) => {
|
||
const isLast = partIndex === segments.length - 1
|
||
if (isLast) {
|
||
return (
|
||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||
{partIndex === 0 ? `${linePrefix}${part}` : part}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
const currentIndex = placeholderIndex++
|
||
const field = resolvedFields[currentIndex] ?? DEFAULT_TEXT_FIELD
|
||
const fieldValue = blankValues[currentIndex] ?? ''
|
||
|
||
if (readonly) {
|
||
const displayValue =
|
||
field.type === 'select'
|
||
? field.options?.find((option) => option.value === fieldValue)?.label ||
|
||
fieldValue
|
||
: fieldValue
|
||
|
||
return (
|
||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||
{partIndex === 0 ? `${linePrefix}${part}` : part}
|
||
{displayValue || field.readOnlyFallback || ' '}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
if (field.type === 'select') {
|
||
return (
|
||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||
{partIndex === 0 ? `${linePrefix}${part}` : part}
|
||
<Select
|
||
className={field.className || 'inline'}
|
||
options={field.options ?? []}
|
||
value={fieldValue}
|
||
onChange={(value) => handleSelectChange(currentIndex, value)}
|
||
placeholder={field.placeholder || '请选择'}
|
||
error={blankErrors[currentIndex]}
|
||
zIndex={10000}
|
||
fitContent={field.fitContent ?? true}
|
||
fontSize={field.fontSize}
|
||
/>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||
{partIndex === 0 ? `${linePrefix}${part}` : part}
|
||
<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]'}`}
|
||
style={{ fontSize: field.fontSize ? `${field.fontSize}px` : '26px' }}
|
||
autoComplete="off"
|
||
value={fieldValue}
|
||
maxLength={field.maxLength ?? 10}
|
||
onChange={(e) => updateBlank(currentIndex, e.target.value)}
|
||
onBlur={() => handleInputBlur(currentIndex)}
|
||
/>
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
})
|
||
}
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
{visible && (
|
||
<>
|
||
{audioSrc && (
|
||
<audio ref={audioRef} src={getPublicPath(audioSrc)} loop className="hidden" />
|
||
)}
|
||
|
||
{!showSendVideo && !letterSent && (
|
||
<motion.div
|
||
className="fixed inset-0 bg-black/50 z-100"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.3 }}
|
||
// onClick={onClose}
|
||
/>
|
||
)}
|
||
|
||
{!showSendVideo && !letterSent && (
|
||
<motion.div
|
||
className="fixed inset-0 z-101 flex items-center justify-center pointer-events-none"
|
||
initial={{ opacity: 0, scale: 0.5, y: 100 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
exit={{ opacity: 0, scale: 0.5, y: 100 }}
|
||
transition={{
|
||
type: 'spring',
|
||
stiffness: 300,
|
||
damping: 30,
|
||
duration: 0.6,
|
||
}}
|
||
>
|
||
<div
|
||
className="relative w-[1200px] h-full pointer-events-auto"
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{
|
||
perspective: '1200px',
|
||
WebkitPerspective: '1200px',
|
||
}}
|
||
>
|
||
<motion.div
|
||
className="relative w-full h-full"
|
||
initial={false}
|
||
animate={{ rotateY: submitted ? 180 : 0 }}
|
||
transition={{ duration: 0.7, ease: 'easeInOut' }}
|
||
style={{
|
||
transformStyle: 'preserve-3d',
|
||
WebkitTransformStyle: 'preserve-3d',
|
||
}}
|
||
>
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
backfaceVisibility: 'hidden',
|
||
WebkitBackfaceVisibility: 'hidden',
|
||
transform: 'translateZ(0)',
|
||
WebkitTransform: 'translateZ(0)',
|
||
pointerEvents: submitted ? 'none' : 'auto',
|
||
}}
|
||
>
|
||
<img
|
||
src={getStaticImage('components/letter/信底')}
|
||
alt="信纸"
|
||
className="absolute inset-0 w-full h-full object-contain"
|
||
/>
|
||
|
||
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:0px_0px_4px_#b37921,6px_6px_4px_#b37921,6px_6px_4px_#b37921,6px_6px_4px_#b37921]">
|
||
<div
|
||
className="text-white text-6xl font-bold tracking-[20px]"
|
||
style={{
|
||
writingMode: 'vertical-rl',
|
||
textOrientation: 'upright',
|
||
}}
|
||
>
|
||
{title}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||
<div
|
||
className={`text-[#4e402e] text-[26px] leading-relaxed ${contentClassName || ''}`}
|
||
>
|
||
{renderLine(false)}
|
||
</div>
|
||
</div>
|
||
|
||
{renderSignature(false)}
|
||
|
||
<div className="absolute bottom-[150px] -right-30">
|
||
<img
|
||
src={getStaticImage('components/letter/笔')}
|
||
alt="笔"
|
||
className="w-full h-full object-contain"
|
||
/>
|
||
</div>
|
||
|
||
<div className="absolute bottom-[80px] left-62 z-99">
|
||
<Image
|
||
src={getStaticImage('components/letter/可提交状态')}
|
||
alt="可提交状态"
|
||
onClick={() => {
|
||
if (!validateBlanks()) return
|
||
setSubmitted(true)
|
||
}}
|
||
button
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
transform: 'rotateY(180deg) translateZ(0)',
|
||
WebkitTransform: 'rotateY(180deg) translateZ(0)',
|
||
backfaceVisibility: 'hidden',
|
||
WebkitBackfaceVisibility: 'hidden',
|
||
pointerEvents: submitted ? 'auto' : 'none',
|
||
}}
|
||
>
|
||
<img
|
||
src={getStaticImage('components/letter/信')}
|
||
alt="信件"
|
||
className="absolute inset-0 w-full h-full object-contain"
|
||
/>
|
||
<Image
|
||
src={getStaticImage('components/close')}
|
||
alt="关闭"
|
||
className="absolute right-38 top-0"
|
||
button
|
||
onClick={() => {
|
||
setSubmitted(false)
|
||
}}
|
||
/>
|
||
|
||
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||
<div
|
||
className={`text-[#4e402e] text-[26px] leading-relaxed ${contentClassName || ''}`}
|
||
>
|
||
{renderLine(true)}
|
||
</div>
|
||
</div>
|
||
|
||
{renderSignature(true)}
|
||
|
||
<div className="absolute bottom-[80px] left-50 z-99">
|
||
<Image
|
||
src={getStaticImage('components/letter/寄出')}
|
||
alt="寄出"
|
||
onClick={() => {
|
||
const nextValues = [...blankValues]
|
||
onSubmit?.(nextValues)
|
||
setLetterSent(true)
|
||
setShowSendVideo(true)
|
||
}}
|
||
button
|
||
/>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
<AnimatePresence>
|
||
{showVideo && showSendVideo && (
|
||
<motion.div
|
||
key="video"
|
||
initial={{ opacity: 1 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.5 }}
|
||
className="fixed inset-0 z-101 h-full w-full"
|
||
>
|
||
<Video
|
||
ref={sendVideoRef}
|
||
src={getPublicPath(videoSrc)}
|
||
className="h-full w-full"
|
||
playsInline
|
||
onCanPlay={handleSendVideoCanPlay}
|
||
onEnded={() => {
|
||
setShowSendVideo(false)
|
||
setShowScene(true)
|
||
}}
|
||
/>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{(showScene || showSendVideo) && replyImageSrc && (
|
||
<motion.div
|
||
className="fixed inset-0 z-102"
|
||
initial={false}
|
||
animate={
|
||
showScene && !showSendVideo && !showReplyImage
|
||
? { opacity: 1, y: 0 }
|
||
: { opacity: 0, y: 50 }
|
||
}
|
||
transition={{ duration: 0.5 }}
|
||
style={{
|
||
pointerEvents: showScene && !showSendVideo && !showReplyImage ? 'auto' : 'none',
|
||
}}
|
||
>
|
||
<img
|
||
src={getStaticImage(sceneImageSrc)}
|
||
alt="场景背景"
|
||
className="absolute inset-0 w-full h-full object-cover"
|
||
/>
|
||
|
||
<Image
|
||
className="absolute bottom-170 left-222"
|
||
src={getStaticImage('components/letter/回信点击')}
|
||
alt="回信点击"
|
||
onClick={() => setShowReplyImage(true)}
|
||
button
|
||
/>
|
||
|
||
<motion.div
|
||
className="absolute bottom-130 left-222"
|
||
animate={{
|
||
y: [-10, 10, -10],
|
||
scale: [0.95, 1.05, 0.95],
|
||
}}
|
||
transition={{
|
||
duration: 1.5,
|
||
repeat: Infinity,
|
||
repeatType: 'loop',
|
||
ease: 'easeInOut',
|
||
}}
|
||
>
|
||
<Image src={getStaticImage(replyButtonImageSrc)} alt="提示点击手指" />
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{(showReplyImage || showScene) && replyImageSrc && (
|
||
<motion.div
|
||
className="fixed inset-0 z-103 flex items-center justify-center bg-black/80"
|
||
initial={false}
|
||
animate={showReplyImage ? { opacity: 1 } : { opacity: 0 }}
|
||
transition={{ duration: 0.3 }}
|
||
style={{ pointerEvents: showReplyImage ? 'auto' : 'none' }}
|
||
>
|
||
<motion.img
|
||
src={getStaticImage(replyImageSrc)}
|
||
alt="回信"
|
||
className="w-full h-full object-contain"
|
||
initial={false}
|
||
animate={showReplyImage ? { scale: 1, opacity: 1 } : { scale: 0.8, opacity: 0 }}
|
||
transition={{ duration: 0.3 }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
|
||
<Image
|
||
src={getStaticImage('components/close')}
|
||
alt="关闭"
|
||
className="absolute top-0 right-0"
|
||
button
|
||
onClick={() => {
|
||
setShowReplyImage(false)
|
||
setShowScene(false)
|
||
if (replyVideoSrc) {
|
||
setShowReplyVideo(true)
|
||
return
|
||
}
|
||
onClose?.()
|
||
onAnimationEnd?.()
|
||
}}
|
||
/>
|
||
</motion.div>
|
||
)}
|
||
|
||
<AnimatePresence>
|
||
{showReplyVideo && replyVideoSrc && (
|
||
<motion.div
|
||
key="reply-video"
|
||
initial={{ opacity: 1 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.5 }}
|
||
className="fixed inset-0 z-104 h-full w-full bg-black"
|
||
>
|
||
<Video
|
||
ref={replyVideoRef}
|
||
src={getPublicPath(replyVideoSrc)}
|
||
className="h-full w-full"
|
||
playsInline
|
||
onCanPlay={handleReplyVideoCanPlay}
|
||
onEnded={() => {
|
||
setShowReplyVideo(false)
|
||
onClose?.()
|
||
onAnimationEnd?.()
|
||
}}
|
||
/>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</>
|
||
)}
|
||
</AnimatePresence>
|
||
)
|
||
}
|
||
|
||
export default LetterTemplate
|