poster-popup.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  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. let nextX = contentPadding + priceWidth + 20 * scale;
  190. // Discount Tag
  191. if (this.product.sellPrice && this.product.price && this.product.price > 0 && this.product.sellPrice < this.product.price) {
  192. const discountNum = (this.product.sellPrice / this.product.price * 10);
  193. const discountText = '二手'+(Number.isInteger(discountNum) ? discountNum : discountNum.toFixed(1)) + '折';
  194. const tagFontSize = 22 * scale;
  195. ctx.setFontSize(tagFontSize);
  196. const textWidth = ctx.measureText(discountText).width;
  197. const tagPadX = 10 * scale;
  198. const tagPadY = 6 * scale;
  199. const tagW = textWidth + tagPadX * 2;
  200. const tagH = tagFontSize + tagPadY * 2;
  201. const tagY = currentY - tagFontSize - tagPadY + 2 * scale;
  202. this.roundRect(ctx, nextX, tagY, tagW, tagH, 8 * scale, '#D81A00');
  203. ctx.setFillStyle('#FFFFFF');
  204. ctx.fillText(discountText, nextX + tagPadX, currentY - 2 * scale);
  205. nextX += tagW + 20 * scale;
  206. }
  207. // Original Price
  208. ctx.setFillStyle('#999999');
  209. ctx.setFontSize(26 * scale);
  210. const origPrice = `原价: ¥ ${this.product.price || '0.00'}`;
  211. ctx.fillText(origPrice, nextX, currentY);
  212. // Divider
  213. const footerHeight = 180 * scale;
  214. const dividerY = this.canvasHeight - footerHeight;
  215. ctx.setStrokeStyle('#dddddd');
  216. if (typeof ctx.setLineDash === 'function') {
  217. ctx.setLineDash([8, 8]);
  218. }
  219. ctx.beginPath();
  220. ctx.moveTo(padding, dividerY);
  221. ctx.lineTo(this.canvasWidth - padding, dividerY);
  222. ctx.stroke();
  223. if (typeof ctx.setLineDash === 'function') {
  224. ctx.setLineDash([]);
  225. }
  226. // 5. Footer (Store & QR)
  227. const footerContentY = dividerY + 50 * scale;
  228. // Store Name
  229. ctx.setFillStyle('#1D1D1D');
  230. ctx.setFontSize(32 * scale);
  231. const storeName = '书嗨循环书店';
  232. const storeNameWidth = ctx.measureText(storeName).width;
  233. ctx.fillText(storeName, padding, footerContentY + 10 * scale);
  234. // Mascot Icon (Behind Store Name)
  235. const iconSize = 70 * scale;
  236. const iconX = padding + storeNameWidth + 40 * scale;
  237. // Align vertically with text (text baseline is at footerContentY + 10*scale)
  238. // Center icon relative to text cap height
  239. const iconY = footerContentY + 10 * scale - (30 * scale) / 2 - iconSize / 2 + 40;
  240. try {
  241. const iconPath = await this.getImageInfo('/pages-sell/static/goods/icon-shuhai.png', '/pages-sell/static/goods/icon-shuhai.png');
  242. if (iconPath) {
  243. ctx.drawImage(iconPath, iconX, iconY, iconSize, iconSize);
  244. }
  245. } catch (e) {
  246. console.error('Draw icon error:', e);
  247. }
  248. ctx.setFillStyle('#666666');
  249. ctx.setFontSize(22 * scale);
  250. ctx.fillText('不辜负每一个爱书的人', padding, footerContentY + 54 * scale);
  251. // QR Code
  252. const qrSize = 130 * scale;
  253. const qrX = this.canvasWidth - padding - qrSize;
  254. const qrY = footerContentY - 20 * scale;
  255. // Use gzh.png as the QR code (Figure 2)
  256. try {
  257. if (this.qrcodeUrl) {
  258. const qrPath = await this.getImageInfo(this.qrcodeUrl);
  259. if (qrPath) {
  260. ctx.drawImage(qrPath, qrX, qrY, qrSize, qrSize);
  261. }
  262. }
  263. } catch (e) {
  264. console.error('Draw QR code error:', e);
  265. }
  266. // 如果二维码获取失败,不影响海报创建,继续后续绘制
  267. // 使用Promise包装draw方法,确保绘制完成
  268. await new Promise((resolve) => {
  269. ctx.draw(false, () => {
  270. // 延长等待时间,确保绘制完成
  271. setTimeout(() => {
  272. resolve();
  273. }, 500);
  274. });
  275. });
  276. // 转换Canvas为图片(增加重试,兼容部分机型导出时机问题)
  277. const tempFilePath = await this.canvasToTempFilePathWithRetry({
  278. canvasId: 'posterCanvas',
  279. // 直接导出实际绘制尺寸,避免“导出阶段再放大”导致模糊
  280. destWidth: this.canvasWidth,
  281. destHeight: this.canvasHeight,
  282. fileType: 'png',
  283. quality: 0.9
  284. }, 3);
  285. this.posterImage = tempFilePath;
  286. uni.hideLoading();
  287. } catch (e) {
  288. console.error('Generate poster error:', e);
  289. uni.hideLoading();
  290. uni.showToast({ title: '生成出错', icon: 'none' });
  291. }
  292. },
  293. canvasToTempFilePathWithRetry(options, retries = 2) {
  294. return new Promise((resolve, reject) => {
  295. const attempt = (left) => {
  296. uni.canvasToTempFilePath({
  297. ...options,
  298. success: (res) => resolve(res.tempFilePath),
  299. fail: (err) => {
  300. if (left > 0) {
  301. setTimeout(() => attempt(left - 1), 180);
  302. return;
  303. }
  304. reject(err);
  305. }
  306. }, this);
  307. };
  308. attempt(retries);
  309. });
  310. },
  311. getImageInfo(url, fallback = '') {
  312. return new Promise((resolve) => {
  313. const safeFallback = fallback || '/static/img/1.png';
  314. const src = typeof url === 'string' ? url : '';
  315. if (!src) {
  316. resolve(safeFallback);
  317. return;
  318. }
  319. // 本地资源直接返回
  320. if (src.startsWith('/') || src.startsWith('static/')) {
  321. resolve(src);
  322. return;
  323. }
  324. // 优先使用 getImageInfo,兼容更多端图片解码
  325. uni.getImageInfo({
  326. src,
  327. success: (res) => {
  328. resolve(res.path || res.tempFilePath || safeFallback);
  329. },
  330. fail: () => {
  331. // 降级下载,失败时不抛异常,避免中断整张海报生成
  332. uni.downloadFile({
  333. url: src,
  334. success: (downloadRes) => {
  335. if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
  336. resolve(downloadRes.tempFilePath);
  337. return;
  338. }
  339. resolve(safeFallback);
  340. },
  341. fail: () => resolve(safeFallback)
  342. });
  343. }
  344. });
  345. });
  346. },
  347. roundRect(ctx, x, y, w, h, r, fillStyle) {
  348. ctx.beginPath();
  349. ctx.moveTo(x + r, y);
  350. ctx.arcTo(x + w, y, x + w, y + h, r);
  351. ctx.arcTo(x + w, y + h, x, y + h, r);
  352. ctx.arcTo(x, y + h, x, y, r);
  353. ctx.arcTo(x, y, x + w, y, r);
  354. ctx.closePath();
  355. if (fillStyle) {
  356. ctx.setFillStyle(fillStyle);
  357. ctx.fill();
  358. }
  359. },
  360. roundRectPath(ctx, x, y, w, h, r, tl, tr, br, bl) {
  361. ctx.beginPath();
  362. ctx.moveTo(x + r, y);
  363. if (tr) ctx.arcTo(x + w, y, x + w, y + h, r);
  364. else ctx.lineTo(x + w, y);
  365. if (br) ctx.arcTo(x + w, y + h, x, y + h, r);
  366. else ctx.lineTo(x + w, y + h);
  367. if (bl) ctx.arcTo(x, y + h, x, y, r);
  368. else ctx.lineTo(x, y + h);
  369. if (tl) ctx.arcTo(x, y, x + w, y, r);
  370. else ctx.lineTo(x, y);
  371. ctx.closePath();
  372. },
  373. drawText(ctx, str, x, y, maxWidth, lineHeight) {
  374. if (!str) return y;
  375. let line = '';
  376. let currentY = y;
  377. for (let i = 0; i < str.length; i++) {
  378. const testLine = line + str[i];
  379. const metrics = ctx.measureText(testLine);
  380. if (metrics.width > maxWidth && i > 0) {
  381. ctx.fillText(line, x, currentY);
  382. line = str[i];
  383. currentY += lineHeight;
  384. } else {
  385. line = testLine;
  386. }
  387. }
  388. ctx.fillText(line, x, currentY);
  389. return currentY;
  390. },
  391. savePoster() {
  392. if (!this.posterImage) return;
  393. uni.saveImageToPhotosAlbum({
  394. filePath: this.posterImage,
  395. success: () => {
  396. uni.showToast({ title: '保存成功', icon: 'success' });
  397. },
  398. fail: (err) => {
  399. console.log(err);
  400. // Handle auth denial
  401. uni.showModal({
  402. title: '提示',
  403. content: '需要保存到相册权限',
  404. success: (res) => {
  405. if (res.confirm) {
  406. uni.openSetting();
  407. }
  408. }
  409. });
  410. }
  411. });
  412. }
  413. }
  414. };
  415. </script>
  416. <style lang="scss" scoped>
  417. .poster-popup-content {
  418. display: flex;
  419. flex-direction: column;
  420. align-items: center;
  421. position: relative;
  422. padding: 0; // Remove padding
  423. background: transparent;
  424. }
  425. .poster-canvas {
  426. position: fixed;
  427. left: 10000px; // Hide canvas off-screen
  428. }
  429. .image-container {
  430. display: flex;
  431. flex-direction: column;
  432. align-items: center;
  433. width: 680rpx; // Match canvas width
  434. .poster-img {
  435. width: 100%;
  436. height: auto;
  437. border-radius: 36rpx;
  438. }
  439. .tips {
  440. margin-top: 20rpx;
  441. color: #fff;
  442. font-size: 24rpx;
  443. }
  444. }
  445. .loading-box {
  446. width: 680rpx;
  447. height: 1300rpx;
  448. display: flex;
  449. flex-direction: column;
  450. justify-content: center;
  451. align-items: center;
  452. background: linear-gradient(0deg, #FBFFFF 0%, #BFFCFE 100%);
  453. border-radius: 36rpx;
  454. .loading-text {
  455. margin-top: 20rpx;
  456. color: #666;
  457. }
  458. }
  459. </style>