uniapp 使用 Canvas 实现横屏手写签名

1,052 阅读16分钟

最近在项目中需要一个签名框的需求,原本想用直接使用类似于 wot-ui 之类组件库里的签名框,但是后来放到界面上发现,组件的样式并不好更改,即使强行修改也总会有些问题。于是,就直接用 Uniapp 自带的 Canvas 实现一个签名组件。

此文章已在9月22日更新,最近因为项目需要支持微信小程序,所以做了一下适配

前言

uniapp 和 H5 中的 Canvas 存在一些差异,为了在 uniapp 中获取到 Canvas 的上下文,需要借助 canvas-id 属性,因此这个属性是必须指定的。

<canvas canvas-id="signatureCanvas"></canvas>

此外,uniapp 还在 Canvas 上绑定了几个触摸事件,通过这些事件,我们就能处理和控制 Canvas 的绘制样式和路径。

事件说明
@touchstart手指触摸动作开始
@touchmove手指触摸后移动
@touchend手指触摸动作结束
@touchcancel手指触摸动作被打断,如来电提醒,弹窗

Canvas 绘制

手写签名的绘制实际上就是在 Canvas 上的线条绘制,因此在绘制线条开始时,我们首先需要确定两个东西。手放在屏幕上的位置,也就是起点,手移动过程中的位置,也就是终点。那么起点和终点怎么标识呢,我们可以定义一个 (x,y) 的坐标点。

type Point = {
  X: number;
  Y: number;
};

同时,我们将其放进一个坐标列表中,用于移动时的坐标点的更新和移除。

const points = ref<Point[]>([]);

准备工作都做好了,我们直接把画布拿过来吧,顺带设置一下全屏宽高和白底背景。

const instance = getCurrentInstance();
const ctx = ref<UniNamespace.CanvasContext>();

onLoad(() => {
  //创建绘图对象
  ctx.value = uni.createCanvasContext("signatureCanvas");
  
  uni.getSystemInfo({
    success: (res) => {
      const width = res.windowWidth;
      const height = res.windowHeight;
      ctx.value.setFillStyle("white");
      ctx.value.fillRect(0, 0, width, height);
    },
  });
});

首先我们把手放在屏幕上,这个时候画布就可以得到起始坐标点了,我们直接把它装进刚才准备好的坐标点列表里面。至于画笔的样式,如果你需要控制绘制线条的粗细,那么可以在每次开始绘制时,设置好画笔的粗细。

function touchstart(e: any) {
  let startX = e.changedTouches[0].x;
  let startY = e.changedTouches[0].y;
  let startPoint = { X: startX, Y: startY };

  //uni对canvas的实现有所不同,需要把起点先存起来,不然会少一段起点绘制
  points.value.push(startPoint);

  //设置画笔样式
  ctx.value.lineWidth = 4;
  ctx.value.lineCap = "round";
  ctx.value.lineJoin = "round";
}

然后开始移动你的手指,触摸移动的时候会记录下每个移动位置的坐标点,所以每一个移动位置的坐标点都是终点,我们也把这个终点存起来。有了起点和终点能做什么?当然是绘制成一条线啦。

function touchmove(e: any) {
  let moveX = e.changedTouches[0].x;
  let moveY = e.changedTouches[0].y;
  let movePoint = { X: moveX, Y: moveY };
  //存点
  points.value.push(movePoint);
  //绘制路径
  draw();
}

function draw() {
  if (points.value.length < 2) {
    return;
  }

  isDraw.value = true;

  const [start, end] = points.value;
  points.value.shift();

  ctx.value.beginPath();
  ctx.value.moveTo(start.X, start.Y);
  ctx.value.lineTo(end.X, end.Y);
  ctx.value.stroke();
  ctx.value.draw(true);
}

每次绘制完成后,我们把最后前一个起点擦掉,把上一个终点设置为起点,这样交替存储起点和终点的列表,不停的绘制线条,这样我们就得到一条路径轨迹。可以确定的是,这个起点和终点的列表始终会是两个,当列表中的值变成一个,即起点时,说明手指触摸停止了,这个时候我们的绘制也就结束了。

绘制停止后,我们也要移除起点,当然最直接的就是清空这个起点和终点的列表。

function touchend() {
  points.value = [];
}

最后,我们把事件绑定到 Canvas 的事件上就行了。这里要注意的一点是,最好把 disable-scroll 属性设置为 true,这样在绘制签名的时候屏幕就不会滚动了,如果屏幕一旦滚动,那么绘制的线条就会变的断断续续,所以最好还是加上。

