feat:黄炎培对话对接中

This commit is contained in:
zhongyidian committed 2026-01-07 21:08:38 +08:00
1 parent 3611a0857a
commit 6ab184b328
125 files changed
+6617 -2391

No files matched your search

+34
View File
@@ -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),以避免全局组件目录过于臃肿 -->
+29 -41
View File
@@ -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>
-526
View File
@@ -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;
-3
View File
@@ -1,3 +0,0 @@
export { default as BambooPuzzleGame } from "./BambooPuzzleGame";
export type { BambooPuzzleGameProps } from "./BambooPuzzleGame";
+139
View File
@@ -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
View File
@@ -1 +1,2 @@
export { GuideSteps, GuideStepsItem } from "./GuideSteps";
export { StudyGuideModal } from "./StudyGuideModal";
+15 -5
View File
@@ -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;
+6 -4
View File
@@ -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 {
+2 -5
View File
@@ -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;
+165
View File
@@ -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;
+166
View File
@@ -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;
+11
View File
@@ -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";
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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)]"
)}
>