Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

328 linhas
13 KiB

  1. <template>
  2. <div>
  3. <AppHero
  4. kicker="AI ONLINE POINTS"
  5. title="点数充值"
  6. desc="点数长期有效,多端通用。小程序、H5、PC 端共享同一份点数。"
  7. >
  8. <template #aside>
  9. <!-- 余额卡 -->
  10. <div class="w-full rounded-3xl border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/80 p-5 shadow-[0_18px_34px_var(--sh-0-92-190-100)] backdrop-blur lg:w-[260px]">
  11. <div class="text-xs font-bold text-[var(--c-8a97a8)]">我的点数</div>
  12. <div class="mt-2 flex items-end gap-1.5">
  13. <span class="text-4xl font-extrabold leading-none text-[var(--c-0b8cff)]">{{ balanceText }}</span>
  14. <span v-if="balanceUnit" class="pb-1 text-sm font-bold text-[var(--c-8a97a8)]">{{ balanceUnit }}</span>
  15. </div>
  16. <NuxtLink to="/bill" class="mt-3 inline-flex items-center gap-1 text-xs font-bold text-[var(--c-0b8cff)] hover:underline">
  17. 查看账单明细
  18. <UIcon name="i-lucide-chevron-right" class="size-3.5" />
  19. </NuxtLink>
  20. </div>
  21. </template>
  22. </AppHero>
  23. <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
  24. <div class="lg:grid lg:grid-cols-12 lg:gap-8">
  25. <!-- ===== 左:套餐选择 ===== -->
  26. <div class="lg:col-span-7">
  27. <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_8px_28px_var(--sh-34-68-112-50)] lg:p-6">
  28. <div class="flex items-baseline">
  29. <h2 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">选择充值套餐</h2>
  30. <span class="ml-auto text-xs text-[var(--c-8a95a6)]">点数长期有效</span>
  31. </div>
  32. <!-- 加载中 -->
  33. <div v-if="loading" class="grid grid-cols-2 gap-3 pt-4 sm:grid-cols-3">
  34. <div v-for="i in 3" :key="i" class="h-[124px] animate-pulse rounded-2xl bg-[var(--c-f1f5fa)]" />
  35. </div>
  36. <!-- 套餐网格 -->
  37. <div v-else-if="packages.length" class="grid grid-cols-2 gap-3 pt-4 sm:grid-cols-3">
  38. <button
  39. v-for="pkg in packages"
  40. :key="pkg.id"
  41. type="button"
  42. class="relative flex min-h-[124px] flex-col items-center justify-center gap-2 rounded-2xl border-2 p-4 transition-all"
  43. :class="current && current.id === pkg.id
  44. ? 'border-[var(--c-0b8cff)] bg-[var(--c-f4faff)] shadow-[0_0_0_4px_var(--sh-11-140-255-100)]'
  45. : 'border-transparent bg-[var(--c-f7f9fd)] hover:border-[var(--c-e5edf7)]'"
  46. @click="selectPackage(pkg)"
  47. >
  48. <span
  49. v-if="pkg.badge"
  50. class="absolute left-1/2 top-2 -translate-x-1/2 whitespace-nowrap rounded-full bg-[var(--c-0b8cff)]/10 px-2 py-0.5 text-[10px] font-extrabold text-[var(--c-0b8cff)]"
  51. >{{ pkg.badge }}</span>
  52. <span class="mt-4 text-xl font-extrabold text-[var(--c-172033)]">{{ pkg.points }}点</span>
  53. <span class="text-sm font-extrabold text-[var(--c-0b8cff)]">¥{{ pkg.price_yuan }}</span>
  54. <span v-if="pkg.old_price_yuan" class="flex items-center gap-1.5 text-xs">
  55. <span class="text-[var(--c-a0aabb)] line-through">¥{{ pkg.old_price_yuan }}</span>
  56. <span
  57. v-if="pkg.bonus_percent"
  58. class="rounded-full bg-[var(--c-edfdf4)] px-1.5 py-0.5 font-extrabold text-[var(--c-159a54)]"
  59. >多送{{ pkg.bonus_percent }}%</span>
  60. </span>
  61. </button>
  62. </div>
  63. <!-- 空态 -->
  64. <div v-else class="py-14 text-center">
  65. <UIcon name="i-lucide-package-x" class="mx-auto mb-3 size-10 text-[var(--c-a0aabb)]" />
  66. <div class="text-sm text-[var(--c-8a95a6)]">暂无可用套餐</div>
  67. </div>
  68. <!-- 确认充值 -->
  69. <button
  70. type="button"
  71. class="mt-5 flex h-12 w-full items-center justify-center rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] text-base font-extrabold text-white shadow-[0_12px_24px_var(--sh-11-140-255-220)] transition-opacity disabled:cursor-not-allowed disabled:opacity-50 lg:h-14"
  72. :disabled="!current || payState === 'loading'"
  73. @click="startPay"
  74. >
  75. {{ buyText }}
  76. </button>
  77. </div>
  78. <!-- 权益 -->
  79. <div class="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
  80. <div class="rounded-2xl bg-[var(--c-0b8cff)]/[0.07] p-4">
  81. <div class="text-sm font-extrabold text-[var(--c-0b8cff)]">点数长期有效</div>
  82. <div class="mt-1.5 text-xs leading-relaxed text-[var(--c-66758a)]">购买后可用于所有 AI 图像工具</div>
  83. </div>
  84. <div class="rounded-2xl bg-[var(--c-0b8cff)]/[0.07] p-4">
  85. <div class="text-sm font-extrabold text-[var(--c-0b8cff)]">多端通用</div>
  86. <div class="mt-1.5 text-xs leading-relaxed text-[var(--c-66758a)]">小程序、H5、PC 端共享点数</div>
  87. </div>
  88. </div>
  89. <!-- 购买须知 -->
  90. <div class="mt-5 rounded-2xl bg-[var(--c-ffffff)] p-5 text-sm leading-relaxed text-[var(--c-8a95a6)] shadow-[0_8px_28px_var(--sh-34-68-112-40)]">
  91. <div class="mb-1 font-bold text-[var(--c-7f8896)]">购买须知:</div>
  92. <div>1、购买后长期有效,不支持退款;</div>
  93. <div>2、同一账号点数可在小程序、H5、PC端使用;</div>
  94. <div>3、点数适用于 AI 图片生成、高清修复、证件照、AI绘画等功能。</div>
  95. </div>
  96. </div>
  97. <!-- ===== 右:扫码支付(吸顶) ===== -->
  98. <div class="mt-5 lg:col-span-5 lg:mt-0">
  99. <div class="space-y-4 lg:sticky lg:top-20">
  100. <div class="rounded-2xl bg-[var(--c-ffffff)] p-6 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
  101. <!-- 未开始 -->
  102. <template v-if="payState === 'idle'">
  103. <UIcon name="i-lucide-qr-code" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" />
  104. <div class="mb-1 text-base font-bold text-[var(--c-172033)]">微信扫码支付</div>
  105. <div class="text-sm text-[var(--c-a0aabb)]">选择套餐后点击「确认充值」</div>
  106. <div class="text-sm text-[var(--c-a0aabb)]">这里会生成支付二维码</div>
  107. </template>
  108. <!-- 生成中 / 待支付 / 成功 -->
  109. <template v-else>
  110. <div class="mb-1 text-base font-bold text-[var(--c-172033)]">
  111. {{ payState === 'success' ? '充值成功' : '微信扫码支付' }}
  112. </div>
  113. <div class="mb-4 text-sm text-[var(--c-8a95a6)]">
  114. {{ payStateText }}
  115. </div>
  116. <div class="mx-auto mb-4 flex h-[200px] w-[200px] items-center justify-center rounded-2xl bg-[var(--c-f7f9fd)]">
  117. <div v-if="payState === 'loading'" class="flex flex-col items-center gap-2">
  118. <span class="size-7 animate-spin rounded-full border-2 border-[var(--c-0b8cff)] border-t-transparent" />
  119. <span class="text-xs text-[var(--c-a0aabb)]">生成二维码...</span>
  120. </div>
  121. <div v-else-if="payState === 'success'" class="flex flex-col items-center gap-2">
  122. <UIcon name="i-lucide-circle-check" class="size-14 text-[var(--c-17a65a)]" />
  123. <span class="text-sm font-bold text-[var(--c-17a65a)]">+{{ current?.points || 0 }} 点已到账</span>
  124. </div>
  125. <ClientOnly v-else-if="codeUrl">
  126. <vue-qr :text="codeUrl" :size="180" :margin="8" />
  127. </ClientOnly>
  128. <div v-else class="flex flex-col items-center gap-2">
  129. <UIcon name="i-lucide-triangle-alert" class="size-9 text-[var(--c-f04438)]" />
  130. <span class="text-xs text-[var(--c-f04438)]">生成失败,请重试</span>
  131. </div>
  132. </div>
  133. <div v-if="payState !== 'success'" class="text-xs text-[var(--c-a0aabb)]">
  134. 支付金额:¥{{ current?.price_yuan || '0.00' }}
  135. </div>
  136. <div class="mt-4 flex justify-center gap-3">
  137. <button
  138. v-if="payState !== 'success'"
  139. class="rounded-full px-4 py-2 text-xs text-[var(--c-8a95a6)] transition-colors hover:text-[var(--c-172033)]"
  140. @click="cancelPay"
  141. >
  142. 取消支付
  143. </button>
  144. <button
  145. v-else
  146. class="rounded-full bg-[var(--c-0b8cff)] px-6 py-2 text-xs font-bold text-white transition-colors hover:bg-[var(--c-0a7ce0)]"
  147. @click="finishPay"
  148. >
  149. 完成
  150. </button>
  151. </div>
  152. </template>
  153. </div>
  154. </div>
  155. </div>
  156. </div>
  157. </div>
  158. </div>
  159. </template>
  160. <script setup>
  161. import VueQr from 'vue-qr'
  162. useSeoMeta({
  163. title: '点数充值 - AI在线',
  164. description: '选择套餐充值点数,点数长期有效、多端通用',
  165. })
  166. const { userInfo, isLogin, showLogin, refreshBalance } = useUser()
  167. const { post } = useApi()
  168. const toast = useToast()
  169. const packages = ref([])
  170. const current = ref(null)
  171. const loading = ref(true)
  172. const balance = reactive({ points: 0 })
  173. // idle | loading | pending | success | error
  174. const payState = ref('idle')
  175. const codeUrl = ref('')
  176. const orderNo = ref('')
  177. let pollingTimer = null
  178. const balanceText = computed(() => (Number(balance.points) < 0 ? '不限' : String(Number(balance.points) || 0)))
  179. const balanceUnit = computed(() => (balanceText.value === '不限' ? '' : '点'))
  180. const buyText = computed(() => {
  181. if (!current.value) return '请选择套餐'
  182. return `确认充值(${current.value.price_yuan}元)`
  183. })
  184. const payStateText = computed(() => {
  185. if (payState.value === 'loading') return '正在创建订单...'
  186. if (payState.value === 'pending') return '请使用微信扫描二维码完成支付'
  187. if (payState.value === 'success') return '点数已到账,可以开始创作了'
  188. return '订单创建失败'
  189. })
  190. function showMsg(msg, color = 'error') {
  191. toast.add({ title: msg, color })
  192. }
  193. // ==================== 套餐 & 余额 ====================
  194. async function loadPackages() {
  195. loading.value = true
  196. try {
  197. const res = await post('/point/packagelist')
  198. packages.value = res.list || []
  199. current.value = packages.value.find(p => p.badge === '推荐') || packages.value[0] || null
  200. } catch (e) {
  201. packages.value = []
  202. showMsg(e.message || '套餐加载失败')
  203. } finally {
  204. loading.value = false
  205. }
  206. }
  207. async function loadBalance() {
  208. if (!isLogin.value) return
  209. try {
  210. const res = await post('/point/balance')
  211. balance.points = res.data?.points || 0
  212. userInfo.value = { ...userInfo.value, balance: balance.points }
  213. } catch {
  214. // 忽略
  215. }
  216. }
  217. function selectPackage(pkg) {
  218. if (payState.value === 'pending' || payState.value === 'loading') cancelPay()
  219. current.value = pkg
  220. }
  221. // ==================== 支付(PC 走微信 NATIVE 扫码) ====================
  222. async function startPay() {
  223. if (!current.value) return
  224. if (!isLogin.value) {
  225. showLogin.value = true
  226. return
  227. }
  228. payState.value = 'loading'
  229. codeUrl.value = ''
  230. try {
  231. const res = await post('/point/createorder', {
  232. package_id: current.value.id,
  233. client: 1, // PC
  234. })
  235. const data = res.data || {}
  236. // NATIVE 支付返回二维码链接(兼容多种字段命名)
  237. const url = data.code_url || data.codeUrl || data.qr_code || ''
  238. orderNo.value = data.order_no || ''
  239. if (url) {
  240. codeUrl.value = url
  241. payState.value = 'pending'
  242. startPolling()
  243. } else {
  244. payState.value = 'error'
  245. showMsg(res.msg || 'PC 端支付暂不可用')
  246. }
  247. } catch (e) {
  248. payState.value = 'error'
  249. showMsg(e.message || '创建订单失败')
  250. }
  251. }
  252. // 轮询订单状态:服务端会主动查微信并流转订单 + 幂等入账
  253. function startPolling() {
  254. clearInterval(pollingTimer)
  255. pollingTimer = setInterval(async () => {
  256. if (!orderNo.value) return
  257. try {
  258. const res = await post('/point/orderstatus', { order_no: orderNo.value })
  259. const d = res.data || {}
  260. if (Number(d.status) === 20) {
  261. clearInterval(pollingTimer)
  262. payState.value = 'success'
  263. balance.points = d.balance ?? balance.points
  264. refreshBalance() // 同步 header 点数
  265. }
  266. } catch {
  267. // 单次失败不中断轮询
  268. }
  269. }, 2000)
  270. }
  271. function cancelPay() {
  272. clearInterval(pollingTimer)
  273. payState.value = 'idle'
  274. codeUrl.value = ''
  275. orderNo.value = ''
  276. }
  277. function finishPay() {
  278. cancelPay()
  279. loadBalance()
  280. }
  281. onMounted(() => {
  282. loadPackages()
  283. loadBalance()
  284. })
  285. watch(isLogin, async (loggedIn) => {
  286. if (!loggedIn) {
  287. cancelPay()
  288. balance.points = 0
  289. }
  290. await loadPackages()
  291. if (loggedIn) await loadBalance()
  292. })
  293. onUnmounted(() => clearInterval(pollingTimer))
  294. </script>