Selaa lähdekoodia

feat(详情页): 添加分享和服务弹窗功能

- 新增分享弹窗组件,支持微信好友分享和生成海报
- 新增服务弹窗组件,展示商品服务详情
- 添加相关图标资源文件
- 优化商品封面图片显示模式为aspectFill
ylong 5 päivää sitten
vanhempi
sitoutus
c592cf9a9d

+ 423 - 0
pages-sell/components/detail/poster-popup.vue

@@ -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>

+ 13 - 1
pages-sell/components/detail/service-card.vue

@@ -25,23 +25,33 @@
 			<text class="separator">|</text>
 			<text class="service-val red">每满 48 元减 5 元</text>
 		</view>
-		<view class="service-row last">
+		<view class="service-row last" @click="openServicePopup">
 			<view class="icon-box">
 				<image src="/pages-sell/static/goods/icon-service.png" class="service-icon" mode="aspectFit"></image>
 			</view>
 			<text class="service-label">服务</text>
 			<text class="separator">|</text>
 			<text class="service-val">破损包赔 · 降价补差 · 24 小时发货</text>
+			<u-icon name="arrow-right" size="24" color="#999" class="arrow"></u-icon>
 		</view>
+		<ServicePopup ref="servicePopup" />
 	</view>
 </template>
 
 <script>
+import ServicePopup from '@/pages-sell/components/service-popup/index.vue';
+
 export default {
 	name: 'ServiceCard',
+	components: {
+		ServicePopup
+	},
 	methods: {
 		onClick() {
 			this.$emit('click');
+		},
+		openServicePopup() {
+			this.$refs.servicePopup.open();
 		}
 	}
 }
