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.
 
 
 
 

243 rader
7.4 KiB

  1. <template>
  2. <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
  3. <!-- 标题 -->
  4. <div class="text-center mb-8">
  5. <h1 class="text-2xl sm:text-3xl font-bold text-slate-900">充值中心</h1>
  6. <p class="mt-2 text-sm text-slate-500">为你的 AI 创作充能</p>
  7. </div>
  8. <!-- 余额 -->
  9. <div class="max-w-sm mx-auto mb-8 px-6 py-4 bg-white rounded-2xl shadow-sm border border-slate-100 text-center">
  10. <span class="text-sm text-slate-500">当前余额</span>
  11. <div class="mt-1 text-3xl font-bold text-indigo-600">{{ userInfo.balance || 0 }} <span class="text-base font-normal text-slate-400">点</span></div>
  12. </div>
  13. <!-- 套餐列表 -->
  14. <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
  15. <div
  16. v-for="(pkg, idx) in products"
  17. :key="pkg.id"
  18. :class="[
  19. 'relative bg-white rounded-2xl p-6 border transition-all duration-200 cursor-pointer text-center',
  20. selectedId === pkg.id
  21. ? 'border-indigo-400 shadow-md ring-2 ring-indigo-500/20'
  22. : 'border-slate-100 shadow-sm hover:shadow-md hover:border-indigo-100',
  23. ]"
  24. @click="selectProduct(pkg)"
  25. >
  26. <div
  27. v-if="idx === 1"
  28. class="absolute -top-2.5 left-1/2 -translate-x-1/2 px-3 py-0.5 bg-green-500 text-white text-xs font-medium rounded-full"
  29. >
  30. 推荐
  31. </div>
  32. <div class="text-lg font-semibold text-slate-900">{{ pkg.points || pkg.point }} 点</div>
  33. <div class="mt-1 text-2xl font-bold text-slate-900">¥{{ pkg.price }}</div>
  34. <div v-if="pkg.discount" class="mt-1 text-xs text-green-600">{{ pkg.discount }}</div>
  35. </div>
  36. </div>
  37. <!-- 支付按钮 -->
  38. <div class="max-w-sm mx-auto">
  39. <button
  40. v-if="!isPaying"
  41. :disabled="!selectedId"
  42. class="w-full py-3 text-sm font-semibold text-white bg-indigo-500 hover:bg-indigo-600 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-xl transition-colors cursor-pointer"
  43. @click="startPay"
  44. >
  45. 确认支付
  46. </button>
  47. <!-- 支付弹窗内嵌 -->
  48. <div v-if="isPaying" class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
  49. <div class="text-center">
  50. <p class="text-sm font-medium text-slate-700 mb-4">微信扫码支付</p>
  51. <!-- 二维码 -->
  52. <div class="w-40 h-40 mx-auto mb-4 flex items-center justify-center">
  53. <div v-if="payState === 'loading'" class="flex flex-col items-center gap-2">
  54. <svg class="w-8 h-8 text-indigo-500 animate-spin" fill="none" viewBox="0 0 24 24">
  55. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
  56. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
  57. </svg>
  58. <span class="text-xs text-slate-400">生成二维码...</span>
  59. </div>
  60. <vue-qr
  61. v-else-if="codeUrl"
  62. :text="codeUrl"
  63. :size="160"
  64. :margin="8"
  65. />
  66. <div v-else class="flex flex-col items-center gap-2">
  67. <svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  68. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
  69. </svg>
  70. <span class="text-xs text-red-400">生成失败</span>
  71. </div>
  72. </div>
  73. <p v-if="payState === 'pending'" class="text-sm text-slate-500 mb-1">
  74. 请使用微信扫描二维码
  75. </p>
  76. <p v-else-if="payState === 'success'" class="text-sm text-green-600 font-medium">
  77. 支付成功!🎉
  78. </p>
  79. <p v-if="payState !== 'success'" class="text-xs text-slate-400">支付金额:¥{{ selectedPrice }}</p>
  80. </div>
  81. <!-- 操作按钮 -->
  82. <div class="flex justify-center gap-3 mt-4">
  83. <button
  84. v-if="payState !== 'success'"
  85. class="px-4 py-2 text-xs text-slate-400 hover:text-slate-600 cursor-pointer"
  86. @click="cancelPay"
  87. >
  88. 取消支付
  89. </button>
  90. <button
  91. v-if="payState === 'success'"
  92. class="px-4 py-2 text-xs text-white bg-green-500 hover:bg-green-600 rounded-lg cursor-pointer"
  93. @click="finishPaySuccess"
  94. >
  95. 完成
  96. </button>
  97. </div>
  98. </div>
  99. </div>
  100. </div>
  101. </template>
  102. <script setup>
  103. import VueQr from 'vue-qr'
  104. useSeoMeta({
  105. title: '充值中心 - 奇想宇宙',
  106. description: '选择套餐,为AI创作充值点数',
  107. })
  108. const { userInfo, isLogin } = useUser()
  109. const { post } = useApi()
  110. // 套餐列表
  111. const products = ref([])
  112. const selectedId = ref(null)
  113. const selectedPrice = ref(0)
  114. // 支付状态
  115. const isPaying = ref(false)
  116. const payState = ref('idle') // idle | loading | pending | success | error
  117. const codeUrl = ref('')
  118. const orderNo = ref('')
  119. let pollingTimer = null
  120. // 获取套餐列表
  121. async function loadProducts() {
  122. try {
  123. const res = await post('/vip/getproductlist')
  124. if (res.code === 0) {
  125. products.value = (res.list || res.data || []).map((p) => ({
  126. ...p,
  127. point: p.point || p.points,
  128. }))
  129. }
  130. } catch {
  131. products.value = []
  132. }
  133. }
  134. // 选择套餐
  135. function selectProduct(pkg) {
  136. selectedId.value = pkg.id
  137. selectedPrice.value = pkg.price
  138. }
  139. // 开始支付(对齐老 PC 的 buy 流程)
  140. async function startPay() {
  141. if (!selectedId.value) return
  142. payState.value = 'loading'
  143. isPaying.value = true
  144. try {
  145. const res = await post('/vip/createorder', {
  146. product_id: selectedId.value,
  147. client: 1,
  148. })
  149. if (res.code === 0 && res.data?.code_url) {
  150. codeUrl.value = res.data.code_url
  151. orderNo.value = res.data.order_no || ''
  152. payState.value = 'pending'
  153. startPolling()
  154. } else {
  155. payState.value = 'error'
  156. }
  157. } catch {
  158. payState.value = 'error'
  159. }
  160. }
  161. // 轮询支付结果(对齐老 PC:每 2 秒轮询 paysuccess)
  162. function startPolling() {
  163. clearInterval(pollingTimer)
  164. pollingTimer = setInterval(async () => {
  165. try {
  166. const res = await post('/vip/paysuccess', { order_no: orderNo.value })
  167. // status == 2 表示支付成功
  168. if (res.code === 0 && (res.data?.status == 2 || res.data?.paid === true)) {
  169. clearInterval(pollingTimer)
  170. payState.value = 'success'
  171. // 刷新余额
  172. await refreshUserInfo()
  173. }
  174. } catch {
  175. // 轮询失败不中断
  176. }
  177. }, 2000)
  178. }
  179. // 取消支付
  180. function cancelPay() {
  181. clearInterval(pollingTimer)
  182. isPaying.value = false
  183. payState.value = 'idle'
  184. codeUrl.value = ''
  185. orderNo.value = ''
  186. }
  187. // 支付成功完成
  188. function finishPaySuccess() {
  189. clearInterval(pollingTimer)
  190. isPaying.value = false
  191. payState.value = 'idle'
  192. codeUrl.value = ''
  193. orderNo.value = ''
  194. selectedId.value = null
  195. }
  196. // 刷新用户余额
  197. async function refreshUserInfo() {
  198. try {
  199. const res = await post('/user/getuserinfo')
  200. if (res.code === 0 && res.data) {
  201. userInfo.value.balance = res.data.balance || res.data.point || 0
  202. }
  203. } catch {
  204. // 忽略
  205. }
  206. }
  207. onMounted(() => {
  208. loadProducts()
  209. if (isLogin.value) {
  210. refreshUserInfo()
  211. }
  212. })
  213. onUnmounted(() => {
  214. clearInterval(pollingTimer)
  215. })
  216. </script>