feat: 添加连线题和选择题功能,增强互动性
- 在多个组件中引入 MatchQuizPanel 和 SingleChoiceQuizPanel,提升用户参与度。 - 更新 HuangyanpeiPlace1、HuangyanpeiPlace3、LuxunPlace1、LuxunPlace2 组件,增加新的选择题和连线题,丰富学习内容。 - 调整成功提示逻辑,确保用户体验流畅。 - 优化视频播放中的锚点逻辑,提升互动性和学习效果。
This commit is contained in:
1 parent
02a3f00a9b
commit
966b47ede3
12 files changed
+1128
-42
No files matched your search
@@ -0,0 +1,337 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MatchQuizPanelProps } from "./types";
|
||||
|
||||
/**
|
||||
* 连线题面板组件
|
||||
* 支持移动端触摸操作
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MatchQuizPanel
|
||||
* visible={true}
|
||||
* title="请将日期与对应的事件连线"
|
||||
* leftItems={[
|
||||
* { id: "1", label: "1892-1898年" },
|
||||
* { id: "2", label: "1919-1923年" },
|
||||
* ]}
|
||||
* rightItems={[
|
||||
* { id: "a", label: "在绍兴三味书屋求学积淀" },
|
||||
* { id: "b", label: "于北京八道湾参加新文化运动" },
|
||||
* ]}
|
||||
* correctMatches={[
|
||||
* { leftId: "1", rightId: "a" },
|
||||
* { leftId: "2", rightId: "b" },
|
||||
* ]}
|
||||
* onSubmit={(matches, isCorrect) => {
|
||||
* console.log("匹配结果:", matches, "是否正确:", isCorrect);
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const MatchQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
leftItems,
|
||||
rightItems,
|
||||
correctMatches,
|
||||
errorMessage,
|
||||
onSubmit,
|
||||
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);
|
||||
|
||||
// 当窗口关闭时,重置状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setMatches([]);
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 处理左列项点击
|
||||
const handleLeftItemClick = (leftId: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
// 如果已经匹配过,先取消匹配
|
||||
const existingMatch = matches.find((m) => m.leftId === leftId);
|
||||
if (existingMatch) {
|
||||
setMatches(matches.filter((m) => m.leftId !== leftId));
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已经选中了另一个左项,取消之前的选中
|
||||
if (selectedLeftId && selectedLeftId !== leftId) {
|
||||
setSelectedLeftId(leftId);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
|
||||
// 切换选中状态
|
||||
setSelectedLeftId(selectedLeftId === leftId ? null : leftId);
|
||||
setError("");
|
||||
};
|
||||
|
||||
// 处理右列项点击
|
||||
const handleRightItemClick = (rightId: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
// 如果没有选中左项,直接返回
|
||||
if (!selectedLeftId) return;
|
||||
|
||||
// 如果已经匹配过,先取消匹配
|
||||
const existingMatch = matches.find((m) => m.rightId === rightId);
|
||||
if (existingMatch) {
|
||||
setMatches(matches.filter((m) => m.rightId !== rightId));
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建新的匹配
|
||||
const newMatch = { leftId: selectedLeftId, rightId };
|
||||
const newMatches = [...matches, newMatch];
|
||||
setMatches(newMatches);
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
|
||||
// 检查是否所有项都已匹配
|
||||
if (newMatches.length === leftItems.length && newMatches.length === rightItems.length) {
|
||||
// 验证答案
|
||||
const isCorrect = validateMatches(newMatches);
|
||||
if (isCorrect) {
|
||||
onSubmit?.(newMatches, true);
|
||||
} else {
|
||||
const errorMsg = errorMessage || "连线不正确,请重新尝试!";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
// 清空匹配,允许重新尝试
|
||||
setTimeout(() => {
|
||||
setMatches([]);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 验证匹配是否正确
|
||||
const validateMatches = (matchesToValidate: Array<{ leftId: string; rightId: string }>) => {
|
||||
if (!correctMatches || matchesToValidate.length !== correctMatches.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查每个匹配是否正确
|
||||
for (const match of matchesToValidate) {
|
||||
const isCorrect = correctMatches.some(
|
||||
(cm) => cm.leftId === match.leftId && cm.rightId === match.rightId
|
||||
);
|
||||
if (!isCorrect) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 获取左项是否已匹配
|
||||
const isLeftItemMatched = (leftId: string) => {
|
||||
return matches.some((m) => m.leftId === leftId);
|
||||
};
|
||||
|
||||
// 获取右项是否已匹配
|
||||
const isRightItemMatched = (rightId: string) => {
|
||||
return matches.some((m) => m.rightId === rightId);
|
||||
};
|
||||
|
||||
// 获取左项对应的右项ID
|
||||
const getMatchedRightId = (leftId: string) => {
|
||||
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;
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="relative w-full max-w-[1728px] h-full max-h-[972px] flex flex-col items-center justify-center p-4 md:p-8">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-full max-w-[1700px] rounded-[32px] bg-black/90 px-6 md:px-20 py-6 md:py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] mb-4 md:mb-8">
|
||||
<div className="text-[24px] md:text-[34px] leading-relaxed text-white text-center">
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-4 md:mt-5 text-center text-[20px] md:text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 连线区域 */}
|
||||
<div className="w-full max-w-[1600px] flex flex-col md:flex-row gap-4 md:gap-8 items-start md:items-center justify-center">
|
||||
{/* 左列:日期 */}
|
||||
<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 matchedRightId = getMatchedRightId(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",
|
||||
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]"
|
||||
)}
|
||||
onClick={() => handleLeftItemClick(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
// 移动端触摸优化
|
||||
e.currentTarget.classList.add("scale-105");
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.classList.remove("scale-105");
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{isMatched && (
|
||||
<div className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-xs md:text-sm">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 中间连线区域(移动端隐藏,桌面端显示) */}
|
||||
<div className="hidden md:block w-16 h-full min-h-[300px] relative flex-shrink-0">
|
||||
{/* 绘制连线 */}
|
||||
<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 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;
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${match.leftId}-${match.rightId}-${index}`}
|
||||
x1="0"
|
||||
y1={startY}
|
||||
x2="100%"
|
||||
y2={endY}
|
||||
stroke="#f0be75"
|
||||
strokeWidth="3"
|
||||
strokeDasharray="5,5"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* 箭头标记定义 */}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
refX="9"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3, 0 6" fill="#f0be75" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 右列:事件 */}
|
||||
<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 matchedLeftId = getMatchedLeftId(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", // 优化移动端触摸
|
||||
disabled || isMatched
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: selectedLeftId
|
||||
? "cursor-pointer"
|
||||
: "cursor-default",
|
||||
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"
|
||||
)}
|
||||
onClick={() => handleRightItemClick(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
if (selectedLeftId) {
|
||||
e.currentTarget.classList.add("scale-105");
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.classList.remove("scale-105");
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{isMatched && (
|
||||
<div className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-xs md:text-sm">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端连线提示 */}
|
||||
<div className="md:hidden mt-4 text-center text-[18px] text-white/80">
|
||||
请先点击左侧日期,再点击右侧对应的事件进行连线
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchQuizPanel;
|
||||
|
||||
Reference in new issue
Block a user