微信小程序学习实录14:微信小程序手写签名功能完整开发方案

微信小程序手写签名功能完整开发方案

一、方案概述

1.1 开发背景

在微信小程序表单场景中,常需实现手写签名功能(如合同签署、审批流程等),需满足"点击唤起签名→手绘签名→保存上传→预览/删除签名"的完整交互流程,同时保证交互流畅、无样式残留等问题。

1.2 功能目标

  • 表单式签名入口:点击虚线框占位区域唤起签名弹窗;
  • 手绘签名:支持Canvas 2D流畅绘制,适配设备像素比避免模糊;
  • 签名管理:保存签名后自动更新表单显示,支持删除已签内容;
  • 交互优化:弹窗显隐无残留,点击响应及时,操作反馈清晰。

1.3 技术栈

  • 框架:微信小程序原生框架(WXML/WXSS/JS);
  • 核心:Canvas 2D API、wx.uploadFile 文件上传、小程序事件系统。

二、核心开发实现

2.1 页面结构设计(WXML)

xml 复制代码
<view class="page-root">
  <!-- 表单式签名区域 -->
  <view class="form-item signature-item">
    <view class="signature-wrap" bindtap="openSignModal" data-type="user_sign_a">
      <image wx:if="{{markerInfo.user_sign_a}}" class="signature-img" src="{{markerInfo.user_sign_a}}" mode="aspectFit"></image>
      <view wx:else class="signature-placeholder">点击此处进行签字</view>
      <view wx:if="{{markerInfo.user_sign_a}}" class="signature-delete" catchtap="deleteSignature" data-type="user_sign_a"></view>
    </view>
  </view>

  <!-- 签名弹窗 -->
  <view class="sign-modal {{isModalShow ? 'modal-show' : ''}}" bindtap="closeSignModal">
    <view class="modal-content" catchtap="stopPropagation">
      <!-- 弹窗头部 -->
      <view class="modal-header">
        <text class="modal-title">手写签名</text>
        <text class="close-icon" bindtap="closeSignModal">×</text>
      </view>
      <!-- 签名画布 -->
      <view class="sign-container">
        <canvas type="2d" id="signCanvas" class="sign-canvas" 
          bindtouchstart="handleTouchStart" 
          bindtouchmove="handleTouchMove" 
          bindtouchend="handleTouchEnd" 
          disable-scroll="true">
        </canvas>
      </view>
      <!-- 操作按钮 -->
      <view class="btn-group">
        <button bindtap="clearSign" class="common-btn">清空签名</button>
        <button bindtap="saveAndUploadSign" class="primary-btn" type="primary">保存签名</button>
      </view>
    </view>
  </view>
</view>

2.2 样式设计(WXSS)

css 复制代码
/* 页面根容器 */
.page-root {
  padding: 40rpx;
  background-color: #f5f5f5;
  min-height: 100vh;
  box-sizing: border-box;
}

/* 表单项基础样式 */
.form-item {
  width: 100%;
  margin-bottom: 30rpx;
}

/* 签名区域核心样式 */
.signature-item {
  align-items: flex-start;
  padding-top: 10rpx;
}

.signature-wrap {
  flex: 1;
  position: relative;
  border: 1rpx dashed #ccc;
  border-radius: 8rpx;
  min-height: 150rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 10rpx;
  cursor: pointer;
  touch-action: manipulation;
  background-color: #fff;
}

.signature-placeholder {
  font-size: 26rpx;
  color: #999;
  text-align: center;
  width: 100%;
  height: 100%;
  line-height: 150rpx;
}

.signature-img {
  width: 100%;
  height: 150rpx;
  border-radius: 8rpx;
}

.signature-delete {
  position: absolute;
  top: -10rpx;
  right: -10rpx;
  width: 40rpx;
  height: 40rpx;
  background-color: #000;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0;
  line-height: 1;
  z-index: 99;
  touch-action: manipulation;
}

.signature-delete::after {
  content: '×';
  color: #ffffff;
  font-size: 28rpx;
  font-weight: bold;
  text-align: center;
}

