Compare commits

...
10 Commits
Author SHA1 Message Date
BlizzardandClaude Opus 5 51ead8f411 style(fe): 身份卡只留截图字段 — 去掉性格标签,高度压到 cardH=510
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:41:38 +08:00
BlizzardandClaude Opus 5 1544151e5c style(fe): 身份卡降到 cardH=540,反面去掉备注行并压紧版式
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:39:09 +08:00
BlizzardandClaude Opus 5 c07ed03539 style(fe): 身份卡正反两张等高(都用 cardH=630),正面中间内容垂直居中
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:37:17 +08:00
BlizzardandClaude Opus 5 473c9f9200 style(fe): 照护卡改证件版式 — 大字「X星人地球移民/居民身份卡」左对齐
- 去掉蓝色标题条,照护卡整体换淡紫底
- 标题两行左对齐:小字带字距「汪星人/喵星人/宠星人地球移民」+ 大字「居民身份卡」
- 按物种动态取字:狗→汪星人、猫→喵星人、其它→宠星人
- 发证机构→签发机构、有效期→有效期限;小程序码落到右下角淡紫底上
- 新增 spacedText 逐字字距工具

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:35:11 +08:00
BlizzardandClaude Opus 5 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
BlizzardandClaude Opus 5 4e3859227e style(fe): 身份卡再精简 — 去掉 logo/徽章/图标 emoji 和多余招呼语
- 正面去掉「宠星人」logo、「宠物身份卡」盾牌徽章、爪印水印,头像上移
- 照护卡标题去掉 🌿,各行去掉 👤📞📝🏛📅 图标只留文字
- 走失框去掉「🔔 走失时请联系主人」标题和「或联系主人」,只留扫码说明
- 去掉页面底部「小程序码发布后出现」占位提示

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:23:42 +08:00
BlizzardandClaude Opus 5 d9a80d7f8a style(fe): 身份卡按参考图收拾 — 去标题/正反面标签/疫苗,卡高按内容自适应
- 去掉顶部「宠物身份卡」大标题和「· 正面 ·/· 背面 ·」灰字,只留两张卡
- 正面卡高度按性格/介绍有无动态算,消掉中间大块空白
- 性格标签为空时整段不画
- 照护卡去掉「疫苗情况」行
- 导出只裁到实际内容底部,卡片下面不再拖空白

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:15:45 +08:00
BlizzardandClaude Opus 5 747e7c9d3a feat(fe): 身份卡手机号门槛加「手动填写」兜底
getPhoneNumber 需企业认证 + 手机号验证权限,个人/未认证小程序唤不起。
门槛卡加「手动填写手机号」入口:填 11 位号 → updateProfile 存库 → 重取卡生成。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:52:31 +08:00
BlizzardandClaude Opus 5 586875f8c5 feat: 身份卡改上下两张卡(正面/背面) + 手机号硬门槛
- canvas 重画成上下两张:正面(暖黄,头像/名字性别/性别品种毛色年龄/性格
  彩色标签/介绍/物ID+一起生活天数)、背面(蓝色照护信息条 + 主人/电话/疫苗/
  备注/发证机构/有效期 + 走失联系框&小程序码)
- 生成前查手机号:没有则唤起 getPhoneNumber 组件,验证成功才生成;
  拒绝则提示「无法生成」不出卡
- IDCard 增 age/days;ic-canvas 高度改 1480 否则导出被裁

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:48:39 +08:00
BlizzardandClaude Opus 5 15adfb76ac feat(fe): 身份卡重绘成正面+照护信息+证件三段式 + 手机号验证
- 正面:头像/名字/物种性别 + 品种·毛色 / 出生·星座 / 体重 + 性格标签chips
- 照护信息:主人 / 联系电话 / 疫苗情况 / 备注(去掉驱虫、过敏)
- 证件区:居民身份卡 + 身份ID / 发证机构 / 有效期 + 小程序码
- 未验证手机号时页面出现「用微信验证手机号」(getPhoneNumber),
  拿到 code 换手机号后重取卡重画;表单去掉过敏输入

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:28:26 +08:00
6 changed files with 314 additions and 177 deletions
+11 -2
View File
@@ -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
View File
@@ -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;
+20 -4
View File
@@ -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 -1
View File
@@ -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}
-3
View File
@@ -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>
+1
View File
@@ -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 }),
// 用户