511 lines
12 KiB
Markdown
511 lines
12 KiB
Markdown
|
|
# 匹配题连线功能实现文档
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
本文档详细介绍了基于React + TypeScript + SVG实现的交互式匹配题连线功能。该功能允许用户通过点击和拖拽的方式,将左侧选项与右侧选项进行连线匹配,并提供实时视觉反馈和答案记录功能。
|
|||
|
|
|
|||
|
|
## 技术架构
|
|||
|
|
|
|||
|
|
### 核心技术栈
|
|||
|
|
- **React 18** - 组件化开发和状态管理
|
|||
|
|
- **TypeScript** - 类型安全和代码可维护性
|
|||
|
|
- **SVG** - 矢量图形绘制连线
|
|||
|
|
- **CSS3** - 样式和动画效果
|
|||
|
|
- **Framer Motion** - 高级动画库(可选)
|
|||
|
|
|
|||
|
|
### 系统架构图
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
用户交互层
|
|||
|
|
↓
|
|||
|
|
事件处理层 (鼠标点击/移动)
|
|||
|
|
↓
|
|||
|
|
状态管理层 (React Hooks)
|
|||
|
|
↓
|
|||
|
|
SVG渲染层 (连线可视化)
|
|||
|
|
↓
|
|||
|
|
数据持久层 (答案存储)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 核心数据结构
|
|||
|
|
|
|||
|
|
### 1. 连线接口定义
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 完成的连线
|
|||
|
|
interface Line {
|
|||
|
|
start: { x: number; y: number }; // 起点坐标
|
|||
|
|
end: { x: number; y: number }; // 终点坐标
|
|||
|
|
leftId: string; // 左侧选项ID
|
|||
|
|
rightId: string; // 右侧选项ID
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 正在绘制的连线
|
|||
|
|
interface ActiveLine {
|
|||
|
|
start: { x: number; y: number }; // 起点坐标
|
|||
|
|
end?: { x: number; y: number }; // 终点坐标(可选,跟随鼠标)
|
|||
|
|
leftId: string; // 左侧选项ID
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 匹配题数据结构
|
|||
|
|
interface MatchingQuestion {
|
|||
|
|
id: string;
|
|||
|
|
leftItems: Array<{ id: string; text: string }>;
|
|||
|
|
rightItems: Array<{ id: string; text: string }>;
|
|||
|
|
correctMatches: { [leftId: string]: string };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. 状态管理
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
const [lines, setLines] = useState<Line[]>([]); // 已完成连线
|
|||
|
|
const [activeLine, setActiveLine] = useState<ActiveLine | null>(null); // 当前绘制连线
|
|||
|
|
const [answers, setAnswers] = useState<{ [key: string]: string }>({}); // 用户答案
|
|||
|
|
const itemRefs = useRef<{ [key: string]: HTMLDivElement }>({}); // DOM元素引用
|
|||
|
|
const svgRef = useRef<SVGSVGElement>(null); // SVG容器引用
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 核心功能实现
|
|||
|
|
|
|||
|
|
### 1. 坐标计算系统
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
/**
|
|||
|
|
* 计算元素中心点坐标(相对于SVG容器)
|
|||
|
|
* @param element - 目标DOM元素
|
|||
|
|
* @returns 中心点坐标
|
|||
|
|
*/
|
|||
|
|
const getItemCenter = (element: HTMLElement) => {
|
|||
|
|
const rect = element.getBoundingClientRect();
|
|||
|
|
const svgRect = svgRef.current?.getBoundingClientRect() || { left: 0, top: 0 };
|
|||
|
|
return {
|
|||
|
|
x: rect.left + rect.width / 2 - svgRect.left,
|
|||
|
|
y: rect.top + rect.height / 2 - svgRect.top
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. 连线开始处理
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
/**
|
|||
|
|
* 处理左侧选项点击事件
|
|||
|
|
* @param leftId - 左侧选项ID
|
|||
|
|
* @param e - 鼠标事件
|
|||
|
|
*/
|
|||
|
|
const handleLeftItemClick = (leftId: string, e: React.MouseEvent) => {
|
|||
|
|
const element = itemRefs.current[leftId];
|
|||
|
|
if (element) {
|
|||
|
|
// 如果该项已经连线,先移除现有连线
|
|||
|
|
if (lines.some(line => line.leftId === leftId)) {
|
|||
|
|
setLines(prev => prev.filter(line => line.leftId !== leftId));
|
|||
|
|
setAnswers(prev => {
|
|||
|
|
const newAnswers = { ...prev };
|
|||
|
|
delete newAnswers[`${leftId}_match`];
|
|||
|
|
return newAnswers;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 创建新的活动连线
|
|||
|
|
const center = getItemCenter(element);
|
|||
|
|
setActiveLine({
|
|||
|
|
start: center,
|
|||
|
|
leftId
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3. 实时连线跟踪
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
/**
|
|||
|
|
* 处理鼠标移动事件(连线跟随鼠标)
|
|||
|
|
* @param e - 鼠标事件
|
|||
|
|
*/
|
|||
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|||
|
|
if (activeLine) {
|
|||
|
|
const svgRect = svgRef.current?.getBoundingClientRect();
|
|||
|
|
if (svgRect) {
|
|||
|
|
setActiveLine({
|
|||
|
|
...activeLine,
|
|||
|
|
end: {
|
|||
|
|
x: e.clientX - svgRect.left,
|
|||
|
|
y: e.clientY - svgRect.top
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4. 连线完成处理
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
/**
|
|||
|
|
* 处理右侧选项点击事件
|
|||
|
|
* @param rightId - 右侧选项ID
|
|||
|
|
* @param e - 鼠标事件
|
|||
|
|
*/
|
|||
|
|
const handleRightItemClick = (rightId: string, e: React.MouseEvent) => {
|
|||
|
|
if (activeLine) {
|
|||
|
|
const element = itemRefs.current[rightId];
|
|||
|
|
if (element) {
|
|||
|
|
// 防止重复连接
|
|||
|
|
if (lines.some(line => line.rightId === rightId)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果左侧项已经有其他连线,先移除
|
|||
|
|
const existingLine = lines.find(line => line.leftId === activeLine.leftId);
|
|||
|
|
if (existingLine) {
|
|||
|
|
setLines(prev => prev.filter(line => line.leftId !== activeLine.leftId));
|
|||
|
|
setAnswers(prev => {
|
|||
|
|
const newAnswers = { ...prev };
|
|||
|
|
delete newAnswers[`${activeLine.leftId}_match`];
|
|||
|
|
return newAnswers;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 创建新连线
|
|||
|
|
const center = getItemCenter(element);
|
|||
|
|
setLines(prev => [...prev, {
|
|||
|
|
start: activeLine.start,
|
|||
|
|
end: center,
|
|||
|
|
leftId: activeLine.leftId,
|
|||
|
|
rightId
|
|||
|
|
}]);
|
|||
|
|
|
|||
|
|
setActiveLine(null);
|
|||
|
|
|
|||
|
|
// 更新答案
|
|||
|
|
setAnswers(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
[`${activeLine.leftId}_match`]: rightId
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## SVG可视化系统
|
|||
|
|
|
|||
|
|
### 1. SVG容器结构
|
|||
|
|
|
|||
|
|
```jsx
|
|||
|
|
<div
|
|||
|
|
className="relative grid md:grid-cols-2 gap-8 min-h-[400px]"
|
|||
|
|
onMouseMove={handleMouseMove}
|
|||
|
|
onMouseLeave={() => setActiveLine(null)}
|
|||
|
|
>
|
|||
|
|
<svg
|
|||
|
|
ref={svgRef}
|
|||
|
|
className="absolute inset-0 pointer-events-none"
|
|||
|
|
style={{ zIndex: 1, width: '100%', height: '100%' }}
|
|||
|
|
>
|
|||
|
|
{/* 已完成的连线 */}
|
|||
|
|
{lines.map((line, i) => (
|
|||
|
|
<g key={i}>
|
|||
|
|
<line
|
|||
|
|
x1={line.start.x}
|
|||
|
|
y1={line.start.y}
|
|||
|
|
x2={line.end.x}
|
|||
|
|
y2={line.end.y}
|
|||
|
|
stroke="#60A5FA"
|
|||
|
|
strokeWidth="2"
|
|||
|
|
className="transition-all duration-300"
|
|||
|
|
/>
|
|||
|
|
<circle cx={line.start.x} cy={line.start.y} r="4" fill="#60A5FA" />
|
|||
|
|
<circle cx={line.end.x} cy={line.end.y} r="4" fill="#60A5FA" />
|
|||
|
|
</g>
|
|||
|
|
))}
|
|||
|
|
|
|||
|
|
{/* 正在绘制的连线 */}
|
|||
|
|
{activeLine && (
|
|||
|
|
<g>
|
|||
|
|
<line
|
|||
|
|
x1={activeLine.start.x}
|
|||
|
|
y1={activeLine.start.y}
|
|||
|
|
x2={activeLine.end?.x || activeLine.start.x}
|
|||
|
|
y2={activeLine.end?.y || activeLine.start.y}
|
|||
|
|
stroke="#60A5FA"
|
|||
|
|
strokeWidth="2"
|
|||
|
|
strokeDasharray="5,5"
|
|||
|
|
className="animate-pulse"
|
|||
|
|
/>
|
|||
|
|
<circle cx={activeLine.start.x} cy={activeLine.start.y} r="4" fill="#60A5FA" />
|
|||
|
|
</g>
|
|||
|
|
)}
|
|||
|
|
</svg>
|
|||
|
|
|
|||
|
|
{/* 左侧选项 */}
|
|||
|
|
<div className="relative z-10">
|
|||
|
|
{/* 选项内容 */}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 右侧选项 */}
|
|||
|
|
<div className="relative z-10">
|
|||
|
|
{/* 选项内容 */}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. 选项组件结构
|
|||
|
|
|
|||
|
|
```jsx
|
|||
|
|
{question.leftItems.map(item => (
|
|||
|
|
<div
|
|||
|
|
key={item.id}
|
|||
|
|
ref={el => el && (itemRefs.current[item.id] = el)}
|
|||
|
|
onClick={(e) => handleLeftItemClick(item.id, e)}
|
|||
|
|
className={`bg-blue-900/20 p-4 rounded-lg border border-blue-500/30 cursor-pointer transition-all duration-300 ${
|
|||
|
|
lines.some(line => line.leftId === item.id)
|
|||
|
|
? 'bg-blue-500/30'
|
|||
|
|
: 'hover:bg-blue-900/40'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<span className="text-white font-medium">{item.text}</span>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 辅助功能实现
|
|||
|
|
|
|||
|
|
### 1. 重置功能
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
/**
|
|||
|
|
* 重置匹配题连线
|
|||
|
|
* @param question - 题目数据
|
|||
|
|
*/
|
|||
|
|
const handleReset = (question: MatchingQuestion) => {
|
|||
|
|
setLines([]);
|
|||
|
|
setAnswers(prev => {
|
|||
|
|
const newAnswers = { ...prev };
|
|||
|
|
question.leftItems.forEach((item) => {
|
|||
|
|
delete newAnswers[`${item.id}_match`];
|
|||
|
|
});
|
|||
|
|
return newAnswers;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 重新打乱选项顺序
|
|||
|
|
setShuffledQuestions(prev =>
|
|||
|
|
prev.map(q =>
|
|||
|
|
q.id === question.id
|
|||
|
|
? {
|
|||
|
|
...q,
|
|||
|
|
leftItems: shuffleArray(q.leftItems),
|
|||
|
|
rightItems: shuffleArray(q.rightItems)
|
|||
|
|
}
|
|||
|
|
: q
|
|||
|
|
)
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. 答案评分系统
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
/**
|
|||
|
|
* 计算匹配题得分
|
|||
|
|
*/
|
|||
|
|
const calculateMatchingScore = () => {
|
|||
|
|
let correctCount = 0;
|
|||
|
|
let totalCount = 0;
|
|||
|
|
|
|||
|
|
matchingQuestions.forEach(question => {
|
|||
|
|
Object.keys(question.correctMatches).forEach(leftId => {
|
|||
|
|
totalCount++;
|
|||
|
|
const userAnswer = answers[`${leftId}_match`];
|
|||
|
|
const correctAnswer = question.correctMatches[leftId];
|
|||
|
|
|
|||
|
|
if (userAnswer === correctAnswer) {
|
|||
|
|
correctCount++;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
correct: correctCount,
|
|||
|
|
total: totalCount,
|
|||
|
|
percentage: Math.round((correctCount / totalCount) * 100)
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3. 数组打乱算法
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
/**
|
|||
|
|
* Fisher-Yates洗牌算法
|
|||
|
|
* @param array - 待打乱的数组
|
|||
|
|
* @returns 打乱后的新数组
|
|||
|
|
*/
|
|||
|
|
const shuffleArray = <T>(array: T[]): T[] => {
|
|||
|
|
const shuffled = [...array];
|
|||
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|||
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|||
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|||
|
|
}
|
|||
|
|
return shuffled;
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 特性与优势
|
|||
|
|
|
|||
|
|
### 1. 用户体验特性
|
|||
|
|
- **实时反馈**:连线过程中提供实时视觉反馈
|
|||
|
|
- **防误操作**:不允许重复连接和无效操作
|
|||
|
|
- **连线替换**:支持重新连接和修改答案
|
|||
|
|
- **视觉提示**:已连接选项有明显的视觉标识
|
|||
|
|
|
|||
|
|
### 2. 技术特性
|
|||
|
|
- **响应式设计**:支持不同屏幕尺寸
|
|||
|
|
- **类型安全**:TypeScript提供完整的类型检查
|
|||
|
|
- **性能优化**:使用React.memo和useMemo优化渲染
|
|||
|
|
- **事件优化**:合理的事件绑定和清理机制
|
|||
|
|
|
|||
|
|
### 3. 可维护性
|
|||
|
|
- **模块化设计**:功能拆分清晰,易于维护
|
|||
|
|
- **可配置性**:题目数据和样式可灵活配置
|
|||
|
|
- **可扩展性**:易于添加新功能和动画效果
|
|||
|
|
|
|||
|
|
## 使用示例
|
|||
|
|
|
|||
|
|
### 1. 基本使用
|
|||
|
|
|
|||
|
|
```jsx
|
|||
|
|
import React from 'react';
|
|||
|
|
import MatchingQuiz from './MatchingQuiz';
|
|||
|
|
|
|||
|
|
const App = () => {
|
|||
|
|
const questionData = {
|
|||
|
|
id: 'demo',
|
|||
|
|
leftItems: [
|
|||
|
|
{ id: 'l1', text: 'React' },
|
|||
|
|
{ id: 'l2', text: 'Vue' }
|
|||
|
|
],
|
|||
|
|
rightItems: [
|
|||
|
|
{ id: 'r1', text: '前端框架' },
|
|||
|
|
{ id: 'r2', text: 'JavaScript库' }
|
|||
|
|
],
|
|||
|
|
correctMatches: {
|
|||
|
|
'l1': 'r2',
|
|||
|
|
'l2': 'r1'
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return <MatchingQuiz question={questionData} />;
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. 自定义样式
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* 自定义连线颜色 */
|
|||
|
|
.matching-line {
|
|||
|
|
stroke: #10B981;
|
|||
|
|
stroke-width: 3;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 自定义选项样式 */
|
|||
|
|
.matching-item {
|
|||
|
|
background: rgba(59, 130, 246, 0.1);
|
|||
|
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.matching-item:hover {
|
|||
|
|
background: rgba(59, 130, 246, 0.2);
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.matching-item.connected {
|
|||
|
|
background: rgba(59, 130, 246, 0.3);
|
|||
|
|
border-color: rgba(59, 130, 246, 0.6);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 性能优化建议
|
|||
|
|
|
|||
|
|
### 1. 渲染优化
|
|||
|
|
```typescript
|
|||
|
|
// 使用React.memo避免不必要的重渲染
|
|||
|
|
const MatchingItem = React.memo<{
|
|||
|
|
item: { id: string; text: string };
|
|||
|
|
isConnected: boolean;
|
|||
|
|
onClick: (id: string) => void;
|
|||
|
|
}>(({ item, isConnected, onClick }) => {
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
onClick={() => onClick(item.id)}
|
|||
|
|
className={`matching-item ${isConnected ? 'connected' : ''}`}
|
|||
|
|
>
|
|||
|
|
{item.text}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. 事件优化
|
|||
|
|
```typescript
|
|||
|
|
// 使用useCallback缓存事件处理函数
|
|||
|
|
const handleLeftClick = useCallback((leftId: string) => {
|
|||
|
|
// 处理逻辑
|
|||
|
|
}, [/* 依赖项 */]);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 故障排除
|
|||
|
|
|
|||
|
|
### 常见问题
|
|||
|
|
|
|||
|
|
1. **连线位置不准确**
|
|||
|
|
- 检查`getBoundingClientRect()`计算
|
|||
|
|
- 确保SVG容器尺寸正确
|
|||
|
|
|
|||
|
|
2. **连线无法绘制**
|
|||
|
|
- 检查SVG的z-index设置
|
|||
|
|
- 确保pointer-events设置正确
|
|||
|
|
|
|||
|
|
3. **性能问题**
|
|||
|
|
- 减少不必要的状态更新
|
|||
|
|
- 使用React.memo优化组件
|
|||
|
|
|
|||
|
|
## 扩展功能
|
|||
|
|
|
|||
|
|
### 1. 添加音效反馈
|
|||
|
|
```typescript
|
|||
|
|
const playConnectionSound = () => {
|
|||
|
|
const audio = new Audio('/sounds/connect.mp3');
|
|||
|
|
audio.play();
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. 添加动画效果
|
|||
|
|
```typescript
|
|||
|
|
// 使用Framer Motion添加连线动画
|
|||
|
|
<motion.line
|
|||
|
|
initial={{ pathLength: 0 }}
|
|||
|
|
animate={{ pathLength: 1 }}
|
|||
|
|
transition={{ duration: 0.5 }}
|
|||
|
|
// ...其他属性
|
|||
|
|
/>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3. 支持触摸设备
|
|||
|
|
```typescript
|
|||
|
|
const handleTouchStart = (e: React.TouchEvent) => {
|
|||
|
|
// 触摸事件处理
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleTouchMove = (e: React.TouchEvent) => {
|
|||
|
|
// 触摸移动处理
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 总结
|
|||
|
|
|
|||
|
|
本匹配题连线功能实现了一个完整的交互式学习组件,结合了现代前端技术的最佳实践。通过React的状态管理、SVG的图形能力和TypeScript的类型安全,创造了一个高质量、易维护的教育应用功能模块。
|
|||
|
|
|
|||
|
|
该实现不仅提供了良好的用户体验,还具备了良好的可扩展性和维护性,可以作为类似教育应用的参考实现。
|