uniapp 手寫簽名組件開發全攻略

引言

在移動應用開發中,手寫簽名功能是一個常見的需求,特別是在電子合同、審批流程、金融交易等場景中。本文將詳細介紹如何基于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 H5const 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 H5uni.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;// 提取文件URLfileUrl = 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 };}// 驗證上傳URLif (!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].xlet temY = e.changedTouches[0].ythis.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 = thisvar 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 APIif (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 APIthis.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 APIthis.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 APIconst 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.canvasToTempFilePathuni.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.canvasToTempFilePathuni.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-componentref="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 = messagesetTimeout(() => {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 = resultthis.previewImage = result.filePaththis.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的高性能手寫簽名組件的開發過程,涵蓋了核心技術實現、性能優化、兼容性處理和錯誤處理等方面。這個組件具有以下特點:

  1. 高性能:通過優化算法和數據結構,確保流暢的繪制體驗

  2. 跨平臺:兼容H5、小程序和App多個平臺

  3. 可擴展:模塊化設計,易于擴展新功能

  4. 健壯性:完善的錯誤處理和用戶提示機制

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/web/93872.shtml
繁體地址,請注明出處:http://hk.pswp.cn/web/93872.shtml
英文地址,請注明出處:http://en.pswp.cn/web/93872.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

理解JavaScript中的函數賦值和調用

&#x1f468; 作者簡介&#xff1a;大家好&#xff0c;我是Taro&#xff0c;全棧領域創作者 ?? 個人主頁&#xff1a;唐璜Taro &#x1f680; 支持我&#xff1a;點贊&#x1f44d;&#x1f4dd; 評論 ??收藏 文章目錄前言一、函數賦值二、函數調用三、 代碼示例總結前言…

交叉編譯 手動安裝 SQLite 庫 移植ARM

# 下載源碼 wget https://www.sqlite.org/2023/sqlite-autoconf-3420000.tar.gz tar -xzf sqlite-autoconf-3420000.tar.gz cd sqlite-autoconf-3420000cd /home/lxh/sqlite-autoconf-3420000 make distclean //清除下&#xff0c;因為我安裝失敗過。 ./configure --hostarm-…

翻譯記憶庫(TMX)與機器翻譯的結合應用

更多內容請見: 機器翻譯修煉-專欄介紹和目錄 文章目錄 一、核心概念解析 1.1 翻譯記憶庫 (Translation Memory, TM) 1.2 翻譯記憶交換格式 (Translation Memory eXchange, TMX) 二、為何要將兩者結合? 2.1 TM和MT的優勢是高度互補的 2.2 TMX在結合中的關鍵作用 2.3 TMX與MT的…

SpringBoot中集成eclipse.paho.client.mqttv3實現mqtt客戶端并支持斷線重連、線程池高并發改造、存儲入庫mqsql和redis示例業務流程,附資源下載

場景 SpringBoot整合MQTT服務器實現消息的發送與訂閱(推送消息與接收推送)&#xff1a; SpringBoot整合MQTT服務器實現消息的發送與訂閱(推送消息與接收推送)_服務端接收mqtt消息-CSDN博客 上面SpringBoot集成MQTT使用的是spring-integration-mqtt依賴&#xff0c;也是經常使…

【考研408數據結構-08】 圖論基礎:存儲結構與遍歷算法

&#x1f4da; 【考研408數據結構-08】 圖論基礎&#xff1a;存儲結構與遍歷算法 &#x1f3af; 考頻&#xff1a;????? | 題型&#xff1a;選擇題、綜合應用題、算法設計題 | 分值&#xff1a;約8-15分 引言 想象你正在規劃一次跨省自駕游&#xff0c;面前攤開一張復雜的…

SQL查詢語句的執行順序

好的&#xff0c;我們來詳細講解一下 SQL 查詢語句的執行順序。 很多人會誤以為 SQL 的執行順序就是我們寫的順序&#xff08;SELECT -> FROM -> WHERE -> GROUP BY -> HAVING -> ORDER BY&#xff09;&#xff0c;但實際上&#xff0c;數據庫引擎在底層處理查詢…

【Android】OKHttp網絡請求原理和弱網優化

【Android】OKHttp網絡請求原理和弱網優化 1. OkHttp 網絡請求原理 OkHttp 的請求過程可以分為 四個關鍵階段&#xff1a; &#xff08;假設你是通過 OkHttpClient.newCall(request).enqueue(callback) 發的請求&#xff09; OkHttpClient│▼ Dispatcher (調度器)│▼ RealC…

概率論基礎教程第4章 隨機變量(四)

4.7 泊松隨機變量 定義 泊松隨機變量&#xff1a;如果一個取值于 $ 0, 1, 2, \ldots $ 的隨機變量對某一個 $ \lambda > 0 $&#xff0c;其分布列為&#xff1a; p(i)P{Xi}e?λλii!i0,1,2,?(7.1) \boxed{p(i) P\{X i\} e^{-\lambda} \frac{\lambda^i}{i!} \qquad i 0…

Unity高級開發:反射原理深入解析與實踐指南 C#

