No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 

544 líneas
14 KiB

  1. <template>
  2. <view class="recharge">
  3. <!-- 余额卡 -->
  4. <view class="head">
  5. <view class="head-left">
  6. <text class="kicker">AI在线点数包</text>
  7. <text class="title">剩余 {{ balance }} 点</text>
  8. <text class="user">用户:{{ userStore.nickname }}</text>
  9. </view>
  10. <view class="avatar">
  11. <image v-if="userStore.avatar" class="avatar-img" :src="userStore.avatar" mode="aspectFill" />
  12. <text v-else>AI</text>
  13. </view>
  14. </view>
  15. <!-- 套餐 -->
  16. <view class="section-title">选择充值套餐</view>
  17. <view class="grid">
  18. <view
  19. v-for="item in packages"
  20. :key="item.id"
  21. class="product"
  22. :class="{ active: current && current.id === item.id, recommend: isRecommendedPackage(item) }"
  23. @click="current = item"
  24. >
  25. <text v-if="item.badge" class="badge">{{ item.badge }}</text>
  26. <text class="points">{{ item.points }}点</text>
  27. <text v-if="isRecommendedPackage(item)" class="recommend-copy">日常创作首选</text>
  28. <text class="price">¥{{ item.price_yuan }}</text>
  29. <view v-if="item.old_price_yuan" class="offer-row">
  30. <text class="old-price">¥{{ item.old_price_yuan }}</text>
  31. <text v-if="item.bonus_percent" class="bonus">多送{{ item.bonus_percent }}%</text>
  32. </view>
  33. </view>
  34. </view>
  35. <view class="buy-btn" :class="{ disabled: !current || paying }" @click="handleBuy">
  36. {{ buyText }}
  37. </view>
  38. <!-- 权益 -->
  39. <view class="perks">
  40. <view class="perk">
  41. <text class="perk-title">点数长期有效</text>
  42. <text class="perk-desc">购买后可用于所有 AI 图像工具</text>
  43. </view>
  44. <view class="perk">
  45. <text class="perk-title">多端通用</text>
  46. <text class="perk-desc">小程序、H5、PC 端共享点数</text>
  47. </view>
  48. </view>
  49. <!-- 购买须知 -->
  50. <view class="notice">
  51. <text class="notice-title">购买须知:</text>
  52. <text class="notice-line">1、购买后长期有效,不支持退款;</text>
  53. <text class="notice-line">2、同一账号点数可在小程序、H5、PC端使用;</text>
  54. <text class="notice-line">3、点数适用于 AI 图片生成、高清修复、证件照、AI绘画等功能。</text>
  55. </view>
  56. </view>
  57. </template>
  58. <script setup>
  59. import { ref, computed } from 'vue'
  60. import { onShow } from '@dcloudio/uni-app'
  61. import { useUserStore } from '@/store/user.js'
  62. import { useApp } from '@/utils/useApp.js'
  63. import { pointApi } from '@/api/index.js'
  64. import { reportCv } from '@/utils/pvcv.js'
  65. const userStore = useUserStore()
  66. const { toast } = useApp()
  67. const packages = ref([])
  68. const current = ref(null)
  69. const balance = ref(0)
  70. const paying = ref(false)
  71. const buyText = computed(() => {
  72. if (!current.value) return '请选择套餐'
  73. return `确认充值(${current.value.price_yuan}元)`
  74. })
  75. function isRecommendedPackage(item = {}) {
  76. return String(item.badge || '').includes('推荐')
  77. }
  78. function ensureLogin() {
  79. if (!userStore.isLogin) {
  80. uni.navigateTo({ url: '/pages/login/login' })
  81. return false
  82. }
  83. return true
  84. }
  85. async function loadPackages() {
  86. try {
  87. const res = await pointApi.getPackageList()
  88. packages.value = res.list || []
  89. current.value = packages.value.find((p) => p.badge === '推荐') || packages.value[0] || null
  90. } catch (e) {
  91. toast(e.msg || '套餐加载失败')
  92. }
  93. }
  94. async function loadBalance() {
  95. if (!userStore.isLogin) return
  96. try {
  97. const res = await pointApi.getBalance()
  98. balance.value = (res.data && res.data.points) || 0
  99. } catch (e) {
  100. // 登录失效由 request 统一处理
  101. }
  102. }
  103. function getWxLoginCode() {
  104. return new Promise((resolve, reject) => {
  105. // #ifdef MP-WEIXIN
  106. uni.login({
  107. provider: 'weixin',
  108. success: (r) => (r.code ? resolve(r.code) : reject(new Error('获取登录凭证失败'))),
  109. fail: () => reject(new Error('获取登录凭证失败'))
  110. })
  111. // #endif
  112. // #ifndef MP-WEIXIN
  113. reject(new Error('当前端暂不支持点数充值'))
  114. // #endif
  115. })
  116. }
  117. function requestVirtualPayment(order) {
  118. return new Promise((resolve, reject) => {
  119. // #ifdef MP-WEIXIN
  120. if (!wx.canIUse || !wx.canIUse('requestVirtualPayment')) {
  121. reject(new Error('当前微信版本不支持虚拟支付,请升级微信'))
  122. return
  123. }
  124. // 调起虚拟支付的完整参数(供审核/排查用)
  125. const payParams = {
  126. signData: order.signData,
  127. paySig: order.paySig,
  128. signature: order.signature,
  129. mode: order.mode
  130. }
  131. console.log('[虚拟支付] wx.requestVirtualPayment 参数:\n' + JSON.stringify(payParams, null, 2))
  132. wx.requestVirtualPayment({
  133. ...payParams,
  134. success: (res) => {
  135. console.log('[虚拟支付] success:', JSON.stringify(res))
  136. resolve(res)
  137. },
  138. fail: (err) => {
  139. console.log('[虚拟支付] fail:', JSON.stringify(err))
  140. reject(err)
  141. }
  142. })
  143. // #endif
  144. // #ifndef MP-WEIXIN
  145. reject(new Error('当前端暂不支持点数充值'))
  146. // #endif
  147. })
  148. }
  149. async function pollOrder(orderNo, times = 5) {
  150. for (let i = 0; i < times; i++) {
  151. try {
  152. const res = await pointApi.getOrderStatus({ order_no: orderNo })
  153. if (res.data && res.data.status === 20) {
  154. balance.value = res.data.balance
  155. return true
  156. }
  157. } catch (e) {
  158. // 忽略单次失败,继续轮询
  159. }
  160. await new Promise((r) => setTimeout(r, 1500))
  161. }
  162. return false
  163. }
  164. async function handleBuy() {
  165. if (!ensureLogin()) return
  166. if (!current.value || paying.value) return
  167. reportCv('point_recharge_click')
  168. paying.value = true
  169. uni.showLoading({ mask: true, title: '发起支付...' })
  170. try {
  171. const code = await getWxLoginCode()
  172. const res = await pointApi.createOrder({ package_id: current.value.id, code })
  173. const order = res.data
  174. reportCv('point_recharge_order')
  175. uni.hideLoading()
  176. // 打印后端返回的下单结果(含 env,1=沙箱不扣费 / 0=现网真实扣费)
  177. console.log('[虚拟支付] createorder 返回:', JSON.stringify(order))
  178. // 解析 signData 里的 env,直观确认环境
  179. try {
  180. const sd = JSON.parse(order.signData || '{}')
  181. console.log('[虚拟支付] signData.env =', sd.env, sd.env === 1 ? '(沙箱-不扣费)' : '(现网-真实扣费!)')
  182. } catch (e) {}
  183. await requestVirtualPayment(order)
  184. uni.showLoading({ mask: true, title: '确认到账...' })
  185. const ok = await pollOrder(order.order_no)
  186. uni.hideLoading()
  187. if (ok) {
  188. reportCv('point_recharge_paid')
  189. toast('充值成功')
  190. } else {
  191. toast('支付成功,点数到账处理中')
  192. loadBalance()
  193. }
  194. } catch (e) {
  195. uni.hideLoading()
  196. const errCode = e && e.errCode
  197. if (errCode === -2) {
  198. // 用户取消支付,不提示
  199. } else {
  200. toast((e && (e.msg || e.errMsg || e.message)) || '支付失败')
  201. }
  202. } finally {
  203. paying.value = false
  204. }
  205. }
  206. onShow(() => {
  207. loadPackages()
  208. loadBalance()
  209. })
  210. </script>
  211. <style lang="scss" scoped>
  212. $accent: #0b8cff;
  213. $muted: #8b95a3;
  214. .recharge {
  215. padding: 36rpx 36rpx calc(48rpx + env(safe-area-inset-bottom));
  216. }
  217. /* 余额卡 */
  218. .head {
  219. display: flex;
  220. align-items: center;
  221. justify-content: space-between;
  222. gap: 32rpx;
  223. min-height: 264rpx;
  224. padding: 44rpx 40rpx;
  225. border-radius: 36rpx;
  226. color: #fff;
  227. box-sizing: border-box;
  228. background:
  229. radial-gradient(circle at 82% 18%, rgba(255, 255, 255, 0.28), transparent 28%),
  230. linear-gradient(135deg, #0b8cff 0%, #20a7ff 48%, #20d3a2 100%);
  231. box-shadow: 0 28rpx 56rpx rgba(11, 140, 255, 0.18);
  232. .head-left {
  233. display: flex;
  234. flex-direction: column;
  235. }
  236. .kicker {
  237. font-size: 26rpx;
  238. font-weight: 800;
  239. color: rgba(255, 255, 255, 0.82);
  240. }
  241. .title {
  242. margin-top: 14rpx;
  243. font-size: 56rpx;
  244. line-height: 1.35;
  245. font-weight: 900;
  246. }
  247. .user {
  248. margin-top: 12rpx;
  249. font-size: 26rpx;
  250. color: rgba(255, 255, 255, 0.78);
  251. }
  252. .avatar {
  253. flex: 0 0 104rpx;
  254. width: 104rpx;
  255. height: 104rpx;
  256. border-radius: 50%;
  257. display: flex;
  258. align-items: center;
  259. justify-content: center;
  260. font-size: 32rpx;
  261. font-weight: 900;
  262. color: #fff;
  263. background: rgba(15, 23, 42, 0.24);
  264. border: 4rpx solid rgba(255, 255, 255, 0.55);
  265. overflow: hidden;
  266. }
  267. .avatar-img {
  268. width: 100%;
  269. height: 100%;
  270. }
  271. }
  272. .section-title {
  273. margin: 48rpx 0 24rpx;
  274. font-size: 34rpx;
  275. font-weight: 900;
  276. color: #1f2733;
  277. }
  278. /* 套餐宫格 */
  279. .grid {
  280. display: grid;
  281. grid-template-columns: repeat(3, minmax(0, 1fr));
  282. gap: 20rpx;
  283. }
  284. .product {
  285. position: relative;
  286. overflow: hidden;
  287. min-height: 212rpx;
  288. padding: 28rpx 16rpx 24rpx;
  289. border: 4rpx solid transparent;
  290. border-radius: 28rpx;
  291. display: flex;
  292. flex-direction: column;
  293. align-items: center;
  294. justify-content: center;
  295. gap: 16rpx;
  296. color: #1f2733;
  297. background: #fff;
  298. box-shadow: 0 20rpx 44rpx rgba(15, 23, 42, 0.06);
  299. transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
  300. &::before {
  301. content: '';
  302. position: absolute;
  303. top: -54rpx;
  304. right: -54rpx;
  305. width: 136rpx;
  306. height: 136rpx;
  307. border-radius: 50%;
  308. background: transparent;
  309. }
  310. &::after {
  311. content: '';
  312. position: absolute;
  313. left: 20rpx;
  314. right: 20rpx;
  315. bottom: 14rpx;
  316. height: 4rpx;
  317. border-radius: 999rpx;
  318. background: transparent;
  319. }
  320. .badge,
  321. .points,
  322. .recommend-copy,
  323. .price,
  324. .offer-row {
  325. position: relative;
  326. z-index: 1;
  327. }
  328. &.recommend {
  329. border-color: rgba(246, 196, 83, 0.82);
  330. color: #fff;
  331. background:
  332. radial-gradient(circle at 82% 8%, rgba(255, 231, 163, 0.28), transparent 32%),
  333. radial-gradient(circle at 8% 92%, rgba(202, 138, 4, 0.18), transparent 36%),
  334. linear-gradient(160deg, #1c1917 0%, #111827 52%, #020617 100%);
  335. box-shadow:
  336. 0 30rpx 72rpx rgba(15, 23, 42, 0.26),
  337. 0 0 0 6rpx rgba(246, 196, 83, 0.12);
  338. }
  339. &.recommend::before {
  340. background: radial-gradient(circle, rgba(255, 216, 106, 0.32), transparent 68%);
  341. }
  342. &.active {
  343. border-color: $accent;
  344. background: linear-gradient(180deg, #f4faff 0%, #ffffff 68%);
  345. box-shadow:
  346. 0 26rpx 56rpx rgba(11, 140, 255, 0.18),
  347. 0 0 0 8rpx rgba(11, 140, 255, 0.1);
  348. }
  349. &.recommend.active {
  350. border-color: #f6c453;
  351. background:
  352. radial-gradient(circle at 82% 8%, rgba(255, 231, 163, 0.38), transparent 32%),
  353. radial-gradient(circle at 8% 92%, rgba(202, 138, 4, 0.22), transparent 38%),
  354. linear-gradient(160deg, #27201a 0%, #111827 50%, #020617 100%);
  355. box-shadow:
  356. 0 34rpx 78rpx rgba(15, 23, 42, 0.32),
  357. 0 0 0 8rpx rgba(246, 196, 83, 0.16),
  358. inset 0 2rpx 0 rgba(255, 255, 255, 0.86);
  359. }
  360. &.recommend.active::after {
  361. background: linear-gradient(90deg, transparent, rgba(255, 216, 106, 0.72), transparent);
  362. box-shadow: 0 0 18rpx rgba(255, 216, 106, 0.3);
  363. }
  364. .badge {
  365. position: absolute;
  366. top: 16rpx;
  367. left: 50%;
  368. transform: translateX(-50%);
  369. max-width: calc(100% - 24rpx);
  370. padding: 6rpx 14rpx;
  371. border-radius: 999rpx;
  372. font-size: 20rpx;
  373. font-weight: 900;
  374. white-space: nowrap;
  375. color: $accent;
  376. background: rgba(11, 140, 255, 0.1);
  377. }
  378. &.recommend .badge {
  379. color: #2a1803;
  380. background: linear-gradient(135deg, #fff1b8 0%, #f6c453 50%, #d89a22 100%);
  381. box-shadow: 0 10rpx 24rpx rgba(216, 154, 34, 0.32);
  382. }
  383. .points {
  384. margin-top: 34rpx;
  385. font-size: 40rpx;
  386. font-weight: 900;
  387. }
  388. .recommend-copy {
  389. margin-top: -10rpx;
  390. font-size: 20rpx;
  391. line-height: 1;
  392. font-weight: 800;
  393. color: #3b8bd8;
  394. }
  395. &.recommend .points {
  396. color: #fff;
  397. text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.32);
  398. }
  399. &.recommend .recommend-copy {
  400. color: rgba(255, 220, 138, 0.92);
  401. }
  402. .price {
  403. font-size: 26rpx;
  404. font-weight: 800;
  405. color: $accent;
  406. }
  407. &.recommend .price {
  408. color: #ffd76a;
  409. text-shadow: 0 2rpx 10rpx rgba(216, 154, 34, 0.22);
  410. }
  411. .offer-row {
  412. display: flex;
  413. align-items: center;
  414. justify-content: center;
  415. gap: 8rpx;
  416. min-height: 30rpx;
  417. white-space: nowrap;
  418. }
  419. .old-price {
  420. color: #9aa6b2;
  421. font-size: 21rpx;
  422. text-decoration: line-through;
  423. }
  424. .bonus {
  425. padding: 3rpx 8rpx;
  426. border-radius: 999rpx;
  427. color: #159a54;
  428. background: #edfdf4;
  429. font-size: 19rpx;
  430. font-weight: 900;
  431. }
  432. &.recommend .old-price {
  433. color: rgba(255, 255, 255, 0.45);
  434. }
  435. &.recommend .bonus {
  436. color: #7a4a00;
  437. background: linear-gradient(135deg, rgba(255, 241, 184, 0.96), rgba(255, 215, 106, 0.96));
  438. }
  439. }
  440. /* 确认充值 */
  441. .buy-btn {
  442. display: flex;
  443. align-items: center;
  444. justify-content: center;
  445. height: 96rpx;
  446. margin-top: 36rpx;
  447. border-radius: 24rpx;
  448. font-size: 30rpx;
  449. font-weight: 900;
  450. color: #fff;
  451. background: linear-gradient(135deg, #0b8cff, #20a7ff);
  452. box-shadow: 0 24rpx 48rpx rgba(11, 140, 255, 0.22);
  453. &.disabled {
  454. opacity: 0.6;
  455. }
  456. }
  457. /* 权益 */
  458. .perks {
  459. display: grid;
  460. grid-template-columns: repeat(2, minmax(0, 1fr));
  461. gap: 20rpx;
  462. margin-top: 36rpx;
  463. .perk {
  464. padding: 28rpx 24rpx;
  465. border-radius: 28rpx;
  466. background: rgba(11, 140, 255, 0.08);
  467. }
  468. .perk-title {
  469. font-size: 28rpx;
  470. font-weight: 900;
  471. color: $accent;
  472. }
  473. .perk-desc {
  474. display: block;
  475. margin-top: 14rpx;
  476. font-size: 24rpx;
  477. line-height: 1.45;
  478. color: $muted;
  479. }
  480. }
  481. /* 购买须知 */
  482. .notice {
  483. margin-top: 44rpx;
  484. padding: 28rpx;
  485. border-radius: 28rpx;
  486. background: #fff;
  487. box-shadow: 0 20rpx 44rpx rgba(15, 23, 42, 0.04);
  488. .notice-title {
  489. display: block;
  490. margin-bottom: 8rpx;
  491. font-size: 26rpx;
  492. font-weight: 800;
  493. color: #8f98a6;
  494. }
  495. .notice-line {
  496. display: block;
  497. font-size: 26rpx;
  498. line-height: 1.8;
  499. color: $muted;
  500. }
  501. }
  502. </style>