diff --git a/QRcode.jpg b/QRcode.jpg new file mode 100644 index 0000000..12db5fb Binary files /dev/null and b/QRcode.jpg differ diff --git a/pages/product-detail/product-detail.css b/pages/product-detail/product-detail.css index 6259eb7..242e8c8 100644 --- a/pages/product-detail/product-detail.css +++ b/pages/product-detail/product-detail.css @@ -53,3 +53,32 @@ height: 12rpx; /* 6px */ background: #f5f5f5; } + +/* 底部操作区:生成分享图按钮 */ +.share-footer { + padding: 24rpx 20rpx; + padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); + background: #ffffff; +} +.share-btn { + height: 88rpx; + display: flex; + align-items: center; + justify-content: center; + background: #ff6800; + border-radius: 44rpx; + font-size: 30rpx; + font-weight: 500; + color: #ffffff; +} + +/* 生成分享图用的画布(uni-app CanvasContext 旧版接口,跨端): + 固定定位移出可视区,仅用于绘制导出; + CSS 尺寸为设计稿(750x1200,比例约 2.5:4)的一半,绘制时 scale(0.5),导出时再按像素密度放大 */ +.share-canvas { + position: fixed; + left: -9999px; + top: 0; + width: 375px; + height: 600px; +} diff --git a/pages/product-detail/product-detail.vue b/pages/product-detail/product-detail.vue index 752cd65..5e6e9eb 100644 --- a/pages/product-detail/product-detail.vue +++ b/pages/product-detail/product-detail.vue @@ -92,6 +92,14 @@ + + + + + + + + @@ -535,6 +543,314 @@ export default { selectCraft(value) { this.selectedCraft = value this.syncPriceByVariant() + }, + + /** + * 底部按钮:生成分享图 + * 流程:加载产品首图 + 本地小程序码 → CanvasContext 绘制 → 导出图片 → 预览 + */ + generateShareImage() { + if (this._generating) return + this._generating = true + uni.showLoading({ title: '生成中...', mask: true }) + this.drawShareImage() + }, + + /** + * 绘制分享图并导出预览(uni-app CanvasContext 旧版接口,跨端) + * 整图固定比例 2.5 : 4(750 x 1200):上半部分产品图(右下角叠小程序码), + * 下方 名称/颜色点/尺码/价格;文字区高度自适应,剩余空间全部给产品图区 + * 画布组件 CSS 尺寸为其一半(375x600),绘制前 scale(0.5),导出时按像素密度放大 + */ + async drawShareImage() { + try { + const ctx = uni.createCanvasContext('shareCanvas', this) + + // ===== 设计稿逻辑尺寸(1 逻辑单位 = 画布 CSS 像素 × 2)===== + const W = 750 + const H = 1200 // 整图固定 750 x 1200,比例约 2.5 : 4 + const PAD = 32 // 页边距 + const IMG_W = 686 // 产品图区宽(圆角,高按剩余空间自适应) + const NAME_FONT = 'bold 36px sans-serif' + const NAME_LH = 50 // 名称行高 + const DOT_SIZE = 30 // 颜色点直径 + + // 名称折行(measureText 为同步测量,与缩放无关,直接测即可) + ctx.font = NAME_FONT + const nameLines = this.wrapNameLines(ctx, this.product.name || '—', IMG_W, 2) + const sizeText = this.sizes.length + ? this.sizes[0] + ' - ' + this.sizes[this.sizes.length - 1] + : '' + const hasDots = this.colors.length > 0 + + // 自下而上排文字区:价格 → 尺码 → 颜色点 → 名称,剩余高度全部给产品图区 + const priceBaseline = H - 36 // 底部留白 36 + let y = priceBaseline - 62 // 价格块顶部 + let sizeTop = 0 + if (sizeText) { + sizeTop = y - 20 - 38 + y = sizeTop + } + let dotsTop = 0 + if (hasDots) { + dotsTop = y - 20 - DOT_SIZE + y = dotsTop + } + const nameTop = y - 24 - nameLines.length * NAME_LH + const imgBottom = nameTop - 26 + const IMG_H = imgBottom - PAD // 产品图区高 + + // 画布组件 CSS 尺寸为设计稿一半,绘制统一缩放 0.5 + ctx.scale(0.5, 0.5) + + // ===== 加载产品首图信息(真实宽高用于 aspectFit)===== + // 小程序码为代码包内静态资源,drawImage 直接支持包内路径,无需 getImageInfo 转换; + // H5 下仍预热一次(触发浏览器缓存),确保 drawImage 内部加载即时完成 + const [productImg] = await Promise.all([ + this.loadImageInfo(this.productImages[0] || ''), + this.loadImageInfo('/static/images/detail/qrcode.png') + ]) + + // 白底铺满画布(jpg 导出透明区域会变黑,需全画布垫白) + ctx.setFillStyle('#ffffff') + ctx.fillRect(0, 0, W, H) + + // ===== 产品图区:圆角矩形 + 灰底 + 图片 aspectFit 居中 ===== + ctx.setFillStyle('#f2f2f2') + this.pathRoundRect(ctx, PAD, PAD, IMG_W, IMG_H, 24) + ctx.fill() + // 优先用 getImageInfo 的真实宽高做 aspectFit; + // 获取失败(如未配置 downloadFile 域名)时回退直接画网络图,按 3:4 估算宽高 + const drawPath = productImg ? productImg.path : this.productImages[0] || '' + const imgW = productImg ? productImg.width : 3 + const imgH = productImg ? productImg.height : 4 + if (drawPath) { + ctx.save() + this.pathRoundRect(ctx, PAD, PAD, IMG_W, IMG_H, 24) + ctx.clip() + const s = Math.min(IMG_W / imgW, IMG_H / imgH) + const dw = imgW * s + const dh = imgH * s + ctx.drawImage(drawPath, PAD + (IMG_W - dw) / 2, PAD + (IMG_H - dh) / 2, dw, dh) + ctx.restore() + } + + // ===== "新"角标(图区左上角)===== + ctx.setFillStyle('#00b578') + this.pathRoundRect(ctx, PAD + 20, PAD + 20, 68, 68, 14) + ctx.fill() + ctx.setFillStyle('#ffffff') + ctx.font = 'bold 36px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('新', PAD + 20 + 34, PAD + 20 + 36) + ctx.textAlign = 'left' + ctx.textBaseline = 'alphabetic' + + // ===== 小程序码:白底圆角衬底,叠在产品图区右下角 ===== + // 代码包内静态资源直接用包内路径绘制(drawImage 原生支持; + // getImageInfo 对包内路径在部分基础库/工具上会失败,导致二维码被跳过) + const qrBg = 200 + const qrX = PAD + IMG_W - qrBg - 16 + const qrY = PAD + IMG_H - qrBg - 16 + ctx.setFillStyle('#ffffff') + this.pathRoundRect(ctx, qrX, qrY, qrBg, qrBg, 14) + ctx.fill() + ctx.drawImage('/static/images/detail/qrcode.png', qrX + 10, qrY + 10, 180, 180) + + // ===== 名称(最多两行,已折好行)===== + ctx.setFillStyle('#1a1a1a') + ctx.font = NAME_FONT + nameLines.forEach((line, i) => { + ctx.fillText(line, PAD, nameTop + i * NAME_LH + 33) + }) + + // ===== 颜色点 ===== + if (hasDots) { + ctx.setLineWidth(1) + this.colors.slice(0, 8).forEach((c, i) => { + ctx.beginPath() + ctx.arc(PAD + 15 + i * 44, dotsTop + 15, 15, 0, Math.PI * 2) + ctx.setFillStyle(c.value || '#e0e0e0') + ctx.fill() + ctx.setStrokeStyle('rgba(0, 0, 0, 0.08)') + ctx.stroke() + }) + } + + // ===== 尺码(首 - 尾)===== + if (sizeText) { + ctx.setFillStyle('#808080') + ctx.font = '28px sans-serif' + ctx.fillText(sizeText, PAD, sizeTop + 28) + } + + // ===== 价格(符号/小数 32px,整数 60px,共用一条基线)===== + const p = this.product.price || {} + ctx.setFillStyle('#ff0000') + let px = PAD + ctx.font = '32px sans-serif' + ctx.fillText(p.symbol || '¥', px, priceBaseline) + px += ctx.measureText(p.symbol || '¥').width + 2 + ctx.font = 'bold 60px sans-serif' + ctx.fillText(p.integer || '0', px, priceBaseline) + px += ctx.measureText(p.integer || '0').width + 2 + ctx.fillText(p.decimal || '', px, priceBaseline) + + // ===== 一次性渲染到画布,完成后导出 ===== + ctx.draw(false, () => { + // #ifdef H5 + this.exportShareImageH5() + return + // #endif + // #ifdef MP-WEIXIN + const dpr = this.getPixelRatio() + uni.canvasToTempFilePath({ + canvasId: 'shareCanvas', + x: 0, + y: 0, + width: W / 2, + height: H / 2, + destWidth: (W / 2) * dpr, + destHeight: (H / 2) * dpr, + fileType: 'jpg', + quality: 0.9, + success: (res) => { + uni.previewImage({ urls: [res.tempFilePath] }) + }, + fail: (err) => { + console.error('[share canvasToTempFilePath fail]', err) + uni.showToast({ title: '生成失败', icon: 'none' }) + }, + complete: () => { + this._generating = false + uni.hideLoading() + } + }, this) + // #endif + }) + } catch (e) { + this._generating = false + uni.hideLoading() + console.error('[drawShareImage error]', e) + uni.showToast({ title: '生成失败', icon: 'none' }) + } + }, + + /** + * 加载图片信息(本地可绘制路径 + 原始宽高),供旧版 CanvasContext.drawImage 使用: + * - 微信小程序:uni.getImageInfo,远程图先下载为本地临时路径 + * - H5:跨域远程图转 blob 同源地址,避免画布被污染导致无法导出 + */ + loadImageInfo(src) { + return new Promise((resolve) => { + if (!src) return resolve(null) + // #ifdef MP-WEIXIN + uni.getImageInfo({ + src: src, + success: (info) => resolve(info), + fail: () => resolve(null) + }) + // #endif + // #ifdef H5 + const isSameOrigin = src.indexOf('//') === -1 || src.indexOf(location.host) !== -1 + const probe = (url) => { + const img = new Image() + img.onload = () => resolve({ path: url, width: img.naturalWidth, height: img.naturalHeight }) + img.onerror = () => resolve(null) + img.src = url + } + if (isSameOrigin) return probe(src) + const xhr = new XMLHttpRequest() + xhr.open('GET', src) + xhr.responseType = 'blob' + xhr.onload = () => { + if (xhr.response) probe(URL.createObjectURL(xhr.response)) + else resolve(null) + } + xhr.onerror = () => resolve(null) + xhr.send() + // #endif + }) + }, + + /** + * H5 导出:取真实 canvas 元素 toDataURL 预览 + * (画布 CSS 尺寸 375x600 与内容一致,整图即所得,已垫白) + */ + exportShareImageH5() { + try { + const el = + document.querySelector('.share-canvas canvas') || document.querySelector('.share-canvas') + if (!el || !el.toDataURL) throw new Error('canvas not found') + uni.previewImage({ urls: [el.toDataURL('image/jpeg', 0.9)] }) + } catch (e) { + console.error('[exportShareImageH5 error]', e) + uni.showToast({ title: '生成失败', icon: 'none' }) + } finally { + this._generating = false + uni.hideLoading() + } + }, + + /** + * 屏幕像素密度(导出图片放大倍数) + */ + getPixelRatio() { + try { + return uni.getSystemInfoSync().pixelRatio || 2 + } catch (e) { + return 2 + } + }, + + /** + * 圆角矩形路径(用 arc 画四角,兼容旧版 CanvasContext,无 arcTo 依赖) + */ + pathRoundRect(ctx, x, y, w, h, r) { + ctx.beginPath() + ctx.moveTo(x + r, y) + ctx.lineTo(x + w - r, y) + ctx.arc(x + w - r, y + r, r, -Math.PI / 2, 0) + ctx.lineTo(x + w, y + h - r) + ctx.arc(x + w - r, y + h - r, r, 0, Math.PI / 2) + ctx.lineTo(x + r, y + h) + ctx.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI) + ctx.lineTo(x, y + r) + ctx.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5) + ctx.closePath() + }, + + /** + * 中文按字符折行:最多 maxLines 行,溢出时行尾补 "..."(保证不超宽) + */ + wrapNameLines(ctx, text, maxWidth, maxLines) { + const chars = String(text).split('') + const lines = [] + let line = '' + let overflow = false + for (let i = 0; i < chars.length; i++) { + const test = line + chars[i] + if (line && ctx.measureText(test).width > maxWidth) { + if (lines.length === maxLines - 1) { + overflow = true + break + } + lines.push(line) + line = chars[i] + } else { + line = test + } + } + if (line && lines.length < maxLines) lines.push(line) + if (overflow && lines.length === maxLines) { + let last = lines[maxLines - 1] + while (last && ctx.measureText(last + '...').width > maxWidth) { + last = last.slice(0, -1) + } + lines[maxLines - 1] = last + '...' + } + return lines } }, computed: { diff --git a/static/images/detail/qrcode.png b/static/images/detail/qrcode.png new file mode 100644 index 0000000..f1ad134 Binary files /dev/null and b/static/images/detail/qrcode.png differ