457 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
		
		
			
		
	
	
			457 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
|  | <template> | |||
|  |   <view class="share-container"> | |||
|  |     <!-- This is the content that will be shared as an image --> | |||
|  |     <view class="share-content" :class="{ 'is-loaded': isLoaded }"> | |||
|  |       <view class="title">扫码注册</view> | |||
|  |       <image | |||
|  |         class="qr-code" | |||
|  |         :src="qrCodeImage" | |||
|  |         mode="aspectFit" | |||
|  |         v-if="qrCodeImage" | |||
|  |       ></image> | |||
|  |       <view v-else class="qr-code-placeholder"> | |||
|  |         <view class="loader"></view> | |||
|  |       </view> | |||
|  |       <view class="tip">扫描二维码,即可完成操作</view> | |||
|  |     </view> | |||
|  | 
 | |||
|  |     <button | |||
|  |       class="share-button" | |||
|  |       :class="{ 'is-loaded': isLoaded }" | |||
|  |       @click="sharePage" | |||
|  |     > | |||
|  |       保存图片并分享 | |||
|  |     </button> | |||
|  | 
 | |||
|  |     <!-- Canvas for generating the share image, positioned off-screen --> | |||
|  |     <canvas | |||
|  |       canvas-id="shareCanvas" | |||
|  |       :style="{ | |||
|  |         width: canvasWidth + 'px', | |||
|  |         height: canvasHeight + 'px', | |||
|  |         position: 'fixed', | |||
|  |         left: '200%', | |||
|  |       }" | |||
|  |     /> | |||
|  |   </view> | |||
|  | </template> | |||
|  | 
 | |||
|  | <script> | |||
|  | import { getShareCode } from '@/config/share' | |||
|  | 
 | |||
