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 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. 上传管理器类

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

/**
 * 显示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 };
  }
  
  // 验证上传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. 数学计算工具

/**
 * 计算两点之间的距离
 * @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].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>

使用案例

<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. 健壮性:完善的错误处理和用户提示机制

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值