diff --git a/components/cl-tabbar.vue b/components/cl-tabbar.vue index 676c126..09fc624 100644 --- a/components/cl-tabbar.vue +++ b/components/cl-tabbar.vue @@ -240,4 +240,33 @@ export default { // margin-bottom: 30px; } } + +/* iPhone安全区域适配 */ + +/* 绿色主题的安全区域适配 */ +.greenEd { + ::v-deep .u-tabbar__content { + background: linear-gradient(to bottom, #fff, #b6fdda); + + /* 绿色主题的安全区域背景 */ + &::after { + background: #b6fdda; + } + } +} + +/* 针对不同屏幕尺寸的优化 */ +@media screen and (max-height: 667px) { + /* iPhone SE等小屏设备 */ + ::v-deep .u-tabbar__content__item-wrapper { + height: 70px; + } +} + +@media screen and (min-height: 812px) { + /* iPhone X及以上机型 */ + ::v-deep .u-tabbar__content__item-wrapper { + height: 80px; + } +} diff --git a/package-lock.json b/package-lock.json index 4d803a4..680a00a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "qrcodejs2": "0.0.2", "swiper": "^3.4.2", "uqrcodejs": "^4.0.7", + "vconsole": "^3.15.1", "vue-clipboard2": "^0.3.3", "vue-i18n": "^9.2.2", "vue-tree-color": "^2.3.2", @@ -64,6 +65,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.27.0", "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/types/-/types-7.27.0.tgz", @@ -1821,6 +1831,29 @@ "node": ">=0.10.0" } }, + "node_modules/copy-text-to-clipboard": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.0.tgz", + "integrity": "sha512-RnJFp1XR/LOBDckxTib5Qjr/PMfkatD0MUCQgdpqS8MdKiNUzBjAQBEN6oUy+jW7LI93BBG3DtMB2KOOKpGs2Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-js": { + "version": "3.43.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.43.0.tgz", + "integrity": "sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://mirrors.cloud.tencent.com/npm/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4357,6 +4390,11 @@ "dev": true, "peer": true }, + "node_modules/mutation-observer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/mutation-observer/-/mutation-observer-1.0.3.tgz", + "integrity": "sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA==" + }, "node_modules/nan": { "version": "2.22.2", "resolved": "https://mirrors.cloud.tencent.com/npm/nan/-/nan-2.22.2.tgz", @@ -6469,6 +6507,18 @@ "base64-arraybuffer": "^1.0.2" } }, + "node_modules/vconsole": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/vconsole/-/vconsole-3.15.1.tgz", + "integrity": "sha512-KH8XLdrq9T5YHJO/ixrjivHfmF2PC2CdVoK6RWZB4yftMykYIaXY1mxZYAic70vADM54kpMQF+dYmvl5NRNy1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.2", + "copy-text-to-clipboard": "^3.0.1", + "core-js": "^3.11.0", + "mutation-observer": "^1.0.3" + } + }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://mirrors.cloud.tencent.com/npm/vm-browserify/-/vm-browserify-1.1.2.tgz", diff --git a/package.json b/package.json index 27b2ced..0ec7876 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "qrcodejs2": "0.0.2", "swiper": "^3.4.2", "uqrcodejs": "^4.0.7", + "vconsole": "^3.15.1", "vue-clipboard2": "^0.3.3", "vue-i18n": "^9.2.2", "vue-tree-color": "^2.3.2", diff --git a/pages/mine/index.vue b/pages/mine/index.vue index 289b7c2..01288a0 100644 --- a/pages/mine/index.vue +++ b/pages/mine/index.vue @@ -402,13 +402,13 @@ export default { menuKey: 'selfHelp', ifshow: true, }, - { - url: '/pages/mine/share/index', - name: '个人推广', - imgurl: '../../static/images/list.svg', - menuKey: 'share', - ifshow: false, - }, + // { + // url: '/pages/mine/share/index', + // name: '个人推广', + // imgurl: '../../static/images/list.svg', + // menuKey: 'share', + // ifshow: false, + // }, { url: '/pages/userSecure/index', name: '账号安全', diff --git a/pages/mine/share/index.vue b/pages/mine/share/index.vue index c651fb2..6847130 100644 --- a/pages/mine/share/index.vue +++ b/pages/mine/share/index.vue @@ -1,37 +1,70 @@ @@ -39,6 +72,8 @@ import html2canvas from 'html2canvas' import { getShareCode } from '@/config/share' import clTabbar from '@/components/cl-tabbar.vue' +import VConsole from 'vconsole' +const vConsole = new VConsole() export default { name: 'ShareQRCode', components: { @@ -52,16 +87,20 @@ export default { canvasHeight: 800, isLoaded: false, shareButtonShow: true, + isWechat: false, // 是否微信环境 + generatedImageUrl: '', // 生成的图片URL,用于微信长按保存 + backgroundImageLoaded: false, // 背景图片是否加载完成 + userInfo: uni.getStorageSync('User'), } }, onLoad() { + this.checkWechatEnvironment() this.handleGetShareCode() // Get screen width to set canvas width dynamically uni.getSystemInfo({ success: res => { this.canvasWidth = res.windowWidth - // Set canvas height to a fixed 800px as requested - this.canvasHeight = 800 + this.canvasHeight = res.windowHeight }, }) }, @@ -72,6 +111,20 @@ export default { }, 100) }, methods: { + desensitization(str) { + if (!str) return '' + if (str.length <= 8) return str.slice(0, 4) + '****' + const len = str.length - 6 + const placeholder = '*'.repeat(len) + return str.slice(0, 4) + placeholder + str.slice(-2) + }, + // 检测微信环境 + checkWechatEnvironment() { + const ua = navigator.userAgent.toLowerCase() + this.isWechat = ua.includes('micromessenger') + console.log('微信环境检测:', this.isWechat) + }, + handleGetShareCode() { // Don't show loading toast, use the placeholder loader instead // uni.showLoading({ title: '加载中...' }) @@ -80,6 +133,11 @@ export default { // 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 + this.$nextTick(() => { + if (this.isWechat) { + this.sharePage() + } + }) } else { uni.showToast({ title: '获取分享码失败', @@ -104,388 +162,163 @@ export default { }) return } - this.shareButtonShow = false - uni.showLoading({ title: '正在生成图片...' }) + + // 统一使用html2canvas生成图片 + uni.showLoading({ title: '加载中...' }) try { - // --- Button Swap Logic --- + // 隐藏按钮,等待DOM更新 this.shareButtonShow = false - // Wait for DOM to update with the fake button await this.$nextTick() - // #ifdef H5 - // Capture the entire container as requested - await this.captureWithHtml2Canvas() - // #endif - - // #ifndef H5 - // Draw the entire container using Canvas - await this.generateImageWithCanvas() - // #endif + // 使用html2canvas截取页面 + await this.capturePageWithHtml2Canvas() } catch (error) { uni.hideLoading() uni.showToast({ title: '图片生成失败,请稍后重试', icon: 'none' }) console.error('sharePage error:', error) } finally { - // --- Always swap back to the real button --- + // 恢复按钮显示 this.shareButtonShow = true } }, - // --- H5-specific method --- - captureWithHtml2Canvas() { - return new Promise((resolve, reject) => { - // #ifdef H5 - // Target the entire #shareContainer as per instruction - const element = document.getElementById('shareContainer') - if (!element) { - uni.hideLoading() - return reject(new Error('Share container element not found')) - } + // 生成的图片加载成功 + onGeneratedImageLoad() { + console.log('生成的图片加载成功') + }, - html2canvas(element, { - useCORS: true, - allowTaint: true, - backgroundColor: null, - scale: window.devicePixelRatio || 2, - logging: false, - }) - .then(canvas => { - this.saveImageToAlbum(canvas.toDataURL('image/png', 1.0)) - resolve() - }) - .catch(err => { - console.error('html2canvas capture failed:', err) - reject(err) - }) - // #endif + // 生成的图片加载失败 + onGeneratedImageError(e) { + console.error('生成的图片加载失败:', e) + uni.showToast({ + title: '图片显示失败', + icon: 'none', }) }, - // A robust canvas-based image generation for all platforms - async generateImageWithCanvas() { - try { - // Load all required images before drawing - const [bgImageTempPath, qrCodeTempPath] = await Promise.all([ - this.getLocalImageTempPath('/static/images/share-bg.png'), - this.base64ToTempFilePath(this.qrCodeImage), - ]) + // 关闭全屏图片 + closeFullscreenImage() { + this.generatedImageUrl = '' + console.log('关闭全屏图片') + }, - if (!bgImageTempPath || !qrCodeTempPath) { - throw new Error('Image resource failed to load') - } + // 背景图片加载成功 + onBackgroundImageLoad() { + this.backgroundImageLoaded = true + console.log('背景图片加载成功') + }, - const ctx = uni.createCanvasContext('shareCanvas', this) - // Draw the entire scene to the canvas - this.drawSceneToCanvas(ctx, bgImageTempPath, qrCodeTempPath) + // 背景图片加载失败 + onBackgroundImageError(e) { + console.error('背景图片加载失败:', e) + }, - return new Promise(resolve => { + // 使用html2canvas截取整个页面 + async capturePageWithHtml2Canvas() { + console.log('开始使用html2canvas截取页面') + + return new Promise((resolve, reject) => { + // 确保所有图片都已加载完成 + const waitForImages = () => { + if (!this.backgroundImageLoaded || !this.qrCodeImage) { + setTimeout(waitForImages, 100) + return + } + + // 额外等待确保渲染完成 setTimeout(() => { - ctx.draw(false, () => { - setTimeout(() => { - this.saveCanvasToAlbum() - resolve() - }, 500) - }) - }, 100) - }) - } catch (error) { - console.error('generateImageWithCanvas error:', error) - throw error - } - }, - - // Main drawing function to replicate the entire scene on canvas - drawSceneToCanvas(ctx, bgPath, qrPath) { - const canvasWidth = this.canvasWidth - const canvasHeight = this.canvasHeight // Fixed at 800px - - // 1. Draw background image - ctx.drawImage(bgPath, 0, 0, canvasWidth, canvasHeight) - - // 2. Draw portal frame (calculations for centering) - const portalWidth = canvasWidth * 0.85 - const portalPadding = 20 - // The white card's width is the portal's inner width - const cardWidth = portalWidth - portalPadding * 2 - // The card is a square, so its height is its width - const cardHeight = cardWidth - const buttonHeight = 50 - const buttonMargin = 20 - const portalHeight = - portalPadding * 2 + cardHeight + buttonMargin + buttonHeight - const portalX = (canvasWidth - portalWidth) / 2 - const portalY = (canvasHeight - portalHeight) / 2 - this.drawPortalFrame(ctx, portalX, portalY, portalWidth, portalHeight) - - // 3. Draw the white QR card - const cardX = portalX + portalPadding - const cardY = portalY + portalPadding - this.drawQrCodeCard(ctx, cardX, cardY, cardWidth, cardHeight) - - // 4. Draw the FAKE button to match the CSS - const buttonX = cardX - const buttonY = cardY + cardHeight + buttonMargin - this.drawStyledButton( - ctx, - buttonX, - buttonY, - cardWidth, - buttonHeight, - '保存图片并分享' - ) - - // 5. Draw the actual QR image (or loader placeholder) on top - // If qrPath is null/empty, this block is skipped, leaving the white card empty (like a placeholder) - if (qrPath) { - // Padding inside the white card - const imagePadding = cardWidth * 0.1 - const qrCodeSize = cardWidth - imagePadding * 2 - const qrCodeX = cardX + imagePadding - const qrCodeY = cardY + imagePadding - ctx.drawImage(qrPath, qrCodeX, qrCodeY, qrCodeSize, qrCodeSize) - } else { - // Draw loader manually if QR is not available - // This part is complex, for now we show an empty card which is better than broken UI - } - }, - - // Helper: Draws the portal frame to match CSS - drawPortalFrame(ctx, x, y, width, height) { - const radius = 25 // 50rpx - ctx.save() - this.drawRoundedRect(ctx, x, y, width, height, radius) - // Frosted glass effect - const portalGradient = ctx.createLinearGradient(x, y, x, y + height) - portalGradient.addColorStop(0, 'rgba(255, 255, 255, 0.4)') - portalGradient.addColorStop(1, 'rgba(255, 255, 255, 0.2)') - ctx.fillStyle = portalGradient - ctx.fill() - // Inner glow border - ctx.shadowColor = 'rgba(255, 255, 255, 0.5)' - ctx.shadowBlur = 10 - ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)' - ctx.lineWidth = 1.5 - ctx.stroke() - ctx.restore() - }, - - // New/Refactored Helper: Draws the single white QR Code card - drawQrCodeCard(ctx, x, y, width, height) { - const borderRadius = 20 // 40rpx - - ctx.save() - this.drawRoundedRect(ctx, x, y, width, height, borderRadius) - // Card background - ctx.fillStyle = 'rgba(255, 255, 255, 0.98)' - // Card shadow - ctx.shadowColor = 'rgba(50, 50, 90, 0.1)' - ctx.shadowBlur = 30 - ctx.shadowOffsetY = 15 - ctx.fill() - // Card border - ctx.strokeStyle = '#f0f0f0' - ctx.lineWidth = 1 - ctx.stroke() - ctx.restore() - }, - - // Helper: Draws the styled button - drawStyledButton(ctx, x, y, width, height, text) { - const radius = height / 2 - - ctx.save() - this.drawRoundedRect(ctx, x, y, width, height, radius) - // Background gradient - const gradient = ctx.createLinearGradient(x, y, x + width, y) - gradient.addColorStop(0, '#0ff0fc') - gradient.addColorStop(0.5, '#0072ff') - gradient.addColorStop(1, '#00c6ff') - ctx.fillStyle = gradient - // Shadow - ctx.shadowColor = 'rgba(0, 114, 255, 0.35)' - ctx.shadowBlur = 20 - ctx.shadowOffsetY = 8 - ctx.fill() - ctx.restore() - - // Text - ctx.save() - ctx.fillStyle = '#ffffff' - ctx.font = '17px sans-serif' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.fillText(text, x + width / 2, y + height / 2) - ctx.restore() - }, - - // Helper: Draw a rectangle with rounded corners - drawRoundedRect(ctx, x, y, width, height, radius) { - if (width < 2 * radius) radius = width / 2 - if (height < 2 * radius) radius = height / 2 - ctx.beginPath() - ctx.moveTo(x + radius, y) - ctx.arcTo(x + width, y, x + width, y + height, radius) - ctx.arcTo(x + width, y + height, x, y + height, radius) - ctx.arcTo(x, y + height, x, y, radius) - ctx.arcTo(x, y, x + width, y, radius) - ctx.closePath() - }, - - saveCanvasToAlbum() { - try { - // 统一的canvas转换参数 - const options = { - canvasId: 'shareCanvas', - fileType: 'png', - quality: 1, - success: res => { - console.log('canvas转换成功:', res) - if (res.tempFilePath) { - this.saveImageToAlbum(res.tempFilePath) - } else { - uni.hideLoading() - uni.showToast({ title: '图片生成失败', icon: 'none' }) + const element = document.getElementById('shareContainer') + if (!element) { + reject(new Error('找不到页面容器')) + return } - }, - fail: err => { - console.error('canvas转换失败:', err) - uni.hideLoading() - uni.showToast({ title: '图片转换失败', icon: 'none' }) - }, + + console.log( + '开始html2canvas截取,容器尺寸:', + element.offsetWidth, + 'x', + element.offsetHeight + ) + + html2canvas(element, { + useCORS: true, + allowTaint: true, + backgroundColor: null, + scale: Math.max(3, window.devicePixelRatio * 2), + logging: true, // 开启日志便于调试 + width: element.offsetWidth, + height: element.offsetHeight, + windowWidth: element.offsetWidth, + windowHeight: element.offsetHeight, + scrollX: 0, + scrollY: 0, + x: 0, + y: 0, + }) + .then(canvas => { + const dataUrl = canvas.toDataURL('image/png', 1.0) + + // 根据环境处理结果 + if (this.isWechat) { + // 微信环境:设置图片供长按保存 + this.generatedImageUrl = dataUrl + uni.hideLoading() + } else { + // 普通浏览器:直接下载图片 + this.downloadImage(dataUrl) + uni.hideLoading() + uni.showToast({ + title: '图片已开始下载', + icon: 'success', + }) + } + + resolve() + }) + .catch(err => { + console.error('html2canvas截取失败:', err) + reject(err) + }) + }, 1000) // 增加等待时间到1000ms } - // 调用canvas转换API - uni.canvasToTempFilePath(options, this) - } catch (error) { - console.error('saveCanvasToAlbum error:', error) - uni.hideLoading() - uni.showToast({ title: '保存失败', icon: 'none' }) - } + // 开始等待图片加载 + waitForImages() + }) }, - // 新增:统一的图片保存方法 - saveImageToAlbum(filePath) { - // #ifdef H5 - // For H5, trigger download instead of saving to album + // 原生图片加载器 + loadImage(src) { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + + img.onload = () => { + console.log( + `图片加载成功: ${src.substring(0, 50)}...`, + `${img.width}x${img.height}` + ) + resolve(img) + } + + img.onerror = error => { + console.error(`图片加载失败: ${src}`, error) + reject(new Error(`图片加载失败: ${src}`)) + } + + img.src = src + }) + }, + + // 下载图片 + downloadImage(dataUrl) { const link = document.createElement('a') - link.href = filePath + link.href = dataUrl link.download = `share_page_${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: filePath, - 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 - }, - - base64ToTempFilePath(base64) { - return new Promise((resolve, reject) => { - try { - // #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. - if (!base64 || typeof base64 !== 'string') { - reject(new Error('无效的base64数据')) - return - } - - const formattedBase64 = base64.replace(/^data:image\/\w+;base64,/, '') - if (!formattedBase64) { - reject(new Error('base64数据格式错误')) - return - } - - // Use a standard path for user data directory. - const filePath = `${uni.env.USER_DATA_PATH}/share_${Date.now()}.png` - const fileManager = uni.getFileSystemManager() - - fileManager.writeFile({ - filePath, - data: formattedBase64, - encoding: 'base64', - success: () => { - resolve(filePath) - }, - fail: err => { - console.error('Failed to write temp file', err) - reject(new Error('临时文件写入失败')) - }, - }) - // #endif - } catch (error) { - console.error('base64ToTempFilePath error:', error) - reject(new Error('图片处理失败')) - } - }) }, }, } @@ -493,9 +326,8 @@ export default { diff --git a/static/images/share-bg.jpg b/static/images/share-bg.jpg index 48c1e1f..962d4ea 100644 Binary files a/static/images/share-bg.jpg and b/static/images/share-bg.jpg differ diff --git a/static/images/share-logo.png b/static/images/share-logo.png new file mode 100644 index 0000000..426b58f Binary files /dev/null and b/static/images/share-logo.png differ