Files
sundynix-pets/pets-fe/pages/idcard/idcard.js
T
Blizzard 59412010dc feat(fe+be): 身份卡加公章、修性别符号、电话显示全、照护卡标题两行
- 修正性别 ♂/♀:用名字字号量宽度再定位,不再怼到名字上(之前用小字号量偏了)
- 正反两张卡右上角各盖一枚「肉垫计划 · 官方认证」红色公章(双圈+五角星,微旋转半透明)
- 照护卡标题改成两行「X星地球移民 / 居民身份卡」(按猫狗取 喵/汪/宠)
- 正面「物ID」改为「ID」
- 后端 IDCard.Phone 返回完整手机号,不再脱敏

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:32:09 +08:00

443 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. 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 = 1480;
// 在 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,
needPhone: false, // 没手机号:门槛,先验证才生成
phoneRefused: false, // 用户拒绝了授权
manualMode: false, // 手动填手机号(getPhoneNumber 唤不起时的兜底)
manualPhone: '',
},
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 });
// 门槛:没手机号不生成,先引导用微信验证
if (!card.phone) {
this.setData({ needPhone: true });
return;
}
// 布局要等页面渲染完 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;
}
this._dpr = dpr;
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 fx = pad, cardW2 = cardW;
const tags = (c.personality || '').split(',').filter(Boolean);
// ══════════ 正面卡(暖黄)══════════
// 高度按内容算,避免留一大块空白
let fH = 372;
if (tags.length) fH += 90;
if (c.intro) fH += 90;
const fY = pad;
const gf = ctx.createLinearGradient(fx, fY, fx, fY + fH);
gf.addColorStop(0, '#FFF7E0');
gf.addColorStop(1, '#FFE7AE');
this.roundRect(ctx, fx, fY, cardW2, fH, 30);
ctx.fillStyle = gf;
ctx.fill();
// 头像圆 + 白边
const avD = 150, avX = fx + 36, avY = fY + 52;
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 = '64px 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 = '#fff';
ctx.lineWidth = 8;
ctx.stroke();
// 名字 + 性别(头像右侧)。性别符号必须用「名字的字号」量宽度再定位,
// 否则换成小字号后 measureText 量出的宽度偏小,符号会怼到名字上
const nx = avX + avD + 28;
const nm = c.name || '毛孩子';
ctx.textAlign = 'left';
ctx.fillStyle = '#2D2925';
ctx.font = '700 52px sans-serif';
ctx.fillText(nm, nx, avY + 46);
const nmW = ctx.measureText(nm).width;
if (c.gender) {
const isF = c.gender === '女孩';
ctx.fillStyle = isF ? '#E86A9B' : '#5B9BE8';
ctx.font = '600 34px sans-serif';
ctx.fillText(isF ? '♀' : '♂', nx + nmW + 16, avY + 44);
}
// 头像右的资料行
const kvF = (label, value, yy) => {
ctx.textAlign = 'left';
ctx.font = '24px sans-serif';
ctx.fillStyle = '#A98C5A';
ctx.fillText(label, nx, yy);
ctx.fillStyle = '#3D3427';
ctx.font = '600 26px sans-serif';
ctx.fillText(value || '—', nx + 78, yy);
};
kvF('性别', c.gender, avY + 92);
kvF('品种', c.breed, avY + 130);
kvF('毛色', c.color, avY + 168);
kvF('年龄', c.age, avY + 206);
// 性格标签(没有就不画,避免留空行)
let ty = fY + 298;
if (tags.length) {
ctx.textAlign = 'left';
ctx.fillStyle = '#8A5A18';
ctx.font = '600 24px sans-serif';
ctx.fillText('🐾 性格标签', fx + 34, ty);
ty += 44;
const tagColors = [['#FFE7A8', '#8A5A18'], ['#D6E9FF', '#2E6DA8'], ['#FFD9DE', '#C24A63']];
let tx = fx + 34;
tags.forEach((t, i) => {
ctx.font = '23px sans-serif';
const tw = ctx.measureText(t).width + 32;
const col = tagColors[i % tagColors.length];
ctx.fillStyle = col[0];
this.roundRect(ctx, tx, ty - 28, tw, 40, 20);
ctx.fill();
ctx.fillStyle = col[1];
ctx.textAlign = 'left';
ctx.fillText(t, tx + 16, ty);
tx += tw + 14;
});
ty += 46;
}
// 介绍 quote
if (c.intro) {
ctx.fillStyle = 'rgba(255,255,255,.6)';
this.roundRect(ctx, fx + 34, ty, cardW2 - 68, 60, 16);
ctx.fill();
ctx.fillStyle = '#7A5A2E';
ctx.font = '25px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('“ ' + c.intro + ' ”', fx + 54, ty + 40);
}
// 底部:身份ID + 一起生活天数(贴卡片底边)
ctx.textAlign = 'left';
ctx.fillStyle = '#9C7A3E';
ctx.font = '22px sans-serif';
ctx.fillText('ID ' + (c.id_no || ''), fx + 34, fY + fH - 56);
if (c.days > 0) {
ctx.fillText('和主人一起生活的第 ' + c.days + ' 天', fx + 34, fY + fH - 22);
}
// 正面印章(右上角)
this.drawStamp(ctx, fx + cardW2 - 78, fY + 82);
// ══════════ 照护卡(白 + 蓝)══════════
const bY = fY + fH + 40, bH = 566;
this.roundRect(ctx, fx, bY, cardW2, bH, 30);
ctx.fillStyle = '#FFFFFF';
ctx.fill();
// 蓝色标题条(两行:「X星地球移民 / 居民身份卡」)
const star = c.species === '狗狗' ? '汪' : c.species === '猫猫' ? '喵' : '宠';
const hb = 92;
const gb = ctx.createLinearGradient(fx, bY, fx + cardW2, bY);
gb.addColorStop(0, '#5AA0E8');
gb.addColorStop(1, '#7FB6F0');
this.roundRect(ctx, fx + 24, bY + 24, cardW2 - 48, hb, 18);
ctx.fillStyle = gb;
ctx.fill();
ctx.textAlign = 'center';
ctx.fillStyle = '#fff';
ctx.font = '700 30px sans-serif';
ctx.fillText(star + '星地球移民', W / 2, bY + 24 + 40);
ctx.font = '700 26px sans-serif';
ctx.fillText('居民身份卡', W / 2, bY + 24 + 76);
// 照护行(标签 + 值,不放图标)
const rowX = fx + 44;
const kvB = (label, value, yy) => {
ctx.textAlign = 'left';
ctx.font = '24px sans-serif';
ctx.fillStyle = '#8D8277';
ctx.fillText(label, rowX, yy);
ctx.font = '600 25px sans-serif';
ctx.fillStyle = '#2D2925';
ctx.fillText(value || '—', rowX + 150, yy);
};
let ry = bY + 140;
kvB('主人', c.owner, ry); ry += 48;
kvB('联系电话', c.phone || '未验证', ry); ry += 48;
kvB('备注', c.notes || '无', ry); ry += 48;
kvB('发证机构', c.org || '肉垫计划', ry); ry += 48;
kvB('有效期', c.validity || '永久有效', ry); ry += 42;
// 走失联系框 + 小程序码
const lostY = ry + 16, lostH = bY + bH - lostY - 30;
this.roundRect(ctx, fx + 24, lostY, cardW2 - 48, lostH, 18);
ctx.fillStyle = '#EAF3FE';
ctx.fill();
ctx.textAlign = 'left';
ctx.fillStyle = '#5B7A9C';
ctx.font = '22px sans-serif';
ctx.fillText('微信扫一扫,查看' + (c.name || '') + '的身份信息', fx + 44, lostY + lostH / 2 + 8);
// 小程序码
const qrD = 118, qrX = fx + cardW2 - 24 - 20 - qrD, qrY = lostY + (lostH - qrD) / 2;
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 = '#9CB4CC';
ctx.font = '18px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('小程序码', qrX + qrD / 2, qrY + qrD / 2 - 2);
ctx.fillText('待发布', qrX + qrD / 2, qrY + qrD / 2 + 24);
}
// 背面印章(右上角,压在蓝条右侧)
this.drawStamp(ctx, fx + cardW2 - 78, bY + 70);
// 只导出到实际内容底部,避免卡片下面拖一条空白
const dpr = this._dpr || 2;
const contentH = bY + bH + pad;
wx.canvasToTempFilePath(
{
canvas: node,
x: 0,
y: 0,
width: W,
height: contentH,
destWidth: W * dpr,
destHeight: contentH * dpr,
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();
},
// 五角星路径(调用方负责 fill/stroke
star(ctx, cx, cy, rO, rI, n) {
ctx.beginPath();
for (let i = 0; i < n * 2; i++) {
const r = i % 2 === 0 ? rO : rI;
const a = -Math.PI / 2 + (i * Math.PI) / n;
const x = cx + r * Math.cos(a), y = cy + r * Math.sin(a);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
},
// 红色公章:双圈 + 顶部五角星 + 「肉垫计划 / 官方认证」,略微旋转、半透明
drawStamp(ctx, cx, cy) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-0.16);
ctx.globalAlpha = 0.68;
ctx.strokeStyle = '#CE4534';
ctx.fillStyle = '#CE4534';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(0, 0, 52, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(0, 0, 43, 0, Math.PI * 2);
ctx.stroke();
this.star(ctx, 0, -23, 11, 4.6, 5);
ctx.fill();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 25px sans-serif';
ctx.fillText('肉垫计划', 0, 10);
ctx.font = '13px sans-serif';
ctx.fillText('官方认证', 0, 32);
ctx.restore();
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
},
// 微信取手机号:新版 getPhoneNumber 返回动态 code,后端换成真实手机号存库,
// 拿回后重新取卡(此时电话就有了)并重画
onGetPhone(e) {
const d = e.detail || {};
if (!d.code) {
// 用户拒绝授权:不给生成,明确提示
this.setData({ phoneRefused: true });
return wx.showToast({ title: '没有手机号,无法生成身份卡', icon: 'none' });
}
wx.showLoading({ title: '验证中…', mask: true });
api
.bindPhone(d.code)
.then(() => api.petIDCard(this.data.petId))
.then((card) => {
wx.hideLoading();
if (!card) return;
this.setData({ card, qrReady: !!card.qr_ready, needPhone: false, phoneRefused: false });
wx.nextTick(() => this.draw());
wx.showToast({ title: '手机号已验证', icon: 'success' });
})
.catch((err) => {
wx.hideLoading();
toastErr(err, '验证失败');
});
},
// 手动填手机号兜底(getPhoneNumber 需企业认证,唤不起时用这个)
showManual() {
this.setData({ manualMode: true });
},
onManualPhone(e) {
this.setData({ manualPhone: e.detail.value });
},
submitManual() {
const p = (this.data.manualPhone || '').trim();
if (!/^1\d{10}$/.test(p)) return wx.showToast({ title: '手机号格式不对', icon: 'none' });
wx.showLoading({ title: '保存中…', mask: true });
api
.updateProfile({ phone: p })
.then(() => api.petIDCard(this.data.petId))
.then((card) => {
wx.hideLoading();
if (!card) return;
this.setData({ card, qrReady: !!card.qr_ready, needPhone: false, manualMode: false });
wx.nextTick(() => this.draw());
})
.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 || '', // 用画好的卡当分享封面
};
},
});