基于 JSXGraph + Vue3.0 的交互式几何作图组件开发实践

基于 JSXGraph + Vue3.0 的交互式几何作图组件开发实践

组件概述

这是一个基于 Vue 3 和 JSXGraph 库开发的交互式几何作图组件。该组件提供了丰富的几何图形绘制功能,包括点、线、圆、多边形等基本图形,以及角度标记、直角标记等特殊标记。

核心功能

1. 基础绘图工具

  • 点:支持在画板上任意位置创建点
  • 线:支持绘制直线
  • 多边形:支持绘制任意多边形,并可设置填充效果
  • 向量:支持绘制向量
  • 圆:支持通过圆心和半径或三点绘制圆
  • 椭圆:支持绘制椭圆

2. 特殊标记功能

  • 角度标记:可以标记任意角度的度数
  • 直角标记:用于标记直角
  • 等长标记:用于标记等长线段
  • 角度弧:用于绘制角度弧

3. 显示控制

  • 网格显示:可控制是否显示网格
  • 坐标轴:可控制是否显示坐标轴
  • 网格对齐:支持点自动对齐到网格
  • 坐标显示:可显示点的坐标
  • 标签显示:可控制是否显示图形标签

4. 样式定制

组件提供了丰富的样式定制选项:

  • 点样式:可自定义点的颜色、大小和透明度
  • 线样式:可自定义线的颜色、宽度和透明度
  • 标签样式:可自定义标签的颜色、大小、透明度和偏移量
  • 填充样式:可自定义多边形的填充颜色和透明度

5. 交互功能

  • 撤销操作:支持撤销上一步操作
  • 清空画板:可一键清空所有图形
  • 元素选择:支持通过右键点击选择元素
  • 批量样式应用:可将样式应用到选中的元素
  • 导出功能:支持导出为 SVG 或 PNG 格式

技术实现

1. 核心依赖

  • Vue 3:Vue + Typescript
  • JSXGraph:用于实现几何图形的绘制和交互
  • Tailwind CSS:用于构建现代化的 UI 界面

2. 依赖安装

bash 复制代码
pnpm add jsxgraph

2. 完整代码实现

模板部分 (Template)
vue 复制代码
<template>
  <div class="w-full">
    <!-- 错误提示 -->
    <div v-if="errorMessage" class="mb-2 rounded-md bg-red-100 p-4">
      <div class="flex">
        <div class="flex-shrink-0">
          <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
          </svg>
        </div>
        <div class="ml-3">
          <p class="text-sm text-red-700">{{ errorMessage }}</p>
        </div>
      </div>
    </div>

    <!-- 工具栏 -->
    <div class="mb-2 flex flex-wrap items-center gap-2">
      <!-- 绘图工具选择 -->
      <div class="flex items-center gap-2">
        <label class="font-medium">绘图工具:</label>
        <select v-model="mode" class="rounded border px-2 py-1">
          <optgroup label="基础工具">
            <option value="point">点</option>
            <option value="line">线</option>
            <option value="polygon">面</option>
            <option value="vector">向量</option>
          </optgroup>
          <optgroup label="圆与椭圆">
            <option value="circle">圆(圆心和半径)</option>
            <option value="circle3">三点圆</option>
            <option value="ellipse">椭圆</option>
          </optgroup>
        </select>
      </div>

      <!-- 显示选项 -->
      <div class="flex items-center gap-2">
        <label class="font-medium">显示选项:</label>
        <label class="inline-flex items-center">
          <input type="checkbox" v-model="showGrid" @change="toggleGrid" class="form-checkbox" />
          <span class="ml-2">网格</span>
        </label>
        <label class="inline-flex items-center">
          <input type="checkbox" v-model="showAxis" @change="toggleAxis" class="form-checkbox" />
          <span class="ml-2">坐标轴</span>
        </label>
      </div>

      <!-- 操作按钮 -->
      <div class="flex gap-2">
        <button @click="undo" class="rounded bg-gray-500 px-3 py-1 text-white" :disabled="!canUndo">
          撤销
        </button>
        <button @click="clearBoard" class="rounded bg-red-500 px-3 py-1 text-white">
          清空画板
        </button>
      </div>
    </div>

    <!-- 样式控制面板 -->
    <div class="mb-2 flex flex-wrap items-center gap-4 rounded-md border border-gray-200 p-3">
      <!-- 点样式控制 -->
      <div class="flex items-center gap-2">
        <label class="font-medium">点样式:</label>
        <input type="color" v-model="pointStyle.color" class="h-8 w-8" title="点颜色" />
        <div class="flex flex-col gap-1">
          <div class="flex items-center gap-2">
            <span class="text-sm text-gray-500">大小: {{ pointStyle.size }}</span>
            <input type="range" v-model="pointStyle.size" min="0" max="20" class="w-24" />
          </div>
        </div>
      </div>
    </div>

    <!-- 画板容器 -->
    <div ref="boardRef" class="h-[600px] w-full rounded-md border"></div>
  </div>
