feat: 增强连线题功能,添加错误反馈机制

- 在 MatchQuizPanel 组件中引入错误连线对的状态管理,提供红色高亮和抖动反馈。
- 更新匹配逻辑,确保用户在连线错误时能及时获得反馈信息。
- 修改类型定义,支持自定义错误提示信息的函数形式,提升灵活性。
- 优化用户体验,确保在错误状态下的匹配项能正确展示。
This commit is contained in:
zhongyidian committed 2026-03-03 13:27:25 +08:00
1 parent b91f2430c6
commit 5d00adc1a5
43 files changed
+1662 -54

No files matched your search

+79 -40
View File
@@ -45,11 +45,16 @@ const MatchQuizPanel = ({
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 }>
>([]);
// 当窗口关闭时,重置状态
useEffect(() => {
if (!visible) {
setMatches([]);
setErrorPairs([]);
setSelectedLeftId(null);
setError("");
}
@@ -99,47 +104,67 @@ const MatchQuizPanel = ({
// 创建新的匹配
const newMatch = { leftId: selectedLeftId, rightId };
const newMatches = [...matches, newMatch];
// 每次连线后立即校验当前所有连线是否存在错误
const wrongMatches =
correctMatches?.filter(
() => true
) && // 保证 correctMatches 存在时才校验
newMatches.filter(
(match) =>
!correctMatches?.some(
(cm) => cm.leftId === match.leftId && cm.rightId === match.rightId
)
);
const hasError = !!wrongMatches && wrongMatches.length > 0;
if (hasError && wrongMatches) {
// 记录错误连线对,用于红色高亮+抖动反馈
setErrorPairs(wrongMatches);
setMatches(newMatches);
setSelectedLeftId(null);
setError("");
let errorMsg: string;
if (typeof errorMessage === "function") {
errorMsg = errorMessage(newMatches);
} else if (errorMessage) {
errorMsg = errorMessage;
} else {
errorMsg = "连线不正确,请重新尝试!";
}
setError(errorMsg);
onError?.(errorMsg);
// 短暂展示错误状态(变红+抖动),随后只清空错误连线,保留已答对的连线
setTimeout(() => {
setMatches((prev) =>
prev.filter(
(m) =>
!wrongMatches.some(
(w) => w.leftId === m.leftId && w.rightId === m.rightId
)
)
);
setErrorPairs([]);
}, 600);
return;
}
// 没有错误时,正常记录匹配
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);
}
// 如果没有错误且所有项都已连完,则认为答题成功
if (
newMatches.length === leftItems.length &&
newMatches.length === rightItems.length
) {
onSubmit?.(newMatches, true);
}
};
// 验证匹配是否正确
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);
@@ -192,7 +217,7 @@ const MatchQuizPanel = ({
{leftItems.map((item) => {
const isMatched = isLeftItemMatched(item.id);
const isSelected = selectedLeftId === item.id;
const matchedRightId = getMatchedRightId(item.id);
const isError = errorPairs.some((p) => p.leftId === item.id);
return (
<div
@@ -204,7 +229,9 @@ const MatchQuizPanel = ({
disabled || isMatched
? "opacity-60 cursor-not-allowed"
: "cursor-pointer",
isSelected
isError
? "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]"
@@ -220,7 +247,12 @@ const MatchQuizPanel = ({
}}
>
{item.label}
{isMatched && (
{isError && (
<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-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]">
<span className="text-white text-[10px] md:text-xs">✕</span>
</div>
)}
{!isError && 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>
@@ -282,7 +314,7 @@ 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 matchedLeftId = getMatchedLeftId(item.id);
const isError = errorPairs.some((p) => p.rightId === item.id);
return (
<div
@@ -296,7 +328,9 @@ const MatchQuizPanel = ({
: selectedLeftId
? "cursor-pointer"
: "cursor-default",
isMatched
isError
? "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]"
@@ -313,7 +347,12 @@ const MatchQuizPanel = ({
}}
>
{item.label}
{isMatched && (
{isError && (
<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-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]">
<span className="text-white text-[10px] md:text-xs">✕</span>
</div>
)}
{!isError && 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>