9 Commits

Author SHA1 Message Date
张成
10dab7ee84 1 2026-04-22 11:10:28 +08:00
张成
3212503a64 添加场地类型 2026-04-22 10:47:11 +08:00
张成
ce1f5485fe 1 2026-04-22 10:30:31 +08:00
张成
33ab46aec0 1 2026-04-22 10:21:53 +08:00
张成
b5081afb2c 1 2026-04-22 10:19:01 +08:00
张成
5a753e7822 Merge branch 'master' of https://git.bimwe.com/bimwe/mini-programs 2026-04-20 15:57:21 +08:00
张成
66c5ea6284 1 2026-04-20 15:57:03 +08:00
8090d679b4 Merge remote-tracking branch 'refs/remotes/origin/master' 2026-04-09 14:58:43 +08:00
4965d6c40e fix: 分享卡片走兜底图优化 2026-04-09 14:58:02 +08:00
16 changed files with 22984 additions and 2427 deletions

20688
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -70,11 +70,11 @@
"eslint-plugin-react": "^7.8.2", "eslint-plugin-react": "^7.8.2",
"eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-react-hooks": "^4.2.0",
"postcss": "^8.4.18", "postcss": "^8.4.18",
"react-refresh": "^0.11.0", "react-refresh": "^0.14.0",
"stylelint": "^14.4.0", "stylelint": "^14.4.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tsconfig-paths-webpack-plugin": "^4.0.1", "tsconfig-paths-webpack-plugin": "^4.0.1",
"typescript": "^5.1.0", "typescript": "^5.1.0",
"webpack": "5.78.0" "webpack": "5.91.0"
} }
} }

View File

@@ -2,7 +2,7 @@
"miniprogramRoot": "dist/", "miniprogramRoot": "dist/",
"projectname": "playBallTogether", "projectname": "playBallTogether",
"description": "playBallTogether", "description": "playBallTogether",
"appid": "wx815b533167eb7b53", "appid": "wx915ecf6c01bea4ec",
"setting": { "setting": {
"urlCheck": true, "urlCheck": true,
"es6": true, "es6": true,
@@ -40,10 +40,21 @@
"minifyWXML": true, "minifyWXML": true,
"swc": true, "swc": true,
"disableSWC": false, "disableSWC": false,
"ignoreUploadUnusedFiles": true "ignoreUploadUnusedFiles": true,
"compileWorklet": false,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false
}, },
"compileType": "miniprogram", "compileType": "miniprogram",
"simulatorType": "wechat", "simulatorType": "wechat",
"simulatorPluginLibVersion": {}, "simulatorPluginLibVersion": {},
"condition": {} "condition": {},
"libVersion": "3.9.0",
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
} }

View File

