/** * 名片海报生成工具 * 使用 Canvas 2D 绘制名片海报 * 名片底部添加名片码(二维码) */ const W = 750 const H = 780 const PADDING = 40 const roundRect = (ctx, x, y, w, h, r) => { ctx.beginPath() ctx.moveTo(x + r, y) ctx.lineTo(x + w - r, y) ctx.quadraticCurveTo(x + w, y, x + w, y + r) ctx.lineTo(x + w, y + h - r) ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h) ctx.lineTo(x + r, y + h) ctx.quadraticCurveTo(x, y + h, x, y + h - r) ctx.lineTo(x, y + r) ctx.quadraticCurveTo(x, y, x + r, y) ctx.closePath() } /** * 创建图片对象 - 必须使用 canvas.createImage() * 微信小程序 Canvas 2D 中不能用 uni.createImage() */ const createCanvasImage = (canvas, src) => { return new Promise((resolve) => { if (!src) return resolve(null) const img = canvas.createImage() img.onload = () => resolve(img) img.onerror = () => { console.warn('图片加载失败:', src) resolve(null) } img.src = src }) } const downloadImage = async (url) => { try { if (!url || !url.startsWith('http')) return url const res = await uni.downloadFile({ url }) if (res.statusCode === 200) return res.tempFilePath return null } catch { return null } } /** * 将图片资源转换为Canvas可用的临时路径 */ const resolveImageSrc = async (src) => { if (!src) return null if (src.startsWith('http')) { try { const res = await uni.downloadFile({ url: src }) if (res.statusCode === 200) return res.tempFilePath return null } catch { return null } } if (src.startsWith('/') || src.startsWith('.')) { try { const res = await uni.getImageInfo({ src }) return res.path } catch { return null } } return src } /** * base64 二维码转临时文件路径 */ const qrBase64ToTempFile = async (base64Data) => { if (!base64Data) return null return new Promise((resolve) => { try { const pureBase64 = base64Data.replace(/^data:image\/\w+;base64,/, '') const fileName = `${Date.now()}_qrcode.png` const filePath = `${wx.env.USER_DATA_PATH}/${fileName}` const fs = uni.getFileSystemManager() fs.writeFile({ filePath, data: pureBase64, encoding: 'base64', success: () => resolve(filePath), fail: () => resolve(null) }) } catch { resolve(null) } }) } const drawCircleAvatar = (ctx, img, cx, cy, r) => { ctx.save() ctx.beginPath() ctx.arc(cx, cy, r, 0, Math.PI * 2) ctx.clip() if (img) ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2) ctx.restore() } const drawAvatarFallback = (ctx, cardInfo, cx, cy, r) => { ctx.save() ctx.beginPath() ctx.arc(cx, cy, r, 0, Math.PI * 2) ctx.fillStyle = '#4080FF' ctx.fill() ctx.strokeStyle = 'rgba(255,255,255,0.6)' ctx.lineWidth = 4 ctx.stroke() const initial = cardInfo.nickName ? cardInfo.nickName.charAt(0) : '?' ctx.fillStyle = '#FFFFFF' ctx.font = `bold ${r}px sans-serif` ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(initial, cx, cy) ctx.restore() } const drawContactRow = (ctx, emoji, text, x, y) => { if (!text) return ctx.save() ctx.beginPath() ctx.arc(x + 20, y + 20, 20, 0, Math.PI * 2) ctx.fillStyle = 'rgba(64, 128, 255, 0.08)' ctx.fill() ctx.restore() ctx.font = '22px sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(emoji, x + 20, y + 20) ctx.font = '26px sans-serif' ctx.textAlign = 'left' ctx.textBaseline = 'middle' ctx.fillStyle = '#555555' const textMaxWidth = 480 const textX = x + 52 if (ctx.measureText(text).width > textMaxWidth) { ctx.textBaseline = 'top' const lineY = y + 10 fillTextWrap(ctx, text, textX, lineY, textMaxWidth, 40, 'left') } else { ctx.fillText(text, textX, y + 20) } } const fillTextWrap = (ctx, text, x, y, maxWidth, lineHeight, align = 'left') => { if (!text) return y ctx.textAlign = align ctx.textBaseline = 'top' const chars = text.split('') let line = '', cy = y for (let i = 0; i < chars.length; i++) { const test = line + chars[i] if (ctx.measureText(test).width > maxWidth && i > 0) { ctx.fillText(line, x, cy) line = chars[i] cy += lineHeight } else { line = test } } ctx.fillText(line, x, cy) return cy + lineHeight } export const generateCardPoster = async (cardInfo = {}, qrInfo = {}) => { return new Promise((resolve, reject) => { const query = uni.createSelectorQuery() query.select('#posterCanvas') .fields({ node: true, size: true }) .exec(async (res) => { if (!res || !res[0]) { reject(new Error('Canvas 节点未找到')) return } const canvas = res[0].node const ctx = canvas.getContext('2d') const dpr = uni.getSystemInfoSync().pixelRatio || 2 canvas.width = W * dpr canvas.height = H * dpr ctx.scale(dpr, dpr) // ===== 1. 渐变背景 ===== const bgGrad = ctx.createLinearGradient(0, 0, 0, H) bgGrad.addColorStop(0, '#4A90E2') bgGrad.addColorStop(0.5, '#6FB3F2') bgGrad.addColorStop(1, '#B0E0E6') ctx.fillStyle = bgGrad ctx.fillRect(0, 0, W, H) // ===== 2. 装饰圆点 ===== ctx.fillStyle = 'rgba(255,255,255,0.10)' ctx.beginPath(); ctx.arc(620, 160, 100, 0, Math.PI * 2); ctx.fill() ctx.beginPath(); ctx.arc(680, 320, 60, 0, Math.PI * 2); ctx.fill() ctx.beginPath(); ctx.arc(-20, 640, 80, 0, Math.PI * 2); ctx.fill() // ===== 3. 白色卡片 ===== const cardX = PADDING, cardY = 60 const cardW = W - PADDING * 2, cardH = 760 ctx.save() ctx.shadowColor = 'rgba(0,0,0,0.08)' ctx.shadowBlur = 24 ctx.shadowOffsetY = 6 ctx.fillStyle = '#FFFFFF' roundRect(ctx, cardX, cardY, cardW, cardH, 24) ctx.fill() ctx.restore() // ===== 4. 内部名片卡片 ===== const innerX = cardX + 32 const innerY = cardY + 32 const innerW = cardW - 64 const innerH = cardH - 64 ctx.save() ctx.shadowColor = 'rgba(0,0,0,0.05)' ctx.shadowBlur = 16 ctx.shadowOffsetY = 3 ctx.fillStyle = '#FFFFFF' roundRect(ctx, innerX, innerY, innerW, innerH, 16) ctx.fill() ctx.restore() // 背景图 try { const bgSrc = await resolveImageSrc('/static/image/home/usecard-bg.png') if (bgSrc) { const cardBg = await createCanvasImage(canvas, bgSrc) if (cardBg) { ctx.save() roundRect(ctx, innerX, innerY, innerW, innerH, 16) ctx.clip() ctx.drawImage(cardBg, innerX, innerY, innerW, innerH) ctx.restore() } } } catch (e) { console.warn('加载名片背景图失败:', e) } // ===== 5. 头像(右上角)===== const avatarCx = innerX + innerW - 100 const avatarCy = innerY + 70 const avatarR = 60 ctx.save() ctx.beginPath() ctx.arc(avatarCx, avatarCy, avatarR + 4, 0, Math.PI * 2) ctx.fillStyle = 'rgba(255,255,255,0.5)' ctx.fill() ctx.restore() let avatarImg = null if (cardInfo.avatar) { const localPath = await downloadImage(cardInfo.avatar) avatarImg = await createCanvasImage(canvas, localPath) } if (avatarImg) { drawCircleAvatar(ctx, avatarImg, avatarCx, avatarCy, avatarR) } else { drawAvatarFallback(ctx, cardInfo, avatarCx, avatarCy, avatarR) } // ===== 6. 姓名 + 职位 + 公司 ===== const nameX = innerX + 44 const nameY = innerY + 50 ctx.fillStyle = '#202020' ctx.font = 'bold 44px sans-serif' ctx.textAlign = 'left' ctx.textBaseline = 'top' const name = cardInfo.nickName || '用户' ctx.fillText(name, nameX, nameY) const nameW = ctx.measureText(name).width if (cardInfo.postName) { const tagX = nameX + nameW + 14 ctx.font = '22px sans-serif' const tagTextW = ctx.measureText(cardInfo.postName).width const tagW = tagTextW + 26 ctx.fillStyle = 'rgba(68, 110, 255, 0.10)' roundRect(ctx, tagX, nameY + 6, tagW, 34, 8) ctx.fill() ctx.fillStyle = '#446EFF' ctx.textAlign = 'left' ctx.textBaseline = 'top' ctx.fillText(cardInfo.postName, tagX + 13, nameY + 11) } // 公司名称 ctx.fillStyle = '#666666' ctx.font = '26px sans-serif' ctx.textAlign = 'left' ctx.textBaseline = 'top' const companyMaxW = innerW - 200 const companyName = cardInfo.companyName || '公司名称' const companyX = innerX + 44 const companyY = nameY + 60 const companyEndY = fillTextWrap(ctx, companyName, companyX, companyY, companyMaxW, 36, 'left') // ===== 7. 联系方式 ===== const contactY = Math.max(companyEndY + 24, innerY + 160) const contactX = innerX + 44 const contactGap = 60 ctx.strokeStyle = '#EEEEEE' ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(contactX, contactY) ctx.lineTo(innerX + innerW - 44, contactY) ctx.stroke() drawContactRow(ctx, '📞', cardInfo.phonenumber, contactX, contactY + 20) drawContactRow(ctx, '✉', cardInfo.email, contactX, contactY + 20 + contactGap) drawContactRow(ctx, '📍', cardInfo.companyAddress, contactX, contactY + 20 + contactGap * 2) // ===== 8. 底部名片码 ===== const qrBoxSize = 130 const cardInnerBottom = cardY + cardH - 32 const qrBoxX = innerX + innerW - qrBoxSize - 44 const qrBoxY = cardInnerBottom - qrBoxSize - 44 // 灰色圆角背景框 ctx.save() ctx.fillStyle = '#F5F7FA' roundRect(ctx, qrBoxX - 8, qrBoxY - 8, qrBoxSize + 16, qrBoxSize + 16, 12) ctx.fill() ctx.restore() // 加载二维码图片(使用 canvas.createImage()) try { const qrLocalPath = await qrBase64ToTempFile(qrInfo.image) if (qrLocalPath) { const qrImg = await createCanvasImage(canvas, qrLocalPath) if (qrImg) { ctx.drawImage(qrImg, qrBoxX, qrBoxY, qrBoxSize, qrBoxSize) } } } catch (e) { console.warn('加载名片码失败:', e) } // ===== 9. 导出 ===== setTimeout(() => { uni.canvasToTempFilePath({ canvas, success: (res) => resolve(res.tempFilePath), fail: (err) => reject(err) }) }, 600) }) }) } export const savePosterToAlbum = (tempFilePath) => { return new Promise((resolve, reject) => { uni.authorize({ scope: 'scope.writePhotosAlbum', success: () => { uni.saveImageToPhotosAlbum({ filePath: tempFilePath, success: () => resolve(), fail: (err) => reject(err) }) }, fail: () => { uni.showModal({ title: '提示', content: '需要授权相册权限才能保存名片', confirmText: '去设置', success: (res) => { if (res.confirm) uni.openSetting({}) reject(new Error('未授权')) } }) } }) }) } export default { generateCardPoster, savePosterToAlbum }