feat: 更新光标组件和场景交互
- 在 Cursor 组件中添加禁用状态,增强用户交互体验。 - 更新多个场景以使用新的光标控制逻辑,确保光标的启用状态与展品访问状态相匹配。 - 在信件组件中添加输入校验功能,提升用户填写体验。 - 调整文本内容和引导对话,确保信息准确性和一致性。
This commit is contained in:
1 parent
a6570efa19
commit
d7ff7d3a45
25 files changed
+769
-158
No files matched your search
@@ -6,44 +6,62 @@ const ORIGINAL_SIZES = {
|
||||
unread: { width: 120, height: 121 },
|
||||
} as const;
|
||||
|
||||
const Cursor = ({
|
||||
className,
|
||||
onClick,
|
||||
read = false,
|
||||
size = 1,
|
||||
}: {
|
||||
interface CursorProps {
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
/** 已阅读 / 已完成 状态;true 时显示预留的 div,由你自行实现效果 */
|
||||
read?: boolean;
|
||||
/** 尺寸缩放比例,默认为 1(原始大小) */
|
||||
size?: number;
|
||||
}) => {
|
||||
// 根据 size 计算实际尺寸,保持原始比例
|
||||
/** 禁用状态,禁用时无法点击但不影响 read 状态显示。当 read=true 时,disabled 不生效 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Cursor = ({
|
||||
className,
|
||||
onClick,
|
||||
read = false,
|
||||
size = 1,
|
||||
disabled = false,
|
||||
}: CursorProps) => {
|
||||
const baseSize = read ? ORIGINAL_SIZES.read : ORIGINAL_SIZES.unread;
|
||||
const width = baseSize.width * size;
|
||||
const height = baseSize.height * size;
|
||||
// 当 read=true 时,disabled 不生效;只有 read=false 且 disabled=true 时才禁用
|
||||
const isDisabled = disabled && !read;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
if (isDisabled) return;
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-pointer select-none",
|
||||
read ? "" : "animate-[spin_3s_linear_infinite]",
|
||||
"flex justify-center items-center pl-0",
|
||||
"transition-all duration-300 hover:scale-105 hover:brightness-110 hover:contrast-110 active:scale-95",
|
||||
"select-none flex justify-center items-center pl-0 transition-all duration-300",
|
||||
isDisabled
|
||||
? "opacity-50"
|
||||
: "hover:scale-105 hover:brightness-110 hover:contrast-110 active:scale-95",
|
||||
!read && "animate-[spin_3s_linear_infinite]",
|
||||
className
|
||||
)}
|
||||
onTouchEnd={(e) => {
|
||||
e.preventDefault(); // 防止 Safari 触发两次
|
||||
onClick();
|
||||
}}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
width: `${baseSize.width * size}px`,
|
||||
height: `${baseSize.height * size}px`,
|
||||
WebkitTapHighlightColor: "transparent",
|
||||
WebkitTouchCallout: "none",
|
||||
WebkitUserSelect: "none",
|
||||
touchAction: "manipulation",
|
||||
touchAction: isDisabled ? "none" : "manipulation",
|
||||
pointerEvents: "auto",
|
||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
||||
backgroundImage: getBackgroundImage(
|
||||
read ? "components/point_" : "components/border"
|
||||
),
|
||||
@@ -51,29 +69,11 @@ const Cursor = ({
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
onClick={onClick}
|
||||
onClick={handleClick}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
draggable={false}
|
||||
>
|
||||
{read ? (
|
||||
// 已阅读 / 已完成 状态
|
||||
// <div
|
||||
// className="absolute -bottom-1 text-[#cf9c4d] text-2xl font-bold"
|
||||
// style={{
|
||||
// // 细一点的描边,避免把内部金色填充盖掉
|
||||
// // WebkitTextStroke: "0.5px #ffffff",
|
||||
// // 用多层白色阴影模拟更厚的外轮廓
|
||||
// textShadow: [
|
||||
// "0 0 2px #ffffff",
|
||||
// "0 0 4px #ffffff",
|
||||
// "0 0 6px rgba(0,0,0,0.35)",
|
||||
// ].join(", "),
|
||||
// }}
|
||||
// >
|
||||
// 已浏览
|
||||
// </div>
|
||||
<></>
|
||||
) : (
|
||||
// 未阅读 / 未完成 状态:保持当前效果
|
||||
{!read && (
|
||||
<img
|
||||
src={getStaticImage("components/point.png")}
|
||||
alt=""
|
||||
@@ -82,6 +82,7 @@ const Cursor = ({
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
objectPosition: "center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { Image } from "@/components";
|
||||
@@ -23,6 +23,47 @@ const LetterToChenyun = ({
|
||||
}: 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 = `敬爱的陈云爷爷:
|
||||
@@ -64,112 +105,241 @@ const LetterToChenyun = ({
|
||||
<div
|
||||
className="relative w-[1200px] h-full pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ perspective: "1200px" }}
|
||||
>
|
||||
{/* 信纸背景 */}
|
||||
<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]">
|
||||
{/* 编辑态 / 只读态: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="text-white text-6xl font-bold tracking-[20px]"
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
writingMode: "vertical-rl",
|
||||
textOrientation: "upright",
|
||||
backfaceVisibility: "hidden",
|
||||
// 编辑态展示时才响应事件,避免与背面点击冲突
|
||||
pointerEvents: submitted ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
寄给陈云爷爷的信
|
||||
</div>
|
||||
</div>
|
||||
{/* 编辑态:信纸背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/letter/信底")}
|
||||
alt="信纸"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
{/* 信件内容区域 */}
|
||||
<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;
|
||||
}
|
||||
{/* 标题 - 垂直显示在左侧 */}
|
||||
<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>
|
||||
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
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>
|
||||
);
|
||||
{/* 信件内容区域(可编辑) */}
|
||||
<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 currentIndex = placeholderIndex++;
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao &&
|
||||
line.trim() !== "" &&
|
||||
!line.includes("您好");
|
||||
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
<input
|
||||
className="inline-block w-[100px] h-[36px] mx-2 align-middle bg-transparent border-b-2 border-dashed border-[#4e402e] text-[24px] text-center outline-none"
|
||||
value={blankValues[currentIndex] ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...blankValues];
|
||||
next[currentIndex] = e.target.value;
|
||||
setBlankValues(next);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<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>
|
||||
|
||||
{/* 笔的图片 */}
|
||||
<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>
|
||||
|
||||
{/* 笔的图片 */}
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage("components/letter/笔")}
|
||||
alt="笔"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</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 bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage("components/letter/可提交状态")}
|
||||
alt="可提交状态"
|
||||
onClick={() => onSubmit?.(blankValues)}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
{/* 信件内容区域(只读) */}
|
||||
<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;
|
||||
}
|
||||
|
||||
{/* 寄信图标(可选,如果需要的话) */}
|
||||
<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>
|
||||
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>
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user