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