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

引言

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

预览效果

小程序电子签名

组件概述

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

  • 平滑手写绘制体验

  • 撤销/清空操作

  • 保存签名到相册

  • 实时预览签名

  • 自动上传到服务器

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

技术架构

封装依赖

javascript 复制代码
// 工具类模块化设计
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'

组件参数设计

javascript 复制代码
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初始化与适配

javascript 复制代码
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. 平滑绘制算法

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

javascript 复制代码
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. 贝塞尔曲线绘制

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

javascript 复制代码
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. 撤销功能实现

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

javascript 复制代码
// 添加历史记录
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. 跨平台保存策略

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

javascript 复制代码
performSave() {
  uni.canvasToTempFilePath({
    canvasId: 'handWriting',
    fileType: 'png',
    quality: 1,
    success: (res) => {
      // H5环境使用下载方式
      // #ifdef 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);
      // #endif
      
      // 小程序环境保存到相册
      // #ifndef H5
      uni.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. 上传管理器类

javascript 复制代码
/**
 * 上传管理器类
 * 负责处理文件上传的完整流程,包括配置验证、上传执行、重试机制、错误处理等
 */
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;
      
      // 提取文件URL
      fileUrl = 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. 消息提示工具

javascript 复制代码
/**
 * 显示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. 验证工具

javascript 复制代码
/**
 * 深度合并对象
 * @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 };
  }
  
  // 验证上传URL
  if (!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. 数学计算工具

javascript 复制代码
/**
 * 计算两点之间的距离
 * @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. 画布工具

javascript 复制代码
/**
 * 获取画布尺寸
 * @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个点进行计算,减少内存占用:

javascript 复制代码
this.points.push(point);
this.points = this.points.slice(-3); // 关键优化

2. 历史记录限制

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

javascript 复制代码
if (this.drawingHistory.length > this.maxHistoryLength) {
  this.drawingHistory = this.drawingHistory.slice(-this.maxHistoryLength);
}

3. 绘制优化

使用requestAnimationFrame优化绘制性能:

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

4. 内存管理

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

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

组件完整代码

javascript 复制代码
<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].x
			let temY = e.changedTouches[0].y
			this.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 = this
			var 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 API
				if (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 API
			this.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 API
			this.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 API
			const 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.canvasToTempFilePath
			uni.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.canvasToTempFilePath
			uni.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>

使用案例

javascript 复制代码
<template>
  <view class="signature-page">
    <!-- 顶部标题 -->
    <view class="page-header">
      <text class="title">电子签名</text>
      <text class="subtitle">请在下方区域签署您的姓名</text>
    </view>

    <!-- 签名组件 -->
    <view class="signature-wrapper">
      <signature-component
        ref="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 = message
      setTimeout(() => {
        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 = result
        this.previewImage = result.filePath
        this.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. 健壮性:完善的错误处理和用户提示机制

相关推荐
To_OC6 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
To_OC6 小时前
LC 17 电话号码的字母组合:我的回溯算法,就是从这道题开窍的
javascript·算法·leetcode
vipbic6 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy7 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志7 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble8 小时前
c端系统,其实更像一个信息展示平台
前端
你怎么知道我是队长8 小时前
JavaScript的变量和数据类型介绍
开发语言·javascript·ecmascript
李姆斯9 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜10 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司10 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能