feat: 新增拼团

This commit is contained in:
2026-09-23 10:29:41 +08:00
parent f7b9e66172
commit 67f8b3f352
33 changed files with 7491 additions and 858 deletions
+746 -18
View File
@@ -35,7 +35,7 @@
</view>
<view class="share-line" style="margin-top:40rpx;"></view>
<view class="qr-view" v-if="qrvalue">
<view class="qr-view" v-if="qrvalue && !isGroupBuy">
<tki-qrcode ref="qrcode" :val="qrvalue" :size="252" />
</view>
@@ -45,7 +45,57 @@
<text class="text-32 text-fu1">取消</text>
</view>
<view class="share-top-image-view">
<!-- 拼团专属卡片 -->
<view class="group-share-card" v-if="isGroupBuy">
<image :src="tu" class="group-card-img" mode="aspectFill"></image>
<view class="group-card-price-row">
<view class="group-card-price-box">
<text class="group-card-price-label">拼团价</text>
<text class="group-card-price-symbol">¥</text>
<text class="group-card-price-num">{{ groupPrice || price }}</text>
</view>
<view class="group-card-tag" v-if="requiredNum">
<text>{{ requiredNum }}人成团</text>
</view>
</view>
<view class="group-card-title">
{{ text }}
</view>
<view class="group-card-divider"></view>
<view class="group-card-bottom">
<view class="group-card-bottom-left">
<view class="group-card-main-title">超值拼团</view>
<view class="group-card-sub-title">人多价更优 拼团更实惠</view>
<view class="group-card-user-row">
<image :src="currentInviterAvatar" class="group-card-avatar" mode="aspectFill"></image>
<text class="group-card-username">{{ currentInviterName }}</text>
</view>
</view>
<view class="group-card-bottom-right">
<view class="group-card-qr-box">
<tki-qrcode
ref="groupQrcode"
cid="tki-group-qrcode-canvas"
:val="qrvalue"
:size="260"
unit="px"
:onval="true"
:lv="2"
:showLoading="false"
@result="onGroupQrResult"
/>
</view>
<text class="group-card-qr-tip">长按识别 参与拼团</text>
</view>
</view>
</view>
<!-- 原普通商品卡片 -->
<view class="share-top-image-view" v-else>
<image :src="tu" class="tu-img"></image>
<view class="image-view-bottom">
<view class="image-view-bottom-left"
@@ -57,6 +107,15 @@
</view>
</view>
</view>
<!-- 隐藏 Canvas 用于生成拼团卡片海报 -->
<view class="hideCanvasView">
<canvas
canvas-id="groupCardCanvas"
id="groupCardCanvas"
style="width: 560px; height: 910px;"
></canvas>
</view>
</view>
</up-popup>
</template>
@@ -65,13 +124,16 @@
import Func from '/utils/func'
import tkiQrcode from './tki-qrcode/tki-qrcode.vue'
import { SHARE_URL } from '@/utils/config.js'
import { getStorageFun, USER_DATA } from '@/utils/auth.js'
export default {
data() {
return {
btnList: ['微信', '朋友圈'],
appBtnList: ['微信', '朋友圈', '复制链接', '保存图片', 'QQ'],
qrvalue: ''
qrvalue: '',
defaultAvatar: 'https://static.tbmall.xin/static/new/default_avatar.png',
groupQrPath: '',
isGeneratingPoster: false
}
},
@@ -82,7 +144,7 @@ export default {
default: false
},
id: {
type: String,
type: [String, Number],
default: ''
},
tu: {
@@ -100,21 +162,50 @@ export default {
hideCopyAndQQ: {
type: Boolean,
default: false
},
sharePath: {
type: String,
default: ''
},
isGroupBuy: {
type: Boolean,
default: false
},
groupPrice: {
type: [String, Number],
default: ''
},
requiredNum: {
type: [String, Number],
default: 2
},
inviterName: {
type: String,
default: ''
},
inviterAvatar: {
type: String,
default: ''
}
},
watch: {
show(val) {
if (val) {
this.initQrCode();
}
},
sharePath() {
this.initQrCode();
},
id() {
this.initQrCode();
},
isGroupBuy() {
this.initQrCode();
}
},
mounted() {
// #ifdef APP-PLUS || H5
if (!this.isInvitePage) {
this.qrvalue = `${SHARE_URL}/#/pages/other_package/productInfo/productInfo?id=${this.id}`
this.$nextTick(() => {
if (this.qrvalue) {
this.creatQrcode();
}
})
}
// #endif
this.initQrCode();
},
computed: {
@@ -134,6 +225,9 @@ export default {
if (this.isInvitePage) {
return items.filter(item => item.name !== '微信');
}
if (this.isGroupBuy) {
return items.filter(item => item.name !== 'QQ');
}
return items;
},
filteredAppBtnList() {
@@ -141,14 +235,79 @@ export default {
if (this.isInvitePage) {
return items.filter(item => item.name !== '复制链接' && item.name !== 'QQ');
}
if (this.isGroupBuy) {
return items.filter(item => item.name !== 'QQ');
}
return items;
},
currentInviterName() {
if (this.inviterName) return this.inviterName;
try {
const u = getStorageFun(USER_DATA) || {};
return u.name || u.nickName || 'Haonan Mu';
} catch (e) {
return 'Haonan Mu';
}
},
currentInviterAvatar() {
if (this.inviterAvatar) return this.inviterAvatar;
try {
const u = getStorageFun(USER_DATA) || {};
return u.avatar || u.avatarUrl || this.defaultAvatar;
} catch (e) {
return this.defaultAvatar;
}
}
},
methods: {
initQrCode() {
if (!this.isInvitePage) {
this.groupQrPath = '';
if (this.isGroupBuy) {
// 拼团专属卡片二维码:路由后面加 id=${this.id}&activedType=groupBuy&activityId=1
let basePath = 'pages/other_package/productInfo/productInfo';
let targetId = this.id != null ? String(this.id) : '';
if (this.sharePath) {
const cleanPath = this.sharePath.startsWith('/') ? this.sharePath.slice(1) : this.sharePath;
const pathOnly = cleanPath.split('?')[0];
if (pathOnly) {
basePath = pathOnly;
}
if (!targetId) {
const idMatch = cleanPath.match(/[?&]id=([^&]+)/);
if (idMatch) {
targetId = idMatch[1];
}
}
}
// 拼团专属卡片二维码:扫码后直接打开 H5 对应商品详情页,路由后面加 id=${this.id}&activedType=groupBuy&activityId=1
this.qrvalue = `${SHARE_URL}/#/${basePath}?id=${targetId}&activedType=groupBuy&activityId=1`;
} else if (this.sharePath) {
const cleanPath = this.sharePath.startsWith('/') ? this.sharePath.slice(1) : this.sharePath;
this.qrvalue = `${SHARE_URL}/#/${cleanPath}`;
} else if (this.id) {
this.qrvalue = `${SHARE_URL}/#/pages/other_package/productInfo/productInfo?id=${this.id}`;
}
if (this.qrvalue) {
this.$nextTick(() => {
setTimeout(() => {
if (this.$refs.qrcode && this.$refs.qrcode._makeCode) {
this.$refs.qrcode._makeCode();
}
if (this.$refs.groupQrcode && this.$refs.groupQrcode._makeCode) {
this.$refs.groupQrcode._makeCode();
}
}, 50);
});
}
}
},
creatQrcode() {
this.$refs.qrcode._makeCode();
if (this.$refs.qrcode && this.$refs.qrcode._makeCode) {
this.$refs.qrcode._makeCode();
}
},
onBtn(index) {
if (index == 2) {
@@ -197,10 +356,395 @@ export default {
Func.myToast('复制成功')
})
// #endif
} else if (index == 3) {
if (this.isGroupBuy) {
this.saveGroupCard();
return;
}
}
this.$emit('share', index)
},
onGroupQrResult(res) {
this.groupQrPath = res;
},
getLocalImage(url) {
return new Promise((resolve) => {
if (!url) {
resolve('');
return;
}
if (
url.startsWith('file://') ||
url.startsWith('_doc') ||
url.startsWith('/storage') ||
url.startsWith('blob:') ||
url.startsWith('data:')
) {
resolve(url);
return;
}
uni.downloadFile({
url: url,
success: (res) => {
if (res.statusCode === 200 && res.tempFilePath) {
resolve(res.tempFilePath);
} else {
uni.getImageInfo({
src: url,
success: (info) => resolve(info.path),
fail: () => resolve('')
});
}
},
fail: () => {
uni.getImageInfo({
src: url,
success: (info) => resolve(info.path),
fail: () => resolve('')
});
}
});
});
},
base64ToTempFile(base64Data) {
return new Promise((resolve) => {
if (!base64Data || !base64Data.startsWith('data:image')) {
resolve(base64Data);
return;
}
// #ifdef APP-PLUS
try {
const bitmap = new plus.nativeObj.Bitmap('qr_' + Date.now());
bitmap.loadBase64Data(
base64Data,
() => {
const tempPath = `_doc/qr_${Date.now()}.png`;
bitmap.save(
tempPath,
{ overwrite: true, format: 'png', quality: 100 },
(e) => {
bitmap.clear();
resolve(e.target);
},
() => {
bitmap.clear();
resolve(base64Data);
}
);
},
() => {
bitmap.clear();
resolve(base64Data);
}
);
} catch (e) {
resolve(base64Data);
}
// #endif
// #ifndef APP-PLUS
resolve(base64Data);
// #endif
});
},
drawRoundedRect(ctx, x, y, width, height, radius, fill, stroke) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.arcTo(x + width, y, x + width, y + radius, radius);
ctx.lineTo(x + width, y + height - radius);
ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
ctx.lineTo(x + radius, y + height);
ctx.arcTo(x, y + height, x, y + height - radius, radius);
ctx.lineTo(x, y + radius);
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
if (fill) {
ctx.fill();
}
if (stroke) {
ctx.stroke();
}
},
drawWrapText(ctx, text, x, y, maxWidth, lineHeight, maxLines = 2) {
if (!text) return y;
const chars = String(text).split('');
let line = '';
let currentLine = 1;
let currentY = y;
for (let n = 0; n < chars.length; n++) {
const testLine = line + chars[n];
const metrics = ctx.measureText ? ctx.measureText(testLine) : { width: testLine.length * 14 };
const testWidth = metrics.width;
if (testWidth > maxWidth && n > 0) {
if (currentLine === maxLines) {
let ellipsisLine = line;
while (ellipsisLine.length > 0) {
const lineWithEllipsis = ellipsisLine + '...';
const m = ctx.measureText ? ctx.measureText(lineWithEllipsis) : { width: lineWithEllipsis.length * 14 };
if (m.width <= maxWidth) {
ctx.fillText(lineWithEllipsis, x, currentY);
break;
}
ellipsisLine = ellipsisLine.slice(0, -1);
}
return currentY + lineHeight;
}
ctx.fillText(line, x, currentY);
line = chars[n];
currentY += lineHeight;
currentLine++;
} else {
line = testLine;
}
}
ctx.fillText(line, x, currentY);
return currentY + lineHeight;
},
async saveGroupCard() {
if (this.isGeneratingPoster) return;
this.isGeneratingPoster = true;
uni.showLoading({ title: '海报生成中...', mask: true });
try {
let localTu = '';
if (this.tu) {
localTu = await this.getLocalImage(this.tu);
}
const avatarUrl = this.currentInviterAvatar;
let localAvatar = '';
if (avatarUrl) {
localAvatar = await this.getLocalImage(avatarUrl);
}
let qrImg = this.groupQrPath || (this.$refs.groupQrcode && this.$refs.groupQrcode.result);
if (!qrImg && this.$refs.groupQrcode) {
if (this.$refs.groupQrcode._makeCode) {
this.$refs.groupQrcode._makeCode();
}
await new Promise((r) => setTimeout(r, 450));
qrImg = this.groupQrPath || (this.$refs.groupQrcode && this.$refs.groupQrcode.result);
}
if (!qrImg) {
await new Promise((r) => setTimeout(r, 300));
qrImg = this.groupQrPath || (this.$refs.groupQrcode && this.$refs.groupQrcode.result);
}
if (qrImg && qrImg.startsWith('data:image')) {
qrImg = await this.base64ToTempFile(qrImg);
}
const ctx = uni.createCanvasContext('groupCardCanvas', this);
const W = 560;
const H = 910;
const pad = 24;
const innerW = W - pad * 2; // 512
// 1. 卡片背景
ctx.fillStyle = '#FFFFFF';
this.drawRoundedRect(ctx, 0, 0, W, H, 24, true, false);
// 2. 商品主图
if (localTu) {
ctx.save();
this.drawRoundedRect(ctx, pad, pad, innerW, 500, 16, false, false);
ctx.clip();
ctx.drawImage(localTu, pad, pad, innerW, 500);
ctx.restore();
} else {
ctx.fillStyle = '#F7F7F7';
this.drawRoundedRect(ctx, pad, pad, innerW, 500, 16, true, false);
}
// 3. 价格行
const priceRowY = pad + 500 + 20; // 544
ctx.font = 'normal 500 24px sans-serif';
ctx.fillStyle = '#7F3BF5';
ctx.textBaseline = 'middle';
ctx.fillText('拼团价', pad, priceRowY + 18);
const labelWidth = ctx.measureText ? ctx.measureText('拼团价').width : 72;
ctx.font = 'bold 22px sans-serif';
ctx.fillStyle = '#7F3BF5';
const symbolX = pad + labelWidth + 6;
ctx.fillText('¥', symbolX, priceRowY + 19);
const symbolWidth = ctx.measureText ? ctx.measureText('¥').width : 16;
const displayPrice = String(this.groupPrice || this.price || '0');
ctx.font = 'bold 42px sans-serif';
ctx.fillStyle = '#7F3BF5';
const numX = symbolX + symbolWidth + 4;
ctx.fillText(displayPrice, numX, priceRowY + 16);
if (this.requiredNum) {
const tagText = `${this.requiredNum}人成团`;
ctx.font = 'normal 500 20px sans-serif';
const tagTextWidth = ctx.measureText ? ctx.measureText(tagText).width : 74;
const tagW = tagTextWidth + 24;
const tagH = 34;
const tagX = W - pad - tagW;
const tagY = priceRowY + 2;
ctx.fillStyle = '#F3EBFE';
this.drawRoundedRect(ctx, tagX, tagY, tagW, tagH, 8, true, false);
ctx.fillStyle = '#7F3BF5';
ctx.textBaseline = 'middle';
ctx.fillText(tagText, tagX + 12, tagY + tagH / 2);
}
// 4. 商品名称 (最多2行,溢出显示省略号)
const titleY = priceRowY + 54;
ctx.fillStyle = '#222222';
ctx.font = 'bold 26px sans-serif';
ctx.textBaseline = 'top';
const nextY = this.drawWrapText(ctx, this.text || '', pad, titleY, innerW, 36, 2);
// 5. 分割线
const dividerY = Math.max(nextY + 12, titleY + 76);
ctx.strokeStyle = '#F0F0F0';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad, dividerY);
ctx.lineTo(W - pad, dividerY);
ctx.stroke();
// 6. 底部区域
const bottomY = dividerY + 18;
ctx.fillStyle = '#1E1E1E';
ctx.font = 'bold 30px sans-serif';
ctx.textBaseline = 'top';
ctx.fillText('超值拼团', pad, bottomY);
ctx.fillStyle = '#999999';
ctx.font = 'normal 20px sans-serif';
ctx.fillText('人多价更优 拼团更实惠', pad, bottomY + 38);
const userRowY = bottomY + 74;
const avatarSize = 44;
if (localAvatar) {
ctx.save();
ctx.beginPath();
ctx.arc(pad + avatarSize / 2, userRowY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI);
ctx.closePath();
ctx.clip();
ctx.drawImage(localAvatar, pad, userRowY, avatarSize, avatarSize);
ctx.restore();
} else {
ctx.fillStyle = '#EEEEEE';
ctx.beginPath();
ctx.arc(pad + avatarSize / 2, userRowY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI);
ctx.fill();
}
ctx.fillStyle = '#333333';
ctx.font = 'normal 500 22px sans-serif';
ctx.textBaseline = 'middle';
const username = this.currentInviterName;
ctx.fillText(username, pad + avatarSize + 12, userRowY + avatarSize / 2);
// 二维码绘制(带纯白底色与四周静区 Padding,确保微信扫码/长按识别成功率)
const qrBoxSize = 144;
const qrInnerPad = 10;
const qrSize = qrBoxSize - qrInnerPad * 2; // 124px
const qrBoxX = W - pad - qrBoxSize;
const qrBoxY = bottomY;
// 绘制白色背景底卡
ctx.fillStyle = '#FFFFFF';
this.drawRoundedRect(ctx, qrBoxX, qrBoxY, qrBoxSize, qrBoxSize, 8, true, false);
if (qrImg) {
ctx.drawImage(qrImg, qrBoxX + qrInnerPad, qrBoxY + qrInnerPad, qrSize, qrSize);
}
ctx.fillStyle = '#999999';
ctx.font = 'normal 17px sans-serif';
ctx.textBaseline = 'top';
const tipText = '长按识别 参与拼团';
const tipWidth = ctx.measureText ? ctx.measureText(tipText).width : 120;
const tipX = qrBoxX + (qrBoxSize - tipWidth) / 2;
ctx.fillText(tipText, tipX, qrBoxY + qrBoxSize + 8);
// 7. 提交绘制
await new Promise((resolve) => {
ctx.draw(false, () => {
setTimeout(resolve, 200);
});
});
// 8. 导出临时图片
const tempFilePath = await new Promise((resolve, reject) => {
uni.canvasToTempFilePath(
{
canvasId: 'groupCardCanvas',
fileType: 'png',
width: W,
height: H,
destWidth: W * 2,
destHeight: H * 2,
quality: 1,
success: (res) => resolve(res.tempFilePath),
fail: (err) => reject(err)
},
this
);
});
uni.hideLoading();
// 9. 保存至相册
// #ifdef APP-PLUS || MP-WEIXIN
uni.saveImageToPhotosAlbum({
filePath: tempFilePath,
success: () => {
uni.showToast({
title: '已保存至手机相册',
icon: 'success',
duration: 2000
});
this.onClose();
},
fail: (err) => {
console.error('保存相册失败:', err);
if (err && err.errMsg && (err.errMsg.includes('auth') || err.errMsg.includes('authorize'))) {
uni.showModal({
title: '提示',
content: '需要保存图片到相册权限,请在系统设置中开启相册权限',
success: (res) => {
if (res.confirm) uni.openSetting();
}
});
} else {
uni.previewImage({ urls: [tempFilePath] });
}
}
});
// #endif
// #ifdef H5
uni.previewImage({ urls: [tempFilePath] });
uni.showToast({ title: '请长按图片保存到手机相册', icon: 'none' });
this.onClose();
// #endif
} catch (err) {
uni.hideLoading();
console.error('生成或保存海报失败:', err);
uni.showToast({
title: '生成海报失败,请重试',
icon: 'none'
});
} finally {
this.isGeneratingPoster = false;
}
},
onClose() {
this.$emit('close');
},
@@ -215,6 +759,181 @@ export default {
</script>
<style lang="scss" scoped>
.group-share-card {
position: absolute;
bottom: 100%;
margin-bottom: 24rpx;
left: 50%;
transform: translateX(-50%);
width: 560rpx;
background-color: #ffffff;
border-radius: 24rpx;
padding: 24rpx;
box-sizing: border-box;
box-shadow: 0 10rpx 36rpx rgba(0, 0, 0, 0.16);
z-index: 10;
.group-card-img {
width: 100%;
height: 500rpx;
border-radius: 16rpx;
background-color: #f7f7f7;
display: block;
}
.group-card-price-row {
margin-top: 20rpx;
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
.group-card-price-box {
display: flex;
flex-direction: row;
align-items: baseline;
.group-card-price-label {
font-size: 26rpx;
color: #7f3bf5;
font-weight: 500;
}
.group-card-price-symbol {
font-size: 24rpx;
color: #7f3bf5;
font-weight: bold;
margin-left: 6rpx;
margin-right: 4rpx;
}
.group-card-price-num {
font-size: 42rpx;
color: #7f3bf5;
font-weight: bold;
line-height: 1;
}
}
.group-card-tag {
background-color: #f3ebfe;
color: #7f3bf5;
font-size: 22rpx;
font-weight: 500;
padding: 6rpx 14rpx;
border-radius: 8rpx;
line-height: 1;
}
}
.group-card-title {
margin-top: 14rpx;
font-size: 28rpx;
font-weight: 500;
color: #222222;
line-height: 38rpx;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
.group-card-divider {
width: 100%;
height: 1rpx;
background-color: #f0f0f0;
margin: 20rpx 0 16rpx 0;
}
.group-card-bottom {
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
.group-card-bottom-left {
flex: 1;
padding-right: 16rpx;
.group-card-main-title {
font-size: 32rpx;
font-weight: bold;
color: #1e1e1e;
line-height: 1.2;
}
.group-card-sub-title {
font-size: 22rpx;
color: #999999;
margin-top: 6rpx;
line-height: 1.2;
}
.group-card-user-row {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 20rpx;
.group-card-avatar {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background-color: #f0f0f0;
flex-shrink: 0;
}
.group-card-username {
font-size: 24rpx;
color: #333333;
font-weight: 500;
margin-left: 12rpx;
max-width: 200rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.group-card-bottom-right {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
.group-card-qr-box {
width: 140rpx;
height: 140rpx;
padding: 8rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
background-color: #ffffff;
border-radius: 8rpx;
overflow: hidden;
::v-deep .tki-qrcode,
::v-deep .tki-qrcode image {
width: 124rpx !important;
height: 124rpx !important;
display: block;
}
}
.group-card-qr-tip {
font-size: 18rpx;
color: #999999;
margin-top: 6rpx;
white-space: nowrap;
}
}
}
}
.share-top-image-view {
width: 400rpx;
min-height: 496rpx;
@@ -361,4 +1080,13 @@ export default {
}
}
.hideCanvasView {
position: fixed;
top: 10000px;
left: 10000px;
width: 560px;
height: 910px;
z-index: -9999;
}
</style>