|
|
@@ -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>
|