<canvas
  class="signature-canvas"
  canvas-id="signatureCanvas"
  :disable-scroll="true"
  @touchstart="touchstart"
  @touchmove="touchmove"
  @touchend="touchend"
></canvas>

Canvas 获取

Canvas 获取只需要使用 uni.canvasToTempFilePath 方法传入 canvasId 就能够获取到签名图片的临时文件路径了,而这个路径在 H5 中是一串 base64 字符,在非 H5 中则是字符路径。

uni.canvasToTempFilePath({
  canvasId: "signatureCanvas",
  success: async (res) => {
    const path = res.tempFilePath;

    // #ifndef H5
    uni.compressImage({
      src: path,
      rotate: 270,
      success: (response) => {
        const tempFilePath = response.tempFilePath;
        console.log(tempFilePath);
        //...
      },
    });
  },
}, instance);

但是这样获取的图片实际上是竖屏的,因为是画布并没有横屏,所以我们需要处理一下图片,很简单逆时针旋转 90 度就行了。

方法有两种,一种是在布局中多增加一个隐藏的 Canvas 用于绘制和旋转图片,另一种是是用兼容性写法。这里我感觉前者需要新增一个多余的 Canvas 侵入性太强,因此我选择了用兼容性的方法实现。

从官方文档中,我们可以看到 uni.compressImage 的兼容性较为全面,只需要单独处理 H5 下的图片旋转就能够实现前者的相同的功能。但是,如果需要兼容鸿蒙下的元服务,那么请使用隐藏 Canvas 处理图片旋转。

image.png

在 H5 中可以使用 Javascript 创建一个原生的 Canvas 进行处理,而非 H5 中可以使用 uniapp API的 uni.compressImage 中的 rotate 参数对图片进行旋转。

这里需要注意的是uni.compressImage 中的 rotate 属性并不支持负数,所以我们只能顺时针旋转 270 度。

虽然 uni.compressImage 支持微信小程序,但是可惜的是其中的关键参数 rotate 在微信小程序上并不支持,所以我们还需要做单独的适配。

image.png

但是写到这里也不想多此一举用多余的 Canvas 处理绘制的图片。我们就只能使 uni.createOffscreenCanvas 和 用微信小程序原生的wx.canvasToTempFilePath API 创建一个离屏 Canvas 导入图片绘制,然后进行旋转图片再导出。这和增加一个额外的 canvas 是一样的,但是我们不需要处理这个 Canvas 的显示问题。

image.png

唯一的不足就是 uni.canvasToTempFilePath 不支持传入 Canvas 对象,只能使用不统一的 wx.canvasToTempFilePath API,而且在 TypeScript 中会提示类型警告,需要自行处理 wx 类型提示。

uni.canvasToTempFilePath(
  {
    canvasId: "signatureCanvas",
    success: async (res) => {
      const path = res.tempFilePath;

      /* #ifdef APP-NVUE ||APP-PLUS ||APP-PLUS-NVUE */
      uni.compressImage({
        src: path,
        rotate: 270,
        success: (response) => {
          isShowModal.value = false;
          previewImage.value = response.tempFilePath;
        },
      });
      /* #endif */

      /* #ifdef H5  */
      previewImage.value = await rotateToBase64File(path);
      isShowModal.value = false;
      /* #endif */

      /* #ifdef MP-WEIXIN */
      const offCanvas: any = uni.createOffscreenCanvas({ type: "2d" });
      const ctx2d = offCanvas.getContext("2d");

      const img = offCanvas.createImage();
      img.src = path;
      img.onload = async () => {
        const w = img.width;
        const h = img.height;

        offCanvas.width = h;
        offCanvas.height = w;

        ctx2d.translate(h / 2, w / 2);
        ctx2d.rotate(-90);
        ctx2d.drawImage(img, -w / 2, -h / 2, w, h);

        // eslint-disable-next-line no-undef
        wx.canvasToTempFilePath({
          canvas: offCanvas,
          success: (response: { tempFilePath: string }) => {
            isShowModal.value = false;
            previewImage.value = response.tempFilePath;
          },
        });
      };
      /* #endif */
    },
  },
  instance
);
  
// #ifdef H5
/**
 * 将 Base64 图片逆时针旋转90度并转换为 File 对象
 * @param {string} base64 - Base64 字符串
 * @param {string} [mimeType] - 文件类型(可选,默认为 image/png)
 * @returns {Promise<string>} - 旋转后的 base64 图片
 */