|  | export default { | |||
|  |   name: 'ShareQRCode', | |||
|  |   data() { | |||
|  |     return { | |||
|  |       qrCodeImage: '', | |||
|  |       // Set canvas dimensions. It's better to get device screen width for this.
 | |||
|  |       canvasWidth: 375, | |||
|  |       canvasHeight: 550, | |||
|  |       isLoaded: false, | |||
|  |     } | |||
|  |   }, | |||
|  |   onLoad() { | |||
|  |     this.handleGetShareCode() | |||
|  |     // Get screen width to set canvas width dynamically
 | |||
|  |     uni.getSystemInfo({ | |||
|  |       success: res => { | |||
|  |         this.canvasWidth = res.windowWidth | |||
|  |         // Adjust height proportionally or keep it fixed
 | |||
|  |         this.canvasHeight = res.windowWidth * 1.4 | |||
|  |       }, | |||
|  |     }) | |||
|  |   }, | |||
|  |   onReady() { | |||
|  |     // Use a short timeout to ensure the initial render is complete before animation
 | |||
|  |     setTimeout(() => { | |||
|  |       this.isLoaded = true | |||
|  |     }, 100) | |||
|  |   }, | |||
|  |   methods: { | |||
|  |     handleGetShareCode() { | |||
|  |       // Don't show loading toast, use the placeholder loader instead
 | |||
|  |       // uni.showLoading({ title: '加载中...' })
 | |||
|  |       getShareCode() | |||
|  |         .then(res => { | |||
|  |           // The screenshot shows the base64 string is in data.datStr
 | |||
|  |           if (res.code === 200 && res.data && res.data.dataStr) { | |||
|  |             this.qrCodeImage = 'data:image/png;base64,' + res.data.dataStr | |||
|  |           } else { | |||
|  |             uni.showToast({ | |||
|  |               title: '获取分享码失败', | |||
|  |               icon: 'none', | |||
|  |             }) | |||
|  |           } | |||
|  |         }) | |||
|  |         .catch(err => { | |||
|  |           console.error('getShareCode error:', err) | |||
|  |           uni.showToast({ | |||
|  |             title: '网络错误,请稍后再试', | |||
|  |             icon: 'none', | |||
|  |           }) | |||
|  |         }) | |||
|  |     }, | |||
|  | 
 | |||
|  |     async sharePage() { | |||
|  |       if (!this.qrCodeImage) { | |||
|  |         uni.showToast({ | |||
|  |           title: '二维码尚未生成', | |||
|  |           icon: 'none', | |||
|  |         }) | |||
|  |         return | |||
|  |       } | |||
|  |       uni.showLoading({ title: '正在生成图片...' }) | |||
|  | 
 | |||
|  |       try { | |||
|  |         const tempImagePath = await this.base64ToTempFilePath(this.qrCodeImage) | |||
|  |         if (!tempImagePath) { | |||
|  |           throw new Error('图片处理失败') | |||
|  |         } | |||
|  | 
 | |||
|  |         const ctx = uni.createCanvasContext('shareCanvas', this) | |||
|  |         this.drawShareImage(ctx, tempImagePath) | |||
|  | 
 | |||
|  |         ctx.draw(false, () => { | |||
|  |           this.saveCanvasToAlbum() | |||
|  |         }) | |||
|  |       } catch (error) { | |||
|  |         uni.hideLoading() | |||
|  |         uni.showToast({ title: error.message || '图片生成失败', icon: 'none' }) | |||
|  |         console.error('sharePage error:', error) | |||
|  |       } | |||
|  |     }, | |||
|  | 
 | |||
|  |     drawShareImage(ctx, tempImagePath) { | |||
|  |       const canvasWidth = this.canvasWidth | |||
|  |       const canvasHeight = this.canvasHeight | |||
|  | 
 | |||
|  |       // White background
 | |||
|  |       ctx.fillStyle = '#FFFFFF' | |||
|  |       ctx.fillRect(0, 0, canvasWidth, canvasHeight) | |||
|  | 
 | |||
|  |       // Title
 | |||
|  |       ctx.setFontSize(22) | |||
|  |       ctx.fillStyle = '#1e1e1e' | |||
|  |       ctx.textAlign = 'center' | |||
|  |       ctx.fillText('扫码注册', canvasWidth / 2, 70) | |||
|  | 
 | |||
|  |       // QR Code Image
 | |||
|  |       const qrCodeSize = canvasWidth * 0.7 | |||
|  |       const qrCodeX = (canvasWidth - qrCodeSize) / 2 | |||
|  |       const qrCodeY = 120 | |||
|  |       ctx.drawImage(tempImagePath, qrCodeX, qrCodeY, qrCodeSize, qrCodeSize) | |||
|  | 
 | |||
|  |       // Tip text
 | |||
|  |       ctx.setFontSize(15) | |||
|  |       ctx.fillStyle = '#888' | |||
|  |       ctx.textAlign = 'center' | |||
|  |       ctx.fillText( | |||
|  |         '扫描二维码,即可完成操作', | |||
|  |         canvasWidth / 2, | |||
|  |         qrCodeY + qrCodeSize + 50 | |||
|  |       ) | |||
|  |     }, | |||
|  | 
 | |||
|  |     saveCanvasToAlbum() { | |||
|  |       uni.canvasToTempFilePath( | |||
|  |         { | |||
|  |           canvasId: 'shareCanvas', | |||
|  |           success: res => { | |||
|  |             // #ifdef H5
 | |||
|  |             // For H5, trigger download instead of saving to album
 | |||
|  |             const link = document.createElement('a') | |||
|  |             link.href = res.tempFilePath | |||
|  |             link.download = `share_qrcode_${Date.now()}.png` | |||
|  |             document.body.appendChild(link) | |||
|  |             link.click() | |||
|  |             document.body.removeChild(link) | |||
|  |             uni.hideLoading() | |||
|  |             uni.showToast({ | |||
|  |               title: '图片已开始下载', | |||
|  |               icon: 'success', | |||
|  |             }) | |||
|  |             // #endif
 | |||
|  | 
 | |||
|  |             // #ifndef H5
 | |||
|  |             // For App and Mini Programs
 | |||
|  |             uni.saveImageToPhotosAlbum({ | |||
|  |               filePath: res.tempFilePath, | |||
|  |               success: () => { | |||
|  |                 uni.hideLoading() | |||
|  |                 uni.showToast({ | |||
|  |                   title: '图片已保存到相册', | |||
|  |                   icon: 'success', | |||
|  |                 }) | |||
|  |               }, | |||
|  |               fail: err => { | |||
|  |                 uni.hideLoading() | |||
|  |                 if ( | |||
|  |                   err.errMsg && | |||
|  |                   (err.errMsg.includes('auth deny') || | |||
|  |                     err.errMsg.includes('auth denied')) | |||
|  |                 ) { | |||
|  |                   uni.showModal({ | |||
|  |                     title: '提示', | |||
|  |                     content: '需要您授权保存相册', | |||
|  |                     showCancel: false, | |||
|  |                     success: () => { | |||
|  |                       uni.openSetting({ | |||
|  |                         success(settingdata) { | |||
|  |                           if ( | |||
|  |                             settingdata.authSetting['scope.writePhotosAlbum'] | |||
|  |                           ) { | |||
|  |                             uni.showToast({ | |||
|  |                               title: '授权成功,请重试', | |||
|  |                               icon: 'none', | |||
|  |                             }) | |||
|  |                           } else { | |||
|  |                             uni.showToast({ | |||
|  |                               title: '获取权限失败', | |||
|  |                               icon: 'none', | |||
|  |                             }) | |||
|  |                           } | |||
|  |                         }, | |||
|  |                       }) | |||
|  |                     }, | |||
|  |                   }) | |||
|  |                 } else { | |||
|  |                   uni.showToast({ title: '保存失败', icon: 'none' }) | |||
|  |                   console.error('saveImageToPhotosAlbum fail:', err) | |||
|  |                 } | |||
|  |               }, | |||
|  |             }) | |||
|  |             // #endif
 | |||
|  |           }, | |||
|  |           fail: err => { | |||
|  |             uni.hideLoading() | |||
|  |             uni.showToast({ title: '图片转换失败', icon: 'none' }) | |||
|  |             console.error('canvasToTempFilePath fail:', err) | |||
|  |           }, | |||
|  |         }, | |||
|  |         this | |||
|  |       ) | |||
|  |     }, | |||
|  | 
 | |||
|  |     base64ToTempFilePath(base64) { | |||
|  |       return new Promise((resolve, reject) => { | |||
|  |         // #ifdef H5
 | |||
|  |         // For H5, we load the base64 into an Image to ensure it's valid,
 | |||
|  |         // but resolve with the base64 string to avoid Uniapp's internal errors
 | |||
|  |         // when its functions expect a string path instead of an Image object.
 | |||
|  |         const image = new Image() | |||
|  |         // Resolve CORS issue for QR code from different origin
 | |||
|  |         image.crossOrigin = 'Anonymous' | |||
|  |         image.src = base64 | |||
|  |         image.onload = () => { | |||
|  |           // Resolve with the string, not the object.
 | |||
|  |           resolve(base64) | |||
|  |         } | |||
|  |         image.onerror = err => { | |||
|  |           console.error('Failed to load image for canvas on H5', err) | |||
|  |           reject(new Error('H5图片加载失败')) | |||
|  |         } | |||
|  |         // #endif
 | |||
|  | 
 | |||
|  |         // #ifndef H5
 | |||
|  |         // For App and Mini Programs, write to a temp file and return the path.
 | |||
|  |         const formattedBase64 = base64.replace(/^data:image\/\w+;base64,/, '') | |||
|  |         // Use a standard path for user data directory.
 | |||
|  |         const filePath = `${uni.env.USER_DATA_PATH}/share_${Date.now()}.png` | |||
|  |         uni.getFileSystemManager().writeFile({ | |||
|  |           filePath, | |||
|  |           data: formattedBase64, | |||
|  |           encoding: 'base64', | |||
|  |           success: () => { | |||
|  |             resolve(filePath) | |||
|  |           }, | |||
|  |           fail: err => { | |||
|  |             console.error('Failed to write temp file', err) | |||
|  |             reject(new Error('临时文件写入失败')) | |||
|  |           }, | |||
|  |         }) | |||
|  |         // #endif
 | |||
|  |       }) | |||
|  |     }, | |||
|  |   }, | |||
|  | } | |||
|  | </script> | |||
|  | 
 | |||
