feat: 初始化多Agent协作系统项目并添加直播回放功能
- 添加导航栏组件及直播回放按钮 - 实现视频播放模态框 - 配置赛博朋克风格主题 - 添加课程首页和课程页面 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
511
匹配题连线功能实现文档.md
Normal file
511
匹配题连线功能实现文档.md
Normal file
@@ -0,0 +1,511 @@
|
||||
# 匹配题连线功能实现文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档详细介绍了基于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的类型安全,创造了一个高质量、易维护的教育应用功能模块。
|
||||
|
||||
该实现不仅提供了良好的用户体验,还具备了良好的可扩展性和维护性,可以作为类似教育应用的参考实现。
|
||||
Reference in New Issue
Block a user