index.vue 31 KB

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