async function rotateToBase64File(base64: string, mimeType: string = "image/png"): Promise<string> {
  const img = new Image();
  img.src = base64;

  await new Promise<void>((resolve, reject) => {
    img.onload = () => resolve();
    img.onerror = (e) => reject(new Error("图片加载失败"));
  });

  const canvas = document.createElement("canvas");
  canvas.width = img.height;
  canvas.height = img.width;

  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("无法获取画布上下文");

  ctx.translate(canvas.width / 2, canvas.height / 2);
  ctx.rotate(-Math.PI / 2); // 逆时针旋转90度
  ctx.drawImage(img, -img.width / 2, -img.height / 2);

  return canvas.toDataURL(mimeType);
}
// #endif

Canvas 清除

当我们需要重新绘制 Canvas 时,需要 clearRect 方法清除所有内容,该方法需要传入整个画布的长宽高,这里我们可以简单通过 uni.getSystemInfo API 获取整个屏幕的宽高直接传入全屏清除。然后,这里我还进行了白色背景的填充,因为 Canvas 背景默认是透明的,所有原先我们填充的背景也会被清除掉,这里我们给它再填充回去。或者,不清除画布,直接填充白色背景应该也能达到同样的效果。

function clear() {
  isDraw.value = false;

  uni.getSystemInfo({
    success: (res) => {
      const width = res.windowWidth;
      const height = res.windowHeight;
      ctx.value.clearRect(0, 0, width, height);

      ctx.value.setFillStyle("white");
      ctx.value.fillRect(0, 0, width, height);
      ctx.value.draw(true);
    },
  });
}

纯界面签名布局实例

一个界面中的 Canvas 全屏签名布局就做好了,完整代码如下

<template>
  <view class="signature-modal" :style="{ height: remainingHeight + 'px' }">
    <!-- 左侧工具栏 -->
    <view class="tool-bar">
      <view class="tool-wrapper">
        <!-- 同意协议 -->
        <view class="agreement-check" @click="changeAgreeChecked">
          <view class="check-icon">
            <uni-icons
              :type="isAgree ? 'checkbox-filled' : 'circle'"
              :color="isAgree ? '#2d99a1' : '#a6a6a6'"
              size="16"
            />
          </view>
          <view class="agreement-label">我已认真阅读并同意</view>
          <view class="agreement-link" @click.stop="openAgreement">《使用同意书》</view>
        </view>

        <!-- 按钮组 -->
        <view class="btn-group">
          <view class="btn btn-cancel" @click="cancel">取消</view>
          <view class="btn btn-clear" @click="clear">重写</view>
          <view class="btn btn-submit" @click="submit">提交</view>
        </view>
      </view>
    </view>

    <!-- 签名画布 -->
    <canvas
      class="signature-canvas"
      canvas-id="signatureCanvas"
      @touchstart="touchstart"
      @touchmove="touchmove"
      @touchend="touchend"
    ></canvas>

    <view class="canvas-title">
      <text>手写签名</text>
    </view>
  </view>
</template>

<script lang="ts" setup>
import { onLoad, onShow } from "@dcloudio/uni-app";
import { ref, onMounted } from "vue";
import { getCurrentInstance } from "vue";

type Point = {
  X: number;
  Y: number;
};

const isAgree = ref<boolean>(false);
const remainingHeight = ref<number>(0);
const isDraw = ref<boolean>(false);

const points = ref<Point[]>([]);
const instance = getCurrentInstance();
const ctx = ref<UniNamespace.CanvasContext>();

onShow(() => {
  isAgree.value = uni.getStorageSync("agreeOrNot");
  if (!isAgree.value) {
    return;
  }

  uni.setStorageSync("agreeOrNotNum", true);
});

onLoad(() => {
  //创建绘图对象
  ctx.value = uni.createCanvasContext("signatureCanvas", instance);
  ctx.value.setFillStyle("white");

  uni.getSystemInfo({
    success: (res) => {
      const width = res.windowWidth;
      const height = res.windowHeight;
      ctx.value.fillRect(0, 0, width, height);
    },
  });

  //设置画笔样式
  ctx.value.lineWidth = 4;
  ctx.value.lineCap = "round";
  ctx.value.lineJoin = "round";
});

onMounted(() => {
  uni.getSystemInfo({
    success(res) {
      const screenHeight = res.windowHeight || res.screenHeight;

      uni
        .createSelectorQuery()
        .select(".signature-modal")
        .boundingClientRect((data) => {
          remainingHeight.value = screenHeight - data.top;
        })
        .exec();
    },
  });
});

// 改变是否同意协议
function changeAgreeChecked() {
  isAgree.value = !isAgree.value;
}
// 打开协议
function openAgreement() {
  // uni.navigateTo({});
}

//触摸开始,获取到起点
function touchstart(e: any) {
  let startX = e.changedTouches[0].x;
  let startY = e.changedTouches[0].y;
  let startPoint = { X: startX, Y: startY };

  //uni对canvas的实现有所不同,需要把起点先存起来,不然会少一段起点绘制
  points.value.push(startPoint);
}

