index.vue 44 KB

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