@@ -4,7 +4,7 @@ const { toastErr } = require('../../utils/ui.js');
// 卡片画布尺寸(逻辑像素)。真实导出会按 dpr 放大
const W = 620 ;
const H = 812 ;
const H = 1000 ;
// 在 canvas 2d 上加载一张图(远程或本地),resolve 出可直接 drawImage 的 image 对象。
// 加载失败 resolve(null)——头像或小程序码缺一张不该让整张卡画不出来
@@ -83,46 +83,37 @@ Page({
const pad = 30 ;
const cardW = W - pad * 2 ;
// ── 上半张 :资料区(蓝紫 渐变)──
const topH = 430 ;
const g1 = ctx . createLinearGradient ( pad , pad , W - pad , topH ) ;
// ═══ 正面 :资料区(暖粉 渐变)═══
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 , pad , cardW , topH - pad , 28 ) ;
this . roundRect ( ctx , pad , s1Y , cardW , s1H , 28 ) ;
ctx . fillStyle = g1 ;
ctx . fill ( ) ;
// 肉垫水印:卡内几枚淡淡的爪印,裁剪在圆角卡里
// 爪印水印
ctx . save ( ) ;
this . roundRect ( ctx , pad , pad , cardW , topH - pad , 28 ) ;
this . roundRect ( ctx , pad , s1Y , cardW , s1H , 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 . font = '130px sans-serif' ;
ctx . fillText ( '🐾' , W - pad - 80 , s1Y + 100 ) ;
ctx . restore ( ) ;
ctx . globalAlpha = 1 ;
ctx . textBaseline = 'alphabetic' ;
// 头像圆(描一圈白边更像证件照)
const avX = pad + 34 , avY = pad + 34 , avD = 120 ;
// 头像圆 + 白边
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 . 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 ) ;
@@ -134,27 +125,25 @@ 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 ;
let y = pad + 52 ;
const line = ( label , value ) => {
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 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' ;
@@ -162,95 +151,86 @@ Page({
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 . 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 + 62 , y ) ;
ctx . fillText ( v2 || '—' , x2 + 66 , y ) ;
}
y += 46 ;
y += 44 ;
} ;
line ( '姓名 ' , c . name ) ;
pair ( '性别 ' , c . gender , '品种 ' , c . breed ) ;
line ( '出生 ' , c . birthday ) ;
pair ( '星座' , c . zodiac , '体重' , c . weight ) ;
line ( '介绍' , c . intro ) ;
pair ( '品种' , c . breed , '毛色 ', c . color ) ;
pair ( '出生 ' , c . birthday , '星座 ' , c . zodiac ) ;
pair ( '体重 ' , c . weight , '' , '' ) ;
// 身份 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 ( [ ] ) ;
ctx . textAlign = 'left' ;
ctx . font = '22px sans-serif ' ;
ctx . fillStyle = '#9A8F82 ' ;
ctx . fillText ( '身份 ID' , pad + 34 , idY + 34 ) ;
ctx . font = '600 26px sans-serif' ;
ctx . fillStyle = '#2D2925' ;
ctx . fillText ( c . id _no || '' , pad + 34 + 90 , idY + 34 ) ;
// 性格标签 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 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 ;
// ═══ 照护信息 ═══
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 , botY + 60 ) ;
ctx . fillText ( '宠 星 人 地 球 移 民' , pad + 40 , s3Y + 50 ) ;
ctx . fillStyle = '#4A5878' ;
ctx . font = '700 52 px sans-serif' ;
ctx . fillText ( '居民身份卡' , pad + 40 , botY + 128 ) ;
ctx . font = '700 48 px sans-serif' ;
ctx . fillText ( '居民身份卡' , pad + 40 , s3Y + 112 ) ;
ctx . fillStyle = '#8A93A8' ;
ctx . font = '22px sans-serif' ;
ctx . fillText ( '签发机构 肉垫计划' , pad + 40 , botY + 180 ) ;
ctx . fillText ( '有效期限 ' + ( c . validity || '永久有效 ' ) , pad + 40 , botY + 214 ) ;
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 ) ;
// 官方认证印章(装饰):淡棕红、旋一点角度,像盖上去的
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 = 118 , qrX = W - pad - 36 - qrD , qrY = s3Y + s3H - 36 - qrD ;
ctx . fillStyle = '#fff' ;
this . roundRect ( ctx , qrX , qrY , qrD , qrD , 12 ) ;
ctx . fill ( ) ;
@@ -285,6 +265,29 @@ Page({
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 ;