Files
ai-course/匹配题连线功能实现文档.md
KQL cc390fc756 feat: 初始化多Agent协作系统项目并添加直播回放功能
- 添加导航栏组件及直播回放按钮
- 实现视频播放模态框
- 配置赛博朋克风格主题
- 添加课程首页和课程页面

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:36:16 +08:00

12 KiB
Raw Permalink Blame History

匹配题连线功能实现文档

概述

本文档详细介绍了基于React + TypeScript + SVG实现的交互式匹配题连线功能。该功能允许用户通过点击和拖拽的方式将左侧选项与右侧选项进行连线匹配并提供实时视觉反馈和答案记录功能。

技术架构

核心技术栈

  • React 18 - 组件化开发和状态管理
  • TypeScript - 类型安全和代码可维护性
  • SVG - 矢量图形绘制连线
  • CSS3 - 样式和动画效果
  • Framer Motion - 高级动画库(可选)

系统架构图

用户交互层
    ↓
事件处理层 (鼠标点击/移动)
    ↓
状态管理层 (React Hooks)
    ↓
SVG渲染层 (连线可视化)
    ↓
数据持久层 (答案存储)

核心数据结构

1. 连线接口定义

// 完成的连线
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. 状态管理

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. 坐标计算系统

/**
 * 计算元素中心点坐标相对于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. 连线开始处理

/**
 * 处理左侧选项点击事件
 * @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. 实时连线跟踪

/**
 * 处理鼠标移动事件(连线跟随鼠标)
 * @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. 连线完成处理

/**
 * 处理右侧选项点击事件
 * @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容器结构

<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. 选项组件结构

{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. 重置功能

/**
 * 重置匹配题连线
 * @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. 答案评分系统

/**
 * 计算匹配题得分
 */
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. 数组打乱算法

/**
 * 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. 基本使用

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. 自定义样式

/* 自定义连线颜色 */
.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. 渲染优化

// 使用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. 事件优化

// 使用useCallback缓存事件处理函数
const handleLeftClick = useCallback((leftId: string) => {
  // 处理逻辑
}, [/* 依赖项 */]);

故障排除

常见问题

  1. 连线位置不准确

    • 检查getBoundingClientRect()计算
    • 确保SVG容器尺寸正确
  2. 连线无法绘制

    • 检查SVG的z-index设置
    • 确保pointer-events设置正确
  3. 性能问题

    • 减少不必要的状态更新
    • 使用React.memo优化组件

扩展功能

1. 添加音效反馈

const playConnectionSound = () => {
  const audio = new Audio('/sounds/connect.mp3');
  audio.play();
};

2. 添加动画效果

// 使用Framer Motion添加连线动画
<motion.line
  initial={{ pathLength: 0 }}
  animate={{ pathLength: 1 }}
  transition={{ duration: 0.5 }}
  // ...其他属性
/>

3. 支持触摸设备

const handleTouchStart = (e: React.TouchEvent) => {
  // 触摸事件处理
};

const handleTouchMove = (e: React.TouchEvent) => {
  // 触摸移动处理
};

总结

本匹配题连线功能实现了一个完整的交互式学习组件结合了现代前端技术的最佳实践。通过React的状态管理、SVG的图形能力和TypeScript的类型安全创造了一个高质量、易维护的教育应用功能模块。

该实现不仅提供了良好的用户体验,还具备了良好的可扩展性和维护性,可以作为类似教育应用的参考实现。