//触摸移动,获取路径点
function touchmove(e: any) {
  let moveX = e.changedTouches[0].x;
  let moveY = e.changedTouches[0].y;
  let movePoint = { X: moveX, Y: moveY };
  //存点
  points.value.push(movePoint);
  //绘制路径
  draw();
}

// 触摸结束,将未绘制的点清空防止对后续路径产生干扰
function touchend() {
  points.value = [];
}

/**
 * 实时绘制笔迹
 * 1. 实时性:必须在手指移动的同时立即绘制,否则会出现“断笔”或延迟。
 * 2. 连续性:用“滑动窗口”思想推进绘制——每次只取路径数组前两个点,
 *    将第一个点作为 moveTo 起点,第二个点作为 lineTo 终点,
 *    绘制完成后把第一个点移除,使上一次的终点自然成为下一次的起点。
 *
 * - 本函数只负责“画一段”,外层应循环调用 draw() 直到 points.value.length < 2。
 */
function draw() {
  if (points.value.length < 2) {
    return;
  }

  isDraw.value = true;

  const [start, end] = points.value;
  points.value.shift();

  ctx.value.beginPath();
  ctx.value.moveTo(start.X, start.Y);
  ctx.value.lineTo(end.X, end.Y);
  ctx.value.stroke();
  ctx.value.draw(true);
}

// 取消
function cancel() {
  // uni.navigateTo({});
  clear();
}

// 重写
function clear() {
  isDraw.value = false;
  uni.getSystemInfo({
    success: (res) => {
      const width = res.windowWidth;
      const height = res.windowHeight;
      ctx.value.clearRect(0, 0, width, height);
      ctx.value.setFillStyle("white");
      ctx.value.fillRect(0, 0, width, height);
      ctx.value.draw(true);
    },
  });
}

// 提交
function submit() {
  if (isDraw.value == false) {
    uni.showToast({
      title: "请先签名",
      icon: "none",
    });
    return;
  }
  uni.canvasToTempFilePath(
    {
      canvasId: "signatureCanvas",
      success: async (res) => {
        const path = res.tempFilePath;

        /* #ifdef APP-NVUE ||APP-PLUS ||APP-PLUS-NVUE */
        uni.compressImage({
          src: path,
          rotate: 270,
          success: (response) => {
            isShowModal.value = false;
            previewImage.value = response.tempFilePath;
          },
        });
        /* #endif */

        /* #ifdef H5  */
        previewImage.value = await rotateToBase64File(path);
        isShowModal.value = false;
        /* #endif */

        /* #ifdef MP-WEIXIN */
        const offCanvas: any = uni.createOffscreenCanvas({ type: "2d" });
        const ctx2d = offCanvas.getContext("2d");
        
        const img = offCanvas.createImage();
        img.src = path;
        img.onload = async () => {
          const w = img.width;
          const h = img.height;
          
          offCanvas.width = h;
          offCanvas.height = w;
          
          ctx2d.translate(h / 2, w / 2);
          ctx2d.rotate(-90);
          ctx2d.drawImage(img, -w / 2, -h / 2, w, h);
          
          // eslint-disable-next-line no-undef
          wx.canvasToTempFilePath({
            canvas: offCanvas,
            success: (response: { tempFilePath: string }) => {
              isShowModal.value = false;
              previewImage.value = response.tempFilePath;
            },
          });
        };
        /* #endif */
      },
    },
    instance,
  );
}

// #ifdef H5
/**
 * 将 Base64 图片逆时针旋转90度并转换为 File 对象
 * @param {string} base64 - Base64 字符串
 * @param {string} [mimeType] - 文件类型(可选,默认为 image/png)
 * @returns {Promise<string>} - 旋转后的 base64 图片
 */
async function rotateToBase64File(base64: string, mimeType: string = "image/png"): Promise<string> {
  const img = new Image();
  img.src = base64;

  await new Promise<void>((resolve, reject) => {
    img.onload = () => resolve();
    img.onerror = (e) => reject(new Error("图片加载失败"));
  });

  const canvas = document.createElement("canvas");
  canvas.width = img.height;
  canvas.height = img.width;

  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("无法获取画布上下文");

  ctx.translate(canvas.width / 2, canvas.height / 2);
  ctx.rotate(-Math.PI / 2); // 逆时针旋转90度
  ctx.drawImage(img, -img.width / 2, -img.height / 2);

  return canvas.toDataURL(mimeType);
}
// #endif
</script>

