poster.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. /**
  2. * 名片海报生成工具
  3. * 使用 Canvas 2D 绘制名片海报
  4. * 名片底部添加名片码(二维码)
  5. */
  6. const W = 750
  7. const H = 820
  8. const PADDING = 40
  9. const roundRect = (ctx, x, y, w, h, r) => {
  10. ctx.beginPath()
  11. ctx.moveTo(x + r, y)
  12. ctx.lineTo(x + w - r, y)
  13. ctx.quadraticCurveTo(x + w, y, x + w, y + r)
  14. ctx.lineTo(x + w, y + h - r)
  15. ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
  16. ctx.lineTo(x + r, y + h)
  17. ctx.quadraticCurveTo(x, y + h, x, y + h - r)
  18. ctx.lineTo(x, y + r)
  19. ctx.quadraticCurveTo(x, y, x + r, y)
  20. ctx.closePath()
  21. }
  22. /**
  23. * 创建图片对象 - 必须使用 canvas.createImage()
  24. * 微信小程序 Canvas 2D 中不能用 uni.createImage()
  25. */
  26. const createCanvasImage = (canvas, src) => {
  27. return new Promise((resolve) => {
  28. if (!src) return resolve(null)
  29. const img = canvas.createImage()
  30. img.onload = () => resolve(img)
  31. img.onerror = () => {
  32. console.warn('图片加载失败:', src)
  33. resolve(null)
  34. }
  35. img.src = src
  36. })
  37. }
  38. const downloadImage = async (url) => {
  39. try {
  40. if (!url || !url.startsWith('http')) return url
  41. const res = await uni.downloadFile({ url })
  42. if (res.statusCode === 200) return res.tempFilePath
  43. return null
  44. } catch { return null }
  45. }
  46. /**
  47. * 将图片资源转换为Canvas可用的临时路径
  48. */
  49. const resolveImageSrc = async (src) => {
  50. if (!src) return null
  51. if (src.startsWith('http')) {
  52. try {
  53. const res = await uni.downloadFile({ url: src })
  54. if (res.statusCode === 200) return res.tempFilePath
  55. return null
  56. } catch { return null }
  57. }
  58. if (src.startsWith('/') || src.startsWith('.')) {
  59. try {
  60. const res = await uni.getImageInfo({ src })
  61. return res.path
  62. } catch { return null }
  63. }
  64. return src
  65. }
  66. /**
  67. * base64 二维码转临时文件路径
  68. */
  69. const qrBase64ToTempFile = async (base64Data) => {
  70. if (!base64Data) return null
  71. return new Promise((resolve) => {
  72. try {
  73. const pureBase64 = base64Data.replace(/^data:image\/\w+;base64,/, '')
  74. const fileName = `${Date.now()}_qrcode.png`
  75. const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`
  76. const fs = uni.getFileSystemManager()
  77. fs.writeFile({
  78. filePath,
  79. data: pureBase64,
  80. encoding: 'base64',
  81. success: () => resolve(filePath),
  82. fail: () => resolve(null)
  83. })
  84. } catch {
  85. resolve(null)
  86. }
  87. })
  88. }
  89. const drawCircleAvatar = (ctx, img, cx, cy, r) => {
  90. ctx.save()
  91. ctx.beginPath()
  92. ctx.arc(cx, cy, r, 0, Math.PI * 2)
  93. ctx.clip()
  94. if (img) ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2)
  95. ctx.restore()
  96. }
  97. const drawAvatarFallback = (ctx, cardInfo, cx, cy, r) => {
  98. ctx.save()
  99. ctx.beginPath()
  100. ctx.arc(cx, cy, r, 0, Math.PI * 2)
  101. ctx.fillStyle = '#4080FF'
  102. ctx.fill()
  103. ctx.strokeStyle = 'rgba(255,255,255,0.6)'
  104. ctx.lineWidth = 4
  105. ctx.stroke()
  106. const initial = cardInfo.nickName ? cardInfo.nickName.charAt(0) : '?'
  107. ctx.fillStyle = '#FFFFFF'
  108. ctx.font = `bold ${r}px sans-serif`
  109. ctx.textAlign = 'center'
  110. ctx.textBaseline = 'middle'
  111. ctx.fillText(initial, cx, cy)
  112. ctx.restore()
  113. }
  114. const drawContactRow = (ctx, emoji, text, x, y) => {
  115. if (!text) return
  116. ctx.save()
  117. ctx.beginPath()
  118. ctx.arc(x + 20, y + 20, 20, 0, Math.PI * 2)
  119. ctx.fillStyle = 'rgba(64, 128, 255, 0.08)'
  120. ctx.fill()
  121. ctx.restore()
  122. ctx.font = '22px sans-serif'
  123. ctx.textAlign = 'center'
  124. ctx.textBaseline = 'middle'
  125. ctx.fillText(emoji, x + 20, y + 20)
  126. ctx.font = '26px sans-serif'
  127. ctx.textAlign = 'left'
  128. ctx.textBaseline = 'middle'
  129. ctx.fillStyle = '#555555'
  130. const textMaxWidth = 480
  131. const textX = x + 52
  132. if (ctx.measureText(text).width > textMaxWidth) {
  133. ctx.textBaseline = 'top'
  134. const lineY = y + 10
  135. fillTextWrap(ctx, text, textX, lineY, textMaxWidth, 40, 'left')
  136. } else {
  137. ctx.fillText(text, textX, y + 20)
  138. }
  139. }
  140. const fillTextWrap = (ctx, text, x, y, maxWidth, lineHeight, align = 'left') => {
  141. if (!text) return y
  142. ctx.textAlign = align
  143. ctx.textBaseline = 'top'
  144. const chars = text.split('')
  145. let line = '', cy = y
  146. for (let i = 0; i < chars.length; i++) {
  147. const test = line + chars[i]
  148. if (ctx.measureText(test).width > maxWidth && i > 0) {
  149. ctx.fillText(line, x, cy)
  150. line = chars[i]
  151. cy += lineHeight
  152. } else {
  153. line = test
  154. }
  155. }
  156. ctx.fillText(line, x, cy)
  157. return cy + lineHeight
  158. }
  159. export const generateCardPoster = async (cardInfo = {}, qrInfo = {}) => {
  160. return new Promise((resolve, reject) => {
  161. const query = uni.createSelectorQuery()
  162. query.select('#posterCanvas')
  163. .fields({ node: true, size: true })
  164. .exec(async (res) => {
  165. if (!res || !res[0]) {
  166. reject(new Error('Canvas 节点未找到'))
  167. return
  168. }
  169. const canvas = res[0].node
  170. const ctx = canvas.getContext('2d')
  171. const dpr = uni.getSystemInfoSync().pixelRatio || 2
  172. canvas.width = W * dpr
  173. canvas.height = H * dpr
  174. ctx.scale(dpr, dpr)
  175. // ===== 1. 渐变背景 =====
  176. const bgGrad = ctx.createLinearGradient(0, 0, 0, H)
  177. bgGrad.addColorStop(0, '#4A90E2')
  178. bgGrad.addColorStop(0.5, '#6FB3F2')
  179. bgGrad.addColorStop(1, '#B0E0E6')
  180. ctx.fillStyle = bgGrad
  181. ctx.fillRect(0, 0, W, H)
  182. // ===== 2. 装饰圆点 =====
  183. ctx.fillStyle = 'rgba(255,255,255,0.10)'
  184. ctx.beginPath(); ctx.arc(620, 160, 100, 0, Math.PI * 2); ctx.fill()
  185. ctx.beginPath(); ctx.arc(680, 320, 60, 0, Math.PI * 2); ctx.fill()
  186. ctx.beginPath(); ctx.arc(-20, 640, 80, 0, Math.PI * 2); ctx.fill()
  187. // ===== 3. 白色卡片 =====
  188. const cardX = PADDING, cardY = 60
  189. const cardW = W - PADDING * 2, cardH = 700
  190. ctx.save()
  191. ctx.shadowColor = 'rgba(0,0,0,0.08)'
  192. ctx.shadowBlur = 24
  193. ctx.shadowOffsetY = 6
  194. ctx.fillStyle = '#FFFFFF'
  195. roundRect(ctx, cardX, cardY, cardW, cardH, 24)
  196. ctx.fill()
  197. ctx.restore()
  198. // ===== 4. 内部名片卡片 =====
  199. const innerX = cardX + 32
  200. const innerY = cardY + 32
  201. const innerW = cardW - 64
  202. const innerH = cardH - 64
  203. ctx.save()
  204. ctx.shadowColor = 'rgba(0,0,0,0.05)'
  205. ctx.shadowBlur = 16
  206. ctx.shadowOffsetY = 3
  207. ctx.fillStyle = '#FFFFFF'
  208. roundRect(ctx, innerX, innerY, innerW, innerH, 16)
  209. ctx.fill()
  210. ctx.restore()
  211. // 背景图
  212. try {
  213. const bgSrc = await resolveImageSrc('/static/image/home/usecard-bg.png')
  214. if (bgSrc) {
  215. const cardBg = await createCanvasImage(canvas, bgSrc)
  216. if (cardBg) {
  217. ctx.save()
  218. roundRect(ctx, innerX, innerY, innerW, innerH, 16)
  219. ctx.clip()
  220. ctx.drawImage(cardBg, innerX, innerY, innerW, innerH)
  221. ctx.restore()
  222. }
  223. }
  224. } catch (e) {
  225. console.warn('加载名片背景图失败:', e)
  226. }
  227. // ===== 5. 头像(右上角)=====
  228. const avatarCx = innerX + innerW - 100
  229. const avatarCy = innerY + 70
  230. const avatarR = 60
  231. ctx.save()
  232. ctx.beginPath()
  233. ctx.arc(avatarCx, avatarCy, avatarR + 4, 0, Math.PI * 2)
  234. ctx.fillStyle = 'rgba(255,255,255,0.5)'
  235. ctx.fill()
  236. ctx.restore()
  237. let avatarImg = null
  238. if (cardInfo.avatar) {
  239. const localPath = await downloadImage(cardInfo.avatar)
  240. avatarImg = await createCanvasImage(canvas, localPath)
  241. }
  242. if (avatarImg) {
  243. drawCircleAvatar(ctx, avatarImg, avatarCx, avatarCy, avatarR)
  244. } else {
  245. drawAvatarFallback(ctx, cardInfo, avatarCx, avatarCy, avatarR)
  246. }
  247. // ===== 6. 姓名 + 职位 + 公司 =====
  248. const nameX = innerX + 44
  249. const nameY = innerY + 50
  250. ctx.fillStyle = '#202020'
  251. ctx.font = 'bold 44px sans-serif'
  252. ctx.textAlign = 'left'
  253. ctx.textBaseline = 'top'
  254. const name = cardInfo.nickName || '用户'
  255. ctx.fillText(name, nameX, nameY)
  256. const nameW = ctx.measureText(name).width
  257. if (cardInfo.postName) {
  258. const tagX = nameX + nameW + 14
  259. ctx.font = '22px sans-serif'
  260. const tagTextW = ctx.measureText(cardInfo.postName).width
  261. const tagW = tagTextW + 26
  262. ctx.fillStyle = 'rgba(68, 110, 255, 0.10)'
  263. roundRect(ctx, tagX, nameY + 6, tagW, 34, 8)
  264. ctx.fill()
  265. ctx.fillStyle = '#446EFF'
  266. ctx.textAlign = 'left'
  267. ctx.textBaseline = 'top'
  268. ctx.fillText(cardInfo.postName, tagX + 13, nameY + 11)
  269. }
  270. // 公司名称
  271. ctx.fillStyle = '#666666'
  272. ctx.font = '26px sans-serif'
  273. ctx.textAlign = 'left'
  274. ctx.textBaseline = 'top'
  275. const companyMaxW = innerW - 200
  276. const companyName = cardInfo.companyName || '公司名称'
  277. const companyX = innerX + 44
  278. const companyY = nameY + 60
  279. const companyEndY = fillTextWrap(ctx, companyName, companyX, companyY, companyMaxW, 36, 'left')
  280. // ===== 7. 联系方式 =====
  281. const contactY = Math.max(companyEndY + 24, innerY + 160)
  282. const contactX = innerX + 44
  283. const contactGap = 60
  284. ctx.strokeStyle = '#EEEEEE'
  285. ctx.lineWidth = 1
  286. ctx.beginPath()
  287. ctx.moveTo(contactX, contactY)
  288. ctx.lineTo(innerX + innerW - 44, contactY)
  289. ctx.stroke()
  290. drawContactRow(ctx, '📞', cardInfo.phonenumber, contactX, contactY + 20)
  291. drawContactRow(ctx, '✉', cardInfo.email, contactX, contactY + 20 + contactGap)
  292. drawContactRow(ctx, '📍', cardInfo.companyAddress, contactX, contactY + 20 + contactGap * 2)
  293. // ===== 8. 底部名片码 =====
  294. const qrBoxSize = 130
  295. const cardInnerBottom = cardY + cardH - 32
  296. const qrBoxX = innerX + innerW - qrBoxSize - 44
  297. const qrBoxY = cardInnerBottom - qrBoxSize - 44
  298. // 灰色圆角背景框
  299. ctx.save()
  300. ctx.fillStyle = '#F5F7FA'
  301. roundRect(ctx, qrBoxX - 8, qrBoxY - 8, qrBoxSize + 16, qrBoxSize + 16, 12)
  302. ctx.fill()
  303. ctx.restore()
  304. // 加载二维码图片(使用 canvas.createImage())
  305. try {
  306. const qrLocalPath = await qrBase64ToTempFile(qrInfo.image)
  307. if (qrLocalPath) {
  308. const qrImg = await createCanvasImage(canvas, qrLocalPath)
  309. if (qrImg) {
  310. ctx.drawImage(qrImg, qrBoxX, qrBoxY, qrBoxSize, qrBoxSize)
  311. }
  312. }
  313. } catch (e) {
  314. console.warn('加载名片码失败:', e)
  315. }
  316. // ===== 9. 导出 =====
  317. setTimeout(() => {
  318. uni.canvasToTempFilePath({
  319. canvas,
  320. success: (res) => resolve(res.tempFilePath),
  321. fail: (err) => reject(err)
  322. })
  323. }, 600)
  324. })
  325. })
  326. }
  327. export const savePosterToAlbum = (tempFilePath) => {
  328. return new Promise((resolve, reject) => {
  329. uni.authorize({
  330. scope: 'scope.writePhotosAlbum',
  331. success: () => {
  332. uni.saveImageToPhotosAlbum({
  333. filePath: tempFilePath,
  334. success: () => resolve(),
  335. fail: (err) => reject(err)
  336. })
  337. },
  338. fail: () => {
  339. uni.showModal({
  340. title: '提示',
  341. content: '需要授权相册权限才能保存名片',
  342. confirmText: '去设置',
  343. success: (res) => {
  344. if (res.confirm) uni.openSetting({})
  345. reject(new Error('未授权'))
  346. }
  347. })
  348. }
  349. })
  350. })
  351. }
  352. export default { generateCardPoster, savePosterToAlbum }