<!-- components/simple-signature/index.vue -->
|
<template>
|
<u-modal
|
:show="showModal"
|
title="手写签名"
|
:showCancelButton="true"
|
confirmText="确认签名"
|
cancelText="取消"
|
:closeOnClickOverlay="false"
|
@confirm="handleConfirm"
|
@cancel="handleCancel"
|
>
|
<view class="signature-modal">
|
<!-- 工具栏 -->
|
<view class="toolbar">
|
<view class="toolbar-left">
|
<view class="color-selector">
|
<text class="label">颜色:</text>
|
<view
|
v-for="color in colors"
|
:key="color"
|
class="color-option"
|
:style="{ backgroundColor: color }"
|
:class="{ active: currentColor === color }"
|
@tap="selectColor(color)"
|
/>
|
</view>
|
<view class="width-selector">
|
<text class="label">粗细:</text>
|
<view
|
v-for="width in widths"
|
:key="width"
|
class="width-option"
|
:class="{ active: currentWidth === width }"
|
@tap="selectWidth(width)"
|
>
|
<view
|
class="width-demo"
|
:style="{
|
width: width * 6 + 'rpx',
|
height: width + 'px',
|
backgroundColor: currentColor
|
}"
|
/>
|
</view>
|
</view>
|
</view>
|
|
<view class="toolbar-right">
|
<button class="btn clear" @tap="clearCanvas">
|
<up-icon name="replay" size="16" />
|
<text>清空</text>
|
</button>
|
</view>
|
</view>
|
|
<!-- 签名区域 -->
|
<view class="signature-area">
|
<canvas
|
canvas-id="signatureCanvas"
|
id="signatureCanvas"
|
:style="{
|
width: canvasWidth + 'px',
|
height: canvasHeight + 'px'
|
}"
|
class="canvas"
|
@touchstart="onTouchStart"
|
@touchmove="onTouchMove"
|
@touchend="onTouchEnd"
|
></canvas>
|
<view v-if="!hasDrawing" class="hint">
|
<up-icon name="edit-pen" size="28" color="#999" />
|
<text>请在此区域签名</text>
|
</view>
|
</view>
|
|
<!-- 预览区域 -->
|
<view v-if="previewImage" class="preview-area">
|
<text class="preview-label">预览:</text>
|
<image
|
:src="previewImage"
|
mode="widthFix"
|
class="preview-img"
|
/>
|
<view v-if="currentFile" class="file-status">
|
<text :class="currentFile.status">{{ getStatusText(currentFile.status) }}</text>
|
</view>
|
</view>
|
|
<!-- 加载状态 -->
|
<view v-if="isLoading" class="loading">
|
<u-loading-icon :text="loadingText" />
|
</view>
|
</view>
|
</u-modal>
|
</template>
|
|
<script setup>
|
import { ref, computed, watch, nextTick, onMounted } from 'vue';
|
|
// 正确使用 defineProps
|
const props = defineProps({
|
show: {
|
type: Boolean,
|
default: false
|
},
|
canvasWidth: {
|
type: Number,
|
default: 650
|
},
|
canvasHeight: {
|
type: Number,
|
default: 300
|
},
|
uploadUrl: {
|
type: String,
|
default: '/api/common/upload'
|
},
|
extraParams: {
|
type: Object,
|
default: () => ({})
|
},
|
readonly: {
|
type: Boolean,
|
default: false
|
}
|
});
|
|
const emit = defineEmits([
|
'update:show',
|
'success',
|
'error',
|
'cancel',
|
'upload-success',
|
'upload-error'
|
]);
|
|
// 响应式数据
|
const showModal = ref(props.show);
|
const currentColor = ref('#000000');
|
const currentWidth = ref(3);
|
const previewImage = ref('');
|
const hasDrawing = ref(false);
|
const isLoading = ref(false);
|
const loadingText = ref('处理中...');
|
|
// 当前处理的文件
|
const currentFile = ref(null);
|
|
// Canvas上下文
|
let ctx = null;
|
let isDrawing = false;
|
let lastX = 0;
|
let lastY = 0;
|
|
// 配置
|
const colors = ['#000000', '#FF0000', '#0000FF', '#4CAF50'];
|
const widths = [1, 2, 3, 4, 5];
|
|
// 监听显示状态
|
watch(() => props.show, (val) => {
|
showModal.value = val;
|
if (val) {
|
// 延迟初始化,确保DOM已渲染
|
setTimeout(() => {
|
initCanvas();
|
}, 300);
|
}
|
});
|
|
watch(showModal, (val) => {
|
emit('update:show', val);
|
if (!val) {
|
// 关闭时重置状态
|
previewImage.value = '';
|
currentFile.value = null;
|
}
|
});
|
|
// 初始化Canvas
|
const initCanvas = () => {
|
console.log('初始化Canvas...');
|
console.log('Canvas尺寸:', props.canvasWidth, 'x', props.canvasHeight);
|
|
// 清空已有上下文
|
ctx = null;
|
|
// 重新获取Canvas上下文
|
ctx = uni.createCanvasContext('signatureCanvas', this);
|
|
if (!ctx) {
|
console.error('Canvas上下文获取失败');
|
uni.showToast({
|
title: 'Canvas初始化失败',
|
icon: 'error'
|
});
|
return;
|
}
|
|
console.log('Canvas上下文获取成功');
|
|
// 设置初始样式
|
ctx.setStrokeStyle(currentColor.value);
|
ctx.setLineWidth(currentWidth.value);
|
ctx.setLineCap('round');
|
ctx.setLineJoin('round');
|
ctx.setFillStyle('#FFFFFF');
|
ctx.fillRect(0, 0, props.canvasWidth, props.canvasHeight);
|
ctx.draw(true);
|
|
console.log('Canvas初始化完成');
|
|
// 重置状态
|
hasDrawing.value = false;
|
previewImage.value = '';
|
currentFile.value = null;
|
};
|
|
// 触摸开始
|
const onTouchStart = (e) => {
|
if (!ctx) {
|
console.error('Canvas上下文未初始化');
|
return;
|
}
|
|
console.log('触摸开始:', e.touches[0]);
|
isDrawing = true;
|
hasDrawing.value = true;
|
|
const touch = e.touches[0];
|
lastX = touch.x;
|
lastY = touch.y;
|
|
// 开始新路径
|
ctx.beginPath();
|
ctx.moveTo(lastX, lastY);
|
ctx.draw(true);
|
};
|
|
// 触摸移动
|
const onTouchMove = (e) => {
|
if (!isDrawing || !ctx) return;
|
|
const touch = e.touches[0];
|
const x = touch.x;
|
const y = touch.y;
|
|
// 绘制线条
|
ctx.lineTo(x, y);
|
ctx.stroke();
|
ctx.draw(true);
|
|
// 更新最后位置
|
lastX = x;
|
lastY = y;
|
};
|
|
// 触摸结束
|
const onTouchEnd = () => {
|
console.log('触摸结束');
|
isDrawing = false;
|
};
|
|
// 选择颜色
|
const selectColor = (color) => {
|
currentColor.value = color;
|
if (ctx) {
|
ctx.setStrokeStyle(color);
|
}
|
};
|
|
// 选择粗细
|
const selectWidth = (width) => {
|
currentWidth.value = width;
|
if (ctx) {
|
ctx.setLineWidth(width);
|
}
|
};
|
|
// 清空画布
|
const clearCanvas = () => {
|
if (!ctx) {
|
console.error('Canvas上下文未初始化');
|
return;
|
}
|
|
console.log('清空画布');
|
|
// 清空画布
|
ctx.setFillStyle('#FFFFFF');
|
ctx.fillRect(0, 0, props.canvasWidth, props.canvasHeight);
|
ctx.draw(true);
|
|
// 重置画笔样式
|
ctx.setStrokeStyle(currentColor.value);
|
ctx.setLineWidth(currentWidth.value);
|
ctx.setLineCap('round');
|
ctx.setLineJoin('round');
|
|
hasDrawing.value = false;
|
previewImage.value = '';
|
currentFile.value = null;
|
};
|
|
// 生成签名图片
|
const generateSignature = () => {
|
return new Promise((resolve, reject) => {
|
if (!hasDrawing.value) {
|
reject(new Error('请先签名'));
|
return;
|
}
|
|
console.log('开始生成签名图片...');
|
|
uni.canvasToTempFilePath({
|
canvasId: 'signatureCanvas',
|
success: (res) => {
|
console.log('生成签名图片成功:', res.tempFilePath);
|
|
const timestamp = Date.now();
|
const fileName = `signature_${timestamp}.png`;
|
|
// 创建文件对象
|
const fileObj = {
|
name: fileName,
|
originalFilename: fileName,
|
url: res.tempFilePath,
|
type: 'image/png',
|
file: {
|
path: res.tempFilePath,
|
name: fileName,
|
size: 0
|
},
|
status: 'pending',
|
attachmentType: 'signature'
|
};
|
|
currentFile.value = fileObj;
|
previewImage.value = res.tempFilePath;
|
|
resolve(fileObj);
|
},
|
fail: (err) => {
|
console.error('生成图片失败:', err);
|
reject(new Error('生成签名图片失败'));
|
}
|
});
|
});
|
};
|
|
// 获取状态文本
|
const getStatusText = (status) => {
|
const statusMap = {
|
'pending': '待上传',
|
'uploading': '上传中...',
|
'success': '上传成功',
|
'error': '上传失败'
|
};
|
return statusMap[status] || '';
|
};
|
|
// 上传签名文件
|
const uploadSignatureFile = async (file) => {
|
return new Promise((resolve, reject) => {
|
if (!file || !file.file || !file.file.path) {
|
reject(new Error('文件信息不完整'));
|
return;
|
}
|
|
// 更新状态
|
currentFile.value.status = 'uploading';
|
|
const token = uni.getStorageSync('token') || '';
|
|
uni.uploadFile({
|
url: props.uploadUrl,
|
filePath: file.file.path,
|
name: 'file',
|
formData: {
|
type: 'signature',
|
timestamp: Date.now(),
|
...props.extraParams
|
},
|
header: {
|
'Authorization': `Bearer ${token}`,
|
'X-Requested-With': 'XMLHttpRequest'
|
},
|
success: (uploadRes) => {
|
console.log('上传响应:', uploadRes);
|
|
if (uploadRes.statusCode === 200) {
|
try {
|
const res = JSON.parse(uploadRes.data);
|
|
if (res.code === 200) {
|
// 构建完整的文件信息
|
const fileData = {
|
...file,
|
url: res.fileName || res.data?.url || res.url,
|
fileName: res.fileName || file.name,
|
newFileName: res.newFileName,
|
originalFilename: res.originalFilename || file.name,
|
size: res.size || 0,
|
status: 'success',
|
serverData: res.data || res
|
};
|
|
currentFile.value = fileData;
|
resolve(fileData);
|
} else {
|
currentFile.value.status = 'error';
|
reject(new Error(res.msg || res.message || '上传失败'));
|
}
|
} catch (e) {
|
console.error('解析响应失败:', e);
|
currentFile.value.status = 'error';
|
reject(new Error('服务器响应格式错误'));
|
}
|
} else {
|
currentFile.value.status = 'error';
|
reject(new Error(`上传失败,状态码: ${uploadRes.statusCode}`));
|
}
|
},
|
fail: (err) => {
|
console.error('上传失败:', err);
|
currentFile.value.status = 'error';
|
reject(new Error(`上传失败: ${err.errMsg}`));
|
}
|
});
|
});
|
};
|
|
// 确认签名
|
const handleConfirm = async () => {
|
if (props.readonly) {
|
uni.showToast({
|
title: '只读模式,无法签名',
|
icon: 'none',
|
duration: 2000
|
});
|
return;
|
}
|
|
if (!hasDrawing.value) {
|
uni.showToast({
|
title: '请先签名',
|
icon: 'none',
|
duration: 2000
|
});
|
return;
|
}
|
|
isLoading.value = true;
|
loadingText.value = '处理中...';
|
|
try {
|
// 1. 生成签名图片
|
loadingText.value = '生成签名图片...';
|
const signatureFile = await generateSignature();
|
|
// 2. 上传签名图片
|
loadingText.value = '上传签名...';
|
const uploadResult = await uploadSignatureFile(signatureFile);
|
|
if (uploadResult.status === 'success') {
|
// 3. 显示成功提示
|
uni.showToast({
|
title: '签名保存成功',
|
icon: 'success',
|
duration: 2000
|
});
|
|
// 4. 触发成功事件
|
emit('upload-success', uploadResult);
|
emit('success', {
|
filePath: uploadResult.url,
|
serverData: uploadResult.serverData,
|
fileData: uploadResult
|
});
|
|
// 5. 延迟关闭弹窗
|
setTimeout(() => {
|
showModal.value = false;
|
isLoading.value = false;
|
}, 1500);
|
|
} else {
|
throw new Error('上传状态异常');
|
}
|
|
} catch (error) {
|
console.error('签名处理失败:', error);
|
isLoading.value = false;
|
|
uni.showToast({
|
title: error.message || '保存失败',
|
icon: 'error',
|
duration: 3000
|
});
|
|
emit('error', error);
|
emit('upload-error', {
|
error: error.message,
|
file: currentFile.value
|
});
|
}
|
};
|
|
// 取消签名
|
const handleCancel = () => {
|
showModal.value = false;
|
emit('cancel');
|
};
|
</script>
|
|
<style lang="scss" scoped>
|
.signature-modal {
|
padding: 20rpx;
|
box-sizing: border-box;
|
position: relative;
|
}
|
|
.toolbar {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 20rpx;
|
padding-bottom: 20rpx;
|
border-bottom: 1rpx solid #eee;
|
|
.toolbar-left {
|
display: flex;
|
flex-direction: column;
|
gap: 20rpx;
|
}
|
|
.label {
|
font-size: 24rpx;
|
color: #666;
|
margin-right: 10rpx;
|
}
|
}
|
|
.color-selector {
|
display: flex;
|
align-items: center;
|
|
.color-option {
|
width: 30rpx;
|
height: 30rpx;
|
border-radius: 50%;
|
margin-right: 15rpx;
|
border: 2rpx solid transparent;
|
transition: transform 0.2s;
|
|
&:active {
|
transform: scale(0.9);
|
}
|
|
&.active {
|
border-color: #007aff;
|
transform: scale(1.1);
|
}
|
}
|
}
|
|
.width-selector {
|
display: flex;
|
align-items: center;
|
|
.width-option {
|
width: 50rpx;
|
height: 40rpx;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-right: 10rpx;
|
border-radius: 8rpx;
|
|
&:active {
|
background-color: #f0f0f0;
|
}
|
|
&.active {
|
background-color: #e6f7ff;
|
border: 1rpx solid #007aff;
|
}
|
|
.width-demo {
|
border-radius: 2rpx;
|
}
|
}
|
}
|
|
.toolbar-right {
|
.btn {
|
padding: 8rpx 20rpx;
|
border: 1rpx solid #dcdfe6;
|
background: #fff;
|
border-radius: 8rpx;
|
font-size: 24rpx;
|
display: flex;
|
align-items: center;
|
gap: 6rpx;
|
|
&.clear {
|
color: #f56c6c;
|
border-color: #fde2e2;
|
}
|
|
&:active {
|
background-color: #f5f7fa;
|
}
|
}
|
}
|
|
.signature-area {
|
position: relative;
|
width: 100%;
|
height: 300rpx;
|
margin-bottom: 20rpx;
|
border: 2rpx dashed #dcdfe6;
|
border-radius: 12rpx;
|
background: #fff;
|
overflow: hidden;
|
|
.canvas {
|
width: 100%;
|
height: 100%;
|
display: block;
|
touch-action: none;
|
}
|
|
.hint {
|
position: absolute;
|
top: 50%;
|
left: 50%;
|
transform: translate(-50%, -50%);
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
gap: 10rpx;
|
color: #999;
|
font-size: 24rpx;
|
pointer-events: none;
|
}
|
}
|
|
.preview-area {
|
padding: 20rpx;
|
background: #f8f9fa;
|
border-radius: 8rpx;
|
margin-top: 20rpx;
|
|
.preview-label {
|
display: block;
|
font-size: 24rpx;
|
color: #666;
|
margin-bottom: 10rpx;
|
}
|
|
.preview-img {
|
width: 200rpx;
|
height: 80rpx;
|
border: 1rpx solid #dcdfe6;
|
border-radius: 4rpx;
|
background: #fff;
|
}
|
|
.file-status {
|
margin-top: 10rpx;
|
|
text {
|
font-size: 24rpx;
|
padding: 4rpx 12rpx;
|
border-radius: 4rpx;
|
display: inline-block;
|
|
&.pending {
|
background-color: #fff7e6;
|
color: #fa8c16;
|
}
|
|
&.uploading {
|
background-color: #e6f7ff;
|
color: #1890ff;
|
}
|
|
&.success {
|
background-color: #f6ffed;
|
color: #52c41a;
|
}
|
|
&.error {
|
background-color: #fff1f0;
|
color: #ff4d4f;
|
}
|
}
|
}
|
}
|
|
.loading {
|
position: absolute;
|
top: 0;
|
left: 0;
|
right: 0;
|
bottom: 0;
|
background: rgba(255, 255, 255, 0.9);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
border-radius: 12rpx;
|
z-index: 1000;
|
}
|
</style>
|