<style lang="scss" scoped>
.signature-modal {
  display: flex;
  justify-content: center;
  align-items: center;
  box-sizing: border-box;
  background: #f5f5f5;
  padding: 15rpx;
  height: 100%;

  /* 左侧工具栏 */
  .tool-bar {
    position: relative;
    width: 100rpx;
    height: 100rpx;
    transform: rotate(90deg);
    margin-right: 10rpx;
    display: flex;
    align-items: center;
    justify-content: center;

    .tool-wrapper {
      display: flex;

      .agreement-check {
        width: 500rpx;
        display: flex;
        justify-content: center;
        align-items: center;

        .check-icon {
          margin-right: 15rpx;
        }
        .agreement-label {
          font-size: 26rpx;
          color: #a6a6a6;
        }
        .agreement-link {
          font-size: 26rpx;
          color: #1684fc;
        }
      }

      .btn-group {
        display: flex;

        .btn {
          white-space: nowrap;
          padding: 10rpx 50rpx;
          border-radius: 28rpx;
          font-size: 26rpx;
          font-weight: 500;
          margin-right: 47rpx;
          display: flex;
          justify-content: center;
          align-items: center;

          &.btn-cancel {
            background: #ffffff;
            color: #666666;
          }
          &.btn-clear {
            background: #959595;
            color: #ffffff;
            margin-right: 15rpx;
          }
          &.btn-submit {
            background: #2d99a1;
            color: #ffffff;
          }
        }
      }
    }
  }

  /* 签名画布 */
  .signature-canvas {
    flex: 1;
    height: 100%;
    background-color: #fff;
    border-radius: 5px;
  }

  /* 右侧竖排标题 */
  .canvas-title {
    transform: translateY(-50%) rotate(90deg);
    font-size: 26rpx;
    font-weight: 400;
    color: #333;
  }
}
</style>

预览效果:

不过,现在只有一个界面,如果直接使用那么还需要对界面进行二次跳转回传签名数据,用户使用体验并不好。所以,还是建议封装成一个签名组件,全屏签名用弹窗的形式显示,签名完成后保留一个签名预览和重新签名的界面。

封装签名组件

模拟横屏 Toast

首先,因为 uniapp 的 toast 并不支持横屏,因此这里我们需要专门为横屏签名写一个横屏的 Toast 提示,模拟横屏签名下的横屏 Toast 显示。

<template>
  <!-- 横屏 Toast -->
  <view id="landscapeToast" class="landscape-toast" v-if="isShowToast">
    <view class="toast-content">
      <text>{{ toastMsg }}</text>
    </view>
  </view>
</template>

<script lang="ts" setup>
import { ref, onMounted } from "vue";

const isShowToast = ref<boolean>(false);
const toastMsg = ref<string>("");

function showToast(msg: string) {
  toastMsg.value = msg;
  isShowToast.value = true;

  setTimeout(() => (isShowToast.value = false), 1500);
}

<style lang="scss" scoped>
/* 横屏Toast  */
.landscape-toast {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) rotate(90deg);
  background-color: #585858;
  color: #ffffff;
  padding: 12px 20px;
  border-radius: 5px;
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  z-index: 9999;
  pointer-events: none;

  .toast-content {
    display: flex;
    align-items: center;
    gap: 8px;
  }
}
</style>

增加签名预览

为了更好地显示签名效果,可以为其增加空白签名界面作为入口,签名完成时再显示签名图片预览。原有的横屏签名默认隐藏,当点击空白区域开始签名时固定覆盖显示在最上层。

<template>
  <view class="signature-container">
    <!-- 签名空白区域 -->
    <view class="signature-empty" v-if="!previewImage">
      <view class="empty-blank" @click="() => (isShowModal = true)">点击此处签名</view>
    </view>

    <!-- 签名预览区域 -->
    <view class="signature-preview" v-else>
      <view class="tool-bar">
        <text>签名预览:</text>
        <text class="resign-btn" @click="reSign">重签</text>
      </view>
      <image :src="previewImage" mode="scaleToFill" />
    </view>
  </view>
  
  <!-- 全屏签名弹窗 -->
  <view class="signature-modal" v-if="isShowModal" :style="{ height: remainingHeight + 'px' }">
      ...
  </view>
  
  <!-- 横屏 Toast -->
  <view id="landscapeToast" class="landscape-toast" v-if="isShowToast">
    <view class="toast-content">
      <text>{{ toastMsg }}</text>
    </view>
  </view>
</template>

组件传参

然后我们为组件定义两个参数,用来传递签名图片和协议同意书的界面跳转链接。

const { agreeLink } = defineProps<{ agreeLink: string }>();
const previewImage = defineModel<string>({ default: "" });

