生成图片

This commit is contained in:
2026-09-04 16:44:05 +08:00
parent 98579baf8f
commit eab43d467f
4 changed files with 345 additions and 0 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+29
View File
@@ -53,3 +53,32 @@
height: 12rpx; /* 6px */ height: 12rpx; /* 6px */
background: #f5f5f5; 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;
}
+316
View File
@@ -92,6 +92,14 @@
<!-- 包装规格表接口暂未提供预留 --> <!-- 包装规格表接口暂未提供预留 -->
<PackagingSpec v-if="packaging" :data="packaging" /> <PackagingSpec v-if="packaging" :data="packaging" />
<!-- 底部生成分享图按钮 -->
<view class="share-footer">
<view class="share-btn" @tap="generateShareImage">生成分享图</view>
</view>
<!-- 生成分享图用的画布uni-app CanvasContext 旧版接口跨端固定定位移出屏幕仅用于绘制导出 -->
<canvas canvas-id="shareCanvas" id="shareCanvas" class="share-canvas"></canvas>
</view> </view>
</template> </template>
@@ -535,6 +543,314 @@ export default {
selectCraft(value) { selectCraft(value) {
this.selectedCraft = value this.selectedCraft = value
this.syncPriceByVariant() 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: { computed: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB