feat:init
This commit is contained in:
1 parent
53d0eeac6b
commit
ae0c050010
494 files changed
+17701
-2318
No files matched your search
@@ -0,0 +1,155 @@
|
||||
import { Outlet, useMatches, useLocation } from "react-router-dom";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { getPublicPath } from "@/lib/utils";
|
||||
import type { VideoRouteHandle } from "@/router/types";
|
||||
import Video, { type VideoRef } from "@/components/video";
|
||||
import { VideoProvider } from "@/contexts/VideoContext";
|
||||
|
||||
/**
|
||||
* 故居布局组件
|
||||
* 用于所有故居页面的统一布局容器
|
||||
* 支持视频前置播放和平滑过渡动画
|
||||
* 渲染子路由内容
|
||||
*/
|
||||
const ResidenceLayout = () => {
|
||||
const matches = useMatches();
|
||||
const location = useLocation();
|
||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
const [isVideoFadingOut, setIsVideoFadingOut] = useState(false);
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
const previousPathRef = useRef<string>("");
|
||||
|
||||
// 获取当前路由的视频路径
|
||||
const videoSrc = useMemo(() => {
|
||||
const currentMatch = matches[matches.length - 1];
|
||||
return (currentMatch?.handle as VideoRouteHandle | undefined)?.videoSrc;
|
||||
}, [matches]);
|
||||
|
||||
// 判断是否是前进导航(而不是后退)
|
||||
const isForwardNavigation = useMemo(() => {
|
||||
const currentPath = location.pathname;
|
||||
const previousPath = previousPathRef.current || "";
|
||||
|
||||
// 提取场景编号(例如:/module/chenyun/place5 -> 5)
|
||||
const getCurrentPlaceNumber = (path: string) => {
|
||||
const match = path.match(/place(\d+)/);
|
||||
return match && match[1] ? parseInt(match[1], 10) : 0;
|
||||
};
|
||||
|
||||
const currentPlace = getCurrentPlaceNumber(currentPath);
|
||||
const previousPlace = getCurrentPlaceNumber(previousPath);
|
||||
|
||||
// 如果当前场景编号大于前一个场景编号,说明是前进
|
||||
// 如果是首次加载(previousPath为空),也视为前进
|
||||
return !previousPath || currentPlace > previousPlace;
|
||||
}, [location.pathname]);
|
||||
|
||||
// 处理视频播放完成(自然结束)
|
||||
const handleVideoEnded = () => {
|
||||
// 先让内容层开始淡入
|
||||
setIsContentVisible(true);
|
||||
// 开始视频层淡出动画
|
||||
setIsVideoFadingOut(true);
|
||||
// 然后延迟移除视频层,让视频层淡出动画完成
|
||||
setTimeout(() => {
|
||||
setIsVideoPlaying(false);
|
||||
setIsVideoFadingOut(false);
|
||||
}, 500); // 与 CSS transition 时间一致
|
||||
};
|
||||
|
||||
// 处理跳过操作
|
||||
const handleSkip = () => {
|
||||
// 立即暂停视频
|
||||
videoRef.current?.pause();
|
||||
// 立即显示内容层
|
||||
setIsContentVisible(true);
|
||||
// 立即移除视频层,不等待淡出动画
|
||||
setIsVideoPlaying(false);
|
||||
setIsVideoFadingOut(false);
|
||||
};
|
||||
|
||||
// 处理视频加载错误
|
||||
const handleVideoError = () => {
|
||||
// 视频加载失败,直接显示内容
|
||||
setIsVideoPlaying(false);
|
||||
setIsContentVisible(true);
|
||||
setIsVideoFadingOut(false);
|
||||
};
|
||||
|
||||
// 路由变化时重置状态
|
||||
useEffect(() => {
|
||||
// 只有在前进导航且有视频时才播放视频
|
||||
if (videoSrc && isForwardNavigation) {
|
||||
// 有视频且是前进,重置状态准备播放
|
||||
setIsVideoPlaying(true);
|
||||
setIsContentVisible(false);
|
||||
setIsVideoFadingOut(false);
|
||||
} else {
|
||||
// 没有视频或是后退,直接显示内容
|
||||
setIsVideoPlaying(false);
|
||||
setIsContentVisible(true);
|
||||
setIsVideoFadingOut(false);
|
||||
}
|
||||
|
||||
// 更新前一个路径
|
||||
previousPathRef.current = location.pathname;
|
||||
}, [location.pathname, videoSrc, isForwardNavigation]);
|
||||
|
||||
// 当视频元素渲染后,重置并确保播放
|
||||
useEffect(() => {
|
||||
if (isVideoPlaying && videoRef.current?.video && videoSrc) {
|
||||
const video = videoRef.current.video;
|
||||
video.currentTime = 0;
|
||||
video.play().catch(() => {
|
||||
// 自动播放失败,直接显示内容
|
||||
handleVideoError();
|
||||
});
|
||||
}
|
||||
}, [isVideoPlaying, videoSrc]);
|
||||
|
||||
return (
|
||||
<VideoProvider
|
||||
isVideoPlaying={isVideoPlaying}
|
||||
isContentVisible={isContentVisible}
|
||||
>
|
||||
<div className="relative w-full h-full">
|
||||
{/* 内容层 - 始终渲染,根据状态控制显示 */}
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
isContentVisible
|
||||
? "z-50 opacity-100" // 显示时在上层
|
||||
: "z-10 opacity-0 pointer-events-none" // 隐藏时在下层且不可交互
|
||||
} transition-opacity duration-300 ease-in`}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* 视频层 - 只在播放时渲染,z-index 动态调整 */}
|
||||
{isVideoPlaying && (
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
isVideoFadingOut
|
||||
? "z-40 opacity-0 pointer-events-none" // 淡出时移到下层
|
||||
: "z-50 opacity-100" // 播放时在上层
|
||||
} bg-black transition-opacity duration-500 ease-out`}
|
||||
>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
src={videoSrc ? getPublicPath(videoSrc) : undefined}
|
||||
autoPlay
|
||||
playsInline
|
||||
onEnded={handleVideoEnded}
|
||||
onError={handleVideoError}
|
||||
showSkipButton={true}
|
||||
skipText="跳过"
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</VideoProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResidenceLayout;
|
||||
Whitespace-only changes.
@@ -0,0 +1,72 @@
|
||||
import { Image } from "@/components";
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
|
||||
export interface AlertProps {
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 副标题 */
|
||||
subtitle: string;
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert 组件 - 提示弹窗
|
||||
* @returns
|
||||
*/
|
||||
const Alert = ({
|
||||
title,
|
||||
subtitle,
|
||||
visible = true,
|
||||
onMaskClick,
|
||||
onClose,
|
||||
}: AlertProps) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||
{/* 蒙版背景 */}
|
||||
<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={cn([
|
||||
"absolute -top-10",
|
||||
"text-white text-7xl font-bold",
|
||||
"[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)]",
|
||||
])}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{/* 副标题 */}
|
||||
<div className="text-white text-5xl font-medium tracking-widest">
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮:去了解 */}
|
||||
<Image
|
||||
src={getStaticImage("components/去了解")}
|
||||
onClick={onClose}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Image } from "@/components";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { ClueData } from "./types";
|
||||
import DialogueContent from "./DialogueContent";
|
||||
|
||||
export interface ClueContentProps {
|
||||
/** 线索数据 */
|
||||
clue: ClueData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索内容组件
|
||||
* 根据线索类型渲染不同的内容
|
||||
*/
|
||||
const ClueContent = ({ clue }: ClueContentProps) => {
|
||||
switch (clue.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<h2 className="text-4xl font-bold text-[#896a27] mb-4 text-center">
|
||||
{clue.title}
|
||||
</h2>
|
||||
{typeof clue.content === "string" ? (
|
||||
<div className="text-3xl leading-16 text-[#896a27] whitespace-pre-line text-center">
|
||||
{clue.content}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-3xl leading-16 text-[#896a27] text-center">
|
||||
{clue.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "map":
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Image
|
||||
src={getStaticImage(clue.mapImage)}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
alt="地图"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "audio":
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="text-5xl leading-relaxed text-white whitespace-pre-line text-center ">
|
||||
{clue.text}
|
||||
</div>
|
||||
<Image
|
||||
className="absolute left-0 bottom-10 w-full "
|
||||
src={getStaticImage(clue.audioImage)}
|
||||
alt="录音"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "dialogue":
|
||||
return <DialogueContent clue={clue} />;
|
||||
|
||||
case "custom":
|
||||
return <>{clue.content}</>;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default ClueContent;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Image } from "@/components";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { DialogueClueData } from "./types";
|
||||
|
||||
export interface DialogueContentProps {
|
||||
/** 对话线索数据 */
|
||||
clue: DialogueClueData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话内容组件
|
||||
* 依次显示多条对话,所有对话同时展示,带有动画效果
|
||||
*/
|
||||
const DialogueContent = ({ clue }: DialogueContentProps) => {
|
||||
const [visibleIndices, setVisibleIndices] = useState<number[]>([]);
|
||||
|
||||
// 依次显示对话:先显示第一条,延迟后显示第二条
|
||||
useEffect(() => {
|
||||
if (clue.dialogues.length === 0) return;
|
||||
|
||||
// 立即显示第一条对话
|
||||
setVisibleIndices([0]);
|
||||
|
||||
// 延迟显示后续对话
|
||||
if (clue.dialogues.length > 1) {
|
||||
const timer = setTimeout(() => {
|
||||
setVisibleIndices((prev) => [...prev, 1]);
|
||||
}, 1500); // 第一条显示1.5秒后显示第二条
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [clue.dialogues.length]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center gap-30 relative px-10">
|
||||
{clue.dialogues.map((dialogue, index) => {
|
||||
const isVisible = visibleIndices.includes(index);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 30, scale: 0.95 }}
|
||||
animate={
|
||||
isVisible
|
||||
? { opacity: 1, y: 0, scale: 1 }
|
||||
: { opacity: 0, y: 30, scale: 0.95 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1], // 自定义缓动函数,更流畅
|
||||
}}
|
||||
className="flex items-center gap-8 w-full max-w-7xl"
|
||||
>
|
||||
{/* 角色头像 */}
|
||||
{dialogue.avatar && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={
|
||||
isVisible ? { opacity: 1, x: 0 } : { opacity: 0, x: -20 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.1,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="flex-shrink-0 flex flex-col items-center"
|
||||
>
|
||||
<Image
|
||||
src={getStaticImage(dialogue.avatar)}
|
||||
className="w-60 h-60 object-contain"
|
||||
alt={dialogue.character}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={
|
||||
isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 10 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="mt-3 px-5 py-2 bg-gray-800/90 rounded-lg"
|
||||
>
|
||||
<span className="text-white text-base font-medium">
|
||||
{dialogue.character}
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 对话气泡 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={isVisible ? { opacity: 1, x: 0 } : { opacity: 0, x: 20 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.2,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="flex-1 relative"
|
||||
>
|
||||
<div className="bg-gray-800/95 rounded-3xl px-20 py-8 shadow-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={isVisible ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.4,
|
||||
}}
|
||||
className="text-white text-4xl leading-relaxed"
|
||||
>
|
||||
{dialogue.text}
|
||||
</motion.div>
|
||||
</div>
|
||||
{/* 对话气泡箭头 - 指向左侧头像 */}
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3">
|
||||
<div className="w-0 h-0 border-t-[12px] border-t-transparent border-r-[12px] border-r-gray-800/95 border-b-[12px] border-b-transparent" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogueContent;
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useState, useMemo, MouseEvent, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
import { Image } from "@/components";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import ClueContent from "./ClueContent";
|
||||
import { ClueData } from "./types";
|
||||
|
||||
export interface ClueOverlayProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 线索数据(如果提供,会优先使用) */
|
||||
clue?: ClueData;
|
||||
/** 自定义内容(文本或其他内容,当 clue 未提供时使用) */
|
||||
children?: ReactNode;
|
||||
/** 额外的自定义类 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索展示框组件
|
||||
* - 中间内容区域可自定义(文本或其他内容)
|
||||
* - 使用背景资源:components/layout/纸(地图和音频类型除外)
|
||||
*/
|
||||
const ClueOverlay = ({
|
||||
visible = true,
|
||||
onClose,
|
||||
clue,
|
||||
children,
|
||||
className,
|
||||
}: ClueOverlayProps) => {
|
||||
const maskSrc = getStaticImage("components/蒙版遮罩");
|
||||
const backSrc = getStaticImage("components/关闭");
|
||||
const paperBgSrc = getStaticImage("components/layout/纸");
|
||||
|
||||
// 获取缩放参数,用于视觉对齐
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 判断是否需要显示纸张背景(地图、音频和对话类型不显示纸张背景)
|
||||
const shouldShowPaperBg =
|
||||
clue?.type !== "map" && clue?.type !== "audio" && clue?.type !== "dialogue";
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleClose = (e: MouseEvent<HTMLImageElement>) => {
|
||||
e.stopPropagation();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
// 视觉对齐样式:确保与 App 内容对齐
|
||||
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="clue-overlay"
|
||||
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 items-center justify-center",
|
||||
"bg-black/70",
|
||||
className
|
||||
)}
|
||||
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 flex items-center justify-center w-full h-full">
|
||||
{shouldShowPaperBg ? (
|
||||
// 文本类型:显示纸张背景
|
||||
<div
|
||||
className="relative"
|
||||
style={{ minWidth: "600px", minHeight: "400px" }}
|
||||
>
|
||||
{/* 纸张背景图片 */}
|
||||
<Image
|
||||
src={paperBgSrc}
|
||||
className="w-full h-full object-contain pointer-events-none select-none"
|
||||
alt=""
|
||||
aria-hidden
|
||||
/>
|
||||
{/* 自定义内容 - 绝对定位在纸张上方 */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ padding: "60px 80px" }}
|
||||
>
|
||||
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||
{clue ? <ClueContent clue={clue} /> : children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 地图和音频类型:直接显示内容,无纸张背景
|
||||
<div className="max-w-full max-h-full w-full flex items-center justify-center">
|
||||
{clue ? <ClueContent clue={clue} /> : children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
// 直接使用 Portal 渲染到 document.body
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default ClueOverlay;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
/** 线索类型 */
|
||||
export type ClueType = "text" | "map" | "audio" | "dialogue" | "custom";
|
||||
|
||||
/** 文本线索数据 */
|
||||
export interface TextClueData {
|
||||
type: "text";
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 内容文本(支持字符串或 ReactNode,字符串可使用 \n 换行) */
|
||||
content: string | ReactNode;
|
||||
}
|
||||
|
||||
/** 地图线索数据 */
|
||||
export interface MapClueData {
|
||||
type: "map";
|
||||
/** 地图图片资源路径 */
|
||||
mapImage: string;
|
||||
}
|
||||
|
||||
/** 录音线索数据 */
|
||||
export interface AudioClueData {
|
||||
type: "audio";
|
||||
/** 文本内容 */
|
||||
text: string;
|
||||
/** 录音图片资源路径 */
|
||||
audioImage: string;
|
||||
}
|
||||
|
||||
/** 对话线索数据 */
|
||||
export interface DialogueClueData {
|
||||
type: "dialogue";
|
||||
/** 对话列表 */
|
||||
dialogues: Array<{
|
||||
/** 角色名称 */
|
||||
character: string;
|
||||
/** 角色头像资源路径 */
|
||||
avatar?: string;
|
||||
/** 对话文本 */
|
||||
text: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** 自定义线索数据 */
|
||||
export interface CustomClueData {
|
||||
type: "custom";
|
||||
/** 自定义内容 */
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
/** 线索数据联合类型 */
|
||||
export type ClueData =
|
||||
| TextClueData
|
||||
| MapClueData
|
||||
| AudioClueData
|
||||
| DialogueClueData
|
||||
| CustomClueData;
|
||||
@@ -0,0 +1,18 @@
|
||||
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 { DragQuizPanel } from "./quiz";
|
||||
export type { DragQuizPanelProps } from "./quiz";
|
||||
export type {
|
||||
ClueData,
|
||||
ClueType,
|
||||
TextClueData,
|
||||
MapClueData,
|
||||
AudioClueData,
|
||||
DialogueClueData,
|
||||
CustomClueData,
|
||||
} from "./clue/types";
|
||||
@@ -0,0 +1,129 @@
|
||||
import { DragFill, type DragFillResult } from "@/components";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DragQuizPanelProps } from "./types";
|
||||
|
||||
/** 拖拽题面板:使用 DragFill 组件 */
|
||||
const DragQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
questionPrefix,
|
||||
questionSuffix,
|
||||
options,
|
||||
blanks,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
}: DragQuizPanelProps) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<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="text-[32px] leading-relaxed text-white">
|
||||
{title && <div className="mb-4">{title}</div>}
|
||||
<div>
|
||||
{questionPrefix && <span>{questionPrefix}</span>}
|
||||
{blanks.map((blank, index) => {
|
||||
const filledResult = value.find((r) => r.blankId === blank.id);
|
||||
const filledValue = filledResult?.optionValue || null;
|
||||
return (
|
||||
<span key={blank.id}>
|
||||
<span
|
||||
data-blank-id={blank.id}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center min-w-[140px] h-[56px] px-6 mx-3",
|
||||
"rounded-xl border-2 transition-all duration-300 shadow-[0_4px_16px_rgba(0,0,0,0.2)]",
|
||||
filledValue
|
||||
? "border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] backdrop-blur-sm"
|
||||
: "border-[#d4c5a9]/60 bg-white/10 backdrop-blur-sm hover:border-[#d4c5a9]"
|
||||
)}
|
||||
>
|
||||
{filledValue ? (
|
||||
<span className="text-[#f0be75] font-semibold flex items-center gap-3">
|
||||
<span className="text-[28px]">{filledValue}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-full bg-[#8b4513]/80 hover:bg-[#a0522d] text-white text-sm transition-all hover:scale-110 shadow-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(
|
||||
value.filter((r) => r.blankId !== blank.id)
|
||||
);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-white/40 text-[24px] font-light">
|
||||
____
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{index < blanks.length - 1 && (
|
||||
<span className="text-white/80 mx-1">、</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{questionSuffix && <span>{questionSuffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用 DragFill 组件 */}
|
||||
<div className="w-[1500px] mt-16">
|
||||
<DragFill
|
||||
options={options}
|
||||
blanks={blanks}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hideLabels
|
||||
showSubmitButton={false}
|
||||
layout="vertical"
|
||||
className="w-full"
|
||||
renderOption={(option, isUsed) => (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-[110px] px-10 flex items-center justify-center",
|
||||
"rounded-[32px] transition-all duration-300",
|
||||
"text-[32px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]",
|
||||
isUsed
|
||||
? "border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50"
|
||||
: "border-[#d4c5a9] bg-gradient-to-br from-[#4a3f35] to-[#3a2f25] text-[#f5e6d3] cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)] hover:scale-[1.02]"
|
||||
)}
|
||||
>
|
||||
{!isUsed && (
|
||||
<div className="absolute inset-0 rounded-[32px] bg-gradient-to-br from-[#d4c5a9]/20 to-transparent pointer-events-none" />
|
||||
)}
|
||||
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)]">
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
renderBlank={(_blank) => {
|
||||
// 返回一个隐藏元素,实际的空白区域在题干中
|
||||
// 但需要通过 data-blank-id 让 DragFill 能找到它
|
||||
return <div className="hidden" />;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-6 text-center text-[26px] text-[#ff4d4f]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DragQuizPanel;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useState } from "react";
|
||||
import { MultipleChoice } from "@/components";
|
||||
import { getBackgroundImage } from "@/lib/utils";
|
||||
import type { MultipleChoiceQuizPanelProps } from "./types";
|
||||
|
||||
/**
|
||||
* 多选题面板组件
|
||||
*
|
||||
* 内部管理多选状态,通过提交按钮提交并验证答案
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MultipleChoiceQuizPanel
|
||||
* visible={true}
|
||||
* title="这是一道多选题"
|
||||
* options={[
|
||||
* { value: "A", label: "选项A" },
|
||||
* { value: "B", label: "选项B" },
|
||||
* { value: "C", label: "选项C" },
|
||||
* ]}
|
||||
* correctAnswer={["A", "B"]}
|
||||
* errorMessage="很抱歉,你选错了!请你再思考一下吧!"
|
||||
* minSelections={1}
|
||||
* maxSelections={2}
|
||||
* minSelectionsErrorMessage="请至少选择 1 项才能提交"
|
||||
* onSubmit={(values, isCorrect) => {
|
||||
* console.log("用户选择:", values, "是否正确:", isCorrect);
|
||||
* }}
|
||||
* onError={(error) => {
|
||||
* console.log("错误信息:", error);
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const MultipleChoiceQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
options,
|
||||
correctAnswer,
|
||||
errorMessage,
|
||||
minSelectionsErrorMessage,
|
||||
onSubmit,
|
||||
onError,
|
||||
disabled = false,
|
||||
minSelections,
|
||||
maxSelections,
|
||||
submitText = "提交",
|
||||
}: MultipleChoiceQuizPanelProps) => {
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
// 处理选项点击
|
||||
const handleOptionChange = (values: string[]) => {
|
||||
if (disabled) return;
|
||||
setSelectedValues(values);
|
||||
setError("");
|
||||
};
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
|
||||
// 检查最小选择数量
|
||||
if (minSelections !== undefined && selectedValues.length < minSelections) {
|
||||
const errorMsg =
|
||||
minSelectionsErrorMessage || `请至少选择 ${minSelections} 项`;
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证答案
|
||||
let isCorrect: boolean | undefined = undefined;
|
||||
if (correctAnswer !== undefined) {
|
||||
// 比较两个数组是否相等(不考虑顺序)
|
||||
const correctSet = new Set(correctAnswer);
|
||||
const selectedSet = new Set(selectedValues);
|
||||
|
||||
isCorrect =
|
||||
correctSet.size === selectedSet.size &&
|
||||
[...correctSet].every((val) => selectedSet.has(val));
|
||||
|
||||
if (isCorrect) {
|
||||
// 答题成功:清空状态,执行 onSubmit(外部控制关闭窗口)
|
||||
const submittedValues = [...selectedValues];
|
||||
setSelectedValues([]);
|
||||
setError("");
|
||||
onSubmit?.(submittedValues, true);
|
||||
} else {
|
||||
// 答题失败:清空状态,执行 onError,允许重新选择
|
||||
setSelectedValues([]);
|
||||
const errorMsg = errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
}
|
||||
} else {
|
||||
// 没有提供正确答案,直接提交
|
||||
const submittedValues = [...selectedValues];
|
||||
setSelectedValues([]);
|
||||
setError("");
|
||||
onSubmit?.(submittedValues, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<div className="relative w-[1728px] h-[972px] flex flex-col items-center py-8">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1600px] min-h-[200px] max-h-[280px] rounded-[32px] bg-black px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] flex-shrink-0">
|
||||
<div className="text-[34px] leading-relaxed text-white">{title}</div>
|
||||
</div>
|
||||
|
||||
{/* 选项区域 - 可滚动 */}
|
||||
<div className="mt-8 w-[1500px] flex-1 overflow-y-auto overflow-x-hidden px-4 min-h-0 quiz-scrollbar">
|
||||
<MultipleChoice
|
||||
options={options}
|
||||
value={selectedValues}
|
||||
disabled={disabled}
|
||||
minSelections={minSelections}
|
||||
maxSelections={maxSelections}
|
||||
showSubmitButton={false}
|
||||
onChange={handleOptionChange}
|
||||
renderOption={(option, isSelected) => {
|
||||
const isDisabled =
|
||||
disabled ||
|
||||
(!isSelected &&
|
||||
maxSelections !== undefined &&
|
||||
selectedValues.length >= maxSelections);
|
||||
|
||||
// 处理选项点击
|
||||
const handleClick = () => {
|
||||
if (isDisabled) return;
|
||||
|
||||
let newValues: string[];
|
||||
if (isSelected) {
|
||||
// 取消选择
|
||||
newValues = selectedValues.filter((v) => v !== option.value);
|
||||
// 检查最小选择数量限制
|
||||
if (
|
||||
minSelections !== undefined &&
|
||||
newValues.length < minSelections
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 选择
|
||||
// 检查最大选择数量限制
|
||||
if (
|
||||
maxSelections !== undefined &&
|
||||
selectedValues.length >= maxSelections
|
||||
) {
|
||||
return;
|
||||
}
|
||||
newValues = [...selectedValues, option.value];
|
||||
}
|
||||
|
||||
handleOptionChange(newValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={option.value} className="mb-4">
|
||||
<div
|
||||
className="w-full max-w-[1431px] min-h-[120px] h-auto py-4 flex items-center px-16 text-[28px] leading-relaxed text-[#fdf5e6] bg-cover bg-center bg-no-repeat transition-all duration-150"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage(
|
||||
isSelected
|
||||
? "components/quiz/选中"
|
||||
: "components/quiz/未选择"
|
||||
),
|
||||
filter: isSelected ? undefined : "brightness(0.96)",
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={(e) => {
|
||||
if (isSelected || isDisabled) return;
|
||||
(
|
||||
e.currentTarget as HTMLDivElement
|
||||
).style.backgroundImage =
|
||||
getBackgroundImage("components/quiz/选中");
|
||||
(e.currentTarget as HTMLDivElement).style.filter = "";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isSelected || isDisabled) return;
|
||||
(
|
||||
e.currentTarget as HTMLDivElement
|
||||
).style.backgroundImage = getBackgroundImage(
|
||||
"components/quiz/未选择"
|
||||
);
|
||||
(e.currentTarget as HTMLDivElement).style.filter =
|
||||
"brightness(0.96)";
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮和错误信息 - 固定在底部,不滚动 */}
|
||||
<div className="w-[1500px] flex-shrink-0 mt-4">
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mb-4 text-center text-[26px] text-[#ff4d4f]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
disabled ||
|
||||
selectedValues.length === 0 ||
|
||||
(minSelections !== undefined &&
|
||||
selectedValues.length < minSelections)
|
||||
}
|
||||
className="px-30 py-4 text-[28px] font-medium text-white bg-[#b4915a] rounded-[40px] transition-all duration-200 hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
|
||||
>
|
||||
{submitText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultipleChoiceQuizPanel;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { SingleChoice } from "@/components";
|
||||
import { getBackgroundImage } from "@/lib/utils";
|
||||
import type { QuizPanelProps } from "./types";
|
||||
|
||||
/** 单选题展示面板:题干使用“底板”背景,点击即选,hover 高亮 */
|
||||
const QuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
options,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
}: QuizPanelProps) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1600px] min-h-[260px] rounded-[32px] bg-black px-20 py-14 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="text-[34px] leading-relaxed text-white">{title}</div>
|
||||
</div>
|
||||
|
||||
{/* 选项区域 */}
|
||||
<div className="mt-16 w-[1500px]">
|
||||
<SingleChoice
|
||||
options={options}
|
||||
value={value}
|
||||
renderOption={(option, isSelected) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="mb-6 cursor-pointer"
|
||||
onClick={() => onChange?.(option.value)}
|
||||
>
|
||||
<div
|
||||
className="w-[1431px] h-[156px] flex items-center px-16 text-[28px] leading-snug text-[#fdf5e6] bg-cover bg-center bg-no-repeat transition-all duration-150"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage(
|
||||
isSelected
|
||||
? "components/quiz/选中"
|
||||
: "components/quiz/未选择"
|
||||
),
|
||||
filter: isSelected ? undefined : "brightness(0.96)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isSelected) return;
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundImage =
|
||||
getBackgroundImage("components/quiz/选中");
|
||||
(e.currentTarget as HTMLDivElement).style.filter = "";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isSelected) return;
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundImage =
|
||||
getBackgroundImage("components/quiz/未选择");
|
||||
(e.currentTarget as HTMLDivElement).style.filter =
|
||||
"brightness(0.96)";
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-6 text-center text-[26px] text-[#ff4d4f]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuizPanel;
|
||||
@@ -0,0 +1,211 @@
|
||||
# Quiz Panel 组件
|
||||
|
||||
用于展示单选题和多选题的面板组件,内部管理选择状态,通过 `onSubmit` 回调通知外部。
|
||||
|
||||
## 组件列表
|
||||
|
||||
- **SingleChoiceQuizPanel** - 单选题面板
|
||||
- **MultipleChoiceQuizPanel** - 多选题面板
|
||||
- **QuizPanel** - 旧版组件(已废弃,保留以保持向后兼容)
|
||||
|
||||
## SingleChoiceQuizPanel - 单选题面板
|
||||
|
||||
### 功能特性
|
||||
|
||||
- ✅ 内部管理选择状态
|
||||
- ✅ 点击选项后自动提交
|
||||
- ✅ 支持答案验证(可选)
|
||||
- ✅ 自动显示错误提示
|
||||
- ✅ 提交后禁用交互
|
||||
|
||||
### 使用方法
|
||||
|
||||
```tsx
|
||||
import { SingleChoiceQuizPanel } from "@/components";
|
||||
|
||||
function MyComponent() {
|
||||
const [showQuiz, setShowQuiz] = useState(false);
|
||||
|
||||
return (
|
||||
<SingleChoiceQuizPanel
|
||||
visible={showQuiz}
|
||||
title="这是一道单选题:以下哪个选项是正确的?"
|
||||
options={[
|
||||
{ value: "A", label: "选项A" },
|
||||
{ value: "B", label: "选项B" },
|
||||
{ value: "C", label: "选项C" },
|
||||
]}
|
||||
correctAnswer="B"
|
||||
errorMessage="再想一想,注意题干中的时间和提示信息哦~"
|
||||
onSubmit={(value, isCorrect) => {
|
||||
console.log("用户选择:", value);
|
||||
console.log("是否正确:", isCorrect);
|
||||
if (isCorrect) {
|
||||
// 选择正确,可以关闭面板或进入下一步
|
||||
setShowQuiz(false);
|
||||
}
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.log("错误信息:", error);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| visible | boolean | - | 是否可见(必填) |
|
||||
| zIndex | number | 10000 | z-index 层级 |
|
||||
| title | string | - | 题目(必填) |
|
||||
| options | SingleChoiceOption[] | - | 选项列表(必填) |
|
||||
| correctAnswer | string | - | 正确答案(可选,用于验证) |
|
||||
| errorMessage | string | "很抱歉,你选错了!请你再思考一下吧!" | 自定义错误提示信息(答案错误时显示) |
|
||||
| onSubmit | (value: string, isCorrect: boolean) => void | - | 提交回调 |
|
||||
| onError | (error: string) => void | - | 错误回调 |
|
||||
| disabled | boolean | false | 是否禁用 |
|
||||
|
||||
### 行为说明
|
||||
|
||||
1. **点击选项**:立即触发提交,如果提供了 `correctAnswer`,会自动验证
|
||||
2. **答案正确**:调用 `onSubmit(value, true)`,面板进入已提交状态
|
||||
3. **答案错误**:显示错误提示,调用 `onError(error)` 和 `onSubmit(value, false)`
|
||||
4. **未提供正确答案**:直接调用 `onSubmit(value, true)`,不进行验证
|
||||
|
||||
## MultipleChoiceQuizPanel - 多选题面板
|
||||
|
||||
### 功能特性
|
||||
|
||||
- ✅ 内部管理多选状态
|
||||
- ✅ 支持最小/最大选择数量限制
|
||||
- ✅ 通过提交按钮提交
|
||||
- ✅ 支持答案验证(可选)
|
||||
- ✅ 自动显示错误提示
|
||||
- ✅ 提交后禁用交互
|
||||
|
||||
### 使用方法
|
||||
|
||||
```tsx
|
||||
import { MultipleChoiceQuizPanel } from "@/components";
|
||||
|
||||
function MyComponent() {
|
||||
const [showQuiz, setShowQuiz] = useState(false);
|
||||
|
||||
return (
|
||||
<MultipleChoiceQuizPanel
|
||||
visible={showQuiz}
|
||||
title="这是一道多选题:以下哪些选项是正确的?(可多选)"
|
||||
options={[
|
||||
{ value: "A", label: "选项A" },
|
||||
{ value: "B", label: "选项B" },
|
||||
{ value: "C", label: "选项C" },
|
||||
{ value: "D", label: "选项D" },
|
||||
]}
|
||||
correctAnswer={["A", "B"]}
|
||||
errorMessage="很抱歉,你选错了!请你再思考一下吧!"
|
||||
minSelections={1}
|
||||
maxSelections={3}
|
||||
minSelectionsErrorMessage="请至少选择 1 项才能提交"
|
||||
submitText="确认提交"
|
||||
onSubmit={(values, isCorrect) => {
|
||||
console.log("用户选择:", values);
|
||||
console.log("是否正确:", isCorrect);
|
||||
if (isCorrect) {
|
||||
setShowQuiz(false);
|
||||
}
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.log("错误信息:", error);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| visible | boolean | - | 是否可见(必填) |
|
||||
| zIndex | number | 10000 | z-index 层级 |
|
||||
| title | string | - | 题目(必填) |
|
||||
| options | MultipleChoiceOption[] | - | 选项列表(必填) |
|
||||
| correctAnswer | string[] | - | 正确答案数组(可选,用于验证) |
|
||||
| errorMessage | string | "很抱歉,你选错了!请你再思考一下吧!" | 自定义错误提示信息(答案错误时显示) |
|
||||
| minSelectionsErrorMessage | string | `请至少选择 ${minSelections} 项` | 自定义最小选择数量不足时的提示信息 |
|
||||
| onSubmit | (values: string[], isCorrect: boolean) => void | - | 提交回调 |
|
||||
| onError | (error: string) => void | - | 错误回调 |
|
||||
| disabled | boolean | false | 是否禁用 |
|
||||
| minSelections | number | - | 最小选择数量 |
|
||||
| maxSelections | number | - | 最大选择数量 |
|
||||
| submitText | string | "提交" | 提交按钮文本 |
|
||||
|
||||
### 行为说明
|
||||
|
||||
1. **选择选项**:点击选项进行多选,受 `minSelections` 和 `maxSelections` 限制
|
||||
2. **点击提交**:验证最小选择数量,如果提供了 `correctAnswer`,会验证答案
|
||||
3. **答案正确**:调用 `onSubmit(values, true)`,面板进入已提交状态
|
||||
4. **答案错误**:显示错误提示,调用 `onError(error)` 和 `onSubmit(values, false)`
|
||||
5. **未提供正确答案**:直接调用 `onSubmit(values, true)`,不进行验证
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从旧版 QuizPanel 迁移
|
||||
|
||||
**旧版用法:**
|
||||
```tsx
|
||||
<QuizPanel
|
||||
visible={showQuiz}
|
||||
title="题目"
|
||||
options={options}
|
||||
value={quizAnswer}
|
||||
error={quizError}
|
||||
onChange={(val) => {
|
||||
setQuizAnswer(val);
|
||||
if (val === "B") {
|
||||
setQuizError("");
|
||||
} else {
|
||||
setQuizError("很抱歉,你选错了!");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
**新版用法:**
|
||||
```tsx
|
||||
<SingleChoiceQuizPanel
|
||||
visible={showQuiz}
|
||||
title="题目"
|
||||
options={options}
|
||||
correctAnswer="B"
|
||||
onSubmit={(value, isCorrect) => {
|
||||
if (isCorrect) {
|
||||
// 选择正确
|
||||
} else {
|
||||
// 选择错误,错误信息已由组件内部显示
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### 主要变化
|
||||
|
||||
1. **不再需要 `onChange`**:组件内部管理选择状态
|
||||
2. **不再需要 `value` 和 `error`**:组件内部管理
|
||||
3. **使用 `onSubmit`**:替代 `onChange`,返回用户选择和是否正确
|
||||
4. **使用 `correctAnswer`**:让组件自动验证答案
|
||||
5. **使用 `onError`**:可选,用于处理错误信息
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 两个组件都使用内部状态管理,不需要外部传入 `value`
|
||||
2. 提交后组件会进入禁用状态,防止重复提交
|
||||
3. 如果提供了 `correctAnswer`,组件会自动验证并显示错误信息
|
||||
4. 多选题的答案比较不考虑顺序,只要选择的选项和正确答案一致即可
|
||||
5. 旧版 `QuizPanel` 仍然可用,但建议迁移到新组件
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from "react";
|
||||
import { SingleChoice } from "@/components";
|
||||
import { getBackgroundImage } from "@/lib/utils";
|
||||
import type { SingleChoiceQuizPanelProps } from "./types";
|
||||
|
||||
/**
|
||||
* 单选题面板组件
|
||||
*
|
||||
* 内部管理选择状态,点击选项后自动提交并验证答案
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SingleChoiceQuizPanel
|
||||
* visible={true}
|
||||
* title="这是一道单选题"
|
||||
* options={[
|
||||
* { value: "A", label: "选项A" },
|
||||
* { value: "B", label: "选项B" },
|
||||
* ]}
|
||||
* correctAnswer="B"
|
||||
* errorMessage="再想一想,注意题干中的时间和提示信息哦~"
|
||||
* onSubmit={(value, isCorrect) => {
|
||||
* console.log("用户选择:", value, "是否正确:", isCorrect);
|
||||
* }}
|
||||
* onError={(error) => {
|
||||
* console.log("错误信息:", error);
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const SingleChoiceQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
options,
|
||||
correctAnswer,
|
||||
errorMessage,
|
||||
onSubmit,
|
||||
onError,
|
||||
disabled = false,
|
||||
}: SingleChoiceQuizPanelProps) => {
|
||||
const [selectedValue, setSelectedValue] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
// 处理选项点击
|
||||
const handleOptionClick = (value: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
setSelectedValue(value);
|
||||
setError("");
|
||||
|
||||
// 验证答案
|
||||
const isCorrect =
|
||||
correctAnswer !== undefined ? value === correctAnswer : undefined;
|
||||
|
||||
// 如果提供了正确答案,进行验证
|
||||
if (correctAnswer !== undefined) {
|
||||
if (isCorrect) {
|
||||
// 答题成功:清空状态,执行 onSubmit(外部控制关闭窗口)
|
||||
setSelectedValue("");
|
||||
setError("");
|
||||
onSubmit?.(value, true);
|
||||
} else {
|
||||
// 答题失败:清空状态,执行 onError,允许重新选择
|
||||
setSelectedValue("");
|
||||
const errorMsg = errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
}
|
||||
} else {
|
||||
// 没有提供正确答案,直接提交
|
||||
setSelectedValue("");
|
||||
setError("");
|
||||
onSubmit?.(value, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1600px] min-h-[260px] rounded-[32px] bg-black px-20 py-14 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="text-[34px] leading-relaxed text-white">{title}</div>
|
||||
</div>
|
||||
|
||||
{/* 选项区域 */}
|
||||
<div className="mt-16 w-[1500px]">
|
||||
<SingleChoice
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
disabled={disabled}
|
||||
renderOption={(option, isSelected) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="mb-6 cursor-pointer"
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
<div
|
||||
className="w-[1431px] h-[156px] flex items-center px-16 text-[28px] leading-snug text-[#fdf5e6] bg-cover bg-center bg-no-repeat transition-all duration-150"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage(
|
||||
isSelected
|
||||
? "components/quiz/选中"
|
||||
: "components/quiz/未选择"
|
||||
),
|
||||
filter: isSelected ? undefined : "brightness(0.96)",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isSelected || disabled) return;
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundImage =
|
||||
getBackgroundImage("components/quiz/选中");
|
||||
(e.currentTarget as HTMLDivElement).style.filter = "";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isSelected || disabled) return;
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundImage =
|
||||
getBackgroundImage("components/quiz/未选择");
|
||||
(e.currentTarget as HTMLDivElement).style.filter =
|
||||
"brightness(0.96)";
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-6 text-center text-[26px] text-[#ff4d4f]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleChoiceQuizPanel;
|
||||
@@ -0,0 +1,13 @@
|
||||
// 新组件
|
||||
export { default as SingleChoiceQuizPanel } from "./SingleChoiceQuizPanel";
|
||||
export { default as MultipleChoiceQuizPanel } from "./MultipleChoiceQuizPanel";
|
||||
export { default as DragQuizPanel } from "./DragQuizPanel";
|
||||
export type {
|
||||
SingleChoiceQuizPanelProps,
|
||||
MultipleChoiceQuizPanelProps,
|
||||
DragQuizPanelProps,
|
||||
} from "./types";
|
||||
|
||||
// 保留旧组件以保持向后兼容
|
||||
export { default as QuizPanel } from "./QuizPanal";
|
||||
export type { QuizPanelProps } from "./types";
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
SingleChoiceOption,
|
||||
MultipleChoiceOption,
|
||||
DragFillResult,
|
||||
DragFillBlank,
|
||||
} from "@/components";
|
||||
|
||||
/** 拖拽题面板配置 */
|
||||
export interface DragQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
/** 题目标题(可选) */
|
||||
title?: string;
|
||||
/** 题干前缀文本(空白区域之前的文本) */
|
||||
questionPrefix?: string;
|
||||
/** 题干后缀文本(空白区域之后的文本) */
|
||||
questionSuffix?: string;
|
||||
/** 选项列表 */
|
||||
options: { value: string; label: string }[];
|
||||
/** 空白区域列表 */
|
||||
blanks: DragFillBlank[];
|
||||
/** 当前值 */
|
||||
value: DragFillResult[];
|
||||
/** 错误提示信息 */
|
||||
error?: string;
|
||||
/** 值变化回调 */
|
||||
onChange: (value: DragFillResult[]) => void;
|
||||
}
|
||||
|
||||
/** 单选题面板配置 */
|
||||
export interface SingleChoiceQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
/** 题目 */
|
||||
title: string;
|
||||
/** 选项列表 */
|
||||
options: SingleChoiceOption[];
|
||||
/** 正确答案(可选,用于验证) */
|
||||
correctAnswer?: string;
|
||||
/** 自定义错误提示信息(答案错误时显示) */
|
||||
errorMessage?: string;
|
||||
/** 提交回调,返回用户选择和是否正确 */
|
||||
onSubmit?: (value: string, isCorrect: boolean) => void;
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 多选题面板配置 */
|
||||
export interface MultipleChoiceQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
/** 题目 */
|
||||
title: string;
|
||||
/** 选项列表 */
|
||||
options: MultipleChoiceOption[];
|
||||
/** 正确答案数组(可选,用于验证) */
|
||||
correctAnswer?: string[];
|
||||
/** 自定义错误提示信息(答案错误时显示) */
|
||||
errorMessage?: string;
|
||||
/** 自定义最小选择数量不足时的提示信息 */
|
||||
minSelectionsErrorMessage?: string;
|
||||
/** 提交回调,返回用户选择和是否正确 */
|
||||
onSubmit?: (values: string[], isCorrect: boolean) => void;
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 最小选择数量 */
|
||||
minSelections?: number;
|
||||
/** 最大选择数量 */
|
||||
maxSelections?: number;
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
}
|
||||
|
||||
/** @deprecated 使用 SingleChoiceQuizPanelProps 替代 */
|
||||
export interface QuizPanelProps {
|
||||
visible: boolean;
|
||||
zIndex?: number;
|
||||
title: string;
|
||||
options: SingleChoiceOption[];
|
||||
value: string;
|
||||
error?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Image } from "@/components";
|
||||
import { getStaticImage, getBackgroundImage } from "@/lib/utils";
|
||||
import type { MouseEvent } from "react";
|
||||
|
||||
export interface SuccessAlertProps {
|
||||
/** 标题 */
|
||||
title?: string;
|
||||
/** 内容文本 */
|
||||
content?: string;
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
/** 继续按钮回调 */
|
||||
onContinue?: () => void;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SuccessAlert 组件 - 成功提示弹窗
|
||||
* 使用 components/正确版 作为背景
|
||||
*/
|
||||
const SuccessAlert = ({
|
||||
title = "恭喜你, 选择正确!",
|
||||
content,
|
||||
visible = true,
|
||||
onMaskClick,
|
||||
onContinue,
|
||||
onClose,
|
||||
}: SuccessAlertProps) => {
|
||||
if (!visible) return null;
|
||||
|
||||
const handleContinue = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// 只调用 onContinue,它会处理关闭逻辑
|
||||
if (onContinue) {
|
||||
onContinue();
|
||||
} else if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
|
||||
{/* 蒙版背景 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage("components/蒙版遮罩"),
|
||||
}}
|
||||
onClick={onMaskClick || onClose}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<div
|
||||
className="relative w-full h-full z-10 flex flex-col items-center justify-center pointer-events-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 文本内容 */}
|
||||
<div className="relative z-10 w-full h-[619px] flex flex-col items-center justify-center gap-8 pt-20 pointer-events-none">
|
||||
{/* 背景图片 - 使用 components/正确版 */}
|
||||
<img
|
||||
className="absolute inset-0 top-0 -z-10"
|
||||
src={getStaticImage("components/正确版")}
|
||||
alt="正确版"
|
||||
/>
|
||||
{/* 标题 */}
|
||||
{title && (
|
||||
<div className="text-[#ffbe57] text-6xl font-bold text-center">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容文本 */}
|
||||
{content && (
|
||||
<div className="text-white text-4xl font-medium max-w-[1600px] leading-relaxed px-20">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 继续按钮 */}
|
||||
<div className="mt-8 relative z-20 pointer-events-auto">
|
||||
<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={handleContinue}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuccessAlert;
|
||||
Reference in new issue
Block a user