Files
shanghai-residences/src/components/letter/LetterTemplate.tsx
T
zhongyidianandClaude Opus 4.7 49ecabbf3a fix: 修复开发/测试包模拟进度被易班同步覆盖的问题
进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖;
现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度,
确保测试包的"到达某故居/全流程完成"调试入口生效。

同时纳入近期未提交的页面、组件、资源与环境变量改动。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:19:36 +08:00

717 lines
24 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 } 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