Files
shanghai-residences/src/components/letter/LetterToSunzhongshan.tsx
T
zhongyidian 76c67b492e feat: 更新 ESLint 和 Prettier 配置,增强代码质量管理
- 在 .gitignore 中添加 public/audio/* 以忽略音频文件。
- 更新 bun.lock,添加 eslint-config-prettier 和 eslint-plugin-prettier 依赖,确保 ESLint 与 Prettier 的兼容性。
- 修改 eslint.config.ts,整合 Prettier 规则,优化代码风格一致性。
- 在 package.json 中添加 lint:fix 和 format 脚本,简化代码格式化和修复流程。
- 更新 tailwind.config.ts 和其他组件文件,统一代码风格,提升可读性。
2026-03-05 13:50:47 +08:00

437 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
export interface LetterToSunzhongshanProps {
/** 是否可见 */
visible?: boolean
/** 关闭回调 */
onClose?: () => void
/** 提交回调 */
onSubmit?: (values: string[]) => void
}
/**
* LetterToSunzhongshan 组件 - 写给孙中山爷爷的信
* 包含飞入和放大动画
*/
const LetterToSunzhongshan = ({
visible = false,
onClose,
onSubmit,
}: 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>
)
}
export default LetterToSunzhongshan