diff --git a/src/components/ShareCardCanvas/index.tsx b/src/components/ShareCardCanvas/index.tsx index b8d5838..f0dd60a 100644 --- a/src/components/ShareCardCanvas/index.tsx +++ b/src/components/ShareCardCanvas/index.tsx @@ -14,7 +14,7 @@ export interface ShareCardData { gameDate: string // 日期,如"6月20日(周五)" gameTime: string // 时间,如"下午5点 2小时" venueName: string // 场地名称,如"因乐驰网球俱乐部(嘉定江桥万达店)" - venueImage: string // 场地图片URL + venueImages: string[] // 场地图片URL // 可选信息 playerImage?: string // 球员图片URL @@ -44,6 +44,11 @@ const ShareCardCanvas: React.FC = ({ // 获取屏幕宽度,如果没有传入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 @@ -222,13 +227,201 @@ const ShareCardCanvas: React.FC = ({ ctx.restore(); } - // 获取 DPR - 使用系统像素比确保高清显示 - // const systemDpr = Taro.getSystemInfoSync().pixelRatio - const dpr = 1 - // Math.min(systemDpr, 3) // 限制最大dpr为3,避免过度放大 + // 绘制右上角场地图片 + 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.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 drawShareCard = async () => { + const drawShareCard = async (ctx: any) => { // 防止重复绘制 if (isDrawing) { console.log('正在绘制中,跳过重复绘制') @@ -240,10 +433,6 @@ const ShareCardCanvas: React.FC = ({ setIsDrawing(true) try { - // 在微信小程序中,需要使用Taro.createCanvasContext - const ctx = Taro.createCanvasContext('shareCardCanvas') - console.log('Canvas上下文创建成功:', ctx) - // 设置Canvas的实际尺寸(使用dpr确保高清显示) const canvasWidthPx = canvasWidth * dpr const canvasHeightPx = canvasHeight * dpr @@ -322,192 +511,35 @@ const ShareCardCanvas: React.FC = ({ 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度 + 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] + } - 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('球员图片占位符绘制完成') + 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) } // 绘制球局信息区域 @@ -516,8 +548,11 @@ const ShareCardCanvas: React.FC = ({ // 球局类型和技能等级 const gameInfoY = infoStartY - const iconSize = scale * 40 + // 图标大小 + const iconSize = scale * 48 + // 图标距离左侧距离 const iconX = scale * 35 + // 文本距离左侧距离 const textX = iconX + iconSize + 20 // 绘制网球图标 @@ -552,45 +587,45 @@ const ShareCardCanvas: React.FC = ({ ctx.drawImage(calendarPath, iconX, timeInfoY, iconSize, iconSize) // 绘制日期(绿色) - drawText(ctx, data.gameDate, dateX, timeInfoY + 4, 300, timeInfoFontSize, '#4CAF50') + drawText(ctx, data.gameDate, dateX, timeInfoY + 8, 300, timeInfoFontSize, '#4CAF50') // 绘制时间(黑色) const timeX = textX + ctx.measureText(data.gameDate).width + 10 * dpr - drawText(ctx, data.gameTime, timeX, timeInfoY + 4, 300, timeInfoFontSize, '#000000') + drawText(ctx, data.gameTime, timeX, timeInfoY + 8, 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') + drawText(ctx, data.venueName, danDaX, locationInfoY + 10, 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绘制命令已发送') + // 绘制完成,调用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) @@ -613,7 +648,10 @@ const ShareCardCanvas: React.FC = ({ console.log('组件挂载,开始绘制分享卡片') // 延迟一下确保Canvas已经渲染 setTimeout(() => { - drawShareCard() + // 在微信小程序中,需要使用Taro.createCanvasContext + const ctx = Taro.createCanvasContext('shareCardCanvas') + console.log('Canvas上下文创建成功:', ctx) + drawShareCard(ctx) }, 500) } }, [data]) // 只依赖data,移除canvasWidth避免无限循环 diff --git a/src/game_pages/list/index.tsx b/src/game_pages/list/index.tsx index 6b77a4a..e195bca 100644 --- a/src/game_pages/list/index.tsx +++ b/src/game_pages/list/index.tsx @@ -14,6 +14,7 @@ import { withAuth } from "@/components"; import { updateUserLocation } from "@/services/userService"; // import ShareCardCanvas from "@/components/ShareCardCanvas"; import { useDictionaryStore } from "@/store/dictionaryStore"; +// import generateShareImage from '@/utils/share' const ListPage = () => { @@ -284,6 +285,27 @@ const ListPage = () => { initDictionaryData() }, []); + // useEffect(() => { + // generateShareImage({ + // userAvatar: "https://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/63f62c80-ac44-4f3b-bb6c-d7f6e8ebf76d.jpg", + // userNickname: "华巴抡卡", + // gameType: "单打", + // skillLevel: "NTRP 2.5 - 3.0", + // gameDate: "6月20日(周五)", + // gameTime: "下午5点 2小时", + // venueName: "因乐驰网球俱乐部(嘉定江桥万达店)", + // venueImages: ["https://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/63f62c80-ac44-4f3b-bb6c-d7f6e8ebf76d.jpg", + // //"https://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/63f62c80-ac44-4f3b-bb6c-d7f6e8ebf76d.jpg" + // ], + // playerImage: "https://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/63f62c80-ac44-4f3b-bb6c-d7f6e8ebf76d.jpg" + // }).then((imagePath) => { + // console.log('===imagePath666', imagePath) + // if (imagePath) { + // setShareImagePath(imagePath) + // } + // }) + // }, []) + return ( <> {/* 自定义导航 */} @@ -352,21 +374,23 @@ const ListPage = () => { {/* 测试分享功能 */} - {/* */} + {/* */} ); diff --git a/src/utils/share.ts b/src/utils/share.ts new file mode 100644 index 0000000..6821cc4 --- /dev/null +++ b/src/utils/share.ts @@ -0,0 +1,706 @@ +import Taro from '@tarojs/taro' + +export interface ShareCardData { + userAvatar: string + userNickname: string + gameType: string + skillLevel: string + gameDate: string + gameTime: string + venueName: string + venueImages: string[] + playerImage?: 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 +} = {} + +// 兼容适配:将微信 CanvasContext 的 set* 方法映射到标准 Canvas 2D 属性 +const polyfillCanvasContext = (ctx: any) => { + if (ctx && typeof ctx === 'object') { + if (typeof ctx.setFillStyle !== 'function') { + ctx.setFillStyle = (v: any) => { ctx.fillStyle = v } + } + if (typeof ctx.setStrokeStyle !== 'function') { + ctx.setStrokeStyle = (v: any) => { ctx.strokeStyle = v } + } + if (typeof ctx.setLineWidth !== 'function') { + ctx.setLineWidth = (v: number) => { ctx.lineWidth = v } + } + if (typeof ctx.setLineCap !== 'function') { + ctx.setLineCap = (v: any) => { ctx.lineCap = v } + } + if (typeof ctx.setLineJoin !== 'function') { + ctx.setLineJoin = (v: any) => { ctx.lineJoin = v } + } + if (typeof ctx.setTextAlign !== 'function') { + ctx.setTextAlign = (v: any) => { ctx.textAlign = v } + } + if (typeof ctx.setTextBaseline !== 'function') { + ctx.setTextBaseline = (v: any) => { ctx.textBaseline = v } + } + if (typeof ctx.setFontSize !== 'function') { + ctx.setFontSize = (size: number) => { ctx.font = `${size}px sans-serif` } + } + if (typeof ctx.setFont !== 'function') { + ctx.setFont = (fontStr: string) => { ctx.font = fontStr } + } + } +} + +// 绘制加粗文字(单行) +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 => { +// return new Promise((resolve, reject) => { +// Taro.getImageInfo({ +// src: src, +// success: (res) => resolve(res.path), +// fail: reject +// }) +// }) +// } + +// 工具函数 - OffscreenCanvas 下加载图片(从 runtime.offscreen 读取) +const loadImage = (src: string): Promise => { + return new Promise((resolve, reject) => { + Taro.getImageInfo({ + src, + success: (res) => { + try { + // @ts-ignore - createImage 为小程序 OffscreenCanvas 能力 + 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 = res.path + } catch (e) { + reject(e) + } + }, + 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(); +} + +// 绘制右上角场地图片 +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.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 drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Promise => { + return new Promise(async (resolve, reject) => { + console.log('开始绘制分享卡片...') + + try { + // 先对 2D 上下文做一次 API 兼容处理 + polyfillCanvasContext(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) + + // 绘制球员图片(右上角)已完成 + 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('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 + 8, 300, timeInfoFontSize, '#4CAF50') + + // 绘制时间(黑色) + const timeX = textX + ctx.measureText(data.gameDate).width + 10 * dpr + drawText(ctx, data.gameTime, timeX, timeInfoY + 8, 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 + 10, 600, 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.error('绘制分享卡片失败:', error) + Taro.showToast({ + title: '生成分享卡片失败', + icon: 'none' + }) + reject(error) + } + }) +} + +let isDrawing = false +export async function generateShareImage(data: ShareCardData): Promise { + 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 + +