u-poster.vue 20 KB

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