poster.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. /**
  2. * 名片海报生成工具
  3. * 使用 Canvas 手动绘制名片
  4. */
  5. /**
  6. * 生成名片海报
  7. * @param {Object} options - 配置选项
  8. * @param {Object} options.cardInfo - 名片信息
  9. * @returns {Promise<String>} - 生成的图片临时路径
  10. */
  11. export const generateCardPoster = async (cardInfo) => {
  12. return new Promise((resolve, reject) => {
  13. try {
  14. // 创建离屏 canvas
  15. const query = uni.createSelectorQuery()
  16. query.select('#posterCanvas')
  17. .fields({
  18. node: true,
  19. size: true
  20. })
  21. .exec(async (res) => {
  22. if (!res[0]) {
  23. reject(new Error('Canvas 节点未找到'))
  24. return
  25. }
  26. const canvas = res[0].node
  27. const ctx = canvas.getContext('2d')
  28. // 设置 canvas 尺寸(高分辨率)
  29. const width = 750
  30. const height = 800
  31. const dpr = uni.getSystemInfoSync().pixelRatio
  32. canvas.width = width * dpr
  33. canvas.height = height * dpr
  34. ctx.scale(dpr, dpr)
  35. // 1. 绘制背景渐变
  36. const gradient = ctx.createLinearGradient(0, 0, 0, height)
  37. gradient.addColorStop(0, '#4A90E2')
  38. gradient.addColorStop(0.5, '#6FB3F2')
  39. gradient.addColorStop(1, '#87CEEB')
  40. ctx.fillStyle = gradient
  41. ctx.fillRect(0, 0, width, height)
  42. // 2. 绘制白色卡片背景
  43. ctx.fillStyle = '#ffffff'
  44. drawRoundRect(ctx, 40, 40, 670, 720, 24)
  45. ctx.fill()
  46. // 3. 绘制头像
  47. const avatarPath = cardInfo.avatar ||
  48. '/static/image/public/avatar-default.png'
  49. await drawAvatar(ctx, avatarPath, 375, 160, 160)
  50. // 4. 绘制姓名和职位
  51. ctx.fillStyle = '#202020'
  52. ctx.font = 'bold 44px sans-serif'
  53. ctx.textAlign = 'center'
  54. ctx.fillText(cardInfo.nickName || '用户', width / 2, 380)
  55. // 职位标签背景
  56. ctx.fillStyle = 'rgba(68, 110, 255, 0.1)'
  57. drawRoundRect(ctx, (width - 180) / 2, 400, 180, 44, 8)
  58. ctx.fill()
  59. ctx.fillStyle = '#446eff'
  60. ctx.font = '26px sans-serif'
  61. ctx.textAlign = 'center'
  62. ctx.fillText(cardInfo.postName || '职位', width / 2, 430)
  63. // 5. 绘制公司名称
  64. ctx.fillStyle = '#666666'
  65. ctx.font = '30px sans-serif'
  66. ctx.textAlign = 'center'
  67. wrapText(ctx, cardInfo.companyName || '公司名称', width / 2, 480, 500, 36)
  68. // 6. 绘制联系方式
  69. const contactY = 560
  70. const contactGap = 65
  71. ctx.textAlign = 'left'
  72. // 电话
  73. if (cardInfo.phonenumber) {
  74. await drawIconText(ctx, 'phone', cardInfo.phonenumber, width / 2 - 130,
  75. contactY)
  76. }
  77. // 邮箱
  78. if (cardInfo.email) {
  79. await drawIconText(ctx, 'email', cardInfo.email, width / 2 - 130,
  80. contactY + contactGap)
  81. }
  82. // 地址
  83. if (cardInfo.companyAddress) {
  84. await drawIconText(ctx, 'location', cardInfo.companyAddress, width / 2 -
  85. 130, contactY + contactGap * 2, )
  86. }
  87. // 导出图片
  88. setTimeout(() => {
  89. uni.canvasToTempFilePath({
  90. canvas: canvas,
  91. success: (res) => {
  92. console.log('海报生成成功:', res.tempFilePath)
  93. resolve(res.tempFilePath)
  94. },
  95. fail: (err) => {
  96. console.error('海报导出失败:', err)
  97. reject(err)
  98. }
  99. })
  100. }, 300)
  101. })
  102. } catch (error) {
  103. console.error('海报生成失败:', error)
  104. reject(error)
  105. }
  106. })
  107. }
  108. /**
  109. * 绘制圆角矩形(兼容微信小程序)
  110. */
  111. const drawRoundRect = (ctx, x, y, width, height, radius) => {
  112. ctx.beginPath()
  113. ctx.moveTo(x + radius, y)
  114. ctx.lineTo(x + width - radius, y)
  115. ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
  116. ctx.lineTo(x + width, y + height - radius)
  117. ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
  118. ctx.lineTo(x + radius, y + height)
  119. ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
  120. ctx.lineTo(x, y + radius)
  121. ctx.quadraticCurveTo(x, y, x + radius, y)
  122. ctx.closePath()
  123. }
  124. /**
  125. * 绘制头像
  126. */
  127. const drawAvatar = async (ctx, avatarUrl, x, y, size) => {
  128. return new Promise((resolve) => {
  129. if (avatarUrl.startsWith('http')) {
  130. // 网络图片
  131. downloadAndDraw(ctx, avatarUrl, x, y, size, resolve)
  132. } else {
  133. drawCircularImage(ctx, avatarUrl, x, y, size, resolve)
  134. }
  135. })
  136. }
  137. /**
  138. * 下载并绘制网络图片
  139. */
  140. const downloadAndDraw = async (ctx, url, x, y, size, resolve) => {
  141. try {
  142. const res = await uni.downloadFile({
  143. url: url
  144. })
  145. if (res.statusCode === 200) {
  146. drawCircularImage(ctx, res.tempFilePath, x, y, size, resolve)
  147. } else {
  148. console.error('图片下载失败:', res.statusCode)
  149. resolve()
  150. }
  151. } catch (error) {
  152. console.error('下载图片异常:', error)
  153. resolve()
  154. }
  155. }
  156. /**
  157. * 绘制圆形图片
  158. */
  159. const drawCircularImage = (ctx, imageUrl, x, y, size, callback) => {
  160. const image = uni.createImage()
  161. image.src = imageUrl
  162. image.onload = () => {
  163. ctx.save()
  164. ctx.beginPath()
  165. ctx.arc(x, y, size / 2, 0, 2 * Math.PI)
  166. ctx.clip()
  167. ctx.drawImage(image, x - size / 2, y - size / 2, size, size)
  168. ctx.restore()
  169. if (callback) callback()
  170. }
  171. image.onerror = (err) => {
  172. console.error('图片加载失败:', err)
  173. if (callback) callback()
  174. }
  175. }
  176. /**
  177. * 绘制图标和文字
  178. */
  179. const drawIconText = async (ctx, type, text, x, y, maxWidth = 350) => {
  180. return new Promise((resolve) => {
  181. const iconSize = 32
  182. const iconY = y - iconSize / 2
  183. // 图标颜色
  184. const iconColors = {
  185. phone: '#4080FF',
  186. email: '#4080FF',
  187. location: '#4080FF'
  188. }
  189. ctx.fillStyle = iconColors[type] || '#4080FF'
  190. // 绘制图标
  191. const icons = {
  192. phone: '',
  193. email: '',
  194. location: ''
  195. }
  196. ctx.font = `${iconSize}px sans-serif`
  197. ctx.textAlign = 'center'
  198. ctx.fillText(icons[type] || '●', x - maxWidth / 2 + 30, iconY + iconSize / 2 + 5)
  199. // 绘制文字
  200. ctx.fillStyle = '#666666'
  201. ctx.font = '26px sans-serif'
  202. ctx.textAlign = 'left'
  203. // 文字截断处理
  204. let displayText = text
  205. if (text.length > 35) {
  206. displayText = text.substring(0, 33) + '...'
  207. }
  208. ctx.fillText(displayText, x - maxWidth / 2 + 70, y + 8)
  209. setTimeout(resolve, 50)
  210. })
  211. }
  212. /**
  213. * 绘制换行文字
  214. */
  215. const wrapText = (ctx, text, x, y, maxWidth, lineHeight) => {
  216. if (!text) return
  217. const words = text.split('')
  218. let line = ''
  219. let currentY = y
  220. for (let i = 0; i < words.length; i++) {
  221. const testLine = line + words[i]
  222. const metrics = ctx.measureText(testLine)
  223. if (metrics.width > maxWidth && i > 0) {
  224. ctx.fillText(line, x, currentY)
  225. line = words[i]
  226. currentY += lineHeight
  227. } else {
  228. line = testLine
  229. }
  230. }
  231. ctx.fillText(line, x, currentY)
  232. }
  233. /**
  234. * 保存海报到相册
  235. */
  236. export const savePosterToAlbum = async (tempFilePath) => {
  237. return new Promise((resolve, reject) => {
  238. uni.authorize({
  239. scope: 'scope.writePhotosAlbum',
  240. success: async () => {
  241. uni.saveImageToPhotosAlbum({
  242. filePath: tempFilePath,
  243. success: () => resolve(),
  244. fail: (err) => reject(err)
  245. })
  246. },
  247. fail: (err) => {
  248. uni.showModal({
  249. title: '提示',
  250. content: '需要授权才能保存到相册',
  251. success: (res) => {
  252. if (res.confirm) {
  253. uni.openSetting({
  254. success: (settingRes) => {
  255. if (settingRes.authSetting[
  256. 'scope.writePhotosAlbum'
  257. ]) {
  258. savePosterToAlbum(
  259. tempFilePath).then(
  260. resolve).catch(
  261. reject)
  262. } else {
  263. reject(new Error('用户拒绝授权'))
  264. }
  265. },
  266. fail: (err) => reject(err)
  267. })
  268. } else {
  269. reject(new Error('用户取消授权'))
  270. }
  271. }
  272. })
  273. }
  274. })
  275. })
  276. }
  277. export default {
  278. generateCardPoster,
  279. savePosterToAlbum
  280. }