poster-popup.vue 14 KB

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