/* 弹窗样式(彻底隐藏无残留) */
.sign-modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.5);
  justify-content: center;
  align-items: center;
  z-index: 999;
  display: none;
  transition: none;
}

.modal-show {
  display: flex;
}

.modal-content {
  width: 90vw;
  max-width: 600rpx;
  background-color: #fff;
  border-radius: 20rpx;
  padding: 30rpx;
  box-sizing: border-box;
}

.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20rpx;
}

.modal-title {
  font-size: 32rpx;
  font-weight: bold;
  color: #333;
}

.close-icon {
  font-size: 40rpx;
  color: #999;
  width: 60rpx;
  height: 60rpx;
  line-height: 60rpx;
  text-align: center;
}

.sign-container {
  width: 100%;
  margin-bottom: 30rpx;
}

.sign-canvas {
  width: 100%;
  height: 300rpx;
  border: 1rpx solid #eee;
  background-color: #fff;
  box-sizing: border-box;
}

.btn-group {
  display: flex;
  gap: 20rpx;
  justify-content: space-between;
}

.common-btn, .primary-btn {
  flex: 1;
  padding: 0;
  font-size: 26rpx;
  height: 80rpx;
  line-height: 80rpx;
  border-radius: 40rpx;
  box-sizing: border-box;
}

2.3 核心逻辑(JS)

