feat(fe+be): 身份卡头像不再压扁 + 内容丰富
- 头像用九参数 drawImage 居中裁剪(cover),竖图/横图都不变形,加白边圈 - 后端 IDCard 增 zodiac(按生日算星座) + weight,卡面加「星座/体重」行 - 加淡淡的肉垫水印 + 旋转的「肉垫计划·官方认证」印章 - 画布加高容纳新增内容 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
// 小程序码取不到不算错——未发布/配额用尽时卡照样出,只是没码
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user