Files
mini-programs/src/components/ShareCardCanvas/index.tsx
2025-09-15 01:03:59 +08:00

654 lines
26 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 React, { useEffect, useState } from 'react'
import { View, Canvas } from '@tarojs/components'
import Taro from '@tarojs/taro'
// 分享卡片数据接口
export interface ShareCardData {
// 用户信息
userAvatar: string // 用户头像URL
userNickname: string // 用户昵称
// 球局信息
gameType: string // 球局类型,如"单打"
skillLevel: string // 技能等级,如"NTRP 2.5 - 3.0"
gameDate: string // 日期,如"6月20日(周五)"
gameTime: string // 时间,如"下午5点 2小时"
venueName: string // 场地名称,如"因乐驰网球俱乐部(嘉定江桥万达店)"
venueImage: string // 场地图片URL
// 可选信息
playerImage?: string // 球员图片URL
}
// 组件Props接口
export interface ShareCardCanvasProps {
data: ShareCardData
width?: number // 卡片宽度默认375
height?: number // 卡片高度默认500
onGenerated?: (imagePath: string) => void // 生成完成回调
className?: string
}
const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
data,
className = '',
onGenerated = () => { }
}) => {
const [tempImagePath, setTempImagePath] = useState('') // 存储Canvas生成的图片路径
const [isDrawing, setIsDrawing] = useState(false) // 防止重复绘制
// 设计稿尺寸
const designWidth = 500
const designHeight = 400
// 获取屏幕宽度如果没有传入width则使用屏幕宽度
const windowWidth = Taro.getSystemInfoSync().windowWidth
// 2. 计算缩放比例(设备宽度 / 设计稿宽度)
const scale = windowWidth / designWidth
// 3. 计算实际显示尺寸(按比例缩放)
const canvasWidth = designWidth * scale
const canvasHeight = designHeight * scale
// 绘制加粗文字(单行)
const drawBoldText = (ctx: any, text: string, x: number, y: number, fontSize: number, color: string, fontFamily?: string) => {
// 设置字体样式
if (fontFamily) {
try {
// 尝试使用setFont方法如果支持
ctx.setFont(`${fontSize}px ${fontFamily}`)
} catch (error) {
// 如果不支持setFont回退到setFontSize
ctx.setFontSize(fontSize)
}
} else {
ctx.setFontSize(fontSize)
}
ctx.setFillStyle(color)
ctx.setTextAlign('left')
ctx.setTextBaseline('top')
// 绘制加粗效果:多次绘制并偏移
ctx.fillText(text, x, y)
ctx.fillText(text, x + 1, y)
ctx.fillText(text, x, y + 1)
ctx.fillText(text, x + 1, y + 1)
}
// 绘制文字(支持自动换行)- 微信小程序版本
const drawText = (ctx: any, text: string, x: number, y: number, maxWidth: number, fontSize: number, color: string, bold: boolean = false, fontFamily?: string) => {
// 设置字体样式
if (fontFamily) {
try {
// 尝试使用setFont方法如果支持
ctx.setFont(`${fontSize}px ${fontFamily}`)
} catch (error) {
// 如果不支持setFont回退到setFontSize
ctx.setFontSize(fontSize)
}
} else {
ctx.setFontSize(fontSize)
}
ctx.setFillStyle(color)
ctx.setTextAlign('left')
ctx.setTextBaseline('top')
const words = text.split('')
let line = ''
let lineY = y
for (let i = 0; i < words.length; i++) {
const testLine = line + words[i]
// 微信小程序中measureText返回的是对象需要访问width属性
const metrics = ctx.measureText(testLine)
const testWidth = metrics.width
if (testWidth > maxWidth && i > 0) {
if (bold) {
// 绘制加粗效果:多次绘制并偏移
ctx.fillText(line, x, lineY)
ctx.fillText(line, x + 1, lineY)
ctx.fillText(line, x, lineY + 1)
ctx.fillText(line, x + 1, lineY + 1)
} else {
ctx.fillText(line, x, lineY)
}
line = words[i]
lineY += fontSize * 1.2
} else {
line = testLine
}
}
if (bold) {
// 绘制加粗效果:多次绘制并偏移
ctx.fillText(line, x, lineY)
ctx.fillText(line, x + 1, lineY)
ctx.fillText(line, x, lineY + 1)
ctx.fillText(line, x + 1, lineY + 1)
} else {
ctx.fillText(line, x, lineY)
}
}
// 绘制圆角矩形函数
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.setFillStyle(bgColor)
drawRoundedRect(ctx, x, y, width, height, radius)
// 绘制边框
ctx.setStrokeStyle(borderColor)
ctx.setLineWidth(1 * dpr)
ctx.stroke()
// 绘制文字
const textCenterX = x + width / 2
const textCenterY = y + height / 2
ctx.setFillStyle(textColor)
ctx.setTextAlign('center')
ctx.setTextBaseline('middle')
ctx.setFontSize(fontSize)
ctx.save()
ctx.translate(textCenterX, textCenterY)
ctx.fillText(text, 0, 0)
ctx.restore()
}
// 加载图片 - 微信小程序版本
const loadImage = (src: string): Promise<string> => {
return new Promise((resolve, reject) => {
Taro.getImageInfo({
src: src,
success: (res) => resolve(res.path),
fail: reject
})
})
}
// 绘制SVG路径到Canvas
const drawSVGPathToCanvas = (ctx: any) => {
// 设置绘制样式
ctx.setStrokeStyle('#48D800');
ctx.setLineWidth(scale * 3 * dpr);
ctx.setLineCap('round');
ctx.setLineJoin('round');
ctx.save();
// 移动到指定位置并缩放
ctx.translate(scale * 210 * 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();
}
// 获取 DPR - 使用系统像素比确保高清显示
// const systemDpr = Taro.getSystemInfoSync().pixelRatio
const dpr = 1
// Math.min(systemDpr, 3) // 限制最大dpr为3避免过度放大
// 绘制分享卡片
const drawShareCard = async () => {
// 防止重复绘制
if (isDrawing) {
console.log('正在绘制中,跳过重复绘制')
return
}
return new Promise(async (resolve, reject) => {
console.log('开始绘制分享卡片...')
setIsDrawing(true)
try {
// 在微信小程序中需要使用Taro.createCanvasContext
const ctx = Taro.createCanvasContext('shareCardCanvas')
console.log('Canvas上下文创建成功:', ctx)
// 设置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, '#D8FFE5')
gradient.addColorStop(1, '#F9FFFB')
ctx.setFillStyle(gradient)
ctx.fillRect(0, 0, canvasWidthPx, canvasHeightPx)
console.log('背景绘制完成')
// 绘制背景条纹 已完成
ctx.setStrokeStyle('rgba(0, 0, 0, 0.03)')
ctx.setLineWidth(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 - 24 * dpr) / 2 // 与头像水平居中对齐
const nicknameFontSize = scale * 24 * dpr
drawText(ctx, data.userNickname, nicknameX, nicknameY, 200 * dpr, nicknameFontSize, '#000000', true, '"Noto Sans SC"')
// 绘制"邀你加入球局"文案
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")
// 绘制"球局"特殊样式
const qiuJuX = inviteX + ctx.measureText('邀你加入').width + 5 * dpr
const qiuJuFontSize = scale * 44 * dpr
drawBoldText(ctx, '球局', qiuJuX, inviteY, qiuJuFontSize, '#48D800', '"Noto Sans SC"')
// 测试绘制网络图片
drawSVGPathToCanvas(ctx)
// 绘制球员图片(右上角)已完成
const playerImgX = scale * 340 * dpr
const playerImgY = scale * 35 * dpr
const playerImgSize = scale * 124 * dpr
const borderRadius = scale * 24 * dpr
const padding = scale * 4 * dpr
const rotation = scale * -8 // 旋转-8度
try {
const playerImgPath = await loadImage(data.playerImage || data.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.setFillStyle('#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.setFillStyle('#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.setFillStyle('#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 infoStartY = scale * 192
const infoSpacing = scale * 64
// 球局类型和技能等级
const gameInfoY = infoStartY
const iconSize = scale * 40
const iconX = scale * 35
const textX = iconX + iconSize + 20
// 绘制网球图标
const tennisBallPath = await loadImage('https://bimwe.oss-cn-shanghai.aliyuncs.com/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 danDaWidth = scale * 76 * dpr
const danDaHeight = scale * 40 * dpr
const danDaRadius = scale * 20 * dpr
const danDaFontSize = scale * 22 * dpr
drawLabel(ctx, danDaX, danDaY, danDaWidth, danDaHeight, danDaRadius, data.gameType, danDaFontSize)
// 绘制技能等级标签
const skillX = scale * 190
const skillY = scale * 196
const skillWidth = scale * 180 * dpr
const skillHeight = scale * 40 * dpr
const skillRadius = scale * 20 * dpr
const skillFontSize = scale * 22 * dpr
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("https://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/ea792a5d-b105-4c95-bfc4-8af558f2b33b.jpg")
ctx.drawImage(calendarPath, iconX, timeInfoY, iconSize, iconSize)
// 绘制日期(绿色)
drawText(ctx, data.gameDate, dateX, timeInfoY + 4, 300, timeInfoFontSize, '#4CAF50')
// 绘制时间(黑色)
const timeX = textX + ctx.measureText(data.gameDate).width + 10 * dpr
drawText(ctx, data.gameTime, timeX, timeInfoY + 4, 300, timeInfoFontSize, '#000000')
// 绘制地点
const locationInfoY = infoStartY + infoSpacing * 2
const locationFontSize = scale * 22 * dpr
const locationPath = await loadImage("https://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/adc9a167-2ea9-4e3b-b963-6a894a1fd91b.jpg")
ctx.drawImage(locationPath, iconX, locationInfoY, iconSize, iconSize)
drawText(ctx, data.venueName, danDaX, locationInfoY + 4, 600, locationFontSize, '#000000')
// 绘制完成调用draw方法
console.log('开始调用ctx.draw()')
ctx.draw(false, () => {
console.log('Canvas绘制完成开始生成图片...')
// 延迟一下再生成图片,确保绘制完成
setTimeout(() => {
Taro.canvasToTempFilePath({
canvasId: 'shareCardCanvas',
fileType: 'png',
quality: 1,
success: (res) => {
console.log('图片生成成功:', res.tempFilePath)
setIsDrawing(false) // 绘制完成,重置状态
resolve(res.tempFilePath)
onGenerated?.(res.tempFilePath)
setTempImagePath(res.tempFilePath)
},
fail: (error) => {
console.error('图片生成失败:', error)
setIsDrawing(false) // 绘制失败,重置状态
reject(error)
}
})
}, 500) // 延迟500ms确保Canvas完全渲染
})
console.log('Canvas绘制命令已发送')
} catch (error) {
console.error('绘制分享卡片失败:', error)
setIsDrawing(false) // 绘制失败,重置状态
Taro.showToast({
title: '生成分享卡片失败',
icon: 'none'
})
reject(error)
}
})
}
// 手动分享方法(已移除,由父组件处理分享)
// 组件挂载后绘制
useEffect(() => {
if (data && !isDrawing && !tempImagePath) {
console.log('组件挂载,开始绘制分享卡片')
// 延迟一下确保Canvas已经渲染
setTimeout(() => {
drawShareCard()
}, 500)
}
}, [data]) // 只依赖data移除canvasWidth避免无限循环
// 暴露分享方法给父组件
useEffect(() => {
if (onGenerated && tempImagePath) {
onGenerated(tempImagePath)
}
}, [tempImagePath]) // 只依赖tempImagePath移除onGenerated避免无限循环
return (
<View className={`share-card-canvas ${className}`}>
<Canvas
canvasId="shareCardCanvas"
style={{
width: `${canvasWidth}px`,
height: `${canvasHeight}px`,
position: 'absolute', // 绝对定位避免影响布局
top: '-9999px', // 移出可视区域
left: '-9999px'
}}
width={`${canvasWidth * dpr}`}
height={`${canvasHeight * dpr}`}
disableScroll={true}
onTouchStart={() => { }}
onTouchMove={() => { }}
onTouchEnd={() => { }}
onTouchCancel={() => { }}
/>
</View>
)
}
export default ShareCardCanvas