- 在 .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 和其他组件文件,统一代码风格,提升可读性。
437 lines
19 KiB
TypeScript
437 lines
19 KiB
TypeScript
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
|