Files
sundynix-pets/pets-fe/pages/idcard/idcard.js
T
Blizzard 15adfb76ac feat(fe): 身份卡重绘成正面+照护信息+证件三段式 + 手机号验证
- 正面:头像/名字/物种性别 + 品种·毛色 / 出生·星座 / 体重 + 性格标签chips
- 照护信息:主人 / 联系电话 / 疫苗情况 / 备注(去掉驱虫、过敏)
- 证件区:居民身份卡 + 身份ID / 发证机构 / 有效期 + 小程序码
- 未验证手机号时页面出现「用微信验证手机号」(getPhoneNumber),
  拿到 code 换手机号后重取卡重画;表单去掉过敏输入

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:28:26 +08:00

326 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
// 卡片画布尺寸(逻辑像素)。真实导出会按 dpr 放大
const W = 620;
const H = 1000;
// 在 canvas 2d 上加载一张图(远程或本地),resolve 出可直接 drawImage 的 image 对象。
// 加载失败 resolve(null)——头像或小程序码缺一张不该让整张卡画不出来
function loadImage(canvas, src) {
return new Promise((resolve) => {
if (!src) return resolve(null);
const img = canvas.createImage();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
}
Page({
data: {
petId: '',
card: null,
cardImg: '',
qrReady: false,
saving: false,
},
onLoad(q) {
store
.ready()
.then(() => {
const id = (q && q.id) || store.currentPetId();
if (!id) {
wx.showToast({ title: '先选一只毛孩子', icon: 'none' });
return;
}
this.setData({ petId: id });
return api.petIDCard(id);
})
.then((card) => {
if (!card) return;
this.setData({ card, qrReady: !!card.qr_ready });
// 布局要等页面渲染完 canvas 节点才拿得到,放 nextTick
wx.nextTick(() => this.draw());
})
.catch((e) => toastErr(e));
},
draw() {
wx.createSelectorQuery()
.select('#idCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const node = res && res[0] && res[0].node;
if (!node) return;
let dpr = 2;
try {
dpr = (wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()).pixelRatio || 2;
} catch (e) {
dpr = 2;
}
node.width = W * dpr;
node.height = H * dpr;
const ctx = node.getContext('2d');
ctx.scale(dpr, dpr);
const c = this.data.card || {};
// 两张远程图都就绪再开画,否则小程序码会画不上或画一半(plan 里点过名的坑)
Promise.all([loadImage(node, c.avatar_url), loadImage(node, c.qr_url)]).then(([avatar, qr]) => {
this.paint(ctx, node, c, avatar, qr);
});
});
},
paint(ctx, node, c, avatar, qr) {
ctx.clearRect(0, 0, W, H);
// 外层留白背景
ctx.fillStyle = '#F3EFE8';
ctx.fillRect(0, 0, W, H);
const pad = 30;
const cardW = W - pad * 2;
// ═══ 正面:资料区(暖粉渐变)═══
const s1Y = pad, s1H = 378;
const g1 = ctx.createLinearGradient(pad, s1Y, W - pad, s1Y + s1H);
g1.addColorStop(0, '#FFF0D6');
g1.addColorStop(0.5, '#FFE0EC');
g1.addColorStop(1, '#E6EEFF');
this.roundRect(ctx, pad, s1Y, cardW, s1H, 28);
ctx.fillStyle = g1;
ctx.fill();
// 爪印水印
ctx.save();
this.roundRect(ctx, pad, s1Y, cardW, s1H, 28);
ctx.clip();
ctx.globalAlpha = 0.06;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '130px sans-serif';
ctx.fillText('🐾', W - pad - 80, s1Y + 100);
ctx.restore();
ctx.globalAlpha = 1;
ctx.textBaseline = 'alphabetic';
// 头像圆 + 白边
const avX = pad + 34, avY = s1Y + 34, avD = 118;
ctx.save();
ctx.beginPath();
ctx.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2);
ctx.clip();
if (avatar) {
const iw = avatar.width || avD, ih = avatar.height || avD, side = Math.min(iw, ih);
ctx.drawImage(avatar, (iw - side) / 2, (ih - side) / 2, side, side, avX, avY, avD, avD);
} else {
ctx.fillStyle = '#FFD9A3';
ctx.fillRect(avX, avY, avD, avD);
ctx.fillStyle = '#8A5A18';
ctx.font = '52px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(c.species === '狗狗' ? '🐶' : '🐱', avX + avD / 2, avY + avD / 2);
}
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 nameX = avX + avD + 26;
ctx.textAlign = 'left';
ctx.fillStyle = '#2D2925';
ctx.font = '700 46px sans-serif';
ctx.fillText(c.name || '毛孩子', nameX, avY + 54);
ctx.fillStyle = '#9A8F82';
ctx.font = '24px sans-serif';
ctx.fillText(c.species + (c.gender ? ' · ' + c.gender : ''), nameX, avY + 96);
// 资料 pair 行(头像下方,全宽两列)
const infoX = pad + 40;
let y = s1Y + avD + 60;
const pair = (l1, v1, l2, v2) => {
ctx.textAlign = 'left';
ctx.font = '24px sans-serif';
ctx.fillStyle = '#9A8F82';
ctx.fillText(l1, infoX, y);
ctx.fillStyle = '#2D2925';
ctx.font = '600 26px sans-serif';
ctx.fillText(v1 || '—', infoX + 66, y);
if (l2) {
const x2 = infoX + 300;
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 + 66, y);
}
y += 44;
};
pair('品种', c.breed, '毛色', c.color);
pair('出生', c.birthday, '星座', c.zodiac);
pair('体重', c.weight, '', '');
// 性格标签 chips
const tags = (c.personality || '').split(',').filter(Boolean);
if (tags.length) {
let tx = infoX;
const ty = y + 8;
tags.forEach((t) => {
ctx.font = '22px sans-serif';
const tw = ctx.measureText(t).width + 28;
ctx.fillStyle = 'rgba(245,169,71,.2)';
this.roundRect(ctx, tx, ty - 26, tw, 36, 18);
ctx.fill();
ctx.fillStyle = '#A96500';
ctx.textAlign = 'left';
ctx.fillText(t, tx + 14, ty);
tx += tw + 12;
});
}
// ═══ 照护信息 ═══
const s2Y = s1Y + s1H + 14, s2H = 290;
this.roundRect(ctx, pad, s2Y, cardW, s2H, 28);
ctx.fillStyle = '#FFFFFF';
ctx.fill();
ctx.textAlign = 'left';
ctx.fillStyle = '#B4623F';
ctx.font = '700 30px sans-serif';
ctx.fillText('照护信息', pad + 40, s2Y + 56);
const kv = (label, value, yy) => {
ctx.textAlign = 'left';
ctx.font = '24px sans-serif';
ctx.fillStyle = '#9A8F82';
ctx.fillText(label, pad + 40, yy);
ctx.font = '600 26px sans-serif';
ctx.fillStyle = '#2D2925';
ctx.fillText(value || '—', pad + 190, yy);
};
kv('主人', c.owner, s2Y + 108);
kv('联系电话', c.phone || '未验证', s2Y + 152);
kv('疫苗情况', c.vaccine_state, s2Y + 196);
kv('备注', c.notes || '无', s2Y + 240);
// ═══ 证件区(蓝紫渐变)+ 小程序码 ═══
const s3Y = s2Y + s2H + 14, s3H = H - s3Y - pad;
const g3 = ctx.createLinearGradient(pad, s3Y, W - pad, s3Y + s3H);
g3.addColorStop(0, '#E8F0FF');
g3.addColorStop(1, '#F0E8FF');
this.roundRect(ctx, pad, s3Y, cardW, s3H, 28);
ctx.fillStyle = g3;
ctx.fill();
ctx.textAlign = 'left';
ctx.fillStyle = '#7D89A8';
ctx.font = '22px sans-serif';
ctx.fillText('宠 星 人 地 球 移 民', pad + 40, s3Y + 50);
ctx.fillStyle = '#4A5878';
ctx.font = '700 48px sans-serif';
ctx.fillText('居民身份卡', pad + 40, s3Y + 112);
ctx.fillStyle = '#8A93A8';
ctx.font = '22px sans-serif';
ctx.fillText('身份 ID ' + (c.id_no || ''), pad + 40, s3Y + 156);
ctx.fillText('签发机构 ' + (c.org || '肉垫计划'), pad + 40, s3Y + 190);
ctx.fillText('有效期限 ' + (c.validity || '永久有效'), pad + 40, s3Y + 224);
// 小程序码
const qrD = 118, qrX = W - pad - 36 - qrD, qrY = s3Y + s3H - 36 - qrD;
ctx.fillStyle = '#fff';
this.roundRect(ctx, qrX, qrY, qrD, qrD, 12);
ctx.fill();
if (qr) {
ctx.drawImage(qr, qrX + 8, qrY + 8, qrD - 16, qrD - 16);
} else {
ctx.fillStyle = '#B5A99D';
ctx.font = '20px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('小程序码', qrX + qrD / 2, qrY + qrD / 2 - 4);
ctx.fillText('待发布', qrX + qrD / 2, qrY + qrD / 2 + 26);
}
wx.canvasToTempFilePath(
{
canvas: node,
fileType: 'png',
success: (r) => this.setData({ cardImg: r.tempFilePath }),
fail: () => wx.showToast({ title: '生成失败,稍后再试', icon: 'none' }),
},
this,
);
},
roundRect(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();
},
// 微信取手机号:新版 getPhoneNumber 返回动态 code,后端换成真实手机号存库,
// 拿回后重新取卡(此时电话就有了)并重画
onGetPhone(e) {
const d = e.detail || {};
if (!d.code) return; // 用户拒绝授权
wx.showLoading({ title: '验证中…', mask: true });
api
.bindPhone(d.code)
.then(() => api.petIDCard(this.data.petId))
.then((card) => {
wx.hideLoading();
if (card) {
this.setData({ card, qrReady: !!card.qr_ready });
this.draw();
}
wx.showToast({ title: '手机号已验证', icon: 'success' });
})
.catch((err) => {
wx.hideLoading();
toastErr(err, '验证失败');
});
},
onSave() {
if (!this.data.cardImg) return wx.showToast({ title: '还在生成,稍等一下', icon: 'none' });
if (this.data.saving) return;
this.setData({ saving: true });
wx.saveImageToPhotosAlbum({
filePath: this.data.cardImg,
success: () => wx.showToast({ title: '已保存到相册', icon: 'success' }),
fail: (err) => {
const msg = (err && err.errMsg) || '';
if (msg.indexOf('cancel') >= 0) return;
// 拒过一次后 authorize 不再弹,必须引导去设置页
if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) {
wx.showModal({
title: '需要相册权限',
content: '保存身份卡需要允许访问相册,去设置里打开一下?',
confirmText: '去设置',
success: (r) => r.confirm && wx.openSetting({}),
});
return;
}
wx.showToast({ title: '保存失败', icon: 'none' });
},
complete: () => this.setData({ saving: false }),
});
},
onShareAppMessage() {
const c = this.data.card || {};
return {
title: `${c.name || '我家毛孩子'} 的居民身份卡`,
path: '/pages/idcard/idcard?id=' + this.data.petId,
imageUrl: this.data.cardImg || '', // 用画好的卡当分享封面
};
},
});