feat: 完善黄炎培体悟
This commit is contained in:
1 parent
66f460633a
commit
bf4a98bf9f
6 files changed
+888
-49
No files matched your search
@@ -1,22 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { Title, Image, Cursor, ExhibitOverlay } from "@/components";
|
||||
import { Title, Image, Cursor } from "@/components";
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import ViseAssemblyDialog from "./scene3/ViseAssemblyDialog";
|
||||
|
||||
const starName = "huangyanpei";
|
||||
|
||||
const exhibitSrcMap: Record<1, string> = {
|
||||
// 预留展品图片路径:请在 images 中放置对应资源
|
||||
1: "huangyanpei/reflection/scene3/11_002",
|
||||
};
|
||||
|
||||
const HuangyanpeiReflectionScene3 = () => {
|
||||
const navigate = useNavigate();
|
||||
const { moduleName } = useParams<{
|
||||
moduleName: string;
|
||||
}>();
|
||||
|
||||
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [visited, setVisited] = useState<Set<number>>(new Set());
|
||||
|
||||
if (!moduleName) {
|
||||
@@ -58,26 +54,23 @@ const HuangyanpeiReflectionScene3 = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 互动展品光标:后续可替换为拖拽/选择类互动 */}
|
||||
{/* 互动展品光标:组装台钳互动 */}
|
||||
<Cursor
|
||||
className="absolute left-96 top-90"
|
||||
onClick={() => setActiveExhibitId(1)}
|
||||
className="absolute right-100 bottom-55"
|
||||
onClick={() => setShowDialog(true)}
|
||||
read={visited.has(1)}
|
||||
/>
|
||||
|
||||
{/* 展品遮罩:当前使用单张图片,占位为后续互动内容 */}
|
||||
<ExhibitOverlay
|
||||
visible={activeExhibitId !== null}
|
||||
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
|
||||
onClose={() => {
|
||||
if (activeExhibitId !== null) {
|
||||
setVisited((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(activeExhibitId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setActiveExhibitId(null);
|
||||
{/* 组装台钳互动弹窗 */}
|
||||
<ViseAssemblyDialog
|
||||
visible={showDialog}
|
||||
onClose={() => setShowDialog(false)}
|
||||
onComplete={() => {
|
||||
setVisited((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(1);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn, getStaticImage, getPublicPath } from "@/lib/utils";
|
||||
import { Image, Video, DragFill } from "@/components";
|
||||
import type { DragFillResult } from "@/components";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import type { VideoRef } from "@/components/video";
|
||||
|
||||
export interface ViseAssemblyDialogProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 完成回调 */
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
type PartType = "A" | "B" | "C";
|
||||
|
||||
/**
|
||||
* 组装台钳互动弹窗
|
||||
* - 顶部三个位置用于放置零件
|
||||
* - 底部三个零件(A、B、C)可以选择
|
||||
* - 答题成功后播放视频
|
||||
*/
|
||||
const ViseAssemblyDialog = ({
|
||||
visible = true,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: ViseAssemblyDialogProps) => {
|
||||
const maskSrc = getStaticImage("components/蒙版遮罩");
|
||||
const backSrc = getStaticImage("components/关闭");
|
||||
|
||||
// 零件背景提示素材
|
||||
const partHintA = getStaticImage("huangyanpei/reflection/scene3/11_005");
|
||||
const partHintB = getStaticImage("huangyanpei/reflection/scene3/11_006");
|
||||
const partHintC = getStaticImage("huangyanpei/reflection/scene3/c");
|
||||
|
||||
// 零件素材
|
||||
const partA = getStaticImage("huangyanpei/reflection/scene3/11_010");
|
||||
const partB = getStaticImage("huangyanpei/reflection/scene3/11_011");
|
||||
const partC = getStaticImage("huangyanpei/reflection/scene3/11_012");
|
||||
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [value, setValue] = useState<DragFillResult[]>([]);
|
||||
const [videoVisible, setVideoVisible] = useState(false);
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
const completedRef = useRef(false);
|
||||
|
||||
// SSR 兼容
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 重置游戏状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setValue([]);
|
||||
completedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 检查是否完成
|
||||
useEffect(() => {
|
||||
if (value.length === 3 && !completedRef.current) {
|
||||
const slot0 = value.find((r) => r.blankId === "slot0");
|
||||
const slot1 = value.find((r) => r.blankId === "slot1");
|
||||
const slot2 = value.find((r) => r.blankId === "slot2");
|
||||
|
||||
// 验证:A->slot0, B->slot1, C->slot2
|
||||
const isCorrect =
|
||||
slot0?.optionValue === "A" &&
|
||||
slot1?.optionValue === "B" &&
|
||||
slot2?.optionValue === "C";
|
||||
|
||||
if (isCorrect) {
|
||||
completedRef.current = true;
|
||||
// 答题成功,播放视频
|
||||
setVideoVisible(true);
|
||||
} else {
|
||||
// 答题失败,清除错误的填充
|
||||
setTimeout(() => {
|
||||
setValue([]);
|
||||
}, 1500);
|
||||
alert("组装顺序不正确,请重新尝试!");
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(e: React.MouseEvent<HTMLImageElement>) => {
|
||||
e.stopPropagation();
|
||||
if (!videoVisible) {
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onClose, videoVisible]
|
||||
);
|
||||
|
||||
// 转换为 DragFill 的 options
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{ value: "A", label: "A" },
|
||||
{ value: "B", label: "B" },
|
||||
{ value: "C", label: "C" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// 转换为 DragFill 的 blanks
|
||||
const blanks = useMemo(
|
||||
() => [
|
||||
{ id: "slot0", label: "位置1" },
|
||||
{ id: "slot1", label: "位置2" },
|
||||
{ id: "slot2", label: "位置3" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// 处理拖拽结果变化
|
||||
const handleValueChange = useCallback((newValue: DragFillResult[]) => {
|
||||
// 验证:只允许 A->slot0, B->slot1, C->slot2
|
||||
const filteredValue = newValue.filter((result) => {
|
||||
const expectedSlot =
|
||||
result.optionValue === "A"
|
||||
? "slot0"
|
||||
: result.optionValue === "B"
|
||||
? "slot1"
|
||||
: "slot2";
|
||||
return result.blankId === expectedSlot;
|
||||
});
|
||||
setValue(filteredValue);
|
||||
}, []);
|
||||
|
||||
const handleVideoEnd = useCallback(() => {
|
||||
setVideoVisible(false);
|
||||
onComplete?.();
|
||||
onClose?.();
|
||||
}, [onComplete, onClose]);
|
||||
|
||||
// 样式
|
||||
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="vise-assembly-dialog"
|
||||
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 justify-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 && !videoVisible && (
|
||||
<Image
|
||||
src={backSrc}
|
||||
onClick={handleClose}
|
||||
alt="返回"
|
||||
className="absolute top-5 right-5 z-20"
|
||||
button
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 视频播放层 */}
|
||||
{videoVisible ? (
|
||||
<div className="fixed inset-0 z-[10000] bg-black">
|
||||
<Video
|
||||
ref={videoRef}
|
||||
src={getPublicPath("video/huangyanpei/huangyanpei_11.mp4")}
|
||||
className="w-full h-full"
|
||||
autoPlay
|
||||
playsInline
|
||||
preload="auto"
|
||||
showSkipButton
|
||||
skipText="跳过"
|
||||
onEnded={handleVideoEnd}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* 内容区域 */
|
||||
<div className="relative z-10 w-full h-full flex flex-col items-center px-16 pt-30 pb-8">
|
||||
{/* 顶部三个位置 - 更大的框 */}
|
||||
<div className="w-full flex items-center justify-between">
|
||||
{[0, 1, 2].map((index) => {
|
||||
const blankId = `slot${index}`;
|
||||
const filled = value.find((r) => r.blankId === blankId);
|
||||
const partType = filled
|
||||
? (filled.optionValue as PartType)
|
||||
: null;
|
||||
const hasPart = partType !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
data-blank-id={blankId}
|
||||
className={cn(
|
||||
"relative w-140 h-90 border-4 border-white rounded-lg transition-all duration-200",
|
||||
hasPart
|
||||
? "bg-white/30"
|
||||
: "bg-white/20 hover:bg-white/30"
|
||||
)}
|
||||
>
|
||||
{/* 背景提示 */}
|
||||
{!hasPart && index === 0 && (
|
||||
<img
|
||||
src={partHintA}
|
||||
alt="位置A提示"
|
||||
className="absolute inset-0 w-full h-full object-contain opacity-40 p-4"
|
||||
/>
|
||||
)}
|
||||
{!hasPart && index === 1 && (
|
||||
<img
|
||||
src={partHintB}
|
||||
alt="位置B提示"
|
||||
className="absolute inset-0 w-full h-full object-contain opacity-40 p-4"
|
||||
/>
|
||||
)}
|
||||
{!hasPart && index === 2 && (
|
||||
<img
|
||||
src={partHintC}
|
||||
alt="位置C提示"
|
||||
className="absolute inset-0 w-full h-full object-contain opacity-40 p-4"
|
||||
/>
|
||||
)}
|
||||
{/* 已放置的零件 */}
|
||||
{partType === "A" && (
|
||||
<img
|
||||
src={partA}
|
||||
alt="零件A"
|
||||
className="absolute inset-0 w-full h-full object-contain p-4"
|
||||
/>
|
||||
)}
|
||||
{partType === "B" && (
|
||||
<img
|
||||
src={partB}
|
||||
alt="零件B"
|
||||
className="absolute inset-0 w-full h-full object-contain p-4"
|
||||
/>
|
||||
)}
|
||||
{partType === "C" && (
|
||||
<img
|
||||
src={partC}
|
||||
alt="零件C"
|
||||
className="absolute inset-0 w-full h-full object-contain p-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 中间的"拼接台钳"按钮 */}
|
||||
<button
|
||||
disabled={value.length < 3}
|
||||
className="mt-8 px-20 py-3 bg-yellow-400 border-8 border-white rounded-xl text-[#432800] text-5xl font-bold cursor-pointer transition-all duration-200 hover:bg-yellow-500 hover:scale-105 active:scale-95 disabled:bg-white/50 disabled:cursor-not-allowed"
|
||||
>
|
||||
拼接台钳
|
||||
</button>
|
||||
|
||||
{/* 底部零件选择区域 - 使用 DragFill */}
|
||||
<div className="w-full gap-16">
|
||||
<div className="text-[#432800] text-5xl font-bold mr-4 my-10">
|
||||
零件区
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<DragFill
|
||||
options={options}
|
||||
blanks={blanks}
|
||||
value={value}
|
||||
onChange={handleValueChange}
|
||||
allowDuplicate={false}
|
||||
layout="horizontal-options"
|
||||
hideLabels={true}
|
||||
showSubmitButton={false}
|
||||
className="w-full"
|
||||
renderOption={(option, isUsed) => {
|
||||
const partSrc =
|
||||
option.value === "A"
|
||||
? partA
|
||||
: option.value === "B"
|
||||
? partB
|
||||
: partC;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center transition-all duration-200",
|
||||
"cursor-move select-none",
|
||||
isUsed
|
||||
? "opacity-30 cursor-not-allowed"
|
||||
: "hover:scale-110 opacity-100"
|
||||
)}
|
||||
>
|
||||
<div className="mr-16 text-8xl font-bold text-[#cf9e51] [text-shadow:_3px_3px_0_#fff,_-3px_-3px_0_#fff,_3px_-3px_0_#fff,_-3px_3px_0_#fff]">
|
||||
{option.value}
|
||||
</div>
|
||||
<img
|
||||
src={partSrc}
|
||||
alt={`零件${option.value}`}
|
||||
className="w-56 h-56 object-contain pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
renderDragging={(option) => {
|
||||
const partSrc =
|
||||
option.value === "A"
|
||||
? partA
|
||||
: option.value === "B"
|
||||
? partB
|
||||
: partC;
|
||||
return (
|
||||
<div className="w-56 h-56">
|
||||
<img
|
||||
src={partSrc}
|
||||
alt={`零件${option.value}`}
|
||||
className="w-full h-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
renderBlank={() => {
|
||||
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
|
||||
return <div className="hidden" />;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default ViseAssemblyDialog;
|
||||
@@ -1,30 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import { Title, Image, Cursor, ExhibitOverlay } from "@/components";
|
||||
import { useState, useCallback } from "react";
|
||||
import { Title, Image, Cursor } from "@/components";
|
||||
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
|
||||
import { SuccessAlert } from "@/components/layout";
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import TowelSalesDialog from "./scene4/TowelSalesDialog";
|
||||
|
||||
const starName = "huangyanpei";
|
||||
|
||||
const exhibitSrcMap: Record<1, string> = {
|
||||
// 预留展品图片路径:请在 images 中放置对应资源
|
||||
1: "huangyanpei/reflection/scene4/12_002",
|
||||
};
|
||||
|
||||
const HuangyanpeiReflectionScene4 = () => {
|
||||
const navigate = useNavigate();
|
||||
const { moduleName } = useParams<{
|
||||
moduleName: string;
|
||||
}>();
|
||||
|
||||
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null);
|
||||
const [_activeExhibitId, _setActiveExhibitId] = useState<1 | 2 | null>(null);
|
||||
const [visited, setVisited] = useState<Set<number>>(new Set());
|
||||
const [showQuiz1, setShowQuiz1] = useState(false);
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||
const [showTowelDialog, setShowTowelDialog] = useState(false);
|
||||
|
||||
// 处理成功提示的继续和关闭
|
||||
const handleSuccessAlertContinue = useCallback(() => {
|
||||
setShowSuccessAlert(false);
|
||||
// 更新visited状态
|
||||
setVisited((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSuccessAlertClose = useCallback(() => {
|
||||
setShowSuccessAlert(false);
|
||||
// 更新visited状态
|
||||
setVisited((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!moduleName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const progressCount = visited.size;
|
||||
const total = 1;
|
||||
const total = 2;
|
||||
const completed = progressCount >= total;
|
||||
const subtitle = completed
|
||||
? "探索中华职业学院·立业之本·商科(已完成)"
|
||||
@@ -58,26 +80,77 @@ const HuangyanpeiReflectionScene4 = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 互动展品光标:后续可替换为拖拽/选择类互动 */}
|
||||
{/* 互动展品光标1 */}
|
||||
<Cursor
|
||||
className="absolute left-100 top-96"
|
||||
onClick={() => setActiveExhibitId(1)}
|
||||
className="absolute left-100 bottom-88"
|
||||
onClick={() => setShowQuiz1(true)}
|
||||
read={visited.has(1)}
|
||||
/>
|
||||
|
||||
{/* 展品遮罩:当前使用单张图片,占位为后续互动内容 */}
|
||||
<ExhibitOverlay
|
||||
visible={activeExhibitId !== null}
|
||||
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
|
||||
onClose={() => {
|
||||
if (activeExhibitId !== null) {
|
||||
setVisited((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(activeExhibitId);
|
||||
return next;
|
||||
});
|
||||
{/* 互动展品光标2 */}
|
||||
<Cursor
|
||||
className="absolute right-82 bottom-88"
|
||||
onClick={() => setShowTowelDialog(true)}
|
||||
read={visited.has(2)}
|
||||
/>
|
||||
|
||||
{/* 选择题1:中华职校商科的核心教学目标 */}
|
||||
<MultipleChoiceQuizPanel
|
||||
visible={showQuiz1}
|
||||
title={`题目 1 (多选题)\n\n中华职校商科的核心教学目标是什么? (多选)`}
|
||||
options={[
|
||||
{
|
||||
value: "A",
|
||||
label: "A. 教会学生投机倒把",
|
||||
},
|
||||
{
|
||||
value: "B",
|
||||
label: "B. 培养诚信经营人才",
|
||||
},
|
||||
{
|
||||
value: "C",
|
||||
label: "C. 推广国货支援实业",
|
||||
},
|
||||
{
|
||||
value: "D",
|
||||
label: "D. 只为个人发财致富",
|
||||
},
|
||||
]}
|
||||
correctAnswer={["B", "C"]}
|
||||
errorMessage='不对哦! 黄先生倡导"商科要为民族工商业服务", 商科是要培养诚信经营、助力国货发展的人才, 可不能想着投机发财呀, 再选一次;'
|
||||
minSelections={1}
|
||||
onSubmit={(values: string[], isCorrect: boolean) => {
|
||||
if (isCorrect) {
|
||||
// 答题成功:关闭选择题,显示成功提示
|
||||
setShowQuiz1(false);
|
||||
setShowSuccessAlert(true);
|
||||
}
|
||||
setActiveExhibitId(null);
|
||||
}}
|
||||
onError={(_error: string) => {
|
||||
// 答题错误时的处理(组件内部已显示错误信息)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 答题成功提示 */}
|
||||
<SuccessAlert
|
||||
visible={showSuccessAlert}
|
||||
title="满分!"
|
||||
content="诚信是经商之本, 推广国货是商科学生的责任, 这正是黄先生的办学初衷。"
|
||||
onContinue={handleSuccessAlertContinue}
|
||||
onClose={handleSuccessAlertClose}
|
||||
/>
|
||||
|
||||
{/* 毛巾推销语拖拽对话框 */}
|
||||
<TowelSalesDialog
|
||||
visible={showTowelDialog}
|
||||
onClose={() => setShowTowelDialog(false)}
|
||||
onComplete={() => {
|
||||
// 完成拖拽后更新visited状态
|
||||
setVisited((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(2);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
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 } from "@/components";
|
||||
import type { DragFillResult } from "@/components";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
|
||||
export interface TowelSalesDialogProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 完成回调 */
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 毛巾推销语拖拽弹窗
|
||||
* - 左边显示4个可选话术
|
||||
* - 右边显示3个毛巾(堆叠显示)
|
||||
* - 拖拽话术到毛巾上,点亮对应的毛巾
|
||||
* - 3个都点亮后完成
|
||||
*/
|
||||
const TowelSalesDialog = ({
|
||||
visible = true,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: TowelSalesDialogProps) => {
|
||||
const maskSrc = getStaticImage("components/蒙版遮罩");
|
||||
const backSrc = getStaticImage("components/关闭");
|
||||
|
||||
// 毛巾图片资源(未点亮)
|
||||
const towel1 = getStaticImage("huangyanpei/reflection/scene4/12_002");
|
||||
const towel2 = getStaticImage("huangyanpei/reflection/scene4/12_003");
|
||||
const towel3 = getStaticImage("huangyanpei/reflection/scene4/12_004");
|
||||
|
||||
// 毛巾图片资源(点亮)
|
||||
const towel1Lit = getStaticImage("huangyanpei/reflection/scene4/12_002_02");
|
||||
const towel2Lit = getStaticImage("huangyanpei/reflection/scene4/12_003_02");
|
||||
const towel3Lit = getStaticImage("huangyanpei/reflection/scene4/12_004_03");
|
||||
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [value, setValue] = useState<DragFillResult[]>([]);
|
||||
const completedRef = useRef(false);
|
||||
|
||||
// SSR 兼容
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 重置游戏状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setValue([]);
|
||||
completedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 检查是否完成(3个毛巾都点亮)
|
||||
useEffect(() => {
|
||||
if (value.length === 3 && !completedRef.current) {
|
||||
completedRef.current = true;
|
||||
// 延迟一点时间让用户看到效果,然后完成
|
||||
setTimeout(() => {
|
||||
onComplete?.();
|
||||
onClose?.();
|
||||
}, 500);
|
||||
}
|
||||
}, [value, onComplete, onClose]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(e: React.MouseEvent<HTMLImageElement>) => {
|
||||
e.stopPropagation();
|
||||
onClose?.();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
// 可选话术选项
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: "option1",
|
||||
label: "这是中华职校生产的国货,质量比洋货还好!",
|
||||
},
|
||||
{
|
||||
value: "option2",
|
||||
label: "价格便宜,量大从优,诚信经营绝不掺假",
|
||||
},
|
||||
{
|
||||
value: "option3",
|
||||
label: "买国货就是支持实业救国,为国家出一份力!",
|
||||
},
|
||||
{
|
||||
value: "option4",
|
||||
label: "我只管赚钱,东西好不好不管哦",
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// 处理拖拽结果变化
|
||||
const handleValueChange = useCallback((newValue: DragFillResult[]) => {
|
||||
// 限制最多3个
|
||||
if (newValue.length <= 3) {
|
||||
setValue(newValue);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 检查毛巾是否被点亮
|
||||
const isTowelLit = useCallback(
|
||||
(towelId: string) => {
|
||||
return value.some((result) => result.blankId === towelId);
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
// 拖拽悬停状态
|
||||
const [hoveredTowel, setHoveredTowel] = useState<string | null>(null);
|
||||
|
||||
// 样式
|
||||
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="towel-sales-dialog"
|
||||
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", "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 items-center justify-center px-30 py-16 pt-30 text-[#432800]">
|
||||
<div className="w-full h-full">
|
||||
{/* 左侧:题目和可选话术 */}
|
||||
<div className="flex justify-between gap-10">
|
||||
{/* 题目文本 */}
|
||||
<div className="mb-8">
|
||||
<div className="text-5xl font-bold mb-6">题目文本</div>
|
||||
<div className="text-4xl leading-relaxed">
|
||||
从以下话术里,挑选3句组成你的推销语 (拖拽至对话框)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:毛巾区域 */}
|
||||
<div className="w-180 flex flex-col items-center">
|
||||
{/* 答题区标签 */}
|
||||
{/* <div className="text-4xl font-bold text-white mb-8">
|
||||
答题区
|
||||
</div> */}
|
||||
|
||||
{/* 毛巾堆叠区域 */}
|
||||
<div className="relative w-full flex flex-col ">
|
||||
{/* 毛巾1 */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
|
||||
// isTowelLit("towel1")
|
||||
// ? "opacity-100 border-[#f0be75]"
|
||||
// : hoveredTowel === "towel1"
|
||||
// ? "opacity-100 border-[#f0be75] bg-[#f0be75]/20"
|
||||
// : "opacity-100 border-transparent"
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (!isTowelLit("towel1")) {
|
||||
setHoveredTowel("towel1");
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setHoveredTowel(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setHoveredTowel(null);
|
||||
const optionValue =
|
||||
e.dataTransfer.getData("text/plain");
|
||||
if (optionValue && !isTowelLit("towel1")) {
|
||||
// 检查是否已经使用过这个选项
|
||||
const alreadyUsed = value.some(
|
||||
(r) => r.optionValue === optionValue
|
||||
);
|
||||
if (!alreadyUsed) {
|
||||
handleValueChange([
|
||||
...value,
|
||||
{ blankId: "towel1", optionValue },
|
||||
]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={isTowelLit("towel1") ? towel1Lit : towel1}
|
||||
alt="毛巾1"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 毛巾2 */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (!isTowelLit("towel2")) {
|
||||
setHoveredTowel("towel2");
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setHoveredTowel(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setHoveredTowel(null);
|
||||
const optionValue =
|
||||
e.dataTransfer.getData("text/plain");
|
||||
if (optionValue && !isTowelLit("towel2")) {
|
||||
// 检查是否已经使用过这个选项
|
||||
const alreadyUsed = value.some(
|
||||
(r) => r.optionValue === optionValue
|
||||
);
|
||||
if (!alreadyUsed) {
|
||||
handleValueChange([
|
||||
...value,
|
||||
{ blankId: "towel2", optionValue },
|
||||
]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={isTowelLit("towel2") ? towel2Lit : towel2}
|
||||
alt="毛巾2"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 毛巾3 */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (!isTowelLit("towel3")) {
|
||||
setHoveredTowel("towel3");
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setHoveredTowel(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setHoveredTowel(null);
|
||||
const optionValue =
|
||||
e.dataTransfer.getData("text/plain");
|
||||
if (optionValue && !isTowelLit("towel3")) {
|
||||
// 检查是否已经使用过这个选项
|
||||
const alreadyUsed = value.some(
|
||||
(r) => r.optionValue === optionValue
|
||||
);
|
||||
if (!alreadyUsed) {
|
||||
handleValueChange([
|
||||
...value,
|
||||
{ blankId: "towel3", optionValue },
|
||||
]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={isTowelLit("towel3") ? towel3Lit : towel3}
|
||||
alt="毛巾3"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 可选话术区域 */}
|
||||
<div className="flex-1">
|
||||
<div className="text-5xl font-bold mb-6">答题区</div>
|
||||
<div className="grid grid-cols-2 gap-y-8 gap-x-26 mt-12">
|
||||
{options.map((option, index) => {
|
||||
const isUsed = value.some(
|
||||
(r) => r.optionValue === option.value
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
" px-10 py-10 rounded-2xl border-3 text-3xl font-medium transition-all",
|
||||
isUsed
|
||||
? "border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50"
|
||||
: "border-[#79471f] bg-black/20 to-[#3a2f25] text-white cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)]"
|
||||
)}
|
||||
draggable={!isUsed}
|
||||
onDragStart={(e) => {
|
||||
if (isUsed) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.dataTransfer.setData(
|
||||
"text/plain",
|
||||
option.value
|
||||
);
|
||||
}}
|
||||
>
|
||||
{index + 1}. {option.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default TowelSalesDialog;
|
||||
Reference in new issue
Block a user