生成图片
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,14 @@
|
||||
|
||||
<!-- 包装规格表(接口暂未提供,预留) -->
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user