267 lines
11 KiB
Vue
267 lines
11 KiB
Vue
<template>
|
|
<div class="space-y-4">
|
|
<!-- ═══ Terminal States: show result, user clicks to return ═══ -->
|
|
|
|
<!-- Success -->
|
|
<template v-if="outcome === 'success'">
|
|
<div class="card p-6">
|
|
<div class="flex flex-col items-center space-y-4 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">{{ props.orderType === 'subscription' ? t('payment.result.subscriptionSuccess') : t('payment.result.success') }}</p>
|
|
<div v-if="paidOrder" class="w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
|
|
<div class="space-y-2 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">#{{ paidOrder.id }}</span>
|
|
</div>
|
|
<div v-if="paidOrder.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">{{ paidOrder.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">${{ paidOrder.pay_amount.toFixed(2) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-primary" @click="handleDone">{{ t('common.confirm') }}</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Cancelled -->
|
|
<template v-else-if="outcome === 'cancelled'">
|
|
<div class="card p-6">
|
|
<div class="flex flex-col items-center space-y-4 py-4">
|
|
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700">
|
|
<svg class="h-8 w-8 text-gray-400 dark:text-gray-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>
|
|
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.qr.cancelled') }}</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.cancelledDesc') }}</p>
|
|
<button class="btn btn-primary" @click="handleDone">{{ t('common.confirm') }}</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Expired / Failed -->
|
|
<template v-else-if="outcome === 'expired'">
|
|
<div class="card p-6">
|
|
<div class="flex flex-col items-center space-y-4 py-4">
|
|
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/30">
|
|
<svg class="h-8 w-8 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.qr.expired') }}</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiredDesc') }}</p>
|
|
<button class="btn btn-primary" @click="handleDone">{{ t('common.confirm') }}</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ═══ Active States: QR or Popup waiting ═══ -->
|
|
|
|
<!-- QR Code Mode -->
|
|
<template v-else-if="qrUrl">
|
|
<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">{{ scanTitle }}</p>
|
|
<div :class="['relative rounded-lg border-2 p-4', qrBorderClass]">
|
|
<canvas ref="qrCanvas" class="mx-auto"></canvas>
|
|
<!-- Brand logo overlay -->
|
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
<span :class="['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]">
|
|
<img :src="isAlipay ? alipayIcon : wxpayIcon" alt="" class="h-5 w-5 brightness-0 invert" />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ scanHint }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="card p-4 text-center">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiresIn') }}</p>
|
|
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
|
|
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
|
|
</div>
|
|
<button class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
|
|
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
|
</button>
|
|
</template>
|
|
|
|
<!-- Waiting for Popup/Redirect Mode -->
|
|
<template v-else>
|
|
<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-primary-500 border-t-transparent"></div>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
|
|
<button v-if="payUrl" class="btn btn-secondary text-sm" @click="reopenPopup">
|
|
{{ t('payment.qr.openPayWindow') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card p-4 text-center">
|
|
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
|
|
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
|
|
</div>
|
|
<button class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
|
|
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { usePaymentStore } from '@/stores/payment'
|
|
import { useAppStore } from '@/stores'
|
|
import { paymentAPI } from '@/api/payment'
|
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
|
import { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
|
import type { PaymentOrder } from '@/types/payment'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
import QRCode from 'qrcode'
|
|
import alipayIcon from '@/assets/icons/alipay.svg'
|
|
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
|
|
|
const props = defineProps<{
|
|
orderId: number
|
|
qrCode: string
|
|
expiresAt: string
|
|
paymentType: string
|
|
payUrl?: string
|
|
orderType?: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{ done: []; success: [] }>()
|
|
|
|
const { t } = useI18n()
|
|
const paymentStore = usePaymentStore()
|
|
const appStore = useAppStore()
|
|
|
|
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
|
const qrUrl = ref('')
|
|
const remainingSeconds = ref(0)
|
|
const cancelling = ref(false)
|
|
const paidOrder = ref<PaymentOrder | null>(null)
|
|
|
|
// Terminal outcome: null = still active, 'success' | 'cancelled' | 'expired'
|
|
const outcome = ref<'success' | 'cancelled' | 'expired' | null>(null)
|
|
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
const isAlipay = computed(() => props.paymentType.includes('alipay'))
|
|
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
|
|
|
|
const qrBorderClass = computed(() => {
|
|
if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20'
|
|
if (isWxpay.value) return 'border-[#2BB741] bg-green-50 dark:border-[#2BB741]/70 dark:bg-green-950/20'
|
|
return 'border-gray-200 bg-white dark:border-dark-600 dark:bg-dark-800'
|
|
})
|
|
|
|
const qrLogoBgClass = computed(() => {
|
|
if (isAlipay.value) return 'bg-[#00AEEF]'
|
|
if (isWxpay.value) return 'bg-[#2BB741]'
|
|
return 'bg-gray-400'
|
|
})
|
|
|
|
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 ''
|
|
})
|
|
|
|
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')
|
|
})
|
|
|
|
function reopenPopup() {
|
|
if (props.payUrl) {
|
|
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
|
}
|
|
}
|
|
|
|
async function renderQR() {
|
|
await nextTick()
|
|
if (!qrCanvas.value || !qrUrl.value) return
|
|
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
|
|
width: 220, margin: 2,
|
|
errorCorrectionLevel: 'M',
|
|
})
|
|
}
|
|
|
|
async function pollStatus() {
|
|
if (!props.orderId || outcome.value) return
|
|
const order = await paymentStore.pollOrderStatus(props.orderId)
|
|
if (!order) return
|
|
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
|
cleanup()
|
|
paidOrder.value = order
|
|
outcome.value = 'success'
|
|
emit('success')
|
|
} else if (order.status === 'CANCELLED') {
|
|
cleanup()
|
|
outcome.value = 'cancelled'
|
|
} else if (order.status === 'EXPIRED' || order.status === 'FAILED') {
|
|
cleanup()
|
|
outcome.value = 'expired'
|
|
}
|
|
}
|
|
|
|
function startCountdown(seconds: number) {
|
|
remainingSeconds.value = Math.max(0, seconds)
|
|
if (remainingSeconds.value <= 0) { outcome.value = 'expired'; return }
|
|
countdownTimer = setInterval(() => {
|
|
remainingSeconds.value--
|
|
if (remainingSeconds.value <= 0) { outcome.value = 'expired'; cleanup() }
|
|
}, 1000)
|
|
}
|
|
|
|
async function handleCancel() {
|
|
if (!props.orderId || cancelling.value) return
|
|
cancelling.value = true
|
|
try {
|
|
await paymentAPI.cancelOrder(props.orderId)
|
|
cleanup()
|
|
outcome.value = 'cancelled'
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
|
} finally {
|
|
cancelling.value = false
|
|
}
|
|
}
|
|
|
|
function handleDone() { cleanup(); emit('done') }
|
|
|
|
function cleanup() {
|
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
|
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
|
|
}
|
|
|
|
// Initialize on mount
|
|
qrUrl.value = props.qrCode
|
|
let seconds = 30 * 60
|
|
if (props.expiresAt) {
|
|
seconds = Math.floor((new Date(props.expiresAt).getTime() - Date.now()) / 1000)
|
|
}
|
|
startCountdown(seconds)
|
|
pollTimer = setInterval(pollStatus, 3000)
|
|
renderQR()
|
|
|
|
watch(() => qrUrl.value, () => renderQR())
|
|
onUnmounted(() => cleanup())
|
|
</script>
|