当前位置: 首页 > news >正文

uniapp 手写签名组件开发全攻略

引言

在移动应用开发中,手写签名功能是一个常见的需求,特别是在电子合同、审批流程、金融交易等场景中。本文将详细介绍如何基于uni-app框架开发一个高性能、功能丰富的手写签名组件,并分享开发过程中的技术要点和最佳实践。

组件概述

这个签名组件提供了完整的签名功能,包括:

  • 平滑手写绘制体验

  • 撤销/清空操作

  • 保存签名到相册

  • 实时预览签名

  • 自动上传到服务器

  • 多平台兼容(H5、小程序、App)

技术架构

封装依赖

// 工具类模块化设计
import { UploadManager } from '../utils/uploadManager.js'
import { showToast, showLoading, hideLoading } from '../utils/messageUtils.js'
import { deepMerge, validateConfig } from '../utils/validationUtils.js'
import { calculateDistance, calculateSpeed } from '../utils/mathUtils.js'
import { getCanvasSize, clearCanvasArea } from '../utils/canvasUtils.js'

组件参数设计

props: {minSpeed: { type: Number, default: 1.5 },        // 最小速度阈值minWidth: { type: Number, default: 3 },          // 最小线条宽度maxWidth: { type: Number, default: 10 },         // 最大线条宽度openSmooth: { type: Boolean, default: true },    // 是否开启平滑绘制maxHistoryLength: { type: Number, default: 20 }, // 最大历史记录数bgColor: { type: String, default: 'transparent' },// 画布背景色uploadUrl: { type: String, default: '' },        // 上传地址uploadConfig: { type: Object, default: () => ({}) } // 上传配置
}

核心技术实现

1. Canvas初始化与适配