最后,我们只需要在父组件中引入签名组件就好了。

<template>
  <!-- 签名板 -->
  <signature v-model="previewImage" :agree-link="agreeLink" />
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import signature from '@/components/signature.vue';

const agreeLink = ref('pages/user/consentForm');

/** 签名图片(base64 或 临时文件路径 tempPath) */
const previewImage = ref('');
</script>

组件完整代码

<template>
  <!-- #ifdef MP-WEIXIN -->
  <page-meta :page-style="isShowModal ? 'overflow:hidden;' : 'overflow:auto'">
  <!-- #endif -->
    <view class="signature-container">
      <!-- 签名空白区域 -->
      <view class="signature-empty" v-if="!previewImage">
        <view class="empty-blank" @click="reSign">点击此处签名</view>
      </view>

      <!-- 签名预览区域 -->
      <view class="signature-preview" v-else>
        <view class="tool-bar">
          <text>签名预览:</text>
          <text class="resign-btn" @click="reSign">重签</text>
        </view>
        <image :src="previewImage" mode="scaleToFill" />
      </view>
    </view>

    <!-- 全屏签名弹窗 -->
    <view
      class="signature-modal"
      v-if="isShowModal"
      :style="{ height: remainingHeight + 'px' }"
      @touchmove.prevent
    >
      <!-- 左侧工具栏 -->
      <view class="tool-bar">
        <view class="tool-wrapper">
          <!-- 同意协议 -->
          <view class="agreement-check" @click="changeAgreeChecked">
            <view class="check-icon">
              <uni-icons
                :type="isAgree ? 'checkbox-filled' : 'circle'"
                :color="isAgree ? '#2d99a1' : '#a6a6a6'"
                size="16"
              />
            </view>
            <view class="agreement-label">我已认真阅读并同意</view>
            <view class="agreement-link" @click.stop="openAgreement">《使用同意书》</view>
          </view>

          <!-- 按钮组 -->
          <view class="btn-group">
            <view class="btn btn-cancel" @click="cancel">取消</view>
            <view class="btn btn-clear" @click="clear">重写</view>
            <view class="btn btn-submit" @click="submit">提交</view>
          </view>
        </view>
      </view>

      <!-- 签名画布 -->
      <canvas
        class="signature-canvas"
        canvas-id="signatureCanvas"
        :disable-scroll="true"
        @touchstart="touchstart"
        @touchmove="touchmove"
        @touchend="touchend"
      ></canvas>

      <view class="canvas-title">
        <text>手写签名</text>
      </view>
    </view>

    <!-- 横屏 Toast -->
    <view id="landscapeToast" class="landscape-toast" v-if="isShowToast">
      <view class="toast-content">
        <text>{{ toastMsg }}</text>
      </view>
    </view>
  <!-- #ifdef MP-WEIXIN -->
  </page-meta>
  <!-- #endif -->
</template>

<script lang="ts" setup>
import { onLoad, onShow } from "@dcloudio/uni-app";
import { ref, onMounted, type ComponentInternalInstance, watch } from "vue";
import { getCurrentInstance } from "vue";

type Point = {
  X: number;
  Y: number;
};

const { agreeLink } = defineProps<{ agreeLink: string }>();
const previewImage = defineModel<string>({ default: "" });

const toastMsg = ref<string>("");
const remainingHeight = ref<number>(0);

const isDraw = ref<boolean>(false);
const isAgree = ref<boolean>(false);
const isShowToast = ref<boolean>(false);
const isShowModal = ref<boolean>(false);

const points = ref<Point[]>([]);
const ctx = ref<UniNamespace.CanvasContext>();
const instance: Readonly<ComponentInternalInstance | null> = getCurrentInstance();

watch(isShowModal, (val) => (val ? lockScroll() : unlockScroll()));

onShow(() => {
  isAgree.value = uni.getStorageSync("agreeOrNot");
  if (!isAgree.value) {
    return;
  }

  uni.setStorageSync("agreeOrNotNum", true);
});

onLoad(() => {
  //创建绘图对象
  ctx.value = uni.createCanvasContext("signatureCanvas", instance);

  uni.getSystemInfo({
    success: (res) => {
      if (!ctx.value) {
        return;
      }
      const width = res.windowWidth;
      const height = res.windowHeight;
      ctx.value.setFillStyle("white");
      ctx.value.fillRect(0, 0, width, height);
    },
  });
});

onMounted(() => {
  uni.getSystemInfo({
    success(res) {
      const screenHeight = res.windowHeight || res.screenHeight;

      uni
        .createSelectorQuery()
        .select(".signature-container")
        .boundingClientRect((data) => {
          remainingHeight.value = screenHeight - data.top;
        })
        .exec();
    },
  });
});

