| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 |
- /**
- * 名片海报生成工具
- * 使用 Canvas 2D 绘制名片海报
- * 名片底部添加名片码(二维码)
- */
- const W = 750
- const H = 820
- 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 = 700
- 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 }
|