sub2api/frontend/src/views/user/PaymentResultView.vue
2026-05-11 11:24:46 +08:00

422 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>