更新依赖项及历史人物相关内容资源

- 更新 package.json 和 package-lock.json 中的依赖项
- 新增/更新陈云、黄炎培、鲁迅、孙中山的音频文件
- 新增场景和组件的图片资源
- 清理未使用的音频和图片文件
- 优化多个组件和页面的实现逻辑
This commit is contained in:
zhongyidian committed 2026-04-20 13:27:18 +08:00
1 parent b1d55b9464
commit e0fa47d21a
629 files changed
+24390 -2991

No files matched your search

+592
View File
@@ -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
+63 -301
View File
@@ -1,68 +1,17 @@
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { getStaticImage } from '@/lib/utils'
import { Image } from '@/components'
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
export interface LetterToChenyunProps {
/** 是否可见 */
visible?: boolean
/** 关闭回调 */
onClose?: () => void
/** 提交回调 */
onSubmit?: (values: string[]) => void
onAnimationEnd?: () => void
audioSrc?: string
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
}
/**
* LetterToChenyun 组件 - 寄给陈云爷爷的信
* 包含飞入和放大动画
*/
const LetterToChenyun = ({ visible = false, onClose, onSubmit }: LetterToChenyunProps) => {
// 下划线可填写内容
const [blankValues, setBlankValues] = useState<string[]>([])
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
// 是否已提交:提交后隐藏编辑信件,展示只读信件
const [submitted, setSubmitted] = useState(false)
// 校验输入:不能为空、每个不超过 10 个字符
const validateBlanks = () => {
// 计算占位总数,防止有些没被输入过而是 undefined
const placeholderCount = (letterContent.match(/____/g) || []).length
const nextErrors: boolean[] = Array(placeholderCount).fill(false)
const nextValues: string[] = [...blankValues]
let hasError = false
for (let i = 0; i < placeholderCount; i++) {
const raw = blankValues[i] || ''
const v = raw.trim()
// 为空 或 超过 10 个字,都视为不符合要求:清空并标红
if (!v || v.length > 10) {
nextValues[i] = ''
nextErrors[i] = true
hasError = true
}
}
setBlankValues(nextValues)
setBlankErrors(nextErrors)
return !hasError
}
// 弹窗关闭后重置内容,方便下次重新填写
useEffect(() => {
if (!visible) {
setSubmitted(false)
setBlankValues([])
setBlankErrors([])
}
}, [visible])
// 信件内容模板(根据设计稿)
const letterContent = `敬爱的陈云爷爷:
const letterContent = `敬爱的陈云爷爷:
您好!
我是____专业的学生。今天,我怀着崇敬又激动的心情,参观了位于上海市青浦区练塘镇的陈云纪念馆,学习了您“不唯上、不唯书、只唯____”的做事准则,还有“综合平衡”“人民至上”的经济思想。我深刻体会到,这些精神不仅是建设年代的智慧结晶,更是新时代指引我们前行的思想灯塔。
@@ -71,250 +20,63 @@ const LetterToChenyun = ({ visible = false, onClose, onSubmit }: LetterToChenyun
站在新时代的起点,我一定以____为志向,勤学苦练,传承精神,让您的务实担当在青春里扎根,让奋斗在建设祖国的实践中绽放光彩!`
const fields: Record<number, LetterFieldConfig> = {
0: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '实践',
},
1: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '勤学钻研',
},
2: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '人民利益',
},
3: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '专业',
},
4: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '实干报国',
},
}
const LetterToChenyun = ({
visible = false,
onClose,
onSubmit,
onAnimationEnd,
audioSrc = 'audio/letter/8如愿.m4a',
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-陈云',
}: LetterToChenyunProps) => {
return (
<AnimatePresence>
{visible && (
<>
{/* 遮罩层 */}
<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}
/>
{/* 信件容器 */}
<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' }}
>
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
<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',
}}
>
寄给陈云的信
</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">
{(() => {
let placeholderIndex = 0
let foundNiHao = false
return letterContent.split('\n').map((line, lineIndex) => {
// 检测是否已经找到"您好"行
if (line.includes('您好')) {
foundNiHao = true
}
// "您好"行之后的非空行需要首行缩进
const needsIndent =
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split('____')
return (
<div
key={lineIndex}
className="mb-6"
style={needsIndent ? { textIndent: '2em' } : {}}
>
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1
if (isLast) {
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
}
const currentIndex = placeholderIndex++
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
<input
name={`blank-${currentIndex}`}
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
blankErrors[currentIndex]
? 'border-red-500'
: 'border-[#4e402e]'
}`}
value={blankValues[currentIndex] ?? ''}
onChange={(e) => {
const next = [...blankValues]
next[currentIndex] = e.target.value
setBlankValues(next)
// 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
}}
/>
</span>
)
})}
</div>
)
})
})()}
</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">
{(() => {
let placeholderIndex = 0
let foundNiHao = false
return letterContent.split('\n').map((line, lineIndex) => {
if (line.includes('您好')) {
foundNiHao = true
}
const needsIndent =
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split('____')
return (
<div
key={lineIndex}
className="mb-6"
style={needsIndent ? { textIndent: '2em' } : {}}
>
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1
if (isLast) {
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
}
const currentIndex = placeholderIndex++
const value = blankValues[currentIndex] ?? ''
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{value || '  '}
</span>
)
})}
</div>
)
})
})()}
</div>
</div>
{/* 寄出按钮:关闭窗口 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image
src={getStaticImage('components/letter/寄出')}
alt="寄出"
onClick={() => {
onSubmit?.(blankValues)
}}
button
/>
</div>
</div>
</motion.div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
<LetterTemplate
visible={visible}
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给陈云的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyImageSrc={replyImageSrc}
/>
)
}
+75 -377
View File
@@ -1,396 +1,94 @@
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { getStaticImage } from '@/lib/utils'
import { Image, Select } from '@/components'
import { filterInput } from '@/lib/utils/sensitiveWords'
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
export interface LetterToHuangyanpeiProps {
/** 是否可见 */
visible?: boolean
/** 关闭回调 */
onClose?: () => void
/** 提交回调 */
onSubmit?: (values: string[]) => void
onAnimationEnd?: () => void
audioSrc?: string
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
}
/**
* LetterToHuangyanpei 组件 - 写给黄炎培先生的信
* 包含飞入和放大动画
*/
const LetterToHuangyanpei = ({ visible = false, onClose, onSubmit }: LetterToHuangyanpeiProps) => {
// 下划线可填写内容
const [blankValues, setBlankValues] = useState<string[]>([])
// 每个空的校验状态:true 表示该空当前不符合要求
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
// 是否已提交:提交后隐藏编辑信件,展示只读信件
const [submitted, setSubmitted] = useState(false)
// 填空类型定义:0=填空1(限填),1=填空2(任填),2=填空3(任填),3=填空4(限填)
const BLANK_1_ANSWER = '手脑并用'
const BLANK_4_ANSWER = '有业者乐业'
// 填空1的选项
const BLANK_1_OPTIONS = [
'手脑并用',
'有业者乐业',
// "知行合一",
// "学以致用",
// "理论实践"
]
// 填空4的选项
const BLANK_4_OPTIONS = [
'手脑并用',
'有业者乐业',
// "有业者敬业",
// "有业者精业",
// "有业者成业",
]
// 校验输入:确保所有填空符合标准
const validateBlanks = () => {
// 计算占位总数,防止有些没被输入过而是 undefined
const placeholderCount = (letterContent.match(/____/g) || []).length
const nextErrors: boolean[] = Array(placeholderCount).fill(false)
const nextValues: string[] = [...blankValues]
let hasError = false
for (let i = 0; i < placeholderCount; i++) {
const raw = nextValues[i] || ''
const v = raw.trim()
if (i === 0) {
// 填空1:限填,必须选择"手脑并用"
if (!v || v !== BLANK_1_ANSWER) {
nextValues[i] = ''
nextErrors[i] = true
hasError = true
}
} else if (i === 1 || i === 2) {
// 填空2、3:任填,不能为空,不超过20个字
if (!v || v.length > 20) {
nextValues[i] = ''
nextErrors[i] = true
hasError = true
}
} else if (i === 3) {
// 填空4:限填,必须选择"有业者乐业"
if (!v || v !== BLANK_4_ANSWER) {
nextValues[i] = ''
nextErrors[i] = true
hasError = true
}
}
}
setBlankValues(nextValues)
setBlankErrors(nextErrors)
return !hasError
}
// 弹窗关闭后重置内容,方便下次重新填写
useEffect(() => {
if (!visible) {
setSubmitted(false)
setBlankValues([])
setBlankErrors([])
}
}, [visible])
// 信件内容模板(根据设计稿)
const letterContent = `敬爱的黄炎培先生:
const commonSelectField: LetterFieldConfig = {
type: 'select',
placeholder: '请选择',
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '手脑并用', label: '手脑并用' },
{ value: '有业者乐业', label: '有业者乐业' },
],
}
const letterContent = `敬爱的黄炎培先生:
您好!
今天,我循着您的足迹,走进了川沙小学堂的新式课堂,亲历了中华职业学校"手脑并用"的实训时光。那些您为破除旧教育桎梏的奔走、为推广职业教育的坚守、为支援国家危亡的热忱,不再是书本上的文字,而是可感可触的精神力量,深深烙印在我心中。
今天,我循着您的足迹,走进了川沙小学堂的新式课堂,亲历了中华职业学校“手脑并用”的实训时光。那些您为破除旧教育桎梏的奔走、为推广职业教育的坚守、为支援国家危亡的热忱,不再是书本上的文字,而是可感可触的精神力量,深深烙印在我心中。
我终于明白,您喊出的"双手万能、____",不是一句口号,而是我们的立身之本;您践行的"教育救国、实业报国",不是一段历史,而是我们这代人接续的使命;您坚守的"因时求变",不是一种选择,而是我们面对未来的底气。
我终于明白,您喊出的“双手万能、____”,不是一句口号,而是我们的立身之本;您践行的“教育救国、实业报国”,不是一段历史,而是我们这代人接续的使命;您坚守的“因时求变”,不是一种选择,而是我们面对未来的底气。
我是____专业的学生,今后,我会在实训课堂上精益求精,像当年中华职校的学长学姐那样,把技能练到极致;我会在志愿服务中践行担当,用所学知识为身边人、为社会做实事;我更会紧跟时代步伐,努力成为____领域的技能人才,为中国制造升级、为民族产业振兴添砖加瓦!
请您放心,您播下的职教火种,我们会让它在新时代燃烧得更旺;您毕生追求的"无业者有业、____"的愿景,我们会用青春和汗水去实现!`
请您放心,您播下的职教火种,我们会让它在新时代燃烧得更旺;您毕生追求的“使无业者有业,使____”的愿景,我们会用青春和汗水去实现!`
const fields: Record<number, LetterFieldConfig> = {
0: {
...commonSelectField,
correctValue: '手脑并用',
hints: {
error: '“双手万能、手脑并用”是黄炎培职业教育思想的核心理念之一。',
},
readOnlyFallback: '手脑并用',
},
1: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '',
},
2: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '',
},
3: {
...commonSelectField,
correctValue: '有业者乐业',
hints: {
error: '黄炎培把职业教育的目的概括为“使无业者有业,使有业者乐业”。',
},
readOnlyFallback: '有业者乐业',
},
}
const LetterToHuangyanpei = ({
visible = false,
onClose,
onSubmit,
onAnimationEnd,
audioSrc = 'audio/letter/4不忘初心.m4a',
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-黄炎培',
}: LetterToHuangyanpeiProps) => {
return (
<AnimatePresence>
{visible && (
<>
{/* 遮罩层 */}
<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}
/>
{/* 信件容器 */}
<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' }}
>
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
<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',
}}
>
写给黄炎培先生的信
</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">
{(() => {
let placeholderIndex = 0
let foundNiHao = false
return letterContent.split('\n').map((line, lineIndex) => {
// 检测是否已经找到"您好"行
if (line.includes('您好')) {
foundNiHao = true
}
// "您好"行之后的非空行需要首行缩进
const needsIndent =
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split('____')
return (
<div key={lineIndex} style={needsIndent ? { textIndent: '2em' } : {}}>
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1
if (isLast) {
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
}
const currentIndex = placeholderIndex++
const isLimitedFill = currentIndex === 0 || currentIndex === 3 // 填空1和填空4为限填
const options =
currentIndex === 0 ? BLANK_1_OPTIONS : BLANK_4_OPTIONS
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{isLimitedFill ? (
// 限填:下拉选择
<Select
className="inline"
options={options.map((opt) => ({
value: opt,
label: opt,
}))}
value={blankValues[currentIndex] ?? ''}
onChange={(selectedValue) => {
const next = [...blankValues]
next[currentIndex] = selectedValue
setBlankValues(next)
// 用户再次选择时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
}}
placeholder="请选择"
error={blankErrors[currentIndex]}
zIndex={10000}
fitContent={true}
/>
) : (
// 任填:文本输入,限制20个字,敏感词过滤
<input
name={`blank-${currentIndex}`}
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
blankErrors[currentIndex]
? 'border-red-500'
: 'border-[#4e402e]'
}`}
autoComplete="off"
value={blankValues[currentIndex] ?? ''}
maxLength={20}
onChange={(e) => {
const rawValue = e.target.value
// 应用敏感词过滤
const filteredValue = filterInput(rawValue)
const next = [...blankValues]
next[currentIndex] = filteredValue
setBlankValues(next)
// 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
}}
/>
)}
</span>
)
})}
</div>
)
})
})()}
</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">
{(() => {
let placeholderIndex = 0
let foundNiHao = false
return letterContent.split('\n').map((line, lineIndex) => {
if (line.includes('您好')) {
foundNiHao = true
}
const needsIndent =
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split('____')
return (
<div key={lineIndex} style={needsIndent ? { textIndent: '2em' } : {}}>
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1
if (isLast) {
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
}
const currentIndex = placeholderIndex++
const value = blankValues[currentIndex] ?? ''
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{value || '  '}
</span>
)
})}
</div>
)
})
})()}
</div>
</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()) {
onSubmit?.(blankValues)
}
}}
button
/>
</div>
</div>
</motion.div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
<LetterTemplate
visible={visible}
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给黄炎培的信"
letterContent={letterContent}
fields={fields}
contentClassName="!text-[25px]"
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyImageSrc={replyImageSrc}
/>
)
}
+110 -379
View File
@@ -1,397 +1,128 @@
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { getStaticImage } from '@/lib/utils'
import { Image, Select } from '@/components'
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
export interface LetterToLuxunProps {
/** 是否可见 */
visible?: boolean
/** 关闭回调 */
onClose?: () => void
/** 提交回调 */
onSubmit?: (values: string[]) => void
onAnimationEnd?: () => void
audioSrc?: string
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
}
/**
* LetterToLuxun 组件 - 写给鲁迅先生的信
* 包含飞入和放大动画
*/
const LetterToLuxun = ({ visible = false, onClose, onSubmit }: LetterToLuxunProps) => {
// 下划线可填写内容
const [blankValues, setBlankValues] = useState<string[]>([])
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
const commonSelectField: LetterFieldConfig = {
type: 'select',
placeholder: '请选择',
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '旨在立人', label: '旨在立人' },
{ value: '批判求真', label: '批判求真' },
],
}
// 是否已提交:提交后隐藏编辑信件,展示只读信件
const [submitted, setSubmitted] = useState(false)
// 填空类型定义:0=填空1(限填),1=填空2(限填),其他=任填
const BLANK_1_ANSWER = '首在立人'
const BLANK_2_ANSWER = '批判求真'
// 填空1的选项
const BLANK_1_OPTIONS = [
'首在立人',
'批判求真',
// 可以添加其他选项
]
// 填空2的选项
const BLANK_2_OPTIONS = [
'首在立人',
'批判求真',
// 可以添加其他选项
]
// 校验输入:确保所有填空符合标准
const validateBlanks = () => {
// 计算占位总数,防止有些没被输入过而是 undefined
const placeholderCount = (letterContent.match(/____/g) || []).length
const nextErrors: boolean[] = Array(placeholderCount).fill(false)
const nextValues: string[] = [...blankValues]
let hasError = false
for (let i = 0; i < placeholderCount; i++) {
const raw = nextValues[i] || ''
const v = raw.trim()
if (i === 0) {
// 填空1:限填,必须选择"首在立人"
if (!v || v !== BLANK_1_ANSWER) {
nextValues[i] = ''
nextErrors[i] = true
hasError = true
}
} else if (i === 1) {
// 填空2:限填,必须选择"批判求真"
if (!v || v !== BLANK_2_ANSWER) {
nextValues[i] = ''
nextErrors[i] = true
hasError = true
}
} else {
// 其他填空:任填,不能为空,不超过 10 个字
if (!v || v.length > 10) {
nextValues[i] = ''
nextErrors[i] = true
hasError = true
}
}
}
setBlankValues(nextValues)
setBlankErrors(nextErrors)
return !hasError
}
// 弹窗关闭后重置内容,方便下次重新填写
useEffect(() => {
if (!visible) {
setSubmitted(false)
setBlankValues([])
setBlankErrors([])
}
}, [visible])
// 信件内容模板(根据设计稿)
const letterContent = `敬爱的鲁迅先生:
const letterContent = `敬爱的鲁迅先生:
您好!
今天,我怀着崇敬的心情走进大陆新村9号,看着您亲手撰写的《呐喊》《且介亭杂文》《故事新编》等作品,您编辑的《萌芽》《前哨》等杂志,您翻译的《艺术论》《死魂灵》等译文,仿佛能看到您"吾将上下而求索"的身影。在这场跨越时空的对话中,我终于明白您坚守的"____",不是抽象的理念,而是我们安身立命的根本;您秉持的"____",不是无谓的锋芒,而是我们直面现实的勇气。
今天,我怀着崇敬的心情走进大陆新村 9 号,看着您亲手撰写的《呐喊》《且介亭杂文》《故事新编》等作品,您编辑的《萌芽》《前哨》等杂志,您翻译的《艺术论》《死魂灵》等译文,仿佛能看到您“吾将上下而求索”的身影。在这场跨越时空的对话中,我终于明白您坚守的“____”,不是抽象的理念,而是我们安身立命的根本;您秉持的“____”,不是无谓的锋芒,而是我们直面现实的勇气。
我是____专业的学生,今后,我会以您"____千夫指"的骨气,在是非面前坚守立场,不随波逐流;我会以您"俯首甘为____"的热忱,参与____活动,用微光温暖他人;我更会记住您"____,走的人多了,便成了路"的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
我是____专业的学生,今后,我会以您“____于夫指”的骨气,在是非面前坚守立场,不随波逐流;我会以您“俯首甘为____”的热忱,参与____活动,用微光温暖他人;我更会记住您“____,走的人多了,也便成了路”的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
请您放心,您播下的"立人"火种,我们会让它在新时代燎原;您毕生追求的"人国"愿景,我们会用青春和奋斗去守护!`
请您放心,您播下的“立人”火种,我们会让它在新时代燎原;您毕生追求的“人国”愿景,我们会用青春和奋斗去守护!`
const fields: Record<number, LetterFieldConfig> = {
0: {
...commonSelectField,
correctValue: '旨在立人',
hints: {
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '旨在立人',
},
1: {
...commonSelectField,
correctValue: '批判求真',
hints: {
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '批判求真',
},
2: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '中文',
},
3: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
correctValue: '横眉冷对',
readOnlyFallback: '横眉冷对',
hints: {
error: '“横眉冷对于夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
},
},
4: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
correctValue: '孺子牛',
readOnlyFallback: '孺子牛',
hints: {
error: '“横眉冷对于夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
},
},
5: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '志愿服务',
},
6: {
type: 'input',
maxLength: 16,
widthClassName: 'w-[120px]',
correctValue: '世上本没有路',
readOnlyFallback: '世上本没有路',
hints: {
error: '鲁迅在小说《故乡》中写道:“地上本没有路,走的人多了,也便成了路。”',
},
},
7: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '专业所长',
},
}
const LetterToLuxun = ({
visible = false,
onClose,
onSubmit,
onAnimationEnd,
audioSrc = 'audio/letter/3我和我的祖国.m4a',
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-鲁迅',
}: LetterToLuxunProps) => {
return (
<AnimatePresence>
{visible && (
<>
{/* 遮罩层 */}
<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}
/>
{/* 信件容器 */}
<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' }}
>
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
<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',
}}
>
写给鲁迅先生的信
</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">
{(() => {
let placeholderIndex = 0
let foundNiHao = false
return letterContent.split('\n').map((line, lineIndex) => {
// 检测是否已经找到"您好"行
if (line.includes('您好')) {
foundNiHao = true
}
// "您好"行之后的非空行需要首行缩进
const needsIndent =
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split('____')
return (
<div
key={lineIndex}
className="mb-2"
style={needsIndent ? { textIndent: '2em' } : {}}
>
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1
if (isLast) {
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
}
const currentIndex = placeholderIndex++
const isLimitedFill = currentIndex === 0 || currentIndex === 1 // 填空1和填空2为限填
const options =
currentIndex === 0 ? BLANK_1_OPTIONS : BLANK_2_OPTIONS
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{isLimitedFill ? (
// 限填:下拉选择
<Select
className="inline"
options={options.map((opt) => ({
value: opt,
label: opt,
}))}
value={blankValues[currentIndex] ?? ''}
onChange={(selectedValue) => {
const next = [...blankValues]
next[currentIndex] = selectedValue
setBlankValues(next)
// 用户再次选择时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
}}
placeholder="请选择"
error={blankErrors[currentIndex]}
zIndex={10000}
fitContent={true}
/>
) : (
// 任填:文本输入
<input
name={`blank-${currentIndex}`}
className={`inline-block w-[100px] h-[36px] mx-2 mb-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
blankErrors[currentIndex]
? 'border-red-500'
: 'border-[#4e402e]'
}`}
value={blankValues[currentIndex] ?? ''}
onChange={(e) => {
const next = [...blankValues]
next[currentIndex] = e.target.value
setBlankValues(next)
// 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
}}
/>
)}
</span>
)
})}
</div>
)
})
})()}
</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 className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
<img
src={getStaticImage("components/letter/寄信图标")}
alt="寄信"
className="w-full h-full object-contain"
/>
</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">
{(() => {
let placeholderIndex = 0
let foundNiHao = false
return letterContent.split('\n').map((line, lineIndex) => {
if (line.includes('您好')) {
foundNiHao = true
}
const needsIndent =
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split('____')
return (
<div
key={lineIndex}
className="mb-2"
style={needsIndent ? { textIndent: '2em' } : {}}
>
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1
if (isLast) {
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
}
const currentIndex = placeholderIndex++
const value = blankValues[currentIndex] ?? ''
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{value || '  '}
</span>
)
})}
</div>
)
})
})()}
</div>
</div>
{/* 寄出按钮:关闭窗口 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image
src={getStaticImage('components/letter/寄出')}
alt="寄出"
onClick={() => {
onSubmit?.(blankValues)
}}
button
/>
</div>
</div>
</motion.div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
<LetterTemplate
visible={visible}
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给鲁迅的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyImageSrc={replyImageSrc}
/>
)
}
+150
View File
@@ -0,0 +1,150 @@
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
export interface LetterToMaozedongProps {
visible?: boolean
onClose?: () => void
onSubmit?: (values: string[]) => void
onAnimationEnd?: () => void
audioSrc?: string
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
}
const commonSelectField: LetterFieldConfig = {
type: 'select',
placeholder: '请选择',
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '实事求是', label: '实事求是' },
{ value: '群众路线', label: '群众路线' },
{ value: '独立自主', label: '独立自主' },
],
}
const letterContent = `敬爱的毛泽东主席:
您好!
我是____专业的学生。今天,我怀着崇敬又激动的心情,参观了位于茂名北路120弄甲秀里的上海毛泽东旧居陈列馆,学习了毛泽东思想活的灵魂——____、____、____。我深刻体会到,这十二个字不仅是革命年代的智慧结晶,更是新时代指引我们前行的思想灯塔。
作为中职生,我深知“____”要求我们在专业学习中脚踏实地,掌握扎实技能;“____”提醒我们,未来无论从事什么职业,都要以服务社会、贡献他人为己任;而“独立自主”则激励我们,在技术革新中____,像C919大飞机团队那样,用____本领(结合所学专业)为推进中国式现代化贡献自己的力量!
站在新时代的起点,我一定以____为志向,勤学苦练,精益求精,让____精神在手中传承,让青春在建设祖国的实践中绽放光彩!`
const fields: Record<number, LetterFieldConfig> = {
0: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '计算机',
},
1: {
...commonSelectField,
correctValue: '实事求是',
hints: {
error: '再想想哦,这里强调的是毛泽东思想活的灵魂之一“实事求是”。',
},
readOnlyFallback: '实事求是',
},
2: {
...commonSelectField,
correctValue: '群众路线',
hints: {
error: '再想想哦,这里对应的是毛泽东思想活的灵魂之一“群众路线”。',
},
readOnlyFallback: '群众路线',
},
3: {
...commonSelectField,
correctValue: '独立自主',
hints: {
error: '再想想哦,这里对应的是毛泽东思想活的灵魂之一“独立自主”。',
},
readOnlyFallback: '独立自主',
},
4: {
type: 'select',
placeholder: '请选择',
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '实事求是', label: '实事求是' },
{ value: '群众路线', label: '群众路线' },
],
correctValue: '实事求是',
hints: {
error: '再想想哦,这里强调的是在专业学习中脚踏实地、掌握扎实技能。',
},
readOnlyFallback: '实事求是',
},
5: {
type: 'select',
placeholder: '请选择',
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '实事求是', label: '实事求是' },
{ value: '群众路线', label: '群众路线' },
],
correctValue: '群众路线',
hints: {
error: '再想想哦,这里对应的是服务社会、贡献他人的“群众路线”精神。',
},
readOnlyFallback: '群众路线',
},
6: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '勇于突破',
},
7: {
type: 'input',
maxLength: 14,
widthClassName: 'w-[120px]',
readOnlyFallback: '专业技能',
},
8: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '技能报国',
},
9: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '实事求是',
},
}
const LetterToMaozedong = ({
visible = false,
onClose,
onSubmit,
onAnimationEnd,
audioSrc = 'audio/letter/1东方红.m4a',
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-毛泽东',
}: LetterToMaozedongProps) => {
return (
<LetterTemplate
visible={visible}
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给毛泽东的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyImageSrc={replyImageSrc}
/>
)
}
export default LetterToMaozedong
+109
View File
@@ -0,0 +1,109 @@
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
export interface LetterToNieerProps {
visible?: boolean
onClose?: () => void
onSubmit?: (values: string[]) => void
onAnimationEnd?: () => void
audioSrc?: string
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
}
const commonSelectField: LetterFieldConfig = {
type: 'select',
placeholder: '请选择',
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '音乐救国', label: '音乐救国' },
{ value: '为人民而歌', label: '为人民而歌' },
{ value: '探索创新', label: '探索创新' },
],
}
const letterContent = `敬爱的聂耳先生:
您好!
今天,我循着激昂的旋律走进您的旧居与国歌展示馆,看着您手写的《义勇军进行曲》曲谱、创作的《开矿歌》《卖报歌》等作品手稿,仿佛能看到您在亭子间熬夜打磨旋律的身影。在这场跨越时空的对话中,我终于明白:您坚守的“____”,不是空洞的口号,而是以音符为武器的担当;您秉持的“____”,不是简单的创作理念,而是扎根生活、传递真情的初心;您践行的“____”,不是盲目的尝试,而是紧跟时代、突破自我的勇气。
我是____专业的学生,今后,我会以您“勤学苦练”的毅力,在专业技能上精益求精,就像您每天坚持练习小提琴,打磨每一个音符;我会以您“心系民众”的热忱,参与____活动,用自己的所长温暖他人;我更会记住您“以旋律传力量”的智慧,在时代浪潮中坚守初心,努力用____为民族复兴、国家强盛添砖加瓦。
请您放心,您谱写的“起来!不愿做奴隶的人们!”的呐喊,我们从未忘记;您用青春铸就的爱国精神,我们会笃实践行!正如您所期盼的,这激昂的国歌旋律,永远是我们前行的号角!`
const fields: Record<number, LetterFieldConfig> = {
0: {
...commonSelectField,
correctValue: '音乐救国',
hints: {
error: '请结合专家的解读和聂耳的创作经历再想想吧!',
},
readOnlyFallback: '音乐救国',
},
1: {
...commonSelectField,
correctValue: '为人民而歌',
hints: {
error: '请结合专家的解读和聂耳的创作经历再想想吧!',
},
readOnlyFallback: '为人民而歌',
},
2: {
...commonSelectField,
correctValue: '探索创新',
hints: {
error: '请结合专家的解读和聂耳的创作经历再想想吧!',
},
readOnlyFallback: '探索创新',
},
3: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '音乐',
},
4: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '志愿服务',
},
5: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '专业所长',
},
}
const LetterToNieer = ({
visible = false,
onClose,
onSubmit,
onAnimationEnd,
audioSrc = 'audio/letter/5我爱你中国.m4a',
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-聂耳',
}: LetterToNieerProps) => {
return (
<LetterTemplate
visible={visible}
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给聂耳的信"
letterContent={letterContent}
contentClassName="!text-[25px]"
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyImageSrc={replyImageSrc}
/>
)
}
export default LetterToNieer
@@ -0,0 +1,100 @@
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
export interface LetterToRongdeshengProps {
visible?: boolean
onClose?: () => void
onSubmit?: (values: string[]) => void
onAnimationEnd?: () => void
audioSrc?: string
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
}
const commonSelectField: LetterFieldConfig = {
type: 'select',
placeholder: '请选择',
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '国家', label: '国家' },
{ value: '个人', label: '个人' },
],
}
const letterContent = `敬爱的荣德生先生:
您好!
我是____专业的学生。今天,我怀着崇敬的心情参观了您的旧居,了解了您从钱庄学徒到“面粉大王”“纺织巨子”的传奇一生,读懂了您“____”的初心与担当。您在民族危亡时的气节、在艰难时局中的坚守、在事业成功后的奉献,都让我深受震撼。
作为新时代青年学生,我会在专业学习中____,像您当年那样钻研实业、精益求精,练就过硬的专业本领;树立正确的财富观和事业观;未来无论从事什么工作,都要把助力____发展放在首位,将个人理想与民族复兴紧密结合;面对困难与挑战时,我会以您为榜样,____,敢于攻坚克难,用自己的专业能力为中国式现代化建设贡献青春力量!
站在新时代的起点,我一定铭记您的精神,勤学苦练、笃行实干,让实业报国的种子在心中扎根,让奋斗成为青春最亮丽的底色,努力成为一名有理想、有本领、有担当的新时代青年!`
const fields: Record<number, LetterFieldConfig> = {
0: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '计算机',
},
1: {
type: 'input',
maxLength: 16,
widthClassName: 'w-[120px]',
correctValue: '实业救国',
readOnlyFallback: '实业救国',
hints: {
error: '“实业救国”是荣德生先生一生坚守的初心,也是他毕生践行的信念。',
},
},
2: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '努力',
},
3: {
...commonSelectField,
correctValue: '国家',
readOnlyFallback: '国家',
hints: {
error: '青年学生应该将个人理想融入国家发展之中。请你再想想吧!',
},
},
4: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '',
},
}
const LetterToRongdesheng = ({
visible = false,
onClose,
onSubmit,
onAnimationEnd,
audioSrc = 'audio/letter/7走向复兴.mp3',
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-荣德生',
}: LetterToRongdeshengProps) => {
return (
<LetterTemplate
visible={visible}
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给荣德生的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyImageSrc={replyImageSrc}
/>
)
}
export default LetterToRongdesheng
+96 -419
View File
@@ -1,435 +1,112 @@
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { getStaticImage } from '@/lib/utils'
import { Image, Select } from '@/components'
import { ErrorAlert } from '@/components/layout'
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
export interface LetterToSunzhongshanProps {
/** 是否可见 */
visible?: boolean
/** 关闭回调 */
onClose?: () => void
/** 提交回调 */
onSubmit?: (values: string[]) => void
onAnimationEnd?: () => void
audioSrc?: string
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
}
const commonSelectField: LetterFieldConfig = {
type: 'select',
placeholder: '请选择',
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '天下为公', label: '天下为公' },
{ value: '敢为天下先', label: '敢为天下先' },
{ value: '振兴中华', label: '振兴中华' },
],
}
const letterContent = `敬爱的孙中山先生:
您好!
今天,我怀着崇敬的心情走进香山路 7 号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为“振兴中华”奔走的身影。在李教授的解读中,我终于明白:您喊出的“____”,不是遥远的历史,而是我们这代人正在接续的接力;您追求的“____”,不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的“____”,不是过去的选择,而是面对未来时必须有的勇气。
我是____专业的学生,今后,我会以您“知难行易”的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您“心系民众”的情怀,参与____,帮身边人解决实际困难;我更会记住您“适乎世界之潮流”的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。
请您放心,“革命尚未成功,同志仍须努力”的嘱托,我们从未忘记;“振兴中华”的初心,我们会用一生践行!`
const fields: Record<number, LetterFieldConfig> = {
0: {
...commonSelectField,
correctValue: '振兴中华',
hints: {
error: '再想想哦,这里强调的是孙中山先生一生为之奋斗的理想。',
},
readOnlyFallback: '振兴中华',
},
1: {
...commonSelectField,
correctValue: '天下为公',
hints: {
error: '再想想哦,这里强调的是孙中山先生心系国家与人民的博大胸怀。',
},
readOnlyFallback: '天下为公',
},
2: {
...commonSelectField,
correctValue: '敢为天下先',
hints: {
error: '再想想哦,这里对应的是孙中山先生敢于探索新道路的胆识。',
},
readOnlyFallback: '敢为天下先',
},
3: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '政治',
},
4: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '实践担当',
},
5: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '时代发展',
},
6: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '所学本领',
},
}
/**
* LetterToSunzhongshan 组件 - 写给孙中山爷爷的信
* 包含飞入和放大动画
*/
const LetterToSunzhongshan = ({
visible = false,
onClose,
onSubmit,
onAnimationEnd,
audioSrc = 'audio/letter/2理想.m4a',
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-孙中山',
}: LetterToSunzhongshanProps) => {
// 下划线可填写内容
const [blankValues, setBlankValues] = useState<string[]>([])
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
// 是否已提交:提交后隐藏编辑信件,展示只读信件
const [submitted, setSubmitted] = useState(false)
// ErrorAlert 相关状态
const [showErrorAlert, setShowErrorAlert] = useState(false)
const [errorAlertText, setErrorAlertText] = useState('')
// 前三个空的正确答案
const BLANK_0_ANSWER = '振兴中华'
const BLANK_1_ANSWER = '天下为公'
const BLANK_2_ANSWER = '与时俱进'
// 最后一个空的正确答案
const LAST_BLANK_ANSWER = '仍需努力'
// 前三个空的选项(所有空都用这三个选项)
const SELECT_OPTIONS = [
{ value: '振兴中华', label: '振兴中华' },
{ value: '天下为公', label: '天下为公' },
{ value: '与时俱进', label: '与时俱进' },
]
// 校验输入:前三个空必须选择正确答案,最后一个空必须是"仍需努力",其他空不能为空且不超过 10 个字符
const validateBlanks = () => {
// 计算占位总数,防止有些没被输入过而是 undefined
const placeholderCount = (letterContent.match(/____/g) || []).length
const nextErrors: boolean[] = Array(placeholderCount).fill(false)
const nextValues: string[] = [...blankValues]
let hasError = false
for (let i = 0; i < placeholderCount; i++) {
const raw = blankValues[i] || ''
const v = raw.trim()
if (i < 3) {
// 前三个空:必须选择正确答案(已在 onChange 中验证,这里只检查是否为空)
if (!v) {
nextValues[i] = ''
nextErrors[i] = true
hasError = true
}
} else if (i === placeholderCount - 1) {
// 最后一个空:必须是"仍需努力"
if (!v || v !== LAST_BLANK_ANSWER) {
nextValues[i] = ''
nextErrors[i] = true
hasError = true
}
} else {
// 其他空:不能为空,不超过 10 个字
if (!v || v.length > 10) {
nextValues[i] = ''
nextErrors[i] = true
hasError = true
}
}
}
setBlankValues(nextValues)
setBlankErrors(nextErrors)
return !hasError
}
// 弹窗关闭后重置内容,方便下次重新填写
useEffect(() => {
if (!visible) {
setSubmitted(false)
setBlankValues([])
setBlankErrors([])
setShowErrorAlert(false)
setErrorAlertText('')
}
}, [visible])
// 信件内容模板(根据设计稿)
const letterContent = `敬爱的孙中山先生:
您好!
今天,我怀着崇敬的心情走进香山路7号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为“振兴中华”奔走的身影。在这场跨越时空的对话中,我终于明白,您喊出的“____”,不是遥远的历史,而是我们这代人正在接续的接力;您追求的“____”,不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的“____”,不是过去的选择,而是面对未来时必须有的勇气。
我是____专业的学生,今后,我会以您“知难行易”的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您“心系民众”的情怀,参与____,帮身边人解决实际困难;我更会记住您“适乎世界之潮流”的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。
请您放心,“革命尚未成功,同志____”的嘱托,我们从未忘记;“振兴中华”的初心,我们会用一生践行!`
return (
<AnimatePresence>
{visible && (
<>
{/* 遮罩层 */}
<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}
/>
{/* 信件容器 */}
<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' }}
>
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
<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',
}}
>
写给孙中山先生的信
</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">
{(() => {
let placeholderIndex = 0
let foundNiHao = false
return letterContent.split('\n').map((line, lineIndex) => {
// 检测是否已经找到"您好"行
if (line.includes('您好')) {
foundNiHao = true
}
// "您好"行之后的非空行需要首行缩进
const needsIndent =
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split('____')
return (
<div
key={lineIndex}
className="mb-2"
style={needsIndent ? { textIndent: '2em' } : {}}
>
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1
if (isLast) {
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
}
const currentIndex = placeholderIndex++
const isSelectFill = currentIndex < 3 // 前三个空使用 Select
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{isSelectFill ? (
// 前三个空:下拉选择
<Select
className="inline"
options={SELECT_OPTIONS}
value={blankValues[currentIndex] ?? ''}
onChange={(selectedValue) => {
const next = [...blankValues]
next[currentIndex] = selectedValue
setBlankValues(next)
// 清除错误状态
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
// 立即验证选择是否正确
// let correctAnswer = "";
// if (currentIndex === 0) {
// correctAnswer = BLANK_0_ANSWER;
// } else if (currentIndex === 1) {
// correctAnswer = BLANK_1_ANSWER;
// } else if (currentIndex === 2) {
// correctAnswer = BLANK_2_ANSWER;
// }
// if (
// correctAnswer &&
// selectedValue !== correctAnswer
// ) {
// // 选择错误,显示错误提示
// setErrorAlertText(
// "请选择正确答案"
// );
// setShowErrorAlert(true);
// // 清空选择
// next[currentIndex] = "";
// setBlankValues(next);
// const nextErrs = [...blankErrors];
// nextErrs[currentIndex] = true;
// setBlankErrors(nextErrs);
// }
}}
placeholder="请选择"
error={blankErrors[currentIndex]}
zIndex={10000}
fitContent={true}
/>
) : (
// 其他空:文本输入
<input
name={`blank-${currentIndex}`}
className={`inline-block w-[110px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
blankErrors[currentIndex]
? 'border-red-500'
: 'border-[#4e402e]'
}`}
value={blankValues[currentIndex] ?? ''}
onChange={(e) => {
const next = [...blankValues]
next[currentIndex] = e.target.value
setBlankValues(next)
// 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
}}
/>
)}
</span>
)
})}
</div>
)
})
})()}
</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 className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
<img
src={getStaticImage("components/letter/寄信图标")}
alt="寄信"
className="w-full h-full object-contain"
/>
</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">
{(() => {
let placeholderIndex = 0
let foundNiHao = false
return letterContent.split('\n').map((line, lineIndex) => {
if (line.includes('您好')) {
foundNiHao = true
}
const needsIndent =
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split('____')
return (
<div
key={lineIndex}
className="mb-2"
style={needsIndent ? { textIndent: '2em' } : {}}
>
{segments.map((part, partIndex) => {
const isLast = partIndex === segments.length - 1
if (isLast) {
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
}
const currentIndex = placeholderIndex++
const value = blankValues[currentIndex] ?? ''
return (
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{value || '  '}
</span>
)
})}
</div>
)
})
})()}
</div>
</div>
{/* 寄出按钮:关闭窗口 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image
src={getStaticImage('components/letter/寄出')}
alt="寄出"
onClick={() => {
onSubmit?.(blankValues)
}}
button
/>
</div>
</div>
</motion.div>
</div>
</motion.div>
{/* 错误提示弹窗 */}
<ErrorAlert
visible={showErrorAlert}
text={errorAlertText}
onClose={() => setShowErrorAlert(false)}
onMaskClick={() => setShowErrorAlert(false)}
zIndex={10002}
/>
</>
)}
</AnimatePresence>
<LetterTemplate
visible={visible}
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给孙中山的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyImageSrc={replyImageSrc}
/>
)
}
@@ -0,0 +1,123 @@
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
export interface LetterToZhanglepingProps {
visible?: boolean
onClose?: () => void
onSubmit?: (values: string[]) => void
onAnimationEnd?: () => void
audioSrc?: string
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
}
const letterContent = `敬爱的张乐平先生:
您好!
今天,我循着您的笔墨足迹,走进了这座充满着您创作的温度的故居。看着三毛从流浪儿到戴红领巾的成长画卷,了解到您“艺术源于生活”的创作坚持,我终于明白:您坚守的“____”,不是简单的创作技巧,而是扎根现实、传递真情的态度;您追求的“____”,不是刻意的形式简化,而是以少胜多、直击人心的智慧;您传递的“____”,不是空洞的道德说教,而是藏在细节中的悲悯与坚守。
我是____专业的学生,今后,我会践行您“笔耕不辍”的坚持,在专业学习中打磨____技能,就像您六十多年如一日坚守漫画创作;我会学习您“贴近生活”的真诚,用____帮助身边有需要的人,让美好在细微处传递;我更会传承您“笔墨随时代”的智慧,紧跟潮流,努力用____传递正能量。请您放心,您笔下的善良与勇敢和您坚守的真诚与热爱,会在我们新时代青年的传承下焕发新的光彩!`
const fields: Record<number, LetterFieldConfig> = {
0: {
type: 'select',
placeholder: '请选择',
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '源于生活', label: '源于生活' },
{ value: '化繁为简', label: '化繁为简' },
{ value: '向善向美', label: '向善向美' },
],
correctValue: '源于生活',
hints: {
error: '请你结合上下文和所学再想想吧!',
},
readOnlyFallback: '源于生活',
},
1: {
type: 'select',
placeholder: '请选择',
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '源于生活', label: '源于生活' },
{ value: '化繁为简', label: '化繁为简' },
{ value: '向善向美', label: '向善向美' },
],
correctValue: '化繁为简',
hints: {
error: '请你结合上下文和所学再想想吧!',
},
readOnlyFallback: '化繁为简',
},
2: {
type: 'select',
placeholder: '请选择',
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '源于生活', label: '源于生活' },
{ value: '化繁为简', label: '化繁为简' },
{ value: '向善向美', label: '向善向美' },
],
correctValue: '向善向美',
hints: {
error: '请你结合上下文和所学再想想吧!',
},
readOnlyFallback: '向善向美',
},
3: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '美术',
},
4: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '创作',
},
5: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '行动',
},
6: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '作品',
},
}
const LetterToZhangleping = ({
visible = false,
onClose,
onSubmit,
onAnimationEnd,
audioSrc = 'audio/letter/6灯火里的中国.m4a',
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-张乐平',
}: LetterToZhanglepingProps) => {
return (
<LetterTemplate
visible={visible}
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给张乐平的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyImageSrc={replyImageSrc}
/>
)
}
export default LetterToZhangleping
+12
View File
@@ -1,6 +1,9 @@
export { default as LetterToChenyun } from './LetterToChenyun'
export type { LetterToChenyunProps } from './LetterToChenyun'
export { default as LetterToMaozedong } from './LetterToMaozedong'
export type { LetterToMaozedongProps } from './LetterToMaozedong'
export { default as LetterToSunzhongshan } from './LetterToSunzhongshan'
export type { LetterToSunzhongshanProps } from './LetterToSunzhongshan'
@@ -9,3 +12,12 @@ export type { LetterToHuangyanpeiProps } from './LetterToHuangyanpei'
export { default as LetterToLuxun } from './LetterToLuxun'
export type { LetterToLuxunProps } from './LetterToLuxun'
export { default as LetterToZhangleping } from './LetterToZhangleping'
export type { LetterToZhanglepingProps } from './LetterToZhangleping'
export { default as LetterToNieer } from './LetterToNieer'
export type { LetterToNieerProps } from './LetterToNieer'
export { default as LetterToRongdesheng } from './LetterToRongdesheng'
export type { LetterToRongdeshengProps } from './LetterToRongdesheng'