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 | ((values: string[]) => Record) 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([]) const [blankErrors, setBlankErrors] = useState([]) 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(null) const replyVideoRef = useRef(null) const audioRef = useRef(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 (
{signatureName.trim() || '  '} {currentDate}
) } return (
handleSignatureNameChange(e.target.value)} /> {currentDate}
) } 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 (
{segments.map((part, partIndex) => { const isLast = partIndex === segments.length - 1 if (isLast) { return ( {partIndex === 0 ? `${linePrefix}${part}` : part} ) } 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 ( {partIndex === 0 ? `${linePrefix}${part}` : part} {displayValue || field.readOnlyFallback || '  '} ) } if (field.type === 'select') { return ( {partIndex === 0 ? `${linePrefix}${part}` : part} updateBlank(currentIndex, e.target.value)} onBlur={() => handleInputBlur(currentIndex)} /> ) })}
) }) } return ( {visible && ( <> {audioSrc && ( ) } export default LetterTemplate