@@ -95,6 +105,8 @@ export default {
 
 		.service-val {
 			color: #666;
+            flex: 1; // Allow text to take remaining space
+            @include ellipsis;
 
 			&.red {
 				color: #D81A00;

+ 166 - 0
pages-sell/components/detail/share-popup.vue

@@ -0,0 +1,166 @@
+<template>
+	<view>
+		<CustomPopup v-model="show" mode="center" width="700rpx" bgColor="transparent" :maskClosable="true">
+			<view class="share-popup-content">
+				<!-- Background Image -->
+				<image class="bg-image" src="/pages-sell/static/goods/icon-share-bg.png" mode="widthFix"></image>
+
+				<!-- Close Button -->
+				<view class="close-btn" @click="close">
+					<image src="/pages-sell/static/goods/icon-close.png" mode="widthFix"></image>
+				</view>
+
+				<!-- Main Content -->
+				<view class="main-box">
+					<view class="title">分享</view>
+					<view class="share-options">
+						<view class="share-item">
+							<button class="share-btn" open-type="share">
+								<image src="/pages-sell/static/goods/icon-wx.png" mode="widthFix"></image>
+								<text>微信好友</text>
+							</button>
+						</view>
+						<view class="share-item" @click="onGeneratePoster">
+							<image src="/pages-sell/static/goods/icon-pic.png" mode="widthFix"></image>
+							<text>生成海报</text>
+						</view>
+					</view>
+				</view>
+			</view>
+		</CustomPopup>
+		
+		<!-- Poster Popup -->
+		<PosterPopup ref="posterPopup" :product="product" :userInfo="userInfo"></PosterPopup>
+	</view>
+</template>
+
+<script>
+import CustomPopup from '@/components/custom-popup.vue';
+import PosterPopup from './poster-popup.vue';
+import { mapState } from 'vuex';
+
+export default {
+	name: 'SharePopup',
+	components: {
+		CustomPopup,
+		PosterPopup
+	},
+	props: {
+		product: {
+			type: Object,
+			default: () => ({})
+		}
+	},
+	computed: {
+		...mapState('user', ['userInfo'])
+	},
+	data() {
+		return {
+			show: false
+		};
+	},
+	methods: {
+		open() {
+			this.show = true;
+		},
+		close() {
+			this.show = false;
+		},
+		onGeneratePoster() {
+			this.close();
+			// Wait for close animation or immediate?
+			// Usually immediate is fine, but if mask fades out, it might look weird.
+			// But CustomPopup v-model controls visibility.
+			this.$nextTick(() => {
+				this.$refs.posterPopup.open();
+			});
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.share-popup-content {
+	position: relative;
+	width: 100%;
+	// Remove padding-top as background image defines the shape
+}
+
+.bg-image {
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: 100%;
+	height: 100%;
+	z-index: 0;
+}
+
+.close-btn {
+	position: absolute;
+	top: -40rpx; // Position outside the card
+	right: 20rpx;
+	width: 80rpx;
+	height: 80rpx;
+	z-index: 10;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+
+	image {
+		width: 60rpx;
+	}
+}
+
+.main-box {
+	// Remove background and border-radius as they are provided by image
+	position: relative;
+	z-index: 1;
+	padding: 80rpx 40rpx 60rpx; // Adjust padding to fit content within the background image design
+
+	.title {
+		font-size: 36rpx;
+		font-weight: bold;
+		color: #308740;
+		margin-bottom: 60rpx;
+		padding-left: 30rpx;
+	}
+
+	.share-options {
+		display: flex;
+		justify-content: space-around;
+		padding: 0 40rpx;
+		padding-top: 60rpx;
+
+		.share-item {
+			display: flex;
+			flex-direction: column;
+			align-items: center;
+			
+			.share-btn {
+				display: flex;
+				flex-direction: column;
+				align-items: center;
+				background: none;
+				padding: 0;
+				margin: 0;
+				line-height: 1.5;
+				
+				&::after {
+					border: none;
+				}
+			}
+
+			image {
+				width: 100rpx;
+				height: 100rpx;
+				margin-bottom: 20rpx;
+			}
+
+			text {
+				font-size: 28rpx;
+				color: #333;
+			}
+		}
+	}
+}
+</style>

+ 12 - 3
pages-sell/pages/detail.vue

@@ -17,8 +17,8 @@
         <view class="content-scroll">
             <!-- Book Cover Area -->
             <view class="cover-area">
-                <image class="book-cover" :src="product.cover" mode="aspectFit"></image>
-                <view class="share-btn">
+                <image class="book-cover" :src="product.cover" mode="aspectFill"></image>
+                <view class="share-btn" @click="openSharePopup">
                     <image src="/pages-sell/static/goods/icon-share.png" class="share-icon"></image>
                     <text>分享</text>
                 </view>
@@ -79,6 +79,9 @@
 
         <!-- Select Popup -->
         <SelectGoodPopup ref="selectPopup" @confirm="onPopupConfirm"></SelectGoodPopup>
+        
+        <!-- Share Popup -->
+        <SharePopup ref="sharePopup" :product="product"></SharePopup>
     </view>
 </template>
 
@@ -90,6 +93,7 @@ import ServiceCard from '../components/detail/service-card.vue'
 import ProductContent from '../components/detail/product-content.vue'
 import FooterBar from '../components/detail/footer-bar.vue'
 import FloatingDrag from "@/components/floating-drag.vue";
+import SharePopup from '../components/detail/share-popup.vue';
 
 export default {
     components: {
@@ -99,7 +103,8 @@ export default {
         ServiceCard,
         ProductContent,
         FooterBar,
-        FloatingDrag
+        FloatingDrag,
+        SharePopup
     },
     data() {
         return {
@@ -169,6 +174,9 @@ export default {
                 url: '/pages-sell/pages/detail?id=' + encodeURIComponent(book.title)
             });
         },
+        openSharePopup() {
+            this.$refs.sharePopup.open();
+        },
         // 处理位置变更
         handlePositionChange(position) {
             this.servicePosition = position;
@@ -246,6 +254,7 @@ export default {
         display: flex;
         flex-direction: column;
         align-items: center;
+        z-index: 10; // Ensure it's clickable
 
         .share-icon {
             width: 40rpx;

BIN
pages-sell/static/goods/icon-close.png


BIN
pages-sell/static/goods/icon-pic.png


BIN
pages-sell/static/goods/icon-share-bg.png


BIN
pages-sell/static/goods/icon-shuhai.png


BIN
pages-sell/static/goods/icon-wx.png