u-poster.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. <template>
  2. <view class="up-poster">
  3. <!-- canvas用于绘制海报 -->
  4. <canvas
  5. v-if="showCanvas"
  6. class="up-poster__hidden-canvas"
  7. :canvas-id="canvasId"
  8. :id="canvasId"
  9. :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }">
  10. </canvas>
  11. <!-- 隐藏的二维码组件,用于生成二维码图片 -->
  12. <up-qrcode
  13. ref="qrCode"
  14. :val="qrCodeValue"
  15. :size="qrCodeSize"
  16. :margin="0"
  17. :loadMake="false"
  18. background="#ffffff"
  19. foreground="#000000"
  20. :class="['up-poster__hidden-qrcode', qrCodeShow ? '' : 'up-poster__hidden-qrcode--hidden']"
  21. />
  22. </view>
  23. </template>
  24. <script>
  25. /**
  26. * Poster 海报组件
  27. * @description 用于生成海报的组件,支持文本、图片、二维码等元素
  28. * @tutorial https://ijry.github.io/uview-plus/components/poster.html
  29. *
  30. * @property {Object} json 海报配置JSON数据
  31. * @property {Object} json.css 海报容器样式
  32. * @property {Array} json.views 海报元素列表
  33. * @property {String} json.views.type 元素类型(text/image/qrcode/view)
  34. * @property {String} json.views.text 文本内容(仅text类型)
  35. * @property {String} json.views.src 图片地址(仅image/qrcode类型)
  36. * @property {Object} json.views.css 元素样式
  37. *
  38. * @example <up-poster :json="posterJson"></up-poster>
  39. */
  40. import {
  41. rpx2px
  42. } from '../../libs/function/index.js';
  43. export default {
  44. name: 'up-poster',
  45. props: {
  46. json: {
  47. type: Object,
  48. default: () => ({})
  49. }
  50. },
  51. data() {
  52. return {
  53. canvasId: 'u-poster-canvas-' + Date.now(),
  54. showCanvas: false,
  55. canvasWidth: 0,
  56. canvasHeight: 0,
  57. // 二维码相关数据
  58. qrCodeValue: '',
  59. qrCodeSize: 200,
  60. qrCodeShow: false,
  61. // 存储多个二维码的数据
  62. qrCodeMap: new Map()
  63. }
  64. },
  65. computed: {
  66. // 根据传入的css生成文本样式
  67. getTextStyle() {
  68. return (css) => {
  69. const style = {};
  70. if (css.color) style.color = css.color;
  71. if (css.fontSize) style.fontSize = css.fontSize;
  72. if (css.fontWeight) style.fontWeight = css.fontWeight;
  73. if (css.lineHeight) style.lineHeight = css.lineHeight;
  74. if (css.textAlign) style.textAlign = css.textAlign;
  75. return style;
  76. }
  77. }
  78. },
  79. methods: {
  80. /**
  81. * 导出海报图片
  82. * @description 根据json配置生成海报并导出为临时图片路径
  83. * @returns {Promise<Object>} 返回包含图片信息的对象
  84. * @author jry ijry@qq.com
  85. */
  86. async exportImage() {
  87. return new Promise(async(resolve, reject) => {
  88. try {
  89. // 获取海报尺寸信息
  90. const posterSize = this.json.css;
  91. // 将rpx转换为px
  92. const width = this.convertRpxToPx(posterSize.width || '750rpx');
  93. const height = this.convertRpxToPx(posterSize.height || '1114rpx');
  94. // 设置canvas尺寸
  95. this.canvasWidth = width;
  96. this.canvasHeight = height;
  97. this.showCanvas = true;
  98. // 等待DOM更新
  99. await this.$nextTick();
  100. // 创建canvas上下文
  101. const ctx = uni.createCanvasContext(this.canvasId, this);
  102. // 绘制背景
  103. if (posterSize.background) {
  104. // 支持渐变背景色
  105. if (posterSize.background.includes('linear-gradient') || posterSize.background.includes('radial-gradient')) {
  106. this.drawGradientBackground(ctx, posterSize, 0, 0, width, height);
  107. } else {
  108. ctx.setFillStyle(posterSize.background);
  109. ctx.fillRect(0, 0, width, height);
  110. }
  111. }
  112. // 绘制所有元素
  113. for (const item of this.json.views) {
  114. await this.drawItem(ctx, item, width, height);
  115. }
  116. // 绘制到canvas
  117. ctx.draw(false, () => {
  118. // 等待绘制完成
  119. setTimeout(() => {
  120. // 导出图片
  121. uni.canvasToTempFilePath({
  122. canvasId: this.canvasId,
  123. success: (res) => {
  124. // 隐藏canvas
  125. this.showCanvas = false;
  126. // 返回图片路径
  127. resolve({
  128. width: width,
  129. height: height,
  130. path: res.tempFilePath,
  131. // H5下添加blob格式
  132. blob: this.dataURLToBlob(res.tempFilePath)
  133. });
  134. },
  135. fail: (err) => {
  136. // 隐藏canvas
  137. this.showCanvas = false;
  138. reject(new Error('导出图片失败: ' + JSON.stringify(err)));
  139. }
  140. }, this);
  141. }, 300);
  142. });
  143. // 超时处理
  144. setTimeout(() => {
  145. this.showCanvas = false;
  146. reject(new Error('导出图片超时'));
  147. }, 10000);
  148. } catch (error) {
  149. this.showCanvas = false;
  150. reject(error);
  151. }
  152. });
  153. },
  154. /**
  155. * 绘制单个元素
  156. * @description 根据元素类型绘制文本、图片、矩形或二维码到canvas
  157. * @param {Object} ctx canvas上下文
  158. * @param {Object} item 元素配置信息
  159. * @param {Number} canvasWidth canvas宽度
  160. * @param {Number} canvasHeight canvas高度
  161. * @returns {Promise} 绘制完成的Promise
  162. * @author jry ijry@qq.com
  163. */
  164. async drawItem(ctx, item, canvasWidth, canvasHeight) {
  165. const css = item.css || {};
  166. const left = this.convertRpxToPx(css.left || '0rpx');
  167. const top = this.convertRpxToPx(css.top || '0rpx');
  168. const width = this.convertRpxToPx(css.width || '0rpx');
  169. const height = this.convertRpxToPx(css.height || '0rpx');
  170. switch (item.type) {
  171. case 'view':
  172. // 绘制矩形背景
  173. if (css.background) {
  174. // 支持渐变背景色
  175. if (css.background.includes('linear-gradient') || css.background.includes('radial-gradient')) {
  176. this.drawGradientBackground(ctx, css, left, top, width, height);
  177. } else {
  178. ctx.setFillStyle(css.background);
  179. // 处理圆角
  180. if (css.radius) {
  181. const radius = this.convertRpxToPx(css.radius);
  182. this.drawRoundRect(ctx, left, top, width, height, radius, css.background);
  183. } else {
  184. ctx.fillRect(left, top, width, height);
  185. }
  186. }
  187. }
  188. break;
  189. case 'text':
  190. // 设置文本样式
  191. if (css.color) ctx.setFillStyle(css.color);
  192. if (css.fontSize) {
  193. const fontSize = this.convertRpxToPx(css.fontSize);
  194. ctx.setFontSize(fontSize);
  195. }
  196. if (css.fontWeight) {
  197. ctx.setLineWidth(css.fontWeight === 'bold' ? 2 : 1);
  198. }
  199. // 处理文本换行
  200. if (css.lineClamp) {
  201. this.drawTextWithLineClamp(ctx, item.text, left, top, width, css);
  202. } else {
  203. // 修复:文本垂直居中对齐问题
  204. const textBaseLine = css.fontSize ? this.convertRpxToPx(css.fontSize) / 2 : 10;
  205. ctx.fillText(item.text, left, top + textBaseLine);
  206. }
  207. break;
  208. case 'image':
  209. // 绘制图片
  210. return new Promise((resolve) => {
  211. uni.getImageInfo({
  212. src: item.src,
  213. success: (res) => {
  214. // console.log('图片加载成功: ' + item.src, res);
  215. // 处理圆角
  216. if (css.radius) {
  217. const radius = this.convertRpxToPx(css.radius);
  218. this.clipRoundRect(ctx, left, top, width, height, radius);
  219. }
  220. // 不能用item.src,要用res.path。
  221. ctx.drawImage(res.path, left, top, width, height);
  222. // 恢复剪切区域
  223. ctx.restore();
  224. resolve();
  225. },
  226. fail: (e) => {
  227. // 图片加载失败时绘制占位符
  228. ctx.setFillStyle('#f5f5f5');
  229. ctx.fillRect(left, top, width, height);
  230. console.log('图片加载失败: ' + item.src, e);
  231. resolve();
  232. }
  233. });
  234. });
  235. case 'qrcode':
  236. // 绘制二维码
  237. if (item.text) {
  238. // 使用u-qrcode生成二维码图片
  239. const qrCodeImageUrl = await this.generateQRCode(item.text, width, height);
  240. return new Promise((resolve) => {
  241. uni.getImageInfo({
  242. src: qrCodeImageUrl,
  243. success: (res) => {
  244. ctx.drawImage(res.path, left, top, width, height);
  245. resolve();
  246. },
  247. fail: () => {
  248. // 二维码加载失败时绘制占位符
  249. ctx.setFillStyle('#f5f5f5');
  250. ctx.fillRect(left, top, width, height);
  251. ctx.setFillStyle('#999');
  252. ctx.setFontSize(12);
  253. ctx.setTextAlign('center');
  254. ctx.fillText('QR', left + width/2, top + height/2);
  255. ctx.setTextAlign('left');
  256. resolve();
  257. }
  258. });
  259. });
  260. } else {
  261. // 绘制二维码占位符
  262. ctx.setFillStyle('#f5f5f5');
  263. ctx.fillRect(left, top, width, height);
  264. ctx.setFillStyle('#999');
  265. ctx.setFontSize(12);
  266. ctx.setTextAlign('center');
  267. ctx.fillText('QR', left + width/2, top + height/2);
  268. ctx.setTextAlign('left');
  269. }
  270. break;
  271. }
  272. },
  273. /**
  274. * 绘制圆角矩形
  275. * @description 绘制指定位置和尺寸的圆角矩形
  276. * @param {Object} ctx canvas上下文
  277. * @param {Number} x x坐标
  278. * @param {Number} y y坐标
  279. * @param {Number} width 宽度
  280. * @param {Number} height 高度
  281. * @param {Number} radius 圆角半径
  282. * @param {String} fillColor 填充颜色
  283. * @author jry ijry@qq.com
  284. */
  285. drawRoundRect(ctx, x, y, width, height, radius, fillColor) {
  286. ctx.save();
  287. ctx.beginPath();
  288. ctx.moveTo(x + radius, y);
  289. ctx.lineTo(x + width - radius, y);
  290. ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
  291. ctx.lineTo(x + width, y + height - radius);
  292. ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
  293. ctx.lineTo(x + radius, y + height);
  294. ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
  295. ctx.lineTo(x, y + radius);
  296. ctx.quadraticCurveTo(x, y, x + radius, y);
  297. ctx.closePath();
  298. if (fillColor) {
  299. ctx.setFillStyle(fillColor);
  300. ctx.fill();
  301. }
  302. ctx.restore();
  303. },
  304. /**
  305. * 裁剪圆角矩形区域
  306. * @description 在canvas上创建圆角矩形裁剪区域
  307. * @param {Object} ctx canvas上下文
  308. * @param {Number} x x坐标
  309. * @param {Number} y y坐标
  310. * @param {Number} width 宽度
  311. * @param {Number} height 高度
  312. * @param {Number} radius 圆角半径
  313. * @author jry ijry@qq.com
  314. */
  315. clipRoundRect(ctx, x, y, width, height, radius) {
  316. ctx.save();
  317. ctx.beginPath();
  318. ctx.arc(x + radius, y + radius, radius, Math.PI, Math.PI * 1.5);
  319. ctx.lineTo(x + width - radius, y);
  320. ctx.arc(x + width - radius, y + radius, radius, Math.PI * 1.5, Math.PI * 2);
  321. ctx.lineTo(x + width, y + height - radius);
  322. ctx.arc(x + width - radius, y + height - radius, radius, 0, Math.PI * 0.5);
  323. ctx.lineTo(x + radius, y + height);
  324. ctx.arc(x + radius, y + height - radius, radius, Math.PI * 0.5, Math.PI);
  325. ctx.closePath();
  326. ctx.clip();
  327. },
  328. /**
  329. * 绘制带行数限制的文本
  330. * @description 绘制可控制最大行数的文本,超出部分显示省略号
  331. * @param {Object} ctx canvas上下文
  332. * @param {String} text 文本内容
  333. * @param {Number} x x坐标
  334. * @param {Number} y y坐标
  335. * @param {Number} maxWidth 最大宽度
  336. * @param {Object} css 样式配置
  337. * @author jry ijry@qq.com
  338. */
  339. drawTextWithLineClamp(ctx, text, x, y, maxWidth, css) {
  340. const lineClamp = parseInt(css.lineClamp) || 1;
  341. const lineHeight = css.lineHeight ? this.convertRpxToPx(css.lineHeight) : 20;
  342. const lines = [];
  343. let currentLine = '';
  344. for (let i = 0; i < text.length; i++) {
  345. const char = text[i];
  346. const testLine = currentLine + char;
  347. const metrics = ctx.measureText(testLine);
  348. if (metrics.width > maxWidth && currentLine !== '') {
  349. lines.push(currentLine);
  350. currentLine = char;
  351. // 如果已达最大行数,添加省略号并结束
  352. if (lines.length === lineClamp) {
  353. if (metrics.width > maxWidth) {
  354. // 添加省略号
  355. let fitLine = currentLine.substring(0, currentLine.length - 1);
  356. while (ctx.measureText(fitLine + '...').width > maxWidth && fitLine.length > 0) {
  357. fitLine = fitLine.substring(0, fitLine.length - 1);
  358. }
  359. lines[lines.length - 1] = fitLine + '...';
  360. }
  361. break;
  362. }
  363. } else {
  364. currentLine = testLine;
  365. }
  366. // 处理最后一行
  367. if (i === text.length - 1 && lines.length < lineClamp) {
  368. lines.push(currentLine);
  369. }
  370. }
  371. // 绘制每一行
  372. for (let i = 0; i < lines.length; i++) {
  373. // 修复:正确计算文本垂直位置
  374. const textBaseLine = css.fontSize ? this.convertRpxToPx(css.fontSize) / 2 : 10;
  375. ctx.fillText(lines[i], x, y + (i * lineHeight) + textBaseLine);
  376. }
  377. },
  378. /**
  379. * 生成二维码图片
  380. * @description 根据文本内容生成二维码图片URL
  381. * @param {String} text 二维码内容
  382. * @param {Number} width 二维码宽度
  383. * @param {Number} height 二维码高度
  384. * @returns {Promise<String>} 二维码图片URL
  385. * @author jry ijry@qq.com
  386. */
  387. generateQRCode(text, width, height) {
  388. return new Promise((resolve) => {
  389. // 为每个二维码生成唯一标识
  390. const qrCodeKey = `${text}_${width}_${height}`;
  391. // 检查是否已经生成过该二维码
  392. if (this.qrCodeMap.has(qrCodeKey)) {
  393. resolve(this.qrCodeMap.get(qrCodeKey));
  394. return;
  395. }
  396. // 使用 u-qrcode 组件生成二维码
  397. try {
  398. // 设置二维码参数
  399. this.qrCodeValue = text;
  400. this.qrCodeSize = Math.max(width, height);
  401. this.qrCodeShow = true;
  402. // 等待DOM更新
  403. this.$nextTick(() => {
  404. // 获取二维码组件实例并导出图片
  405. if (this.$refs.qrCode) {
  406. // 延迟一点时间确保二维码渲染完成
  407. setTimeout(() => {
  408. // 调用 u-qrcode 的 toTempFilePath 方法导出图片
  409. this.$refs.qrCode.toTempFilePath({
  410. success: (res) => {
  411. // 缓存二维码图片路径
  412. this.qrCodeMap.set(qrCodeKey, res.tempFilePath);
  413. this.qrCodeShow = false;
  414. resolve(res.tempFilePath);
  415. },
  416. fail: (err) => {
  417. console.error('二维码生成失败:', err);
  418. this.qrCodeShow = false;
  419. }
  420. });
  421. }, 300);
  422. } else {
  423. // 如果没有 u-qrcode 组件,返回占位符
  424. this.qrCodeShow = false;
  425. }
  426. });
  427. } catch (error) {
  428. console.error('生成二维码出错:', error);
  429. this.qrCodeShow = false;
  430. }
  431. });
  432. },
  433. /**
  434. * 将rpx单位转换为px
  435. * @description 根据屏幕密度将rpx单位转换为px单位
  436. * @param {String|Number} rpxValue rpx值
  437. * @returns {Number} 转换后的px值
  438. * @author jry ijry@qq.com
  439. */
  440. convertRpxToPx(rpxValue) {
  441. if (typeof rpxValue === 'number') return rpxValue;
  442. // 使用rpx2px方法
  443. if (typeof rpxValue === 'string' && rpxValue.endsWith('rpx')) {
  444. const value = parseFloat(rpxValue);
  445. return rpx2px(value);
  446. }
  447. return parseFloat(rpxValue) || 0;
  448. },
  449. /**
  450. * 绘制渐变背景
  451. * @description 绘制线性渐变或径向渐变背景
  452. * @param {Object} ctx canvas上下文
  453. * @param {Object} css 样式配置
  454. * @param {Number} left 左边距
  455. * @param {Number} top 上边距
  456. * @param {Number} width 宽度
  457. * @param {Number} height 高度
  458. * @author jry ijry@qq.com
  459. */
  460. drawGradientBackground(ctx, css, left, top, width, height) {
  461. const background = css.background;
  462. let gradient = null;
  463. // 处理线性渐变
  464. if (background.includes('linear-gradient')) {
  465. // 解析线性渐变角度和颜色
  466. const angleMatch = background.match(/linear-gradient\((\d+)deg/);
  467. const angle = angleMatch ? parseInt(angleMatch[1]) : 135;
  468. // 根据角度计算渐变起点和终点
  469. let startX = left, startY = top, endX = left + width, endY = top + height;
  470. // 简化的角度处理(支持常见角度)
  471. if (angle === 0) {
  472. startX = left;
  473. startY = top + height;
  474. endX = left;
  475. endY = top;
  476. } else if (angle === 90) {
  477. startX = left;
  478. startY = top;
  479. endX = left + width;
  480. endY = top;
  481. } else if (angle === 180) {
  482. startX = left;
  483. startY = top;
  484. endX = left;
  485. endY = top + height;
  486. } else if (angle === 270) {
  487. startX = left + width;
  488. startY = top;
  489. endX = left;
  490. endY = top;
  491. }
  492. gradient = ctx.createLinearGradient(startX, startY, endX, endY);
  493. // 解析颜色值
  494. const colorMatches = background.match(/#[0-9a-fA-F]+|rgba?\([^)]+\)/g);
  495. if (colorMatches && colorMatches.length >= 2) {
  496. // 添加渐变色点
  497. colorMatches.forEach((color, index) => {
  498. const stop = index / (colorMatches.length - 1);
  499. gradient.addColorStop(stop, color);
  500. });
  501. }
  502. }
  503. // 处理径向渐变
  504. else if (background.includes('radial-gradient')) {
  505. // 径向渐变从中心开始
  506. const centerX = left + width / 2;
  507. const centerY = top + height / 2;
  508. const radius = Math.min(width, height) / 2;
  509. gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
  510. // 解析颜色值
  511. const colorMatches = background.match(/#[0-9a-fA-F]+|rgba?\([^)]+\)/g);
  512. if (colorMatches && colorMatches.length >= 2) {
  513. // 添加渐变色点
  514. colorMatches.forEach((color, index) => {
  515. const stop = index / (colorMatches.length - 1);
  516. gradient.addColorStop(stop, color);
  517. });
  518. }
  519. }
  520. if (gradient) {
  521. ctx.setFillStyle(gradient);
  522. // 处理圆角
  523. if (css.radius) {
  524. const radius = this.convertRpxToPx(css.radius);
  525. this.drawRoundRect(ctx, left, top, width, height, radius, gradient);
  526. } else {
  527. ctx.fillRect(left, top, width, height);
  528. }
  529. }
  530. },
  531. /**
  532. * 将dataURL转换为Blob
  533. * @description H5环境下将base64格式的dataURL转换为Blob对象
  534. * @param {String} dataURL base64格式的图片数据
  535. * @returns {Blob} Blob对象
  536. * @author jry ijry@qq.com
  537. */
  538. dataURLToBlob(dataURL) {
  539. // 检查是否为H5环境且是base64数据
  540. // #ifdef H5
  541. if (dataURL && dataURL.startsWith('data:image')) {
  542. const parts = dataURL.split(';base64,');
  543. const contentType = parts[0].split(':')[1];
  544. const raw = window.atob(parts[1]);
  545. const rawLength = raw.length;
  546. const uInt8Array = new Uint8Array(rawLength);
  547. for (let i = 0; i < rawLength; ++i) {
  548. uInt8Array[i] = raw.charCodeAt(i);
  549. }
  550. return new Blob([uInt8Array], { type: contentType });
  551. }
  552. // #endif
  553. return null;
  554. },
  555. }
  556. }
  557. </script>
  558. <style lang="scss" scoped>
  559. .up-poster {
  560. position: relative;
  561. &__canvas {
  562. position: relative;
  563. overflow: hidden;
  564. }
  565. &__hidden-canvas {
  566. position: fixed;
  567. top: -10000px;
  568. left: -10000px;
  569. z-index: -1;
  570. }
  571. &__hidden-qrcode {
  572. position: fixed;
  573. top: -10000px;
  574. left: -10000px;
  575. z-index: -1;
  576. &--hidden {
  577. display: none;
  578. }
  579. }
  580. }
  581. </style>