diff --git a/src/App.vue b/src/App.vue
index ff0ca8b..84baeff 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -193,7 +193,7 @@ button::after {
.share-canvas {
width: 300px;
- height: 534px;
+ height: 530px;
position: absolute;
top: -1000px;
left: 0;
diff --git a/src/canvas.js b/src/canvas.js
index b1d06a6..11e9682 100644
--- a/src/canvas.js
+++ b/src/canvas.js
@@ -1,4 +1,15 @@
-import { loadImage } from "@/util";
+const loadImage = (src) =>
+ new Promise((resolve, reject) => {
+ try {
+ uni.getImageInfo({
+ src,
+ success: (res) => resolve(res.path || res.tempFilePath || src),
+ fail: (e) => reject(e),
+ });
+ } catch (e) {
+ reject(src);
+ }
+ });
function drawLine(ctx, x1, y1, x2, y2, color = "#000", width = 1) {
ctx.beginPath();
@@ -482,7 +493,7 @@ async function loadCanvasImage(canvas, src) {
});
}
-export const sharePractiseData = async (canvasId, data) => {
+export const sharePointData = async (canvasId, data) => {
try {
const width = 375;
const height = 460;
@@ -550,3 +561,452 @@ export const sharePractiseData = async (canvasId, data) => {
console.error("generateShareImage 绘制失败:", e);
}
};
+
+export function renderRankTitle(ctx, text) {
+ const fontSize = 8;
+ const textWidth = ctx.measureText(text).width;
+ const padding = 8; // 文字与背景边缘的间距
+ const radius = 8; // 圆角半径
+ const textX = 76;
+ const textY = 50;
+ const x = textX - padding - 10; // 文字 x 坐标减去内边距
+ const y = textY - fontSize - padding / 2 + 2; // 文字 y 坐标减去字体大小和内边距
+ const width = textWidth + padding * 2 - 25; // 背景宽度
+ const height = fontSize + padding - 2; // 背景高度
+
+ // 开始绘制圆角矩形
+ ctx.beginPath();
+ // 从左上角开始,顺时针绘制
+ ctx.moveTo(x + radius, y);
+ // 上边框
+ ctx.lineTo(x + width - radius, y);
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
+ // 右边框
+ ctx.lineTo(x + width, y + height - radius);
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
+ // 下边框
+ ctx.lineTo(x + radius, y + height);
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
+ // 左边框
+ ctx.lineTo(x, y + radius);
+ ctx.quadraticCurveTo(x, y, x + radius, y);
+
+ // 设置背景颜色并填充
+ if (typeof ctx.setFillStyle === "function") {
+ ctx.setFillStyle("#5F51FF");
+ } else {
+ ctx.fillStyle = "#5F51FF";
+ }
+ ctx.fill();
+
+ if (typeof ctx.setFontSize === "function") {
+ ctx.setFontSize(fontSize);
+ ctx.setTextAlign("center");
+ ctx.setFillStyle("rgba(255, 255, 255, 0.9)");
+ } else {
+ ctx.font = `${fontSize}px sans-serif`;
+ ctx.textAlign = "center";
+ ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
+ }
+ ctx.fillText(text, textX, textY); // 绘制文字
+}
+
+export function renderScores(ctx, arrows = [], bgImg) {
+ let rowIndex = 0;
+ arrows.forEach((item, i) => {
+ rowIndex = i;
+ if (arrows.length >= 36 && i < 36) {
+ if (bgImg) {
+ ctx.drawImage(
+ bgImg,
+ 16 + (i % 9) * 30,
+ 290 + Math.ceil((i + 1) / 9) * 30,
+ 27,
+ 27
+ );
+ } else {
+ ctx.drawImage(
+ "../static/score-bg.png",
+ 16 + (i % 9) * 30,
+ 290 + Math.ceil((i + 1) / 9) * 30,
+ 27,
+ 27
+ );
+ }
+ renderText(
+ ctx,
+ item.ring,
+ 18,
+ "#fed847",
+ 29.5 + (i % 9) * 30,
+ 310 + Math.ceil((i + 1) / 9) * 30,
+ "center"
+ );
+ } else if (arrows.length >= 12 && i < 12) {
+ if (i > 5) rowIndex = i - 6;
+ if (bgImg) {
+ ctx.drawImage(bgImg, 24 + rowIndex * 42, i > 5 ? 362 : 320, 38, 38);
+ } else {
+ ctx.drawImage(
+ "../static/score-bg.png",
+ 24 + rowIndex * 42,
+ i > 5 ? 362 : 320,
+ 38,
+ 38
+ );
+ }
+ renderText(
+ ctx,
+ item.ring,
+ 23,
+ "#fed847",
+ 43 + rowIndex * 42,
+ i > 5 ? 389 : 347,
+ "center"
+ );
+ }
+ });
+}
+
+export function renderLine(ctx, from) {
+ ctx.beginPath();
+ if (typeof ctx.setLineWidth === "function") {
+ ctx.setLineWidth(1);
+ ctx.setStrokeStyle("rgba(255, 255, 255, 0.3)");
+ } else {
+ ctx.lineWidth = 1;
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.3)";
+ }
+ const length = 35;
+ ctx.moveTo(from, 295);
+ ctx.lineTo(from + length, 295);
+ ctx.stroke();
+}
+
+export async function sharePractiseData(canvasId, type, user, data) {
+ try {
+ const width = 300;
+ const height = 530;
+
+ const { canvas, ctx } = await getCanvas2DContext(canvasId, width, height);
+
+ // 预加载所有图片
+
+ const bgImgSrc = await loadImage(
+ "https://static.shelingxingqiu.com/attachment/2026-01-07/dfia8qlexbwoqaezyd.png"
+ );
+ const bgImg = await loadCanvasImage(canvas, bgImgSrc);
+
+ const avatarImgPromise = loadImage(user.avatar).then((path) =>
+ loadCanvasImage(canvas, path)
+ );
+ const lvlImgPromise = loadImage(user.lvlImage).then((path) =>
+ loadCanvasImage(canvas, path)
+ );
+
+ let titleImageSrc = "../static/first-try-title.png";
+ if (type == 2) {
+ titleImageSrc = "../static/practise-one-title.png";
+ } else if (type == 3) {
+ titleImageSrc = "../static/practise-two-title.png";
+ }
+ const titleImgPromise = loadCanvasImage(canvas, titleImageSrc);
+ const scoreBgImgPromise = loadCanvasImage(canvas, "../static/score-bg.png");
+ const qrCodeImgPromise = loadCanvasImage(canvas, "../static/qr-code.png");
+
+ const [avatarImg, lvlImg, titleImg, scoreBgImg, qrCodeImg] =
+ await Promise.all([
+ avatarImgPromise,
+ lvlImgPromise,
+ titleImgPromise,
+ scoreBgImgPromise,
+ qrCodeImgPromise,
+ ]);
+
+ // 先填充整体背景色
+ ctx.fillStyle = "#F5F5F5";
+ ctx.fillRect(0, 0, width, height);
+
+ ctx.drawImage(bgImg, 0, 0, width, height);
+
+ drawRoundImage(ctx, avatarImg, 17, 20, 32, 32, 20);
+ ctx.drawImage(lvlImg, 12, 15, 42, 42);
+
+ renderText(ctx, user.nickName, 13, "#fff", 58, 34);
+ renderRankTitle(ctx, user.lvlName);
+
+ let subTitle = "正式开启弓箭手之路";
+ if (type > 1) {
+ subTitle = `今日弓箭练习打卡 ${data.createdAt
+ .split(" ")[0]
+ .replaceAll("-", ".")}`;
+ }
+
+ ctx.drawImage(titleImg, (width - 160) / 2, 160, 160, 40);
+
+ ctx.font = "18px sans-serif";
+ renderText(ctx, subTitle, 18, "#fff", width / 2, 224, "center");
+
+ renderText(ctx, "共", 14, "#fff", 122, 300);
+ const totalRing = data.arrows.reduce((last, next) => last + next.ring, 0);
+ renderText(ctx, totalRing, 14, "#fed847", 148, 300, "center");
+ renderText(ctx, "环", 14, "#fff", 161, 300);
+
+ renderLine(ctx, 77);
+ renderLine(ctx, 185);
+
+ renderScores(ctx, data.arrows, scoreBgImg);
+
+ ctx.drawImage(qrCodeImg, width * 0.06, height * 0.87, 52, 52);
+ renderText(ctx, "射灵平台", 12, "#fff", width * 0.26, height * 0.9);
+ renderText(
+ ctx,
+ "快加入我们一起玩吧~",
+ 9,
+ "rgba(255, 255, 255, 0.5)",
+ width * 0.26,
+ height * 0.93
+ );
+ renderText(
+ ctx,
+ "后羿就是这样练成的",
+ 9,
+ "rgba(255, 255, 255, 0.5)",
+ width * 0.26,
+ height * 0.955
+ );
+ // 2D 模式下无需 ctx.draw()
+ } catch (err) {
+ console.log(err);
+ }
+}
+
+export const generateShareCard = (canvasId, date, actual, total) => {
+ try {
+ const ctx = uni.createCanvasContext(canvasId);
+ const imgUrl =
+ "https://static.shelingxingqiu.com/attachment/2025-11-04/ddzpm4tyh5vunyacsr.png";
+ const drawWidth = 375;
+ const drawHeight = 300;
+ // 先填充整体背景色
+ if (typeof ctx.setFillStyle === "function") {
+ ctx.setFillStyle("#F5F5F5");
+ } else {
+ ctx.fillStyle = "#F5F5F5";
+ }
+ if (typeof ctx.fillRect === "function") {
+ ctx.fillRect(0, 0, drawWidth, drawHeight);
+ }
+ // 兼容第三个参数传入 "actual/total" 的情况
+ let a = actual;
+ let t = total;
+ if (
+ (t === undefined || t === null) &&
+ typeof a === "string" &&
+ a.includes("/")
+ ) {
+ const parts = a.split("/");
+ a = parts[0];
+ t = parts[1] || "";
+ }
+
+ return new Promise((resolve, reject) => {
+ uni.getImageInfo({
+ src: imgUrl,
+ success: (res) => {
+ const path = res.path || res.tempFilePath || imgUrl;
+ if (typeof ctx.clearRect === "function") {
+ ctx.clearRect(0, 0, drawWidth, drawHeight);
+ }
+ ctx.drawImage(path, 0, 0, drawWidth, drawHeight);
+ // 绘制左上角日期(白色,22px)
+ renderText(ctx, String(date || ""), 18, "#FFFFFF", 6, 20, "left");
+ // 居中绘制第三个参数为圆角黑底标签
+ const rectW = 200;
+ const rectH = 78;
+ const radius = 39;
+ const rectX = (drawWidth - rectW) / 2;
+ const rectY = (drawHeight - rectH) / 2 - 12;
+ ctx.save();
+ ctx.beginPath();
+ ctx.moveTo(rectX + radius, rectY);
+ ctx.lineTo(rectX + rectW - radius, rectY);
+ ctx.quadraticCurveTo(
+ rectX + rectW,
+ rectY,
+ rectX + rectW,
+ rectY + radius
+ );
+ ctx.lineTo(rectX + rectW, rectY + rectH - radius);
+ ctx.quadraticCurveTo(
+ rectX + rectW,
+ rectY + rectH,
+ rectX + rectW - radius,
+ rectY + rectH
+ );
+ ctx.lineTo(rectX + radius, rectY + rectH);
+ ctx.quadraticCurveTo(
+ rectX,
+ rectY + rectH,
+ rectX,
+ rectY + rectH - radius
+ );
+ ctx.lineTo(rectX, rectY + radius);
+ ctx.quadraticCurveTo(rectX, rectY, rectX + radius, rectY);
+ ctx.closePath();
+ ctx.setFillStyle("rgba(0,0,0,0.5)");
+ ctx.fill();
+ ctx.restore();
+
+ // 居中排版:左侧显示 actual/,右侧显示 total,再追加“环”
+ ctx.save();
+ if (typeof ctx.setTextBaseline === "function")
+ ctx.setTextBaseline("middle");
+ const centerX = rectX + rectW / 2;
+ const centerY = rectY + rectH / 2;
+ // 左半部:右对齐 actual/
+ renderText(ctx, `${a}/`, 40, "#FFFFFF", centerX, centerY, "right");
+ // 右半部:左对齐 total
+ renderText(
+ ctx,
+ `${t || ""}`,
+ 30,
+ "#FFFFFF",
+ centerX,
+ centerY,
+ "left"
+ );
+ // 追加单位“环”:放在 total 文本之后 16px
+ const totalWidth = ctx.measureText(`${t || ""}`).width || 0;
+ renderText(
+ ctx,
+ "环",
+ 20,
+ "#FFFFFF",
+ centerX + totalWidth + 5,
+ centerY,
+ "left"
+ );
+ ctx.restore();
+ ctx.draw(false, () => {
+ try {
+ uni.canvasToTempFilePath({
+ canvasId,
+ width: drawWidth,
+ height: drawHeight,
+ destWidth: drawWidth,
+ destHeight: drawHeight,
+ fileType: "png",
+ quality: 1,
+ success: (r) => {
+ const path = r.tempFilePath || r.apFilePath || r.filePath;
+ resolve(path);
+ },
+ fail: (err) => reject(err),
+ });
+ } catch (err) {
+ reject(err);
+ }
+ });
+ },
+ fail: () => {
+ try {
+ ctx.drawImage(imgUrl, 0, 0, drawWidth, drawHeight);
+ // 绘制左上角日期(白色,22px)
+ renderText(ctx, String(date || ""), 22, "#FFFFFF", 10, 22, "left");
+ // 居中绘制第三个参数为圆角黑底标签
+ const rectW = 200;
+ const rectH = 78;
+ const radius = 39;
+ const rectX = (drawWidth - rectW) / 2;
+ const rectY = (drawHeight - rectH) / 2;
+ ctx.save();
+ ctx.beginPath();
+ ctx.moveTo(rectX + radius, rectY);
+ ctx.lineTo(rectX + rectW - radius, rectY);
+ ctx.quadraticCurveTo(
+ rectX + rectW,
+ rectY,
+ rectX + rectW,
+ rectY + radius
+ );
+ ctx.lineTo(rectX + rectW, rectY + rectH - radius);
+ ctx.quadraticCurveTo(
+ rectX + rectW,
+ rectY + rectH,
+ rectX + rectW - radius,
+ rectY + rectH
+ );
+ ctx.lineTo(rectX + radius, rectY + rectH);
+ ctx.quadraticCurveTo(
+ rectX,
+ rectY + rectH,
+ rectX,
+ rectY + rectH - radius
+ );
+ ctx.lineTo(rectX, rectY + radius);
+ ctx.quadraticCurveTo(rectX, rectY, rectX + radius, rectY);
+ ctx.closePath();
+ ctx.setFillStyle("rgba(0,0,0,0.5)");
+ ctx.fill();
+ ctx.restore();
+ // 居中排版:左侧显示 actual/,右侧显示 total,再追加“环”
+ ctx.save();
+ if (typeof ctx.setTextBaseline === "function")
+ ctx.setTextBaseline("middle");
+ const centerX = rectX + rectW / 2;
+ const centerY = rectY + rectH / 2;
+ // 左半部:右对齐 actual/
+ renderText(ctx, `${a}/`, 40, "#FFFFFF", centerX, centerY, "right");
+ // 右半部:左对齐 total
+ renderText(
+ ctx,
+ `${t || ""}`,
+ 35,
+ "#FFFFFF",
+ centerX,
+ centerY,
+ "left"
+ );
+ // 追加单位“环”:放在 total 文本之后 16px
+ const totalWidth = ctx.measureText(`${t || ""}`).width || 0;
+ renderText(
+ ctx,
+ "环",
+ 20,
+ "#FFFFFF",
+ centerX + totalWidth + 5,
+ centerY,
+ "left"
+ );
+ ctx.restore();
+ ctx.draw(false, () => {
+ try {
+ uni.canvasToTempFilePath({
+ canvasId,
+ width: drawWidth,
+ height: drawHeight,
+ destWidth: drawWidth,
+ destHeight: drawHeight,
+ fileType: "png",
+ quality: 1,
+ success: (r) => {
+ const path = r.tempFilePath || r.apFilePath || r.filePath;
+ resolve(path);
+ },
+ fail: (err) => reject(err),
+ });
+ } catch (err) {
+ reject(err);
+ }
+ });
+ } catch (e) {
+ reject(e);
+ }
+ },
+ });
+ });
+ } catch (e) {
+ console.error("generateShareCardImage 绘制失败:", e);
+ }
+};
diff --git a/src/pages/first-try.vue b/src/pages/first-try.vue
index c14bbe9..0635a46 100644
--- a/src/pages/first-try.vue
+++ b/src/pages/first-try.vue
@@ -13,8 +13,9 @@ import BowPower from "@/components/BowPower.vue";
import TestDistance from "@/components/TestDistance.vue";
import BubbleTip from "@/components/BubbleTip.vue";
import audioManager from "@/audioManager";
-import { createPractiseAPI } from "@/apis";
-import { generateCanvasImage, wxShare, debounce } from "@/util";
+import { createPractiseAPI, getPractiseAPI } from "@/apis";
+import { sharePractiseData } from "@/canvas";
+import { wxShare, debounce } from "@/util";
import { MESSAGETYPES } from "@/constants";
import useStore from "@/store";
import { storeToRefs } from "pinia";
@@ -60,34 +61,29 @@ const createPractise = async (arrows) => {
if (result) practiseId.value = result.id;
};
+const onOver = async () => {
+ start.value = false;
+ practiseResult.value = await getPractiseAPI(practiseId.value);
+};
+
async function onReceiveMessage(messages = []) {
messages.forEach((msg) => {
if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
- if (scores.value.length < total) {
- scores.value.push(msg.target);
- }
if (step.value === 2 && msg.target.dst / 100 >= 5) {
btnDisabled.value = false;
showGuide.value = true;
+ } else if (scores.value.length < total) {
+ scores.value.push(msg.target);
}
- }
- if (msg.constructor === MESSAGETYPES.ShootSyncMePracticeID) {
- if (practiseId.value && practiseId.value === msg.practice.id) {
- setTimeout(() => {
- start.value = false;
- practiseResult.value = {
- ...msg.practice,
- arrows: JSON.parse(msg.practice.arrows),
- lvl: msg.lvl,
- };
- }, 1500);
+ if (scores.value.length === total) {
+ setTimeout(onOver, 1500);
}
}
});
}
const onClickShare = debounce(async () => {
- await generateCanvasImage("shareCanvas", 1, user.value, practiseResult.value);
+ await sharePractiseData("shareCanvas", 1, user.value, practiseResult.value);
await wxShare("shareCanvas");
});
@@ -262,7 +258,7 @@ const onClose = () => {
: ''
}finish-tip.png`"
/>
-
+
diff --git a/src/pages/index.vue b/src/pages/index.vue
index 53487e0..96539b6 100644
--- a/src/pages/index.vue
+++ b/src/pages/index.vue
@@ -1,5 +1,5 @@