720 lines
29 KiB
TypeScript
720 lines
29 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
||
import { View, Canvas } from '@tarojs/components'
|
||
import Taro from '@tarojs/taro'
|
||
import { OSS_BASE } from "@/config/api";
|
||
|
||
// 分享卡片数据接口
|
||
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 // 场地名称,如"因乐驰网球俱乐部(嘉定江桥万达店)"
|
||
venueImages: 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 [canvasNode, setCanvasNode] = useState<any>(null) // 2D Canvas 节点
|
||
const [ctx2d, setCtx2d] = useState<any>(null) // 2D 上下文
|
||
const [is2dCtx, setIs2dCtx] = useState(false) // 是否为 2D 上下文
|
||
|
||
// 设计稿尺寸
|
||
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
|
||
|
||
// 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()
|
||
}
|
||
|
||
// 加载图片 - 微信小程序版本
|
||
const loadImage = (src: string, canvas?: any): Promise<any> => {
|
||
return new Promise((resolve, reject) => {
|
||
if (!canvas || typeof canvas.createImage !== 'function') {
|
||
reject(new Error('2D canvas is required to load images'))
|
||
return
|
||
}
|
||
try {
|
||
const img = canvas.createImage()
|
||
img.onload = () => resolve(img)
|
||
img.onerror = (e: any) => reject(e)
|
||
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, canvas?: 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, canvas)
|
||
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) => {
|
||
// 防止重复绘制
|
||
if (isDrawing) {
|
||
console.log('正在绘制中,跳过重复绘制')
|
||
return
|
||
}
|
||
|
||
return new Promise(async (resolve, reject) => {
|
||
console.log('开始绘制分享卡片...')
|
||
setIsDrawing(true)
|
||
|
||
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, canvasNode)
|
||
// 微信小程序中绘制圆形头像需要特殊处理
|
||
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.fillStyle = '#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
|
||
// drawText(ctx, data.userNickname, nicknameX, nicknameY, 200 * dpr, nicknameFontSize, '#000000', true, 'Noto Sans SC')
|
||
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, canvasNode)
|
||
// 前面的图
|
||
const venueFrontConfig = {
|
||
...venueBaseConfig,
|
||
venueImage: data.venueImages?.[0],
|
||
rotation: scale * 8, // 旋转-8度
|
||
}
|
||
await drawVenueImages(ctx, venueFrontConfig, canvasNode)
|
||
} else {
|
||
await drawVenueImages(ctx, venueBaseConfig, canvasNode)
|
||
}
|
||
|
||
// 绘制球局信息区域
|
||
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`, canvasNode)
|
||
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`, canvasNode)
|
||
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
|
||
// drawText(ctx, data.gameTime, timeX, timeInfoY + 8, 300, timeInfoFontSize, '#000000')
|
||
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`, canvasNode)
|
||
ctx.drawImage(locationPath, iconX, locationInfoY, iconSize, iconSize)
|
||
drawBoldText(ctx, data.venueName, danDaX, locationInfoY + 10, locationFontSize, '#000000')
|
||
|
||
// 绘制完成,调用draw方法
|
||
console.log('开始调用ctx.draw()')
|
||
const doExport = () => {
|
||
console.log('Canvas绘制完成,开始生成图片...')
|
||
const opts: any = {
|
||
fileType: 'png',
|
||
quality: 1,
|
||
success: (res: any) => {
|
||
console.log('图片生成成功:', res.tempFilePath)
|
||
setIsDrawing(false)
|
||
resolve(res.tempFilePath)
|
||
onGenerated?.(res.tempFilePath)
|
||
setTempImagePath(res.tempFilePath)
|
||
},
|
||
fail: (error: any) => {
|
||
console.warn('图片生成失败:', error)
|
||
setIsDrawing(false)
|
||
reject(error)
|
||
}
|
||
}
|
||
if (canvasNode) {
|
||
opts.canvas = canvasNode
|
||
} else {
|
||
opts.canvasId = 'shareCardCanvas'
|
||
}
|
||
;(Taro as any).canvasToTempFilePath(opts)
|
||
}
|
||
if (typeof (ctx as any).draw === 'function') {
|
||
;(ctx as any).draw(false, () => setTimeout(doExport, 300))
|
||
} else {
|
||
setTimeout(doExport, 100)
|
||
}
|
||
console.log('Canvas绘制命令已发送')
|
||
|
||
} catch (error) {
|
||
console.warn('绘制分享卡片失败:', error)
|
||
setIsDrawing(false) // 绘制失败,重置状态
|
||
Taro.showToast({
|
||
title: '生成分享卡片失败',
|
||
icon: 'none'
|
||
})
|
||
reject(error)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 手动分享方法(已移除,由父组件处理分享)
|
||
|
||
// 使用 HTTPS 远程字体(woff2)加载到小程序渲染层;不改字号
|
||
useEffect(() => {
|
||
try {
|
||
(Taro as any).loadFontFace({
|
||
global: true,
|
||
family: 'Noto Sans SC',
|
||
source: 'url("https://fonts.gstatic.com/s/notosanssc/v39/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaGzjCnYlNbPzT7HEL7j12XCOHJKg4RgZw3nFTvwZ8atTsBvwlvRUk7mYP2g.24.woff2")',
|
||
desc: { style: 'normal', weight: '700' },
|
||
success: () => {
|
||
console.log('===Noto Sans SC 远程字体加载成功')
|
||
try {
|
||
if (ctx2d && is2dCtx) {
|
||
drawShareCard(ctx2d)
|
||
} else {
|
||
console.log('字体已加载,但 2D 上下文尚未就绪,等待初始化后再绘制')
|
||
}
|
||
} catch (e) {
|
||
console.warn('===字体加载成功但重绘失败(忽略)', e)
|
||
}
|
||
},
|
||
fail: (err: any) => {
|
||
console.warn('===Noto Sans SC 远程字体加载失败:', err)
|
||
}
|
||
})
|
||
} catch (e) {
|
||
console.warn('===loadFontFace 不可用,跳过远程字体加载', e)
|
||
}
|
||
}, [ctx2d, is2dCtx])
|
||
|
||
|
||
// 初始化 2D Canvas(可回退旧版)
|
||
useEffect(() => {
|
||
try {
|
||
;(Taro as any).createSelectorQuery()
|
||
.select('#shareCardCanvas')
|
||
.fields({ node: true, size: true })
|
||
.exec((res: any[]) => {
|
||
const data = res && res[0]
|
||
if (data && data.node) {
|
||
const canvas = data.node
|
||
const context = canvas.getContext('2d')
|
||
// DPR 缩放,提升清晰度(当前 dpr = 1 也可正常显示)
|
||
const sys = (Taro as any).getSystemInfoSync?.() || {}
|
||
const ratio = sys.pixelRatio || 1
|
||
canvas.width = canvasWidth * ratio
|
||
canvas.height = canvasHeight * ratio
|
||
context.scale(ratio, ratio)
|
||
setCanvasNode(canvas)
|
||
setCtx2d(context)
|
||
setIs2dCtx(true)
|
||
console.log('2D Canvas 初始化成功')
|
||
} else {
|
||
setIs2dCtx(false)
|
||
console.log('2D Canvas 不可用,回退旧版 Canvas')
|
||
}
|
||
})
|
||
} catch (e) {
|
||
console.warn('初始化 2D Canvas 失败,回退旧版', e)
|
||
setIs2dCtx(false)
|
||
}
|
||
}, [])
|
||
|
||
// 组件挂载后绘制(仅在 2D 上下文就绪后绘制,避免回退导致 getImageInfo 404)
|
||
useEffect(() => {
|
||
if (data && !isDrawing && !tempImagePath && ctx2d && is2dCtx) {
|
||
console.log('组件挂载,开始绘制分享卡片')
|
||
// 延迟一下确保Canvas已经渲染
|
||
setTimeout(() => {
|
||
console.log('使用 2D Canvas 开始绘制')
|
||
drawShareCard(ctx2d)
|
||
}, 500)
|
||
}
|
||
}, [data, ctx2d, is2dCtx]) // 等 2D 上下文可用后再绘制
|
||
|
||
// 暴露分享方法给父组件
|
||
useEffect(() => {
|
||
if (onGenerated && tempImagePath) {
|
||
onGenerated(tempImagePath)
|
||
}
|
||
}, [tempImagePath]) // 只依赖tempImagePath,移除onGenerated避免无限循环
|
||
|
||
|
||
return (
|
||
<View className={`share-card-canvas ${className}`}>
|
||
<Canvas
|
||
canvasId="shareCardCanvas"
|
||
id="shareCardCanvas"
|
||
type="2d"
|
||
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
|
||
|