生成图片
This commit is contained in:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 |
Reference in New Issue
Block a user