javascript 复制代码
Page({
  data: {
    isModalShow: false,       // 弹窗显示状态
    canvasCtx: null,          // Canvas 2D上下文
    canvasNode: null,         // Canvas节点实例
    isDrawing: false,         // 是否正在绘制
    hasDrawn: false,          // 是否有签名内容
    markerInfo: {             // 签名数据存储
      user_sign_a: ''
    },
    currentSignType: ''       // 当前签名类型(支持多签名扩展)
  },

  /**
   * 打开签名弹窗(核心:确保点击响应)
   */
  openSignModal(e) {
    console.log('触发签名弹窗', e);
    const signType = e.currentTarget.dataset.type || 'user_sign_a';
    this.setData({
      isModalShow: true,
      currentSignType: signType
    }, () => {
      // 延迟初始化Canvas,避免阻塞事件响应
      setTimeout(() => {
        this.initCanvas();
      }, 100);
    });
    wx.showToast({
      title: '正在打开签名面板',
      icon: 'none',
      duration: 800
    });
  },

  /**
   * 关闭签名弹窗(彻底清空状态)
   */
  closeSignModal() {
    const { canvasCtx, canvasNode } = this.data;
    // 强制清空Canvas内容,避免透传残留
    if (canvasCtx && canvasNode) {
      const dpr = wx.getSystemInfoSync().pixelRatio || 1;
      canvasCtx.fillStyle = '#fff';
      canvasCtx.fillRect(0, 0, canvasNode.width / dpr, canvasNode.height / dpr);
    }
    // 重置所有状态
    this.setData({
      isModalShow: false,
      hasDrawn: false,
      isDrawing: false
    });
  },

  /**
   * 阻止弹窗内容区点击穿透
   */
  stopPropagation() {},

  /**
   * 初始化Canvas(适配像素比,解决模糊)
   */
  initCanvas() {
    try {
      const query = wx.createSelectorQuery().in(this);
      query.select('#signCanvas')
        .fields({ node: true, size: true })
        .exec((res) => {
          if (!res[0] || !res[0].node) return;

          const canvas = res[0].node;
          const ctx = canvas.getContext('2d');
          const dpr = wx.getSystemInfoSync().pixelRatio || 1;

          // 适配设备像素比
          canvas.width = res[0].width * dpr;
          canvas.height = res[0].height * dpr;
          ctx.scale(dpr, dpr);

          // 初始化画笔样式
          ctx.lineWidth = 3;
          ctx.lineCap = 'round'; // 笔迹端点圆润
          ctx.lineJoin = 'round'; // 笔迹拐角圆润
          ctx.strokeStyle = '#000';

          // 填充白色背景,避免透明底问题
          ctx.fillStyle = '#fff';
          ctx.fillRect(0, 0, res[0].width, res[0].height);

          this.setData({ canvasCtx: ctx, canvasNode: canvas });
        });
    } catch (e) {
      console.error('Canvas初始化失败', e);
      wx.showToast({ title: '画布初始化失败', icon: 'none' });
    }
  },

  /**
   * 触摸开始:开始绘制签名
   */
  handleTouchStart(e) {
    const { canvasCtx } = this.data;
    if (!canvasCtx) return;

    this.setData({ isDrawing: true });
    const x = e.touches[0].x;
    const y = e.touches[0].y;
    canvasCtx.beginPath();
    canvasCtx.moveTo(x, y);
  },

  /**
   * 触摸移动:绘制签名轨迹
   */
  handleTouchMove(e) {
    const { canvasCtx, isDrawing } = this.data;
    if (!canvasCtx || !isDrawing) return;

    const x = e.touches[0].x;
    const y = e.touches[0].y;
    canvasCtx.lineTo(x, y);
    canvasCtx.stroke();
    this.setData({ hasDrawn: true });
  },

  /**
   * 触摸结束:停止绘制
   */
  handleTouchEnd() {
    this.setData({ isDrawing: false });
  },

  /**
   * 清空签名画布
   */
  clearSign() {
    const { canvasCtx, canvasNode } = this.data;
    if (!canvasCtx || !canvasNode) return;

    const dpr = wx.getSystemInfoSync().pixelRatio || 1;
    canvasCtx.fillStyle = '#fff';
    canvasCtx.fillRect(0, 0, canvasNode.width / dpr, canvasNode.height / dpr);
    this.setData({ hasDrawn: false });
  },

  /**
   * 保存并上传签名(核心:更新表单显示)
   */
  saveAndUploadSign() {
    const { hasDrawn, canvasNode, currentSignType } = this.data;

    // 校验签名内容
    if (!hasDrawn) {
      wx.showToast({ title: '请先完成手写签名', icon: 'none' });
      return;
    }
    if (!canvasNode) {
      wx.showToast({ title: '画布初始化失败', icon: 'none' });
      return;
    }

    // 将Canvas转为临时文件
    wx.canvasToTempFilePath({
      canvas: canvasNode,
      fileType: 'png',
      quality: 0.8,
      success: (res) => {
        // 上传签名文件到后端(替换为实际接口)
        wx.uploadFile({
          url: 'https://your-domain.com/api/upload-sign', // 后端接口地址
          filePath: res.tempFilePath,
          name: 'sign_img',
          formData: {
            user_id: '1001',       // 业务参数:用户ID
            order_id: 'OD20260101',// 业务参数:订单ID
            sign_type: currentSignType // 签名类型
          },
          success: (uploadRes) => {
            try {
              const result = JSON.parse(uploadRes.data);
              if (result.code === 200) {
                wx.showToast({ title: '签名保存成功', icon: 'success' });
                // 更新表单签名显示
                const updateData = {};
                updateData[`markerInfo.${currentSignType}`] = result.data.sign_url;
                this.setData(updateData, () => {
                  // 延迟关闭弹窗,确保样式渲染完成
                  setTimeout(() => {
                    this.closeSignModal();
                  }, 100);
                });
              } else {
                wx.showToast({ title: result.msg || '签名保存失败', icon: 'none' });
              }
            } catch (e) {
              console.error('解析后端数据失败', e);
              wx.showToast({ title: '数据解析失败', icon: 'none' });
            }
          },
          fail: (err) => {
            console.error('上传失败', err);
            wx.showToast({ title: '网络错误,上传失败', icon: 'none' });
          }
        });
      },
      fail: (err) => {
        console.error('Canvas转文件失败', err);
        wx.showToast({ title: '签名生成失败', icon: 'none' });
      }
    }, this);
  },

  /**
   * 删除已保存的签名
   */
  deleteSignature(e) {
    const signType = e.currentTarget.dataset.type;
    wx.showModal({
      title: '提示',
      content: '确定删除该签名吗?',
      success: (res) => {
        if (res.confirm) {
          const updateData = {};
          updateData[`markerInfo.${signType}`] = '';
          this.setData(updateData);
        }
      }
    });
  }
});

