Skip to content

电子签名功能实现笔记

项目背景

电子签名功能在现代应用中非常常见,尤其是在需要用户确认或授权的场景中。它可以通过触摸屏幕或鼠标在画布上绘制签名,并将其保存为图像文件。

整体架构

graph TD
    A[前端应用] --> B[签名画板模块]
    A --> C[二维码模块]
    B --> D[Canvas渲染]
    B --> E[事件处理]
    C --> F[移动端适配]
    
    D --> G[图像处理]
    E --> H[性能优化]
    F --> I[跨端通信]

技术栈

NOTE

核心技术栈及选型理由:

技术用途选型理由
Vue3前端框架响应式系统、组合式API
Canvas API绘图能力性能好、可控性强
Ant Design VueUI组件库企业级组件、主题定制
QRCode二维码生成轻量级、兼容性好
sequenceDiagram
    participant U as User
    participant C as Canvas
    participant R as Renderer
    
    U->>C: Touch/Mouse Event
    C->>R: 事件节流
    R->>C: 二次贝塞尔曲线
    C->>U: 平滑渲染

主要功能

  • 签名画板:用户可以在画布上绘制签名。
  • 二维码签名:通过生成二维码,用户可以在手机上进行签名。
  • 签名上传:将签名图像上传到服务器,并通过URL进行回调。
  • 签名确认:用户可以确认签名并提交。

代码实现

签名画板

  • 使用<canvas>元素作为签名画板。
  • 通过touchstarttouchmovetouchend事件处理用户的绘制动作。
  • 提供"重新签名"和"确认签名"按钮。

二维码签名

  • 使用QRCode库生成签名页面的二维码。
  • 用户通过手机扫描二维码进行签名。
  • 通过URL参数传递签名结果。

签名上传

  • 使用toBlob方法将签名画布转换为图像文件。
  • 通过FormData上传文件到服务器。
  • 使用URLSearchParams解析URL参数,获取sidcallback

代码片段

以下是实现电子签名代码:

vue
<template>
  <div class="signature-page">
    <!-- 顶部导航 -->
    <div class="nav-header">
      <div :s class="title">电子签名</div>
    </div>

    <!-- 签名画板区域 -->
    <div class="signature-board">
      <canvas ref="signatureCanvas" :width="canvasWidth" :height="canvasHeight" @touchstart="handleTouchStart"
        @touchmove="handleTouchMove" @touchend="handleTouchEnd"></canvas>
    </div>

    <!-- 底部操作按钮 -->
    <div class="action-buttons">
      <a-button @click="clearCanvas" class="action-btn">
        重新签名
      </a-button>
      <a-button type="primary" @click="confirmSignature" class="action-btn">
        确认签名
      </a-button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { uploadFileAPI } from '@/apis/File/File';

const route = useRoute();
const router = useRouter();

// 画布相关状态变量
const signatureCanvas = ref(null); // 画布DOM引用
const canvasWidth = window.innerWidth * 0.9; // 画布宽度,设置为屏幕宽度的90%
const canvasHeight = window.innerHeight * 0.7; // 画布高度,设置为屏幕高度的70%
let ctx = null; // 画布上下文对象
let isDrawing = false; // 是否正在绘制的标志
let lastX = 0; // 上一次触摸点的X坐标
let lastY = 0; // 上一次触摸点的Y坐标

/**
 * 初始化画布配置
 * 设置画布样式、背景色并绘制网格
 */
const initCanvas = () => {
  /**
   * `getContext('2d')`获取2D绘图上下文
   * 所有的绘图操作都是通过这个上下文对象进行
   * `ctx`是一个强大的绘图工具,包含了所有2D绘图的方法
   */
  const canvas = signatureCanvas.value;
  ctx = canvas.getContext('2d');

  // 设置画笔样式
  ctx.strokeStyle = '#000'; // 画笔颜色为黑色
  ctx.lineWidth = 2; // 线条宽度为2px
  ctx.lineCap = 'round'; // 线条端点为圆形
  ctx.lineJoin = 'round'; // 线条连接处为圆形

  // 绘制白色背景
  ctx.fillStyle = '#fff';
  // (X,Y,宽度,高度),从(0,0)点开始填充整个画布
  ctx.fillRect(0, 0, canvasWidth, canvasHeight);

  // 添加网格背景便于签名对齐
  drawGrid();
};

/**
 * 绘制网格背景
 * 创建浅灰色网格线帮助用户对齐签名
 */
const drawGrid = () => {
  ctx.beginPath();
  ctx.strokeStyle = '#e5e5e5'; // 网格线颜色为浅灰色
  ctx.lineWidth = 0.5; // 网格线宽度为0.5px

  // 绘制水平网格线
  for (let i = 0; i < canvasHeight; i += 20) {
    ctx.moveTo(0, i);
    ctx.lineTo(canvasWidth, i);
  }

  // 绘制垂直网格线
  for (let i = 0; i < canvasWidth; i += 20) {
    ctx.moveTo(i, 0);
    ctx.lineTo(i, canvasHeight);
  }

  ctx.stroke();

  // 重置画笔样式为签名笔样式
  ctx.strokeStyle = '#000';
  ctx.lineWidth = 2;
};

/**
 * 处理触摸开始事件
 * 记录起始触摸点坐标
 */
const handleTouchStart = (e) => {
  isDrawing = true;
  const touch = e.touches[0]; // 获取第一个触摸点
  const rect = signatureCanvas.value.getBoundingClientRect(); // 获取元素的位置和尺寸,用于计算触摸点相对于画布的坐标
  lastX = touch.clientX - rect.left; // 计算相对于画布的X坐标
  lastY = touch.clientY - rect.top; // 计算相对于画布的Y坐标
};