// 改变是否同意协议
function changeAgreeChecked() {
  isAgree.value = !isAgree.value;
}
// 打开协议
function openAgreement() {
  uni.navigateTo({ url: agreeLink });
}

//触摸开始,获取到起点
function touchstart(e: any) {
  if (!ctx.value) {
    return;
  }

  let startX = e.changedTouches[0].x;
  let startY = e.changedTouches[0].y;
  let startPoint = { X: startX, Y: startY };

  //uni对canvas的实现有所不同,需要把起点先存起来,不然会少一段起点绘制
  points.value.push(startPoint);

  //设置画笔样式
  ctx.value.lineWidth = 4;
  ctx.value.lineCap = "round";
  ctx.value.lineJoin = "round";
}

//触摸移动,获取路径点
function touchmove(e: any) {
  let moveX = e.changedTouches[0].x;
  let moveY = e.changedTouches[0].y;
  let movePoint = { X: moveX, Y: moveY };
  //存点
  points.value.push(movePoint);
  //绘制路径
  draw();
}

// 触摸结束,将未绘制的点清空防止对后续路径产生干扰
function touchend() {
  points.value = [];
}

/**
 * 实时绘制笔迹
 * 1. 实时性:必须在手指移动的同时立即绘制,否则会出现“断笔”或延迟。
 * 2. 连续性:用“滑动窗口”思想推进绘制——每次只取路径数组前两个点,
 *    将第一个点作为 moveTo 起点,第二个点作为 lineTo 终点,
 *    绘制完成后把第一个点移除,使上一次的终点自然成为下一次的起点。
 *
 * - 本函数只负责“画一段”,外层应循环调用 draw() 直到 points.value.length < 2。
 */
function draw() {
  if (points.value.length < 2) {
    return;
  }

  if (!ctx.value) {
    return;
  }

  isDraw.value = true;

  const [start, end] = points.value;
  points.value.shift();

  ctx.value.beginPath();
  ctx.value.moveTo(start.X, start.Y);
  ctx.value.lineTo(end.X, end.Y);
  ctx.value.stroke();
  ctx.value.draw(true);
}

// 取消
function cancel() {
  isShowModal.value = false;
  clear();
}

// 重写
function clear() {
  isDraw.value = false;

  uni.getSystemInfo({
    success: (res) => {
      if (!ctx.value) {
        return;
      }

      const width = res.windowWidth;
      const height = res.windowHeight;
      ctx.value.clearRect(0, 0, width, height);

      ctx.value.setFillStyle("white");
      ctx.value.fillRect(0, 0, width, height);
      ctx.value.draw(true);
    },
  });
}

// 提交
function submit() {
  if (isDraw.value == false) {
    showToast("请先签名");
    return;
  }

  if (!isAgree.value) {
    showToast("请先勾选签名同意书");
    return;
  }

  uni.canvasToTempFilePath(
    {
      canvasId: "signatureCanvas",
      success: async (res) => {
        const path = res.tempFilePath;

        /* #ifdef APP-NVUE ||APP-PLUS ||APP-PLUS-NVUE */
        uni.compressImage({
          src: path,
          rotate: 270,
          success: (response) => {
            isShowModal.value = false;
            previewImage.value = response.tempFilePath;
          },
        });
        /* #endif */

        /* #ifdef H5  */
        previewImage.value = await rotateToBase64File(path);
        isShowModal.value = false;
        /* #endif */

        /* #ifdef MP-WEIXIN */
        // 创建离屏Canvas
        const offCanvas: any = uni.createOffscreenCanvas({ type: "2d" });
        const ctx2d = offCanvas.getContext("2d");
        // 加载图片
        const img = offCanvas.createImage();
        img.src = path;
        img.onload = async () => {
          const w = img.width;
          const h = img.height;
          // 画布尺寸交换
          offCanvas.width = h;
          offCanvas.height = w;
          // 逆时针 90°
          ctx2d.translate(h / 2, w / 2);
          ctx2d.rotate(-90);
          ctx2d.drawImage(img, -w / 2, -h / 2, w, h);
          // 导出临时文件
          // eslint-disable-next-line no-undef
          wx.canvasToTempFilePath({
            canvas: offCanvas,
            success: (response: { tempFilePath: string }) => {
              isShowModal.value = false;
              previewImage.value = response.tempFilePath;
            },
          });
        };
        /* #endif */
      },
    },
    instance,
  );
}

//重签
function reSign() {
  isDraw.value = false;
  isShowModal.value = true;
  previewImage.value = "";
  clear();
}

