422 lines
15 KiB
Vue
422 lines
15 KiB
Vue
<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-if="isPending"
|
||
class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/30">
|
||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-yellow-500 border-t-transparent"></div>
|
||
</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">
|
||
{{ statusTitle }}
|
||
</h2>
|
||
<p v-if="isPending" class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||
{{ t('payment.result.processingHint') }}
|
||
</p>
|
||
</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.baseAmount') }}</span>
|
||
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(baseAmount) }}</span>
|
||
</div>
|
||
<div v-if="order.fee_rate > 0" class="flex justify-between">
|
||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.fee') }} ({{ order.fee_rate }}%)</span>
|
||
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(feeAmount) }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
|
||
<span class="font-bold text-primary-600 dark:text-primary-400">{{ formatGatewayAmount(order.pay_amount) }}</span>
|
||
</div>
|
||
<div v-if="order.amount !== order.pay_amount" class="flex justify-between">
|
||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.creditedAmount') }}</span>
|
||
<span class="font-medium text-gray-900 dark:text-white">{{ order.order_type === 'balance' ? '$' + order.amount.toFixed(2) : formatGatewayAmount(order.amount) }}</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(paymentMethodI18nKey(order.payment_type), normalizedOrderPaymentType(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.payAmount') }}</span>
|
||
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(Number(returnInfo.money) || 0) }}</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(paymentMethodI18nKey(returnInfo.type), normalizedOrderPaymentType(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, onBeforeUnmount, onMounted } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
||
import {
|
||
PAYMENT_RECOVERY_STORAGE_KEY,
|
||
clearPaymentRecoverySnapshot,
|
||
readPaymentRecoverySnapshot,
|
||
} from '@/components/payment/paymentFlow'
|
||
import { usePaymentStore } from '@/stores/payment'
|
||
import { paymentAPI } from '@/api/payment'
|
||
import type { PaymentOrder } from '@/types/payment'
|
||
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
|
||
import { normalizePaymentMethodForDisplay, paymentMethodI18nKey } from './paymentUx'
|
||
|
||
const i18n = useI18n()
|
||
const { t } = i18n
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const paymentStore = usePaymentStore()
|
||
|
||
const order = ref<PaymentOrder | null>(null)
|
||
const loading = ref(true)
|
||
const currency = ref('CNY')
|
||
|
||
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 PENDING_STATUSES = new Set(['PENDING', 'CREATED', 'WAITING', 'PROCESSING'])
|
||
const STATUS_REFRESH_INTERVAL_MS = 2000
|
||
const STATUS_REFRESH_MAX_ATTEMPTS = 15
|
||
|
||
let statusRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||
const refreshAttempts = ref(0)
|
||
|
||
/** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */
|
||
const baseAmount = computed(() => {
|
||
if (!order.value) return 0
|
||
const feeRate = Number(order.value.fee_rate) || 0
|
||
if (feeRate <= 0) return order.value.pay_amount ?? 0
|
||
return Math.round((order.value.pay_amount / (1 + feeRate / 100)) * 100) / 100
|
||
})
|
||
|
||
/** 手续费 = pay_amount - baseAmount */
|
||
const feeAmount = computed(() => {
|
||
if (!order.value) return 0
|
||
const feeRate = Number(order.value.fee_rate) || 0
|
||
if (feeRate <= 0) return 0
|
||
return Math.round((order.value.pay_amount - baseAmount.value) * 100) / 100
|
||
})
|
||
|
||
const localeCode = computed(() => {
|
||
const raw = i18n.locale as unknown
|
||
if (typeof raw === 'string') return raw
|
||
if (raw && typeof raw === 'object' && 'value' in raw) {
|
||
return String((raw as { value?: string }).value || '')
|
||
}
|
||
return undefined
|
||
})
|
||
|
||
const isSuccess = computed(() => {
|
||
return isSuccessStatus(order.value?.status)
|
||
})
|
||
|
||
const isPending = computed(() => {
|
||
return isPendingStatus(order.value?.status)
|
||
})
|
||
|
||
const statusTitle = computed(() => {
|
||
if (isSuccess.value) {
|
||
return t('payment.result.success')
|
||
}
|
||
if (isPending.value) {
|
||
return t('payment.result.processing')
|
||
}
|
||
return t('payment.result.failed')
|
||
})
|
||
|
||
function normalizedOrderPaymentType(paymentType: string): string {
|
||
return normalizePaymentMethodForDisplay(paymentType) || paymentType
|
||
}
|
||
|
||
function formatGatewayAmount(value: number): string {
|
||
return formatPaymentAmount(value, currency.value, localeCode.value)
|
||
}
|
||
|
||
function setResolvedOrder(nextOrder: PaymentOrder | null): void {
|
||
order.value = nextOrder
|
||
if (nextOrder?.currency) {
|
||
currency.value = normalizePaymentCurrency(nextOrder.currency)
|
||
}
|
||
}
|
||
|
||
function normalizeOrderStatus(status: string | null | undefined): string {
|
||
return String(status || '').trim().toUpperCase()
|
||
}
|
||
|
||
function isSuccessStatus(status: string | null | undefined): boolean {
|
||
return SUCCESS_STATUSES.has(normalizeOrderStatus(status))
|
||
}
|
||
|
||
function isPendingStatus(status: string | null | undefined): boolean {
|
||
return PENDING_STATUSES.has(normalizeOrderStatus(status))
|
||
}
|
||
|
||
function readRouteQueryString(key: string): string {
|
||
const value = route.query[key]
|
||
if (Array.isArray(value)) {
|
||
return typeof value[0] === 'string' ? value[0] : ''
|
||
}
|
||
return typeof value === 'string' ? value : ''
|
||
}
|
||
|
||
function restoreRecoverySnapshot(context: {
|
||
resumeToken: string
|
||
routeOrderId: number
|
||
routeOutTradeNo: string
|
||
}) {
|
||
if (typeof window === 'undefined') {
|
||
return null
|
||
}
|
||
|
||
const rawSnapshot = window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)
|
||
if (!rawSnapshot) {
|
||
return null
|
||
}
|
||
|
||
if (context.resumeToken) {
|
||
return readPaymentRecoverySnapshot(rawSnapshot, {
|
||
resumeToken: context.resumeToken,
|
||
})
|
||
}
|
||
|
||
if (!context.routeOrderId && !context.routeOutTradeNo) {
|
||
return null
|
||
}
|
||
|
||
const restored = readPaymentRecoverySnapshot(rawSnapshot)
|
||
if (!restored) {
|
||
return null
|
||
}
|
||
|
||
if (context.routeOrderId > 0 && restored.orderId !== context.routeOrderId) {
|
||
return null
|
||
}
|
||
|
||
if (context.routeOutTradeNo && restored.outTradeNo !== context.routeOutTradeNo) {
|
||
return null
|
||
}
|
||
|
||
return restored
|
||
}
|
||
|
||
async function resolveOrderFromResumeToken(resumeToken: string): Promise<PaymentOrder | null> {
|
||
try {
|
||
const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken)
|
||
return result.data
|
||
} catch (_err: unknown) {
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function resolveOrderFromOutTradeNo(outTradeNo: string): Promise<PaymentOrder | null> {
|
||
try {
|
||
const result = await paymentAPI.verifyOrderPublic(outTradeNo)
|
||
return result.data
|
||
} catch (_err: unknown) {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function clearStatusRefreshTimer(): void {
|
||
if (statusRefreshTimer !== null) {
|
||
clearTimeout(statusRefreshTimer)
|
||
statusRefreshTimer = null
|
||
}
|
||
}
|
||
|
||
function clearRecoverySnapshot(): void {
|
||
if (typeof window === 'undefined') return
|
||
clearPaymentRecoverySnapshot(window.localStorage, PAYMENT_RECOVERY_STORAGE_KEY)
|
||
}
|
||
|
||
function clearRecoverySnapshotForTerminalStatus(status: string | null | undefined): void {
|
||
if (!status) return
|
||
if (!isPendingStatus(status)) {
|
||
clearRecoverySnapshot()
|
||
}
|
||
}
|
||
|
||
function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>) | null): void {
|
||
clearStatusRefreshTimer()
|
||
if (!refreshOrder || !isPending.value || refreshAttempts.value >= STATUS_REFRESH_MAX_ATTEMPTS) {
|
||
return
|
||
}
|
||
|
||
statusRefreshTimer = setTimeout(async () => {
|
||
refreshAttempts.value += 1
|
||
const refreshedOrder = await refreshOrder()
|
||
if (refreshedOrder) {
|
||
setResolvedOrder(refreshedOrder)
|
||
clearRecoverySnapshotForTerminalStatus(refreshedOrder.status)
|
||
}
|
||
|
||
if (isPendingStatus(order.value?.status)) {
|
||
scheduleStatusRefresh(refreshOrder)
|
||
}
|
||
}, STATUS_REFRESH_INTERVAL_MS)
|
||
}
|
||
|
||
onMounted(async () => {
|
||
const resumeToken = readRouteQueryString('resume_token')
|
||
const routeOrderId = Number(readRouteQueryString('order_id')) || 0
|
||
let outTradeNo = readRouteQueryString('out_trade_no')
|
||
let orderId = 0
|
||
let resumeTokenLookupFailed = false
|
||
|
||
const restored = restoreRecoverySnapshot({
|
||
resumeToken,
|
||
routeOrderId,
|
||
routeOutTradeNo: outTradeNo,
|
||
})
|
||
if (restored?.orderId) {
|
||
orderId = restored.orderId
|
||
}
|
||
if (restored?.currency) {
|
||
currency.value = normalizePaymentCurrency(restored.currency)
|
||
}
|
||
if (!outTradeNo && restored?.outTradeNo) {
|
||
outTradeNo = restored.outTradeNo
|
||
}
|
||
|
||
if (resumeToken) {
|
||
const resolvedOrder = await resolveOrderFromResumeToken(resumeToken)
|
||
if (resolvedOrder) {
|
||
setResolvedOrder(resolvedOrder)
|
||
if (!orderId) {
|
||
orderId = resolvedOrder.id
|
||
}
|
||
} else if (routeOrderId > 0) {
|
||
resumeTokenLookupFailed = true
|
||
orderId = routeOrderId
|
||
} else {
|
||
resumeTokenLookupFailed = true
|
||
}
|
||
} else if (routeOrderId > 0) {
|
||
orderId = routeOrderId
|
||
}
|
||
|
||
const hasLegacyFallbackContext = readRouteQueryString('trade_status').trim() !== ''
|
||
const shouldUsePublicOutTradeNo = outTradeNo !== '' && (hasLegacyFallbackContext || routeOrderId > 0 || orderId > 0)
|
||
|
||
if (!order.value && orderId && (!resumeToken || routeOrderId > 0)) {
|
||
try {
|
||
setResolvedOrder(await paymentStore.pollOrderStatus(orderId))
|
||
} catch (_err: unknown) {
|
||
// Order lookup failed, will try legacy fallback below when possible.
|
||
}
|
||
}
|
||
|
||
if (!order.value && shouldUsePublicOutTradeNo && (!resumeToken || resumeTokenLookupFailed)) {
|
||
const legacyOrder = await resolveOrderFromOutTradeNo(outTradeNo)
|
||
if (legacyOrder) {
|
||
setResolvedOrder(legacyOrder)
|
||
if (!orderId) {
|
||
orderId = legacyOrder.id
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!order.value && !orderId && outTradeNo && hasLegacyFallbackContext) {
|
||
returnInfo.value = {
|
||
outTradeNo,
|
||
money: String(route.query.money || ''),
|
||
type: String(route.query.type || ''),
|
||
tradeStatus: String(route.query.trade_status || ''),
|
||
}
|
||
}
|
||
|
||
const refreshOrder = async (): Promise<PaymentOrder | null> => {
|
||
if (resumeToken) {
|
||
const resolvedOrder = await resolveOrderFromResumeToken(resumeToken)
|
||
if (resolvedOrder) {
|
||
return resolvedOrder
|
||
}
|
||
}
|
||
|
||
if (orderId) {
|
||
try {
|
||
return await paymentStore.pollOrderStatus(orderId)
|
||
} catch (_err: unknown) {
|
||
// Fall through to legacy public verification when order polling is unavailable.
|
||
}
|
||
}
|
||
|
||
if (shouldUsePublicOutTradeNo) {
|
||
return await resolveOrderFromOutTradeNo(outTradeNo)
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
if (isPendingStatus(order.value?.status)) {
|
||
scheduleStatusRefresh(refreshOrder)
|
||
} else if (order.value) {
|
||
clearRecoverySnapshotForTerminalStatus(order.value.status)
|
||
} else if (returnInfo.value) {
|
||
clearRecoverySnapshot()
|
||
}
|
||
loading.value = false
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
clearStatusRefreshTimer()
|
||
})
|
||
</script>
|