/**
 * 处理触摸移动事件
 * 绘制签名线条,使用贝塞尔曲线实现平滑效果
 */
const handleTouchMove = (e) => {
  if (!isDrawing) return; // 如果不是绘制状态则返回
  e.preventDefault(); // 阻止默认滚动行为

  const touch = e.touches[0];
  const rect = signatureCanvas.value.getBoundingClientRect();
  const currentX = touch.clientX - rect.left; // 当前触摸点X坐标
  const currentY = touch.clientY - rect.top; // 当前触摸点Y坐标

  // 绘制直线
  ctx.beginPath();
  ctx.moveTo(lastX, lastY); // 从上一次触摸点开始绘制
  ctx.lineTo(currentX, currentY); // 绘制到当前触摸点
  ctx.stroke(); // 绘制线条

  // 使用二次贝塞尔曲线使线条更平滑
  ctx.quadraticCurveTo(lastX, lastY, currentX, currentY); 
  ctx.stroke();

  // 更新上一次的坐标点
  lastX = currentX;
  lastY = currentY;
};

/**
 * 处理触摸结束事件
 * 重置绘制状态
 */
const handleTouchEnd = () => {
  isDrawing = false;
};

/**
 * 清除画布内容
 * 重新绘制白色背景和网格
 */
const clearCanvas = () => {
  ctx.fillStyle = '#fff';
  ctx.fillRect(0, 0, canvasWidth, canvasHeight);
  drawGrid();
};


/**
 * 确认签名并上传
 * @description 将画布内容转换为图片并上传到服务器
 * @returns {Promise<void>} 无返回值的异步函数
 * @throws {Error} 上传失败时抛出错误
 */
const confirmSignature = async () => {
  try {
    // 将画布内容转换为blob对象,使用Promise包装canvas的toBlob方法
    const blob = await new Promise(resolve => {
      // 调用canvas的toBlob方法,将画布内容转换为png格式的blob对象
      signatureCanvas.value.toBlob(resolve, 'image/png');
    });

    // 获取URL参数中的sid
    const urlParams = new URLSearchParams(window.location.search);
    const sid = urlParams.get('sid');

    // 创建文件对象并上传
    const file = new File([blob], `signature_${sid}.png`, { type: 'image/png' });
    const formData = new FormData();
    formData.append('file', file);

    // 调用上传API
    const res = await uploadFileAPI(formData);
    const signatureUrl = res.data.data || res.data;

    if (signatureUrl) {
      // 处理回调URL  
      const callback = urlParams.get('callback');
      if (callback) {
        // decodeURIComponent() 用于解码URL编码的字符串
        // 例如: 将 %20 转换为空格, %2F 转换为 /, %3F 转换为 ?
        // 在URL中,某些字符(如空格、中文等)会被编码,需要解码后才能正确使用
        const callbackUrl = new URL(decodeURIComponent(callback));

        // 本地开发环境IP替换
        if (callbackUrl.hostname === 'localhost') {
          callbackUrl.hostname = '192.168.2.37';
        }

        // 将签名URL添加到hash中
        // encodeURIComponent() 用于将字符串编码为URL安全的格式
        // 它会将以下字符进行编码:
        // - 非字母数字字符(如空格会编码为%20)
        // - 特殊字符(如/会编码为%2F)
        // - Unicode字符(如中文会编码为%E4%B8%AD%E6%96%87)
        // 这样可以确保URL中包含的所有字符都是合法的,避免出现解析错误
        callbackUrl.hash = `signature=${encodeURIComponent(signatureUrl)}`;
        window.location.href = callbackUrl.toString();
      }
      message.success('签名上传成功');
    }
  } catch (error) {
    console.error('上传签名失败:', error);
    message.error('签名上传失败,请重试');
  }
};

// 组件生命周期钩子
onMounted(() => {
  initCanvas(); // 初始化画布

  // 验证签名会话
  if (!route.query.sid) {
    message.error('无效的签名会话');
    router.push('/error');
  }
});

// 阻止页面缩放相关事件处理
const preventDefault = (e) => e.preventDefault();

onMounted(() => {
  document.addEventListener('touchmove', preventDefault, { passive: false });
});

onUnmounted(() => {
  document.removeEventListener('touchmove', preventDefault);
});
</script>
css
<style scoped>
.signature-page {
  height: 100vh;
  display: flex;
  flex-direction: column;
  background-color: #f5f5f5;
  touch-action: none;
}

.nav-header {
  height: 50px;
  background-color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.title {
  font-size: 18px;
  font-weight: 500;
}

.signature-board {
  flex: 1;
  padding: 20px;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
}

canvas {
  background: #fff;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  border-radius: 8px;
  touch-action: none;
}

.action-buttons {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 16px;
  background-color: #fff;
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
  display: flex;
  justify-content: space-around;
  z-index: 100;
}

.action-btn {
  flex: 1;
  margin: 0 8px;
  height: 40px;
}
</style>

Q & A

javascript
// 防抖处理
let checkInterval = null;

const startPolling = () => {
  if (!checkInterval) {
    checkInterval = setInterval(checkSignatureUrl, 1000);
  }
};

const stopPolling = () => {
  if (checkInterval) {
    clearInterval(checkInterval);
    checkInterval = null;
  }
};
  • 避免重复创建定时器
  • 及时清理不需要的资源
  • 优化轮询频率
  • 减少不必要的DOM操作