canvasUtils.js 17 KB

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