电子签名功能实现笔记
项目背景
电子签名功能在现代应用中非常常见,尤其是在需要用户确认或授权的场景中。它可以通过触摸屏幕或鼠标在画布上绘制签名,并将其保存为图像文件。
整体架构
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 Vue | UI组件库 | 企业级组件、主题定制 |
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>
元素作为签名画板。 - 通过
touchstart
、touchmove
、touchend
事件处理用户的绘制动作。 - 提供"重新签名"和"确认签名"按钮。
二维码签名
- 使用
QRCode
库生成签名页面的二维码。 - 用户通过手机扫描二维码进行签名。
- 通过URL参数传递签名结果。
签名上传
- 使用
toBlob
方法将签名画布转换为图像文件。 - 通过
FormData
上传文件到服务器。 - 使用
URLSearchParams
解析URL参数,获取sid
和callback
。
代码片段
以下是实现电子签名代码:
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操作