推拿小程序前端代码仓库
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.

601 lines
14 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 = 349
  124. this.canvasH = 389
  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. ctx.setFillStyle('#000000')
  178. ctx.setFontSize(16)
  179. let text = '邀请好友'
  180. // 计算文本的宽度和高度
  181. let metrics = ctx.measureText(text);
  182. ctx.fillText(text, this._center - metrics.width / 2, 53);
  183. text = `邀请码:${this.userId}`
  184. // 计算文本的宽度和高度
  185. metrics = ctx.measureText(text);
  186. // todo: check code
  187. ctx.fillText(text, this._center - metrics.width / 2, 342);
  188. // 获取二维码图片并绘制
  189. try {
  190. const qrCodeImagePath = await this.getQRCodeImage()
  191. if (qrCodeImagePath) {
  192. const qrSize = 192
  193. const qrX = (this.canvasW - qrSize) / 2
  194. const qrY = 94
  195. ctx.drawImage(qrCodeImagePath, qrX, qrY, qrSize, qrSize)
  196. }
  197. } catch (e) {
  198. console.log('二维码绘制失败', e)
  199. }
  200. // 执行绘制
  201. ctx.draw(false, () => {
  202. // 导出图片
  203. setTimeout(() => {
  204. this.canvasToTempFilePath()
  205. }, 1000)
  206. })
  207. return
  208. // 绘制标题
  209. ctx.setFillStyle('#333333')
  210. ctx.setFontSize(18)
  211. ctx.setTextAlign('center')
  212. ctx.fillText('邀请好友一起享受优质服务', this.canvasW / 2, 30)
  213. // 绘制用户头像(如果有)
  214. if (this.userInfo.headImage) {
  215. try {
  216. // 先下载图片到本地
  217. const avatarPath = await this.downloadImage(this.userInfo.headImage)
  218. const avatarSize = 60
  219. const avatarX = (this.canvasW - avatarSize) / 2
  220. const avatarY = 50
  221. // 绘制圆形头像背景
  222. ctx.setFillStyle('#f0f0f0')
  223. ctx.beginPath()
  224. ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI)
  225. ctx.fill()
  226. // 绘制圆形头像
  227. ctx.save()
  228. ctx.beginPath()
  229. ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI)
  230. ctx.clip()
  231. ctx.drawImage(avatarPath, avatarX, avatarY, avatarSize, avatarSize)
  232. ctx.restore()
  233. } catch (e) {
  234. console.log('头像绘制失败', e)
  235. // 绘制默认头像圆圈
  236. const avatarSize = 60
  237. const avatarX = (this.canvasW - avatarSize) / 2
  238. const avatarY = 50
  239. ctx.setFillStyle('#e0e0e0')
  240. ctx.beginPath()
  241. ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI)
  242. ctx.fill()
  243. }
  244. } else {
  245. // 没有头像时绘制默认头像圆圈
  246. const avatarSize = 60
  247. const avatarX = (this.canvasW - avatarSize) / 2
  248. const avatarY = 50
  249. ctx.setFillStyle('#e0e0e0')
  250. ctx.beginPath()
  251. ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI)
  252. ctx.fill()
  253. }
  254. // 绘制用户昵称
  255. if (this.userInfo.nickName) {
  256. ctx.setFillStyle('#333333')
  257. ctx.setFontSize(16)
  258. ctx.setTextAlign('center')
  259. ctx.fillText(this.userInfo.nickName || '用户', this.canvasW / 2, 140)
  260. }
  261. // 获取二维码图片并绘制
  262. try {
  263. const qrCodeImagePath = await this.getQRCodeImage()
  264. if (qrCodeImagePath) {
  265. const qrSize = 120
  266. const qrX = (this.canvasW - qrSize) / 2
  267. const qrY = 160
  268. ctx.drawImage(qrCodeImagePath, qrX, qrY, qrSize, qrSize)
  269. }
  270. } catch (e) {
  271. console.log('二维码绘制失败', e)
  272. }
  273. // 绘制底部文案
  274. ctx.setFillStyle('#666666')
  275. ctx.setFontSize(14)
  276. ctx.setTextAlign('center')
  277. ctx.fillText('扫码关注,享受专业服务', this.canvasW / 2, 310)
  278. // 执行绘制
  279. ctx.draw(false, () => {
  280. // 导出图片
  281. setTimeout(() => {
  282. this.canvasToTempFilePath()
  283. }, 1000)
  284. })
  285. },
  286. // 下载网络图片到本地
  287. downloadImage(url) {
  288. return new Promise((resolve, reject) => {
  289. // H5环境下直接使用网络图片URL,避免跨域问题
  290. // #ifdef H5
  291. resolve(url)
  292. // #endif
  293. // #ifndef H5
  294. uni.downloadFile({
  295. url: url,
  296. success: (res) => {
  297. if (res.statusCode === 200) {
  298. resolve(res.tempFilePath)
  299. } else {
  300. reject('下载失败')
  301. }
  302. },
  303. fail: reject
  304. })
  305. // #endif
  306. })
  307. },
  308. // 获取二维码图片
  309. getQRCodeImage() {
  310. return new Promise((resolve, reject) => {
  311. if (this.$refs.qrCodeComponent) {
  312. this.$refs.qrCodeComponent.toTempFilePath({
  313. success: (res) => {
  314. resolve(res.tempFilePath)
  315. },
  316. fail: (err) => {
  317. reject(err)
  318. }
  319. })
  320. } else {
  321. reject('二维码组件不存在')
  322. }
  323. })
  324. },
  325. // 画布导出为图片
  326. canvasToTempFilePath() {
  327. // #ifdef H5
  328. // H5环境下直接转换canvas
  329. setTimeout(() => {
  330. this.convertCanvasToBase64()
  331. }, 500)
  332. // #endif
  333. // #ifndef H5
  334. uni.canvasToTempFilePath({
  335. canvasId: 'myCanvas',
  336. success: (res) => {
  337. this.tempFilePath = res.tempFilePath
  338. console.log('海报生成成功', res.tempFilePath)
  339. },
  340. fail: (err) => {
  341. console.log('海报生成失败', err)
  342. uni.showToast({
  343. title: '海报生成失败',
  344. icon: 'none'
  345. })
  346. }
  347. }, this)
  348. // #endif
  349. },
  350. // 将canvas转换为base64图片
  351. convertCanvasToBase64() {
  352. // #ifdef H5
  353. try {
  354. // 获取canvas元素
  355. const canvasElement = document.querySelector('#myCanvas')
  356. if (!canvasElement) {
  357. console.error('找不到canvas元素')
  358. this.retryConvert()
  359. return
  360. }
  361. // 检查是否是canvas元素
  362. if (canvasElement.tagName.toLowerCase() !== 'canvas') {
  363. console.error('元素不是canvas')
  364. this.retryConvert()
  365. return
  366. }
  367. // 等待canvas绘制完成
  368. setTimeout(() => {
  369. try {
  370. // 转换为base64
  371. const dataURL = canvasElement.toDataURL('image/png', 1.0)
  372. if (dataURL && dataURL.startsWith('data:image')) {
  373. this.tempFilePath = dataURL
  374. console.log('海报生成成功')
  375. this.retryCount = 0 // 重置重试次数
  376. } else {
  377. throw new Error('生成的图片数据无效')
  378. }
  379. } catch (e) {
  380. console.error('转换图片失败', e)
  381. this.retryConvert()
  382. }
  383. }, 200)
  384. } catch (e) {
  385. console.error('转换图片失败', e)
  386. this.retryConvert()
  387. }
  388. // #endif
  389. },
  390. // 重试转换
  391. retryConvert() {
  392. if (this.retryCount < this.maxRetry) {
  393. this.retryCount++
  394. console.log(`重试转换图片,第${this.retryCount}`)
  395. setTimeout(() => {
  396. this.convertCanvasToBase64()
  397. }, 500)
  398. } else {
  399. console.log('重试次数已达上限,使用备用方法')
  400. this.fallbackCanvasConvert()
  401. }
  402. },
  403. // 备用的canvas转换方法
  404. fallbackCanvasConvert() {
  405. uni.canvasToTempFilePath({
  406. canvasId: 'myCanvas',
  407. success: (res) => {
  408. this.tempFilePath = res.tempFilePath
  409. console.log('海报生成成功(备用方法)', res.tempFilePath)
  410. },
  411. fail: (err) => {
  412. console.log('海报生成失败', err)
  413. uni.showToast({
  414. title: '海报生成失败',
  415. icon: 'none'
  416. })
  417. }
  418. }, this)
  419. },
  420. // 保存图片 - H5环境提示用户长按
  421. handleSaveImage() {
  422. if (!this.tempFilePath) {
  423. uni.showToast({
  424. title: '图片还未生成完成',
  425. icon: 'none'
  426. })
  427. return
  428. }
  429. // H5环境下提示用户长按保存
  430. uni.showModal({
  431. title: '保存图片',
  432. content: '请长按上方图片,选择"保存图片"即可保存到手机',
  433. showCancel: false,
  434. confirmText: '知道了'
  435. })
  436. }
  437. }
  438. }
  439. </script>
  440. <style lang="scss" scoped>
  441. .placard {
  442. display: flex;
  443. align-items: start;
  444. justify-content: center;
  445. min-height: 100vh;
  446. background-color: #f5f5f5;
  447. padding: 48rpx 26rpx;
  448. .placard-content {
  449. display: flex;
  450. flex-direction: column;
  451. align-items: center;
  452. .img-box {
  453. background-color: #fff;
  454. border-radius: 10px;
  455. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
  456. overflow: hidden;
  457. margin-bottom: 20px;
  458. img {
  459. user-select: none;
  460. -webkit-user-select: none;
  461. -webkit-touch-callout: default;
  462. }
  463. }
  464. .loading-box {
  465. background-color: #fff;
  466. border-radius: 10px;
  467. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
  468. margin-bottom: 20px;
  469. display: flex;
  470. align-items: center;
  471. justify-content: center;
  472. .loading-content {
  473. display: flex;
  474. flex-direction: column;
  475. align-items: center;
  476. .loading-spinner {
  477. width: 30px;
  478. height: 30px;
  479. border: 3px solid #f3f3f3;
  480. border-top: 3px solid #84A73F;
  481. border-radius: 50%;
  482. animation: spin 1s linear infinite;
  483. margin-bottom: 10px;
  484. }
  485. .loading-text {
  486. color: #666;
  487. font-size: 14px;
  488. }
  489. }
  490. }
  491. .add-btn {
  492. display: flex;
  493. justify-content: center;
  494. align-items: center;
  495. width: 100%;
  496. .btn {
  497. display: flex;
  498. align-items: center;
  499. justify-content: center;
  500. width: 80%;
  501. height: 40px;
  502. border-radius: 20px;
  503. color: white;
  504. font-size: 16px;
  505. background: linear-gradient(to right, #84A73F, #D8FF8F);
  506. margin-top: 20px;
  507. box-shadow: 0 4px 12px rgba(132, 167, 63, 0.3);
  508. cursor: pointer;
  509. &:active {
  510. transform: scale(0.98);
  511. transition: transform 0.1s;
  512. }
  513. }
  514. }
  515. }
  516. }
  517. .hidden-canvas {
  518. opacity: 0;
  519. position: fixed;
  520. top: -9999px;
  521. left: -9999px;
  522. pointer-events: none;
  523. }
  524. .qrcode {
  525. position: fixed;
  526. top: -9999px;
  527. left: -9999px;
  528. opacity: 0;
  529. pointer-events: none;
  530. }
  531. @keyframes spin {
  532. 0% { transform: rotate(0deg); }
  533. 100% { transform: rotate(360deg); }
  534. }
  535. </style>