Ver código fonte

feat: 新增手写签名画布组件

xieyong 6 dias atrás
pai
commit
cc19398369
1 arquivos alterados com 432 adições e 0 exclusões
  1. 432 0
      src/components/upload/signCanvas.vue

+ 432 - 0
src/components/upload/signCanvas.vue

@@ -0,0 +1,432 @@
+<!--
+  Canvas 手写签名组件
+  - 点击「签名」弹出画布,手写完成后上传到文件服务,v-model 返回文件存储路径
+  - change 事件返回文件信息 { url, id, name },便于提交时带上文件 id
+-->
+<template>
+  <div class="sign-canvas">
+    <div class="sign-box">
+      <img
+        v-if="previewUrl"
+        :src="previewUrl"
+        class="sign-image"
+        :style="imageStyle"
+        alt="签名"
+      />
+      <div v-else class="sign-empty" :style="imageStyle">未签名</div>
+    </div>
+    <div class="sign-actions">
+      <el-button
+        size="mini"
+        icon="el-icon-edit"
+        :disabled="isDisabled"
+        @click="openDialog"
+        >{{ value ? '重新签名' : '签名' }}</el-button
+      >
+      <el-button
+        size="mini"
+        type="text"
+        :disabled="isDisabled || !value"
+        @click="clearSign"
+        >清除</el-button
+      >
+    </div>
+
+    <el-dialog
+      title="请在框内签名"
+      :visible.sync="visible"
+      width="560px"
+      append-to-body
+      @open="initCanvas"
+      @closed="resetCanvas"
+    >
+      <div class="sign-dialog">
+        <canvas
+          ref="canvas"
+          class="sign-board"
+          :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
+          @mousedown="onMouseDown"
+          @mousemove="onMouseMove"
+          @mouseup="onMouseUp"
+          @mouseleave="onMouseUp"
+          @touchstart.prevent="onTouchStart"
+          @touchmove.prevent="onTouchMove"
+          @touchend.prevent="onTouchUp"
+        ></canvas>
+      </div>
+      <div slot="footer">
+        <el-button size="small" @click="clearCanvas">清空</el-button>
+        <el-button size="small" @click="undo">撤销</el-button>
+        <el-button size="small" @click="visible = false">取消</el-button>
+        <el-button
+          size="small"
+          type="primary"
+          :loading="loading"
+          @click="confirm"
+          >确定</el-button
+        >
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+  import { uploadFile } from '@/api/system/file/index';
+  import { getImageUrl, getFileApiUrls } from '@/utils/file';
+  import request from '@/utils/request';
+
+  export default {
+    name: 'SignCanvas',
+    inject: {
+      // 表单禁用状态(查看模式下不可签名)
+      elForm: {
+        default: null
+      }
+    },
+    props: {
+      // 签名文件存储路径
+      value: {
+        type: String,
+        default: ''
+      },
+      // 所属模块
+      module: {
+        type: String,
+        default: 'main'
+      },
+      // 签名显示宽度
+      width: {
+        type: Number,
+        default: 180
+      },
+      // 签名显示高度
+      height: {
+        type: Number,
+        default: 100
+      },
+      // 画布宽度
+      canvasWidth: {
+        type: Number,
+        default: 480
+      },
+      // 画布高度
+      canvasHeight: {
+        type: Number,
+        default: 200
+      },
+      disabled: {
+        type: Boolean,
+        default: false
+      }
+    },
+    data() {
+      return {
+        visible: false,
+        loading: false,
+        drawing: false,
+        // 笔画记录,每个元素是一串坐标点,用于撤销与重绘
+        strokes: [],
+        // 当前正在书写的笔画
+        current: null,
+        // 签名预览地址(直链或 blob 地址)
+        previewUrl: ''
+      };
+    },
+    computed: {
+      // 表单禁用时不可签名
+      isDisabled() {
+        return this.disabled || !!(this.elForm && this.elForm.disabled);
+      },
+      imageStyle() {
+        return {
+          width: this.width + 'px',
+          height: this.height + 'px'
+        };
+      }
+    },
+    watch: {
+      value: {
+        immediate: true,
+        handler() {
+          this.resolvePreview();
+        }
+      }
+    },
+    beforeDestroy() {
+      this.revokePreview();
+    },
+    methods: {
+      // 解析签名预览地址
+      // 主应用注入了 sessionStorage.filePath 时直接用带 token 的直链;
+      // 否则退回带 token 的接口拉取文件,转成 blob 地址显示
+      async resolvePreview() {
+        const path = this.value;
+        if (!path) {
+          this.revokePreview();
+          return;
+        }
+        if (path.indexOf('data:') === 0 || /^https?:\/\//i.test(path)) {
+          this.revokePreview();
+          this.previewUrl = path;
+          return;
+        }
+        if (sessionStorage.filePath) {
+          this.revokePreview();
+          this.previewUrl = getImageUrl(path);
+          return;
+        }
+        const urls = getFileApiUrls({ storePath: path });
+        for (const url of urls) {
+          try {
+            const res = await request.get(url, {
+              responseType: 'blob'
+            });
+            if (res.data && res.data.size) {
+              this.revokePreview();
+              this.previewUrl = URL.createObjectURL(res.data);
+              return;
+            }
+          } catch (e) {
+            // 当前地址不可用,继续尝试下一个
+          }
+        }
+        // 拉取失败时保留原有预览(例如刚签完的本地图片)
+      },
+      // 释放 blob 地址
+      revokePreview() {
+        if (this.previewUrl && this.previewUrl.indexOf('blob:') === 0) {
+          URL.revokeObjectURL(this.previewUrl);
+        }
+        this.previewUrl = '';
+      },
+      openDialog() {
+        this.visible = true;
+      },
+      // 初始化画布
+      initCanvas() {
+        this.$nextTick(() => {
+          const canvas = this.$refs.canvas;
+          if (!canvas) {
+            return;
+          }
+          const dpr = window.devicePixelRatio || 1;
+          canvas.width = this.canvasWidth * dpr;
+          canvas.height = this.canvasHeight * dpr;
+          const ctx = canvas.getContext('2d');
+          ctx.setTransform(1, 0, 0, 1, 0, 0);
+          ctx.scale(dpr, dpr);
+          ctx.lineWidth = 2.5;
+          ctx.lineCap = 'round';
+          ctx.lineJoin = 'round';
+          ctx.strokeStyle = '#303133';
+          this.strokes = [];
+          this.current = null;
+          this.drawing = false;
+        });
+      },
+      resetCanvas() {
+        this.strokes = [];
+        this.current = null;
+        this.drawing = false;
+      },
+      // 取画布坐标(按显示尺寸换算,兼容高清屏)
+      getPoint(e) {
+        const canvas = this.$refs.canvas;
+        if (!canvas) {
+          return { x: 0, y: 0 };
+        }
+        const rect = canvas.getBoundingClientRect();
+        const touch = e.touches && e.touches.length ? e.touches[0] : e;
+        return {
+          x: (touch.clientX - rect.left) * (this.canvasWidth / rect.width),
+          y: (touch.clientY - rect.top) * (this.canvasHeight / rect.height)
+        };
+      },
+      getContext() {
+        const canvas = this.$refs.canvas;
+        return canvas ? canvas.getContext('2d') : null;
+      },
+      // 画一段线
+      drawLine(from, to) {
+        const ctx = this.getContext();
+        if (!ctx) {
+          return;
+        }
+        ctx.beginPath();
+        ctx.moveTo(from.x, from.y);
+        ctx.lineTo(to.x, to.y);
+        ctx.stroke();
+      },
+      onMouseDown(e) {
+        this.startStroke(this.getPoint(e));
+      },
+      onMouseMove(e) {
+        if (!this.drawing) {
+          return;
+        }
+        this.moveStroke(this.getPoint(e));
+      },
+      onMouseUp() {
+        this.endStroke();
+      },
+      onTouchStart(e) {
+        this.startStroke(this.getPoint(e));
+      },
+      onTouchMove(e) {
+        if (!this.drawing) {
+          return;
+        }
+        this.moveStroke(this.getPoint(e));
+      },
+      onTouchUp() {
+        this.endStroke();
+      },
+      startStroke(point) {
+        this.drawing = true;
+        this.current = [point];
+        // 落笔时画一个点,避免轻点没有痕迹
+        this.drawLine(point, point);
+      },
+      moveStroke(point) {
+        if (!this.current) {
+          return;
+        }
+        const last = this.current[this.current.length - 1];
+        this.current.push(point);
+        this.drawLine(last, point);
+      },
+      endStroke() {
+        if (this.drawing && this.current && this.current.length) {
+          this.strokes.push(this.current);
+        }
+        this.current = null;
+        this.drawing = false;
+      },
+      // 清空画布
+      clearCanvas() {
+        const ctx = this.getContext();
+        if (ctx) {
+          ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
+        }
+        this.strokes = [];
+        this.current = null;
+        this.drawing = false;
+      },
+      // 撤销上一笔
+      undo() {
+        if (!this.strokes.length) {
+          return;
+        }
+        this.strokes.pop();
+        this.redraw();
+      },
+      // 按笔画记录重绘
+      redraw() {
+        const ctx = this.getContext();
+        if (!ctx) {
+          return;
+        }
+        ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
+        this.strokes.forEach((stroke) => {
+          if (!stroke || !stroke.length) {
+            return;
+          }
+          ctx.beginPath();
+          ctx.moveTo(stroke[0].x, stroke[0].y);
+          stroke.forEach((point) => ctx.lineTo(point.x, point.y));
+          ctx.stroke();
+        });
+      },
+      // 清除已签内容
+      clearSign() {
+        this.$emit('input', '');
+        this.$emit('change', { url: '', id: '', name: '' });
+      },
+      // 确认签名并上传
+      async confirm() {
+        if (!this.strokes.length) {
+          this.$message.warning('请先签名');
+          return;
+        }
+        const canvas = this.$refs.canvas;
+        if (!canvas) {
+          return;
+        }
+        this.loading = true;
+        try {
+          const blob = await new Promise((resolve) => {
+            canvas.toBlob(resolve, 'image/png');
+          });
+          if (!blob) {
+            this.$message.error('签名生成失败');
+            return;
+          }
+          const name = `signature_${Date.now()}.png`;
+          const file = new File([blob], name, { type: 'image/png' });
+          // 先用本地图片即时回显,上传成功后换成服务端地址
+          this.revokePreview();
+          this.previewUrl = URL.createObjectURL(blob);
+          const res = await uploadFile({
+            module: this.module,
+            multiPartFile: file
+          });
+          const data = res && res.data;
+          const path =
+            (data && (data.storePath || data.url || data.path)) || '';
+          if (path) {
+            this.$emit('input', path);
+            this.$emit('change', {
+              url: path,
+              id: (data && data.id) || '',
+              name
+            });
+            this.visible = false;
+          } else {
+            this.$message.error('签名上传失败,请重试');
+          }
+        } catch (e) {
+          this.$message.error('签名上传失败,请重试');
+        } finally {
+          this.loading = false;
+        }
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .sign-canvas {
+    display: flex;
+    align-items: flex-start;
+    .sign-box {
+      border: 1px dashed #dcdfe6;
+      border-radius: 4px;
+      background: #fff;
+      overflow: hidden;
+    }
+    .sign-image {
+      display: block;
+      object-fit: contain;
+    }
+    .sign-empty {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      color: #c0c4cc;
+      font-size: 12px;
+    }
+    .sign-actions {
+      margin-left: 8px;
+    }
+  }
+  .sign-dialog {
+    text-align: center;
+    .sign-board {
+      border: 1px dashed #dcdfe6;
+      border-radius: 4px;
+      background: #fff;
+      cursor: crosshair;
+      touch-action: none;
+    }
+  }
+</style>