Compare commits
10
Commits
ef172f4e4a
...
51ead8f411
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51ead8f411 | ||
|
|
1544151e5c | ||
|
|
c07ed03539 | ||
|
|
473c9f9200 | ||
|
|
59412010dc | ||
|
|
4e3859227e | ||
|
|
d9a80d7f8a | ||
|
|
747e7c9d3a | ||
|
|
586875f8c5 | ||
|
|
15adfb76ac |
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
@@ -14,6 +15,8 @@ type IDCard struct {
|
||||
Gender string `json:"gender"`
|
||||
Breed string `json:"breed"`
|
||||
Birthday string `json:"birthday"` // "2023 年 12 月 15 日"
|
||||
Age string `json:"age"` // 年龄,如 "2岁7个月"
|
||||
Days int `json:"days"` // 一起生活的天数(按到家日)
|
||||
Zodiac string `json:"zodiac"` // 星座,按生日算
|
||||
Weight string `json:"weight"` // 体重,如 "13.5kg"
|
||||
Intro string `json:"intro"`
|
||||
@@ -145,12 +148,18 @@ func (s *Service) GetIDCard(userID, petID string) (*IDCard, error) {
|
||||
allergy = "无"
|
||||
}
|
||||
|
||||
// 一起生活的天数:按到家日算,没填就 0
|
||||
days := 0
|
||||
if pet.ArrivedAt != nil && !pet.ArrivedAt.IsZero() {
|
||||
days = int(time.Since(*pet.ArrivedAt).Hours()/24) + 1
|
||||
}
|
||||
|
||||
card := &IDCard{
|
||||
Name: pet.Name, Species: pet.Type, Gender: pet.Gender, Breed: pet.Breed,
|
||||
Birthday: bd, Zodiac: zodiac, Weight: pet.Weight, Intro: intro,
|
||||
Birthday: bd, Age: pet.Age, Days: days, Zodiac: zodiac, Weight: pet.Weight, Intro: intro,
|
||||
Color: pet.Color, Personality: pet.Personality,
|
||||
AvatarURL: pet.AvatarURL, IDNo: petIDNo(pet), Validity: validity,
|
||||
Owner: owner, Phone: maskPhone(user.Phone), Allergy: allergy, Notes: pet.Notes,
|
||||
Owner: owner, Phone: user.Phone, Allergy: allergy, Notes: pet.Notes,
|
||||
VaccineState: vaccineState, DewormState: dewormState, Org: "肉垫计划",
|
||||
}
|
||||
|
||||
|
||||
+240
-143
@@ -4,7 +4,7 @@ const { toastErr } = require('../../utils/ui.js');
|
||||
|
||||
// 卡片画布尺寸(逻辑像素)。真实导出会按 dpr 放大
|
||||
const W = 620;
|
||||
const H = 812;
|
||||
const H = 1480;
|
||||
|
||||
// 在 canvas 2d 上加载一张图(远程或本地),resolve 出可直接 drawImage 的 image 对象。
|
||||
// 加载失败 resolve(null)——头像或小程序码缺一张不该让整张卡画不出来
|
||||
@@ -25,6 +25,10 @@ Page({
|
||||
cardImg: '',
|
||||
qrReady: false,
|
||||
saving: false,
|
||||
needPhone: false, // 没手机号:门槛,先验证才生成
|
||||
phoneRefused: false, // 用户拒绝了授权
|
||||
manualMode: false, // 手动填手机号(getPhoneNumber 唤不起时的兜底)
|
||||
manualPhone: '',
|
||||
},
|
||||
onLoad(q) {
|
||||
store
|
||||
@@ -41,6 +45,11 @@ Page({
|
||||
.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());
|
||||
})
|
||||
@@ -60,6 +69,7 @@ Page({
|
||||
} catch (e) {
|
||||
dpr = 2;
|
||||
}
|
||||
this._dpr = dpr;
|
||||
node.width = W * dpr;
|
||||
node.height = H * dpr;
|
||||
const ctx = node.getContext('2d');
|
||||
@@ -83,190 +93,173 @@ Page({
|
||||
const pad = 30;
|
||||
const cardW = W - pad * 2;
|
||||
|
||||
// ── 上半张:资料区(蓝紫渐变)──
|
||||
const topH = 430;
|
||||
const g1 = ctx.createLinearGradient(pad, pad, W - pad, topH);
|
||||
g1.addColorStop(0, '#FFF0D6');
|
||||
g1.addColorStop(0.5, '#FFE0EC');
|
||||
g1.addColorStop(1, '#E6EEFF');
|
||||
this.roundRect(ctx, pad, pad, cardW, topH - pad, 28);
|
||||
ctx.fillStyle = g1;
|
||||
const fx = pad, cardW2 = cardW;
|
||||
|
||||
// ══════════ 正面卡(暖黄)══════════
|
||||
// 正反两张卡等高(都用 cardH),中间内容垂直居中,避免正面留空
|
||||
const cardH = 510;
|
||||
const fH = cardH;
|
||||
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();
|
||||
|
||||
// 肉垫水印:卡内几枚淡淡的爪印,裁剪在圆角卡里
|
||||
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;
|
||||
// 头像圆 + 白边
|
||||
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.closePath();
|
||||
ctx.clip();
|
||||
if (avatar) {
|
||||
// 居中裁剪成正方形再画:竖图/横图都不会被压变形(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);
|
||||
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.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 = 'rgba(255,255,255,.9)';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 8;
|
||||
ctx.stroke();
|
||||
|
||||
// 资料行
|
||||
const infoX = avX + avD + 28;
|
||||
let y = pad + 52;
|
||||
const line = (label, value) => {
|
||||
// 名字 + 性别(头像右侧)。性别符号必须用「名字的字号」量宽度再定位,
|
||||
// 否则换成小字号后 measureText 量出的宽度偏小,符号会怼到名字上
|
||||
const nx = avX + avD + 28;
|
||||
const nm = c.name || '毛孩子';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = '24px sans-serif';
|
||||
ctx.fillStyle = '#9A8F82';
|
||||
ctx.fillText(label, infoX, y);
|
||||
ctx.fillStyle = '#2D2925';
|
||||
ctx.font = '600 26px sans-serif';
|
||||
ctx.fillText(value || '—', infoX + ctx.measureText(label).width + 14, y);
|
||||
y += 46;
|
||||
};
|
||||
// 一行放两组「标签 值」,X 起点给定
|
||||
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';
|
||||
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);
|
||||
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);
|
||||
}
|
||||
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 + 344;
|
||||
ctx.strokeStyle = 'rgba(122,74,8,.22)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([6, 6]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad + 34, idY);
|
||||
ctx.lineTo(W - pad - 34, idY);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
// 头像右的资料行
|
||||
const kvF = (label, value, yy) => {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = '22px sans-serif';
|
||||
ctx.fillStyle = '#9A8F82';
|
||||
ctx.fillText('身份 ID', pad + 34, idY + 34);
|
||||
ctx.font = '24px sans-serif';
|
||||
ctx.fillStyle = '#A98C5A';
|
||||
ctx.fillText(label, nx, yy);
|
||||
ctx.fillStyle = '#3D3427';
|
||||
ctx.font = '600 26px sans-serif';
|
||||
ctx.fillStyle = '#2D2925';
|
||||
ctx.fillText(c.id_no || '', pad + 34 + 90, idY + 34);
|
||||
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);
|
||||
|
||||
// ── 下半张:证件标题 + 小程序码 ──
|
||||
const botY = topH + 12;
|
||||
const botH = H - botY - pad;
|
||||
const g2 = ctx.createLinearGradient(pad, botY, W - pad, botY + botH);
|
||||
g2.addColorStop(0, '#E8F0FF');
|
||||
g2.addColorStop(1, '#F0E8FF');
|
||||
this.roundRect(ctx, pad, botY, cardW, botH, 28);
|
||||
ctx.fillStyle = g2;
|
||||
// 介绍 quote(在 info 和底栏之间垂直居中)
|
||||
if (c.intro) {
|
||||
const ty = fY + 300 + Math.max(0, Math.floor(((fY + fH - 92) - (fY + 300) - 60) / 2));
|
||||
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 = cardH;
|
||||
const gbc = ctx.createLinearGradient(fx, bY, fx, bY + bH);
|
||||
gbc.addColorStop(0, '#E6E7FB');
|
||||
gbc.addColorStop(1, '#F2F0FA');
|
||||
this.roundRect(ctx, fx, bY, cardW2, bH, 30);
|
||||
ctx.fillStyle = gbc;
|
||||
ctx.fill();
|
||||
|
||||
// 大标题(两行左对齐):小字「X星人地球移民」+ 大字「居民身份卡」
|
||||
// 汪星人=狗、喵星人=猫,其它兜底宠星人
|
||||
const star = c.species === '狗狗' ? '汪' : c.species === '猫猫' ? '喵' : '宠';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#7D89A8';
|
||||
ctx.font = '22px sans-serif';
|
||||
ctx.fillText('宠 星 人 地 球 移 民', pad + 40, botY + 60);
|
||||
ctx.fillStyle = '#4A5878';
|
||||
ctx.font = '700 52px sans-serif';
|
||||
ctx.fillText('居民身份卡', pad + 40, botY + 128);
|
||||
ctx.fillStyle = '#8A90AE';
|
||||
ctx.font = '600 27px sans-serif';
|
||||
this.spacedText(ctx, star + '星人地球移民', fx + 44, bY + 64, 8);
|
||||
ctx.fillStyle = '#3E4A63';
|
||||
ctx.font = '800 58px sans-serif';
|
||||
ctx.fillText('居民身份卡', fx + 44, bY + 140);
|
||||
|
||||
ctx.fillStyle = '#8A93A8';
|
||||
ctx.font = '22px sans-serif';
|
||||
ctx.fillText('签发机构 肉垫计划', pad + 40, botY + 180);
|
||||
ctx.fillText('有效期限 ' + (c.validity || '永久有效'), pad + 40, botY + 214);
|
||||
// 资料行(标签 + 值)
|
||||
const rowX = fx + 44;
|
||||
const kvB = (label, value, yy) => {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = '25px sans-serif';
|
||||
ctx.fillStyle = '#9A9FB5';
|
||||
ctx.fillText(label, rowX, yy);
|
||||
ctx.font = '600 26px sans-serif';
|
||||
ctx.fillStyle = '#3E4A63';
|
||||
ctx.fillText(value || '—', rowX + 160, yy);
|
||||
};
|
||||
let ry = bY + 198;
|
||||
kvB('主人', c.owner, ry); ry += 46;
|
||||
kvB('联系电话', c.phone || '未验证', ry); ry += 46;
|
||||
kvB('签发机构', c.org || '肉垫计划', ry); ry += 46;
|
||||
kvB('有效期限', c.validity || '永久有效', ry);
|
||||
|
||||
// 官方认证印章(装饰):淡棕红、旋一点角度,像盖上去的
|
||||
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;
|
||||
// 小程序码(右下角,直接落在淡紫底上)
|
||||
const qrD = 114, qrX = fx + cardW2 - 44 - qrD, qrY = bY + bH - 30 - qrD;
|
||||
ctx.fillStyle = '#fff';
|
||||
this.roundRect(ctx, qrX, qrY, qrD, qrD, 12);
|
||||
this.roundRect(ctx, qrX, qrY, qrD, qrD, 14);
|
||||
ctx.fill();
|
||||
if (qr) {
|
||||
ctx.drawImage(qr, qrX + 8, qrY + 8, qrD - 16, qrD - 16);
|
||||
ctx.drawImage(qr, qrX + 9, qrY + 9, qrD - 18, qrD - 18);
|
||||
} else {
|
||||
ctx.fillStyle = '#B5A99D';
|
||||
ctx.font = '20px sans-serif';
|
||||
ctx.fillStyle = '#A7ABC4';
|
||||
ctx.font = '18px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('小程序码', qrX + qrD / 2, qrY + qrD / 2 - 4);
|
||||
ctx.fillText('待发布', qrX + qrD / 2, qrY + qrD / 2 + 26);
|
||||
ctx.fillText('小程序码', qrX + qrD / 2, qrY + qrD / 2 - 2);
|
||||
ctx.fillText('待发布', qrX + qrD / 2, qrY + qrD / 2 + 24);
|
||||
}
|
||||
// 扫码说明(左侧,和小程序码同高)
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#8A90AE';
|
||||
ctx.font = '22px sans-serif';
|
||||
ctx.fillText('微信扫一扫', fx + 44, qrY + qrD / 2 - 6);
|
||||
ctx.fillText('查看' + (c.name || '') + '的身份信息', fx + 44, qrY + qrD / 2 + 28);
|
||||
|
||||
// 背面印章(右上角)
|
||||
this.drawStamp(ctx, fx + cardW2 - 84, bY + 92);
|
||||
|
||||
// 只导出到实际内容底部,避免卡片下面拖一条空白
|
||||
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' }),
|
||||
@@ -285,6 +278,110 @@ Page({
|
||||
ctx.closePath();
|
||||
},
|
||||
|
||||
// 逐字加字距地画一行(canvas 的 letterSpacing 各端支持不一,手动排最稳)
|
||||
spacedText(ctx, text, x, y, gap) {
|
||||
let cx = x;
|
||||
for (const ch of text) {
|
||||
ctx.fillText(ch, cx, y);
|
||||
cx += ctx.measureText(ch).width + gap;
|
||||
}
|
||||
},
|
||||
|
||||
// 五角星路径(调用方负责 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;
|
||||
|
||||
@@ -2,16 +2,31 @@
|
||||
|
||||
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||
<view class="page-body ic-body">
|
||||
<!-- 手机号门槛:没验证不生成,先唤起微信取号 -->
|
||||
<view wx:if="{{needPhone}}" class="ic-gate">
|
||||
<view class="ic-gate-ic"><pt-icon name="user" size="{{72}}"></pt-icon></view>
|
||||
<view class="ic-gate-t">生成身份卡需要手机号</view>
|
||||
<view class="ic-gate-p">身份卡背面的「联系电话」用于走失时联系你,验证后才能生成</view>
|
||||
|
||||
<block wx:if="{{!manualMode}}">
|
||||
<button wx:if="{{!phoneRefused}}" class="btn btn-primary btn-block ic-gate-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhone">用微信验证手机号</button>
|
||||
<view wx:else class="ic-gate-refuse">没有手机号,无法生成身份卡</view>
|
||||
<view class="ic-gate-manual" bindtap="showManual">唤不起来?手动填写手机号</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<input class="input ic-gate-input" type="number" maxlength="11" placeholder="输入 11 位手机号" placeholder-class="placeholder" value="{{manualPhone}}" bindinput="onManualPhone"/>
|
||||
<button class="btn btn-primary btn-block ic-gate-btn" bindtap="submitManual">保存并生成</button>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 画好的卡当成图片展示。canvas 本身是离屏的(挪到屏幕外),只用来生成图 -->
|
||||
<block wx:else>
|
||||
<view class="ic-stage">
|
||||
<image wx:if="{{cardImg}}" class="ic-img" src="{{cardImg}}" mode="widthFix" show-menu-by-longpress="{{true}}"></image>
|
||||
<view wx:else class="ic-loading">正在生成身份卡…</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!qrReady}}" class="ic-tip">
|
||||
小程序码要在小程序发布后才会出现,现在先放一个占位。
|
||||
</view>
|
||||
|
||||
<view class="ic-actions">
|
||||
<view class="btn btn-soft ic-btn" bindtap="onSave">
|
||||
<pt-icon name="photo" size="{{32}}"></pt-icon>下载到相册
|
||||
@@ -20,6 +35,7 @@
|
||||
<pt-icon name="share" size="{{32}}"></pt-icon>分享给好友
|
||||
</button>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
@@ -18,4 +18,21 @@ button.ic-btn{padding:0;line-height:normal}
|
||||
button.ic-btn::after{border:none}
|
||||
|
||||
/* 离屏 canvas:挪出可视区,只用来生成图 */
|
||||
.ic-canvas{position:fixed;left:-9999rpx;top:0;width:620px;height:760px}
|
||||
.ic-canvas{position:fixed;left:-9999rpx;top:0;width:620px;height:1480px}
|
||||
|
||||
/* 手机号门槛卡 */
|
||||
.ic-gate{
|
||||
background:#fff;border-radius:var(--r-lg);box-shadow:var(--sd-1);
|
||||
padding:var(--sp-6) var(--sp-5);margin-top:var(--sp-5);text-align:center;
|
||||
}
|
||||
.ic-gate-ic{
|
||||
width:140rpx;height:140rpx;margin:0 auto var(--sp-4);border-radius:50%;
|
||||
background:var(--primary-soft);color:var(--primary);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
}
|
||||
.ic-gate-t{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)}
|
||||
.ic-gate-p{font-size:var(--fs-sm);color:var(--muted);line-height:1.6;margin:var(--sp-2) 0 var(--sp-5)}
|
||||
.ic-gate-refuse{color:var(--red-ink);font-weight:var(--fw-b);font-size:var(--fs-md)}
|
||||
.ic-gate-btn{margin-top:var(--sp-2)}
|
||||
.ic-gate-manual{margin-top:var(--sp-4);color:var(--muted);font-size:var(--fs-sm);text-decoration:underline}
|
||||
.ic-gate-input{margin-bottom:var(--sp-3);text-align:center}
|
||||
|
||||
@@ -78,9 +78,6 @@
|
||||
<view wx:for="{{personaTags}}" wx:key="*this" class="option {{personaSel[item] ? 'selected' : ''}}"
|
||||
data-v="{{item}}" bindtap="togglePersona">{{item}}</view>
|
||||
</view></view>
|
||||
<view class="field"><label>过敏情况</label>
|
||||
<input class="input" placeholder="没有就填「无」" placeholder-class="placeholder"
|
||||
value="{{form.allergy}}" maxlength="32" bindinput="onAllergy"/></view>
|
||||
<view class="field"><label>备注(身份卡背面显示)</label>
|
||||
<textarea class="textarea" placeholder="如:肠胃敏感,请勿喂陌生食物" placeholder-class="placeholder"
|
||||
value="{{form.notes}}" maxlength="80" bindinput="onNotes"></textarea></view>
|
||||
|
||||
@@ -42,6 +42,7 @@ const api = {
|
||||
|
||||
// 用户
|
||||
getProfile: () => request({ url: '/api/user/profile' }),
|
||||
bindPhone: (code) => request({ url: '/api/user/phone', method: 'POST', data: { code } }),
|
||||
updateProfile: (body) => request({ url: '/api/user/profile', method: 'PUT', data: body }),
|
||||
|
||||
// 用户
|
||||
|
||||
Reference in New Issue
Block a user