feat: add puzzle residence configuration and reflection progress logic
- Introduced `puzzleResidences.ts` to define puzzle residence configurations including properties like display name, position, and puzzle availability. - Added `reflectionProgress.ts` to manage badge unlocking logic based on place completion, including functions to determine unlocked badges and video sources for scenes.
This commit is contained in:
1 parent
e0fa47d21a
commit
f5f36f75b5
153 files changed
+3846
-2352
No files matched your search
@@ -1,34 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { MatchQuizPanelProps } from './types'
|
||||
import { useScale } from '@/contexts/ScaleContext'
|
||||
import type { MatchQuizItem, 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,
|
||||
@@ -41,23 +15,26 @@ const MatchQuizPanel = ({
|
||||
onError,
|
||||
disabled = false,
|
||||
}: MatchQuizPanelProps) => {
|
||||
const { scale } = useScale()
|
||||
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 quizAreaRef = useRef<HTMLDivElement>(null)
|
||||
const leftItemRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
const rightItemRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
// 当前错误的连线对,用于做红色高亮+抖动反馈
|
||||
// ✓/✕ 圆点的 ref
|
||||
const leftDotRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
const rightDotRefs = 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)
|
||||
// 连线端点坐标(相对于 quizArea)
|
||||
const [lineAnchors, setLineAnchors] = useState<
|
||||
Record<string, { startX: number; startY: number; endX: number; endY: number }>
|
||||
>({})
|
||||
const [svgSize, setSvgSize] = useState({ width: 0, height: 300 })
|
||||
const rafIdRef = useRef<number | null>(null)
|
||||
const useRowAlignedDesktopLayout = leftItems.length === rightItems.length
|
||||
|
||||
// 当窗口关闭时,重置状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setMatches([])
|
||||
@@ -67,11 +44,9 @@ const MatchQuizPanel = ({
|
||||
}
|
||||
}, [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))
|
||||
@@ -80,41 +55,35 @@ const MatchQuizPanel = ({
|
||||
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)
|
||||
if (existingMatch.leftId === selectedLeftId) {
|
||||
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 存在时才校验
|
||||
correctMatches?.filter(() => true) &&
|
||||
newMatches.filter(
|
||||
(match) =>
|
||||
!correctMatches?.some((cm) => cm.leftId === match.leftId && cm.rightId === match.rightId)
|
||||
@@ -123,7 +92,6 @@ const MatchQuizPanel = ({
|
||||
const hasError = !!wrongMatches && wrongMatches.length > 0
|
||||
|
||||
if (hasError && wrongMatches) {
|
||||
// 记录错误连线对,用于红色高亮+抖动反馈
|
||||
setErrorPairs(wrongMatches)
|
||||
setMatches(newMatches)
|
||||
setSelectedLeftId(null)
|
||||
@@ -137,9 +105,9 @@ const MatchQuizPanel = ({
|
||||
} else {
|
||||
errorMsg = '连线不正确,请重新尝试!'
|
||||
}
|
||||
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
// 短暂展示错误状态(变红+抖动),随后只清空错误连线,保留已答对的连线
|
||||
setTimeout(() => {
|
||||
setMatches((prev) =>
|
||||
prev.filter(
|
||||
@@ -151,93 +119,124 @@ const MatchQuizPanel = ({
|
||||
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 isLeftItemMatched = (leftId: string) => matches.some((m) => m.leftId === leftId)
|
||||
const isRightItemMatched = (rightId: string) => matches.some((m) => m.rightId === rightId)
|
||||
|
||||
// 获取右项是否已匹配
|
||||
const isRightItemMatched = (rightId: string) => {
|
||||
return matches.some((m) => m.rightId === rightId)
|
||||
}
|
||||
const getAnchorForMatch = useCallback(
|
||||
(match: { leftId: string; rightId: string }) => {
|
||||
const quizAreaEl = quizAreaRef.current
|
||||
if (!quizAreaEl) return null
|
||||
|
||||
const measureCenters = useCallback(() => {
|
||||
const middleEl = middleRef.current
|
||||
if (!middleEl) return
|
||||
const leftCard = leftItemRefs.current[match.leftId]
|
||||
const rightCard = rightItemRefs.current[match.rightId]
|
||||
const leftDot = leftDotRefs.current[match.leftId]
|
||||
const rightDot = rightDotRefs.current[match.rightId]
|
||||
if (!leftCard || !rightCard || !leftDot || !rightDot) return null
|
||||
|
||||
const middleRect = middleEl.getBoundingClientRect()
|
||||
const nextLeft: Record<string, number> = {}
|
||||
const nextRight: Record<string, number> = {}
|
||||
const areaRect = quizAreaEl.getBoundingClientRect()
|
||||
const leftAnchorRect = leftDot.getBoundingClientRect()
|
||||
const rightAnchorRect = rightDot.getBoundingClientRect()
|
||||
|
||||
// 显式同步中间连线层高度,避免因 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)
|
||||
return {
|
||||
startX: (leftAnchorRect.left + leftAnchorRect.width / 2 - areaRect.left) / scale,
|
||||
startY: (leftAnchorRect.top + leftAnchorRect.height / 2 - areaRect.top) / scale,
|
||||
endX: (rightAnchorRect.left + rightAnchorRect.width / 2 - areaRect.left) / scale,
|
||||
endY: (rightAnchorRect.top + rightAnchorRect.height / 2 - areaRect.top) / scale,
|
||||
}
|
||||
},
|
||||
[scale]
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
const measureAnchors = useCallback(() => {
|
||||
const quizAreaEl = quizAreaRef.current
|
||||
if (!quizAreaEl) return
|
||||
|
||||
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()
|
||||
})
|
||||
const areaRect = quizAreaEl.getBoundingClientRect()
|
||||
setSvgSize({
|
||||
width: areaRect.width / scale,
|
||||
height: areaRect.height / scale,
|
||||
})
|
||||
}, [measureCenters])
|
||||
|
||||
// 首次展示、匹配变化、选中/错误状态变化都会影响位置(含动画/缩放),延后一帧再测
|
||||
const next: Record<string, { startX: number; startY: number; endX: number; endY: number }> = {}
|
||||
|
||||
for (const match of matches) {
|
||||
const anchor = getAnchorForMatch(match)
|
||||
if (!anchor) continue
|
||||
next[`${match.leftId}-${match.rightId}`] = anchor
|
||||
}
|
||||
|
||||
setLineAnchors(next)
|
||||
}, [getAnchorForMatch, matches, scale])
|
||||
|
||||
const scheduleMeasure = useCallback(
|
||||
(mode: 'all' | 'new-only' = 'all') => {
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
rafIdRef.current = null
|
||||
}
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null
|
||||
|
||||
if (mode === 'all') {
|
||||
measureAnchors()
|
||||
return
|
||||
}
|
||||
|
||||
const quizAreaEl = quizAreaRef.current
|
||||
if (!quizAreaEl) return
|
||||
|
||||
const areaRect = quizAreaEl.getBoundingClientRect()
|
||||
setSvgSize({
|
||||
width: areaRect.width / scale,
|
||||
height: areaRect.height / scale,
|
||||
})
|
||||
|
||||
setLineAnchors((prev) => {
|
||||
const next: Record<
|
||||
string,
|
||||
{ startX: number; startY: number; endX: number; endY: number }
|
||||
> = {}
|
||||
|
||||
for (const match of matches) {
|
||||
const key = `${match.leftId}-${match.rightId}`
|
||||
next[key] = prev[key] ??
|
||||
getAnchorForMatch(match) ?? { startX: 0, startY: 0, endX: 0, endY: 0 }
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
[getAnchorForMatch, matches, measureAnchors, scale]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
scheduleMeasure()
|
||||
}, [visible, matches.length, errorPairs.length, selectedLeftId, scheduleMeasure])
|
||||
scheduleMeasure('new-only')
|
||||
}, [visible, matches, scheduleMeasure])
|
||||
|
||||
// 监听尺寸变化(窗口 resize / 容器尺寸变化 / 文本换行)
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
const middleEl = middleRef.current
|
||||
if (!middleEl) return
|
||||
const quizAreaEl = quizAreaRef.current
|
||||
if (!quizAreaEl) return
|
||||
|
||||
const handleResize = () => scheduleMeasure()
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
const ro = new ResizeObserver(() => scheduleMeasure())
|
||||
ro.observe(middleEl)
|
||||
ro.observe(quizAreaEl)
|
||||
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)
|
||||
@@ -246,6 +245,125 @@ const MatchQuizPanel = ({
|
||||
}
|
||||
}, [visible, scheduleMeasure])
|
||||
|
||||
const renderLeftItem = (item: MatchQuizItem) => {
|
||||
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
|
||||
ref={(el) => {
|
||||
leftDotRefs.current[item.id] = el
|
||||
}}
|
||||
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
|
||||
ref={(el) => {
|
||||
leftDotRefs.current[item.id] = el
|
||||
}}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const renderRightItem = (item: MatchQuizItem) => {
|
||||
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
|
||||
ref={(el) => {
|
||||
rightDotRefs.current[item.id] = el
|
||||
}}
|
||||
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
|
||||
ref={(el) => {
|
||||
rightDotRefs.current[item.id] = el
|
||||
}}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
@@ -255,13 +373,9 @@ const MatchQuizPanel = ({
|
||||
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>
|
||||
<div className="text-[24px] md:text-[34px] leading-relaxed text-white">{title}</div>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-4 md:mt-5 text-center text-[20px] md:text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
@@ -269,163 +383,78 @@ const MatchQuizPanel = ({
|
||||
)}
|
||||
</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 className="w-full max-w-[1600px] md:hidden flex flex-col gap-4 items-start justify-center">
|
||||
<div className="w-full flex flex-col gap-3">{leftItems.map(renderLeftItem)}</div>
|
||||
<div className="w-full flex flex-col gap-3">{rightItems.map(renderRightItem)}</div>
|
||||
</div>
|
||||
|
||||
{/* 桌面端 */}
|
||||
<div ref={quizAreaRef} className="hidden md:block w-full max-w-[1600px] relative">
|
||||
{/* SVG 覆盖整个题目区域,用绝对像素坐标画线 */}
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none overflow-visible z-10"
|
||||
width={svgSize.width}
|
||||
height={svgSize.height}
|
||||
viewBox={`0 0 ${Math.max(svgSize.width, 1)} ${Math.max(svgSize.height, 1)}`}
|
||||
>
|
||||
{matches.map((match, index) => {
|
||||
const key = `${match.leftId}-${match.rightId}`
|
||||
const anchor = lineAnchors[key]
|
||||
if (!anchor) return null
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${key}-${index}`}
|
||||
x1={anchor.startX}
|
||||
y1={anchor.startY}
|
||||
x2={anchor.endX}
|
||||
y2={anchor.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>
|
||||
|
||||
{useRowAlignedDesktopLayout ? (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_64px_minmax(0,1fr)] gap-x-8 gap-y-4 items-stretch">
|
||||
{leftItems.map((leftItem, index) => {
|
||||
const rightItem = rightItems[index]
|
||||
if (!rightItem) return null
|
||||
|
||||
return (
|
||||
<div key={`row-${leftItem.id}-${rightItem.id}`} className="contents">
|
||||
<div className="min-h-full">{renderLeftItem(leftItem)}</div>
|
||||
<div className="w-16" />
|
||||
<div className="min-h-full">{renderRightItem(rightItem)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-8 items-stretch justify-center">
|
||||
<div className="w-1/2 flex flex-col gap-4">{leftItems.map(renderLeftItem)}</div>
|
||||
<div className="w-16 shrink-0" />
|
||||
<div className="w-1/2 flex flex-col gap-4">{rightItems.map(renderRightItem)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 移动端连线提示 */}
|
||||
<div className="md:hidden mt-4 text-center text-[18px] text-white/80">
|
||||
请先点击左侧日期,再点击右侧对应的事件进行连线
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user