Unity高級開發&#xff1a;反射原理深入解析與實踐指南 在Unity游戲開發中&#xff0c;反射&#xff08;Reflection&#xff09; 是一項強大的元編程技術&#xff0c;它允許程序在運行時動態地獲取類型信息、創建對象和調用方法。根據Unity官方統計&#xff0c;超過78%的商業游…

任務五 推薦頁面功能開發

一、推薦頁面需求分析 由推薦頁面效果圖,可以看出,推薦頁面主要由頂部輪播圖和歌單列表頁面組成 二、推薦頁面輪播圖組件封裝 由于輪播圖,可能在項目多個地方用到,因此可以將輪播圖抽調成一個組件,然后各個頁面調用這個組件。 在開發輪播圖組件時,需要安裝better-scro…

【工具使用-Docker容器】構建自己的鏡像和容器

1. 鏡像和容器介紹 鏡像&#xff08;Image&#xff09;是一個只讀的模板&#xff0c;包含了運行某個應用所需的全部內容&#xff0c;比如&#xff1a; 操作系統&#xff08;比如 Ubuntu&#xff09;應用程序代碼運行環境&#xff08;如 Python、Java、Node.js 等&#xff09;庫…

Apache Shiro550 漏洞(CVE-2016-4437):原理剖析與實戰 SOP

在 Web 安全領域&#xff0c;反序列化漏洞一直是威脅等級極高的存在&#xff0c;而 Apache Shiro 框架中的 Shiro550 漏洞&#xff08;CVE-2016-4437&#xff09;&#xff0c;更是因利用門檻低、影響范圍廣&#xff0c;成為滲透測試中頻繁遇到的經典漏洞。本文將從 “原理拆解”…

安卓開發者自學鴻蒙開發3持久化/數據與UI綁定

AppStorage,PersistentStorage與StorageLink AppStorage是應用全局狀態管理器,數據存儲于內存中,常見的如全局的黑暗模式,StorageLink是用來綁定AppStorage的鍵到ui上的工具,省去了用戶手寫代碼的無聊過程,PersistentStorage可以綁定AppStorage的鍵,自動持久化到磁盤,同時支持多…

GitHub宕機生存指南:從應急協作到高可用架構設計

GitHub宕機生存指南&#xff1a;從應急協作到高可用架構設計 摘要&#xff1a; GitHub作為全球開發者的協作中心&#xff0c;其服務穩定性至關重要。然而&#xff0c;任何在線服務都無法保證100%的可用性。本文深入探討了當GitHub意外宕機時&#xff0c;開發團隊應如何應對。我…

機器學習算法篇(十三)------詞向量轉化的算法思想詳解與基于詞向量轉換的文本數據處理的好評差評分類實戰(NPL基礎實戰)

目錄 一、詞向量原理介紹 (1). 詞向量的核心概念 (2). 傳統文本表示的局限性 1. 獨熱編碼&#xff08;One-Hot Encoding&#xff09; 2. 詞袋模型&#xff08;Bag of Words&#xff09; 3. TF-IDF (3). 詞向量的核心原理 (4). 主流詞向量模型 1. Word2Vec&#xff08;20…

JS自定義函數(2)

1. 變量的作用域全局變量定義&#xff1a;在函數外聲明的變量作用范圍&#xff1a;在整個JS文檔中生效生命周期&#xff1a;頁面關閉時銷毀局部變量定義&#xff1a;在函數內用 var 聲明的變量作用范圍&#xff1a;只能在函數內部使用生命周期&#xff1a;函數執行完畢時銷毀作…

【數據集】Argoverse 數據集:自動駕駛研究的強大基石

Argoverse數據集&#xff1a;自動駕駛研究的強大基石 在自動駕駛技術蓬勃發展的當下&#xff0c;高質量的數據集對于推動相關算法研究和模型訓練起著舉足輕重的作用。Argoverse 數據集便是其中的佼佼者&#xff0c;它為自動駕駛領域的眾多任務提供了豐富且優質的數據資源。 一、…

--- 哈希表和哈希沖突 ---

哈希&#xff08;散列&#xff09;方法是對插入的數據通過哈希函數計算出一個哈希地值&#xff0c;并將這個哈希地址作為儲存改數據的地址&#xff0c;這樣下次再查找這個數據時&#xff0c;只需要通過哈希函數再獲取到該地址然后直接去拿就好這樣就做到了不經過任何比較&#…

數學建模-評價類問題-優劣解距離法(TOPSIS)

1-AI帶你認識TOPSIS&#x1f4d8; 一、TOPSIS 方法簡介1. ??基本定義&#xff1a;????TOPSIS&#xff08;Technique for Order Preference by Similarity to an Ideal Solution&#xff09;??&#xff0c;中文通常稱為&#xff1a;???優劣解距離法?????逼近理想…

Go協程:從匯編視角揭秘實現奧秘

&#x1f680; Go協程&#xff1a;從匯編視角揭秘實現奧秘 #Go語言 #協程原理 #并發編程 #底層實現 引用&#xff1a; 關于 Go 協同程序&#xff08;Coroutines 協程&#xff09;、Go 匯編及一些注意事項。 &#x1f31f; 前言&#xff1a;重新定義并發編程范式 在當今高并發…