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"
/>
-
+