You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

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