// #ifdef H5
/**
 * 将 Base64 图片逆时针旋转90度并转换为 File 对象
 * @param {string} base64 - Base64 字符串
 * @param {string} [mimeType] - 文件类型(可选,默认为 image/png)
 * @returns {Promise<string>} - 旋转后的 base64 图片
 */
async function rotateToBase64File(base64: string, mimeType: string = "image/png"): Promise<string> {
  const img = new Image();
  img.src = base64;

  await new Promise<void>((resolve, reject) => {
    img.onload = () => resolve();
    img.onerror = () => reject(new Error("图片加载失败"));
  });

  const canvas = document.createElement("canvas");
  canvas.width = img.height;
  canvas.height = img.width;

  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("无法获取画布上下文");

  ctx.translate(canvas.width / 2, canvas.height / 2);
  ctx.rotate(-Math.PI / 2); // 逆时针旋转90度
  ctx.drawImage(img, -img.width / 2, -img.height / 2);

  return canvas.toDataURL(mimeType);
}
// #endif

function showToast(msg: string) {
  toastMsg.value = msg;
  isShowToast.value = true;

  setTimeout(() => (isShowToast.value = false), 1500);
}

/* 禁止/恢复页面滚动 */
function lockScroll() {
  /* #ifdef H5 */
  document.body.classList.add("lock-scroll");
  /* #endif */
}

function unlockScroll() {
  /* #ifdef H5 */
  document.body.classList.remove("lock-scroll");
  /* #endif */
}
</script>

<style lang="scss" scoped>
.signature-empty {
  width: 100%;
  box-sizing: border-box;
  padding: 20rpx;

  .empty-blank {
    width: 100%;
    height: 400rpx;
    border: 4rpx dashed #c4c4c4;
    box-sizing: border-box;
    border-radius: 20rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #c4c4c4;
  }
}

.signature-preview {
  width: 100%;
  box-sizing: border-box;
  padding: 20rpx;

  .tool-bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 15rpx;

    .resign-btn {
      background-color: #2d99a1;
      color: #ffffff;
      font-size: 24rpx;
      padding: 10rpx 25rpx;
      border-radius: 40rpx;
    }
  }

  image {
    width: 100%;
    height: 400rpx;
    border: 4rpx dashed #c4c4c4;
    box-sizing: border-box;
    border-radius: 20rpx;
  }
}

.signature-modal {
  display: flex;
  justify-content: center;
  align-items: center;
  box-sizing: border-box;
  background: #f5f5f5;
  padding: 15rpx;
  height: 100%;
  z-index: 10;
  position: fixed;
  top: 0;

  /* 左侧工具栏 */
  .tool-bar {
    position: relative;
    width: 100rpx;
    height: 100rpx;
    transform: rotate(90deg);
    margin-right: 10rpx;
    display: flex;
    align-items: center;
    justify-content: center;

    .tool-wrapper {
      display: flex;

      .agreement-check {
        width: 500rpx;
        display: flex;
        justify-content: center;
        align-items: center;

        .check-icon {
          margin-right: 15rpx;
        }
        .agreement-label {
          font-size: 26rpx;
          color: #a6a6a6;
        }
        .agreement-link {
          font-size: 26rpx;
          color: #1684fc;
        }
      }

      .btn-group {
        display: flex;

        .btn {
          white-space: nowrap;
          padding: 10rpx 50rpx;
          border-radius: 28rpx;
          font-size: 26rpx;
          font-weight: 500;
          margin-right: 47rpx;
          display: flex;
          justify-content: center;
          align-items: center;

          &.btn-cancel {
            background: #ffffff;
            color: #666666;
          }
          &.btn-clear {
            background: #959595;
            color: #ffffff;
            margin-right: 15rpx;
          }
          &.btn-submit {
            background: #2d99a1;
            color: #ffffff;
          }
        }
      }
    }
  }

  /* 签名画布 */
  .signature-canvas {
    flex: 1;
    height: 100%;
    background-color: #fff;
    border-radius: 20rpx;
  }

  /* 右侧竖排标题 */
  .canvas-title {
    transform: translateY(-50%) rotate(90deg);
    font-size: 26rpx;
    font-weight: 400;
    color: #333;
  }
}

/* 横屏Toast  */
.landscape-toast {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) rotate(90deg);
  background-color: #585858;
  color: #ffffff;
  padding: 12px 20px;
  border-radius: 5px;
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  z-index: 9999;
  pointer-events: none;

  s .toast-content {
    display: flex;
    align-items: center;
    gap: 8px;
  }
}

.lock-scroll {
  overflow: hidden;
  height: 100vh;
}
</style>

实现效果预览:

参考资料