import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from '@/lib/utils' import type { MatchQuizPanelProps } from './types' /** * 连线题面板组件 * 支持移动端触摸操作 * * @example * ```tsx * { * console.log("匹配结果:", matches, "是否正确:", isCorrect); * }} * /> * ``` */ const MatchQuizPanel = ({ visible, zIndex = 10000, title, leftItems, rightItems, correctMatches, errorMessage, onSubmit, onError, disabled = false, }: MatchQuizPanelProps) => { const [matches, setMatches] = useState>([]) const [selectedLeftId, setSelectedLeftId] = useState(null) const [error, setError] = useState('') const containerRef = useRef(null) const middleRef = useRef(null) const leftColRef = useRef(null) const rightColRef = useRef(null) const leftItemRefs = useRef>({}) const rightItemRefs = useRef>({}) // 当前错误的连线对,用于做红色高亮+抖动反馈 const [errorPairs, setErrorPairs] = useState>([]) const [leftCenters, setLeftCenters] = useState>({}) const [rightCenters, setRightCenters] = useState>({}) const [middleHeight, setMiddleHeight] = useState(300) const rafIdRef = useRef(null) // 当窗口关闭时,重置状态 useEffect(() => { if (!visible) { setMatches([]) setErrorPairs([]) 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] // 每次连线后立即校验当前所有连线是否存在错误 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) { onSubmit?.(newMatches, true) } } // 获取左项是否已匹配 const isLeftItemMatched = (leftId: string) => { return matches.some((m) => m.leftId === leftId) } // 获取右项是否已匹配 const isRightItemMatched = (rightId: string) => { return matches.some((m) => m.rightId === rightId) } const measureCenters = useCallback(() => { const middleEl = middleRef.current if (!middleEl) return const middleRect = middleEl.getBoundingClientRect() const nextLeft: Record = {} const nextRight: Record = {} // 显式同步中间连线层高度,避免因 flex/stretch/内容换行导致坐标系基准漂移 const leftRect = leftColRef.current?.getBoundingClientRect() const rightRect = rightColRef.current?.getBoundingClientRect() const nextMiddleHeight = Math.max(leftRect?.height ?? 0, rightRect?.height ?? 0, 300) setMiddleHeight(nextMiddleHeight) for (const item of leftItems) { const el = leftItemRefs.current[item.id] if (!el) continue const rect = el.getBoundingClientRect() nextLeft[item.id] = rect.top + rect.height / 2 - middleRect.top } for (const item of rightItems) { const el = rightItemRefs.current[item.id] if (!el) continue const rect = el.getBoundingClientRect() nextRight[item.id] = rect.top + rect.height / 2 - middleRect.top } setLeftCenters(nextLeft) setRightCenters(nextRight) }, [leftItems, rightItems]) const scheduleMeasure = useCallback(() => { if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current) rafIdRef.current = null } // 双 RAF:等待浏览器完成一帧布局/绘制(含 transform/transition/pulse 的本帧生效) rafIdRef.current = requestAnimationFrame(() => { rafIdRef.current = requestAnimationFrame(() => { rafIdRef.current = null measureCenters() }) }) }, [measureCenters]) // 首次展示、匹配变化、选中/错误状态变化都会影响位置(含动画/缩放),延后一帧再测 useEffect(() => { if (!visible) return scheduleMeasure() }, [visible, matches.length, errorPairs.length, selectedLeftId, scheduleMeasure]) // 监听尺寸变化(窗口 resize / 容器尺寸变化 / 文本换行) useEffect(() => { if (!visible) return const middleEl = middleRef.current if (!middleEl) return const handleResize = () => scheduleMeasure() window.addEventListener('resize', handleResize) const ro = new ResizeObserver(() => scheduleMeasure()) ro.observe(middleEl) if (containerRef.current) ro.observe(containerRef.current) if (leftColRef.current) ro.observe(leftColRef.current) if (rightColRef.current) ro.observe(rightColRef.current) return () => { window.removeEventListener('resize', handleResize) if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current) ro.disconnect() } }, [visible, scheduleMeasure]) if (!visible) return null return (
{/* 题干卡片 */}
{title}
{/* 答案反馈 */} {error && (
{error}
)}
{/* 连线区域 */}
{/* 左列:日期 */}
{leftItems.map((item) => { const isMatched = isLeftItemMatched(item.id) const isSelected = selectedLeftId === item.id const isError = errorPairs.some((p) => p.leftId === item.id) return (
{ leftItemRefs.current[item.id] = el }} 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', 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]' : 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-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} {isError && (
✕
)} {!isError && isMatched && (
✓
)}
) })}
{/* 中间连线区域(移动端隐藏,桌面端显示) */}
{/* 绘制连线 */} {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 startY = leftCenters[match.leftId] const endY = rightCenters[match.rightId] if (startY === undefined || endY === undefined) return null return ( ) })} {/* 箭头标记定义 */}
{/* 右列:事件 */}
{rightItems.map((item) => { const isMatched = isRightItemMatched(item.id) const isError = errorPairs.some((p) => p.rightId === item.id) return (
{ rightItemRefs.current[item.id] = el }} 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', 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-linear-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} {isError && (
✕
)} {!isError && isMatched && (
✓
)}
) })}
{/* 移动端连线提示 */}
请先点击左侧日期,再点击右侧对应的事件进行连线
) } export default MatchQuizPanel