canvasUtils.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. // Canvas工具类 - 用于处理海报生成
  2. export class CanvasUtils {
  3. constructor(canvasId) {
  4. this.canvasId = canvasId;
  5. }
  6. // 下载图片到本地(带重试机制)
  7. downloadImage(src, retryCount = 3) {
  8. return new Promise((resolve, reject) => {
  9. // 如果是本地路径,直接返回
  10. if (src.startsWith('/') || src.startsWith('http://localhost') || src.startsWith('file://')) {
  11. resolve(src);
  12. return;
  13. }
  14. const tryDownload = (attempt = 1) => {
  15. uni.downloadFile({
  16. url: src,
  17. success: (res) => {
  18. if (res.statusCode === 200) {
  19. resolve(res.tempFilePath);
  20. } else {
  21. console.error(`图片下载失败,状态码: ${res.statusCode}`);
  22. if (attempt < retryCount) {
  23. setTimeout(() => {
  24. tryDownload(attempt + 1);
  25. }, 1000 * attempt);
  26. } else {
  27. reject(new Error(`下载失败,状态码: ${res.statusCode}`));
  28. }
  29. }
  30. },
  31. fail: (err) => {
  32. console.error(`图片下载失败 (第${attempt}次): ${src}`, err);
  33. if (attempt < retryCount) {
  34. setTimeout(() => {
  35. tryDownload(attempt + 1);
  36. }, 1000 * attempt);
  37. } else {
  38. console.error(`图片下载最终失败: ${src}`);
  39. reject(err);
  40. }
  41. }
  42. });
  43. };
  44. tryDownload();
  45. });
  46. }
  47. // 绘制海报
  48. drawPoster(data) {
  49. return new Promise(async (resolve, reject) => {
  50. try {
  51. // 基础参数 - 使用2倍分辨率提高清晰度
  52. const w = uni.upx2px(690) * 2
  53. const top = uni.upx2px(0) * 2
  54. const r = uni.upx2px(24) * 2
  55. // 计算内容布局参数 - 使用2倍分辨率
  56. const watermarkArr = data.treeObj.watermarkArr || [];
  57. const lineHeight = uni.upx2px(40) * 2; // 水印文字行间距
  58. const startY = uni.upx2px(870) * 2; // 水印文字起始Y坐标
  59. const lastWatermarkY = startY + ((watermarkArr.length - 1) * lineHeight);
  60. const logoHeight = uni.upx2px(80) * 2;
  61. const logoSpacing = uni.upx2px(26) * 2;
  62. const logoY = lastWatermarkY - logoHeight - logoSpacing;
  63. const brandTextY = logoY + logoHeight + logoSpacing;
  64. // 使用固定高度,确保有足够空间显示所有内容
  65. // 计算canvas高度(根据内容自适应)
  66. const bottomMargin = uni.upx2px(20) * 2; // 底部边距
  67. const h = brandTextY + uni.upx2px(30) * 2 + bottomMargin; // 30rpx是文字高度估算
  68. // 下载所有图片到本地
  69. const imagePromises = [];
  70. const downloadedImages = {};
  71. // 下载二维码图片
  72. if (data.treeObj.qrCodeUrl) {
  73. imagePromises.push(
  74. this.downloadImage(data.treeObj.qrCodeUrl).then(localPath => {
  75. downloadedImages.qrCodeUrl = localPath;
  76. })
  77. );
  78. }
  79. // 下载树图片
  80. if (data.treeObj.posterUrl) {
  81. imagePromises.push(
  82. this.downloadImage(data.treeObj.posterUrl).then(localPath => {
  83. downloadedImages.posterUrl = localPath;
  84. })
  85. );
  86. }
  87. // 下载树牌背景图片
  88. const treeNameBgUrl = 'https://birdseye-img.sysuimars.com/youwei-uniapp/img/treePage/tag-bg.png';
  89. imagePromises.push(
  90. this.downloadImage(treeNameBgUrl).then(localPath => {
  91. downloadedImages.treeNameBgUrl = localPath;
  92. })
  93. );
  94. // 下载logo图片
  95. const logoUrl = 'https://birdseye-img.sysuimars.com/youwei-uniapp/img/treePage/logo.png';
  96. imagePromises.push(
  97. this.downloadImage(logoUrl).then(localPath => {
  98. downloadedImages.logoUrl = localPath;
  99. })
  100. );
  101. // 等待所有图片下载完成(带容错处理)
  102. try {
  103. await Promise.all(imagePromises);
  104. } catch (error) {
  105. // 继续执行,让canvas尝试绘制可用的图片
  106. }
  107. const ctx = uni.createCanvasContext(this.canvasId);
  108. if (!ctx) {
  109. reject(new Error('canvas context创建失败'));
  110. return;
  111. }
  112. // 清空canvas
  113. ctx.clearRect(0, 0, w, h);
  114. // 绘制白色背景
  115. ctx.setFillStyle('rgba(255, 255, 255, 0)')
  116. ctx.fillRect(0, 0, w, h);
  117. this.drawRoundedRect(ctx, 0, top, w, h, r, '#ffffff');
  118. // 绘制年份
  119. ctx.setFillStyle('#000000')
  120. ctx.setTextAlign('left') // 设置文字对齐方式
  121. ctx.setTextBaseline('top') // 设置文字基线
  122. ctx.font = `bold ${uni.upx2px(36) * 2}px "SweiSpringCJKtc", Arial, sans-serif` // 设置字体样式:粗体 + 自定义字体
  123. ctx.fillText(data.treeObj.year, uni.upx2px(26) * 2, uni.upx2px(34) * 2)
  124. // 绘制月份
  125. ctx.font = `bold ${uni.upx2px(172) * 2}px "SweiSpringCJKtc", Arial, sans-serif` // 设置字体样式:粗体 + 自定义字体,172rpx大小
  126. ctx.fillText(data.treeObj.monthNumber, uni.upx2px(26) * 2, uni.upx2px(76) * 2)
  127. // 绘制斜线
  128. ctx.setLineWidth(uni.upx2px(4) * 2) // 设置线条宽度
  129. ctx.beginPath()
  130. ctx.moveTo(uni.upx2px(160) * 2, uni.upx2px(180) * 2) // 斜线起点(右上角)
  131. ctx.lineTo(uni.upx2px(140) * 2, uni.upx2px(220) * 2) // 斜线终点(左下角)
  132. ctx.stroke()
  133. // 绘制日
  134. ctx.font = `bold ${uni.upx2px(48) * 2}px "SweiSpringCJKtc", Arial, sans-serif` // 设置字体样式:粗体 + 自定义字体,172rpx大小
  135. ctx.fillText(data.treeObj.day, uni.upx2px(162) * 2, uni.upx2px(180) * 2)
  136. // 绘制二维码图片
  137. if (downloadedImages.qrCodeUrl) {
  138. try {
  139. ctx.drawImage(downloadedImages.qrCodeUrl, uni.upx2px(545) * 2, uni.upx2px(34) * 2, uni.upx2px(130) * 2, uni.upx2px(142) * 2);
  140. } catch (error) {
  141. }
  142. }
  143. // 添加品种 绘制树龄 - 靠右对齐
  144. ctx.setFontSize(uni.upx2px(24) * 2)
  145. ctx.setTextAlign('right')
  146. ctx.fillText(`${data.treeObj.pz} ${data.treeObj.age}年 ${data.treeObj.age > 9 ? "老树" : "树龄"}`, uni.upx2px(665) * 2, uni.upx2px(186) * 2)
  147. // 绘制气候适宜 绘制采摘方式 - 靠右对齐
  148. ctx.setFontSize(uni.upx2px(24) * 2)
  149. ctx.setTextAlign('right')
  150. ctx.fillText(`${data.treeObj.phenology} 气候适宜-${data.treeObj.howTxt || "果园采摘"}`, uni.upx2px(665) * 2, uni.upx2px(225) * 2)
  151. // 绘制树图片(带圆角)
  152. const imgX = uni.upx2px(26) * 2;
  153. const imgY = uni.upx2px(280) * 2;
  154. const imgW = uni.upx2px(640) * 2;
  155. const imgH = uni.upx2px(480) * 2;
  156. const radius = uni.upx2px(10) * 2;
  157. // 创建圆角路径
  158. ctx.beginPath();
  159. ctx.moveTo(imgX + radius, imgY);
  160. ctx.lineTo(imgX + imgW - radius, imgY);
  161. ctx.arc(imgX + imgW - radius, imgY + radius, radius, -Math.PI/2, 0);
  162. ctx.lineTo(imgX + imgW, imgY + imgH - radius);
  163. ctx.arc(imgX + imgW - radius, imgY + imgH - radius, radius, 0, Math.PI/2);
  164. ctx.lineTo(imgX + radius, imgY + imgH);
  165. ctx.arc(imgX + radius, imgY + imgH - radius, radius, Math.PI/2, Math.PI);
  166. ctx.lineTo(imgX, imgY + radius);
  167. ctx.arc(imgX + radius, imgY + radius, radius, Math.PI, -Math.PI/2);
  168. ctx.closePath();
  169. // 裁剪为圆角区域
  170. ctx.save();
  171. ctx.clip();
  172. // 绘制图片
  173. if (downloadedImages.posterUrl) {
  174. try {
  175. ctx.drawImage(downloadedImages.posterUrl, imgX, imgY, imgW, imgH);
  176. } catch (error) {
  177. console.error('树图片绘制失败:', error);
  178. }
  179. }
  180. // 恢复裁剪
  181. ctx.restore();
  182. // 绘制图片文字背景和边框(圆角)
  183. const textX = uni.upx2px(450) * 2;
  184. const textY = uni.upx2px(316) * 2;
  185. const textWidth = uni.upx2px(180) * 2; // 背景宽度
  186. const textHeight = uni.upx2px(18) * 2; // 背景高度
  187. const padding = uni.upx2px(16) * 2; // 内边距
  188. const textRadius = uni.upx2px(25) * 2; // 圆角半径
  189. // 绘制黑色半透明背景(圆角)
  190. ctx.setFillStyle('rgba(0, 0, 0, 0.6)');
  191. ctx.beginPath();
  192. ctx.moveTo(textX - padding + textRadius, textY - padding);
  193. ctx.lineTo(textX - padding + textWidth + padding * 2 - textRadius, textY - padding);
  194. ctx.arc(textX - padding + textWidth + padding * 2 - textRadius, textY - padding + textRadius, textRadius, -Math.PI/2, 0);
  195. ctx.lineTo(textX - padding + textWidth + padding * 2, textY - padding + textHeight + padding * 2 - textRadius);
  196. ctx.arc(textX - padding + textWidth + padding * 2 - textRadius, textY - padding + textHeight + padding * 2 - textRadius, textRadius, 0, Math.PI/2);
  197. ctx.lineTo(textX - padding + textRadius, textY - padding + textHeight + padding * 2);
  198. ctx.arc(textX - padding + textRadius, textY - padding + textHeight + padding * 2 - textRadius, textRadius, Math.PI/2, Math.PI);
  199. ctx.lineTo(textX - padding, textY - padding + textRadius);
  200. ctx.arc(textX - padding + textRadius, textY - padding + textRadius, textRadius, Math.PI, -Math.PI/2);
  201. ctx.closePath();
  202. ctx.fill();
  203. // 绘制白色边框(圆角)
  204. ctx.setStrokeStyle('rgba(255, 255, 255, 0.39)');
  205. ctx.setLineWidth(uni.upx2px(2) * 2);
  206. ctx.beginPath();
  207. ctx.moveTo(textX - padding + textRadius, textY - padding);
  208. ctx.lineTo(textX - padding + textWidth + padding * 2 - textRadius, textY - padding);
  209. ctx.arc(textX - padding + textWidth + padding * 2 - textRadius, textY - padding + textRadius, textRadius, -Math.PI/2, 0);
  210. ctx.lineTo(textX - padding + textWidth + padding * 2, textY - padding + textHeight + padding * 2 - textRadius);
  211. ctx.arc(textX - padding + textWidth + padding * 2 - textRadius, textY - padding + textHeight + padding * 2 - textRadius, textRadius, 0, Math.PI/2);
  212. ctx.lineTo(textX - padding + textRadius, textY - padding + textHeight + padding * 2);
  213. ctx.arc(textX - padding + textRadius, textY - padding + textHeight + padding * 2 - textRadius, textRadius, Math.PI/2, Math.PI);
  214. ctx.lineTo(textX - padding, textY - padding + textRadius);
  215. ctx.arc(textX - padding + textRadius, textY - padding + textRadius, textRadius, Math.PI, -Math.PI/2);
  216. ctx.closePath();
  217. ctx.stroke();
  218. // 绘制文字
  219. ctx.setFillStyle('#ffffff');
  220. ctx.setTextAlign('center');
  221. ctx.setTextBaseline('middle');
  222. // 重置字体为默认字体
  223. ctx.font = `bold ${uni.upx2px(20) * 2}px Arial, sans-serif`;
  224. ctx.fillText(`${data.treeObj.pz}-${data.treeObj.countyName}`, textX + textWidth / 2, textY + textHeight / 2);
  225. // 绘制树牌文字背景
  226. if (downloadedImages.treeNameBgUrl) {
  227. try {
  228. ctx.drawImage(downloadedImages.treeNameBgUrl, uni.upx2px(40) * 2, uni.upx2px(486) * 2, uni.upx2px(276) * 2, uni.upx2px(274) * 2);
  229. } catch (error) {
  230. console.error('树牌背景图片绘制失败:', error);
  231. }
  232. }
  233. // 绘制树牌文字
  234. ctx.setFillStyle('#ffffff');
  235. ctx.font = `bold ${uni.upx2px(36) * 2}px "jiangxizhuokai", Arial, sans-serif`
  236. ctx.fillText(`【${data.treeName}】`, uni.upx2px(178) * 2, uni.upx2px(565) * 2)
  237. ctx.font = `bold ${uni.upx2px(18) * 2}px "jiangxizhuokai", Arial, sans-serif`
  238. 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) * 2, uni.upx2px(610) * 2)
  239. // 绘制横线(在水印文字上面)
  240. ctx.setStrokeStyle('#000000'); // 设置线条颜色为黑色
  241. ctx.setLineWidth(uni.upx2px(2) * 2);
  242. // 计算第一行水印文字的长度
  243. const firstWatermarkText = data.treeObj.watermarkArr[0] || '';
  244. ctx.font = `bold ${uni.upx2px(24) * 2}px "SweiSpringCJKtc", Arial, sans-serif`;
  245. const textMetrics = ctx.measureText(firstWatermarkText);
  246. const watermarkTextWidth = textMetrics.width;
  247. ctx.beginPath();
  248. ctx.moveTo(uni.upx2px(36) * 2, uni.upx2px(820) * 2); // 横线起点,左边距离36rpx
  249. ctx.lineTo(uni.upx2px(36) * 2 + watermarkTextWidth, uni.upx2px(820) * 2); // 横线终点,根据文字长度
  250. ctx.stroke();
  251. // 绘制水印文字(根据数组长度动态生成)
  252. ctx.setFillStyle('#000000');
  253. ctx.font = `bold ${uni.upx2px(24) * 2}px "SweiSpringCJKtc", Arial, sans-serif`
  254. ctx.setTextAlign('left'); // 设置左对齐,确保第一个字在指定位置
  255. watermarkArr.forEach((text, index) => {
  256. const y = startY + (index * lineHeight);
  257. ctx.fillText(text, uni.upx2px(36) * 2, y);
  258. });
  259. // 绘制logo(与最后一条水印文字对齐)
  260. if (downloadedImages.logoUrl) {
  261. try {
  262. ctx.drawImage(downloadedImages.logoUrl, uni.upx2px(590) * 2, logoY, uni.upx2px(76) * 2, logoHeight);
  263. } catch (error) {
  264. console.error('logo图片绘制失败:', error);
  265. }
  266. }
  267. // 绘制飞鸟有味(与logo保持间距)
  268. ctx.setFillStyle('#000000');
  269. ctx.font = `bold ${uni.upx2px(22) * 2}px "SweiSpringCJKtc", Arial, sans-serif`
  270. ctx.fillText(`飞鸟有味`, uni.upx2px(580) * 2, brandTextY);
  271. ctx.draw(false, () => {
  272. // 等待canvas渲染完成
  273. setTimeout(() => {
  274. // 额外等待时间确保图片完全渲染
  275. setTimeout(() => {
  276. resolve();
  277. }, 1000);
  278. }, 2000); // 增加等待时间确保图片渲染完成
  279. });
  280. } catch (error) {
  281. console.error('canvas绘制出错:', error);
  282. reject(error);
  283. }
  284. });
  285. }
  286. // 绘制圆角矩形
  287. drawRoundedRect(ctx, x, y, w, h, r, color) {
  288. ctx.setFillStyle(color);
  289. ctx.beginPath();
  290. // 左上角
  291. ctx.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5)
  292. // 右上角
  293. ctx.arc(x + w - r, y + r, r, Math.PI * 1.5, Math.PI * 2)
  294. // 右下角
  295. ctx.arc(x + w - r, y + h - r, r, 0, Math.PI * 0.5)
  296. // 左下角
  297. ctx.arc(x + r, y + h - r, r, Math.PI * 0.5, Math.PI)
  298. ctx.closePath()
  299. ctx.fill()
  300. }
  301. // 保存到相册
  302. saveToAlbum() {
  303. return new Promise((resolve, reject) => {
  304. uni.showLoading({
  305. title: '正在生成海报...',
  306. mask: true
  307. });
  308. // 先检查canvas是否存在
  309. const query = uni.createSelectorQuery();
  310. query.select(`#${this.canvasId}`).boundingClientRect((rect) => {
  311. if (!rect) {
  312. console.error('Canvas元素不存在');
  313. uni.hideLoading();
  314. uni.showToast({
  315. title: 'Canvas元素不存在',
  316. icon: 'none'
  317. });
  318. reject(new Error('Canvas元素不存在'));
  319. return;
  320. }
  321. uni.canvasToTempFilePath({
  322. canvasId: this.canvasId,
  323. width: 1380, // 2倍分辨率宽度
  324. height: 2280, // 2倍分辨率高度
  325. destWidth: 1380, // 2倍分辨率宽度
  326. destHeight: 2280, // 2倍分辨率高度
  327. fileType: 'png',
  328. success: (res) => {
  329. uni.hideLoading();
  330. uni.showLoading({
  331. title: '正在保存...',
  332. mask: true
  333. });
  334. // 生成带时间戳的海报文件名
  335. const timestamp = new Date().getTime();
  336. const posterFileName = `海报_${timestamp}.png`;
  337. uni.saveImageToPhotosAlbum({
  338. filePath: res.tempFilePath,
  339. success: () => {
  340. uni.hideLoading();
  341. uni.showToast({
  342. title: '海报保存成功',
  343. icon: 'success',
  344. duration: 2000
  345. });
  346. resolve(true);
  347. },
  348. fail: (err) => {
  349. console.error('保存到相册失败:', err);
  350. uni.hideLoading();
  351. if (err.errMsg && err.errMsg.includes('auth deny')) {
  352. uni.showModal({
  353. title: '权限提示',
  354. content: '需要相册权限才能保存图片,请前往设置开启',
  355. confirmText: '去设置',
  356. success: (modalRes) => {
  357. if (modalRes.confirm) {
  358. uni.openSetting();
  359. }
  360. }
  361. });
  362. } else {
  363. uni.showToast({
  364. title: '保存失败: ' + (err.errMsg || '未知错误'),
  365. icon: 'none',
  366. duration: 3000
  367. });
  368. }
  369. reject(err);
  370. }
  371. });
  372. },
  373. fail: (err) => {
  374. console.error('canvas转图片失败:', err);
  375. uni.hideLoading();
  376. uni.showToast({
  377. title: '图片生成失败: ' + (err.errMsg || '未知错误'),
  378. icon: 'none',
  379. duration: 3000
  380. });
  381. reject(err);
  382. }
  383. });
  384. }).exec();
  385. });
  386. }
  387. // 完整的保存流程
  388. async generateAndSave(data) {
  389. try {
  390. // 检查数据完整性
  391. if (!data || !data.treeObj) {
  392. throw new Error('数据不完整');
  393. }
  394. // 检查相册权限
  395. const authResult = await this.checkPhotoAlbumAuth();
  396. if (!authResult) {
  397. throw new Error('没有相册权限');
  398. }
  399. await this.drawPoster(data);
  400. await this.saveToAlbum();
  401. return true;
  402. } catch (error) {
  403. console.error('海报生成和保存失败:', error);
  404. uni.showToast({
  405. title: error.message || '海报保存失败',
  406. icon: 'none'
  407. });
  408. return false;
  409. }
  410. }
  411. // 检查相册权限
  412. checkPhotoAlbumAuth() {
  413. return new Promise((resolve) => {
  414. uni.getSetting({
  415. success: (res) => {
  416. if (res.authSetting['scope.writePhotosAlbum'] === false) {
  417. // 用户之前拒绝了权限,引导用户开启
  418. uni.showModal({
  419. title: '提示',
  420. content: '需要相册权限才能保存图片,是否前往设置?',
  421. success: (modalRes) => {
  422. if (modalRes.confirm) {
  423. uni.openSetting({
  424. success: (settingRes) => {
  425. if (settingRes.authSetting['scope.writePhotosAlbum']) {
  426. resolve(true);
  427. } else {
  428. resolve(false);
  429. }
  430. }
  431. });
  432. } else {
  433. resolve(false);
  434. }
  435. }
  436. });
  437. } else {
  438. // 请求权限
  439. uni.authorize({
  440. scope: 'scope.writePhotosAlbum',
  441. success: () => {
  442. resolve(true);
  443. },
  444. fail: () => {
  445. uni.showModal({
  446. title: '提示',
  447. content: '需要相册权限才能保存图片,是否前往设置?',
  448. success: (modalRes) => {
  449. if (modalRes.confirm) {
  450. uni.openSetting({
  451. success: (settingRes) => {
  452. if (settingRes.authSetting['scope.writePhotosAlbum']) {
  453. resolve(true);
  454. } else {
  455. resolve(false);
  456. }
  457. }
  458. });
  459. } else {
  460. resolve(false);
  461. }
  462. }
  463. });
  464. }
  465. });
  466. }
  467. },
  468. fail: () => {
  469. resolve(false);
  470. }
  471. });
  472. });
  473. }
  474. }