引言
在移动应用开发中,手写签名功能是一个常见的需求,特别是在电子合同、审批流程、金融交易等场景中。本文将详细介绍如何基于uni-app框架开发一个高性能、功能丰富的手写签名组件,并分享开发过程中的技术要点和最佳实践。
组件概述
这个签名组件提供了完整的签名功能,包括:
-
平滑手写绘制体验
-
撤销/清空操作
-
保存签名到相册
-
实时预览签名
-
自动上传到服务器
-
多平台兼容(H5、小程序、App)
技术架构
封装依赖
// 工具类模块化设计
import { UploadManager } from '../utils/uploadManager.js'
import { showToast, showLoading, hideLoading } from '../utils/messageUtils.js'
import { deepMerge, validateConfig } from '../utils/validationUtils.js'
import { calculateDistance, calculateSpeed } from '../utils/mathUtils.js'
import { getCanvasSize, clearCanvasArea } from '../utils/canvasUtils.js'
组件参数设计
props: {
minSpeed: { type: Number, default: 1.5 }, // 最小速度阈值
minWidth: { type: Number, default: 3 }, // 最小线条宽度
maxWidth: { type: Number, default: 10 }, // 最大线条宽度
openSmooth: { type: Boolean, default: true }, // 是否开启平滑绘制
maxHistoryLength: { type: Number, default: 20 }, // 最大历史记录数
bgColor: { type: String, default: 'transparent' },// 画布背景色
uploadUrl: { type: String, default: '' }, // 上传地址
uploadConfig: { type: Object, default: () => ({}) } // 上传配置
}
核心技术实现
1. Canvas初始化与适配
initCanvas() {
try {
this.ctx = uni.createCanvasContext("handWriting", this);
this.$nextTick(() => {
// 获取容器实际尺寸
uni.createSelectorQuery().select('.handCenter').boundingClientRect(rect => {
this.canvasWidth = rect.width;
this.canvasHeight = rect.height;
// 设置背景色
if (this.bgColor && this.bgColor !== 'transparent') {
this.drawBgColor();
}
this.isCanvasReady = true;
}).exec();
});
} catch (error) {
// 异常处理和重试机制
setTimeout(() => this.initCanvas(), 500);
}
}
2. 平滑绘制算法
这是组件的核心功能,通过速度感知的线条宽度调整实现自然书写效果:
initPoint(x, y) {
const point = { x, y, t: Date.now() };
const prePoint = this.points.slice(-1)[0];
if (prePoint && this.openSmooth) {
// 计算与上一个点的距离和速度
point.distance = calculateDistance(point.x, point.y, prePoint.x, prePoint.y);
point.speed = calculateSpeed(point.distance, point.t - prePoint.t);
// 根据速度动态计算线条宽度
point.lineWidth = this.getLineWidth(point.speed);
// 限制宽度变化率,避免突变
const prePoint2 = this.points.slice(-2, -1)[0];
if (prePoint2 && prePoint2.lineWidth && prePoint.lineWidth) {
const rate = (point.lineWidth - prePoint.lineWidth) / prePoint.lineWidth;
const maxRate = this.maxWidthDiffRate / 100;
if (Math.abs(rate) > maxRate) {
const per = rate > 0 ? maxRate : -maxRate;
point.lineWidth = prePoint.lineWidth * (1 + per);
}
}
}
this.points.push(point);
this.points = this.points.slice(-3); // 只保留最近3个点
this.currentStroke.push(point);
}
3. 贝塞尔曲线绘制
为了实现平滑的书写效果,我们使用二次贝塞尔曲线进行绘制:
drawSmoothLine(prePoint, point) {
const dis_x = point.x - prePoint.x;
const dis_y = point.y - prePoint.y;
// 计算控制点
if (Math.abs(dis_x) + Math.abs(dis_y) <= 2) {
point.lastX1 = point.lastX2 = prePoint.x + dis_x * 0.5;
point.lastY1 = point.lastY2 = prePoint.y + dis_y * 0.5;
} else {
point.lastX1 = prePoint.x + dis_x * 0.3;
point.lastY1 = prePoint.y + dis_y * 0.3;
point.lastX2 = prePoint.x + dis_x * 0.7;
point.lastY2 = prePoint.y + dis_y * 0.7;
}
point.perLineWidth = (prePoint.lineWidth + point.lineWidth) / 2;
if (typeof prePoint.lastX1 === 'number') {
// 绘制曲线
this.drawCurveLine(prePoint.lastX2, prePoint.lastY2,
prePoint.x, prePoint.y,
point.lastX1, point.lastY1,
point.perLineWidth);
// 绘制梯形填充,确保线条连续性
if (!prePoint.isFirstPoint) {
const data = this.getRadianData(prePoint.lastX1, prePoint.lastY1,
prePoint.lastX2, prePoint.lastY2);
const points1 = this.getRadianPoints(data, prePoint.lastX1, prePoint.lastY1,
prePoint.perLineWidth / 2);
const points2 = this.getRadianPoints(data, prePoint.lastX2, prePoint.lastY2,
point.perLineWidth / 2);
this.drawTrapezoid(points1[0], points2[0], points2[1], points1[1]);
}
} else {
point.isFirstPoint = true;
}
}
4. 撤销功能实现
撤销功能通过维护绘制历史记录实现:
// 添加历史记录
addHistory() {
if (this.currentStroke.length > 0) {
const strokeData = {
points: JSON.parse(JSON.stringify(this.currentStroke)),
color: this.lineColor,
baseLineWidth: this.maxWidth,
minWidth: this.minWidth,
maxWidth: this.maxWidth,
openSmooth: this.openSmooth,
minSpeed: this.minSpeed,
timestamp: Date.now()
};
this.drawingHistory.push(strokeData);
// 限制历史记录长度
if (this.drawingHistory.length > this.maxHistoryLength) {
this.drawingHistory = this.drawingHistory.slice(-this.maxHistoryLength);
}
this.currentStroke = [];
}
}
// 撤销操作
undo() {
if (this.drawingHistory.length > 0) {
// 移除最后一个笔画
this.drawingHistory.pop();
// 清空画布并重绘所有剩余笔画
this.clearCanvas();
this.redrawAllStrokes();
showToast('已撤销上一步', 'success', 1500);
}
}
5. 跨平台保存策略
针对不同平台采用不同的保存策略:
performSave() {
uni.canvasToTempFilePath({
canvasId: 'handWriting',
fileType: 'png',
quality: 1,
success: (res) => {
// H5环境使用下载方式
// #ifdef H5
const link = document.createElement('a');
link.download = `signature_${Date.now()}.png`;
link.href = res.tempFilePath;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// #endif
// 小程序环境保存到相册
// #ifndef H5
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
showToast('已成功保存到相册', 'success', 2000);
},
fail: (saveError) => {
// 处理权限问题
if (saveError.errMsg.includes('auth')) {
showModal('保存失败', '需要相册权限,请在设置中开启', '去设置')
.then((modalRes) => {
if (modalRes.confirm) uni.openSetting();
});
}
}
});
// #endif
}
}, this);
}
6. 上传管理器类
/**
* 上传管理器类
* 负责处理文件上传的完整流程,包括配置验证、上传执行、重试机制、错误处理等
*/
export class UploadManager {
constructor() {
// 上传状态管理
this.uploadState = {
isUploading: false,
currentRetry: 0,
lastError: null,
uploadStartTime: null,
canRetry: true
};
}
/**
* 执行文件上传
* @param {string} filePath - 文件路径
* @param {Object} config - 上传配置
* @returns {Promise} 上传结果
*/
async performUpload(filePath, config) {
if (!filePath) {
throw new Error('文件路径不能为空');
}
if (!config || !config.uploadUrl) {
throw new Error('上传配置或上传地址不能为空');
}
this.uploadState.isUploading = true;
this.uploadState.error = null;
try {
const result = await this._uploadWithUni(filePath, config);
return await this.handleUploadSuccess(result, config);
} catch (error) {
return await this.handleUploadError(error, filePath, config);
}
}
/**
* 处理上传成功
* @param {Object} result - 上传结果
* @param {Object} config - 上传配置
* @returns {Object} 处理后的结果
*/
handleUploadSuccess(result, config) {
this.uploadState.isUploading = false;
this.uploadState.retryCount = 0;
let fileUrl = null;
try {
// 尝试解析响应数据
const responseData = typeof result.data === 'string' ? JSON.parse(result.data) : result.data;
// 提取文件URL
fileUrl = this._extractFileUrl(responseData, config);
if (!fileUrl) {
throw new Error('无法从响应中提取文件URL');
}
return {
success: true,
fileUrl,
response: responseData,
statusCode: result.statusCode
};
} catch (error) {
console.error('[UploadManager] 处理上传成功响应时出错:', error);
throw new Error('上传成功但处理响应失败: ' + error.message);
}
}
/**
* 处理上传错误
* @param {Object} error - 错误对象
* @param {string} filePath - 文件路径
* @param {Object} config - 上传配置
* @returns {Object} 错误处理结果
*/
async handleUploadError(error, filePath, config) {
this.uploadState.isUploading = false;
this.uploadState.error = error;
// 判断是否需要重试
if (this._shouldRetryUpload(error) && this.uploadState.retryCount < this.maxRetries) {
return await this.handleUploadRetry(filePath, config);
}
// 重试次数用完或不需要重试,返回最终错误
return {
success: false,
error: error,
message: this._getErrorMessage(error),
retryCount: this.uploadState.retryCount,
canRetry: this.uploadState.retryCount < this.maxRetries
};
}
/**
* 处理上传重试
* @param {string} filePath - 文件路径
* @param {Object} config - 上传配置
* @returns {Promise} 重试结果
*/
async handleUploadRetry(filePath, config) {
this.uploadState.retryCount++;
// 计算重试延迟时间(指数退避)
const delay = Math.min(1000 * Math.pow(2, this.uploadState.retryCount - 1), 10000);
console.log(`[UploadManager] 第${this.uploadState.retryCount}次重试,延迟${delay}ms`);
// 等待延迟时间
await new Promise(resolve => setTimeout(resolve, delay));
// 重新尝试上传
return await this.performUpload(filePath, config);
}
/**
* 重置上传状态
*/
resetUploadState() {
this.uploadState = {
isUploading: false,
retryCount: 0,
error: null
};
}
/**
* 使用uni.uploadFile执行上传
* @private
* @param {string} filePath - 文件路径
* @param {Object} config - 上传配置
* @returns {Promise} 上传Promise
*/
_uploadWithUni(filePath, config) {
return new Promise((resolve, reject) => {
const uploadOptions = {
url: config.uploadUrl,
filePath: filePath,
name: config.fileName || 'file',
timeout: config.timeout || 60000
};
// 添加表单数据
if (config.formData && typeof config.formData === 'object') {
uploadOptions.formData = config.formData;
}
// 添加请求头
if (config.headers && typeof config.headers === 'object') {
uploadOptions.header = config.headers;
}
uni.uploadFile({
...uploadOptions,
success: resolve,
fail: reject
});
});
}
/**
* 从响应数据中提取文件URL
* @private
* @param {Object} responseData - 响应数据
* @param {Object} config - 上传配置
* @returns {string|null} 文件URL
*/
_extractFileUrl(responseData, config) {
if (!responseData) return null;
// 常见的URL字段名
const urlFields = ['url', 'fileUrl', 'file_url', 'path', 'filePath', 'file_path', 'src', 'link'];
// 直接查找URL字段
for (const field of urlFields) {
if (responseData[field]) {
return responseData[field];
}
}
// 查找嵌套的data字段
if (responseData.data) {
for (const field of urlFields) {
if (responseData.data[field]) {
return responseData.data[field];
}
}
}
// 查找result字段
if (responseData.result) {
for (const field of urlFields) {
if (responseData.result[field]) {
return responseData.result[field];
}
}
}
return null;
}
/**
* 判断是否应该重试上传
* @private
* @param {Object} error - 错误对象
* @returns {boolean} 是否应该重试
*/
_shouldRetryUpload(error) {
if (!error) return false;
const errorMsg = (error.errMsg || error.message || '').toLowerCase();
// 网络相关错误可以重试
if (errorMsg.includes('network') || errorMsg.includes('timeout') ||
errorMsg.includes('连接') || errorMsg.includes('超时')) {
return true;
}
// 服务器5xx错误可以重试
if (errorMsg.includes('500') || errorMsg.includes('502') ||
errorMsg.includes('503') || errorMsg.includes('504')) {
return true;
}
// 其他错误不重试(如4xx客户端错误)
return false;
}
/**
* 获取用户友好的错误消息
* @private
* @param {Object} error - 错误对象
* @returns {string} 错误消息
*/
_getErrorMessage(error) {
if (!error) return '上传失败';
const errorMsg = (error.errMsg || error.message || '').toLowerCase();
if (errorMsg.includes('network') || errorMsg.includes('连接')) {
return '网络连接失败,请检查网络设置';
}
if (errorMsg.includes('timeout') || errorMsg.includes('超时')) {
return '上传超时,请稍后重试';
}
if (errorMsg.includes('500')) {
return '服务器内部错误,请稍后重试';
}
if (errorMsg.includes('404')) {
return '上传地址不存在,请检查配置';
}
if (errorMsg.includes('403')) {
return '没有上传权限,请联系管理员';
}
if (errorMsg.includes('413')) {
return '文件过大,请选择较小的文件';
}
return error.errMsg || error.message || '上传失败,请重试';
}
}
/**
* 创建上传管理器实例
* @returns {UploadManager} 上传管理器实例
*/
export function createUploadManager() {
return new UploadManager();
}
/**
* 默认导出上传管理器类
*/
export default UploadManager;
7. 消息提示工具
/**
* 显示Toast消息
* @param {string} title - 消息标题
* @param {string} icon - 图标类型 ('success', 'error', 'loading', 'none')
* @param {number} duration - 显示时长(毫秒)
* @param {Object} options - 其他选项
*/
export function showToast(title, icon = 'none', duration = 2000, options = {}) {
if (!title) {
console.warn('[messageUtils] showToast: title is required');
return;
}
const toastOptions = {
title: String(title),
icon: ['success', 'error', 'loading', 'none'].includes(icon) ? icon : 'none',
duration: Math.max(1000, Math.min(duration, 10000)), // 限制在1-10秒之间
...options
};
try {
uni.showToast(toastOptions);
} catch (error) {
console.error('[messageUtils] showToast error:', error);
}
}
/**
* 显示加载状态
* @param {string} title - 加载提示文字
* @param {boolean} mask - 是否显示透明蒙层
*/
export function showLoading(title = '加载中...', mask = true) {
try {
uni.showLoading({
title: String(title),
mask: Boolean(mask)
});
} catch (error) {
console.error('[messageUtils] showLoading error:', error);
}
}
/**
* 隐藏加载状态
*/
export function hideLoading() {
try {
uni.hideLoading();
} catch (error) {
console.error('[messageUtils] hideLoading error:', error);
}
}
/**
* 格式化错误消息
* @param {string} message - 原始错误消息
* @param {Object} error - 错误对象
* @returns {string} 格式化后的用户友好消息
*/
export function formatErrorMessage(message, error) {
if (!error) return message || '操作失败';
const errorMsg = (error.errMsg || error.message || error.toString()).toLowerCase();
// 网络相关错误
if (errorMsg.includes('network') || errorMsg.includes('连接')) {
return '网络连接失败,请检查网络设置';
}
if (errorMsg.includes('timeout') || errorMsg.includes('超时')) {
return '操作超时,请稍后重试';
}
// 服务器相关错误
if (errorMsg.includes('500')) {
return '服务器内部错误,请稍后重试';
}
if (errorMsg.includes('404')) {
return '请求的资源不存在';
}
if (errorMsg.includes('403')) {
return '没有操作权限,请联系管理员';
}
if (errorMsg.includes('401')) {
return '身份验证失败,请重新登录';
}
// 文件相关错误
if (errorMsg.includes('file') || errorMsg.includes('文件')) {
return '文件处理失败,请重试';
}
// 权限相关错误
if (errorMsg.includes('auth') || errorMsg.includes('permission')) {
return '权限不足,请检查应用权限设置';
}
return message || '操作失败,请重试';
}
/**
* 显示最终错误消息
* @param {string} message - 错误消息
* @param {Object} error - 错误对象
* @param {Object} config - 显示配置
*/
export function showFinalError(message, error, config = {}) {
const formattedMessage = formatErrorMessage(message, error);
const { useModal = false, duration = 3000 } = config;
if (useModal) {
showModal({
title: '错误提示',
content: formattedMessage,
showCancel: false,
confirmText: '确定'
});
} else {
showToast(formattedMessage, 'error', duration);
}
}
/**
* 显示确认对话框
* @param {string} title - 对话框标题
* @param {string} content - 对话框内容
* @param {Object} options - 其他选项
* @returns {Promise} 用户选择结果
*/
export function showModal(title, content, options = {}) {
const defaultOptions = {
title: title || '提示',
content: content || '',
showCancel: true,
cancelText: '取消',
confirmText: '确定'
};
const modalOptions = { ...defaultOptions, ...options };
return new Promise((resolve, reject) => {
try {
uni.showModal({
...modalOptions,
success: (res) => {
resolve({
confirm: res.confirm,
cancel: res.cancel
});
},
fail: (error) => {
console.error('[messageUtils] showModal error:', error);
reject(error);
}
});
} catch (error) {
console.error('[messageUtils] showModal error:', error);
reject(error);
}
});
}
/**
* 显示操作菜单
* @param {Array} itemList - 菜单项列表
* @param {Object} options - 其他选项
* @returns {Promise} 用户选择结果
*/
export function showActionSheet(itemList, options = {}) {
const { itemColor = '#000000' } = options;
if (!Array.isArray(itemList) || itemList.length === 0) {
console.warn('[messageUtils] showActionSheet: itemList is required and should not be empty');
return Promise.reject(new Error('itemList is required'));
}
return new Promise((resolve, reject) => {
try {
uni.showActionSheet({
itemList,
itemColor,
success: (res) => {
resolve({
tapIndex: res.tapIndex,
selectedItem: itemList[res.tapIndex]
});
},
fail: (error) => {
if (error.errMsg && error.errMsg.includes('cancel')) {
resolve({ cancel: true });
} else {
console.error('[messageUtils] showActionSheet error:', error);
reject(error);
}
}
});
} catch (error) {
console.error('[messageUtils] showActionSheet error:', error);
reject(error);
}
});
}
/**
* 默认导出所有消息工具函数
*/
export default {
showToast,
showLoading,
hideLoading,
formatErrorMessage,
showFinalError,
showModal,
showActionSheet
};
8. 验证工具
/**
* 深度合并对象
* @param {Object} target - 目标对象
* @param {Object} source - 源对象
* @returns {Object} 合并后的对象
*/
export function deepMerge(target, source) {
if (!source || typeof source !== 'object') {
return target;
}
const result = JSON.parse(JSON.stringify(target));
for (const key in source) {
if (source.hasOwnProperty(key)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
// 递归合并对象
result[key] = deepMerge(result[key] || {}, source[key]);
} else {
// 直接覆盖基本类型和数组
result[key] = source[key];
}
}
}
return result;
}
/**
* 验证配置对象
* @param {Object} config - 配置对象
* @returns {Object} 验证结果 {isValid: boolean, errors: Array}
*/
export function validateConfig(config) {
const errors = [];
if (!config || typeof config !== 'object') {
errors.push('配置对象不能为空且必须是对象类型');
return { isValid: false, errors };
}
// 验证上传URL
if (!validateUrl(config.uploadUrl)) {
errors.push('uploadUrl 必须是有效的URL格式');
}
// 验证文件名
if (!validateFileName(config.fileName)) {
errors.push('fileName 必须是有效的字符串');
}
// 验证文件类型
if (!validateFileType(config.fileType)) {
errors.push('fileType 必须是 png、jpg 或 jpeg');
}
// 验证质量参数
if (!validateQuality(config.quality)) {
errors.push('quality 必须是 0-1 之间的数字');
}
// 验证超时时间
if (!validateTimeout(config.timeout)) {
errors.push('timeout 必须是大于0的数字');
}
// 验证headers(如果存在)
if (config.headers && !validateHeaders(config.headers)) {
errors.push('headers 必须是对象类型');
}
// 验证formData(如果存在)
if (config.formData && !validateFormData(config.formData)) {
errors.push('formData 必须是对象类型');
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* 验证URL格式
* @param {string} url - 待验证的URL
* @returns {boolean} 是否为有效URL
*/
export function validateUrl(url) {
if (!url || typeof url !== 'string') {
return false;
}
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* 验证文件类型
* @param {string} fileType - 文件类型
* @param {Array} allowedTypes - 允许的文件类型列表
* @returns {boolean} 是否为有效文件类型
*/
export function validateFileType(fileType, allowedTypes = ['png', 'jpg', 'jpeg']) {
if (!fileType || typeof fileType !== 'string') {
return false;
}
return allowedTypes.includes(fileType.toLowerCase());
}
/**
* 验证质量参数
* @param {number} quality - 质量参数
* @returns {boolean} 是否为有效质量参数
*/
export function validateQuality(quality) {
return typeof quality === 'number' && quality >= 0 && quality <= 1;
}
/**
* 验证超时时间
* @param {number} timeout - 超时时间(毫秒)
* @returns {boolean} 是否为有效超时时间
*/
export function validateTimeout(timeout) {
return typeof timeout === 'number' && timeout > 0;
}
/**
* 验证文件名
* @param {string} fileName - 文件名
* @returns {boolean} 是否为有效文件名
*/
export function validateFileName(fileName) {
if (!fileName || typeof fileName !== 'string') {
return false;
}
// 检查文件名是否包含非法字符
const invalidChars = /[<>:"/\\|?*]/;
return !invalidChars.test(fileName) && fileName.trim().length > 0;
}
/**
* 验证请求头对象
* @param {Object} headers - 请求头对象
* @returns {boolean} 是否为有效请求头
*/
export function validateHeaders(headers) {
return headers && typeof headers === 'object' && !Array.isArray(headers);
}
/**
* 验证表单数据对象
* @param {Object} formData - 表单数据对象
* @returns {boolean} 是否为有效表单数据
*/
export function validateFormData(formData) {
return formData && typeof formData === 'object' && !Array.isArray(formData);
}
/**
* 默认导出所有验证工具函数
*/
export default {
deepMerge,
validateConfig,
validateUrl,
validateFileType,
validateQuality,
validateTimeout,
validateFileName,
validateHeaders,
validateFormData
};
9. 数学计算工具
/**
* 计算两点之间的距离
* @param {Object} point1 - 第一个点 {x, y}
* @param {Object} point2 - 第二个点 {x, y}
* @returns {number} 距离值
*/
export function calculateDistance(point1, point2) {
if (!point1 || !point2 ||
typeof point1.x !== 'number' || typeof point1.y !== 'number' ||
typeof point2.x !== 'number' || typeof point2.y !== 'number') {
return 0;
}
const dx = point2.x - point1.x;
const dy = point2.y - point1.y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* 计算绘制速度
* @param {number} distance - 距离
* @param {number} time - 时间差(毫秒)
* @returns {number} 速度值
*/
export function calculateSpeed(distance, time) {
if (typeof distance !== 'number' || typeof time !== 'number' || time <= 0) {
return 0;
}
return distance / time;
}
/**
* 根据速度计算线宽
* @param {number} speed - 绘制速度
* @param {number} minWidth - 最小线宽
* @param {number} maxWidth - 最大线宽
* @returns {number} 计算后的线宽
*/
export function calculateLineWidth(speed, minWidth = 2, maxWidth = 6) {
if (typeof speed !== 'number' || speed < 0) {
return minWidth;
}
// 速度越快,线条越细
const speedFactor = Math.min(speed / 10, 1); // 将速度标准化到0-1范围
const width = maxWidth - (maxWidth - minWidth) * speedFactor;
return clamp(width, minWidth, maxWidth);
}
/**
* 获取弧度数据
* @param {number} x1 - 起始点x坐标
* @param {number} y1 - 起始点y坐标
* @param {number} x2 - 结束点x坐标
* @param {number} y2 - 结束点y坐标
* @returns {Object} 弧度相关数据 {val, pos}
*/
export function getRadianData(x1, y1, x2, y2) {
if (typeof x1 !== 'number' || typeof y1 !== 'number' ||
typeof x2 !== 'number' || typeof y2 !== 'number') {
return { val: 0, pos: 1 };
}
const dis_x = x2 - x1;
const dis_y = y2 - y1;
if (dis_x === 0) {
return { val: 0, pos: -1 };
}
if (dis_y === 0) {
return { val: 0, pos: 1 };
}
const val = Math.abs(Math.atan(dis_y / dis_x));
if ((x2 > x1 && y2 < y1) || (x2 < x1 && y2 > y1)) {
return { val: val, pos: 1 };
}
return { val: val, pos: -1 };
}
/**
* 根据弧度获取点坐标
* @param {Object} center - 中心点
* @param {number} radius - 半径
* @param {number} angle - 角度(弧度)
* @returns {Object} 点坐标 {x, y}
*/
export function getRadianPoint(center, radius, angle) {
if (!center || typeof center.x !== 'number' || typeof center.y !== 'number' ||
typeof radius !== 'number' || typeof angle !== 'number') {
return { x: 0, y: 0 };
}
return {
x: center.x + radius * Math.cos(angle),
y: center.y + radius * Math.sin(angle)
};
}
/**
* 根据弧度数据获取垂直于线段的两个点
* @param {Object} radianData - 弧度数据对象,包含val和pos属性
* @param {number} x - 中心点x坐标
* @param {number} y - 中心点y坐标
* @param {number} halfLineWidth - 线宽的一半
* @returns {Array} 包含两个点的数组 [{x, y}, {x, y}]
*/
export function getRadianPoints(radianData, x, y, halfLineWidth) {
if (!radianData || typeof radianData.val !== 'number' || typeof radianData.pos !== 'number' ||
typeof x !== 'number' || typeof y !== 'number' ||
typeof halfLineWidth !== 'number') {
return [{ x: 0, y: 0 }, { x: 0, y: 0 }];
}
if (radianData.val === 0) {
if (radianData.pos === 1) {
return [
{ x: x, y: y + halfLineWidth },
{ x: x, y: y - halfLineWidth }
];
}
return [
{ y: y, x: x + halfLineWidth },
{ y: y, x: x - halfLineWidth }
];
}
const dis_x = Math.sin(radianData.val) * halfLineWidth;
const dis_y = Math.cos(radianData.val) * halfLineWidth;
if (radianData.pos === 1) {
return [
{ x: x + dis_x, y: y + dis_y },
{ x: x - dis_x, y: y - dis_y }
];
}
return [
{ x: x + dis_x, y: y - dis_y },
{ x: x - dis_x, y: y + dis_y }
];
}
/**
* 数值精度处理
* @param {number} value - 需要处理的数值
* @param {number} precision - 精度位数
* @returns {number} 处理后的数值
*/
export function toFixed(value, precision = 1) {
if (typeof value !== 'number' || typeof precision !== 'number') {
return 0;
}
return parseFloat(value.toFixed(Math.max(0, precision)));
}
/**
* 限制数值在指定范围内
* @param {number} value - 输入值
* @param {number} min - 最小值
* @param {number} max - 最大值
* @returns {number} 限制后的值
*/
export function clamp(value, min, max) {
if (typeof value !== 'number' || typeof min !== 'number' || typeof max !== 'number') {
return value || 0;
}
return Math.min(Math.max(value, min), max);
}
/**
* 线性插值
* @param {number} start - 起始值
* @param {number} end - 结束值
* @param {number} factor - 插值因子(0-1)
* @returns {number} 插值结果
*/
export function lerp(start, end, factor) {
if (typeof start !== 'number' || typeof end !== 'number' || typeof factor !== 'number') {
return start || 0;
}
return start + (end - start) * clamp(factor, 0, 1);
}
/**
* 角度转弧度
* @param {number} degrees - 角度值
* @returns {number} 弧度值
*/
export function degreesToRadians(degrees) {
if (typeof degrees !== 'number') {
return 0;
}
return degrees * (Math.PI / 180);
}
/**
* 弧度转角度
* @param {number} radians - 弧度值
* @returns {number} 角度值
*/
export function radiansToDegrees(radians) {
if (typeof radians !== 'number') {
return 0;
}
return radians * (180 / Math.PI);
}
/**
* 默认导出所有数学工具函数
*/
export default {
calculateDistance,
calculateSpeed,
calculateLineWidth,
getRadianData,
getRadianPoints,
toFixed,
clamp,
lerp
};
10. 画布工具
/**
* 获取画布尺寸
* @param {string} canvasId - 画布ID
* @param {Object} component - 组件实例
* @returns {Promise<Object>} 画布尺寸信息
*/
export function getCanvasSize(canvasId, component) {
return new Promise((resolve, reject) => {
if (!canvasId || !component) {
reject(new Error('canvasId和component参数不能为空'));
return;
}
try {
const query = uni.createSelectorQuery().in(component);
query.select(`#${canvasId}`)
.boundingClientRect(data => {
if (data) {
resolve({
width: data.width,
height: data.height,
left: data.left,
top: data.top
});
} else {
reject(new Error('无法获取画布尺寸信息'));
}
})
.exec();
} catch (error) {
console.error('[canvasUtils] getCanvasSize error:', error);
reject(error);
}
});
}
/**
* 检查画布是否就绪
* @param {Object} ctx - 画布上下文
* @returns {boolean} 是否就绪
*/
export function isCanvasReady(ctx) {
return ctx && typeof ctx === 'object' && typeof ctx.draw === 'function';
}
/**
* 清空画布指定区域
* @param {Object} ctx - 画布上下文
* @param {number} x - 起始x坐标
* @param {number} y - 起始y坐标
* @param {number} width - 宽度
* @param {number} height - 高度
*/
export function clearCanvasArea(ctx, x = 0, y = 0, width, height) {
if (!isCanvasReady(ctx)) {
console.warn('[canvasUtils] clearCanvasArea: 画布上下文无效');
return;
}
try {
ctx.clearRect(x, y, width, height);
ctx.draw();
} catch (error) {
console.error('[canvasUtils] clearCanvasArea error:', error);
}
}
/**
* 设置画布样式
* @param {Object} ctx - 画布上下文
* @param {Object} style - 样式配置
*/
export function setCanvasStyle(ctx, style = {}) {
if (!isCanvasReady(ctx)) {
console.warn('[canvasUtils] setCanvasStyle: 画布上下文无效');
return;
}
try {
const {
strokeStyle = '#000000',
fillStyle = '#000000',
lineWidth = 2,
lineCap = 'round',
lineJoin = 'round',
globalAlpha = 1
} = style;
ctx.strokeStyle = strokeStyle;
ctx.fillStyle = fillStyle;
ctx.lineWidth = lineWidth;
ctx.lineCap = lineCap;
ctx.lineJoin = lineJoin;
ctx.globalAlpha = globalAlpha;
} catch (error) {
console.error('[canvasUtils] setCanvasStyle error:', error);
}
}
/**
* 坐标转换(屏幕坐标转画布坐标)
* @param {Object} screenPoint - 屏幕坐标点
* @param {Object} canvasInfo - 画布信息
* @returns {Object} 画布坐标点
*/
export function screenToCanvas(screenPoint, canvasInfo) {
if (!screenPoint || !canvasInfo ||
typeof screenPoint.x !== 'number' || typeof screenPoint.y !== 'number') {
return { x: 0, y: 0 };
}
const { left = 0, top = 0 } = canvasInfo;
return {
x: screenPoint.x - left,
y: screenPoint.y - top
};
}
/**
* 获取系统信息并计算默认画布尺寸
* @param {number} widthRatio - 宽度比例
* @param {number} heightRatio - 高度比例
* @returns {Object} 默认画布尺寸 {width, height}
*/
export function getDefaultCanvasSize(widthRatio = 0.85, heightRatio = 0.95) {
try {
const systemInfo = uni.getSystemInfoSync();
const { windowWidth, windowHeight } = systemInfo;
return {
width: Math.min(windowWidth * widthRatio, 400),
height: Math.min(windowHeight * heightRatio, 300)
};
} catch (error) {
console.error('[canvasUtils] getDefaultCanvasSize error:', error);
return {
width: 300,
height: 200
};
}
}
/**
* 创建画布上下文
* @param {string} canvasId - 画布ID
* @param {Object} component - 组件实例
* @returns {Object} 画布上下文
*/
export function createCanvasContext(canvasId, component) {
if (!canvasId) {
console.error('[canvasUtils] createCanvasContext: canvasId不能为空');
return null;
}
try {
return uni.createCanvasContext(canvasId, component);
} catch (error) {
console.error('[canvasUtils] createCanvasContext error:', error);
return null;
}
}
/**
* 画布转临时文件
* @param {string} canvasId - 画布ID
* @param {Object} options - 转换选项
* @param {Object} component - 组件实例
* @returns {Promise} 临时文件路径
*/
export function canvasToTempFile(canvasId, options, component) {
return new Promise((resolve, reject) => {
if (!canvasId) {
reject(new Error('canvasId不能为空'));
return;
}
const defaultOptions = {
fileType: 'png',
quality: 1,
destWidth: undefined,
destHeight: undefined
};
const finalOptions = { ...defaultOptions, ...options };
try {
uni.canvasToTempFilePath({
canvasId,
...finalOptions,
success: (res) => {
if (res.tempFilePath) {
resolve(res.tempFilePath);
} else {
reject(new Error('生成临时文件失败'));
}
},
fail: (error) => {
console.error('[canvasUtils] canvasToTempFile error:', error);
reject(error);
}
}, component);
} catch (error) {
console.error('[canvasUtils] canvasToTempFile error:', error);
reject(error);
}
});
}
/**
* 检查画布是否为空
* @param {Object} ctx - 画布上下文
* @param {number} width - 画布宽度
* @param {number} height - 画布高度
* @returns {boolean} 画布是否为空
*/
export function isCanvasEmpty(ctx, width, height) {
if (!isCanvasReady(ctx) || typeof width !== 'number' || typeof height !== 'number') {
return true;
}
try {
// 获取画布图像数据
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
// 检查是否所有像素都是透明的
for (let i = 3; i < data.length; i += 4) {
if (data[i] !== 0) { // alpha通道不为0表示有内容
return false;
}
}
return true;
} catch (error) {
// 如果无法获取图像数据,假设画布不为空
console.warn('[canvasUtils] isCanvasEmpty: 无法检测画布内容,假设不为空');
return false;
}
}
/**
* 默认导出所有画布工具函数
*/
export default {
getCanvasSize,
isCanvasReady,
clearCanvasArea,
setCanvasStyle,
screenToCanvas,
getDefaultCanvasSize,
createCanvasContext,
canvasToTempFile,
isCanvasEmpty
};
性能优化策略
1. 点数据优化
只保留最近3个点进行计算,减少内存占用:
this.points.push(point);
this.points = this.points.slice(-3); // 关键优化
2. 历史记录限制
限制历史记录数量,防止内存溢出:
if (this.drawingHistory.length > this.maxHistoryLength) {
this.drawingHistory = this.drawingHistory.slice(-this.maxHistoryLength);
}
3. 绘制优化
使用requestAnimationFrame优化绘制性能:
onDraw() {
if (this.points.length < 2) return;
if (typeof this.requestAnimationFrame === 'function') {
this.requestAnimationFrame(() => {
this.drawSmoothLine(prePoint, point);
});
} else {
this.drawSmoothLine(prePoint, point);
}
}
4. 内存管理
及时清理不再使用的数据和资源:
clear() {
clearCanvasArea(this.ctx, 0, 0, this.canvasWidth, this.canvasHeight);
this.historyList.length = 0;
this.drawingHistory = [];
this.currentStroke = [];
this.points = [];
}
组件完整代码
<template>
<view>
<view class="wrapper">
<view class="handBtn">
<button @click="clear" type="success" class="delBtn">清空</button>
<button @click="saveCanvasAsImg" type="info" class="saveBtn">保存</button>
<button @click="previewCanvasImg" type="warning" class="previewBtn">预览</button>
<button @click="undo" type="error" class="undoBtn">撤销</button>
<button @click="complete" type="primary" class="subCanvas">完成</button>
</view>
<view class="handCenter">
<canvas
canvas-id="handWriting"
id="handWriting"
class="handWriting"
:disable-scroll="true"
@touchstart="uploadScaleStart"
@touchmove="uploadScaleMove"
@touchend="uploadScaleEnd"
></canvas>
</view>
<view class="handRight">
<view class="handTitle">请签名</view>
</view>
</view>
</view>
</template>
<script>
// 导入工具类
import { UploadManager } from '../utils/uploadManager.js'
import { showToast, showLoading, hideLoading, formatErrorMessage, showFinalError, showModal } from '../utils/messageUtils.js'
import { deepMerge, validateConfig, validateUrl, validateFileType, validateQuality, validateTimeout, validateFileName, validateHeaders, validateFormData } from '../utils/validationUtils.js'
import { calculateDistance, calculateSpeed, calculateLineWidth, getRadianData, getRadianPoints, toFixed, clamp, lerp } from '../utils/mathUtils.js'
import { getCanvasSize, isCanvasReady, clearCanvasArea, setCanvasStyle, screenToCanvas, getDefaultCanvasSize, createCanvasContext, canvasToTempFile, isCanvasEmpty } from '../utils/canvasUtils.js'
export default {
data() {
return {
ctx: '',
canvasWidth: 0,
canvasHeight: 0,
lineColor: '#1A1A1A',
points: [],
historyList: [],
drawingHistory: [],
currentStroke: [],
canAddHistory: true,
isCanvasReady: false,
mergedConfig: {},
uploadManager: null,
defaultConfig: {
uploadUrl: '',
headers: {
'Content-Type': 'multipart/form-data'
},
formData: {},
fileName: 'signature',
fileType: 'png',
quality: 1,
timeout: 30000,
retryCount: 2,
retryDelay: 1000,
showErrorToast: true,
errorToastDuration: 3000,
enableAutoRetry: true,
retryOnNetworkError: true,
retryOnServerError: false
},
getImagePath: () => {
return new Promise((resolve) => {
uni.canvasToTempFilePath({
canvasId: 'handWriting',
fileType: 'png',
quality: 1, //图片质量
success: res => resolve(res.tempFilePath),
})
})
},
toDataURL: void 0,
requestAnimationFrame: void 0,
};
},
props: {
minSpeed: {
type: Number,
default: 1.5
},
minWidth: {
type: Number,
default: 3,
},
maxWidth: {
type: Number,
default: 10
},
openSmooth: {
type: Boolean,
default: true
},
maxHistoryLength: {
type: Number,
default: 20
},
maxWidthDiffRate: {
type: Number,
default: 20
},
bgColor: {
type: String,
default: 'transparent'
},
uploadUrl: {
type: String,
default: '',
validator: function (value) {
if (!value) return true;
try {
new URL(value);
return true;
} catch {
return false;
}
}
},
uploadConfig: {
type: Object,
default: () => ({
headers: {
'Content-Type': 'multipart/form-data'
},
formData: {
timestamp: Date.now()
},
fileName: 'signature',
fileType: 'png',
quality: 1,
timeout: 30000
}),
validator: function (value) {
return value && typeof value === 'object';
}
},
},
methods: {
// 配置管理方法
// 合并配置(用户配置 + 默认配置)
mergeConfig() {
try {
// 深拷贝默认配置
const baseConfig = JSON.parse(JSON.stringify(this.defaultConfig));
// 处理用户传入的配置
const userConfig = {
uploadUrl: this.uploadUrl,
...this.uploadConfig
};
// 使用工具类合并配置
this.mergedConfig = deepMerge(baseConfig, userConfig);
// 使用工具类验证配置
validateConfig(this.mergedConfig);
} catch (error) {
this.mergedConfig = JSON.parse(JSON.stringify(this.defaultConfig));
}
},
// 新增:获取当前有效配置
getCurrentConfig() {
if (!this.mergedConfig || Object.keys(this.mergedConfig).length === 0) {
this.mergeConfig();
}
return this.mergedConfig;
},
// 检查canvas上下文是否可用
checkCanvasContext() {
if (!this.ctx) {
this.initCanvas();
return false;
}
return true;
},
initCanvas() {
try {
this.ctx = uni.createCanvasContext("handWriting", this);
if (this.ctx) {
this.$nextTick(() => {
uni.createSelectorQuery().select('.handCenter').boundingClientRect(rect => {
if (rect && rect.width > 0 && rect.height > 0) {
this.canvasWidth = rect.width;
this.canvasHeight = rect.height;
} else {
const systemInfo = uni.getSystemInfoSync();
this.canvasWidth = Math.floor(systemInfo.windowWidth * 0.85);
this.canvasHeight = Math.floor(systemInfo.windowHeight * 0.95);
}
try {
if (this.bgColor && this.bgColor !== 'transparent') {
this.drawBgColor();
}
this.isCanvasReady = true;
} catch (error) {
this.isCanvasReady = false;
}
}).exec();
});
} else {
setTimeout(() => this.initCanvas(), 500);
}
} catch (error) {
setTimeout(() => this.initCanvas(), 500);
}
},
uploadScaleStart(e) {
if (!this.isCanvasReady) {
this.initCanvas();
return;
}
if (!this.checkCanvasContext()) {
return;
}
this.canAddHistory = true;
try {
this.ctx.setStrokeStyle(this.lineColor);
this.ctx.setLineCap("round");
} catch (error) {
console.error('设置画笔样式失败:', error);
}
},
uploadScaleMove(e) {
if (!this.isCanvasReady) {
return;
}
let temX = e.changedTouches[0].x
let temY = e.changedTouches[0].y
this.initPoint(temX, temY)
this.onDraw()
},
uploadScaleEnd() {
this.canAddHistory = true;
if (this.points.length >= 2) {
if (this.currentStroke.length > 0) {
this.addHistory();
}
}
this.points = [];
},
initPoint(x, y) {
var point = {
x: x,
y: y,
t: Date.now()
};
var prePoint = this.points.slice(-1)[0];
if (prePoint && (prePoint.t === point.t || prePoint.x === x && prePoint.y === y)) {
return;
}
if (prePoint && this.openSmooth) {
var prePoint2 = this.points.slice(-2, -1)[0];
// 使用工具类计算距离和速度
point.distance = calculateDistance(point.x, point.y, prePoint.x, prePoint.y);
point.speed = calculateSpeed(point.distance, point.t - prePoint.t);
point.lineWidth = this.getLineWidth(point.speed);
if (prePoint2 && prePoint2.lineWidth && prePoint.lineWidth) {
var rate = (point.lineWidth - prePoint.lineWidth) / prePoint.lineWidth;
var maxRate = this.maxWidthDiffRate / 100;
maxRate = maxRate > 1 ? 1 : maxRate < 0.01 ? 0.01 : maxRate;
if (Math.abs(rate) > maxRate) {
var per = rate > 0 ? maxRate : -maxRate;
point.lineWidth = prePoint.lineWidth * (1 + per);
}
}
}
this.points.push(point);
this.points = this.points.slice(-3);
this.currentStroke.push({
x: point.x,
y: point.y,
t: point.t,
lineWidth: point.lineWidth || this.minWidth,
speed: point.speed || 0,
distance: point.distance || 0
});
},
getLineWidth(speed) {
// 使用工具类计算线宽
return calculateLineWidth(speed, this.minSpeed, this.minWidth, this.maxWidth);
},
onDraw() {
if (this.points.length < 2) return;
var point = this.points.slice(-1)[0];
var prePoint = this.points.slice(-2, -1)[0];
let that = this
var onDraw = function onDraw() {
if (that.openSmooth) {
that.drawSmoothLine(prePoint, point);
} else {
that.drawNoSmoothLine(prePoint, point);
}
};
if (typeof this.requestAnimationFrame === 'function') {
this.requestAnimationFrame(function () {
return onDraw();
});
} else {
onDraw();
}
},
//添加历史记录
addHistory() {
if (!this.maxHistoryLength || !this.canAddHistory) return;
this.canAddHistory = false;
// 统一使用笔画数据保存历史记录
if (this.currentStroke.length > 0) {
// 创建笔画对象,包含所有绘制信息
const strokeData = {
points: JSON.parse(JSON.stringify(this.currentStroke)), // 深拷贝点数据
color: this.lineColor, // 当前笔画的颜色
baseLineWidth: this.maxWidth, // 基础线条宽度
minWidth: this.minWidth, // 最小线条宽度
maxWidth: this.maxWidth, // 最大线条宽度
openSmooth: this.openSmooth, // 是否开启平滑
minSpeed: this.minSpeed, // 最小速度
maxWidthDiffRate: this.maxWidthDiffRate, // 最大差异率
timestamp: Date.now()
};
// 添加到绘制历史
this.drawingHistory.push(strokeData);
// 限制历史记录长度
if (this.drawingHistory.length > this.maxHistoryLength) {
this.drawingHistory = this.drawingHistory.slice(-this.maxHistoryLength);
}
// 同步更新historyList长度用于isEmpty检查
this.historyList.length = this.drawingHistory.length;
// 清空当前笔画
this.currentStroke = [];
console.log('Stroke added to history:', {
strokeCount: this.drawingHistory.length,
pointsInStroke: strokeData.points.length,
color: strokeData.color
});
} else {
console.log('No current stroke to add to history');
}
// 重置添加历史标志
setTimeout(() => {
this.canAddHistory = true;
}, 100);
},
//画平滑线
drawSmoothLine(prePoint, point) {
var dis_x = point.x - prePoint.x;
var dis_y = point.y - prePoint.y;
if (Math.abs(dis_x) + Math.abs(dis_y) <= 2) {
point.lastX1 = point.lastX2 = prePoint.x + dis_x * 0.5;
point.lastY1 = point.lastY2 = prePoint.y + dis_y * 0.5;
} else {
point.lastX1 = prePoint.x + dis_x * 0.3;
point.lastY1 = prePoint.y + dis_y * 0.3;
point.lastX2 = prePoint.x + dis_x * 0.7;
point.lastY2 = prePoint.y + dis_y * 0.7;
}
point.perLineWidth = (prePoint.lineWidth + point.lineWidth) / 2;
if (typeof prePoint.lastX1 === 'number') {
this.drawCurveLine(prePoint.lastX2, prePoint.lastY2, prePoint.x, prePoint.y, point.lastX1, point
.lastY1, point.perLineWidth);
if (prePoint.isFirstPoint) return;
if (prePoint.lastX1 === prePoint.lastX2 && prePoint.lastY1 === prePoint.lastY2) return;
var data = this.getRadianData(prePoint.lastX1, prePoint.lastY1, prePoint.lastX2, prePoint.lastY2);
var points1 = this.getRadianPoints(data, prePoint.lastX1, prePoint.lastY1, prePoint.perLineWidth / 2);
var points2 = this.getRadianPoints(data, prePoint.lastX2, prePoint.lastY2, point.perLineWidth / 2);
this.drawTrapezoid(points1[0], points2[0], points2[1], points1[1]);
} else {
point.isFirstPoint = true;
}
},
//画不平滑线
drawNoSmoothLine(prePoint, point) {
point.lastX = prePoint.x + (point.x - prePoint.x) * 0.5;
point.lastY = prePoint.y + (point.y - prePoint.y) * 0.5;
if (typeof prePoint.lastX === 'number') {
this.drawCurveLine(prePoint.lastX, prePoint.lastY, prePoint.x, prePoint.y, point.lastX, point.lastY,
this.maxWidth);
}
},
//画线
drawCurveLine(x1, y1, x2, y2, x3, y3, lineWidth, skipDraw = false) {
if (!this.checkCanvasContext()) return;
lineWidth = Number(lineWidth.toFixed(1));
try {
// 统一使用uni-app的canvas API
if (this.ctx.setLineWidth) {
this.ctx.setLineWidth(lineWidth);
}
this.ctx.lineWidth = lineWidth;
// 确保线条样式设置正确,防止虚线效果
this.ctx.setLineCap('round');
this.ctx.setLineJoin('round');
this.ctx.setStrokeStyle(this.lineColor);
this.ctx.beginPath();
this.ctx.moveTo(Number(x1.toFixed(1)), Number(y1.toFixed(1)));
this.ctx.quadraticCurveTo(Number(x2.toFixed(1)), Number(y2.toFixed(1)), Number(x3.toFixed(1)), Number(y3
.toFixed(1)));
this.ctx.stroke();
// 统一调用draw方法,但重绘时跳过
if (this.ctx.draw && !skipDraw) {
this.ctx.draw(true);
}
} catch (error) {
console.error('Error in drawCurveLine:', error);
}
},
//画梯形
drawTrapezoid(point1, point2, point3, point4) {
if (!this.checkCanvasContext()) return;
this.ctx.beginPath();
this.ctx.moveTo(Number(point1.x.toFixed(1)), Number(point1.y.toFixed(1)));
this.ctx.lineTo(Number(point2.x.toFixed(1)), Number(point2.y.toFixed(1)));
this.ctx.lineTo(Number(point3.x.toFixed(1)), Number(point3.y.toFixed(1)));
this.ctx.lineTo(Number(point4.x.toFixed(1)), Number(point4.y.toFixed(1)));
// 统一使用uni-app的canvas API
this.ctx.setFillStyle(this.lineColor);
this.ctx.fill();
this.ctx.draw(true);
},
//画梯形(用于重绘,跳过draw调用)
drawTrapezoidForRedraw(point1, point2, point3, point4) {
if (!this.checkCanvasContext()) return;
this.ctx.beginPath();
this.ctx.moveTo(Number(point1.x.toFixed(1)), Number(point1.y.toFixed(1)));
this.ctx.lineTo(Number(point2.x.toFixed(1)), Number(point2.y.toFixed(1)));
this.ctx.lineTo(Number(point3.x.toFixed(1)), Number(point3.y.toFixed(1)));
this.ctx.lineTo(Number(point4.x.toFixed(1)), Number(point4.y.toFixed(1)));
// 统一使用uni-app的canvas API
this.ctx.setFillStyle(this.lineColor);
this.ctx.fill();
// 重绘时跳过单独的draw调用,统一在redrawAllStrokes中调用
},
//获取弧度
getRadianData(x1, y1, x2, y2) {
// 使用工具类获取弧度数据
return getRadianData(x1, y1, x2, y2);
},
//获取弧度点
getRadianPoints(radianData, x, y, halfLineWidth) {
// 使用工具类获取弧度点
return getRadianPoints(radianData, x, y, halfLineWidth);
},
/**
* 背景色
*/
drawBgColor() {
const config = this.getCurrentConfig();
if (!this.ctx || !config.bgColor) return;
// 直接使用 canvas API 绘制背景色
this.ctx.setFillStyle(config.bgColor);
this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
this.ctx.draw(true); // 保留之前的绘制内容
},
//图片绘制
// drawByImage(url) {
// if (!this.ctx || !url) return;
// // 直接使用 canvas API 绘制图片
// this.ctx.drawImage(url, 0, 0, this.canvasWidth, this.canvasHeight);
// this.ctx.draw(true); // 保留之前的绘制内容
// },
/**
* 清空画布
*/
clear() {
if (!this.isCanvasReady) {
showToast('画布未就绪,请稍后再试', 'none', 2000);
return;
}
if (!this.checkCanvasContext()) return;
try {
// 使用工具类清空画布
clearCanvasArea(this.ctx, 0, 0, this.canvasWidth, this.canvasHeight);
// 重新绘制背景色(如果不是透明的话)
this.drawBgColor();
// 清空所有历史记录和当前绘制点
this.historyList.length = 0;
this.drawingHistory = []; // 清空绘制历史
this.currentStroke = []; // 清空当前笔画
this.points = [];
showToast('画布已清空', 'success', 1500);
} catch (error) {
console.error('Error clearing canvas:', error);
showToast('清空失败,请重试', 'none', 2000);
}
},
// 清空画布(不清除历史记录)
clearCanvas() {
if (!this.ctx) {
console.error('Canvas context not available for clearing');
return;
}
try {
// 使用工具类清空画布
clearCanvasArea(this.ctx, 0, 0, this.canvasWidth, this.canvasHeight);
console.log('Canvas cleared successfully with transparent background');
} catch (error) {
console.error('Error clearing canvas:', error);
}
},
// 重新绘制所有历史笔画
redrawAllStrokes() {
if (!this.ctx || this.drawingHistory.length === 0) {
console.log('No context or no history to redraw');
return;
}
console.log('Redrawing', this.drawingHistory.length, 'strokes');
try {
// 清空画布
this.clearCanvas();
// 如果需要背景色则绘制(透明背景时跳过)
if (this.bgColor && this.bgColor !== 'transparent' && this.bgColor !== 'rgba(0,0,0,0)') {
this.drawBgColor();
}
// 遍历所有历史笔画
for (let i = 0; i < this.drawingHistory.length; i++) {
const stroke = this.drawingHistory[i];
this.redrawSingleStroke(stroke, i);
}
// 统一使用uni-app的canvas API调用draw()来应用绘制
this.ctx.draw();
console.log('All strokes redrawn successfully');
} catch (error) {
console.error('Error redrawing strokes:', error);
}
},
// 重新绘制单个笔画
redrawSingleStroke(stroke, strokeIndex) {
if (!stroke || !stroke.points || stroke.points.length < 2) {
console.log('Invalid stroke data for redraw:', strokeIndex);
return;
}
try {
// 设置笔画颜色
this.ctx.setStrokeStyle(stroke.color || this.lineColor);
this.ctx.setLineCap('round');
this.ctx.setLineJoin('round');
if (stroke.openSmooth && stroke.points.length > 2) {
// 平滑绘制 - 完全模拟原始绘制过程
this.redrawSmoothStrokeAccurate(stroke);
} else {
// 直线绘制 - 使用笔画的基础线条宽度
this.ctx.setLineWidth(stroke.baseLineWidth || stroke.lineWidth || this.maxWidth);
this.ctx.beginPath();
this.redrawStraightStroke(stroke.points);
this.ctx.stroke();
}
console.log('Stroke', strokeIndex, 'redrawn with', stroke.points.length, 'points');
} catch (error) {
console.error('Error redrawing single stroke:', strokeIndex, error);
}
},
// 重新绘制平滑笔画
redrawSmoothStroke(points) {
if (points.length < 2) return;
this.ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length - 1; i++) {
const currentPoint = points[i];
const nextPoint = points[i + 1];
const controlX = (currentPoint.x + nextPoint.x) / 2;
const controlY = (currentPoint.y + nextPoint.y) / 2;
this.ctx.quadraticCurveTo(currentPoint.x, currentPoint.y, controlX, controlY);
}
// 绘制最后一个点
if (points.length > 1) {
const lastPoint = points[points.length - 1];
this.ctx.lineTo(lastPoint.x, lastPoint.y);
}
},
// 重新绘制带宽度的平滑笔画
redrawSmoothStrokeWithWidth(points) {
if (points.length < 2) return;
// 遍历所有点,使用每个点保存的线条宽度进行绘制
for (let i = 0; i < points.length - 1; i++) {
const currentPoint = points[i];
const nextPoint = points[i + 1];
// 使用当前点的线条宽度,如果没有则使用默认值
const lineWidth = currentPoint.lineWidth || this.maxWidth;
this.ctx.setLineWidth(lineWidth);
this.ctx.beginPath();
this.ctx.moveTo(currentPoint.x, currentPoint.y);
if (i < points.length - 2) {
// 不是最后一段,使用平滑曲线
const controlPoint = points[i + 1];
const endPoint = points[i + 2];
const controlX = (controlPoint.x + endPoint.x) / 2;
const controlY = (controlPoint.y + endPoint.y) / 2;
this.ctx.quadraticCurveTo(controlPoint.x, controlPoint.y, controlX, controlY);
} else {
// 最后一段,直接连线
this.ctx.lineTo(nextPoint.x, nextPoint.y);
}
this.ctx.stroke();
}
},
// 精确重绘平滑笔画 - 完全模拟原始绘制过程
redrawSmoothStrokeAccurate(stroke) {
if (!stroke.points || stroke.points.length < 2) return;
const points = stroke.points;
// 保存当前线条颜色,确保重绘时使用正确的颜色
const originalColor = this.lineColor;
this.lineColor = stroke.color || originalColor;
// 模拟原始的绘制过程,逐段绘制
for (let i = 1; i < points.length; i++) {
const prePoint = points[i - 1];
const point = points[i];
// 重建点的完整信息(模拟initPoint的处理)
if (stroke.openSmooth && i < points.length - 1) {
// 计算控制点信息(模拟drawSmoothLine的逻辑)
const dis_x = point.x - prePoint.x;
const dis_y = point.y - prePoint.y;
if (Math.abs(dis_x) + Math.abs(dis_y) <= 2) {
point.lastX1 = point.lastX2 = prePoint.x + dis_x * 0.5;
point.lastY1 = point.lastY2 = prePoint.y + dis_y * 0.5;
} else {
point.lastX1 = prePoint.x + dis_x * 0.3;
point.lastY1 = prePoint.y + dis_y * 0.3;
point.lastX2 = prePoint.x + dis_x * 0.7;
point.lastY2 = prePoint.y + dis_y * 0.7;
}
// 计算平均线条宽度
point.perLineWidth = ((prePoint.lineWidth || stroke.minWidth || this.minWidth) +
(point.lineWidth || stroke.minWidth || this.minWidth)) / 2;
// 使用原始的drawCurveLine逻辑,跳过单独的draw调用
if (typeof prePoint.lastX1 === 'number') {
this.drawCurveLine(prePoint.lastX2, prePoint.lastY2, prePoint.x, prePoint.y,
point.lastX1, point.lastY1, point.perLineWidth, true);
// 添加梯形绘制逻辑,确保线条连续性和粗细一致
if (!prePoint.isFirstPoint) {
if (!(prePoint.lastX1 === prePoint.lastX2 && prePoint.lastY1 === prePoint.lastY2)) {
var data = this.getRadianData(prePoint.lastX1, prePoint.lastY1, prePoint.lastX2, prePoint.lastY2);
var points1 = this.getRadianPoints(data, prePoint.lastX1, prePoint.lastY1, prePoint.perLineWidth / 2);
var points2 = this.getRadianPoints(data, prePoint.lastX2, prePoint.lastY2, point.perLineWidth / 2);
// 绘制梯形填充,但跳过单独的draw调用
this.drawTrapezoidForRedraw(points1[0], points2[0], points2[1], points1[1]);
}
} else {
// 标记第一个点
point.isFirstPoint = true;
}
}
} else {
// 非平滑模式,直接绘制线段
const lineWidth = point.lineWidth || stroke.baseLineWidth || this.maxWidth;
this.drawCurveLine(prePoint.x, prePoint.y, prePoint.x, prePoint.y,
point.x, point.y, lineWidth, true);
}
}
// 恢复原始线条颜色
this.lineColor = originalColor;
},
// 重新绘制直线笔画
redrawStraightStroke(points) {
if (points.length < 2) return;
this.ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
this.ctx.lineTo(points[i].x, points[i].y);
}
},
//撤消
undo() {
if (!this.isCanvasReady) {
showToast('画布未就绪,请稍后再试', 'none', 2000);
return;
}
// 检查是否有可撤销的操作
if (this.isEmpty()) {
showToast('没有可撤销的操作', 'none', 1500);
return;
}
if (!this.checkCanvasContext()) return;
try {
// 统一使用uni-app的canvas API,实现真正的逐步撤销
if (this.drawingHistory.length > 0) {
// 移除最后一个绘制操作
const removedStroke = this.drawingHistory.pop();
console.log('Removed stroke from history:', {
remainingStrokes: this.drawingHistory.length,
removedPoints: removedStroke.points.length
});
// 同步更新historyList长度
this.historyList.length = this.drawingHistory.length;
// 清空画布
this.clearCanvas();
// 重新绘制剩余的所有操作
this.redrawAllStrokes();
showToast('已撤销上一步', 'success', 1500);
console.log('Undo completed, remaining strokes:', this.drawingHistory.length);
} else {
showToast('没有可撤销的操作', 'none', 1500);
}
} catch (error) {
console.error('Error in undo:', error);
showToast('撤销失败,请重试', 'none', 2000);
}
},
//是否为空
isEmpty() {
// 统一使用uni-app的canvas API检查空画布逻辑
const hasDrawing = this.drawingHistory.length > 0 || this.currentStroke.length > 0;
console.log('[签名组件] Canvas isEmpty 详细检查:', {
isEmpty: !hasDrawing,
historyListLength: this.historyList.length,
drawingHistoryLength: this.drawingHistory.length,
currentStrokeLength: this.currentStroke.length,
hasDrawing: hasDrawing
});
return !hasDrawing;
},
/**
* @param {Object} str
* @param {Object} color
* 选择颜色
*/
selectColorEvent(str, color) {
this.selectColor = str;
this.lineColor = color;
if (this.checkCanvasContext()) {
try {
this.ctx.setStrokeStyle(this.lineColor);
uni.showToast({
title: `已选择${str === 'black' ? '黑色' : '红色'}`,
icon: 'success',
duration: 1000
});
} catch (error) {
console.error('Error setting color:', error);
}
}
},
//保存到相册
saveCanvasAsImg() {
if (!this.isCanvasReady) {
showToast('画布未就绪,请稍后再试', 'none', 2000);
return;
}
if (this.isEmpty()) {
showToast('没有任何绘制内容哦', 'none', 2000);
return;
}
if (!this.checkCanvasContext()) return;
// 统一使用uni-app方法保存
this.performSave();
},
// 执行保存操作(统一入口)
performSave() {
// 基础验证
if (!this.isCanvasReady) {
showToast('画布未就绪,请稍后再试', 'none', 2000);
return;
}
if (this.isEmpty()) {
showToast('没有任何绘制内容哦', 'none', 2000);
return;
}
if (!this.checkCanvasContext()) return;
showLoading('正在保存...');
// 统一使用uni.canvasToTempFilePath API
const canvasOptions = {
canvasId: 'handWriting',
fileType: 'png',
quality: 1,
success: (res) => {
hideLoading();
// #ifdef H5
// H5环境:创建下载链接
const link = document.createElement('a');
link.download = `signature_${Date.now()}.png`;
link.href = res.tempFilePath;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showToast('签名已下载', 'success', 2000);
// #endif
// #ifndef H5
// 小程序环境:保存到相册
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: (saveRes) => {
showToast('已成功保存到相册', 'success', 2000);
},
fail: (saveError) => {
if (saveError.errMsg.includes('auth')) {
showModal('保存失败', '需要相册权限,请在设置中开启', '去设置')
.then((modalRes) => {
if (modalRes.confirm) {
uni.openSetting();
}
});
} else {
showToast('保存失败,请重试', 'none', 2000);
}
}
});
// #endif
},
fail: (error) => {
hideLoading();
console.error('[保存失败]:', error);
showToast('生成签名图片失败', 'none', 2000);
}
};
uni.canvasToTempFilePath(canvasOptions, this);
},
//预览
previewCanvasImg() {
if (!this.isCanvasReady) {
showToast('画布未就绪,请稍后再试', 'none', 2000);
return;
}
if (this.isEmpty()) {
showToast('没有任何绘制内容哦', 'none', 2000);
return;
}
if (!this.checkCanvasContext()) return;
showLoading('正在生成预览...');
const canvasOptions = {
canvasId: 'handWriting',
fileType: 'png', // 改为png格式,兼容性更好
quality: 1,
success: (res) => {
console.log(res)
hideLoading();
uni.previewImage({
urls: [res.tempFilePath],
current: 0,
success: (res) => {
console.log(res, 'res')
},
fail: (error) => {
showToast('预览失败,请重试', 'none', 2000);
}
});
},
fail: (error) => {
hideLoading();
console.error('Canvas to temp file failed:', error);
showToast('生成预览图片失败', 'none', 2000);
}
};
// 统一使用uni.canvasToTempFilePath
uni.canvasToTempFilePath(canvasOptions, this);
},
// 完成签名
complete() {
if (!this.isCanvasReady) {
showToast('画布未就绪,请稍后再试', 'none', 2000);
return;
}
if (this.isEmpty()) {
showToast('请先进行签名', 'none', 2000);
return;
}
if (!this.checkCanvasContext()) return;
showLoading('正在生成签名...');
const canvasOptions = {
canvasId: 'handWriting',
fileType: 'png',
quality: 1,
success: (res) => {
// 生成签名图片成功后,上传到服务器
this.uploadSignatureImage(res.tempFilePath);
},
fail: (error) => {
hideLoading();
console.error('Canvas to temp file failed:', error);
showToast('生成签名失败,请重试', 'none', 2000);
}
};
// 统一使用uni.canvasToTempFilePath
uni.canvasToTempFilePath(canvasOptions, this);
},
// 上传签名图片到服务器
uploadSignatureImage(filePath) {
const config = this.getCurrentConfig();
// 使用UploadManager处理上传
this.uploadManager.performUpload(filePath, config)
.then(result => {
hideLoading();
this.$emit('complete', {
filePath: result.fileUrl,
success: true,
response: result.response,
retryCount: result.retryCount,
uploadTime: result.uploadTime
});
showToast('签名上传成功', 'success', 2000);
})
.catch(error => {
hideLoading();
const errorMsg = formatErrorMessage(error.message || error.toString());
showFinalError(errorMsg, this.getCurrentConfig());
this.$emit('complete', {
success: false,
error: error.message,
originalError: error,
retryCount: error.retryCount || 0
});
});
},
},
mounted() {
console.log('[签名组件] mounted 开始执行');
// 合并配置
this.mergeConfig();
// 初始化上传管理器
this.uploadManager = new UploadManager();
this.initCanvas();
},
};
</script>
<style lang="scss" scoped>
page {
background: #fbfbfb;
height: auto;
overflow: hidden;
}
.wrapper {
display: flex;
height: 100%;
align-content: center;
flex-direction: row;
justify-content: center;
font-size: 28rpx;
z-index: 999999;
border: 2rpx dashed #666;
background-color: rgba(0, 0, 0, 0.05);
}
.handWriting {
background: #fff;
width: 100%;
height: 100%;
}
.handRight {
display: inline-flex;
align-items: center;
}
.handCenter {
border: 4rpx dashed #e9e9e9;
flex: 5;
overflow: hidden;
box-sizing: border-box;
}
.handTitle {
transform: rotate(90deg);
flex: 1;
color: #666;
}
.handBtn {
height: 95vh;
display: inline-flex;
flex-direction: column;
justify-content: center;
align-content: center;
align-items: center;
flex: 1;
gap: 100rpx;
}
.handBtn button {
font-size: 28rpx;
color: #666;
background-color: transparent;
border: none;
transform: rotate(90deg);
width: 150rpx;
height: 70rpx;
}
/* 各个按钮的字体白色 背景色 */
.handBtn button:nth-child(1) {
color: #fff;
background-color: #007AFF;
}
.handBtn button:nth-child(2) {
color: #fff;
background-color: #FF4D4F;
}
.handBtn button:nth-child(3) {
color: #fff;
background-color: #00C49F;
}
.handBtn button:nth-child(4) {
color: #fff;
background-color: #FF9900;
}
.handBtn button:nth-child(5) {
color: #fff;
background-color: #9900FF;
}
</style>
使用案例
<template>
<view class="signature-page">
<!-- 顶部标题 -->
<view class="page-header">
<text class="title">电子签名</text>
<text class="subtitle">请在下方区域签署您的姓名</text>
</view>
<!-- 签名组件 -->
<view class="signature-wrapper">
<signature-component
ref="signatureRef"
:upload-url="uploadUrl"
:upload-config="uploadConfig"
:min-speed="1.2"
:min-width="2"
:max-width="12"
:open-smooth="true"
:max-history-length="25"
:bg-color="'#ffffff'"
@complete="onSignatureComplete"
@error="onSignatureError"
></signature-component>
</view>
<!-- 操作按钮组 -->
<view class="action-buttons">
<view class="btn-row">
<button class="btn btn-primary" @tap="handleSave">
<text class="btn-icon">💾</text>
<text class="btn-text">保存到相册</text>
</button>
<button class="btn btn-secondary" @tap="handlePreview">
<text class="btn-icon">👁️</text>
<text class="btn-text">预览签名</text>
</button>
</view>
<view class="btn-row">
<button class="btn btn-warning" @tap="handleClear">
<text class="btn-icon">🗑️</text>
<text class="btn-text">清空画布</text>
</button>
<button class="btn btn-info" @tap="handleUndo">
<text class="btn-icon">↩️</text>
<text class="btn-text">撤销上一步</text>
</button>
</view>
<button class="btn btn-success confirm-btn" @tap="handleComplete">
<text class="btn-icon">✅</text>
<text class="btn-text">确认并上传签名</text>
</button>
</view>
<!-- 预览区域 -->
<view class="preview-section" v-if="previewImage">
<view class="section-title">
<text>签名预览</text>
</view>
<view class="preview-image-container">
<image :src="previewImage" mode="aspectFit" class="preview-image" />
</view>
</view>
<!-- 上传结果 -->
<view class="result-section" v-if="uploadResult">
<view class="section-title">
<text>上传结果</text>
</view>
<view class="result-content">
<text>{{ uploadResult }}</text>
</view>
</view>
<!-- 状态提示 -->
<view class="status-toast" v-if="statusMessage">
<text>{{ statusMessage }}</text>
</view>
</view>
</template>
<script>
// 导入签名组件
import signatureComponent from '@/components/sign.vue'
export default {
components: {
signatureComponent
},
data() {
return {
// 上传配置 - 根据实际API调整
uploadUrl: 'https://your-api-domain.com/api/upload/signature',
uploadConfig: {
headers: {
'Authorization': 'Bearer ' + uni.getStorageSync('token'),
'X-Requested-With': 'XMLHttpRequest'
},
formData: {
userId: uni.getStorageSync('userId') || 'unknown',
businessType: 'contract',
timestamp: Date.now(),
platform: uni.getSystemInfoSync().platform
},
fileName: `signature_${Date.now()}`,
fileType: 'png',
quality: 0.9,
timeout: 20000,
retryCount: 3
},
previewImage: '',
uploadResult: '',
statusMessage: '',
signatureData: null
}
},
onLoad(options) {
// 可以从页面参数中获取业务信息
if (options.contractId) {
this.uploadConfig.formData.contractId = options.contractId
}
this.showTips('请在画布区域签署您的姓名')
},
methods: {
// 显示提示信息
showTips(message, duration = 3000) {
this.statusMessage = message
setTimeout(() => {
this.statusMessage = ''
}, duration)
},
// 保存签名
handleSave() {
if (!this.$refs.signatureRef) {
this.showTips('签名组件未初始化')
return
}
if (this.$refs.signatureRef.isEmpty()) {
uni.showToast({
title: '请先进行签名',
icon: 'none',
duration: 2000
})
return
}
this.$refs.signatureRef.saveCanvasAsImg()
this.showTips('正在保存签名...')
},
// 预览签名
handlePreview() {
if (!this.$refs.signatureRef) {
this.showTips('签名组件未初始化')
return
}
if (this.$refs.signatureRef.isEmpty()) {
uni.showToast({
title: '请先进行签名',
icon: 'none',
duration: 2000
})
return
}
this.$refs.signatureRef.previewCanvasImg()
},
// 清空画布
handleClear() {
if (!this.$refs.signatureRef) {
this.showTips('签名组件未初始化')
return
}
uni.showModal({
title: '提示',
content: '确定要清空画布吗?',
success: (res) => {
if (res.confirm) {
this.$refs.signatureRef.clear()
this.previewImage = ''
this.uploadResult = ''
this.showTips('画布已清空')
}
}
})
},
// 撤销操作
handleUndo() {
if (!this.$refs.signatureRef) {
this.showTips('签名组件未初始化')
return
}
this.$refs.signatureRef.undo()
},
// 完成并上传
handleComplete() {
if (!this.$refs.signatureRef) {
this.showTips('签名组件未初始化')
return
}
if (this.$refs.signatureRef.isEmpty()) {
uni.showToast({
title: '请先进行签名',
icon: 'none',
duration: 2000
})
return
}
uni.showModal({
title: '确认签名',
content: '确认提交此签名吗?提交后无法修改',
success: (res) => {
if (res.confirm) {
this.$refs.signatureRef.complete()
this.showTips('正在上传签名...', 5000)
}
}
})
},
// 签名完成回调
onSignatureComplete(result) {
console.log('签名完成回调:', result)
if (result.success) {
this.signatureData = result
this.previewImage = result.filePath
this.uploadResult = `✅ 签名上传成功!\n\n` +
`📁 文件已保存\n` +
`⏰ 时间: ${new Date().toLocaleString()}\n` +
`🆔 业务ID: ${this.uploadConfig.formData.contractId || '无'}`
this.showTips('签名上传成功!')
// 在实际业务中,这里可以跳转到下一步
uni.showToast({
title: '签名成功',
icon: 'success',
duration: 2000
})
// 3秒后返回上一页(根据业务需求调整)
setTimeout(() => {
uni.navigateBack({
delta: 1,
animationType: 'pop-out',
animationDuration: 300
})
}, 3000)
} else {
this.uploadResult = `❌ 上传失败!\n\n` +
`📛 错误: ${result.error || '未知错误'}\n` +
`🔄 重试次数: ${result.retryCount || 0}`
this.showTips('上传失败,请重试')
uni.showModal({
title: '上传失败',
content: result.error || '网络异常,请检查网络后重试',
showCancel: true,
cancelText: '取消',
confirmText: '重试',
success: (res) => {
if (res.confirm) {
this.handleComplete()
}
}
})
}
},
// 错误处理
onSignatureError(error) {
console.error('签名组件错误:', error)
uni.showToast({
title: '发生错误,请重试',
icon: 'error',
duration: 2000
})
}
}
}
</script>
<style lang="scss" scoped>
.signature-page {
padding: 20rpx;
background: #f8f9fa;
min-height: 100vh;
}
.page-header {
text-align: center;
padding: 30rpx 0;
.title {
font-size: 40rpx;
font-weight: bold;
color: #333;
display: block;
}
.subtitle {
font-size: 28rpx;
color: #666;
margin-top: 10rpx;
display: block;
}
}
.signature-wrapper {
background: #fff;
border-radius: 16rpx;
padding: 20rpx;
margin: 20rpx 0;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
}
.action-buttons {
margin: 40rpx 0;
}
.btn-row {
display: flex;
justify-content: space-between;
margin-bottom: 20rpx;
gap: 20rpx;
}
.btn {
flex: 1;
padding: 24rpx 0;
border-radius: 12rpx;
border: none;
font-size: 28rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
&-primary {
background: linear-gradient(135deg, #007aff, #0056cc);
color: white;
}
&-secondary {
background: linear-gradient(135deg, #ff9500, #ff6b00);
color: white;
}
&-warning {
background: linear-gradient(135deg, #ff3b30, #d70015);
color: white;
}
&-info {
background: linear-gradient(135deg, #5ac8fa, #007aff);
color: white;
}
&-success {
background: linear-gradient(135deg, #34c759, #00a651);
color: white;
}
}
.confirm-btn {
width: 100%;
margin-top: 30rpx;
}
.preview-section,
.result-section {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin: 30rpx 0;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
border-left: 8rpx solid #007aff;
padding-left: 20rpx;
}
.preview-image-container {
width: 100%;
height: 300rpx;
border: 2rpx dashed #ddd;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f9f9f9;
}
.preview-image {
width: 100%;
height: 100%;
border-radius: 8rpx;
}
.result-content {
background: #f8f9fa;
padding: 24rpx;
border-radius: 12rpx;
font-size: 26rpx;
line-height: 1.6;
color: #333;
white-space: pre-line;
}
.status-toast {
position: fixed;
bottom: 100rpx;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 20rpx 40rpx;
border-radius: 50rpx;
font-size: 26rpx;
z-index: 1000;
animation: fadeInOut 3s ease-in-out;
}
@keyframes fadeInOut {
0%,
100% {
opacity: 0;
transform: translateX(-50%) translateY(20rpx);
}
10%,
90% {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.btn-row {
flex-direction: column;
gap: 15rpx;
}
.btn {
width: 100%;
}
}
</style>
总结与展望
本文详细介绍了一个基于uni-app的高性能手写签名组件的开发过程,涵盖了核心技术实现、性能优化、兼容性处理和错误处理等方面。这个组件具有以下特点:
-
高性能:通过优化算法和数据结构,确保流畅的绘制体验
-
跨平台:兼容H5、小程序和App多个平台
-
可扩展:模块化设计,易于扩展新功能
-
健壮性:完善的错误处理和用户提示机制