Files
mini-programs/src/utils/share.ts
2026-02-14 12:59:21 +08:00

600 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Taro from '@tarojs/taro'
import { OSS_BASE } from "@/config/api";
export interface ShareCardData {
userAvatar: string
userNickname: string
gameType: string
skillLevel: string
gameDate: string
gameTime: string
venueName: string
venueImages: string[]
}
export interface GenerateShareImageOptions {
canvasId?: string
width?: number
height?: number
dpr?: number
}
// 设计稿尺寸
const designWidth = 500
const designHeight = 400
// 获取屏幕宽度如果没有传入width则使用屏幕宽度
const windowWidth = Taro.getSystemInfoSync().windowWidth
// 获取 DPR - 使用系统像素比确保高清显示
// const systemDpr = Taro.getSystemInfoSync().pixelRatio
const dpr = 1
// Math.min(systemDpr, 3) // 限制最大dpr为3避免过度放大
// 2. 计算缩放比例(设备宽度 / 设计稿宽度)
const scale = windowWidth / designWidth
// 3. 计算实际显示尺寸(按比例缩放)
const canvasWidth = designWidth * scale
const canvasHeight = designHeight * scale
// 模块级运行时:存放公共对象/参数,避免层层传参
const runtime: {
offscreen?: any
} = {}
// 2D Canvas 字体设置
const setFont2D = (ctx: any, fontSize: number, family?: string, weight?: string) => {
const fam = family || 'Noto Sans SC'
const wt = weight || '500'
ctx.font = `${wt} ${fontSize}px "${fam}"`
}
// 绘制加粗文字(单行,支持可选描边式加粗)
const drawBoldText = (ctx: any, text: string, x: number, y: number, fontSize: number, color: string, fontFamily: string = 'Noto Sans SC', fontWeight: string = '500') => {
setFont2D(ctx, fontSize, fontFamily, fontWeight)
ctx.fillStyle = color
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
ctx.fillText(text, x, y)
// if (isBold) {
// ctx.fillText(text, x + 1, y)
// ctx.fillText(text, x, y + 1)
// ctx.fillText(text, x + 1, y + 1)
// }
}
// 绘制圆角矩形函数
const drawRoundedRect = (ctx: any, x: number, y: number, width: number, height: number, radius: number) => {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.arcTo(x + width, y, x + width, y + radius, radius);
ctx.lineTo(x + width, y + height - radius);
ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
ctx.lineTo(x + radius, y + height);
ctx.arcTo(x, y + height, x, y + height - radius, radius);
ctx.lineTo(x, y + radius);
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
ctx.fill();
}
// 绘制标签函数(通用)
const drawLabel = (ctx: any, x: number, y: number, width: number, height: number, radius: number, text: string, fontSize: number, textColor: string = '#000000', bgColor: string = '#FFFFFF', borderColor: string = '#E0E0E0') => {
// 绘制背景
ctx.fillStyle = bgColor
drawRoundedRect(ctx, x, y, width, height, radius)
// 绘制边框
ctx.strokeStyle = borderColor
ctx.lineWidth = 1 * dpr
ctx.stroke()
// 绘制文字
const textCenterX = x + width / 2
const textCenterY = y + height / 2
ctx.fillStyle = textColor
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
setFont2D(ctx, fontSize)
ctx.save()
ctx.translate(textCenterX, textCenterY)
ctx.fillText(text, 0, 0)
ctx.restore()
}
// 工具函数 - OffscreenCanvas 下加载图片(使用 offscreen.createImage
const loadImage = (src: string): Promise<any> => {
return new Promise((resolve, reject) => {
try {
const off = runtime.offscreen
if (!off || typeof off.createImage !== 'function') {
throw new Error('OffscreenCanvas 未初始化或不支持 createImage')
}
const img = off.createImage()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
} catch (e) {
reject(e)
}
})
}
// 绘制SVG路径到Canvas
const drawSVGPathToCanvas = (ctx: any) => {
// 设置绘制样式
ctx.strokeStyle = '#00E5AD';
ctx.lineWidth = scale * 3 * dpr;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.save();
// 移动到指定位置并缩放
ctx.translate(scale * 200 * dpr, scale * 90 * dpr);
const scaleValue = 0.8
ctx.scale(scaleValue, scaleValue);
// 手动绘制SVG路径微信小程序不支持Path2D
ctx.beginPath();
ctx.moveTo(109.808, 6.10642);
ctx.bezierCurveTo(89.5111, -2.79359, 33.4629, 3.77737, 7.49927, 30.2355);
ctx.bezierCurveTo(4.78286, 33.0044, 1.93351, 36.3827, 1.66282, 41.2355);
ctx.bezierCurveTo(1.21704, 49.1959, 7.56651, 55.1359, 13.1231, 57.7309);
ctx.bezierCurveTo(20.6373, 61.24, 28.4568, 62.6334, 36.2033, 63.2984);
ctx.bezierCurveTo(58.8813, 65.247, 81.6582, 60.9714, 101.719, 48.424);
ctx.bezierCurveTo(105.278, 46.1993, 108.823, 43.6365, 111.378, 39.5712);
ctx.bezierCurveTo(113.934, 35.5058, 115.361, 29.6397, 114.14, 24.1205);
ctx.bezierCurveTo(112.685, 17.5296, 107.961, 13.1328, 103.328, 10.8187);
ctx.bezierCurveTo(94.2761, 6.29607, 84.6677, 7.3453, 75.41, 8.45251);
// 绘制路径
ctx.stroke();
// 恢复上下文状态
ctx.restore();
}
// 绘制右上角场地图片
const drawVenueImages = async (ctx: any, venueImageConfig: any) => {
// 如果只有一张图
const playerImgX = venueImageConfig.venueImgX
const playerImgY = venueImageConfig.venueImgY
const playerImgSize = venueImageConfig.venueImgSize
const borderRadius = venueImageConfig.borderRadius
const padding = venueImageConfig.padding
const rotation = venueImageConfig.rotation // 旋转-8度
const venueImage = venueImageConfig.venueImage
try {
const playerImgPath = await loadImage(venueImage)
ctx.save()
// 移动到旋转中心点
const centerX = playerImgX + playerImgSize / 2
const centerY = playerImgY + playerImgSize / 2
ctx.translate(centerX, centerY)
// 旋转-8度
ctx.rotate((rotation * Math.PI) / 180)
// 1. 先绘制白色圆角矩形背景
ctx.fillStyle = '#FFFFFF'
ctx.beginPath()
// 使用更精确的圆角矩形绘制
const rectX = -playerImgSize / 2
const rectY = -playerImgSize / 2
const rectWidth = playerImgSize
const rectHeight = playerImgSize
// 从左上角开始,顺时针绘制
// 左上角圆角
ctx.moveTo(rectX + borderRadius, rectY)
ctx.quadraticCurveTo(rectX, rectY, rectX, rectY + borderRadius)
// 左边
ctx.lineTo(rectX, rectY + rectHeight - borderRadius)
// 左下角圆角
ctx.quadraticCurveTo(rectX, rectY + rectHeight, rectX + borderRadius, rectY + rectHeight)
// 下边
ctx.lineTo(rectX + rectWidth - borderRadius, rectY + rectHeight)
// 右下角圆角
ctx.quadraticCurveTo(rectX + rectWidth, rectY + rectHeight, rectX + rectWidth, rectY + rectHeight - borderRadius)
// 右边
ctx.lineTo(rectX + rectWidth, rectY + borderRadius)
// 右上角圆角
ctx.quadraticCurveTo(rectX + rectWidth, rectY, rectX + rectWidth - borderRadius, rectY)
// 上边
ctx.lineTo(rectX + borderRadius, rectY)
ctx.closePath()
ctx.fill()
// 2. 绘制图片带4px内边距
const imgX = -playerImgSize / 2 + padding
const imgY = -playerImgSize / 2 + padding
const imgSize = playerImgSize - padding * 2
// 设置圆角裁剪区域
ctx.beginPath()
const imgRadius = borderRadius - padding
// 从左上角开始,顺时针绘制
// 左上角圆角
ctx.moveTo(imgX + imgRadius, imgY)
ctx.quadraticCurveTo(imgX, imgY, imgX, imgY + imgRadius)
// 左边
ctx.lineTo(imgX, imgY + imgSize - imgRadius)
// 左下角圆角
ctx.quadraticCurveTo(imgX, imgY + imgSize, imgX + imgRadius, imgY + imgSize)
// 下边
ctx.lineTo(imgX + imgSize - imgRadius, imgY + imgSize)
// 右下角圆角
ctx.quadraticCurveTo(imgX + imgSize, imgY + imgSize, imgX + imgSize, imgY + imgSize - imgRadius)
// 右边
ctx.lineTo(imgX + imgSize, imgY + imgRadius)
// 右上角圆角
ctx.quadraticCurveTo(imgX + imgSize, imgY, imgX + imgSize - imgRadius, imgY)
// 上边
ctx.lineTo(imgX + imgRadius, imgY)
ctx.closePath()
ctx.clip()
// 绘制图片
ctx.drawImage(playerImgPath, imgX, imgY, imgSize, imgSize)
ctx.restore()
} catch (error) {
// 如果图片加载失败,绘制占位符
ctx.save()
const centerX = playerImgX + playerImgSize / 2
const centerY = playerImgY + playerImgSize / 2
ctx.translate(centerX, centerY)
ctx.rotate((rotation * Math.PI) / 180)
// 绘制白色圆角矩形背景
ctx.fillStyle = '#FFFFFF'
ctx.beginPath()
const rectX = -playerImgSize / 2
const rectY = -playerImgSize / 2
const rectWidth = playerImgSize
const rectHeight = playerImgSize
// 从左上角开始,顺时针绘制
// 左上角圆角
ctx.moveTo(rectX + borderRadius, rectY)
ctx.quadraticCurveTo(rectX, rectY, rectX, rectY + borderRadius)
// 左边
ctx.lineTo(rectX, rectY + rectHeight - borderRadius)
// 左下角圆角
ctx.quadraticCurveTo(rectX, rectY + rectHeight, rectX + borderRadius, rectY + rectHeight)
// 下边
ctx.lineTo(rectX + rectWidth - borderRadius, rectY + rectHeight)
// 右下角圆角
ctx.quadraticCurveTo(rectX + rectWidth, rectY + rectHeight, rectX + rectWidth, rectY + rectHeight - borderRadius)
// 右边
ctx.lineTo(rectX + rectWidth, rectY + borderRadius)
// 右上角圆角
ctx.quadraticCurveTo(rectX + rectWidth, rectY, rectX + rectWidth - borderRadius, rectY)
// 上边
ctx.lineTo(rectX + borderRadius, rectY)
ctx.closePath()
ctx.fill()
// 绘制灰色占位符(带内边距)
const imgX = -playerImgSize / 2 + padding
const imgY = -playerImgSize / 2 + padding
const imgSize = playerImgSize - padding * 2
ctx.fillStyle = '#E0E0E0'
ctx.beginPath()
const imgRadius = borderRadius - padding
// 从左上角开始,顺时针绘制
// 左上角圆角
ctx.moveTo(imgX + imgRadius, imgY)
ctx.quadraticCurveTo(imgX, imgY, imgX, imgY + imgRadius)
// 左边
ctx.lineTo(imgX, imgY + imgSize - imgRadius)
// 左下角圆角
ctx.quadraticCurveTo(imgX, imgY + imgSize, imgX + imgRadius, imgY + imgSize)
// 下边
ctx.lineTo(imgX + imgSize - imgRadius, imgY + imgSize)
// 右下角圆角
ctx.quadraticCurveTo(imgX + imgSize, imgY + imgSize, imgX + imgSize, imgY + imgSize - imgRadius)
// 右边
ctx.lineTo(imgX + imgSize, imgY + imgRadius)
// 右上角圆角
ctx.quadraticCurveTo(imgX + imgSize, imgY, imgX + imgSize - imgRadius, imgY)
// 上边
ctx.lineTo(imgX + imgRadius, imgY)
ctx.closePath()
ctx.fill()
ctx.restore()
console.log('球员图片占位符绘制完成')
}
}
// 绘制分享卡片
const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Promise<string> => {
return new Promise(async (resolve, reject) => {
console.log('开始绘制分享卡片...')
try {
// 设置Canvas的实际尺寸使用dpr确保高清显示
const canvasWidthPx = canvasWidth * dpr
const canvasHeightPx = canvasHeight * dpr
// 清空画布
ctx.clearRect(0, 0, canvasWidthPx, canvasHeightPx)
console.log('画布已清空')
// 如果dpr大于2进行缩放处理以避免内容过大
if (dpr > 2) {
const scale = 2 / dpr
ctx.scale(scale, scale)
console.log('应用缩放:', scale)
}
// 绘制背景 - 渐变色 已完成
const gradient = ctx.createLinearGradient(0, 0, 0, canvasHeightPx)
gradient.addColorStop(0, '#BFFFEF')
gradient.addColorStop(1, '#F2FFFC')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, canvasWidthPx, canvasHeightPx)
console.log('背景绘制完成')
// 绘制背景条纹 已完成
ctx.strokeStyle = 'rgba(0, 0, 0, 0.03)'
ctx.lineWidth = 2
for (let i = 0; i < canvasWidthPx; i += 4) {
ctx.beginPath()
ctx.moveTo(i, 0)
ctx.lineTo(i, canvasHeightPx)
ctx.stroke()
}
// 绘制用户头像(左上角) 已完成
const avatarSize = scale * 32 * dpr // 32px * dpr
const avatarX = scale * 35 * dpr // 距离左侧35px
const avatarY = scale * 35 * dpr // 距离顶部35px
try {
const avatarPath = await loadImage(data.userAvatar)
// 微信小程序中绘制圆形头像需要特殊处理
ctx.save()
ctx.beginPath()
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI)
ctx.clip()
ctx.drawImage(avatarPath, avatarX, avatarY, avatarSize, avatarSize)
ctx.restore()
} catch (error) {
// 如果头像加载失败,绘制默认头像
ctx.setFillStyle('#CCCCCC')
ctx.beginPath()
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI)
ctx.fill()
}
// 绘制用户昵称 已完成
const nicknameX = avatarX + avatarSize + 8 * dpr // 距离头像8px
const nicknameY = avatarY + (avatarSize - 18 * dpr) / 2 // 与头像水平居中对齐
const nicknameFontSize = scale * 18 * dpr
drawBoldText(ctx, data.userNickname, nicknameX, nicknameY, nicknameFontSize, '#000000', 'Noto Sans SC', '900')
// 绘制"邀你加入球局"文案
const inviteX = scale * 35 * dpr // 距离画布左侧35px
const inviteY = scale * 100 * dpr // 距离画布顶部79px
const inviteFontSize = scale * 44 * dpr
// 绘制"邀你加入"
drawBoldText(ctx, '邀你加入', inviteX, inviteY, inviteFontSize, '#000000', 'Noto Sans SC', '900')
// 绘制"球局"特殊样式
const qiuJuX = inviteX + ctx.measureText('邀你加入').width + 4 * dpr
const qiuJuFontSize = scale * 44 * dpr
drawBoldText(ctx, '球局', qiuJuX, inviteY, qiuJuFontSize, '#00E5AD', 'Noto Sans SC', '900')
// 测试绘制网络图片
drawSVGPathToCanvas(ctx)
// 绘制球员图片(右上角)已完成
let venueBaseConfig = {
venueImgX: scale * 340 * dpr,
venueImgY: scale * 35 * dpr,
rotation: scale * -8, // 旋转-8度
venueImgSize: scale * 124 * dpr,
borderRadius: scale * 24 * dpr,
padding: scale * 4 * dpr,
venueImage: data.venueImages?.[0]
}
if (data.venueImages.length > 1) {
// 后面的图
const venueBackConfig = {
...venueBaseConfig,
venueImage: data.venueImages?.[1],
venueImgX: scale * 400 * dpr,
venueImgY: scale * 35 * dpr,
rotation: scale * -10, // 旋转-10度
}
await drawVenueImages(ctx, venueBackConfig)
// 前面的图
const venueFrontConfig = {
...venueBaseConfig,
venueImage: data.venueImages?.[0],
rotation: scale * 8, // 旋转-8度
}
await drawVenueImages(ctx, venueFrontConfig)
} else {
await drawVenueImages(ctx, venueBaseConfig)
}
// 绘制球局信息区域
const infoStartY = scale * 192
const infoSpacing = scale * 64
// 球局类型和技能等级
const gameInfoY = infoStartY
// 图标大小
const iconSize = scale * 48
// 图标距离左侧距离
const iconX = scale * 35
// 文本距离左侧距离
const textX = iconX + iconSize + 20
// 绘制网球图标
const tennisBallPath = await loadImage(`${OSS_BASE}/front/ball/images/b3eaf45e-ef28-4e45-9195-823b832e0451.jpg`)
ctx.drawImage(tennisBallPath, iconX, gameInfoY, iconSize, iconSize)
// 绘制"单打"标签
const danDaX = scale * 100
const danDaY = scale * 196
const danDaHeight = scale * 40 * dpr
const danDaRadius = scale * 20 * dpr
const danDaFontSize = scale * 22 * dpr
// 根据内容动态计算标签宽度(左右内边距)
const danDaPaddingX = scale * 16 * dpr
setFont2D(ctx, danDaFontSize)
const danDaTextWidth = ctx.measureText(data.gameType).width
const danDaWidth = danDaTextWidth + danDaPaddingX * 2
drawLabel(ctx, danDaX, danDaY, danDaWidth, danDaHeight, danDaRadius, data.gameType, danDaFontSize)
// 绘制技能等级标签(基于“单打”标签实际宽度后移)
const labelGap = scale * 16 // 两个标签之间的间距(不乘 dpr保持视觉间距
const skillX = danDaX + danDaWidth + labelGap
const skillY = scale * 196
const skillHeight = scale * 40 * dpr
const skillRadius = scale * 20 * dpr
const skillFontSize = scale * 22 * dpr
// 根据内容动态计算技能标签宽度
const skillPaddingX = scale * 20 * dpr
setFont2D(ctx, skillFontSize)
const skillTextWidth = ctx.measureText(data.skillLevel).width
const skillWidth = skillTextWidth + skillPaddingX * 2
drawLabel(ctx, skillX, skillY, skillWidth, skillHeight, skillRadius, data.skillLevel, skillFontSize)
// 绘制日期时间
const dateX = danDaX
const timeInfoY = infoStartY + infoSpacing
const timeInfoFontSize = scale * 24 * dpr
const calendarPath = await loadImage(`${OSS_BASE}/front/ball/images/ea792a5d-b105-4c95-bfc4-8af558f2b33b.jpg`)
ctx.drawImage(calendarPath, iconX, timeInfoY, iconSize, iconSize)
// 绘制日期(绿色,非描边粗体)
drawBoldText(ctx, data.gameDate, dateX, timeInfoY + 8, timeInfoFontSize, '#00E5AD')
// 绘制时间(黑色)
const timeX = textX + ctx.measureText(data.gameDate).width + 10 * dpr
drawBoldText(ctx, data.gameTime, timeX, timeInfoY + 8, timeInfoFontSize, '#000000')
// 绘制地点
const locationInfoY = infoStartY + infoSpacing * 2
const locationFontSize = scale * 22 * dpr
const locationPath = await loadImage(`${OSS_BASE}/front/ball/images/adc9a167-2ea9-4e3b-b963-6a894a1fd91b.jpg`)
ctx.drawImage(locationPath, iconX, locationInfoY, iconSize, iconSize)
drawBoldText(ctx, data.venueName, danDaX, locationInfoY + 10, locationFontSize, '#000000')
try {
const wxAny: any = (typeof (globalThis as any) !== 'undefined' && (globalThis as any).wx) ? (globalThis as any).wx : null
if (wxAny && typeof wxAny.canvasToTempFilePath === 'function') {
wxAny.canvasToTempFilePath({
canvas: offscreen,
fileType: 'png',
quality: 1,
success: (res: any) => {
console.log('===res666', res)
resolve(res.tempFilePath)
},
fail: reject
})
return
}
} catch { }
reject(new Error('无法导出图片OffscreenCanvas 转文件失败)'))
console.log('Canvas绘制命令已发送')
} catch (error) {
console.warn('绘制分享卡片失败:', error)
Taro.showToast({
title: '生成分享卡片失败',
icon: 'none'
})
reject(error)
}
})
}
let isDrawing = false
export async function generateShareImage(data: ShareCardData): Promise<string> {
if (isDrawing) {
return ''
}
// 如果传入了 canvasId则优先使用页面 Canvas 进行绘制(可见且稳定)
const anyGlobal: any = Taro as any
const weappGlobal: any = (typeof (globalThis as any) !== 'undefined' && (globalThis as any).wx) ? (globalThis as any).wx : null
const createOffscreen = anyGlobal?.createOffscreenCanvas || weappGlobal?.createOffscreenCanvas || (Taro as any)?.createOffscreenCanvas
if (!createOffscreen) {
throw new Error('OffscreenCanvas 不可用')
}
// 设置Canvas的实际尺寸使用dpr确保高清显示
const canvasWidthPx = canvasWidth * dpr
const canvasHeightPx = canvasHeight * dpr
// @ts-ignore - 小程序下 createOffscreenCanvas 接口
const offscreen = createOffscreen({ type: '2d', width: canvasWidthPx, height: canvasHeightPx })
// @ts-ignore
let ctx: CanvasRenderingContext2D = offscreen.getContext('2d')
// 记录到 runtime供 loadImage 使用)
runtime.offscreen = offscreen
isDrawing = true
const imagePath = await drawShareCard(ctx, data, offscreen)
isDrawing = false
return imagePath
}
export default generateShareImage