@@ -1,6 +1,6 @@
{ {
"libVersion": "3.9.0", "libVersion": "3.15.1",
"projectname": "playBallTogether", "projectname": "mini-programs",
"condition": {}, "condition": {},
"setting": { "setting": {
"urlCheck": false, "urlCheck": false,

View File

@@ -45,10 +45,9 @@ const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
// 获取屏幕宽度如果没有传入width则使用屏幕宽度 // 获取屏幕宽度如果没有传入width则使用屏幕宽度
const windowWidth = Taro.getSystemInfoSync().windowWidth const windowWidth = Taro.getSystemInfoSync().windowWidth
// 获取 DPR - 使用系统像素比确保高清显示 // 获取 DPR:统一与 share.ts 策略,限制上限避免内存过高
// const systemDpr = Taro.getSystemInfoSync().pixelRatio const systemDpr = (Taro as any).getSystemInfoSync?.().pixelRatio || 1
const dpr = 1 const dpr = Math.min(Math.max(systemDpr, 1), 2)
// Math.min(systemDpr, 3) // 限制最大dpr为3避免过度放大
// 2. 计算缩放比例(设备宽度 / 设计稿宽度) // 2. 计算缩放比例(设备宽度 / 设计稿宽度)
const scale = windowWidth / designWidth const scale = windowWidth / designWidth
@@ -105,7 +104,7 @@ const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
// 绘制边框 // 绘制边框
ctx.strokeStyle = borderColor ctx.strokeStyle = borderColor
ctx.lineWidth = 1 * dpr ctx.lineWidth = 1
ctx.stroke() ctx.stroke()
// 绘制文字 // 绘制文字
@@ -145,14 +144,14 @@ const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
const drawSVGPathToCanvas = (ctx: any) => { const drawSVGPathToCanvas = (ctx: any) => {
// 设置绘制样式 // 设置绘制样式
ctx.strokeStyle = '#00E5AD'; ctx.strokeStyle = '#00E5AD';
ctx.lineWidth = scale * 3 * dpr; ctx.lineWidth = scale * 3;
ctx.lineCap = 'round'; ctx.lineCap = 'round';
ctx.lineJoin = 'round'; ctx.lineJoin = 'round';
ctx.save(); ctx.save();
// 移动到指定位置并缩放 // 移动到指定位置并缩放
ctx.translate(scale * 200 * dpr, scale * 90 * dpr); ctx.translate(scale * 200, scale * 90);
const scaleValue = 0.8 const scaleValue = 0.8
ctx.scale(scaleValue, scaleValue); ctx.scale(scaleValue, scaleValue);
@@ -382,43 +381,39 @@ const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
setIsDrawing(true) setIsDrawing(true)
try { try {
// 设置Canvas的实际尺寸使用dpr确保高清显示 // 统一坐标系:先用物理像素清空,再缩放到逻辑坐标绘制
const canvasWidthPx = canvasWidth * dpr const canvasWidthPx = canvasWidth * dpr
const canvasHeightPx = canvasHeight * dpr const canvasHeightPx = canvasHeight * dpr
if (typeof ctx.setTransform === 'function') {
// 清空画布 ctx.setTransform(1, 0, 0, 1, 0, 0)
}
ctx.clearRect(0, 0, canvasWidthPx, canvasHeightPx) ctx.clearRect(0, 0, canvasWidthPx, canvasHeightPx)
ctx.save()
ctx.scale(dpr, dpr)
console.log('画布已清空') 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) const gradient = ctx.createLinearGradient(0, 0, 0, canvasHeight)
gradient.addColorStop(0, '#BFFFEF') gradient.addColorStop(0, '#BFFFEF')
gradient.addColorStop(1, '#F2FFFC') gradient.addColorStop(1, '#F2FFFC')
ctx.fillStyle = gradient ctx.fillStyle = gradient
ctx.fillRect(0, 0, canvasWidthPx, canvasHeightPx) ctx.fillRect(0, 0, canvasWidth, canvasHeight)
console.log('背景绘制完成') console.log('背景绘制完成')
// 绘制背景条纹 已完成 // 绘制背景条纹 已完成
ctx.strokeStyle = 'rgba(0, 0, 0, 0.03)' ctx.strokeStyle = 'rgba(0, 0, 0, 0.03)'
ctx.lineWidth = 2 ctx.lineWidth = 2
for (let i = 0; i < canvasWidthPx; i += 4) { for (let i = 0; i < canvasWidth; i += 4) {
ctx.beginPath() ctx.beginPath()
ctx.moveTo(i, 0) ctx.moveTo(i, 0)
ctx.lineTo(i, canvasHeightPx) ctx.lineTo(i, canvasHeight)
ctx.stroke() ctx.stroke()
} }
// 绘制用户头像(左上角) 已完成 // 绘制用户头像(左上角) 已完成
const avatarSize = scale * 32 * dpr // 32px * dpr const avatarSize = scale * 32
const avatarX = scale * 35 * dpr // 距离左侧35px const avatarX = scale * 35
const avatarY = scale * 35 * dpr // 距离顶部35px const avatarY = scale * 35
try { try {
const avatarPath = await loadImage(data.userAvatar, canvasNode) const avatarPath = await loadImage(data.userAvatar, canvasNode)
@@ -438,23 +433,23 @@ const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
} }
// 绘制用户昵称 已完成 // 绘制用户昵称 已完成
const nicknameX = avatarX + avatarSize + 8 * dpr // 距离头像8px const nicknameX = avatarX + avatarSize + scale * 8
const nicknameY = avatarY + (avatarSize - 18 * dpr) / 2 // 与头像水平居中对齐 const nicknameY = avatarY + (avatarSize - scale * 18) / 2
const nicknameFontSize = scale * 18 * dpr const nicknameFontSize = scale * 18
// drawText(ctx, data.userNickname, nicknameX, nicknameY, 200 * dpr, nicknameFontSize, '#000000', true, 'Noto Sans SC') // 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') drawBoldText(ctx, data.userNickname, nicknameX, nicknameY, nicknameFontSize, '#000000', 'Noto Sans SC', '900')
// 绘制"邀你加入球局"文案 // 绘制"邀你加入球局"文案
const inviteX = scale * 35 * dpr // 距离画布左侧35px const inviteX = scale * 35
const inviteY = scale * 100 * dpr // 距离画布顶部79px const inviteY = scale * 100
const inviteFontSize = scale * 44 * dpr const inviteFontSize = scale * 44
// 绘制"邀你加入" // 绘制"邀你加入"
drawBoldText(ctx, '邀你加入', inviteX, inviteY, inviteFontSize, '#000000', 'Noto Sans SC', '900') drawBoldText(ctx, '邀你加入', inviteX, inviteY, inviteFontSize, '#000000', 'Noto Sans SC', '900')
// 绘制"球局"特殊样式 // 绘制"球局"特殊样式
const qiuJuX = inviteX + ctx.measureText('邀你加入').width + 4 * dpr const qiuJuX = inviteX + ctx.measureText('邀你加入').width + scale * 4
const qiuJuFontSize = scale * 44 * dpr const qiuJuFontSize = scale * 44
drawBoldText(ctx, '球局', qiuJuX, inviteY, qiuJuFontSize, '#00E5AD', 'Noto Sans SC', '900') drawBoldText(ctx, '球局', qiuJuX, inviteY, qiuJuFontSize, '#00E5AD', 'Noto Sans SC', '900')
// 测试绘制网络图片 // 测试绘制网络图片
@@ -462,12 +457,12 @@ const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
// 绘制球员图片(右上角)已完成 // 绘制球员图片(右上角)已完成
let venueBaseConfig = { let venueBaseConfig = {
venueImgX: scale * 340 * dpr, venueImgX: scale * 340,
venueImgY: scale * 35 * dpr, venueImgY: scale * 35,
rotation: scale * -8, // 旋转-8度 rotation: scale * -8, // 旋转-8度
venueImgSize: scale * 124 * dpr, venueImgSize: scale * 124,
borderRadius: scale * 24 * dpr, borderRadius: scale * 24,
padding: scale * 4 * dpr, padding: scale * 4,
venueImage: data.venueImages?.[0] venueImage: data.venueImages?.[0]
} }
@@ -476,8 +471,8 @@ const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
const venueBackConfig = { const venueBackConfig = {
...venueBaseConfig, ...venueBaseConfig,
venueImage: data.venueImages?.[1], venueImage: data.venueImages?.[1],
venueImgX: scale * 400 * dpr, venueImgX: scale * 400,
venueImgY: scale * 35 * dpr, venueImgY: scale * 35,
rotation: scale * -10, // 旋转-10度 rotation: scale * -10, // 旋转-10度
} }
await drawVenueImages(ctx, venueBackConfig, canvasNode) await drawVenueImages(ctx, venueBackConfig, canvasNode)
@@ -512,11 +507,11 @@ const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
// 绘制"单打"标签 // 绘制"单打"标签
const danDaX = scale * 100 const danDaX = scale * 100
const danDaY = scale * 196 const danDaY = scale * 196
const danDaHeight = scale * 40 * dpr const danDaHeight = scale * 40
const danDaRadius = scale * 20 * dpr const danDaRadius = scale * 20
const danDaFontSize = scale * 22 * dpr const danDaFontSize = scale * 22
// 根据内容动态计算标签宽度(左右内边距) // 根据内容动态计算标签宽度(左右内边距)
const danDaPaddingX = scale * 16 * dpr const danDaPaddingX = scale * 16
setFont2D(ctx, danDaFontSize) setFont2D(ctx, danDaFontSize)
const danDaTextWidth = ctx.measureText(data.gameType).width const danDaTextWidth = ctx.measureText(data.gameType).width
const danDaWidth = danDaTextWidth + danDaPaddingX * 2 const danDaWidth = danDaTextWidth + danDaPaddingX * 2
@@ -527,11 +522,11 @@ const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
const labelGap = scale * 16 // 两个标签之间的间距(不乘 dpr保持视觉间距 const labelGap = scale * 16 // 两个标签之间的间距(不乘 dpr保持视觉间距
const skillX = danDaX + danDaWidth + labelGap const skillX = danDaX + danDaWidth + labelGap
const skillY = scale * 196 const skillY = scale * 196
const skillHeight = scale * 40 * dpr const skillHeight = scale * 40
const skillRadius = scale * 20 * dpr const skillRadius = scale * 20
const skillFontSize = scale * 22 * dpr const skillFontSize = scale * 22
// 根据内容动态计算技能标签宽度 // 根据内容动态计算技能标签宽度
const skillPaddingX = scale * 20 * dpr const skillPaddingX = scale * 20
setFont2D(ctx, skillFontSize) setFont2D(ctx, skillFontSize)
const skillTextWidth = ctx.measureText(data.skillLevel).width const skillTextWidth = ctx.measureText(data.skillLevel).width
const skillWidth = skillTextWidth + skillPaddingX * 2 const skillWidth = skillTextWidth + skillPaddingX * 2
@@ -541,7 +536,7 @@ const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
// 绘制日期时间 // 绘制日期时间
const dateX = danDaX const dateX = danDaX
const timeInfoY = infoStartY + infoSpacing const timeInfoY = infoStartY + infoSpacing
const timeInfoFontSize = scale * 24 * dpr const timeInfoFontSize = scale * 24
const calendarPath = await loadImage(`${OSS_BASE}/front/ball/images/ea792a5d-b105-4c95-bfc4-8af558f2b33b.jpg`, canvasNode) const calendarPath = await loadImage(`${OSS_BASE}/front/ball/images/ea792a5d-b105-4c95-bfc4-8af558f2b33b.jpg`, canvasNode)
ctx.drawImage(calendarPath, iconX, timeInfoY, iconSize, iconSize) ctx.drawImage(calendarPath, iconX, timeInfoY, iconSize, iconSize)
@@ -549,17 +544,18 @@ const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
drawBoldText(ctx, data.gameDate, dateX, timeInfoY + 8, timeInfoFontSize, '#00E5AD') drawBoldText(ctx, data.gameDate, dateX, timeInfoY + 8, timeInfoFontSize, '#00E5AD')
// 绘制时间(黑色) // 绘制时间(黑色)
const timeX = textX + ctx.measureText(data.gameDate).width + 10 * dpr const timeX = textX + ctx.measureText(data.gameDate).width + scale * 10
// drawText(ctx, data.gameTime, timeX, timeInfoY + 8, 300, timeInfoFontSize, '#000000') // drawText(ctx, data.gameTime, timeX, timeInfoY + 8, 300, timeInfoFontSize, '#000000')
drawBoldText(ctx, data.gameTime, timeX, timeInfoY + 8, timeInfoFontSize, '#000000') drawBoldText(ctx, data.gameTime, timeX, timeInfoY + 8, timeInfoFontSize, '#000000')
// 绘制地点 // 绘制地点
const locationInfoY = infoStartY + infoSpacing * 2 const locationInfoY = infoStartY + infoSpacing * 2
const locationFontSize = scale * 22 * dpr const locationFontSize = scale * 22
const locationPath = await loadImage(`${OSS_BASE}/front/ball/images/adc9a167-2ea9-4e3b-b963-6a894a1fd91b.jpg`, canvasNode) const locationPath = await loadImage(`${OSS_BASE}/front/ball/images/adc9a167-2ea9-4e3b-b963-6a894a1fd91b.jpg`, canvasNode)
ctx.drawImage(locationPath, iconX, locationInfoY, iconSize, iconSize) ctx.drawImage(locationPath, iconX, locationInfoY, iconSize, iconSize)
drawBoldText(ctx, data.venueName, danDaX, locationInfoY + 10, locationFontSize, '#000000') drawBoldText(ctx, data.venueName, danDaX, locationInfoY + 10, locationFontSize, '#000000')
ctx.restore()
// 绘制完成调用draw方法 // 绘制完成调用draw方法
console.log('开始调用ctx.draw()') console.log('开始调用ctx.draw()')
const doExport = () => { const doExport = () => {
@@ -649,12 +645,9 @@ const ShareCardCanvas: React.FC<ShareCardCanvasProps> = ({
if (data && data.node) { if (data && data.node) {
const canvas = data.node const canvas = data.node
const context = canvas.getContext('2d') const context = canvas.getContext('2d')
// DPR 缩放,提升清晰度(当前 dpr = 1 也可正常显示) // 仅设置物理像素尺寸,绘制阶段统一在 drawShareCard 中做 ctx.scale(dpr, dpr)
const sys = (Taro as any).getSystemInfoSync?.() || {} canvas.width = canvasWidth * dpr
const ratio = sys.pixelRatio || 1 canvas.height = canvasHeight * dpr
canvas.width = canvasWidth * ratio
canvas.height = canvasHeight * ratio
context.scale(ratio, ratio)
setCanvasNode(canvas) setCanvasNode(canvas)
setCtx2d(context) setCtx2d(context)
setIs2dCtx(true) setIs2dCtx(true)

View File

@@ -15,7 +15,7 @@ export interface UploadFromWxProps {
async function convert_to_jpg_and_compress( async function convert_to_jpg_and_compress(
src: string, src: string,
{ width, height } { width, height, quality }: { width: number; height: number; quality: number }
): Promise<string> { ): Promise<string> {
const canvas = Taro.createOffscreenCanvas({ type: "2d", width, height }); const canvas = Taro.createOffscreenCanvas({ type: "2d", width, height });
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
@@ -33,22 +33,54 @@ async function convert_to_jpg_and_compress(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
Taro.canvasToTempFilePath({ Taro.canvasToTempFilePath({
canvas: canvas as unknown as Taro.Canvas, canvas: canvas as unknown as Taro.Canvas,
fileType: "png", fileType: "jpg",
quality: 0.7, quality,
success: (res) => resolve(res.tempFilePath), success: (res) => resolve(res.tempFilePath),
fail: reject, fail: reject,
}); });
}); });
} }
function getFileSize(path: string): Promise<number> {
const fs = (Taro as any).getFileSystemManager();
return new Promise((resolve, reject) => {
fs.stat({
path,
success: (res) => resolve(res.stats.size),
fail: reject,
});
});
}
async function compressImage(files) { async function compressImage(files) {
const res: string[] = []; const res: string[] = [];
const qualityList = [0.9, 0.85, 0.8, 0.75, 0.7, 0.65, 0.6];
for (const file of files) { for (const file of files) {
const compressed_image = await convert_to_jpg_and_compress(file.path, { // 小图且体积已在目标范围内时,直接上传原图,避免二次压缩变糊
width: file.width, if (
height: file.height, file.size <= TARGET_IMAGE_SIZE &&
}); file.width <= IMAGE_MAX_SIZE.width &&
res.push(compressed_image); file.height <= IMAGE_MAX_SIZE.height
) {
res.push(file.path);
continue;
}
let bestImage = file.path;
for (const quality of qualityList) {
const compressedImage = await convert_to_jpg_and_compress(file.path, {
width: file.width,
height: file.height,
quality,
});
const compressedSize = await getFileSize(compressedImage);
bestImage = compressedImage;
if (compressedSize <= TARGET_IMAGE_SIZE) {
break;
}
}
res.push(bestImage);
} }
return res; return res;
} }
@@ -58,6 +90,8 @@ const IMAGE_MAX_SIZE = {
width: 1080, width: 1080,
height: 720, height: 720,
}; };
// 压缩目标体积(约 300KB
const TARGET_IMAGE_SIZE = 800 * 1024;
// 标准长宽比,判断标准 // 标准长宽比,判断标准
const STANDARD_ASPECT_RATIO = IMAGE_MAX_SIZE.width / IMAGE_MAX_SIZE.height; const STANDARD_ASPECT_RATIO = IMAGE_MAX_SIZE.width / IMAGE_MAX_SIZE.height;

View File

@@ -138,11 +138,8 @@ export default forwardRef(({ id, from, detail, userInfo }, ref) => {
} }
} }
useShareAppMessage(async (res) => { useShareAppMessage(async () => {
await changeMessageType(); const url = shareImageUrl || (await generateShareImageUrl());
await ensureUserInfo();
const url = await generateShareImageUrl();
// console.log(res, "res");
return { return {
title: detail.title, title: detail.title,
imageUrl: url, imageUrl: url,

View File

@@ -12,9 +12,11 @@ export default function VenueInfo(props) {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { const {
venue_description, venue_description,
court_type,
venue_description_tag = [], venue_description_tag = [],
venue_image_list = [], venue_image_list = [],
} = detail; } = detail;
const venue_tags = [court_type, ...venue_description_tag].filter(Boolean);
// 统一为 URL 数组:接口可能是 { id, url }[] 或 string[] // 统一为 URL 数组:接口可能是 { id, url }[] 或 string[]
const screenshot_urls = (venue_image_list || []).map((item) => const screenshot_urls = (venue_image_list || []).map((item) =>
@@ -61,7 +63,7 @@ export default function VenueInfo(props) {
<View className={styles["venue-detail-content"]}> <View className={styles["venue-detail-content"]}>
{/* venue detail tags */} {/* venue detail tags */}
<View className={styles["venue-detail-content-tags"]}> <View className={styles["venue-detail-content-tags"]}>
{insertDotInTags(venue_description_tag).map((tag, index) => ( {insertDotInTags(venue_tags).map((tag, index) => (
<View <View
key={index} key={index}
className={styles["venue-detail-content-tags-tag"]} className={styles["venue-detail-content-tags-tag"]}

View File

@@ -23,7 +23,6 @@ import {
isPhoneNumber, isPhoneNumber,
genGameLength, genGameLength,
} from "@/utils"; } from "@/utils";
import { getStorage, setStorage } from "@/store/storage";
import { useGlobalStore } from "@/store/global"; import { useGlobalStore } from "@/store/global";
import { useOrder } from "@/store/orderStore"; import { useOrder } from "@/store/orderStore";
import detailService, { GameData } from "@/services/detailService"; import detailService, { GameData } from "@/services/detailService";
@@ -692,35 +691,26 @@ const OrderCheck = () => {
} }
setPaying(true); setPaying(true);
let payment_params = {}; let payment_params: any = {};
try { try {
payment_params = await getPaymentParams(); payment_params = await getPaymentParams();
if (!id) {
setStorage("backFlag", "1");
Taro.redirectTo({
url: `/order_pages/orderDetail/index?id=${payment_params.order_id}`,
});
}
await payOrder(payment_params); await payOrder(payment_params);
Taro.showToast({ Taro.showToast({
title: "支付成功", title: "支付成功",
icon: "success", icon: "success",
}); });
const backFlag = getStorage("backFlag"); // 支付成功后再跳转,避免部分机型(如华为)在页面切换中拉起支付导致卡死
if (backFlag === "1") { if (!id && payment_params?.order_id) {
setStorage("backFlag", "0"); Taro.redirectTo({
Taro.navigateBack(); url: `/order_pages/orderDetail/index?id=${payment_params.order_id}`,
});
} }
// Taro.navigateBack({
// delta: 1,
// });
} catch (error) { } catch (error) {
Taro.showToast({ Taro.showToast({
title: error.message, title: error.message,
icon: "none", icon: "none",
}); });
} finally { } finally {
setStorage("backFlag", "0");
init(); init();
setPaying(false); setPaying(false);
} }

View File

@@ -30,6 +30,18 @@ import DownloadIcon from "@/static/ntrp/ntrp_download.svg";
import ReTestIcon from "@/static/ntrp/ntrp_re-action.svg"; import ReTestIcon from "@/static/ntrp/ntrp_re-action.svg";
import styles from "./index.module.scss"; import styles from "./index.module.scss";
/** 微信小程序码 scene 最长 32 字符 */
const WX_SCENE_MAX_LEN = 32;
/** 分享图/太阳码r={record_id},落地后在 NtrpEvaluate 归一化为 stage=result&id=&from_share=1 */
function buildNtrpShareScene(recordId: string | number): string {
const scene = `r=${recordId}`;
if (scene.length > WX_SCENE_MAX_LEN) {
console.warn("[ntrp-evaluate] share scene exceeds WeChat limit:", scene);
}
return scene;
}
const sourceTypeToTextMap = new Map([ const sourceTypeToTextMap = new Map([
[EvaluateScene.detail, "继续加入球局"], [EvaluateScene.detail, "继续加入球局"],
[EvaluateScene.publish, "继续发布球局"], [EvaluateScene.publish, "继续发布球局"],
@@ -485,7 +497,8 @@ function Test() {
function Result() { function Result() {
const { params } = useRouter(); const { params } = useRouter();
const { id } = params; const { id, from_share } = params;
const fromShare = from_share === "1" || from_share === "true";
const userInfo = useUserInfo(); const userInfo = useUserInfo();
const { fetchUserInfo, updateUserInfo } = useUserActions(); const { fetchUserInfo, updateUserInfo } = useUserActions();
const { type, next, clear } = useEvaluate(); const { type, next, clear } = useEvaluate();
@@ -514,13 +527,14 @@ function Result() {
init(); init();
}, [id]); }, [id]);
// 获取二维码 - 调用接口生成分享二维码 // 获取二维码 - 太阳码携带当次 record_id扫码进入分享结果页
async function fetchQRCode() { async function fetchQRCode() {
try { try {
// 调用接口生成二维码,分享当前页面 const recordId = id != null && id !== "" ? String(id) : "";
if (!recordId) return;
const qrCodeUrlRes = await DetailService.getQrCodeUrl({ const qrCodeUrlRes = await DetailService.getQrCodeUrl({
page: "other_pages/ntrp-evaluate/index", page: "other_pages/ntrp-evaluate/index",
scene: `stage=${StageType.INTRO}`, scene: buildNtrpShareScene(recordId),
}); });
setQrCodeUrl(qrCodeUrlRes.data.ossPath); setQrCodeUrl(qrCodeUrlRes.data.ossPath);
// if (qrCodeUrlRes.code === 0 && qrCodeUrlRes.data?.qr_code_base64) { // if (qrCodeUrlRes.code === 0 && qrCodeUrlRes.data?.qr_code_base64) {
@@ -536,29 +550,57 @@ function Result() {
} }
async function getResultById() { async function getResultById() {
const res = await evaluateService.getTestResult({ record_id: Number(id) }); const recordId = Number(id);
if (res.code === 0) { if (!id || Number.isNaN(recordId) || recordId <= 0) {
setResult(res.data); Taro.showToast({ title: "链接无效", icon: "none" });
Taro.redirectTo({
const sortOrder = res.data.sort || []; url: `/other_pages/ntrp-evaluate/index?stage=${StageType.INTRO}`,
const abilities = res.data.radar_data.abilities; });
const sortedKeys = sortOrder.filter((k) => k in abilities); return;
const remainingKeys = Object.keys(abilities).filter( }
(k) => !sortOrder.includes(k), try {
const res = await evaluateService.getTestResult(
{
record_id: recordId,
...(fromShare ? { from_share: true } : {}),
},
fromShare ? { showToast: false } : undefined,
); );
const allKeys = [...sortedKeys, ...remainingKeys]; if (res.code === 0) {
let radarData: [string, number][] = allKeys.map((key) => [ setResult(res.data);
key,
Math.min( const sortOrder = res.data.sort || [];
100, const abilities = res.data.radar_data.abilities;
Math.floor( const sortedKeys = sortOrder.filter((k) => k in abilities);
(abilities[key].current_score / abilities[key].max_score) * 100, const remainingKeys = Object.keys(abilities).filter(
(k) => !sortOrder.includes(k),
);
const allKeys = [...sortedKeys, ...remainingKeys];
const nextRadarData: [string, number][] = allKeys.map((key) => [
key,
Math.min(
100,
Math.floor(
(abilities[key].current_score / abilities[key].max_score) * 100,
),
), ),
), ]);
]); setRadarData(nextRadarData);
// 直接使用接口 sort 顺序,不经过 adjustRadarLabels 重新排序 if (!fromShare) {
setRadarData(radarData); updateUserLevel(res.data.record_id, res.data.ntrp_level);
updateUserLevel(res.data.record_id, res.data.ntrp_level); }
}
} catch (e: any) {
if (fromShare) {
Taro.showToast({
title: e?.message || "分享已失效或无权查看",
icon: "none",
});
Taro.redirectTo({
url: `/other_pages/ntrp-evaluate/index?stage=${StageType.INTRO}`,
});
}
// 本人结果页失败时 httpService 已 toast此处不再重复
} }
} }
@@ -615,10 +657,11 @@ function Result() {
// 确保二维码已获取,如果没有则重新获取 // 确保二维码已获取,如果没有则重新获取
let finalQrCodeUrl = qrCodeUrl; let finalQrCodeUrl = qrCodeUrl;
if (!finalQrCodeUrl) { if (!finalQrCodeUrl) {
// 直接调用接口获取二维码 const recordId = id != null && id !== "" ? String(id) : "";
if (!recordId) throw new Error("缺少测评记录");
const qrCodeUrlRes = await DetailService.getQrCodeUrl({ const qrCodeUrlRes = await DetailService.getQrCodeUrl({
page: "other_pages/ntrp-evaluate/index", page: "other_pages/ntrp-evaluate/index",
scene: `stage=${StageType.INTRO}`, scene: buildNtrpShareScene(recordId),
}); });
finalQrCodeUrl = qrCodeUrlRes.data.ossPath; finalQrCodeUrl = qrCodeUrlRes.data.ossPath;
// if (qrCodeUrlRes.code === 0 && qrCodeUrlRes.data?.qr_code_base64) { // if (qrCodeUrlRes.code === 0 && qrCodeUrlRes.data?.qr_code_base64) {
@@ -628,16 +671,20 @@ function Result() {
// } // }
} }
// 使用 RadarV2 的 generateFullImage 方法生成完整图片 const posterNickname = fromShare
const userNickname = (userInfo as any)?.nickname; ? result?.sharer_nickname || "好友"
const titleText = userNickname : (userInfo as any)?.nickname;
? `${userNickname}的 NTRP 测试结果为` const titleText = posterNickname
? `${posterNickname}的 NTRP 测试结果为`
: "你的 NTRP 测试结果为"; : "你的 NTRP 测试结果为";
const posterAvatar = fromShare
? result?.sharer_avatar_url || (userInfo as any)?.avatar_url
: (userInfo as any)?.avatar_url;
const imageUrl = await radarV2Ref.current?.generateFullImage({ const imageUrl = await radarV2Ref.current?.generateFullImage({
title: titleText, title: titleText,
ntrpLevel: result?.ntrp_level, ntrpLevel: result?.ntrp_level,
levelDescription: result?.level_description, levelDescription: result?.level_description,
avatarUrl: (userInfo as any)?.avatar_url, avatarUrl: posterAvatar,
qrCodeUrl: finalQrCodeUrl, qrCodeUrl: finalQrCodeUrl,
bottomText: "长按识别二维码,快来加入,有你就有场!", bottomText: "长按识别二维码,快来加入,有你就有场!",
width: 750, // 设计稿宽度 width: 750, // 设计稿宽度
@@ -651,8 +698,7 @@ function Result() {
} }
async function handleSaveImage() { async function handleSaveImage() {
console.log(userInfo); if (!fromShare && !userInfo?.phone) {
if (!userInfo?.phone) {
handleAuth(); handleAuth();
return; return;
} }
@@ -691,12 +737,21 @@ function Result() {
}); });
} }
useShareAppMessage(async (res) => { useShareAppMessage(async () => {
console.log("res", result); const rid = result?.record_id ?? Number(id);
const sharePath =
rid && !Number.isNaN(Number(rid))
? `/other_pages/ntrp-evaluate/index?stage=${StageType.RESULT}&id=${rid}&from_share=1`
: `/other_pages/ntrp-evaluate/index?stage=${StageType.INTRO}`;
const shareNickname = fromShare
? result?.sharer_nickname || "好友"
: (userInfo as any)?.nickname || "好友";
return { return {
title: "来测一测你的NTRP等级吧", title: result?.ntrp_level
? `来看看 ${shareNickname} 的测评结果`
: "来测一测你的NTRP等级吧",
imageUrl: result?.level_img || undefined, imageUrl: result?.level_img || undefined,
path: `/other_pages/ntrp-evaluate/index?stage=${StageType.INTRO}`, path: sharePath,
}; };
}); });
@@ -714,6 +769,18 @@ function Result() {
function handleGo() {} function handleGo() {}
const cardTitleText = fromShare
? result?.sharer_nickname
? `${result.sharer_nickname}的 NTRP 测试结果为`
: "好友分享的 NTRP 测试结果为"
: (userInfo as any)?.nickname
? `${(userInfo as any).nickname}的 NTRP 测试结果为`
: "你的 NTRP 测试结果为";
const cardAvatarUrl = fromShare
? result?.sharer_avatar_url || ""
: userInfo?.avatar_url || "";
return ( return (
<View className={styles.resultContainer}> <View className={styles.resultContainer}>
<CommonGuideBar /> <CommonGuideBar />
@@ -727,7 +794,7 @@ function Result() {
<View className={styles.avatar}> <View className={styles.avatar}>
<Image <Image
className={styles.avatarUrl} className={styles.avatarUrl}
src={userInfo?.avatar_url || ""} src={cardAvatarUrl}
mode="aspectFill" mode="aspectFill"
/> />
</View> </View>
@@ -742,11 +809,7 @@ function Result() {
</View> </View>
<View className={styles.desc}> <View className={styles.desc}>
<View className={styles.tip}> <View className={styles.tip}>
<Text> <Text>{cardTitleText}</Text>
{(userInfo as any)?.nickname
? `${(userInfo as any).nickname}的 NTRP 测试结果为`
: "你的 NTRP 测试结果为"}
</Text>
</View> </View>
<View className={styles.levelWrap}> <View className={styles.levelWrap}>
<Text>NTRP</Text> <Text>NTRP</Text>
@@ -767,7 +830,7 @@ function Result() {
<Text></Text> <Text></Text>
</View> </View>
</View> </View>
{userInfo?.phone ? ( {userInfo?.phone && !fromShare ? (
<View className={styles.updateTip}> <View className={styles.updateTip}>
<Text> <Text>
NTRP {" "} NTRP {" "}
@@ -775,11 +838,17 @@ function Result() {
</Text> </Text>
<Text className={styles.grayTip}>()</Text> <Text className={styles.grayTip}>()</Text>
</View> </View>
) : ( ) : !userInfo?.phone ? (
<View className={styles.updateTip}> <View className={styles.updateTip}>
<Text></Text> <Text></Text>
</View> </View>
)} ) : fromShare ? (
<View className={styles.updateTip}>
<Text className={styles.grayTip}>
NTRP
</Text>
</View>
) : null}
<View className={styles.actions}> <View className={styles.actions}>
<View className={styles.viewGame} onClick={handleGoon}> <View className={styles.viewGame} onClick={handleGoon}>
<Button className={styles.viewGameBtn}> <Button className={styles.viewGameBtn}>
@@ -821,14 +890,10 @@ function Result() {
<RadarChartV2 <RadarChartV2
ref={radarV2Ref} ref={radarV2Ref}
data={radarData} data={radarData}
title={ title={cardTitleText}
(userInfo as any)?.nickname
? `${(userInfo as any).nickname}的 NTRP 测试结果为`
: "你的 NTRP 测试结果为"
}
ntrpLevel={result?.ntrp_level} ntrpLevel={result?.ntrp_level}
levelDescription={result?.level_description} levelDescription={result?.level_description}
avatarUrl={(userInfo as any)?.avatar_url} avatarUrl={cardAvatarUrl}
qrCodeUrl={qrCodeUrl} qrCodeUrl={qrCodeUrl}
bottomText="长按识别二维码,快来加入,有你就有场!" bottomText="长按识别二维码,快来加入,有你就有场!"
/> />
@@ -844,9 +909,21 @@ const ComponentsMap = {
}; };
function NtrpEvaluate() { function NtrpEvaluate() {
// const { updateUserInfo } = useUserActions();
const { params } = useRouter(); const { params } = useRouter();
// const { redirect } = params;
// 太阳码 scene 仅支持 32 字符,用 r=id 落地后归一化为结果页 + from_share
useEffect(() => {
const r = params.r;
if (r && !params.stage) {
Taro.redirectTo({
url: `/other_pages/ntrp-evaluate/index?stage=${StageType.RESULT}&id=${encodeURIComponent(String(r))}&from_share=1`,
});
}
}, [params.r, params.stage]);
if (params.r && !params.stage) {
return null;
}
const stage = params.stage as StageType; const stage = params.stage as StageType;

View File

@@ -1,5 +1,5 @@
import httpService from "./httpService"; import httpService from "./httpService";
import type { ApiResponse } from "./httpService"; import type { ApiResponse, RequestConfig } from "./httpService";
export enum StageType { export enum StageType {
INTRO = "intro", INTRO = "intro",
@@ -59,6 +59,9 @@ export interface TestResultData {
radar_data: RadarData; radar_data: RadarData;
answers: Answer[]; answers: Answer[];
sort?: string[]; // 雷达图能力项排序,如 ["正手球质", "正手控制", ...] sort?: string[]; // 雷达图能力项排序,如 ["正手球质", "正手控制", ...]
/** 分享查看时后端可返回测评归属用户,用于展示头像昵称 */
sharer_nickname?: string;
sharer_avatar_url?: string;
} }
// 单条测试记录 // 单条测试记录
@@ -118,11 +121,15 @@ class EvaluateService {
return httpService.post("/ntrp/history", {}, { showLoading: true }); return httpService.post("/ntrp/history", {}, { showLoading: true });
} }
// 获取测试详情 // 获取测试详情from_share 为 true 时表示扫码/分享查看他人当次结果,需后端 /ntrp/detail 按 record_id 放行只读)
async getTestResult(req: { async getTestResult(
record_id: number; req: { record_id: number; from_share?: boolean },
}): Promise<ApiResponse<TestResultData>> { httpConfig?: Partial<RequestConfig>,
return httpService.post("/ntrp/detail", req, { showLoading: true }); ): Promise<ApiResponse<TestResultData>> {
return httpService.post("/ntrp/detail", req, {
showLoading: true,
...httpConfig,
});
} }
// 获取最后一次(最新)测试结果 // 获取最后一次(最新)测试结果

View File

@@ -217,7 +217,7 @@ export class UserService {
// 处理人数统计 - 兼容不同的字段名 // 处理人数统计 - 兼容不同的字段名
const registered_count = const registered_count =
game.current_players || game.participant_count || 0; game.participant_count ?? game.current_players ?? 0;
const max_count = game.max_players || game.max_participants || 0; const max_count = game.max_players || game.max_participants || 0;
// 转换为 ListCard 期望的格式 // 转换为 ListCard 期望的格式

View File

@@ -319,8 +319,9 @@ export async function generatePosterImage(data: any): Promise<string> {
console.log("start !!!!"); console.log("start !!!!");
// const dpr = Taro.getWindowInfo().pixelRatio; const systemDpr =
const dpr = 1; Taro.getWindowInfo?.().pixelRatio || Taro.getSystemInfoSync().pixelRatio || 1;
const dpr = Math.min(Math.max(systemDpr, 1), 2);
// console.log(dpr, 'dpr') // console.log(dpr, 'dpr')
const width = 600; const width = 600;
const height = 1000; const height = 1000;
@@ -487,7 +488,7 @@ export async function generatePosterImage(data: any): Promise<string> {
const { tempFilePath } = await Taro.canvasToTempFilePath({ const { tempFilePath } = await Taro.canvasToTempFilePath({
canvas, canvas,
fileType: 'png', fileType: 'png',
quality: 0.7, quality: 1,
}); });
console.log('tempFilePath', tempFilePath) console.log('tempFilePath', tempFilePath)
return tempFilePath; return tempFilePath;

View File

@@ -7,14 +7,40 @@ export function delay(ms: number) {
export async function payOrder(params) { export async function payOrder(params) {
const { timeStamp, nonceStr, package: _package, signType, paySign } = params; const { timeStamp, nonceStr, package: _package, signType, paySign } = params;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
Taro.requestPayment({ let settled = false;
timeStamp, const timeout = setTimeout(() => {
nonceStr, if (settled) return;
package: _package, settled = true;
signType, reject(new Error("支付响应超时,请在订单页确认支付结果"));
paySign, }, 20000);
success: resolve,
fail: reject.bind(null, new Error("支付失败")), const finish = (cb: () => void) => {
}); if (settled) return;
settled = true;
clearTimeout(timeout);
cb();
};
try {
Taro.requestPayment({
timeStamp,
nonceStr,
package: _package,
signType,
paySign,
success: (res) => finish(() => resolve(res)),
fail: (err: any) =>
finish(() => {
const errMsg = String(err?.errMsg || "");
if (errMsg.includes("cancel")) {
reject(new Error("已取消支付"));
return;
}
reject(new Error("支付失败,请重试"));
}),
});
} catch {
finish(() => reject(new Error("支付拉起失败,请重试")));
}
}); });
} }

View File

@@ -26,10 +26,9 @@ const designHeight = 400
// 获取屏幕宽度如果没有传入width则使用屏幕宽度 // 获取屏幕宽度如果没有传入width则使用屏幕宽度
const windowWidth = Taro.getSystemInfoSync().windowWidth const windowWidth = Taro.getSystemInfoSync().windowWidth
// 获取 DPR - 使用系统像素比确保高清显示 // 获取 DPR - 使用系统像素比确保高清显示,限制上限避免内存占用过高
// const systemDpr = Taro.getSystemInfoSync().pixelRatio const systemDpr = Taro.getSystemInfoSync().pixelRatio || 1
const dpr = 1 const dpr = Math.min(Math.max(systemDpr, 1), 2)
// Math.min(systemDpr, 3) // 限制最大dpr为3避免过度放大
// 2. 计算缩放比例(设备宽度 / 设计稿宽度) // 2. 计算缩放比例(设备宽度 / 设计稿宽度)
const scale = windowWidth / designWidth const scale = windowWidth / designWidth
@@ -88,7 +87,7 @@ const drawLabel = (ctx: any, x: number, y: number, width: number, height: number
// 绘制边框 // 绘制边框
ctx.strokeStyle = borderColor ctx.strokeStyle = borderColor
ctx.lineWidth = 1 * dpr ctx.lineWidth = 1
ctx.stroke() ctx.stroke()
// 绘制文字 // 绘制文字
@@ -115,16 +114,25 @@ function with_cache_bust(url: string): string {
// 工具函数 - OffscreenCanvas 下加载图片(使用 offscreen.createImage // 工具函数 - OffscreenCanvas 下加载图片(使用 offscreen.createImage
const loadImage = (src: string): Promise<any> => { const loadImage = (src: string): Promise<any> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null
try { try {
const off = runtime.offscreen const off = runtime.offscreen
if (!off || typeof off.createImage !== 'function') { if (!off || typeof off.createImage !== 'function') {
throw new Error('OffscreenCanvas 未初始化或不支持 createImage') throw new Error('OffscreenCanvas 未初始化或不支持 createImage')
} }
const img = off.createImage() const img = off.createImage()
img.onload = () => resolve(img) timer = setTimeout(() => reject(new Error(`图片加载超时: ${src}`)), 8000)
img.onerror = reject img.onload = () => {
if (timer) clearTimeout(timer)
resolve(img)
}
img.onerror = (e: any) => {
if (timer) clearTimeout(timer)
reject(e)
}
img.src = with_cache_bust(src) img.src = with_cache_bust(src)
} catch (e) { } catch (e) {
if (timer) clearTimeout(timer)
reject(e) reject(e)
} }
}) })
@@ -134,14 +142,14 @@ const loadImage = (src: string): Promise<any> => {
const drawSVGPathToCanvas = (ctx: any) => { const drawSVGPathToCanvas = (ctx: any) => {
// 设置绘制样式 // 设置绘制样式
ctx.strokeStyle = '#00E5AD'; ctx.strokeStyle = '#00E5AD';
ctx.lineWidth = scale * 3 * dpr; ctx.lineWidth = scale * 3;
ctx.lineCap = 'round'; ctx.lineCap = 'round';
ctx.lineJoin = 'round'; ctx.lineJoin = 'round';
ctx.save(); ctx.save();
// 移动到指定位置并缩放 // 移动到指定位置并缩放
ctx.translate(scale * 200 * dpr, scale * 90 * dpr); ctx.translate(scale * 200, scale * 90);
const scaleValue = 0.8 const scaleValue = 0.8
ctx.scale(scaleValue, scaleValue); ctx.scale(scaleValue, scaleValue);
@@ -364,43 +372,36 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro
console.log('开始绘制分享卡片...') console.log('开始绘制分享卡片...')
try { try {
// 设置Canvas的实际尺寸使用dpr确保高清显示 // 统一逻辑坐标:先按 dpr 缩放,再使用设计坐标绘制
const canvasWidthPx = canvasWidth * dpr const canvasWidthPx = canvasWidth * dpr
const canvasHeightPx = canvasHeight * dpr const canvasHeightPx = canvasHeight * dpr
// 清空画布
ctx.clearRect(0, 0, canvasWidthPx, canvasHeightPx) ctx.clearRect(0, 0, canvasWidthPx, canvasHeightPx)
ctx.save()
ctx.scale(dpr, dpr)
console.log('画布已清空') 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) const gradient = ctx.createLinearGradient(0, 0, 0, canvasHeight)
gradient.addColorStop(0, '#BFFFEF') gradient.addColorStop(0, '#BFFFEF')
gradient.addColorStop(1, '#F2FFFC') gradient.addColorStop(1, '#F2FFFC')
ctx.fillStyle = gradient ctx.fillStyle = gradient
ctx.fillRect(0, 0, canvasWidthPx, canvasHeightPx) ctx.fillRect(0, 0, canvasWidth, canvasHeight)
console.log('背景绘制完成') console.log('背景绘制完成')
// 绘制背景条纹 已完成 // 绘制背景条纹 已完成
ctx.strokeStyle = 'rgba(0, 0, 0, 0.03)' ctx.strokeStyle = 'rgba(0, 0, 0, 0.03)'
ctx.lineWidth = 2 ctx.lineWidth = 2
for (let i = 0; i < canvasWidthPx; i += 4) { for (let i = 0; i < canvasWidth; i += 4) {
ctx.beginPath() ctx.beginPath()
ctx.moveTo(i, 0) ctx.moveTo(i, 0)
ctx.lineTo(i, canvasHeightPx) ctx.lineTo(i, canvasHeight)
ctx.stroke() ctx.stroke()
} }
// 绘制用户头像(左上角) 已完成 // 绘制用户头像(左上角) 已完成
const avatarSize = scale * 32 * dpr // 32px * dpr const avatarSize = scale * 32
const avatarX = scale * 35 * dpr // 距离左侧35px const avatarX = scale * 35
const avatarY = scale * 35 * dpr // 距离顶部35px const avatarY = scale * 35
try { try {
const avatarPath = await loadImage(data.userAvatar) const avatarPath = await loadImage(data.userAvatar)
@@ -413,29 +414,29 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro
ctx.restore() ctx.restore()
} catch (error) { } catch (error) {
// 如果头像加载失败,绘制默认头像 // 如果头像加载失败,绘制默认头像
ctx.setFillStyle('#CCCCCC') ctx.fillStyle = '#CCCCCC'
ctx.beginPath() ctx.beginPath()
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI) ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI)
ctx.fill() ctx.fill()
} }
// 绘制用户昵称 已完成 // 绘制用户昵称 已完成
const nicknameX = avatarX + avatarSize + 8 * dpr // 距离头像8px const nicknameX = avatarX + avatarSize + scale * 8
const nicknameY = avatarY + (avatarSize - 18 * dpr) / 2 // 与头像水平居中对齐 const nicknameY = avatarY + (avatarSize - scale * 18) / 2
const nicknameFontSize = scale * 18 * dpr const nicknameFontSize = scale * 18
drawBoldText(ctx, data.userNickname, nicknameX, nicknameY, nicknameFontSize, '#000000', 'Noto Sans SC', '900') drawBoldText(ctx, data.userNickname, nicknameX, nicknameY, nicknameFontSize, '#000000', 'Noto Sans SC', '900')
// 绘制"邀你加入球局"文案 // 绘制"邀你加入球局"文案
const inviteX = scale * 35 * dpr // 距离画布左侧35px const inviteX = scale * 35
const inviteY = scale * 100 * dpr // 距离画布顶部79px const inviteY = scale * 100
const inviteFontSize = scale * 44 * dpr const inviteFontSize = scale * 44
// 绘制"邀你加入" // 绘制"邀你加入"
drawBoldText(ctx, '邀你加入', inviteX, inviteY, inviteFontSize, '#000000', 'Noto Sans SC', '900') drawBoldText(ctx, '邀你加入', inviteX, inviteY, inviteFontSize, '#000000', 'Noto Sans SC', '900')
// 绘制"球局"特殊样式 // 绘制"球局"特殊样式
const qiuJuX = inviteX + ctx.measureText('邀你加入').width + 4 * dpr const qiuJuX = inviteX + ctx.measureText('邀你加入').width + scale * 4
const qiuJuFontSize = scale * 44 * dpr const qiuJuFontSize = scale * 44
drawBoldText(ctx, '球局', qiuJuX, inviteY, qiuJuFontSize, '#00E5AD', 'Noto Sans SC', '900') drawBoldText(ctx, '球局', qiuJuX, inviteY, qiuJuFontSize, '#00E5AD', 'Noto Sans SC', '900')
// 测试绘制网络图片 // 测试绘制网络图片
@@ -443,12 +444,12 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro
// 绘制球员图片(右上角)已完成 // 绘制球员图片(右上角)已完成
let venueBaseConfig = { let venueBaseConfig = {
venueImgX: scale * 340 * dpr, venueImgX: scale * 340,
venueImgY: scale * 35 * dpr, venueImgY: scale * 35,
rotation: scale * -8, // 旋转-8度 rotation: scale * -8, // 旋转-8度
venueImgSize: scale * 124 * dpr, venueImgSize: scale * 124,
borderRadius: scale * 24 * dpr, borderRadius: scale * 24,
padding: scale * 4 * dpr, padding: scale * 4,
venueImage: data.venueImages?.[0] venueImage: data.venueImages?.[0]
} }
@@ -457,8 +458,8 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro
const venueBackConfig = { const venueBackConfig = {
...venueBaseConfig, ...venueBaseConfig,
venueImage: data.venueImages?.[1], venueImage: data.venueImages?.[1],
venueImgX: scale * 400 * dpr, venueImgX: scale * 400,
venueImgY: scale * 35 * dpr, venueImgY: scale * 35,
rotation: scale * -10, // 旋转-10度 rotation: scale * -10, // 旋转-10度
} }
await drawVenueImages(ctx, venueBackConfig) await drawVenueImages(ctx, venueBackConfig)
@@ -493,11 +494,11 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro
// 绘制"单打"标签 // 绘制"单打"标签
const danDaX = scale * 100 const danDaX = scale * 100
const danDaY = scale * 196 const danDaY = scale * 196
const danDaHeight = scale * 40 * dpr const danDaHeight = scale * 40
const danDaRadius = scale * 20 * dpr const danDaRadius = scale * 20
const danDaFontSize = scale * 22 * dpr const danDaFontSize = scale * 22
// 根据内容动态计算标签宽度(左右内边距) // 根据内容动态计算标签宽度(左右内边距)
const danDaPaddingX = scale * 16 * dpr const danDaPaddingX = scale * 16
setFont2D(ctx, danDaFontSize) setFont2D(ctx, danDaFontSize)
const danDaTextWidth = ctx.measureText(data.gameType).width const danDaTextWidth = ctx.measureText(data.gameType).width
const danDaWidth = danDaTextWidth + danDaPaddingX * 2 const danDaWidth = danDaTextWidth + danDaPaddingX * 2
@@ -508,11 +509,11 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro
const labelGap = scale * 16 // 两个标签之间的间距(不乘 dpr保持视觉间距 const labelGap = scale * 16 // 两个标签之间的间距(不乘 dpr保持视觉间距
const skillX = danDaX + danDaWidth + labelGap const skillX = danDaX + danDaWidth + labelGap
const skillY = scale * 196 const skillY = scale * 196
const skillHeight = scale * 40 * dpr const skillHeight = scale * 40
const skillRadius = scale * 20 * dpr const skillRadius = scale * 20
const skillFontSize = scale * 22 * dpr const skillFontSize = scale * 22
// 根据内容动态计算技能标签宽度 // 根据内容动态计算技能标签宽度
const skillPaddingX = scale * 20 * dpr const skillPaddingX = scale * 20
setFont2D(ctx, skillFontSize) setFont2D(ctx, skillFontSize)
const skillTextWidth = ctx.measureText(data.skillLevel).width const skillTextWidth = ctx.measureText(data.skillLevel).width
const skillWidth = skillTextWidth + skillPaddingX * 2 const skillWidth = skillTextWidth + skillPaddingX * 2
@@ -522,7 +523,7 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro
// 绘制日期时间 // 绘制日期时间
const dateX = danDaX const dateX = danDaX
const timeInfoY = infoStartY + infoSpacing const timeInfoY = infoStartY + infoSpacing
const timeInfoFontSize = scale * 24 * dpr const timeInfoFontSize = scale * 24
const calendarPath = await loadImage(`${OSS_BASE}/front/ball/images/ea792a5d-b105-4c95-bfc4-8af558f2b33b.jpg`) const calendarPath = await loadImage(`${OSS_BASE}/front/ball/images/ea792a5d-b105-4c95-bfc4-8af558f2b33b.jpg`)
ctx.drawImage(calendarPath, iconX, timeInfoY, iconSize, iconSize) ctx.drawImage(calendarPath, iconX, timeInfoY, iconSize, iconSize)
@@ -530,15 +531,16 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro
drawBoldText(ctx, data.gameDate, dateX, timeInfoY + 8, timeInfoFontSize, '#00E5AD') drawBoldText(ctx, data.gameDate, dateX, timeInfoY + 8, timeInfoFontSize, '#00E5AD')
// 绘制时间(黑色) // 绘制时间(黑色)
const timeX = textX + ctx.measureText(data.gameDate).width + 10 * dpr const timeX = textX + ctx.measureText(data.gameDate).width + scale * 10
drawBoldText(ctx, data.gameTime, timeX, timeInfoY + 8, timeInfoFontSize, '#000000') drawBoldText(ctx, data.gameTime, timeX, timeInfoY + 8, timeInfoFontSize, '#000000')
// 绘制地点 // 绘制地点
const locationInfoY = infoStartY + infoSpacing * 2 const locationInfoY = infoStartY + infoSpacing * 2
const locationFontSize = scale * 22 * dpr const locationFontSize = scale * 22
const locationPath = await loadImage(`${OSS_BASE}/front/ball/images/adc9a167-2ea9-4e3b-b963-6a894a1fd91b.jpg`) const locationPath = await loadImage(`${OSS_BASE}/front/ball/images/adc9a167-2ea9-4e3b-b963-6a894a1fd91b.jpg`)
ctx.drawImage(locationPath, iconX, locationInfoY, iconSize, iconSize) ctx.drawImage(locationPath, iconX, locationInfoY, iconSize, iconSize)
drawBoldText(ctx, data.venueName, danDaX, locationInfoY + 10, locationFontSize, '#000000') drawBoldText(ctx, data.venueName, danDaX, locationInfoY + 10, locationFontSize, '#000000')
ctx.restore()
try { try {
const wxAny: any = (typeof (globalThis as any) !== 'undefined' && (globalThis as any).wx) ? (globalThis as any).wx : null const wxAny: any = (typeof (globalThis as any) !== 'undefined' && (globalThis as any).wx) ? (globalThis as any).wx : null
if (wxAny && typeof wxAny.canvasToTempFilePath === 'function') { if (wxAny && typeof wxAny.canvasToTempFilePath === 'function') {
@@ -594,10 +596,12 @@ export async function generateShareImage(data: ShareCardData): Promise<string> {
// 记录到 runtime供 loadImage 使用) // 记录到 runtime供 loadImage 使用)
runtime.offscreen = offscreen runtime.offscreen = offscreen
isDrawing = true isDrawing = true
try {
const imagePath = await drawShareCard(ctx, data, offscreen) const imagePath = await drawShareCard(ctx, data, offscreen)
isDrawing = false return imagePath
return imagePath } finally {
isDrawing = false
}
} }
export default generateShareImage export default generateShareImage

4133
yarn.lock

File diff suppressed because it is too large Load Diff