| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429 |
- <template>
- <view class="share-container">
- <!-- 顶部导航栏(模拟美团风格) -->
- <!-- <up-status-bar /> -->
- <CustomNavigationBar :show-back="true" title="优惠券分享" />
- <!-- 分享图片预览区域 -->
- <view class="preview-area" :style="{ paddingTop: `calc(${topSafeAreaHeight}px + 90rpx)`}">
- <canvas id="shareCanvas" type="2d" canvas-id="shareCanvas"
- :style="{ width: `${canvasWidth}px`, height: `${canvasHeight}px` }" class="canvas-hide" />
- <!-- 预览图片容器 -->
- <view v-if="shareImageUrl" class="preview-image-wrapper">
- <image :src="shareImageUrl" mode="widthFix" class="preview-image" @longpress="handleLongPressImage"
- @tap="handleImagePreview" />
- </view>
- <!-- 广誉源H5链接-二维码 -->
- <view v-if="couponDetail.platformSource == '2'" class="qc-code-img">
- <uqrcode ref="uqrcodeRef" :hide="true" canvas-id="qrcode" size="240" sizeUnit="rpx"
- :value="`https://city.baoxianzhanggui.com/fragrance/#/pages/index/index?shareType='COUPON'&shareContentId=${shareId}`"
- h5DownloadName="领取优惠券" :h5SaveIsDownload="true" logo="@/static/images/gyy_logo.png" :logoSize="60" :logoBorderRadius="8" logoBorderColor="#FFFFFF" :logoBorderWidth="2" />
- <!-- h5DownloadName="领取优惠券" :h5SaveIsDownload="true" /> -->
- <!-- <image mode="scaleToFill" :src="qcCodeImg" /> -->
- </view>
- <!-- 生成图片按钮(如果自动生成失败) -->
- <view v-if="!shareImageUrl && !loading" class="generate-btn-container">
- <button class="generate-btn" @tap="generateShareImage">
- <text class="icon">🔄</text>
- <text>生成分享图片</text>
- </button>
- </view>
- </view>
- <!-- 分享操作区域 -->
- <view class="action-area">
- <!-- 操作说明 -->
- <view class="action-intro">
- <text class="intro-title">分享方式</text>
- <text class="intro-desc">选择以下方式分享给好友</text>
- </view>
- <view class="share-buttons">
- <!-- 分享给好友 - 使用微信原生分享卡片 -->
- <button class="share-btn share-friend" @tap="handleShareFriend">
- <view class="btn-icon">
- <text class="icon">👥</text>
- </view>
- <text class="btn-text">分享好友</text>
- <text class="btn-desc">发送图片给好友</text>
- </button>
- <!-- 保存到相册 -->
- <button class="share-btn share-save" :disabled="!shareImageUrl" @tap="handleSaveImage">
- <view class="btn-icon">
- <text class="icon">💾</text>
- </view>
- <text class="btn-text">保存图片</text>
- <text class="btn-desc">保存到手机相册</text>
- </button>
- </view>
- </view>
- <!-- 加载提示 -->
- <view v-if="loading" class="loading-mask">
- <view class="loading-content">
- <view class="loading-spinner" />
- <text>生成分享图片中...</text>
- </view>
- </view>
- </view>
- </template>
- <script setup>
- import { onLoad } from '@dcloudio/uni-app'
- import { useRequest } from 'alova/client'
- import { storeToRefs } from 'pinia'
- import { nextTick, ref } from 'vue'
- import CustomNavigationBar from '@/components/CustomNavigationBar.vue'
- import { getCouponDetail } from '@/api/home'
- import { getWxQcCode, markSuccess } from '@/api/receiveCoupon'
- import { useInviteCodeStore } from '@/store/inviteQCCode'
- import { parseUrlParams, safeAreaInsets, isPcWeixinMp } from '@/utils'
- import { getLastPartAfterSlash } from '@/utils/couponClassFormat'
- import { getImageUrl } from '@/utils/imageUtil'
- import Uqrcode from '@/uni_modules/Sansnn-uQRCode/components/uqrcode/uqrcode.vue'
- definePage({
- style: {
- navigationBarTitleText: '',
- navigationStyle: 'custom',
- }
- // enableShareAppMessage: true,
- // enableShareTimeline: true,
- })
- // 响应式数据
- const uqrcodeRef = ref(null)
- const loading = ref(false)
- const shareImageUrl = ref('')
- const canvasWidth = ref(750)
- const canvasHeight = ref(1200)
- const topSafeAreaHeight = safeAreaInsets?.top || 0
- const inviteCodeStore = useInviteCodeStore()
- const { inviteCode } = storeToRefs(inviteCodeStore)
- // 系统信息
- const systemInfo = ref({})
- const shareId = ref('')
- const shareRecordId = ref('')
- const { send, data: couponDetail } = useRequest(getCouponDetail, {
- immediate: false,
- initialData: {
- name: '',
- type: '2',
- relatedName: '',
- ruleMinSpendAmount: '',
- ruleReductionAmount: '',
- validityType: '1',
- validDays: '',
- validStartTime: '',
- validEndTime: '',
- }
- })
- // 生命周期
- onLoad(async (options) => {
- // 获取系统信息
- systemInfo.value = uni.getSystemInfoSync()
- if (options.couponId) {
- shareId.value = options.couponId
- await send({ templateId: options.couponId })
- }
- // 页面加载后生成分享图片
- await nextTick()
- await generateShareImage()
- })
- function handleBack() {
- uni.navigateBack()
- }
- // 获取canvas实例
- function getCanvasInstance() {
- return new Promise((resolve, reject) => {
- const query = uni.createSelectorQuery()
- query.select('#shareCanvas')
- .fields({ node: true, size: true })
- .exec((res) => {
- if (res[0]) {
- const canvas = res[0].node
- const ctx = canvas.getContext('2d')
- resolve({ canvas, ctx })
- }
- else {
- reject(new Error('Canvas not found'))
- }
- })
- })
- }
- async function getWxacodeBase64() {
- // const token = await getWxToken()
- // console.log('token:', token, shareId.value)
- // const res = await getWxacode(token.data.access_token, shareId.value)
- const res = await getWxQcCode({
- shareType: 'COUPON',
- sharePath: 'pages/home/index',
- shareContentId: shareId.value,
- width: 290,
- })
- const contentType = res.header['Content-Type'] || res.header['content-type']
- const contentData = res.header['x-content-data'] || res.header['x-content-data']
- if (contentType && contentType.includes('image')) {
- // 返回的是图片
- // 将arraybuffer转换为base64
- const arrayBuffer = res.data
- const base64 = uni.arrayBufferToBase64(arrayBuffer)
- const imageUrl = `data:image/jpeg;base64,${base64}`
- const params = parseUrlParams(contentData)
- shareRecordId.value = params[inviteCode.value.shareKey]
- console.log('小程序码imageUrl:', imageUrl);
-
- return imageUrl
- }
- else {
- // 返回的是错误信息
- try {
- // 尝试将arraybuffer转换为字符串
- const decoder = new TextDecoder('utf-8')
- const errorText = decoder.decode(new Uint8Array(res.data))
- const errorData = JSON.parse(errorText)
- throw new Error(errorData.errmsg || '获取小程序码失败')
- }
- catch (e) {
- throw new Error('获取小程序码失败,请检查access_token')
- }
- }
- }
- // 生成分享图片-广誉源
- async function gyyShareImage() {
- return new Promise((resole, reject) => {
- uqrcodeRef.value.toTempFilePath({
- success: ({ tempFilePath }) => {
- resole(tempFilePath);
- },
- fail: (err) => {
- reject(err);
- }
- });
- })
- }
- // 生成分享图片-本地生活
- async function generateShareImage() {
- if (loading.value)
- return
- loading.value = true
- try {
- // 计算尺寸
- const dpr = systemInfo.value.pixelRatio || 1
- const baseWidth = 375
- const imageRatio = 649 / 1413
- canvasWidth.value = baseWidth * dpr
- canvasHeight.value = Math.round((baseWidth / imageRatio) * dpr)
- await nextTick()
- // 获取canvas实例
- const { canvas, ctx } = await getCanvasInstance()
- // 设置canvas尺寸
- canvas.width = canvasWidth.value
- canvas.height = canvasHeight.value
- // 生成二维码
- let qrCodeImg = null
- try {
- if (couponDetail.value.platformSource === '1') {
- qrCodeImg = await getWxacodeBase64()
- } else {
- // 广誉源-h5链接-二维码
- qrCodeImg = await gyyShareImage()
- }
- }
- catch (error) {
- console.error('获取小程序失败:', error)
- // 在小程序中,如果获取二维码失败,直接使用null
- qrCodeImg = null
- }
- // 绘制完整的分享图片
- await drawCompleteShareImage(ctx, canvas, qrCodeImg)
- }
- catch (error) {
- console.error('生成分享图片失败:', error)
- uni.showToast({
- title: '生成失败,请重试',
- icon: 'none',
- })
- }
- finally {
- loading.value = false
- }
- }
- // 绘制圆角矩形
- function drawRoundRect(ctx, x, y, width, height, radius) {
- ctx.beginPath()
- ctx.moveTo(x + radius, y)
- ctx.lineTo(x + width - radius, y)
- ctx.arcTo(x + width, y, x + width, y + radius, radius)
- ctx.lineTo(x + width, y + height - radius)
- ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius)
- ctx.lineTo(x + radius, y + height)
- ctx.arcTo(x, y + height, x, y + height - radius, radius)
- ctx.lineTo(x, y + radius)
- ctx.arcTo(x, y, x + radius, y, radius)
- ctx.closePath()
- }
- // 绘制完整的分享图片
- async function drawCompleteShareImage(ctx, canvas, qrCodeImg) {
- return new Promise(async (resolve, reject) => {
- try {
- const { width, height } = canvas
- const scale = width / 375 // 缩放比例
- // 1. 加载并绘制远程背景图片
- let backgroundImgName = couponDetail.value.platformSource === '1' ? 'local_share_poster' : 'gyy_share_poster'
- const backgroundImgUrl = getImageUrl(`@img/share/${backgroundImgName}.png`)
- const backgroundImg = await loadRemoteImage(canvas, backgroundImgUrl)
- ctx.drawImage(backgroundImg, 0, 0, width, height)
- // 2. 绘制标题
- drawTitle(ctx, width, height, scale)
- // 3. 绘制优惠券金额
- drawCouponAmount(ctx, width, height, scale)
- // 4. 绘制优惠券描述
- drawCouponDescription(ctx, width, height, scale)
- // 5. 绘制二维码区域
- if (couponDetail.value.platformSource === '2') {
- await drawGyyQRCodeArea(ctx, canvas, qrCodeImg, width, height, scale)
- } else {
- await drawQRCodeArea(ctx, canvas, qrCodeImg, width, height, scale)
- }
- // 6. 绘制底部信息
- // drawFooter(ctx, width, height, scale)
- // 生成图片
- uni.canvasToTempFilePath({
- canvas,
- quality: 0.9,
- success: (res) => {
- shareImageUrl.value = res.tempFilePath
- resolve()
- },
- fail: (err) => {
- console.error('生成图片失败:', err)
- reject(err)
- }
- }, this)
- }
- catch (error) {
- console.error('绘制图片失败:', error)
- reject(error)
- }
- })
- }
- // 加载远程图片的函数
- async function loadRemoteImage(canvas, imageUrl) {
- return new Promise((resolve, reject) => {
- const image = canvas.createImage()
- const timeout = setTimeout(() => {
- reject(new Error('图片加载超时'))
- }, 10000) // 10秒超时
- uni.getImageInfo({
- src: imageUrl,
- success: (res) => {
- image.onload = () => {
- clearTimeout(timeout)
- const imageWidth = image.naturalWidth || image.width || res.width
- const imageHeight = image.naturalHeight || image.height || res.height
- if ((isPcWeixinMp() || image.complete) && imageWidth > 0 && imageHeight > 0) {
- resolve(image)
- }
- else {
- reject(new Error('图片加载事件触发但尺寸无效'))
- }
- }
- image.onerror = (err) => {
- clearTimeout(timeout)
- console.error('加载远程图片失败:', err)
- reject(err)
- }
- image.src = res.path
- },
- fail: (fail) => {
- reject(new Error('获取图片信息失败'))
- },
- })
- })
- }
- // 绘制标题
- function drawTitle(ctx, width, height, scale) {
- const titleText = couponDetail.value.type === '4' ? '兑换券' : '优惠券'
- const textY = couponDetail.value.type === '4' ? '180' : '155'
- const fontSize = couponDetail.value.type === '4' ? '24' : '18'
- ctx.fillStyle = '#FF5365'
- ctx.font = `500 ${fontSize * scale}px PinfgFang SC`
- ctx.textAlign = 'center'
- ctx.fillText(titleText, width / 2, textY * scale)
- }
- // 绘制优惠券金额
- function drawCouponAmount(ctx, width, height, scale) {
- const centerX = width / 2
- const bottomY = 220 * scale
- ctx.textBaseline = 'bottom' // 设置文本基线为底部对齐
- if (couponDetail.value.type === '4') {
- // 兑换券:指定商品
- }
- else if (couponDetail.value.type === '2') {
- // 折扣券:数字 + 折
- const numberFont = `bold ${48 * scale}px D-DIN Exp-DINExp`
- const unitFont = `bold ${16 * scale}px PingFang SC`
- ctx.fillStyle = '#FF5365'
- // 计算数字宽度
- ctx.font = numberFont
- const number = couponDetail.value.ruleDiscountRate
- const numberWidth = ctx.measureText(number).width
- // 计算"折"字宽度
- ctx.font = unitFont
- const unit = '折'
- const unitWidth = ctx.measureText(unit).width
- // 计算总宽度
- const totalWidth = numberWidth + unitWidth + 6 * scale // 8px间距(减小间距)
- // 计算起始X坐标(整体居中)
- const startX = centerX - totalWidth / 2
- // 绘制数字
- ctx.font = numberFont
- ctx.textAlign = 'left'
- ctx.fillText(number, startX, bottomY)
- // 绘制"折"字(与数字间距6px,垂直位置微调使视觉对齐)
- ctx.font = unitFont
- // 根据字体大小比例微调"折"字的y坐标,使其视觉上与数字底部对齐
- const unitY = bottomY - (48 - 16) * scale * 0.3 // 微调垂直位置
- ctx.fillText(unit, startX + numberWidth + 5 * scale, unitY)
- }
- else if (couponDetail.value.type === '3') {
- // 满减券:数字 + 元
- const numberFont = `bold ${48 * scale}px D-DIN Exp-DINExp`
- const symbolFont = `500 ${16 * scale}px PingFang SC`
- // 计算数字宽度
- ctx.fillStyle = '#FF5365'
- ctx.font = numberFont
- const number = couponDetail.value.ruleReductionAmount
- const numberWidth = ctx.measureText(number).width
- // 计算"元"符号宽度
- ctx.fillStyle = '#FF5365'
- ctx.font = symbolFont
- const symbol = '元'
- const symbolWidth = ctx.measureText(symbol).width
- console.log('symbolWidth:', ctx.measureText(symbol))
- // const symbolHeight = ctx.measureText(symbol).height
- // 计算总宽度
- const totalWidth = symbolWidth + numberWidth + 10 * scale // 10px间距(减小间距)
- // 计算起始X坐标(整体居中)
- const startX = centerX - totalWidth / 2
- // 绘制"元"符号(垂直位置微调使视觉对齐)
- ctx.textAlign = 'left'
- // 根据字体大小比例微调"元"符号的y坐标,使其视觉上比数字底部高2px
- const symbolY = bottomY - (48 - 16) * scale * 0.3 // 微调垂直位置
- ctx.fillText(symbol, startX + numberWidth + 5 * scale, symbolY)
- // 绘制数字
- ctx.fillStyle = '#FF5365'
- ctx.font = numberFont
- ctx.fillText(number, startX + 3 * scale, bottomY)
- }
- // 恢复默认文本对齐方式
- ctx.textAlign = 'center'
- ctx.textBaseline = 'alphabetic'
- }
- // 绘制优惠券描述
- function drawCouponDescription(ctx, width, height, scale) {
- // 主标题
- ctx.fillStyle = '#000000'
- ctx.font = `400 ${14 * scale}px PingFang SC`
- ctx.textAlign = 'center'
- // 文本自动换行辅助函数
- const drawWrappedText = (text, fontSize, yPosition, color, maxLines = 2) => {
- ctx.font = `${fontSize * scale}px PingFang SC`
- ctx.fillStyle = color
- ctx.textAlign = 'center'
- const maxWidth = width - 80 * scale
- const lineHeight = fontSize * 1.5 * scale
- let currentY = yPosition
- let currentLine = ''
- let lineCount = 0
- for (let char of text) {
- const testLine = currentLine + char
- const metrics = ctx.measureText(testLine)
- if (metrics.width > maxWidth && currentLine.length > 0) {
- ctx.fillText(currentLine, width / 2, currentY)
- currentLine = char
- currentY += lineHeight
- lineCount++
- // 超过最大行数则停止
- if (lineCount >= maxLines) {
- // 最后一行添加省略号
- currentLine = `${currentLine}...`
- break
- }
- }
- else {
- currentLine = testLine
- }
- }
- if (currentLine && lineCount < maxLines) {
- ctx.fillText(currentLine, width / 2, currentY)
- }
- return currentY + lineHeight // 返回下一行的y坐标
- }
- const relatedType = couponDetail.value.relatedType
- let descriptionText = ''
- let platformSource = couponDetail.value.platformSource
- let type = couponDetail.value.type
- let relatedName = couponDetail.value.relatedName
- if (type === '4') {
- // 兑换券-指定商品
- relatedName = getLastPartAfterSlash(couponDetail.value.relatedName)
- descriptionText = `限${relatedName}${platformSource === '1' ? '分类商品' : '类'}使用`
- } else {
- if (relatedType === '1') {
- descriptionText = `全部适用`
- } else {
- descriptionText = `限部分适用`
- }
- }
- // 使用自动换行函数绘制
- const fontSize = 14
- const lineHeight = 20 * 1.5 * scale
- const descY = couponDetail.value.type === '4' ? 275 : 255
- const nextLineY = drawWrappedText(descriptionText, fontSize, descY * scale, '#000000')
- }
- // 绘制二维码区域
- async function drawQRCodeArea(ctx, canvas, qrCodeImg, width, height, scale) {
- const qrSize = 120 * scale
- const qrX = (width - qrSize) / 2
- const qrY = couponDetail.value.type === '4' ? 310 * scale : 280 * scale
- // 使用柔和的白色背景,增加卡片层次感
- ctx.fillStyle = '#FFFFFF'
- ctx.fill()
- // 清除阴影
- ctx.shadowColor = 'transparent'
- ctx.shadowBlur = 0
- ctx.shadowOffsetY = 0
- // 1. 优化二维码边框:添加双层边框效果
- const qrRadius = qrSize / 2
- const centerX = qrX + qrRadius
- const centerY = qrY + qrRadius
- // 外层渐变边框
- ctx.save()
- ctx.beginPath()
- ctx.arc(centerX, centerY, qrRadius + 8 * scale, 0, Math.PI * 2)
- // 创建渐变效果
- const gradient = ctx.createLinearGradient(centerX - qrRadius, centerY - qrRadius, centerX + qrRadius, centerY + qrRadius)
- gradient.addColorStop(0, '#E8F5E9')
- gradient.addColorStop(1, '#C8E6C9')
- ctx.fillStyle = gradient
- ctx.fill()
- ctx.restore()
- // 内层白色边框
- ctx.beginPath()
- ctx.arc(centerX, centerY, qrRadius + 4 * scale, 0, Math.PI * 2)
- ctx.fillStyle = '#FFFFFF'
- ctx.fill()
- // 2. 绘制二维码图片(保持原有逻辑,增加加载成功后的边框效果)
- if (qrCodeImg) {
- try {
- let imgSrc = qrCodeImg
- let tempFilePath = ''
- // 检查是否已经是临时文件路径
- const isTempFile = typeof qrCodeImg === 'string' && !qrCodeImg.startsWith('data:image')
- // 如果是临时文件路径,直接使用
- if (isTempFile) {
- tempFilePath = qrCodeImg
- }
- else {
- // 处理base64图片数据
- if (typeof qrCodeImg === 'string') {
- // 安全地尝试解码URI,避免URIError
- try {
- if (qrCodeImg.includes('%')) {
- imgSrc = decodeURIComponent(qrCodeImg)
- }
- }
- catch (uriError) {
- console.warn('URI解码失败,使用原始数据:', uriError)
- }
- // 确保base64数据有正确的前缀
- if (!imgSrc.startsWith('data:image')) {
- imgSrc = `data:image/png;base64,${imgSrc}`
- }
- }
- // 尝试使用临时文件方式
- try {
- // #ifdef MP-WEIXIN
- const fs = uni.getFileSystemManager()
- const tempFileName = `temp_qr_${Date.now()}.png`
- tempFilePath = `${uni.env.USER_DATA_PATH}/${tempFileName}`
- // 提取base64数据部分
- let base64Data = imgSrc
- if (imgSrc.includes('base64,')) {
- base64Data = imgSrc.split('base64,')[1]
- }
- fs.writeFileSync(tempFilePath, base64Data, 'base64')
- // #endif
- }
- catch (fileError) {
- console.warn('转换base64为临时文件失败,尝试直接使用base64:', fileError)
- // 清除临时文件路径,确保使用base64
- tempFilePath = ''
- }
- }
- const qrImage = canvas.createImage()
- // 使用Promise确保图片加载完成
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- reject(new Error('图片加载超时'))
- }, 10000) // 10秒超时
- uni.getImageInfo({
- src: tempFilePath || imgSrc,
- success: (res) => {
- qrImage.onload = () => {
- // console.log('图片加载成功:', res)
- clearTimeout(timeout)
- // console.log('二维码图片加载成功', {
- // complete: qrImage.complete,
- // naturalWidth: qrImage.naturalWidth || qrCodeImg.width || res.width,
- // naturalHeight: qrImage.naturalHeight || qrCodeImg.height || res.height,
- // }, qrImage)
- // 获取图片尺寸,兼容微信小程序环境
- const imageWidth = qrImage.naturalWidth || qrImage.width || res.width
- const imageHeight = qrImage.naturalHeight || qrImage.height || res.height
- // 再次检查图片是否真正加载完成
- if ((isPcWeixinMp() || qrImage.complete) && imageWidth > 0 && imageHeight > 0) {
- resolve()
- }
- else {
- reject(new Error('图片加载事件触发但尺寸无效'))
- }
- }
- qrImage.onerror = (err) => {
- clearTimeout(timeout)
- console.error('加载二维码图片失败:', err)
- reject(err)
- }
- // 优先使用临时文件路径,否则使用base64数据
- qrImage.src = res.path
- },
- fail: (fail) => {
- reject(new Error('获取图片信息失败'))
- },
- })
- })
- const finalWidth = qrImage.naturalWidth || qrImage.width
- const finalHeight = qrImage.naturalHeight || qrImage.height
- // 最后再检查一次图片尺寸
- if (finalWidth <= 0 || finalHeight <= 0) {
- throw new Error('图片尺寸无效')
- }
- // 绘制图片
- ctx.save()
- ctx.beginPath()
- ctx.arc(centerX, centerY, qrRadius, 0, Math.PI * 2)
- ctx.clip()
- ctx.drawImage(qrImage, qrX, qrY, qrSize, qrSize)
- // 图片绘制成功后,添加一个精致的内边框
- ctx.restore()
- ctx.beginPath()
- ctx.arc(centerX, centerY, qrRadius, 0, Math.PI * 2)
- ctx.lineWidth = 2 * scale
- ctx.strokeStyle = 'rgba(0, 0, 0, 0.05)'
- ctx.stroke()
- console.log('二维码绘制成功')
- }
- catch (error) {
- console.error('绘制二维码失败:', error)
- // 增加更详细的错误日志
- if (typeof qrCodeImg === 'string') {
- console.log('二维码数据源类型:', qrCodeImg.startsWith('data:image') ? 'base64' : '临时文件路径')
- }
- }
- }
- // 3. 优化二维码提示文字:使用更现代的字体和颜色
- // 有效期
- ctx.fillStyle = '#000000'
- ctx.font = `400 ${14 * scale}px PingFang SC`
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- const validityType = couponDetail.value.validityType
- let validityText = ''
- if (validityType === '2') {
- validityText = `自领取之日起${couponDetail.value.validDays}日内使用`
- } else if (validityType === '1') {
- validityText = `限${couponDetail.value.validStartTime}到${couponDetail.value.validEndTime}内使用`
- } else {
- validityText = `长期有效`
- }
- const validityY = couponDetail.value.type === '4' ? 40 : 37
- ctx.fillText(validityText, width / 2, qrY + qrSize + validityY * scale)
- // 使用规则
- ctx.fillStyle = '#FF5365'
- ctx.font = `600 ${15 * scale}px PingFang SC`
- let ruleText = ''
- if (couponDetail.value.type === '2') {
- ruleText = `满${couponDetail.value.ruleMinSpendAmount}元可用,最高优惠${couponDetail.value.ruleDiscountCapAmount}元`
- }
- else if (couponDetail.value.type === '3') {
- ruleText = `满${couponDetail.value.ruleMinSpendAmount}元可用`
- }
- ctx.fillText(ruleText, width / 2, qrY + qrSize + 65 * scale)
- }
- // 绘制二维码区域(广誉源)
- async function drawGyyQRCodeArea(ctx, canvas, qrCodeImg, width, height, scale) {
- const qrSize = 120 * scale
- const qrX = (width - qrSize) / 2
- const qrY = couponDetail.value.type === '4' ? 310 * scale : 280 * scale
- // 使用柔和的白色背景,增加卡片层次感
- ctx.fillStyle = '#FFFFFF'
- ctx.fill()
- // 清除阴影
- ctx.shadowColor = 'transparent'
- ctx.shadowBlur = 0
- ctx.shadowOffsetY = 0
- // 1. 优化二维码边框:添加双层边框效果(直角正方形)
- const borderWidth = 8 * scale // 外层边框宽度
- const innerBorderWidth = 4 * scale // 内层边框宽度
- // 外层渐变边框
- ctx.save()
- ctx.beginPath()
- // 绘制外层正方形(比二维码大 borderWidth)
- ctx.rect(qrX - borderWidth, qrY - borderWidth, qrSize + borderWidth * 2, qrSize + borderWidth * 2)
- // 创建渐变效果
- const gradient = ctx.createLinearGradient(qrX, qrY, qrX + qrSize, qrY + qrSize)
- gradient.addColorStop(0, '#E8F5E9')
- gradient.addColorStop(1, '#C8E6C9')
- ctx.fillStyle = gradient
- ctx.fill()
- ctx.restore()
- // 内层白色边框
- ctx.beginPath()
- // 绘制内层正方形(比二维码大 innerBorderWidth)
- ctx.rect(qrX - innerBorderWidth, qrY - innerBorderWidth, qrSize + innerBorderWidth * 2, qrSize + innerBorderWidth * 2)
- ctx.fillStyle = '#FFFFFF'
- ctx.fill()
- // 2. 绘制二维码图片(保持原有逻辑,增加加载成功后的边框效果)
- if (qrCodeImg) {
- try {
- let imgSrc = qrCodeImg
- let tempFilePath = ''
- // 检查是否已经是临时文件路径
- const isTempFile = typeof qrCodeImg === 'string' && !qrCodeImg.startsWith('data:image')
- // 如果是临时文件路径,直接使用
- if (isTempFile) {
- tempFilePath = qrCodeImg
- }
- else {
- // 处理base64图片数据
- if (typeof qrCodeImg === 'string') {
- // 安全地尝试解码URI,避免URIError
- try {
- if (qrCodeImg.includes('%')) {
- imgSrc = decodeURIComponent(qrCodeImg)
- }
- }
- catch (uriError) {
- console.warn('URI解码失败,使用原始数据:', uriError)
- }
- // 确保base64数据有正确的前缀
- if (!imgSrc.startsWith('data:image')) {
- imgSrc = `data:image/png;base64,${imgSrc}`
- }
- }
- // 尝试使用临时文件方式
- try {
- // #ifdef MP-WEIXIN
- const fs = uni.getFileSystemManager()
- const tempFileName = `temp_qr_${Date.now()}.png`
- tempFilePath = `${uni.env.USER_DATA_PATH}/${tempFileName}`
- // 提取base64数据部分
- let base64Data = imgSrc
- if (imgSrc.includes('base64,')) {
- base64Data = imgSrc.split('base64,')[1]
- }
- fs.writeFileSync(tempFilePath, base64Data, 'base64')
- // console.log('Base64已转换为临时文件:', tempFilePath)
- // #endif
- }
- catch (fileError) {
- console.warn('转换base64为临时文件失败,尝试直接使用base64:', fileError)
- // 清除临时文件路径,确保使用base64
- tempFilePath = ''
- }
- }
- const qrImage = canvas.createImage()
- // 使用Promise确保图片加载完成
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- reject(new Error('图片加载超时'))
- }, 10000) // 10秒超时
- uni.getImageInfo({
- src: tempFilePath || imgSrc,
- success: (res) => {
- qrImage.onload = () => {
- // console.log('图片加载成功:', res)
- clearTimeout(timeout)
- // console.log('二维码图片加载成功', {
- // complete: qrImage.complete,
- // naturalWidth: qrImage.naturalWidth || qrCodeImg.width || res.width,
- // naturalHeight: qrImage.naturalHeight || qrCodeImg.height || res.height,
- // }, qrImage)
- // 获取图片尺寸,兼容微信小程序环境
- const imageWidth = qrImage.naturalWidth || qrImage.width || res.width
- const imageHeight = qrImage.naturalHeight || qrImage.height || res.height
- // 再次检查图片是否真正加载完成
- if ((isPcWeixinMp() || qrImage.complete) && imageWidth > 0 && imageHeight > 0) {
- resolve()
- }
- else {
- reject(new Error('图片加载事件触发但尺寸无效'))
- }
- }
- qrImage.onerror = (err) => {
- clearTimeout(timeout)
- console.error('加载二维码图片失败:', err)
- reject(err)
- }
- // 优先使用临时文件路径,否则使用base64数据
- qrImage.src = res.path
- },
- fail: (fail) => {
- reject(new Error('获取图片信息失败'))
- },
- })
- })
- const finalWidth = qrImage.naturalWidth || qrImage.width
- const finalHeight = qrImage.naturalHeight || qrImage.height
- // 最后再检查一次图片尺寸
- if (finalWidth <= 0 || finalHeight <= 0) {
- throw new Error('图片尺寸无效')
- }
- // 绘制图片(正方形,不需要裁剪)
- ctx.save()
- ctx.drawImage(qrImage, qrX, qrY, qrSize, qrSize)
- // 图片绘制成功后,添加一个精致的内边框
- ctx.restore()
- ctx.beginPath()
- ctx.rect(qrX, qrY, qrSize, qrSize)
- ctx.lineWidth = 2 * scale
- ctx.strokeStyle = 'rgba(0, 0, 0, 0.05)'
- ctx.stroke()
- console.log('二维码绘制成功')
- }
- catch (error) {
- console.error('绘制二维码失败:', error)
- // 增加更详细的错误日志
- if (typeof qrCodeImg === 'string') {
- console.log('二维码数据源类型:', qrCodeImg.startsWith('data:image') ? 'base64' : '临时文件路径')
- }
- }
- }
- // 3. 优化二维码提示文字:使用更现代的字体和颜色
- // 有效期
- ctx.fillStyle = '#000000'
- ctx.font = `400 ${14 * scale}px PingFang SC`
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- const validityType = couponDetail.value.validityType
- let validityText = ''
- if (validityType === '2') {
- validityText = `自领取之日起${couponDetail.value.validDays}日内使用`
- } else if (validityType === '1') {
- validityText = `限${couponDetail.value.validStartTime}到${couponDetail.value.validEndTime}内使用`
- } else {
- validityText = `长期有效`
- }
- const validityY = couponDetail.value.type === '4' ? 40 : 37
- ctx.fillText(validityText, width / 2, qrY + qrSize + validityY * scale)
- // 使用规则
- ctx.fillStyle = '#FF5365'
- ctx.font = `600 ${15 * scale}px PingFang SC`
- let ruleText = ''
- if (couponDetail.value.type === '2') {
- ruleText = `满${couponDetail.value.ruleMinSpendAmount}元可用,最高优惠${couponDetail.value.ruleDiscountCapAmount}元`
- }
- else if (couponDetail.value.type === '3') {
- ruleText = `满${couponDetail.value.ruleMinSpendAmount}元可用`
- }
- ctx.fillText(ruleText, width / 2, qrY + qrSize + 65 * scale)
- }
- // 绘制底部信息
- function drawFooter(ctx, width, height, scale) {
- // 计算二维码区域的位置
- const qrSize = 200 * scale
- const qrY = 435 * scale
- // 底部信息显示在二维码下方,与二维码提示文字保持适当间距
- const footerY = qrY + qrSize + 80 * scale // 在"长按识别小程序码领取"文字下方40*scale处
- ctx.fillStyle = '#999999'
- ctx.font = `${16 * scale}px sans-serif`
- ctx.textAlign = 'center'
- ctx.fillText('分享自微信小程序', width / 2, footerY)
- }
- // 分享给好友
- function handleShareFriend() {
- if (!shareImageUrl.value) {
- uni.showToast({
- title: '请先生成分享图片',
- icon: 'none',
- })
- return
- }
- uni.showShareImageMenu({
- path: shareImageUrl.value,
- needShowEntrance: false,
- success: async (res) => {
- console.log('分享成功', res)
- await markSuccess(shareRecordId.value)
- },
- fail: (err) => {
- console.log('分享失败', err)
- },
- })
- }
- // 保存图片到相册
- async function handleSaveImage() {
- if (!shareImageUrl.value) {
- uni.showToast({
- title: '请先生成分享图片',
- icon: 'none',
- })
- return
- }
- uni.showLoading({
- title: '保存中...',
- mask: true,
- })
- try {
- await uni.saveImageToPhotosAlbum({
- filePath: shareImageUrl.value,
- })
- uni.hideLoading()
- uni.showToast({
- title: '保存成功',
- icon: 'success',
- })
- await markSuccess(shareRecordId.value)
- }
- catch (error) {
- uni.hideLoading()
- console.error('保存失败:', error)
- if (error.errMsg && error.errMsg.includes('auth deny')) {
- uni.showModal({
- title: '提示',
- content: '需要您授权保存到相册',
- confirmText: '去设置',
- success: (res) => {
- if (res.confirm) {
- uni.openSetting()
- }
- }
- })
- }
- else {
- uni.showToast({
- title: '保存失败,请重试',
- icon: 'none',
- })
- }
- }
- }
- // 长按图片操作
- function handleLongPressImage() {
- uni.showActionSheet({
- itemList: ['保存到相册', '分享给好友'],
- success: (res) => {
- if (res.tapIndex === 0) {
- handleSaveImage()
- }
- else if (res.tapIndex === 1) {
- handleShareFriend()
- }
- }
- })
- }
- </script>
- <style lang="scss" scoped>
- /* 页面容器 */
- .share-container {
- display: flex;
- flex-direction: column;
- min-height: 100vh;
- background: #f5f5f5;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
- }
- /* 顶部导航栏 */
- .top-nav {
- // background: linear-gradient(-46deg, #ff7a75 0%, #ed6b66 98%);
- box-shadow: 0 2rpx 10rpx rgba(255, 100, 71, 0.3);
- position: fixed;
- /* 固定定位 */
- top: 0;
- /* 顶部对齐 */
- left: 0;
- /* 左侧对齐 */
- right: 0;
- /* 右侧对齐 */
- z-index: 999;
- /* 确保在其他元素之上 */
- padding-top: var(--safe-area-inset-top);
- /* 适配安全区域 */
- }
- /* 自定义导航栏 */
- .custom-navbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 20rpx 30rpx;
- height: 88rpx;
- box-sizing: border-box;
- }
- /* 返回按钮 */
- .back-btn {
- width: 60rpx;
- height: 60rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .back-icon {
- font-size: 36rpx;
- color: #ffffff;
- font-weight: bold;
- }
- /* 导航栏标题 */
- .nav-title {
- font-family: Inter, Inter;
- font-weight: 400;
- font-size: 32rpx;
- color: #FFFFFF;
- flex: 1;
- text-align: center;
- }
- /* 右侧占位 */
- .right-space {
- width: 60rpx;
- }
- /* 安全区域 */
- .safe-top {
- background: linear-gradient(-46deg, #ff7a75 0%, #ed6b66 98%);
- }
- /* 预览区域 */
- .preview-area {
- flex: 1;
- display: flex;
- justify-content: center;
- align-items: center;
- // background: #ffffff;
- position: relative;
- min-height: 1627rpx;
- }
- /* 预览图片容器 */
- .preview-image-wrapper {
- width: 100%;
- // max-width: 600rpx;
- position: relative;
- }
- /* 预览图片 */
- .qc-code-img {
- position: absolute;
- top: -9999px;
- left: -9999px;
- opacity: 0;
- }
- .preview-image {
- width: 100%;
- // border-radius: 20rpx;
- box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.15);
- transition:
- transform 0.3s ease,
- box-shadow 0.3s ease;
- }
- .preview-image:active {
- transform: scale(1.02);
- box-shadow: 0 15rpx 50rpx rgba(0, 0, 0, 0.2);
- }
- /* 图片操作提示 */
- .image-tips {
- display: flex;
- justify-content: center;
- margin-top: 20rpx;
- padding: 0 20rpx;
- }
- .tip-text {
- font-size: 24rpx;
- color: #666666;
- }
- /* 生成图片按钮容器 */
- .generate-btn-container {
- display: flex;
- justify-content: center;
- align-items: center;
- width: 100%;
- }
- /* 生成图片按钮 */
- .generate-btn {
- background: linear-gradient(-46deg, #ff7a75 0%, #ed6b66 98%);
- color: #ffffff;
- border: none;
- border-radius: 50rpx;
- padding: 25rpx 60rpx;
- font-size: 32rpx;
- font-weight: bold;
- box-shadow: 0 8rpx 25rpx rgba(255, 100, 71, 0.4);
- transition: all 0.3s ease;
- display: flex;
- align-items: center;
- gap: 10rpx;
- }
- .generate-btn:active {
- transform: translateY(2rpx);
- box-shadow: 0 5rpx 15rpx rgba(255, 100, 71, 0.3);
- }
- /* 操作区域 */
- .action-area {
- background: #ffffff;
- padding: 40rpx 30rpx;
- margin-top: -25rpx;
- border-radius: 30rpx 30rpx 0 0;
- box-shadow: 0 -10rpx 30rpx rgba(0, 0, 0, 0.05);
- z-index: 2;
- }
- /* 操作说明 */
- .action-intro {
- text-align: center;
- margin-bottom: 40rpx;
- }
- .intro-title {
- display: block;
- font-size: 36rpx;
- font-weight: bold;
- color: #333333;
- margin-bottom: 10rpx;
- }
- .intro-desc {
- display: block;
- font-size: 28rpx;
- color: #666666;
- }
- /* 分享按钮容器 */
- .share-buttons {
- display: flex;
- justify-content: space-around;
- margin-bottom: 40rpx;
- }
- /* 分享按钮 */
- .share-btn {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- background: #ffffff;
- border: 2rpx solid #ff6347;
- border-radius: 20rpx;
- padding: 30rpx 20rpx;
- width: 200rpx;
- transition: all 0.3s ease;
- }
- .share-btn:active {
- background: #fff5f0;
- transform: translateY(2rpx);
- }
- /* 分享好友按钮 */
- .share-friend {
- border-color: #52c41a;
- color: #52c41a;
- }
- .share-friend .btn-icon {
- background: #e6f7ff;
- color: #52c41a;
- }
- /* 保存图片按钮 */
- .share-save {
- border-color: #ff8c00;
- color: #ff8c00;
- }
- .share-save .btn-icon {
- background: #fff5f0;
- color: #ff8c00;
- }
- /* 分享朋友圈按钮 */
- .share-moment {
- border-color: #ff6347;
- color: #ff6347;
- }
- .share-moment .btn-icon {
- background: #fff5f0;
- color: #ff6347;
- }
- /* 按钮图标 */
- .btn-icon {
- width: 80rpx;
- height: 80rpx;
- border-radius: 50%;
- display: flex;
- justify-content: center;
- align-items: center;
- margin-bottom: 15rpx;
- }
- .btn-icon .icon {
- font-size: 40rpx;
- }
- /* 按钮文本 */
- .btn-text {
- font-size: 28rpx;
- font-weight: bold;
- margin-bottom: 8rpx;
- }
- /* 按钮描述 */
- .btn-desc {
- font-size: 22rpx;
- opacity: 0.8;
- }
- /* 额外提示 */
- .extra-tips {
- background: #fffbe6;
- border: 2rpx solid #ffeaa7;
- border-radius: 15rpx;
- padding: 25rpx;
- }
- .tip-content {
- font-size: 24rpx;
- color: #856404;
- line-height: 36rpx;
- }
- /* 加载遮罩 */
- .loading-mask {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(255, 255, 255, 0.9);
- display: flex;
- justify-content: center;
- align-items: center;
- z-index: 999;
- }
- /* 加载内容 */
- .loading-content {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- }
- /* 加载动画 */
- .loading-spinner {
- width: 80rpx;
- height: 80rpx;
- border: 8rpx solid #f3f3f3;
- border-top: 8rpx solid #ff6347;
- border-radius: 50%;
- animation: spin 1s linear infinite;
- margin-bottom: 20rpx;
- }
- /* 加载文字 */
- .loading-content text {
- font-size: 28rpx;
- color: #666666;
- }
- /* 隐藏canvas */
- .canvas-hide {
- position: absolute;
- top: -9999px;
- left: -9999px;
- opacity: 0;
- }
- /* 动画 */
- @keyframes spin {
- 0% {
- transform: rotate(0deg);
- }
- 100% {
- transform: rotate(360deg);
- }
- }
- /* 响应式设计 */
- @media screen and (max-height: 1000rpx) {
- .preview-area {
- min-height: 600rpx;
- }
- .action-area {
- padding: 30rpx 20rpx;
- }
- }
- </style>
|