initCanvas() {try {this.ctx = uni.createCanvasContext("handWriting", this);this.$nextTick(() => {// 获取容器实际尺寸uni.createSelectorQuery().select('.handCenter').boundingClientRect(rect => {this.canvasWidth = rect.width;this.canvasHeight = rect.height;// 设置背景色if (this.bgColor && this.bgColor !== 'transparent') {this.drawBgColor();}this.isCanvasReady = true;}).exec();});} catch (error) {// 异常处理和重试机制setTimeout(() => this.initCanvas(), 500);}
}

2. 平滑绘制算法

这是组件的核心功能,通过速度感知的线条宽度调整实现自然书写效果:

initPoint(x, y) {const point = { x, y, t: Date.now() };const prePoint = this.points.slice(-1)[0];if (prePoint && this.openSmooth) {// 计算与上一个点的距离和速度point.distance = calculateDistance(point.x, point.y, prePoint.x, prePoint.y);point.speed = calculateSpeed(point.distance, point.t - prePoint.t);// 根据速度动态计算线条宽度point.lineWidth = this.getLineWidth(point.speed);// 限制宽度变化率,避免突变const prePoint2 = this.points.slice(-2, -1)[0];if (prePoint2 && prePoint2.lineWidth && prePoint.lineWidth) {const rate = (point.lineWidth - prePoint.lineWidth) / prePoint.lineWidth;const maxRate = this.maxWidthDiffRate / 100;if (Math.abs(rate) > maxRate) {const per = rate > 0 ? maxRate : -maxRate;point.lineWidth = prePoint.lineWidth * (1 + per);}}}this.points.push(point);this.points = this.points.slice(-3); // 只保留最近3个点this.currentStroke.push(point);
}

3. 贝塞尔曲线绘制

为了实现平滑的书写效果,我们使用二次贝塞尔曲线进行绘制:

drawSmoothLine(prePoint, point) {const dis_x = point.x - prePoint.x;const dis_y = point.y - prePoint.y;// 计算控制点if (Math.abs(dis_x) + Math.abs(dis_y) <= 2) {point.lastX1 = point.lastX2 = prePoint.x + dis_x * 0.5;point.lastY1 = point.lastY2 = prePoint.y + dis_y * 0.5;} else {point.lastX1 = prePoint.x + dis_x * 0.3;point.lastY1 = prePoint.y + dis_y * 0.3;point.lastX2 = prePoint.x + dis_x * 0.7;point.lastY2 = prePoint.y + dis_y * 0.7;}point.perLineWidth = (prePoint.lineWidth + point.lineWidth) / 2;if (typeof prePoint.lastX1 === 'number') {// 绘制曲线this.drawCurveLine(prePoint.lastX2, prePoint.lastY2, prePoint.x, prePoint.y,point.lastX1, point.lastY1, point.perLineWidth);// 绘制梯形填充,确保线条连续性if (!prePoint.isFirstPoint) {const data = this.getRadianData(prePoint.lastX1, prePoint.lastY1, prePoint.lastX2, prePoint.lastY2);const points1 = this.getRadianPoints(data, prePoint.lastX1, prePoint.lastY1, prePoint.perLineWidth / 2);const points2 = this.getRadianPoints(data, prePoint.lastX2, prePoint.lastY2, point.perLineWidth / 2);this.drawTrapezoid(points1[0], points2[0], points2[1], points1[1]);}} else {point.isFirstPoint = true;}
}

4. 撤销功能实现

撤销功能通过维护绘制历史记录实现:

// 添加历史记录
addHistory() {if (this.currentStroke.length > 0) {const strokeData = {points: JSON.parse(JSON.stringify(this.currentStroke)),color: this.lineColor,baseLineWidth: this.maxWidth,minWidth: this.minWidth,maxWidth: this.maxWidth,openSmooth: this.openSmooth,minSpeed: this.minSpeed,timestamp: Date.now()};this.drawingHistory.push(strokeData);// 限制历史记录长度if (this.drawingHistory.length > this.maxHistoryLength) {this.drawingHistory = this.drawingHistory.slice(-this.maxHistoryLength);}this.currentStroke = [];}
}// 撤销操作
undo() {if (this.drawingHistory.length > 0) {// 移除最后一个笔画this.drawingHistory.pop();// 清空画布并重绘所有剩余笔画this.clearCanvas();this.redrawAllStrokes();showToast('已撤销上一步', 'success', 1500);}
}

5. 跨平台保存策略

针对不同平台采用不同的保存策略:

performSave() {uni.canvasToTempFilePath({canvasId: 'handWriting',fileType: 'png',quality: 1,success: (res) => {// H5环境使用下载方式// #ifdef H5const link = document.createElement('a');link.download = `signature_${Date.now()}.png`;link.href = res.tempFilePath;document.body.appendChild(link);link.click();document.body.removeChild(link);// #endif// 小程序环境保存到相册// #ifndef H5uni.saveImageToPhotosAlbum({filePath: res.tempFilePath,success: () => {showToast('已成功保存到相册', 'success', 2000);},fail: (saveError) => {// 处理权限问题if (saveError.errMsg.includes('auth')) {showModal('保存失败', '需要相册权限,请在设置中开启', '去设置').then((modalRes) => {if (modalRes.confirm) uni.openSetting();});}}});// #endif}}, this);
}

6. 上传管理器类

/*** 上传管理器类* 负责处理文件上传的完整流程,包括配置验证、上传执行、重试机制、错误处理等*/
export class UploadManager {constructor() {// 上传状态管理this.uploadState = {isUploading: false,currentRetry: 0,lastError: null,uploadStartTime: null,canRetry: true};}/*** 执行文件上传* @param {string} filePath - 文件路径* @param {Object} config - 上传配置* @returns {Promise} 上传结果*/async performUpload(filePath, config) {if (!filePath) {throw new Error('文件路径不能为空');}if (!config || !config.uploadUrl) {throw new Error('上传配置或上传地址不能为空');}this.uploadState.isUploading = true;this.uploadState.error = null;try {const result = await this._uploadWithUni(filePath, config);return await this.handleUploadSuccess(result, config);} catch (error) {return await this.handleUploadError(error, filePath, config);}}/*** 处理上传成功* @param {Object} result - 上传结果* @param {Object} config - 上传配置* @returns {Object} 处理后的结果*/handleUploadSuccess(result, config) {this.uploadState.isUploading = false;this.uploadState.retryCount = 0;let fileUrl = null;try {// 尝试解析响应数据const responseData = typeof result.data === 'string' ? JSON.parse(result.data) : result.data;// 提取文件URLfileUrl = this._extractFileUrl(responseData, config);if (!fileUrl) {throw new Error('无法从响应中提取文件URL');}return {success: true,fileUrl,response: responseData,statusCode: result.statusCode};} catch (error) {console.error('[UploadManager] 处理上传成功响应时出错:', error);throw new Error('上传成功但处理响应失败: ' + error.message);}}/*** 处理上传错误* @param {Object} error - 错误对象* @param {string} filePath - 文件路径* @param {Object} config - 上传配置* @returns {Object} 错误处理结果*/async handleUploadError(error, filePath, config) {this.uploadState.isUploading = false;this.uploadState.error = error;// 判断是否需要重试if (this._shouldRetryUpload(error) && this.uploadState.retryCount < this.maxRetries) {return await this.handleUploadRetry(filePath, config);}// 重试次数用完或不需要重试,返回最终错误return {success: false,error: error,message: this._getErrorMessage(error),retryCount: this.uploadState.retryCount,canRetry: this.uploadState.retryCount < this.maxRetries};}/*** 处理上传重试* @param {string} filePath - 文件路径* @param {Object} config - 上传配置* @returns {Promise} 重试结果*/async handleUploadRetry(filePath, config) {this.uploadState.retryCount++;// 计算重试延迟时间(指数退避)const delay = Math.min(1000 * Math.pow(2, this.uploadState.retryCount - 1), 10000);console.log(`[UploadManager] 第${this.uploadState.retryCount}次重试,延迟${delay}ms`);// 等待延迟时间await new Promise(resolve => setTimeout(resolve, delay));// 重新尝试上传return await this.performUpload(filePath, config);}/*** 重置上传状态*/resetUploadState() {this.uploadState = {isUploading: false,retryCount: 0,error: null};}/*** 使用uni.uploadFile执行上传* @private* @param {string} filePath - 文件路径* @param {Object} config - 上传配置* @returns {Promise} 上传Promise*/_uploadWithUni(filePath, config) {return new Promise((resolve, reject) => {const uploadOptions = {url: config.uploadUrl,filePath: filePath,name: config.fileName || 'file',timeout: config.timeout || 60000};// 添加表单数据if (config.formData && typeof config.formData === 'object') {uploadOptions.formData = config.formData;}// 添加请求头if (config.headers && typeof config.headers === 'object') {uploadOptions.header = config.headers;}uni.uploadFile({...uploadOptions,success: resolve,fail: reject});});}/*** 从响应数据中提取文件URL* @private* @param {Object} responseData - 响应数据* @param {Object} config - 上传配置* @returns {string|null} 文件URL*/_extractFileUrl(responseData, config) {if (!responseData) return null;// 常见的URL字段名const urlFields = ['url', 'fileUrl', 'file_url', 'path', 'filePath', 'file_path', 'src', 'link'];// 直接查找URL字段for (const field of urlFields) {if (responseData[field]) {return responseData[field];}}// 查找嵌套的data字段if (responseData.data) {for (const field of urlFields) {if (responseData.data[field]) {return responseData.data[field];}}}// 查找result字段if (responseData.result) {for (const field of urlFields) {if (responseData.result[field]) {return responseData.result[field];}}}return null;}/*** 判断是否应该重试上传* @private* @param {Object} error - 错误对象* @returns {boolean} 是否应该重试*/_shouldRetryUpload(error) {if (!error) return false;const errorMsg = (error.errMsg || error.message || '').toLowerCase();// 网络相关错误可以重试if (errorMsg.includes('network') || errorMsg.includes('timeout') || errorMsg.includes('连接') || errorMsg.includes('超时')) {return true;}// 服务器5xx错误可以重试if (errorMsg.includes('500') || errorMsg.includes('502') || errorMsg.includes('503') || errorMsg.includes('504')) {return true;}// 其他错误不重试(如4xx客户端错误)return false;}/*** 获取用户友好的错误消息* @private* @param {Object} error - 错误对象* @returns {string} 错误消息*/_getErrorMessage(error) {if (!error) return '上传失败';const errorMsg = (error.errMsg || error.message || '').toLowerCase();if (errorMsg.includes('network') || errorMsg.includes('连接')) {return '网络连接失败,请检查网络设置';}if (errorMsg.includes('timeout') || errorMsg.includes('超时')) {return '上传超时,请稍后重试';}if (errorMsg.includes('500')) {return '服务器内部错误,请稍后重试';}if (errorMsg.includes('404')) {return '上传地址不存在,请检查配置';}if (errorMsg.includes('403')) {return '没有上传权限,请联系管理员';}if (errorMsg.includes('413')) {return '文件过大,请选择较小的文件';}return error.errMsg || error.message || '上传失败,请重试';}
}/*** 创建上传管理器实例* @returns {UploadManager} 上传管理器实例*/
export function createUploadManager() {return new UploadManager();
}/*** 默认导出上传管理器类*/
export default UploadManager;

7. 消息提示工具

/*** 显示Toast消息* @param {string} title - 消息标题* @param {string} icon - 图标类型 ('success', 'error', 'loading', 'none')* @param {number} duration - 显示时长(毫秒)* @param {Object} options - 其他选项*/
export function showToast(title, icon = 'none', duration = 2000, options = {}) {if (!title) {console.warn('[messageUtils] showToast: title is required');return;}const toastOptions = {title: String(title),icon: ['success', 'error', 'loading', 'none'].includes(icon) ? icon : 'none',duration: Math.max(1000, Math.min(duration, 10000)), // 限制在1-10秒之间...options};try {uni.showToast(toastOptions);} catch (error) {console.error('[messageUtils] showToast error:', error);}
}/*** 显示加载状态* @param {string} title - 加载提示文字* @param {boolean} mask - 是否显示透明蒙层*/
export function showLoading(title = '加载中...', mask = true) {try {uni.showLoading({title: String(title),mask: Boolean(mask)});} catch (error) {console.error('[messageUtils] showLoading error:', error);}
}/*** 隐藏加载状态*/
export function hideLoading() {try {uni.hideLoading();} catch (error) {console.error('[messageUtils] hideLoading error:', error);}
}/*** 格式化错误消息* @param {string} message - 原始错误消息* @param {Object} error - 错误对象* @returns {string} 格式化后的用户友好消息*/
export function formatErrorMessage(message, error) {if (!error) return message || '操作失败';const errorMsg = (error.errMsg || error.message || error.toString()).toLowerCase();// 网络相关错误if (errorMsg.includes('network') || errorMsg.includes('连接')) {return '网络连接失败,请检查网络设置';}if (errorMsg.includes('timeout') || errorMsg.includes('超时')) {return '操作超时,请稍后重试';}// 服务器相关错误if (errorMsg.includes('500')) {return '服务器内部错误,请稍后重试';}if (errorMsg.includes('404')) {return '请求的资源不存在';}if (errorMsg.includes('403')) {return '没有操作权限,请联系管理员';}if (errorMsg.includes('401')) {return '身份验证失败,请重新登录';}// 文件相关错误if (errorMsg.includes('file') || errorMsg.includes('文件')) {return '文件处理失败,请重试';}// 权限相关错误if (errorMsg.includes('auth') || errorMsg.includes('permission')) {return '权限不足,请检查应用权限设置';}return message || '操作失败,请重试';
}/*** 显示最终错误消息* @param {string} message - 错误消息* @param {Object} error - 错误对象* @param {Object} config - 显示配置*/
export function showFinalError(message, error, config = {}) {const formattedMessage = formatErrorMessage(message, error);const { useModal = false, duration = 3000 } = config;if (useModal) {showModal({title: '错误提示',content: formattedMessage,showCancel: false,confirmText: '确定'});} else {showToast(formattedMessage, 'error', duration);}
}/*** 显示确认对话框* @param {string} title - 对话框标题* @param {string} content - 对话框内容* @param {Object} options - 其他选项* @returns {Promise} 用户选择结果*/
export function showModal(title, content, options = {}) {const defaultOptions = {title: title || '提示',content: content || '',showCancel: true,cancelText: '取消',confirmText: '确定'};const modalOptions = { ...defaultOptions, ...options };return new Promise((resolve, reject) => {try {uni.showModal({...modalOptions,success: (res) => {resolve({confirm: res.confirm,cancel: res.cancel});},fail: (error) => {console.error('[messageUtils] showModal error:', error);reject(error);}});} catch (error) {console.error('[messageUtils] showModal error:', error);reject(error);}});
}/*** 显示操作菜单* @param {Array} itemList - 菜单项列表* @param {Object} options - 其他选项* @returns {Promise} 用户选择结果*/
export function showActionSheet(itemList, options = {}) {const { itemColor = '#000000' } = options;if (!Array.isArray(itemList) || itemList.length === 0) {console.warn('[messageUtils] showActionSheet: itemList is required and should not be empty');return Promise.reject(new Error('itemList is required'));}return new Promise((resolve, reject) => {try {uni.showActionSheet({itemList,itemColor,success: (res) => {resolve({tapIndex: res.tapIndex,selectedItem: itemList[res.tapIndex]});},fail: (error) => {if (error.errMsg && error.errMsg.includes('cancel')) {resolve({ cancel: true });} else {console.error('[messageUtils] showActionSheet error:', error);reject(error);}}});} catch (error) {console.error('[messageUtils] showActionSheet error:', error);reject(error);}});
}/*** 默认导出所有消息工具函数*/
export default {showToast,showLoading,hideLoading,formatErrorMessage,showFinalError,showModal,showActionSheet
};

8. 验证工具

/*** 深度合并对象* @param {Object} target - 目标对象* @param {Object} source - 源对象* @returns {Object} 合并后的对象*/
export function deepMerge(target, source) {if (!source || typeof source !== 'object') {return target;}const result = JSON.parse(JSON.stringify(target));for (const key in source) {if (source.hasOwnProperty(key)) {if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {// 递归合并对象result[key] = deepMerge(result[key] || {}, source[key]);} else {// 直接覆盖基本类型和数组result[key] = source[key];}}}return result;
}/*** 验证配置对象* @param {Object} config - 配置对象* @returns {Object} 验证结果 {isValid: boolean, errors: Array}*/
export function validateConfig(config) {const errors = [];if (!config || typeof config !== 'object') {errors.push('配置对象不能为空且必须是对象类型');return { isValid: false, errors };}// 验证上传URLif (!validateUrl(config.uploadUrl)) {errors.push('uploadUrl 必须是有效的URL格式');}// 验证文件名if (!validateFileName(config.fileName)) {errors.push('fileName 必须是有效的字符串');}// 验证文件类型if (!validateFileType(config.fileType)) {errors.push('fileType 必须是 png、jpg 或 jpeg');}// 验证质量参数if (!validateQuality(config.quality)) {errors.push('quality 必须是 0-1 之间的数字');}// 验证超时时间if (!validateTimeout(config.timeout)) {errors.push('timeout 必须是大于0的数字');}// 验证headers(如果存在)if (config.headers && !validateHeaders(config.headers)) {errors.push('headers 必须是对象类型');}// 验证formData(如果存在)if (config.formData && !validateFormData(config.formData)) {errors.push('formData 必须是对象类型');}return {isValid: errors.length === 0,errors};
}/*** 验证URL格式* @param {string} url - 待验证的URL* @returns {boolean} 是否为有效URL*/
export function validateUrl(url) {if (!url || typeof url !== 'string') {return false;}try {new URL(url);return true;} catch {return false;}
}/*** 验证文件类型* @param {string} fileType - 文件类型* @param {Array} allowedTypes - 允许的文件类型列表* @returns {boolean} 是否为有效文件类型*/
export function validateFileType(fileType, allowedTypes = ['png', 'jpg', 'jpeg']) {if (!fileType || typeof fileType !== 'string') {return false;}return allowedTypes.includes(fileType.toLowerCase());
}/*** 验证质量参数* @param {number} quality - 质量参数* @returns {boolean} 是否为有效质量参数*/
export function validateQuality(quality) {return typeof quality === 'number' && quality >= 0 && quality <= 1;
}/*** 验证超时时间* @param {number} timeout - 超时时间(毫秒)* @returns {boolean} 是否为有效超时时间*/
export function validateTimeout(timeout) {return typeof timeout === 'number' && timeout > 0;
}/*** 验证文件名* @param {string} fileName - 文件名* @returns {boolean} 是否为有效文件名*/
export function validateFileName(fileName) {if (!fileName || typeof fileName !== 'string') {return false;}// 检查文件名是否包含非法字符const invalidChars = /[<>:"/\\|?*]/;return !invalidChars.test(fileName) && fileName.trim().length > 0;
}/*** 验证请求头对象* @param {Object} headers - 请求头对象* @returns {boolean} 是否为有效请求头*/
export function validateHeaders(headers) {return headers && typeof headers === 'object' && !Array.isArray(headers);
}/*** 验证表单数据对象* @param {Object} formData - 表单数据对象* @returns {boolean} 是否为有效表单数据*/
export function validateFormData(formData) {return formData && typeof formData === 'object' && !Array.isArray(formData);
}/*** 默认导出所有验证工具函数*/
export default {deepMerge,validateConfig,validateUrl,validateFileType,validateQuality,validateTimeout,validateFileName,validateHeaders,validateFormData
};

9. 数学计算工具

/*** 计算两点之间的距离* @param {Object} point1 - 第一个点 {x, y}* @param {Object} point2 - 第二个点 {x, y}* @returns {number} 距离值*/
export function calculateDistance(point1, point2) {if (!point1 || !point2 || typeof point1.x !== 'number' || typeof point1.y !== 'number' ||typeof point2.x !== 'number' || typeof point2.y !== 'number') {return 0;}const dx = point2.x - point1.x;const dy = point2.y - point1.y;return Math.sqrt(dx * dx + dy * dy);
}/*** 计算绘制速度* @param {number} distance - 距离* @param {number} time - 时间差(毫秒)* @returns {number} 速度值*/
export function calculateSpeed(distance, time) {if (typeof distance !== 'number' || typeof time !== 'number' || time <= 0) {return 0;}return distance / time;
}/*** 根据速度计算线宽* @param {number} speed - 绘制速度* @param {number} minWidth - 最小线宽* @param {number} maxWidth - 最大线宽* @returns {number} 计算后的线宽*/
export function calculateLineWidth(speed, minWidth = 2, maxWidth = 6) {if (typeof speed !== 'number' || speed < 0) {return minWidth;}// 速度越快,线条越细const speedFactor = Math.min(speed / 10, 1); // 将速度标准化到0-1范围const width = maxWidth - (maxWidth - minWidth) * speedFactor;return clamp(width, minWidth, maxWidth);
}/*** 获取弧度数据* @param {number} x1 - 起始点x坐标* @param {number} y1 - 起始点y坐标* @param {number} x2 - 结束点x坐标* @param {number} y2 - 结束点y坐标* @returns {Object} 弧度相关数据 {val, pos}*/
export function getRadianData(x1, y1, x2, y2) {if (typeof x1 !== 'number' || typeof y1 !== 'number' ||typeof x2 !== 'number' || typeof y2 !== 'number') {return { val: 0, pos: 1 };}const dis_x = x2 - x1;const dis_y = y2 - y1;if (dis_x === 0) {return { val: 0, pos: -1 };}if (dis_y === 0) {return { val: 0, pos: 1 };}const val = Math.abs(Math.atan(dis_y / dis_x));if ((x2 > x1 && y2 < y1) || (x2 < x1 && y2 > y1)) {return { val: val, pos: 1 };}return { val: val, pos: -1 };
}/*** 根据弧度获取点坐标* @param {Object} center - 中心点* @param {number} radius - 半径* @param {number} angle - 角度(弧度)* @returns {Object} 点坐标 {x, y}*/
export function getRadianPoint(center, radius, angle) {if (!center || typeof center.x !== 'number' || typeof center.y !== 'number' ||typeof radius !== 'number' || typeof angle !== 'number') {return { x: 0, y: 0 };}return {x: center.x + radius * Math.cos(angle),y: center.y + radius * Math.sin(angle)};
}/*** 根据弧度数据获取垂直于线段的两个点* @param {Object} radianData - 弧度数据对象,包含val和pos属性* @param {number} x - 中心点x坐标* @param {number} y - 中心点y坐标* @param {number} halfLineWidth - 线宽的一半* @returns {Array} 包含两个点的数组 [{x, y}, {x, y}]*/
export function getRadianPoints(radianData, x, y, halfLineWidth) {if (!radianData || typeof radianData.val !== 'number' || typeof radianData.pos !== 'number' ||typeof x !== 'number' || typeof y !== 'number' ||typeof halfLineWidth !== 'number') {return [{ x: 0, y: 0 }, { x: 0, y: 0 }];}if (radianData.val === 0) {if (radianData.pos === 1) {return [{ x: x, y: y + halfLineWidth },{ x: x, y: y - halfLineWidth }];}return [{ y: y, x: x + halfLineWidth },{ y: y, x: x - halfLineWidth }];}const dis_x = Math.sin(radianData.val) * halfLineWidth;const dis_y = Math.cos(radianData.val) * halfLineWidth;if (radianData.pos === 1) {return [{ x: x + dis_x, y: y + dis_y },{ x: x - dis_x, y: y - dis_y }];}return [{ x: x + dis_x, y: y - dis_y },{ x: x - dis_x, y: y + dis_y }];
}/*** 数值精度处理* @param {number} value - 需要处理的数值* @param {number} precision - 精度位数* @returns {number} 处理后的数值*/
export function toFixed(value, precision = 1) {if (typeof value !== 'number' || typeof precision !== 'number') {return 0;}return parseFloat(value.toFixed(Math.max(0, precision)));
}/*** 限制数值在指定范围内* @param {number} value - 输入值* @param {number} min - 最小值* @param {number} max - 最大值* @returns {number} 限制后的值*/
export function clamp(value, min, max) {if (typeof value !== 'number' || typeof min !== 'number' || typeof max !== 'number') {return value || 0;}return Math.min(Math.max(value, min), max);
}/*** 线性插值* @param {number} start - 起始值* @param {number} end - 结束值* @param {number} factor - 插值因子(0-1)* @returns {number} 插值结果*/
export function lerp(start, end, factor) {if (typeof start !== 'number' || typeof end !== 'number' || typeof factor !== 'number') {return start || 0;}return start + (end - start) * clamp(factor, 0, 1);
}/*** 角度转弧度* @param {number} degrees - 角度值* @returns {number} 弧度值*/
export function degreesToRadians(degrees) {if (typeof degrees !== 'number') {return 0;}return degrees * (Math.PI / 180);
}/*** 弧度转角度* @param {number} radians - 弧度值* @returns {number} 角度值*/
export function radiansToDegrees(radians) {if (typeof radians !== 'number') {return 0;}return radians * (180 / Math.PI);
}/*** 默认导出所有数学工具函数*/
export default {calculateDistance,calculateSpeed,calculateLineWidth,getRadianData,getRadianPoints,toFixed,clamp,lerp
};

10. 画布工具

/*** 获取画布尺寸* @param {string} canvasId - 画布ID* @param {Object} component - 组件实例* @returns {Promise<Object>} 画布尺寸信息*/
export function getCanvasSize(canvasId, component) {return new Promise((resolve, reject) => {if (!canvasId || !component) {reject(new Error('canvasId和component参数不能为空'));return;}try {const query = uni.createSelectorQuery().in(component);query.select(`#${canvasId}`).boundingClientRect(data => {if (data) {resolve({width: data.width,height: data.height,left: data.left,top: data.top});} else {reject(new Error('无法获取画布尺寸信息'));}}).exec();} catch (error) {console.error('[canvasUtils] getCanvasSize error:', error);reject(error);}});
}/*** 检查画布是否就绪* @param {Object} ctx - 画布上下文* @returns {boolean} 是否就绪*/
export function isCanvasReady(ctx) {return ctx && typeof ctx === 'object' && typeof ctx.draw === 'function';
}/*** 清空画布指定区域* @param {Object} ctx - 画布上下文* @param {number} x - 起始x坐标* @param {number} y - 起始y坐标* @param {number} width - 宽度* @param {number} height - 高度*/
export function clearCanvasArea(ctx, x = 0, y = 0, width, height) {if (!isCanvasReady(ctx)) {console.warn('[canvasUtils] clearCanvasArea: 画布上下文无效');return;}try {ctx.clearRect(x, y, width, height);ctx.draw();} catch (error) {console.error('[canvasUtils] clearCanvasArea error:', error);}
}/*** 设置画布样式* @param {Object} ctx - 画布上下文* @param {Object} style - 样式配置*/
export function setCanvasStyle(ctx, style = {}) {if (!isCanvasReady(ctx)) {console.warn('[canvasUtils] setCanvasStyle: 画布上下文无效');return;}try {const {strokeStyle = '#000000',fillStyle = '#000000',lineWidth = 2,lineCap = 'round',lineJoin = 'round',globalAlpha = 1} = style;ctx.strokeStyle = strokeStyle;ctx.fillStyle = fillStyle;ctx.lineWidth = lineWidth;ctx.lineCap = lineCap;ctx.lineJoin = lineJoin;ctx.globalAlpha = globalAlpha;} catch (error) {console.error('[canvasUtils] setCanvasStyle error:', error);}
}/*** 坐标转换(屏幕坐标转画布坐标)* @param {Object} screenPoint - 屏幕坐标点* @param {Object} canvasInfo - 画布信息* @returns {Object} 画布坐标点*/
export function screenToCanvas(screenPoint, canvasInfo) {if (!screenPoint || !canvasInfo || typeof screenPoint.x !== 'number' || typeof screenPoint.y !== 'number') {return { x: 0, y: 0 };}const { left = 0, top = 0 } = canvasInfo;return {x: screenPoint.x - left,y: screenPoint.y - top};
}/*** 获取系统信息并计算默认画布尺寸* @param {number} widthRatio - 宽度比例* @param {number} heightRatio - 高度比例* @returns {Object} 默认画布尺寸 {width, height}*/
export function getDefaultCanvasSize(widthRatio = 0.85, heightRatio = 0.95) {try {const systemInfo = uni.getSystemInfoSync();const { windowWidth, windowHeight } = systemInfo;return {width: Math.min(windowWidth * widthRatio, 400),height: Math.min(windowHeight * heightRatio, 300)};} catch (error) {console.error('[canvasUtils] getDefaultCanvasSize error:', error);return {width: 300,height: 200};}
}/*** 创建画布上下文* @param {string} canvasId - 画布ID* @param {Object} component - 组件实例* @returns {Object} 画布上下文*/
export function createCanvasContext(canvasId, component) {if (!canvasId) {console.error('[canvasUtils] createCanvasContext: canvasId不能为空');return null;}try {return uni.createCanvasContext(canvasId, component);} catch (error) {console.error('[canvasUtils] createCanvasContext error:', error);return null;}
}/*** 画布转临时文件* @param {string} canvasId - 画布ID* @param {Object} options - 转换选项* @param {Object} component - 组件实例* @returns {Promise} 临时文件路径*/
export function canvasToTempFile(canvasId, options, component) {return new Promise((resolve, reject) => {if (!canvasId) {reject(new Error('canvasId不能为空'));return;}const defaultOptions = {fileType: 'png',quality: 1,destWidth: undefined,destHeight: undefined};const finalOptions = { ...defaultOptions, ...options };try {uni.canvasToTempFilePath({canvasId,...finalOptions,success: (res) => {if (res.tempFilePath) {resolve(res.tempFilePath);} else {reject(new Error('生成临时文件失败'));}},fail: (error) => {console.error('[canvasUtils] canvasToTempFile error:', error);reject(error);}}, component);} catch (error) {console.error('[canvasUtils] canvasToTempFile error:', error);reject(error);}});
}/*** 检查画布是否为空* @param {Object} ctx - 画布上下文* @param {number} width - 画布宽度* @param {number} height - 画布高度* @returns {boolean} 画布是否为空*/
export function isCanvasEmpty(ctx, width, height) {if (!isCanvasReady(ctx) || typeof width !== 'number' || typeof height !== 'number') {return true;}try {// 获取画布图像数据const imageData = ctx.getImageData(0, 0, width, height);const data = imageData.data;// 检查是否所有像素都是透明的for (let i = 3; i < data.length; i += 4) {if (data[i] !== 0) { // alpha通道不为0表示有内容return false;}}return true;} catch (error) {// 如果无法获取图像数据,假设画布不为空console.warn('[canvasUtils] isCanvasEmpty: 无法检测画布内容,假设不为空');return false;}
}/*** 默认导出所有画布工具函数*/
export default {getCanvasSize,isCanvasReady,clearCanvasArea,setCanvasStyle,screenToCanvas,getDefaultCanvasSize,createCanvasContext,canvasToTempFile,isCanvasEmpty
};

性能优化策略

1. 点数据优化

只保留最近3个点进行计算,减少内存占用:

this.points.push(point);
this.points = this.points.slice(-3); // 关键优化

2. 历史记录限制

限制历史记录数量,防止内存溢出:

if (this.drawingHistory.length > this.maxHistoryLength) {this.drawingHistory = this.drawingHistory.slice(-this.maxHistoryLength);
}

3. 绘制优化

使用requestAnimationFrame优化绘制性能:

onDraw() {if (this.points.length < 2) return;if (typeof this.requestAnimationFrame === 'function') {this.requestAnimationFrame(() => {this.drawSmoothLine(prePoint, point);});} else {this.drawSmoothLine(prePoint, point);}
}

4. 内存管理

及时清理不再使用的数据和资源:

clear() {clearCanvasArea(this.ctx, 0, 0, this.canvasWidth, this.canvasHeight);this.historyList.length = 0;this.drawingHistory = [];this.currentStroke = [];this.points = [];
}

组件完整代码

<template><view><view class="wrapper"><view class="handBtn"><button @click="clear" type="success" class="delBtn">清空</button><button @click="saveCanvasAsImg" type="info" class="saveBtn">保存</button><button @click="previewCanvasImg" type="warning" class="previewBtn">预览</button><button @click="undo" type="error" class="undoBtn">撤销</button><button @click="complete" type="primary" class="subCanvas">完成</button></view><view class="handCenter"><canvas canvas-id="handWriting" id="handWriting"class="handWriting" :disable-scroll="true" @touchstart="uploadScaleStart"@touchmove="uploadScaleMove" @touchend="uploadScaleEnd"></canvas></view><view class="handRight"><view class="handTitle">请签名</view></view></view></view>
</template><script>
// 导入工具类
import { UploadManager } from '../utils/uploadManager.js'
import { showToast, showLoading, hideLoading, formatErrorMessage, showFinalError, showModal } from '../utils/messageUtils.js'
import { deepMerge, validateConfig, validateUrl, validateFileType, validateQuality, validateTimeout, validateFileName, validateHeaders, validateFormData } from '../utils/validationUtils.js'
import { calculateDistance, calculateSpeed, calculateLineWidth, getRadianData, getRadianPoints, toFixed, clamp, lerp } from '../utils/mathUtils.js'
import { getCanvasSize, isCanvasReady, clearCanvasArea, setCanvasStyle, screenToCanvas, getDefaultCanvasSize, createCanvasContext, canvasToTempFile, isCanvasEmpty } from '../utils/canvasUtils.js'export default {data() {return {ctx: '',canvasWidth: 0,canvasHeight: 0,lineColor: '#1A1A1A',points: [],historyList: [],drawingHistory: [],currentStroke: [],canAddHistory: true,isCanvasReady: false,mergedConfig: {},uploadManager: null,defaultConfig: {uploadUrl: '',headers: {'Content-Type': 'multipart/form-data'},formData: {},fileName: 'signature',fileType: 'png',quality: 1,timeout: 30000,retryCount: 2,retryDelay: 1000,showErrorToast: true,errorToastDuration: 3000,enableAutoRetry: true,retryOnNetworkError: true,retryOnServerError: false},getImagePath: () => {return new Promise((resolve) => {uni.canvasToTempFilePath({canvasId: 'handWriting',fileType: 'png',quality: 1, //图片质量success: res => resolve(res.tempFilePath),})})},toDataURL: void 0,requestAnimationFrame: void 0,};},props: {minSpeed: {type: Number,default: 1.5},minWidth: {type: Number,default: 3,},maxWidth: {type: Number,default: 10},openSmooth: {type: Boolean,default: true},maxHistoryLength: {type: Number,default: 20},maxWidthDiffRate: {type: Number,default: 20},bgColor: {type: String,default: 'transparent'},uploadUrl: {type: String,default: '',validator: function (value) {if (!value) return true;try {new URL(value);return true;} catch {return false;}}},uploadConfig: {type: Object,default: () => ({headers: {'Content-Type': 'multipart/form-data'},formData: {timestamp: Date.now()},fileName: 'signature',fileType: 'png',quality: 1,timeout: 30000}),validator: function (value) {return value && typeof value === 'object';}},},methods: {// 配置管理方法// 合并配置(用户配置 + 默认配置)mergeConfig() {try {// 深拷贝默认配置const baseConfig = JSON.parse(JSON.stringify(this.defaultConfig));// 处理用户传入的配置const userConfig = {uploadUrl: this.uploadUrl,...this.uploadConfig};// 使用工具类合并配置this.mergedConfig = deepMerge(baseConfig, userConfig);// 使用工具类验证配置validateConfig(this.mergedConfig);} catch (error) {this.mergedConfig = JSON.parse(JSON.stringify(this.defaultConfig));}},// 新增:获取当前有效配置getCurrentConfig() {if (!this.mergedConfig || Object.keys(this.mergedConfig).length === 0) {this.mergeConfig();}return this.mergedConfig;},// 检查canvas上下文是否可用checkCanvasContext() {if (!this.ctx) {this.initCanvas();return false;}return true;},initCanvas() {try {this.ctx = uni.createCanvasContext("handWriting", this);if (this.ctx) {this.$nextTick(() => {uni.createSelectorQuery().select('.handCenter').boundingClientRect(rect => {if (rect && rect.width > 0 && rect.height > 0) {this.canvasWidth = rect.width;this.canvasHeight = rect.height;} else {const systemInfo = uni.getSystemInfoSync();this.canvasWidth = Math.floor(systemInfo.windowWidth * 0.85);this.canvasHeight = Math.floor(systemInfo.windowHeight * 0.95);}try {if (this.bgColor && this.bgColor !== 'transparent') {this.drawBgColor();}this.isCanvasReady = true;} catch (error) {this.isCanvasReady = false;}}).exec();});} else {setTimeout(() => this.initCanvas(), 500);}} catch (error) {setTimeout(() => this.initCanvas(), 500);}},uploadScaleStart(e) {if (!this.isCanvasReady) {this.initCanvas();return;}if (!this.checkCanvasContext()) {return;}this.canAddHistory = true;try {this.ctx.setStrokeStyle(this.lineColor);this.ctx.setLineCap("round");} catch (error) {console.error('设置画笔样式失败:', error);}},uploadScaleMove(e) {if (!this.isCanvasReady) {return;}let temX = e.changedTouches[0].xlet temY = e.changedTouches[0].ythis.initPoint(temX, temY)this.onDraw()},uploadScaleEnd() {this.canAddHistory = true;if (this.points.length >= 2) {if (this.currentStroke.length > 0) {this.addHistory();}}this.points = [];},initPoint(x, y) {var point = {x: x,y: y,t: Date.now()};var prePoint = this.points.slice(-1)[0];if (prePoint && (prePoint.t === point.t || prePoint.x === x && prePoint.y === y)) {return;}if (prePoint && this.openSmooth) {var prePoint2 = this.points.slice(-2, -1)[0];// 使用工具类计算距离和速度point.distance = calculateDistance(point.x, point.y, prePoint.x, prePoint.y);point.speed = calculateSpeed(point.distance, point.t - prePoint.t);point.lineWidth = this.getLineWidth(point.speed);if (prePoint2 && prePoint2.lineWidth && prePoint.lineWidth) {var rate = (point.lineWidth - prePoint.lineWidth) / prePoint.lineWidth;var maxRate = this.maxWidthDiffRate / 100;maxRate = maxRate > 1 ? 1 : maxRate < 0.01 ? 0.01 : maxRate;if (Math.abs(rate) > maxRate) {var per = rate > 0 ? maxRate : -maxRate;point.lineWidth = prePoint.lineWidth * (1 + per);}}}this.points.push(point);this.points = this.points.slice(-3);this.currentStroke.push({x: point.x,y: point.y,t: point.t,lineWidth: point.lineWidth || this.minWidth,speed: point.speed || 0,distance: point.distance || 0});},getLineWidth(speed) {// 使用工具类计算线宽return calculateLineWidth(speed, this.minSpeed, this.minWidth, this.maxWidth);},onDraw() {if (this.points.length < 2) return;var point = this.points.slice(-1)[0];var prePoint = this.points.slice(-2, -1)[0];let that = thisvar onDraw = function onDraw() {if (that.openSmooth) {that.drawSmoothLine(prePoint, point);} else {that.drawNoSmoothLine(prePoint, point);}};if (typeof this.requestAnimationFrame === 'function') {this.requestAnimationFrame(function () {return onDraw();});} else {onDraw();}},//添加历史记录addHistory() {if (!this.maxHistoryLength || !this.canAddHistory) return;this.canAddHistory = false;// 统一使用笔画数据保存历史记录if (this.currentStroke.length > 0) {// 创建笔画对象,包含所有绘制信息const strokeData = {points: JSON.parse(JSON.stringify(this.currentStroke)), // 深拷贝点数据color: this.lineColor, // 当前笔画的颜色baseLineWidth: this.maxWidth, // 基础线条宽度minWidth: this.minWidth, // 最小线条宽度maxWidth: this.maxWidth, // 最大线条宽度openSmooth: this.openSmooth, // 是否开启平滑minSpeed: this.minSpeed, // 最小速度maxWidthDiffRate: this.maxWidthDiffRate, // 最大差异率timestamp: Date.now()};// 添加到绘制历史this.drawingHistory.push(strokeData);// 限制历史记录长度if (this.drawingHistory.length > this.maxHistoryLength) {this.drawingHistory = this.drawingHistory.slice(-this.maxHistoryLength);}// 同步更新historyList长度用于isEmpty检查this.historyList.length = this.drawingHistory.length;// 清空当前笔画this.currentStroke = [];console.log('Stroke added to history:', {strokeCount: this.drawingHistory.length,pointsInStroke: strokeData.points.length,color: strokeData.color});} else {console.log('No current stroke to add to history');}// 重置添加历史标志setTimeout(() => {this.canAddHistory = true;}, 100);},//画平滑线drawSmoothLine(prePoint, point) {var dis_x = point.x - prePoint.x;var dis_y = point.y - prePoint.y;if (Math.abs(dis_x) + Math.abs(dis_y) <= 2) {point.lastX1 = point.lastX2 = prePoint.x + dis_x * 0.5;point.lastY1 = point.lastY2 = prePoint.y + dis_y * 0.5;} else {point.lastX1 = prePoint.x + dis_x * 0.3;point.lastY1 = prePoint.y + dis_y * 0.3;point.lastX2 = prePoint.x + dis_x * 0.7;point.lastY2 = prePoint.y + dis_y * 0.7;}point.perLineWidth = (prePoint.lineWidth + point.lineWidth) / 2;if (typeof prePoint.lastX1 === 'number') {this.drawCurveLine(prePoint.lastX2, prePoint.lastY2, prePoint.x, prePoint.y, point.lastX1, point.lastY1, point.perLineWidth);if (prePoint.isFirstPoint) return;if (prePoint.lastX1 === prePoint.lastX2 && prePoint.lastY1 === prePoint.lastY2) return;var data = this.getRadianData(prePoint.lastX1, prePoint.lastY1, prePoint.lastX2, prePoint.lastY2);var points1 = this.getRadianPoints(data, prePoint.lastX1, prePoint.lastY1, prePoint.perLineWidth / 2);var points2 = this.getRadianPoints(data, prePoint.lastX2, prePoint.lastY2, point.perLineWidth / 2);this.drawTrapezoid(points1[0], points2[0], points2[1], points1[1]);} else {point.isFirstPoint = true;}},//画不平滑线drawNoSmoothLine(prePoint, point) {point.lastX = prePoint.x + (point.x - prePoint.x) * 0.5;point.lastY = prePoint.y + (point.y - prePoint.y) * 0.5;if (typeof prePoint.lastX === 'number') {this.drawCurveLine(prePoint.lastX, prePoint.lastY, prePoint.x, prePoint.y, point.lastX, point.lastY,this.maxWidth);}},//画线drawCurveLine(x1, y1, x2, y2, x3, y3, lineWidth, skipDraw = false) {if (!this.checkCanvasContext()) return;lineWidth = Number(lineWidth.toFixed(1));try {// 统一使用uni-app的canvas APIif (this.ctx.setLineWidth) {this.ctx.setLineWidth(lineWidth);}this.ctx.lineWidth = lineWidth;// 确保线条样式设置正确,防止虚线效果this.ctx.setLineCap('round');this.ctx.setLineJoin('round');this.ctx.setStrokeStyle(this.lineColor);this.ctx.beginPath();this.ctx.moveTo(Number(x1.toFixed(1)), Number(y1.toFixed(1)));this.ctx.quadraticCurveTo(Number(x2.toFixed(1)), Number(y2.toFixed(1)), Number(x3.toFixed(1)), Number(y3.toFixed(1)));this.ctx.stroke();// 统一调用draw方法,但重绘时跳过if (this.ctx.draw && !skipDraw) {this.ctx.draw(true);}} catch (error) {console.error('Error in drawCurveLine:', error);}},//画梯形drawTrapezoid(point1, point2, point3, point4) {if (!this.checkCanvasContext()) return;this.ctx.beginPath();this.ctx.moveTo(Number(point1.x.toFixed(1)), Number(point1.y.toFixed(1)));this.ctx.lineTo(Number(point2.x.toFixed(1)), Number(point2.y.toFixed(1)));this.ctx.lineTo(Number(point3.x.toFixed(1)), Number(point3.y.toFixed(1)));this.ctx.lineTo(Number(point4.x.toFixed(1)), Number(point4.y.toFixed(1)));// 统一使用uni-app的canvas APIthis.ctx.setFillStyle(this.lineColor);this.ctx.fill();this.ctx.draw(true);},//画梯形(用于重绘,跳过draw调用)drawTrapezoidForRedraw(point1, point2, point3, point4) {if (!this.checkCanvasContext()) return;this.ctx.beginPath();this.ctx.moveTo(Number(point1.x.toFixed(1)), Number(point1.y.toFixed(1)));this.ctx.lineTo(Number(point2.x.toFixed(1)), Number(point2.y.toFixed(1)));this.ctx.lineTo(Number(point3.x.toFixed(1)), Number(point3.y.toFixed(1)));this.ctx.lineTo(Number(point4.x.toFixed(1)), Number(point4.y.toFixed(1)));// 统一使用uni-app的canvas APIthis.ctx.setFillStyle(this.lineColor);this.ctx.fill();// 重绘时跳过单独的draw调用,统一在redrawAllStrokes中调用},//获取弧度getRadianData(x1, y1, x2, y2) {// 使用工具类获取弧度数据return getRadianData(x1, y1, x2, y2);},//获取弧度点getRadianPoints(radianData, x, y, halfLineWidth) {// 使用工具类获取弧度点return getRadianPoints(radianData, x, y, halfLineWidth);},/*** 背景色*/drawBgColor() {const config = this.getCurrentConfig();if (!this.ctx || !config.bgColor) return;// 直接使用 canvas API 绘制背景色this.ctx.setFillStyle(config.bgColor);this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);this.ctx.draw(true); // 保留之前的绘制内容},//图片绘制// drawByImage(url) {// 	if (!this.ctx || !url) return;// 	// 直接使用 canvas API 绘制图片// 	this.ctx.drawImage(url, 0, 0, this.canvasWidth, this.canvasHeight);// 	this.ctx.draw(true); // 保留之前的绘制内容// },/*** 清空画布*/clear() {if (!this.isCanvasReady) {showToast('画布未就绪,请稍后再试', 'none', 2000);return;}if (!this.checkCanvasContext()) return;try {// 使用工具类清空画布clearCanvasArea(this.ctx, 0, 0, this.canvasWidth, this.canvasHeight);// 重新绘制背景色(如果不是透明的话)this.drawBgColor();// 清空所有历史记录和当前绘制点this.historyList.length = 0;this.drawingHistory = []; // 清空绘制历史this.currentStroke = []; // 清空当前笔画this.points = [];showToast('画布已清空', 'success', 1500);} catch (error) {console.error('Error clearing canvas:', error);showToast('清空失败,请重试', 'none', 2000);}},// 清空画布(不清除历史记录)clearCanvas() {if (!this.ctx) {console.error('Canvas context not available for clearing');return;}try {// 使用工具类清空画布clearCanvasArea(this.ctx, 0, 0, this.canvasWidth, this.canvasHeight);console.log('Canvas cleared successfully with transparent background');} catch (error) {console.error('Error clearing canvas:', error);}},// 重新绘制所有历史笔画redrawAllStrokes() {if (!this.ctx || this.drawingHistory.length === 0) {console.log('No context or no history to redraw');return;}console.log('Redrawing', this.drawingHistory.length, 'strokes');try {// 清空画布this.clearCanvas();// 如果需要背景色则绘制(透明背景时跳过)if (this.bgColor && this.bgColor !== 'transparent' && this.bgColor !== 'rgba(0,0,0,0)') {this.drawBgColor();}// 遍历所有历史笔画for (let i = 0; i < this.drawingHistory.length; i++) {const stroke = this.drawingHistory[i];this.redrawSingleStroke(stroke, i);}// 统一使用uni-app的canvas API调用draw()来应用绘制this.ctx.draw();console.log('All strokes redrawn successfully');} catch (error) {console.error('Error redrawing strokes:', error);}},// 重新绘制单个笔画redrawSingleStroke(stroke, strokeIndex) {if (!stroke || !stroke.points || stroke.points.length < 2) {console.log('Invalid stroke data for redraw:', strokeIndex);return;}try {// 设置笔画颜色this.ctx.setStrokeStyle(stroke.color || this.lineColor);this.ctx.setLineCap('round');this.ctx.setLineJoin('round');if (stroke.openSmooth && stroke.points.length > 2) {// 平滑绘制 - 完全模拟原始绘制过程this.redrawSmoothStrokeAccurate(stroke);} else {// 直线绘制 - 使用笔画的基础线条宽度this.ctx.setLineWidth(stroke.baseLineWidth || stroke.lineWidth || this.maxWidth);this.ctx.beginPath();this.redrawStraightStroke(stroke.points);this.ctx.stroke();}console.log('Stroke', strokeIndex, 'redrawn with', stroke.points.length, 'points');} catch (error) {console.error('Error redrawing single stroke:', strokeIndex, error);}},// 重新绘制平滑笔画redrawSmoothStroke(points) {if (points.length < 2) return;this.ctx.moveTo(points[0].x, points[0].y);for (let i = 1; i < points.length - 1; i++) {const currentPoint = points[i];const nextPoint = points[i + 1];const controlX = (currentPoint.x + nextPoint.x) / 2;const controlY = (currentPoint.y + nextPoint.y) / 2;this.ctx.quadraticCurveTo(currentPoint.x, currentPoint.y, controlX, controlY);}// 绘制最后一个点if (points.length > 1) {const lastPoint = points[points.length - 1];this.ctx.lineTo(lastPoint.x, lastPoint.y);}},// 重新绘制带宽度的平滑笔画redrawSmoothStrokeWithWidth(points) {if (points.length < 2) return;// 遍历所有点,使用每个点保存的线条宽度进行绘制for (let i = 0; i < points.length - 1; i++) {const currentPoint = points[i];const nextPoint = points[i + 1];// 使用当前点的线条宽度,如果没有则使用默认值const lineWidth = currentPoint.lineWidth || this.maxWidth;this.ctx.setLineWidth(lineWidth);this.ctx.beginPath();this.ctx.moveTo(currentPoint.x, currentPoint.y);if (i < points.length - 2) {// 不是最后一段,使用平滑曲线const controlPoint = points[i + 1];const endPoint = points[i + 2];const controlX = (controlPoint.x + endPoint.x) / 2;const controlY = (controlPoint.y + endPoint.y) / 2;this.ctx.quadraticCurveTo(controlPoint.x, controlPoint.y, controlX, controlY);} else {// 最后一段,直接连线this.ctx.lineTo(nextPoint.x, nextPoint.y);}this.ctx.stroke();}},// 精确重绘平滑笔画 - 完全模拟原始绘制过程redrawSmoothStrokeAccurate(stroke) {if (!stroke.points || stroke.points.length < 2) return;const points = stroke.points;// 保存当前线条颜色,确保重绘时使用正确的颜色const originalColor = this.lineColor;this.lineColor = stroke.color || originalColor;// 模拟原始的绘制过程,逐段绘制for (let i = 1; i < points.length; i++) {const prePoint = points[i - 1];const point = points[i];// 重建点的完整信息(模拟initPoint的处理)if (stroke.openSmooth && i < points.length - 1) {// 计算控制点信息(模拟drawSmoothLine的逻辑)const dis_x = point.x - prePoint.x;const dis_y = point.y - prePoint.y;if (Math.abs(dis_x) + Math.abs(dis_y) <= 2) {point.lastX1 = point.lastX2 = prePoint.x + dis_x * 0.5;point.lastY1 = point.lastY2 = prePoint.y + dis_y * 0.5;} else {point.lastX1 = prePoint.x + dis_x * 0.3;point.lastY1 = prePoint.y + dis_y * 0.3;point.lastX2 = prePoint.x + dis_x * 0.7;point.lastY2 = prePoint.y + dis_y * 0.7;}// 计算平均线条宽度point.perLineWidth = ((prePoint.lineWidth || stroke.minWidth || this.minWidth) +(point.lineWidth || stroke.minWidth || this.minWidth)) / 2;// 使用原始的drawCurveLine逻辑,跳过单独的draw调用if (typeof prePoint.lastX1 === 'number') {this.drawCurveLine(prePoint.lastX2, prePoint.lastY2, prePoint.x, prePoint.y,point.lastX1, point.lastY1, point.perLineWidth, true);// 添加梯形绘制逻辑,确保线条连续性和粗细一致if (!prePoint.isFirstPoint) {if (!(prePoint.lastX1 === prePoint.lastX2 && prePoint.lastY1 === prePoint.lastY2)) {var data = this.getRadianData(prePoint.lastX1, prePoint.lastY1, prePoint.lastX2, prePoint.lastY2);var points1 = this.getRadianPoints(data, prePoint.lastX1, prePoint.lastY1, prePoint.perLineWidth / 2);var points2 = this.getRadianPoints(data, prePoint.lastX2, prePoint.lastY2, point.perLineWidth / 2);// 绘制梯形填充,但跳过单独的draw调用this.drawTrapezoidForRedraw(points1[0], points2[0], points2[1], points1[1]);}} else {// 标记第一个点point.isFirstPoint = true;}}} else {// 非平滑模式,直接绘制线段const lineWidth = point.lineWidth || stroke.baseLineWidth || this.maxWidth;this.drawCurveLine(prePoint.x, prePoint.y, prePoint.x, prePoint.y,point.x, point.y, lineWidth, true);}}// 恢复原始线条颜色this.lineColor = originalColor;},// 重新绘制直线笔画redrawStraightStroke(points) {if (points.length < 2) return;this.ctx.moveTo(points[0].x, points[0].y);for (let i = 1; i < points.length; i++) {this.ctx.lineTo(points[i].x, points[i].y);}},//撤消undo() {if (!this.isCanvasReady) {showToast('画布未就绪,请稍后再试', 'none', 2000);return;}// 检查是否有可撤销的操作if (this.isEmpty()) {showToast('没有可撤销的操作', 'none', 1500);return;}if (!this.checkCanvasContext()) return;try {// 统一使用uni-app的canvas API,实现真正的逐步撤销if (this.drawingHistory.length > 0) {// 移除最后一个绘制操作const removedStroke = this.drawingHistory.pop();console.log('Removed stroke from history:', {remainingStrokes: this.drawingHistory.length,removedPoints: removedStroke.points.length});// 同步更新historyList长度this.historyList.length = this.drawingHistory.length;// 清空画布this.clearCanvas();// 重新绘制剩余的所有操作this.redrawAllStrokes();showToast('已撤销上一步', 'success', 1500);console.log('Undo completed, remaining strokes:', this.drawingHistory.length);} else {showToast('没有可撤销的操作', 'none', 1500);}} catch (error) {console.error('Error in undo:', error);showToast('撤销失败,请重试', 'none', 2000);}},//是否为空isEmpty() {// 统一使用uni-app的canvas API检查空画布逻辑const hasDrawing = this.drawingHistory.length > 0 || this.currentStroke.length > 0;console.log('[签名组件] Canvas isEmpty 详细检查:', {isEmpty: !hasDrawing,historyListLength: this.historyList.length,drawingHistoryLength: this.drawingHistory.length,currentStrokeLength: this.currentStroke.length,hasDrawing: hasDrawing});return !hasDrawing;},/*** @param {Object} str* @param {Object} color* 选择颜色*/selectColorEvent(str, color) {this.selectColor = str;this.lineColor = color;if (this.checkCanvasContext()) {try {this.ctx.setStrokeStyle(this.lineColor);uni.showToast({title: `已选择${str === 'black' ? '黑色' : '红色'}`,icon: 'success',duration: 1000});} catch (error) {console.error('Error setting color:', error);}}},//保存到相册saveCanvasAsImg() {if (!this.isCanvasReady) {showToast('画布未就绪,请稍后再试', 'none', 2000);return;}if (this.isEmpty()) {showToast('没有任何绘制内容哦', 'none', 2000);return;}if (!this.checkCanvasContext()) return;// 统一使用uni-app方法保存this.performSave();},// 执行保存操作(统一入口)performSave() {// 基础验证if (!this.isCanvasReady) {showToast('画布未就绪,请稍后再试', 'none', 2000);return;}if (this.isEmpty()) {showToast('没有任何绘制内容哦', 'none', 2000);return;}if (!this.checkCanvasContext()) return;showLoading('正在保存...');// 统一使用uni.canvasToTempFilePath APIconst canvasOptions = {canvasId: 'handWriting',fileType: 'png',quality: 1,success: (res) => {hideLoading();// #ifdef H5// H5环境:创建下载链接const link = document.createElement('a');link.download = `signature_${Date.now()}.png`;link.href = res.tempFilePath;document.body.appendChild(link);link.click();document.body.removeChild(link);showToast('签名已下载', 'success', 2000);// #endif// #ifndef H5// 小程序环境:保存到相册uni.saveImageToPhotosAlbum({filePath: res.tempFilePath,success: (saveRes) => {showToast('已成功保存到相册', 'success', 2000);},fail: (saveError) => {if (saveError.errMsg.includes('auth')) {showModal('保存失败', '需要相册权限,请在设置中开启', '去设置').then((modalRes) => {if (modalRes.confirm) {uni.openSetting();}});} else {showToast('保存失败,请重试', 'none', 2000);}}});// #endif},fail: (error) => {hideLoading();console.error('[保存失败]:', error);showToast('生成签名图片失败', 'none', 2000);}};uni.canvasToTempFilePath(canvasOptions, this);},//预览previewCanvasImg() {if (!this.isCanvasReady) {showToast('画布未就绪,请稍后再试', 'none', 2000);return;}if (this.isEmpty()) {showToast('没有任何绘制内容哦', 'none', 2000);return;}if (!this.checkCanvasContext()) return;showLoading('正在生成预览...');const canvasOptions = {canvasId: 'handWriting',fileType: 'png', // 改为png格式,兼容性更好quality: 1,success: (res) => {console.log(res)hideLoading();uni.previewImage({urls: [res.tempFilePath],current: 0,success: (res) => {console.log(res, 'res')},fail: (error) => {showToast('预览失败,请重试', 'none', 2000);}});},fail: (error) => {hideLoading();console.error('Canvas to temp file failed:', error);showToast('生成预览图片失败', 'none', 2000);}};// 统一使用uni.canvasToTempFilePathuni.canvasToTempFilePath(canvasOptions, this);},// 完成签名complete() {if (!this.isCanvasReady) {showToast('画布未就绪,请稍后再试', 'none', 2000);return;}if (this.isEmpty()) {showToast('请先进行签名', 'none', 2000);return;}if (!this.checkCanvasContext()) return;showLoading('正在生成签名...');const canvasOptions = {canvasId: 'handWriting',fileType: 'png',quality: 1,success: (res) => {// 生成签名图片成功后,上传到服务器this.uploadSignatureImage(res.tempFilePath);},fail: (error) => {hideLoading();console.error('Canvas to temp file failed:', error);showToast('生成签名失败,请重试', 'none', 2000);}};// 统一使用uni.canvasToTempFilePathuni.canvasToTempFilePath(canvasOptions, this);},// 上传签名图片到服务器uploadSignatureImage(filePath) {const config = this.getCurrentConfig();// 使用UploadManager处理上传this.uploadManager.performUpload(filePath, config).then(result => {hideLoading();this.$emit('complete', {filePath: result.fileUrl,success: true,response: result.response,retryCount: result.retryCount,uploadTime: result.uploadTime});showToast('签名上传成功', 'success', 2000);}).catch(error => {hideLoading();const errorMsg = formatErrorMessage(error.message || error.toString());showFinalError(errorMsg, this.getCurrentConfig());this.$emit('complete', {success: false,error: error.message,originalError: error,retryCount: error.retryCount || 0});});},},mounted() {console.log('[签名组件] mounted 开始执行');// 合并配置this.mergeConfig();// 初始化上传管理器this.uploadManager = new UploadManager();this.initCanvas();},
};
</script><style lang="scss" scoped>
page {background: #fbfbfb;height: auto;overflow: hidden;
}.wrapper {display: flex;height: 100%;align-content: center;flex-direction: row;justify-content: center;font-size: 28rpx;z-index: 999999;border: 2rpx dashed #666;background-color: rgba(0, 0, 0, 0.05);}.handWriting {background: #fff;width: 100%;height: 100%;
}.handRight {display: inline-flex;align-items: center;
}.handCenter {border: 4rpx dashed #e9e9e9;flex: 5;overflow: hidden;box-sizing: border-box;
}.handTitle {transform: rotate(90deg);flex: 1;color: #666;
}.handBtn {height: 95vh;display: inline-flex;flex-direction: column;justify-content: center;align-content: center;align-items: center;flex: 1;gap: 100rpx;
}.handBtn button {font-size: 28rpx;color: #666;background-color: transparent;border: none;transform: rotate(90deg);width: 150rpx;height: 70rpx;
}/* 各个按钮的字体白色 背景色 */
.handBtn button:nth-child(1) {color: #fff;background-color: #007AFF;
}.handBtn button:nth-child(2) {color: #fff;background-color: #FF4D4F;
}.handBtn button:nth-child(3) {color: #fff;background-color: #00C49F;
}.handBtn button:nth-child(4) {color: #fff;background-color: #FF9900;
}.handBtn button:nth-child(5) {color: #fff;background-color: #9900FF;
}
</style>

使用案例

<template><view class="signature-page"><!-- 顶部标题 --><view class="page-header"><text class="title">电子签名</text><text class="subtitle">请在下方区域签署您的姓名</text></view><!-- 签名组件 --><view class="signature-wrapper"><signature-componentref="signatureRef":upload-url="uploadUrl":upload-config="uploadConfig":min-speed="1.2":min-width="2":max-width="12":open-smooth="true":max-history-length="25":bg-color="'#ffffff'"@complete="onSignatureComplete"@error="onSignatureError"></signature-component></view><!-- 操作按钮组 --><view class="action-buttons"><view class="btn-row"><button class="btn btn-primary" @tap="handleSave"><text class="btn-icon">💾</text><text class="btn-text">保存到相册</text></button><button class="btn btn-secondary" @tap="handlePreview"><text class="btn-icon">👁️</text><text class="btn-text">预览签名</text></button></view><view class="btn-row"><button class="btn btn-warning" @tap="handleClear"><text class="btn-icon">🗑️</text><text class="btn-text">清空画布</text></button><button class="btn btn-info" @tap="handleUndo"><text class="btn-icon">↩️</text><text class="btn-text">撤销上一步</text></button></view><button class="btn btn-success confirm-btn" @tap="handleComplete"><text class="btn-icon">✅</text><text class="btn-text">确认并上传签名</text></button></view><!-- 预览区域 --><view class="preview-section" v-if="previewImage"><view class="section-title"><text>签名预览</text></view><view class="preview-image-container"><image :src="previewImage" mode="aspectFit" class="preview-image" /></view></view><!-- 上传结果 --><view class="result-section" v-if="uploadResult"><view class="section-title"><text>上传结果</text></view><view class="result-content"><text>{{ uploadResult }}</text></view></view><!-- 状态提示 --><view class="status-toast" v-if="statusMessage"><text>{{ statusMessage }}</text></view></view>
</template><script>
// 导入签名组件
import signatureComponent from '@/components/sign.vue'export default {components: {signatureComponent},data() {return {// 上传配置 - 根据实际API调整uploadUrl: 'https://your-api-domain.com/api/upload/signature',uploadConfig: {headers: {'Authorization': 'Bearer ' + uni.getStorageSync('token'),'X-Requested-With': 'XMLHttpRequest'},formData: {userId: uni.getStorageSync('userId') || 'unknown',businessType: 'contract',timestamp: Date.now(),platform: uni.getSystemInfoSync().platform},fileName: `signature_${Date.now()}`,fileType: 'png',quality: 0.9,timeout: 20000,retryCount: 3},previewImage: '',uploadResult: '',statusMessage: '',signatureData: null}},onLoad(options) {// 可以从页面参数中获取业务信息if (options.contractId) {this.uploadConfig.formData.contractId = options.contractId}this.showTips('请在画布区域签署您的姓名')},methods: {// 显示提示信息showTips(message, duration = 3000) {this.statusMessage = messagesetTimeout(() => {this.statusMessage = ''}, duration)},// 保存签名handleSave() {if (!this.$refs.signatureRef) {this.showTips('签名组件未初始化')return}if (this.$refs.signatureRef.isEmpty()) {uni.showToast({title: '请先进行签名',icon: 'none',duration: 2000})return}this.$refs.signatureRef.saveCanvasAsImg()this.showTips('正在保存签名...')},// 预览签名handlePreview() {if (!this.$refs.signatureRef) {this.showTips('签名组件未初始化')return}if (this.$refs.signatureRef.isEmpty()) {uni.showToast({title: '请先进行签名',icon: 'none',duration: 2000})return}this.$refs.signatureRef.previewCanvasImg()},// 清空画布handleClear() {if (!this.$refs.signatureRef) {this.showTips('签名组件未初始化')return}uni.showModal({title: '提示',content: '确定要清空画布吗?',success: (res) => {if (res.confirm) {this.$refs.signatureRef.clear()this.previewImage = ''this.uploadResult = ''this.showTips('画布已清空')}}})},// 撤销操作handleUndo() {if (!this.$refs.signatureRef) {this.showTips('签名组件未初始化')return}this.$refs.signatureRef.undo()},// 完成并上传handleComplete() {if (!this.$refs.signatureRef) {this.showTips('签名组件未初始化')return}if (this.$refs.signatureRef.isEmpty()) {uni.showToast({title: '请先进行签名',icon: 'none',duration: 2000})return}uni.showModal({title: '确认签名',content: '确认提交此签名吗?提交后无法修改',success: (res) => {if (res.confirm) {this.$refs.signatureRef.complete()this.showTips('正在上传签名...', 5000)}}})},// 签名完成回调onSignatureComplete(result) {console.log('签名完成回调:', result)if (result.success) {this.signatureData = resultthis.previewImage = result.filePaththis.uploadResult = `✅ 签名上传成功!\n\n` +`📁 文件已保存\n` +`⏰ 时间: ${new Date().toLocaleString()}\n` +`🆔 业务ID: ${this.uploadConfig.formData.contractId || '无'}`this.showTips('签名上传成功!')// 在实际业务中,这里可以跳转到下一步uni.showToast({title: '签名成功',icon: 'success',duration: 2000})// 3秒后返回上一页(根据业务需求调整)setTimeout(() => {uni.navigateBack({delta: 1,animationType: 'pop-out',animationDuration: 300})}, 3000)} else {this.uploadResult = `❌ 上传失败!\n\n` +`📛 错误: ${result.error || '未知错误'}\n` +`🔄 重试次数: ${result.retryCount || 0}`this.showTips('上传失败,请重试')uni.showModal({title: '上传失败',content: result.error || '网络异常,请检查网络后重试',showCancel: true,cancelText: '取消',confirmText: '重试',success: (res) => {if (res.confirm) {this.handleComplete()}}})}},// 错误处理onSignatureError(error) {console.error('签名组件错误:', error)uni.showToast({title: '发生错误,请重试',icon: 'error',duration: 2000})}}
}
</script><style lang="scss" scoped>
.signature-page {padding: 20rpx;background: #f8f9fa;min-height: 100vh;
}.page-header {text-align: center;padding: 30rpx 0;.title {font-size: 40rpx;font-weight: bold;color: #333;display: block;}.subtitle {font-size: 28rpx;color: #666;margin-top: 10rpx;display: block;}
}.signature-wrapper {background: #fff;border-radius: 16rpx;padding: 20rpx;margin: 20rpx 0;box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
}.action-buttons {margin: 40rpx 0;
}.btn-row {display: flex;justify-content: space-between;margin-bottom: 20rpx;gap: 20rpx;
}.btn {flex: 1;padding: 24rpx 0;border-radius: 12rpx;border: none;font-size: 28rpx;display: flex;align-items: center;justify-content: center;gap: 10rpx;&-primary {background: linear-gradient(135deg, #007aff, #0056cc);color: white;}&-secondary {background: linear-gradient(135deg, #ff9500, #ff6b00);color: white;}&-warning {background: linear-gradient(135deg, #ff3b30, #d70015);color: white;}&-info {background: linear-gradient(135deg, #5ac8fa, #007aff);color: white;}&-success {background: linear-gradient(135deg, #34c759, #00a651);color: white;}
}.confirm-btn {width: 100%;margin-top: 30rpx;
}.preview-section,
.result-section {background: #fff;border-radius: 16rpx;padding: 30rpx;margin: 30rpx 0;box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
}.section-title {font-size: 32rpx;font-weight: bold;color: #333;margin-bottom: 20rpx;border-left: 8rpx solid #007aff;padding-left: 20rpx;
}.preview-image-container {width: 100%;height: 300rpx;border: 2rpx dashed #ddd;border-radius: 12rpx;display: flex;align-items: center;justify-content: center;background: #f9f9f9;
}.preview-image {width: 100%;height: 100%;border-radius: 8rpx;
}.result-content {background: #f8f9fa;padding: 24rpx;border-radius: 12rpx;font-size: 26rpx;line-height: 1.6;color: #333;white-space: pre-line;
}.status-toast {position: fixed;bottom: 100rpx;left: 50%;transform: translateX(-50%);background: rgba(0, 0, 0, 0.7);color: white;padding: 20rpx 40rpx;border-radius: 50rpx;font-size: 26rpx;z-index: 1000;animation: fadeInOut 3s ease-in-out;
}@keyframes fadeInOut {0%,100% {opacity: 0;transform: translateX(-50%) translateY(20rpx);}10%,90% {opacity: 1;transform: translateX(-50%) translateY(0);}
}/* 响应式设计 */
@media (max-width: 768px) {.btn-row {flex-direction: column;gap: 15rpx;}.btn {width: 100%;}
}
</style>

总结与展望

本文详细介绍了一个基于uni-app的高性能手写签名组件的开发过程,涵盖了核心技术实现、性能优化、兼容性处理和错误处理等方面。这个组件具有以下特点:

  1. 高性能:通过优化算法和数据结构,确保流畅的绘制体验

  2. 跨平台:兼容H5、小程序和App多个平台

  3. 可扩展:模块化设计,易于扩展新功能

  4. 健壮性:完善的错误处理和用户提示机制

http://www.dtcms.com/a/340991.html

相关文章:

  • 三极管单电源供电中电阻关系的理解
  • Oracle:创建触发器,当目标表相关字段有数据变动时,存入临时表
  • 开发避坑指南(29):微信昵称特殊字符存储异常修复方案
  • 0基础安卓逆向原理与实践:第5章:APK结构分析与解包
  • pinctrl和gpio子系统实验
  • 读者写者问题
  • 接地电阻柜的核心作用
  • postman+newman+jenkins接口自动化
  • Python 文件操作与异常处理全解析
  • 7.Kotlin的日期类
  • Flink实现Exactly-Once语义的完整技术分解
  • 自动驾驶导航信号使用方式调研
  • ABAP OOP革命:ALV报表面向对象改造深度实战
  • PiscCode使用MediaPipe Face Landmarker实现实时人脸特征点检测
  • Tomcat 性能优化终极指南
  • 从零开始学AI——13
  • 吴恩达 Machine Learning(Class 3)
  • MySQL 8.x的性能优化文档整理
  • JavaScript 性能优化实战(易懂版)
  • InfluxDB 查询性能优化实战(一)
  • 【PSINS工具箱】平面上的组合导航,观测量为位置、速度、航向角。附完整的MATLAB代码
  • sqli-labs通关笔记-第58关 GET字符型报错注入(单引号闭合 限制5次探测机会)
  • 六大缓存(Caching)策略揭秘:延迟与复杂性的完美平衡
  • git-git submodule和git subtree的使用方式
  • 大规模IP轮换对网站的影响(服务器压力、风控)
  • CISP-PTE之路--05文
  • 企业微信2025年发布会新功能解读:企业微信AI——2025年企业协作的「最优解」是如何炼成的?
  • 跨境电商独立站搭建多少钱?响应式设计 + 全球 CDN 加速服务
  • IBMS系统集成平台具备哪些管理优势?核心价值体现在哪里?
  • HTTP/1.1 与 HTTP/2 全面对比:性能革命的深度解析