|
|
@@ -0,0 +1,423 @@
|
|
|
+<template>
|
|
|
+ <CustomPopup v-model="show" mode="center" width="680rpx" bgColor="transparent" :maskClosable="true">
|
|
|
+ <view class="poster-popup-content">
|
|
|
+
|
|
|
+ <!-- Canvas (Hidden) -->
|
|
|
+ <canvas canvas-id="posterCanvas" class="poster-canvas" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"></canvas>
|
|
|
+
|
|
|
+ <!-- Generated Image -->
|
|
|
+ <view class="image-container" v-if="posterImage">
|
|
|
+ <image :src="posterImage" mode="widthFix" class="poster-img" @longpress="savePoster"></image>
|
|
|
+ <view class="tips">长按保存图片到相册</view>
|
|
|
+ </view>
|
|
|
+ <view class="loading-box" v-else>
|
|
|
+ <u-loading mode="circle" size="60" color="#fff"></u-loading>
|
|
|
+ <text class="loading-text">海报生成中...</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </CustomPopup>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import CustomPopup from '@/components/custom-popup.vue';
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'PosterPopup',
|
|
|
+ components: {
|
|
|
+ CustomPopup
|
|
|
+ },
|
|
|
+ props: {
|
|
|
+ product: {
|
|
|
+ type: Object,
|
|
|
+ default: () => ({})
|
|
|
+ },
|
|
|
+ userInfo: {
|
|
|
+ type: Object,
|
|
|
+ default: () => ({})
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ show: false,
|
|
|
+ posterImage: '',
|
|
|
+ canvasWidth: 350, // px
|
|
|
+ canvasHeight: 530, // px
|
|
|
+ ctx: null
|
|
|
+ };
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ open() {
|
|
|
+ this.show = true;
|
|
|
+ if (!this.posterImage) {
|
|
|
+ this.generatePoster();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ close() {
|
|
|
+ this.show = false;
|
|
|
+ },
|
|
|
+ async generatePoster() {
|
|
|
+ uni.showLoading({ title: '生成中...' });
|
|
|
+ try {
|
|
|
+ const ctx = uni.createCanvasContext('posterCanvas', this);
|
|
|
+ this.ctx = ctx;
|
|
|
+
|
|
|
+ // Setup dimensions
|
|
|
+ const sysInfo = uni.getSystemInfoSync();
|
|
|
+ const dpr = sysInfo.pixelRatio || 2;
|
|
|
+ // Multiply scale by dpr to ensure high-res drawing on the canvas backing store
|
|
|
+ const scale = (sysInfo.windowWidth / 750) * dpr;
|
|
|
+
|
|
|
+ // User request: Canvas 340px, Padding 10px.
|
|
|
+ // Mapping to rpx: 340px -> 680rpx, 10px -> 20rpx.
|
|
|
+ const width = Math.floor(680 * scale);
|
|
|
+ const height = Math.floor(1140 * scale);
|
|
|
+ const padding = 20 * scale;
|
|
|
+
|
|
|
+ this.canvasWidth = width;
|
|
|
+ this.canvasHeight = height;
|
|
|
+
|
|
|
+ // 1. Draw Background (Gradient, Rounded)
|
|
|
+ // linear-gradient(0deg, #FBFFFF 0%, #BFFCFE 100%) -> Bottom to Top
|
|
|
+ const grd = ctx.createLinearGradient(0, height, 0, 0);
|
|
|
+ grd.addColorStop(0, '#FBFFFF');
|
|
|
+ grd.addColorStop(1, '#BFFCFE');
|
|
|
+
|
|
|
+ this.roundRect(ctx, 0, 0, width, height, 36 * scale, grd);
|
|
|
+
|
|
|
+ // 2. Draw Product Cover
|
|
|
+ const coverPath = await this.getImageInfo(this.product.cover || '/static/img/1.png');
|
|
|
+ // Image width = Canvas width - 2 * padding
|
|
|
+ const imgWidth = width - (2 * padding);
|
|
|
+ const coverHeight = imgWidth; // Square cover
|
|
|
+ const coverX = padding;
|
|
|
+ const coverY = padding;
|
|
|
+
|
|
|
+ ctx.save();
|
|
|
+ // Rounded corners for the cover image itself?
|
|
|
+ // Prototype usually has rounded corners for the cover too.
|
|
|
+ this.roundRectPath(ctx, coverX, coverY, imgWidth, coverHeight, 24 * scale, true, true, true, true);
|
|
|
+ ctx.clip();
|
|
|
+ ctx.drawImage(coverPath, coverX, coverY, imgWidth, coverHeight);
|
|
|
+ ctx.restore();
|
|
|
+
|
|
|
+ // 3. Draw User Info
|
|
|
+ const moveDownY = 20 * scale;
|
|
|
+ const contentPadding = 30 * scale;
|
|
|
+
|
|
|
+ const avatarSize = 80 * scale;
|
|
|
+ const avatarX = padding;
|
|
|
+ const avatarY = coverY + coverHeight - (avatarSize / 2) + moveDownY;
|
|
|
+
|
|
|
+ // Avatar Border/Background
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.arc(avatarX + avatarSize/2, avatarY + avatarSize/2, avatarSize/2 + 2*scale, 0, 2 * Math.PI);
|
|
|
+ ctx.setFillStyle('#FFFFFF');
|
|
|
+ ctx.fill();
|
|
|
+
|
|
|
+ // Avatar Image
|
|
|
+ const avatarUrl = this.userInfo.avatar || 'https://img.yzcdn.cn/vant/cat.jpeg';
|
|
|
+ const avatarPath = await this.getImageInfo(avatarUrl);
|
|
|
+ ctx.save();
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.arc(avatarX + avatarSize/2, avatarY + avatarSize/2, avatarSize/2, 0, 2 * Math.PI);
|
|
|
+ ctx.clip();
|
|
|
+ ctx.drawImage(avatarPath, avatarX, avatarY, avatarSize, avatarSize);
|
|
|
+ ctx.restore();
|
|
|
+
|
|
|
+ // User Name & Recomm Text
|
|
|
+ // Align with avatar
|
|
|
+ const userInfoY = coverY + coverHeight + 25 * scale + moveDownY;
|
|
|
+ const textLeftX = avatarX + avatarSize + 16 * scale;
|
|
|
+
|
|
|
+ ctx.setFillStyle('#1D1D1D');
|
|
|
+ ctx.setFontSize(28 * scale);
|
|
|
+ ctx.font = `bold ${28 * scale}px sans-serif`;
|
|
|
+ ctx.setTextAlign('left');
|
|
|
+ ctx.fillText(this.userInfo.nickname || '微信用户', textLeftX, userInfoY);
|
|
|
+
|
|
|
+ ctx.setFillStyle('#666666');
|
|
|
+ ctx.setFontSize(26 * scale);
|
|
|
+ ctx.font = `normal ${26 * scale}px sans-serif`;
|
|
|
+ ctx.setTextAlign('right');
|
|
|
+ // Align to right padding edge
|
|
|
+ ctx.fillText('推荐您一本好书', width - padding, userInfoY);
|
|
|
+ ctx.setTextAlign('left'); // Reset
|
|
|
+
|
|
|
+ // 4. Product Info
|
|
|
+ let currentY = coverY + coverHeight + 110 * scale + moveDownY;
|
|
|
+
|
|
|
+ // Title
|
|
|
+ ctx.setFillStyle('#1D1D1D');
|
|
|
+ const titleFontSize = 34 * scale;
|
|
|
+ ctx.setFontSize(titleFontSize);
|
|
|
+ ctx.font = `bold ${titleFontSize}px sans-serif`;
|
|
|
+ const title = this.product.title || '未知书名';
|
|
|
+ // Width constraint: Canvas width - 2*contentPadding
|
|
|
+ const lastTitleY = this.drawText(ctx, title, contentPadding, currentY, width - 2 * contentPadding, titleFontSize * 1.5);
|
|
|
+ currentY = lastTitleY + 60 * scale;
|
|
|
+
|
|
|
+ // Author
|
|
|
+ ctx.setFillStyle('#666666');
|
|
|
+ const authorFontSize = 26 * scale;
|
|
|
+ ctx.setFontSize(authorFontSize);
|
|
|
+ ctx.font = `normal ${authorFontSize}px sans-serif`;
|
|
|
+ ctx.fillText(`作者: ${this.product.author || '未知'}`, contentPadding, currentY);
|
|
|
+ currentY += 70 * scale;
|
|
|
+
|
|
|
+ // Price
|
|
|
+ ctx.setFillStyle('#D81A00');
|
|
|
+ const priceFontSize = 44 * scale;
|
|
|
+ ctx.setFontSize(priceFontSize);
|
|
|
+ ctx.font = `bold ${priceFontSize}px sans-serif`;
|
|
|
+ const price = `¥ ${this.product.balancePrice || '0.00'}`;
|
|
|
+ const priceWidth = ctx.measureText(price).width;
|
|
|
+ ctx.fillText(price, contentPadding, currentY);
|
|
|
+
|
|
|
+ // Discount Tag
|
|
|
+ const discount = '5.3折';
|
|
|
+ const tagX = contentPadding + priceWidth + 16 * scale;
|
|
|
+ const tagY = currentY - 26 * scale;
|
|
|
+
|
|
|
+ ctx.setFillStyle('#D81A00');
|
|
|
+ this.roundRect(ctx, tagX, tagY, 70 * scale, 32 * scale, 4 * scale, '#D81A00');
|
|
|
+
|
|
|
+ ctx.setFillStyle('#FFFFFF');
|
|
|
+ ctx.setFontSize(20 * scale);
|
|
|
+ ctx.font = `normal ${20 * scale}px sans-serif`;
|
|
|
+ ctx.fillText(discount, tagX + 8 * scale, tagY + 22 * scale);
|
|
|
+
|
|
|
+ // Original Price
|
|
|
+ ctx.setFillStyle('#999999');
|
|
|
+ ctx.setFontSize(26 * scale);
|
|
|
+ ctx.font = `normal ${26 * scale}px sans-serif`;
|
|
|
+ const origPrice = `原价: ¥ ${this.product.originalPrice || '0.00'}`;
|
|
|
+ ctx.fillText(origPrice, tagX + 90 * scale, currentY);
|
|
|
+
|
|
|
+ // Divider
|
|
|
+ const footerHeight = 180 * scale;
|
|
|
+ const dividerY = height - footerHeight;
|
|
|
+
|
|
|
+ ctx.setStrokeStyle('#dddddd');
|
|
|
+ ctx.setLineDash([8, 8]);
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(padding, dividerY);
|
|
|
+ ctx.lineTo(width - padding, dividerY);
|
|
|
+ ctx.stroke();
|
|
|
+ ctx.setLineDash([]);
|
|
|
+
|
|
|
+ // 5. Footer (Store & QR)
|
|
|
+ const footerContentY = dividerY + 50 * scale;
|
|
|
+
|
|
|
+ // Store Name
|
|
|
+ ctx.setFillStyle('#1D1D1D');
|
|
|
+ ctx.setFontSize(32 * scale);
|
|
|
+ ctx.font = `bold ${32 * scale}px YouSheBiaoTiHei`;
|
|
|
+ const storeName = '书嗨循环书店';
|
|
|
+ const storeNameWidth = ctx.measureText(storeName).width;
|
|
|
+ ctx.fillText(storeName, padding, footerContentY + 10 * scale);
|
|
|
+
|
|
|
+ // Mascot Icon (Behind Store Name)
|
|
|
+ const iconSize = 70 * scale;
|
|
|
+ const iconX = padding + storeNameWidth + 40 * scale;
|
|
|
+ // Align vertically with text (text baseline is at footerContentY + 10*scale)
|
|
|
+ // Center icon relative to text cap height
|
|
|
+ const iconY = footerContentY + 10 * scale - (30 * scale) / 2 - iconSize / 2 + 40;
|
|
|
+ const iconPath = await this.getImageInfo('/pages-sell/static/goods/icon-shuhai.png');
|
|
|
+ ctx.drawImage(iconPath, iconX, iconY, iconSize, iconSize);
|
|
|
+
|
|
|
+ ctx.setFillStyle('#666666');
|
|
|
+ ctx.setFontSize(22 * scale);
|
|
|
+ ctx.font = `normal ${22 * scale}px sans-serif`;
|
|
|
+ ctx.fillText('不辜负每一个爱书的人', padding, footerContentY + 54 * scale);
|
|
|
+
|
|
|
+ // QR Code
|
|
|
+ const qrSize = 130 * scale;
|
|
|
+ const qrX = width - padding - qrSize;
|
|
|
+ const qrY = footerContentY - 20 * scale;
|
|
|
+
|
|
|
+ // Use gzh.png as the QR code (Figure 2)
|
|
|
+ const qrPath = await this.getImageInfo('/pages-sell/static/goods/icon-shuhai.png');
|
|
|
+ ctx.drawImage(qrPath, qrX, qrY, qrSize, qrSize);
|
|
|
+
|
|
|
+ ctx.draw(false, () => {
|
|
|
+ setTimeout(() => {
|
|
|
+ uni.canvasToTempFilePath({
|
|
|
+ canvasId: 'posterCanvas',
|
|
|
+ destWidth: width,
|
|
|
+ destHeight: height,
|
|
|
+ fileType: 'png',
|
|
|
+ quality: 1,
|
|
|
+ success: (res) => {
|
|
|
+ this.posterImage = res.tempFilePath;
|
|
|
+ uni.hideLoading();
|
|
|
+ },
|
|
|
+ fail: (err) => {
|
|
|
+ console.error(err);
|
|
|
+ uni.hideLoading();
|
|
|
+ uni.showToast({
|
|
|
+ title: '生成失败',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }, this);
|
|
|
+ }, 200);
|
|
|
+ });
|
|
|
+
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e);
|
|
|
+ uni.hideLoading();
|
|
|
+ uni.showToast({ title: '生成出错', icon: 'none' });
|
|
|
+ }
|
|
|
+ },
|
|
|
+ getImageInfo(url) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ if (!url) {
|
|
|
+ // Return a 1x1 transparent png base64 or similar if no url, or reject
|
|
|
+ resolve('/static/logo.png'); // Fallback
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // Handle local paths directly
|
|
|
+ if (url.startsWith('/') || url.startsWith('static/')) {
|
|
|
+ resolve(url);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ uni.downloadFile({
|
|
|
+ url: url,
|
|
|
+ success: (res) => {
|
|
|
+ if (res.statusCode === 200) {
|
|
|
+ resolve(res.tempFilePath);
|
|
|
+ } else {
|
|
|
+ reject(res);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ fail: (err) => {
|
|
|
+ reject(err);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ },
|
|
|
+ roundRect(ctx, x, y, w, h, r, fillStyle) {
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(x + r, y);
|
|
|
+ ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
|
+ ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
|
+ ctx.arcTo(x, y + h, x, y, r);
|
|
|
+ ctx.arcTo(x, y, x + w, y, r);
|
|
|
+ ctx.closePath();
|
|
|
+ if (fillStyle) {
|
|
|
+ ctx.setFillStyle(fillStyle);
|
|
|
+ ctx.fill();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ roundRectPath(ctx, x, y, w, h, r, tl, tr, br, bl) {
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(x + r, y);
|
|
|
+ if (tr) ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
|
+ else ctx.lineTo(x + w, y);
|
|
|
+
|
|
|
+ if (br) ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
|
+ else ctx.lineTo(x + w, y + h);
|
|
|
+
|
|
|
+ if (bl) ctx.arcTo(x, y + h, x, y, r);
|
|
|
+ else ctx.lineTo(x, y + h);
|
|
|
+
|
|
|
+ if (tl) ctx.arcTo(x, y, x + w, y, r);
|
|
|
+ else ctx.lineTo(x, y);
|
|
|
+
|
|
|
+ ctx.closePath();
|
|
|
+ },
|
|
|
+ drawText(ctx, str, x, y, maxWidth, lineHeight) {
|
|
|
+ if (!str) return y;
|
|
|
+ let line = '';
|
|
|
+ let currentY = y;
|
|
|
+ for (let i = 0; i < str.length; i++) {
|
|
|
+ const testLine = line + str[i];
|
|
|
+ const metrics = ctx.measureText(testLine);
|
|
|
+ if (metrics.width > maxWidth && i > 0) {
|
|
|
+ ctx.fillText(line, x, currentY);
|
|
|
+ line = str[i];
|
|
|
+ currentY += lineHeight;
|
|
|
+ } else {
|
|
|
+ line = testLine;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ctx.fillText(line, x, currentY);
|
|
|
+ return currentY;
|
|
|
+ },
|
|
|
+ savePoster() {
|
|
|
+ if (!this.posterImage) return;
|
|
|
+ uni.saveImageToPhotosAlbum({
|
|
|
+ filePath: this.posterImage,
|
|
|
+ success: () => {
|
|
|
+ uni.showToast({ title: '保存成功', icon: 'success' });
|
|
|
+ },
|
|
|
+ fail: (err) => {
|
|
|
+ console.log(err);
|
|
|
+ // Handle auth denial
|
|
|
+ uni.showModal({
|
|
|
+ title: '提示',
|
|
|
+ content: '需要保存到相册权限',
|
|
|
+ success: (res) => {
|
|
|
+ if (res.confirm) {
|
|
|
+ uni.openSetting();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.poster-popup-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ position: relative;
|
|
|
+ padding: 0; // Remove padding
|
|
|
+ background: transparent;
|
|
|
+}
|
|
|
+
|
|
|
+.poster-canvas {
|
|
|
+ position: fixed;
|
|
|
+ left: 10000px; // Hide canvas off-screen
|
|
|
+}
|
|
|
+
|
|
|
+.image-container {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ width: 680rpx; // Match canvas width
|
|
|
+
|
|
|
+ .poster-img {
|
|
|
+ width: 100%;
|
|
|
+ height: auto;
|
|
|
+ border-radius: 36rpx;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tips {
|
|
|
+ margin-top: 20rpx;
|
|
|
+ color: #fff;
|
|
|
+ font-size: 24rpx;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.loading-box {
|
|
|
+ width: 680rpx;
|
|
|
+ height: 1300rpx;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ background: linear-gradient(0deg, #FBFFFF 0%, #BFFCFE 100%);
|
|
|
+ border-radius: 36rpx;
|
|
|
+
|
|
|
+ .loading-text {
|
|
|
+ margin-top: 20rpx;
|
|
|
+ color: #666;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|