Files
shanghai-residences/src/components/letter/LetterTemplate.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

788 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { getPublicPath, getStaticImage } from '@/lib/utils'
import { filterInput, validateInput, containsSensitiveWord } 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 [isComposing, setIsComposing] = 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 signatureValidation = validateInput(trimmedSignatureName)
if (!signatureValidation.valid) {
showHint(`落款姓名${signatureValidation.message}`)
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.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)
}
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 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)
// 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 = 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()
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)}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={(e) => {
setIsComposing(false)
// 输入法完成后立即过滤
handleSignatureNameChange(filterInput(e.currentTarget.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}
<style>{`.blank-input::placeholder { color: #999999; font-size: 26px; }`}</style>
<input
name={`blank-${currentIndex}`}
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>
)
})}
</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