更新依赖项及历史人物相关内容资源
- 更新 package.json 和 package-lock.json 中的依赖项 - 新增/更新陈云、黄炎培、鲁迅、孙中山的音频文件 - 新增场景和组件的图片资源 - 清理未使用的音频和图片文件 - 优化多个组件和页面的实现逻辑
This commit is contained in:
1 parent
b1d55b9464
commit
e0fa47d21a
629 files changed
+24390
-2991
No files matched your search
@@ -0,0 +1,592 @@
|
||||
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'
|
||||
|
||||
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
|
||||
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',
|
||||
sceneImageSrc = 'components/letter/动画结束后场景',
|
||||
replyButtonImageSrc = 'components/letter/点击敬读',
|
||||
replyImageSrc,
|
||||
}: LetterTemplateProps) => {
|
||||
const [blankValues, setBlankValues] = useState<string[]>([])
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [showSendVideo, setShowSendVideo] = useState(false)
|
||||
const [showScene, setShowScene] = useState(false)
|
||||
const [showReplyImage, setShowReplyImage] = useState(false)
|
||||
const sendVideoRef = useRef<VideoRef>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
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 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)
|
||||
setBlankValues([])
|
||||
setBlankErrors([])
|
||||
setShowSendVideo(false)
|
||||
setShowScene(false)
|
||||
setShowReplyImage(false)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 音频播放逻辑
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audioSrc) return
|
||||
|
||||
if (visible && !showSendVideo) {
|
||||
audio.currentTime = 0
|
||||
audio.play().catch(() => {})
|
||||
} else {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}, [visible, showSendVideo, 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 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 && (
|
||||
<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 && (
|
||||
<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' }}
|
||||
>
|
||||
<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' }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
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>
|
||||
|
||||
<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-1/2 transform -translate-x-1/2 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)',
|
||||
backfaceVisibility: '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>
|
||||
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/寄出')}
|
||||
alt="寄出"
|
||||
onClick={() => {
|
||||
const nextValues = [...blankValues]
|
||||
onSubmit?.(nextValues)
|
||||
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 && !showReplyImage && replyImageSrc && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-102"
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<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 && replyImageSrc && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-103 flex items-center justify-center bg-black/80"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<motion.img
|
||||
src={getStaticImage(replyImageSrc)}
|
||||
alt="回信"
|
||||
className="w-full h-full object-contain"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
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)
|
||||
onClose?.()
|
||||
onAnimationEnd?.()
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default LetterTemplate
|
||||
Reference in new issue
Block a user