poster.js 9.7 KB

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