推拿小程序前端代码仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

562 lines
13 KiB

  1. <template>
  2. <view class="placard">
  3. <view class="placard-content">
  4. <!-- H5环境显示最终图片 -->
  5. <view v-if="tempFilePath" class="img-box" :style="{ width: canvasW + 'px', height: canvasH + 'px' }">
  6. <img
  7. :src="tempFilePath"
  8. :style="{ width: canvasW + 'px', height: canvasH + 'px' }"
  9. @contextmenu.prevent
  10. style="display: block; border-radius: 10px;"
  11. />
  12. </view>
  13. <!-- 生成中的提示 -->
  14. <view v-else class="loading-box" :style="{ width: canvasW + 'px', height: canvasH + 'px' }">
  15. <view class="loading-content">
  16. <view class="loading-spinner"></view>
  17. <text class="loading-text">
  18. {{ !userId ? '获取用户信息中...' : '海报生成中...' }}
  19. </text>
  20. </view>
  21. </view>
  22. <!-- 二维码组件 - 隐藏 -->
  23. <view v-if="userId" class="qrcode" ref="qrcode">
  24. <uv-qrcode
  25. :value="qrCodeValue"
  26. :size="qrCodeSize"
  27. type="image/png"
  28. ref="qrCodeComponent"
  29. @complete="onQRCodeComplete">
  30. </uv-qrcode>
  31. </view>
  32. <!-- 画布 - 隐藏 -->
  33. <canvas
  34. :style="{ width: canvasW + 'px', height: canvasH + 'px' }"
  35. canvas-id="myCanvas"
  36. id="myCanvas"
  37. :width="canvasW"
  38. :height="canvasH"
  39. class="hidden-canvas">
  40. </canvas>
  41. <view class="add-btn">
  42. <view class="btn" @click="handleSaveImage">
  43. 长按图片保存到手机
  44. </view>
  45. </view>
  46. </view>
  47. </view>
  48. </template>
  49. <script>
  50. import Vue from 'vue'
  51. import { mapState } from 'vuex'
  52. export default {
  53. name: 'Placard',
  54. computed: {
  55. ...mapState(['userInfo']),
  56. // 获取用户ID
  57. userId() {
  58. return this.userInfo?.id || ''
  59. },
  60. // 动态生成二维码内容
  61. qrCodeContent() {
  62. if (this.userId) {
  63. return Vue.prototype.$config.redirect + `?vid=${this.userId}`
  64. }
  65. return Vue.prototype.$config.redirect
  66. }
  67. },
  68. data() {
  69. return {
  70. qrCodeValue: '', // 初始为空,等用户信息加载后再设置
  71. qrCodeSize: 180,
  72. qrCodeDarkColor: '#000',
  73. qrCodeLightColor: '#fff',
  74. margin: 0,
  75. //画布信息
  76. canvasW: 299,
  77. canvasH: 403,
  78. //设备信息
  79. systemInfo: {},
  80. //图片路径
  81. tempFilePath: '',
  82. _rpx: 1, // H5环境使用固定比例
  83. _center: 0,
  84. // 二维码是否生成完成
  85. qrCodeReady: false,
  86. // 重试次数
  87. retryCount: 0,
  88. maxRetry: 1,
  89. // 用户信息是否已加载
  90. userInfoLoaded: false
  91. }
  92. },
  93. watch: {
  94. // 监听用户信息变化
  95. userInfo: {
  96. handler(newVal) {
  97. if (newVal && newVal.id) {
  98. this.userInfoLoaded = true
  99. this.qrCodeValue = this.qrCodeContent
  100. // 如果二维码还未生成,现在生成
  101. if (!this.qrCodeReady) {
  102. this.$nextTick(() => {
  103. this.generateQRCode()
  104. })
  105. }
  106. }
  107. },
  108. deep: true,
  109. immediate: true
  110. }
  111. },
  112. mounted() {
  113. this.initCanvas()
  114. },
  115. onShow() {
  116. // 页面显示时确保获取用户信息
  117. this.getUserInfo()
  118. },
  119. methods: {
  120. // 初始化画布
  121. initCanvas() {
  122. // H5环境使用固定尺寸,便于显示
  123. this.canvasW = 299
  124. this.canvasH = 403
  125. this.qrCodeSize = 180
  126. this._center = this.canvasW / 2
  127. // 检查用户信息是否已加载
  128. if (this.userInfoLoaded && this.qrCodeValue) {
  129. this.generateQRCode()
  130. } else {
  131. // 获取用户信息
  132. this.getUserInfo()
  133. }
  134. },
  135. // 获取用户信息
  136. getUserInfo() {
  137. // 如果store中没有用户信息,尝试获取
  138. if (!this.userInfo?.id) {
  139. this.$store.commit('getUserInfo')
  140. }
  141. },
  142. // 生成二维码
  143. generateQRCode() {
  144. // 确保有用户ID才生成二维码
  145. if (!this.userId) {
  146. console.log('等待用户信息加载...')
  147. return
  148. }
  149. // 确保二维码内容已设置
  150. if (!this.qrCodeValue) {
  151. this.qrCodeValue = this.qrCodeContent
  152. }
  153. console.log('生成二维码,内容:', this.qrCodeValue)
  154. this.$nextTick(() => {
  155. if (this.$refs.qrCodeComponent) {
  156. this.$refs.qrCodeComponent.make()
  157. }
  158. })
  159. },
  160. // 二维码生成完成回调
  161. onQRCodeComplete(res) {
  162. if (res.success) {
  163. this.qrCodeReady = true
  164. this.draw()
  165. }
  166. },
  167. // 绘制海报
  168. async draw() {
  169. if (!this.qrCodeReady) {
  170. console.log('二维码未准备好')
  171. return
  172. }
  173. const ctx = uni.createCanvasContext('myCanvas', this)
  174. // 设置背景色
  175. ctx.setFillStyle('#ffffff')
  176. ctx.fillRect(0, 0, this.canvasW, this.canvasH)
  177. // 绘制标题
  178. ctx.setFillStyle('#333333')
  179. ctx.setFontSize(18)
  180. ctx.setTextAlign('center')
  181. ctx.fillText('邀请好友一起享受优质服务', this.canvasW / 2, 30)
  182. // 绘制用户头像(如果有)
  183. if (this.userInfo.headImage) {
  184. try {
  185. // 先下载图片到本地
  186. const avatarPath = await this.downloadImage(this.userInfo.headImage)
  187. const avatarSize = 60
  188. const avatarX = (this.canvasW - avatarSize) / 2
  189. const avatarY = 50
  190. // 绘制圆形头像背景
  191. ctx.setFillStyle('#f0f0f0')
  192. ctx.beginPath()
  193. ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI)
  194. ctx.fill()
  195. // 绘制圆形头像
  196. ctx.save()
  197. ctx.beginPath()
  198. ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI)
  199. ctx.clip()
  200. ctx.drawImage(avatarPath, avatarX, avatarY, avatarSize, avatarSize)
  201. ctx.restore()
  202. } catch (e) {
  203. console.log('头像绘制失败', e)
  204. // 绘制默认头像圆圈
  205. const avatarSize = 60
  206. const avatarX = (this.canvasW - avatarSize) / 2
  207. const avatarY = 50
  208. ctx.setFillStyle('#e0e0e0')
  209. ctx.beginPath()
  210. ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI)
  211. ctx.fill()
  212. }
  213. } else {
  214. // 没有头像时绘制默认头像圆圈
  215. const avatarSize = 60
  216. const avatarX = (this.canvasW - avatarSize) / 2
  217. const avatarY = 50
  218. ctx.setFillStyle('#e0e0e0')
  219. ctx.beginPath()
  220. ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI)
  221. ctx.fill()
  222. }
  223. // 绘制用户昵称
  224. if (this.userInfo.nickName) {
  225. ctx.setFillStyle('#333333')
  226. ctx.setFontSize(16)
  227. ctx.setTextAlign('center')
  228. ctx.fillText(this.userInfo.nickName || '用户', this.canvasW / 2, 140)
  229. }
  230. // 获取二维码图片并绘制
  231. try {
  232. const qrCodeImagePath = await this.getQRCodeImage()
  233. if (qrCodeImagePath) {
  234. const qrSize = 120
  235. const qrX = (this.canvasW - qrSize) / 2
  236. const qrY = 160
  237. ctx.drawImage(qrCodeImagePath, qrX, qrY, qrSize, qrSize)
  238. }
  239. } catch (e) {
  240. console.log('二维码绘制失败', e)
  241. }
  242. // 绘制底部文案
  243. ctx.setFillStyle('#666666')
  244. ctx.setFontSize(14)
  245. ctx.setTextAlign('center')
  246. ctx.fillText('扫码关注,享受专业服务', this.canvasW / 2, 310)
  247. // 执行绘制
  248. ctx.draw(false, () => {
  249. // 导出图片
  250. setTimeout(() => {
  251. this.canvasToTempFilePath()
  252. }, 1000)
  253. })
  254. },
  255. // 下载网络图片到本地
  256. downloadImage(url) {
  257. return new Promise((resolve, reject) => {
  258. // H5环境下直接使用网络图片URL,避免跨域问题
  259. // #ifdef H5
  260. resolve(url)
  261. // #endif
  262. // #ifndef H5
  263. uni.downloadFile({
  264. url: url,
  265. success: (res) => {
  266. if (res.statusCode === 200) {
  267. resolve(res.tempFilePath)
  268. } else {
  269. reject('下载失败')
  270. }
  271. },
  272. fail: reject
  273. })
  274. // #endif
  275. })
  276. },
  277. // 获取二维码图片
  278. getQRCodeImage() {
  279. return new Promise((resolve, reject) => {
  280. if (this.$refs.qrCodeComponent) {
  281. this.$refs.qrCodeComponent.toTempFilePath({
  282. success: (res) => {
  283. resolve(res.tempFilePath)
  284. },
  285. fail: (err) => {
  286. reject(err)
  287. }
  288. })
  289. } else {
  290. reject('二维码组件不存在')
  291. }
  292. })
  293. },
  294. // 画布导出为图片
  295. canvasToTempFilePath() {
  296. // #ifdef H5
  297. // H5环境下直接转换canvas
  298. setTimeout(() => {
  299. this.convertCanvasToBase64()
  300. }, 500)
  301. // #endif
  302. // #ifndef H5
  303. uni.canvasToTempFilePath({
  304. canvasId: 'myCanvas',
  305. success: (res) => {
  306. this.tempFilePath = res.tempFilePath
  307. console.log('海报生成成功', res.tempFilePath)
  308. },
  309. fail: (err) => {
  310. console.log('海报生成失败', err)
  311. uni.showToast({
  312. title: '海报生成失败',
  313. icon: 'none'
  314. })
  315. }
  316. }, this)
  317. // #endif
  318. },
  319. // 将canvas转换为base64图片
  320. convertCanvasToBase64() {
  321. // #ifdef H5
  322. try {
  323. // 获取canvas元素
  324. const canvasElement = document.querySelector('#myCanvas')
  325. if (!canvasElement) {
  326. console.error('找不到canvas元素')
  327. this.retryConvert()
  328. return
  329. }
  330. // 检查是否是canvas元素
  331. if (canvasElement.tagName.toLowerCase() !== 'canvas') {
  332. console.error('元素不是canvas')
  333. this.retryConvert()
  334. return
  335. }
  336. // 等待canvas绘制完成
  337. setTimeout(() => {
  338. try {
  339. // 转换为base64
  340. const dataURL = canvasElement.toDataURL('image/png', 1.0)
  341. if (dataURL && dataURL.startsWith('data:image')) {
  342. this.tempFilePath = dataURL
  343. console.log('海报生成成功')
  344. this.retryCount = 0 // 重置重试次数
  345. } else {
  346. throw new Error('生成的图片数据无效')
  347. }
  348. } catch (e) {
  349. console.error('转换图片失败', e)
  350. this.retryConvert()
  351. }
  352. }, 200)
  353. } catch (e) {
  354. console.error('转换图片失败', e)
  355. this.retryConvert()
  356. }
  357. // #endif
  358. },
  359. // 重试转换
  360. retryConvert() {
  361. if (this.retryCount < this.maxRetry) {
  362. this.retryCount++
  363. console.log(`重试转换图片,第${this.retryCount}`)
  364. setTimeout(() => {
  365. this.convertCanvasToBase64()
  366. }, 500)
  367. } else {
  368. console.log('重试次数已达上限,使用备用方法')
  369. this.fallbackCanvasConvert()
  370. }
  371. },
  372. // 备用的canvas转换方法
  373. fallbackCanvasConvert() {
  374. uni.canvasToTempFilePath({
  375. canvasId: 'myCanvas',
  376. success: (res) => {
  377. this.tempFilePath = res.tempFilePath
  378. console.log('海报生成成功(备用方法)', res.tempFilePath)
  379. },
  380. fail: (err) => {
  381. console.log('海报生成失败', err)
  382. uni.showToast({
  383. title: '海报生成失败',
  384. icon: 'none'
  385. })
  386. }
  387. }, this)
  388. },
  389. // 保存图片 - H5环境提示用户长按
  390. handleSaveImage() {
  391. if (!this.tempFilePath) {
  392. uni.showToast({
  393. title: '图片还未生成完成',
  394. icon: 'none'
  395. })
  396. return
  397. }
  398. // H5环境下提示用户长按保存
  399. uni.showModal({
  400. title: '保存图片',
  401. content: '请长按上方图片,选择"保存图片"即可保存到手机',
  402. showCancel: false,
  403. confirmText: '知道了'
  404. })
  405. }
  406. }
  407. }
  408. </script>
  409. <style lang="scss" scoped>
  410. .placard {
  411. display: flex;
  412. align-items: center;
  413. justify-content: center;
  414. min-height: 100vh;
  415. background-color: #f5f5f5;
  416. padding: 20px;
  417. .placard-content {
  418. display: flex;
  419. flex-direction: column;
  420. align-items: center;
  421. .img-box {
  422. background-color: #fff;
  423. border-radius: 10px;
  424. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
  425. overflow: hidden;
  426. margin-bottom: 20px;
  427. img {
  428. user-select: none;
  429. -webkit-user-select: none;
  430. -webkit-touch-callout: default;
  431. }
  432. }
  433. .loading-box {
  434. background-color: #fff;
  435. border-radius: 10px;
  436. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
  437. margin-bottom: 20px;
  438. display: flex;
  439. align-items: center;
  440. justify-content: center;
  441. .loading-content {
  442. display: flex;
  443. flex-direction: column;
  444. align-items: center;
  445. .loading-spinner {
  446. width: 30px;
  447. height: 30px;
  448. border: 3px solid #f3f3f3;
  449. border-top: 3px solid #84A73F;
  450. border-radius: 50%;
  451. animation: spin 1s linear infinite;
  452. margin-bottom: 10px;
  453. }
  454. .loading-text {
  455. color: #666;
  456. font-size: 14px;
  457. }
  458. }
  459. }
  460. .add-btn {
  461. display: flex;
  462. justify-content: center;
  463. align-items: center;
  464. width: 100%;
  465. .btn {
  466. display: flex;
  467. align-items: center;
  468. justify-content: center;
  469. width: 80%;
  470. height: 40px;
  471. border-radius: 20px;
  472. color: white;
  473. font-size: 16px;
  474. background: linear-gradient(to right, #84A73F, #D8FF8F);
  475. margin-top: 20px;
  476. box-shadow: 0 4px 12px rgba(132, 167, 63, 0.3);
  477. cursor: pointer;
  478. &:active {
  479. transform: scale(0.98);
  480. transition: transform 0.1s;
  481. }
  482. }
  483. }
  484. }
  485. }
  486. .hidden-canvas {
  487. opacity: 0;
  488. position: fixed;
  489. top: -9999px;
  490. left: -9999px;
  491. pointer-events: none;
  492. }
  493. .qrcode {
  494. position: fixed;
  495. top: -9999px;
  496. left: -9999px;
  497. opacity: 0;
  498. pointer-events: none;
  499. }
  500. @keyframes spin {
  501. 0% { transform: rotate(0deg); }
  502. 100% { transform: rotate(360deg); }
  503. }
  504. </style>