poster.js 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. /**
  2. * 名片海报生成工具
  3. * 使用 Canvas 2D 绘制名片海报
  4. */
  5. const W = 750
  6. const H = 780
  7. const PADDING = 40
  8. const roundRect = (ctx, x, y, w, h, r) => {
  9. ctx.beginPath()
  10. ctx.moveTo(x + r, y)
  11. ctx.lineTo(x + w - r, y)
  12. ctx.quadraticCurveTo(x + w, y, x + w, y + r)
  13. ctx.lineTo(x + w, y + h - r)
  14. ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
  15. ctx.lineTo(x + r, y + h)
  16. ctx.quadraticCurveTo(x, y + h, x, y + h - r)
  17. ctx.lineTo(x, y + r)
  18. ctx.quadraticCurveTo(x, y, x + r, y)
  19. ctx.closePath()
  20. }
  21. const loadImage = (src) => {
  22. return new Promise((resolve) => {
  23. if (!src) return resolve(null)
  24. const img = uni.createImage()
  25. img.src = src
  26. img.onload = () => resolve(img)
  27. img.onerror = () => resolve(null)
  28. })
  29. }
  30. const downloadImage = async (url) => {
  31. try {
  32. if (!url || !url.startsWith('http')) return url
  33. const res = await uni.downloadFile({ url })
  34. if (res.statusCode === 200) return res.tempFilePath
  35. return null
  36. } catch { return null }
  37. }
  38. const drawCircleAvatar = (ctx, img, cx, cy, r) => {
  39. ctx.save()
  40. ctx.beginPath()
  41. ctx.arc(cx, cy, r, 0, Math.PI * 2)
  42. ctx.clip()
  43. if (img) ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2)
  44. ctx.restore()
  45. }
  46. const drawAvatarFallback = (ctx, cardInfo, cx, cy, r) => {
  47. ctx.save()
  48. ctx.beginPath()
  49. ctx.arc(cx, cy, r, 0, Math.PI * 2)
  50. ctx.fillStyle = '#4080FF'
  51. ctx.fill()
  52. ctx.strokeStyle = 'rgba(255,255,255,0.6)'
  53. ctx.lineWidth = 4
  54. ctx.stroke()
  55. const initial = cardInfo.nickName ? cardInfo.nickName.charAt(0) : '?'
  56. ctx.fillStyle = '#FFFFFF'
  57. ctx.font = `bold ${r}px sans-serif`
  58. ctx.textAlign = 'center'
  59. ctx.textBaseline = 'middle'
  60. ctx.fillText(initial, cx, cy)
  61. ctx.restore()
  62. }
  63. const drawContactRow = (ctx, emoji, text, x, y) => {
  64. if (!text) return
  65. // 图标背景圆
  66. ctx.save()
  67. ctx.beginPath()
  68. ctx.arc(x + 20, y + 20, 20, 0, Math.PI * 2)
  69. ctx.fillStyle = 'rgba(64, 128, 255, 0.08)'
  70. ctx.fill()
  71. ctx.restore()
  72. // Emoji
  73. ctx.font = '22px sans-serif'
  74. ctx.textAlign = 'center'
  75. ctx.textBaseline = 'middle'
  76. ctx.fillText(emoji, x + 20, y + 20)
  77. // 文字 - 超长时自动换行
  78. ctx.font = '26px sans-serif'
  79. ctx.textAlign = 'left'
  80. ctx.textBaseline = 'middle'
  81. ctx.fillStyle = '#555555'
  82. // 计算可用宽度(图标到右边界)
  83. const textMaxWidth = 480 // 联系方式文本最大宽度
  84. const textX = x + 52
  85. if (ctx.measureText(text).width > textMaxWidth) {
  86. // 超长时换行显示(从垂直居中位置开始)
  87. ctx.textBaseline = 'top'
  88. const lineY = y + 10 // 和图标居中对齐的偏移
  89. fillTextWrap(ctx, text, textX, lineY, textMaxWidth, 40, 'left')
  90. } else {
  91. ctx.fillText(text, textX, y + 20)
  92. }
  93. }
  94. const fillTextWrap = (ctx, text, x, y, maxWidth, lineHeight, align = 'left') => {
  95. if (!text) return y
  96. ctx.textAlign = align
  97. ctx.textBaseline = 'top'
  98. const chars = text.split('')
  99. let line = '', cy = y
  100. for (let i = 0; i < chars.length; i++) {
  101. const test = line + chars[i]
  102. if (ctx.measureText(test).width > maxWidth && i > 0) {
  103. ctx.fillText(line, x, cy)
  104. line = chars[i]
  105. cy += lineHeight
  106. } else {
  107. line = test
  108. }
  109. }
  110. ctx.fillText(line, x, cy)
  111. return cy + lineHeight
  112. }
  113. export const generateCardPoster = async (cardInfo = {}) => {
  114. return new Promise((resolve, reject) => {
  115. const query = uni.createSelectorQuery()
  116. query.select('#posterCanvas')
  117. .fields({ node: true, size: true })
  118. .exec(async (res) => {
  119. if (!res || !res[0]) {
  120. reject(new Error('Canvas 节点未找到'))
  121. return
  122. }
  123. const canvas = res[0].node
  124. const ctx = canvas.getContext('2d')
  125. const dpr = uni.getSystemInfoSync().pixelRatio || 2
  126. canvas.width = W * dpr
  127. canvas.height = H * dpr
  128. ctx.scale(dpr, dpr)
  129. // ===== 1. 渐变背景 =====
  130. const bgGrad = ctx.createLinearGradient(0, 0, 0, H)
  131. bgGrad.addColorStop(0, '#4A90E2')
  132. bgGrad.addColorStop(0.5, '#6FB3F2')
  133. bgGrad.addColorStop(1, '#B0E0E6')
  134. ctx.fillStyle = bgGrad
  135. ctx.fillRect(0, 0, W, H)
  136. // ===== 2. 装饰圆点 =====
  137. ctx.fillStyle = 'rgba(255,255,255,0.10)'
  138. ctx.beginPath(); ctx.arc(620, 160, 100, 0, Math.PI * 2); ctx.fill()
  139. ctx.beginPath(); ctx.arc(680, 320, 60, 0, Math.PI * 2); ctx.fill()
  140. ctx.beginPath(); ctx.arc(-20, 640, 80, 0, Math.PI * 2); ctx.fill()
  141. // ===== 3. 白色卡片 =====
  142. const cardX = PADDING, cardY = 60
  143. const cardW = W - PADDING * 2, cardH = 660
  144. ctx.save()
  145. ctx.shadowColor = 'rgba(0,0,0,0.08)'
  146. ctx.shadowBlur = 24
  147. ctx.shadowOffsetY = 6
  148. ctx.fillStyle = '#FFFFFF'
  149. roundRect(ctx, cardX, cardY, cardW, cardH, 24)
  150. ctx.fill()
  151. ctx.restore()
  152. // ===== 4. 内部名片卡片 =====
  153. const innerX = cardX + 32
  154. const innerY = cardY + 32
  155. const innerW = cardW - 64
  156. const innerH = cardH - 64
  157. ctx.save()
  158. ctx.shadowColor = 'rgba(0,0,0,0.05)'
  159. ctx.shadowBlur = 16
  160. ctx.shadowOffsetY = 3
  161. ctx.fillStyle = '#FFFFFF'
  162. roundRect(ctx, innerX, innerY, innerW, innerH, 16)
  163. ctx.fill()
  164. ctx.restore()
  165. // 背景图
  166. try {
  167. const cardBg = await loadImage('/static/image/home/usecard-bg.png')
  168. if (cardBg) {
  169. ctx.save()
  170. roundRect(ctx, innerX, innerY, innerW, innerH, 16)
  171. ctx.clip()
  172. ctx.drawImage(cardBg, innerX, innerY, innerW, innerH)
  173. ctx.restore()
  174. }
  175. } catch (e) {}
  176. // ===== 5. 头像(右上角)=====
  177. const avatarCx = innerX + innerW - 100
  178. const avatarCy = innerY + 70
  179. const avatarR = 60
  180. ctx.save()
  181. ctx.beginPath()
  182. ctx.arc(avatarCx, avatarCy, avatarR + 4, 0, Math.PI * 2)
  183. ctx.fillStyle = 'rgba(255,255,255,0.5)'
  184. ctx.fill()
  185. ctx.restore()
  186. let avatarImg = null
  187. if (cardInfo.avatar) {
  188. const localPath = await downloadImage(cardInfo.avatar)
  189. avatarImg = await loadImage(localPath)
  190. }
  191. if (avatarImg) {
  192. drawCircleAvatar(ctx, avatarImg, avatarCx, avatarCy, avatarR)
  193. } else {
  194. drawAvatarFallback(ctx, cardInfo, avatarCx, avatarCy, avatarR)
  195. }
  196. // ===== 6. 姓名 + 职位 + 公司 =====
  197. const nameX = innerX + 44
  198. const nameY = innerY + 50
  199. ctx.fillStyle = '#202020'
  200. ctx.font = 'bold 44px sans-serif'
  201. ctx.textAlign = 'left'
  202. ctx.textBaseline = 'top'
  203. const name = cardInfo.nickName || '用户'
  204. ctx.fillText(name, nameX, nameY)
  205. const nameW = ctx.measureText(name).width
  206. // 职位标签
  207. if (cardInfo.postName) {
  208. const tagX = nameX + nameW + 14
  209. ctx.font = '22px sans-serif'
  210. const tagTextW = ctx.measureText(cardInfo.postName).width
  211. const tagW = tagTextW + 26
  212. ctx.fillStyle = 'rgba(68, 110, 255, 0.10)'
  213. roundRect(ctx, tagX, nameY + 6, tagW, 34, 8)
  214. ctx.fill()
  215. ctx.fillStyle = '#446EFF'
  216. ctx.textAlign = 'left'
  217. ctx.textBaseline = 'top'
  218. ctx.fillText(cardInfo.postName, tagX + 13, nameY + 11)
  219. }
  220. // 公司名称(最大宽度避开右上方头像)
  221. ctx.fillStyle = '#666666'
  222. ctx.font = '26px sans-serif'
  223. ctx.textAlign = 'left'
  224. ctx.textBaseline = 'top'
  225. const companyMaxW = innerW - 160
  226. const companyName = cardInfo.companyName || '公司名称'
  227. // 如果公司名太长,手动截断
  228. ctx.font = '26px sans-serif'
  229. if (ctx.measureText(companyName).width > companyMaxW) {
  230. let display = companyName
  231. while (ctx.measureText(display + '...').width > companyMaxW && display.length > 0) {
  232. display = display.substring(0, display.length - 1)
  233. }
  234. ctx.fillText(display + '...', innerX + 44, nameY + 60)
  235. } else {
  236. ctx.fillText(companyName, innerX + 44, nameY + 60)
  237. }
  238. // ===== 7. 联系方式 =====
  239. const contactY = innerY + 160
  240. const contactX = innerX + 44
  241. const contactGap = 60
  242. ctx.strokeStyle = '#EEEEEE'
  243. ctx.lineWidth = 1
  244. ctx.beginPath()
  245. ctx.moveTo(contactX, contactY)
  246. ctx.lineTo(innerX + innerW - 44, contactY)
  247. ctx.stroke()
  248. drawContactRow(ctx, '📞', cardInfo.phonenumber, contactX, contactY + 20)
  249. drawContactRow(ctx, '✉', cardInfo.email, contactX, contactY + 20 + contactGap)
  250. drawContactRow(ctx, '📍', cardInfo.companyAddress, contactX, contactY + 20 + contactGap * 2)
  251. // ===== 8. 导出 =====
  252. setTimeout(() => {
  253. uni.canvasToTempFilePath({
  254. canvas,
  255. success: (res) => resolve(res.tempFilePath),
  256. fail: (err) => reject(err)
  257. })
  258. }, 400)
  259. })
  260. })
  261. }
  262. export const savePosterToAlbum = (tempFilePath) => {
  263. return new Promise((resolve, reject) => {
  264. uni.authorize({
  265. scope: 'scope.writePhotosAlbum',
  266. success: () => {
  267. uni.saveImageToPhotosAlbum({
  268. filePath: tempFilePath,
  269. success: () => resolve(),
  270. fail: (err) => reject(err)
  271. })
  272. },
  273. fail: () => {
  274. uni.showModal({
  275. title: '提示',
  276. content: '需要授权相册权限才能保存名片',
  277. confirmText: '去设置',
  278. success: (res) => {
  279. if (res.confirm) uni.openSetting({})
  280. reject(new Error('未授权'))
  281. }
  282. })
  283. }
  284. })
  285. })
  286. }
  287. export default { generateCardPoster, savePosterToAlbum }