poster-popup.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  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" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"></canvas>
  6. <!-- Generated Image -->
  7. <view class="image-container" v-if="posterImage">
  8. <image :src="posterImage" mode="widthFix" class="poster-img" @longpress="savePoster"></image>
  9. <view class="tips">长按保存图片到相册</view>
  10. </view>
  11. <view class="loading-box" v-else>
  12. <u-loading mode="circle" size="60" color="#fff"></u-loading>
  13. <text class="loading-text">海报生成中...</text>
  14. </view>
  15. </view>
  16. </CustomPopup>
  17. </template>
  18. <script>
  19. import CustomPopup from '@/components/custom-popup.vue';
  20. export default {
  21. name: 'PosterPopup',
  22. components: {
  23. CustomPopup
  24. },
  25. props: {
  26. product: {
  27. type: Object,
  28. default: () => ({})
  29. },
  30. userInfo: {
  31. type: Object,
  32. default: () => ({})
  33. }
  34. },
  35. data() {
  36. return {
  37. show: false,
  38. posterImage: '',
  39. canvasWidth: 350, // px
  40. canvasHeight: 530, // px
  41. ctx: null
  42. };
  43. },
  44. methods: {
  45. open() {
  46. this.show = true;
  47. if (!this.posterImage) {
  48. this.generatePoster();
  49. }
  50. },
  51. close() {
  52. this.show = false;
  53. },
  54. async generatePoster() {
  55. uni.showLoading({ title: '生成中...' });
  56. try {
  57. const ctx = uni.createCanvasContext('posterCanvas', this);
  58. this.ctx = ctx;
  59. // Setup dimensions
  60. const sysInfo = uni.getSystemInfoSync();
  61. const dpr = sysInfo.pixelRatio || 2;
  62. // Multiply scale by dpr to ensure high-res drawing on the canvas backing store
  63. const scale = (sysInfo.windowWidth / 750) * dpr;
  64. // User request: Canvas 340px, Padding 10px.
  65. // Mapping to rpx: 340px -> 680rpx, 10px -> 20rpx.
  66. const width = Math.floor(680 * scale);
  67. const height = Math.floor(1140 * scale);
  68. const padding = 20 * scale;
  69. this.canvasWidth = width;
  70. this.canvasHeight = height;
  71. // 1. Draw Background (Gradient, Rounded)
  72. // linear-gradient(0deg, #FBFFFF 0%, #BFFCFE 100%) -> Bottom to Top
  73. const grd = ctx.createLinearGradient(0, height, 0, 0);
  74. grd.addColorStop(0, '#FBFFFF');
  75. grd.addColorStop(1, '#BFFCFE');
  76. this.roundRect(ctx, 0, 0, width, height, 36 * scale, grd);
  77. // 2. Draw Product Cover
  78. const coverPath = await this.getImageInfo(this.product.cover || '/static/img/1.png');
  79. // Image width = Canvas width - 2 * padding
  80. const imgWidth = width - (2 * padding);
  81. const coverHeight = imgWidth; // Square cover
  82. const coverX = padding;
  83. const coverY = padding;
  84. ctx.save();
  85. // Rounded corners for the cover image itself?
  86. // Prototype usually has rounded corners for the cover too.
  87. this.roundRectPath(ctx, coverX, coverY, imgWidth, coverHeight, 24 * scale, true, true, true, true);
  88. ctx.clip();
  89. ctx.drawImage(coverPath, coverX, coverY, imgWidth, coverHeight);
  90. ctx.restore();
  91. // 3. Draw User Info
  92. const moveDownY = 20 * scale;
  93. const contentPadding = 30 * scale;
  94. const avatarSize = 80 * scale;
  95. const avatarX = padding;
  96. const avatarY = coverY + coverHeight - (avatarSize / 2) + moveDownY;
  97. // Avatar Border/Background
  98. ctx.beginPath();
  99. ctx.arc(avatarX + avatarSize/2, avatarY + avatarSize/2, avatarSize/2 + 2*scale, 0, 2 * Math.PI);
  100. ctx.setFillStyle('#FFFFFF');
  101. ctx.fill();
  102. // Avatar Image
  103. const avatarUrl = this.userInfo.avatar || 'https://img.yzcdn.cn/vant/cat.jpeg';
  104. const avatarPath = await this.getImageInfo(avatarUrl);
  105. ctx.save();
  106. ctx.beginPath();
  107. ctx.arc(avatarX + avatarSize/2, avatarY + avatarSize/2, avatarSize/2, 0, 2 * Math.PI);
  108. ctx.clip();
  109. ctx.drawImage(avatarPath, avatarX, avatarY, avatarSize, avatarSize);
  110. ctx.restore();
  111. // User Name & Recomm Text
  112. // Align with avatar
  113. const userInfoY = coverY + coverHeight + 25 * scale + moveDownY;
  114. const textLeftX = avatarX + avatarSize + 16 * scale;
  115. ctx.setFillStyle('#1D1D1D');
  116. ctx.setFontSize(28 * scale);
  117. ctx.font = `bold ${28 * scale}px sans-serif`;
  118. ctx.setTextAlign('left');
  119. ctx.fillText(this.userInfo.nickname || '微信用户', textLeftX, userInfoY);
  120. ctx.setFillStyle('#666666');
  121. ctx.setFontSize(26 * scale);
  122. ctx.font = `normal ${26 * scale}px sans-serif`;
  123. ctx.setTextAlign('right');
  124. // Align to right padding edge
  125. ctx.fillText('推荐您一本好书', width - padding, userInfoY);
  126. ctx.setTextAlign('left'); // Reset
  127. // 4. Product Info
  128. let currentY = coverY + coverHeight + 110 * scale + moveDownY;
  129. // Title
  130. ctx.setFillStyle('#1D1D1D');
  131. const titleFontSize = 34 * scale;
  132. ctx.setFontSize(titleFontSize);
  133. ctx.font = `bold ${titleFontSize}px sans-serif`;
  134. const title = this.product.title || '未知书名';
  135. // Width constraint: Canvas width - 2*contentPadding
  136. const lastTitleY = this.drawText(ctx, title, contentPadding, currentY, width - 2 * contentPadding, titleFontSize * 1.5);
  137. currentY = lastTitleY + 60 * scale;
  138. // Author
  139. ctx.setFillStyle('#666666');
  140. const authorFontSize = 26 * scale;
  141. ctx.setFontSize(authorFontSize);
  142. ctx.font = `normal ${authorFontSize}px sans-serif`;
  143. ctx.fillText(`作者: ${this.product.author || '未知'}`, contentPadding, currentY);
  144. currentY += 70 * scale;
  145. // Price
  146. ctx.setFillStyle('#D81A00');
  147. const priceFontSize = 44 * scale;
  148. ctx.setFontSize(priceFontSize);
  149. ctx.font = `bold ${priceFontSize}px sans-serif`;
  150. const price = `¥ ${this.product.balancePrice || '0.00'}`;
  151. const priceWidth = ctx.measureText(price).width;
  152. ctx.fillText(price, contentPadding, currentY);
  153. // Discount Tag
  154. const discount = '5.3折';
  155. const tagX = contentPadding + priceWidth + 16 * scale;
  156. const tagY = currentY - 26 * scale;
  157. ctx.setFillStyle('#D81A00');
  158. this.roundRect(ctx, tagX, tagY, 70 * scale, 32 * scale, 4 * scale, '#D81A00');
  159. ctx.setFillStyle('#FFFFFF');
  160. ctx.setFontSize(20 * scale);
  161. ctx.font = `normal ${20 * scale}px sans-serif`;
  162. ctx.fillText(discount, tagX + 8 * scale, tagY + 22 * scale);
  163. // Original Price
  164. ctx.setFillStyle('#999999');
  165. ctx.setFontSize(26 * scale);
  166. ctx.font = `normal ${26 * scale}px sans-serif`;
  167. const origPrice = `原价: ¥ ${this.product.originalPrice || '0.00'}`;
  168. ctx.fillText(origPrice, tagX + 90 * scale, currentY);
  169. // Divider
  170. const footerHeight = 180 * scale;
  171. const dividerY = height - footerHeight;
  172. ctx.setStrokeStyle('#dddddd');
  173. ctx.setLineDash([8, 8]);
  174. ctx.beginPath();
  175. ctx.moveTo(padding, dividerY);
  176. ctx.lineTo(width - padding, dividerY);
  177. ctx.stroke();
  178. ctx.setLineDash([]);
  179. // 5. Footer (Store & QR)
  180. const footerContentY = dividerY + 50 * scale;
  181. // Store Name
  182. ctx.setFillStyle('#1D1D1D');
  183. ctx.setFontSize(32 * scale);
  184. ctx.font = `bold ${32 * scale}px YouSheBiaoTiHei`;
  185. const storeName = '书嗨循环书店';
  186. const storeNameWidth = ctx.measureText(storeName).width;
  187. ctx.fillText(storeName, padding, footerContentY + 10 * scale);
  188. // Mascot Icon (Behind Store Name)
  189. const iconSize = 70 * scale;
  190. const iconX = padding + storeNameWidth + 40 * scale;
  191. // Align vertically with text (text baseline is at footerContentY + 10*scale)
  192. // Center icon relative to text cap height
  193. const iconY = footerContentY + 10 * scale - (30 * scale) / 2 - iconSize / 2 + 40;
  194. const iconPath = await this.getImageInfo('/pages-sell/static/goods/icon-shuhai.png');
  195. ctx.drawImage(iconPath, iconX, iconY, iconSize, iconSize);
  196. ctx.setFillStyle('#666666');
  197. ctx.setFontSize(22 * scale);
  198. ctx.font = `normal ${22 * scale}px sans-serif`;
  199. ctx.fillText('不辜负每一个爱书的人', padding, footerContentY + 54 * scale);
  200. // QR Code
  201. const qrSize = 130 * scale;
  202. const qrX = width - padding - qrSize;
  203. const qrY = footerContentY - 20 * scale;
  204. // Use gzh.png as the QR code (Figure 2)
  205. const qrPath = await this.getImageInfo('/pages-sell/static/goods/icon-shuhai.png');
  206. ctx.drawImage(qrPath, qrX, qrY, qrSize, qrSize);
  207. ctx.draw(false, () => {
  208. setTimeout(() => {
  209. uni.canvasToTempFilePath({
  210. canvasId: 'posterCanvas',
  211. destWidth: width,
  212. destHeight: height,
  213. fileType: 'png',
  214. quality: 1,
  215. success: (res) => {
  216. this.posterImage = res.tempFilePath;
  217. uni.hideLoading();
  218. },
  219. fail: (err) => {
  220. console.error(err);
  221. uni.hideLoading();
  222. uni.showToast({
  223. title: '生成失败',
  224. icon: 'none'
  225. });
  226. }
  227. }, this);
  228. }, 200);
  229. });
  230. } catch (e) {
  231. console.error(e);
  232. uni.hideLoading();
  233. uni.showToast({ title: '生成出错', icon: 'none' });
  234. }
  235. },
  236. getImageInfo(url) {
  237. return new Promise((resolve, reject) => {
  238. if (!url) {
  239. // Return a 1x1 transparent png base64 or similar if no url, or reject
  240. resolve('/static/logo.png'); // Fallback
  241. return;
  242. }
  243. // Handle local paths directly
  244. if (url.startsWith('/') || url.startsWith('static/')) {
  245. resolve(url);
  246. return;
  247. }
  248. uni.downloadFile({
  249. url: url,
  250. success: (res) => {
  251. if (res.statusCode === 200) {
  252. resolve(res.tempFilePath);
  253. } else {
  254. reject(res);
  255. }
  256. },
  257. fail: (err) => {
  258. reject(err);
  259. }
  260. });
  261. });
  262. },
  263. roundRect(ctx, x, y, w, h, r, fillStyle) {
  264. ctx.beginPath();
  265. ctx.moveTo(x + r, y);
  266. ctx.arcTo(x + w, y, x + w, y + h, r);
  267. ctx.arcTo(x + w, y + h, x, y + h, r);
  268. ctx.arcTo(x, y + h, x, y, r);
  269. ctx.arcTo(x, y, x + w, y, r);
  270. ctx.closePath();
  271. if (fillStyle) {
  272. ctx.setFillStyle(fillStyle);
  273. ctx.fill();
  274. }
  275. },
  276. roundRectPath(ctx, x, y, w, h, r, tl, tr, br, bl) {
  277. ctx.beginPath();
  278. ctx.moveTo(x + r, y);
  279. if (tr) ctx.arcTo(x + w, y, x + w, y + h, r);
  280. else ctx.lineTo(x + w, y);
  281. if (br) ctx.arcTo(x + w, y + h, x, y + h, r);
  282. else ctx.lineTo(x + w, y + h);
  283. if (bl) ctx.arcTo(x, y + h, x, y, r);
  284. else ctx.lineTo(x, y + h);
  285. if (tl) ctx.arcTo(x, y, x + w, y, r);
  286. else ctx.lineTo(x, y);
  287. ctx.closePath();
  288. },
  289. drawText(ctx, str, x, y, maxWidth, lineHeight) {
  290. if (!str) return y;
  291. let line = '';
  292. let currentY = y;
  293. for (let i = 0; i < str.length; i++) {
  294. const testLine = line + str[i];
  295. const metrics = ctx.measureText(testLine);
  296. if (metrics.width > maxWidth && i > 0) {
  297. ctx.fillText(line, x, currentY);
  298. line = str[i];
  299. currentY += lineHeight;
  300. } else {
  301. line = testLine;
  302. }
  303. }
  304. ctx.fillText(line, x, currentY);
  305. return currentY;
  306. },
  307. savePoster() {
  308. if (!this.posterImage) return;
  309. uni.saveImageToPhotosAlbum({
  310. filePath: this.posterImage,
  311. success: () => {
  312. uni.showToast({ title: '保存成功', icon: 'success' });
  313. },
  314. fail: (err) => {
  315. console.log(err);
  316. // Handle auth denial
  317. uni.showModal({
  318. title: '提示',
  319. content: '需要保存到相册权限',
  320. success: (res) => {
  321. if (res.confirm) {
  322. uni.openSetting();
  323. }
  324. }
  325. });
  326. }
  327. });
  328. }
  329. }
  330. };
  331. </script>
  332. <style lang="scss" scoped>
  333. .poster-popup-content {
  334. display: flex;
  335. flex-direction: column;
  336. align-items: center;
  337. position: relative;
  338. padding: 0; // Remove padding
  339. background: transparent;
  340. }
  341. .poster-canvas {
  342. position: fixed;
  343. left: 10000px; // Hide canvas off-screen
  344. }
  345. .image-container {
  346. display: flex;
  347. flex-direction: column;
  348. align-items: center;
  349. width: 680rpx; // Match canvas width
  350. .poster-img {
  351. width: 100%;
  352. height: auto;
  353. border-radius: 36rpx;
  354. }
  355. .tips {
  356. margin-top: 20rpx;
  357. color: #fff;
  358. font-size: 24rpx;
  359. }
  360. }
  361. .loading-box {
  362. width: 680rpx;
  363. height: 1300rpx;
  364. display: flex;
  365. flex-direction: column;
  366. justify-content: center;
  367. align-items: center;
  368. background: linear-gradient(0deg, #FBFFFF 0%, #BFFCFE 100%);
  369. border-radius: 36rpx;
  370. .loading-text {
  371. margin-top: 20rpx;
  372. color: #666;
  373. }
  374. }
  375. </style>