|
- <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="recharge-package relative flex min-h-[124px] flex-col items-center justify-center gap-2 rounded-2xl border-2 p-4 transition-all"
- :class="{ 'is-active': current && current.id === pkg.id, 'is-recommended': isRecommendedPackage(pkg) }"
- @click="selectPackage(pkg)"
- >
- <span
- v-if="pkg.badge"
- class="recharge-package-badge 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="recharge-package-points mt-4 text-xl font-extrabold text-[var(--c-172033)]">{{ pkg.points }}点</span>
- <span v-if="isRecommendedPackage(pkg)" class="recharge-package-copy">日常创作首选</span>
- <span class="recharge-package-price text-sm font-extrabold text-[var(--c-0b8cff)]">¥{{ pkg.price_yuan }}</span>
- <span v-if="pkg.old_price_yuan" class="recharge-package-offer flex items-center gap-1.5 text-xs">
- <span class="recharge-package-old-price text-[var(--c-a0aabb)] line-through">¥{{ pkg.old_price_yuan }}</span>
- <span
- v-if="pkg.bonus_percent"
- class="recharge-package-bonus 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="payIdleIcon" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" />
- <div class="mb-1 text-base font-bold text-[var(--c-172033)]">{{ payIdleTitle }}</div>
- <div class="text-sm text-[var(--c-a0aabb)]">{{ payIdleDesc1 }}</div>
- <div class="text-sm text-[var(--c-a0aabb)]">{{ payIdleDesc2 }}</div>
- </template>
-
- <!-- 生成中 / 待支付 / 成功 -->
- <template v-else>
- <div class="mb-1 text-base font-bold text-[var(--c-172033)]">
- {{ payPanelTitle }}
- </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>
-
- <div v-else-if="isMobileClient && payState === 'pending'" class="flex flex-col items-center gap-2">
- <UIcon name="i-lucide-smartphone" class="size-12 text-[var(--c-0b8cff)]" />
- <span class="text-xs font-bold text-[var(--c-0b8cff)]">等待支付完成...</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 { reportCv } = usePvcv()
- const toast = useToast()
- const route = useRoute()
- const { clientId, isMobileClient, isWechatClient } = useClientDevice()
- const wxPayOpenId = useCookie('wechat_pay_open_id', { maxAge: 30 * 24 * 3600, path: '/' })
- const WECHAT_MP_APPID = 'wxd2aa05b2249b16ce'
-
- 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 payIdleIcon = computed(() => (isMobileClient.value ? 'i-lucide-smartphone' : 'i-lucide-qr-code'))
- const payIdleTitle = computed(() => {
- if (!isMobileClient.value) return '微信扫码支付'
- return isWechatClient.value ? '微信内支付' : '手机微信支付'
- })
- const payIdleDesc1 = computed(() => {
- if (!isMobileClient.value) return '选择套餐后点击「确认充值」'
- return isWechatClient.value ? '选择套餐后将拉起微信支付' : '选择套餐后将跳转微信支付页'
- })
- const payIdleDesc2 = computed(() => {
- if (!isMobileClient.value) return '这里会生成支付二维码'
- return '支付完成后点数会自动到账'
- })
- const payPanelTitle = computed(() => {
- if (payState.value === 'success') return '充值成功'
- if (!isMobileClient.value) return '微信扫码支付'
- return isWechatClient.value ? '微信内支付' : '手机微信支付'
- })
- const payStateText = computed(() => {
- if (payState.value === 'loading') return isMobileClient.value ? '正在创建订单并准备拉起支付...' : '正在创建订单...'
- if (payState.value === 'pending') {
- if (!isMobileClient.value) return '请使用微信扫描二维码完成支付'
- return isWechatClient.value ? '请在微信支付弹窗中完成支付' : '请在打开的微信支付页面完成支付'
- }
- if (payState.value === 'success') return '点数已到账,可以开始创作了'
- return '订单创建失败'
- })
-
- function showMsg(msg, color = 'error') {
- toast.add({ title: msg, color })
- }
-
- function isRecommendedPackage(pkg = {}) {
- return String(pkg.badge || '').includes('推荐')
- }
-
- // ==================== 套餐 & 余额 ====================
- 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 扫码 / H5 微信支付) ====================
- function isInvalidOpenId(value) {
- return !value || value === 'undefined' || value === 'null'
- }
-
- function cleanOAuthQuery() {
- if (!import.meta.client) return
- const url = new URL(window.location.href)
- url.searchParams.delete('code')
- url.searchParams.delete('state')
- window.history.replaceState({}, document.title, url.toString())
- }
-
- function getWechatAuthUrl(packageId) {
- const url = new URL(window.location.href)
- url.searchParams.delete('code')
- url.searchParams.delete('state')
- const redirectUri = encodeURIComponent(url.toString())
- return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${WECHAT_MP_APPID}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base&state=pointbuy_${packageId}#wechat_redirect`
- }
-
- function getQueryValue(value) {
- return Array.isArray(value) ? value[0] : value
- }
-
- function invokeWechatPay(payInfo) {
- if (!import.meta.client) return
-
- const payload = {
- appId: payInfo.appId || payInfo.appid || WECHAT_MP_APPID,
- timeStamp: String(payInfo.timeStamp || payInfo.timestamp || ''),
- nonceStr: payInfo.nonceStr || payInfo.noncestr || '',
- package: payInfo.package || (payInfo.prepay_id ? `prepay_id=${payInfo.prepay_id}` : ''),
- signType: payInfo.signType || 'RSA',
- paySign: payInfo.paySign || payInfo.paysign || '',
- }
-
- const onReady = () => {
- window.WeixinJSBridge.invoke('getBrandWCPayRequest', payload, (res = {}) => {
- if (res.err_msg === 'get_brand_wcpay_request:ok') {
- pollOnce()
- } else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
- cancelPay()
- showMsg('支付已取消', 'warning')
- } else {
- payState.value = 'error'
- showMsg('支付失败,请重试')
- }
- })
- }
-
- if (typeof window.WeixinJSBridge === 'undefined') {
- document.addEventListener('WeixinJSBridgeReady', onReady, false)
- } else {
- onReady()
- }
- }
-
- async function handleWechatOAuthReturn() {
- if (!import.meta.client || !isMobileClient.value || !isWechatClient.value) return
- const code = getQueryValue(route.query.code)
- const state = String(getQueryValue(route.query.state) || '')
- if (!code || !state.startsWith('pointbuy_')) return
-
- try {
- const res = await post('/common/getopenid', { code, client: 2 })
- const openId = res.data?.wx_open_id || ''
- if (!openId) throw new Error('获取 openid 失败')
- wxPayOpenId.value = openId
- cleanOAuthQuery()
-
- const packageId = Number(state.replace('pointbuy_', ''))
- const target = packages.value.find(item => Number(item.id) === packageId)
- if (target) current.value = target
- if (current.value) await startPay({ skipOauth: true })
- } catch (e) {
- cleanOAuthQuery()
- showMsg(e.message || '微信支付身份获取失败,请重新点击充值')
- }
- }
-
- async function startPay(options = {}) {
- if (!current.value) return
- reportCv('point_recharge_click', { page_key: 'recharge' })
- if (!isLogin.value) {
- showLogin.value = true
- return
- }
-
- if (isMobileClient.value && isWechatClient.value && isInvalidOpenId(wxPayOpenId.value) && !options.skipOauth) {
- window.location.href = getWechatAuthUrl(current.value.id)
- return
- }
-
- payState.value = 'loading'
- codeUrl.value = ''
- try {
- const payload = {
- package_id: current.value.id,
- client: clientId.value,
- base_in_wechat: isWechatClient.value ? 1 : 0,
- }
- if (isMobileClient.value && isWechatClient.value) {
- payload.open_id = wxPayOpenId.value
- }
-
- const res = await post('/point/createorder', payload)
- const data = res.data || {}
- orderNo.value = data.order_no || ''
- reportCv('point_recharge_order', { page_key: 'recharge' })
-
- if (data.mode === 'wxpay_jsapi' || data.package || data.paySign) {
- payState.value = 'pending'
- startPolling()
- invokeWechatPay(data)
- return
- }
-
- const h5Url = data.h5_url || data.h5Url || ''
- if (data.mode === 'wxpay_h5' && h5Url) {
- payState.value = 'pending'
- startPolling()
- window.location.href = h5Url
- return
- }
-
- const url = data.code_url || data.codeUrl || data.qr_code || ''
- if (url) {
- codeUrl.value = url
- payState.value = 'pending'
- startPolling()
- } else {
- payState.value = 'error'
- showMsg(res.msg || '支付暂不可用')
- }
- } catch (e) {
- payState.value = 'error'
- showMsg(e.message || '创建订单失败')
- }
- }
-
- // 轮询订单状态:服务端会主动查微信并流转订单 + 幂等入账
- async function pollOnce() {
- 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()
- reportCv('point_recharge_paid', { page_key: 'recharge' })
- }
- } catch {
- // 单次失败不中断轮询
- }
- }
-
- function startPolling() {
- clearInterval(pollingTimer)
- pollingTimer = setInterval(async () => {
- await pollOnce()
- }, 2000)
- }
-
- function cancelPay() {
- clearInterval(pollingTimer)
- payState.value = 'idle'
- codeUrl.value = ''
- orderNo.value = ''
- }
-
- function finishPay() {
- cancelPay()
- loadBalance()
- }
-
- onMounted(async () => {
- await loadPackages()
- await loadBalance()
- await handleWechatOAuthReturn()
- })
-
- watch(isLogin, async (loggedIn) => {
- if (!loggedIn) {
- cancelPay()
- balance.points = 0
- }
- await loadPackages()
- if (loggedIn) await loadBalance()
- })
-
- onUnmounted(() => clearInterval(pollingTimer))
- </script>
-
- <style scoped>
- .recharge-package {
- overflow: hidden;
- border-color: transparent;
- background: var(--c-f7f9fd);
- box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
- transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
- }
-
- .recharge-package:hover {
- border-color: var(--c-e5edf7);
- }
-
- .recharge-package::before {
- content: '';
- position: absolute;
- top: -34px;
- right: -34px;
- width: 92px;
- height: 92px;
- border-radius: 999px;
- background: transparent;
- }
-
- .recharge-package::after {
- content: '';
- position: absolute;
- right: 14px;
- bottom: 10px;
- left: 14px;
- height: 2px;
- border-radius: 999px;
- background: transparent;
- }
-
- .recharge-package > * {
- position: relative;
- z-index: 1;
- }
-
- .recharge-package.is-recommended {
- border-color: rgba(246, 196, 83, 0.82);
- color: #fff;
- background:
- radial-gradient(circle at 82% 8%, rgba(255, 231, 163, 0.28), transparent 32%),
- radial-gradient(circle at 8% 92%, rgba(202, 138, 4, 0.18), transparent 36%),
- linear-gradient(160deg, #1c1917 0%, #111827 52%, #020617 100%);
- box-shadow:
- 0 18px 42px rgba(15, 23, 42, 0.26),
- 0 0 0 4px rgba(246, 196, 83, 0.12);
- }
-
- .recharge-package.is-recommended::before {
- background: radial-gradient(circle, rgba(255, 216, 106, 0.32), transparent 68%);
- }
-
- .recharge-package.is-active {
- border-color: var(--c-0b8cff);
- background: linear-gradient(180deg, #f4faff 0%, #ffffff 68%);
- box-shadow:
- 0 16px 34px rgba(11, 140, 255, 0.18),
- 0 0 0 4px var(--sh-11-140-255-100);
- }
-
- .recharge-package.is-recommended.is-active {
- background:
- radial-gradient(circle at 82% 8%, rgba(255, 231, 163, 0.38), transparent 32%),
- radial-gradient(circle at 8% 92%, rgba(202, 138, 4, 0.22), transparent 38%),
- linear-gradient(160deg, #27201a 0%, #111827 50%, #020617 100%);
- border-color: #f6c453;
- box-shadow:
- 0 20px 48px rgba(15, 23, 42, 0.32),
- 0 0 0 5px rgba(246, 196, 83, 0.16),
- inset 0 1px 0 rgba(255, 255, 255, 0.86);
- }
-
- .recharge-package.is-recommended.is-active::after {
- background: linear-gradient(90deg, transparent, rgba(255, 216, 106, 0.72), transparent);
- box-shadow: 0 0 10px rgba(255, 216, 106, 0.3);
- }
-
- .recharge-package.is-recommended .recharge-package-badge {
- color: #2a1803;
- background: linear-gradient(135deg, #fff1b8 0%, #f6c453 50%, #d89a22 100%);
- box-shadow: 0 6px 16px rgba(216, 154, 34, 0.32);
- }
-
- .recharge-package-copy {
- margin-top: -0.25rem;
- font-size: 11px;
- font-weight: 800;
- line-height: 1;
- color: #3b8bd8;
- }
-
- .recharge-package.is-recommended .recharge-package-points {
- color: #fff;
- text-shadow: 0 1px 5px rgba(0, 0, 0, 0.32);
- }
-
- .recharge-package.is-recommended .recharge-package-copy {
- color: rgba(255, 220, 138, 0.92);
- }
-
- .recharge-package.is-recommended .recharge-package-price {
- color: #ffd76a;
- text-shadow: 0 1px 6px rgba(216, 154, 34, 0.22);
- }
-
- .recharge-package.is-recommended .recharge-package-old-price {
- color: rgba(255, 255, 255, 0.45);
- }
-
- .recharge-package.is-recommended .recharge-package-bonus {
- color: #7a4a00;
- background: linear-gradient(135deg, rgba(255, 241, 184, 0.96), rgba(255, 215, 106, 0.96));
- }
- </style>
|