</template>
脚本部分 (Script)
typescript 复制代码
<script setup lang="ts">
  import { onMounted, onUnmounted, ref, reactive, watch } from 'vue';
  import JXG from 'jsxgraph';

  const boardRef = ref<HTMLDivElement | null>(null);
  const mode = ref<string>('point');
  const showGrid = ref(true);
  const showAxis = ref(true);
  const snapToGrid = ref(true);
  const showCoordinates = ref(false);
  const canUndo = ref(false);
  const errorMessage = ref('');
  const showLabels = ref(true);

  interface BoardElement extends JXG.GeometryElement {
    elType: string;
    selected?: boolean;
    visProp: {
      [name: string]: unknown;
      fillColor?: string;
    };
  }

  type CustomPoint = JXG.Point & { selected?: boolean };
  type CustomLine = JXG.Line & { selected?: boolean };
  type CustomCircle = JXG.Circle & { selected?: boolean };
  type CustomArrow = JXG.Arrow & { selected?: boolean };

  let board: JXG.Board | null = null;
  const state = reactive({
    elements: [] as BoardElement[],
    points: [] as JXG.Point[],
    tempPoints: [] as JXG.Point[],
  });

  // 文件输入引用
  const fileInput = ref<HTMLInputElement | null>(null);

  // 添加样式控制状态
  const pointStyle = reactive({
    color: '#1e40af',
    size: 4,
    opacity: 1
  });

  const lineStyle = reactive({
    color: '#2563eb',
    width: 2,
    opacity: 1
  });

  const hasSelectedElements = ref(false);

  const labelStyle = reactive({
    color: '#1e40af',
    size: 14,
    offset: [10, 10],
    opacity: 1
  });

  const fillStyle = reactive({
    enabled: true,
    color: '#93c5fd',
    opacity: 0.3
  });

  // 添加导出格式状态
  const exportFormat = ref<'svg' | 'png'>('svg');

  // 修改 loadMathJax 函数
  const loadMathJax = () => {
    return new Promise((resolve, reject) => {
      if ((window as any).MathJax) {
        resolve((window as any).MathJax);
        return;
      }

      // 简化 MathJax 配置,只保留几何标签渲染所需功能
      window.MathJax = {
        tex: {
          inlineMath: [['$', '$']],
          processEscapes: true
        },
        svg: {
          fontCache: 'global'
        }
      };

      const script = document.createElement('script');
      script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js';
      script.async = true;
      script.onload = () => {
        resolve((window as any).MathJax);
      };
      script.onerror = reject;
      document.head.appendChild(script);
    });
  };

  // 初始化画板
  onMounted(async () => {
    try {
      // await loadMathJax(); //todo
      if (!boardRef.value) return;

      board = JXG.JSXGraph.initBoard(boardRef.value, {
        boundingbox: [-15, 15, 15, -15],
        axis: false,  // 初始化时不显示坐标轴
        grid: false,  // 初始化时不显示网格
        showNavigation: true,
        showCopyright: false,
        keepaspectratio: true,
        pan: {
          enabled: true
        }
      });

      // 添加画板事件监听
      board.on('down', handleBoardClick);
      board.on('move', () => {
        if (board) {
          board.update();
        }
      });

      // 初始化时根据选项显示网格和坐标轴
      if (showGrid.value) {
        board.create('grid', []);
      }
      if (showAxis.value) {
        board.create('axis', [[0, 0], [1, 0]]);
        board.create('axis', [[0, 0], [0, 1]]);
      }

      // 禁用右键菜单
      boardRef.value.addEventListener('contextmenu', (e: Event) => {
        e.preventDefault();
      });
    } catch (error) {
      console.error('Failed to initialize geometry board:', error);
    }
  });

  // 清理资源
  onUnmounted(() => {
    if (board) {
      board.off('down');
      JXG.JSXGraph.freeBoard(board);
      board = null;
    }
  });

  // 处理点击事件
  const handleBoardClick = (e: MouseEvent) => {
    if (!board) return;

    // 阻止默认的右键菜单
    e.preventDefault();

    const coords = board.getUsrCoordsOfMouse(e);
    const [x, y] = coords;

    // 获取点击位置下的所有元素
    const elementsUnderMouse = board.getAllObjectsUnderMouse(e);

    // 右键点击用于选择元素
    if (e.button === 2) {
      const clickedElement = elementsUnderMouse[0] as BoardElement;
      if (clickedElement) {
        clickedElement.selected = !clickedElement.selected;
        hasSelectedElements.value = board.objectsList.some((obj: any) => (obj as BoardElement).selected);
        
        // 更新元素的显示样式
        if (clickedElement.selected) {
          if (clickedElement.elType === 'point') {
            clickedElement.setAttribute({strokeColor: '#ff0000'});
          } else {
            clickedElement.setAttribute({
              strokeColor: '#ff0000',
              fillColor: clickedElement.elType === 'polygon' ? '#ff0000' : clickedElement.visProp.fillColor
            });
          }
        } else {
          if (clickedElement.elType === 'point') {
            clickedElement.setAttribute({strokeColor: pointStyle.color});
          } else {
            clickedElement.setAttribute({
              strokeColor: lineStyle.color,
              fillColor: clickedElement.elType === 'polygon' ? fillStyle.color : clickedElement.visProp.fillColor
            });
          }
        }
        board.update();
      }
      return;
    }

    // 左键点击用于创建元素
    if (e.button === 0) {
      // 检查是否点击了已存在的点
      const clickedPoint = elementsUnderMouse.find(
        (obj: any) => obj.elType === 'point'
      );

      // 如果点击了已存在的点,不创建新点
      if (clickedPoint) {
        return;
      }

      switch (mode.value) {
        case 'point':
          createPoint(x, y);
          break;
        case 'line':
          handleLineCreation(x, y);
          break;
        case 'ray':
          handleRayCreation(x, y);
          break;
        case 'vector':
          handleVectorCreation(x, y);
          break;
        case 'circle':
          handleCircleCreation(x, y);
          break;
        case 'circle3':
          handleCircle3Creation(x, y);
          break;
        case 'ellipse':
          handleEllipseCreation(x, y);
          break;
        case 'polygon':
          handlePolygonCreation(x, y);
          break;
        case 'midpoint':
          handleMidpointCreation(x, y);
          break;
        case 'angle':
          handleAngleMarkCreation(x, y);
          break;
        case 'rightangle':
          handleRightAngleMarkCreation(x, y);
          break;
        case 'equal':
          handleEqualMarkCreation(x, y);
          break;
        case 'arc':
          handleArcCreation(x, y);
          break;
      }
    }
  };

  // 创建点
  function createPoint(x: number, y: number): JXG.Point {
    const name = String.fromCharCode(65 + state.points.length);
    const pt = board!.create('point', [x, y], {
      name: name,
      size: pointStyle.size,
      withLabel: true,
      label: { 
        offset: labelStyle.offset,
        fontSize: labelStyle.size,
        strokeColor: labelStyle.color,
        opacity: labelStyle.opacity,
        useMathJax: true,
        parse: false,
        fixed: false,
        highlight: false,
        visible: showLabels.value,
        cssStyle: 'cursor: default'
      },
      snapToGrid: snapToGrid.value,
      snapSizeX: 1,
      snapSizeY: 1,
      color: pointStyle.color,
      opacity: pointStyle.opacity,
      highlight: true,
      drag: function() {
        if (board) {
          board.update();
        }
      }
    }) as JXG.Point;

    // 设置初始标签文本
    if (showCoordinates.value && pt.label) {
      pt.label.setText(`$${name}(${pt.X().toFixed(2)}, ${pt.Y().toFixed(2)})$`);
    } else if (pt.label) {
      pt.label.setText(`$${name}$`);
    }

    // 修改点的更新函数
    pt.on('update', function(this: JXG.Point) {
      if (showCoordinates.value && this.label) {
        this.label.setText(`$${this.name}(${this.X().toFixed(2)}, ${this.Y().toFixed(2)})$`);
      } else if (this.label) {
        this.label.setText(`$${this.name}$`);
      }
      // 触发 MathJax 重新渲染
      if (window.MathJax) {
        window.MathJax.typeset?.([this.label?.rendNode]);
      }
    });

    // 添加双击事件处理
    pt.on('dblclick', function(this: JXG.Point) {
      const input = document.createElement('input');
      input.type = 'text';
      input.value = this.name;
      input.style.position = 'absolute';
      input.style.zIndex = '1000';
      input.style.width = '50px';
      input.style.fontSize = '14px';
      input.style.padding = '2px';
      input.style.border = '1px solid #2563eb';
      input.style.borderRadius = '4px';
      input.style.backgroundColor = 'white';
      input.style.textAlign = 'center';
      
      // 获取点在屏幕上的坐标
      const coords = this.coords.scrCoords;
      const boardRect = boardRef.value?.getBoundingClientRect() || { left: 0, top: 0 };
      
      // 设置输入框位置,直接使用点的屏幕坐标
      input.style.left = `${coords[1] + boardRect.left - 25}px`;
      input.style.top = `${coords[2] + boardRect.top - 10}px`;
      
      document.body.appendChild(input);
      input.focus();
      input.select(); // 自动选中文本
      
      const handleBlur = () => {
        const newName = input.value.trim();
        if (newName) {
          this.name = newName;
          if (showCoordinates.value && this.label) {
            this.label.setText(`$${newName}(${this.X().toFixed(2)}, ${this.Y().toFixed(2)})$`);
          } else if (this.label) {
            this.label.setText(`$${newName}$`);
          }
          // 触发 MathJax 重新渲染
          if (window.MathJax) {
            window.MathJax.typesetPromise?.();
          }
          board!.update();
        }
        input.remove();
      };
      
      input.addEventListener('blur', handleBlur);
      input.addEventListener('keypress', (e) => {
        if (e.key === 'Enter') {
          handleBlur();
        }
      });
    });

    state.points.push(pt);
    state.elements.push(pt as BoardElement);
    canUndo.value = true;
    return pt;
  }

  // 修改标签文本更新后的处理
  function updatePointsLabels() {
    state.points.forEach((point) => {
      if (showCoordinates.value && point.label) {
        point.label.setText(`$${point.name}(${point.X().toFixed(2)}, ${point.Y().toFixed(2)})$`);
      } else if (point.label) {
        point.label.setText(`$${point.name}$`);
      }
    });
    // 触发 MathJax 重新渲染
    if (window.MathJax && board) {
      const labelNodes = state.points
        .filter(p => p.label && p.label.rendNode)
        .map(p => p?.label?.rendNode);
      window.MathJax.typeset?.(labelNodes);
    }
    if (board) {
      board.update();
    }
  }

  // 监听显示坐标选项的变化
  watch(showCoordinates, (newValue) => {
    updatePointsLabels();
    if (board) {
      board.update();
    }
  }, { immediate: true });

  // 修改线段创建函数
  function handleLineCreation(x: number, y: number) {
    const pt = createPoint(x, y);
    state.tempPoints.push(pt);

    if (state.tempPoints.length === 2) {
      const [p1, p2] = state.tempPoints;
      const line = board!.create('line', [p1, p2], {
        straightFirst: false,
        straightLast: false,
        strokeWidth: lineStyle.width,
        strokeColor: lineStyle.color,
        opacity: lineStyle.opacity,
        highlight: true,
        withLabel: true,
        name: function() {
          const dx = p2.X() - p1.X();
          const dy = p2.Y() - p1.Y();
          const length = Math.sqrt(dx * dx + dy * dy).toFixed(2);
          // return `${length}`;
          return '';
        },
        label: {
          fontSize: 14,
          strokeColor: lineStyle.color,
          position: 'middle',
          offset: [0, 10],
          useMathJax: true,
          parse: false
        }
      });

      // 添加线段的选择事件
      (line as unknown as JXG.GeometryElement).on('up', function(this: CustomLine) {
        this.selected = !this.selected;
        hasSelectedElements.value = board!.objectsList.some((obj: any) => obj.selected);
        if (this.selected) {
          this.setAttribute({strokeColor: '#ff0000'});
        } else {
          this.setAttribute({strokeColor: lineStyle.color});
        }
        board!.update();
      });

      state.elements.push(line as unknown as BoardElement);
      state.tempPoints = [];
    }
  }

  // 处理射线创建
  function handleRayCreation(x: number, y: number) {
    const pt = createPoint(x, y);
    state.tempPoints.push(pt);

    if (state.tempPoints.length === 2) {
      const [p1, p2] = state.tempPoints;
      const ray = board!.create('line', [p1, p2], {
        straightFirst: false,
        straightLast: true,
        strokeWidth: 2,
        strokeColor: '#2563eb',
      });
      state.elements.push(ray as unknown as BoardElement);
      state.tempPoints = [];
    }
  }

  // 修改向量创建函数
  function handleVectorCreation(x: number, y: number) {
    const pt = createPoint(x, y);
    state.tempPoints.push(pt);

    if (state.tempPoints.length === 2) {
      const [p1, p2] = state.tempPoints;
      const vector = board!.create('arrow', [p1, p2], {
        strokeWidth: lineStyle.width,
        strokeColor: lineStyle.color,
        opacity: lineStyle.opacity,
        withLabel: true,
        name: function() {
          const dx = p2.X() - p1.X();
          const dy = p2.Y() - p1.Y();
          const length = Math.sqrt(dx * dx + dy * dy).toFixed(2);
          // return `|v| = ${length}`;
          return '';
        },
        label: {
          fontSize: 14,
          strokeColor: lineStyle.color,
          position: 'middle',
          offset: [0, 10],
          useMathJax: true,
          parse: false
        }
      });

      // 添加向量的选择事件
      (vector as unknown as JXG.GeometryElement).on('up', function(this: CustomArrow) {
        this.selected = !this.selected;
        hasSelectedElements.value = board!.objectsList.some((obj: any) => obj.selected);
        if (this.selected) {
          this.setAttribute({strokeColor: '#ff0000'});
        } else {
          this.setAttribute({strokeColor: lineStyle.color});
        }
        board!.update();
      });

      state.elements.push(vector as unknown as BoardElement);
      state.tempPoints = [];
    }
  }

  // 修改圆的创建函数
  function handleCircleCreation(x: number, y: number) {
    const pt = createPoint(x, y);
    state.tempPoints.push(pt);

    if (state.tempPoints.length === 2) {
      const [center, point] = state.tempPoints;
      const circle = board!.create('circle', [center, point], {
        strokeWidth: lineStyle.width,
        strokeColor: lineStyle.color,
        opacity: lineStyle.opacity,
        fillColor: 'none',
        withLabel: true,
        name: function(this: JXG.Circle) {
          const radius = this.Radius();
          // return `r = ${radius.toFixed(2)}`;
          return '';
        },
        label: {
          fontSize: 14,
          strokeColor: lineStyle.color,
          position: 'top',
          offset: [0, 10],
          useMathJax: true,
          parse: false
        }
      });

      // 添加圆的选择事件
      (circle as unknown as JXG.GeometryElement).on('up', function(this: CustomCircle) {
        this.selected = !this.selected;
        hasSelectedElements.value = board!.objectsList.some((obj: any) => obj.selected);
        if (this.selected) {
          this.setAttribute({strokeColor: '#ff0000'});
        } else {
          this.setAttribute({strokeColor: lineStyle.color});
        }
        board!.update();
      });

      state.elements.push(circle as unknown as BoardElement);
      state.tempPoints = [];
    }
  }

  // 处理三点圆创建
  function handleCircle3Creation(x: number, y: number) {
    const pt = createPoint(x, y);
    state.tempPoints.push(pt);

    if (state.tempPoints.length === 3) {
      const circle = board!.create('circumcircle', [...state.tempPoints], {
        strokeWidth: 2,
        strokeColor: '#2563eb',
        fillColor: 'none',
      });
      state.elements.push(circle as unknown as BoardElement);
      state.tempPoints = [];
    }
  }

  // 处理椭圆创建
  function handleEllipseCreation(x: number, y: number) {
    const pt = createPoint(x, y);
    state.tempPoints.push(pt);

    if (state.tempPoints.length === 3) {
      const [f1, f2, p] = state.tempPoints;
      const ellipse = board!.create('ellipse', [f1, f2, p], {
        strokeWidth: 2,
        strokeColor: '#2563eb',
        fillColor: 'none',
      }) as BoardElement;
      state.elements.push(ellipse as unknown as BoardElement);
      state.tempPoints = [];
    }
  }

  // 修改多边形创建函数
  function handlePolygonCreation(x: number, y: number) {
    const pt = createPoint(x, y);
    state.tempPoints.push(pt);
  }

  // 完成多边形创建
  function completePolygon() {
    if (!board || state.tempPoints.length < 3) return;

    const polygon = board.create('polygon', [...state.tempPoints], {
      borders: { 
        strokeWidth: lineStyle.width,
        strokeColor: lineStyle.color,
        opacity: lineStyle.opacity,
        highlight: true
      },
      fillColor: fillStyle.enabled ? fillStyle.color : 'transparent',
      fillOpacity: fillStyle.opacity,
      withLabel: true,
      name: function(this: JXG.Polygon) {
        const area = this.Area();
        // return `A = ${area.toFixed(2)}`;
        return '';
      },
      label: {
        fontSize: labelStyle.size,
        strokeColor: labelStyle.color,
        position: 'middle',
        offset: labelStyle.offset,
        useMathJax: true,
        parse: false,
        visible: showLabels.value
      }
    }) as BoardElement;

    // 添加多边形的选择事件
    (polygon as unknown as JXG.GeometryElement).on('up', function(this: any) {
      this.selected = !this.selected;
      hasSelectedElements.value = board!.objectsList.some((obj: any) => obj.selected);
      if (this.selected) {
        this.setAttribute({
          strokeColor: '#ff0000',
          fillColor: '#ff0000'
        });
      } else {
        this.setAttribute({
          strokeColor: lineStyle.color,
          fillColor: lineStyle.color
        });
      }
      board!.update();
    });

    state.elements.push(polygon as unknown as BoardElement);
    state.tempPoints = [];
  }

  // 处理中点创建
  function handleMidpointCreation(x: number, y: number) {
    const pt = createPoint(x, y);
    state.tempPoints.push(pt);

    if (state.tempPoints.length === 2) {
      const [p1, p2] = state.tempPoints;
      const midpoint = board!.create('midpoint', [p1, p2], {
        withLabel: true,
        name: 'M',
        color: '#dc2626',
      });
      state.elements.push(midpoint as unknown as BoardElement);
      state.tempPoints = [];
    }
  }

  // 切换网格显示
  function toggleGrid() {
    if (!board) return;

    // 移除现有的网格
    const grids = board.objectsList.filter((obj: any) => obj.elType === 'grid');
    grids.forEach((grid) => board?.removeObject(grid as BoardElement));

    // 如果需要显示网格,则创建新的网格
    if (showGrid.value) {
      board?.create('grid', [], {});
    }

    board?.update();
  }

  // 切换坐标轴显示
  function toggleAxis() {
    if (!board) return;

    // 移除现有的坐标轴
    const axes = board.objectsList.filter((obj: any) => obj.elType === 'axis');
    axes.forEach((axis) => board?.removeObject(axis as BoardElement));

    // 如果需要显示坐标轴,则创建新的坐标轴
    if (showAxis.value) {
      board?.create('axis', [[0, 0], [1, 0]] as [number, number][]);
      board?.create('axis', [[0, 0], [0, 1]] as [number, number][]);
    }

    board?.update();
  }

  // 撤销操作
  function undo() {
    if (state.elements.length > 0) {
      const element = state.elements.pop();
      if (element && board) {
        board.removeObject(element as BoardElement);
        if (element.elType === 'point') {
          state.points = state.points.filter((p) => p !== element);
        }
      }
      canUndo.value = state.elements.length > 0;
    }
  }

  // 清空画板
  function clearBoard() {
    if (!board) return;

    const b = board;
    state.elements.forEach((element) => {
      b.removeObject(element as BoardElement);
    });
    state.elements = [];
    state.points = [];
    state.tempPoints = [];
    canUndo.value = false;
  }

  // 修改导出图像函数
  async function exportAsImage() {
    try {
      const svgEl = boardRef.value?.querySelector('svg');
      if (!svgEl) {
        console.error('未找到 SVG 元素');
        return;
      }

      // 等待MathJax完成渲染
      if (window.MathJax) {
        await window.MathJax.typesetPromise();
        await new Promise(resolve => setTimeout(resolve, 100));
      }

      // 克隆SVG元素并处理
      const clonedSvg = svgEl.cloneNode(true) as SVGElement;
      clonedSvg.style.backgroundColor = 'transparent';
      
      // 移除网格和坐标轴
      clonedSvg.querySelectorAll('[id^="JXG_GID"], [id*="grid"], [id*="axis"]').forEach(el => el.remove());

      // 处理标签
      const processLabels = () => {
        const labels = clonedSvg.querySelectorAll('[id^="JXG_text"], .MathJax');
        labels.forEach(label => {
          const el = label as SVGElement;
          el.style.visibility = 'visible';
          el.style.display = 'block';
          el.style.zIndex = '1000';
          
          // 处理文本颜色
          el.querySelectorAll('text').forEach(text => {
            const color = text.getAttribute('stroke') || '#000000';
            text.style.fill = color;
            text.style.stroke = color;
          });
        });
      };

      // 处理标签和样式
      processLabels();

      // 计算边界
      const bbox = svgEl.getBBox();
      const padding = 40;
      const width = bbox.width + padding;
      const height = bbox.height + padding;

      // 设置尺寸
      clonedSvg.setAttribute('width', width.toString());
      clonedSvg.setAttribute('height', height.toString());
      clonedSvg.setAttribute('viewBox', `${bbox.x - padding/2} ${bbox.y - padding/2} ${width} ${height}`);

      // 转换为字符串
      const svgString = new XMLSerializer().serializeToString(clonedSvg);

      if (exportFormat.value === 'svg') {
        // 导出SVG
        const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
        const url = URL.createObjectURL(blob);
        const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
        const a = document.createElement('a');
        a.href = url;
        a.download = `geometry-elements-${timestamp}.svg`;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
      } else {
        // 导出PNG
        const canvas = document.createElement('canvas');
        const scale = 2;
        canvas.width = width * scale;
        canvas.height = height * scale;
        
        // 创建图像
        const img = new Image();
        const base64Data = btoa(unescape(encodeURIComponent(svgString)));
        img.src = `data:image/svg+xml;base64,${base64Data}`;
        
        // 等待图像加载
        await new Promise((resolve, reject) => {
          img.onload = resolve;
          img.onerror = reject;
        });

        const ctx = canvas.getContext('2d');
        if (ctx) {
          ctx.clearRect(0, 0, canvas.width, canvas.height);
          ctx.imageSmoothingEnabled = true;
          ctx.imageSmoothingQuality = 'high';
          ctx.scale(scale, scale);
          ctx.drawImage(img, 0, 0);
          
          // 保存为PNG
          const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
          canvas.toBlob((blob) => {
            if (blob) {
              const url = URL.createObjectURL(blob);
              const a = document.createElement('a');
              a.href = url;
              a.download = `geometry-elements-${timestamp}.png`;
              document.body.appendChild(a);
              a.click();
              document.body.removeChild(a);
              URL.revokeObjectURL(url);
            }
          }, 'image/png');
        }
      }
    } catch (error) {
      console.error('导出图像时发生错误:', error);
    }
  }

  // 监听模式变化,清除临时点
  watch(mode, () => {
    if (state.tempPoints.length > 0) {
      const shouldClear = window.confirm('切换工具将清除当前未完成的图形,是否继续?');
      if (shouldClear) {
        state.tempPoints.forEach((point) => {
          if (board) board.removeObject(point as BoardElement);
        });
        state.tempPoints = [];
      } else {
        mode.value = 'polygon'; // 如果用户取消,保持在多边形模式
      }
    }
  });

  // 计算顺时针角度的函数
  function calculateClockwiseAngle(p1: JXG.Point, p2: JXG.Point, p3: JXG.Point): number {
    // 将点转换为相对于p2的向量
    const v1x = p1.X() - p2.X();
    const v1y = p1.Y() - p2.Y();
    const v2x = p3.X() - p2.X();
    const v2y = p3.Y() - p2.Y();
    
    // 计算两个向量的角度
    const angle1 = Math.atan2(v1y, v1x);
    const angle2 = Math.atan2(v2y, v2x);
    
    // 计算顺时针角度
    let angle = angle2 - angle1;
    
    // 确保角度为正值(0到360度之间)
    if (angle < 0) {
      angle += 2 * Math.PI;
    }
    
    return angle;
  }

  // 清除错误信息
  function clearError() {
    errorMessage.value = '';
  }

  // 显示错误信息
  function showError(message: string) {
    errorMessage.value = message;
    // 3秒后自动清除错误信息
    setTimeout(() => {
      clearError();
    }, 3000);
  }

  // 重新初始化画板
  function reinitializeBoard() {
    try {
      if (board) {
        board.off('down');
        JXG.JSXGraph.freeBoard(board);
      }

      if (!boardRef.value) return;

      board = JXG.JSXGraph.initBoard(boardRef.value, {
        boundingbox: [-10, 10, 10, -10],
        axis: showAxis.value,
        grid: showGrid.value,
        showNavigation: true,
        showCopyright: false,
        keepaspectratio: true,
        pan: {
          enabled: true
        }
      });

      board.on('down', handleBoardClick);
      board.on('move', () => {
        if (board) {
          board.update();
        }
      });

      // 清空临时点
      state.tempPoints = [];
    } catch (error) {
      console.error('重新初始化画板失败:', error);
      showError('重新初始化画板失败,请刷新页面重试');
    }
  }

  // 修改处理角度弧创建函数
  function handleArcCreation(x: number, y: number) {
    try {
      const pt = createPoint(x, y);
      state.tempPoints.push(pt);

      if (state.tempPoints.length === 3) {
        const [p1, p2, p3] = state.tempPoints;

        // 创建角度弧
        const arc = board!.create('angle', [p3, p2, p1], {
          radius: 2,
          type: 'sector',
          strokeColor: '#2563eb',
          fillColor: 'none',
          name: function() {
            const angleRad = calculateClockwiseAngle(p3, p2, p1);
            const degrees = (angleRad * 180 / Math.PI).toFixed(1);
            const radians = angleRad.toFixed(2);
            return `${degrees}° (${radians} rad)`;
          },
          withLabel: true,
          label: {
            fontSize: 16,
            strokeColor: '#2563eb',
            cssStyle: 'font-style:italic',
            position: 'top',
            offset: [0, 0],
            anchorX: 'middle',
            anchorY: 'middle',
            visible: true,
            useMathJax: true,
            parse: false
          }
        }) as BoardElement;

        // 创建角度点之间的线段
        const line1 = board!.create('segment', [p2, p3], {
          strokeColor: '#2563eb',
          strokeWidth: 2
        }) as BoardElement;

        const line2 = board!.create('segment', [p2, p1], {
          strokeColor: '#2563eb',
          strokeWidth: 2
        }) as BoardElement;

        state.elements.push(arc, line1, line2);
        state.tempPoints = [];
      }
    } catch (error) {
      console.error('创建角度弧时发生错误:', error);
      showError('创建角度弧失败,正在重新初始化画板...');
      reinitializeBoard();
    }
  }

  // 修改角度标记创建函数
  function handleAngleMarkCreation(x: number, y: number) {
    try {
      const pt = createPoint(x, y);
      state.tempPoints.push(pt);

      if (state.tempPoints.length === 3) {
        const [p1, p2, p3] = state.tempPoints;
        
        // 创建角度标记
        const angle = board!.create('angle', [p3, p2, p1], {
          type: 'sector',
          radius: 1.5,
          strokeColor: lineStyle.color,
          strokeWidth: lineStyle.width,
          strokeOpacity: lineStyle.opacity,
          fillColor: fillStyle.enabled ? fillStyle.color : 'transparent',
          fillOpacity: fillStyle.opacity,
          name: function() {
            const angleRad = calculateClockwiseAngle(p3, p2, p1);
            const degrees = (angleRad * 180 / Math.PI).toFixed(1);
            return `${degrees}°`;
          },
          label: {
            fontSize: labelStyle.size,
            strokeColor: labelStyle.color,
            cssStyle: 'font-style:italic',
            position: 'top',
            offset: labelStyle.offset,
            anchorX: 'middle',
            anchorY: 'middle',
            visible: showLabels.value,
            useMathJax: true,
            parse: false
          }
        }) as BoardElement;

        // 创建角度点之间的线段
        const line1 = board!.create('segment', [p2, p3], {
          strokeColor: '#2563eb',
          strokeWidth: 2
        }) as BoardElement;

        const line2 = board!.create('segment', [p2, p1], {
          strokeColor: '#2563eb',
          strokeWidth: 2
        }) as BoardElement;

        state.elements.push(angle, line1, line2);
        state.tempPoints = [];

        if (board) {
          board.update();
        }
      }
    } catch (error) {
      console.error('创建角度标记时发生错误:', error);
      showError('创建角度标记失败,正在重新初始化画板...');
      reinitializeBoard();
    }
  }

  // 触发文件选择
  function triggerFileInput() {
    fileInput.value?.click();
  }

  // 保存为JSON
  function saveToJson() {
    try {
      const boardData = {
        elements: state.elements.map(element => {
          if (element.elType === 'point') {
            return {
              type: 'point',
              name: element.name,
              x: (element as JXG.Point).X(),
              y: (element as JXG.Point).Y()
            };
          } else if (element.elType === 'line') {
            const line = element as JXG.Line;
            return {
              type: 'line',
              straightFirst: line.visProp.straightfirst,
              straightLast: line.visProp.straightlast,
              point1: {
                x: line.point1.X(),
                y: line.point1.Y()
              },
              point2: {
                x: line.point2.X(),
                y: line.point2.Y()
              }
            };
          } else if (element.elType === 'circle') {
            const circle = element as JXG.Circle;
            const radius = circle.Radius();
            return {
              type: 'circle',
              center: {
                x: circle.center.X(),
                y: circle.center.Y()
              },
              radius: radius,
              point: {
                x: circle.center.X() + radius,
                y: circle.center.Y()
              }
            };
          } else if (element.elType === 'polygon') {
            const polygon = element as JXG.Polygon;
            return {
              type: 'polygon',
              vertices: polygon.vertices.map(vertex => ({
                x: vertex.X(),
                y: vertex.Y()
              }))
            };
          } else if (element.elType === 'angle') {
            const angle = element as any;
            return {
              type: 'angle',
              point1: {
                x: angle.point1.X(),
                y: angle.point1.Y()
              },
              point2: {
                x: angle.point2.X(),
                y: angle.point2.Y()
              },
              point3: {
                x: angle.point3.X(),
                y: angle.point3.Y()
              },
              angleType: angle.visProp.type,
              radius: angle.visProp.radius
            };
          } else if (element.elType === 'arrow') {
            const arrow = element as JXG.Arrow;
            return {
              type: 'vector',
              point1: {
                x: arrow.point1.X(),
                y: arrow.point1.Y()
              },
              point2: {
                x: arrow.point2.X(),
                y: arrow.point2.Y()
              }
            };
          }
          return null;
        }).filter(Boolean)
      };

      const jsonStr = JSON.stringify(boardData, null, 2);
      const blob = new Blob([jsonStr], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
      
      const a = document.createElement('a');
      a.href = url;
      a.download = `geometry-board-${timestamp}.json`;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    } catch (error) {
      console.error('保存数据时发生错误:', error);
    }
  }

  // 从JSON加载
  async function loadFromJson(event: Event) {
    try {
      const file = (event.target as HTMLInputElement).files?.[0];
      if (!file) return;

      const text = await file.text();
      const data = JSON.parse(text);

      // 清空当前画板
      clearBoard();

      // 重建元素
      for (const element of data.elements) {
        if (element.type === 'point') {
          const pt = board!.create('point', [element.x, element.y], {
            name: element.name,
            size: 4,
            withLabel: true,
            label: { 
              offset: [10, 10],
              fontSize: 14,
              useMathJax: true,
              parse: false,
              fixed: false,
              highlight: false,
              visible: true,
              cssStyle: 'cursor: default'
            },
            snapToGrid: snapToGrid.value,
            snapSizeX: 1,
            snapSizeY: 1,
            color: '#1e40af'
          }) as JXG.Point;

          // 添加坐标显示的更新函数
          pt.on('update', function(this: JXG.Point) {
            if (showCoordinates.value && this.label) {
              this.label.setText(`${this.name} (${this.X().toFixed(2)}, ${this.Y().toFixed(2)})`);
            } else if (this.label) {
              this.label.setText(this.name);
            }
          });

          state.points.push(pt);
          state.elements.push(pt as BoardElement);
        } else if (element.type === 'line') {
          const p1 = createPoint(element.point1.x, element.point1.y);
          const p2 = createPoint(element.point2.x, element.point2.y);
          const line = board!.create('line', [p1, p2], {
            straightFirst: element.straightFirst,
            straightLast: element.straightLast,
            strokeWidth: 2,
            strokeColor: '#2563eb'
          });
          state.elements.push(line as BoardElement);
        } else if (element.type === 'circle') {
          const center = createPoint(element.center.x, element.center.y);
          const point = createPoint(element.point.x, element.point.y);
          const circle = board!.create('circle', [center, point], {
            strokeWidth: 2,
            strokeColor: '#2563eb',
            fillColor: 'none'
          });
          state.elements.push(circle as BoardElement);
        } else if (element.type === 'polygon') {
          const vertices = element.vertices.map((v: any) => createPoint(v.x, v.y));
          const polygon = board!.create('polygon', vertices, {
            borders: { strokeWidth: 2, strokeColor: '#2563eb' },
            fillColor: '#93c5fd',
            fillOpacity: 0.3
          });
          state.elements.push(polygon as BoardElement);
        } else if (element.type === 'vector') {
          const p1 = createPoint(element.point1.x, element.point1.y);
          const p2 = createPoint(element.point2.x, element.point2.y);
          const vector = board!.create('arrow', [p1, p2], {
            strokeWidth: 2,
            strokeColor: '#2563eb'
          });
          state.elements.push(vector as BoardElement);
        } else if (element.type === 'angle') {
          const p1 = createPoint(element.point1.x, element.point1.y);
          const p2 = createPoint(element.point2.x, element.point2.y);
          const p3 = createPoint(element.point3.x, element.point3.y);

          // 创建角度标记
          const angle = board!.create('angle', [p1, p2, p3], {
            type: element.angleType || 'sector',
            radius: element.radius || 0.8,
            strokeColor: '#2563eb',
            fillColor: element.angleType === 'square' ? 'none' : '#93c5fd',
            fillOpacity: 0.3,
            withLabel: true,
            name: () => {
              try {
                const angleRad = calculateClockwiseAngle(p1, p2, p3);
                return (angleRad * 180 / Math.PI).toFixed(1) + '°';
              } catch (e) {
                return '0°';
              }
            },
            label: {
              fontSize: 14,
              strokeColor: '#2563eb',
              cssStyle: 'font-style:italic',
              position: 'top',
              offset: [0, 0],
              anchorX: 'middle',
              anchorY: 'middle',
              visible: true,
              useMathJax: true,
              parse: false
            }
          });

          // 创建角度点之间的线段
          const line1 = board!.create('segment', [p2, p1], {
            strokeColor: '#2563eb',
            strokeWidth: 2
          });

          const line2 = board!.create('segment', [p2, p3], {
            strokeColor: '#2563eb',
            strokeWidth: 2
          });

          state.elements.push(angle as BoardElement, line1 as BoardElement, line2 as BoardElement);
        }
      }

      // 更新画板
      board?.update();

      // 清空文件输入,以便可以重复选择同一个文件
      if (fileInput.value) {
        fileInput.value.value = '';
      }
    } catch (error) {
      console.error('加载数据时发生错误:', error);
    }
  }

  // 处理直角标记创建
  function handleRightAngleMarkCreation(x: number, y: number) {
    try {
      const pt = createPoint(x, y);
      state.tempPoints.push(pt);

      if (state.tempPoints.length === 3) {
        const [p1, p2, p3] = state.tempPoints;

        // 创建直角标记
        const rightAngle = board!.create('angle', [p3, p2, p1], {
          type: 'square', // 使用方形标记表示直角
          radius: 0.8,
          strokeColor: '#2563eb',
          fillColor: 'none',
          name: function() {
            const angleRad = calculateClockwiseAngle(p3, p2, p1);
            return (angleRad * 180 / Math.PI).toFixed(1) + '°';
          },
          withLabel: true,
          label: {
            fontSize: 14,
            strokeColor: '#2563eb',
            cssStyle: 'font-style:italic',
            position: 'rd',
            offset: [0, 0],
            anchorX: 'middle',
            anchorY: 'middle',
            visible: true,
            useMathJax: true,
            parse: false,
            fixed: false,
            highlight: false,
            rotate: true
          }
        });

        // 创建角度点之间的线段
        const line1 = board!.create('segment', [p2, p3], {
          strokeColor: '#2563eb',
          strokeWidth: 2
        }) as BoardElement;

        const line2 = board!.create('segment', [p2, p1], {
          strokeColor: '#2563eb',
          strokeWidth: 2
        }) as BoardElement;

        state.elements.push(rightAngle as BoardElement, line1, line2);
        state.tempPoints = [];
      }
    } catch (error) {
      console.error('创建直角标记时发生错误:', error);
      showError('创建直角标记失败,正在重新初始化画板...');
      reinitializeBoard();
    }
  }

  // 处理等长标记创建
  function handleEqualMarkCreation(x: number, y: number) {
    try {
      const pt = createPoint(x, y);
      state.tempPoints.push(pt);

      if (state.tempPoints.length === 4) {
        const [p1, p2, p3, p4] = state.tempPoints;
        
        // 创建第一条线段
        const line1 = board!.create('segment', [p1, p2], {
          strokeWidth: 2,
          strokeColor: '#2563eb'
        }) as BoardElement;

        // 创建第二条线段
        const line2 = board!.create('segment', [p3, p4], {
          strokeWidth: 2,
          strokeColor: '#2563eb'
        }) as BoardElement;

        // 在两条线段上添加相等标记(小线段)
        const mark1 = board!.create('segment', [
          board!.create('point', [() => {
            const x = (p1.X() + p2.X()) / 2;
            const y = (p1.Y() + p2.Y()) / 2;
            return [x, y];
          }], {visible: false}),
          board!.create('point', [() => {
            const x = (p1.X() + p2.X()) / 2;
            const y = (p1.Y() + p2.Y()) / 2 + 0.3;
            return [x, y];
          }], {visible: false})
        ], {strokeWidth: 2, strokeColor: '#2563eb'}) as BoardElement;

        const mark2 = board!.create('segment', [
          board!.create('point', [() => {
            const x = (p3.X() + p4.X()) / 2;
            const y = (p3.Y() + p4.Y()) / 2;
            return [x, y];
          }], {visible: false}),
          board!.create('point', [() => {
            const x = (p3.X() + p4.X()) / 2;
            const y = (p3.Y() + p4.Y()) / 2 + 0.3;
            return [x, y];
          }], {visible: false})
        ], {strokeWidth: 2, strokeColor: '#2563eb'}) as BoardElement;

        state.elements.push(line1, line2, mark1, mark2);
        state.tempPoints = [];
      }
    } catch (error) {
      console.error('创建等长标记时发生错误:', error);
      showError('创建等长标记失败,正在重新初始化画板...');
      reinitializeBoard();
    }
  }

  // 修改应用样式函数
  function applyStylesToSelected() {
    if (!board) return;

    board.objectsList.forEach((obj: any) => {
      if (obj.selected) {
        if (obj.elType === 'point') {
          obj.setAttribute({
            color: pointStyle.color,
            size: pointStyle.size,
            opacity: pointStyle.opacity,
            visible: showLabels.value,
            label: {
              color: labelStyle.color,
              fontSize: labelStyle.size,
              opacity: labelStyle.opacity,
              offset: labelStyle.offset
            }
          });
        } else if ([
          'line', 'segment', 'arrow', 'circle', 'angle', 'polygon',
          'circumcircle', 'ellipse'
        ].includes(obj.elType)) {
          // 设置边框样式
          obj.setAttribute({
            strokeColor: lineStyle.color,
            strokeWidth: lineStyle.width,
            strokeOpacity: lineStyle.opacity
          });
          
          // 更新标签样式
          if (obj.label) {
            obj.label.setAttribute({
              strokeColor: labelStyle.color,
              fontSize: labelStyle.size,
              opacity: labelStyle.opacity,
              offset: labelStyle.offset,
              visible: showLabels.value
            });
          }
          
          // 如果是多边形或角度标记,更新填充样式
          if (['polygon', 'angle'].includes(obj.elType)) {
            obj.setAttribute({
              fillColor: fillStyle.enabled ? fillStyle.color : 'transparent',
              fillOpacity: fillStyle.opacity
            });
          }
        }
        obj.selected = false;
      }
    });

    hasSelectedElements.value = false;
    board.update();
  }

  // 修改标签显示选项
  function toggleLabels() {
    if (!board) return;

    const b = board;
    const labels = b.objectsList.filter((obj: any) => obj.elType === 'label');
    labels.forEach((label: any) => {
      label.visible = showLabels.value;
    });
    b.update();
  }
样式部分 (Style)
scss 复制代码
<style scoped>
  @import url('https://cdn.jsdelivr.net/npm/jsxgraph/distrib/jsxgraph.css');

  /* 表单控件样式 */
  .form-checkbox {
    @apply rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50;
  }

  /* 滑块样式 */
  input[type="range"] {
    @apply h-2 rounded-lg bg-gray-200;
  }

  /* 颜色选择器样式 */
  input[type="color"] {
    @apply cursor-pointer rounded border border-gray-300;
  }

  /* 画板容器样式 */
  .board-container {
    @apply relative w-full h-[600px] border rounded-md overflow-hidden;
  }

  /* 工具栏样式 */
  .toolbar {
    @apply flex flex-wrap items-center gap-2 mb-2 p-2 bg-gray-50 rounded-md;
  }

  /* 样式控制面板 */
  .style-panel {
    @apply flex flex-wrap items-center gap-4 p-3 border rounded-md mb-2;
  }

  /* 按钮样式 */
  .btn {
    @apply px-3 py-1 rounded text-white transition-colors duration-200;
  }

  .btn-primary {
    @apply bg-blue-500 hover:bg-blue-600;
  }

  .btn-danger {
    @apply bg-red-500 hover:bg-red-600;
  }

  .btn-success {
    @apply bg-green-500 hover:bg-green-600;
  }

  /* 禁用状态样式 */
  .disabled {
    @apply opacity-50 cursor-not-allowed;
  }

  /* 标签样式 */
  .label {
    @apply font-medium text-gray-700;
  }

  /* 工具提示样式 */
  [title] {
    @apply cursor-help;
  }
</style>

效果展示

总结

这个几何作图组件通过结合 Vue 3 和 JSXGraph,实现了功能丰富、交互友好的几何图形绘制功能。它不仅提供了基础的绘图工具,还支持丰富的样式定制和交互操作,可以满足教学、演示等多种场景的需求。

相关推荐
_一条咸鱼_1 小时前
深入解析 Vue API 模块原理:从基础到源码的全方位探究(八)
前端·javascript·面试
患得患失9491 小时前
【前端】【难点】前端富文本开发的核心难点总结与思路优化
前端·富文本
执键行天涯1 小时前
在vue项目中package.json中的scripts 中 dev:“xxx“中的xxx什么概念
前端·vue.js·json
雯0609~1 小时前
html:文件上传-一次性可上传多个文件,将文件展示到页面(可删除
前端·html
涵信1 小时前
2024年React最新高频面试题及核心考点解析,涵盖基础、进阶和新特性,助你高效备战
前端·react.js·前端框架
mmm.c1 小时前
应对多版本vue,nvm,node,npm,yarn的使用
前端·vue.js·npm
混血哲谈1 小时前
全新电脑如何快速安装nvm,npm,pnpm
前端·npm·node.js
天天扭码1 小时前
项目登录注册页面太丑?试试我“仿制”的丝滑页面(全源码可复制)
前端·css·html
桂月二二2 小时前
Vue3服务端渲染深度实战:SSR架构优化与企业级应用
前端·vue.js·架构
萌萌哒草头将军2 小时前
🚀🚀🚀 这六个事半功倍的 Pinia 库,你一定要知道!
前端·javascript·vue.js