Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 

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