|  | <style lang="scss" scoped> | |||
|  | .share-container { | |||
|  |   display: flex; | |||
|  |   flex-direction: column; | |||
|  |   align-items: center; | |||
|  |   justify-content: center; | |||
|  |   padding: 40rpx; | |||
|  |   background: linear-gradient(to bottom, #e0f7fa 0%, #ffffff 100%); | |||
|  |   min-height: 100vh; | |||
|  |   box-sizing: border-box; | |||
|  |   position: relative; | |||
|  |   overflow: hidden; | |||
|  | } | |||
|  | 
 | |||
|  | @keyframes float { | |||
|  |   0% { | |||
|  |     transform: translateY(0px) scale(1); | |||
|  |     opacity: 0.7; | |||
|  |   } | |||
|  |   50% { | |||
|  |     transform: translateY(-20px) scale(1.03); | |||
|  |     opacity: 1; | |||
|  |   } | |||
|  |   100% { | |||
|  |     transform: translateY(0px) scale(1); | |||
|  |     opacity: 0.7; | |||
|  |   } | |||
|  | } | |||
|  | 
 | |||
|  | .share-container::before, | |||
|  | .share-container::after { | |||
|  |   content: ''; | |||
|  |   position: absolute; | |||
|  |   border-radius: 50%; | |||
|  |   background: linear-gradient( | |||
|  |     to top, | |||
|  |     rgba(0, 198, 255, 0.05), | |||
|  |     rgba(0, 114, 255, 0.1) | |||
|  |   ); | |||
|  |   z-index: 1; | |||
|  |   pointer-events: none; | |||
|  | } | |||
|  | 
 | |||
|  | .share-container::before { | |||
|  |   width: 400rpx; | |||
|  |   height: 400rpx; | |||
|  |   top: -150rpx; | |||
|  |   left: -150rpx; | |||
|  |   animation: float 12s ease-in-out infinite; | |||
|  | } | |||
|  | 
 | |||
|  | .share-container::after { | |||
|  |   width: 500rpx; | |||
|  |   height: 500rpx; | |||
|  |   bottom: -200rpx; | |||
|  |   right: -200rpx; | |||
|  |   animation: float 15s ease-in-out infinite -5s; | |||
|  | } | |||
|  | 
 | |||
|  | .share-content { | |||
|  |   background: radial-gradient( | |||
|  |     circle at 50% 0%, | |||
|  |     rgba(220, 235, 255, 0.9), | |||
|  |     #ffffff 80% | |||
|  |   ); | |||
|  |   border-radius: 30rpx; | |||
|  |   padding: 80rpx 50rpx; | |||
|  |   display: flex; | |||
|  |   flex-direction: column; | |||
|  |   align-items: center; | |||
|  |   width: 100%; | |||
|  |   box-shadow: | |||
|  |     0 16rpx 48rpx rgba(0, 0, 0, 0.1), | |||
|  |     inset 0 1px 2px rgba(255, 255, 255, 0.7); | |||
|  |   margin-bottom: 60rpx; | |||
|  |   position: relative; | |||
|  |   z-index: 2; | |||
|  |   opacity: 0; | |||
|  |   transform: translateY(40rpx); | |||
|  |   transition: | |||
|  |     transform 0.6s cubic-bezier(0.25, 1, 0.5, 1), | |||
|  |     opacity 0.6s ease; | |||
|  |   overflow: hidden; | |||
|  | } | |||
|  | 
 | |||
|  | .share-content::before { | |||
|  |   content: ''; | |||
|  |   position: absolute; | |||
|  |   top: 0; | |||
|  |   left: 0; | |||
|  |   right: 0; | |||
|  |   height: 6rpx; | |||
|  |   background-image: linear-gradient(90deg, #00c6ff, #0072ff); | |||
|  |   opacity: 0.9; | |||
|  | } | |||
|  | 
 | |||
|  | .share-content.is-loaded { | |||
|  |   opacity: 1; | |||
|  |   transform: translateY(0); | |||
|  | } | |||
|  | 
 | |||
|  | .title { | |||
|  |   font-size: 44rpx; | |||
|  |   font-weight: 500; | |||
|  |   color: #1e1e1e; | |||
|  |   margin-bottom: 60rpx; | |||
|  | } | |||
|  | 
 | |||
|  | .qr-code { | |||
|  |   width: 450rpx; | |||
|  |   height: 450rpx; | |||
|  |   margin-bottom: 30rpx; | |||
|  |   border-radius: 16rpx; | |||
|  | } | |||
|  | 
 | |||
|  | .qr-code-placeholder { | |||
|  |   width: 450rpx; | |||
|  |   height: 450rpx; | |||
|  |   background-color: #f0f2f5; | |||
|  |   display: flex; | |||
|  |   align-items: center; | |||
|  |   justify-content: center; | |||
|  |   margin-bottom: 30rpx; | |||
|  |   border-radius: 16rpx; | |||
|  | } | |||
|  | 
 | |||
|  | .loader { | |||
|  |   width: 100rpx; | |||
|  |   height: 100rpx; | |||
|  |   border: 8rpx solid rgba(0, 0, 0, 0.1); | |||
|  |   border-left-color: #0072ff; | |||
|  |   border-radius: 50%; | |||
|  |   animation: spin 1s linear infinite; | |||
|  | } | |||
|  | 
 | |||
|  | @keyframes spin { | |||
|  |   to { | |||
|  |     transform: rotate(360deg); | |||
|  |   } | |||
|  | } | |||
|  | 
 | |||
|  | .tip { | |||
|  |   font-size: 30rpx; | |||
|  |   color: #888; | |||
|  | } | |||
|  | 
 | |||
|  | .share-button { | |||
|  |   margin-top: 0; | |||
|  |   width: 90%; | |||
|  |   background-image: linear-gradient(90deg, #0072ff, #00c6ff); | |||
|  |   color: white; | |||
|  |   border-radius: 50rpx; | |||
|  |   font-size: 34rpx; | |||
|  |   height: 100rpx; | |||
|  |   line-height: 100rpx; | |||
|  |   box-shadow: 0 10rpx 20rpx rgba(0, 114, 255, 0.25); | |||
|  |   border: none; | |||
|  |   transition: | |||
|  |     transform 0.2s ease, | |||
|  |     box-shadow 0.2s ease; | |||
|  |   z-index: 2; | |||
|  |   opacity: 0; | |||
|  |   transform: translateY(40rpx); | |||
|  |   transition: | |||
|  |     transform 0.6s cubic-bezier(0.25, 1, 0.5, 1) 0.1s, | |||
|  |     opacity 0.6s ease 0.1s; | |||
|  | } | |||
|  | 
 | |||
|  | .share-button.is-loaded { | |||
|  |   opacity: 1; | |||
|  |   transform: translateY(0); | |||
|  | } | |||
|  | 
 | |||
|  | .share-button:active { | |||
|  |   transform: translateY(2rpx); | |||
|  |   box-shadow: 0 6rpx 12rpx rgba(0, 114, 255, 0.3); | |||
|  | } | |||
|  | </style> |