Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 

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