From d9563a25c6442d168d5b5b00ff3c1e00092b5420 Mon Sep 17 00:00:00 2001 From: kron Date: Mon, 17 Nov 2025 12:02:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E4=BA=AB=E5=9B=BE=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=80=A7=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/canvas.js | 481 ++++++++++++++++++++++++++++++++ src/pages/point-book-detail.vue | 23 +- src/pages/point-book.vue | 1 - src/util.js | 449 +++-------------------------- 4 files changed, 544 insertions(+), 410 deletions(-) create mode 100644 src/canvas.js diff --git a/src/canvas.js b/src/canvas.js new file mode 100644 index 0000000..e677b99 --- /dev/null +++ b/src/canvas.js @@ -0,0 +1,481 @@ +import { loadImage } from "@/util"; + +function drawLine(ctx, x1, y1, x2, y2, color = "#000", width = 1) { + ctx.beginPath(); + ctx.lineWidth = width; + ctx.strokeStyle = color; + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); +} + +const drawRoundImage = async ( + ctx, + img, + x, + y, + width, + height, + radius, + borderColor = null, + borderWidth = 0 +) => { + ctx.save(); + // 创建圆角路径 + 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.clip(); + // 绘制图片 + ctx.drawImage(img, x, y, width, height); + ctx.restore(); + + // 绘制边框(可选) + if (borderWidth > 0 && borderColor) { + // 内收路径,使描边完全落在给定宽高内 + const inset = borderWidth / 2; + const bx = x + inset; + const by = y + inset; + const bw = width - inset * 2; + const bh = height - inset * 2; + const br = Math.max(radius - inset, 0); + + ctx.beginPath(); + ctx.moveTo(bx + br, by); + // 右上角 + ctx.lineTo(bx + bw - br, by); + ctx.arcTo(bx + bw, by, bx + bw, by + br, br); + // 右下角 + ctx.lineTo(bx + bw, by + bh - br); + ctx.arcTo(bx + bw, by + bh, bx + bw - br, by + bh, br); + // 左下角 + ctx.lineTo(bx + br, by + bh); + ctx.arcTo(bx, by + bh, bx, by + bh - br, br); + // 左上角 + ctx.lineTo(bx, by + br); + ctx.arcTo(bx, by, bx + br, by, br); + + if (typeof ctx.setLineWidth === "function") { + ctx.setLineWidth(borderWidth); + } else { + ctx.lineWidth = borderWidth; + } + if (typeof ctx.setStrokeStyle === "function") { + ctx.setStrokeStyle(borderColor); + } else { + ctx.strokeStyle = borderColor; + } + ctx.stroke(); + } +}; + +function drawRingCircle(ctx, x, y, text, diameter = 12) { + const fillColor = "#ff4444"; + const borderColor = "#ffffff"; + const borderWidth = 1; + const r = diameter / 2; + + const cx = x - r - borderWidth; + const cy = y - r - borderWidth; + + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fillStyle = fillColor; + + ctx.shadowColor = "rgba(0,0,0,0.25)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 1; + ctx.fill(); + + // 重置阴影 + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + + ctx.lineWidth = borderWidth; + ctx.strokeStyle = borderColor; + ctx.stroke(); + + const fontSize = 9; + ctx.save(); + ctx.translate(cx, cy); + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = `${fontSize}px sans-serif`; + ctx.fillStyle = "#ffffff"; + ctx.scale(0.7, 1); + ctx.fillText(String(text ?? ""), 0, 0); + ctx.restore(); +} + +function drawRoundedRect( + ctx, + x, + y, + width, + height, + radius, + fillColor = "#fff", + strokeColor = null, + lineWidth = 1 +) { + const r = Math.max(Math.min(radius, Math.min(width, height) / 2), 0); + if (r === 0) { + ctx.beginPath(); + ctx.rect(x, y, width, height); + if (fillColor) { + ctx.fillStyle = fillColor; + ctx.fill(); + } + if (strokeColor) { + ctx.lineWidth = lineWidth; + ctx.strokeStyle = strokeColor; + ctx.stroke(); + } + return; + } + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + width - r, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + r); + ctx.lineTo(x + width, y + height - r); + ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height); + ctx.lineTo(x + r, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); + + if (fillColor) { + ctx.fillStyle = fillColor; + ctx.fill(); + } + + if (strokeColor) { + ctx.lineWidth = lineWidth; + ctx.strokeStyle = strokeColor; + ctx.stroke(); + } +} + +function drawTextBoxesLine( + ctx, + text, + startX, + baselineY, + fontSize = 9, + padding = 10, + bgColor = "#fff", + textColor = "#000", + radius = 4, + gap = 0 +) { + const str = String(text || ""); + ctx.font = `${fontSize}px sans-serif`; + const w = ctx.measureText(str).width; // 整段文本宽度 + const hPadding = padding + 4; + const rectW = Math.ceil(w) + hPadding * 2; + const rectH = fontSize + padding * 2; + const rectY = baselineY - fontSize - padding / 1.3; + drawRoundedRect(ctx, startX, rectY, rectW, rectH, radius, bgColor, null, 0); + renderText( + ctx, + str, + fontSize, + textColor, + startX + rectW / 2, + baselineY, + "center" + ); + return startX + rectW + gap; +} + +function renderText( + ctx, + text, + size, + color, + x, + y, + textAlign = "left", + rotateDeg = 0 +) { + ctx.font = `${size}px sans-serif`; + ctx.fillStyle = color; + ctx.textAlign = textAlign; + ctx.textBaseline = "alphabetic"; + if (rotateDeg) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate((Math.PI / 180) * rotateDeg); + ctx.fillText(text, 0, 0); + ctx.restore(); + } else { + ctx.fillText(text, x, y); + } +} + +export const generateShareImage = async (canvasId, data) => { + try { + const pointBook = uni.getStorageSync("point-book"); + const arrowData = data.groups && data.groups[0] ? data.groups[0] : {}; + const hasPoint = + Array.isArray(arrowData.list) && + arrowData.list.some((arrow) => arrow?.x && arrow?.y); + + const width = 375; + const height = hasPoint ? 800 : 440; + // 获取 Canvas 2D 上下文并按 DPR 设置 + const { canvas, ctx } = await getCanvas2DContext(canvasId, width, height); + + // 背景填充 + ctx.fillStyle = "#F5F5F5"; + ctx.fillRect(0, 0, width, height); + + // 背景图 + const bgSrc = await loadImage( + "https://static.shelingxingqiu.com/attachment/2025-11-14/de88ugdgqwecnmsqfv.png" + ); + const bgImg = await loadCanvasImage(canvas, bgSrc); + ctx.drawImage(bgImg, 0, 0, width, 300); + + // 头像 + const avatarSrc = await loadImage(data.user.avatar); + const avatarImg = await loadCanvasImage(canvas, avatarSrc); + await drawRoundImage(ctx, avatarImg, 13, 13, 54, 54, 27, "#000", 1); + + renderText(ctx, data.user.name, 18, "#000", 84, 36); + renderText(ctx, data.recordDate, 9, "#fff", 350, 24, "center", 39); + renderText(ctx, "今日打卡", 13, "#fff", 336, 38, "center", 39); + + // 文本标签 + let cursorX = 84; + cursorX = drawTextBoxesLine( + ctx, + pointBook.bowType.name, + cursorX, + 58, + 9, + 5, + "#fff", + "#000", + 6, + 2 + ); + cursorX = drawTextBoxesLine( + ctx, + String(pointBook.distance) + " 米", + cursorX + 6, + 58, + 9, + 5, + "#fff", + "#000", + 6, + 2 + ); + cursorX = drawTextBoxesLine( + ctx, + pointBook.bowtargetType.name, + cursorX + 6, + 58, + 9, + 5, + "#fff", + "#000", + 6, + 2 + ); + + renderText(ctx, "落点稳定性", 13, "#999", 25, 110); + renderText(ctx, "黄心率", 13, "#999", 145, 110); + renderText(ctx, "10环数", 13, "#999", 262, 110); + + renderText( + ctx, + Number(arrowData.stability.toFixed(2)), + 15, + "#000", + 25, + 133 + ); + renderText( + ctx, + Number((arrowData.yellowRate * 100).toFixed(2)) + "%", + 15, + "#000", + 145, + 133 + ); + renderText(ctx, arrowData.tenRings, 15, "#000", 262, 133); + + renderText(ctx, "平均环数", 13, "#999", 25, 175); + renderText(ctx, "总换数", 13, "#999", 145, 175); + renderText( + ctx, + Number(arrowData.averageRing.toFixed(2)), + 15, + "#000", + 25, + 200 + ); + renderText( + ctx, + `${arrowData.userTotalRing}/${arrowData.totalRing}`, + 15, + "#000", + 145, + 200 + ); + + if (hasPoint) { + drawRoundedRect(ctx, 20, 230, 5, 15, 5, "#FED847"); + renderText(ctx, "落点分布", 13, "#999", 32, 242); + + const bowSrc = await loadImage(pointBook.bowtargetType.iconPng); + const bowImg = await loadCanvasImage(canvas, bowSrc); + ctx.drawImage(bowImg, 375 * 0.08, 250, 375 * 0.84, 375 * 0.84); + arrowData.list + .filter((arrow) => arrow?.x && arrow?.y) + .forEach((arrow, index) => { + const px = 375 * 0.08 + 375 * 0.84 * arrow.x; + const py = 250 + 375 * 0.84 * arrow.y; + drawRingCircle(ctx, px, py, index + 1); + }); + } + + const ringNames = ["X", 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, "M"]; + const ringBarHeight = hasPoint ? 700 : 340; + + let rings = new Array(12).fill(0); + arrowData.list.forEach((arrow) => { + if (arrow.ring === 0) rings[0]++; + else if (arrow.ring === -1) rings[11]++; + else rings[arrow.ring]++; + }); + [rings[0], rings[11]] = [rings[11], rings[0]]; + rings = rings.reverse().map((count) => ({ + count, + rate: count / arrowData.list.length, + })); + + const barColor = (rate) => { + if (rate >= 0.4) return "#FDC540"; + if (rate >= 0.2) return "#FED847"; + return "#ffe88f"; + }; + const barWidth = 20; + const barSpacing = 7.1; + const startX = 38; + const startY = ringBarHeight - 15; + + rings.forEach((ring, index) => { + const barHeight = ring.rate * 90; + if (ring.rate > 0) { + renderText( + ctx, + Number((ring.rate * 100).toFixed(1)) + "%", + 9, + "#333", + startX + index * (barWidth + barSpacing), + startY - barHeight - (hasPoint ? 6 : 3) + ); + } + ctx.fillStyle = barColor(ring.rate); + ctx.fillRect( + startX + index * (barWidth + barSpacing), + startY - barHeight, + barWidth, + barHeight + ); + }); + + renderText(ctx, "环值", 9, "#999", 15, ringBarHeight - 1); + ringNames.forEach((ring, index) => { + renderText( + ctx, + ring, + 11, + "#333", + 48 + index * 27, + ringBarHeight, + "center" + ); + }); + drawLine(ctx, 15, ringBarHeight - 15, 362, ringBarHeight - 15, "#333"); + + const qrcodeSrc = await loadImage( + "https://static.shelingxingqiu.com/attachment/2025-11-13/de7fzgghsfgqu0ytu6.png" + ); + const qrcodeImg = await loadCanvasImage(canvas, qrcodeSrc); + ctx.drawImage(qrcodeImg, 40, hasPoint ? 715 : 358, 68, 68); + + renderText(ctx, "射灵星球", 18, "#333", 120, hasPoint ? 734 : 380); + renderText( + ctx, + "高效记录每一箭,快来一起打卡吧!", + 13, + "#999", + 120, + hasPoint ? 756 : 402 + ); + renderText(ctx, "扫码打卡", 13, "#FFA118", 120, hasPoint ? 777 : 422); + // 2D 即时绘制,无需 ctx.draw() + } catch (e) { + console.error("generateShareImage 绘制失败:", e); + } +}; + +// 顶部导入与工具方法 +async function getCanvas2DContext(canvasId, targetWidth, targetHeight) { + return new Promise((resolve) => { + const query = uni.createSelectorQuery(); + query + .select(`#${canvasId}`) + .fields({ node: true, size: true }) + .exec((res) => { + const { node: canvas } = res[0] || {}; + const ctx = canvas.getContext("2d"); + const dpr = uni.getSystemInfoSync().pixelRatio || 1; + + const w = targetWidth || res[0].width; + const h = targetHeight || res[0].height; + + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + resolve({ canvas, ctx, dpr, cssWidth: w, cssHeight: h }); + }); + }); +} + +async function loadCanvasImage(canvas, src) { + return new Promise((resolve, reject) => { + try { + const img = canvas.createImage(); + img.onload = () => resolve(img); + img.onerror = (e) => reject(e); + img.src = src; + } catch (e) { + reject(e); + } + }); +} diff --git a/src/pages/point-book-detail.vue b/src/pages/point-book-detail.vue index 38eac85..6af54a8 100644 --- a/src/pages/point-book-detail.vue +++ b/src/pages/point-book-detail.vue @@ -7,7 +7,8 @@ import ScreenHint2 from "@/components/ScreenHint2.vue"; import RingBarChart from "@/components/RingBarChart.vue"; import { getPointBookDetailAPI, addNoteAPI } from "@/apis"; -import { wxShare, generateShareCard, generateShareImage } from "@/util"; +import { wxShare, generateShareCard } from "@/util"; +import { generateShareImage } from "@/canvas"; import useStore from "@/store"; import { storeToRefs } from "pinia"; @@ -23,6 +24,7 @@ const targetId = ref(0); const targetSrc = ref(""); const arrows = ref([]); const notes = ref(""); +const hasPoint = ref(false); const record = ref({ groups: [], user: {}, @@ -94,6 +96,11 @@ onLoad(async (options) => { if (options.id) { const result = await getPointBookDetailAPI(options.id || 243); record.value = result; + const arrowData = + record.value.groups && record.value.groups[0] + ? record.value.groups[0] + : {}; + hasPoint.value = (arrowData.list || []).some((arrow) => arrow.x && arrow.y); notes.value = result.remark || ""; const config = uni.getStorageSync("point-book-config"); config.targetOption.some((item) => { @@ -170,8 +177,9 @@ onShareTimeline(async () => { > @@ -216,7 +224,7 @@ onShareTimeline(async () => { {{ notes ? "我的备注" : "添加备注" }} - + 落点分布 - + { :scroll="false" /> - +