// Canvas工具类 - 用于处理海报生成 export class CanvasUtils { constructor(canvasId) { this.canvasId = canvasId; } // 下载图片到本地(带重试机制) downloadImage(src, retryCount = 3) { return new Promise((resolve, reject) => { // 如果是本地路径,直接返回 if (src.startsWith('/') || src.startsWith('http://localhost') || src.startsWith('file://')) { resolve(src); return; } const tryDownload = (attempt = 1) => { uni.downloadFile({ url: src, success: (res) => { if (res.statusCode === 200) { resolve(res.tempFilePath); } else { console.error(`图片下载失败,状态码: ${res.statusCode}`); if (attempt < retryCount) { setTimeout(() => { tryDownload(attempt + 1); }, 1000 * attempt); } else { reject(new Error(`下载失败,状态码: ${res.statusCode}`)); } } }, fail: (err) => { console.error(`图片下载失败 (第${attempt}次): ${src}`, err); if (attempt < retryCount) { setTimeout(() => { tryDownload(attempt + 1); }, 1000 * attempt); } else { console.error(`图片下载最终失败: ${src}`); reject(err); } } }); }; tryDownload(); }); } // 绘制海报 drawPoster(data) { return new Promise(async (resolve, reject) => { try { // 基础参数 const w = uni.upx2px(690) const top = uni.upx2px(0) const r = uni.upx2px(24) // 计算内容布局参数 const watermarkArr = data.treeObj.watermarkArr || []; const lineHeight = uni.upx2px(40); // 水印文字行间距 const startY = uni.upx2px(870); // 水印文字起始Y坐标 const lastWatermarkY = startY + ((watermarkArr.length - 1) * lineHeight); const logoHeight = uni.upx2px(80); const logoSpacing = uni.upx2px(26); const logoY = lastWatermarkY - logoHeight - logoSpacing; const brandTextY = logoY + logoHeight + logoSpacing; // 使用固定高度,确保有足够空间显示所有内容 // 计算canvas高度(根据内容自适应) const bottomMargin = uni.upx2px(20); // 底部边距 const h = brandTextY + uni.upx2px(30) + bottomMargin; // 30rpx是文字高度估算 // 下载所有图片到本地 const imagePromises = []; const downloadedImages = {}; // 下载二维码图片 if (data.treeObj.qrCodeUrl) { imagePromises.push( this.downloadImage(data.treeObj.qrCodeUrl).then(localPath => { downloadedImages.qrCodeUrl = localPath; }) ); } // 下载树图片 if (data.treeObj.posterUrl) { imagePromises.push( this.downloadImage(data.treeObj.posterUrl).then(localPath => { downloadedImages.posterUrl = localPath; }) ); } // 下载树牌背景图片 const treeNameBgUrl = 'https://birdseye-img.sysuimars.com/youwei-uniapp/img/treePage/tag-bg.png'; imagePromises.push( this.downloadImage(treeNameBgUrl).then(localPath => { downloadedImages.treeNameBgUrl = localPath; }) ); // 下载logo图片 const logoUrl = 'https://birdseye-img.sysuimars.com/youwei-uniapp/img/treePage/logo.png'; imagePromises.push( this.downloadImage(logoUrl).then(localPath => { downloadedImages.logoUrl = localPath; }) ); // 等待所有图片下载完成(带容错处理) try { await Promise.all(imagePromises); } catch (error) { // 继续执行,让canvas尝试绘制可用的图片 } const ctx = uni.createCanvasContext(this.canvasId); if (!ctx) { reject(new Error('canvas context创建失败')); return; } // 清空canvas ctx.clearRect(0, 0, w, h); // 绘制白色背景 ctx.setFillStyle('rgba(255, 255, 255, 0)') ctx.fillRect(0, 0, w, h); this.drawRoundedRect(ctx, 0, top, w, h, r, '#ffffff'); // 绘制年份 ctx.setFillStyle('#000000') ctx.setTextAlign('left') // 设置文字对齐方式 ctx.setTextBaseline('top') // 设置文字基线 ctx.font = `bold ${uni.upx2px(36)}px "SweiSpringCJKtc", Arial, sans-serif` // 设置字体样式:粗体 + 自定义字体 ctx.fillText(data.treeObj.year, uni.upx2px(26), uni.upx2px(34)) // 绘制月份 ctx.font = `bold ${uni.upx2px(172)}px "SweiSpringCJKtc", Arial, sans-serif` // 设置字体样式:粗体 + 自定义字体,172rpx大小 ctx.fillText(data.treeObj.monthNumber, uni.upx2px(26), uni.upx2px(76)) // 绘制斜线 ctx.setLineWidth(uni.upx2px(4)) // 设置线条宽度 ctx.beginPath() ctx.moveTo(uni.upx2px(160), uni.upx2px(180)) // 斜线起点(右上角) ctx.lineTo(uni.upx2px(140), uni.upx2px(220)) // 斜线终点(左下角) ctx.stroke() // 绘制日 ctx.font = `bold ${uni.upx2px(48)}px "SweiSpringCJKtc", Arial, sans-serif` // 设置字体样式:粗体 + 自定义字体,172rpx大小 ctx.fillText(data.treeObj.day, uni.upx2px(162), uni.upx2px(180)) // 绘制二维码图片 if (downloadedImages.qrCodeUrl) { try { ctx.drawImage(downloadedImages.qrCodeUrl, uni.upx2px(w - -190), uni.upx2px(34), uni.upx2px(130), uni.upx2px(142)); } catch (error) { } } // 添加品种 ctx.setFontSize(uni.upx2px(24)) ctx.fillText(data.treeObj.pz, uni.upx2px(w - -218), uni.upx2px(190)) // 绘制树龄 ctx.setFontSize(uni.upx2px(24)) ctx.fillText(`${data.treeObj.age}年 ${data.treeObj.age > 9 ? "老树" : "树龄"}`, uni.upx2px(w - -104), uni.upx2px(190)) // 绘制气候适宜 ctx.setFontSize(uni.upx2px(24)) ctx.fillText(data.treeObj.phenology, uni.upx2px(w - -32), uni.upx2px(230)) // 绘制采摘方式 ctx.setFontSize(uni.upx2px(24)) ctx.fillText(`气候适宜-${data.treeObj.howTxt || "果园采摘"}`, uni.upx2px(w - -114), uni.upx2px(230)) // 绘制树图片(带圆角) const imgX = uni.upx2px(26); const imgY = uni.upx2px(280); const imgW = uni.upx2px(640); const imgH = uni.upx2px(480); const radius = uni.upx2px(10); // 创建圆角路径 ctx.beginPath(); ctx.moveTo(imgX + radius, imgY); ctx.lineTo(imgX + imgW - radius, imgY); ctx.arc(imgX + imgW - radius, imgY + radius, radius, -Math.PI/2, 0); ctx.lineTo(imgX + imgW, imgY + imgH - radius); ctx.arc(imgX + imgW - radius, imgY + imgH - radius, radius, 0, Math.PI/2); ctx.lineTo(imgX + radius, imgY + imgH); ctx.arc(imgX + radius, imgY + imgH - radius, radius, Math.PI/2, Math.PI); ctx.lineTo(imgX, imgY + radius); ctx.arc(imgX + radius, imgY + radius, radius, Math.PI, -Math.PI/2); ctx.closePath(); // 裁剪为圆角区域 ctx.save(); ctx.clip(); // 绘制图片 if (downloadedImages.posterUrl) { try { ctx.drawImage(downloadedImages.posterUrl, imgX, imgY, imgW, imgH); } catch (error) { console.error('树图片绘制失败:', error); } } // 恢复裁剪 ctx.restore(); // 绘制图片文字背景和边框(圆角) const textX = uni.upx2px(w - -104); const textY = uni.upx2px(316); const textWidth = uni.upx2px(180); // 背景宽度 const textHeight = uni.upx2px(18); // 背景高度 const padding = uni.upx2px(16); // 内边距 const textRadius = uni.upx2px(30); // 圆角半径 // 绘制黑色半透明背景(圆角) ctx.setFillStyle('rgba(0, 0, 0, 0.6)'); ctx.beginPath(); ctx.moveTo(textX - padding + textRadius, textY - padding); ctx.lineTo(textX - padding + textWidth + padding * 2 - textRadius, textY - padding); ctx.arc(textX - padding + textWidth + padding * 2 - textRadius, textY - padding + textRadius, textRadius, -Math.PI/2, 0); ctx.lineTo(textX - padding + textWidth + padding * 2, textY - padding + textHeight + padding * 2 - textRadius); ctx.arc(textX - padding + textWidth + padding * 2 - textRadius, textY - padding + textHeight + padding * 2 - textRadius, textRadius, 0, Math.PI/2); ctx.lineTo(textX - padding + textRadius, textY - padding + textHeight + padding * 2); ctx.arc(textX - padding + textRadius, textY - padding + textHeight + padding * 2 - textRadius, textRadius, Math.PI/2, Math.PI); ctx.lineTo(textX - padding, textY - padding + textRadius); ctx.arc(textX - padding + textRadius, textY - padding + textRadius, textRadius, Math.PI, -Math.PI/2); ctx.closePath(); ctx.fill(); // 绘制白色边框(圆角) ctx.setStrokeStyle('rgba(255, 255, 255, 0.39)'); ctx.setLineWidth(uni.upx2px(2)); ctx.beginPath(); ctx.moveTo(textX - padding + textRadius, textY - padding); ctx.lineTo(textX - padding + textWidth + padding * 2 - textRadius, textY - padding); ctx.arc(textX - padding + textWidth + padding * 2 - textRadius, textY - padding + textRadius, textRadius, -Math.PI/2, 0); ctx.lineTo(textX - padding + textWidth + padding * 2, textY - padding + textHeight + padding * 2 - textRadius); ctx.arc(textX - padding + textWidth + padding * 2 - textRadius, textY - padding + textHeight + padding * 2 - textRadius, textRadius, 0, Math.PI/2); ctx.lineTo(textX - padding + textRadius, textY - padding + textHeight + padding * 2); ctx.arc(textX - padding + textRadius, textY - padding + textHeight + padding * 2 - textRadius, textRadius, Math.PI/2, Math.PI); ctx.lineTo(textX - padding, textY - padding + textRadius); ctx.arc(textX - padding + textRadius, textY - padding + textRadius, textRadius, Math.PI, -Math.PI/2); ctx.closePath(); ctx.stroke(); // 绘制文字 ctx.setFillStyle('#ffffff'); ctx.setTextAlign('center'); ctx.setTextBaseline('middle'); // 重置字体为默认字体 ctx.font = `bold ${uni.upx2px(20)}px Arial, sans-serif`; ctx.fillText(`${data.treeObj.pz}-${data.treeObj.countyName}`, textX + textWidth / 2, textY + textHeight / 2); // 绘制树牌文字背景 if (downloadedImages.treeNameBgUrl) { try { ctx.drawImage(downloadedImages.treeNameBgUrl, uni.upx2px(40), uni.upx2px(486), uni.upx2px(276), uni.upx2px(274)); } catch (error) { console.error('树牌背景图片绘制失败:', error); } } // 绘制树牌文字 ctx.setFillStyle('#ffffff'); ctx.font = `bold ${uni.upx2px(36)}px "jiangxizhuokai", Arial, sans-serif` ctx.fillText(`【${data.treeName}】`, uni.upx2px(178), uni.upx2px(565)) ctx.font = `bold ${uni.upx2px(18)}px "jiangxizhuokai", Arial, sans-serif` ctx.fillText(`${data.userInfo.nickname || data.userInfo.name} ${data.treeObj.year}.${data.treeObj.monthNumber >= 10 ? data.treeObj.monthNumber : "0" + data.treeObj.monthNumber}.${data.treeObj.day}`, uni.upx2px(178), uni.upx2px(610)) // 绘制横线(在水印文字上面) ctx.setStrokeStyle('#000000'); // 设置线条颜色为黑色 ctx.setLineWidth(uni.upx2px(2)); // 计算第一行水印文字的长度 const firstWatermarkText = data.treeObj.watermarkArr[0] || ''; ctx.font = `bold ${uni.upx2px(24)}px "SweiSpringCJKtc", Arial, sans-serif`; const textMetrics = ctx.measureText(firstWatermarkText); const watermarkTextWidth = textMetrics.width; ctx.beginPath(); ctx.moveTo(uni.upx2px(36), uni.upx2px(820)); // 横线起点,左边距离36rpx ctx.lineTo(uni.upx2px(36) + watermarkTextWidth, uni.upx2px(820)); // 横线终点,根据文字长度 ctx.stroke(); // 绘制水印文字(根据数组长度动态生成) ctx.setFillStyle('#000000'); ctx.font = `bold ${uni.upx2px(24)}px "SweiSpringCJKtc", Arial, sans-serif` ctx.setTextAlign('left'); // 设置左对齐,确保第一个字在指定位置 watermarkArr.forEach((text, index) => { const y = startY + (index * lineHeight); ctx.fillText(text, uni.upx2px(36), y); }); // 绘制logo(与最后一条水印文字对齐) if (downloadedImages.logoUrl) { try { ctx.drawImage(downloadedImages.logoUrl, uni.upx2px(w - -230), logoY, uni.upx2px(76), logoHeight); } catch (error) { console.error('logo图片绘制失败:', error); } } // 绘制飞鸟有味(与logo保持间距) ctx.setFillStyle('#000000'); ctx.font = `bold ${uni.upx2px(22)}px "SweiSpringCJKtc", Arial, sans-serif` ctx.fillText(`飞鸟有味`, uni.upx2px(w - -225), brandTextY); ctx.draw(false, () => { // 等待canvas渲染完成 setTimeout(() => { // 额外等待时间确保图片完全渲染 setTimeout(() => { resolve(); }, 1000); }, 2000); // 增加等待时间确保图片渲染完成 }); } catch (error) { console.error('canvas绘制出错:', error); reject(error); } }); } // 绘制圆角矩形 drawRoundedRect(ctx, x, y, w, h, r, color) { ctx.setFillStyle(color); ctx.beginPath(); // 左上角 ctx.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5) // 右上角 ctx.arc(x + w - r, y + r, r, Math.PI * 1.5, Math.PI * 2) // 右下角 ctx.arc(x + w - r, y + h - r, r, 0, Math.PI * 0.5) // 左下角 ctx.arc(x + r, y + h - r, r, Math.PI * 0.5, Math.PI) ctx.closePath() ctx.fill() } // 保存到相册 saveToAlbum() { return new Promise((resolve, reject) => { uni.showLoading({ title: '正在生成图片...', mask: true }); // 先检查canvas是否存在 const query = uni.createSelectorQuery(); query.select(`#${this.canvasId}`).boundingClientRect((rect) => { if (!rect) { console.error('Canvas元素不存在'); uni.hideLoading(); uni.showToast({ title: 'Canvas元素不存在', icon: 'none' }); reject(new Error('Canvas元素不存在')); return; } uni.canvasToTempFilePath({ canvasId: this.canvasId, width: 690, height: 1140, // 固定高度1140rpx destWidth: 690, destHeight: 1140, // 固定高度1140rpx fileType: 'png', success: (res) => { uni.hideLoading(); uni.showLoading({ title: '正在保存...', mask: true }); uni.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: () => { uni.hideLoading(); uni.showToast({ title: '保存成功', icon: 'success', duration: 2000 }); resolve(true); }, fail: (err) => { console.error('保存到相册失败:', err); uni.hideLoading(); if (err.errMsg && err.errMsg.includes('auth deny')) { uni.showModal({ title: '权限提示', content: '需要相册权限才能保存图片,请前往设置开启', confirmText: '去设置', success: (modalRes) => { if (modalRes.confirm) { uni.openSetting(); } } }); } else { uni.showToast({ title: '保存失败: ' + (err.errMsg || '未知错误'), icon: 'none', duration: 3000 }); } reject(err); } }); }, fail: (err) => { console.error('canvas转图片失败:', err); uni.hideLoading(); uni.showToast({ title: '图片生成失败: ' + (err.errMsg || '未知错误'), icon: 'none', duration: 3000 }); reject(err); } }); }).exec(); }); } // 完整的保存流程 async generateAndSave(data) { try { // 检查数据完整性 if (!data || !data.treeObj) { throw new Error('数据不完整'); } // 检查相册权限 const authResult = await this.checkPhotoAlbumAuth(); if (!authResult) { throw new Error('没有相册权限'); } await this.drawPoster(data); await this.saveToAlbum(); return true; } catch (error) { console.error('海报生成和保存失败:', error); uni.showToast({ title: error.message || '保存失败', icon: 'none' }); return false; } } // 检查相册权限 checkPhotoAlbumAuth() { return new Promise((resolve) => { uni.getSetting({ success: (res) => { if (res.authSetting['scope.writePhotosAlbum'] === false) { // 用户之前拒绝了权限,引导用户开启 uni.showModal({ title: '提示', content: '需要相册权限才能保存图片,是否前往设置?', success: (modalRes) => { if (modalRes.confirm) { uni.openSetting({ success: (settingRes) => { if (settingRes.authSetting['scope.writePhotosAlbum']) { resolve(true); } else { resolve(false); } } }); } else { resolve(false); } } }); } else { // 请求权限 uni.authorize({ scope: 'scope.writePhotosAlbum', success: () => { resolve(true); }, fail: () => { uni.showModal({ title: '提示', content: '需要相册权限才能保存图片,是否前往设置?', success: (modalRes) => { if (modalRes.confirm) { uni.openSetting({ success: (settingRes) => { if (settingRes.authSetting['scope.writePhotosAlbum']) { resolve(true); } else { resolve(false); } } }); } else { resolve(false); } } }); } }); } }, fail: () => { resolve(false); } }); }); } }