diff --git a/pets-be/internal/service/idcard.go b/pets-be/internal/service/idcard.go index 81c191d..a11dd47 100644 --- a/pets-be/internal/service/idcard.go +++ b/pets-be/internal/service/idcard.go @@ -14,6 +14,8 @@ type IDCard struct { Gender string `json:"gender"` Breed string `json:"breed"` Birthday string `json:"birthday"` // "2023 年 12 月 15 日" + Zodiac string `json:"zodiac"` // 星座,按生日算 + Weight string `json:"weight"` // 体重,如 "13.5kg" Intro string `json:"intro"` AvatarURL string `json:"avatar_url"` IDNo string `json:"id_no"` // 玩具身份号,不入库 @@ -51,6 +53,26 @@ func petIDNo(pet *model.Pet) string { return b.String() } +// zodiacOf 按月日算西方星座。按各星座起始日(含)顺序判定, +// 落在 12/22–1/19 的默认摩羯座 +func zodiacOf(month, day int) string { + starts := []struct { + m, d int + name string + }{ + {1, 20, "水瓶座"}, {2, 19, "双鱼座"}, {3, 21, "白羊座"}, {4, 20, "金牛座"}, + {5, 21, "双子座"}, {6, 22, "巨蟹座"}, {7, 23, "狮子座"}, {8, 23, "处女座"}, + {9, 23, "天秤座"}, {10, 24, "天蝎座"}, {11, 22, "射手座"}, {12, 22, "摩羯座"}, + } + name := "摩羯座" + for _, s := range starts { + if month > s.m || (month == s.m && day >= s.d) { + name = s.name + } + } + return name +} + // GetIDCard 组装身份卡。小程序码是 best-effort:拿不到(未发布/配额)也照出卡 func (s *Service) GetIDCard(userID, petID string) (*IDCard, error) { pet, err := s.GetPet(userID, petID) @@ -72,10 +94,15 @@ func (s *Service) GetIDCard(userID, petID string) (*IDCard, error) { validity = pet.Birthday.Format("2006.01.02") + " - 永远" } + zodiac := "" + if pet.Birthday != nil && !pet.Birthday.IsZero() { + zodiac = zodiacOf(int(pet.Birthday.Month()), pet.Birthday.Day()) + } + card := &IDCard{ Name: pet.Name, Species: pet.Type, Gender: pet.Gender, Breed: pet.Breed, - Birthday: bd, Intro: intro, AvatarURL: pet.AvatarURL, - IDNo: petIDNo(pet), Validity: validity, + Birthday: bd, Zodiac: zodiac, Weight: pet.Weight, Intro: intro, + AvatarURL: pet.AvatarURL, IDNo: petIDNo(pet), Validity: validity, } // 小程序码取不到不算错——未发布/配额用尽时卡照样出,只是没码 diff --git a/pets-fe/pages/idcard/idcard.js b/pets-fe/pages/idcard/idcard.js index b0ebe3f..7958780 100644 --- a/pets-fe/pages/idcard/idcard.js +++ b/pets-fe/pages/idcard/idcard.js @@ -4,7 +4,7 @@ const { toastErr } = require('../../utils/ui.js'); // 卡片画布尺寸(逻辑像素)。真实导出会按 dpr 放大 const W = 620; -const H = 760; +const H = 812; // 在 canvas 2d 上加载一张图(远程或本地),resolve 出可直接 drawImage 的 image 对象。 // 加载失败 resolve(null)——头像或小程序码缺一张不该让整张卡画不出来 @@ -84,7 +84,7 @@ Page({ const cardW = W - pad * 2; // ── 上半张:资料区(蓝紫渐变)── - const topH = 380; + const topH = 430; const g1 = ctx.createLinearGradient(pad, pad, W - pad, topH); g1.addColorStop(0, '#FFF0D6'); g1.addColorStop(0.5, '#FFE0EC'); @@ -93,15 +93,36 @@ Page({ ctx.fillStyle = g1; ctx.fill(); - // 头像圆 - const avX = pad + 34, avY = pad + 34, avD = 108; + // 肉垫水印:卡内几枚淡淡的爪印,裁剪在圆角卡里 + ctx.save(); + this.roundRect(ctx, pad, pad, cardW, topH - pad, 28); + ctx.clip(); + ctx.globalAlpha = 0.06; + ctx.font = '120px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('🐾', W - pad - 70, pad + 90); + ctx.font = '80px sans-serif'; + ctx.fillText('🐾', W - pad - 150, topH - 70); + ctx.restore(); + ctx.globalAlpha = 1; + ctx.textBaseline = 'alphabetic'; + + // 头像圆(描一圈白边更像证件照) + const avX = pad + 34, avY = pad + 34, avD = 120; ctx.save(); ctx.beginPath(); ctx.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2); ctx.closePath(); ctx.clip(); if (avatar) { - ctx.drawImage(avatar, avX, avY, avD, avD); + // 居中裁剪成正方形再画:竖图/横图都不会被压变形(cover 效果) + const iw = avatar.width || avD; + const ih = avatar.height || avD; + const side = Math.min(iw, ih); + const sx = (iw - side) / 2; + const sy = (ih - side) / 2; + ctx.drawImage(avatar, sx, sy, side, side, avX, avY, avD, avD); } else { ctx.fillStyle = '#FFD9A3'; ctx.fillRect(avX, avY, avD, avD); @@ -113,6 +134,12 @@ Page({ } ctx.restore(); ctx.textBaseline = 'alphabetic'; + // 头像白边圈 + ctx.beginPath(); + ctx.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255,255,255,.9)'; + ctx.lineWidth = 5; + ctx.stroke(); // 资料行 const infoX = avX + avD + 28; @@ -127,30 +154,35 @@ Page({ ctx.fillText(value || '—', infoX + ctx.measureText(label).width + 14, y); y += 46; }; - line('姓名', c.name); - // 性别 + 品种放一行 - ctx.textAlign = 'left'; - ctx.font = '24px sans-serif'; - ctx.fillStyle = '#9A8F82'; - ctx.fillText('性别', infoX, y); - ctx.fillStyle = '#2D2925'; - ctx.font = '600 26px sans-serif'; - const gw = ctx.measureText(c.gender || '—').width; - ctx.fillText(c.gender || '—', infoX + 62, y); - if (c.breed) { - ctx.fillStyle = '#9A8F82'; + // 一行放两组「标签 值」,X 起点给定 + const pair = (l1, v1, l2, v2) => { + ctx.textAlign = 'left'; ctx.font = '24px sans-serif'; - ctx.fillText('品种', infoX + 62 + gw + 24, y); + ctx.fillStyle = '#9A8F82'; + ctx.fillText(l1, infoX, y); ctx.fillStyle = '#2D2925'; ctx.font = '600 26px sans-serif'; - ctx.fillText(c.breed, infoX + 62 + gw + 24 + 62, y); - } - y += 46; + const w1 = ctx.measureText(v1 || '—').width; + ctx.fillText(v1 || '—', infoX + 62, y); + if (l2 && v2) { + const x2 = infoX + 62 + w1 + 26; + ctx.fillStyle = '#9A8F82'; + ctx.font = '24px sans-serif'; + ctx.fillText(l2, x2, y); + ctx.fillStyle = '#2D2925'; + ctx.font = '600 26px sans-serif'; + ctx.fillText(v2, x2 + 62, y); + } + y += 46; + }; + line('姓名', c.name); + pair('性别', c.gender, '品种', c.breed); line('出生', c.birthday); + pair('星座', c.zodiac, '体重', c.weight); line('介绍', c.intro); // 身份 ID:虚线 + 号 - const idY = pad + 300; + const idY = pad + 344; ctx.strokeStyle = 'rgba(122,74,8,.22)'; ctx.lineWidth = 1; ctx.setLineDash([6, 6]); @@ -190,6 +222,33 @@ Page({ ctx.fillText('签发机构 肉垫计划', pad + 40, botY + 180); ctx.fillText('有效期限 ' + (c.validity || '永久有效'), pad + 40, botY + 214); + // 官方认证印章(装饰):淡棕红、旋一点角度,像盖上去的 + ctx.save(); + ctx.translate(W - pad - 120, botY + 100); + ctx.rotate((-14 * Math.PI) / 180); + ctx.globalAlpha = 0.5; + ctx.strokeStyle = '#B4623F'; + ctx.fillStyle = '#B4623F'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.arc(0, 0, 60, 0, Math.PI * 2); + ctx.stroke(); + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(0, 0, 50, 0, Math.PI * 2); + ctx.stroke(); + ctx.font = '32px sans-serif'; + ctx.fillText('★', 0, -16); + ctx.font = '700 22px sans-serif'; + ctx.fillText('肉垫计划', 0, 16); + ctx.font = '12px sans-serif'; + ctx.fillText('官方认证', 0, 38); + ctx.restore(); + ctx.globalAlpha = 1; + ctx.textBaseline = 'alphabetic'; + // 小程序码(未发布时 qr 为 null,画个占位框) const qrD = 128, qrX = W - pad - 40 - qrD, qrY = botY + botH - 40 - qrD; ctx.fillStyle = '#fff';