feat:黄炎培对话对接中
This commit is contained in:
1 parent
3611a0857a
commit
6ab184b328
125 files changed
+6617
-2391
No files matched your search
@@ -154,3 +154,37 @@ export type { ComponentNameProps, ComponentNameRef } from "./component-name";
|
||||
| `*.hooks.ts` | 组件专用 hooks | 有可复用的状态逻辑 |
|
||||
| `*.utils.ts` | 工具函数 | 有纯函数逻辑需要提取 |
|
||||
| `*.constants.ts` | 常量定义 | 有多个常量需要共享 |
|
||||
|
||||
<!-- 1. common / base (基础组件)
|
||||
存放与业务逻辑解耦的原子级组件。这些组件通常不包含 API 请求,只负责展示和交互。
|
||||
示例:MyButton, MyInput, Modal, Icon。
|
||||
用途:如果项目没有使用 Ant Design 或 Element Plus 等 UI 库,或者在其基础上二次封装,则放在此处 [1, 2]。
|
||||
2. business / biz (业务组件)
|
||||
存放与特定业务逻辑耦合、但在多个页面中复用的组件。
|
||||
示例:UserSelect(包含用户列表接口请求)、AddressPicker、OrderCard。
|
||||
用途:这些组件包含业务状态或特定的数据处理逻辑 [1, 3]。
|
||||
3. layout (布局组件)
|
||||
存放页面的整体结构组件。
|
||||
示例:Header, Footer, Sidebar, NavBar。
|
||||
用途:定义应用的外壳结构,通常在全局路由配置中使用 [2, 4]。
|
||||
4. hoc / render-props (高阶/逻辑组件)
|
||||
存放不直接渲染 UI,而是通过高阶组件(HOC)或 Render Props 模式提供逻辑复用的组件。
|
||||
示例:withAuth(权限校验)、ErrorBoundary(错误边界)。
|
||||
5. svg / icons (图标组件)
|
||||
专门用于存放封装后的 SVG 图标或字体图标组件。
|
||||
用途:方便通过组件形式调用图标,如 <IconName />。
|
||||
标准组件内部结构
|
||||
为了保持清晰,每个具体的组件通常建议以文件夹的形式存在,而不是单个文件:
|
||||
text
|
||||
components/
|
||||
└── MyComponent/
|
||||
├── index.ts # 组件对外暴露的入口
|
||||
├── MyComponent.tsx # 组件核心代码
|
||||
├── MyComponent.scss # 组件私有样式
|
||||
├── types.ts # TypeScript 类型定义
|
||||
├── hooks.ts # 组件内部抽离的逻辑 (可选)
|
||||
└── __tests__/ # 单元测试文件 (可选)
|
||||
请谨慎使用此类代码。
|
||||
|
||||
注意事项:页面级组件
|
||||
请注意,仅在单个页面中使用的私有组件,通常不建议放在 src/components 下,而应放在对应页面的文件夹内(例如 src/pages/Home/components),以避免全局组件目录过于臃肿 -->
|
||||
@@ -26,15 +26,10 @@ export interface ExhibitOverlayProps {
|
||||
description?: string;
|
||||
/** 是否自动播放描述语音,默认 false */
|
||||
autoPlayDescription?: boolean;
|
||||
/** 自定义点击区域配置 */
|
||||
clickableAreas?: Array<{
|
||||
/** 区域名称 */
|
||||
name: string;
|
||||
/** 相对于图片的位置和大小(百分比) */
|
||||
position: { left: number; top: number; width: number; height: number };
|
||||
/** 点击回调 */
|
||||
onClick: () => void;
|
||||
}>;
|
||||
/** 点击展示 */
|
||||
onClick?: () => void;
|
||||
/** 是否禁用拖拽,默认 false。当为 true 时,禁用拖拽和缩放功能,确保 onClick 事件能正常触发 */
|
||||
disableDrag?: boolean;
|
||||
}
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
@@ -54,7 +49,8 @@ const ExhibitOverlay = ({
|
||||
className,
|
||||
description,
|
||||
autoPlayDescription = false,
|
||||
clickableAreas,
|
||||
onClick,
|
||||
disableDrag = false,
|
||||
}: ExhibitOverlayProps) => {
|
||||
const maskSrc = getStaticImage("components/蒙版遮罩");
|
||||
const backSrc = getStaticImage("components/关闭");
|
||||
@@ -62,6 +58,7 @@ const ExhibitOverlay = ({
|
||||
// 获取缩放参数,用于视觉对齐
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
@@ -128,6 +125,7 @@ const ExhibitOverlay = ({
|
||||
pointers.current.clear();
|
||||
lastDistance.current = null;
|
||||
dragStartRef.current = null;
|
||||
setIsDragging(false);
|
||||
// 释放所有指针捕获
|
||||
if (containerRef.current) {
|
||||
pointers.current.forEach((_, pointerId) => {
|
||||
@@ -244,6 +242,9 @@ const ExhibitOverlay = ({
|
||||
// 指针按下:记录初始状态
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
// 如果禁用了拖拽,直接返回,不处理拖拽事件
|
||||
if (disableDrag) return;
|
||||
|
||||
if (!containerRef.current || !viewElRef.current) return;
|
||||
try {
|
||||
containerRef.current.setPointerCapture(e.pointerId);
|
||||
@@ -266,6 +267,7 @@ const ExhibitOverlay = ({
|
||||
startY: e.clientY,
|
||||
startTransform: { ...transformRef.current },
|
||||
};
|
||||
setIsDragging(true);
|
||||
} else if (pointers.current.size === 2) {
|
||||
// 双指缩放:记录初始距离
|
||||
const values = Array.from(pointers.current.values());
|
||||
@@ -278,7 +280,7 @@ const ExhibitOverlay = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[getDistance]
|
||||
[getDistance, disableDrag]
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -397,6 +399,8 @@ const ExhibitOverlay = ({
|
||||
} else {
|
||||
dragStartRef.current = null;
|
||||
}
|
||||
|
||||
setIsDragging(pointers.current.size > 0);
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -492,7 +496,7 @@ const ExhibitOverlay = ({
|
||||
|
||||
{/* 展品内容 - 在剩余空间中居中 */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2"
|
||||
className="absolute top-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{
|
||||
// 有描述时:从描述右侧30px开始,到右边界,在这个区域内居中
|
||||
left: description ? "520px" : "0", // 30 + 450 + 30
|
||||
@@ -510,17 +514,22 @@ const ExhibitOverlay = ({
|
||||
maxHeight: containerSize,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: disableDrag
|
||||
? "default"
|
||||
: isDragging
|
||||
? "grabbing"
|
||||
: "grab",
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onPointerDown={disableDrag ? undefined : handlePointerDown}
|
||||
onPointerMove={disableDrag ? undefined : handlePointerMove}
|
||||
onPointerUp={disableDrag ? undefined : handlePointerUp}
|
||||
onPointerCancel={disableDrag ? undefined : handlePointerUp}
|
||||
onWheel={disableDrag ? undefined : handleWheel}
|
||||
onDoubleClick={disableDrag ? undefined : handleDoubleClick}
|
||||
>
|
||||
<div
|
||||
ref={viewElRef}
|
||||
className="max-w-full max-h-full select-none relative"
|
||||
className="max-w-full max-h-full select-none relative pointer-events-auto"
|
||||
style={{
|
||||
transform: "translate3d(0px, 0px, 0) scale(1)",
|
||||
transformOrigin: "0 0",
|
||||
@@ -533,34 +542,13 @@ const ExhibitOverlay = ({
|
||||
WebkitPerspective: "1000",
|
||||
perspective: "1000",
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Image
|
||||
src={exhibitSrc}
|
||||
alt="展品"
|
||||
className="max-w-full max-h-full object-contain drop-shadow-2xl"
|
||||
/>
|
||||
{/* 可点击区域 */}
|
||||
{clickableAreas &&
|
||||
clickableAreas.map((area, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute cursor-pointer transition-all duration-200 hover:bg-white/10"
|
||||
style={{
|
||||
left: `${area.position.left}%`,
|
||||
top: `${area.position.top}%`,
|
||||
width: `${area.position.width}%`,
|
||||
height: `${area.position.height}%`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
area.onClick();
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
// 阻止拖拽事件
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,526 +0,0 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
import { Image, DragFill } from "@/components";
|
||||
import type { DragFillResult } from "@/components";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
|
||||
export interface BambooPuzzleGameProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 完成回调 */
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
type PieceId = "A" | "B" | "C" | "D" | "E" | "F";
|
||||
type ContainerType = "horizontal" | "leftPillar" | "rightPillar";
|
||||
|
||||
interface Piece {
|
||||
id: PieceId;
|
||||
src: string;
|
||||
correctContainer: ContainerType;
|
||||
}
|
||||
|
||||
// 根据效果图:A和B是立柱(可以放在左右任意一边),C是横杆(必须放在横杆容器)
|
||||
const pieces: Piece[] = [
|
||||
{
|
||||
id: "A",
|
||||
src: "huangyanpei/reflection/scene1/9_008",
|
||||
correctContainer: "leftPillar",
|
||||
},
|
||||
{
|
||||
id: "B",
|
||||
src: "huangyanpei/reflection/scene1/9_0013",
|
||||
correctContainer: "horizontal",
|
||||
},
|
||||
{
|
||||
id: "C",
|
||||
src: "huangyanpei/reflection/scene1/9_009",
|
||||
correctContainer: "rightPillar",
|
||||
},
|
||||
{
|
||||
id: "D",
|
||||
src: "huangyanpei/reflection/scene1/9_0010",
|
||||
correctContainer: "horizontal",
|
||||
},
|
||||
{
|
||||
id: "E",
|
||||
src: "huangyanpei/reflection/scene1/9_0011",
|
||||
correctContainer: "horizontal",
|
||||
},
|
||||
{
|
||||
id: "F",
|
||||
src: "huangyanpei/reflection/scene1/9_0012",
|
||||
correctContainer: "horizontal",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 毛竹拼单杠游戏组件
|
||||
* - 使用蒙版遮罩作为背景
|
||||
* - 使用 DragFill 组件实现拖拽功能
|
||||
*/
|
||||
const BambooPuzzleGame = ({
|
||||
visible = true,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: BambooPuzzleGameProps) => {
|
||||
const maskSrc = getStaticImage("components/蒙版遮罩");
|
||||
const backSrc = getStaticImage("components/关闭");
|
||||
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [value, setValue] = useState<DragFillResult[]>([]);
|
||||
const [errorContainers, setErrorContainers] = useState<Set<ContainerType>>(
|
||||
new Set()
|
||||
);
|
||||
const [shakeKey, setShakeKey] = useState(0);
|
||||
const completedRef = useRef(false);
|
||||
|
||||
// SSR 兼容
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 重置游戏状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setValue([]);
|
||||
setErrorContainers(new Set());
|
||||
setShakeKey(0);
|
||||
completedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 检查是否完成 - 三个都拼了才验证
|
||||
useEffect(() => {
|
||||
if (value.length === 3) {
|
||||
const leftPillar = value.find((r) => r.blankId === "leftPillar");
|
||||
const rightPillar = value.find((r) => r.blankId === "rightPillar");
|
||||
const horizontal = value.find((r) => r.blankId === "horizontal");
|
||||
|
||||
// 验证规则:
|
||||
// 1. C 必须在横杆容器
|
||||
// 2. A 和 B 可以在左右任意立柱(但必须各占一个)
|
||||
const isCorrect =
|
||||
horizontal?.optionValue === "C" &&
|
||||
leftPillar &&
|
||||
rightPillar &&
|
||||
(leftPillar.optionValue === "A" || leftPillar.optionValue === "B") &&
|
||||
(rightPillar.optionValue === "A" || rightPillar.optionValue === "B") &&
|
||||
leftPillar.optionValue !== rightPillar.optionValue;
|
||||
|
||||
if (isCorrect && !completedRef.current) {
|
||||
// 拼对了,调用完成回调(只调用一次)
|
||||
completedRef.current = true;
|
||||
onComplete?.();
|
||||
} else {
|
||||
// 拼错了,找出错误的容器
|
||||
const errors = new Set<ContainerType>();
|
||||
|
||||
// 检查横杆容器:必须是 C
|
||||
if (horizontal?.optionValue !== "C") {
|
||||
errors.add("horizontal");
|
||||
}
|
||||
|
||||
// 检查立柱容器:必须是 A 或 B,且不能相同
|
||||
if (
|
||||
!leftPillar ||
|
||||
(leftPillar.optionValue !== "A" && leftPillar.optionValue !== "B")
|
||||
) {
|
||||
errors.add("leftPillar");
|
||||
}
|
||||
if (
|
||||
!rightPillar ||
|
||||
(rightPillar.optionValue !== "A" && rightPillar.optionValue !== "B")
|
||||
) {
|
||||
errors.add("rightPillar");
|
||||
}
|
||||
// 如果两个立柱相同,都标记为错误
|
||||
if (
|
||||
leftPillar &&
|
||||
rightPillar &&
|
||||
leftPillar.optionValue === rightPillar.optionValue
|
||||
) {
|
||||
errors.add("leftPillar");
|
||||
errors.add("rightPillar");
|
||||
}
|
||||
|
||||
// 显示错误效果:变红并抖动
|
||||
setErrorContainers(errors);
|
||||
setShakeKey((prev) => prev + 1); // 触发动画重新播放
|
||||
|
||||
// 1.5秒后清除错误状态和错误的填充
|
||||
setTimeout(() => {
|
||||
setErrorContainers(new Set());
|
||||
// 清除错误的填充
|
||||
setValue((prev) =>
|
||||
prev.filter((r) => !errors.has(r.blankId as ContainerType))
|
||||
);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
}, [value, onComplete]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(e: React.MouseEvent<HTMLImageElement>) => {
|
||||
e.stopPropagation();
|
||||
onClose?.();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
// 转换为 DragFill 的 options
|
||||
const options = useMemo(
|
||||
() =>
|
||||
pieces.map((piece) => ({
|
||||
value: piece.id,
|
||||
label: piece.id,
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
// 转换为 DragFill 的 blanks
|
||||
const blanks = useMemo(
|
||||
() => [
|
||||
{ id: "horizontal", label: "横杆容器" },
|
||||
{ id: "leftPillar", label: "立柱容器" },
|
||||
{ id: "rightPillar", label: "立柱容器" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// 样式
|
||||
const overlayStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const scaledContainerStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
overflow: "hidden",
|
||||
}),
|
||||
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
key="bamboo-puzzle-game"
|
||||
style={overlayStyle}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<div style={scaledContainerStyle}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col items-center",
|
||||
"bg-black/70"
|
||||
)}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
{/* 背景蒙版 */}
|
||||
{maskSrc && (
|
||||
<Image
|
||||
src={maskSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 返回按钮 */}
|
||||
{onClose && (
|
||||
<Image
|
||||
src={backSrc}
|
||||
onClick={handleClose}
|
||||
alt="返回"
|
||||
className="absolute top-5 right-5 z-20"
|
||||
button
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 游戏内容 */}
|
||||
<div className="relative z-10 w-full h-full flex flex-col">
|
||||
{/* 标题 */}
|
||||
<div className="mt-8 mb-4 text-center">
|
||||
<h1
|
||||
className={cn([
|
||||
"text-5xl font-bold mb-2 text-white",
|
||||
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]",
|
||||
])}
|
||||
>
|
||||
毛竹拼单杠
|
||||
</h1>
|
||||
<p className="text-4xl text-white mt-10">
|
||||
请将下方的竹子拖到正确位置,拼出单杠!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 拼图区域 - 空白容器 */}
|
||||
<div
|
||||
className="flex-1 w-full relative"
|
||||
style={{ minHeight: "400px" }}
|
||||
>
|
||||
{/* 横杆容器 - 上方居中 */}
|
||||
<motion.div
|
||||
key={`horizontal-${shakeKey}`}
|
||||
data-blank-id="horizontal"
|
||||
className={cn(
|
||||
"absolute border-2 border-dashed rounded-4xl",
|
||||
"flex flex-col items-center justify-center transition-all duration-200",
|
||||
errorContainers.has("horizontal")
|
||||
? "border-red-500 bg-red-500/20"
|
||||
: "border-white/60 bg-white/5"
|
||||
)}
|
||||
animate={
|
||||
errorContainers.has("horizontal")
|
||||
? {
|
||||
x: [0, -15, 15, -15, 15, -10, 10, 0],
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}
|
||||
: { x: 0 }
|
||||
}
|
||||
style={{
|
||||
left: `${DESIGN_WIDTH * 0.15}px`,
|
||||
top: `${DESIGN_HEIGHT * 0.02}px`,
|
||||
width: `${DESIGN_WIDTH * 0.7}px`,
|
||||
height: `${DESIGN_HEIGHT * 0.2}px`,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const filled = value.find(
|
||||
(r) => r.blankId === "horizontal"
|
||||
);
|
||||
const piece = filled
|
||||
? pieces.find((p) => p.id === filled.optionValue)
|
||||
: null;
|
||||
return piece ? (
|
||||
<Image
|
||||
src={getStaticImage(piece.src)}
|
||||
alt={piece.id}
|
||||
className="w-full h-full object-contain p-2"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="text-white/30 text-4xl">
|
||||
拖拽横杆到这里
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</motion.div>
|
||||
|
||||
{/* 左立柱容器 */}
|
||||
<motion.div
|
||||
key={`leftPillar-${shakeKey}`}
|
||||
data-blank-id="leftPillar"
|
||||
className={cn(
|
||||
"absolute border-2 border-dashed rounded-4xl",
|
||||
"flex flex-col items-center justify-center transition-all duration-200",
|
||||
errorContainers.has("leftPillar")
|
||||
? "border-red-500 bg-red-500/20"
|
||||
: "border-white/60 bg-white/5"
|
||||
)}
|
||||
animate={
|
||||
errorContainers.has("leftPillar")
|
||||
? {
|
||||
x: [0, -15, 15, -15, 15, -10, 10, 0],
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}
|
||||
: { x: 0 }
|
||||
}
|
||||
style={{
|
||||
left: `${DESIGN_WIDTH * 0.15}px`,
|
||||
top: `${DESIGN_HEIGHT * 0.14}px`,
|
||||
width: `${DESIGN_WIDTH * 0.12}px`,
|
||||
height: `${DESIGN_HEIGHT * 0.32}px`,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const filled = value.find(
|
||||
(r) => r.blankId === "leftPillar"
|
||||
);
|
||||
const piece = filled
|
||||
? pieces.find((p) => p.id === filled.optionValue)
|
||||
: null;
|
||||
return piece ? (
|
||||
<Image
|
||||
src={getStaticImage(piece.src)}
|
||||
alt={piece.id}
|
||||
className="w-full h-full object-contain p-2"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="text-white/30 text-4xl">
|
||||
拖拽立柱到这里
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</motion.div>
|
||||
|
||||
{/* 右立柱容器 */}
|
||||
<motion.div
|
||||
key={`rightPillar-${shakeKey}`}
|
||||
data-blank-id="rightPillar"
|
||||
className={cn(
|
||||
"absolute border-2 border-dashed rounded-4xl",
|
||||
"flex flex-col items-center justify-center transition-all duration-200",
|
||||
errorContainers.has("rightPillar")
|
||||
? "border-red-500 bg-red-500/20"
|
||||
: "border-white/60 bg-white/5"
|
||||
)}
|
||||
animate={
|
||||
errorContainers.has("rightPillar")
|
||||
? {
|
||||
x: [0, -15, 15, -15, 15, -10, 10, 0],
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}
|
||||
: { x: 0 }
|
||||
}
|
||||
style={{
|
||||
left: `${DESIGN_WIDTH * 0.74}px`,
|
||||
top: `${DESIGN_HEIGHT * 0.14}px`,
|
||||
width: `${DESIGN_WIDTH * 0.12}px`,
|
||||
height: `${DESIGN_HEIGHT * 0.32}px`,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const filled = value.find(
|
||||
(r) => r.blankId === "rightPillar"
|
||||
);
|
||||
const piece = filled
|
||||
? pieces.find((p) => p.id === filled.optionValue)
|
||||
: null;
|
||||
return piece ? (
|
||||
<Image
|
||||
src={getStaticImage(piece.src)}
|
||||
alt={piece.id}
|
||||
className="w-full h-full object-contain p-2"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="text-white/30 text-4xl">
|
||||
拖拽立柱到这里
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 道具区域 - 使用 DragFill 组件 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full"
|
||||
style={{
|
||||
paddingLeft: `${DESIGN_WIDTH * 0.05}px`,
|
||||
paddingBottom: `${DESIGN_HEIGHT * 0.05}px`,
|
||||
width: `${DESIGN_WIDTH * 0.9}px`,
|
||||
}}
|
||||
>
|
||||
<div className="text-[#243204] mb-20 text-4xl font-blod">
|
||||
你的毛竹道具
|
||||
</div>
|
||||
<DragFill
|
||||
options={options}
|
||||
blanks={blanks}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
allowDuplicate={false}
|
||||
layout="horizontal-options"
|
||||
hideLabels={true}
|
||||
showSubmitButton={false}
|
||||
className="w-full"
|
||||
renderOption={(option, isUsed) => {
|
||||
const piece = pieces.find((p) => p.id === option.value);
|
||||
if (!piece) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center",
|
||||
" h-24 rounded-lg transition-all duration-200",
|
||||
"cursor-move select-none",
|
||||
isUsed
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:scale-110"
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={getStaticImage(piece.src)}
|
||||
alt={piece.id}
|
||||
className="w-full h-full object-contain pointer-events-none"
|
||||
draggable={false}
|
||||
objectFit="contain"
|
||||
/>
|
||||
<span className="text-white text-base mt-1 font-medium">
|
||||
{piece.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
renderDragging={(option) => {
|
||||
const piece = pieces.find((p) => p.id === option.value);
|
||||
if (!piece) return null;
|
||||
return (
|
||||
<div className="w-24 h-24">
|
||||
<Image
|
||||
src={getStaticImage(piece.src)}
|
||||
alt={piece.id}
|
||||
className="w-full h-full object-contain"
|
||||
draggable={false}
|
||||
objectFit="contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
renderBlank={() => {
|
||||
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
|
||||
return <div className="hidden" />;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default BambooPuzzleGame;
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as BambooPuzzleGame } from "./BambooPuzzleGame";
|
||||
export type { BambooPuzzleGameProps } from "./BambooPuzzleGame";
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Image } from "@/components";
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
|
||||
export interface StudyGuideModalProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 标题文案,默认:温馨提示 */
|
||||
title?: string;
|
||||
/** 主体内容(支持换行、富文本) */
|
||||
children?: ReactNode;
|
||||
/** 点击继续按钮 */
|
||||
onContinue?: () => void;
|
||||
/** 点击遮罩是否关闭,默认 true */
|
||||
maskClosable?: boolean;
|
||||
/** 自定义 zIndex,默认 120 */
|
||||
zIndex?: number;
|
||||
/** 对齐方式,默认 center */
|
||||
ailgn?: "center" | "left" | "right";
|
||||
// 垂直对齐方式,默认 center
|
||||
verticalAlign?: "center" | "top" | "bottom";
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用学习引导弹窗
|
||||
* 使用 images/components/guide 下的引导框 + 继续按钮资源
|
||||
*/
|
||||
export const StudyGuideModal = ({
|
||||
visible = false,
|
||||
title: _title = "温馨提示",
|
||||
children,
|
||||
onContinue,
|
||||
maskClosable: _maskClosable = true,
|
||||
zIndex = 120,
|
||||
ailgn = "center",
|
||||
verticalAlign = "top",
|
||||
}: StudyGuideModalProps) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!visible || !mounted) return null;
|
||||
|
||||
const handleMaskClick = () => {
|
||||
// if (maskClosable) {
|
||||
// onContinue?.();
|
||||
// }
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center"
|
||||
style={{ zIndex, pointerEvents: "auto" }}
|
||||
>
|
||||
{/* 背景遮罩 */}
|
||||
<div className="absolute inset-0 bg-black/60" onClick={handleMaskClick} />
|
||||
|
||||
{/* 引导内容 */}
|
||||
<div className="relative w-[1700px] h-[760px] pointer-events-none pt-65">
|
||||
{/* 引导框背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/guide/引导框")}
|
||||
alt="学习引导背景"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
{/* 文本内容 */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 w-[1400px] flex items-center justify-center mx-auto text-white pointer-events-none",
|
||||
ailgn === "left" && "justify-start",
|
||||
ailgn === "right" && "justify-end"
|
||||
)}
|
||||
>
|
||||
{/* {title && (
|
||||
<div className="mt-40 text-5xl font-bold text-[#ffe7b2] tracking-[12px]">
|
||||
{title}
|
||||
</div>
|
||||
)} */}
|
||||
{children && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-4xl leading-relaxed whitespace-pre-line",
|
||||
"[text-shadow:_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4)]"
|
||||
)}
|
||||
style={{
|
||||
marginTop: verticalAlign === "center" ? "100px" : "",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 继续按钮 */}
|
||||
<div className="absolute right-15 bottom-8 z-20 pointer-events-auto">
|
||||
<Image
|
||||
src={getStaticImage("components/guide/继续")}
|
||||
alt="继续"
|
||||
button
|
||||
onClick={onContinue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 当 zIndex >= 10000 时,使用 Portal 渲染到 document.body,并应用缩放
|
||||
if (zIndex >= 10000) {
|
||||
const scaledContent = (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
pointerEvents: "auto",
|
||||
zIndex,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
return createPortal(scaledContent, document.body);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export { GuideSteps, GuideStepsItem } from "./GuideSteps";
|
||||
export { StudyGuideModal } from "./StudyGuideModal";
|
||||
+15
-5
@@ -14,7 +14,7 @@ export type { ImageProps } from "./image";
|
||||
export { Title } from "./title";
|
||||
|
||||
// Guide components
|
||||
export { GuideSteps, GuideStepsItem } from "./guide";
|
||||
export { GuideSteps, GuideStepsItem, StudyGuideModal } from "./guide";
|
||||
|
||||
// Exhibit overlay
|
||||
export { ExhibitOverlay } from "./exhibit";
|
||||
@@ -68,10 +68,6 @@ export type {
|
||||
ToastInstance,
|
||||
} from "./toast";
|
||||
|
||||
// Game components
|
||||
export { BambooPuzzleGame } from "./game";
|
||||
export type { BambooPuzzleGameProps } from "./game";
|
||||
|
||||
// Clue components
|
||||
export { ClueHintModal, ClueBookModal } from "./clue";
|
||||
export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from "./clue";
|
||||
@@ -79,3 +75,17 @@ export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from "./clue";
|
||||
// Animation components
|
||||
export { flyToAnimation } from "./animation";
|
||||
export type { FlyToAnimationOptions, FlyToPosition } from "./animation";
|
||||
|
||||
// Letter components
|
||||
export {
|
||||
LetterToChenyun,
|
||||
LetterToSunzhongshan,
|
||||
LetterToHuangyanpei,
|
||||
LetterToLuxun,
|
||||
} from "./letter";
|
||||
export type {
|
||||
LetterToChenyunProps,
|
||||
LetterToSunzhongshanProps,
|
||||
LetterToHuangyanpeiProps,
|
||||
LetterToLuxunProps,
|
||||
} from "./letter";
|
||||
@@ -0,0 +1,70 @@
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
|
||||
export interface ErrorAlertProps {
|
||||
/** 文本 */
|
||||
text: string;
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 自定义 zIndex */
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert 组件 - 提示弹窗
|
||||
* @returns
|
||||
*/
|
||||
const ErrorAlert = ({
|
||||
text,
|
||||
visible = true,
|
||||
onMaskClick,
|
||||
onClose,
|
||||
zIndex = 10000,
|
||||
}: ErrorAlertProps) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
{/* 蒙版背景 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${getStaticImage("components/蒙版遮罩")})`,
|
||||
}}
|
||||
onClick={onMaskClick || onClose}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="relative w-full z-10 flex flex-col items-center justify-center gap-10">
|
||||
{/* 标题和副标题容器 - 带深色背景条 */}
|
||||
<div className="w-full bg-black/70 px-20 py-20 flex flex-col items-center gap-4">
|
||||
{/* 副标题 */}
|
||||
<div className="text-white text-5xl font-medium tracking-widest">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 继续按钮 */}
|
||||
<div
|
||||
className="w-[414px] h-[109px] bg-cover bg-center bg-no-repeat cursor-pointer hover:scale-105 hover:brightness-110 transition-all duration-300"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
${getBackgroundImage("components/继续前进")},
|
||||
${getBackgroundImage("components/按钮")}
|
||||
`,
|
||||
}}
|
||||
onClick={onClose}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorAlert;
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,10 +1,12 @@
|
||||
export { default as ResidenceLayout } from "./ResidenceLayout";
|
||||
export { default as ClueOverlay } from "./clue";
|
||||
export type { ClueOverlayProps } from "./clue";
|
||||
export { default as Alert } from "./alert";
|
||||
export type { AlertProps } from "./alert";
|
||||
export { default as SuccessAlert } from "./success-alert";
|
||||
export type { SuccessAlertProps } from "./success-alert";
|
||||
export { default as Alert } from "./alert/title-alert";
|
||||
export type { AlertProps } from "./alert/title-alert";
|
||||
export { default as SuccessAlert } from "./alert/success-alert";
|
||||
export type { SuccessAlertProps } from "./alert/success-alert";
|
||||
export { default as ErrorAlert } from "./alert/error-alert";
|
||||
export type { ErrorAlertProps } from "./alert/error-alert";
|
||||
export { DragQuizPanel } from "./quiz";
|
||||
export type { DragQuizPanelProps } from "./quiz";
|
||||
export type {
|
||||
|
||||
@@ -24,7 +24,7 @@ const DragQuizPanel = ({
|
||||
>
|
||||
<div className="relative w-[1728px] h-[972px] flex flex-col items-center pt-12 pb-14 gap-10">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1600px] min-h-[220px] rounded-[32px] bg-black/90 px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="w-[1600px] rounded-[32px] bg-black/90 px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="text-[32px] leading-relaxed text-white">
|
||||
{title && <div className="mb-4">{title}</div>}
|
||||
<div>
|
||||
@@ -116,9 +116,7 @@ const DragQuizPanel = ({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-6 text-center text-[26px] text-[#ff4d4f]">
|
||||
{error}
|
||||
</div>
|
||||
<div className="text-center text-[26px] text-[#ff4d4f]">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,4 +124,3 @@ const DragQuizPanel = ({
|
||||
};
|
||||
|
||||
export default DragQuizPanel;
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { Image } from "@/components";
|
||||
|
||||
export interface LetterToChenyunProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 提交回调 */
|
||||
onSubmit?: (values: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* LetterToChenyun 组件 - 寄给陈云爷爷的信
|
||||
* 包含飞入和放大动画
|
||||
*/
|
||||
const LetterToChenyun = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: LetterToChenyunProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([]);
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
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()}
|
||||
>
|
||||
{/* 信纸背景 */}
|
||||
<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;
|
||||
return letterContent.split("\n").map((line, lineIndex) => {
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div key={lineIndex} className="mb-6">
|
||||
{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
|
||||
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>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</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={() => onSubmit?.(blankValues)}
|
||||
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>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default LetterToChenyun;
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { Image } from "@/components";
|
||||
|
||||
export interface LetterToHuangyanpeiProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 提交回调 */
|
||||
onSubmit?: (values: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* LetterToHuangyanpei 组件 - 写给黄炎培先生的信
|
||||
* 包含飞入和放大动画
|
||||
*/
|
||||
const LetterToHuangyanpei = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: LetterToHuangyanpeiProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([]);
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
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()}
|
||||
>
|
||||
{/* 信纸背景 */}
|
||||
<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;
|
||||
return letterContent.split("\n").map((line, lineIndex) => {
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div key={lineIndex} className="mb-0">
|
||||
{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
|
||||
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>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</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={() => onSubmit?.(blankValues)}
|
||||
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>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default LetterToHuangyanpei;
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { Image } from "@/components";
|
||||
|
||||
export interface LetterToLuxunProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 提交回调 */
|
||||
onSubmit?: (values: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* LetterToLuxun 组件 - 写给鲁迅先生的信
|
||||
* 包含飞入和放大动画
|
||||
*/
|
||||
const LetterToLuxun = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: LetterToLuxunProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([]);
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
const letterContent = `敬爱的鲁迅先生:
|
||||
|
||||
您好!
|
||||
|
||||
今天,我怀着崇敬的心情走进大陆新村9号,看着您亲手撰写的《呐喊》《且介亭杂文》《故事新编》等作品,您编辑的《萌芽》《前哨》等杂志,您翻译的《艺术论》《死魂灵》等译文,仿佛能看到您"舌将上下而求索"的身影。在这场跨越时空的对话中,我终于明白您坚守的"____",不是抽象的理念,而是我们安身立命的根本;您秉持的"____",不是无谓的锋芒,而是我们直面现实的勇气。
|
||||
|
||||
我是____专业的学生,今后,我会以您"____干夫指"的骨气,在是非面前坚守立场,不随波逐流;我会以您"俯首甘为____"的热忱,参与____活动,用微光温暖他人;我更会记住您"____,走的人多了,便成了路"的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
|
||||
|
||||
请您放心,您播下的"立人"火种,我们会让它在新时代燎原;您毕生追求的"人国"愿景,我们会用青春和奋斗去守护!`;
|
||||
|
||||
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()}
|
||||
>
|
||||
{/* 信纸背景 */}
|
||||
<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;
|
||||
return letterContent.split("\n").map((line, lineIndex) => {
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div key={lineIndex} className="mb-2">
|
||||
{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
|
||||
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>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</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={() => onSubmit?.(blankValues)}
|
||||
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>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default LetterToLuxun;
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { Image } from "@/components";
|
||||
|
||||
export interface LetterToSunzhongshanProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 提交回调 */
|
||||
onSubmit?: (values: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* LetterToSunzhongshan 组件 - 写给孙中山爷爷的信
|
||||
* 包含飞入和放大动画
|
||||
*/
|
||||
const LetterToSunzhongshan = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: LetterToSunzhongshanProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([]);
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
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()}
|
||||
>
|
||||
{/* 信纸背景 */}
|
||||
<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;
|
||||
return letterContent.split("\n").map((line, lineIndex) => {
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div key={lineIndex} className="mb-2">
|
||||
{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
|
||||
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>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</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={() => onSubmit?.(blankValues)}
|
||||
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>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default LetterToSunzhongshan;
|
||||
@@ -0,0 +1,11 @@
|
||||
export { default as LetterToChenyun } from "./LetterToChenyun";
|
||||
export type { LetterToChenyunProps } from "./LetterToChenyun";
|
||||
|
||||
export { default as LetterToSunzhongshan } from "./LetterToSunzhongshan";
|
||||
export type { LetterToSunzhongshanProps } from "./LetterToSunzhongshan";
|
||||
|
||||
export { default as LetterToHuangyanpei } from "./LetterToHuangyanpei";
|
||||
export type { LetterToHuangyanpeiProps } from "./LetterToHuangyanpei";
|
||||
|
||||
export { default as LetterToLuxun } from "./LetterToLuxun";
|
||||
export type { LetterToLuxunProps } from "./LetterToLuxun";
|
||||
@@ -113,7 +113,7 @@ const Narration = ({
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-50">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black/80 z-50">
|
||||
<div
|
||||
className={cn(
|
||||
"w-[1600px] bg-black/90 rounded-[32px] px-20 py-16",
|
||||
|
||||
@@ -464,7 +464,7 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
{!hideLabels && (
|
||||
<h3 className="text-lg font-semibold text-gray-700">填空</h3>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col">
|
||||
{normalizedBlanks.map((blank) => {
|
||||
const filledValue = getBlankFill(blank.id);
|
||||
const isHovered = hoveredBlank === blank.id && !!draggingOption;
|
||||
|
||||
@@ -31,7 +31,7 @@ const Title = ({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold",
|
||||
"absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold whitespace-nowrap",
|
||||
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]"
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user