Files
shanghai-residences/src/components/layout/quiz/MatchQuizPanel.tsx
T
zhongyidian e0fa47d21a 更新依赖项及历史人物相关内容资源
- 更新 package.json 和 package-lock.json 中的依赖项
- 新增/更新陈云、黄炎培、鲁迅、孙中山的音频文件
- 新增场景和组件的图片资源
- 清理未使用的音频和图片文件
- 优化多个组件和页面的实现逻辑
2026-04-20 13:27:18 +08:00

438 lines
16 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } 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)
const middleRef = useRef<HTMLDivElement>(null)
const leftColRef = useRef<HTMLDivElement>(null)
const rightColRef = useRef<HTMLDivElement>(null)
const leftItemRefs = useRef<Record<string, HTMLDivElement | null>>({})
const rightItemRefs = useRef<Record<string, HTMLDivElement | null>>({})
// 当前错误的连线对,用于做红色高亮+抖动反馈
const [errorPairs, setErrorPairs] = useState<Array<{ leftId: string; rightId: string }>>([])
const [leftCenters, setLeftCenters] = useState<Record<string, number>>({})
const [rightCenters, setRightCenters] = useState<Record<string, number>>({})
const [middleHeight, setMiddleHeight] = useState<number>(300)
const rafIdRef = useRef<number | null>(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<string, number> = {}
const nextRight: Record<string, number> = {}
// 显式同步中间连线层高度,避免因 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 (
<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-stretch justify-center">
{/* 左列:日期 */}
<div ref={leftColRef} 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)
return (
<div
key={item.id}
ref={(el) => {
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 && (
<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>
)}
</div>
)
})}
</div>
{/* 中间连线区域(移动端隐藏,桌面端显示) */}
<div
ref={middleRef}
className="hidden md:block w-16 relative shrink-0"
style={{ height: middleHeight }}
>
{/* 绘制连线 */}
<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 startY = leftCenters[match.leftId]
const endY = rightCenters[match.rightId]
if (startY === undefined || endY === undefined) return null
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 ref={rightColRef} 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)
return (
<div
key={item.id}
ref={(el) => {
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 && (
<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>
)}
</div>
)
})}
</div>
</div>
{/* 移动端连线提示 */}
<div className="md:hidden mt-4 text-center text-[18px] text-white/80">
请先点击左侧日期,再点击右侧对应的事件进行连线
</div>
</div>
</div>
)
}
export default MatchQuizPanel