- 删除 .claude/settings.local.json 配置文件,清理不必要的代码。 - 更新多个组件以引入音频资源,增强用户互动体验。 - 调整引导对话文本,确保信息准确性和一致性。 - 更新图像资源,提升视觉内容的准确性和一致性。
442 lines
19 KiB
TypeScript
442 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 { filterInput } from "@/lib/utils/sensitiveWords";
|
||
|
||
export interface LetterToHuangyanpeiProps {
|
||
/** 是否可见 */
|
||
visible?: boolean;
|
||
/** 关闭回调 */
|
||
onClose?: () => void;
|
||
/** 提交回调 */
|
||
onSubmit?: (values: string[]) => void;
|
||
}
|
||
|
||
/**
|
||
* 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 = `敬爱的黄炎培先生:
|
||
|
||
您好!
|
||
|
||
今天,我循着您的足迹,走进了川沙小学堂的新式课堂,亲历了中华职业学校"手脑并用"的实训时光。那些您为破除旧教育桎梏的奔走、为推广职业教育的坚守、为支援国家危亡的热忱,不再是书本上的文字,而是可感可触的精神力量,深深烙印在我心中。
|
||
|
||
我终于明白,您喊出的"双手万能、____",不是一句口号,而是我们的立身之本;您践行的"教育救国、实业报国",不是一段历史,而是我们这代人接续的使命;您坚守的"因时求变",不是一种选择,而是我们面对未来的底气。
|
||
|
||
我是____专业的学生,今后,我会在实训课堂上精益求精,像当年中华职校的学长学姐那样,把技能练到极致;我会在志愿服务中践行担当,用所学知识为身边人、为社会做实事;我更会紧跟时代步伐,努力成为____领域的技能人才,为中国制造升级、为民族产业振兴添砖加瓦!
|
||
|
||
请您放心,您播下的职教火种,我们会让它在新时代燃烧得更旺;您毕生追求的"无业者有业、____"的愿景,我们会用青春和汗水去实现!`;
|
||
|
||
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>
|
||
);
|
||
};
|
||
|
||
export default LetterToHuangyanpei;
|