|
- <template>
- <div>
- <AppHero
- kicker="AI ONLINE POINTS"
- title="点数充值"
- desc="点数长期有效,多端通用。小程序、H5、PC 端共享同一份点数。"
- >
- <template #aside>
- <!-- 余额卡 -->
- <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]">
- <div class="text-xs font-bold text-[var(--c-8a97a8)]">我的点数</div>
- <div class="mt-2 flex items-end gap-1.5">
- <span class="text-4xl font-extrabold leading-none text-[var(--c-0b8cff)]">{{ balanceText }}</span>
- <span v-if="balanceUnit" class="pb-1 text-sm font-bold text-[var(--c-8a97a8)]">{{ balanceUnit }}</span>
- </div>
- <NuxtLink to="/bill" class="mt-3 inline-flex items-center gap-1 text-xs font-bold text-[var(--c-0b8cff)] hover:underline">
- 查看账单明细
- <UIcon name="i-lucide-chevron-right" class="size-3.5" />
- </NuxtLink>
- </div>
- </template>
- </AppHero>
-
- <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
- <div class="lg:grid lg:grid-cols-12 lg:gap-8">
- <!-- ===== 左:套餐选择 ===== -->
- <div class="lg:col-span-7">
- <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_8px_28px_var(--sh-34-68-112-50)] lg:p-6">
- <div class="flex items-baseline">
- <h2 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">选择充值套餐</h2>
- <span class="ml-auto text-xs text-[var(--c-8a95a6)]">点数长期有效</span>
- </div>
-
- <!-- 加载中 -->
- <div v-if="loading" class="grid grid-cols-2 gap-3 pt-4 sm:grid-cols-3">
- <div v-for="i in 3" :key="i" class="h-[124px] animate-pulse rounded-2xl bg-[var(--c-f1f5fa)]" />
- </div>
-
- <!-- 套餐网格 -->
- <div v-else-if="packages.length" class="grid grid-cols-2 gap-3 pt-4 sm:grid-cols-3">
- <button
- v-for="pkg in packages"
- :key="pkg.id"
- type="button"
- class="relative flex min-h-[124px] flex-col items-center justify-center gap-2 rounded-2xl border-2 p-4 transition-all"
- :class="current && current.id === pkg.id
- ? 'border-[var(--c-0b8cff)] bg-[var(--c-f4faff)] shadow-[0_0_0_4px_var(--sh-11-140-255-100)]'
- : 'border-transparent bg-[var(--c-f7f9fd)] hover:border-[var(--c-e5edf7)]'"
- @click="selectPackage(pkg)"
- >
- <span
- v-if="pkg.badge"
- 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)]"
- >{{ pkg.badge }}</span>
- <span class="mt-4 text-xl font-extrabold text-[var(--c-172033)]">{{ pkg.points }}点</span>
- <span class="text-sm font-extrabold text-[var(--c-0b8cff)]">¥{{ pkg.price_yuan }}</span>
- <span v-if="pkg.old_price_yuan" class="flex items-center gap-1.5 text-xs">
- <span class="text-[var(--c-a0aabb)] line-through">¥{{ pkg.old_price_yuan }}</span>
- <span
- v-if="pkg.bonus_percent"
- class="rounded-full bg-[var(--c-edfdf4)] px-1.5 py-0.5 font-extrabold text-[var(--c-159a54)]"
- >多送{{ pkg.bonus_percent }}%</span>
- </span>
- </button>
- </div>
-
- <!-- 空态 -->
- <div v-else class="py-14 text-center">
- <UIcon name="i-lucide-package-x" class="mx-auto mb-3 size-10 text-[var(--c-a0aabb)]" />
- <div class="text-sm text-[var(--c-8a95a6)]">暂无可用套餐</div>
- </div>
-
- <!-- 确认充值 -->
- <button
- type="button"
- 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"
- :disabled="!current || payState === 'loading'"
- @click="startPay"
- >
- {{ buyText }}
- </button>
- </div>
-
- <!-- 权益 -->
- <div class="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
- <div class="rounded-2xl bg-[var(--c-0b8cff)]/[0.07] p-4">
- <div class="text-sm font-extrabold text-[var(--c-0b8cff)]">点数长期有效</div>
- <div class="mt-1.5 text-xs leading-relaxed text-[var(--c-66758a)]">购买后可用于所有 AI 图像工具</div>
- </div>
- <div class="rounded-2xl bg-[var(--c-0b8cff)]/[0.07] p-4">
- <div class="text-sm font-extrabold text-[var(--c-0b8cff)]">多端通用</div>
- <div class="mt-1.5 text-xs leading-relaxed text-[var(--c-66758a)]">小程序、H5、PC 端共享点数</div>
- </div>
- </div>
-
- <!-- 购买须知 -->
- <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)]">
- <div class="mb-1 font-bold text-[var(--c-7f8896)]">购买须知:</div>
- <div>1、购买后长期有效,不支持退款;</div>
- <div>2、同一账号点数可在小程序、H5、PC端使用;</div>
- <div>3、点数适用于 AI 图片生成、高清修复、证件照、AI绘画等功能。</div>
- </div>
- </div>
-
- <!-- ===== 右:扫码支付(吸顶) ===== -->
- <div class="mt-5 lg:col-span-5 lg:mt-0">
- <div class="space-y-4 lg:sticky lg:top-20">
- <div class="rounded-2xl bg-[var(--c-ffffff)] p-6 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
- <!-- 未开始 -->
- <template v-if="payState === 'idle'">
- <UIcon name="i-lucide-qr-code" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" />
- <div class="mb-1 text-base font-bold text-[var(--c-172033)]">微信扫码支付</div>
- <div class="text-sm text-[var(--c-a0aabb)]">选择套餐后点击「确认充值」</div>
- <div class="text-sm text-[var(--c-a0aabb)]">这里会生成支付二维码</div>
- </template>
-
- <!-- 生成中 / 待支付 / 成功 -->
- <template v-else>
- <div class="mb-1 text-base font-bold text-[var(--c-172033)]">
- {{ payState === 'success' ? '充值成功' : '微信扫码支付' }}
- </div>
- <div class="mb-4 text-sm text-[var(--c-8a95a6)]">
- {{ payStateText }}
- </div>
-
- <div class="mx-auto mb-4 flex h-[200px] w-[200px] items-center justify-center rounded-2xl bg-[var(--c-f7f9fd)]">
- <div v-if="payState === 'loading'" class="flex flex-col items-center gap-2">
- <span class="size-7 animate-spin rounded-full border-2 border-[var(--c-0b8cff)] border-t-transparent" />
- <span class="text-xs text-[var(--c-a0aabb)]">生成二维码...</span>
- </div>
-
- <div v-else-if="payState === 'success'" class="flex flex-col items-center gap-2">
- <UIcon name="i-lucide-circle-check" class="size-14 text-[var(--c-17a65a)]" />
- <span class="text-sm font-bold text-[var(--c-17a65a)]">+{{ current?.points || 0 }} 点已到账</span>
- </div>
-
- <ClientOnly v-else-if="codeUrl">
- <vue-qr :text="codeUrl" :size="180" :margin="8" />
- </ClientOnly>
-
- <div v-else class="flex flex-col items-center gap-2">
- <UIcon name="i-lucide-triangle-alert" class="size-9 text-[var(--c-f04438)]" />
- <span class="text-xs text-[var(--c-f04438)]">生成失败,请重试</span>
- </div>
- </div>
-
- <div v-if="payState !== 'success'" class="text-xs text-[var(--c-a0aabb)]">
- 支付金额:¥{{ current?.price_yuan || '0.00' }}
- </div>
-
- <div class="mt-4 flex justify-center gap-3">
- <button
- v-if="payState !== 'success'"
- class="rounded-full px-4 py-2 text-xs text-[var(--c-8a95a6)] transition-colors hover:text-[var(--c-172033)]"
- @click="cancelPay"
- >
- 取消支付
- </button>
- <button
- v-else
- class="rounded-full bg-[var(--c-0b8cff)] px-6 py-2 text-xs font-bold text-white transition-colors hover:bg-[var(--c-0a7ce0)]"
- @click="finishPay"
- >
- 完成
- </button>
- </div>
- </template>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </template>
-
- <script setup>
- import VueQr from 'vue-qr'
-
- useSeoMeta({
- title: '点数充值 - AI在线',
- description: '选择套餐充值点数,点数长期有效、多端通用',
- })
-
- const { userInfo, isLogin, showLogin, refreshBalance } = useUser()
- const { post } = useApi()
- const toast = useToast()
-
- const packages = ref([])
- const current = ref(null)
- const loading = ref(true)
- const balance = reactive({ points: 0 })
-
- // idle | loading | pending | success | error
- const payState = ref('idle')
- const codeUrl = ref('')
- const orderNo = ref('')
- let pollingTimer = null
-
- const balanceText = computed(() => (Number(balance.points) < 0 ? '不限' : String(Number(balance.points) || 0)))
- const balanceUnit = computed(() => (balanceText.value === '不限' ? '' : '点'))
- const buyText = computed(() => {
- if (!current.value) return '请选择套餐'
- return `确认充值(${current.value.price_yuan}元)`
- })
- const payStateText = computed(() => {
- if (payState.value === 'loading') return '正在创建订单...'
- if (payState.value === 'pending') return '请使用微信扫描二维码完成支付'
- if (payState.value === 'success') return '点数已到账,可以开始创作了'
- return '订单创建失败'
- })
-
- function showMsg(msg, color = 'error') {
- toast.add({ title: msg, color })
- }
-
- // ==================== 套餐 & 余额 ====================
- async function loadPackages() {
- loading.value = true
- try {
- const res = await post('/point/packagelist')
- packages.value = res.list || []
- current.value = packages.value.find(p => p.badge === '推荐') || packages.value[0] || null
- } catch (e) {
- packages.value = []
- showMsg(e.message || '套餐加载失败')
- } finally {
- loading.value = false
- }
- }
-
- async function loadBalance() {
- if (!isLogin.value) return
- try {
- const res = await post('/point/balance')
- balance.points = res.data?.points || 0
- userInfo.value = { ...userInfo.value, balance: balance.points }
- } catch {
- // 忽略
- }
- }
-
- function selectPackage(pkg) {
- if (payState.value === 'pending' || payState.value === 'loading') cancelPay()
- current.value = pkg
- }
-
- // ==================== 支付(PC 走微信 NATIVE 扫码) ====================
- async function startPay() {
- if (!current.value) return
- if (!isLogin.value) {
- showLogin.value = true
- return
- }
-
- payState.value = 'loading'
- codeUrl.value = ''
- try {
- const res = await post('/point/createorder', {
- package_id: current.value.id,
- client: 1, // PC
- })
- const data = res.data || {}
- // NATIVE 支付返回二维码链接(兼容多种字段命名)
- const url = data.code_url || data.codeUrl || data.qr_code || ''
- orderNo.value = data.order_no || ''
- if (url) {
- codeUrl.value = url
- payState.value = 'pending'
- startPolling()
- } else {
- payState.value = 'error'
- showMsg(res.msg || 'PC 端支付暂不可用')
- }
- } catch (e) {
- payState.value = 'error'
- showMsg(e.message || '创建订单失败')
- }
- }
-
- // 轮询订单状态:服务端会主动查微信并流转订单 + 幂等入账
- function startPolling() {
- clearInterval(pollingTimer)
- pollingTimer = setInterval(async () => {
- if (!orderNo.value) return
- try {
- const res = await post('/point/orderstatus', { order_no: orderNo.value })
- const d = res.data || {}
- if (Number(d.status) === 20) {
- clearInterval(pollingTimer)
- payState.value = 'success'
- balance.points = d.balance ?? balance.points
- refreshBalance() // 同步 header 点数
- }
- } catch {
- // 单次失败不中断轮询
- }
- }, 2000)
- }
-
- function cancelPay() {
- clearInterval(pollingTimer)
- payState.value = 'idle'
- codeUrl.value = ''
- orderNo.value = ''
- }
-
- function finishPay() {
- cancelPay()
- loadBalance()
- }
-
- onMounted(() => {
- loadPackages()
- loadBalance()
- })
-
- watch(isLogin, async (loggedIn) => {
- if (!loggedIn) {
- cancelPay()
- balance.points = 0
- }
- await loadPackages()
- if (loggedIn) await loadBalance()
- })
-
- onUnmounted(() => clearInterval(pollingTimer))
- </script>
|