feat(payment): add complete payment system with multi-provider support
Add a full payment and subscription system supporting EasyPay (Alipay/WeChat), Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
This commit is contained in:
203
frontend/src/views/user/PaymentQRCodeView.vue
Normal file
203
frontend/src/views/user/PaymentQRCodeView.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="mx-auto flex max-w-md flex-col items-center space-y-6 py-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ qrUrl ? scanTitle : t('payment.qr.payInNewWindow') }}
|
||||
</h2>
|
||||
<div v-if="qrUrl" class="rounded-2xl bg-white p-6 shadow-lg dark:bg-dark-800">
|
||||
<canvas ref="qrCanvas" class="mx-auto"></canvas>
|
||||
</div>
|
||||
<!-- Scan prompt for QR code -->
|
||||
<p v-if="qrUrl && !expired && scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ scanHint }}
|
||||
</p>
|
||||
<div v-if="expired" class="text-center">
|
||||
<p class="text-lg font-medium text-red-500">{{ t('payment.qr.expired') }}</p>
|
||||
<button class="btn btn-primary mt-4" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ qrUrl ? t('payment.qr.expiresIn') : t('payment.qr.payInNewWindowHint') }}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
|
||||
<p class="mt-2 text-sm text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
|
||||
</div>
|
||||
<a v-if="payUrl && !qrUrl && !expired" :href="payUrl" target="_blank" rel="noopener noreferrer"
|
||||
class="btn btn-primary w-full py-3">
|
||||
{{ t('payment.qr.openPayWindow') }}
|
||||
</a>
|
||||
<!-- Cancel button -->
|
||||
<button v-if="!expired && orderId" class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
|
||||
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
||||
</button>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { useAppStore } from '@/stores'
|
||||
import QRCode from 'qrcode'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const paymentStore = usePaymentStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const qrUrl = ref('')
|
||||
const payUrl = ref('')
|
||||
const orderId = ref(0)
|
||||
const remainingSeconds = ref(0)
|
||||
const expired = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const paymentType = ref('')
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const countdownDisplay = computed(() => {
|
||||
const m = Math.floor(remainingSeconds.value / 60)
|
||||
const s = remainingSeconds.value % 60
|
||||
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
|
||||
})
|
||||
|
||||
const isAlipay = computed(() => paymentType.value.includes('alipay'))
|
||||
const isWxpay = computed(() => paymentType.value.includes('wxpay'))
|
||||
|
||||
const scanTitle = computed(() => {
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipay')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpay')
|
||||
return t('payment.qr.scanToPay')
|
||||
})
|
||||
|
||||
const scanHint = computed(() => {
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipayHint')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpayHint')
|
||||
return ''
|
||||
})
|
||||
|
||||
function getLogoForType(): string | null {
|
||||
if (isAlipay.value) return alipayIcon
|
||||
if (isWxpay.value) return wxpayIcon
|
||||
return null
|
||||
}
|
||||
|
||||
async function renderQR() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || !qrUrl.value) return
|
||||
|
||||
// Use high error correction to support logo overlay
|
||||
const logoSrc = getLogoForType()
|
||||
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: logoSrc ? 'H' : 'M',
|
||||
})
|
||||
|
||||
if (!logoSrc) return
|
||||
|
||||
// Draw logo in center of QR code
|
||||
const canvas = qrCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const img = new Image()
|
||||
img.src = logoSrc
|
||||
img.onload = () => {
|
||||
const logoSize = 48
|
||||
const x = (canvas.width - logoSize) / 2
|
||||
const y = (canvas.height - logoSize) / 2
|
||||
// White background with rounded corners
|
||||
const pad = 5
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.beginPath()
|
||||
const r = 6
|
||||
ctx.moveTo(x - pad + r, y - pad)
|
||||
ctx.arcTo(x + logoSize + pad, y - pad, x + logoSize + pad, y + logoSize + pad, r)
|
||||
ctx.arcTo(x + logoSize + pad, y + logoSize + pad, x - pad, y + logoSize + pad, r)
|
||||
ctx.arcTo(x - pad, y + logoSize + pad, x - pad, y - pad, r)
|
||||
ctx.arcTo(x - pad, y - pad, x + logoSize + pad, y - pad, r)
|
||||
ctx.fill()
|
||||
// Draw logo
|
||||
ctx.drawImage(img, x, y, logoSize, logoSize)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
if (!orderId.value) return
|
||||
const order = await paymentStore.pollOrderStatus(orderId.value)
|
||||
if (!order) return
|
||||
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
||||
cleanup()
|
||||
router.push({ path: '/payment/result', query: { order_id: String(orderId.value), status: 'success' } })
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
expired.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
remainingSeconds.value = Math.max(0, seconds)
|
||||
if (remainingSeconds.value <= 0) {
|
||||
expired.value = true
|
||||
return
|
||||
}
|
||||
countdownTimer = setInterval(() => {
|
||||
remainingSeconds.value--
|
||||
if (remainingSeconds.value <= 0) {
|
||||
expired.value = true
|
||||
cleanup()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!orderId.value || cancelling.value) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await paymentAPI.cancelOrder(orderId.value)
|
||||
cleanup()
|
||||
router.push('/purchase')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
|
||||
}
|
||||
|
||||
watch(qrUrl, () => renderQR())
|
||||
|
||||
onMounted(() => {
|
||||
orderId.value = Number(route.query.order_id) || 0
|
||||
qrUrl.value = String(route.query.qr || '')
|
||||
payUrl.value = String(route.query.pay_url || '')
|
||||
paymentType.value = String(route.query.payment_type || '')
|
||||
|
||||
// Calculate countdown from expiresAt
|
||||
const expiresAtStr = String(route.query.expires_at || '')
|
||||
let seconds = 30 * 60 // fallback: 30 minutes
|
||||
if (expiresAtStr) {
|
||||
const expiresAt = new Date(expiresAtStr)
|
||||
const now = new Date()
|
||||
seconds = Math.floor((expiresAt.getTime() - now.getTime()) / 1000)
|
||||
}
|
||||
startCountdown(seconds)
|
||||
pollTimer = setInterval(pollStatus, 3000)
|
||||
renderQR()
|
||||
})
|
||||
|
||||
onUnmounted(() => cleanup())
|
||||
</script>
|
||||
165
frontend/src/views/user/PaymentResultView.vue
Normal file
165
frontend/src/views/user/PaymentResultView.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-dark-900">
|
||||
<div class="w-full max-w-md space-y-6">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Status Icon -->
|
||||
<div class="text-center">
|
||||
<div v-if="isSuccess"
|
||||
class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<svg class="h-10 w-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else
|
||||
class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<svg class="h-10 w-10 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mt-4 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ isSuccess ? t('payment.result.success') : t('payment.result.failed') }}
|
||||
</h2>
|
||||
</div>
|
||||
<!-- Order Info -->
|
||||
<div v-if="order" class="rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800">
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">#{{ order.id }}</span>
|
||||
</div>
|
||||
<div v-if="order.out_trade_no" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ order.out_trade_no }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">¥{{ order.pay_amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ t('payment.methods.' + order.payment_type, order.payment_type) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</span>
|
||||
<OrderStatusBadge :status="order.status" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- EasyPay return info (when no order loaded) -->
|
||||
<div v-else-if="returnInfo" class="rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800">
|
||||
<div class="space-y-3 text-sm">
|
||||
<div v-if="returnInfo.outTradeNo" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ returnInfo.outTradeNo }}</span>
|
||||
</div>
|
||||
<div v-if="returnInfo.money" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">¥{{ returnInfo.money }}</span>
|
||||
</div>
|
||||
<div v-if="returnInfo.type" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ t('payment.methods.' + returnInfo.type, returnInfo.type) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button class="btn btn-secondary flex-1" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
<button class="btn btn-primary flex-1" @click="router.push('/orders')">{{ t('payment.result.viewOrders') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const paymentStore = usePaymentStore()
|
||||
|
||||
const order = ref<PaymentOrder | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
interface ReturnInfo {
|
||||
outTradeNo: string
|
||||
money: string
|
||||
type: string
|
||||
tradeStatus: string
|
||||
}
|
||||
const returnInfo = ref<ReturnInfo | null>(null)
|
||||
|
||||
const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING'])
|
||||
|
||||
const isSuccess = computed(() => {
|
||||
// Always prioritize actual order status from backend
|
||||
if (order.value) {
|
||||
return SUCCESS_STATUSES.has(order.value.status)
|
||||
}
|
||||
// Fallback only when order not loaded
|
||||
if (route.query.status === 'success') return true
|
||||
if (route.query.trade_status === 'TRADE_SUCCESS') return true
|
||||
return false
|
||||
})
|
||||
|
||||
/** Extract numeric order ID from out_trade_no like "sub2_46" → 46 */
|
||||
function parseOutTradeNo(outTradeNo: string): number {
|
||||
const match = outTradeNo.match(/_(\d+)$/)
|
||||
return match ? Number(match[1]) : 0
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Try order_id first (internal navigation from QRCode/Stripe pages)
|
||||
let orderId = Number(route.query.order_id) || 0
|
||||
const outTradeNo = String(route.query.out_trade_no || '')
|
||||
|
||||
// Fallback: EasyPay return URL with out_trade_no
|
||||
if (!orderId && outTradeNo) {
|
||||
orderId = parseOutTradeNo(outTradeNo)
|
||||
// Store return info for display when order lookup fails
|
||||
returnInfo.value = {
|
||||
outTradeNo,
|
||||
money: String(route.query.money || ''),
|
||||
type: String(route.query.type || ''),
|
||||
tradeStatus: String(route.query.trade_status || ''),
|
||||
}
|
||||
}
|
||||
|
||||
// Verify payment via public endpoint (works without login)
|
||||
if (outTradeNo) {
|
||||
try {
|
||||
const result = await paymentAPI.verifyOrderPublic(outTradeNo)
|
||||
order.value = result.data
|
||||
} catch (_err: unknown) {
|
||||
// Public verify failed, try authenticated endpoint if logged in
|
||||
try {
|
||||
const result = await paymentAPI.verifyOrder(outTradeNo)
|
||||
order.value = result.data
|
||||
} catch (_e: unknown) { /* fall through */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Normal order lookup by ID (if verify didn't load the order)
|
||||
if (!order.value && orderId) {
|
||||
try {
|
||||
order.value = await paymentStore.pollOrderStatus(orderId)
|
||||
} catch (_err: unknown) {
|
||||
// Order lookup failed, will show returnInfo fallback
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
616
frontend/src/views/user/PaymentView.vue
Normal file
616
frontend/src/views/user/PaymentView.vue
Normal file
@@ -0,0 +1,616 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Tab Switcher (hide during payment and subscription confirm) -->
|
||||
<div v-if="tabs.length > 1 && paymentPhase === 'select' && !selectedPlan" class="flex space-x-1 rounded-xl bg-gray-100 p-1 dark:bg-dark-800">
|
||||
<button v-for="tab in tabs" :key="tab.key"
|
||||
class="flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all"
|
||||
:class="activeTab === tab.key ? 'bg-white text-gray-900 shadow dark:bg-dark-700 dark:text-white' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
@click="activeTab = tab.key">{{ tab.label }}</button>
|
||||
</div>
|
||||
<!-- Payment in progress (shared by recharge and subscription) -->
|
||||
<template v-if="paymentPhase === 'paying'">
|
||||
<PaymentStatusPanel
|
||||
:order-id="paymentState.orderId"
|
||||
:qr-code="paymentState.qrCode"
|
||||
:expires-at="paymentState.expiresAt"
|
||||
:payment-type="paymentState.paymentType"
|
||||
:pay-url="paymentState.payUrl"
|
||||
:order-type="paymentState.orderType"
|
||||
@done="onPaymentDone"
|
||||
@success="onPaymentSuccess"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="paymentPhase === 'stripe'">
|
||||
<StripePaymentInline
|
||||
:order-id="paymentState.orderId"
|
||||
:client-secret="paymentState.clientSecret"
|
||||
:publishable-key="checkout.stripe_publishable_key"
|
||||
:pay-amount="paymentState.payAmount"
|
||||
@success="onPaymentSuccess"
|
||||
@done="onStripeDone"
|
||||
@back="resetPayment"
|
||||
@redirect="onStripeRedirect"
|
||||
/>
|
||||
</template>
|
||||
<!-- Tab content (select phase) -->
|
||||
<template v-else>
|
||||
<!-- Top-up Tab -->
|
||||
<template v-if="activeTab === 'recharge'">
|
||||
<!-- Recharge Account Card -->
|
||||
<div class="card p-5">
|
||||
<p class="text-xs font-medium text-gray-400 dark:text-gray-500">{{ t('payment.rechargeAccount') }}</p>
|
||||
<p class="mt-1 text-base font-semibold text-gray-900 dark:text-white">{{ user?.username || '' }}</p>
|
||||
<p class="mt-0.5 text-sm font-medium text-green-600 dark:text-green-400">{{ t('payment.currentBalance') }}: {{ user?.balance?.toFixed(2) || '0.00' }}</p>
|
||||
</div>
|
||||
<div v-if="enabledMethods.length === 0" class="card py-16 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.notAvailable') }}</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="card p-6">
|
||||
<AmountInput
|
||||
v-model="amount"
|
||||
:amounts="[10, 20, 50, 100, 200, 500, 1000, 2000, 5000]"
|
||||
:min="globalMinAmount"
|
||||
:max="globalMaxAmount"
|
||||
/>
|
||||
<p v-if="amountError" class="mt-2 text-xs text-amber-600 dark:text-amber-300">{{ amountError }}</p>
|
||||
</div>
|
||||
<div v-if="enabledMethods.length >= 1" class="card p-6">
|
||||
<PaymentMethodSelector
|
||||
:methods="methodOptions"
|
||||
:selected="selectedMethod"
|
||||
@select="selectedMethod = $event"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="feeRate > 0 && validAmount > 0" class="card p-6">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
|
||||
<span class="text-gray-900 dark:text-white">${{ validAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
|
||||
<span class="text-gray-900 dark:text-white">${{ feeAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">${{ totalAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
|
||||
<span v-if="submitting" class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
{{ t('common.processing') }}
|
||||
</span>
|
||||
<span v-else>{{ t('payment.createOrder') }} ${{ (feeRate > 0 && validAmount > 0 ? totalAmount : validAmount).toFixed(2) }}</span>
|
||||
</button>
|
||||
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<!-- Subscribe Tab -->
|
||||
<template v-else-if="activeTab === 'subscription'">
|
||||
<!-- Subscription confirm (inline, replaces plan list) -->
|
||||
<template v-if="selectedPlan">
|
||||
<div class="card p-5">
|
||||
<!-- Header: platform badge + plan name -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span :class="['rounded-md border px-2 py-0.5 text-xs font-medium', planBadgeClass]">
|
||||
{{ platformLabel(selectedPlan.group_platform || '') }}
|
||||
</span>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ selectedPlan.name }}</h3>
|
||||
</div>
|
||||
<!-- Price -->
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through dark:text-gray-500">
|
||||
${{ selectedPlan.original_price }}
|
||||
</span>
|
||||
<span :class="['text-3xl font-bold', planTextClass]">${{ selectedPlan.price }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">/ {{ planValiditySuffix }}</span>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<p v-if="selectedPlan.description" class="mt-2 text-sm leading-relaxed text-gray-500 dark:text-gray-400">
|
||||
{{ selectedPlan.description }}
|
||||
</p>
|
||||
<!-- Rate + Limits grid -->
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.rate') }}</span>
|
||||
<div class="flex items-baseline">
|
||||
<span :class="['text-lg font-bold', planTextClass]">×{{ selectedPlan.rate_multiplier ?? 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedPlan.daily_limit_usd != null">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.dailyLimit') }}</span>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.daily_limit_usd }}</div>
|
||||
</div>
|
||||
<div v-if="selectedPlan.weekly_limit_usd != null">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.weeklyLimit') }}</span>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.weekly_limit_usd }}</div>
|
||||
</div>
|
||||
<div v-if="selectedPlan.monthly_limit_usd != null">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.monthlyLimit') }}</span>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.monthly_limit_usd }}</div>
|
||||
</div>
|
||||
<div v-if="selectedPlan.daily_limit_usd == null && selectedPlan.weekly_limit_usd == null && selectedPlan.monthly_limit_usd == null">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.quota') }}</span>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">{{ t('payment.planCard.unlimited') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="enabledMethods.length >= 1" class="card p-6">
|
||||
<PaymentMethodSelector
|
||||
:methods="subMethodOptions"
|
||||
:selected="selectedMethod"
|
||||
@select="selectedMethod = $event"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="feeRate > 0 && selectedPlan.price > 0" class="card p-6">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
|
||||
<span class="text-gray-900 dark:text-white">${{ selectedPlan.price.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
|
||||
<span class="text-gray-900 dark:text-white">${{ subFeeAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">${{ subTotalAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmitSubscription || submitting" @click="confirmSubscribe">
|
||||
<span v-if="submitting" class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
{{ t('common.processing') }}
|
||||
</span>
|
||||
<span v-else>{{ t('payment.createOrder') }} ${{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }}</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary w-full" @click="selectedPlan = null">{{ t('common.cancel') }}</button>
|
||||
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Plan list -->
|
||||
<template v-else>
|
||||
<div v-if="checkout.plans.length === 0" class="card py-16 text-center">
|
||||
<Icon name="gift" size="xl" class="mx-auto mb-3 text-gray-300 dark:text-dark-600" />
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.noPlans') }}</p>
|
||||
</div>
|
||||
<div v-else :class="planGridClass">
|
||||
<SubscriptionPlanCard v-for="plan in checkout.plans" :key="plan.id" :plan="plan" :active-subscriptions="activeSubscriptions" @select="selectPlan" />
|
||||
</div>
|
||||
<!-- Active subscriptions (compact, below plan list) -->
|
||||
<div v-if="activeSubscriptions.length > 0">
|
||||
<p class="mb-2 text-xs font-medium text-gray-400 dark:text-gray-500">{{ t('payment.activeSubscription') }}</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="sub in activeSubscriptions" :key="sub.id"
|
||||
class="flex items-center gap-3 rounded-xl border border-gray-100 bg-white px-3 py-2 dark:border-dark-700 dark:bg-dark-800">
|
||||
<div :class="['h-6 w-1 shrink-0 rounded-full', platformAccentBarClass(sub.group?.platform || '')]" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="truncate text-xs font-semibold text-gray-900 dark:text-white">{{ sub.group?.name || `Group #${sub.group_id}` }}</span>
|
||||
<span :class="['shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-medium', platformBadgeLightClass(sub.group?.platform || '')]">{{ platformLabel(sub.group?.platform || '') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-3 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
<span>{{ t('payment.planCard.rate') }}: ×{{ sub.group?.rate_multiplier ?? 1 }}</span>
|
||||
<span v-if="sub.group?.daily_limit_usd == null && sub.group?.weekly_limit_usd == null && sub.group?.monthly_limit_usd == null">{{ t('payment.planCard.quota') }}: {{ t('payment.planCard.unlimited') }}</span>
|
||||
<span v-if="sub.expires_at">{{ t('userSubscriptions.daysRemaining', { days: getDaysRemaining(sub.expires_at) }) }}</span>
|
||||
<span v-else>{{ t('userSubscriptions.noExpiration') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge badge-success shrink-0 text-[10px]">{{ t('userSubscriptions.status.active') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<div v-if="(checkout.help_text || checkout.help_image_url) && paymentPhase === 'select' && !selectedPlan" class="card p-4">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<img v-if="checkout.help_image_url" :src="checkout.help_image_url" alt=""
|
||||
class="h-40 max-w-full cursor-pointer rounded-lg object-contain transition-opacity hover:opacity-80"
|
||||
@click="previewImage = checkout.help_image_url" />
|
||||
<p v-if="checkout.help_text" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ checkout.help_text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Renewal Plan Selection Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="showRenewalModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeRenewalModal">
|
||||
<div class="relative w-full max-w-lg rounded-2xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-dark-700 dark:bg-dark-900">
|
||||
<!-- Close button -->
|
||||
<button class="absolute right-4 top-4 rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-gray-200" @click="closeRenewalModal">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.selectPlan') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<SubscriptionPlanCard v-for="plan in renewalPlans" :key="plan.id" :plan="plan" :active-subscriptions="activeSubscriptions" @select="selectPlanFromModal" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<!-- Image Preview Overlay -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="previewImage" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm" @click="previewImage = ''">
|
||||
<img :src="previewImage" alt="" class="max-h-[85vh] max-w-[90vw] rounded-xl object-contain shadow-2xl" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useSubscriptionStore } from '@/stores/subscriptions'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import type { SubscriptionPlan, CheckoutInfoResponse } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import AmountInput from '@/components/payment/AmountInput.vue'
|
||||
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
|
||||
import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||
import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, platformTextClass, platformLabel } from '@/utils/platformColors'
|
||||
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
|
||||
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
|
||||
import StripePaymentInline from '@/components/payment/StripePaymentInline.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const paymentStore = usePaymentStore()
|
||||
const subscriptionStore = useSubscriptionStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const activeSubscriptions = computed(() => subscriptionStore.activeSubscriptions)
|
||||
|
||||
function getDaysRemaining(expiresAt: string): number {
|
||||
const diff = new Date(expiresAt).getTime() - Date.now()
|
||||
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)))
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const activeTab = ref<'recharge' | 'subscription'>('recharge')
|
||||
const amount = ref<number | null>(null)
|
||||
const selectedMethod = ref('')
|
||||
const selectedPlan = ref<SubscriptionPlan | null>(null)
|
||||
const previewImage = ref('')
|
||||
|
||||
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
|
||||
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select')
|
||||
const paymentState = ref({ orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' })
|
||||
|
||||
function resetPayment() {
|
||||
paymentPhase.value = 'select'
|
||||
paymentState.value = { orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }
|
||||
}
|
||||
|
||||
function onPaymentDone() {
|
||||
const wasSubscription = paymentState.value.orderType === 'subscription'
|
||||
resetPayment()
|
||||
selectedPlan.value = null
|
||||
if (wasSubscription) {
|
||||
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function onPaymentSuccess() {
|
||||
authStore.refreshUser()
|
||||
if (paymentState.value.orderType === 'subscription') {
|
||||
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function onStripeDone() {
|
||||
const wasSubscription = paymentState.value.orderType === 'subscription'
|
||||
resetPayment()
|
||||
selectedPlan.value = null
|
||||
if (wasSubscription) {
|
||||
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function onStripeRedirect(orderId: number, payUrl: string) {
|
||||
paymentState.value = { ...paymentState.value, orderId, payUrl, qrCode: '' }
|
||||
paymentPhase.value = 'paying'
|
||||
}
|
||||
|
||||
// All checkout data from single API call
|
||||
const checkout = ref<CheckoutInfoResponse>({
|
||||
methods: {}, global_min: 0, global_max: 0,
|
||||
plans: [], balance_disabled: false, help_text: '', help_image_url: '', stripe_publishable_key: '',
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
const result: { key: 'recharge' | 'subscription'; label: string }[] = []
|
||||
if (!checkout.value.balance_disabled) result.push({ key: 'recharge', label: t('payment.tabTopUp') })
|
||||
result.push({ key: 'subscription', label: t('payment.tabSubscribe') })
|
||||
return result
|
||||
})
|
||||
|
||||
const enabledMethods = computed(() => Object.keys(checkout.value.methods))
|
||||
const validAmount = computed(() => amount.value ?? 0)
|
||||
|
||||
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
|
||||
const planGridClass = computed(() => {
|
||||
const n = checkout.value.plans.length
|
||||
if (n <= 2) return 'grid grid-cols-1 gap-5 sm:grid-cols-2'
|
||||
return 'grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3'
|
||||
})
|
||||
|
||||
// Check if an amount fits a method's [min, max]. 0 = no limit.
|
||||
function amountFitsMethod(amt: number, methodType: string): boolean {
|
||||
if (amt <= 0) return true
|
||||
const ml = checkout.value.methods[methodType]
|
||||
if (!ml) return false
|
||||
if (ml.single_min > 0 && amt < ml.single_min) return false
|
||||
if (ml.single_max > 0 && amt > ml.single_max) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Global range for AmountInput (union of all methods, precomputed by backend)
|
||||
const globalMinAmount = computed(() => checkout.value.global_min)
|
||||
const globalMaxAmount = computed(() => checkout.value.global_max)
|
||||
|
||||
// Selected method's limits (for validation and error messages)
|
||||
const selectedLimit = computed(() => checkout.value.methods[selectedMethod.value])
|
||||
|
||||
const methodOptions = computed<PaymentMethodOption[]>(() =>
|
||||
enabledMethods.value.map((type) => {
|
||||
const ml = checkout.value.methods[type]
|
||||
return {
|
||||
type,
|
||||
fee_rate: ml?.fee_rate ?? 0,
|
||||
available: ml?.available !== false && amountFitsMethod(validAmount.value, type),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const feeRate = computed(() => selectedLimit.value?.fee_rate ?? 0)
|
||||
const feeAmount = computed(() =>
|
||||
feeRate.value > 0 && validAmount.value > 0
|
||||
? Math.ceil(((validAmount.value * feeRate.value) / 100) * 100) / 100
|
||||
: 0
|
||||
)
|
||||
const totalAmount = computed(() =>
|
||||
feeRate.value > 0 && validAmount.value > 0
|
||||
? Math.round((validAmount.value + feeAmount.value) * 100) / 100
|
||||
: validAmount.value
|
||||
)
|
||||
|
||||
const amountError = computed(() => {
|
||||
if (validAmount.value <= 0) return ''
|
||||
// No method can handle this amount
|
||||
if (!enabledMethods.value.some((m) => amountFitsMethod(validAmount.value, m))) {
|
||||
return t('payment.amountNoMethod')
|
||||
}
|
||||
// Selected method can't handle this amount (but others can)
|
||||
const ml = selectedLimit.value
|
||||
if (ml) {
|
||||
if (ml.single_min > 0 && validAmount.value < ml.single_min) return t('payment.amountTooLow', { min: ml.single_min })
|
||||
if (ml.single_max > 0 && validAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: ml.single_max })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
validAmount.value > 0
|
||||
&& amountFitsMethod(validAmount.value, selectedMethod.value)
|
||||
&& selectedLimit.value?.available !== false
|
||||
)
|
||||
|
||||
// Subscription-specific: method options based on plan price
|
||||
const subMethodOptions = computed<PaymentMethodOption[]>(() => {
|
||||
const planPrice = selectedPlan.value?.price ?? 0
|
||||
return enabledMethods.value.map((type) => {
|
||||
const ml = checkout.value.methods[type]
|
||||
return {
|
||||
type,
|
||||
fee_rate: ml?.fee_rate ?? 0,
|
||||
available: ml?.available !== false && amountFitsMethod(planPrice, type),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const subFeeAmount = computed(() => {
|
||||
const price = selectedPlan.value?.price ?? 0
|
||||
if (feeRate.value <= 0 || price <= 0) return 0
|
||||
return Math.ceil(((price * feeRate.value) / 100) * 100) / 100
|
||||
})
|
||||
|
||||
const subTotalAmount = computed(() => {
|
||||
const price = selectedPlan.value?.price ?? 0
|
||||
if (feeRate.value <= 0 || price <= 0) return price
|
||||
return Math.round((price + subFeeAmount.value) * 100) / 100
|
||||
})
|
||||
|
||||
const canSubmitSubscription = computed(() =>
|
||||
selectedPlan.value !== null
|
||||
&& amountFitsMethod(selectedPlan.value.price, selectedMethod.value)
|
||||
&& selectedLimit.value?.available !== false
|
||||
)
|
||||
|
||||
// Auto-switch to first available method when current selection can't handle the amount
|
||||
watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method]) => {
|
||||
if (amt <= 0 || amountFitsMethod(amt, method)) return
|
||||
const available = enabledMethods.value.find((m) => amountFitsMethod(amt, m))
|
||||
if (available) selectedMethod.value = available
|
||||
})
|
||||
|
||||
// Payment button class: follows selected payment method color
|
||||
const paymentButtonClass = computed(() => {
|
||||
const m = selectedMethod.value
|
||||
if (!m) return 'btn-primary'
|
||||
if (m.includes('alipay')) return 'btn-alipay'
|
||||
if (m.includes('wxpay')) return 'btn-wxpay'
|
||||
if (m === 'stripe') return 'btn-stripe'
|
||||
return 'btn-primary'
|
||||
})
|
||||
|
||||
// Subscription confirm: platform accent colors (clean card, no gradient)
|
||||
const planBadgeClass = computed(() => platformBadgeClass(selectedPlan.value?.group_platform || ''))
|
||||
const planTextClass = computed(() => platformTextClass(selectedPlan.value?.group_platform || ''))
|
||||
|
||||
// Renewal modal state
|
||||
const showRenewalModal = ref(false)
|
||||
const renewGroupId = ref<number | null>(null)
|
||||
const renewalPlans = computed(() => {
|
||||
if (renewGroupId.value == null) return []
|
||||
return checkout.value.plans.filter(p => p.group_id === renewGroupId.value)
|
||||
})
|
||||
|
||||
const planValiditySuffix = computed(() => {
|
||||
if (!selectedPlan.value) return ''
|
||||
const u = selectedPlan.value.validity_unit || 'day'
|
||||
if (u === 'month') return t('payment.perMonth')
|
||||
if (u === 'year') return t('payment.perYear')
|
||||
return `${selectedPlan.value.validity_days}${t('payment.days')}`
|
||||
})
|
||||
|
||||
function selectPlan(plan: SubscriptionPlan) {
|
||||
selectedPlan.value = plan
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
function selectPlanFromModal(plan: SubscriptionPlan) {
|
||||
showRenewalModal.value = false
|
||||
renewGroupId.value = null
|
||||
selectedPlan.value = plan
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
function closeRenewalModal() {
|
||||
showRenewalModal.value = false
|
||||
renewGroupId.value = null
|
||||
}
|
||||
|
||||
async function handleSubmitRecharge() {
|
||||
if (!canSubmit.value || submitting.value) return
|
||||
await createOrder(validAmount.value, 'balance')
|
||||
}
|
||||
|
||||
async function confirmSubscribe() {
|
||||
if (!selectedPlan.value || submitting.value) return
|
||||
await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id)
|
||||
}
|
||||
|
||||
async function createOrder(orderAmount: number, orderType: string, planId?: number) {
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = await paymentStore.createOrder({
|
||||
amount: orderAmount,
|
||||
payment_type: selectedMethod.value,
|
||||
order_type: orderType,
|
||||
plan_id: planId,
|
||||
})
|
||||
const openWindow = (url: string) => {
|
||||
window.open(url, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
}
|
||||
if (result.client_secret) {
|
||||
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: '', expiresAt: '',
|
||||
paymentType: selectedMethod.value, payUrl: '',
|
||||
clientSecret: result.client_secret, payAmount: result.pay_amount,
|
||||
orderType,
|
||||
}
|
||||
paymentPhase.value = 'stripe'
|
||||
} else if (result.qr_code) {
|
||||
// QR mode: show QR code inline
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: result.qr_code,
|
||||
expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '',
|
||||
clientSecret: '', payAmount: 0,
|
||||
orderType,
|
||||
}
|
||||
paymentPhase.value = 'paying'
|
||||
} else if (result.pay_url) {
|
||||
// Redirect/popup mode: open payment URL, show waiting state inline
|
||||
openWindow(result.pay_url)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
|
||||
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
||||
clientSecret: '', payAmount: 0,
|
||||
orderType,
|
||||
}
|
||||
paymentPhase.value = 'paying'
|
||||
} else {
|
||||
errorMessage.value = t('payment.result.failed')
|
||||
appStore.showError(errorMessage.value)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as Record<string, unknown>
|
||||
if (apiErr.reason === 'TOO_MANY_PENDING') {
|
||||
const metadata = apiErr.metadata as Record<string, unknown> | undefined
|
||||
errorMessage.value = t('payment.errors.tooManyPending', { max: metadata?.max || '' })
|
||||
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
|
||||
errorMessage.value = t('payment.errors.cancelRateLimited')
|
||||
} else {
|
||||
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
}
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await paymentAPI.getCheckoutInfo()
|
||||
checkout.value = res.data
|
||||
if (enabledMethods.value.length) {
|
||||
const order: readonly string[] = METHOD_ORDER
|
||||
const sorted = [...enabledMethods.value].sort((a, b) => {
|
||||
const ai = order.indexOf(a)
|
||||
const bi = order.indexOf(b)
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
||||
})
|
||||
selectedMethod.value = sorted[0]
|
||||
}
|
||||
if (checkout.value.balance_disabled) {
|
||||
activeTab.value = 'subscription'
|
||||
}
|
||||
// Handle renewal navigation: ?tab=subscription&group=123
|
||||
if (route.query.tab === 'subscription') {
|
||||
activeTab.value = 'subscription'
|
||||
if (route.query.group) {
|
||||
const groupId = Number(route.query.group)
|
||||
const groupPlans = checkout.value.plans.filter(p => p.group_id === groupId)
|
||||
if (groupPlans.length === 1) {
|
||||
selectedPlan.value = groupPlans[0]
|
||||
} else if (groupPlans.length > 1) {
|
||||
renewGroupId.value = groupId
|
||||
showRenewalModal.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
finally { loading.value = false }
|
||||
// Fetch active subscriptions (uses cache, non-blocking)
|
||||
subscriptionStore.fetchActiveSubscriptions().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
287
frontend/src/views/user/StripePaymentView.vue
Normal file
287
frontend/src/views/user/StripePaymentView.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<component :is="isPopup ? 'div' : AppLayout" :class="isPopup ? 'min-h-screen bg-gray-50 dark:bg-dark-900' : ''">
|
||||
<div class="mx-auto max-w-lg space-y-6 py-8" :class="isPopup ? 'px-4' : ''">
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<div v-else-if="initError" class="card p-8 text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<Icon name="exclamationCircle" size="xl" class="text-red-500" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.stripeLoadFailed') }}</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ initError }}</p>
|
||||
<button class="btn btn-primary mt-6" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Amount header -->
|
||||
<div v-if="order" class="card overflow-hidden">
|
||||
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-6 text-center">
|
||||
<p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p>
|
||||
<p class="mt-1 text-3xl font-bold text-white">¥{{ order.pay_amount.toFixed(2) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WeChat QR Code display -->
|
||||
<template v-if="wechatQrUrl">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.qr.scanWxpay') }}</p>
|
||||
<div class="relative rounded-lg border-2 border-[#2BB741] bg-green-50 p-4 dark:border-[#2BB741]/70 dark:bg-green-950/20">
|
||||
<img :src="wechatQrUrl" alt="WeChat Pay QR" class="h-56 w-56 rounded" />
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span class="rounded-full bg-[#2BB741] p-2 shadow ring-2 ring-white">
|
||||
<svg class="h-5 w-5 text-white" viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.636 4.35c-2.084 0-3.993.672-5.363 1.844-1.188.982-2.004 2.308-2.004 3.862 0 1.207.546 2.355 1.483 3.285.114.113.238.213.358.321l-.105.42c-.021.084-.042.17-.042.253 0 .168.126.258.282.258.065 0 .126-.025.18-.058l1.27-.765a.69.69 0 0 1 .58-.086c.96.282 1.99.437 3.043.437 2.633 0 5.03-.972 6.4-2.5.782-.87 1.258-1.901 1.258-3.006 0-3.328-3.325-6.006-7.34-6.006zm-3.21 3.09c.52 0 .94.429.94.957a.949.949 0 0 1-.94.955.949.949 0 0 1-.94-.955c0-.528.42-.957.94-.957zm4.739 0c.52 0 .94.429.94.957a.949.949 0 0 1-.94.955.949.949 0 0 1-.94-.955c0-.528.42-.957.94-.957z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.scanWxpayHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.waitingPayment') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Alipay redirecting state -->
|
||||
<template v-else-if="redirecting">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-[#00AEEF] border-t-transparent"></div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success state -->
|
||||
<template v-else-if="stripeSuccess">
|
||||
<div class="card p-6 text-center">
|
||||
<div class="flex flex-col items-center gap-3 py-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Icon name="check" size="lg" class="text-green-500" />
|
||||
</div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.result.success') }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.stripeSuccessProcessing') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Fallback: full Payment Element (no method param or unknown method) -->
|
||||
<template v-else-if="showPaymentElement">
|
||||
<div class="card p-6">
|
||||
<div id="stripe-payment-element" class="min-h-[200px]"></div>
|
||||
<p v-if="stripeError" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ stripeError }}</p>
|
||||
<button class="btn btn-stripe mt-6 w-full py-3 text-base" :disabled="stripeSubmitting || !stripeReady" @click="handleGenericPay">
|
||||
<span v-if="stripeSubmitting" class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
{{ t('common.processing') }}
|
||||
</span>
|
||||
<span v-else>{{ t('payment.stripePay') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button class="btn btn-secondary" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="stripeError && !showPaymentElement" class="card p-4">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ stripeError }}</p>
|
||||
<button class="btn btn-secondary mt-3 w-full" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const paymentStore = usePaymentStore()
|
||||
|
||||
// Popup mode: skip AppLayout when opened with a specific method (alipay/wechat_pay)
|
||||
const isPopup = computed(() => !!route.query.method)
|
||||
|
||||
const loading = ref(true)
|
||||
const initError = ref('')
|
||||
const stripeError = ref('')
|
||||
const stripeSubmitting = ref(false)
|
||||
const stripeSuccess = ref(false)
|
||||
const stripeReady = ref(false)
|
||||
const order = ref<PaymentOrder | null>(null)
|
||||
const wechatQrUrl = ref('')
|
||||
const redirecting = ref(false)
|
||||
const showPaymentElement = ref(false)
|
||||
|
||||
let stripeInstance: Stripe | null = null
|
||||
let elementsInstance: StripeElements | null = null
|
||||
let redirectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
const orderId = Number(route.query.order_id)
|
||||
const clientSecret = String(route.query.client_secret || '')
|
||||
const method = String(route.query.method || '')
|
||||
|
||||
if (!orderId || !clientSecret) {
|
||||
loading.value = false
|
||||
initError.value = t('payment.stripeMissingParams')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await paymentAPI.getOrder(orderId)
|
||||
order.value = res.data
|
||||
|
||||
await paymentStore.fetchConfig()
|
||||
const publishableKey = paymentStore.config?.stripe_publishable_key
|
||||
if (!publishableKey) { initError.value = t('payment.stripeNotConfigured'); return }
|
||||
|
||||
const { loadStripe } = await import('@stripe/stripe-js')
|
||||
const stripe = await loadStripe(publishableKey)
|
||||
if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return }
|
||||
|
||||
stripeInstance = stripe
|
||||
loading.value = false
|
||||
|
||||
// Direct confirm for specific methods (no Payment Element needed)
|
||||
if (method === 'alipay') {
|
||||
await confirmAlipay(stripe, clientSecret, orderId)
|
||||
} else if (method === 'wechat_pay') {
|
||||
await confirmWechatPay(stripe, clientSecret)
|
||||
} else {
|
||||
// Fallback: render full Payment Element
|
||||
showPaymentElement.value = true
|
||||
await nextTick()
|
||||
mountPaymentElement(stripe, clientSecret)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (redirectTimer) clearTimeout(redirectTimer)
|
||||
})
|
||||
|
||||
async function confirmAlipay(stripe: Stripe, clientSecret: string, orderId: number) {
|
||||
redirecting.value = true
|
||||
const returnUrl = window.location.origin + '/payment/result?order_id=' + orderId + '&status=success'
|
||||
const { error } = await stripe.confirmAlipayPayment(clientSecret, { return_url: returnUrl })
|
||||
if (error) {
|
||||
redirecting.value = false
|
||||
stripeError.value = error.message || t('payment.result.failed')
|
||||
}
|
||||
// If no error, Stripe redirects automatically — nothing else to do
|
||||
}
|
||||
|
||||
async function confirmWechatPay(stripe: Stripe, clientSecret: string) {
|
||||
const { paymentIntent, error } = await (stripe as Stripe & {
|
||||
confirmWechatPayPayment: (cs: string, opts: Record<string, unknown>) => Promise<{ paymentIntent?: { status: string; next_action?: { wechat_pay_display_qr_code?: { image_data_url?: string } } }; error?: { message?: string } }>
|
||||
}).confirmWechatPayPayment(clientSecret, {
|
||||
payment_method_options: { wechat_pay: { client: 'web' } },
|
||||
})
|
||||
|
||||
if (error) {
|
||||
stripeError.value = error.message || t('payment.result.failed')
|
||||
return
|
||||
}
|
||||
|
||||
// Extract QR code image from next_action
|
||||
const qrData = paymentIntent?.next_action?.wechat_pay_display_qr_code?.image_data_url
|
||||
if (qrData) {
|
||||
wechatQrUrl.value = qrData
|
||||
// Poll for completion
|
||||
startPolling()
|
||||
} else if (paymentIntent?.status === 'succeeded') {
|
||||
stripeSuccess.value = true
|
||||
scheduleClose()
|
||||
} else {
|
||||
stripeError.value = t('payment.result.failed')
|
||||
}
|
||||
}
|
||||
|
||||
function mountPaymentElement(stripe: Stripe, clientSecret: string) {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
const elements = stripe.elements({
|
||||
clientSecret,
|
||||
appearance: { theme: isDark ? 'night' : 'stripe', variables: { borderRadius: '8px' } },
|
||||
})
|
||||
elementsInstance = elements
|
||||
const paymentElement = elements.create('payment', {
|
||||
layout: 'tabs',
|
||||
paymentMethodOrder: ['alipay', 'wechat_pay', 'card', 'link'],
|
||||
} as Record<string, unknown>)
|
||||
paymentElement.mount('#stripe-payment-element')
|
||||
paymentElement.on('ready', () => { stripeReady.value = true })
|
||||
}
|
||||
|
||||
async function handleGenericPay() {
|
||||
if (!stripeInstance || !elementsInstance || stripeSubmitting.value) return
|
||||
stripeSubmitting.value = true
|
||||
stripeError.value = ''
|
||||
try {
|
||||
const { error } = await stripeInstance.confirmPayment({
|
||||
elements: elementsInstance,
|
||||
confirmParams: {
|
||||
return_url: window.location.origin + '/payment/result?order_id=' + route.query.order_id + '&status=success',
|
||||
},
|
||||
redirect: 'if_required',
|
||||
})
|
||||
if (error) {
|
||||
stripeError.value = error.message || t('payment.result.failed')
|
||||
} else {
|
||||
stripeSuccess.value = true
|
||||
scheduleClose()
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
stripeError.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
} finally {
|
||||
stripeSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startPolling() {
|
||||
const orderId = Number(route.query.order_id)
|
||||
if (!orderId) return
|
||||
pollTimer = setInterval(async () => {
|
||||
const o = await paymentStore.pollOrderStatus(orderId)
|
||||
if (!o) return
|
||||
if (o.status === 'COMPLETED' || o.status === 'PAID') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
stripeSuccess.value = true
|
||||
wechatQrUrl.value = ''
|
||||
scheduleClose()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function scheduleClose() {
|
||||
if (window.opener) {
|
||||
redirectTimer = setTimeout(() => { window.close() }, 2000)
|
||||
} else {
|
||||
redirectTimer = setTimeout(() => {
|
||||
router.push({ path: '/payment/result', query: { order_id: String(route.query.order_id || ''), status: 'success' } })
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (redirectTimer) clearTimeout(redirectTimer)
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
164
frontend/src/views/user/StripePopupView.vue
Normal file
164
frontend/src/views/user/StripePopupView.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-slate-50 p-4 dark:bg-slate-950">
|
||||
<div
|
||||
class="w-full max-w-md space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<!-- Amount + Order ID -->
|
||||
<div v-if="amount" class="text-center">
|
||||
<p class="text-3xl font-bold" :style="{ color: methodColor }">¥{{ amount }}</p>
|
||||
<p v-if="orderId" class="mt-1 text-sm text-gray-500 dark:text-slate-400">
|
||||
{{ t('payment.orders.orderId') }}: {{ orderId }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="space-y-3">
|
||||
<div
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
<button
|
||||
class="w-full text-sm underline dark:text-blue-400 dark:hover:text-blue-300"
|
||||
:style="{ color: methodColor }"
|
||||
@click="closeWindow"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Success -->
|
||||
<div v-else-if="success" class="space-y-3 py-4 text-center">
|
||||
<div class="text-5xl text-green-600 dark:text-green-400">✓</div>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">{{ t('payment.result.success') }}</p>
|
||||
<button
|
||||
class="text-sm underline dark:text-blue-400 dark:hover:text-blue-300"
|
||||
:style="{ color: methodColor }"
|
||||
@click="closeWindow"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading / Redirecting -->
|
||||
<div v-else class="flex items-center justify-center py-8">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
|
||||
:style="{ borderColor: methodColor, borderTopColor: 'transparent' }"
|
||||
/>
|
||||
<span class="ml-3 text-sm text-gray-500 dark:text-slate-400">{{ hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
alipay: '#00AEEF',
|
||||
wechat_pay: '#07C160',
|
||||
}
|
||||
const DEFAULT_METHOD_COLOR = '#635bff'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const orderId = String(route.query.order_id || '')
|
||||
const method = String(route.query.method || 'alipay')
|
||||
const amount = String(route.query.amount || '')
|
||||
|
||||
const methodColor = computed(() => METHOD_COLORS[method] || DEFAULT_METHOD_COLOR)
|
||||
|
||||
const error = ref('')
|
||||
const success = ref(false)
|
||||
const hint = ref(t('payment.stripePopup.redirecting'))
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function closeWindow() { window.close() }
|
||||
|
||||
onMounted(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return
|
||||
if (event.data?.type !== 'STRIPE_POPUP_INIT') return
|
||||
window.removeEventListener('message', handler)
|
||||
initStripe(event.data.clientSecret, event.data.publishableKey)
|
||||
}
|
||||
window.addEventListener('message', handler)
|
||||
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!error.value && !success.value) {
|
||||
error.value = t('payment.stripePopup.timeout')
|
||||
}
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
|
||||
async function initStripe(clientSecret: string, publishableKey: string) {
|
||||
if (!clientSecret || !publishableKey) {
|
||||
error.value = t('payment.stripeMissingParams')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { loadStripe } = await import('@stripe/stripe-js')
|
||||
const stripe = await loadStripe(publishableKey)
|
||||
if (!stripe) { error.value = t('payment.stripeLoadFailed'); return }
|
||||
|
||||
const returnUrl = window.location.origin + '/payment/result?order_id=' + orderId + '&status=success'
|
||||
|
||||
if (method === 'alipay') {
|
||||
// Alipay: redirect this popup to Alipay payment page
|
||||
const { error: err } = await stripe.confirmAlipayPayment(clientSecret, { return_url: returnUrl })
|
||||
if (err) error.value = err.message || t('payment.result.failed')
|
||||
} else if (method === 'wechat_pay') {
|
||||
// WeChat: Stripe shows its built-in QR dialog, user scans, promise resolves
|
||||
hint.value = t('payment.stripePopup.loadingQr')
|
||||
const result = await (stripe as any).confirmWechatPayPayment(clientSecret, {
|
||||
payment_method_options: { wechat_pay: { client: 'web' } },
|
||||
})
|
||||
if (result.error) {
|
||||
error.value = result.error.message || t('payment.result.failed')
|
||||
} else if (result.paymentIntent?.status === 'succeeded') {
|
||||
success.value = true
|
||||
setTimeout(closeWindow, 2000)
|
||||
} else {
|
||||
// Payment not completed (user closed QR dialog)
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : t('payment.stripeLoadFailed')
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const token = document.cookie.split('; ').find(c => c.startsWith('token='))?.split('=')[1]
|
||||
|| localStorage.getItem('token') || ''
|
||||
const res = await fetch('/api/v1/payment/orders/' + orderId, {
|
||||
headers: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const status = data?.data?.status
|
||||
if (status === 'COMPLETED' || status === 'PAID') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
success.value = true
|
||||
setTimeout(closeWindow, 2000)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
@@ -28,39 +28,50 @@
|
||||
<div
|
||||
v-for="subscription in subscriptions"
|
||||
:key="subscription.id"
|
||||
class="card overflow-hidden"
|
||||
class="overflow-hidden rounded-2xl border bg-white dark:bg-dark-800"
|
||||
:class="platformBorderClass(subscription.group?.platform || '')"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-100 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-100 dark:bg-purple-900/30"
|
||||
>
|
||||
<Icon name="creditCard" size="md" class="text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div :class="['h-1.5 w-1.5 shrink-0 rounded-full', platformAccentDotClass(subscription.group?.platform || '')]" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ subscription.group?.name || `Group #${subscription.group_id}` }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ subscription.group?.description || '' }}
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ subscription.group?.name || `Group #${subscription.group_id}` }}
|
||||
</h3>
|
||||
<span :class="['rounded-md border px-2 py-0.5 text-[11px] font-medium', platformBadgeClass(subscription.group?.platform || '')]">
|
||||
{{ platformLabel(subscription.group?.platform || '') }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="subscription.group?.description" class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ subscription.group.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
subscription.status === 'active'
|
||||
? 'badge-success'
|
||||
: subscription.status === 'expired'
|
||||
? 'badge-warning'
|
||||
: 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ t(`userSubscriptions.status.${subscription.status}`) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
subscription.status === 'active'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||
: subscription.status === 'expired'
|
||||
? 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
]"
|
||||
>
|
||||
{{ t(`userSubscriptions.status.${subscription.status}`) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="subscription.status === 'active'"
|
||||
:class="['rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-colors', platformButtonClass(subscription.group?.platform || '')]"
|
||||
@click="router.push({ path: '/purchase', query: { tab: 'subscription', group: String(subscription.group_id) } })"
|
||||
>
|
||||
{{ t('payment.renewNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Progress -->
|
||||
@@ -237,14 +248,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import subscriptionsAPI from '@/api/subscriptions'
|
||||
import type { UserSubscription } from '@/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatDateOnly } from '@/utils/format'
|
||||
import { platformBorderClass, platformBadgeClass, platformButtonClass, platformLabel } from '@/utils/platformColors'
|
||||
|
||||
function platformAccentDotClass(p: string): string {
|
||||
switch (p) {
|
||||
case 'anthropic': return 'bg-orange-500'
|
||||
case 'openai': return 'bg-emerald-500'
|
||||
case 'antigravity': return 'bg-purple-500'
|
||||
case 'gemini': return 'bg-blue-500'
|
||||
default: return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const subscriptions = ref<UserSubscription[]>([])
|
||||
|
||||
175
frontend/src/views/user/UserOrdersView.vue
Normal file
175
frontend/src/views/user/UserOrdersView.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-4">
|
||||
<!-- Filters -->
|
||||
<div class="card p-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Select v-model="currentFilter" :options="statusFilters" class="w-36" @change="fetchOrders" />
|
||||
<div class="flex flex-1 items-center justify-end gap-2">
|
||||
<button @click="fetchOrders" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<OrderTable :orders="orders" :loading="loading">
|
||||
<template #actions="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="row.status === 'PENDING'" @click="handleCancel(row.id)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20">
|
||||
<Icon name="x" size="sm" />
|
||||
<span>{{ t('payment.orders.cancel') }}</span>
|
||||
</button>
|
||||
<button v-if="row.status === 'COMPLETED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
|
||||
<Icon name="dollar" size="sm" />
|
||||
<span>{{ t('payment.orders.requestRefund') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</OrderTable>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Confirm Dialog -->
|
||||
<BaseDialog :show="!!cancelTargetId" :title="t('payment.orders.cancel')" width="narrow" @close="cancelTargetId = null">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">{{ t('payment.confirmCancel') }}</p>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn btn-secondary" @click="cancelTargetId = null">{{ t('common.cancel') }}</button>
|
||||
<button class="btn btn-danger" :disabled="actionLoading" @click="confirmCancel">{{ actionLoading ? t('common.processing') : t('payment.orders.cancel') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Refund Dialog -->
|
||||
<BaseDialog :show="!!refundTarget" :title="t('payment.orders.requestRefund')" @close="refundTarget = null">
|
||||
<div v-if="refundTarget" class="space-y-4">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-mono text-gray-900 dark:text-white">#{{ refundTarget.id }}</span>
|
||||
</div>
|
||||
<div class="mt-2 flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="text-gray-900 dark:text-white">${{ refundTarget.amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.refundReason') }}</label>
|
||||
<textarea v-model="refundReason" rows="3" class="input mt-1 w-full" :placeholder="t('payment.refundReasonPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn btn-secondary" @click="refundTarget = null">{{ t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" :disabled="actionLoading || !refundReason.trim()" @click="confirmRefund">{{ actionLoading ? t('common.processing') : t('payment.orders.requestRefund') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import OrderTable from '@/components/payment/OrderTable.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const orders = ref<PaymentOrder[]>([])
|
||||
const currentFilter = ref('')
|
||||
const cancelTargetId = ref<number | null>(null)
|
||||
const refundTarget = ref<PaymentOrder | null>(null)
|
||||
const refundReason = ref('')
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const statusFilters = computed(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'PENDING', label: t('payment.status.pending') },
|
||||
{ value: 'COMPLETED', label: t('payment.status.completed') },
|
||||
{ value: 'FAILED', label: t('payment.status.failed') },
|
||||
{ value: 'REFUNDED', label: t('payment.status.refunded') },
|
||||
])
|
||||
|
||||
async function fetchOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await paymentAPI.getMyOrders({
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
status: currentFilter.value || undefined,
|
||||
})
|
||||
orders.value = res.data.items || []
|
||||
pagination.total = res.data.total || 0
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) { pagination.page = page; fetchOrders() }
|
||||
function handlePageSizeChange(size: number) { pagination.page_size = size; pagination.page = 1; fetchOrders() }
|
||||
|
||||
function handleCancel(orderId: number) { cancelTargetId.value = orderId }
|
||||
|
||||
async function confirmCancel() {
|
||||
if (!cancelTargetId.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await paymentAPI.cancelOrder(cancelTargetId.value)
|
||||
appStore.showSuccess(t('common.success'))
|
||||
cancelTargetId.value = null
|
||||
await fetchOrders()
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openRefundDialog(order: PaymentOrder) { refundTarget.value = order; refundReason.value = '' }
|
||||
|
||||
async function confirmRefund() {
|
||||
if (!refundTarget.value || !refundReason.value.trim()) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await paymentAPI.requestRefund(refundTarget.value.id, { reason: refundReason.value.trim() })
|
||||
appStore.showSuccess(t('common.success'))
|
||||
refundTarget.value = null
|
||||
refundReason.value = ''
|
||||
await fetchOrders()
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => fetchOrders())
|
||||
</script>
|
||||
Reference in New Issue
Block a user