【Vue】 签名组件
一、需求背景:
检查业务,检查完成后,执行人需要签字证明检查完成
二、实现效果:
三、技术实现
通过canvas转换成blob对象,可以上传到文件服务,或者是下载另存为到本地磁盘
注意重点,canvas的样式的宽高和dom对象宽高一定要一致才可以,否则无法在面板绘制线条!
<template> <el-dialog title="签名面板" :close-on-click-modal="false" append-to-body :visible.sync="visible" class="JNPF-dialog JNPF-dialog_center" lock-scroll width="600px"> <canvas id="signatureCanvas" style="width: 500px; height: 300px;"></canvas> <span slot="footer" class="dialog-footer"> <el-button @click="clearCanvas"> 清 除</el-button> <el-button @click="visible = false"> 取 消</el-button> <el-button type="primary" @click="dataFormSubmit()" :loading="btnLoading"> 确 定</el-button> </span> </el-dialog> </template> <script> import request from '@/utils/request' export default { name: 'SignPanel', components: {}, props: { pathType: { type: String, require: true, default: 'annexpic' }, apiData: { type: Object, require: true, default: () => {} } }, data() { return { visible: false, btnLoading: false, canvasInstance: null, canvasContext: null, drawing: false, lastX: false, lastY: false } }, mounted() { }, methods: { init() { this.visible = true this.$nextTick(() => { this.initialCanvas() }) }, initialCanvas() { // 获取画布元素和上下文对象 const canvas = document.getElementById('signatureCanvas') const ctx = canvas.getContext('2d'); this.canvasInstance = canvas this.canvasContext = ctx // 设置 canvas 的宽度和高度 canvas.width = 500; // 根据需要设置 canvas.height = 300; // 根据需要设置 // // 初始化变量 this.drawing = false this.lastX = 0 this.lastY = 0 const _that = this; // 处理鼠标按下事件 canvas.addEventListener('mousedown', (e) => { _that.lastX = e.offsetX _that.lastY = e.offsetY _that.drawing=true; }) // 处理鼠标移动事件 canvas.addEventListener('mousemove', (e) => { if (!_that.drawing) return ctx.beginPath() ctx.moveTo(_that.lastX, _that.lastY) ctx.lineTo(e.offsetX , e.offsetY) ctx.stroke() ctx.closePath() _that.lastX = e.offsetX _that.lastY = e.offsetY }) // 处理鼠标松开事件 canvas.addEventListener('mouseup', (e) => { _that.drawing = false }) // 处理鼠标离开事件 canvas.addEventListener('mouseout', () => { _that.drawing = false }) }, dataFormSubmit() { this.toUploadSignPic() }, dataURLtoBlob(dataUrl) { const arr = dataUrl.split(','), mime = arr[0].match(/:(.*?)/)[1] const bstr = atob(arr[1]) let n = bstr.length const u8arr = new Uint8Array(n) while(n--){ u8arr[n] = bstr.charCodeAt(n) } return new Blob([u8arr], {type:mime}) }, toUploadSignPic() { const dataUrl = this.canvasInstance.toDataURL('image/png') const blobData = this.dataURLtoBlob(dataUrl) // 创建 File 对象 const fileName = 'image.png' const file = new File([blobData], fileName, {type: 'image/png'}) const formData = new FormData() formData.append('file', file) this.uploadSignApi(formData).then(res => { if (res.code !== 200) return this.$emit('whenSuccess', res, file) this.visible = false }).catch(err => { this.$emit('whenError', err, file) this.visible = false }) }, toDownloadSignPic() { const dataUrl = this.canvasInstance.toDataURL('image/png') const link = document.createElement('a') link.href = dataUrl link.download = 'signature.png' link.click() }, clearCanvas() { this.canvasContext.clearRect(0, 0, this.canvasInstance.width, this.canvasInstance.height) }, uploadSignApi(formData) { const apiPath = `/api/file/Uploader/${this.pathType}` const param = this.apiData Object.keys(param).forEach(key => ( formData.append(key, param[key]) )) return request({ url: apiPath, method: 'POST', headers: { 'Content-Type': 'multipart/form-data' }, data: formData }) } } } </script> <style scoped lang="scss"> canvas { border: 1px solid #DCDFE6; cursor: crosshair; border-radius: 5px; } </style>
因为兼容现有系统的组件,我而外将框架自带的图片上传改造成签名上传组件
图片上传组件点击时一定会选取本地文件,为了解决这个问题我是选择直接隐藏了上传组件
改为追加了一个签名面板按钮,面板确认时,发射器回调到组件上传成功的回调
因为签名只存在一份,所以文件数量限制1即可,通过上传成功的回调就能拦截处理
<template> <div class="UploadFile-container"> <el-button @click="openSignPanel" style="margin-right: 5px;">打开签名</el-button> <template v-if="fileList.length"> <transition-group class="el-upload-list el-upload-list--picture-card" tag="ul" name="el-list"> <li class="el-upload-list__item is-success" v-for="(file,index) in fileList" :key="file.fileId"> <el-image :src="define.comUrl+file.url" class="el-upload-list__item-thumbnail" :preview-src-list="getImgList(fileList)" :z-index="10000" :ref="'image'+index"> </el-image> <span class="el-upload-list__item-actions"> <span class="el-upload-list__item-preview" @click="handlePictureCardPreview(index)"> <i class="el-icon-zoom-in"></i> </span> <span v-if="!disabled" class="el-upload-list__item-delete" @click="handleRemove(index)"> <i class="el-icon-delete"></i> </span> </span> </li> </transition-group> </template> <template v-if="!detailed"> <el-upload v-show="false" :action="define.comUploadUrl+'/'+type" :headers="uploadHeaders" :data="params" ref="elUpload" :on-success="handleSuccess" :multiple="limit!==1" :show-file-list="false" accept="image/*" :before-upload="beforeUpload" :disabled="disabled" list-type="picture-card" :auto-upload="false" class="upload-btn"> <i slot="default" class="el-icon-plus" disabled></i> </el-upload> </template> <template> <div class="el-upload__tip" slot="tip" v-if="tipText">{{ tipText }}</div> </template> <sign-panel :visible.sync="signPaneVisible" :path-type="type" :api-data="params" ref="signForm" @whenSuccess="signUploadSuccess" @whenError="signUploadError" /> </div> </template> <script> import emitter from 'element-ui/src/mixins/emitter' import SignPanel from '@/components/Generator/components/Upload/SignPanel.vue' import BigForm from '@/views/dp-mng/scr-se-check/big-form.vue' let { methods: { dispatch } } = emitter const units = { KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024 } export default { name: 'UploadSign', components: { BigForm, SignPanel }, props: { value: { type: Array, default: () => [] }, type: { type: String, default: 'annexpic' }, disabled: { type: Boolean, default: false }, detailed: { type: Boolean, default: false }, showTip: { type: Boolean, default: false }, limit: { type: Number, default: 0 }, accept: { type: String, default: 'image/*' }, sizeUnit: { type: String, default: 'MB' }, pathType: { type: String, default: 'defaultPath' }, isAccount: { type: Number, default: 0 }, folder: { type: String, default: '' }, fileSize: { default: 10 }, tipText: { type: String, default: '' } }, data() { return { signPaneVisible: false, fileList: [], uploadHeaders: { Authorization: this.$store.getters.token }, } }, watch: { value: { immediate: true, handler(val) { this.fileList = Array.isArray(val) ? val : [] } } }, computed: { params() { return { pathType: this.pathType, isAccount: this.isAccount, folder: this.folder } } }, methods: { signUploadSuccess(result, file) { this.handleSuccess(result, file, this.$refs.elUpload.fileList) }, signUploadError() { console.log(result) }, openSignPanel() { this.signPaneVisible = true this.$refs.signForm.init() }, beforeUpload(file) { if (this.fileList.length >= this.limit) { this.handleExceed() return false } const unitNum = units[this.sizeUnit]; if (!this.fileSize) return true let isRightSize = file.size / unitNum < this.fileSize if (!isRightSize) { this.$message.error(`图片大小超过${this.fileSize}${this.sizeUnit}`) return isRightSize; } let isAccept = new RegExp('image/*').test(file.type) if (!isAccept) { this.$message.error(`请上传图片`) return isAccept; } return isRightSize && isAccept; }, handleSuccess(res, file, fileList) { if (this.fileList.length >= this.limit) return this.handleExceed() if (res.code == 200) { this.fileList.push({ name: file.name, fileId: res.data.name, url: res.data.url }) this.$emit('input', this.fileList) this.$emit('change', this.fileList) dispatch.call(this, 'ElFormItem', 'el.form.change', this.fileList) } else { this.$refs.elUpload.uploadFiles.splice(fileList.length - 1, 1) fileList.filter(o => o.uid != file.uid) this.$emit('input', this.fileList) this.$emit('change', this.fileList) dispatch.call(this, 'ElFormItem', 'el.form.change', this.fileList) this.$message({ message: res.msg, type: 'error', duration: 1500 }) } }, handleExceed(files, fileList) { this.$message.warning(`当前限制最多可以上传${this.limit}张图片`) }, handlePictureCardPreview(index) { this.$refs['image' + index][0].clickHandler() }, handleRemove(index) { this.fileList.splice(index, 1) this.$refs.elUpload.uploadFiles.splice(index, 1) this.$emit("input", this.fileList) this.$emit('change', this.fileList) dispatch.call(this, 'ElFormItem', 'el.form.change', this.fileList) }, getImgList(list) { const newList = list.map(o => this.define.comUrl + o.url) return newList } } } </script> <style lang="scss" scoped> >>> .el-upload-list--picture-card .el-upload-list__item { width: 120px; height: 120px; } >>> .el-upload--picture-card { width: 120px; height: 120px; line-height: 120px; } .upload-btn { display: inline-block; } .el-upload__tip { color: #a5a5a5; word-break: break-all; line-height: 1.3; margin-top: 5px; } // .el-upload-list--picture-card { // display: inline-block; // height: 0; // } </style>
表单效果:
四、2024年04月02日更新:
发现复用签名组件时,第一个签名完成后,第二个签名无法绘制了
猜测是引用丢失问题,id唯一,第一次使用后,第二次就还是指向这个id的对象
绘制时的dom元素还是之前那个对象
所以解决办法是每次签名都用新的dom元素
五、关于H5和小程序版本的签名组件
直接使用uniapp的API就可以实现效果,在uniapp里面不能再通过dom元素抓取了
详细API说明见:
https://uniapp.dcloud.net.cn/component/canvas.html#canvas
组件代码:check-sign.vue
<template> <view> <van-overlay :show="initialCanvasFlag" z-index="999999"> <div class="wrapper"> <van-loading class="loading-icon" type="spinner" size="32px" text-size="18px" color="#1989fa">加载中...</van-loading> </div> </van-overlay> <view v-show="!initialCanvasFlag"> <view class="canvas-tank"> <canvas canvas-id="signatureCanvas" id="signatureCanvas" @touchstart="startDrawing" @touchmove="draw" @touchend="stopDrawing"></canvas> </view> <view class="form-footer"> <van-row style="width: 100%;" justify="space-around" gutter="10"> <van-col span="8" class="form-footer-btn"> <van-button round block type="default" @click="cancelSignature">取消</van-button> </van-col> <van-col span="8" class="form-footer-btn"> <van-button round block type="default" @click="clearSignature">清空</van-button> </van-col> <van-col span="8" class="form-footer-btn"> <van-button round block type="primary" :loading="uploadLoading" @click="saveSignature">确定</van-button> </van-col> </van-row> </view> </view> </view> </template> <script> import { uploadPic } from '@/api/jnpf/file' export default { data() { return { uploadLoading: false, initialCanvasFlag: false, canvasIsDrawing: false, canvasInstance: null, canvasContext: null, canvasWidth: 0, canvasHeight: 0, lastX: 0, // 上一个点的 X 坐标 lastY: 0, // 上一个点的 Y 坐标 flag: '', flagMap: { '1': 'whenSignConfirm', '2': 'whenSign2Confirm' }, flagCancelMap: { '1': 'whenSignCancel', '2': 'whenSign2Cancel' } } }, onReady() { this.initialCanvas() }, onLoad(options) { this.flag = options.flag }, methods: { /* ------------- canvasEvent start ---------------- */ startDrawing(event) { this.canvasIsDrawing = true const { x, y } = event.touches[0] this.lastX = x this.lastY = y }, draw(event) { if (!this.canvasIsDrawing) return const { x, y } = event.touches[0] const currentX = x const currentY = y this.canvasContext.beginPath() this.canvasContext.moveTo(this.lastX, this.lastY) this.canvasContext.lineTo(currentX, currentY) this.canvasContext.setStrokeStyle('black') this.canvasContext.setLineWidth(5) this.canvasContext.stroke() this.canvasContext.draw(true) this.lastX = currentX this.lastY = currentY }, stopDrawing(event) { if (!this.canvasIsDrawing) return this.canvasIsDrawing = false this.canvasContext.draw(true) // 刷新画布 }, /* ------------- canvasEvent end ---------------- */ initialCanvas() { this.initialCanvasFlag = true /* 动态获取canvas画布的宽高 */ const { windowWidth, windowHeight } = uni.getSystemInfoSync() this.canvasWidth = windowWidth this.canvasHeight = windowHeight - 110 console.log(`sys ${windowWidth}, ${windowHeight}`) console.log(`canvas ${this.canvasWidth}, ${this.canvasHeight}`) /* 对象绑定 */ // const ctx = canvas.getContext('2d') // this.canvasInstance = canvas this.canvasContext = uni.createCanvasContext('signatureCanvas') this.initialCanvasFlag = false }, clearSignature() { this.canvasContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight) this.canvasContext.draw(true) }, cancelSignature() { uni.navigateBack() }, saveSignature() { this.uploadLoading = true const timestamp = new Date().getTime() uni.canvasToTempFilePath({ canvasId: 'signatureCanvas', fileType: 'jpg', // 输出的图片格式 quality: 1, // 图片的质量,范围0~1,默认值为1 success: (res) => { // 这里可以将 tempFilePath 保存到本地或者上传到服务器 const dataUrl = res.tempFilePath // 创建 File 对象 const fileName = `sign-${timestamp}.png` uploadPic(dataUrl,fileName).then(res => { uni.showToast({ title: '签名已保存', icon: 'success' }) this.uploadLoading = false }).catch(err => { // console.error(err) uni.showToast({ title: '保存签名成功', icon: 'none' }) this.uploadLoading = false /* 给上一页传递数据 */ let pages = getCurrentPages() let prevPage = pages[pages.length - 2] const callBackMethodName = this.flagMap[this.flag] prevPage.$vm[callBackMethodName](err, fileName) uni.navigateBack() }) }, fail: (err) => { console.error(err) uni.showToast({ title: '保存签名失败', icon: 'none' }) this.uploadLoading = false /* 给上一页传递数据 */ let pages = getCurrentPages() let prevPage = pages[pages.length - 2] const cancelCallBackMethodName = this.flagCancelMap[this.flag] prevPage.$vm[cancelCallBackMethodName](err) uni.navigateBack() } }) }, dataURLtoBlob(dataUrl) { const arr = dataUrl.split(','), mime = arr[0].match(/:(.*?)/)[1] const bstr = atob(arr[1]) let n = bstr.length const u8arr = new Uint8Array(n) while(n--) u8arr[n] = bstr.charCodeAt(n) return new Blob([u8arr], { type: mime }) }, } } </script> <style scoped> .form-footer { position: fixed; width: 100vw; padding: 10px 5px 10px 5px; bottom: 0; background-color: #fff; } .canvas-tank { width: 100vw; height: calc(100vh - 110px); background-color: #fff; /* border: 1px solid black; */ } #signatureCanvas { width: 100vw; height: calc(100vh - 110px); } .wrapper { display: flex; align-items: center; justify-content: center; height: 100vh; width: 100vw; } .loading-icon { width: 120px; height: 120px; } </style>
实现效果:
先上传到后台服务,响应文件存储信息
接口不是uni原生的,用的是Alova接口请求,有做Uni的适配参数
/* 图片上传接口 */ export const uploadPic = (filePath,fileName) => { const type = 'annexpic' const defaultPara = { pathType: 'defaultPath', isAccount: 0, folder: '' } return requestJnpf.Post( `${uploadPath}/${type}`, { filePath, fileName, ... defaultPara }, { requestType: 'upload', fileType: 'image' }) .send(); }
关于Alova的一些文档
1、默认会开启响应缓存,要配置禁用缓存
https://alova.js.org/zh-CN/tutorial/cache/mode
2、文件上传有uni适配
https://alova.js.org/zh-CN/tutorial/request-adapter/alova-adapter-uniapp/
结合Vant上传组件的方式:
重写上传组件的上传事件
<van-field name="signPicList" label="责任单位签字" readonly :rules="rules.signPicList" required> <template #input> <van-uploader v-model="dataForm.signPicList" @delete="whenSignDelete" @click-upload="whenOpenSignUpload"> <van-button icon="plus" type="primary" size="mini">打开签名面板</van-button> </van-uploader> </template> </van-field> <van-field name="signPic2List" label="检查单位签字" readonly :rules="rules.signPic2List" required> <template #input> <van-uploader v-model="dataForm.signPic2List" @delete="whenSign2Delete" @click-upload="whenOpenSign2Upload"> <van-button icon="plus" type="primary" size="mini">打开签名面板</van-button> </van-uploader> </template> </van-field>
vant-upload的选取文件事件可以被阻止,这样可以跳转到签名组件了
whenSignDelete(val) { this.dataForm.signPic = [] this.dataForm.signPicList = [] }, whenOpenSignUpload(event) { /* 阻止默认的选取文件事件 */ event.preventDefault() /* 跳转到签字页面进行签字处理 */ uni.navigateTo({ url: `${this.signUrl}?flag=1`, animationType: 'slide-in-right', animationDuration: 200 }) },
表单样式:
六、是否签名的校验处理:
我的猜想和别人组件里的是差不多的,追加了一个是否绘制了的判断
uniApp 的api同理
七、签名回显处理
Web端通过canvasContext的drawImage绘制,入参需要提供Image元素对象,src声明图像来源
要设置图片对象的跨域属性,否则在重新提交签名的时候,canvas转换报错 图像被污染....
然后绘制方法要放到onload中才会触发
initialCanvasEcho() { if (!this.echoSrc) return const image = new Image() image.src = this.echoSrc image.setAttribute("crossOrigin",'anonymous') /* canvas跨域报错 */ image.onload = () => { this.canvasContext.drawImage(image, 0, 0) this.hasDraw = true } }
uniapp的canvas做好了一层封装,直接提供图片url即可:
initialCanvasEcho() { if (!this.signSrc) return this.canvasContext.drawImage(this.signSrc, 0, 0, this.canvasWidth, this.canvasHeight) this.canvasContext.draw(true) this.canvasHasDrawing = true }
但是这里发现的问题是回显只作用最开始的一次,后续不能回显了,问题暂定中....
签名回显处理的问题解决:
首次加载 onLoad -> onReady,后续加载 onReady -> onLoad
onLoad 先走就会收到图片地址,然后再调用echo方法回显是正常的
但是后续重复调用的时候,是onReady先触发的,echo方法在前,没判断到图片存在
所以解决办法是再onLoad事件频道收到回显事件后,补加一个回调处理的