|
@@ -1,306 +1,301 @@
|
|
|
/**
|
|
/**
|
|
|
* 名片海报生成工具
|
|
* 名片海报生成工具
|
|
|
- * 使用 Canvas 手动绘制名片
|
|
|
|
|
|
|
+ * 使用 Canvas 2D 绘制名片海报
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
|
-/**
|
|
|
|
|
- * 生成名片海报
|
|
|
|
|
- * @param {Object} options - 配置选项
|
|
|
|
|
- * @param {Object} options.cardInfo - 名片信息
|
|
|
|
|
- * @returns {Promise<String>} - 生成的图片临时路径
|
|
|
|
|
- */
|
|
|
|
|
-export const generateCardPoster = async (cardInfo) => {
|
|
|
|
|
- return new Promise((resolve, reject) => {
|
|
|
|
|
- try {
|
|
|
|
|
- // 创建离屏 canvas
|
|
|
|
|
- const query = uni.createSelectorQuery()
|
|
|
|
|
- query.select('#posterCanvas')
|
|
|
|
|
- .fields({
|
|
|
|
|
- node: true,
|
|
|
|
|
- size: true
|
|
|
|
|
- })
|
|
|
|
|
- .exec(async (res) => {
|
|
|
|
|
- if (!res[0]) {
|
|
|
|
|
- reject(new Error('Canvas 节点未找到'))
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const canvas = res[0].node
|
|
|
|
|
- const ctx = canvas.getContext('2d')
|
|
|
|
|
-
|
|
|
|
|
- // 设置 canvas 尺寸(高分辨率)
|
|
|
|
|
- const width = 750
|
|
|
|
|
- const height = 800
|
|
|
|
|
- const dpr = uni.getSystemInfoSync().pixelRatio
|
|
|
|
|
-
|
|
|
|
|
- canvas.width = width * dpr
|
|
|
|
|
- canvas.height = height * dpr
|
|
|
|
|
- ctx.scale(dpr, dpr)
|
|
|
|
|
-
|
|
|
|
|
- // 1. 绘制背景渐变
|
|
|
|
|
- const gradient = ctx.createLinearGradient(0, 0, 0, height)
|
|
|
|
|
- gradient.addColorStop(0, '#4A90E2')
|
|
|
|
|
- gradient.addColorStop(0.5, '#6FB3F2')
|
|
|
|
|
- gradient.addColorStop(1, '#87CEEB')
|
|
|
|
|
- ctx.fillStyle = gradient
|
|
|
|
|
- ctx.fillRect(0, 0, width, height)
|
|
|
|
|
-
|
|
|
|
|
- // 2. 绘制白色卡片背景
|
|
|
|
|
- ctx.fillStyle = '#ffffff'
|
|
|
|
|
- drawRoundRect(ctx, 40, 40, 670, 720, 24)
|
|
|
|
|
- ctx.fill()
|
|
|
|
|
-
|
|
|
|
|
- // 3. 绘制头像
|
|
|
|
|
- const avatarPath = cardInfo.avatar ||
|
|
|
|
|
- '/static/image/public/avatar-default.png'
|
|
|
|
|
- await drawAvatar(ctx, avatarPath, 375, 160, 160)
|
|
|
|
|
-
|
|
|
|
|
- // 4. 绘制姓名和职位
|
|
|
|
|
- ctx.fillStyle = '#202020'
|
|
|
|
|
- ctx.font = 'bold 44px sans-serif'
|
|
|
|
|
- ctx.textAlign = 'center'
|
|
|
|
|
- ctx.fillText(cardInfo.nickName || '用户', width / 2, 380)
|
|
|
|
|
-
|
|
|
|
|
- // 职位标签背景
|
|
|
|
|
- ctx.fillStyle = 'rgba(68, 110, 255, 0.1)'
|
|
|
|
|
- drawRoundRect(ctx, (width - 180) / 2, 400, 180, 44, 8)
|
|
|
|
|
- ctx.fill()
|
|
|
|
|
-
|
|
|
|
|
- ctx.fillStyle = '#446eff'
|
|
|
|
|
- ctx.font = '26px sans-serif'
|
|
|
|
|
- ctx.textAlign = 'center'
|
|
|
|
|
- ctx.fillText(cardInfo.postName || '职位', width / 2, 430)
|
|
|
|
|
-
|
|
|
|
|
- // 5. 绘制公司名称
|
|
|
|
|
- ctx.fillStyle = '#666666'
|
|
|
|
|
- ctx.font = '30px sans-serif'
|
|
|
|
|
- ctx.textAlign = 'center'
|
|
|
|
|
- wrapText(ctx, cardInfo.companyName || '公司名称', width / 2, 480, 500, 36)
|
|
|
|
|
-
|
|
|
|
|
- // 6. 绘制联系方式
|
|
|
|
|
- const contactY = 560
|
|
|
|
|
- const contactGap = 65
|
|
|
|
|
- ctx.textAlign = 'left'
|
|
|
|
|
- // 电话
|
|
|
|
|
- if (cardInfo.phonenumber) {
|
|
|
|
|
- await drawIconText(ctx, 'phone', cardInfo.phonenumber, width / 2 - 130,
|
|
|
|
|
- contactY)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+const W = 750
|
|
|
|
|
+const H = 780
|
|
|
|
|
+const PADDING = 40
|
|
|
|
|
|
|
|
- // 邮箱
|
|
|
|
|
- if (cardInfo.email) {
|
|
|
|
|
- await drawIconText(ctx, 'email', cardInfo.email, width / 2 - 130,
|
|
|
|
|
- contactY + contactGap)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 地址
|
|
|
|
|
- if (cardInfo.companyAddress) {
|
|
|
|
|
- await drawIconText(ctx, 'location', cardInfo.companyAddress, width / 2 -
|
|
|
|
|
- 130, contactY + contactGap * 2, )
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 导出图片
|
|
|
|
|
- setTimeout(() => {
|
|
|
|
|
- uni.canvasToTempFilePath({
|
|
|
|
|
- canvas: canvas,
|
|
|
|
|
- success: (res) => {
|
|
|
|
|
- console.log('海报生成成功:', res.tempFilePath)
|
|
|
|
|
- resolve(res.tempFilePath)
|
|
|
|
|
- },
|
|
|
|
|
- fail: (err) => {
|
|
|
|
|
- console.error('海报导出失败:', err)
|
|
|
|
|
- reject(err)
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- }, 300)
|
|
|
|
|
- })
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('海报生成失败:', error)
|
|
|
|
|
- reject(error)
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 绘制圆角矩形(兼容微信小程序)
|
|
|
|
|
- */
|
|
|
|
|
-const drawRoundRect = (ctx, x, y, width, height, radius) => {
|
|
|
|
|
|
|
+const roundRect = (ctx, x, y, w, h, r) => {
|
|
|
ctx.beginPath()
|
|
ctx.beginPath()
|
|
|
- ctx.moveTo(x + radius, y)
|
|
|
|
|
- ctx.lineTo(x + width - radius, y)
|
|
|
|
|
- ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
|
|
|
|
|
- ctx.lineTo(x + width, y + height - radius)
|
|
|
|
|
- ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
|
|
|
|
|
- ctx.lineTo(x + radius, y + height)
|
|
|
|
|
- ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
|
|
|
|
|
- ctx.lineTo(x, y + radius)
|
|
|
|
|
- ctx.quadraticCurveTo(x, y, x + radius, y)
|
|
|
|
|
|
|
+ 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()
|
|
ctx.closePath()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/**
|
|
|
|
|
- * 绘制头像
|
|
|
|
|
- */
|
|
|
|
|
-const drawAvatar = async (ctx, avatarUrl, x, y, size) => {
|
|
|
|
|
|
|
+const loadImage = (src) => {
|
|
|
return new Promise((resolve) => {
|
|
return new Promise((resolve) => {
|
|
|
- if (avatarUrl.startsWith('http')) {
|
|
|
|
|
- // 网络图片
|
|
|
|
|
- downloadAndDraw(ctx, avatarUrl, x, y, size, resolve)
|
|
|
|
|
- } else {
|
|
|
|
|
- drawCircularImage(ctx, avatarUrl, x, y, size, resolve)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (!src) return resolve(null)
|
|
|
|
|
+ const img = uni.createImage()
|
|
|
|
|
+ img.src = src
|
|
|
|
|
+ img.onload = () => resolve(img)
|
|
|
|
|
+ img.onerror = () => resolve(null)
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/**
|
|
|
|
|
- * 下载并绘制网络图片
|
|
|
|
|
- */
|
|
|
|
|
-const downloadAndDraw = async (ctx, url, x, y, size, resolve) => {
|
|
|
|
|
|
|
+const downloadImage = async (url) => {
|
|
|
try {
|
|
try {
|
|
|
- const res = await uni.downloadFile({
|
|
|
|
|
- url: url
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- if (res.statusCode === 200) {
|
|
|
|
|
- drawCircularImage(ctx, res.tempFilePath, x, y, size, resolve)
|
|
|
|
|
- } else {
|
|
|
|
|
- console.error('图片下载失败:', res.statusCode)
|
|
|
|
|
- resolve()
|
|
|
|
|
- }
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('下载图片异常:', error)
|
|
|
|
|
- resolve()
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ 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 }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/**
|
|
|
|
|
- * 绘制圆形图片
|
|
|
|
|
- */
|
|
|
|
|
-const drawCircularImage = (ctx, imageUrl, x, y, size, callback) => {
|
|
|
|
|
- const image = uni.createImage()
|
|
|
|
|
- image.src = imageUrl
|
|
|
|
|
- image.onload = () => {
|
|
|
|
|
- ctx.save()
|
|
|
|
|
- ctx.beginPath()
|
|
|
|
|
- ctx.arc(x, y, size / 2, 0, 2 * Math.PI)
|
|
|
|
|
- ctx.clip()
|
|
|
|
|
- ctx.drawImage(image, x - size / 2, y - size / 2, size, size)
|
|
|
|
|
- ctx.restore()
|
|
|
|
|
- if (callback) callback()
|
|
|
|
|
- }
|
|
|
|
|
- image.onerror = (err) => {
|
|
|
|
|
- console.error('图片加载失败:', err)
|
|
|
|
|
- if (callback) callback()
|
|
|
|
|
- }
|
|
|
|
|
|
|
+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 drawIconText = async (ctx, type, text, x, y, maxWidth = 350) => {
|
|
|
|
|
- return new Promise((resolve) => {
|
|
|
|
|
- const iconSize = 32
|
|
|
|
|
- const iconY = y - iconSize / 2
|
|
|
|
|
-
|
|
|
|
|
- // 图标颜色
|
|
|
|
|
- const iconColors = {
|
|
|
|
|
- phone: '#4080FF',
|
|
|
|
|
- email: '#4080FF',
|
|
|
|
|
- location: '#4080FF'
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- ctx.fillStyle = iconColors[type] || '#4080FF'
|
|
|
|
|
-
|
|
|
|
|
- // 绘制图标
|
|
|
|
|
- const icons = {
|
|
|
|
|
- phone: '',
|
|
|
|
|
- email: '',
|
|
|
|
|
- location: ''
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- ctx.font = `${iconSize}px sans-serif`
|
|
|
|
|
- ctx.textAlign = 'center'
|
|
|
|
|
- ctx.fillText(icons[type] || '●', x - maxWidth / 2 + 30, iconY + iconSize / 2 + 10)
|
|
|
|
|
-
|
|
|
|
|
- // 绘制文字
|
|
|
|
|
- ctx.fillStyle = '#666666'
|
|
|
|
|
- ctx.font = '26px sans-serif'
|
|
|
|
|
- ctx.textAlign = 'left'
|
|
|
|
|
-
|
|
|
|
|
- // 文字截断处理
|
|
|
|
|
- let displayText = text
|
|
|
|
|
- if (text.length > 35) {
|
|
|
|
|
- displayText = text.substring(0, 33) + '...'
|
|
|
|
|
- }
|
|
|
|
|
- ctx.fillText(displayText, x - maxWidth / 2 + 70, y + 8)
|
|
|
|
|
-
|
|
|
|
|
- setTimeout(resolve, 50)
|
|
|
|
|
- })
|
|
|
|
|
|
|
+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 wrapText = (ctx, text, x, y, maxWidth, lineHeight) => {
|
|
|
|
|
|
|
+const drawContactRow = (ctx, emoji, text, x, y) => {
|
|
|
if (!text) return
|
|
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()
|
|
|
|
|
+ // Emoji
|
|
|
|
|
+ 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'
|
|
|
|
|
+ let display = text
|
|
|
|
|
+ if (text.length > 24) display = text.substring(0, 22) + '...'
|
|
|
|
|
+ ctx.fillText(display, x + 52, y + 20)
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- const words = text.split('')
|
|
|
|
|
- let line = ''
|
|
|
|
|
- let currentY = y
|
|
|
|
|
-
|
|
|
|
|
- for (let i = 0; i < words.length; i++) {
|
|
|
|
|
- const testLine = line + words[i]
|
|
|
|
|
- const metrics = ctx.measureText(testLine)
|
|
|
|
|
-
|
|
|
|
|
- if (metrics.width > maxWidth && i > 0) {
|
|
|
|
|
- ctx.fillText(line, x, currentY)
|
|
|
|
|
- line = words[i]
|
|
|
|
|
- currentY += lineHeight
|
|
|
|
|
|
|
+const fillTextWrap = (ctx, text, x, y, maxWidth, lineHeight) => {
|
|
|
|
|
+ if (!text) return y
|
|
|
|
|
+ ctx.textAlign = 'center'
|
|
|
|
|
+ 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 {
|
|
} else {
|
|
|
- line = testLine
|
|
|
|
|
|
|
+ line = test
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- ctx.fillText(line, x, currentY)
|
|
|
|
|
|
|
+ ctx.fillText(line, x, cy)
|
|
|
|
|
+ return cy + lineHeight
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/**
|
|
|
|
|
- * 保存海报到相册
|
|
|
|
|
- */
|
|
|
|
|
-export const savePosterToAlbum = async (tempFilePath) => {
|
|
|
|
|
|
|
+export const generateCardPoster = async (cardInfo = {}) => {
|
|
|
|
|
+ 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 = 660
|
|
|
|
|
+ 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 cardBg = await loadImage('/static/image/home/usecard-bg.png')
|
|
|
|
|
+ if (cardBg) {
|
|
|
|
|
+ ctx.save()
|
|
|
|
|
+ roundRect(ctx, innerX, innerY, innerW, innerH, 16)
|
|
|
|
|
+ ctx.clip()
|
|
|
|
|
+ ctx.drawImage(cardBg, innerX, innerY, innerW, innerH)
|
|
|
|
|
+ ctx.restore()
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (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 loadImage(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 - 160
|
|
|
|
|
+ const companyName = cardInfo.companyName || '公司名称'
|
|
|
|
|
+ // 如果公司名太长,手动截断
|
|
|
|
|
+ ctx.font = '26px sans-serif'
|
|
|
|
|
+ if (ctx.measureText(companyName).width > companyMaxW) {
|
|
|
|
|
+ let display = companyName
|
|
|
|
|
+ while (ctx.measureText(display + '...').width > companyMaxW && display.length > 0) {
|
|
|
|
|
+ display = display.substring(0, display.length - 1)
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.fillText(display + '...', innerX + 44, nameY + 60)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ctx.fillText(companyName, innerX + 44, nameY + 60)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 7. 联系方式 =====
|
|
|
|
|
+ const contactY = 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. 导出 =====
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ uni.canvasToTempFilePath({
|
|
|
|
|
+ canvas,
|
|
|
|
|
+ success: (res) => resolve(res.tempFilePath),
|
|
|
|
|
+ fail: (err) => reject(err)
|
|
|
|
|
+ })
|
|
|
|
|
+ }, 400)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export const savePosterToAlbum = (tempFilePath) => {
|
|
|
return new Promise((resolve, reject) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
uni.authorize({
|
|
uni.authorize({
|
|
|
scope: 'scope.writePhotosAlbum',
|
|
scope: 'scope.writePhotosAlbum',
|
|
|
- success: async () => {
|
|
|
|
|
|
|
+ success: () => {
|
|
|
uni.saveImageToPhotosAlbum({
|
|
uni.saveImageToPhotosAlbum({
|
|
|
filePath: tempFilePath,
|
|
filePath: tempFilePath,
|
|
|
success: () => resolve(),
|
|
success: () => resolve(),
|
|
|
fail: (err) => reject(err)
|
|
fail: (err) => reject(err)
|
|
|
})
|
|
})
|
|
|
},
|
|
},
|
|
|
- fail: (err) => {
|
|
|
|
|
|
|
+ fail: () => {
|
|
|
uni.showModal({
|
|
uni.showModal({
|
|
|
title: '提示',
|
|
title: '提示',
|
|
|
- content: '需要授权才能保存到相册',
|
|
|
|
|
|
|
+ content: '需要授权相册权限才能保存名片',
|
|
|
|
|
+ confirmText: '去设置',
|
|
|
success: (res) => {
|
|
success: (res) => {
|
|
|
- if (res.confirm) {
|
|
|
|
|
- uni.openSetting({
|
|
|
|
|
- success: (settingRes) => {
|
|
|
|
|
- if (settingRes.authSetting[
|
|
|
|
|
- 'scope.writePhotosAlbum'
|
|
|
|
|
- ]) {
|
|
|
|
|
- savePosterToAlbum(
|
|
|
|
|
- tempFilePath).then(
|
|
|
|
|
- resolve).catch(
|
|
|
|
|
- reject)
|
|
|
|
|
- } else {
|
|
|
|
|
- reject(new Error('用户拒绝授权'))
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- fail: (err) => reject(err)
|
|
|
|
|
- })
|
|
|
|
|
- } else {
|
|
|
|
|
- reject(new Error('用户取消授权'))
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (res.confirm) uni.openSetting({})
|
|
|
|
|
+ reject(new Error('未授权'))
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
@@ -308,7 +303,4 @@ export const savePosterToAlbum = async (tempFilePath) => {
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export default {
|
|
|
|
|
- generateCardPoster,
|
|
|
|
|
- savePosterToAlbum
|
|
|
|
|
-}
|
|
|
|
|
|
|
+export default { generateCardPoster, savePosterToAlbum }
|