function profile(pet) { return { ...pet, genderLabel: pet.gender === "male" ? "男孩" : pet.gender === "female" ? "女孩" : "未填写", birthday: pet.birth_date ? pet.birth_date.slice(0, 10) : "未填写", weightLabel: pet.weight_kg ? `${pet.weight_kg} kg` : "未填写", cardNumber: "PET-" + (pet.id || "").replace(/-/g, "").slice(0, 12).toUpperCase(), }; } function round(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function text(ctx, value, x, y, size, color, width, bold = false) { ctx.font = `${bold ? "600 " : ""}${size}px sans-serif`; ctx.fillStyle = color; let label = String(value || "未填写"); if (width && ctx.measureText(label).width > width) { while (label && ctx.measureText(label + "…").width > width) label = label.slice(0, -1); label += "…"; } ctx.fillText(label, x, y); } function image(canvas, path) { return new Promise((resolve, reject) => { const img = canvas.createImage(); img.onload = () => resolve(img); img.onerror = () => reject(new Error("照片加载失败,请重试")); img.src = path; }); } async function renderCard(canvas, pet) { const p = profile(pet); canvas.width = 1200; canvas.height = 760; const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, 1200, 760); const gradient = ctx.createLinearGradient(0, 0, 1200, 760); gradient.addColorStop(0, "#fff3e5"); gradient.addColorStop(1, "#f0e9f8"); ctx.fillStyle = gradient; round(ctx, 0, 0, 1200, 760, 48); ctx.fill(); ctx.save(); round(ctx, 0, 0, 1200, 760, 48); ctx.clip(); ctx.fillStyle = "#ffffff66"; ctx.beginPath(); ctx.arc(1080, 140, 180, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(1020, 700, 210, 0, Math.PI * 2); ctx.fill(); ctx.restore(); ctx.strokeStyle = "#e9d6c7"; ctx.lineWidth = 2; round(ctx, 20, 20, 1160, 720, 34); ctx.stroke(); const logo = await image(canvas, "/assets/logo-corgi-v2.png"); ctx.save(); ctx.beginPath(); ctx.arc(89, 87, 35, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(logo, 54, 52, 70, 70); ctx.restore(); ctx.strokeStyle = "#fffaf4"; ctx.lineWidth = 5; ctx.beginPath(); ctx.arc(89, 87, 35, 0, Math.PI * 2); ctx.stroke(); text(ctx, "肉垫计划", 140, 82, 27, "#795a4b", 220, true); text(ctx, "PAWPRINT ID", 140, 113, 18, "#a48d7e", 220); text(ctx, "毛孩身份卡", 842, 103, 38, "#604a40", 300, true); ctx.strokeStyle = "#dfcdbf"; ctx.beginPath(); ctx.moveTo(54, 155); ctx.lineTo(1146, 155); ctx.stroke(); ctx.fillStyle = "#fffaf4"; round(ctx, 58, 200, 310, 340, 28); ctx.fill(); if (p.avatar_url) { const info = await new Promise((resolve, reject) => wx.getImageInfo({ src: p.avatar_url, success: resolve, fail: () => reject(new Error("照片下载失败,请检查网络后重试")), }), ); const photo = await image(canvas, info.path); ctx.save(); round(ctx, 70, 212, 286, 316, 20); ctx.clip(); // Aspect fill, with a centered crop matching the card's portrait window. const ratio = 286 / 316; const sw = Math.min(photo.width, photo.height * ratio), sh = sw / ratio; ctx.drawImage( photo, (photo.width - sw) / 2, (photo.height - sh) / 2, sw, sh, 70, 212, 286, 316, ); ctx.restore(); } else { ctx.fillStyle = "#d18b67"; ctx.beginPath(); ctx.ellipse(213, 399, 53, 42, 0, 0, Math.PI * 2); ctx.fill(); for (const [x, y, r] of [ [145, 335, 20], [188, 309, 22], [235, 309, 22], [278, 335, 20], ]) { ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } text(ctx, "等待一张可爱照片", 114, 491, 23, "#9b7966", 230); } text(ctx, p.name, 410, 250, 58, "#604a40", 700, true); text( ctx, `${p.breed || "品种未填写"} · ${p.genderLabel}`, 412, 298, 28, "#886c5f", 700, ); const fields = [ ["生日", p.birthday, 412, 368], ["体重", p.weightLabel, 805, 368], ["毛色", p.coat || "未填写", 412, 465], ]; for (const [label, value, x, y] of fields) { text(ctx, label, x, y, 23, "#a48d7e", 290); text( ctx, value, x, y + 43, 31, "#604a40", x === 412 && y === 465 ? 680 : 300, true, ); } ctx.strokeStyle = "#dfcdbf"; ctx.beginPath(); ctx.moveTo(54, 585); ctx.lineTo(1146, 585); ctx.stroke(); text(ctx, "陪伴编号", 58, 635, 20, "#a48d7e", 200); text(ctx, p.cardNumber, 58, 676, 29, "#795a4b", 650, true); text(ctx, "每一位家人,都值得被好好照顾", 650, 669, 24, "#886c5f", 490); return new Promise((resolve, reject) => wx.canvasToTempFilePath({ canvas, fileType: "png", success: (res) => resolve(res.tempFilePath), fail: () => reject(new Error("档案图片生成失败,请重试")), }), ); } async function saveToAlbum(filePath) { try { await new Promise((resolve, reject) => wx.saveImageToPhotosAlbum({ filePath, success: resolve, fail: reject }), ); } catch (error) { if (/cancel/.test(error.errMsg || "")) return false; const settings = await new Promise((resolve, reject) => wx.getSetting({ success: resolve, fail: reject }), ); if (settings.authSetting["scope.writePhotosAlbum"] !== false) throw new Error("保存失败,请在真机上重试"); const confirm = await wx.showModal({ title: "允许保存档案卡", content: "需要相册权限,将这张毛孩身份卡保存到你的手机。", confirmText: "去设置", }); if (!confirm.confirm) return false; const enabled = await new Promise((resolve, reject) => wx.openSetting({ success: resolve, fail: reject }), ); if (!enabled.authSetting["scope.writePhotosAlbum"]) return false; await new Promise((resolve, reject) => wx.saveImageToPhotosAlbum({ filePath, success: resolve, fail: () => reject(new Error("保存失败,请重试")), }), ); } return true; } module.exports = { profile, renderCard, saveToAlbum };