You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

647 rivejä
20 KiB

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