index.vue 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207
  1. <template>
  2. <view class="share-container">
  3. <!-- 顶部导航栏(模拟美团风格) -->
  4. <!-- <up-status-bar /> -->
  5. <view class="top-nav" :style="{ paddingTop: `${topSafeAreaHeight}px` }">
  6. <view class="custom-navbar">
  7. <view class="back-btn" @tap="handleBack">
  8. <up-icon name="arrow-left" size="36rpx" color="#ffffff" />
  9. </view>
  10. <view class="nav-title">
  11. 优惠券分享
  12. </view>
  13. <view class="right-space" />
  14. </view>
  15. </view>
  16. <!-- 分享图片预览区域 -->
  17. <view class="preview-area" :style="{ paddingTop: `calc(${topSafeAreaHeight}px + 112rpx)` }">
  18. <canvas id="shareCanvas" type="2d" canvas-id="shareCanvas"
  19. :style="{ width: `${canvasWidth}px`, height: `${canvasHeight}px` }" class="canvas-hide" />
  20. <!-- 预览图片容器 -->
  21. <view v-if="shareImageUrl" class="preview-image-wrapper">
  22. <image :src="shareImageUrl" mode="widthFix" class="preview-image" @longpress="handleLongPressImage"
  23. @tap="handleImagePreview" />
  24. <!-- 图片操作提示 -->
  25. <view class="image-tips">
  26. <text class="tip-text">长按保存/分享</text>
  27. </view>
  28. </view>
  29. <!-- 生成图片按钮(如果自动生成失败) -->
  30. <view v-if="!shareImageUrl && !loading" class="generate-btn-container">
  31. <button class="generate-btn" @tap="generateShareImage">
  32. <text class="icon">🔄</text>
  33. <text>生成分享图片</text>
  34. </button>
  35. </view>
  36. </view>
  37. <!-- 分享操作区域 -->
  38. <view class="action-area">
  39. <!-- 操作说明 -->
  40. <view class="action-intro">
  41. <text class="intro-title">分享方式</text>
  42. <text class="intro-desc">选择以下方式分享给好友</text>
  43. </view>
  44. <view class="share-buttons">
  45. <!-- 分享给好友 - 使用微信原生分享卡片 -->
  46. <button class="share-btn share-friend" @tap="handleShareFriend">
  47. <view class="btn-icon">
  48. <text class="icon">👥</text>
  49. </view>
  50. <text class="btn-text">分享好友</text>
  51. <text class="btn-desc">发送图片给好友</text>
  52. </button>
  53. <!-- 保存到相册 -->
  54. <button class="share-btn share-save" :disabled="!shareImageUrl" @tap="handleSaveImage">
  55. <view class="btn-icon">
  56. <text class="icon">💾</text>
  57. </view>
  58. <text class="btn-text">保存图片</text>
  59. <text class="btn-desc">保存到手机相册</text>
  60. </button>
  61. </view>
  62. <!-- 额外提示 -->
  63. <!-- <view class="extra-tips">
  64. <text class="tip-content">💡 提示:分享卡片包含进入小程序的入口,方便好友快速领取优惠券</text>
  65. </view> -->
  66. </view>
  67. <!-- 加载提示 -->
  68. <view v-if="loading" class="loading-mask">
  69. <view class="loading-content">
  70. <view class="loading-spinner" />
  71. <text>生成分享图片中...</text>
  72. </view>
  73. </view>
  74. </view>
  75. </template>
  76. <script setup>
  77. import { onLoad } from '@dcloudio/uni-app'
  78. import { useRequest } from 'alova/client'
  79. import { storeToRefs } from 'pinia'
  80. import { nextTick, ref } from 'vue'
  81. import { getCouponDetail } from '@/api/home'
  82. import { getWxQcCode, markSuccess } from '@/api/receiveCoupon'
  83. import { useInviteCodeStore } from '@/store/inviteQCCode'
  84. import { parseUrlParams, safeAreaInsets, isPcWeixinMp } from '@/utils'
  85. import { getLastPartAfterSlash } from '@/utils/couponClassFormat'
  86. definePage({
  87. style: {
  88. navigationBarTitleText: '',
  89. navigationStyle: 'custom',
  90. }
  91. // enableShareAppMessage: true,
  92. // enableShareTimeline: true,
  93. })
  94. // 响应式数据
  95. const loading = ref(false)
  96. const shareImageUrl = ref('')
  97. const canvasWidth = ref(750)
  98. const canvasHeight = ref(1200)
  99. const topSafeAreaHeight = safeAreaInsets?.top || 0
  100. const inviteCodeStore = useInviteCodeStore()
  101. const { inviteCode } = storeToRefs(inviteCodeStore)
  102. // 系统信息
  103. const systemInfo = ref({})
  104. const shareId = ref('')
  105. const shareRecordId = ref('')
  106. const { send, data: couponDetail } = useRequest(getCouponDetail, {
  107. immediate: false,
  108. initialData: {
  109. name: '',
  110. type: '2',
  111. relatedName: '',
  112. ruleMinSpendAmount: '',
  113. ruleReductionAmount: '',
  114. validityType: '1',
  115. validDays: '',
  116. validStartTime: '',
  117. validEndTime: '',
  118. }
  119. })
  120. // 生命周期
  121. onLoad(async (options) => {
  122. // 获取系统信息
  123. systemInfo.value = uni.getSystemInfoSync()
  124. if (options.couponId) {
  125. shareId.value = options.couponId
  126. await send({ templateId: options.couponId })
  127. }
  128. // 页面加载后生成分享图片
  129. await nextTick()
  130. await generateShareImage()
  131. })
  132. function handleBack() {
  133. uni.navigateBack()
  134. }
  135. // 获取canvas实例
  136. function getCanvasInstance() {
  137. return new Promise((resolve, reject) => {
  138. const query = uni.createSelectorQuery()
  139. query.select('#shareCanvas')
  140. .fields({ node: true, size: true })
  141. .exec((res) => {
  142. if (res[0]) {
  143. const canvas = res[0].node
  144. const ctx = canvas.getContext('2d')
  145. resolve({ canvas, ctx })
  146. }
  147. else {
  148. reject(new Error('Canvas not found'))
  149. }
  150. })
  151. })
  152. }
  153. async function getWxacodeBase64() {
  154. // const token = await getWxToken()
  155. // console.log('token:', token, shareId.value)
  156. // const res = await getWxacode(token.data.access_token, shareId.value)
  157. const res = await getWxQcCode({
  158. shareType: 'COUPON',
  159. sharePath: 'pages/home/home',
  160. shareContentId: shareId.value,
  161. width: 290,
  162. })
  163. const contentType = res.header['Content-Type'] || res.header['content-type']
  164. const contentData = res.header['x-content-data'] || res.header['x-content-data']
  165. if (contentType && contentType.includes('image')) {
  166. // 返回的是图片
  167. // 将arraybuffer转换为base64
  168. const arrayBuffer = res.data
  169. const base64 = uni.arrayBufferToBase64(arrayBuffer)
  170. const imageUrl = `data:image/jpeg;base64,${base64}`
  171. const params = parseUrlParams(contentData)
  172. shareRecordId.value = params[inviteCode.value.shareKey]
  173. return imageUrl
  174. }
  175. else {
  176. // 返回的是错误信息
  177. try {
  178. // 尝试将arraybuffer转换为字符串
  179. const decoder = new TextDecoder('utf-8')
  180. const errorText = decoder.decode(new Uint8Array(res.data))
  181. const errorData = JSON.parse(errorText)
  182. throw new Error(errorData.errmsg || '获取小程序码失败')
  183. }
  184. catch (e) {
  185. throw new Error('获取小程序码失败,请检查access_token')
  186. }
  187. }
  188. }
  189. // 生成分享图片
  190. async function generateShareImage() {
  191. if (loading.value)
  192. return
  193. loading.value = true
  194. try {
  195. // 计算尺寸
  196. const dpr = systemInfo.value.pixelRatio || 1
  197. canvasWidth.value = 375 * dpr
  198. canvasHeight.value = 750 * dpr
  199. await nextTick()
  200. // 获取canvas实例
  201. const { canvas, ctx } = await getCanvasInstance()
  202. // 设置canvas尺寸
  203. canvas.width = canvasWidth.value
  204. canvas.height = canvasHeight.value
  205. // 生成二维码
  206. let qrCodeImg = null
  207. try {
  208. qrCodeImg = await getWxacodeBase64()
  209. }
  210. catch (error) {
  211. console.error('获取小程序失败:', error)
  212. // 在小程序中,如果获取二维码失败,直接使用null
  213. qrCodeImg = null
  214. }
  215. // 绘制完整的分享图片
  216. await drawCompleteShareImage(ctx, canvas, qrCodeImg)
  217. }
  218. catch (error) {
  219. console.error('生成分享图片失败:', error)
  220. uni.showToast({
  221. title: '生成失败,请重试',
  222. icon: 'none',
  223. })
  224. }
  225. finally {
  226. loading.value = false
  227. }
  228. }
  229. // 绘制圆角矩形
  230. function drawRoundRect(ctx, x, y, width, height, radius) {
  231. ctx.beginPath()
  232. ctx.moveTo(x + radius, y)
  233. ctx.lineTo(x + width - radius, y)
  234. ctx.arcTo(x + width, y, x + width, y + radius, radius)
  235. ctx.lineTo(x + width, y + height - radius)
  236. ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius)
  237. ctx.lineTo(x + radius, y + height)
  238. ctx.arcTo(x, y + height, x, y + height - radius, radius)
  239. ctx.lineTo(x, y + radius)
  240. ctx.arcTo(x, y, x + radius, y, radius)
  241. ctx.closePath()
  242. }
  243. // 绘制完整的分享图片
  244. async function drawCompleteShareImage(ctx, canvas, qrCodeImg) {
  245. return new Promise(async (resolve, reject) => {
  246. try {
  247. const { width, height } = canvas
  248. const scale = width / 375 // 缩放比例
  249. // 1. 绘制背景
  250. ctx.fillStyle = '#ffffff'
  251. ctx.fillRect(0, 0, width, height)
  252. // 2. 绘制顶部装饰
  253. drawTopDecoration(ctx, width, height, scale)
  254. // 3. 绘制标题
  255. drawTitle(ctx, width, height, scale)
  256. // 4. 绘制优惠券金额
  257. drawCouponAmount(ctx, width, height, scale)
  258. // 5. 绘制优惠券描述
  259. drawCouponDescription(ctx, width, height, scale)
  260. // 6. 绘制二维码区域
  261. await drawQRCodeArea(ctx, canvas, qrCodeImg, width, height, scale)
  262. // 7. 绘制底部信息
  263. drawFooter(ctx, width, height, scale)
  264. // 生成图片
  265. uni.canvasToTempFilePath({
  266. canvas,
  267. quality: 0.9,
  268. success: (res) => {
  269. shareImageUrl.value = res.tempFilePath
  270. resolve()
  271. },
  272. fail: (err) => {
  273. console.error('生成图片失败:', err)
  274. reject(err)
  275. }
  276. }, this)
  277. }
  278. catch (error) {
  279. console.error('绘制图片失败:', error)
  280. reject(error)
  281. }
  282. })
  283. }
  284. // 绘制顶部装饰
  285. function drawTopDecoration(ctx, width, height, scale) {
  286. const gradient = ctx.createLinearGradient(0, 0, width, 0)
  287. gradient.addColorStop(0, '#FF7A75')
  288. gradient.addColorStop(0.98, '#ED6B66')
  289. // 绘制带圆角的顶部卡片
  290. drawRoundRect(ctx, 20 * scale, 10 * scale, width - 40 * scale, 160 * scale, 20 * scale)
  291. ctx.fillStyle = gradient
  292. ctx.fill()
  293. // 添加阴影效果
  294. ctx.shadowColor = 'rgba(255, 107, 53, 0.3)'
  295. ctx.shadowBlur = 20 * scale
  296. ctx.shadowOffsetY = 10 * scale
  297. // 绘制主体卡片
  298. drawRoundRect(ctx, 20 * scale, 130 * scale, width - 40 * scale, height - 150 * scale, 20 * scale)
  299. ctx.fillStyle = '#FFFFFF'
  300. ctx.fill()
  301. // 清除阴影
  302. ctx.shadowColor = 'transparent'
  303. ctx.shadowBlur = 0
  304. ctx.shadowOffsetY = 0
  305. }
  306. // 绘制标题
  307. function drawTitle(ctx, width, height, scale) {
  308. ctx.fillStyle = '#ffffff'
  309. ctx.font = `bold ${30 * scale}px sans-serif`
  310. ctx.textAlign = 'center'
  311. ctx.fillText('优惠券', width / 2, 60 * scale)
  312. // 添加副标题
  313. ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'
  314. ctx.font = `${16 * scale}px sans-serif`
  315. ctx.fillText('专享优惠,先到先得', width / 2, 90 * scale)
  316. }
  317. // 绘制优惠券金额
  318. function drawCouponAmount(ctx, width, height, scale) {
  319. const centerX = width / 2
  320. const bottomY = 230 * scale
  321. ctx.textBaseline = 'bottom' // 设置文本基线为底部对齐
  322. if (couponDetail.value.type === '2') {
  323. // 折扣券:数字 + 折
  324. const numberFont = `bold ${72 * scale}px Arial`
  325. const unitFont = `bold ${36 * scale}px Arial`
  326. ctx.fillStyle = '#FF6B35'
  327. // 计算数字宽度
  328. ctx.font = numberFont
  329. const number = couponDetail.value.ruleDiscountRate
  330. const numberWidth = ctx.measureText(number).width
  331. // 计算"折"字宽度
  332. ctx.font = unitFont
  333. const unit = '折'
  334. const unitWidth = ctx.measureText(unit).width
  335. // 计算总宽度
  336. const totalWidth = numberWidth + unitWidth + 8 * scale // 8px间距(减小间距)
  337. // 计算起始X坐标(整体居中)
  338. const startX = centerX - totalWidth / 2
  339. // 绘制数字
  340. ctx.font = numberFont
  341. ctx.textAlign = 'left'
  342. ctx.fillText(number, startX, bottomY)
  343. // 绘制"折"字(与数字间距8px,垂直位置微调使视觉对齐)
  344. ctx.font = unitFont
  345. // 根据字体大小比例微调"折"字的y坐标,使其视觉上与数字底部对齐
  346. const unitY = bottomY - (72 - 36) * scale * 0.35 // 微调垂直位置
  347. ctx.fillText(unit, startX + numberWidth + 8 * scale, unitY)
  348. }
  349. else if (couponDetail.value.type === '3') {
  350. // 满减券:¥ + 数字
  351. const numberFont = `bold ${72 * scale}px Arial`
  352. const symbolFont = `bold ${36 * scale}px Arial`
  353. // 计算数字宽度
  354. ctx.fillStyle = '#FF6B35'
  355. ctx.font = numberFont
  356. const number = couponDetail.value.ruleReductionAmount
  357. const numberWidth = ctx.measureText(number).width
  358. // 计算"¥"符号宽度
  359. ctx.fillStyle = '#FF9500'
  360. ctx.font = symbolFont
  361. const symbol = '¥'
  362. const symbolWidth = ctx.measureText(symbol).width
  363. console.log('symbolWidth:', ctx.measureText(symbol))
  364. // const symbolHeight = ctx.measureText(symbol).height
  365. // 计算总宽度
  366. const totalWidth = symbolWidth + numberWidth + 8 * scale // 8px间距(减小间距)
  367. // 计算起始X坐标(整体居中)
  368. const startX = centerX - totalWidth / 2
  369. // 绘制"¥"符号(垂直位置微调使视觉对齐)
  370. ctx.textAlign = 'left'
  371. // 根据字体大小比例微调"¥"符号的y坐标,使其视觉上与数字底部对齐
  372. const symbolY = bottomY - (72 - 36) * scale * 0.25 // 微调垂直位置
  373. ctx.fillText(symbol, startX, symbolY)
  374. // 绘制数字
  375. ctx.fillStyle = '#FF6B35'
  376. ctx.font = numberFont
  377. ctx.fillText(number, startX + symbolWidth + 8 * scale, bottomY)
  378. }
  379. // 恢复默认文本对齐方式
  380. ctx.textAlign = 'center'
  381. ctx.textBaseline = 'alphabetic'
  382. }
  383. // 绘制优惠券描述
  384. function drawCouponDescription(ctx, width, height, scale) {
  385. // 主标题
  386. ctx.fillStyle = '#333333'
  387. ctx.font = `${28 * scale}px sans-serif`
  388. ctx.textAlign = 'center'
  389. // 文本自动换行辅助函数
  390. const drawWrappedText = (text, fontSize, yPosition, color, maxLines = 2) => {
  391. ctx.font = `${fontSize * scale}px sans-serif`
  392. ctx.fillStyle = color
  393. ctx.textAlign = 'center'
  394. const maxWidth = width - 80 * scale
  395. const lineHeight = fontSize * 1.5 * scale
  396. let currentY = yPosition
  397. let currentLine = ''
  398. let lineCount = 0
  399. for (let char of text) {
  400. const testLine = currentLine + char
  401. const metrics = ctx.measureText(testLine)
  402. if (metrics.width > maxWidth && currentLine.length > 0) {
  403. ctx.fillText(currentLine, width / 2, currentY)
  404. currentLine = char
  405. currentY += lineHeight
  406. lineCount++
  407. // 超过最大行数则停止
  408. if (lineCount >= maxLines) {
  409. // 最后一行添加省略号
  410. currentLine = `${currentLine}...`
  411. break
  412. }
  413. }
  414. else {
  415. currentLine = testLine
  416. }
  417. }
  418. if (currentLine && lineCount < maxLines) {
  419. ctx.fillText(currentLine, width / 2, currentY)
  420. }
  421. return currentY + lineHeight // 返回下一行的y坐标
  422. }
  423. const relatedType = couponDetail.value.relatedType
  424. let descriptionText = ''
  425. if (couponDetail.value?.relatedName && couponDetail.value?.relatedName !== 'null') {
  426. let relatedName = couponDetail.value.relatedName
  427. if (relatedType === '2') {
  428. relatedName = getLastPartAfterSlash(couponDetail.value.relatedName)
  429. descriptionText = `限${relatedName}分类商品使用`
  430. }
  431. else {
  432. descriptionText = `限${relatedName}商品使用`
  433. }
  434. } else if (couponDetail.value?.storeName && couponDetail.value?.storeName !== 'null') {
  435. descriptionText = `仅限${couponDetail.value?.storeName}商户使用`
  436. } else if (couponDetail.value?.locality && couponDetail.value?.locality !== 'null') {
  437. descriptionText = `仅限${couponDetail.value?.locality}使用`
  438. } else {
  439. throw new Error('无法获取该优惠券内容')
  440. }
  441. // 使用自动换行函数绘制
  442. const fontSize = 20
  443. const lineHeight = fontSize * 1.5 * scale
  444. const nextLineY = drawWrappedText(descriptionText, fontSize, 260 * scale, '#333333')
  445. // 添加分隔线 - 根据文本高度调整分隔线位置
  446. // 减lineHeight是因为nextLineY返回的是下一行的位置
  447. ctx.strokeStyle = '#E9ECEF'
  448. ctx.lineWidth = 2 * scale
  449. ctx.beginPath()
  450. ctx.moveTo(40 * scale, nextLineY - lineHeight + 10 * scale) // 调整分隔线位置
  451. ctx.lineTo(width - 40 * scale, nextLineY - lineHeight + 10 * scale)
  452. ctx.stroke()
  453. // 有效期
  454. // ctx.fillStyle = '#FF6B35'
  455. // ctx.font = `${20 * scale}px sans-serif`
  456. // ctx.textAlign = 'center'
  457. // 有效期
  458. let currentY = nextLineY - lineHeight + 40 * scale
  459. const validityType = couponDetail.value.validityType
  460. let validityText = ''
  461. if (validityType === '2') {
  462. validityText = `自领取之日起${couponDetail.value.validDays}日内使用`
  463. }
  464. else if (validityType === '1') {
  465. validityText = `限${couponDetail.value.validStartTime}到${couponDetail.value.validEndTime}日内使用`
  466. }
  467. else {
  468. validityText = `长期有效`
  469. }
  470. currentY = drawWrappedText(validityText, 20, currentY, '#FF6B35')
  471. // 使用规则
  472. let ruleText = ''
  473. if (couponDetail.value.type === '2') {
  474. ruleText = `满${couponDetail.value.ruleMinSpendAmount}元可用,最高优惠${couponDetail.value.ruleDiscountCapAmount}元`
  475. }
  476. else if (couponDetail.value.type === '3') {
  477. ruleText = `满${couponDetail.value.ruleMinSpendAmount}元可用`
  478. }
  479. drawWrappedText(ruleText, 18, currentY, '#666666')
  480. }
  481. // 绘制二维码区域
  482. async function drawQRCodeArea(ctx, canvas, qrCodeImg, width, height, scale) {
  483. const qrSize = 180 * scale
  484. const qrX = (width - qrSize) / 2
  485. const qrY = 435 * scale
  486. // 1. 优化卡片样式:使用更柔和的阴影和渐变背景
  487. ctx.shadowColor = 'rgba(0, 0, 0, 0.08)'
  488. ctx.shadowBlur = 15 * scale
  489. ctx.shadowOffsetY = 8 * scale
  490. drawRoundRect(ctx, qrX - 30 * scale, qrY - 30 * scale, qrSize + 60 * scale, qrSize + 106 * scale, 20 * scale)
  491. // 使用柔和的白色背景,增加卡片层次感
  492. ctx.fillStyle = '#FFFFFF'
  493. ctx.fill()
  494. // 清除阴影
  495. ctx.shadowColor = 'transparent'
  496. ctx.shadowBlur = 0
  497. ctx.shadowOffsetY = 0
  498. // 2. 优化二维码边框:添加双层边框效果
  499. const qrRadius = qrSize / 2
  500. const centerX = qrX + qrRadius
  501. const centerY = qrY + qrRadius
  502. // 外层渐变边框
  503. ctx.save()
  504. ctx.beginPath()
  505. ctx.arc(centerX, centerY, qrRadius + 8 * scale, 0, Math.PI * 2)
  506. // 创建渐变效果
  507. const gradient = ctx.createLinearGradient(centerX - qrRadius, centerY - qrRadius, centerX + qrRadius, centerY + qrRadius)
  508. gradient.addColorStop(0, '#E8F5E9')
  509. gradient.addColorStop(1, '#C8E6C9')
  510. ctx.fillStyle = gradient
  511. ctx.fill()
  512. ctx.restore()
  513. // 内层白色边框
  514. ctx.beginPath()
  515. ctx.arc(centerX, centerY, qrRadius + 4 * scale, 0, Math.PI * 2)
  516. ctx.fillStyle = '#FFFFFF'
  517. ctx.fill()
  518. // 3. 绘制二维码图片(保持原有逻辑,增加加载成功后的边框效果)
  519. if (qrCodeImg) {
  520. try {
  521. let imgSrc = qrCodeImg
  522. let tempFilePath = ''
  523. // 检查是否已经是临时文件路径
  524. const isTempFile = typeof qrCodeImg === 'string' && !qrCodeImg.startsWith('data:image')
  525. // 如果是临时文件路径,直接使用
  526. if (isTempFile) {
  527. tempFilePath = qrCodeImg
  528. }
  529. else {
  530. // 处理base64图片数据
  531. if (typeof qrCodeImg === 'string') {
  532. // 安全地尝试解码URI,避免URIError
  533. try {
  534. if (qrCodeImg.includes('%')) {
  535. imgSrc = decodeURIComponent(qrCodeImg)
  536. }
  537. }
  538. catch (uriError) {
  539. console.warn('URI解码失败,使用原始数据:', uriError)
  540. }
  541. // 确保base64数据有正确的前缀
  542. if (!imgSrc.startsWith('data:image')) {
  543. imgSrc = `data:image/png;base64,${imgSrc}`
  544. }
  545. }
  546. // 尝试使用临时文件方式
  547. try {
  548. // #ifdef MP-WEIXIN
  549. const fs = uni.getFileSystemManager()
  550. const tempFileName = `temp_qr_${Date.now()}.png`
  551. tempFilePath = `${uni.env.USER_DATA_PATH}/${tempFileName}`
  552. // 提取base64数据部分
  553. let base64Data = imgSrc
  554. if (imgSrc.includes('base64,')) {
  555. base64Data = imgSrc.split('base64,')[1]
  556. }
  557. fs.writeFileSync(tempFilePath, base64Data, 'base64')
  558. console.log('Base64已转换为临时文件:', tempFilePath)
  559. // #endif
  560. }
  561. catch (fileError) {
  562. console.warn('转换base64为临时文件失败,尝试直接使用base64:', fileError)
  563. // 清除临时文件路径,确保使用base64
  564. tempFilePath = ''
  565. }
  566. }
  567. const qrImage = canvas.createImage()
  568. // 使用Promise确保图片加载完成
  569. await new Promise((resolve, reject) => {
  570. const timeout = setTimeout(() => {
  571. reject(new Error('图片加载超时'))
  572. }, 10000) // 10秒超时
  573. uni.getImageInfo({
  574. src: tempFilePath || imgSrc,
  575. success: (res) => {
  576. qrImage.onload = () => {
  577. console.log('图片加载成功:', res)
  578. clearTimeout(timeout)
  579. console.log('二维码图片加载成功', {
  580. complete: qrImage.complete,
  581. naturalWidth: qrImage.naturalWidth || qrCodeImg.width || res.width,
  582. naturalHeight: qrImage.naturalHeight || qrCodeImg.height || res.height,
  583. }, qrImage)
  584. // 获取图片尺寸,兼容微信小程序环境
  585. const imageWidth = qrImage.naturalWidth || qrImage.width || res.width
  586. const imageHeight = qrImage.naturalHeight || qrImage.height || res.height
  587. // 再次检查图片是否真正加载完成
  588. if ((isPcWeixinMp() || qrImage.complete) && imageWidth > 0 && imageHeight > 0) {
  589. resolve()
  590. }
  591. else {
  592. reject(new Error('图片加载事件触发但尺寸无效'))
  593. }
  594. }
  595. qrImage.onerror = (err) => {
  596. clearTimeout(timeout)
  597. console.error('加载二维码图片失败:', err)
  598. reject(err)
  599. }
  600. // 优先使用临时文件路径,否则使用base64数据
  601. qrImage.src = res.path
  602. },
  603. fail: (fail) => {
  604. reject(new Error('获取图片信息失败'))
  605. },
  606. })
  607. })
  608. const finalWidth = qrImage.naturalWidth || qrImage.width
  609. const finalHeight = qrImage.naturalHeight || qrImage.height
  610. // 最后再检查一次图片尺寸
  611. if (finalWidth <= 0 || finalHeight <= 0) {
  612. throw new Error('图片尺寸无效')
  613. }
  614. // 绘制图片
  615. ctx.save()
  616. ctx.beginPath()
  617. ctx.arc(centerX, centerY, qrRadius, 0, Math.PI * 2)
  618. ctx.clip()
  619. ctx.drawImage(qrImage, qrX, qrY, qrSize, qrSize)
  620. // 图片绘制成功后,添加一个精致的内边框
  621. ctx.restore()
  622. ctx.beginPath()
  623. ctx.arc(centerX, centerY, qrRadius, 0, Math.PI * 2)
  624. ctx.lineWidth = 2 * scale
  625. ctx.strokeStyle = 'rgba(0, 0, 0, 0.05)'
  626. ctx.stroke()
  627. console.log('二维码绘制成功')
  628. }
  629. catch (error) {
  630. console.error('绘制二维码失败:', error)
  631. // 增加更详细的错误日志
  632. if (typeof qrCodeImg === 'string') {
  633. console.log('二维码数据源类型:', qrCodeImg.startsWith('data:image') ? 'base64' : '临时文件路径')
  634. }
  635. }
  636. }
  637. // 4. 优化二维码提示文字:使用更现代的字体和颜色
  638. ctx.fillStyle = '#424242'
  639. ctx.font = `${20 * scale}px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif`
  640. ctx.textAlign = 'center'
  641. ctx.textBaseline = 'middle'
  642. ctx.fillText('长按识别小程序码', width / 2, qrY + qrSize + 31 * scale)
  643. // 增加辅助文字,让提示更清晰
  644. ctx.fillStyle = '#9E9E9E'
  645. ctx.font = `${16 * scale}px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif`
  646. ctx.fillText('领取专属优惠券', width / 2, qrY + qrSize + 60 * scale)
  647. }
  648. // 绘制底部信息
  649. function drawFooter(ctx, width, height, scale) {
  650. // 计算二维码区域的位置
  651. const qrSize = 200 * scale
  652. const qrY = 435 * scale
  653. // 底部信息显示在二维码下方,与二维码提示文字保持适当间距
  654. const footerY = qrY + qrSize + 80 * scale // 在"长按识别小程序码领取"文字下方40*scale处
  655. ctx.fillStyle = '#999999'
  656. ctx.font = `${16 * scale}px sans-serif`
  657. ctx.textAlign = 'center'
  658. ctx.fillText('分享自微信小程序', width / 2, footerY)
  659. }
  660. // 分享给好友
  661. function handleShareFriend() {
  662. if (!shareImageUrl.value) {
  663. uni.showToast({
  664. title: '请先生成分享图片',
  665. icon: 'none',
  666. })
  667. return
  668. }
  669. uni.showShareImageMenu({
  670. path: shareImageUrl.value,
  671. needShowEntrance: false,
  672. success: async (res) => {
  673. console.log('分享成功', res)
  674. await markSuccess(shareRecordId.value)
  675. },
  676. fail: (err) => {
  677. console.log('分享失败', err)
  678. },
  679. })
  680. }
  681. // 保存图片到相册
  682. async function handleSaveImage() {
  683. if (!shareImageUrl.value) {
  684. uni.showToast({
  685. title: '请先生成分享图片',
  686. icon: 'none',
  687. })
  688. return
  689. }
  690. uni.showLoading({
  691. title: '保存中...',
  692. mask: true,
  693. })
  694. try {
  695. await uni.saveImageToPhotosAlbum({
  696. filePath: shareImageUrl.value,
  697. })
  698. uni.hideLoading()
  699. uni.showToast({
  700. title: '保存成功',
  701. icon: 'success',
  702. })
  703. await markSuccess(shareRecordId.value)
  704. }
  705. catch (error) {
  706. uni.hideLoading()
  707. console.error('保存失败:', error)
  708. if (error.errMsg && error.errMsg.includes('auth deny')) {
  709. uni.showModal({
  710. title: '提示',
  711. content: '需要您授权保存到相册',
  712. confirmText: '去设置',
  713. success: (res) => {
  714. if (res.confirm) {
  715. uni.openSetting()
  716. }
  717. }
  718. })
  719. }
  720. else {
  721. uni.showToast({
  722. title: '保存失败,请重试',
  723. icon: 'none',
  724. })
  725. }
  726. }
  727. }
  728. // 长按图片操作
  729. function handleLongPressImage() {
  730. uni.showActionSheet({
  731. itemList: ['保存到相册', '分享给好友'],
  732. success: (res) => {
  733. if (res.tapIndex === 0) {
  734. handleSaveImage()
  735. }
  736. else if (res.tapIndex === 1) {
  737. handleShareFriend()
  738. }
  739. }
  740. })
  741. }
  742. </script>
  743. <style lang="scss" scoped>
  744. /* 页面容器 */
  745. .share-container {
  746. display: flex;
  747. flex-direction: column;
  748. min-height: 100vh;
  749. background: #f5f5f5;
  750. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  751. }
  752. /* 顶部导航栏 */
  753. .top-nav {
  754. background: linear-gradient(-46deg, #ff7a75 0%, #ed6b66 98%);
  755. box-shadow: 0 2rpx 10rpx rgba(255, 100, 71, 0.3);
  756. position: fixed;
  757. /* 固定定位 */
  758. top: 0;
  759. /* 顶部对齐 */
  760. left: 0;
  761. /* 左侧对齐 */
  762. right: 0;
  763. /* 右侧对齐 */
  764. z-index: 999;
  765. /* 确保在其他元素之上 */
  766. padding-top: var(--safe-area-inset-top);
  767. /* 适配安全区域 */
  768. }
  769. /* 自定义导航栏 */
  770. .custom-navbar {
  771. display: flex;
  772. align-items: center;
  773. justify-content: space-between;
  774. padding: 20rpx 30rpx;
  775. height: 88rpx;
  776. box-sizing: border-box;
  777. }
  778. /* 返回按钮 */
  779. .back-btn {
  780. width: 60rpx;
  781. height: 60rpx;
  782. display: flex;
  783. align-items: center;
  784. justify-content: center;
  785. }
  786. .back-icon {
  787. font-size: 36rpx;
  788. color: #ffffff;
  789. font-weight: bold;
  790. }
  791. /* 导航栏标题 */
  792. .nav-title {
  793. color: #ffffff;
  794. font-size: 36rpx;
  795. font-weight: bold;
  796. flex: 1;
  797. text-align: center;
  798. }
  799. /* 右侧占位 */
  800. .right-space {
  801. width: 60rpx;
  802. }
  803. /* 安全区域 */
  804. .safe-top {
  805. background: linear-gradient(-46deg, #ff7a75 0%, #ed6b66 98%);
  806. }
  807. /* 预览区域 */
  808. .preview-area {
  809. flex: 1;
  810. display: flex;
  811. justify-content: center;
  812. align-items: center;
  813. background: #ffffff;
  814. padding: 40rpx 20rpx;
  815. position: relative;
  816. min-height: 700rpx;
  817. }
  818. /* 预览图片容器 */
  819. .preview-image-wrapper {
  820. width: 100%;
  821. max-width: 600rpx;
  822. position: relative;
  823. }
  824. /* 预览图片 */
  825. .preview-image {
  826. width: 100%;
  827. border-radius: 20rpx;
  828. box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.15);
  829. transition:
  830. transform 0.3s ease,
  831. box-shadow 0.3s ease;
  832. }
  833. .preview-image:active {
  834. transform: scale(1.02);
  835. box-shadow: 0 15rpx 50rpx rgba(0, 0, 0, 0.2);
  836. }
  837. /* 图片操作提示 */
  838. .image-tips {
  839. display: flex;
  840. justify-content: center;
  841. margin-top: 20rpx;
  842. padding: 0 20rpx;
  843. }
  844. .tip-text {
  845. font-size: 24rpx;
  846. color: #666666;
  847. }
  848. /* 生成图片按钮容器 */
  849. .generate-btn-container {
  850. display: flex;
  851. justify-content: center;
  852. align-items: center;
  853. width: 100%;
  854. }
  855. /* 生成图片按钮 */
  856. .generate-btn {
  857. background: linear-gradient(-46deg, #ff7a75 0%, #ed6b66 98%);
  858. color: #ffffff;
  859. border: none;
  860. border-radius: 50rpx;
  861. padding: 25rpx 60rpx;
  862. font-size: 32rpx;
  863. font-weight: bold;
  864. box-shadow: 0 8rpx 25rpx rgba(255, 100, 71, 0.4);
  865. transition: all 0.3s ease;
  866. display: flex;
  867. align-items: center;
  868. gap: 10rpx;
  869. }
  870. .generate-btn:active {
  871. transform: translateY(2rpx);
  872. box-shadow: 0 5rpx 15rpx rgba(255, 100, 71, 0.3);
  873. }
  874. /* 操作区域 */
  875. .action-area {
  876. background: #ffffff;
  877. padding: 40rpx 30rpx;
  878. margin-top: 20rpx;
  879. border-radius: 30rpx 30rpx 0 0;
  880. box-shadow: 0 -10rpx 30rpx rgba(0, 0, 0, 0.05);
  881. }
  882. /* 操作说明 */
  883. .action-intro {
  884. text-align: center;
  885. margin-bottom: 40rpx;
  886. }
  887. .intro-title {
  888. display: block;
  889. font-size: 36rpx;
  890. font-weight: bold;
  891. color: #333333;
  892. margin-bottom: 10rpx;
  893. }
  894. .intro-desc {
  895. display: block;
  896. font-size: 28rpx;
  897. color: #666666;
  898. }
  899. /* 分享按钮容器 */
  900. .share-buttons {
  901. display: flex;
  902. justify-content: space-around;
  903. margin-bottom: 40rpx;
  904. }
  905. /* 分享按钮 */
  906. .share-btn {
  907. display: flex;
  908. flex-direction: column;
  909. align-items: center;
  910. justify-content: center;
  911. background: #ffffff;
  912. border: 2rpx solid #ff6347;
  913. border-radius: 20rpx;
  914. padding: 30rpx 20rpx;
  915. width: 200rpx;
  916. transition: all 0.3s ease;
  917. }
  918. .share-btn:active {
  919. background: #fff5f0;
  920. transform: translateY(2rpx);
  921. }
  922. /* 分享好友按钮 */
  923. .share-friend {
  924. border-color: #52c41a;
  925. color: #52c41a;
  926. }
  927. .share-friend .btn-icon {
  928. background: #e6f7ff;
  929. color: #52c41a;
  930. }
  931. /* 保存图片按钮 */
  932. .share-save {
  933. border-color: #ff8c00;
  934. color: #ff8c00;
  935. }
  936. .share-save .btn-icon {
  937. background: #fff5f0;
  938. color: #ff8c00;
  939. }
  940. /* 分享朋友圈按钮 */
  941. .share-moment {
  942. border-color: #ff6347;
  943. color: #ff6347;
  944. }
  945. .share-moment .btn-icon {
  946. background: #fff5f0;
  947. color: #ff6347;
  948. }
  949. /* 按钮图标 */
  950. .btn-icon {
  951. width: 80rpx;
  952. height: 80rpx;
  953. border-radius: 50%;
  954. display: flex;
  955. justify-content: center;
  956. align-items: center;
  957. margin-bottom: 15rpx;
  958. }
  959. .btn-icon .icon {
  960. font-size: 40rpx;
  961. }
  962. /* 按钮文本 */
  963. .btn-text {
  964. font-size: 28rpx;
  965. font-weight: bold;
  966. margin-bottom: 8rpx;
  967. }
  968. /* 按钮描述 */
  969. .btn-desc {
  970. font-size: 22rpx;
  971. opacity: 0.8;
  972. }
  973. /* 额外提示 */
  974. .extra-tips {
  975. background: #fffbe6;
  976. border: 2rpx solid #ffeaa7;
  977. border-radius: 15rpx;
  978. padding: 25rpx;
  979. }
  980. .tip-content {
  981. font-size: 24rpx;
  982. color: #856404;
  983. line-height: 36rpx;
  984. }
  985. /* 加载遮罩 */
  986. .loading-mask {
  987. position: fixed;
  988. top: 0;
  989. left: 0;
  990. width: 100%;
  991. height: 100%;
  992. background: rgba(255, 255, 255, 0.9);
  993. display: flex;
  994. justify-content: center;
  995. align-items: center;
  996. z-index: 999;
  997. }
  998. /* 加载内容 */
  999. .loading-content {
  1000. display: flex;
  1001. flex-direction: column;
  1002. align-items: center;
  1003. justify-content: center;
  1004. }
  1005. /* 加载动画 */
  1006. .loading-spinner {
  1007. width: 80rpx;
  1008. height: 80rpx;
  1009. border: 8rpx solid #f3f3f3;
  1010. border-top: 8rpx solid #ff6347;
  1011. border-radius: 50%;
  1012. animation: spin 1s linear infinite;
  1013. margin-bottom: 20rpx;
  1014. }
  1015. /* 加载文字 */
  1016. .loading-content text {
  1017. font-size: 28rpx;
  1018. color: #666666;
  1019. }
  1020. /* 隐藏canvas */
  1021. .canvas-hide {
  1022. position: absolute;
  1023. top: -9999px;
  1024. left: -9999px;
  1025. opacity: 0;
  1026. }
  1027. /* 动画 */
  1028. @keyframes spin {
  1029. 0% {
  1030. transform: rotate(0deg);
  1031. }
  1032. 100% {
  1033. transform: rotate(360deg);
  1034. }
  1035. }
  1036. /* 响应式设计 */
  1037. @media screen and (max-height: 1000rpx) {
  1038. .preview-area {
  1039. min-height: 600rpx;
  1040. }
  1041. .action-area {
  1042. padding: 30rpx 20rpx;
  1043. }
  1044. }
  1045. </style>