Files
hzdyjs/web/src/components/SignaturePad/index.vue
T
2025-10-24 15:18:41 +08:00

279 lines
6.3 KiB
Vue

<template>
<div class="signature-pad-wrapper">
<div class="signature-header">
<span class="signature-title">手写签名</span>
<el-button v-if="hasSignature" size="small" type="danger" :icon="Delete" @click="handleClear">清空</el-button>
</div>
<div class="signature-canvas-container">
<canvas
ref="canvasRef"
class="signature-canvas"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
></canvas>
<div v-if="!hasSignature" class="signature-placeholder">请在此处签名</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { Delete } from '@element-plus/icons-vue';
interface Props {
modelValue?: string; // Base64签名数据
width?: number;
height?: number;
lineWidth?: number;
lineColor?: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
width: 500,
height: 200,
lineWidth: 2,
lineColor: '#000000'
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
const canvasRef = ref<HTMLCanvasElement>();
const hasSignature = ref(false);
const isDrawing = ref(false);
let ctx: CanvasRenderingContext2D | null = null;
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
initCanvas();
// 如果有初始值,加载到画布上
if (props.modelValue) {
loadSignature(props.modelValue);
}
// 监听容器大小变化
if (canvasRef.value?.parentElement) {
resizeObserver = new ResizeObserver(() => {
handleResize();
});
resizeObserver.observe(canvasRef.value.parentElement);
}
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
// 监听外部值变化
watch(
() => props.modelValue,
(newVal) => {
if (newVal && !hasSignature.value) {
loadSignature(newVal);
}
}
);
const initCanvas = () => {
if (!canvasRef.value) return;
const canvas = canvasRef.value;
const container = canvas.parentElement;
// 获取容器的实际宽度
const containerWidth = container?.clientWidth || props.width;
// 设置画布的物理尺寸(实际像素)
canvas.width = containerWidth;
canvas.height = props.height;
// 同时设置CSS样式,确保显示尺寸与物理尺寸一致
canvas.style.width = `${containerWidth}px`;
canvas.style.height = `${props.height}px`;
ctx = canvas.getContext('2d');
if (ctx) {
ctx.strokeStyle = props.lineColor;
ctx.lineWidth = props.lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
};
// 处理容器大小变化
const handleResize = () => {
if (!canvasRef.value) return;
// 保存当前签名
const currentSignature = hasSignature.value ? canvasRef.value.toDataURL('image/png') : '';
// 重新初始化画布
initCanvas();
// 恢复签名
if (currentSignature && hasSignature.value) {
nextTick(() => {
loadSignature(currentSignature);
});
}
};
const startDrawing = (x: number, y: number) => {
if (!ctx || !canvasRef.value) return;
isDrawing.value = true;
const rect = canvasRef.value.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(x - rect.left, y - rect.top);
};
const draw = (x: number, y: number) => {
if (!isDrawing.value || !ctx || !canvasRef.value) return;
const rect = canvasRef.value.getBoundingClientRect();
ctx.lineTo(x - rect.left, y - rect.top);
ctx.stroke();
hasSignature.value = true;
};
const stopDrawing = () => {
if (!isDrawing.value) return;
isDrawing.value = false;
if (ctx) {
ctx.closePath();
}
// 导出签名数据
if (hasSignature.value && canvasRef.value) {
const signatureData = canvasRef.value.toDataURL('image/png');
emit('update:modelValue', signatureData);
}
};
// 鼠标事件处理
const handleMouseDown = (e: MouseEvent) => {
startDrawing(e.clientX, e.clientY);
};
const handleMouseMove = (e: MouseEvent) => {
draw(e.clientX, e.clientY);
};
const handleMouseUp = () => {
stopDrawing();
};
// 触摸事件处理(移动端支持)
const handleTouchStart = (e: TouchEvent) => {
e.preventDefault();
const touch = e.touches[0];
startDrawing(touch.clientX, touch.clientY);
};
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault();
const touch = e.touches[0];
draw(touch.clientX, touch.clientY);
};
const handleTouchEnd = (e: TouchEvent) => {
e.preventDefault();
stopDrawing();
};
// 清空签名
const handleClear = () => {
if (!canvasRef.value || !ctx) return;
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
hasSignature.value = false;
emit('update:modelValue', '');
};
// 加载签名
const loadSignature = (base64Data: string) => {
if (!canvasRef.value || !ctx) return;
const img = new Image();
img.onload = () => {
ctx!.clearRect(0, 0, canvasRef.value!.width, canvasRef.value!.height);
ctx!.drawImage(img, 0, 0);
hasSignature.value = true;
};
img.src = base64Data;
};
// 获取签名数据
const getSignature = (): string => {
if (!canvasRef.value || !hasSignature.value) return '';
return canvasRef.value.toDataURL('image/png');
};
// 对外暴露方法
defineExpose({
clear: handleClear,
getSignature,
hasSignature: () => hasSignature.value
});
</script>
<style scoped lang="scss">
.signature-pad-wrapper {
width: 100%;
.signature-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.signature-title {
font-size: 14px;
color: #606266;
font-weight: 500;
}
}
.signature-canvas-container {
position: relative;
width: 100%;
border: 1px solid #dcdfe6;
border-radius: 4px;
background-color: #ffffff;
overflow: hidden;
&:hover {
border-color: #c0c4cc;
}
.signature-canvas {
display: block;
width: 100%;
cursor: crosshair;
touch-action: none;
}
.signature-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #c0c4cc;
font-size: 14px;
pointer-events: none;
user-select: none;
}
}
}
</style>