三、常见问题及解决方案

3.1 点击签名区域无响应

问题原因
  • 样式缺失导致点击区域不可交互;
  • Canvas初始化逻辑阻塞事件响应;
  • 元素层级覆盖导致点击穿透。
解决方案
  • signature-wrap添加cursor: pointertouch-action: manipulation
  • 延迟初始化Canvas(setTimeout),优先执行弹窗显示逻辑;
  • 补充page-root根容器,避免点击区域被挤压/隐藏。

3.2 签名后弹窗未彻底隐藏

问题原因
  • 弹窗使用opacity仅透明化,元素仍残留;
  • Canvas绘制内容透传至页面;
  • 弹窗关闭时机过早,样式未渲染完成。
解决方案
  • 弹窗显隐切换为display: none/flex,彻底移除元素;
  • 关闭弹窗时强制清空Canvas内容;
  • 保存签名后延迟100ms关闭弹窗。

3.3 签名图片模糊

问题原因
  • 未适配设备像素比(dpr),Canvas实际分辨率不足。
解决方案
  • 初始化Canvas时,将宽高乘以设备像素比;
  • 通过ctx.scale(dpr, dpr)缩放画布,保证绘制精度。

3.4 多签名场景扩展

  • 复制签名区域WXML,修改data-type(如user_sign_b);
  • JS无需修改,currentSignType自动适配不同签名类型;
  • 后端接口通过sign_type区分存储不同签名。

四、后端接口适配建议

4.1 接口地址

POST https://your-domain.com/api/upload-sign

4.2 请求参数

参数名 类型 说明
sign_img File 签名图片文件
user_id String 用户ID(业务参数)
order_id String 订单ID(业务参数)
sign_type String 签名类型(如user_sign_a)

4.3 返回格式

json 复制代码
{
  "code": 200,
  "msg": "上传成功",
  "data": {
    "sign_url": "https://your-domain.com/sign/xxx.png" // 签名图片访问地址
  }
}

五、优化与扩展建议

5.1 体验优化

  • 增加笔迹粗细/颜色配置;
  • 签名弹窗添加"重签"按钮,简化操作流程;
  • 加载状态提示(如上传时显示loading)。

5.2 功能扩展

  • 支持签名撤销(记录绘制轨迹,分步回退);
  • 签名图片压缩(调整quality参数,平衡清晰度与大小);
  • 横屏签名适配(调整Canvas宽高比例)。

5.3 兼容性

  • 适配小程序基础库版本≥2.10.0;
  • 针对低版本库,降级使用Canvas 1.0 API。

六、总结

本方案实现了微信小程序表单式手写签名的完整功能,解决了"点击无响应、弹窗残留、图片模糊"等核心问题,代码结构清晰、可扩展性强。通过Canvas 2D适配设备像素比保证绘制精度,通过样式和逻辑优化确保交互流畅,同时支持多签名场景扩展,可直接应用于合同签署、审批等业务场景。

相关推荐
魔芋红茶2 小时前
Spring Security 学习笔记 1:快速开始
笔记·学习·spring
皮蛋sol周2 小时前
嵌入式学习数据结构(三)栈 链式 循环队列
arm开发·数据结构·学习·算法··循环队列·链式队列
Kratzdisteln2 小时前
【1902】优化后的三路径学习系统
android·学习
仰泳之鹅2 小时前
【PID学习】多环PID
学习·pid
testpassportcn3 小时前
CompTIA A+ 220-1201 認證介紹|CompTIA A+ Core 1 考試內容、題型與高效備考指南
网络·学习·改行学it
2501_944934733 小时前
数据洞察力:职业转型的核心竞争力
学习
AI视觉网奇3 小时前
ue5 默认相机设置
笔记·学习·ue5
山土成旧客3 小时前
【Python学习打卡-Day44】站在巨人的肩膀上:玩转PyTorch预训练模型与迁移学习
pytorch·python·学习
星河天欲瞩3 小时前
【深度学习Day1】环境配置(CUDA、PyTorch)
人工智能·pytorch·python·深度学习·学习·机器学习·conda