poster-popup.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. <template>
  2. <CustomPopup v-model="show" mode="center" width="680rpx" bgColor="transparent" :maskClosable="true">
  3. <view class="poster-popup-content">
  4. <!-- Canvas (Hidden) -->
  5. <canvas canvas-id="posterCanvas" class="poster-canvas"
  6. :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"></canvas>
  7. <!-- Generated Image -->
  8. <view class="image-container" v-if="posterImage">
  9. <image :src="posterImage" mode="widthFix" class="poster-img" @longpress="savePoster"></image>
  10. <view class="tips">长按保存图片到相册</view>
  11. </view>
  12. <view class="loading-box" v-else>
  13. <u-loading mode="circle" size="60" color="#fff"></u-loading>
  14. <text class="loading-text">海报生成中...</text>
  15. </view>
  16. </view>
  17. </CustomPopup>
  18. </template>
  19. <script>
  20. import CustomPopup from '@/components/custom-popup.vue';
  21. export default {
  22. name: 'PosterPopup',
  23. components: {
  24. CustomPopup
  25. },
  26. props: {
  27. product: {
  28. type: Object,
  29. default: () => ({})
  30. },
  31. userInfo: {
  32. type: Object,
  33. default: () => ({})
  34. },
  35. qrcodeUrl: {
  36. type: String,
  37. default: ''
  38. }
  39. },
  40. data() {
  41. return {
  42. show: false,
  43. posterImage: '',
  44. canvasWidth: 350, // px
  45. canvasHeight: 530, // px
  46. ctx: null
  47. };
  48. },
  49. methods: {
  50. open() {
  51. this.show = true;
  52. if (!this.posterImage) {
  53. this.generatePoster();
  54. }
  55. },
  56. close() {
  57. this.show = false;
  58. },
  59. async generatePoster() {
  60. uni.showLoading({ title: '生成中...' });
  61. try {
  62. // 确保Canvas元素已渲染
  63. await new Promise(resolve => setTimeout(resolve, 100));
  64. const ctx = uni.createCanvasContext('posterCanvas', this);
  65. this.ctx = ctx;
  66. // Setup dimensions
  67. const sysInfo = uni.getSystemInfoSync();
  68. const baseScale = (sysInfo.windowWidth || 375) / 750;
  69. // 清晰度与稳定性折中:按设备像素比绘制,但最多放大到 2 倍
  70. const renderRatio = Math.min(Math.max(sysInfo.pixelRatio || 1, 1), 2);
  71. const scale = baseScale * renderRatio;
  72. // User request: Canvas 340px, Padding 10px.
  73. // Mapping to rpx: 340px -> 680rpx, 10px -> 20rpx.
  74. const width = Math.floor(680 * scale);
  75. const height = Math.floor(1140 * scale);
  76. const padding = 20 * scale;
  77. // 确保Canvas尺寸在合理范围内
  78. const maxCanvasSize = 2000; // 保守上限,兼顾清晰度与稳定性
  79. if (width > maxCanvasSize || height > maxCanvasSize) {
  80. // 缩放Canvas尺寸到合理范围
  81. const scaleRatio = Math.min(maxCanvasSize / width, maxCanvasSize / height, 1);
  82. const scaledWidth = Math.floor(width * scaleRatio);
  83. const scaledHeight = Math.floor(height * scaleRatio);
  84. this.canvasWidth = scaledWidth;
  85. this.canvasHeight = scaledHeight;
  86. } else {
  87. this.canvasWidth = width;
  88. this.canvasHeight = height;
  89. }
  90. // 1. Draw Background (Gradient, Rounded)
  91. // linear-gradient(0deg, #FBFFFF 0%, #BFFCFE 100%) -> Bottom to Top
  92. const grd = ctx.createLinearGradient(0, this.canvasHeight, 0, 0);
  93. grd.addColorStop(0, '#FBFFFF');
  94. grd.addColorStop(1, '#BFFCFE');
  95. this.roundRect(ctx, 0, 0, this.canvasWidth, this.canvasHeight, 36 * scale, grd);
  96. // 2. Draw Product Cover
  97. const coverPath = await this.getImageInfo(this.product.cover || '/static/img/1.png', '/static/img/1.png');
  98. // Image width = Canvas width - 2 * padding
  99. const imgWidth = this.canvasWidth - (2 * padding);
  100. const coverHeight = imgWidth; // Square cover
  101. const coverX = padding;
  102. const coverY = padding;
  103. ctx.save();
  104. // Rounded corners for the cover image itself?
  105. // Prototype usually has rounded corners for the cover too.
  106. this.roundRectPath(ctx, coverX, coverY, imgWidth, coverHeight, 24 * scale, true, true, true, true);
  107. ctx.clip();
  108. try {
  109. if (coverPath) {
  110. ctx.drawImage(coverPath, coverX, coverY, imgWidth, coverHeight);
  111. } else {
  112. throw new Error('coverPath is empty');
  113. }
  114. } catch (e) {
  115. console.error('Draw cover image error:', e);
  116. // 绘制失败时使用默认颜色块
  117. ctx.setFillStyle('#f0f0f0');
  118. ctx.fillRect(coverX, coverY, imgWidth, coverHeight);
  119. }
  120. ctx.restore();
  121. // 3. Draw User Info
  122. const moveDownY = 20 * scale;
  123. const contentPadding = 30 * scale;
  124. const avatarSize = 80 * scale;
  125. const avatarX = padding;
  126. const avatarY = coverY + coverHeight - (avatarSize / 2) + moveDownY;
  127. // Avatar Border/Background
  128. ctx.beginPath();
  129. ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2 + 2 * scale, 0, 2 * Math.PI);
  130. ctx.setFillStyle('#FFFFFF');
  131. ctx.fill();
  132. // Avatar Image
  133. const avatarUrl = this.userInfo.imgPath || 'https://shuhi.oss-cn-qingdao.aliyuncs.com/mini/logo3.png';
  134. const avatarPath = await this.getImageInfo(avatarUrl, avatarUrl);
  135. ctx.save();
  136. ctx.beginPath();
  137. ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI);
  138. ctx.clip();
  139. try {
  140. if (avatarPath) {
  141. ctx.drawImage(avatarPath, avatarX, avatarY, avatarSize, avatarSize);
  142. } else {
  143. throw new Error('avatarPath is empty');
  144. }
  145. } catch (e) {
  146. console.error('Draw avatar error:', e);
  147. // 绘制失败时使用默认颜色块
  148. ctx.setFillStyle('#f0f0f0');
  149. ctx.fillRect(avatarX, avatarY, avatarSize, avatarSize);
  150. }
  151. ctx.restore();
  152. // User Name & Recomm Text
  153. // Align with avatar
  154. const userInfoY = coverY + coverHeight + 25 * scale + moveDownY;
  155. const textLeftX = avatarX + avatarSize + 16 * scale;
  156. ctx.setFillStyle('#1D1D1D');
  157. ctx.setFontSize(28 * scale);
  158. ctx.setTextAlign('left');
  159. ctx.fillText(this.userInfo.nickName || '微信用户', textLeftX, userInfoY);
  160. ctx.setFillStyle('#666666');
  161. ctx.setFontSize(26 * scale);
  162. ctx.setTextAlign('right');
  163. // Align to right padding edge
  164. ctx.fillText('推荐您一本好书', this.canvasWidth - padding, userInfoY);
  165. ctx.setTextAlign('left'); // Reset
  166. // 4. Product Info
  167. let currentY = coverY + coverHeight + 110 * scale + moveDownY;
  168. // Title
  169. ctx.setFillStyle('#1D1D1D');
  170. const titleFontSize = 34 * scale;
  171. ctx.setFontSize(titleFontSize);
  172. const title = this.product.bookName || '未知书名';
  173. // Width constraint: Canvas width - 2*contentPadding
  174. const lastTitleY = this.drawText(ctx, title, contentPadding, currentY, this.canvasWidth - 2 * contentPadding, titleFontSize * 1.5);
  175. currentY = lastTitleY + 60 * scale;
  176. // Author
  177. ctx.setFillStyle('#666666');
  178. const authorFontSize = 26 * scale;
  179. ctx.setFontSize(authorFontSize);
  180. ctx.fillText(`作者: ${this.product.author || '未知'}`, contentPadding, currentY);
  181. currentY += 70 * scale;
  182. // Price
  183. ctx.setFillStyle('#D81A00');
  184. const priceFontSize = 44 * scale;
  185. ctx.setFontSize(priceFontSize);
  186. const price = `¥ ${this.product.sellPrice || '0.00'}`;
  187. const priceWidth = ctx.measureText(price).width;
  188. ctx.fillText(price, contentPadding, currentY);
  189. // Original Price
  190. ctx.setFillStyle('#999999');
  191. ctx.setFontSize(26 * scale);
  192. const origPrice = `原价: ¥ ${this.product.price || '0.00'}`;
  193. ctx.fillText(origPrice, contentPadding + priceWidth + 20 * scale, currentY);
  194. // Divider
  195. const footerHeight = 180 * scale;
  196. const dividerY = this.canvasHeight - footerHeight;
  197. ctx.setStrokeStyle('#dddddd');
  198. if (typeof ctx.setLineDash === 'function') {
  199. ctx.setLineDash([8, 8]);
  200. }
  201. ctx.beginPath();
  202. ctx.moveTo(padding, dividerY);
  203. ctx.lineTo(this.canvasWidth - padding, dividerY);
  204. ctx.stroke();
  205. if (typeof ctx.setLineDash === 'function') {
  206. ctx.setLineDash([]);
  207. }
  208. // 5. Footer (Store & QR)
  209. const footerContentY = dividerY + 50 * scale;
  210. // Store Name
  211. ctx.setFillStyle('#1D1D1D');
  212. ctx.setFontSize(32 * scale);
  213. const storeName = '书嗨循环书店';
  214. const storeNameWidth = ctx.measureText(storeName).width;
  215. ctx.fillText(storeName, padding, footerContentY + 10 * scale);
  216. // Mascot Icon (Behind Store Name)
  217. const iconSize = 70 * scale;
  218. const iconX = padding + storeNameWidth + 40 * scale;
  219. // Align vertically with text (text baseline is at footerContentY + 10*scale)
  220. // Center icon relative to text cap height
  221. const iconY = footerContentY + 10 * scale - (30 * scale) / 2 - iconSize / 2 + 40;
  222. try {
  223. const iconPath = await this.getImageInfo('/pages-sell/static/goods/icon-shuhai.png', '/pages-sell/static/goods/icon-shuhai.png');
  224. if (iconPath) {
  225. ctx.drawImage(iconPath, iconX, iconY, iconSize, iconSize);
  226. }
  227. } catch (e) {
  228. console.error('Draw icon error:', e);
  229. }
  230. ctx.setFillStyle('#666666');
  231. ctx.setFontSize(22 * scale);
  232. ctx.fillText('不辜负每一个爱书的人', padding, footerContentY + 54 * scale);
  233. // QR Code
  234. const qrSize = 130 * scale;
  235. const qrX = this.canvasWidth - padding - qrSize;
  236. const qrY = footerContentY - 20 * scale;
  237. // Use gzh.png as the QR code (Figure 2)
  238. try {
  239. if (this.qrcodeUrl) {
  240. const qrPath = await this.getImageInfo(this.qrcodeUrl);
  241. if (qrPath) {
  242. ctx.drawImage(qrPath, qrX, qrY, qrSize, qrSize);
  243. }
  244. }
  245. } catch (e) {
  246. console.error('Draw QR code error:', e);
  247. }
  248. // 如果二维码获取失败,不影响海报创建,继续后续绘制
  249. // 使用Promise包装draw方法,确保绘制完成
  250. await new Promise((resolve) => {
  251. ctx.draw(false, () => {
  252. // 延长等待时间,确保绘制完成
  253. setTimeout(() => {
  254. resolve();
  255. }, 500);
  256. });
  257. });
  258. // 转换Canvas为图片(增加重试,兼容部分机型导出时机问题)
  259. const tempFilePath = await this.canvasToTempFilePathWithRetry({
  260. canvasId: 'posterCanvas',
  261. // 直接导出实际绘制尺寸,避免“导出阶段再放大”导致模糊
  262. destWidth: this.canvasWidth,
  263. destHeight: this.canvasHeight,
  264. fileType: 'png',
  265. quality: 0.9
  266. }, 3);
  267. this.posterImage = tempFilePath;
  268. uni.hideLoading();
  269. } catch (e) {
  270. console.error('Generate poster error:', e);
  271. uni.hideLoading();
  272. uni.showToast({ title: '生成出错', icon: 'none' });
  273. }
  274. },
  275. canvasToTempFilePathWithRetry(options, retries = 2) {
  276. return new Promise((resolve, reject) => {
  277. const attempt = (left) => {
  278. uni.canvasToTempFilePath({
  279. ...options,
  280. success: (res) => resolve(res.tempFilePath),
  281. fail: (err) => {
  282. if (left > 0) {
  283. setTimeout(() => attempt(left - 1), 180);
  284. return;
  285. }
  286. reject(err);
  287. }
  288. }, this);
  289. };
  290. attempt(retries);
  291. });
  292. },
  293. getImageInfo(url, fallback = '') {
  294. return new Promise((resolve) => {
  295. const safeFallback = fallback || '/static/img/1.png';
  296. const src = typeof url === 'string' ? url : '';
  297. if (!src) {
  298. resolve(safeFallback);
  299. return;
  300. }
  301. // 本地资源直接返回
  302. if (src.startsWith('/') || src.startsWith('static/')) {
  303. resolve(src);
  304. return;
  305. }
  306. // 优先使用 getImageInfo,兼容更多端图片解码
  307. uni.getImageInfo({
  308. src,
  309. success: (res) => {
  310. resolve(res.path || res.tempFilePath || safeFallback);
  311. },
  312. fail: () => {
  313. // 降级下载,失败时不抛异常,避免中断整张海报生成
  314. uni.downloadFile({
  315. url: src,
  316. success: (downloadRes) => {
  317. if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
  318. resolve(downloadRes.tempFilePath);
  319. return;
  320. }
  321. resolve(safeFallback);
  322. },
  323. fail: () => resolve(safeFallback)
  324. });
  325. }
  326. });
  327. });
  328. },
  329. roundRect(ctx, x, y, w, h, r, fillStyle) {
  330. ctx.beginPath();
  331. ctx.moveTo(x + r, y);
  332. ctx.arcTo(x + w, y, x + w, y + h, r);
  333. ctx.arcTo(x + w, y + h, x, y + h, r);
  334. ctx.arcTo(x, y + h, x, y, r);
  335. ctx.arcTo(x, y, x + w, y, r);
  336. ctx.closePath();
  337. if (fillStyle) {
  338. ctx.setFillStyle(fillStyle);
  339. ctx.fill();
  340. }
  341. },
  342. roundRectPath(ctx, x, y, w, h, r, tl, tr, br, bl) {
  343. ctx.beginPath();
  344. ctx.moveTo(x + r, y);
  345. if (tr) ctx.arcTo(x + w, y, x + w, y + h, r);
  346. else ctx.lineTo(x + w, y);
  347. if (br) ctx.arcTo(x + w, y + h, x, y + h, r);
  348. else ctx.lineTo(x + w, y + h);
  349. if (bl) ctx.arcTo(x, y + h, x, y, r);
  350. else ctx.lineTo(x, y + h);
  351. if (tl) ctx.arcTo(x, y, x + w, y, r);
  352. else ctx.lineTo(x, y);
  353. ctx.closePath();
  354. },
  355. drawText(ctx, str, x, y, maxWidth, lineHeight) {
  356. if (!str) return y;
  357. let line = '';
  358. let currentY = y;
  359. for (let i = 0; i < str.length; i++) {
  360. const testLine = line + str[i];
  361. const metrics = ctx.measureText(testLine);
  362. if (metrics.width > maxWidth && i > 0) {
  363. ctx.fillText(line, x, currentY);
  364. line = str[i];
  365. currentY += lineHeight;
  366. } else {
  367. line = testLine;
  368. }
  369. }
  370. ctx.fillText(line, x, currentY);
  371. return currentY;
  372. },
  373. savePoster() {
  374. if (!this.posterImage) return;
  375. uni.saveImageToPhotosAlbum({
  376. filePath: this.posterImage,
  377. success: () => {
  378. uni.showToast({ title: '保存成功', icon: 'success' });
  379. },
  380. fail: (err) => {
  381. console.log(err);
  382. // Handle auth denial
  383. uni.showModal({
  384. title: '提示',
  385. content: '需要保存到相册权限',
  386. success: (res) => {
  387. if (res.confirm) {
  388. uni.openSetting();
  389. }
  390. }
  391. });
  392. }
  393. });
  394. }
  395. }
  396. };
  397. </script>
  398. <style lang="scss" scoped>
  399. .poster-popup-content {
  400. display: flex;
  401. flex-direction: column;
  402. align-items: center;
  403. position: relative;
  404. padding: 0; // Remove padding
  405. background: transparent;
  406. }
  407. .poster-canvas {
  408. position: fixed;
  409. left: 10000px; // Hide canvas off-screen
  410. }
  411. .image-container {
  412. display: flex;
  413. flex-direction: column;
  414. align-items: center;
  415. width: 680rpx; // Match canvas width
  416. .poster-img {
  417. width: 100%;
  418. height: auto;
  419. border-radius: 36rpx;
  420. }
  421. .tips {
  422. margin-top: 20rpx;
  423. color: #fff;
  424. font-size: 24rpx;
  425. }
  426. }
  427. .loading-box {
  428. width: 680rpx;
  429. height: 1300rpx;
  430. display: flex;
  431. flex-direction: column;
  432. justify-content: center;
  433. align-items: center;
  434. background: linear-gradient(0deg, #FBFFFF 0%, #BFFCFE 100%);
  435. border-radius: 36rpx;
  436. .loading-text {
  437. margin-top: 20rpx;
  438. color: #666;
  439. }
  440. }
  441. </style>