feat: 完善黄炎培体悟

This commit is contained in:
zhongyidian committed 2025-12-31 17:48:23 +08:00
1 parent 66f460633a
commit bf4a98bf9f
6 files changed
+888 -49

No files matched your search

@@ -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;