feat: 更新 ESLint 和 Prettier 配置,增强代码质量管理
- 在 .gitignore 中添加 public/audio/* 以忽略音频文件。 - 更新 bun.lock,添加 eslint-config-prettier 和 eslint-plugin-prettier 依赖,确保 ESLint 与 Prettier 的兼容性。 - 修改 eslint.config.ts,整合 Prettier 规则,优化代码风格一致性。 - 在 package.json 中添加 lint:fix 和 format 脚本,简化代码格式化和修复流程。 - 更新 tailwind.config.ts 和其他组件文件,统一代码风格,提升可读性。
This commit is contained in:
1 parent
5d00adc1a5
commit
76c67b492e
216 files changed
+12454
-13976
No files matched your search
@@ -1,21 +1,21 @@
|
||||
import { Outlet, useMatches, useLocation } from "react-router-dom";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { getPublicPath, getStaticImage } from "@/lib/utils";
|
||||
import type { VideoRouteHandle } from "@/router/types";
|
||||
import Video, { type VideoRef } from "@/components/video";
|
||||
import { VideoProvider } from "@/contexts/VideoContext";
|
||||
import { PuzzleBoxProvider, usePuzzleBox } from "@/contexts/PuzzleBoxContext";
|
||||
import { PuzzleAnimationProvider } from "@/contexts/PuzzleAnimationContext";
|
||||
import { Image, PuzzleSystem, flyToAnimation } from "@/components";
|
||||
import { usePuzzleStore } from "@/store/puzzleStore";
|
||||
import PuzzlePiecePortal from "@/components/puzzle/PuzzlePiecePortal";
|
||||
import { Outlet, useMatches, useLocation } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import type { VideoRouteHandle } from '@/router/types'
|
||||
import Video, { type VideoRef } from '@/components/video'
|
||||
import { VideoProvider } from '@/contexts/VideoContext'
|
||||
import { PuzzleBoxProvider, usePuzzleBox } from '@/contexts/PuzzleBoxContext'
|
||||
import { PuzzleAnimationProvider } from '@/contexts/PuzzleAnimationContext'
|
||||
import { Image, PuzzleSystem, flyToAnimation } from '@/components'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
import PuzzlePiecePortal from '@/components/puzzle/PuzzlePiecePortal'
|
||||
|
||||
/**
|
||||
* 支持拼图盒功能的故居列表
|
||||
* 后续如需为其他故居添加拼图盒,只需在此数组中添加对应的故居名称即可
|
||||
*/
|
||||
const RESIDENCES_WITH_PUZZLE = ["chenyun", "huangyanpei","sunzhongshan","luxun"] as const;
|
||||
const RESIDENCES_WITH_PUZZLE = ['chenyun', 'huangyanpei', 'sunzhongshan', 'luxun'] as const
|
||||
|
||||
/**
|
||||
* 故居布局组件
|
||||
@@ -24,41 +24,41 @@ const RESIDENCES_WITH_PUZZLE = ["chenyun", "huangyanpei","sunzhongshan","luxun"]
|
||||
* 渲染子路由内容
|
||||
*/
|
||||
const ResidenceLayoutContent = () => {
|
||||
const matches = useMatches();
|
||||
const location = useLocation();
|
||||
const { puzzleBoxRef } = usePuzzleBox();
|
||||
const { unlockPuzzle, isPuzzleUnlocked } = usePuzzleStore();
|
||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
const [isVideoFadingOut, setIsVideoFadingOut] = useState(false);
|
||||
const [isPuzzleSystemVisible, setIsPuzzleSystemVisible] = useState(false);
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
const previousPathRef = useRef<string>("");
|
||||
const matches = useMatches()
|
||||
const location = useLocation()
|
||||
const { puzzleBoxRef } = usePuzzleBox()
|
||||
const { unlockPuzzle, isPuzzleUnlocked } = usePuzzleStore()
|
||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false)
|
||||
const [isContentVisible, setIsContentVisible] = useState(false)
|
||||
const [isVideoFadingOut, setIsVideoFadingOut] = useState(false)
|
||||
const [isPuzzleSystemVisible, setIsPuzzleSystemVisible] = useState(false)
|
||||
const videoRef = useRef<VideoRef>(null)
|
||||
const previousPathRef = useRef<string>('')
|
||||
|
||||
// 碎片动画相关状态
|
||||
const [showPuzzlePiece, setShowPuzzlePiece] = useState(false);
|
||||
const [puzzlePieceId, setPuzzlePieceId] = useState<number | null>(null);
|
||||
const [isAnimationPlaying, setIsAnimationPlaying] = useState(false);
|
||||
const [isFlying, setIsFlying] = useState(false); // 标记是否正在飞行
|
||||
const puzzlePieceRef = useRef<HTMLDivElement>(null);
|
||||
const animationInProgressRef = useRef<boolean>(false); // 防止重复执行动画
|
||||
const [showPuzzlePiece, setShowPuzzlePiece] = useState(false)
|
||||
const [puzzlePieceId, setPuzzlePieceId] = useState<number | null>(null)
|
||||
const [isAnimationPlaying, setIsAnimationPlaying] = useState(false)
|
||||
const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行
|
||||
const puzzlePieceRef = useRef<HTMLDivElement>(null)
|
||||
const animationInProgressRef = useRef<boolean>(false) // 防止重复执行动画
|
||||
|
||||
// 从路径中提取故居名称(starName),如 'chenyun', 'sunzhongshan' 等
|
||||
// 路径格式:/:moduleName/:starName/:page
|
||||
const residence = useMemo(() => {
|
||||
const pathParts = location.pathname.split("/").filter(Boolean);
|
||||
const pathParts = location.pathname.split('/').filter(Boolean)
|
||||
// 路径格式:moduleName/starName/page,所以 starName 是第二个部分
|
||||
if (pathParts.length >= 2) {
|
||||
return pathParts[1];
|
||||
return pathParts[1]
|
||||
}
|
||||
return "";
|
||||
}, [location.pathname]);
|
||||
return ''
|
||||
}, [location.pathname])
|
||||
|
||||
// 判断当前故居是否支持拼图盒功能
|
||||
const hasPuzzleFeature = useMemo(
|
||||
() => RESIDENCES_WITH_PUZZLE.includes(residence as any),
|
||||
[residence]
|
||||
);
|
||||
)
|
||||
|
||||
// 动画配置常量
|
||||
const ANIMATION_CONFIG = {
|
||||
@@ -69,221 +69,207 @@ const ResidenceLayoutContent = () => {
|
||||
MAX_RETRIES: 3, // 最大重试次数
|
||||
SCALE: { from: 1, to: 0.25 }, // 缩放比例(更小的目标尺寸,效果更明显)
|
||||
OPACITY: [1, 1, 0.6, 0] as number[], // 透明度变化(更平滑的淡出)
|
||||
};
|
||||
}
|
||||
|
||||
// 清理动画状态
|
||||
const cleanupAnimation = () => {
|
||||
setIsAnimationPlaying(false);
|
||||
setShowPuzzlePiece(false);
|
||||
setPuzzlePieceId(null);
|
||||
setIsFlying(false);
|
||||
animationInProgressRef.current = false;
|
||||
};
|
||||
setIsAnimationPlaying(false)
|
||||
setShowPuzzlePiece(false)
|
||||
setPuzzlePieceId(null)
|
||||
setIsFlying(false)
|
||||
animationInProgressRef.current = false
|
||||
}
|
||||
|
||||
// 执行飞行动画
|
||||
const executeFlyAnimation = (pieceId: number) => {
|
||||
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
setIsFlying(true);
|
||||
setIsFlying(true)
|
||||
|
||||
// 使用 requestAnimationFrame 确保元素已完全渲染
|
||||
requestAnimationFrame(() => {
|
||||
if (!puzzlePieceRef.current || !residence) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
flyToAnimation({
|
||||
fromRef: puzzlePieceRef,
|
||||
toRef: puzzleBoxRef,
|
||||
targetPosition: "center",
|
||||
targetPosition: 'center',
|
||||
duration: ANIMATION_CONFIG.FLY_DURATION,
|
||||
scale: ANIMATION_CONFIG.SCALE,
|
||||
opacity: ANIMATION_CONFIG.OPACITY,
|
||||
ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速
|
||||
onComplete: () => {
|
||||
if (residence) {
|
||||
unlockPuzzle(residence, pieceId);
|
||||
unlockPuzzle(residence, pieceId)
|
||||
}
|
||||
cleanupAnimation();
|
||||
cleanupAnimation()
|
||||
},
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
return true;
|
||||
};
|
||||
return true
|
||||
}
|
||||
|
||||
// 等待元素准备就绪并执行动画
|
||||
const waitForElementsAndAnimate = async (
|
||||
pieceId: number,
|
||||
retryCount = 0
|
||||
): Promise<void> => {
|
||||
const waitForElementsAndAnimate = async (pieceId: number, retryCount = 0): Promise<void> => {
|
||||
if (!residence) {
|
||||
cleanupAnimation();
|
||||
return;
|
||||
cleanupAnimation()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查元素是否已准备好
|
||||
if (puzzleBoxRef.current && puzzlePieceRef.current) {
|
||||
const success = executeFlyAnimation(pieceId);
|
||||
if (success) return;
|
||||
const success = executeFlyAnimation(pieceId)
|
||||
if (success) return
|
||||
}
|
||||
|
||||
// 如果元素未准备好且未超过最大重试次数,则重试
|
||||
if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY)
|
||||
);
|
||||
return waitForElementsAndAnimate(pieceId, retryCount + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY))
|
||||
return waitForElementsAndAnimate(pieceId, retryCount + 1)
|
||||
}
|
||||
|
||||
// 超过最大重试次数,直接解锁并清理
|
||||
console.warn("Puzzle animation: 元素未准备好,直接解锁");
|
||||
unlockPuzzle(residence, pieceId);
|
||||
cleanupAnimation();
|
||||
};
|
||||
console.warn('Puzzle animation: 元素未准备好,直接解锁')
|
||||
unlockPuzzle(residence, pieceId)
|
||||
cleanupAnimation()
|
||||
}
|
||||
|
||||
// 处理碎片动画触发(仅支持拼图功能的故居)
|
||||
const handleTriggerPuzzleAnimation = (pieceId: number) => {
|
||||
if (!residence || !hasPuzzleFeature) return;
|
||||
if (!residence || !hasPuzzleFeature) return
|
||||
|
||||
// 防止重复执行动画
|
||||
if (animationInProgressRef.current) return;
|
||||
if (animationInProgressRef.current) return
|
||||
|
||||
// 检查碎片是否已解锁
|
||||
if (isPuzzleUnlocked(residence, pieceId)) return;
|
||||
if (isPuzzleUnlocked(residence, pieceId)) return
|
||||
|
||||
// 标记动画正在进行
|
||||
animationInProgressRef.current = true;
|
||||
animationInProgressRef.current = true
|
||||
|
||||
// 开始动画流程:禁用所有操作
|
||||
setIsAnimationPlaying(true);
|
||||
setPuzzlePieceId(pieceId);
|
||||
setShowPuzzlePiece(true);
|
||||
setIsAnimationPlaying(true)
|
||||
setPuzzlePieceId(pieceId)
|
||||
setShowPuzzlePiece(true)
|
||||
|
||||
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(async () => {
|
||||
// 等待初始出现动画(旋转+放大)完成
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION)
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION))
|
||||
|
||||
// 等待发光动画显示一段时间,让用户看到碎片
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION)
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION))
|
||||
|
||||
// 等待元素准备就绪并执行飞行动画
|
||||
await waitForElementsAndAnimate(pieceId);
|
||||
});
|
||||
});
|
||||
};
|
||||
await waitForElementsAndAnimate(pieceId)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前路由的视频路径
|
||||
const videoSrc = useMemo(() => {
|
||||
const currentMatch = matches[matches.length - 1];
|
||||
return (currentMatch?.handle as VideoRouteHandle | undefined)?.videoSrc;
|
||||
}, [matches]);
|
||||
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 || "";
|
||||
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 match = path.match(/place(\d+)/)
|
||||
return match && match[1] ? parseInt(match[1], 10) : 0
|
||||
}
|
||||
|
||||
const currentPlace = getCurrentPlaceNumber(currentPath);
|
||||
const previousPlace = getCurrentPlaceNumber(previousPath);
|
||||
const currentPlace = getCurrentPlaceNumber(currentPath)
|
||||
const previousPlace = getCurrentPlaceNumber(previousPath)
|
||||
|
||||
// 如果当前场景编号大于前一个场景编号,说明是前进
|
||||
// 如果是首次加载(previousPath为空),也视为前进
|
||||
return !previousPath || currentPlace > previousPlace;
|
||||
}, [location.pathname]);
|
||||
return !previousPath || currentPlace > previousPlace
|
||||
}, [location.pathname])
|
||||
|
||||
// 处理视频播放完成(自然结束)
|
||||
const handleVideoEnded = () => {
|
||||
// 先让内容层开始淡入
|
||||
setIsContentVisible(true);
|
||||
setIsContentVisible(true)
|
||||
// 开始视频层淡出动画
|
||||
setIsVideoFadingOut(true);
|
||||
setIsVideoFadingOut(true)
|
||||
// 然后延迟移除视频层,让视频层淡出动画完成
|
||||
setTimeout(() => {
|
||||
setIsVideoPlaying(false);
|
||||
setIsVideoFadingOut(false);
|
||||
}, 500); // 与 CSS transition 时间一致
|
||||
};
|
||||
setIsVideoPlaying(false)
|
||||
setIsVideoFadingOut(false)
|
||||
}, 500) // 与 CSS transition 时间一致
|
||||
}
|
||||
|
||||
// 处理跳过操作
|
||||
const handleSkip = () => {
|
||||
// 立即暂停视频
|
||||
videoRef.current?.pause();
|
||||
videoRef.current?.pause()
|
||||
// 立即显示内容层
|
||||
setIsContentVisible(true);
|
||||
setIsContentVisible(true)
|
||||
// 立即移除视频层,不等待淡出动画
|
||||
setIsVideoPlaying(false);
|
||||
setIsVideoFadingOut(false);
|
||||
};
|
||||
setIsVideoPlaying(false)
|
||||
setIsVideoFadingOut(false)
|
||||
}
|
||||
|
||||
// 处理视频加载错误
|
||||
const handleVideoError = () => {
|
||||
// 视频加载失败,直接显示内容
|
||||
setIsVideoPlaying(false);
|
||||
setIsContentVisible(true);
|
||||
setIsVideoFadingOut(false);
|
||||
};
|
||||
setIsVideoPlaying(false)
|
||||
setIsContentVisible(true)
|
||||
setIsVideoFadingOut(false)
|
||||
}
|
||||
|
||||
// 路由变化时重置状态
|
||||
useEffect(() => {
|
||||
// 只有在前进导航且有视频时才播放视频
|
||||
if (videoSrc && isForwardNavigation) {
|
||||
// 有视频且是前进,重置状态准备播放
|
||||
setIsVideoPlaying(true);
|
||||
setIsContentVisible(false);
|
||||
setIsVideoFadingOut(false);
|
||||
setIsVideoPlaying(true)
|
||||
setIsContentVisible(false)
|
||||
setIsVideoFadingOut(false)
|
||||
} else {
|
||||
// 没有视频或是后退,直接显示内容
|
||||
setIsVideoPlaying(false);
|
||||
setIsContentVisible(true);
|
||||
setIsVideoFadingOut(false);
|
||||
setIsVideoPlaying(false)
|
||||
setIsContentVisible(true)
|
||||
setIsVideoFadingOut(false)
|
||||
}
|
||||
|
||||
// 更新前一个路径
|
||||
previousPathRef.current = location.pathname;
|
||||
}, [location.pathname, videoSrc, isForwardNavigation]);
|
||||
previousPathRef.current = location.pathname
|
||||
}, [location.pathname, videoSrc, isForwardNavigation])
|
||||
|
||||
// 当视频元素渲染后,重置并确保播放
|
||||
useEffect(() => {
|
||||
if (isVideoPlaying && videoRef.current?.video && videoSrc) {
|
||||
const video = videoRef.current.video;
|
||||
video.currentTime = 0;
|
||||
const video = videoRef.current.video
|
||||
video.currentTime = 0
|
||||
video.play().catch(() => {
|
||||
// 自动播放失败,直接显示内容
|
||||
handleVideoError();
|
||||
});
|
||||
handleVideoError()
|
||||
})
|
||||
}
|
||||
}, [isVideoPlaying, videoSrc]);
|
||||
}, [isVideoPlaying, videoSrc])
|
||||
|
||||
return (
|
||||
<VideoProvider
|
||||
isVideoPlaying={isVideoPlaying}
|
||||
isContentVisible={isContentVisible}
|
||||
>
|
||||
<PuzzleAnimationProvider
|
||||
triggerPuzzleAnimation={handleTriggerPuzzleAnimation}
|
||||
>
|
||||
<VideoProvider isVideoPlaying={isVideoPlaying} isContentVisible={isContentVisible}>
|
||||
<PuzzleAnimationProvider triggerPuzzleAnimation={handleTriggerPuzzleAnimation}>
|
||||
<div className="relative w-full h-full">
|
||||
{/* 内容层 - 始终渲染,根据状态控制显示 */}
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
isContentVisible
|
||||
? "z-50 opacity-100" // 显示时在上层
|
||||
: "z-10 opacity-0 pointer-events-none" // 隐藏时在下层且不可交互
|
||||
? 'z-50 opacity-100' // 显示时在上层
|
||||
: 'z-10 opacity-0 pointer-events-none' // 隐藏时在下层且不可交互
|
||||
} transition-opacity duration-300 ease-in`}
|
||||
>
|
||||
<Outlet />
|
||||
@@ -293,13 +279,13 @@ const ResidenceLayoutContent = () => {
|
||||
ref={puzzleBoxRef}
|
||||
className="absolute left-4 bottom-4 z-[1] cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.stopPropagation()
|
||||
if (residence) {
|
||||
setIsPuzzleSystemVisible(true);
|
||||
setIsPuzzleSystemVisible(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Image src={getStaticImage("components/拼图盒")} button />
|
||||
<Image src={getStaticImage('components/拼图盒')} button />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -309,8 +295,8 @@ const ResidenceLayoutContent = () => {
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
isVideoFadingOut
|
||||
? "z-40 opacity-0 pointer-events-none" // 淡出时移到下层
|
||||
: "z-50 opacity-100" // 播放时在上层
|
||||
? 'z-40 opacity-0 pointer-events-none' // 淡出时移到下层
|
||||
: 'z-50 opacity-100' // 播放时在上层
|
||||
} bg-black transition-opacity duration-500 ease-out`}
|
||||
>
|
||||
<Video
|
||||
@@ -337,24 +323,21 @@ const ResidenceLayoutContent = () => {
|
||||
)}
|
||||
|
||||
{/* 碎片显示 - 用于飞行动画,显示在屏幕正中间(仅支持拼图功能的故居显示) */}
|
||||
{hasPuzzleFeature &&
|
||||
showPuzzlePiece &&
|
||||
puzzlePieceId &&
|
||||
residence && (
|
||||
<PuzzlePiecePortal
|
||||
ref={puzzlePieceRef}
|
||||
residence={residence}
|
||||
pieceId={puzzlePieceId}
|
||||
hidden={isFlying}
|
||||
/>
|
||||
)}
|
||||
{hasPuzzleFeature && showPuzzlePiece && puzzlePieceId && residence && (
|
||||
<PuzzlePiecePortal
|
||||
ref={puzzlePieceRef}
|
||||
residence={residence}
|
||||
pieceId={puzzlePieceId}
|
||||
hidden={isFlying}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 动画遮罩层 - 动画播放时盖住所有元素,防止操作 */}
|
||||
{isAnimationPlaying &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100000] pointer-events-auto"
|
||||
style={{ touchAction: "none" }}
|
||||
style={{ touchAction: 'none' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.preventDefault()}
|
||||
onTouchMove={(e) => e.preventDefault()}
|
||||
@@ -365,15 +348,15 @@ const ResidenceLayoutContent = () => {
|
||||
</div>
|
||||
</PuzzleAnimationProvider>
|
||||
</VideoProvider>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const ResidenceLayout = () => {
|
||||
return (
|
||||
<PuzzleBoxProvider>
|
||||
<ResidenceLayoutContent />
|
||||
</PuzzleBoxProvider>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ResidenceLayout;
|
||||
export default ResidenceLayout
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useScale } from '@/contexts/ScaleContext'
|
||||
|
||||
export interface ScaledPortalProps {
|
||||
/** 是否启用(不启用则直接渲染 children) */
|
||||
enabled?: boolean;
|
||||
enabled?: boolean
|
||||
/** z-index 层级(建议 > ExhibitOverlay 的 9999) */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 子元素 */
|
||||
children: ReactNode;
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,43 +17,37 @@ export interface ScaledPortalProps {
|
||||
* - 使用 Portal 渲染到 document.body,避免被其他 Portal/遮罩层挡住
|
||||
* - 使用 useScale 的 offset/scale 做视觉对齐(与 ExhibitOverlay 一致)
|
||||
*/
|
||||
const ScaledPortal = ({
|
||||
enabled = true,
|
||||
zIndex = 10050,
|
||||
children,
|
||||
}: ScaledPortalProps) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const ScaledPortal = ({ enabled = true, zIndex = 10050, children }: ScaledPortalProps) => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!enabled) return <>{children}</>;
|
||||
if (!mounted) return null;
|
||||
if (!enabled) return <>{children}</>
|
||||
if (!mounted) return null
|
||||
|
||||
const scaled = (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
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",
|
||||
transformOrigin: 'top left',
|
||||
pointerEvents: 'auto',
|
||||
zIndex,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(scaled, document.body);
|
||||
};
|
||||
|
||||
export default ScaledPortal;
|
||||
)
|
||||
|
||||
return createPortal(scaled, document.body)
|
||||
}
|
||||
|
||||
export default ScaledPortal
|
||||
@@ -1,16 +1,16 @@
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
|
||||
export interface ErrorAlertProps {
|
||||
/** 文本 */
|
||||
text: string;
|
||||
text: string
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
onMaskClick?: () => void
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
onClose?: () => void
|
||||
/** 自定义 zIndex */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,18 +24,15 @@ const ErrorAlert = ({
|
||||
onClose,
|
||||
zIndex = 10000,
|
||||
}: ErrorAlertProps) => {
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<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/蒙版遮罩")})`,
|
||||
backgroundImage: `url(${getStaticImage('components/蒙版遮罩')})`,
|
||||
}}
|
||||
onClick={onMaskClick || onClose}
|
||||
/>
|
||||
@@ -45,9 +42,7 @@ const ErrorAlert = ({
|
||||
{/* 标题和副标题容器 - 带深色背景条 */}
|
||||
<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 className="text-white text-5xl font-medium tracking-widest">{text}</div>
|
||||
</div>
|
||||
|
||||
{/* 继续按钮 */}
|
||||
@@ -55,8 +50,8 @@ const ErrorAlert = ({
|
||||
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/按钮")}
|
||||
${getBackgroundImage('components/继续前进')},
|
||||
${getBackgroundImage('components/按钮')}
|
||||
`,
|
||||
}}
|
||||
onClick={onClose}
|
||||
@@ -64,7 +59,7 @@ const ErrorAlert = ({
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorAlert;
|
||||
export default ErrorAlert
|
||||
@@ -1,27 +1,27 @@
|
||||
import { getStaticImage, getBackgroundImage } from "@/lib/utils";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
import { getStaticImage, getBackgroundImage } from '@/lib/utils'
|
||||
import type { MouseEvent } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useAudioManager } from '@/hooks/useAudioManager'
|
||||
|
||||
export interface SuccessAlertProps {
|
||||
/** 标题 */
|
||||
title?: string;
|
||||
title?: string
|
||||
/** 内容文本 */
|
||||
content?: string;
|
||||
content?: string
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 成功提示音频地址(可选) */
|
||||
audioSrc?: string;
|
||||
audioSrc?: string
|
||||
/** 弹窗出现时是否自动播放音频(默认 true) */
|
||||
autoPlayAudio?: boolean;
|
||||
autoPlayAudio?: boolean
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
audioChannelId?: string
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
onMaskClick?: () => void
|
||||
/** 继续按钮回调 */
|
||||
onContinue?: () => void;
|
||||
onContinue?: () => void
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +29,7 @@ export interface SuccessAlertProps {
|
||||
* 使用 components/正确版 作为背景
|
||||
*/
|
||||
const SuccessAlert = ({
|
||||
title = "恭喜你, 选择正确!",
|
||||
title = '恭喜你, 选择正确!',
|
||||
content,
|
||||
visible = true,
|
||||
audioSrc,
|
||||
@@ -40,43 +40,41 @@ const SuccessAlert = ({
|
||||
onClose,
|
||||
}: SuccessAlertProps) => {
|
||||
const { currentSrc, play, stop } = useAudioManager(
|
||||
audioChannelId
|
||||
? { mode: "channel", channelId: audioChannelId }
|
||||
: { mode: "global" }
|
||||
);
|
||||
audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' }
|
||||
)
|
||||
|
||||
// 弹窗显示时自动播放音频
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (!autoPlayAudio) return;
|
||||
if (!audioSrc) return;
|
||||
play(audioSrc);
|
||||
}, [visible, autoPlayAudio, audioSrc, play]);
|
||||
if (!visible) return
|
||||
if (!autoPlayAudio) return
|
||||
if (!audioSrc) return
|
||||
play(audioSrc)
|
||||
}, [visible, autoPlayAudio, audioSrc, play])
|
||||
|
||||
// 弹窗关闭时停止音频(仅当当前播放的是该弹窗的 audioSrc)
|
||||
useEffect(() => {
|
||||
if (visible) return;
|
||||
if (visible) return
|
||||
if (audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
}, [visible, audioSrc, currentSrc, stop]);
|
||||
}, [visible, audioSrc, currentSrc, stop])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
const handleContinue = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// 点击继续时停止音频
|
||||
if (audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
// 只调用 onContinue,它会处理关闭逻辑
|
||||
if (onContinue) {
|
||||
onContinue();
|
||||
onContinue()
|
||||
} else if (onClose) {
|
||||
onClose();
|
||||
onClose()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
|
||||
@@ -84,7 +82,7 @@ const SuccessAlert = ({
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage("components/蒙版遮罩"),
|
||||
backgroundImage: getBackgroundImage('components/蒙版遮罩'),
|
||||
}}
|
||||
onClick={onMaskClick || onClose}
|
||||
/>
|
||||
@@ -99,15 +97,11 @@ const SuccessAlert = ({
|
||||
{/* 背景图片 - 使用 components/正确版 */}
|
||||
<img
|
||||
className="absolute inset-0 top-0 -z-10"
|
||||
src={getStaticImage("components/正确版")}
|
||||
src={getStaticImage('components/正确版')}
|
||||
alt="正确版"
|
||||
/>
|
||||
{/* 标题 */}
|
||||
{title && (
|
||||
<div className="text-[#ffbe57] text-6xl font-bold text-center">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{title && <div className="text-[#ffbe57] text-6xl font-bold text-center">{title}</div>}
|
||||
|
||||
{/* 内容文本 */}
|
||||
{content && (
|
||||
@@ -123,8 +117,8 @@ const SuccessAlert = ({
|
||||
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/按钮")}
|
||||
${getBackgroundImage('components/继续前进')},
|
||||
${getBackgroundImage('components/按钮')}
|
||||
`,
|
||||
}}
|
||||
onClick={handleContinue}
|
||||
@@ -133,7 +127,7 @@ const SuccessAlert = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default SuccessAlert;
|
||||
export default SuccessAlert
|
||||
@@ -1,31 +1,25 @@
|
||||
import { Image } from "@/components";
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
import { Image } from '@/components'
|
||||
import { cn, getStaticImage } from '@/lib/utils'
|
||||
|
||||
export interface AlertProps {
|
||||
/** 标题 */
|
||||
title: string;
|
||||
title: string
|
||||
/** 副标题 */
|
||||
subtitle: string;
|
||||
subtitle: string
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
onMaskClick?: () => void
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert 组件 - 提示弹窗
|
||||
* @returns
|
||||
*/
|
||||
const Alert = ({
|
||||
title,
|
||||
subtitle,
|
||||
visible = true,
|
||||
onMaskClick,
|
||||
onClose,
|
||||
}: AlertProps) => {
|
||||
if (!visible) return null;
|
||||
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">
|
||||
@@ -33,7 +27,7 @@ const Alert = ({
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${getStaticImage("components/蒙版遮罩")})`,
|
||||
backgroundImage: `url(${getStaticImage('components/蒙版遮罩')})`,
|
||||
}}
|
||||
onClick={onMaskClick || onClose}
|
||||
/>
|
||||
@@ -45,28 +39,22 @@ const Alert = ({
|
||||
{/* 标题 */}
|
||||
<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)]",
|
||||
'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 className="text-white text-5xl font-medium tracking-widest">{subtitle}</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮:去了解 */}
|
||||
<Image
|
||||
src={getStaticImage("components/去了解")}
|
||||
onClick={onClose}
|
||||
button
|
||||
/>
|
||||
<Image src={getStaticImage('components/去了解')} onClick={onClose} button />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Alert;
|
||||
export default Alert
|
||||
@@ -1,16 +1,16 @@
|
||||
export { default as ResidenceLayout } from "./ResidenceLayout";
|
||||
export { default as ClueOverlay } from "../../pages/chenyun/reflection/clue";
|
||||
export type { ClueOverlayProps } from "../../pages/chenyun/reflection/clue";
|
||||
export { default as ScaledPortal } from "./ScaledPortal";
|
||||
export type { ScaledPortalProps } from "./ScaledPortal";
|
||||
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 { default as ResidenceLayout } from './ResidenceLayout'
|
||||
export { default as ClueOverlay } from '../../pages/chenyun/reflection/clue'
|
||||
export type { ClueOverlayProps } from '../../pages/chenyun/reflection/clue'
|
||||
export { default as ScaledPortal } from './ScaledPortal'
|
||||
export type { ScaledPortalProps } from './ScaledPortal'
|
||||
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 {
|
||||
// ClueData,
|
||||
// ClueType,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { DragFill } from "@/components";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DragQuizPanelProps } from "./types";
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { DragFill } from '@/components'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DragQuizPanelProps } from './types'
|
||||
|
||||
/** 拖拽题面板:使用 DragFill 组件 */
|
||||
const DragQuizPanel = ({
|
||||
@@ -18,25 +18,22 @@ const DragQuizPanel = ({
|
||||
onChange,
|
||||
}: DragQuizPanelProps) => {
|
||||
// 跟踪之前的 visible 状态
|
||||
const prevVisibleRef = useRef(visible);
|
||||
const prevVisibleRef = useRef(visible)
|
||||
|
||||
// 当窗口关闭时,清除拖拽状态
|
||||
useEffect(() => {
|
||||
// 如果从可见变为不可见,清除拖拽状态
|
||||
if (prevVisibleRef.current && !visible && value.length > 0) {
|
||||
onChange([]);
|
||||
onChange([])
|
||||
}
|
||||
prevVisibleRef.current = visible;
|
||||
prevVisibleRef.current = visible
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<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">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1700px] rounded-[32px] bg-black/90 px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
@@ -46,18 +43,18 @@ const DragQuizPanel = ({
|
||||
<div>
|
||||
{questionPrefix && <span>{questionPrefix}</span>}
|
||||
{blanks.map((blank, index) => {
|
||||
const filledResult = value.find((r) => r.blankId === blank.id);
|
||||
const filledValue = filledResult?.optionValue || null;
|
||||
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)]",
|
||||
'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]"
|
||||
? '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 ? (
|
||||
@@ -67,38 +64,28 @@ const DragQuizPanel = ({
|
||||
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)
|
||||
);
|
||||
e.stopPropagation()
|
||||
onChange(value.filter((r) => r.blankId !== blank.id))
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-white/40 text-[24px] font-light">
|
||||
____
|
||||
</span>
|
||||
<span className="text-white/40 text-[24px] font-light">____</span>
|
||||
)}
|
||||
</span>
|
||||
{index < blanks.length - 1 && (
|
||||
<span className="text-white/80 mx-1">
|
||||
{blankSeparators && blankSeparators[index]
|
||||
? blankSeparators[index]
|
||||
: "、"}
|
||||
{blankSeparators && blankSeparators[index] ? blankSeparators[index] : '、'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
})}
|
||||
{questionSuffix && <span>{questionSuffix}</span>}
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-5 text-center text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,12 +104,12 @@ const DragQuizPanel = ({
|
||||
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)]",
|
||||
'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]"
|
||||
? '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 && (
|
||||
@@ -136,13 +123,13 @@ const DragQuizPanel = ({
|
||||
renderBlank={(_blank) => {
|
||||
// 返回一个隐藏元素,实际的空白区域在题干中
|
||||
// 但需要通过 data-blank-id 让 DragFill 能找到它
|
||||
return <div className="hidden" />;
|
||||
return <div className="hidden" />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default DragQuizPanel;
|
||||
export default DragQuizPanel
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MatchQuizPanelProps } from "./types";
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { MatchQuizPanelProps } from './types'
|
||||
|
||||
/**
|
||||
* 连线题面板组件
|
||||
@@ -41,153 +41,141 @@ const MatchQuizPanel = ({
|
||||
onError,
|
||||
disabled = false,
|
||||
}: MatchQuizPanelProps) => {
|
||||
const [matches, setMatches] = useState<Array<{ leftId: string; rightId: string }>>([]);
|
||||
const [selectedLeftId, setSelectedLeftId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string>("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [matches, setMatches] = useState<Array<{ leftId: string; rightId: string }>>([])
|
||||
const [selectedLeftId, setSelectedLeftId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string>('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// 当前错误的连线对,用于做红色高亮+抖动反馈
|
||||
const [errorPairs, setErrorPairs] = useState<
|
||||
Array<{ leftId: string; rightId: string }>
|
||||
>([]);
|
||||
const [errorPairs, setErrorPairs] = useState<Array<{ leftId: string; rightId: string }>>([])
|
||||
|
||||
// 当窗口关闭时,重置状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setMatches([]);
|
||||
setErrorPairs([]);
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
setMatches([])
|
||||
setErrorPairs([])
|
||||
setSelectedLeftId(null)
|
||||
setError('')
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
// 处理左列项点击
|
||||
const handleLeftItemClick = (leftId: string) => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
|
||||
// 如果已经匹配过,先取消匹配
|
||||
const existingMatch = matches.find((m) => m.leftId === leftId);
|
||||
const existingMatch = matches.find((m) => m.leftId === leftId)
|
||||
if (existingMatch) {
|
||||
setMatches(matches.filter((m) => m.leftId !== leftId));
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
return;
|
||||
setMatches(matches.filter((m) => m.leftId !== leftId))
|
||||
setSelectedLeftId(null)
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经选中了另一个左项,取消之前的选中
|
||||
if (selectedLeftId && selectedLeftId !== leftId) {
|
||||
setSelectedLeftId(leftId);
|
||||
setError("");
|
||||
return;
|
||||
setSelectedLeftId(leftId)
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 切换选中状态
|
||||
setSelectedLeftId(selectedLeftId === leftId ? null : leftId);
|
||||
setError("");
|
||||
};
|
||||
setSelectedLeftId(selectedLeftId === leftId ? null : leftId)
|
||||
setError('')
|
||||
}
|
||||
|
||||
// 处理右列项点击
|
||||
const handleRightItemClick = (rightId: string) => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
|
||||
// 如果没有选中左项,直接返回
|
||||
if (!selectedLeftId) return;
|
||||
if (!selectedLeftId) return
|
||||
|
||||
// 如果已经匹配过,先取消匹配
|
||||
const existingMatch = matches.find((m) => m.rightId === rightId);
|
||||
const existingMatch = matches.find((m) => m.rightId === rightId)
|
||||
if (existingMatch) {
|
||||
setMatches(matches.filter((m) => m.rightId !== rightId));
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
return;
|
||||
setMatches(matches.filter((m) => m.rightId !== rightId))
|
||||
setSelectedLeftId(null)
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 创建新的匹配
|
||||
const newMatch = { leftId: selectedLeftId, rightId };
|
||||
const newMatches = [...matches, newMatch];
|
||||
const newMatch = { leftId: selectedLeftId, rightId }
|
||||
const newMatches = [...matches, newMatch]
|
||||
|
||||
// 每次连线后立即校验当前所有连线是否存在错误
|
||||
const wrongMatches =
|
||||
correctMatches?.filter(
|
||||
() => true
|
||||
) && // 保证 correctMatches 存在时才校验
|
||||
correctMatches?.filter(() => true) && // 保证 correctMatches 存在时才校验
|
||||
newMatches.filter(
|
||||
(match) =>
|
||||
!correctMatches?.some(
|
||||
(cm) => cm.leftId === match.leftId && cm.rightId === match.rightId
|
||||
)
|
||||
);
|
||||
!correctMatches?.some((cm) => cm.leftId === match.leftId && cm.rightId === match.rightId)
|
||||
)
|
||||
|
||||
const hasError = !!wrongMatches && wrongMatches.length > 0;
|
||||
const hasError = !!wrongMatches && wrongMatches.length > 0
|
||||
|
||||
if (hasError && wrongMatches) {
|
||||
// 记录错误连线对,用于红色高亮+抖动反馈
|
||||
setErrorPairs(wrongMatches);
|
||||
setMatches(newMatches);
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
setErrorPairs(wrongMatches)
|
||||
setMatches(newMatches)
|
||||
setSelectedLeftId(null)
|
||||
setError('')
|
||||
|
||||
let errorMsg: string;
|
||||
if (typeof errorMessage === "function") {
|
||||
errorMsg = errorMessage(newMatches);
|
||||
let errorMsg: string
|
||||
if (typeof errorMessage === 'function') {
|
||||
errorMsg = errorMessage(newMatches)
|
||||
} else if (errorMessage) {
|
||||
errorMsg = errorMessage;
|
||||
errorMsg = errorMessage
|
||||
} else {
|
||||
errorMsg = "连线不正确,请重新尝试!";
|
||||
errorMsg = '连线不正确,请重新尝试!'
|
||||
}
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
// 短暂展示错误状态(变红+抖动),随后只清空错误连线,保留已答对的连线
|
||||
setTimeout(() => {
|
||||
setMatches((prev) =>
|
||||
prev.filter(
|
||||
(m) =>
|
||||
!wrongMatches.some(
|
||||
(w) => w.leftId === m.leftId && w.rightId === m.rightId
|
||||
)
|
||||
(m) => !wrongMatches.some((w) => w.leftId === m.leftId && w.rightId === m.rightId)
|
||||
)
|
||||
);
|
||||
setErrorPairs([]);
|
||||
}, 600);
|
||||
return;
|
||||
)
|
||||
setErrorPairs([])
|
||||
}, 600)
|
||||
return
|
||||
}
|
||||
|
||||
// 没有错误时,正常记录匹配
|
||||
setMatches(newMatches);
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
setMatches(newMatches)
|
||||
setSelectedLeftId(null)
|
||||
setError('')
|
||||
|
||||
// 如果没有错误且所有项都已连完,则认为答题成功
|
||||
if (
|
||||
newMatches.length === leftItems.length &&
|
||||
newMatches.length === rightItems.length
|
||||
) {
|
||||
onSubmit?.(newMatches, true);
|
||||
if (newMatches.length === leftItems.length && newMatches.length === rightItems.length) {
|
||||
onSubmit?.(newMatches, true)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 获取左项是否已匹配
|
||||
const isLeftItemMatched = (leftId: string) => {
|
||||
return matches.some((m) => m.leftId === leftId);
|
||||
};
|
||||
return matches.some((m) => m.leftId === leftId)
|
||||
}
|
||||
|
||||
// 获取右项是否已匹配
|
||||
const isRightItemMatched = (rightId: string) => {
|
||||
return matches.some((m) => m.rightId === rightId);
|
||||
};
|
||||
return matches.some((m) => m.rightId === rightId)
|
||||
}
|
||||
|
||||
// 获取左项对应的右项ID
|
||||
const getMatchedRightId = (leftId: string) => {
|
||||
const match = matches.find((m) => m.leftId === leftId);
|
||||
return match?.rightId || null;
|
||||
};
|
||||
const match = matches.find((m) => m.leftId === leftId)
|
||||
return match?.rightId || null
|
||||
}
|
||||
|
||||
// 获取右项对应的左项ID
|
||||
const getMatchedLeftId = (rightId: string) => {
|
||||
const match = matches.find((m) => m.rightId === rightId);
|
||||
return match?.leftId || null;
|
||||
};
|
||||
const match = matches.find((m) => m.rightId === rightId)
|
||||
return match?.leftId || null
|
||||
}
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -215,35 +203,33 @@ const MatchQuizPanel = ({
|
||||
{/* 左列:日期 */}
|
||||
<div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
|
||||
{leftItems.map((item) => {
|
||||
const isMatched = isLeftItemMatched(item.id);
|
||||
const isSelected = selectedLeftId === item.id;
|
||||
const isError = errorPairs.some((p) => p.leftId === item.id);
|
||||
const isMatched = isLeftItemMatched(item.id)
|
||||
const isSelected = selectedLeftId === item.id
|
||||
const isError = errorPairs.some((p) => p.leftId === item.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200",
|
||||
"text-[20px] md:text-[28px] leading-relaxed cursor-pointer",
|
||||
"touch-manipulation", // 优化移动端触摸
|
||||
disabled || isMatched
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
|
||||
'text-[20px] md:text-[28px] leading-relaxed cursor-pointer',
|
||||
'touch-manipulation', // 优化移动端触摸
|
||||
disabled || isMatched ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
isError
|
||||
? "bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse"
|
||||
? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
|
||||
: isSelected
|
||||
? "bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105"
|
||||
: isMatched
|
||||
? "bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]"
|
||||
: "bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]"
|
||||
? 'bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105'
|
||||
: isMatched
|
||||
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
|
||||
: 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
|
||||
)}
|
||||
onClick={() => handleLeftItemClick(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
// 移动端触摸优化
|
||||
e.currentTarget.classList.add("scale-105");
|
||||
e.currentTarget.classList.add('scale-105')
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.classList.remove("scale-105");
|
||||
e.currentTarget.classList.remove('scale-105')
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
@@ -258,7 +244,7 @@ const MatchQuizPanel = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -267,18 +253,18 @@ const MatchQuizPanel = ({
|
||||
{/* 绘制连线 */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none overflow-visible">
|
||||
{matches.map((match, index) => {
|
||||
const leftItem = leftItems.find((item) => item.id === match.leftId);
|
||||
const rightItem = rightItems.find((item) => item.id === match.rightId);
|
||||
if (!leftItem || !rightItem) return null;
|
||||
const leftItem = leftItems.find((item) => item.id === match.leftId)
|
||||
const rightItem = rightItems.find((item) => item.id === match.rightId)
|
||||
if (!leftItem || !rightItem) return null
|
||||
|
||||
const leftIndex = leftItems.findIndex((item) => item.id === match.leftId);
|
||||
const rightIndex = rightItems.findIndex((item) => item.id === match.rightId);
|
||||
const leftIndex = leftItems.findIndex((item) => item.id === match.leftId)
|
||||
const rightIndex = rightItems.findIndex((item) => item.id === match.rightId)
|
||||
|
||||
// 计算连线起点和终点(基于项目高度和间距)
|
||||
const itemHeight = 80; // 每个项目的大概高度
|
||||
const gap = 16; // 项目之间的间距
|
||||
const startY = leftIndex * (itemHeight + gap) + itemHeight / 2;
|
||||
const endY = rightIndex * (itemHeight + gap) + itemHeight / 2;
|
||||
const itemHeight = 80 // 每个项目的大概高度
|
||||
const gap = 16 // 项目之间的间距
|
||||
const startY = leftIndex * (itemHeight + gap) + itemHeight / 2
|
||||
const endY = rightIndex * (itemHeight + gap) + itemHeight / 2
|
||||
|
||||
return (
|
||||
<line
|
||||
@@ -292,7 +278,7 @@ const MatchQuizPanel = ({
|
||||
strokeDasharray="5,5"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
);
|
||||
)
|
||||
})}
|
||||
{/* 箭头标记定义 */}
|
||||
<defs>
|
||||
@@ -313,37 +299,37 @@ const MatchQuizPanel = ({
|
||||
{/* 右列:事件 */}
|
||||
<div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
|
||||
{rightItems.map((item) => {
|
||||
const isMatched = isRightItemMatched(item.id);
|
||||
const isError = errorPairs.some((p) => p.rightId === item.id);
|
||||
const isMatched = isRightItemMatched(item.id)
|
||||
const isError = errorPairs.some((p) => p.rightId === item.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200",
|
||||
"text-[20px] md:text-[28px] leading-relaxed",
|
||||
"touch-manipulation", // 优化移动端触摸
|
||||
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
|
||||
'text-[20px] md:text-[28px] leading-relaxed',
|
||||
'touch-manipulation', // 优化移动端触摸
|
||||
disabled || isMatched
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
? 'opacity-60 cursor-not-allowed'
|
||||
: selectedLeftId
|
||||
? "cursor-pointer"
|
||||
: "cursor-default",
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-default',
|
||||
isError
|
||||
? "bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse"
|
||||
? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
|
||||
: isMatched
|
||||
? "bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]"
|
||||
: selectedLeftId
|
||||
? "bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]"
|
||||
: "bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60"
|
||||
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
|
||||
: selectedLeftId
|
||||
? 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
|
||||
: 'bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60'
|
||||
)}
|
||||
onClick={() => handleRightItemClick(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
if (selectedLeftId) {
|
||||
e.currentTarget.classList.add("scale-105");
|
||||
e.currentTarget.classList.add('scale-105')
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.classList.remove("scale-105");
|
||||
e.currentTarget.classList.remove('scale-105')
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
@@ -358,7 +344,7 @@ const MatchQuizPanel = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,8 +355,7 @@ const MatchQuizPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchQuizPanel;
|
||||
)
|
||||
}
|
||||
|
||||
export default MatchQuizPanel
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { MultipleChoice } from "@/components";
|
||||
import type { MultipleChoiceQuizPanelProps } from "./types";
|
||||
import { useState, useEffect } from 'react'
|
||||
import { MultipleChoice } from '@/components'
|
||||
import type { MultipleChoiceQuizPanelProps } from './types'
|
||||
|
||||
/**
|
||||
* 多选题面板组件
|
||||
@@ -44,94 +44,85 @@ const MultipleChoiceQuizPanel = ({
|
||||
disabled = false,
|
||||
minSelections,
|
||||
maxSelections,
|
||||
submitText = "提交",
|
||||
submitText = '提交',
|
||||
}: MultipleChoiceQuizPanelProps) => {
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([])
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
// 当窗口关闭时,清除选中状态和错误状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSelectedValues([]);
|
||||
setError("");
|
||||
setSelectedValues([])
|
||||
setError('')
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
// 处理选项点击
|
||||
const handleOptionChange = (values: string[]) => {
|
||||
if (disabled) return;
|
||||
setSelectedValues(values);
|
||||
setError("");
|
||||
};
|
||||
if (disabled) return
|
||||
setSelectedValues(values)
|
||||
setError('')
|
||||
}
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
|
||||
// 检查最小选择数量
|
||||
if (minSelections !== undefined && selectedValues.length < minSelections) {
|
||||
const errorMsg =
|
||||
minSelectionsErrorMessage || `请至少选择 ${minSelections} 项`;
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
return;
|
||||
const errorMsg = minSelectionsErrorMessage || `请至少选择 ${minSelections} 项`
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证答案
|
||||
let isCorrect: boolean | undefined = undefined;
|
||||
let isCorrect: boolean | undefined = undefined
|
||||
if (correctAnswer !== undefined) {
|
||||
// 比较两个数组是否相等(不考虑顺序)
|
||||
const correctSet = new Set(correctAnswer);
|
||||
const selectedSet = new Set(selectedValues);
|
||||
const correctSet = new Set(correctAnswer)
|
||||
const selectedSet = new Set(selectedValues)
|
||||
|
||||
isCorrect =
|
||||
correctSet.size === selectedSet.size &&
|
||||
[...correctSet].every((val) => selectedSet.has(val));
|
||||
correctSet.size === selectedSet.size && [...correctSet].every((val) => selectedSet.has(val))
|
||||
|
||||
if (isCorrect) {
|
||||
// 答题成功:清空状态,执行 onSubmit(外部控制关闭窗口)
|
||||
const submittedValues = [...selectedValues];
|
||||
setSelectedValues([]);
|
||||
setError("");
|
||||
onSubmit?.(submittedValues, true);
|
||||
const submittedValues = [...selectedValues]
|
||||
setSelectedValues([])
|
||||
setError('')
|
||||
onSubmit?.(submittedValues, true)
|
||||
} else {
|
||||
// 答题失败:清空状态,执行 onError,允许重新选择
|
||||
const submittedValues = [...selectedValues];
|
||||
setSelectedValues([]);
|
||||
const submittedValues = [...selectedValues]
|
||||
setSelectedValues([])
|
||||
const errorMsg =
|
||||
typeof errorMessage === "function"
|
||||
typeof errorMessage === 'function'
|
||||
? errorMessage(submittedValues)
|
||||
: errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
: errorMessage || '很抱歉,你选错了!请你再思考一下吧!'
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
}
|
||||
} else {
|
||||
// 没有提供正确答案,直接提交
|
||||
const submittedValues = [...selectedValues];
|
||||
setSelectedValues([]);
|
||||
setError("");
|
||||
onSubmit?.(submittedValues, true);
|
||||
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="fixed inset-0 flex items-center justify-center bg-black/40" style={{ zIndex }}>
|
||||
<div className="relative w-[1728px] flex flex-col items-center py-6">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1700px] 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>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-5 text-center text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
</div>
|
||||
|
||||
{/* 选项区域 - 可滚动 */}
|
||||
@@ -149,56 +140,48 @@ const MultipleChoiceQuizPanel = ({
|
||||
disabled ||
|
||||
(!isSelected &&
|
||||
maxSelections !== undefined &&
|
||||
selectedValues.length >= maxSelections);
|
||||
selectedValues.length >= maxSelections)
|
||||
|
||||
// 处理选项点击
|
||||
const handleClick = () => {
|
||||
if (isDisabled) return;
|
||||
if (isDisabled) return
|
||||
|
||||
let newValues: string[];
|
||||
let newValues: string[]
|
||||
if (isSelected) {
|
||||
// 取消选择
|
||||
newValues = selectedValues.filter((v) => v !== option.value);
|
||||
newValues = selectedValues.filter((v) => v !== option.value)
|
||||
// 检查最小选择数量限制
|
||||
if (
|
||||
minSelections !== undefined &&
|
||||
newValues.length < minSelections
|
||||
) {
|
||||
return;
|
||||
if (minSelections !== undefined && newValues.length < minSelections) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 选择
|
||||
// 检查最大选择数量限制
|
||||
if (
|
||||
maxSelections !== undefined &&
|
||||
selectedValues.length >= maxSelections
|
||||
) {
|
||||
return;
|
||||
if (maxSelections !== undefined && selectedValues.length >= maxSelections) {
|
||||
return
|
||||
}
|
||||
newValues = [...selectedValues, option.value];
|
||||
newValues = [...selectedValues, option.value]
|
||||
}
|
||||
|
||||
handleOptionChange(newValues);
|
||||
};
|
||||
handleOptionChange(newValues)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={option.value} className="mb-4">
|
||||
<div
|
||||
className={`w-[1500px] flex items-center p-4 text-[28px] leading-relaxed rounded-tr-[50px] rounded-bl-4xl transition-all duration-150 ${
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer"
|
||||
isDisabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
} ${
|
||||
isSelected
|
||||
? "bg-[#d4a574] text-[#5c3e2e] bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]"
|
||||
: "bg-[#d7b888] text-[#5c3e2e] hover:brightness-110"
|
||||
? 'bg-[#d4a574] text-[#5c3e2e] bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]'
|
||||
: 'bg-[#d7b888] text-[#5c3e2e] hover:brightness-110'
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -212,8 +195,7 @@ const MultipleChoiceQuizPanel = ({
|
||||
disabled={
|
||||
disabled ||
|
||||
selectedValues.length === 0 ||
|
||||
(minSelections !== undefined &&
|
||||
selectedValues.length < minSelections)
|
||||
(minSelections !== undefined && selectedValues.length < minSelections)
|
||||
}
|
||||
className="px-30 py-4 text-[28px] font-medium text-white bg-[#c2995f] rounded-[40px] transition-all duration-200 hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
|
||||
>
|
||||
@@ -223,7 +205,7 @@ const MultipleChoiceQuizPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default MultipleChoiceQuizPanel;
|
||||
export default MultipleChoiceQuizPanel
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SingleChoice } from "@/components";
|
||||
import { getBackgroundImage } from "@/lib/utils";
|
||||
import type { QuizPanelProps } from "./types";
|
||||
import { SingleChoice } from '@/components'
|
||||
import { getBackgroundImage } from '@/lib/utils'
|
||||
import type { QuizPanelProps } from './types'
|
||||
|
||||
/** 单选题展示面板:题干使用“底板”背景,点击即选,hover 高亮 */
|
||||
const QuizPanel = ({
|
||||
@@ -12,13 +12,10 @@ const QuizPanel = ({
|
||||
error,
|
||||
onChange,
|
||||
}: QuizPanelProps) => {
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<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]">
|
||||
@@ -40,24 +37,21 @@ const QuizPanel = ({
|
||||
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/未选择"
|
||||
isSelected ? 'components/quiz/选中' : 'components/quiz/未选择'
|
||||
),
|
||||
filter: isSelected ? undefined : "brightness(0.96)",
|
||||
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 = "";
|
||||
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)";
|
||||
if (isSelected) return
|
||||
;(e.currentTarget as HTMLDivElement).style.backgroundImage =
|
||||
getBackgroundImage('components/quiz/未选择')
|
||||
;(e.currentTarget as HTMLDivElement).style.filter = 'brightness(0.96)'
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
@@ -67,15 +61,11 @@ const QuizPanel = ({
|
||||
/>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-6 text-center text-[26px] text-[#ff4d4f]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-6 text-center text-[26px] text-[#ff4d4f]">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default QuizPanel;
|
||||
export default QuizPanel
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
SelectFillQuizPanelProps,
|
||||
SelectFillResult,
|
||||
} from "./types";
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SelectFillQuizPanelProps, SelectFillResult } from './types'
|
||||
|
||||
/**
|
||||
* 选择填空题面板组件
|
||||
@@ -21,131 +18,124 @@ const SelectFillQuizPanel = ({
|
||||
onSubmit,
|
||||
onError,
|
||||
disabled = false,
|
||||
submitText = "提交",
|
||||
submitText = '提交',
|
||||
}: SelectFillQuizPanelProps) => {
|
||||
const [results, setResults] = useState<SelectFillResult[]>([]);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [results, setResults] = useState<SelectFillResult[]>([])
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
// 当前正在填的空的下标(从 0 开始),默认为第一个空
|
||||
const [activeBlankIndex, setActiveBlankIndex] = useState(0);
|
||||
const [activeBlankIndex, setActiveBlankIndex] = useState(0)
|
||||
|
||||
// 关闭时重置内部状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setResults([]);
|
||||
setError("");
|
||||
setActiveBlankIndex(0);
|
||||
setResults([])
|
||||
setError('')
|
||||
setActiveBlankIndex(0)
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
const isAllFilled = blanks.every((blank) =>
|
||||
results.some((r) => r.blankId === blank.id && r.value !== "")
|
||||
);
|
||||
results.some((r) => r.blankId === blank.id && r.value !== '')
|
||||
)
|
||||
|
||||
// 点击下方选项:填入当前激活的空
|
||||
const handleOptionClick = (value: string) => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
|
||||
const currentBlank = blanks[activeBlankIndex];
|
||||
if (!currentBlank) return;
|
||||
const currentBlank = blanks[activeBlankIndex]
|
||||
if (!currentBlank) return
|
||||
|
||||
const newResults: SelectFillResult[] = (() => {
|
||||
const others = results.filter((r) => r.blankId !== currentBlank.id);
|
||||
return [...others, { blankId: currentBlank.id, value }];
|
||||
})();
|
||||
const others = results.filter((r) => r.blankId !== currentBlank.id)
|
||||
return [...others, { blankId: currentBlank.id, value }]
|
||||
})()
|
||||
|
||||
setResults(newResults);
|
||||
setError("");
|
||||
setResults(newResults)
|
||||
setError('')
|
||||
|
||||
// 自动跳到下一个空
|
||||
const nextIndex = (() => {
|
||||
// 优先跳到还未填写的下一个空
|
||||
for (let i = 0; i < blanks.length; i++) {
|
||||
const blankItem = blanks[i]!;
|
||||
const hasValue = newResults.some(
|
||||
(r) => r.blankId === blankItem.id && r.value !== ""
|
||||
);
|
||||
if (!hasValue) return i;
|
||||
const blankItem = blanks[i]!
|
||||
const hasValue = newResults.some((r) => r.blankId === blankItem.id && r.value !== '')
|
||||
if (!hasValue) return i
|
||||
}
|
||||
return activeBlankIndex;
|
||||
})();
|
||||
setActiveBlankIndex(nextIndex);
|
||||
};
|
||||
return activeBlankIndex
|
||||
})()
|
||||
setActiveBlankIndex(nextIndex)
|
||||
}
|
||||
|
||||
// 清空某个空
|
||||
const handleClearBlank = (blankId: string) => {
|
||||
if (disabled) return;
|
||||
setResults(results.filter((r) => r.blankId !== blankId));
|
||||
setError("");
|
||||
const index = blanks.findIndex((b) => b.id === blankId);
|
||||
if (index >= 0) setActiveBlankIndex(index);
|
||||
};
|
||||
if (disabled) return
|
||||
setResults(results.filter((r) => r.blankId !== blankId))
|
||||
setError('')
|
||||
const index = blanks.findIndex((b) => b.id === blankId)
|
||||
if (index >= 0) setActiveBlankIndex(index)
|
||||
}
|
||||
|
||||
// 提交答案
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
|
||||
// 有空未填
|
||||
if (!isAllFilled) {
|
||||
const msg = "请先把所有空都填完再提交哦~";
|
||||
setError(msg);
|
||||
onError?.(msg);
|
||||
return;
|
||||
const msg = '请先把所有空都填完再提交哦~'
|
||||
setError(msg)
|
||||
onError?.(msg)
|
||||
return
|
||||
}
|
||||
|
||||
const filledResults = blanks.map((blank) => {
|
||||
const r = results.find((res) => res.blankId === blank.id);
|
||||
return { blankId: blank.id, value: r?.value ?? "" };
|
||||
});
|
||||
const r = results.find((res) => res.blankId === blank.id)
|
||||
return { blankId: blank.id, value: r?.value ?? '' }
|
||||
})
|
||||
|
||||
let isCorrect = true;
|
||||
let isCorrect = true
|
||||
if (correctAnswers) {
|
||||
for (const r of filledResults) {
|
||||
const expected = correctAnswers[r.blankId];
|
||||
const expected = correctAnswers[r.blankId]
|
||||
if (expected !== undefined && expected !== r.value) {
|
||||
isCorrect = false;
|
||||
break;
|
||||
isCorrect = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!correctAnswers) {
|
||||
onSubmit?.(filledResults, true);
|
||||
return;
|
||||
onSubmit?.(filledResults, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (isCorrect) {
|
||||
setError("");
|
||||
onSubmit?.(filledResults, true);
|
||||
setError('')
|
||||
onSubmit?.(filledResults, true)
|
||||
} else {
|
||||
const msg =
|
||||
errorMessage || "还不太对,再仔细对照题干和提示想一想吧~";
|
||||
setError(msg);
|
||||
const msg = errorMessage || '还不太对,再仔细对照题干和提示想一想吧~'
|
||||
setError(msg)
|
||||
// 错误后清空已填写内容,重新作答
|
||||
setResults([]);
|
||||
setActiveBlankIndex(0);
|
||||
onError?.(msg);
|
||||
setResults([])
|
||||
setActiveBlankIndex(0)
|
||||
onError?.(msg)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getBlankValue = (blankId: string) =>
|
||||
results.find((r) => r.blankId === blankId)?.value ?? "";
|
||||
const getBlankValue = (blankId: string) => results.find((r) => r.blankId === blankId)?.value ?? ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<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-[1700px] rounded-[32px] bg-black 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">
|
||||
{titlePrefix && <span>{titlePrefix}</span>}
|
||||
{blanks.map((blank, index) => {
|
||||
const value = getBlankValue(blank.id);
|
||||
const isActive = index === activeBlankIndex;
|
||||
const value = getBlankValue(blank.id)
|
||||
const isActive = index === activeBlankIndex
|
||||
return (
|
||||
<span key={blank.id}>
|
||||
{blank.prefix && <span>{blank.prefix}</span>}
|
||||
@@ -153,29 +143,29 @@ const SelectFillQuizPanel = ({
|
||||
type="button"
|
||||
className={`inline-flex items-center justify-center min-w-[120px] h-[52px] px-4 mx-2 rounded-xl border-2 text-[28px] transition-all ${
|
||||
disabled
|
||||
? "opacity-60 cursor-not-allowed border-[#d4c5a9]/40"
|
||||
: "cursor-pointer"
|
||||
? 'opacity-60 cursor-not-allowed border-[#d4c5a9]/40'
|
||||
: 'cursor-pointer'
|
||||
} ${
|
||||
value
|
||||
? "border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] text-[#f0be75]"
|
||||
: "border-[#d4c5a9]/60 bg-white/10 text-white/40"
|
||||
? 'border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] text-[#f0be75]'
|
||||
: 'border-[#d4c5a9]/60 bg-white/10 text-white/40'
|
||||
} ${
|
||||
isActive && !disabled
|
||||
? "ring-2 ring-[#f0be75] ring-offset-2 ring-offset-black"
|
||||
: ""
|
||||
? 'ring-2 ring-[#f0be75] ring-offset-2 ring-offset-black'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setActiveBlankIndex(index);
|
||||
if (disabled) return
|
||||
setActiveBlankIndex(index)
|
||||
}}
|
||||
>
|
||||
{value || "____"}
|
||||
{value || '____'}
|
||||
{value && !disabled && (
|
||||
<span
|
||||
className="ml-3 text-[22px] text-[#f0be75]/80"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearBlank(blank.id);
|
||||
e.stopPropagation()
|
||||
handleClearBlank(blank.id)
|
||||
}}
|
||||
>
|
||||
×
|
||||
@@ -184,17 +174,13 @@ const SelectFillQuizPanel = ({
|
||||
</button>
|
||||
{blank.suffix && <span>{blank.suffix}</span>}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
})}
|
||||
{titleSuffix && <span>{titleSuffix}</span>}
|
||||
</div>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-6 text-center text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-6 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
</div>
|
||||
|
||||
{/* 选项区域 */}
|
||||
@@ -207,8 +193,8 @@ const SelectFillQuizPanel = ({
|
||||
disabled={disabled}
|
||||
className={`min-w-[90px] h-[72px] px-6 rounded-2xl text-[28px] font-medium border-2 transition-all ${
|
||||
disabled
|
||||
? "opacity-60 cursor-not-allowed bg-[#d7b888]/60 border-[#d4c5a9]/60 text-[#5c3e2e]/60"
|
||||
: "cursor-pointer bg-[#d7b888] border-[#d4c5a9] text-[#5c3e2e] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]"
|
||||
? 'opacity-60 cursor-not-allowed bg-[#d7b888]/60 border-[#d4c5a9]/60 text-[#5c3e2e]/60'
|
||||
: 'cursor-pointer bg-[#d7b888] border-[#d4c5a9] text-[#5c3e2e] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
|
||||
}`}
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
@@ -231,9 +217,7 @@ const SelectFillQuizPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectFillQuizPanel;
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectFillQuizPanel
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { SingleChoice } from "@/components";
|
||||
import type { SingleChoiceQuizPanelProps } from "./types";
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SingleChoice } from '@/components'
|
||||
import type { SingleChoiceQuizPanelProps } from './types'
|
||||
|
||||
/**
|
||||
* 单选题面板组件
|
||||
@@ -39,70 +39,61 @@ const SingleChoiceQuizPanel = ({
|
||||
onError,
|
||||
disabled = false,
|
||||
}: SingleChoiceQuizPanelProps) => {
|
||||
const [selectedValue, setSelectedValue] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [selectedValue, setSelectedValue] = useState<string>('')
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
// 当窗口关闭时,重置内部状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSelectedValue("");
|
||||
setError("");
|
||||
setSelectedValue('')
|
||||
setError('')
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
// 处理选项点击
|
||||
const handleOptionClick = (value: string) => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
|
||||
setSelectedValue(value);
|
||||
setError("");
|
||||
setSelectedValue(value)
|
||||
setError('')
|
||||
|
||||
// 验证答案
|
||||
const isCorrect =
|
||||
correctAnswer !== undefined ? value === correctAnswer : undefined;
|
||||
const isCorrect = correctAnswer !== undefined ? value === correctAnswer : undefined
|
||||
|
||||
// 如果提供了正确答案,进行验证
|
||||
if (correctAnswer !== undefined) {
|
||||
if (isCorrect) {
|
||||
// 答题成功:清空状态,执行 onSubmit(外部控制关闭窗口)
|
||||
setSelectedValue("");
|
||||
setError("");
|
||||
onSubmit?.(value, true);
|
||||
setSelectedValue('')
|
||||
setError('')
|
||||
onSubmit?.(value, true)
|
||||
} else {
|
||||
// 答题失败:清空状态,执行 onError,允许重新选择
|
||||
setSelectedValue("");
|
||||
const customError = onWrongSelect?.(value);
|
||||
const errorMsg =
|
||||
customError || errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
setSelectedValue('')
|
||||
const customError = onWrongSelect?.(value)
|
||||
const errorMsg = customError || errorMessage || '很抱歉,你选错了!请你再思考一下吧!'
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
}
|
||||
} else {
|
||||
// 没有提供正确答案,直接提交
|
||||
setSelectedValue("");
|
||||
setError("");
|
||||
onSubmit?.(value, true);
|
||||
setSelectedValue('')
|
||||
setError('')
|
||||
onSubmit?.(value, true)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<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-[1700px] rounded-[32px] bg-black px-20 py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="text-[34px] leading-relaxed text-white">{title}</div>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-5 text-center text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
</div>
|
||||
|
||||
{/* 选项区域 */}
|
||||
@@ -119,13 +110,11 @@ const SingleChoiceQuizPanel = ({
|
||||
>
|
||||
<div
|
||||
className={`w-[1500px] flex items-center p-4 text-[28px] leading-relaxed rounded-tr-[50px] rounded-bl-4xl transition-all duration-150 ${
|
||||
disabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer"
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
} ${
|
||||
isSelected
|
||||
? "bg-[#d4a574] text-[#5c3e2e]"
|
||||
: "bg-[#d7b888] text-[#5c3e2e] hover:bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]"
|
||||
? 'bg-[#d4a574] text-[#5c3e2e]'
|
||||
: 'bg-[#d7b888] text-[#5c3e2e] hover:bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
@@ -136,7 +125,7 @@ const SingleChoiceQuizPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default SingleChoiceQuizPanel;
|
||||
export default SingleChoiceQuizPanel
|
||||
@@ -1,9 +1,9 @@
|
||||
// 新组件
|
||||
export { default as SingleChoiceQuizPanel } from "./SingleChoiceQuizPanel";
|
||||
export { default as MultipleChoiceQuizPanel } from "./MultipleChoiceQuizPanel";
|
||||
export { default as DragQuizPanel } from "./DragQuizPanel";
|
||||
export { default as MatchQuizPanel } from "./MatchQuizPanel";
|
||||
export { default as SelectFillQuizPanel } from "./SelectFillQuizPanel";
|
||||
export { default as SingleChoiceQuizPanel } from './SingleChoiceQuizPanel'
|
||||
export { default as MultipleChoiceQuizPanel } from './MultipleChoiceQuizPanel'
|
||||
export { default as DragQuizPanel } from './DragQuizPanel'
|
||||
export { default as MatchQuizPanel } from './MatchQuizPanel'
|
||||
export { default as SelectFillQuizPanel } from './SelectFillQuizPanel'
|
||||
export type {
|
||||
SingleChoiceQuizPanelProps,
|
||||
MultipleChoiceQuizPanelProps,
|
||||
@@ -14,8 +14,8 @@ export type {
|
||||
SelectFillQuizPanelProps,
|
||||
SelectFillBlank,
|
||||
SelectFillResult,
|
||||
} from "./types";
|
||||
} from './types'
|
||||
|
||||
// 保留旧组件以保持向后兼容
|
||||
export { default as QuizPanel } from "./QuizPanal";
|
||||
export type { QuizPanelProps } from "./types";
|
||||
export { default as QuizPanel } from './QuizPanal'
|
||||
export type { QuizPanelProps } from './types'
|
||||
@@ -3,188 +3,188 @@ import {
|
||||
MultipleChoiceOption,
|
||||
DragFillResult,
|
||||
DragFillBlank,
|
||||
} from "@/components";
|
||||
} from '@/components'
|
||||
|
||||
/** 拖拽题面板配置 */
|
||||
export interface DragQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 题目标题(可选) */
|
||||
title?: string;
|
||||
title?: string
|
||||
/** 题干前缀文本(空白区域之前的文本) */
|
||||
questionPrefix?: string;
|
||||
questionPrefix?: string
|
||||
/** 题干后缀文本(空白区域之后的文本) */
|
||||
questionSuffix?: string;
|
||||
questionSuffix?: string
|
||||
/** 选项列表 */
|
||||
options: { value: string; label: string }[];
|
||||
options: { value: string; label: string }[]
|
||||
/** 空白区域列表 */
|
||||
blanks: DragFillBlank[];
|
||||
blanks: DragFillBlank[]
|
||||
/** 空白之间的分隔符数组(可选,如果不提供则使用默认的"、") */
|
||||
blankSeparators?: string[];
|
||||
blankSeparators?: string[]
|
||||
/** 当前值 */
|
||||
value: DragFillResult[];
|
||||
value: DragFillResult[]
|
||||
/** 错误提示信息 */
|
||||
error?: string;
|
||||
error?: string
|
||||
/** 值变化回调 */
|
||||
onChange: (value: DragFillResult[]) => void;
|
||||
onChange: (value: DragFillResult[]) => void
|
||||
}
|
||||
|
||||
/** 单选题面板配置 */
|
||||
export interface SingleChoiceQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 题目 */
|
||||
title: string;
|
||||
title: string
|
||||
/** 选项列表 */
|
||||
options: SingleChoiceOption[];
|
||||
options: SingleChoiceOption[]
|
||||
/** 正确答案(可选,用于验证) */
|
||||
correctAnswer?: string;
|
||||
correctAnswer?: string
|
||||
/** 自定义错误提示信息(答案错误时显示) */
|
||||
errorMessage?: string;
|
||||
errorMessage?: string
|
||||
/** 提交回调,返回用户选择和是否正确 */
|
||||
onSubmit?: (value: string, isCorrect: boolean) => void;
|
||||
onSubmit?: (value: string, isCorrect: boolean) => void
|
||||
/** 答错时回调,可返回自定义解析文案覆盖默认 errorMessage */
|
||||
onWrongSelect?: (value: string) => string | void;
|
||||
onWrongSelect?: (value: string) => string | void
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
onError?: (error: string) => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 多选题面板配置 */
|
||||
export interface MultipleChoiceQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 题目 */
|
||||
title: string;
|
||||
title: string
|
||||
/** 选项列表 */
|
||||
options: MultipleChoiceOption[];
|
||||
options: MultipleChoiceOption[]
|
||||
/** 正确答案数组(可选,用于验证) */
|
||||
correctAnswer?: string[];
|
||||
correctAnswer?: string[]
|
||||
/** 自定义错误提示信息(答案错误时显示),可以是字符串或函数(根据用户选择返回不同提示) */
|
||||
errorMessage?: string | ((selectedValues: string[]) => string);
|
||||
errorMessage?: string | ((selectedValues: string[]) => string)
|
||||
/** 自定义最小选择数量不足时的提示信息 */
|
||||
minSelectionsErrorMessage?: string;
|
||||
minSelectionsErrorMessage?: string
|
||||
/** 提交回调,返回用户选择和是否正确 */
|
||||
onSubmit?: (values: string[], isCorrect: boolean) => void;
|
||||
onSubmit?: (values: string[], isCorrect: boolean) => void
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
onError?: (error: string) => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
/** 最小选择数量 */
|
||||
minSelections?: number;
|
||||
minSelections?: number
|
||||
/** 最大选择数量 */
|
||||
maxSelections?: number;
|
||||
maxSelections?: number
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
submitText?: string
|
||||
}
|
||||
|
||||
/** 连线题项 */
|
||||
export interface MatchQuizItem {
|
||||
/** 唯一标识 */
|
||||
id: string;
|
||||
id: string
|
||||
/** 显示文本 */
|
||||
label: string;
|
||||
label: string
|
||||
}
|
||||
|
||||
/** 连线题匹配 */
|
||||
export interface MatchQuizMatch {
|
||||
/** 左列项ID */
|
||||
leftId: string;
|
||||
leftId: string
|
||||
/** 右列项ID */
|
||||
rightId: string;
|
||||
rightId: string
|
||||
}
|
||||
|
||||
/** 连线题面板配置 */
|
||||
export interface MatchQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 题目 */
|
||||
title: string;
|
||||
title: string
|
||||
/** 左列项列表(日期) */
|
||||
leftItems: MatchQuizItem[];
|
||||
leftItems: MatchQuizItem[]
|
||||
/** 右列项列表(事件) */
|
||||
rightItems: MatchQuizItem[];
|
||||
rightItems: MatchQuizItem[]
|
||||
/** 正确答案匹配列表 */
|
||||
correctMatches: MatchQuizMatch[];
|
||||
correctMatches: MatchQuizMatch[]
|
||||
/**
|
||||
* 自定义错误提示信息(答案错误时显示)
|
||||
* 可以是固定文案,或根据当前匹配结果返回不同提示
|
||||
*/
|
||||
errorMessage?: string | ((matches: MatchQuizMatch[]) => string);
|
||||
errorMessage?: string | ((matches: MatchQuizMatch[]) => string)
|
||||
/** 提交回调,返回用户匹配和是否正确 */
|
||||
onSubmit?: (matches: MatchQuizMatch[], isCorrect: boolean) => void;
|
||||
onSubmit?: (matches: MatchQuizMatch[], isCorrect: boolean) => void
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
onError?: (error: string) => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 选择填空题中,每一个空的配置 */
|
||||
export interface SelectFillBlank {
|
||||
/** 唯一标识,用于和答案、结果关联 */
|
||||
id: string;
|
||||
id: string
|
||||
/** 空前面的文本(可选) */
|
||||
prefix?: string;
|
||||
prefix?: string
|
||||
/** 空后面的文本(可选) */
|
||||
suffix?: string;
|
||||
suffix?: string
|
||||
}
|
||||
|
||||
/** 选择填空题的答题结果 */
|
||||
export interface SelectFillResult {
|
||||
blankId: string;
|
||||
value: string;
|
||||
blankId: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 选择填空题面板配置 */
|
||||
export interface SelectFillQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 题目前缀整体文本(可选) */
|
||||
titlePrefix?: string;
|
||||
titlePrefix?: string
|
||||
/** 题目后缀整体文本(可选) */
|
||||
titleSuffix?: string;
|
||||
titleSuffix?: string
|
||||
/** 填空位配置(控制有几个空、空前后的说明文本) */
|
||||
blanks: SelectFillBlank[];
|
||||
blanks: SelectFillBlank[]
|
||||
/** 可选项列表(如 0-9 的数字、若干短语等) */
|
||||
options: { value: string; label: string }[];
|
||||
options: { value: string; label: string }[]
|
||||
/**
|
||||
* 正确答案映射
|
||||
* key 为 blankId,value 为该空对应的正确值
|
||||
*/
|
||||
correctAnswers?: Record<string, string>;
|
||||
correctAnswers?: Record<string, string>
|
||||
/** 自定义错误提示信息(答案错误时显示) */
|
||||
errorMessage?: string;
|
||||
errorMessage?: string
|
||||
/**
|
||||
* 提交回调,返回用户选择结果与是否全部正确
|
||||
* 注意:如果未提供 correctAnswers,则 isCorrect 恒为 true
|
||||
*/
|
||||
onSubmit?: (results: SelectFillResult[], isCorrect: boolean) => void;
|
||||
onSubmit?: (results: SelectFillResult[], isCorrect: boolean) => void
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
onError?: (error: string) => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
submitText?: string
|
||||
}
|
||||
|
||||
/** @deprecated 使用 SingleChoiceQuizPanelProps 替代 */
|
||||
export interface QuizPanelProps {
|
||||
visible: boolean;
|
||||
zIndex?: number;
|
||||
title: string;
|
||||
options: SingleChoiceOption[];
|
||||
value: string;
|
||||
error?: string;
|
||||
onChange?: (value: string) => void;
|
||||
visible: boolean
|
||||
zIndex?: number
|
||||
title: string
|
||||
options: SingleChoiceOption[]
|
||||
value: string
|
||||
error?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
Reference in new issue
Block a user